use crate::filter::TimeFilter;
use crate::preserve::Metadata as _;
use crate::progress::Progress;
use crate::safedir::{self, Dir, FileMeta, Handle};
use crate::walk::{EntryKind, LeafPermit, PermitKind};
use crate::walk_driver::{
DirAction, DirPreResult, EntryCx, ProcessedChildren, WalkVisitor, process_entry,
};
use anyhow::{Context, anyhow};
use std::ffi::OsStr;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tracing::instrument;
fn mode_of(meta: &FileMeta) -> u32 {
meta.permissions().mode() & 0o7777
}
pub type Error = crate::error::OperationError<Summary>;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct OwnerProgram {
pub file: Option<u32>,
pub dir: Option<u32>,
pub symlink: Option<u32>,
}
impl OwnerProgram {
#[must_use]
pub fn for_kind(&self, kind: EntryKind) -> Option<u32> {
match kind {
EntryKind::Dir => self.dir,
EntryKind::Symlink => self.symlink,
EntryKind::File | EntryKind::Special => self.file,
}
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.file.is_none() && self.dir.is_none() && self.symlink.is_none()
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ModeSpec {
Symbolic(Vec<SymbolicClause>),
Octal(u32),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct SymbolicClause {
pub who: u8,
pub op: ModeOp,
pub perms: u8,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ModeOp {
Add,
Remove,
Set,
}
pub(crate) const WHO_U: u8 = 0b001;
pub(crate) const WHO_G: u8 = 0b010;
pub(crate) const WHO_O: u8 = 0b100;
pub(crate) const WHO_A: u8 = WHO_U | WHO_G | WHO_O;
pub(crate) const PERM_R: u8 = 0b00_0001;
pub(crate) const PERM_W: u8 = 0b00_0010;
pub(crate) const PERM_X: u8 = 0b00_0100;
pub(crate) const PERM_BIGX: u8 = 0b00_1000;
pub(crate) const PERM_S: u8 = 0b01_0000;
pub(crate) const PERM_T: u8 = 0b10_0000;
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct ModeProgram {
pub file: Option<ModeSpec>,
pub dir: Option<ModeSpec>,
}
impl ModeProgram {
#[must_use]
pub fn for_kind(&self, kind: EntryKind) -> Option<&ModeSpec> {
match kind {
EntryKind::Dir => self.dir.as_ref(),
EntryKind::Symlink => None,
EntryKind::File | EntryKind::Special => self.file.as_ref(),
}
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.file.is_none() && self.dir.is_none()
}
}
#[derive(Clone, Debug)]
pub struct Settings {
pub mode: ModeProgram,
pub owner: OwnerProgram,
pub group: OwnerProgram,
pub fail_early: bool,
pub defer_dir_changes: bool,
pub filter: Option<crate::filter::FilterSettings>,
pub time_filter: Option<TimeFilter>,
pub dry_run: Option<crate::config::DryRunMode>,
}
#[derive(Copy, Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
pub struct Summary {
pub files_changed: usize,
pub symlinks_changed: usize,
pub directories_changed: usize,
pub files_unchanged: usize,
pub symlinks_unchanged: usize,
pub directories_unchanged: usize,
pub files_skipped: usize,
pub symlinks_skipped: usize,
pub directories_skipped: usize,
}
impl std::ops::Add for Summary {
type Output = Self;
fn add(self, other: Self) -> Self {
Self {
files_changed: self.files_changed + other.files_changed,
symlinks_changed: self.symlinks_changed + other.symlinks_changed,
directories_changed: self.directories_changed + other.directories_changed,
files_unchanged: self.files_unchanged + other.files_unchanged,
symlinks_unchanged: self.symlinks_unchanged + other.symlinks_unchanged,
directories_unchanged: self.directories_unchanged + other.directories_unchanged,
files_skipped: self.files_skipped + other.files_skipped,
symlinks_skipped: self.symlinks_skipped + other.symlinks_skipped,
directories_skipped: self.directories_skipped + other.directories_skipped,
}
}
}
impl std::fmt::Display for Summary {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(
f,
"files changed: {}\n\
symlinks changed: {}\n\
directories changed: {}\n\
files unchanged: {}\n\
symlinks unchanged: {}\n\
directories unchanged: {}\n\
files skipped: {}\n\
symlinks skipped: {}\n\
directories skipped: {}\n",
self.files_changed,
self.symlinks_changed,
self.directories_changed,
self.files_unchanged,
self.symlinks_unchanged,
self.directories_unchanged,
self.files_skipped,
self.symlinks_skipped,
self.directories_skipped
)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum IdKind {
User,
Group,
}
impl IdKind {
fn getent_database(self) -> &'static str {
match self {
IdKind::User => "passwd",
IdKind::Group => "group",
}
}
fn label(self) -> &'static str {
match self {
IdKind::User => "user",
IdKind::Group => "group",
}
}
}
fn resolve_id(token: &str, kind: IdKind, getent: &GetentResolver) -> anyhow::Result<u32> {
if let Ok(n) = token.parse::<u32>() {
return Ok(n);
}
let in_process = match kind {
IdKind::User => {
nix::unistd::User::from_name(token).map(|user| user.map(|u| u.uid.as_raw()))
}
IdKind::Group => {
nix::unistd::Group::from_name(token).map(|group| group.map(|g| g.gid.as_raw()))
}
};
match in_process {
Ok(Some(id)) => Ok(id),
Ok(None) | Err(_) => resolve_via_getent(token, kind, getent),
}
}
const GETENT_NOT_FOUND: i32 = 2;
fn resolve_via_getent(token: &str, kind: IdKind, getent: &GetentResolver) -> anyhow::Result<u32> {
match getent.program()? {
Some(path) => resolve_via_getent_cmd(path.as_os_str(), token, kind),
None => resolve_via_getent_cmd(OsStr::new("getent"), token, kind),
}
}
fn resolve_via_getent_cmd(
getent_program: &OsStr,
token: &str,
kind: IdKind,
) -> anyhow::Result<u32> {
let database = kind.getent_database();
let label = kind.label();
let prog = getent_program.to_string_lossy();
let output = std::process::Command::new(getent_program)
.args(["--", database, token])
.output()
.with_context(|| {
format!(
"cannot run `{prog} {database} {token}` to look up the {label} name; \
use a numeric id instead"
)
})?;
if output.status.code() == Some(GETENT_NOT_FOUND) {
return Err(anyhow!("unknown {label}: {token}"));
}
if !output.status.success() {
let status = output.status;
let stderr = String::from_utf8_lossy(&output.stderr);
let stderr = stderr.trim();
let detail = if stderr.is_empty() {
String::new()
} else {
format!(": {stderr}")
};
return Err(anyhow!(
"`{prog} {database} {token}` failed with {status}{detail}; \
use a numeric id instead"
));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let line = stdout
.lines()
.next()
.ok_or_else(|| anyhow!("`{prog} {database} {token}` produced no output"))?;
parse_getent_id(line).with_context(|| format!("unexpected getent output {line:?}"))
}
const TRUSTED_GETENT_DIRS: &[&str] = &["/usr/bin", "/bin", "/run/current-system/sw/bin"];
#[must_use]
pub fn is_privileged() -> bool {
let euid = nix::unistd::geteuid();
euid.as_raw() == 0 || nix::unistd::getuid() != euid
}
#[derive(Clone, Debug)]
pub struct GetentResolver {
explicit: Option<PathBuf>,
privileged: bool,
}
impl Default for GetentResolver {
fn default() -> Self {
Self {
explicit: None,
privileged: false,
}
}
}
impl GetentResolver {
pub fn from_cli(getent_path: Option<PathBuf>, privileged: bool) -> anyhow::Result<Self> {
if let Some(path) = &getent_path
&& !path.is_absolute()
{
return Err(anyhow!(
"--getent-path must be an absolute path, got {path:?}"
));
}
Ok(Self {
explicit: getent_path,
privileged,
})
}
fn program(&self) -> anyhow::Result<Option<PathBuf>> {
self.program_in(TRUSTED_GETENT_DIRS)
}
fn program_in(&self, trusted_dirs: &[&str]) -> anyhow::Result<Option<PathBuf>> {
if let Some(path) = &self.explicit {
return Ok(Some(path.clone()));
}
if !self.privileged {
return Ok(None);
}
for dir in trusted_dirs {
let candidate = Path::new(dir).join("getent");
if candidate.is_file() {
return Ok(Some(candidate));
}
}
Err(anyhow!(
"running with elevated privilege and could not find `getent` in any trusted \
directory ({}); PATH is intentionally ignored when privileged so a name lookup \
cannot exec an attacker-controlled binary as root — pass an absolute \
--getent-path, or use numeric ids",
trusted_dirs.join(", ")
))
}
}
fn parse_getent_id(line: &str) -> anyhow::Result<u32> {
let field = line
.split(':')
.nth(2)
.ok_or_else(|| anyhow!("expected at least 3 ':'-separated fields"))?;
field
.parse::<u32>()
.with_context(|| format!("parsing id field {field:?}"))
}
pub fn parse_owner_dsl(
s: &str,
kind: IdKind,
getent: &GetentResolver,
) -> anyhow::Result<OwnerProgram> {
let mut prog = OwnerProgram::default();
let mut bare: Option<u32> = None;
for clause in s.split_whitespace() {
if let Some((ty, rest)) = clause.split_once(':') {
let id = resolve_id(rest, kind, getent)?;
match ty {
"f" | "file" => prog.file = Some(id),
"d" | "dir" | "directory" => prog.dir = Some(id),
"l" | "link" | "symlink" => prog.symlink = Some(id),
_ => return Err(anyhow!("unknown type prefix {ty:?} (expected f:/d:/l:)")),
}
} else if bare.is_some() {
return Err(anyhow!(
"multiple bare values in {s:?}; use f:/d:/l: prefixes to set different types"
));
} else {
bare = Some(resolve_id(clause, kind, getent)?);
}
}
if let Some(b) = bare {
prog.file.get_or_insert(b);
prog.dir.get_or_insert(b);
prog.symlink.get_or_insert(b);
}
Ok(prog)
}
#[must_use]
pub fn apply_mode(current: u32, spec: &ModeSpec, is_dir: bool) -> u32 {
match spec {
ModeSpec::Octal(m) => m & 0o7777,
ModeSpec::Symbolic(clauses) => {
let mut mode = current & 0o7777;
for clause in clauses {
mode = apply_clause(mode, *clause, is_dir);
}
mode
}
}
}
fn apply_clause(current: u32, clause: SymbolicClause, is_dir: bool) -> u32 {
let any_exec = current & 0o111 != 0;
let exec =
(clause.perms & PERM_X != 0) || (clause.perms & PERM_BIGX != 0 && (is_dir || any_exec));
let r = clause.perms & PERM_R != 0;
let w = clause.perms & PERM_W != 0;
let s = clause.perms & PERM_S != 0;
let t = clause.perms & PERM_T != 0;
let mut value: u32 = 0;
if clause.who & WHO_U != 0 {
if r {
value |= 0o400;
}
if w {
value |= 0o200;
}
if exec {
value |= 0o100;
}
if s {
value |= 0o4000;
}
}
if clause.who & WHO_G != 0 {
if r {
value |= 0o040;
}
if w {
value |= 0o020;
}
if exec {
value |= 0o010;
}
if s {
value |= 0o2000;
}
}
if clause.who & WHO_O != 0 {
if r {
value |= 0o004;
}
if w {
value |= 0o002;
}
if exec {
value |= 0o001;
}
}
if t && clause.who & WHO_O != 0 {
value |= 0o1000;
}
match clause.op {
ModeOp::Add => current | value,
ModeOp::Remove => current & !value,
ModeOp::Set => {
let mut clear: u32 = 0;
if clause.who & WHO_U != 0 {
clear |= 0o4700;
}
if clause.who & WHO_G != 0 {
clear |= 0o2070;
}
if clause.who & WHO_O != 0 {
clear |= 0o1007;
}
(current & !clear) | value
}
}
}
fn parse_mode_token(token: &str) -> anyhow::Result<ModeSpec> {
if token.is_empty() {
return Err(anyhow!("empty mode"));
}
if token.bytes().all(|b| b.is_ascii_digit()) {
if token.bytes().any(|b| b > b'7') {
return Err(anyhow!("invalid octal mode {token:?} (digits must be 0-7)"));
}
let value = u32::from_str_radix(token, 8)
.with_context(|| format!("parsing octal mode {token:?}"))?;
if value > 0o7777 {
return Err(anyhow!("octal mode {token:?} out of range (max 0o7777)"));
}
return Ok(ModeSpec::Octal(value));
}
let clauses = token
.split(',')
.map(parse_symbolic_clause)
.collect::<anyhow::Result<Vec<_>>>()?;
Ok(ModeSpec::Symbolic(clauses))
}
fn parse_symbolic_clause(clause: &str) -> anyhow::Result<SymbolicClause> {
let op_pos = clause
.find(['+', '-', '='])
.ok_or_else(|| anyhow!("mode clause {clause:?} missing +, - or ="))?;
let (who_str, rest) = clause.split_at(op_pos);
let op = match &rest[..1] {
"+" => ModeOp::Add,
"-" => ModeOp::Remove,
"=" => ModeOp::Set,
_ => unreachable!("find guaranteed one of +-="),
};
let perms_str = &rest[1..];
let mut who = 0u8;
for ch in who_str.chars() {
who |= match ch {
'u' => WHO_U,
'g' => WHO_G,
'o' => WHO_O,
'a' => WHO_A,
other => {
return Err(anyhow!(
"invalid 'who' {other:?} in {clause:?} (expected u/g/o/a)"
));
}
};
}
if who == 0 {
who = WHO_A;
}
let mut perms = 0u8;
for ch in perms_str.chars() {
perms |= match ch {
'r' => PERM_R,
'w' => PERM_W,
'x' => PERM_X,
'X' => PERM_BIGX,
's' => PERM_S,
't' => PERM_T,
other => return Err(anyhow!("invalid permission {other:?} in {clause:?}")),
};
}
Ok(SymbolicClause { who, op, perms })
}
pub fn parse_mode_dsl(s: &str) -> anyhow::Result<ModeProgram> {
let mut prog = ModeProgram::default();
let mut bare: Option<ModeSpec> = None;
for clause in s.split_whitespace() {
if let Some((ty, rest)) = clause.split_once(':') {
let spec = parse_mode_token(rest)?;
match ty {
"f" | "file" => prog.file = Some(spec),
"d" | "dir" | "directory" => prog.dir = Some(spec),
"l" | "link" | "symlink" => {
return Err(anyhow!(
"symlink mode (l:) is not settable on Linux; remove the l: section"
));
}
_ => return Err(anyhow!("unknown type prefix {ty:?} (expected f:/d:)")),
}
} else if bare.is_some() {
return Err(anyhow!(
"multiple bare mode expressions in {s:?}; chain sub-ops with commas (e.g. g+r,o+w)"
));
} else {
bare = Some(parse_mode_token(clause)?);
}
}
if let Some(b) = bare {
prog.file.get_or_insert(b.clone());
prog.dir.get_or_insert(b);
}
Ok(prog)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct EntryPlan {
pub chown: Option<(Option<u32>, Option<u32>)>,
pub chmod: Option<u32>,
}
impl EntryPlan {
pub(crate) fn is_noop(&self) -> bool {
self.chown.is_none() && self.chmod.is_none()
}
}
pub(crate) fn compute_plan(
cur_mode: u32,
cur_uid: u32,
cur_gid: u32,
kind: EntryKind,
settings: &Settings,
) -> EntryPlan {
let cur_mode = cur_mode & 0o7777;
let uid_change = settings.owner.for_kind(kind).filter(|&u| u != cur_uid);
let gid_change = settings.group.for_kind(kind).filter(|&g| g != cur_gid);
let need_chown = uid_change.is_some() || gid_change.is_some();
let chown = need_chown.then_some((uid_change, gid_change));
let chmod = if kind == EntryKind::Symlink {
None
} else if let Some(spec) = settings.mode.for_kind(kind) {
let desired = apply_mode(cur_mode, spec, kind == EntryKind::Dir);
if desired != cur_mode || (need_chown && desired & 0o6000 != 0) {
Some(desired)
} else {
None
}
} else if need_chown && cur_mode & 0o6000 != 0 {
Some(cur_mode)
} else {
None
};
EntryPlan { chown, chmod }
}
fn inc_changed(prog: &Progress, kind: EntryKind) -> Summary {
match kind {
EntryKind::Dir => {
prog.directories_changed.inc();
Summary {
directories_changed: 1,
..Default::default()
}
}
EntryKind::Symlink => {
prog.symlinks_changed.inc();
Summary {
symlinks_changed: 1,
..Default::default()
}
}
EntryKind::File | EntryKind::Special => {
prog.files_changed.inc();
Summary {
files_changed: 1,
..Default::default()
}
}
}
}
fn inc_unchanged(prog: &Progress, kind: EntryKind) -> Summary {
match kind {
EntryKind::Dir => {
prog.directories_unchanged.inc();
Summary {
directories_unchanged: 1,
..Default::default()
}
}
EntryKind::Symlink => {
prog.symlinks_unchanged.inc();
Summary {
symlinks_unchanged: 1,
..Default::default()
}
}
EntryKind::File | EntryKind::Special => {
prog.files_unchanged.inc();
Summary {
files_unchanged: 1,
..Default::default()
}
}
}
}
fn skipped_summary_for(kind: EntryKind) -> Summary {
match kind {
EntryKind::Dir => Summary {
directories_skipped: 1,
..Default::default()
},
EntryKind::Symlink => Summary {
symlinks_skipped: 1,
..Default::default()
},
EntryKind::File | EntryKind::Special => Summary {
files_skipped: 1,
..Default::default()
},
}
}
fn describe_change(cur_mode: u32, cur_uid: u32, cur_gid: u32, plan: &EntryPlan) -> String {
let mut parts = Vec::new();
if let Some(mode) = plan.chmod {
if mode == cur_mode & 0o7777 {
parts.push(format!("mode {mode:04o} (re-applied after chown)"));
} else {
parts.push(format!("mode {:04o}->{:04o}", cur_mode & 0o7777, mode));
}
}
if let Some((uid, gid)) = plan.chown {
if let Some(uid) = uid {
parts.push(format!("owner {cur_uid}->{uid}"));
}
if let Some(gid) = gid {
parts.push(format!("group {cur_gid}->{gid}"));
}
}
parts.join(", ")
}
async fn apply_plan(handle: &Handle, plan: &EntryPlan) -> anyhow::Result<()> {
if let Some((uid, gid)) = plan.chown {
safedir::fchown_handle(handle, congestion::Side::Destination, uid, gid)
.await
.with_context(|| format!("failed to chown via fd (uid={uid:?}, gid={gid:?})"))?;
}
if let Some(mode) = plan.chmod {
safedir::chmod_via_proc_fd(handle, congestion::Side::Destination, mode)
.await
.with_context(|| format!("failed to chmod via fd to {mode:04o}"))?;
}
Ok(())
}
async fn apply_entry_change(
prog: &'static Progress,
path: &std::path::Path,
handle: &Handle,
kind: EntryKind,
settings: &Settings,
) -> Result<Summary, Error> {
if let Some(ref time_filter) = settings.time_filter {
let metadata =
match safedir::stat_meta_via_proc_fd(handle, congestion::Side::Destination).await {
Ok(md) => md,
Err(err) => {
let err = anyhow::Error::new(err).context(format!(
"failed reading metadata for time filter on {path:?}"
));
if settings.fail_early {
return Err(Error::new(err, Default::default()));
}
tracing::warn!("time filter failed for {:?}, skipping: {:#}", path, &err);
kind.inc_skipped(prog);
return Ok(skipped_summary_for(kind));
}
};
match time_filter.matches(&metadata) {
Ok(result) => {
if let Some(reason) = result.as_skip_reason() {
if let Some(mode) = settings.dry_run {
crate::dry_run::report_time_skip(path, reason, mode, kind.label());
}
kind.inc_skipped(prog);
return Ok(skipped_summary_for(kind));
}
}
Err(err) => {
let err = err.context(format!("failed evaluating time filter on {path:?}"));
if settings.fail_early {
return Err(Error::new(err, Default::default()));
}
tracing::warn!("time filter failed for {:?}, skipping: {:#}", path, &err);
kind.inc_skipped(prog);
return Ok(skipped_summary_for(kind));
}
}
}
let meta = handle.meta();
let cur_mode = mode_of(meta);
let plan = compute_plan(cur_mode, meta.uid(), meta.gid(), kind, settings);
if plan.is_noop() {
if let Some(crate::config::DryRunMode::All) = settings.dry_run {
println!("unchanged {} {:?}", kind.label(), path);
}
return Ok(inc_unchanged(prog, kind));
}
if settings.dry_run.is_some() {
let desc = describe_change(cur_mode, meta.uid(), meta.gid(), &plan);
println!("would modify {} {:?}: {}", kind.label(), path, desc);
return Ok(inc_changed(prog, kind));
}
apply_plan(handle, &plan)
.await
.map_err(|err| Error::new(err, Default::default()))?;
Ok(inc_changed(prog, kind))
}
#[instrument(skip(prog_track, settings))]
pub async fn chmod(
prog_track: &'static Progress,
path: &std::path::Path,
settings: &Settings,
) -> Result<Summary, Error> {
let operand = crate::walk::split_root_operand(path)
.await
.map_err(|err| Error::new(err, Default::default()))?;
let parent_path = operand.parent.as_path();
let name = operand.name.as_os_str();
let path = operand.display.as_path();
let parent = Dir::open_parent_dir(parent_path, congestion::Side::Destination)
.await
.with_context(|| format!("cannot open parent directory {parent_path:?}"))
.map_err(|err| Error::new(err, Default::default()))?;
let parent = Arc::new(parent.into_tree());
if let Some(ref filter) = settings.filter {
let root_handle = parent
.child(name)
.await
.with_context(|| format!("failed reading metadata from {path:?}"))
.map_err(|err| Error::new(err, Default::default()))?;
let name_path = std::path::Path::new(name);
match filter.should_include_root_item(name_path, root_handle.kind() == EntryKind::Dir) {
crate::filter::FilterResult::Included => {}
result => {
let kind = root_handle.kind();
if let Some(mode) = settings.dry_run {
crate::dry_run::report_skip(path, &result, mode, kind.label_long());
}
kind.inc_skipped(prog_track);
return Ok(skipped_summary_for(kind));
}
}
}
run_chmod_root(prog_track, &parent, name, path, settings).await
}
async fn run_chmod_root(
prog_track: &'static Progress,
parent: &Arc<Dir>,
name: &OsStr,
root: &std::path::Path,
settings: &Settings,
) -> Result<Summary, Error> {
let visitor = Arc::new(ChmodVisitor {
prog_track,
settings: settings.clone(),
});
let root_cx = EntryCx {
parent: Arc::clone(parent),
name: name.to_owned(),
rel_path: PathBuf::new(),
filter_path: PathBuf::new(),
real_path: root.to_path_buf(),
dry_run: settings.dry_run.is_some(),
prog_track,
};
process_entry(visitor, root_cx, (), None).await }
struct ChmodVisitor {
prog_track: &'static Progress,
settings: Settings,
}
struct ChmodDirState {
handle: Handle,
traversed_only: bool,
base: Summary,
pre_order_error: Option<anyhow::Error>,
}
impl WalkVisitor for ChmodVisitor {
type Summary = Summary;
type DirContext = ();
type DirState = ChmodDirState;
fn root_dir_context(&self) {}
fn permit_kind(&self) -> PermitKind {
PermitKind::PendingMeta
}
fn want_permit(&self, hint: Option<EntryKind>) -> bool {
hint.is_some_and(|k| k != EntryKind::Dir)
}
fn fail_early(&self) -> bool {
self.settings.fail_early
}
fn filter(&self) -> Option<&crate::filter::FilterSettings> {
self.settings.filter.as_ref()
}
fn on_skip(
&self,
cx: &EntryCx,
kind: EntryKind,
skip_result: &crate::filter::FilterResult,
) -> Summary {
if let Some(mode) = self.settings.dry_run {
crate::dry_run::report_skip(&cx.real_path, skip_result, mode, kind.label());
}
skipped_summary_for(kind)
}
async fn visit_leaf(
&self,
cx: &EntryCx,
_parent_ctx: &(),
handle: Handle,
kind: EntryKind,
permit: Option<LeafPermit>,
) -> Result<Summary, Error> {
let _permit = permit;
apply_entry_change(
self.prog_track,
&cx.real_path,
&handle,
kind,
&self.settings,
)
.await
}
async fn dir_pre(&self, cx: &EntryCx, _parent_ctx: &(), handle: &Handle) -> DirPreResult<Self> {
let path = &cx.real_path;
let traversed_only = self.settings.filter.as_ref().is_some_and(|f| {
f.has_includes() && !f.directly_matches_include(&cx.filter_path, true)
});
let mut base = Summary::default();
let mut pre_order_error: Option<anyhow::Error> = None;
if !self.settings.defer_dir_changes {
match apply_dir_self(
self.prog_track,
path,
handle,
traversed_only,
&self.settings,
)
.await
{
Ok(dir_summary) => base = base + dir_summary,
Err(error) if self.settings.fail_early => {
return Err(Error::new(error.source, base + error.summary));
}
Err(error) => {
tracing::error!("chmod: {:?} failed with: {:#}", path, &error);
base = base + error.summary;
pre_order_error = Some(error.source);
}
}
}
let dir_handle = handle
.try_clone()
.with_context(|| format!("cannot duplicate directory handle for {path:?}"))
.map_err(|err| Error::new(err, base))?;
match cx.parent.open_dir(&cx.name).await {
Ok(dir) => Ok(DirAction::Descend {
dir: Arc::new(dir),
child_ctx: (),
state: ChmodDirState {
handle: dir_handle,
traversed_only,
base,
pre_order_error,
},
}),
Err(error) => {
let error = anyhow::Error::new(error)
.context(format!("cannot open directory {path:?} for reading"));
if self.settings.fail_early {
return Err(Error::new(error, base));
}
let errors = crate::error_collector::ErrorCollector::default();
if let Some(pre_order_error) = pre_order_error {
errors.push(pre_order_error);
}
tracing::error!("chmod: {:#}", &error);
errors.push(error);
if self.settings.defer_dir_changes {
match apply_dir_self(
self.prog_track,
path,
handle,
traversed_only,
&self.settings,
)
.await
{
Ok(dir_summary) => base = base + dir_summary,
Err(error) => {
tracing::error!("chmod: {:?} failed with: {:#}", path, &error);
base = base + error.summary;
errors.push(error.source);
}
}
}
Err(Error::new(errors.into_error().unwrap(), base))
}
}
}
async fn dir_post(
&self,
cx: &EntryCx,
state: ChmodDirState,
_processed: &ProcessedChildren,
child_result: Result<Summary, Error>,
) -> Result<Summary, Error> {
let ChmodDirState {
handle,
traversed_only,
base,
pre_order_error,
} = state;
let path = &cx.real_path;
let (child_summary, child_error) = match child_result {
Ok(summary) => (summary, None),
Err(err) => (err.summary, Some(err.source)),
};
let mut summary = base + child_summary;
let errors = crate::error_collector::ErrorCollector::default();
if let Some(pre_order_error) = pre_order_error {
errors.push(pre_order_error);
}
if let Some(child_error) = child_error {
errors.push(child_error);
}
if self.settings.defer_dir_changes {
match apply_dir_self(
self.prog_track,
path,
&handle,
traversed_only,
&self.settings,
)
.await
{
Ok(dir_summary) => summary = summary + dir_summary,
Err(error) => {
if self.settings.fail_early {
return Err(Error::new(error.source, summary + error.summary));
}
tracing::error!("chmod: {:?} failed with: {:#}", path, &error);
summary = summary + error.summary;
errors.push(error.source);
}
}
}
if let Some(error) = errors.into_error() {
return Err(Error::new(error, summary));
}
Ok(summary)
}
}
async fn apply_dir_self(
prog_track: &'static Progress,
path: &std::path::Path,
handle: &Handle,
traversed_only: bool,
settings: &Settings,
) -> Result<Summary, Error> {
if traversed_only {
if let Some(crate::config::DryRunMode::All) = settings.dry_run {
println!("skip dir {path:?} (only traversed for include matches)");
}
prog_track.directories_skipped.inc();
return Ok(skipped_summary_for(EntryKind::Dir));
}
apply_entry_change(prog_track, path, handle, EntryKind::Dir, settings).await
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mode_token_octal() {
assert_eq!(parse_mode_token("2775").unwrap(), ModeSpec::Octal(0o2775));
assert_eq!(parse_mode_token("0644").unwrap(), ModeSpec::Octal(0o644));
}
#[test]
fn mode_token_octal_out_of_range_errors() {
assert!(parse_mode_token("9999").is_err()); assert!(parse_mode_token("77777").is_err()); }
#[test]
fn mode_token_symbolic_simple() {
let spec = parse_mode_token("g+w").unwrap();
assert_eq!(
spec,
ModeSpec::Symbolic(vec![SymbolicClause {
who: WHO_G,
op: ModeOp::Add,
perms: PERM_W
}])
);
}
#[test]
fn mode_token_symbolic_omitted_who_means_all() {
let spec = parse_mode_token("+x").unwrap();
assert_eq!(
spec,
ModeSpec::Symbolic(vec![SymbolicClause {
who: WHO_A,
op: ModeOp::Add,
perms: PERM_X
}])
);
}
#[test]
fn mode_token_symbolic_comma_chained() {
let spec = parse_mode_token("u+rw,g-w").unwrap();
let ModeSpec::Symbolic(clauses) = spec else {
panic!("expected symbolic")
};
assert_eq!(clauses.len(), 2);
assert_eq!(
clauses[0],
SymbolicClause {
who: WHO_U,
op: ModeOp::Add,
perms: PERM_R | PERM_W
}
);
assert_eq!(
clauses[1],
SymbolicClause {
who: WHO_G,
op: ModeOp::Remove,
perms: PERM_W
}
);
}
#[test]
fn mode_token_symbolic_bigx_and_specials() {
let spec = parse_mode_token("g+rwXs").unwrap();
assert_eq!(
spec,
ModeSpec::Symbolic(vec![SymbolicClause {
who: WHO_G,
op: ModeOp::Add,
perms: PERM_R | PERM_W | PERM_BIGX | PERM_S,
}])
);
}
#[test]
fn mode_token_rejects_garbage() {
assert!(parse_mode_token("q+z").is_err());
assert!(parse_mode_token("g!w").is_err());
assert!(parse_mode_token("").is_err());
}
#[test]
fn summary_add_combines_fields() {
let a = Summary {
files_changed: 1,
directories_changed: 2,
files_unchanged: 3,
..Default::default()
};
let b = Summary {
files_changed: 10,
symlinks_skipped: 4,
..Default::default()
};
let sum = a + b;
assert_eq!(sum.files_changed, 11);
assert_eq!(sum.directories_changed, 2);
assert_eq!(sum.files_unchanged, 3);
assert_eq!(sum.symlinks_skipped, 4);
}
#[test]
fn owner_dsl_bare_applies_to_all_types() {
let prog = parse_owner_dsl("0", IdKind::User, &GetentResolver::default()).unwrap();
assert_eq!(prog.file, Some(0));
assert_eq!(prog.dir, Some(0));
assert_eq!(prog.symlink, Some(0));
}
#[test]
fn owner_dsl_per_type_overrides() {
let prog = parse_owner_dsl("f:1 d:2", IdKind::User, &GetentResolver::default()).unwrap();
assert_eq!(prog.file, Some(1));
assert_eq!(prog.dir, Some(2));
assert_eq!(prog.symlink, None);
}
#[test]
fn owner_dsl_bare_plus_override() {
let prog = parse_owner_dsl("5 d:2", IdKind::Group, &GetentResolver::default()).unwrap();
assert_eq!(prog.file, Some(5));
assert_eq!(prog.dir, Some(2));
assert_eq!(prog.symlink, Some(5));
}
#[test]
fn owner_dsl_explicit_before_bare_is_order_independent() {
let prog = parse_owner_dsl("f:1 5", IdKind::User, &GetentResolver::default()).unwrap();
assert_eq!(prog.file, Some(1));
assert_eq!(prog.dir, Some(5));
assert_eq!(prog.symlink, Some(5));
}
#[test]
fn owner_dsl_rejects_multiple_bare() {
assert!(parse_owner_dsl("1 2", IdKind::User, &GetentResolver::default()).is_err());
}
#[test]
fn owner_dsl_rejects_unknown_id() {
assert!(
parse_owner_dsl(
"definitely-no-such-group-xyz",
IdKind::Group,
&GetentResolver::default()
)
.is_err()
);
}
#[test]
fn owner_dsl_resolves_root_name() {
let prog = parse_owner_dsl("root", IdKind::User, &GetentResolver::default()).unwrap();
assert_eq!(prog.file, Some(0));
assert_eq!(prog.dir, Some(0));
assert_eq!(prog.symlink, Some(0));
}
#[test]
fn getent_id_parses_passwd_and_group_lines() {
assert_eq!(
parse_getent_id("alice:x:1234:100:Alice:/home/alice:/bin/sh").unwrap(),
1234
);
assert_eq!(parse_getent_id("data:*:5678:alice,bob").unwrap(), 5678);
}
#[test]
fn getent_id_rejects_malformed_lines() {
assert!(parse_getent_id("").is_err());
assert!(parse_getent_id("alice").is_err());
assert!(parse_getent_id("alice:x").is_err());
assert!(parse_getent_id("alice:x:notanumber:100").is_err());
}
fn write_stub_getent(dir: &std::path::Path, name: &str, body: &str) -> std::path::PathBuf {
use std::os::unix::fs::PermissionsExt;
let path = dir.join(name);
std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
path
}
#[test]
fn getent_stub_resolves_directory_service_names() {
let tmp = tempfile::tempdir().unwrap();
let user_stub = write_stub_getent(
tmp.path(),
"getent-user",
"[ \"$1\" = -- ] && [ \"$2\" = passwd ] || exit 99\necho 'ldapuser:*:4242:4242:LDAP User:/home/ldapuser:/bin/sh'",
);
let group_stub = write_stub_getent(
tmp.path(),
"getent-group",
"[ \"$1\" = -- ] && [ \"$2\" = group ] || exit 99\necho 'ldapgroup:*:4343:ldapuser'",
);
assert_eq!(
resolve_via_getent_cmd(user_stub.as_os_str(), "ldapuser", IdKind::User).unwrap(),
4242
);
assert_eq!(
resolve_via_getent_cmd(group_stub.as_os_str(), "ldapgroup", IdKind::Group).unwrap(),
4343
);
}
#[test]
fn getent_stub_not_found_is_unknown() {
let tmp = tempfile::tempdir().unwrap();
let stub = write_stub_getent(tmp.path(), "getent-miss", "exit 2");
let err = resolve_via_getent_cmd(stub.as_os_str(), "nosuch", IdKind::Group).unwrap_err();
assert!(
format!("{err:#}").contains("unknown group: nosuch"),
"got: {err:#}"
);
}
#[test]
fn getent_missing_program_suggests_numeric_id() {
let tmp = tempfile::tempdir().unwrap();
let missing = tmp.path().join("no-such-getent");
let err = resolve_via_getent_cmd(missing.as_os_str(), "alice", IdKind::User).unwrap_err();
assert!(
format!("{err:#}").contains("use a numeric id instead"),
"got: {err:#}"
);
}
#[test]
fn getent_stub_garbled_output_errors() {
let tmp = tempfile::tempdir().unwrap();
let stub = write_stub_getent(tmp.path(), "getent-garbled", "echo 'not a passwd line'");
let err = resolve_via_getent_cmd(stub.as_os_str(), "alice", IdKind::User).unwrap_err();
assert!(
format!("{err:#}").contains("unexpected getent output"),
"got: {err:#}"
);
}
#[test]
fn getent_stub_exit0_no_output_errors() {
let tmp = tempfile::tempdir().unwrap();
let stub = write_stub_getent(tmp.path(), "getent-empty", "exit 0");
let err = resolve_via_getent_cmd(stub.as_os_str(), "alice", IdKind::User).unwrap_err();
assert!(
format!("{err:#}").contains("produced no output"),
"got: {err:#}"
);
}
#[test]
fn getent_real_resolves_root() {
assert_eq!(
resolve_via_getent("root", IdKind::User, &GetentResolver::default()).unwrap(),
0
);
assert_eq!(
resolve_via_getent("root", IdKind::Group, &GetentResolver::default()).unwrap(),
0
);
}
#[test]
fn getent_real_option_like_name_fails_closed_no_injection() {
let err = resolve_via_getent("--service=files", IdKind::Group, &GetentResolver::default())
.unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("unknown group") || msg.contains("produced no output"),
"option-like name must fail closed (not resolve to an id), got: {msg}"
);
}
#[test]
fn getent_source_rejects_relative_explicit_path() {
let err = GetentResolver::from_cli(Some(PathBuf::from("getent")), false).unwrap_err();
assert!(
format!("{err:#}").contains("must be an absolute path"),
"got: {err:#}"
);
}
#[test]
fn getent_source_explicit_absolute_used_even_when_privileged() {
let resolver =
GetentResolver::from_cli(Some(PathBuf::from("/opt/nss/getent")), true).unwrap();
assert_eq!(
resolver.program_in(&["/usr/bin"]).unwrap(),
Some(PathBuf::from("/opt/nss/getent"))
);
}
#[test]
fn getent_source_unprivileged_searches_path() {
let resolver = GetentResolver::from_cli(None, false).unwrap();
assert_eq!(resolver.program_in(&["/nonexistent"]).unwrap(), None);
}
#[test]
fn getent_source_privileged_probes_trusted_dirs_not_path() {
let tmp = tempfile::tempdir().unwrap();
let bin = tmp.path().join("getent");
std::fs::write(&bin, "#!/bin/sh\n").unwrap();
let resolver = GetentResolver::from_cli(None, true).unwrap();
let dirs = [tmp.path().to_str().unwrap()];
assert_eq!(resolver.program_in(&dirs).unwrap(), Some(bin));
}
#[test]
fn getent_source_privileged_missing_getent_errors_not_path_fallback() {
let tmp = tempfile::tempdir().unwrap(); let resolver = GetentResolver::from_cli(None, true).unwrap();
let dir = tmp.path().to_str().unwrap();
let err = resolver.program_in(&[dir]).unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("PATH is intentionally ignored"),
"should explain PATH is not consulted, got: {msg}"
);
assert!(
msg.contains(dir),
"should name the searched dir, got: {msg}"
);
}
fn sym(s: &str) -> ModeSpec {
parse_mode_token(s).unwrap()
}
#[test]
fn apply_mode_group_add_remove() {
assert_eq!(apply_mode(0o644, &sym("g+w"), false), 0o664);
assert_eq!(apply_mode(0o664, &sym("g-w"), false), 0o644);
}
#[test]
fn apply_mode_set_clears_other_bits() {
assert_eq!(apply_mode(0o755, &sym("o="), false), 0o750);
assert_eq!(apply_mode(0o000, &sym("u=rwx,go=rx"), false), 0o755);
assert_eq!(apply_mode(0o1755, &sym("o="), false), 0o0750);
}
#[test]
fn apply_mode_conditional_bigx() {
assert_eq!(apply_mode(0o644, &sym("a+X"), false), 0o644);
assert_eq!(apply_mode(0o744, &sym("a+X"), false), 0o755);
assert_eq!(apply_mode(0o644, &sym("a+X"), true), 0o755);
}
#[test]
fn apply_mode_setgid_and_sticky() {
assert_eq!(apply_mode(0o750, &sym("g+rwxs"), true), 0o2770);
assert_eq!(apply_mode(0o755, &sym("+t"), true), 0o1755);
assert_eq!(apply_mode(0o755, &sym("u+s"), false), 0o4755);
}
#[test]
fn apply_mode_sticky_only_responds_to_other() {
assert_eq!(apply_mode(0o755, &sym("u+t"), false), 0o755);
assert_eq!(apply_mode(0o755, &sym("g+t"), false), 0o755);
assert_eq!(apply_mode(0o755, &sym("ug+t"), false), 0o755);
assert_eq!(apply_mode(0o755, &sym("o+t"), false), 0o1755);
assert_eq!(apply_mode(0o755, &sym("+t"), false), 0o1755);
assert_eq!(apply_mode(0o1755, &sym("u-t"), false), 0o1755);
assert_eq!(apply_mode(0o1755, &sym("o-t"), false), 0o755);
}
#[test]
fn apply_mode_octal_is_absolute() {
assert_eq!(apply_mode(0o4755, &sym("644"), false), 0o644);
assert_eq!(apply_mode(0o000, &sym("2775"), true), 0o2775);
}
#[test]
fn mode_dsl_bare_applies_to_file_and_dir_not_symlink() {
let prog = parse_mode_dsl("g+rwX").unwrap();
assert!(prog.file.is_some());
assert!(prog.dir.is_some());
assert!(prog.for_kind(EntryKind::Symlink).is_none());
}
#[test]
fn mode_dsl_per_type() {
let prog = parse_mode_dsl("f:g+rw d:g+rwxs").unwrap();
assert_eq!(prog.file, Some(sym("g+rw")));
assert_eq!(prog.dir, Some(sym("g+rwxs")));
}
#[test]
fn mode_dsl_bare_plus_override() {
let prog = parse_mode_dsl("g+r d:g+rwx").unwrap();
assert_eq!(prog.file, Some(sym("g+r")));
assert_eq!(prog.dir, Some(sym("g+rwx")));
}
#[test]
fn mode_dsl_rejects_symlink_section() {
assert!(parse_mode_dsl("l:g+w").is_err());
}
#[test]
fn mode_dsl_rejects_multiple_bare() {
assert!(parse_mode_dsl("g+r o+w").is_err());
}
#[test]
fn mode_dsl_rejects_unknown_prefix() {
assert!(parse_mode_dsl("z:644").is_err());
}
#[test]
fn mode_dsl_single_type_leaves_other_none() {
let prog_f = parse_mode_dsl("f:644").unwrap();
assert!(prog_f.file.is_some());
assert!(prog_f.dir.is_none());
let prog_d = parse_mode_dsl("d:755").unwrap();
assert!(prog_d.dir.is_some());
assert!(prog_d.file.is_none());
}
fn settings_with(mode: &str, owner: Option<&str>, group: Option<&str>) -> Settings {
Settings {
mode: if mode.is_empty() {
ModeProgram::default()
} else {
parse_mode_dsl(mode).unwrap()
},
owner: owner
.map(|s| parse_owner_dsl(s, IdKind::User, &GetentResolver::default()).unwrap())
.unwrap_or_default(),
group: group
.map(|s| parse_owner_dsl(s, IdKind::Group, &GetentResolver::default()).unwrap())
.unwrap_or_default(),
fail_early: false,
defer_dir_changes: false,
filter: None,
time_filter: None,
dry_run: None,
}
}
#[test]
fn plan_noop_when_already_correct() {
let s = settings_with("g+r", None, None);
let plan = compute_plan(0o644, 1000, 1000, EntryKind::File, &s);
assert!(plan.is_noop());
}
#[test]
fn plan_chmod_when_mode_differs() {
let s = settings_with("g+w", None, None);
let plan = compute_plan(0o644, 1000, 1000, EntryKind::File, &s);
assert_eq!(plan.chmod, Some(0o664));
assert!(plan.chown.is_none());
}
#[test]
fn plan_chown_only_changed_ids() {
let s = settings_with("", None, Some("2000"));
let plan = compute_plan(0o644, 1000, 1000, EntryKind::File, &s);
assert_eq!(plan.chown, Some((None, Some(2000))));
assert!(plan.chmod.is_none());
}
#[test]
fn plan_preserves_setgid_across_chgrp() {
let s = settings_with("", None, Some("2000"));
let plan = compute_plan(0o2755, 1000, 1000, EntryKind::File, &s);
assert_eq!(plan.chown, Some((None, Some(2000))));
assert_eq!(plan.chmod, Some(0o2755));
}
#[test]
fn plan_symlink_never_chmods_but_chowns() {
let s = settings_with("g+w", None, Some("2000"));
let plan = compute_plan(0o777, 1000, 1000, EntryKind::Symlink, &s);
assert!(plan.chmod.is_none());
assert_eq!(plan.chown, Some((None, Some(2000))));
}
#[test]
fn plan_preserves_setuid_when_mode_rule_noop_but_chown_runs() {
let s = settings_with("g+r", Some("2000"), None);
let plan = compute_plan(0o4755, 1000, 1000, EntryKind::File, &s);
assert_eq!(plan.chown, Some((Some(2000), None)));
assert_eq!(plan.chmod, Some(0o4755));
}
#[test]
fn plan_preserves_setgid_dir_across_chgrp() {
let s = settings_with("", None, Some("2000"));
let plan = compute_plan(0o2770, 1000, 1000, EntryKind::Dir, &s);
assert_eq!(plan.chown, Some((None, Some(2000))));
assert_eq!(plan.chmod, Some(0o2770));
}
static RACE_PROGRESS: std::sync::LazyLock<Progress> = std::sync::LazyLock::new(Progress::new);
fn spawn_file_symlink_swapper(
dir: std::path::PathBuf,
entry_name: &'static str,
sentinel: std::path::PathBuf,
stop: std::sync::Arc<std::sync::atomic::AtomicBool>,
) -> std::thread::JoinHandle<()> {
use std::os::unix::fs::PermissionsExt;
std::thread::spawn(move || {
let entry = dir.join(entry_name);
let staged_real = dir.join("__staged_real");
let staged_link = dir.join("__staged_link");
while !stop.load(std::sync::atomic::Ordering::Relaxed) {
let _ = std::fs::remove_file(&staged_real);
if std::fs::write(&staged_real, b"REAL").is_err() {
continue;
}
let _ =
std::fs::set_permissions(&staged_real, std::fs::Permissions::from_mode(0o644));
let _ = std::fs::rename(&staged_real, &entry);
let _ = std::fs::remove_file(&staged_link);
let _ = std::os::unix::fs::symlink(&sentinel, &staged_link);
let _ = std::fs::rename(&staged_link, &entry);
}
})
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn entry_symlink_swap_never_changes_sentinel_mode() -> anyhow::Result<()> {
use std::os::unix::fs::PermissionsExt;
let tmp = crate::testutils::create_temp_dir().await?;
let root = tmp.as_path();
let sentinel = root.join("sentinel_secret");
tokio::fs::write(&sentinel, b"SENTINEL").await?;
std::fs::set_permissions(&sentinel, std::fs::Permissions::from_mode(0o600))?;
let sentinel_uid_before =
std::os::unix::fs::MetadataExt::uid(&std::fs::symlink_metadata(&sentinel)?);
let target_dir = root.join("tree");
tokio::fs::create_dir(&target_dir).await?;
let entry_path = target_dir.join("entry");
tokio::fs::write(&entry_path, b"REAL").await?;
std::fs::set_permissions(&entry_path, std::fs::Permissions::from_mode(0o644))?;
let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let swapper =
spawn_file_symlink_swapper(target_dir.clone(), "entry", sentinel.clone(), stop.clone());
let settings = settings_with("g+w", None, None);
let mut caught = 0usize;
let mut changed_real = 0usize;
for i in 0..300 {
let result = tokio::time::timeout(
std::time::Duration::from_secs(30),
chmod(&RACE_PROGRESS, &target_dir, &settings),
)
.await
.expect("rchm must not hang under concurrent swapping");
match result {
Ok(summary) => changed_real += summary.files_changed,
Err(_) => caught += 1, }
let sentinel_md = std::fs::symlink_metadata(&sentinel)?;
assert_eq!(
sentinel_md.permissions().mode() & 0o7777,
0o600,
"iteration {i}: sentinel mode changed — rchm followed the symlink to chmod it"
);
assert_eq!(
std::os::unix::fs::MetadataExt::uid(&sentinel_md),
sentinel_uid_before,
"iteration {i}: sentinel owner changed — rchm followed the symlink to chown it"
);
}
stop.store(true, std::sync::atomic::Ordering::Relaxed);
swapper.join().expect("swapper thread panicked");
tracing::info!("entry swap: caught={caught}, changed_real={changed_real}");
assert!(
caught + changed_real > 0,
"expected at least one observable outcome across the iterations"
);
Ok(())
}
mod max_open_files_tests {
use super::*;
use crate::walk_driver::process_entry;
static PROGRESS: std::sync::LazyLock<Progress> = std::sync::LazyLock::new(Progress::new);
#[tokio::test]
async fn hinted_leaf_that_is_dir_drops_permit_before_recursion() -> anyhow::Result<()> {
let root = crate::testutils::create_temp_dir().await?;
let dir_path = root.join("d");
tokio::fs::create_dir(&dir_path).await?;
let child_path = dir_path.join("c");
tokio::fs::write(&child_path, b"x").await?;
std::fs::set_permissions(&child_path, std::fs::Permissions::from_mode(0o644))?;
throttle::set_max_open_files(1);
let parent = Dir::open_parent_dir(&root, congestion::Side::Destination)
.await
.context("open parent dir")?;
let parent = Arc::new(parent.into_tree());
let name = std::ffi::OsStr::new("d");
let handle = parent.child(name).await.context("classify d")?;
assert_eq!(
handle.kind(),
EntryKind::Dir,
"fixture `d` must be a directory"
);
drop(handle);
let visitor = Arc::new(ChmodVisitor {
prog_track: &PROGRESS,
settings: settings_with("g+w", None, None),
});
let cx = EntryCx {
parent: Arc::clone(&parent),
name: name.to_owned(),
rel_path: PathBuf::new(),
filter_path: PathBuf::new(),
real_path: dir_path.clone(),
dry_run: false,
prog_track: &PROGRESS,
};
let permit = crate::walk::preacquire_leaf_permit(
PermitKind::PendingMeta,
Some(EntryKind::File),
|_| true,
)
.await;
assert!(permit.is_some(), "the pre-acquire must take the one permit");
let result = tokio::time::timeout(
std::time::Duration::from_secs(20),
process_entry(visitor, cx, (), permit),
)
.await;
throttle::set_max_open_files(0);
let summary = result
.context(
"process_entry hung — leaf permit held across directory recursion (deadlock)",
)?
.map_err(|e| e.source)
.context("process_entry failed")?;
assert_eq!(
summary.files_changed, 1,
"child file should have its mode changed"
);
assert_eq!(
summary.directories_changed, 1,
"directory should have its mode changed"
);
assert_eq!(
std::fs::symlink_metadata(&child_path)?.permissions().mode() & 0o777,
0o664,
"child file mode should be g+w applied"
);
Ok(())
}
}
}