diff --git a/crates/degu/src/commands/clean/preview.rs b/crates/degu/src/commands/clean/preview.rs index 3e68cf8..a63a2cb 100644 --- a/crates/degu/src/commands/clean/preview.rs +++ b/crates/degu/src/commands/clean/preview.rs @@ -418,13 +418,20 @@ fn assess_path(path: &Path) -> PreviewStagingStatus { ); } }; - match crate::lifecycle::feasibility::probe(&canonical_parent, basename) { + // The preview reads no trash, so it asks of the first sequence. + match crate::lifecycle::feasibility::probe(&canonical_parent, basename, 1) { Err(ProbeFailure::ParentUnavailable(error)) => unavailable( "source_parent_unavailable", "race_or_io", &format!("failed to hold sealed staging source parent: {error}"), ), Err(ProbeFailure::Certification(error)) => certification_unavailable(error), + Err(ProbeFailure::NameTooLong) => PreviewStagingStatus::Blocked { + kind: "trash_name_too_long", + category: "resource_limit", + relative_path: None, + reason: crate::lifecycle::feasibility::NAME_TOO_LONG.to_owned(), + }, Err(ProbeFailure::Assessment(error)) => assessment_failure_status(error), Ok(HeldTreePolicyAssessmentOutcome::TreePolicyAssessed { tree, .. }) => { PreviewStagingStatus::TreePolicyAssessed { diff --git a/crates/degu/src/lifecycle/stage/feasibility.rs b/crates/degu/src/lifecycle/stage/feasibility.rs index efee9a7..ae33f4f 100644 --- a/crates/degu/src/lifecycle/stage/feasibility.rs +++ b/crates/degu/src/lifecycle/stage/feasibility.rs @@ -21,7 +21,8 @@ use degu_core::backend::{ CertificationError, HeldTreeAssessmentFailure, HeldTreePolicyAssessmentOutcome, assess_held_tree_policy_metadata, certify_held_fd, }; -use rustix::fs::{Mode, OFlags}; +use rustix::fd::OwnedFd; +use rustix::fs::{AtFlags, Mode, OFlags}; /// How degu opens a directory it intends to hold. /// @@ -35,31 +36,61 @@ pub(crate) const OPEN_DIRECTORY: OFlags = OFlags::RDONLY /// Why the probe could not answer. /// -/// Three kinds, because the readers separate them: a parent that could not be +/// Four kinds, because the readers separate them: a parent that could not be /// held is a race or an I/O fault, a certification refusal names a backend the -/// staging path will not use, and an assessment failure carries the tree policy -/// that withheld the answer. +/// staging path will not use, a name the trash cannot hold is refused whatever +/// the tree holds, and an assessment failure carries the tree policy that +/// withheld the answer. pub(crate) enum ProbeFailure { ParentUnavailable(rustix::io::Errno), Certification(CertificationError), + NameTooLong, Assessment(HeldTreeAssessmentFailure), } -/// Look at one already-canonical parent and the basename beneath it. +/// Why a source the trash cannot hold under its name is refused. +pub(crate) const NAME_TOO_LONG: &str = + "the source's name is too long for the trash once a reservation's sequence is put before it"; + +/// Look at one already-canonical parent and the basename beneath it, which a +/// reservation of `sequence` would name in the trash. /// /// The caller canonicalizes: the preview resolves a path the reader typed, and /// production already holds a canonical source, so resolving again here would /// re-introduce a second answer to a question one of them has already settled. +/// Production counts the sequence the reservation will take; the preview reads +/// no trash and asks of the first, the shortest. pub(crate) fn probe( canonical_parent: &Path, basename: &OsStr, + sequence: u64, ) -> Result { let source_parent = rustix::fs::open(canonical_parent, OPEN_DIRECTORY, Mode::empty()) .map_err(ProbeFailure::ParentUnavailable)?; + let name_fits = trash_holds(&source_parent, basename, sequence); let evidence = certify_held_fd(source_parent).map_err(ProbeFailure::Certification)?; + if !name_fits { + return Err(ProbeFailure::NameTooLong); + } assess_held_tree_policy_metadata(evidence, basename).map_err(ProbeFailure::Assessment) } +/// Whether the trash can hold `basename` under the name a reservation of +/// `sequence` gives it. +/// +/// The trash is on the source's mount, so the source parent can answer, and +/// the reservation's own first step, a no-follow lookup of that name, is how. +/// The filesystem refuses a name it could never hold by its own measure, which +/// on APFS is not a byte count. Any other answer is left to the reservation. +fn trash_holds(source_parent: &OwnedFd, basename: &OsStr, sequence: u64) -> bool { + let name = + super::production::destination_name(&super::production::claim_name(sequence), basename); + !matches!( + rustix::fs::statat(source_parent, &name, AtFlags::SYMLINK_NOFOLLOW), + Err(rustix::io::Errno::NAMETOOLONG) + ) +} + /// The narrowest and the widest shape a staging of one finding can take. /// /// Two lengths are only known once staging runs, so they are bounded. A @@ -149,7 +180,7 @@ mod tests { std::os::unix::fs::symlink(&real, &link).expect("a symlinked parent"); // Through the link the open must refuse rather than land on `real`. - let refused = probe(&link, std::ffi::OsStr::new("cache")); + let refused = probe(&link, std::ffi::OsStr::new("cache"), 1); assert!( matches!(refused, Err(ProbeFailure::ParentUnavailable(_))), "a symlinked parent was followed" @@ -157,10 +188,29 @@ mod tests { // The same directory reached directly gets past the open, so the refusal // above is the flag and not the fixture. - let direct = probe(&real, std::ffi::OsStr::new("cache")); + let direct = probe(&real, std::ffi::OsStr::new("cache"), 1); assert!( !matches!(direct, Err(ProbeFailure::ParentUnavailable(_))), "the real parent could not be held either" ); } + + /// The filesystem measures a name, not this probe. APFS does not count + /// bytes, so 250 three-byte characters fit behind a sequence that 251 do + /// not, though both are far past 255 bytes. + #[cfg(target_os = "macos")] + #[test] + fn apfs_measures_names() { + let root = tempfile::tempdir().expect("a directory"); + let parent = root.path().canonicalize().expect("a canonical parent"); + for (count, fits) in [(250, true), (251, false)] { + let name = "\u{4e2d}".repeat(count); + std::fs::create_dir(parent.join(&name)).expect("a source APFS holds"); + let refused = matches!( + probe(&parent, OsStr::new(&name), 1), + Err(ProbeFailure::NameTooLong) + ); + assert_eq!(refused, !fits, "{count} characters"); + } + } } diff --git a/crates/degu/src/lifecycle/stage/production.rs b/crates/degu/src/lifecycle/stage/production.rs index 73418fd..554d771 100644 --- a/crates/degu/src/lifecycle/stage/production.rs +++ b/crates/degu/src/lifecycle/stage/production.rs @@ -1,4 +1,5 @@ -use std::ffi::OsString; +use std::collections::HashMap; +use std::ffi::{OsStr, OsString}; use std::io; use std::path::{Path, PathBuf}; @@ -63,11 +64,14 @@ pub(super) fn batch_preflight( ctx: &DetectCtx, plan: &CapturedCleanPlan, ) -> Option> { + // How many items before this one reserve in each trash root, so each is + // asked about the sequence it will take once they have. + let mut ahead = HashMap::new(); let first_failure = plan.items_with_identities() .enumerate() .find_map(|(index, (finding, identity))| { - preflight_item(ctx, finding, identity) + preflight_item(ctx, finding, identity, &mut ahead) .and_then(require_batch_assessment) .err() .map(|reason| (index, finding.path().to_path_buf(), reason)) @@ -118,16 +122,21 @@ fn preflight_item( ctx: &DetectCtx, finding: &Finding, identity: &EntryIdentity, + ahead: &mut HashMap, ) -> Result { // Match ordinary production's primary-failure order exactly. In particular, // a pathname policy failure must not be masked by a held-tree deferral or // assessment error that ordinary `execute` would never reach first. let policy = preflight_policy(ctx, finding, identity)?; - preflight_tree_policy(&policy.canonical_source) + let before = ahead.entry(policy.trash_root.clone()).or_insert(0); + let sequence = planned_sequence(&policy.trash_root, *before); + *before += 1; + preflight_tree_policy(&policy.canonical_source, sequence) } fn preflight_tree_policy( canonical_source: &Path, + sequence: u64, ) -> Result { let canonical_parent = canonical_source .parent() @@ -135,14 +144,17 @@ fn preflight_tree_policy( let root_basename = canonical_source .file_name() .ok_or_else(|| "sealed staging source has no basename".to_string())?; - super::feasibility::probe(canonical_parent, root_basename).map_err(|failure| match failure { - ProbeFailure::ParentUnavailable(error) => { - format!("failed to hold sealed staging source parent: {error}") - } - ProbeFailure::Certification(error) => { - format!("sealed staging source-parent certification failed: {error:?}") + super::feasibility::probe(canonical_parent, root_basename, sequence).map_err(|failure| { + match failure { + ProbeFailure::ParentUnavailable(error) => { + format!("failed to hold sealed staging source parent: {error}") + } + ProbeFailure::Certification(error) => { + format!("sealed staging source-parent certification failed: {error:?}") + } + ProbeFailure::NameTooLong => super::feasibility::NAME_TOO_LONG.to_owned(), + ProbeFailure::Assessment(error) => error.to_string(), } - ProbeFailure::Assessment(error) => error.to_string(), }) } @@ -154,6 +166,20 @@ struct HeldReservation { claim_identity: ObjectIdentity, } +/// The claim a reservation of `sequence` takes in `.claims`. +pub(super) fn claim_name(sequence: u64) -> OsString { + OsString::from(format!("{sequence:0width$}", width = RESERVATION_WIDTH)) +} + +/// The name a reservation gives the source in the trash: its claim, a dash, +/// and the source's own name. +pub(super) fn destination_name(claim: &OsStr, source: &OsStr) -> OsString { + let mut name = claim.to_os_string(); + name.push("-"); + name.push(source); + name +} + impl HeldReservation { fn reserve(destination_parent: OwnedFd, source: &Path) -> io::Result<(Self, OsString)> { let claims = rustix::fs::openat( @@ -168,11 +194,8 @@ impl HeldReservation { .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "source has no basename"))?; let mut sequence = next_sequence(&destination_parent)?; for _ in 0..RESERVATION_ATTEMPTS { - let claim_basename = - OsString::from(format!("{sequence:0width$}", width = RESERVATION_WIDTH)); - let mut destination_basename = claim_basename.clone(); - destination_basename.push("-"); - destination_basename.push(source_name); + let claim_basename = claim_name(sequence); + let destination_basename = destination_name(&claim_basename, source_name); match rustix::fs::statat( &destination_parent, &destination_basename, @@ -278,7 +301,8 @@ pub(super) fn execute( Ok(policy) => policy, Err(reason) => return failed(finding, reason, false), }; - if let Err(reason) = preflight_tree_policy(&policy.canonical_source) { + let sequence = planned_sequence(&policy.trash_root, 0); + if let Err(reason) = preflight_tree_policy(&policy.canonical_source, sequence) { return failed(finding, reason, false); } @@ -727,6 +751,54 @@ fn preflight_policy( policy::complete(source, lexical_trash) } +/// The sequence a reservation in `trash_root` takes once `ahead` more have +/// been made there: past every entry's sequence, then past each claim already +/// taken, as [`HeldReservation::reserve`] counts. A trash not made yet starts +/// at the first. It reads and changes nothing. A trash it cannot read is left +/// to the preparation and the reservation that follow to refuse in their own +/// words, and is answered with the first sequence, the shortest. +fn planned_sequence(trash_root: &Path, ahead: u64) -> u64 { + let counted = || -> io::Result { + let (mut sequence, claims) = match open_directory(trash_root) { + Ok(parent) => { + let next = next_sequence(&parent)?; + match rustix::fs::openat(&parent, ".claims", OPEN_DIRECTORY, Mode::empty()) { + Ok(claims) => (next, Some(claims)), + Err(rustix::io::Errno::NOENT) => (next, None), + Err(error) => return Err(error.into()), + } + } + Err(error) if error.kind() == io::ErrorKind::NotFound => (1, None), + Err(error) => return Err(error), + }; + let mut free = 0; + loop { + let taken = match &claims { + Some(claims) => match rustix::fs::statat( + claims, + claim_name(sequence), + AtFlags::SYMLINK_NOFOLLOW, + ) { + Ok(_) => true, + Err(rustix::io::Errno::NOENT) => false, + Err(error) => return Err(error.into()), + }, + None => false, + }; + if !taken { + if free == ahead { + return Ok(sequence); + } + free += 1; + } + sequence = sequence.checked_add(1).ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidData, "trash sequence overflow") + })?; + } + }; + counted().unwrap_or(1) +} + fn next_sequence(destination_parent: &OwnedFd) -> io::Result { let duplicate = rustix::io::dup(destination_parent).map_err(io::Error::from)?; let mut maximum = 0_u64; @@ -788,6 +860,26 @@ mod tests { use super::*; use std::os::unix::fs::PermissionsExt; + /// A reservation's sequence passes every entry's, then each claim already + /// taken, and each reservation ahead of it in the same trash takes one + /// more. A trash not made yet starts at the first. + #[test] + fn planned_sequences() { + let temp = tempfile::tempdir().unwrap(); + let trash = temp.path().join("trash"); + assert_eq!(planned_sequence(&trash, 0), 1); + assert_eq!(planned_sequence(&trash, 2), 3); + std::fs::create_dir_all(trash.join(".claims")).unwrap(); + std::fs::create_dir(trash.join("0005-a")).unwrap(); + std::fs::create_dir(trash.join("9998-b")).unwrap(); + assert_eq!(planned_sequence(&trash, 0), 9999); + assert_eq!(planned_sequence(&trash, 1), 10000); + std::fs::write(trash.join(".claims").join(claim_name(9999)), b"").unwrap(); + std::fs::write(trash.join(".claims").join(claim_name(10001)), b"").unwrap(); + assert_eq!(planned_sequence(&trash, 0), 10000); + assert_eq!(planned_sequence(&trash, 1), 10002); + } + #[test] fn deferred_tree_assessment_is_always_rejected_by_atomic_preflight() { use degu_core::backend::{ @@ -848,7 +940,7 @@ mod tests { // produce a held-tree deferral when assessment is tried first. Atomic // production must nevertheless preserve ordinary pathname policy. let ordinary = preflight_policy(&ctx, &finding, &identity).unwrap_err(); - let atomic = preflight_item(&ctx, &finding, &identity).unwrap_err(); + let atomic = preflight_item(&ctx, &finding, &identity, &mut HashMap::new()).unwrap_err(); std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o700)).unwrap(); assert_eq!(atomic, ordinary); diff --git a/crates/degu/tests/clean/sealed_admission.rs b/crates/degu/tests/clean/sealed_admission.rs index c583a12..84a2175 100644 --- a/crates/degu/tests/clean/sealed_admission.rs +++ b/crates/degu/tests/clean/sealed_admission.rs @@ -898,6 +898,189 @@ fn explicit_path_batch_rejects_before_any_item_mutates() { assert!(external_peer.is_file()); } +/// A reservation names a trash entry by its sequence, at least four digits, a +/// dash and the source's own name. A source the trash cannot hold under that +/// name is blocked in the preview and refused before a clean prepares any +/// trash, alone or in an explicit batch. One byte shorter, it is staged. +#[test] +fn trash_name_limit() { + let Some(fixture) = Fixture::new() else { + return; + }; + let trash = fixture.state.path().join("degu/trash"); + for (length, fits) in [(251, false), (250, true)] { + let cache = fixture + .home + .path() + .join("relocated") + .join("p".repeat(length)); + std::fs::create_dir_all(&cache).unwrap(); + std::fs::write( + cache.join("CACHEDIR.TAG"), + "Signature: 8a477f597d28d172789f06886806bc55\n", + ) + .unwrap(); + std::fs::write(cache.join("wheel.whl"), [0u8; 2048]).unwrap(); + strip_extended_attributes(fixture.home.path()).unwrap(); + crate::common::make_tree_non_shared_writable(fixture.home.path()).unwrap(); + let run = |args: &[&str]| -> serde_json::Value { + let output = fixture + .command(args) + .env("PIP_CACHE_DIR", &cache) + .output() + .unwrap(); + assert_eq!(output.status.success(), fits || args.contains(&"-n")); + serde_json::from_slice(&output.stdout).unwrap_or_else(|_| { + panic!("{}", String::from_utf8_lossy(&output.stderr)); + }) + }; + + let preview = run(&["clean", "-n", "--json"]); + let preflight = &preview["staging_preflight"][0]; + assert_eq!( + preview["planned"].as_array().unwrap().len(), + 1, + "{preview:#}" + ); + if fits { + assert_eq!(preflight["status"], "tree_policy_assessed", "{preview:#}"); + let staged = run(&["clean", "--yes", "--json"]); + assert_eq!(staged["executed"][0]["state"], "staged", "{staged:#}"); + assert!(trash.join(format!("0001-{}", "p".repeat(length))).is_dir()); + assert!(!cache.exists()); + continue; + } + assert_eq!(preflight["status"], "blocked", "{preview:#}"); + assert_eq!(preflight["kind"], "trash_name_too_long", "{preview:#}"); + assert_eq!(preflight["category"], "resource_limit", "{preview:#}"); + for args in [ + &["clean", "--yes", "--json"][..], + &[ + "clean", + "--yes", + "--json", + "--path", + cache.to_str().unwrap(), + ], + ] { + let refused = run(args); + let item = &refused["executed"][0]; + assert_eq!(item["state"], "stage_failed", "{refused:#}"); + assert!( + item["outcome"]["failed"]["reason"] + .as_str() + .unwrap() + .contains("too long for the trash"), + "{refused:#}" + ); + assert!(cache.join("wheel.whl").is_file()); + assert!(!trash.exists(), "a refused name prepared the trash"); + } + } +} + +/// A batch reserves its items one after another, so each takes the trash's +/// next sequence counted past the items ahead of it in the same trash. With +/// the trash at 9998, a short name ahead of a 250-byte one takes 9999 and +/// leaves the 250-byte one 10000, a name 256 bytes long. The batch is refused +/// before either is staged. A clean of every finding stages the short name +/// at 9999 and refuses the 250-byte one at its own preflight, before its +/// reservation, for the sequence it would take. +#[test] +fn batch_sequence_limit() { + let Some(fixture) = Fixture::new() else { + return; + }; + // A first staging makes the trash the way a clean makes it. + assert_output_success(&fixture.run(&["clean", "--yes", "--json"])); + let trash = fixture.state.path().join("degu/trash"); + let filler = trash.join("9998-filler"); + std::fs::create_dir(&filler).unwrap(); + std::fs::set_permissions(&filler, std::fs::Permissions::from_mode(0o700)).unwrap(); + // A plan runs its largest item first, so the short name is the larger. + let short = fixture.home.path().join(".npm"); + std::fs::create_dir_all(&short).unwrap(); + std::fs::write(short.join("content"), [0_u8; 65_536]).unwrap(); + let long = fixture.home.path().join("relocated").join("p".repeat(250)); + std::fs::create_dir_all(&long).unwrap(); + std::fs::write( + long.join("CACHEDIR.TAG"), + "Signature: 8a477f597d28d172789f06886806bc55\n", + ) + .unwrap(); + std::fs::write(long.join("wheel.whl"), [0u8; 2048]).unwrap(); + strip_extended_attributes(fixture.home.path()).unwrap(); + crate::common::make_tree_non_shared_writable(fixture.home.path()).unwrap(); + + let output = fixture + .command(&[ + "clean", + "--yes", + "--json", + "--path", + short.to_str().unwrap(), + "--path", + long.to_str().unwrap(), + ]) + .env("PIP_CACHE_DIR", &long) + .output() + .unwrap(); + let report: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert!(!output.status.success(), "{report:#}"); + let executed = report["executed"].as_array().unwrap(); + let state = |path: &Path| { + let name = path.file_name().unwrap().to_str().unwrap(); + executed + .iter() + .find(|item| { + item["path"] + .as_str() + .unwrap() + .ends_with(&format!("/{name}")) + }) + .unwrap_or_else(|| panic!("{report:#}")) + .clone() + }; + assert_eq!(state(&short)["state"], "not_attempted", "{report:#}"); + let refused = state(&long); + assert_eq!(refused["state"], "stage_failed", "{report:#}"); + assert!( + refused["outcome"]["failed"]["reason"] + .as_str() + .unwrap() + .contains("too long for the trash"), + "{report:#}" + ); + assert!(short.join("content").is_file()); + assert!(long.join("wheel.whl").is_file()); + let entries = std::fs::read_dir(&trash) + .unwrap() + .map(|entry| entry.unwrap().file_name().into_string().unwrap()) + .filter(|name| !name.starts_with('.')) + .count(); + assert_eq!(entries, 2, "a refused batch staged an item"); + + let output = fixture + .command(&["clean", "--yes", "--json"]) + .env("PIP_CACHE_DIR", &long) + .output() + .unwrap(); + let report: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert!(!output.status.success(), "{report:#}"); + assert!(trash.join("9999-.npm").is_dir(), "{report:#}"); + let refused = &report["executed"] + .as_array() + .unwrap() + .iter() + .find(|item| item["state"] == "stage_failed") + .unwrap_or_else(|| panic!("{report:#}"))["outcome"]["failed"]["reason"]; + assert!( + refused.as_str().unwrap().contains("too long for the trash"), + "{report:#}" + ); + assert!(long.join("wheel.whl").is_file()); +} + fn assert_atomic_batch_rejected_without_source_mutation( output: &std::process::Output, eligible: &Path, @@ -987,6 +1170,10 @@ impl Fixture { } fn run(&self, args: &[&str]) -> std::process::Output { + self.command(args).output().unwrap() + } + + fn command(&self, args: &[&str]) -> Command { let mut command = Command::new(assert_cmd::cargo::cargo_bin("degu")); command .env_clear() @@ -997,7 +1184,7 @@ impl Fixture { .env("DEGU_INTEGRATION_TEST_ANCHOR", &self.anchor) // Intentionally omit DEGU_INTEGRATION_TEST_LEGACY_CLEAN. .args(args); - command.output().unwrap() + command } fn assert_preview_assessed(&self) { diff --git a/docs/usage.md b/docs/usage.md index 7f042e9..9d1ac97 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -75,7 +75,7 @@ Preview the **Eligible to clean** plan before staging it in degu's undoable tras degu clean -n ``` -The preview does not modify data, and it is honest about execution: every planned finding passes through the same metadata-only sealed-staging admission assessment that production re-verifies. Findings that sealed staging cannot execute are moved out of the **Eligible to clean** totals into explicit **Blocked by sealed staging preflight** or **Needs execution validation** groups with their reasons, and `--json` reports a `staging_preflight` entry aligned with each planned item. The preview also measures the seal WAL without taking its lease: `--json` reports `wal_room` — its `limit`, what its frames hold (`committed`), what entries already in the trash hold for their own undo or purge (`reserved`), what is left for new staging (`available`), and what the clean's lease would compact away before staging anything (`reclaimable`) — or `unavailable` with the reason it could not be read. Each assessed item's `wal_needed` is what its whole life needs from that room. Items are measured in plan order against what the items before them would leave, and one that does not fit is blocked as `wal_full` with what it `needed` and what was `available`. The preview counts an item's trash name and the cleaning process's id at their longest, and staging measures the same room again under the lease with the real ones: an item the preview admits is admitted unless the trash changed in between or an item before it could not be assessed or sized. An item it blocks may still fit, at shorter lengths, in what compaction gave back, or in room the items before it did not take, since any of them may fail before writing anything and the clean goes on to the next; so the items after it are measured as if it had been staged. An explicit `--path`/`--review` selection is assessed atomically: if any selected item fails preflight, the whole batch is rejected before any source, trash, or WAL mutation and the remaining items report `not_attempted`. Room in the seal WAL is not part of that batch check: staging refuses an item that does not fit when it reaches it, and the items staged before it stay staged. +The preview does not modify data, and it is honest about execution: every planned finding passes through the same metadata-only sealed-staging admission assessment that production re-verifies. Findings that sealed staging cannot execute are moved out of the **Eligible to clean** totals into explicit **Blocked by sealed staging preflight** or **Needs execution validation** groups with their reasons, and `--json` reports a `staging_preflight` entry aligned with each planned item. A source whose name the trash cannot hold once a reservation's sequence and a dash are put before it is blocked as `trash_name_too_long`, and a clean refuses it before preparing the trash. The preview also measures the seal WAL without taking its lease: `--json` reports `wal_room` — its `limit`, what its frames hold (`committed`), what entries already in the trash hold for their own undo or purge (`reserved`), what is left for new staging (`available`), and what the clean's lease would compact away before staging anything (`reclaimable`) — or `unavailable` with the reason it could not be read. Each assessed item's `wal_needed` is what its whole life needs from that room. Items are measured in plan order against what the items before them would leave, and one that does not fit is blocked as `wal_full` with what it `needed` and what was `available`. The preview counts an item's trash name and the cleaning process's id at their longest, and staging measures the same room again under the lease with the real ones: an item the preview admits is admitted unless the trash changed in between or an item before it could not be assessed or sized. An item it blocks may still fit, at shorter lengths, in what compaction gave back, or in room the items before it did not take, since any of them may fail before writing anything and the clean goes on to the next; so the items after it are measured as if it had been staged. An explicit `--path`/`--review` selection is assessed atomically: if any selected item fails preflight, the whole batch is rejected before any source, trash, or WAL mutation and the remaining items report `not_attempted`. Room in the seal WAL is not part of that batch check: staging refuses an item that does not fit when it reaches it, and the items staged before it stay staged. Running `degu clean` without `--purge` stages the current **Eligible to clean** findings; staged entries later expire under the [seven-day purge policy](safety.md#staging-undo-and-purge). Sources outside HOME are supported when degu can create or validate a private trash domain on the same certified local mount; cross-mount copy/delete remains forbidden. The authority anchor and WAL store may be on different mounts from that data domain.