Skip to main content

cleanlib_client/
ecosystem_detect.rs

1//! CLEANLIB-691 — canonical manifest-filename → ecosystem detection.
2//!
3//! Single source of truth so every surface infers the ecosystem identically:
4//! the CLI (`cleanlib scan --packages <file>` without `--ecosystem`), the LSP,
5//! the MCP tools, and the SDK convenience wrappers. Lives in `cleanlib-client`
6//! (not the CLI) for the same reason the customer-state taxonomy does — it is a
7//! cross-surface contract, not a CLI-local helper.
8//!
9//! Detection is on the file NAME (basename). It is deliberately conservative:
10//! it recognises the canonical manifest/lockfile names and returns `None` for
11//! anything ambiguous or unrecognised, so the caller can prompt for an explicit
12//! `--ecosystem` rather than guess wrong on a security product.
13
14/// The wire ecosystem string for a manifest/lockfile path, or `None` when the
15/// filename is not a recognised manifest (the caller then asks the user to pass
16/// `--ecosystem` explicitly). Matches the App's actual canonical set
17/// (`cleanlib-app::http::SUPPORTED_ECOSYSTEMS`): npm / pypi / go / maven /
18/// crates / nuget / rubygems / composer. CLEANLIB-864: this used to also claim
19/// "packagist" (composer.json/.lock) and "pub" (pubspec.yaml/.lock) — neither
20/// is in that set, so every detection into either name failed at the App with
21/// `400 unknown_ecosystem` on every attempt. composer.json/.lock now map to
22/// the real name; pubspec.yaml/.lock are dropped (no `pub` backend exists at
23/// all, so returning `None` and letting the caller be told to pass an explicit
24/// `--ecosystem` is the honest answer, not a wrong one).
25///
26/// The `path` may be a full path or a bare filename; only the last path
27/// component (after `/` or `\`) is inspected.
28pub fn ecosystem_from_path(path: &str) -> Option<&'static str> {
29    let name = path.rsplit(['/', '\\']).next().unwrap_or(path);
30
31    // Exact canonical manifest/lockfile names (the deterministic ≥95% case).
32    let eco = match name {
33        "package.json" | "package-lock.json" | "npm-shrinkwrap.json" | "yarn.lock"
34        | "pnpm-lock.yaml" => "npm",
35        "requirements.txt" | "Pipfile" | "Pipfile.lock" | "pyproject.toml" | "poetry.lock"
36        | "setup.py" => "pypi",
37        "Cargo.toml" | "Cargo.lock" => "crates",
38        "go.mod" | "go.sum" => "go",
39        "pom.xml" | "build.gradle" | "build.gradle.kts" => "maven",
40        // CLEANLIB-864: was "packagist" -- the App's actual customer-verdict
41        // route rejects that name outright (`SUPPORTED_ECOSYSTEMS` in
42        // cleanlib-app::http.rs is the 8-name canonical set: npm/pypi/go/
43        // maven/crates/nuget/rubygems/composer, no "packagist"). Live-confirmed:
44        // `/v1/customer/verdicts/composer/...` -> 200, `/v1/customer/verdicts/
45        // packagist/...` -> 400 unknown_ecosystem. This function's ONE job is
46        // to hand callers a name the rest of the platform accepts, so it was
47        // simply wrong, not a stylistic choice.
48        "composer.json" | "composer.lock" => "composer",
49        "Gemfile" | "Gemfile.lock" => "rubygems",
50        "packages.config" => "nuget",
51        // CLEANLIB-864: `pubspec.yaml`/`pubspec.lock` (Dart/Flutter) used to
52        // detect as "pub", which has NO backend at all -- not even the
53        // audit-only fallback vocabulary the App's other ecosystem list
54        // carries covers actually serving verdicts for it. Detecting a
55        // manifest into a name that can never resolve is worse than not
56        // detecting it: the caller gets a confident answer that is
57        // deterministically wrong on every attempt, forever, with no
58        // `--ecosystem` override able to fix it since no real ecosystem
59        // exists to pass. Dropped entirely so these fall through to `None`
60        // below (the same honest "ask the user" path used for any other
61        // unrecognised manifest) until a real `pub` backend exists.
62        _ => "",
63    };
64    if !eco.is_empty() {
65        return Some(eco);
66    }
67
68    // Patterned names the exact match can't enumerate:
69    //  - `requirements-dev.txt` / `requirements-test.txt` … → pypi
70    //  - `<project>.csproj` → nuget
71    if name.starts_with("requirements-") && name.ends_with(".txt") {
72        return Some("pypi");
73    }
74    if name.ends_with(".csproj") {
75        return Some("nuget");
76    }
77
78    None
79}
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84
85    #[test]
86    fn canonical_manifests_map_to_their_ecosystem() {
87        let cases = [
88            ("package.json", "npm"),
89            ("package-lock.json", "npm"),
90            ("yarn.lock", "npm"),
91            ("pnpm-lock.yaml", "npm"),
92            ("requirements.txt", "pypi"),
93            ("pyproject.toml", "pypi"),
94            ("Pipfile", "pypi"),
95            ("poetry.lock", "pypi"),
96            ("Cargo.toml", "crates"),
97            ("Cargo.lock", "crates"),
98            ("go.mod", "go"),
99            ("go.sum", "go"),
100            ("pom.xml", "maven"),
101            ("build.gradle", "maven"),
102            ("build.gradle.kts", "maven"),
103            // CLEANLIB-864: was "packagist" -- the App's real customer-verdict
104            // route rejects that name (400 unknown_ecosystem); the canonical
105            // name is "composer". This assertion used to pin the WRONG value
106            // -- it passed because it agreed with the bug, not because the
107            // bug was absent.
108            ("composer.json", "composer"),
109            ("composer.lock", "composer"),
110            ("Gemfile", "rubygems"),
111            ("Gemfile.lock", "rubygems"),
112            ("packages.config", "nuget"),
113        ];
114        for (file, eco) in cases {
115            assert_eq!(ecosystem_from_path(file), Some(eco), "{file}");
116        }
117    }
118
119    #[test]
120    fn cleanlib_864_pub_is_no_longer_detected_no_backend_exists() {
121        // pubspec.yaml/.lock used to detect as "pub", a name the App never
122        // accepts on the customer-verdict route (no backend exists for it at
123        // all). Dropped entirely -- must now fall through to the same honest
124        // `None` (ask for --ecosystem) as any other unrecognised manifest,
125        // not silently resolve to a name that can never work.
126        assert_eq!(ecosystem_from_path("pubspec.yaml"), None);
127        assert_eq!(ecosystem_from_path("pubspec.lock"), None);
128    }
129
130    #[test]
131    fn inspects_only_the_basename_of_a_full_path() {
132        assert_eq!(ecosystem_from_path("/home/dev/proj/package.json"), Some("npm"));
133        assert_eq!(ecosystem_from_path("./sub/dir/Cargo.toml"), Some("crates"));
134        // Windows-style separators too.
135        assert_eq!(ecosystem_from_path(r"C:\proj\go.mod"), Some("go"));
136    }
137
138    #[test]
139    fn patterned_names_are_detected() {
140        assert_eq!(ecosystem_from_path("requirements-dev.txt"), Some("pypi"));
141        assert_eq!(ecosystem_from_path("requirements-test.txt"), Some("pypi"));
142        assert_eq!(ecosystem_from_path("MyApp.csproj"), Some("nuget"));
143    }
144
145    #[test]
146    fn unrecognised_or_ambiguous_returns_none() {
147        // A random file, an extensionless name, and a bare directory-ish path
148        // must NOT be guessed — the caller prompts for explicit --ecosystem.
149        assert_eq!(ecosystem_from_path("deps.txt"), None);
150        assert_eq!(ecosystem_from_path("README.md"), None);
151        assert_eq!(ecosystem_from_path("manifest"), None);
152        assert_eq!(ecosystem_from_path(""), None);
153    }
154
155    /// Contract guard credited to a human engineer's independently-authored
156    /// PR (#568/#569 — same CLEANLIB-864/865 root causes, arriving after this
157    /// crate's own fix had already merged). Their PR added exactly this
158    /// regression class of test where ours didn't: it would have caught the
159    /// original bug (`ecosystem_from_path` emitting `"packagist"`, a name
160    /// cleanlib-app's `SUPPORTED_ECOSYSTEMS` rejects outright) automatically,
161    /// rather than needing a human to notice a live 400 by hand.
162    ///
163    /// `WIRE_ACCEPTED` is verified directly against
164    /// `cleanlib-app/src/http.rs::SUPPORTED_ECOSYSTEMS` (not just copied from
165    /// this file's own comments) — that's the actual wire contract this
166    /// function must never violate.
167    #[test]
168    fn every_detected_ecosystem_is_wire_accepted() {
169        const WIRE_ACCEPTED: [&str; 8] =
170            ["npm", "pypi", "go", "maven", "crates", "nuget", "rubygems", "composer"];
171
172        // Every manifest filename this function recognizes: the full exact-
173        // match table, plus one representative name per patterned case.
174        //
175        // `pubspec.yaml`/`pubspec.lock` are included even though this crate's
176        // CLEANLIB-864 fix took a different (equally valid) shape than
177        // #568/#569's KNOWN_GAP-list approach: rather than keep detecting
178        // `"pub"` and documenting it as an accepted exception, this crate
179        // drops `pub` detection entirely — see
180        // `cleanlib_864_pub_is_no_longer_detected_no_backend_exists` above —
181        // so they fall through to `None` and this loop's `continue` skips
182        // them today. They stay in the input set anyway so a future
183        // reintroduction of `pub` detection would be caught by this same
184        // guard instead of silently missed.
185        let manifests = [
186            "package.json",
187            "package-lock.json",
188            "npm-shrinkwrap.json",
189            "yarn.lock",
190            "pnpm-lock.yaml",
191            "requirements.txt",
192            "Pipfile",
193            "Pipfile.lock",
194            "pyproject.toml",
195            "poetry.lock",
196            "setup.py",
197            "Cargo.toml",
198            "Cargo.lock",
199            "go.mod",
200            "go.sum",
201            "pom.xml",
202            "build.gradle",
203            "build.gradle.kts",
204            "composer.json",
205            "composer.lock",
206            "Gemfile",
207            "Gemfile.lock",
208            "packages.config",
209            "pubspec.yaml",
210            "pubspec.lock",
211            // patterned cases
212            "requirements-dev.txt",
213            "MyApp.csproj",
214        ];
215        for m in manifests {
216            let Some(eco) = ecosystem_from_path(m) else {
217                continue;
218            };
219            assert!(
220                WIRE_ACCEPTED.contains(&eco),
221                "{m} detects as `{eco}`, which cleanlib-app's SUPPORTED_ECOSYSTEMS rejects"
222            );
223        }
224    }
225}