diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f6d7c1df9..955e1b867 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -3,29 +3,31 @@ name: Test on: push: branches: [main] - paths-ignore: - - "docs/**" - - "*.md" - # Content-checked by contract tests (vitest + cargo test) — must still run CI. - - "!docs/doctoring/release-artifact-provenance.md" - - "!docs/doctoring/tauri-content-security-policy.md" - - "!docs/doctoring/model-artifact-integrity.md" - - "!docs/doctoring/model-load-handle-binding.md" - - "!docs/development/icloud-local-eviction-batch.md" - - "!docs/architecture/goals/cloud-offload-goal.json" - - "!CHANGELOG.md" + paths: + - "**" + - "!docs/**" + - "!*.md" + # GitHub supports re-inclusion only with ordered positive patterns under `paths`. + - "docs/doctoring/release-artifact-provenance.md" + - "docs/doctoring/tauri-content-security-policy.md" + - "docs/doctoring/model-artifact-integrity.md" + - "docs/doctoring/model-load-handle-binding.md" + - "docs/development/icloud-local-eviction-batch.md" + - "docs/architecture/goals/cloud-offload-goal.json" + - "CHANGELOG.md" pull_request: - paths-ignore: - - "docs/**" - - "*.md" - # Content-checked by contract tests (vitest + cargo test) — must still run CI. - - "!docs/doctoring/release-artifact-provenance.md" - - "!docs/doctoring/tauri-content-security-policy.md" - - "!docs/doctoring/model-artifact-integrity.md" - - "!docs/doctoring/model-load-handle-binding.md" - - "!docs/development/icloud-local-eviction-batch.md" - - "!docs/architecture/goals/cloud-offload-goal.json" - - "!CHANGELOG.md" + paths: + - "**" + - "!docs/**" + - "!*.md" + # GitHub supports re-inclusion only with ordered positive patterns under `paths`. + - "docs/doctoring/release-artifact-provenance.md" + - "docs/doctoring/tauri-content-security-policy.md" + - "docs/doctoring/model-artifact-integrity.md" + - "docs/doctoring/model-load-handle-binding.md" + - "docs/development/icloud-local-eviction-batch.md" + - "docs/architecture/goals/cloud-offload-goal.json" + - "CHANGELOG.md" permissions: contents: read @@ -38,45 +40,155 @@ jobs: test: runs-on: ubuntu-latest timeout-minutes: 30 + env: + CARGO_BUILD_JOBS: 2 + CARGO_INCREMENTAL: 0 + CARGO_PROFILE_TEST_DEBUG: 0 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} persist-credentials: false - name: Install Tauri system deps run: | - sudo apt-get update - sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev + for source_file in /etc/apt/sources.list.d/*; do + if [[ -f "$source_file" ]] && grep -q 'dl.google.com/linux/chrome' "$source_file"; then + sudo rm -f "$source_file" + fi + done + sudo apt-get -o Acquire::Retries=3 update + sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev lsof - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 with: workspaces: src-tauri cache-targets: false + - name: Configure private test temp + run: | + mkdir -p "$RUNNER_TEMP/disksage" + echo "TMPDIR=$RUNNER_TEMP/disksage" >> "$GITHUB_ENV" - name: Rust tests (includes unix symlink test) - run: cargo test --manifest-path src-tauri/Cargo.toml + id: rust_test + continue-on-error: true + run: | + set -o pipefail + cargo test --locked --manifest-path src-tauri/Cargo.toml 2>&1 | tee "$RUNNER_TEMP/disksage-rust-test.log" + - name: Upload authoritative Rust diagnostic transcript + if: steps.rust_test.outcome == 'success' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: rust-test-diagnostics-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/disksage-rust-test.log + if-no-files-found: error + - name: Upload authoritative Rust test failure transcript + if: steps.rust_test.outcome == 'failure' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: rust-test-failure-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/disksage-rust-test.log + if-no-files-found: error + - name: Preserve Rust test failure + if: steps.rust_test.outcome == 'failure' + run: exit 1 - name: Headless cloud planner tests - run: cargo test --manifest-path src-tauri/Cargo.toml --features cloud-cli --bin disksage-cloud-plan + run: cargo test --locked --manifest-path src-tauri/Cargo.toml --features cloud-cli --bin disksage-cloud-plan - name: Exact duplicate audit tests run: | - cargo test --manifest-path src-tauri/Cargo.toml --features cloud-cli duplicate_audit - cargo test --manifest-path src-tauri/Cargo.toml --features cloud-cli --bin disksage-duplicate-audit + cargo test --locked --manifest-path src-tauri/Cargo.toml --features cloud-cli duplicate_audit + cargo test --locked --manifest-path src-tauri/Cargo.toml --features cloud-cli --bin disksage-duplicate-audit - name: Extraction-free archive tree proof tests run: | - cargo test --manifest-path src-tauri/Cargo.toml --features archive-cli archive_git_tree - cargo test --manifest-path src-tauri/Cargo.toml --features archive-cli --bin disksage-archive-tree - cargo test --manifest-path src-tauri/Cargo.toml --features archive-cli --test archive_tree_help_exit + cargo test --locked --manifest-path src-tauri/Cargo.toml --features archive-cli archive_git_tree + cargo test --locked --manifest-path src-tauri/Cargo.toml --features archive-cli --bin disksage-archive-tree + cargo test --locked --manifest-path src-tauri/Cargo.toml --features archive-cli --test archive_tree_help_exit - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: 20.19.0 + node-version: 22.12.0 - run: npm ci - - run: npm test + - name: Run npm test + id: npm_test + continue-on-error: true + run: | + set -o pipefail + npm test 2>&1 | tee "$RUNNER_TEMP/disksage-npm-test.log" + - name: Upload authoritative npm test failure transcript + if: steps.npm_test.outcome == 'failure' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: npm-test-failure-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/disksage-npm-test.log + if-no-files-found: error + - name: Diagnose SvelteKit sync after npm test failure + if: steps.npm_test.outcome == 'failure' + continue-on-error: true + run: npm exec -- svelte-kit sync + - name: Diagnose Vitest after npm test failure + if: steps.npm_test.outcome == 'failure' + continue-on-error: true + run: npm exec -- vitest run + - name: Diagnose workflow contract after npm test failure + if: steps.npm_test.outcome == 'failure' + continue-on-error: true + run: node --test scripts/ci/workflow-concurrency-contract.test.mjs + - name: Diagnose browser test after npm test failure + if: steps.npm_test.outcome == 'failure' + continue-on-error: true + run: npm run test:browser --if-present + - name: Preserve npm test failure + if: steps.npm_test.outcome == 'failure' + run: exit 1 - run: npm run build + macos-cache-cleanup: + runs-on: macos-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + ref: ${{ github.event.pull_request.head.sha || github.sha }} + - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + with: + workspaces: src-tauri + cache-targets: false + - name: macOS cache cleanup regressions when owner source is present + env: + TMPDIR: ${{ runner.temp }} + run: | + for test_name in cache_cleanup_corepack_scope cache_cleanup_cli_permanent_gradle generated_cache_staged_activity; do + if [[ -f "src-tauri/tests/${test_name}.rs" ]]; then + cargo test --locked --manifest-path src-tauri/Cargo.toml --test "$test_name" + else + printf 'SKIP %s: owner source absent; no runtime regression executed\n' "$test_name" + fi + done + - name: macOS Unix process-group regression when owner source is present + env: + TMPDIR: ${{ runner.temp }} + run: | + if [[ -f "src-tauri/src/unix_process_group.rs" ]]; then + cargo test --locked --manifest-path src-tauri/Cargo.toml --lib unix_process_group::tests + else + printf 'SKIP unix_process_group: owner source absent; no runtime regression executed\n' + fi + - name: macOS provider global-sync process-group regression when owner source is present + env: + TMPDIR: ${{ runner.temp }} + run: | + if [[ -f "src-tauri/tests/provider_global_sync_success_pipe_contract.rs" ]]; then + cargo test --locked --manifest-path src-tauri/Cargo.toml --test provider_global_sync_success_pipe_contract + else + printf 'SKIP provider_global_sync_success_pipe_contract: owner source absent; no runtime regression executed\n' + fi + windows-home-resolution: runs-on: windows-latest - timeout-minutes: 10 + timeout-minutes: 30 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} persist-credentials: false - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable - name: Windows absolute-home regression @@ -85,6 +197,26 @@ jobs: New-Item -ItemType Directory -Force target | Out-Null rustc --edition=2021 --test src-tauri/tests/home_resolution_contract.rs -o target/home-resolution-contract.exe & .\target\home-resolution-contract.exe + - name: Windows agent-state regression when owner source is present + shell: pwsh + run: | + if (Test-Path 'src-tauri/src/agent_state_guard.rs') { + rustc --edition=2021 --test src-tauri/src/agent_state_guard.rs -o target/agent-state-guard.exe + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + & .\target\agent-state-guard.exe --nocapture + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + } else { + Write-Output 'SKIP agent_state_guard: owner source absent; no runtime regression executed' + } + - name: Windows provider OAuth process contract when owner source is present + shell: pwsh + run: | + if (Test-Path 'src-tauri/tests/provider_oauth_cli_process.rs') { + cargo test --manifest-path src-tauri/Cargo.toml --locked --features cloud-cli --test provider_oauth_cli_process + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + } else { + Write-Output 'SKIP provider_oauth_cli_process: owner source absent; no runtime regression executed' + } llm-engine-build: runs-on: ubuntu-latest @@ -92,10 +224,16 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} persist-credentials: false - name: Install build deps (llama.cpp native + tauri) run: | - sudo apt-get update + for source_file in /etc/apt/sources.list.d/*; do + if [[ -f "$source_file" ]] && grep -q 'dl.google.com/linux/chrome' "$source_file"; then + sudo rm -f "$source_file" + fi + done + sudo apt-get -o Acquire::Retries=3 update sudo apt-get install -y cmake clang libclang-dev libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 @@ -103,4 +241,4 @@ jobs: workspaces: src-tauri cache-targets: false - name: Build with llm-engine (compiles real llama.cpp CPU + engine.rs FFI) - run: cargo test --manifest-path src-tauri/Cargo.toml --features llm-engine --lib --no-run + run: cargo test --locked --manifest-path src-tauri/Cargo.toml --features llm-engine --lib --no-run diff --git a/src-tauri/src/brew_cleanup.rs b/src-tauri/src/brew_cleanup.rs index 6312b153d..74deed8ea 100644 --- a/src-tauri/src/brew_cleanup.rs +++ b/src-tauri/src/brew_cleanup.rs @@ -4,9 +4,9 @@ //! existing human confirmation boundary; it never supplies a command or path. use serde::{Deserialize, Serialize}; +#[cfg(any(target_os = "macos", all(test, unix)))] +use std::io; use std::io::Write; -#[cfg(target_os = "macos")] -use std::io::{self, Read}; use std::path::{Path, PathBuf}; pub const SCHEMA_VERSION: u32 = 1; @@ -14,6 +14,7 @@ pub const EXECUTABLE: &str = "brew"; pub const DRY_RUN_ARGUMENTS: [&str; 3] = ["cleanup", "--prune-prefix", "--dry-run"]; pub const EXECUTE_ARGUMENTS: [&str; 2] = ["cleanup", "--prune-prefix"]; const MAX_OUTPUT_BYTES: usize = 32 * 1024; +const MAX_BREW_SCRIPT_BYTES: usize = 8 * 1024 * 1024; const MAX_REASON_CHARS: usize = 1_000; const COMMAND_TIMEOUT_MS: u64 = 120_000; pub const MAX_JUDGMENT_AGE_MS: u64 = 5 * 60 * 1_000; @@ -109,7 +110,7 @@ struct CommandOutput { truncated: bool, } -#[cfg(target_os = "macos")] +#[cfg(any(target_os = "macos", all(test, unix)))] struct VerifiedBrewExecutable { file: std::fs::File, identity: String, @@ -141,8 +142,9 @@ fn fixed_brew_path() -> Result { Err("brew-cleanup-unsupported-platform".into()) } -#[cfg(target_os = "macos")] +#[cfg(any(target_os = "macos", all(test, unix)))] fn open_verified_brew(path: &Path) -> Result { + use std::io::{Read, Seek, SeekFrom}; use std::os::unix::fs::{MetadataExt, PermissionsExt}; let path_metadata = std::fs::symlink_metadata(path) @@ -153,9 +155,9 @@ fn open_verified_brew(path: &Path) -> Result { { return Err("brew-cleanup-executable-identity-bound-execution-unavailable".into()); } - let file = std::fs::File::open(path) + let mut source = std::fs::File::open(path) .map_err(|_| "brew-cleanup-executable-identity-bound-execution-unavailable".to_string())?; - let opened_metadata = file + let opened_metadata = source .metadata() .map_err(|_| "brew-cleanup-executable-identity-bound-execution-unavailable".to_string())?; let current_metadata = std::fs::symlink_metadata(path) @@ -168,18 +170,82 @@ fn open_verified_brew(path: &Path) -> Result { { return Err("brew-cleanup-executable-identity-bound-execution-unavailable".into()); } + if opened_metadata.len() == 0 || opened_metadata.len() > MAX_BREW_SCRIPT_BYTES as u64 { + return Err("brew-cleanup-executable-size-invalid".into()); + } + + let mut snapshot = tempfile::tempfile() + .map_err(|_| "brew-cleanup-executable-snapshot-unavailable".to_string())?; + let mut hasher = blake3::Hasher::new(); + let mut captured_bytes = 0usize; + let mut buffer = [0u8; 16 * 1024]; + loop { + let read = source + .read(&mut buffer) + .map_err(|_| "brew-cleanup-executable-snapshot-unavailable".to_string())?; + if read == 0 { + break; + } + captured_bytes = captured_bytes + .checked_add(read) + .ok_or_else(|| "brew-cleanup-executable-size-invalid".to_string())?; + if captured_bytes > MAX_BREW_SCRIPT_BYTES { + return Err("brew-cleanup-executable-size-invalid".into()); + } + hasher.update(&buffer[..read]); + snapshot + .write_all(&buffer[..read]) + .map_err(|_| "brew-cleanup-executable-snapshot-unavailable".to_string())?; + } + if captured_bytes == 0 { + return Err("brew-cleanup-executable-size-invalid".into()); + } + snapshot + .sync_all() + .map_err(|_| "brew-cleanup-executable-snapshot-unavailable".to_string())?; + snapshot + .seek(SeekFrom::Start(0)) + .map_err(|_| "brew-cleanup-executable-snapshot-unavailable".to_string())?; + + let opened_after_snapshot = source + .metadata() + .map_err(|_| "brew-cleanup-executable-identity-bound-execution-unavailable".to_string())?; + let current_after_snapshot = std::fs::symlink_metadata(path) + .map_err(|_| "brew-cleanup-executable-identity-bound-execution-unavailable".to_string())?; + if !opened_after_snapshot.is_file() + || current_after_snapshot.file_type().is_symlink() + || !current_after_snapshot.is_file() + || current_after_snapshot.permissions().mode() & 0o111 == 0 + || opened_metadata.dev() != opened_after_snapshot.dev() + || opened_metadata.ino() != opened_after_snapshot.ino() + || opened_after_snapshot.dev() != current_after_snapshot.dev() + || opened_after_snapshot.ino() != current_after_snapshot.ino() + { + return Err("brew-cleanup-executable-identity-bound-execution-unavailable".into()); + } + Ok(VerifiedBrewExecutable { - identity: format!("{}:{}", opened_metadata.dev(), opened_metadata.ino()), - file, + identity: format!( + "{}:{}:{}", + opened_metadata.dev(), + opened_metadata.ino(), + hasher.finalize().to_hex() + ), + file: snapshot, }) } -#[cfg(target_os = "macos")] +#[cfg(any(target_os = "macos", all(test, unix)))] fn run_command(mut command: std::process::Command) -> Result { + use crate::unix_process_group::{ + signal_private_process_group, spawn_bounded_cancellable_pipe_reader, + wait_for_child_without_reap, NoReapWaitOutcome, PipeReaderCancellation, + }; use std::os::unix::process::CommandExt; use std::process::Stdio; - use std::thread; - use std::time::{Duration, Instant}; + use std::time::Duration; + + const POLL_INTERVAL: Duration = Duration::from_millis(50); // Keep the verified brew wrapper and any descendants in one private group so a timeout cannot // leave a maintenance child holding the output pipes or continuing after the gate fails. @@ -197,56 +263,102 @@ fn run_command(mut command: std::process::Command) -> Result stdout, + None => { + let _ = signal_private_process_group(child_pid, libc::SIGKILL); + let _ = child.kill(); + let _ = child.wait(); + return Err("brew-cleanup-stdout-unavailable".into()); + } + }; + let stderr = match child.stderr.take() { + Some(stderr) => stderr, + None => { + let _ = signal_private_process_group(child_pid, libc::SIGKILL); + let _ = child.kill(); + let _ = child.wait(); + return Err("brew-cleanup-stderr-unavailable".into()); + } + }; + let reader_cancellation = PipeReaderCancellation::new(); + let stdout_reader = match spawn_bounded_cancellable_pipe_reader( + stdout, + MAX_OUTPUT_BYTES, + POLL_INTERVAL, + reader_cancellation.clone(), + ) { + Ok(reader) => reader, + Err(_) => { + let _ = signal_private_process_group(child_pid, libc::SIGKILL); + let _ = child.kill(); + let _ = child.wait(); + return Err("brew-cleanup-stdout-reader-failed".into()); + } + }; + let stderr_reader = match spawn_bounded_cancellable_pipe_reader( + stderr, + MAX_OUTPUT_BYTES, + POLL_INTERVAL, + reader_cancellation.clone(), + ) { + Ok(reader) => reader, + Err(_) => { + let _ = signal_private_process_group(child_pid, libc::SIGKILL); + let _ = child.kill(); + let _ = child.wait(); + reader_cancellation.cancel(); + let _ = stdout_reader.join(); + return Err("brew-cleanup-stderr-reader-failed".into()); + } }; - let deadline = Instant::now() + Duration::from_millis(COMMAND_TIMEOUT_MS); - let status = loop { - match child.try_wait() { - Ok(Some(status)) => break status, - Ok(None) if Instant::now() >= deadline => { - kill_group(); - let _ = child.kill(); - let _ = child.wait(); - drop(stdout_reader); - drop(stderr_reader); - return Err("brew-cleanup-timeout".into()); - } - Ok(None) => thread::sleep(Duration::from_millis(50)), - Err(_) => { - kill_group(); - let _ = child.kill(); - let _ = child.wait(); - drop(stdout_reader); - drop(stderr_reader); - return Err("brew-cleanup-wait-failed".into()); - } + let lifecycle_result = match wait_for_child_without_reap( + child_pid, + Duration::from_millis(COMMAND_TIMEOUT_MS), + POLL_INTERVAL, + ) { + Ok(NoReapWaitOutcome::ExitedUnreaped) => { + // The unreaped leader pins the private PGID until inheriting descendants are settled. + let _ = signal_private_process_group(child_pid, libc::SIGKILL); + child + .wait() + .map_err(|_| "brew-cleanup-wait-failed".to_string()) + } + Ok(NoReapWaitOutcome::TimedOutStillRunning) => { + let _ = signal_private_process_group(child_pid, libc::SIGKILL); + let _ = child.kill(); + let _ = child.wait(); + Err("brew-cleanup-timeout".into()) + } + Err(_) => { + // Without a successful no-reap observation, only the direct child is safe to target. + let _ = child.kill(); + let _ = child.wait(); + Err("brew-cleanup-wait-failed".into()) } }; - let (stdout, stdout_truncated) = stdout_reader - .join() + + reader_cancellation.cancel(); + let stdout_result = stdout_reader.join(); + let stderr_result = stderr_reader.join(); + + let status = lifecycle_result?; + let (stdout, stdout_truncated) = stdout_result .map_err(|_| "brew-cleanup-stdout-reader-failed".to_string())? .map_err(|_| "brew-cleanup-stdout-read-failed".to_string())?; - let (stderr, stderr_truncated) = stderr_reader - .join() + let (stderr, stderr_truncated) = stderr_result .map_err(|_| "brew-cleanup-stderr-reader-failed".to_string())? .map_err(|_| "brew-cleanup-stderr-read-failed".to_string())?; Ok(CommandOutput { status_code: status.code().unwrap_or(-1), - stdout, - stderr, + stdout: String::from_utf8_lossy(&stdout) + .into_owned() + .replace('\0', ""), + stderr: String::from_utf8_lossy(&stderr) + .into_owned() + .replace('\0', ""), truncated: stdout_truncated || stderr_truncated, }) } @@ -287,30 +399,6 @@ fn run_brew_object_bound(path: &Path, args: &[&str]) -> Result<(String, CommandO Ok((identity, output)) } -#[cfg(target_os = "macos")] -fn read_bounded(reader: &mut impl Read) -> io::Result<(String, bool)> { - let mut retained = Vec::with_capacity(MAX_OUTPUT_BYTES); - let mut chunk = [0u8; 8 * 1024]; - let mut truncated = false; - loop { - let read = reader.read(&mut chunk)?; - if read == 0 { - break; - } - if retained.len() < MAX_OUTPUT_BYTES { - let keep = (MAX_OUTPUT_BYTES - retained.len()).min(read); - retained.extend_from_slice(&chunk[..keep]); - truncated |= keep < read; - } else { - truncated = true; - } - } - let text = String::from_utf8_lossy(&retained) - .into_owned() - .replace('\0', ""); - Ok((text, truncated)) -} - #[cfg(not(target_os = "macos"))] fn run_brew_object_bound(_path: &Path, _args: &[&str]) -> Result<(String, CommandOutput), String> { Err("brew-cleanup-unsupported-platform".into()) @@ -810,13 +898,44 @@ mod tests { assert_eq!(EXECUTE_ARGUMENTS, ["cleanup", "--prune-prefix"]); } - #[cfg(target_os = "macos")] + #[cfg(unix)] #[test] fn command_output_reader_drains_without_retaining_unbounded_output() { - let mut reader = std::io::Cursor::new(vec![b'x'; MAX_OUTPUT_BYTES + 1]); - let (text, truncated) = read_bounded(&mut reader).unwrap(); - assert_eq!(text.len(), MAX_OUTPUT_BYTES); - assert!(truncated); + let mut command = std::process::Command::new("/usr/bin/printf"); + command.arg("%s").arg("x".repeat(MAX_OUTPUT_BYTES + 1)); + + let output = run_command(command).unwrap(); + + assert_eq!(output.stdout.len(), MAX_OUTPUT_BYTES); + assert!(output.truncated); + } + + #[cfg(target_os = "linux")] + #[test] + fn escaped_inherited_pipe_writer_does_not_extend_command_completion() { + use std::time::{Duration, Instant}; + + let fixture = tempfile::tempdir().unwrap(); + let escaped_ready = fixture.path().join("escaped-ready"); + let mut command = std::process::Command::new("/bin/sh"); + command + .env("DISKSAGE_ESCAPED_READY", &escaped_ready) + .args([ + "-c", + "/usr/bin/setsid /bin/sh -c 'printf ready > \"$1\"; sleep 3' sh \"$DISKSAGE_ESCAPED_READY\" & while [ ! -s \"$DISKSAGE_ESCAPED_READY\" ]; do sleep 0.01; done; printf 'ready\\0'; printf 'warning' >&2", + ]); + let started = Instant::now(); + + let output = run_command(command).unwrap(); + + assert_eq!(output.status_code, 0); + assert_eq!(output.stdout, "ready"); + assert_eq!(output.stderr, "warning"); + assert!(!output.truncated); + assert!( + started.elapsed() < Duration::from_secs(2), + "an escaped descendant retaining stdout/stderr delayed command completion" + ); } #[cfg(target_os = "macos")] @@ -834,6 +953,39 @@ mod tests { assert_eq!(output.stdout, "object-bound\n"); } + #[cfg(unix)] + #[test] + fn verified_brew_snapshot_preserves_approved_bytes_after_same_inode_mutation() { + use std::io::{Read, Seek, SeekFrom, Write}; + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + + let script = tempfile::NamedTempFile::new().unwrap(); + let path = script.path().to_path_buf(); + let approved = b"#!/bin/bash\nprintf 'approved\\n'\n"; + let changed = b"#!/bin/bash\nprintf 'changed!\\n'\n"; + std::fs::write(&path, approved).unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700)).unwrap(); + + let mut verified = open_verified_brew(&path).unwrap(); + let before = std::fs::metadata(&path).unwrap(); + let mut writer = std::fs::OpenOptions::new() + .write(true) + .truncate(true) + .open(script.path()) + .unwrap(); + writer.write_all(changed).unwrap(); + writer.sync_all().unwrap(); + let after = std::fs::metadata(&path).unwrap(); + assert_eq!(before.dev(), after.dev()); + assert_eq!(before.ino(), after.ino()); + + verified.file.seek(SeekFrom::Start(0)).unwrap(); + let mut captured = Vec::new(); + verified.file.read_to_end(&mut captured).unwrap(); + assert_eq!(captured, approved); + assert_eq!(verified.identity.split(':').count(), 3); + } + #[cfg(unix)] #[test] fn audit_records_are_create_new_and_private() { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3c7e458b4..5638e6356 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -61,6 +61,10 @@ pub mod cloud_eviction; pub mod cloud_review; pub mod cloud_transfer; pub mod content_digest; +/// Unix-only identity-preserving child/process-group lifecycle mechanics. +#[cfg(unix)] +#[allow(dead_code)] +pub(crate) mod unix_process_group; pub mod duplicate_audit; pub mod icloud_sync_health; pub mod judge_calibration; diff --git a/src-tauri/src/unix_process_group.rs b/src-tauri/src/unix_process_group.rs new file mode 100644 index 000000000..97cfd97cd --- /dev/null +++ b/src-tauri/src/unix_process_group.rs @@ -0,0 +1,472 @@ +//! Identity-preserving Unix child/process-group lifecycle primitives. +//! +//! A private process-group leader must remain waitable until every signal that targets its +//! numeric process-group ID has been sent. Reaping the leader first allows that numeric PID/PGID +//! to be reused, so a later negative-PID signal can target an unrelated process group. +//! +//! This module deliberately owns only Unix subprocess lifecycle mechanics. Domain decisions such +//! as which container, provider, cloud object, or filesystem path may be changed remain with the +//! calling bounded context. + +use std::io::{self, Read}; +use std::mem::MaybeUninit; +use std::os::fd::AsRawFd; +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, +}; +use std::thread; +use std::time::{Duration, Instant}; + +/// Result of observing one direct child without consuming its wait status. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ChildObservation { + /// The selected child has not yet entered a waitable exited state. + Running, + /// The selected child exited, but its wait status remains unconsumed and its PID stays pinned. + ExitedUnreaped, +} + +/// Bounded outcome of waiting for a child while preserving its wait status. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum NoReapWaitOutcome { + /// The child exited before the deadline and remains waitable for the caller's final reap. + ExitedUnreaped, + /// The deadline elapsed while the child was still running and therefore still owns its PID. + TimedOutStillRunning, +} + +/// Shared cancellation token for readers that must not wait forever for inherited pipe writers. +/// +/// Callers cancel only after the direct child has been settled or when lifecycle observation has +/// failed closed. Readers keep bytes already observed by the nonblocking pipe reader, but +/// cancellation never requires a later `WouldBlock`: once the caller's capture budget is full, one +/// additional successful read is enough to prove truncation and terminate even if an escaped +/// descendant keeps writing continuously. +#[derive(Debug, Clone)] +pub(crate) struct PipeReaderCancellation { + cancelled: Arc, +} + +impl PipeReaderCancellation { + pub(crate) fn new() -> Self { + Self { + cancelled: Arc::new(AtomicBool::new(false)), + } + } + + pub(crate) fn cancel(&self) { + self.cancelled.store(true, Ordering::Release); + } + + fn is_cancelled(&self) -> bool { + self.cancelled.load(Ordering::Acquire) + } +} + +/// Spawn a bounded Unix pipe reader that can stop after child settlement without requiring EOF. +/// +/// Child stdout/stderr descriptors can remain open in descendants that escape the caller's +/// private process group. A blocking `read` followed by an unbounded thread join would then extend +/// a completed CLI operation indefinitely. This helper preserves existing descriptor flags, +/// enables `O_NONBLOCK`, retries `Interrupted`, waits through `WouldBlock` while the child remains +/// active, and exits on `WouldBlock` after cancellation. If reads continue succeeding after +/// cancellation, the reader exits once the capture budget is full and a read has proven that bytes +/// were omitted. Output remains capped and reports whether bytes beyond `max_capture_bytes` were +/// observed. +pub(crate) fn spawn_bounded_cancellable_pipe_reader( + mut reader: R, + max_capture_bytes: usize, + poll_interval: Duration, + cancellation: PipeReaderCancellation, +) -> io::Result, bool)>>> +where + R: Read + AsRawFd + Send + 'static, +{ + let fd = reader.as_raw_fd(); + let flags = unsafe { libc::fcntl(fd, libc::F_GETFL) }; + if flags == -1 { + return Err(io::Error::last_os_error()); + } + if unsafe { libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) } == -1 { + return Err(io::Error::last_os_error()); + } + + Ok(thread::spawn(move || { + let mut buffer = [0u8; 65_536]; + let mut captured = Vec::new(); + let mut truncated = false; + loop { + match reader.read(&mut buffer) { + Ok(0) => break, + Ok(read) => { + let room = max_capture_bytes.saturating_sub(captured.len()); + let retained = read.min(room); + captured.extend_from_slice(&buffer[..retained]); + if retained < read { + truncated = true; + } + if cancellation.is_cancelled() + && captured.len() == max_capture_bytes + && truncated + { + break; + } + } + Err(error) if error.kind() == io::ErrorKind::Interrupted => continue, + Err(error) if error.kind() == io::ErrorKind::WouldBlock => { + if cancellation.is_cancelled() { + break; + } + thread::sleep(poll_interval); + } + Err(error) => return Err(error), + } + } + Ok((captured, truncated)) + })) +} + +/// Retry only operations interrupted before completion; every other error remains fail closed. +fn retry_interrupted(mut operation: impl FnMut() -> io::Result) -> io::Result { + loop { + match operation() { + Err(error) if error.kind() == io::ErrorKind::Interrupted => continue, + result => return result, + } + } +} + +/// Observe a direct child with `waitid(..., WNOHANG | WNOWAIT)` without reaping it. +/// +/// `WNOWAIT` is the safety property: callers may still target the child's private process group by +/// numeric PGID while the leader remains waitable. With `WNOHANG`, POSIX defines a zero `si_pid` +/// when no selected child is waitable; using the returned child PID is therefore the portable +/// discriminator instead of treating `si_signo` as the readiness flag. `EINTR` is retried because +/// it does not invalidate the pinned child identity; every other observation error is returned. +/// After descendant cleanup is complete, the caller must consume the status with `Child::wait()` +/// exactly once. +pub(crate) fn observe_child_without_reap(child_pid: u32) -> io::Result { + let child_id = libc::id_t::try_from(child_pid) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "child PID exceeds id_t"))?; + let mut info = MaybeUninit::::zeroed(); + retry_interrupted(|| { + let result = unsafe { + libc::waitid( + libc::P_PID, + child_id, + info.as_mut_ptr(), + libc::WEXITED | libc::WNOHANG | libc::WNOWAIT, + ) + }; + if result == -1 { + Err(io::Error::last_os_error()) + } else { + Ok(()) + } + })?; + let info = unsafe { info.assume_init() }; + let observed_pid = unsafe { info.si_pid() }; + if observed_pid == 0 { + return Ok(ChildObservation::Running); + } + let observed_pid = u32::try_from(observed_pid) + .map_err(|_| io::Error::other("waitid returned an invalid child PID"))?; + if observed_pid != child_pid { + return Err(io::Error::other(format!( + "waitid returned unexpected child PID {observed_pid}; expected {child_pid}" + ))); + } + if info.si_signo != libc::SIGCHLD { + return Err(io::Error::other(format!( + "waitid returned unexpected signal {} for child {child_pid}", + info.si_signo + ))); + } + Ok(ChildObservation::ExitedUnreaped) +} + +/// Wait for a direct child to exit or for `timeout` to elapse without consuming its wait status. +/// +/// This is the shared polling boundary for private-process-group callers. An exited result still +/// pins the leader PID because `WNOWAIT` leaves it waitable; a timeout result means the leader is +/// still running. Interrupted observations are retried inside `observe_child_without_reap`; other +/// errors are returned immediately and never fall back to `try_wait()`, because such a fallback +/// could reap the leader before a later group signal. +pub(crate) fn wait_for_child_without_reap( + child_pid: u32, + timeout: Duration, + poll_interval: Duration, +) -> io::Result { + let started = Instant::now(); + loop { + match observe_child_without_reap(child_pid)? { + ChildObservation::ExitedUnreaped => return Ok(NoReapWaitOutcome::ExitedUnreaped), + ChildObservation::Running if started.elapsed() >= timeout => { + return Ok(NoReapWaitOutcome::TimedOutStillRunning); + } + ChildObservation::Running => thread::sleep(poll_interval), + } + } +} + +/// Send a signal to the private process group whose leader is `child_pid`. +/// +/// Callers must invoke this only while the group leader is live or exited-but-unreaped. This +/// function intentionally does not reap the leader; preserving or consuming that identity is the +/// caller's explicit lifecycle decision. +pub(crate) fn signal_private_process_group(child_pid: u32, signal: i32) -> io::Result<()> { + let leader = libc::pid_t::try_from(child_pid) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "child PID exceeds pid_t"))?; + if leader <= 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "child PID must be positive", + )); + } + let result = unsafe { libc::kill(-leader, signal) }; + if result == -1 { + return Err(io::Error::last_os_error()); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::Cell; + use std::io::{Read, Write}; + use std::os::unix::net::UnixStream; + use std::os::unix::process::CommandExt; + use std::process::{Child, Command, Stdio}; + use std::sync::mpsc; + + fn spawn_private_group_shell(script: &str, stdout: Stdio) -> Child { + let mut command = Command::new("/bin/sh"); + command + .arg("-c") + .arg(script) + .stdout(stdout) + .stderr(Stdio::null()); + unsafe { + command.pre_exec(|| { + if libc::setpgid(0, 0) == -1 { + return Err(io::Error::last_os_error()); + } + Ok(()) + }); + } + command + .spawn() + .expect("spawn private process-group leader") + } + + fn wait_until_exited_without_reap(child_pid: u32) { + assert_eq!( + wait_for_child_without_reap( + child_pid, + Duration::from_secs(2), + Duration::from_millis(10), + ) + .expect("wait for child without reap"), + NoReapWaitOutcome::ExitedUnreaped + ); + } + + #[test] + fn retry_interrupted_retries_only_interrupted_errors() { + let attempts = Cell::new(0usize); + let result = retry_interrupted(|| { + let attempt = attempts.get(); + attempts.set(attempt + 1); + if attempt < 2 { + Err(io::Error::from(io::ErrorKind::Interrupted)) + } else { + Ok(17usize) + } + }) + .expect("interrupted operations are retried"); + assert_eq!(result, 17); + assert_eq!(attempts.get(), 3); + + let error = retry_interrupted::<()>(|| Err(io::Error::from(io::ErrorKind::InvalidInput))) + .expect_err("non-interrupted errors remain fail closed"); + assert_eq!(error.kind(), io::ErrorKind::InvalidInput); + } + + #[test] + fn cancellable_pipe_reader_drains_available_bytes_without_waiting_for_eof() { + let (reader, mut writer) = UnixStream::pair().expect("unix stream pair"); + writer.write_all(b"ready").expect("write fixture bytes"); + let cancellation = PipeReaderCancellation::new(); + let handle = spawn_bounded_cancellable_pipe_reader( + reader, + 64, + Duration::from_millis(1), + cancellation.clone(), + ) + .expect("spawn cancellable reader"); + + cancellation.cancel(); + let (captured, truncated) = handle + .join() + .expect("reader thread join") + .expect("reader result"); + + assert_eq!(captured, b"ready"); + assert!(!truncated); + drop(writer); + } + + #[test] + fn cancellable_pipe_reader_preserves_output_cap_semantics() { + let (reader, mut writer) = UnixStream::pair().expect("unix stream pair"); + writer + .write_all(b"abcdefgh") + .expect("write over-cap fixture bytes"); + let cancellation = PipeReaderCancellation::new(); + let handle = spawn_bounded_cancellable_pipe_reader( + reader, + 4, + Duration::from_millis(1), + cancellation.clone(), + ) + .expect("spawn cancellable reader"); + + cancellation.cancel(); + let (captured, truncated) = handle + .join() + .expect("reader thread join") + .expect("reader result"); + + assert_eq!(captured, b"abcd"); + assert!(truncated); + } + + #[test] + fn cancellable_pipe_reader_stops_continuous_writer_after_cancellation() { + let (reader, mut writer) = UnixStream::pair().expect("unix stream pair"); + let (ready_tx, ready_rx) = mpsc::channel(); + let writer_thread = thread::spawn(move || { + let chunk = [b'x'; 1_024]; + writer.write_all(&chunk).expect("write initial fixture bytes"); + ready_tx.send(()).expect("publish continuous-writer readiness"); + loop { + match writer.write_all(&chunk) { + Ok(()) => {} + Err(error) + if matches!( + error.kind(), + io::ErrorKind::BrokenPipe + | io::ErrorKind::ConnectionReset + | io::ErrorKind::NotConnected + ) => + { + break; + } + Err(error) => panic!("continuous writer failed unexpectedly: {error}"), + } + } + }); + let cancellation = PipeReaderCancellation::new(); + let handle = spawn_bounded_cancellable_pipe_reader( + reader, + 4_096, + Duration::from_millis(1), + cancellation.clone(), + ) + .expect("spawn cancellable reader"); + ready_rx + .recv_timeout(Duration::from_secs(1)) + .expect("continuous writer did not become ready"); + + let started = Instant::now(); + cancellation.cancel(); + let (captured, truncated) = handle + .join() + .expect("reader thread join") + .expect("reader result"); + + assert_eq!(captured.len(), 4_096); + assert!(truncated); + assert!( + started.elapsed() < Duration::from_secs(1), + "continuous escaped writer extended reader settlement" + ); + writer_thread.join().expect("continuous writer thread join"); + } + + #[test] + fn exited_child_remains_waitable_until_explicit_reap() { + let mut child = spawn_private_group_shell("exit 7", Stdio::null()); + let child_pid = child.id(); + wait_until_exited_without_reap(child_pid); + + assert_eq!( + observe_child_without_reap(child_pid).expect("repeat no-reap observation"), + ChildObservation::ExitedUnreaped + ); + let status = child.wait().expect("explicit final reap"); + assert_eq!(status.code(), Some(7)); + } + + #[test] + fn bounded_wait_times_out_without_reaping_or_reusing_leader_identity() { + let mut child = spawn_private_group_shell("sleep 30", Stdio::null()); + let child_pid = child.id(); + assert_eq!( + wait_for_child_without_reap( + child_pid, + Duration::from_millis(20), + Duration::from_millis(5), + ) + .expect("bounded wait for live child"), + NoReapWaitOutcome::TimedOutStillRunning + ); + assert_eq!( + observe_child_without_reap(child_pid).expect("child remains observable after timeout"), + ChildObservation::Running + ); + + signal_private_process_group(child_pid, libc::SIGKILL) + .expect("terminate timed-out private process group"); + let status = child.wait().expect("reap timed-out leader after group cleanup"); + assert!(!status.success()); + } + + #[test] + fn process_group_is_signaled_before_leader_reap_and_pipe_closes() { + let started = Instant::now(); + let mut child = spawn_private_group_shell("sleep 30 & printf ready", Stdio::piped()); + let child_pid = child.id(); + let mut stdout = child.stdout.take().expect("child stdout pipe"); + + wait_until_exited_without_reap(child_pid); + signal_private_process_group(child_pid, libc::SIGKILL) + .expect("terminate descendants while leader identity is pinned"); + let status = child.wait().expect("reap group leader after cleanup signal"); + assert!(status.success()); + + let mut output = String::new(); + stdout + .read_to_string(&mut output) + .expect("drain bounded test output"); + assert_eq!(output, "ready"); + assert!(started.elapsed() < Duration::from_secs(2)); + } + + #[test] + fn live_group_can_be_terminated_before_reap() { + let mut child = spawn_private_group_shell("sleep 30", Stdio::null()); + let child_pid = child.id(); + assert_eq!( + observe_child_without_reap(child_pid).expect("observe running child"), + ChildObservation::Running + ); + + signal_private_process_group(child_pid, libc::SIGKILL) + .expect("terminate live private process group"); + let status = child.wait().expect("reap terminated leader after group cleanup"); + assert!(!status.success()); + } +} diff --git a/src-tauri/tests/brew_cleanup_execution_authority.rs b/src-tauri/tests/brew_cleanup_execution_authority.rs index 619f1ac3b..b5d9e05f6 100644 --- a/src-tauri/tests/brew_cleanup_execution_authority.rs +++ b/src-tauri/tests/brew_cleanup_execution_authority.rs @@ -49,9 +49,9 @@ fn object_bound_brew_launch_must_use_privileged_bash_mode() { .find("fn run_verified_brew(") .expect("verified brew runner must exist"); let runner_end = source[runner_start..] - .find("fn read_bounded(") + .find("fn run_brew_object_bound(") .map(|offset| runner_start + offset) - .expect("verified brew runner must end before bounded reader"); + .expect("verified brew runner must end before brew object-bound wrapper"); let runner = &source[runner_start..runner_end]; assert!( @@ -61,35 +61,45 @@ fn object_bound_brew_launch_must_use_privileged_bash_mode() { } #[test] -fn timeout_and_wait_failure_must_not_join_pipe_readers() { +fn observation_failure_targets_only_the_direct_child_before_reader_settlement() { let source = source("src/brew_cleanup.rs"); let runner_start = source .find("fn run_command(") .expect("bounded command runner must exist"); let runner_end = source[runner_start..] - .find("fn run_brew_object_bound(") + .find("fn run_verified_brew(") .map(|offset| runner_start + offset) - .expect("bounded command runner must end before brew object-bound wrapper"); + .expect("bounded command runner must end before verified brew wrapper"); let runner = &source[runner_start..runner_end]; - let timeout_start = runner - .find("Ok(None) if Instant::now() >= deadline") - .expect("timeout branch must exist"); - let wait_failure_start = runner - .find("Err(_) =>") - .expect("wait-failure branch must exist"); - let timeout = &runner[timeout_start..wait_failure_start]; - let wait_failure = &runner[wait_failure_start..]; + let failure_start = runner + .find("// Without a successful no-reap observation") + .expect("observation failure branch must document its identity boundary"); + let failure_end = runner[failure_start..] + .find("\n }\n };") + .map(|offset| failure_start + offset) + .expect("observation failure branch must end before reader settlement"); + let failure = &runner[failure_start..failure_end]; - for failure_branch in [timeout, wait_failure] { - assert!( - failure_branch.contains("drop(stdout_reader);") - && failure_branch.contains("drop(stderr_reader);"), - "failure paths must detach reader threads after terminating the direct child" - ); - assert!( - !failure_branch.contains("stdout_reader.join()") - && !failure_branch.contains("stderr_reader.join()"), - "failure paths must not wait forever on pipes retained by descendant processes" - ); - } + assert!( + failure.contains("child.kill()") && failure.contains("child.wait()"), + "observation failure must terminate and reap the direct child" + ); + assert!( + !failure.contains("signal_private_process_group("), + "observation failure must never guess a negative process-group ID" + ); + let settlement = &runner[failure_end..]; + let cancellation = settlement + .find("reader_cancellation.cancel();") + .expect("reader settlement must publish cancellation"); + let stdout_join = settlement + .find("stdout_reader.join()") + .expect("stdout reader must be joined"); + let stderr_join = settlement + .find("stderr_reader.join()") + .expect("stderr reader must be joined"); + assert!( + cancellation < stdout_join && cancellation < stderr_join, + "reader cancellation must be visible before both owned joins" + ); } diff --git a/src-tauri/tests/brew_cleanup_snapshot_testability_contract.rs b/src-tauri/tests/brew_cleanup_snapshot_testability_contract.rs new file mode 100644 index 000000000..764937ae7 --- /dev/null +++ b/src-tauri/tests/brew_cleanup_snapshot_testability_contract.rs @@ -0,0 +1,75 @@ +use std::fs; +use std::path::PathBuf; + +fn brew_cleanup_source() -> String { + fs::read_to_string(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src/brew_cleanup.rs")) + .expect("brew cleanup production source must be readable") +} + +#[test] +fn verified_brew_snapshot_boundary_is_exercisable_in_unix_tests_only() { + let source = brew_cleanup_source(); + let testable_unix_cfg = "#[cfg(any(target_os = \"macos\", all(test, unix)))]"; + + assert!( + source.contains(&format!( + "{testable_unix_cfg}\nstruct VerifiedBrewExecutable" + )), + "the verified executable holder must remain macOS production code while becoming exercisable in Unix unit tests" + ); + assert!( + source.contains(&format!("{testable_unix_cfg}\nfn open_verified_brew")), + "the exact executable opener must be testable on the Linux CI runner without broadening runtime platform support" + ); +} + +#[test] +fn brew_command_must_use_identity_preserving_cancellable_unix_lifecycle() { + let source = brew_cleanup_source(); + let run_command = source + .split_once("fn run_command(mut command: std::process::Command) -> Result {") + .expect("brew cleanup run_command boundary must exist") + .1 + .split_once("fn run_verified_brew(") + .expect("brew cleanup run_command boundary must end before run_verified_brew") + .0; + + assert!( + run_command.contains("wait_for_child_without_reap("), + "brew cleanup must keep the private process-group leader waitable until descendant cleanup settles" + ); + assert!( + run_command.contains("PipeReaderCancellation::new()"), + "brew cleanup must own explicit cancellation for descendants that retain inherited stdout/stderr writers" + ); + assert!( + run_command.matches("spawn_bounded_cancellable_pipe_reader(").count() >= 2, + "both stdout and stderr must use the canonical bounded cancellable reader lifecycle" + ); + assert!( + !run_command.contains("child.try_wait()"), + "brew cleanup must not reap the leader before the final private-group cleanup opportunity" + ); + assert!( + !run_command.contains("thread::spawn(move || read_bounded"), + "brew cleanup must not retain blocking reader threads that can outlive the command deadline" + ); + + let cancellation = run_command + .find("reader_cancellation.cancel();") + .expect("child settlement must publish reader cancellation"); + let stdout_join = run_command[cancellation..] + .find("stdout_reader") + .and_then(|offset| run_command[cancellation + offset..].find(".join()")) + .map(|offset| cancellation + offset) + .expect("stdout reader must remain owned and joined after cancellation"); + let stderr_join = run_command[cancellation..] + .find("stderr_reader") + .and_then(|offset| run_command[cancellation + offset..].find(".join()")) + .map(|offset| cancellation + offset) + .expect("stderr reader must remain owned and joined after cancellation"); + assert!( + cancellation < stdout_join && cancellation < stderr_join, + "reader cancellation must be visible before either owned pipe reader can be joined" + ); +}