diff --git a/.github/workflows/ci-rust-linux.yml b/.github/workflows/ci-rust-linux.yml index 6458339b99..860e82afc6 100644 --- a/.github/workflows/ci-rust-linux.yml +++ b/.github/workflows/ci-rust-linux.yml @@ -15,6 +15,7 @@ on: - "libs/cua-driver/rust/crates/platform-linux/**" - "libs/cua-driver/wayland-helper/**" - "libs/cua-driver/tests/fixtures/**" + - "scripts/ci/linux/test-pacman-updates.sh" - "nix/**" - "flake.nix" - "flake.lock" @@ -34,6 +35,7 @@ on: - "libs/cua-driver/rust/crates/platform-linux/**" - "libs/cua-driver/wayland-helper/**" - "libs/cua-driver/tests/fixtures/**" + - "scripts/ci/linux/test-pacman-updates.sh" - "nix/**" - "flake.nix" - "flake.lock" @@ -48,6 +50,60 @@ concurrency: cancel-in-progress: true jobs: + pacman: + name: Arch native pacman updates + runs-on: ubuntu-latest + container: archlinux:base-devel + timeout-minutes: 30 + defaults: + run: + shell: bash + steps: + - name: Install Arch build and test dependencies + run: | + pacman -Syu --noconfirm --needed git rust clang pkgconf jq \ + dbus libx11 libxi libxtst libxext libxkbcommon wayland \ + xorg-server-xvfb xorg-xauth at-spi2-core + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + - uses: Swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2 + with: + workspaces: "libs/cua-driver/rust -> target" + key: arch-pacman + - name: Build exact candidate and native package tests + working-directory: libs/cua-driver/rust + run: | + set -euo pipefail + cargo test -p cua-driver --test release_channel_cli_test --no-run \ + --locked --features portal-input --message-format=json > pacman-build.jsonl + test_binary=$(jq -r 'select(.reason == "compiler-artifact" and .target.name == "release_channel_cli_test" and .executable != null) | .executable' pacman-build.jsonl) + test -x "$test_binary" + echo "PACMAN_TEST_BINARY=$test_binary" >> "$GITHUB_ENV" + - name: Verify real pacman ownership through CLI and MCP + env: + CUA_E2E_UNRESTRICTED_GUI: "1" + EXPECTED_SOURCE_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + run: | + set -euo pipefail + CUA_E2E_SOURCE_SHA=$(git -c safe.directory="$GITHUB_WORKSPACE" rev-parse HEAD) + test "$CUA_E2E_SOURCE_SHA" = "$EXPECTED_SOURCE_SHA" + export CUA_E2E_SOURCE_SHA + xvfb-run -a dbus-run-session -- env CUA_PACMAN_TEST_DISPOSABLE=1 \ + bash scripts/ci/linux/test-pacman-updates.sh \ + libs/cua-driver/rust/target/debug/cua-driver "$PACMAN_TEST_BINARY" \ + pacman-update-evidence + - name: Upload native package evidence + if: always() + uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4 + with: + name: pacman-update-evidence + if-no-files-found: error + path: | + pacman-update-evidence/*.log + pacman-update-evidence/*.txt + libs/cua-driver/rust/pacman-build.jsonl + unit: name: Rust Linux unit and compile runs-on: ubuntu-latest diff --git a/docs/content/docs/how-to-guides/driver/update.mdx b/docs/content/docs/how-to-guides/driver/update.mdx index 665ea2a4cf..5f6933a1e9 100644 --- a/docs/content/docs/how-to-guides/driver/update.mdx +++ b/docs/content/docs/how-to-guides/driver/update.mdx @@ -11,6 +11,41 @@ Version numbers, timestamps, and release links in the sample output below are illustrative. Use your command's actual output to identify the installed and available releases; the 0.13.0 migration notes describe that historical upgrade. +## Pacman-managed installations + +This behavior requires a build containing the [pacman update fix (#3636)](https://github.com/trycua/cua/pull/3636). +It is not included in the 0.24.0 release. + +If pacman owns the running Cua Driver executable, use your package repository +to update it: + +```bash +sudo pacman -Syu +``` + +Driver does not run this command for you. It skips the upstream update banner +and refuses its vendor installer and stable/nightly channel changes. The package +repository controls which version is available, including its release-age policy. +Having pacman installed alone does not change an independently installed Driver's +update behavior. Driver queries `/usr/bin/pacman` directly, so shell aliases and +executables earlier in `PATH` do not override ownership detection. + +For a pacman-owned executable, `check-update`, `update`, and `update --apply` +return an unavailable-check result with pacman guidance and a nonzero exit code. +The CLI JSON and MCP `check_for_update` payload keep `latest_version`, +`selected_channel`, `install_command`, and `release_notes_url` as `null`, set +`update_available` and `cache_hit` to `false`, and explain the reason in `error`. +This does not mean that the package is up to date: Driver does not query pacman's +repository databases or reuse a cached GitHub release. The `source` field remains +`github_releases`, identifying the upstream check that was not performed. + +`channel status` and `channel set` also return pacman guidance and a nonzero exit +code. Their JSON output sets `selected_channel` to `null` and explains the reason +in `error`. A saved stable/nightly preference is ignored and left unchanged. + +The vendor update and channel instructions that follow apply to installations +not managed by pacman. + ## Before updating from 0.12.x to 0.13.0 The installer command, CLI/MCP connection flow, tool names, and Python and diff --git a/docs/content/docs/reference/cua-driver/mcp-tools-linux.mdx b/docs/content/docs/reference/cua-driver/mcp-tools-linux.mdx index e0db84b05c..5e1c0d7f14 100644 --- a/docs/content/docs/reference/cua-driver/mcp-tools-linux.mdx +++ b/docs/content/docs/reference/cua-driver/mcp-tools-linux.mdx @@ -737,7 +737,7 @@ Stability: schema_version="1" is the contract. Future breaking changes will be ` ### `check_for_update` -Check the saved stable/nightly Cua Driver channel for a release on GitHub. Returns current and selected channels, current and latest versions, an `update_available` boolean, the install one-liner, and the release notes URL. Read-only — never installs. Mirror of `cua-driver check-update --json`. +Check the saved stable/nightly Cua Driver channel for a release on GitHub. Returns current and selected channels, current and latest versions, an `update_available` boolean, the install one-liner, and the release notes URL. Read-only — never installs. Pacman-owned Linux executables return package-manager guidance without checking GitHub. Mirror of `cua-driver check-update --json`. **Arguments:** none. diff --git a/docs/content/docs/reference/cua-driver/mcp-tools-windows.mdx b/docs/content/docs/reference/cua-driver/mcp-tools-windows.mdx index 03e4bbcaae..df4d82bdf2 100644 --- a/docs/content/docs/reference/cua-driver/mcp-tools-windows.mdx +++ b/docs/content/docs/reference/cua-driver/mcp-tools-windows.mdx @@ -851,7 +851,7 @@ Stability: schema_version="1" is the contract. Future breaking changes will be ` ### `check_for_update` -Check the saved stable/nightly Cua Driver channel for a release on GitHub. Returns current and selected channels, current and latest versions, an `update_available` boolean, the install one-liner, and the release notes URL. Read-only — never installs. Mirror of `cua-driver check-update --json`. +Check the saved stable/nightly Cua Driver channel for a release on GitHub. Returns current and selected channels, current and latest versions, an `update_available` boolean, the install one-liner, and the release notes URL. Read-only — never installs. Pacman-owned Linux executables return package-manager guidance without checking GitHub. Mirror of `cua-driver check-update --json`. **Arguments:** none. diff --git a/docs/content/docs/reference/cua-driver/mcp-tools.mdx b/docs/content/docs/reference/cua-driver/mcp-tools.mdx index 3d46e9bd8c..0fa16aeddb 100644 --- a/docs/content/docs/reference/cua-driver/mcp-tools.mdx +++ b/docs/content/docs/reference/cua-driver/mcp-tools.mdx @@ -803,7 +803,7 @@ Stability: schema_version="1" is the contract. Future breaking changes will be ` ### `check_for_update` -Check the saved stable/nightly Cua Driver channel for a release on GitHub. Returns current and selected channels, current and latest versions, an `update_available` boolean, the install one-liner, and the release notes URL. Read-only — never installs. Mirror of `cua-driver check-update --json`. +Check the saved stable/nightly Cua Driver channel for a release on GitHub. Returns current and selected channels, current and latest versions, an `update_available` boolean, the install one-liner, and the release notes URL. Read-only — never installs. Pacman-owned Linux executables return package-manager guidance without checking GitHub. Mirror of `cua-driver check-update --json`. **Arguments:** none. diff --git a/libs/cua-driver/rust/crates/cua-driver/src/check_update_tool.rs b/libs/cua-driver/rust/crates/cua-driver/src/check_update_tool.rs index bc150cef6d..c7375967ae 100644 --- a/libs/cua-driver/rust/crates/cua-driver/src/check_update_tool.rs +++ b/libs/cua-driver/rust/crates/cua-driver/src/check_update_tool.rs @@ -33,7 +33,8 @@ fn def() -> &'static ToolDef { description: "Check the saved stable/nightly Cua Driver channel for a release on GitHub. \ Returns current and selected channels, current and latest versions, an `update_available` boolean, \ the install one-liner, and the release notes URL. Read-only — never \ - installs. Mirror of `cua-driver check-update --json`." + installs. Pacman-owned Linux executables return package-manager guidance \ + without checking GitHub. Mirror of `cua-driver check-update --json`." .into(), input_schema: serde_json::json!({ "type": "object", @@ -69,7 +70,7 @@ impl Tool for CheckForUpdateTool { ); let summary = if let Some(err) = &state.error { - format!("Update check failed: {err}") + format!("Update check unavailable: {err}") } else if state.update_available { let latest = state.latest_version.as_deref().unwrap_or("?"); format!( diff --git a/libs/cua-driver/rust/crates/cua-driver/src/cli.rs b/libs/cua-driver/rust/crates/cua-driver/src/cli.rs index 47eea7d4cb..6d59ad90eb 100644 --- a/libs/cua-driver/rust/crates/cua-driver/src/cli.rs +++ b/libs/cua-driver/rust/crates/cua-driver/src/cli.rs @@ -2984,6 +2984,13 @@ fn run_recording_render(args: &[String]) { /// installer script — see [`crate::updater`] for why we go through the script /// instead of re-implementing the asset resolution + atomic swap + GC in Rust. pub fn run_update_cmd(apply: bool, json: bool) { + if crate::updater::is_pacman_managed() { + print_check_update_state( + crate::version_check::check_update_state_with_ownership(false, true), + json, + ); + return; + } if apply && crate::bundle::is_local_installation() { eprintln!( "cua-driver-local is managed by scripts/install-local.sh (or install-local.ps1); \ @@ -3723,6 +3730,10 @@ fn run_permissions_grant() { /// the payload. pub fn run_check_update_cmd(json: bool, no_cache: bool) { let state = crate::version_check::check_update_state(no_cache); + print_check_update_state(state, json); +} + +fn print_check_update_state(state: crate::version_check::UpdateState, json: bool) { crate::version_check::capture_update_state(&state, crate::telemetry::UpdateCheckSource::Cli); if json { @@ -3748,7 +3759,7 @@ pub fn run_check_update_cmd(json: bool, no_cache: bool) { (None, Some(err)) => { println!("Latest: "); println!(); - println!("Could not reach GitHub: {err}"); + println!("Update check unavailable: {err}"); } (None, None) => { // Network failed AND no cache existed — `error` should be set; @@ -3766,6 +3777,24 @@ pub fn run_check_update_cmd(json: bool, no_cache: bool) { /// Inspect or persist the release channel. Selection never installs by itself; /// replacement remains explicit through `cua-driver update --apply`. pub fn run_channel_cmd(subcommand: &str, value: Option<&str>, json: bool) { + if crate::updater::is_pacman_managed() { + if json { + let current = + crate::release_channel::ReleaseChannel::from_version(env!("CARGO_PKG_VERSION")); + println!( + "{}", + serde_json::json!({ + "selected_channel": null, + "current_channel": current.map(|channel| channel.as_str()), + "current_version": env!("CARGO_PKG_VERSION"), + "error": crate::updater::PACMAN_UPDATE_GUIDANCE, + }) + ); + } else { + eprintln!("{}", crate::updater::PACMAN_UPDATE_GUIDANCE); + } + process::exit(1); + } let result = match subcommand { "status" => crate::release_channel::selected(), "set" => { diff --git a/libs/cua-driver/rust/crates/cua-driver/src/release_channel.rs b/libs/cua-driver/rust/crates/cua-driver/src/release_channel.rs index 35a922e281..a64c205035 100644 --- a/libs/cua-driver/rust/crates/cua-driver/src/release_channel.rs +++ b/libs/cua-driver/rust/crates/cua-driver/src/release_channel.rs @@ -93,6 +93,9 @@ fn selected_at(path: &std::path::Path) -> Result { } pub fn set(channel: ReleaseChannel) -> Result<(), String> { + if crate::updater::is_pacman_managed() { + return Err(crate::updater::PACMAN_UPDATE_GUIDANCE.to_owned()); + } let path = state_path()?; set_at(&path, channel) } diff --git a/libs/cua-driver/rust/crates/cua-driver/src/updater.rs b/libs/cua-driver/rust/crates/cua-driver/src/updater.rs index e5a7dd7186..65a7bfe234 100644 --- a/libs/cua-driver/rust/crates/cua-driver/src/updater.rs +++ b/libs/cua-driver/rust/crates/cua-driver/src/updater.rs @@ -22,6 +22,67 @@ use std::process::{Command, ExitStatus}; +pub(crate) const PACMAN_UPDATE_GUIDANCE: &str = + "This executable is managed by pacman. Update with `sudo pacman -Syu`; \ + release selection and availability are controlled by your package repository. \ + The upstream installer and stable/nightly channel switching are disabled."; + +/// Only positive package ownership disables the upstream updater. Missing +/// pacman, failed queries, and unresolved paths retain unmanaged behavior. +pub(crate) fn is_pacman_managed() -> bool { + #[cfg(target_os = "linux")] + { + std::env::current_exe() + .map(|path| pacman_owns_executable(&path, std::path::Path::new("/usr/bin/pacman"))) + .unwrap_or(false) + } + #[cfg(not(target_os = "linux"))] + { + false + } +} + +#[cfg(any(target_os = "linux", all(test, unix)))] +fn pacman_owns_executable(executable: &std::path::Path, pacman: &std::path::Path) -> bool { + if pacman_owns_path(executable, pacman) { + return true; + } + executable + .canonicalize() + .is_ok_and(|resolved| resolved != executable && pacman_owns_path(&resolved, pacman)) +} + +#[cfg(any(target_os = "linux", all(test, unix)))] +fn pacman_owns_path(path: &std::path::Path, pacman: &std::path::Path) -> bool { + use std::process::Stdio; + use std::time::{Duration, Instant}; + + let Ok(mut child) = Command::new(pacman) + .args(["-Qoq", "--"]) + .arg(path) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + else { + return false; + }; + let deadline = Instant::now() + Duration::from_millis(500); + loop { + match child.try_wait() { + Ok(Some(status)) => return status.success(), + Ok(None) if Instant::now() < deadline => { + std::thread::sleep(Duration::from_millis(10)); + } + _ => { + let _ = child.kill(); + let _ = child.wait(); + return false; + } + } + } +} + /// Canonical install-script URLs. Match what the docs print as the one-liner; /// users who run `cua-driver update --apply` and re-run the printed manual /// command land at the exact same script. Per-OS gating keeps the unused @@ -42,6 +103,16 @@ const RELEASE_VERSION_ENV: &str = "CUA_DRIVER_RELEASE_VERSION"; /// installer's exit status so the caller can produce the right /// "succeeded / failed — re-run manually" message. pub fn run_install_script(version: &str) -> std::io::Result { + run_install_script_with_ownership(version, is_pacman_managed()) +} + +fn run_install_script_with_ownership(version: &str, managed: bool) -> std::io::Result { + if managed { + return Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + PACMAN_UPDATE_GUIDANCE, + )); + } #[cfg(windows)] { // Match the documented Windows one-liner: `irm | iex`. @@ -98,3 +169,74 @@ pub fn manual_install_one_liner() -> String { format!("curl -fsSL {CANONICAL_INSTALL_SH} | bash") } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn managed_install_never_starts_installer() { + let error = run_install_script_with_ownership("0.24.0", true).unwrap_err(); + assert_eq!(error.kind(), std::io::ErrorKind::PermissionDenied); + assert!(error.to_string().contains("sudo pacman -Syu")); + } + + #[cfg(unix)] + fn fake_pacman(root: &std::path::Path, script: &str) -> std::path::PathBuf { + use std::os::unix::fs::PermissionsExt; + let path = root.join("pacman"); + std::fs::write(&path, format!("#!/bin/sh\n{script}\n")).unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); + path + } + + #[cfg(unix)] + #[test] + fn ownership_requires_successful_query_with_literal_path() { + let root = tempfile::tempdir().unwrap(); + let pacman = fake_pacman( + root.path(), + "[ \"$1\" = -Qoq ] && [ \"$2\" = -- ] && [ \"$3\" = '/usr/bin/driver ; $(false)' ]", + ); + assert!(pacman_owns_executable( + std::path::Path::new("/usr/bin/driver ; $(false)"), + &pacman + )); + assert!(!pacman_owns_executable( + std::path::Path::new("/usr/bin/unmanaged"), + &pacman + )); + } + + #[cfg(unix)] + #[test] + fn ownership_queries_symlink_target() { + let root = tempfile::tempdir().unwrap(); + let target = root.path().join("packaged-driver"); + std::fs::write(&target, "fixture").unwrap(); + let link = root.path().join("driver"); + std::os::unix::fs::symlink(&target, &link).unwrap(); + let pacman = fake_pacman( + root.path(), + "case \"$3\" in */packaged-driver) exit 0;; *) exit 1;; esac", + ); + assert!(pacman_owns_executable(&link, &pacman)); + } + + #[cfg(unix)] + #[test] + fn missing_failed_and_timed_out_queries_are_not_ownership() { + let root = tempfile::tempdir().unwrap(); + let executable = root.path().join("missing-driver"); + assert!(!pacman_owns_executable( + &executable, + &root.path().join("missing-pacman") + )); + let pacman = fake_pacman(root.path(), "exit 2"); + assert!(!pacman_owns_executable(&executable, &pacman)); + fake_pacman(root.path(), "while :; do :; done"); + let started = std::time::Instant::now(); + assert!(!pacman_owns_executable(&executable, &pacman)); + assert!(started.elapsed() < std::time::Duration::from_secs(2)); + } +} diff --git a/libs/cua-driver/rust/crates/cua-driver/src/version_check.rs b/libs/cua-driver/rust/crates/cua-driver/src/version_check.rs index c8ff54695c..ba0561585f 100644 --- a/libs/cua-driver/rust/crates/cua-driver/src/version_check.rs +++ b/libs/cua-driver/rust/crates/cua-driver/src/version_check.rs @@ -120,7 +120,7 @@ pub struct UpdateState { pub current_version: String, pub current_channel: Option<&'static str>, pub selected_channel: Option<&'static str>, - /// `None` when the network fetch failed and no usable cache existed — + /// `None` when upstream checking is unavailable or failed without a cache — /// the `error` field carries the human-readable reason. pub latest_version: Option, pub update_available: bool, @@ -131,8 +131,7 @@ pub struct UpdateState { /// ISO-8601 UTC timestamp of when this check ran (NOT when the cached /// result was originally fetched — see `cache_hit`). pub checked_at: String, - /// `true` when we short-circuited via the on-disk cache (no network - /// round-trip this invocation). `false` when we actually hit GitHub. + /// `true` when the result came from the on-disk cache. pub cache_hit: bool, /// Shell one-liner to apply the update. `None` when already up-to-date /// or the check failed. @@ -140,8 +139,8 @@ pub struct UpdateState { /// URL of the release notes page on GitHub. `None` when already /// up-to-date or the check failed. pub release_notes_url: Option, - /// Human-readable error string when the network fetch failed AND no - /// cache was available. `None` on success / cache fallback. + /// Human-readable reason an upstream check is unavailable or failed + /// without a cache. `None` on success / cache fallback. pub error: Option, } @@ -194,11 +193,30 @@ fn install_one_liner() -> String { /// `UpdateState.error` with a non-empty `current_version` so consumers /// always get a well-formed payload they can branch on. pub fn check_update_state(no_cache: bool) -> UpdateState { + check_update_state_with_ownership(no_cache, crate::updater::is_pacman_managed()) +} + +pub(crate) fn check_update_state_with_ownership(no_cache: bool, managed: bool) -> UpdateState { let current = env!("CARGO_PKG_VERSION").to_owned(); let now = unix_now(); let checked_at = iso8601(now); let current_channel = crate::release_channel::ReleaseChannel::from_version(¤t); + if managed { + return UpdateState { + current_version: current, + current_channel: current_channel.map(|channel| channel.as_str()), + selected_channel: None, + latest_version: None, + update_available: false, + source: "github_releases", + checked_at, + cache_hit: false, + install_command: None, + release_notes_url: None, + error: Some(crate::updater::PACMAN_UPDATE_GUIDANCE.to_owned()), + }; + } let selected_channel = match crate::release_channel::selected() { Ok(channel) => channel, Err(error) => { @@ -339,6 +357,28 @@ where F: FnOnce() -> Result, W: std::io::Write, { + run_check_and_announce_with_ownership( + current, + fetch, + &mut writer, + capture_telemetry, + crate::updater::is_pacman_managed(), + ); +} + +fn run_check_and_announce_with_ownership( + current: &str, + fetch: F, + mut writer: W, + capture_telemetry: bool, + managed: bool, +) where + F: FnOnce() -> Result, + W: std::io::Write, +{ + if managed { + return; + } let now = unix_now(); let Ok(selected_channel) = crate::release_channel::selected() else { return; @@ -667,6 +707,9 @@ pub fn fetch_latest_version() -> Result { pub fn fetch_latest_version_for( channel: crate::release_channel::ReleaseChannel, ) -> Result { + if crate::updater::is_pacman_managed() { + return Err(crate::updater::PACMAN_UPDATE_GUIDANCE.to_owned()); + } let agent = ureq::Agent::config_builder() .timeout_global(Some(std::time::Duration::from_secs(HTTP_TIMEOUT_SECONDS))) .build() @@ -791,6 +834,50 @@ mod tests { use super::*; use std::sync::Mutex; + #[test] + fn pacman_managed_check_ignores_upstream_cache_and_channel() { + let _g = ENV_LOCK.lock().unwrap(); + with_isolated_home(|home| { + write_cache(&VersionCache { + latest_version: Some("999.0.0".into()), + last_checked_unix: Some(unix_now()), + ..Default::default() + }) + .unwrap(); + std::fs::write( + home.join(crate::bundle::user_home_subdirectory()) + .join("release-channel"), + "invalid-channel\n", + ) + .unwrap(); + let before = std::fs::read(cache_path().unwrap()).unwrap(); + for no_cache in [false, true] { + let state = check_update_state_with_ownership(no_cache, true); + assert!(!state.update_available); + assert!(!state.cache_hit); + assert!(state.latest_version.is_none()); + assert!(state.selected_channel.is_none()); + assert!(state.install_command.is_none()); + assert!(state.release_notes_url.is_none()); + assert!(state.error.unwrap().contains("sudo pacman -Syu")); + assert_eq!(std::fs::read(cache_path().unwrap()).unwrap(), before); + } + }); + } + + #[test] + fn pacman_managed_startup_never_fetches_or_prints_banner() { + let mut banner = Vec::new(); + run_check_and_announce_with_ownership( + "0.24.0", + || panic!("managed startup must not fetch"), + &mut banner, + false, + true, + ); + assert!(banner.is_empty()); + } + /// All env-mutating tests serialise on this lock — `std::env::set_var` /// is process-global, parallel tests would race. static ENV_LOCK: Mutex<()> = Mutex::new(()); diff --git a/libs/cua-driver/rust/crates/cua-driver/tests/release_channel_cli_test.rs b/libs/cua-driver/rust/crates/cua-driver/tests/release_channel_cli_test.rs index 94375c7f21..b7f004335d 100644 --- a/libs/cua-driver/rust/crates/cua-driver/tests/release_channel_cli_test.rs +++ b/libs/cua-driver/rust/crates/cua-driver/tests/release_channel_cli_test.rs @@ -57,3 +57,205 @@ fn channel_cli_fails_closed_on_invalid_saved_state() { ); assert!(stderr.contains("channel set stable"), "{stderr}"); } + +#[cfg(target_os = "linux")] +mod pacman { + use super::*; + use cua_driver_testkit::Driver; + use std::os::unix::fs::PermissionsExt; + + fn fixture() -> tempfile::TempDir { + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("pacman"); + // A successful imposter on PATH must never establish package ownership. + std::fs::write( + &path, + "#!/bin/sh\n: > \"$HOME/fake-pacman-called\"\nexit 0\n", + ) + .unwrap(); + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap(); + std::fs::write(root.path().join("release-channel"), "nightly\n").unwrap(); + std::fs::create_dir(root.path().join(".cua-driver")).unwrap(); + root + } + + fn command( + executable: &std::path::Path, + root: &std::path::Path, + args: &[&str], + ) -> std::process::Output { + Command::new(executable) + .args(args) + .env("PATH", root) + .env("CUA_DRIVER_RS_HOME", root) + .env("HOME", root) + .env("CUA_DRIVER_RS_TELEMETRY_ENABLED", "0") + .output() + .unwrap() + } + + fn managed_executable() -> std::path::PathBuf { + let executable = std::path::PathBuf::from( + std::env::var_os("PACMAN_TEST_MANAGED_EXECUTABLE") + .expect("native test requires PACMAN_TEST_MANAGED_EXECUTABLE"), + ); + let owned = Command::new("/usr/bin/pacman") + .args(["-Qoq", "--"]) + .arg(std::fs::canonicalize(&executable).expect("resolve native candidate")) + .output() + .expect("native test requires real /usr/bin/pacman"); + assert!(owned.status.success(), "candidate must be pacman-owned"); + assert!(!owned.stdout.is_empty(), "pacman must identify its owner"); + executable + } + + fn assert_unavailable(state: &serde_json::Value) { + assert_eq!(state["update_available"], false); + assert_eq!(state["cache_hit"], false); + for field in [ + "latest_version", + "selected_channel", + "install_command", + "release_notes_url", + ] { + assert!(state[field].is_null(), "{state}"); + } + assert!(state["error"] + .as_str() + .unwrap() + .contains("sudo pacman -Syu")); + } + + #[test] + #[ignore = "requires a real pacman-owned PACMAN_TEST_MANAGED_EXECUTABLE in a disposable Linux guest"] + fn managed_cli_checks_and_apply_return_package_guidance() { + let executable = managed_executable(); + let root = fixture(); + // A tempting cached upstream nightly must never be advertised. + let cache = r#"{"latest_version":"999.0.0-nightly.20260907.1","channel":"nightly","last_checked_unix":9999999999}"#; + let cache_path = root.path().join(".cua-driver/version_check.json"); + std::fs::write(&cache_path, cache).unwrap(); + for args in [ + vec!["check-update", "--json"], + vec!["check-update", "--json", "--no-cache"], + vec!["update", "--json"], + vec!["update", "--apply", "--json"], + ] { + let output = command(&executable, root.path(), &args); + assert!(!output.status.success()); + let state = serde_json::from_slice(&output.stdout).unwrap(); + assert_unavailable(&state); + } + let text = command(&executable, root.path(), &["update", "--apply"]); + assert!(!text.status.success()); + assert!(String::from_utf8_lossy(&text.stdout).contains("sudo pacman -Syu")); + assert_eq!(std::fs::read_to_string(&cache_path).unwrap(), cache); + let preference = root.path().join("release-channel"); + for saved in [None, Some("nightly\n"), Some("broken\n")] { + match saved { + Some(value) => std::fs::write(&preference, value).unwrap(), + None => std::fs::remove_file(&preference).unwrap(), + } + for args in [ + vec!["channel", "status"], + vec!["channel", "set", "stable"], + vec!["channel", "set", "nightly"], + ] { + let text = command(&executable, root.path(), &args); + assert!(!text.status.success()); + assert!(text.stdout.is_empty()); + assert!(String::from_utf8_lossy(&text.stderr).contains("sudo pacman -Syu")); + let mut json_args = args; + json_args.push("--json"); + let json = command(&executable, root.path(), &json_args); + assert!(!json.status.success()); + let state: serde_json::Value = serde_json::from_slice(&json.stdout).unwrap(); + assert_eq!( + state.get("selected_channel"), + Some(&serde_json::Value::Null) + ); + for field in ["current_version", "current_channel"] { + assert!(!state[field].as_str().unwrap().is_empty(), "{state}"); + } + assert!(state["error"] + .as_str() + .unwrap() + .contains("sudo pacman -Syu")); + assert_eq!(std::fs::read_to_string(&preference).ok().as_deref(), saved); + assert_eq!(std::fs::read_to_string(&cache_path).unwrap(), cache); + } + } + std::fs::remove_file(&cache_path).unwrap(); + let uncached = command(&executable, root.path(), &["check-update", "--json"]); + assert!(!uncached.status.success()); + assert_unavailable(&serde_json::from_slice(&uncached.stdout).unwrap()); + assert!( + !cache_path.exists(), + "managed checks must not create an upstream cache" + ); + assert!(!root.path().join("fake-pacman-called").exists()); + } + + #[test] + fn fake_path_pacman_cannot_disable_unmanaged_channel_switching() { + let executable = std::env::var_os("PACMAN_TEST_UNMANAGED_EXECUTABLE") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| env!("CARGO_BIN_EXE_cua-driver").into()); + let root = fixture(); + let output = command( + &executable, + root.path(), + &["channel", "set", "stable", "--json"], + ); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let state: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(state["selected_channel"], "stable"); + std::fs::write( + root.path().join(".cua-driver/version_check.json"), + r#"{"latest_version":"999.0.0","channel":"stable","last_checked_unix":9999999999}"#, + ) + .unwrap(); + let output = command(&executable, root.path(), &["check-update", "--json"]); + assert!(output.status.success()); + let state: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(state["update_available"], true); + assert_eq!(state["cache_hit"], true); + assert_eq!(state["latest_version"], "999.0.0"); + assert!(state["error"].is_null()); + assert!(!root.path().join("fake-pacman-called").exists()); + } + + #[test] + #[ignore = "requires a real pacman-owned candidate and CUA_TEST_DRIVER_BIN in a disposable Linux guest"] + fn managed_mcp_check_returns_same_unavailable_state() { + let executable = managed_executable(); + assert_eq!( + std::path::PathBuf::from( + std::env::var_os("CUA_TEST_DRIVER_BIN").expect("set testkit binary override") + ), + executable, + "testkit must start the exact packaged candidate (including symlink entry points)" + ); + let root = fixture(); + let mut driver = cua_driver_testkit::McpDriver::spawn_with_env(&[ + ("PATH", root.path().to_str().unwrap()), + ("CUA_DRIVER_RS_HOME", root.path().to_str().unwrap()), + ("HOME", root.path().to_str().unwrap()), + ]) + .expect("start test daemon and MCP proxy"); + let result = driver.call("check_for_update", serde_json::json!({})); + assert!(result.text().starts_with("Update check unavailable:")); + assert!( + result.text().contains("sudo pacman -Syu"), + "{:?}", + result.raw + ); + assert_unavailable(&result.raw["result"]["structuredContent"]); + assert!(!root.path().join(".cua-driver/version_check.json").exists()); + assert!(!root.path().join("fake-pacman-called").exists()); + } +} diff --git a/scripts/ci/README.md b/scripts/ci/README.md index 84573c036d..fce674eca3 100644 --- a/scripts/ci/README.md +++ b/scripts/ci/README.md @@ -80,6 +80,33 @@ For the test layout and the distinction between unit tests, shared harnesses, and native harnesses, see `libs/cua-driver/docs/test-harnesses-guide.md`. +## Native pacman update test + +The `Arch native pacman updates` job in `ci-rust-linux.yml` checks package +ownership with real pacman in a disposable Arch container. It packages the +candidate under `/usr/lib/cua-driver-pacman-test`, verifies CLI and MCP update +guidance through the installed binary and a symlink, and checks that an +unmanaged copy retains vendor updates and channel selection. A fake `pacman` +on `PATH` must not change either result. The job removes only its fixture +package and retains source, binary, toolchain, and test evidence. + +To reproduce it, build `cua-driver` and `release_channel_cli_test` with +`--locked --features portal-input` in a disposable Arch guest/container. Run +as root with Xvfb and a session bus: + +```bash +CUA_E2E_SOURCE_SHA=FULL_COMMIT_SHA CUA_E2E_UNRESTRICTED_GUI=1 \ + xvfb-run -a dbus-run-session -- env CUA_PACMAN_TEST_DISPOSABLE=1 \ + bash scripts/ci/linux/test-pacman-updates.sh \ + CANDIDATE_BINARY INTEGRATION_TEST_BINARY NEW_EVIDENCE_DIRECTORY +``` + +The runner rejects existing fixture packages and payload paths. Do not run it +on a user's host. This is package-update validation, not a Hyprland or Omarchy +desktop certification, and it does not replace the desktop matrix. + +## Desktop runners + | Runner | Session | Canonical command | | ------------------------------- | ------------------------------------------------------------------ | ----------------- | | `linux/run-rust-e2e.sh` | Existing Linux X11 or Wayland desktop | no selector | diff --git a/scripts/ci/linux/test-pacman-updates.sh b/scripts/ci/linux/test-pacman-updates.sh new file mode 100755 index 0000000000..2d7519254e --- /dev/null +++ b/scripts/ci/linux/test-pacman-updates.sh @@ -0,0 +1,123 @@ +#!/usr/bin/env bash +# Run only in a disposable Arch Linux guest/container; never replace the installed driver. +set -euo pipefail + +if [[ $# != 3 || ${CUA_PACMAN_TEST_DISPOSABLE:-} != 1 ]]; then + echo "Usage: CUA_PACMAN_TEST_DISPOSABLE=1 $0 CANDIDATE TEST_BINARY NEW_EVIDENCE_DIR" >&2 + exit 2 +fi +if [[ $(uname -s) != Linux || $EUID != 0 || ! -x /usr/bin/pacman ]]; then + echo "Requires root in a disposable Linux guest with /usr/bin/pacman." >&2 + exit 2 +fi +if ! grep -qx 'ID=arch' /etc/os-release; then + echo "Requires a disposable Arch Linux guest/container." >&2 + exit 2 +fi + +candidate=$(realpath -- "$1") +test_binary=$(realpath -- "$2") +if [[ ! -x $candidate || ! -x $test_binary ]]; then + echo "Candidate and integration-test binary must be executable." >&2 + exit 2 +fi +if /usr/bin/pacman -Q cua-driver-pacman-test >/dev/null 2>&1 || + [[ -e /usr/lib/cua-driver-pacman-test || -L /usr/lib/cua-driver-pacman-test ]]; then + echo "Refusing to overwrite an existing test package or payload path." >&2 + exit 2 +fi + +# A new directory prevents accidental overwrites and retains all native evidence. +mkdir -- "$3" +evidence=$(realpath -- "$3") +exec > >(tee "$evidence/run.log") 2>&1 +echo "Evidence: $evidence" +{ + echo "source_sha=${CUA_E2E_SOURCE_SHA:?Set CUA_E2E_SOURCE_SHA to the tested commit}" + cat /etc/os-release + /usr/bin/pacman --version + rustc --version + cargo --version + sha256sum -- "$candidate" "$test_binary" +} | tee "$evidence/provenance.txt" +"$candidate" --version +"$test_binary" --list > "$evidence/tests.txt" +for test in managed_cli_checks_and_apply_return_package_guidance managed_mcp_check_returns_same_unavailable_state fake_path_pacman_cannot_disable_unmanaged_channel_switching; do + if ! grep -Fx "pacman::$test: test" "$evidence/tests.txt"; then + echo "Integration-test binary is missing required test: $test" >&2 + exit 2 + fi +done + +installed=0 +cleanup() { + local result=$? + trap - EXIT + if [[ $installed == 1 ]]; then + if ! /usr/bin/pacman -R --noconfirm cua-driver-pacman-test; then + echo "Failed to remove task package cua-driver-pacman-test." >&2 + result=1 + elif [[ -e /usr/lib/cua-driver-pacman-test || -L /usr/lib/cua-driver-pacman-test ]]; then + echo "Task package payload remains after removal." >&2 + result=1 + fi + fi + echo "Native pacman test exit: $result; evidence retained at $evidence" + exit "$result" +} +trap cleanup EXIT + +install -m 755 -- "$candidate" "$evidence/unmanaged-cua-driver" +test_unmanaged() { + if /usr/bin/pacman -Qoq -- "$evidence/unmanaged-cua-driver"; then + echo "Unmanaged copy unexpectedly has a package owner." >&2 + exit 1 + fi + PACMAN_TEST_UNMANAGED_EXECUTABLE="$evidence/unmanaged-cua-driver" \ + timeout 60 "$test_binary" \ + --exact pacman::fake_path_pacman_cannot_disable_unmanaged_channel_switching \ + --nocapture --test-threads=1 +} +echo "Testing unmanaged candidate before package installation" +test_unmanaged + +mkdir -p -- "$evidence/package/usr/lib/cua-driver-pacman-test" +install -m 755 -- "$candidate" "$evidence/package/usr/lib/cua-driver-pacman-test/cua-driver" +cat > "$evidence/package/.PKGINFO" </dev/null 2>&1 || + [[ -e /usr/lib/cua-driver-pacman-test || -L /usr/lib/cua-driver-pacman-test ]]; then + echo "Task package or payload remains after removal." >&2 + exit 1 +fi +echo "Testing unmanaged candidate after verified package removal" +test_unmanaged