Skip to main content

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}