Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion crates/degu/src/commands/clean/preview.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
64 changes: 57 additions & 7 deletions crates/degu/src/lifecycle/stage/feasibility.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand All @@ -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<HeldTreePolicyAssessmentOutcome, ProbeFailure> {
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
Expand Down Expand Up @@ -149,18 +180,37 @@ 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"
);

// 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");
}
}
}
126 changes: 109 additions & 17 deletions crates/degu/src/lifecycle/stage/production.rs
Original file line number Diff line number Diff line change
@@ -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};

Expand Down Expand Up @@ -63,11 +64,14 @@ pub(super) fn batch_preflight(
ctx: &DetectCtx,
plan: &CapturedCleanPlan,
) -> Option<Vec<CleanExecution>> {
// 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))
Expand Down Expand Up @@ -118,31 +122,39 @@ fn preflight_item(
ctx: &DetectCtx,
finding: &Finding,
identity: &EntryIdentity,
ahead: &mut HashMap<PathBuf, u64>,
) -> Result<HeldTreePolicyAssessmentOutcome, String> {
// 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<HeldTreePolicyAssessmentOutcome, String> {
let canonical_parent = canonical_source
.parent()
.ok_or_else(|| "sealed staging source has no parent".to_string())?;
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(),
})
}

Expand All @@ -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(
Expand All @@ -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,
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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<u64> {
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<u64> {
let duplicate = rustix::io::dup(destination_parent).map_err(io::Error::from)?;
let mut maximum = 0_u64;
Expand Down Expand Up @@ -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::{
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading