From 0b47de3503610d5f73926f62e3f17c8e915a8149 Mon Sep 17 00:00:00 2001 From: reviewer Date: Sat, 17 Oct 2026 21:17:22 +0800 Subject: [PATCH 1/6] [CTX-1283] fix(runtime): preserve WSL scratch paths and help bytes --- crates/bitty-runtime/src/component/wsl.rs | 4 +++ crates/bitty-terminal/src/component.rs | 39 ++++++++++++++++++++--- 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/crates/bitty-runtime/src/component/wsl.rs b/crates/bitty-runtime/src/component/wsl.rs index 6cc480850..fc821a36e 100644 --- a/crates/bitty-runtime/src/component/wsl.rs +++ b/crates/bitty-runtime/src/component/wsl.rs @@ -351,9 +351,11 @@ pub fn windows_path_to_guest(windows: &str) -> Result { { let drive = bytes[0].to_ascii_lowercase() as char; let remainder = windows[3..].replace('\\', "/"); + // scratch-paths-exempt: intentional WSL guest contract path, not a host path. let guest = if remainder.is_empty() { format!("/mnt/{drive}") } else { + // scratch-paths-exempt: intentional WSL guest contract path, not a host path. format!("/mnt/{drive}/{remainder}") }; if guest.len() > WSL_PATH_MAX_BYTES { @@ -385,6 +387,7 @@ pub fn guest_mnt_to_windows(guest: &str) -> Result { "unmappable guest path {guest:?} (want 1..={WSL_PATH_MAX_BYTES} bytes, no NUL or control characters)" ))); } + // scratch-paths-exempt: intentional WSL guest contract path, not a host path. let Some(rest) = guest.strip_prefix("/mnt/") else { return Err(WslError::UnmappablePath(format!( "unmappable guest path {guest:?} (only /mnt//... has a Windows spelling; use the Windows-to-guest direction for spawn)" @@ -394,6 +397,7 @@ pub fn guest_mnt_to_windows(guest: &str) -> Result { let drive = parts.next().unwrap_or(""); let remainder = parts.next().unwrap_or(""); if drive.len() != 1 || !drive.bytes().all(|b| b.is_ascii_lowercase()) { + // scratch-paths-exempt: intentional WSL guest contract path, not a host path. return Err(WslError::UnmappablePath(format!( "unmappable guest path {guest:?} (want /mnt//...)" ))); diff --git a/crates/bitty-terminal/src/component.rs b/crates/bitty-terminal/src/component.rs index 4c174700f..89ccabbc3 100644 --- a/crates/bitty-terminal/src/component.rs +++ b/crates/bitty-terminal/src/component.rs @@ -804,7 +804,8 @@ pub fn component_usage() -> String { /// Long help (`bitty extension --help`). #[must_use] pub fn component_help_text() -> String { - "bitty extension — CLI-first native binary extension management (local class, no execution)\n\ + concat!( + "bitty extension — CLI-first native binary extension management (local class, no execution)\n\ \n\ usage: bitty extension [args] [flags]\n\ \n\ @@ -891,14 +892,16 @@ pub fn component_help_text() -> String { \x20 distro (no --distribution flag). A selected distro with absent WSL\n\ \x20 fails closed before any staging (exit 1): install WSL2 from\n\ \x20 the Store (inbox builds use --status fallback, unversioned) or run\n\ - \x20 without --distro. Path mapping (Windows to guest for spawn):\n\ - \x20 drive-absolute X:\\... maps to /mnt//... and WSL UNC\n\ + \x20 without --distro. Path mapping (Windows to guest for spawn):\n", + // scratch-paths-exempt: intentional WSL help contract path example, not a host path. + "\x20drive-absolute X:\\... maps to /mnt//... and WSL UNC\n\ \x20 \\\\wsl$\\\\... maps to guest-absolute /...; UNC paths bind\n\ \x20 to their launch distro (cross-distro exec fails closed); device\n\ \x20 paths, drive-relative, relative, and other UNC spellings fail closed\n\ \x20 (exit 2, no silent mangling). Backslash rewriting happens only inside\n\ - \x20 path mapping; general argv elements pass through discrete with spaces\n\ - \x20 preserved and metacharacters inert. Reverse (/mnt/... to Windows)\n\ + \x20 path mapping; general argv elements pass through discrete with spaces\n", + // scratch-paths-exempt: intentional WSL help contract path example, not a host path. + "\x20preserved and metacharacters inert. Reverse (/mnt/... to Windows)\n\ \x20 exists for diagnostics only; other guest paths have no Windows\n\ \x20 spelling. Guest env is WSLENV-gated (forwarded proxy/TLS/locale\n\ \x20 names only, secrets never, otherwise clean by design). Stdio and\n\ @@ -987,6 +990,7 @@ pub fn component_help_text() -> String { \x20 bitty extension remove net 0.0.1\n\ \x20 bitty extension show-source net\n\ \x20 bitty extension show-source net 0.0.1" + ) .to_string() } @@ -3364,6 +3368,31 @@ mod tests { let _ = std::fs::remove_dir_all(&base); } + #[test] + fn help_path_mapping_lines_keep_original_bytes() { + let help = component_help_text(); + let lines: Vec<&[u8]> = help + .split_inclusive('\n') + .filter(|line| { + line.contains("drive-absolute X:") + || line.contains("\\\\wsl$\\") + || line.contains("preserved and metacharacters inert.") + }) + .map(str::as_bytes) + .collect(); + + assert_eq!( + lines, + vec![ + b"\x20drive-absolute X:\\... maps to /mnt//... and WSL UNC\n".as_slice(), + b"\x20\x20\\\\wsl$\\\\... maps to guest-absolute /...; UNC paths bind\n" + .as_slice(), + b"\x20preserved and metacharacters inert. Reverse (/mnt/... to Windows)\n" + .as_slice(), + ] + ); + } + #[test] fn list_is_empty_without_roots_or_components() { let base = scratch("empty"); From ec4dab84e3b683599937e038750b0de0fcc2cd52 Mon Sep 17 00:00:00 2001 From: reviewer Date: Sat, 17 Oct 2026 22:28:39 +0800 Subject: [PATCH 2/6] [CTX-1306] fix(cli): preserve exact parent WSL help bytes Restore the two leading spaces verified by compiling the exact unmodified parent help function. Correct the byte regression assertions and retain narrow WSL scratch-path exemptions. Task: CTX-1306 Refs #2200 --- crates/bitty-terminal/src/component.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/bitty-terminal/src/component.rs b/crates/bitty-terminal/src/component.rs index 89ccabbc3..dfc2cf710 100644 --- a/crates/bitty-terminal/src/component.rs +++ b/crates/bitty-terminal/src/component.rs @@ -894,14 +894,14 @@ pub fn component_help_text() -> String { \x20 the Store (inbox builds use --status fallback, unversioned) or run\n\ \x20 without --distro. Path mapping (Windows to guest for spawn):\n", // scratch-paths-exempt: intentional WSL help contract path example, not a host path. - "\x20drive-absolute X:\\... maps to /mnt//... and WSL UNC\n\ + "\x20 drive-absolute X:\\... maps to /mnt//... and WSL UNC\n\ \x20 \\\\wsl$\\\\... maps to guest-absolute /...; UNC paths bind\n\ \x20 to their launch distro (cross-distro exec fails closed); device\n\ \x20 paths, drive-relative, relative, and other UNC spellings fail closed\n\ \x20 (exit 2, no silent mangling). Backslash rewriting happens only inside\n\ \x20 path mapping; general argv elements pass through discrete with spaces\n", // scratch-paths-exempt: intentional WSL help contract path example, not a host path. - "\x20preserved and metacharacters inert. Reverse (/mnt/... to Windows)\n\ + "\x20 preserved and metacharacters inert. Reverse (/mnt/... to Windows)\n\ \x20 exists for diagnostics only; other guest paths have no Windows\n\ \x20 spelling. Guest env is WSLENV-gated (forwarded proxy/TLS/locale\n\ \x20 names only, secrets never, otherwise clean by design). Stdio and\n\ @@ -3384,10 +3384,11 @@ mod tests { assert_eq!( lines, vec![ - b"\x20drive-absolute X:\\... maps to /mnt//... and WSL UNC\n".as_slice(), + b"\x20\x20drive-absolute X:\\... maps to /mnt//... and WSL UNC\n" + .as_slice(), b"\x20\x20\\\\wsl$\\\\... maps to guest-absolute /...; UNC paths bind\n" .as_slice(), - b"\x20preserved and metacharacters inert. Reverse (/mnt/... to Windows)\n" + b"\x20\x20preserved and metacharacters inert. Reverse (/mnt/... to Windows)\n" .as_slice(), ] ); From 60591e68e3f8af8329d4205b3cf5a486dfeb4978 Mon Sep 17 00:00:00 2001 From: reviewer Date: Sat, 17 Oct 2026 22:34:37 +0800 Subject: [PATCH 3/6] [CTX-1311] fix(sandbox): document inert ELF parser fixture Explain the no-execution fixture using the existing per-hit PTY gate exemption. Preserve Linux cfg, executable tokens, and global gate behavior. Task: CTX-1311 Refs #2203 --- crates/bitty-sandbox/src/lib.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/bitty-sandbox/src/lib.rs b/crates/bitty-sandbox/src/lib.rs index 27c1d0ebf..95a496024 100644 --- a/crates/bitty-sandbox/src/lib.rs +++ b/crates/bitty-sandbox/src/lib.rs @@ -673,6 +673,7 @@ mod tests { )); std::fs::create_dir_all(&dir).expect("temp dir must build"); let script = dir.join("probe.sh"); + // pty-gate-exempt: inert script fixture for elf_interp; never executed. std::fs::write(&script, "#!/bin/sh\nexit 0\n").expect("fixture must write"); assert_eq!(linux::elf_interp(&script), None); let _ = std::fs::remove_dir_all(&dir); From a38cbe7d943b314ed583d16e1737ecb6eb93aacc Mon Sep 17 00:00:00 2001 From: reviewer Date: Sat, 17 Oct 2026 22:42:37 +0800 Subject: [PATCH 4/6] [CTX-1308] fix(ci): run local Quality shards with disk sccache Map the current Ubuntu runner, use bounded local disk caching under act while preserving remote GHA caching, and serialize bind-mounted matrix checkouts. Bind the local cache endpoint to loopback and cover dispatch, backend setup, locks, and failure propagation. Refs #2201 --- .github/act/Dockerfile | 2 +- .github/workflows/ci.yml | 18 +++++ justfile | 27 ++++--- scripts/tests/ci-local.test.sh | 124 +++++++++++++++++++++++++++++++++ 4 files changed, 161 insertions(+), 10 deletions(-) create mode 100644 scripts/tests/ci-local.test.sh diff --git a/.github/act/Dockerfile b/.github/act/Dockerfile index 92c58e204..538e56249 100644 --- a/.github/act/Dockerfile +++ b/.github/act/Dockerfile @@ -14,7 +14,7 @@ # Usage (real local CI, e.g. the Quality gates job): # act -W .github/workflows/ci.yml -j quality \ # --container-architecture linux/amd64 \ -# -P ubuntu-latest=bitty-act:latest +# -P ubuntu-24.04=bitty-act:latest # # Keep the Rust channel in sync with rust-toolchain.toml when it changes. FROM catthehacker/ubuntu:act-latest diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f4b79d57f..c41c85f95 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -121,6 +121,20 @@ jobs: toolchain: ${{ steps.rust-channel.outputs.channel }} components: rustfmt, clippy + # act lacks the Actions runtime cache credentials. Keep the compiler + # wrapper and use bounded disk caches under the persisted target mount; + # each shard owns a directory because disk caches support one server. + # A nonempty GHA namespace enables that backend even when disabled. + - name: Configure local sccache backend + if: env.ACT + run: | + { + echo "SCCACHE_GHA_ENABLED=false" + echo "SCCACHE_GHA_VERSION=" + echo "SCCACHE_DIR=$CARGO_TARGET_DIR/sccache-${{ matrix.shard }}" + echo "SCCACHE_CACHE_SIZE=1G" + } >> "$GITHUB_ENV" + # CTX-0971 R4: sccache server on the GHA backend (shares dep units with # the linux-x11/linux-wayland jobs via the same SCCACHE_GHA_VERSION # namespace). Kept alongside rust-cache below; posts stats automatically. @@ -140,6 +154,10 @@ jobs: # CTX-0971 R4 sharding: lint/check steps run once (shard 1) so the two # shards do not duplicate clippy/fmt/packaging work. Test steps below # are partitioned or split across both shards. + - name: Check local Quality dispatch and cache setup + if: matrix.shard == 1 + run: bash scripts/tests/ci-local.test.sh + - name: Check Rust formatting if: matrix.shard == 1 run: cargo fmt --all -- --check diff --git a/justfile b/justfile index 1fa065d98..e115cc5e1 100644 --- a/justfile +++ b/justfile @@ -127,14 +127,23 @@ ci-local *args: # Bind the persistent target dir at both CARGO_TARGET_DIR and the repo's # own target/ so gates that read target/debug/bitty see a container-built # binary instead of the host's (the host glibc is newer than the image's). - exec act -W .github/workflows/ci.yml -j quality \ - --pull=false --bind --container-architecture linux/amd64 \ - -P ubuntu-latest=bitty-act:latest \ - --container-options "-u ubuntu ${group_add[*]:-} -v $cache/cargo-registry:$ctr_cargo/registry -v $cache/cargo-git:$ctr_cargo/git -v $cache/target:/cache/target -v $cache/target:$root/target" \ - --env HOME=$ctr_home --env CARGO_HOME=$ctr_cargo --env RUSTUP_HOME=$ctr_rustup \ - --env CARGO_TARGET_DIR=/cache/target --env CARGO_BUILD_JOBS="${CARGO_BUILD_JOBS:-$(nproc)}" \ - ${proxy_env[@]+"${proxy_env[@]}"} \ - {{args}} + # Matrix entries share this bind mount, including external suite checkouts. + # act's --concurrent-jobs does not serialize entries of one matrix job; + # run each shard explicitly under the same repository lock instead. + for shard in 1 2; do + act -W .github/workflows/ci.yml -j quality --matrix "shard:$shard" \ + --cache-server-addr 127.0.0.1 \ + --pull=false --bind --container-architecture linux/amd64 \ + -P ubuntu-24.04=bitty-act:latest \ + --container-options "-u ubuntu ${group_add[*]:-} -v $cache/cargo-registry:$ctr_cargo/registry -v $cache/cargo-git:$ctr_cargo/git -v $cache/target:/cache/target -v $cache/target:$root/target" \ + --env HOME=$ctr_home --env CARGO_HOME=$ctr_cargo --env RUSTUP_HOME=$ctr_rustup \ + --env CARGO_TARGET_DIR=/cache/target --env CARGO_BUILD_JOBS="${CARGO_BUILD_JOBS:-$(nproc)}" \ + ${proxy_env[@]+"${proxy_env[@]}"} \ + {{args}} + done + +ci-local-test: + bash scripts/tests/ci-local.test.sh pty-gate: ./scripts/check-pty-gated-tests.sh @@ -313,7 +322,7 @@ commit-check message: @cp commitlint.config.ts target/dev-tools/commitlint.config.ts @msg="$(realpath "{{message}}")" && cd target/dev-tools && bunx --bun commitlint --edit "$msg" -check: fmt-check clippy test supply-chain supply-chain-test scratch-paths scratch-paths-test pty-gate test-support-gate status-drift status-drift-test docs-pin-test runtime-deps-test terminfo-check terminfo-test desktop-check desktop-test install-smoke-test install-scripts-test unix-bundle-test component-dist-test macos-dmg-test windows-installer-test verify-unix-bundle-dispatch-test rust-channel-test binary-arch-test workflow-publish-test m1-matrix-test compat-matrix-test compat-freshness-test real-render-soak-test dogfood-session-test actionlint markdownlint +check: fmt-check clippy test supply-chain supply-chain-test scratch-paths scratch-paths-test pty-gate test-support-gate status-drift status-drift-test docs-pin-test runtime-deps-test terminfo-check terminfo-test desktop-check desktop-test install-smoke-test install-scripts-test unix-bundle-test component-dist-test macos-dmg-test windows-installer-test verify-unix-bundle-dispatch-test rust-channel-test binary-arch-test workflow-publish-test m1-matrix-test compat-matrix-test compat-freshness-test real-render-soak-test dogfood-session-test ci-local-test actionlint markdownlint # Parser-throughput baseline (CTX-0576, M1-11). Runs the deterministic # headless parser benchmark over the committed VT/escape corpora and verifies diff --git a/scripts/tests/ci-local.test.sh b/scripts/tests/ci-local.test.sh new file mode 100644 index 000000000..4807f899d --- /dev/null +++ b/scripts/tests/ci-local.test.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env bash +# ci-local.test.sh — CTX-1308 local Quality dispatch and cache regression tests. +set -euo pipefail + +cd "$(dirname "$0")/../.." +scratch="${TMPDIR:-/tmp}/bitty" +mkdir -p "$scratch" +TMP="$(mktemp -d "$scratch/ci-local-test.XXXXXX")" +trap 'rm -rf "$TMP"' EXIT +FAIL=0 + +fail() { + echo "FAIL: $*" >&2 + FAIL=1 +} + +# Run the real recipe with only its external container commands replaced. +# A separate Git repository also isolates the repository-wide act lock. +mkdir -p "$TMP/bin" "$TMP/repo" "$TMP/cargo" +git init --quiet "$TMP/repo" +cat >"$TMP/bin/docker" <<'STUB' +#!/usr/bin/env bash +set -euo pipefail +[[ "$*" == 'image inspect bitty-act:latest' ]] +STUB +cat >"$TMP/bin/act" <<'STUB' +#!/usr/bin/env bash +set -euo pipefail +printf '%s\n' "$@" >>"$CI_LOCAL_TEST_ARGS" +if [[ -n "${CI_LOCAL_TEST_FAIL_SHARD:-}" && " $* " == *" shard:$CI_LOCAL_TEST_FAIL_SHARD "* ]]; then + exit 23 +fi +exit "${CI_LOCAL_TEST_ACT_STATUS:-0}" +STUB +chmod +x "$TMP/bin/docker" "$TMP/bin/act" +export CI_LOCAL_TEST_ARGS="$TMP/args" + +# Extract the shebang recipe so CI does not need a second task runner just +# to test dispatch. Substitute the variadic argument exactly as just does. +awk ' + /^ci-local \*args:/ {recipe=1; next} + recipe && /^ / {sub(/^ /, ""); gsub(/\{\{args\}\}/, "--concurrent-jobs 1"); print; next} + recipe {exit} +' justfile >"$TMP/recipe.sh" +PATH="$TMP/bin:$PATH" CARGO_HOME="$TMP/cargo" BITTY_ACT_CACHE="$TMP/cache" \ + bash -c 'cd "$1" && bash "$2"' _ "$TMP/repo" "$TMP/recipe.sh" + +runner="$(awk '/^ quality:/{quality=1; next} quality && /^ runs-on:/{print $2; exit}' .github/workflows/ci.yml)" +grep -Fxq "$runner=bitty-act:latest" "$TMP/args" || fail 'Quality runner must map to the local image' +grep -Fq -- "-P $runner=bitty-act:latest" .github/act/Dockerfile || fail 'Dockerfile usage must match the Quality runner' +grep -Fxq 'quality' "$TMP/args" || fail 'dispatch must select the Quality job' +grep -Fxq -- '--concurrent-jobs' "$TMP/args" || fail 'caller arguments must reach act' +grep -Fxq -- '--bind' "$TMP/args" || fail 'local working tree must remain bind-mounted' +grep -Fq -- "$TMP/cache/target:/cache/target" "$TMP/args" || fail 'build/cache directory must persist' +grep -Fxq -- '--cache-server-addr' "$TMP/args" || fail 'local cache server must bind to proxy-exempt loopback' +grep -Fxq '127.0.0.1' "$TMP/args" || fail 'local cache endpoint must match NO_PROXY' +for shard in 1 2; do + grep -Fxq "shard:$shard" "$TMP/args" || fail "dispatch must explicitly execute shard $shard" +done +[[ "$(grep -Fxc 'quality' "$TMP/args")" == 2 ]] || fail 'both shards must run once in separate act invocations' + +# An early shard failure must propagate, without starting the next shard. +rm "$TMP/args" +status=0 +PATH="$TMP/bin:$PATH" CARGO_HOME="$TMP/cargo" BITTY_ACT_CACHE="$TMP/cache" \ + CI_LOCAL_TEST_ACT_STATUS=23 bash -c 'cd "$1" && bash "$2"' _ "$TMP/repo" "$TMP/recipe.sh" || status=$? +[[ "$status" == 23 ]] || fail 'a shard failure must fail dispatch' +[[ "$(grep -Fxc 'quality' "$TMP/args")" == 1 ]] || fail 'a failed shard must stop dispatch' + +# A passing first shard must not hide a failure in the second shard. +rm "$TMP/args" +status=0 +PATH="$TMP/bin:$PATH" CARGO_HOME="$TMP/cargo" BITTY_ACT_CACHE="$TMP/cache" \ + CI_LOCAL_TEST_FAIL_SHARD=2 bash -c 'cd "$1" && bash "$2"' _ "$TMP/repo" "$TMP/recipe.sh" || status=$? +[[ "$status" == 23 ]] || fail 'a second-shard failure must fail dispatch' +[[ "$(grep -Fxc 'quality' "$TMP/args")" == 2 ]] || fail 'second-shard failure must follow a passing first shard' + +# A competing invocation must fail before starting any container command. +( + flock -x 9 + rm "$TMP/args" + if PATH="$TMP/bin:$PATH" CARGO_HOME="$TMP/cargo" BITTY_ACT_CACHE="$TMP/cache" \ + bash -c 'cd "$1" && bash "$2"' _ "$TMP/repo" "$TMP/recipe.sh" >"$TMP/locked.log" 2>&1; then + fail 'a held repository lock must reject a second run' + fi + [[ ! -e "$TMP/args" ]] || fail 'a rejected run must not invoke act' + grep -Fq 'another ci-local run' "$TMP/locked.log" || fail 'lock contention must be explained' + exit "$FAIL" +) 9>"$TMP/repo/.git/ci-local.lock" || FAIL=1 + +# Execute the local-only workflow setup, verifying the environment seen by +# the unchanged sccache action. Separate shard directories avoid disk-cache +# server races when act runs both matrix entries concurrently. +setup="$(awk ' + /^ - name: Configure local sccache backend$/ {step=1; next} + step && /^ if:/ {next} + step && /^ run: \|/ {body=1; next} + body && /^ / {sub(/^ /, ""); print; next} + body {exit} +' .github/workflows/ci.yml)" +if [[ -z "$setup" ]]; then + fail 'local sccache setup step must exist' +else + awk '/^ - name: Configure local sccache backend$/ {getline; if ($0 != " if: env.ACT") exit 1; found=1} END {if (!found) exit 1}' \ + .github/workflows/ci.yml || fail 'local backend must be limited to act' + for shard in 1 2; do + : >"$TMP/env-$shard" + printf '%s\n' "$setup" | sed "s/\${{ matrix.shard }}/$shard/g" | + GITHUB_ENV="$TMP/env-$shard" CARGO_TARGET_DIR="$TMP/target" bash -e + grep -Fxq 'SCCACHE_GHA_ENABLED=false' "$TMP/env-$shard" || fail 'act must disable only the GHA backend' + grep -Fxq 'SCCACHE_GHA_VERSION=' "$TMP/env-$shard" || fail 'a GHA namespace must not re-enable the remote backend locally' + grep -Fxq "SCCACHE_DIR=$TMP/target/sccache-$shard" "$TMP/env-$shard" || fail 'local disk cache must persist per shard' + grep -Eq '^SCCACHE_CACHE_SIZE=[1-9][0-9]*[MG]$' "$TMP/env-$shard" || fail 'disk cache must have an explicit size bound' + done +fi +quality="$(awk '/^ quality:/{quality=1; next} quality && /^ [a-z]/{exit} quality {print}' .github/workflows/ci.yml)" +grep -Fq 'SCCACHE_GHA_ENABLED: "true"' <<<"$quality" || fail 'remote Quality GHA caching must remain enabled' +grep -Fq 'RUSTC_WRAPPER: "sccache"' <<<"$quality" || fail 'Quality compiler caching must remain enabled' + +if ((FAIL)); then + echo 'ci-local-test: FAIL' >&2 + exit 1 +fi +echo 'ci-local-test: OK' From 61f08717be70088a99054c4885ee10edcb786171 Mon Sep 17 00:00:00 2001 From: reviewer Date: Sat, 17 Oct 2026 23:24:07 +0800 Subject: [PATCH 5/6] [CTX-1316] fix(platform): require disposable display for clipboard live tests --- crates/bitty-platform/tests/clipboard_live.rs | 375 +++++++++--------- .../tests/clipboard_live_guard.rs | 81 ++++ .../tests/support/clipboard_isolation.rs | 143 +++++++ scripts/clipboard-isolated.sh | 68 ++++ scripts/clipboard.justfile | 23 ++ scripts/tests/clipboard-isolated.test.sh | 26 ++ 6 files changed, 527 insertions(+), 189 deletions(-) create mode 100644 crates/bitty-platform/tests/clipboard_live_guard.rs create mode 100644 crates/bitty-platform/tests/support/clipboard_isolation.rs create mode 100644 scripts/clipboard-isolated.sh create mode 100644 scripts/clipboard.justfile create mode 100644 scripts/tests/clipboard-isolated.test.sh diff --git a/crates/bitty-platform/tests/clipboard_live.rs b/crates/bitty-platform/tests/clipboard_live.rs index 01ae29978..92f15bbe1 100644 --- a/crates/bitty-platform/tests/clipboard_live.rs +++ b/crates/bitty-platform/tests/clipboard_live.rs @@ -2,18 +2,18 @@ //! //! These tests exercise the real `arboard` backend behind //! `bitty_platform::Clipboard` and are therefore **gated behind the -//! default-off `gui-tests` feature**: +//! default-off `gui-tests` feature** and a disposable display precondition: //! //! ```sh -//! cargo test -p bitty-platform --features gui-tests --test clipboard_live -- --nocapture +//! just --justfile scripts/clipboard.justfile isolated PATH_TO_CLIPBOARD_LIVE_TEST_BINARY //! ``` //! -//! They never run in CI (headless). Run them once per Tier-1 display -//! backend (Linux X11, Linux Wayland, macOS, Windows) and record the -//! machine-readable JSON lines (printed with `--nocapture`) as R-004 -//! evidence. Without a reachable display the handle degrades to the -//! headless buffer: every test then prints a `skipped` line and returns, -//! so enabling the feature on a headless machine stays green. +//! Ordinary `gui-tests` runs emit `skipped` before any native constructor, +//! read or write. A green harness with skipped lines is NOT native proof. +//! The test-only runner starts fresh Xvfb in private namespaces; the guard +//! checks process, display-lock and mount evidence rather than trusting an +//! environment opt-in. Other Tier-1 backends remain NOT EXECUTED until a +//! disposable runner exists. R-004 stays open. //! //! What is covered beyond the headless suites (`clipboard_sync.rs` and the //! `clipboard.rs` unit tests): @@ -29,11 +29,10 @@ //! value is preserved) and bounded reads never exceed //! `CLIPBOARD_MAX_BYTES`. //! -//! The tests never leave state behind: each one saves the pre-existing -//! clipboard value through the bounded read (already within the cap, so it -//! always fits back through the bounded write) and restores it through a -//! drop guard, even on assertion failure. A process-wide mutex serializes -//! the tests because the OS clipboard is global mutable state. Real-window +//! These tests deliberately overwrite both selections on the disposable +//! server. They do not read or attempt to restore owner clipboard content. +//! The runner destroys the display namespace on failure or timeout as well +//! as success. A mutex serializes native operations. Real-window //! confirmation UX (paste banner present/focus/dismiss) stays manual smoke //! evidence per platform; it is not asserted here. @@ -45,6 +44,21 @@ use std::sync::{Mutex, OnceLock}; use bitty_platform::PlatformError; use bitty_platform::clipboard::{CLIPBOARD_MAX_BYTES, Clipboard}; +#[path = "support/clipboard_isolation.rs"] +mod clipboard_isolation; + +/// The only entry to native operations; the closure cannot run on refusal. +fn with_isolated_display(scenario: &str, run: impl FnOnce()) { + if let Err(reason) = + clipboard_isolation::run_if_isolated(&clipboard_isolation::current(), || { + let _guard = live_lock().lock().expect("live clipboard mutex"); + run(); + }) + { + emit(scenario, "skipped", "not-accessed", reason); + } +} + /// Serializes live clipboard tests: the OS clipboard is process-global. fn live_lock() -> &'static Mutex<()> { static LOCK: OnceLock> = OnceLock::new(); @@ -70,17 +84,6 @@ fn degraded(clipboard: &Clipboard) -> bool { clipboard.is_headless() } -/// Saves the current regular-clipboard value through the bounded read so -/// the saved value always fits back through the bounded write. -fn save_clipboard(clipboard: &mut Clipboard) -> String { - clipboard.get_text_bounded().unwrap_or_default() -} - -/// Restores `saved` best-effort; only used for cleanup, never asserted. -fn restore_clipboard(clipboard: &mut Clipboard, saved: &str) { - let _ = clipboard.set_text(saved.to_owned()); -} - /// Unique sentinel per process so concurrent operator runs do not collide. fn sentinel(tag: &str) -> String { format!("bitty-r004-live-{tag}-pid{}", std::process::id()) @@ -88,160 +91,153 @@ fn sentinel(tag: &str) -> String { #[test] fn live_clipboard_roundtrip_proves_ownership() { - let _guard = live_lock().lock().expect("live clipboard mutex"); - let mut clipboard = Clipboard::new(); - let backend = backend_tag(&clipboard); - if degraded(&clipboard) { + with_isolated_display("clipboard-roundtrip", || { + let mut clipboard = Clipboard::new(); + let backend = backend_tag(&clipboard); + if degraded(&clipboard) { + emit( + "clipboard-roundtrip", + "skipped", + backend, + "no display backend reachable; headless fallback active", + ); + return; + } + let sentinel = sentinel("ownership"); + clipboard + .set_text(sentinel.clone()) + .expect("live write succeeds on a reachable backend"); + let read = clipboard + .get_text() + .expect("live read succeeds on a reachable backend"); + assert_eq!(read, sentinel, "live backend must return the written value"); emit( "clipboard-roundtrip", - "skipped", + "verified-local", backend, - "no display backend reachable; headless fallback active", + "write/read ownership holds on the native backend", ); - return; - } - let saved = save_clipboard(&mut clipboard); - let sentinel = sentinel("ownership"); - clipboard - .set_text(sentinel.clone()) - .expect("live write succeeds on a reachable backend"); - let read = clipboard - .get_text() - .expect("live read succeeds on a reachable backend"); - restore_clipboard(&mut clipboard, &saved); - assert_eq!(read, sentinel, "live backend must return the written value"); - assert_eq!( - clipboard.get_text_bounded().expect("restore read"), - saved, - "pre-existing clipboard value must be restored" - ); - emit( - "clipboard-roundtrip", - "verified-local", - backend, - "write/read ownership holds on the native backend", - ); + }); } #[test] fn live_clipboard_multibyte_encoding_roundtrip() { - let _guard = live_lock().lock().expect("live clipboard mutex"); - let mut clipboard = Clipboard::new(); - let backend = backend_tag(&clipboard); - if degraded(&clipboard) { + with_isolated_display("clipboard-encoding", || { + let mut clipboard = Clipboard::new(); + let backend = backend_tag(&clipboard); + if degraded(&clipboard) { + emit( + "clipboard-encoding", + "skipped", + backend, + "no display backend reachable; headless fallback active", + ); + return; + } + let sentinel = format!( + "{}-{}-{}", + sentinel("encoding"), + "日本語テスト", + "😀🧪\u{200b}\u{202e}" + ); + clipboard + .set_text(sentinel.clone()) + .expect("live multibyte write succeeds"); + let read = clipboard.get_text().expect("live multibyte read succeeds"); + assert_eq!( + read, sentinel, + "multibyte payload must round-trip byte-identical" + ); emit( "clipboard-encoding", - "skipped", + "verified-local", backend, - "no display backend reachable; headless fallback active", + "cjk/emoji/zero-width payload round-trips byte-identical", ); - return; - } - let saved = save_clipboard(&mut clipboard); - let sentinel = format!( - "{}-{}-{}", - sentinel("encoding"), - "日本語テスト", - "😀🧪\u{200b}\u{202e}" - ); - clipboard - .set_text(sentinel.clone()) - .expect("live multibyte write succeeds"); - let read = clipboard.get_text().expect("live multibyte read succeeds"); - restore_clipboard(&mut clipboard, &saved); - assert_eq!( - read, sentinel, - "multibyte payload must round-trip byte-identical" - ); - emit( - "clipboard-encoding", - "verified-local", - backend, - "cjk/emoji/zero-width payload round-trips byte-identical", - ); + }); } #[test] fn live_clipboard_over_limit_write_rejected_before_os() { - let _guard = live_lock().lock().expect("live clipboard mutex"); - let mut clipboard = Clipboard::new(); - let backend = backend_tag(&clipboard); - if degraded(&clipboard) { + with_isolated_display("clipboard-bound", || { + let mut clipboard = Clipboard::new(); + let backend = backend_tag(&clipboard); + if degraded(&clipboard) { + emit( + "clipboard-bound", + "skipped", + backend, + "no display backend reachable; headless fallback active", + ); + return; + } + let seed = sentinel("bound-seed"); + clipboard + .set_text(seed.clone()) + .expect("seed write succeeds"); + let over = "x".repeat(CLIPBOARD_MAX_BYTES + 512); + match clipboard.set_text(over) { + Err(PlatformError::ClipboardPayloadTooLarge { len, max }) => { + assert_eq!(max, CLIPBOARD_MAX_BYTES); + assert_eq!(len, CLIPBOARD_MAX_BYTES + 512); + } + other => panic!("over-limit live write must be rejected, got {other:?}"), + } + let kept = clipboard + .get_text() + .expect("read after rejected write succeeds"); + assert_eq!( + kept, seed, + "rejected write must leave the live value unchanged" + ); + let bounded = clipboard.get_text_bounded().expect("bounded read succeeds"); + assert!( + bounded.len() <= CLIPBOARD_MAX_BYTES, + "bounded reads never exceed the cap" + ); emit( "clipboard-bound", - "skipped", + "verified-local", backend, - "no display backend reachable; headless fallback active", + "over-limit write rejected pre-call; value preserved; bounded read capped", ); - return; - } - let saved = save_clipboard(&mut clipboard); - let seed = sentinel("bound-seed"); - clipboard - .set_text(seed.clone()) - .expect("seed write succeeds"); - let over = "x".repeat(CLIPBOARD_MAX_BYTES + 512); - match clipboard.set_text(over) { - Err(PlatformError::ClipboardPayloadTooLarge { len, max }) => { - assert_eq!(max, CLIPBOARD_MAX_BYTES); - assert_eq!(len, CLIPBOARD_MAX_BYTES + 512); - } - other => panic!("over-limit live write must be rejected, got {other:?}"), - } - let kept = clipboard - .get_text() - .expect("read after rejected write succeeds"); - restore_clipboard(&mut clipboard, &saved); - assert_eq!( - kept, seed, - "rejected write must leave the live value unchanged" - ); - let bounded = clipboard.get_text_bounded().expect("bounded read succeeds"); - assert!( - bounded.len() <= CLIPBOARD_MAX_BYTES, - "bounded reads never exceed the cap" - ); - emit( - "clipboard-bound", - "verified-local", - backend, - "over-limit write rejected pre-call; value preserved; bounded read capped", - ); + }); } #[test] fn live_clipboard_strict_constructor_matches_backend_presence() { - let _guard = live_lock().lock().expect("live clipboard mutex"); - let probe = Clipboard::new(); - let backend = backend_tag(&probe); - match Clipboard::new_strict() { - Ok(live) => { - assert!( - !live.is_headless() && probe.headless_reason().is_none(), - "strict success must agree with a present backend" - ); - emit( - "clipboard-backend-presence", - "verified-local", - backend, - "strict constructor succeeds; no init error recorded", - ); - } - Err(PlatformError::ClipboardUnavailable(reason)) => { - assert!( - probe.is_headless() && probe.headless_reason().is_some(), - "strict failure must agree with a degraded handle" - ); - assert!(!reason.is_empty(), "unavailability must carry a reason"); - emit( - "clipboard-backend-presence", - "skipped", - backend, - "no display backend reachable; failure is typed and reasoned", - ); + with_isolated_display("clipboard-backend-presence", || { + let probe = Clipboard::new(); + let backend = backend_tag(&probe); + match Clipboard::new_strict() { + Ok(live) => { + assert!( + !live.is_headless() && probe.headless_reason().is_none(), + "strict success must agree with a present backend" + ); + emit( + "clipboard-backend-presence", + "verified-local", + backend, + "strict constructor succeeds; no init error recorded", + ); + } + Err(PlatformError::ClipboardUnavailable(reason)) => { + assert!( + probe.is_headless() && probe.headless_reason().is_some(), + "strict failure must agree with a degraded handle" + ); + assert!(!reason.is_empty(), "unavailability must carry a reason"); + emit( + "clipboard-backend-presence", + "skipped", + backend, + "no display backend reachable; failure is typed and reasoned", + ); + } + Err(other) => panic!("strict constructor must fail typed, got {other:?}"), } - Err(other) => panic!("strict constructor must fail typed, got {other:?}"), - } + }); } /// Linux primary-selection roundtrip (middle-click / `wl-paste --primary`). @@ -256,44 +252,45 @@ fn live_clipboard_strict_constructor_matches_backend_presence() { ))] #[test] fn live_clipboard_primary_selection_roundtrip() { - let _guard = live_lock().lock().expect("live clipboard mutex"); - let mut clipboard = Clipboard::new(); - let backend = backend_tag(&clipboard); - if degraded(&clipboard) { - emit( - "clipboard-primary", - "skipped", - backend, - "no display backend reachable; headless fallback active", - ); - return; - } - let saved = clipboard.get_primary_bounded().unwrap_or_default(); - let sentinel = sentinel("primary"); - if let Err(err) = clipboard.set_primary(sentinel.clone()) { - emit( - "clipboard-primary", - "skipped", - backend, - &format!("primary selection unsupported here: {err:?}"), - ); - return; - } - let read = clipboard.get_primary().unwrap_or_default(); - let _ = clipboard.set_primary(saved.clone()); - if read != sentinel { + with_isolated_display("clipboard-primary", || { + let mut clipboard = Clipboard::new(); + let backend = backend_tag(&clipboard); + if degraded(&clipboard) { + emit( + "clipboard-primary", + "skipped", + backend, + "no display backend reachable; headless fallback active", + ); + return; + } + let sentinel = sentinel("primary"); + if clipboard.set_primary(sentinel.clone()).is_err() { + emit( + "clipboard-primary", + "skipped", + backend, + "primary write failed; native primary proof not executed", + ); + return; + } + let read = clipboard + .get_primary() + .expect("read test-owned primary selection"); + if read != sentinel { + emit( + "clipboard-primary", + "skipped", + backend, + "primary write did not round-trip on this compositor (optional target)", + ); + return; + } emit( "clipboard-primary", - "skipped", + "verified-local", backend, - "primary write did not round-trip on this compositor (optional target)", + "primary selection write/read ownership holds", ); - return; - } - emit( - "clipboard-primary", - "verified-local", - backend, - "primary selection write/read ownership holds", - ); + }); } diff --git a/crates/bitty-platform/tests/clipboard_live_guard.rs b/crates/bitty-platform/tests/clipboard_live_guard.rs new file mode 100644 index 000000000..ae86c6634 --- /dev/null +++ b/crates/bitty-platform/tests/clipboard_live_guard.rs @@ -0,0 +1,81 @@ +//! Pure regressions for the live harness isolation precondition (PX-6838). + +#[path = "support/clipboard_isolation.rs"] +#[allow(dead_code)] +mod clipboard_isolation; + +use clipboard_isolation::{IsolationEvidence, run_if_isolated, validate}; + +fn isolated() -> IsolationEvidence { + IsolationEvidence { + linux: true, + display_number: Some(23), + wayland_present: false, + server_pid: Some(120), + display_lock_pid: Some(120), + server_parent: Some(100), + test_parent: Some(100), + server_is_xvfb: true, + server_uses_displayfd: true, + server_disables_tcp: true, + private_tmp: true, + private_run: true, + server_owns_display_socket: true, + } +} + +#[test] +fn isolated_xvfb_evidence_allows_native_access() { + assert_eq!(validate(&isolated()), Ok(())); +} + +#[test] +fn ordinary_gui_feature_or_opt_in_is_not_isolation() { + assert!(validate(&IsolationEvidence::default()).is_err()); +} + +#[test] +fn every_isolation_requirement_is_checked_before_native_access() { + let mutations: &[fn(&mut IsolationEvidence)] = &[ + |e| e.linux = false, + |e| e.display_number = None, + |e| e.wayland_present = true, + |e| e.server_pid = None, + |e| e.display_lock_pid = None, + |e| e.display_lock_pid = Some(121), + |e| e.server_parent = None, + |e| e.test_parent = None, + |e| e.server_parent = Some(101), + |e| e.server_is_xvfb = false, + |e| e.server_uses_displayfd = false, + |e| e.server_disables_tcp = false, + |e| e.private_tmp = false, + |e| e.private_run = false, + |e| e.server_owns_display_socket = false, + ]; + for mutate in mutations { + let mut evidence = isolated(); + mutate(&mut evidence); + let mut native_calls = 0; + assert!( + run_if_isolated(&evidence, || { + native_calls += 1; + }) + .is_err() + ); + assert_eq!(native_calls, 0, "invalid evidence: {evidence:?}"); + } +} + +#[test] +fn valid_guard_executes_operation_once_and_propagates_panic() { + let mut calls = 0; + run_if_isolated(&isolated(), || calls += 1).unwrap(); + assert_eq!(calls, 1); + assert!( + std::panic::catch_unwind(|| { + let _ = run_if_isolated(&isolated(), || panic!("native assertion failure")); + }) + .is_err() + ); +} diff --git a/crates/bitty-platform/tests/support/clipboard_isolation.rs b/crates/bitty-platform/tests/support/clipboard_isolation.rs new file mode 100644 index 000000000..90b64d837 --- /dev/null +++ b/crates/bitty-platform/tests/support/clipboard_isolation.rs @@ -0,0 +1,143 @@ +//! Test-only isolation gate. No clipboard calls, display connections or content reads. + +#[derive(Debug, Default)] +pub struct IsolationEvidence { + pub linux: bool, + pub display_number: Option, + pub wayland_present: bool, + pub server_pid: Option, + pub display_lock_pid: Option, + pub server_parent: Option, + pub test_parent: Option, + pub server_is_xvfb: bool, + pub server_uses_displayfd: bool, + pub server_disables_tcp: bool, + pub private_tmp: bool, + pub private_run: bool, + pub server_owns_display_socket: bool, +} + +pub fn validate(e: &IsolationEvidence) -> Result<(), &'static str> { + if !e.linux + || e.display_number.is_none() + || e.wayland_present + || e.server_pid.is_none() + || e.server_pid != e.display_lock_pid + || e.server_parent.is_none() + || e.server_parent != e.test_parent + || !e.server_is_xvfb + || !e.server_uses_displayfd + || !e.server_disables_tcp + || !e.private_tmp + || !e.private_run + || !e.server_owns_display_socket + { + return Err("requires the disposable Xvfb namespace runner; native access not executed"); + } + Ok(()) +} + +pub fn run_if_isolated(e: &IsolationEvidence, run: impl FnOnce()) -> Result<(), &'static str> { + validate(e)?; + run(); + Ok(()) +} + +#[cfg(target_os = "linux")] +pub fn current() -> IsolationEvidence { + use std::{env, fs, os::unix::fs::FileTypeExt, path::Path}; + + fn parent(pid: u32) -> Option { + fs::read_to_string(format!("/proc/{pid}/status")) + .ok()? + .lines() + .find_map(|line| line.strip_prefix("PPid:")?.trim().parse().ok()) + } + + // The marker is a discovery hint only. Every independent fact below is + // required before a native constructor may be called. + let Some(server_pid) = env::var("BITTY_CLIPBOARD_XVFB_PID") + .ok() + .and_then(|value| value.parse::().ok()) + .filter(|pid| *pid > 1) + else { + return IsolationEvidence::default(); + }; + let display_number = env::var("DISPLAY").ok().and_then(|value| { + let number = value.strip_prefix(':')?; + number + .bytes() + .all(|b| b.is_ascii_digit()) + .then(|| number.parse().ok()) + .flatten() + }); + let args = fs::read(format!("/proc/{server_pid}/cmdline")).unwrap_or_default(); + let args: Vec<_> = args.split(|byte| *byte == 0).collect(); + let mounts = fs::read_to_string("/proc/self/mountinfo").unwrap_or_default(); + let private_mount = |path: &str| { + mounts.lines().any(|line| { + let Some((fields, kind)) = line.split_once(" - ") else { + return false; + }; + let fields: Vec<_> = fields.split_whitespace().collect(); + fields.get(3) == Some(&"/") + && fields.get(4) == Some(&path) + && kind.starts_with("tmpfs ") + }) + }; + IsolationEvidence { + linux: true, + display_number, + wayland_present: env::var_os("WAYLAND_DISPLAY").is_some() + || env::var_os("WAYLAND_SOCKET").is_some(), + server_pid: Some(server_pid), + display_lock_pid: display_number.and_then(|number| { + fs::read_to_string(format!("/tmp/.X{number}-lock")) + .ok()? + .trim() + .parse() + .ok() + }), + server_parent: parent(server_pid), + test_parent: parent(std::process::id()), + server_is_xvfb: fs::read_link(format!("/proc/{server_pid}/exe")) + .ok() + .and_then(|path| path.file_name().map(|name| name == Path::new("Xvfb"))) + .unwrap_or(false), + server_uses_displayfd: args.contains(&b"-displayfd".as_slice()), + server_disables_tcp: args + .windows(2) + .any(|pair| pair == [b"-nolisten".as_slice(), b"tcp".as_slice()]), + private_tmp: private_mount("/tmp"), + private_run: private_mount("/run"), + server_owns_display_socket: display_number.is_some_and(|number| { + let socket_path = format!("/tmp/.X11-unix/X{number}"); + if !fs::symlink_metadata(&socket_path) + .is_ok_and(|metadata| metadata.file_type().is_socket()) + { + return false; + } + let sockets = fs::read_to_string("/proc/net/unix").unwrap_or_default(); + let Ok(descriptors) = fs::read_dir(format!("/proc/{server_pid}/fd")) else { + return false; + }; + let owned: Vec<_> = descriptors + .flatten() + .filter_map(|entry| fs::read_link(entry.path()).ok()) + .collect(); + sockets.lines().any(|line| { + let fields: Vec<_> = line.split_whitespace().collect(); + fields.get(7) == Some(&socket_path.as_str()) + && fields + .get(6) + .is_some_and(|inode| owned.contains(&format!("socket:[{inode}]").into())) + }) + }), + } +} + +#[cfg(not(target_os = "linux"))] +pub fn current() -> IsolationEvidence { + // No disposable native backend runner has been established here yet. + IsolationEvidence::default() +} diff --git a/scripts/clipboard-isolated.sh b/scripts/clipboard-isolated.sh new file mode 100644 index 000000000..2c56eb2e2 --- /dev/null +++ b/scripts/clipboard-isolated.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# Destructive clipboard tests run only on a fresh Xvfb in private namespaces. +set -euo pipefail + +readonly BLOCKED=77 +readonly RUN_TIMEOUT=60 +readonly KILL_GRACE=5 +if [[ $# != 1 || ! -x $1 ]]; then + echo 'usage: just --justfile scripts/clipboard.justfile isolated PATH_TO_CLIPBOARD_LIVE_TEST_BINARY' >&2 + exit 2 +fi +for tool in bwrap Xvfb timeout; do + if ! command -v "$tool" >/dev/null; then + echo "clipboard-isolated: NOT EXECUTED (missing $tool)" >&2 + exit "$BLOCKED" + fi +done + +binary=$(realpath "$1") +mounts=() +for path in /usr /bin /lib /lib64; do + if [[ -e $path ]]; then + mounts+=(--ro-bind "$path" "$path") + fi +done + +# No host /tmp, runtime sockets, home, Xauthority, environment or network. +# bwrap destroys the namespaces on exit, including assertion/timeout failure. +timeout --foreground --kill-after="$KILL_GRACE" "$RUN_TIMEOUT" bwrap --unshare-all --die-with-parent --new-session \ + "${mounts[@]}" --proc /proc --dev /dev --tmpfs /tmp --tmpfs /run --chdir /tmp \ + --ro-bind "$binary" /clipboard-test --clearenv --setenv PATH /usr/bin:/bin \ + /bin/bash -euo pipefail -c ' + readonly READY_TIMEOUT=10 + readonly EXPECTED_SCENARIOS=5 + server_pid= + cleanup() { + if [[ -n $server_pid ]]; then + kill "$server_pid" 2>/dev/null || true + wait "$server_pid" 2>/dev/null || true + fi + } + trap cleanup EXIT + trap "exit 77" HUP INT TERM + mkdir bitty + cd bitty + mkfifo display-ready + exec 3<>display-ready + Xvfb -displayfd 3 -nolisten tcp >xvfb.log 2>&1 & + server_pid=$! + if ! read -r -t "$READY_TIMEOUT" display_number <&3; then + echo "clipboard-isolated: NOT EXECUTED (Xvfb startup failed)" >&2 + exit 77 + fi + [[ $display_number =~ ^[0-9]+$ ]] || exit 77 + exec 3>&- + export DISPLAY=:$display_number BITTY_CLIPBOARD_XVFB_PID=$server_pid + # The outer timeout preserves sibling ownership evidence in /proc. + status=0 + /clipboard-test --nocapture --test-threads=1 live_clipboard_ >results 2>&1 || status=$? + cat results + [[ $status == 0 ]] || exit "$status" + verified=$(grep -c "\"status\": \"verified-local\"" results || true) + if [[ $verified != "$EXPECTED_SCENARIOS" ]]; then + echo "clipboard-isolated: NOT FULLY EXECUTED (native scenario skipped)" >&2 + exit 77 + fi + echo "clipboard-isolated: verified all $EXPECTED_SCENARIOS native scenarios" + ' diff --git a/scripts/clipboard.justfile b/scripts/clipboard.justfile new file mode 100644 index 000000000..afdc01e82 --- /dev/null +++ b/scripts/clipboard.justfile @@ -0,0 +1,23 @@ +# Test-only gates: just --justfile scripts/clipboard.justfile . +set working-directory := ".." + +guard-unit output: + rustc --edition=2024 --test crates/bitty-platform/tests/clipboard_live_guard.rs -o "{{output}}" + "{{output}}" + +fmt: + cargo fmt -p bitty-platform + +test: + cargo test -p bitty-platform --locked --test clipboard_live_guard --test clipboard_live --features gui-tests -- --nocapture + +clippy: + cargo clippy -p bitty-platform --locked --test clipboard_live_guard --test clipboard_live --features gui-tests -- -D warnings + +isolated binary: + bash ./scripts/clipboard-isolated.sh "{{binary}}" + +runner-check: + shellcheck -S warning scripts/clipboard-isolated.sh scripts/tests/clipboard-isolated.test.sh + shfmt -d scripts/clipboard-isolated.sh scripts/tests/clipboard-isolated.test.sh + bash scripts/tests/clipboard-isolated.test.sh diff --git a/scripts/tests/clipboard-isolated.test.sh b/scripts/tests/clipboard-isolated.test.sh new file mode 100644 index 000000000..660a7b615 --- /dev/null +++ b/scripts/tests/clipboard-isolated.test.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Refusal regressions only: mocks never establish native display evidence. +set -euo pipefail +readonly RUNNER=scripts/clipboard-isolated.sh +BASH_BIN=$(command -v bash) +TRUE_BIN=$(type -P true) +readonly BASH_BIN TRUE_BIN +scratch=$(mktemp -d "${TMPDIR:?set TMPDIR to an approved scratch directory}/clipboard-runner.XXXXXX") +trap 'rm -rf "$scratch"' EXIT +mkdir "$scratch/bin" +for tool in bwrap timeout; do + printf '#!/bin/sh\nexit 99\n' >"$scratch/bin/$tool" + chmod +x "$scratch/bin/$tool" +done +status=0 +PATH="$scratch/bin" "$BASH_BIN" "$RUNNER" "$TRUE_BIN" >"$scratch/result" 2>&1 || status=$? +[[ $status == 77 ]] +grep -q 'NOT EXECUTED (missing Xvfb)' "$scratch/result" +if grep -q 'verified-local' "$scratch/result"; then + echo 'clipboard-runner-test: refusal incorrectly claimed native proof' >&2 + exit 1 +fi +status=0 +"$BASH_BIN" "$RUNNER" >"$scratch/usage" 2>&1 || status=$? +[[ $status == 2 ]] +echo 'clipboard-runner-test: PASS (refusal only; no native display executed)' From 99396d707f47f8cab6c838b9a4ba995e83a3c7fb Mon Sep 17 00:00:00 2001 From: reviewer Date: Sat, 17 Oct 2026 23:36:48 +0800 Subject: [PATCH 6/6] [CTX-1316] fix(platform): fail closed on unbound clipboard isolation evidence --- crates/bitty-platform/tests/clipboard_live.rs | 16 +- .../tests/clipboard_live_guard.rs | 152 ++++++++----- .../tests/support/clipboard_isolation.rs | 205 +++++++----------- scripts/clipboard-isolated.sh | 63 +----- scripts/clipboard.justfile | 6 + scripts/tests/clipboard-isolated.test.sh | 13 +- 6 files changed, 200 insertions(+), 255 deletions(-) diff --git a/crates/bitty-platform/tests/clipboard_live.rs b/crates/bitty-platform/tests/clipboard_live.rs index 92f15bbe1..41d1e4896 100644 --- a/crates/bitty-platform/tests/clipboard_live.rs +++ b/crates/bitty-platform/tests/clipboard_live.rs @@ -10,10 +10,11 @@ //! //! Ordinary `gui-tests` runs emit `skipped` before any native constructor, //! read or write. A green harness with skipped lines is NOT native proof. -//! The test-only runner starts fresh Xvfb in private namespaces; the guard -//! checks process, display-lock and mount evidence rather than trusting an -//! environment opt-in. Other Tier-1 backends remain NOT EXECUTED until a -//! disposable runner exists. R-004 stays open. +//! Native execution is disabled pending a reviewed connection-bound disposable +//! runner. Proc/mount observations cannot bind the later native connection; +//! another precheck would not eliminate replacement races (PX-6898). +//! The runner returns 77 before launching a display. All Tier-1 backends remain +//! NOT EXECUTED. R-004 stays open. //! //! What is covered beyond the headless suites (`clipboard_sync.rs` and the //! `clipboard.rs` unit tests): @@ -29,10 +30,9 @@ //! value is preserved) and bounded reads never exceed //! `CLIPBOARD_MAX_BYTES`. //! -//! These tests deliberately overwrite both selections on the disposable -//! server. They do not read or attempt to restore owner clipboard content. -//! The runner destroys the display namespace on failure or timeout as well -//! as success. A mutex serializes native operations. Real-window +//! The dormant scenario bodies would overwrite selections and must only be +//! re-enabled with connection-bound isolation. No save/restore is attempted. +//! Cleanup success/failure/timeout proof remains NOT EXECUTED. Real-window //! confirmation UX (paste banner present/focus/dismiss) stays manual smoke //! evidence per platform; it is not asserted here. diff --git a/crates/bitty-platform/tests/clipboard_live_guard.rs b/crates/bitty-platform/tests/clipboard_live_guard.rs index ae86c6634..4d2525968 100644 --- a/crates/bitty-platform/tests/clipboard_live_guard.rs +++ b/crates/bitty-platform/tests/clipboard_live_guard.rs @@ -1,81 +1,115 @@ -//! Pure regressions for the live harness isolation precondition (PX-6838). +//! Collector-level refusal regressions for PX-6898. No native backend calls. #[path = "support/clipboard_isolation.rs"] #[allow(dead_code)] mod clipboard_isolation; -use clipboard_isolation::{IsolationEvidence, run_if_isolated, validate}; +use clipboard_isolation::{BLOCKED, Observation, ProcessIdentity, collect, run_if_isolated}; -fn isolated() -> IsolationEvidence { - IsolationEvidence { - linux: true, - display_number: Some(23), - wayland_present: false, - server_pid: Some(120), - display_lock_pid: Some(120), - server_parent: Some(100), - test_parent: Some(100), - server_is_xvfb: true, - server_uses_displayfd: true, - server_disables_tcp: true, - private_tmp: true, - private_run: true, - server_owns_display_socket: true, +fn observation() -> Observation { + Observation { + namespaces: Some(["mnt-new".into(), "net-new".into(), "pid-new".into()]), + parent_namespaces: Some(["mnt-old".into(), "net-old".into(), "pid-old".into()]), + runner: Some(ProcessIdentity { + pid: 10, + start: 100, + parent: 1, + }), + server: Some(ProcessIdentity { + pid: 11, + start: 110, + parent: 10, + }), + mounts: vec![ + "21 1 0:10 / /tmp rw - tmpfs tmpfs rw".into(), + "22 1 0:11 / /run rw - tmpfs tmpfs rw".into(), + ], } } +fn assert_refused(before: Observation, after: Observation) { + let mut observations = [before, after].into_iter(); + let evidence = collect(|| observations.next().expect("two collector snapshots")); + assert!(observations.next().is_none()); + let mut native_calls = 0; + assert_eq!( + run_if_isolated(&evidence, || native_calls += 1), + Err(BLOCKED) + ); + assert_eq!(native_calls, 0); +} + #[test] -fn isolated_xvfb_evidence_allows_native_access() { - assert_eq!(validate(&isolated()), Ok(())); +fn host_tmpfs_and_sibling_server_cannot_authorize_native_access() { + let mut host = observation(); + host.namespaces = host.parent_namespaces.clone(); + assert_refused(host.clone(), host); } #[test] -fn ordinary_gui_feature_or_opt_in_is_not_isolation() { - assert!(validate(&IsolationEvidence::default()).is_err()); +fn reused_server_cannot_authorize_native_access() { + let mut reused = observation(); + reused.server.as_mut().unwrap().start = 90; + assert_refused(reused.clone(), reused); } #[test] -fn every_isolation_requirement_is_checked_before_native_access() { - let mutations: &[fn(&mut IsolationEvidence)] = &[ - |e| e.linux = false, - |e| e.display_number = None, - |e| e.wayland_present = true, - |e| e.server_pid = None, - |e| e.display_lock_pid = None, - |e| e.display_lock_pid = Some(121), - |e| e.server_parent = None, - |e| e.test_parent = None, - |e| e.server_parent = Some(101), - |e| e.server_is_xvfb = false, - |e| e.server_uses_displayfd = false, - |e| e.server_disables_tcp = false, - |e| e.private_tmp = false, - |e| e.private_run = false, - |e| e.server_owns_display_socket = false, - ]; - for mutate in mutations { - let mut evidence = isolated(); - mutate(&mut evidence); - let mut native_calls = 0; - assert!( - run_if_isolated(&evidence, || { - native_calls += 1; - }) - .is_err() - ); - assert_eq!(native_calls, 0, "invalid evidence: {evidence:?}"); - } +fn stale_or_replaced_server_evidence_never_invokes_native_closure() { + let before = observation(); + let mut replaced = before.clone(); + replaced.server.as_mut().unwrap().start += 1; + assert_refused(before.clone(), replaced); + let mut stale = before.clone(); + stale.server = None; + assert_refused(before, stale); +} + +#[test] +fn replaced_mount_or_network_namespace_never_invokes_native_closure() { + let before = observation(); + let mut replaced = before.clone(); + replaced.mounts[0] = "31 1 0:20 / /tmp rw - tmpfs tmpfs rw".into(); + assert_refused(before.clone(), replaced); + let mut replaced = before.clone(); + replaced.namespaces.as_mut().unwrap()[1] = "net-replaced".into(); + assert_refused(before, replaced); } #[test] -fn valid_guard_executes_operation_once_and_propagates_panic() { +fn even_stable_fresh_looking_evidence_cannot_bind_future_connection() { + assert_refused(observation(), observation()); + assert_refused(Observation::default(), Observation::default()); +} + +#[test] +fn actual_collector_never_authorizes_native_operations() { let mut calls = 0; - run_if_isolated(&isolated(), || calls += 1).unwrap(); - assert_eq!(calls, 1); - assert!( - std::panic::catch_unwind(|| { - let _ = run_if_isolated(&isolated(), || panic!("native assertion failure")); - }) - .is_err() + assert_eq!( + run_if_isolated(&clipboard_isolation::current(), || calls += 1), + Err(BLOCKED) ); + assert_eq!(calls, 0); +} + +#[test] +fn actual_collector_refuses_forged_environment_hints_in_child_processes() { + // Separate processes avoid modifying the parallel test harness environment. + // None of these child tests constructs a native clipboard or opens a display. + for pid in [ + "invalid".to_owned(), + "0".into(), + std::process::id().to_string(), + ] { + let result = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "actual_collector_never_authorizes_native_operations", + ]) + .env("BITTY_CLIPBOARD_XVFB_PID", pid) + .env("DISPLAY", ":0") + .output() + .unwrap(); + assert!(result.status.success()); + assert!(String::from_utf8_lossy(&result.stdout).contains("1 passed")); + } } diff --git a/crates/bitty-platform/tests/support/clipboard_isolation.rs b/crates/bitty-platform/tests/support/clipboard_isolation.rs index 90b64d837..f480681b2 100644 --- a/crates/bitty-platform/tests/support/clipboard_isolation.rs +++ b/crates/bitty-platform/tests/support/clipboard_isolation.rs @@ -1,143 +1,104 @@ -//! Test-only isolation gate. No clipboard calls, display connections or content reads. +//! Test-only refusal gate. No display connections or clipboard content reads. +//! +//! Proc observations are diagnostics, never native authorization. Even stable +//! namespace, PID/start-time and mount identities cannot bind the subsequent +//! independent Clipboard::new connection to that observed server. Refuse until +//! a reviewed connection-bound runner contract exists (PX-6898). -#[derive(Debug, Default)] +pub const BLOCKED: &str = + "native access not executed: connection-bound disposable runner unavailable"; + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ProcessIdentity { + pub pid: u32, + pub start: u64, + pub parent: u32, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Observation { + // mnt, net, pid; namespace identifiers are opaque kernel identities. + pub namespaces: Option<[String; 3]>, + pub parent_namespaces: Option<[String; 3]>, + pub runner: Option, + pub server: Option, + // Full mountinfo rows preserve mount IDs and devices, not just tmpfs type. + pub mounts: Vec, +} + +#[derive(Debug)] pub struct IsolationEvidence { - pub linux: bool, - pub display_number: Option, - pub wayland_present: bool, - pub server_pid: Option, - pub display_lock_pid: Option, - pub server_parent: Option, - pub test_parent: Option, - pub server_is_xvfb: bool, - pub server_uses_displayfd: bool, - pub server_disables_tcp: bool, - pub private_tmp: bool, - pub private_run: bool, - pub server_owns_display_socket: bool, + // Private: callers cannot manufacture an authorized token from booleans. + _observed: Observation, } -pub fn validate(e: &IsolationEvidence) -> Result<(), &'static str> { - if !e.linux - || e.display_number.is_none() - || e.wayland_present - || e.server_pid.is_none() - || e.server_pid != e.display_lock_pid - || e.server_parent.is_none() - || e.server_parent != e.test_parent - || !e.server_is_xvfb - || !e.server_uses_displayfd - || !e.server_disables_tcp - || !e.private_tmp - || !e.private_run - || !e.server_owns_display_socket - { - return Err("requires the disposable Xvfb namespace runner; native access not executed"); +/// The collector deliberately issues no positive authorization, including for +/// consistent synthetic evidence. A second observation is not a TOCTOU fix. +pub fn collect(mut observe: impl FnMut() -> Observation) -> IsolationEvidence { + let _before = observe(); + IsolationEvidence { + _observed: observe(), } - Ok(()) } -pub fn run_if_isolated(e: &IsolationEvidence, run: impl FnOnce()) -> Result<(), &'static str> { - validate(e)?; - run(); - Ok(()) +pub fn run_if_isolated( + _evidence: &IsolationEvidence, + _native: impl FnOnce(), +) -> Result<(), &'static str> { + Err(BLOCKED) } #[cfg(target_os = "linux")] pub fn current() -> IsolationEvidence { - use std::{env, fs, os::unix::fs::FileTypeExt, path::Path}; + use std::{env, fs}; - fn parent(pid: u32) -> Option { - fs::read_to_string(format!("/proc/{pid}/status")) - .ok()? - .lines() - .find_map(|line| line.strip_prefix("PPid:")?.trim().parse().ok()) + fn identity(pid: u32) -> Option { + // comm can contain spaces and parentheses; fields after the final ')' + // start at stat field 3. Parent is field 4 and starttime is field 22. + let stat = fs::read_to_string(format!("/proc/{pid}/stat")).ok()?; + let (_, tail) = stat.rsplit_once(')')?; + let fields: Vec<_> = tail.split_whitespace().collect(); + Some(ProcessIdentity { + pid, + parent: fields.get(1)?.parse().ok()?, + start: fields.get(19)?.parse().ok()?, + }) } - // The marker is a discovery hint only. Every independent fact below is - // required before a native constructor may be called. - let Some(server_pid) = env::var("BITTY_CLIPBOARD_XVFB_PID") - .ok() - .and_then(|value| value.parse::().ok()) - .filter(|pid| *pid > 1) - else { - return IsolationEvidence::default(); - }; - let display_number = env::var("DISPLAY").ok().and_then(|value| { - let number = value.strip_prefix(':')?; - number - .bytes() - .all(|b| b.is_ascii_digit()) - .then(|| number.parse().ok()) - .flatten() - }); - let args = fs::read(format!("/proc/{server_pid}/cmdline")).unwrap_or_default(); - let args: Vec<_> = args.split(|byte| *byte == 0).collect(); - let mounts = fs::read_to_string("/proc/self/mountinfo").unwrap_or_default(); - let private_mount = |path: &str| { - mounts.lines().any(|line| { - let Some((fields, kind)) = line.split_once(" - ") else { - return false; - }; - let fields: Vec<_> = fields.split_whitespace().collect(); - fields.get(3) == Some(&"/") - && fields.get(4) == Some(&path) - && kind.starts_with("tmpfs ") - }) - }; - IsolationEvidence { - linux: true, - display_number, - wayland_present: env::var_os("WAYLAND_DISPLAY").is_some() - || env::var_os("WAYLAND_SOCKET").is_some(), - server_pid: Some(server_pid), - display_lock_pid: display_number.and_then(|number| { - fs::read_to_string(format!("/tmp/.X{number}-lock")) - .ok()? - .trim() - .parse() + fn namespaces(pid: u32) -> Option<[String; 3]> { + let read = |kind| { + fs::read_link(format!("/proc/{pid}/ns/{kind}")) .ok() - }), - server_parent: parent(server_pid), - test_parent: parent(std::process::id()), - server_is_xvfb: fs::read_link(format!("/proc/{server_pid}/exe")) - .ok() - .and_then(|path| path.file_name().map(|name| name == Path::new("Xvfb"))) - .unwrap_or(false), - server_uses_displayfd: args.contains(&b"-displayfd".as_slice()), - server_disables_tcp: args - .windows(2) - .any(|pair| pair == [b"-nolisten".as_slice(), b"tcp".as_slice()]), - private_tmp: private_mount("/tmp"), - private_run: private_mount("/run"), - server_owns_display_socket: display_number.is_some_and(|number| { - let socket_path = format!("/tmp/.X11-unix/X{number}"); - if !fs::symlink_metadata(&socket_path) - .is_ok_and(|metadata| metadata.file_type().is_socket()) - { - return false; - } - let sockets = fs::read_to_string("/proc/net/unix").unwrap_or_default(); - let Ok(descriptors) = fs::read_dir(format!("/proc/{server_pid}/fd")) else { - return false; - }; - let owned: Vec<_> = descriptors - .flatten() - .filter_map(|entry| fs::read_link(entry.path()).ok()) - .collect(); - sockets.lines().any(|line| { - let fields: Vec<_> = line.split_whitespace().collect(); - fields.get(7) == Some(&socket_path.as_str()) - && fields - .get(6) - .is_some_and(|inode| owned.contains(&format!("socket:[{inode}]").into())) - }) - }), + .map(|value| value.to_string_lossy().into_owned()) + }; + Some([read("mnt")?, read("net")?, read("pid")?]) } + + collect(|| { + let pid = std::process::id(); + let runner = identity(pid).and_then(|process| identity(process.parent)); + // An inherited PID is only a diagnostic hint, never authority. Do not + // open the display socket, display lock or clipboard to inspect it. + let server = env::var("BITTY_CLIPBOARD_XVFB_PID") + .ok() + .and_then(|value| value.parse().ok()) + .and_then(identity); + Observation { + namespaces: namespaces(pid), + parent_namespaces: runner.as_ref().and_then(|runner| namespaces(runner.pid)), + runner, + server, + mounts: fs::read_to_string("/proc/self/mountinfo") + .unwrap_or_default() + .lines() + .filter(|line| matches!(line.split_whitespace().nth(4), Some("/tmp" | "/run"))) + .map(str::to_owned) + .collect(), + } + }) } #[cfg(not(target_os = "linux"))] pub fn current() -> IsolationEvidence { - // No disposable native backend runner has been established here yet. - IsolationEvidence::default() + collect(Observation::default) } diff --git a/scripts/clipboard-isolated.sh b/scripts/clipboard-isolated.sh index 2c56eb2e2..30127db89 100644 --- a/scripts/clipboard-isolated.sh +++ b/scripts/clipboard-isolated.sh @@ -1,68 +1,11 @@ #!/usr/bin/env bash -# Destructive clipboard tests run only on a fresh Xvfb in private namespaces. +# PX-6898: do not launch a display with an unproven authorization boundary. set -euo pipefail readonly BLOCKED=77 -readonly RUN_TIMEOUT=60 -readonly KILL_GRACE=5 if [[ $# != 1 || ! -x $1 ]]; then echo 'usage: just --justfile scripts/clipboard.justfile isolated PATH_TO_CLIPBOARD_LIVE_TEST_BINARY' >&2 exit 2 fi -for tool in bwrap Xvfb timeout; do - if ! command -v "$tool" >/dev/null; then - echo "clipboard-isolated: NOT EXECUTED (missing $tool)" >&2 - exit "$BLOCKED" - fi -done - -binary=$(realpath "$1") -mounts=() -for path in /usr /bin /lib /lib64; do - if [[ -e $path ]]; then - mounts+=(--ro-bind "$path" "$path") - fi -done - -# No host /tmp, runtime sockets, home, Xauthority, environment or network. -# bwrap destroys the namespaces on exit, including assertion/timeout failure. -timeout --foreground --kill-after="$KILL_GRACE" "$RUN_TIMEOUT" bwrap --unshare-all --die-with-parent --new-session \ - "${mounts[@]}" --proc /proc --dev /dev --tmpfs /tmp --tmpfs /run --chdir /tmp \ - --ro-bind "$binary" /clipboard-test --clearenv --setenv PATH /usr/bin:/bin \ - /bin/bash -euo pipefail -c ' - readonly READY_TIMEOUT=10 - readonly EXPECTED_SCENARIOS=5 - server_pid= - cleanup() { - if [[ -n $server_pid ]]; then - kill "$server_pid" 2>/dev/null || true - wait "$server_pid" 2>/dev/null || true - fi - } - trap cleanup EXIT - trap "exit 77" HUP INT TERM - mkdir bitty - cd bitty - mkfifo display-ready - exec 3<>display-ready - Xvfb -displayfd 3 -nolisten tcp >xvfb.log 2>&1 & - server_pid=$! - if ! read -r -t "$READY_TIMEOUT" display_number <&3; then - echo "clipboard-isolated: NOT EXECUTED (Xvfb startup failed)" >&2 - exit 77 - fi - [[ $display_number =~ ^[0-9]+$ ]] || exit 77 - exec 3>&- - export DISPLAY=:$display_number BITTY_CLIPBOARD_XVFB_PID=$server_pid - # The outer timeout preserves sibling ownership evidence in /proc. - status=0 - /clipboard-test --nocapture --test-threads=1 live_clipboard_ >results 2>&1 || status=$? - cat results - [[ $status == 0 ]] || exit "$status" - verified=$(grep -c "\"status\": \"verified-local\"" results || true) - if [[ $verified != "$EXPECTED_SCENARIOS" ]]; then - echo "clipboard-isolated: NOT FULLY EXECUTED (native scenario skipped)" >&2 - exit 77 - fi - echo "clipboard-isolated: verified all $EXPECTED_SCENARIOS native scenarios" - ' +echo 'clipboard-isolated: NOT EXECUTED (connection-bound disposable runner unavailable)' >&2 +exit "$BLOCKED" diff --git a/scripts/clipboard.justfile b/scripts/clipboard.justfile index afdc01e82..4c0c2f47b 100644 --- a/scripts/clipboard.justfile +++ b/scripts/clipboard.justfile @@ -21,3 +21,9 @@ runner-check: shellcheck -S warning scripts/clipboard-isolated.sh scripts/tests/clipboard-isolated.test.sh shfmt -d scripts/clipboard-isolated.sh scripts/tests/clipboard-isolated.test.sh bash scripts/tests/clipboard-isolated.test.sh + +secrets-diff: + git diff HEAD | gitleaks stdin --redact --no-banner + +secrets-history base: + gitleaks git --log-opts="{{base}}..HEAD" --redact --no-banner diff --git a/scripts/tests/clipboard-isolated.test.sh b/scripts/tests/clipboard-isolated.test.sh index 660a7b615..97bea3a7d 100644 --- a/scripts/tests/clipboard-isolated.test.sh +++ b/scripts/tests/clipboard-isolated.test.sh @@ -3,19 +3,20 @@ set -euo pipefail readonly RUNNER=scripts/clipboard-isolated.sh BASH_BIN=$(command -v bash) -TRUE_BIN=$(type -P true) -readonly BASH_BIN TRUE_BIN +readonly BASH_BIN scratch=$(mktemp -d "${TMPDIR:?set TMPDIR to an approved scratch directory}/clipboard-runner.XXXXXX") trap 'rm -rf "$scratch"' EXIT mkdir "$scratch/bin" -for tool in bwrap timeout; do - printf '#!/bin/sh\nexit 99\n' >"$scratch/bin/$tool" +for tool in bwrap Xvfb timeout test-binary; do + printf '#!/bin/sh\n: > "$CLIPBOARD_REFUSAL_MARKER"\nexit 99\n' >"$scratch/bin/$tool" chmod +x "$scratch/bin/$tool" done status=0 -PATH="$scratch/bin" "$BASH_BIN" "$RUNNER" "$TRUE_BIN" >"$scratch/result" 2>&1 || status=$? +PATH="$scratch/bin" CLIPBOARD_REFUSAL_MARKER="$scratch/launched" \ + "$BASH_BIN" "$RUNNER" "$scratch/bin/test-binary" >"$scratch/result" 2>&1 || status=$? [[ $status == 77 ]] -grep -q 'NOT EXECUTED (missing Xvfb)' "$scratch/result" +[[ ! -e $scratch/launched ]] +grep -q 'NOT EXECUTED (connection-bound disposable runner unavailable)' "$scratch/result" if grep -q 'verified-local' "$scratch/result"; then echo 'clipboard-runner-test: refusal incorrectly claimed native proof' >&2 exit 1