Skip to content

g1t/crates/scan/src/sbom.rs

207 lines9,422 bytesCodeBlame
1//! A software bill of materials: a repository's dependency graph as an SPDX
2//! 2.3 document in JSON, the format most tools that read SBOMs accept.
3//!
4//! The repository is the document's one described package; every
5//! dependency is a package it `DEPENDS_ON` (or `DEV_DEPENDENCY_OF` it, for
6//! development only), named by its package URL. What a lockfile does not
7//! record (where to download it, its checksum, a license it does not
8//! state) is `NOASSERTION`, as SPDX asks.
9
10use std::collections::BTreeSet;
11
12use serde_json::{Value, json};
13
14use crate::graph::Dependency;
15
16/// What the document describes.
17pub struct Subject<'a> {
18 /// `acme/rocket`.
19 pub full_name: &'a str,
20 /// Where the repository is: `https://g1t.sh/acme/rocket`.
21 pub url: &'a str,
22 /// The commit the lockfiles were read at.
23 pub commit: Option<&'a str>,
24 /// A unique id for this document, such as a random hex string.
25 pub unique: &'a str,
26 /// RFC 3339, seconds precision, in UTC: `2026-10-07T12:00:00Z`.
27 pub created: &'a str,
28}
29
30/// The characters an SPDX id may hold are letters, digits, `.` and `-`.
31fn spdx_id(text: &str) -> String {
32 let mut out = String::with_capacity(text.len());
33 for c in text.chars() {
34 let c = if c.is_ascii_alphanumeric() || c == '.' { c } else { '-' };
35 if !(c == '-' && out.ends_with('-')) {
36 out.push(c);
37 }
38 }
39 out.trim_matches('-').to_owned()
40}
41
42/// A license expression SPDX accepts, or `NOASSERTION`. Lockfiles hold
43/// whatever the package author wrote, so anything that is not a plain
44/// expression of ids is not asserted.
45fn license_field(license: Option<&str>) -> String {
46 let Some(license) = license.map(str::trim).filter(|license| !license.is_empty()) else {
47 return "NOASSERTION".to_owned();
48 };
49 // Ids joined by AND, OR and WITH: every other word an operator.
50 let words: Vec<&str> = license.split(|c: char| c.is_whitespace() || c == '(' || c == ')').filter(|w| !w.is_empty()).collect();
51 let id = |word: &str| word.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '.' | '+' | ':'));
52 let valid = words.iter().enumerate().all(|(at, word)| {
53 if at % 2 == 1 { matches!(*word, "AND" | "OR" | "WITH") } else { id(word) }
54 }) && words.len() % 2 == 1;
55 if valid { license.to_owned() } else { "NOASSERTION".to_owned() }
56}
57
58/// The SPDX 2.3 JSON document for `dependencies`.
59pub fn spdx(subject: &Subject, dependencies: &[Dependency]) -> Value {
60 let root = format!("SPDXRef-Repository-{}", spdx_id(subject.full_name));
61 let mut packages = vec![json!({
62 "SPDXID": root,
63 "name": subject.full_name,
64 "versionInfo": subject.commit.unwrap_or("NOASSERTION"),
65 "downloadLocation": format!("git+{}.git", subject.url),
66 "filesAnalyzed": false,
67 "licenseConcluded": "NOASSERTION",
68 "licenseDeclared": "NOASSERTION",
69 "copyrightText": "NOASSERTION",
70 "primaryPackagePurpose": "SOURCE",
71 "externalRefs": [],
72 })];
73 let mut relationships = vec![json!({
74 "spdxElementId": "SPDXRef-DOCUMENT",
75 "relationshipType": "DESCRIBES",
76 "relatedSpdxElement": root,
77 })];
78 // One package per name and version, whichever lockfiles hold it.
79 let mut seen = BTreeSet::new();
80 let mut sorted: Vec<&Dependency> = dependencies.iter().collect();
81 sorted.sort_by(|a, b| (a.purl(), a.development, &a.manifest).cmp(&(b.purl(), b.development, &b.manifest)));
82 for dep in sorted {
83 let purl = dep.purl();
84 if !seen.insert(purl.clone()) {
85 continue;
86 }
87 let id = format!("SPDXRef-Package-{}", spdx_id(&format!("{}-{}-{}", crate::graph::purl_type(dep.package.ecosystem), dep.package.name, dep.package.version)));
88 packages.push(json!({
89 "SPDXID": id,
90 "name": dep.package.name,
91 "versionInfo": dep.package.version,
92 "downloadLocation": "NOASSERTION",
93 "filesAnalyzed": false,
94 "licenseConcluded": "NOASSERTION",
95 "licenseDeclared": license_field(dep.license.as_deref()),
96 "copyrightText": "NOASSERTION",
97 "primaryPackagePurpose": "LIBRARY",
98 "comment": format!("Resolved by {} ({} dependency).", dep.manifest, dep.relationship.as_str()),
99 "externalRefs": [{
100 "referenceCategory": "PACKAGE-MANAGER",
101 "referenceType": "purl",
102 "referenceLocator": purl,
103 }],
104 }));
105 relationships.push(if dep.development {
106 json!({ "spdxElementId": id, "relationshipType": "DEV_DEPENDENCY_OF", "relatedSpdxElement": root })
107 } else {
108 json!({ "spdxElementId": root, "relationshipType": "DEPENDS_ON", "relatedSpdxElement": id })
109 });
110 }
111 json!({
112 "spdxVersion": "SPDX-2.3",
113 "dataLicense": "CC0-1.0",
114 "SPDXID": "SPDXRef-DOCUMENT",
115 "name": format!("{} dependency graph", subject.full_name),
116 "documentNamespace": format!("{}/sbom/{}", subject.url, subject.unique),
117 "creationInfo": {
118 "created": subject.created,
119 "creators": ["Tool: g1t", "Organization: g1t"],
120 "comment": "Read from the repository's lockfiles on its default branch.",
121 },
122 "documentDescribes": [root],
123 "packages": packages,
124 "relationships": relationships,
125 })
126}
127
128#[cfg(test)]
129mod tests {
130 use super::*;
131 use crate::graph::Relationship;
132 use crate::lockfiles::{Ecosystem, Package};
133
134 fn dep(ecosystem: Ecosystem, name: &str, version: &str, development: bool, license: Option<&str>) -> Dependency {
135 Dependency {
136 package: Package { ecosystem, name: name.into(), version: version.into() },
137 manifest: "package-lock.json".into(),
138 relationship: Relationship::Direct,
139 development,
140 license: license.map(str::to_owned),
141 }
142 }
143
144 fn document() -> Value {
145 let subject = Subject {
146 full_name: "acme/rocket",
147 url: "https://g1t.sh/acme/rocket",
148 commit: Some("4807077b296e6edbf410d55e72749d3e1170c291"),
149 unique: "0f2c9d1e",
150 created: "2026-10-07T12:00:00Z",
151 };
152 spdx(&subject, &[
153 dep(Ecosystem::Npm, "@babel/core", "7.0.0", true, Some("MIT")),
154 dep(Ecosystem::Npm, "lodash", "4.17.21", false, Some("MIT")),
155 dep(Ecosystem::Npm, "lodash", "4.17.21", false, Some("MIT")),
156 dep(Ecosystem::Cargo, "serde", "1.0.0", false, Some("see LICENSE file")),
157 ])
158 }
159
160 #[test]
161 fn the_document_has_what_spdx_requires() {
162 let doc = document();
163 assert_eq!(doc["spdxVersion"], "SPDX-2.3");
164 assert_eq!(doc["dataLicense"], "CC0-1.0");
165 assert_eq!(doc["SPDXID"], "SPDXRef-DOCUMENT");
166 assert_eq!(doc["documentNamespace"], "https://g1t.sh/acme/rocket/sbom/0f2c9d1e");
167 assert_eq!(doc["creationInfo"]["created"], "2026-10-07T12:00:00Z");
168 // The repository and three packages: the duplicate lodash is one.
169 let packages = doc["packages"].as_array().unwrap();
170 assert_eq!(packages.len(), 4);
171 let ids: Vec<&str> = packages.iter().map(|package| package["SPDXID"].as_str().unwrap()).collect();
172 assert_eq!(BTreeSet::from_iter(ids.iter()).len(), ids.len(), "ids are unique");
173 for id in &ids {
174 assert!(id.starts_with("SPDXRef-") && id[8..].chars().all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-'), "{id}");
175 }
176 for package in packages {
177 for field in ["name", "downloadLocation", "filesAnalyzed", "licenseConcluded", "licenseDeclared", "copyrightText"] {
178 assert!(package.get(field).is_some(), "{field} in {package}");
179 }
180 }
181 assert_eq!(packages[0]["SPDXID"], "SPDXRef-Repository-acme-rocket");
182 // To check it with an SPDX validator: G1T_WRITE_SBOM=path cargo test -p g1t-scan sbom
183 if let Ok(path) = std::env::var("G1T_WRITE_SBOM") {
184 std::fs::write(path, serde_json::to_string_pretty(&doc).unwrap()).unwrap();
185 }
186 assert_eq!(doc["documentDescribes"][0], "SPDXRef-Repository-acme-rocket");
187 }
188
189 #[test]
190 fn packages_are_named_by_purl_and_related_to_the_repository() {
191 let doc = document();
192 let packages = doc["packages"].as_array().unwrap();
193 let babel = packages.iter().find(|package| package["name"] == "@babel/core").unwrap();
194 assert_eq!(babel["SPDXID"], "SPDXRef-Package-npm-babel-core-7.0.0");
195 assert_eq!(babel["externalRefs"][0]["referenceLocator"], "pkg:npm/%40babel/core@7.0.0");
196 assert_eq!(babel["licenseDeclared"], "MIT");
197 // Free text is not a license expression.
198 let serde = packages.iter().find(|package| package["name"] == "serde").unwrap();
199 assert_eq!(serde["licenseDeclared"], "NOASSERTION");
200 let relationships = doc["relationships"].as_array().unwrap();
201 assert!(relationships.iter().any(|r| r["relationshipType"] == "DEV_DEPENDENCY_OF"
202 && r["spdxElementId"] == "SPDXRef-Package-npm-babel-core-7.0.0"));
203 assert!(relationships.iter().any(|r| r["relationshipType"] == "DEPENDS_ON"
204 && r["relatedSpdxElement"] == "SPDXRef-Package-npm-lodash-4.17.21"));
205 assert_eq!(relationships.len(), 4);
206 }
207}