diff --git a/docs/architecture/podman-desktop-evidence.md b/docs/architecture/podman-desktop-evidence.md new file mode 100644 index 000000000..5946cd518 --- /dev/null +++ b/docs/architecture/podman-desktop-evidence.md @@ -0,0 +1,135 @@ +# 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; and 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. + +A complete exact-image observation must contain both the exact unused-image record count and the SHA-256 commitment to that candidate set. The frontend rejects complete evidence when either member is missing and rejects a fingerprint that has no exact record observation. Partial evidence may retain safe exact-record counts after Rust removes an invalid fingerprint and emits an issue; this remains explicitly incomplete rather than being mislabeled as a complete candidate set. + +Any projected issue code forces `evidence_complete` to false, even when an upstream caller incorrectly supplies `true`. The frontend independently rejects a response that combines `evidence_complete: true` with one or more issue codes. This keeps completeness as an integrity assertion rather than a cosmetic label. + +The only assessment status admitted by schema version 1 is `unverified`. If a contradictory headless plan supplies a concrete `physically_reclaimable_bytes` value while the assessment remains unverified, the Rust projection clears that value before IPC, marks the evidence incomplete, and emits `podman-desktop-unverified-physical-reclaim-claim`. A future verified physical-reclaim contract requires an explicit schema and evidence-authority change; it cannot appear by silently forwarding a new headless value. + +The two user-facing safety notices are also part of schema version 1 rather than arbitrary display text. The frontend accepts only those two exact statements in the defined order and count. Any modified, duplicated, reordered, additional, path-bearing, or otherwise noncanonical notice fails closed with `invalid-notices` instead of being rendered. + +The platform field is also schema-bound because it appears in the user interface. Schema version 1 admits only the Tauri desktop targets `linux`, `macos`, and `windows`. Unsupported, path-bearing, machine-specific, or account-specific platform text fails closed with `invalid-platform` rather than becoming visible evidence. + +### 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. 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 and conservative + +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. + +A positive candidate observation itself conservatively requires review in its own domain, even if an upstream assessment accidentally omits the corresponding recommended-action record. Rust derives the image, stopped-container, and volume review booleans from both the action list and the observed candidates. The frontend independently rejects a candidate domain whose required review boolean is false. An extra conservative `true` remains advisory only and never creates mutation authority. + +### 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. The UI never uses color as the only carrier of completeness. Text labels always state whether evidence is complete or partial. + +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. +- Contradictory unverified physical-reclaim claims are removed in Rust before IPC rather than relying on frontend refusal. +- 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. +- Any issue forces partial evidence in Rust, and the frontend refuses contradictory complete-plus-issues payloads. +- Complete exact-image evidence cannot omit or detach its candidate-set commitment. +- Positive candidates cannot be displayed with a false no-review signal in their own domain. +- Arbitrary notice or platform text cannot become a path, machine-name, or account-detail display channel. +- 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. +- Module-level `missing_docs` enforcement and source-level documentation contracts keep the Podman desktop functions 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. +- Notice wording, supported platform identifiers, candidate/fingerprint relations, and review-boundary semantics are schema-bound; changing them requires coordinated Rust/frontend contract review rather than a copy-only UI edit. + +## Verification matrix + +| Invariant | Deterministic evidence | +|---|---| +| No machine names or paths in desktop JSON | Rust serialization tests search for private fixture values | +| Delimiter-free or malformed issue text cannot cross IPC | Rust unit and integration tests expect `podman-evidence-error` | +| Any projected issue forces partial evidence | `podman_desktop_issue_privacy.rs` contradicts upstream completeness and requires false | +| Complete-plus-issues payloads are rejected | TypeScript parser regression expects `inconsistent-evidence-completeness` | +| Complete exact-image evidence requires its fingerprint | TypeScript parser regression expects `inconsistent-image-candidate-fingerprint` | +| A fingerprint cannot exist without exact image records | TypeScript parser regression rejects detached commitments even for partial evidence | +| Observed candidates conservatively require domain review | `podman_desktop_candidate_review_consistency.rs` omits actions and requires all three review booleans | +| Candidate-plus-false-review payloads are rejected | TypeScript parser regressions cover image, stopped-container, and volume domains separately | +| Unverified physical-reclaim claims cannot cross IPC | `podman_desktop_physical_reclaim_claim.rs` requires removal, incomplete evidence, and a stable issue code | +| Arbitrary or duplicated notices cannot reach the UI | TypeScript parser regression requires the exact schema-v1 notice sequence | +| Unsupported or path-bearing platform values cannot reach the UI | TypeScript parser regression admits only `linux`, `macos`, and `windows` | +| Image/container/volume review separation | Rust projection tests and TypeScript view-model tests | +| Invalid fingerprint fails closed | Rust and TypeScript malformed-fingerprint tests | +| Missing observations stay unknown | Rust and TypeScript null-preservation tests | +| Exact Tauri command contract | Rust public-command integration test and 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 | +| Beginner-readable frontend function documentation | Source-level JSDoc regression test checks every production function declaration | +| Beginner-readable Rust function documentation | `missing_docs` plus `podman_desktop_documentation_contract.rs` | + +## Release acceptance + +This slice is release-eligible only after the exact integrated head passes Rust formatting and tests; frontend unit tests and exact coverage; Svelte type checking and production build; security and SAST workflows; current-head review with no unresolved actionable finding; actual repository/governance review policy; and packaging, provenance, and release acceptance. + +## 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/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ad9481876..9e353dc30 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -75,6 +75,10 @@ pub mod naruon_cloud_copy_readiness; pub mod naruon_lineage; /// Path-free ontology organization lineage handoff for Naruon/semantic-data-portal. pub mod organization_lineage; +/// Privacy-safe desktop projection of read-only Podman reclaim evidence. +pub mod podman_desktop; +/// Distinct IPC registration for the privacy-safe Podman evidence contract. +pub mod podman_desktop_bridge; /// Read-only evidence plus exact-identity-bound Podman reclaim execution authority. #[path = "podman_reclaim_public.rs"] pub mod podman_reclaim; @@ -138,6 +142,7 @@ pub fn run() { commands::reason_unknown_extensions, commands::plan_brew_cleanup, commands::inspect_podman_reclaim, + podman_desktop_bridge::inspect_podman_desktop_evidence, commands::execute_podman_dangling_image_prune, commands::judge_brew_cleanup, commands::validate_judge_calibration, @@ -171,4 +176,4 @@ pub fn run() { ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); -} +} \ No newline at end of file diff --git a/src-tauri/src/podman_desktop.rs b/src-tauri/src/podman_desktop.rs new file mode 100644 index 000000000..86574aa4e --- /dev/null +++ b/src-tauri/src/podman_desktop.rs @@ -0,0 +1,545 @@ +//! 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 probe is complete and no projected issue invalidates the evidence. + 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()) +} + +/// Return the bounded kebab-case prefix of an untrusted diagnostic code when it is safe. +fn stable_code_prefix(value: &str) -> Option { + 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'-'); + valid.then(|| code.to_string()) +} + +/// 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 { + stable_code_prefix(value).unwrap_or_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, assessment +/// codes, unverified physical-reclaim claims, or any projected issue fail closed by clearing +/// unsafe data and marking the response incomplete. Positive candidates conservatively force the +/// corresponding review boundary even if an upstream recommended-action record is missing. +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()); + } + + let assessment_status_valid = plan.assessment.status == "unverified"; + let assessment_status = if assessment_status_valid { + plan.assessment.status.clone() + } else { + "unverified".to_string() + }; + let mut assessment_codes_valid = assessment_status_valid; + let mut reason_codes = plan + .assessment + .reason_codes + .iter() + .map(|reason| { + stable_code_prefix(reason).unwrap_or_else(|| { + assessment_codes_valid = false; + "podman-assessment-error".to_string() + }) + }) + .collect::>(); + reason_codes.sort(); + reason_codes.dedup(); + if !assessment_codes_valid { + issue_codes.push("podman-desktop-invalid-assessment-code".to_string()); + } + + let physical_reclaim_claim_valid = plan.assessment.physically_reclaimable_bytes.is_none(); + let physically_reclaimable_bytes = if physical_reclaim_claim_valid { + plan.assessment.physically_reclaimable_bytes + } else { + issue_codes.push("podman-desktop-unverified-physical-reclaim-claim".to_string()); + None + }; + + issue_codes.sort(); + issue_codes.dedup(); + let issues_absent = issue_codes.is_empty(); + + 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), + }; + + let image_review_required = has_action( + &plan, + PodmanRecommendedActionKind::ReviewUnusedImages, + ) || candidates.image_candidate_bytes.is_some_and(|bytes| bytes > 0) + || candidates.unused_image_records.is_some_and(|records| records > 0); + let stopped_container_review_required = has_action( + &plan, + PodmanRecommendedActionKind::ReviewStoppedContainers, + ) || candidates + .stopped_container_candidate_bytes + .is_some_and(|bytes| bytes > 0) + || candidates + .stopped_container_records + .is_some_and(|records| records > 0); + let volume_review_required = has_action( + &plan, + PodmanRecommendedActionKind::ReviewUnusedVolumes, + ) || candidates.volume_candidate_bytes.is_some_and(|bytes| bytes > 0); + + PodmanDesktopEvidence { + schema_kind: PODMAN_DESKTOP_SCHEMA_KIND, + schema_version: 1, + platform: plan.platform, + evidence_complete: plan.evidence_complete + && fingerprint_valid + && assessment_codes_valid + && physical_reclaim_claim_valid + && issues_absent, + elapsed_ms: plan.elapsed_ms, + capacity, + candidates, + review_boundaries: PodmanDesktopReviewBoundaries { + image_review_required, + stopped_container_review_required, + volume_review_required, + }, + 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, + 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. +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), + }), + dangling_prune_approval_phrase: None, + 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.store = None; + plan.system_df = None; + plan.unused_images = None; + plan.evidence_complete = false; + 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/src/podman_desktop_bridge.rs b/src-tauri/src/podman_desktop_bridge.rs new file mode 100644 index 000000000..cec9fbfb5 --- /dev/null +++ b/src-tauri/src/podman_desktop_bridge.rs @@ -0,0 +1,15 @@ +//! Tauri registration boundary for privacy-safe Podman desktop evidence. +//! +//! DiskSage also exposes a separately governed Podman inspection/prune flow. This module keeps +//! the read-only privacy projection on its own command name so the two contracts cannot alias. + +use crate::podman_desktop::PodmanDesktopEvidence; + +/// Return the read-only, privacy-safe Podman evidence projection on a distinct IPC command. +/// +/// The underlying projection performs no mutation. Its schema-bound notices describe this +/// evidence surface; separately governed Podman actions remain outside this command contract. +#[tauri::command] +pub fn inspect_podman_desktop_evidence() -> PodmanDesktopEvidence { + crate::podman_desktop::inspect_podman_reclaim() +} 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..937ee1332 --- /dev/null +++ b/src-tauri/tests/podman_desktop_branch_coverage.rs @@ -0,0 +1,182 @@ +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), + }), + dangling_prune_approval_phrase: None, + 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())); +} + +/// Preserve fail-closed candidate review while distinguishing action-approval branches. +#[test] +fn observed_candidates_force_review_even_without_matching_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); + + // The stopped-container path is satisfied by a matching approved action. Image and volume + // deliberately are not, but their non-zero observed candidates still force review. An + // unrelated approved action cannot substitute for the object-domain boundary. + 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_bridge_command.rs b/src-tauri/tests/podman_desktop_bridge_command.rs new file mode 100644 index 000000000..dfb15db18 --- /dev/null +++ b/src-tauri/tests/podman_desktop_bridge_command.rs @@ -0,0 +1,20 @@ +use disksage_lib::podman_desktop::PODMAN_DESKTOP_SCHEMA_KIND; +use disksage_lib::podman_desktop_bridge::inspect_podman_desktop_evidence; + +/// Exercise the separately registered privacy-safe Podman command through its public Rust boundary. +/// +/// The probe may report partial evidence when Podman is absent or unhealthy, but the bridge must +/// always preserve the schema and must never claim verified host physical reclaimability. +#[test] +fn privacy_safe_podman_bridge_executes_public_boundary() { + let evidence = inspect_podman_desktop_evidence(); + + 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_eq!(evidence.notices.len(), 2); + 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_candidate_review_consistency.rs b/src-tauri/tests/podman_desktop_candidate_review_consistency.rs new file mode 100644 index 000000000..f13e83763 --- /dev/null +++ b/src-tauri/tests/podman_desktop_candidate_review_consistency.rs @@ -0,0 +1,79 @@ +//! Fail-closed review-boundary regressions for observed Podman candidates. +//! +//! Review booleans are decision-support evidence, not mutation authority. They still must not be +//! false when the same projected payload contains a non-zero candidate in that object domain, +//! even if an upstream assessment accidentally omits its recommended-action record. + +use disksage_lib::podman_desktop::redact_podman_reclaim_plan; +use disksage_lib::podman_reclaim::{ + PodmanReclaimAssessment, PodmanReclaimPlan, PodmanStoreEvidence, + PodmanSystemDfCategoryEvidence, PodmanSystemDfEvidence, PodmanUnusedImageEvidence, + PODMAN_RECLAIM_SCHEMA_KIND, +}; + +/// Build one deterministic `podman system df` category observation. +fn category(reclaimable_bytes: u64) -> PodmanSystemDfCategoryEvidence { + PodmanSystemDfCategoryEvidence { + total: 2, + active: 1, + size_bytes: reclaimable_bytes.saturating_add(10), + reclaimable_bytes, + } +} + +/// Build a plan with candidates but deliberately omit every recommended action. +fn candidate_plan_without_actions() -> PodmanReclaimPlan { + PodmanReclaimPlan { + schema_kind: PODMAN_RECLAIM_SCHEMA_KIND, + schema_version: 3, + platform: "macos", + evidence_complete: true, + elapsed_ms: 1, + machine: None, + raw_image: None, + guest_filesystem: None, + store: Some(PodmanStoreEvidence { + graph_root: "/private/graph-root".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), + }), + dangling_prune_approval_phrase: None, + assessment: PodmanReclaimAssessment { + physically_reclaimable_bytes: None, + podman_reported_reclaimable_bytes: Some(300), + raw_allocated_minus_guest_used_bytes: None, + status: "unverified".to_string(), + reason_codes: vec!["host-physical-reclaim-unverified".to_string()], + recommended_actions: vec![], + }, + issues: vec![], + } +} + +/// Candidate observations themselves conservatively require review in their own domain. +#[test] +fn observed_candidates_force_independent_review_boundaries() { + let evidence = redact_podman_reclaim_plan(candidate_plan_without_actions()); + + assert!(evidence.review_boundaries.image_review_required); + assert!(evidence.review_boundaries.stopped_container_review_required); + assert!(evidence.review_boundaries.volume_review_required); +} 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..c4036e704 --- /dev/null +++ b/src-tauri/tests/podman_desktop_issue_privacy.rs @@ -0,0 +1,62 @@ +//! 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 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, + dangling_prune_approval_phrase: 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/")); +} + +/// Any projected issue forces completeness false even if an upstream caller contradicts it. +#[test] +fn projected_issue_codes_fail_completeness_closed() { + let mut plan = plan_with_issue("podman-info-failed:/run/user/501/private.sock"); + plan.evidence_complete = true; + + let evidence = redact_podman_reclaim_plan(plan); + + assert_eq!(evidence.issue_codes, vec!["podman-info-failed"]); + assert!(!evidence.evidence_complete); +} diff --git a/src-tauri/tests/podman_desktop_physical_reclaim_claim.rs b/src-tauri/tests/podman_desktop_physical_reclaim_claim.rs new file mode 100644 index 000000000..742a8bf7b --- /dev/null +++ b/src-tauri/tests/podman_desktop_physical_reclaim_claim.rs @@ -0,0 +1,50 @@ +//! Fail-closed regression for contradictory Podman physical-reclaim evidence. +//! +//! A headless plan with an `unverified` assessment may not publish a concrete host-physical +//! reclaim amount to the desktop. The Rust projection must clear the claim and mark the evidence +//! incomplete before the untrusted IPC boundary, rather than relying on frontend rejection. + +use disksage_lib::podman_desktop::redact_podman_reclaim_plan; +use disksage_lib::podman_reclaim::{ + PodmanReclaimAssessment, PodmanReclaimPlan, PODMAN_RECLAIM_SCHEMA_KIND, +}; + +/// Build the smallest contradictory plan that carries an unverified physical-reclaim claim. +fn contradictory_plan() -> PodmanReclaimPlan { + PodmanReclaimPlan { + schema_kind: PODMAN_RECLAIM_SCHEMA_KIND, + schema_version: 3, + platform: "macos", + evidence_complete: true, + elapsed_ms: 1, + machine: None, + raw_image: None, + guest_filesystem: None, + store: None, + system_df: None, + unused_images: None, + dangling_prune_approval_phrase: None, + assessment: PodmanReclaimAssessment { + physically_reclaimable_bytes: Some(4096), + podman_reported_reclaimable_bytes: None, + raw_allocated_minus_guest_used_bytes: None, + status: "unverified".to_string(), + reason_codes: vec!["host-physical-reclaim-unverified".to_string()], + recommended_actions: vec![], + }, + issues: vec![], + } +} + +/// Contradictory physical-reclaim claims are removed and make the projection incomplete. +#[test] +fn unverified_physical_reclaim_claim_fails_closed_in_rust_projection() { + let evidence = redact_podman_reclaim_plan(contradictory_plan()); + + assert_eq!(evidence.assessment_status, "unverified"); + assert_eq!(evidence.physically_reclaimable_bytes, None); + assert!(!evidence.evidence_complete); + assert!(evidence + .issue_codes + .contains(&"podman-desktop-unverified-physical-reclaim-claim".to_string())); +} diff --git a/src-tauri/tests/podman_desktop_review_regressions.rs b/src-tauri/tests/podman_desktop_review_regressions.rs new file mode 100644 index 000000000..76be9dab3 --- /dev/null +++ b/src-tauri/tests/podman_desktop_review_regressions.rs @@ -0,0 +1,83 @@ +//! Review regressions for the privacy-safe Podman desktop boundary. +//! +//! These tests exercise two fail-closed contracts discovered during exact-head review: assessment +//! text may not cross IPC as unbounded local detail, and the registered Tauri command may not +//! disappear from a `coverage` configuration while `lib.rs` still references it. + +use disksage_lib::podman_desktop::redact_podman_reclaim_plan; +use disksage_lib::podman_reclaim::{ + PodmanReclaimAssessment, PodmanReclaimPlan, PODMAN_RECLAIM_SCHEMA_KIND, +}; + +/// Build the smallest public plan that can carry hostile assessment text into projection. +fn plan_with_assessment(status: &str, reason_codes: &[&str]) -> PodmanReclaimPlan { + PodmanReclaimPlan { + schema_kind: PODMAN_RECLAIM_SCHEMA_KIND, + schema_version: 3, + platform: "macos", + evidence_complete: true, + elapsed_ms: 1, + machine: None, + raw_image: None, + guest_filesystem: None, + store: None, + system_df: None, + unused_images: None, + dangling_prune_approval_phrase: None, + assessment: PodmanReclaimAssessment { + physically_reclaimable_bytes: None, + podman_reported_reclaimable_bytes: None, + raw_allocated_minus_guest_used_bytes: None, + status: status.to_string(), + reason_codes: reason_codes.iter().map(|value| (*value).to_string()).collect(), + recommended_actions: vec![], + }, + issues: vec![], + } +} + +/// Host paths, socket-like text, and duplicate detail never survive assessment projection. +#[test] +fn hostile_assessment_text_is_redacted_and_fails_completeness_closed() { + let evidence = redact_podman_reclaim_plan(plan_with_assessment( + "/Users/alice/private-machine.sock", + &[ + "host-physical-reclaim-unverified:/Users/alice/private-machine.sock", + "/run/user/501/podman.sock", + "host-physical-reclaim-unverified:duplicate-private-detail", + ], + )); + + assert_eq!(evidence.assessment_status, "unverified"); + assert_eq!( + evidence.reason_codes, + vec![ + "host-physical-reclaim-unverified".to_string(), + "podman-assessment-error".to_string(), + ] + ); + assert!(!evidence.evidence_complete); + assert!(evidence + .issue_codes + .contains(&"podman-desktop-invalid-assessment-code".to_string())); + + 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/")); + assert!(!json.contains("/run/user/")); +} + +/// The public command definition and Tauri registration must remain cfg-compatible. +#[test] +fn registered_command_is_not_removed_only_from_coverage_builds() { + let command_source = include_str!("../src/podman_desktop.rs").replace("\r\n", "\n"); + let library_source = include_str!("../src/lib.rs").replace("\r\n", "\n"); + + assert!(library_source.contains( + "podman_desktop_bridge::inspect_podman_desktop_evidence", + )); + assert!(!command_source.contains( + "#[cfg(not(coverage))]\n#[tauri::command]\npub fn inspect_podman_reclaim", + )); +} diff --git a/src/lib/Cleanup.svelte b/src/lib/Cleanup.svelte index 2c18753cc..de074dc82 100644 --- a/src/lib/Cleanup.svelte +++ b/src/lib/Cleanup.svelte @@ -2,6 +2,8 @@ import * as api from "./api"; import { fmtBytes } from "./fmt"; import { verdictBadge } from "./verdictBadge"; + import { podmanEvidenceErrorMessage, podmanPruneErrorMessage } from "./podmanEvidenceError"; + import { podmanRecommendedActionLabel } from "./podmanRecommendedActionLabel"; import { confirm } from "@tauri-apps/plugin-dialog"; import GitWorktreeCleanup from "./GitWorktreeCleanup.svelte"; import BrewCleanup from "./BrewCleanup.svelte"; @@ -54,7 +56,7 @@ try { podmanPlan = await api.inspectPodmanReclaim(); } catch (e) { - podmanError = String(e); + podmanError = podmanEvidenceErrorMessage(e); podmanPlan = null; } finally { podmanBusy = false; @@ -88,7 +90,7 @@ podmanPruneRationale = ""; podmanPlan = await api.inspectPodmanReclaim(); } catch (e) { - podmanPruneError = String(e); + podmanPruneError = podmanPruneErrorMessage(e); } finally { podmanPruneBusy = false; } @@ -274,7 +276,8 @@

Podman VM 저장소

- 게스트·이미지·volume 증거만 읽습니다. prune, 삭제, trim, 중지는 이 화면에서 실행하지 않습니다. + 상태 조회는 읽기 전용입니다. dangling 이미지 정리는 정확한 승인 문구와 사유를 입력한 뒤에만 실행됩니다. + volume·컨테이너·tagged image·VM·TRIM·raw image는 이 흐름에서 변경하지 않으며, 실제 물리 회수량은 전후 호스트 관측 없이는 확정하지 않습니다.

+ + + {#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)}
+
가상 디스크 논리 크기
{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 데이터 할당 공간
{optionalBytes(evidence.capacity.graph_root_allocated_bytes)}
+
Podman 데이터 사용 공간
{optionalBytes(evidence.capacity.graph_root_used_bytes)}
+
가상 디스크와 환경 차이
{optionalBytes(evidence.raw_allocated_minus_guest_used_bytes)}
+
확인된 정리 후보 합계
{optionalBytes(evidence.podman_reported_reclaimable_bytes)}
+
+ +

항목별 확인

+
+
+
이미지

{view.image_review_label}

+
확인된 정리 후보
{optionalBytes(evidence.candidates.image_candidate_bytes)}
사용되지 않는 항목
{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)}
+
+
+ + {#if hasActionableReasonCodes(evidence)} +

추가 확인이 필요한 항목이 있습니다. 상태를 다시 확인한 뒤 정리 여부를 판단하십시오.

+ {/if} + {#if view.has_issues} + + {/if} + {/if} + + + diff --git a/src/lib/podmanCleanupPrivacyContract.test.ts b/src/lib/podmanCleanupPrivacyContract.test.ts new file mode 100644 index 000000000..e1f850043 --- /dev/null +++ b/src/lib/podmanCleanupPrivacyContract.test.ts @@ -0,0 +1,37 @@ +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); + +/** Read the production Cleanup component so privacy-safe error wiring cannot silently regress. */ +function cleanupSource(): string { + return readFileSync(resolve(repositoryRoot, "src/lib/Cleanup.svelte"), "utf8"); +} + +describe("Cleanup Podman privacy and authority copy", () => { + it("routes inspection and prune failures through their privacy-safe customer mappers", () => { + const source = cleanupSource(); + expect(source).toContain( + 'import { podmanEvidenceErrorMessage, podmanPruneErrorMessage } from "./podmanEvidenceError";', + ); + expect(source).toContain("podmanError = podmanEvidenceErrorMessage(e);"); + expect(source).toContain("podmanPruneError = podmanPruneErrorMessage(e);"); + expect(source).not.toContain("podmanError = String(e);"); + expect(source).not.toContain("podmanPruneError = String(e);"); + }); + + it("does not claim the Cleanup screen is mutation-free when dangling-image prune is present", () => { + const source = cleanupSource(); + expect(source).toContain("dangling 이미지 정리는 정확한 승인 문구와 사유를 입력한 뒤에만 실행됩니다."); + expect(source).not.toContain("prune, 삭제, trim, 중지는 이 화면에서 실행하지 않습니다."); + }); + + it("keeps the exact destructive approval phrase out of the input placeholder", () => { + const source = cleanupSource(); + expect(source).not.toContain('placeholder={podmanPlan.dangling_prune_approval_phrase}'); + expect(source).toContain('필요한 승인 문구: {podmanPlan.dangling_prune_approval_phrase}'); + expect(source).toContain('placeholder="승인 문구를 직접 입력하십시오"'); + }); +}); 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..cd3b34d5d --- /dev/null +++ b/src/lib/podmanEvidence.error.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; + +import { podmanEvidenceErrorMessage, podmanPruneErrorMessage } from "./podmanEvidenceError"; + +describe("podmanEvidenceErrorMessage", () => { + it.each([ + new Error("podman failed at /Users/alice/.local/share/containers"), + "transport error: private-machine.sock", + "toString", + "constructor", + "__proto__", + { 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 저장 공간을 확인하지 못했습니다. 상태를 확인한 뒤 다시 시도하십시오."); + expect(message).not.toContain("alice"); + expect(message).not.toContain("private-machine"); + expect(message).not.toContain("account-local-context"); + expect(message).not.toContain("podman-evidence"); + expect(message).toContain("다시 시도하십시오"); + }); + + it.each([ + "podman-prune-confirmation-mismatch", + "podman-prune-candidate-set-changed", + "podman-prune-machine-not-running", + ])("does not surface prune-only recovery guidance during read-only inspection for %s", (reason) => { + expect(podmanEvidenceErrorMessage(reason)).toBe( + "Podman 저장 공간을 확인하지 못했습니다. 상태를 확인한 뒤 다시 시도하십시오.", + ); + }); +}); + +describe("podmanPruneErrorMessage", () => { + it.each([ + [ + "podman-prune-confirmation-mismatch", + "승인 문구가 최신 정리 계획과 일치하지 않습니다. 현재 계획을 다시 확인한 뒤 승인 문구를 다시 입력하십시오.", + ], + [ + "podman-prune-candidate-set-changed", + "정리 후보가 변경되었습니다. 최신 Podman 상태를 다시 확인하고 새 계획을 검토하십시오.", + ], + [ + "podman-prune-machine-not-running", + "Podman 머신이 실행 중이 아닙니다. 머신 상태를 확인한 뒤 정리 계획을 다시 불러오십시오.", + ], + ])("maps stable prune code %s to bounded recovery guidance", (reason, expected) => { + expect(podmanPruneErrorMessage(reason)).toBe(expected); + }); + + it.each([ + new Error("podman-prune-candidate-set-changed: /Users/alice/private"), + "socket private-machine.sock failed", + "toString", + "constructor", + "__proto__", + { reason: "podman-prune-confirmation-mismatch", secret: "account-local-context" }, + null, + undefined, + ])("does not reflect untrusted prune failure detail %#", (reason) => { + const message = podmanPruneErrorMessage(reason); + expect(message).toBe( + "Podman 정리를 완료하지 못했습니다. 최신 상태를 다시 확인한 뒤 정리 계획을 재검토하십시오.", + ); + expect(message).not.toContain("alice"); + expect(message).not.toContain("private-machine"); + expect(message).not.toContain("account-local-context"); + expect(message).not.toContain("/Users/"); + expect(message.length).toBeLessThanOrEqual(120); + }); +}); diff --git a/src/lib/podmanEvidence.test.ts b/src/lib/podmanEvidence.test.ts new file mode 100644 index 000000000..cd9106534 --- /dev/null +++ b/src/lib/podmanEvidence.test.ts @@ -0,0 +1,232 @@ +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, + hasActionableReasonCodes, + loadPodmanEvidence, + parsePodmanDesktopEvidence, + podmanEvidenceView, +} 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: [], + notices: [ + "Podman-reported logical candidates are not verified host physical reclaimability.", + "This desktop surface exposes no prune, remove, machine lifecycle, TRIM, or raw-image mutation command.", + ], + }; +} + +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)); + }); + + it("preserves unknown observations as null", () => { + const value = cloneFixture(); + for (const key of Object.keys(value.capacity)) value.capacity[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); + }); + + 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("rejects unsupported or path-bearing platform values", () => { + const pathBearing = cloneFixture(); + pathBearing.platform = "/Users/alice/private-machine"; + expect(() => parsePodmanDesktopEvidence(pathBearing)).toThrow("invalid-platform"); + + const unsupported = cloneFixture(); + unsupported.platform = "plan9"; + expect(() => parsePodmanDesktopEvidence(unsupported)).toThrow("invalid-platform"); + }); + + it("rejects complete evidence that also carries issue codes", () => { + const inconsistent = cloneFixture(); + inconsistent.issue_codes = ["partial-evidence"]; + expect(() => parsePodmanDesktopEvidence(inconsistent)).toThrow( + "inconsistent-evidence-completeness", + ); + }); + + it("rejects malformed candidate fingerprints", () => { + const malformed = cloneFixture(); + malformed.candidates.image_candidate_set_sha256 = "BAD"; + expect(() => parsePodmanDesktopEvidence(malformed)).toThrow( + "invalid-image-candidate-set-sha256", + ); + }); + + it("rejects complete exact-image evidence without its candidate-set fingerprint", () => { + const inconsistent = cloneFixture(); + inconsistent.candidates.image_candidate_set_sha256 = null; + expect(() => parsePodmanDesktopEvidence(inconsistent)).toThrow( + "inconsistent-image-candidate-fingerprint", + ); + }); + + it("rejects fingerprints that have no exact image-record observation", () => { + const inconsistent = cloneFixture(); + inconsistent.evidence_complete = false; + inconsistent.issue_codes = ["partial-evidence"]; + inconsistent.candidates.unused_image_records = null; + expect(() => parsePodmanDesktopEvidence(inconsistent)).toThrow( + "inconsistent-image-candidate-fingerprint", + ); + }); + + it("rejects candidate domains whose mandatory review boundary is false", () => { + const image = cloneFixture(); + image.review_boundaries.image_review_required = false; + expect(() => parsePodmanDesktopEvidence(image)).toThrow( + "inconsistent-image-review-boundary", + ); + + const container = cloneFixture(); + container.review_boundaries.stopped_container_review_required = false; + expect(() => parsePodmanDesktopEvidence(container)).toThrow( + "inconsistent-stopped-container-review-boundary", + ); + + const volume = cloneFixture(); + volume.review_boundaries.volume_review_required = false; + expect(() => parsePodmanDesktopEvidence(volume)).toThrow( + "inconsistent-volume-review-boundary", + ); + }); + + it("rejects physical reclaim claims while the only supported assessment is unverified", () => { + const inconsistent = cloneFixture(); + inconsistent.physically_reclaimable_bytes = 1; + expect(() => parsePodmanDesktopEvidence(inconsistent)).toThrow( + "unverified-physical-reclaim-claim", + ); + }); + + it("rejects path-bearing or noncanonical notices before the UI boundary", () => { + const pathBearing = cloneFixture(); + pathBearing.notices = ["Podman socket /Users/alice/.local/share/podman.sock failed"]; + expect(() => parsePodmanDesktopEvidence(pathBearing)).toThrow("invalid-notices"); + + const duplicate = cloneFixture(); + duplicate.notices = [duplicate.notices[0], duplicate.notices[0]]; + expect(() => parsePodmanDesktopEvidence(duplicate)).toThrow("invalid-notices"); + }); +}); + +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_desktop_evidence"); + }); +}); + +describe("podmanEvidenceView", () => { + it("labels complete evidence while keeping physical reclaim unknown", () => { + const evidence = parsePodmanDesktopEvidence(fixture()); + expect(podmanEvidenceView(evidence)).toMatchObject({ + completeness_label: "확인 완료", + completeness_tone: "complete", + physical_reclaim_label: "검증되지 않음", + image_review_label: "이미지 별도 검토 필요", + container_review_label: "중지된 작업 별도 확인 필요", + volume_review_label: "저장 공간 별도 확인 필요", + has_issues: false, + }); + }); + + it("labels partial evidence without candidate review signals and surfaces issue presence", () => { + const value = cloneFixture(); + value.evidence_complete = false; + value.issue_codes = ["partial-evidence"]; + value.candidates.image_candidate_bytes = 0; + value.candidates.stopped_container_candidate_bytes = 0; + value.candidates.volume_candidate_bytes = 0; + value.candidates.unused_image_records = 0; + value.candidates.stopped_container_records = 0; + value.review_boundaries.image_review_required = false; + value.review_boundaries.stopped_container_review_required = false; + value.review_boundaries.volume_review_required = false; + + const evidence = parsePodmanDesktopEvidence(value); + expect(podmanEvidenceView(evidence)).toMatchObject({ + completeness_label: "확인 불완전", + completeness_tone: "partial", + image_review_label: "이미지 검토 신호 없음", + container_review_label: "중지된 작업 확인 사항 없음", + volume_review_label: "저장 공간 확인 사항 없음", + has_issues: true, + }); + }); + + it("does not flag the standing physical-reclaim notice as an action", () => { + const evidence = parsePodmanDesktopEvidence(fixture()); + expect(hasActionableReasonCodes(evidence)).toBe(false); + evidence.reason_codes.push("podman-api-evidence-missing"); + expect(hasActionableReasonCodes(evidence)).toBe(true); + }); +}); diff --git a/src/lib/podmanEvidence.ts b/src/lib/podmanEvidence.ts new file mode 100644 index 000000000..b2e31cd95 --- /dev/null +++ b/src/lib/podmanEvidence.ts @@ -0,0 +1,386 @@ +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"; + +/** Exact privacy-safe notices emitted by schema version 1. */ +const PODMAN_DESKTOP_NOTICES = [ + "Podman-reported logical candidates are not verified host physical reclaimability.", + "This desktop surface exposes no prune, remove, machine lifecycle, TRIM, or raw-image mutation command.", +] as const; + +/** Desktop operating-system identifiers supported by the Tauri application. */ +export type PodmanDesktopPlatform = "linux" | "macos" | "windows"; + +/** 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: PodmanDesktopPlatform; + 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; +} + +/** Return whether the evidence contains a reason that requires a fresh customer review. */ +export function hasActionableReasonCodes( + evidence: Pick, +): boolean { + return evidence.reason_codes.some((code) => code !== "host-physical-reclaim-unverified"); +} + +type InvokeFunction = (command: string) => Promise; +type JsonRecord = Record; + +/** Require a plain JSON object and reject arrays, null, and primitive values. */ +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. */ +function stringValue(value: unknown, label: string): string { + if (typeof value !== "string") throw new Error(`invalid-${label}`); + return value; +} + +/** Require one supported desktop operating-system identifier. */ +function platformValue(value: unknown): PodmanDesktopPlatform { + if (value !== "linux" && value !== "macos" && value !== "windows") { + throw new Error("invalid-platform"); + } + return value; +} + +/** Require a boolean value from an untrusted response field. */ +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. */ +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 unavailable observation as null or validate its unsigned value. */ +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. */ +function stringArray(value: unknown, label: string): string[] { + if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) { + throw new Error(`invalid-${label}`); + } + return [...value]; +} + +/** Require the exact schema-versioned notices rather than rendering arbitrary local text. */ +function canonicalNotices(value: unknown): string[] { + const notices = stringArray(value, "notices"); + if ( + notices.length !== PODMAN_DESKTOP_NOTICES.length || + notices.some((notice, index) => notice !== PODMAN_DESKTOP_NOTICES[index]) + ) { + throw new Error("invalid-notices"); + } + return [...PODMAN_DESKTOP_NOTICES]; +} + +/** Return true only for a bounded lowercase kebab-case code safe across IPC. */ +function isStableCode(value: unknown): value is string { + return typeof value === "string" && /^[a-z][a-z0-9-]{0,95}$/.test(value); +} + +/** Require a duplicate-free array of bounded lowercase kebab-case codes. */ +function stableCodeArray(value: unknown, label: string): string[] { + if ( + !Array.isArray(value) || + !value.every(isStableCode) || + new Set(value).size !== value.length + ) { + throw new Error(`invalid-${label}`); + } + return [...value]; +} + +/** Require the only assessment status currently emitted by the Rust authority. */ +function assessmentStatus(value: unknown): string { + if (value !== "unverified") throw new Error("invalid-assessment-status"); + return value; +} + +/** Validate an optional lowercase SHA-256 commitment. */ +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; +} + +/** Return true only when a nullable observation contains a positive value. */ +function hasPositiveObservation(value: number | null): boolean { + return value !== null && value > 0; +} + +/** Parse the capacity section while preserving every measurement as a distinct concept. */ +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 physical savings. */ +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. */ +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", + ), + }; +} + +/** + * Reject semantic contradictions between candidate evidence, fingerprints, and review domains. + * + * Complete exact-image evidence must include both an exact-record count and its set commitment. + * Partial evidence may omit an invalid fingerprint while retaining safe counts, but a fingerprint + * may never appear without the exact-record observation it commits to. Any positive candidate in + * a domain conservatively requires its own review boundary; a review signal never authorizes a + * different domain and remains advisory only. + */ +function validateCandidateConsistency( + candidates: PodmanDesktopCandidateEvidence, + reviewBoundaries: PodmanDesktopReviewBoundaries, + evidenceComplete: boolean, +): void { + const hasExactImageRecords = candidates.unused_image_records !== null; + const hasImageFingerprint = candidates.image_candidate_set_sha256 !== null; + if ( + (hasImageFingerprint && !hasExactImageRecords) || + (evidenceComplete && (!hasExactImageRecords || !hasImageFingerprint)) + ) { + throw new Error("inconsistent-image-candidate-fingerprint"); + } + + if ( + (hasPositiveObservation(candidates.image_candidate_bytes) || + hasPositiveObservation(candidates.unused_image_records)) && + !reviewBoundaries.image_review_required + ) { + throw new Error("inconsistent-image-review-boundary"); + } + if ( + (hasPositiveObservation(candidates.stopped_container_candidate_bytes) || + hasPositiveObservation(candidates.stopped_container_records)) && + !reviewBoundaries.stopped_container_review_required + ) { + throw new Error("inconsistent-stopped-container-review-boundary"); + } + if ( + hasPositiveObservation(candidates.volume_candidate_bytes) && + !reviewBoundaries.volume_review_required + ) { + throw new Error("inconsistent-volume-review-boundary"); + } +} + +/** Parse the Rust response and fail closed on schema, type, range, or semantic 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"); + } + const platform = platformValue(evidence.platform); + const evidence_complete = booleanValue(evidence.evidence_complete, "evidence-complete"); + const issue_codes = stableCodeArray(evidence.issue_codes, "issue-codes"); + if (evidence_complete && issue_codes.length > 0) { + throw new Error("inconsistent-evidence-completeness"); + } + const assessment_status = assessmentStatus(evidence.assessment_status); + const physically_reclaimable_bytes = optionalUnsignedInteger( + evidence.physically_reclaimable_bytes, + "physically-reclaimable-bytes", + ); + if (assessment_status === "unverified" && physically_reclaimable_bytes !== null) { + throw new Error("unverified-physical-reclaim-claim"); + } + const candidates = parseCandidates(evidence.candidates); + const review_boundaries = parseReviewBoundaries(evidence.review_boundaries); + validateCandidateConsistency(candidates, review_boundaries, evidence_complete); + + return { + schema_kind: PODMAN_DESKTOP_SCHEMA_KIND, + schema_version: 1, + platform, + evidence_complete, + elapsed_ms: unsignedInteger(evidence.elapsed_ms, "elapsed-ms"), + capacity: parseCapacity(evidence.capacity), + candidates, + review_boundaries, + 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, + reason_codes: stableCodeArray(evidence.reason_codes, "reason-codes"), + issue_codes, + notices: canonicalNotices(evidence.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_desktop_evidence"), + ); +} + +/** 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: "검증되지 않음", + 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/podmanEvidenceAssessmentPrivacy.test.ts b/src/lib/podmanEvidenceAssessmentPrivacy.test.ts new file mode 100644 index 000000000..6118b6d3d --- /dev/null +++ b/src/lib/podmanEvidenceAssessmentPrivacy.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; +import { + PODMAN_DESKTOP_SCHEMA_KIND, + parsePodmanDesktopEvidence, +} from "./podmanEvidence"; + +/** Build one otherwise-valid desktop response so each test changes only assessment text. */ +function fixture(): Record { + return { + schema_kind: PODMAN_DESKTOP_SCHEMA_KIND, + schema_version: 1, + platform: "macos", + evidence_complete: false, + elapsed_ms: 1, + capacity: { + configured_disk_bytes: null, + raw_logical_bytes: null, + host_allocated_bytes: null, + guest_total_bytes: null, + guest_used_bytes: null, + guest_available_bytes: null, + graph_root_allocated_bytes: null, + graph_root_used_bytes: null, + }, + candidates: { + image_candidate_bytes: null, + stopped_container_candidate_bytes: null, + volume_candidate_bytes: null, + unused_image_records: null, + stopped_container_records: null, + image_candidate_set_sha256: null, + }, + review_boundaries: { + image_review_required: false, + stopped_container_review_required: false, + volume_review_required: false, + }, + physically_reclaimable_bytes: null, + podman_reported_reclaimable_bytes: null, + raw_allocated_minus_guest_used_bytes: null, + assessment_status: "unverified", + reason_codes: ["host-physical-reclaim-unverified"], + issue_codes: [], + notices: [], + }; +} + +describe("Podman assessment privacy validation", () => { + it("rejects path-bearing or unsupported assessment status", () => { + for (const status of [ + "/Users/alice/private-machine.sock", + "UNVERIFIED", + "unverified:private-detail", + "unknown", + ]) { + const value = fixture(); + value.assessment_status = status; + expect(() => parsePodmanDesktopEvidence(value)).toThrow("invalid-assessment-status"); + } + }); + + it("rejects path-bearing, malformed, oversized, or duplicate reason codes", () => { + const invalidReasonSets = [ + ["/run/user/501/podman.sock"], + ["UPPERCASE"], + ["unsafe_code"], + [`a${"b".repeat(96)}`], + ["partial-evidence", "partial-evidence"], + ]; + + for (const reasonCodes of invalidReasonSets) { + const value = fixture(); + value.reason_codes = reasonCodes; + expect(() => parsePodmanDesktopEvidence(value)).toThrow("invalid-reason-codes"); + } + }); + + it("rejects malformed issue codes at the untrusted Tauri boundary", () => { + const value = fixture(); + value.issue_codes = ["podman-info-failed:/Users/alice/private.sock"]; + expect(() => parsePodmanDesktopEvidence(value)).toThrow("invalid-issue-codes"); + }); +}); diff --git a/src/lib/podmanEvidenceCoverageContract.test.ts b/src/lib/podmanEvidenceCoverageContract.test.ts new file mode 100644 index 000000000..0fc1db32d --- /dev/null +++ b/src/lib/podmanEvidenceCoverageContract.test.ts @@ -0,0 +1,24 @@ +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); + +/** Read the source-controlled Vitest configuration. */ +function readVitestConfig(): string { + return readFileSync(resolve(repositoryRoot, "vitest.config.ts"), "utf8"); +} + +describe("Podman desktop coverage contract", () => { + it("keeps both Podman frontend production modules inside the exact 100% coverage gate", () => { + const config = readVitestConfig(); + + expect(config).toContain('"src/lib/podmanEvidence.ts"'); + expect(config).toContain('"src/lib/podmanEvidenceError.ts"'); + expect(config).toContain("statements: 100"); + expect(config).toContain("branches: 100"); + expect(config).toContain("functions: 100"); + expect(config).toContain("lines: 100"); + }); +}); diff --git a/src/lib/podmanEvidenceCustomerCopyContract.test.ts b/src/lib/podmanEvidenceCustomerCopyContract.test.ts new file mode 100644 index 000000000..51e680291 --- /dev/null +++ b/src/lib/podmanEvidenceCustomerCopyContract.test.ts @@ -0,0 +1,24 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const source = readFileSync(new URL("./PodmanEvidence.svelte", import.meta.url), "utf8"); +const scriptEnd = source.indexOf(""); +const styleStart = source.indexOf(" + \ No newline at end of file diff --git a/vitest.config.ts b/vitest.config.ts index 198e3dcb8..9aacfb223 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", + "src/lib/podmanRecommendedActionLabel.ts", ], reporter: ["text", "json", "json-summary"], - // ponytail: 위 include 5개 순수 로직 파일은 헤드리스로 완전 검증 가능하므로 - // 네 지표 모두 100%로 고정한다. 이 게이트는 scope를 넓히지 않는다 — - // Svelte 컴포넌트는 여전히 cargo test + 수동 체크리스트로 검증한다. + // ponytail: 위 include의 헤드리스 순수 로직/API 계약 파일은 완전 검증 가능하므로 + // 네 지표 모두 100%로 고정한다. Svelte 컴포넌트의 렌더링은 build와 접근성 계약으로 검증한다. thresholds: { statements: 100, branches: 100, @@ -26,4 +28,4 @@ export default defineConfig({ }, }, }, -}); +}); \ No newline at end of file