Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion updater/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
97 changes: 90 additions & 7 deletions updater/src/upstream.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -93,20 +88,33 @@ 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();

while let Some(chunk) = stream.next().await {
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()
Expand All @@ -115,6 +123,14 @@ pub async fn download_dmg(
.collect::<String>();
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,
Expand Down Expand Up @@ -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()),
Expand All @@ -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(())
}

Expand Down
Loading