diff --git a/updater/Cargo.toml b/updater/Cargo.toml index 0ff3acabc..3aaafcbfa 100644 --- a/updater/Cargo.toml +++ b/updater/Cargo.toml @@ -16,11 +16,11 @@ serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.150" semver = "1.0.28" sha2 = "0.11.0" +tempfile = "3.27.0" tokio = { version = "1.52.3", features = ["fs", "io-util", "macros", "process", "rt-multi-thread", "signal", "time"] } toml = "1.1.2" tracing = "0.1.44" tracing-subscriber = { version = "0.3.23", features = ["fmt"] } [dev-dependencies] -tempfile = "3.27.0" wiremock = "0.6.5" diff --git a/updater/src/upstream.rs b/updater/src/upstream.rs index 2a6687c31..c1567e5e2 100644 --- a/updater/src/upstream.rs +++ b/updater/src/upstream.rs @@ -80,11 +80,6 @@ pub async fn download_dmg( .await .with_context(|| format!("Failed to create {}", destination_dir.display()))?; - let destination = destination_dir.join("Codex.dmg"); - let mut file = File::create(&destination) - .await - .with_context(|| format!("Failed to create {}", destination.display()))?; - let response = client .get(dmg_url) .send() @@ -93,6 +88,16 @@ pub async fn download_dmg( .error_for_status() .with_context(|| format!("GET request for {dmg_url} returned an error status"))?; + let destination = destination_dir.join("Codex.dmg"); + let temporary = tempfile::NamedTempFile::new_in(destination_dir).with_context(|| { + format!( + "Failed to create download staging file in {}", + destination_dir.display() + ) + })?; + let (temporary_file, temporary_path) = temporary.into_parts(); + let mut file = File::from_std(temporary_file); + let mut hasher = Sha256::new(); let mut stream = response.bytes_stream(); @@ -100,13 +105,16 @@ pub async fn download_dmg( let chunk = chunk.with_context(|| format!("Failed downloading {dmg_url}"))?; file.write_all(&chunk) .await - .with_context(|| format!("Failed writing {}", destination.display()))?; + .with_context(|| format!("Failed writing {}", temporary_path.display()))?; hasher.update(&chunk); } file.flush() .await - .with_context(|| format!("Failed flushing {}", destination.display()))?; + .with_context(|| format!("Failed flushing {}", temporary_path.display()))?; + file.sync_all() + .await + .with_context(|| format!("Failed syncing {}", temporary_path.display()))?; let sha256 = hasher .finalize() @@ -115,6 +123,14 @@ pub async fn download_dmg( .collect::(); let candidate_version = derive_candidate_version(&sha256, version_timestamp)?; + drop(file); + temporary_path.persist(&destination).with_context(|| { + format!( + "Failed to atomically replace {} with completed download", + destination.display() + ) + })?; + Ok(DownloadedDmg { path: destination, sha256, @@ -184,6 +200,7 @@ mod tests { let client = Client::builder().build()?; let temp = tempdir()?; + tokio::fs::write(temp.path().join("Codex.dmg"), b"old-dmg").await?; let downloaded = download_dmg( &client, &format!("{}/Codex.dmg", server.uri()), @@ -198,6 +215,72 @@ mod tests { "678cd508ffe0071e217020a7a4eecbebe25362c022ac78c13a5ae87b7a3a0c92" ); assert_eq!(downloaded.candidate_version, "2026.03.24.120000+678cd508"); + assert_eq!(tokio::fs::read(downloaded.path).await?, body); + Ok(()) + } + + #[tokio::test] + async fn failed_download_preserves_cached_dmg() -> Result<()> { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/Codex.dmg")) + .respond_with(ResponseTemplate::new(503)) + .mount(&server) + .await; + + let client = Client::builder().build()?; + let temp = tempdir()?; + let destination = temp.path().join("Codex.dmg"); + tokio::fs::write(&destination, b"known-good-dmg").await?; + + let error = download_dmg( + &client, + &format!("{}/Codex.dmg", server.uri()), + temp.path(), + Utc.with_ymd_and_hms(2026, 3, 24, 12, 0, 0).unwrap(), + ) + .await + .expect_err("HTTP error should fail the download"); + + assert!(error.to_string().contains("returned an error status")); + assert_eq!(tokio::fs::read(&destination).await?, b"known-good-dmg"); + assert_eq!(std::fs::read_dir(temp.path())?.count(), 1); + Ok(()) + } + + #[tokio::test] + async fn interrupted_download_preserves_cached_dmg() -> Result<()> { + use std::io::{Read, Write}; + + let listener = std::net::TcpListener::bind("127.0.0.1:0")?; + let address = listener.local_addr()?; + let server = std::thread::spawn(move || -> std::io::Result<()> { + let (mut stream, _) = listener.accept()?; + let mut request = [0; 1024]; + let _ = stream.read(&mut request)?; + stream.write_all( + b"HTTP/1.1 200 OK\r\nContent-Length: 100\r\nConnection: close\r\n\r\npartial", + )?; + Ok(()) + }); + + let client = Client::builder().build()?; + let temp = tempdir()?; + let destination = temp.path().join("Codex.dmg"); + tokio::fs::write(&destination, b"known-good-dmg").await?; + + download_dmg( + &client, + &format!("http://{address}/Codex.dmg"), + temp.path(), + Utc.with_ymd_and_hms(2026, 3, 24, 12, 0, 0).unwrap(), + ) + .await + .expect_err("truncated response should fail the download"); + server.join().expect("test server should not panic")?; + + assert_eq!(tokio::fs::read(&destination).await?, b"known-good-dmg"); + assert_eq!(std::fs::read_dir(temp.path())?.count(), 1); Ok(()) }