#[derive(Debug, Clone)]
pub struct VerdictInputs {
pub dereference: bool,
}
#[derive(Debug, Clone)]
pub struct Verdict {
pub safe: bool,
pub reasons: Vec<String>,
pub caveats: Vec<String>,
}
impl Verdict {
pub fn render(&self) -> String {
let mut out = String::new();
if self.safe {
out.push_str("TOCTOU status: SAFE\n");
} else {
out.push_str("TOCTOU status: NOT SAFE\n");
for reason in &self.reasons {
out.push_str(&format!(" Reason: {}\n", reason));
}
}
for caveat in &self.caveats {
out.push_str(&format!(" Note: {}\n", caveat));
}
out
}
}
pub fn toctou_verdict(inputs: &VerdictInputs) -> Verdict {
let linux_build = cfg!(target_os = "linux");
let safe = !inputs.dereference && linux_build;
let mut reasons = Vec::new();
if inputs.dereference {
reasons.push(
"--dereference/-L follows symlinks by request, so a swapped link is followed \
— not hardened under privilege asymmetry"
.to_string(),
);
}
if !linux_build {
reasons
.push("the TOCTOU-hardened path is Linux-only; this build does not use it".to_string());
}
let caveats = vec![
"Hardening assumes the directory named on the command line (and the path components \
above it) are not modifiable by a less-privileged actor; it protects everything at or \
below the named root. Also assumes fs.protected_hardlinks=1 (Linux default)."
.to_string(),
];
Verdict {
safe,
reasons,
caveats,
}
}
pub fn strict_operand_violation(path: &std::path::Path) -> Option<String> {
use std::os::unix::ffi::OsStrExt;
let bytes = path.as_os_str().as_bytes();
if bytes.is_empty() {
return Some("operand is empty".to_string());
}
if bytes[0] != b'/' {
return Some(format!(
"operand {path:?} is not absolute; --require-toctou-safe requires absolute, \
fully-resolved operand paths (e.g. the output of realpath)"
));
}
let segments: Vec<&[u8]> = bytes[1..].split(|byte| *byte == b'/').collect();
for (index, segment) in segments.iter().enumerate() {
let last = index + 1 == segments.len();
if segment.is_empty() && !last {
return Some(format!(
"operand {path:?} contains an empty path segment (`//`); \
--require-toctou-safe requires lexically normal operand paths \
(e.g. the output of realpath)"
));
}
if *segment == b"." || *segment == b".." {
return Some(format!(
"operand {path:?} contains a `{}` component; --require-toctou-safe \
requires lexically normal operand paths (e.g. the output of realpath)",
String::from_utf8_lossy(segment)
));
}
}
None
}
#[derive(Debug)]
pub enum LinterAction {
Exit { output: String, code: i32 },
Proceed,
}
pub fn run_linter(
dereference: bool,
toctou_check: bool,
require_toctou_safe: bool,
operands: &[std::path::PathBuf],
) -> LinterAction {
if !toctou_check && !require_toctou_safe {
return LinterAction::Proceed;
}
let inputs = VerdictInputs { dereference };
let verdict = toctou_verdict(&inputs);
if toctou_check {
let code = if verdict.safe { 0 } else { 1 };
let mut output = verdict.render();
for operand in operands {
if let Some(violation) = strict_operand_violation(operand) {
output.push_str(&format!(
" Note: --require-toctou-safe would refuse this invocation: {}\n",
violation
));
}
}
if cfg!(target_os = "linux") && !crate::safedir::openat2_available() {
output.push_str(
" Note: --require-toctou-safe would refuse this invocation: the kernel \
lacks openat2(2) (Linux 5.6+), so strict operand resolution is unavailable\n",
);
}
return LinterAction::Exit { output, code };
}
let mut reasons: Vec<String> = verdict.reasons.clone();
if verdict.safe {
reasons.extend(operands.iter().filter_map(|p| strict_operand_violation(p)));
if !crate::safedir::openat2_available() {
reasons.push(
"the kernel lacks openat2(2) (Linux 5.6+), so strict operand resolution \
is unavailable"
.to_string(),
);
}
}
if !reasons.is_empty() {
let mut msg = "Refusing to run: invocation is not TOCTOU-safe.\n".to_string();
for reason in &reasons {
msg.push_str(&format!(" Reason: {}\n", reason));
}
return LinterAction::Exit {
output: msg,
code: 1,
};
}
crate::safedir::enable_strict_operand_resolution();
LinterAction::Proceed
}
pub fn enforce_or_exit(
dereference: bool,
toctou_check: bool,
require_toctou_safe: bool,
operands: &[std::path::PathBuf],
) {
match run_linter(dereference, toctou_check, require_toctou_safe, operands) {
LinterAction::Exit { output, code } => {
print!("{}", output);
std::process::exit(code);
}
LinterAction::Proceed => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn no_dereference_is_safe() {
let v = toctou_verdict(&VerdictInputs { dereference: false });
if cfg!(target_os = "linux") {
assert!(v.safe, "no-dereference on Linux should be safe");
assert!(v.reasons.is_empty(), "no reasons expected for safe verdict");
} else {
assert!(!v.safe);
}
assert!(
!v.caveats.is_empty(),
"caveats must be present even when safe"
);
}
#[test]
fn dereference_is_not_safe() {
let v = toctou_verdict(&VerdictInputs { dereference: true });
assert!(!v.safe, "dereference must make the verdict not-safe");
assert!(
!v.reasons.is_empty(),
"at least one reason must be present when not safe"
);
assert!(
v.reasons
.iter()
.any(|r| r.contains("dereference") || r.contains("-L")),
"reason must mention dereference/-L, got: {:?}",
v.reasons
);
}
#[test]
fn caveats_always_present() {
for deref in [false, true] {
let v = toctou_verdict(&VerdictInputs { dereference: deref });
assert!(
!v.caveats.is_empty(),
"caveats must be present regardless of verdict (deref={})",
deref
);
assert!(
v.caveats
.iter()
.any(|c| c.contains("named on the command line")),
"trusted-boundary caveat must be present, got: {:?}",
v.caveats
);
}
}
#[test]
fn render_safe_contains_safe() {
let v = toctou_verdict(&VerdictInputs { dereference: false });
let rendered = v.render();
if cfg!(target_os = "linux") {
assert!(
rendered.contains("SAFE"),
"rendered output must contain SAFE: {rendered}"
);
}
}
#[test]
fn render_not_safe_contains_not_safe() {
let v = toctou_verdict(&VerdictInputs { dereference: true });
let rendered = v.render();
assert!(
rendered.contains("NOT SAFE"),
"rendered output must contain NOT SAFE: {rendered}"
);
}
#[test]
fn strict_form_accepts_absolute_normalized_paths() {
for ok in ["/a/b", "/a/b/", "/", "/a"] {
assert!(
strict_operand_violation(std::path::Path::new(ok)).is_none(),
"expected {ok:?} to be accepted"
);
}
}
#[test]
fn strict_form_rejects_relative_paths() {
for bad in ["a/b", ".", "..", "./a", "../a", ""] {
let violation = strict_operand_violation(std::path::Path::new(bad));
assert!(violation.is_some(), "expected {bad:?} to be rejected");
}
let msg = strict_operand_violation(std::path::Path::new("a/b")).unwrap();
assert!(
msg.contains("absolute"),
"relative-path message must mention absolute, got: {msg}"
);
}
#[test]
fn strict_form_rejects_dot_and_dotdot_components() {
for bad in ["/a/../b", "/a/./b", "/a/..", "/a/.", "/.."] {
let violation = strict_operand_violation(std::path::Path::new(bad));
assert!(violation.is_some(), "expected {bad:?} to be rejected");
}
let msg = strict_operand_violation(std::path::Path::new("/a/../b")).unwrap();
assert!(
msg.contains(".."),
"dotdot message must name the component, got: {msg}"
);
}
#[test]
fn strict_form_rejects_empty_segments() {
for bad in ["//a", "/a//b", "/a/b//"] {
let violation = strict_operand_violation(std::path::Path::new(bad));
assert!(violation.is_some(), "expected {bad:?} to be rejected");
}
}
#[cfg(target_os = "linux")]
#[test]
fn require_mode_rejects_bad_operand() {
let operands = vec![std::path::PathBuf::from("rel/path")];
match run_linter(false, false, true, &operands) {
LinterAction::Exit { output, code } => {
assert_eq!(code, 1, "bad operand must exit 1");
assert!(
output.contains("rel/path") && output.contains("absolute"),
"message must name the operand and the requirement, got: {output}"
);
}
LinterAction::Proceed => panic!("bad operand must not proceed"),
}
}
#[cfg(target_os = "linux")]
#[test]
fn require_mode_lists_every_bad_operand() {
let operands = vec![
std::path::PathBuf::from("rel/src"),
std::path::PathBuf::from("/ok/dst"),
std::path::PathBuf::from("/bad/../dst"),
];
match run_linter(false, false, true, &operands) {
LinterAction::Exit { output, .. } => {
assert!(
output.contains("rel/src") && output.contains("/bad/../dst"),
"all violations must be listed, got: {output}"
);
}
LinterAction::Proceed => panic!("bad operands must not proceed"),
}
}
#[test]
fn require_mode_flag_refusal_suppresses_operand_reasons() {
let operands = vec![std::path::PathBuf::from("rel/path")];
match run_linter(true, false, true, &operands) {
LinterAction::Exit { output, code } => {
assert_eq!(code, 1);
assert!(
output.contains("dereference") || output.contains("-L"),
"the -L reason must be present, got: {output}"
);
assert!(
!output.contains("rel/path"),
"operand reasons must be suppressed when the flag verdict already \
refuses, got: {output}"
);
}
LinterAction::Proceed => panic!("-L must not proceed under require mode"),
}
}
#[cfg(target_os = "linux")]
#[test]
fn check_mode_keeps_verdict_but_notes_bad_operand() {
let operands = vec![std::path::PathBuf::from("rel/path")];
match run_linter(false, true, false, &operands) {
LinterAction::Exit { output, code } => {
assert_eq!(code, 0, "check-mode exit code must stay verdict-based");
assert!(
output.contains("SAFE"),
"verdict must be unchanged, got: {output}"
);
assert!(
output.contains("rel/path") && output.contains("--require-toctou-safe"),
"check mode must note the operand strict-form violation, got: {output}"
);
}
LinterAction::Proceed => panic!("check mode always exits"),
}
}
}