#![allow(
clippy::expect_used,
clippy::unwrap_used,
clippy::panic,
clippy::indexing_slicing,
clippy::too_many_lines,
clippy::similar_names,
// Debug formatting of paths is intentional: a failing vector may
// contain NUL / control bytes that Display would render invisibly.
clippy::unnecessary_debug_formatting
)]
use std::path::{Component, Path, PathBuf};
use csaf_core::path_security::safe_join;
const BASE: &str = "/var/lib/csaf-crud/csaf";
fn base() -> PathBuf {
PathBuf::from(BASE)
}
#[track_caller]
fn assert_rejected(label: &str, input: &str) {
let got = safe_join(&base(), input);
assert!(
got.is_none(),
"{label}: expected REJECTION for {input:?}, got accepted as {got:?}"
);
}
#[track_caller]
fn assert_accepted_under_base(label: &str, input: &str) {
let got = safe_join(&base(), input);
let resolved =
got.unwrap_or_else(|| panic!("{label}: expected ACCEPT for {input:?}, got rejected"));
assert!(
resolved.starts_with(base()),
"{label}: result {resolved:?} escapes base {BASE} for input {input:?}"
);
assert!(
!resolved
.components()
.any(|c| matches!(c, Component::ParentDir)),
"{label}: result {resolved:?} still contains `..` for input {input:?}"
);
}
#[test]
fn happy_simple_filename() {
assert_accepted_under_base("happy_simple_filename", "advisory.json");
}
#[test]
fn happy_year_subdir() {
assert_accepted_under_base("happy_year_subdir", "2026/001/ndaal-sa-2026-001.json");
}
#[test]
fn happy_deep_nesting() {
assert_accepted_under_base("happy_deep_nesting", "a/b/c/d/e/f/g/h/i/j/k.json");
}
#[test]
fn happy_curdir_segments_collapse() {
assert_accepted_under_base("happy_curdir_segments_collapse", "./2026/./001/./adv.json");
}
#[test]
fn happy_dotfile() {
assert_accepted_under_base("happy_dotfile", ".hidden");
}
#[test]
fn happy_long_filename() {
let long_name = "a".repeat(255);
assert_accepted_under_base("happy_long_filename", &long_name);
}
#[test]
fn happy_path_with_special_chars() {
assert_accepted_under_base("happy_path_with_special_chars", "name with spaces.json");
assert_accepted_under_base("happy_path_with_unicode", "ç-é-ñ.json");
assert_accepted_under_base("happy_path_with_emoji", "🚀.json");
}
#[test]
fn cs_absolute_unix_root() {
assert_rejected("cs_absolute_unix_root", "/etc/passwd");
}
#[test]
fn cs_absolute_unix_root_dot() {
assert_rejected("cs_absolute_unix_root_dot", "/./etc/passwd");
}
#[test]
fn cs_absolute_unix_double_slash() {
assert_rejected("cs_absolute_unix_double_slash", "//etc/passwd");
}
#[test]
fn cs_parent_one_level() {
assert_rejected("cs_parent_one_level", "../etc/passwd");
}
#[test]
fn cs_parent_multi_level() {
assert_rejected("cs_parent_multi_level", "../../../../../../etc/passwd");
}
#[test]
fn cs_parent_then_descend() {
assert_rejected("cs_parent_then_descend", "../csaf-other/secret.json");
}
#[test]
fn cs_descend_then_escape() {
assert_rejected("cs_descend_then_escape", "2026/../../etc/passwd");
}
#[test]
fn cs_alternating_dots() {
assert_rejected("cs_alternating_dots", "./../foo/../../bar");
}
#[test]
fn cs_only_dots() {
assert_rejected("cs_only_dots", "..");
assert_rejected("cs_only_dots_doubled", "../..");
}
#[test]
fn cs_curdir_only() {
assert_accepted_under_base("cs_curdir_only", ".");
}
#[test]
fn cs_empty_string() {
assert_accepted_under_base("cs_empty_string", "");
}
#[test]
fn cs_trailing_slash() {
assert_accepted_under_base("cs_trailing_slash", "2026/");
}
#[test]
fn cs_trailing_parent() {
assert_rejected("cs_trailing_parent_only", "..");
assert_accepted_under_base("cs_trailing_parent_after_descent", "2026/..");
}
#[test]
fn cs_nul_leading() {
assert_rejected("cs_nul_leading", "\0advisory.json");
}
#[test]
fn cs_nul_trailing() {
assert_rejected("cs_nul_trailing", "advisory.json\0");
}
#[test]
fn cs_nul_embedded() {
assert_rejected("cs_nul_embedded", "advisory\0.json");
}
#[test]
fn cs_nul_after_escape() {
assert_rejected("cs_nul_after_escape", "../etc/passwd\0.json");
}
#[test]
fn cs_nul_after_safe_segment() {
assert_rejected("cs_nul_after_safe_segment", "2026/001\0.json");
}
#[test]
fn cs_symlink_name_is_safe() {
assert_accepted_under_base("cs_symlink_name_is_safe", "etc-passwd-link");
}
#[test]
fn cs_proc_self_environ_relative() {
assert_accepted_under_base("cs_proc_self_environ_relative", "proc/self/environ");
}
#[test]
fn cs_absolute_proc_self_environ() {
assert_rejected("cs_absolute_proc_self_environ", "/proc/self/environ");
}
#[test]
fn owasp_classic() {
assert_rejected("owasp_classic", "../../../etc/passwd");
}
#[test]
fn owasp_url_encoded_slash() {
assert_accepted_under_base("owasp_url_encoded_slash", "..%2f..%2fetc%2fpasswd");
}
#[test]
fn owasp_double_url_encoded() {
assert_accepted_under_base("owasp_double_url_encoded", "%252e%252e%252fetc");
}
#[test]
fn owasp_overlong_utf8_dot() {
assert_accepted_under_base("owasp_overlong_utf8_dot", "\u{2024}\u{2024}/etc");
}
#[test]
fn owasp_windows_backslash_traversal_on_unix() {
#[cfg(unix)]
assert_accepted_under_base("owasp_windows_backslash_traversal_on_unix", "..\\..\\etc");
}
#[test]
fn owasp_unc_path_unix() {
#[cfg(unix)]
assert_accepted_under_base("owasp_unc_path_unix", "\\\\server\\share\\file");
}
#[test]
fn owasp_drive_letter_unix() {
#[cfg(unix)]
assert_accepted_under_base("owasp_drive_letter_unix", "C:/etc/passwd");
}
#[cfg(windows)]
#[test]
fn owasp_drive_letter_windows() {
assert_rejected("owasp_drive_letter_windows", "C:\\etc\\passwd");
}
#[cfg(windows)]
#[test]
fn owasp_unc_path_windows() {
assert_rejected("owasp_unc_path_windows", "\\\\server\\share\\file");
}
#[test]
fn owasp_null_byte_truncation() {
assert_rejected("owasp_null_byte_truncation", "../../../etc/passwd\0.jpg");
}
#[test]
fn owasp_mixed_separators() {
#[cfg(unix)]
assert_accepted_under_base("owasp_mixed_separators_unix", "..\\..\\../etc/passwd");
#[cfg(windows)]
assert_rejected("owasp_mixed_separators_windows", "..\\..\\../etc/passwd");
}
#[test]
fn csaf_tracking_id_traversal() {
assert_rejected("csaf_tracking_id_traversal", "../../etc/passwd");
}
#[test]
fn csaf_tracking_id_absolute() {
assert_rejected("csaf_tracking_id_absolute", "/etc/passwd");
}
#[test]
fn csaf_index_txt_path_with_escape() {
assert_rejected(
"csaf_index_txt_path_with_escape",
"2026/../../../../tmp/pwned.json",
);
}
#[test]
fn csaf_advisory_id_with_curdir() {
assert_accepted_under_base("csaf_advisory_id_with_curdir", "./ndaal-sa-2026-044.json");
}
#[test]
fn csaf_advisory_id_legitimate_dot() {
assert_accepted_under_base("csaf_advisory_id_legitimate_dot", "v1.2.3-advisory.json");
}
#[test]
fn csaf_atom_id_with_uri_fragment() {
assert_accepted_under_base(
"csaf_atom_id_with_uri_fragment",
"https:%2F%2Fexample.com%2Fa%2Fb",
);
}
#[test]
fn csaf_extremely_deep_traversal() {
let attack = "../".repeat(32) + "etc/passwd";
assert_rejected("csaf_extremely_deep_traversal", &attack);
}
#[test]
fn unicode_combining_acute_dot() {
let nfd = "e\u{0301}.json";
assert_accepted_under_base("unicode_combining_acute_dot", nfd);
}
#[test]
fn unicode_rtl_override() {
let rtl = "advisory\u{202E}json.txt";
assert_accepted_under_base("unicode_rtl_override", rtl);
}
#[test]
fn unicode_fullwidth_dot_segments() {
let fullwidth = "\u{FF0E}\u{FF0E}/etc/passwd";
assert_accepted_under_base("unicode_fullwidth_dot_segments", fullwidth);
}
#[test]
fn unicode_zero_width_joiner() {
let zwj = ".\u{200D}./etc/passwd";
assert_accepted_under_base("unicode_zero_width_joiner", zwj);
}
#[test]
fn unicode_homoglyph_slash() {
let homoglyph = "etc\u{2215}passwd";
assert_accepted_under_base("unicode_homoglyph_slash", homoglyph);
}
#[test]
fn property_every_traversal_depth_rejected() {
for depth in 1..=64 {
let attack = "../".repeat(depth) + "etc/passwd";
assert_rejected(&format!("property_traversal_depth_{depth}"), &attack);
}
}
#[test]
fn property_balanced_pop_then_descend_rejected() {
for n in 1..=16 {
let mut attack = String::new();
for _ in 0..n {
attack.push_str("a/../");
}
attack.push_str("../etc/passwd");
assert_rejected(&format!("property_balanced_pop_then_descend_n{n}"), &attack);
}
}
#[test]
fn property_balanced_pop_stays_at_base_accepted() {
for n in 1..=16 {
let mut path = String::new();
for _ in 0..n {
path.push_str("a/../");
}
path.push_str("etc/passwd");
assert_accepted_under_base(&format!("property_balanced_pop_n{n}"), &path);
}
}
#[test]
fn property_nul_at_every_position_rejected() {
let benign = "advisory-2026-001.json";
for pos in 0..=benign.len() {
let mut s = String::with_capacity(benign.len() + 1);
s.push_str(&benign[..pos]);
s.push('\0');
s.push_str(&benign[pos..]);
assert_rejected(&format!("property_nul_pos_{pos}"), &s);
}
}
#[test]
fn idempotence_double_safe_join_is_consistent() {
let inputs = [
"2026/001/adv.json",
"deeply/nested/file.json",
"./curdir/then/file.json",
];
for input in inputs {
let first = safe_join(&base(), input);
let second = safe_join(&base(), input);
assert_eq!(first, second, "non-idempotent for {input:?}");
}
}
#[test]
fn post_condition_accepted_paths_have_no_parent_dir_component() {
let inputs = [
"a/b/c.json",
"./a/./b/./c.json",
"x/y/../z/file.json",
"x/y/../../",
];
for input in inputs {
if let Some(resolved) = safe_join(&base(), input) {
assert!(
!resolved
.components()
.any(|c| matches!(c, Component::ParentDir)),
"post-walk path {resolved:?} still has ParentDir for input {input:?}"
);
}
}
}
#[test]
fn post_condition_accepted_paths_start_with_base() {
let inputs = [
"a.json",
"./a.json",
"deep/nested/dir/a.json",
"a/../b.json",
];
for input in inputs {
if let Some(resolved) = safe_join(&base(), input) {
assert!(
resolved.starts_with(base()),
"accepted {resolved:?} does not start with base for input {input:?}"
);
}
}
}
#[test]
fn base_with_trailing_slash_equivalent() {
let base_a = Path::new(BASE);
let base_b = PathBuf::from(format!("{BASE}/"));
for input in ["a.json", "2026/b.json", "./c.json"] {
let ra = safe_join(base_a, input);
let rb = safe_join(&base_b, input);
assert_eq!(
ra, rb,
"result differs by base trailing-slash for input {input:?}: a={ra:?} b={rb:?}"
);
}
}