use super::{PackValidationError, Validator};
use crate::pack::{ChildRef, PackManifest};
pub(crate) struct ChildPathValidator;
impl Validator for ChildPathValidator {
fn name(&self) -> &'static str {
"child_path_bare_name"
}
fn check(&self, pack: &PackManifest) -> Vec<PackValidationError> {
let mut errs = Vec::new();
for child in &pack.children {
if let Some(err) = check_one(child) {
errs.push(err);
}
}
errs
}
}
#[must_use]
pub(crate) fn check_one(child: &ChildRef) -> Option<PackValidationError> {
let (effective, attribution) = match child.path.as_deref() {
Some(p) => (p.to_string(), Attribution::Explicit(p.to_string())),
None => (child.effective_path(), Attribution::UrlDerived(child.url.clone())),
};
let reason = reject_reason(&effective)?;
let (child_name, path) = match attribution {
Attribution::Explicit(label) => (label.clone(), label),
Attribution::UrlDerived(url) => (url, effective),
};
Some(PackValidationError::ChildPathInvalid { child_name, path, reason: reason.to_string() })
}
enum Attribution {
Explicit(String),
UrlDerived(String),
}
pub(crate) struct DupChildPathValidator;
impl Validator for DupChildPathValidator {
fn name(&self) -> &'static str {
"child_path_no_duplicates"
}
fn check(&self, pack: &PackManifest) -> Vec<PackValidationError> {
use std::collections::BTreeMap;
let mut by_path: BTreeMap<String, Vec<String>> = BTreeMap::new();
for child in &pack.children {
let effective = child.effective_path();
if reject_reason(&effective).is_some() {
continue;
}
by_path.entry(effective).or_default().push(child.url.clone());
}
let mut errs = Vec::new();
for (path, urls) in by_path {
if urls.len() >= 2 {
errs.push(PackValidationError::ChildPathDuplicate { path, urls });
}
}
errs
}
}
pub(crate) fn reject_reason(path: &str) -> Option<&'static str> {
if path.is_empty() {
return Some("empty string is not a valid child path");
}
if path.contains('/') || path.contains('\\') {
return Some("path separators are not allowed (children[].path must be a bare name)");
}
if path == "." || path == ".." {
return Some("`.` and `..` are not allowed (children[].path must be a bare name)");
}
if !matches_bare_name_regex(path) {
return Some(
"must match `^[a-z][a-z0-9-]*$` (letter-led, lowercase, digits and hyphens allowed)",
);
}
None
}
fn matches_bare_name_regex(s: &str) -> bool {
let mut chars = s.chars();
match chars.next() {
Some(c) if c.is_ascii_lowercase() => {}
_ => return false,
}
chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
}
#[cfg(test)]
mod tests {
use super::*;
use crate::pack::{ChildRef, PackManifest, PackType, SchemaVersion};
use std::collections::BTreeMap;
fn pack_with_child_paths(paths: &[&str]) -> PackManifest {
let children = paths
.iter()
.map(|p| ChildRef {
url: format!("https://example.invalid/{p}"),
path: Some((*p).to_string()),
r#ref: None,
})
.collect();
PackManifest {
schema_version: SchemaVersion::current(),
name: "p".to_string(),
r#type: PackType::Meta,
version: None,
depends_on: Vec::new(),
children,
actions: Vec::new(),
teardown: None,
extensions: BTreeMap::new(),
}
}
fn validate_path(path: &str) -> Vec<PackValidationError> {
ChildPathValidator.check(&pack_with_child_paths(&[path]))
}
#[test]
fn rejection_table() {
let cases: &[(&str, &str)] = &[
("", "empty"),
("foo/bar", "separator"),
("foo\\bar", "separator"),
("/abs", "separator"),
("../escape", "separator"),
(".", "`.` and `..`"),
("..", "`.` and `..`"),
("Foo", "`^[a-z]"),
("1foo", "letter-led"),
];
for (input, expected_reason_substr) in cases {
let errs = validate_path(input);
assert_eq!(errs.len(), 1, "input {input:?}");
match &errs[0] {
PackValidationError::ChildPathInvalid { path, reason, .. } => {
assert_eq!(path, input, "input {input:?}");
assert!(
reason.contains(expected_reason_substr),
"input {input:?} reason: {reason}",
);
}
other => panic!("input {input:?} wrong variant: {other:?}"),
}
}
}
#[test]
fn accept_table() {
for ok in ["foo", "a", "algo-leet", "foo-bar", "foo123", "a1-b2"] {
assert!(validate_path(ok).is_empty(), "input {ok:?} should accept");
}
}
#[test]
fn url_derived_tail_is_validated_when_path_absent() {
let ok = PackManifest {
schema_version: SchemaVersion::current(),
name: "p".to_string(),
r#type: PackType::Meta,
version: None,
depends_on: Vec::new(),
children: vec![ChildRef {
url: "https://example.invalid/foo.git".to_string(),
path: None,
r#ref: None,
}],
actions: Vec::new(),
teardown: None,
extensions: BTreeMap::new(),
};
assert!(ChildPathValidator.check(&ok).is_empty());
let bad = PackManifest {
schema_version: SchemaVersion::current(),
name: "p".to_string(),
r#type: PackType::Meta,
version: None,
depends_on: Vec::new(),
children: vec![ChildRef {
url: "https://example.invalid/...git".to_string(),
path: None,
r#ref: None,
}],
actions: Vec::new(),
teardown: None,
extensions: BTreeMap::new(),
};
let errs = ChildPathValidator.check(&bad);
assert_eq!(errs.len(), 1, "errs: {errs:?}");
match &errs[0] {
PackValidationError::ChildPathInvalid { child_name, path, .. } => {
assert_eq!(child_name, "https://example.invalid/...git");
assert_eq!(path, "..");
}
other => panic!("wrong variant: {other:?}"),
}
}
#[test]
fn aggregates_errors_across_multiple_children() {
let pack = pack_with_child_paths(&["good", "foo/bar", "..", "ALSO-BAD"]);
let errs = ChildPathValidator.check(&pack);
assert_eq!(errs.len(), 3, "errs: {errs:?}");
}
fn pack_with_children(entries: &[(&str, Option<&str>)]) -> PackManifest {
let children = entries
.iter()
.map(|(url, path)| ChildRef {
url: (*url).to_string(),
path: path.map(str::to_string),
r#ref: None,
})
.collect();
PackManifest {
schema_version: SchemaVersion::current(),
name: "p".to_string(),
r#type: PackType::Meta,
version: None,
depends_on: Vec::new(),
children,
actions: Vec::new(),
teardown: None,
extensions: BTreeMap::new(),
}
}
#[test]
fn dup_validator_passes_on_distinct_paths() {
let pack =
pack_with_children(&[("https://x/a.git", Some("a")), ("https://x/b.git", Some("b"))]);
assert!(DupChildPathValidator.check(&pack).is_empty());
}
#[test]
fn dup_validator_flags_two_children_at_same_explicit_path() {
let pack = pack_with_children(&[
("https://x/a.git", Some("foo")),
("https://y/b.git", Some("foo")),
]);
let errs = DupChildPathValidator.check(&pack);
assert_eq!(errs.len(), 1, "errs: {errs:?}");
match &errs[0] {
PackValidationError::ChildPathDuplicate { path, urls } => {
assert_eq!(path, "foo");
assert_eq!(urls.len(), 2);
assert!(urls.contains(&"https://x/a.git".to_string()));
assert!(urls.contains(&"https://y/b.git".to_string()));
}
other => panic!("wrong variant: {other:?}"),
}
}
#[test]
fn dup_validator_collides_explicit_path_with_url_tail() {
let pack = pack_with_children(&[
("https://x/foo.git", None),
("https://y/elsewhere.git", Some("foo")),
]);
let errs = DupChildPathValidator.check(&pack);
assert_eq!(errs.len(), 1, "errs: {errs:?}");
match &errs[0] {
PackValidationError::ChildPathDuplicate { path, urls } => {
assert_eq!(path, "foo");
assert_eq!(urls.len(), 2);
}
other => panic!("wrong variant: {other:?}"),
}
}
#[test]
fn dup_validator_skips_children_with_invalid_path() {
let pack = pack_with_children(&[
("https://x/a.git", Some("../escape")),
("https://x/b.git", Some("good")),
]);
assert!(DupChildPathValidator.check(&pack).is_empty());
}
}