diff --git a/crates/relicario-server/src/lib.rs b/crates/relicario-server/src/lib.rs index d2caa0b..97148a7 100644 --- a/crates/relicario-server/src/lib.rs +++ b/crates/relicario-server/src/lib.rs @@ -32,6 +32,13 @@ pub fn classify_path(path: &str) -> PathClass { if slug.is_empty() { return PathClass::Rejected("empty collection slug in items path".to_string()); } + // Defense-in-depth: mirror `OrgCollections::validate` — a slug containing + // '.' (e.g. a `..`/`.` path-traversal attempt) is structurally invalid. + // git normalizes most `./` away before the hook sees the path, so this is + // unreachable today; it keeps the hook self-defensive regardless. + if slug.contains('.') { + return PathClass::Rejected(format!("invalid collection slug: {:?}", slug)); + } return PathClass::Item { collection: slug.to_string() }; } diff --git a/crates/relicario-server/tests/org_hook.rs b/crates/relicario-server/tests/org_hook.rs index 5b6ebf0..7c306bf 100644 --- a/crates/relicario-server/tests/org_hook.rs +++ b/crates/relicario-server/tests/org_hook.rs @@ -18,8 +18,8 @@ fn item_write_yields_collection_slug() { } #[test] -fn item_write_nested_slug_takes_leading_segment_only() { - // Slugs cannot contain '/', so a 4-segment path is malformed → Rejected. +fn item_write_nested_slug_is_rejected() { + // Slugs cannot contain '/', so a path with extra segments is malformed → Rejected. assert_eq!( classify_path("items/prod/sub/x.enc"), PathClass::Rejected("items path must be items//.enc".to_string()) @@ -51,6 +51,16 @@ fn empty_slug_segment_is_rejected() { ); } +#[test] +fn dotted_slug_is_rejected() { + // Defense-in-depth (mirrors OrgCollections::validate): a slug containing '.' + // — e.g. a ".."/"." path-traversal attempt — is rejected. + assert_eq!( + classify_path("items/../x.enc"), + PathClass::Rejected("invalid collection slug: \"..\"".to_string()) + ); +} + use relicario_server::extract_schema_version; #[test]