#![cfg(feature = "cli")]
#![allow(clippy::unwrap_used, clippy::panic, clippy::expect_used)]
use std::path::Path;
use std::process::Command;
fn afdata() -> Command {
Command::new(env!("CARGO_BIN_EXE_afdata"))
}
fn run(args: &[&str]) -> std::process::Output {
afdata().args(args).output().expect("failed to run afdata")
}
fn json_stderr(output: &std::process::Output) -> serde_json::Value {
serde_json::from_slice(&output.stderr)
.unwrap_or_else(|err| panic!("stderr is not JSON: {err}: {:?}", output.stderr))
}
fn error_code(output: &std::process::Output) -> String {
json_stderr(output)["error"]["code"]
.as_str()
.unwrap_or_else(|| panic!("no error.code in {:?}", output.stderr))
.to_string()
}
fn assert_guard_ok(output: &std::process::Output, expected: &Path) {
assert!(output.status.success(), "{output:?}");
assert!(output.stderr.is_empty(), "{output:?}");
let stdout = String::from_utf8(output.stdout.clone()).expect("stdout is not UTF-8");
assert!(!stdout.ends_with('\n'), "{stdout:?}");
assert_eq!(Path::new(&stdout), plain_form(expected), "{stdout:?}");
}
#[cfg(windows)]
fn plain_form(path: &Path) -> std::path::PathBuf {
use std::ffi::OsString;
use std::path::{Component, Prefix};
let mut components = path.components();
let Some(Component::Prefix(prefix)) = components.next() else {
return path.to_path_buf();
};
let Prefix::VerbatimDisk(letter) = prefix.kind() else {
return path.to_path_buf();
};
let mut plain = OsString::from(format!("{}:", letter as char));
plain.push(components.as_path().as_os_str());
std::path::PathBuf::from(plain)
}
#[cfg(not(windows))]
fn plain_form(path: &Path) -> &Path {
path
}
fn assert_guard_failed(output: &std::process::Output, expected_code: &str) {
assert_eq!(output.status.code(), Some(1), "{output:?}");
assert!(
output.stdout.is_empty(),
"guard leaked to stdout on failure: {output:?}"
);
assert_eq!(error_code(output), expected_code, "{output:?}");
}
#[test]
fn path_type_accepts_a_real_directory_outside_the_reject_set() {
let dir = tempfile::tempdir().unwrap();
let elsewhere = dir.path().join("elsewhere");
std::fs::create_dir(&elsewhere).unwrap();
let output = run(&["guard", "path", elsewhere.to_str().unwrap()]);
let expected = std::fs::canonicalize(&elsewhere).unwrap();
assert_guard_ok(&output, &expected);
}
#[test]
fn empty_value_is_rejected_without_touching_the_filesystem() {
let output = run(&["guard", "path", ""]);
assert_guard_failed(&output, "guard_empty_value");
}
#[test]
fn value_containing_a_newline_is_rejected() {
let output = run(&["guard", "path", "line-one\nline-two"]);
assert_guard_failed(&output, "guard_contains_newline");
}
#[test]
fn an_all_whitespace_value_is_rejected_as_empty() {
let output = run(&["guard", "path", " "]);
assert_guard_failed(&output, "guard_empty_value");
}
#[test]
fn a_value_starting_with_a_dash_survives_the_argv_boundary_as_an_absolute_path() {
let dir = tempfile::tempdir().unwrap();
let dashed = dir.path().join("-rf");
std::fs::create_dir(&dashed).unwrap();
let bare = afdata()
.current_dir(dir.path())
.args(["guard", "path", "-rf"])
.output()
.unwrap();
assert_eq!(bare.status.code(), Some(2), "{bare:?}");
assert!(bare.stdout.is_empty(), "{bare:?}");
let separated = afdata()
.current_dir(dir.path())
.args(["guard", "path", "--", "-rf"])
.output()
.unwrap();
assert_guard_ok(&separated, &std::fs::canonicalize(&dashed).unwrap());
}
#[test]
fn a_value_with_spaces_and_a_quote_passes_through_intact() {
let dir = tempfile::tempdir().unwrap();
let awkward = dir.path().join("a b'c d");
std::fs::create_dir(&awkward).unwrap();
let output = run(&["guard", "path", awkward.to_str().unwrap()]);
assert_guard_ok(&output, &std::fs::canonicalize(&awkward).unwrap());
}
#[test]
fn a_home_reached_through_a_symlink_is_still_in_the_reject_set() {
let dir = tempfile::tempdir().unwrap();
let real_home = dir.path().join("real_home");
std::fs::create_dir(&real_home).unwrap();
let home_link = dir.path().join("home_link");
#[cfg(unix)]
std::os::unix::fs::symlink(&real_home, &home_link).unwrap();
#[cfg(windows)]
std::os::windows::fs::symlink_dir(&real_home, &home_link).unwrap();
let output = afdata()
.env(
if cfg!(windows) { "USERPROFILE" } else { "HOME" },
&home_link,
)
.args(["guard", "path", real_home.to_str().unwrap()])
.output()
.unwrap();
assert_guard_failed(&output, "guard_rejected_target");
}
#[test]
fn filesystem_root_is_rejected() {
#[cfg(unix)]
let root = "/";
#[cfg(windows)]
let root = "C:\\";
let output = run(&["guard", "path", root]);
assert_guard_failed(&output, "guard_rejected_target");
}
#[test]
fn home_directory_and_its_parent_are_rejected() {
let home = std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" })
.map(std::path::PathBuf::from);
let Some(home) = home else {
return;
};
let output = run(&["guard", "path", home.to_str().unwrap()]);
assert_guard_failed(&output, "guard_rejected_target");
if let Some(parent) = home.parent() {
let output = run(&["guard", "path", parent.to_str().unwrap()]);
assert_guard_failed(&output, "guard_rejected_target");
}
}
#[test]
fn cwd_itself_and_its_parent_are_rejected() {
let dir = tempfile::tempdir().unwrap();
let nested = dir.path().join("nested");
std::fs::create_dir(&nested).unwrap();
let output = afdata()
.current_dir(&nested)
.args(["guard", "cwd_path", "."])
.output()
.unwrap();
assert_guard_failed(&output, "guard_rejected_target");
let output = afdata()
.current_dir(&nested)
.args(["guard", "path", nested.to_str().unwrap()])
.output()
.unwrap();
assert_guard_failed(&output, "guard_rejected_target");
let output = afdata()
.current_dir(&nested)
.args(["guard", "path", dir.path().to_str().unwrap()])
.output()
.unwrap();
assert_guard_failed(&output, "guard_rejected_target");
}
#[test]
fn cwd_path_accepts_children_and_rejects_outsiders() {
let cwd_dir = tempfile::tempdir().unwrap();
let child = cwd_dir.path().join("child");
std::fs::create_dir(&child).unwrap();
let inside = afdata()
.current_dir(cwd_dir.path())
.args(["guard", "cwd_path", "child"])
.output()
.unwrap();
let expected = std::fs::canonicalize(&child).unwrap();
assert_guard_ok(&inside, &expected);
let outside_dir = tempfile::tempdir().unwrap();
let outside = afdata()
.current_dir(cwd_dir.path())
.args(["guard", "cwd_path", outside_dir.path().to_str().unwrap()])
.output()
.unwrap();
assert_guard_failed(&outside, "guard_outside_containment");
}
#[test]
fn cwd_path_follows_a_mid_script_cd() {
let root = tempfile::tempdir().unwrap();
let first = root.path().join("first");
let second = root.path().join("second");
std::fs::create_dir(&first).unwrap();
std::fs::create_dir(&second).unwrap();
let first_child = first.join("only-under-first");
std::fs::create_dir(&first_child).unwrap();
let output = afdata()
.current_dir(&second)
.args(["guard", "cwd_path", first_child.to_str().unwrap()])
.output()
.unwrap();
assert_guard_failed(&output, "guard_outside_containment");
}
struct RealSystemTempChild {
path: std::path::PathBuf,
}
impl RealSystemTempChild {
fn new(name: &str) -> Self {
#[cfg(target_os = "macos")]
let base = Path::new("/private/tmp");
#[cfg(all(unix, not(target_os = "macos")))]
let base = Path::new("/tmp");
#[cfg(windows)]
let base = Path::new(r"C:\Windows\Temp");
let path = base.join(format!("afdata-guard-e2e-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&path);
std::fs::create_dir_all(&path).unwrap_or_else(|err| {
panic!("failed to create fixture under the real system temp area {base:?}: {err}")
});
Self { path }
}
fn path(&self) -> &Path {
&self.path
}
}
impl Drop for RealSystemTempChild {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.path);
}
}
#[test]
fn tmp_path_recognizes_the_real_temp_area_regardless_of_spoofed_tmpdir_env() {
let real = RealSystemTempChild::new("real-root");
let child = real.path().join("work");
std::fs::create_dir(&child).unwrap();
let expected = std::fs::canonicalize(&child).unwrap();
let spoofed_target = std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" })
.map(std::path::PathBuf::from);
for tmpdir_override in [
spoofed_target
.as_deref()
.map(|p| p.to_str().unwrap().to_string()),
Some(String::new()),
]
.into_iter()
.flatten()
{
let output = afdata()
.env("TMPDIR", &tmpdir_override)
.env("TMP", &tmpdir_override)
.env("TEMP", &tmpdir_override)
.args(["guard", "tmp_path", child.to_str().unwrap()])
.output()
.unwrap();
assert_guard_ok(&output, &expected);
}
let output = afdata()
.env_remove("TMPDIR")
.env_remove("TMP")
.env_remove("TEMP")
.args(["guard", "tmp_path", child.to_str().unwrap()])
.output()
.unwrap();
assert_guard_ok(&output, &expected);
}
#[test]
fn tmp_path_rejects_a_mktemp_result_from_a_tmpdir_pointed_at_home() {
let Some(home) = std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" })
.map(std::path::PathBuf::from)
else {
return;
};
let fake_mktemp_result = home.join(format!("afdata-guard-e2e-fake-tmp-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&fake_mktemp_result);
std::fs::create_dir(&fake_mktemp_result).unwrap();
let output = afdata()
.env("TMPDIR", &home)
.env("TMP", &home)
.env("TEMP", &home)
.args(["guard", "tmp_path", fake_mktemp_result.to_str().unwrap()])
.output()
.unwrap();
std::fs::remove_dir_all(&fake_mktemp_result).unwrap();
assert_guard_failed(&output, "guard_outside_containment");
}
#[test]
fn tmp_path_rejects_a_real_directory_outside_every_root() {
let target = Path::new(env!("CARGO_MANIFEST_DIR")).join("cli");
let output = run(&["guard", "tmp_path", target.to_str().unwrap()]);
assert_guard_failed(&output, "guard_outside_containment");
}
#[cfg(target_os = "macos")]
#[test]
fn tmp_path_recognizes_var_tmp_through_its_symlink_to_private_var_tmp() {
let output = run(&[
"guard",
"tmp_path",
"/var/tmp/afdata-guard-e2e-nonexistent-probe",
]);
let expected = Path::new("/private/var/tmp/afdata-guard-e2e-nonexistent-probe");
assert_guard_ok(&output, expected);
}
#[cfg(unix)]
#[test]
fn final_symlink_segment_is_guarded_not_its_target() {
let dir = tempfile::tempdir().unwrap();
let target_dir = dir.path().join("target_dir");
std::fs::create_dir(&target_dir).unwrap();
let link = dir.path().join("link_name");
std::os::unix::fs::symlink(&target_dir, &link).unwrap();
let output = run(&["guard", "path", link.to_str().unwrap()]);
let canonical_dir = std::fs::canonicalize(dir.path()).unwrap();
assert_guard_ok(&output, &canonical_dir.join("link_name"));
}
#[cfg(unix)]
#[test]
fn a_symlink_inside_the_temp_area_pointing_out_of_it_is_rejected() {
let real = RealSystemTempChild::new("escape-link");
let escape_target = Path::new(env!("CARGO_MANIFEST_DIR")).join("cli");
let link = real.path().join("escape_link");
std::os::unix::fs::symlink(&escape_target, &link).unwrap();
let output = run(&["guard", "tmp_path", link.to_str().unwrap()]);
assert_guard_failed(&output, "guard_symlink_escapes_containment");
assert!(escape_target.exists());
}
#[test]
fn under_root_accepts_a_child_and_rejects_an_outsider() {
let anchor = tempfile::tempdir().unwrap();
let child = anchor.path().join("build");
std::fs::create_dir(&child).unwrap();
let inside = run(&[
"guard",
"path",
child.to_str().unwrap(),
"--under",
anchor.path().to_str().unwrap(),
]);
assert_guard_ok(&inside, &std::fs::canonicalize(&child).unwrap());
let outside = tempfile::tempdir().unwrap();
let rejected = run(&[
"guard",
"path",
outside.path().to_str().unwrap(),
"--under",
anchor.path().to_str().unwrap(),
]);
assert_guard_failed(&rejected, "guard_outside_containment");
}
#[test]
fn a_blank_under_root_is_rejected_rather_than_silently_meaning_cwd() {
let anchor = tempfile::tempdir().unwrap();
let child = anchor.path().join("build");
std::fs::create_dir(&child).unwrap();
for blank in ["", " "] {
let output = afdata()
.current_dir(anchor.path())
.args(["guard", "path", child.to_str().unwrap(), "--under", blank])
.output()
.unwrap();
assert_guard_failed(&output, "guard_invalid_root");
}
}
#[test]
fn a_traversal_segment_is_refused_before_normalization_can_launder_it() {
let anchor = tempfile::tempdir().unwrap();
let inner = anchor.path().join("public");
std::fs::create_dir(&inner).unwrap();
let escaped = inner.join("..");
let output = run(&[
"guard",
"path",
escaped.to_str().unwrap(),
"--under",
anchor.path().to_str().unwrap(),
]);
assert_guard_failed(&output, "guard_traversal_segment");
let root_itself = run(&[
"guard",
"path",
anchor.path().to_str().unwrap(),
"--under",
anchor.path().to_str().unwrap(),
]);
assert_guard_failed(&root_itself, "guard_outside_containment");
}
#[test]
fn a_traversal_segment_is_refused_for_every_type_and_position() {
let anchor = tempfile::tempdir().unwrap();
let inner = anchor.path().join("nested");
std::fs::create_dir(&inner).unwrap();
for value in [
inner.join("..").to_str().unwrap().to_string(),
inner.join("../sibling").to_str().unwrap().to_string(),
"../relative".to_string(),
] {
for guard_type in ["path", "tmp_path", "cwd_path"] {
let output = afdata()
.current_dir(anchor.path())
.args(["guard", guard_type, &value])
.output()
.unwrap();
assert_guard_failed(&output, "guard_traversal_segment");
}
}
}
#[test]
fn a_traversal_segment_inside_the_under_root_is_accepted() {
let anchor = tempfile::tempdir().unwrap();
let inner = anchor.path().join("public");
let child = inner.join("blog");
std::fs::create_dir_all(&child).unwrap();
let root_via_traversal = inner.join("..").join("public");
let output = run(&[
"guard",
"path",
child.to_str().unwrap(),
"--under",
root_via_traversal.to_str().unwrap(),
]);
assert_guard_ok(&output, &std::fs::canonicalize(&child).unwrap());
}
#[test]
fn extra_positional_arguments_are_a_usage_error() {
let output = run(&["guard", "path", "one", "two"]);
assert_eq!(output.status.code(), Some(2), "{output:?}");
assert!(output.stdout.is_empty(), "{output:?}");
assert_eq!(
error_code(&output),
"cli_unexpected_positional",
"{output:?}"
);
}
#[test]
fn missing_value_argument_is_a_usage_error() {
let output = run(&["guard", "path"]);
assert_eq!(output.status.code(), Some(2), "{output:?}");
assert!(output.stdout.is_empty(), "{output:?}");
}
#[test]
fn unregistered_type_is_a_usage_error() {
let output = run(&["guard", "bogus", "/tmp/x"]);
assert_eq!(output.status.code(), Some(2), "{output:?}");
assert!(output.stdout.is_empty(), "{output:?}");
assert_eq!(
error_code(&output),
"cli_invalid_argument_value",
"{output:?}"
);
}
#[test]
fn guard_rejects_a_non_default_output_argument() {
let output = run(&["guard", "path", "/tmp", "--output", "json"]);
assert_eq!(output.status.code(), Some(2), "{output:?}");
assert_eq!(
error_code(&output),
"cli_unregistered_combination",
"{output:?}"
);
}
#[test]
fn guard_rejects_a_non_default_output_to_argument() {
let output = run(&["guard", "path", "/tmp", "--output-to", "stdout"]);
assert_eq!(output.status.code(), Some(2), "{output:?}");
assert_eq!(
error_code(&output),
"cli_unregistered_combination",
"{output:?}"
);
}
#[test]
fn a_rejected_target_is_not_named_in_stdout_for_a_downstream_verb_to_consume() {
let dir = tempfile::tempdir().unwrap();
let sentinel = dir.path().join("sentinel.txt");
std::fs::write(&sentinel, b"do not delete me").unwrap();
let output = afdata()
.current_dir(dir.path())
.args(["guard", "cwd_path", "."])
.output()
.unwrap();
assert_guard_failed(&output, "guard_rejected_target");
assert!(
sentinel.exists(),
"fixture must survive a rejected guard call"
);
assert_eq!(std::fs::read(&sentinel).unwrap(), b"do not delete me");
}