iuna

iuna

iuna - experimental mainnet-candidate protocol
git clone https://getiuna.org/git/iuna.git
Log | Files | Refs | README | LICENSE

updater.rs (10140B)


      1 use std::{
      2     collections::BTreeMap,
      3     fs::{self, OpenOptions},
      4     io::{Read, Write},
      5     path::Path,
      6 };
      7 
      8 use anyhow::{Context, Result, bail};
      9 use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
     10 use flate2::read::GzDecoder;
     11 use minisign_verify::{PublicKey, Signature};
     12 use semver::Version;
     13 use serde::Deserialize;
     14 use sha2::{Digest, Sha256};
     15 
     16 const RELEASE_METADATA_URL: &str = "https://getiuna.org/downloads/latest.json";
     17 const UPDATE_PUBLIC_KEY: &str = include_str!("../config/update-signing.key.pub");
     18 const MAX_UPDATE_BYTES: u64 = 256 * 1024 * 1024;
     19 
     20 #[derive(Debug, Deserialize)]
     21 struct ReleaseMetadata {
     22     version: String,
     23     #[serde(default)]
     24     artifacts: BTreeMap<String, ReleaseArtifact>,
     25 }
     26 
     27 #[derive(Debug, Deserialize)]
     28 struct ReleaseArtifact {
     29     url: String,
     30     sha256: String,
     31     signature: String,
     32 }
     33 
     34 pub(crate) async fn handle_cli_command() -> Result<bool> {
     35     let mut args = std::env::args().skip(1);
     36     let Some(command) = args.next() else {
     37         return Ok(false);
     38     };
     39 
     40     if matches!(command.as_str(), "--version" | "-V") {
     41         if args.next().is_some() {
     42             bail!("--version does not accept additional arguments");
     43         }
     44         println!("iuna {}", env!("CARGO_PKG_VERSION"));
     45         return Ok(true);
     46     }
     47 
     48     if command != "update" {
     49         return Ok(false);
     50     }
     51 
     52     let check_only = match args.next().as_deref() {
     53         None => false,
     54         Some("--check") => true,
     55         Some(other) => bail!("unknown update option {other}; use `iuna update [--check]`"),
     56     };
     57     if let Some(other) = args.next() {
     58         bail!("unexpected update argument {other}; use `iuna update [--check]`");
     59     }
     60 
     61     let release = fetch_release_metadata().await?;
     62     let current = Version::parse(env!("CARGO_PKG_VERSION")).context("invalid built-in version")?;
     63     let available = Version::parse(release.version.trim_start_matches('v'))
     64         .context("release metadata contains an invalid version")?;
     65 
     66     if available <= current {
     67         println!("iuna v{current} is up to date");
     68         return Ok(true);
     69     }
     70 
     71     println!("iuna v{available} is available (currently v{current})");
     72     if check_only {
     73         return Ok(true);
     74     }
     75 
     76     install_update(&release, &available).await?;
     77     println!("updated iuna to v{available}; restart any running iuna service");
     78     Ok(true)
     79 }
     80 
     81 async fn fetch_release_metadata() -> Result<ReleaseMetadata> {
     82     let response = reqwest::Client::builder()
     83         .timeout(std::time::Duration::from_secs(30))
     84         .build()?
     85         .get(RELEASE_METADATA_URL)
     86         .header(reqwest::header::ACCEPT, "application/json")
     87         .send()
     88         .await
     89         .context("could not fetch iuna release metadata")?
     90         .error_for_status()
     91         .context("iuna release metadata request failed")?;
     92 
     93     response
     94         .json()
     95         .await
     96         .context("could not decode iuna release metadata")
     97 }
     98 
     99 async fn install_update(release: &ReleaseMetadata, version: &Version) -> Result<()> {
    100     let target = update_target()?;
    101     let artifact = release
    102         .artifacts
    103         .get(target)
    104         .with_context(|| format!("release v{version} has no artifact for {target}"))?;
    105     let archive = download_artifact(artifact).await?;
    106     verify_artifact(&archive, artifact)?;
    107 
    108     let current_exe =
    109         std::env::current_exe().context("could not locate the running iuna binary")?;
    110     replace_executable(&current_exe, &archive, version)
    111 }
    112 
    113 async fn download_artifact(artifact: &ReleaseArtifact) -> Result<Vec<u8>> {
    114     let response = reqwest::Client::builder()
    115         .timeout(std::time::Duration::from_secs(300))
    116         .build()?
    117         .get(&artifact.url)
    118         .send()
    119         .await
    120         .context("could not download the iuna update")?
    121         .error_for_status()
    122         .context("iuna update download failed")?;
    123 
    124     if response
    125         .content_length()
    126         .is_some_and(|size| size > MAX_UPDATE_BYTES)
    127     {
    128         bail!("iuna update is larger than the allowed 256 MiB");
    129     }
    130     let bytes = response
    131         .bytes()
    132         .await
    133         .context("could not read the iuna update")?;
    134     if bytes.len() as u64 > MAX_UPDATE_BYTES {
    135         bail!("iuna update is larger than the allowed 256 MiB");
    136     }
    137     Ok(bytes.to_vec())
    138 }
    139 
    140 fn verify_artifact(bytes: &[u8], artifact: &ReleaseArtifact) -> Result<()> {
    141     let actual_hash = format!("{:x}", Sha256::digest(bytes));
    142     if !actual_hash.eq_ignore_ascii_case(&artifact.sha256) {
    143         bail!("iuna update checksum verification failed");
    144     }
    145 
    146     let public_key_text = decode_tauri_signature(UPDATE_PUBLIC_KEY)
    147         .context("the embedded iuna update public key is invalid")?;
    148     let public_key = PublicKey::decode(&public_key_text)
    149         .context("the embedded iuna update public key is invalid")?;
    150     let signature_text = decode_tauri_signature(&artifact.signature)
    151         .context("the iuna update signature is invalid")?;
    152     let signature =
    153         Signature::decode(&signature_text).context("the iuna update signature is invalid")?;
    154     public_key
    155         .verify(bytes, &signature, true)
    156         .context("iuna update signature verification failed")
    157 }
    158 
    159 fn decode_tauri_signature(encoded: &str) -> Result<String> {
    160     let decoded = BASE64
    161         .decode(encoded.trim())
    162         .context("invalid base64 in Tauri signature")?;
    163     String::from_utf8(decoded).context("Tauri signature is not UTF-8")
    164 }
    165 
    166 fn replace_executable(current_exe: &Path, archive: &[u8], version: &Version) -> Result<()> {
    167     let parent = current_exe
    168         .parent()
    169         .context("the running iuna binary has no parent directory")?;
    170     let staged = parent.join(format!(".iuna-update-{version}-{}", std::process::id()));
    171     let result = stage_binary(&staged, archive).and_then(|_| {
    172         fs::rename(&staged, current_exe).with_context(|| {
    173             format!(
    174                 "cannot replace {}; install iuna in a writable directory or run the update with sufficient permissions",
    175                 current_exe.display()
    176             )
    177         })
    178     });
    179     if result.is_err() {
    180         let _ = fs::remove_file(&staged);
    181     }
    182     result
    183 }
    184 
    185 fn stage_binary(destination: &Path, archive: &[u8]) -> Result<()> {
    186     let decoder = GzDecoder::new(archive);
    187     let mut tar = tar::Archive::new(decoder);
    188     let mut found = false;
    189 
    190     for entry in tar
    191         .entries()
    192         .context("could not read the iuna update archive")?
    193     {
    194         let mut entry = entry.context("could not read an iuna update archive entry")?;
    195         let path = entry
    196             .path()
    197             .context("invalid path in iuna update archive")?;
    198         if path.file_name().and_then(|name| name.to_str()) != Some("iuna")
    199             || !entry.header().entry_type().is_file()
    200         {
    201             continue;
    202         }
    203         if found {
    204             bail!("iuna update archive contains multiple binaries");
    205         }
    206 
    207         let mut output = OpenOptions::new()
    208             .create_new(true)
    209             .write(true)
    210             .open(destination)
    211             .with_context(|| format!("cannot stage update at {}", destination.display()))?;
    212         let mut buffer = [0_u8; 64 * 1024];
    213         loop {
    214             let count = entry.read(&mut buffer)?;
    215             if count == 0 {
    216                 break;
    217             }
    218             output.write_all(&buffer[..count])?;
    219         }
    220         output.sync_all()?;
    221         found = true;
    222     }
    223 
    224     if !found {
    225         bail!("iuna update archive does not contain an iuna binary");
    226     }
    227 
    228     #[cfg(unix)]
    229     {
    230         use std::os::unix::fs::PermissionsExt;
    231         fs::set_permissions(destination, fs::Permissions::from_mode(0o755))?;
    232     }
    233     Ok(())
    234 }
    235 
    236 fn update_target() -> Result<&'static str> {
    237     #[cfg(all(target_os = "linux", target_arch = "x86_64"))]
    238     return Ok("linux-x86_64");
    239     #[cfg(all(target_os = "linux", target_arch = "aarch64"))]
    240     return Ok("linux-aarch64");
    241     #[cfg(not(any(
    242         all(target_os = "linux", target_arch = "x86_64"),
    243         all(target_os = "linux", target_arch = "aarch64")
    244     )))]
    245     bail!("automatic CLI updates are not available for this platform")
    246 }
    247 
    248 #[cfg(test)]
    249 mod tests {
    250     use super::*;
    251 
    252     #[test]
    253     fn release_metadata_remains_compatible_with_original_shape() {
    254         let metadata: ReleaseMetadata = serde_json::from_str(
    255             r#"{"tag":"v0.4.29","version":"0.4.29","url":"https://getiuna.org/downloads/"}"#,
    256         )
    257         .unwrap();
    258         assert_eq!(metadata.version, "0.4.29");
    259         assert!(metadata.artifacts.is_empty());
    260     }
    261 
    262     #[test]
    263     fn archive_without_binary_is_rejected() {
    264         let mut encoded = Vec::new();
    265         {
    266             let encoder =
    267                 flate2::write::GzEncoder::new(&mut encoded, flate2::Compression::default());
    268             let mut archive = tar::Builder::new(encoder);
    269             let mut header = tar::Header::new_gnu();
    270             header.set_size(4);
    271             header.set_cksum();
    272             archive
    273                 .append_data(&mut header, "README.md", &b"test"[..])
    274                 .unwrap();
    275             archive.into_inner().unwrap().finish().unwrap();
    276         }
    277         let temp = tempfile::tempdir().unwrap();
    278         let error = stage_binary(&temp.path().join("iuna"), &encoded).unwrap_err();
    279         assert!(error.to_string().contains("does not contain"));
    280     }
    281 
    282     #[test]
    283     fn embedded_public_key_verifies_tauri_signature_format() {
    284         const SIGNATURE: &str = "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVRVDljclZTTXNMbzZUQ3cwMUNibXF4bHhBZ0Vka0pCREhXS1VFWmNsbVc4ejVsUzZaN2ZNQ1VoVkwyV05nMGF2dzMrNjNMUlB5Wm1WS2JnbG4xVXhjV2s0N3dsMWx5aGdNPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg5MDQwNjQ0CWZpbGU6dG1wLklaQThVTDRXOEEKRldRWG9UaXplbFAwVk5CUllNSHJDSSsyM2dLaXBPTXA0UWNOc0xtLyt2d2lLdTgvSmM3dWRZZmpqTkl2Q3RCMTlEWEx5dVNUK0gxeHlJQXY2VWhNQWc9PQo=";
    285         let artifact = ReleaseArtifact {
    286             url: "https://getiuna.org/test".to_string(),
    287             sha256: format!("{:x}", Sha256::digest(b"iuna updater test vector")),
    288             signature: SIGNATURE.to_string(),
    289         };
    290         verify_artifact(b"iuna updater test vector", &artifact).unwrap();
    291     }
    292 }