1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
use anyhow::{Result, anyhow};
use clap::Parser;
use tracing::instrument;
#[derive(Parser, Debug, Clone)]
#[command(
name = "rchm",
version,
about = "Recursively change permissions/ownership of large filesets - a dchmod replacement",
long_about = "`rchm` recursively applies chmod/chgrp/chown changes to large filesets.
The --group and --owner options take a per-type DSL: a bare value applies to all
entries (files, directories, and symlinks via lchown); f:/d:/l: prefixes target one
type. --mode uses the same DSL but covers only files and directories (a bare value
sets both) — symlink mode bits aren't settable on Linux, so l: is rejected for --mode.
EXAMPLES:
# join group 'data' and add group rwx to dirs, rw to files
rchm --group data --mode 'f:g+rw d:g+rwxs' /data --progress --summary
# dchmod-style: one mode for everything
rchm --mode g+rwX /data --progress"
)]
struct Args {
/// Mode change DSL (e.g. 'g+rwX' or 'f:g+rw d:g+rwxs'). Symbolic or octal.
#[arg(long, value_name = "DSL", help_heading = "Operation")]
mode: Option<String>,
/// Group change DSL: a group name/gid, optionally per type (e.g. 'data' or 'f:data d:wheel')
#[arg(long, value_name = "DSL", help_heading = "Operation")]
group: Option<String>,
/// Owner change DSL: a user name/uid, optionally per type (e.g. 'root' or 'd:root')
#[arg(long, value_name = "DSL", help_heading = "Operation")]
owner: Option<String>,
/// Exit on first error
#[arg(short = 'e', long = "fail-early", help_heading = "Operation")]
fail_early: bool,
/// Apply directory changes after their contents (post-order) instead of before
///
/// By default directory changes are applied before descending (like `chmod -R`), so
/// `--mode d:u+rwx` can recover an unreadable directory. Use this flag when recursively
/// removing the owner's own read/execute from directories, where pre-order would lock
/// itself out of the contents.
#[arg(long, help_heading = "Operation")]
defer_dir_changes: bool,
/// Glob pattern for paths to include (can be specified multiple times)
#[arg(long, value_name = "PATTERN", action = clap::ArgAction::Append, help_heading = "Filtering")]
include: Vec<String>,
/// Glob pattern for paths to exclude (can be specified multiple times)
#[arg(long, value_name = "PATTERN", action = clap::ArgAction::Append, help_heading = "Filtering")]
exclude: Vec<String>,
/// Read filter patterns from file
#[arg(long, value_name = "PATH", conflicts_with_all = ["include", "exclude"], help_heading = "Filtering")]
filter_file: Option<std::path::PathBuf>,
/// Only change entries whose modification time is at least this old (e.g. 30d, 12h)
#[arg(long, value_name = "DURATION", help_heading = "Filtering")]
modified_before: Option<String>,
/// Only change entries whose creation (birth) time is at least this old
#[arg(long, value_name = "DURATION", help_heading = "Filtering")]
#[cfg_attr(target_env = "musl", arg(hide = true))]
created_before: Option<String>,
/// Preview mode - show what would change without changing it
#[arg(long, value_name = "MODE", help_heading = "Filtering")]
dry_run: Option<common::DryRunMode>,
/// Print summary at the end
#[arg(long, help_heading = "Progress & output")]
summary: bool,
/// Quiet mode, don't report errors
#[arg(short = 'q', long = "quiet", help_heading = "Progress & output")]
quiet: bool,
/// Maximum number of open files, 0 means no limit, unspecified means 80% of system limit
#[arg(long, value_name = "N", help_heading = "Performance & throttling")]
max_open_files: Option<usize>,
/// Chunk size used to calculate number of I/O per file
///
/// Modifying this setting to a value > 0 is REQUIRED when using --iops-throttle.
#[arg(
long,
default_value = "0",
value_name = "SIZE",
help_heading = "Performance & throttling"
)]
chunk_size: u64,
#[command(flatten)]
common: common::cli::CommonArgs,
// TOCTOU safety
/// Print TOCTOU-safety verdict for this invocation and exit (0 = safe, 1 = not safe)
///
/// Analyzes whether the invocation is hardened against symlink/path-swap races
/// and exits without performing the chmod/chown operation.
#[arg(long, help_heading = "Security")]
toctou_check: bool,
/// Refuse to run unless the invocation uses the TOCTOU-hardened walk
///
/// Refuses non-Linux builds (rchm has no --dereference), kernels without openat2
/// (Linux 5.6+), and any operand that is not absolute and lexically normal (no
/// `.`/`..`/`//`; realpath output qualifies). Operand root opens then resolve with
/// openat2(RESOLVE_NO_SYMLINKS), so a symlink in any directory component fails closed (a
/// symlink operand itself is never followed — it is handled as the link object). Path
/// POLICY stays the caller's — lock paths down in the wrapper or sudo rule. See "Scope
/// of TOCTOU safety" in docs/tocttou.md. Intended to be pinned by a privileged wrapper
/// or an exact sudo rule; it does not make arbitrary trailing options or operands safe.
#[arg(long, conflicts_with = "toctou_check", help_heading = "Security")]
require_toctou_safe: bool,
/// Absolute path to the `getent` binary used to resolve user/group names
///
/// When set, name lookups spawn exactly this binary instead of searching PATH. Pin it in a
/// privileged wrapper or exact sudo rule so rchm resolves names via a known-good getent
/// regardless of the caller's PATH (e.g. `--getent-path=/usr/bin/getent`). May be given at
/// most once — duplicate rejection prevents an unsafe trailing-wildcard policy from being
/// overridden with an attacker-controlled path. When unset and running privileged, rchm
/// searches a fixed list of trusted system directories instead of PATH; numeric ids never
/// need getent at all.
#[arg(long, value_name = "PATH", action = clap::ArgAction::Append, help_heading = "Security")]
getent_path: Vec<std::path::PathBuf>,
/// Clear set-user-ID and set-group-ID bits on every selected non-symlink
///
/// Applies to non-symlinks targeted by an applicable --mode, --owner or --group rule.
/// The requested mode is computed first, then both set-ID bits are removed; the sticky bit
/// is unaffected. Intended to be pinned by privileged wrappers that must not create or
/// preserve set-ID files or directories.
#[arg(long, help_heading = "Security")]
no_setid: bool,
/// Path(s) to modify
#[arg()]
paths: Vec<std::path::PathBuf>,
}
fn build_settings(args: &Args) -> Result<common::chmod::Settings> {
let mode = args
.mode
.as_deref()
.map(common::chmod::parse_mode_dsl)
.transpose()?
.unwrap_or_default();
// reduce repeated --getent-path to at most one. rejecting a duplicate (rather than taking the
// last) is a security property: an unsafe sudo rule ending in a wildcard (`... *`) could
// otherwise let an attacker append a second --getent-path to override its pinned value.
let getent_path = match args.getent_path.as_slice() {
[] => None,
[path] => Some(path.clone()),
_ => {
return Err(anyhow!(
"--getent-path may be given at most once (a duplicate could override the path \
baked into a wildcard sudo rule)"
));
}
};
let getent =
common::chmod::GetentResolver::from_cli(getent_path, common::chmod::is_privileged())?;
let owner = args
.owner
.as_deref()
.map(|s| common::chmod::parse_owner_dsl(s, common::chmod::IdKind::User, &getent))
.transpose()?
.unwrap_or_default();
let group = args
.group
.as_deref()
.map(|s| common::chmod::parse_owner_dsl(s, common::chmod::IdKind::Group, &getent))
.transpose()?
.unwrap_or_default();
if mode.is_empty() && owner.is_empty() && group.is_empty() {
return Err(anyhow!(
"nothing to do: specify at least one of --mode, --group or --owner"
));
}
let filter = common::filter::FilterSettings::from_args(
args.filter_file.as_deref(),
&args.include,
&args.exclude,
)?;
let time_filter = common::filter::TimeFilter::from_cli_args(
args.modified_before.as_deref(),
args.created_before.as_deref(),
)?;
Ok(common::chmod::Settings {
mode,
owner,
group,
no_setid: args.no_setid,
fail_early: args.fail_early,
defer_dir_changes: args.defer_dir_changes,
filter,
time_filter,
dry_run: args.dry_run,
})
}
#[instrument]
async fn async_main(args: Args) -> Result<common::chmod::Summary> {
let settings = build_settings(&args)?;
if args.paths.is_empty() {
return Err(anyhow!(
"no paths given: specify at least one path to modify"
));
}
let mut join_set = tokio::task::JoinSet::new();
for path in args.paths {
let settings = settings.clone();
join_set.spawn(async move { common::chmod(&path, &settings).await });
}
let error_collector = common::error_collector::ErrorCollector::default();
let mut summary = common::chmod::Summary::default();
while let Some(res) = join_set.join_next().await {
match res? {
Ok(s) => summary = summary + s,
Err(error) => {
tracing::error!("{:#}", &error);
summary = summary + error.summary;
if args.fail_early {
if args.summary {
return Err(anyhow!("{}\n\n{}", error, &summary));
}
return Err(anyhow!("{}", error));
}
error_collector.push(error.source);
}
}
}
if let Some(err) = error_collector.into_error() {
if args.summary {
return Err(anyhow!("{:#}\n\n{}", err, &summary));
}
return Err(err);
}
Ok(summary)
}
fn main() -> Result<()> {
let args = Args::parse();
common::filter::reject_created_before_on_musl("rchm", args.created_before.as_deref())?;
// TOCTOU linter: must run before the async runtime starts.
// rchm has no --dereference flag; dereference is always false. Operands are
// passed so --require-toctou-safe can enforce the strict operand contract
// (absolute + lexically normal, resolved RESOLVE_NO_SYMLINKS); keeping the
// directories along them out of a less-privileged actor's write control
// remains the caller's responsibility (see the "Scope of TOCTOU safety"
// section of docs/tocttou.md).
common::toctou_check::enforce_or_exit(
false,
args.toctou_check,
args.require_toctou_safe,
&args.paths,
);
let dry_run_warnings = args.dry_run.map(|_| {
common::DryRunWarnings::new(
args.common.progress_requested(),
args.summary,
args.common.verbose,
false, // rchm has no --overwrite
!args.include.is_empty()
|| !args.exclude.is_empty()
|| args.filter_file.is_some()
|| args.modified_before.is_some()
|| args.created_before.is_some(),
false, // rchm has no destination
false, // rchm has no --ignore-existing
)
});
let is_dry_run = dry_run_warnings.is_some();
let func = {
let args = args.clone();
|| async_main(args)
};
let output = args
.common
.output_config(args.quiet, !is_dry_run && args.summary);
let runtime = args.common.runtime_config();
let throttle = args
.common
.throttle_config(args.max_open_files, args.chunk_size);
let tracing = common::TracingConfig::local("rchm");
let progress = if is_dry_run {
None
} else {
args.common
.user_progress_settings(common::progress::LocalProgressKind::Modify)
};
let res = common::run(progress, output, runtime, throttle, tracing, func);
if let Some(warnings) = dry_run_warnings {
warnings.print();
}
if res.is_none() {
std::process::exit(1);
}
Ok(())
}