From 0a6fb8b3d77fbea96634acb9876142a93fd4993d Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Fri, 23 Oct 2026 05:11:03 +0000 Subject: [PATCH] feat(agent): ship portable Linux installs and signed updates --- .agents/skills/develop-maple-agent/SKILL.md | 8 +- .github/workflows/agent-desktop-build.yml | 57 +- .github/workflows/agent-updates-publish.yml | 4 +- apps/maple-agent/Cargo.lock | 16 +- apps/maple-agent/Cargo.toml | 2 +- apps/maple-agent/README.md | 16 +- apps/maple-agent/app/build.rs | 4 +- apps/maple-agent/app/src/desktop.rs | 20 +- apps/maple-agent/app/src/host.rs | 3 +- apps/maple-agent/app/src/main.rs | 132 +++- apps/maple-agent/app/src/self_launch.rs | 5 + apps/maple-agent/app/src/update.rs | 382 ++++++++++-- .../examples/validate-release-archive.rs | 31 +- .../crates/maple-agent-updater/src/client.rs | 14 +- .../maple-agent-updater/src/installer.rs | 119 +++- .../src/installer/linux_bundle.rs | 590 ++++++++++++++++++ .../src/installer/linux_bundle_tests.rs | 520 +++++++++++++++ .../maple-agent-updater/src/manifest.rs | 105 +++- .../crates/maple-agent-updater/src/tests.rs | 373 ++++++++++- .../tests/fixtures/legacy-v1-validator.rs | 252 ++++++++ .../tests/fixtures/linux-v2-contract.md | 9 + .../tests/fixtures/linux-v2-manifest.json | 1 + .../fixtures/signed-linux-v2-envelope.json | 1 + .../maple-agent/crates/maple-agent/src/lib.rs | 1 + .../crates/maple-agent/src/self_launch.rs | 417 +++++++++++++ .../crates/maple-agent/src/store/integrity.rs | 6 +- apps/maple-agent/docs/desktop-builds.md | 75 ++- apps/maple-agent/docs/release-profiles.md | 16 +- apps/maple-agent/docs/updates.md | 66 +- apps/maple-agent/justfile | 3 + apps/maple-agent/release-profiles.json | 2 + apps/maple-agent/release_profile.rs | 55 +- apps/maple-agent/scripts/linux-install.py | 266 ++++++++ apps/maple-agent/scripts/linux-install.sh | 9 + .../scripts/linux-release-appimage.py | 27 +- .../scripts/linux-release-bundle.py | 250 ++++++++ .../scripts/linux-release-bundle.sh | 4 + apps/maple-agent/scripts/package-release.sh | 9 +- apps/maple-agent/scripts/release-info.py | 67 +- .../scripts/smoke-linux-bundle-host.sh | 123 ++++ .../maple-agent/scripts/test_linux_install.py | 251 ++++++++ .../scripts/test_linux_release_bundle.py | 252 ++++++++ .../scripts/test_macos_build_info.py | 6 +- .../scripts/test_release_packaging.py | 201 +++++- .../scripts/test_smoke_linux_bundle_host.py | 242 +++++++ .../scripts/test_smoke_macos_release.py | 6 +- apps/maple-agent/scripts/verify-release.sh | 41 +- .../scripts/verify-updater-archive.sh | 51 +- flake.nix | 3 + scripts/ci/test_agent_updates_workflows.py | 3 + scripts/ci/test_agent_workflows.py | 71 ++- scripts/ci/test_nix_workflows.py | 4 +- services/agent-updates/README.md | 33 +- services/agent-updates/publisher/README.md | 56 +- services/agent-updates/publisher/publisher.py | 341 ++++++---- .../agent-updates/publisher/test_minisign.py | 27 + .../agent-updates/publisher/test_publisher.py | 291 ++++++++- .../agent-updates/publisher/test_trust.py | 14 +- services/agent-updates/src/linux-feed.test.ts | 410 ++++++++++++ services/agent-updates/src/linux-fixtures.ts | 32 + services/agent-updates/src/manifest.ts | 41 +- services/agent-updates/src/runtime.test.ts | 52 +- services/agent-updates/src/worker.ts | 122 ++-- 63 files changed, 6174 insertions(+), 436 deletions(-) create mode 100644 apps/maple-agent/app/src/self_launch.rs create mode 100644 apps/maple-agent/crates/maple-agent-updater/src/installer/linux_bundle.rs create mode 100644 apps/maple-agent/crates/maple-agent-updater/src/installer/linux_bundle_tests.rs create mode 100644 apps/maple-agent/crates/maple-agent-updater/tests/fixtures/legacy-v1-validator.rs create mode 100644 apps/maple-agent/crates/maple-agent-updater/tests/fixtures/linux-v2-contract.md create mode 100644 apps/maple-agent/crates/maple-agent-updater/tests/fixtures/linux-v2-manifest.json create mode 100644 apps/maple-agent/crates/maple-agent-updater/tests/fixtures/signed-linux-v2-envelope.json create mode 100644 apps/maple-agent/crates/maple-agent/src/self_launch.rs create mode 100644 apps/maple-agent/scripts/linux-install.py create mode 100755 apps/maple-agent/scripts/linux-install.sh create mode 100644 apps/maple-agent/scripts/linux-release-bundle.py create mode 100755 apps/maple-agent/scripts/linux-release-bundle.sh create mode 100644 apps/maple-agent/scripts/smoke-linux-bundle-host.sh create mode 100644 apps/maple-agent/scripts/test_linux_install.py create mode 100644 apps/maple-agent/scripts/test_linux_release_bundle.py create mode 100644 apps/maple-agent/scripts/test_smoke_linux_bundle_host.py create mode 100644 services/agent-updates/src/linux-feed.test.ts create mode 100644 services/agent-updates/src/linux-fixtures.ts diff --git a/.agents/skills/develop-maple-agent/SKILL.md b/.agents/skills/develop-maple-agent/SKILL.md index 5e1385278..c5fc350ae 100644 --- a/.agents/skills/develop-maple-agent/SKILL.md +++ b/.agents/skills/develop-maple-agent/SKILL.md @@ -99,9 +99,11 @@ ownership at the layer implementing the effect. Never treat a passing source import or a native login screen as authenticated chat or containment proof. Packaged Agent Prod uses its independently signed R2 update feed with compiled -primary and backup public-key pins. Packaged Prod checks the compiled feed; -publication remains disabled pending owner service setup. Invitations still -require the signed installed-update rehearsal. +primary and backup public-key pins. Packaged Prod checks the compiled platform +feed; publication is a separate protected operation. Invitations require the +platform-specific signed installed-update rehearsal. Linux uses user-owned tar +bundles and a separate v2 wire schema, preserving the shipped macOS v1 feed; +one release may update both platforms to the same version. Dev and unpackaged builds have no production updater. Empty-key profiles still disable update traffic. Do not substitute a test key or inherit feed/key overrides from the launch environment. Embedded profiles, bundle IDs and state namespaces diff --git a/.github/workflows/agent-desktop-build.yml b/.github/workflows/agent-desktop-build.yml index 007e298fb..558472f2a 100644 --- a/.github/workflows/agent-desktop-build.yml +++ b/.github/workflows/agent-desktop-build.yml @@ -269,7 +269,7 @@ jobs: retention-days: 30 linux: - name: Linux AppImage (${{ matrix.variant }}) + name: Linux bundle (${{ matrix.variant }}) needs: changes if: ${{ always() && !cancelled() && (needs.changes.result != 'success' || needs.changes.outputs.agent != 'false') && (github.event_name == 'pull_request' || (github.repository == 'MaplePrivacyLabs/Maple' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch'))) }} runs-on: blacksmith-8vcpu-ubuntu-2404 @@ -311,7 +311,7 @@ jobs: cp "$RUNNER_TEMP/agent-environment.json" target/ci-metrics/environment.json - name: Build optimized Agent with embedded profile run: nix develop --no-update-lock-file . -c python3 ../../scripts/ci/agent_measure.py --phase release-build --target target -- ./scripts/build-release.sh "$MAPLE_RELEASE_PROFILE" - - name: Package Linux AppImage + - name: Package Linux installed bundle run: | args=() if [ "$GITHUB_EVENT_NAME" = pull_request ]; then args=(--unsigned); fi @@ -320,7 +320,7 @@ jobs: run: | args=() if [ "$GITHUB_EVENT_NAME" = pull_request ]; then args=(--unsigned); fi - nix develop --no-update-lock-file . -c ./scripts/verify-release.sh "$MAPLE_RELEASE_PROFILE" "dist/$MAPLE_RELEASE_PROFILE" "${args[@]}" + nix develop --no-update-lock-file . -c ./scripts/verify-release.sh "$MAPLE_RELEASE_PROFILE" "dist/$MAPLE_RELEASE_PROFILE" "${args[@]}" --platform linux-x86_64 - name: Upload Agent packages uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: @@ -351,16 +351,36 @@ jobs: id-token: write attestations: write runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 60 strategy: fail-fast: false matrix: variant: [dev, prod] + env: + MAPLE_RELEASE_PROFILE: ${{ matrix.variant }} steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + ref: ${{ github.sha }} + persist-credentials: false + - name: Install pinned Nix + uses: DeterminateSystems/determinate-nix-action@96c5ee93a9dddf2830d31e1da6ec719077336c28 # v3.23.3 + with: + source-tag: v3.23.3 + github-token: "" + - name: Verify pinned Nix runtime + working-directory: . + run: bash scripts/ci/verify-nix-runtime.sh + - name: Build trusted installer archive scanner + run: nix develop --no-update-lock-file . -c cargo build --locked -p maple-agent-updater --example validate-release-archive - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 with: name: maple-agent-${{ matrix.variant }}-linux-x86_64-${{ github.run_id }} path: artifacts + - name: Validate exact Linux artifact inventory and identity + run: python3 scripts/release-info.py verify "$MAPLE_RELEASE_PROFILE" ../../artifacts --source-sha "$GITHUB_SHA" --platform linux-x86_64 + - name: Scan actual Linux bundle with installer parser + run: nix develop --no-update-lock-file . -c ./scripts/verify-updater-archive.sh ../../artifacts --profile "$MAPLE_RELEASE_PROFILE" --platform linux-x86_64 - name: Attest exact downloadable Linux packages uses: actions/attest@281a49d4cbb0a72c9575a50d18f6deb515a11deb with: @@ -472,26 +492,37 @@ jobs: done env: GH_TOKEN: ${{ github.token }} + - name: Build trusted installer archive scanner + run: nix develop --no-update-lock-file . -c cargo build --locked -p maple-agent-updater --example validate-release-archive + - name: Scan downloaded Linux bundle with installer parser + run: | + args=() + if [ "$GITHUB_EVENT_NAME" = pull_request ]; then args=(--unsigned); fi + nix develop --no-update-lock-file . -c ./scripts/verify-updater-archive.sh "../../artifacts" --profile "$MAPLE_RELEASE_PROFILE" --platform linux-x86_64 "${args[@]}" - name: Verify downloaded hashes and profile run: | args=() if [ "$GITHUB_EVENT_NAME" = pull_request ]; then args=(--unsigned); fi - nix develop --no-update-lock-file . -c ./scripts/verify-release.sh "$MAPLE_RELEASE_PROFILE" "../../artifacts" "${args[@]}" - - name: Smoke downloaded AppImage without Nix + nix develop --no-update-lock-file . -c ./scripts/verify-release.sh "$MAPLE_RELEASE_PROFILE" "../../artifacts" "${args[@]}" --platform linux-x86_64 + - name: Smoke downloaded Linux bundle without Nix working-directory: . run: | set -euo pipefail - image="$(find artifacts -maxdepth 1 -type f -name '*.AppImage')" - test -n "$image" - test "$(printf '%s\n' "$image" | wc -l)" -eq 1 + shopt -s nullglob + archives=(artifacts/*-linux-x86_64.tar.gz) + test "${#archives[@]}" -eq 1 + test -d /etc/ssl/certs docker run --rm --platform linux/amd64 --user 65534:65534 \ --network none --read-only --tmpfs /tmp:exec,mode=1777 \ --volume "$PWD/artifacts:/artifacts:ro" \ + --volume "$PWD/apps/maple-agent/scripts/smoke-linux-bundle-host.sh:/checks/smoke-linux-bundle-host.sh:ro" \ + --volume /etc/ssl/certs:/etc/ssl/certs:ro \ ubuntu@sha256:f610ab94648195aa356059f5b41d6085c9d4d903c072430cdd1af7bdb646106b /bin/sh -ec ' cd /tmp - /artifacts/*.AppImage --appimage-extract >/dev/null - ./squashfs-root/AppRun --version >&2 - ./squashfs-root/AppRun --build-info + tar -xzpf /artifacts/*-linux-x86_64.tar.gz + ./maple-agent*.app/AppRun --version >&2 + ./maple-agent*.app/AppRun --build-info + MAPLE_AGENT_SMOKE_DIAGNOSTICS=1 /bin/sh /checks/smoke-linux-bundle-host.sh ./maple-agent*.app/AppRun >&2 ' > "$RUNNER_TEMP/portable-build-info.json" python3 - "$MAPLE_RELEASE_PROFILE" "$RUNNER_TEMP/portable-build-info.json" artifacts/build-info.json <<'PY' import json @@ -499,6 +530,6 @@ jobs: actual = json.load(open(sys.argv[2])) expected = json.load(open(sys.argv[3])) if actual != expected or actual.get("profile") != sys.argv[1]: - raise SystemExit("portable AppImage build profile differs from verified package") + raise SystemExit("portable Linux bundle build profile differs from verified package") print("Verified Agent launch and embedded profile without a Nix installation") PY diff --git a/.github/workflows/agent-updates-publish.yml b/.github/workflows/agent-updates-publish.yml index 2f49a4be5..fe689ec2e 100644 --- a/.github/workflows/agent-updates-publish.yml +++ b/.github/workflows/agent-updates-publish.yml @@ -17,9 +17,9 @@ on: required: true type: string include_linux: - description: Include Linux only after its packaged update launch gate passes + description: Publish this release for Linux too (disable only for a legacy macOS-only run) required: true - default: false + default: true type: boolean permissions: diff --git a/apps/maple-agent/Cargo.lock b/apps/maple-agent/Cargo.lock index 753bf9377..04e9417f8 100644 --- a/apps/maple-agent/Cargo.lock +++ b/apps/maple-agent/Cargo.lock @@ -5755,7 +5755,7 @@ dependencies = [ [[package]] name = "maple-agent" -version = "0.0.3" +version = "0.0.4" dependencies = [ "agent-client-protocol", "anyhow", @@ -5807,7 +5807,7 @@ dependencies = [ [[package]] name = "maple-agent-app" -version = "0.0.3" +version = "0.0.4" dependencies = [ "axum", "base64 0.22.1", @@ -5862,7 +5862,7 @@ dependencies = [ [[package]] name = "maple-agent-updater" -version = "0.0.3" +version = "0.0.4" dependencies = [ "anyhow", "base64 0.22.1", @@ -5890,7 +5890,7 @@ dependencies = [ [[package]] name = "maple-billing" -version = "0.0.3" +version = "0.0.4" dependencies = [ "reqwest 0.13.4", "serde", @@ -7675,7 +7675,7 @@ dependencies = [ [[package]] name = "pi-agent-core" -version = "0.0.3" +version = "0.0.4" dependencies = [ "async-trait", "futures-util", @@ -7688,7 +7688,7 @@ dependencies = [ [[package]] name = "pi-ai" -version = "0.0.3" +version = "0.0.4" dependencies = [ "async-trait", "bytes", @@ -7705,7 +7705,7 @@ dependencies = [ [[package]] name = "pi-coding-agent" -version = "0.0.3" +version = "0.0.4" dependencies = [ "async-trait", "base64 0.22.1", @@ -10055,7 +10055,7 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" [[package]] name = "store-bench" -version = "0.0.3" +version = "0.0.4" dependencies = [ "anyhow", "base64 0.22.1", diff --git a/apps/maple-agent/Cargo.toml b/apps/maple-agent/Cargo.toml index ab05ab0f1..3052ef3df 100644 --- a/apps/maple-agent/Cargo.toml +++ b/apps/maple-agent/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/maple-agent", "crates/maple-billing", "crates/maple-agent-upd [workspace.package] edition = "2024" -version = "0.0.3" +version = "0.0.4" license = "MIT" [workspace.dependencies] diff --git a/apps/maple-agent/README.md b/apps/maple-agent/README.md index 01a52b3ea..a1eb0bfa7 100644 --- a/apps/maple-agent/README.md +++ b/apps/maple-agent/README.md @@ -135,7 +135,8 @@ Goose, with an independent dependency graph. - Billing status from the Maple billing API. - Desktop notifications when a task finishes or asks a question while the window is not focused. -- Release check on launch: a banner links to a newer GitHub release. +- Signed updates for packaged Prod: background download and Restart to Update; + read-only installations offer a verified manual download. Nothing is downloaded or installed by the app. - Window size and maximized state persist between launches. @@ -305,7 +306,7 @@ just release # local release build only For hosted Dev and Prod packages, see [desktop builds](docs/desktop-builds.md) and [build profiles](docs/release-profiles.md). The desktop build workflow produces both profiles on protected `master`; macOS packages use Developer ID -signing and notarization, and Linux packages are portable AppImages. +signing and notarization, and Linux packages are portable user-local tar bundles. The root also offers `just agent-check`, `just agent-build`, and `just agent-dev`. Native development on macOS requires full Xcode at @@ -589,17 +590,18 @@ check. 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 the update URL and reviewed primary/backup public keys; inherited environment -variables cannot replace them. Packaged Prod builds check the compiled feed, while -publication remains disabled pending owner service setup. Invitations still -require the signed installed-update rehearsal. +variables cannot replace them. Packaged Prod builds check the compiled platform +feed; publication is a separate protected operation. Invitations require the +signed installed-update rehearsal for each supported platform. An empty-key profile still reports that updates are not configured and makes no update requests. Updates authenticate product, channel, version, target, byte size and SHA-256 before staging. Automatic downloads default on; installation requires the explicit Restart to Update action, fresh feed revalidation and profile-host -shutdown. Read-only installations offer a manual download. Linux automatic -replacement stays disabled until its packaged update gate is independently +shutdown. Read-only installations offer a manual download. Linux x86_64 uses +a user-local directory bundle, with atomic directory exchange and relaunch +through its AppRun entrypoint. Its packaged update gate must be independently validated. See [updater development and activation](docs/updates.md). The [desktop build workflow](docs/desktop-builds.md) provides Actions artifacts diff --git a/apps/maple-agent/app/build.rs b/apps/maple-agent/app/build.rs index f9d349dd4..becd20c98 100644 --- a/apps/maple-agent/app/build.rs +++ b/apps/maple-agent/app/build.rs @@ -53,6 +53,8 @@ fn main() { let pcr = std::env::var("VITE_OPEN_SECRET_PCR_ENVIRONMENT").ok(); let (channel, profile) = release_profile::select_profile(channel.as_deref(), pcr.as_deref()) .expect("validated Maple Agent build profile"); + let target_os = std::env::var("CARGO_CFG_TARGET_OS").expect("Cargo target OS"); + let update_feed_url = profile.update_feed_for_target_os(&target_os); let metadata = serde_json::json!({ "profile": channel, "display_name": profile.display_name, @@ -68,7 +70,7 @@ fn main() { "git_revision": revision, "source_sha": source_sha, "update_tag_prefix": profile.update_tag_prefix, - "update_feed_url": profile.update_feed_url, + "update_feed_url": update_feed_url, "update_public_keys": profile.update_public_keys, "prerelease": profile.prerelease, }); diff --git a/apps/maple-agent/app/src/desktop.rs b/apps/maple-agent/app/src/desktop.rs index 6e9f8a443..062df7476 100644 --- a/apps/maple-agent/app/src/desktop.rs +++ b/apps/maple-agent/app/src/desktop.rs @@ -2704,8 +2704,7 @@ impl StartupError { cx.background_executor() .spawn(async move { crate::host::stop(&crate::configured_api_url())?; - let exe = std::env::current_exe().map_err(|e| e.to_string())?; - std::process::Command::new(exe) + crate::self_launch::command()? .spawn() .map_err(|e| e.to_string())?; Ok::<(), String>(()) @@ -2780,8 +2779,14 @@ impl Render for StartupError { } fn show_startup_error(error: String) { + #[cfg(target_os = "linux")] + let restart_path = crate::self_launch::executable().ok(); gpui_platform::application().run(move |cx| { cx.set_app_identity(crate::profile::BUNDLE_ID, crate::profile::DISPLAY_NAME); + #[cfg(target_os = "linux")] + if let Some(path) = restart_path { + cx.set_restart_path(path); + } cx.on_action(|_: &QuitApp, cx| quit_app(cx)); ui::menus::install(cx); let bounds = Bounds::centered(None, size(px(560.), px(260.)), cx); @@ -2836,6 +2841,15 @@ fn show_update_about(cx: &mut App) { pub fn run() { crate::init_logging(crate::LogOutput::FileAndStderr); log::debug!("startup: logging ready at {} ms", crate::startup_elapsed()); + #[cfg(target_os = "linux")] + let restart_path = match crate::self_launch::executable() { + Ok(path) => path, + Err(error) => { + log::error!("Could not start Maple: {error}"); + show_startup_error(error); + return; + } + }; // The frontend starts with defaults until the host's bounded settings // snapshot arrives. Locked startup never reads saved application data. @@ -2857,6 +2871,8 @@ pub fn run() { .with_assets(crate::assets::Assets) .run(move |cx: &mut App| { cx.set_app_identity(crate::profile::BUNDLE_ID, crate::profile::DISPLAY_NAME); + #[cfg(target_os = "linux")] + cx.set_restart_path(restart_path); cx.set_quit_mode(gpui::QuitMode::Explicit); log::debug!("startup: gpui app ready at {} ms", crate::startup_elapsed()); if let Err(error) = cx.text_system().add_fonts( diff --git a/apps/maple-agent/app/src/host.rs b/apps/maple-agent/app/src/host.rs index c066316f8..941bd45f9 100644 --- a/apps/maple-agent/app/src/host.rs +++ b/apps/maple-agent/app/src/host.rs @@ -574,8 +574,7 @@ async fn connect( } fn spawn_host() -> Result<(), String> { - let mut command = - std::process::Command::new(std::env::current_exe().map_err(|e| e.to_string())?); + let mut command = crate::self_launch::command()?; command .args(["host", "serve"]) .stdin(std::process::Stdio::null()) diff --git a/apps/maple-agent/app/src/main.rs b/apps/maple-agent/app/src/main.rs index 699db57bd..8bc3343ed 100644 --- a/apps/maple-agent/app/src/main.rs +++ b/apps/maple-agent/app/src/main.rs @@ -22,6 +22,7 @@ mod notify; mod platform; mod profile; mod remote; +mod self_launch; mod settings; #[cfg(feature = "desktop")] mod shortcuts; @@ -252,14 +253,12 @@ fn main() { match cli.mode { Some(Mode::Host { command }) => { init_logging(LogOutput::FileOnly); - let result = match command { + let result = match &command { HostCommand::Serve => host::run(configured_api_url()), HostCommand::Stop => host::stop(&configured_api_url()), }; if let Err(error) = result { - log::error!("Maple host command failed: {error}"); - eprintln!("{error}"); - std::process::exit(1); + exit_host_error(&command, &error); } } Some(Mode::Remote { command }) => { @@ -330,6 +329,39 @@ fn main() { } } +/// A detached host has no stderr, so leave a diagnostic before exiting. Only +/// fixed summaries reach the log: errors can contain service URLs or paths. +fn exit_host_error(command: &HostCommand, error: &str) -> ! { + let operation = match command { + HostCommand::Serve => "serve", + HostCommand::Stop => "stop", + }; + // env_logger's Pipe target writes and flushes each record synchronously, + // and TeeWriter also flushes ERROR records. process::exit skips destructors. + log::error!( + "Maple host {operation} failed: {}", + host_error_summary(error) + ); + eprintln!("{error}"); + std::process::exit(1); +} + +fn host_error_summary(error: &str) -> &'static str { + if error.starts_with("failed to build the billing HTTP client:") + || error == "builder error" + || error.contains("no root certificates found") + || error.contains("No CA certificates were loaded from the system") + { + "HTTP client initialization failed; check OS CA certificates and TLS trust configuration." + } else if error.starts_with("Cannot open host lock:") + || error.starts_with("Cannot acquire Maple host ownership:") + { + "Host profile lock is unavailable; check profile storage permissions and existing host processes." + } else { + "Host operation failed; check profile configuration and runtime dependencies." + } +} + /// `maple-agent proxy`: an OpenAI-compatible endpoint in front of Maple's /// enclave, for tools that speak the OpenAI API. Runs until killed. #[cfg(feature = "proxy")] @@ -530,7 +562,10 @@ enum LogOutput { fn init_logging(output: LogOutput) { let paths = backend::store_paths(); let log_dir = paths.logs(); - let file = paths.open_log().ok(); + let file = paths + .prepare_control_root() + .and_then(|paths| paths.open_log()) + .ok(); const DEFAULT_FILTER: &str = "info,iroh=warn,iroh_relay=warn,noq=warn,noq_proto=warn,netwatch=warn,tracing::span=warn"; let mut builder = match output { @@ -594,7 +629,7 @@ impl std::io::Write for TeeWriter { #[cfg(test)] mod tests { - use super::{Cli, LoginArgs, Mode}; + use super::{Cli, HostCommand, LoginArgs, Mode}; use clap::Parser; fn parse(args: &[&str]) -> Result, clap::Error> { @@ -602,6 +637,91 @@ mod tests { .map(|cli| cli.mode) } + #[test] + fn host_error_summaries_include_only_fixed_guidance() { + let private = "https://fake-user:fake-password@example.invalid/private?token=fake-token account=fake-account"; + for error in [ + format!("failed to build the billing HTTP client: builder error: {private}"), + "builder error".to_string(), + format!("no root certificates found: {private}"), + format!("No CA certificates were loaded from the system: {private}"), + ] { + assert_eq!( + super::host_error_summary(&error), + "HTTP client initialization failed; check OS CA certificates and TLS trust configuration." + ); + } + for error in [ + format!("Cannot open host lock: /private/fake-account: {private}"), + format!("Cannot acquire Maple host ownership: {private}"), + ] { + assert_eq!( + super::host_error_summary(&error), + "Host profile lock is unavailable; check profile storage permissions and existing host processes." + ); + } + assert_eq!( + super::host_error_summary(private), + "Host operation failed; check profile configuration and runtime dependencies." + ); + } + + #[test] + fn host_error_exit_leaves_sanitized_log_after_process_exit() { + const CHILD_OPERATION: &str = "MAPLE_TEST_HOST_ERROR_LOG_CHILD"; + if let Ok(operation) = std::env::var(CHILD_OPERATION) { + super::init_logging(super::LogOutput::FileOnly); + let command = match operation.as_str() { + "serve" => HostCommand::Serve, + "stop" => HostCommand::Stop, + _ => panic!("invalid child operation"), + }; + super::exit_host_error( + &command, + "failed to build the billing HTTP client: builder error: https://fake-user:fake-password@example.invalid/private?token=fake-token account=fake-account", + ); + } + + for operation in ["serve", "stop"] { + let root = tempfile::tempdir().unwrap(); + let data = root.path().join("data"); + let status = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "tests::host_error_exit_leaves_sanitized_log_after_process_exit", + ]) + .env(CHILD_OPERATION, operation) + .env("XDG_CONFIG_HOME", root.path().join("config")) + .env("XDG_DATA_HOME", &data) + .env("RUST_LOG", "off") + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .unwrap(); + assert_eq!(status.code(), Some(1)); + let log = std::fs::read_to_string( + data.join(crate::profile::DATA_NAMESPACE) + .join("store-v1/logs/maple-agent.log"), + ) + .unwrap(); + assert!(log.contains("ERROR")); + assert!(log.contains(&format!("Maple host {operation} failed:"))); + assert!(log.contains("check OS CA certificates and TLS trust configuration")); + for private in [ + "https://", + "fake-user", + "fake-password", + "example.invalid", + "?token=", + "fake-token", + "fake-account", + ] { + assert!(!log.contains(private), "private input reached the log"); + } + } + } + #[test] fn build_info_is_a_standalone_exit_mode() { let cli = Cli::try_parse_from(["maple-agent", "--build-info"]).unwrap(); diff --git a/apps/maple-agent/app/src/self_launch.rs b/apps/maple-agent/app/src/self_launch.rs new file mode 100644 index 000000000..b916c0fe0 --- /dev/null +++ b/apps/maple-agent/app/src/self_launch.rs @@ -0,0 +1,5 @@ +//! App entry points share package validation with bounded core subprocesses. + +pub use maple_agent::self_launch::command; +#[cfg(all(target_os = "linux", feature = "desktop"))] +pub use maple_agent::self_launch::executable; diff --git a/apps/maple-agent/app/src/update.rs b/apps/maple-agent/app/src/update.rs index e9ee0ef43..50008b509 100644 --- a/apps/maple-agent/app/src/update.rs +++ b/apps/maple-agent/app/src/update.rs @@ -349,14 +349,7 @@ impl Controller { } let startup = async { if let Ok(service) = self.service().await { - let installed = service.installed_path.clone(); - let _ = tokio::task::spawn_blocking(move || { - maple_agent_updater::installer::cleanup_current_installation( - &installed, - env!("CARGO_PKG_VERSION"), - ) - }) - .await; + cleanup_installed_update(service).await; match service.updater.restore_prepared().await { Ok(Some(prepared)) => { let mut releases = self.0.releases.lock().await; @@ -755,9 +748,10 @@ impl Controller { } // Retire old-process RPC admission and retain host ownership // until this PID exits, including if the restart helper fails. + let restart_path = installed.restart_path(); backend.retire_after_update(); - controller.installed(installed.installed_path.clone(), &installed_version, host_fence.take()); - Ok(installed.installed_path) + controller.installed(restart_path.clone(), &installed_version, host_fence.take()); + Ok(restart_path) }).map_err(|error| error.to_string()); drop(host_fence); if result.is_err() { @@ -801,6 +795,46 @@ async fn discard_preparation(preparation: maple_agent_updater::installer::Instal discard_preparation_with_lock(preparation, None).await; } +async fn cleanup_installed_update(service: &Service) { + let installed = service.installed_path.clone(); + #[cfg(target_os = "linux")] + { + if service.manual_only() { + return; + } + let Ok(origin) = compiled_feed_url(Target::LinuxX86_64) else { + return; + }; + let keys = crate::profile::UPDATE_PUBLIC_KEYS + .iter() + .map(|key| (*key).to_owned()) + .collect::>(); + // Equal-version prepared downloads are deliberately rejected during + // 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( + &installed, + env!("CARGO_PKG_VERSION"), + env!("MAPLE_GIT_SOURCE_SHA"), + &origin, + &keys, + ) + }) + .await; + } + #[cfg(not(target_os = "linux"))] + { + let _ = tokio::task::spawn_blocking(move || { + maple_agent_updater::installer::cleanup_current_installation( + &installed, + env!("CARGO_PKG_VERSION"), + ) + }) + .await; + } +} + async fn discard_preparation_with_lock( preparation: maple_agent_updater::installer::InstallPreparation, lock: Option, @@ -813,10 +847,18 @@ async fn discard_preparation_with_lock( } fn manual_download_url(release: &VerifiedRelease) -> Option { - let artifact = if cfg!(target_os = "linux") { - release.manifest.downloads.linux_appimage.as_ref()? + let target = if cfg!(target_os = "linux") { + Target::LinuxX86_64 } else { - &release.manifest.downloads.macos + Target::DarwinAarch64 + }; + manual_download_url_for_target(release, target) +} + +fn manual_download_url_for_target(release: &VerifiedRelease, target: Target) -> Option { + let artifact = match target { + Target::DarwinAarch64 => release.manifest.downloads.macos.as_ref()?, + Target::LinuxX86_64 => release.manifest.downloads.linux_bundle.as_ref()?, }; release .origin @@ -826,7 +868,10 @@ fn manual_download_url(release: &VerifiedRelease) -> Option { } fn build_service() -> Result { - let (installed_path, manual_only) = installation()?; + let Installation { + installed_path, + manual_only, + } = installation()?; let target = if cfg!(all(target_os = "macos", target_arch = "aarch64")) { Target::DarwinAarch64 } else if cfg!(all(target_os = "linux", target_arch = "x86_64")) { @@ -835,10 +880,7 @@ fn build_service() -> Result { return Err("Updates are not supported on this platform".into()); }; let config = Config { - feed_url: crate::profile::UPDATE_FEED_URL - .ok_or("Updates not configured")? - .parse() - .map_err(|_| "Invalid packaged update configuration")?, + feed_url: compiled_feed_url(target)?, public_keys: crate::profile::UPDATE_PUBLIC_KEYS .iter() .map(|key| (*key).to_owned()) @@ -859,14 +901,49 @@ fn build_service() -> Result { }) } +fn compiled_feed_url(target: Target) -> Result { + feed_url_for_target( + crate::profile::UPDATE_FEED_URL.ok_or("Updates not configured")?, + target, + ) +} + +fn feed_url_for_target(feed: &str, target: Target) -> Result { + let feed = feed + .parse::() + .map_err(|_| "Invalid packaged update configuration")?; + // The profile and embedded build-info must describe the pointer we use. + // Do not silently turn a shipped Mac v1 pointer into a Linux v2 pointer. + if feed.path() != target.feed_path() { + return Err("Invalid packaged update configuration".into()); + } + Ok(feed) +} + fn update_state_dir() -> PathBuf { // Signed discovery/anti-rollback state survives local-store reset/repair. crate::backend::profile_data_root().join("updates") } -fn installation() -> Result<(PathBuf, bool), String> { +struct Installation { + installed_path: PathBuf, + manual_only: bool, +} + +impl Installation { + #[cfg(any(not(target_os = "macos"), all(test, unix)))] + fn manual(executable: &Path) -> Self { + Self { + installed_path: executable.to_owned(), + manual_only: true, + } + } +} + +fn installation() -> Result { let exe = std::env::current_exe().map_err(|_| "Could not locate the installed app")?; - if cfg!(target_os = "macos") { + #[cfg(target_os = "macos")] + { let app = exe .ancestors() .nth(3) @@ -875,13 +952,45 @@ fn installation() -> Result<(PathBuf, bool), String> { let app = std::fs::canonicalize(app).map_err(|_| "Could not locate the installed app")?; let manual = manual_macos_path(&app) || maple_agent_updater::installer::discover_installation(&exe, None).is_err(); - Ok((app, manual)) - } else { - // Linux stays notify-only until packaged host/update/relaunch gates are - // demonstrated. The launch helper fix is an independent prerequisite. - Ok((exe, true)) + Ok(Installation { + installed_path: app, + manual_only: manual, + }) + } + #[cfg(target_os = "linux")] + { + Ok(linux_installation( + &exe, + maple_agent::self_launch::executable(), + )) + } + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + { + Ok(Installation::manual(&exe)) + } +} + +#[cfg(any(target_os = "linux", all(test, unix)))] +fn linux_installation(executable: &Path, resolved: Result) -> Installation { + // AppRun may execute through the bundled loader. Resolve that entrypoint + // through the shared launcher, then let the installer validate its bundle + // layout, compiled identity and replacement permissions. + let Ok(path) = resolved else { + return Installation::manual(executable); + }; + match maple_agent_updater::installer::discover_linux_installation( + &path, + env!("CARGO_PKG_VERSION"), + env!("MAPLE_GIT_SOURCE_SHA"), + ) { + Ok(installed_path) => Installation { + installed_path, + manual_only: false, + }, + Err(_) => Installation::manual(executable), } } +#[cfg(any(target_os = "macos", test))] fn manual_macos_path(path: &Path) -> bool { path.starts_with("/Volumes") || path @@ -893,6 +1002,100 @@ fn manual_macos_path(path: &Path) -> bool { mod tests { use super::*; + #[cfg(unix)] + fn linux_bundle_fixture(parent: &Path) -> PathBuf { + use std::os::unix::fs::PermissionsExt; + + let bundle = parent.join("maple-agent.app"); + for directory in [ + bundle.clone(), + bundle.join("usr"), + bundle.join("usr/bin"), + bundle.join("usr/lib"), + ] { + std::fs::create_dir(&directory).unwrap(); + std::fs::set_permissions(&directory, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + for executable in [ + bundle.join("AppRun"), + bundle.join("usr/bin/maple-agent"), + bundle.join("usr/lib/ld-linux-x86-64.so.2"), + ] { + std::fs::write(&executable, b"#!/bin/sh\nexit 0\n").unwrap(); + std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + std::fs::write( + bundle.join("bundle.json"), + serde_json::to_vec(&serde_json::json!({ + "format": "maple-agent-linux-bundle-v1", + "product": "maple-agent", + "profile": "prod", + "version": env!("CARGO_PKG_VERSION"), + "build": "1", + "source_sha": env!("MAPLE_GIT_SOURCE_SHA"), + "bundle_id": "cloud.opensecret.maple.agent", + "entrypoint": "AppRun", + "executable": "usr/bin/maple-agent", + })) + .unwrap(), + ) + .unwrap(); + std::fs::set_permissions( + bundle.join("bundle.json"), + std::fs::Permissions::from_mode(0o644), + ) + .unwrap(); + bundle + } + + #[cfg(unix)] + #[test] + fn linux_bundle_discovery_uses_the_bundle_directory_and_manual_fallbacks() { + let directory = tempfile::tempdir().unwrap(); + let parent = directory.path().canonicalize().unwrap(); + let bundle = linux_bundle_fixture(&parent); + let loader = bundle.join("usr/lib/ld-linux-x86-64.so.2"); + let legacy_appimage = parent.join("old-maple.AppImage"); + std::fs::write(&legacy_appimage, b"legacy AppImage context").unwrap(); + let automatic = linux_installation(&loader, Ok(bundle.join("AppRun"))); + assert!(!automatic.manual_only); + assert_eq!(automatic.installed_path, bundle); + + for resolved in [ + Err("invalid launcher context".into()), + Ok(loader.clone()), + Ok(legacy_appimage), + ] { + let manual = linux_installation(&loader, resolved); + assert!(manual.manual_only); + assert_eq!(manual.installed_path, loader); + } + std::fs::remove_file(bundle.join("bundle.json")).unwrap(); + let invalid = linux_installation(&loader, Ok(bundle.join("AppRun"))); + assert!(invalid.manual_only); + assert_eq!(invalid.installed_path, loader); + } + + #[cfg(unix)] + #[test] + fn linux_bundle_with_read_only_parent_remains_manual_only() { + use std::os::unix::fs::PermissionsExt; + + // A privileged test process can replace a mode-0555 directory's child. + if unsafe { libc::geteuid() } == 0 { + return; + } + let directory = tempfile::tempdir().unwrap(); + let parent = directory.path().canonicalize().unwrap(); + let bundle = linux_bundle_fixture(&parent); + let loader = bundle.join("usr/lib/ld-linux-x86-64.so.2"); + std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o555)).unwrap(); + let manual = linux_installation(&loader, Ok(bundle.join("AppRun"))); + std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o700)).unwrap(); + assert!(manual.manual_only); + assert_eq!(manual.installed_path, loader); + } + #[cfg(unix)] #[test] fn quit_cleanup_without_tokio_preserves_download_and_installation() { @@ -901,10 +1104,21 @@ mod tests { assert!(tokio::runtime::Handle::try_current().is_err()); let directory = tempfile::tempdir().unwrap(); let root = directory.path().canonicalize().unwrap(); + #[cfg(target_os = "linux")] + let installed = linux_bundle_fixture(&root); + #[cfg(not(target_os = "linux"))] let installed = root.join("Maple Agent.app"); + #[cfg(target_os = "linux")] + let staging = { + use sha2::Digest; + let binding = sha2::Sha256::digest(installed.as_os_str().as_encoded_bytes()); + root.join(format!(".maple-agent-update-{binding:x}-abcdefghijklmnop")) + }; + #[cfg(not(target_os = "linux"))] let staging = root.join(".maple-agent-update-abcdefghijklmnop"); - let staged = staging.join("Maple Agent.app"); + let staged = staging.join(installed.file_name().unwrap()); let cached = root.join("download.payload"); + #[cfg(not(target_os = "linux"))] std::fs::create_dir(&installed).unwrap(); std::fs::create_dir(&staging).unwrap(); std::fs::set_permissions(&staging, std::fs::Permissions::from_mode(0o700)).unwrap(); @@ -964,6 +1178,80 @@ mod tests { assert!(!update_state_dir().starts_with(store.root())); } + #[test] + fn platform_feed_selection_uses_the_exact_compiled_target_pointer() { + for origin in ["https://updates.trymaple.ai", "https://updates.maple.ai"] { + let mac = format!("{origin}/agent/stable/latest.json"); + let linux = format!("{origin}/agent/stable/linux/latest.json"); + assert_eq!( + feed_url_for_target(&mac, Target::DarwinAarch64) + .unwrap() + .as_str(), + mac + ); + assert_eq!( + feed_url_for_target(&linux, Target::LinuxX86_64) + .unwrap() + .as_str(), + linux + ); + assert!(feed_url_for_target(&mac, Target::LinuxX86_64).is_err()); + assert!(feed_url_for_target(&linux, Target::DarwinAarch64).is_err()); + } + assert!(feed_url_for_target("not a URL", Target::LinuxX86_64).is_err()); + } + + #[test] + fn mac_manual_download_remains_available_without_a_linux_bundle() { + let feed = feed_url_for_target( + "https://updates.trymaple.ai/agent/stable/latest.json", + Target::DarwinAarch64, + ) + .unwrap(); + let key = + include_str!("../../crates/maple-agent-updater/tests/fixtures/public-test-key.pub") + .to_owned(); + let release = maple_agent_updater::manifest::verify_envelope( + include_bytes!("../../crates/maple-agent-updater/tests/fixtures/signed-envelope.json"), + &feed, + &[key], + ) + .unwrap(); + assert!( + manual_download_url_for_target(&release, Target::DarwinAarch64) + .unwrap() + .ends_with("/Maple-Agent.dmg") + ); + assert!(manual_download_url_for_target(&release, Target::LinuxX86_64).is_none()); + } + + #[test] + fn linux_manual_download_uses_the_signed_bundle_without_a_mac_download() { + let feed = feed_url_for_target( + "https://updates.trymaple.ai/agent/stable/linux/latest.json", + Target::LinuxX86_64, + ) + .unwrap(); + let key = + include_str!("../../crates/maple-agent-updater/tests/fixtures/public-test-key.pub") + .to_owned(); + let release = maple_agent_updater::manifest::verify_envelope( + include_bytes!( + "../../crates/maple-agent-updater/tests/fixtures/signed-linux-v2-envelope.json" + ), + &feed, + &[key], + ) + .unwrap(); + assert_eq!( + manual_download_url_for_target(&release, Target::LinuxX86_64).as_deref(), + Some( + "https://updates.trymaple.ai/agent/stable/releases/0.0.2/Maple-Agent-linux-x86_64.tar.gz" + ) + ); + assert!(manual_download_url_for_target(&release, Target::DarwinAarch64).is_none()); + } + #[tokio::test] async fn deferred_store_bootstrap_cannot_overwrite_a_newer_opt_out() { let controller = Controller::new(false); @@ -1111,23 +1399,45 @@ mod tests { assert_eq!(controller.snapshot().state, State::RestartRequired); assert!(controller.0.service.get().is_none()); } - // This exercises macOS installation permissions with the macOS-only signed - // fixture. Linux discovery requires its own artifact/download inventory. - #[cfg(target_os = "macos")] + // Each platform uses its authenticated inventory, including Linux's v2 + // bundle-only manifest, when replacement permissions require manual install. + #[cfg(any(target_os = "macos", target_os = "linux"))] #[tokio::test] async fn permission_failure_switches_to_signed_manual_download_before_host_stop() { let controller = Controller::new(true); let temp = tempfile::tempdir().unwrap(); - let installed = temp.path().join("Maple Agent.app"); + let installed = temp.path().join(if cfg!(target_os = "linux") { + "maple-agent.app" + } else { + "Maple Agent.app" + }); std::fs::create_dir_all(&installed).unwrap(); - let feed: maple_agent_updater::Url = "https://updates.trymaple.ai/agent/stable/latest.json" - .parse() - .unwrap(); + #[cfg(target_os = "linux")] + let (target, envelope, filename) = ( + Target::LinuxX86_64, + include_bytes!( + "../../crates/maple-agent-updater/tests/fixtures/signed-linux-v2-envelope.json" + ) + .as_slice(), + "Maple-Agent-linux-x86_64.tar.gz", + ); + #[cfg(target_os = "macos")] + let (target, envelope, filename) = ( + Target::DarwinAarch64, + include_bytes!("../../crates/maple-agent-updater/tests/fixtures/signed-envelope.json") + .as_slice(), + "Maple-Agent.dmg", + ); + let feed = feed_url_for_target( + &format!("https://updates.trymaple.ai{}", target.feed_path()), + target, + ) + .unwrap(); let key = include_str!("../../crates/maple-agent-updater/tests/fixtures/public-test-key.pub") .to_owned(); let release = maple_agent_updater::manifest::verify_envelope( - include_bytes!("../../crates/maple-agent-updater/tests/fixtures/signed-envelope.json"), + envelope, &feed, std::slice::from_ref(&key), ) @@ -1138,7 +1448,7 @@ mod tests { feed_url: feed, public_keys: vec![key], current_version: "0.0.1".parse().unwrap(), - target: Target::DarwinAarch64, + target, installed_path: installed.clone(), state_dir: temp.path().join("updates"), }) @@ -1165,7 +1475,7 @@ mod tests { snapshot .manual_download_url .unwrap() - .ends_with("/Maple-Agent.dmg") + .ends_with(&format!("/{filename}")) ); assert!(snapshot.label.contains("install manually")); assert!(controller.restart_path().is_none()); diff --git a/apps/maple-agent/crates/maple-agent-updater/examples/validate-release-archive.rs b/apps/maple-agent/crates/maple-agent-updater/examples/validate-release-archive.rs index b4861e985..413c81efc 100644 --- a/apps/maple-agent/crates/maple-agent-updater/examples/validate-release-archive.rs +++ b/apps/maple-agent/crates/maple-agent-updater/examples/validate-release-archive.rs @@ -3,7 +3,7 @@ use std::{fs::File, io::Read, path::Path}; -use maple_agent_updater::installer::validate_archive; +use maple_agent_updater::installer::{validate_archive, validate_linux_bundle_archive}; use sha2::{Digest, Sha256}; const MAX_ARCHIVE_BYTES: u64 = 4 * 1024 * 1024 * 1024; @@ -37,12 +37,23 @@ fn digest(path: &Path) -> anyhow::Result<(String, u64)> { fn main() -> anyhow::Result<()> { let arguments: Vec<_> = std::env::args_os().skip(1).collect(); - anyhow::ensure!( - arguments.len() == 2, - "usage: validate-release-archive ARCHIVE EXPECTED_SHA256" - ); - let path = Path::new(&arguments[0]); - let expected = arguments[1] + let (archive, expected, profile) = match arguments.as_slice() { + [archive, expected] => (archive, expected, None), + [mode, flag, profile, archive, expected] + if mode == "--linux-bundle" && flag == "--profile" => + { + let profile = profile + .to_str() + .filter(|value| matches!(*value, "dev" | "prod")) + .ok_or_else(|| anyhow::anyhow!("Linux bundle profile must be dev or prod"))?; + (archive, expected, Some(profile)) + } + _ => anyhow::bail!( + "usage: validate-release-archive [--linux-bundle --profile dev|prod] ARCHIVE EXPECTED_SHA256" + ), + }; + let path = Path::new(archive); + let expected = expected .to_str() .ok_or_else(|| anyhow::anyhow!("digest must be ASCII"))?; anyhow::ensure!( @@ -57,7 +68,11 @@ fn main() -> anyhow::Result<()> { before.0 == expected, "archive does not match the package checksum" ); - validate_archive(path)?; + if let Some(profile) = profile { + validate_linux_bundle_archive(path, profile)?; + } else { + validate_archive(path)?; + } anyhow::ensure!(digest(path)? == before, "archive changed during validation"); println!( "{}", 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 a5902e1fd..f20ae5582 100644 --- a/apps/maple-agent/crates/maple-agent-updater/src/client.rs +++ b/apps/maple-agent/crates/maple-agent-updater/src/client.rs @@ -166,7 +166,9 @@ impl Updater { ); bytes.extend_from_slice(&chunk); } - manifest::verify_envelope(&bytes, &origin, &self.config.public_keys) + let release = manifest::verify_envelope(&bytes, &origin, &self.config.public_keys)?; + release.artifact(self.config.target)?; + Ok::<_, anyhow::Error>(release) }) .await .context("update metadata request timed out")? @@ -183,21 +185,21 @@ impl Updater { verified.manifest == release.manifest, "release fields differ from its signed envelope" ); + verified.artifact(self.config.target)?; Ok(verified) } fn eligible(&self, release: &VerifiedRelease) -> Result { + // Schema and target are bound before rollback memory can advance or a + // saved preparation can become eligible for filesystem effects. + release.artifact(self.config.target)?; let version = release.version(); Ok(version > self.config.current_version && self .state .highest_verified()? .as_ref() - .is_none_or(|highest| &version >= highest) - && release - .manifest - .artifacts - .contains_key(self.config.target.key())) + .is_none_or(|highest| &version >= highest)) } pub async fn check(&self) -> Result { 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 25134160d..2afc60058 100644 --- a/apps/maple-agent/crates/maple-agent-updater/src/installer.rs +++ b/apps/maple-agent/crates/maple-agent-updater/src/installer.rs @@ -28,6 +28,12 @@ use std::os::unix::ffi::OsStrExt; #[cfg(unix)] use std::os::unix::{fs::MetadataExt, fs::OpenOptionsExt, fs::PermissionsExt}; +mod linux_bundle; + +pub use linux_bundle::{ + cleanup_current_linux_bundle, discover_linux_installation, validate_linux_bundle_archive, +}; + const APP_NAME: &str = "Maple Agent.app"; const STAGING_PREFIX: &str = ".maple-agent-update-"; const STAGING_RANDOM_LENGTH: usize = 16; @@ -114,7 +120,6 @@ impl Drop for InstallLock { } /// Find a supported installation from the real executable, never a saved path. -/// AppImage updates remain notify-only until the packaged Linux launch gate. pub fn discover_installation(executable: &Path, _appimage: Option<&Path>) -> Result { #[cfg(target_os = "macos")] { @@ -144,6 +149,10 @@ 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<()> { + #[cfg(target_os = "linux")] + if linux_bundle::is_bundle_path(installed) { + return linux_bundle::preflight(installed); + } ensure_supported()?; validate_destination(installed)?; let parent = installed.parent().unwrap(); @@ -192,7 +201,11 @@ fn replacement_access(path: &Path, rights: libc::c_int) -> Result<()> { } pub fn acquire_install_lock(installed: &Path) -> Result { - validate_destination(installed)?; + if linux_bundle::is_bundle_path(installed) { + linux_bundle::validate_destination(installed)?; + } else { + validate_destination(installed)?; + } let file = open_install_lock(installed)?; file.try_lock_exclusive().map_err(|e| { if e.kind() == io::ErrorKind::WouldBlock { @@ -279,13 +292,21 @@ 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 { - preflight_installation(&update.installed_path)?; - prepare_inner(update, Verifier::Native) + if cfg!(target_os = "linux") { + linux_bundle::prepare(update) + } else { + preflight_installation(&update.installed_path)?; + prepare_inner(update, Verifier::Native) + } } pub fn revalidate(preparation: &InstallPreparation, update: &PreparedUpdate) -> Result<()> { - preflight_installation(&update.installed_path)?; - revalidate_inner(preparation, update, Verifier::Native) + if cfg!(target_os = "linux") { + linux_bundle::revalidate(preparation, update) + } else { + preflight_installation(&update.installed_path)?; + revalidate_inner(preparation, update, Verifier::Native) + } } /// Apply the updater's exact archive acceptance rules to a production release @@ -298,6 +319,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 linux_bundle::is_bundle_path(&preparation.installed_path) { + return linux_bundle::discard(preparation); + } validate_destination(&preparation.installed_path)?; validate_staging(&preparation.installed_path, &preparation.staging_dir)?; if preparation.payload_path != preparation.staging_dir.join("payload") @@ -313,14 +337,18 @@ pub fn discard_preparation(preparation: &InstallPreparation) -> Result<()> { } /// Requires the caller to retain the application lock and profile host fences. -/// It rechecks the downloaded archive, every staged byte, and the OS signature +/// It rechecks the downloaded archive, every staged byte, and platform identity /// immediately before the atomic swap. No downloaded executable is launched. pub fn replace( preparation: &InstallPreparation, update: &PreparedUpdate, ) -> Result { - ensure_supported()?; - replace_inner(preparation, update, Verifier::Native, Synchronizer::Native) + if cfg!(target_os = "linux") { + linux_bundle::replace(preparation, update) + } else { + ensure_supported()?; + replace_inner(preparation, update, Verifier::Native, Synchronizer::Native) + } } fn replace_inner( @@ -361,6 +389,15 @@ 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) { + self.installed_path.join("AppRun") + } else { + self.installed_path.clone() + } + } + /// Call only after the new app reached its startup checkpoint. On failure, /// retain the backup; automatic crash rollback is deliberately not implied. pub fn checkpoint_cleanup(&self) -> Result<()> { @@ -480,7 +517,7 @@ enum Verifier { #[derive(Clone, Copy)] enum Synchronizer { Native, - #[cfg(all(test, target_os = "macos"))] + #[cfg(all(test, unix))] Test(fn(&Path) -> Result<()>), } @@ -488,7 +525,7 @@ impl Synchronizer { fn sync(self, directory: &Path) -> Result<()> { match self { Self::Native => sync_directory(directory), - #[cfg(all(test, target_os = "macos"))] + #[cfg(all(test, unix))] Self::Test(sync) => sync(directory), } } @@ -688,7 +725,7 @@ fn open_regular(path: &Path, create: bool, exclusive: bool) -> Result { #[cfg(unix)] options .mode(0o600) - .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC); + .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK); let file = options.open(path)?; if !file.metadata()?.is_file() { return Err(InstallError::Invalid("expected a regular file")); @@ -767,10 +804,21 @@ enum Node { Symlink(String), } +type ArchiveInventory = (BTreeMap, Option>); + /// Raw iteration prevents tar's GNU/PAX preprocessing from allocating an /// attacker-declared extension size. Extensions are bounded and parsed here; /// no ownership, xattrs, flags, hard links, sparse or special files are applied. fn scan_archive(payload: &Path, output: Option<&Path>) -> Result> { + scan_archive_root(payload, output, APP_NAME).map(|(nodes, _)| nodes) +} + +fn scan_archive_root( + payload: &Path, + output: Option<&Path>, + app_name: &str, +) -> Result { + let mut descriptor = None; let input = open_regular(payload, false, false)?; let decoded = MultiGzDecoder::new(input); let bounded = ExpansionLimit { @@ -847,7 +895,7 @@ fn scan_archive(payload: &Path, output: Option<&Path>) -> Result) -> Result MAX_EXPANDED_BYTES { return Err(InstallError::Invalid("archive expansion limit exceeded")); } - let digest = if let Some(root) = output { + let digest = if app_name != APP_NAME && name == format!("{app_name}/bundle.json") { + if size > 16 * 1024 { + return Err(InstallError::Invalid("bundle descriptor is too large")); + } + let mut bytes = Vec::new(); + entry.read_to_end(&mut bytes)?; + if bytes.len() as u64 != size { + return Err(InstallError::Invalid("truncated bundle descriptor")); + } + if let Some(root) = output { + let destination = root.join(&name); + create_public_directory(destination.parent().unwrap())?; + let mut file = open_regular(&destination, true, true)?; + file.write_all(&bytes)?; + set_mode(&destination, mode)?; + file.sync_all()?; + } + let digest = Sha256::digest(&bytes).into(); + descriptor = Some(bytes); + digest + } else if let Some(root) = output { let destination = root.join(&name); create_public_directory(destination.parent().unwrap())?; let mut file = open_regular(&destination, true, true)?; @@ -913,7 +981,7 @@ fn scan_archive(payload: &Path, output: Option<&Path>) -> Result) -> Result = nodes.keys().cloned().collect(); @@ -977,10 +1045,10 @@ fn scan_archive(payload: &Path, output: Option<&Path>) -> Result Result { +fn archive_path(raw: &str, directory: bool, app_name: &str) -> Result { let name = if directory { raw.strip_suffix('/').unwrap_or(raw) } else { @@ -993,14 +1061,14 @@ fn archive_path(raw: &str, directory: bool) -> Result { || name .split('/') .any(|part| part.is_empty() || part == "." || part == "..") - || name.split('/').next() != Some(APP_NAME) + || name.split('/').next() != Some(app_name) { return Err(InstallError::Invalid("unsafe archive path")); } Ok(name.to_owned()) } -fn validate_link(name: &str, link: &str) -> Result<()> { +fn validate_link(name: &str, link: &str, app_name: &str) -> Result<()> { if link.is_empty() || link.len() > MAX_PATH_BYTES || link.starts_with('/') @@ -1023,7 +1091,7 @@ fn validate_link(name: &str, link: &str) -> Result<()> { _ => resolved.push(part), } } - if resolved.first() != Some(&APP_NAME) { + if resolved.first() != Some(&app_name) { return Err(InstallError::Invalid("symlink escapes application")); } Ok(()) @@ -1118,6 +1186,10 @@ fn set_mode(path: &Path, mode: u32) -> Result<()> { } fn scan_tree(bundle: &Path) -> Result> { + scan_tree_root(bundle, APP_NAME) +} + +fn scan_tree_root(bundle: &Path, app_name: &str) -> Result> { let mut nodes = BTreeMap::new(); let parent = bundle .parent() @@ -1139,7 +1211,7 @@ fn scan_tree(bundle: &Path) -> Result> { let link = link .to_str() .ok_or(InstallError::Invalid("invalid staged symlink"))?; - validate_link(name, link)?; + validate_link(name, link, app_name)?; Node::Symlink(link.to_owned()) } else if metadata.is_dir() { #[cfg(unix)] @@ -1498,8 +1570,9 @@ mod tests { notes: "fixture".into(), artifacts: BTreeMap::from([("darwin-aarch64".into(), artifact)]), downloads: Downloads { - macos: dmg, + macos: Some(dmg), linux_appimage: None, + linux_bundle: None, }, }, envelope: "test-only authenticated caller fixture".into(), diff --git a/apps/maple-agent/crates/maple-agent-updater/src/installer/linux_bundle.rs b/apps/maple-agent/crates/maple-agent-updater/src/installer/linux_bundle.rs new file mode 100644 index 000000000..f7a884a70 --- /dev/null +++ b/apps/maple-agent/crates/maple-agent-updater/src/installer/linux_bundle.rs @@ -0,0 +1,590 @@ +//! User-owned Linux directory bundles. No downloaded executable is run while +//! preparing or authenticating an update. macOS keeps its own bundle verifier. +use super::*; +use crate::{Url, manifest}; + +const PROD_ROOT: &str = "maple-agent.app"; +const DEV_ROOT: &str = "maple-agent-dev.app"; +const DESCRIPTOR_LIMIT: u64 = 16 * 1024; +const RECEIPT_LIMIT: u64 = (manifest::MAX_ENVELOPE_BYTES * 6 + 16 * 1024) as u64; + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct Bundle { + format: String, + product: String, + profile: String, + version: String, + build: String, + source_sha: String, + bundle_id: String, + entrypoint: String, + executable: String, +} + +impl Bundle { + fn parse(bytes: &[u8], profile: &str) -> Result { + if bytes.len() as u64 > DESCRIPTOR_LIMIT { + return Err(InstallError::Invalid("bundle descriptor is too large")); + } + let bundle: Self = serde_json::from_slice(bytes) + .map_err(|_| InstallError::Invalid("invalid Linux bundle descriptor"))?; + let identity = match profile { + "prod" => "cloud.opensecret.maple.agent", + "dev" => "cloud.opensecret.maple.agent.dev", + _ => return Err(InstallError::Invalid("invalid Linux bundle profile")), + }; + if bundle.format != "maple-agent-linux-bundle-v1" + || bundle.product != "maple-agent" + || bundle.profile != profile + || bundle.bundle_id != identity + || bundle.entrypoint != "AppRun" + || bundle.executable != "usr/bin/maple-agent" + || bundle.build.is_empty() + || bundle.build.len() > 18 + || bundle.build.starts_with('0') + || !bundle.build.bytes().all(|b| b.is_ascii_digit()) + || bundle.source_sha.len() != 40 + || !bundle + .source_sha + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) + || manifest::stable_version(&bundle.version).is_err() + { + return Err(InstallError::Invalid("Linux bundle identity changed")); + } + Ok(bundle) + } + + fn matches_release(&self, release: &crate::VerifiedRelease) -> Result<()> { + if self.version != release.manifest.version + || self.build != release.manifest.build + || self.source_sha != release.manifest.source_sha + { + return Err(InstallError::Invalid( + "bundle differs from signed release identity", + )); + } + Ok(()) + } +} + +fn root_name(profile: &str) -> Result<&'static str> { + match profile { + "prod" => Ok(PROD_ROOT), + "dev" => Ok(DEV_ROOT), + _ => Err(InstallError::Invalid("invalid Linux bundle profile")), + } +} + +pub(super) fn is_bundle_path(path: &Path) -> bool { + path.file_name() + .is_some_and(|name| name == PROD_ROOT || name == DEV_ROOT) +} + +fn archive( + payload: &Path, + output: Option<&Path>, + profile: &str, +) -> Result<(BTreeMap, Bundle)> { + let root = root_name(profile)?; + let (nodes, descriptor) = scan_archive_root(payload, output, root)?; + let bundle = Bundle::parse( + &descriptor.ok_or(InstallError::Invalid("missing bundle descriptor"))?, + profile, + )?; + for executable in [ + "AppRun", + "usr/bin/maple-agent", + "usr/lib/ld-linux-x86-64.so.2", + ] { + if !matches!( + nodes.get(&format!("{root}/{executable}")), + Some(Node::File { mode: 0o755, .. }) + ) { + return Err(InstallError::Invalid( + "missing ordinary Linux bundle executable", + )); + } + } + if !matches!( + nodes.get(&format!("{root}/bundle.json")), + Some(Node::File { mode: 0o644, .. }) + ) { + return Err(InstallError::Invalid( + "invalid bundle descriptor permissions", + )); + } + Ok((nodes, bundle)) +} + +/// Release CI uses the same bounded parser and metadata/layout checks as install. +/// This inspects data only, without extraction or execution. +pub fn validate_linux_bundle_archive(payload: &Path, profile: &str) -> Result<()> { + archive(payload, None, profile).map(|_| ()) +} + +fn read_bounded(path: &Path, limit: u64) -> Result> { + let file = open_regular(path, false, false)?; + let metadata = file.metadata()?; + #[cfg(unix)] + if metadata.nlink() != 1 { + return Err(InstallError::Invalid("metadata file is hard-linked")); + } + if metadata.len() > limit { + return Err(InstallError::Invalid("metadata file is too large")); + } + let mut bytes = Vec::new(); + file.take(limit + 1).read_to_end(&mut bytes)?; + if bytes.len() as u64 > limit { + return Err(InstallError::Invalid("metadata file grew")); + } + Ok(bytes) +} + +fn installed_bundle(path: &Path) -> Result { + let bundle = Bundle::parse( + &read_bounded(&path.join("bundle.json"), DESCRIPTOR_LIMIT)?, + "prod", + )?; + for directory in ["usr", "usr/bin", "usr/lib"] { + let metadata = fs::symlink_metadata(path.join(directory))?; + if !metadata.is_dir() || metadata.file_type().is_symlink() { + return Err(InstallError::Invalid("Linux bundle layout changed")); + } + } + for executable in [ + "AppRun", + "usr/bin/maple-agent", + "usr/lib/ld-linux-x86-64.so.2", + ] { + let metadata = fs::symlink_metadata(path.join(executable))?; + if !metadata.is_file() || metadata.file_type().is_symlink() { + return Err(InstallError::Invalid("Linux bundle executable changed")); + } + #[cfg(unix)] + if metadata.mode() & 0o7777 != 0o755 || metadata.nlink() != 1 { + return Err(InstallError::Invalid( + "unsafe Linux bundle executable permissions", + )); + } + } + Ok(bundle) +} + +pub(super) fn validate_destination(installed: &Path) -> Result<()> { + if !installed.is_absolute() + || installed.file_name().is_none_or(|name| name != PROD_ROOT) + || installed + .to_str() + .is_none_or(|value| value.chars().any(char::is_control)) + || installed.components().any(|part| { + matches!(part, Component::CurDir | Component::ParentDir) + || part + .as_os_str() + .to_str() + .is_some_and(|value| value.starts_with(STAGING_PREFIX)) + }) + { + return Err(InstallError::NotifyOnly( + "run a user-owned installed Maple Agent Linux bundle", + )); + } + let metadata = fs::symlink_metadata(installed)?; + if !metadata.is_dir() + || metadata.file_type().is_symlink() + || installed.canonicalize()? != installed + { + return Err(InstallError::Invalid( + "installation is not a canonical Linux bundle directory", + )); + } + #[cfg(unix)] + if metadata.uid() != unsafe { libc::geteuid() } || metadata.mode() & 0o7022 != 0 { + return Err(InstallError::NotifyOnly( + "this Linux bundle is not safely owned by this account", + )); + } + Ok(()) +} + +pub(super) fn preflight(installed: &Path) -> Result<()> { + validate_destination(installed)?; + installed_bundle(installed)?; + let _probe = tempfile::Builder::new() + .prefix(".maple-agent-write-probe-") + .tempfile_in(installed.parent().unwrap()) + .map_err(destination_error)?; + let _lock = open_install_lock(installed)?; + Ok(()) +} + +/// `resolved_apprun` comes from the running bundle's validated launcher context, +/// not an environment path. The compiled identity prevents adopting another copy. +pub fn discover_linux_installation( + resolved_apprun: &Path, + running_version: &str, + running_source_sha: &str, +) -> Result { + if resolved_apprun + .file_name() + .is_none_or(|name| name != "AppRun") + || resolved_apprun.canonicalize()? != resolved_apprun + { + return Err(InstallError::NotifyOnly( + "run the installed Linux AppRun launcher", + )); + } + let installed = resolved_apprun + .parent() + .ok_or(InstallError::Invalid("missing bundle parent"))?; + preflight(installed)?; + let bundle = installed_bundle(installed)?; + if bundle.version != running_version || bundle.source_sha != running_source_sha { + return Err(InstallError::Invalid( + "installed Linux bundle differs from running app", + )); + } + Ok(installed.to_owned()) +} + +fn stage_prefix(installed: &Path) -> String { + let digest = hex::encode(Sha256::digest(installed.as_os_str().as_encoded_bytes())); + format!("{STAGING_PREFIX}{digest}-") +} + +fn validate_stage(installed: &Path, stage: &Path) -> Result<()> { + let prefix = stage_prefix(installed); + if stage.parent() != installed.parent() + || !stage + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| { + name.strip_prefix(&prefix).is_some_and(|tail| { + tail.len() == STAGING_RANDOM_LENGTH + && tail.bytes().all(|b| b.is_ascii_alphanumeric()) + }) + }) + || stage.canonicalize()? != stage + { + return Err(InstallError::Invalid( + "Linux staging does not belong to this installation", + )); + } + let metadata = fs::symlink_metadata(stage)?; + if !metadata.is_dir() || metadata.file_type().is_symlink() { + return Err(InstallError::Invalid("Linux staging directory changed")); + } + #[cfg(unix)] + if metadata.uid() != unsafe { libc::geteuid() } + || metadata.mode() & 0o777 != 0o700 + || metadata.dev() != fs::metadata(installed)?.dev() + { + return Err(InstallError::Invalid( + "Linux staging ownership, permissions or volume changed", + )); + } + Ok(()) +} + +fn expected_artifact(update: &PreparedUpdate) -> Result<&Artifact> { + manifest::validate_manifest(&update.release.manifest) + .map_err(|_| InstallError::Invalid("manifest fields changed"))?; + update + .release + .artifact(Target::LinuxX86_64) + .map_err(|_| InstallError::Invalid("missing Linux v2 bundle payload")) +} + +fn upgrade_destination(update: &PreparedUpdate) -> Result<()> { + validate_destination(&update.installed_path)?; + let current = installed_bundle(&update.installed_path)?; + let current_version = manifest::stable_version(¤t.version) + .map_err(|_| InstallError::Invalid("invalid installed Linux version"))?; + let proposed = manifest::stable_version(&update.release.manifest.version) + .map_err(|_| InstallError::Invalid("invalid proposed Linux version"))?; + if proposed <= current_version { + return Err(InstallError::Invalid( + "installation already has this version or a newer version", + )); + } + Ok(()) +} + +pub(super) fn prepare(update: &PreparedUpdate) -> Result { + if !cfg!(all(target_os = "linux", target_arch = "x86_64")) { + return Err(InstallError::NotifyOnly( + "automatic Linux installation requires x86_64 Linux", + )); + } + preflight(&update.installed_path)?; + upgrade_destination(update)?; + 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.parent().unwrap()) + .map_err(destination_error)?; + set_mode(temporary.path(), 0o700)?; + 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(PROD_ROOT); + if scan_tree_root(&staged_path, PROD_ROOT)? != expected { + return Err(InstallError::Invalid( + "staged Linux bundle differs from signed archive", + )); + } + sync_directory(temporary.path())?; + let prepared = 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(prepared) +} + +pub(super) fn revalidate(prepared: &InstallPreparation, update: &PreparedUpdate) -> Result<()> { + preflight(&update.installed_path)?; + upgrade_destination(update)?; + 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(PROD_ROOT) + || prepared.version != update.release.manifest.version + || prepared.build != update.release.manifest.build + || prepared.size != artifact.size + || prepared.sha256 != artifact.sha256 + || fs::symlink_metadata(prepared.staging_dir.join(RECEIPT_NAME)).is_ok() + { + return Err(InstallError::Invalid("persisted Linux preparation changed")); + } + verify_payload(&prepared.payload_path, artifact)?; + let (expected, bundle) = archive(&prepared.payload_path, None, "prod")?; + bundle.matches_release(&update.release)?; + if scan_tree_root(&prepared.staged_path, PROD_ROOT)? != expected { + return Err(InstallError::Invalid( + "staged Linux content differs from signed archive", + )); + } + Ok(()) +} + +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(PROD_ROOT) + || fs::symlink_metadata(prepared.staging_dir.join(RECEIPT_NAME)).is_ok() + { + return Err(InstallError::Invalid( + "cannot discard a changed Linux preparation or retained backup", + )); + } + fs::remove_dir_all(&prepared.staging_dir)?; + sync_directory(prepared.installed_path.parent().unwrap()) +} + +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct Receipt { + format: String, + installed: InstalledUpdate, + envelope: String, +} + +pub(super) fn replace( + prepared: &InstallPreparation, + update: &PreparedUpdate, +) -> Result { + replace_with_synchronizer(prepared, update, Synchronizer::Native) +} + +fn replace_with_synchronizer( + prepared: &InstallPreparation, + update: &PreparedUpdate, + synchronizer: Synchronizer, +) -> Result { + if !cfg!(all(target_os = "linux", target_arch = "x86_64")) { + return Err(InstallError::NotifyOnly( + "automatic Linux installation requires x86_64 Linux", + )); + } + revalidate(prepared, update)?; + let mut installed = InstalledUpdate { + installed_path: prepared.installed_path.clone(), + staging_dir: prepared.staging_dir.clone(), + backup_path: prepared.staged_path.clone(), + version: prepared.version.clone(), + build: prepared.build.clone(), + durability_warning: false, + }; + let receipt = Receipt { + format: "maple-agent-linux-install-v1".into(), + installed: installed.clone(), + envelope: update.release.envelope.clone(), + }; + let bytes = serde_json::to_vec(&receipt) + .map_err(|_| InstallError::Invalid("cannot serialize Linux receipt"))?; + if bytes.len() as u64 > RECEIPT_LIMIT { + return Err(InstallError::Invalid("Linux receipt is too large")); + } + let mut file = tempfile::NamedTempFile::new_in(&prepared.staging_dir)?; + file.write_all(&bytes)?; + file.as_file().sync_all()?; + file.persist(prepared.staging_dir.join(RECEIPT_NAME)) + .map_err(|error| error.error)?; + sync_directory(&prepared.staging_dir)?; + // Persist the private stage's parent entry before it can retain the old app. + sync_directory(prepared.installed_path.parent().unwrap())?; + if let Err(error) = exchange(&prepared.installed_path, &prepared.staged_path) { + let _ = fs::remove_file(prepared.staging_dir.join(RECEIPT_NAME)); + return Err(error); + } + // Successful exchange is the commit point, including later fsync errors. + let stage_synced = synchronizer.sync(&prepared.staging_dir).is_ok(); + let parent_synced = synchronizer + .sync(prepared.installed_path.parent().unwrap()) + .is_ok(); + installed.durability_warning = !stage_synced || !parent_synced; + Ok(installed) +} + +fn exchange(installed: &Path, staged: &Path) -> Result<()> { + #[cfg(target_os = "linux")] + { + use std::{ffi::CString, os::unix::ffi::OsStrExt}; + let installed = CString::new(installed.as_os_str().as_bytes()) + .map_err(|_| InstallError::Invalid("invalid installation path"))?; + let staged = CString::new(staged.as_os_str().as_bytes()) + .map_err(|_| InstallError::Invalid("invalid staging path"))?; + if unsafe { + libc::renameat2( + libc::AT_FDCWD, + installed.as_ptr(), + libc::AT_FDCWD, + staged.as_ptr(), + libc::RENAME_EXCHANGE, + ) + } != 0 + { + let error = io::Error::last_os_error(); + if matches!( + error.raw_os_error(), + Some(libc::ENOSYS | libc::EINVAL | libc::EOPNOTSUPP) + ) { + return Err(InstallError::NotifyOnly( + "this filesystem does not support atomic Linux bundle replacement", + )); + } + return Err(error.into()); + } + Ok(()) + } + #[cfg(not(target_os = "linux"))] + { + let _ = (installed, staged); + Err(InstallError::NotifyOnly( + "Linux replacement is unavailable on this platform", + )) + } +} + +/// After SQL/frontend startup succeeds, authenticate retained receipts using +/// compiled key pins and the actual running identity before deleting old bytes. +pub fn cleanup_current_linux_bundle( + installed: &Path, + running_version: &str, + running_source_sha: &str, + compiled_origin: &Url, + compiled_public_keys: &[String], +) -> Result { + validate_destination(installed)?; + let _lock = acquire_install_lock(installed)?; + let current = installed_bundle(installed)?; + if current.version != running_version || current.source_sha != running_source_sha { + return Err(InstallError::Invalid( + "installed Linux identity differs from running app", + )); + } + let parent = installed.parent().unwrap(); + let prefix = stage_prefix(installed); + let mut cleaned = 0; + for entry in fs::read_dir(parent)?.take(4096) { + let entry = entry?; + if !entry + .file_name() + .to_str() + .is_some_and(|name| name.starts_with(&prefix)) + { + continue; + } + let stage = entry.path(); + if validate_stage(installed, &stage).is_err() { + continue; + } + match fs::symlink_metadata(stage.join(RECEIPT_NAME)) { + Err(error) if error.kind() == io::ErrorKind::NotFound => { + fs::remove_dir_all(&stage)?; + sync_directory(parent)?; + cleaned += 1; + continue; + } + Ok(_) => {} + Err(_) => continue, + } + // An invalid receipt may still retain the previous install. Preserve it. + let verified = (|| -> Result<()> { + let receipt: Receipt = + serde_json::from_slice(&read_bounded(&stage.join(RECEIPT_NAME), RECEIPT_LIMIT)?) + .map_err(|_| InstallError::Invalid("invalid Linux install receipt"))?; + if receipt.format != "maple-agent-linux-install-v1" + || receipt.installed.installed_path != installed + || receipt.installed.staging_dir != stage + || receipt.installed.backup_path != stage.join(PROD_ROOT) + || receipt.installed.version != running_version + || receipt.installed.build != current.build + { + return Err(InstallError::Invalid("Linux receipt identity changed")); + } + let release = manifest::verify_envelope( + receipt.envelope.as_bytes(), + compiled_origin, + compiled_public_keys, + ) + .map_err(|_| InstallError::Invalid("Linux receipt signature is invalid"))?; + current.matches_release(&release)?; + let artifact = release + .artifact(Target::LinuxX86_64) + .map_err(|_| InstallError::Invalid("Linux receipt has the wrong payload format"))?; + let payload = stage.join("payload"); + verify_payload(&payload, artifact)?; + let (expected, bundle) = archive(&payload, None, "prod")?; + bundle.matches_release(&release)?; + if scan_tree_root(installed, PROD_ROOT)? != expected { + return Err(InstallError::Invalid( + "installed Linux tree differs from signed archive", + )); + } + Ok(()) + })(); + if verified.is_ok() { + fs::remove_dir_all(&stage)?; + sync_directory(parent)?; + cleaned += 1; + } + } + Ok(cleaned) +} + +#[cfg(all(test, unix))] +#[path = "linux_bundle_tests.rs"] +mod tests; 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 new file mode 100644 index 000000000..c335a6502 --- /dev/null +++ b/apps/maple-agent/crates/maple-agent-updater/src/installer/linux_bundle_tests.rs @@ -0,0 +1,520 @@ +use super::*; +use base64::{Engine, engine::general_purpose::STANDARD}; +use blake2::Blake2b512; +use ed25519_dalek::{Signer, SigningKey}; +use flate2::{Compression, write::GzEncoder}; + +enum Member { + Directory(String), + File(String, Vec, u32), + Link(String, String), + Special(String, u8), +} + +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(); + let (name, bytes, kind, mode, link) = match member { + Member::Directory(name) => (name, &[][..], b'5', 0o755, None), + Member::File(name, bytes, mode) => (name, bytes.as_slice(), b'0', *mode, None), + Member::Link(name, target) => (name, &[][..], b'2', 0o777, Some(target)), + Member::Special(name, kind) => (name, &[][..], *kind, 0o644, None), + }; + header.set_mode(mode); + header.set_size(bytes.len() as u64); + header.set_entry_type(tar::EntryType::new(kind)); + header.set_uid(0); + header.set_gid(0); + header.set_mtime(0); + // Preserve deliberately unsafe paths in negative fixtures instead of + // letting tar's convenience writer reject them before our parser runs. + assert!(name.len() < 100); + header.as_mut_bytes()[..100].fill(0); + header.as_mut_bytes()[..name.len()].copy_from_slice(name.as_bytes()); + if let Some(target) = link { + header.set_link_name(target).unwrap(); + } + header.set_cksum(); + writer.append(&header, bytes).unwrap(); + } + writer + .into_inner() + .unwrap() + .finish() + .unwrap() + .sync_all() + .unwrap(); +} + +fn descriptor(version: &str, build: &str, source: &str) -> Bundle { + Bundle { + format: "maple-agent-linux-bundle-v1".into(), + product: "maple-agent".into(), + profile: "prod".into(), + version: version.into(), + build: build.into(), + source_sha: source.into(), + bundle_id: "cloud.opensecret.maple.agent".into(), + entrypoint: "AppRun".into(), + executable: "usr/bin/maple-agent".into(), + } +} + +fn members(bundle: &Bundle) -> Vec { + vec![ + Member::Directory(PROD_ROOT.into()), + Member::Directory(format!("{PROD_ROOT}/usr")), + Member::Directory(format!("{PROD_ROOT}/usr/bin")), + Member::Directory(format!("{PROD_ROOT}/usr/lib")), + Member::File( + format!("{PROD_ROOT}/bundle.json"), + serde_json::to_vec(bundle).unwrap(), + 0o644, + ), + // These are filesystem fixtures, never executable test programs. + Member::File( + format!("{PROD_ROOT}/AppRun"), + format!("launcher {}", bundle.version).into_bytes(), + 0o755, + ), + Member::File( + format!("{PROD_ROOT}/usr/bin/maple-agent"), + format!("payload {}", bundle.version).into_bytes(), + 0o755, + ), + Member::File( + format!("{PROD_ROOT}/usr/lib/ld-linux-x86-64.so.2"), + b"loader fixture".to_vec(), + 0o755, + ), + Member::Link( + format!("{PROD_ROOT}/usr/lib/current-loader"), + "ld-linux-x86-64.so.2".into(), + ), + ] +} + +fn public_key(seed: u8) -> String { + let signing = SigningKey::from_bytes(&[seed; 32]); + let mut bytes = b"Ed12345678".to_vec(); + bytes.extend_from_slice(signing.verifying_key().as_bytes()); + STANDARD.encode(bytes) +} + +fn signed_release(payload: &Path, bundle: &Bundle) -> crate::VerifiedRelease { + let artifact = Artifact { + path: format!( + "/agent/stable/releases/{}/Maple-Agent-linux-x86_64.tar.gz", + bundle.version + ), + size: fs::metadata(payload).unwrap().len(), + sha256: hex::encode(Sha256::digest(fs::read(payload).unwrap())), + }; + let manifest = crate::Manifest { + format: "maple-agent-update-v2".into(), + product: "maple-agent".into(), + channel: "stable".into(), + version: bundle.version.clone(), + build: bundle.build.clone(), + source_sha: bundle.source_sha.clone(), + pub_date: "2026-10-23T00:00:00Z".into(), + notes: "Linux filesystem fixture".into(), + artifacts: BTreeMap::from([("linux-x86_64".into(), artifact.clone())]), + downloads: crate::Downloads { + macos: None, + linux_appimage: None, + linux_bundle: Some(artifact), + }, + }; + let text = serde_json::to_string(&manifest).unwrap(); + let comment = format!( + "product=maple-agent channel=stable version={}", + bundle.version + ); + // The deterministic key is test-only and never an application trust pin. + let signing = SigningKey::from_bytes(&[7; 32]); + let signature = signing.sign(&Blake2b512::digest(text.as_bytes())); + let mut packet = b"ED12345678".to_vec(); + packet.extend_from_slice(&signature.to_bytes()); + let mut global_message = signature.to_bytes().to_vec(); + global_message.extend_from_slice(comment.as_bytes()); + let global_signature = signing.sign(&global_message); + let envelope = serde_json::to_vec(&serde_json::json!({ + "manifest": text, + "signature": format!("untrusted comment: TEST ONLY\n{}\ntrusted comment: {comment}\n{}\n", STANDARD.encode(packet), STANDARD.encode(global_signature.to_bytes())), + })).unwrap(); + manifest::verify_envelope(&envelope, &origin(), &[public_key(7)]).unwrap() +} + +fn origin() -> Url { + "https://updates.example.test".parse().unwrap() +} + +struct Fixture { + _directory: tempfile::TempDir, + root: PathBuf, + update: PreparedUpdate, +} + +fn fixture() -> Fixture { + let directory = tempfile::tempdir().unwrap(); + let root = directory.path().canonicalize().unwrap(); + let parent = root.join("installation with spaces"); + fs::create_dir(&parent).unwrap(); + set_mode(&parent, 0o755).unwrap(); + let old_payload = root.join("old.tar.gz"); + write_archive( + &old_payload, + &members(&descriptor("0.0.1", "1", &"a".repeat(40))), + ); + archive(&old_payload, Some(&parent), "prod").unwrap(); + let payload_path = root.join("download.tar.gz"); + let bundle = descriptor("0.0.2", "2", &"b".repeat(40)); + write_archive(&payload_path, &members(&bundle)); + let release = signed_release(&payload_path, &bundle); + Fixture { + _directory: directory, + root, + update: PreparedUpdate { + release, + payload_path, + installed_path: parent.join(PROD_ROOT), + }, + } +} + +#[test] +fn archive_and_discovery_require_the_exact_bundle_identity() { + let fixture = fixture(); + validate_linux_bundle_archive(&fixture.update.payload_path, "prod").unwrap(); + assert!(validate_linux_bundle_archive(&fixture.update.payload_path, "dev").is_err()); + assert_eq!( + discover_linux_installation( + &fixture.update.installed_path.join("AppRun"), + "0.0.1", + &"a".repeat(40) + ) + .unwrap(), + fixture.update.installed_path, + ); + assert!( + discover_linux_installation( + &fixture.update.installed_path.join("AppRun"), + "0.0.1", + &"b".repeat(40) + ) + .is_err() + ); + let alias = fixture.root.join("launcher-alias"); + std::os::unix::fs::symlink(fixture.update.installed_path.join("AppRun"), &alias).unwrap(); + assert!(discover_linux_installation(&alias, "0.0.1", &"a".repeat(40)).is_err()); +} + +#[test] +fn both_archive_roots_reject_escape_links_special_files_and_wrong_roots() { + let directory = tempfile::tempdir().unwrap(); + let sentinel = directory.path().join("outside"); + fs::write(&sentinel, b"untouched").unwrap(); + for root in [APP_NAME, PROD_ROOT] { + let cases = [ + vec![Member::File( + "../outside".into(), + b"changed".to_vec(), + 0o644, + )], + vec![Member::Directory("another.app".into())], + vec![Member::Link(format!("{root}/escape"), "../outside".into())], + vec![Member::Special(format!("{root}/hardlink"), b'1')], + vec![Member::Special(format!("{root}/fifo"), b'6')], + vec![ + Member::Directory(format!("{root}/sub")), + Member::Link(format!("{root}/sub/up"), "..".into()), + Member::Link(format!("{root}/escape"), "sub/up/../outside".into()), + ], + ]; + for (index, mut extra) in cases.into_iter().enumerate() { + let payload = directory.path().join(format!("bad-{index}.tar.gz")); + let mut members = vec![Member::Directory(root.into())]; + members.append(&mut extra); + write_archive(&payload, &members); + let output = directory.path().join(format!("output-{index}")); + fs::create_dir_all(&output).unwrap(); + assert!(scan_archive_root(&payload, Some(&output), root).is_err()); + assert_eq!(fs::read(&sentinel).unwrap(), b"untouched"); + fs::remove_dir_all(output).unwrap(); + } + } +} + +#[cfg(all(target_os = "linux", target_arch = "x86_64"))] +#[test] +fn preparation_revalidates_signed_bytes_modes_links_and_extra_files() { + for mutation in 0..5 { + let fixture = fixture(); + let _lock = acquire_install_lock(&fixture.update.installed_path).unwrap(); + let prepared = prepare(&fixture.update).unwrap(); + revalidate(&prepared, &fixture.update).unwrap(); + let cache = fs::metadata(&fixture.update.payload_path).unwrap(); + let copy = fs::metadata(&prepared.payload_path).unwrap(); + assert_ne!((cache.dev(), cache.ino()), (copy.dev(), copy.ino())); + match mutation { + 0 => fs::write(prepared.staged_path.join("AppRun"), b"changed launcher").unwrap(), + 1 => set_mode(&prepared.staged_path.join("AppRun"), 0o644).unwrap(), + 2 => { + fs::remove_file(prepared.staged_path.join("AppRun")).unwrap(); + std::os::unix::fs::symlink( + "usr/bin/maple-agent", + prepared.staged_path.join("AppRun"), + ) + .unwrap(); + } + 3 => fs::write(prepared.staged_path.join("unexpected"), b"extra file").unwrap(), + 4 => fs::write(&prepared.payload_path, b"changed signed archive").unwrap(), + _ => unreachable!(), + } + assert!(revalidate(&prepared, &fixture.update).is_err()); + assert_eq!( + installed_bundle(&fixture.update.installed_path) + .unwrap() + .version, + "0.0.1" + ); + discard(&prepared).unwrap(); + assert!(fixture.update.payload_path.exists()); + } +} + +#[cfg(all(target_os = "linux", target_arch = "x86_64"))] +#[test] +fn stale_preparation_cannot_replace_an_equal_or_newer_installation() { + for current_version in ["0.0.2", "0.0.3"] { + let fixture = fixture(); + let _lock = acquire_install_lock(&fixture.update.installed_path).unwrap(); + let prepared = prepare(&fixture.update).unwrap(); + let current = descriptor(current_version, "3", &"c".repeat(40)); + fs::write( + fixture.update.installed_path.join("bundle.json"), + serde_json::to_vec(¤t).unwrap(), + ) + .unwrap(); + fs::write( + fixture.update.installed_path.join("AppRun"), + b"newer live installation", + ) + .unwrap(); + assert!(revalidate(&prepared, &fixture.update).is_err()); + assert!(replace(&prepared, &fixture.update).is_err()); + assert_eq!( + fs::read(fixture.update.installed_path.join("AppRun")).unwrap(), + b"newer live installation" + ); + assert!(!prepared.staging_dir.join(RECEIPT_NAME).exists()); + discard(&prepared).unwrap(); + } +} + +#[test] +fn orphan_cleanup_preserves_other_installations_and_uncertain_receipts() { + let fixture = fixture(); + let installed = &fixture.update.installed_path; + let make_stage = |path: &Path| { + let stage = tempfile::Builder::new() + .prefix(&stage_prefix(path)) + .rand_bytes(STAGING_RANDOM_LENGTH) + .tempdir_in(installed.parent().unwrap()) + .unwrap(); + set_mode(stage.path(), 0o700).unwrap(); + let path = stage.keep(); + fs::write(path.join("sentinel"), b"retained").unwrap(); + path + }; + let orphan = make_stage(installed); + let other = make_stage(&installed.with_file_name(DEV_ROOT)); + let uncertain = make_stage(installed); + fs::write(uncertain.join(RECEIPT_NAME), b"truncated receipt").unwrap(); + assert_eq!( + cleanup_current_linux_bundle( + installed, + "0.0.1", + &"a".repeat(40), + &origin(), + &[public_key(7)] + ) + .unwrap(), + 1 + ); + assert!(!orphan.exists()); + assert_eq!(fs::read(other.join("sentinel")).unwrap(), b"retained"); + assert_eq!(fs::read(uncertain.join("sentinel")).unwrap(), b"retained"); +} + +#[cfg(all(target_os = "linux", target_arch = "x86_64"))] +fn commit(fixture: &Fixture) -> (InstallPreparation, InstalledUpdate) { + let lock = acquire_install_lock(&fixture.update.installed_path).unwrap(); + let prepared = prepare(&fixture.update).unwrap(); + let installed = replace(&prepared, &fixture.update).unwrap(); + drop(lock); + (prepared, installed) +} + +#[cfg(all(target_os = "linux", target_arch = "x86_64"))] +#[test] +fn native_exchange_retains_backup_until_authenticated_new_startup() { + let fixture = fixture(); + let (prepared, installed) = commit(&fixture); + assert_eq!( + installed.restart_path(), + fixture.update.installed_path.join("AppRun") + ); + assert_eq!( + installed_bundle(&installed.installed_path).unwrap().version, + "0.0.2" + ); + assert_eq!( + installed_bundle(&installed.backup_path).unwrap().version, + "0.0.1" + ); + assert!(discard(&prepared).is_err()); + assert!(installed.checkpoint_cleanup().is_err()); + assert!( + cleanup_current_linux_bundle( + &installed.installed_path, + "0.0.1", + &"a".repeat(40), + &origin(), + &[public_key(7)] + ) + .is_err() + ); + assert_eq!( + cleanup_current_linux_bundle( + &installed.installed_path, + "0.0.2", + &"b".repeat(40), + &origin(), + &[public_key(9)] + ) + .unwrap(), + 0 + ); + assert!(installed.backup_path.exists()); + // No profile-state save occurred: the signed receipt alone recovers the + // commit once the caller supplies the new runtime's startup identity. + assert_eq!( + cleanup_current_linux_bundle( + &installed.installed_path, + "0.0.2", + &"b".repeat(40), + &origin(), + &[public_key(7)] + ) + .unwrap(), + 1 + ); + assert!(!installed.staging_dir.exists()); + assert!(fixture.update.payload_path.exists()); + assert_eq!( + installed_bundle(&installed.installed_path).unwrap().version, + "0.0.2" + ); +} + +#[cfg(all(target_os = "linux", target_arch = "x86_64"))] +#[test] +fn checkpoint_preserves_backups_when_receipt_payload_or_installed_tree_changes() { + for mutation in 0..4 { + let fixture = fixture(); + let (prepared, installed) = commit(&fixture); + match mutation { + 0 => fs::write( + installed.staging_dir.join(RECEIPT_NAME), + b"malformed receipt", + ) + .unwrap(), + 1 => { + let path = installed.staging_dir.join(RECEIPT_NAME); + let mut receipt: serde_json::Value = + serde_json::from_slice(&fs::read(&path).unwrap()).unwrap(); + receipt["installed"]["installed_path"] = + serde_json::json!(fixture.root.join("another.app")); + fs::write(path, serde_json::to_vec(&receipt).unwrap()).unwrap(); + } + 2 => fs::write(&prepared.payload_path, b"changed retained payload").unwrap(), + 3 => fs::write( + installed.installed_path.join("AppRun"), + b"changed installed launcher", + ) + .unwrap(), + _ => unreachable!(), + } + assert_eq!( + cleanup_current_linux_bundle( + &installed.installed_path, + "0.0.2", + &"b".repeat(40), + &origin(), + &[public_key(7)] + ) + .unwrap(), + 0 + ); + assert!(installed.backup_path.exists()); + assert_eq!( + installed_bundle(&installed.backup_path).unwrap().version, + "0.0.1" + ); + } +} + +#[cfg(all(target_os = "linux", target_arch = "x86_64"))] +#[test] +fn native_exchange_failure_preserves_the_running_installation() { + let fixture = fixture(); + let prepared = prepare(&fixture.update).unwrap(); + assert!( + exchange( + &fixture.update.installed_path, + &fixture.root.join("missing") + ) + .is_err() + ); + assert_eq!( + installed_bundle(&fixture.update.installed_path) + .unwrap() + .version, + "0.0.1" + ); + assert_eq!( + installed_bundle(&prepared.staged_path).unwrap().version, + "0.0.2" + ); + discard(&prepared).unwrap(); +} + +#[cfg(all(target_os = "linux", target_arch = "x86_64"))] +#[test] +fn post_exchange_sync_failure_is_a_committed_update() { + let fixture = fixture(); + let lock = acquire_install_lock(&fixture.update.installed_path).unwrap(); + let prepared = prepare(&fixture.update).unwrap(); + let installed = replace_with_synchronizer( + &prepared, + &fixture.update, + Synchronizer::Test(|_| Err(io::Error::other("injected post-commit sync failure").into())), + ) + .unwrap(); + assert!(installed.durability_warning); + assert_eq!( + installed_bundle(&installed.installed_path).unwrap().version, + "0.0.2" + ); + assert_eq!( + installed_bundle(&installed.backup_path).unwrap().version, + "0.0.1" + ); + assert!(installed.staging_dir.join(RECEIPT_NAME).is_file()); + assert!(discard(&prepared).is_err()); + drop(lock); +} 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 673e4c791..9e56d3918 100644 --- a/apps/maple-agent/crates/maple-agent-updater/src/manifest.rs +++ b/apps/maple-agent/crates/maple-agent-updater/src/manifest.rs @@ -24,6 +24,34 @@ impl Target { Self::LinuxX86_64 => "linux-x86_64", } } + + pub fn feed_path(self) -> &'static str { + match self { + Self::DarwinAarch64 => "/agent/stable/latest.json", + Self::LinuxX86_64 => "/agent/stable/linux/latest.json", + } + } + + pub fn manifest_filename(self) -> &'static str { + match self { + Self::DarwinAarch64 => "manifest.json", + Self::LinuxX86_64 => "linux-manifest.json", + } + } + + pub fn manifest_path(self, version: &str) -> String { + format!( + "/agent/stable/releases/{version}/{}", + self.manifest_filename() + ) + } + + fn manifest_format(self) -> &'static str { + match self { + Self::DarwinAarch64 => "maple-agent-update-v1", + Self::LinuxX86_64 => "maple-agent-update-v2", + } + } } #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] @@ -37,9 +65,12 @@ pub struct Artifact { #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct Downloads { - pub macos: Artifact, + #[serde(skip_serializing_if = "Option::is_none")] + pub macos: Option, #[serde(rename = "linux-appimage", skip_serializing_if = "Option::is_none")] pub linux_appimage: Option, + #[serde(rename = "linux-bundle", skip_serializing_if = "Option::is_none")] + pub linux_bundle: Option, } #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] @@ -73,6 +104,10 @@ impl VerifiedRelease { } pub fn artifact(&self, target: Target) -> Result<&Artifact> { + ensure!( + self.manifest.format == target.manifest_format(), + "release format does not support this target" + ); self.manifest .artifacts .get(target.key()) @@ -131,6 +166,7 @@ pub fn verify_envelope(bytes: &[u8], origin: &Url, keys: &[String]) -> Result bool { .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) } +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 + // even when a forbidden download field has a null value. + let value: serde_json::Value = serde_json::from_str(text)?; + let downloads = value["downloads"] + .as_object() + .context("invalid download inventory")?; + match format { + "maple-agent-update-v1" => ensure!( + !downloads.contains_key("linux-bundle"), + "Linux bundles require update format v2" + ), + "maple-agent-update-v2" => ensure!( + downloads.len() == 1 && downloads.contains_key("linux-bundle"), + "update format v2 requires only the Linux bundle download" + ), + _ => bail!("unsupported update format"), + } + Ok(()) +} + pub fn validate_manifest(manifest: &Manifest) -> Result<()> { ensure!( - manifest.format == "maple-agent-update-v1", + matches!( + manifest.format.as_str(), + "maple-agent-update-v1" | "maple-agent-update-v2" + ), "unsupported update format" ); ensure!(manifest.product == "maple-agent", "wrong update product"); @@ -196,6 +257,18 @@ pub fn validate_manifest(manifest: &Manifest) -> Result<()> { .any(|c| c.is_control() && c != '\n' && c != '\t'), "invalid release notes" ); + match manifest.format.as_str() { + "maple-agent-update-v1" => validate_v1_inventory(manifest), + "maple-agent-update-v2" => validate_v2_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" + ); ensure!( manifest.artifacts.contains_key("darwin-aarch64"), "macOS artifact is required" @@ -208,7 +281,15 @@ pub fn validate_manifest(manifest: &Manifest) -> Result<()> { }; validate_artifact(artifact, &manifest.version, suffix)?; } - validate_artifact(&manifest.downloads.macos, &manifest.version, ".dmg")?; + validate_artifact( + manifest + .downloads + .macos + .as_ref() + .context("macOS download is required")?, + &manifest.version, + ".dmg", + )?; ensure!( manifest.artifacts.contains_key("linux-x86_64") == manifest.downloads.linux_appimage.is_some(), @@ -224,6 +305,24 @@ pub fn validate_manifest(manifest: &Manifest) -> Result<()> { Ok(()) } +fn validate_v2_inventory(manifest: &Manifest) -> Result<()> { + ensure!( + manifest.artifacts.len() == 1 && manifest.artifacts.contains_key("linux-x86_64"), + "update format v2 requires only the Linux artifact" + ); + ensure!( + manifest.downloads.macos.is_none() && manifest.downloads.linux_appimage.is_none(), + "update format v2 requires only the Linux bundle download" + ); + let artifact = &manifest.artifacts["linux-x86_64"]; + validate_artifact(artifact, &manifest.version, ".tar.gz")?; + ensure!( + manifest.downloads.linux_bundle.as_ref() == Some(artifact), + "Linux bundle download differs from updater payload" + ); + Ok(()) +} + fn validate_artifact(artifact: &Artifact, version: &str, suffix: &str) -> Result<()> { ensure!( artifact.size > 0 && artifact.size <= MAX_ARTIFACT_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 b2c6e4483..7d44d8dda 100644 --- a/apps/maple-agent/crates/maple-agent-updater/src/tests.rs +++ b/apps/maple-agent/crates/maple-agent-updater/src/tests.rs @@ -19,6 +19,13 @@ use crate::{ const PAYLOAD: &[u8] = b"signed payload fixture bytes"; +// Frozen pre-Linux validator from 264560b0, exercising the installed macOS +// client's complete parsing and signature contract against new publisher bytes. +#[allow(dead_code)] +mod legacy_v1 { + include!("../tests/fixtures/legacy-v1-validator.rs"); +} + fn key(seed: u8) -> String { let signing = SigningKey::from_bytes(&[seed; 32]); let mut bytes = b"Ed12345678".to_vec(); @@ -75,12 +82,46 @@ fn manifest(version: &str) -> Manifest { artifact(version, "Maple-Agent.app.tar.gz"), )]), downloads: Downloads { - macos: artifact(version, "Maple-Agent.dmg"), + macos: Some(artifact(version, "Maple-Agent.dmg")), + linux_appimage: None, + linux_bundle: None, + }, + } +} + +fn legacy_linux_manifest(version: &str) -> Manifest { + let mut value = manifest(version); + let linux = artifact(version, "Maple-Agent.AppImage"); + value.artifacts.insert("linux-x86_64".into(), linux.clone()); + value.downloads.linux_appimage = Some(linux); + value +} + +fn linux_manifest(version: &str) -> Manifest { + let linux = artifact(version, "Maple-Agent-linux-x86_64.tar.gz"); + Manifest { + format: "maple-agent-update-v2".into(), + artifacts: BTreeMap::from([("linux-x86_64".into(), linux.clone())]), + downloads: Downloads { + macos: None, linux_appimage: None, + linux_bundle: Some(linux), }, + ..manifest(version) } } +fn envelope_value(value: &serde_json::Value) -> Vec { + sign_text( + &serde_json::to_string(value).unwrap(), + &format!( + "product=maple-agent channel=stable version={}", + value["version"].as_str().unwrap() + ), + 7, + ) +} + fn origin() -> crate::Url { "https://updates.example.test".parse().unwrap() } @@ -94,12 +135,196 @@ fn static_public_publisher_fixture_uses_the_same_authenticated_contract() { ) .unwrap(); assert_eq!(release.manifest, manifest("0.0.2")); + assert_eq!( + serde_json::to_vec(&release.manifest).unwrap(), + include_bytes!("../tests/fixtures/manifest.json") + ); assert_eq!( release.manifest.artifacts["darwin-aarch64"].sha256, hex::encode(Sha256::digest(PAYLOAD)) ); } +#[test] +fn installed_v1_validator_accepts_new_macos_bytes_and_rejects_linux_v2() { + let macos = envelope(&manifest("0.0.4")); + let legacy = legacy_v1::verify_envelope(&macos, &origin(), &[key(7)]).unwrap(); + assert_eq!(legacy.manifest.format, "maple-agent-update-v1"); + assert_eq!(legacy.manifest.artifacts.len(), 1); + assert!(legacy.manifest.artifacts.contains_key("darwin-aarch64")); + assert!(legacy.manifest.downloads.linux_appimage.is_none()); + assert!( + legacy_v1::verify_envelope(&envelope(&linux_manifest("0.0.4")), &origin(), &[key(7)]) + .is_err() + ); +} + +#[test] +fn static_linux_v2_fixture_uses_the_same_public_test_key() { + let signed = include_bytes!("../tests/fixtures/signed-linux-v2-envelope.json"); + let release = verify_envelope( + signed, + &origin(), + &[include_str!("../tests/fixtures/public-test-key.pub").to_owned()], + ) + .unwrap(); + assert_eq!(release.manifest, linux_manifest("0.0.2")); + assert_eq!( + serde_json::to_vec(&release.manifest).unwrap(), + include_bytes!("../tests/fixtures/linux-v2-manifest.json") + ); + assert!(legacy_v1::verify_envelope(signed, &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")] { + let signed = envelope(&value); + assert!(legacy_v1::verify_envelope(&signed, &origin(), &[key(7)]).is_ok()); + let release = verify_envelope(&signed, &origin(), &[key(7)]).unwrap(); + assert!(release.artifact(Target::DarwinAarch64).is_ok()); + assert!(release.artifact(Target::LinuxX86_64).is_err()); + } + let mut value = serde_json::to_value(manifest("0.0.4")).unwrap(); + value["downloads"]["linux-appimage"] = serde_json::Value::Null; + let signed = envelope_value(&value); + assert!(legacy_v1::verify_envelope(&signed, &origin(), &[key(7)]).is_ok()); + assert!(verify_envelope(&signed, &origin(), &[key(7)]).is_ok()); + + let mutations: Vec = vec![ + |m| m["unknown"] = json!(true), + |m| m["downloads"]["unknown"] = json!(true), + |m| m["downloads"]["linux-bundle"] = serde_json::Value::Null, + |m| m["downloads"]["macos"] = serde_json::Value::Null, + |m| { + m["downloads"].as_object_mut().unwrap().remove("macos"); + }, + |m| m["artifacts"]["darwin-aarch64"]["unknown"] = json!(true), + ]; + for mutation in mutations { + let mut value = serde_json::to_value(manifest("0.0.4")).unwrap(); + mutation(&mut value); + let signed = envelope_value(&value); + assert!(legacy_v1::verify_envelope(&signed, &origin(), &[key(7)]).is_err()); + assert!(verify_envelope(&signed, &origin(), &[key(7)]).is_err()); + } +} + +#[test] +fn linux_v2_is_an_exact_linux_bundle_inventory() { + let release = + verify_envelope(&envelope(&linux_manifest("0.0.4")), &origin(), &[key(7)]).unwrap(); + assert!(release.artifact(Target::DarwinAarch64).is_err()); + assert_eq!( + release.artifact(Target::LinuxX86_64).unwrap(), + release.manifest.downloads.linux_bundle.as_ref().unwrap() + ); + let mutations: Vec = vec![ + |m| m.format = "maple-agent-update-v1".into(), + |m| m.product = "research".into(), + |m| m.channel = "dev".into(), + |m| m.version = "0.0.4-beta.1".into(), + |m| m.build = "04".into(), + |m| m.source_sha = "A".repeat(40), + |m| m.pub_date = "2026-10-21T15:00:00+00:00".into(), + |m| m.notes = "bad\u{0}".into(), + |m| { + m.artifacts.clear(); + }, + |m| { + m.artifacts.insert( + "darwin-aarch64".into(), + artifact("0.0.4", "Maple-Agent.app.tar.gz"), + ); + }, + |m| m.downloads.macos = Some(artifact("0.0.4", "Maple-Agent.dmg")), + |m| m.downloads.linux_appimage = Some(artifact("0.0.4", "Maple-Agent.AppImage")), + |m| m.downloads.linux_bundle = None, + |m| m.downloads.linux_bundle.as_mut().unwrap().size += 1, + |m| { + m.artifacts.get_mut("linux-x86_64").unwrap().path = + "/agent/stable/releases/0.0.4/Maple-Agent.AppImage".into(); + m.downloads.linux_bundle = m.artifacts.get("linux-x86_64").cloned(); + }, + ]; + for mutation in mutations { + let mut value = linux_manifest("0.0.4"); + mutation(&mut value); + assert!( + verify_envelope(&envelope(&value), &origin(), &[key(7)]).is_err(), + "accepted {value:?}" + ); + } +} + +#[test] +fn linux_v2_rejects_unknown_and_null_legacy_fields() { + let mutations: Vec = vec![ + |m| m["unknown"] = json!(true), + |m| m["downloads"]["unknown"] = serde_json::Value::Null, + |m| m["downloads"]["macos"] = serde_json::Value::Null, + |m| m["downloads"]["linux-appimage"] = serde_json::Value::Null, + |m| m["artifacts"]["linux-x86_64"]["unknown"] = json!(true), + ]; + for mutation in mutations { + let mut value = serde_json::to_value(linux_manifest("0.0.4")).unwrap(); + mutation(&mut value); + assert!(verify_envelope(&envelope_value(&value), &origin(), &[key(7)]).is_err()); + } +} + +#[test] +fn linux_v2_payload_bounds_apply_even_when_the_download_matches() { + let mut invalid = Vec::new(); + for path in [ + "/agent/stable/releases/0.0.3/Maple-Agent.tar.gz", + "/agent/stable/releases/0.0.4/../Maple-Agent.tar.gz", + "/agent/stable/releases/0.0.4/Maple-Agent.tar.gz?token=x", + "/agent/stable/releases/0.0.4/Maple-Agent.AppImage", + "https://evil.test/Maple-Agent.tar.gz", + ] { + let mut artifact = artifact("0.0.4", "Maple-Agent.tar.gz"); + artifact.path = path.into(); + invalid.push(artifact); + } + for size in [0, manifest::MAX_ARTIFACT_BYTES + 1] { + let mut artifact = artifact("0.0.4", "Maple-Agent.tar.gz"); + artifact.size = size; + invalid.push(artifact); + } + let mut bad_digest = artifact("0.0.4", "Maple-Agent.tar.gz"); + bad_digest.sha256 = "A".repeat(64); + invalid.push(bad_digest); + for artifact in invalid { + let mut value = linux_manifest("0.0.4"); + value + .artifacts + .insert("linux-x86_64".into(), artifact.clone()); + value.downloads.linux_bundle = Some(artifact); + assert!(verify_envelope(&envelope(&value), &origin(), &[key(7)]).is_err()); + } +} + +#[test] +fn target_metadata_paths_keep_legacy_macos_and_separate_linux() { + assert_eq!( + Target::DarwinAarch64.feed_path(), + "/agent/stable/latest.json" + ); + assert_eq!( + Target::LinuxX86_64.feed_path(), + "/agent/stable/linux/latest.json" + ); + assert_eq!( + Target::DarwinAarch64.manifest_path("0.0.4"), + "/agent/stable/releases/0.0.4/manifest.json" + ); + assert_eq!( + Target::LinuxX86_64.manifest_path("0.0.4"), + "/agent/stable/releases/0.0.4/linux-manifest.json" + ); +} + #[test] fn exact_bytes_and_authenticated_comment_are_required() { let manifest = manifest("0.0.2"); @@ -265,13 +490,29 @@ impl Fixture { } fn config(&self, temp: &tempfile::TempDir) -> Config { - let installed = temp.path().join("Maple Agent.app"); - std::fs::create_dir_all(&installed).unwrap(); + self.config_for_target(temp, Target::DarwinAarch64) + } + + fn config_for_target(&self, temp: &tempfile::TempDir, target: Target) -> Config { + let installed = match target { + Target::DarwinAarch64 => { + let path = temp.path().join("Maple Agent.app"); + std::fs::create_dir_all(&path).unwrap(); + path + } + Target::LinuxX86_64 => { + let path = temp.path().join("maple-agent"); + std::fs::write(&path, b"old installed executable").unwrap(); + path + } + }; + let mut feed_url = self.url.clone(); + feed_url.set_path(target.feed_path()); Config { - feed_url: self.url.clone(), + feed_url, public_keys: vec![key(7)], current_version: Version::new(0, 0, 1), - target: Target::DarwinAarch64, + target, installed_path: installed, state_dir: temp.path().join("updates"), } @@ -572,17 +813,123 @@ async fn redirect_loops_and_oversized_metadata_are_bounded() { } #[tokio::test] -async fn absent_linux_does_not_invalidate_macos_and_offers_no_linux_update() { - let signed = envelope(&manifest("0.0.2")); - assert!(verify_envelope(&signed, &origin(), &[key(7)]).is_ok()); - let server = Fixture::new(vec![ok(&signed)]).await; +async fn wrong_target_feed_cannot_advance_rollback_memory_or_start_a_download() { + for (target, value) in [ + (Target::LinuxX86_64, manifest("0.0.2")), + (Target::LinuxX86_64, legacy_linux_manifest("0.0.2")), + (Target::DarwinAarch64, linux_manifest("0.0.2")), + ] { + let signed = envelope(&value); + let server = Fixture::new(vec![ok(&signed)]).await; + let release = verify_envelope(&signed, &server.url, &[key(7)]).unwrap(); + let temp = tempfile::tempdir().unwrap(); + let config = server.config_for_target(&temp, target); + let installed_path = config.installed_path.clone(); + let updater = test_updater(config).unwrap(); + assert!(updater.check().await.is_err()); + assert!(updater.download(&release).await.is_err()); + let state = + crate::state::StateStore::new(&temp.path().join("updates"), &installed_path).unwrap(); + assert!(state.highest_verified().unwrap().is_none()); + assert!(state.prepared().unwrap().is_none()); + assert_eq!(server.requests.lock().unwrap().len(), 1); + } +} + +#[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 mut config = server.config(&temp); - config.target = Target::LinuxX86_64; + 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!( - test_updater(config).unwrap().check().await.unwrap(), - CheckOutcome::Current + 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 ")); +} + +#[tokio::test] +async fn linux_clears_a_saved_legacy_appimage_preparation_without_installing_it() { + let server = Fixture::new(vec![]).await; + let temp = tempfile::tempdir().unwrap(); + let config = server.config_for_target(&temp, Target::LinuxX86_64); + let state = crate::state::StateStore::new(&config.state_dir, &config.installed_path).unwrap(); + let release = verify_envelope( + &envelope(&legacy_linux_manifest("0.0.2")), + &server.url, + &[key(7)], + ) + .unwrap(); + state.advance(&release.version()).unwrap(); + state.save_prepared(&release).unwrap(); + let payload_path = + state.payload_path(&release.manifest.artifacts["linux-x86_64"].sha256, false); + std::fs::write(&payload_path, PAYLOAD).unwrap(); + let prepared = crate::PreparedUpdate { + release, + payload_path, + installed_path: config.installed_path.clone(), + }; + let updater = test_updater(config).unwrap(); + let committed = std::cell::Cell::new(false); + assert!( + updater + .with_commit_guard(&prepared, || { + committed.set(true); + Ok(()) + }) + .is_err() + ); + assert!(!committed.get()); + assert!(updater.restore_prepared().await.is_err()); + assert!(state.prepared().unwrap().is_none()); + assert_eq!( + state.highest_verified().unwrap(), + Some(Version::new(0, 0, 2)) + ); + assert!(server.requests.lock().unwrap().is_empty()); +} + +#[tokio::test] +async fn wrong_target_revalidation_keeps_a_good_linux_preparation_for_retry() { + let signed = envelope(&linux_manifest("0.0.2")); + let server = Fixture::new(vec![ + ok(&signed), + ok(PAYLOAD), + ok(&envelope(&manifest("0.0.3"))), + ]) + .await; + let temp = tempfile::tempdir().unwrap(); + let updater = test_updater(server.config_for_target(&temp, Target::LinuxX86_64)).unwrap(); + let prepared = updater.download(&available(&updater).await).await.unwrap(); + assert!(updater.revalidate(&prepared).await.is_err()); + assert!(updater.restore_prepared().await.unwrap().is_some()); + let state = + crate::state::StateStore::new(&temp.path().join("updates"), &prepared.installed_path) + .unwrap(); + assert_eq!( + state.highest_verified().unwrap(), + Some(Version::new(0, 0, 2)) + ); } #[test] diff --git a/apps/maple-agent/crates/maple-agent-updater/tests/fixtures/legacy-v1-validator.rs b/apps/maple-agent/crates/maple-agent-updater/tests/fixtures/legacy-v1-validator.rs new file mode 100644 index 000000000..673e4c791 --- /dev/null +++ b/apps/maple-agent/crates/maple-agent-updater/tests/fixtures/legacy-v1-validator.rs @@ -0,0 +1,252 @@ +use std::collections::BTreeMap; + +use anyhow::{Context, Result, bail, ensure}; +use minisign_verify::{PublicKey, Signature}; +use semver::Version; +use serde::{Deserialize, Serialize}; +use time::{OffsetDateTime, format_description::well_known::Rfc3339}; +use url::Url; + +pub const MAX_ENVELOPE_BYTES: usize = 128 * 1024; +pub const MAX_MANIFEST_BYTES: usize = 64 * 1024; +pub const MAX_ARTIFACT_BYTES: u64 = 4 * 1024 * 1024 * 1024; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Target { + DarwinAarch64, + LinuxX86_64, +} + +impl Target { + pub fn key(self) -> &'static str { + match self { + Self::DarwinAarch64 => "darwin-aarch64", + Self::LinuxX86_64 => "linux-x86_64", + } + } +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct Artifact { + pub path: String, + pub size: u64, + pub sha256: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct Downloads { + pub macos: Artifact, + #[serde(rename = "linux-appimage", skip_serializing_if = "Option::is_none")] + pub linux_appimage: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct Manifest { + pub format: String, + pub product: String, + pub channel: String, + pub version: String, + pub build: String, + pub source_sha: String, + pub pub_date: String, + pub notes: String, + pub artifacts: BTreeMap, + pub downloads: Downloads, +} + +/// An authenticated manifest and its exact envelope bytes. Public fields allow +/// presentation, but every authority-bearing operation authenticates them again. +#[derive(Clone, Debug)] +pub struct VerifiedRelease { + pub manifest: Manifest, + pub envelope: String, + pub origin: Url, +} + +impl VerifiedRelease { + pub fn version(&self) -> Version { + // Only constructed after schema validation; mutation is rechecked at use. + Version::parse(&self.manifest.version).expect("validated release version") + } + + pub fn artifact(&self, target: Target) -> Result<&Artifact> { + self.manifest + .artifacts + .get(target.key()) + .context("release does not support this target") + } + + pub fn artifact_url(&self, target: Target) -> Result { + Ok(self.origin.join(&self.artifact(target)?.path)?) + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Envelope { + manifest: String, + signature: String, +} + +pub fn verify_envelope(bytes: &[u8], origin: &Url, keys: &[String]) -> Result { + ensure!( + bytes.len() <= MAX_ENVELOPE_BYTES, + "update envelope is too large" + ); + let envelope: Envelope = serde_json::from_slice(bytes).context("invalid update envelope")?; + ensure!( + envelope.manifest.len() <= MAX_MANIFEST_BYTES, + "update manifest is too large" + ); + ensure!( + envelope.signature.len() <= 4096, + "update signature is too large" + ); + // Require exactly the four minisign lines. The parser otherwise ignores + // additional lines, which makes malformed publisher output ambiguous. + ensure!( + envelope.signature.lines().count() == 4, + "invalid signature text" + ); + let signature = Signature::decode(&envelope.signature).context("invalid update signature")?; + ensure!( + keys.iter().any(|key| { + let key = if key.starts_with("untrusted comment:") { + PublicKey::decode(key) + } else { + PublicKey::from_base64(key) + }; + key.is_ok_and(|key| { + key.verify(envelope.manifest.as_bytes(), &signature, false) + .is_ok() + }) + }), + "update signature was not made by a pinned key" + ); + // Signature and its trusted comment are authenticated before interpreting + // product, paths, sizes, version or any other manifest field. + let manifest: Manifest = + serde_json::from_str(&envelope.manifest).context("invalid signed manifest")?; + validate_manifest(&manifest)?; + ensure!( + signature.trusted_comment() + == format!( + "product=maple-agent channel=stable version={}", + manifest.version + ), + "signed comment does not match update identity" + ); + Ok(VerifiedRelease { + manifest, + envelope: std::str::from_utf8(bytes)?.to_owned(), + origin: origin.origin().ascii_serialization().parse()?, + }) +} + +pub fn stable_version(value: &str) -> Result { + ensure!(value.len() <= 64, "version is too long"); + let version = Version::parse(value).context("invalid update version")?; + ensure!( + version.pre.is_empty() && version.build.is_empty() && version.to_string() == value, + "update version must be canonical stable SemVer" + ); + Ok(version) +} + +fn lowercase_hex(value: &str, len: usize) -> bool { + value.len() == len + && value + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) +} + +pub fn validate_manifest(manifest: &Manifest) -> Result<()> { + ensure!( + manifest.format == "maple-agent-update-v1", + "unsupported update format" + ); + ensure!(manifest.product == "maple-agent", "wrong update product"); + ensure!(manifest.channel == "stable", "wrong update channel"); + stable_version(&manifest.version)?; + ensure!( + !manifest.build.is_empty() + && manifest.build.len() <= 18 + && !manifest.build.starts_with('0') + && manifest.build.bytes().all(|b| b.is_ascii_digit()), + "invalid update build number" + ); + ensure!( + lowercase_hex(&manifest.source_sha, 40), + "invalid update source SHA" + ); + let published = + OffsetDateTime::parse(&manifest.pub_date, &Rfc3339).context("invalid publication time")?; + ensure!( + manifest.pub_date.ends_with('Z') && published.offset().is_utc(), + "publication time must be UTC" + ); + ensure!( + manifest.notes.len() <= 16 * 1024 + && !manifest + .notes + .chars() + .any(|c| c.is_control() && c != '\n' && c != '\t'), + "invalid release notes" + ); + ensure!( + manifest.artifacts.contains_key("darwin-aarch64"), + "macOS artifact is required" + ); + for (target, artifact) in &manifest.artifacts { + let suffix = match target.as_str() { + "darwin-aarch64" => ".app.tar.gz", + "linux-x86_64" => ".AppImage", + _ => bail!("unsupported update target"), + }; + validate_artifact(artifact, &manifest.version, suffix)?; + } + validate_artifact(&manifest.downloads.macos, &manifest.version, ".dmg")?; + ensure!( + manifest.artifacts.contains_key("linux-x86_64") + == manifest.downloads.linux_appimage.is_some(), + "Linux updater and download inventory differ" + ); + if let Some(artifact) = &manifest.downloads.linux_appimage { + validate_artifact(artifact, &manifest.version, ".AppImage")?; + ensure!( + Some(artifact) == manifest.artifacts.get("linux-x86_64"), + "Linux download differs from updater payload" + ); + } + Ok(()) +} + +fn validate_artifact(artifact: &Artifact, version: &str, suffix: &str) -> Result<()> { + ensure!( + artifact.size > 0 && artifact.size <= MAX_ARTIFACT_BYTES, + "invalid artifact size" + ); + ensure!( + lowercase_hex(&artifact.sha256, 64), + "invalid artifact SHA-256" + ); + let prefix = format!("/agent/stable/releases/{version}/"); + let filename = artifact + .path + .strip_prefix(&prefix) + .context("artifact path has wrong release prefix")?; + ensure!( + !filename.is_empty() + && filename.len() <= 200 + && filename.ends_with(suffix) + && !filename.starts_with('.') + && filename + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"._-".contains(&b)), + "invalid artifact filename" + ); + Ok(()) +} diff --git a/apps/maple-agent/crates/maple-agent-updater/tests/fixtures/linux-v2-contract.md b/apps/maple-agent/crates/maple-agent-updater/tests/fixtures/linux-v2-contract.md new file mode 100644 index 000000000..32df00ad0 --- /dev/null +++ b/apps/maple-agent/crates/maple-agent-updater/tests/fixtures/linux-v2-contract.md @@ -0,0 +1,9 @@ +The Linux v2 fixture uses the same public deterministic test-only key and +ASCII payload as the existing v1 fixtures. It is not an application archive. +Its feed is `/agent/stable/linux/latest.json`; immutable signed metadata lives +at `/agent/stable/releases/0.0.2/linux-manifest.json`. + +`legacy-v1-validator.rs` preserves the pre-Linux validator from commit +`264560b0` byte for byte. Tests compile it independently to ensure newly emitted +macOS v1 metadata remains readable by the installed client. Existing v1 fixtures +remain unchanged. diff --git a/apps/maple-agent/crates/maple-agent-updater/tests/fixtures/linux-v2-manifest.json b/apps/maple-agent/crates/maple-agent-updater/tests/fixtures/linux-v2-manifest.json new file mode 100644 index 000000000..f4635572c --- /dev/null +++ b/apps/maple-agent/crates/maple-agent-updater/tests/fixtures/linux-v2-manifest.json @@ -0,0 +1 @@ +{"format":"maple-agent-update-v2","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":{"linux-x86_64":{"path":"/agent/stable/releases/0.0.2/Maple-Agent-linux-x86_64.tar.gz","size":28,"sha256":"106cfb1b4d93e07f8c02c2a3440aafd7dd2f5b0889bbb7ba77bc99ca85ef84da"}},"downloads":{"linux-bundle":{"path":"/agent/stable/releases/0.0.2/Maple-Agent-linux-x86_64.tar.gz","size":28,"sha256":"106cfb1b4d93e07f8c02c2a3440aafd7dd2f5b0889bbb7ba77bc99ca85ef84da"}}} \ No newline at end of file diff --git a/apps/maple-agent/crates/maple-agent-updater/tests/fixtures/signed-linux-v2-envelope.json b/apps/maple-agent/crates/maple-agent-updater/tests/fixtures/signed-linux-v2-envelope.json new file mode 100644 index 000000000..91410af51 --- /dev/null +++ b/apps/maple-agent/crates/maple-agent-updater/tests/fixtures/signed-linux-v2-envelope.json @@ -0,0 +1 @@ +{"manifest":"{\"format\":\"maple-agent-update-v2\",\"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\":{\"linux-x86_64\":{\"path\":\"/agent/stable/releases/0.0.2/Maple-Agent-linux-x86_64.tar.gz\",\"size\":28,\"sha256\":\"106cfb1b4d93e07f8c02c2a3440aafd7dd2f5b0889bbb7ba77bc99ca85ef84da\"}},\"downloads\":{\"linux-bundle\":{\"path\":\"/agent/stable/releases/0.0.2/Maple-Agent-linux-x86_64.tar.gz\",\"size\":28,\"sha256\":\"106cfb1b4d93e07f8c02c2a3440aafd7dd2f5b0889bbb7ba77bc99ca85ef84da\"}}}","signature":"untrusted comment: TEST ONLY\nRUQxMjM0NTY3OEkC9tyHYJCzCVerkIRvU04vr2OUwEXyZXHd80fnaCfnDuinWAUi+jkuiopvOp2RZG+ZsmBuWkgDil6Ma/opog8=\ntrusted comment: product=maple-agent channel=stable version=0.0.2\nomXs8wDDqgW5/dBX7CXzLyjzyP+Kc729oOVZsqsrsl0/Mk/Fz18kWMeuDP+hr7/lpJxjkO8sPAj7WKHtCeXlDw==\n"} \ No newline at end of file diff --git a/apps/maple-agent/crates/maple-agent/src/lib.rs b/apps/maple-agent/crates/maple-agent/src/lib.rs index 246154b4a..6abea28f3 100644 --- a/apps/maple-agent/crates/maple-agent/src/lib.rs +++ b/apps/maple-agent/crates/maple-agent/src/lib.rs @@ -16,6 +16,7 @@ pub use desktop_environment::prepare_process_environment; pub mod maple_api; pub mod open_secret_config; pub mod private_file; +pub mod self_launch; /// Host-owned profile store; callers use typed persistence APIs. pub mod store; diff --git a/apps/maple-agent/crates/maple-agent/src/self_launch.rs b/apps/maple-agent/crates/maple-agent/src/self_launch.rs new file mode 100644 index 000000000..1fb004514 --- /dev/null +++ b/apps/maple-agent/crates/maple-agent/src/self_launch.rs @@ -0,0 +1,417 @@ +//! Resolve the application entry point, including Linux's bundled loader. +//! +//! AppRun executes the loader explicitly, so `current_exe()` names that +//! loader. Re-enter the persistent bundle's AppRun for host startup and restart. +//! Legacy AppImages re-enter their outer image to retain an independent mount. + +use std::{path::PathBuf, process::Command}; + +pub fn command() -> Result { + executable().map(Command::new) +} + +/// Re-execute a bounded child within this process's package mount. This uses +/// the loader directly, without an AppImage runtime or shell between parent +/// and child, so private stdin and parent-death/cancellation ownership survive. +/// The caller may clear the child's environment after resolving this command. +pub fn child_command() -> Result { + let current = std::env::current_exe().map_err(|error| error.to_string())?; + #[cfg(target_os = "linux")] + { + linux_child_command( + current, + std::env::args_os().next().as_deref(), + std::env::var_os("APPDIR").as_deref(), + std::env::var_os("APPIMAGE").as_deref(), + std::env::var_os("APPIMAGE_EXTRACT_AND_RUN").is_some(), + ) + } + #[cfg(not(target_os = "linux"))] + { + Ok(Command::new(current)) + } +} + +pub fn executable() -> Result { + let current = std::env::current_exe().map_err(|error| error.to_string())?; + #[cfg(target_os = "linux")] + { + linux_executable( + current, + std::env::args_os().next().as_deref(), + std::env::var_os("APPDIR").as_deref(), + std::env::var_os("APPIMAGE").as_deref(), + std::env::var_os("APPIMAGE_EXTRACT_AND_RUN").is_some(), + ) + } + #[cfg(not(target_os = "linux"))] + { + Ok(current) + } +} + +#[cfg(any(target_os = "linux", all(test, unix)))] +const LOADER: &str = "ld-linux-x86-64.so.2"; + +#[cfg(any(target_os = "linux", all(test, unix)))] +fn linux_executable( + current: PathBuf, + argv0: Option<&std::ffi::OsStr>, + appdir: Option<&std::ffi::OsStr>, + appimage: Option<&std::ffi::OsStr>, + extract_and_run: bool, +) -> Result { + linux_launch(current, argv0, appdir, appimage, extract_and_run).map(|launch| launch.executable) +} + +#[cfg(any(target_os = "linux", all(test, unix)))] +struct LinuxPackage { + directory: PathBuf, + loader: PathBuf, + payload: PathBuf, +} + +#[cfg(any(target_os = "linux", all(test, unix)))] +struct LinuxLaunch { + executable: PathBuf, + package: Option, +} + +#[cfg(any(target_os = "linux", all(test, unix)))] +fn linux_child_command( + current: PathBuf, + argv0: Option<&std::ffi::OsStr>, + appdir: Option<&std::ffi::OsStr>, + appimage: Option<&std::ffi::OsStr>, + extract_and_run: bool, +) -> Result { + let launch = linux_launch(current, argv0, appdir, appimage, extract_and_run)?; + let Some(package) = launch.package else { + return Ok(Command::new(launch.executable)); + }; + let mut library_path = package.directory.join("usr/lib").into_os_string(); + // Match AppRun's explicit loader path without relying on inherited linker + // variables. The checker clears its environment before spawning. + library_path + .push(":/lib/x86_64-linux-gnu:/usr/lib/x86_64-linux-gnu:/lib64:/usr/lib64:/lib:/usr/lib"); + let mut command = Command::new(package.loader); + command + .args(["--inhibit-cache", "--library-path"]) + .arg(library_path) + .arg(package.payload); + Ok(command) +} + +#[cfg(any(target_os = "linux", all(test, unix)))] +fn linux_launch( + current: PathBuf, + argv0: Option<&std::ffi::OsStr>, + appdir: Option<&std::ffi::OsStr>, + appimage: Option<&std::ffi::OsStr>, + extract_and_run: bool, +) -> Result { + use std::{io::Read, path::Path}; + + // Ignore another AppImage's inherited variables for ordinary binaries. + // A loader or executable inside APPDIR is a recognized package context: + // inconsistent inputs there must never fall back to executing the loader. + let in_appdir = appdir.is_some_and(|path| current.starts_with(Path::new(path))); + if current.file_name() != Some(LOADER.as_ref()) && !in_appdir { + return Ok(LinuxLaunch { + executable: current, + package: None, + }); + } + let invalid = || { + "Cannot relaunch this Linux bundle: invalid APPDIR, loader or payload. Launch the intact installed maple-agent.app/AppRun, or run the original legacy .AppImage.".to_string() + }; + let appdir = Path::new(appdir.ok_or_else(invalid)?); + if !appdir.is_absolute() { + return Err(invalid()); + } + let appdir = appdir.canonicalize().map_err(|_| invalid())?; + let loader = appdir.join("usr/lib").join(LOADER); + let payload = appdir.join("usr/bin/maple-agent"); + let launcher = appdir.join("AppRun"); + let canonical_loader = loader.canonicalize().map_err(|_| invalid())?; + let canonical_payload = payload.canonicalize().map_err(|_| invalid())?; + let canonical_launcher = launcher.canonicalize().map_err(|_| invalid())?; + let argv0 = Path::new(argv0.ok_or_else(invalid)?); + if !argv0.is_absolute() + || !canonical_loader.starts_with(&appdir) + || !canonical_payload.starts_with(&appdir) + || !canonical_launcher.starts_with(&appdir) + || current.canonicalize().map_err(|_| invalid())? != canonical_loader + || argv0.canonicalize().map_err(|_| invalid())? != canonical_payload + || !is_executable_file(&loader) + || !is_executable_file(&payload) + || !is_executable_file(&launcher) + { + return Err(invalid()); + } + + // The pinned runtime shares this extraction directory across invocations + // and deletes it when *either* wrapper exits. A detached host would lose + // its resources. Reject both the environment and command-line forms. + if extract_and_run + || appdir + .file_name() + .is_some_and(|name| name.as_encoded_bytes().starts_with(b"appimage_extracted_")) + { + return Err("Maple host startup and restart do not support temporary --appimage-extract-and-run mode. Install FUSE and run the .AppImage normally, or extract it once with --appimage-extract and run squashfs-root/AppRun.".into()); + } + let package = Some(LinuxPackage { + directory: appdir.clone(), + loader: canonical_loader, + payload: canonical_payload, + }); + let Some(appimage) = appimage else { + // Official tar bundles and persistent extractions need no runtime. + return Ok(LinuxLaunch { + executable: canonical_launcher, + package, + }); + }; + let appimage = Path::new(appimage); + if !appimage.is_absolute() || !is_executable_file(appimage) { + return Err(invalid()); + } + let appimage = appimage.canonicalize().map_err(|_| invalid())?; + if appimage.starts_with(&appdir) { + return Err(invalid()); + } + let mut header = [0; 11]; + std::fs::File::open(&appimage) + .and_then(|mut file| file.read_exact(&mut header)) + .map_err(|_| invalid())?; + if &header[..4] != b"\x7fELF" || &header[8..] != b"AI\x02" { + return Err(invalid()); + } + Ok(LinuxLaunch { + executable: appimage, + package, + }) +} + +#[cfg(any(target_os = "linux", all(test, unix)))] +fn is_executable_file(path: &std::path::Path) -> bool { + use std::os::unix::fs::PermissionsExt; + std::fs::metadata(path) + .is_ok_and(|metadata| metadata.is_file() && metadata.permissions().mode() & 0o111 != 0) +} + +#[cfg(all(test, unix))] +mod tests { + use super::*; + use std::{ffi::OsStr, os::unix::fs::PermissionsExt, path::Path}; + + struct Package { + temporary: PathBuf, + appdir: PathBuf, + loader: PathBuf, + payload: PathBuf, + image: PathBuf, + } + + fn executable_file(path: &Path, bytes: &[u8]) { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, bytes).unwrap(); + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + + impl Package { + fn new() -> Self { + static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let temporary = std::env::temp_dir().join(format!( + "maple-self-launch-{}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(), + NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed), + )); + std::fs::create_dir(&temporary).unwrap(); + let temporary = temporary.canonicalize().unwrap(); + let appdir = temporary.join("install with spaces/maple-agent.app"); + let loader = appdir.join("usr/lib").join(LOADER); + let payload = appdir.join("usr/bin/maple-agent"); + let image = temporary.join("Maple Agent.AppImage"); + executable_file(&loader, b"loader"); + executable_file(&payload, b"payload"); + executable_file(&appdir.join("AppRun"), b"#!/bin/sh\n"); + executable_file(&image, b"\x7fELF\x02\x01\x01\x00AI\x02"); + Self { + temporary, + appdir, + loader, + payload, + image, + } + } + + fn resolve(&self, image: Option<&OsStr>) -> Result { + linux_executable( + self.loader.clone(), + Some(self.payload.as_os_str()), + Some(self.appdir.as_os_str()), + image, + false, + ) + } + } + + impl Drop for Package { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.temporary); + } + } + + #[test] + fn image_reentry_uses_absolute_image_with_spaces_instead_of_loader() { + let package = Package::new(); + assert_eq!( + package.resolve(Some(package.image.as_os_str())).unwrap(), + package.image + ); + } + + #[test] + fn normal_binary_ignores_inherited_appimage_context() { + let package = Package::new(); + let ordinary = package.temporary.join("normal-agent"); + assert_eq!( + linux_executable( + ordinary.clone(), + None, + Some(package.appdir.as_os_str()), + Some(package.image.as_os_str()), + true + ) + .unwrap(), + ordinary + ); + } + + #[test] + fn tar_bundle_without_appimage_reenters_apprun() { + let package = Package::new(); + assert_eq!( + package.resolve(None).unwrap(), + package.appdir.join("AppRun") + ); + } + + #[test] + fn bounded_child_uses_direct_loader_with_no_environment_or_shell_dependency() { + let package = Package::new(); + for image in [None, Some(package.image.as_os_str())] { + let mut command = linux_child_command( + package.loader.clone(), + Some(package.payload.as_os_str()), + Some(package.appdir.as_os_str()), + image, + false, + ) + .unwrap(); + command.env("PRIVATE_LAUNCH_CANARY", "private").env_clear(); + command + .arg("--internal-store-integrity-check") + .arg("/private/store-v1"); + assert_eq!(command.get_program(), package.loader.as_os_str()); + let args = command.get_args().collect::>(); + assert_eq!(args[0], "--inhibit-cache"); + assert_eq!(args[1], "--library-path"); + assert!( + args[2] + .to_str() + .unwrap() + .starts_with(package.appdir.join("usr/lib").to_str().unwrap()) + ); + assert_eq!(args[3], package.payload.as_os_str()); + assert_eq!(args[4], "--internal-store-integrity-check"); + assert_eq!(args[5], "/private/store-v1"); + assert_eq!(args.len(), 6); + assert_eq!(command.get_envs().count(), 0); + } + } + + #[test] + fn invalid_recognized_context_never_executes_loader() { + let package = Package::new(); + assert!( + linux_executable( + package.loader.clone(), + Some(package.payload.as_os_str()), + None, + Some(package.image.as_os_str()), + false + ) + .is_err() + ); + assert!( + linux_executable( + package.loader.clone(), + Some(package.payload.as_os_str()), + Some(OsStr::new("relative/AppDir")), + Some(package.image.as_os_str()), + false + ) + .is_err() + ); + assert!( + linux_executable( + package.loader.clone(), + Some(package.loader.as_os_str()), + Some(package.appdir.as_os_str()), + Some(package.image.as_os_str()), + false + ) + .is_err() + ); + assert!( + package + .resolve(Some(OsStr::new("relative.AppImage"))) + .is_err() + ); + assert!(package.resolve(Some(package.payload.as_os_str())).is_err()); + std::fs::write(&package.image, b"#!/bin/sh\nexit 0\n").unwrap(); + assert!(package.resolve(Some(package.image.as_os_str())).is_err()); + } + + #[test] + fn missing_nonexecutable_and_escaped_package_files_are_rejected() { + let package = Package::new(); + std::fs::set_permissions(&package.image, std::fs::Permissions::from_mode(0o644)).unwrap(); + assert!(package.resolve(Some(package.image.as_os_str())).is_err()); + std::fs::remove_file(&package.payload).unwrap(); + assert!(package.resolve(None).is_err()); + std::os::unix::fs::symlink(&package.image, &package.payload).unwrap(); + assert!(package.resolve(None).is_err()); + } + + #[test] + fn both_temporary_extract_and_run_forms_fail_with_recovery_instructions() { + let package = Package::new(); + let error = linux_executable( + package.loader.clone(), + Some(package.payload.as_os_str()), + Some(package.appdir.as_os_str()), + Some(package.image.as_os_str()), + true, + ) + .unwrap_err(); + assert!(error.contains("Install FUSE")); + let temporary_appdir = package.appdir.with_file_name("appimage_extracted_fakehash"); + std::fs::rename(&package.appdir, &temporary_appdir).unwrap(); + let error = linux_executable( + temporary_appdir.join("usr/lib").join(LOADER), + Some(temporary_appdir.join("usr/bin/maple-agent").as_os_str()), + Some(temporary_appdir.as_os_str()), + Some(package.image.as_os_str()), + false, + ) + .unwrap_err(); + assert!(error.contains("--appimage-extract")); + } +} diff --git a/apps/maple-agent/crates/maple-agent/src/store/integrity.rs b/apps/maple-agent/crates/maple-agent/src/store/integrity.rs index 8975907ea..41541e0fb 100644 --- a/apps/maple-agent/crates/maple-agent/src/store/integrity.rs +++ b/apps/maple-agent/crates/maple-agent/src/store/integrity.rs @@ -547,7 +547,11 @@ fn native_windows_directory() -> StoreResult { } fn checker_command(paths: &StorePaths) -> StoreResult { - let mut command = Command::new(std::env::current_exe()?); + // Keep the checker as our direct child, including bundled-loader packages. + // Bypass launcher wrappers so watchdog parent verification and direct + // cancellation/reaping are preserved. No key enters argv or environment. + let mut command = crate::self_launch::child_command() + .map_err(|_| std::io::Error::other("integrity checker executable is unavailable"))?; #[cfg(test)] command.args([ "--exact", diff --git a/apps/maple-agent/docs/desktop-builds.md b/apps/maple-agent/docs/desktop-builds.md index 7f49a89da..93e3b58fe 100644 --- a/apps/maple-agent/docs/desktop-builds.md +++ b/apps/maple-agent/docs/desktop-builds.md @@ -13,9 +13,10 @@ Unrelated master commits have no new Agent artifacts. Existing downloads keep their original source SHA and provenance; they are never relabeled as a newer commit. A manual dispatch from `master` forces fresh packages, including when the previous run's 30-day artifacts have expired. macOS ARM64 builds are -Developer ID signed, notarized, and stapled. Linux x86_64 builds are AppImages -with a bundled runtime; CI also checks each downloaded AppImage's command-line -startup in an Ubuntu 24.04 container with no Nix installation and no network +Developer ID signed, notarized, and stapled. Linux x86_64 builds are tar bundles +with a bundled runtime and a normal application directory. CI checks each +downloaded bundle's command-line startup and detached host lifecycle in an +Ubuntu 24.04 container with no Nix installation and no network access. Each master run completes independently; newer PR updates cancel older previews. @@ -73,9 +74,38 @@ retry can reuse a successful compilation while that internal artifact is retaine Final packages are retained for 30 days; internal prebuilt binaries for 7 days. 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 an AppImage; make it -executable and launch it. Each artifact also contains public build information, -a package manifest, and checksums. +coexist with Maple Research. Linux packages contain +`maple-agent-PROFILE-VERSION-SHA-linux-x86_64.tar.gz`. Extract the archive and run +the contained `install.sh` as your desktop user: + +```sh +tar -xzpf maple-agent-prod-VERSION-SHA-linux-x86_64.tar.gz +./maple-agent.app/install.sh +``` + +The `-p` option preserves the archive's reviewed file modes, including when your +shell uses a restrictive umask. The installer requires those modes unchanged. + +The Dev archive uses `maple-agent-dev.app`. The installer creates a per-user +application directory at `~/.local/maple-agent.app` (Dev: +`~/.local/maple-agent-dev.app`), a command in `~/.local/bin`, and a desktop entry. +Python 3 is required for initial installation only. First install +refuses existing installation or integration paths; subsequent upgrades use the +application's updater. Its `AppRun` entrypoint starts the +bundled executable and runtime directly, without an AppImage mount or FUSE. +The host must provide its normal CA certificate trust store for TLS validation; +existing user certificate-path overrides remain supported. The isolated CI +container mounts the runner's public `/etc/ssl/certs` read-only and keeps TLS +validation enabled. +Compose and dead-key input use the host's `/usr/share/X11/locale` data when +available; an explicit `XLOCALEDIR` override remains unchanged. +Initial installation leaves profile, SQL, authentication, and settings data +untouched. Managed uninstall is not yet provided. +The final Linux artifact contains exactly that tar archive, `build-info.json`, +`manifest.json`, and `SHA256SUMS`. The archive has one application root, +including `bundle.json`, the entrypoint, executable, runtime libraries, licenses, +and installer helpers. Package verification extracts to temporary storage and +does not install into your application directory. Native BSDtar creation explicitly disables xattrs, file flags, ACLs and macOS metadata, while preserving signed file bytes, public modes and symlink targets. @@ -86,9 +116,11 @@ notarization, Gatekeeper and DMG comparison checks remain required. Every final master package file, including its public metadata and checksums, receives GitHub build provenance. The trusted macOS signing job mints provenance -after packaging. Linux compilation uses a read-only token; a separate trusted -job downloads and attests the final Linux artifacts without checking out or -executing repository code or launching artifacts. Fresh verifiers check the +after packaging. Linux compilation uses a read-only token and builds each +profile once. A separate trusted job checks out the run's exact master source, +compiles the updater-only archive scanner, and reads each downloaded tar through +the installer's Linux parser before attesting its final files. That job never +launches artifact code. Fresh verifiers repeat the actual archive scan and check the downloaded files against this workflow, `master`, and the exact source commit. For a downloaded file, verify the provenance with the GitHub CLI: @@ -112,8 +144,11 @@ may still require macOS's **Open Anyway** approval. PR previews are not attested The macOS GUI smoke establishes window creation, entry into the first render, and process survival; it does not capture a presented frame or verify login, chat, -microphone or capture permissions. Linux command-line checks do not exercise -X11/Wayland or host GPU drivers; those remain beta runtime checks. Dev checkout +microphone or capture permissions. The Linux host smoke uses empty private state +and a sanitized environment. Two signed-out ACP clients must reuse one host and +receive a fixed sign-in or local store setup guard; stopping that isolated +host must release its owner lock and socket. These command-line checks do not exercise +X11/Wayland or host GPU drivers; those remain native runtime checks. Dev checkout and portal requests use the Dev billing API, and pricing/payment return links use `app-dev.maple.ai`, which is protected by Cloudflare Access. Prod uses `app.maple.ai`. Profile changes require checkout success/cancellation and portal @@ -129,6 +164,14 @@ nix develop --no-update-lock-file . -c ./scripts/package-release.sh dev --unsign nix develop --no-update-lock-file . -c ./scripts/verify-release.sh dev dist/dev --unsigned ``` +On Linux these commands produce and verify the installed bundle tar. The +standalone AppImage builder remains a separate packaging option and is not the +official updater artifact. Linux metadata validation explicitly selects +`linux-x86_64`, including its platform-specific update feed; macOS metadata +continues to use the macOS feed. Dev has no update feed on either platform. +Both platforms retain one release version and source commit; separate feed +paths preserve compatibility with existing macOS clients. + For a produced Prod updater archive, compile the trusted data-only scanner and validate the exact package directory (add `--unsigned` for PR packages): @@ -137,6 +180,11 @@ nix develop --no-update-lock-file . -c cargo build --locked -p maple-agent-updat nix develop --no-update-lock-file . -c ./scripts/verify-updater-archive.sh dist/prod --unsigned ``` +For Linux, pass `--platform linux-x86_64`; the scanner accepts both hosted +profiles (`--profile dev` for a Dev archive). The scanner checks the actual tar +bytes against `SHA256SUMS` and validates archive data without extracting or +executing the application. + Use `prod` for the production service profile. Official macOS signing is run only by the trusted master CI job, reusing the existing `desktop-signing` GitHub environment and its five Apple secrets. Application compilation runs on a @@ -150,8 +198,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 -check that feed; publication remains disabled pending owner service setup, and -invitations still require the signed update rehearsal. An empty-key profile +check their platform's feed. 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 [updater activation](updates.md). diff --git a/apps/maple-agent/docs/release-profiles.md b/apps/maple-agent/docs/release-profiles.md index c37f0ea97..9b466b127 100644 --- a/apps/maple-agent/docs/release-profiles.md +++ b/apps/maple-agent/docs/release-profiles.md @@ -55,11 +55,17 @@ are `profile`, `display_name`, `bundle_id`, `data_namespace`, `api_url`, revision with a possible `-dirty` suffix; `source_sha` is the full source commit. Tarball/pure Nix builds without Git report `unknown` for unavailable revisions. -Packaged Prod builds use only the compiled Agent stable feed at -`https://updates.trymaple.ai/agent/stable/latest.json`. The profile pins the +Packaged Prod builds use only the compiled Agent stable feed: +`https://updates.trymaple.ai/agent/stable/latest.json` on macOS, or +`https://updates.trymaple.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. +Both feeds describe the same Agent version, build and source when publishing both +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 remains disabled pending owner service setup; invitations -still require the signed update rehearsal. An empty-key profile reports +launch. Publication is a separate protected operation; invitations +require the signed update rehearsal for each supported platform. An empty-key profile reports **Updates not configured** and makes no update requests. Active trust requires two distinct public keys; the backup supports planned rotation and does not revoke a compromised accepted @@ -74,6 +80,6 @@ Configured Prod checks after launch and approximately hourly, verifies signed metadata and package contents, and downloads automatically by default. Users can turn automatic downloads off and download explicitly. Installation waits for **Restart to Update** and a warning that tasks and remote access stop. -Read-only/translocated apps and the currently gated Linux updater offer manual +Read-only/translocated apps and unsupported Linux install contexts offer manual download links from signed metadata. See [updater development and activation](updates.md) for verification, recovery and the separate production activation boundary. diff --git a/apps/maple-agent/docs/updates.md b/apps/maple-agent/docs/updates.md index 5eed6aa1d..3cb03ffe8 100644 --- a/apps/maple-agent/docs/updates.md +++ b/apps/maple-agent/docs/updates.md @@ -1,12 +1,13 @@ # Agent updates Packaged Prod builds use the Agent-only signed update service at -`https://updates.trymaple.ai/agent/stable/latest.json`. Dev and unpackaged +`https://updates.trymaple.ai/agent/stable/latest.json` on macOS and +`https://updates.trymaple.ai/agent/stable/linux/latest.json` on Linux. 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. Publication remains disabled pending owner service and routing setup; -pinning keys does not deploy or publish anything. Invitations still require the -signed installed-update rehearsal below. Environment +launch. Builds and production publication remain separate operations; +pinning keys does not deploy or publish anything. Invitations require the +platform-specific signed installed-update rehearsal below. Environment variables cannot supply a feed or signing key. `MAPLE_DISABLE_UPDATE_CHECK=1` can only disable checks. A profile with no keys still shows **Updates not configured** and makes no updater requests. @@ -29,7 +30,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. A previous bundle remains in the staging directory until +atomic bundle exchange or Linux directory exchange. A previous bundle remains in the staging directory 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. @@ -44,14 +45,23 @@ the old process. A relaunch failure never triggers an automatic downgrade. Read-only locations, disk images, translocated bundles and accounts lacking replacement permission offer a manual download before active tasks stop. The installation lock uses one shared inode for the canonical application, readable -by other Mac accounts; separate profile locks do not replace that exclusion. +by other Mac accounts; Linux also binds staging and cleanup to its canonical +installation. Separate profile locks do not replace that exclusion. Permissions are rechecked before host shutdown and replacement can still fail safely if the filesystem changes afterward. -Linux currently offers manual installation: the AppImage self-launch -repair is a prerequisite, and does not by itself prove safe replacement and -relaunch. Enabling Linux automatic installation requires the packaged gates -below. Unsupported architectures do not install another architecture's payload. +Linux x86_64 uses a persistent user-local directory bundle distributed as a +`.tar.gz`, with launcher and desktop integration. The bundle carries its loader +and client libraries; users need no FUSE mount or Nix runtime. Host graphics +drivers, CA certificates and desktop services remain required. Read-only, +inconsistent or unsupported installations offer the signed manual download. +Unsupported architectures do not install another architecture's payload. +See [desktop builds](desktop-builds.md) for installation commands. + +The bundled loader makes `current_exe()` refer to the loader. Detached hosts and +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`. ## Wire and trust contract @@ -65,8 +75,14 @@ The `maple-agent-update-v1` manifest identifies `maple-agent`, `stable`, a canonical stable SemVer, a positive decimal build number, the source commit, UTC publication date, bounded plain-text notes, updater artifacts, and manual downloads. macOS arm64 uses `.app.tar.gz` for installation and `.dmg` for manual -download. Optional Linux x86_64 entries reference the same `.AppImage` in both -inventories. Each object has a signed path, byte size, and SHA-256. +download. Legacy v1 parsing retains its optional `.AppImage` contract, but new +macOS publications remain mac-only for compatibility with installed clients. +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 +signed path, byte size, and SHA-256. Paths stay under `/agent/stable/releases//`. Metadata is bounded to 128 KiB, manifest text to 64 KiB, signature text to 4 KiB, release notes @@ -103,6 +119,15 @@ path traversal, hard links, special files, duplicate paths and escaping or cycli symlinks while allowing internal framework links. No downloaded executable runs before replacement. +Linux installation additionally requires canonical `bundle.json` identity, +matching running version/source and the expected persistent layout. Preparation +uses the same bounded archive/tree scanner. Under the install lock, replacement +rechecks the actual installed version and atomically exchanges directories with +`RENAME_EXCHANGE`; unsupported filesystems fail before commit. A stale process +cannot replace a newer installed bundle. Stage names are installation-specific, +and cleanup authenticates the receipt and new installed tree after successful +store bootstrap before deleting a retained previous bundle. + ## Owner setup before activation Implementation PRs do not create infrastructure, production signing keys, @@ -163,7 +188,7 @@ signature/notarization checks remain intact. They do not prove Developer ID distribution, deployed service behavior, or a successful real package update. -Before invites, publish an unadvertised signed `0.0.1`, then `0.0.2`, and run the +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 start during installation, the new version works, and a failed or interrupted @@ -171,12 +196,15 @@ attempt preserves a runnable installation. Keep version, build, source SHA, workflow run, manifest, artifact hashes, OS, install location and observed result with the rehearsal evidence. The invitation date slips if these gates fail. -For Linux, first verify AppImage GUI, host, ACP and reopen behavior on the target -Ubuntu environment, including spaces in the path and the supported extraction -mode. Then verify a real AppImage N-to-N+1 replacement and relaunch with host -exclusion and failure recovery. Until that evidence exists, Linux automatic -installation stays disabled and Linux does not count as passing the preview's -updater gate. Do not infer package proof from a launcher fixture or Rust unit test. +For Linux, first verify tar-bundle installation, GUI, host, ACP and reopen on the +available Ubuntu environment, including spaces in the path, a clean runtime +environment and no FUSE or Nix runtime. The first release targets `0.0.4`; an +owner Omarchy test provides additional desktop coverage. Fedora and other distro +compatibility remain unverified until exercised. Then verify a real signed +N-to-N+1 directory replacement/relaunch through the production UI with host +exclusion, persistence and failure recovery. The first installation alone does +not pass the preview's updater gate. Do not infer package proof from a launcher +fixture or Rust unit test; assess audio, portals, CUA and GPU support separately. Recovery must preserve the signed high-water mark. Fix a bad offer by publishing a new, higher version; do not rewrite an immutable version or lower the pointer diff --git a/apps/maple-agent/justfile b/apps/maple-agent/justfile index 06f2f02c2..857b86908 100644 --- a/apps/maple-agent/justfile +++ b/apps/maple-agent/justfile @@ -89,9 +89,12 @@ packaging-test: python3 ./scripts/test_third_party_notices.py python3 ./scripts/test_release_packaging.py python3 ./scripts/test_linux_release_appimage.py + python3 ./scripts/test_linux_release_bundle.py + python3 ./scripts/test_linux_install.py python3 ./scripts/test_macos_release_dylibs.py python3 ./scripts/test_macos_build_info.py python3 ./scripts/test_smoke_macos_release.py + python3 ./scripts/test_smoke_linux_bundle_host.py # Build a release binary that keeps symbols for backtraces. release-debug: diff --git a/apps/maple-agent/release-profiles.json b/apps/maple-agent/release-profiles.json index 67e6ea661..f1a189ae9 100644 --- a/apps/maple-agent/release-profiles.json +++ b/apps/maple-agent/release-profiles.json @@ -11,6 +11,7 @@ "pcr_environment": "development", "update_tag_prefix": "maple-agent-dev-v", "update_feed_url": null, + "linux_update_feed_url": null, "update_public_keys": [], "prerelease": true }, @@ -26,6 +27,7 @@ "pcr_environment": "production", "update_tag_prefix": "maple-agent-v", "update_feed_url": "https://updates.trymaple.ai/agent/stable/latest.json", + "linux_update_feed_url": "https://updates.trymaple.ai/agent/stable/linux/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 74dc0e3fc..40a3822bc 100644 --- a/apps/maple-agent/release_profile.rs +++ b/apps/maple-agent/release_profile.rs @@ -18,10 +18,23 @@ pub struct ReleaseProfile { pub pcr_environment: String, pub update_tag_prefix: String, pub update_feed_url: Option, + pub linux_update_feed_url: Option, pub update_public_keys: Vec, pub prerelease: bool, } +impl ReleaseProfile { + /// The platform feed is compiled into both constants and build metadata. + /// Unsupported targets never inherit another platform's update protocol. + pub fn update_feed_for_target_os(&self, target_os: &str) -> Option<&str> { + match target_os { + "macos" => self.update_feed_url.as_deref(), + "linux" => self.linux_update_feed_url.as_deref(), + _ => None, + } + } +} + pub fn profiles() -> Result, String> { let profiles: BTreeMap = serde_json::from_str(include_str!("release-profiles.json")).map_err(|e| e.to_string())?; @@ -87,6 +100,12 @@ fn validate_profiles(profiles: &BTreeMap) -> Result<(), } else { None } + || profile.linux_update_feed_url.as_deref() + != if channel == "prod" { + Some("https://updates.trymaple.ai/agent/stable/linux/latest.json") + } else { + None + } || (channel == "dev" && !profile.update_public_keys.is_empty()) || !matches!(profile.update_public_keys.len(), 0 | 2) || profile @@ -141,6 +160,7 @@ pub fn select_profile( // Development binaries must never discover or install a Prod update, // even if the selected packaged profile gains signing keys later. profile.update_feed_url = None; + profile.linux_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. @@ -210,7 +230,8 @@ pub fn emit(manifest_path: &str, helper_path: &str, app: bool) { constants.push_str(&format!( "pub const AUTH_ORIGIN: Option<&str> = {auth_origin};\n" )); - let update_feed_url = match profile.update_feed_url.as_deref() { + let target_os = std::env::var("CARGO_CFG_TARGET_OS").expect("Cargo target OS"); + let update_feed_url = match profile.update_feed_for_target_os(&target_os) { Some(url) => format!( "Some({})", serde_json::to_string(url).expect("profile update feed URL") @@ -315,6 +336,38 @@ mod tests { assert!(validate_profiles(&profiles).is_err()); } + #[test] + fn compiled_update_feeds_match_platform_and_disable_unsupported_targets() { + let (_, prod) = select_profile(Some("prod"), None).unwrap(); + assert_eq!( + prod.update_feed_for_target_os("macos"), + Some("https://updates.trymaple.ai/agent/stable/latest.json") + ); + assert_eq!( + prod.update_feed_for_target_os("linux"), + Some("https://updates.trymaple.ai/agent/stable/linux/latest.json") + ); + for target_os in ["windows", "freebsd", ""] { + assert_eq!(prod.update_feed_for_target_os(target_os), None); + } + for channel in [None, Some("dev")] { + let (_, profile) = select_profile(channel, None).unwrap(); + for target_os in ["macos", "linux", "windows"] { + assert_eq!(profile.update_feed_for_target_os(target_os), None); + } + } + 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; + assert_eq!( + validate_profiles(&configured).is_ok(), + channel == "dev" && configured["dev"].linux_update_feed_url.is_none() + ); + } + } + } + #[test] fn updater_profile_accepts_disabled_key_setup_and_rejects_empty_or_duplicate_keys() { let original = profiles().unwrap(); diff --git a/apps/maple-agent/scripts/linux-install.py b/apps/maple-agent/scripts/linux-install.py new file mode 100644 index 000000000..4e53ec941 --- /dev/null +++ b/apps/maple-agent/scripts/linux-install.py @@ -0,0 +1,266 @@ +#!/usr/bin/env python3 +"""First-install one trusted Linux bundle; never replace an app or touch profile data.""" + +import argparse +import ctypes +import errno +import fcntl +import hashlib +import json +import os +from pathlib import Path, PurePosixPath +import re +import shlex +import shutil +import stat +import sys +import tempfile + + +FORMAT = "maple-agent-linux-bundle-v1" +FIELDS = {"format", "product", "profile", "version", "build", "source_sha", + "bundle_id", "entrypoint", "executable"} +PROFILES = { + "prod": ("maple-agent.app", "maple-agent", "cloud.opensecret.maple.agent", "Maple Agent"), + "dev": ("maple-agent-dev.app", "maple-agent-dev", "cloud.opensecret.maple.agent.dev", "Maple Agent Dev"), +} +MAX_ENTRIES = 20_000 +MAX_FILE = 2 * 1024**3 +MAX_TOTAL = 4 * 1024**3 +MAX_PATH = 1024 +MAX_DESCRIPTOR = 16 * 1024 + + +class InstallError(RuntimeError): + pass + + +def need(condition, message): + if not condition: + raise InstallError(message) + + +def unique_object(pairs): + result = {} + for key, value in pairs: + need(key not in result, "duplicate metadata field") + result[key] = value + return result + + +def read_json(path): + info = path.lstat() + need(stat.S_ISREG(info.st_mode) and info.st_nlink == 1 and info.st_size <= MAX_DESCRIPTOR, + "invalid or oversized metadata file") + with path.open("rb") as source: + data = source.read(MAX_DESCRIPTOR + 1) + need(len(data) <= MAX_DESCRIPTOR, "oversized metadata file") + return json.loads(data, object_pairs_hook=unique_object) + + +def validate_descriptor(value, profile=None): + need(isinstance(value, dict) and set(value) == FIELDS + and all(isinstance(item, str) for item in value.values()), "invalid bundle descriptor fields") + need(value["format"] == FORMAT and value["product"] == "maple-agent", "wrong bundle product/format") + selected = value["profile"] + need(selected in PROFILES and (profile is None or selected == profile), "wrong bundle profile") + need(value["bundle_id"] == PROFILES[selected][2] + and value["entrypoint"] == "AppRun" and value["executable"] == "usr/bin/maple-agent", + "wrong bundle identity or entry point") + need(re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?", value["version"]) + and re.fullmatch(r"[1-9][0-9]*", value["build"]) + and re.fullmatch(r"[0-9a-f]{40}", value["source_sha"]), "invalid bundle release identity") + return value + + +def digest(path): + result = hashlib.sha256() + with path.open("rb") as source: + while block := source.read(1024 * 1024): + result.update(block) + return result.hexdigest() + + +def inventory(root): + """Validate a user-owned tree without following directory symlinks.""" + need(root.is_dir() and not root.is_symlink(), "bundle must be a real directory") + result, total = {}, 0 + for path in (root, *sorted(root.rglob("*"))): + info = path.lstat() + relative = path.relative_to(root).as_posix() + need(len(result) < MAX_ENTRIES and len(relative.encode()) <= MAX_PATH, + "bundle inventory exceeds bounds") + need(info.st_uid == os.getuid(), "bundle is not owned by the current user") + mode = stat.S_IMODE(info.st_mode) + if stat.S_ISDIR(info.st_mode): + need(mode == 0o755, "bundle directory has unsafe permissions") + record = ("directory", mode) + elif stat.S_ISREG(info.st_mode): + need(info.st_nlink == 1 and info.st_size <= MAX_FILE, "bundle file is unsafe or oversized") + total += info.st_size + need(total <= MAX_TOTAL, "expanded bundle exceeds bounds") + with path.open("rb") as source: + executable = source.read(4) == b"\x7fELF" + executable = executable or relative in ("AppRun", "install.sh") + need(mode == (0o755 if executable else 0o644), "bundle file has unsafe permissions") + record = ("file", mode, info.st_size, digest(path)) + elif stat.S_ISLNK(info.st_mode): + target = path.readlink() + need(not target.is_absolute() and len(str(target).encode()) <= MAX_PATH, + "bundle symlink target is invalid") + try: + resolved = path.resolve(strict=True) + resolved.relative_to(root.resolve()) + except (OSError, RuntimeError, ValueError) as error: + raise InstallError("bundle symlink escapes, cycles or is dangling") from error + record = ("symlink", str(target)) + else: + raise InstallError("bundle contains a special file") + result[relative] = record + return result + + +def validate_bundle(root, profile=None): + descriptor = validate_descriptor(read_json(root / "bundle.json"), profile) + need(root.name == PROFILES[descriptor["profile"]][0], "unexpected bundle root name") + records = inventory(root) + for relative in ("AppRun", "usr/bin/maple-agent", "usr/lib/ld-linux-x86-64.so.2", + "usr/lib/libc.so.6", "install.sh", "usr/share/maple-agent/install.py"): + need(relative in records and records[relative][0] == "file", "bundle is missing a required regular file") + return descriptor, records + + +def owned_directory(path): + if not path.exists() and not path.is_symlink(): + path.mkdir(mode=0o755) + info = path.lstat() + need(stat.S_ISDIR(info.st_mode) and info.st_uid == os.getuid(), "managed parent is not an owned directory") + + +def owned_parents(home, relative): + current = home + need(current.is_dir() and current.stat().st_uid == os.getuid(), "home is not owned by the current user") + for part in PurePosixPath(relative).parts: + current /= part + owned_directory(current) + return current + + +def layout(home, profile): + root_name, command, bundle_id, _ = PROFILES[profile] + return { + "bundle": home / ".local" / root_name, + "wrapper": home / ".local/bin" / command, + "desktop": home / ".local/share/applications" / (bundle_id + ".desktop"), + "icon": home / ".local/share/icons/hicolor/256x256/apps" / (bundle_id + ".png"), + "receipt": home / ".local/share/maple-agent-installer" / (profile + ".json"), + } + + +def rename_absent(source, destination): + """Linux first install must never replace a concurrently created destination.""" + libc = ctypes.CDLL(None, use_errno=True) + rename = getattr(libc, "renameat2", None) + need(rename is not None, "safe installation requires Linux renameat2") + rename.argtypes = (ctypes.c_int, ctypes.c_char_p, ctypes.c_int, ctypes.c_char_p, ctypes.c_uint) + rename.restype = ctypes.c_int + if rename(-100, os.fsencode(source), -100, os.fsencode(destination), 1) != 0: + code = ctypes.get_errno() + if code == errno.EEXIST: + raise InstallError("installation already exists; use the app's updater") + raise OSError(code, "could not publish the first installation") + + +def exclusive_write(path, data, mode): + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, mode) + try: + with os.fdopen(fd, "wb") as target: + target.write(data) + target.flush() + os.fchmod(target.fileno(), mode) + os.fsync(target.fileno()) + except BaseException: + path.unlink() + raise + + +def desktop_argument(value): + # Desktop Entry Exec quoting is distinct from shell quoting; percent is a field code. + value = value.replace("%", "%%") + return '"' + re.sub(r'([\\"`$])', r'\\\1', value) + '"' + + +def install(source, home): + descriptor, expected = validate_bundle(source) + profile = descriptor["profile"] + paths = layout(home, profile) + for relative in (".local", ".local/bin", ".local/share/applications", + ".local/share/icons/hicolor/256x256/apps", ".local/share/maple-agent-installer"): + owned_parents(home, relative) + need(not any(path.exists() or path.is_symlink() for path in paths.values()), + "installation or integration path already exists; nothing was replaced") + bundle_id, display = PROFILES[profile][2:] + wrapper = ("#!/bin/sh\n# maple-agent-user-install-v1\nexec " + + shlex.quote(str(paths["bundle"] / "AppRun")) + ' "$@"\n').encode() + desktop = ("[Desktop Entry]\nType=Application\n" + f"Name={display}\n" + + "Exec=" + desktop_argument(str(paths["wrapper"])) + " %U\n" + + f"Icon={bundle_id}\nTerminal=false\nCategories=Office;Utility;\n" + + f"StartupWMClass={bundle_id}\n").encode() + icon = source / "usr/share/icons/hicolor/256x256/apps" / (bundle_id + ".png") + need(icon.is_file() and not icon.is_symlink() and icon.stat().st_size <= 4 * 1024 * 1024, + "bundle icon is invalid") + outputs = {"wrapper": (wrapper, 0o755), "desktop": (desktop, 0o644), "icon": (icon.read_bytes(), 0o644)} + created, installed_identity = [], None + try: + with tempfile.TemporaryDirectory(prefix=".maple-agent-install-", dir=home / ".local") as temporary: + staged = Path(temporary) / source.name + shutil.copytree(source, staged, symlinks=True) + need(validate_bundle(staged)[1] == expected, "bundle changed while copying") + rename_absent(staged, paths["bundle"]) + info = paths["bundle"].stat() + installed_identity = (info.st_dev, info.st_ino) + for kind, (data, mode) in outputs.items(): + exclusive_write(paths[kind], data, mode) + created.append((paths[kind], hashlib.sha256(data).hexdigest())) + receipt = {"format": "maple-agent-user-install-v1", "profile": profile, "bundle_id": bundle_id, + "files": {kind: hashlib.sha256(data).hexdigest() for kind, (data, _) in outputs.items()}} + exclusive_write(paths["receipt"], (json.dumps(receipt, sort_keys=True) + "\n").encode(), 0o600) + except BaseException: + for path, checksum in reversed(created): + if path.is_file() and not path.is_symlink() and digest(path) == checksum: + path.unlink() + if installed_identity and paths["bundle"].is_dir() and not paths["bundle"].is_symlink(): + info = paths["bundle"].stat() + if (info.st_dev, info.st_ino) == installed_identity: + shutil.rmtree(paths["bundle"]) + raise + return paths + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--bundle", type=Path, required=True) + args = parser.parse_args() + need(sys.platform == "linux" and os.geteuid() != 0, "run as your normal Linux user, without sudo") + source = args.bundle.resolve(strict=True) + descriptor = validate_descriptor(read_json(source / "bundle.json")) + home = Path.home().resolve(strict=True) + prefix = owned_parents(home, ".local") + lock_path = prefix / ".maple-agent-install.lock" + fd = os.open(lock_path, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600) + with os.fdopen(fd, "rb") as lock: + info = os.fstat(lock.fileno()) + need(stat.S_ISREG(info.st_mode) and info.st_uid == os.getuid() and info.st_nlink == 1, + "unsafe installer lock") + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + paths = install(source, home) + print("Installed " + PROFILES[descriptor["profile"]][3] + ". Launch " + str(paths["wrapper"])) + print("Updates are managed by the app. Profile data was not changed.") + + +if __name__ == "__main__": + try: + main() + except (InstallError, OSError, ValueError, KeyError) as error: + print("Maple installation failed: " + str(error), file=sys.stderr) + raise SystemExit(1) diff --git a/apps/maple-agent/scripts/linux-install.sh b/apps/maple-agent/scripts/linux-install.sh new file mode 100755 index 000000000..de909c5dc --- /dev/null +++ b/apps/maple-agent/scripts/linux-install.sh @@ -0,0 +1,9 @@ +#!/bin/sh +# Run from an extracted, verified release bundle. No sudo and no network access. +set -eu +bundle=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd -P) +if ! command -v python3 >/dev/null 2>&1; then + echo "Python 3 is required for initial installation only." >&2 + exit 1 +fi +exec python3 "$bundle/usr/share/maple-agent/install.py" --bundle "$bundle" "$@" diff --git a/apps/maple-agent/scripts/linux-release-appimage.py b/apps/maple-agent/scripts/linux-release-appimage.py index 8dddda78b..918d40dbd 100755 --- a/apps/maple-agent/scripts/linux-release-appimage.py +++ b/apps/maple-agent/scripts/linux-release-appimage.py @@ -219,10 +219,17 @@ def copy_library(source, name): APPRUN = r'''#!/bin/sh set -eu -appdir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +appdir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd -P) +if [ "${APPDIR:-}" != "$appdir" ]; then + unset APPIMAGE APPIMAGE_EXTRACT_AND_RUN +fi +export APPDIR="$appdir" # Only the main Agent process receives this loader path. External CLI tools # continue to use their host loader and environment. library_path="$appdir/usr/lib:/lib/x86_64-linux-gnu:/usr/lib/x86_64-linux-gnu:/lib64:/usr/lib64:/lib:/usr/lib" +case "${FONTCONFIG_FILE:-}" in + */usr/share/maple-agent/fonts.conf) unset FONTCONFIG_FILE ;; +esac if [ -z "${FONTCONFIG_FILE:-}" ]; then if [ -f /etc/fonts/fonts.conf ]; then export FONTCONFIG_FILE=/etc/fonts/fonts.conf @@ -231,14 +238,24 @@ def copy_library(source, name): export FONTCONFIG_FILE="$appdir/usr/share/maple-agent/fonts.conf" fi fi +# Compose data belongs to the desktop, not the bundled library's build store. +# Preserve an explicit XLOCALEDIR, including an intentionally empty value. +if [ -z "${XLOCALEDIR+x}" ] && [ -r /usr/share/X11/locale/compose.dir ]; then + export XLOCALEDIR=/usr/share/X11/locale +fi # ALSA's package defaults point into the build store. Use the desktop's audio # routing and plugins (including PulseAudio/PipeWire) when they are installed. if [ -f /usr/share/alsa/alsa.conf ]; then export ALSA_CONFIG_PATH="${ALSA_CONFIG_PATH:-/usr/share/alsa/alsa.conf}" export ALSA_CONFIG_DIR="${ALSA_CONFIG_DIR:-/usr/share/alsa}" fi -if [ -d /usr/lib/x86_64-linux-gnu/alsa-lib ]; then - export ALSA_PLUGIN_DIR="${ALSA_PLUGIN_DIR:-/usr/lib/x86_64-linux-gnu/alsa-lib}" +if [ -z "${ALSA_PLUGIN_DIR:-}" ]; then + for plugins in /usr/lib/x86_64-linux-gnu/alsa-lib /usr/lib64/alsa-lib /usr/lib/alsa-lib; do + if [ -d "$plugins" ]; then + export ALSA_PLUGIN_DIR="$plugins" + break + fi + done fi # The Nix loader cannot use the host ld.so.cache. Select host GPU manifests # explicitly, and include standard host library directories above for their @@ -307,7 +324,7 @@ def normalize_public_permissions(appdir): if path.is_dir(): path.chmod(0o755) elif path.is_file(): - path.chmod(0o755 if path.name == "AppRun" or elf(path) else 0o644) + path.chmod(0o755 if path in (appdir / "AppRun", appdir / "install.sh") or elf(path) else 0o644) def glibc_version_needs(output): @@ -357,7 +374,7 @@ def audit(appdir, metadata=None): if path.is_dir() and not path.is_symlink() and path.stat().st_mode & 0o777 != 0o755: raise PackagingError(f"Restrictive or unsafe public package directory permissions: {path}") if path.is_file() and not path.is_symlink(): - expected_mode = 0o755 if path.name == "AppRun" or elf(path) else 0o644 + expected_mode = 0o755 if path in (appdir / "AppRun", appdir / "install.sh") or elf(path) else 0o644 if path.stat().st_mode & 0o7777 != expected_mode: raise PackagingError(f"Restrictive or unsafe public package file permissions: {path}") if not path.is_file() or path.is_symlink() or not elf(path): diff --git a/apps/maple-agent/scripts/linux-release-bundle.py b/apps/maple-agent/scripts/linux-release-bundle.py new file mode 100644 index 000000000..cd3575ec8 --- /dev/null +++ b/apps/maple-agent/scripts/linux-release-bundle.py @@ -0,0 +1,250 @@ +#!/usr/bin/env python3 +"""Package the audited Linux AppDir as a portable, user-installable tar bundle.""" + +import argparse +import gzip +import importlib.util +import json +import os +from pathlib import Path, PurePosixPath +import posixpath +import re +import shutil +import sys +import tarfile +import tempfile + + +SCRIPTS = Path(__file__).resolve().parent + + +def load(name, filename): + spec = importlib.util.spec_from_file_location(name, SCRIPTS / filename) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +stage = load("maple_linux_stage", "linux-release-appimage.py") +install = load("maple_linux_install", "linux-install.py") +MAX_EXTENSION = 16 * 1024 + + +def bundle_apprun(): + # A persistent tar installation must never re-enter an inherited outer + # AppImage, even when that image set APPDIR to this same directory. + legacy = 'if [ "${APPDIR:-}" != "$appdir" ]; then\n unset APPIMAGE APPIMAGE_EXTRACT_AND_RUN\nfi\n' + install.need(stage.APPRUN.count(legacy) == 1, "shared launcher contract changed") + return stage.APPRUN.replace(legacy, "unset APPIMAGE APPIMAGE_EXTRACT_AND_RUN\n", 1) + + +def descriptor(metadata, source_sha): + return install.validate_descriptor({ + "format": install.FORMAT, "product": "maple-agent", "profile": metadata["channel"], + "version": metadata["version"], "build": metadata["build_number"], "source_sha": source_sha, + "bundle_id": metadata["bundle_id"], "entrypoint": "AppRun", "executable": "usr/bin/maple-agent", + }) + + +def _skip(stream, size): + while size: + data = stream.read(min(size, 1024 * 1024)) + install.need(data, "truncated tar payload") + size -= len(data) + + +def preflight_headers(archive): + """Bound raw extension sizes before Python's tar parser can allocate them.""" + install.need(archive.is_file() and not archive.is_symlink(), "archive must be a regular file") + total, count = 0, 0 + with gzip.open(archive, "rb") as stream: + while True: + header = stream.read(512) + install.need(len(header) == 512, "tar has no complete end marker") + if header == bytes(512): + install.need(stream.read(512) == bytes(512), "invalid tar end marker") + trailing = 0 + while block := stream.read(1024): + trailing += len(block) + install.need(trailing <= 10240 and not any(block), "unexpected trailing tar content") + return + count += 1 + install.need(count <= install.MAX_ENTRIES, "too many tar headers") + member = tarfile.TarInfo.frombuf(header, "utf-8", "strict") + install.need(member.size >= 0, "negative tar size") + if member.type in (tarfile.GNUTYPE_LONGNAME, tarfile.GNUTYPE_LONGLINK, + tarfile.XHDTYPE, tarfile.XGLTYPE): + install.need(member.size <= MAX_EXTENSION, "oversized tar extension") + else: + install.need(member.isfile() or member.isdir() or member.issym(), "unsupported tar entry type") + install.need(member.size <= install.MAX_FILE, "oversized tar file") + total += member.size + install.need(total <= install.MAX_TOTAL, "expanded tar exceeds bounds") + _skip(stream, (member.size + 511) // 512 * 512) + + +def _safe_name(name): + install.need(isinstance(name, str) and name and len(name.encode()) <= install.MAX_PATH + and not any(ord(char) < 32 or char == "\\" for char in name), "invalid tar path") + path = PurePosixPath(name) + install.need(not path.is_absolute() and ".." not in path.parts and str(path) == name, + "non-canonical or escaping tar path") + return path + + +def inspect_archive(archive, expected_profile=None): + archive = Path(archive) + preflight_headers(archive) + members, links, value = {}, {}, None + with tarfile.open(archive, "r:gz") as source: + for member in source: + name = member.name.rstrip("/") if member.isdir() else member.name + path = _safe_name(name) + install.need(name not in members and len(members) < install.MAX_ENTRIES, + "duplicate or excessive tar entries") + install.need(member.sparse is None and not any("sparse" in key.lower() for key in member.pax_headers), + "sparse archives are unsupported") + install.need(member.isfile() or member.isdir() or member.issym(), "unsupported tar entry") + install.need(0 <= member.size <= install.MAX_FILE, "oversized tar member") + if member.isdir(): + install.need(member.mode == 0o755, "unsafe tar directory permissions") + elif member.isfile(): + payload = source.extractfile(member) + prefix = payload.read(4) + executable = prefix == b"\x7fELF" or path.name in ("AppRun", "install.sh") and len(path.parts) == 2 + install.need(member.mode == (0o755 if executable else 0o644), "unsafe tar file permissions") + if path.name == "bundle.json" and len(path.parts) == 2: + install.need(member.size <= install.MAX_DESCRIPTOR, "oversized bundle descriptor") + data = prefix + payload.read(install.MAX_DESCRIPTOR + 1) + install.need(len(data) == member.size, "truncated bundle descriptor") + value = install.validate_descriptor(json.loads(data, object_pairs_hook=install.unique_object), expected_profile) + payload.close() + else: + install.need(member.size == 0 and member.linkname and not member.linkname.startswith("/") + and len(member.linkname.encode()) <= install.MAX_PATH + and not any(ord(char) < 32 or char == "\\" for char in member.linkname), + "invalid tar symlink") + target = posixpath.normpath(str(path.parent / member.linkname)) + target_path = _safe_name(target) + install.need(target_path.parts[0] == path.parts[0], "tar symlink escapes root") + links[name] = target + members[name] = member + install.need(value is not None, "missing root bundle descriptor") + root = install.PROFILES[value["profile"]][0] + install.need(root in members and members[root].isdir() + and all(PurePosixPath(name).parts[0] == root for name in members), "wrong or multiple bundle roots") + total = sum(member.size for member in members.values() if member.isfile()) + install.need(total <= install.MAX_TOTAL, "expanded tar exceeds bounds") + for name in members: + install.need(not any(str(parent) in links for parent in PurePosixPath(name).parents), + "tar writes through a symlink") + for name, target in links.items(): + visited = {name} + while target in links: + install.need(target not in visited and len(visited) < 40, "tar symlink cycle") + visited.add(target) + target = links[target] + install.need(target in members, "tar contains dangling symlink") + for relative in ("bundle.json", "AppRun", "usr/bin/maple-agent", "usr/lib/ld-linux-x86-64.so.2", + "usr/lib/libc.so.6", "install.sh", "usr/share/maple-agent/install.py"): + member = members.get(root + "/" + relative) + install.need(member is not None and member.isfile(), "tar is missing required regular payload") + return value, members + + +def extract_archive(archive, output, profile=None): + value, members = inspect_archive(archive, profile) + output = Path(output) + install.need(not output.exists() and not output.is_symlink(), "extraction output must be absent") + output.mkdir(mode=0o700, parents=True) + with tarfile.open(archive, "r:gz") as source: + for name, member in sorted(members.items(), key=lambda item: (len(PurePosixPath(item[0]).parts), item[0])): + if member.issym(): + continue + path = output / name + if member.isdir(): + path.mkdir(mode=0o755) + path.chmod(0o755) + else: + with source.extractfile(member) as incoming, path.open("xb") as target: + shutil.copyfileobj(incoming, target, 1024 * 1024) + path.chmod(member.mode) + for name, member in members.items(): + if member.issym(): + (output / name).symlink_to(member.linkname) + return output / install.PROFILES[value["profile"]][0] + + +def audit(root): + value, _ = install.validate_bundle(Path(root)) + actual = stage.audit(Path(root)) + recorded = install.read_json(Path(root) / "usr/share/maple-agent/runtime-audit.json") + install.need(actual == recorded, "bundle runtime audit differs") + return value + + +def build(binary, output): + binary, output = Path(binary).absolute(), Path(output).absolute() + install.need(not output.exists() and not output.is_symlink(), "refusing to replace an existing artifact") + metadata = stage.metadata_from_environment() + value = descriptor(metadata, os.environ["MAPLE_PACKAGE_SOURCE_SHA"]) + prefix = f"maple-agent-{value['profile']}-{value['version']}-" + suffix = "-linux-x86_64.tar.gz" + install.need(output.name.startswith(prefix) and output.name.endswith(suffix), "wrong Linux artifact name") + revision = output.name[len(prefix):-len(suffix)] + install.need(re.fullmatch(r"[0-9a-f]{7,40}", revision) and value["source_sha"].startswith(revision), + "artifact revision differs from bundle source") + epoch = int(os.environ["SOURCE_DATE_EPOCH"]) + install.need(0 <= epoch <= 2**32 - 1, "invalid source date epoch") + output.parent.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix=".linux-bundle-", dir=output.parent) as temporary: + temporary = Path(temporary) + root = temporary / install.PROFILES[value["profile"]][0] + stage.run(sys.executable, SCRIPTS / "linux-release-appimage.py", "--stage-only", binary, root) + (root / "AppRun").write_text(bundle_apprun()) + shutil.copyfile(SCRIPTS / "linux-install.sh", root / "install.sh") + shutil.copyfile(SCRIPTS / "linux-install.py", root / "usr/share/maple-agent/install.py") + (root / "bundle.json").write_text(json.dumps(value, sort_keys=True, separators=(",", ":")) + "\n") + stage.normalize_public_permissions(root) + audit(root) + packed = temporary / "payload.tar.gz" + with packed.open("xb") as target, gzip.GzipFile(filename="", mode="wb", fileobj=target, mtime=epoch) as compressed: + with tarfile.open(fileobj=compressed, mode="w", format=tarfile.GNU_FORMAT, dereference=False) as archive: + for path in (root, *sorted(root.rglob("*"))): + name = root.name + ("/" + path.relative_to(root).as_posix() if path != root else "") + member = archive.gettarinfo(str(path), name) + member.uid = member.gid = 0 + member.uname = member.gname = "" + member.mtime = epoch + with path.open("rb") if member.isfile() else open(os.devnull, "rb") as incoming: + archive.addfile(member, incoming if member.isfile() else None) + packed.chmod(0o644) + inspect_archive(packed, value["profile"]) + os.link(packed, output) + return output + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("operation", help="BINARY, audit or extract") + parser.add_argument("path", type=Path) + parser.add_argument("output", type=Path, nargs="?") + parser.add_argument("--profile", choices=("dev", "prod")) + args = parser.parse_args() + if args.operation == "audit": + install.need(args.output is None, "audit accepts one bundle directory") + print(json.dumps(audit(args.path), sort_keys=True)) + elif args.operation == "extract": + install.need(args.output is not None, "extract requires archive and absent output directory") + print(extract_archive(args.path, args.output, args.profile)) + else: + install.need(args.output is None and args.profile is None, "build accepts binary and output archive") + print(build(Path(args.operation), args.path)) + + +if __name__ == "__main__": + try: + main() + except (install.InstallError, stage.PackagingError, OSError, ValueError, KeyError, tarfile.TarError) as error: + print("Maple Linux bundle failed: " + str(error), file=sys.stderr) + raise SystemExit(1) diff --git a/apps/maple-agent/scripts/linux-release-bundle.sh b/apps/maple-agent/scripts/linux-release-bundle.sh new file mode 100755 index 000000000..344487a88 --- /dev/null +++ b/apps/maple-agent/scripts/linux-release-bundle.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +script_dir="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +exec python3 "$script_dir/linux-release-bundle.py" "$@" diff --git a/apps/maple-agent/scripts/package-release.sh b/apps/maple-agent/scripts/package-release.sh index ac25d0f23..9d10dca79 100755 --- a/apps/maple-agent/scripts/package-release.sh +++ b/apps/maple-agent/scripts/package-release.sh @@ -41,13 +41,14 @@ if [[ "$platform" == macos-aarch64 ]]; then else "$binary" --build-info > "$staging/build-info.json" fi -python3 "$metadata_tool" validate "$profile" "$staging/build-info.json" --source-sha "$source_sha" -field() { python3 "$metadata_tool" field "$profile" "$staging/build-info.json" --field "$1"; } +python3 "$metadata_tool" validate "$profile" "$staging/build-info.json" --source-sha "$source_sha" --platform "$platform" +field() { python3 "$metadata_tool" field "$profile" "$staging/build-info.json" --field "$1" --platform "$platform"; } export MAPLE_PACKAGE_CHANNEL="$profile" export MAPLE_PACKAGE_APP_NAME="$(field display_name)" export MAPLE_PACKAGE_BUNDLE_ID="$(field bundle_id)" export MAPLE_PACKAGE_VERSION="$(field version)" export MAPLE_PACKAGE_BUILD_NUMBER="${GITHUB_RUN_NUMBER:-1}" +export MAPLE_PACKAGE_SOURCE_SHA="$source_sha" export SOURCE_DATE_EPOCH="$(git -C "$component" show -s --format=%ct "$source_sha")" name="maple-agent-${profile}-${MAPLE_PACKAGE_VERSION}-$(field git_revision)-${platform}" @@ -59,13 +60,13 @@ if [[ "$platform" == macos-aarch64 ]]; then APPLE_TEAM_ID="${APPLE_TEAM_ID:-}" \ "$component/scripts/macos-release-app.sh" "$binary" "$staging" "$name" "${unsigned[@]}" else - "$component/scripts/linux-release-appimage.sh" "$binary" "$staging/${name}.AppImage" + "$component/scripts/linux-release-bundle.sh" "$binary" "$staging/${name}.tar.gz" fi python3 "$metadata_tool" manifest "$profile" "$staging/build-info.json" \ --output "$staging" --platform "$platform" "${unsigned[@]}" verification=() [[ "$platform" != macos-aarch64 ]] || verification=(--static) -"$component/scripts/verify-release.sh" "$profile" "$staging" "${unsigned[@]}" "${verification[@]}" +"$component/scripts/verify-release.sh" "$profile" "$staging" "${unsigned[@]}" "${verification[@]}" --platform "$platform" # Keep the previous package until the complete new package passes verification. destination="$component/dist/$profile" diff --git a/apps/maple-agent/scripts/release-info.py b/apps/maple-agent/scripts/release-info.py index 73af1c892..536d9fbe8 100755 --- a/apps/maple-agent/scripts/release-info.py +++ b/apps/maple-agent/scripts/release-info.py @@ -22,8 +22,8 @@ BUILD_FIELDS = set(PUBLIC_FIELDS) | {"profile", "version", "git_revision", "source_sha"} -def read_info(profile, path, source_sha=None): - return validate_info(profile, json.loads(Path(path).read_text()), source_sha) +def read_info(profile, path, source_sha=None, platform=None): + return validate_info(profile, json.loads(Path(path).read_text()), source_sha, platform) def validate_update_keys(keys): @@ -47,16 +47,21 @@ def validate_update_keys(keys): public_points.add(decoded[10:]) -def validate_info(profile, info, source_sha=None): +def validate_info(profile, info, source_sha=None, platform=None): profiles = json.loads((COMPONENT / "release-profiles.json").read_text()) if profile not in ("dev", "prod"): raise ValueError("release profile must be dev or prod") + if platform not in (None, "macos-aarch64", "linux-x86_64"): + raise ValueError("unsupported artifact platform") if not isinstance(info, dict) or set(info) != BUILD_FIELDS: raise ValueError("build-info must contain only the documented public release fields") if info["profile"] != profile: raise ValueError("binary release profile does not match the requested package") for field in PUBLIC_FIELDS: - if info[field] != profiles[profile][field]: + expected = profiles[profile]["linux_update_feed_url"] if ( + field == "update_feed_url" and platform == "linux-x86_64" + ) else profiles[profile][field] + if info[field] != expected: raise ValueError(f"binary {field} does not match the checked-in release profile") if not isinstance(info["prerelease"], bool): raise ValueError("binary prerelease marker must be a boolean") @@ -104,12 +109,32 @@ def write_plist(info, path, build_number): Path(path).write_bytes(plistlib.dumps(plist)) +def linux_package_name(info): + return f"maple-agent-{info['profile']}-{info['version']}-{info['git_revision']}-linux-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): + raise ValueError("artifact directory contains a symlink or unsupported file") + return {path.name for path in files} + + def write_manifest(info, directory, platform, unsigned): directory = Path(directory) - suffix = ".dmg" if platform == "macos-aarch64" else ".AppImage" - packages = sorted(p.name for p in directory.iterdir() if p.is_file() and p.name.endswith(suffix)) + if platform not in ("macos-aarch64", "linux-x86_64"): + raise ValueError("unsupported artifact platform") + files = public_files(directory) + suffix = ".dmg" if platform == "macos-aarch64" else ".tar.gz" + packages = sorted(name for name in files if name.endswith(suffix)) if len(packages) != 1: raise ValueError("artifact directory must contain exactly one platform package") + if platform == "linux-x86_64" and ( + packages[0] != linux_package_name(info) + or not {"build-info.json", packages[0]} <= files + or files - {"build-info.json", "manifest.json", "SHA256SUMS", packages[0]} + ): + raise ValueError("Linux artifact directory must contain only the expected bundle and public metadata") manifest = { "schema_version": 1, "profile": info["profile"], @@ -132,17 +157,21 @@ def write_manifest(info, directory, platform, unsigned): )) for path in directory.iterdir(): if path.is_file(): - path.chmod(0o755 if path.name.endswith(".AppImage") else 0o644) + path.chmod(0o644) directory.chmod(0o755) -def verify_artifacts(profile, directory, unsigned, source_sha=None): +def verify_artifacts(profile, directory, unsigned, source_sha=None, platform=None): directory = Path(directory).resolve() - info = read_info(profile, directory / "build-info.json", source_sha) + files = public_files(directory) manifest = json.loads((directory / "manifest.json").read_text()) - platform = manifest.get("platform") - if platform not in ("macos-aarch64", "linux-x86_64"): + manifest_platform = manifest.get("platform") + if manifest_platform not in ("macos-aarch64", "linux-x86_64"): raise ValueError("unsupported artifact platform") + if platform is not None and manifest_platform != platform: + raise ValueError("manifest platform does not match the requested platform") + platform = manifest_platform + info = read_info(profile, directory / "build-info.json", source_sha, platform) if manifest.get("schema_version") != 1 or any( manifest.get(key) != info[key] for key in ("profile", "source_sha", "version") ): @@ -153,6 +182,13 @@ def verify_artifacts(profile, directory, unsigned, source_sha=None): "ad-hoc" if unsigned else "developer-id-notarized" ): raise ValueError("unexpected macOS signing policy") + if platform == "linux-x86_64": + if manifest.get("macos_signing", "missing") is not None or manifest.get("linux_integrity") != "sha256": + raise ValueError("unexpected Linux integrity policy") + if manifest.get("package") != linux_package_name(info) or files != { + "build-info.json", "manifest.json", "SHA256SUMS", linux_package_name(info), + }: + raise ValueError("Linux artifact directory must contain exactly the expected bundle and public metadata") lines = (directory / "SHA256SUMS").read_text().splitlines() checked = set() for line in lines: @@ -166,11 +202,10 @@ def verify_artifacts(profile, directory, unsigned, source_sha=None): if path.is_symlink() or not path.is_file() or hashlib.sha256(path.read_bytes()).hexdigest() != digest: raise ValueError(f"artifact checksum failed: {name}") checked.add(name) - files = {p.name for p in directory.iterdir() if p.is_file() and p.name != "SHA256SUMS"} - if checked != files or any(not p.is_file() or p.is_symlink() for p in directory.iterdir()): + if checked != files - {"SHA256SUMS"}: raise ValueError("artifact directory contains unchecked files or directories") package = manifest.get("package", "") - suffix = ".dmg" if platform == "macos-aarch64" else ".AppImage" + suffix = ".dmg" if platform == "macos-aarch64" else ".tar.gz" if package not in checked or not package.endswith(suffix): raise ValueError("manifest package is missing or has the wrong format") return manifest @@ -261,7 +296,7 @@ def main(): parser.add_argument("--unsigned", action="store_true") args = parser.parse_args() if args.operation == "verify": - print(json.dumps(verify_artifacts(args.profile, args.path, args.unsigned, args.source_sha))) + print(json.dumps(verify_artifacts(args.profile, args.path, args.unsigned, args.source_sha, args.platform))) return if args.operation == "extract-app": app_name = json.loads((COMPONENT / "release-profiles.json").read_text())[args.profile]["display_name"] @@ -270,7 +305,7 @@ def main(): if args.operation == "compare-apps": compare_app_payloads(args.path, args.output) return - info = read_info(args.profile, args.path, args.source_sha) + info = read_info(args.profile, args.path, args.source_sha, args.platform) if args.operation == "field": if args.field not in BUILD_FIELDS: raise ValueError("field is not public release metadata") diff --git a/apps/maple-agent/scripts/smoke-linux-bundle-host.sh b/apps/maple-agent/scripts/smoke-linux-bundle-host.sh new file mode 100644 index 000000000..1894331a8 --- /dev/null +++ b/apps/maple-agent/scripts/smoke-linux-bundle-host.sh @@ -0,0 +1,123 @@ +#!/bin/sh +# Check an installed Linux bundle AppRun without an account or credentials. +# Run in the verifier's network-disabled Ubuntu container. Requires coreutils, +# grep and util-linux flock; no Nix or Python is needed inside the container. +set -eu +umask 077 +if [ "$#" -ne 1 ] || [ ! -f "$1" ] || [ ! -x "$1" ] || [ -L "$1" ]; then + echo 'usage: smoke-linux-bundle-host.sh /installed/maple-agent.app/AppRun' >&2 + exit 2 +fi +apprun=$(CDPATH= cd -- "$(dirname -- "$1")" && pwd)/$(basename -- "$1") +smoke_timeout=${MAPLE_AGENT_SMOKE_TIMEOUT:-30} +case "$smoke_timeout" in ''|*[!0-9]*|0) exit 2 ;; esac +smoke_path=${PATH:-/usr/bin:/bin} +scratch=$(mktemp -d /tmp/maple-agent-bundle-smoke.XXXXXX) +scratch=$(CDPATH= cd -- "$scratch" && pwd -P) +endpoint='' +owner='' +mkdir "$scratch/home" "$scratch/config" "$scratch/data" "$scratch/cache" "$scratch/tmp" +cd -- "$scratch" + +agent() { + agent_timeout=$smoke_timeout + # Short cancellation probes must not also expire cold identity discovery. + if [ "${1:-}" = --build-info ] && [ "$agent_timeout" -lt 10 ]; then + agent_timeout=10 + fi + timeout "$agent_timeout" env -i PATH="$smoke_path" HOME="$scratch/home" \ + XDG_CONFIG_HOME="$scratch/config" XDG_DATA_HOME="$scratch/data" \ + XDG_CACHE_HOME="$scratch/cache" TMPDIR="$scratch/tmp" \ + MAPLE_DISABLE_UPDATE_CHECK=1 "$apprun" "$@" +} +locked() { + [ -f "$owner" ] && ! flock -n "$owner" true +} +fail() { echo "$1" >&2; exit 1; } +diagnostics() { + [ "${MAPLE_AGENT_SMOKE_DIAGNOSTICS:-0}" = 1 ] || return 0 + # This probe passes no inherited credentials and uses empty private state. + # Opt-in CI output is bounded and strips escapes while allowing whitespace. + echo "Isolated signed-out ACP exit status: $result" >&2 + for diagnostic in "$error" "$scratch/data/$namespace/store-v1/logs/maple-agent.log"; do + [ -f "$diagnostic" ] || continue + echo 'Bounded isolated startup diagnostics:' >&2 + tail -c 8192 "$diagnostic" | head -c 8192 | LC_ALL=C tr -cd '\11\12\15\40-\176' >&2 + echo >&2 + done +} +cleanup() { + status=$? + trap - EXIT INT TERM HUP + safe=true + if [ -n "$endpoint" ]; then + agent host stop >/dev/null 2>&1 || true + deadline=$(($(date +%s) + smoke_timeout)) + while locked || [ -S "$endpoint/ipc.sock" ]; do + if [ "$(date +%s)" -ge "$deadline" ]; then safe=false; break; fi + sleep 0.05 + done + fi + if "$safe"; then + rm -rf -- "$scratch" + if [ -n "$endpoint" ]; then rm -rf -- "$endpoint"; fi + else + echo 'The isolated host did not release ownership; probe state was retained' >&2 + status=1 + fi + if [ "$status" -eq 0 ]; then + echo 'Verified Linux bundle AppRun starts one host, preserves the signed-out ACP guard across two clients, and releases ownership on stop' + fi + exit "$status" +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM +trap 'exit 129' HUP + +# These roots contain only controlled ASCII characters. This is the exact JSON +# storage owner identity in host/ipc.rs, without scanning another host's files. +agent --build-info > "$scratch/build-info.json" 2>/dev/null || fail 'Could not read AppRun build identity' +namespace=$(sed -n 's/.*"data_namespace"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$scratch/build-info.json") +case "$namespace" in maple-agent|maple-agent-dev|maple-agent-prod) ;; *) fail 'Unexpected Agent data namespace' ;; esac +mkdir "$scratch/config/$namespace" "$scratch/data/$namespace" +store="$scratch/data/$namespace/store-v1" +owner="$store/owner.lock" +uid=$(id -u) +identity=$(printf '["%s","%s","%s"]' "$store" "$namespace" "$uid" | sha256sum | cut -c1-24) +candidate="/tmp/maple-agent-$uid/$identity" +[ ! -e "$candidate" ] || fail 'The smoke profile unexpectedly already has an endpoint' +endpoint=$candidate + +signed_out_client() { + number=$1 + output="$scratch/output-$number" + error="$scratch/error-$number" + result=0 + # Both fixed unauthenticated gates exercise on-demand spawn and self-launch: + # a fresh store may require setup or open without a saved sign-in. + agent acp < /dev/null > "$output" 2> "$error" || result=$? + if [ "$result" -eq 124 ]; then + diagnostics + fail 'The signed-out ACP probe timed out' + fi + if [ "$result" -ne 1 ] || ! { + grep -Fxq 'Maple needs local store setup. Open Maple to continue.' "$error" || + grep -Fxq 'Maple ACP requires sign-in. Sign in through Maple Desktop or `maple-agent login` first.' "$error" + }; then + diagnostics + fail 'The ACP probe did not return an expected signed-out guard' + fi +} +socket_identity() { + [ -S "$endpoint/ipc.sock" ] || fail 'The isolated host did not retain its IPC socket' + ls -di "$endpoint/ipc.sock" | awk '{print $1}' +} + +signed_out_client 1 +inode=$(socket_identity) +locked || fail 'The host did not retain ownership after the signed-out ACP client exited' +signed_out_client 2 +[ "$(socket_identity)" = "$inode" ] && locked || fail 'The second ACP client did not reuse the same host' +# The EXIT trap stops this isolated host and checks its lock and socket before +# removing state. A failed stop cannot produce a successful verification. diff --git a/apps/maple-agent/scripts/test_linux_install.py b/apps/maple-agent/scripts/test_linux_install.py new file mode 100644 index 000000000..b947d6afa --- /dev/null +++ b/apps/maple-agent/scripts/test_linux_install.py @@ -0,0 +1,251 @@ +#!/usr/bin/env python3 +"""First-install boundaries with synthetic bundles; never launch the Agent.""" + +import errno +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import shutil +import stat +import subprocess +import sys +import tarfile +import tempfile +import unittest +from unittest import mock + + +spec = importlib.util.spec_from_file_location( + "linux_install", Path(__file__).with_name("linux-install.py") +) +installer = importlib.util.module_from_spec(spec) +spec.loader.exec_module(installer) + +SOURCE = "1234567890abcdef1234567890abcdef12345678" +IDENTITIES = { + "prod": ("maple-agent.app", "cloud.opensecret.maple.agent"), + "dev": ("maple-agent-dev.app", "cloud.opensecret.maple.agent.dev"), +} + + +def bundle_fixture(parent, profile="prod"): + root_name, bundle_id = IDENTITIES[profile] + root = parent / root_name + descriptor = { + "format": "maple-agent-linux-bundle-v1", "product": "maple-agent", + "profile": profile, "version": "0.0.4", "build": "500", "source_sha": SOURCE, + "bundle_id": bundle_id, "entrypoint": "AppRun", "executable": "usr/bin/maple-agent", + } + files = { + "bundle.json": json.dumps(descriptor, sort_keys=True).encode(), + "AppRun": b'#!/bin/sh\nprintf "%s\\n" "$@"\n', + "install.sh": b"#!/bin/sh\nexit 0\n", + "usr/bin/maple-agent": b"\x7fELFfixture-agent", + "usr/lib/ld-linux-x86-64.so.2": b"\x7fELFfixture-loader", + "usr/lib/libc.so.6": b"\x7fELFfixture-libc", + "usr/share/maple-agent/install.py": b"# synthetic installer payload\n", + f"usr/share/icons/hicolor/256x256/apps/{bundle_id}.png": b"fixture-public-icon", + } + for name, data in files.items(): + path = root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(data) + path.chmod(0o755 if name in ("AppRun", "install.sh") or data.startswith(b"\x7fELF") else 0o644) + (root / "usr/lib/libc-link.so").symlink_to("libc.so.6") + for path in (root, *root.rglob("*")): + if path.is_dir() and not path.is_symlink(): + path.chmod(0o755) + return root + + +def portable_rename_absent(source, destination): + # The production implementation's Linux renameat2 is tested separately. + # This adapter lets first-install file ownership/rollback run on macOS CI. + if destination.exists() or destination.is_symlink(): + raise installer.InstallError("installation already exists") + source.rename(destination) + + +class LinuxInstallTests(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + self.home = self.root / "home with 'quotes' and %" + self.home.mkdir() + self.source = bundle_fixture(self.root / "download") + patch = mock.patch.object(installer, "rename_absent", portable_rename_absent) + patch.start() + self.addCleanup(patch.stop) + + def test_first_install_copies_bundle_and_integrates_without_profile_data_changes(self): + data = self.home / ".local/share/maple-agent/store-v1" + data.mkdir(parents=True) + sentinel = data / "untouched-fixture" + sentinel.write_bytes(b"encrypted-profile-fixture") + before = installer.inventory(self.source) + paths = installer.install(self.source, self.home) + self.assertEqual(installer.inventory(paths["bundle"]), before) + self.assertEqual(installer.inventory(self.source), before) + self.assertEqual(sentinel.read_bytes(), b"encrypted-profile-fixture") + self.assertEqual(paths["bundle"], self.home / ".local/maple-agent.app") + self.assertFalse(paths["wrapper"].is_symlink()) + result = subprocess.run([str(paths["wrapper"]), "argument with spaces", "--literal"], + check=True, capture_output=True, text=True) + self.assertEqual(result.stdout, "argument with spaces\n--literal\n") + self.assertEqual(stat.S_IMODE(paths["wrapper"].stat().st_mode), 0o755) + self.assertEqual(stat.S_IMODE(paths["receipt"].stat().st_mode), 0o600) + desktop = paths["desktop"].read_text() + self.assertIn("Exec=\"", desktop) + self.assertIn("%%", desktop) + self.assertIn(" %U\n", desktop) + receipt = json.loads(paths["receipt"].read_text()) + self.assertEqual(receipt["profile"], "prod") + for kind in ("wrapper", "desktop", "icon"): + self.assertEqual(receipt["files"][kind], hashlib.sha256(paths[kind].read_bytes()).hexdigest()) + + def test_dev_and_prod_have_distinct_bundle_commands_and_desktop_entries(self): + prod = installer.install(self.source, self.home) + dev_source = bundle_fixture(self.root / "dev-download", "dev") + dev = installer.install(dev_source, self.home) + self.assertEqual(dev["bundle"].name, "maple-agent-dev.app") + self.assertEqual(dev["wrapper"].name, "maple-agent-dev") + self.assertEqual(dev["desktop"].name, "cloud.opensecret.maple.agent.dev.desktop") + self.assertTrue(set(prod.values()).isdisjoint(dev.values())) + + def test_every_preexisting_managed_path_is_refused_without_replacement(self): + for kind in ("bundle", "wrapper", "desktop", "icon", "receipt"): + with self.subTest(kind=kind): + home = self.root / ("existing-" + kind) + home.mkdir() + paths = installer.layout(home, "prod") + occupied = paths[kind] + occupied.parent.mkdir(parents=True, exist_ok=True) + occupied.write_bytes(b"existing-owner-content") + with self.assertRaisesRegex(installer.InstallError, "already exists"): + installer.install(self.source, home) + self.assertEqual(occupied.read_bytes(), b"existing-owner-content") + self.assertFalse(any(path.exists() for name, path in paths.items() if name != kind)) + + def test_symlink_managed_parent_and_destination_are_refused(self): + outside = self.root / "outside" + outside.mkdir() + (self.home / ".local").symlink_to(outside, target_is_directory=True) + with self.assertRaisesRegex(installer.InstallError, "owned directory"): + installer.install(self.source, self.home) + self.assertEqual(list(outside.iterdir()), []) + (self.home / ".local").unlink() + (self.home / ".local").mkdir() + destination = self.home / ".local/maple-agent.app" + destination.symlink_to(outside, target_is_directory=True) + with self.assertRaisesRegex(installer.InstallError, "already exists"): + installer.install(self.source, self.home) + self.assertTrue(destination.is_symlink()) + + def test_integration_failure_rolls_back_only_new_install_and_keeps_profile_data(self): + sentinel = self.home / "profile-preservation-fixture" + sentinel.write_bytes(b"keep") + original = installer.exclusive_write + calls = 0 + + def fail_second_output(path, data, mode): + nonlocal calls + calls += 1 + if calls == 2: + raise OSError("fixture disk failure") + original(path, data, mode) + + with mock.patch.object(installer, "exclusive_write", side_effect=fail_second_output): + with self.assertRaisesRegex(OSError, "fixture disk failure"): + installer.install(self.source, self.home) + self.assertFalse(any(path.exists() for path in installer.layout(self.home, "prod").values())) + self.assertEqual(sentinel.read_bytes(), b"keep") + self.assertTrue(self.source.is_dir()) + + def test_source_mutation_during_copy_is_refused_before_publication(self): + original = shutil.copytree + + def changed_copy(source, destination, *args, **kwargs): + result = original(source, destination, *args, **kwargs) + if Path(source) == self.source: + (Path(destination) / "usr/share/maple-agent/install.py").write_bytes(b"changed") + return result + + with mock.patch.object(installer.shutil, "copytree", side_effect=changed_copy): + with self.assertRaisesRegex(installer.InstallError, "changed while copying"): + installer.install(self.source, self.home) + self.assertFalse(installer.layout(self.home, "prod")["bundle"].exists()) + + def test_unsafe_tree_modes_hardlinks_and_escape_are_refused(self): + cases = { + "writable": lambda root: (root / "AppRun").chmod(0o777), + "setid": lambda root: (root / "AppRun").chmod(0o4755), + "hardlink": lambda root: os.link(root / "AppRun", root / "extra-link"), + "escape": lambda root: (root / "escape").symlink_to("../../outside"), + "cycle": lambda root: (root / "cycle").symlink_to("cycle"), + } + for label, mutate in cases.items(): + with self.subTest(label=label): + source = bundle_fixture(self.root / label) + try: + mutate(source) + except OSError as error: + if label == "setid" and error.errno == errno.EPERM: + self.skipTest("sandbox refuses setuid fixture creation") + raise + if label == "setid" and not source.joinpath("AppRun").stat().st_mode & stat.S_ISUID: + self.skipTest("filesystem strips the setuid bit from fixture files") + with self.assertRaises(installer.InstallError): + installer.validate_bundle(source) + + def test_descriptor_rejects_identity_substitution_and_duplicate_json_keys(self): + value = json.loads((self.source / "bundle.json").read_text()) + for key, replacement in (("build", "0500"), ("source_sha", "a" * 39), + ("profile", "dev"), ("entrypoint", "../AppRun"), + ("version", "latest")): + with self.subTest(key=key): + changed = dict(value, **{key: replacement}) + with self.assertRaises(installer.InstallError): + installer.validate_descriptor(changed) + (self.source / "bundle.json").write_text('{"profile":"prod","profile":"dev"}') + with self.assertRaisesRegex(installer.InstallError, "duplicate"): + installer.read_json(self.source / "bundle.json") + + @unittest.skipUnless(shutil.which("tar"), "tar is required for the documented first-install path") + def test_documented_permission_preserving_extraction_under_umask_077(self): + archive = self.root / "download.tar.gz" + with tarfile.open(archive, "w:gz", format=tarfile.GNU_FORMAT, dereference=False) as target: + target.add(self.source, arcname=self.source.name) + extracted = self.root / "extracted" + extracted.mkdir() + subprocess.run(["/bin/sh", "-c", 'umask 077; exec tar -xzpf "$1" -C "$2"', + "documented-extraction", str(archive), str(extracted)], check=True) + source = extracted / "maple-agent.app" + self.assertEqual(stat.S_IMODE(source.stat().st_mode), 0o755) + self.assertEqual(stat.S_IMODE((source / "bundle.json").stat().st_mode), 0o644) + paths = installer.install(source, self.home) + self.assertTrue(paths["wrapper"].is_file()) + self.assertEqual(stat.S_IMODE((paths["bundle"] / "AppRun").stat().st_mode), 0o755) + + @unittest.skipUnless(sys.platform == "linux", "renameat2 is Linux-specific") + def test_real_atomic_no_replace_preserves_concurrent_destination(self): + source = self.root / "rename-source" + destination = self.root / "rename-destination" + source.mkdir() + destination.mkdir() + sentinel = destination / "owner" + sentinel.write_bytes(b"concurrent installation") + # Bypass only this fixture's portability adapter. + with mock.patch.object(installer, "rename_absent", self.real_rename): + with self.assertRaisesRegex(installer.InstallError, "already exists"): + installer.rename_absent(source, destination) + self.assertTrue(source.is_dir()) + self.assertEqual(sentinel.read_bytes(), b"concurrent installation") + + real_rename = staticmethod(installer.rename_absent) + + +if __name__ == "__main__": + unittest.main() diff --git a/apps/maple-agent/scripts/test_linux_release_bundle.py b/apps/maple-agent/scripts/test_linux_release_bundle.py new file mode 100644 index 000000000..c8091296b --- /dev/null +++ b/apps/maple-agent/scripts/test_linux_release_bundle.py @@ -0,0 +1,252 @@ +#!/usr/bin/env python3 +"""Synthetic, data-only Linux bundle archive boundary fixtures.""" + +import gzip +import importlib.util +import io +import json +import os +from pathlib import Path +import stat +import subprocess +import sys +import tarfile +import tempfile +import unittest +from unittest import mock + + +spec = importlib.util.spec_from_file_location("linux_release_bundle", Path(__file__).with_name("linux-release-bundle.py")) +bundle = importlib.util.module_from_spec(spec) +spec.loader.exec_module(bundle) + +ROOT = "maple-agent.app" +DESCRIPTOR = { + "format": "maple-agent-linux-bundle-v1", "product": "maple-agent", "profile": "prod", + "version": "0.0.4", "build": "42", "source_sha": "a" * 40, + "bundle_id": "cloud.opensecret.maple.agent", "entrypoint": "AppRun", + "executable": "usr/bin/maple-agent", +} + + +def entry(name, data=b"fixture data\n", mode=0o644, kind=tarfile.REGTYPE, target=""): + return name, data, mode, kind, target + + +def fixture_entries(descriptor=None): + """Explicit public layout; fake ELF bytes are never executable programs.""" + directories = (ROOT, ROOT + "/usr", ROOT + "/usr/bin", ROOT + "/usr/lib", + ROOT + "/usr/share", ROOT + "/usr/share/maple-agent") + return [*(entry(name, b"", 0o755, tarfile.DIRTYPE) for name in directories), + entry(ROOT + "/bundle.json", json.dumps(DESCRIPTOR if descriptor is None else descriptor).encode()), + entry(ROOT + "/AppRun", b"#!/bin/sh\nexit 71\n", 0o755), + entry(ROOT + "/install.sh", b"#!/bin/sh\nexit 72\n", 0o755), + entry(ROOT + "/usr/bin/maple-agent", b"\x7fELFsynthetic-agent", 0o755), + entry(ROOT + "/usr/lib/ld-linux-x86-64.so.2", b"\x7fELFsynthetic-loader", 0o755), + entry(ROOT + "/usr/lib/libc.so.6", b"\x7fELFsynthetic-libc", 0o755), + entry(ROOT + "/usr/share/maple-agent/install.py", b"raise SystemExit(73)\n"), + entry(ROOT + "/usr/lib/libc-alias.so", b"", 0o777, tarfile.SYMTYPE, "libc.so.6")] + + +class LinuxBundleArchiveTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.directory = Path(self.temporary.name) + self.archive = self.directory / "fixture.tar.gz" + + def write_archive(self, entries=None, archive_format=tarfile.GNU_FORMAT): + entries = fixture_entries() if entries is None else entries + with tarfile.open(self.archive, "w:gz", format=archive_format) as target: + for name, data, mode, kind, link in entries: + member = tarfile.TarInfo(name) + member.type, member.mode, member.linkname = kind, mode, link + member.size = len(data) if kind in (tarfile.REGTYPE, tarfile.GNUTYPE_LONGNAME, + tarfile.GNUTYPE_LONGLINK, tarfile.XHDTYPE, tarfile.XGLTYPE) else 0 + target.addfile(member, io.BytesIO(data) if member.size else None) + return self.archive + + def reject(self, entries, message=None): + self.write_archive(entries) + if message is None: + with self.assertRaises(bundle.install.InstallError): + bundle.inspect_archive(self.archive, "prod") + else: + with self.assertRaisesRegex(bundle.install.InstallError, message): + bundle.inspect_archive(self.archive, "prod") + + def test_valid_archive_extracts_exact_payload_and_modes_without_execution(self): + self.write_archive() + output = self.directory / "private-extraction" + previous = os.umask(0o077) + try: + with mock.patch.object(bundle.stage, "run", side_effect=AssertionError("artifact execution")): + descriptor, members = bundle.inspect_archive(self.archive, "prod") + root = bundle.extract_archive(self.archive, output, "prod") + finally: + os.umask(previous) + self.assertEqual(descriptor, DESCRIPTOR) + self.assertEqual(root, output / ROOT) + self.assertEqual(len(members), 14) + self.assertEqual(stat.S_IMODE(output.stat().st_mode), 0o700) + self.assertEqual(stat.S_IMODE(root.stat().st_mode), 0o755) + self.assertEqual((root / "usr/bin/maple-agent").read_bytes(), b"\x7fELFsynthetic-agent") + self.assertEqual(stat.S_IMODE((root / "AppRun").stat().st_mode), 0o755) + self.assertEqual(stat.S_IMODE((root / "bundle.json").stat().st_mode), 0o644) + self.assertEqual((root / "usr/lib/libc-alias.so").readlink(), Path("libc.so.6")) + self.assertEqual((root / "usr/lib/libc-alias.so").read_bytes(), b"\x7fELFsynthetic-libc") + + def test_extraction_refuses_existing_destination_without_changes(self): + self.write_archive() + output = self.directory / "existing" + output.mkdir() + marker = output / "keep" + marker.write_bytes(b"unchanged") + with self.assertRaisesRegex(bundle.install.InstallError, "must be absent"): + bundle.extract_archive(self.archive, output, "prod") + self.assertEqual(marker.read_bytes(), b"unchanged") + self.assertEqual(list(output.iterdir()), [marker]) + + def test_accepts_dev_identity_only_under_its_distinct_root(self): + descriptor = {**DESCRIPTOR, "profile": "dev", "bundle_id": "cloud.opensecret.maple.agent.dev"} + entries = [(name.replace(ROOT, "maple-agent-dev.app", 1), data, mode, kind, link) + for name, data, mode, kind, link in fixture_entries(descriptor)] + self.write_archive(entries) + actual, members = bundle.inspect_archive(self.archive, "dev") + self.assertEqual(actual, descriptor) + self.assertIn("maple-agent-dev.app/AppRun", members) + with self.assertRaisesRegex(bundle.install.InstallError, "profile"): + bundle.inspect_archive(self.archive, "prod") + + def test_rejects_escaping_and_noncanonical_member_names(self): + for name in ("/absolute", "../outside", ROOT + "/../outside", ROOT + "//double", + ROOT + "/./dot", ROOT + "/back\\slash", ROOT + "/line\nfeed"): + with self.subTest(name=name): + self.reject(fixture_entries() + [entry(name)]) + + def test_rejects_duplicate_multiple_or_wrong_roots_and_wrong_profile(self): + self.reject(fixture_entries() + [entry(ROOT + "/bundle.json", b"{}")], "duplicate") + self.reject(fixture_entries() + [entry("other.app", b"", 0o755, tarfile.DIRTYPE)], "roots") + renamed = [(name.replace(ROOT, "other.app", 1), data, mode, kind, link) + for name, data, mode, kind, link in fixture_entries()] + self.reject(renamed, "roots") + self.write_archive() + with self.assertRaisesRegex(bundle.install.InstallError, "profile"): + bundle.inspect_archive(self.archive, "dev") + + def test_rejects_missing_payload_and_invalid_descriptor_identity(self): + self.reject([item for item in fixture_entries() if item[0] != ROOT + "/usr/bin/maple-agent"], "required") + self.reject([item for item in fixture_entries() if item[0] != ROOT + "/usr/bin/maple-agent"] + [ + entry(ROOT + "/usr/bin/maple-agent", b"", 0o777, tarfile.SYMTYPE, "../lib/libc.so.6")], "required") + for field, value in (("product", "research"), ("format", "unknown"), + ("bundle_id", "cloud.opensecret.maple.agent.dev"), + ("entrypoint", "other"), ("executable", "../outside"), + ("build", "0"), ("source_sha", "a" * 8)): + with self.subTest(field=field): + self.reject(fixture_entries({**DESCRIPTOR, field: value})) + self.reject(fixture_entries({**DESCRIPTOR, "extra": "not public"}), "fields") + duplicate = (json.dumps(DESCRIPTOR)[:-1] + ', "product": "maple-agent"}').encode() + self.reject([(name, duplicate if name == ROOT + "/bundle.json" else data, mode, kind, link) + for name, data, mode, kind, link in fixture_entries()], "duplicate") + + def test_rejects_escaping_dangling_cyclic_and_parent_symlinks(self): + for target in ("/outside", "../../../outside", "missing.so"): + with self.subTest(target=target): + self.reject(fixture_entries() + [entry(ROOT + "/usr/lib/unsafe", b"", 0o777, + tarfile.SYMTYPE, target)]) + self.reject(fixture_entries() + [ + entry(ROOT + "/usr/lib/first", b"", 0o777, tarfile.SYMTYPE, "second"), + entry(ROOT + "/usr/lib/second", b"", 0o777, tarfile.SYMTYPE, "first")], "cycle") + self.reject(fixture_entries() + [ + entry(ROOT + "/usr/alias", b"", 0o777, tarfile.SYMTYPE, "lib"), + entry(ROOT + "/usr/alias/child")], "through a symlink") + + def test_rejects_hardlinks_and_special_files(self): + for kind in (tarfile.LNKTYPE, tarfile.FIFOTYPE, tarfile.CHRTYPE, tarfile.BLKTYPE): + with self.subTest(kind=kind): + self.reject(fixture_entries() + [entry(ROOT + "/special", b"", 0o644, kind, + ROOT + "/bundle.json")], "unsupported") + + def test_rejects_unsafe_stored_modes(self): + for name, mode in ((ROOT, 0o700), (ROOT, 0o775), (ROOT + "/bundle.json", 0o666), + (ROOT + "/AppRun", 0o750), (ROOT + "/usr/bin/maple-agent", 0o4755)): + with self.subTest(name=name, mode=oct(mode)): + entries = [(path, data, mode if path == name else stored, kind, link) + for path, data, stored, kind, link in fixture_entries()] + self.reject(entries, "permissions") + + def test_rejects_oversized_descriptor_before_json_parse(self): + entries = [(name, b" " * (16 * 1024 + 1) if name == ROOT + "/bundle.json" else data, + mode, kind, link) for name, data, mode, kind, link in fixture_entries()] + self.reject(entries, "oversized bundle descriptor") + + def test_bounds_raw_gnu_and_pax_extension_headers(self): + for kind in (tarfile.GNUTYPE_LONGNAME, tarfile.GNUTYPE_LONGLINK, tarfile.XHDTYPE, tarfile.XGLTYPE): + with self.subTest(kind=kind): + oversized = entry("././@LongLink", b"x" * (16 * 1024 + 1), 0o644, kind) + self.reject([oversized] + fixture_entries(), "oversized tar extension") + + def test_accepts_bounded_long_names_from_gnu_and_pax_archives(self): + long_path = ROOT + "/usr/share/" + "long-" * 30 + "notice.txt" + for archive_format in (tarfile.GNU_FORMAT, tarfile.PAX_FORMAT): + with self.subTest(archive_format=archive_format): + self.write_archive(fixture_entries() + [entry(long_path, b"long notice\n")], archive_format) + descriptor, members = bundle.inspect_archive(self.archive, "prod") + self.assertEqual(descriptor["profile"], "prod") + self.assertIn(long_path, members) + + def test_rejects_content_after_tar_end_markers(self): + self.write_archive() + raw = gzip.decompress(self.archive.read_bytes()) + self.archive.write_bytes(gzip.compress(raw + b"untrusted trailing content")) + with self.assertRaisesRegex(bundle.install.InstallError, "trailing tar content"): + bundle.inspect_archive(self.archive, "prod") + + +class LinuxBundleLauncherTests(unittest.TestCase): + def test_clears_same_appdir_image_state_and_preserves_host_overrides(self): + with tempfile.TemporaryDirectory() as temporary: + app = Path(temporary).resolve() / "maple-agent.app" + library = app / "usr/lib" + library.mkdir(parents=True) + (app / "usr/bin").mkdir() + (app / "usr/bin/maple-agent").write_text("not executed by the fixture loader") + launcher = app / "AppRun" + launcher.write_text(bundle.bundle_apprun()) + launcher.chmod(0o755) + loader = library / "ld-linux-x86-64.so.2" + loader.write_text(f"#!{sys.executable}\nimport json, os, sys\n" + "print(json.dumps({'env': dict(os.environ), 'argv': sys.argv[1:]}))\n") + loader.chmod(0o755) + custom = {"FONTCONFIG_FILE": "/custom/fonts.conf", "FONTCONFIG_PATH": "/custom/fonts", + "ALSA_CONFIG_PATH": "/custom/alsa.conf", "ALSA_CONFIG_DIR": "/custom/alsa", + "ALSA_PLUGIN_DIR": "/custom/alsa-lib", "PULSE_SERVER": "unix:/custom/pulse", + "PIPEWIRE_REMOTE": "fixture-pipewire", "VK_DRIVER_FILES": "/custom/vulkan.json"} + for locale_override in ("/custom/X11/locale", "", None): + with self.subTest(XLOCALEDIR=locale_override): + # Keep the pinned shell's utilities available inside Nix sandboxes. + environment = {"PATH": os.environ["PATH"], "APPDIR": str(app), + "APPIMAGE": "/stale/outer.AppImage", "APPIMAGE_EXTRACT_AND_RUN": "1", **custom} + if locale_override is not None: + environment["XLOCALEDIR"] = locale_override + result = subprocess.run([str(launcher), "--build-info"], env=environment, + capture_output=True, text=True, timeout=10) + self.assertEqual(result.returncode, 0, result.stderr) + observed = json.loads(result.stdout) + self.assertEqual(observed["env"]["APPDIR"], str(app)) + self.assertNotIn("APPIMAGE", observed["env"]) + self.assertNotIn("APPIMAGE_EXTRACT_AND_RUN", observed["env"]) + for key, value in custom.items(): + self.assertEqual(observed["env"][key], value, key) + if locale_override is not None: + self.assertEqual(observed["env"]["XLOCALEDIR"], locale_override) + elif os.access("/usr/share/X11/locale/compose.dir", os.R_OK): + self.assertEqual(observed["env"]["XLOCALEDIR"], "/usr/share/X11/locale") + else: + self.assertNotIn("XLOCALEDIR", observed["env"]) + self.assertEqual(observed["argv"][:2], ["--inhibit-cache", "--library-path"]) + self.assertEqual(observed["argv"][-2:], [str(app / "usr/bin/maple-agent"), "--build-info"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/apps/maple-agent/scripts/test_macos_build_info.py b/apps/maple-agent/scripts/test_macos_build_info.py index a6a643cf7..9ab5eca31 100644 --- a/apps/maple-agent/scripts/test_macos_build_info.py +++ b/apps/maple-agent/scripts/test_macos_build_info.py @@ -17,12 +17,16 @@ spec = importlib.util.spec_from_file_location("macos_build_info", Path(__file__).with_name("macos-build-info.py")) metadata = importlib.util.module_from_spec(spec) spec.loader.exec_module(metadata) +spec = importlib.util.spec_from_file_location("release_info", Path(__file__).with_name("release-info.py")) +release_info = importlib.util.module_from_spec(spec) +spec.loader.exec_module(release_info) SOURCE_SHA = "a" * 40 def public_info(profile="dev", source_sha=SOURCE_SHA): profiles = json.loads(Path(__file__).resolve().parent.parent.joinpath("release-profiles.json").read_text()) - return dict(profiles[profile], profile=profile, version="0.1.0", git_revision=source_sha[:8], source_sha=source_sha) + public = {field: profiles[profile][field] for field in release_info.PUBLIC_FIELDS} + return dict(public, profile=profile, version="0.1.0", git_revision=source_sha[:8], source_sha=source_sha) def macho(payload, *, cpu=metadata.CPU_TYPE_ARM64, subtype=0, kind=metadata.MH_EXECUTE, duplicate=False, owner=b"__TEXT"): diff --git a/apps/maple-agent/scripts/test_release_packaging.py b/apps/maple-agent/scripts/test_release_packaging.py index bed773cb5..4b36f5e31 100644 --- a/apps/maple-agent/scripts/test_release_packaging.py +++ b/apps/maple-agent/scripts/test_release_packaging.py @@ -2,6 +2,7 @@ """Hermetic identity, artifact-integrity and native-signing boundary tests.""" import base64 +import hashlib import importlib.util import io import json @@ -26,6 +27,11 @@ spec.loader.exec_module(release_info) +def profile_info(profile): + profiles = json.loads((SCRIPTS.parent / "release-profiles.json").read_text()) + return {field: profiles[profile][field] for field in release_info.PUBLIC_FIELDS} + + class NativeArchiveMetadataTests(unittest.TestCase): @unittest.skipUnless(sys.platform == "darwin", "requires native macOS BSDtar metadata") def test_native_release_tar_omits_host_metadata_and_preserves_payload(self): @@ -101,7 +107,7 @@ def setUp(self): self.git("-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid", "-c", "commit.gpgsign=false", "commit", "--quiet", "-m", "fixture source") self.source = self.git("rev-parse", "HEAD").stdout.strip() - profile = json.loads((self.component / "release-profiles.json").read_text())["dev"] + profile = profile_info("dev") self.info = {**profile, "profile": "dev", "version": "0.1.0", "source_sha": self.source, "git_revision": self.source[:8]} self.metadata = self.component / "fixture-build-info.json" @@ -175,13 +181,12 @@ def setUp(self): for name in ("verify-release.sh", "release-info.py"): shutil.copy2(SCRIPTS / name, scripts / name) shutil.copy2(SCRIPTS.parent / "release-profiles.json", self.component) - profiles = json.loads((self.component / "release-profiles.json").read_text()) - self.info = {**profiles["dev"], "profile": "dev", "version": "0.1.0", + self.info = {**profile_info("dev"), "profile": "dev", "version": "0.1.0", "source_sha": "a" * 40, "git_revision": "a" * 8} self.artifacts = self.root / "artifacts" self.artifacts.mkdir() (self.artifacts / "build-info.json").write_text(json.dumps(self.info) + "\n") - launcher = f"""#!{sys.executable} + self.launcher = f"""#!{sys.executable} import sys if sys.argv[1:] == ["--build-info"]: print({json.dumps(self.info)!r}) @@ -190,35 +195,28 @@ def setUp(self): else: raise SystemExit("unexpected fixture launcher arguments") """ - self.image = self.artifacts / "fixture.AppImage" - self.image.write_text(f"""#!{sys.executable} -import os -from pathlib import Path -import sys -assert sys.argv[1:] == ["--appimage-extract"] -appdir = Path("squashfs-root") -appdir.mkdir(mode=0o755) -for name, content, mode in (("AppRun", {launcher!r}, int(os.environ["FIXTURE_EXEC_MODE"], 8)), - ("resource.txt", "fixture data", int(os.environ["FIXTURE_DATA_MODE"], 8))): - descriptor = os.open(appdir / name, os.O_WRONLY | os.O_CREAT | os.O_EXCL, mode) - with os.fdopen(descriptor, "w") as output: - output.write(content) -""") - release_info.write_manifest(self.info, self.artifacts, "linux-x86_64", True) + self.archive = self.artifacts / release_info.linux_package_name(self.info) # Substitute only the audit/runtime closure and host identity; the real # verifier still checks the manifest, checksums, profile and source. - (scripts / "linux-release-appimage.py").write_text("""import os + (scripts / "linux-release-bundle.py").write_text("""import importlib.util from pathlib import Path import sys -assert sys.argv[1] == "audit" -appdir = Path(sys.argv[2]) +if sys.argv[1] == "extract": + assert sys.argv[4:] == ["--profile", "dev"] + spec = importlib.util.spec_from_file_location("release_info", Path(__file__).with_name("release-info.py")) + release_info = importlib.util.module_from_spec(spec) + spec.loader.exec_module(release_info) + appdir = release_info.extract_app_archive(sys.argv[2], sys.argv[3], "maple-agent-dev") +else: + assert sys.argv[1] == "audit", sys.argv + appdir = Path(sys.argv[2]) assert appdir.stat().st_mode & 0o777 == 0o755 assert (appdir / "AppRun").stat().st_mode & 0o7777 == 0o755 assert (appdir / "resource.txt").stat().st_mode & 0o7777 == 0o644 private = appdir.parent / "private-after-extraction" private.write_text("fixture private metadata") assert private.stat().st_mode & 0o777 == 0o600, "extraction changed the parent's private umask" -assert appdir.parent.stat().st_mode & 0o777 == 0o700 +assert appdir.parent.stat().st_mode & 0o777 == 0o700, "extraction parent must remain private" """) commands = self.root / "commands" commands.mkdir() @@ -232,9 +230,29 @@ def setUp(self): self.env = dict(os.environ, PATH=str(commands) + os.pathsep + os.environ["PATH"], TMPDIR=str(self.root), FIXTURE_EXEC_MODE="755", FIXTURE_DATA_MODE="644") + def write_archive(self): + root = "maple-agent-dev.app" + members = [(root, None, 0o755), + (root + "/AppRun", self.launcher, int(self.env["FIXTURE_EXEC_MODE"], 8)), + (root + "/resource.txt", "fixture data", int(self.env["FIXTURE_DATA_MODE"], 8))] + with tarfile.open(self.archive, "w:gz") as archive: + for name, content, mode in members: + member = tarfile.TarInfo(name) + member.mode = mode + if content is None: + member.type = tarfile.DIRTYPE + archive.addfile(member) + else: + data = content.encode() + member.size = len(data) + archive.addfile(member, io.BytesIO(data)) + release_info.write_manifest(self.info, self.artifacts, "linux-x86_64", True) + def verify(self): + self.write_archive() result = subprocess.run(["bash", str(self.component / "scripts/verify-release.sh"), "dev", - str(self.artifacts), "--unsigned"], env=self.env, capture_output=True, text=True) + str(self.artifacts), "--unsigned", "--platform", "linux-x86_64"], + env=self.env, capture_output=True, text=True) self.assertFalse(list(self.root.glob("maple-agent-verify.*")), "verifier private files must be cleaned") return result @@ -269,8 +287,7 @@ def setUp(self): entitlements = self.component / "app/packaging/macos-entitlements.plist" entitlements.parent.mkdir(parents=True) shutil.copy2(SCRIPTS.parent / "app/packaging/macos-entitlements.plist", entitlements) - profiles = json.loads((self.component / "release-profiles.json").read_text()) - self.info = {**profiles["dev"], "profile": "dev", "version": "0.1.0", + self.info = {**profile_info("dev"), "profile": "dev", "version": "0.1.0", "source_sha": "a" * 40, "git_revision": "a" * 8} self.bundle = self.root / f"{self.info['display_name']}.app" binary = self.bundle / "Contents/MacOS/maple-agent" @@ -359,9 +376,8 @@ def setUp(self): self.temp = tempfile.TemporaryDirectory() self.addCleanup(self.temp.cleanup) self.directory = Path(self.temp.name) - profiles = json.loads((SCRIPTS.parent / "release-profiles.json").read_text()) self.info = { - **profiles["dev"], "profile": "dev", "version": "0.1.0", + **profile_info("dev"), "profile": "dev", "version": "0.1.0", "source_sha": "a" * 40, "git_revision": "a" * 8, } self.path = self.directory / "build-info.json" @@ -411,7 +427,7 @@ def test_update_feed_and_keys_cannot_drift_from_the_packaged_profile(self): self.info[field] = original def test_prod_metadata_requires_the_configured_primary_and_backup_pins(self): - prod = json.loads((SCRIPTS.parent / "release-profiles.json").read_text())["prod"] + prod = profile_info("prod") self.info = {**prod, "profile": "prod", "version": "0.0.1", "source_sha": "a" * 40, "git_revision": "a" * 8} self.write_info() @@ -423,6 +439,26 @@ def test_prod_metadata_requires_the_configured_primary_and_backup_pins(self): with self.subTest(keys=keys), self.assertRaisesRegex(ValueError, "release profile"): release_info.read_info("prod", self.path) + 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") + def test_backup_requires_a_distinct_public_key_not_a_changed_comment_or_id(self): def key(point, key_id=b"12345678", comment="fixture"): packet = base64.b64encode(b"Ed" + key_id + point).decode() @@ -489,6 +525,113 @@ def test_unchecked_files_symlinks_and_checksum_traversal_are_rejected(self): release_info.verify_artifacts("dev", self.directory, True) +class LinuxReleaseMetadataTests(unittest.TestCase): + setUp = ReleaseMetadataTests.setUp + write_info = ReleaseMetadataTests.write_info + + def package(self): + package = self.directory / release_info.linux_package_name(self.info) + package.write_bytes(b"compressed Linux bundle fixture") + release_info.write_manifest(self.info, self.directory, "linux-x86_64", True) + return package + + def write_checksums(self): + (self.directory / "SHA256SUMS").write_text("".join( + f"{hashlib.sha256(path.read_bytes()).hexdigest()} {path.name}\n" + for path in sorted(self.directory.iterdir()) if path.name != "SHA256SUMS" + )) + + def test_linux_package_records_checksum_policy_and_exact_public_inventory(self): + package = self.package() + manifest = release_info.verify_artifacts("dev", self.directory, True, platform="linux-x86_64") + self.assertEqual(manifest["schema_version"], 1) + 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({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())) + with self.assertRaisesRegex(ValueError, "requested platform"): + release_info.verify_artifacts("dev", self.directory, True, platform="macos-aarch64") + + def test_linux_package_name_must_match_profile_version_and_source_revision(self): + expected = release_info.linux_package_name(self.info) + for name in ("fixture.AppImage", "fixture.tar.gz", expected.replace("-dev-", "-prod-"), + expected.replace("-0.1.0-", "-0.2.0-"), expected.replace("-aaaaaaaa-", "-bbbbbbbb-")): + with self.subTest(name=name): + package = self.directory / name + package.write_bytes(b"wrong package") + with self.assertRaises(ValueError): + release_info.write_manifest(self.info, self.directory, "linux-x86_64", True) + package.unlink() + + def test_linux_extra_files_fail_even_with_valid_checksums(self): + self.package() + extra = self.directory / "extra.txt" + extra.write_text("checked but not public release inventory") + self.write_checksums() + with self.assertRaisesRegex(ValueError, "exactly the expected bundle"): + release_info.verify_artifacts("dev", self.directory, True) + with self.assertRaises(ValueError): + release_info.write_manifest(self.info, self.directory, "linux-x86_64", True) + + def test_downloaded_renamed_linux_package_is_rejected_with_consistent_manifest_and_checksums(self): + package = self.package() + package.rename(self.directory / "fixture.tar.gz") + path = self.directory / "manifest.json" + manifest = json.loads(path.read_text()) + path.write_text(json.dumps({**manifest, "package": "fixture.tar.gz"})) + self.write_checksums() + with self.assertRaisesRegex(ValueError, "exactly the expected bundle"): + release_info.verify_artifacts("dev", self.directory, True) + + def test_linux_prod_artifact_validates_the_platform_specific_feed(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, + "update_feed_url": profiles["prod"]["linux_update_feed_url"]} + self.write_info() + self.package() + release_info.verify_artifacts("prod", self.directory, True, platform="linux-x86_64") + self.info["update_feed_url"] = profiles["prod"]["update_feed_url"] + self.write_info() + self.write_checksums() + with self.assertRaisesRegex(ValueError, "update_feed_url"): + release_info.verify_artifacts("prod", self.directory, True, platform="linux-x86_64") + + def test_linux_integrity_and_signing_policy_cannot_be_relabelled(self): + self.package() + path = self.directory / "manifest.json" + original = json.loads(path.read_text()) + for field, value in (("macos_signing", "developer-id-notarized"), + ("linux_integrity", "signed"), ("linux_integrity", None)): + with self.subTest(field=field, value=value): + path.write_text(json.dumps({**original, field: value})) + self.write_checksums() + with self.assertRaisesRegex(ValueError, "Linux integrity"): + release_info.verify_artifacts("dev", self.directory, True) + path.write_text(json.dumps({key: value for key, value in original.items() if key != "macos_signing"})) + self.write_checksums() + with self.assertRaisesRegex(ValueError, "Linux integrity"): + release_info.verify_artifacts("dev", self.directory, True) + + def test_linux_symlink_or_directory_cannot_replace_public_metadata(self): + self.package() + for name in ("build-info.json", "manifest.json", "SHA256SUMS"): + with self.subTest(name=name): + path = self.directory / name + original = path.read_bytes() + path.unlink() + path.symlink_to(self.directory / "outside") + with self.assertRaisesRegex(ValueError, "symlink"): + release_info.verify_artifacts("dev", self.directory, True) + path.unlink() + path.write_bytes(original) + (self.directory / "unexpected-directory").mkdir() + with self.assertRaisesRegex(ValueError, "unsupported file"): + release_info.verify_artifacts("dev", self.directory, True) + + class AppArchiveTests(unittest.TestCase): def setUp(self): self.temp = tempfile.TemporaryDirectory() diff --git a/apps/maple-agent/scripts/test_smoke_linux_bundle_host.py b/apps/maple-agent/scripts/test_smoke_linux_bundle_host.py new file mode 100644 index 000000000..cb606d2d1 --- /dev/null +++ b/apps/maple-agent/scripts/test_smoke_linux_bundle_host.py @@ -0,0 +1,242 @@ +#!/usr/bin/env python3 +"""Process-level fixtures for the isolated Linux bundle AppRun host smoke.""" + +import json +import os +from pathlib import Path +import shutil +import signal +import subprocess +import sys +import tempfile +import time +import unittest + + +SMOKE = Path(__file__).with_name("smoke-linux-bundle-host.sh") + + +FIXTURE = r'''import fcntl, hashlib, json, os, pathlib, socket, subprocess, sys, threading, time +record = pathlib.Path(RECORD) +behavior = BEHAVIOR +mode = sys.argv[1:] +if mode == ["--build-info"]: + if behavior == "slow-identity-timeout": + time.sleep(2) + record.write_text(json.dumps({"environment": dict(os.environ), "scratch": os.getcwd()})) + print(json.dumps({"data_namespace": "maple-agent-dev"})) + sys.exit(0) +namespace = "maple-agent-dev" +store = pathlib.Path(os.environ["XDG_DATA_HOME"]) / namespace / "store-v1" +storage = [str(store.resolve()), namespace, str(os.geteuid())] +identity = hashlib.sha256(json.dumps(storage, separators=(",", ":")).encode()).hexdigest()[:24] +endpoint = pathlib.Path(f"/tmp/maple-agent-{os.geteuid()}") / identity +address = endpoint / "ipc.sock" +if mode == ["host", "serve"]: + store.mkdir(mode=0o700, parents=True, exist_ok=True) + endpoint.parent.mkdir(mode=0o700, exist_ok=True) + endpoint.mkdir(mode=0o700, exist_ok=True) + with (store / "owner.lock").open("a+") as lock: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + server = socket.socket(socket.AF_UNIX) + server.bind(str(address)) + server.listen() + record.with_suffix(".host").write_text(str(os.getpid())) + record.with_suffix(".endpoint").write_text(str(endpoint)) + while True: + connection, _ = server.accept() + message = connection.recv(16) + if message == b"stop": + connection.close() + if behavior == "refuse-stop": + continue + break + def drain(connection): + while connection.recv(1024): + pass + connection.close() + threading.Thread(target=drain, args=(connection,), daemon=True).start() + server.close() + address.unlink() + record.with_suffix(".stopped").write_text("stopped") +elif mode == ["host", "stop"]: + connection = socket.socket(socket.AF_UNIX) + try: + connection.connect(str(address)) + except FileNotFoundError: + sys.exit(1) + connection.sendall(b"stop") + connection.close() + deadline = time.monotonic() + 5 + while not record.with_suffix(".stopped").exists() and time.monotonic() < deadline: + time.sleep(0.01) +elif mode == ["acp"]: + if behavior in ("timeout", "slow-identity-timeout"): + time.sleep(30) + if not address.exists(): + subprocess.Popen([sys.executable, __file__, "host", "serve"], + stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, start_new_session=True) + deadline = time.monotonic() + 5 + while not address.exists() and time.monotonic() < deadline: + time.sleep(0.01) + connection = socket.socket(socket.AF_UNIX) + connection.connect(str(address)) + connection.sendall(b"acp") + with record.with_suffix(".clients").open("a") as clients: + clients.write("client\n") + connection.close() + if behavior == "reject": + print("private-fixture-value", file=sys.stderr, flush=True) + elif behavior == "diagnostics": + log = store / "logs/maple-agent.log" + log.parent.mkdir() + log.write_text("fixture-log-start\n" + "fixture-log-line\n" * 2000 + "fixture-log-end\n") + print("\x1b[31m" + "fixture-error-line\n" * 2000 + "\x1b[0m", file=sys.stderr, flush=True) + elif behavior == "store-setup": + print("Maple needs local store setup. Open Maple to continue.", + file=sys.stderr, flush=True) + else: + print("Maple ACP requires sign-in. Sign in through Maple Desktop or `maple-agent login` first.", + file=sys.stderr, flush=True) + sys.exit(1) +else: + sys.exit(2) +''' + + +class LinuxBundleHostSmokeTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + self.fixture = self.root / "AppRun" + self.record = self.root / "process.json" + self.tools = self.root / "tools" + self.tools.mkdir() + # macOS fixture runs use Python's flock with the Linux CLI contract; + # the network-disabled Ubuntu verifier has util-linux flock already. + flock = self.tools / "flock" + flock.write_text(f'''#!{sys.executable} +import fcntl, os, sys +with open(sys.argv[2], "a+") as lock: + try: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + sys.exit(1) +''') + flock.chmod(0o755) + + def run_smoke(self, behavior="success", environment=None, timeout="10"): + self.fixture.write_text( + f"#!{sys.executable}\nRECORD = {str(self.record)!r}\nBEHAVIOR = {behavior!r}\n" + FIXTURE + ) + self.fixture.chmod(0o755) + parent = dict(os.environ) if environment is None else environment.copy() + parent.update(PATH=f"{self.tools}{os.pathsep}{os.environ['PATH']}", MAPLE_AGENT_SMOKE_TIMEOUT=timeout) + return subprocess.run(["/bin/sh", str(SMOKE), str(self.fixture)], env=parent, + capture_output=True, text=True, timeout=20, check=False) + + def assert_cleaned(self, host_started=True): + state = json.loads(self.record.read_text()) + self.assertFalse(Path(state["scratch"]).exists()) + if host_started: + self.assertTrue(self.record.with_suffix(".stopped").exists()) + return state + + def test_sign_in_guard_twice_reuses_host_stops_and_isolates_credentials(self): + parent = dict(os.environ, APPIMAGE="/unrelated.AppImage", APPDIR="/unrelated", + APPIMAGE_EXTRACT_AND_RUN="1", NO_CLEANUP="1", HOME="/existing-home", + GITHUB_TOKEN="fixture-secret", BWS_ACCESS_TOKEN="fixture-secret", + MAPLE_API_KEY="fixture-secret", LD_PRELOAD="fixture-injection") + result = self.run_smoke(environment=parent) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("releases ownership", result.stdout) + self.assertIn("signed-out ACP guard", result.stdout) + self.assertEqual(self.record.with_suffix(".clients").read_text().splitlines(), ["client", "client"]) + environment = self.assert_cleaned()["environment"] + for key in ("APPIMAGE", "APPDIR", "APPIMAGE_EXTRACT_AND_RUN", "NO_CLEANUP", + "GITHUB_TOKEN", "BWS_ACCESS_TOKEN", "MAPLE_API_KEY", "LD_PRELOAD"): + self.assertNotIn(key, environment) + for key in ("HOME", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME", "TMPDIR"): + self.assertTrue(Path(environment[key]).is_relative_to(environment["HOME"].rsplit("/", 1)[0])) + + def test_store_setup_guard_also_requires_the_same_host_and_clean_shutdown(self): + result = self.run_smoke("store-setup") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.record.with_suffix(".clients").read_text().splitlines(), ["client", "client"]) + self.assert_cleaned() + + def test_unexpected_guard_stops_only_the_fixture_host_and_hides_output(self): + result = self.run_smoke("reject") + self.assertNotEqual(result.returncode, 0) + self.assertIn("did not return an expected signed-out guard", result.stderr) + self.assertNotIn("private-fixture-value", result.stderr) + self.assert_cleaned() + + def test_opt_in_diagnostics_are_bounded_and_remove_terminal_controls(self): + parent = dict(os.environ, MAPLE_AGENT_SMOKE_DIAGNOSTICS="1", GITHUB_TOKEN="fixture-secret") + result = self.run_smoke("diagnostics", environment=parent) + self.assertNotEqual(result.returncode, 0) + self.assertIn("ACP exit status: 1", result.stderr) + self.assertIn("fixture-log-end", result.stderr) + self.assertNotIn("fixture-log-start", result.stderr) + self.assertNotIn("\x1b", result.stderr) + self.assertNotIn("fixture-secret", result.stderr) + self.assertLess(len(result.stderr), 17000) + self.assert_cleaned() + + def test_client_timeout_is_bounded_and_does_not_stop_unrelated_process(self): + unrelated = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(30)"]) + try: + result = self.run_smoke("timeout", timeout="1") + self.assertNotEqual(result.returncode, 0) + self.assertIn("signed-out ACP probe timed out", result.stderr) + self.assertIsNone(unrelated.poll()) + self.assert_cleaned(host_started=False) + finally: + unrelated.terminate() + unrelated.wait(timeout=5) + + def test_cold_identity_discovery_does_not_use_the_short_client_timeout(self): + result = self.run_smoke("slow-identity-timeout", timeout="1") + self.assertNotEqual(result.returncode, 0) + self.assertIn("signed-out ACP probe timed out", result.stderr) + self.assert_cleaned(host_started=False) + + def test_failed_host_stop_retains_state_and_cannot_claim_success(self): + try: + result = self.run_smoke("refuse-stop", timeout="2") + self.assertNotEqual(result.returncode, 0) + self.assertIn("probe state was retained", result.stderr) + self.assertNotIn("Verified", result.stdout) + state = json.loads(self.record.read_text()) + self.assertTrue(Path(state["scratch"]).exists()) + finally: + # This fixture deliberately refuses its normal shutdown command. + # Stop only its recorded PID, then remove its retained test paths. + host_record = self.record.with_suffix(".host") + if host_record.exists(): + os.kill(int(host_record.read_text()), signal.SIGTERM) + endpoint = Path(self.record.with_suffix(".endpoint").read_text()) + import fcntl + state = json.loads(self.record.read_text()) + owner = Path(state["environment"]["XDG_DATA_HOME"]) / "maple-agent-dev/store-v1/owner.lock" + with owner.open("r+") as lock: + deadline = time.monotonic() + 5 + while True: + try: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + break + except BlockingIOError: + if time.monotonic() >= deadline: + raise + time.sleep(0.01) + shutil.rmtree(endpoint) + if self.record.exists(): + state = json.loads(self.record.read_text()) + shutil.rmtree(state["scratch"], ignore_errors=True) + + +if __name__ == "__main__": + unittest.main() diff --git a/apps/maple-agent/scripts/test_smoke_macos_release.py b/apps/maple-agent/scripts/test_smoke_macos_release.py index c905e995a..3f9f15bdf 100644 --- a/apps/maple-agent/scripts/test_smoke_macos_release.py +++ b/apps/maple-agent/scripts/test_smoke_macos_release.py @@ -39,6 +39,8 @@ def setUp(self): }, target) def fixture(self, behavior): + # Timeout fixtures allow cold sandbox Python initialization before + # cleanup assertions depend on the child's PID record and signal setup. program = '''import json, os, pathlib, signal, sys, time record = pathlib.Path(sys.argv[1]) record.write_text(json.dumps({"pid": os.getpid(), "environment": dict(os.environ), "cwd": os.getcwd()})) @@ -78,7 +80,7 @@ def test_missing_render_times_out_and_cleans_only_the_launched_process(self): time.sleep(30) ''') with self.assertRaisesRegex(smoke.SmokeError, "startup timeout"): - smoke.observe_gui(command, timeout=0.4, settle=0.1) + smoke.observe_gui(command, timeout=5, settle=0.1) self.assert_cleaned() self.assertIsNone(unrelated.poll()) @@ -154,7 +156,7 @@ def test_timeout_reaps_a_process_which_ignores_sigterm(self): time.sleep(30) ''') with self.assertRaisesRegex(smoke.SmokeError, "startup timeout"): - smoke.observe_gui(command, timeout=0.4, settle=0.1) + smoke.observe_gui(command, timeout=5, settle=0.1) self.assert_cleaned() def test_regular_agent_bundle_and_non_agent_or_symlink_rejection(self): diff --git a/apps/maple-agent/scripts/verify-release.sh b/apps/maple-agent/scripts/verify-release.sh index 92d0bf90a..1483a0515 100755 --- a/apps/maple-agent/scripts/verify-release.sh +++ b/apps/maple-agent/scripts/verify-release.sh @@ -3,27 +3,33 @@ set +x set -euo pipefail umask 077 -if [[ $# -lt 2 || $# -gt 4 || ( "$1" != dev && "$1" != prod ) ]]; then - echo "usage: verify-release.sh dev|prod ARTIFACT_DIRECTORY [--unsigned] [--static]" >&2 +if [[ $# -lt 2 || ( "$1" != dev && "$1" != prod ) ]]; then + echo "usage: verify-release.sh dev|prod ARTIFACT_DIRECTORY [--unsigned] [--static] [--platform PLATFORM]" >&2 exit 2 fi profile="$1" directory="$(cd "$2" && pwd)" unsigned=() static=false +platform_options=() shift 2 -for option in "$@"; do - case "$option" in - --unsigned) [[ ${#unsigned[@]} == 0 ]] || exit 2; unsigned=(--unsigned) ;; - --static) [[ "$static" == false ]] || exit 2; static=true ;; - *) echo "unknown verification option: $option" >&2; exit 2 ;; +while [[ $# -gt 0 ]]; do + case "$1" in + --unsigned) [[ ${#unsigned[@]} == 0 ]] || exit 2; unsigned=(--unsigned); shift ;; + --static) [[ "$static" == false ]] || exit 2; static=true; shift ;; + --platform) + [[ ${#platform_options[@]} == 0 && $# -ge 2 && ( "$2" == macos-aarch64 || "$2" == linux-x86_64 ) ]] || exit 2 + platform_options=(--platform "$2") + shift 2 + ;; + *) echo "unknown verification option: $1" >&2; exit 2 ;; esac done component="$(cd "$(dirname "$0")/.." && pwd)" unset APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_ID APPLE_ID_PASSWORD APPLE_PASSWORD APPLE_TEAM_ID unset TAURI_SIGNING_PRIVATE_KEY TAURI_SIGNING_PRIVATE_KEY_PASSWORD expected_source="$(git -C "$component" rev-parse HEAD)" -manifest="$(python3 "$component/scripts/release-info.py" verify "$profile" "$directory" --source-sha "$expected_source" "${unsigned[@]}")" +manifest="$(python3 "$component/scripts/release-info.py" verify "$profile" "$directory" --source-sha "$expected_source" "${unsigned[@]}" "${platform_options[@]}")" platform="$(python3 -c 'import json,sys; print(json.loads(sys.argv[1])["platform"])' "$manifest")" package="$(python3 -c 'import json,sys; print(json.loads(sys.argv[1])["package"])' "$manifest")" temporary="$(mktemp -d "${TMPDIR:-/tmp}/maple-agent-verify.XXXXXX")" @@ -135,15 +141,16 @@ PY else [[ "$static" == false ]] || { echo "static verification is only supported for macOS packages" >&2; exit 2; } [[ "$(uname -s)-$(uname -m)" == Linux-x86_64 ]] || { echo "Linux artifacts require x86_64 verification" >&2; exit 1; } - # GitHub artifact download preserves bytes but resets executable file modes. - # Restore public execution before the subsequent non-root/no-Nix smoke. - chmod 0755 "$directory/$package" - # Preserve stored public modes for audit. Keep private verifier files under - # the outer 077 umask, and do not mask unsafe archive modes into safe ones. - (umask 000; cd "$temporary" && "$directory/$package" --appimage-extract >/dev/null) - python3 "$component/scripts/linux-release-appimage.py" audit "$temporary/squashfs-root" - "$temporary/squashfs-root/AppRun" --build-info > "$temporary/build-info.json" + # Parse and audit stored bundle paths/modes before executing the payload. + # Extraction remains private and does not install into the user's app tree. + python3 "$component/scripts/linux-release-bundle.py" extract "$directory/$package" \ + "$temporary/extracted" --profile "$profile" + bundle_root=maple-agent.app + [[ "$profile" == prod ]] || bundle_root=maple-agent-dev.app + app="$temporary/extracted/$bundle_root" + python3 "$component/scripts/linux-release-bundle.py" audit "$app" + "$app/AppRun" --build-info > "$temporary/build-info.json" cmp "$directory/build-info.json" "$temporary/build-info.json" - "$temporary/squashfs-root/AppRun" --version + "$app/AppRun" --version fi echo "verified ${profile} ${platform} release artifacts" diff --git a/apps/maple-agent/scripts/verify-updater-archive.sh b/apps/maple-agent/scripts/verify-updater-archive.sh index a16139b42..4878acd41 100755 --- a/apps/maple-agent/scripts/verify-updater-archive.sh +++ b/apps/maple-agent/scripts/verify-updater-archive.sh @@ -1,20 +1,53 @@ #!/usr/bin/env bash -# Read actual produced Prod package data with the trusted installer scanner. +# Read actual produced package data with the trusted installer scanner. set -euo pipefail -if [[ $# -lt 1 || $# -gt 2 || ( $# == 2 && "$2" != --unsigned ) ]]; then - echo "usage: verify-updater-archive.sh ARTIFACT_DIRECTORY [--unsigned]" >&2 +if [[ $# -lt 1 ]]; then + echo "usage: verify-updater-archive.sh ARTIFACT_DIRECTORY [--unsigned] [--profile dev|prod] [--platform PLATFORM]" >&2 exit 2 fi component="$(cd "$(dirname "$0")/.." && pwd)" directory="$(cd "$1" && pwd)" unsigned=() -[[ $# == 1 ]] || unsigned=(--unsigned) +profile=prod +profile_set=false +platform_options=() +shift +while [[ $# -gt 0 ]]; do + case "$1" in + --unsigned) + [[ ${#unsigned[@]} == 0 ]] || exit 2 + unsigned=(--unsigned) + shift + ;; + --profile) + [[ "$profile_set" == false && $# -ge 2 && ( "$2" == dev || "$2" == prod ) ]] || exit 2 + profile="$2" + profile_set=true + shift 2 + ;; + --platform) + [[ ${#platform_options[@]} == 0 && $# -ge 2 && ( "$2" == macos-aarch64 || "$2" == linux-x86_64 ) ]] || exit 2 + platform_options=(--platform "$2") + shift 2 + ;; + *) echo "unknown archive verification option: $1" >&2; exit 2 ;; + esac +done scanner="$component/target/debug/examples/validate-release-archive" [[ -x "$scanner" && ! -L "$scanner" ]] || { echo "build the trusted updater archive scanner first" >&2; exit 1; } -python3 "$component/scripts/release-info.py" verify prod "$directory" \ - --source-sha "$(git -C "$component" rev-parse HEAD)" "${unsigned[@]}" >/dev/null -archives=("$directory"/*.app.tar.gz) -[[ ${#archives[@]} == 1 && -f "${archives[0]}" && ! -L "${archives[0]}" ]] || { echo "expected exactly one regular Prod app archive" >&2; exit 1; } +manifest="$(python3 "$component/scripts/release-info.py" verify "$profile" "$directory" \ + --source-sha "$(git -C "$component" rev-parse HEAD)" "${unsigned[@]}" "${platform_options[@]}")" +platform="$(python3 -c 'import json,sys; print(json.loads(sys.argv[1])["platform"])' "$manifest")" +scanner_options=() +if [[ "$platform" == linux-x86_64 ]]; then + package="$(python3 -c 'import json,sys; print(json.loads(sys.argv[1])["package"])' "$manifest")" + archives=("$directory/$package") + scanner_options=(--linux-bundle --profile "$profile") +else + [[ "$profile" == prod ]] || { echo "macOS updater scanner supports only Prod packages" >&2; exit 2; } + archives=("$directory"/*.app.tar.gz) +fi +[[ ${#archives[@]} == 1 && -f "${archives[0]}" && ! -L "${archives[0]}" ]] || { echo "expected exactly one regular app archive" >&2; exit 1; } archive="${archives[0]}" expected="$(python3 - "$directory/SHA256SUMS" "$(basename "$archive")" <<'PY' import re,sys @@ -25,4 +58,4 @@ if len(matches) != 1 or not re.fullmatch(r"[0-9a-f]{64}", matches[0]): print(matches[0]) PY )" -"$scanner" "$archive" "$expected" +"$scanner" "${scanner_options[@]}" "$archive" "$expected" diff --git a/flake.nix b/flake.nix index 7132c96f0..759a404cd 100644 --- a/flake.nix +++ b/flake.nix @@ -825,9 +825,12 @@ python3 apps/maple-agent/scripts/test_third_party_notices.py python3 apps/maple-agent/scripts/test_release_packaging.py python3 apps/maple-agent/scripts/test_linux_release_appimage.py + python3 apps/maple-agent/scripts/test_linux_release_bundle.py + python3 apps/maple-agent/scripts/test_linux_install.py python3 apps/maple-agent/scripts/test_macos_release_dylibs.py python3 apps/maple-agent/scripts/test_macos_build_info.py python3 apps/maple-agent/scripts/test_smoke_macos_release.py + python3 apps/maple-agent/scripts/test_smoke_linux_bundle_host.py touch "$out" ''; diff --git a/scripts/ci/test_agent_updates_workflows.py b/scripts/ci/test_agent_updates_workflows.py index 6769b7f60..57f53fd76 100644 --- a/scripts/ci/test_agent_updates_workflows.py +++ b/scripts/ci/test_agent_updates_workflows.py @@ -38,6 +38,9 @@ def test_manual_master_only_and_separate_authority(self): self.assertEqual(config["concurrency"], { "group": "agent-updates-production", "cancel-in-progress": False, }) + # 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) jobs = config["jobs"] self.assertEqual(set(jobs), {"verify", "reserve", "sign", "publish"}) self.assertNotIn("environment", jobs["verify"]) diff --git a/scripts/ci/test_agent_workflows.py b/scripts/ci/test_agent_workflows.py index 3c414cbcf..9c702b53f 100644 --- a/scripts/ci/test_agent_workflows.py +++ b/scripts/ci/test_agent_workflows.py @@ -477,8 +477,37 @@ def test_provenance_is_mandatory_and_only_minted_by_trusted_master_jobs(self): self.assertEqual(job["steps"][download]["with"], { "name": linux_upload["name"], "path": "artifacts", }) - for step in job["steps"]: - self.assertNotIn("run", step) + scanner_build = next(i for i, step in enumerate(job["steps"]) + if "cargo build --locked -p maple-agent-updater --example validate-release-archive" + in step.get("run", "")) + metadata = next(i for i, step in enumerate(job["steps"]) + if "release-info.py verify" in step.get("run", "")) + scanner = next(i for i, step in enumerate(job["steps"]) + if "./scripts/verify-updater-archive.sh" in step.get("run", "")) + self.assertLess(scanner_build, scanner) + self.assertLess(download, metadata) + self.assertLess(metadata, scanner) + self.assertLess(scanner, attests[0]) + self.assertNotIn("if", job["steps"][scanner_build]) + self.assertNotIn("if", job["steps"][scanner]) + self.assertIn('--profile "$MAPLE_RELEASE_PROFILE"', job["steps"][scanner]["run"]) + self.assertIn("--platform linux-x86_64", job["steps"][scanner]["run"]) + self.assertFalse(any("verify-release.sh" in step.get("run", "") + or "AppRun" in step.get("run", "") + for step in job["steps"]), + "attestation inspects data without executing artifact code") + + def test_linux_official_bundle_reuses_one_profile_build_and_has_no_appimage_runtime(self): + job = workflow(self.WORKFLOW)["jobs"]["linux"] + self.assertEqual(job["name"], "Linux bundle (${{ matrix.variant }})") + builds = [step for step in job["steps"] + if "./scripts/build-release.sh" in step.get("run", "")] + self.assertEqual(len(builds), 1) + self.assertFalse(any("cargo build" in step.get("run", "") for step in job["steps"])) + package = (ROOT / "apps/maple-agent/scripts/package-release.sh").read_text() + self.assertIn('scripts/linux-release-bundle.sh" "$binary" "$staging/${name}.tar.gz"', package) + self.assertIn('export MAPLE_PACKAGE_SOURCE_SHA="$source_sha"', package) + self.assertNotIn("linux-release-appimage", package) def test_macos_packaging_uses_verified_prebuilt_binaries_on_fresh_runners(self): jobs = workflow(self.WORKFLOW)["jobs"] @@ -640,6 +669,18 @@ def test_downloaded_packages_are_verified_without_signing_credentials(self): self.assertLess(provenance, verify) if name == "verify-macos": self.assertNotIn("--unsigned", job["steps"][verify]["run"]) + else: + scanner_build = next(i for i, step in enumerate(job["steps"]) + if "cargo build --locked -p maple-agent-updater --example validate-release-archive" + in step.get("run", "")) + scanner = next(i for i, step in enumerate(job["steps"]) + if "./scripts/verify-updater-archive.sh" in step.get("run", "")) + self.assertLess(provenance, scanner_build) + self.assertLess(scanner_build, scanner) + self.assertLess(download, scanner) + self.assertLess(scanner, verify) + self.assertNotIn("if", job["steps"][scanner_build]) + self.assertNotIn("if", job["steps"][scanner]) smoke = next(step for step in jobs["verify-linux"]["steps"] if "docker run" in step.get("run", ""))["run"] self.assertRegex(smoke, r"ubuntu@sha256:[0-9a-f]{64}") @@ -650,6 +691,13 @@ def test_downloaded_packages_are_verified_without_signing_credentials(self): self.assertRegex(smoke, r"--tmpfs /tmp:[^\s]*mode=1777") self.assertIn("--build-info", smoke) self.assertIn("actual != expected", smoke) + self.assertIn("tar -xzpf /artifacts/*-linux-x86_64.tar.gz", smoke) + self.assertIn("./maple-agent*.app/AppRun", smoke) + self.assertIn("/bin/sh /checks/smoke-linux-bundle-host.sh ./maple-agent*.app/AppRun >&2", smoke) + self.assertIn("--volume \"$PWD/apps/maple-agent/scripts/smoke-linux-bundle-host.sh:/checks/smoke-linux-bundle-host.sh:ro\"", smoke) + self.assertIn("test -d /etc/ssl/certs", smoke) + self.assertIn("--volume /etc/ssl/certs:/etc/ssl/certs:ro", smoke) + self.assertNotIn("--appimage-extract", smoke) def test_downloaded_provenance_enforces_workflow_ref_commit_and_every_file(self): for name in ("verify-macos", "verify-linux"): @@ -714,8 +762,27 @@ def test_linux_pr_packages_and_both_verifiers_use_unsigned_mode_only_for_pr_even expected.append(directory.format(profile=profile)) if event == "pull_request": expected.append("--unsigned") + if script == "verify-release.sh": + expected.extend(["--platform", "linux-x86_64"]) self.assertEqual(arguments[script_index + 1:], expected) + scanner = next(step for step in jobs["verify-linux"]["steps"] + if "./scripts/verify-updater-archive.sh" in step.get("run", "")) + for profile in ("dev", "prod"): + for event in ("pull_request", "push", "workflow_dispatch"): + with self.subTest(scanner_profile=profile, event=event): + command = r'''nix() { printf '%s\0' "$@"; } +''' + scanner["run"] + result = subprocess.run(["bash", "-c", command], check=True, capture_output=True, + env={**os.environ, "GITHUB_EVENT_NAME": event, + "MAPLE_RELEASE_PROFILE": profile}) + arguments = [value.decode() for value in result.stdout.split(b"\0")[:-1]] + script_index = arguments.index("./scripts/verify-updater-archive.sh") + expected = ["../../artifacts", "--profile", profile, "--platform", "linux-x86_64"] + if event == "pull_request": + expected.append("--unsigned") + self.assertEqual(arguments[script_index + 1:], expected) + class AgentDiffSelectionTests(unittest.TestCase): def setUp(self): diff --git a/scripts/ci/test_nix_workflows.py b/scripts/ci/test_nix_workflows.py index a469fbabc..691126aa0 100644 --- a/scripts/ci/test_nix_workflows.py +++ b/scripts/ci/test_nix_workflows.py @@ -18,8 +18,8 @@ "name": "Verify pinned Nix runtime", "run": "bash scripts/ci/verify-nix-runtime.sh", } -# Independent Agent provider and Linux release jobs each retain pinned setup. -EXPECTED_INSTALLS = 78 +# Independent Agent provider, Linux release and attestation jobs retain pinned setup. +EXPECTED_INSTALLS = 79 @functools.cache diff --git a/services/agent-updates/README.md b/services/agent-updates/README.md index 553543b9d..a8a1a683a 100644 --- a/services/agent-updates/README.md +++ b/services/agent-updates/README.md @@ -31,13 +31,16 @@ 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 signed envelope from `stable/latest.json` | -| `/agent/stable/releases/V/manifest.json` | Immutable 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 | +| 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 | Missing objects/targets return 404. Malformed metadata, mismatched stored sizes and bucket failures return bounded 503 responses. The Worker checks schema and @@ -58,6 +61,22 @@ same `.AppImage`. Each artifact contains exactly `path`, positive `size` (at mos digits, `.`, `_`, `-`; traversal and encoded separators are rejected. Supporting build-info and provenance objects in R2 are private, not public artifact routes. +The separate Linux feed uses `format: maple-agent-update-v2` with the same exact +top-level fields and limits. Its `artifacts` contains only `linux-x86_64`, a +`.tar.gz` bundle; its `downloads` contains only `linux-bundle`, describing that +same path, size and hash. It cannot contain macOS or legacy AppImage targets. +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. + +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 +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. + Limits: 128 KiB envelope, 64 KiB manifest, 4 KiB minisign signature, 16 KiB notes. Signature format is prehashed minisign Ed25519 with exact trusted comment `product=maple-agent channel=stable version=`. No expiry is claimed. diff --git a/services/agent-updates/publisher/README.md b/services/agent-updates/publisher/README.md index 1e2201b55..3dda31932 100644 --- a/services/agent-updates/publisher/README.md +++ b/services/agent-updates/publisher/README.md @@ -17,13 +17,17 @@ 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 +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. + ```sh publisher=services/agent-updates/publisher/publisher.py -python3 "$publisher" trust --run-id BUILD_RUN_ID --output trust.json -# Use --linux on trust and --linux linux-artifacts on prepare only when the -# Linux package and installed-update acceptance gates have passed. -python3 "$publisher" prepare --run-id BUILD_RUN_ID --version 0.0.1 \ - --build BUILD_RUN_NUMBER --macos macos-artifacts --notes-file notes.txt \ +python3 "$publisher" trust --run-id BUILD_RUN_ID --linux --output trust.json +# Both platform packages come from this one verified producer run. +python3 "$publisher" prepare --run-id BUILD_RUN_ID --version 0.0.4 \ + --build BUILD_RUN_NUMBER --macos macos-artifacts --linux linux-artifacts --notes-file notes.txt \ --public-key public-key.pub --output bundle python3 "$publisher" reserve --bundle bundle --public-key bundle/public-key.pub \ --endpoint https://ACCOUNT.r2.cloudflarestorage.com --bucket AGENT_BUCKET @@ -52,18 +56,36 @@ match the selected run's build number, version and application identity. Sparse archives are rejected; extension headers and cumulative metadata are bounded. The manifest build number is the selected build run's `run_number`, matching the -packaging workflow's `CFBundleVersion` input. Its application version must match +packaging workflow's `CFBundleVersion` input and Linux `bundle.json` descriptor. +The Linux descriptor is read with bounded archive metadata, entry and expanded-size +limits, without extraction or execution; its exact Prod identity, version, build, +source, entrypoint and executable must match the selected release. Its application version must match the selected packages and be a canonical stable SemVer. The default publication date is the build run's creation date, making preparation deterministic across retries. `--pub-date` can select another UTC date, but retries must preserve it, the notes and every other reserved byte. No timestamps are inserted when signing. +Linux uses an actual `.tar.gz` bundle and a separate signed v2 feed. Preparation +always writes the mac-only v1 `manifest.json`, preserving the wire shape accepted +by installed Mac clients. With `--linux`, it additionally writes +`linux-manifest.json` with format `maple-agent-update-v2`, only the +`linux-x86_64` artifact and the identical `linux-bundle` download. The two +manifests share product, channel, version, build, source, date and notes. The +same primary key signs both exact documents as `envelope.json` and +`linux-envelope.json`. Linux publication also requires the canonical compiled +Linux feed in both the selected source profile and trusted publisher profile; +Mac-only producers do not need that field. + `reserve` creates `stable/releases/V/publication-manifest.json` using `If-None-Match: *`. This private object reserves the exact unsigned bytes before the signing stage. It also reserves `publication-metadata.json`, an exact hash and size inventory of the private audit sidecars. Both digests are bound by the -reservation receipt. It recovers an existing complete signed manifest into the -bundle, preserving its envelope on a retry. `sign` requires the reservation +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 +reserved before signing. It recovers each existing complete signed manifest into +the bundle, preserving both envelopes 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. @@ -81,6 +103,20 @@ release verifies the objects, repairs missing objects with create-only writes an leaves the pointer unchanged; a different envelope at the same or lower version is rejected. Failed attempts never delete objects or automatically restore a pointer. +For Linux, the immutable signed envelope is +`stable/releases/V/linux-manifest.json` and the separate conditional pointer is +`stable/linux/latest.json`. The public paths are +`/agent/stable/linux/latest.json` and +`/agent/stable/releases/V/linux-manifest.json`; `/download/agent/stable/linux` +redirects to the signed tar bundle. Both pointers are authenticated and snapshotted +before any promotion. Every selected payload, audit sidecar and complete envelope +is verified before either pointer advances. The two CAS writes are deliberately +independent: if the second fails, an exact retry verifies the already advanced +feed and safely promotes the remaining one. A newer or conflicting pointer blocks +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. + 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 @@ -88,8 +124,8 @@ plus the stable run/source/artifact identity and attestation policy in it is omitted from durable metadata so a successful verifier-only rerun can reuse 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 Linux -is included; a missing, empty, partial or extra inventory is rejected. These +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 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 diff --git a/services/agent-updates/publisher/publisher.py b/services/agent-updates/publisher/publisher.py index 1fdf38ab6..b850905c6 100644 --- a/services/agent-updates/publisher/publisher.py +++ b/services/agent-updates/publisher/publisher.py @@ -46,6 +46,7 @@ DIGEST = re.compile(r"[0-9a-f]{64}\Z") ROOT = Path(__file__).resolve().parents[3] FEED_URL = "https://updates.trymaple.ai/agent/stable/latest.json" +LINUX_FEED_URL = "https://updates.trymaple.ai/agent/stable/linux/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 @@ -153,7 +154,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"] == "maple-agent-update-v1" and manifest["product"] == "maple-agent" and manifest["channel"] == "stable", "wrong update identity") + need(manifest["format"] in {"maple-agent-update-v1", "maple-agent-update-v2"} 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") @@ -168,8 +169,14 @@ def validate_manifest(data): need(isinstance(notes, str) and len(notes.encode("utf-8")) <= MAX_NOTES, "oversized notes") 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"] - need(isinstance(artifacts, dict) and {"darwin-aarch64"} <= set(artifacts) <= {"darwin-aarch64", "linux-x86_64"}, "invalid update targets") - need(isinstance(downloads, dict) and set(downloads) == ({"macos", "linux-appimage"} if "linux-x86_64" in artifacts else {"macos"}), "downloads do not match update targets") + linux_v2 = manifest["format"] == "maple-agent-update-v2" + if 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: + # Retain the legacy v1 contract when interpreting historical envelopes. + need(isinstance(artifacts, dict) and {"darwin-aarch64"} <= set(artifacts) <= {"darwin-aarch64", "linux-x86_64"}, "invalid update targets") + need(isinstance(downloads, dict) and set(downloads) == ({"macos", "linux-appimage"} if "linux-x86_64" in artifacts else {"macos"}), "downloads do not match update targets") for kind, entries in (("artifacts", artifacts), ("downloads", downloads)): for target, entry in entries.items(): need(isinstance(entry, dict) and set(entry) == {"path", "size", "sha256"}, "invalid artifact record") @@ -179,12 +186,12 @@ 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 = ".app.tar.gz" if target == "darwin-aarch64" else ".dmg" if target == "macos" else ".AppImage" + suffix = ".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") if "linux-x86_64" in artifacts: - need(artifacts["linux-x86_64"] == downloads["linux-appimage"], "Linux download and update differ") + need(artifacts["linux-x86_64"] == downloads["linux-bundle" if linux_v2 else "linux-appimage"], "Linux download and update differ") return manifest @@ -240,7 +247,7 @@ def validate_run(run, jobs, linux=False): need(isinstance(jobs, dict) and jobs.get("total_count") == len(jobs.get("jobs", [])), "incomplete build job inventory") required = {"Signed macOS (prod)", "Verify downloaded macOS (prod)"} if linux: - required |= {"Linux AppImage (prod)", "Attest Linux packages (prod)", "Verify downloaded Linux (prod)"} + required |= {"Linux bundle (prod)", "Attest Linux packages (prod)", "Verify downloaded Linux (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) @@ -289,16 +296,18 @@ def public_key_text(path): return lines[0] -def validate_pins(key, local_profile, source_profile): +def validate_pins(key, local_profile, source_profile, linux=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") 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): +def check_source_pins(public_key, source_sha, linux=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}") @@ -307,7 +316,7 @@ def check_source_pins(public_key, source_sha): 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)) + validate_pins(key, local, decode_json(source_bytes, MAX_MANIFEST), linux) return key @@ -397,6 +406,49 @@ def validate_archive_build(archive, version, build, bundle_id="cloud.opensecret. need(found.get("CFBundleVersion") == build and found.get("CFBundleShortVersionString") == version and found.get("CFBundleIdentifier") == bundle_id, "app Info.plist build, version or identity differs from selected run") +class LinuxTarInfo(BoundedTarInfo): + def _proc_member(self, archive): + need(getattr(archive, "maple_headers", 0) < 20_000, "Linux archive exceeds bounded header inventory") + if self.type in {tarfile.XHDTYPE, tarfile.XGLTYPE, tarfile.SOLARIS_XHDTYPE, tarfile.GNUTYPE_LONGNAME, tarfile.GNUTYPE_LONGLINK}: + need(self.size <= 16 * 1024, "Linux archive extension metadata exceeds bounds") + return super()._proc_member(archive) + + +def validate_linux_archive_build(archive, version, build, source_sha, bundle_id): + """Bind the small descriptor without extracting or executing the Linux app. + + The producer and downloaded consumer additionally scan the complete layout. + This publisher check independently binds their payload to the selected run. + """ + expected = {"format": "maple-agent-linux-bundle-v1", "product": "maple-agent", + "profile": "prod", "version": version, "build": build, + "source_sha": source_sha, "bundle_id": bundle_id, + "entrypoint": "AppRun", "executable": "usr/bin/maple-agent"} + need(isinstance(build, str) and re.fullmatch(r"[1-9][0-9]{0,17}", build), "Linux package build must match the selected run") + found, count, expanded = None, 0, 0 + try: + with regular_file(archive) as compressed: + with tarfile.open(fileobj=compressed, mode="r|gz", tarinfo=LinuxTarInfo) as source: + for member in source: + count += 1 + expanded += member.size + need(count <= 20_000 and len(member.name.encode("utf-8")) <= 1024 + and 0 <= member.size <= 2 * 1024**3 and expanded <= 4 * 1024**3, + "Linux archive exceeds bounded inventory") + if member.name != "maple-agent.app/bundle.json": + continue + need(found is None and member.isfile() and 0 < member.size <= MAX_MANIFEST, + "invalid or duplicate Linux bundle descriptor") + with source.extractfile(member) as payload: + data = payload.read(MAX_MANIFEST + 1) + need(len(data) == member.size, "truncated Linux bundle descriptor") + found = decode_json(data, MAX_MANIFEST) + need(isinstance(found, dict), "invalid Linux bundle descriptor") + except (tarfile.TarError, EOFError, OSError, RecursionError) as error: + raise PublishError("invalid Linux bundle archive") from error + need(found == expected, "Linux bundle build, version, source or identity differs from selected run") + + 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") @@ -407,12 +459,12 @@ def validate_package(directory, platform, source_sha, version, attestor=attest, release_info = importlib.util.module_from_spec(spec) spec.loader.exec_module(release_info) try: - release_info.validate_info("prod", info, source_sha) + release_info.validate_info("prod", info, source_sha, platform) except (ValueError, KeyError, TypeError) as error: 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 ".AppImage") + package = stem + (".dmg" if platform == "macos-aarch64" else ".tar.gz") binaries = [package, stem + ".app.tar.gz"] if platform == "macos-aarch64" else [package] expected = {"build-info.json", "manifest.json", "SHA256SUMS", *binaries} if platform == "macos-aarch64": @@ -442,6 +494,8 @@ def validate_package(directory, platform, source_sha, version, attestor=attest, need(isinstance(notary, dict) and notary.get("status") == "Accepted" and notary.get("issues") in (None, []), "package notarization was not accepted") if expected_build is not None: 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"]) for name in sorted(expected): attestor(directory / name, source_sha) if metadata_records is not None: @@ -453,21 +507,25 @@ def prepare(run_id, version, build, macos, linux, notes_file, output, public_key version_tuple(version) proof = trust_run(run_id, bool(linux)) need(build == proof["build"], "build number must match the package run number") - key = check_source_pins(public_key, proof["source_sha"]) + key = check_source_pins(public_key, proof["source_sha"], bool(linux)) notes = read_bytes(notes_file, MAX_NOTES).decode("utf-8") mac_metadata, linux_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, metadata_records=linux_metadata) if linux else [] + lin = validate_package(linux, "linux-x86_64", proof["source_sha"], version, expected_build=proof["build"], metadata_records=linux_metadata) if linux 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]}} - if lin: - manifest["artifacts"]["linux-x86_64"] = lin[0][1] - manifest["downloads"]["linux-appimage"] = lin[0][1] data = encode_json(manifest) validate_manifest(data) output = Path(output) output.mkdir(parents=True, exist_ok=True) need(not output.is_symlink(), "bundle directory must not be a symlink") write_once(output / "manifest.json", data) + if lin: + linux_manifest = {**manifest, "format": "maple-agent-update-v2", + "artifacts": {"linux-x86_64": lin[0][1]}, + "downloads": {"linux-bundle": lin[0][1]}} + linux_data = encode_json(linux_manifest) + validate_manifest(linux_data) + write_once(output / "linux-manifest.json", linux_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" @@ -502,10 +560,59 @@ def prepare(run_id, version, build, macos, linux, notes_file, output, public_key return manifest +def bundle_manifests(bundle): + """Both exact unsigned wire documents are 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. + """ + 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"), + ): + 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") + 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") + 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") + publications.append({"name": name, "envelope_name": envelope, "reservation": reservation, + "pointer": pointer, "data": data, "manifest": manifest}) + return publications + + +def reservation_receipt(publications, metadata_inventory): + primary = publications[0] + 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() + return receipt + + def bundle_records(bundle, manifest): payloads = Path(bundle) / "payloads" need(payloads.is_dir() and not payloads.is_symlink(), "invalid prepared payload directory") - records = {record["path"].rsplit("/", 1)[1]: record for record in [*manifest["artifacts"].values(), *manifest["downloads"].values()]} + publications = bundle_manifests(bundle) + need(publications[0]["manifest"] == manifest, "primary prepared manifest differs") + records = {} + for publication in publications: + wire = publication["manifest"] + for record in [*wire["artifacts"].values(), *wire["downloads"].values()]: + name = record["path"].rsplit("/", 1)[1] + need(name not in records or records[name] == record, "conflicting prepared artifact records") + records[name] = record need(set(path.name for path in payloads.iterdir()) == set(records), "prepared payload inventory differs") for name, record in records.items(): need(hash_file(payloads / name, record["size"]) == (record["sha256"], record["size"]), "prepared artifact hash differs") @@ -519,7 +626,7 @@ def bundle_metadata(bundle, manifest): data = read_bytes(index, MAX_MANIFEST) inventory = decode_json(data, MAX_MANIFEST) expected = {"provenance.json"} | MACOS_METADATA_NAMES - if "linux-x86_64" in manifest["artifacts"]: + if len(bundle_manifests(bundle)) == 2: expected |= LINUX_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" @@ -663,102 +770,113 @@ def immutable_put(store, key, data, content_type="application/octet-stream", max def reserve(bundle, store, public_key=None): bundle = Path(bundle) - data = read_bytes(bundle / "manifest.json", MAX_MANIFEST) - manifest = validate_manifest(data) + publications = bundle_manifests(bundle) + manifest = publications[0]["manifest"] bundle_records(bundle, manifest) metadata_inventory, _ = bundle_metadata(bundle, manifest) - # Reserve exact unsigned bytes before any signing job can use the version. - immutable_put(store, release_key(manifest["version"], "publication-manifest.json"), data, "application/json") - reserved = store.get(release_key(manifest["version"], "publication-manifest.json"), MAX_MANIFEST) - need(reserved is not None and reserved[0] == data, "reserved manifest read-back differs") + # Bind both exact unsigned documents 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"]) + immutable_put(store, key, publication["data"], "application/json") + reserved = store.get(key, MAX_MANIFEST) + need(reserved is not None and reserved[0] == publication["data"], "reserved manifest read-back differs") immutable_put(store, release_key(manifest["version"], "publication-metadata.json"), metadata_inventory, "application/json") 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, "reserved metadata read-back differs") - existing = store.get(release_key(manifest["version"], "manifest.json"), MAX_ENVELOPE) - if existing: - envelope, old_manifest = parse_envelope(existing[0]) - need(envelope["manifest"].encode("utf-8") == data, "reserved envelope has different manifest bytes") - if public_key: - minisign_verify(existing[0], public_key) - write_once(bundle / "envelope.json", existing[0]) - receipt = {"manifest_sha256": hashlib.sha256(data).hexdigest(), "metadata_inventory_sha256": hashlib.sha256(metadata_inventory).hexdigest(), "version": manifest["version"]} - write_once(bundle / "reservation.json", encode_json(receipt)) + for publication in publications: + existing = store.get(release_key(manifest["version"], publication["name"]), MAX_ENVELOPE) + if existing: + envelope, _ = parse_envelope(existing[0]) + need(envelope["manifest"].encode("utf-8") == publication["data"], "reserved envelope has different manifest bytes") + if public_key: + minisign_verify(existing[0], public_key) + write_once(bundle / publication["envelope_name"], existing[0]) + write_once(bundle / "reservation.json", encode_json(reservation_receipt(publications, metadata_inventory))) return manifest def sign(bundle, secret_key, public_key): bundle = Path(bundle) need(public_key_text(public_key) == public_key_text(bundle / "public-key.pub"), "signing key differs from the prepared pinned key") - data = read_bytes(bundle / "manifest.json", MAX_MANIFEST) - manifest = validate_manifest(data) + publications = bundle_manifests(bundle) + manifest = publications[0]["manifest"] bundle_records(bundle, manifest) metadata_inventory, _ = bundle_metadata(bundle, manifest) receipt = decode_json(read_bytes(bundle / "reservation.json", MAX_MANIFEST)) - need(receipt == {"manifest_sha256": hashlib.sha256(data).hexdigest(), "metadata_inventory_sha256": hashlib.sha256(metadata_inventory).hexdigest(), "version": manifest["version"]}, "manifest has not been reserved before signing") - if (bundle / "envelope.json").exists(): - existing = read_bytes(bundle / "envelope.json", MAX_ENVELOPE) - need(parse_envelope(existing)[0]["manifest"].encode("utf-8") == data, "existing envelope differs") - minisign_verify(existing, public_key) - return manifest - with tempfile.TemporaryDirectory(prefix="maple-agent-sign-") as temporary: - message = Path(temporary) / "manifest.json" - signature = Path(temporary) / "signature.minisig" - message.write_bytes(data) - result = subprocess.run(["minisign", "-S", "-W", "-s", str(secret_key), "-m", str(message), "-x", str(signature), "-c", "Maple Agent update", "-t", trusted_comment(manifest["version"])], stdin=subprocess.DEVNULL, capture_output=True, timeout=30) - need(result.returncode == 0, "minisign signing failed") - signature_data = read_bytes(signature, MAX_SIGNATURE).decode("utf-8") - envelope = encode_json({"manifest": data.decode("utf-8"), "signature": signature_data}) - minisign_verify(envelope, public_key) - write_once(bundle / "envelope.json", envelope) + need(receipt == reservation_receipt(publications, metadata_inventory), "manifest has not been reserved before signing") + for publication in publications: + target = bundle / publication["envelope_name"] + data = publication["data"] + if target.exists() or target.is_symlink(): + existing = read_bytes(target, MAX_ENVELOPE) + need(parse_envelope(existing)[0]["manifest"].encode("utf-8") == data, "existing envelope differs") + minisign_verify(existing, public_key) + continue + with tempfile.TemporaryDirectory(prefix="maple-agent-sign-") as temporary: + message = Path(temporary) / "manifest.json" + signature = Path(temporary) / "signature.minisig" + message.write_bytes(data) + result = subprocess.run(["minisign", "-S", "-W", "-s", str(secret_key), "-m", str(message), "-x", str(signature), "-c", "Maple Agent update", "-t", trusted_comment(manifest["version"])], stdin=subprocess.DEVNULL, capture_output=True, timeout=30) + need(result.returncode == 0, "minisign signing failed") + signature_data = read_bytes(signature, MAX_SIGNATURE).decode("utf-8") + envelope = encode_json({"manifest": data.decode("utf-8"), "signature": signature_data}) + minisign_verify(envelope, public_key) + write_once(target, envelope) return manifest def publish(bundle, store, public_key, verifier=minisign_verify, previous_verifier=verify_current): bundle = Path(bundle) need(public_key_text(public_key) == public_key_text(bundle / "public-key.pub"), "publication key differs from the prepared pinned key") - data = read_bytes(bundle / "manifest.json", MAX_MANIFEST) - envelope = read_bytes(bundle / "envelope.json", MAX_ENVELOPE) - manifest = verifier(envelope, public_key) - need(parse_envelope(envelope)[0]["manifest"].encode("utf-8") == data, "signed and reserved manifest bytes differ") + publications = bundle_manifests(bundle) + manifest = publications[0]["manifest"] records = bundle_records(bundle, manifest) metadata_inventory, metadata_records = bundle_metadata(bundle, manifest) - reserved = store.get(release_key(manifest["version"], "publication-manifest.json"), MAX_MANIFEST) - need(reserved is not None and reserved[0] == data, "version must be reserved before signing and publication") + for publication in publications: + envelope = read_bytes(bundle / publication["envelope_name"], MAX_ENVELOPE) + verified = verifier(envelope, public_key) + need(verified == publication["manifest"] and parse_envelope(envelope)[0]["manifest"].encode("utf-8") == publication["data"], "signed and reserved manifest bytes differ") + publication["envelope"] = envelope + reserved = store.get(release_key(manifest["version"], publication["reservation"]), MAX_MANIFEST) + 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 before uploading. An operator changing the pointer while this - # publisher runs must win over this attempt; retry requires fresh review. - pointer = store.get("stable/latest.json", MAX_ENVELOPE) - if pointer: - previous = previous_verifier(pointer[0], public_key) - if pointer[0] == envelope: - # Repair deleted/missing objects with create-only writes; existing - # different bytes remain a hard conflict, even on a feed retry. - # The already matching pointer never needs to be rewritten. - for path, record in records: - immutable_put(store, record["path"][len("/agent/"):], path, maximum=record["size"]) - need(store.same(record["path"][len("/agent/"):], record["sha256"], record["size"]), "published artifact differs or is missing") - publish_metadata(store, manifest["version"], metadata_records) - immutable_put(store, release_key(manifest["version"], "manifest.json"), envelope, "application/json") - immutable = store.get(release_key(manifest["version"], "manifest.json"), MAX_ENVELOPE) - need(immutable is not None and immutable[0] == envelope, "published envelope differs or is missing") - return "already-published" - need(version_tuple(manifest["version"]) > version_tuple(previous["version"]), "release version must advance the published version") + # Snapshot ALL selected feeds before uploading or promoting either. A later + # operator change wins its CAS. Partial dual 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) + publication["previous"] = pointer + if pointer: + previous = previous_verifier(pointer[0], public_key) + need(previous["format"] == publication["manifest"]["format"], "published feed format differs") + if pointer[0] != publication["envelope"]: + need(version_tuple(manifest["version"]) > version_tuple(previous["version"]), "release version must advance the published version") for path, record in records: - immutable_put(store, record["path"][len("/agent/"):], path, maximum=record["size"]) - need(store.same(record["path"][len("/agent/"):], record["sha256"], record["size"]), "uploaded artifact read-back differs") + key = record["path"][len("/agent/"):] + immutable_put(store, key, path, maximum=record["size"]) + need(store.same(key, record["sha256"], record["size"]), "uploaded artifact read-back differs") publish_metadata(store, manifest["version"], metadata_records) - immutable_put(store, release_key(manifest["version"], "manifest.json"), envelope, "application/json") - complete = store.get(release_key(manifest["version"], "manifest.json"), MAX_ENVELOPE) - need(complete is not None and complete[0] == envelope, "complete manifest read-back differs") - condition = ("If-Match", pointer[1]) if pointer else ("If-None-Match", "*") - try: - store.put("stable/latest.json", envelope, condition=condition, content_type="application/json") - except StoreError as error: - if error.status == 412: - raise PublishError("publication pointer changed concurrently; inspect it before retrying") from None - raise - return "published" + for publication in publications: + key = release_key(manifest["version"], publication["name"]) + immutable_put(store, key, publication["envelope"], "application/json") + complete = store.get(key, MAX_ENVELOPE) + need(complete is not None and complete[0] == publication["envelope"], "complete manifest read-back differs") + changed = False + for publication in publications: + pointer = publication["previous"] + if pointer and pointer[0] == publication["envelope"]: + continue + condition = ("If-Match", pointer[1]) if pointer else ("If-None-Match", "*") + try: + store.put(publication["pointer"], publication["envelope"], condition=condition, content_type="application/json") + except StoreError as error: + if error.status == 412: + raise PublishError("publication pointer changed concurrently; inspect it before retrying") from None + raise + changed = True + return "published" if changed else "already-published" def live_request(opener, url, *, method="GET", headers=None, maximum=MAX_ENVELOPE): @@ -778,30 +896,35 @@ def live_request(opener, url, *, method="GET", headers=None, maximum=MAX_ENVELOP def verify_live(bundle, public_key, base_url): bundle = Path(bundle) origin = https_origin(base_url) - envelope = read_bytes(bundle / "envelope.json", MAX_ENVELOPE) - manifest = minisign_verify(envelope, public_key) + publications = bundle_manifests(bundle) + manifest = publications[0]["manifest"] bundle_records(bundle, manifest) opener = urllib.request.build_opener(NoRedirect) - # Match the native updater and preserve the exact bytes being verified, - # including full payload GETs that do not go through live_request. + # Match the native updater on every request, including separate full GETs. opener.addheaders = [("User-Agent", "Maple-Agent-Updater/1"), ("Accept-Encoding", "identity")] - for path, expected in (("/agent/stable/latest.json", envelope), (public_path(manifest["version"], "manifest.json"), envelope)): - status, _, body = live_request(opener, origin + path) - need(status == 200 and body == expected, f"live signed metadata differs at {path} (HTTP {status})") - for record in {item["path"]: item for item in [*manifest["artifacts"].values(), *manifest["downloads"].values()]}.values(): - status, headers, _ = live_request(opener, origin + record["path"], method="HEAD", maximum=0) - need(status == 200 and headers.get("Content-Length") == str(record["size"]), "live artifact HEAD failed") - status, headers, part = live_request(opener, origin + record["path"], headers={"Range": "bytes=0-0"}, maximum=1) - need(status == 206 and len(part) == 1 and headers.get("Content-Range") == f"bytes 0-0/{record['size']}", "live artifact range request failed") - request = urllib.request.Request(origin + record["path"]) - try: - with opener.open(request, timeout=120) as response: - need(response.status == 200 and hash_stream(response, record["size"]) == (record["sha256"], record["size"]), "live artifact digest differs") - except (urllib.error.URLError, OSError): - raise PublishError("live artifact download failed") from None - for target, record in manifest["downloads"].items(): - status, headers, _ = live_request(opener, origin + f"/download/agent/stable/{target}", maximum=1024) - need(status in {302, 307} and headers.get("Location") in {record["path"], origin + record["path"]}, "live download redirect differs") + for publication in publications: + envelope = read_bytes(bundle / publication["envelope_name"], MAX_ENVELOPE) + verified = minisign_verify(envelope, public_key) + need(verified == publication["manifest"] and parse_envelope(envelope)[0]["manifest"].encode("utf-8") == publication["data"], "live verification envelope differs from preparation") + for path in ("/agent/" + publication["pointer"], public_path(manifest["version"], publication["name"])): + status, _, body = live_request(opener, origin + path) + need(status == 200 and body == envelope, f"live signed metadata differs at {path} (HTTP {status})") + wire = publication["manifest"] + for record in {item["path"]: item for item in [*wire["artifacts"].values(), *wire["downloads"].values()]}.values(): + status, headers, _ = live_request(opener, origin + record["path"], method="HEAD", maximum=0) + need(status == 200 and headers.get("Content-Length") == str(record["size"]), "live artifact HEAD failed") + status, headers, part = live_request(opener, origin + record["path"], headers={"Range": "bytes=0-0"}, maximum=1) + need(status == 206 and len(part) == 1 and headers.get("Content-Range") == f"bytes 0-0/{record['size']}", "live artifact range request failed") + request = urllib.request.Request(origin + record["path"]) + try: + with opener.open(request, timeout=120) as response: + need(response.status == 200 and hash_stream(response, record["size"]) == (record["sha256"], record["size"]), "live artifact digest differs") + except (urllib.error.URLError, OSError): + raise PublishError("live artifact download failed") from None + for target, record in wire["downloads"].items(): + alias = "linux" if target == "linux-bundle" else target + status, headers, _ = live_request(opener, origin + f"/download/agent/stable/{alias}", maximum=1024) + need(status in {302, 307} and headers.get("Location") in {record["path"], origin + record["path"]}, "live download redirect differs") return manifest diff --git a/services/agent-updates/publisher/test_minisign.py b/services/agent-updates/publisher/test_minisign.py index 64cd4ac09..f5585b71e 100644 --- a/services/agent-updates/publisher/test_minisign.py +++ b/services/agent-updates/publisher/test_minisign.py @@ -117,6 +117,33 @@ def test_tampered_signed_bytes_and_comment_are_rejected(self): with self.assertRaisesRegex(publisher.PublishError, "comment"): publisher.minisign_verify(publisher.encode_json(envelope), self.key) + def test_dual_feed_real_signatures_share_key_and_retry_exactly(self): + 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) + 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() + 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")} + 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") + 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") + publisher.reserve(bundle, store, key) + 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"]) + def test_real_temporary_key_reservation_signing_verification_and_retry(self): class ReservationStore: def __init__(self): diff --git a/services/agent-updates/publisher/test_publisher.py b/services/agent-updates/publisher/test_publisher.py index 606c0b498..f385860de 100644 --- a/services/agent-updates/publisher/test_publisher.py +++ b/services/agent-updates/publisher/test_publisher.py @@ -63,6 +63,7 @@ def __init__(self): self.fail_once = None self.corrupt_once = None self.before_pointer = None + self.before_linux_pointer = None def set(self, key, data): self.objects[key] = data @@ -90,6 +91,9 @@ def put(self, key, data, *, condition, content_type="application/octet-stream"): if key == "stable/latest.json" and self.before_pointer: callback, self.before_pointer = self.before_pointer, None callback() + if key == "stable/linux/latest.json" and self.before_linux_pointer: + callback, self.before_linux_pointer = self.before_linux_pointer, None + callback() if key == self.fail_once: self.fail_once = None raise publisher.StoreError(503) @@ -135,6 +139,14 @@ def open(self, request, timeout): if path in {"/agent/stable/latest.json", immutable} and method == "GET": return FakeResponse(self.envelope) records = [*self.manifest["artifacts"].values(), *self.manifest["downloads"].values()] + linux_path = self.bundle / "linux-manifest.json" + if linux_path.exists(): + linux = publisher.validate_manifest(linux_path.read_bytes()) + if path in {"/agent/stable/linux/latest.json", publisher.public_path(linux["version"], "linux-manifest.json")} and method == "GET": + return FakeResponse((self.bundle / "linux-envelope.json").read_bytes()) + 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()) record = next((item for item in records if item["path"] == path), None) if record: data = (self.bundle / "payloads" / path.rsplit("/", 1)[1]).read_bytes() @@ -172,7 +184,7 @@ def make_bundle(self, version, name, notes="Release fixture", linux=False): artifacts = {} binaries = [("agent.app.tar.gz", b"archive fixture"), ("agent.dmg", b"disk image fixture")] if linux: - binaries.append(("agent.AppImage", b"AppImage fixture")) + binaries.append(("agent-linux.tar.gz", b"Linux tar fixture")) for filename, data in binaries: (payloads / filename).write_bytes(data) artifacts[filename] = { @@ -187,8 +199,11 @@ def make_bundle(self, version, name, notes="Release fixture", linux=False): "downloads": {"macos": artifacts["agent.dmg"]}, } if linux: - manifest["artifacts"]["linux-x86_64"] = artifacts["agent.AppImage"] - manifest["downloads"]["linux-appimage"] = artifacts["agent.AppImage"] + linux_manifest = {**manifest, "format": "maple-agent-update-v2", + "artifacts": {"linux-x86_64": artifacts["agent-linux.tar.gz"]}, + "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)) envelope = synthetic_envelope(manifest) (bundle / "manifest.json").write_bytes(publisher.encode_json(manifest)) (bundle / "envelope.json").write_bytes(envelope) @@ -346,6 +361,131 @@ def test_linux_bundle_includes_and_publishes_all_required_private_sidecars(self) for name in (*MACOS_METADATA, *LINUX_METADATA): self.assertEqual(self.store.objects[self.metadata_key(name)], (bundle / "metadata" / name).read_bytes()) + def test_linux_v2_has_separate_reserved_bytes_envelope_and_pointer(self): + bundle, mac, mac_envelope = self.make_bundle("1.2.3", "dual-feed", linux=True) + linux_data = (bundle / "linux-manifest.json").read_bytes() + linux = publisher.validate_manifest(linux_data) + self.assertEqual(set(mac["artifacts"]), {"darwin-aarch64"}) + self.assertEqual(set(mac["downloads"]), {"macos"}) + self.assertEqual(linux["format"], "maple-agent-update-v2") + self.assertEqual(linux["artifacts"]["linux-x86_64"], linux["downloads"]["linux-bundle"]) + self.assertTrue(linux["downloads"]["linux-bundle"]["path"].endswith(".tar.gz")) + publisher.reserve(bundle, self.store) + receipt = json.loads((bundle / "reservation.json").read_bytes()) + self.assertEqual(receipt["linux_manifest_sha256"], hashlib.sha256(linux_data).hexdigest()) + self.assertEqual(self.store.objects[self.release_key("publication-linux-manifest.json")], linux_data) + self.assertEqual(self.publish(bundle), "published") + self.assertEqual(self.store.objects["stable/latest.json"], mac_envelope) + self.assertEqual(self.store.objects["stable/linux/latest.json"], (bundle / "linux-envelope.json").read_bytes()) + self.assertEqual(self.store.objects[self.release_key("linux-manifest.json")], (bundle / "linux-envelope.json").read_bytes()) + self.assertNotEqual(self.store.objects["stable/latest.json"], self.store.objects["stable/linux/latest.json"]) + self.store.calls.clear() + self.assertEqual(self.publish(bundle), "already-published") + self.assertFalse(any(call[0] == "put" for call in self.store.calls)) + + def test_mac_only_publication_never_reads_or_writes_linux_pointer(self): + self.store.set("stable/linux/latest.json", b"unrelated Linux pointer") + self.reserve() + self.assertEqual(self.publish(), "published") + self.assertEqual(self.store.objects["stable/linux/latest.json"], b"unrelated Linux pointer") + self.assertFalse(any(call[1] == "stable/linux/latest.json" for call in self.store.calls)) + + def test_linux_v2_rejects_mixed_targets_wrong_suffix_and_different_download(self): + bundle, mac, _ = self.make_bundle("1.2.3", "linux-schema", linux=True) + valid = json.loads((bundle / "linux-manifest.json").read_bytes()) + for variant in ("mac-target", "mac-download", "appimage", "different-download", "unknown-field"): + with self.subTest(variant=variant): + value = json.loads(json.dumps(valid)) + if variant == "mac-target": + value["artifacts"]["darwin-aarch64"] = mac["artifacts"]["darwin-aarch64"] + elif variant == "mac-download": + value["downloads"]["macos"] = mac["downloads"]["macos"] + elif variant == "appimage": + value["artifacts"]["linux-x86_64"]["path"] += ".AppImage" + value["downloads"]["linux-bundle"]["path"] += ".AppImage" + elif variant == "different-download": + value["downloads"]["linux-bundle"]["sha256"] = "b" * 64 + else: + value["unknown"] = True + with self.assertRaises(publisher.PublishError): + publisher.validate_manifest(publisher.encode_json(value)) + + def test_dual_feed_identity_and_payloads_fail_before_any_reservation(self): + for variant in ("source_sha", "version", "build", "notes", "pub_date", "missing-payload"): + with self.subTest(variant=variant): + bundle, _, _ = self.make_bundle("1.2.3", "identity-" + variant, linux=True) + if variant == "missing-payload": + (bundle / "payloads/agent-linux.tar.gz").unlink() + else: + value = json.loads((bundle / "linux-manifest.json").read_bytes()) + value[variant] = {"source_sha": "b" * 40, "version": "1.2.4", "build": "43", + "notes": "other notes", "pub_date": "2026-10-22T18:00:00Z"}[variant] + (bundle / "linux-manifest.json").write_bytes(publisher.encode_json(value)) + store = FakeStore() + with self.assertRaises(publisher.PublishError): + publisher.reserve(bundle, store) + self.assertFalse(store.calls) + + def test_linux_reservation_conflict_cannot_authorize_signing(self): + bundle, _, _ = self.make_bundle("1.2.3", "linux-conflict", linux=True) + self.store.set(self.release_key("publication-linux-manifest.json"), b"different bytes") + with self.assertRaisesRegex(publisher.PublishError, "immutable object differs"): + publisher.reserve(bundle, self.store) + self.assertFalse((bundle / "reservation.json").exists()) + self.assertNotIn("stable/latest.json", self.store.objects) + self.assertNotIn("stable/linux/latest.json", self.store.objects) + + def test_linux_partial_promotion_retry_repairs_only_unpromoted_pointer(self): + bundle, _, mac_envelope = self.make_bundle("1.2.3", "linux-interruption", linux=True) + publisher.reserve(bundle, self.store) + self.store.fail_once = "stable/linux/latest.json" + with self.assertRaises(publisher.StoreError): + self.publish(bundle) + self.assertEqual(self.store.objects["stable/latest.json"], mac_envelope) + self.assertNotIn("stable/linux/latest.json", self.store.objects) + self.store.calls.clear() + self.assertEqual(self.publish(bundle), "published") + writes = [call[1] for call in self.store.calls if call[0] == "put"] + self.assertEqual(writes, ["stable/linux/latest.json"]) + + def test_linux_pointer_race_preserves_competitor_and_retry_cannot_roll_it_back(self): + bundle, _, _ = self.make_bundle("1.2.3", "linux-race", linux=True) + competitor, _, _ = self.make_bundle("1.2.4", "linux-competitor", linux=True) + competing_envelope = (competitor / "linux-envelope.json").read_bytes() + publisher.reserve(bundle, self.store) + self.store.before_linux_pointer = lambda: self.store.set("stable/linux/latest.json", competing_envelope) + with self.assertRaisesRegex(publisher.PublishError, "changed concurrently"): + self.publish(bundle) + self.assertEqual(self.store.objects["stable/linux/latest.json"], competing_envelope) + self.store.calls.clear() + with self.assertRaisesRegex(publisher.PublishError, "must advance"): + self.publish(bundle) + self.assertFalse(any(call[0] == "put" for call in self.store.calls)) + + def test_dual_retry_repairs_missing_linux_objects_without_replacing_pointers(self): + bundle, _, _ = self.make_bundle("1.2.3", "linux-repair", linux=True) + publisher.reserve(bundle, self.store) + self.publish(bundle) + linux = json.loads((bundle / "linux-manifest.json").read_bytes()) + missing = linux["artifacts"]["linux-x86_64"]["path"][len("/agent/"):] + del self.store.objects[missing] + del self.store.objects[self.release_key("linux-manifest.json")] + before = {key: self.store.get(key) for key in ("stable/latest.json", "stable/linux/latest.json")} + self.store.calls.clear() + self.assertEqual(self.publish(bundle), "already-published") + self.assertEqual({call[1] for call in self.store.calls if call[0] == "put"}, + {missing, self.release_key("linux-manifest.json")}) + self.assertEqual(before, {key: self.store.get(key) for key in before}) + + def test_linux_reservation_recovers_existing_envelope_exactly(self): + bundle, _, _ = self.make_bundle("1.2.3", "linux-existing", linux=True) + publisher.reserve(bundle, self.store) + self.publish(bundle) + envelope = (bundle / "linux-envelope.json").read_bytes() + (bundle / "linux-envelope.json").unlink() + publisher.reserve(bundle, self.store) + self.assertEqual((bundle / "linux-envelope.json").read_bytes(), envelope) + def test_metadata_missing_corrupt_unchecked_and_symlink_files_fail_before_reservation(self): for variant in ("missing", "corrupt", "unchecked", "symlink"): with self.subTest(variant=variant): @@ -723,6 +863,32 @@ def test_live_verification_covers_public_feed_immutable_head_range_and_download_ for method, range_header in (("HEAD", None), ("GET", "bytes=0-0"), ("GET", None)): self.assertIn((method, record["path"], range_header), origin.calls) + def test_live_dual_feed_verification_checks_linux_envelope_payload_and_alias(self): + bundle, manifest, envelope = self.make_bundle("1.2.3", "dual-live", linux=True) + origin = FixtureOrigin(bundle, manifest, 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") + for path in ("/agent/stable/linux/latest.json", publisher.public_path("1.2.3", "linux-manifest.json"), + "/download/agent/stable/linux"): + self.assertIn(("GET", path, None), origin.calls) + record = json.loads((bundle / "linux-manifest.json").read_bytes())["artifacts"]["linux-x86_64"] + for method, range_header in (("HEAD", None), ("GET", "bytes=0-0"), ("GET", None)): + self.assertIn((method, record["path"], range_header), origin.calls) + + def test_linux_bytes_cannot_change_after_reservation_to_obtain_a_signature(self): + bundle, _, _ = self.make_bundle("1.2.3", "changed-linux-sign", linux=True) + publisher.reserve(bundle, self.store) + changed = json.loads((bundle / "linux-manifest.json").read_bytes()) + changed["artifacts"]["linux-x86_64"]["path"] = publisher.public_path("1.2.3", "other-linux.tar.gz") + changed["downloads"]["linux-bundle"] = changed["artifacts"]["linux-x86_64"] + (bundle / "payloads/agent-linux.tar.gz").rename(bundle / "payloads/other-linux.tar.gz") + (bundle / "linux-manifest.json").write_bytes(publisher.encode_json(changed)) + with patch.object(publisher.subprocess, "run") as signer, \ + self.assertRaisesRegex(publisher.PublishError, "not been reserved"): + publisher.sign(bundle, self.root / "unused-secret", bundle / "public-key.pub") + signer.assert_not_called() + def test_live_verification_sends_native_headers_through_urllib_for_every_request(self): origin = FixtureOrigin(self.bundle, self.manifest, self.envelope) requests = [] @@ -1025,6 +1191,123 @@ def test_pax_sparse_variants_are_rejected_before_stdlib_sparse_map_parsing(self) self.assertFalse(self.attested) +class LinuxPackageBoundaryTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.directory = Path(self.temporary.name) + profiles = json.loads((publisher.ROOT / "apps/maple-agent/release-profiles.json").read_text()) + spec = importlib.util.spec_from_file_location("linux_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) + 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.LINUX_FEED_URL}) + self.stem = "maple-agent-prod-1.2.3-" + SOURCE_SHA[:9] + "-linux-x86_64" + self.descriptor = {"format": "maple-agent-linux-bundle-v1", "product": "maple-agent", + "profile": "prod", "version": "1.2.3", "build": "42", + "source_sha": SOURCE_SHA, "bundle_id": "cloud.opensecret.maple.agent", + "entrypoint": "AppRun", "executable": "usr/bin/maple-agent"} + metadata = {"schema_version": 1, "profile": "prod", "platform": "linux-x86_64", + "source_sha": SOURCE_SHA, "version": "1.2.3", "package": self.stem + ".tar.gz", + "distribution": "master", "macos_signing": None, "linux_integrity": "sha256"} + (self.directory / "build-info.json").write_bytes(publisher.encode_json(self.info)) + (self.directory / "manifest.json").write_bytes(publisher.encode_json(metadata)) + self.write_archive() + self.checksums() + self.attested = [] + + def write_archive(self, entries=None): + if entries is None: + entries = [("maple-agent.app/bundle.json", tarfile.REGTYPE, publisher.encode_json(self.descriptor))] + with tarfile.open(self.directory / (self.stem + ".tar.gz"), "w:gz") as archive: + for name, kind, data in entries: + member = tarfile.TarInfo(name) + member.type, member.mode = kind, 0o644 + if kind == tarfile.REGTYPE: + member.size = len(data) + archive.addfile(member, io.BytesIO(data)) + else: + member.linkname = "other.json" + 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, "linux-x86_64", SOURCE_SHA, "1.2.3", + attestor=lambda path, source: self.attested.append((path.name, source)), + expected_build="42") + + def test_actual_tar_descriptor_binds_build_source_identity_and_attests_all_four_files(self): + records = self.validate() + self.assertEqual(len(records), 1) + self.assertEqual(records[0][0].name, self.stem + ".tar.gz") + self.assertEqual(len(self.attested), 4) + self.assertTrue(all(source == SOURCE_SHA for _, source in self.attested)) + + def test_wrong_or_extra_descriptor_identity_fields_fail_before_attestation(self): + original = dict(self.descriptor) + for field, value in (("build", "43"), ("source_sha", "b" * 40), ("version", "1.2.4"), + ("profile", "dev"), ("entrypoint", "other"), ("executable", "other"), + ("bundle_id", "other"), ("extra", True)): + with self.subTest(field=field): + self.descriptor = {**original, field: value} + self.write_archive() + self.checksums() + with self.assertRaisesRegex(publisher.PublishError, "differs from selected run"): + self.validate() + self.assertFalse(self.attested) + + def test_missing_duplicate_nonregular_or_oversized_linux_descriptor_fails(self): + entry = ("maple-agent.app/bundle.json", tarfile.REGTYPE, publisher.encode_json(self.descriptor)) + for entries in ([], [entry, entry], [(entry[0], tarfile.SYMTYPE, b"")], + [(entry[0], tarfile.REGTYPE, b"null"), entry], + [(entry[0], tarfile.REGTYPE, bytes(publisher.MAX_MANIFEST + 1))]): + with self.subTest(entries=len(entries)): + self.write_archive(entries) + self.checksums() + with self.assertRaises(publisher.PublishError): + self.validate() + self.assertFalse(self.attested) + + def test_actual_preparation_keeps_mac_wire_compatible_and_reserves_linux_separately(self): + mac = PackageBoundaryTests() + mac.setUp() + self.addCleanup(mac.doCleanups) + notes = self.directory.parent / (self.directory.name + "-notes") + notes.write_text("release notes") + self.addCleanup(notes.unlink) + output = self.directory / "prepared" + key = self.directory.parent / (self.directory.name + "-key") + key.write_text(PUBLIC_KEY + "\n") + self.addCleanup(key.unlink) + proof = {"run_id": 123, "run_attempt": 1, "source_sha": SOURCE_SHA, "build": "42", + "pub_date": "2026-10-21T18:00:00Z", "artifacts": {"macos": "mac", "linux": "linux"}} + 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), \ + 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, self.directory, notes, output, key) + pins.assert_called_once_with(key, SOURCE_SHA, True) + self.assertEqual(set(prepared["artifacts"]), {"darwin-aarch64"}) + self.assertEqual(set(prepared["downloads"]), {"macos"}) + linux = publisher.validate_manifest((output / "linux-manifest.json").read_bytes()) + self.assertEqual(linux["format"], "maple-agent-update-v2") + self.assertEqual(linux["build"], prepared["build"]) + self.assertEqual(linux["source_sha"], prepared["source_sha"]) + self.assertEqual(len(list((output / "payloads").iterdir())), 3) + store = FakeStore() + publisher.reserve(output, store) + self.assertEqual(store.objects[publisher.release_key("1.2.3", "publication-manifest.json")], (output / "manifest.json").read_bytes()) + self.assertEqual(store.objects[publisher.release_key("1.2.3", "publication-linux-manifest.json")], (output / "linux-manifest.json").read_bytes()) + + class TrustedRunTests(unittest.TestCase): def setUp(self): self.run = { @@ -1053,7 +1336,7 @@ def test_macos_requires_concrete_completed_prod_jobs(self): def test_linux_is_optional_but_each_linux_gate_is_required_when_selected(self): with self.assertRaises(publisher.PublishError): publisher.validate_run(self.run, self.jobs(), linux=True) - names = self.names | {"Linux AppImage (prod)", "Attest Linux packages (prod)", "Verify downloaded Linux (prod)"} + names = self.names | {"Linux bundle (prod)", "Attest Linux packages (prod)", "Verify downloaded Linux (prod)"} publisher.validate_run(self.run, self.jobs(names), linux=True) for name in names - self.names: with self.subTest(name=name), self.assertRaises(publisher.PublishError): diff --git a/services/agent-updates/publisher/test_trust.py b/services/agent-updates/publisher/test_trust.py index bcd9bbf42..096beb1ed 100644 --- a/services/agent-updates/publisher/test_trust.py +++ b/services/agent-updates/publisher/test_trust.py @@ -25,7 +25,7 @@ def setUp(self): self.jobs = {"total_count": 5, "jobs": [ {"name": name, "status": "completed", "conclusion": "success"} for name in ("Signed macOS (prod)", "Verify downloaded macOS (prod)", - "Linux AppImage (prod)", "Attest Linux packages (prod)", + "Linux bundle (prod)", "Attest Linux packages (prod)", "Verify downloaded Linux (prod)") ]} @@ -121,6 +121,18 @@ def test_feed_mismatch_and_missing_pins_fail(self): with self.assertRaises(p.PublishError): p.validate_pins(self.key, self.profile, bad) + def test_linux_feed_is_pinned_on_both_profiles_only_when_linux_selected(self): + # Historical Mac-only producers need no Linux profile field. + p.validate_pins(self.key, self.profile, deepcopy(self.profile), False) + profile = deepcopy(self.profile) + profile["prod"]["linux_update_feed_url"] = p.LINUX_FEED_URL + p.validate_pins(self.key, profile, deepcopy(profile), True) + for side in (0, 1): + profiles = [deepcopy(profile), deepcopy(profile)] + profiles[side]["prod"]["linux_update_feed_url"] = p.FEED_URL + with self.subTest(side=side), self.assertRaisesRegex(p.PublishError, "Linux update feed"): + p.validate_pins(self.key, *profiles, linux=True) + def test_source_profile_is_read_at_exact_source_sha(self): with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) diff --git a/services/agent-updates/src/linux-feed.test.ts b/services/agent-updates/src/linux-feed.test.ts new file mode 100644 index 000000000..fc255ea29 --- /dev/null +++ b/services/agent-updates/src/linux-feed.test.ts @@ -0,0 +1,410 @@ +import { expect, test } from "bun:test"; +import { encodeFixture, fixture, PAYLOAD } from "./fixtures"; +import { encodeLinuxFixture, linuxFixture } from "./linux-fixtures"; +import { parseEnvelope, parseManifest } from "./manifest"; +import worker, { type Bucket } from "./worker"; + +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: '"linux-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: '"linux-fixture"' } : null; + } +} + +function setup() { + const bucket = new MemoryBucket(); + const mac = fixture(true); + const linux = linuxFixture(); + bucket.objects.set("stable/latest.json", mac.bytes); + bucket.objects.set("stable/linux/latest.json", linux.bytes); + bucket.objects.set("stable/releases/0.0.2/manifest.json", mac.bytes); + bucket.objects.set("stable/releases/0.0.2/linux-manifest.json", linux.bytes); + for (const manifest of [mac.manifest, linux.manifest]) + for (const artifact of Object.values(manifest.artifacts)) + 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 }; +} + +test("macOS and Linux latest feeds return their own exact envelope and uncached HEAD", async () => { + const { fetch, mac, linux, bucket } = setup(); + for (const [path, key, bytes] of [ + ["/agent/stable/latest.json", "stable/latest.json", mac.bytes], + [ + "/agent/stable/linux/latest.json", + "stable/linux/latest.json", + linux.bytes, + ], + ] as const) { + for (const method of ["GET", "HEAD"]) { + bucket.calls = []; + const result = await fetch(path, { 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(bytes.length)); + expect(new Uint8Array(await result.arrayBuffer())).toEqual( + method === "HEAD" ? new Uint8Array() : bytes, + ); + expect(bucket.calls).toEqual([`get:${key}`]); + } + } +}); + +test("each download alias uses only its platform feed and remains uncached", async () => { + const { fetch, bucket, mac, linux } = setup(); + for (const [alias, key, artifact] of [ + ["macos", "stable/latest.json", mac.manifest.downloads.macos], + [ + "linux-appimage", + "stable/latest.json", + mac.manifest.downloads["linux-appimage"]!, + ], + [ + "linux", + "stable/linux/latest.json", + linux.manifest.downloads["linux-bundle"], + ], + ] as const) { + for (const method of ["GET", "HEAD"]) { + bucket.calls = []; + const result = await fetch(`/download/agent/stable/${alias}`, { method }); + expect(result.status).toBe(302); + expect(result.headers.get("location")).toBe(artifact.path); + expect(result.headers.get("cache-control")).toBe("no-store"); + expect(result.headers.get("cdn-cache-control")).toBe("no-store"); + expect(bucket.calls).toEqual([`get:${key}`]); + } + } + bucket.objects.delete("stable/linux/latest.json"); + expect((await fetch("/download/agent/stable/linux")).status).toBe(404); + expect((await fetch("/download/agent/stable/linux-appimage")).status).toBe( + 302, + ); + bucket.objects.delete("stable/latest.json"); + expect((await fetch("/download/agent/stable/linux-appimage")).status).toBe( + 404, + ); +}); + +test("latest and immutable metadata cannot cross formats or fall back to another feed", async () => { + for (const [key, paths, wrong] of [ + [ + "stable/latest.json", + [ + "/agent/stable/latest.json", + "/download/agent/stable/macos", + "/download/agent/stable/linux-appimage", + ], + linuxFixture().bytes, + ], + [ + "stable/linux/latest.json", + ["/agent/stable/linux/latest.json", "/download/agent/stable/linux"], + fixture().bytes, + ], + [ + "stable/releases/0.0.2/manifest.json", + ["/agent/stable/releases/0.0.2/manifest.json"], + linuxFixture().bytes, + ], + [ + "stable/releases/0.0.2/linux-manifest.json", + ["/agent/stable/releases/0.0.2/linux-manifest.json"], + fixture().bytes, + ], + ] as const) { + const { fetch, bucket } = setup(); + bucket.objects.set(key, wrong); + 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(result.headers.get("cdn-cache-control")).toBe("no-store"); + expect(bucket.calls).toEqual([`get:${key}`]); + } + bucket.objects.delete(key); + for (const path of paths) expect((await fetch(path)).status).toBe(404); + } +}); + +test("Linux immutable metadata is byte-exact and bound to the requested version", async () => { + const { fetch, bucket, linux } = setup(); + for (const method of ["GET", "HEAD"]) { + const result = await fetch( + "/agent/stable/releases/0.0.2/linux-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() : linux.bytes, + ); + } + bucket.objects.set("stable/releases/0.0.3/linux-manifest.json", linux.bytes); + expect( + (await fetch("/agent/stable/releases/0.0.3/linux-manifest.json")).status, + ).toBe(503); +}); + +test("corrupt Linux discovery fails closed without consulting the macOS feed", async () => { + const { fetch, bucket } = setup(); + bucket.objects.set("stable/linux/latest.json", new Uint8Array([0xff])); + for (const path of [ + "/agent/stable/linux/latest.json", + "/download/agent/stable/linux", + ]) { + bucket.calls = []; + const result = await fetch(path); + expect(result.status).toBe(503); + expect(await result.text()).toBe("503\n"); + expect(result.headers.get("cache-control")).toBe("no-store"); + expect(bucket.calls).toEqual(["get:stable/linux/latest.json"]); + } + expect((await fetch("/agent/stable/latest.json")).status).toBe(200); +}); + +test("Linux artifact streams through its version-specific signed enumeration", async () => { + const { fetch, bucket, linux } = setup(); + const path = linux.manifest.artifacts["linux-x86_64"].path; + const full = await fetch(path); + expect(full.status).toBe(200); + expect(full.headers.get("content-type")).toBe("application/gzip"); + expect(full.headers.get("cache-control")).toContain("immutable"); + expect(new Uint8Array(await full.arrayBuffer())).toEqual(PAYLOAD); + expect(bucket.calls).toEqual([ + "get:stable/releases/0.0.2/manifest.json", + "get:stable/releases/0.0.2/linux-manifest.json", + `head:${path.replace("/agent/", "")}`, + `get:${path.replace("/agent/", "")}`, + ]); + const range = await fetch(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(path, { headers: { range: "bytes=9999-" } })).status, + ).toBe(416); + expect( + (await fetch(path, { headers: { "if-none-match": '"linux-fixture"' } })) + .status, + ).toBe(304); + expect( + ( + await fetch(path, { + headers: { range: "bytes=2-6", "if-range": '"changed"' }, + }) + ).status, + ).toBe(200); + bucket.calls = []; + const head = await fetch(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(""); + expect(bucket.calls).not.toContain(`get:${path.replace("/agent/", "")}`); + bucket.objects.set(path.replace("/agent/", ""), new Uint8Array([1])); + expect((await fetch(path)).status).toBe(503); +}); + +test("v1 artifact hits do not consult Linux; missing v1 permits Linux-only releases", async () => { + const { fetch, bucket, mac, linux } = setup(); + bucket.objects.set( + "stable/releases/0.0.2/linux-manifest.json", + new Uint8Array([0xff]), + ); + const macPath = mac.manifest.artifacts["darwin-aarch64"].path; + expect((await fetch(macPath)).status).toBe(200); + expect(bucket.calls).not.toContain( + "get:stable/releases/0.0.2/linux-manifest.json", + ); + bucket.objects.set("stable/releases/0.0.2/linux-manifest.json", linux.bytes); + bucket.objects.delete("stable/releases/0.0.2/manifest.json"); + expect( + (await fetch(linux.manifest.artifacts["linux-x86_64"].path)).status, + ).toBe(200); + expect((await fetch(macPath)).status).toBe(404); +}); + +test("Linux discovery cannot authorize a release without its complete immutable manifest", async () => { + const { fetch, bucket, linux } = setup(); + const path = linux.manifest.artifacts["linux-x86_64"].path; + bucket.objects.delete("stable/releases/0.0.2/linux-manifest.json"); + expect((await fetch("/agent/stable/linux/latest.json")).status).toBe(200); + bucket.calls = []; + expect((await fetch(path)).status).toBe(404); + expect(bucket.calls).toEqual([ + "get:stable/releases/0.0.2/manifest.json", + "get:stable/releases/0.0.2/linux-manifest.json", + ]); +}); + +test("corrupt consulted release metadata never falls through to a healthy sibling", async () => { + for (const bad of [new Uint8Array([0xff]), linuxFixture().bytes]) { + const { fetch, bucket, linux } = setup(); + bucket.objects.set("stable/releases/0.0.2/manifest.json", bad); + bucket.calls = []; + expect( + (await fetch(linux.manifest.artifacts["linux-x86_64"].path)).status, + ).toBe(503); + expect(bucket.calls).toEqual(["get:stable/releases/0.0.2/manifest.json"]); + } + const { fetch, bucket, linux } = setup(); + bucket.objects.set( + "stable/releases/0.0.2/linux-manifest.json", + new Uint8Array([0xff]), + ); + expect( + (await fetch(linux.manifest.artifacts["linux-x86_64"].path)).status, + ).toBe(503); + bucket.objects.set("stable/releases/0.0.3/manifest.json", fixture().bytes); + bucket.objects.set("stable/releases/0.0.3/linux-manifest.json", linux.bytes); + bucket.calls = []; + expect( + ( + await fetch( + "/agent/stable/releases/0.0.3/Maple-Agent-linux-x86_64.tar.gz", + ) + ).status, + ).toBe(503); + expect(bucket.calls).toEqual(["get:stable/releases/0.0.3/manifest.json"]); +}); + +test("Linux paths preserve private-object and strict route boundaries", async () => { + const { fetch, bucket } = setup(); + for (const file of [ + "publication-manifest.json", + "publication-metadata.json", + "private.tar.gz", + "metadata/linux-build-info.json", + ]) { + 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/linux/latest.json?x=y", + "/agent/stable/linux//latest.json", + "/agent/stable/linux%2flatest.json", + "/download/agent/stable/linux-bundle", + ]) { + bucket.calls = []; + expect((await fetch(path)).status).toBe(404); + expect(bucket.calls).toEqual([]); + } + const post = await fetch("/agent/stable/linux/latest.json", { + method: "POST", + }); + expect(post.status).toBe(405); + expect(post.headers.get("allow")).toBe("GET, HEAD"); +}); + +test("v2 permits only the exact Linux tar bundle target and matching download", () => { + expect(parseEnvelope(linuxFixture().bytes).manifest.format).toBe( + "maple-agent-update-v2", + ); + const mutations = [ + (v: any) => { + v.format = "maple-agent-update-v3"; + }, + (v: any) => { + v.extra = true; + }, + (v: any) => { + v.artifacts["darwin-aarch64"] = v.artifacts["linux-x86_64"]; + }, + (v: any) => { + v.downloads.macos = v.downloads["linux-bundle"]; + }, + (v: any) => { + v.downloads["linux-appimage"] = v.downloads["linux-bundle"]; + }, + (v: any) => { + delete v.artifacts["linux-x86_64"]; + }, + (v: any) => { + delete v.downloads["linux-bundle"]; + }, + (v: any) => { + v.downloads["linux-bundle"].path += ".AppImage"; + }, + (v: any) => { + v.artifacts["linux-x86_64"].path = v.downloads["linux-bundle"].path = + "/agent/stable/releases/0.0.2/Agent.AppImage"; + }, + (v: any) => { + v.downloads["linux-bundle"].path = + "/agent/stable/releases/0.0.2/other.tar.gz"; + }, + (v: any) => { + v.downloads["linux-bundle"].size += 1; + }, + (v: any) => { + v.downloads["linux-bundle"].sha256 = "d".repeat(64); + }, + (v: any) => { + v.artifacts["linux-x86_64"].extra = true; + }, + (v: any) => { + v.product = "research"; + }, + (v: any) => { + v.artifacts["linux-x86_64"].path = + "/agent/stable/releases/0.0.1/Agent.tar.gz"; + }, + ]; + for (const mutate of mutations) { + const { manifest } = linuxFixture(); + mutate(manifest); + expect(() => parseEnvelope(encodeLinuxFixture(manifest))).toThrow(); + } +}); + +test("historical 0.0.3 v1 bytes and optional AppImage remain accepted without reinterpretation", () => { + const { manifest } = fixture(true); + manifest.version = "0.0.3"; + for (const artifact of [ + ...Object.values(manifest.artifacts), + ...Object.values(manifest.downloads), + ]) + artifact.path = artifact.path.replace("/0.0.2/", "/0.0.3/"); + const bytes = encodeFixture(manifest); + expect(parseEnvelope(bytes).manifest).toEqual(manifest); + expect(parseManifest(manifest).format).toBe("maple-agent-update-v1"); + const linux = linuxFixture().manifest; + (linux as any).format = "maple-agent-update-v1"; + expect(() => parseManifest(linux)).toThrow(); +}); diff --git a/services/agent-updates/src/linux-fixtures.ts b/services/agent-updates/src/linux-fixtures.ts new file mode 100644 index 000000000..c1e908fcf --- /dev/null +++ b/services/agent-updates/src/linux-fixtures.ts @@ -0,0 +1,32 @@ +import type { ManifestV2 } from "./manifest"; +import { fixture, PAYLOAD } from "./fixtures"; + +export function encodeLinuxFixture( + manifest: ManifestV2, +): 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 linuxFixture(): { + manifest: ManifestV2; + bytes: Uint8Array; +} { + const artifact = { + path: "/agent/stable/releases/0.0.2/Maple-Agent-linux-x86_64.tar.gz", + size: PAYLOAD.length, + sha256: "c".repeat(64), + }; + const manifest: ManifestV2 = { + ...fixture().manifest, + format: "maple-agent-update-v2", + artifacts: { "linux-x86_64": artifact }, + downloads: { "linux-bundle": { ...artifact } }, + }; + return { manifest, bytes: encodeLinuxFixture(manifest) }; +} diff --git a/services/agent-updates/src/manifest.ts b/services/agent-updates/src/manifest.ts index e41376def..e342b34d0 100644 --- a/services/agent-updates/src/manifest.ts +++ b/services/agent-updates/src/manifest.ts @@ -10,8 +10,7 @@ export interface Artifact { size: number; sha256: string; } -export interface Manifest { - format: "maple-agent-update-v1"; +interface ManifestFields { product: "maple-agent"; channel: "stable"; version: string; @@ -19,9 +18,18 @@ export interface Manifest { source_sha: string; pub_date: string; notes: string; +} +export interface Manifest extends ManifestFields { + format: "maple-agent-update-v1"; artifacts: { "darwin-aarch64": Artifact; "linux-x86_64"?: Artifact }; downloads: { macos: Artifact; "linux-appimage"?: Artifact }; } +export interface ManifestV2 extends ManifestFields { + format: "maple-agent-update-v2"; + artifacts: { "linux-x86_64": Artifact }; + downloads: { "linux-bundle": Artifact }; +} +export type UpdateManifest = Manifest | ManifestV2; export interface Envelope { manifest: string; signature: string; @@ -72,7 +80,7 @@ function artifact( ); } -export function parseManifest(value: unknown): Manifest { +export function parseManifest(value: unknown): UpdateManifest { if ( !record(value) || !keys(value, [ @@ -87,7 +95,9 @@ export function parseManifest(value: unknown): Manifest { "artifacts", "downloads", ]) || - value.format !== "maple-agent-update-v1" || + !["maple-agent-update-v1", "maple-agent-update-v2"].includes( + value.format as string, + ) || value.product !== "maple-agent" || value.channel !== "stable" || typeof value.version !== "string" || @@ -106,8 +116,27 @@ export function parseManifest(value: unknown): Manifest { !scalarText(value.notes) || /[\x00-\x08\x0b-\x1f\x7f]/.test(value.notes) || !record(value.artifacts) || + !record(value.downloads) + ) + throw new Error("invalid manifest"); + if (value.format === "maple-agent-update-v2") { + const linux = value.artifacts["linux-x86_64"]; + const download = value.downloads["linux-bundle"]; + if ( + !keys(value.artifacts, ["linux-x86_64"]) || + !keys(value.downloads, ["linux-bundle"]) || + !artifact(linux, value.version, ".tar.gz") || + !artifact(download, value.version, ".tar.gz") || + linux.path !== download.path || + linux.size !== download.size || + linux.sha256 !== download.sha256 + ) + throw new Error("invalid Linux bundle manifest"); + return value as unknown as ManifestV2; + } + // Keep the shipped v1 target/path rules, including optional AppImages. + if ( !keys(value.artifacts, ["darwin-aarch64", "linux-x86_64"]) || - !record(value.downloads) || !keys(value.downloads, ["macos", "linux-appimage"]) || !artifact( value.artifacts["darwin-aarch64"], @@ -137,7 +166,7 @@ export function parseManifest(value: unknown): Manifest { // authenticate exact bytes with pinned keys; R2 write authority is not signing authority. export function parseEnvelope(bytes: Uint8Array): { envelope: Envelope; - manifest: Manifest; + manifest: UpdateManifest; } { if (bytes.length === 0 || bytes.length > MAX_ENVELOPE_BYTES) throw new Error("oversized envelope"); diff --git a/services/agent-updates/src/runtime.test.ts b/services/agent-updates/src/runtime.test.ts index 6e638cda4..e2d4538d3 100644 --- a/services/agent-updates/src/runtime.test.ts +++ b/services/agent-updates/src/runtime.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { expect, test } from "bun:test"; import { fixture, PAYLOAD } from "./fixtures"; +import { linuxFixture } from "./linux-fixtures"; // Miniflare is Wrangler's exact locked runtime dependency. Node hosts the // harness; assertions make real requests through workerd and its R2 binding. @@ -13,7 +14,7 @@ const server = ` process.on('SIGTERM', async()=>{await mf.dispose(); process.exit(0);}); process.send({origin:(await mf.ready).origin}); `; -test("real workerd R2 binding streams exact immutable bytes, ranges, HEAD and redirects", async () => { +test("real workerd R2 binding serves separate feeds, immutable bytes, ranges, HEAD and redirects", async () => { const directory = await mkdtemp(join(tmpdir(), "agent-updates-workerd-")); let child: ReturnType | undefined; let timer: ReturnType | undefined; @@ -25,12 +26,16 @@ test("real workerd R2 binding streams exact immutable bytes, ranges, HEAD and re 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,payload} = await request.json(); + const {metadata,linuxMetadata,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); await env.AGENT_RELEASES.put('stable/releases/0.0.2/Maple-Agent.app.tar.gz',Uint8Array.from(payload)); await env.AGENT_RELEASES.put('stable/releases/0.0.2/Maple-Agent.dmg',Uint8Array.from(payload)); + 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/releases/0.0.2/publication-manifest.json',Uint8Array.from(payload)); return new Response('seeded'); } return worker.fetch(request,env); @@ -78,6 +83,7 @@ test("real workerd R2 binding streams exact immutable bytes, ranges, HEAD and re method: "POST", body: JSON.stringify({ metadata: Array.from(fixture().bytes), + linuxMetadata: Array.from(linuxFixture().bytes), payload: Array.from(PAYLOAD), }), }); @@ -108,6 +114,48 @@ test("real workerd R2 binding streams exact immutable bytes, ranges, HEAD and re expect(head.status).toBe(200); expect(head.headers.get("content-length")).toBe(String(PAYLOAD.length)); expect(await head.text()).toBe(""); + const linuxLatest = await fetch(`${origin}/agent/stable/linux/latest.json`); + expect(linuxLatest.status).toBe(200); + expect(linuxLatest.headers.get("cdn-cache-control")).toBe("no-store"); + expect(new Uint8Array(await linuxLatest.arrayBuffer())).toEqual( + linuxFixture().bytes, + ); + const linuxRedirect = await fetch(`${origin}/download/agent/stable/linux`, { + redirect: "manual", + }); + expect(linuxRedirect.status).toBe(302); + expect(linuxRedirect.headers.get("cache-control")).toBe("no-store"); + expect(linuxRedirect.headers.get("location")).toBe( + linuxFixture().manifest.artifacts["linux-x86_64"].path, + ); + const linuxArtifact = `${origin}${linuxFixture().manifest.artifacts["linux-x86_64"].path}`; + const linuxRange = await fetch(linuxArtifact, { + headers: { range: "bytes=2-7" }, + }); + expect(linuxRange.status).toBe(206); + expect(new Uint8Array(await linuxRange.arrayBuffer())).toEqual( + PAYLOAD.slice(2, 8), + ); + const linuxHead = await fetch(linuxArtifact, { method: "HEAD" }); + expect(linuxHead.status).toBe(200); + expect(linuxHead.headers.get("content-length")).toBe( + String(PAYLOAD.length), + ); + expect(await linuxHead.text()).toBe(""); + const linuxManifest = await fetch( + `${origin}/agent/stable/releases/0.0.2/linux-manifest.json`, + ); + expect(linuxManifest.status).toBe(200); + expect(new Uint8Array(await linuxManifest.arrayBuffer())).toEqual( + linuxFixture().bytes, + ); + expect( + ( + await fetch( + `${origin}/agent/stable/releases/0.0.2/publication-manifest.json`, + ) + ).status, + ).toBe(404); expect( (await fetch(`${origin}/download/agent/stable/linux-appimage`)).status, ).toBe(404); diff --git a/services/agent-updates/src/worker.ts b/services/agent-updates/src/worker.ts index a899f838e..c9401c0d8 100644 --- a/services/agent-updates/src/worker.ts +++ b/services/agent-updates/src/worker.ts @@ -3,7 +3,7 @@ import { parseEnvelope, releasePath, type Artifact, - type Manifest, + type UpdateManifest, } from "./manifest"; interface StoredObject { @@ -41,10 +41,14 @@ function response( }, }); } -async function metadata( +async function metadata( bucket: Bucket, key: string, -): Promise<{ bytes: Uint8Array; manifest: Manifest } | null> { + format: Format, +): Promise<{ + bytes: Uint8Array; + manifest: Extract; +} | null> { const object = await bucket.get(key); if (!object) return null; if (object.size === 0 || object.size > MAX_ENVELOPE_BYTES || !object.body) { @@ -74,7 +78,29 @@ async function metadata( bytes.set(chunk, offset); offset += chunk.length; } - return { bytes, manifest: parseEnvelope(bytes).manifest }; + const manifest = parseEnvelope(bytes).manifest; + if (manifest.format !== format) throw new Error("manifest format mismatch"); + return { + bytes, + manifest: manifest as Extract, + }; +} + +function metadataResponse( + request: Request, + bytes: Uint8Array, + immutable = false, +): Response { + return new Response(request.method === "HEAD" ? null : bytes, { + headers: { + ...(immutable + ? { "cache-control": "public, max-age=31536000, immutable" } + : NO_STORE), + "content-type": "application/json; charset=utf-8", + "content-length": String(bytes.length), + "x-content-type-options": "nosniff", + }, + }); } function requestedRange( @@ -185,6 +211,26 @@ export default { ) return response(404, request.method); try { + if ( + path === "/agent/stable/linux/latest.json" || + path === "/download/agent/stable/linux" + ) { + const current = await metadata( + env.AGENT_RELEASES, + "stable/linux/latest.json", + "maple-agent-update-v2", + ); + if (!current) return response(404, request.method); + return path.startsWith("/download/") + ? new Response(null, { + status: 302, + headers: { + ...NO_STORE, + location: current.manifest.downloads["linux-bundle"].path, + }, + }) + : metadataResponse(request, current.bytes); + } if ( path === "/agent/stable/latest.json" || path.startsWith("/download/agent/stable/") @@ -200,6 +246,7 @@ export default { const current = await metadata( env.AGENT_RELEASES, "stable/latest.json", + "maple-agent-update-v1", ); if (!current) return response(404, request.method); if (path.startsWith("/download/")) { @@ -213,47 +260,48 @@ export default { }) : response(404, request.method); } - return new Response(request.method === "HEAD" ? null : current.bytes, { - headers: { - ...NO_STORE, - "content-type": "application/json; charset=utf-8", - "content-length": String(current.bytes.length), - }, - }); + return metadataResponse(request, current.bytes); } const release = releasePath(path); if (!release) return response(404, request.method); - const published = await metadata( - env.AGENT_RELEASES, - `stable/releases/${release.version}/manifest.json`, - ); - if (!published) return response(404, request.method); - if (published.manifest.version !== release.version) - throw new Error("version mismatch"); - if (release.file === "manifest.json") - return new Response( - request.method === "HEAD" ? null : published.bytes, - { - headers: { - "cache-control": "public, max-age=31536000, immutable", - "content-type": "application/json; charset=utf-8", - "content-length": String(published.bytes.length), - "x-content-type-options": "nosniff", - }, - }, + // Explicit metadata routes never fall back to their sibling feed. Other + // paths are authorized only by exact enumeration in these two 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); + for (const format of candidates) { + const file = + format === "maple-agent-update-v1" + ? "manifest.json" + : "linux-manifest.json"; + const published = await metadata( + env.AGENT_RELEASES, + `stable/releases/${release.version}/${file}`, + format, ); - const artifact = [ - ...Object.values(published.manifest.artifacts), - ...Object.values(published.manifest.downloads), - ].find((value) => value.path === path); - return artifact - ? await artifactResponse( + if (!published) continue; + if (published.manifest.version !== release.version) + throw new Error("version mismatch"); + if (release.file === file) + return metadataResponse(request, published.bytes, true); + const artifact = [ + ...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. + if (artifact) + return await artifactResponse( request, env.AGENT_RELEASES, release.key, artifact, - ) - : response(404, request.method); + ); + } + return response(404, request.method); } catch { return response(503, request.method); }