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(¤t_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 }