From 411cfce77ccd2fdf867aa2b9dade7fa37971c5a2 Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Tue, 27 Oct 2026 05:01:24 +0000 Subject: [PATCH 1/6] Add signed Windows Agent updates and versioned installation --- .github/workflows/agent-desktop-build.yml | 5 +- .github/workflows/agent-updates-publish.yml | 23 +- .../agent-windows-signature-probe.yml | 96 + .../workflows/agent-windows-updater-tests.yml | 93 + apps/maple-agent/Cargo.lock | 1 + apps/maple-agent/README.md | 5 +- apps/maple-agent/app/src/desktop.rs | 54 +- apps/maple-agent/app/src/main.rs | 107 + apps/maple-agent/app/src/update.rs | 40 +- apps/maple-agent/app/src/windows_bootstrap.rs | 329 +++ .../crates/maple-agent-updater/Cargo.toml | 3 + .../examples/windows_signature_probe.rs | 60 + .../crates/maple-agent-updater/src/client.rs | 17 +- .../maple-agent-updater/src/installer.rs | 78 +- .../src/installer/linux_bundle_tests.rs | 1 + .../src/installer/windows_bundle.rs | 2056 +++++++++++++++++ .../installer/windows_bundle/authenticode.rs | 415 ++++ .../installer/windows_bundle/windows_fs.rs | 837 +++++++ .../src/installer/windows_bundle_tests.rs | 492 ++++ .../windows_bundle_transaction_tests.rs | 526 +++++ .../crates/maple-agent-updater/src/lib.rs | 7 +- .../maple-agent-updater/src/manifest.rs | 158 +- .../crates/maple-agent-updater/src/state.rs | 182 +- .../crates/maple-agent-updater/src/tests.rs | 170 +- .../tests/fixtures/README.md | 5 + .../fixtures/signed-windows-v3-envelope.json | 1 + .../tests/fixtures/windows-v3-contract.md | 15 + .../tests/fixtures/windows-v3-manifest.json | 1 + .../tests/windows_bootstrap_native.rs | 33 + apps/maple-agent/docs/desktop-builds.md | 27 +- apps/maple-agent/docs/release-profiles.md | 44 +- apps/maple-agent/docs/updates.md | 69 +- apps/maple-agent/release-profiles.json | 2 + apps/maple-agent/release_profile.rs | 36 +- apps/maple-agent/scripts/release-info.py | 32 +- .../scripts/test_release_packaging.py | 52 +- .../scripts/test_windows_build_info.py | 12 +- .../scripts/test_windows_release.py | 397 +++- .../maple-agent/scripts/windows-installer.nsi | 121 +- apps/maple-agent/scripts/windows-release.py | 269 ++- docs/repository-workflows.md | 21 + scripts/ci/test_agent_updates_workflows.py | 5 + scripts/ci/test_agent_workflows.py | 2 + .../ci/test_windows_signature_reference.py | 132 ++ scripts/ci/test_windows_updater_workflow.py | 101 + scripts/ci/windows-updater-tests.ps1 | 180 ++ scripts/ci/windows_signature_reference.py | 170 ++ services/agent-updates/README.md | 37 +- .../0002_windows_download_events.sql | 33 + services/agent-updates/publisher/README.md | 64 +- services/agent-updates/publisher/publisher.py | 197 +- .../agent-updates/publisher/test_minisign.py | 26 +- .../agent-updates/publisher/test_publisher.py | 28 +- .../publisher/test_windows_publisher.py | 391 ++++ services/agent-updates/src/linux-feed.test.ts | 1 + services/agent-updates/src/manifest.ts | 25 +- services/agent-updates/src/runtime.test.ts | 52 +- .../src/tracking-runtime.test.ts | 93 +- services/agent-updates/src/tracking.test.ts | 77 +- .../agent-updates/src/windows-feed.test.ts | 359 +++ .../agent-updates/src/windows-fixtures.ts | 34 + services/agent-updates/src/worker.ts | 39 +- services/download-tracking/README.md | 72 +- services/download-tracking/query.py | 2 +- services/download-tracking/test_query.py | 46 + services/download-tracking/tracking.ts | 43 +- 66 files changed, 8743 insertions(+), 358 deletions(-) create mode 100644 .github/workflows/agent-windows-signature-probe.yml create mode 100644 .github/workflows/agent-windows-updater-tests.yml create mode 100644 apps/maple-agent/app/src/windows_bootstrap.rs create mode 100644 apps/maple-agent/crates/maple-agent-updater/examples/windows_signature_probe.rs create mode 100644 apps/maple-agent/crates/maple-agent-updater/src/installer/windows_bundle.rs create mode 100644 apps/maple-agent/crates/maple-agent-updater/src/installer/windows_bundle/authenticode.rs create mode 100644 apps/maple-agent/crates/maple-agent-updater/src/installer/windows_bundle/windows_fs.rs create mode 100644 apps/maple-agent/crates/maple-agent-updater/src/installer/windows_bundle_tests.rs create mode 100644 apps/maple-agent/crates/maple-agent-updater/src/installer/windows_bundle_transaction_tests.rs create mode 100644 apps/maple-agent/crates/maple-agent-updater/tests/fixtures/signed-windows-v3-envelope.json create mode 100644 apps/maple-agent/crates/maple-agent-updater/tests/fixtures/windows-v3-contract.md create mode 100644 apps/maple-agent/crates/maple-agent-updater/tests/fixtures/windows-v3-manifest.json create mode 100644 apps/maple-agent/crates/maple-agent-updater/tests/windows_bootstrap_native.rs create mode 100644 scripts/ci/test_windows_signature_reference.py create mode 100644 scripts/ci/test_windows_updater_workflow.py create mode 100644 scripts/ci/windows-updater-tests.ps1 create mode 100644 scripts/ci/windows_signature_reference.py create mode 100644 services/agent-updates/migrations/0002_windows_download_events.sql create mode 100644 services/agent-updates/publisher/test_windows_publisher.py create mode 100644 services/agent-updates/src/windows-feed.test.ts create mode 100644 services/agent-updates/src/windows-fixtures.ts diff --git a/.github/workflows/agent-desktop-build.yml b/.github/workflows/agent-desktop-build.yml index ddc9244d0..ced5a7ee4 100644 --- a/.github/workflows/agent-desktop-build.yml +++ b/.github/workflows/agent-desktop-build.yml @@ -546,6 +546,7 @@ jobs: variant: [dev, prod] env: MAPLE_RELEASE_PROFILE: ${{ matrix.variant }} + MAPLE_PACKAGE_BUILD_NUMBER: ${{ github.run_number }} steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 with: @@ -598,6 +599,7 @@ jobs: variant: [dev] env: MAPLE_RELEASE_PROFILE: ${{ matrix.variant }} + MAPLE_PACKAGE_BUILD_NUMBER: ${{ github.run_number }} steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 with: @@ -634,6 +636,7 @@ jobs: variant: ${{ fromJSON(github.event_name == 'pull_request' && '["dev"]' || '["dev","prod"]') }} env: MAPLE_RELEASE_PROFILE: ${{ matrix.variant }} + MAPLE_PACKAGE_BUILD_NUMBER: ${{ github.run_number }} steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 with: @@ -661,7 +664,7 @@ jobs: done env: GH_TOKEN: ${{ github.token }} - - name: Verify downloaded Windows package and install lifecycle + - name: Verify downloaded Windows package and profile-specific install policy run: | # The stock image exports these Azure CLI tooling/cache paths. # Keep other Azure/signing variables visible to the fail-closed guard. diff --git a/.github/workflows/agent-updates-publish.yml b/.github/workflows/agent-updates-publish.yml index 502348321..846e242a3 100644 --- a/.github/workflows/agent-updates-publish.yml +++ b/.github/workflows/agent-updates-publish.yml @@ -21,6 +21,11 @@ on: required: true default: true type: boolean + include_windows: + description: Include qualified Windows packages after Windows update delivery is activated + required: true + default: false + type: boolean permissions: contents: read @@ -53,10 +58,15 @@ jobs: env: ACTIVATED: ${{ vars.AGENT_UPDATES_ENABLED }} PUBLIC_KEY: ${{ vars.AGENT_UPDATE_PUBLIC_KEY }} + INCLUDE_WINDOWS: ${{ inputs.include_windows }} + WINDOWS_ACTIVATED: ${{ vars.AGENT_WINDOWS_UPDATES_ENABLED }} run: | set -euo pipefail [ "$ACTIVATED" = true ] || { echo 'Agent update production setup has not been activated by its owner.' >&2; exit 1; } [ -n "$PUBLIC_KEY" ] || { echo 'Agent public update key is not configured.' >&2; exit 1; } + if [ "$INCLUDE_WINDOWS" = true ]; then + [ "$WINDOWS_ACTIVATED" = true ] || { echo 'Windows update delivery has not completed its owner-approved native qualification.' >&2; exit 1; } + fi printf '%s\n' "$PUBLIC_KEY" > public-key.pub - name: Verify exact build, download final packages and prepare without secrets env: @@ -65,12 +75,15 @@ jobs: RELEASE_VERSION: ${{ inputs.version }} RELEASE_NOTES: ${{ inputs.notes }} INCLUDE_LINUX: ${{ inputs.include_linux }} + INCLUDE_WINDOWS: ${{ inputs.include_windows }} run: | nix develop --no-update-lock-file .#ci -c bash -euo pipefail <<'PREPARE' linux_args=() + windows_args=() if [ "$INCLUDE_LINUX" = true ]; then linux_args=(--linux); fi + if [ "$INCLUDE_WINDOWS" = true ]; then windows_args=(--windows); fi python3 services/agent-updates/publisher/publisher.py trust \ - --run-id "$BUILD_RUN_ID" "${linux_args[@]}" --output trust.json + --run-id "$BUILD_RUN_ID" "${linux_args[@]}" "${windows_args[@]}" --output trust.json gh run download "$BUILD_RUN_ID" --repo "$GITHUB_REPOSITORY" \ --name "$(jq -er .artifacts.macos trust.json)" --dir packages/macos linux_args=() @@ -79,10 +92,16 @@ jobs: --name "$(jq -er .artifacts.linux trust.json)" --dir packages/linux linux_args=(--linux packages/linux) fi + windows_args=() + if [ "$INCLUDE_WINDOWS" = true ]; then + gh run download "$BUILD_RUN_ID" --repo "$GITHUB_REPOSITORY" \ + --name "$(jq -er .artifacts.windows trust.json)" --dir packages/windows + windows_args=(--windows packages/windows) + fi printf '%s' "$RELEASE_NOTES" > notes.txt python3 services/agent-updates/publisher/publisher.py prepare \ --run-id "$BUILD_RUN_ID" --version "$RELEASE_VERSION" --build "$(jq -er .build trust.json)" \ - --macos packages/macos "${linux_args[@]}" --notes-file notes.txt --public-key public-key.pub --output bundle + --macos packages/macos "${linux_args[@]}" "${windows_args[@]}" --notes-file notes.txt --public-key public-key.pub --output bundle PREPARE - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: diff --git a/.github/workflows/agent-windows-signature-probe.yml b/.github/workflows/agent-windows-signature-probe.yml new file mode 100644 index 000000000..984a4c0a0 --- /dev/null +++ b/.github/workflows/agent-windows-signature-probe.yml @@ -0,0 +1,96 @@ +name: Agent Windows Historical Signature Probe + +permissions: + contents: read + actions: read + +on: + workflow_dispatch: + inputs: + producer_run: + description: Exact trusted master Desktop producer run + type: string + required: true + producer_attempt: + description: Exact completed producer attempt + type: string + required: true + source_sha: + description: Exact producer source SHA + type: string + required: true + artifact_id: + description: Exact final Prod Windows artifact ID + type: string + required: true + zip_sha256: + description: Exact API artifact ZIP SHA256 + type: string + required: true + zip_bytes: + description: Exact API artifact ZIP byte size + type: string + required: true + pe_member: + description: Exact signed PE basename in the artifact + type: string + required: true + pe_sha256: + description: Exact signed PE SHA256 + type: string + required: true + +jobs: + signature-probe: + runs-on: windows-2025 + timeout-minutes: 20 + env: + CARGO_TERM_COLOR: never + CARGO_INCREMENTAL: 0 + CARGO_PROFILE_DEV_DEBUG: 0 + RUSTFLAGS: -D warnings + MAPLE_PROBE_RUN: ${{ inputs.producer_run }} + MAPLE_PROBE_ATTEMPT: ${{ inputs.producer_attempt }} + MAPLE_PROBE_SOURCE: ${{ inputs.source_sha }} + MAPLE_PROBE_ARTIFACT: ${{ inputs.artifact_id }} + MAPLE_PROBE_ZIP_SHA: ${{ inputs.zip_sha256 }} + MAPLE_PROBE_ZIP_BYTES: ${{ inputs.zip_bytes }} + MAPLE_PROBE_PE_MEMBER: ${{ inputs.pe_member }} + MAPLE_PROBE_PE_SHA: ${{ inputs.pe_sha256 }} + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 + with: + toolchain: 1.98.0 + - uses: ilammy/msvc-dev-cmd@a102174a2b586eec2ea151a69e6fd14404a8ce7c + with: + arch: x64 + - name: Select and verify the native linker + working-directory: apps/maple-agent + shell: pwsh + run: python3 ../../scripts/ci/agent_windows.py + - name: Validate the exact historical reference without execution + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + run: python3 scripts/ci/windows_signature_reference.py + - name: Run the production Authenticode verifier against the reference + working-directory: apps/maple-agent + shell: pwsh + run: | + $reference = Join-Path $env:RUNNER_TEMP 'maple-windows-signature-probe/reference.exe' + $result = Join-Path $env:RUNNER_TEMP 'maple-windows-signature-probe/signature-result.json' + cargo run -p maple-agent-updater --locked --example windows_signature_probe -- $reference $env:MAPLE_PROBE_PE_SHA > $result + if ($LASTEXITCODE -ne 0) { throw 'Native historical signature interoperability probe failed.' } + - name: Preserve exact selection and native result + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: windows-historical-signature-${{ github.run_id }}-${{ github.run_attempt }} + path: | + ${{ runner.temp }}/maple-windows-signature-probe/selection.json + ${{ runner.temp }}/maple-windows-signature-probe/signature-result.json + if-no-files-found: error + retention-days: 14 diff --git a/.github/workflows/agent-windows-updater-tests.yml b/.github/workflows/agent-windows-updater-tests.yml new file mode 100644 index 000000000..1be5bc5da --- /dev/null +++ b/.github/workflows/agent-windows-updater-tests.yml @@ -0,0 +1,93 @@ +name: Agent Windows Updater Tests + +# Native component tests run without the signing/publishing environments. A +# green result is not qualification of an official signed installed upgrade. +permissions: + contents: read + +on: + pull_request: + paths: + - ".github/workflows/agent-windows-updater-tests.yml" + - "scripts/ci/windows-updater-tests.ps1" + - "scripts/ci/test_windows_updater_workflow.py" + - "scripts/ci/agent_windows.py" + - "apps/maple-agent/Cargo.toml" + - "apps/maple-agent/Cargo.lock" + - "apps/maple-agent/app/Cargo.toml" + - "apps/maple-agent/crates/maple-agent-updater/**" + - "apps/maple-agent/release_profile.rs" + - "apps/maple-agent/release-profiles.json" + - "apps/maple-agent/app/src/main.rs" + - "apps/maple-agent/app/src/profile.rs" + - "apps/maple-agent/app/src/update.rs" + - "apps/maple-agent/app/src/desktop.rs" + - "apps/maple-agent/app/src/windows_bootstrap.rs" + - "apps/maple-agent/scripts/windows-release.py" + - "apps/maple-agent/scripts/windows-installer.nsi" + - "apps/maple-agent/scripts/release-info.py" + push: + branches: [master] + paths: + - ".github/workflows/agent-windows-updater-tests.yml" + - "scripts/ci/windows-updater-tests.ps1" + - "scripts/ci/test_windows_updater_workflow.py" + - "scripts/ci/agent_windows.py" + - "apps/maple-agent/Cargo.toml" + - "apps/maple-agent/Cargo.lock" + - "apps/maple-agent/app/Cargo.toml" + - "apps/maple-agent/crates/maple-agent-updater/**" + - "apps/maple-agent/release_profile.rs" + - "apps/maple-agent/release-profiles.json" + - "apps/maple-agent/app/src/main.rs" + - "apps/maple-agent/app/src/profile.rs" + - "apps/maple-agent/app/src/update.rs" + - "apps/maple-agent/app/src/desktop.rs" + - "apps/maple-agent/app/src/windows_bootstrap.rs" + - "apps/maple-agent/scripts/windows-release.py" + - "apps/maple-agent/scripts/windows-installer.nsi" + - "apps/maple-agent/scripts/release-info.py" + workflow_dispatch: + +concurrency: + group: agent-windows-updater-tests-${{ github.ref }} + cancel-in-progress: true + +jobs: + native-updater: + name: Native Windows updater component tests + runs-on: windows-2025 + timeout-minutes: 35 + env: + CARGO_TERM_COLOR: never + CARGO_INCREMENTAL: 0 + CARGO_PROFILE_DEV_DEBUG: 0 + RUSTFLAGS: -D warnings + steps: + - name: Checkout the tested source + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false + - name: Install the repository's pinned Windows Rust + uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 + with: + toolchain: 1.98.0 + - name: Select Windows MSVC and SDK + uses: ilammy/msvc-dev-cmd@a102174a2b586eec2ea151a69e6fd14404a8ce7c + with: + arch: x64 + - name: Pin and smoke-test the selected Windows linker + working-directory: apps/maple-agent + shell: pwsh + run: python3 ../../scripts/ci/agent_windows.py + - name: Exercise the native updater and bootstrap + shell: pwsh + run: ./scripts/ci/windows-updater-tests.ps1 + - name: Retain the native test receipt and logs + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: windows-updater-native-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/maple-windows-updater-tests/ + if-no-files-found: error + retention-days: 14 diff --git a/apps/maple-agent/Cargo.lock b/apps/maple-agent/Cargo.lock index 54670da25..6e5769d6a 100644 --- a/apps/maple-agent/Cargo.lock +++ b/apps/maple-agent/Cargo.lock @@ -5900,6 +5900,7 @@ dependencies = [ "time", "tokio", "url", + "windows-sys 0.61.2", ] [[package]] diff --git a/apps/maple-agent/README.md b/apps/maple-agent/README.md index c1eef9d67..2f361399e 100644 --- a/apps/maple-agent/README.md +++ b/apps/maple-agent/README.md @@ -626,8 +626,9 @@ check. ## Update and release boundary -Automatic updates currently support macOS and Linux. Windows Dev/Prod installers -are Actions downloads with manual upgrades; see [desktop builds](docs/desktop-builds.md). +Packaged Prod updates support macOS, Linux and Windows through separate signed +platform feeds. Windows also provides a signed per-user manual installer; +see [desktop builds](docs/desktop-builds.md). Packaged Prod builds use the independent signed Agent update feed. Dev and unpackaged/MDE builds do not check a production updater. The Prod profile pins diff --git a/apps/maple-agent/app/src/desktop.rs b/apps/maple-agent/app/src/desktop.rs index 1e5fce1f4..cff09f75e 100644 --- a/apps/maple-agent/app/src/desktop.rs +++ b/apps/maple-agent/app/src/desktop.rs @@ -2099,6 +2099,8 @@ struct DesktopLifecycle { quit_completed: std::rc::Rc>, quit_requested: std::rc::Rc>, frontend_detached: std::rc::Rc>, + #[cfg(target_os = "windows")] + update_restart_pending: std::rc::Rc>, } impl gpui::Global for DesktopLifecycle {} @@ -2111,7 +2113,7 @@ fn may_finish_quit(cx: &App) -> bool { } fn restart_installed_update(path: PathBuf, cx: &mut App) { - let Some(state) = cx.try_global::() else { + let Some(state) = cx.try_global::().cloned() else { return; }; if state.updater.restart_path().as_ref() != Some(&path) @@ -2119,13 +2121,47 @@ fn restart_installed_update(path: PathBuf, cx: &mut App) { { return; } - state.quit_completed.set(true); - #[cfg(target_os = "macos")] - native_quit::allow_update_restart(); - cx.set_restart_path(path); - cx.restart(); - #[cfg(target_os = "macos")] - native_quit::finish_update_restart(); + #[cfg(target_os = "windows")] + { + if state.update_restart_pending.replace(true) { + return; + } + // Signature/tree verification can touch disk and trust services. Keep + // it off the UI thread; repeated clicks share this one restart attempt. + // Native argv and exact process waiting avoid shell path interpolation. + let restart_path = path.clone(); + let restart = cx.background_executor().spawn(async move { + crate::windows_bootstrap::schedule_restart(&restart_path, crate::profile::PROFILE) + }); + cx.spawn(async move |cx| { + let result = restart.await; + cx.update(|cx| { + if let Err(error) = result { + state.update_restart_pending.set(false); + log::error!("Windows update restart could not start: {error}"); + show_update_about(cx); + return; + } + if state.updater.restart_path().as_ref() == Some(&path) + && state.backend.is_retired_after_update() + { + state.quit_completed.set(true); + cx.quit(); + } + }); + }) + .detach(); + } + #[cfg(not(target_os = "windows"))] + { + state.quit_completed.set(true); + #[cfg(target_os = "macos")] + native_quit::allow_update_restart(); + cx.set_restart_path(path); + cx.restart(); + #[cfg(target_os = "macos")] + native_quit::finish_update_restart(); + } } fn finish_quit(cx: &mut App) { @@ -3078,6 +3114,8 @@ pub fn run() { quit_completed: Default::default(), quit_requested: Default::default(), frontend_detached: Default::default(), + #[cfg(target_os = "windows")] + update_restart_pending: Default::default(), }); #[cfg(target_os = "macos")] if let Err(error) = native_quit::install(cx) { diff --git a/apps/maple-agent/app/src/main.rs b/apps/maple-agent/app/src/main.rs index 406832645..2ac9b0732 100644 --- a/apps/maple-agent/app/src/main.rs +++ b/apps/maple-agent/app/src/main.rs @@ -35,6 +35,8 @@ mod tray; mod ui; #[cfg(feature = "desktop")] mod update; +#[cfg(all(feature = "desktop", target_os = "windows"))] +mod windows_bootstrap; use backend::AgentBackend; use clap::{Args, Parser, Subcommand}; @@ -75,6 +77,22 @@ struct Cli { #[cfg(all(feature = "desktop", target_os = "linux"))] #[arg(long, hide = true, conflicts_with_all = ["build_info", "internal_store_integrity_check", "desktop_args"])] internal_theme_watch: bool, + /// Credential-free native installer admission before any installed write. + #[cfg(all(feature = "desktop", target_os = "windows"))] + #[arg(long, hide = true, value_name = "INSTALL_ROOT", conflicts_with_all = ["build_info", "internal_store_integrity_check", "desktop_args", "internal_windows_installer_activate", "internal_windows_uninstall_cleanup", "internal_windows_restart"])] + internal_windows_installer_preflight: Option, + #[cfg(all(feature = "desktop", target_os = "windows"))] + #[arg(long, hide = true, num_args = 2, value_names = ["INSTALL_ROOT", "GENERATION"], conflicts_with_all = ["build_info", "internal_store_integrity_check", "desktop_args", "internal_windows_installer_preflight", "internal_windows_installer_activate", "internal_windows_uninstall_cleanup", "internal_windows_restart"])] + internal_windows_installer_prepare: Vec, + #[cfg(all(feature = "desktop", target_os = "windows"))] + #[arg(long, hide = true, value_name = "GENERATION", conflicts_with_all = ["build_info", "internal_store_integrity_check", "desktop_args", "internal_windows_uninstall_cleanup", "internal_windows_restart"])] + internal_windows_installer_activate: Option, + #[cfg(all(feature = "desktop", target_os = "windows"))] + #[arg(long, hide = true, conflicts_with_all = ["build_info", "internal_store_integrity_check", "desktop_args", "internal_windows_restart"])] + internal_windows_uninstall_cleanup: bool, + #[cfg(all(feature = "desktop", target_os = "windows"))] + #[arg(long, hide = true, value_name = "PROCESS_IDENTITY", conflicts_with_all = ["build_info", "internal_store_integrity_check", "desktop_args"])] + internal_windows_restart: Option, #[command(subcommand)] mode: Option, /// Arguments a desktop launcher may append (file paths, `%u` expansions). @@ -232,7 +250,96 @@ pub(crate) fn startup_elapsed() -> u128 { } fn main() { + #[cfg(all(feature = "desktop", target_os = "windows"))] + if profile::PACKAGED { + // Delegate before Clap so the immutable bootstrap does not freeze the + // selected generation's CLI grammar or report its own old version. + match windows_bootstrap::delegate_before_parse(profile::PROFILE) { + Ok(Some(code)) => std::process::exit(code), + Ok(None) => {} + Err(message) => { + eprintln!("{message}"); + std::process::exit(1); + } + } + } let cli = Cli::parse(); + #[cfg(all(feature = "desktop", target_os = "windows"))] + { + // The stable launcher and installer modes never open application data, + // credentials or a host. Their paths and code identities are checked + // by the updater's native installer before mutation or delegation. + let internal = if let Some(root) = &cli.internal_windows_installer_preflight { + Some( + std::env::current_exe() + .map_err(|_| "Could not locate the Windows installer checker".to_owned()) + .and_then(|exe| { + maple_agent_updater::installer::preflight_windows_installation( + &exe, + root, + profile::PROFILE, + ) + .map_err(|error| error.to_string()) + }), + ) + } else if !cli.internal_windows_installer_prepare.is_empty() { + Some( + std::env::current_exe() + .map_err(|_| "Could not locate the Windows installer checker".to_owned()) + .and_then(|exe| { + let root = + std::path::PathBuf::from(&cli.internal_windows_installer_prepare[0]); + let generation = cli.internal_windows_installer_prepare[1] + .to_str() + .ok_or_else(|| "Invalid Windows generation".to_owned())?; + maple_agent_updater::installer::prepare_windows_installation( + &exe, + &root, + profile::PROFILE, + generation, + ) + .map_err(|error| error.to_string()) + }), + ) + } else if let Some(generation) = &cli.internal_windows_installer_activate { + Some( + std::env::current_exe() + .map_err(|_| "Could not locate the Windows bootstrap".to_owned()) + .and_then(|exe| { + maple_agent_updater::installer::activate_windows_installation( + &exe, + profile::PROFILE, + generation, + ) + .map_err(|error| error.to_string()) + }), + ) + } else if cli.internal_windows_uninstall_cleanup { + Some( + std::env::current_exe() + .map_err(|_| "Could not locate the Windows bootstrap".to_owned()) + .and_then(|exe| { + maple_agent_updater::installer::cleanup_windows_installation( + &exe, + profile::PROFILE, + ) + .map(|_| ()) + .map_err(|error| error.to_string()) + }), + ) + } else { + cli.internal_windows_restart + .as_deref() + .map(|identity| windows_bootstrap::run_restart(identity, profile::PROFILE)) + }; + if let Some(result) = internal { + if let Err(error) = result { + eprintln!("{error}"); + std::process::exit(1); + } + return; + } + } if cli.build_info { println!( "{}", diff --git a/apps/maple-agent/app/src/update.rs b/apps/maple-agent/app/src/update.rs index 3299a14c4..2d121011b 100644 --- a/apps/maple-agent/app/src/update.rs +++ b/apps/maple-agent/app/src/update.rs @@ -1003,12 +1003,17 @@ async fn discard_preparation(preparation: maple_agent_updater::installer::Instal async fn cleanup_installed_update(service: &Service) { let installed = service.installed_path.clone(); - #[cfg(target_os = "linux")] + #[cfg(any(target_os = "linux", target_os = "windows"))] { if service.manual_only() { return; } - let Ok(origin) = compiled_feed_url(Target::LinuxX86_64) else { + let target = if cfg!(target_os = "windows") { + Target::WindowsX86_64 + } else { + Target::LinuxX86_64 + }; + let Ok(origin) = compiled_feed_url(target) else { return; }; let keys = crate::profile::UPDATE_PUBLIC_KEYS @@ -1019,7 +1024,11 @@ async fn cleanup_installed_update(service: &Service) { // restore. Authenticate the install receipt against this running build // and the compiled feed/key pins before removing its retained backup. let _ = tokio::task::spawn_blocking(move || { - maple_agent_updater::installer::cleanup_current_linux_bundle( + #[cfg(target_os = "linux")] + let cleanup = maple_agent_updater::installer::cleanup_current_linux_bundle; + #[cfg(target_os = "windows")] + let cleanup = maple_agent_updater::installer::cleanup_current_windows_bundle; + cleanup( &installed, env!("CARGO_PKG_VERSION"), env!("MAPLE_GIT_SOURCE_SHA"), @@ -1029,7 +1038,7 @@ async fn cleanup_installed_update(service: &Service) { }) .await; } - #[cfg(not(target_os = "linux"))] + #[cfg(not(any(target_os = "linux", target_os = "windows")))] { let _ = tokio::task::spawn_blocking(move || { maple_agent_updater::installer::cleanup_current_installation( @@ -1055,6 +1064,8 @@ async fn discard_preparation_with_lock( fn manual_download_url(release: &VerifiedRelease) -> Option { let target = if cfg!(target_os = "linux") { Target::LinuxX86_64 + } else if cfg!(target_os = "windows") { + Target::WindowsX86_64 } else { Target::DarwinAarch64 }; @@ -1065,6 +1076,7 @@ fn manual_download_url_for_target(release: &VerifiedRelease, target: Target) -> let artifact = match target { Target::DarwinAarch64 => release.manifest.downloads.macos.as_ref()?, Target::LinuxX86_64 => release.manifest.downloads.linux_bundle.as_ref()?, + Target::WindowsX86_64 => release.manifest.downloads.windows.as_ref()?, }; release .origin @@ -1082,6 +1094,8 @@ fn build_service() -> Result { Target::DarwinAarch64 } else if cfg!(all(target_os = "linux", target_arch = "x86_64")) { Target::LinuxX86_64 + } else if cfg!(all(target_os = "windows", target_arch = "x86_64")) { + Target::WindowsX86_64 } else { return Err("Updates are not supported on this platform".into()); }; @@ -1170,7 +1184,23 @@ fn installation() -> Result { maple_agent::self_launch::executable(), )) } - #[cfg(not(any(target_os = "macos", target_os = "linux")))] + #[cfg(target_os = "windows")] + { + Ok( + match maple_agent_updater::installer::discover_windows_installation( + &exe, + env!("CARGO_PKG_VERSION"), + env!("MAPLE_GIT_SOURCE_SHA"), + ) { + Ok(installed_path) => Installation { + installed_path, + manual_only: false, + }, + Err(_) => Installation::manual(&exe), + }, + ) + } + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] { Ok(Installation::manual(&exe)) } diff --git a/apps/maple-agent/app/src/windows_bootstrap.rs b/apps/maple-agent/app/src/windows_bootstrap.rs new file mode 100644 index 000000000..a6b593748 --- /dev/null +++ b/apps/maple-agent/app/src/windows_bootstrap.rs @@ -0,0 +1,329 @@ +//! Credential-free Windows bootstrap and restart. Never evaluates a shell script. +//! The installer owns destination/profile/code validation; this module only +//! delegates to its selected generation or waits for an exact retiring process. + +use std::{ + ffi::OsString, + os::windows::process::CommandExt, + path::Path, + process::{Command, Stdio}, +}; + +use windows_sys::Win32::{ + Foundation::{CloseHandle, FILETIME, HANDLE, WAIT_OBJECT_0, WAIT_TIMEOUT}, + System::Threading::{ + CREATE_NO_WINDOW, GetCurrentProcess, GetProcessTimes, OpenProcess, + PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_SYNCHRONIZE, WaitForSingleObject, + }, +}; + +struct ProcessHandle(HANDLE); +impl Drop for ProcessHandle { + fn drop(&mut self) { + unsafe { CloseHandle(self.0) }; + } +} + +fn creation_time(handle: HANDLE) -> Result { + let mut created = FILETIME::default(); + let mut exited = FILETIME::default(); + let mut kernel = FILETIME::default(); + let mut user = FILETIME::default(); + if unsafe { GetProcessTimes(handle, &mut created, &mut exited, &mut kernel, &mut user) } == 0 { + return Err("Could not identify the retiring Maple process".into()); + } + Ok((u64::from(created.dwHighDateTime) << 32) | u64::from(created.dwLowDateTime)) +} + +fn restart_identity(pid: u32, created: u64) -> String { + format!("{pid}:{created}") +} + +fn parse_restart_identity(value: &str) -> Result<(u32, u64), String> { + let (pid, created) = value + .split_once(':') + .filter(|_| value.len() <= 32) + .ok_or("Invalid Windows restart identity")?; + let pid: u32 = pid + .parse() + .map_err(|_| "Invalid Windows restart identity")?; + let created: u64 = created + .parse() + .map_err(|_| "Invalid Windows restart identity")?; + if pid == 0 || created == 0 || restart_identity(pid, created) != value { + return Err("Invalid Windows restart identity".into()); + } + Ok((pid, created)) +} + +/// Spawn the trusted stable bootstrap before retiring the current desktop. +/// A failed spawn leaves the committed update available for a deliberate retry. +pub(crate) fn schedule_restart(bootstrap: &Path, profile: &str) -> Result<(), String> { + // Revalidate the actual bootstrap/selected generation before executing it. + if maple_agent_updater::installer::resolve_windows_launcher(bootstrap, profile) + .map_err(|_| "The Windows update launcher could not be verified")? + .is_none() + { + return Err("The Windows update launcher is not an installed bootstrap".into()); + } + let created = creation_time(unsafe { GetCurrentProcess() })?; + Command::new(bootstrap) + .arg("--internal-windows-restart") + .arg(restart_identity(std::process::id(), created)) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .creation_flags(CREATE_NO_WINDOW) + .spawn() + .map_err(|_| "Could not start the Windows restart helper")?; + Ok(()) +} + +/// This early mode opens no application/store/backend state. PID creation time +/// prevents a reused PID from becoming restart authority. +pub(crate) fn run_restart(identity: &str, profile: &str) -> Result<(), String> { + let (pid, expected_creation) = parse_restart_identity(identity)?; + let process = unsafe { + OpenProcess( + PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_SYNCHRONIZE, + 0, + pid, + ) + }; + if !process.is_null() { + let process = ProcessHandle(process); + if creation_time(process.0)? != expected_creation { + return Err("The retiring Windows process identity changed".into()); + } + match unsafe { WaitForSingleObject(process.0, 120_000) } { + WAIT_OBJECT_0 => {} + WAIT_TIMEOUT => { + return Err( + "Maple has not finished shutting down; reopen it after quitting".into(), + ); + } + _ => return Err("Could not wait for Maple to shut down".into()), + } + } else { + let error = std::io::Error::last_os_error(); + // ERROR_INVALID_PARAMETER: the PID no longer exists. Access denial and + // other errors never mean that the old process has finished. + if error.raw_os_error() != Some(87) { + return Err("Could not identify the retiring Windows process".into()); + } + } + let bootstrap = + std::env::current_exe().map_err(|_| "Could not locate the Windows bootstrap")?; + let target = maple_agent_updater::installer::resolve_windows_launcher(&bootstrap, profile) + .map_err(|_| "The Windows update launcher could not be verified")? + .ok_or("The restart helper is not the stable Windows bootstrap")?; + Command::new(target) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .creation_flags(CREATE_NO_WINDOW) + .spawn() + .map_err(|_| "Could not launch the updated Windows application")?; + Ok(()) +} + +fn is_internal_entrypoint(arguments: &[OsString]) -> bool { + arguments.first().is_some_and(|argument| { + [ + "--internal-windows-installer-preflight", + "--internal-windows-installer-prepare", + "--internal-windows-installer-activate", + "--internal-windows-uninstall-cleanup", + "--internal-windows-restart", + ] + .iter() + .any(|reserved| argument == *reserved) + }) +} + +/// Delegate before Clap parses the application's CLI. The stable bootstrap may +/// be older than the selected generation and must not freeze its CLI grammar. +/// Only the exact current native helper entrypoints remain owned by this binary; +/// main parses and validates those arguments before executing their operation. +pub(crate) fn delegate_before_parse(profile: &str) -> Result, String> { + let arguments: Vec = std::env::args_os().skip(1).collect(); + if is_internal_entrypoint(&arguments) { + return Ok(None); + } + let executable = std::env::current_exe().map_err(|_| "Could not locate Maple")?; + let Some(target) = + maple_agent_updater::installer::resolve_windows_launcher(&executable, profile) + .map_err(|_| "The installed Windows application could not be verified")? + else { + return Ok(None); + }; + forward_verified_target(&target, &arguments, !arguments.is_empty()).map(Some) +} + +/// This private primitive receives only a target already admitted by the native +/// installer. Native argv/stdio/status never pass through a shell command string. +fn forward_verified_target( + target: &Path, + arguments: &[OsString], + wait_for_exit: bool, +) -> Result { + let mut command = Command::new(target); + command.args(arguments); + if !wait_for_exit { + command.creation_flags(CREATE_NO_WINDOW); + } + let mut child = command + .spawn() + .map_err(|_| "Could not start the installed Maple application")?; + Ok(if wait_for_exit { + child + .wait() + .map_err(|_| "Could not wait for the Maple command")? + .code() + .unwrap_or(1) + } else { + 0 + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn windows_restart_identity_is_canonical_and_bounded() { + assert_eq!(parse_restart_identity("42:12345"), Ok((42, 12345))); + for value in [ + "0:123", + "1:0", + "01:2", + "1:02", + "1:2:3", + "1:$(echo)", + "4294967296:1", + "1:18446744073709551616", + ] { + assert!(parse_restart_identity(value).is_err()); + } + } + + #[test] + fn windows_current_process_has_stable_creation_identity() { + let handle = unsafe { GetCurrentProcess() }; + let created = creation_time(handle).unwrap(); + assert!(created > 0); + assert_eq!(creation_time(handle).unwrap(), created); + } + + #[test] + fn windows_bootstrap_only_reserves_exact_internal_entrypoints() { + assert!(is_internal_entrypoint(&[ + "--internal-windows-installer-prepare".into(), + "C:\\Maple stage & data".into(), + "0.0.10-702".into(), + ])); + assert!(is_internal_entrypoint(&[ + "--internal-windows-restart".into(), + "42:12345".into(), + ])); + for arguments in [ + vec![], + vec!["--help".into()], + vec!["--version".into()], + vec!["--future-generation-option".into()], + vec!["--internal-windows-restart=42:12345".into()], + vec!["--internal-windows-restart-other".into()], + vec!["--".into(), "--internal-windows-restart".into()], + ] { + assert!(!is_internal_entrypoint(&arguments)); + } + } + + #[test] + fn windows_forward_preserves_native_argv_and_exit_status() { + use std::os::windows::ffi::OsStrExt; + + let scratch = tempfile::tempdir().unwrap(); + let directory = scratch.path().join("Maple stage & Ξ©"); + std::fs::create_dir(&directory).unwrap(); + let source = directory.join("argv-fixture.rs"); + let target = directory.join("forward ' $ & ; fixture.exe"); + let output = directory.join("native-argv.bin"); + // The fixture records the actual argv delivered by Windows. It uses + // only std, and never installs a certificate or bypasses admission of a + // production target. Ordinary fixture execution is confined to cfg(test). + std::fs::write( + &source, + r#" +use std::{fs::File, io::Write, os::windows::ffi::OsStrExt}; +fn main() { + let arguments: Vec<_> = std::env::args_os().skip(1).collect(); + let mut output = File::create(&arguments[0]).unwrap(); + output.write_all(&(arguments.len() as u32).to_le_bytes()).unwrap(); + for argument in &arguments { + let units: Vec<_> = argument.encode_wide().collect(); + output.write_all(&(units.len() as u32).to_le_bytes()).unwrap(); + for unit in units { + output.write_all(&unit.to_le_bytes()).unwrap(); + } + } + output.sync_all().unwrap(); + std::process::exit(37); +} +"#, + ) + .unwrap(); + let mut compiler = Command::new("rustc"); + compiler.args([ + "--edition=2024", + "--crate-name", + "windows_bootstrap_argv_fixture", + "--target", + "x86_64-pc-windows-msvc", + "-C", + "debuginfo=0", + ]); + // Reuse the CI's selected native linker instead of rediscovering it. + if let Some(linker) = std::env::var_os("CARGO_TARGET_X86_64_PC_WINDOWS_MSVC_LINKER") { + let mut option = OsString::from("linker="); + option.push(linker); + compiler.arg("-C").arg(option); + } + let compiled = compiler + .arg(&source) + .arg("-o") + .arg(&target) + .output() + .unwrap(); + assert!( + compiled.status.success(), + "native argv fixture compile failed: {}", + String::from_utf8_lossy(&compiled.stderr) + ); + let arguments: Vec = vec![ + output.clone().into_os_string(), + "".into(), + "space argument".into(), + "quotes \"inside\"".into(), + r"trailing\path\\".into(), + r#"backslash quote \\" end"#.into(), + "a&b;c$d`e".into(), + "'single quotes'".into(), + "δΈ–η•Œ πŸͺ΄".into(), + "--future-option=with space".into(), + ]; + assert_eq!( + forward_verified_target(&target, &arguments, true).unwrap(), + 37 + ); + let mut expected = (arguments.len() as u32).to_le_bytes().to_vec(); + for argument in arguments { + let units: Vec<_> = argument.encode_wide().collect(); + expected.extend((units.len() as u32).to_le_bytes()); + for unit in units { + expected.extend(unit.to_le_bytes()); + } + } + assert_eq!(std::fs::read(output).unwrap(), expected); + } +} diff --git a/apps/maple-agent/crates/maple-agent-updater/Cargo.toml b/apps/maple-agent/crates/maple-agent-updater/Cargo.toml index da02744b9..8eda0440f 100644 --- a/apps/maple-agent/crates/maple-agent-updater/Cargo.toml +++ b/apps/maple-agent/crates/maple-agent-updater/Cargo.toml @@ -25,6 +25,9 @@ tar = "0.4" flate2 = "1" plist = "1" +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "=0.61.2", features = ["Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_Security_Cryptography", "Win32_Security_Cryptography_Catalog", "Win32_Security_Cryptography_Sip", "Win32_Security_WinTrust", "Win32_Storage_FileSystem", "Win32_System_Diagnostics_ToolHelp", "Win32_System_Registry", "Win32_System_SystemServices", "Win32_System_Threading"] } + [dev-dependencies] base64 = "0.22" blake2 = "0.10" diff --git a/apps/maple-agent/crates/maple-agent-updater/examples/windows_signature_probe.rs b/apps/maple-agent/crates/maple-agent-updater/examples/windows_signature_probe.rs new file mode 100644 index 000000000..a8bac7c35 --- /dev/null +++ b/apps/maple-agent/crates/maple-agent-updater/examples/windows_signature_probe.rs @@ -0,0 +1,60 @@ +//! Manual, read-only historical Authenticode interoperability probe. The selected +//! executable is never launched; this is not installed-update qualification. + +#[cfg(windows)] +#[allow(dead_code)] +#[path = "../src/installer/windows_bundle/authenticode.rs"] +mod authenticode; + +#[cfg(windows)] +fn main() { + use sha2::{Digest, Sha256}; + use std::io::Read; + + let arguments: Vec<_> = std::env::args_os().skip(1).collect(); + assert_eq!( + arguments.len(), + 2, + "expected exact reference path and SHA256" + ); + let path = std::path::Path::new(&arguments[0]); + let expected = arguments[1].to_str().expect("expected SHA256"); + assert!(expected.len() == 64 && expected.bytes().all(|b| b.is_ascii_hexdigit())); + let mut bytes = Vec::new(); + std::fs::File::open(path) + .unwrap() + .take(64 * 1024 * 1024 + 1) + .read_to_end(&mut bytes) + .unwrap(); + assert!( + bytes.len() <= 64 * 1024 * 1024, + "reference PE exceeds bound" + ); + assert_eq!(hex::encode(Sha256::digest(&bytes)), expected); + assert!( + authenticode::verify(path), + "historical Maple admission signature was rejected" + ); + assert!( + authenticode::verify_startup_or_unsigned(path, false), + "historical Maple startup signature was rejected" + ); + println!( + "{}", + serde_json::json!({ + "schema": "maple-windows-historical-signature-probe-v1", + "reference_sha256": expected, + "admission_signature_valid": true, + "startup_signature_valid": true, + "historical_signature_interoperability": true, + "new_updater_lifecycle_qualified": false, + "final_source_release_qualified": false, + }) + ); +} + +#[cfg(not(windows))] +fn main() { + eprintln!("The historical signature probe requires native Windows."); + std::process::exit(2); +} diff --git a/apps/maple-agent/crates/maple-agent-updater/src/client.rs b/apps/maple-agent/crates/maple-agent-updater/src/client.rs index f20ae5582..6417d6af7 100644 --- a/apps/maple-agent/crates/maple-agent-updater/src/client.rs +++ b/apps/maple-agent/crates/maple-agent-updater/src/client.rs @@ -336,8 +336,8 @@ impl Updater { "unsafe prepared payload destination" ); } - tokio::fs::rename(&partial_path, &payload_path).await?; - std::fs::File::open(self.state.root.join("payloads"))?.sync_all()?; + state::replace_file(&partial_path, &payload_path)?; + state::sync_directory(&self.state.root.join("payloads"))?; self.state.save_prepared(&release)?; Ok(PreparedUpdate { release, @@ -544,16 +544,7 @@ fn validate_range(value: &str, offset: u64, total: u64) -> Result<()> { } pub(crate) async fn verify_payload(path: &Path, artifact: &Artifact) -> Result<()> { - let mut options = std::fs::OpenOptions::new(); - options.read(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - options.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC); - } - let file = options - .open(path) - .context("prepared payload is missing or unsafe")?; + let file = state::open_readonly(path).context("prepared payload is missing or unsafe")?; ensure!( file.metadata()?.file_type().is_file() && file.metadata()?.len() == artifact.size, "update payload has incorrect size" @@ -578,7 +569,7 @@ pub(crate) async fn verify_payload(path: &Path, artifact: &Artifact) -> Result<( Ok(()) } -#[cfg(all(test, unix))] +#[cfg(test)] pub(crate) fn test_updater(config: Config) -> Result { Updater::construct(config, true) } diff --git a/apps/maple-agent/crates/maple-agent-updater/src/installer.rs b/apps/maple-agent/crates/maple-agent-updater/src/installer.rs index fb513faf7..3b43be606 100644 --- a/apps/maple-agent/crates/maple-agent-updater/src/installer.rs +++ b/apps/maple-agent/crates/maple-agent-updater/src/installer.rs @@ -29,6 +29,18 @@ use std::os::unix::ffi::OsStrExt; use std::os::unix::{fs::MetadataExt, fs::OpenOptionsExt, fs::PermissionsExt}; mod linux_bundle; +mod windows_bundle; + +pub use windows_bundle::{ + activate_windows_installation, cleanup_current_windows_bundle, cleanup_windows_installation, + discover_windows_installation, preflight_windows_installation, prepare_windows_installation, + resolve_windows_launcher, validate_windows_bundle_archive, +}; +#[cfg(windows)] +pub(crate) use windows_bundle::{ + windows_atomic_replace, windows_open_private, windows_open_readonly, windows_private_directory, + windows_read_bounded, windows_sync_directory, +}; pub use linux_bundle::{ cleanup_current_linux_bundle, discover_linux_installation, validate_linux_bundle_archive, @@ -151,6 +163,9 @@ pub fn discover_installation(executable: &Path, _appimage: Option<&Path>) -> Res /// stopping the host. This is a preflight, not authority: permissions may still /// change, and callers must handle a failed atomic replacement safely. pub fn preflight_installation(installed: &Path) -> Result<()> { + if cfg!(windows) { + return windows_bundle::preflight(installed); + } #[cfg(target_os = "linux")] if linux_bundle::is_bundle_path(installed) { return linux_bundle::preflight(installed); @@ -203,7 +218,9 @@ fn replacement_access(path: &Path, rights: libc::c_int) -> Result<()> { } pub fn acquire_install_lock(installed: &Path) -> Result { - if linux_bundle::is_bundle_path(installed) { + if cfg!(windows) { + windows_bundle::validate_destination(installed)?; + } else if linux_bundle::is_bundle_path(installed) { linux_bundle::validate_destination(installed)?; } else { validate_destination(installed)?; @@ -223,9 +240,17 @@ pub fn acquire_install_lock(installed: &Path) -> Result { } })?; validate_install_lock_path(installed, &file)?; + #[cfg(windows)] + windows_bundle::validate_destination(installed)?; Ok(InstallLock { _file: file }) } +#[cfg(windows)] +fn install_lock_path(installed: &Path) -> Result { + Ok(installed.join(".install.lock")) +} + +#[cfg(not(windows))] fn install_lock_path(installed: &Path) -> Result { let parent = installed .parent() @@ -245,6 +270,14 @@ fn install_lock_path(installed: &Path) -> Result { Ok(parent.join(format!(".maple-agent-install-{digest}.lock"))) } +#[cfg(windows)] +fn open_install_lock(installed: &Path) -> Result { + let file = windows_open_private(&install_lock_path(installed)?, false)?; + validate_install_lock_path(installed, &file)?; + Ok(file) +} + +#[cfg(not(windows))] fn open_install_lock(installed: &Path) -> Result { let path = install_lock_path(installed)?; let mut create = OpenOptions::new(); @@ -311,7 +344,9 @@ fn validate_install_lock_path(installed: &Path, file: &File) -> Result<()> { /// The caller retains the application lock for this preparation's entire /// lifetime, including while Ready, until it is installed or discarded. pub fn prepare(update: &PreparedUpdate) -> Result { - if cfg!(target_os = "linux") { + if cfg!(windows) { + windows_bundle::prepare(update) + } else if cfg!(target_os = "linux") { linux_bundle::prepare(update) } else { preflight_installation(&update.installed_path)?; @@ -320,7 +355,9 @@ pub fn prepare(update: &PreparedUpdate) -> Result { } pub fn revalidate(preparation: &InstallPreparation, update: &PreparedUpdate) -> Result<()> { - if cfg!(target_os = "linux") { + if cfg!(windows) { + windows_bundle::revalidate(preparation, update) + } else if cfg!(target_os = "linux") { linux_bundle::revalidate(preparation, update) } else { preflight_installation(&update.installed_path)?; @@ -338,6 +375,9 @@ pub fn validate_archive(payload: &Path) -> Result<()> { /// receipt exists the directory may contain the prior app, so only a verified /// new startup may clear it through checkpoint cleanup. pub fn discard_preparation(preparation: &InstallPreparation) -> Result<()> { + if cfg!(windows) { + return windows_bundle::discard(preparation); + } if linux_bundle::is_bundle_path(&preparation.installed_path) { return linux_bundle::discard(preparation); } @@ -362,7 +402,9 @@ pub fn replace( preparation: &InstallPreparation, update: &PreparedUpdate, ) -> Result { - if cfg!(target_os = "linux") { + if cfg!(windows) { + windows_bundle::replace(preparation, update) + } else if cfg!(target_os = "linux") { linux_bundle::replace(preparation, update) } else { ensure_supported()?; @@ -410,7 +452,9 @@ fn replace_inner( impl InstalledUpdate { /// The installed directory remains state identity; Linux executes AppRun. pub fn restart_path(&self) -> PathBuf { - if linux_bundle::is_bundle_path(&self.installed_path) { + if cfg!(windows) { + self.installed_path.join("maple-agent.exe") + } else if linux_bundle::is_bundle_path(&self.installed_path) { self.installed_path.join("AppRun") } else { self.installed_path.clone() @@ -737,6 +781,10 @@ fn validate_staging(installed: &Path, root: &Path) -> Result<()> { } fn open_regular(path: &Path, create: bool, exclusive: bool) -> Result { + #[cfg(windows)] + if !create { + return windows_open_readonly(path); + } let mut options = OpenOptions::new(); options .read(true) @@ -964,7 +1012,11 @@ fn scan_archive_with_alias( if expanded > MAX_EXPANDED_BYTES { return Err(InstallError::Invalid("archive expansion limit exceeded")); } - let digest = if app_name != APP_NAME && name == format!("{app_name}/bundle.json") { + let digest = if app_name != APP_NAME + && (name == format!("{app_name}/bundle.json") + || (app_name == "maple-agent-windows" + && name == format!("{app_name}/bundle-info.json"))) + { if size > 16 * 1024 { return Err(InstallError::Invalid("bundle descriptor is too large")); } @@ -1459,9 +1511,16 @@ fn write_receipt(installed: &InstalledUpdate) -> Result<()> { } fn sync_directory(path: &Path) -> Result<()> { - let file = File::open(path)?; - file.sync_all()?; - Ok(()) + #[cfg(windows)] + { + windows_sync_directory(path) + } + #[cfg(not(windows))] + { + let file = File::open(path)?; + file.sync_all()?; + Ok(()) + } } fn swap_bundles(installed: &Path, staged: &Path) -> Result<()> { @@ -1619,6 +1678,7 @@ mod tests { macos: Some(dmg), linux_appimage: None, linux_bundle: None, + windows: None, }, }, envelope: "test-only authenticated caller fixture".into(), diff --git a/apps/maple-agent/crates/maple-agent-updater/src/installer/linux_bundle_tests.rs b/apps/maple-agent/crates/maple-agent-updater/src/installer/linux_bundle_tests.rs index c335a6502..f8b8ba1d0 100644 --- a/apps/maple-agent/crates/maple-agent-updater/src/installer/linux_bundle_tests.rs +++ b/apps/maple-agent/crates/maple-agent-updater/src/installer/linux_bundle_tests.rs @@ -126,6 +126,7 @@ fn signed_release(payload: &Path, bundle: &Bundle) -> crate::VerifiedRelease { macos: None, linux_appimage: None, linux_bundle: Some(artifact), + windows: None, }, }; let text = serde_json::to_string(&manifest).unwrap(); diff --git a/apps/maple-agent/crates/maple-agent-updater/src/installer/windows_bundle.rs b/apps/maple-agent/crates/maple-agent-updater/src/installer/windows_bundle.rs new file mode 100644 index 000000000..e89aafbb0 --- /dev/null +++ b/apps/maple-agent/crates/maple-agent-updater/src/installer/windows_bundle.rs @@ -0,0 +1,2056 @@ +//! Windows uses immutable version directories behind a stable signed launcher. +//! Committing current.json never replaces a running executable. Retained old +//! generations remain usable through interruptions and are deleted only after +//! the new application's authenticated startup checkpoint. +#![cfg_attr(not(windows), allow(dead_code))] + +use super::*; +use crate::{Url, manifest}; +use std::io::{Seek, SeekFrom}; + +#[cfg(windows)] +mod authenticode; +#[cfg(windows)] +mod windows_fs; + +const ROOT: &str = "maple-agent-windows"; +const DESCRIPTOR: &str = "bundle-info.json"; +const POINTER: &str = "current.json"; +const MANUAL_INTENT: &str = ".installer-intent.json"; +const UNINSTALL_INTENT: &str = ".uninstaller-intent.json"; +const TRANSACTION_TEMP_PREFIX: &str = ".maple-transaction-"; +const POINTER_FORMAT: &str = "maple-agent-windows-current-v1"; +const RECEIPT_FORMAT: &str = "maple-agent-windows-install-v1"; +const DESCRIPTOR_LIMIT: u64 = 16 * 1024; +const RECEIPT_LIMIT: u64 = (manifest::MAX_ENVELOPE_BYTES * 6 + 16 * 1024) as u64; +const FILES: &[&str] = &[ + "maple-agent.exe", + "LICENSE.txt", + "build-info.json", + DESCRIPTOR, + "notices/THIRD_PARTY_NOTICES.txt", + "notices/THIRD_PARTY_NOTICES.html", + "notices/manifest.json", + "notices/wordlists/eff-large-wordlist.txt", + "notices/wordlists/SOURCES.md", +]; +const DIRS: &[&str] = &["", "notices", "notices/wordlists"]; + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +struct Bundle { + format: String, + product: String, + profile: String, + version: String, + build: String, + source_sha: String, + bundle_id: String, + entrypoint: String, +} + +fn profile_identity(profile: &str) -> Result<&'static str> { + match profile { + "prod" => Ok("cloud.opensecret.maple.agent"), + "dev" => Ok("cloud.opensecret.maple.agent.dev"), + _ => Err(InstallError::Invalid("invalid Windows bundle profile")), + } +} + +impl Bundle { + fn parse(bytes: &[u8], profile: &str) -> Result { + if bytes.is_empty() || bytes.len() as u64 > DESCRIPTOR_LIMIT { + return Err(InstallError::Invalid("Windows bundle descriptor size")); + } + let bundle: Self = serde_json::from_slice(bytes) + .map_err(|_| InstallError::Invalid("invalid Windows bundle descriptor"))?; + if bundle.format != "maple-agent-windows-bundle-v1" + || bundle.product != "maple-agent" + || bundle.profile != profile + || bundle.bundle_id != profile_identity(profile)? + || bundle.entrypoint != "maple-agent.exe" + || !valid_build(&bundle.build) + || !valid_source(&bundle.source_sha) + || manifest::stable_version(&bundle.version).is_err() + { + return Err(InstallError::Invalid("Windows bundle identity changed")); + } + Ok(bundle) + } + + fn generation(&self) -> String { + format!("{}-{}", self.version, self.build) + } + + fn matches_release(&self, release: &crate::VerifiedRelease) -> Result<()> { + if self.profile != "prod" + || self.version != release.manifest.version + || self.build != release.manifest.build + || self.source_sha != release.manifest.source_sha + { + return Err(InstallError::Invalid( + "Windows bundle differs from signed release", + )); + } + Ok(()) + } +} + +fn valid_build(build: &str) -> bool { + !build.is_empty() + && build.len() <= 18 + && !build.starts_with('0') + && build.bytes().all(|b| b.is_ascii_digit()) +} +fn valid_source(source: &str) -> bool { + source.len() == 40 + && source + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) +} +fn valid_generation(generation: &str) -> bool { + generation.len() <= 84 + && generation.rsplit_once('-').is_some_and(|(version, build)| { + manifest::stable_version(version).is_ok() && valid_build(build) + }) +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +struct Current { + format: String, + generation: String, +} +impl Current { + fn new(bundle: &Bundle) -> Self { + Self { + format: POINTER_FORMAT.into(), + generation: bundle.generation(), + } + } + fn parse(bytes: &[u8]) -> Result { + if bytes.is_empty() || bytes.len() > 1024 { + return Err(InstallError::Invalid("Windows pointer size")); + } + let pointer: Self = serde_json::from_slice(bytes) + .map_err(|_| InstallError::Invalid("invalid Windows pointer"))?; + if pointer.format != POINTER_FORMAT || !valid_generation(&pointer.generation) { + return Err(InstallError::Invalid("Windows pointer identity changed")); + } + Ok(pointer) + } + fn path(&self, root: &Path) -> PathBuf { + root.join("versions").join(&self.generation) + } +} + +fn read_bounded(path: &Path, limit: u64) -> Result> { + #[cfg(windows)] + { + windows_fs::read_bounded(path, limit)? + .ok_or_else(|| InstallError::Io(io::Error::from(io::ErrorKind::NotFound))) + } + #[cfg(not(windows))] + { + let file = open_regular(path, false, false)?; + if file.metadata()?.len() > limit { + return Err(InstallError::Invalid("Windows metadata size")); + } + let mut bytes = Vec::new(); + file.take(limit + 1).read_to_end(&mut bytes)?; + if bytes.len() as u64 > limit { + return Err(InstallError::Invalid("Windows metadata grew")); + } + Ok(bytes) + } +} + +fn archive( + payload: &Path, + output: Option<&Path>, + profile: &str, +) -> Result<(BTreeMap, Bundle)> { + // Inspect the entire namespace before creating any files. Only fixed plain + // names are admitted, excluding ADS, case aliases, links and extra members. + let (nodes, descriptor) = scan_archive_root(payload, None, ROOT)?; + validate_inventory(&nodes)?; + let bundle = Bundle::parse( + &descriptor.ok_or(InstallError::Invalid("missing Windows bundle descriptor"))?, + profile, + )?; + if let Some(output) = output { + let extracted = scan_archive_root(payload, Some(output), ROOT)?; + if extracted.0 != nodes { + return Err(InstallError::Invalid( + "Windows archive changed during extraction", + )); + } + } + Ok((nodes, bundle)) +} + +fn validate_inventory(nodes: &BTreeMap) -> Result<()> { + let expected: BTreeSet = FILES + .iter() + .map(|file| format!("{ROOT}/{file}")) + .chain(DIRS.iter().map(|dir| { + if dir.is_empty() { + ROOT.into() + } else { + format!("{ROOT}/{dir}") + } + })) + .collect(); + if nodes.keys().cloned().collect::>() != expected { + return Err(InstallError::Invalid("Windows bundle inventory differs")); + } + for file in FILES { + if !matches!( + nodes.get(&format!("{ROOT}/{file}")), + Some(Node::File { mode: 0o644, .. }) + ) { + return Err(InstallError::Invalid( + "Windows bundle requires regular files", + )); + } + } + for dir in DIRS { + let path = if dir.is_empty() { + ROOT.into() + } else { + format!("{ROOT}/{dir}") + }; + if !matches!(nodes.get(&path), Some(Node::Directory)) { + return Err(InstallError::Invalid( + "Windows bundle requires ordinary directories", + )); + } + } + Ok(()) +} + +/// Data-only bounded archive verification, shared with release qualification. +pub fn validate_windows_bundle_archive(payload: &Path, profile: &str) -> Result<()> { + let _ = archive(payload, None, profile)?; + Ok(()) +} + +fn checked_entry(path: &Path) -> Result { + let metadata = fs::symlink_metadata(path)?; + if metadata.file_type().is_symlink() { + return Err(InstallError::Invalid("Windows path is a link")); + } + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + if metadata.file_attributes() & 0x400 != 0 { + return Err(InstallError::Invalid("Windows path is a reparse point")); + } + windows_fs::validate_path(path)?; + if metadata.is_dir() { + windows_fs::private_directory(path)?; + } + } + Ok(metadata) +} + +fn scan_generation(directory: &Path) -> Result> { + let mut nodes = BTreeMap::new(); + let mut pending = vec![(directory.to_owned(), ROOT.to_owned())]; + let mut total = 0u64; + while let Some((path, name)) = pending.pop() { + if nodes.len() >= 32 { + return Err(InstallError::Invalid( + "unexpected Windows generation entries", + )); + } + let metadata = checked_entry(&path)?; + let node = if metadata.is_dir() { + for entry in fs::read_dir(&path)?.take(33) { + let entry = entry?; + let leaf = entry + .file_name() + .into_string() + .map_err(|_| InstallError::Invalid("Windows filename encoding"))?; + if leaf.is_empty() + || !leaf + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"-_.".contains(&b)) + { + return Err(InstallError::Invalid("unexpected Windows filename")); + } + pending.push((entry.path(), format!("{name}/{leaf}"))); + } + Node::Directory + } else if metadata.is_file() { + #[cfg(unix)] + if metadata.nlink() != 1 { + return Err(InstallError::Invalid("Windows payload is hard-linked")); + } + total = total + .checked_add(metadata.len()) + .ok_or(InstallError::Invalid("Windows size overflow"))?; + if metadata.len() > MAX_FILE_BYTES || total > MAX_EXPANDED_BYTES { + return Err(InstallError::Invalid("Windows generation size")); + } + let mut file = open_regular(&path, false, false)?; + Node::File { + mode: 0o644, + size: metadata.len(), + digest: hash_reader(&mut file, metadata.len())?, + } + } else { + return Err(InstallError::Invalid("Windows payload has a special file")); + }; + if nodes.insert(name, node).is_some() { + return Err(InstallError::Invalid("duplicate Windows tree entry")); + } + } + validate_inventory(&nodes)?; + Ok(nodes) +} + +fn descriptor(directory: &Path, profile: &str) -> Result { + Bundle::parse( + &read_bounded(&directory.join(DESCRIPTOR), DESCRIPTOR_LIMIT)?, + profile, + ) +} + +// Read the signed .maple PE section without executing downloaded code. Windows +// Authenticode validates the executable; metadata must additionally identify +// this product/profile/version/source rather than any other Maple-signed app. +fn pe_build_info(path: &Path) -> Result { + let mut file = open_regular(path, false, false)?; + let file_size = file.metadata()?.len(); + let mut read = |offset: u64, length: usize| -> Result> { + if offset + .checked_add(length as u64) + .is_none_or(|end| end > file_size) + { + return Err(InstallError::Invalid("truncated Windows PE")); + } + file.seek(SeekFrom::Start(offset))?; + let mut bytes = vec![0; length]; + file.read_exact(&mut bytes)?; + Ok(bytes) + }; + let dos = read(0, 64)?; + if &dos[..2] != b"MZ" { + return Err(InstallError::CodeSignature); + } + let position = u32::from_le_bytes(dos[60..64].try_into().unwrap()) as u64; + if !(64..=1024 * 1024).contains(&position) { + return Err(InstallError::CodeSignature); + } + let header = read(position, 24)?; + let u16at = |bytes: &[u8], n: usize| u16::from_le_bytes(bytes[n..n + 2].try_into().unwrap()); + let u32at = |bytes: &[u8], n: usize| u32::from_le_bytes(bytes[n..n + 4].try_into().unwrap()); + let count = usize::from(u16at(&header, 6)); + let optional_size = usize::from(u16at(&header, 20)); + let flags = u16at(&header, 22); + if &header[..4] != b"PE\0\0" + || u16at(&header, 4) != 0x8664 + || !(1..=96).contains(&count) + || !(112..=4096).contains(&optional_size) + || flags & 2 == 0 + || flags & 0x2000 != 0 + { + return Err(InstallError::CodeSignature); + } + let optional = read(position + 24, optional_size)?; + if u16at(&optional, 0) != 0x20b { + return Err(InstallError::CodeSignature); + } + let headers_size = u32at(&optional, 60) as u64; + let table_offset = position + 24 + optional_size as u64; + if table_offset + count as u64 * 40 > headers_size + || headers_size > 1024 * 1024 + || headers_size > file_size + { + return Err(InstallError::CodeSignature); + } + let table = read(table_offset, count * 40)?; + let mut found = None; + let mut ranges = Vec::new(); + for section in table.as_chunks::<40>().0 { + let size = u32at(section, 16) as u64; + let offset = u32at(section, 20) as u64; + if size > 0 { + if offset < headers_size + || offset.checked_add(size).is_none_or(|end| end > file_size) + || ranges + .iter() + .any(|(start, end)| offset < *end && *start < offset + size) + { + return Err(InstallError::CodeSignature); + } + ranges.push((offset, offset + size)); + } + if §ion[..8] != b".maple\0\0" { + continue; + } + if found.is_some() { + return Err(InstallError::CodeSignature); + } + let length = u32at(section, 8) as usize; + let flags = u32at(section, 36); + if length == 0 + || length > DESCRIPTOR_LIMIT as usize + || size < length as u64 + || size > DESCRIPTOR_LIMIT + 65536 + || flags & 0x40000040 != 0x40000040 + || flags & 0xa0000000 != 0 + { + return Err(InstallError::CodeSignature); + } + let bytes = read(offset, size as usize)?; + if bytes[length..].iter().any(|b| *b != 0) { + return Err(InstallError::CodeSignature); + } + found = Some( + serde_json::from_slice(&bytes[..length]).map_err(|_| InstallError::CodeSignature)?, + ); + } + found.ok_or(InstallError::CodeSignature) +} + +#[derive(Clone, Copy)] +enum IdentityPurpose { + Admission, + Startup, +} + +fn verify_identity(directory: &Path, bundle: &Bundle) -> Result<()> { + verify_identity_for(directory, bundle, IdentityPurpose::Admission) +} + +fn verify_identity_metadata(directory: &Path, bundle: &Bundle) -> Result { + let info: serde_json::Value = serde_json::from_slice(&read_bounded( + &directory.join("build-info.json"), + DESCRIPTOR_LIMIT, + )?) + .map_err(|_| InstallError::Invalid("invalid Windows build metadata"))?; + let executable = directory.join("maple-agent.exe"); + let embedded = pe_build_info(&executable)?; + if embedded != info + || info["profile"] != bundle.profile + || info["bundle_id"] != bundle.bundle_id + || info["version"] != bundle.version + || info["source_sha"] != bundle.source_sha + || info["update_feed_url"] + != if bundle.profile == "prod" { + serde_json::json!("https://updates.maple.ai/agent/stable/windows/latest.json") + } else { + serde_json::Value::Null + } + { + return Err(InstallError::CodeSignature); + } + Ok(executable) +} + +fn verify_identity_for(directory: &Path, bundle: &Bundle, purpose: IdentityPurpose) -> Result<()> { + let executable = verify_identity_metadata(directory, bundle)?; + #[cfg(windows)] + if !match purpose { + IdentityPurpose::Admission => { + authenticode::verify_or_unsigned(&executable, bundle.profile == "dev") + } + IdentityPurpose::Startup => { + authenticode::verify_startup_or_unsigned(&executable, bundle.profile == "dev") + } + } { + return Err(InstallError::CodeSignature); + } + #[cfg(not(windows))] + let _ = (purpose, executable); + #[cfg(not(windows))] + { + Err(InstallError::NotifyOnly( + "native Windows signature verification requires Windows", + )) + } + #[cfg(windows)] + Ok(()) +} + +fn generation(root: &Path, profile: &str) -> Result<(Current, PathBuf, Bundle)> { + generation_for(root, profile, IdentityPurpose::Admission) +} + +fn generation_for( + root: &Path, + profile: &str, + purpose: IdentityPurpose, +) -> Result<(Current, PathBuf, Bundle)> { + let current = Current::parse(&read_bounded(&root.join(POINTER), 1024)?)?; + let path = current.path(root); + let bundle = descriptor(&path, profile)?; + if bundle.generation() != current.generation { + return Err(InstallError::Invalid( + "Windows generation differs from pointer", + )); + } + let _ = scan_generation(&path)?; + verify_identity_for(&path, &bundle, purpose)?; + Ok((current, path, bundle)) +} + +fn expected_artifact(update: &PreparedUpdate) -> Result<&Artifact> { + manifest::validate_manifest(&update.release.manifest) + .map_err(|_| InstallError::Invalid("Windows manifest fields changed"))?; + update + .release + .artifact(Target::WindowsX86_64) + .map_err(|_| InstallError::Invalid("missing Windows updater bundle")) +} + +#[cfg(windows)] +fn registered_root(root: &Path, profile: &str) -> Result<()> { + use std::os::windows::ffi::OsStringExt; + use windows_sys::Win32::Foundation::ERROR_SUCCESS; + use windows_sys::Win32::System::Registry::*; + let key: Vec = format!( + "Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{}", + profile_identity(profile)? + ) + .encode_utf16() + .chain([0]) + .collect(); + let mut handle = std::ptr::null_mut(); + if unsafe { + RegOpenKeyExW( + HKEY_CURRENT_USER, + key.as_ptr(), + 0, + KEY_QUERY_VALUE | KEY_WOW64_32KEY, + &mut handle, + ) + } != ERROR_SUCCESS + { + return Err(InstallError::NotifyOnly( + "run the registered per-user Windows installation", + )); + } + struct Key(HKEY); + impl Drop for Key { + fn drop(&mut self) { + unsafe { + RegCloseKey(self.0); + } + } + } + let handle = Key(handle); + let name: Vec = "InstallLocation".encode_utf16().chain([0]).collect(); + let mut kind = 0; + let mut length = 0; + if unsafe { + RegQueryValueExW( + handle.0, + name.as_ptr(), + std::ptr::null(), + &mut kind, + std::ptr::null_mut(), + &mut length, + ) + } != ERROR_SUCCESS + || kind != REG_SZ + || length < 4 + || length > 65536 + || length % 2 != 0 + { + return Err(InstallError::Invalid( + "Windows install registration changed", + )); + } + let mut value = vec![0u16; length as usize / 2]; + if unsafe { + RegQueryValueExW( + handle.0, + name.as_ptr(), + std::ptr::null(), + &mut kind, + value.as_mut_ptr().cast(), + &mut length, + ) + } != ERROR_SUCCESS + || kind != REG_SZ + || value.last() != Some(&0) + || value[..value.len() - 1].contains(&0) + { + return Err(InstallError::Invalid( + "Windows install registration changed", + )); + } + let path = PathBuf::from(std::ffi::OsString::from_wide(&value[..value.len() - 1])); + if path.canonicalize()? != root { + return Err(InstallError::Invalid( + "Windows registration belongs to another installation", + )); + } + Ok(()) +} + +pub(super) fn validate_destination(root: &Path) -> Result<()> { + validate_destination_for(root, IdentityPurpose::Admission) +} + +fn validate_destination_for(root: &Path, purpose: IdentityPurpose) -> Result<()> { + #[cfg(windows)] + { + windows_fs::private_directory(root)?; + if root.join(MANUAL_INTENT).try_exists()? { + if let Ok((_, _, current)) = generation_for(root, "prod", IdentityPurpose::Startup) { + recover_completed_manual_intent(root, ¤t)?; + } + } + if root.join(MANUAL_INTENT).try_exists()? || root.join(UNINSTALL_INTENT).try_exists()? { + return Err(InstallError::Busy); + } + windows_fs::private_directory(&root.join("versions"))?; + if !checked_entry(root)?.is_dir() || !checked_entry(&root.join("versions"))?.is_dir() { + return Err(InstallError::Invalid( + "Windows installation directories changed", + )); + } + registered_root(root, "prod")?; + let bootstrap = pe_build_info(&root.join("maple-agent.exe"))?; + if bootstrap["profile"] != "prod" + || bootstrap["bundle_id"] != profile_identity("prod")? + || !match purpose { + IdentityPurpose::Admission => authenticode::verify(&root.join("maple-agent.exe")), + IdentityPurpose::Startup => { + authenticode::verify_startup_or_unsigned(&root.join("maple-agent.exe"), false) + } + } + { + return Err(InstallError::CodeSignature); + } + let _ = generation_for(root, "prod", purpose)?; + Ok(()) + } + #[cfg(not(windows))] + { + let _ = (root, purpose); + Err(InstallError::NotifyOnly( + "Windows installation requires native Windows", + )) + } +} + +/// Resolve only a root bootstrap or a registered generation, never an arbitrary +/// path supplied by a pointer. This is called before normal app/host startup. +pub fn resolve_windows_launcher(executable: &Path, profile: &str) -> Result> { + #[cfg(windows)] + { + let executable = executable.canonicalize()?; + checked_entry(&executable)?; + let parent = executable + .parent() + .ok_or(InstallError::Invalid("Windows executable parent"))?; + if executable + .file_name() + .is_none_or(|leaf| leaf != "maple-agent.exe") + { + return Ok(None); + } + // A generation executes normally. It is still checked against its + // descriptor; old generations may remain alive during pointer commit. + if let Some(versions) = parent + .parent() + .filter(|p| p.file_name().is_some_and(|n| n == "versions")) + { + if !parent + .file_name() + .and_then(|n| n.to_str()) + .is_some_and(valid_generation) + { + return Err(InstallError::Invalid("invalid Windows generation path")); + } + let root = versions + .parent() + .ok_or(InstallError::Invalid("missing Windows root"))?; + windows_fs::private_directory(root)?; + registered_root(root, profile)?; + if live_uninstall_owner(root, profile)? { + return Err(InstallError::Busy); + } + let pointer = Current::parse(&read_bounded(&root.join(POINTER), 1024)?)?; + if pointer.path(root) != parent { + return Err(InstallError::Invalid( + "inactive Windows generation cannot start", + )); + } + let bundle = descriptor(parent, profile)?; + if bundle.generation() != parent.file_name().unwrap().to_string_lossy() { + return Err(InstallError::Invalid("Windows generation identity changed")); + } + let _ = scan_generation(parent)?; + verify_identity_for(parent, &bundle, IdentityPurpose::Startup)?; + recover_completed_manual_intent(root, &bundle)?; + return Ok(None); + } + if !parent.join(POINTER).try_exists()? { + if parent.join("versions").try_exists()? || registered_root(parent, profile).is_ok() { + return Err(InstallError::Invalid( + "installed Windows pointer is missing; retry the installer", + )); + } + return Err(InstallError::NotifyOnly( + "run the registered Windows installation; reinstall to restore its pointer", + )); + } + windows_fs::private_directory(parent)?; + registered_root(parent, profile)?; + if live_uninstall_owner(parent, profile)? { + return Err(InstallError::Busy); + } + let bootstrap = pe_build_info(&executable)?; + if bootstrap["profile"] != profile + || bootstrap["bundle_id"] != profile_identity(profile)? + || !authenticode::verify_startup_or_unsigned(&executable, profile == "dev") + { + return Err(InstallError::CodeSignature); + } + let (_, path, bundle) = generation_for(parent, profile, IdentityPurpose::Startup)?; + recover_completed_manual_intent(parent, &bundle)?; + // Deliberately construct from validated fixed segments, not JSON paths. + Ok(Some(path.join("maple-agent.exe"))) + } + #[cfg(not(windows))] + { + let _ = (executable, profile); + Ok(None) + } +} + +pub fn discover_windows_installation( + executable: &Path, + version: &str, + source_sha: &str, +) -> Result { + #[cfg(windows)] + { + let executable = executable.canonicalize()?; + let directory = executable + .parent() + .ok_or(InstallError::Invalid("missing Windows generation"))?; + let versions = directory + .parent() + .filter(|p| p.file_name().is_some_and(|name| name == "versions")) + .ok_or(InstallError::NotifyOnly( + "run the installed Windows launcher", + ))?; + let root = versions + .parent() + .ok_or(InstallError::Invalid("missing Windows installation"))?; + validate_destination_for(root, IdentityPurpose::Startup)?; + let (_, current, bundle) = generation_for(root, "prod", IdentityPurpose::Startup)?; + if current != directory || bundle.version != version || bundle.source_sha != source_sha { + return Err(InstallError::Invalid( + "Windows running identity differs from installed generation", + )); + } + Ok(root.to_owned()) + } + #[cfg(not(windows))] + { + let _ = (executable, version, source_sha); + Err(InstallError::NotifyOnly( + "Windows installation requires native Windows", + )) + } +} + +pub(super) fn preflight(root: &Path) -> Result<()> { + validate_destination(root)?; + #[cfg(windows)] + { + let probe = tempfile::NamedTempFile::new_in(root)?; + windows_fs::open_private(probe.path(), false)?.sync_all()?; + probe.close()?; + } + let _lock = open_install_lock(root)?; + Ok(()) +} + +fn stage_prefix(root: &Path) -> String { + format!( + "{STAGING_PREFIX}{}-", + hex::encode(Sha256::digest(root.as_os_str().as_encoded_bytes())) + ) +} +fn validate_stage(root: &Path, stage: &Path) -> Result<()> { + if stage.parent() != Some(root) + || !stage + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| { + name.strip_prefix(&stage_prefix(root)) + .is_some_and(|suffix| { + suffix.len() == STAGING_RANDOM_LENGTH + && suffix.bytes().all(|b| b.is_ascii_alphanumeric()) + }) + }) + || stage.canonicalize()? != stage + || !checked_entry(stage)?.is_dir() + { + return Err(InstallError::Invalid("Windows staging directory changed")); + } + Ok(()) +} + +pub(super) fn prepare(update: &PreparedUpdate) -> Result { + preflight(&update.installed_path)?; + let (_, _, current) = generation(&update.installed_path, "prod")?; + if manifest::stable_version(&update.release.manifest.version) + .map_err(|_| InstallError::Invalid("Windows proposed version"))? + <= manifest::stable_version(¤t.version) + .map_err(|_| InstallError::Invalid("Windows current version"))? + { + return Err(InstallError::Invalid("Windows version is not newer")); + } + let artifact = expected_artifact(update)?; + let temporary = tempfile::Builder::new() + .prefix(&stage_prefix(&update.installed_path)) + .rand_bytes(STAGING_RANDOM_LENGTH) + .tempdir_in(&update.installed_path)?; + #[cfg(windows)] + windows_fs::private_directory(temporary.path())?; + let payload_path = temporary.path().join("payload"); + copy_verified_payload(&update.payload_path, &payload_path, artifact)?; + let (expected, bundle) = archive(&payload_path, Some(temporary.path()), "prod")?; + bundle.matches_release(&update.release)?; + let staged_path = temporary.path().join(ROOT); + if scan_generation(&staged_path)? != expected { + return Err(InstallError::Invalid( + "staged Windows bytes differ from signed archive", + )); + } + verify_identity(&staged_path, &bundle)?; + windows_sync_directory(temporary.path())?; + let preparation = InstallPreparation { + installed_path: update.installed_path.clone(), + staging_dir: temporary.path().to_owned(), + payload_path, + staged_path, + version: bundle.version, + build: bundle.build, + size: artifact.size, + sha256: artifact.sha256.clone(), + }; + let _ = temporary.keep(); + Ok(preparation) +} + +pub(super) fn revalidate(prepared: &InstallPreparation, update: &PreparedUpdate) -> Result<()> { + validate_destination(&update.installed_path)?; + validate_stage(&update.installed_path, &prepared.staging_dir)?; + let artifact = expected_artifact(update)?; + if prepared.installed_path != update.installed_path + || prepared.payload_path != prepared.staging_dir.join("payload") + || prepared.staged_path != prepared.staging_dir.join(ROOT) + || prepared.version != update.release.manifest.version + || prepared.build != update.release.manifest.build + || prepared.size != artifact.size + || prepared.sha256 != artifact.sha256 + || prepared.staging_dir.join(RECEIPT_NAME).exists() + { + return Err(InstallError::Invalid("Windows preparation changed")); + } + let (_, _, current) = generation(&update.installed_path, "prod")?; + if manifest::stable_version(&prepared.version) + .map_err(|_| InstallError::Invalid("Windows prepared version"))? + <= manifest::stable_version(¤t.version) + .map_err(|_| InstallError::Invalid("Windows current version"))? + { + return Err(InstallError::Invalid("Windows version is not newer")); + } + verify_payload(&prepared.payload_path, artifact)?; + let (expected, bundle) = archive(&prepared.payload_path, None, "prod")?; + bundle.matches_release(&update.release)?; + if scan_generation(&prepared.staged_path)? != expected { + return Err(InstallError::Invalid("Windows staged bytes changed")); + } + verify_identity(&prepared.staged_path, &bundle) +} + +pub(super) fn discard(prepared: &InstallPreparation) -> Result<()> { + validate_destination(&prepared.installed_path)?; + validate_stage(&prepared.installed_path, &prepared.staging_dir)?; + if prepared.payload_path != prepared.staging_dir.join("payload") + || prepared.staged_path != prepared.staging_dir.join(ROOT) + || prepared.staging_dir.join(RECEIPT_NAME).exists() + { + return Err(InstallError::Invalid( + "cannot discard retained Windows install", + )); + } + safe_remove_tree(&prepared.staging_dir)?; + windows_sync_directory(&prepared.installed_path) +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct Receipt { + format: String, + installed: InstalledUpdate, + envelope: String, +} + +fn write_private_json(path: &Path, value: &T) -> Result<()> { + let bytes = serde_json::to_vec(value) + .map_err(|_| InstallError::Invalid("Windows transaction serialization"))?; + let parent = path + .parent() + .ok_or(InstallError::Invalid("missing Windows transaction parent"))?; + let mut temporary = tempfile::Builder::new() + .prefix(TRANSACTION_TEMP_PREFIX) + .rand_bytes(STAGING_RANDOM_LENGTH) + .tempfile_in(parent)?; + temporary.write_all(&bytes)?; + temporary.as_file().sync_all()?; + let temp = temporary.into_temp_path(); + windows_atomic_replace(&temp, path)?; + // MoveFileEx consumed the original path. TempPath's best-effort cleanup is a no-op. + Ok(()) +} + +pub(super) fn replace( + prepared: &InstallPreparation, + update: &PreparedUpdate, +) -> Result { + revalidate(prepared, update)?; + let (previous, old_path, _) = generation(&prepared.installed_path, "prod")?; + let (_, bundle) = archive(&prepared.payload_path, None, "prod")?; + let next = Current::new(&bundle); + if next.generation == previous.generation { + return Err(InstallError::Invalid("Windows generation already current")); + } + let installed = InstalledUpdate { + installed_path: prepared.installed_path.clone(), + staging_dir: prepared.staging_dir.clone(), + backup_path: old_path, + version: prepared.version.clone(), + build: prepared.build.clone(), + durability_warning: false, + }; + let receipt = Receipt { + format: RECEIPT_FORMAT.into(), + installed: installed.clone(), + envelope: update.release.envelope.clone(), + }; + write_private_json(&prepared.staging_dir.join(RECEIPT_NAME), &receipt)?; + commit_generation( + &prepared.installed_path, + &prepared.staged_path, + &next, + &prepared.staging_dir.join(RECEIPT_NAME), + || Ok(()), + )?; + // Atomic pointer publication is the commit point. The prior generation is + // untouched, and the running old executable remains valid until restart. + Ok(installed) +} + +// The same filesystem commit primitive is exercised by native interruption +// tests. Authentication happens immediately above this function in replace; +// no test verifier or runtime fault switch can bypass it in production. +fn commit_generation( + root: &Path, + staged: &Path, + next: &Current, + receipt_path: &Path, + before_publish: impl FnOnce() -> Result<()>, +) -> Result<()> { + if !valid_generation(&next.generation) || next.format != POINTER_FORMAT { + return Err(InstallError::Invalid("Windows commit pointer changed")); + } + let next_path = next.path(root); + let reused = next_path.exists(); + let result = (|| -> Result<()> { + if reused { + if scan_generation(&next_path)? != scan_generation(staged)? { + return Err(InstallError::Invalid("Windows generation collision")); + } + } else { + fs::rename(staged, &next_path)?; + } + windows_sync_directory(&root.join("versions"))?; + before_publish()?; + write_private_json(&root.join(POINTER), next) + })(); + if let Err(error) = result { + // No pointer was committed on any reported failure. A failure to move + // back leaves authenticated new bytes as an inactive generation; retry + // compares every byte and can reuse it without touching the old one. + if !reused && next_path.exists() { + let _ = fs::rename(&next_path, staged); + } + let _ = fs::remove_file(receipt_path); + return Err(error); + } + Ok(()) +} + +fn safe_remove_tree(root: &Path) -> Result<()> { + let metadata = checked_entry(root)?; + if metadata.is_dir() { + for entry in fs::read_dir(root)? { + safe_remove_tree(&entry?.path())?; + } + fs::remove_dir(root)?; + } else if metadata.is_file() { + fs::remove_file(root)?; + } else { + return Err(InstallError::Invalid( + "Windows removal encountered a special file", + )); + } + Ok(()) +} + +/// Authenticate a new startup before deleting previous bytes or staged state. +pub fn cleanup_current_windows_bundle( + root: &Path, + version: &str, + source: &str, + origin: &Url, + keys: &[String], +) -> Result { + validate_destination(root)?; + let _lock = acquire_install_lock(root)?; + let (pointer, current_path, current) = generation(root, "prod")?; + if current.version != version || current.source_sha != source { + return Err(InstallError::Invalid("Windows startup identity changed")); + } + let mut cleaned = 0; + for entry in fs::read_dir(root)?.take(4096) { + let entry = entry?; + let stage = entry.path(); + if !entry + .file_name() + .to_str() + .is_some_and(|name| name.starts_with(&stage_prefix(root))) + || validate_stage(root, &stage).is_err() + { + continue; + } + let Ok(bytes) = read_bounded(&stage.join(RECEIPT_NAME), RECEIPT_LIMIT) else { + continue; + }; + let Ok(receipt) = serde_json::from_slice::(&bytes) else { + continue; + }; + let authenticated = (|| -> Result<()> { + if receipt.format != RECEIPT_FORMAT + || receipt.installed.installed_path != root + || receipt.installed.staging_dir != stage + || receipt.installed.version != version + || receipt.installed.build != current.build + || receipt.installed.backup_path == current_path + || receipt.installed.backup_path.parent() != Some(&root.join("versions")) + || !receipt + .installed + .backup_path + .file_name() + .and_then(|x| x.to_str()) + .is_some_and(valid_generation) + { + return Err(InstallError::Invalid("Windows retained receipt changed")); + } + let release = manifest::verify_envelope(receipt.envelope.as_bytes(), origin, keys) + .map_err(|_| InstallError::CodeSignature)?; + current.matches_release(&release)?; + let artifact = release + .artifact(Target::WindowsX86_64) + .map_err(|_| InstallError::Invalid("Windows receipt target"))?; + verify_payload(&stage.join("payload"), artifact)?; + let (expected, bundle) = archive(&stage.join("payload"), None, "prod")?; + bundle.matches_release(&release)?; + if scan_generation(¤t_path)? != expected + || pointer.generation != bundle.generation() + { + return Err(InstallError::Invalid("Windows committed bytes differ")); + } + if receipt.installed.backup_path.exists() { + let old = descriptor(&receipt.installed.backup_path, "prod")?; + if old.generation() + != receipt + .installed + .backup_path + .file_name() + .unwrap() + .to_string_lossy() + { + return Err(InstallError::Invalid("Windows backup identity changed")); + } + let _ = scan_generation(&receipt.installed.backup_path)?; + verify_identity(&receipt.installed.backup_path, &old)?; + #[cfg(windows)] + let guard = Some(windows_fs::open_executable_mutation_guard( + &receipt.installed.backup_path.join("maple-agent.exe"), + )?); + #[cfg(not(windows))] + let guard = None; + remove_generation_files(&receipt.installed.backup_path, guard)?; + } + safe_remove_tree(&stage)?; + windows_sync_directory(root)?; + Ok(()) + })(); + if authenticated.is_ok() { + cleaned += 1; + } + } + Ok(cleaned) +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct ManualIntent { + format: String, + profile: String, + generation: String, + version: String, + source_sha: String, + executable_sha256: String, + owner_pid: u32, + owner_created: u64, +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct UninstallIntent { + format: String, + profile: String, + owner_pid: u32, + owner_created: u64, + generations: Vec, +} + +impl UninstallIntent { + fn parse(bytes: &[u8], profile: &str) -> Result { + let intent: Self = serde_json::from_slice(bytes) + .map_err(|_| InstallError::Invalid("Windows uninstall transaction"))?; + if intent.format != "maple-agent-windows-uninstall-intent-v1" + || intent.profile != profile + || intent.generations.len() > 256 + || intent + .generations + .iter() + .any(|name| !valid_generation(name)) + || intent.generations.iter().collect::>().len() != intent.generations.len() + { + return Err(InstallError::Invalid( + "Windows uninstall transaction identity", + )); + } + Ok(intent) + } +} + +#[cfg(windows)] +fn live_uninstall_owner(root: &Path, profile: &str) -> Result { + if !root.join(UNINSTALL_INTENT).try_exists()? { + return Ok(false); + } + let intent = + UninstallIntent::parse(&read_bounded(&root.join(UNINSTALL_INTENT), 65536)?, profile)?; + Ok(process_created(intent.owner_pid)?.is_some_and(|created| created == intent.owner_created)) +} + +#[cfg(windows)] +fn trusted_installer(executable: &Path, profile: &str) -> Result { + let info = pe_build_info(executable)?; + if info["profile"] != profile + || info["bundle_id"] != profile_identity(profile)? + || !info["version"] + .as_str() + .is_some_and(|v| manifest::stable_version(v).is_ok()) + || !info["source_sha"].as_str().is_some_and(valid_source) + || !authenticode::verify_or_unsigned(executable, profile == "dev") + { + return Err(InstallError::CodeSignature); + } + Ok(info) +} + +fn exact_file_digest(path: &Path) -> Result { + let mut file = open_regular(path, false, false)?; + let size = file.metadata()?.len(); + Ok(hex::encode(hash_reader(&mut file, size)?)) +} + +#[cfg(windows)] +fn process_created(pid: u32) -> Result> { + use windows_sys::Win32::{ + Foundation::{CloseHandle, ERROR_INVALID_PARAMETER, FILETIME}, + System::Threading::{ + GetExitCodeProcess, GetProcessTimes, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, + }, + }; + let process = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) }; + if process.is_null() { + let error = io::Error::last_os_error(); + return if error.raw_os_error() == Some(ERROR_INVALID_PARAMETER as i32) { + Ok(None) + } else { + Err(error.into()) + }; + } + struct Handle(windows_sys::Win32::Foundation::HANDLE); + impl Drop for Handle { + fn drop(&mut self) { + unsafe { + CloseHandle(self.0); + } + } + } + let process = Handle(process); + let mut status = 0; + if unsafe { GetExitCodeProcess(process.0, &mut status) } == 0 { + return Err(io::Error::last_os_error().into()); + } + if status != 259 { + return Ok(None); + } + let mut created = FILETIME::default(); + let mut exited = FILETIME::default(); + let mut kernel = FILETIME::default(); + let mut user = FILETIME::default(); + if unsafe { GetProcessTimes(process.0, &mut created, &mut exited, &mut kernel, &mut user) } == 0 + { + return Err(io::Error::last_os_error().into()); + } + Ok(Some( + (u64::from(created.dwHighDateTime) << 32) | u64::from(created.dwLowDateTime), + )) +} + +#[cfg(windows)] +fn installer_owner() -> Result<(u32, u64)> { + use windows_sys::Win32::{ + Foundation::{CloseHandle, INVALID_HANDLE_VALUE}, + System::{ + Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW, + TH32CS_SNAPPROCESS, + }, + Threading::GetCurrentProcessId, + }, + }; + let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) }; + if snapshot == INVALID_HANDLE_VALUE { + return Err(io::Error::last_os_error().into()); + } + struct Handle(windows_sys::Win32::Foundation::HANDLE); + impl Drop for Handle { + fn drop(&mut self) { + unsafe { + CloseHandle(self.0); + } + } + } + let snapshot = Handle(snapshot); + let mut entry = PROCESSENTRY32W::default(); + entry.dwSize = std::mem::size_of::() as u32; + let current = unsafe { GetCurrentProcessId() }; + let mut more = unsafe { Process32FirstW(snapshot.0, &mut entry) }; + while more != 0 { + if entry.th32ProcessID == current { + let created = process_created(entry.th32ParentProcessID)? + .ok_or(InstallError::Invalid("installer parent exited"))?; + return Ok((entry.th32ParentProcessID, created)); + } + more = unsafe { Process32NextW(snapshot.0, &mut entry) }; + } + Err(InstallError::Invalid("installer parent unavailable")) +} + +// The caller has already validated the actual current generation's signature, +// metadata and fixed tree. Only a completed matching intent can be removed; +// an interrupted pending install leaves the previous app launchable unchanged. +fn recover_completed_manual_intent(root: &Path, current: &Bundle) -> Result<()> { + if !root.join(MANUAL_INTENT).try_exists()? { + return Ok(()); + } + let recovery = (|| -> Result<()> { + let lock = open_install_lock(root)?; + lock.try_lock_exclusive().map_err(|_| InstallError::Busy)?; + let intent: ManualIntent = + serde_json::from_slice(&read_bounded(&root.join(MANUAL_INTENT), DESCRIPTOR_LIMIT)?) + .map_err(|_| InstallError::Invalid("Windows recovery intent"))?; + #[cfg(windows)] + if process_created(intent.owner_pid)?.is_some_and(|created| created == intent.owner_created) + { + return Err(InstallError::Busy); + } + let pointer = Current::parse(&read_bounded(&root.join(POINTER), 1024)?)?; + if intent.format != "maple-agent-windows-installer-intent-v1" + || intent.profile != current.profile + || intent.generation != current.generation() + || pointer.generation != current.generation() + || intent.version != current.version + || intent.source_sha != current.source_sha + || intent.executable_sha256 + != exact_file_digest(&pointer.path(root).join("maple-agent.exe"))? + { + return Err(InstallError::Invalid( + "Windows manual transaction is still pending", + )); + } + #[cfg(windows)] + retire_abandoned_uninstall(root, ¤t.profile, ¤t.generation())?; + fs::remove_file(root.join(MANUAL_INTENT))?; + windows_sync_directory(root) + })(); + // Collection/repair failure cannot turn a still-valid committed generation + // into a startup outage. Auto-update remains Busy while intent is retained. + let _ = recovery; + Ok(()) +} + +#[cfg(windows)] +fn generation_write_guards(root: &Path) -> Result> { + let mut guards = Vec::new(); + for entry in fs::read_dir(root.join("versions"))?.take(4096) { + let entry = entry?; + if !entry.file_name().to_str().is_some_and(valid_generation) { + return Err(InstallError::Invalid("unknown Windows generation")); + } + known_partial_generation(&entry.path())?; + let executable = entry.path().join("maple-agent.exe"); + if executable.try_exists()? { + guards.push(windows_fs::open_private(&executable, false)?); + } + } + Ok(guards) +} + +// A partial generation is repairable only when every existing entry is one of +// our fixed regular files/directories. Never remove arbitrary files on retry. +fn known_partial_generation(path: &Path) -> Result<()> { + let mut pending = vec![(path.to_owned(), String::new())]; + while let Some((path, name)) = pending.pop() { + let metadata = checked_entry(&path)?; + if metadata.is_dir() { + if !DIRS.contains(&name.as_str()) { + return Err(InstallError::Invalid("unknown Windows installer directory")); + } + #[cfg(windows)] + windows_fs::private_directory(&path)?; + for entry in fs::read_dir(&path)?.take(32) { + let entry = entry?; + let leaf = entry + .file_name() + .into_string() + .map_err(|_| InstallError::Invalid("Windows installer filename"))?; + let name = if name.is_empty() { + leaf + } else { + format!("{name}/{leaf}") + }; + pending.push((entry.path(), name)); + } + } else if metadata.is_file() && FILES.contains(&name.as_str()) { + // Cleanup may retain a delete-capable write guard on the exe. + // A READ-only-sharing open would deny that existing write handle. + // This existing-only read validates owner/DACL/link invariants, + // permits concurrent guard writes, and works on the mapped current + // executable during an authenticated startup checkpoint. + #[cfg(windows)] + let _ = windows_fs::open_shared_readonly(&path)?; + #[cfg(not(windows))] + let _ = open_regular(&path, false, false)?; + } else { + return Err(InstallError::Invalid("unknown Windows installer file")); + } + } + Ok(()) +} + +// Revalidate every remaining path before deleting a known partial or complete +// generation. A delete-capable write guard prevents any new executable image +// mapping while permitting our own unlink; close it before removing directories. +fn remove_generation_files(path: &Path, guard: Option) -> Result<()> { + known_partial_generation(path)?; + for name in FILES { + let file = path.join(name); + if file.try_exists()? { + fs::remove_file(file)?; + } + } + drop(guard); + for name in DIRS.iter().rev() { + let directory = path.join(name); + if directory.try_exists()? { + fs::remove_dir(directory)?; + } + } + Ok(()) +} + +#[cfg(windows)] +fn incomplete_generation_guard(path: &Path) -> Result> { + known_partial_generation(path)?; + let executable = path.join("maple-agent.exe"); + if executable.try_exists()? { + Ok(Some(windows_fs::open_executable_mutation_guard( + &executable, + )?)) + } else { + Ok(None) + } +} + +#[cfg(windows)] +fn complete_generation(path: &Path, profile: &str, name: &str) -> Result<()> { + let bundle = descriptor(path, profile)?; + if bundle.generation() != name { + return Err(InstallError::Invalid("Windows generation identity changed")); + } + let _ = scan_generation(path)?; + verify_identity(path, &bundle) +} + +#[cfg(windows)] +fn structurally_complete_generation(path: &Path, profile: &str, name: &str) -> Result<()> { + let bundle = descriptor(path, profile)?; + if bundle.generation() != name { + return Err(InstallError::Invalid( + "Windows retained generation identity", + )); + } + let _ = scan_generation(path)?; + let _ = verify_identity_metadata(path, &bundle)?; + // Retaining existing rollback bytes must not depend on a revocation + // network response. This grants no launch/install authority: every later + // use still performs its purpose-specific native signature verification. + Ok(()) +} + +#[cfg(windows)] +fn abandoned_uninstall(root: &Path, profile: &str) -> Result> { + if !root.join(UNINSTALL_INTENT).try_exists()? { + return Ok(None); + } + let intent = + UninstallIntent::parse(&read_bounded(&root.join(UNINSTALL_INTENT), 65536)?, profile)?; + if process_created(intent.owner_pid)?.is_some_and(|created| created == intent.owner_created) { + return Err(InstallError::Busy); + } + Ok(Some(intent)) +} + +// A dead uninstall journal can explain missing/partial old app bytes. It never +// authorizes arbitrary paths: all present children must be journal-owned, the +// exact current generation, or the separately recorded manual install target. +#[cfg(windows)] +fn validate_uninstall_repair(root: &Path, profile: &str, intent: &UninstallIntent) -> Result<()> { + let current = if root.join(POINTER).try_exists()? { + Some(Current::parse(&read_bounded(&root.join(POINTER), 1024)?)?) + } else { + None + }; + let manual = if root.join(MANUAL_INTENT).try_exists()? { + let manual: ManualIntent = + serde_json::from_slice(&read_bounded(&root.join(MANUAL_INTENT), DESCRIPTOR_LIMIT)?) + .map_err(|_| InstallError::Invalid("Windows repair manual intent"))?; + if manual.format != "maple-agent-windows-installer-intent-v1" + || manual.profile != profile + || !valid_generation(&manual.generation) + { + return Err(InstallError::Invalid("Windows repair manual identity")); + } + Some(manual.generation) + } else { + None + }; + if let Some(current) = ¤t { + if !intent.generations.contains(¤t.generation) + && generation_for(root, profile, IdentityPurpose::Startup).is_err() + { + return Err(InstallError::Invalid( + "Windows missing generation is not in uninstall journal", + )); + } + } + let versions = root.join("versions"); + if versions.try_exists()? { + windows_fs::private_directory(&versions)?; + for (index, entry) in fs::read_dir(&versions)?.enumerate() { + let entry = entry?; + let name = entry + .file_name() + .into_string() + .map_err(|_| InstallError::Invalid("Windows repair generation name"))?; + if index >= 256 + || !valid_generation(&name) + || !(intent.generations.contains(&name) + || current + .as_ref() + .is_some_and(|pointer| pointer.generation == name) + || manual + .as_ref() + .is_some_and(|generation| *generation == name)) + { + return Err(InstallError::Invalid( + "unknown Windows uninstall repair generation", + )); + } + known_partial_generation(&entry.path())?; + } + } + Ok(()) +} + +// Called under the install lock after a complete new current generation is +// authenticated. Valid rollback generations are retained; only old journal- +// owned incomplete fixed-file trees are removed. The journal survives failure. +#[cfg(windows)] +fn retire_abandoned_uninstall(root: &Path, profile: &str, current_generation: &str) -> Result<()> { + let Some(intent) = abandoned_uninstall(root, profile)? else { + return Ok(()); + }; + validate_uninstall_repair(root, profile, &intent)?; + for name in &intent.generations { + if name == current_generation { + continue; + } + let path = root.join("versions").join(name); + if path.try_exists()? && structurally_complete_generation(&path, profile, name).is_err() { + let guard = incomplete_generation_guard(&path)?; + remove_generation_files(&path, guard)?; + } + } + fs::remove_file(root.join(UNINSTALL_INTENT))?; + windows_sync_directory(root) +} + +#[cfg(windows)] +fn inspect_manual_root(root: &Path, profile: &str) -> Result<()> { + windows_fs::private_directory(root)?; + if live_uninstall_owner(root, profile)? { + return Err(InstallError::Busy); + } + for entry in fs::read_dir(root)?.take(4096) { + let entry = entry?; + let name = entry.file_name(); + let name = name + .to_str() + .ok_or(InstallError::Invalid("Windows root filename"))?; + if matches!( + name, + "maple-agent.exe" + | "uninstall.exe" + | POINTER + | MANUAL_INTENT + | UNINSTALL_INTENT + | ".install.lock" + ) || name + .strip_prefix(TRANSACTION_TEMP_PREFIX) + .is_some_and(|suffix| { + suffix.len() == STAGING_RANDOM_LENGTH + && suffix.bytes().all(|c| c.is_ascii_alphanumeric()) + }) + { + if !checked_entry(&entry.path())?.is_file() { + return Err(InstallError::Invalid("Windows root special file")); + } + if name == ".install.lock" { + let _ = windows_fs::open_private(&entry.path(), false)?; + } else { + let _ = open_regular(&entry.path(), false, false)?; + } + } else if name == "versions" { + windows_fs::private_directory(&entry.path())?; + } else if name.starts_with(&stage_prefix(root)) { + validate_stage(root, &entry.path())?; + } else { + return Err(InstallError::Invalid( + "unknown files in Windows installation; choose an empty directory", + )); + } + } + if root.join("maple-agent.exe").try_exists()? { + let _ = trusted_installer(&root.join("maple-agent.exe"), profile)?; + } + if let Some(intent) = abandoned_uninstall(root, profile)? { + validate_uninstall_repair(root, profile, &intent)?; + return Ok(()); + } + if root.join(POINTER).try_exists()? { + registered_root(root, profile)?; + let _ = generation(root, profile)?; + } else if !root.join(MANUAL_INTENT).try_exists()? { + // Creation of the private lock/versions directory can itself be + // interrupted before the intent's atomic rename. Only this empty + // structural prefix is resumable without an intent; a flat/unknown + // previous installation is deliberately unsupported. + if root.join("maple-agent.exe").try_exists()? + || root.join("uninstall.exe").try_exists()? + || (root.join("versions").try_exists()? + && fs::read_dir(root.join("versions"))?.next().is_some()) + { + return Err(InstallError::Invalid( + "Windows installation is incomplete without a recovery intent", + )); + } + } + Ok(()) +} + +/// NSIS calls this read-only admission check from its extracted native checker +/// before writing application files or per-user registration. +pub fn preflight_windows_installation(checker: &Path, root: &Path, profile: &str) -> Result<()> { + #[cfg(windows)] + { + let checker = checker.canonicalize()?; + let _ = trusted_installer(&checker, profile)?; + windows_fs::validate_path_syntax(root)?; + if root.file_name().is_none() { + return Err(InstallError::Invalid("Windows installer destination")); + } + let mut existing = root; + while !existing.try_exists()? { + existing = existing + .parent() + .ok_or(InstallError::Invalid("Windows installer parent"))?; + } + // Existing destinations are never taken over by changing their ACL. + windows_fs::private_directory(existing)?; + if root.try_exists()? { + let root = root.canonicalize()?; + if fs::read_dir(&root)?.next().is_some() { + inspect_manual_root(&root, profile)?; + } + } + Ok(()) + } + #[cfg(not(windows))] + { + let _ = (checker, root, profile); + Err(InstallError::NotifyOnly( + "Windows installer requires native Windows", + )) + } +} + +/// Start/resume the explicit signed manual-install transaction. The durable +/// intent excludes automatic updates during NSIS copying; the exact committed +/// generation stays launchable if an installer is interrupted. The marker +/// it is removed only after a fully validated atomic pointer commit. +pub fn prepare_windows_installation( + checker: &Path, + root: &Path, + profile: &str, + generation_name: &str, +) -> Result<()> { + #[cfg(windows)] + { + preflight_windows_installation(checker, root, profile)?; + if !valid_generation(generation_name) { + return Err(InstallError::Invalid("Windows manual generation")); + } + let checker = checker.canonicalize()?; + let info = trusted_installer(&checker, profile)?; + if generation_name.rsplit_once('-').unwrap().0 != info["version"].as_str().unwrap() { + return Err(InstallError::Invalid( + "Windows installer generation identity", + )); + } + windows_fs::private_directory(root)?; + let root = root.canonicalize()?; + let lock = open_install_lock(&root)?; + lock.try_lock_exclusive().map_err(|error| { + if error.kind() == io::ErrorKind::WouldBlock + || error.raw_os_error() == fs2::lock_contended_error().raw_os_error() + { + InstallError::Busy + } else { + error.into() + } + })?; + // Recheck after acquiring the lock: an updater may have committed while + // this installer was performing its read-only admission checks. + if root.join(POINTER).try_exists()? || root.join(MANUAL_INTENT).try_exists()? { + inspect_manual_root(&root, profile)?; + } + if root.join(MANUAL_INTENT).try_exists()? { + let previous: ManualIntent = + serde_json::from_slice(&read_bounded(&root.join(MANUAL_INTENT), DESCRIPTOR_LIMIT)?) + .map_err(|_| InstallError::Invalid("Windows installer recovery intent"))?; + if process_created(previous.owner_pid)? + .is_some_and(|created| created == previous.owner_created) + { + return Err(InstallError::Busy); + } + if previous.format != "maple-agent-windows-installer-intent-v1" + || previous.profile != profile + || !valid_generation(&previous.generation) + { + return Err(InstallError::Invalid("Windows prior installer intent")); + } + let current = if root.join(POINTER).try_exists()? { + Some(Current::parse(&read_bounded(&root.join(POINTER), 1024)?)?) + } else { + None + }; + let prior_path = root.join("versions").join(&previous.generation); + if prior_path.try_exists()? + && !current + .as_ref() + .is_some_and(|pointer| pointer.generation == previous.generation) + && structurally_complete_generation(&prior_path, profile, &previous.generation) + .is_err() + { + let guard = incomplete_generation_guard(&prior_path)?; + remove_generation_files(&prior_path, guard)?; + } + } + if let Some(intent) = abandoned_uninstall(&root, profile)? { + validate_uninstall_repair(&root, profile, &intent)?; + let current = if root.join(POINTER).try_exists()? { + Some(Current::parse(&read_bounded(&root.join(POINTER), 1024)?)?) + } else { + None + }; + for name in &intent.generations { + if current + .as_ref() + .is_some_and(|pointer| pointer.generation == *name) + { + continue; + } + let path = root.join("versions").join(name); + if path.try_exists()? + && structurally_complete_generation(&path, profile, name).is_err() + { + let guard = incomplete_generation_guard(&path)?; + remove_generation_files(&path, guard)?; + } + } + } + let owner = installer_owner()?; + windows_fs::private_directory(&root.join("versions"))?; + let current = if root.join(POINTER).try_exists()? { + Some(Current::parse(&read_bounded(&root.join(POINTER), 1024)?)?) + } else { + None + }; + // Manual repair never modifies a mapped generation, even a rollback. + for entry in fs::read_dir(root.join("versions"))?.take(4096) { + let entry = entry?; + if !entry.file_name().to_str().is_some_and(valid_generation) { + return Err(InstallError::Invalid("unknown Windows generation")); + } + known_partial_generation(&entry.path())?; + if entry.path().join("maple-agent.exe").try_exists()? { + let probe = OpenOptions::new() + .append(true) + .open(entry.path().join("maple-agent.exe"))?; + drop(probe); + } + } + let intent = ManualIntent { + format: "maple-agent-windows-installer-intent-v1".into(), + profile: profile.into(), + generation: generation_name.into(), + version: info["version"].as_str().unwrap().into(), + source_sha: info["source_sha"].as_str().unwrap().into(), + executable_sha256: exact_file_digest(&checker)?, + owner_pid: owner.0, + owner_created: owner.1, + }; + // Publish admission intent before copying any owned installation file. + write_private_json(&root.join(MANUAL_INTENT), &intent)?; + let target = root.join("versions").join(generation_name); + if target.try_exists()? { + let complete = descriptor(&target, profile) + .and_then(|bundle| { + if bundle.generation() != generation_name { + return Err(InstallError::Invalid("Windows repair identity")); + } + let _ = scan_generation(&target)?; + verify_identity(&target, &bundle) + }) + .is_ok(); + if !complete { + if current + .as_ref() + .is_some_and(|pointer| pointer.generation == generation_name) + { + return Err(InstallError::Invalid( + "cannot repair active Windows generation in place", + )); + } + let guard = incomplete_generation_guard(&target)?; + remove_generation_files(&target, guard)?; + } + } + if !root.join("maple-agent.exe").try_exists()? { + let temporary = tempfile::Builder::new() + .prefix(TRANSACTION_TEMP_PREFIX) + .rand_bytes(STAGING_RANDOM_LENGTH) + .tempfile_in(&root)? + .into_temp_path(); + fs::copy(&checker, &temporary)?; + if exact_file_digest(&temporary)? != intent.executable_sha256 + || !authenticode::verify_or_unsigned(&temporary, profile == "dev") + { + return Err(InstallError::CodeSignature); + } + windows_atomic_replace(&temporary, &root.join("maple-agent.exe"))?; + } + windows_sync_directory(&root)?; + Ok(()) + } + #[cfg(not(windows))] + { + let _ = (checker, root, profile, generation_name); + Err(InstallError::NotifyOnly( + "Windows installer preparation requires native Windows", + )) + } +} + +/// Activation keeps the stable bootstrap and authenticates the exact new +/// generation against the durable signed-checker intent before publication. +pub fn activate_windows_installation( + executable: &Path, + profile: &str, + generation_name: &str, +) -> Result<()> { + #[cfg(windows)] + { + if !valid_generation(generation_name) { + return Err(InstallError::Invalid("Windows activation generation")); + } + let executable = executable.canonicalize()?; + let root = executable + .parent() + .ok_or(InstallError::Invalid("Windows activation root"))?; + if executable + .file_name() + .is_none_or(|x| x != "maple-agent.exe") + { + return Err(InstallError::Invalid("Windows activation executable")); + } + windows_fs::private_directory(root)?; + registered_root(root, profile)?; + let _ = trusted_installer(&executable, profile)?; + let lock = open_install_lock(root)?; + lock.try_lock_exclusive().map_err(|error| { + if error.kind() == io::ErrorKind::WouldBlock + || error.raw_os_error() == fs2::lock_contended_error().raw_os_error() + { + InstallError::Busy + } else { + error.into() + } + })?; + let intent: ManualIntent = + serde_json::from_slice(&read_bounded(&root.join(MANUAL_INTENT), DESCRIPTOR_LIMIT)?) + .map_err(|_| InstallError::Invalid("Windows installer intent"))?; + if intent.format != "maple-agent-windows-installer-intent-v1" + || intent.profile != profile + || intent.generation != generation_name + || (intent.owner_pid, intent.owner_created) != installer_owner()? + { + return Err(InstallError::Invalid("Windows installer intent identity")); + } + let path = root.join("versions").join(generation_name); + let bundle = descriptor(&path, profile)?; + if bundle.generation() != generation_name + || bundle.version != intent.version + || bundle.source_sha != intent.source_sha + || exact_file_digest(&path.join("maple-agent.exe"))? != intent.executable_sha256 + { + return Err(InstallError::Invalid( + "Windows installer activation identity", + )); + } + let _ = scan_generation(&path)?; + verify_identity(&path, &bundle)?; + let _generation_guards = generation_write_guards(root)?; + write_private_json(&root.join(POINTER), &Current::new(&bundle))?; + drop(_generation_guards); + // Publication committed. Cleanup cannot report an uncommitted failure + // to NSIS, which would otherwise roll back valid registration. Startup + // authenticates this exact generation and retries retained intent. + if retire_abandoned_uninstall(root, profile, &bundle.generation()).is_ok() { + let _ = fs::remove_file(root.join(MANUAL_INTENT)); + } + let _ = windows_sync_directory(root); + Ok(()) + } + #[cfg(not(windows))] + { + let _ = (executable, profile, generation_name); + Err(InstallError::NotifyOnly( + "Windows activation requires native Windows", + )) + } +} + +/// Uninstaller entry derives authority from its own registered bootstrap path. +/// Unknown files and live mapped generations are preserved rather than followed. +pub fn cleanup_windows_installation(executable: &Path, profile: &str) -> Result { + #[cfg(windows)] + { + let executable = executable.canonicalize()?; + if executable + .file_name() + .is_none_or(|x| x != "maple-agent.exe") + { + return Err(InstallError::Invalid("Windows uninstaller executable")); + } + let root = executable + .parent() + .ok_or(InstallError::Invalid("Windows uninstall parent"))?; + windows_fs::validate_path(root)?; + registered_root(root, profile)?; + if pe_build_info(&executable)?["profile"] != profile + || !authenticode::verify_or_unsigned(&executable, profile == "dev") + { + return Err(InstallError::CodeSignature); + } + let lock = open_install_lock(root)?; + lock.try_lock_exclusive().map_err(|_| InstallError::Busy)?; + let owner = installer_owner()?; + if root.join(MANUAL_INTENT).try_exists()? { + let intent: ManualIntent = + serde_json::from_slice(&read_bounded(&root.join(MANUAL_INTENT), DESCRIPTOR_LIMIT)?) + .map_err(|_| InstallError::Invalid("Windows uninstall manual intent"))?; + if process_created(intent.owner_pid)? + .is_some_and(|created| created == intent.owner_created) + { + return Err(InstallError::Busy); + } + } + let previous = if root.join(UNINSTALL_INTENT).try_exists()? { + let intent = UninstallIntent::parse( + &read_bounded(&root.join(UNINSTALL_INTENT), 65536)?, + profile, + )?; + if (intent.owner_pid, intent.owner_created) != owner + && process_created(intent.owner_pid)? + .is_some_and(|created| created == intent.owner_created) + { + return Err(InstallError::Busy); + } + Some(intent) + } else { + None + }; + let versions = root.join("versions"); + let mut directories = Vec::new(); + if versions.try_exists()? { + checked_entry(&versions)?; + for entry in fs::read_dir(&versions)? { + let entry = entry?; + let name = entry + .file_name() + .into_string() + .map_err(|_| InstallError::Invalid("Windows uninstall generation name"))?; + if directories.len() >= 256 || !valid_generation(&name) { + return Err(InstallError::Invalid( + "unknown Windows installed generation", + )); + } + if let Some(previous) = &previous { + if !previous.generations.contains(&name) { + return Err(InstallError::Invalid( + "generation appeared during Windows uninstall", + )); + } + known_partial_generation(&entry.path())?; + } else { + complete_generation(&entry.path(), profile, &name)?; + } + // Retained write handles exclude mapped images and block new + // mappings through file deletion, while FILE_SHARE_DELETE + // permits the uninstaller's own fixed-file unlink operations. + let guard = incomplete_generation_guard(&entry.path())?; + directories.push((name, entry.path(), guard)); + } + } + let intent = UninstallIntent { + format: "maple-agent-windows-uninstall-intent-v1".into(), + profile: profile.into(), + owner_pid: owner.0, + owner_created: owner.1, + generations: previous + .map(|intent| intent.generations) + .unwrap_or_else(|| { + directories + .iter() + .map(|(name, _, _)| name.clone()) + .collect() + }), + }; + write_private_json(&root.join(UNINSTALL_INTENT), &intent)?; + let count = directories.len(); + for (_, path, guard) in directories { + remove_generation_files(&path, guard)?; + } + if versions.try_exists()? { + fs::remove_dir(&versions)?; + } + // NSIS retains this live-owner intent after the helper exits, performs + // all fixed root/shortcut/registry removals, and deletes this marker + // last. No later recursive/root-file mutation may follow its release. + Ok(count) + } + #[cfg(not(windows))] + { + let _ = (executable, profile); + Err(InstallError::NotifyOnly( + "Windows uninstall requires native Windows", + )) + } +} + +#[cfg(windows)] +pub(crate) fn windows_private_directory(path: &Path) -> Result<()> { + Ok(windows_fs::private_directory(path)?) +} +#[cfg(windows)] +pub(crate) fn windows_open_private(path: &Path, truncate: bool) -> Result { + Ok(windows_fs::open_private(path, truncate)?) +} +#[cfg(windows)] +pub(crate) fn windows_open_readonly(path: &Path) -> Result { + Ok(windows_fs::open_readonly(path)?) +} +#[cfg(windows)] +pub(crate) fn windows_read_bounded(path: &Path, maximum: u64) -> Result>> { + Ok(windows_fs::read_bounded(path, maximum)?) +} +#[cfg(windows)] +pub(crate) fn windows_sync_directory(path: &Path) -> Result<()> { + Ok(windows_fs::sync_directory(path)?) +} +#[cfg(not(windows))] +fn windows_sync_directory(path: &Path) -> Result<()> { + sync_directory(path) +} +#[cfg(windows)] +pub(crate) fn windows_atomic_replace(source: &Path, destination: &Path) -> Result<()> { + Ok(windows_fs::atomic_replace(source, destination)?) +} +#[cfg(not(windows))] +fn windows_atomic_replace(source: &Path, destination: &Path) -> Result<()> { + fs::rename(source, destination)?; + sync_directory(destination.parent().unwrap()) +} + +#[cfg(test)] +#[path = "windows_bundle_tests.rs"] +mod tests; + +#[cfg(all(test, windows))] +#[path = "windows_bundle_transaction_tests.rs"] +mod transaction_tests; diff --git a/apps/maple-agent/crates/maple-agent-updater/src/installer/windows_bundle/authenticode.rs b/apps/maple-agent/crates/maple-agent-updater/src/installer/windows_bundle/authenticode.rs new file mode 100644 index 000000000..7076ec3aa --- /dev/null +++ b/apps/maple-agent/crates/maple-agent-updater/src/installer/windows_bundle/authenticode.rs @@ -0,0 +1,415 @@ +//! Native Authenticode checks for staged Windows executables. The signed update +//! envelope authenticates the complete archive independently; Windows trust must +//! additionally accept the executable and the expected publisher and timestamp. + +use std::{ + ffi::c_void, + fs::OpenOptions, + io::{Read, Seek, SeekFrom}, + mem::size_of, + os::windows::{ffi::OsStrExt, fs::MetadataExt, fs::OpenOptionsExt, io::AsRawHandle}, + path::Path, +}; + +use windows_sys::Win32::{ + Foundation::{GetLastError, INVALID_HANDLE_VALUE, SetLastError, TRUST_E_NOSIGNATURE}, + Security::{ + Cryptography::{CERT_NAME_ATTR_TYPE, CertGetNameStringW, szOID_COMMON_NAME}, + WinTrust::{ + CRYPT_PROVIDER_SGNR, SGNR_TYPE_TIMESTAMP, WINTRUST_ACTION_GENERIC_VERIFY_V2, + WINTRUST_DATA, WINTRUST_DATA_0, WINTRUST_FILE_INFO, WTD_CACHE_ONLY_URL_RETRIEVAL, + WTD_CHOICE_FILE, WTD_DISABLE_MD2_MD4, WTD_REVOCATION_CHECK_CHAIN_EXCLUDE_ROOT, + WTD_REVOCATION_CHECK_NONE, WTD_REVOKE_NONE, WTD_STATEACTION_CLOSE, + WTD_STATEACTION_VERIFY, WTD_UI_NONE, WTHelperGetProvCertFromChain, + WTHelperGetProvSignerFromChain, WTHelperProvDataFromStateData, WinVerifyTrust, + }, + }, + Storage::FileSystem::{ + FILE_ATTRIBUTE_REPARSE_POINT, FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_READ, + }, +}; + +const EXPECTED_PUBLISHER: &str = "Mutiny Wallet Inc. dba OpenSecret"; +const MAX_NAME_UNITS: u32 = 512; + +#[derive(Clone, Copy)] +enum Purpose { + Admission, + Startup, +} + +impl Purpose { + fn provider_flags(self) -> u32 { + match self { + Self::Admission => WTD_REVOCATION_CHECK_CHAIN_EXCLUDE_ROOT | WTD_DISABLE_MD2_MD4, + Self::Startup => { + WTD_CACHE_ONLY_URL_RETRIEVAL | WTD_REVOCATION_CHECK_NONE | WTD_DISABLE_MD2_MD4 + } + } + } +} + +fn trust_configuration(info: &mut WINTRUST_FILE_INFO, purpose: Purpose) -> WINTRUST_DATA { + WINTRUST_DATA { + cbStruct: size_of::() as u32, + dwUIChoice: WTD_UI_NONE, + fdwRevocationChecks: WTD_REVOKE_NONE, + dwUnionChoice: WTD_CHOICE_FILE, + Anonymous: WINTRUST_DATA_0 { pFile: info }, + dwStateAction: WTD_STATEACTION_VERIFY, + dwProvFlags: purpose.provider_flags(), + ..Default::default() + } +} + +/// State belongs to WinTrust, including after a failed verification. Closing it +/// is mandatory for every VERIFY call, not only for successful verifications. +struct TrustState(WINTRUST_DATA); + +impl Drop for TrustState { + fn drop(&mut self) { + self.0.dwStateAction = WTD_STATEACTION_CLOSE; + let mut action = WINTRUST_ACTION_GENERIC_VERIFY_V2; + // SAFETY: the state was initialized for this exact action. Its file and + // path arguments outlive this guard; CLOSE releases provider resources. + unsafe { + WinVerifyTrust( + INVALID_HANDLE_VALUE, + &mut action, + (&mut self.0 as *mut WINTRUST_DATA).cast::(), + ); + } + } +} + +fn publisher_matches(name: &[u16]) -> bool { + name.len() > 1 + && name.last() == Some(&0) + && !name[..name.len() - 1].contains(&0) + && String::from_utf16(&name[..name.len() - 1]).is_ok_and(|name| name == EXPECTED_PUBLISHER) +} + +/// An unsigned Dev executable must still be a PE with no certificate table. +/// WinTrust can report NOSIGNATURE for malformed signatures too, so its error +/// alone must never turn a damaged signed executable into an unsigned one. +fn pe_has_no_certificate(reader: &mut (impl Read + Seek)) -> bool { + let Ok(length) = reader.seek(SeekFrom::End(0)) else { + return false; + }; + let mut dos = [0u8; 64]; + if reader.seek(SeekFrom::Start(0)).is_err() + || reader.read_exact(&mut dos).is_err() + || &dos[..2] != b"MZ" + { + return false; + } + let pe_offset = u64::from(u32::from_le_bytes(dos[60..64].try_into().unwrap())); + if !(64..=16 * 1024 * 1024).contains(&pe_offset) || pe_offset + 24 > length { + return false; + } + let mut coff = [0u8; 24]; + if reader.seek(SeekFrom::Start(pe_offset)).is_err() + || reader.read_exact(&mut coff).is_err() + || &coff[..4] != b"PE\0\0" + || u16::from_le_bytes(coff[22..24].try_into().unwrap()) & 2 == 0 + || !(1..=96).contains(&u16::from_le_bytes(coff[6..8].try_into().unwrap())) + { + return false; + } + let optional_size = usize::from(u16::from_le_bytes(coff[20..22].try_into().unwrap())); + if !(96..=4096).contains(&optional_size) || pe_offset + 24 + optional_size as u64 > length { + return false; + } + let mut optional = vec![0u8; optional_size]; + if reader.read_exact(&mut optional).is_err() { + return false; + } + let directory_start = match u16::from_le_bytes(optional[..2].try_into().unwrap()) { + 0x10b => 96, // PE32, including the NSIS uninstaller. + 0x20b => 112, // PE32+, including the x86-64 app. + _ => return false, + }; + if optional.len() < directory_start { + return false; + } + let directory_count = u32::from_le_bytes( + optional[directory_start - 4..directory_start] + .try_into() + .unwrap(), + ) as usize; + // Both supported toolchains emit the standard 16 data directories. Reject + // truncated/ambiguous headers rather than infer that a signature is absent. + if directory_count != 16 || optional.len() < directory_start + 16 * 8 { + return false; + } + let certificate = &optional[directory_start + 4 * 8..directory_start + 5 * 8]; + certificate == [0; 8] +} + +/// Verify data only. This never runs the downloaded executable or invokes a +/// shell. There are no environment, registry, or test overrides of the signer. +/// Windows may retrieve certificate-chain/revocation material under its trust +/// policy; an unavailable or untrusted chain fails closed. +pub(super) fn verify(path: &Path) -> bool { + verify_for_purpose(path, false, Purpose::Admission) +} + +/// Only the caller's embedded Dev profile may opt into unsigned local builds. +/// Signed files always require the exact Maple publisher and trusted timestamp, +/// including when unsigned Dev files are permitted. Production updates call +/// `verify`, and never enter this exception. +pub(super) fn verify_or_unsigned(path: &Path, allow_unsigned: bool) -> bool { + verify_for_purpose(path, allow_unsigned, Purpose::Admission) +} + +/// Reauthenticate an already admitted installed executable at normal startup. +/// Startup uses only cached chain material and does not perform revocation URL +/// retrieval; it still requires Windows trust, exact publisher and timestamp. +/// Installation and update mutation must continue to use the admission APIs. +pub(super) fn verify_startup_or_unsigned(path: &Path, allow_unsigned: bool) -> bool { + verify_for_purpose(path, allow_unsigned, Purpose::Startup) +} + +fn verify_for_purpose(path: &Path, allow_unsigned: bool, purpose: Purpose) -> bool { + if !path.is_absolute() { + return false; + } + let mut wide: Vec = path.as_os_str().encode_wide().collect(); + if wide.is_empty() || wide.len() > 32_766 || wide.contains(&0) { + return false; + } + wide.push(0); + // Deny concurrent writes/deletion while the same handle is authenticated. + // The caller separately validates every staging ancestor and archive hash. + let Ok(file) = OpenOptions::new() + .read(true) + .share_mode(FILE_SHARE_READ) + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT) + .open(path) + else { + return false; + }; + let Ok(metadata) = file.metadata() else { + return false; + }; + if !metadata.is_file() || metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return false; + } + let mut info = WINTRUST_FILE_INFO { + cbStruct: size_of::() as u32, + pcwszFilePath: wide.as_ptr(), + hFile: file.as_raw_handle(), + ..Default::default() + }; + let mut state = TrustState(trust_configuration(&mut info, purpose)); + let mut action = WINTRUST_ACTION_GENERIC_VERIFY_V2; + // SAFETY: all structures have their required sizes and pointers refer to + // live immutable path/file data. The state remains live until CLOSE. + let (status, reason) = unsafe { + SetLastError(0); + let status = WinVerifyTrust( + INVALID_HANDLE_VALUE, + &mut action, + (&mut state.0 as *mut WINTRUST_DATA).cast::(), + ); + (status, GetLastError()) + }; + if allow_unsigned && status == TRUST_E_NOSIGNATURE && reason == TRUST_E_NOSIGNATURE as u32 { + return pe_has_no_certificate(&mut &file); + } + // WinVerifyTrust returns LONG, not HRESULT. Only exactly zero is success. + if status != 0 || state.0.hWVTStateData.is_null() { + return false; + } + // SAFETY: provider pointers are owned by the successful state and remain + // valid until its guard closes it. No provider-owned memory is modified. + unsafe { + let provider = WTHelperProvDataFromStateData(state.0.hWVTStateData); + if provider.is_null() || (*provider).dwError != 0 { + return false; + } + let signer = WTHelperGetProvSignerFromChain(provider, 0, 0, 0); + if signer.is_null() + || (*signer).dwError != 0 + || (*signer).csCertChain == 0 + || (*signer).csCounterSigners == 0 + { + return false; + } + let certificate = WTHelperGetProvCertFromChain(signer, 0); + if certificate.is_null() || (*certificate).dwError != 0 || (*certificate).pCert.is_null() { + return false; + } + let count = CertGetNameStringW( + (*certificate).pCert, + CERT_NAME_ATTR_TYPE, + 0, + szOID_COMMON_NAME.cast::(), + std::ptr::null_mut(), + 0, + ); + if !(2..=MAX_NAME_UNITS).contains(&count) { + return false; + } + let mut name = vec![0u16; count as usize]; + if CertGetNameStringW( + (*certificate).pCert, + CERT_NAME_ATTR_TYPE, + 0, + szOID_COMMON_NAME.cast::(), + name.as_mut_ptr(), + count, + ) != count + || !publisher_matches(&name) + { + return false; + } + let timestamp = WTHelperGetProvSignerFromChain(provider, 0, 1, 0); + valid_timestamp(timestamp) + } +} + +unsafe fn valid_timestamp(timestamp: *const CRYPT_PROVIDER_SGNR) -> bool { + // SAFETY: this pointer, when non-null, comes from the live verified provider + // state. Bounds come from that provider, not from serialized app metadata. + unsafe { + !timestamp.is_null() + && (*timestamp).dwError == 0 + && (*timestamp).dwSignerType & SGNR_TYPE_TIMESTAMP != 0 + && (*timestamp).csCertChain > 0 + && !(*timestamp).pasCertChain.is_null() + && !(*(*timestamp).pasCertChain).pCert.is_null() + && (*(*timestamp).pasCertChain).dwError == 0 + && ((*timestamp).sftVerifyAsOf.dwLowDateTime != 0 + || (*timestamp).sftVerifyAsOf.dwHighDateTime != 0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn purpose_provider_flags_pin_startup_offline_and_admission_revocation() { + assert_eq!(Purpose::Admission.provider_flags(), 0x2080); + assert_eq!(Purpose::Startup.provider_flags(), 0x3010); + assert_eq!( + Purpose::Admission.provider_flags() & WTD_CACHE_ONLY_URL_RETRIEVAL, + 0 + ); + assert_eq!( + Purpose::Startup.provider_flags() & WTD_REVOCATION_CHECK_CHAIN_EXCLUDE_ROOT, + 0 + ); + } + + #[test] + fn native_trust_configuration_keeps_startup_and_admission_separate() { + let mut info = WINTRUST_FILE_INFO::default(); + for (purpose, flags) in [(Purpose::Admission, 0x2080), (Purpose::Startup, 0x3010)] { + let data = trust_configuration(&mut info, purpose); + assert_eq!(data.cbStruct as usize, size_of::()); + assert_eq!(data.dwProvFlags, flags); + assert_eq!(data.fdwRevocationChecks, WTD_REVOKE_NONE); + assert_eq!(data.dwUIChoice, WTD_UI_NONE); + assert_eq!(data.dwUnionChoice, WTD_CHOICE_FILE); + assert_eq!(data.dwStateAction, WTD_STATEACTION_VERIFY); + assert!(data.hWVTStateData.is_null()); + } + } + + #[test] + fn signer_requires_the_exact_compiled_publisher() { + let encode = |value: &str| value.encode_utf16().chain(Some(0)).collect::>(); + assert!(publisher_matches(&encode(EXPECTED_PUBLISHER))); + for other in [ + "A different trusted software publisher", + "Mutiny Wallet Inc. dba OpenSecret ", + "mutiny wallet inc. dba opensecret", + "Mutiny Wallet Inc. dba OpenSecret\0untrusted", + ] { + assert!(!publisher_matches(&encode(other))); + } + assert!(!publisher_matches(&[])); + assert!(!publisher_matches(&[0])); + assert!(!publisher_matches(&[0xD800, 0])); + } + + #[test] + fn pe_certificate_directory_must_be_completely_absent() { + use std::io::Cursor; + + for (magic, directory_start, optional_size) in [(0x10bu16, 96, 224), (0x20b, 112, 240)] { + let mut bytes = vec![0u8; 64 + 24 + optional_size]; + bytes[..2].copy_from_slice(b"MZ"); + bytes[60..64].copy_from_slice(&64u32.to_le_bytes()); + bytes[64..68].copy_from_slice(b"PE\0\0"); + bytes[70..72].copy_from_slice(&1u16.to_le_bytes()); + bytes[84..86].copy_from_slice(&(optional_size as u16).to_le_bytes()); + bytes[86..88].copy_from_slice(&2u16.to_le_bytes()); + bytes[88..90].copy_from_slice(&magic.to_le_bytes()); + bytes[88 + directory_start - 4..88 + directory_start] + .copy_from_slice(&16u32.to_le_bytes()); + assert!(pe_has_no_certificate(&mut Cursor::new(&bytes))); + for nonzero in 0..8 { + let mut signed = bytes.clone(); + signed[88 + directory_start + 4 * 8 + nonzero] = 1; + assert!(!pe_has_no_certificate(&mut Cursor::new(signed))); + } + for truncated_at in [0, 63, 87, bytes.len() - 1] { + assert!(!pe_has_no_certificate(&mut Cursor::new( + &bytes[..truncated_at] + ))); + } + bytes[88 + directory_start - 4..88 + directory_start] + .copy_from_slice(&4u32.to_le_bytes()); + assert!(!pe_has_no_certificate(&mut Cursor::new(bytes))); + } + } + + #[test] + fn native_verification_rejects_missing_malformed_and_nonfiles() { + let temporary = tempfile::tempdir().unwrap(); + let file = temporary.path().join("not-signed.exe"); + std::fs::write(&file, b"not a signed portable executable").unwrap(); + for path in [ + file, + temporary.path().join("missing.exe"), + temporary.path().to_owned(), + Path::new("relative.exe").to_owned(), + ] { + assert!(!verify(&path)); + assert!(!verify_or_unsigned(&path, true)); + assert!(!verify_startup_or_unsigned(&path, false)); + assert!(!verify_startup_or_unsigned(&path, true)); + } + } + + #[test] + fn native_unsigned_test_executable_requires_the_explicit_dev_exception() { + // Cargo's test executable has not passed through the release signing + // job. It exercises the real Windows provider, without running any + // downloaded file or installing a test certificate into the OS store. + let executable = std::env::current_exe().unwrap(); + assert!(!verify(&executable)); + assert!(verify_or_unsigned(&executable, true)); + assert!(!verify_startup_or_unsigned(&executable, false)); + assert!(verify_startup_or_unsigned(&executable, true)); + } + + #[test] + fn native_microsoft_publisher_is_rejected_even_for_unsigned_dev() { + // Native qualification separately proves this fixed Windows binary has + // a valid Microsoft signature; another publisher cannot be a Dev bypass. + let executable = std::path::PathBuf::from(std::env::var_os("SystemRoot").unwrap()) + .join("System32") + .join("WindowsPowerShell") + .join("v1.0") + .join("powershell.exe"); + assert!(executable.is_file()); + assert!(!verify(&executable)); + assert!(!verify_or_unsigned(&executable, true)); + assert!(!verify_startup_or_unsigned(&executable, false)); + assert!(!verify_startup_or_unsigned(&executable, true)); + } +} diff --git a/apps/maple-agent/crates/maple-agent-updater/src/installer/windows_bundle/windows_fs.rs b/apps/maple-agent/crates/maple-agent-updater/src/installer/windows_bundle/windows_fs.rs new file mode 100644 index 000000000..a20bc398d --- /dev/null +++ b/apps/maple-agent/crates/maple-agent-updater/src/installer/windows_bundle/windows_fs.rs @@ -0,0 +1,837 @@ +//! Handle-checked, per-user Windows storage. No path is followed through a +//! junction/reparse point and existing ACLs are never repaired by taking over +//! a directory. Pointer publication uses a same-directory write-through move. + +use std::{ + ffi::{OsStr, c_void}, + fs::{self, File}, + io::{self, Read}, + mem::size_of, + os::windows::{ + ffi::OsStrExt, + io::{AsRawHandle, FromRawHandle}, + }, + path::{Component, Path, PathBuf, Prefix}, + ptr::{null, null_mut}, +}; + +use windows_sys::Win32::{ + Foundation::{ + CloseHandle, GENERIC_ALL, GENERIC_READ, GENERIC_WRITE, HANDLE, INVALID_HANDLE_VALUE, + LocalFree, + }, + Security::{ + ACCESS_ALLOWED_ACE, ACE_HEADER, ACL, ACL_SIZE_INFORMATION, AclSizeInformation, + Authorization::{ + ConvertSidToStringSidW, ConvertStringSecurityDescriptorToSecurityDescriptorW, + GetSecurityInfo, SDDL_REVISION_1, SE_FILE_OBJECT, + }, + CreateWellKnownSid, DACL_SECURITY_INFORMATION, EqualSid, GetAce, GetAclInformation, + GetTokenInformation, INHERIT_ONLY_ACE, IsValidAcl, IsValidSid, OWNER_SECURITY_INFORMATION, + PSID, SECURITY_ATTRIBUTES, SECURITY_MAX_SID_SIZE, TOKEN_QUERY, TOKEN_USER, TokenUser, + WinBuiltinAdministratorsSid, WinLocalSystemSid, + }, + Storage::FileSystem::{ + BY_HANDLE_FILE_INFORMATION, CreateDirectoryW, CreateFileW, DELETE, FILE_APPEND_DATA, + FILE_ATTRIBUTE_DIRECTORY, FILE_ATTRIBUTE_REPARSE_POINT, FILE_DELETE_CHILD, + FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_READ_ATTRIBUTES, + FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, FILE_WRITE_ATTRIBUTES, + FILE_WRITE_DATA, FILE_WRITE_EA, GetFileInformationByHandle, MOVEFILE_REPLACE_EXISTING, + MOVEFILE_WRITE_THROUGH, MoveFileExW, OPEN_ALWAYS, OPEN_EXISTING, READ_CONTROL, WRITE_DAC, + WRITE_OWNER, + }, + System::{ + SystemServices::{ACCESS_ALLOWED_ACE_TYPE, ACCESS_DENIED_ACE_TYPE}, + Threading::{GetCurrentProcess, OpenProcessToken}, + }, +}; + +const WRITE_RIGHTS: u32 = GENERIC_ALL + | GENERIC_WRITE + | DELETE + | WRITE_DAC + | WRITE_OWNER + | FILE_WRITE_DATA + | FILE_APPEND_DATA + | FILE_WRITE_EA + | FILE_WRITE_ATTRIBUTES + | FILE_DELETE_CHILD; + +fn invalid() -> io::Error { + io::Error::new( + io::ErrorKind::PermissionDenied, + "unsafe Windows updater storage", + ) +} + +fn wide(value: &OsStr) -> io::Result> { + let mut result: Vec = value.encode_wide().collect(); + if result.contains(&0) || result.len() > 32_000 { + return Err(invalid()); + } + result.push(0); + Ok(result) +} + +/// Only absolute local drive paths are accepted. Verbatim disk paths produced +/// by canonicalize are accepted; device paths, UNC paths and ADS are refused. +fn checked_components(path: &Path) -> io::Result> { + let mut components = path.components(); + let prefix = components.next().ok_or_else(invalid)?; + if !matches!(prefix, Component::Prefix(p) if matches!(p.kind(), Prefix::Disk(_) | Prefix::VerbatimDisk(_))) + || !matches!(components.next(), Some(Component::RootDir)) + { + return Err(invalid()); + } + let mut current = PathBuf::from(prefix.as_os_str()); + current.push("\\"); + let mut paths = vec![current.clone()]; + for part in components { + let Component::Normal(name) = part else { + return Err(invalid()); + }; + let name = name.to_str().ok_or_else(invalid)?; + // Windows aliases (trailing space/dot), ADS and reserved device names + // must not name different objects after a Win32 operation normalizes. + let stem = name + .split('.') + .next() + .unwrap_or_default() + .to_ascii_uppercase(); + if name.is_empty() + || name.ends_with('.') + || name.ends_with(' ') + || name + .chars() + .any(|c| c.is_control() || matches!(c, ':' | '<' | '>' | '"' | '|' | '?' | '*')) + || matches!( + stem.as_str(), + "CON" | "PRN" | "AUX" | "NUL" | "CONIN$" | "CONOUT$" + ) + || ["COM", "LPT"].iter().any(|prefix| { + stem.strip_prefix(prefix).is_some_and(|suffix| { + let mut chars = suffix.chars(); + chars + .next() + .is_some_and(|c| c.is_ascii_digit() || matches!(c, 'ΒΉ' | 'Β²' | 'Β³')) + && chars.next().is_none() + }) + }) + { + return Err(invalid()); + } + current.push(name); + paths.push(current.clone()); + } + Ok(paths) +} + +fn open_raw( + path: &Path, + access: u32, + disposition: u32, + security: *const SECURITY_ATTRIBUTES, +) -> io::Result { + open_raw_shared( + path, + access, + disposition, + security, + FILE_SHARE_READ | FILE_SHARE_WRITE, + ) +} + +fn open_raw_shared( + path: &Path, + access: u32, + disposition: u32, + security: *const SECURITY_ATTRIBUTES, + sharing: u32, +) -> io::Result { + let name = wide(path.as_os_str())?; + // SAFETY: name and optional security descriptor outlive CreateFileW. The + // returned noninheritable handle is transferred exactly once into File. + let handle = unsafe { + CreateFileW( + name.as_ptr(), + access, + sharing, + security, + disposition, + FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS, + null_mut(), + ) + }; + if handle == INVALID_HANDLE_VALUE { + return Err(io::Error::last_os_error()); + } + Ok(unsafe { File::from_raw_handle(handle.cast()) }) +} + +fn validate_path_binding(path: &Path, file: &File) -> io::Result<()> { + let first = information(file)?; + let current = open_raw(path, FILE_READ_ATTRIBUTES, OPEN_EXISTING, null())?; + let second = information(¤t)?; + if ( + first.dwVolumeSerialNumber, + first.nFileIndexHigh, + first.nFileIndexLow, + ) != ( + second.dwVolumeSerialNumber, + second.nFileIndexHigh, + second.nFileIndexLow, + ) { + return Err(invalid()); + } + Ok(()) +} + +fn information(file: &File) -> io::Result { + let mut info = BY_HANDLE_FILE_INFORMATION::default(); + if unsafe { GetFileInformationByHandle(file.as_raw_handle().cast(), &mut info) } == 0 { + return Err(io::Error::last_os_error()); + } + if info.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT != 0 + || (info.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY == 0 && info.nNumberOfLinks != 1) + { + return Err(invalid()); + } + Ok(info) +} + +/// Retaining ancestor handles without FILE_SHARE_DELETE fences rename/removal +/// of those exact components while the privileged open/create is performed. +fn ancestors(path: &Path, include_leaf: bool) -> io::Result> { + let paths = checked_components(path)?; + let count = paths.len().saturating_sub(usize::from(!include_leaf)); + let mut guards = Vec::with_capacity(count); + for path in paths.iter().take(count) { + let file = open_raw(path, FILE_READ_ATTRIBUTES, OPEN_EXISTING, null())?; + let info = information(&file)?; + if path != paths.last().unwrap() || !include_leaf { + if info.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY == 0 { + return Err(invalid()); + } + } + guards.push(file); + } + Ok(guards) +} + +pub(super) fn validate_path_syntax(path: &Path) -> io::Result<()> { + checked_components(path).map(|_| ()) +} + +pub(super) fn validate_path(path: &Path) -> io::Result<()> { + ancestors(path, true).map(|_| ()) +} + +struct Token(HANDLE); +impl Drop for Token { + fn drop(&mut self) { + unsafe { + CloseHandle(self.0); + } + } +} + +struct Local(*mut c_void); +impl Drop for Local { + fn drop(&mut self) { + if !self.0.is_null() { + unsafe { + LocalFree(self.0); + } + } + } +} + +struct CurrentUser { + storage: Vec, +} +impl CurrentUser { + fn new() -> io::Result { + let mut token = null_mut(); + if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) } == 0 { + return Err(io::Error::last_os_error()); + } + let token = Token(token); + let mut length = 0; + unsafe { + GetTokenInformation(token.0, TokenUser, null_mut(), 0, &mut length); + } + if length < size_of::() as u32 || length > 4096 { + return Err(invalid()); + } + let mut storage = vec![0usize; (length as usize).div_ceil(size_of::())]; + if unsafe { + GetTokenInformation( + token.0, + TokenUser, + storage.as_mut_ptr().cast(), + length, + &mut length, + ) + } == 0 + { + return Err(io::Error::last_os_error()); + } + Ok(Self { storage }) + } + fn sid(&self) -> PSID { + // SAFETY: GetTokenInformation initialized an aligned TOKEN_USER and its + // referenced SID in storage, which stays allocated for this borrow. + unsafe { (*(self.storage.as_ptr().cast::())).User.Sid } + } + fn string(&self) -> io::Result { + let mut value = null_mut(); + if unsafe { ConvertSidToStringSidW(self.sid(), &mut value) } == 0 { + return Err(io::Error::last_os_error()); + } + let allocation = Local(value.cast()); + let mut length = 0; + while length < 256 && unsafe { *value.add(length) } != 0 { + length += 1; + } + if length == 256 { + return Err(invalid()); + } + let result = String::from_utf16(unsafe { std::slice::from_raw_parts(value, length) }) + .map_err(|_| invalid())?; + drop(allocation); + Ok(result) + } +} + +fn known_sid(kind: i32) -> io::Result> { + let mut data = vec![0u32; (SECURITY_MAX_SID_SIZE as usize).div_ceil(4)]; + let mut length = SECURITY_MAX_SID_SIZE; + if unsafe { CreateWellKnownSid(kind, null_mut(), data.as_mut_ptr().cast(), &mut length) } == 0 { + return Err(io::Error::last_os_error()); + } + Ok(data) +} + +fn same_sid(a: PSID, b: PSID) -> bool { + !a.is_null() && !b.is_null() && unsafe { EqualSid(a, b) } != 0 +} + +fn validate_private_handle(file: &File) -> io::Result<()> { + information(file)?; + let user = CurrentUser::new()?; + let system = known_sid(WinLocalSystemSid)?; + let admins = known_sid(WinBuiltinAdministratorsSid)?; + let mut owner = null_mut(); + let mut dacl: *mut ACL = null_mut(); + let mut descriptor = null_mut(); + let code = unsafe { + GetSecurityInfo( + file.as_raw_handle().cast(), + SE_FILE_OBJECT, + OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION, + &mut owner, + null_mut(), + &mut dacl, + null_mut(), + &mut descriptor, + ) + }; + if code != 0 { + return Err(io::Error::from_raw_os_error(code as i32)); + } + let _allocation = Local(descriptor); + if !same_sid(owner, user.sid()) || dacl.is_null() || unsafe { IsValidAcl(dacl) } == 0 { + return Err(invalid()); + } + let mut size = ACL_SIZE_INFORMATION::default(); + if unsafe { + GetAclInformation( + dacl, + (&mut size as *mut ACL_SIZE_INFORMATION).cast(), + size_of::() as u32, + AclSizeInformation, + ) + } == 0 + || size.AceCount > 1024 + { + return Err(invalid()); + } + for index in 0..size.AceCount { + let mut raw = null_mut(); + if unsafe { GetAce(dacl, index, &mut raw) } == 0 || raw.is_null() { + return Err(invalid()); + } + let header = unsafe { &*raw.cast::() }; + if header.AceFlags as u32 & INHERIT_ONLY_ACE != 0 { + continue; + } + if header.AceType as u32 == ACCESS_DENIED_ACE_TYPE { + continue; + } + if header.AceType as u32 != ACCESS_ALLOWED_ACE_TYPE + || (header.AceSize as usize) < size_of::() + 4 + { + return Err(invalid()); + } + let ace = unsafe { &*raw.cast::() }; + let sid = (&ace.SidStart as *const u32).cast_mut().cast::(); + // Bound the SID inside its ACE before asking Windows to inspect it. + let subauthorities = unsafe { *(sid.cast::().add(1)) } as usize; + if subauthorities > 15 + || 8 + 8 + subauthorities * 4 > header.AceSize as usize + || unsafe { IsValidSid(sid) } == 0 + { + return Err(invalid()); + } + if ace.Mask & WRITE_RIGHTS != 0 + && !same_sid(sid, user.sid()) + && !same_sid(sid, system.as_ptr().cast_mut().cast()) + && !same_sid(sid, admins.as_ptr().cast_mut().cast()) + { + return Err(invalid()); + } + } + Ok(()) +} + +fn private_security() -> io::Result { + let sid = CurrentUser::new()?.string()?; + let sddl = wide(OsStr::new(&format!( + "O:{sid}D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;FA;;;{sid})" + )))?; + let mut descriptor = null_mut(); + if unsafe { + ConvertStringSecurityDescriptorToSecurityDescriptorW( + sddl.as_ptr(), + SDDL_REVISION_1, + &mut descriptor, + null_mut(), + ) + } == 0 + { + return Err(io::Error::last_os_error()); + } + Ok(Local(descriptor)) +} + +fn security_attributes(descriptor: &Local) -> SECURITY_ATTRIBUTES { + SECURITY_ATTRIBUTES { + nLength: size_of::() as u32, + lpSecurityDescriptor: descriptor.0, + bInheritHandle: 0, + } +} + +pub(super) fn private_directory(path: &Path) -> io::Result<()> { + checked_components(path)?; + if fs::symlink_metadata(path).is_err_and(|e| e.kind() == io::ErrorKind::NotFound) { + let parent = path.parent().ok_or_else(invalid)?; + if !parent.exists() { + private_directory(parent)?; + } + let _parents = ancestors(path, false)?; + let descriptor = private_security()?; + let attributes = security_attributes(&descriptor); + let name = wide(path.as_os_str())?; + if unsafe { CreateDirectoryW(name.as_ptr(), &attributes) } == 0 { + let error = io::Error::last_os_error(); + if error.kind() != io::ErrorKind::AlreadyExists { + return Err(error); + } + } + } + let _parents = ancestors(path, false)?; + let directory = open_raw( + path, + READ_CONTROL | FILE_READ_ATTRIBUTES, + OPEN_EXISTING, + null(), + )?; + if information(&directory)?.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY == 0 { + return Err(invalid()); + } + validate_private_handle(&directory)?; + validate_path_binding(path, &directory) +} + +pub(super) fn open_private(path: &Path, truncate: bool) -> io::Result { + let _parents = ancestors(path, false)?; + let descriptor = private_security()?; + let attributes = security_attributes(&descriptor); + let file = open_raw( + path, + GENERIC_READ | GENERIC_WRITE | READ_CONTROL, + OPEN_ALWAYS, + &attributes, + )?; + if information(&file)?.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY != 0 { + return Err(invalid()); + } + validate_private_handle(&file)?; + validate_path_binding(path, &file)?; + if truncate { + file.set_len(0)?; + } + Ok(file) +} + +pub(super) fn open_readonly(path: &Path) -> io::Result { + let _parents = ancestors(path, false)?; + let file = open_raw_shared( + path, + GENERIC_READ | READ_CONTROL, + OPEN_EXISTING, + null(), + FILE_SHARE_READ, + )?; + if information(&file)?.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY != 0 { + return Err(invalid()); + } + validate_private_handle(&file)?; + validate_path_binding(path, &file)?; + Ok(file) +} + +/// Validate/read an existing mapped image or a file already protected by the +/// caller's mutation guard. This does not pin bytes against concurrent writes; +/// use open_readonly when mutation-boundary validation needs that guarantee. +pub(super) fn open_shared_readonly(path: &Path) -> io::Result { + let _parents = ancestors(path, false)?; + let file = open_raw_shared( + path, + GENERIC_READ | READ_CONTROL, + OPEN_EXISTING, + null(), + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + )?; + if information(&file)?.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY != 0 { + return Err(invalid()); + } + validate_private_handle(&file)?; + validate_path_binding(path, &file)?; + Ok(file) +} + +/// Acquire only after tree, PE identity and signature validation. Holding write +/// access prevents a new executable image mapping while delete sharing permits +/// the owner's validated removal to retain this guard through unlink. +pub(super) fn open_executable_mutation_guard(path: &Path) -> io::Result { + let _parents = ancestors(path, false)?; + let file = open_raw_shared( + path, + GENERIC_READ | GENERIC_WRITE | READ_CONTROL, + OPEN_EXISTING, + null(), + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + )?; + if information(&file)?.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY != 0 { + return Err(invalid()); + } + validate_private_handle(&file)?; + validate_path_binding(path, &file)?; + Ok(file) +} + +pub(super) fn read_bounded(path: &Path, maximum: u64) -> io::Result>> { + let file = match open_readonly(path) { + Ok(file) => file, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error), + }; + if file.metadata()?.len() > maximum { + return Err(invalid()); + } + let mut data = Vec::new(); + file.take(maximum.saturating_add(1)) + .read_to_end(&mut data)?; + if data.len() as u64 > maximum { + return Err(invalid()); + } + Ok(Some(data)) +} + +/// Windows does not implement Unix directory fsync. Keep all path/ACL checks, +/// and require atomic_replace for durable pointer/state-name publication. +pub(super) fn sync_directory(path: &Path) -> io::Result<()> { + let _parents = ancestors(path, false)?; + let directory = open_raw( + path, + READ_CONTROL | FILE_READ_ATTRIBUTES, + OPEN_EXISTING, + null(), + )?; + if information(&directory)?.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY == 0 { + return Err(invalid()); + } + validate_private_handle(&directory)?; + validate_path_binding(path, &directory) +} + +pub(super) fn atomic_replace(source: &Path, destination: &Path) -> io::Result<()> { + if source == destination || source.parent() != destination.parent() { + return Err(invalid()); + } + let parent = source.parent().ok_or_else(invalid)?; + private_directory(parent)?; + let _parents = ancestors(source, false)?; + { + let input = open_raw( + source, + GENERIC_READ | GENERIC_WRITE | READ_CONTROL, + OPEN_EXISTING, + null(), + )?; + if information(&input)?.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY != 0 { + return Err(invalid()); + } + validate_private_handle(&input)?; + input.sync_all()?; + } + match open_raw( + destination, + READ_CONTROL | FILE_READ_ATTRIBUTES, + OPEN_EXISTING, + null(), + ) { + Ok(file) => { + if information(&file)?.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY != 0 { + return Err(invalid()); + } + validate_private_handle(&file)?; + } + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(error), + } + let old = wide(source.as_os_str())?; + let new = wide(destination.as_os_str())?; + if unsafe { + MoveFileExW( + old.as_ptr(), + new.as_ptr(), + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH, + ) + } == 0 + { + return Err(io::Error::last_os_error()); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn refuses_nonlocal_and_ambiguous_windows_names() { + for value in [ + r"relative\state", + r"\\server\share\state", + r"\\.\C:\state", + r"C:\state:other", + r"C:\state.\x", + r"C:\CON\x", + r"C:\COM1.txt", + r"C:\LPTΒΉ.txt", + r"C:\CONOUT$", + ] { + assert!(checked_components(Path::new(value)).is_err(), "{value}"); + } + } + + #[test] + fn private_storage_round_trip_and_atomic_replacement() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path().join("protected"); + private_directory(&root).unwrap(); + let target = root.join("state.json"); + let mut old = open_private(&target, false).unwrap(); + std::io::Write::write_all(&mut old, b"old").unwrap(); + old.sync_all().unwrap(); + drop(old); + assert_eq!(read_bounded(&target, 64).unwrap().unwrap(), b"old"); + let pending = root.join("pending.json"); + let mut file = open_private(&pending, false).unwrap(); + std::io::Write::write_all(&mut file, b"new").unwrap(); + file.sync_all().unwrap(); + drop(file); + atomic_replace(&pending, &target).unwrap(); + assert_eq!(read_bounded(&target, 64).unwrap().unwrap(), b"new"); + assert!(!pending.exists()); + } + + #[test] + fn rejects_hard_linked_state_and_preserves_oversized_file() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path().join("protected"); + private_directory(&root).unwrap(); + let state = root.join("state"); + let file = open_private(&state, false).unwrap(); + file.set_len(128).unwrap(); + drop(file); + assert!(read_bounded(&state, 64).is_err()); + fs::hard_link(&state, root.join("alias")).unwrap(); + assert!(open_private(&state, true).is_err()); + assert!(open_executable_mutation_guard(&state).is_err()); + assert_eq!(fs::metadata(&state).unwrap().len(), 128); + } + + #[test] + fn retained_delete_capable_guard_allows_owned_unlink() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path().join("protected"); + private_directory(&root).unwrap(); + let executable = root.join("fixture.exe"); + let mut file = open_private(&executable, false).unwrap(); + std::io::Write::write_all(&mut file, b"unchanged executable fixture").unwrap(); + file.sync_all().unwrap(); + drop(file); + let guard = open_executable_mutation_guard(&executable).unwrap(); + assert_eq!( + fs::read(&executable).unwrap(), + b"unchanged executable fixture" + ); + fs::remove_file(&executable).unwrap(); + drop(guard); + assert!(!executable.exists()); + assert!(open_executable_mutation_guard(&executable).is_err()); + assert!(!executable.exists(), "guard must not create absent files"); + } + + #[test] + fn native_mutation_guard_blocks_loader_until_released() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path().join("protected"); + private_directory(&root).unwrap(); + let executable = root.join("maple-agent.exe"); + // Copy this native updater test binary into explicitly private storage. + // --list only enumerates its tests; it starts no app, GUI or backend. + let mut source = File::open(std::env::current_exe().unwrap()).unwrap(); + let mut copied = open_private(&executable, false).unwrap(); + io::copy(&mut source, &mut copied).unwrap(); + copied.sync_all().unwrap(); + drop(copied); + drop(source); + + let guard = open_executable_mutation_guard(&executable).unwrap(); + assert!( + std::process::Command::new(&executable) + .arg("--list") + .output() + .is_err(), + "a retained mutation guard must prevent Windows from mapping the image" + ); + drop(guard); + let result = std::process::Command::new(&executable) + .arg("--list") + .output() + .expect("the same validated test image must load after releasing the guard"); + assert!( + result.status.success(), + "native test listing failed: {result:?}" + ); + assert!( + String::from_utf8(result.stdout) + .unwrap() + .contains("native_mutation_guard_blocks_loader_until_released"), + "the launched image must be this native updater test binary" + ); + } + + #[test] + fn native_shared_read_validation_accepts_mapped_image_and_mutation_guard() { + let mapped = open_shared_readonly(&std::env::current_exe().unwrap()) + .expect("shared read validation must admit the mapped native test image"); + assert!(mapped.metadata().unwrap().len() > 0); + drop(mapped); + + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path().join("protected"); + private_directory(&root).unwrap(); + let path = root.join("fixture.exe"); + let bytes = b"unchanged guarded fixture"; + let mut file = open_private(&path, false).unwrap(); + std::io::Write::write_all(&mut file, bytes).unwrap(); + file.sync_all().unwrap(); + drop(file); + let guard = open_executable_mutation_guard(&path).unwrap(); + let mut shared = open_shared_readonly(&path).unwrap(); + let mut actual = Vec::new(); + shared.read_to_end(&mut actual).unwrap(); + assert_eq!(actual, bytes); + assert_eq!(shared.metadata().unwrap().len(), bytes.len() as u64); + drop(shared); + drop(guard); + assert_eq!(fs::read(&path).unwrap(), bytes); + assert!(open_shared_readonly(&root.join("absent")).is_err()); + assert!(!root.join("absent").exists()); + } + + #[test] + fn rejects_existing_foreign_write_acl_before_truncation() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path().join("protected"); + private_directory(&root).unwrap(); + let state = root.join("unsafe-state"); + let sid = CurrentUser::new().unwrap().string().unwrap(); + let sddl = wide(OsStr::new(&format!( + "O:{sid}D:P(A;;FA;;;{sid})(A;;FW;;;WD)" + ))) + .unwrap(); + let mut descriptor = null_mut(); + assert_ne!( + unsafe { + ConvertStringSecurityDescriptorToSecurityDescriptorW( + sddl.as_ptr(), + SDDL_REVISION_1, + &mut descriptor, + null_mut(), + ) + }, + 0 + ); + let descriptor = Local(descriptor); + let attributes = security_attributes(&descriptor); + let mut file = open_raw( + &state, + GENERIC_READ | GENERIC_WRITE | READ_CONTROL, + OPEN_ALWAYS, + &attributes, + ) + .unwrap(); + std::io::Write::write_all(&mut file, b"preserve").unwrap(); + drop(file); + assert!(open_readonly(&state).is_err()); + assert!(open_private(&state, true).is_err()); + assert!(open_executable_mutation_guard(&state).is_err()); + assert_eq!(fs::read(&state).unwrap(), b"preserve"); + } + + #[test] + fn rejects_file_and_ancestor_reparse_points_when_symlink_privilege_available() { + use std::os::windows::fs::{symlink_dir, symlink_file}; + + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path().join("protected"); + private_directory(&root).unwrap(); + let state = root.join("state"); + drop(open_private(&state, false).unwrap()); + let alias = root.join("alias"); + if let Err(error) = symlink_file(&state, &alias) { + // Non-developer Windows accounts may not hold SeCreateSymbolicLinkPrivilege. + // The native qualification harness must exercise reparse denial on + // a host that supports the fixture; other errors remain failures. + if error.raw_os_error() == Some(1314) { + return; + } + panic!("create file symlink fixture: {error}"); + } + assert!(validate_path(&alias).is_err()); + assert!(open_readonly(&alias).is_err()); + assert!(open_private(&alias, true).is_err()); + assert!(open_executable_mutation_guard(&alias).is_err()); + let directory_alias = temporary.path().join("alias-directory"); + symlink_dir(&root, &directory_alias).unwrap(); + assert!(open_private(&directory_alias.join("new-state"), false).is_err()); + assert!(!root.join("new-state").exists()); + } +} diff --git a/apps/maple-agent/crates/maple-agent-updater/src/installer/windows_bundle_tests.rs b/apps/maple-agent/crates/maple-agent-updater/src/installer/windows_bundle_tests.rs new file mode 100644 index 000000000..81e3d59dd --- /dev/null +++ b/apps/maple-agent/crates/maple-agent-updater/src/installer/windows_bundle_tests.rs @@ -0,0 +1,492 @@ +//! Data-only fixtures: these tests never run a downloaded executable. +use super::*; +use flate2::{Compression, write::GzEncoder}; + +#[derive(Clone)] +struct Member { + name: String, + bytes: Vec, + kind: u8, + mode: u32, + link: Option, +} + +fn bundle(profile: &str) -> Bundle { + Bundle { + format: "maple-agent-windows-bundle-v1".into(), + product: "maple-agent".into(), + profile: profile.into(), + version: "0.0.10".into(), + build: "702".into(), + source_sha: "a".repeat(40), + bundle_id: profile_identity(profile).unwrap().into(), + entrypoint: "maple-agent.exe".into(), + } +} + +fn members(bundle: &Bundle) -> Vec { + let mut members: Vec = DIRS + .iter() + .map(|name| Member { + name: if name.is_empty() { + ROOT.into() + } else { + format!("{ROOT}/{name}") + }, + bytes: Vec::new(), + kind: b'5', + mode: 0o755, + link: None, + }) + .collect(); + members.extend(FILES.iter().map(|name| Member { + name: format!("{ROOT}/{name}"), + bytes: if *name == DESCRIPTOR { + serde_json::to_vec(bundle).unwrap() + } else { + format!("non-executable fixture: {name}").into_bytes() + }, + kind: b'0', + mode: 0o644, + link: None, + })); + members +} + +fn private_fixture() -> tempfile::TempDir { + let directory = tempfile::tempdir().unwrap(); + #[cfg(windows)] + windows_fs::private_directory(directory.path()).unwrap(); + directory +} + +fn write_archive(path: &Path, members: &[Member]) { + let output = File::create(path).unwrap(); + let mut writer = tar::Builder::new(GzEncoder::new(output, Compression::fast())); + for member in members { + let mut header = tar::Header::new_ustar(); + header.set_mode(member.mode); + header.set_size(member.bytes.len() as u64); + header.set_entry_type(tar::EntryType::new(member.kind)); + header.set_uid(0); + header.set_gid(0); + header.set_mtime(0); + // Unsafe fixtures reach the real reader instead of tar's safe writer. + assert!(member.name.len() < 100); + header.as_mut_bytes()[..100].fill(0); + header.as_mut_bytes()[..member.name.len()].copy_from_slice(member.name.as_bytes()); + if let Some(link) = &member.link { + header.set_link_name(link).unwrap(); + } + header.set_cksum(); + writer.append(&header, member.bytes.as_slice()).unwrap(); + } + writer + .into_inner() + .unwrap() + .finish() + .unwrap() + .sync_all() + .unwrap(); +} + +#[test] +fn exact_archive_round_trip_binds_all_nine_files_and_three_directories() { + let directory = private_fixture(); + let payload = directory.path().join("update.tar.gz"); + let output = directory.path().join("extract"); + fs::create_dir(&output).unwrap(); + let descriptor = bundle("prod"); + write_archive(&payload, &members(&descriptor)); + validate_windows_bundle_archive(&payload, "prod").unwrap(); + assert!(validate_windows_bundle_archive(&payload, "dev").is_err()); + let (expected, actual) = archive(&payload, Some(&output), "prod").unwrap(); + assert_eq!(actual, descriptor); + assert_eq!(expected.len(), 12); + assert_eq!( + expected + .values() + .filter(|node| matches!(node, Node::File { .. })) + .count(), + 9 + ); + assert_eq!( + expected + .values() + .filter(|node| matches!(node, Node::Directory)) + .count(), + 3 + ); + assert_eq!(scan_generation(&output.join(ROOT)).unwrap(), expected); + fs::write(output.join(ROOT).join("LICENSE.txt"), b"tampered").unwrap(); + assert_ne!(scan_generation(&output.join(ROOT)).unwrap(), expected); +} + +#[test] +fn rejected_archive_never_begins_extraction() { + let directory = private_fixture(); + let payload = directory.path().join("update.tar.gz"); + let output = directory.path().join("extract"); + fs::create_dir(&output).unwrap(); + let valid = members(&bundle("prod")); + let executable_index = valid + .iter() + .position(|entry| entry.name.ends_with("/maple-agent.exe")) + .unwrap(); + let mut cases = Vec::new(); + let mut missing = valid.clone(); + missing.remove(executable_index); + cases.push(missing); + let mut duplicate = valid.clone(); + duplicate.push(valid[executable_index].clone()); + cases.push(duplicate); + let mut extra = valid.clone(); + extra.push(Member { + name: format!("{ROOT}/uninstall.exe"), + bytes: b"not an updater payload".to_vec(), + kind: b'0', + mode: 0o644, + link: None, + }); + cases.push(extra); + for name in [ + format!("{ROOT}/../outside"), + format!("{ROOT}/notices/../../outside"), + format!("{ROOT}/Maple-Agent.exe"), + format!("{ROOT}/maple-agent.exe:alternate"), + format!("{ROOT}/notices\\other"), + format!("{ROOT}/maple-agent.exe."), + "wrong-root/maple-agent.exe".into(), + format!("/{ROOT}/maple-agent.exe"), + ] { + let mut altered = valid.clone(); + altered[executable_index].name = name; + cases.push(altered); + } + for (kind, mode, link) in [ + (b'2', 0o777, Some("LICENSE.txt")), + (b'1', 0o644, Some("LICENSE.txt")), + (b'6', 0o644, None), + (b'0', 0o755, None), + ] { + let mut altered = valid.clone(); + altered[executable_index].kind = kind; + altered[executable_index].mode = mode; + altered[executable_index].link = link.map(str::to_owned); + if kind != b'0' { + altered[executable_index].bytes.clear(); + } + cases.push(altered); + } + for (index, case) in cases.iter().enumerate() { + write_archive(&payload, case); + assert!( + archive(&payload, Some(&output), "prod").is_err(), + "case {index}" + ); + assert_eq!( + fs::read_dir(&output).unwrap().count(), + 0, + "case {index} wrote files before admission" + ); + } +} + +#[test] +fn descriptors_bind_profile_product_release_and_strict_json_fields() { + let original = serde_json::to_value(bundle("prod")).unwrap(); + assert_eq!( + Bundle::parse(&serde_json::to_vec(&original).unwrap(), "prod").unwrap(), + bundle("prod") + ); + assert!(Bundle::parse(&serde_json::to_vec(&original).unwrap(), "dev").is_err()); + let development = bundle("dev"); + Bundle::parse(&serde_json::to_vec(&development).unwrap(), "dev").unwrap(); + for (field, value) in [ + ("format", "maple-agent-windows-bundle-v2"), + ("product", "maple-research"), + ("profile", "local"), + ("bundle_id", "cloud.opensecret.maple.agent.dev"), + ("entrypoint", "../maple-agent.exe"), + ("version", "0.0.10-beta.1"), + ("version", "0.0.10+other"), + ("build", "0"), + ("build", "0702"), + ("build", "-1"), + ("build", "1e3"), + ("build", "1234567890123456789"), + ("source_sha", "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"), + ("source_sha", "aabbcc"), + ] { + let mut altered = original.clone(); + altered[field] = value.into(); + assert!( + Bundle::parse(&serde_json::to_vec(&altered).unwrap(), "prod").is_err(), + "accepted {field}={value}" + ); + } + let mut extra = original.clone(); + extra["destination"] = "C:\\other".into(); + assert!(Bundle::parse(&serde_json::to_vec(&extra).unwrap(), "prod").is_err()); + assert!(Bundle::parse(&vec![b' '; DESCRIPTOR_LIMIT as usize + 1], "prod").is_err()); + assert!(Bundle::parse(b"", "prod").is_err()); + assert!(Bundle::parse(b"{\"format\":\"one\",\"format\":\"two\"}", "prod").is_err()); +} + +#[test] +fn descriptor_comparison_rejects_changed_release_version_build_or_source() { + let descriptor = bundle("prod"); + // This tests the post-authentication field comparison only. Signature + // verification is covered separately by manifest tests. + let release = crate::VerifiedRelease { + manifest: crate::Manifest { + format: "maple-agent-update-v3".into(), + product: "maple-agent".into(), + channel: "stable".into(), + version: descriptor.version.clone(), + build: descriptor.build.clone(), + source_sha: descriptor.source_sha.clone(), + pub_date: "2026-10-27T00:00:00Z".into(), + notes: "Data-only identity comparison fixture".into(), + artifacts: BTreeMap::new(), + downloads: crate::Downloads { + macos: None, + linux_appimage: None, + linux_bundle: None, + windows: None, + }, + }, + envelope: String::new(), + origin: "https://updates.example.test".parse().unwrap(), + }; + descriptor.matches_release(&release).unwrap(); + let mut altered = release.clone(); + altered.manifest.version = "0.0.11".into(); + assert!(descriptor.matches_release(&altered).is_err()); + let mut altered = release.clone(); + altered.manifest.build = "703".into(); + assert!(descriptor.matches_release(&altered).is_err()); + let mut altered = release.clone(); + altered.manifest.source_sha = "b".repeat(40); + assert!(descriptor.matches_release(&altered).is_err()); + assert!(bundle("dev").matches_release(&release).is_err()); +} + +#[test] +fn current_pointer_cannot_name_a_path_or_ambiguous_generation() { + let descriptor = bundle("prod"); + let current = Current::new(&descriptor); + let bytes = serde_json::to_vec(¤t).unwrap(); + assert_eq!(Current::parse(&bytes).unwrap(), current); + assert_eq!( + current.path(Path::new("installation")), + Path::new("installation") + .join("versions") + .join("0.0.10-702") + ); + for value in [ + "../0.0.10-702", + "0.0.10-702/other", + "0.0.10-702\\other", + "C:\\0.0.10-702", + "0.0.10-0702", + "0.0.10-0", + "0.0.10--1", + "0.0.10-beta-702", + "0.0.10+other-702", + "0.0.10-702.", + "0.0.10-702 ", + "0.0.10-702:other", + "0.0.10", + ] { + let bytes = + serde_json::to_vec(&serde_json::json!({"format": POINTER_FORMAT, "generation": value})) + .unwrap(); + assert!( + Current::parse(&bytes).is_err(), + "accepted generation {value}" + ); + } + for value in [ + serde_json::json!({"format":"other","generation":"0.0.10-702"}), + serde_json::json!({"format":POINTER_FORMAT,"generation":"0.0.10-702","path":"elsewhere"}), + serde_json::json!({"format":POINTER_FORMAT}), + serde_json::json!({"format":POINTER_FORMAT,"generation":702}), + ] { + assert!(Current::parse(&serde_json::to_vec(&value).unwrap()).is_err()); + } + assert!(Current::parse(&vec![b' '; 1025]).is_err()); + assert!(Current::parse(b"").is_err()); +} + +const PE_OFFSET: usize = 0x80; +const OPTIONAL_OFFSET: usize = PE_OFFSET + 24; +const SECTION_OFFSET: usize = OPTIONAL_OFFSET + 240; + +fn put16(bytes: &mut [u8], offset: usize, value: u16) { + bytes[offset..offset + 2].copy_from_slice(&value.to_le_bytes()); +} +fn put32(bytes: &mut [u8], offset: usize, value: u32) { + bytes[offset..offset + 4].copy_from_slice(&value.to_le_bytes()); +} + +fn pe_fixture(info: &serde_json::Value) -> Vec { + let metadata = serde_json::to_vec(info).unwrap(); + assert!(metadata.len() < 512); + let mut bytes = vec![0; 0x800]; + bytes[..2].copy_from_slice(b"MZ"); + put32(&mut bytes, 60, PE_OFFSET as u32); + bytes[PE_OFFSET..PE_OFFSET + 4].copy_from_slice(b"PE\0\0"); + put16(&mut bytes, PE_OFFSET + 4, 0x8664); + put16(&mut bytes, PE_OFFSET + 6, 2); + put16(&mut bytes, PE_OFFSET + 20, 240); + put16(&mut bytes, PE_OFFSET + 22, 2); + put16(&mut bytes, OPTIONAL_OFFSET, 0x20b); + put32(&mut bytes, OPTIONAL_OFFSET + 60, 0x200); + bytes[SECTION_OFFSET..SECTION_OFFSET + 8].copy_from_slice(b".maple\0\0"); + put32(&mut bytes, SECTION_OFFSET + 8, metadata.len() as u32); + put32(&mut bytes, SECTION_OFFSET + 16, 0x200); + put32(&mut bytes, SECTION_OFFSET + 20, 0x200); + put32(&mut bytes, SECTION_OFFSET + 36, 0x40000040); + let text = SECTION_OFFSET + 40; + bytes[text..text + 8].copy_from_slice(b".text\0\0\0"); + put32(&mut bytes, text + 8, 16); + put32(&mut bytes, text + 16, 0x200); + put32(&mut bytes, text + 20, 0x400); + put32(&mut bytes, text + 36, 0x60000020); + bytes[0x200..0x200 + metadata.len()].copy_from_slice(&metadata); + bytes +} + +fn read_pe(bytes: &[u8]) -> Result { + let directory = private_fixture(); + let path = directory.path().join("fixture.exe"); + fs::write(&path, bytes).unwrap(); + pe_build_info(&path) +} + +#[test] +fn pe_metadata_parser_reads_x64_maple_section_without_running_code() { + let info = serde_json::json!({"profile":"prod","bundle_id":"cloud.opensecret.maple.agent","version":"0.0.10","source_sha":"a".repeat(40)}); + assert_eq!(read_pe(&pe_fixture(&info)).unwrap(), info); +} + +#[test] +fn pe_metadata_parser_rejects_header_architecture_and_bounds_changes() { + let good = pe_fixture(&serde_json::json!({"version":"0.0.10"})); + let mut cases = Vec::new(); + for (offset, value) in [ + (PE_OFFSET + 4, 0x14c), + (PE_OFFSET + 6, 0), + (PE_OFFSET + 6, 97), + (PE_OFFSET + 20, 111), + (PE_OFFSET + 20, 4097), + (PE_OFFSET + 22, 0), + (PE_OFFSET + 22, 0x2002), + (OPTIONAL_OFFSET, 0x10b), + ] { + let mut altered = good.clone(); + put16(&mut altered, offset, value); + cases.push(altered); + } + for (offset, value) in [ + (60, 63), + (60, 1024 * 1024 + 1), + (OPTIONAL_OFFSET + 60, 0x180), + (OPTIONAL_OFFSET + 60, 1024 * 1024 + 1), + (SECTION_OFFSET + 20, 0x100), + (SECTION_OFFSET + 20, 0x800), + (SECTION_OFFSET + 16, 0xffff_ffff), + ] { + let mut altered = good.clone(); + put32(&mut altered, offset, value); + cases.push(altered); + } + let mut missing_mz = good.clone(); + missing_mz[0] = b'X'; + cases.push(missing_mz); + let mut missing_pe = good.clone(); + missing_pe[PE_OFFSET] = b'X'; + cases.push(missing_pe); + for length in [ + 0, + 63, + PE_OFFSET + 23, + OPTIONAL_OFFSET + 239, + SECTION_OFFSET + 79, + 0x3ff, + ] { + cases.push(good[..length].to_vec()); + } + for (index, bytes) in cases.iter().enumerate() { + assert!(read_pe(bytes).is_err(), "accepted changed header {index}"); + } +} + +#[test] +fn pe_metadata_parser_rejects_duplicate_writeable_executable_or_overlapping_sections() { + let good = pe_fixture(&serde_json::json!({"version":"0.0.10"})); + let mut cases = Vec::new(); + for flags in [0xc0000040, 0x60000040, 0x40000000, 0x40] { + let mut altered = good.clone(); + put32(&mut altered, SECTION_OFFSET + 36, flags); + cases.push(altered); + } + let mut duplicate = good.clone(); + let first: Vec = duplicate[SECTION_OFFSET..SECTION_OFFSET + 40].to_vec(); + duplicate[SECTION_OFFSET + 40..SECTION_OFFSET + 80].copy_from_slice(&first); + put32(&mut duplicate, SECTION_OFFSET + 40 + 20, 0x400); + cases.push(duplicate); + let mut overlap = good.clone(); + put32(&mut overlap, SECTION_OFFSET + 40 + 20, 0x300); + cases.push(overlap); + let mut missing = good.clone(); + missing[SECTION_OFFSET] = b'X'; + cases.push(missing); + for length in [0, DESCRIPTOR_LIMIT as u32 + 1, 513] { + let mut altered = good.clone(); + put32(&mut altered, SECTION_OFFSET + 8, length); + cases.push(altered); + } + let mut padding = good.clone(); + padding[0x3ff] = 1; + cases.push(padding); + let mut malformed = good.clone(); + malformed[0x200] = b'['; + cases.push(malformed); + for (index, bytes) in cases.iter().enumerate() { + assert!(read_pe(bytes).is_err(), "accepted unsafe section {index}"); + } +} + +#[test] +fn uninstall_journal_cannot_authorize_arbitrary_paths() { + let valid = serde_json::json!({"format":"maple-agent-windows-uninstall-intent-v1","profile":"prod","owner_pid":1,"owner_created":1,"generations":["0.0.9-701"]}); + assert!(UninstallIntent::parse(&serde_json::to_vec(&valid).unwrap(), "prod").is_ok()); + assert!(UninstallIntent::parse(&serde_json::to_vec(&valid).unwrap(), "dev").is_err()); + for name in [ + "../outside", + "C:\\outside", + "0.0.9-0", + "0.0.9-01", + "0.0.9-701/extra", + "0.0.9-701:stream", + ] { + let mut changed = valid.clone(); + changed["generations"] = serde_json::json!([name]); + assert!(UninstallIntent::parse(&serde_json::to_vec(&changed).unwrap(), "prod").is_err()); + } + let mut duplicate = valid.clone(); + duplicate["generations"] = serde_json::json!(["0.0.9-701", "0.0.9-701"]); + assert!(UninstallIntent::parse(&serde_json::to_vec(&duplicate).unwrap(), "prod").is_err()); + let mut unknown = valid.clone(); + unknown["root"] = serde_json::json!("C:\\outside"); + assert!(UninstallIntent::parse(&serde_json::to_vec(&unknown).unwrap(), "prod").is_err()); + let mut oversized = valid; + oversized["generations"] = serde_json::Value::Array( + (0..257) + .map(|n| serde_json::json!(format!("0.0.9-{}", n + 1))) + .collect(), + ); + assert!(UninstallIntent::parse(&serde_json::to_vec(&oversized).unwrap(), "prod").is_err()); +} diff --git a/apps/maple-agent/crates/maple-agent-updater/src/installer/windows_bundle_transaction_tests.rs b/apps/maple-agent/crates/maple-agent-updater/src/installer/windows_bundle_transaction_tests.rs new file mode 100644 index 000000000..186a25018 --- /dev/null +++ b/apps/maple-agent/crates/maple-agent-updater/src/installer/windows_bundle_transaction_tests.rs @@ -0,0 +1,526 @@ +//! Native filesystem transaction tests. Fixtures contain ordinary data files; +//! these tests do not waive Authenticode, verify installation admission, or run +//! an executable. Production admission and signature checks remain separate. +#![cfg(windows)] + +use super::*; +use std::cell::Cell; + +struct TransactionFixture { + _temporary: tempfile::TempDir, + root: PathBuf, + staged: PathBuf, + receipt: PathBuf, + previous: Current, + next: Current, + previous_nodes: BTreeMap, + next_nodes: BTreeMap, +} + +impl TransactionFixture { + fn new() -> Self { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path().join("installation"); + windows_fs::private_directory(&root).unwrap(); + windows_fs::private_directory(&root.join("versions")).unwrap(); + let previous = Current { + format: POINTER_FORMAT.into(), + generation: "0.0.9-701".into(), + }; + let next = Current { + format: POINTER_FORMAT.into(), + generation: "0.0.10-702".into(), + }; + let previous_path = previous.path(&root); + fixture_tree(&previous_path, "0.0.9", "701"); + let previous_nodes = scan_generation(&previous_path).unwrap(); + write_private_json(&root.join(POINTER), &previous).unwrap(); + + let stage = root.join("transaction-stage"); + windows_fs::private_directory(&stage).unwrap(); + let staged = stage.join(ROOT); + fixture_tree(&staged, "0.0.10", "702"); + let next_nodes = scan_generation(&staged).unwrap(); + let receipt = stage.join(RECEIPT_NAME); + durable_file(&receipt, b"durable transaction receipt fixture\n"); + Self { + _temporary: temporary, + root, + staged, + receipt, + previous, + next, + previous_nodes, + next_nodes, + } + } + + fn current(&self) -> Current { + Current::parse(&read_bounded(&self.root.join(POINTER), 1024).unwrap()).unwrap() + } + + fn assert_previous_untouched(&self) { + assert_eq!(self.current(), self.previous); + assert_eq!( + scan_generation(&self.previous.path(&self.root)).unwrap(), + self.previous_nodes + ); + } + + fn assert_unpublished_retry_state(&self) { + self.assert_previous_untouched(); + assert!(!self.next.path(&self.root).exists()); + assert_eq!(scan_generation(&self.staged).unwrap(), self.next_nodes); + assert!(!self.receipt.exists()); + } + + fn assert_published(&self) { + assert_eq!(self.current(), self.next); + assert_eq!( + scan_generation(&self.next.path(&self.root)).unwrap(), + self.next_nodes + ); + assert_eq!( + scan_generation(&self.previous.path(&self.root)).unwrap(), + self.previous_nodes + ); + assert!(!self.staged.exists()); + assert_eq!( + read_bounded(&self.receipt, 1024).unwrap(), + b"durable transaction receipt fixture\n" + ); + } +} + +fn durable_file(path: &Path, bytes: &[u8]) { + let mut file = windows_fs::open_private(path, true).unwrap(); + file.write_all(bytes).unwrap(); + file.sync_all().unwrap(); +} + +fn fixture_tree(path: &Path, version: &str, build: &str) { + for directory in DIRS { + windows_fs::private_directory(&path.join(directory)).unwrap(); + } + assert_eq!(FILES.len(), 9); + let bundle = Bundle { + format: "maple-agent-windows-bundle-v1".into(), + product: "maple-agent".into(), + profile: "prod".into(), + version: version.into(), + build: build.into(), + source_sha: "a".repeat(40), + bundle_id: profile_identity("prod").unwrap().into(), + entrypoint: "maple-agent.exe".into(), + }; + for name in FILES { + let bytes = if *name == DESCRIPTOR { + serde_json::to_vec(&bundle).unwrap() + } else { + format!("ordinary transaction fixture {version}/{build}: {name}\n").into_bytes() + }; + durable_file(&path.join(name), &bytes); + } +} + +#[test] +fn native_transaction_commits_pointer_and_preserves_old_generation() { + let fixture = TransactionFixture::new(); + // Deny writes and deletion of the old exe throughout publication, as an + // active Windows image does. This is a file-share restriction, not proof of + // executing an app; no binary fixture is launched by this test. + let old_executable = + windows_fs::open_readonly(&fixture.previous.path(&fixture.root).join("maple-agent.exe")) + .unwrap(); + let reached_publish = Cell::new(false); + commit_generation( + &fixture.root, + &fixture.staged, + &fixture.next, + &fixture.receipt, + || { + reached_publish.set(true); + fixture.assert_previous_untouched(); + assert!(!fixture.staged.exists()); + assert!(fixture.receipt.exists()); + assert_eq!( + scan_generation(&fixture.next.path(&fixture.root)).unwrap(), + fixture.next_nodes + ); + Ok(()) + }, + ) + .unwrap(); + assert!(reached_publish.get()); + fixture.assert_published(); + assert!(old_executable.metadata().unwrap().is_file()); +} + +#[test] +fn native_transaction_interruption_before_pointer_keeps_previous() { + let fixture = TransactionFixture::new(); + let reached_publish = Cell::new(false); + let result = commit_generation( + &fixture.root, + &fixture.staged, + &fixture.next, + &fixture.receipt, + || { + reached_publish.set(true); + fixture.assert_previous_untouched(); + assert!(!fixture.staged.exists()); + assert!(fixture.next.path(&fixture.root).is_dir()); + assert!(fixture.receipt.exists()); + Err(InstallError::Invalid("injected before pointer publication")) + }, + ); + assert!(matches!( + result, + Err(InstallError::Invalid("injected before pointer publication")) + )); + assert!(reached_publish.get()); + fixture.assert_unpublished_retry_state(); +} + +#[test] +fn native_transaction_pointer_lock_failure_is_retryable() { + let fixture = TransactionFixture::new(); + // FILE_SHARE_READ excludes deletion and therefore makes atomic replacement + // fail on native Windows. Releasing this real handle must permit a retry. + let locked_pointer = windows_fs::open_readonly(&fixture.root.join(POINTER)).unwrap(); + let reached_publish = Cell::new(false); + let result = commit_generation( + &fixture.root, + &fixture.staged, + &fixture.next, + &fixture.receipt, + || { + reached_publish.set(true); + Ok(()) + }, + ); + assert!(result.is_err()); + assert!(reached_publish.get()); + fixture.assert_unpublished_retry_state(); + drop(locked_pointer); + + // The production replacement path writes a fresh durable receipt before + // every commit attempt; reproduce that contract for the same staged bytes. + durable_file(&fixture.receipt, b"durable transaction receipt fixture\n"); + commit_generation( + &fixture.root, + &fixture.staged, + &fixture.next, + &fixture.receipt, + || Ok(()), + ) + .unwrap(); + fixture.assert_published(); +} + +#[test] +fn native_install_lock_rejects_concurrent_writers() { + let fixture = TransactionFixture::new(); + let path = fixture.root.join(".install.lock"); + let first = windows_fs::open_private(&path, false).unwrap(); + let second = windows_fs::open_private(&path, false).unwrap(); + fs2::FileExt::try_lock_exclusive(&first).unwrap(); + assert!(fs2::FileExt::try_lock_exclusive(&second).is_err()); + fs2::FileExt::unlock(&first).unwrap(); + fs2::FileExt::try_lock_exclusive(&second).unwrap(); + fs2::FileExt::unlock(&second).unwrap(); +} + +fn write_manual_intent(root: &Path, bundle: &Bundle, executable: &Path) -> Vec { + let intent = ManualIntent { + format: "maple-agent-windows-installer-intent-v1".into(), + profile: bundle.profile.clone(), + generation: bundle.generation(), + version: bundle.version.clone(), + source_sha: bundle.source_sha.clone(), + executable_sha256: exact_file_digest(executable).unwrap(), + // Zero identifies a stale owner for the recovery fixtures below. The + // live-owner test replaces these with actual process identity values. + owner_pid: 0, + owner_created: 0, + }; + write_private_json(&root.join(MANUAL_INTENT), &intent).unwrap(); + read_bounded(&root.join(MANUAL_INTENT), DESCRIPTOR_LIMIT).unwrap() +} + +#[test] +fn native_pending_manual_intent_preserves_current_generation() { + let fixture = TransactionFixture::new(); + let previous_bundle = descriptor(&fixture.previous.path(&fixture.root), "prod").unwrap(); + let next_bundle = descriptor(&fixture.staged, "prod").unwrap(); + let intent_bytes = write_manual_intent( + &fixture.root, + &next_bundle, + &fixture.staged.join("maple-agent.exe"), + ); + + // Exercise only recovery's filesystem decision after the production caller + // has verified its actual current signature and tree. Ordinary fixture + // bytes provide no signed-launch or full installer admission evidence. + recover_completed_manual_intent(&fixture.root, &previous_bundle).unwrap(); + fixture.assert_previous_untouched(); + assert_eq!( + read_bounded(&fixture.root.join(MANUAL_INTENT), DESCRIPTOR_LIMIT).unwrap(), + intent_bytes + ); + assert!(!fixture.next.path(&fixture.root).exists()); + assert_eq!( + scan_generation(&fixture.staged).unwrap(), + fixture.next_nodes + ); + assert!(fixture.receipt.exists()); +} + +#[test] +fn native_completed_manual_intent_recovers_after_pointer_commit() { + let fixture = TransactionFixture::new(); + let next_bundle = descriptor(&fixture.staged, "prod").unwrap(); + let intent_bytes = write_manual_intent( + &fixture.root, + &next_bundle, + &fixture.staged.join("maple-agent.exe"), + ); + commit_generation( + &fixture.root, + &fixture.staged, + &fixture.next, + &fixture.receipt, + || Ok(()), + ) + .unwrap(); + fixture.assert_published(); + assert!(fixture.root.join(MANUAL_INTENT).is_file()); + + // Model interruption after pointer publication and before intent deletion. + // Signature/tree admission remains the real launcher's prerequisite; this + // calls only its recovery helper and checks exact identity/digest matching. + let current_bundle = descriptor(&fixture.next.path(&fixture.root), "prod").unwrap(); + let mut changed: ManualIntent = serde_json::from_slice(&intent_bytes).unwrap(); + changed.executable_sha256 = "0".repeat(64); + write_private_json(&fixture.root.join(MANUAL_INTENT), &changed).unwrap(); + let changed_bytes = read_bounded(&fixture.root.join(MANUAL_INTENT), DESCRIPTOR_LIMIT).unwrap(); + recover_completed_manual_intent(&fixture.root, ¤t_bundle).unwrap(); + assert_eq!( + read_bounded(&fixture.root.join(MANUAL_INTENT), DESCRIPTOR_LIMIT).unwrap(), + changed_bytes + ); + fixture.assert_published(); + + // Matching version/source alone cannot clear another executable's intent. + durable_file(&fixture.root.join(MANUAL_INTENT), &intent_bytes); + recover_completed_manual_intent(&fixture.root, ¤t_bundle).unwrap(); + assert!(!fixture.root.join(MANUAL_INTENT).exists()); + fixture.assert_published(); + + // Repeated startup recovery is a harmless no-op once collection completed. + recover_completed_manual_intent(&fixture.root, ¤t_bundle).unwrap(); + fixture.assert_published(); +} + +fn current_process_owner() -> (u32, u64) { + let pid = unsafe { windows_sys::Win32::System::Threading::GetCurrentProcessId() }; + let created = process_created(pid) + .unwrap() + .expect("the native test process must still be live"); + (pid, created) +} + +#[test] +fn native_live_matching_manual_intent_is_never_cleared() { + let fixture = TransactionFixture::new(); + let current = descriptor(&fixture.previous.path(&fixture.root), "prod").unwrap(); + let bytes = write_manual_intent( + &fixture.root, + ¤t, + &fixture.previous.path(&fixture.root).join("maple-agent.exe"), + ); + let mut intent: ManualIntent = serde_json::from_slice(&bytes).unwrap(); + let owner = current_process_owner(); + intent.owner_pid = owner.0; + intent.owner_created = owner.1; + write_private_json(&fixture.root.join(MANUAL_INTENT), &intent).unwrap(); + let before = read_bounded(&fixture.root.join(MANUAL_INTENT), DESCRIPTOR_LIMIT).unwrap(); + + // Even an exact current generation cannot clear an intent owned by a live + // installer. This exercises process identity and recovery, not admission. + recover_completed_manual_intent(&fixture.root, ¤t).unwrap(); + assert_eq!( + read_bounded(&fixture.root.join(MANUAL_INTENT), DESCRIPTOR_LIMIT).unwrap(), + before + ); + fixture.assert_previous_untouched(); + assert_eq!( + scan_generation(&fixture.staged).unwrap(), + fixture.next_nodes + ); +} + +#[test] +fn native_uninstall_owner_fences_until_nsis_finishes() { + let fixture = TransactionFixture::new(); + let marker = fixture.root.join(".uninstaller-intent.json"); + let owner = current_process_owner(); + let mut intent = UninstallIntent { + format: "maple-agent-windows-uninstall-intent-v1".into(), + profile: "prod".into(), + owner_pid: owner.0, + owner_created: owner.1, + generations: vec![fixture.previous.generation.clone()], + }; + write_private_json(&marker, &intent).unwrap(); + let before = read_bounded(&marker, DESCRIPTOR_LIMIT).unwrap(); + assert!(live_uninstall_owner(&fixture.root, "prod").unwrap()); + assert!(live_uninstall_owner(&fixture.root, "dev").is_err()); + assert_eq!(read_bounded(&marker, DESCRIPTOR_LIMIT).unwrap(), before); + fixture.assert_previous_untouched(); + + // PID reuse cannot adopt an old marker: creation time must match too. + intent.owner_created ^= 1; + write_private_json(&marker, &intent).unwrap(); + assert!(!live_uninstall_owner(&fixture.root, "prod").unwrap()); + assert!(marker.is_file()); + + intent.owner_pid = 0; + intent.owner_created = 0; + write_private_json(&marker, &intent).unwrap(); + let stale = read_bounded(&marker, DESCRIPTOR_LIMIT).unwrap(); + assert!(!live_uninstall_owner(&fixture.root, "prod").unwrap()); + assert_eq!(read_bounded(&marker, DESCRIPTOR_LIMIT).unwrap(), stale); + fixture.assert_previous_untouched(); + + // Ownership inspection never removes NSIS's durable marker on its behalf. + // Model NSIS completing its own outer transaction explicitly. + fs::remove_file(&marker).unwrap(); + assert!(!live_uninstall_owner(&fixture.root, "prod").unwrap()); + fixture.assert_previous_untouched(); +} + +#[test] +fn native_partial_generation_removal_preserves_unknown_files() { + let fixture = TransactionFixture::new(); + fs::remove_file(fixture.staged.join("LICENSE.txt")).unwrap(); + fs::remove_file(fixture.staged.join("notices/THIRD_PARTY_NOTICES.html")).unwrap(); + let guard = windows_fs::open_executable_mutation_guard(&fixture.staged.join("maple-agent.exe")) + .unwrap(); + remove_generation_files(&fixture.staged, Some(guard)).unwrap(); + assert!(!fixture.staged.exists()); + fixture.assert_previous_untouched(); + + let unknown = fixture.root.join("versions").join("0.0.11-703"); + fixture_tree(&unknown, "0.0.11", "703"); + durable_file(&unknown.join("my-project.txt"), b"user-owned data\n"); + let before: Vec<_> = FILES + .iter() + .copied() + .chain(std::iter::once("my-project.txt")) + .map(|name| (name, fs::read(unknown.join(name)).unwrap())) + .collect(); + let guard = + windows_fs::open_executable_mutation_guard(&unknown.join("maple-agent.exe")).unwrap(); + // Removal itself must prevalidate every entry, even if its caller forgot. + assert!(remove_generation_files(&unknown, Some(guard)).is_err()); + for (name, bytes) in before { + assert_eq!(fs::read(unknown.join(name)).unwrap(), bytes); + } + fixture.assert_previous_untouched(); +} + +#[test] +fn native_retained_guard_removes_owned_generation_without_partial_pointer_change() { + let fixture = TransactionFixture::new(); + commit_generation( + &fixture.root, + &fixture.staged, + &fixture.next, + &fixture.receipt, + || Ok(()), + ) + .unwrap(); + fixture.assert_published(); + let pointer_before = read_bounded(&fixture.root.join(POINTER), 1024).unwrap(); + let current_pointer_guard = windows_fs::open_readonly(&fixture.root.join(POINTER)).unwrap(); + let previous = fixture.previous.path(&fixture.root); + let guard = + windows_fs::open_executable_mutation_guard(&previous.join("maple-agent.exe")).unwrap(); + remove_generation_files(&previous, Some(guard)).unwrap(); + assert!(!previous.exists()); + assert_eq!( + read_bounded(&fixture.root.join(POINTER), 1024).unwrap(), + pointer_before + ); + assert_eq!(fixture.current(), fixture.next); + assert_eq!( + scan_generation(&fixture.next.path(&fixture.root)).unwrap(), + fixture.next_nodes + ); + assert!(fixture.receipt.is_file()); + assert!(current_pointer_guard.metadata().unwrap().is_file()); +} + +#[test] +fn native_dead_uninstall_journal_allows_only_owned_partial_repair() { + let fixture = TransactionFixture::new(); + let previous = fixture.previous.path(&fixture.root); + let pointer_before = read_bounded(&fixture.root.join(POINTER), 1024).unwrap(); + let guard = + windows_fs::open_executable_mutation_guard(&previous.join("maple-agent.exe")).unwrap(); + remove_generation_files(&previous, Some(guard)).unwrap(); + let intent = UninstallIntent { + format: "maple-agent-windows-uninstall-intent-v1".into(), + profile: "prod".into(), + owner_pid: 0, + owner_created: 0, + generations: vec![fixture.previous.generation.clone()], + }; + write_private_json(&fixture.root.join(UNINSTALL_INTENT), &intent).unwrap(); + let journal_before = + read_bounded(&fixture.root.join(UNINSTALL_INTENT), DESCRIPTOR_LIMIT).unwrap(); + assert!(!live_uninstall_owner(&fixture.root, "prod").unwrap()); + assert_eq!(fixture.current(), fixture.previous); + assert!(!previous.exists()); + validate_uninstall_repair(&fixture.root, "prod", &intent).unwrap(); + + // A dead journal explains an incomplete owned tree without pretending these + // ordinary files constitute a signed PE or full manual preflight proof. + windows_fs::private_directory(&previous).unwrap(); + durable_file(&previous.join("LICENSE.txt"), b"remaining owned license\n"); + validate_uninstall_repair(&fixture.root, "prod", &intent).unwrap(); + + let unknown = previous.join("user-project.txt"); + durable_file(&unknown, b"unowned data must survive repair refusal\n"); + assert!(validate_uninstall_repair(&fixture.root, "prod", &intent).is_err()); + assert_eq!( + fs::read(&unknown).unwrap(), + b"unowned data must survive repair refusal\n" + ); + assert_eq!( + fs::read(previous.join("LICENSE.txt")).unwrap(), + b"remaining owned license\n" + ); + // Remove only this test's injected file, then prove the refusal below comes + // from an unowned generation rather than the earlier unknown-file control. + fs::remove_file(&unknown).unwrap(); + validate_uninstall_repair(&fixture.root, "prod", &intent).unwrap(); + let extra = fixture.root.join("versions").join("0.0.11-703"); + fixture_tree(&extra, "0.0.11", "703"); + let extra_before = scan_generation(&extra).unwrap(); + assert!(validate_uninstall_repair(&fixture.root, "prod", &intent).is_err()); + assert_eq!(scan_generation(&extra).unwrap(), extra_before); + assert_eq!( + fs::read(previous.join("LICENSE.txt")).unwrap(), + b"remaining owned license\n" + ); + assert_eq!( + read_bounded(&fixture.root.join(POINTER), 1024).unwrap(), + pointer_before + ); + assert_eq!( + read_bounded(&fixture.root.join(UNINSTALL_INTENT), DESCRIPTOR_LIMIT).unwrap(), + journal_before + ); +} diff --git a/apps/maple-agent/crates/maple-agent-updater/src/lib.rs b/apps/maple-agent/crates/maple-agent-updater/src/lib.rs index 4f9fde226..044831abc 100644 --- a/apps/maple-agent/crates/maple-agent-updater/src/lib.rs +++ b/apps/maple-agent/crates/maple-agent-updater/src/lib.rs @@ -1,8 +1,9 @@ //! Agent-only authenticated update discovery, download and installation primitives. //! Callers must retain installation and host-startup fences around installation. //! -//! Filesystem security tests target macOS/Linux Unix semantics. Windows CI -//! compiles this crate; Windows application updates are intentionally unsupported. +//! Platform-specific installers share the authenticated discovery and download +//! state machine. Signed metadata keeps the macOS, Linux and Windows inventories +//! separate so adding a platform cannot reinterpret installed clients' feeds. mod client; pub mod installer; pub mod manifest; @@ -14,5 +15,5 @@ pub use semver::Version; pub use state::PreparedUpdate; pub use url::Url; -#[cfg(all(test, unix))] +#[cfg(test)] mod tests; diff --git a/apps/maple-agent/crates/maple-agent-updater/src/manifest.rs b/apps/maple-agent/crates/maple-agent-updater/src/manifest.rs index 9e56d3918..798d6c613 100644 --- a/apps/maple-agent/crates/maple-agent-updater/src/manifest.rs +++ b/apps/maple-agent/crates/maple-agent-updater/src/manifest.rs @@ -15,6 +15,7 @@ pub const MAX_ARTIFACT_BYTES: u64 = 4 * 1024 * 1024 * 1024; pub enum Target { DarwinAarch64, LinuxX86_64, + WindowsX86_64, } impl Target { @@ -22,6 +23,7 @@ impl Target { match self { Self::DarwinAarch64 => "darwin-aarch64", Self::LinuxX86_64 => "linux-x86_64", + Self::WindowsX86_64 => "windows-x86_64", } } @@ -29,6 +31,7 @@ impl Target { match self { Self::DarwinAarch64 => "/agent/stable/latest.json", Self::LinuxX86_64 => "/agent/stable/linux/latest.json", + Self::WindowsX86_64 => "/agent/stable/windows/latest.json", } } @@ -36,6 +39,7 @@ impl Target { match self { Self::DarwinAarch64 => "manifest.json", Self::LinuxX86_64 => "linux-manifest.json", + Self::WindowsX86_64 => "windows-manifest.json", } } @@ -50,6 +54,7 @@ impl Target { match self { Self::DarwinAarch64 => "maple-agent-update-v1", Self::LinuxX86_64 => "maple-agent-update-v2", + Self::WindowsX86_64 => "maple-agent-update-v3", } } } @@ -71,6 +76,8 @@ pub struct Downloads { pub linux_appimage: Option, #[serde(rename = "linux-bundle", skip_serializing_if = "Option::is_none")] pub linux_bundle: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub windows: Option, } #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] @@ -201,7 +208,7 @@ fn lowercase_hex(value: &str, len: usize) -> bool { fn validate_download_fields(text: &str, format: &str) -> Result<()> { // Optional fields cannot distinguish omission from an explicit null. Keep - // legacy v1's exact field contract, and require a Linux-only v2 inventory + // legacy v1's exact field contract, and require platform-only inventories // even when a forbidden download field has a null value. let value: serde_json::Value = serde_json::from_str(text)?; let downloads = value["downloads"] @@ -209,13 +216,17 @@ fn validate_download_fields(text: &str, format: &str) -> Result<()> { .context("invalid download inventory")?; match format { "maple-agent-update-v1" => ensure!( - !downloads.contains_key("linux-bundle"), - "Linux bundles require update format v2" + !downloads.contains_key("linux-bundle") && !downloads.contains_key("windows"), + "platform downloads require their own update format" ), "maple-agent-update-v2" => ensure!( downloads.len() == 1 && downloads.contains_key("linux-bundle"), "update format v2 requires only the Linux bundle download" ), + "maple-agent-update-v3" => ensure!( + downloads.len() == 1 && downloads.contains_key("windows"), + "update format v3 requires only the Windows installer download" + ), _ => bail!("unsupported update format"), } Ok(()) @@ -225,7 +236,7 @@ pub fn validate_manifest(manifest: &Manifest) -> Result<()> { ensure!( matches!( manifest.format.as_str(), - "maple-agent-update-v1" | "maple-agent-update-v2" + "maple-agent-update-v1" | "maple-agent-update-v2" | "maple-agent-update-v3" ), "unsupported update format" ); @@ -260,14 +271,15 @@ pub fn validate_manifest(manifest: &Manifest) -> Result<()> { match manifest.format.as_str() { "maple-agent-update-v1" => validate_v1_inventory(manifest), "maple-agent-update-v2" => validate_v2_inventory(manifest), + "maple-agent-update-v3" => validate_v3_inventory(manifest), _ => unreachable!("update format was validated above"), } } fn validate_v1_inventory(manifest: &Manifest) -> Result<()> { ensure!( - manifest.downloads.linux_bundle.is_none(), - "Linux bundles require update format v2" + manifest.downloads.linux_bundle.is_none() && manifest.downloads.windows.is_none(), + "platform downloads require their own update format" ); ensure!( manifest.artifacts.contains_key("darwin-aarch64"), @@ -311,7 +323,9 @@ fn validate_v2_inventory(manifest: &Manifest) -> Result<()> { "update format v2 requires only the Linux artifact" ); ensure!( - manifest.downloads.macos.is_none() && manifest.downloads.linux_appimage.is_none(), + manifest.downloads.macos.is_none() + && manifest.downloads.linux_appimage.is_none() + && manifest.downloads.windows.is_none(), "update format v2 requires only the Linux bundle download" ); let artifact = &manifest.artifacts["linux-x86_64"]; @@ -323,6 +337,34 @@ fn validate_v2_inventory(manifest: &Manifest) -> Result<()> { Ok(()) } +fn validate_v3_inventory(manifest: &Manifest) -> Result<()> { + ensure!( + manifest.artifacts.len() == 1 && manifest.artifacts.contains_key("windows-x86_64"), + "update format v3 requires only the Windows artifact" + ); + ensure!( + manifest.downloads.macos.is_none() + && manifest.downloads.linux_appimage.is_none() + && manifest.downloads.linux_bundle.is_none(), + "update format v3 requires only the Windows installer download" + ); + validate_artifact( + &manifest.artifacts["windows-x86_64"], + &manifest.version, + ".tar.gz", + )?; + validate_artifact( + manifest + .downloads + .windows + .as_ref() + .context("Windows installer download is required")?, + &manifest.version, + ".exe", + )?; + Ok(()) +} + fn validate_artifact(artifact: &Artifact, version: &str, suffix: &str) -> Result<()> { ensure!( artifact.size > 0 && artifact.size <= MAX_ARTIFACT_BYTES, @@ -349,3 +391,105 @@ fn validate_artifact(artifact: &Artifact, version: &str, suffix: &str) -> Result ); Ok(()) } + +#[cfg(test)] +mod windows_tests { + use super::*; + + fn fixture() -> VerifiedRelease { + verify_envelope( + include_bytes!("../tests/fixtures/signed-windows-v3-envelope.json"), + &"https://updates.example.test".parse().unwrap(), + &[include_str!("../tests/fixtures/public-test-key.pub").to_owned()], + ) + .unwrap() + } + + #[test] + fn signed_windows_v3_fixture_keeps_its_exact_bytes_and_platform() { + let release = fixture(); + assert_eq!( + serde_json::to_vec(&release.manifest).unwrap(), + include_bytes!("../tests/fixtures/windows-v3-manifest.json") + ); + assert!(release.artifact(Target::WindowsX86_64).is_ok()); + assert!(release.artifact(Target::DarwinAarch64).is_err()); + assert!(release.artifact(Target::LinuxX86_64).is_err()); + assert_ne!( + release.artifact(Target::WindowsX86_64).unwrap().path, + release.manifest.downloads.windows.as_ref().unwrap().path + ); + } + + #[test] + fn windows_v3_requires_exact_independent_bundle_and_installer_inventory() { + let mutations: Vec = vec![ + |m| m.format = "maple-agent-update-v2".into(), + |m| m.product = "maple-research".into(), + |m| m.channel = "dev".into(), + |m| m.artifacts.clear(), + |m| { + m.artifacts + .insert("linux-x86_64".into(), m.artifacts["windows-x86_64"].clone()); + }, + |m| m.downloads.windows = None, + |m| m.downloads.macos = m.downloads.windows.clone(), + |m| m.downloads.linux_appimage = m.downloads.windows.clone(), + |m| m.downloads.linux_bundle = m.downloads.windows.clone(), + |m| m.artifacts.get_mut("windows-x86_64").unwrap().size = 0, + |m| { + m.artifacts.get_mut("windows-x86_64").unwrap().size = MAX_ARTIFACT_BYTES + 1; + }, + |m| m.artifacts.get_mut("windows-x86_64").unwrap().sha256 = "A".repeat(64), + |m| m.downloads.windows.as_mut().unwrap().sha256 = "bad".into(), + |m| m.downloads.windows.as_mut().unwrap().size = 0, + |m| { + m.downloads.windows.as_mut().unwrap().path = + "/agent/stable/releases/0.0.1/Maple-Agent-setup.exe".into(); + }, + |m| { + m.downloads.windows.as_mut().unwrap().path = + "/agent/stable/releases/0.0.2/Maple-Agent.tar.gz".into(); + }, + |m| { + m.artifacts.get_mut("windows-x86_64").unwrap().path = + "/agent/stable/releases/0.0.2/Maple-Agent-setup.exe".into(); + }, + ]; + for mutation in mutations { + let mut manifest = fixture().manifest; + mutation(&mut manifest); + assert!( + validate_manifest(&manifest).is_err(), + "accepted {manifest:?}" + ); + } + } + + #[test] + fn platform_download_fields_are_rejected_even_when_explicitly_null() { + let windows = serde_json::to_string(&fixture().manifest).unwrap(); + for field in ["macos", "linux-appimage", "linux-bundle", "unknown"] { + let mut value: serde_json::Value = serde_json::from_str(&windows).unwrap(); + value["downloads"][field] = serde_json::Value::Null; + assert!(validate_download_fields(&value.to_string(), "maple-agent-update-v3").is_err()); + } + for (bytes, format) in [ + ( + include_str!("../tests/fixtures/manifest.json"), + "maple-agent-update-v1", + ), + ( + include_str!("../tests/fixtures/linux-v2-manifest.json"), + "maple-agent-update-v2", + ), + ] { + let mut value: serde_json::Value = serde_json::from_str(bytes).unwrap(); + value["downloads"]["windows"] = serde_json::Value::Null; + assert!(validate_download_fields(&value.to_string(), format).is_err()); + value["downloads"]["windows"] = + serde_json::to_value(fixture().manifest.downloads.windows.unwrap()).unwrap(); + assert!(validate_download_fields(&value.to_string(), format).is_err()); + } + } +} diff --git a/apps/maple-agent/crates/maple-agent-updater/src/state.rs b/apps/maple-agent/crates/maple-agent-updater/src/state.rs index 1457b05dd..b39f70d20 100644 --- a/apps/maple-agent/crates/maple-agent-updater/src/state.rs +++ b/apps/maple-agent/crates/maple-agent-updater/src/state.rs @@ -1,6 +1,8 @@ +#[cfg(not(windows))] +use std::{fs::OpenOptions, io::Read}; use std::{ - fs::{self, File, OpenOptions}, - io::{Read, Write}, + fs::{self, File}, + io::Write, path::{Path, PathBuf}, }; @@ -220,10 +222,25 @@ impl StateStore { let mut temporary = tempfile::NamedTempFile::new_in(&self.root)?; temporary.write_all(&after)?; temporary.as_file().sync_all()?; - temporary - .persist(&path) - .context("cannot persist updater state")?; - File::open(&self.root)?.sync_all()?; + #[cfg(windows)] + { + let (file, temporary_path) = temporary + .keep() + .context("cannot retain updater state temporary")?; + drop(file); + if let Err(error) = crate::installer::windows_atomic_replace(&temporary_path, &path) + { + let _ = fs::remove_file(&temporary_path); + return Err(error).context("cannot persist updater state"); + } + } + #[cfg(not(windows))] + { + temporary + .persist(&path) + .context("cannot persist updater state")?; + } + sync_directory(&self.root)?; } FileExt::unlock(&lock)?; Ok(result) @@ -231,67 +248,120 @@ impl StateStore { } pub(crate) fn private_directory(path: &Path) -> Result<()> { - fs::create_dir_all(path)?; - ensure!( - fs::symlink_metadata(path)?.file_type().is_dir(), - "updater directory is not a real directory" - ); - #[cfg(unix)] + #[cfg(windows)] + return crate::installer::windows_private_directory(path).map_err(Into::into); + #[cfg(not(windows))] { - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(path, fs::Permissions::from_mode(0o700))?; + fs::create_dir_all(path)?; + ensure!( + fs::symlink_metadata(path)?.file_type().is_dir(), + "updater directory is not a real directory" + ); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(0o700))?; + } + Ok(()) } - Ok(()) } pub(crate) fn open_private(path: &Path, truncate: bool) -> Result { - let mut options = OpenOptions::new(); - options - .read(true) - .write(true) - .create(true) - .truncate(truncate); - #[cfg(unix)] + #[cfg(windows)] + return crate::installer::windows_open_private(path, truncate).map_err(Into::into); + #[cfg(not(windows))] { - use std::os::unix::fs::OpenOptionsExt; + let mut options = OpenOptions::new(); options - .mode(0o600) - .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC); + .read(true) + .write(true) + .create(true) + .truncate(truncate); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options + .mode(0o600) + .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC); + } + let file = options.open(path)?; + ensure!( + file.metadata()?.file_type().is_file(), + "updater file is not regular" + ); + Ok(file) + } +} + +pub(crate) fn open_readonly(path: &Path) -> Result { + #[cfg(windows)] + return crate::installer::windows_open_readonly(path).map_err(Into::into); + #[cfg(not(windows))] + { + let mut options = OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC); + } + let file = options.open(path)?; + ensure!(file.metadata()?.is_file(), "updater file is not regular"); + Ok(file) + } +} + +pub(crate) fn sync_directory(path: &Path) -> Result<()> { + #[cfg(windows)] + return crate::installer::windows_sync_directory(path).map_err(Into::into); + #[cfg(not(windows))] + { + File::open(path)?.sync_all()?; + Ok(()) + } +} + +pub(crate) fn replace_file(source: &Path, destination: &Path) -> Result<()> { + #[cfg(windows)] + return crate::installer::windows_atomic_replace(source, destination).map_err(Into::into); + #[cfg(not(windows))] + { + fs::rename(source, destination)?; + Ok(()) } - let file = options.open(path)?; - ensure!( - file.metadata()?.file_type().is_file(), - "updater file is not regular" - ); - Ok(file) } pub(crate) fn read_bounded(path: &Path, maximum: u64) -> Result>> { - let mut options = OpenOptions::new(); - options.read(true); - #[cfg(unix)] + #[cfg(windows)] + return crate::installer::windows_read_bounded(path, maximum).map_err(Into::into); + #[cfg(not(windows))] { - use std::os::unix::fs::OpenOptionsExt; - options.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC); + let mut options = OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC); + } + let file = match options.open(path) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error.into()), + }; + ensure!( + file.metadata()?.is_file(), + "updater state is not a regular file" + ); + ensure!( + file.metadata()?.len() <= maximum, + "updater state is too large" + ); + let mut bytes = Vec::new(); + file.take(maximum + 1).read_to_end(&mut bytes)?; + ensure!( + bytes.len() <= maximum as usize, + "updater state is too large" + ); + Ok(Some(bytes)) } - let file = match options.open(path) { - Ok(file) => file, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), - Err(error) => return Err(error.into()), - }; - ensure!( - file.metadata()?.is_file(), - "updater state is not a regular file" - ); - ensure!( - file.metadata()?.len() <= maximum, - "updater state is too large" - ); - let mut bytes = Vec::new(); - file.take(maximum + 1).read_to_end(&mut bytes)?; - ensure!( - bytes.len() <= maximum as usize, - "updater state is too large" - ); - Ok(Some(bytes)) } diff --git a/apps/maple-agent/crates/maple-agent-updater/src/tests.rs b/apps/maple-agent/crates/maple-agent-updater/src/tests.rs index 7d44d8dda..da3ff7c61 100644 --- a/apps/maple-agent/crates/maple-agent-updater/src/tests.rs +++ b/apps/maple-agent/crates/maple-agent-updater/src/tests.rs @@ -1,6 +1,5 @@ use std::{ collections::BTreeMap, - path::PathBuf, sync::{Arc, Mutex}, }; @@ -85,6 +84,7 @@ fn manifest(version: &str) -> Manifest { macos: Some(artifact(version, "Maple-Agent.dmg")), linux_appimage: None, linux_bundle: None, + windows: None, }, } } @@ -106,6 +106,24 @@ fn linux_manifest(version: &str) -> Manifest { macos: None, linux_appimage: None, linux_bundle: Some(linux), + windows: None, + }, + ..manifest(version) + } +} + +fn windows_manifest(version: &str) -> Manifest { + Manifest { + format: "maple-agent-update-v3".into(), + artifacts: BTreeMap::from([( + "windows-x86_64".into(), + artifact(version, "Maple-Agent-windows-x86_64.tar.gz"), + )]), + downloads: Downloads { + macos: None, + linux_appimage: None, + linux_bundle: None, + windows: Some(artifact(version, "Maple-Agent-windows-x86_64-setup.exe")), }, ..manifest(version) } @@ -176,6 +194,27 @@ fn static_linux_v2_fixture_uses_the_same_public_test_key() { assert!(legacy_v1::verify_envelope(signed, &origin(), &[key(7)]).is_err()); } +#[test] +fn static_windows_v3_fixture_uses_the_same_public_test_key() { + let signed = include_bytes!("../tests/fixtures/signed-windows-v3-envelope.json"); + let release = verify_envelope( + signed, + &origin(), + &[include_str!("../tests/fixtures/public-test-key.pub").to_owned()], + ) + .unwrap(); + assert_eq!(release.manifest, windows_manifest("0.0.2")); + assert!(legacy_v1::verify_envelope(signed, &origin(), &[key(7)]).is_err()); + assert!(release.artifact(Target::DarwinAarch64).is_err()); + assert!(release.artifact(Target::LinuxX86_64).is_err()); + assert!(release.artifact(Target::WindowsX86_64).is_ok()); + + for mut value in [manifest("0.0.4"), linux_manifest("0.0.4")] { + value.downloads.windows = Some(artifact("0.0.4", "Maple-Agent-setup.exe")); + assert!(verify_envelope(&envelope(&value), &origin(), &[key(7)]).is_err()); + } +} + #[test] fn legacy_v1_schema_behavior_is_preserved_including_optional_appimage() { for value in [manifest("0.0.4"), legacy_linux_manifest("0.0.4")] { @@ -306,7 +345,7 @@ fn linux_v2_payload_bounds_apply_even_when_the_download_matches() { } #[test] -fn target_metadata_paths_keep_legacy_macos_and_separate_linux() { +fn target_metadata_paths_keep_legacy_macos_and_separate_platforms() { assert_eq!( Target::DarwinAarch64.feed_path(), "/agent/stable/latest.json" @@ -323,6 +362,14 @@ fn target_metadata_paths_keep_legacy_macos_and_separate_linux() { Target::LinuxX86_64.manifest_path("0.0.4"), "/agent/stable/releases/0.0.4/linux-manifest.json" ); + assert_eq!( + Target::WindowsX86_64.feed_path(), + "/agent/stable/windows/latest.json" + ); + assert_eq!( + Target::WindowsX86_64.manifest_path("0.0.4"), + "/agent/stable/releases/0.0.4/windows-manifest.json" + ); } #[test] @@ -505,6 +552,12 @@ impl Fixture { std::fs::write(&path, b"old installed executable").unwrap(); path } + Target::WindowsX86_64 => { + let path = temp.path().join("Maple Agent"); + std::fs::create_dir_all(&path).unwrap(); + std::fs::write(path.join("maple-agent.exe"), b"old installed executable").unwrap(); + path + } }; let mut feed_url = self.url.clone(); feed_url.set_path(target.feed_path()); @@ -818,6 +871,10 @@ async fn wrong_target_feed_cannot_advance_rollback_memory_or_start_a_download() (Target::LinuxX86_64, manifest("0.0.2")), (Target::LinuxX86_64, legacy_linux_manifest("0.0.2")), (Target::DarwinAarch64, linux_manifest("0.0.2")), + (Target::WindowsX86_64, manifest("0.0.2")), + (Target::WindowsX86_64, linux_manifest("0.0.2")), + (Target::DarwinAarch64, windows_manifest("0.0.2")), + (Target::LinuxX86_64, windows_manifest("0.0.2")), ] { let signed = envelope(&value); let server = Fixture::new(vec![ok(&signed)]).await; @@ -837,34 +894,44 @@ async fn wrong_target_feed_cannot_advance_rollback_memory_or_start_a_download() } #[tokio::test] -async fn linux_v2_download_restore_revalidate_and_commit_use_the_linux_feed() { - let signed = envelope(&linux_manifest("0.0.2")); - let server = Fixture::new(vec![ok(&signed), ok(PAYLOAD), ok(&signed)]).await; - let temp = tempfile::tempdir().unwrap(); - let updater = test_updater(server.config_for_target(&temp, Target::LinuxX86_64)).unwrap(); - let release = available(&updater).await; - let prepared = updater.download(&release).await.unwrap(); - assert_eq!(std::fs::read(&prepared.payload_path).unwrap(), PAYLOAD); - assert!(updater.restore_prepared().await.unwrap().is_some()); - assert!(matches!( - updater.revalidate(&prepared).await.unwrap(), - Revalidation::Ready(_) - )); - let committed = std::cell::Cell::new(false); - updater - .with_commit_guard(&prepared, || { - committed.set(true); - Ok(()) - }) - .unwrap(); - assert!(committed.get()); - let requests = server.requests.lock().unwrap(); - assert!(requests[0].starts_with("GET /agent/stable/linux/latest.json ")); - assert!( - requests[1] - .starts_with("GET /agent/stable/releases/0.0.2/Maple-Agent-linux-x86_64.tar.gz ") - ); - assert!(requests[2].starts_with("GET /agent/stable/linux/latest.json ")); +async fn platform_download_restore_revalidate_and_commit_use_their_exact_feed() { + for (target, value, filename) in [ + ( + Target::LinuxX86_64, + linux_manifest("0.0.2"), + "Maple-Agent-linux-x86_64.tar.gz", + ), + ( + Target::WindowsX86_64, + windows_manifest("0.0.2"), + "Maple-Agent-windows-x86_64.tar.gz", + ), + ] { + let signed = envelope(&value); + let server = Fixture::new(vec![ok(&signed), ok(PAYLOAD), ok(&signed)]).await; + let temp = tempfile::tempdir().unwrap(); + let updater = test_updater(server.config_for_target(&temp, target)).unwrap(); + let release = available(&updater).await; + let prepared = updater.download(&release).await.unwrap(); + assert_eq!(std::fs::read(&prepared.payload_path).unwrap(), PAYLOAD); + assert!(updater.restore_prepared().await.unwrap().is_some()); + assert!(matches!( + updater.revalidate(&prepared).await.unwrap(), + Revalidation::Ready(_) + )); + let committed = std::cell::Cell::new(false); + updater + .with_commit_guard(&prepared, || { + committed.set(true); + Ok(()) + }) + .unwrap(); + assert!(committed.get()); + let requests = server.requests.lock().unwrap(); + assert!(requests[0].starts_with(&format!("GET {} ", target.feed_path()))); + assert!(requests[1].starts_with(&format!("GET /agent/stable/releases/0.0.2/{filename} "))); + assert!(requests[2].starts_with(&format!("GET {} ", target.feed_path()))); + } } #[tokio::test] @@ -1044,6 +1111,27 @@ async fn commit_guard_excludes_competing_advancement_without_blocking() { assert!(updater.with_commit_guard(&prepared, || Ok(())).is_err()); } +#[tokio::test] +async fn successful_commit_result_survives_loss_of_state_record_after_replacement() { + let server = Fixture::new(vec![ok(&envelope(&manifest("0.0.2"))), ok(PAYLOAD)]).await; + let temp = tempfile::tempdir().unwrap(); + let updater = test_updater(server.config(&temp)).unwrap(); + let prepared = updater.download(&available(&updater).await).await.unwrap(); + let result = updater + .with_commit_guard(&prepared, || { + // Removing the record works even when the platform prevents unlink + // of the retained lock. No later state IO may turn this committed + // outcome into a failure or recreate the record. + std::fs::remove_file(temp.path().join("updates/state.json")) + .map_err(|e| e.to_string())?; + Ok("committed") + }) + .unwrap(); + assert_eq!(result, "committed"); + assert!(!temp.path().join("updates/state.json").exists()); +} + +#[cfg(unix)] #[tokio::test] async fn successful_commit_result_survives_loss_of_state_directory_after_replacement() { let server = Fixture::new(vec![ok(&envelope(&manifest("0.0.2"))), ok(PAYLOAD)]).await; @@ -1061,6 +1149,26 @@ async fn successful_commit_result_survives_loss_of_state_directory_after_replace assert_eq!(result, "committed"); } +#[cfg(windows)] +#[tokio::test] +async fn commit_guard_retains_windows_state_lock_against_deletion() { + let server = Fixture::new(vec![ok(&envelope(&windows_manifest("0.0.2"))), ok(PAYLOAD)]).await; + let temp = tempfile::tempdir().unwrap(); + let updater = test_updater(server.config_for_target(&temp, Target::WindowsX86_64)).unwrap(); + let prepared = updater.download(&available(&updater).await).await.unwrap(); + let lock_path = temp.path().join("updates/state.lock"); + updater + .with_commit_guard(&prepared, || { + // Native private handles intentionally exclude FILE_SHARE_DELETE: + // deletion cannot detach this installation's active lock inode. + assert!(std::fs::remove_file(&lock_path).is_err()); + assert!(lock_path.is_file()); + Ok(()) + }) + .unwrap(); + std::fs::remove_file(lock_path).unwrap(); +} + #[cfg(unix)] #[tokio::test] async fn saved_payload_symlinks_are_never_followed() { @@ -1069,7 +1177,7 @@ async fn saved_payload_symlinks_are_never_followed() { let temp = tempfile::tempdir().unwrap(); let updater = test_updater(server.config(&temp)).unwrap(); let prepared = updater.download(&available(&updater).await).await.unwrap(); - let outside: PathBuf = temp.path().join("outside"); + let outside = temp.path().join("outside"); std::fs::write(&outside, PAYLOAD).unwrap(); std::fs::remove_file(&prepared.payload_path).unwrap(); symlink(&outside, &prepared.payload_path).unwrap(); diff --git a/apps/maple-agent/crates/maple-agent-updater/tests/fixtures/README.md b/apps/maple-agent/crates/maple-agent-updater/tests/fixtures/README.md index 8995dbb52..675f59527 100644 --- a/apps/maple-agent/crates/maple-agent-updater/tests/fixtures/README.md +++ b/apps/maple-agent/crates/maple-agent-updater/tests/fixtures/README.md @@ -6,3 +6,8 @@ be pinned in an application profile or used to sign a production release. `signed-envelope.json` embeds the same bytes and signature for client, Worker and publisher contract tests. The artifact bytes are the ASCII string `signed payload fixture bytes`; they are not an application archive. + +The Linux v2 and Windows v3 fixtures use this same public key and payload. Their +platform-specific contracts are described in `linux-v2-contract.md` and +`windows-v3-contract.md`. Neither the Windows bundle nor installer fixture is +executable or establishes Authenticode trust. diff --git a/apps/maple-agent/crates/maple-agent-updater/tests/fixtures/signed-windows-v3-envelope.json b/apps/maple-agent/crates/maple-agent-updater/tests/fixtures/signed-windows-v3-envelope.json new file mode 100644 index 000000000..d1f8331f3 --- /dev/null +++ b/apps/maple-agent/crates/maple-agent-updater/tests/fixtures/signed-windows-v3-envelope.json @@ -0,0 +1 @@ +{"manifest":"{\"format\":\"maple-agent-update-v3\",\"product\":\"maple-agent\",\"channel\":\"stable\",\"version\":\"0.0.2\",\"build\":\"2\",\"source_sha\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"pub_date\":\"2026-10-21T15:00:00Z\",\"notes\":\"A test update.\\nPlain text.\",\"artifacts\":{\"windows-x86_64\":{\"path\":\"/agent/stable/releases/0.0.2/Maple-Agent-windows-x86_64.tar.gz\",\"size\":28,\"sha256\":\"106cfb1b4d93e07f8c02c2a3440aafd7dd2f5b0889bbb7ba77bc99ca85ef84da\"}},\"downloads\":{\"windows\":{\"path\":\"/agent/stable/releases/0.0.2/Maple-Agent-windows-x86_64-setup.exe\",\"size\":28,\"sha256\":\"106cfb1b4d93e07f8c02c2a3440aafd7dd2f5b0889bbb7ba77bc99ca85ef84da\"}}}","signature":"untrusted comment: TEST ONLY\nRUQxMjM0NTY3OHn77zOcQDfEUffmN/4+emNOhLmIcQGNkviT/2tpJEG9lh/l3Zw4ljHikFOH/7LhB+O+wvWWeYc9/ArB2yWiuQk=\ntrusted comment: product=maple-agent channel=stable version=0.0.2\njF5rijKJrrO/pxRR0ydMy6VtqAdX25ywmULUVodUak67O+5riTDOhLUdOe90noDYNQALhQ2Wm+Dk4R30hyFGAQ==\n"} \ No newline at end of file diff --git a/apps/maple-agent/crates/maple-agent-updater/tests/fixtures/windows-v3-contract.md b/apps/maple-agent/crates/maple-agent-updater/tests/fixtures/windows-v3-contract.md new file mode 100644 index 000000000..10286edb3 --- /dev/null +++ b/apps/maple-agent/crates/maple-agent-updater/tests/fixtures/windows-v3-contract.md @@ -0,0 +1,15 @@ +The Windows v3 fixture uses the public deterministic test-only key `[7; 32]` +and the ASCII payload `signed payload fixture bytes`, like the v1/v2 fixtures. +Neither the updater nor installer fixture is executable or proves Authenticode +trust. Never pin this public key in an application profile. + +The Windows-only manifest uses `format: maple-agent-update-v3`, exactly one +`artifacts.windows-x86_64` `.tar.gz` bundle and one `downloads.windows` `.exe` +installer. These artifacts have separate independently validated paths, sizes +and SHA-256 hashes. All common manifest fields, bounds and signature comments +retain the existing contract. + +The separate discovery path is `/agent/stable/windows/latest.json`; immutable +signed metadata lives at `/agent/stable/releases/0.0.2/windows-manifest.json`. +Windows inventory is forbidden in macOS v1 and Linux v2 metadata, including +explicit-null download fields. Windows v3 similarly rejects all legacy fields. diff --git a/apps/maple-agent/crates/maple-agent-updater/tests/fixtures/windows-v3-manifest.json b/apps/maple-agent/crates/maple-agent-updater/tests/fixtures/windows-v3-manifest.json new file mode 100644 index 000000000..b55a340c1 --- /dev/null +++ b/apps/maple-agent/crates/maple-agent-updater/tests/fixtures/windows-v3-manifest.json @@ -0,0 +1 @@ +{"format":"maple-agent-update-v3","product":"maple-agent","channel":"stable","version":"0.0.2","build":"2","source_sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","pub_date":"2026-10-21T15:00:00Z","notes":"A test update.\nPlain text.","artifacts":{"windows-x86_64":{"path":"/agent/stable/releases/0.0.2/Maple-Agent-windows-x86_64.tar.gz","size":28,"sha256":"106cfb1b4d93e07f8c02c2a3440aafd7dd2f5b0889bbb7ba77bc99ca85ef84da"}},"downloads":{"windows":{"path":"/agent/stable/releases/0.0.2/Maple-Agent-windows-x86_64-setup.exe","size":28,"sha256":"106cfb1b4d93e07f8c02c2a3440aafd7dd2f5b0889bbb7ba77bc99ca85ef84da"}}} \ No newline at end of file diff --git a/apps/maple-agent/crates/maple-agent-updater/tests/windows_bootstrap_native.rs b/apps/maple-agent/crates/maple-agent-updater/tests/windows_bootstrap_native.rs new file mode 100644 index 000000000..1f9599555 --- /dev/null +++ b/apps/maple-agent/crates/maple-agent-updater/tests/windows_bootstrap_native.rs @@ -0,0 +1,33 @@ +//! Native component harness for the app's real stable Windows bootstrap module. +//! This intentionally reuses the updater workspace lock/dependencies without +//! linking GPUI or claiming an officially signed A-to-B application upgrade. + +#![cfg(windows)] + +// App entrypoints are type-checked here but unit tests do not call every one. +#[allow(dead_code)] +#[path = "../../../app/src/windows_bootstrap.rs"] +mod windows_bootstrap; + +#[test] +fn native_reparse_fixtures_are_available() { + use std::os::windows::fs::{symlink_dir, symlink_file}; + + let scratch = tempfile::tempdir().unwrap(); + let file = scratch.path().join("source"); + std::fs::write(&file, b"task-owned fixture").unwrap(); + let file_link = scratch.path().join("file-link"); + symlink_file(&file, &file_link) + .expect("native reparse coverage requires Windows symlink privilege"); + assert!(std::fs::symlink_metadata(file_link).unwrap().is_symlink()); + let directory = scratch.path().join("directory"); + std::fs::create_dir(&directory).unwrap(); + let directory_link = scratch.path().join("directory-link"); + symlink_dir(&directory, &directory_link) + .expect("native ancestor-reparse coverage requires Windows symlink privilege"); + assert!( + std::fs::symlink_metadata(directory_link) + .unwrap() + .is_symlink() + ); +} diff --git a/apps/maple-agent/docs/desktop-builds.md b/apps/maple-agent/docs/desktop-builds.md index 16f622898..618bd876f 100644 --- a/apps/maple-agent/docs/desktop-builds.md +++ b/apps/maple-agent/docs/desktop-builds.md @@ -28,7 +28,7 @@ embedded uninstaller and final installer. PRs produce unsigned Dev previews. | Profile | Application | macOS bundle ID | Configuration/data namespace | Update delivery | | --- | --- | --- | --- | --- | | Dev | Maple Work Dev | `cloud.opensecret.maple.agent.dev` | `maple-agent-dev` | Disabled; master Actions artifacts | -| Prod | Maple Work | `cloud.opensecret.maple.agent` | `maple-agent-prod` | macOS/Linux signed Agent feed; Windows manual installer; publication separately gated | +| Prod | Maple Work | `cloud.opensecret.maple.agent` | `maple-agent-prod` | Platform-specific signed Agent feed; publication separately gated | The binary embeds its profile, hosted API/client/billing settings, and PCR environment. `--build-info` prints the public build identity as JSON without @@ -94,12 +94,15 @@ They retain the same run/profile identity across retries, so a packaging-only retry can reuse a successful compilation while that internal artifact is retained. Final packages are retained for 30 days; internal prebuilt binaries for 7 days. -Windows packages contain an `-windows-x86_64-setup.exe` installer, public -build-info, package manifest and checksums. Run the installer as your desktop +Windows packages contain an `-windows-x86_64-setup.exe` installer, an updater +`.tar.gz`, public build-info, package manifest and checksums. Run the installer as your desktop user. Dev and Prod use distinct per-user installation directories, shortcuts and uninstall entries, separate from Research. Uninstall removes installed program -files and integration while preserving account, settings and task data. Windows -upgrades currently use a new installer download; automatic updates are disabled. +files and integration while preserving account, settings and task data. Packaged +Prod uses a separate signed Windows v3 feed. The per-user installation keeps a +stable signed launcher and uninstaller; updates stage a new +`versions/-/` generation and atomically select it with +`current.json`, preserving the running generation. Dev has no production feed. macOS packages contain a DMG and application archive; drag the chosen Agent app into Applications. Both Agent profiles can coexist with Maple Research. Linux packages contain @@ -208,6 +211,7 @@ From the component directory in Git Bash on Windows: ```sh ./scripts/build-release.sh dev +export MAPLE_PACKAGE_BUILD_NUMBER=1 # Actions supplies its actual github.run_number python3 scripts/windows-release.py package dev --unsigned python3 scripts/windows-release.py verify dev dist/dev --unsigned --smoke ``` @@ -217,6 +221,14 @@ uses the same command without `--unsigned`, only in the trusted master job after Azure login and the existing pinned Artifact Signing module setup. The signing runner never executes downloaded app code. Install/uninstall smoke runs only on a separate credential-free verifier. +The exact five final files, build number, source/profile and fixed tar inventory +are checked before attestation and again after download. The tar is generated +from the same extracted installer generation, so its signed executable and +all public notice/descriptor bytes match. Native preflight checks destination +and code trust before installed files or registration change; a durable manual +install intent permits safe interrupted-install recovery. Unsigned Prod must +refuse before modification; unsigned Dev remains a positive install/GUI/uninstall +preview with no production feed or keys. For a produced Prod updater archive, compile the trusted data-only scanner and validate the exact package directory (add `--unsigned` for PR packages): @@ -244,8 +256,9 @@ The independent Agent publisher promotes only verified Prod artifacts into the Agent R2 bucket. It creates no GitHub Release/tag and does not redeploy Research. The client accepts only its signed Agent product/channel/target manifest, with the primary and backup keys embedded in the Prod profile. Packaged Prod builds -on macOS/Linux check their platform's feed. Windows has no updater feed or -automatic update backend. Publication remains separately gated, and +on macOS/Linux/Windows check their platform's feed. The Windows v3 feed binds +its generation `.tar.gz` separately from the manual `.exe` download and does +not change the installed macOS v1 or Linux v2 contracts. Publication remains separately gated, and invitations require a signed native update rehearsal on every required launch platform. An empty-key profile still disables updater traffic. See diff --git a/apps/maple-agent/docs/release-profiles.md b/apps/maple-agent/docs/release-profiles.md index eb232334e..c12406083 100644 --- a/apps/maple-agent/docs/release-profiles.md +++ b/apps/maple-agent/docs/release-profiles.md @@ -64,14 +64,18 @@ Tarball/pure Nix builds without Git report `unknown` for unavailable revisions. Packaged Prod builds use only the compiled Agent stable feed: `https://updates.maple.ai/agent/stable/latest.json` on macOS, or -`https://updates.maple.ai/agent/stable/linux/latest.json` on Linux. The checked-in -`update_feed_url` and `linux_update_feed_url` select those targets; embedded -build-info retains the existing `update_feed_url` field with the effective feed. +`https://updates.maple.ai/agent/stable/linux/latest.json` on Linux, and +`https://updates.maple.ai/agent/stable/windows/latest.json` on Windows. The checked-in +`update_feed_url`, `linux_update_feed_url` and `windows_update_feed_url` select +those targets; embedded build-info retains the existing `update_feed_url` field +with the effective feed. Older installed packages retain their compiled `updates.trymaple.ai` feeds; -preserve that hostname and both legacy feed paths. Signed manifest paths and -trusted keys are unchanged, so the two hostnames can serve the same release. -Both feeds describe the same Agent version, build and source when publishing both -platforms. Separate wire schemas preserve installed macOS client compatibility; +preserve that hostname and both legacy feed paths. Existing macOS/Linux signed +manifest paths and trusted keys are unchanged, so the two hostnames can serve +the same release. +The platform feeds describe the same Agent version, build and source when +publishing multiple platforms. Separate wire schemas preserve installed macOS +client compatibility; this does not require separate release cadences. The profile pins the reviewed primary and backup public keys, enabling packaged Prod discovery after launch. Publication is a separate protected operation; invitations @@ -81,9 +85,29 @@ makes no update requests. Active trust requires two distinct public keys; the backup supports planned rotation and does not revoke a compromised accepted primary. -Windows Dev/Prod installers use the same profile and state identities, but their -compiled update feed is absent. Windows upgrades currently require a new -installer download; the existing macOS/Linux feeds do not authorize Windows updates. +Windows Dev/Prod installers retain their profile and state identities. Packaged +Prod uses its separate Windows v3 signed feed, a `.tar.gz` update bundle, and an +NSIS `.exe` for manual installation. The macOS v1 and Linux v2 feeds cannot +authorize a Windows update. Windows packages use the existing Authenticode +signing identity alongside the Agent feed's pinned primary and backup keys. +Dev and unpackaged Windows builds remain excluded from production updates. +The final Windows package artifact has exactly five files: the signed manual +installer, its versioned updater `.tar.gz`, `build-info.json`, `manifest.json` +and `SHA256SUMS`. The manifest binds the positive decimal Actions build number, +source, profile, installer and updater names. The bundle's +`maple-agent-windows/` root contains the signed application, public build and +bundle descriptors, license and the fixed notice/wordlist inventory. It omits +the uninstaller. + +Windows installations keep a stable signed root launcher and uninstaller, plus +immutable generation payloads under `versions/-/`. A native +helper validates the generation before atomically replacing `current.json`; +the running generation stays intact until shutdown. Manual installation uses +the same native trust and destination checks before changing installed files. +Unsigned Prod previews must refuse installation, while Dev previews have no +production feed or keys. A future change to the stable bootstrap requires an +explicit manual-installer migration; the normal updater changes only the +generation payload. Dev and unpackaged builds contain no updater feed or keys. No runtime override can opt either into production updating. `MAPLE_DISABLE_UPDATE_CHECK=1` remains diff --git a/apps/maple-agent/docs/updates.md b/apps/maple-agent/docs/updates.md index 7753097d2..6c6108fb2 100644 --- a/apps/maple-agent/docs/updates.md +++ b/apps/maple-agent/docs/updates.md @@ -2,7 +2,8 @@ Packaged Prod builds use the Agent-only signed update service at `https://updates.maple.ai/agent/stable/latest.json` on macOS and -`https://updates.maple.ai/agent/stable/linux/latest.json` on Linux. Dev and unpackaged +`https://updates.maple.ai/agent/stable/linux/latest.json` on Linux, and +`https://updates.maple.ai/agent/stable/windows/latest.json` on Windows x86_64. Dev and unpackaged builds do not check that service. The Prod profile now pins the reviewed primary and backup public keys, so packaged Prod builds check the compiled feed after launch. Builds and production publication remain separate operations; @@ -100,7 +101,7 @@ The installer holds the per-application lock while staging a verified bundle beside the installed application and while that update is ready. It checks the fresh signed offer again and holds the profile's existing startup and host ownership locks from host stop through replacement. It checks the payload and staged tree again before macOS's -atomic bundle exchange or Linux directory exchange. A previous bundle remains in the staging directory until +atomic bundle exchange, Linux directory exchange, or Windows generation-pointer commit. A previous bundle remains in the installation until the new installed version starts successfully and validates its receipt. This is crash recovery retention, not automatic rollback or permission to install an older signed version. @@ -133,6 +134,48 @@ reopen use the validated persistent `AppRun`; the bounded SQL integrity child executes the validated loader/payload directly to preserve parent ownership and private stdin. After replacement, restart targets the committed bundle's `AppRun`. +### Windows installation and recovery + +Windows starts with a per-user NSIS installer, not a legacy-layout migration. +The registered installation contains a stable signed `maple-agent.exe` bootstrap +and `uninstall.exe`, `current.json`, and complete generations under +`versions/-/`. Shortcuts and CLI invocations use the bootstrap; +it authenticates the selected generation and forwards native arguments. CLI +help, version, build information and ACP belong to that selected generation. +The stable bootstrap and uninstaller are not replaced by in-app updates; +changing their contract requires a separately qualified manual installer. + +An update verifies the signed v3 offer, archive hash, exact bounded generation +inventory, embedded profile/version/source identity, and native Authenticode +trust and timestamp for `Mutiny Wallet Inc. dba OpenSecret`. Prod never accepts +unsigned preview code. The installer writes a complete new generation before +atomically replacing `current.json` with a same-directory write-through rename. +The running executable is not overwritten. The previous generation remains +until the new application's successful store checkpoint authenticates its +receipt and bytes. A missing or malformed pointer and newly launched inactive +generations fail closed; there is no implicit launch of the older root binary. + +Manual installation uses a durable transaction intent tied to the actual +installer process and creation time. A concurrent installer is refused; an +interrupted installer can resume without deleting unknown files. An incomplete +manual transaction does not prevent launch of the authenticated current +generation. Native checks reject reparse points, hardlinks, unsupported paths +and directories writable by another unprivileged account. The updater does not +repair unsafe ownership or permissions. + +Restart uses native process arguments and waits for the retiring process's +exact PID and creation time. It does not construct a PowerShell command from +the installation path. If relaunch fails, reopen the registered launcher; +the committed pointer and retained generation remain available. Windows uses +the same desktop and host exclusion, remote update admission and manual +controller policy described above. + +Normal launch verifies the installed code with local Windows trust data and no +certificate-network retrieval. New payload admission retains chain/revocation +verification. This keeps an already installed application independent of live +certificate-service availability; it does not obtain fresh revocation knowledge +at every launch. Local distrust, invalid signatures and changed code still fail. + ## Maple Work filename bridge Display branding is Maple Work; current macOS packages still contain @@ -179,7 +222,12 @@ Linux uses a separate `maple-agent-update-v2` envelope with only `artifacts["linux-x86_64"]` and `downloads["linux-bundle"]`, both describing the same `.tar.gz`. Its immutable envelope is `releases//linux-manifest.json`; macOS retains `releases//manifest.json`. Clients bind their target to the -matching schema and compiled feed before advancing state. Each artifact has a +matching schema and compiled feed before advancing state. Windows uses +`maple-agent-update-v3` with exactly `artifacts["windows-x86_64"]` for its +generation `.tar.gz` and `downloads["windows"]` for the separate signed NSIS +`.exe`. Its immutable envelope is `releases//windows-manifest.json`. +Older v1/v2 manifests retain their existing strict fields and bytes; they do +not admit Windows fields. Each artifact has a signed path, byte size, and SHA-256. Paths stay under `/agent/stable/releases//`. Metadata is @@ -286,6 +334,21 @@ signature/notarization checks remain intact. They do not prove Developer ID distribution, deployed service behavior, or a successful real package update. +The focused **Agent Windows Updater Tests** workflow runs the actual updater +and bootstrap component tests on Windows with no signing or publication +credentials. It requires the named native filesystem, trust rejection, +transaction and recovery cases and rejects ignored or empty suites. These +fixtures do not establish official Maple signatures, installed A-to-B updates, +visible GUI or paired remote-upgrade behavior. Record those qualifications +separately from a green component job or an installer consumer. + +Windows publication is opt-in and additionally requires the repository variable +`AGENT_WINDOWS_UPDATES_ENABLED=true`. Leave it disabled until signed installed +upgrades, interruption recovery, native relaunch, GUI and paired remote checks +are qualified. Before public Windows downloads, apply the reviewed download +tracking `0002` migration as documented by that service; its existing database +was initialized outside Wrangler's migration history. + Before invites, publish two unadvertised signed increasing versions and run the actual installed application's check, download, confirmation, host stop, swap and relaunch through the production service. Verify active host clients cannot diff --git a/apps/maple-agent/release-profiles.json b/apps/maple-agent/release-profiles.json index 1b4a396d5..833c0c045 100644 --- a/apps/maple-agent/release-profiles.json +++ b/apps/maple-agent/release-profiles.json @@ -12,6 +12,7 @@ "update_tag_prefix": "maple-agent-dev-v", "update_feed_url": null, "linux_update_feed_url": null, + "windows_update_feed_url": null, "update_public_keys": [], "prerelease": true }, @@ -28,6 +29,7 @@ "update_tag_prefix": "maple-agent-v", "update_feed_url": "https://updates.maple.ai/agent/stable/latest.json", "linux_update_feed_url": "https://updates.maple.ai/agent/stable/linux/latest.json", + "windows_update_feed_url": "https://updates.maple.ai/agent/stable/windows/latest.json", "update_public_keys": [ "RWSEawHGDqhs8hlPvW08LCDXexMVkxqCtKhorNHTVpgMmYlls07md+mc", "RWR3nhVL8nlVmwQBTSKl/dl6a0V3eseG260Be3/aYVb4+K8MgBfyOyN4" diff --git a/apps/maple-agent/release_profile.rs b/apps/maple-agent/release_profile.rs index a9737114c..13125a10b 100644 --- a/apps/maple-agent/release_profile.rs +++ b/apps/maple-agent/release_profile.rs @@ -19,6 +19,7 @@ pub struct ReleaseProfile { pub update_tag_prefix: String, pub update_feed_url: Option, pub linux_update_feed_url: Option, + pub windows_update_feed_url: Option, pub update_public_keys: Vec, pub prerelease: bool, } @@ -30,6 +31,7 @@ impl ReleaseProfile { match target_os { "macos" => self.update_feed_url.as_deref(), "linux" => self.linux_update_feed_url.as_deref(), + "windows" => self.windows_update_feed_url.as_deref(), _ => None, } } @@ -106,6 +108,12 @@ fn validate_profiles(profiles: &BTreeMap) -> Result<(), } else { None } + || profile.windows_update_feed_url.as_deref() + != if channel == "prod" { + Some("https://updates.maple.ai/agent/stable/windows/latest.json") + } else { + None + } || (channel == "dev" && !profile.update_public_keys.is_empty()) || !matches!(profile.update_public_keys.len(), 0 | 2) || profile @@ -161,6 +169,7 @@ pub fn select_profile( // even if the selected packaged profile gains signing keys later. profile.update_feed_url = None; profile.linux_update_feed_url = None; + profile.windows_update_feed_url = None; profile.update_public_keys.clear(); // Local/MDE builds keep the existing explicit PCR override. Invalid // values still fail runtime validation rather than being normalized. @@ -288,6 +297,8 @@ mod tests { assert!(dev.prerelease); assert!(!prod.prerelease); assert_eq!(dev.update_feed_url, None); + assert_eq!(dev.linux_update_feed_url, None); + assert_eq!(dev.windows_update_feed_url, None); assert!(dev.update_public_keys.is_empty()); assert_eq!( prod.update_feed_url.as_deref(), @@ -322,6 +333,8 @@ mod tests { assert_eq!(profile.bundle_id, "cloud.opensecret.maple.agent.debug"); assert_eq!(profile.auth_origin, None); assert_eq!(profile.update_feed_url, None); + assert_eq!(profile.linux_update_feed_url, None); + assert_eq!(profile.windows_update_feed_url, None); assert!(profile.update_public_keys.is_empty()); } @@ -349,10 +362,17 @@ mod tests { legacy_macos.get_mut("prod").unwrap().update_feed_url = Some("https://updates.trymaple.ai/agent/stable/latest.json".into()); assert!(validate_profiles(&legacy_macos).is_err()); - let mut legacy_linux = original; + let mut legacy_linux = original.clone(); legacy_linux.get_mut("prod").unwrap().linux_update_feed_url = Some("https://updates.trymaple.ai/agent/stable/linux/latest.json".into()); assert!(validate_profiles(&legacy_linux).is_err()); + let mut legacy_windows = original; + legacy_windows + .get_mut("prod") + .unwrap() + .windows_update_feed_url = + Some("https://updates.trymaple.ai/agent/stable/windows/latest.json".into()); + assert!(validate_profiles(&legacy_windows).is_err()); } #[test] @@ -366,7 +386,11 @@ mod tests { prod.update_feed_for_target_os("linux"), Some("https://updates.maple.ai/agent/stable/linux/latest.json") ); - for target_os in ["windows", "freebsd", ""] { + assert_eq!( + prod.update_feed_for_target_os("windows"), + Some("https://updates.maple.ai/agent/stable/windows/latest.json") + ); + for target_os in ["freebsd", ""] { assert_eq!(prod.update_feed_for_target_os(target_os), None); } for channel in [None, Some("dev")] { @@ -378,11 +402,17 @@ mod tests { for channel in ["dev", "prod"] { for feed in [None, Some("https://wrong.example/latest.json".into())] { let mut configured = profiles().unwrap(); - configured.get_mut(channel).unwrap().linux_update_feed_url = feed; + configured.get_mut(channel).unwrap().linux_update_feed_url = feed.clone(); assert_eq!( validate_profiles(&configured).is_ok(), channel == "dev" && configured["dev"].linux_update_feed_url.is_none() ); + let mut configured = profiles().unwrap(); + configured.get_mut(channel).unwrap().windows_update_feed_url = feed; + assert_eq!( + validate_profiles(&configured).is_ok(), + channel == "dev" && configured["dev"].windows_update_feed_url.is_none() + ); } } } diff --git a/apps/maple-agent/scripts/release-info.py b/apps/maple-agent/scripts/release-info.py index eafe5a824..46109ae3e 100755 --- a/apps/maple-agent/scripts/release-info.py +++ b/apps/maple-agent/scripts/release-info.py @@ -59,7 +59,7 @@ def validate_info(profile, info, source_sha=None, platform=None): raise ValueError("binary release profile does not match the requested package") for field in PUBLIC_FIELDS: if field == "update_feed_url" and platform == "windows-x86_64": - expected = None # Windows distribution has no installed updater yet. + expected = profiles[profile]["windows_update_feed_url"] elif field == "update_feed_url" and platform == "linux-x86_64": expected = profiles[profile]["linux_update_feed_url"] else: @@ -132,6 +132,10 @@ def windows_package_name(info): return f"maple-agent-{info['profile']}-{info['version']}-{info['git_revision']}-windows-x86_64-setup.exe" +def windows_archive_name(info): + return f"maple-agent-{info['profile']}-{info['version']}-{info['git_revision']}-windows-x86_64.tar.gz" + + def public_files(directory): files = list(directory.iterdir()) if any(path.is_symlink() or not path.is_file() for path in files): @@ -139,7 +143,7 @@ def public_files(directory): return {path.name for path in files} -def write_manifest(info, directory, platform, unsigned): +def write_manifest(info, directory, platform, unsigned, build_number=None): directory = Path(directory) if platform not in ("macos-aarch64", "linux-x86_64", "windows-x86_64"): raise ValueError("unsupported artifact platform") @@ -170,11 +174,15 @@ def write_manifest(info, directory, platform, unsigned): manifest["macos_signing"] = None manifest["linux_integrity"] = "sha256" if platform == "windows-x86_64": - expected = {"build-info.json", windows_package_name(info)} + if not isinstance(build_number, str) or not re.fullmatch(r"[1-9][0-9]{0,17}", build_number): + raise ValueError("Windows package build number must be a positive decimal string") + expected = {"build-info.json", windows_package_name(info), windows_archive_name(info)} if packages[0] != windows_package_name(info) or not expected <= files or files - expected - {"manifest.json", "SHA256SUMS"}: - raise ValueError("Windows artifact directory must contain only the expected installer and public metadata") + raise ValueError("Windows artifact directory must contain only the expected installer, updater bundle and public metadata") manifest["macos_signing"] = None manifest["windows_signing"] = "unsigned" if unsigned else "authenticode" + manifest["build"] = build_number + manifest["updater"] = windows_archive_name(info) (directory / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8", newline="\n") files = sorted(p for p in directory.iterdir() if p.is_file() and p.name != "SHA256SUMS") (directory / "SHA256SUMS").write_text("".join( @@ -218,9 +226,15 @@ def verify_artifacts(profile, directory, unsigned, source_sha=None, platform=Non if manifest.get("macos_signing", "missing") is not None or manifest.get("windows_signing") != ("unsigned" if unsigned else "authenticode"): raise ValueError("unexpected Windows signing policy") if manifest.get("package") != windows_package_name(info) or files != { - "build-info.json", "manifest.json", "SHA256SUMS", windows_package_name(info), + "build-info.json", "manifest.json", "SHA256SUMS", windows_package_name(info), windows_archive_name(info), }: - raise ValueError("Windows artifact directory must contain exactly the expected installer and public metadata") + raise ValueError("Windows artifact directory must contain exactly the expected installer, updater bundle and public metadata") + if (manifest.get("updater") != windows_archive_name(info) + or not isinstance(manifest.get("build"), str) + or not re.fullmatch(r"[1-9][0-9]{0,17}", manifest["build"])): + raise ValueError("Windows manifest must bind its exact updater bundle and positive build number") + if set(manifest) != {"schema_version", "profile", "platform", "source_sha", "version", "package", "distribution", "macos_signing", "windows_signing", "build", "updater"}: + raise ValueError("Windows manifest has unexpected metadata fields") lines = (directory / "SHA256SUMS").read_text().splitlines() checked = set() for line in lines: @@ -322,7 +336,7 @@ def main(): parser.add_argument("path") parser.add_argument("--source-sha") parser.add_argument("--field") - parser.add_argument("--build-number", default="1") + parser.add_argument("--build-number") parser.add_argument("--output") parser.add_argument("--platform", choices=("macos-aarch64", "linux-x86_64", "windows-x86_64")) parser.add_argument("--unsigned", action="store_true") @@ -344,9 +358,9 @@ def main(): raise ValueError("field is not public release metadata") print(info[args.field]) elif args.operation == "plist": - write_plist(info, args.output, args.build_number) + write_plist(info, args.output, args.build_number or "1") elif args.operation == "manifest": - write_manifest(info, args.output, args.platform, args.unsigned) + write_manifest(info, args.output, args.platform, args.unsigned, args.build_number) if __name__ == "__main__": diff --git a/apps/maple-agent/scripts/test_release_packaging.py b/apps/maple-agent/scripts/test_release_packaging.py index 4839bf35d..bb28631aa 100644 --- a/apps/maple-agent/scripts/test_release_packaging.py +++ b/apps/maple-agent/scripts/test_release_packaging.py @@ -488,21 +488,22 @@ def test_prod_feed_validation_uses_the_explicit_platform_not_the_input_url(self) profiles = json.loads((SCRIPTS.parent / "release-profiles.json").read_text()) self.info = {**profile_info("prod"), "profile": "prod", "version": "0.1.0", "source_sha": "a" * 40, "git_revision": "a" * 8} - self.write_info() - release_info.read_info("prod", self.path) - release_info.read_info("prod", self.path, platform="macos-aarch64") - with self.assertRaisesRegex(ValueError, "update_feed_url"): - release_info.read_info("prod", self.path, platform="linux-x86_64") - self.info["update_feed_url"] = profiles["prod"]["linux_update_feed_url"] - self.write_info() - release_info.read_info("prod", self.path, platform="linux-x86_64") - for platform in (None, "macos-aarch64"): - with self.subTest(platform=platform), self.assertRaisesRegex(ValueError, "update_feed_url"): - release_info.read_info("prod", self.path, platform=platform) - self.info["update_feed_url"] = "https://wrong.invalid/agent/stable/linux/latest.json" - self.write_info() - with self.assertRaisesRegex(ValueError, "update_feed_url"): - release_info.read_info("prod", self.path, platform="linux-x86_64") + feeds = { + None: profiles["prod"]["update_feed_url"], + "macos-aarch64": profiles["prod"]["update_feed_url"], + "linux-x86_64": profiles["prod"]["linux_update_feed_url"], + "windows-x86_64": profiles["prod"]["windows_update_feed_url"], + } + for platform, expected in feeds.items(): + for feed in {*feeds.values(), None, "https://wrong.invalid/agent/stable/latest.json"}: + with self.subTest(platform=platform, feed=feed): + self.info["update_feed_url"] = feed + self.write_info() + if feed == expected: + release_info.read_info("prod", self.path, platform=platform) + else: + with self.assertRaisesRegex(ValueError, "update_feed_url"): + release_info.read_info("prod", self.path, platform=platform) def test_backup_requires_a_distinct_public_key_not_a_changed_comment_or_id(self): def key(point, key_id=b"12345678", comment="fixture"): @@ -529,6 +530,14 @@ def test_release_plist_has_identity_permissions_and_no_shell_environment(self): with self.assertRaises(ValueError): release_info.write_plist(self.info, output, "0") + def test_macos_plist_cli_preserves_its_default_build_number(self): + output = self.directory / "Info.plist" + subprocess.run( + [sys.executable, str(SCRIPTS / "release-info.py"), "plist", "dev", str(self.path), + "--output", str(output)], check=True, capture_output=True, text=True, + ) + self.assertEqual(plistlib.loads(output.read_bytes())["CFBundleVersion"], "1") + def package(self, unsigned=True): (self.directory / "fixture.dmg").write_bytes(b"package") release_info.write_manifest(self.info, self.directory, "macos-aarch64", unsigned) @@ -539,6 +548,15 @@ def test_unsigned_package_cannot_pass_master_verification(self): with self.assertRaises(ValueError): release_info.verify_artifacts("dev", self.directory, False) + def test_macos_manifest_keeps_its_existing_schema_without_windows_fields(self): + self.package(False) + manifest = release_info.verify_artifacts("dev", self.directory, False, platform="macos-aarch64") + self.assertEqual(set(manifest), { + "schema_version", "profile", "platform", "source_sha", "version", + "package", "distribution", "macos_signing", + }) + self.assertEqual(manifest["macos_signing"], "developer-id-notarized") + def test_final_downloaded_package_tamper_is_rejected(self): self.package(False) (self.directory / "fixture.dmg").write_bytes(b"altered package") @@ -593,6 +611,10 @@ def test_linux_package_records_checksum_policy_and_exact_public_inventory(self): self.assertEqual(manifest["package"], "maple-agent-dev-0.1.0-aaaaaaaa-linux-x86_64.tar.gz") self.assertIsNone(manifest["macos_signing"]) self.assertEqual(manifest["linux_integrity"], "sha256") + self.assertEqual(set(manifest), { + "schema_version", "profile", "platform", "source_sha", "version", + "package", "distribution", "macos_signing", "linux_integrity", + }) self.assertEqual({path.name for path in self.directory.iterdir()}, {"build-info.json", "manifest.json", "SHA256SUMS", package.name}) self.assertTrue(all(path.stat().st_mode & 0o777 == 0o644 for path in self.directory.iterdir())) diff --git a/apps/maple-agent/scripts/test_windows_build_info.py b/apps/maple-agent/scripts/test_windows_build_info.py index 110c3b20f..a59f99132 100644 --- a/apps/maple-agent/scripts/test_windows_build_info.py +++ b/apps/maple-agent/scripts/test_windows_build_info.py @@ -30,8 +30,7 @@ def public_info(profile="dev", source_sha=SOURCE_SHA): profiles = json.loads((SCRIPTS.parent / "release-profiles.json").read_text()) public = {field: profiles[profile][field] for field in release_info.PUBLIC_FIELDS} - # Windows has no automatic update feed in this preview. - return dict(public, update_feed_url=None, profile=profile, version="0.1.0", + return dict(public, update_feed_url=profiles[profile]["windows_update_feed_url"], profile=profile, version="0.1.0", git_revision=source_sha[:8], source_sha=source_sha) @@ -125,10 +124,17 @@ def test_exact_public_identity_without_execution_and_profile_binding(self): with self.subTest(source=source), self.assertRaisesRegex(ValueError, "full lowercase SHA"): metadata.validated_build_info("dev", self.binary, source) - def test_both_profiles_use_windows_identity_without_update_feed(self): + def test_both_profiles_use_their_own_windows_update_feed(self): for profile in ("dev", "prod"): self.write(public_info(profile)) self.assertEqual(metadata.validated_build_info(profile, self.binary, SOURCE_SHA), public_info(profile)) + self.assertEqual(public_info(profile)["update_feed_url"], + "https://updates.maple.ai/agent/stable/windows/latest.json" if profile == "prod" else None) + for url in ("https://updates.maple.ai/agent/stable/latest.json", + "https://updates.maple.ai/agent/stable/linux/latest.json", None): + self.write({**public_info("prod"), "update_feed_url": url}) + with self.subTest(url=url), self.assertRaisesRegex(ValueError, "update_feed_url"): + metadata.validated_build_info("prod", self.binary, SOURCE_SHA) def test_rejects_non_pe_architectures_dlls_and_bad_optional_headers(self): for options in ({"machine": 0x14C}, {"machine": 0xAA64}, {"characteristics": 0x2022}, {"characteristics": 0}): diff --git a/apps/maple-agent/scripts/test_windows_release.py b/apps/maple-agent/scripts/test_windows_release.py index 83bc6c742..daa4e329b 100644 --- a/apps/maple-agent/scripts/test_windows_release.py +++ b/apps/maple-agent/scripts/test_windows_release.py @@ -2,12 +2,17 @@ """Windows package identity, extraction and signing-boundary regressions.""" import hashlib +import gzip import importlib.util +import io import json from pathlib import Path +import re import subprocess import sys +import tarfile import tempfile +import types import unittest from unittest.mock import patch import zipfile @@ -24,12 +29,26 @@ def info(profile="dev"): return { **{key: profiles[profile][key] for key in release.release_info.PUBLIC_FIELDS}, "profile": profile, "version": "0.1.2", "source_sha": "a" * 40, - "git_revision": "a" * 8, "update_feed_url": None, + "git_revision": "a" * 8, "update_feed_url": profiles[profile]["windows_update_feed_url"], } class WindowsMetadataTests(unittest.TestCase): - def test_windows_prod_has_its_own_disabled_feed_without_erasing_public_keys(self): + def package(self, directory, profile="dev", unsigned=True, build="12"): + identity = info(profile) + (directory / release.release_info.windows_package_name(identity)).write_bytes(b"installer fixture") + (directory / release.release_info.windows_archive_name(identity)).write_bytes(b"bundle fixture") + (directory / "build-info.json").write_text(json.dumps(identity)) + release.release_info.write_manifest(identity, directory, release.PLATFORM, unsigned, build) + return identity + + def write_checksums(self, directory): + (directory / "SHA256SUMS").write_text("".join( + f"{hashlib.sha256(path.read_bytes()).hexdigest()} {path.name}\n" + for path in sorted(directory.iterdir()) if path.name != "SHA256SUMS" + )) + + def test_windows_prod_has_its_own_feed_without_erasing_public_keys(self): identity = info("prod") release.release_info.validate_info("prod", identity, "a" * 40, release.PLATFORM) self.assertEqual(len(identity["update_public_keys"]), 2) @@ -45,17 +64,29 @@ def test_windows_manifest_binds_exact_installer_name_source_profile_and_signing_ identity = info(profile) package = directory / release.release_info.windows_package_name(identity) package.write_bytes(b"installer fixture") + (directory / release.release_info.windows_archive_name(identity)).write_bytes(b"bundle fixture") (directory / "build-info.json").write_text(json.dumps(identity)) - release.release_info.write_manifest(identity, directory, release.PLATFORM, unsigned) + release.release_info.write_manifest(identity, directory, release.PLATFORM, unsigned, "12") manifest = release.release_info.verify_artifacts(profile, directory, unsigned, "a" * 40, release.PLATFORM) self.assertEqual(manifest["windows_signing"], "unsigned" if unsigned else "authenticode") + self.assertEqual(manifest["updater"], release.release_info.windows_archive_name(identity)) + self.assertEqual(manifest["build"], "12") + self.assertEqual({path.name for path in directory.iterdir()}, { + package.name, release.release_info.windows_archive_name(identity), + "build-info.json", "manifest.json", "SHA256SUMS", + }) + self.assertEqual(set(manifest), { + "schema_version", "profile", "platform", "source_sha", "version", + "package", "distribution", "macos_signing", "windows_signing", "build", "updater", + }) + self.assertTrue(all(path.stat().st_mode & 0o777 == 0o644 for path in directory.iterdir())) with self.assertRaises(ValueError): release.release_info.verify_artifacts(profile, directory, not unsigned, "a" * 40, release.PLATFORM) with self.assertRaises(ValueError): release.release_info.verify_artifacts(profile, directory, unsigned, "b" * 40, release.PLATFORM) package.rename(directory / "renamed-setup.exe") with self.assertRaises(ValueError): - release.release_info.write_manifest(identity, directory, release.PLATFORM, unsigned) + release.release_info.write_manifest(identity, directory, release.PLATFORM, unsigned, "12") def test_unknown_installer_payload_cannot_be_blessed_by_new_checksums(self): with tempfile.TemporaryDirectory() as temporary: @@ -65,7 +96,206 @@ def test_unknown_installer_payload_cannot_be_blessed_by_new_checksums(self): (directory / "build-info.json").write_text(json.dumps(identity)) (directory / "extra.exe").write_bytes(b"unexpected") with self.assertRaises(ValueError): - release.release_info.write_manifest(identity, directory, release.PLATFORM, True) + release.release_info.write_manifest(identity, directory, release.PLATFORM, True, "12") + + def test_windows_manifest_requires_bundle_and_binds_build_and_inventory(self): + with tempfile.TemporaryDirectory() as temporary: + directory = Path(temporary) + identity = info() + (directory / release.release_info.windows_package_name(identity)).write_bytes(b"installer") + (directory / "build-info.json").write_text(json.dumps(identity)) + with self.assertRaisesRegex(ValueError, "updater bundle"): + release.release_info.write_manifest(identity, directory, release.PLATFORM, True, "12") + (directory / release.release_info.windows_archive_name(identity)).write_bytes(b"bundle") + for build in (None, False, 12, "", "0", "01", "12x", "1" * 19): + with self.subTest(build=build), self.assertRaisesRegex(ValueError, "build number"): + release.release_info.write_manifest(identity, directory, release.PLATFORM, True, build) + + def test_windows_manifest_cli_requires_an_explicit_build_number(self): + with tempfile.TemporaryDirectory() as temporary: + directory = Path(temporary) + self.package(directory) + (directory / "manifest.json").unlink() + (directory / "SHA256SUMS").unlink() + command = [sys.executable, str(SCRIPTS / "release-info.py"), "manifest", "dev", + str(directory / "build-info.json"), "--output", str(directory), + "--platform", release.PLATFORM, "--unsigned"] + result = subprocess.run(command, capture_output=True, text=True) + self.assertNotEqual(result.returncode, 0) + self.assertIn("Windows package build number", result.stderr) + self.assertFalse((directory / "manifest.json").exists()) + self.assertFalse((directory / "SHA256SUMS").exists()) + subprocess.run([*command, "--build-number", "42"], check=True, capture_output=True, text=True) + manifest = release.release_info.verify_artifacts("dev", directory, True, "a" * 40, release.PLATFORM) + self.assertEqual(manifest["build"], "42") + + def test_downloaded_manifest_rejects_wrong_build_bundle_or_extra_fields_even_with_new_checksums(self): + with tempfile.TemporaryDirectory() as temporary: + directory = Path(temporary) + self.package(directory) + path = directory / "manifest.json" + original = json.loads(path.read_text()) + for field, value in ( + ("build", None), ("build", 12), ("build", False), ("build", "0"), + ("build", "01"), ("build", "1" * 19), + ("updater", "maple-agent-prod-0.1.2-aaaaaaaa-windows-x86_64.tar.gz"), + ("updater", "https://wrong.invalid/bundle.tar.gz"), + ("macos_signing", "developer-id-notarized"), ("windows_signing", "sha256"), + ("unexpected", "public but not part of the manifest contract"), + ): + with self.subTest(field=field, value=value): + path.write_text(json.dumps({**original, field: value})) + self.write_checksums(directory) + with self.assertRaises(ValueError): + release.release_info.verify_artifacts("dev", directory, True, "a" * 40, release.PLATFORM) + for field in ("build", "updater", "macos_signing", "windows_signing"): + with self.subTest(missing=field): + path.write_text(json.dumps({key: value for key, value in original.items() if key != field})) + self.write_checksums(directory) + with self.assertRaises(ValueError): + release.release_info.verify_artifacts("dev", directory, True, "a" * 40, release.PLATFORM) + + def test_downloaded_inventory_cannot_be_renamed_or_extended_with_consistent_checksums(self): + for mutation in ("renamed-installer", "renamed-bundle", "missing-installer", "missing-bundle", "extra"): + with self.subTest(mutation=mutation), tempfile.TemporaryDirectory() as temporary: + directory = Path(temporary) + identity = self.package(directory) + path = directory / "manifest.json" + manifest = json.loads(path.read_text()) + if mutation == "extra": + (directory / "extra.txt").write_bytes(b"unexpected public file") + else: + field = "package" if mutation.endswith("installer") else "updater" + package = directory / manifest[field] + if mutation.startswith("renamed"): + name = "renamed-setup.exe" if field == "package" else "renamed.tar.gz" + package.rename(directory / name) + manifest[field] = name + path.write_text(json.dumps(manifest)) + else: + package.unlink() + self.write_checksums(directory) + with self.assertRaisesRegex(ValueError, "exactly the expected installer"): + release.release_info.verify_artifacts(identity["profile"], directory, True, "a" * 40, release.PLATFORM) + + def test_every_final_file_is_checksum_bound(self): + for filename in ("build-info.json", "manifest.json", release.release_info.windows_package_name(info()), + release.release_info.windows_archive_name(info())): + with self.subTest(filename=filename), tempfile.TemporaryDirectory() as temporary: + directory = Path(temporary) + self.package(directory) + with (directory / filename).open("ab") as file: + file.write(b"\n") + with self.assertRaisesRegex(ValueError, "checksum"): + release.release_info.verify_artifacts("dev", directory, True, "a" * 40, release.PLATFORM) + + def test_final_inventory_rejects_links_and_directories(self): + for name in ("build-info.json", "manifest.json", "SHA256SUMS", + release.release_info.windows_package_name(info()), release.release_info.windows_archive_name(info())): + for kind in ("symlink", "directory"): + with self.subTest(name=name, kind=kind), tempfile.TemporaryDirectory() as temporary: + directory = Path(temporary) + self.package(directory) + path = directory / name + path.unlink() + if kind == "symlink": + path.symlink_to(directory / "absent-target") + else: + path.mkdir() + with self.assertRaisesRegex(ValueError, "unsupported file"): + release.release_info.verify_artifacts("dev", directory, True, "a" * 40, release.PLATFORM) + + +class BundleTests(unittest.TestCase): + def payload(self, directory, identity=None, build="12"): + identity = info() if identity is None else identity + for name in release.PAYLOAD_FILES: + file = directory / name + file.parent.mkdir(parents=True, exist_ok=True) + file.write_bytes(("payload " + name).encode()) + (directory / "bundle-info.json").write_text(json.dumps(release.bundle_descriptor(identity, build), sort_keys=True, separators=(",", ":")) + "\n") + return directory + + def archive(self, path, members): + with gzip.open(path, "wb") as compressed: + with tarfile.open(fileobj=compressed, mode="w", format=tarfile.USTAR_FORMAT) as archive: + for member, content in members: + archive.addfile(member, io.BytesIO(content) if content is not None else None) + + def test_bundle_is_deterministic_exact_generation_payload_and_matches_installer_bytes(self): + with tempfile.TemporaryDirectory() as temporary: + parent = Path(temporary) + payload = self.payload(parent / "payload") + first, second = parent / "first.tar.gz", parent / "second.tar.gz" + release.write_bundle(payload, first, info(), "12") + release.write_bundle(payload, second, info(), "12") + self.assertEqual(first.read_bytes(), second.read_bytes()) + hashes = release.inspect_bundle(first, info(), "12", payload) + self.assertEqual(set(hashes), release.BUNDLE_FILES) + self.assertNotIn("uninstall.exe", hashes) + (payload / "maple-agent.exe").write_bytes(b"changed installer payload") + with self.assertRaisesRegex(ValueError, "differs from"): + release.inspect_bundle(first, info(), "12", payload) + for changed_info, build in ((info("prod"), "12"), (info(), "13")): + with self.assertRaisesRegex(ValueError, "identity"): + release.inspect_bundle(first, changed_info, build) + + def test_archive_rejects_links_case_aliases_duplicate_unknown_paths_and_extensions(self): + with tempfile.TemporaryDirectory() as temporary: + parent = Path(temporary) + payload = self.payload(parent / "payload") + valid = parent / "valid.tar.gz" + release.write_bundle(payload, valid, info(), "12") + with tarfile.open(valid, "r:gz") as archive: + members = [(member, archive.extractfile(member).read() if member.isfile() else None) for member in archive] + for name, kind in (("../escape", tarfile.REGTYPE), (release.BUNDLE_ROOT + "/MAPLE-AGENT.exe", tarfile.REGTYPE), (release.BUNDLE_ROOT + "/extra.dll", tarfile.REGTYPE), (members[-1][0].name, tarfile.REGTYPE), (release.BUNDLE_ROOT + "/link", tarfile.SYMTYPE), (release.BUNDLE_ROOT + "/long", tarfile.GNUTYPE_LONGNAME)): + with self.subTest(name=name, kind=kind): + entry = tarfile.TarInfo(name) + entry.type, entry.mode = kind, 0o644 + if kind == tarfile.SYMTYPE: + entry.linkname = "maple-agent.exe" + bad = parent / "bad.tar.gz" + self.archive(bad, [*members, (entry, b"")]) + with self.assertRaises((ValueError, tarfile.TarError)): + release.inspect_bundle(bad, info(), "12") + + def test_archive_rejects_duplicate_descriptor_keys_truncated_and_trailing_content(self): + with tempfile.TemporaryDirectory() as temporary: + parent = Path(temporary) + payload = self.payload(parent / "payload") + valid = parent / "valid.tar.gz" + release.write_bundle(payload, valid, info(), "12") + with tarfile.open(valid, "r:gz") as archive: + members = [(member, archive.extractfile(member).read() if member.isfile() else None) for member in archive] + changed = [] + for entry, content in members: + if entry.name.endswith("/bundle-info.json"): + content = content.replace(b'{', b'{"profile":"dev",', 1) + entry.size = len(content) + changed.append((entry, content)) + bad = parent / "duplicate.tar.gz" + self.archive(bad, changed) + with self.assertRaisesRegex(ValueError, "duplicate"): + release.inspect_bundle(bad, info(), "12") + raw = gzip.decompress(valid.read_bytes()) + for content in (raw[:512], raw + b"not tar"): + bad.write_bytes(gzip.compress(content)) + with self.assertRaises((ValueError, tarfile.TarError)): + release.inspect_bundle(bad, info(), "12") + + def test_bundle_rejects_unlisted_directories_and_links_before_writing(self): + with tempfile.TemporaryDirectory() as temporary: + parent = Path(temporary) + payload = self.payload(parent / "payload") + (payload / "extra").mkdir() + with self.assertRaisesRegex(ValueError, "exact standalone"): + release.write_bundle(payload, parent / "extra.tar.gz", info(), "12") + (payload / "extra").rmdir() + (payload / "extra").symlink_to(parent, target_is_directory=True) + with self.assertRaisesRegex(ValueError, "links or reparse"): + release.write_bundle(payload, parent / "link.tar.gz", info(), "12") + self.assertFalse((parent / "extra.tar.gz").exists()) + self.assertFalse((parent / "link.tar.gz").exists()) class RuntimeClosureTests(unittest.TestCase): @@ -84,30 +314,39 @@ def test_os_components_and_api_sets_are_allowed_but_compiler_runtime_is_not(self class InstallerExtractionTests(unittest.TestCase): + def expected_names(self): + return sorted({"versions/0.1.2-12/" + name for name in release.BUNDLE_FILES} | {"maple-agent.exe", "uninstall.exe", release.INSTALLER_CHECKER}) + + def member_data(self, name): + if name.endswith("/bundle-info.json"): + return (json.dumps(release.bundle_descriptor(info(), "12"), sort_keys=True, separators=(",", ":")) + "\n").encode() + return b"fixture content!!" + def inventory(self, names): - return "\n\n".join(f"Path = {name}\nSize = 17\nFolder = -" for name in names) + "\n" + return "\n\n".join(f"Path = {name}\nSize = {len(self.member_data(name))}\nFolder = -" for name in names) + "\n" def extract(self, directory, names, unsigned): def command(arguments, **_options): if arguments[1] == "l": return subprocess.CompletedProcess(arguments, 0, self.inventory(names)) return subprocess.CompletedProcess(arguments, 0) - def member(_extractor, _installer, _name, destination, _limit): + def member(_extractor, _installer, name, destination, _limit): destination.parent.mkdir(parents=True, exist_ok=True) - destination.write_bytes(b"fixture content!!") - return 17 + data = self.member_data(name) + destination.write_bytes(data) + return len(data) with patch.object(release, "seven_zip", return_value="7z"), patch.object(release, "run", side_effect=command), patch.object(release, "extract_member", side_effect=member): - return release.extract_installer(Path("fixture-setup.exe"), directory, unsigned) + return release.extract_installer(Path("fixture-setup.exe"), directory, unsigned, info(), "12") def test_exact_signed_and_unsigned_payload_inventories(self): - for unsigned, uninstaller in ((False, "uninstall.exe"), (True, "uninstall.exe"), (True, "uninstall.exe.nsis")): + for unsigned, uninstaller in ((False, "uninstall.exe"), (True, "uninstall.exe")): with self.subTest(unsigned=unsigned), tempfile.TemporaryDirectory() as temporary: directory = Path(temporary) - result = self.extract(directory, sorted(release.PAYLOAD_FILES | {uninstaller}), unsigned) + result = self.extract(directory, self.expected_names(), unsigned) self.assertEqual(result, directory / uninstaller) def test_rejects_traversal_duplicate_extra_and_missing_before_extraction(self): - valid = sorted(release.PAYLOAD_FILES | {"uninstall.exe.nsis"}) + valid = self.expected_names() for names in (valid + ["../escape"], valid + ["C:/escape"], valid + ["extra.dll"], valid + [valid[0]], valid[:-1]): with self.subTest(names=names), tempfile.TemporaryDirectory() as temporary: directory = Path(temporary) @@ -116,11 +355,19 @@ def test_rejects_traversal_duplicate_extra_and_missing_before_extraction(self): self.assertEqual(list(directory.iterdir()), []) def test_reconstructed_uninstaller_can_omit_preliminary_size(self): - names = sorted(release.PAYLOAD_FILES | {"uninstall.exe"}) + names = self.expected_names() listing = self.inventory(names).replace("Path = uninstall.exe\nSize = 17", "Path = uninstall.exe\nSize = ") with tempfile.TemporaryDirectory() as temporary, patch.object(self, "inventory", return_value=listing): self.assertEqual(self.extract(Path(temporary), names, True).name, "uninstall.exe") + def test_temporary_preflight_checker_must_equal_exact_generation_executable(self): + original = self.member_data + def changed(name): + return b"changed checker!!" if name == release.INSTALLER_CHECKER else original(name) + with tempfile.TemporaryDirectory() as temporary, patch.object(self, "member_data", side_effect=changed): + with self.assertRaisesRegex(ValueError, "checker differs"): + self.extract(Path(temporary), self.expected_names(), True) + def test_member_stream_cannot_write_beyond_its_budget(self): original = subprocess.Popen def fixture(*_arguments, **options): @@ -133,6 +380,17 @@ def fixture(*_arguments, **options): class PackageBoundaryTests(unittest.TestCase): + def test_downloaded_verifier_checks_the_standalone_uninstaller_in_both_policies(self): + for unsigned in (False, True): + with self.subTest(unsigned=unsigned), tempfile.TemporaryDirectory() as temporary: + directory = Path(temporary) + manifest = {"package": "installer.exe", "updater": "bundle.tar.gz", "build": "12"} + with patch.dict(release.os.environ, MAPLE_PACKAGE_BUILD_NUMBER="12"), patch.object(release, "source_sha", return_value="a" * 40), patch.object(release.release_info, "verify_artifacts", return_value=manifest), patch.object(release.release_info, "read_info", return_value=info()), patch.object(release, "signature") as signature, patch.object(release, "extract_installer", side_effect=lambda _installer, payload, *_args: payload / "uninstall.exe"), patch.object(release, "verify_payload"), patch.object(release, "inspect_bundle", return_value={}), patch.object(release, "smoke_installer") as smoke: + release.verify("dev", directory, unsigned, smoke=False) + self.assertEqual([call.args[0].name for call in signature.call_args_list], ["installer.exe", "uninstall.exe"]) + self.assertTrue(all(call.args[1] == unsigned for call in signature.call_args_list)) + smoke.assert_not_called() + def test_packager_signs_payload_before_compression_and_final_output_afterward_without_running_app(self): with tempfile.TemporaryDirectory() as temporary: component = Path(temporary) @@ -149,13 +407,22 @@ def compiler(arguments, **_options): calls.append(("compiler", str(arguments[0]))) output = next((component / "dist").glob(".windows-*/output")) (output / release.release_info.windows_package_name(info())).write_bytes(b"NSIS fixture") - with patch.object(release, "COMPONENT", component), patch.object(release, "prepare", return_value=(binary, info())), patch.object(release.notices, "stage", side_effect=stage), patch.object(release, "signature", side_effect=sign), patch.object(release, "verify_payload"), patch.object(release, "install_nsis", return_value="trusted-makensis.exe"), patch.object(release, "run", side_effect=compiler), patch.object(release, "verify") as verify: + def extract(_installer, directory, _unsigned, identity, build): + (directory / "versions" / (identity["version"] + "-" + build)).mkdir(parents=True) + uninstaller = directory / "uninstall.exe" + uninstaller.write_bytes(b"uninstaller fixture") + return uninstaller + def bundle(_payload, archive, _info, _build): + calls.append(("bundle", Path(archive).name)) + archive.write_bytes(b"bundle fixture") + with patch.dict(release.os.environ, MAPLE_PACKAGE_BUILD_NUMBER="12"), patch.object(release, "COMPONENT", component), patch.object(release, "prepare", return_value=(binary, info())), patch.object(release.notices, "stage", side_effect=stage), patch.object(release, "signature", side_effect=sign), patch.object(release, "verify_payload"), patch.object(release, "install_nsis", return_value="trusted-makensis.exe"), patch.object(release, "run", side_effect=compiler), patch.object(release, "extract_installer", side_effect=extract), patch.object(release, "write_bundle", side_effect=bundle), patch.object(release, "verify") as verify: release.package("dev", unsigned=False) app_signed = calls.index(("sign", "maple-agent.exe")) compiled = calls.index(("compiler", "trusted-makensis.exe")) installer_signed = calls.index(("sign", release.release_info.windows_package_name(info()))) self.assertLess(app_signed, compiled) self.assertLess(compiled, installer_signed) + self.assertLess(installer_signed, calls.index(("bundle", release.release_info.windows_archive_name(info())))) self.assertEqual([call for call in calls if call[0] == "compiler"], [("compiler", "trusted-makensis.exe")]) self.assertFalse(verify.call_args.kwargs["smoke"]) @@ -180,14 +447,110 @@ def test_nsis_archive_rejects_hash_mismatch_and_traversal(self): def test_generated_config_escapes_nsis_values_and_profiles_have_distinct_ids(self): with tempfile.TemporaryDirectory() as temporary: path = Path(temporary) / "configuration.nsh" - release.write_nsis_config(path, info(), Path('C:/a $variable/path with "quote"'), Path("C:/setup.exe"), unsigned=True) + release.write_nsis_config(path, info(), Path('C:/a $variable/path with "quote"'), Path("C:/setup.exe"), unsigned=True, build="12") text = path.read_text(encoding="utf-8") self.assertIn("$$variable", text) self.assertIn('$\\"quote$\\"', text) - self.assertNotIn("!define SIGNER", text) + self.assertIn("!define SIGNER", text) + self.assertIn('!define UNSIGNED_UNINSTALLER "1"', text) + self.assertIn('!define GENERATION "0.1.2-12"', text) self.assertNotEqual(info("dev")["bundle_id"], info("prod")["bundle_id"]) self.assertNotEqual(info("dev")["display_name"], info("prod")["display_name"]) + def test_nsis_native_preflight_precedes_installed_files_and_pointer_activation(self): + source = (SCRIPTS / "windows-installer.nsi").read_text() + preflight = source.index('--internal-windows-installer-preflight "$INSTDIR"') + prepare = source.index('--internal-windows-installer-prepare "$INSTDIR" ${GENERATION}') + self.assertLess(preflight, prepare) + self.assertLess(prepare, source.index('SetOutPath "$INSTDIR"')) + self.assertLess(preflight, source.index('WriteRegStr HKCU')) + self.assertLess(source.index('File "${PAYLOAD}\\bundle-info.json"'), source.index('--internal-windows-installer-activate ${GENERATION}')) + self.assertNotIn('File /oname=current.json', source) + self.assertNotIn('RMDir /r', source) + self.assertIn('--internal-windows-uninstall-cleanup', source) + cleanup = source.index('--internal-windows-uninstall-cleanup') + self.assertLess(cleanup, source.index('Delete "$INSTDIR\\.install.lock"')) + self.assertLess(cleanup, source.index('Delete "$INSTDIR\\.installer-intent.json"')) + self.assertIn('IfFileExists "$INSTDIR\\maple-agent.exe" bootstrap_ready', source) + self.assertIn('IfFileExists "$INSTDIR\\uninstall.exe" uninstaller_ready', source) + + def test_nsis_helper_launch_errors_cannot_reuse_a_previous_success_exit_code(self): + source = (SCRIPTS / "windows-installer.nsi").read_text() + instructions = [line.strip() for line in source.splitlines() if line.strip() and not line.lstrip().startswith(';')] + calls = [index for index, line in enumerate(instructions) if line.startswith('ExecWait ')] + self.assertEqual(len(calls), 4) + for index in calls: + command = instructions[index] + destination = 'failed' if '--internal-windows-installer-activate ' in command else ( + 'refuse' if '--internal-windows-uninstall-cleanup' in command else 'refused' + ) + with self.subTest(command=command): + # NSIS leaves the output variable undefined on launch error. + # Test its error flag before ever reading a stale zero in $0. + self.assertEqual(instructions[index - 1], 'ClearErrors') + self.assertEqual(instructions[index + 1], 'IfErrors ' + destination) + self.assertEqual(instructions[index + 2], 'StrCmp $0 0 0 ' + destination) + + def test_nsis_uninstall_keeps_native_fence_until_all_mutations_are_finished(self): + source = (SCRIPTS / "windows-installer.nsi").read_text().split('Section "Uninstall"', 1)[1] + release_fence = 'Delete "$INSTDIR\\.uninstaller-intent.json"' + self.assertEqual(source.count(release_fence), 1) + before, after = source.split(release_fence) + for mutation in ('Delete "$INSTDIR\\current.json"', 'Delete "$INSTDIR\\.install.lock"', + 'Delete "$INSTDIR\\maple-agent.exe"', 'Delete "$INSTDIR\\uninstall.exe"', + 'Delete "$SMPROGRAMS\\${DISPLAY_NAME}\\${DISPLAY_NAME}.lnk"', + 'DeleteRegKey HKCU'): + with self.subTest(mutation=mutation): + self.assertIn(mutation, before) + mutation = re.compile(r'^(?:Delete(?:RegKey|RegValue)?|RMDir|Write\w*|CreateDirectory|CreateShortcut|SetOutPath|File)\b') + remaining = [line.strip() for line in after.splitlines() if mutation.match(line.strip())] + self.assertEqual(remaining, []) + + +class UnsignedProdSmokeTests(unittest.TestCase): + def registry(self): + def missing(*_arguments): + raise FileNotFoundError + return types.SimpleNamespace(KEY_READ=1, KEY_WOW64_32KEY=2, HKEY_CURRENT_USER=3, OpenKey=missing) + + def smoke(self, invoke): + # This portable test covers the verifier's negative-proof assertions. + # Actual NSIS/native trust behavior remains a Windows consumer test. + with patch.dict(sys.modules, winreg=self.registry()), patch.object(release, "smoke_environment", return_value={}), patch.object(subprocess, "run", side_effect=invoke), patch.object(release, "gui_smoke") as gui: + release.smoke_installer(Path("preview.exe"), info("prod"), True, "12", {}) + gui.assert_not_called() + + def test_unsigned_prod_requires_failure_without_destination_changes(self): + self.smoke(lambda *_arguments, **_options: subprocess.CompletedProcess("preview", 1)) + with self.assertRaisesRegex(ValueError, "refuse before"): + self.smoke(lambda *_arguments, **_options: subprocess.CompletedProcess("preview", 0)) + + def test_unsigned_prod_rejects_partial_destination_changes_even_after_failure(self): + def partial(command, **_options): + Path(command.split("/D=", 1)[1]).mkdir() + return subprocess.CompletedProcess(command, 1) + with self.assertRaisesRegex(ValueError, "refuse before"): + self.smoke(partial) + + def test_unsigned_prod_rejects_registration_changes_even_after_failure(self): + registry = self.registry() + class Opened: + def __enter__(self): + return self + def __exit__(self, *_arguments): + return False + calls = 0 + def changed(*_arguments): + nonlocal calls + calls += 1 + if calls == 1: + raise FileNotFoundError + return Opened() + registry.OpenKey = changed + with patch.dict(sys.modules, winreg=registry), patch.object(release, "smoke_environment", return_value={}), patch.object(subprocess, "run", return_value=subprocess.CompletedProcess("preview", 1)): + with self.assertRaisesRegex(ValueError, "registration"): + release.smoke_installer(Path("preview.exe"), info("prod"), True, "12", {}) + if __name__ == "__main__": unittest.main() diff --git a/apps/maple-agent/scripts/windows-installer.nsi b/apps/maple-agent/scripts/windows-installer.nsi index e291256d3..5d1101b9e 100644 --- a/apps/maple-agent/scripts/windows-installer.nsi +++ b/apps/maple-agent/scripts/windows-installer.nsi @@ -20,8 +20,14 @@ VIAddVersionKey /LANG=1033 "LegalCopyright" "Maple Privacy Labs" !ifdef SIGNER ; NSIS generates a standalone uninstaller and signs it without running any ; downloaded executable. A signer error aborts the package build. +!ifdef UNSIGNED_UNINSTALLER +; Preview bundles need the same standalone PE inventory. Verification performs +; no signing and requires the compiler output to remain unsigned. +!uninstfinalize 'pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -File "${SIGNER}" "%1" -VerifyOnly -Unsigned' = 0 +!else !uninstfinalize 'pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -File "${SIGNER}" "%1"' = 0 !endif +!endif Page directory Page instfiles @@ -50,39 +56,69 @@ unsupported: supported: FunctionEnd +Var PreviousInstallLocation +Var PreviousDisplayVersion +Var HadRegistration + Section "Install" - ; Fail before changing any installed files if the desktop or background host - ; still has the executable open. Append mode preserves the existing bytes; - ; opening for write fails while Windows maps the running executable. - IfFileExists "$INSTDIR\maple-agent.exe" 0 ready + ; The temporary copy is the same verified signed application PE. Its private + ; early command performs no GUI, account, store or backend initialization. + ; All destination/DACL/reparse checks happen before any installed file or + ; registry changes. Unsigned Prod previews deliberately stop here. + InitPluginsDir + SetOutPath "$PLUGINSDIR" + File /oname=maple-installer-check.exe "${PAYLOAD}\maple-agent.exe" ClearErrors - FileOpen $0 "$INSTDIR\maple-agent.exe" a - IfErrors locked - FileClose $0 - Goto ready -locked: - MessageBox MB_OK|MB_ICONSTOP "Close ${DISPLAY_NAME} and its background host before installing." /SD IDOK - SetErrorLevel 1 - Abort -ready: + ExecWait '"$PLUGINSDIR\maple-installer-check.exe" --internal-windows-installer-preflight "$INSTDIR"' $0 + IfErrors refused + StrCmp $0 0 0 refused + ; The signed helper durably records the manual operation, creates a private + ; root, bootstraps only a fresh install and repairs incomplete non-current + ; generation files. A retry must never overwrite a live or valid rollback + ; generation, nor remain stuck with an interrupted NSIS file copy. + ClearErrors + ExecWait '"$PLUGINSDIR\maple-installer-check.exe" --internal-windows-installer-prepare "$INSTDIR" ${GENERATION}' $0 + IfErrors refused + StrCmp $0 0 0 refused + + StrCpy $HadRegistration 0 + ClearErrors + ReadRegStr $PreviousInstallLocation HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${BUNDLE_ID}" "InstallLocation" + IfErrors unregistered + StrCmp $PreviousInstallLocation "$INSTDIR" 0 refused + StrCpy $HadRegistration 1 + ReadRegStr $PreviousDisplayVersion HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${BUNDLE_ID}" "DisplayVersion" +unregistered: + ; Never overwrite the stable bootstrap or an existing generation. All new + ; generation bytes arrive before the native helper commits current.json. SetOutPath "$INSTDIR" - SetOverwrite on + IfFileExists "$INSTDIR\maple-agent.exe" bootstrap_ready ClearErrors File "${PAYLOAD}\maple-agent.exe" IfErrors failed +bootstrap_ready: + SetOutPath "$INSTDIR\versions\${GENERATION}" + SetOverwrite off + ClearErrors + File "${PAYLOAD}\maple-agent.exe" File "${PAYLOAD}\LICENSE.txt" File "${PAYLOAD}\build-info.json" - SetOutPath "$INSTDIR\notices" + SetOutPath "$INSTDIR\versions\${GENERATION}\notices" File "${PAYLOAD}\notices\THIRD_PARTY_NOTICES.txt" File "${PAYLOAD}\notices\THIRD_PARTY_NOTICES.html" File "${PAYLOAD}\notices\manifest.json" - SetOutPath "$INSTDIR\notices\wordlists" + SetOutPath "$INSTDIR\versions\${GENERATION}\notices\wordlists" File "${PAYLOAD}\notices\wordlists\eff-large-wordlist.txt" File "${PAYLOAD}\notices\wordlists\SOURCES.md" + SetOutPath "$INSTDIR\versions\${GENERATION}" + File "${PAYLOAD}\bundle-info.json" IfErrors failed + SetOverwrite on SetOutPath "$INSTDIR" + IfFileExists "$INSTDIR\uninstall.exe" uninstaller_ready WriteUninstaller "$INSTDIR\uninstall.exe" IfErrors failed +uninstaller_ready: CreateDirectory "$SMPROGRAMS\${DISPLAY_NAME}" CreateShortcut "$SMPROGRAMS\${DISPLAY_NAME}\${DISPLAY_NAME}.lnk" "$INSTDIR\maple-agent.exe" CreateShortcut "$SMPROGRAMS\${DISPLAY_NAME}\Uninstall.lnk" "$INSTDIR\uninstall.exe" @@ -96,10 +132,23 @@ ready: WriteRegDWORD HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${BUNDLE_ID}" "NoModify" 1 WriteRegDWORD HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${BUNDLE_ID}" "NoRepair" 1 IfErrors failed + ClearErrors + ExecWait '"$INSTDIR\maple-agent.exe" --internal-windows-installer-activate ${GENERATION}' $0 + IfErrors failed + StrCmp $0 0 0 failed SetErrorLevel 0 Goto done failed: - MessageBox MB_OK|MB_ICONSTOP "Installation could not replace every application file. Close the application and retry." /SD IDOK + ; The native activation failure leaves the old pointer unchanged. Restore + ; registration truth; user data and an earlier generation remain untouched. + StrCmp $HadRegistration 1 restore_registration + DeleteRegKey HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${BUNDLE_ID}" + Goto refused +restore_registration: + WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${BUNDLE_ID}" "InstallLocation" "$PreviousInstallLocation" + WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${BUNDLE_ID}" "DisplayVersion" "$PreviousDisplayVersion" +refused: + MessageBox MB_OK|MB_ICONSTOP "Installation could not be completed safely. Close the application and retry from a verified installer." /SD IDOK SetErrorLevel 1 Abort done: @@ -107,33 +156,43 @@ SectionEnd Section "Uninstall" SetShellVarContext current - ; A copied uninstaller must never remove files from an arbitrary directory. ReadRegStr $0 HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${BUNDLE_ID}" "InstallLocation" StrCmp $0 "$INSTDIR" 0 refuse + ; The trusted bootstrap derives its actual root and removes fixed owned + ; generation files only after signature, lock, DACL and reparse checks. + ; It preserves unknown user files, AppData and profile/database state. + ClearErrors + ExecWait '"$INSTDIR\maple-agent.exe" --internal-windows-uninstall-cleanup' $0 + IfErrors refuse + StrCmp $0 0 0 refuse ClearErrors + Delete "$INSTDIR\current.json" + ; The helper has exited and released this exact private lock. Removing only + ; its fixed transaction files permits a later clean reinstall at this root. + IfFileExists "$INSTDIR\.install.lock" 0 lock_removed + Delete "$INSTDIR\.install.lock" +lock_removed: + IfFileExists "$INSTDIR\.installer-intent.json" 0 intent_removed + Delete "$INSTDIR\.installer-intent.json" +intent_removed: Delete "$INSTDIR\maple-agent.exe" IfErrors refuse - Delete "$INSTDIR\LICENSE.txt" - Delete "$INSTDIR\build-info.json" - Delete "$INSTDIR\notices\THIRD_PARTY_NOTICES.txt" - Delete "$INSTDIR\notices\THIRD_PARTY_NOTICES.html" - Delete "$INSTDIR\notices\manifest.json" - Delete "$INSTDIR\notices\wordlists\eff-large-wordlist.txt" - Delete "$INSTDIR\notices\wordlists\SOURCES.md" Delete "$INSTDIR\uninstall.exe" - RMDir "$INSTDIR\notices\wordlists" - RMDir "$INSTDIR\notices" - ; Remove empty application directories only; user-created files and all - ; configuration/data under AppData remain untouched. - RMDir "$INSTDIR" + RMDir "$INSTDIR\versions" Delete "$SMPROGRAMS\${DISPLAY_NAME}\${DISPLAY_NAME}.lnk" Delete "$SMPROGRAMS\${DISPLAY_NAME}\Uninstall.lnk" RMDir "$SMPROGRAMS\${DISPLAY_NAME}" DeleteRegKey HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${BUNDLE_ID}" + ; The durable fence belongs to this NSIS process. Keep it while performing + ; all installed-file, shortcut and registration changes, after the native + ; helper releases its handles. No root/registration mutation may follow its + ; removal. Leave the empty root so a concurrently admitted reinstall cannot + ; lose its destination between directory creation and publishing its intent. + Delete "$INSTDIR\.uninstaller-intent.json" SetErrorLevel 0 Goto done refuse: - MessageBox MB_OK|MB_ICONSTOP "Close ${DISPLAY_NAME} and its background host, then uninstall from its installed location." /SD IDOK + MessageBox MB_OK|MB_ICONSTOP "Close ${DISPLAY_NAME} and its background host, then uninstall from its registered installed location." /SD IDOK SetErrorLevel 1 Abort done: diff --git a/apps/maple-agent/scripts/windows-release.py b/apps/maple-agent/scripts/windows-release.py index fa31b06cd..2edde57e1 100644 --- a/apps/maple-agent/scripts/windows-release.py +++ b/apps/maple-agent/scripts/windows-release.py @@ -6,6 +6,7 @@ """ import argparse +import gzip import hashlib import importlib.util import json @@ -16,6 +17,7 @@ import stat import subprocess import sys +import tarfile import tempfile import threading import time @@ -53,6 +55,12 @@ PAYLOAD_FILES = {"maple-agent.exe", "LICENSE.txt", "build-info.json"} | { f"notices/{name}" for name in NOTICE_FILES } +BUNDLE_ROOT = "maple-agent-windows" +BUNDLE_FORMAT = "maple-agent-windows-bundle-v1" +BUNDLE_FILES = PAYLOAD_FILES | {"bundle-info.json"} +BUNDLE_DIRECTORIES = {"", "notices", "notices/wordlists"} +MAX_BUNDLE_BYTES = 768 * 1024 * 1024 +INSTALLER_CHECKER = "$PLUGINSDIR/maple-installer-check.exe" def module(name): @@ -85,6 +93,29 @@ def regular(path): raise ValueError("Windows payload cannot traverse a reparse point") +def bundle_payload_inventory(directory): + """Reject extra directories and link/reparse entries before archiving.""" + files, directories = set(), {""} + if directory.is_symlink() or not directory.is_dir(): + raise ValueError("Windows updater payload requires an ordinary directory") + for path in (directory, *directory.rglob("*")): + if path.is_symlink() or getattr(path.lstat(), "st_file_attributes", 0) & 0x400: + raise ValueError("Windows updater payload cannot contain links or reparse points") + name = path.relative_to(directory).as_posix() + if path == directory: + continue + if path.is_dir(): + directories.add(name) + elif path.is_file(): + regular(path) + files.add(name) + else: + raise ValueError("Windows updater payload contains an unsupported entry") + if files != BUNDLE_FILES or directories != BUNDLE_DIRECTORIES: + raise ValueError("Updater bundle requires the exact standalone Windows installer payload") + return files + + def validate_imports(binary): imports = pe.inspect_pe(binary)["imports"] unknown = [name for name in imports if name not in SYSTEM_IMPORTS and not re.fullmatch(r"(?:api|ext)-ms-win-[a-z0-9-]+\.dll", name)] @@ -118,20 +149,22 @@ def nsis_literal(value): return value.replace("$", "$$").replace('"', '$\\"') -def write_nsis_config(path, info, payload, installer, unsigned): +def write_nsis_config(path, info, payload, installer, unsigned, build): values = { "DISPLAY_NAME": info["display_name"], "BUNDLE_ID": info["bundle_id"], "VERSION": info["version"], "NUMERIC_VERSION": info["version"].split("-", 1)[0], "PAYLOAD": payload, "INSTALLER": installer, + "BUILD": build, "GENERATION": info["version"] + "-" + build, } - if not unsigned: - signer = SCRIPTS / "windows-sign-file.ps1" - # !uninstfinalize is a compiler command string. Reject cmd.exe expansion - # syntax in the trusted script's filesystem path rather than quoting it - # as though a JSON string or argv list controlled cmd.exe parsing. - if any(char in str(signer) for char in '%&|<>^"\r\n'): - raise ValueError("Unsafe path for the native uninstaller signing command") - values["SIGNER"] = signer + signer = SCRIPTS / "windows-sign-file.ps1" + # !uninstfinalize is a compiler command string. Reject cmd.exe expansion + # syntax in the trusted script's filesystem path rather than quoting it + # as though a JSON string or argv list controlled cmd.exe parsing. + if any(char in str(signer) for char in '%&|<>^"\r\n'): + raise ValueError("Unsafe path for the native uninstaller signing command") + values["SIGNER"] = signer + if unsigned: + values["UNSIGNED_UNINSTALLER"] = "1" path.write_text("".join(f'!define {key} "{nsis_literal(value)}"\n' for key, value in values.items()), encoding="utf-8", newline="\n") @@ -195,7 +228,7 @@ def extract_member(extractor, installer, name, destination, limit): process.stdout.close() -def extract_installer(installer, destination, unsigned): +def extract_installer(installer, destination, unsigned, info, build): extractor = seven_zip() listing = run([extractor, "l", "-slt", "-ba", installer], capture_output=True, text=True, encoding="utf-8").stdout if len(listing) > 256 * 1024: @@ -210,14 +243,14 @@ def extract_installer(installer, destination, unsigned): if relative.is_absolute() or ".." in relative.parts or ":" in name or name in files: raise ValueError("Unsafe or duplicate Windows installer payload path") files[name] = fields - # Depending on the 7-Zip version, an unsigned NSIS uninstaller is either - # reconstructed as a PE or exposed as bytecode with a .nsis suffix. Signed - # builds must contain the actual signed standalone PE from !uninstfinalize. - extras = set(files) - PAYLOAD_FILES - allowed_uninstallers = {"uninstall.exe", "uninstall.exe.nsis"} if unsigned else {"uninstall.exe"} - if len(extras) != 1 or not extras <= allowed_uninstallers or not PAYLOAD_FILES <= set(files): + generation = info["version"] + "-" + build + payload_prefix = "versions/" + generation + "/" + expected = {payload_prefix + name for name in BUNDLE_FILES} | {"maple-agent.exe", "uninstall.exe", INSTALLER_CHECKER} + # !uninstfinalize emits the actual standalone PE for signed and preview + # installers. The root uninstaller is not part of versioned update payloads. + if set(files) != expected: raise ValueError("Windows installer has an unexpected payload inventory") - uninstaller = next(iter(extras)) + uninstaller = "uninstall.exe" # 7-Zip cannot always report a reconstructed uninstaller's size before # extraction. Every member is streamed through a hard bound; only that one # NSIS-generated member may omit its size in the preliminary inventory. @@ -241,6 +274,14 @@ def extract_installer(installer, destination, unsigned): raise ValueError("Extracted Windows installer differs from its inventory") for name in actual: regular(destination / name) + payload = destination / "versions" / generation + generation_executable = (payload / "maple-agent.exe").read_bytes() + for name in ("maple-agent.exe", INSTALLER_CHECKER): + if (destination / name).read_bytes() != generation_executable: + raise ValueError("Windows bootstrap or preflight checker differs from the signed generation executable") + descriptor = json.loads((payload / "bundle-info.json").read_bytes(), object_pairs_hook=pe.unique_object) + if descriptor != bundle_descriptor(info, build): + raise ValueError("Windows installer generation identity differs") return destination / uninstaller @@ -258,8 +299,129 @@ def verify_payload(profile, directory, expected_info, unsigned, *, verify_signat signature(binary, unsigned) +def build_number(): + value = os.environ.get("MAPLE_PACKAGE_BUILD_NUMBER", "") + if not re.fullmatch(r"[1-9][0-9]{0,17}", value): + raise ValueError("MAPLE_PACKAGE_BUILD_NUMBER must be a positive decimal string") + return value + + +def bundle_descriptor(info, build): + if not isinstance(build, str) or not re.fullmatch(r"[1-9][0-9]{0,17}", build): + raise ValueError("Windows bundle build must be a positive decimal string") + return { + "format": BUNDLE_FORMAT, "product": "maple-agent", "profile": info["profile"], + "version": info["version"], "build": build, "source_sha": info["source_sha"], + "bundle_id": info["bundle_id"], "entrypoint": "maple-agent.exe", + } + + +def write_bundle(payload, archive_path, info, build): + """Package the exact extracted NSIS files, never execute a downloaded PE.""" + bundle_payload_inventory(payload) + descriptor = json.dumps(bundle_descriptor(info, build), sort_keys=True, separators=(",", ":")) + "\n" + if (payload / "bundle-info.json").read_text(encoding="utf-8") != descriptor: + raise ValueError("Updater bundle descriptor differs from the verified installer") + total = sum((payload / name).stat().st_size for name in BUNDLE_FILES) + if total > MAX_BUNDLE_BYTES: + raise ValueError("Windows updater payload exceeds extraction bounds") + with archive_path.open("xb") as target: + with gzip.GzipFile(fileobj=target, mode="wb", filename="", mtime=0) as compressed: + with tarfile.open(fileobj=compressed, mode="w", format=tarfile.USTAR_FORMAT) as archive: + for name in sorted(BUNDLE_DIRECTORIES): + entry = tarfile.TarInfo(BUNDLE_ROOT + ("/" + name if name else "")) + entry.type, entry.mode = tarfile.DIRTYPE, 0o755 + archive.addfile(entry) + for name in sorted(BUNDLE_FILES): + file = payload / name + entry = tarfile.TarInfo(BUNDLE_ROOT + "/" + name) + entry.size, entry.mode = file.stat().st_size, 0o644 + with file.open("rb") as data: + archive.addfile(entry, data) + inspect_bundle(archive_path, info, build) + + +def preflight_bundle_headers(archive_path): + """Reject extensions before tarfile can consume attacker-controlled metadata.""" + regular(archive_path) + if archive_path.stat().st_size > MAX_BUNDLE_BYTES: + raise ValueError("Windows updater archive exceeds bounds") + total, count = 0, 0 + with gzip.open(archive_path, "rb") as stream: + while True: + header = stream.read(512) + if len(header) != 512: + raise ValueError("Windows updater archive is truncated") + if header == bytes(512): + if stream.read(512) != bytes(512): + raise ValueError("Windows updater archive has an invalid end marker") + trailing = stream.read(10241) + if len(trailing) > 10240 or any(trailing): + raise ValueError("Windows updater archive has trailing content") + return + count += 1 + entry = tarfile.TarInfo.frombuf(header, "utf-8", "strict") + if count > 128 or entry.type not in (tarfile.REGTYPE, tarfile.AREGTYPE, tarfile.DIRTYPE): + raise ValueError("Windows updater archive contains unsupported entries") + if not 0 <= entry.size <= MAX_BUNDLE_BYTES or (entry.isdir() and entry.size): + raise ValueError("Windows updater archive has an invalid member size") + total += entry.size + if total > MAX_BUNDLE_BYTES: + raise ValueError("Windows updater archive exceeds expanded bounds") + remaining = (entry.size + 511) // 512 * 512 + while remaining: + block = stream.read(min(remaining, 1024 * 1024)) + if not block: + raise ValueError("Windows updater archive payload is truncated") + remaining -= len(block) + + +def inspect_bundle(archive_path, info, build, extracted=None): + """Inspect fixed public contents and optionally compare the signed installer.""" + preflight_bundle_headers(archive_path) + expected = {BUNDLE_ROOT + "/" + name for name in BUNDLE_FILES} + expected_dirs = {BUNDLE_ROOT + ("/" + name if name else "") for name in BUNDLE_DIRECTORIES} + seen, digests, descriptor = set(), {}, None + with tarfile.open(archive_path, "r:gz") as archive: + for entry in archive: + name = entry.name + if name in seen or entry.linkname or entry.pax_headers or name not in expected | expected_dirs: + raise ValueError("Windows updater archive has an unexpected or duplicate path") + seen.add(name) + if name in expected_dirs: + if not entry.isdir() or entry.mode != 0o755: + raise ValueError("Windows updater archive has an invalid directory") + continue + if not entry.isfile() or entry.mode != 0o644: + raise ValueError("Windows updater archive requires ordinary public files") + source = archive.extractfile(entry) + if source is None: + raise ValueError("Windows updater archive member is unavailable") + digest = hashlib.sha256() + data = bytearray() + while block := source.read(1024 * 1024): + digest.update(block) + if name.endswith("/bundle-info.json"): + data.extend(block) + if len(data) > 16 * 1024: + raise ValueError("Windows updater descriptor exceeds bounds") + relative = name[len(BUNDLE_ROOT) + 1:] + digests[relative] = digest.hexdigest() + if relative == "bundle-info.json": + descriptor = json.loads(data, object_pairs_hook=pe.unique_object) + if extracted is not None: + original = extracted / relative + regular(original) + if digest.hexdigest() != hashlib.sha256(original.read_bytes()).hexdigest(): + raise ValueError("Windows updater bundle differs from the verified installer payload") + if seen != expected | expected_dirs or descriptor != bundle_descriptor(info, build): + raise ValueError("Windows updater archive identity or inventory differs") + return digests + + def package(profile, unsigned): binary, info = prepare(profile) + build = build_number() destination = COMPONENT / "dist" / profile if destination.exists(): raise ValueError("Move the previous profile artifact directory before packaging") @@ -275,16 +437,26 @@ def package(profile, unsigned): metadata = json.dumps(info, sort_keys=True, separators=(",", ":")) + "\n" (payload / "build-info.json").write_text(metadata, encoding="utf-8", newline="\n") notices.stage(COMPONENT, payload / "notices", TARGET) + (payload / "bundle-info.json").write_text(json.dumps(bundle_descriptor(info, build), sort_keys=True, separators=(",", ":")) + "\n", encoding="utf-8", newline="\n") signature(payload / "maple-agent.exe", unsigned, sign=not unsigned) verify_payload(profile, payload, info, unsigned) installer = output / release_info.windows_package_name(info) configuration = scratch / "package.nsh" - write_nsis_config(configuration, info, payload, installer, unsigned) + write_nsis_config(configuration, info, payload, installer, unsigned, build) compiler = install_nsis(scratch) run([compiler, "/V3", f"/DMAPLE_CONFIG={configuration}", SCRIPTS / "windows-installer.nsi"], timeout=300) signature(installer, unsigned, sign=not unsigned) + extracted = scratch / "extracted" + extracted.mkdir() + uninstaller = extract_installer(installer, extracted, unsigned, info, build) + if uninstaller.name != "uninstall.exe": + raise ValueError("Windows updater requires a standalone PE uninstaller") + generation = extracted / "versions" / (info["version"] + "-" + build) + verify_payload(profile, generation, info, unsigned) + signature(uninstaller, unsigned) + write_bundle(generation, output / release_info.windows_archive_name(info), info, build) shutil.copyfile(payload / "build-info.json", output / "build-info.json") - release_info.write_manifest(info, output, PLATFORM, unsigned) + release_info.write_manifest(info, output, PLATFORM, unsigned, build) # Data-only parsing of the compressed final signed bytes; never execute # the app, generated uninstaller or installer on the signing worker. verify(profile, output, unsigned, smoke=False) @@ -305,7 +477,7 @@ def smoke_environment(scratch): return environment -def smoke_installer(installer, info, unsigned): +def smoke_installer(installer, info, unsigned, build, expected_hashes): import winreg # Only the explicit native smoke path loads the Windows API. registry = "Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\" + info["bundle_id"] @@ -320,14 +492,40 @@ def smoke_installer(installer, info, unsigned): environment = smoke_environment(scratch) installed = scratch / "install with spaces" command = f'"{installer}" /S /D={installed}' + if unsigned and info["profile"] == "prod": + # Prod trust is never relaxed for a preview. The same native + # preflight must refuse before destination files or registration. + result = subprocess.run(command_as_windows_args(command), env=environment, timeout=120) + if result.returncode == 0 or installed.exists(): + raise ValueError("Unsigned Prod installer did not refuse before destination changes") + try: + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, registry, 0, registry_access): + raise ValueError("Unsigned Prod installer changed installation registration") + except FileNotFoundError: + pass + print("Verified unsigned Prod refusal before installed files, pointer or registration changes; no positive GUI/install proof") + return run(command_as_windows_args(command), env=environment, timeout=120) executable = installed / "maple-agent.exe" uninstaller = installed / "uninstall.exe" + generation_name = info["version"] + "-" + build + generation = installed / "versions" / generation_name try: with winreg.OpenKey(winreg.HKEY_CURRENT_USER, registry, 0, registry_access) as key: if winreg.QueryValueEx(key, "InstallLocation")[0] != str(installed): raise ValueError("Installer did not register the exact per-user installation") - verify_payload(info["profile"], installed, info, unsigned) + pointer_bytes = (installed / "current.json").read_bytes() + if json.loads(pointer_bytes, object_pairs_hook=pe.unique_object) != {"format": "maple-agent-windows-current-v1", "generation": generation_name}: + raise ValueError("Installed Windows current generation pointer differs") + verify_payload(info["profile"], generation, info, unsigned) + bundle_payload_inventory(generation) + for name in BUNDLE_FILES: + regular(generation / name) + if hashlib.sha256((generation / name).read_bytes()).hexdigest() != expected_hashes[name]: + raise ValueError("Installed Windows generation differs from the verified updater payload") + if executable.read_bytes() != (generation / "maple-agent.exe").read_bytes(): + raise ValueError("Freshly installed Windows bootstrap differs from its generation") + signature(executable, unsigned) signature(uninstaller, unsigned) actual = run([executable, "--build-info"], env=environment, capture_output=True, text=True).stdout if json.loads(actual) != info: @@ -341,7 +539,17 @@ def smoke_installer(installer, info, unsigned): )): bounded_diagnostics(result.stderr) raise ValueError("Installed Windows ACP did not preserve the signed-out guard") - gui_smoke(executable, scratch, environment) + # Bootstrap CLI probes above exercise native argv/stdio/status + # forwarding. GUI bootstrap intentionally detaches, so observe + # the exact selected installed generation for render/lifetime and + # deterministic cleanup instead of the short-lived launcher PID. + gui_smoke(generation / "maple-agent.exe", scratch, environment) + # A repeated manual installer must retain the active generation + # and stable bootstrap rather than rewriting a running payload. + bootstrap_hash = hashlib.sha256(executable.read_bytes()).hexdigest() + run(command_as_windows_args(command), env=environment, timeout=120) + if (installed / "current.json").read_bytes() != pointer_bytes or hashlib.sha256(executable.read_bytes()).hexdigest() != bootstrap_hash: + raise ValueError("Repeated Windows install changed stable bootstrap or active pointer") finally: if executable.is_file(): run([executable, "host", "stop"], env=environment, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=45) @@ -356,6 +564,8 @@ def smoke_installer(installer, info, unsigned): run(command_as_windows_args(f'"{uninstaller}" /S _?={installed}'), env=environment, timeout=120) if executable.exists() or sentinel.read_text(encoding="utf-8") != "preserve me" or state.read_text(encoding="utf-8") != "preserve state": raise ValueError("Uninstall did not remove the app while preserving user files and state") + if any((installed / name).exists() for name in ("current.json", ".install.lock", ".installer-intent.json", ".uninstaller-intent.json", "versions")): + raise ValueError("Uninstall left owned generation or transaction files behind") try: with winreg.OpenKey(winreg.HKEY_CURRENT_USER, registry, 0, registry_access): raise ValueError("Uninstall left the profile's registration behind") @@ -410,19 +620,22 @@ def gui_smoke(executable, scratch, environment): def verify(profile, directory, unsigned, *, smoke): directory = directory.resolve() manifest = release_info.verify_artifacts(profile, directory, unsigned, source_sha(), PLATFORM) + if manifest["build"] != build_number(): + raise ValueError("Windows package build differs from the selected producer build") info = release_info.read_info(profile, directory / "build-info.json", source_sha(), PLATFORM) installer = directory / manifest["package"] signature(installer, unsigned) with tempfile.TemporaryDirectory(prefix="maple-windows-extract-") as temporary: payload = Path(temporary) - uninstaller = extract_installer(installer, payload, unsigned) - verify_payload(profile, payload, info, unsigned) - if not unsigned: - signature(uninstaller, unsigned=False) + uninstaller = extract_installer(installer, payload, unsigned, info, manifest["build"]) + generation = payload / "versions" / (info["version"] + "-" + manifest["build"]) + verify_payload(profile, generation, info, unsigned) + signature(uninstaller, unsigned) + expected_hashes = inspect_bundle(directory / manifest["updater"], info, manifest["build"], generation) if smoke: if any(name.startswith(("AZURE_", "MAPLE_WINDOWS_ARTIFACT_SIGNING_")) for name in os.environ): raise ValueError("Installed Windows smoke requires a verifier without signing configuration") - smoke_installer(installer, info, unsigned) + smoke_installer(installer, info, unsigned, manifest["build"], expected_hashes) print(f"Verified {profile} Windows installer identity, exact inventory and signatures") @@ -449,6 +662,6 @@ def main(): if __name__ == "__main__": try: main() - except (ValueError, OSError, subprocess.SubprocessError, zipfile.BadZipFile) as error: + except (ValueError, OSError, subprocess.SubprocessError, zipfile.BadZipFile, tarfile.TarError) as error: print(f"Windows package verification failed: {error}", file=sys.stderr) sys.exit(1) diff --git a/docs/repository-workflows.md b/docs/repository-workflows.md index d8af5f3db..4b8ac9d39 100644 --- a/docs/repository-workflows.md +++ b/docs/repository-workflows.md @@ -149,6 +149,27 @@ Cargo build-script fixture before restore. Cache probes use `agent_cache_command.py` to identify the selected compiler while excluding its optional incidental installed-Rustup inventory. +`agent-windows-updater-tests.yml` separately runs native updater, filesystem, +Authenticode rejection and application bootstrap component tests on +`windows-2025` for the affected Agent paths. It uses the existing pinned Windows +Rust and linker setup without signing credentials, release environments or +dependency-artifact restoration. Required native cases must execute, including +reparse fixtures; missing fixture privileges fail the job. Its source/run/attempt +receipt and Cargo logs are retained even on failure. The lightweight bootstrap +target does not replace the real Windows application build, official Maple +signature verification, signed installed A-to-B upgrade, visible GUI or paired +remote upgrade qualification. + +The separate manual-only `agent-windows-signature-probe.yml` accepts an exact +historical producer run/attempt/source and artifact ZIP/PE digests. It validates +successful trusted-master Windows Prod build, signing and downloaded-consumer +jobs, bounded ZIP bytes and package identity before reading the selected PE with +the real admission/startup Authenticode verifier. It never launches that PE. +There is no default historical reference or dependency from ordinary PR tests; +expired references must be selected again explicitly. Its result proves only +historical signature interoperability, not the new updater's installed upgrade +or final-source release qualification. + Agent workspace, provider, plain release and Desktop Dev/Prod builders first try `agent_dependency_archive.py` through the Actions artifact service. A consumer accepts only this repository's designated push/master workflow and diff --git a/scripts/ci/test_agent_updates_workflows.py b/scripts/ci/test_agent_updates_workflows.py index f382cf406..915657fe9 100644 --- a/scripts/ci/test_agent_updates_workflows.py +++ b/scripts/ci/test_agent_updates_workflows.py @@ -41,6 +41,7 @@ def test_manual_master_only_and_separate_authority(self): # Normal releases keep both platforms on one version. Historical # macOS-only runs remain available through an explicit override. self.assertIs(config["on"]["workflow_dispatch"]["inputs"]["include_linux"]["default"], True) + self.assertIs(config["on"]["workflow_dispatch"]["inputs"]["include_windows"]["default"], False) jobs = config["jobs"] self.assertEqual(set(jobs), {"verify", "reserve", "sign", "publish"}) self.assertNotIn("environment", jobs["verify"]) @@ -126,6 +127,10 @@ def test_preparation_verifies_provenance_before_any_secrets(self): self.assertIn("--version", source) self.assertIn("--run-id", source) self.assertIn("--linux", source) + self.assertIn("--windows", source) + self.assertIn("AGENT_WINDOWS_UPDATES_ENABLED", str(verify)) + self.assertIn('"$WINDOWS_ACTIVATED" = true', source) + self.assertIn('"$(jq -er .artifacts.windows trust.json)"', source) self.assertNotIn("secrets.", str(verify)) self.assertNotIn("--unsigned", source) self.assertNotIn("gh release", source) diff --git a/scripts/ci/test_agent_workflows.py b/scripts/ci/test_agent_workflows.py index 0d0b068ff..936565b4b 100644 --- a/scripts/ci/test_agent_workflows.py +++ b/scripts/ci/test_agent_workflows.py @@ -542,6 +542,7 @@ def test_windows_preview_and_native_verifier_have_no_signing_credentials(self): "apps/maple-agent/target/third-party-notices", ]) for name in ("windows-unsigned", "windows"): + self.assertEqual(jobs[name]["env"]["MAPLE_PACKAGE_BUILD_NUMBER"], "${{ github.run_number }}") download = next(step["with"] for step in jobs[name]["steps"] if step.get("uses", "").startswith("actions/download-artifact@")) self.assertEqual(download, {"name": upload["name"], "path": "apps/maple-agent/target"}) @@ -552,6 +553,7 @@ def test_windows_preview_and_native_verifier_have_no_signing_credentials(self): self.assertTrue(any('package "$MAPLE_RELEASE_PROFILE" --unsigned' in step.get("run", "") for step in preview["steps"])) verify = jobs["verify-windows"] + self.assertEqual(verify["env"]["MAPLE_PACKAGE_BUILD_NUMBER"], "${{ github.run_number }}") self.assertEqual(verify["needs"], ["windows", "windows-unsigned"]) self.assertIn("always() && !cancelled()", verify["if"]) self.assertIn("needs.windows-unsigned.result == 'success'", verify["if"]) diff --git a/scripts/ci/test_windows_signature_reference.py b/scripts/ci/test_windows_signature_reference.py new file mode 100644 index 000000000..6d7795af3 --- /dev/null +++ b/scripts/ci/test_windows_signature_reference.py @@ -0,0 +1,132 @@ +"""Exact historical signature reference selection, without network or PE execution.""" + +import copy +import hashlib +import io +import json +from pathlib import Path +import stat +import subprocess +import unittest +import zipfile + +import windows_signature_reference as probe + + +def selected(): + return probe.selection({ + "MAPLE_PROBE_RUN": "123", "MAPLE_PROBE_ATTEMPT": "1", + "MAPLE_PROBE_ARTIFACT": "456", "MAPLE_PROBE_ZIP_BYTES": "1000", + "MAPLE_PROBE_SOURCE": "a" * 40, "MAPLE_PROBE_ZIP_SHA": "b" * 64, + "MAPLE_PROBE_PE_MEMBER": "setup.exe", "MAPLE_PROBE_PE_SHA": "c" * 64, + }) + + +def metadata(): + run = {"id": 123, "run_attempt": 1, "head_sha": "a" * 40, + "head_branch": "master", "event": "push", "path": probe.PRODUCER, + "repository": {"full_name": probe.REPOSITORY, "id": 1}, + "head_repository": {"full_name": probe.REPOSITORY}, + "status": "completed", "conclusion": "success"} + artifact = {"id": 456, "expired": False, + "name": "maple-agent-prod-windows-x86_64-123", "size_in_bytes": 1000, + "digest": "sha256:" + "b" * 64, "created_at": "2026-10-27T00:01:00Z", + "workflow_run": {"id": 123, "head_sha": "a" * 40, + "repository_id": 1, "head_repository_id": 1}} + jobs = {"total_count": 3, "jobs": [ + {"id": index + 1, "name": name, "status": "completed", "conclusion": "success", + "labels": ["windows-2025"], "started_at": "2026-10-27T00:00:00Z", + "completed_at": "2026-10-27T00:02:00Z"} for index, name in enumerate(probe.JOBS) + ]} + return run, artifact, jobs + + +def archive(extra=None, changed_source=False): + selection = selected() + executable = b"MZ ordinary data fixture; never executable" + build = {"source_sha": "a" * 40, "profile": "prod", "version": "0.0.7", + "bundle_id": "cloud.opensecret.maple.agent"} + manifest = {"source_sha": "d" * 40 if changed_source else "a" * 40, + "profile": "prod", "version": "0.0.7", "platform": "windows-x86_64", + "distribution": "master", "windows_signing": "authenticode", "package": "setup.exe"} + output = io.BytesIO() + with zipfile.ZipFile(output, "w") as payload: + payload.writestr("build-info.json", json.dumps(build)) + payload.writestr("manifest.json", json.dumps(manifest)) + payload.writestr("setup.exe", executable) + if extra is not None: + payload.writestr(extra, b"untrusted extra") + data = output.getvalue() + selection.update(ZIP_BYTES=len(data), ZIP_SHA=hashlib.sha256(data).hexdigest(), + PE_SHA=hashlib.sha256(executable).hexdigest()) + return selection, data, executable + + +class WindowsSignatureReferenceTests(unittest.TestCase): + def test_exact_successful_producer_and_three_native_jobs(self): + self.assertEqual(len(probe.validate_producer(selected(), *metadata())), 3) + for mutate in ( + lambda r, a, j: r.update(head_branch="feature"), + lambda r, a, j: r.update(run_attempt=2), + lambda r, a, j: r["head_repository"].update(full_name="someone/fork"), + lambda r, a, j: a.update(digest="sha256:" + "d" * 64), + lambda r, a, j: a.update(created_at="2026-10-27T00:03:00Z"), + lambda r, a, j: j["jobs"][1].update(conclusion="failure"), + lambda r, a, j: j["jobs"][2].update(conclusion="skipped"), + ): + values = copy.deepcopy(metadata()) + mutate(*values) + with self.assertRaises(ValueError): + probe.validate_producer(selected(), *values) + + def test_selectors_cannot_be_paths_urls_or_unbounded_sizes(self): + for key, value in (("RUN", "123/other"), ("ATTEMPT", "0"), + ("ZIP_BYTES", str(probe.MAX_ZIP + 1)), + ("SOURCE", "a" * 39), ("PE_MEMBER", "../setup.exe"), + ("PE_MEMBER", "https://example.test/setup.exe")): + environment = {"MAPLE_PROBE_" + name: str(value) for name, value in selected().items()} + environment["MAPLE_PROBE_" + key] = value + with self.assertRaises(ValueError): + probe.selection(environment) + + def test_exact_archive_and_pe_digest_are_required(self): + selection, data, executable = archive() + self.assertEqual(probe.reference_pe(selection, data), executable) + for field in ("ZIP_SHA", "PE_SHA"): + altered = dict(selection, **{field: "0" * 64}) + with self.assertRaises(ValueError): + probe.reference_pe(altered, data) + selection, data, _ = archive(changed_source=True) + with self.assertRaises(ValueError): + probe.reference_pe(selection, data) + + def test_unsafe_inventory_is_refused_before_extracting_any_member(self): + link = zipfile.ZipInfo("link") + link.external_attr = (stat.S_IFLNK | 0o777) << 16 + for extra in ("../outside", "C:outside", "other\\file", "SETUP.EXE", link): + selection, data, _ = archive(extra) + with self.assertRaises(ValueError): + probe.reference_pe(selection, data) + + def test_probe_workflow_is_manual_read_only_and_has_no_implicit_reference(self): + root = Path(__file__).resolve().parents[2] + workflow = json.loads(subprocess.check_output([ + "yq", "-o=json", ".", str(root / ".github/workflows/agent-windows-signature-probe.yml") + ], text=True)) + self.assertEqual(set(workflow["on"]), {"workflow_dispatch"}) + self.assertEqual(workflow["permissions"], {"contents": "read", "actions": "read"}) + inputs = workflow["on"]["workflow_dispatch"]["inputs"] + self.assertEqual(len(inputs), 8) + self.assertTrue(all(value["required"] and "default" not in value for value in inputs.values())) + job = workflow["jobs"]["signature-probe"] + self.assertEqual(job["runs-on"], "windows-2025") + self.assertNotIn("environment", job) + self.assertNotIn("secrets", json.dumps(workflow)) + self.assertNotIn("${{ inputs.", "\n".join(step.get("run", "") for step in job["steps"])) + actions = [step["uses"] for step in job["steps"] if "uses" in step] + self.assertTrue(all(len(action.split("@")[1]) == 40 for action in actions)) + self.assertFalse(any("cache" in action or "download-artifact" in action for action in actions)) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/ci/test_windows_updater_workflow.py b/scripts/ci/test_windows_updater_workflow.py new file mode 100644 index 000000000..1a0a96d33 --- /dev/null +++ b/scripts/ci/test_windows_updater_workflow.py @@ -0,0 +1,101 @@ +"""Keep native Windows updater tests unprivileged and separate from releases.""" + +import functools +import json +from pathlib import Path +import re +import subprocess +import unittest + + +ROOT = Path(__file__).resolve().parents[2] +WORKFLOW = "agent-windows-updater-tests.yml" + + +@functools.cache +def workflow(name=WORKFLOW): + return json.loads(subprocess.check_output( + ["yq", "-o=json", ".", str(ROOT / ".github/workflows" / name)], text=True, + )) + + +class WindowsUpdaterWorkflowTests(unittest.TestCase): + def test_native_tests_have_no_release_or_signing_authority(self): + config = workflow() + self.assertEqual(config["permissions"], {"contents": "read"}) + self.assertEqual(set(config["on"]), {"pull_request", "push", "workflow_dispatch"}) + self.assertEqual(config["on"]["push"]["branches"], ["master"]) + self.assertEqual(set(config["jobs"]), {"native-updater"}) + job = config["jobs"]["native-updater"] + self.assertEqual(job["runs-on"], "windows-2025") + self.assertNotIn("environment", job) + self.assertNotIn("secrets", job) + self.assertNotIn("permissions", job) + self.assertNotIn("strategy", job) + self.assertLessEqual(job["timeout-minutes"], 35) + self.assertNotIn("secrets.", json.dumps(config)) + self.assertNotIn("id-token", json.dumps(config)) + commands = "\n".join(step.get("run", "") for step in job["steps"]) + self.assertNotRegex(commands, r"(?:gh\s+(?:release|workflow)|wrangler|publisher\.py|windows-sign-file|artifact-sign|cargo\s+publish)") + + def test_native_toolchain_matches_existing_windows_pin_and_avoids_artifact_cache(self): + steps = workflow()["jobs"]["native-updater"]["steps"] + actions = [step["uses"] for step in steps if "uses" in step] + self.assertTrue(all(re.fullmatch(r"[^@]+@[0-9a-f]{40}", action) for action in actions)) + self.assertFalse(any("cache" in action or "download-artifact" in action for action in actions)) + checkout = next(step for step in steps if step.get("uses", "").startswith("actions/checkout@")) + self.assertIs(checkout["with"]["persist-credentials"], False) + rust = next(step for step in steps if step.get("uses", "").startswith("dtolnay/rust-toolchain@")) + existing = next(step for step in workflow("agent-desktop-build.yml")["jobs"]["windows-build"]["steps"] + if step.get("uses", "").startswith("dtolnay/rust-toolchain@")) + self.assertEqual(rust["uses"], existing["uses"]) + self.assertEqual(rust["with"]["toolchain"], existing["with"]["toolchain"]) + linker = next(step for step in steps if "agent_windows.py" in step.get("run", "")) + self.assertEqual(linker["working-directory"], "apps/maple-agent") + self.assertEqual(linker["shell"], "pwsh") + test = next(step for step in steps if "windows-updater-tests.ps1" in step.get("run", "")) + self.assertEqual(test["shell"], "pwsh") + self.assertNotIn("continue-on-error", test) + + def test_relevant_contract_changes_select_native_tests_without_research_packaging(self): + required = { + ".github/workflows/agent-windows-updater-tests.yml", + "scripts/ci/windows-updater-tests.ps1", + "scripts/ci/test_windows_updater_workflow.py", + "apps/maple-agent/crates/maple-agent-updater/**", + "apps/maple-agent/Cargo.toml", "apps/maple-agent/Cargo.lock", + "apps/maple-agent/app/Cargo.toml", + "apps/maple-agent/release_profile.rs", "apps/maple-agent/release-profiles.json", + "apps/maple-agent/app/src/main.rs", "apps/maple-agent/app/src/update.rs", + "apps/maple-agent/app/src/windows_bootstrap.rs", "apps/maple-agent/app/src/desktop.rs", + "apps/maple-agent/scripts/windows-release.py", "apps/maple-agent/scripts/windows-installer.nsi", + } + for event in ("push", "pull_request"): + paths = set(workflow()["on"][event]["paths"]) + self.assertTrue(required <= paths) + self.assertFalse(any(path.startswith(("apps/maple-research/", "services/opensecret/")) for path in paths)) + + def test_failures_retain_evidence_and_do_not_claim_signed_lifecycle_qualification(self): + upload = next(step for step in workflow()["jobs"]["native-updater"]["steps"] + if step.get("uses", "").startswith("actions/upload-artifact@")) + self.assertEqual(upload["if"], "always()") + self.assertEqual(upload["with"]["if-no-files-found"], "error") + self.assertIn("github.run_attempt", upload["with"]["name"]) + script = (ROOT / "scripts/ci/windows-updater-tests.ps1").read_text() + self.assertIn("[System.PlatformID]::Win32NT", script) + self.assertIn("'--locked'", script) + self.assertIn("'--test', 'windows_bootstrap_native'", script) + self.assertIn("'--test-threads=1'", script) + self.assertIn("Get-AuthenticodeSignature -LiteralPath $unrelated", script) + self.assertIn("A required native Windows test group is missing", script) + self.assertIn("without skipped tests", script) + for field in ( + "official_maple_signature_qualified", "signed_installed_a_to_b_qualified", + "visible_gui_upgrade_qualified", "paired_remote_upgrade_qualified", + ): + self.assertRegex(script, rf"{field}\s*=\s*\$false") + self.assertNotRegex(script, rf"{field}\s*=\s*\$true") + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/ci/windows-updater-tests.ps1 b/scripts/ci/windows-updater-tests.ps1 new file mode 100644 index 000000000..440366483 --- /dev/null +++ b/scripts/ci/windows-updater-tests.ps1 @@ -0,0 +1,180 @@ +param() + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version 3.0 +$PSNativeCommandUseErrorActionPreference = $false + +if ([System.Environment]::OSVersion.Platform -ne [System.PlatformID]::Win32NT) { + throw 'Native Windows updater tests require Windows.' +} +if (-not $env:RUNNER_TEMP -or $env:GITHUB_SHA -notmatch '^[0-9a-f]{40}$' -or + -not $env:GITHUB_RUN_ID -or -not $env:GITHUB_RUN_ATTEMPT -or -not $env:GITHUB_REPOSITORY) { + throw 'The isolated Actions test context is missing.' +} +# These tests execute untrusted fixture data. Signing and publication credentials +# belong only to the separate protected producer/publisher jobs. +foreach ($name in @( + 'AZURE_CLIENT_ID', 'AZURE_TENANT_ID', 'AZURE_SUBSCRIPTION_ID', + 'CLOUDFLARE_API_TOKEN', 'BWS_ACCESS_TOKEN', 'AGENT_UPDATES_BWS_ACCESS_TOKEN', + 'AGENT_UPDATE_SIGNING_KEY', 'AGENT_R2_ACCESS_KEY_ID', 'AGENT_R2_SECRET_ACCESS_KEY' +)) { + if (-not [string]::IsNullOrEmpty([System.Environment]::GetEnvironmentVariable($name))) { + throw 'A signing or publication credential is present in native tests.' + } +} +foreach ($entry in Get-ChildItem Env:) { + if ($entry.Name.StartsWith('MAPLE_WINDOWS_ARTIFACT_SIGNING_')) { + throw 'Artifact signing configuration is present in native tests.' + } +} + +$repository = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../..')) +$component = Join-Path $repository 'apps/maple-agent' +$reportDirectory = Join-Path $env:RUNNER_TEMP 'maple-windows-updater-tests' +New-Item -ItemType Directory -Path $reportDirectory -Force | Out-Null +$receiptPath = Join-Path $reportDirectory 'receipt.json' +$receipt = [ordered]@{ + schema = 'maple-windows-updater-native-tests-v1' + source = $env:GITHUB_SHA + repository = $env:GITHUB_REPOSITORY + run_id = $env:GITHUB_RUN_ID + run_attempt = $env:GITHUB_RUN_ATTEMPT + started_at = [DateTimeOffset]::UtcNow.ToString('o') + result = 'running' + platform = 'x86_64-pc-windows-msvc' + native_component_tests = $false + official_maple_signature_qualified = $false + signed_installed_a_to_b_qualified = $false + visible_gui_upgrade_qualified = $false + paired_remote_upgrade_qualified = $false +} + +function Save-Receipt { + $receipt | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $receiptPath -Encoding utf8 +} + +function Invoke-CargoStep { + param([string]$LogName, [string[]]$CargoArguments) + $path = Join-Path $reportDirectory $LogName + & cargo @CargoArguments *> $path + $exitCode = $LASTEXITCODE + if ($exitCode -ne 0) { + Get-Content -LiteralPath $path -Tail 150 + throw "Native updater Cargo step failed with exit code $exitCode." + } + return $path +} + +Save-Receipt +try { + $identity = (& rustc -vV) -join "`n" + if ($LASTEXITCODE -ne 0 -or + $identity -notmatch '(?m)^release: 1\.98\.0$' -or + $identity -notmatch '(?m)^host: x86_64-pc-windows-msvc$') { + throw 'The Windows Rust toolchain differs from the repository pin.' + } + $receipt.rust_release = '1.98.0' + + # Use the OS's signed PowerShell executable as an unrelated-publisher fixture. + # It is never copied over or modified, and is not a downloaded test executable. + $unrelated = Join-Path $env:SystemRoot 'System32/WindowsPowerShell/v1.0/powershell.exe' + $signature = Get-AuthenticodeSignature -LiteralPath $unrelated + if ($signature.Status -ne 'Valid' -or $null -eq $signature.SignerCertificate -or + $signature.SignerCertificate.Subject -notmatch '(^|,\s*)CN=Microsoft' -or + $signature.SignerCertificate.Subject -match '(^|,\s*)CN=Mutiny Wallet Inc\. dba OpenSecret(,|$)') { + throw 'The unrelated signed Windows fixture is unavailable or has another identity.' + } + $receipt.unrelated_os_signature_valid = $true + + Push-Location $component + try { + $listPath = Invoke-CargoStep 'test-list.log' @( + 'test', '-p', 'maple-agent-updater', '--locked', '--lib', + '--test', 'windows_bootstrap_native', '--', '--list', '--format', 'terse' + ) + $testNames = @(Get-Content -LiteralPath $listPath | ForEach-Object { + if ($_ -match '^([A-Za-z0-9_:]+): test$') { $Matches[1] } + }) + # Requiring native cases prevents an accidental cfg/filter change from + # turning this Windows job into an empty or host-independent-only success. + $requiredPatterns = @( + 'installer::windows_bundle::authenticode::tests::native_verification_rejects_missing_malformed_and_nonfiles', + 'installer::windows_bundle::authenticode::tests::native_unsigned_test_executable_requires_the_explicit_dev_exception', + 'installer::windows_bundle::authenticode::tests::native_microsoft_publisher_is_rejected_even_for_unsigned_dev', + 'installer::windows_bundle::authenticode::tests::signer_requires_the_exact_compiled_publisher', + 'installer::windows_bundle::authenticode::tests::pe_certificate_directory_must_be_completely_absent', + 'installer::windows_bundle::authenticode::tests::purpose_provider_flags_pin_startup_offline_and_admission_revocation', + 'installer::windows_bundle::authenticode::tests::native_trust_configuration_keeps_startup_and_admission_separate', + 'installer::windows_bundle::windows_fs::tests::refuses_nonlocal_and_ambiguous_windows_names', + 'installer::windows_bundle::windows_fs::tests::private_storage_round_trip_and_atomic_replacement', + 'installer::windows_bundle::windows_fs::tests::rejects_hard_linked_state_and_preserves_oversized_file', + 'installer::windows_bundle::windows_fs::tests::rejects_existing_foreign_write_acl_before_truncation', + 'installer::windows_bundle::windows_fs::tests::rejects_file_and_ancestor_reparse_points_when_symlink_privilege_available', + 'installer::windows_bundle::windows_fs::tests::native_mutation_guard_blocks_loader_until_released', + 'installer::windows_bundle::windows_fs::tests::native_shared_read_validation_accepts_mapped_image_and_mutation_guard', + 'installer::windows_bundle::tests::exact_archive_round_trip_binds_all_nine_files_and_three_directories', + 'installer::windows_bundle::tests::rejected_archive_never_begins_extraction', + 'installer::windows_bundle::tests::descriptors_bind_profile_product_release_and_strict_json_fields', + 'installer::windows_bundle::tests::descriptor_comparison_rejects_changed_release_version_build_or_source', + 'installer::windows_bundle::tests::current_pointer_cannot_name_a_path_or_ambiguous_generation', + 'installer::windows_bundle::tests::pe_metadata_parser_reads_x64_maple_section_without_running_code', + 'installer::windows_bundle::tests::pe_metadata_parser_rejects_header_architecture_and_bounds_changes', + 'installer::windows_bundle::tests::pe_metadata_parser_rejects_duplicate_writeable_executable_or_overlapping_sections', + 'installer::windows_bundle::transaction_tests::native_transaction_commits_pointer_and_preserves_old_generation', + 'installer::windows_bundle::transaction_tests::native_transaction_interruption_before_pointer_keeps_previous', + 'installer::windows_bundle::transaction_tests::native_transaction_pointer_lock_failure_is_retryable', + 'installer::windows_bundle::transaction_tests::native_install_lock_rejects_concurrent_writers', + 'installer::windows_bundle::transaction_tests::native_pending_manual_intent_preserves_current_generation', + 'installer::windows_bundle::transaction_tests::native_completed_manual_intent_recovers_after_pointer_commit', + 'installer::windows_bundle::transaction_tests::native_live_matching_manual_intent_is_never_cleared', + 'installer::windows_bundle::transaction_tests::native_uninstall_owner_fences_until_nsis_finishes', + 'installer::windows_bundle::transaction_tests::native_partial_generation_removal_preserves_unknown_files', + 'installer::windows_bundle::transaction_tests::native_retained_guard_removes_owned_generation_without_partial_pointer_change', + 'installer::windows_bundle::transaction_tests::native_dead_uninstall_journal_allows_only_owned_partial_repair', + 'tests::downloads_and_reauthenticates_preparation_across_restart', + 'tests::rollback_clears_offer_without_lowering_persisted_high_watermark', + 'tests::resumes_only_exact_valid_ranges_and_final_digest', + 'tests::interrupted_transfer_preserves_a_resumable_verified_prefix', + 'tests::platform_download_restore_revalidate_and_commit_use_their_exact_feed', + 'tests::wrong_target_feed_cannot_advance_rollback_memory_or_start_a_download', + 'tests::commit_guard_excludes_competing_advancement_without_blocking', + 'tests::newer_process_verification_between_ready_and_commit_blocks_old_installation', + 'tests::successful_commit_result_survives_loss_of_state_record_after_replacement', + 'tests::commit_guard_retains_windows_state_lock_against_deletion', + 'native_reparse_fixtures_are_available', + 'windows_bootstrap::tests::windows_restart_identity_is_canonical_and_bounded', + 'windows_bootstrap::tests::windows_current_process_has_stable_creation_identity', + 'windows_bootstrap::tests::windows_bootstrap_only_reserves_exact_internal_entrypoints', + 'windows_bootstrap::tests::windows_forward_preserves_native_argv_and_exit_status' + ) + foreach ($pattern in $requiredPatterns) { + if (@($testNames | Where-Object { $_ -match "^$pattern$" }).Count -eq 0) { + throw 'A required native Windows test group is missing.' + } + } + $receipt.discovered_tests = $testNames.Count + $runPath = Invoke-CargoStep 'native-tests.log' @( + 'test', '-p', 'maple-agent-updater', '--locked', '--lib', + '--test', 'windows_bootstrap_native', '--', '--test-threads=1' + ) + $summaries = @(Get-Content -LiteralPath $runPath | Where-Object { $_ -match '^test result:' }) + if ($summaries.Count -ne 2 -or @($summaries | Where-Object { + $_ -notmatch '^test result: ok\. [1-9][0-9]* passed; 0 failed; 0 ignored;' + }).Count -ne 0) { + throw 'Both native test binaries must execute nonempty suites without skipped tests.' + } + $receipt.test_summaries = $summaries + $receipt.native_component_tests = $true + $receipt.result = 'pass' + $receipt.completed_at = [DateTimeOffset]::UtcNow.ToString('o') + Save-Receipt + Get-Content -LiteralPath $runPath -Tail 12 + } finally { + Pop-Location + } +} catch { + $receipt.result = 'fail' + $receipt.completed_at = [DateTimeOffset]::UtcNow.ToString('o') + Save-Receipt + throw +} diff --git a/scripts/ci/windows_signature_reference.py b/scripts/ci/windows_signature_reference.py new file mode 100644 index 000000000..aad3cfb8a --- /dev/null +++ b/scripts/ci/windows_signature_reference.py @@ -0,0 +1,170 @@ +"""Select one immutable historical signed Windows PE for a read-only probe.""" + +import hashlib +import io +import json +import os +from pathlib import Path, PurePosixPath +import re +import stat +import subprocess +import threading +import zipfile +from datetime import datetime + + +REPOSITORY = "MaplePrivacyLabs/Maple" +PRODUCER = ".github/workflows/agent-desktop-build.yml" +JOBS = ("Build Windows binary (prod)", "Signed Windows (prod)", + "Verify downloaded Windows (prod)") +MAX_ZIP = 64 * 1024 * 1024 + + +def selection(environment): + values = {} + for name in ("RUN", "ATTEMPT", "ARTIFACT", "ZIP_BYTES"): + value = environment.get("MAPLE_PROBE_" + name, "") + if not re.fullmatch(r"[1-9][0-9]{0,18}", value): + raise ValueError("Invalid positive numeric selector: " + name) + values[name] = int(value) + if values["ZIP_BYTES"] > MAX_ZIP: + raise ValueError("Reference ZIP exceeds the fixed 64 MiB bound") + for name, length in (("SOURCE", 40), ("ZIP_SHA", 64), ("PE_SHA", 64)): + value = environment.get("MAPLE_PROBE_" + name, "") + if not re.fullmatch(r"[0-9a-f]{" + str(length) + "}", value): + raise ValueError("Invalid exact source/digest selector: " + name) + values[name] = value + member = environment.get("MAPLE_PROBE_PE_MEMBER", "") + if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,159}\.exe", member): + raise ValueError("Reference PE must be one portable basename") + values["PE_MEMBER"] = member + return values + + +def gh_bytes(path, limit, timeout=45): + environment = dict(os.environ) + environment.pop("GH_DEBUG", None) + process = subprocess.Popen( + ["gh", "api", "--hostname", "github.com", "repos/" + REPOSITORY + path], + stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, env=environment, + ) + timer = threading.Timer(timeout, process.kill) + timer.daemon = True + timer.start() + result = bytearray() + try: + while True: + chunk = process.stdout.read(65536) + if not chunk: + break + if len(result) + len(chunk) > limit: + raise ValueError("GitHub response exceeded its fixed bound") + result.extend(chunk) + if process.wait(timeout=10) != 0: + raise RuntimeError("Bounded GitHub reference request failed") + return bytes(result) + finally: + timer.cancel() + if process.poll() is None: + process.kill() + process.wait(timeout=10) + process.stdout.close() + + +def instant(value): + return datetime.fromisoformat(value.replace("Z", "+00:00")) + + +def validate_producer(selected, run, artifact, jobs): + if (run["id"] != selected["RUN"] or run["run_attempt"] != selected["ATTEMPT"] + or run["head_sha"] != selected["SOURCE"] or run["head_branch"] != "master" + or run["event"] not in ("push", "workflow_dispatch") or run["path"] != PRODUCER + or run["repository"]["full_name"] != REPOSITORY + or run["head_repository"]["full_name"] != REPOSITORY + or run["status"] != "completed" or run["conclusion"] != "success"): + raise ValueError("Reference is not the selected successful trusted master producer") + if (artifact["id"] != selected["ARTIFACT"] or artifact["expired"] + or artifact["name"] != "maple-agent-prod-windows-x86_64-" + str(selected["RUN"]) + or artifact["size_in_bytes"] != selected["ZIP_BYTES"] + or artifact["digest"] != "sha256:" + selected["ZIP_SHA"] + or artifact["workflow_run"]["id"] != selected["RUN"] + or artifact["workflow_run"]["head_sha"] != selected["SOURCE"] + or artifact["workflow_run"]["repository_id"] != run["repository"]["id"] + or artifact["workflow_run"]["head_repository_id"] != run["repository"]["id"]): + raise ValueError("Reference artifact identity changed") + if jobs["total_count"] > 100: + raise ValueError("Reference jobs exceed the bounded one-page inventory") + chosen = [] + for name in JOBS: + matches = [job for job in jobs["jobs"] if job["name"] == name] + if (len(matches) != 1 or matches[0]["status"] != "completed" + or matches[0]["conclusion"] != "success" + or "windows-2025" not in matches[0]["labels"]): + raise ValueError("Exact native Prod build/sign/download job did not pass") + chosen.append(matches[0]) + signing = chosen[1] + if not (instant(signing["started_at"]) <= instant(artifact["created_at"]) + <= instant(signing["completed_at"])): + raise ValueError("Artifact does not belong to the selected signing attempt") + return [{key: job[key] for key in ("id", "name", "conclusion")} for job in chosen] + + +def reference_pe(selected, archive): + if len(archive) != selected["ZIP_BYTES"] or hashlib.sha256(archive).hexdigest() != selected["ZIP_SHA"]: + raise ValueError("Reference ZIP differs from its exact size/digest") + with zipfile.ZipFile(io.BytesIO(archive)) as payload: + members = payload.infolist() + if len(members) > 32 or sum(item.file_size for item in members) > 256 * 1024 * 1024: + raise ValueError("Reference inventory exceeds fixed bounds") + names = set() + for item in members: + path = PurePosixPath(item.filename) + if (path.is_absolute() or ".." in path.parts or "\\" in item.filename + or ":" in item.filename or item.filename.lower() in names + or stat.S_ISLNK(item.external_attr >> 16) or item.flag_bits & 1 + or item.compress_type not in (zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED)): + raise ValueError("Unsafe reference ZIP inventory") + names.add(item.filename.lower()) + for name in ("build-info.json", "manifest.json"): + if payload.getinfo(name).file_size > 65536: + raise ValueError("Reference metadata exceeds its bound") + build = json.loads(payload.read("build-info.json")) + manifest = json.loads(payload.read("manifest.json")) + if (build["source_sha"] != selected["SOURCE"] or build["profile"] != "prod" + or build["bundle_id"] != "cloud.opensecret.maple.agent" + or manifest["source_sha"] != selected["SOURCE"] or manifest["profile"] != "prod" + or manifest["platform"] != "windows-x86_64" or manifest["distribution"] != "master" + or manifest["windows_signing"] != "authenticode" + or manifest["version"] != build["version"] or manifest["package"] != selected["PE_MEMBER"]): + raise ValueError("Reference package metadata differs from its selected producer") + if payload.getinfo(selected["PE_MEMBER"]).file_size > MAX_ZIP: + raise ValueError("Reference PE exceeds its fixed bound") + executable = payload.read(selected["PE_MEMBER"]) + if executable[:2] != b"MZ" or hashlib.sha256(executable).hexdigest() != selected["PE_SHA"]: + raise ValueError("Reference PE differs from its exact digest") + return executable + + +def main(): + selected = selection(os.environ) + report = Path(os.environ["RUNNER_TEMP"]) / "maple-windows-signature-probe" + report.mkdir(exist_ok=True) + run = json.loads(gh_bytes("/actions/runs/{}/attempts/{}".format(selected["RUN"], selected["ATTEMPT"]), 1024 * 1024)) + artifact = json.loads(gh_bytes("/actions/artifacts/" + str(selected["ARTIFACT"]), 1024 * 1024)) + jobs = json.loads(gh_bytes("/actions/runs/{}/attempts/{}/jobs?per_page=100".format(selected["RUN"], selected["ATTEMPT"]), 1024 * 1024)) + chosen = validate_producer(selected, run, artifact, jobs) + archive = gh_bytes("/actions/artifacts/{}/zip".format(selected["ARTIFACT"]), selected["ZIP_BYTES"], 120) + executable = reference_pe(selected, archive) + (report / "reference.exe").write_bytes(executable) + (report / "selection.json").write_text(json.dumps({ + "schema": "maple-windows-historical-signature-selection-v1", + "probe_source": os.environ["GITHUB_SHA"], "probe_run": os.environ["GITHUB_RUN_ID"], + "probe_attempt": os.environ["GITHUB_RUN_ATTEMPT"], "repository": REPOSITORY, + "selection": selected, "producer_jobs": chosen, + "new_updater_lifecycle_qualified": False, "final_source_release_qualified": False, + }, indent=2) + "\n") + print("Exact historical signed PE downloaded and hash-verified; it was not executed.") + + +if __name__ == "__main__": + main() diff --git a/services/agent-updates/README.md b/services/agent-updates/README.md index b499ab43c..a9e38c728 100644 --- a/services/agent-updates/README.md +++ b/services/agent-updates/README.md @@ -34,16 +34,19 @@ successful packaged update. Only GET and HEAD are supported. Metadata and errors use `no-store` at both browser and CDN layers. No origin fetch is made on failure. -| Path | Behavior | -| ---------------------------------------------- | -------------------------------------------------------------------------------- | -| `/agent/stable/latest.json` | Exact v1 signed envelope from `stable/latest.json` | -| `/agent/stable/linux/latest.json` | Exact Linux v2 signed envelope from `stable/linux/latest.json` | -| `/agent/stable/releases/V/manifest.json` | Immutable v1 signed envelope | -| `/agent/stable/releases/V/linux-manifest.json` | Immutable Linux v2 signed envelope | -| `/agent/stable/releases/V/FILE` | Signed, enumerated artifact; GET/HEAD, single byte ranges, ETag, immutable cache | -| `/download/agent/stable/macos` | Uncached 302 to the current DMG | -| `/download/agent/stable/linux-appimage` | Uncached 302 only when Linux is present | -| `/download/agent/stable/linux` | Uncached 302 to the current v2 Linux tar bundle | +| Path | Behavior | +| ------------------------------------------------ | -------------------------------------------------------------------------------- | +| `/agent/stable/latest.json` | Exact v1 signed envelope from `stable/latest.json` | +| `/agent/stable/linux/latest.json` | Exact Linux v2 signed envelope from `stable/linux/latest.json` | +| `/agent/stable/windows/latest.json` | Exact Windows v3 signed envelope from `stable/windows/latest.json` | +| `/agent/stable/releases/V/manifest.json` | Immutable v1 signed envelope | +| `/agent/stable/releases/V/linux-manifest.json` | Immutable Linux v2 signed envelope | +| `/agent/stable/releases/V/windows-manifest.json` | Immutable Windows v3 signed envelope | +| `/agent/stable/releases/V/FILE` | Signed, enumerated artifact; GET/HEAD, single byte ranges, ETag, immutable cache | +| `/download/agent/stable/macos` | Uncached 302 to the current DMG | +| `/download/agent/stable/linux-appimage` | Uncached 302 only when Linux is present | +| `/download/agent/stable/linux` | Uncached 302 to the current v2 Linux tar bundle | +| `/download/agent/stable/windows` | Uncached 302 to the current v3 Windows installer | Missing objects/targets return 404. Malformed metadata, mismatched stored sizes and bucket failures return bounded 503 responses. The Worker checks schema and @@ -72,13 +75,23 @@ The original latest feed and immutable `manifest.json` require v1; Linux latest and `linux-manifest.json` require v2. The historical v1 schema and AppImage alias remain supported without reinterpreting stored signed bytes. +The separate Windows feed uses `format: maple-agent-update-v3` with the same +top-level fields and limits. Its `artifacts` contains only `windows-x86_64`, a +`.tar.gz` updater bundle; its `downloads` contains only `windows`, a distinct +`.exe` installer. Both files are bound to their own signed path, size and hash. +Windows latest and immutable `windows-manifest.json` require v3. Windows fields +are rejected by the historical v1/v2 schemas, including null fields, and a v3 +document cannot contain macOS, Linux or AppImage entries. + Artifact lookup reads the requested version's fixed `manifest.json` first and serves an exact enumerated match without consulting Linux metadata. A missing or valid nonmatching v1 manifest permits an exact match from that version's -`linux-manifest.json`. Every consulted manifest must have its required format +`linux-manifest.json`, then `windows-manifest.json` when neither earlier +manifest enumerates the path. An earlier exact match returns without consulting +later platform manifests. Every consulted manifest must have its required format and requested version; corrupt metadata returns 503 without fallback. No latest pointer authorizes a release artifact, and no unsigned auxiliary object is -exposed by either feed. +exposed by any feed. Limits: 128 KiB envelope, 64 KiB manifest, 4 KiB minisign signature, 16 KiB notes. Signature format is prehashed minisign Ed25519 with exact trusted comment diff --git a/services/agent-updates/migrations/0002_windows_download_events.sql b/services/agent-updates/migrations/0002_windows_download_events.sql new file mode 100644 index 000000000..9e71d5d94 --- /dev/null +++ b/services/agent-updates/migrations/0002_windows_download_events.sql @@ -0,0 +1,33 @@ +-- D1's migration runner/batch owns the transaction. Explicit BEGIN/COMMIT are +-- intentionally absent. Execute the whole file atomically, never statementwise. +-- Keep 0001 unchanged: production's original schema predates migration history. +CREATE TABLE download_events_windows ( + event_id TEXT PRIMARY KEY NOT NULL, + observed_at TEXT NOT NULL, + product TEXT NOT NULL CHECK (product IN ('agent', 'research')), + host TEXT NOT NULL CHECK (host IN ('updates.maple.ai', 'updates.trymaple.ai')), + version TEXT, + platform TEXT NOT NULL CHECK (platform IN ('macos', 'windows', 'linux', 'android', 'all')), + artifact TEXT NOT NULL CHECK (artifact IN ('macos-dmg', 'macos-updater', 'windows-installer', 'windows-bundle', 'linux-bundle', 'linux-appimage', 'linux-deb', 'linux-rpm', 'android-apk', 'latest', 'manifest')), + kind TEXT NOT NULL CHECK (kind IN ('artifact', 'alias', 'metadata')), + method TEXT NOT NULL CHECK (method IN ('GET', 'HEAD')), + status INTEGER NOT NULL CHECK (status BETWEEN 200 AND 599), + range TEXT NOT NULL CHECK (range IN ('none', 'requested', 'partial', 'unsatisfiable')), + result TEXT NOT NULL CHECK (result IN ('success', 'redirect', 'not_modified', 'client_error', 'server_error')), + response_length INTEGER CHECK (response_length BETWEEN 0 AND 4294967296), + client_kind TEXT NOT NULL CHECK (client_kind IN ('browser', 'maple', 'cli', 'other')), + is_probe INTEGER NOT NULL CHECK (is_probe IN (0, 1)) +); + +INSERT INTO download_events_windows + (event_id, observed_at, product, host, version, platform, artifact, kind, + method, status, range, result, response_length, client_kind, is_probe) +SELECT event_id, observed_at, product, host, version, platform, artifact, kind, + method, status, range, result, response_length, client_kind, is_probe +FROM download_events; + +DROP TABLE download_events; +ALTER TABLE download_events_windows RENAME TO download_events; + +CREATE INDEX download_events_observed_at ON download_events(observed_at); +CREATE INDEX download_events_trends ON download_events(product, kind, observed_at); diff --git a/services/agent-updates/publisher/README.md b/services/agent-updates/publisher/README.md index 19b300ed0..98405f7fe 100644 --- a/services/agent-updates/publisher/README.md +++ b/services/agent-updates/publisher/README.md @@ -17,10 +17,15 @@ are required; the exact item-ID inputs are listed in the [service setup](../READ The public key must already be pinned both in the selected build's source profile and in the publisher's trusted source profile. An empty key list blocks publication. -Normal publication defaults to both platforms at the same version, build and +Normal publication defaults to macOS and Linux at the same version, build and source. Disable Linux only explicitly for a historical macOS-only run. Initial unadvertised Linux publications enable the real N-to-N+1 rehearsal; passing that rehearsal remains a gate before invitations. +Windows is opt-in with `include_windows: true`; it remains disabled by default. +The workflow also requires `AGENT_WINDOWS_UPDATES_ENABLED=true` before a Windows +publication can proceed. Enable that owner-controlled activation only after the +native signed-package, install, update, failure-recovery and relaunch qualification. +Implementation and offline tests alone do not qualify that activation. ```sh publisher=services/agent-updates/publisher/publisher.py @@ -38,6 +43,10 @@ python3 "$publisher" publish --bundle bundle --public-key bundle/public-key.pub --base-url https://updates.maple.ai ``` +For an activated Windows release, also pass `--windows` to `trust` and +`--windows windows-artifacts` to `prepare`. All included packages must come from +that same selected producer run, with the same source, version and build. + `trust` returns the source SHA, selected artifact names, exact run attempt, publication date and build number as JSON. Only completed trusted master runs of `agent-desktop-build.yml` qualify. The selected Prod signing/package and downloaded @@ -54,6 +63,17 @@ checksums are validated before copying the binary payloads into the bundle. The macOS archive's bounded regular `Info.plist` member is read without extraction to match the selected run's build number, version and application identity. Sparse archives are rejected; extension headers and cumulative metadata are bounded. +Windows additionally requires the concrete successful `Build Windows binary (prod)`, +`Signed Windows (prod)` and `Verify downloaded Windows (prod)` jobs. Its exact +five-file final artifact contains the signed NSIS setup `.exe`, generation +`.tar.gz`, `build-info.json`, `manifest.json` and `SHA256SUMS`. Every file's +attestation is checked. The bounded Windows archive scan requires the fixed +ordinary-file/directory layout and exact descriptor, including source, build and +application identity; embedded build-info bytes must match the attested sidecar. +Links, extensions, auxiliary entries, malformed end markers and trailing content +are rejected without extracting or executing downloaded files. The generation +archive contains the signed application executable and public notices; the +stable launcher and NSIS uninstaller remain outside each generation. The manifest build number is the selected build run's `run_number`, matching the packaging workflow's `CFBundleVersion` input and Linux `bundle.json` descriptor. @@ -76,6 +96,15 @@ same primary key signs both exact documents as `envelope.json` and Linux feed in both the selected source profile and trusted publisher profile; Mac-only producers do not need that field. +Windows uses its own strict `maple-agent-update-v3` document in +`windows-manifest.json`. Its sole `windows-x86_64` artifact is the generation +`.tar.gz`; its sole `windows` download is the signed setup `.exe`. These are +different payloads. With `--windows`, preparation writes that document and its +`windows-envelope.json` signed with the same primary key. The canonical compiled +Windows feed must be pinned in both selected-source and trusted-publisher +profiles. Version, build, source, date and notes must agree with all included +documents. macOS v1 and Linux v2 retain their existing wire shapes. + The canonical compiled feeds use `https://updates.maple.ai`; both source and trusted-publisher profiles must pin those exact URLs. Packages built before the hostname change keep checking `updates.trymaple.ai`, so retain delivery there. @@ -91,9 +120,11 @@ size inventory of the private audit sidecars. Both digests are bound by the reservation receipt. When Linux is included, it separately reserves `publication-linux-manifest.json` and binds `linux_manifest_sha256` in that same receipt. Linux payload hashes are inside the exact reserved Linux document; its -sidecars are inside the reserved metadata inventory. All selected bytes are +sidecars are inside the reserved metadata inventory. Windows equivalently reserves +`publication-windows-manifest.json` and binds `windows_manifest_sha256` in the +receipt. All selected bytes are reserved before signing. It recovers each existing complete signed manifest into -the bundle, preserving both envelopes on a retry. `sign` requires the reservation +the bundle, preserving every envelope on a retry. `sign` requires the reservation receipt, signs the exact bytes with prehashed minisign and the exact trusted comment `product=maple-agent channel=stable version=V`, then verifies the signature. @@ -125,6 +156,17 @@ the retry; nothing restores or rolls back a pointer automatically. Mac-only publication never reads or changes the Linux pointer. The existing four stages and three protected environment approvals remain unchanged. +Windows uses immutable `stable/releases/V/windows-manifest.json` and independent +conditional pointer `stable/windows/latest.json`. The public feed is +`/agent/stable/windows/latest.json`; `/download/agent/stable/windows` redirects to +its signed setup executable. Both Windows payloads are immutable and enumerated +in that signed document. All selected feed pointers are authenticated and +snapshotted before uploads, and each keeps its own opaque ETag for conditional +promotion. An interrupted multi-feed promotion can resume only from the same +reserved and signed bytes. A concurrent Windows pointer change blocks that +promotion and remains untouched. Publications without Windows never read or +change its pointer. + The bundle contains raw, provenance-checked build-info, package manifest, SHA256SUMS and macOS notarization logs under `metadata/`, prefixed by platform, plus the stable run/source/artifact identity and attestation policy in @@ -133,7 +175,18 @@ it is omitted from durable metadata so a successful verifier-only rerun can reus the exact release. A finite filename allowlist and a 4 MiB limit per file bound this audit inventory. Reservation, signing and publication require exactly the five macOS sidecars and provenance, plus the three Linux sidecars when the -separate Linux document is included; a missing, empty, partial or extra inventory is rejected. These +separate Linux document is included and three Windows sidecars when its document +is included; a missing, empty, partial or extra inventory is rejected. The exact +bundle file inventories, including payloads, metadata, manifests and key, are: + +| Included platforms | Prepared | Reserved | Signed | +| --- | ---: | ---: | ---: | +| macOS | 12 | 13 | 14 | +| macOS + Linux | 17 | 18 | 20 | +| macOS + Windows | 18 | 19 | 21 | +| macOS + Linux + Windows | 23 | 24 | 27 | + +These objects are stored immutably under `stable/releases/V/metadata/` and verified before the complete manifest appears. They remain private because the Worker serves only artifacts enumerated in the @@ -150,7 +203,8 @@ Publication finishes by checking the live latest and immutable signed manifest, signature, full artifact hashes, HEAD lengths, one-byte ranges, and installer redirect destinations. All public requests use the native client's `Maple-Agent-Updater/1` identity and `Accept-Encoding: identity`, including full -payload downloads. Repeat that separately with `verify-live --bundle bundle +payload downloads. They also send `X-Maple-Download-Probe: 1` so publication +verification does not inflate private download analytics. Repeat that separately with `verify-live --bundle bundle --public-key bundle/public-key.pub --base-url https://updates.maple.ai` after a transport failure or operational change. If this final check fails after promotion, inspect the live state and retry the identical bundle; publication may already diff --git a/services/agent-updates/publisher/publisher.py b/services/agent-updates/publisher/publisher.py index 2fd2e16fe..60a48dea8 100644 --- a/services/agent-updates/publisher/publisher.py +++ b/services/agent-updates/publisher/publisher.py @@ -11,6 +11,7 @@ import base64 from contextlib import contextmanager from datetime import datetime, timezone +import gzip import hashlib import hmac import importlib.util @@ -29,6 +30,7 @@ import urllib.parse import urllib.request from xml.parsers.expat import ExpatError +import zlib REPOSITORY = "MaplePrivacyLabs/Maple" @@ -47,9 +49,11 @@ ROOT = Path(__file__).resolve().parents[3] FEED_URL = "https://updates.maple.ai/agent/stable/latest.json" LINUX_FEED_URL = "https://updates.maple.ai/agent/stable/linux/latest.json" +WINDOWS_FEED_URL = "https://updates.maple.ai/agent/stable/windows/latest.json" MACOS_METADATA_NAMES = {"macos-" + name for name in ("build-info.json", "manifest.json", "SHA256SUMS", "notarization-log.json", "dmg-notarization-log.json")} LINUX_METADATA_NAMES = {"linux-" + name for name in ("build-info.json", "manifest.json", "SHA256SUMS")} -METADATA_NAMES = {"provenance.json"} | MACOS_METADATA_NAMES | LINUX_METADATA_NAMES +WINDOWS_METADATA_NAMES = {"windows-" + name for name in ("build-info.json", "manifest.json", "SHA256SUMS")} +METADATA_NAMES = {"provenance.json"} | MACOS_METADATA_NAMES | LINUX_METADATA_NAMES | WINDOWS_METADATA_NAMES class PublishError(ValueError): @@ -154,7 +158,7 @@ def validate_manifest(data): manifest = decode_json(data, MAX_MANIFEST) fields = {"format", "product", "channel", "version", "build", "source_sha", "pub_date", "notes", "artifacts", "downloads"} need(isinstance(manifest, dict) and set(manifest) == fields, "invalid manifest fields") - need(manifest["format"] in {"maple-agent-update-v1", "maple-agent-update-v2"} and manifest["product"] == "maple-agent" and manifest["channel"] == "stable", "wrong update identity") + need(manifest["format"] in {"maple-agent-update-v1", "maple-agent-update-v2", "maple-agent-update-v3"} and manifest["product"] == "maple-agent" and manifest["channel"] == "stable", "wrong update identity") version_tuple(manifest["version"]) build = manifest["build"] need(isinstance(build, str) and re.fullmatch(r"[1-9][0-9]{0,17}", build), "invalid build number") @@ -170,7 +174,11 @@ def validate_manifest(data): need(all(ord(char) >= 32 or char in "\n\t" for char in notes) and "\x7f" not in notes, "notes contain ASCII controls") artifacts, downloads = manifest["artifacts"], manifest["downloads"] linux_v2 = manifest["format"] == "maple-agent-update-v2" - if linux_v2: + windows_v3 = manifest["format"] == "maple-agent-update-v3" + if windows_v3: + need(isinstance(artifacts, dict) and set(artifacts) == {"windows-x86_64"}, "invalid Windows v3 update targets") + need(isinstance(downloads, dict) and set(downloads) == {"windows"}, "invalid Windows v3 downloads") + elif linux_v2: need(isinstance(artifacts, dict) and set(artifacts) == {"linux-x86_64"}, "invalid Linux v2 update targets") need(isinstance(downloads, dict) and set(downloads) == {"linux-bundle"}, "invalid Linux v2 downloads") else: @@ -186,7 +194,7 @@ def validate_manifest(data): need(path.startswith(prefix), "artifact path has wrong release root") filename = path[len(prefix):] need(public_path(manifest["version"], filename) == path, "invalid artifact path") - suffix = ".tar.gz" if linux_v2 else ".app.tar.gz" if target == "darwin-aarch64" else ".dmg" if target == "macos" else ".AppImage" + suffix = (".tar.gz" if kind == "artifacts" else ".exe") if windows_v3 else ".tar.gz" if linux_v2 else ".app.tar.gz" if target == "darwin-aarch64" else ".dmg" if target == "macos" else ".AppImage" need(filename.endswith(suffix), "wrong artifact suffix") need(type(entry["size"]) is int and 0 < entry["size"] <= MAX_ARTIFACT, "invalid artifact size") need(isinstance(entry["sha256"], str) and DIGEST.fullmatch(entry["sha256"]), "invalid artifact digest") @@ -237,7 +245,7 @@ def gh_json(path): return decode_json(result.stdout) -def validate_run(run, jobs, linux=False): +def validate_run(run, jobs, linux=False, windows=False): need(isinstance(run, dict) and run.get("repository", {}).get("full_name") == REPOSITORY and run.get("head_repository", {}).get("full_name") == REPOSITORY, "untrusted build repository") need(run.get("path") == WORKFLOW and run.get("head_branch") == "master" and run.get("event") in {"push", "workflow_dispatch"}, "untrusted build workflow or ref") need(run.get("status") == "completed", "build run has not completed") @@ -248,6 +256,8 @@ def validate_run(run, jobs, linux=False): required = {"Signed macOS (prod)", "Verify downloaded macOS (prod)"} if linux: required |= {"Linux bundle (prod)", "Attest Linux packages (prod)", "Verify downloaded Linux (prod)"} + if windows: + required |= {"Build Windows binary (prod)", "Signed Windows (prod)", "Verify downloaded Windows (prod)"} for name in required: matches = [job for job in jobs["jobs"] if job.get("name") == name] need(len(matches) == 1 and matches[0].get("status") == "completed" and matches[0].get("conclusion") == "success", "required Prod package job did not succeed: " + name) @@ -256,7 +266,7 @@ def validate_run(run, jobs, linux=False): return run -def trust_run(run_id, linux=False): +def trust_run(run_id, linux=False, windows=False): need(str(run_id).isdecimal() and int(run_id) > 0, "invalid run ID") run = gh_json(f"repos/{REPOSITORY}/actions/runs/{int(run_id)}") attempt = run.get("run_attempt") @@ -265,13 +275,15 @@ def trust_run(run_id, linux=False): # only failed consumers were re-run. An attempt-only inventory would omit # those producers even though their exact artifacts remain authoritative. jobs = gh_json(f"repos/{REPOSITORY}/actions/runs/{int(run_id)}/jobs?filter=latest&per_page=100") - validate_run(run, jobs, linux) + validate_run(run, jobs, linux, windows) need(run["id"] == int(run_id), "build run ID differs") artifacts = gh_json(f"repos/{REPOSITORY}/actions/runs/{int(run_id)}/artifacts?per_page=100") need(artifacts.get("total_count") == len(artifacts.get("artifacts", [])), "incomplete artifact inventory") names = {"macos": f"maple-agent-prod-macos-aarch64-{int(run_id)}"} if linux: names["linux"] = f"maple-agent-prod-linux-x86_64-{int(run_id)}" + if windows: + names["windows"] = f"maple-agent-prod-windows-x86_64-{int(run_id)}" for name in names.values(): matches = [item for item in artifacts["artifacts"] if item.get("name") == name] need(len(matches) == 1 and matches[0].get("expired") is False, "required package artifact missing, duplicated or expired") @@ -296,18 +308,20 @@ def public_key_text(path): return lines[0] -def validate_pins(key, local_profile, source_profile, linux=False): +def validate_pins(key, local_profile, source_profile, linux=False, windows=False): for profile in (local_profile, source_profile): need(isinstance(profile, dict) and isinstance(profile.get("prod"), dict), "missing Prod release profile") prod = profile["prod"] need(prod.get("update_feed_url") == FEED_URL, "Prod update feed does not match the production feed") if linux: need(prod.get("linux_update_feed_url") == LINUX_FEED_URL, "Prod Linux update feed does not match the production feed") + if windows: + need(prod.get("windows_update_feed_url") == WINDOWS_FEED_URL, "Prod Windows update feed does not match the production feed") keys = prod.get("update_public_keys") need(isinstance(keys, list) and all(isinstance(item, str) for item in keys) and key in keys, "signing key is not pinned by the selected source and trusted publisher") -def check_source_pins(public_key, source_sha, linux=False): +def check_source_pins(public_key, source_sha, linux=False, windows=False): key = public_key_text(public_key) local = decode_json(read_bytes(ROOT / "apps/maple-agent/release-profiles.json", MAX_MANIFEST)) response = gh_json(f"repos/{REPOSITORY}/contents/apps/maple-agent/release-profiles.json?ref={source_sha}") @@ -316,7 +330,7 @@ def check_source_pins(public_key, source_sha, linux=False): source_bytes = base64.b64decode(response["content"].replace("\n", ""), validate=True) except ValueError as error: raise PublishError("selected source profile encoding is invalid") from error - validate_pins(key, local, decode_json(source_bytes, MAX_MANIFEST), linux) + validate_pins(key, local, decode_json(source_bytes, MAX_MANIFEST), linux, windows) return key @@ -449,6 +463,98 @@ def validate_linux_archive_build(archive, version, build, source_sha, bundle_id) need(found == expected, "Linux bundle build, version, source or identity differs from selected run") +class WindowsTarInfo(BoundedTarInfo): + def _proc_member(self, archive): + need(getattr(archive, "maple_headers", 0) < 32, "Windows archive exceeds bounded header inventory") + # The Windows bundle has a fixed ASCII layout and needs no extension, + # sparse, link, device or executable archive metadata interpretation. + need(self.type in {tarfile.REGTYPE, tarfile.AREGTYPE, tarfile.DIRTYPE}, + "Windows archive contains unsupported member metadata") + return super()._proc_member(archive) + + +def preflight_windows_archive(archive): + """Bound the raw tar stream, including bytes hidden after its end markers.""" + expanded, count = 0, 0 + with regular_file(archive) as compressed: + with gzip.GzipFile(fileobj=compressed, mode="rb") as stream: + while True: + header = stream.read(512) + need(len(header) == 512, "truncated Windows archive header") + if header == bytes(512): + need(stream.read(512) == bytes(512), "invalid Windows archive end marker") + trailing = stream.read(10241) + need(len(trailing) <= 10240 and not any(trailing), + "Windows archive has trailing content or excessive padding") + return + count += 1 + member = tarfile.TarInfo.frombuf(header, "utf-8", "strict") + need(count <= 32 and member.type in {tarfile.REGTYPE, tarfile.AREGTYPE, tarfile.DIRTYPE} + and not member.linkname, "Windows archive contains unsupported member metadata") + expanded += member.size + need(0 <= member.size <= 2 * 1024**3 and expanded <= 4 * 1024**3 + and (not member.isdir() or member.size == 0), + "Windows archive exceeds bounded inventory") + remaining = (member.size + 511) // 512 * 512 + while remaining: + block = stream.read(min(remaining, CHUNK)) + need(bool(block), "truncated Windows archive payload") + remaining -= len(block) + + +def validate_windows_archive_build(archive, version, build, source_sha, bundle_id, build_info): + """Validate the complete fixed Windows layout without extraction/execution. + + Native signed-package consumers verify Authenticode on the generation executable. This + independent publisher check binds their attested archive to the selected + release and rejects every auxiliary or ambiguous archive member. + """ + root = "maple-agent-windows" + files = {"bundle-info.json", "maple-agent.exe", "LICENSE.txt", "build-info.json", + "notices/THIRD_PARTY_NOTICES.txt", "notices/THIRD_PARTY_NOTICES.html", "notices/manifest.json", + "notices/wordlists/eff-large-wordlist.txt", "notices/wordlists/SOURCES.md"} + expected_files = {root + "/" + name for name in files} + expected_dirs = {root, root + "/notices", root + "/notices/wordlists"} + expected = {"format": "maple-agent-windows-bundle-v1", "product": "maple-agent", "profile": "prod", + "version": version, "build": build, "source_sha": source_sha, + "bundle_id": bundle_id, "entrypoint": "maple-agent.exe"} + need(isinstance(build, str) and re.fullmatch(r"[1-9][0-9]{0,17}", build), + "Windows package build must match the selected run") + seen, descriptor, embedded_info, expanded = set(), None, None, 0 + try: + preflight_windows_archive(archive) + with regular_file(archive) as compressed: + with tarfile.open(fileobj=compressed, mode="r|gz", tarinfo=WindowsTarInfo) as source: + for member in source: + name = member.name + need(name not in seen and len(name.encode("utf-8")) <= 200, + "duplicate or oversized Windows archive path") + seen.add(name) + expanded += member.size + need(0 <= member.size <= 2 * 1024**3 and expanded <= 4 * 1024**3, + "Windows archive exceeds bounded inventory") + if member.isdir(): + need(name in expected_dirs and member.size == 0 and member.mode == 0o755, + "invalid Windows archive directory") + continue + need(member.isfile() and name in expected_files and member.mode == 0o644 and member.size > 0, + "invalid Windows archive file inventory") + if name in {root + "/bundle-info.json", root + "/build-info.json"}: + need(member.size <= MAX_MANIFEST, "oversized Windows bundle identity") + with source.extractfile(member) as payload: + data = payload.read(MAX_MANIFEST + 1) + need(len(data) == member.size, "truncated Windows bundle identity") + if name.endswith("/bundle-info.json"): + descriptor = decode_json(data, MAX_MANIFEST) + else: + embedded_info = data + except (tarfile.TarError, EOFError, OSError, UnicodeError, RecursionError, zlib.error) as error: + raise PublishError("invalid Windows bundle archive") from error + need(seen == expected_files | expected_dirs, "Windows archive file inventory differs") + need(descriptor == expected, "Windows bundle build, version, source or identity differs from selected run") + need(embedded_info == build_info, "Windows archive build-info bytes differ from the attested sidecar") + + def validate_package(directory, platform, source_sha, version, attestor=attest, expected_build=None, metadata_records=None): directory = Path(directory) need(directory.is_dir() and not directory.is_symlink(), "package input must be a directory") @@ -464,8 +570,8 @@ def validate_package(directory, platform, source_sha, version, attestor=attest, raise PublishError("package does not match the Prod build identity") from error need(info["version"] == version, "package version differs from publication version") stem = f"maple-agent-prod-{version}-{info['git_revision']}-{platform}" - package = stem + (".dmg" if platform == "macos-aarch64" else ".tar.gz") - binaries = [package, stem + ".app.tar.gz"] if platform == "macos-aarch64" else [package] + package = stem + (".dmg" if platform == "macos-aarch64" else "-setup.exe" if platform == "windows-x86_64" else ".tar.gz") + binaries = [package, stem + ".app.tar.gz"] if platform == "macos-aarch64" else [package, stem + ".tar.gz"] if platform == "windows-x86_64" else [package] expected = {"build-info.json", "manifest.json", "SHA256SUMS", *binaries} if platform == "macos-aarch64": expected |= {"notarization-log.json", "dmg-notarization-log.json"} @@ -479,6 +585,9 @@ def validate_package(directory, platform, source_sha, version, attestor=attest, expected_metadata = {"schema_version": 1, "profile": "prod", "platform": platform, "source_sha": source_sha, "version": version, "package": package, "distribution": "master", "macos_signing": "developer-id-notarized" if platform == "macos-aarch64" else None} if platform == "linux-x86_64": expected_metadata["linux_integrity"] = "sha256" + if platform == "windows-x86_64": + need(isinstance(expected_build, str) and re.fullmatch(r"[1-9][0-9]{0,17}", expected_build), "Windows package build must match the selected run") + expected_metadata.update(windows_signing="authenticode", updater=stem + ".tar.gz", build=expected_build) need(isinstance(metadata, dict) and type(metadata.get("schema_version")) is int and metadata == expected_metadata, "invalid final package metadata") checked = set() for line in read_bytes(directory / "SHA256SUMS", 16 * 1024).decode("ascii").splitlines(): @@ -496,6 +605,8 @@ def validate_package(directory, platform, source_sha, version, attestor=attest, validate_archive_build(directory / binaries[1], version, expected_build, info["bundle_id"]) if platform == "linux-x86_64": validate_linux_archive_build(directory / binaries[0], version, expected_build, source_sha, info["bundle_id"]) + if platform == "windows-x86_64": + validate_windows_archive_build(directory / binaries[1], version, expected_build, source_sha, info["bundle_id"], read_bytes(directory / "build-info.json", 16 * 1024)) for name in sorted(expected): attestor(directory / name, source_sha) if metadata_records is not None: @@ -503,15 +614,17 @@ def validate_package(directory, platform, source_sha, version, attestor=attest, return [(directory / name, {"path": public_path(version, name), **records[name]}) for name in binaries] -def prepare(run_id, version, build, macos, linux, notes_file, output, public_key, pub_date=None): +def prepare(run_id, version, build, macos, linux, notes_file, output, public_key, pub_date=None, windows=None): version_tuple(version) - proof = trust_run(run_id, bool(linux)) + options = {"windows": True} if windows else {} + proof = trust_run(run_id, bool(linux), **options) need(build == proof["build"], "build number must match the package run number") - key = check_source_pins(public_key, proof["source_sha"], bool(linux)) + key = check_source_pins(public_key, proof["source_sha"], bool(linux), **options) notes = read_bytes(notes_file, MAX_NOTES).decode("utf-8") - mac_metadata, linux_metadata = {}, {} + mac_metadata, linux_metadata, windows_metadata = {}, {}, {} mac = validate_package(macos, "macos-aarch64", proof["source_sha"], version, expected_build=proof["build"], metadata_records=mac_metadata) lin = validate_package(linux, "linux-x86_64", proof["source_sha"], version, expected_build=proof["build"], metadata_records=linux_metadata) if linux else [] + win = validate_package(windows, "windows-x86_64", proof["source_sha"], version, expected_build=proof["build"], metadata_records=windows_metadata) if windows else [] manifest = {"format": "maple-agent-update-v1", "product": "maple-agent", "channel": "stable", "version": version, "build": build, "source_sha": proof["source_sha"], "pub_date": pub_date or proof["pub_date"], "notes": notes, "artifacts": {"darwin-aarch64": mac[1][1]}, "downloads": {"macos": mac[0][1]}} data = encode_json(manifest) validate_manifest(data) @@ -526,13 +639,20 @@ def prepare(run_id, version, build, macos, linux, notes_file, output, public_key linux_data = encode_json(linux_manifest) validate_manifest(linux_data) write_once(output / "linux-manifest.json", linux_data) + if win: + windows_manifest = {**manifest, "format": "maple-agent-update-v3", + "artifacts": {"windows-x86_64": win[1][1]}, + "downloads": {"windows": win[0][1]}} + windows_data = encode_json(windows_manifest) + validate_manifest(windows_data) + write_once(output / "windows-manifest.json", windows_data) write_once(output / "trust.json", encode_json(proof)) write_once(output / "public-key.pub", ("untrusted comment: Maple Agent update key\n" + key + "\n").encode("ascii")) metadata = output / "metadata" metadata.mkdir(exist_ok=True) need(not metadata.is_symlink(), "metadata directory must not be a symlink") inventory = {} - for prefix, directory, source_records in (("macos", macos, mac_metadata), ("linux", linux, linux_metadata)): + for prefix, directory, source_records in (("macos", macos, mac_metadata), ("linux", linux, linux_metadata), ("windows", windows, windows_metadata)): for name, record in sorted(source_records.items()): data = read_bytes(Path(directory) / name, MAX_JSON) need((hashlib.sha256(data).hexdigest(), len(data)) == (record["sha256"], record["size"]), "package metadata changed after provenance verification") @@ -549,7 +669,7 @@ def prepare(run_id, version, build, macos, linux, notes_file, output, public_key payloads = output / "payloads" payloads.mkdir(exist_ok=True) need(not payloads.is_symlink(), "payload directory must not be a symlink") - for source, record in mac + lin: + for source, record in mac + lin + win: destination = payloads / source.name if destination.exists() or destination.is_symlink(): need(hash_file(destination) == (record["sha256"], record["size"]), "existing prepared artifact differs") @@ -561,31 +681,33 @@ def prepare(run_id, version, build, macos, linux, notes_file, output, public_key def bundle_manifests(bundle): - """Both exact unsigned wire documents are prepared before reservation/signing. + """Every exact unsigned document is prepared before reservation/signing. The first document remains compatible with shipped v1 Mac clients. Linux v2 - has a separate pointer, immutable envelope and reservation in the same bucket. + and Windows v3 each have their own pointer, envelope and reservation. """ bundle = Path(bundle) publications = [] for name, envelope, reservation, pointer in ( ("manifest.json", "envelope.json", "publication-manifest.json", "stable/latest.json"), ("linux-manifest.json", "linux-envelope.json", "publication-linux-manifest.json", "stable/linux/latest.json"), + ("windows-manifest.json", "windows-envelope.json", "publication-windows-manifest.json", "stable/windows/latest.json"), ): path = bundle / name - if name == "linux-manifest.json" and not (path.exists() or path.is_symlink()): - need(not ((bundle / envelope).exists() or (bundle / envelope).is_symlink()), "Linux envelope has no prepared manifest") + if name != "manifest.json" and not (path.exists() or path.is_symlink()): + need(not ((bundle / envelope).exists() or (bundle / envelope).is_symlink()), "platform envelope has no prepared manifest") continue data = read_bytes(path, MAX_MANIFEST) manifest = validate_manifest(data) if not publications: need(manifest["format"] == "maple-agent-update-v1" and set(manifest["artifacts"]) == {"darwin-aarch64"}, "new Mac publication must remain mac-only v1") else: - need(manifest["format"] == "maple-agent-update-v2", "Linux publication must use v2") + expected_format = "maple-agent-update-v2" if name == "linux-manifest.json" else "maple-agent-update-v3" + need(manifest["format"] == expected_format, "platform publication has wrong format") primary = publications[0]["manifest"] need(all(manifest[field] == primary[field] for field in ("product", "channel", "version", "build", "source_sha", "pub_date", "notes")), - "Mac and Linux release identity differs") + "platform release identity differs") publications.append({"name": name, "envelope_name": envelope, "reservation": reservation, "pointer": pointer, "data": data, "manifest": manifest}) return publications @@ -596,8 +718,9 @@ def reservation_receipt(publications, metadata_inventory): receipt = {"manifest_sha256": hashlib.sha256(primary["data"]).hexdigest(), "metadata_inventory_sha256": hashlib.sha256(metadata_inventory).hexdigest(), "version": primary["manifest"]["version"]} - if len(publications) == 2: - receipt["linux_manifest_sha256"] = hashlib.sha256(publications[1]["data"]).hexdigest() + for publication in publications[1:]: + prefix = "linux" if publication["name"] == "linux-manifest.json" else "windows" + receipt[prefix + "_manifest_sha256"] = hashlib.sha256(publication["data"]).hexdigest() return receipt @@ -626,8 +749,11 @@ def bundle_metadata(bundle, manifest): data = read_bytes(index, MAX_MANIFEST) inventory = decode_json(data, MAX_MANIFEST) expected = {"provenance.json"} | MACOS_METADATA_NAMES - if len(bundle_manifests(bundle)) == 2: + formats = {publication["manifest"]["format"] for publication in bundle_manifests(bundle)} + if "maple-agent-update-v2" in formats: expected |= LINUX_METADATA_NAMES + if "maple-agent-update-v3" in formats: + expected |= WINDOWS_METADATA_NAMES need(isinstance(inventory, dict) and set(inventory) == expected, "private metadata inventory must contain the exact sidecars for every included target") directory = bundle / "metadata" need(directory.is_dir() and not directory.is_symlink() and {path.name for path in directory.iterdir()} == set(inventory), "private metadata inventory differs") @@ -774,7 +900,7 @@ def reserve(bundle, store, public_key=None): manifest = publications[0]["manifest"] bundle_records(bundle, manifest) metadata_inventory, _ = bundle_metadata(bundle, manifest) - # Bind both exact unsigned documents and the complete sidecar inventory + # Bind every exact unsigned document and the complete sidecar inventory # before the signing job can retrieve a key or use this release version. for publication in publications: key = release_key(manifest["version"], publication["reservation"]) @@ -842,8 +968,8 @@ def publish(bundle, store, public_key, verifier=minisign_verify, previous_verifi need(reserved is not None and reserved[0] == publication["data"], "version must be reserved before signing and publication") reserved_metadata = store.get(release_key(manifest["version"], "publication-metadata.json"), MAX_MANIFEST) need(reserved_metadata is not None and reserved_metadata[0] == metadata_inventory, "metadata must be reserved before signing and publication") - # Snapshot ALL selected feeds before uploading or promoting either. A later - # operator change wins its CAS. Partial dual promotion resumes by exact retry; + # Snapshot ALL selected feeds before uploading or promoting any. A later + # operator change wins its CAS. Partial promotion resumes by exact retry; # there is no unsafe multi-pointer rollback or unconditional replacement. for publication in publications: pointer = store.get(publication["pointer"], MAX_ENVELOPE) @@ -900,8 +1026,9 @@ def verify_live(bundle, public_key, base_url): manifest = publications[0]["manifest"] bundle_records(bundle, manifest) opener = urllib.request.build_opener(NoRedirect) - # Match the native updater on every request, including separate full GETs. - opener.addheaders = [("User-Agent", "Maple-Agent-Updater/1"), ("Accept-Encoding", "identity")] + # Match native delivery while excluding release verification from analytics. + opener.addheaders = [("User-Agent", "Maple-Agent-Updater/1"), ("Accept-Encoding", "identity"), + ("X-Maple-Download-Probe", "1")] for publication in publications: envelope = read_bytes(bundle / publication["envelope_name"], MAX_ENVELOPE) verified = minisign_verify(envelope, public_key) @@ -934,6 +1061,7 @@ def main(): trust = commands.add_parser("trust") trust.add_argument("--run-id", required=True) trust.add_argument("--linux", action="store_true") + trust.add_argument("--windows", action="store_true") trust.add_argument("--output", required=True) prep = commands.add_parser("prepare") prep.add_argument("--run-id", required=True) @@ -941,6 +1069,7 @@ def main(): prep.add_argument("--build", required=True) prep.add_argument("--macos", required=True) prep.add_argument("--linux") + prep.add_argument("--windows") prep.add_argument("--notes-file", required=True) prep.add_argument("--pub-date") prep.add_argument("--output", required=True) @@ -958,11 +1087,11 @@ def main(): command.add_argument("--base-url", required=True) args = parser.parse_args() if args.command == "trust": - proof = trust_run(args.run_id, args.linux) + proof = trust_run(args.run_id, args.linux, args.windows) write_once(args.output, encode_json(proof)) print(json.dumps(proof, sort_keys=True)) elif args.command == "prepare": - manifest = prepare(args.run_id, args.version, args.build, args.macos, args.linux, args.notes_file, args.output, args.public_key, args.pub_date) + manifest = prepare(args.run_id, args.version, args.build, args.macos, args.linux, args.notes_file, args.output, args.public_key, args.pub_date, args.windows) print(f"prepared Agent {manifest['version']} from {manifest['source_sha']}") elif args.command == "reserve": manifest = reserve(args.bundle, R2Store(args.endpoint, args.bucket), args.public_key) diff --git a/services/agent-updates/publisher/test_minisign.py b/services/agent-updates/publisher/test_minisign.py index f5585b71e..76655fd00 100644 --- a/services/agent-updates/publisher/test_minisign.py +++ b/services/agent-updates/publisher/test_minisign.py @@ -35,6 +35,13 @@ def test_raw_public_key_file_verifies_with_the_real_cli(self): key.write_text(publisher.public_key_text(self.key) + "\n") self.assertEqual(publisher.minisign_verify(self.data, key)["version"], "0.0.2") + def test_windows_native_client_shared_fixture_verifies_exact_v3_bytes(self): + shared = publisher.ROOT / "apps/maple-agent/crates/maple-agent-updater/tests/fixtures" + envelope = (shared / "signed-windows-v3-envelope.json").read_bytes() + manifest = publisher.minisign_verify(envelope, self.key) + self.assertEqual(manifest["format"], "maple-agent-update-v3") + self.assertEqual(json.loads(envelope)["manifest"].encode(), (shared / "windows-v3-manifest.json").read_bytes()) + def test_existing_signed_pointer_rotation_retry_and_missing_object_repair(self): class Store: def __init__(self): @@ -118,24 +125,33 @@ def test_tampered_signed_bytes_and_comment_are_rejected(self): publisher.minisign_verify(publisher.encode_json(envelope), self.key) def test_dual_feed_real_signatures_share_key_and_retry_exactly(self): + self.check_multiplatform_signatures(windows=False) + + def test_all_three_feed_real_signatures_share_key_and_retry_exactly(self): + self.check_multiplatform_signatures(windows=True) + + def check_multiplatform_signatures(self, windows): from test_publisher import FakeStore, PublicationTests fixtures = PublicationTests() fixtures.setUp() self.addCleanup(fixtures.doCleanups) - bundle, _, _ = fixtures.make_bundle("1.2.3", "real-dual", linux=True) + bundle, _, _ = fixtures.make_bundle("1.2.3", "real-multiplatform", linux=True, windows=windows) key, secret = bundle / "public-key.pub", fixtures.root / "offline-dual-secret.key" key.unlink() result = subprocess.run(["minisign", "-G", "-W", "-p", str(key), "-s", str(secret)], stdin=subprocess.DEVNULL, capture_output=True, timeout=30) self.assertEqual(result.returncode, 0, "offline temporary test key generation failed") - (bundle / "envelope.json").unlink() - (bundle / "linux-envelope.json").unlink() + names = ["envelope.json", "linux-envelope.json"] + (["windows-envelope.json"] if windows else []) + for name in names: + (bundle / name).unlink() store = FakeStore() publisher.reserve(bundle, store, key) publisher.sign(bundle, secret, key) - envelopes = {name: (bundle / name).read_bytes() for name in ("envelope.json", "linux-envelope.json")} + envelopes = {name: (bundle / name).read_bytes() for name in names} self.assertEqual(publisher.minisign_verify(envelopes["envelope.json"], key)["format"], "maple-agent-update-v1") self.assertEqual(publisher.minisign_verify(envelopes["linux-envelope.json"], key)["format"], "maple-agent-update-v2") + if windows: + self.assertEqual(publisher.minisign_verify(envelopes["windows-envelope.json"], key)["format"], "maple-agent-update-v3") publisher.sign(bundle, secret, key) self.assertEqual(envelopes, {name: (bundle / name).read_bytes() for name in envelopes}) self.assertEqual(publisher.publish(bundle, store, key, previous_verifier=publisher.minisign_verify), "published") @@ -143,6 +159,8 @@ def test_dual_feed_real_signatures_share_key_and_retry_exactly(self): self.assertEqual(publisher.publish(bundle, store, key, previous_verifier=publisher.minisign_verify), "already-published") self.assertEqual(envelopes["envelope.json"], store.objects["stable/latest.json"]) self.assertEqual(envelopes["linux-envelope.json"], store.objects["stable/linux/latest.json"]) + if windows: + self.assertEqual(envelopes["windows-envelope.json"], store.objects["stable/windows/latest.json"]) def test_real_temporary_key_reservation_signing_verification_and_retry(self): class ReservationStore: diff --git a/services/agent-updates/publisher/test_publisher.py b/services/agent-updates/publisher/test_publisher.py index f385860de..33fe025e0 100644 --- a/services/agent-updates/publisher/test_publisher.py +++ b/services/agent-updates/publisher/test_publisher.py @@ -32,6 +32,7 @@ "macos-manifest.json", "macos-notarization-log.json", "provenance.json", ) LINUX_METADATA = ("linux-SHA256SUMS", "linux-build-info.json", "linux-manifest.json") +WINDOWS_METADATA = ("windows-SHA256SUMS", "windows-build-info.json", "windows-manifest.json") def synthetic_envelope(manifest): @@ -147,6 +148,14 @@ def open(self, request, timeout): if path == "/download/agent/stable/linux" and method == "GET": return FakeResponse(b"", status=302, headers={"Location": linux["downloads"]["linux-bundle"]["path"]}) records += list(linux["artifacts"].values()) + windows_path = self.bundle / "windows-manifest.json" + if windows_path.exists(): + windows = publisher.validate_manifest(windows_path.read_bytes()) + if path in {"/agent/stable/windows/latest.json", publisher.public_path(windows["version"], "windows-manifest.json")} and method == "GET": + return FakeResponse((self.bundle / "windows-envelope.json").read_bytes()) + if path == "/download/agent/stable/windows" and method == "GET": + return FakeResponse(b"", status=302, headers={"Location": windows["downloads"]["windows"]["path"]}) + records += [*windows["artifacts"].values(), *windows["downloads"].values()] record = next((item for item in records if item["path"] == path), None) if record: data = (self.bundle / "payloads" / path.rsplit("/", 1)[1]).read_bytes() @@ -177,7 +186,7 @@ def setUp(self): self.store = FakeStore() self.bundle, self.manifest, self.envelope = self.make_bundle("1.2.3", "first") - def make_bundle(self, version, name, notes="Release fixture", linux=False): + def make_bundle(self, version, name, notes="Release fixture", linux=False, windows=False): bundle = self.root / name payloads = bundle / "payloads" payloads.mkdir(parents=True) @@ -185,6 +194,8 @@ def make_bundle(self, version, name, notes="Release fixture", linux=False): binaries = [("agent.app.tar.gz", b"archive fixture"), ("agent.dmg", b"disk image fixture")] if linux: binaries.append(("agent-linux.tar.gz", b"Linux tar fixture")) + if windows: + binaries += [("agent-windows.tar.gz", b"Windows tar fixture"), ("agent-windows.exe", b"Windows installer fixture")] for filename, data in binaries: (payloads / filename).write_bytes(data) artifacts[filename] = { @@ -204,13 +215,19 @@ def make_bundle(self, version, name, notes="Release fixture", linux=False): "downloads": {"linux-bundle": artifacts["agent-linux.tar.gz"]}} (bundle / "linux-manifest.json").write_bytes(publisher.encode_json(linux_manifest)) (bundle / "linux-envelope.json").write_bytes(synthetic_envelope(linux_manifest)) + if windows: + windows_manifest = {**manifest, "format": "maple-agent-update-v3", + "artifacts": {"windows-x86_64": artifacts["agent-windows.tar.gz"]}, + "downloads": {"windows": artifacts["agent-windows.exe"]}} + (bundle / "windows-manifest.json").write_bytes(publisher.encode_json(windows_manifest)) + (bundle / "windows-envelope.json").write_bytes(synthetic_envelope(windows_manifest)) envelope = synthetic_envelope(manifest) (bundle / "manifest.json").write_bytes(publisher.encode_json(manifest)) (bundle / "envelope.json").write_bytes(envelope) (bundle / "public-key.pub").write_text(PUBLIC_KEY + "\n") metadata = bundle / "metadata" metadata.mkdir() - for filename in (*MACOS_METADATA, *(LINUX_METADATA if linux else ())): + for filename in (*MACOS_METADATA, *(LINUX_METADATA if linux else ()), *(WINDOWS_METADATA if windows else ())): data = b"offline checksum fixture\n" if filename.endswith("SHA256SUMS") else \ publisher.encode_json({"fixture": filename, "version": version}) (metadata / filename).write_bytes(data) @@ -894,12 +911,13 @@ def test_live_verification_sends_native_headers_through_urllib_for_every_request requests = [] def offline_https(_handler, request): - headers = (request.get_header("User-agent"), request.get_header("Accept-encoding")) + headers = (request.get_header("User-agent"), request.get_header("Accept-encoding"), + request.get_header("X-maple-download-probe")) requests.append((request.get_method(), urlsplit(request.full_url).path, request.get_header("Range"), headers)) # Model the public edge's client policy through urllib's real # request, response and redirect handlers, without network access. - response = origin.open(request, request.timeout) if headers == ("Maple-Agent-Updater/1", "identity") \ + response = origin.open(request, request.timeout) if headers == ("Maple-Agent-Updater/1", "identity", "1") \ else FakeResponse(b"client headers required", status=403) response_headers = Message() for name, value in response.headers.items(): @@ -917,7 +935,7 @@ def offline_https(_handler, request): # Two metadata GETs, HEAD/range/full GET for each of two payloads, and # one installer redirect GET. The redirect must never be followed. self.assertEqual(len(requests), 9) - self.assertEqual({request[3] for request in requests}, {("Maple-Agent-Updater/1", "identity")}) + self.assertEqual({request[3] for request in requests}, {("Maple-Agent-Updater/1", "identity", "1")}) self.assertIn(("GET", "/agent/stable/latest.json", None), origin.calls) self.assertIn(("GET", publisher.public_path("1.2.3", "manifest.json"), None), origin.calls) self.assertIn(("GET", "/download/agent/stable/macos", None), origin.calls) diff --git a/services/agent-updates/publisher/test_windows_publisher.py b/services/agent-updates/publisher/test_windows_publisher.py new file mode 100644 index 000000000..2fc2d6d58 --- /dev/null +++ b/services/agent-updates/publisher/test_windows_publisher.py @@ -0,0 +1,391 @@ +"""Windows-specific provenance, immutable publication and archive boundaries.""" + +from copy import deepcopy +import gzip +import hashlib +import importlib.util +import io +import json +from pathlib import Path +import tarfile +import tempfile +import unittest +from unittest.mock import patch + +import test_publisher as fixtures +from test_publisher import ( + FakeStore, FixtureOrigin, + PUBLIC_KEY, SOURCE_SHA, fixture_verifier, publisher, synthetic_envelope, +) + + +class WindowsPublicationTests(unittest.TestCase): + def setUp(self): + self.fixture = fixtures.PublicationTests() + self.fixture.setUp() + self.addCleanup(self.fixture.doCleanups) + + def bundle(self, name="windows", linux=True): + return self.fixture.make_bundle("1.2.3", name, linux=linux, windows=True) + + def publish(self, bundle, store): + return publisher.publish(bundle, store, bundle / "public-key.pub", + fixture_verifier, previous_verifier=fixture_verifier) + + def test_windows_has_its_own_exact_reserved_signed_document_and_pointer(self): + bundle, mac, mac_envelope = self.bundle() + store = FakeStore() + publications = publisher.bundle_manifests(bundle) + self.assertEqual([x["manifest"]["format"] for x in publications], + ["maple-agent-update-v1", "maple-agent-update-v2", "maple-agent-update-v3"]) + publisher.reserve(bundle, store) + receipt = json.loads((bundle / "reservation.json").read_bytes()) + self.assertEqual(receipt["windows_manifest_sha256"], hashlib.sha256((bundle / "windows-manifest.json").read_bytes()).hexdigest()) + self.assertEqual(receipt["linux_manifest_sha256"], hashlib.sha256((bundle / "linux-manifest.json").read_bytes()).hexdigest()) + self.assertEqual(store.objects[publisher.release_key("1.2.3", "publication-windows-manifest.json")], + (bundle / "windows-manifest.json").read_bytes()) + self.assertEqual(self.publish(bundle, store), "published") + self.assertEqual(store.objects["stable/latest.json"], mac_envelope) + self.assertEqual(store.objects["stable/windows/latest.json"], (bundle / "windows-envelope.json").read_bytes()) + self.assertEqual(store.objects[publisher.release_key("1.2.3", "windows-manifest.json")], + (bundle / "windows-envelope.json").read_bytes()) + self.assertEqual(set(mac["artifacts"]), {"darwin-aarch64"}) + for name in publisher.WINDOWS_METADATA_NAMES: + self.assertIn(publisher.metadata_key("1.2.3", name), store.objects) + self.assertEqual(self.publish(bundle, store), "already-published") + + def test_mac_and_windows_without_linux_do_not_misclassify_the_second_manifest(self): + bundle, _, _ = self.bundle(linux=False) + store = FakeStore() + publisher.reserve(bundle, store) + receipt = json.loads((bundle / "reservation.json").read_bytes()) + self.assertIn("windows_manifest_sha256", receipt) + self.assertNotIn("linux_manifest_sha256", receipt) + self.publish(bundle, store) + self.assertIn("stable/windows/latest.json", store.objects) + self.assertFalse(any(call[1] == "stable/linux/latest.json" for call in store.calls)) + + def test_windows_metadata_is_exact_and_cannot_be_omitted_or_attached_to_other_formats(self): + for mode in ("missing", "partial", "extra"): + with self.subTest(mode=mode): + bundle, _, _ = self.bundle(mode) + directory = bundle / "metadata" + if mode == "missing": + for name in publisher.WINDOWS_METADATA_NAMES: + (directory / name).unlink() + elif mode == "partial": + (directory / "windows-manifest.json").unlink() + else: + (bundle / "windows-manifest.json").unlink() + (bundle / "windows-envelope.json").unlink() + (bundle / "payloads/agent-windows.tar.gz").unlink() + (bundle / "payloads/agent-windows.exe").unlink() + self.fixture.index_metadata(bundle) + store = FakeStore() + with self.assertRaisesRegex(publisher.PublishError, "exact sidecars"): + publisher.reserve(bundle, store) + self.assertFalse(store.calls) + + def test_windows_identity_mismatch_and_unsupported_fields_fail_before_reservation(self): + for field, value in (("build", "43"), ("source_sha", "b" * 40), + ("notes", "other notes"), ("format", "maple-agent-update-v2")): + with self.subTest(field=field): + bundle, _, _ = self.bundle(field) + wire = json.loads((bundle / "windows-manifest.json").read_bytes()) + wire[field] = value + (bundle / "windows-manifest.json").write_bytes(publisher.encode_json(wire)) + with self.assertRaises(publisher.PublishError): + publisher.reserve(bundle, FakeStore()) + bundle, _, _ = self.bundle("schema") + wire = json.loads((bundle / "windows-manifest.json").read_bytes()) + for change in (lambda x: x["artifacts"].update({"darwin-aarch64": next(iter(x["artifacts"].values()))}), + lambda x: x["downloads"].update({"macos": next(iter(x["downloads"].values()))}), + lambda x: x["artifacts"]["windows-x86_64"].update(path=x["downloads"]["windows"]["path"]), + lambda x: x["downloads"]["windows"].update(path=x["artifacts"]["windows-x86_64"]["path"])): + bad = deepcopy(wire) + change(bad) + with self.assertRaises(publisher.PublishError): + publisher.validate_manifest(publisher.encode_json(bad)) + + def test_windows_partial_promotion_retry_only_repairs_the_missing_pointer(self): + bundle, _, _ = self.bundle() + store = FakeStore() + publisher.reserve(bundle, store) + store.fail_once = "stable/windows/latest.json" + with self.assertRaises(publisher.StoreError): + self.publish(bundle, store) + self.assertIn("stable/latest.json", store.objects) + self.assertIn("stable/linux/latest.json", store.objects) + self.assertNotIn("stable/windows/latest.json", store.objects) + store.calls.clear() + self.assertEqual(self.publish(bundle, store), "published") + writes = [call[1] for call in store.calls if call[0] == "put"] + self.assertEqual(writes, ["stable/windows/latest.json"]) + + def test_windows_pointer_race_preserves_competing_bytes_and_refuses_retry(self): + bundle, _, _ = self.bundle() + competitor = b"a competing release pointer" + store = FakeStore() + publisher.reserve(bundle, store) + original_put = store.put + + def put(key, data, **kwargs): + if key == "stable/windows/latest.json": + store.set(key, competitor) + return original_put(key, data, **kwargs) + + with patch.object(store, "put", side_effect=put), \ + self.assertRaisesRegex(publisher.PublishError, "changed concurrently"): + self.publish(bundle, store) + self.assertEqual(store.objects["stable/windows/latest.json"], competitor) + store.calls.clear() + with self.assertRaises(publisher.PublishError): + self.publish(bundle, store) + self.assertFalse(any(call[0] == "put" for call in store.calls)) + self.assertEqual(store.objects["stable/windows/latest.json"], competitor) + + def test_windows_conflicting_reservation_and_changed_signed_bytes_fail_closed(self): + bundle, _, _ = self.bundle() + store = FakeStore() + store.set(publisher.release_key("1.2.3", "publication-windows-manifest.json"), b"different") + with self.assertRaises(publisher.PublishError): + publisher.reserve(bundle, store) + self.assertNotIn("stable/windows/latest.json", store.objects) + store = FakeStore() + publisher.reserve(bundle, store) + wire = json.loads((bundle / "windows-manifest.json").read_bytes()) + wire["notes"] = "changed" + (bundle / "windows-envelope.json").write_bytes(synthetic_envelope(wire)) + with self.assertRaises(publisher.PublishError): + self.publish(bundle, store) + self.assertNotIn("stable/windows/latest.json", store.objects) + + def test_live_verifier_covers_windows_feed_immutable_both_payloads_and_alias(self): + bundle, mac, envelope = self.bundle() + origin = FixtureOrigin(bundle, mac, envelope) + with patch.object(publisher, "minisign_verify", fixture_verifier), \ + patch.object(publisher.urllib.request, "build_opener", return_value=origin): + publisher.verify_live(bundle, bundle / "public-key.pub", "https://updates.example") + paths = {path for _, path, _ in origin.calls} + for path in ("/agent/stable/windows/latest.json", "/agent/stable/releases/1.2.3/windows-manifest.json", + "/download/agent/stable/windows", "/agent/stable/releases/1.2.3/agent-windows.tar.gz", + "/agent/stable/releases/1.2.3/agent-windows.exe"): + self.assertIn(path, paths) + for suffix in ("agent-windows.tar.gz", "agent-windows.exe"): + path = "/agent/stable/releases/1.2.3/" + suffix + self.assertIn(("HEAD", path, None), origin.calls) + self.assertIn(("GET", path, "bytes=0-0"), origin.calls) + self.assertIn(("GET", path, None), origin.calls) + + +class WindowsTrustTests(unittest.TestCase): + def setUp(self): + self.run = {"id": 123, "run_attempt": 1, "run_number": 42, + "repository": {"full_name": publisher.REPOSITORY}, + "head_repository": {"full_name": publisher.REPOSITORY}, + "path": publisher.WORKFLOW, "head_branch": "master", "event": "push", + "status": "completed", "head_sha": SOURCE_SHA, "created_at": "2026-10-21T18:00:00Z"} + self.base = {"Signed macOS (prod)", "Verify downloaded macOS (prod)"} + self.windows = {"Build Windows binary (prod)", "Signed Windows (prod)", "Verify downloaded Windows (prod)"} + + def jobs(self, names): + jobs = [{"name": name, "status": "completed", "conclusion": "success"} for name in names] + return {"total_count": len(jobs), "jobs": jobs} + + def test_each_windows_prod_job_is_required_and_aggregate_or_skip_cannot_substitute(self): + publisher.validate_run(self.run, self.jobs(self.base | self.windows), windows=True) + for missing in self.windows: + with self.subTest(missing=missing), self.assertRaises(publisher.PublishError): + publisher.validate_run(self.run, self.jobs((self.base | self.windows) - {missing}), windows=True) + for conclusion in ("skipped", "failure", "cancelled", None): + jobs = self.jobs(self.base | self.windows) + next(x for x in jobs["jobs"] if x["name"] == "Signed Windows (prod)")["conclusion"] = conclusion + with self.subTest(conclusion=conclusion), self.assertRaises(publisher.PublishError): + publisher.validate_run(self.run, jobs, windows=True) + publisher.validate_run(self.run, self.jobs(self.base), windows=False) + + def test_trust_selects_exact_windows_artifact_and_rejects_missing_expired_duplicate(self): + names = ["maple-agent-prod-macos-aarch64-123", "maple-agent-prod-windows-x86_64-123"] + artifacts = {"total_count": 2, "artifacts": [{"name": name, "expired": False} for name in names]} + with patch.object(publisher, "gh_json", side_effect=[self.run, self.jobs(self.base | self.windows), artifacts]): + proof = publisher.trust_run(123, windows=True) + self.assertEqual(proof["artifacts"]["windows"], names[1]) + for entries in (artifacts["artifacts"][:1], [*artifacts["artifacts"], artifacts["artifacts"][1]], + [artifacts["artifacts"][0], {"name": names[1], "expired": True}]): + bad = {"total_count": len(entries), "artifacts": entries} + with patch.object(publisher, "gh_json", side_effect=[self.run, self.jobs(self.base | self.windows), bad]), \ + self.assertRaises(publisher.PublishError): + publisher.trust_run(123, windows=True) + + def test_windows_source_and_publisher_need_same_canonical_feed_and_pinned_key(self): + profile = {"prod": {"update_feed_url": publisher.FEED_URL, "windows_update_feed_url": publisher.WINDOWS_FEED_URL, + "update_public_keys": [PUBLIC_KEY]}} + publisher.validate_pins(PUBLIC_KEY, profile, deepcopy(profile), windows=True) + for bad in (None, "https://foreign.example/windows/latest.json", publisher.FEED_URL): + changed = deepcopy(profile) + changed["prod"]["windows_update_feed_url"] = bad + for first, second in ((changed, profile), (profile, changed)): + with self.subTest(bad=bad), self.assertRaises(publisher.PublishError): + publisher.validate_pins(PUBLIC_KEY, first, second, windows=True) + + +class WindowsPackageBoundaryTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.directory = Path(self.temporary.name) + spec = importlib.util.spec_from_file_location("windows_fixture_info", publisher.ROOT / "apps/maple-agent/scripts/release-info.py") + release_info = importlib.util.module_from_spec(spec) + spec.loader.exec_module(release_info) + profiles = json.loads((publisher.ROOT / "apps/maple-agent/release-profiles.json").read_text()) + self.info = {field: profiles["prod"][field] for field in release_info.PUBLIC_FIELDS} + self.info.update(profile="prod", version="1.2.3", source_sha=SOURCE_SHA, + git_revision=SOURCE_SHA[:9], update_feed_url=publisher.WINDOWS_FEED_URL) + self.info_bytes = publisher.encode_json(self.info) + self.stem = "maple-agent-prod-1.2.3-" + SOURCE_SHA[:9] + "-windows-x86_64" + self.metadata = {"schema_version": 1, "profile": "prod", "platform": "windows-x86_64", + "source_sha": SOURCE_SHA, "version": "1.2.3", "package": self.stem + "-setup.exe", + "distribution": "master", "macos_signing": None, "windows_signing": "authenticode", + "updater": self.stem + ".tar.gz", "build": "42"} + self.descriptor = {"format": "maple-agent-windows-bundle-v1", "product": "maple-agent", + "profile": "prod", "version": "1.2.3", "build": "42", "source_sha": SOURCE_SHA, + "bundle_id": self.info["bundle_id"], "entrypoint": "maple-agent.exe"} + (self.directory / "build-info.json").write_bytes(self.info_bytes) + (self.directory / "manifest.json").write_bytes(publisher.encode_json(self.metadata)) + (self.directory / (self.stem + "-setup.exe")).write_bytes(b"offline signed installer fixture; never execute") + self.write_archive() + self.checksums() + self.attested = [] + + def entries(self): + root = "maple-agent-windows" + items = [(name, tarfile.DIRTYPE, b"") for name in (root, root + "/notices", root + "/notices/wordlists")] + files = {"bundle-info.json": publisher.encode_json(self.descriptor), "build-info.json": self.info_bytes, + "maple-agent.exe": b"offline signed PE fixture; never execute", "LICENSE.txt": b"license", + "notices/THIRD_PARTY_NOTICES.txt": b"notices", "notices/THIRD_PARTY_NOTICES.html": b"html", + "notices/manifest.json": b"{}", "notices/wordlists/eff-large-wordlist.txt": b"word", + "notices/wordlists/SOURCES.md": b"source"} + return items + [(root + "/" + name, tarfile.REGTYPE, data) for name, data in files.items()] + + def write_archive(self, entries=None): + with tarfile.open(self.directory / (self.stem + ".tar.gz"), "w:gz", format=tarfile.USTAR_FORMAT) as archive: + for name, kind, data in self.entries() if entries is None else entries: + member = tarfile.TarInfo(name) + member.type, member.mode = kind, 0o755 if kind == tarfile.DIRTYPE else 0o644 + if kind == tarfile.REGTYPE: + member.size = len(data) + archive.addfile(member, io.BytesIO(data)) + else: + member.linkname = "outside" if kind == tarfile.SYMTYPE else "" + archive.addfile(member) + + def checksums(self): + (self.directory / "SHA256SUMS").write_text("".join( + hashlib.sha256(path.read_bytes()).hexdigest() + " " + path.name + "\n" + for path in sorted(self.directory.iterdir()) if path.name != "SHA256SUMS" + )) + + def validate(self): + return publisher.validate_package(self.directory, "windows-x86_64", SOURCE_SHA, "1.2.3", + expected_build="42", attestor=lambda path, source: self.attested.append((path.name, source))) + + def test_exact_installer_and_bundle_are_bound_to_source_build_inventory_and_all_attestations(self): + result = self.validate() + self.assertEqual([path.name for path, _ in result], [self.stem + "-setup.exe", self.stem + ".tar.gz"]) + self.assertEqual({name for name, _ in self.attested}, {p.name for p in self.directory.iterdir()}) + self.assertEqual(len(self.attested), 5) + + def test_archive_identity_mismatch_missing_extra_duplicate_links_and_sidecar_drift_fail_before_attestation(self): + original = deepcopy(self.descriptor) + for field, value in (("build", "43"), ("source_sha", "b" * 40), ("profile", "dev"), ("extra", True)): + with self.subTest(field=field): + self.descriptor = {**original, field: value} + self.write_archive() + self.checksums() + with self.assertRaises(publisher.PublishError): + self.validate() + self.assertFalse(self.attested) + self.descriptor = original + entries = self.entries() + for changed in (entries[:-1], entries + [entries[-1]], entries + [("maple-agent-windows/extra", tarfile.REGTYPE, b"extra")], + entries + [("maple-agent-windows/uninstall.exe", tarfile.REGTYPE, b"forbidden generation uninstaller")], + [(name, tarfile.SYMTYPE if name.endswith("/maple-agent.exe") else kind, data) for name, kind, data in entries], + [(name, kind, b"other identity" if name.endswith("/build-info.json") else data) for name, kind, data in entries]): + with self.subTest(entries=len(changed)): + self.write_archive(changed) + self.checksums() + with self.assertRaises(publisher.PublishError): + self.validate() + self.assertFalse(self.attested) + + def test_unsigned_wrong_manifest_build_and_extra_final_file_cannot_qualify(self): + for field, value in (("windows_signing", "unsigned-pr"), ("distribution", "unsigned-pr"), ("build", "43")): + with self.subTest(field=field): + changed = {**self.metadata, field: value} + (self.directory / "manifest.json").write_bytes(publisher.encode_json(changed)) + self.checksums() + with self.assertRaises(publisher.PublishError): + self.validate() + self.assertFalse(self.attested) + (self.directory / "manifest.json").write_bytes(publisher.encode_json(self.metadata)) + (self.directory / "extra.exe").write_bytes(b"undeclared payload") + with self.assertRaises(publisher.PublishError): + self.validate() + self.assertFalse(self.attested) + + def test_trailing_content_invalid_end_marker_excessive_padding_and_truncated_gzip_fail(self): + archive = self.directory / (self.stem + ".tar.gz") + canonical = gzip.decompress(archive.read_bytes()) + # Find the actual tar end, skipping member bodies rather than searching + # for zero bytes that may legitimately occur inside a PE file. + offset = 0 + while canonical[offset:offset + 512] != bytes(512): + member = tarfile.TarInfo.frombuf(canonical[offset:offset + 512], "utf-8", "strict") + offset += 512 + (member.size + 511) // 512 * 512 + variants = [gzip.compress(canonical + b"undeclared content"), + gzip.compress(canonical[:offset + 512] + b"bad second end marker".ljust(512, b"\0")), + gzip.compress(canonical + bytes(10241)), + gzip.compress(canonical)[:-4]] + for corrupt in variants: + with self.subTest(size=len(corrupt)): + archive.write_bytes(corrupt) + self.checksums() + with self.assertRaises(publisher.PublishError): + self.validate() + self.assertFalse(self.attested) + + def test_preparation_keeps_mac_bytes_independent_and_binds_windows_sidecars(self): + mac = fixtures.PackageBoundaryTests() + mac.setUp() + self.addCleanup(mac.doCleanups) + notes = self.directory.parent / (self.directory.name + "-notes") + notes.write_text("release notes") + self.addCleanup(notes.unlink) + key = self.directory.parent / (self.directory.name + "-key") + key.write_text(PUBLIC_KEY + "\n") + self.addCleanup(key.unlink) + output = self.directory.parent / (self.directory.name + "-prepared") + self.addCleanup(lambda: __import__("shutil").rmtree(output, ignore_errors=True)) + proof = {"run_id": 123, "run_attempt": 1, "source_sha": SOURCE_SHA, "build": "42", + "pub_date": "2026-10-21T18:00:00Z", "artifacts": {"macos": "mac", "windows": "windows"}} + validator = publisher.validate_package + def offline_validation(*args, **kwargs): + return validator(*args, **kwargs, attestor=lambda path, source: None) + with patch.object(publisher, "trust_run", return_value=proof) as trust, \ + patch.object(publisher, "check_source_pins", return_value=PUBLIC_KEY) as pins, \ + patch.object(publisher, "validate_package", side_effect=offline_validation): + prepared = publisher.prepare(123, "1.2.3", "42", mac.directory, None, notes, output, key, windows=self.directory) + trust.assert_called_once_with(123, False, windows=True) + pins.assert_called_once_with(key, SOURCE_SHA, False, windows=True) + self.assertEqual(set(prepared["artifacts"]), {"darwin-aarch64"}) + windows = publisher.validate_manifest((output / "windows-manifest.json").read_bytes()) + self.assertEqual(windows["artifacts"]["windows-x86_64"]["path"].rsplit("/", 1)[1], self.stem + ".tar.gz") + self.assertEqual(windows["downloads"]["windows"]["path"].rsplit("/", 1)[1], self.stem + "-setup.exe") + self.assertEqual(len(list((output / "payloads").iterdir())), 4) + self.assertEqual(sum(path.is_file() for path in output.rglob("*")), 18) + publisher.reserve(output, FakeStore()) + self.assertEqual(sum(path.is_file() for path in output.rglob("*")), 19) + + +if __name__ == "__main__": + unittest.main() diff --git a/services/agent-updates/src/linux-feed.test.ts b/services/agent-updates/src/linux-feed.test.ts index fc255ea29..0677d1941 100644 --- a/services/agent-updates/src/linux-feed.test.ts +++ b/services/agent-updates/src/linux-feed.test.ts @@ -266,6 +266,7 @@ test("Linux discovery cannot authorize a release without its complete immutable expect(bucket.calls).toEqual([ "get:stable/releases/0.0.2/manifest.json", "get:stable/releases/0.0.2/linux-manifest.json", + "get:stable/releases/0.0.2/windows-manifest.json", ]); }); diff --git a/services/agent-updates/src/manifest.ts b/services/agent-updates/src/manifest.ts index e342b34d0..d822b3efa 100644 --- a/services/agent-updates/src/manifest.ts +++ b/services/agent-updates/src/manifest.ts @@ -29,7 +29,12 @@ export interface ManifestV2 extends ManifestFields { artifacts: { "linux-x86_64": Artifact }; downloads: { "linux-bundle": Artifact }; } -export type UpdateManifest = Manifest | ManifestV2; +export interface ManifestV3 extends ManifestFields { + format: "maple-agent-update-v3"; + artifacts: { "windows-x86_64": Artifact }; + downloads: { windows: Artifact }; +} +export type UpdateManifest = Manifest | ManifestV2 | ManifestV3; export interface Envelope { manifest: string; signature: string; @@ -95,9 +100,11 @@ export function parseManifest(value: unknown): UpdateManifest { "artifacts", "downloads", ]) || - !["maple-agent-update-v1", "maple-agent-update-v2"].includes( - value.format as string, - ) || + ![ + "maple-agent-update-v1", + "maple-agent-update-v2", + "maple-agent-update-v3", + ].includes(value.format as string) || value.product !== "maple-agent" || value.channel !== "stable" || typeof value.version !== "string" || @@ -134,6 +141,16 @@ export function parseManifest(value: unknown): UpdateManifest { throw new Error("invalid Linux bundle manifest"); return value as unknown as ManifestV2; } + if (value.format === "maple-agent-update-v3") { + if ( + !keys(value.artifacts, ["windows-x86_64"]) || + !keys(value.downloads, ["windows"]) || + !artifact(value.artifacts["windows-x86_64"], value.version, ".tar.gz") || + !artifact(value.downloads.windows, value.version, ".exe") + ) + throw new Error("invalid Windows bundle manifest"); + return value as unknown as ManifestV3; + } // Keep the shipped v1 target/path rules, including optional AppImages. if ( !keys(value.artifacts, ["darwin-aarch64", "linux-x86_64"]) || diff --git a/services/agent-updates/src/runtime.test.ts b/services/agent-updates/src/runtime.test.ts index e2d4538d3..781a1956e 100644 --- a/services/agent-updates/src/runtime.test.ts +++ b/services/agent-updates/src/runtime.test.ts @@ -4,6 +4,7 @@ import { join } from "node:path"; import { expect, test } from "bun:test"; import { fixture, PAYLOAD } from "./fixtures"; import { linuxFixture } from "./linux-fixtures"; +import { windowsFixture } from "./windows-fixtures"; // Miniflare is Wrangler's exact locked runtime dependency. Node hosts the // harness; assertions make real requests through workerd and its R2 binding. @@ -26,7 +27,7 @@ test("real workerd R2 binding serves separate feeds, immutable bytes, ranges, HE import worker from ${JSON.stringify(join(import.meta.dir, "index.ts"))}; export default { async fetch(request,env) { if (new URL(request.url).pathname === '/__seed' && request.method === 'POST') { - const {metadata,linuxMetadata,payload} = await request.json(); + const {metadata,linuxMetadata,windowsMetadata,payload} = await request.json(); const bytes = Uint8Array.from(metadata); await env.AGENT_RELEASES.put('stable/latest.json',bytes); await env.AGENT_RELEASES.put('stable/releases/0.0.2/manifest.json',bytes); @@ -35,6 +36,10 @@ test("real workerd R2 binding serves separate feeds, immutable bytes, ranges, HE await env.AGENT_RELEASES.put('stable/linux/latest.json',Uint8Array.from(linuxMetadata)); await env.AGENT_RELEASES.put('stable/releases/0.0.2/linux-manifest.json',Uint8Array.from(linuxMetadata)); await env.AGENT_RELEASES.put('stable/releases/0.0.2/Maple-Agent-linux-x86_64.tar.gz',Uint8Array.from(payload)); + await env.AGENT_RELEASES.put('stable/windows/latest.json',Uint8Array.from(windowsMetadata)); + await env.AGENT_RELEASES.put('stable/releases/0.0.2/windows-manifest.json',Uint8Array.from(windowsMetadata)); + await env.AGENT_RELEASES.put('stable/releases/0.0.2/Maple-Agent-windows-x86_64.tar.gz',Uint8Array.from(payload)); + await env.AGENT_RELEASES.put('stable/releases/0.0.2/Maple-Agent-windows-x86_64-setup.exe',Uint8Array.from(payload)); await env.AGENT_RELEASES.put('stable/releases/0.0.2/publication-manifest.json',Uint8Array.from(payload)); return new Response('seeded'); } @@ -84,6 +89,7 @@ test("real workerd R2 binding serves separate feeds, immutable bytes, ranges, HE body: JSON.stringify({ metadata: Array.from(fixture().bytes), linuxMetadata: Array.from(linuxFixture().bytes), + windowsMetadata: Array.from(windowsFixture().bytes), payload: Array.from(PAYLOAD), }), }); @@ -149,6 +155,50 @@ test("real workerd R2 binding serves separate feeds, immutable bytes, ranges, HE expect(new Uint8Array(await linuxManifest.arrayBuffer())).toEqual( linuxFixture().bytes, ); + const windows = windowsFixture(); + const windowsLatest = await fetch( + `${origin}/agent/stable/windows/latest.json`, + ); + expect(windowsLatest.status).toBe(200); + expect(windowsLatest.headers.get("cdn-cache-control")).toBe("no-store"); + expect(new Uint8Array(await windowsLatest.arrayBuffer())).toEqual( + windows.bytes, + ); + const windowsRedirect = await fetch( + `${origin}/download/agent/stable/windows`, + { redirect: "manual" }, + ); + expect(windowsRedirect.status).toBe(302); + expect(windowsRedirect.headers.get("location")).toBe( + windows.manifest.downloads.windows.path, + ); + expect(windowsRedirect.headers.get("cache-control")).toBe("no-store"); + const windowsManifest = await fetch( + `${origin}/agent/stable/releases/0.0.2/windows-manifest.json`, + ); + expect(windowsManifest.status).toBe(200); + expect(new Uint8Array(await windowsManifest.arrayBuffer())).toEqual( + windows.bytes, + ); + for (const artifact of [ + windows.manifest.artifacts["windows-x86_64"], + windows.manifest.downloads.windows, + ]) { + const full = await fetch(`${origin}${artifact.path}`); + expect(full.status).toBe(200); + expect(new Uint8Array(await full.arrayBuffer())).toEqual(PAYLOAD); + const partial = await fetch(`${origin}${artifact.path}`, { + headers: { range: "bytes=2-7" }, + }); + expect(partial.status).toBe(206); + expect(new Uint8Array(await partial.arrayBuffer())).toEqual( + PAYLOAD.slice(2, 8), + ); + const head = await fetch(`${origin}${artifact.path}`, { method: "HEAD" }); + expect(head.status).toBe(200); + expect(head.headers.get("content-length")).toBe(String(PAYLOAD.length)); + expect(await head.text()).toBe(""); + } expect( ( await fetch( diff --git a/services/agent-updates/src/tracking-runtime.test.ts b/services/agent-updates/src/tracking-runtime.test.ts index c4d4207fc..3e8b4d576 100644 --- a/services/agent-updates/src/tracking-runtime.test.ts +++ b/services/agent-updates/src/tracking-runtime.test.ts @@ -52,6 +52,7 @@ test("real workerd queues persist Agent and Research request events once and ret ` import agent from ${JSON.stringify(join(import.meta.dir, "index.ts"))}; import research from ${JSON.stringify(join(import.meta.dir, "../../updates/src/index.ts"))}; + import { trackResponse } from ${JSON.stringify(join(import.meta.dir, "../../download-tracking/tracking.ts"))}; export default { async queue(batch, env, ctx) { await agent.queue(batch, env, ctx); @@ -61,9 +62,8 @@ test("real workerd queues persist Agent and Research request events once and ret const path = new URL(request.url).pathname; if (path === '/__seed' && request.method === 'POST') { const { metadata, payload, latest, catalog, migration } = await request.json(); - for (const statement of migration.split(';')) { - if (statement.trim()) await env.DOWNLOAD_DB.prepare(statement).run(); - } + const statements = migration.split(';').filter(statement => statement.trim()); + await env.DOWNLOAD_DB.batch(statements.map(statement => env.DOWNLOAD_DB.prepare(statement))); await env.DOWNLOAD_DB.prepare('CREATE TABLE test_deliveries (message_count INTEGER NOT NULL)').run(); await env.AGENT_RELEASES.put('stable/latest.json', Uint8Array.from(metadata)); await env.AGENT_RELEASES.put('stable/releases/0.0.2/manifest.json', Uint8Array.from(metadata)); @@ -73,6 +73,29 @@ test("real workerd queues persist Agent and Research request events once and ret await env.AGENT_RELEASES.put('test/installers.json', JSON.stringify(catalog)); return new Response('seeded'); } + if (path === '/__upgrade' && request.method === 'POST') { + const { migration, fail } = await request.json(); + const statements = migration.split(';').filter(statement => statement.trim()); + if (fail) statements.push('SELECT * FROM injected_migration_failure'); + try { + await env.DOWNLOAD_DB.batch(statements.map(statement => env.DOWNLOAD_DB.prepare(statement))); + return new Response('upgraded'); + } catch { + return new Response('migration rolled back', { status: 503 }); + } + } + if (path === '/__schema') { + const schema = await env.DOWNLOAD_DB.prepare("SELECT type, name, sql FROM sqlite_master WHERE name LIKE 'download_events%' ORDER BY type, name").all(); + return Response.json(schema.results); + } + if (path === '/__windows' && request.method === 'POST') { + const payload = Uint8Array.from((await request.json()).payload); + const response = new Response(payload, { headers: { 'content-length': String(payload.length) } }); + trackResponse(new Request('https://updates.maple.ai/agent/stable/releases/0.0.10/maple-agent-prod-0.0.10-abcdef0-windows-x86_64.tar.gz', { + headers: { 'x-maple-download-probe': '1' }, + }), response, env, ctx, 'agent'); + return response; + } if (path === '/__rows') { const rows = await env.DOWNLOAD_DB.prepare('SELECT * FROM download_events ORDER BY observed_at, event_id').all(); const delivered = await env.DOWNLOAD_DB.prepare('SELECT COALESCE(SUM(message_count), 0) AS count FROM test_deliveries').first(); @@ -230,14 +253,70 @@ test("real workerd queues persist Agent and Research request events once and ret ), ).toBe(true); + const upgrade = await Bun.file( + join(import.meta.dir, "../migrations/0002_windows_download_events.sql"), + ).text(); + const schemaBefore = await fetch(`${origin}/__schema`).then((response) => + response.json(), + ); + expect( + ( + await fetch(`${origin}/__upgrade`, { + method: "POST", + body: JSON.stringify({ migration: upgrade, fail: true }), + }) + ).status, + ).toBe(503); + expect((await snapshot()).rows).toEqual(first.rows); + expect( + await fetch(`${origin}/__schema`).then((response) => response.json()), + ).toEqual(schemaBefore); + expect( + ( + await fetch(`${origin}/__upgrade`, { + method: "POST", + body: JSON.stringify({ migration: upgrade, fail: false }), + }) + ).status, + ).toBe(200); + expect((await snapshot()).rows).toEqual(first.rows); + const schemaAfter = (await fetch(`${origin}/__schema`).then((response) => + response.json(), + )) as Array<{ type: string; name: string; sql: string }>; + expect( + schemaAfter.filter((row) => row.type === "index").map((row) => row.name), + ).toEqual(["download_events_observed_at", "download_events_trends"]); + expect(schemaAfter.find((row) => row.type === "table")?.sql).toContain( + "'windows-bundle'", + ); + const windows = await fetch(`${origin}/__windows`, { + method: "POST", + body: JSON.stringify({ payload: Array.from(PAYLOAD) }), + }); + expect(new Uint8Array(await windows.arrayBuffer())).toEqual(PAYLOAD); + const withWindows = await eventually( + snapshot, + (value) => value.rows.length === 8, + ); + expect( + withWindows.rows.filter((row) => row.platform === "windows"), + ).toHaveLength(1); + expect( + withWindows.rows.find((row) => row.platform === "windows"), + ).toMatchObject({ + artifact: "windows-bundle", + kind: "artifact", + is_probe: 1, + }); + expect((await fetch(`${origin}/__replay`, { method: "POST" })).status).toBe( 200, ); const replayed = await eventually( snapshot, - (value) => value.deliveries >= first.deliveries + 2, + (value) => value.deliveries >= withWindows.deliveries + 2, ); - expect(replayed.rows).toEqual(first.rows); + expect(replayed.rows).toEqual(withWindows.rows); expect((await fetch(`${origin}/__outage`, { method: "POST" })).status).toBe( 200, @@ -257,11 +336,11 @@ test("real workerd queues persist Agent and Research request events once and ret ).toBe(200); const recovered = await eventually( snapshot, - (value) => value.rows.length === 8, + (value) => value.rows.length === 9, ); expect( recovered.rows.filter((row) => row.kind === "artifact"), - ).toHaveLength(4); + ).toHaveLength(5); } finally { clearTimeout(timer); child?.kill("SIGTERM"); diff --git a/services/agent-updates/src/tracking.test.ts b/services/agent-updates/src/tracking.test.ts index 792b0ea98..9b9211575 100644 --- a/services/agent-updates/src/tracking.test.ts +++ b/services/agent-updates/src/tracking.test.ts @@ -67,13 +67,15 @@ function message( }; } +async function migration(name: string): Promise { + return Bun.file(new URL(`../migrations/${name}`, import.meta.url)).text(); +} + async function database() { const sqlite = new Database(":memory:"); - sqlite.exec( - await Bun.file( - new URL("../migrations/0001_download_events.sql", import.meta.url), - ).text(), - ); + sqlite.exec(await migration("0001_download_events.sql")); + const upgrade = await migration("0002_windows_download_events.sql"); + sqlite.transaction(() => sqlite.exec(upgrade))(); const db: EventDatabase = { prepare(sql) { return { @@ -91,6 +93,63 @@ async function database() { return { sqlite, db }; } +test("Windows routes distinguish v3 metadata, NSIS redirects and update bundles", async () => { + const prefix = "/agent/stable/releases/0.0.10/"; + const stem = "maple-agent-prod-0.0.10-abcdef0-windows-x86_64"; + for (const [file, kind] of [ + [`${stem}.tar.gz`, "windows-bundle"], + [`${stem}-setup.exe`, "windows-installer"], + ] as const) { + const observation = capture(prefix + file); + await observation.done(); + expect(observation.events[0]).toMatchObject({ + product: "agent", + platform: "windows", + artifact: kind, + kind: "artifact", + version: "0.0.10", + }); + expect(isDownloadEvent(observation.events[0])).toBe(true); + expect( + isDownloadEvent({ ...observation.events[0], platform: "linux" }), + ).toBe(false); + expect( + isDownloadEvent({ ...observation.events[0], product: "research" }), + ).toBe(false); + if (kind === "windows-bundle") + expect(isDownloadEvent({ ...observation.events[0], kind: "alias" })).toBe( + false, + ); + } + const alias = capture( + "/download/agent/stable/windows", + new Response(null, { + status: 302, + headers: { location: prefix + stem + "-setup.exe" }, + }), + ); + await alias.done(); + expect(alias.events[0]).toMatchObject({ + kind: "alias", + platform: "windows", + artifact: "windows-installer", + version: "0.0.10", + }); + expect(isDownloadEvent(alias.events[0])).toBe(true); + for (const file of [ + "agent-windows-arm64.tar.gz", + "agent-windows-x86.tar.gz", + "agent-Windows-x86_64.tar.gz", + "agent-windows-x86_64-dev.tar.gz", + "agent-windows-x86_64.exe", + "agent-windows-x86_64.app.tar.gz", + ]) { + const observation = capture(prefix + file); + await observation.done(); + expect(observation.events).toHaveLength(0); + } +}); + test("captures original UTC timestamp and random observation ID without reading/replacing bytes or headers", async () => { const bytes = new Uint8Array([0, 255, 1, 128]); const response = new Response(bytes, { @@ -345,6 +404,7 @@ test("metadata capture never parses or rewrites bodies and needs no version asse ["/latest.json", "research", "latest", null, "all"], ["/agent/stable/latest.json", "agent", "latest", null, "macos"], ["/agent/stable/linux/latest.json", "agent", "latest", null, "linux"], + ["/agent/stable/windows/latest.json", "agent", "latest", null, "windows"], [ "/agent/stable/releases/0.0.9/manifest.json", "agent", @@ -359,6 +419,13 @@ test("metadata capture never parses or rewrites bodies and needs no version asse "0.0.9", "linux", ], + [ + "/agent/stable/releases/0.0.10/windows-manifest.json", + "agent", + "manifest", + "0.0.10", + "windows", + ], ] as const) { const observation = capture( path, diff --git a/services/agent-updates/src/windows-feed.test.ts b/services/agent-updates/src/windows-feed.test.ts new file mode 100644 index 000000000..adf3ab37d --- /dev/null +++ b/services/agent-updates/src/windows-feed.test.ts @@ -0,0 +1,359 @@ +import { expect, test } from "bun:test"; +import { fixture, PAYLOAD } from "./fixtures"; +import { linuxFixture } from "./linux-fixtures"; +import { parseEnvelope, parseManifest } from "./manifest"; +import { encodeWindowsFixture, windowsFixture } from "./windows-fixtures"; +import worker, { type Bucket } from "./worker"; + +test("Windows v3 parser agrees with the shared Rust manifest and signed envelope fixtures", async () => { + const fixtures = new URL( + "../../../apps/maple-agent/crates/maple-agent-updater/tests/fixtures/", + import.meta.url, + ); + const [manifestSource, envelopeSource] = await Promise.all([ + Bun.file(new URL("windows-v3-manifest.json", fixtures)).text(), + Bun.file(new URL("signed-windows-v3-envelope.json", fixtures)).text(), + ]); + const expected = JSON.parse(manifestSource); + const parsed = parseEnvelope(new TextEncoder().encode(envelopeSource)); + expect(parseManifest(expected)).toEqual(expected); + expect(parsed.manifest).toEqual(expected); + expect(parsed.envelope).toEqual(JSON.parse(envelopeSource)); + expect(parsed.envelope.manifest).toBe(manifestSource); +}); + +class MemoryBucket implements Bucket { + objects = new Map>(); + calls: string[] = []; + async get( + key: string, + options?: { range: { offset: number; length: number } }, + ) { + this.calls.push(`get:${key}`); + const bytes = this.objects.get(key); + if (!bytes) return null; + const range = options?.range; + return { + size: bytes.length, + httpEtag: '"windows-fixture"', + body: new Response( + range ? bytes.slice(range.offset, range.offset + range.length) : bytes, + ).body!, + }; + } + async head(key: string) { + this.calls.push(`head:${key}`); + const bytes = this.objects.get(key); + return bytes ? { size: bytes.length, httpEtag: '"windows-fixture"' } : null; + } +} + +function setup() { + const bucket = new MemoryBucket(); + const mac = fixture(true); + const linux = linuxFixture(); + const windows = windowsFixture(); + for (const [key, bytes] of [ + ["stable/latest.json", mac.bytes], + ["stable/linux/latest.json", linux.bytes], + ["stable/windows/latest.json", windows.bytes], + ["stable/releases/0.0.2/manifest.json", mac.bytes], + ["stable/releases/0.0.2/linux-manifest.json", linux.bytes], + ["stable/releases/0.0.2/windows-manifest.json", windows.bytes], + ] as const) + bucket.objects.set(key, bytes); + for (const manifest of [mac.manifest, linux.manifest, windows.manifest]) + for (const artifact of [ + ...Object.values(manifest.artifacts), + ...Object.values(manifest.downloads), + ]) + bucket.objects.set(artifact.path.replace("/agent/", ""), PAYLOAD); + const fetch = (path: string, init?: RequestInit) => + worker.fetch(new Request(`https://updates.example${path}`, init), { + AGENT_RELEASES: bucket, + }); + return { bucket, fetch, mac, linux, windows }; +} + +test("Windows discovery and installer aliases read only their exact uncached v3 pointer", async () => { + const { fetch, bucket, windows } = setup(); + for (const method of ["GET", "HEAD"]) { + bucket.calls = []; + const result = await fetch("/agent/stable/windows/latest.json", { method }); + expect(result.status).toBe(200); + expect(result.headers.get("cache-control")).toBe("no-store"); + expect(result.headers.get("cdn-cache-control")).toBe("no-store"); + expect(result.headers.get("content-length")).toBe( + String(windows.bytes.length), + ); + expect(new Uint8Array(await result.arrayBuffer())).toEqual( + method === "HEAD" ? new Uint8Array() : windows.bytes, + ); + expect(bucket.calls).toEqual(["get:stable/windows/latest.json"]); + bucket.calls = []; + const redirect = await fetch("/download/agent/stable/windows", { method }); + expect(redirect.status).toBe(302); + expect(redirect.headers.get("location")).toBe( + windows.manifest.downloads.windows.path, + ); + expect(redirect.headers.get("cache-control")).toBe("no-store"); + expect(redirect.headers.get("cdn-cache-control")).toBe("no-store"); + expect(bucket.calls).toEqual(["get:stable/windows/latest.json"]); + } + bucket.objects.delete("stable/windows/latest.json"); + expect((await fetch("/agent/stable/windows/latest.json")).status).toBe(404); + expect((await fetch("/download/agent/stable/windows")).status).toBe(404); + expect((await fetch("/agent/stable/latest.json")).status).toBe(200); + expect((await fetch("/agent/stable/linux/latest.json")).status).toBe(200); +}); + +test("Windows immutable metadata is byte exact and bound to the v3 requested version", async () => { + const { fetch, bucket, windows } = setup(); + for (const method of ["GET", "HEAD"]) { + bucket.calls = []; + const result = await fetch( + "/agent/stable/releases/0.0.2/windows-manifest.json", + { method }, + ); + expect(result.status).toBe(200); + expect(result.headers.get("cache-control")).toContain("immutable"); + expect(new Uint8Array(await result.arrayBuffer())).toEqual( + method === "HEAD" ? new Uint8Array() : windows.bytes, + ); + expect(bucket.calls).toEqual([ + "get:stable/releases/0.0.2/windows-manifest.json", + ]); + } + bucket.objects.set( + "stable/releases/0.0.3/windows-manifest.json", + windows.bytes, + ); + expect( + (await fetch("/agent/stable/releases/0.0.3/windows-manifest.json")).status, + ).toBe(503); +}); + +test("Windows discovery and immutable metadata refuse cross-format substitutions and corruption", async () => { + for (const key of [ + "stable/windows/latest.json", + "stable/releases/0.0.2/windows-manifest.json", + ]) { + for (const bad of [ + fixture().bytes, + linuxFixture().bytes, + new Uint8Array([0xff]), + ]) { + const { fetch, bucket } = setup(); + bucket.objects.set(key, bad); + const paths = key.endsWith("latest.json") + ? [ + "/agent/stable/windows/latest.json", + "/download/agent/stable/windows", + ] + : ["/agent/stable/releases/0.0.2/windows-manifest.json"]; + for (const path of paths) { + bucket.calls = []; + const result = await fetch(path); + expect(result.status).toBe(503); + expect(await result.text()).toBe("503\n"); + expect(bucket.calls).toEqual([`get:${key}`]); + } + } + } + for (const key of [ + "stable/latest.json", + "stable/linux/latest.json", + "stable/releases/0.0.2/manifest.json", + "stable/releases/0.0.2/linux-manifest.json", + ]) { + const { fetch, bucket, windows } = setup(); + bucket.objects.set(key, windows.bytes); + expect((await fetch(`/agent/${key}`)).status).toBe(503); + } +}); + +test("Windows bundle and installer stream only from their version's immutable signed enumeration", async () => { + const { fetch, bucket, windows } = setup(); + for (const artifact of [ + windows.manifest.artifacts["windows-x86_64"], + windows.manifest.downloads.windows, + ]) { + bucket.calls = []; + const result = await fetch(artifact.path); + expect(result.status).toBe(200); + expect(new Uint8Array(await result.arrayBuffer())).toEqual(PAYLOAD); + expect(result.headers.get("cache-control")).toContain("immutable"); + expect(bucket.calls).toEqual([ + "get:stable/releases/0.0.2/manifest.json", + "get:stable/releases/0.0.2/linux-manifest.json", + "get:stable/releases/0.0.2/windows-manifest.json", + `head:${artifact.path.replace("/agent/", "")}`, + `get:${artifact.path.replace("/agent/", "")}`, + ]); + const range = await fetch(artifact.path, { + headers: { range: "bytes=2-6" }, + }); + expect(range.status).toBe(206); + expect(range.headers.get("content-range")).toBe( + `bytes 2-6/${PAYLOAD.length}`, + ); + expect(new Uint8Array(await range.arrayBuffer())).toEqual( + PAYLOAD.slice(2, 7), + ); + expect( + (await fetch(artifact.path, { headers: { range: "bytes=9999-" } })) + .status, + ).toBe(416); + expect( + ( + await fetch(artifact.path, { + headers: { "if-none-match": '"windows-fixture"' }, + }) + ).status, + ).toBe(304); + const head = await fetch(artifact.path, { + method: "HEAD", + headers: { range: "bytes=2-6" }, + }); + expect(head.status).toBe(200); + expect(head.headers.get("content-length")).toBe(String(PAYLOAD.length)); + expect(head.headers.get("content-range")).toBeNull(); + expect(await head.text()).toBe(""); + } + const path = windows.manifest.artifacts["windows-x86_64"].path; + bucket.objects.delete("stable/releases/0.0.2/windows-manifest.json"); + expect((await fetch("/agent/stable/windows/latest.json")).status).toBe(200); + expect((await fetch(path)).status).toBe(404); +}); + +test("earlier platform hits do not consult Windows, while missing earlier manifests permit Windows-only releases", async () => { + const { fetch, bucket, mac, linux, windows } = setup(); + bucket.objects.set( + "stable/releases/0.0.2/windows-manifest.json", + new Uint8Array([0xff]), + ); + for (const path of [ + mac.manifest.artifacts["darwin-aarch64"].path, + linux.manifest.artifacts["linux-x86_64"].path, + ]) { + bucket.calls = []; + expect((await fetch(path)).status).toBe(200); + expect(bucket.calls).not.toContain( + "get:stable/releases/0.0.2/windows-manifest.json", + ); + } + bucket.objects.set( + "stable/releases/0.0.2/windows-manifest.json", + windows.bytes, + ); + bucket.objects.delete("stable/releases/0.0.2/manifest.json"); + bucket.objects.delete("stable/releases/0.0.2/linux-manifest.json"); + expect( + (await fetch(windows.manifest.artifacts["windows-x86_64"].path)).status, + ).toBe(200); + for (const key of [ + "stable/releases/0.0.2/manifest.json", + "stable/releases/0.0.2/linux-manifest.json", + ]) { + bucket.objects.set(key, new Uint8Array([0xff])); + bucket.calls = []; + expect((await fetch(windows.manifest.downloads.windows.path)).status).toBe( + 503, + ); + expect(bucket.calls).not.toContain( + "get:stable/releases/0.0.2/windows-manifest.json", + ); + bucket.objects.delete(key); + } +}); + +test("Windows routing leaves auxiliary objects private and rejects ambiguous paths and unsupported methods", async () => { + const { fetch, bucket } = setup(); + for (const file of [ + "windows-build-info.json", + "windows-SHA256SUMS", + "private.exe", + "private.tar.gz", + ]) { + bucket.objects.set(`stable/releases/0.0.2/${file}`, PAYLOAD); + bucket.calls = []; + expect((await fetch(`/agent/stable/releases/0.0.2/${file}`)).status).toBe( + 404, + ); + expect(bucket.calls.some((call) => call.endsWith(`/${file}`))).toBe(false); + } + for (const path of [ + "/agent/stable/windows/latest.json?x=y", + "/agent/stable/windows//latest.json", + "/agent/stable/windows%2flatest.json", + "/download/agent/stable/windows-x86_64", + ]) { + bucket.calls = []; + expect((await fetch(path)).status).toBe(404); + expect(bucket.calls).toEqual([]); + } + const result = await fetch("/agent/stable/windows/latest.json", { + method: "POST", + }); + expect(result.status).toBe(405); + expect(result.headers.get("allow")).toBe("GET, HEAD"); +}); + +test("v3 permits only Windows bundle and installer targets without changing v1 or v2 acceptance", () => { + expect(parseEnvelope(windowsFixture().bytes).manifest.format).toBe( + "maple-agent-update-v3", + ); + for (const mutate of [ + (v: any) => { + v.extra = true; + }, + (v: any) => { + delete v.artifacts["windows-x86_64"]; + }, + (v: any) => { + delete v.downloads.windows; + }, + (v: any) => { + v.artifacts["linux-x86_64"] = v.artifacts["windows-x86_64"]; + }, + (v: any) => { + v.downloads["linux-bundle"] = v.artifacts["windows-x86_64"]; + }, + (v: any) => { + v.downloads.macos = null; + }, + (v: any) => { + v.artifacts["windows-x86_64"].path += ".exe"; + }, + (v: any) => { + v.downloads.windows.path += ".tar.gz"; + }, + (v: any) => { + v.downloads.windows.path = "/agent/stable/releases/0.0.1/Maple-Agent.exe"; + }, + (v: any) => { + v.artifacts["windows-x86_64"].size = 0; + }, + (v: any) => { + v.downloads.windows.sha256 = "no"; + }, + (v: any) => { + v.artifacts["windows-x86_64"].extra = true; + }, + (v: any) => { + v.product = "research"; + }, + (v: any) => { + v.format = "maple-agent-update-v2"; + }, + ]) { + const { manifest } = windowsFixture(); + mutate(manifest); + expect(() => parseEnvelope(encodeWindowsFixture(manifest))).toThrow(); + } + for (const manifest of [fixture(true).manifest, linuxFixture().manifest]) { + expect(parseManifest(manifest)).toEqual(manifest); + (manifest as any).downloads.windows = null; + expect(() => parseManifest(manifest)).toThrow(); + } +}); diff --git a/services/agent-updates/src/windows-fixtures.ts b/services/agent-updates/src/windows-fixtures.ts new file mode 100644 index 000000000..c56fc0b57 --- /dev/null +++ b/services/agent-updates/src/windows-fixtures.ts @@ -0,0 +1,34 @@ +import type { ManifestV3 } from "./manifest"; +import { fixture, PAYLOAD } from "./fixtures"; + +export function encodeWindowsFixture( + manifest: ManifestV3, +): Uint8Array { + const envelope = JSON.parse(new TextDecoder().decode(fixture().bytes)); + envelope.manifest = JSON.stringify(manifest); + envelope.signature = envelope.signature.replace( + "version=0.0.2", + `version=${manifest.version}`, + ); + return new TextEncoder().encode(JSON.stringify(envelope)); +} + +export function windowsFixture(): { + manifest: ManifestV3; + bytes: Uint8Array; +} { + const artifact = (name: string) => ({ + path: `/agent/stable/releases/0.0.2/${name}`, + size: PAYLOAD.length, + sha256: "d".repeat(64), + }); + const manifest: ManifestV3 = { + ...fixture().manifest, + format: "maple-agent-update-v3", + artifacts: { + "windows-x86_64": artifact("Maple-Agent-windows-x86_64.tar.gz"), + }, + downloads: { windows: artifact("Maple-Agent-windows-x86_64-setup.exe") }, + }; + return { manifest, bytes: encodeWindowsFixture(manifest) }; +} diff --git a/services/agent-updates/src/worker.ts b/services/agent-updates/src/worker.ts index 8ddca65dd..1126d074b 100644 --- a/services/agent-updates/src/worker.ts +++ b/services/agent-updates/src/worker.ts @@ -220,6 +220,26 @@ const deliveryWorker = { ) return response(404, request.method); try { + if ( + path === "/agent/stable/windows/latest.json" || + path === "/download/agent/stable/windows" + ) { + const current = await metadata( + env.AGENT_RELEASES, + "stable/windows/latest.json", + "maple-agent-update-v3", + ); + if (!current) return response(404, request.method); + return path.startsWith("/download/") + ? new Response(null, { + status: 302, + headers: { + ...NO_STORE, + location: current.manifest.downloads.windows.path, + }, + }) + : metadataResponse(request, current.bytes); + } if ( path === "/agent/stable/linux/latest.json" || path === "/download/agent/stable/linux" @@ -274,18 +294,26 @@ const deliveryWorker = { const release = releasePath(path); if (!release) return response(404, request.method); // Explicit metadata routes never fall back to their sibling feed. Other - // paths are authorized only by exact enumeration in these two fixed keys. + // paths are authorized only by exact enumeration in these fixed keys. const candidates = release.file === "manifest.json" ? (["maple-agent-update-v1"] as const) : release.file === "linux-manifest.json" ? (["maple-agent-update-v2"] as const) - : (["maple-agent-update-v1", "maple-agent-update-v2"] as const); + : release.file === "windows-manifest.json" + ? (["maple-agent-update-v3"] as const) + : ([ + "maple-agent-update-v1", + "maple-agent-update-v2", + "maple-agent-update-v3", + ] as const); for (const format of candidates) { const file = format === "maple-agent-update-v1" ? "manifest.json" - : "linux-manifest.json"; + : format === "maple-agent-update-v2" + ? "linux-manifest.json" + : "windows-manifest.json"; const published = await metadata( env.AGENT_RELEASES, `stable/releases/${release.version}/${file}`, @@ -300,8 +328,9 @@ const deliveryWorker = { ...Object.values(published.manifest.artifacts), ...Object.values(published.manifest.downloads), ].find((value) => value.path === path); - // A v1 hit retains the shipped behavior without consulting Linux. A - // missing/nonmatching v1 permits v2; corrupt metadata never falls through. + // A hit retains shipped behavior without consulting later platform + // manifests. Missing/nonmatching earlier formats permit the next; + // corrupt consulted metadata never falls through. if (artifact) return await artifactResponse( request, diff --git a/services/download-tracking/README.md b/services/download-tracking/README.md index c70cc7190..3d89e2564 100644 --- a/services/download-tracking/README.md +++ b/services/download-tracking/README.md @@ -30,6 +30,17 @@ be a full response after an `If-Range` mismatch. `206` is a partial response, `416` is an unsatisfied range, and `304` transfers no payload. HEAD and metadata polls are also recorded separately. Probe exclusion is the reporting default. +Windows Agent v3 observations use platform `windows`. The native feed +`/agent/stable/windows/latest.json` and immutable `windows-manifest.json` are +metadata; `/download/agent/stable/windows` is redirect intent for the NSIS +`*-windows-x86_64-setup.exe` installer. The updater's separate +`*-windows-x86_64.tar.gz` payload is `windows-bundle`, while the `.exe` is +`windows-installer`. Windows-like filenames with a different profile are rejected +by the classifier rather than falling through to Linux's historical tar suffix. +Research Windows installer redirects retain their existing product and kind. +Tracking classifies observations only; it does not enable Windows publication or +authorize serving an artifact. + ## Availability and retry boundaries Capture runs under `waitUntil` after response construction and never awaits the @@ -153,6 +164,61 @@ The query token need not have queue-mutation permission. Use the existing separately authorized operations credential for replay; never broaden reporting access merely to drain a DLQ. +## Windows schema upgrade + +`0001_download_events.sql` is an immutable historical migration. The original +production schema was installed through the SQL API, so its presence does not +establish a Wrangler migration-history entry. The Windows change adds +`0002_windows_download_events.sql`; it rebuilds the table to extend the artifact +CHECK with `windows-bundle`, copies all fifteen columns, and recreates both +existing indexes. It neither resets nor prunes history. Fresh databases apply +both migrations in order. Existing production must apply **only 0002**. + +An authorized operator must perform the upgrade before activating Windows +tracking producers: + +1. Confirm the intended account/database from the current binding and existing + operations credential. Inspect the current table and two index definitions, + verify the complete expected 0001 schema, and confirm whether a migration + history table exists. Record a restorable backup/checkpoint and a private + sorted all-column snapshot or digest, plus row count, distinct UUID count, + probe count, nullable-version count, and earliest/latest timestamps. +2. Review the exact 0002 bytes. Execute its complete statements in **one D1 + transactional batch** through the SQL API, a trusted `D1Database.batch`, or + the pinned Wrangler command below. Never issue each statement as a separate + request. D1 owns the transaction; the file deliberately has no explicit + `BEGIN`/`COMMIT`. This needs the existing authorized D1-write operations + context, not a broadened reporting token. + + From the monorepo root, inside the pinned shell with that existing credential + environment already injected: + + ```sh + cd services/agent-updates + bunx wrangler d1 execute maple-downloads --remote \ + --command "$(cat migrations/0002_windows_download_events.sql)" + ``` + + The equivalent SQL API request is one `POST .../d1/database/ID/query` with + `batch: [{sql: statement1}, ...]` containing every statement from this reviewed + file, in order. Do not attach statement-wise retry logic. + +3. Verify exact preservation of every old row/UUID/probe/timestamp and both index + definitions. Confirm the new table CHECK includes `windows-bundle`, the + temporary `download_events_windows` table is absent, and all prior reports + still agree. Record the execution receipt and reviewed file hash privately. + If the response was ambiguous, inspect this state before any retry. +4. Deploy the matching consumer/classifier only through the separately authorized + rollout. Check a marked Windows observation and the unchanged Mac, Linux and + Research counters. A source migration test is not a production upgrade. + +Do **not** run blanket `wrangler d1 migrations apply` on this manually initialized +production database: without a baseline history entry it will try 0001 again. +Do not edit 0001, delete/recreate the database, or stamp migration history merely +to clear that error. This change preserves the existing history convention; +adopting Wrangler migration history later requires a separately reviewed baseline +after exact schema verification. + ## Local verification ```sh @@ -162,7 +228,9 @@ nix develop --no-update-lock-file .#ci -c python3 -m unittest discover -s servic Focused TypeScript tests cover response preservation, route privacy, queue/SQL outages, malformed events and real SQLite deduplication. The Agent service's runtime test exercises actual local workerd request β†’ Queue β†’ D1 delivery, -duplicate events and a missing-table outage. These are separate from production +duplicate events and a missing-table outage. It also applies the Windows table +rebuild to populated D1 storage, checks exact old-row/index preservation, and +injects a failure after the rebuild to prove whole-batch rollback. These are separate from production configuration, deployment and live collection verification. References: [D1 query API and accepted permissions](https://developers.cloudflare.com/api/resources/d1/subresources/database/methods/query/), @@ -170,3 +238,5 @@ References: [D1 query API and accepted permissions](https://developers.cloudflar [configurable retention](https://developers.cloudflare.com/changelog/post/2025-02-14-customize-queue-retention-period/), [dead-letter queues](https://developers.cloudflare.com/queues/configuration/dead-letter-queues/), [authenticated pull and acknowledgement](https://developers.cloudflare.com/queues/configuration/pull-consumers/). +Schema upgrade references: [D1 transactional batches](https://developers.cloudflare.com/d1/worker-api/d1-database/#batch), +[SQL import transaction compatibility](https://developers.cloudflare.com/d1/best-practices/import-export-data/#convert-sqlite-database-files). diff --git a/services/download-tracking/query.py b/services/download-tracking/query.py index a0e1e7c84..6e8c51dcc 100644 --- a/services/download-tracking/query.py +++ b/services/download-tracking/query.py @@ -19,7 +19,7 @@ CONFIG = Path(__file__).resolve().parents[1] / "agent-updates" / "wrangler.jsonc" UTC = timezone.utc ARTIFACTS = ( - "macos-dmg", "macos-updater", "windows-installer", "linux-bundle", + "macos-dmg", "macos-updater", "windows-installer", "windows-bundle", "linux-bundle", "linux-appimage", "linux-deb", "linux-rpm", "android-apk", "latest", "manifest", ) FILTERS = { diff --git a/services/download-tracking/test_query.py b/services/download-tracking/test_query.py index c366436a5..bf9f54079 100644 --- a/services/download-tracking/test_query.py +++ b/services/download-tracking/test_query.py @@ -79,6 +79,7 @@ def test_exact_readonly_sql_executes_on_migration_and_separates_kinds_probes_ran db = sqlite3.connect(":memory:") db.row_factory = sqlite3.Row db.executescript((Path(__file__).parents[1] / "agent-updates/migrations/0001_download_events.sql").read_text()) + db.executescript((Path(__file__).parents[1] / "agent-updates/migrations/0002_windows_download_events.sql").read_text()) base = ["2026-10-27T12:00:00.000Z", "agent", "updates.maple.ai", "0.0.9", "macos", "macos-dmg", "artifact", "GET", 200, "none", "success", 42, "cli", 0] for index, changes in enumerate(({}, {7: "HEAD"}, {8: 206, 9: "partial"}, {6: "alias", 8: 302, 10: "redirect"}, {13: 1})): @@ -100,6 +101,51 @@ def test_exact_readonly_sql_executes_on_migration_and_separates_kinds_probes_ran self.assertEqual(health, {"latest_observed_at": base[0], "persisted_events": 4, "utc_day_events": 4}) db.close() + def test_windows_upgrade_preserves_every_existing_field_and_index(self): + db = sqlite3.connect(":memory:") + db.row_factory = sqlite3.Row + migrations = Path(__file__).parents[1] / "agent-updates/migrations" + db.executescript((migrations / "0001_download_events.sql").read_text()) + insert = "INSERT INTO download_events VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)" + mac = ["11111111-1111-4111-8111-111111111111", "2026-10-27T12:00:00.000Z", "agent", "updates.maple.ai", "0.0.9", "macos", "macos-dmg", "artifact", "GET", 200, "none", "success", 42, "cli", 0] + linux_probe = ["22222222-2222-4222-8222-222222222222", "2026-10-27T12:00:01.000Z", "agent", "updates.trymaple.ai", "0.0.9", "linux", "linux-bundle", "artifact", "GET", 206, "partial", "success", 12, "maple", 1] + research = ["33333333-3333-4333-8333-333333333333", "2026-10-27T12:00:02.000Z", "research", "updates.trymaple.ai", None, "all", "latest", "metadata", "HEAD", 200, "none", "success", None, "browser", 0] + for values in (mac, linux_probe, research): + db.execute(insert, values) + db.commit() + before = [tuple(item) for item in db.execute("SELECT * FROM download_events ORDER BY event_id")] + windows = ["44444444-4444-4444-8444-444444444444", "2026-10-27T12:00:03.000Z", "agent", "updates.maple.ai", "0.0.10", "windows", "windows-bundle", "artifact", "GET", 200, "none", "success", 84, "maple", 1] + with self.assertRaises(sqlite3.IntegrityError): + db.execute(insert, windows) + db.rollback() + db.execute("BEGIN") + for statement in (migrations / "0002_windows_download_events.sql").read_text().split(";"): + if statement.strip(): + db.execute(statement) + db.commit() + self.assertEqual([tuple(item) for item in db.execute("SELECT * FROM download_events ORDER BY event_id")], before) + indexes = {item["name"] for item in db.execute("PRAGMA index_list(download_events)")} + self.assertTrue({"download_events_observed_at", "download_events_trends"}.issubset(indexes)) + with self.assertRaises(sqlite3.IntegrityError): + db.execute(insert, mac) + db.rollback() + db.execute(insert, windows) + installer = windows.copy() + installer[0] = "55555555-5555-4555-8555-555555555555" + installer[6] = "windows-installer" + installer[7] = "alias" + installer[9] = 302 + installer[11] = "redirect" + db.execute(insert, installer) + for artifact in ("windows-bundle", "windows-installer"): + sql, params, _ = query.query_spec(query.parse_args(["--platform", "windows", "--product", "agent", "--artifact", artifact, "--include-probes"]), NOW) + rows = [dict(item) for item in db.execute(sql, params)] + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0]["artifact"], artifact) + self.assertEqual(rows[0]["events"], 1) + self.assertNotIn(artifact, sql) + db.close() + def test_environment_only_credentials_and_fixed_private_endpoint(self): calls = [] def send(request): diff --git a/services/download-tracking/tracking.ts b/services/download-tracking/tracking.ts index d32f328e2..7e5f8a4d4 100644 --- a/services/download-tracking/tracking.ts +++ b/services/download-tracking/tracking.ts @@ -6,6 +6,7 @@ type Artifact = | "macos-dmg" | "macos-updater" | "windows-installer" + | "windows-bundle" | "linux-bundle" | "linux-appimage" | "linux-deb" @@ -89,6 +90,13 @@ function agentArtifact(path: string): Route | null { const file = match[2]; if (file.length > 200 || file.includes("..")) return null; const base = { version: match[1], kind: "artifact" as const }; + if (file.endsWith("-windows-x86_64.tar.gz")) + return { ...base, platform: "windows", artifact: "windows-bundle" }; + if (file.endsWith("-windows-x86_64-setup.exe")) + return { ...base, platform: "windows", artifact: "windows-installer" }; + // Wrong Windows profiles must not fall through to Linux's historical generic + // tar suffix. Classification does not authorize serving any artifact. + if (file.toLowerCase().includes("windows")) return null; if (file.endsWith(".app.tar.gz")) return { ...base, platform: "macos", artifact: "macos-updater" }; if (file.endsWith(".dmg")) @@ -103,6 +111,7 @@ function agentArtifact(path: string): Route | null { const AGENT_ALIASES: Record> = { macos: { platform: "macos", artifact: "macos-dmg" }, linux: { platform: "linux", artifact: "linux-bundle" }, + windows: { platform: "windows", artifact: "windows-installer" }, "linux-appimage": { platform: "linux", artifact: "linux-appimage" }, }; const RESEARCH_ALIASES: Record> = { @@ -167,23 +176,33 @@ function routeFor( if (product === "agent") { if ( path === "/agent/stable/latest.json" || - path === "/agent/stable/linux/latest.json" + path === "/agent/stable/linux/latest.json" || + path === "/agent/stable/windows/latest.json" ) return { kind: "metadata", version: null, - platform: path.includes("/linux/") ? "linux" : "macos", + platform: path.includes("/linux/") + ? "linux" + : path.includes("/windows/") + ? "windows" + : "macos", artifact: "latest", }; const manifest = - /^\/agent\/stable\/releases\/([^/]+)\/(linux-manifest|manifest)\.json$/.exec( + /^\/agent\/stable\/releases\/([^/]+)\/(linux-manifest|windows-manifest|manifest)\.json$/.exec( path, ); if (manifest && stableVersion(manifest[1])) return { kind: "metadata", version: manifest[1], - platform: manifest[2] === "linux-manifest" ? "linux" : "macos", + platform: + manifest[2] === "linux-manifest" + ? "linux" + : manifest[2] === "windows-manifest" + ? "windows" + : "macos", artifact: "manifest", }; const artifact = agentArtifact(path); @@ -363,6 +382,7 @@ export function isDownloadEvent(value: unknown): value is DownloadEvent { "macos-dmg", "macos-updater", "windows-installer", + "windows-bundle", "linux-bundle", "linux-appimage", "linux-deb", @@ -399,13 +419,14 @@ export function isDownloadEvent(value: unknown): value is DownloadEvent { "macos-dmg": ["macos"], "macos-updater": ["macos"], "windows-installer": ["windows"], + "windows-bundle": ["windows"], "linux-bundle": ["linux"], "linux-appimage": ["linux"], "linux-deb": ["linux"], "linux-rpm": ["linux"], "android-apk": ["android"], - latest: ["all", "macos", "linux"], - manifest: ["macos", "linux"], + latest: ["all", "macos", "linux", "windows"], + manifest: ["macos", "linux", "windows"], }; if ( !artifactPlatform[event.artifact as Artifact].includes( @@ -430,17 +451,17 @@ export function isDownloadEvent(value: unknown): value is DownloadEvent { if (event.product === "research") return ( event.kind === "alias" && - !["macos-updater", "linux-bundle"].includes(event.artifact as string) + !["macos-updater", "linux-bundle", "windows-bundle"].includes( + event.artifact as string, + ) ); if ( - ["windows-installer", "linux-deb", "linux-rpm", "android-apk"].includes( - event.artifact as string, - ) + ["linux-deb", "linux-rpm", "android-apk"].includes(event.artifact as string) ) return false; return event.kind === "artifact" ? event.version !== null - : event.artifact !== "macos-updater"; + : !["macos-updater", "windows-bundle"].includes(event.artifact as string); } const INSERT = `INSERT INTO download_events From 31eb3e3fb9f420d2135368d0a2fb4d9fc2122d8a Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Tue, 27 Oct 2026 05:18:21 +0000 Subject: [PATCH 2/6] Correct native Windows ownership and catalog admission --- .../installer/windows_bundle/authenticode.rs | 209 +++++++++++++++++- .../installer/windows_bundle/windows_fs.rs | 127 +++++++++-- apps/maple-agent/docs/updates.md | 8 + scripts/ci/windows-updater-tests.ps1 | 5 + 4 files changed, 316 insertions(+), 33 deletions(-) diff --git a/apps/maple-agent/crates/maple-agent-updater/src/installer/windows_bundle/authenticode.rs b/apps/maple-agent/crates/maple-agent-updater/src/installer/windows_bundle/authenticode.rs index 7076ec3aa..6c5fae1cd 100644 --- a/apps/maple-agent/crates/maple-agent-updater/src/installer/windows_bundle/authenticode.rs +++ b/apps/maple-agent/crates/maple-agent-updater/src/installer/windows_bundle/authenticode.rs @@ -4,7 +4,7 @@ use std::{ ffi::c_void, - fs::OpenOptions, + fs::{File, OpenOptions}, io::{Read, Seek, SeekFrom}, mem::size_of, os::windows::{ffi::OsStrExt, fs::MetadataExt, fs::OpenOptionsExt, io::AsRawHandle}, @@ -12,9 +12,20 @@ use std::{ }; use windows_sys::Win32::{ - Foundation::{GetLastError, INVALID_HANDLE_VALUE, SetLastError, TRUST_E_NOSIGNATURE}, + Foundation::{ + CRYPT_E_NOT_FOUND, ERROR_INVALID_DATA, ERROR_INVALID_HANDLE, ERROR_NOT_FOUND, GetLastError, + INVALID_HANDLE_VALUE, SetLastError, TRUST_E_NOSIGNATURE, + }, Security::{ - Cryptography::{CERT_NAME_ATTR_TYPE, CertGetNameStringW, szOID_COMMON_NAME}, + Cryptography::{ + BCRYPT_SHA1_ALGORITHM, BCRYPT_SHA256_ALGORITHM, CERT_NAME_ATTR_TYPE, + Catalog::{ + CryptCATAdminAcquireContext2, CryptCATAdminCalcHashFromFileHandle2, + CryptCATAdminEnumCatalogFromHash, CryptCATAdminReleaseCatalogContext, + CryptCATAdminReleaseContext, + }, + CertGetNameStringW, szOID_COMMON_NAME, + }, WinTrust::{ CRYPT_PROVIDER_SGNR, SGNR_TYPE_TIMESTAMP, WINTRUST_ACTION_GENERIC_VERIFY_V2, WINTRUST_DATA, WINTRUST_DATA_0, WINTRUST_FILE_INFO, WTD_CACHE_ONLY_URL_RETRIEVAL, @@ -82,6 +93,144 @@ impl Drop for TrustState { } } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum CatalogLookup { + Absent, + Present, + Error(u32), +} + +fn catalog_lookup_result(catalog: isize, error: u32) -> CatalogLookup { + if catalog != 0 { + CatalogLookup::Present + } else if error == 0 || error == ERROR_NOT_FOUND || error == CRYPT_E_NOT_FOUND as u32 { + // EnumCatalogFromHash documents NULL as exhaustion. Preserve an actual + // error rather than treating every NULL return as an unsigned file. + CatalogLookup::Absent + } else { + CatalogLookup::Error(error) + } +} + +struct CatalogState { + admin: isize, + catalog: isize, +} + +impl Drop for CatalogState { + fn drop(&mut self) { + // SAFETY: these handles come only from their matching CryptCAT APIs. + // Catalog state must be released before its administrator context. + unsafe { + if self.catalog != 0 { + CryptCATAdminReleaseCatalogContext(self.admin, self.catalog, 0); + } + if self.admin != 0 { + CryptCATAdminReleaseContext(self.admin, 0); + } + } + } +} + +fn catalog_membership(file: &File, algorithm: *const u16, expected_size: u32) -> CatalogLookup { + let mut state = CatalogState { + admin: 0, + catalog: 0, + }; + // NULL subsystem requests global catalog finds, not just driver catalogs. + // The policy is NULL because this is presence detection, not a new way to + // trust signed code. A weak/untrusted catalog still excludes unsigned Dev. + if unsafe { + CryptCATAdminAcquireContext2( + &mut state.admin, + std::ptr::null(), + algorithm, + std::ptr::null(), + 0, + ) + } == 0 + { + return CatalogLookup::Error(unsafe { GetLastError() }); + } + if state.admin == 0 { + return CatalogLookup::Error(ERROR_INVALID_HANDLE); + } + let mut reader = file; + if let Err(error) = reader.seek(SeekFrom::Start(0)) { + return CatalogLookup::Error( + error.raw_os_error().unwrap_or(ERROR_INVALID_DATA as i32) as u32 + ); + } + let mut length = 0; + // SAFETY: the file remains open without write/delete sharing. The context + // selects the exact algorithm; the first call obtains a bounded hash size. + if unsafe { + CryptCATAdminCalcHashFromFileHandle2( + state.admin, + file.as_raw_handle(), + &mut length, + std::ptr::null_mut(), + 0, + ) + } == 0 + { + return CatalogLookup::Error(unsafe { GetLastError() }); + } + if length != expected_size || length > 32 { + return CatalogLookup::Error(ERROR_INVALID_DATA); + } + if let Err(error) = reader.seek(SeekFrom::Start(0)) { + return CatalogLookup::Error( + error.raw_os_error().unwrap_or(ERROR_INVALID_DATA as i32) as u32 + ); + } + let mut hash = [0u8; 32]; + // SAFETY: length was checked against both the algorithm and buffer bounds. + if unsafe { + CryptCATAdminCalcHashFromFileHandle2( + state.admin, + file.as_raw_handle(), + &mut length, + hash.as_mut_ptr(), + 0, + ) + } == 0 + { + return CatalogLookup::Error(unsafe { GetLastError() }); + } + if length != expected_size { + return CatalogLookup::Error(ERROR_INVALID_DATA); + } + // SAFETY: context and immutable calculated hash remain live. We need only + // the first result; its ownership transfers directly into the close guard. + let error = unsafe { + SetLastError(0); + state.catalog = CryptCATAdminEnumCatalogFromHash( + state.admin, + hash.as_ptr(), + length, + 0, + std::ptr::null_mut(), + ); + GetLastError() + }; + catalog_lookup_result(state.catalog, error) +} + +fn catalog_memberships(file: &File) -> [CatalogLookup; 2] { + // Documented Windows catalog v2/v1 member algorithms. This checks local + // system catalog membership; it does not inspect unregistered external .cat + // files or introduce catalog signatures as an accepted Maple payload format. + [ + catalog_membership(file, BCRYPT_SHA256_ALGORITHM, 32), + catalog_membership(file, BCRYPT_SHA1_ALGORITHM, 20), + ] +} + +fn no_catalog_membership(lookups: [CatalogLookup; 2]) -> bool { + lookups == [CatalogLookup::Absent; 2] +} + fn publisher_matches(name: &[u16]) -> bool { name.len() > 1 && name.last() == Some(&0) @@ -215,7 +364,8 @@ fn verify_for_purpose(path: &Path, allow_unsigned: bool, purpose: Purpose) -> bo (status, GetLastError()) }; if allow_unsigned && status == TRUST_E_NOSIGNATURE && reason == TRUST_E_NOSIGNATURE as u32 { - return pe_has_no_certificate(&mut &file); + return pe_has_no_certificate(&mut &file) + && no_catalog_membership(catalog_memberships(&file)); } // WinVerifyTrust returns LONG, not HRESULT. Only exactly zero is success. if status != 0 || state.0.hWVTStateData.is_null() { @@ -289,6 +439,26 @@ unsafe fn valid_timestamp(timestamp: *const CRYPT_PROVIDER_SGNR) -> bool { mod tests { use super::*; + #[test] + fn catalog_presence_and_lookup_errors_never_enable_unsigned_dev() { + assert!(no_catalog_membership([CatalogLookup::Absent; 2])); + for error in [0, ERROR_NOT_FOUND, CRYPT_E_NOT_FOUND as u32] { + assert_eq!(catalog_lookup_result(0, error), CatalogLookup::Absent); + assert_eq!(catalog_lookup_result(1, error), CatalogLookup::Present); + } + for error in [5, ERROR_INVALID_DATA, ERROR_INVALID_HANDLE, 1062, u32::MAX] { + assert_eq!(catalog_lookup_result(0, error), CatalogLookup::Error(error)); + } + for rejected in [ + CatalogLookup::Present, + CatalogLookup::Error(0), + CatalogLookup::Error(5), + ] { + assert!(!no_catalog_membership([rejected, CatalogLookup::Absent])); + assert!(!no_catalog_membership([CatalogLookup::Absent, rejected])); + } + } + #[test] fn purpose_provider_flags_pin_startup_offline_and_admission_revocation() { assert_eq!(Purpose::Admission.provider_flags(), 0x2080); @@ -397,15 +567,36 @@ mod tests { assert!(verify_startup_or_unsigned(&executable, true)); } + fn windows_powershell_fixture() -> std::path::PathBuf { + std::path::PathBuf::from(std::env::var_os("SystemRoot").unwrap()) + .join("System32") + .join("WindowsPowerShell") + .join("v1.0") + .join("powershell.exe") + } + + #[test] + fn native_catalog_lookup_distinguishes_unsigned_and_windows_signed_executables() { + let unsigned = File::open(std::env::current_exe().unwrap()).unwrap(); + let unsigned_memberships = catalog_memberships(&unsigned); + assert_eq!(unsigned_memberships, [CatalogLookup::Absent; 2]); + + // The native harness independently verifies Microsoft's OS signature. + // This same fixed fixture caught the original embedded-only bypass. + let signed = File::open(windows_powershell_fixture()).unwrap(); + let signed_memberships = catalog_memberships(&signed); + assert!( + signed_memberships.contains(&CatalogLookup::Present), + "expected a registered Windows catalog: {signed_memberships:?}" + ); + assert!(!no_catalog_membership(signed_memberships)); + } + #[test] fn native_microsoft_publisher_is_rejected_even_for_unsigned_dev() { // Native qualification separately proves this fixed Windows binary has // a valid Microsoft signature; another publisher cannot be a Dev bypass. - let executable = std::path::PathBuf::from(std::env::var_os("SystemRoot").unwrap()) - .join("System32") - .join("WindowsPowerShell") - .join("v1.0") - .join("powershell.exe"); + let executable = windows_powershell_fixture(); assert!(executable.is_file()); assert!(!verify(&executable)); assert!(!verify_or_unsigned(&executable, true)); diff --git a/apps/maple-agent/crates/maple-agent-updater/src/installer/windows_bundle/windows_fs.rs b/apps/maple-agent/crates/maple-agent-updater/src/installer/windows_bundle/windows_fs.rs index a20bc398d..f44008779 100644 --- a/apps/maple-agent/crates/maple-agent-updater/src/installer/windows_bundle/windows_fs.rs +++ b/apps/maple-agent/crates/maple-agent-updater/src/installer/windows_bundle/windows_fs.rs @@ -58,16 +58,22 @@ const WRITE_RIGHTS: u32 = GENERIC_ALL | FILE_DELETE_CHILD; fn invalid() -> io::Error { + invalid_at("unspecified-guard") +} + +// Fixed operation labels make native qualification failures actionable without +// exposing filesystem paths, user names, SIDs, ACL contents or other state. +fn invalid_at(stage: &'static str) -> io::Error { io::Error::new( io::ErrorKind::PermissionDenied, - "unsafe Windows updater storage", + format!("unsafe Windows updater storage ({stage})"), ) } fn wide(value: &OsStr) -> io::Result> { let mut result: Vec = value.encode_wide().collect(); if result.contains(&0) || result.len() > 32_000 { - return Err(invalid()); + return Err(invalid_at("path-encoding-or-length")); } result.push(0); Ok(result) @@ -81,14 +87,14 @@ fn checked_components(path: &Path) -> io::Result> { if !matches!(prefix, Component::Prefix(p) if matches!(p.kind(), Prefix::Disk(_) | Prefix::VerbatimDisk(_))) || !matches!(components.next(), Some(Component::RootDir)) { - return Err(invalid()); + return Err(invalid_at("nonlocal-or-relative-path")); } let mut current = PathBuf::from(prefix.as_os_str()); current.push("\\"); let mut paths = vec![current.clone()]; for part in components { let Component::Normal(name) = part else { - return Err(invalid()); + return Err(invalid_at("nonnormal-path-component")); }; let name = name.to_str().ok_or_else(invalid)?; // Windows aliases (trailing space/dot), ADS and reserved device names @@ -118,7 +124,7 @@ fn checked_components(path: &Path) -> io::Result> { }) }) { - return Err(invalid()); + return Err(invalid_at("ambiguous-path-component")); } current.push(name); paths.push(current.clone()); @@ -181,7 +187,7 @@ fn validate_path_binding(path: &Path, file: &File) -> io::Result<()> { second.nFileIndexHigh, second.nFileIndexLow, ) { - return Err(invalid()); + return Err(invalid_at("opened-path-identity-mismatch")); } Ok(()) } @@ -191,10 +197,11 @@ fn information(file: &File) -> io::Result { if unsafe { GetFileInformationByHandle(file.as_raw_handle().cast(), &mut info) } == 0 { return Err(io::Error::last_os_error()); } - if info.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT != 0 - || (info.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY == 0 && info.nNumberOfLinks != 1) - { - return Err(invalid()); + if info.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return Err(invalid_at("reparse-point")); + } + if info.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY == 0 && info.nNumberOfLinks != 1 { + return Err(invalid_at("file-hardlink-count")); } Ok(info) } @@ -210,7 +217,7 @@ fn ancestors(path: &Path, include_leaf: bool) -> io::Result> { let info = information(&file)?; if path != paths.last().unwrap() || !include_leaf { if info.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY == 0 { - return Err(invalid()); + return Err(invalid_at("ancestor-not-directory")); } } guards.push(file); @@ -316,6 +323,10 @@ fn same_sid(a: PSID, b: PSID) -> bool { !a.is_null() && !b.is_null() && unsafe { EqualSid(a, b) } != 0 } +fn trusted_storage_sid(sid: PSID, user: PSID, system: PSID, admins: PSID) -> bool { + same_sid(sid, user) || same_sid(sid, system) || same_sid(sid, admins) +} + fn validate_private_handle(file: &File) -> io::Result<()> { information(file)?; let user = CurrentUser::new()?; @@ -340,8 +351,23 @@ fn validate_private_handle(file: &File) -> io::Result<()> { return Err(io::Error::from_raw_os_error(code as i32)); } let _allocation = Local(descriptor); - if !same_sid(owner, user.sid()) || dacl.is_null() || unsafe { IsValidAcl(dacl) } == 0 { - return Err(invalid()); + // TokenOwner may be Administrators for an elevated process even when + // TokenUser is the signed-in user. Owners have implicit WRITE_DAC, so the + // owner must belong to the exact same authority set as allowed writers. + // Explicit updater-created objects continue to specify O:. + if !trusted_storage_sid( + owner, + user.sid(), + system.as_ptr().cast_mut().cast(), + admins.as_ptr().cast_mut().cast(), + ) { + return Err(invalid_at("owner-outside-trusted-set")); + } + if dacl.is_null() { + return Err(invalid_at("null-dacl")); + } + if unsafe { IsValidAcl(dacl) } == 0 { + return Err(invalid_at("invalid-dacl")); } let mut size = ACL_SIZE_INFORMATION::default(); if unsafe { @@ -354,12 +380,12 @@ fn validate_private_handle(file: &File) -> io::Result<()> { } == 0 || size.AceCount > 1024 { - return Err(invalid()); + return Err(invalid_at("dacl-information-or-count")); } for index in 0..size.AceCount { let mut raw = null_mut(); if unsafe { GetAce(dacl, index, &mut raw) } == 0 || raw.is_null() { - return Err(invalid()); + return Err(invalid_at("dacl-ace-read")); } let header = unsafe { &*raw.cast::() }; if header.AceFlags as u32 & INHERIT_ONLY_ACE != 0 { @@ -368,10 +394,11 @@ fn validate_private_handle(file: &File) -> io::Result<()> { if header.AceType as u32 == ACCESS_DENIED_ACE_TYPE { continue; } - if header.AceType as u32 != ACCESS_ALLOWED_ACE_TYPE - || (header.AceSize as usize) < size_of::() + 4 - { - return Err(invalid()); + if header.AceType as u32 != ACCESS_ALLOWED_ACE_TYPE { + return Err(invalid_at("unsupported-effective-ace-type")); + } + if (header.AceSize as usize) < size_of::() + 4 { + return Err(invalid_at("short-allowed-ace")); } let ace = unsafe { &*raw.cast::() }; let sid = (&ace.SidStart as *const u32).cast_mut().cast::(); @@ -381,14 +408,17 @@ fn validate_private_handle(file: &File) -> io::Result<()> { || 8 + 8 + subauthorities * 4 > header.AceSize as usize || unsafe { IsValidSid(sid) } == 0 { - return Err(invalid()); + return Err(invalid_at("ace-sid-bounds-or-validity")); } if ace.Mask & WRITE_RIGHTS != 0 - && !same_sid(sid, user.sid()) - && !same_sid(sid, system.as_ptr().cast_mut().cast()) - && !same_sid(sid, admins.as_ptr().cast_mut().cast()) + && !trusted_storage_sid( + sid, + user.sid(), + system.as_ptr().cast_mut().cast(), + admins.as_ptr().cast_mut().cast(), + ) { - return Err(invalid()); + return Err(invalid_at("foreign-write-ace")); } } Ok(()) @@ -621,6 +651,55 @@ pub(super) fn atomic_replace(source: &Path, destination: &Path) -> io::Result<() mod tests { use super::*; + #[test] + fn native_owner_allowlist_rejects_unprivileged_sids() { + use windows_sys::Win32::Security::{WinBuiltinUsersSid, WinWorldSid}; + + let user = CurrentUser::new().unwrap(); + let system = known_sid(WinLocalSystemSid).unwrap(); + let admins = known_sid(WinBuiltinAdministratorsSid).unwrap(); + let users = known_sid(WinBuiltinUsersSid).unwrap(); + let everyone = known_sid(WinWorldSid).unwrap(); + let allowed = |sid| { + trusted_storage_sid( + sid, + user.sid(), + system.as_ptr().cast_mut().cast(), + admins.as_ptr().cast_mut().cast(), + ) + }; + assert!(allowed(user.sid())); + assert!(allowed(system.as_ptr().cast_mut().cast())); + assert!(allowed(admins.as_ptr().cast_mut().cast())); + assert!(!allowed(users.as_ptr().cast_mut().cast())); + assert!(!allowed(everyone.as_ptr().cast_mut().cast())); + assert!(!allowed(null_mut())); + } + + #[test] + fn native_default_owner_storage_and_named_temp_atomic_replace() { + // Default Windows ownership is TokenOwner, which can be the exact + // Administrators SID under an elevated native runner/installer. + let temporary = tempfile::tempdir().unwrap(); + private_directory(temporary.path()).unwrap(); + let destination = temporary.path().join("state.json"); + fs::write(&destination, b"previous").unwrap(); + assert_eq!( + read_bounded(&destination, 64).unwrap().unwrap(), + b"previous" + ); + let mut pending = tempfile::NamedTempFile::new_in(temporary.path()).unwrap(); + std::io::Write::write_all(&mut pending, b"replacement").unwrap(); + pending.as_file().sync_all().unwrap(); + let pending = pending.into_temp_path(); + atomic_replace(&pending, &destination).unwrap(); + assert_eq!( + read_bounded(&destination, 64).unwrap().unwrap(), + b"replacement" + ); + assert!(!pending.exists()); + } + #[test] fn refuses_nonlocal_and_ambiguous_windows_names() { for value in [ diff --git a/apps/maple-agent/docs/updates.md b/apps/maple-agent/docs/updates.md index 6c6108fb2..babf91d9b 100644 --- a/apps/maple-agent/docs/updates.md +++ b/apps/maple-agent/docs/updates.md @@ -163,6 +163,14 @@ generation. Native checks reject reparse points, hardlinks, unsupported paths and directories writable by another unprivileged account. The updater does not repair unsafe ownership or permissions. +Storage ownership must belong to the current user, SYSTEM or the built-in +Administrators group, the same principals permitted to write its private DACL. +This admits Windows' elevated default owner without admitting another +unprivileged owner or writer. Native-created private objects explicitly select +the user owner. Unsigned Dev previews require both an absent embedded PE +certificate and no registered local SHA-256/SHA-1 catalog membership; a +catalog-signed system file cannot be treated as an unsigned Dev executable. + Restart uses native process arguments and waits for the retiring process's exact PID and creation time. It does not construct a PowerShell command from the installation path. If relaunch fails, reopen the registered launcher; diff --git a/scripts/ci/windows-updater-tests.ps1 b/scripts/ci/windows-updater-tests.ps1 index 440366483..ce9337f9a 100644 --- a/scripts/ci/windows-updater-tests.ps1 +++ b/scripts/ci/windows-updater-tests.ps1 @@ -85,6 +85,7 @@ try { throw 'The unrelated signed Windows fixture is unavailable or has another identity.' } $receipt.unrelated_os_signature_valid = $true + $receipt.unrelated_os_signature_type = [string]$signature.SignatureType Push-Location $component try { @@ -105,6 +106,8 @@ try { 'installer::windows_bundle::authenticode::tests::pe_certificate_directory_must_be_completely_absent', 'installer::windows_bundle::authenticode::tests::purpose_provider_flags_pin_startup_offline_and_admission_revocation', 'installer::windows_bundle::authenticode::tests::native_trust_configuration_keeps_startup_and_admission_separate', + 'installer::windows_bundle::authenticode::tests::catalog_presence_and_lookup_errors_never_enable_unsigned_dev', + 'installer::windows_bundle::authenticode::tests::native_catalog_lookup_distinguishes_unsigned_and_windows_signed_executables', 'installer::windows_bundle::windows_fs::tests::refuses_nonlocal_and_ambiguous_windows_names', 'installer::windows_bundle::windows_fs::tests::private_storage_round_trip_and_atomic_replacement', 'installer::windows_bundle::windows_fs::tests::rejects_hard_linked_state_and_preserves_oversized_file', @@ -112,6 +115,8 @@ try { 'installer::windows_bundle::windows_fs::tests::rejects_file_and_ancestor_reparse_points_when_symlink_privilege_available', 'installer::windows_bundle::windows_fs::tests::native_mutation_guard_blocks_loader_until_released', 'installer::windows_bundle::windows_fs::tests::native_shared_read_validation_accepts_mapped_image_and_mutation_guard', + 'installer::windows_bundle::windows_fs::tests::native_owner_allowlist_rejects_unprivileged_sids', + 'installer::windows_bundle::windows_fs::tests::native_default_owner_storage_and_named_temp_atomic_replace', 'installer::windows_bundle::tests::exact_archive_round_trip_binds_all_nine_files_and_three_directories', 'installer::windows_bundle::tests::rejected_archive_never_begins_extraction', 'installer::windows_bundle::tests::descriptors_bind_profile_product_release_and_strict_json_fields', From 4e9984b1512583506c0363aa61faadb889fc58cb Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Tue, 27 Oct 2026 05:32:25 +0000 Subject: [PATCH 3/6] Qualify Windows locks against their native admission contract --- .../maple-agent-updater/src/installer.rs | 8 +++- .../windows_bundle_transaction_tests.rs | 42 +++++++++++++++++-- scripts/ci/test_windows_updater_workflow.py | 1 + scripts/ci/windows-updater-tests.ps1 | 3 +- 4 files changed, 47 insertions(+), 7 deletions(-) diff --git a/apps/maple-agent/crates/maple-agent-updater/src/installer.rs b/apps/maple-agent/crates/maple-agent-updater/src/installer.rs index 3b43be606..2d06783e0 100644 --- a/apps/maple-agent/crates/maple-agent-updater/src/installer.rs +++ b/apps/maple-agent/crates/maple-agent-updater/src/installer.rs @@ -2459,8 +2459,12 @@ mod tests { } } -#[cfg(test)] -mod portable_lock_tests { +// This fixture exercises the Unix bundle path contract. Windows public lock +// admission additionally requires a registered, signed versioned installation; +// its native storage/locking primitive is exercised by the required Windows +// native_install_lock_rejects_concurrent_writers test. +#[cfg(all(test, unix))] +mod unix_lock_tests { use super::*; #[test] diff --git a/apps/maple-agent/crates/maple-agent-updater/src/installer/windows_bundle_transaction_tests.rs b/apps/maple-agent/crates/maple-agent-updater/src/installer/windows_bundle_transaction_tests.rs index 186a25018..8886f6e74 100644 --- a/apps/maple-agent/crates/maple-agent-updater/src/installer/windows_bundle_transaction_tests.rs +++ b/apps/maple-agent/crates/maple-agent-updater/src/installer/windows_bundle_transaction_tests.rs @@ -218,14 +218,48 @@ fn native_transaction_pointer_lock_failure_is_retryable() { fixture.assert_published(); } +#[test] +fn native_public_install_lock_refuses_unadmitted_root_without_pointer_or_lock() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path().join("unadmitted-installation"); + windows_fs::private_directory(&root).unwrap(); + // No registry/profile/signature fixture is forged: this deliberately fails + // the public installed-product admission boundary before opening its lock. + assert!(acquire_install_lock(&root).is_err()); + for name in [ + POINTER, + ".install.lock", + "maple-agent.exe", + "uninstall.exe", + DESCRIPTOR, + ] { + assert!(!root.join(name).exists(), "admission created {name}"); + } + // Admission may establish its private structural prefix, but must not + // stage or change payload bytes for an unadmitted installation. + for entry in fs::read_dir(&root).unwrap() { + let entry = entry.unwrap(); + assert_eq!(entry.file_name(), "versions"); + assert!(entry.file_type().unwrap().is_dir()); + assert!(fs::read_dir(entry.path()).unwrap().next().is_none()); + } +} + #[test] fn native_install_lock_rejects_concurrent_writers() { let fixture = TransactionFixture::new(); - let path = fixture.root.join(".install.lock"); - let first = windows_fs::open_private(&path, false).unwrap(); - let second = windows_fs::open_private(&path, false).unwrap(); + // Exercise the real Windows lock path and private-handle admission. The + // fixture supplies ordinary bytes, so it cannot satisfy the independent + // signed/registered public installation precondition. + let first = open_install_lock(&fixture.root).unwrap(); + let second = open_install_lock(&fixture.root).unwrap(); fs2::FileExt::try_lock_exclusive(&first).unwrap(); - assert!(fs2::FileExt::try_lock_exclusive(&second).is_err()); + let error = fs2::FileExt::try_lock_exclusive(&second).unwrap_err(); + assert_eq!( + error.raw_os_error(), + fs2::lock_contended_error().raw_os_error(), + "production contention classification must recognize the native error" + ); fs2::FileExt::unlock(&first).unwrap(); fs2::FileExt::try_lock_exclusive(&second).unwrap(); fs2::FileExt::unlock(&second).unwrap(); diff --git a/scripts/ci/test_windows_updater_workflow.py b/scripts/ci/test_windows_updater_workflow.py index 1a0a96d33..db40ed932 100644 --- a/scripts/ci/test_windows_updater_workflow.py +++ b/scripts/ci/test_windows_updater_workflow.py @@ -86,6 +86,7 @@ def test_failures_retain_evidence_and_do_not_claim_signed_lifecycle_qualificatio self.assertIn("'--locked'", script) self.assertIn("'--test', 'windows_bootstrap_native'", script) self.assertIn("'--test-threads=1'", script) + self.assertIn("'--locked', '--no-fail-fast', '--lib'", script) self.assertIn("Get-AuthenticodeSignature -LiteralPath $unrelated", script) self.assertIn("A required native Windows test group is missing", script) self.assertIn("without skipped tests", script) diff --git a/scripts/ci/windows-updater-tests.ps1 b/scripts/ci/windows-updater-tests.ps1 index ce9337f9a..c27bae71d 100644 --- a/scripts/ci/windows-updater-tests.ps1 +++ b/scripts/ci/windows-updater-tests.ps1 @@ -129,6 +129,7 @@ try { 'installer::windows_bundle::transaction_tests::native_transaction_interruption_before_pointer_keeps_previous', 'installer::windows_bundle::transaction_tests::native_transaction_pointer_lock_failure_is_retryable', 'installer::windows_bundle::transaction_tests::native_install_lock_rejects_concurrent_writers', + 'installer::windows_bundle::transaction_tests::native_public_install_lock_refuses_unadmitted_root_without_pointer_or_lock', 'installer::windows_bundle::transaction_tests::native_pending_manual_intent_preserves_current_generation', 'installer::windows_bundle::transaction_tests::native_completed_manual_intent_recovers_after_pointer_commit', 'installer::windows_bundle::transaction_tests::native_live_matching_manual_intent_is_never_cleared', @@ -159,7 +160,7 @@ try { } $receipt.discovered_tests = $testNames.Count $runPath = Invoke-CargoStep 'native-tests.log' @( - 'test', '-p', 'maple-agent-updater', '--locked', '--lib', + 'test', '-p', 'maple-agent-updater', '--locked', '--no-fail-fast', '--lib', '--test', 'windows_bootstrap_native', '--', '--test-threads=1' ) $summaries = @(Get-Content -LiteralPath $runPath | Where-Object { $_ -match '^test result:' }) From 63cdb12a99ab4d07c4ad4338dcd170a33f076f01 Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Tue, 27 Oct 2026 05:54:58 +0000 Subject: [PATCH 4/6] Preserve strict macOS build-number CLI validation --- apps/maple-agent/scripts/release-info.py | 2 +- .../scripts/test_release_packaging.py | 19 ++++++++++++++++--- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/apps/maple-agent/scripts/release-info.py b/apps/maple-agent/scripts/release-info.py index 46109ae3e..d1557c0f6 100755 --- a/apps/maple-agent/scripts/release-info.py +++ b/apps/maple-agent/scripts/release-info.py @@ -358,7 +358,7 @@ def main(): raise ValueError("field is not public release metadata") print(info[args.field]) elif args.operation == "plist": - write_plist(info, args.output, args.build_number or "1") + write_plist(info, args.output, "1" if args.build_number is None else args.build_number) elif args.operation == "manifest": write_manifest(info, args.output, args.platform, args.unsigned, args.build_number) diff --git a/apps/maple-agent/scripts/test_release_packaging.py b/apps/maple-agent/scripts/test_release_packaging.py index bb28631aa..881c406f8 100644 --- a/apps/maple-agent/scripts/test_release_packaging.py +++ b/apps/maple-agent/scripts/test_release_packaging.py @@ -532,11 +532,24 @@ def test_release_plist_has_identity_permissions_and_no_shell_environment(self): def test_macos_plist_cli_preserves_its_default_build_number(self): output = self.directory / "Info.plist" - subprocess.run( + for arguments, expected in (([], "1"), (["--build-number", "42"], "42")): + with self.subTest(arguments=arguments): + subprocess.run( + [sys.executable, str(SCRIPTS / "release-info.py"), "plist", "dev", str(self.path), + "--output", str(output), *arguments], check=True, capture_output=True, text=True, + ) + self.assertEqual(plistlib.loads(output.read_bytes())["CFBundleVersion"], expected) + + def test_macos_plist_cli_rejects_an_explicitly_empty_build_number(self): + output = self.directory / "Info.plist" + result = subprocess.run( [sys.executable, str(SCRIPTS / "release-info.py"), "plist", "dev", str(self.path), - "--output", str(output)], check=True, capture_output=True, text=True, + "--output", str(output), "--build-number", ""], capture_output=True, text=True, ) - self.assertEqual(plistlib.loads(output.read_bytes())["CFBundleVersion"], "1") + self.assertNotEqual(result.returncode, 0) + self.assertIn("package build number must be a positive integer", result.stderr) + self.assertFalse(output.exists()) + self.assertFalse((self.directory / "Resources").exists()) def package(self, unsigned=True): (self.directory / "fixture.dmg").write_bytes(b"package") From 27b519345ddea3992725fcbf0651065d1ad09dca Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Tue, 27 Oct 2026 06:05:07 +0000 Subject: [PATCH 5/6] Initialize Windows restart state in desktop fixtures --- apps/maple-agent/app/src/desktop/maintenance_tests.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/maple-agent/app/src/desktop/maintenance_tests.rs b/apps/maple-agent/app/src/desktop/maintenance_tests.rs index ba1e3d886..8ca919ebe 100644 --- a/apps/maple-agent/app/src/desktop/maintenance_tests.rs +++ b/apps/maple-agent/app/src/desktop/maintenance_tests.rs @@ -730,6 +730,8 @@ fn close_lifecycle(host: &FixtureHost) -> DesktopLifecycle { quit_completed: Default::default(), quit_requested: Default::default(), frontend_detached: Default::default(), + #[cfg(target_os = "windows")] + update_restart_pending: Default::default(), } } @@ -784,6 +786,8 @@ fn pending_quit_cannot_finish_an_update_installation(cx: &mut gpui::TestAppConte quit_completed: Default::default(), quit_requested: Default::default(), frontend_detached: Default::default(), + #[cfg(target_os = "windows")] + update_restart_pending: Default::default(), }); updater.set_test_state(crate::update::State::Ready); assert!(may_finish_quit(cx)); From 3ea360dc7646ae190ecc7dcda3de6349299a58ad Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Tue, 27 Oct 2026 06:16:09 +0000 Subject: [PATCH 6/6] Typecheck Windows application targets independently of release builds --- .../workflows/agent-windows-updater-tests.yml | 61 ++++++++++++++++ docs/repository-workflows.md | 7 ++ scripts/ci/test_windows_updater_workflow.py | 71 +++++++++++++------ scripts/ci/windows-app-typecheck.ps1 | 63 ++++++++++++++++ 4 files changed, 180 insertions(+), 22 deletions(-) create mode 100644 scripts/ci/windows-app-typecheck.ps1 diff --git a/.github/workflows/agent-windows-updater-tests.yml b/.github/workflows/agent-windows-updater-tests.yml index 1be5bc5da..88fbace76 100644 --- a/.github/workflows/agent-windows-updater-tests.yml +++ b/.github/workflows/agent-windows-updater-tests.yml @@ -10,6 +10,7 @@ on: paths: - ".github/workflows/agent-windows-updater-tests.yml" - "scripts/ci/windows-updater-tests.ps1" + - "scripts/ci/windows-app-typecheck.ps1" - "scripts/ci/test_windows_updater_workflow.py" - "scripts/ci/agent_windows.py" - "apps/maple-agent/Cargo.toml" @@ -22,6 +23,7 @@ on: - "apps/maple-agent/app/src/profile.rs" - "apps/maple-agent/app/src/update.rs" - "apps/maple-agent/app/src/desktop.rs" + - "apps/maple-agent/app/src/desktop/**" - "apps/maple-agent/app/src/windows_bootstrap.rs" - "apps/maple-agent/scripts/windows-release.py" - "apps/maple-agent/scripts/windows-installer.nsi" @@ -31,6 +33,7 @@ on: paths: - ".github/workflows/agent-windows-updater-tests.yml" - "scripts/ci/windows-updater-tests.ps1" + - "scripts/ci/windows-app-typecheck.ps1" - "scripts/ci/test_windows_updater_workflow.py" - "scripts/ci/agent_windows.py" - "apps/maple-agent/Cargo.toml" @@ -43,6 +46,7 @@ on: - "apps/maple-agent/app/src/profile.rs" - "apps/maple-agent/app/src/update.rs" - "apps/maple-agent/app/src/desktop.rs" + - "apps/maple-agent/app/src/desktop/**" - "apps/maple-agent/app/src/windows_bootstrap.rs" - "apps/maple-agent/scripts/windows-release.py" - "apps/maple-agent/scripts/windows-installer.nsi" @@ -54,6 +58,63 @@ concurrency: cancel-in-progress: true jobs: + app-typecheck: + name: Windows app and test target typecheck + runs-on: windows-2025 + timeout-minutes: 30 + env: + CARGO_TERM_COLOR: never + CARGO_INCREMENTAL: 0 + CARGO_PROFILE_DEV_DEBUG: 0 + RUSTFLAGS: -D warnings + steps: + - name: Checkout the tested source + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false + - name: Install the repository's pinned Windows Rust + uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 + with: + toolchain: 1.98.0 + - name: Select Windows MSVC and SDK + uses: ilammy/msvc-dev-cmd@a102174a2b586eec2ea151a69e6fd14404a8ce7c + with: + arch: x64 + - name: Select native Windows Perl for the static crypto build + shell: bash + run: | + native_perl=/c/Strawberry/perl/bin/perl.exe + if [ ! -x "$native_perl" ]; then + echo 'Native Windows Perl is missing from the runner' >&2 + exit 1 + fi + "$native_perl" -MLocale::Maketext::Simple -MIPC::Cmd -e 'die "OpenSSL requires native Windows Perl\n" unless $^O eq "MSWin32"; print "$^V\n"' + native_perl_windows="$(cygpath -m "$native_perl")" + printf 'OPENSSL_SRC_PERL=%s\n' "$native_perl_windows" >> "$GITHUB_ENV" + - name: Pin and smoke-test the selected Windows linker + working-directory: apps/maple-agent + shell: pwsh + run: python3 ../../scripts/ci/agent_windows.py + - name: Select the SDK shader compiler for GPUI + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + if (-not $env:WindowsSdkVerBinPath) { throw 'Selected Windows SDK binary path is missing' } + $fxc = Join-Path $env:WindowsSdkVerBinPath 'x64/fxc.exe' + if (-not (Test-Path -LiteralPath $fxc -PathType Leaf)) { throw 'Selected SDK shader compiler is missing' } + "GPUI_FXC_PATH=$fxc" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + - name: Typecheck the Windows application and every app test target + shell: pwsh + run: ./scripts/ci/windows-app-typecheck.ps1 + - name: Retain the typecheck receipt and complete Cargo log + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: windows-app-typecheck-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/maple-windows-app-typecheck/ + if-no-files-found: error + retention-days: 14 + native-updater: name: Native Windows updater component tests runs-on: windows-2025 diff --git a/docs/repository-workflows.md b/docs/repository-workflows.md index 4b8ac9d39..41d1ca6cc 100644 --- a/docs/repository-workflows.md +++ b/docs/repository-workflows.md @@ -160,6 +160,13 @@ target does not replace the real Windows application build, official Maple signature verification, signed installed A-to-B upgrade, visible GUI or paired remote upgrade qualification. +The same workflow independently typechecks `maple-agent-app --all-targets` on +Windows with `cargo check --locked`, including Windows-only desktop fixtures. +It runs in parallel with native updater tests and retains its own source/run +receipt and complete compiler log. This catches app/test type errors without +release code generation or waiting for release LTO. A successful typecheck does +not claim test execution, packaging or installed-upgrade qualification. + The separate manual-only `agent-windows-signature-probe.yml` accepts an exact historical producer run/attempt/source and artifact ZIP/PE digests. It validates successful trusted-master Windows Prod build, signing and downloaded-consumer diff --git a/scripts/ci/test_windows_updater_workflow.py b/scripts/ci/test_windows_updater_workflow.py index db40ed932..42599ce7a 100644 --- a/scripts/ci/test_windows_updater_workflow.py +++ b/scripts/ci/test_windows_updater_workflow.py @@ -25,34 +25,34 @@ def test_native_tests_have_no_release_or_signing_authority(self): self.assertEqual(config["permissions"], {"contents": "read"}) self.assertEqual(set(config["on"]), {"pull_request", "push", "workflow_dispatch"}) self.assertEqual(config["on"]["push"]["branches"], ["master"]) - self.assertEqual(set(config["jobs"]), {"native-updater"}) - job = config["jobs"]["native-updater"] - self.assertEqual(job["runs-on"], "windows-2025") - self.assertNotIn("environment", job) - self.assertNotIn("secrets", job) - self.assertNotIn("permissions", job) - self.assertNotIn("strategy", job) - self.assertLessEqual(job["timeout-minutes"], 35) + self.assertEqual(set(config["jobs"]), {"native-updater", "app-typecheck"}) + for name, job in config["jobs"].items(): + self.assertEqual(job["runs-on"], "windows-2025") + for authority in ("environment", "secrets", "permissions", "strategy", "needs"): + self.assertNotIn(authority, job) + self.assertLessEqual(job["timeout-minutes"], 30 if name == "app-typecheck" else 35) + commands = "\n".join(step.get("run", "") for step in job["steps"]) + self.assertNotRegex(commands, r"(?:gh\s+(?:release|workflow)|wrangler|publisher\.py|windows-sign-file|artifact-sign|cargo\s+publish)") self.assertNotIn("secrets.", json.dumps(config)) self.assertNotIn("id-token", json.dumps(config)) - commands = "\n".join(step.get("run", "") for step in job["steps"]) - self.assertNotRegex(commands, r"(?:gh\s+(?:release|workflow)|wrangler|publisher\.py|windows-sign-file|artifact-sign|cargo\s+publish)") def test_native_toolchain_matches_existing_windows_pin_and_avoids_artifact_cache(self): - steps = workflow()["jobs"]["native-updater"]["steps"] - actions = [step["uses"] for step in steps if "uses" in step] - self.assertTrue(all(re.fullmatch(r"[^@]+@[0-9a-f]{40}", action) for action in actions)) - self.assertFalse(any("cache" in action or "download-artifact" in action for action in actions)) - checkout = next(step for step in steps if step.get("uses", "").startswith("actions/checkout@")) - self.assertIs(checkout["with"]["persist-credentials"], False) - rust = next(step for step in steps if step.get("uses", "").startswith("dtolnay/rust-toolchain@")) existing = next(step for step in workflow("agent-desktop-build.yml")["jobs"]["windows-build"]["steps"] if step.get("uses", "").startswith("dtolnay/rust-toolchain@")) - self.assertEqual(rust["uses"], existing["uses"]) - self.assertEqual(rust["with"]["toolchain"], existing["with"]["toolchain"]) - linker = next(step for step in steps if "agent_windows.py" in step.get("run", "")) - self.assertEqual(linker["working-directory"], "apps/maple-agent") - self.assertEqual(linker["shell"], "pwsh") + for job in workflow()["jobs"].values(): + steps = job["steps"] + actions = [step["uses"] for step in steps if "uses" in step] + self.assertTrue(all(re.fullmatch(r"[^@]+@[0-9a-f]{40}", action) for action in actions)) + self.assertFalse(any("cache" in action or "download-artifact" in action for action in actions)) + checkout = next(step for step in steps if step.get("uses", "").startswith("actions/checkout@")) + self.assertIs(checkout["with"]["persist-credentials"], False) + rust = next(step for step in steps if step.get("uses", "").startswith("dtolnay/rust-toolchain@")) + self.assertEqual(rust["uses"], existing["uses"]) + self.assertEqual(rust["with"]["toolchain"], existing["with"]["toolchain"]) + linker = next(step for step in steps if "agent_windows.py" in step.get("run", "")) + self.assertEqual(linker["working-directory"], "apps/maple-agent") + self.assertEqual(linker["shell"], "pwsh") + steps = workflow()["jobs"]["native-updater"]["steps"] test = next(step for step in steps if "windows-updater-tests.ps1" in step.get("run", "")) self.assertEqual(test["shell"], "pwsh") self.assertNotIn("continue-on-error", test) @@ -61,6 +61,7 @@ def test_relevant_contract_changes_select_native_tests_without_research_packagin required = { ".github/workflows/agent-windows-updater-tests.yml", "scripts/ci/windows-updater-tests.ps1", + "scripts/ci/windows-app-typecheck.ps1", "scripts/ci/test_windows_updater_workflow.py", "apps/maple-agent/crates/maple-agent-updater/**", "apps/maple-agent/Cargo.toml", "apps/maple-agent/Cargo.lock", @@ -68,6 +69,7 @@ def test_relevant_contract_changes_select_native_tests_without_research_packagin "apps/maple-agent/release_profile.rs", "apps/maple-agent/release-profiles.json", "apps/maple-agent/app/src/main.rs", "apps/maple-agent/app/src/update.rs", "apps/maple-agent/app/src/windows_bootstrap.rs", "apps/maple-agent/app/src/desktop.rs", + "apps/maple-agent/app/src/desktop/**", "apps/maple-agent/scripts/windows-release.py", "apps/maple-agent/scripts/windows-installer.nsi", } for event in ("push", "pull_request"): @@ -75,6 +77,31 @@ def test_relevant_contract_changes_select_native_tests_without_research_packagin self.assertTrue(required <= paths) self.assertFalse(any(path.startswith(("apps/maple-research/", "services/opensecret/")) for path in paths)) + def test_app_typecheck_covers_windows_test_fixtures_without_release_codegen(self): + job = workflow()["jobs"]["app-typecheck"] + commands = "\n".join(step.get("run", "") for step in job["steps"]) + self.assertIn("OPENSSL_SRC_PERL", commands) + self.assertIn("MSWin32", commands) + self.assertIn("GPUI_FXC_PATH", commands) + self.assertIn("x64/fxc.exe", commands) + check = next(step for step in job["steps"] if "windows-app-typecheck.ps1" in step.get("run", "")) + self.assertEqual(check["shell"], "pwsh") + self.assertNotIn("continue-on-error", check) + script = (ROOT / "scripts/ci/windows-app-typecheck.ps1").read_text() + self.assertIn("@('check', '-p', 'maple-agent-app', '--all-targets', '--locked')", script) + self.assertNotRegex(script, r"cargo\s+(?:build|test|run)|--release|--no-default-features") + self.assertIn("$receipt.cargo_exit_code -ne 0", script) + self.assertIn("$receipt.app_all_targets_typechecked = $true", script) + for field in ("runtime_tests_executed", "app_code_generated", "release_package_qualified"): + self.assertRegex(script, rf"{field}\s*=\s*\$false") + self.assertNotRegex(script, rf"{field}\s*=\s*\$true") + for binding in ("GITHUB_SHA", "GITHUB_RUN_ID", "GITHUB_RUN_ATTEMPT"): + self.assertIn(binding, script) + upload = next(step for step in job["steps"] if step.get("uses", "").startswith("actions/upload-artifact@")) + self.assertEqual(upload["if"], "always()") + self.assertEqual(upload["with"]["if-no-files-found"], "error") + self.assertIn("github.run_attempt", upload["with"]["name"]) + def test_failures_retain_evidence_and_do_not_claim_signed_lifecycle_qualification(self): upload = next(step for step in workflow()["jobs"]["native-updater"]["steps"] if step.get("uses", "").startswith("actions/upload-artifact@")) diff --git a/scripts/ci/windows-app-typecheck.ps1 b/scripts/ci/windows-app-typecheck.ps1 new file mode 100644 index 000000000..3a4b16591 --- /dev/null +++ b/scripts/ci/windows-app-typecheck.ps1 @@ -0,0 +1,63 @@ +param() + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version 3.0 +$PSNativeCommandUseErrorActionPreference = $false + +if ([System.Environment]::OSVersion.Platform -ne [System.PlatformID]::Win32NT) { + throw 'Application typechecking requires native Windows.' +} +if (-not $env:RUNNER_TEMP -or $env:GITHUB_SHA -notmatch '^[0-9a-f]{40}$' -or + -not $env:GITHUB_RUN_ID -or -not $env:GITHUB_RUN_ATTEMPT -or -not $env:GITHUB_REPOSITORY) { + throw 'The isolated Actions typecheck context is missing.' +} +$repository = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../..')) +$report = Join-Path $env:RUNNER_TEMP 'maple-windows-app-typecheck' +New-Item -ItemType Directory -Path $report -Force | Out-Null +$receiptPath = Join-Path $report 'receipt.json' +$logPath = Join-Path $report 'cargo-check.log' +$receipt = [ordered]@{ + schema = 'maple-windows-app-typecheck-v1' + source = $env:GITHUB_SHA + repository = $env:GITHUB_REPOSITORY + run_id = $env:GITHUB_RUN_ID + run_attempt = $env:GITHUB_RUN_ATTEMPT + started_at = [DateTimeOffset]::UtcNow.ToString('o') + platform = 'x86_64-pc-windows-msvc' + package = 'maple-agent-app' + all_targets = $true + result = 'running' + app_all_targets_typechecked = $false + runtime_tests_executed = $false + app_code_generated = $false + release_package_qualified = $false +} +$receipt | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $receiptPath -Encoding utf8 + +Push-Location (Join-Path $repository 'apps/maple-agent') +try { + $identity = (& rustc -vV) -join "`n" + if ($LASTEXITCODE -ne 0 -or + $identity -notmatch '(?m)^release: 1\.98\.0$' -or + $identity -notmatch '(?m)^host: x86_64-pc-windows-msvc$') { + throw 'The Windows typecheck toolchain differs from the repository pin.' + } + $receipt.rust_release = '1.98.0' + $arguments = @('check', '-p', 'maple-agent-app', '--all-targets', '--locked') + & cargo @arguments *> $logPath + $receipt.cargo_exit_code = $LASTEXITCODE + if ($receipt.cargo_exit_code -ne 0) { + Get-Content -LiteralPath $logPath -Tail 100 + throw 'Windows app all-targets typechecking failed.' + } + $receipt.app_all_targets_typechecked = $true + $receipt.result = 'pass' + Get-Content -LiteralPath $logPath -Tail 10 +} catch { + $receipt.result = 'fail' + throw +} finally { + $receipt.completed_at = [DateTimeOffset]::UtcNow.ToString('o') + $receipt | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $receiptPath -Encoding utf8 + Pop-Location +}