Skip to main content

aube_scripts/
policy.rs

1//! Allowlist/denylist policy for running dependency lifecycle scripts.
2//!
3//! Mirrors pnpm's `createAllowBuildFunction` — given an `allowBuilds`
4//! map (`Record<string, boolean>`) and a `dangerouslyAllowAllBuilds`
5//! flag, produce a function from `(pkgName, version)` to an allow /
6//! deny / unspecified decision. Unspecified means "fall through to the
7//! caller's default," which for aube is always "deny."
8//!
9//! ## Entry shapes
10//!
11//! Keys in the `allowBuilds` map support three forms:
12//!
13//! - `"esbuild"` — bare name, matches every version of the package
14//! - `"esbuild@0.19.0"` — exact version match
15//! - `"esbuild@0.19.0 || 0.20.0"` — exact version union
16//!
17//! Semver ranges are intentionally *not* supported, matching pnpm's
18//! `expandPackageVersionSpecs` behavior: if you pin a version in the
19//! allowlist you're asserting a specific build has been audited, so
20//! range matching would defeat the point.
21//!
22//! Name patterns may also contain `*` wildcards, mirroring pnpm's
23//! `@pnpm/config.matcher`. `@babel/*` matches every package under the
24//! `@babel` scope, `*-loader` matches any name ending in `-loader`,
25//! and a bare `*` matches every package. `*` is the only supported
26//! metacharacter and always matches a possibly-empty run of any
27//! characters. Wildcards must stand alone — combining them with a
28//! version spec (`@babel/*@1.0.0`) is rejected, since a wildcard
29//! name can't be used to assert "this exact build was audited."
30
31use aube_manifest::AllowBuildRaw;
32use std::collections::{BTreeMap, HashSet};
33
34/// The decision for a single `(name, version)` lookup.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum AllowDecision {
37    /// Package is explicitly allowed — run its lifecycle scripts.
38    Allow,
39    /// Package is explicitly denied — skip even if a broader rule would allow.
40    Deny,
41    /// No rule matched; caller applies its default (aube denies).
42    Unspecified,
43}
44
45/// Resolved policy for deciding whether a package may run its
46/// lifecycle scripts.
47#[derive(Debug, Clone, Default)]
48pub struct BuildPolicy {
49    allow_all: bool,
50    /// Expanded allow-keys: bare names (match any version) and
51    /// `name@version` strings (match that specific version).
52    allowed: HashSet<String>,
53    denied: HashSet<String>,
54    /// Bare-name patterns containing `*` wildcards. Checked with a
55    /// linear scan after the exact-match sets; wildcard rules are rare
56    /// enough that the linear pass is cheaper than building an
57    /// automaton.
58    allowed_wildcards: Vec<String>,
59    denied_wildcards: Vec<String>,
60}
61
62impl BuildPolicy {
63    /// A policy that denies every package (the aube default).
64    pub fn deny_all() -> Self {
65        Self::default()
66    }
67
68    /// A policy that allows every package, regardless of the map.
69    /// Corresponds to `--dangerously-allow-all-builds`.
70    pub fn allow_all() -> Self {
71        Self {
72            allow_all: true,
73            ..Self::default()
74        }
75    }
76
77    /// Build from a raw `allowBuilds` map plus pnpm's canonical
78    /// `onlyBuiltDependencies` / `neverBuiltDependencies` flat lists,
79    /// plus the `dangerouslyAllowAllBuilds` flag.
80    ///
81    /// All three sources merge into one allow/deny set — pnpm uses the
82    /// flat lists in most real-world projects, and aube's `allowBuilds`
83    /// map is the superset format. Unrecognized `allowBuilds` value
84    /// shapes are collected in the returned `warnings` vec so the
85    /// caller can surface them through the progress UI.
86    pub fn from_config(
87        allow_builds: &BTreeMap<String, AllowBuildRaw>,
88        only_built: &[String],
89        never_built: &[String],
90        dangerously_allow_all: bool,
91    ) -> (Self, Vec<BuildPolicyError>) {
92        if dangerously_allow_all {
93            return (Self::allow_all(), Vec::new());
94        }
95        let mut allowed = HashSet::new();
96        let mut denied = HashSet::new();
97        let mut allowed_wildcards = Vec::new();
98        let mut denied_wildcards = Vec::new();
99        let mut warnings = Vec::new();
100
101        for (pattern, value) in allow_builds {
102            let bool_value = match value {
103                AllowBuildRaw::Bool(b) => *b,
104                AllowBuildRaw::Other(raw) => {
105                    warnings.push(BuildPolicyError::UnsupportedValue {
106                        pattern: pattern.clone(),
107                        raw: raw.clone(),
108                    });
109                    continue;
110                }
111            };
112            match expand_spec(pattern) {
113                Ok(expanded) => {
114                    let (exact, wild) = if bool_value {
115                        (&mut allowed, &mut allowed_wildcards)
116                    } else {
117                        (&mut denied, &mut denied_wildcards)
118                    };
119                    sort_entries(expanded, exact, wild);
120                }
121                Err(e) => warnings.push(e),
122            }
123        }
124
125        // `onlyBuiltDependencies` / `neverBuiltDependencies` support the
126        // same pattern forms as `allowBuilds` map keys (bare name, exact
127        // version, exact version union), so route them through the same
128        // `expand_spec` — a single `esbuild@0.20.0` pin works in either
129        // format.
130        for pattern in only_built {
131            match expand_spec(pattern) {
132                Ok(expanded) => sort_entries(expanded, &mut allowed, &mut allowed_wildcards),
133                Err(e) => warnings.push(e),
134            }
135        }
136        for pattern in never_built {
137            match expand_spec(pattern) {
138                Ok(expanded) => sort_entries(expanded, &mut denied, &mut denied_wildcards),
139                Err(e) => warnings.push(e),
140            }
141        }
142
143        (
144            Self {
145                allow_all: false,
146                allowed,
147                denied,
148                allowed_wildcards,
149                denied_wildcards,
150            },
151            warnings,
152        )
153    }
154
155    /// Decide whether `(name, version)` may run lifecycle scripts.
156    /// Explicit denies always win over allows (mirrors pnpm).
157    pub fn decide(&self, name: &str, version: &str) -> AllowDecision {
158        let with_version = format!("{name}@{version}");
159        if self.denied.contains(name) || self.denied.contains(&with_version) {
160            return AllowDecision::Deny;
161        }
162        if matches_any_wildcard(name, &self.denied_wildcards) {
163            return AllowDecision::Deny;
164        }
165        if self.allow_all {
166            return AllowDecision::Allow;
167        }
168        if self.allowed.contains(name) || self.allowed.contains(&with_version) {
169            return AllowDecision::Allow;
170        }
171        if matches_any_wildcard(name, &self.allowed_wildcards) {
172            return AllowDecision::Allow;
173        }
174        AllowDecision::Unspecified
175    }
176
177    /// True when the policy would allow something — any rule at all, or
178    /// allow-all mode. Lets callers cheaply skip the whole dep-script
179    /// phase when nothing could possibly run.
180    pub fn has_any_allow_rule(&self) -> bool {
181        self.allow_all || !self.allowed.is_empty() || !self.allowed_wildcards.is_empty()
182    }
183}
184
185/// Split one entry list from `expand_spec` across the exact-match set
186/// and the wildcard list. Wildcards are identified by a literal `*` in
187/// the string; since `expand_spec` rejects `wildcard@version`, a `*`
188/// can only appear in a bare name.
189fn sort_entries(entries: Vec<String>, exact: &mut HashSet<String>, wildcards: &mut Vec<String>) {
190    for entry in entries {
191        if entry.contains('*') {
192            if !wildcards.iter().any(|p| p == &entry) {
193                wildcards.push(entry);
194            }
195        } else {
196            exact.insert(entry);
197        }
198    }
199}
200
201/// Match `name` against a `*`-wildcard pattern. `*` matches any
202/// (possibly-empty) run of characters — including `/`, so `@babel/*`
203/// matches every package in the scope. Called only for patterns known
204/// to contain at least one `*`; a pattern with no `*` is routed to the
205/// exact-match set instead.
206///
207/// The algorithm is greedy-leftmost for the middle segments with the
208/// prefix anchored on the left and the suffix anchored on the right.
209/// That works for plain `*` globs (no `?`, no character classes): if
210/// any valid assignment of middle positions exists, the leftmost
211/// valid assignment is one of them, and greedy finds it. A fixed
212/// right anchor is what makes this safe — `ends_with(last)` is
213/// independent of greedy choices, and everything between the last
214/// greedy hit and the suffix anchor is a free `*`.
215fn matches_any_wildcard(name: &str, patterns: &[String]) -> bool {
216    patterns.iter().any(|p| matches_wildcard(name, p))
217}
218
219fn matches_wildcard(name: &str, pattern: &str) -> bool {
220    let parts: Vec<&str> = pattern.split('*').collect();
221    // `split` on a pattern with N wildcards yields N+1 parts, so the
222    // two-element case is the minimum we see here.
223    let (first, rest) = match parts.split_first() {
224        Some(pair) => pair,
225        None => return false,
226    };
227    let Some(after_prefix) = name.strip_prefix(first) else {
228        return false;
229    };
230    let (last, middle) = match rest.split_last() {
231        Some(pair) => pair,
232        // `rest` is never empty here — the caller guarantees the
233        // pattern contains at least one `*`, so `parts.len() >= 2`.
234        // Fail closed rather than silently allow if that invariant
235        // ever drifts: a default-allow here would be a security bypass.
236        None => {
237            debug_assert!(false, "matches_wildcard called with no-wildcard pattern");
238            return false;
239        }
240    };
241
242    let mut remaining = after_prefix;
243    for mid in middle {
244        match remaining.find(mid) {
245            Some(idx) => remaining = &remaining[idx + mid.len()..],
246            None => return false,
247        }
248    }
249    remaining.len() >= last.len() && remaining.ends_with(last)
250}
251
252#[derive(Debug, Clone, thiserror::Error)]
253pub enum BuildPolicyError {
254    #[error("allowBuilds entry {pattern:?} has unsupported value {raw:?}: expected true/false")]
255    UnsupportedValue { pattern: String, raw: String },
256    #[error("allowBuilds pattern {0:?} contains an invalid version union")]
257    InvalidVersionUnion(String),
258    #[error("allowBuilds pattern {0:?} mixes a wildcard name with a version union")]
259    WildcardWithVersion(String),
260}
261
262/// Parse one entry from the allowBuilds map into the set of strings
263/// that will be matched at decide-time. Mirrors pnpm's
264/// `expandPackageVersionSpecs`.
265fn expand_spec(pattern: &str) -> Result<Vec<String>, BuildPolicyError> {
266    let (name, versions_part) = split_name_and_versions(pattern);
267
268    if versions_part.is_empty() {
269        return Ok(vec![name.to_string()]);
270    }
271    if name.contains('*') {
272        return Err(BuildPolicyError::WildcardWithVersion(pattern.to_string()));
273    }
274
275    let mut out = Vec::new();
276    for raw in versions_part.split("||") {
277        let trimmed = raw.trim();
278        if trimmed.is_empty() || !is_exact_semver(trimmed) {
279            return Err(BuildPolicyError::InvalidVersionUnion(pattern.to_string()));
280        }
281        out.push(format!("{name}@{trimmed}"));
282    }
283    Ok(out)
284}
285
286/// Split `pattern` into `(name, version_spec)`, respecting a leading
287/// `@` for scoped packages so `@scope/foo@1.0.0` parses correctly.
288fn split_name_and_versions(pattern: &str) -> (&str, &str) {
289    let scoped = pattern.starts_with('@');
290    let search_from = if scoped { 1 } else { 0 };
291    match pattern[search_from..].find('@') {
292        Some(rel) => {
293            let at = search_from + rel;
294            (&pattern[..at], &pattern[at + 1..])
295        }
296        None => (pattern, ""),
297    }
298}
299
300/// Minimal exact-semver validator — accepts `MAJOR.MINOR.PATCH` plus an
301/// optional `-prerelease` / `+build` tail. We intentionally don't pull
302/// in the `semver` crate here because the file is tiny and this is the
303/// only place in aube-scripts that cares about semver shape.
304fn is_exact_semver(s: &str) -> bool {
305    // Strip build metadata; it doesn't affect equality for our purposes.
306    let core = s.split('+').next().unwrap_or(s);
307    // Strip pre-release; the shape just needs to parse as numeric triple.
308    let main = core.split('-').next().unwrap_or(core);
309    let parts: Vec<&str> = main.split('.').collect();
310    if parts.len() != 3 {
311        return false;
312    }
313    parts
314        .iter()
315        .all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit()))
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321
322    fn policy(pairs: &[(&str, bool)]) -> BuildPolicy {
323        let map: BTreeMap<String, AllowBuildRaw> = pairs
324            .iter()
325            .map(|(k, v)| ((*k).to_string(), AllowBuildRaw::Bool(*v)))
326            .collect();
327        let (p, errs) = BuildPolicy::from_config(&map, &[], &[], false);
328        assert!(errs.is_empty(), "unexpected warnings: {errs:?}");
329        p
330    }
331
332    #[test]
333    fn bare_name_allows_any_version() {
334        let p = policy(&[("esbuild", true)]);
335        assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Allow);
336        assert_eq!(p.decide("esbuild", "0.25.0"), AllowDecision::Allow);
337        assert_eq!(p.decide("rollup", "4.0.0"), AllowDecision::Unspecified);
338    }
339
340    #[test]
341    fn exact_version_is_strict() {
342        let p = policy(&[("esbuild@0.19.0", true)]);
343        assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Allow);
344        assert_eq!(p.decide("esbuild", "0.19.1"), AllowDecision::Unspecified);
345    }
346
347    #[test]
348    fn version_union_splits() {
349        let p = policy(&[("esbuild@0.19.0 || 0.20.1", true)]);
350        assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Allow);
351        assert_eq!(p.decide("esbuild", "0.20.1"), AllowDecision::Allow);
352        assert_eq!(p.decide("esbuild", "0.20.0"), AllowDecision::Unspecified);
353    }
354
355    #[test]
356    fn scoped_package_parses() {
357        let p = policy(&[("@swc/core@1.3.0", true)]);
358        assert_eq!(p.decide("@swc/core", "1.3.0"), AllowDecision::Allow);
359        assert_eq!(p.decide("@swc/core", "1.4.0"), AllowDecision::Unspecified);
360    }
361
362    #[test]
363    fn scoped_bare_name() {
364        let p = policy(&[("@swc/core", true)]);
365        assert_eq!(p.decide("@swc/core", "1.3.0"), AllowDecision::Allow);
366    }
367
368    #[test]
369    fn dangerously_allow_all_bypasses_deny_list() {
370        // pnpm's `createAllowBuildFunction` short-circuits to `() => true`
371        // when `dangerouslyAllowAllBuilds` is set, dropping the entire
372        // allowBuilds map — including any `false` entries. Pin that
373        // behavior so a future refactor doesn't accidentally start
374        // honoring deny rules under allow-all.
375        let mut map = BTreeMap::new();
376        map.insert("esbuild".into(), AllowBuildRaw::Bool(false));
377        let (p, errs) = BuildPolicy::from_config(&map, &[], &[], true);
378        assert!(errs.is_empty());
379        assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Allow);
380    }
381
382    #[test]
383    fn deny_wins_over_allow_when_both_listed() {
384        let map: BTreeMap<String, AllowBuildRaw> = [
385            ("esbuild".to_string(), AllowBuildRaw::Bool(true)),
386            ("esbuild@0.19.0".to_string(), AllowBuildRaw::Bool(false)),
387        ]
388        .into_iter()
389        .collect();
390        let (p, errs) = BuildPolicy::from_config(&map, &[], &[], false);
391        assert!(errs.is_empty());
392        assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Deny);
393        assert_eq!(p.decide("esbuild", "0.19.1"), AllowDecision::Allow);
394    }
395
396    #[test]
397    fn deny_all_is_default() {
398        let p = BuildPolicy::deny_all();
399        assert_eq!(p.decide("anything", "1.0.0"), AllowDecision::Unspecified);
400        assert!(!p.has_any_allow_rule());
401    }
402
403    #[test]
404    fn allow_all_flag() {
405        let p = BuildPolicy::allow_all();
406        assert_eq!(p.decide("anything", "1.0.0"), AllowDecision::Allow);
407        assert!(p.has_any_allow_rule());
408    }
409
410    #[test]
411    fn invalid_version_union_reports_warning() {
412        let map: BTreeMap<String, AllowBuildRaw> = [(
413            "esbuild@not-a-version".to_string(),
414            AllowBuildRaw::Bool(true),
415        )]
416        .into_iter()
417        .collect();
418        let (p, errs) = BuildPolicy::from_config(&map, &[], &[], false);
419        assert_eq!(errs.len(), 1);
420        // The broken entry should not leak into the allowed set.
421        assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Unspecified);
422    }
423
424    #[test]
425    fn non_bool_value_reports_warning() {
426        let map: BTreeMap<String, AllowBuildRaw> =
427            [("esbuild".to_string(), AllowBuildRaw::Other("maybe".into()))]
428                .into_iter()
429                .collect();
430        let (_, errs) = BuildPolicy::from_config(&map, &[], &[], false);
431        assert_eq!(errs.len(), 1);
432    }
433
434    #[test]
435    fn only_built_dependencies_allowlist_coexists_with_allow_builds() {
436        // pnpm's canonical `onlyBuiltDependencies` flat list is additive
437        // with `allowBuilds`, so both sources populate the same allowed
438        // set. Same pattern vocabulary — bare name or exact version.
439        let map = BTreeMap::new();
440        let only_built = vec!["esbuild".to_string(), "@swc/core@1.3.0".to_string()];
441        let (p, errs) = BuildPolicy::from_config(&map, &only_built, &[], false);
442        assert!(errs.is_empty());
443        assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Allow);
444        assert_eq!(p.decide("@swc/core", "1.3.0"), AllowDecision::Allow);
445        assert_eq!(p.decide("@swc/core", "1.4.0"), AllowDecision::Unspecified);
446        assert!(p.has_any_allow_rule());
447    }
448
449    #[test]
450    fn never_built_dependencies_denies() {
451        let map = BTreeMap::new();
452        let only_built = vec!["esbuild".to_string()];
453        let never_built = vec!["esbuild@0.19.0".to_string()];
454        let (p, errs) = BuildPolicy::from_config(&map, &only_built, &never_built, false);
455        assert!(errs.is_empty());
456        assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Deny);
457        assert_eq!(p.decide("esbuild", "0.20.0"), AllowDecision::Allow);
458    }
459
460    #[test]
461    fn never_built_beats_allow_builds_map() {
462        // Cross-source precedence: a bare-name deny in
463        // `neverBuiltDependencies` overrides a bare-name allow in the
464        // `allowBuilds` map. Mirrors the in-map deny-wins test above.
465        let map: BTreeMap<String, AllowBuildRaw> =
466            [("esbuild".to_string(), AllowBuildRaw::Bool(true))]
467                .into_iter()
468                .collect();
469        let never_built = vec!["esbuild".to_string()];
470        let (p, errs) = BuildPolicy::from_config(&map, &[], &never_built, false);
471        assert!(errs.is_empty());
472        assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Deny);
473    }
474
475    #[test]
476    fn splits_scoped_correctly() {
477        assert_eq!(
478            split_name_and_versions("@swc/core@1.3.0"),
479            ("@swc/core", "1.3.0")
480        );
481        assert_eq!(split_name_and_versions("@swc/core"), ("@swc/core", ""));
482        assert_eq!(
483            split_name_and_versions("esbuild@0.19.0"),
484            ("esbuild", "0.19.0")
485        );
486        assert_eq!(split_name_and_versions("esbuild"), ("esbuild", ""));
487    }
488
489    #[test]
490    fn wildcard_scope_allows_every_scope_member() {
491        let p = policy(&[("@babel/*", true)]);
492        assert_eq!(p.decide("@babel/core", "7.0.0"), AllowDecision::Allow);
493        assert_eq!(
494            p.decide("@babel/preset-env", "7.22.0"),
495            AllowDecision::Allow
496        );
497        assert_eq!(p.decide("@swc/core", "1.3.0"), AllowDecision::Unspecified);
498        assert_eq!(
499            p.decide("babel-loader", "9.0.0"),
500            AllowDecision::Unspecified
501        );
502        assert!(p.has_any_allow_rule());
503    }
504
505    #[test]
506    fn wildcard_suffix_matches_any_prefix() {
507        let p = policy(&[("*-loader", true)]);
508        assert_eq!(p.decide("css-loader", "6.0.0"), AllowDecision::Allow);
509        assert_eq!(p.decide("babel-loader", "9.0.0"), AllowDecision::Allow);
510        assert_eq!(
511            p.decide("loader-utils", "3.0.0"),
512            AllowDecision::Unspecified
513        );
514    }
515
516    #[test]
517    fn bare_star_matches_everything_and_is_distinct_from_allow_all() {
518        // `*` in the allowlist behaves like "allow every package" but
519        // is still a normal allow rule — deny entries still override
520        // it, unlike `dangerouslyAllowAllBuilds` which short-circuits.
521        let map: BTreeMap<String, AllowBuildRaw> = [
522            ("*".to_string(), AllowBuildRaw::Bool(true)),
523            ("sketchy-pkg".to_string(), AllowBuildRaw::Bool(false)),
524        ]
525        .into_iter()
526        .collect();
527        let (p, errs) = BuildPolicy::from_config(&map, &[], &[], false);
528        assert!(errs.is_empty());
529        assert_eq!(p.decide("esbuild", "0.19.0"), AllowDecision::Allow);
530        assert_eq!(p.decide("sketchy-pkg", "1.0.0"), AllowDecision::Deny);
531    }
532
533    #[test]
534    fn denied_wildcard_blocks_allowed_exact() {
535        let map: BTreeMap<String, AllowBuildRaw> = [
536            ("@babel/core".to_string(), AllowBuildRaw::Bool(true)),
537            ("@babel/*".to_string(), AllowBuildRaw::Bool(false)),
538        ]
539        .into_iter()
540        .collect();
541        let (p, errs) = BuildPolicy::from_config(&map, &[], &[], false);
542        assert!(errs.is_empty());
543        assert_eq!(p.decide("@babel/core", "7.0.0"), AllowDecision::Deny);
544        assert_eq!(p.decide("@babel/traverse", "7.0.0"), AllowDecision::Deny);
545    }
546
547    #[test]
548    fn wildcard_with_version_is_rejected() {
549        let map: BTreeMap<String, AllowBuildRaw> =
550            [("@babel/*@7.0.0".to_string(), AllowBuildRaw::Bool(true))]
551                .into_iter()
552                .collect();
553        let (p, errs) = BuildPolicy::from_config(&map, &[], &[], false);
554        assert_eq!(errs.len(), 1);
555        assert!(matches!(errs[0], BuildPolicyError::WildcardWithVersion(_)));
556        // The rejected entry should not leak through as either an
557        // exact or a wildcard allow.
558        assert_eq!(p.decide("@babel/core", "7.0.0"), AllowDecision::Unspecified);
559    }
560
561    #[test]
562    fn wildcards_flow_through_flat_lists_too() {
563        let only_built = vec!["@types/*".to_string()];
564        let never_built = vec!["*-internal".to_string()];
565        let (p, errs) =
566            BuildPolicy::from_config(&BTreeMap::new(), &only_built, &never_built, false);
567        assert!(errs.is_empty());
568        assert_eq!(p.decide("@types/node", "20.0.0"), AllowDecision::Allow);
569        assert_eq!(p.decide("@types/react", "18.0.0"), AllowDecision::Allow);
570        assert_eq!(p.decide("acme-internal", "1.0.0"), AllowDecision::Deny);
571    }
572
573    #[test]
574    fn matches_wildcard_handles_all_positions() {
575        assert!(matches_wildcard("@babel/core", "@babel/*"));
576        assert!(matches_wildcard("@babel/", "@babel/*"));
577        assert!(!matches_wildcard("@babe/core", "@babel/*"));
578
579        assert!(matches_wildcard("css-loader", "*-loader"));
580        assert!(matches_wildcard("-loader", "*-loader"));
581        assert!(!matches_wildcard("loader-x", "*-loader"));
582
583        assert!(matches_wildcard("foobar", "foo*bar"));
584        assert!(matches_wildcard("foo-x-bar", "foo*bar"));
585        assert!(!matches_wildcard("foobaz", "foo*bar"));
586
587        assert!(matches_wildcard("@x/anything", "*"));
588        assert!(matches_wildcard("", "*"));
589
590        // Adjacent wildcards collapse to a single match, same as glob.
591        assert!(matches_wildcard("anything", "**"));
592    }
593
594    #[test]
595    fn matches_wildcard_multi_segment_greedy_is_correct() {
596        // Three+ wildcards exercise the greedy-leftmost middle-segment
597        // scan with a fixed-right suffix anchor. Each case either has a
598        // valid assignment (should match) or none (should not), and
599        // greedy-leftmost finds it whenever one exists — the fixed
600        // right anchor prevents greedy from eating characters the
601        // suffix needs.
602        assert!(matches_wildcard("abca", "*a*bc*a"));
603        assert!(matches_wildcard("xabcaYa", "*a*bc*a"));
604        assert!(matches_wildcard("abcaXa", "*a*bc*a"));
605        assert!(matches_wildcard("ababab", "*ab*ab*"));
606        assert!(matches_wildcard("abcd", "a*b*c*d"));
607        assert!(matches_wildcard("a1b2c3d", "a*b*c*d"));
608
609        // Needs two non-overlapping occurrences of the middle / last
610        // anchors but the input only provides enough characters for
611        // one, so no assignment exists.
612        assert!(!matches_wildcard("aab", "*ab*ab"));
613        assert!(!matches_wildcard("abab", "*abc*abc"));
614
615        // Four wildcards still obey the same rules.
616        assert!(matches_wildcard(
617            "@acme/core-loader-plugin",
618            "@acme/*-*-plugin"
619        ));
620        assert!(!matches_wildcard(
621            "@acme/core-plugin-extra",
622            "@acme/*-*-plugin"
623        ));
624    }
625
626    #[test]
627    fn semver_shape() {
628        assert!(is_exact_semver("1.2.3"));
629        assert!(is_exact_semver("0.19.0"));
630        assert!(is_exact_semver("1.0.0-alpha"));
631        assert!(is_exact_semver("1.0.0+build.42"));
632        assert!(!is_exact_semver("1.2"));
633        assert!(!is_exact_semver("^1.2.3"));
634        assert!(!is_exact_semver("1.x.0"));
635    }
636}