#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SizePredicate {
GreaterThan(u64),
AtLeast(u64),
}
impl SizePredicate {
pub fn matches(&self, len: u64) -> bool {
match *self {
SizePredicate::GreaterThan(t) => len > t,
SizePredicate::AtLeast(t) => len >= t,
}
}
pub fn parse(spec: &str) -> Result<SizePredicate, GateParseError> {
let trimmed = spec.trim();
let (at_least, rest) = if let Some(rest) = trimmed.strip_prefix(">=") {
(true, rest)
} else if let Some(rest) = trimmed.strip_prefix('>') {
(false, rest)
} else {
return Err(GateParseError::Operator);
};
let bytes = parse_size(rest.trim())?;
Ok(if at_least {
SizePredicate::AtLeast(bytes)
} else {
SizePredicate::GreaterThan(bytes)
})
}
}
fn parse_size(spec: &str) -> Result<u64, GateParseError> {
let spec = spec.trim();
if spec.is_empty() {
return Err(GateParseError::Number);
}
let split = spec
.find(|c: char| !c.is_ascii_digit() && c != '_')
.unwrap_or(spec.len());
let (digits, unit) = spec.split_at(split);
let number: u64 = digits
.replace('_', "")
.parse()
.map_err(|_| GateParseError::Number)?;
let multiplier = match unit.trim().to_ascii_lowercase().as_str() {
"" | "b" => 1,
"kb" => 1_000,
"mb" => 1_000_000,
"gb" => 1_000_000_000,
"kib" => 1024,
"mib" => 1024 * 1024,
"gib" => 1024 * 1024 * 1024,
_ => return Err(GateParseError::Unit),
};
number
.checked_mul(multiplier)
.ok_or(GateParseError::Overflow)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GateParseError {
Operator,
Number,
Unit,
Overflow,
}
impl std::fmt::Display for GateParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let msg = match self {
GateParseError::Operator => "size predicate must start with '>' or '>='",
GateParseError::Number => "size predicate needs an integer (e.g. '> 1MB')",
GateParseError::Unit => "unknown size unit (use B/KB/MB/GB/KiB/MiB/GiB)",
GateParseError::Overflow => "size predicate overflows a 64-bit byte count",
};
f.write_str(msg)
}
}
impl std::error::Error for GateParseError {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Gate {
glob: Option<String>,
size: Option<SizePredicate>,
}
impl Default for Gate {
fn default() -> Self {
Gate {
glob: Some(DEFAULT_GLOB.to_string()),
size: None,
}
}
}
pub const DEFAULT_GLOB: &str = "**/*.node";
impl Gate {
pub fn any() -> Self {
Gate {
glob: None,
size: None,
}
}
pub fn new(glob: Option<&str>, size: Option<&str>) -> Result<Gate, GateParseError> {
let size = match size {
None => None,
Some(s) if s.trim().is_empty() => None,
Some(s) => Some(SizePredicate::parse(s)?),
};
Ok(Gate {
glob: glob.map(str::to_string),
size,
})
}
pub fn with_glob(mut self, glob: &str) -> Self {
self.glob = Some(glob.to_string());
self
}
pub fn with_size(mut self, size: SizePredicate) -> Self {
self.size = Some(size);
self
}
pub fn glob(&self) -> Option<&str> {
self.glob.as_deref()
}
pub fn size(&self) -> Option<SizePredicate> {
self.size
}
pub fn matches(&self, name: &str, len: u64) -> bool {
if let Some(glob) = &self.glob {
if !glob_match(glob, name) {
return false;
}
}
if let Some(size) = &self.size {
if !size.matches(len) {
return false;
}
}
true
}
}
pub fn glob_match(pattern: &str, text: &str) -> bool {
glob_inner(pattern.as_bytes(), text.as_bytes())
}
fn glob_inner(pat: &[u8], text: &[u8]) -> bool {
let Some(&pc) = pat.first() else {
return text.is_empty();
};
match pc {
b'*' => {
let double = pat.get(1) == Some(&b'*');
if double {
let after = &pat[2..];
let collapsed = after.strip_prefix(b"/").unwrap_or(after);
if glob_inner(collapsed, text) || glob_inner(after, text) {
return true;
}
for i in 0..text.len() {
if glob_inner(after, &text[i + 1..]) || glob_inner(collapsed, &text[i + 1..]) {
return true;
}
}
false
} else {
let rest = &pat[1..];
if glob_inner(rest, text) {
return true;
}
for i in 0..text.len() {
if text[i] == b'/' {
break;
}
if glob_inner(rest, &text[i + 1..]) {
return true;
}
}
false
}
}
b'?' => match text.first() {
Some(&c) if c != b'/' => glob_inner(&pat[1..], &text[1..]),
_ => false,
},
c => match text.first() {
Some(&t) if t == c => glob_inner(&pat[1..], &text[1..]),
_ => false,
},
}
}
#[cfg(test)]
#[cfg_attr(coverage_nightly, coverage(off))]
mod tests {
use proptest::prelude::*;
use super::*;
proptest! {
#[test]
fn size_predicate_parse_never_panics(s in ".*") {
let _ = SizePredicate::parse(&s);
}
#[test]
fn bare_number_spec_round_trips(n in any::<u64>()) {
prop_assert_eq!(
SizePredicate::parse(&format!(">{n}")),
Ok(SizePredicate::GreaterThan(n))
);
prop_assert_eq!(
SizePredicate::parse(&format!(">= {n}")),
Ok(SizePredicate::AtLeast(n))
);
}
#[test]
fn decimal_unit_scales_like_the_oracle(n in 0u64..=1_000_000) {
prop_assert_eq!(parse_size(&format!("{n}KB")), Ok(n * 1_000));
prop_assert_eq!(parse_size(&format!("{n}MB")), Ok(n * 1_000_000));
prop_assert_eq!(parse_size(&format!("{n}kib")), Ok(n * 1024));
}
#[test]
fn gate_matches_never_panics(text in ".*", len in any::<u64>()) {
let _ = Gate::default().matches(&text, len);
let _ = Gate::any().matches(&text, len);
}
#[test]
fn literal_glob_matches_iff_equal(
lit in "[a-zA-Z0-9._-]{0,32}",
text in "[a-zA-Z0-9._-]{0,32}",
) {
prop_assert_eq!(glob_match(&lit, &text), lit == text);
}
#[test]
fn double_star_matches_any_path(text in ".*") {
prop_assert!(glob_match("**", &text));
}
#[test]
fn glob_match_never_panics(
pattern in "[a-z/.?*]{0,12}",
text in "[a-z/.]{0,24}",
) {
let _ = glob_match(&pattern, &text);
}
}
#[test]
fn parses_units_case_insensitively() {
assert_eq!(parse_size("512"), Ok(512));
assert_eq!(parse_size("512B"), Ok(512));
assert_eq!(parse_size("1kb"), Ok(1_000));
assert_eq!(parse_size("1KB"), Ok(1_000));
assert_eq!(parse_size("2MB"), Ok(2_000_000));
assert_eq!(parse_size("3gb"), Ok(3_000_000_000));
assert_eq!(parse_size("1KiB"), Ok(1024));
assert_eq!(parse_size("1mib"), Ok(1024 * 1024));
assert_eq!(parse_size("1GiB"), Ok(1024 * 1024 * 1024));
assert_eq!(parse_size("1 MB"), Ok(1_000_000));
assert_eq!(parse_size("1_000"), Ok(1_000));
}
#[test]
fn rejects_bad_size_literals() {
assert_eq!(parse_size(""), Err(GateParseError::Number));
assert_eq!(parse_size("MB"), Err(GateParseError::Number));
assert_eq!(parse_size("10PB"), Err(GateParseError::Unit));
assert_eq!(
parse_size("99999999999999999999GB"),
Err(GateParseError::Number)
);
assert_eq!(
parse_size("18446744073709551615KB"),
Err(GateParseError::Overflow)
);
}
#[test]
fn parses_predicate_operators() {
assert_eq!(
SizePredicate::parse("> 1MB"),
Ok(SizePredicate::GreaterThan(1_000_000))
);
assert_eq!(
SizePredicate::parse(">=1MB"),
Ok(SizePredicate::AtLeast(1_000_000))
);
assert_eq!(
SizePredicate::parse(" >= 4 KiB "),
Ok(SizePredicate::AtLeast(4096))
);
assert_eq!(SizePredicate::parse("1MB"), Err(GateParseError::Operator));
assert_eq!(SizePredicate::parse("< 1MB"), Err(GateParseError::Operator));
}
#[test]
fn predicate_comparison_is_exact_at_the_boundary() {
let gt = SizePredicate::GreaterThan(1000);
assert!(!gt.matches(1000));
assert!(gt.matches(1001));
let ge = SizePredicate::AtLeast(1000);
assert!(ge.matches(1000));
assert!(!ge.matches(999));
}
#[test]
fn glob_matches_node_addons_anywhere() {
assert!(glob_match(
"**/*.node",
"node_modules/foo/build/Release/addon.node"
));
assert!(glob_match("**/*.node", "addon.node"));
assert!(glob_match("*.node", "addon.node"));
assert!(!glob_match("*.node", "dir/addon.node"));
assert!(!glob_match("**/*.node", "addon.so"));
}
#[test]
fn glob_handles_question_and_literal_and_double_star_edges() {
assert!(glob_match("a?c", "abc"));
assert!(!glob_match("a?c", "a/c"));
assert!(glob_match("**", "any/deep/path"));
assert!(glob_match("a/**/b", "a/x/y/b"));
assert!(glob_match("a/**/b", "a/b"));
assert!(glob_match("exact", "exact"));
assert!(!glob_match("exact", "exacted"));
assert!(glob_match("pre*", "prefix"));
}
#[test]
fn gate_default_is_node_glob_no_floor() {
let g = Gate::default();
assert_eq!(g.glob(), Some("**/*.node"));
assert_eq!(g.size(), None);
assert!(g.matches("build/Release/x.node", 10));
assert!(!g.matches("build/Release/x.so", 10));
}
#[test]
fn gate_requires_both_halves() {
let g = Gate::new(Some("**/*.node"), Some(">= 1MB")).unwrap();
assert!(g.matches("a/b.node", 2_000_000));
assert!(!g.matches("a/b.node", 500_000));
assert!(!g.matches("a/b.so", 2_000_000));
}
#[test]
fn gate_any_matches_everything() {
let g = Gate::any();
assert!(g.matches("whatever.xyz", 0));
assert_eq!(g.glob(), None);
assert_eq!(g.size(), None);
}
#[test]
fn gate_new_treats_empty_size_as_no_floor() {
let g = Gate::new(None, Some(" ")).unwrap();
assert_eq!(g.size(), None);
assert!(g.matches("anything", 0));
}
#[test]
fn gate_builders_chain() {
let g = Gate::any()
.with_glob("**/*.dylib")
.with_size(SizePredicate::GreaterThan(100));
assert!(g.matches("a/b.dylib", 200));
assert!(!g.matches("a/b.dylib", 50));
}
#[test]
fn gate_new_propagates_parse_errors() {
assert_eq!(Gate::new(None, Some("nope")), Err(GateParseError::Operator));
}
#[test]
fn gate_new_with_a_glob_and_no_size_applies_no_floor() {
let g = Gate::new(Some("**/*.node"), None).unwrap();
assert_eq!(g.size(), None);
assert!(g.matches("a/b.node", 1));
assert!(!g.matches("a/b.so", 1));
}
#[test]
fn single_star_never_spans_a_separator() {
let g = Gate::new(Some("a*c"), None).unwrap();
assert!(g.matches("ac", 0), "the star matches a zero-width run");
assert!(g.matches("abc", 0), "matches within one segment");
assert!(!g.matches("ab/c", 0), "a single * never crosses a /");
}
#[test]
fn parse_error_display_is_distinct() {
let msgs: Vec<String> = [
GateParseError::Operator,
GateParseError::Number,
GateParseError::Unit,
GateParseError::Overflow,
]
.iter()
.map(ToString::to_string)
.collect();
assert!(msgs.iter().all(|m| !m.is_empty()));
let mut sorted = msgs.clone();
sorted.sort();
sorted.dedup();
assert_eq!(sorted.len(), 4);
}
}