common/toctou_check.rs
1//! TOCTOU-safety verdict logic for `--toctou-check` and `--require-toctou-safe`.
2//!
3//! This module provides the [`toctou_verdict`] function used by `--toctou-check` and
4//! `--require-toctou-safe` on every RCP tool. The verdict reflects whether the
5//! invocation uses the TOCTOU-hardened walk: `safe = !dereference && linux`.
6//!
7//! `--require-toctou-safe` additionally enforces the strict operand contract: every
8//! operand must be absolute and lexically normal ([`strict_operand_violation`]), and
9//! on Proceed the linter arms process-wide strict operand resolution
10//! ([`crate::safedir::enable_strict_operand_resolution`]) so every operand
11//! root/parent open resolves with `openat2(RESOLVE_NO_SYMLINKS)` — a symlink in any
12//! component of an operand path fails closed at the open. What remains the caller's
13//! responsibility is keeping the directories along the operand paths out of a
14//! less-privileged actor's *write* control (a writer can still rename real
15//! directories into place). See the "Scope of TOCTOU safety" section of
16//! `docs/tocttou.md` for the authoritative definition of the boundary.
17
18/// Normalized inputs used to compute a TOCTOU-safety verdict.
19///
20/// Each tool populates this from its parsed CLI flags. Tools without a
21/// `--dereference` flag (rchm, rrm) always pass `dereference: false`.
22#[derive(Debug, Clone)]
23pub struct VerdictInputs {
24 /// Whether `--dereference` / `-L` was requested (following symlinks).
25 pub dereference: bool,
26}
27
28/// The result of a TOCTOU-safety analysis.
29#[derive(Debug, Clone)]
30pub struct Verdict {
31 /// Whether the invocation is considered TOCTOU-safe.
32 pub safe: bool,
33 /// Human-readable reasons why the invocation is NOT safe (empty when `safe == true`).
34 pub reasons: Vec<String>,
35 /// Caveats that apply even when `safe == true` (trusted-boundary statements).
36 pub caveats: Vec<String>,
37}
38
39impl Verdict {
40 /// Render a human-readable summary of this verdict.
41 pub fn render(&self) -> String {
42 let mut out = String::new();
43 if self.safe {
44 out.push_str("TOCTOU status: SAFE\n");
45 } else {
46 out.push_str("TOCTOU status: NOT SAFE\n");
47 for reason in &self.reasons {
48 out.push_str(&format!(" Reason: {}\n", reason));
49 }
50 }
51 for caveat in &self.caveats {
52 out.push_str(&format!(" Note: {}\n", caveat));
53 }
54 out
55 }
56}
57
58/// Compute the TOCTOU-safety verdict for an invocation described by `inputs`.
59///
60/// An invocation is NOT safe only when:
61/// - `dereference` is true (`--dereference`/`-L` follows symlinks by request), or
62/// - the build target is non-Linux (the hardened path is Linux-only).
63///
64/// All other flags (`--delete`, remote, filtering) are now hardened and do NOT
65/// affect the verdict.
66///
67/// The verdict reflects only whether the hardened walk is in use. It does NOT — and
68/// cannot — vouch for the trust of the operand path's prefix; that is the caller's
69/// responsibility (see the "Scope of TOCTOU safety" section of `docs/tocttou.md`).
70/// The "safe" verdict therefore always carries a caveat stating the trusted-boundary
71/// assumption the caller must ensure.
72pub fn toctou_verdict(inputs: &VerdictInputs) -> Verdict {
73 let linux_build = cfg!(target_os = "linux");
74 let safe = !inputs.dereference && linux_build;
75
76 let mut reasons = Vec::new();
77 if inputs.dereference {
78 reasons.push(
79 "--dereference/-L follows symlinks by request, so a swapped link is followed \
80 — not hardened under privilege asymmetry"
81 .to_string(),
82 );
83 }
84 if !linux_build {
85 reasons
86 .push("the TOCTOU-hardened path is Linux-only; this build does not use it".to_string());
87 }
88
89 let caveats = vec![
90 "Hardening assumes the directory named on the command line (and the path components \
91 above it) are not modifiable by a less-privileged actor; it protects everything at or \
92 below the named root. Also assumes fs.protected_hardlinks=1 (Linux default)."
93 .to_string(),
94 ];
95
96 Verdict {
97 safe,
98 reasons,
99 caveats,
100 }
101}
102
103/// Returns why `path` violates the strict operand form required by
104/// `--require-toctou-safe`, or `None` when the form is acceptable.
105///
106/// The strict form is: absolute, and lexically normal — no `.` or `..`
107/// components and no empty (`//`) segments; a single trailing slash is allowed
108/// (it carries copy-into meaning for destinations). `realpath` output always
109/// satisfies it. The check is purely lexical: it guarantees the path *string*
110/// can only denote the object literally at that path, so a wrapper or sudo
111/// policy that validated the string validated the operand. The matching
112/// *resolution* guarantee (no symlink in any component at open time) is
113/// enforced separately via `openat2(RESOLVE_NO_SYMLINKS)` — see
114/// [`crate::safedir::enable_strict_operand_resolution`].
115pub fn strict_operand_violation(path: &std::path::Path) -> Option<String> {
116 use std::os::unix::ffi::OsStrExt;
117 let bytes = path.as_os_str().as_bytes();
118 if bytes.is_empty() {
119 return Some("operand is empty".to_string());
120 }
121 if bytes[0] != b'/' {
122 return Some(format!(
123 "operand {path:?} is not absolute; --require-toctou-safe requires absolute, \
124 fully-resolved operand paths (e.g. the output of realpath)"
125 ));
126 }
127 let segments: Vec<&[u8]> = bytes[1..].split(|byte| *byte == b'/').collect();
128 for (index, segment) in segments.iter().enumerate() {
129 let last = index + 1 == segments.len();
130 if segment.is_empty() && !last {
131 return Some(format!(
132 "operand {path:?} contains an empty path segment (`//`); \
133 --require-toctou-safe requires lexically normal operand paths \
134 (e.g. the output of realpath)"
135 ));
136 }
137 if *segment == b"." || *segment == b".." {
138 return Some(format!(
139 "operand {path:?} contains a `{}` component; --require-toctou-safe \
140 requires lexically normal operand paths (e.g. the output of realpath)",
141 String::from_utf8_lossy(segment)
142 ));
143 }
144 }
145 None
146}
147
148/// Result of the CLI linter check, indicating whether the caller should proceed.
149///
150/// When this is `Exit { code }`, the caller must print `output` and exit with
151/// `code` WITHOUT starting the operation. When this is `Proceed`, the caller
152/// continues normally.
153#[derive(Debug)]
154pub enum LinterAction {
155 /// Caller should print the message and exit with this code.
156 Exit { output: String, code: i32 },
157 /// Caller should proceed with the normal operation.
158 Proceed,
159}
160
161/// Run the TOCTOU CLI linter checks.
162///
163/// Call this in each tool's `main()` after argument parsing, before starting
164/// the async runtime / operation. Returns [`LinterAction::Exit`] when either
165/// `toctou_check` or `require_toctou_safe` demands early exit, or
166/// [`LinterAction::Proceed`] when the tool should run normally.
167///
168/// The verdict itself operates purely on the flags (`!dereference && linux`).
169/// Under `--require-toctou-safe` the linter additionally enforces the strict
170/// operand contract on `operands`: each must be absolute and lexically normal
171/// ([`strict_operand_violation`]), the kernel must support `openat2(2)`, and on
172/// Proceed strict operand resolution is armed process-wide
173/// ([`crate::safedir::enable_strict_operand_resolution`]) so every operand
174/// root/parent open resolves with `RESOLVE_NO_SYMLINKS`. What remains the
175/// caller's responsibility is keeping the directories along the operand paths
176/// out of a less-privileged actor's *write* control (a writer can still rename
177/// real directories); see the "Scope of TOCTOU safety" section of
178/// `docs/tocttou.md`.
179///
180/// # Parameters
181///
182/// - `dereference`: whether `--dereference`/`-L` is set (false for rchm/rrm).
183/// - `toctou_check`: whether `--toctou-check` was passed.
184/// - `require_toctou_safe`: whether `--require-toctou-safe` was passed.
185/// - `operands`: every path operand as written on the command line (for remote
186/// operands, the path part). rcpd passes `&[]` — its operands arrive from the
187/// master, which has already validated them.
188pub fn run_linter(
189 dereference: bool,
190 toctou_check: bool,
191 require_toctou_safe: bool,
192 operands: &[std::path::PathBuf],
193) -> LinterAction {
194 if !toctou_check && !require_toctou_safe {
195 return LinterAction::Proceed;
196 }
197
198 let inputs = VerdictInputs { dereference };
199 let verdict = toctou_verdict(&inputs);
200
201 if toctou_check {
202 // print verdict and exit — no operation performed. The exit code reflects
203 // only the verdict; strict-form notes below are informational, telling the
204 // caller whether --require-toctou-safe would accept these operands.
205 let code = if verdict.safe { 0 } else { 1 };
206 let mut output = verdict.render();
207 for operand in operands {
208 if let Some(violation) = strict_operand_violation(operand) {
209 output.push_str(&format!(
210 " Note: --require-toctou-safe would refuse this invocation: {}\n",
211 violation
212 ));
213 }
214 }
215 // the openat2 note only makes sense on Linux (where a pre-5.6 kernel is the reason strict
216 // mode is unavailable); on non-Linux the verdict already carries the "Linux-only" reason,
217 // and the Linux-specific "kernel lacks openat2" wording would just confuse.
218 if cfg!(target_os = "linux") && !crate::safedir::openat2_available() {
219 output.push_str(
220 " Note: --require-toctou-safe would refuse this invocation: the kernel \
221 lacks openat2(2) (Linux 5.6+), so strict operand resolution is unavailable\n",
222 );
223 }
224 return LinterAction::Exit { output, code };
225 }
226
227 // --require-toctou-safe mode: refuse when the hardened walk is not in use, when
228 // an operand violates the strict form, or when the kernel cannot enforce
229 // strict resolution.
230 let mut reasons: Vec<String> = verdict.reasons.clone();
231 if verdict.safe {
232 reasons.extend(operands.iter().filter_map(|p| strict_operand_violation(p)));
233 if !crate::safedir::openat2_available() {
234 reasons.push(
235 "the kernel lacks openat2(2) (Linux 5.6+), so strict operand resolution \
236 is unavailable"
237 .to_string(),
238 );
239 }
240 }
241 if !reasons.is_empty() {
242 let mut msg = "Refusing to run: invocation is not TOCTOU-safe.\n".to_string();
243 for reason in &reasons {
244 msg.push_str(&format!(" Reason: {}\n", reason));
245 }
246 return LinterAction::Exit {
247 output: msg,
248 code: 1,
249 };
250 }
251
252 // arm strict operand resolution for the rest of the process: every operand
253 // root/parent open now resolves with openat2(RESOLVE_NO_SYMLINKS).
254 crate::safedir::enable_strict_operand_resolution();
255 LinterAction::Proceed
256}
257
258/// Run the TOCTOU CLI linter and act on its verdict: print the message and exit the process when
259/// the linter demands it ([`LinterAction::Exit`]), or return so the caller proceeds
260/// ([`LinterAction::Proceed`]).
261///
262/// This is the print-and-exit half of [`run_linter`], shared verbatim by every tool's `main()` so
263/// the print/exit policy lives in one place (the testable verdict core stays in [`run_linter`] /
264/// [`toctou_verdict`]). Call it immediately after argument parsing, before the async runtime / any
265/// filesystem operation. `dereference` is `false` for tools without a `--dereference` flag
266/// (rchm/rrm/rlink). `operands` is every path operand as written (see [`run_linter`]).
267pub fn enforce_or_exit(
268 dereference: bool,
269 toctou_check: bool,
270 require_toctou_safe: bool,
271 operands: &[std::path::PathBuf],
272) {
273 match run_linter(dereference, toctou_check, require_toctou_safe, operands) {
274 LinterAction::Exit { output, code } => {
275 print!("{}", output);
276 std::process::exit(code);
277 }
278 LinterAction::Proceed => {}
279 }
280}
281
282#[cfg(test)]
283mod tests {
284 use super::*;
285
286 // ---------------------------------------------------------------------------
287 // Verdict tests
288 // ---------------------------------------------------------------------------
289
290 #[test]
291 fn no_dereference_is_safe() {
292 let v = toctou_verdict(&VerdictInputs { dereference: false });
293 if cfg!(target_os = "linux") {
294 assert!(v.safe, "no-dereference on Linux should be safe");
295 assert!(v.reasons.is_empty(), "no reasons expected for safe verdict");
296 } else {
297 // non-Linux: not safe due to platform
298 assert!(!v.safe);
299 }
300 assert!(
301 !v.caveats.is_empty(),
302 "caveats must be present even when safe"
303 );
304 }
305
306 #[test]
307 fn dereference_is_not_safe() {
308 let v = toctou_verdict(&VerdictInputs { dereference: true });
309 assert!(!v.safe, "dereference must make the verdict not-safe");
310 assert!(
311 !v.reasons.is_empty(),
312 "at least one reason must be present when not safe"
313 );
314 // the dereference reason must be in the list
315 assert!(
316 v.reasons
317 .iter()
318 .any(|r| r.contains("dereference") || r.contains("-L")),
319 "reason must mention dereference/-L, got: {:?}",
320 v.reasons
321 );
322 }
323
324 #[test]
325 fn caveats_always_present() {
326 for deref in [false, true] {
327 let v = toctou_verdict(&VerdictInputs { dereference: deref });
328 assert!(
329 !v.caveats.is_empty(),
330 "caveats must be present regardless of verdict (deref={})",
331 deref
332 );
333 // the trusted-boundary caveat must be there
334 assert!(
335 v.caveats
336 .iter()
337 .any(|c| c.contains("named on the command line")),
338 "trusted-boundary caveat must be present, got: {:?}",
339 v.caveats
340 );
341 }
342 }
343
344 #[test]
345 fn render_safe_contains_safe() {
346 let v = toctou_verdict(&VerdictInputs { dereference: false });
347 let rendered = v.render();
348 if cfg!(target_os = "linux") {
349 assert!(
350 rendered.contains("SAFE"),
351 "rendered output must contain SAFE: {rendered}"
352 );
353 }
354 }
355
356 #[test]
357 fn render_not_safe_contains_not_safe() {
358 let v = toctou_verdict(&VerdictInputs { dereference: true });
359 let rendered = v.render();
360 assert!(
361 rendered.contains("NOT SAFE"),
362 "rendered output must contain NOT SAFE: {rendered}"
363 );
364 }
365
366 // ---------------------------------------------------------------------------
367 // Strict operand form tests
368 // ---------------------------------------------------------------------------
369
370 #[test]
371 fn strict_form_accepts_absolute_normalized_paths() {
372 for ok in ["/a/b", "/a/b/", "/", "/a"] {
373 assert!(
374 strict_operand_violation(std::path::Path::new(ok)).is_none(),
375 "expected {ok:?} to be accepted"
376 );
377 }
378 }
379
380 #[test]
381 fn strict_form_rejects_relative_paths() {
382 for bad in ["a/b", ".", "..", "./a", "../a", ""] {
383 let violation = strict_operand_violation(std::path::Path::new(bad));
384 assert!(violation.is_some(), "expected {bad:?} to be rejected");
385 }
386 let msg = strict_operand_violation(std::path::Path::new("a/b")).unwrap();
387 assert!(
388 msg.contains("absolute"),
389 "relative-path message must mention absolute, got: {msg}"
390 );
391 }
392
393 #[test]
394 fn strict_form_rejects_dot_and_dotdot_components() {
395 for bad in ["/a/../b", "/a/./b", "/a/..", "/a/.", "/.."] {
396 let violation = strict_operand_violation(std::path::Path::new(bad));
397 assert!(violation.is_some(), "expected {bad:?} to be rejected");
398 }
399 let msg = strict_operand_violation(std::path::Path::new("/a/../b")).unwrap();
400 assert!(
401 msg.contains(".."),
402 "dotdot message must name the component, got: {msg}"
403 );
404 }
405
406 #[test]
407 fn strict_form_rejects_empty_segments() {
408 for bad in ["//a", "/a//b", "/a/b//"] {
409 let violation = strict_operand_violation(std::path::Path::new(bad));
410 assert!(violation.is_some(), "expected {bad:?} to be rejected");
411 }
412 }
413
414 // ---------------------------------------------------------------------------
415 // Linter operand-enforcement tests
416 // ---------------------------------------------------------------------------
417
418 #[cfg(target_os = "linux")]
419 #[test]
420 fn require_mode_rejects_bad_operand() {
421 let operands = vec![std::path::PathBuf::from("rel/path")];
422 match run_linter(false, false, true, &operands) {
423 LinterAction::Exit { output, code } => {
424 assert_eq!(code, 1, "bad operand must exit 1");
425 assert!(
426 output.contains("rel/path") && output.contains("absolute"),
427 "message must name the operand and the requirement, got: {output}"
428 );
429 }
430 LinterAction::Proceed => panic!("bad operand must not proceed"),
431 }
432 }
433
434 #[cfg(target_os = "linux")]
435 #[test]
436 fn require_mode_lists_every_bad_operand() {
437 let operands = vec![
438 std::path::PathBuf::from("rel/src"),
439 std::path::PathBuf::from("/ok/dst"),
440 std::path::PathBuf::from("/bad/../dst"),
441 ];
442 match run_linter(false, false, true, &operands) {
443 LinterAction::Exit { output, .. } => {
444 assert!(
445 output.contains("rel/src") && output.contains("/bad/../dst"),
446 "all violations must be listed, got: {output}"
447 );
448 }
449 LinterAction::Proceed => panic!("bad operands must not proceed"),
450 }
451 }
452
453 // NOTE: the tests where require mode PROCEEDS (and thereby arms the one-way
454 // process-global strict-resolution switch) live in tests/strict_resolution.rs —
455 // their own integration binary and therefore their own process, so the arming
456 // cannot leak into other lib tests under the plain `cargo test` harness (used
457 // by the nix checkPhase). Every test below stays on a refusal/check path and
458 // never arms the switch.
459
460 #[test]
461 fn require_mode_flag_refusal_suppresses_operand_reasons() {
462 // when the verdict itself is not safe (-L), the refusal lists only the verdict
463 // reasons — operand strict-form violations are not appended to the noise
464 let operands = vec![std::path::PathBuf::from("rel/path")];
465 match run_linter(true, false, true, &operands) {
466 LinterAction::Exit { output, code } => {
467 assert_eq!(code, 1);
468 assert!(
469 output.contains("dereference") || output.contains("-L"),
470 "the -L reason must be present, got: {output}"
471 );
472 assert!(
473 !output.contains("rel/path"),
474 "operand reasons must be suppressed when the flag verdict already \
475 refuses, got: {output}"
476 );
477 }
478 LinterAction::Proceed => panic!("-L must not proceed under require mode"),
479 }
480 }
481
482 #[cfg(target_os = "linux")]
483 #[test]
484 fn check_mode_keeps_verdict_but_notes_bad_operand() {
485 let operands = vec![std::path::PathBuf::from("rel/path")];
486 match run_linter(false, true, false, &operands) {
487 LinterAction::Exit { output, code } => {
488 assert_eq!(code, 0, "check-mode exit code must stay verdict-based");
489 assert!(
490 output.contains("SAFE"),
491 "verdict must be unchanged, got: {output}"
492 );
493 assert!(
494 output.contains("rel/path") && output.contains("--require-toctou-safe"),
495 "check mode must note the operand strict-form violation, got: {output}"
496 );
497 }
498 LinterAction::Proceed => panic!("check mode always exits"),
499 }
500 }
501}