Skip to content

g1t/services/security/src/manifests.rs

366 lines13,959 bytesCodeBlame
1//! Which dependencies a project names itself, from its manifests: what
2//! version updates keep current by default (`allow`'s `direct`), and
3//! whether each is for production or development.
4
5use serde_json::Value;
6
7/// How a project depends on a package.
8#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
9pub enum DependencyType {
10 /// Named in a manifest, needed to run.
11 Production,
12 /// Named in a manifest, needed only to build or test.
13 Development,
14 /// Not named: something else depends on it.
15 Indirect,
16}
17
18impl DependencyType {
19 /// As update pull requests' commit messages put it.
20 pub fn label(self) -> &'static str {
21 match self {
22 DependencyType::Production => "direct:production",
23 DependencyType::Development => "direct:development",
24 DependencyType::Indirect => "indirect",
25 }
26 }
27
28 pub fn direct(self) -> bool {
29 self != DependencyType::Indirect
30 }
31
32 /// Whether `allow`'s or a group's `dependency-type` covers it.
33 pub fn is(self, kind: &str) -> bool {
34 match kind {
35 "all" => true,
36 "direct" => self.direct(),
37 "indirect" => self == DependencyType::Indirect,
38 "production" => self == DependencyType::Production,
39 "development" => self == DependencyType::Development,
40 _ => false,
41 }
42 }
43}
44
45/// A dependency a manifest names.
46#[derive(Clone, Debug, PartialEq, Eq)]
47pub struct Declared {
48 pub name: String,
49 /// The requirement as written: `^1.2.0`, `1.2`, `>=2,<3`; none when it
50 /// names no version.
51 pub requirement: Option<String>,
52 pub kind: DependencyType,
53}
54
55/// The manifest files each supported ecosystem keeps in a directory.
56pub fn manifest_names(ecosystem: &str) -> &'static [&'static str] {
57 match ecosystem {
58 "npm" => &["package.json"],
59 "cargo" => &["Cargo.toml"],
60 "gomod" => &["go.mod"],
61 "pip" => &["requirements.txt", "pyproject.toml"],
62 _ => &[],
63 }
64}
65
66fn push(found: &mut Vec<Declared>, name: &str, requirement: Option<String>, kind: DependencyType) {
67 if name.is_empty() {
68 return;
69 }
70 match found.iter_mut().find(|known| known.name == name) {
71 // Production wins over development when a package is both.
72 Some(known) if kind < known.kind => {
73 known.kind = kind;
74 known.requirement = requirement.or(known.requirement.take());
75 }
76 Some(_) => {}
77 None => found.push(Declared { name: name.to_owned(), requirement, kind }),
78 }
79}
80
81/// `package.json`: `dependencies` and `optionalDependencies` are for
82/// production, `devDependencies` for development. Packages from anywhere
83/// but the registry (a path, a workspace, git, an alias) are left out.
84pub fn package_json(text: &str) -> Vec<Declared> {
85 let Ok(manifest) = serde_json::from_str::<Value>(text) else { return Vec::new() };
86 let mut found = Vec::new();
87 for (section, kind) in [
88 ("dependencies", DependencyType::Production),
89 ("optionalDependencies", DependencyType::Production),
90 ("devDependencies", DependencyType::Development),
91 ] {
92 let Some(entries) = manifest.get(section).and_then(Value::as_object) else { continue };
93 for (name, range) in entries {
94 let Some(range) = range.as_str() else { continue };
95 if range.contains(':') || range.contains('/') {
96 continue;
97 }
98 push(&mut found, name, Some(range.to_owned()), kind);
99 }
100 }
101 found
102}
103
104/// `Cargo.toml`: `[dependencies]` and `[build-dependencies]` are for
105/// production, `[dev-dependencies]` for development, in a workspace's
106/// `[workspace.dependencies]` and per target too. A crate renamed with
107/// `package = "…"` is listed by its real name; one from a path or git with
108/// no version is left out.
109pub fn cargo_toml(text: &str) -> Vec<Declared> {
110 let Ok(manifest) = toml::from_str::<toml::Value>(text) else { return Vec::new() };
111 let mut found = Vec::new();
112 let mut read = |table: Option<&toml::Value>, kind: DependencyType| {
113 let Some(table) = table.and_then(toml::Value::as_table) else { return };
114 for (key, spec) in table {
115 let (name, requirement) = match spec {
116 toml::Value::String(requirement) => (key.clone(), Some(requirement.clone())),
117 toml::Value::Table(fields) => {
118 if fields.get("workspace").and_then(toml::Value::as_bool) == Some(true) {
119 continue;
120 }
121 let Some(version) = fields.get("version").and_then(toml::Value::as_str) else { continue };
122 let name = fields.get("package").and_then(toml::Value::as_str).unwrap_or(key);
123 (name.to_owned(), Some(version.to_owned()))
124 }
125 _ => continue,
126 };
127 push(&mut found, &name, requirement, kind);
128 }
129 };
130 let sections = [
131 ("dependencies", DependencyType::Production),
132 ("build-dependencies", DependencyType::Production),
133 ("dev-dependencies", DependencyType::Development),
134 ];
135 for (section, kind) in sections {
136 read(manifest.get(section), kind);
137 read(manifest.get("workspace").and_then(|workspace| workspace.get(section)), kind);
138 }
139 if let Some(targets) = manifest.get("target").and_then(toml::Value::as_table) {
140 for target in targets.values() {
141 for (section, kind) in sections {
142 read(target.get(section), kind);
143 }
144 }
145 }
146 found
147}
148
149/// `go.mod`'s `require`s: those marked `// indirect` are indirect, the
150/// rest the module's own.
151pub fn go_mod(text: &str) -> Vec<Declared> {
152 let mut found = Vec::new();
153 let mut block = false;
154 for raw in text.lines() {
155 let (code, comment) = raw.split_once("//").unwrap_or((raw, ""));
156 let line = code.trim();
157 if block {
158 if line == ")" {
159 block = false;
160 continue;
161 }
162 } else if line.starts_with("require (") || line == "require(" {
163 block = true;
164 continue;
165 }
166 let line = if block { line } else if let Some(rest) = line.strip_prefix("require ") { rest.trim() } else { continue };
167 let mut words = line.split_whitespace();
168 if let (Some(module), Some(version)) = (words.next(), words.next()) {
169 let kind = if comment.trim() == "indirect" { DependencyType::Indirect } else { DependencyType::Production };
170 found.push(Declared { name: module.to_owned(), requirement: Some(version.to_owned()), kind });
171 }
172 }
173 found
174}
175
176/// A Python package name as PyPI compares it.
177pub fn python_name(name: &str) -> String {
178 name.trim().to_lowercase().replace(['_', '.'], "-")
179}
180
181/// One requirement line: `requests[security]>=2,<3 ; python_version > "3"`.
182fn python_requirement(line: &str) -> Option<(String, Option<String>)> {
183 let line = line.split(';').next()?.trim();
184 let end = line.find(|c: char| !(c.is_ascii_alphanumeric() || "-_.".contains(c))).unwrap_or(line.len());
185 let name = &line[..end];
186 if name.is_empty() {
187 return None;
188 }
189 let rest = line[end..].trim();
190 let rest = match rest.strip_prefix('[') {
191 Some(extras) => extras.split_once(']').map_or("", |(_, after)| after).trim(),
192 None => rest,
193 };
194 Some((python_name(name), (!rest.is_empty()).then(|| rest.replace(' ', ""))))
195}
196
197/// `requirements.txt`: every package it names is the project's own.
198pub fn requirements_txt(text: &str) -> Vec<Declared> {
199 let mut found = Vec::new();
200 for line in text.lines() {
201 let line = line.split(" #").next().unwrap_or_default().trim();
202 if line.is_empty() || line.starts_with('#') || line.starts_with('-') || line.contains("://") {
203 continue;
204 }
205 if let Some((name, requirement)) = python_requirement(line) {
206 push(&mut found, &name, requirement, DependencyType::Production);
207 }
208 }
209 found
210}
211
212/// `pyproject.toml`: `[project]`'s dependencies and Poetry's main group are
213/// for production; Poetry's other groups and `dev-dependencies` for
214/// development. `python` itself is left out.
215pub fn pyproject_toml(text: &str) -> Vec<Declared> {
216 let Ok(manifest) = toml::from_str::<toml::Value>(text) else { return Vec::new() };
217 let mut found = Vec::new();
218 let project = manifest.get("project");
219 for requirement in project.and_then(|p| p.get("dependencies")).and_then(toml::Value::as_array).into_iter().flatten() {
220 if let Some((name, spec)) = requirement.as_str().and_then(python_requirement) {
221 push(&mut found, &name, spec, DependencyType::Production);
222 }
223 }
224 let poetry = manifest.get("tool").and_then(|tool| tool.get("poetry"));
225 let mut table = |table: Option<&toml::Value>, kind: DependencyType| {
226 for (name, spec) in table.and_then(toml::Value::as_table).into_iter().flatten() {
227 if name == "python" {
228 continue;
229 }
230 let requirement = match spec {
231 toml::Value::String(text) => Some(text.clone()),
232 toml::Value::Table(fields) => match fields.get("version").and_then(toml::Value::as_str) {
233 Some(version) => Some(version.to_owned()),
234 None => continue,
235 },
236 _ => continue,
237 };
238 push(&mut found, &python_name(name), requirement, kind);
239 }
240 };
241 table(poetry.and_then(|p| p.get("dependencies")), DependencyType::Production);
242 table(poetry.and_then(|p| p.get("dev-dependencies")), DependencyType::Development);
243 if let Some(groups) = poetry.and_then(|p| p.get("group")).and_then(toml::Value::as_table) {
244 for group in groups.values() {
245 table(group.get("dependencies"), DependencyType::Development);
246 }
247 }
248 found
249}
250
251/// The dependencies one manifest file names, by its file name.
252pub fn declared(file_name: &str, text: &str) -> Vec<Declared> {
253 match file_name {
254 "package.json" => package_json(text),
255 "Cargo.toml" => cargo_toml(text),
256 "go.mod" => go_mod(text),
257 "requirements.txt" => requirements_txt(text),
258 "pyproject.toml" => pyproject_toml(text),
259 _ => Vec::new(),
260 }
261}
262
263#[cfg(test)]
264mod tests {
265 use super::*;
266
267 fn names(found: &[Declared]) -> Vec<String> {
268 found
269 .iter()
270 .map(|d| format!("{}@{}:{}", d.name, d.requirement.as_deref().unwrap_or("-"), d.kind.label()))
271 .collect()
272 }
273
274 #[test]
275 fn package_json_by_section() {
276 let text = r#"{"dependencies":{"react":"^18.2.0","local":"file:../x","alias":"npm:react@18","ws":"workspace:*"},
277 "devDependencies":{"vitest":"~1.0.0","react":"^18.2.0"},"optionalDependencies":{"fsevents":"2.3.3"},
278 "peerDependencies":{"react-dom":"*"}}"#;
279 assert_eq!(
280 names(&package_json(text)),
281 ["react@^18.2.0:direct:production", "fsevents@2.3.3:direct:production", "vitest@~1.0.0:direct:development"]
282 );
283 }
284
285 #[test]
286 fn cargo_toml_sections_targets_and_workspaces() {
287 let text = r#"
288[workspace]
289members = ["crates/*", "app"]
290
291[workspace.dependencies]
292serde = { version = "1", features = ["derive"] }
293
294[dependencies]
295anyhow = "1.0"
296local = { path = "../local" }
297shared = { workspace = true }
298renamed = { package = "real-name", version = "0.3" }
299
300[dev-dependencies]
301proptest = "1"
302
303[target.'cfg(unix)'.dependencies]
304libc = "0.2"
305"#;
306 assert_eq!(
307 names(&cargo_toml(text)),
308 [
309 "anyhow@1.0:direct:production",
310 "real-name@0.3:direct:production",
311 "serde@1:direct:production",
312 "proptest@1:direct:development",
313 "libc@0.2:direct:production"
314 ]
315 );
316 }
317
318 #[test]
319 fn go_mod_marks_indirect() {
320 let text = "module x\n\nrequire golang.org/x/text v0.3.0\n\nrequire (\n\tgithub.com/a/b v1.6.0 // indirect\n\tgolang.org/x/net v0.7.0\n)\n";
321 assert_eq!(
322 names(&go_mod(text)),
323 ["golang.org/x/text@v0.3.0:direct:production", "github.com/a/b@v1.6.0:indirect", "golang.org/x/net@v0.7.0:direct:production"]
324 );
325 }
326
327 #[test]
328 fn python_manifests() {
329 let requirements = "# web\nDjango==3.2.0\nrequests[security] >= 2.19, < 3 ; python_version >= '3'\nflask\n-r base.txt\nhttps://x/y.whl\n";
330 assert_eq!(
331 names(&requirements_txt(requirements)),
332 ["django@==3.2.0:direct:production", "requests@>=2.19,<3:direct:production", "flask@-:direct:production"]
333 );
334 let pyproject = r#"
335[project]
336dependencies = ["Jinja2>=3.0", "Flask_Cors"]
337
338[tool.poetry.dependencies]
339python = "^3.11"
340requests = "^2.0"
341pinned = { version = "1.2.3", optional = true }
342fromgit = { git = "https://x" }
343
344[tool.poetry.group.test.dependencies]
345pytest = "^7"
346"#;
347 assert_eq!(
348 names(&pyproject_toml(pyproject)),
349 [
350 "jinja2@>=3.0:direct:production",
351 "flask-cors@-:direct:production",
352 "pinned@1.2.3:direct:production",
353 "requests@^2.0:direct:production",
354 "pytest@^7:direct:development"
355 ]
356 );
357 }
358
359 #[test]
360 fn dependency_types_as_rules_name_them() {
361 assert!(DependencyType::Production.is("direct") && DependencyType::Production.is("production") && DependencyType::Production.is("all"));
362 assert!(!DependencyType::Development.is("production") && DependencyType::Development.is("development"));
363 assert!(DependencyType::Indirect.is("indirect") && !DependencyType::Indirect.is("direct"));
364 assert_eq!(manifest_names("pip"), ["requirements.txt", "pyproject.toml"]);
365 }
366}