use std::path::Path;
use ignition_core::client::workspace::{
build_mapping, local_path_for, segment_escape, segment_unescape,
};
use ignition_core::error::CoreError;
use proptest::prelude::*;
use proptest::string::string_regex;
fn hostile_segment() -> impl Strategy<Value = String> {
string_regex(r"[^\x00/]{1,24}")
.expect("hostile segment regex compiles")
.prop_filter("`.`/`..` refuse (P3 corpus)", |s| s != "." && s != "..")
}
fn valid_member_path() -> impl Strategy<Value = String> {
prop_oneof![
3 => proptest::collection::vec(hostile_segment(), 1..=4)
.prop_map(|segments| segments.join("/")),
1 => proptest::collection::vec(hostile_segment(), 8..=20)
.prop_map(|segments| segments.join("/")),
]
}
fn refused_member_path() -> impl Strategy<Value = String> {
prop_oneof![
(hostile_segment(), hostile_segment(), hostile_segment())
.prop_map(|(a, b, c)| format!("{a}\0{b}/{c}")),
(
hostile_segment(),
hostile_segment(),
prop::sample::select(vec![".", ".."]),
hostile_segment()
)
.prop_map(|(a, b, bad, c)| format!("{a}/{b}/{bad}/{c}")),
(valid_member_path(), valid_member_path()).prop_map(|(a, b)| format!("{a}//{b}")),
valid_member_path().prop_map(|a| format!("/{a}")),
valid_member_path().prop_map(|a| format!("{a}/")),
]
}
fn safe_member_path() -> impl Strategy<Value = String> {
proptest::collection::vec(
string_regex(r"[A-Za-z0-9._-]{1,32}")
.expect("safe segment regex compiles")
.prop_filter("`.`/`..` refuse", |s| s != "." && s != ".."),
1..=5,
)
.prop_map(|segments| segments.join("/"))
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(512))]
#[test]
fn p1_round_trip_is_exact(member in valid_member_path()) {
let local = local_path_for(&member)
.unwrap_or_else(|err| panic!("generated path {member:?} must map, got {err}"));
prop_assert_eq!(unescape_local(&local), member);
}
#[test]
fn p2_injective_byte_wise(a in valid_member_path(), b in valid_member_path()) {
prop_assume!(a != b, "distinct paths only — equal paths trivially map equal");
let local_a = local_path_for(&a)
.unwrap_or_else(|err| panic!("path {a:?} must map, got {err}"));
let local_b = local_path_for(&b)
.unwrap_or_else(|err| panic!("path {b:?} must map, got {err}"));
prop_assert_ne!(
local_a.to_string_lossy(),
local_b.to_string_lossy(),
"distinct members {:?} and {:?} collapsed onto one local path",
a,
b
);
}
#[test]
fn p3_refusals_name_the_member(bad in refused_member_path()) {
let CoreError::InvalidInput { reason } = local_path_for(&bad).expect_err(
"refusal-corpus path must refuse, never sanitize",
) else {
panic!("refusal must ride InvalidInput (no new slugs)");
};
prop_assert!(
reason.contains(&bad),
"refusal must name the offending member path {:?}; got: {}",
bad,
reason
);
}
#[test]
fn p4_safe_domain_is_idempotent(member in safe_member_path()) {
let local = local_path_for(&member)
.unwrap_or_else(|err| panic!("safe path {member:?} must map, got {err}"));
prop_assert_eq!(
ignition_core::client::scripts_codec::tree_relative_string(&local),
member,
"tree-relative rendering is the bijection's /-form (host separators never leak)"
);
}
}
fn unescape_local(local: &Path) -> String {
let mut parts = Vec::new();
for component in local.components() {
let segment = component
.as_os_str()
.to_str()
.expect("the mapping emits UTF-8-only names");
parts.push(
segment_unescape(segment)
.unwrap_or_else(|err| panic!("component {segment:?} must unescape: {err}")),
);
}
parts.join("/")
}
fn member_set() -> impl Strategy<Value = std::collections::BTreeSet<String>> {
proptest::collection::btree_set(valid_member_path(), 1..=8)
}
proptest! {
#[test]
fn p5_build_mapping_total_and_pairwise_injective(members in member_set()) {
let input: Vec<String> = members.into_iter().collect();
let mapping = match build_mapping(&input) {
Ok(mapping) => mapping,
Err(_) => {
prop_assume!(false, "refused set — the fold-collision class is P6's corpus");
unreachable!()
}
};
prop_assert_eq!(mapping.len(), input.len(), "total: one entry per member");
for member in &input {
prop_assert!(mapping.contains_key(member), "member {:?} missing from map", member);
}
let rendered: Vec<String> = mapping
.values()
.map(|local| local.to_string_lossy().into_owned())
.collect();
for (index, a) in rendered.iter().enumerate() {
for b in rendered.iter().skip(index + 1) {
prop_assert_ne!(a, b, "two members rendered the same local path");
}
}
}
}
#[test]
fn p6_case_fold_collision_refuses_naming_both() {
for (a, b) in [("P13/A", "p13/a"), ("Foo", "foo")] {
let err = build_mapping(&[a.to_string(), b.to_string()])
.expect_err("fold-colliding members must refuse");
let CoreError::InvalidInput { reason } = err else {
panic!("refusal must ride InvalidInput (no new slugs)");
};
assert!(
reason.contains(a) && reason.contains(b),
"refusal must name BOTH colliding members ({a:?}, {b:?}): {reason}"
);
let reversed = build_mapping(&[b.to_string(), a.to_string()]).expect_err("reversed");
let CoreError::InvalidInput {
reason: reason_reversed,
} = reversed
else {
panic!("refusal must ride InvalidInput (no new slugs)");
};
assert_eq!(reason, reason_reversed, "collision message is order-stable");
}
let ok = build_mapping(&["P13/A/x".to_string(), "p13/B".to_string()])
.expect("distinct folded paths map");
assert_eq!(ok.len(), 2);
}
#[test]
fn p6_exact_duplicate_refuses() {
let err = build_mapping(&[
"ignition/script-python/a".to_string(),
"ignition/script-python/a".to_string(),
])
.expect_err("exact duplicate must refuse");
let CoreError::InvalidInput { reason } = err else {
panic!("refusal must ride InvalidInput (no new slugs)");
};
assert!(
reason.contains("ignition/script-python/a"),
"refusal names the duplicate: {reason}"
);
}
#[test]
fn p6_unicode_variants_are_not_fold_collisions() {
let nfc = "caf\u{e9}/view.json".to_string();
let nfd = "cafe\u{301}/view.json".to_string();
let mapping = build_mapping(&[nfc.clone(), nfd.clone()])
.expect("byte-distinct members are not fold collisions");
assert_eq!(mapping.len(), 2);
assert_ne!(mapping[&nfc], mapping[&nfd]);
}
#[test]
fn build_mapping_is_order_stable() {
let members: Vec<String> = vec![
"ignition/script-python/b".into(),
"com.example/views/Dash/view.json".into(),
"ignition/script-python/a".into(),
];
let mut reversed = members.clone();
reversed.reverse();
assert_eq!(
build_mapping(&members).unwrap(),
build_mapping(&reversed).unwrap()
);
}
use ignition_core::client::resources;
use ignition_core::client::workspace::MemberSource;
fn member_fixture_zip() -> Vec<u8> {
use std::io::Write as _;
let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let options = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Deflated);
writer
.start_file("project.json", options)
.expect("project.json starts");
writer
.write_all(br#"{"title":"T","enabled":true}"#)
.expect("project.json writes");
for (member, bytes) in [
(
"com.example/resources/views/Dashboard/view.json",
br#"{"scope":"A"}"#.as_slice(),
),
(
"ignition/resources/my file%20/x.json",
br#"{"payload":"hostile name rides fine"}"#.as_slice(),
),
("ignition/resources/script-python/e2e/scratch", b"print('e2e')"),
(
"ignition/resources/script-python/uat/resource.json",
br#"{"scope":"G","version":1,"files":["scratch.py"],"attributes":{"lastModification":{"actor":"admin","timestamp":"2026-08-28T10:00:00Z","signature":"sig-a"},"lastModificationSignature":"sig-a","notes":"kept"}}"#.as_slice(),
),
] {
writer.start_file(member, options).expect("member starts");
writer.write_all(bytes).expect("member writes");
}
writer.finish().expect("zip finalizes").into_inner()
}
fn rewritten_descriptor(bytes: &[u8]) -> Vec<u8> {
let mut value: serde_json::Value = serde_json::from_slice(bytes).expect("descriptor json");
let last_modification = value
.get_mut("attributes")
.and_then(|attributes| attributes.get_mut("lastModification"))
.and_then(|last| last.as_object_mut())
.expect("descriptor carries lastModification");
last_modification.insert(
"timestamp".to_string(),
serde_json::json!("2026-08-29T11:30:00Z"),
);
last_modification.insert("signature".to_string(), serde_json::json!("sig-b"));
serde_json::to_vec(&value).expect("rewritten descriptor serializes")
}
fn populated_tree() -> (
tempfile::TempDir,
std::collections::BTreeMap<String, std::path::PathBuf>,
Vec<u8>,
) {
let zip_bytes = member_fixture_zip();
let user_paths = resources::resource_members(&zip_bytes).expect("fixture zip walks");
let mapping = build_mapping(&user_paths).expect("fixture members map");
let temp = tempfile::tempdir().expect("tempdir");
for (member, local) in &mapping {
let target = temp.path().join(local);
std::fs::create_dir_all(target.parent().expect("nested member")).expect("checkout dirs");
let bytes = resources::read_member(&zip_bytes, member).expect("fixture member reads");
let bytes = if member.ends_with("/resource.json") {
rewritten_descriptor(&bytes)
} else {
bytes
};
std::fs::write(&target, bytes).expect("checkout file writes");
}
(temp, mapping, zip_bytes)
}
#[test]
fn zip_and_tree_sources_are_equivalent() {
let (temp, mapping, zip_bytes) = populated_tree();
let zip_source = MemberSource::Zip(zip_bytes);
let tree_source = MemberSource::Tree {
root: temp.path().to_path_buf(),
mapping,
};
assert_eq!(
zip_source.members().expect("zip members"),
tree_source.members().expect("tree members"),
"identical member lists from both sources"
);
assert_eq!(
zip_source.member_hashes().expect("zip hashes"),
tree_source.member_hashes().expect("tree hashes"),
"identical member hashes from both sources — descriptor normalization \
and hostile-name escaping ride BOTH"
);
for member in [
"ignition/script-python/e2e/scratch",
"ignition/my file%20/x.json",
] {
assert_eq!(
zip_source.read(member).expect("zip read"),
tree_source.read(member).expect("tree read"),
"byte-identical read from both sources: {member:?}"
);
}
}
#[test]
fn tree_source_is_manifest_scoped() {
let (temp, mapping, _) = populated_tree();
let tree = MemberSource::Tree {
root: temp.path().to_path_buf(),
mapping,
};
std::fs::write(temp.path().join("rogue.txt"), b"rogue").expect("rogue writes");
let CoreError::InvalidInput { reason } = tree.member_hashes().expect_err("rogue file refuses")
else {
panic!("strictness refusal must ride InvalidInput");
};
assert!(reason.contains("rogue.txt"), "rogue named: {reason}");
std::fs::remove_file(temp.path().join("rogue.txt")).expect("rogue removed");
let victim = tree
.members()
.expect("members list")
.first()
.expect("nonempty")
.clone();
let MemberSource::Tree { root, mapping } = &tree else {
panic!("tree variant");
};
let victim_local = mapping.get(&victim).expect("victim is mapped").clone();
std::fs::remove_file(root.join(&victim_local)).expect("victim removed");
let CoreError::InvalidInput { reason } =
tree.member_hashes().expect_err("missing member refuses")
else {
panic!("strictness refusal must ride InvalidInput");
};
assert!(
reason.contains(&victim),
"missing member named: {reason} (victim: {victim})"
);
let CoreError::InvalidInput { reason } = tree.read(&victim).expect_err("missing read") else {
panic!("read refusal must ride InvalidInput");
};
assert!(reason.contains(&victim));
let CoreError::InvalidInput { reason } =
tree.read("nope/never-mapped").expect_err("unmapped read")
else {
panic!("read refusal must ride InvalidInput");
};
assert!(reason.contains("nope/never-mapped"));
assert!(
tree.members().expect("members").contains(&victim),
"members() is the recorded mapping's keys, whatever the disk says"
);
}
#[test]
fn zip_source_matches_resources_functions_directly() {
let zip_bytes = member_fixture_zip();
let source = MemberSource::Zip(zip_bytes.clone());
assert_eq!(
source.members().expect("source members"),
resources::resource_members(&zip_bytes).expect("direct members")
);
assert_eq!(
source.member_hashes().expect("source hashes"),
resources::member_hashes(&zip_bytes).expect("direct hashes")
);
}
#[test]
fn case_variant_siblings_map_byte_distinct() {
let upper = local_path_for("P13/Foo").expect("upper maps");
let lower = local_path_for("P13/foo").expect("lower maps");
assert_ne!(upper.to_string_lossy(), lower.to_string_lossy());
}
#[test]
fn percent_names_round_trip_without_double_decode() {
for name in ["100%20done", "%2e%2e%2ftrick", "%25", "a%2fb", "%41%42"] {
let local = local_path_for(&format!("ignition/{name}"))
.unwrap_or_else(|err| panic!("percent name {name:?} must map: {err}"));
let on_disk = local.file_name().expect("last segment").to_str().unwrap();
assert_eq!(
segment_unescape(on_disk).unwrap_or_else(|err| panic!("{on_disk:?}: {err}")),
name,
"no double decode: the member name survives verbatim"
);
}
}
#[test]
fn control_bytes_escape_to_hex() {
let local = local_path_for("a\u{1}b/x").expect("control byte maps");
assert_eq!(
ignition_core::client::scripts_codec::tree_relative_string(&local),
"a%01b/x",
"the /-form is the pinned spelling on every host"
);
assert_eq!(
segment_escape("tab\tsep").expect("tab escapes"),
"tab%09sep"
);
}
#[test]
fn safe_names_stay_legible_spaces_escape() {
assert_eq!(
ignition_core::client::scripts_codec::tree_relative_string(
&local_path_for("readme.md").expect("plain name"),
),
"readme.md"
);
assert_eq!(
ignition_core::client::scripts_codec::tree_relative_string(
&local_path_for("my notes").expect("spaced name"),
),
"my%20notes"
);
}
#[test]
fn nfc_nfd_variants_round_trip_and_stay_distinct() {
let nfc = "caf\u{e9}/view.json".to_string();
let nfd = "cafe\u{301}/view.json".to_string();
let local_nfc = local_path_for(&nfc).expect("nfc maps");
let local_nfd = local_path_for(&nfd).expect("nfd maps");
assert_ne!(
local_nfc.to_string_lossy(),
local_nfd.to_string_lossy(),
"NFC and NFD are distinct byte sequences and stay distinct files"
);
assert_eq!(unescape_local(&local_nfc), nfc);
assert_eq!(unescape_local(&local_nfd), nfd);
}
#[test]
fn traversal_refuses_exactly_dots_in_names_map() {
for path in [".", "..", "./a", "../a", "a/.", "a/..", "a/./b", "a/../b"] {
let err = local_path_for(path).expect_err("traversal-shaped path must refuse");
let CoreError::InvalidInput { reason } = err else {
panic!("refusal must ride InvalidInput");
};
assert!(
reason.contains(path),
"refusal must name {path:?}: {reason}"
);
}
for name in ["...", "..hidden", "a..b", ".hidden"] {
assert!(
local_path_for(name).is_ok(),
"dots inside a name are ordinary safe bytes: {name:?}"
);
}
}
#[test]
fn nul_anywhere_refuses() {
for path in ["a\0b", "\0", "a/b\0", "\0/leading"] {
let err = local_path_for(path).expect_err("NUL must refuse");
let CoreError::InvalidInput { reason } = err else {
panic!("refusal must ride InvalidInput");
};
assert!(reason.contains(path));
}
}
#[test]
fn length_cap_on_escaped_form() {
let max = "a".repeat(255);
assert_eq!(
local_path_for(&max)
.expect("exactly at cap")
.to_string_lossy(),
max
);
let err = local_path_for(&"a".repeat(256)).expect_err("over cap must refuse");
let CoreError::InvalidInput { reason } = err else {
panic!("cap refusal must ride InvalidInput");
};
assert!(
reason.contains(&"a".repeat(256)),
"refusal names the member"
);
assert!(
local_path_for(&" ".repeat(85)).is_ok(),
"85 spaces = 255 escaped"
);
assert!(
local_path_for(&" ".repeat(86)).is_err(),
"86 spaces = 258 escaped"
);
}
#[test]
fn unescape_is_strict_inverse() {
for bad in [
"%", "%2", "%zz", "%2G", "abc%", "a%2", "%00", "%2e", "%2e%2e", "%2f",
] {
assert!(
segment_unescape(bad).is_err(),
"malformed or out-of-image segment must refuse: {bad:?}"
);
}
assert_eq!(
segment_unescape("a%20b%2Ec").expect("valid escapes decode"),
"a b.c"
);
let hostile = "caf\u{e9} \u{1} 100%20 %2e";
let escaped = segment_escape(hostile).expect("hostile segment escapes");
assert_eq!(
segment_unescape(&escaped).expect("escaped decodes"),
hostile,
"escape/unescape round-trips exactly"
);
}