diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 736ca6c..ba8d770 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -129,6 +129,24 @@ jobs: # A binary killed by a signal must not look like a success (#87). run: node --test npm/test-launcher.js + # `bugsee-cli update` replaces the running executable with the `self-replace` crate, whose Windows + # implementation (move the old .exe aside, drop the new one in, delete the leftovers from a helper + # process) is entirely different from the Unix rename. A dependency bump that touches it (the crate + # itself, or the `windows-sys` it is built on) must fail HERE, by name, not somewhere in the + # 500-test suite. Both Windows architectures, because the release ships both. + self-update-windows: + name: self-update (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [windows-latest, windows-11-arm] + steps: + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - run: cargo test --test update_e2e -- --nocapture + test: name: test (${{ matrix.os }}) runs-on: ${{ matrix.os }} diff --git a/Cargo.lock b/Cargo.lock index c248dc4..d45ef46 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -59,7 +59,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -70,7 +70,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -507,7 +507,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1302,7 +1302,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1751,7 +1751,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1844,13 +1844,13 @@ dependencies = [ [[package]] name = "self-replace" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03ec815b5eab420ab893f63393878d89c90fdd94c0bcc44c07abb8ad95552fb7" +checksum = "609c5606d9d357b26e4ee1a7a01beac396628d0fa01ebbade2a09cefb88e65af" dependencies = [ "fastrand", "tempfile", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1998,7 +1998,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2184,7 +2184,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2763,7 +2763,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] diff --git a/tests/update_e2e.rs b/tests/update_e2e.rs index ec315f6..b1d14f7 100644 --- a/tests/update_e2e.rs +++ b/tests/update_e2e.rs @@ -419,3 +419,117 @@ async fn update_readonly_dir_fails_safe_and_keeps_binary() { "a failed self-replace must leave the existing binary untouched" ); } + +/// Mount the three routes of a release: the per-major pointer, the archive and its SHA-256. +#[cfg(windows)] +async fn serve_release(newer: &str, triple: &str, artefact: &[u8]) -> MockServer { + let server = MockServer::start().await; + mount_pointer(&server, newer).await; + Mock::given(method("GET")) + .and(wm_path(format!( + "/v{newer}/bugsee-cli-{triple}.{}", + artifact_ext() + ))) + .respond_with(ResponseTemplate::new(200).set_body_bytes(artefact.to_vec())) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(wm_path(format!( + "/v{newer}/bugsee-cli-{triple}.{}.sha256", + artifact_ext() + ))) + .respond_with( + ResponseTemplate::new(200).set_body_string(format!("{} x", sha256_hex(artefact))), + ) + .mount(&server) + .await; + server +} + +/// What is next to the binary, by file name. +#[cfg(windows)] +fn names_in(dir: &Path) -> Vec { + let mut names: Vec = std::fs::read_dir(dir) + .unwrap() + .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) + .collect(); + names.sort(); + names +} + +/// The Windows half of `self_replace`, which is not a rename: a running `.exe` cannot be +/// overwritten, so the crate moves the old binary aside, puts the new one in place and starts a +/// copy of itself that deletes the leftovers once this process has exited. +/// +/// The test above puts a sentinel in place, which proves the bytes land but not that what lands +/// is a program. This one swaps in a REAL binary (this build plus a few trailing bytes, which a PE +/// loader ignores) and then checks what a user depends on after an update: +/// - the replaced `.exe` launches, and reports the version; +/// - it can update again, over a file that was itself put there by a self-replace; +/// - nothing but the binary is left in its directory once the helper has finished. +#[cfg(windows)] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn update_on_windows_leaves_a_runnable_binary_that_updates_again_and_no_litter() { + let triple = host_triple(); + let tmp = tempfile::tempdir().unwrap(); + let bindir = tmp.path().join("bin"); + std::fs::create_dir(&bindir).unwrap(); + let exe = copy_binary(&bindir); + let exe_name = exe.file_name().unwrap().to_string_lossy().into_owned(); + let real = std::fs::read(&exe).unwrap(); + let newer = format!("{}.99.0", current_major()); + + for round in 1..=2 { + let mut payload = real.clone(); + payload.extend_from_slice(format!("\0BUGSEE-TEST-OVERLAY-{round}\0").as_bytes()); + let artefacts = tmp.path().join(format!("release-{round}")); + std::fs::create_dir(&artefacts).unwrap(); + let archive = build_release_archive(&artefacts, triple, &payload); + let server = serve_release(&newer, triple, &archive).await; + + let (base, exe_for_run) = (server.uri(), exe.clone()); + tokio::task::spawn_blocking(move || { + assert_cmd::Command::new(&exe_for_run) + .env("BUGSEE_CLI_UPDATE_BASE_URL", &base) + .arg("update") + .assert() + .success(); + }) + .await + .unwrap(); + + assert_eq!( + std::fs::read(&exe).unwrap(), + payload, + "round {round}: the binary should be the downloaded release" + ); + + // It is a program, not just bytes: it starts and knows its version. + let exe_for_run = exe.clone(); + tokio::task::spawn_blocking(move || { + assert_cmd::Command::new(&exe_for_run) + .arg("--version") + .assert() + .success() + .stdout(predicates::str::contains(env!("CARGO_PKG_VERSION"))); + }) + .await + .unwrap(); + + // The helper that removes the old binary runs after the updater exits: give it time, then + // require that only the binary is left. + let mut left = names_in(&bindir); + for _ in 0..80 { + if left == [exe_name.clone()] { + break; + } + std::thread::sleep(std::time::Duration::from_millis(250)); + left = names_in(&bindir); + } + assert_eq!( + left, + [exe_name.clone()], + "round {round}: self-replace left files next to the binary" + ); + } +}