Skip to content

Commit d1dff22

Browse files
committed
fix(security): dual-pattern project filter preserves glob syntax while matching normalized names
1 parent 5c980b4 commit d1dff22

1 file changed

Lines changed: 34 additions & 11 deletions

File tree

crates/core/src/project_filter.rs

Lines changed: 34 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@ use globset::{Glob, GlobSet, GlobSetBuilder};
22

33
#[derive(Clone)]
44
pub struct ProjectFilter {
5-
matcher: GlobSet,
5+
/// Original patterns (preserves glob syntax like `[a-z]`)
6+
raw_matcher: GlobSet,
7+
/// Normalized patterns (lowercase, hyphens→underscores for ProjectId matching)
8+
normalized_matcher: GlobSet,
69
}
710

811
impl ProjectFilter {
@@ -11,7 +14,7 @@ impl ProjectFilter {
1114
}
1215

1316
pub fn is_excluded(&self, project: &str) -> bool {
14-
self.matcher.is_match(project)
17+
self.raw_matcher.is_match(project) || self.normalized_matcher.is_match(project)
1518
}
1619

1720
fn from_env_value(raw: Option<&str>) -> Option<Self> {
@@ -23,17 +26,23 @@ impl ProjectFilter {
2326
where
2427
I: IntoIterator<Item = &'a str>,
2528
{
26-
let mut builder = GlobSetBuilder::new();
29+
let mut raw_builder = GlobSetBuilder::new();
30+
let mut norm_builder = GlobSetBuilder::new();
2731
let mut added = 0usize;
2832

2933
for pattern in patterns {
3034
let expanded = expand_home(pattern);
31-
// Normalize pattern the same way ProjectId::normalize() does
32-
// (lowercase, hyphens → underscores) so that a pattern like
33-
// "My-Secret-Project" matches the normalized input "my_secret_project".
35+
36+
if let Ok(glob) = Glob::new(&expanded) {
37+
raw_builder.add(glob);
38+
}
39+
40+
// Normalized pattern — lowercase + hyphens→underscores for ProjectId matching.
41+
// This may corrupt character classes ([a-z] → [a_z]) but that's fine:
42+
// the raw matcher already handles those correctly.
3443
let normalized = expanded.to_lowercase().replace('-', "_");
3544
if let Ok(glob) = Glob::new(&normalized) {
36-
builder.add(glob);
45+
norm_builder.add(glob);
3746
added = added.saturating_add(1);
3847
}
3948
}
@@ -42,8 +51,12 @@ impl ProjectFilter {
4251
return None;
4352
}
4453

45-
let matcher = builder.build().ok()?;
46-
Some(Self { matcher })
54+
let raw_matcher = raw_builder.build().ok()?;
55+
let normalized_matcher = norm_builder.build().ok()?;
56+
Some(Self {
57+
raw_matcher,
58+
normalized_matcher,
59+
})
4760
}
4861
}
4962

@@ -89,9 +102,10 @@ mod tests {
89102
#[test]
90103
fn normalizes_patterns_to_match_project_id() {
91104
let filter = ProjectFilter::from_env_value(Some("My-Secret-Project")).expect("filter");
92-
// ProjectId::new("My-Secret-Project").to_string() == "my_secret_project"
105+
// Normalized matcher: my_secret_project matches
93106
assert!(filter.is_excluded("my_secret_project"));
94-
assert!(!filter.is_excluded("My-Secret-Project"));
107+
// Raw matcher: exact case match
108+
assert!(filter.is_excluded("My-Secret-Project"));
95109
}
96110

97111
#[test]
@@ -101,6 +115,15 @@ mod tests {
101115
assert!(filter.is_excluded("my_secret_other"));
102116
}
103117

118+
#[test]
119+
fn preserves_glob_character_classes() {
120+
// [a-z] must NOT become [a_z] — raw matcher preserves original syntax
121+
let filter = ProjectFilter::from_env_value(Some("[a-z]*_project")).expect("filter");
122+
assert!(filter.is_excluded("my_project"));
123+
assert!(filter.is_excluded("x_project"));
124+
assert!(!filter.is_excluded("1_project"));
125+
}
126+
104127
#[test]
105128
fn returns_none_for_empty_env_value() {
106129
assert!(ProjectFilter::from_env_value(Some(" , ")).is_none());

0 commit comments

Comments
 (0)