diff --git a/src/visibility.rs b/src/visibility.rs index 5af7850..ea3749e 100644 --- a/src/visibility.rs +++ b/src/visibility.rs @@ -82,11 +82,39 @@ fn matches_pattern(candidate: &str, pattern: &str) -> bool { if pattern == "*" { return true; } - if let Some((prefix, suffix)) = pattern.split_once('*') { - candidate.starts_with(prefix) && candidate[prefix.len()..].ends_with(suffix) - } else { - candidate == pattern + if !pattern.contains('*') { + return candidate == pattern; + } + + let parts: Vec<&str> = pattern.split('*').collect(); + let first = parts[0]; + let last = parts[parts.len() - 1]; + + if !candidate.starts_with(first) || !candidate.ends_with(last) { + return false; + } + + let mut rest = &candidate[first.len()..]; + let Some(tail_start) = rest.len().checked_sub(last.len()) else { + return false; + }; + if tail_start == 0 && parts.len() == 2 { + return true; } + // Consume the fixed tail so middle segments cannot overlap it. + rest = &rest[..tail_start]; + + for segment in &parts[1..parts.len() - 1] { + if segment.is_empty() { + continue; + } + let Some(pos) = rest.find(segment) else { + return false; + }; + rest = &rest[pos + segment.len()..]; + } + + true } /// Whether `candidate` matches one declared private-branch `pattern`. @@ -477,4 +505,51 @@ mod tests { let _ = std::fs::remove_file(&path); let _ = std::fs::remove_dir(&dir); } + + #[test] + fn test_path_globs_support_multiple_wildcards_and_pem_suffix() { + // SEC-02: multi-* globs like *key* must match real paths/branches. + // SEC-01 (documented intent): *.pem is the suffix form for key files. + let policy = VisibilityPolicy { + private_paths: vec!["*.pem".into(), "*key*".into(), "a*b*c".into()], + embargo_until: None, + private_branches: vec!["*audit*".into(), "wip/*/secret".into()], + resign_key_id: None, + embargo_recipients: vec![], + }; + + for path in [ + "key.pem", + "deploy/id.pem", + "certs/server.pem", + "src/secret-key", + "keyring/data.txt", + "aXbYc", + ] { + assert!( + policy.path_is_private(path), + "path {path} must match a private glob" + ); + } + assert!( + !policy.path_is_private("src/main.rs"), + "unrelated path must not match" + ); + // Single-* glob still works. + let single = VisibilityPolicy { + private_paths: vec!["logs/*.log".into()], + embargo_until: None, + private_branches: vec!["private/*".into()], + resign_key_id: None, + embargo_recipients: vec![], + }; + assert!(single.path_is_private("logs/app.log")); + assert!(single.branch_is_private("private/feature")); + assert!(!single.branch_is_private("public/feature")); + + assert!(policy.branch_is_private("feature-audit")); + assert!(policy.branch_is_private("my-audit-branch")); + assert!(policy.branch_is_private("wip/team/secret")); + assert!(!policy.branch_is_private("feature/public")); + } } diff --git a/visibility.toml b/visibility.toml index 201a1c7..69a359f 100644 --- a/visibility.toml +++ b/visibility.toml @@ -14,7 +14,7 @@ # `config/.env` and `config/.env.local`; `secrets/` matches anything below # `secrets/`; `*` is the only wildcard and `.pem` matches a file literally # named `.pem`, not `server.pem`. Use `*.pem` for a suffix. -private_paths = [".env", "secrets/", ".pem"] +private_paths = [".env", "secrets/", "*.pem"] # No embargo schedule for this repo yet; add `embargo_until = "YYYY-MM-DD"` # to hold all changes until that date. Public export refuses under embargo.