diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 736ca6c..4f6e8df 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -129,6 +129,26 @@ 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 + # One test at a time: each self-replace starts a helper process that makes a single attempt + # to delete the leftovers, and the test watches for exactly that. + - run: cargo test --test update_e2e -- --nocapture --test-threads=1 + test: name: test (${{ matrix.os }}) runs-on: ${{ matrix.os }} diff --git a/tests/update_e2e.rs b/tests/update_e2e.rs index ec315f6..a7ba749 100644 --- a/tests/update_e2e.rs +++ b/tests/update_e2e.rs @@ -419,3 +419,130 @@ 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 in a directory, 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 is left behind once the helper has finished. `self-replace` moves the old binary and +/// starts its `.__selfdelete__.exe` helper from `env::temp_dir()` (it only falls back to the +/// binary's directory when that is on another volume), so the updater is given a temp directory +/// the test owns and BOTH that and the binary's directory must end up clean. +#[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 systemp = tmp.path().join(format!("systemp-{round}")); + std::fs::create_dir(&systemp).unwrap(); + + let (base, exe_for_run, temp_for_run) = (server.uri(), exe.clone(), systemp.clone()); + tokio::task::spawn_blocking(move || { + assert_cmd::Command::new(&exe_for_run) + .env("BUGSEE_CLI_UPDATE_BASE_URL", &base) + .env("TMP", &temp_for_run) + .env("TEMP", &temp_for_run) + .arg("update") + .assert() + .success(); + }) + .await + .unwrap(); + + assert_eq!( + std::fs::read(&exe).unwrap(), + payload, + "round {round}: the binary should be the downloaded release" + ); + + // The helper that removes the old binary runs after the updater exits: give it time, then + // require that only the binary is left, and that nothing is left in the temp directory. + // This comes BEFORE launching the new binary, so nothing else is touching the files while + // the helper makes its single attempt to delete them. + let (mut left, mut in_temp) = (names_in(&bindir), names_in(&systemp)); + for _ in 0..80 { + if left == [exe_name.clone()] && in_temp.is_empty() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(250)); + (left, in_temp) = (names_in(&bindir), names_in(&systemp)); + } + assert_eq!( + left, + [exe_name.clone()], + "round {round}: self-replace left files next to the binary" + ); + assert!( + in_temp.is_empty(), + "round {round}: self-replace left files in the temp directory: {in_temp:?}" + ); + + // 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(); + } +}