Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 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 | ||
| 10 | use std::collections::BTreeSet; | |
| 11 | ||
| 12 | use serde_json::{Value, json}; | |
| 13 | ||
| 14 | use crate::graph::Dependency; | |
| 15 | ||
| 16 | /// What the document describes. | |
| 17 | pub 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 `-`. | |
| 31 | fn 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. | |
| 45 | fn 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`. | |
| 59 | pub 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)] | |
| 129 | mod 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 | } |