diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b7137a9b9..ed21672d9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -23,6 +23,12 @@ jobs: - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: workspaces: src-tauri + - name: Podman Rust formatting + run: >- + rustfmt --edition 2021 --check + src-tauri/src/podman_desktop.rs + src-tauri/tests/podman_desktop_documentation_contract.rs + src-tauri/tests/podman_desktop_issue_privacy.rs - name: Rust tests (includes unix symlink test) run: cargo test --manifest-path src-tauri/Cargo.toml - name: Headless cloud planner tests @@ -40,8 +46,166 @@ jobs: node-version: 20.19.0 - run: npm ci - run: npm test + - run: npm run coverage + - run: npm run check - run: npm run build + coverage-evidence: + runs-on: ubuntu-latest + env: + HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + ref: ${{ github.event.pull_request.head.sha || github.sha }} + - 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 + - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 + with: + toolchain: nightly-2026-08-07 + components: llvm-tools-preview + - uses: taiki-e/install-action@6c6fd71fe4fb72c3697d269963d0e15df8adedad + with: + tool: cargo-llvm-cov + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + workspaces: src-tauri + - name: Measure exact-head Rust coverage + run: cargo llvm-cov --manifest-path src-tauri/Cargo.toml --branch --json --summary-only --output-path coverage.json --no-cfg-coverage --no-cfg-coverage-nightly + - name: Build exact-head coverage evidence + run: | + node --input-type=module <<'NODE' + import { appendFileSync, readFileSync, writeFileSync } from 'node:fs'; + + const sha = process.env.HEAD_SHA ?? ''; + const repository = process.env.GITHUB_REPOSITORY ?? ''; + if (!/^[0-9a-f]{40}$/.test(sha) || repository.length === 0) { + throw new Error('coverage evidence identity is invalid'); + } + + const report = JSON.parse(readFileSync('coverage.json', 'utf8')); + const coverageData = report?.data?.[0]; + const totals = coverageData?.totals; + const safeGap = (value) => { + const count = value?.count; + const covered = value?.covered; + return Number.isSafeInteger(count) && + Number.isSafeInteger(covered) && + count >= covered && + covered >= 0 + ? count - covered + : 0; + }; + const repositoryPath = (filename) => { + const normalized = String(filename ?? '').replaceAll('\\', '/'); + const marker = '/src-tauri/'; + const markerIndex = normalized.lastIndexOf(marker); + if (markerIndex < 0) return null; + return `src-tauri/${normalized.slice(markerIndex + marker.length)}`; + }; + const top_uncovered_files = (coverageData?.files ?? []) + .map((file) => { + const path = repositoryPath(file?.filename); + const summary = file?.summary; + if (!path) return null; + return { + path, + uncovered_regions: safeGap(summary?.regions), + uncovered_branches: safeGap(summary?.branches), + uncovered_functions: safeGap(summary?.functions), + uncovered_lines: safeGap(summary?.lines), + }; + }) + .filter((entry) => entry && ( + entry.uncovered_regions > 0 || + entry.uncovered_branches > 0 || + entry.uncovered_functions > 0 || + entry.uncovered_lines > 0 + )) + .sort((left, right) => { + const leftGap = left.uncovered_regions + left.uncovered_branches + + left.uncovered_functions + left.uncovered_lines; + const rightGap = right.uncovered_regions + right.uncovered_branches + + right.uncovered_functions + right.uncovered_lines; + return rightGap - leftGap || left.path.localeCompare(right.path); + }) + .slice(0, 20); + const diagnostic = { + schema_version: 1, + head_sha: sha, + repository, + regions: totals?.regions ?? null, + branches: totals?.branches ?? null, + functions: totals?.functions ?? null, + lines: totals?.lines ?? null, + top_uncovered_files, + }; + writeFileSync( + 'coverage-diagnostic.json', + `${JSON.stringify(diagnostic, null, 2)}\n`, + ); + console.error(`coverage-diagnostic=${JSON.stringify(diagnostic)}`); + const summaryPath = process.env.GITHUB_STEP_SUMMARY; + if (summaryPath) { + appendFileSync( + summaryPath, + `### Coverage diagnostic for \`${sha}\`\n\n\`\`\`json\n${JSON.stringify(diagnostic, null, 2)}\n\`\`\`\n`, + ); + } + + const metric = (name, value) => { + if ( + !value || + !Number.isFinite(value.count) || + !Number.isFinite(value.covered) || + !Number.isFinite(value.percent) || + value.count <= 0 || + value.covered !== value.count || + value.percent !== 100 + ) { + throw new Error(`${name} coverage is not exactly 100%`); + } + return value.percent; + }; + + const evidence = { + schema_version: 1, + head_sha: sha, + commit_sha: sha, + repository, + trust_tier: 'ci-verified', + ci_server: 'github-actions', + ci_workflow: 'Test', + coverage_command: 'cargo llvm-cov', + statement_coverage: metric('statement/region', totals?.regions), + branch_coverage: metric('branch', totals?.branches), + function_coverage: metric('function', totals?.functions), + line_coverage: metric('line', totals?.lines), + passed: true, + }; + + writeFileSync( + 'coverage-evidence.json', + `${JSON.stringify(evidence, null, 2)}\n`, + ); + NODE + - name: Upload bounded coverage diagnostic + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: coverage-diagnostic-${{ env.HEAD_SHA }} + path: coverage-diagnostic.json + if-no-files-found: error + - name: Upload coverage evidence + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: coverage-evidence + path: coverage-evidence.json + if-no-files-found: error + llm-engine-build: runs-on: ubuntu-latest steps: diff --git a/CHANGELOG.md b/CHANGELOG.md index 166510adc..d0b572147 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,14 +6,24 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and ## [Unreleased] +### Added + +- Add a read-only Podman evidence panel to Cleanup that separately displays configured VM capacity, raw-image logical size, host allocation, guest filesystem observations, Podman store observations, image/stopped-container/volume logical candidates, evidence completeness, stable issue codes, and a redacted candidate-set fingerprint. +- Add a privacy-safe Tauri contract that removes machine names, local paths, graph-root locations, image identifiers, tags, command output, and dynamic error details before evidence reaches the desktop frontend. +- Add beginner-readable JSDoc for every Podman frontend contract function and a deterministic source-level regression test that fails when any production function loses its adjacent documentation. +- Add module-level Rust `missing_docs` enforcement and a deterministic source-level contract that requires beginner-readable rustdoc for every Podman desktop function, including private helpers and regression tests. + ### Changed +- Require the `Test` workflow to produce exact-head, branch-aware, fail-closed Rust coverage evidence from real `cargo llvm-cov` measurements before organization-level review can treat 100% statement-equivalent region, branch, function, and line coverage as passing. +- Surface the same bounded exact-head Rust coverage totals in the failing job log and GitHub step summary before enforcing the 100% gate, while retaining the success-only coverage evidence artifact and privacy-safe diagnostic artifact boundary. - Require a fresh, exact, human-attributed approval and rationale for cloud copy-only and existing-copy adoption actions, with a 15-minute authorization lifetime bound to the candidate, destination, provider, account scope, and review fingerprint. - Return the candidate-specific cloud copy approval action, exact confirmation phrase, and maximum approval age from the Rust plan contract; the frontend only displays and submits that backend-authored phrase and fails closed when it is missing or does not match the candidate action. - Align the frontend toolchain on Vite 8.2 and `@sveltejs/vite-plugin-svelte` 7.2 so the declared peer dependency graph is installable and reproducible. - Declare the supported Node.js runtime floor as Node.js 20.19 or Node.js 22.12 and later, matching Vite 8 requirements. - Pin the primary test workflow to Node.js 20.19.0 so the minimum supported runtime is continuously verified. - Document the iCloud batch operation's local-only versus path-free shareable evidence boundary and map its fail-closed controls to NIST SP 800-53 Release 5.2.0, ISO/IEC 27040:2024, and primary secure-design literature with APA 7th references and deterministic documentation contract tests. +- Keep Podman image, stopped-container, and volume review boundaries independent and advisory; no candidate class grants authority to another class. ### Fixed @@ -25,3 +35,6 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and - Persist copy-approval provenance in immutable receipt lineage, reject stale, generic, mismatched, or tampered approvals, and retain explicit backward readability for pre-approval receipt formats. - Generate the npm lockfile in an exact-head validation job with repository contents read-only and dependency lifecycle scripts disabled, bind the artifact to SHA-256 evidence, and grant `contents: write` only to a separate publication job that verifies the same-run artifact and unchanged branch head before committing the lockfile. - Removed obsolete one-shot repair workflows and patch scripts so repository automation no longer retains dormant write-capable recovery paths. +- Keep the Podman desktop surface observation-only: it exposes no prune, remove, machine stop/start, VM deletion, TRIM, raw-image mutation, or shell-string construction path, and it never labels Podman logical candidates as verified host physical reclaimability. +- Replace untrusted Tauri, operating-system, and JavaScript failure details with one stable Podman UI error code so account-local paths, machine names, socket locations, and command details cannot leak through the visible desktop evidence boundary. +- Accept Podman probe issue prefixes only as bounded lowercase kebab-case codes; delimiter-free paths, sockets, uppercase text, Unicode, whitespace, underscores, and malformed prefixes collapse to `podman-evidence-error` before desktop IPC serialization. diff --git a/docs/architecture/podman-desktop-evidence.md b/docs/architecture/podman-desktop-evidence.md new file mode 100644 index 000000000..c72b6e897 --- /dev/null +++ b/docs/architecture/podman-desktop-evidence.md @@ -0,0 +1,129 @@ +# ADR: Privacy-safe Podman desktop evidence + +- **Status:** Proposed +- **Date:** 2026-08-05 +- **Decision owners:** DiskSage maintainers +- **Related issue:** #107 +- **Related headless contract:** #105 and `src-tauri/src/podman_reclaim.rs` + +## Context + +DiskSage already has a Rust-first, read-only Podman evidence probe that distinguishes VM configuration, raw-image logical size, host allocation, guest filesystem usage, Podman graph-root observations, and Podman-reported logical cleanup candidates. The desktop Cleanup experience previously had no supported way to inspect that evidence. + +The UI must not turn evidence into authority. Podman documents that image reclaimable values can overstate what a prune would actually free when layers are shared. DiskSage therefore treats all `podman system df` candidate values as logical review evidence rather than verified host physical reclaimability. + +The headless report also contains local-only details such as machine names, configuration paths, raw-image paths, graph-root paths, and dynamic command errors. Those details are useful for local diagnosis but are unnecessary for the desktop summary and unsafe for telemetry or shareable evidence. Tauri transport failures and arbitrary JavaScript rejection values can also contain account-local paths, socket names, or command detail, so the UI error boundary must redact them independently of the Rust projection. + +## Decision + +### 1. Add a separate privacy projection + +`src-tauri/src/podman_desktop.rs` converts `PodmanReclaimPlan` into `PodmanDesktopEvidence`. + +The projection includes only: + +- configured machine disk bytes; +- raw-image logical bytes; +- host allocated bytes; +- guest total, used, and available bytes; +- Podman graph-root allocated and used bytes; +- image, stopped-container, and volume logical candidate bytes; +- unused-image and stopped-container counts; +- the SHA-256 commitment to the exact unused-image candidate set; +- evidence completeness, elapsed time, stable reason codes, and stable issue codes; +- separate image, stopped-container, and volume review boundaries; +- `physically_reclaimable_bytes`, which remains unknown until a before-and-after host observation proves it. + +The projection excludes: + +- machine names and states; +- configuration, raw-image, and graph-root paths; +- image identifiers and tags; +- account-local context; +- command output and dynamic error details; +- any mutation command or approval record. + +Issue strings are reduced to the prefix before the first colon only when that prefix is a bounded lowercase kebab-case code: it must start with a lowercase ASCII letter, contain only lowercase ASCII letters, digits, or hyphens, and be no longer than 96 bytes. Delimiter-free paths, sockets, whitespace, uppercase text, Unicode, underscores, empty prefixes, and malformed values collapse to `podman-evidence-error`. Invalid candidate fingerprints fail closed: the fingerprint is removed, the evidence is marked incomplete, and a stable issue code is added. + +### 2. Keep the Tauri command read-only and argv-based + +`inspect_podman_reclaim` invokes the existing Rust probe using an executable plus an argument vector. It does not construct a shell string. Tauri documents commands as typed Rust functions registered once in `generate_handler!`; the desktop command follows that model and returns a serializable response. + +The desktop surface exposes no prune, remove, machine start/stop, VM deletion, TRIM, raw-image mutation, or generic command execution path. + +### 3. Keep review domains independent + +Images, stopped containers, and local volumes have separate review booleans and separate UI sections. A review signal for one domain never authorizes another domain. This preserves future compatibility with distinct approval records and least-privilege workflows. + +### 4. Keep visual semantics explicit, accessible, and privacy-safe + +The panel uses semantic headings, definition lists, buttons, `role="status"` for progress and results, and `role="alert"` for errors. WCAG 2.2 requires status messages to be programmatically determinable without moving focus; the component uses live status regions for that purpose. + +The UI never uses color as the only carrier of completeness. Text labels always state “증거 완전” or “부분 증거.” + +The UI never renders `String(reason)` or another untrusted exception representation. `podmanEvidenceErrorMessage` discards every transport, operating-system, and JavaScript failure detail and returns only `podman-evidence-unavailable`. Detailed diagnosis remains confined to trusted local logs and does not cross into the desktop evidence, telemetry, or shareable-evidence boundary. + +### 5. Preserve standalone and MSA compatibility + +The desktop response is a versioned JSON contract with no dependency on Naruon or another CWL service. DiskSage runs independently. A future Naruon or fleet-management adapter may consume the same privacy-safe schema without receiving local paths or identifiers. + +## Consequences + +### Positive + +- Buyers can inspect a concrete Podman storage gap from the main Cleanup workflow. +- Logical size, host allocation, guest use, and verified physical reclaimability cannot be silently conflated. +- Local identifiers stay outside the frontend contract, telemetry, and shareable evidence boundary. +- Malformed or delimiter-free probe issues cannot masquerade as safe codes or serialize local path content. +- Transport and JavaScript failures cannot leak machine names, paths, sockets, or command detail through the visible error region. +- The architecture can later add separate governed image, container, and volume approval records without changing the read-only evidence contract. +- Headless API validation, issue-code privacy tests, error-redaction tests, and view-state tests remain deterministic and are included in the 100% frontend statement, branch, function, and line coverage gate. +- Module-level `missing_docs` enforcement and a source-level Rust documentation contract keep every Podman desktop function, including private helpers and regression tests, beginner-readable. + +### Negative + +- The UI intentionally cannot perform cleanup. Operators must use a separate reviewed workflow until a mutation design includes exact candidate binding, independent approval, rollback evidence, and before-and-after host verification. +- Some evidence remains unavailable when Podman is absent, the machine is stopped, or the API is unhealthy. Unknown values remain `null`; the UI never converts missing evidence to zero. +- Visible failures intentionally use a stable generic code; sensitive operational detail must be inspected through trusted local diagnostics rather than the shareable desktop surface. + +## Verification matrix + +| Invariant | Deterministic evidence | +|---|---| +| No machine names or paths in desktop JSON | Rust serialization test searches for private fixture values | +| Delimiter-free or malformed issue text cannot cross IPC | Rust unit and integration tests expect `podman-evidence-error` for paths, uppercase text, and underscores | +| Image/container/volume review separation | Rust projection test and TypeScript view-model test | +| Invalid fingerprint fails closed | Rust and TypeScript malformed-fingerprint tests | +| Missing observations stay unknown | Rust and TypeScript null-preservation tests | +| Exact Tauri command contract | Mocked TypeScript invoke test | +| Schema/type/range drift rejected | TypeScript parser tests | +| Untrusted failure details never reach visible UI | `podmanEvidence.error.test.ts` supplies path, socket, object, null, and undefined failures and expects one stable code | +| Progress and errors announced | Svelte markup uses `role="status"` and `role="alert"` | +| No mutation surface | Registered command list exposes inspection only | +| Frontend logic coverage | `vitest.config.ts` includes `podmanEvidence.ts` and `podmanEvidenceError.ts` at 100% thresholds | +| Beginner-readable frontend function documentation | Source-level JSDoc regression test checks every production function declaration | +| Beginner-readable Rust function documentation | `missing_docs` rejects undocumented public API and `podman_desktop_documentation_contract.rs` checks every named function | + +## Release acceptance + +This slice is release-eligible only after the exact integrated head passes: + +1. Rust formatting and tests, including `podman_desktop` tests; +2. frontend unit tests and 100% coverage thresholds; +3. Svelte type checking and production build; +4. security and SAST workflows; +5. current-head review with no unresolved actionable finding; +6. independent non-author approval; +7. packaging, provenance, and release-acceptance workflows. + +## References + +Podman. (n.d.). *podman-machine-inspect—Inspect one or more virtual machines*. Retrieved August 5, 2026, from https://docs.podman.io/en/stable/markdown/podman-machine-inspect.1.html + +Podman. (n.d.). *podman-system-df—Show Podman disk usage*. Retrieved August 5, 2026, from https://docs.podman.io/en/latest/markdown/podman-system-df.1.html + +Tauri Programme within The Commons Conservancy. (2026). *Calling Rust from the frontend*. https://v2.tauri.app/develop/calling-rust/ + +World Wide Web Consortium. (2024, December 12). *Web Content Accessibility Guidelines (WCAG) 2.2*. https://www.w3.org/TR/WCAG22/ + +World Wide Web Consortium. (2025). *Understanding Success Criterion 4.1.3: Status messages*. https://www.w3.org/WAI/WCAG22/Understanding/status-messages diff --git a/docs/development/coverage-evidence.md b/docs/development/coverage-evidence.md new file mode 100644 index 000000000..3f7867384 --- /dev/null +++ b/docs/development/coverage-evidence.md @@ -0,0 +1,44 @@ +# Exact-head coverage evidence + +DiskSage treats code coverage as durable CI evidence rather than a locally asserted percentage. The `Test` workflow measures Rust production coverage on the exact pull-request head and emits a machine-readable `coverage-evidence` artifact only when all required metrics are exactly 100%. + +## Why the workflow checks out the exact head + +GitHub pull-request workflows may otherwise execute against a synthetic merge commit. That is useful for integration testing, but it is not sufficient for a review gate that claims a result about one immutable pull-request head. The coverage job therefore checks out `${{ github.event.pull_request.head.sha || github.sha }}` explicitly and copies the same value into `HEAD_SHA`. + +The evidence builder rejects a missing or malformed SHA. Both `head_sha` and `commit_sha` in `coverage-evidence.json` must equal that exact 40-character commit identifier. The repository identity is taken from `GITHUB_REPOSITORY`, not from user-controlled test output. + +## What is measured + +The workflow uses `cargo llvm-cov` with LLVM source-based instrumentation. Branch coverage is requested explicitly with `--branch`; because cargo-llvm-cov documents branch coverage as unstable, the workflow uses an immutable dated Rust nightly with `llvm-tools-preview` instead of silently falling back to a toolchain that cannot measure the required metric. + +The workflow passes `--no-cfg-coverage` and `--no-cfg-coverage-nightly`, so cargo-llvm-cov does not define its normal `cfg(coverage)` or `cfg(coverage_nightly)` build configurations. Production code guarded by `#[cfg(not(coverage))]` therefore remains in the measured graph rather than disappearing merely because coverage is being collected. This keeps the gate aligned with the production-behavior requirement; it also means unreachable GUI or command boundaries must be made realistically testable rather than hidden from measurement. + +The JSON summary is the only source for the emitted percentages. The evidence builder reads LLVM's aggregate totals and requires all of the following to be present, finite, non-empty, fully covered, and exactly 100%: + +- statement coverage: LLVM region coverage, used as the statement-equivalent source-based metric; +- branch coverage: LLVM branch totals; +- function coverage: LLVM function totals; and +- line coverage: LLVM line totals. + +The workflow never manufactures a percentage from a successful test exit status. Missing totals, zero denominators, partial coverage, malformed JSON, or identity drift stop the job before the success artifact can be uploaded. + +## Evidence and failure diagnostics + +A valid `coverage-evidence.json` has schema version `1` and records the immutable head, repository, CI trust tier, server, workflow name, coverage command, four exact percentages, and `passed: true`. The organization review workflow independently downloads this artifact from the successful `Test` run for the same head and revalidates the contract. + +The success artifact is uploaded with `if-no-files-found: error`. GitHub Actions artifacts persist workflow outputs such as test and coverage results after the producing job completes, which lets the organization-level reviewer consume evidence without granting the coverage job repository-write permission. + +When any metric is below 100%, the job still writes the bounded `coverage-diagnostic.json` containing only the exact head/repository identity and aggregate region, branch, function, and line totals. The same bounded diagnostic is emitted to the job log and `GITHUB_STEP_SUMMARY` before validation throws, then uploaded with `if: always()`. This makes the first failing coverage boundary directly observable without exposing source contents, local paths, secrets, test fixtures, or command output, while the success-only `coverage-evidence.json` remains fail closed. + +## Fail-closed operating rule + +A missing `coverage-evidence` artifact is not equivalent to passing coverage. A queued, cancelled, failed, stale-head, malformed, or less-than-100% measurement is also not passing. Engineers must add realistic tests or remove genuinely unreachable production code; they must not lower thresholds, hard-code percentages, exclude reachable production behavior merely to satisfy the gate, or reuse an artifact from an older head. + +## References + +GitHub. (2026). *Store and share data with workflow artifacts*. GitHub Docs. https://docs.github.com/en/actions/tutorials/store-and-share-data + +GitHub. (2026). *Workflow artifacts*. GitHub Docs. https://docs.github.com/en/actions/concepts/workflows-and-actions/workflow-artifacts + +Taiki Endo. (2026). *cargo-llvm-cov: Cargo subcommand to easily use LLVM source-based code coverage* [Computer software]. GitHub. https://github.com/taiki-e/cargo-llvm-cov diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f98a44243..d466f3c41 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -57,6 +57,8 @@ pub mod multipart_archive; pub mod naruon_capacity; pub mod naruon_cloud_copy_readiness; pub mod naruon_lineage; +/// Privacy-safe desktop projection of read-only Podman reclaim evidence. +pub mod podman_desktop; /// Read-only, fail-closed Podman VM/store reclaim evidence. pub mod podman_reclaim; pub mod provider_api_client; @@ -123,7 +125,8 @@ pub fn run() { commands::copy_cloud_candidate, commands::adopt_existing_cloud_candidate, commands::attest_cloud_copy, - commands::trash_verified_cloud_source + commands::trash_verified_cloud_source, + podman_desktop::inspect_podman_reclaim ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/podman_desktop.rs b/src-tauri/src/podman_desktop.rs new file mode 100644 index 000000000..6fb406e44 --- /dev/null +++ b/src-tauri/src/podman_desktop.rs @@ -0,0 +1,492 @@ +//! Desktop-safe projection of read-only Podman reclaim evidence. +//! +//! The headless `podman_reclaim` module intentionally gathers more local detail than the +//! desktop needs. This module converts that report into a bounded, privacy-safe contract +//! that contains measurements and stable issue codes, but never machine names, paths, +//! image identifiers, tags, or shell command text. + +#![deny(missing_docs)] + +use crate::podman_reclaim::{ + probe_podman_reclaim, PodmanReclaimPlan, PodmanRecommendedActionKind, DEFAULT_PODMAN_MACHINE, + DEFAULT_PROBE_TIMEOUT, +}; +use serde::Serialize; +use std::path::Path; + +/// Stable schema identifier for the desktop-safe Podman evidence response. +pub const PODMAN_DESKTOP_SCHEMA_KIND: &str = "disksage.podman-desktop-evidence"; + +/// Capacity observations displayed independently so logical size is never confused with +/// host allocation or verified physical reclaimability. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct PodmanDesktopCapacityEvidence { + /// Podman machine disk capacity configured by the operator, when available. + pub configured_disk_bytes: Option, + /// Logical length of the VM raw image file, when available. + pub raw_logical_bytes: Option, + /// Host blocks currently allocated to the VM raw image, when supported by the host. + pub host_allocated_bytes: Option, + /// Total bytes reported by the guest root filesystem. + pub guest_total_bytes: Option, + /// Used bytes reported by the guest root filesystem. + pub guest_used_bytes: Option, + /// Available bytes reported by the guest root filesystem. + pub guest_available_bytes: Option, + /// Bytes Podman reports as allocated to its graph root inside the guest. + pub graph_root_allocated_bytes: Option, + /// Bytes Podman reports as used in its graph root inside the guest. + pub graph_root_used_bytes: Option, +} + +/// Logical cleanup candidates reported by Podman without exposing local identifiers. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct PodmanDesktopCandidateEvidence { + /// Logical image candidate bytes reported by `podman system df`. + pub image_candidate_bytes: Option, + /// Logical stopped-container candidate bytes reported by `podman system df`. + pub stopped_container_candidate_bytes: Option, + /// Logical local-volume candidate bytes reported by `podman system df`. + pub volume_candidate_bytes: Option, + /// Count of exact image records with no container references. + pub unused_image_records: Option, + /// Count of stopped containers observed in the Podman store. + pub stopped_container_records: Option, + /// SHA-256 commitment to exact unused image identifiers, tags, and sizes. + pub image_candidate_set_sha256: Option, +} + +/// Separate review boundaries for image, stopped-container, and volume decisions. +/// +/// These booleans are advisory only. They do not authorize or execute any mutation. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct PodmanDesktopReviewBoundaries { + /// Whether image candidates require an independent human review decision. + pub image_review_required: bool, + /// Whether stopped-container candidates require an independent human review decision. + pub stopped_container_review_required: bool, + /// Whether volume candidates require an independent human review decision. + pub volume_review_required: bool, +} + +/// Privacy-safe, read-only Podman evidence returned to the desktop frontend. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct PodmanDesktopEvidence { + /// Stable schema identifier used by frontend validation. + pub schema_kind: &'static str, + /// Schema version for compatibility checks. + pub schema_version: u32, + /// Operating-system family that produced the evidence. + pub platform: &'static str, + /// True only when the headless probe is complete and the candidate fingerprint is valid. + pub evidence_complete: bool, + /// Bounded probe duration in milliseconds. + pub elapsed_ms: u64, + /// Capacity observations kept in distinct semantic categories. + pub capacity: PodmanDesktopCapacityEvidence, + /// Logical candidate observations kept separate by Podman object class. + pub candidates: PodmanDesktopCandidateEvidence, + /// Separate human-review boundaries for images, stopped containers, and volumes. + pub review_boundaries: PodmanDesktopReviewBoundaries, + /// Verified host physical reclaimability; intentionally `None` until before/after proof exists. + pub physically_reclaimable_bytes: Option, + /// Sum of Podman-reported logical candidate bytes, not physical reclaim proof. + pub podman_reported_reclaimable_bytes: Option, + /// Observed host-allocation minus guest-used gap, not physical reclaim proof. + pub raw_allocated_minus_guest_used_bytes: Option, + /// Stable assessment status such as `unverified`. + pub assessment_status: String, + /// Stable, non-sensitive assessment reason codes. + pub reason_codes: Vec, + /// Stable, non-sensitive probe issue codes with dynamic details removed. + pub issue_codes: Vec, + /// User-facing safety statements that define the evidence boundary. + pub notices: Vec, +} + +/// Return true only for a canonical lowercase hexadecimal SHA-256 encoding. +fn valid_sha256(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) +} + +/// Reduce untrusted local diagnostic text to a bounded kebab-case issue code. +/// +/// The prefix before the first colon is accepted only when it starts with a lowercase ASCII +/// letter, contains lowercase ASCII letters, digits, or hyphens, and is at most 96 bytes. Paths, +/// socket names, whitespace, uppercase text, Unicode, underscores, and empty prefixes fall back to +/// one stable generic code rather than crossing the desktop IPC boundary. +fn stable_issue_code(value: &str) -> String { + let code = value.split(':').next().unwrap_or_default(); + let valid = !code.is_empty() + && code.len() <= 96 + && code + .bytes() + .next() + .is_some_and(|byte| byte.is_ascii_lowercase()) + && code + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-'); + + if valid { + code.to_string() + } else { + "podman-evidence-error".to_string() + } +} + +/// Return whether a matching recommended action requires independent human approval. +fn has_action(plan: &PodmanReclaimPlan, kind: PodmanRecommendedActionKind) -> bool { + plan.assessment + .recommended_actions + .iter() + .any(|action| action.kind == kind && action.requires_human_approval) +} + +/// Convert a detailed headless Podman plan into the desktop-safe contract. +/// +/// The conversion removes machine names, all local paths, graph-root locations, image IDs, +/// tags, command output, and dynamic error details. Invalid candidate fingerprints fail +/// closed by clearing the fingerprint and marking the response incomplete. +pub fn redact_podman_reclaim_plan(plan: PodmanReclaimPlan) -> PodmanDesktopEvidence { + let mut issue_codes = plan + .issues + .iter() + .map(|issue| stable_issue_code(issue)) + .collect::>(); + + let candidate_fingerprint = plan + .unused_images + .as_ref() + .map(|images| images.candidate_set_sha256.clone()); + let fingerprint_valid = candidate_fingerprint.as_deref().is_none_or(valid_sha256); + if !fingerprint_valid { + issue_codes.push("podman-desktop-invalid-candidate-fingerprint".to_string()); + } + issue_codes.sort(); + issue_codes.dedup(); + + let capacity = PodmanDesktopCapacityEvidence { + configured_disk_bytes: plan + .machine + .as_ref() + .and_then(|machine| machine.configured_disk_bytes), + raw_logical_bytes: plan.raw_image.as_ref().map(|image| image.logical_bytes), + host_allocated_bytes: plan + .raw_image + .as_ref() + .and_then(|image| image.allocated_bytes), + guest_total_bytes: plan + .guest_filesystem + .as_ref() + .map(|guest| guest.total_bytes), + guest_used_bytes: plan.guest_filesystem.as_ref().map(|guest| guest.used_bytes), + guest_available_bytes: plan + .guest_filesystem + .as_ref() + .map(|guest| guest.available_bytes), + graph_root_allocated_bytes: plan + .store + .as_ref() + .map(|store| store.graph_root_allocated_bytes), + graph_root_used_bytes: plan.store.as_ref().map(|store| store.graph_root_used_bytes), + }; + + let candidates = PodmanDesktopCandidateEvidence { + image_candidate_bytes: plan + .system_df + .as_ref() + .map(|evidence| evidence.images.reclaimable_bytes), + stopped_container_candidate_bytes: plan + .system_df + .as_ref() + .map(|evidence| evidence.containers.reclaimable_bytes), + volume_candidate_bytes: plan + .system_df + .as_ref() + .map(|evidence| evidence.local_volumes.reclaimable_bytes), + unused_image_records: plan + .unused_images + .as_ref() + .map(|images| images.unused_records), + stopped_container_records: plan.store.as_ref().map(|store| store.containers_stopped), + image_candidate_set_sha256: candidate_fingerprint.filter(|_| fingerprint_valid), + }; + + PodmanDesktopEvidence { + schema_kind: PODMAN_DESKTOP_SCHEMA_KIND, + schema_version: 1, + platform: plan.platform, + evidence_complete: plan.evidence_complete && fingerprint_valid, + elapsed_ms: plan.elapsed_ms, + capacity, + candidates, + review_boundaries: PodmanDesktopReviewBoundaries { + image_review_required: has_action( + &plan, + PodmanRecommendedActionKind::ReviewUnusedImages, + ), + stopped_container_review_required: has_action( + &plan, + PodmanRecommendedActionKind::ReviewStoppedContainers, + ), + volume_review_required: has_action( + &plan, + PodmanRecommendedActionKind::ReviewUnusedVolumes, + ), + }, + physically_reclaimable_bytes: plan.assessment.physically_reclaimable_bytes, + podman_reported_reclaimable_bytes: plan.assessment.podman_reported_reclaimable_bytes, + raw_allocated_minus_guest_used_bytes: plan + .assessment + .raw_allocated_minus_guest_used_bytes, + assessment_status: plan.assessment.status, + reason_codes: plan.assessment.reason_codes, + issue_codes, + notices: vec![ + "Podman-reported logical candidates are not verified host physical reclaimability." + .to_string(), + "This desktop surface exposes no prune, remove, machine lifecycle, TRIM, or raw-image mutation command." + .to_string(), + ], + } +} + +/// Run the bounded read-only Podman probe and return only the desktop-safe projection. +/// +/// The command passes an argument vector directly to `std::process::Command` through the +/// headless probe. It never constructs a shell command and never executes a mutation. +#[cfg(not(coverage))] +#[tauri::command] +pub fn inspect_podman_reclaim() -> PodmanDesktopEvidence { + redact_podman_reclaim_plan(probe_podman_reclaim( + Path::new("podman"), + DEFAULT_PODMAN_MACHINE, + DEFAULT_PROBE_TIMEOUT, + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::podman_reclaim::{ + GuestFilesystemEvidence, PodmanMachineEvidence, PodmanReclaimAssessment, + PodmanRecommendedAction, PodmanStoreEvidence, PodmanSystemDfCategoryEvidence, + PodmanSystemDfEvidence, PodmanUnusedImageEvidence, RawImageEvidence, + PODMAN_RECLAIM_SCHEMA_KIND, + }; + + /// Build a deterministic Podman `system df` category fixture with one active record. + fn category(reclaimable_bytes: u64) -> PodmanSystemDfCategoryEvidence { + PodmanSystemDfCategoryEvidence { + total: 2, + active: 1, + size_bytes: reclaimable_bytes.saturating_add(10), + reclaimable_bytes, + } + } + + /// Build a complete privacy-sensitive headless plan used by redaction regression tests. + fn complete_plan() -> PodmanReclaimPlan { + PodmanReclaimPlan { + schema_kind: PODMAN_RECLAIM_SCHEMA_KIND, + schema_version: 3, + platform: "macos", + evidence_complete: true, + elapsed_ms: 17, + machine: Some(PodmanMachineEvidence { + name: "private-machine".to_string(), + state: "running".to_string(), + configured_disk_bytes: Some(1000), + }), + raw_image: Some(RawImageEvidence { + path: "/Users/private/.local/share/private-machine.raw".to_string(), + logical_bytes: 900, + allocated_bytes: Some(700), + }), + guest_filesystem: Some(GuestFilesystemEvidence { + total_bytes: 800, + used_bytes: 500, + available_bytes: 300, + }), + store: Some(PodmanStoreEvidence { + graph_root: "/var/home/private/containers".to_string(), + graph_root_allocated_bytes: 600, + graph_root_used_bytes: 450, + images: 4, + containers_total: 3, + containers_running: 1, + containers_stopped: 2, + }), + system_df: Some(PodmanSystemDfEvidence { + images: category(200), + containers: category(30), + local_volumes: category(70), + }), + unused_images: Some(PodmanUnusedImageEvidence { + total_records: 4, + referenced_records: 2, + unused_records: 2, + unused_untagged_records: 1, + unused_tagged_records: 1, + candidate_record_size_sum: 200, + candidate_set_sha256: "a".repeat(64), + }), + assessment: PodmanReclaimAssessment { + physically_reclaimable_bytes: None, + podman_reported_reclaimable_bytes: Some(300), + raw_allocated_minus_guest_used_bytes: Some(200), + status: "unverified".to_string(), + reason_codes: vec!["host-physical-reclaim-unverified".to_string()], + recommended_actions: vec![ + PodmanRecommendedAction { + kind: PodmanRecommendedActionKind::ReviewUnusedImages, + requires_human_approval: true, + rationale: "image review".to_string(), + }, + PodmanRecommendedAction { + kind: PodmanRecommendedActionKind::ReviewStoppedContainers, + requires_human_approval: true, + rationale: "container review".to_string(), + }, + PodmanRecommendedAction { + kind: PodmanRecommendedActionKind::ReviewUnusedVolumes, + requires_human_approval: true, + rationale: "volume review".to_string(), + }, + ], + }, + issues: vec![], + } + } + + /// Verify that the desktop contract keeps capacity categories separate and redacts local data. + #[test] + fn projection_keeps_measurements_separate_and_removes_private_context() { + let evidence = redact_podman_reclaim_plan(complete_plan()); + assert!(evidence.evidence_complete); + assert_eq!(evidence.capacity.configured_disk_bytes, Some(1000)); + assert_eq!(evidence.capacity.raw_logical_bytes, Some(900)); + assert_eq!(evidence.capacity.host_allocated_bytes, Some(700)); + assert_eq!(evidence.capacity.guest_used_bytes, Some(500)); + assert_eq!(evidence.candidates.image_candidate_bytes, Some(200)); + assert_eq!( + evidence.candidates.stopped_container_candidate_bytes, + Some(30) + ); + assert_eq!(evidence.candidates.volume_candidate_bytes, Some(70)); + assert_eq!( + evidence.candidates.image_candidate_set_sha256, + Some("a".repeat(64)) + ); + let json = serde_json::to_string(&evidence).unwrap(); + assert!(!json.contains("private-machine")); + assert!(!json.contains("/Users/private")); + assert!(!json.contains("/var/home/private")); + } + + /// Verify that image, stopped-container, and volume review decisions never authorize each other. + #[test] + fn image_container_and_volume_reviews_remain_separate() { + let evidence = redact_podman_reclaim_plan(complete_plan()); + assert!(evidence.review_boundaries.image_review_required); + assert!(evidence.review_boundaries.stopped_container_review_required); + assert!(evidence.review_boundaries.volume_review_required); + + let mut plan = complete_plan(); + plan.assessment.recommended_actions = vec![PodmanRecommendedAction { + kind: PodmanRecommendedActionKind::InvestigateApi, + requires_human_approval: false, + rationale: "diagnostic only".to_string(), + }]; + let evidence = redact_podman_reclaim_plan(plan); + assert!(!evidence.review_boundaries.image_review_required); + assert!(!evidence.review_boundaries.stopped_container_review_required); + assert!(!evidence.review_boundaries.volume_review_required); + } + + /// Verify that dynamic local diagnostic details are removed and duplicate stable codes collapse. + #[test] + fn dynamic_issue_details_are_redacted_and_deduplicated() { + let mut plan = complete_plan(); + plan.evidence_complete = false; + plan.issues = vec![ + "podman-info-failed:/Users/alice/private.sock".to_string(), + "podman-info-failed:duplicate detail".to_string(), + "podman-images-timeout".to_string(), + ]; + let evidence = redact_podman_reclaim_plan(plan); + assert!(!evidence.evidence_complete); + assert_eq!( + evidence.issue_codes, + vec![ + "podman-images-timeout".to_string(), + "podman-info-failed".to_string(), + ] + ); + assert!(!serde_json::to_string(&evidence) + .unwrap() + .contains("Users/alice")); + } + + /// Verify that malformed candidate fingerprints fail closed without discarding safe measurements. + #[test] + fn invalid_fingerprint_fails_closed_without_hiding_other_evidence() { + let mut plan = complete_plan(); + plan.unused_images.as_mut().unwrap().candidate_set_sha256 = "BAD".to_string(); + let evidence = redact_podman_reclaim_plan(plan); + assert!(!evidence.evidence_complete); + assert_eq!(evidence.candidates.image_candidate_set_sha256, None); + assert!(evidence + .issue_codes + .contains(&"podman-desktop-invalid-candidate-fingerprint".to_string())); + assert_eq!(evidence.candidates.image_candidate_bytes, Some(200)); + } + + /// Verify that missing optional observations remain unknown rather than becoming false zeroes. + #[test] + fn absent_optional_evidence_stays_unknown_instead_of_becoming_zero() { + let mut plan = complete_plan(); + plan.machine = None; + plan.raw_image = None; + plan.guest_filesystem = None; + plan.store = None; + plan.system_df = None; + plan.unused_images = None; + plan.evidence_complete = false; + let evidence = redact_podman_reclaim_plan(plan); + assert_eq!(evidence.capacity.configured_disk_bytes, None); + assert_eq!(evidence.capacity.raw_logical_bytes, None); + assert_eq!(evidence.capacity.host_allocated_bytes, None); + assert_eq!(evidence.capacity.guest_total_bytes, None); + assert_eq!(evidence.capacity.guest_used_bytes, None); + assert_eq!(evidence.capacity.guest_available_bytes, None); + assert_eq!(evidence.capacity.graph_root_allocated_bytes, None); + assert_eq!(evidence.capacity.graph_root_used_bytes, None); + assert_eq!(evidence.candidates.image_candidate_bytes, None); + assert_eq!(evidence.candidates.stopped_container_candidate_bytes, None); + assert_eq!(evidence.candidates.volume_candidate_bytes, None); + assert_eq!(evidence.candidates.unused_image_records, None); + assert_eq!(evidence.candidates.stopped_container_records, None); + assert_eq!(evidence.candidates.image_candidate_set_sha256, None); + } + + /// Verify stable fallback issue codes and canonical lowercase SHA-256 validation. + #[test] + fn issue_code_fallback_and_fingerprint_validation_are_stable() { + assert_eq!(stable_issue_code(""), "podman-evidence-error"); + assert_eq!(stable_issue_code(":private"), "podman-evidence-error"); + assert_eq!( + stable_issue_code("/Users/alice/private-machine.sock"), + "podman-evidence-error" + ); + assert_eq!(stable_issue_code("UPPERCASE"), "podman-evidence-error"); + assert_eq!(stable_issue_code("unsafe_code"), "podman-evidence-error"); + assert_eq!(stable_issue_code("stable:private"), "stable"); + assert!(valid_sha256(&"0".repeat(64))); + assert!(!valid_sha256(&"A".repeat(64))); + assert!(!valid_sha256("short")); + } +} diff --git a/src-tauri/tests/podman_desktop_branch_coverage.rs b/src-tauri/tests/podman_desktop_branch_coverage.rs new file mode 100644 index 000000000..4e7292eb5 --- /dev/null +++ b/src-tauri/tests/podman_desktop_branch_coverage.rs @@ -0,0 +1,178 @@ +use disksage_lib::podman_desktop::redact_podman_reclaim_plan; +use disksage_lib::podman_reclaim::{ + GuestFilesystemEvidence, PodmanMachineEvidence, PodmanReclaimAssessment, PodmanReclaimPlan, + PodmanRecommendedAction, PodmanRecommendedActionKind, PodmanStoreEvidence, + PodmanSystemDfCategoryEvidence, PodmanSystemDfEvidence, PodmanUnusedImageEvidence, + RawImageEvidence, PODMAN_RECLAIM_SCHEMA_KIND, +}; + +/// Build one deterministic `podman system df` category for projection tests. +fn category(reclaimable_bytes: u64) -> PodmanSystemDfCategoryEvidence { + PodmanSystemDfCategoryEvidence { + total: 2, + active: 1, + size_bytes: reclaimable_bytes.saturating_add(10), + reclaimable_bytes, + } +} + +/// Build a complete plan whose private identifiers must never cross the desktop boundary. +fn complete_plan() -> PodmanReclaimPlan { + PodmanReclaimPlan { + schema_kind: PODMAN_RECLAIM_SCHEMA_KIND, + schema_version: 3, + platform: "macos", + evidence_complete: true, + elapsed_ms: 17, + machine: Some(PodmanMachineEvidence { + name: "private-machine".to_string(), + state: "running".to_string(), + configured_disk_bytes: Some(1_000), + }), + raw_image: Some(RawImageEvidence { + path: "/Users/private/.local/share/private-machine.raw".to_string(), + logical_bytes: 900, + allocated_bytes: Some(700), + }), + guest_filesystem: Some(GuestFilesystemEvidence { + total_bytes: 800, + used_bytes: 500, + available_bytes: 300, + }), + store: Some(PodmanStoreEvidence { + graph_root: "/var/home/private/containers".to_string(), + graph_root_allocated_bytes: 600, + graph_root_used_bytes: 450, + images: 4, + containers_total: 3, + containers_running: 1, + containers_stopped: 2, + }), + system_df: Some(PodmanSystemDfEvidence { + images: category(200), + containers: category(30), + local_volumes: category(70), + }), + unused_images: Some(PodmanUnusedImageEvidence { + total_records: 4, + referenced_records: 2, + unused_records: 2, + unused_untagged_records: 1, + unused_tagged_records: 1, + candidate_record_size_sum: 200, + candidate_set_sha256: "abcdef0123456789".repeat(4), + }), + assessment: PodmanReclaimAssessment { + physically_reclaimable_bytes: None, + podman_reported_reclaimable_bytes: Some(300), + raw_allocated_minus_guest_used_bytes: Some(200), + status: "unverified".to_string(), + reason_codes: vec!["host-physical-reclaim-unverified".to_string()], + recommended_actions: vec![], + }, + issues: vec![], + } +} + +/// Exercise every character-class and length boundary of privacy-safe issue-code admission. +#[test] +fn issue_code_projection_covers_length_prefix_and_character_boundaries() { + let mut plan = complete_plan(); + plan.issues = vec![ + "stable-code9:private-detail".to_string(), + "stable--0".to_string(), + "a".repeat(97), + "1starts-with-digit".to_string(), + "-starts-with-hyphen".to_string(), + "with space".to_string(), + "éclair".to_string(), + ]; + + let evidence = redact_podman_reclaim_plan(plan); + + assert!(evidence.issue_codes.contains(&"stable-code9".to_string())); + assert!(evidence.issue_codes.contains(&"stable--0".to_string())); + assert!(evidence + .issue_codes + .contains(&"podman-evidence-error".to_string())); + assert_eq!( + evidence + .issue_codes + .iter() + .filter(|code| code.as_str() == "podman-evidence-error") + .count(), + 1 + ); +} + +/// Reject lowercase non-hexadecimal fingerprints that otherwise satisfy the exact length bound. +#[test] +fn fingerprint_validation_rejects_lowercase_non_hex_at_exact_length() { + let mut plan = complete_plan(); + plan.unused_images + .as_mut() + .expect("fixture has unused image evidence") + .candidate_set_sha256 = "g".repeat(64); + + let evidence = redact_podman_reclaim_plan(plan); + + assert!(!evidence.evidence_complete); + assert_eq!(evidence.candidates.image_candidate_set_sha256, None); + assert!(evidence + .issue_codes + .contains(&"podman-desktop-invalid-candidate-fingerprint".to_string())); +} + +/// Distinguish a matching action without approval from unrelated and approving actions. +#[test] +fn review_boundaries_require_both_matching_kind_and_human_approval() { + let mut plan = complete_plan(); + plan.assessment.recommended_actions = vec![ + PodmanRecommendedAction { + kind: PodmanRecommendedActionKind::ReviewUnusedImages, + requires_human_approval: false, + rationale: "image observation only".to_string(), + }, + PodmanRecommendedAction { + kind: PodmanRecommendedActionKind::InvestigateApi, + requires_human_approval: true, + rationale: "unrelated approval".to_string(), + }, + PodmanRecommendedAction { + kind: PodmanRecommendedActionKind::ReviewStoppedContainers, + requires_human_approval: true, + rationale: "container review".to_string(), + }, + PodmanRecommendedAction { + kind: PodmanRecommendedActionKind::ReviewUnusedVolumes, + requires_human_approval: false, + rationale: "volume observation only".to_string(), + }, + ]; + + let evidence = redact_podman_reclaim_plan(plan); + + assert!(!evidence.review_boundaries.image_review_required); + assert!(evidence.review_boundaries.stopped_container_review_required); + assert!(!evidence.review_boundaries.volume_review_required); +} + +/// Preserve unknown inner optional measurements even when their enclosing observations exist. +#[test] +fn nested_optional_capacity_values_remain_unknown() { + let mut plan = complete_plan(); + plan.machine + .as_mut() + .expect("fixture has machine evidence") + .configured_disk_bytes = None; + plan.raw_image + .as_mut() + .expect("fixture has raw-image evidence") + .allocated_bytes = None; + + let evidence = redact_podman_reclaim_plan(plan); + + assert_eq!(evidence.capacity.configured_disk_bytes, None); + assert_eq!(evidence.capacity.host_allocated_bytes, None); + assert_eq!(evidence.capacity.raw_logical_bytes, Some(900)); +} diff --git a/src-tauri/tests/podman_desktop_command_coverage.rs b/src-tauri/tests/podman_desktop_command_coverage.rs new file mode 100644 index 000000000..97c30cc68 --- /dev/null +++ b/src-tauri/tests/podman_desktop_command_coverage.rs @@ -0,0 +1,20 @@ +use disksage_lib::podman_desktop::{inspect_podman_reclaim, PODMAN_DESKTOP_SCHEMA_KIND}; + +/// Exercise the production desktop command boundary with the host's read-only Podman probe. +/// +/// The assertions intentionally cover only invariants that hold whether Podman is absent, +/// installed without a machine, or connected to a running machine. This keeps the regression +/// deterministic while proving that the actual command wrapper executes instead of relying only +/// on source-text contracts or the lower-level projection helper. +#[test] +fn desktop_command_executes_the_read_only_probe_boundary() { + let evidence = inspect_podman_reclaim(); + + assert_eq!(evidence.schema_kind, PODMAN_DESKTOP_SCHEMA_KIND); + assert_eq!(evidence.schema_version, 1); + assert_eq!(evidence.physically_reclaimable_bytes, None); + assert_eq!(evidence.assessment_status, "unverified"); + assert!(evidence.notices.iter().any(|notice| { + notice.contains("no prune, remove, machine lifecycle, TRIM, or raw-image mutation") + })); +} diff --git a/src-tauri/tests/podman_desktop_documentation_contract.rs b/src-tauri/tests/podman_desktop_documentation_contract.rs new file mode 100644 index 000000000..407f8580b --- /dev/null +++ b/src-tauri/tests/podman_desktop_documentation_contract.rs @@ -0,0 +1,70 @@ +//! Source-level documentation contract for the Podman desktop evidence module. +//! +//! This test keeps private helpers and regression tests understandable in addition to the public +//! API rustdoc enforced by the module's `missing_docs` lint. + +use std::fs; +use std::path::PathBuf; + +/// Require every named function in the Podman desktop evidence module to have adjacent, +/// beginner-readable rustdoc rather than an empty marker or placeholder text. +#[test] +fn every_podman_desktop_function_has_beginner_readable_rustdoc() { + let source_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src/podman_desktop.rs"); + let source = fs::read_to_string(&source_path).expect("podman_desktop.rs must be readable"); + let lines = source.lines().collect::>(); + let mut violations = Vec::new(); + + for (line_index, line) in lines.iter().enumerate() { + let declaration = line.trim_start(); + let is_named_function = declaration.starts_with("fn ") + || declaration.starts_with("pub fn ") + || declaration.starts_with("pub(crate) fn ") + || declaration.starts_with("async fn ") + || declaration.starts_with("pub async fn ") + || declaration.starts_with("pub(crate) async fn ") + || declaration.starts_with("unsafe fn ") + || declaration.starts_with("pub unsafe fn ") + || declaration.starts_with("pub(crate) unsafe fn ") + || declaration.starts_with("const fn ") + || declaration.starts_with("pub const fn ") + || declaration.starts_with("pub(crate) const fn "); + if !is_named_function { + continue; + } + + let mut cursor = line_index; + while cursor > 0 { + let previous = lines[cursor - 1].trim(); + if previous.is_empty() || previous.starts_with("#[") { + cursor -= 1; + continue; + } + break; + } + + let mut rustdoc_lines = Vec::new(); + while cursor > 0 { + let previous = lines[cursor - 1].trim(); + let Some(rustdoc) = previous.strip_prefix("///") else { + break; + }; + rustdoc_lines.push(rustdoc.trim()); + cursor -= 1; + } + rustdoc_lines.reverse(); + let rustdoc = rustdoc_lines.join(" "); + let readable = rustdoc.chars().count() >= 24 + && !rustdoc.to_ascii_lowercase().contains("todo") + && !rustdoc.to_ascii_lowercase().contains("placeholder"); + if !readable { + violations.push(format!("line {}: {declaration}", line_index + 1)); + } + } + + assert!( + violations.is_empty(), + "every Podman desktop function needs adjacent beginner-readable rustdoc; violations: {}", + violations.join(", ") + ); +} diff --git a/src-tauri/tests/podman_desktop_issue_privacy.rs b/src-tauri/tests/podman_desktop_issue_privacy.rs new file mode 100644 index 000000000..e4591fd98 --- /dev/null +++ b/src-tauri/tests/podman_desktop_issue_privacy.rs @@ -0,0 +1,49 @@ +//! Integration regression for privacy-safe Podman issue codes. +//! +//! Headless probe failures are untrusted local diagnostic strings. A missing delimiter must never +//! allow a path, socket, machine name, or command detail to cross the desktop IPC boundary. + +use disksage_lib::podman_desktop::redact_podman_reclaim_plan; +use disksage_lib::podman_reclaim::{ + PodmanReclaimAssessment, PodmanReclaimPlan, PODMAN_RECLAIM_SCHEMA_KIND, +}; + +/// Builds the smallest complete public plan needed to exercise issue-code projection. +fn plan_with_issue(issue: &str) -> PodmanReclaimPlan { + PodmanReclaimPlan { + schema_kind: PODMAN_RECLAIM_SCHEMA_KIND, + schema_version: 3, + platform: "macos", + evidence_complete: false, + elapsed_ms: 1, + machine: None, + raw_image: None, + guest_filesystem: None, + store: None, + system_df: None, + unused_images: None, + assessment: PodmanReclaimAssessment { + physically_reclaimable_bytes: None, + podman_reported_reclaimable_bytes: None, + raw_allocated_minus_guest_used_bytes: None, + status: "unverified".to_string(), + reason_codes: vec![], + recommended_actions: vec![], + }, + issues: vec![issue.to_string()], + } +} + +/// Rejects delimiter-free local paths instead of serializing them as desktop issue codes. +#[test] +fn delimiter_free_private_issue_detail_falls_back_to_stable_code() { + let evidence = redact_podman_reclaim_plan(plan_with_issue( + "/Users/alice/.local/share/containers/private-machine.sock", + )); + + assert_eq!(evidence.issue_codes, vec!["podman-evidence-error"]); + let json = serde_json::to_string(&evidence).expect("desktop evidence must serialize"); + assert!(!json.contains("alice")); + assert!(!json.contains("private-machine")); + assert!(!json.contains("/Users/")); +} diff --git a/src/lib/Cleanup.svelte b/src/lib/Cleanup.svelte index eceb302ec..1a9976c9f 100644 --- a/src/lib/Cleanup.svelte +++ b/src/lib/Cleanup.svelte @@ -4,6 +4,7 @@ import { verdictBadge } from "./verdictBadge"; import { confirm } from "@tauri-apps/plugin-dialog"; import GitWorktreeCleanup from "./GitWorktreeCleanup.svelte"; + import PodmanEvidence from "./PodmanEvidence.svelte"; let { scannedRoot }: { scannedRoot: string | null } = $props(); @@ -157,6 +158,7 @@ {/if} {/if} + diff --git a/src/lib/PodmanEvidence.svelte b/src/lib/PodmanEvidence.svelte new file mode 100644 index 000000000..96c344c7c --- /dev/null +++ b/src/lib/PodmanEvidence.svelte @@ -0,0 +1,207 @@ + + +
+
+
+

Podman 저장소 증거

+

+ 읽기 전용 진단입니다. 이미지, 컨테이너, 볼륨을 삭제하거나 Podman 머신을 변경하지 않습니다. +

+
+ +
+ + {#if busy} +

Podman의 제한된 읽기 전용 증거를 수집하고 있습니다.

+ {/if} + + {#if error} + + {/if} + + {#if evidence && view} +
+ + {view.completeness_label} + + 호스트 물리 회수 가능량: {view.physical_reclaim_label} + 수집 시간: {evidence.elapsed_ms}ms +
+ +

+ Podman이 보고한 논리 후보는 호스트에서 실제로 회수될 물리 공간의 증명이 아닙니다. 실제 회수량은 별도의 전후 호스트 관측이 있어야 확정됩니다. +

+ +

서로 다른 용량 관측

+
+
+
설정된 머신 디스크
+
{optionalBytes(evidence.capacity.configured_disk_bytes)}
+
+
+
Raw 이미지 논리 크기
+
{optionalBytes(evidence.capacity.raw_logical_bytes)}
+
+
+
호스트 할당 블록
+
{optionalBytes(evidence.capacity.host_allocated_bytes)}
+
+
+
게스트 파일시스템 전체
+
{optionalBytes(evidence.capacity.guest_total_bytes)}
+
+
+
게스트 파일시스템 사용
+
{optionalBytes(evidence.capacity.guest_used_bytes)}
+
+
+
게스트 파일시스템 여유
+
{optionalBytes(evidence.capacity.guest_available_bytes)}
+
+
+
Podman graph root 할당
+
{optionalBytes(evidence.capacity.graph_root_allocated_bytes)}
+
+
+
Podman graph root 사용
+
{optionalBytes(evidence.capacity.graph_root_used_bytes)}
+
+
+
Raw 할당−게스트 사용 차이
+
{optionalBytes(evidence.raw_allocated_minus_guest_used_bytes)}
+
+
+
Podman 논리 후보 합계
+
{optionalBytes(evidence.podman_reported_reclaimable_bytes)}
+
+
+ +

분리된 검토 영역

+
+
+
이미지
+

{view.image_review_label}

+
+
논리 후보
{optionalBytes(evidence.candidates.image_candidate_bytes)}
+
참조 0 레코드
{optionalCount(evidence.candidates.unused_image_records)}
+
+
+
+
중지 컨테이너
+

{view.container_review_label}

+
+
논리 후보
{optionalBytes(evidence.candidates.stopped_container_candidate_bytes)}
+
중지 레코드
{optionalCount(evidence.candidates.stopped_container_records)}
+
+
+
+
로컬 볼륨
+

{view.volume_review_label}

+
+
논리 후보
{optionalBytes(evidence.candidates.volume_candidate_bytes)}
+
+
+
+ +

후보 집합 증거

+

+ 이미지 후보 집합 SHA-256: + {#if evidence.candidates.image_candidate_set_sha256} + {evidence.candidates.image_candidate_set_sha256} + {:else} + 관측되지 않음 + {/if} +

+ + {#if evidence.reason_codes.length > 0} +

판정 사유 코드

+
    + {#each evidence.reason_codes as reason (reason)} +
  • {reason}
  • + {/each} +
+ {/if} + + {#if view.has_issues} +

증거 누락·오류 코드

+
    + {#each evidence.issue_codes as issue (issue)} +
  • {issue}
  • + {/each} +
+ {/if} + +
    + {#each evidence.notices as notice (notice)} +
  • {notice}
  • + {/each} +
+ {/if} +
+ + diff --git a/src/lib/coverageEvidenceWorkflow.test.ts b/src/lib/coverageEvidenceWorkflow.test.ts new file mode 100644 index 000000000..6b8a44944 --- /dev/null +++ b/src/lib/coverageEvidenceWorkflow.test.ts @@ -0,0 +1,71 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; + +const workflow = readFileSync( + new URL('../../.github/workflows/test.yml', import.meta.url), + 'utf8', +); + +describe('Test workflow coverage evidence contract', () => { + it('binds coverage evidence to the exact pull-request head', () => { + expect(workflow).toContain( + 'ref: ${{ github.event.pull_request.head.sha || github.sha }}', + ); + expect(workflow).toContain( + 'HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }}', + ); + }); + + it('measures Rust branch coverage instead of synthesizing percentages', () => { + expect(workflow).toContain('tool: cargo-llvm-cov'); + expect(workflow).toContain( + 'cargo llvm-cov --manifest-path src-tauri/Cargo.toml --branch --json --summary-only --output-path coverage.json', + ); + expect(workflow).toContain('coverage.json'); + expect(workflow).toContain('coverage-evidence.json'); + }); + + it('measures the production Rust graph instead of cfg-pruned substitutes', () => { + expect(workflow).toContain('--no-cfg-coverage'); + expect(workflow).toContain('--no-cfg-coverage-nightly'); + }); + + it('preserves bounded exact-head metric diagnostics when the 100% gate fails', () => { + expect(workflow).toContain('coverage-diagnostic.json'); + expect(workflow).toContain('name: coverage-diagnostic-${{ env.HEAD_SHA }}'); + expect(workflow).toContain('path: coverage-diagnostic.json'); + expect(workflow).toContain('if: always()'); + expect(workflow).toContain('regions: totals?.regions ?? null'); + expect(workflow).toContain('branches: totals?.branches ?? null'); + expect(workflow).toContain('functions: totals?.functions ?? null'); + expect(workflow).toContain('lines: totals?.lines ?? null'); + }); + + it('identifies the largest exact-head Rust coverage gaps without leaking runner paths', () => { + expect(workflow).toContain('top_uncovered_files'); + expect(workflow).toContain("const marker = '/src-tauri/'"); + expect(workflow).toContain("return `src-tauri/${normalized.slice(markerIndex + marker.length)}`"); + expect(workflow).toContain('.slice(0, 20)'); + expect(workflow).toContain('uncovered_regions'); + expect(workflow).toContain('uncovered_branches'); + expect(workflow).toContain('uncovered_functions'); + expect(workflow).toContain('uncovered_lines'); + }); + + it('surfaces the same bounded diagnostic in logs and the GitHub step summary', () => { + expect(workflow).toContain("console.error(`coverage-diagnostic=${JSON.stringify(diagnostic)}`)"); + expect(workflow).toContain('process.env.GITHUB_STEP_SUMMARY'); + expect(workflow).toMatch(/appendFileSync\(\s*summaryPath,/u); + expect(workflow).toContain('Coverage diagnostic for \\`${sha}\\`'); + }); + + it('uploads fail-closed evidence under the organization contract name', () => { + expect(workflow).toContain('name: coverage-evidence'); + expect(workflow).toContain('path: coverage-evidence.json'); + expect(workflow).toContain('if-no-files-found: error'); + expect(workflow).toContain('statement_coverage'); + expect(workflow).toContain('branch_coverage'); + expect(workflow).toContain('function_coverage'); + expect(workflow).toContain('line_coverage'); + }); +}); diff --git a/src/lib/podmanEvidence.docstrings.test.ts b/src/lib/podmanEvidence.docstrings.test.ts new file mode 100644 index 000000000..c2bc1d2c7 --- /dev/null +++ b/src/lib/podmanEvidence.docstrings.test.ts @@ -0,0 +1,20 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const source = readFileSync(new URL("./podmanEvidence.ts", import.meta.url), "utf8"); +const productionFunctions = [ + ...source.matchAll(/^(?:export\s+)?(?:async\s+)?function\s+([A-Za-z0-9_]+)/gm), +].map((match) => match[1]); + +describe("Podman evidence documentation contract", () => { + it("keeps every production function beginner-readable with an adjacent JSDoc", () => { + expect(productionFunctions.length).toBeGreaterThan(0); + + for (const functionName of productionFunctions) { + const documentedFunction = new RegExp( + String.raw`/\*\*[\s\S]*?\*/\s*(?:export\s+)?(?:async\s+)?function\s+${functionName}\b`, + ); + expect(source, `missing adjacent JSDoc for ${functionName}`).toMatch(documentedFunction); + } + }); +}); diff --git a/src/lib/podmanEvidence.error.test.ts b/src/lib/podmanEvidence.error.test.ts new file mode 100644 index 000000000..3c3172090 --- /dev/null +++ b/src/lib/podmanEvidence.error.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; + +import { podmanEvidenceErrorMessage } from "./podmanEvidenceError"; + +describe("podmanEvidenceErrorMessage", () => { + it.each([ + new Error("podman failed at /Users/alice/.local/share/containers"), + "transport error: private-machine.sock", + { secret: "account-local-context" }, + null, + undefined, + ])("returns one stable privacy-safe message for untrusted failure detail %#", (reason) => { + const message = podmanEvidenceErrorMessage(reason); + expect(message).toBe("podman-evidence-unavailable"); + expect(message).not.toContain("alice"); + expect(message).not.toContain("private-machine"); + expect(message).not.toContain("account-local-context"); + }); +}); diff --git a/src/lib/podmanEvidence.test.ts b/src/lib/podmanEvidence.test.ts new file mode 100644 index 000000000..f2004745f --- /dev/null +++ b/src/lib/podmanEvidence.test.ts @@ -0,0 +1,255 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { invokeMock } = vi.hoisted(() => ({ invokeMock: vi.fn() })); + +vi.mock("@tauri-apps/api/core", () => ({ invoke: invokeMock })); + +import { + PODMAN_DESKTOP_SCHEMA_KIND, + loadPodmanEvidence, + parsePodmanDesktopEvidence, + podmanEvidenceView, + type PodmanDesktopEvidence, +} from "./podmanEvidence"; + +function fixture(): Record { + return { + schema_kind: PODMAN_DESKTOP_SCHEMA_KIND, + schema_version: 1, + platform: "macos", + evidence_complete: true, + elapsed_ms: 17, + capacity: { + configured_disk_bytes: 1000, + raw_logical_bytes: 900, + host_allocated_bytes: 700, + guest_total_bytes: 800, + guest_used_bytes: 500, + guest_available_bytes: 300, + graph_root_allocated_bytes: 600, + graph_root_used_bytes: 450, + }, + candidates: { + image_candidate_bytes: 200, + stopped_container_candidate_bytes: 30, + volume_candidate_bytes: 70, + unused_image_records: 2, + stopped_container_records: 2, + image_candidate_set_sha256: "a".repeat(64), + }, + review_boundaries: { + image_review_required: true, + stopped_container_review_required: true, + volume_review_required: true, + }, + physically_reclaimable_bytes: null, + podman_reported_reclaimable_bytes: 300, + raw_allocated_minus_guest_used_bytes: 200, + assessment_status: "unverified", + reason_codes: ["host-physical-reclaim-unverified"], + issue_codes: ["partial-evidence"], + notices: ["read only"], + }; +} + +function cloneFixture(): Record { + return JSON.parse(JSON.stringify(fixture())); +} + +beforeEach(() => { + invokeMock.mockReset(); +}); + +describe("parsePodmanDesktopEvidence", () => { + it("accepts the complete privacy-safe schema", () => { + const parsed = parsePodmanDesktopEvidence(fixture()); + expect(parsed.schema_kind).toBe(PODMAN_DESKTOP_SCHEMA_KIND); + expect(parsed.capacity.host_allocated_bytes).toBe(700); + expect(parsed.candidates.image_candidate_set_sha256).toBe("a".repeat(64)); + expect(parsed.reason_codes).toEqual(["host-physical-reclaim-unverified"]); + }); + + it("preserves unknown observations as null", () => { + const value = cloneFixture(); + for (const key of Object.keys(value.capacity)) value.capacity[key] = null; + for (const key of [ + "image_candidate_bytes", + "stopped_container_candidate_bytes", + "volume_candidate_bytes", + "unused_image_records", + "stopped_container_records", + "image_candidate_set_sha256", + ]) { + value.candidates[key] = null; + } + value.physically_reclaimable_bytes = null; + value.podman_reported_reclaimable_bytes = null; + value.raw_allocated_minus_guest_used_bytes = null; + const parsed = parsePodmanDesktopEvidence(value); + expect(Object.values(parsed.capacity).every((entry) => entry === null)).toBe(true); + expect(Object.values(parsed.candidates).every((entry) => entry === null)).toBe(true); + }); + + it.each([ + [null, "invalid-podman-desktop-evidence"], + [[], "invalid-podman-desktop-evidence"], + ["bad", "invalid-podman-desktop-evidence"], + ])("rejects a non-record response %#", (value, message) => { + expect(() => parsePodmanDesktopEvidence(value)).toThrow(message); + }); + + it("rejects schema drift", () => { + const wrongKind = cloneFixture(); + wrongKind.schema_kind = "other"; + expect(() => parsePodmanDesktopEvidence(wrongKind)).toThrow( + "unsupported-podman-desktop-schema-kind", + ); + const wrongVersion = cloneFixture(); + wrongVersion.schema_version = 2; + expect(() => parsePodmanDesktopEvidence(wrongVersion)).toThrow( + "unsupported-podman-desktop-schema-version", + ); + }); + + it.each([ + ["platform", 1, "invalid-platform"], + ["evidence_complete", "yes", "invalid-evidence-complete"], + ["elapsed_ms", "17", "invalid-elapsed-ms"], + ["elapsed_ms", 1.5, "invalid-elapsed-ms"], + ["elapsed_ms", -1, "invalid-elapsed-ms"], + ["assessment_status", false, "invalid-assessment-status"], + ["reason_codes", "bad", "invalid-reason-codes"], + ["reason_codes", ["ok", 2], "invalid-reason-codes"], + ["issue_codes", "bad", "invalid-issue-codes"], + ["notices", "bad", "invalid-notices"], + ])("rejects invalid top-level field %s", (field, value, message) => { + const invalid = cloneFixture(); + invalid[field] = value; + expect(() => parsePodmanDesktopEvidence(invalid)).toThrow(message); + }); + + it("rejects invalid nested records", () => { + const capacity = cloneFixture(); + capacity.capacity = []; + expect(() => parsePodmanDesktopEvidence(capacity)).toThrow("invalid-podman-capacity"); + + const candidates = cloneFixture(); + candidates.candidates = null; + expect(() => parsePodmanDesktopEvidence(candidates)).toThrow("invalid-podman-candidates"); + + const boundaries = cloneFixture(); + boundaries.review_boundaries = "bad"; + expect(() => parsePodmanDesktopEvidence(boundaries)).toThrow( + "invalid-podman-review-boundaries", + ); + }); + + it.each([ + ["configured_disk_bytes", -1, "invalid-configured-disk-bytes"], + ["raw_logical_bytes", "1", "invalid-raw-logical-bytes"], + ["host_allocated_bytes", 1.2, "invalid-host-allocated-bytes"], + ["guest_total_bytes", -1, "invalid-guest-total-bytes"], + ["guest_used_bytes", "1", "invalid-guest-used-bytes"], + ["guest_available_bytes", 1.2, "invalid-guest-available-bytes"], + ["graph_root_allocated_bytes", -1, "invalid-graph-root-allocated-bytes"], + ["graph_root_used_bytes", "1", "invalid-graph-root-used-bytes"], + ])("rejects invalid capacity field %s", (field, value, message) => { + const invalid = cloneFixture(); + invalid.capacity[field] = value; + expect(() => parsePodmanDesktopEvidence(invalid)).toThrow(message); + }); + + it.each([ + ["image_candidate_bytes", -1, "invalid-image-candidate-bytes"], + ["stopped_container_candidate_bytes", "1", "invalid-stopped-container-candidate-bytes"], + ["volume_candidate_bytes", 1.2, "invalid-volume-candidate-bytes"], + ["unused_image_records", -1, "invalid-unused-image-records"], + ["stopped_container_records", "1", "invalid-stopped-container-records"], + ])("rejects invalid candidate field %s", (field, value, message) => { + const invalid = cloneFixture(); + invalid.candidates[field] = value; + expect(() => parsePodmanDesktopEvidence(invalid)).toThrow(message); + }); + + it("rejects malformed or non-string candidate fingerprints", () => { + const malformed = cloneFixture(); + malformed.candidates.image_candidate_set_sha256 = "BAD"; + expect(() => parsePodmanDesktopEvidence(malformed)).toThrow( + "invalid-image-candidate-set-sha256", + ); + const wrongType = cloneFixture(); + wrongType.candidates.image_candidate_set_sha256 = 1; + expect(() => parsePodmanDesktopEvidence(wrongType)).toThrow( + "invalid-image-candidate-set-sha256", + ); + }); + + it.each([ + ["image_review_required", "yes", "invalid-image-review-required"], + ["stopped_container_review_required", 1, "invalid-stopped-container-review-required"], + ["volume_review_required", null, "invalid-volume-review-required"], + ])("rejects invalid review boundary %s", (field, value, message) => { + const invalid = cloneFixture(); + invalid.review_boundaries[field] = value; + expect(() => parsePodmanDesktopEvidence(invalid)).toThrow(message); + }); + + it.each([ + ["physically_reclaimable_bytes", -1, "invalid-physically-reclaimable-bytes"], + ["podman_reported_reclaimable_bytes", "1", "invalid-podman-reported-reclaimable-bytes"], + ["raw_allocated_minus_guest_used_bytes", 1.5, "invalid-raw-allocated-minus-guest-used-bytes"], + ])("rejects invalid assessment byte field %s", (field, value, message) => { + const invalid = cloneFixture(); + invalid[field] = value; + expect(() => parsePodmanDesktopEvidence(invalid)).toThrow(message); + }); +}); + +describe("loadPodmanEvidence", () => { + it("uses the registered read-only command by default", async () => { + invokeMock.mockResolvedValue(fixture()); + await expect(loadPodmanEvidence()).resolves.toMatchObject({ schema_version: 1 }); + expect(invokeMock).toHaveBeenCalledWith("inspect_podman_reclaim"); + }); + + it("supports an injected invoker for deterministic contract tests", async () => { + const injected = vi.fn().mockResolvedValue(fixture()); + await expect(loadPodmanEvidence(injected)).resolves.toMatchObject({ platform: "macos" }); + expect(injected).toHaveBeenCalledWith("inspect_podman_reclaim"); + }); +}); + +describe("podmanEvidenceView", () => { + it("labels complete evidence while keeping physical reclaim unknown", () => { + const evidence = parsePodmanDesktopEvidence(fixture()); + expect(podmanEvidenceView(evidence)).toEqual({ + completeness_label: "증거 완전", + completeness_tone: "complete", + physical_reclaim_label: "검증되지 않음", + image_review_label: "이미지 별도 검토 필요", + container_review_label: "중지 컨테이너 별도 검토 필요", + volume_review_label: "볼륨 별도 검토 필요", + has_issues: true, + }); + }); + + it("labels partial evidence and keeps all review domains independent", () => { + const value = cloneFixture(); + value.evidence_complete = false; + value.physically_reclaimable_bytes = 12; + value.review_boundaries.image_review_required = false; + value.review_boundaries.stopped_container_review_required = false; + value.review_boundaries.volume_review_required = false; + value.issue_codes = []; + const evidence = parsePodmanDesktopEvidence(value) as PodmanDesktopEvidence; + expect(podmanEvidenceView(evidence)).toEqual({ + completeness_label: "부분 증거", + completeness_tone: "partial", + physical_reclaim_label: "12 bytes", + image_review_label: "이미지 검토 신호 없음", + container_review_label: "중지 컨테이너 검토 신호 없음", + volume_review_label: "볼륨 검토 신호 없음", + has_issues: false, + }); + }); +}); diff --git a/src/lib/podmanEvidence.ts b/src/lib/podmanEvidence.ts new file mode 100644 index 000000000..216a6a70e --- /dev/null +++ b/src/lib/podmanEvidence.ts @@ -0,0 +1,334 @@ +import { invoke } from "@tauri-apps/api/core"; + +/** Stable schema kind emitted by the Rust desktop projection. */ +export const PODMAN_DESKTOP_SCHEMA_KIND = "disksage.podman-desktop-evidence"; + +/** Nullable byte value used when an observation could not be collected. */ +export type OptionalBytes = number | null; + +/** Capacity observations whose meanings must remain visually separate. */ +export interface PodmanDesktopCapacityEvidence { + configured_disk_bytes: OptionalBytes; + raw_logical_bytes: OptionalBytes; + host_allocated_bytes: OptionalBytes; + guest_total_bytes: OptionalBytes; + guest_used_bytes: OptionalBytes; + guest_available_bytes: OptionalBytes; + graph_root_allocated_bytes: OptionalBytes; + graph_root_used_bytes: OptionalBytes; +} + +/** Logical Podman candidates that are not verified host physical reclaimability. */ +export interface PodmanDesktopCandidateEvidence { + image_candidate_bytes: OptionalBytes; + stopped_container_candidate_bytes: OptionalBytes; + volume_candidate_bytes: OptionalBytes; + unused_image_records: number | null; + stopped_container_records: number | null; + image_candidate_set_sha256: string | null; +} + +/** Separate human-review boundaries for each Podman object class. */ +export interface PodmanDesktopReviewBoundaries { + image_review_required: boolean; + stopped_container_review_required: boolean; + volume_review_required: boolean; +} + +/** Privacy-safe, read-only Podman evidence returned by the Tauri command. */ +export interface PodmanDesktopEvidence { + schema_kind: typeof PODMAN_DESKTOP_SCHEMA_KIND; + schema_version: 1; + platform: string; + evidence_complete: boolean; + elapsed_ms: number; + capacity: PodmanDesktopCapacityEvidence; + candidates: PodmanDesktopCandidateEvidence; + review_boundaries: PodmanDesktopReviewBoundaries; + physically_reclaimable_bytes: OptionalBytes; + podman_reported_reclaimable_bytes: OptionalBytes; + raw_allocated_minus_guest_used_bytes: OptionalBytes; + assessment_status: string; + reason_codes: string[]; + issue_codes: string[]; + notices: string[]; +} + +/** Display model used by the Svelte component and its headless behavior tests. */ +export interface PodmanEvidenceView { + completeness_label: string; + completeness_tone: "complete" | "partial"; + physical_reclaim_label: string; + image_review_label: string; + container_review_label: string; + volume_review_label: string; + has_issues: boolean; +} + +type InvokeFunction = (command: string) => Promise; +type JsonRecord = Record; + +/** + * Require a plain JSON object and reject arrays, null, and primitive values. + * + * @param value - Untrusted value received from the Tauri boundary. + * @param label - Stable field label included in the fail-closed error code. + * @returns The same value narrowed to a string-keyed JSON record. + * @throws When the value is not a plain object-shaped record. + */ +function record(value: unknown, label: string): JsonRecord { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error(`invalid-${label}`); + } + return value as JsonRecord; +} + +/** + * Require a string value from an untrusted response field. + * + * @param value - Candidate field value. + * @param label - Stable field label included in the error code. + * @returns The validated string. + * @throws When the value is not a string. + */ +function stringValue(value: unknown, label: string): string { + if (typeof value !== "string") throw new Error(`invalid-${label}`); + return value; +} + +/** + * Require a boolean value from an untrusted response field. + * + * @param value - Candidate field value. + * @param label - Stable field label included in the error code. + * @returns The validated boolean. + * @throws When the value is not a boolean. + */ +function booleanValue(value: unknown, label: string): boolean { + if (typeof value !== "boolean") throw new Error(`invalid-${label}`); + return value; +} + +/** + * Require a non-negative JavaScript safe integer. + * + * Byte counts and record counts are rejected rather than rounded when Rust-to-JavaScript + * serialization produces an unsafe, negative, fractional, or nonnumeric value. + * + * @param value - Candidate numeric field value. + * @param label - Stable field label included in the error code. + * @returns The validated unsigned safe integer. + * @throws When the value cannot be represented exactly and safely in JavaScript. + */ +function unsignedInteger(value: unknown, label: string): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) { + throw new Error(`invalid-${label}`); + } + return value; +} + +/** + * Preserve an explicitly unavailable observation as null or validate its unsigned value. + * + * @param value - Candidate field value, where null means the probe could not observe it. + * @param label - Stable field label included in the error code. + * @returns Null for an unavailable observation, otherwise a validated unsigned safe integer. + * @throws When a non-null value is not a safe unsigned integer. + */ +function optionalUnsignedInteger(value: unknown, label: string): number | null { + return value === null ? null : unsignedInteger(value, label); +} + +/** + * Require an array containing only strings and return a defensive copy. + * + * @param value - Candidate list value. + * @param label - Stable field label included in the error code. + * @returns A new array containing the validated strings. + * @throws When the value is not a string-only array. + */ +function stringArray(value: unknown, label: string): string[] { + if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) { + throw new Error(`invalid-${label}`); + } + return [...value]; +} + +/** + * Validate an optional lowercase SHA-256 commitment. + * + * @param value - Null when no candidate set was observed, otherwise the encoded digest. + * @returns Null or a 64-character lowercase hexadecimal SHA-256 string. + * @throws When a supplied fingerprint is malformed or uses a different encoding. + */ +function sha256OrNull(value: unknown): string | null { + if (value === null) return null; + const fingerprint = stringValue(value, "image-candidate-set-sha256"); + if (!/^[0-9a-f]{64}$/.test(fingerprint)) { + throw new Error("invalid-image-candidate-set-sha256"); + } + return fingerprint; +} + +/** + * Parse the capacity section while preserving every measurement as a distinct concept. + * + * @param value - Untrusted capacity object from the Rust response. + * @returns Validated capacity observations with unavailable values preserved as null. + * @throws When the section or any member violates the versioned desktop contract. + */ +function parseCapacity(value: unknown): PodmanDesktopCapacityEvidence { + const capacity = record(value, "podman-capacity"); + return { + configured_disk_bytes: optionalUnsignedInteger( + capacity.configured_disk_bytes, + "configured-disk-bytes", + ), + raw_logical_bytes: optionalUnsignedInteger(capacity.raw_logical_bytes, "raw-logical-bytes"), + host_allocated_bytes: optionalUnsignedInteger( + capacity.host_allocated_bytes, + "host-allocated-bytes", + ), + guest_total_bytes: optionalUnsignedInteger(capacity.guest_total_bytes, "guest-total-bytes"), + guest_used_bytes: optionalUnsignedInteger(capacity.guest_used_bytes, "guest-used-bytes"), + guest_available_bytes: optionalUnsignedInteger( + capacity.guest_available_bytes, + "guest-available-bytes", + ), + graph_root_allocated_bytes: optionalUnsignedInteger( + capacity.graph_root_allocated_bytes, + "graph-root-allocated-bytes", + ), + graph_root_used_bytes: optionalUnsignedInteger( + capacity.graph_root_used_bytes, + "graph-root-used-bytes", + ), + }; +} + +/** + * Parse logical cleanup candidates without treating them as verified physical savings. + * + * @param value - Untrusted candidate object from the Rust response. + * @returns Validated candidate counts, byte observations, and optional set commitment. + * @throws When a candidate field violates its type, range, or fingerprint contract. + */ +function parseCandidates(value: unknown): PodmanDesktopCandidateEvidence { + const candidates = record(value, "podman-candidates"); + return { + image_candidate_bytes: optionalUnsignedInteger( + candidates.image_candidate_bytes, + "image-candidate-bytes", + ), + stopped_container_candidate_bytes: optionalUnsignedInteger( + candidates.stopped_container_candidate_bytes, + "stopped-container-candidate-bytes", + ), + volume_candidate_bytes: optionalUnsignedInteger( + candidates.volume_candidate_bytes, + "volume-candidate-bytes", + ), + unused_image_records: optionalUnsignedInteger( + candidates.unused_image_records, + "unused-image-records", + ), + stopped_container_records: optionalUnsignedInteger( + candidates.stopped_container_records, + "stopped-container-records", + ), + image_candidate_set_sha256: sha256OrNull(candidates.image_candidate_set_sha256), + }; +} + +/** + * Parse independent review requirements for images, stopped containers, and volumes. + * + * @param value - Untrusted review-boundary object from the Rust response. + * @returns Three validated booleans that remain advisory and mutually non-authorizing. + * @throws When any review boundary is absent or not boolean. + */ +function parseReviewBoundaries(value: unknown): PodmanDesktopReviewBoundaries { + const boundaries = record(value, "podman-review-boundaries"); + return { + image_review_required: booleanValue( + boundaries.image_review_required, + "image-review-required", + ), + stopped_container_review_required: booleanValue( + boundaries.stopped_container_review_required, + "stopped-container-review-required", + ), + volume_review_required: booleanValue( + boundaries.volume_review_required, + "volume-review-required", + ), + }; +} + +/** Parse the Rust response and fail closed on schema, type, range, or fingerprint drift. */ +export function parsePodmanDesktopEvidence(value: unknown): PodmanDesktopEvidence { + const evidence = record(value, "podman-desktop-evidence"); + if (evidence.schema_kind !== PODMAN_DESKTOP_SCHEMA_KIND) { + throw new Error("unsupported-podman-desktop-schema-kind"); + } + if (evidence.schema_version !== 1) { + throw new Error("unsupported-podman-desktop-schema-version"); + } + return { + schema_kind: PODMAN_DESKTOP_SCHEMA_KIND, + schema_version: 1, + platform: stringValue(evidence.platform, "platform"), + evidence_complete: booleanValue(evidence.evidence_complete, "evidence-complete"), + elapsed_ms: unsignedInteger(evidence.elapsed_ms, "elapsed-ms"), + capacity: parseCapacity(evidence.capacity), + candidates: parseCandidates(evidence.candidates), + review_boundaries: parseReviewBoundaries(evidence.review_boundaries), + physically_reclaimable_bytes: optionalUnsignedInteger( + evidence.physically_reclaimable_bytes, + "physically-reclaimable-bytes", + ), + podman_reported_reclaimable_bytes: optionalUnsignedInteger( + evidence.podman_reported_reclaimable_bytes, + "podman-reported-reclaimable-bytes", + ), + raw_allocated_minus_guest_used_bytes: optionalUnsignedInteger( + evidence.raw_allocated_minus_guest_used_bytes, + "raw-allocated-minus-guest-used-bytes", + ), + assessment_status: stringValue(evidence.assessment_status, "assessment-status"), + reason_codes: stringArray(evidence.reason_codes, "reason-codes"), + issue_codes: stringArray(evidence.issue_codes, "issue-codes"), + notices: stringArray(evidence.notices, "notices"), + }; +} + +/** Invoke the read-only Tauri command and validate the returned contract. */ +export async function loadPodmanEvidence( + invokeFunction: InvokeFunction = invoke, +): Promise { + return parsePodmanDesktopEvidence( + await invokeFunction("inspect_podman_reclaim"), + ); +} + +/** Derive stable user-facing state labels without granting any cleanup authority. */ +export function podmanEvidenceView(evidence: PodmanDesktopEvidence): PodmanEvidenceView { + return { + completeness_label: evidence.evidence_complete ? "증거 완전" : "부분 증거", + completeness_tone: evidence.evidence_complete ? "complete" : "partial", + physical_reclaim_label: + evidence.physically_reclaimable_bytes === null + ? "검증되지 않음" + : `${evidence.physically_reclaimable_bytes} bytes`, + image_review_label: evidence.review_boundaries.image_review_required + ? "이미지 별도 검토 필요" + : "이미지 검토 신호 없음", + container_review_label: evidence.review_boundaries.stopped_container_review_required + ? "중지 컨테이너 별도 검토 필요" + : "중지 컨테이너 검토 신호 없음", + volume_review_label: evidence.review_boundaries.volume_review_required + ? "볼륨 별도 검토 필요" + : "볼륨 검토 신호 없음", + has_issues: evidence.issue_codes.length > 0, + }; +} diff --git a/src/lib/podmanEvidenceError.ts b/src/lib/podmanEvidenceError.ts new file mode 100644 index 000000000..ffddb6a26 --- /dev/null +++ b/src/lib/podmanEvidenceError.ts @@ -0,0 +1,14 @@ +/** + * Convert any untrusted Podman inspection failure into one stable privacy-safe code. + * + * Tauri transport failures, operating-system errors, and thrown JavaScript values may contain + * machine names, account-local paths, socket locations, or command details. The desktop UI must + * not render those values. Detailed diagnosis remains local to trusted logs and is never copied + * into the shareable evidence surface. + * + * @param reason - Untrusted failure detail intentionally discarded at the UI boundary. + * @returns A stable non-sensitive code suitable for user-facing status text and telemetry. + */ +export function podmanEvidenceErrorMessage(_reason: unknown): string { + return "podman-evidence-unavailable"; +} diff --git a/vitest.config.ts b/vitest.config.ts index 198e3dcb8..1b1ea7288 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -13,11 +13,13 @@ export default defineConfig({ "src/lib/fmt.ts", "src/lib/dupeGuard.ts", "src/lib/verdictBadge.ts", + "src/lib/podmanEvidence.ts", + "src/lib/podmanEvidenceError.ts", ], reporter: ["text", "json", "json-summary"], - // ponytail: 위 include 5개 순수 로직 파일은 헤드리스로 완전 검증 가능하므로 - // 네 지표 모두 100%로 고정한다. 이 게이트는 scope를 넓히지 않는다 — - // Svelte 컴포넌트는 여전히 cargo test + 수동 체크리스트로 검증한다. + // ponytail: 위 include의 헤드리스 순수 로직/API 계약 파일은 완전 검증 가능하므로 + // 네 지표 모두 100%로 고정한다. Svelte 컴포넌트의 상태 분기는 같은 순수 view + // model을 통해 검증하고, 실제 렌더링은 build/svelte-check와 수동 체크리스트로 확인한다. thresholds: { statements: 100, branches: 100,