Skip to main content

fleetreach_cli/
reach.rs

1//! `--reachability`: a **heuristic** source-presence check — NOT static call-graph
2//! reachability analysis.
3//!
4//! Two complementary signals, both build-free greps of the repo's *own* source:
5//!
6//! - **Cargo (symbol-presence):** for findings whose advisory names functions, grep the
7//!   affected repos' `.rs` source for those names ("do I call any affected function in my
8//!   code?"). Sets `Some(true)`/`Some(false)`.
9//! - **Tier-C feeders (import-presence):** grep the repo's source for use of a **direct**
10//!   dependency. For npm / Julia / RubyGems the lockfile coordinate is the import name (exact);
11//!   for PyPI / NuGet / Maven / Packagist / Swift / Hex the coordinate differs from the import
12//!   name, so the predicate derives import-name *candidates* from the coordinate (a per-ecosystem
13//!   heuristic — e.g. Hex `foo_bar` → `FooBar` module). For GitHub Actions a `uses:` reference is
14//!   an active CI step, a sound-positive signal. Either way this only ever raises a finding to
15//!   `Some(true)` on a positive match;
16//!   it **never** emits `Some(false)`, because a grep can miss an import (dynamic `require`,
17//!   re-export, an irregular dist→module name) and a false `Some(false)` would let
18//!   `--reachable-only` drop a real vulnerability. So the worst a Tier-C miss can do is leave
19//!   `reachable = None` (unknown) — never a false-clean; and a heuristic over-match only
20//!   over-reports reachability (safe).
21//!
22//! Verdict meaning:
23//! - `Some(true)`  — a name/import appears in your source (possibly reachable).
24//! - `Some(false)` — Cargo only: no affected name appears in your source (could *still* be
25//!   reached via a dependency — this only scans your code).
26//! - `None`        — not checked, advisory names no functions, or a Tier-C dep not found
27//!   imported (unknown, never auto-suppressed).
28//!
29//! A `false` never proves the vuln is unreachable, so it never auto-suppresses by default —
30//! `--reachable-only` is a separate, explicit opt-in.
31
32use std::path::Path;
33
34use fleetreach_core::{DependencyKind, Ecosystem, FleetReport, Occurrence};
35use walkdir::WalkDir;
36
37use crate::config::Config;
38
39/// Annotate each vulnerability's `reachable` from the source-presence heuristic.
40pub fn assess(report: &mut FleetReport, config: &Config) {
41    for finding in &mut report.vulnerabilities {
42        if finding.ecosystem.is_cargo() {
43            assess_cargo_symbols(finding, config);
44        } else if let Some(scan) = import_scanner(finding.ecosystem) {
45            assess_tier_c_imports(finding, config, scan);
46        }
47        // Go has its own (govulncheck) engine and is handled in the scan path, not here.
48    }
49}
50
51// --- Cargo symbol-presence (the original heuristic, unchanged) ---
52
53fn assess_cargo_symbols(finding: &mut fleetreach_core::VulnFinding, config: &Config) {
54    if finding.affected_functions.is_empty() {
55        return; // nothing to look for -> leave None (unknown)
56    }
57    // The function/type short names to search for.
58    let names: Vec<&str> = finding
59        .affected_functions
60        .iter()
61        .map(|p| p.rsplit("::").next().unwrap_or(p.as_str()))
62        .collect();
63    // The repos in which this finding appears.
64    let repos: std::collections::BTreeSet<&str> = finding
65        .occurrences
66        .iter()
67        .filter_map(|o| match o {
68            Occurrence::InRepo { repo, .. } => Some(repo.0.as_str()),
69            Occurrence::Toolchain { .. } => None,
70        })
71        .collect();
72
73    let found = repos.iter().any(|repo_id| {
74        config
75            .repos
76            .iter()
77            .find(|r| r.id.0 == *repo_id)
78            .is_some_and(|r| source_mentions_symbol(&r.path, &names))
79    });
80    finding.reachable = Some(found);
81}
82
83/// Does any `.rs` file under `dir` (excluding `target/`) mention any of `names`?
84fn source_mentions_symbol(dir: &Path, names: &[&str]) -> bool {
85    scan_source(dir, &["rs"], &[], |text| {
86        names.iter().any(|n| mentions(text, n))
87    })
88}
89
90/// A crude call/path test: the name used as a call `name(`, a method `.name`, or
91/// a path `::name`. Reduces (does not eliminate) coincidental matches.
92fn mentions(text: &str, name: &str) -> bool {
93    text.contains(&format!("{name}("))
94        || text.contains(&format!(".{name}"))
95        || text.contains(&format!("::{name}"))
96}
97
98// --- Tier-C import-presence ---
99
100/// A pure per-file-text predicate: does this source text import `package`?
101type ImportPredicate = fn(text: &str, package: &str) -> bool;
102
103/// The source globs + import predicate for each Tier-C ecosystem. For npm/Julia/RubyGems the
104/// lockfile coordinate IS the source import name (exact). For PyPI/NuGet/Maven/Packagist/Swift/Hex
105/// the coordinate ≠ the import name, so the predicate derives import-name *candidates* from the
106/// coordinate (a heuristic) — but because this only ever raises a finding to `Some(true)` and
107/// never to `Some(false)`, a heuristic miss is harmless (the finding stays `None`, never a
108/// false-clean) and a coincidental hit only over-reports reachability. GitHub Actions is exact
109/// (a `uses:` reference). Only Go is `None` here (it has its own govulncheck engine).
110fn import_scanner(eco: Ecosystem) -> Option<(&'static [&'static str], ImportPredicate)> {
111    match eco {
112        // Coordinate == import name (exact).
113        Ecosystem::Npm => Some((&["js", "mjs", "cjs", "ts", "tsx", "jsx"], npm_imports_text)),
114        Ecosystem::Julia => Some((&["jl"], julia_imports_text)),
115        Ecosystem::RubyGems => Some((&["rb", "rake"], rubygems_imports_text)),
116        // Coordinate → import-name candidates (heuristic, fail-open-to-unknown).
117        Ecosystem::Pypi => Some((&["py"], pypi_imports_text)),
118        Ecosystem::NuGet => Some((&["cs", "fs", "vb"], nuget_imports_text)),
119        Ecosystem::Maven => Some((&["java", "kt", "scala", "groovy"], maven_imports_text)),
120        Ecosystem::Packagist => Some((&["php"], packagist_imports_text)),
121        Ecosystem::Swift => Some((&["swift"], swift_imports_text)),
122        Ecosystem::Hex => Some((&["ex", "exs"], hex_module_used_text)),
123        // A referenced action actively runs in CI (sound-positive, not a heuristic).
124        Ecosystem::GitHubActions => Some((&["yml", "yaml"], ghactions_uses_text)),
125        _ => None,
126    }
127}
128
129/// Raise a Tier-C finding to `Some(true)` if a **direct** dependency it names is imported in
130/// any affected repo's own source. Never sets `Some(false)` — see the module docs.
131fn assess_tier_c_imports(
132    finding: &mut fleetreach_core::VulnFinding,
133    config: &Config,
134    (exts, pred): (&'static [&'static str], ImportPredicate),
135) {
136    let imported = finding.occurrences.iter().any(|o| match o {
137        Occurrence::InRepo {
138            repo,
139            package,
140            dependency_kind: DependencyKind::Direct,
141            ..
142        } => config
143            .repos
144            .iter()
145            .find(|r| r.id.0 == repo.0)
146            .is_some_and(|r| scan_source(&r.path, exts, &[], |text| pred(text, package))),
147        // Transitive deps are expected to be absent from your source (a dependency uses
148        // them, not you), so they carry no import signal — leave them unknown.
149        _ => false,
150    });
151    if imported {
152        finding.reachable = Some(true);
153    }
154}
155
156/// npm: a `require`/`import`/dynamic-`import()` whose module specifier is `pkg` or a `pkg/…`
157/// subpath. Scoped names (`@scope/name`) work verbatim. The import keyword must be on the
158/// same line to keep coincidental string literals from matching.
159fn npm_imports_text(text: &str, pkg: &str) -> bool {
160    let specifiers = [
161        format!("'{pkg}'"),
162        format!("\"{pkg}\""),
163        format!("'{pkg}/"),
164        format!("\"{pkg}/"),
165    ];
166    text.lines().any(|line| {
167        (line.contains("require") || line.contains("import") || line.contains("from"))
168            && specifiers.iter().any(|s| line.contains(s.as_str()))
169    })
170}
171
172/// Julia: a `using`/`import` statement that names the package as a whole word
173/// (`using Foo`, `import Foo, Bar`, `import Foo: x`, `using Foo.Sub`).
174fn julia_imports_text(text: &str, pkg: &str) -> bool {
175    text.lines().any(|line| {
176        let t = line.trim_start();
177        (t.starts_with("using ") || t.starts_with("import ")) && word_present(line, pkg)
178    })
179}
180
181/// RubyGems: a `require 'gem'` / `require "gem"` (or a `gem/…` subpath). Some gems require a
182/// path that differs from the gem name (e.g. `activesupport` → `require 'active_support'`);
183/// those simply stay unknown (`None`) rather than risk a false `Some(false)`.
184fn rubygems_imports_text(text: &str, pkg: &str) -> bool {
185    let needles = [
186        format!("'{pkg}'"),
187        format!("\"{pkg}\""),
188        format!("'{pkg}/"),
189        format!("\"{pkg}/"),
190    ];
191    text.lines()
192        .any(|line| line.contains("require") && needles.iter().any(|n| line.contains(n.as_str())))
193}
194
195/// PyPI: `import mod` / `from mod import …`. The PyPI **dist** name usually maps to a module by
196/// lowercasing and turning `-`/`.` into `_` (`Flask`→`flask`, `python-dateutil`→`python_dateutil`).
197/// Irregular maps (`PyYAML`→`yaml`, `beautifulsoup4`→`bs4`) simply miss → stay unknown.
198fn pypi_imports_text(text: &str, pkg: &str) -> bool {
199    let module = pkg.to_ascii_lowercase().replace(['-', '.'], "_");
200    let candidates = [module, pkg.to_ascii_lowercase()];
201    text.lines().any(|line| {
202        let t = line.trim_start();
203        (t.starts_with("import ") || t.starts_with("from "))
204            && candidates
205                .iter()
206                .any(|c| !c.is_empty() && word_present(line, c))
207    })
208}
209
210/// NuGet: a `using Some.Namespace;`. The root .NET namespace is usually the package id
211/// (`Newtonsoft.Json` → `using Newtonsoft.Json;` / `using Newtonsoft.Json.Linq;`).
212fn nuget_imports_text(text: &str, pkg: &str) -> bool {
213    text.lines().any(|line| {
214        let t = line.trim_start();
215        t.strip_prefix("using ")
216            .or_else(|| t.strip_prefix("global using "))
217            .map(str::trim_start)
218            .is_some_and(|rest| namespace_starts_with(rest, pkg))
219    })
220}
221
222/// Maven: a Java/Kotlin `import group.subpkg.Class;`. The Java package is not the
223/// `group:artifact` coordinate, but it almost always starts with the **group** (the org's
224/// reverse-DNS), so match the group as the import prefix.
225fn maven_imports_text(text: &str, pkg: &str) -> bool {
226    let Some((group, _artifact)) = pkg.split_once(':') else {
227        return false;
228    };
229    if group.is_empty() {
230        return false;
231    }
232    text.lines().any(|line| {
233        let t = line.trim_start();
234        t.strip_prefix("import ")
235            .map(|r| r.strip_prefix("static ").unwrap_or(r))
236            .map(str::trim_start)
237            .is_some_and(|rest| namespace_starts_with(rest, group))
238    })
239}
240
241/// Packagist: a PHP `use Vendor\Pkg\…;`. The PSR-4 namespace is not in the lockfile, but it is
242/// usually a PascalCase of the `vendor`/`name` segments (`monolog/monolog` → `Monolog\`,
243/// `symfony/http-kernel` → `…\HttpKernel\`). Match a `use` statement whose namespace contains the
244/// PascalCased package segment.
245fn packagist_imports_text(text: &str, pkg: &str) -> bool {
246    let candidates: Vec<String> = pkg
247        .split('/')
248        .map(pascal_case)
249        .filter(|c| !c.is_empty())
250        .collect();
251    if candidates.is_empty() {
252        return false;
253    }
254    text.lines().any(|line| {
255        let t = line.trim_start();
256        t.starts_with("use ") && candidates.iter().any(|c| namespace_segment_present(t, c))
257    })
258}
259
260/// Swift: an `import Module`. The module name is not the `owner/repo` identity, but it is often
261/// the repo name with a leading `swift-` stripped (`swift-nio` → `NIO`, matched case-insensitively).
262/// Weak by nature — many modules diverge — but a hit only adds a (true) signal, never suppresses.
263fn swift_imports_text(text: &str, pkg: &str) -> bool {
264    let id = pkg.rsplit('/').next().unwrap_or(pkg);
265    let stripped = id
266        .strip_prefix("swift-")
267        .or_else(|| id.strip_prefix("Swift"))
268        .unwrap_or(id);
269    let candidates = [id.to_string(), stripped.replace('-', "")];
270    text.lines().any(|line| {
271        let t = line.trim_start();
272        t.strip_prefix("import ").map(str::trim).is_some_and(|m| {
273            candidates
274                .iter()
275                .any(|c| !c.is_empty() && m.eq_ignore_ascii_case(c))
276        })
277    })
278}
279
280/// Hex (Elixir): a package `foo_bar` exposes a `FooBar` module, referenced as a qualified call
281/// (`FooBar.run`, `Plug.Conn`) or named in an `alias`/`import`/`use`/`require` directive. Irregular
282/// module names (`ecto_sql` → `Ecto.SQL`, `gen_stage` → `GenStage`) may miss, which is safe — a
283/// miss only leaves the finding `None`.
284fn hex_module_used_text(text: &str, pkg: &str) -> bool {
285    let module = pascal_case(pkg);
286    if module.is_empty() {
287        return false;
288    }
289    text.lines().any(|line| {
290        let t = line.trim_start();
291        let directive = (t.starts_with("alias ")
292            || t.starts_with("import ")
293            || t.starts_with("use ")
294            || t.starts_with("require "))
295            && word_present(t, &module);
296        directive || module_qualified(line, &module)
297    })
298}
299
300/// Whether `module` appears as a qualified-call head (`Module.`) at a segment boundary — the
301/// char before is neither an identifier char nor `.` (so a submodule `MyApp.Plug.` is not a
302/// match for `Plug`).
303fn module_qualified(line: &str, module: &str) -> bool {
304    let needle = format!("{module}.");
305    line.match_indices(&needle).any(|(i, _)| {
306        line[..i]
307            .chars()
308            .next_back()
309            .is_none_or(|c| !is_ident_char(c) && c != '.')
310    })
311}
312
313/// GitHub Actions: a `uses: owner/repo[/subpath]@ref` step. The package id is the lowercased
314/// `owner/repo[/subpath]`; a workflow that `uses:` it is actively invoking it in CI, so a match
315/// is effectively sound-positive (the finding came from such a line in the first place).
316fn ghactions_uses_text(text: &str, pkg: &str) -> bool {
317    let needle = format!("{pkg}@");
318    text.lines().any(|line| {
319        let low = line.to_ascii_lowercase();
320        low.contains("uses:") && low.contains(&needle)
321    })
322}
323
324/// Whether a dotted namespace path (`Newtonsoft.Json.Linq;`) starts with `prefix` at a segment
325/// boundary (so `prefix` is followed by `.`, `;`, whitespace, or end — not more identifier).
326fn namespace_starts_with(path: &str, prefix: &str) -> bool {
327    path.strip_prefix(prefix)
328        .is_some_and(|rest| rest.chars().next().is_none_or(|c| !is_ident_char(c)))
329}
330
331/// Whether a PHP `use` line names `segment` as a whole `\`-delimited namespace segment.
332fn namespace_segment_present(line: &str, segment: &str) -> bool {
333    line.match_indices(segment).any(|(i, _)| {
334        let before = line[..i].chars().next_back();
335        let after = line[i + segment.len()..].chars().next();
336        before.is_none_or(|c| c == '\\' || c == ' ') && after.is_none_or(|c| !is_ident_char(c))
337    })
338}
339
340fn is_ident_char(c: char) -> bool {
341    c.is_ascii_alphanumeric() || c == '_'
342}
343
344/// PascalCase a `-`/`_`-separated identifier (`http-kernel` → `HttpKernel`).
345fn pascal_case(s: &str) -> String {
346    s.split(['-', '_'])
347        .filter(|w| !w.is_empty())
348        .map(|w| {
349            let mut chars = w.chars();
350            match chars.next() {
351                Some(first) => first.to_ascii_uppercase().to_string() + chars.as_str(),
352                None => String::new(),
353            }
354        })
355        .collect()
356}
357
358/// Whether `word` appears in `hay` bounded by non-identifier characters (so `Foo` does not
359/// match inside `FooBar`). Package names here are ASCII, so byte boundaries are safe.
360fn word_present(hay: &str, word: &str) -> bool {
361    if word.is_empty() {
362        return false;
363    }
364    let bytes = hay.as_bytes();
365    let mut from = 0;
366    while let Some(rel) = hay[from..].find(word) {
367        let start = from + rel;
368        let end = start + word.len();
369        let before_ok = start == 0 || !is_ident_byte(bytes[start - 1]);
370        let after_ok = end >= bytes.len() || !is_ident_byte(bytes[end]);
371        if before_ok && after_ok {
372            return true;
373        }
374        from = start + 1;
375    }
376    false
377}
378
379fn is_ident_byte(b: u8) -> bool {
380    b.is_ascii_alphanumeric() || b == b'_'
381}
382
383/// Walk `dir` for files with one of `exts` (or an exact name in `names`), skipping vendored
384/// directories, and return true as soon as `pred` matches a file's text.
385fn scan_source(dir: &Path, exts: &[&str], names: &[&str], pred: impl Fn(&str) -> bool) -> bool {
386    const SKIP: &[&str] = &["target", "node_modules", "vendor", ".git", "dist", "build"];
387    WalkDir::new(dir)
388        .into_iter()
389        .filter_entry(|e| !SKIP.contains(&e.file_name().to_str().unwrap_or("")))
390        .filter_map(Result::ok)
391        .filter(|e| e.file_type().is_file())
392        .filter(|e| {
393            let p = e.path();
394            let ext_ok = p
395                .extension()
396                .and_then(|x| x.to_str())
397                .is_some_and(|x| exts.contains(&x));
398            let name_ok = p
399                .file_name()
400                .and_then(|n| n.to_str())
401                .is_some_and(|n| names.contains(&n));
402            ext_ok || name_ok
403        })
404        .any(|e| {
405            std::fs::read_to_string(e.path())
406                .map(|text| pred(&text))
407                .unwrap_or(false)
408        })
409}
410
411#[cfg(test)]
412mod tests {
413    #![allow(clippy::unwrap_used)]
414    use super::*;
415
416    #[test]
417    fn npm_detects_require_and_import_forms() {
418        assert!(npm_imports_text("const _ = require('lodash')", "lodash"));
419        assert!(npm_imports_text("import x from \"lodash\"", "lodash"));
420        assert!(npm_imports_text("import { a } from 'lodash/fp'", "lodash"));
421        assert!(npm_imports_text("await import('lodash')", "lodash"));
422        assert!(npm_imports_text("import x from '@scope/pkg'", "@scope/pkg"));
423        // a bare string literal is not an import, and a different package is not a match
424        assert!(!npm_imports_text("const s = 'lodash'", "lodash"));
425        assert!(!npm_imports_text("require('lodash-es')", "lodash"));
426        assert!(!npm_imports_text("import x from 'react'", "lodash"));
427    }
428
429    #[test]
430    fn julia_detects_using_and_import_whole_word() {
431        assert!(julia_imports_text("using HTTP", "HTTP"));
432        assert!(julia_imports_text("  import HTTP", "HTTP"));
433        assert!(julia_imports_text("using HTTP, JSON", "JSON"));
434        assert!(julia_imports_text("import HTTP: get", "HTTP"));
435        assert!(julia_imports_text("using HTTP.Sub", "HTTP"));
436        // whole-word: HTTP must not match inside HTTPClient
437        assert!(!julia_imports_text("using HTTPClient", "HTTP"));
438        // not an import line
439        assert!(!julia_imports_text("x = HTTP", "HTTP"));
440    }
441
442    #[test]
443    fn rubygems_detects_require_forms() {
444        assert!(rubygems_imports_text("require 'rack'", "rack"));
445        assert!(rubygems_imports_text("require \"rack\"", "rack"));
446        assert!(rubygems_imports_text("require 'rack/utils'", "rack"));
447        // a require for a different gem, and a non-require mention, do not match
448        assert!(!rubygems_imports_text("require 'rackup'", "rack"));
449        assert!(!rubygems_imports_text("rack = 1", "rack"));
450    }
451
452    #[test]
453    fn word_present_respects_boundaries() {
454        assert!(word_present("using Foo, Bar", "Foo"));
455        assert!(word_present("a Foo b", "Foo"));
456        assert!(!word_present("Foobar", "Foo"));
457        assert!(!word_present("myFoo", "Foo"));
458    }
459
460    #[test]
461    fn pypi_maps_dist_name_to_module() {
462        assert!(pypi_imports_text("import requests", "requests"));
463        assert!(pypi_imports_text("from flask import Flask", "Flask")); // case-fold
464        assert!(pypi_imports_text(
465            "import python_dateutil",
466            "python-dateutil"
467        )); // - -> _
468        assert!(pypi_imports_text("import requests.sessions", "requests"));
469        // not an import line, and an irregular map (PyYAML->yaml) misses (stays unknown)
470        assert!(!pypi_imports_text("x = requests", "requests"));
471        assert!(!pypi_imports_text("import yaml", "PyYAML"));
472    }
473
474    #[test]
475    fn nuget_matches_using_namespace() {
476        assert!(nuget_imports_text(
477            "using Newtonsoft.Json;",
478            "Newtonsoft.Json"
479        ));
480        assert!(nuget_imports_text(
481            "using Newtonsoft.Json.Linq;",
482            "Newtonsoft.Json"
483        ));
484        assert!(nuget_imports_text("global using Serilog;", "Serilog"));
485        // a different package and a non-using line do not match
486        assert!(!nuget_imports_text(
487            "using Newtonsoft.JsonNet;",
488            "Newtonsoft.Json"
489        ));
490        assert!(!nuget_imports_text("var x = Serilog;", "Serilog"));
491    }
492
493    #[test]
494    fn maven_matches_group_import_prefix() {
495        let coord = "org.apache.logging.log4j:log4j-core";
496        assert!(maven_imports_text(
497            "import org.apache.logging.log4j.Logger;",
498            coord
499        ));
500        assert!(maven_imports_text(
501            "import static org.apache.logging.log4j.Level.INFO;",
502            coord
503        ));
504        // a different group does not match
505        assert!(!maven_imports_text("import org.slf4j.Logger;", coord));
506    }
507
508    #[test]
509    fn packagist_matches_pascal_cased_namespace() {
510        assert!(packagist_imports_text(
511            "use Monolog\\Logger;",
512            "monolog/monolog"
513        ));
514        assert!(packagist_imports_text(
515            "use Symfony\\Component\\HttpKernel\\Kernel;",
516            "symfony/http-kernel"
517        ));
518        // not a use line
519        assert!(!packagist_imports_text(
520            "$x = new Monolog();",
521            "monolog/monolog"
522        ));
523    }
524
525    #[test]
526    fn swift_strips_prefix_and_matches_module() {
527        assert!(swift_imports_text("import NIO", "swift-nio"));
528        assert!(swift_imports_text("import Vapor", "vapor/vapor"));
529        // a non-import line does not match
530        assert!(!swift_imports_text("let nio = 1", "swift-nio"));
531    }
532
533    #[test]
534    fn pascal_case_splits_separators() {
535        assert_eq!(pascal_case("http-kernel"), "HttpKernel");
536        assert_eq!(pascal_case("monolog"), "Monolog");
537        assert_eq!(pascal_case("php_unit"), "PhpUnit");
538    }
539
540    #[test]
541    fn hex_matches_pascal_module_usage() {
542        // qualified call, and alias/import/use/require directives
543        assert!(hex_module_used_text(
544            "    Plug.Conn.send_resp(conn)",
545            "plug"
546        ));
547        assert!(hex_module_used_text(
548            "  alias Phoenix.Controller",
549            "phoenix"
550        ));
551        assert!(hex_module_used_text("  use Phoenix.Router", "phoenix"));
552        assert!(hex_module_used_text("import FooBar", "foo_bar")); // foo_bar -> FooBar
553                                                                   // a submodule of another app is NOT a match for the bare module
554        assert!(!hex_module_used_text("MyApp.Plug.call()", "plug"));
555        // a non-usage mention does not match
556        assert!(!hex_module_used_text("# plug is great", "plug"));
557    }
558
559    #[test]
560    fn ghactions_matches_uses_reference() {
561        assert!(ghactions_uses_text(
562            "      - uses: actions/checkout@v4",
563            "actions/checkout"
564        ));
565        // case-insensitive (GitHub treats owner/repo case-insensitively)
566        assert!(ghactions_uses_text(
567            "      - uses: Actions/Checkout@v4",
568            "actions/checkout"
569        ));
570        // subpath action id (the coordinate includes the subpath)
571        assert!(ghactions_uses_text(
572            "      - uses: github/codeql-action/analyze@v3",
573            "github/codeql-action/analyze"
574        ));
575        // a different action, and a non-uses line, do not match
576        assert!(!ghactions_uses_text(
577            "      - uses: actions/setup-node@v4",
578            "actions/checkout"
579        ));
580        assert!(!ghactions_uses_text(
581            "  name: actions/checkout",
582            "actions/checkout"
583        ));
584    }
585}