Skip to main content

gunnar_core/
sbom.rs

1//! **A CycloneDX SBOM out of a locked `Cargo.lock`** (roadmap phase 3).
2//!
3//! An artifact's provenance says *what was built and from what commit*. An SBOM
4//! says *what was in it*. They answer different questions and a chain needs
5//! both: "which build produced this binary" and "does this binary contain the
6//! crate that was just advisory'd".
7//!
8//! # Why the lockfile and not `cargo metadata`
9//!
10//! Because the lockfile is what the **build** resolved, and it is a file, and
11//! it is in the commit. `cargo metadata` re-resolves — against the registry
12//! index as it stands *now* — so an SBOM produced that way describes a build
13//! that might never have happened, and cannot be reproduced from the commit
14//! alone. Reading the lock makes the SBOM a function of the source, which is
15//! the only version of it that can be attested to.
16//!
17//! It also means no network, no `cargo`, and no toolchain: an SBOM can be made
18//! on the machine that has the artifact, from the commit the provenance names.
19//!
20//! # What it does not claim
21//!
22//! * **Not the resolved graph for one binary.** A lockfile is the union over
23//!   every target and every feature of the workspace, so this over-reports: a
24//!   crate in the lock is not necessarily linked into the artifact. Narrowing it
25//!   needs the per-artifact graph `cargo tree -e normal` produces, which is what
26//!   `xtask guard` already measures, and joining the two is named here rather
27//!   than fudged.
28//! * **No licences.** A lockfile does not carry them. Emitting an empty licence
29//!   field would be a field a consumer reads as "no licence", so there is none.
30//! * **No vulnerabilities.** That is a scanner's job against this document; a
31//!   generator that also judged would be two things that can disagree.
32
33/// One resolved package, as the lockfile records it.
34#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
35pub struct Package {
36    /// The crate name.
37    pub name: String,
38    /// The exact version the lock resolved to.
39    pub version: String,
40    /// The registry or path the lock recorded, if any.
41    pub source: Option<String>,
42    /// The registry checksum, if the lock carries one. Absent for path and git
43    /// dependencies, which is a fact about them and not a gap here.
44    pub checksum: Option<String>,
45}
46
47impl Package {
48    /// The package URL a CycloneDX consumer joins on.
49    ///
50    /// `pkg:cargo/<name>@<version>` — the type is registered, and using it is
51    /// what lets an existing scanner read this document without knowing
52    /// anything about gunnar.
53    pub fn purl(&self) -> String {
54        format!("pkg:cargo/{}@{}", self.name, self.version)
55    }
56}
57
58/// Parse a `Cargo.lock`.
59///
60/// A **small, total** reader rather than a TOML dependency: the lockfile's
61/// grammar for the part that matters is `[[package]]` followed by `key = "…"`
62/// lines, and `gunnar-core` adding a TOML parser to the appliance graph to read
63/// four keys is not a trade worth making. Anything it does not understand is
64/// skipped rather than guessed at, and a lock with no packages comes back empty
65/// — which the caller must not render as a valid empty SBOM.
66pub fn parse_lock(text: &str) -> Vec<Package> {
67    let mut out = Vec::new();
68    let mut current: Option<Package> = None;
69    for line in text.lines() {
70        let line = line.trim();
71        if line == "[[package]]" {
72            if let Some(package) = current.take() {
73                out.push(package);
74            }
75            current = Some(Package {
76                name: String::new(),
77                version: String::new(),
78                source: None,
79                checksum: None,
80            });
81            continue;
82        }
83        if line.starts_with('[') {
84            // Any other table ends the package block. `[metadata]` at the end
85            // of an old lockfile is exactly this case.
86            if let Some(package) = current.take() {
87                out.push(package);
88            }
89            continue;
90        }
91        let Some(package) = current.as_mut() else {
92            continue;
93        };
94        let Some((key, value)) = line.split_once('=') else {
95            continue;
96        };
97        let value = value.trim().trim_matches('"');
98        match key.trim() {
99            "name" => package.name = value.to_owned(),
100            "version" => package.version = value.to_owned(),
101            "source" => package.source = Some(value.to_owned()),
102            "checksum" => package.checksum = Some(value.to_owned()),
103            _ => {}
104        }
105    }
106    if let Some(package) = current.take() {
107        out.push(package);
108    }
109    // A `[[package]]` block with no name is a lockfile this reader did not
110    // understand, and a component with an empty name is worse than a missing
111    // one: a scanner would key on it.
112    out.retain(|package| !package.name.is_empty() && !package.version.is_empty());
113    out.sort();
114    out.dedup();
115    out
116}
117
118/// Render CycloneDX 1.5 JSON for `packages`.
119///
120/// `subject` names what the document is *about* — the artifact these components
121/// went into — so an SBOM is never a free-floating list of crates. It is
122/// carried as the metadata component's `name`, and its digest as a hash, which
123/// is what joins this document to a [`crate::BuildRecord`]'s subject.
124pub fn cyclonedx(
125    packages: &[Package],
126    subject_name: &str,
127    subject_sha256: &str,
128    serial: &str,
129) -> String {
130    let mut out = String::new();
131    out.push_str("{\n");
132    out.push_str("  \"bomFormat\": \"CycloneDX\",\n");
133    out.push_str("  \"specVersion\": \"1.5\",\n");
134    out.push_str(&format!("  \"serialNumber\": \"urn:uuid:{serial}\",\n"));
135    out.push_str("  \"version\": 1,\n");
136    out.push_str("  \"metadata\": {\n");
137    out.push_str("    \"tools\": [{ \"vendor\": \"nordisk\", \"name\": \"gunnar\" }],\n");
138    out.push_str("    \"component\": {\n");
139    out.push_str("      \"type\": \"application\",\n");
140    out.push_str(&format!("      \"name\": {},\n", json_string(subject_name)));
141    out.push_str("      \"hashes\": [\n");
142    out.push_str(&format!(
143        "        {{ \"alg\": \"SHA-256\", \"content\": {} }}\n",
144        json_string(subject_sha256)
145    ));
146    out.push_str("      ]\n");
147    out.push_str("    }\n");
148    out.push_str("  },\n");
149    out.push_str("  \"components\": [\n");
150    for (index, package) in packages.iter().enumerate() {
151        out.push_str("    {\n");
152        out.push_str("      \"type\": \"library\",\n");
153        out.push_str(&format!(
154            "      \"name\": {},\n",
155            json_string(&package.name)
156        ));
157        out.push_str(&format!(
158            "      \"version\": {},\n",
159            json_string(&package.version)
160        ));
161        if let Some(checksum) = &package.checksum {
162            // The registry's own checksum, and it is the only hash a lockfile
163            // has. It is the `.crate` archive's, not the built artifact's, and
164            // CycloneDX's `SHA-256` on a library component means exactly that.
165            out.push_str("      \"hashes\": [\n");
166            out.push_str(&format!(
167                "        {{ \"alg\": \"SHA-256\", \"content\": {} }}\n",
168                json_string(checksum)
169            ));
170            out.push_str("      ],\n");
171        }
172        out.push_str(&format!(
173            "      \"purl\": {}\n",
174            json_string(&package.purl())
175        ));
176        out.push_str(if index + 1 == packages.len() {
177            "    }\n"
178        } else {
179            "    },\n"
180        });
181    }
182    out.push_str("  ]\n");
183    out.push_str("}\n");
184    out
185}
186
187/// Minimal JSON string escaping.
188///
189/// Hand-rolled for the same reason the lock reader is: this crate is in the
190/// appliance graph and a serialiser for four field types is not worth a
191/// dependency. It escapes what JSON requires and nothing else.
192fn json_string(value: &str) -> String {
193    let mut out = String::with_capacity(value.len() + 2);
194    out.push('"');
195    for ch in value.chars() {
196        match ch {
197            '"' => out.push_str("\\\""),
198            '\\' => out.push_str("\\\\"),
199            '\n' => out.push_str("\\n"),
200            '\r' => out.push_str("\\r"),
201            '\t' => out.push_str("\\t"),
202            c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
203            c => out.push(c),
204        }
205    }
206    out.push('"');
207    out
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    const LOCK: &str = r#"
215version = 4
216
217[[package]]
218name = "anyhow"
219version = "1.0.104"
220source = "registry+https://github.com/rust-lang/crates.io-index"
221checksum = "abc123"
222
223[[package]]
224name = "gunnar-core"
225version = "1.1.3"
226dependencies = [
227 "anyhow",
228]
229
230[[package]]
231name = "serde"
232version = "1.0.229"
233source = "registry+https://github.com/rust-lang/crates.io-index"
234checksum = "def456"
235
236[metadata]
237"something" = "else"
238"#;
239
240    #[test]
241    fn the_reader_finds_every_package_and_keeps_the_checksum_where_there_is_one() {
242        let packages = parse_lock(LOCK);
243        assert_eq!(packages.len(), 3, "{packages:?}");
244        let anyhow = packages
245            .iter()
246            .find(|p| p.name == "anyhow")
247            .expect("anyhow");
248        assert_eq!(anyhow.version, "1.0.104");
249        assert_eq!(anyhow.checksum.as_deref(), Some("abc123"));
250
251        // A path dependency has no checksum, and that absence must survive as
252        // `None` rather than becoming an empty string a scanner would key on.
253        let local = packages
254            .iter()
255            .find(|p| p.name == "gunnar-core")
256            .expect("the workspace member");
257        assert_eq!(local.checksum, None);
258        assert_eq!(local.source, None);
259    }
260
261    /// `[metadata]` and any other table must end the package block, or the
262    /// keys under it are read as the last package's.
263    #[test]
264    fn a_trailing_table_does_not_leak_into_the_last_package() {
265        let packages = parse_lock(LOCK);
266        let serde = packages.iter().find(|p| p.name == "serde").expect("serde");
267        assert_eq!(serde.version, "1.0.229");
268        assert_eq!(serde.checksum.as_deref(), Some("def456"));
269    }
270
271    #[test]
272    fn an_empty_or_unreadable_lock_yields_no_packages_rather_than_a_guess() {
273        assert!(parse_lock("").is_empty());
274        assert!(parse_lock("nothing to see").is_empty());
275        // A block with no version is not a package this reader understood.
276        assert!(parse_lock("[[package]]\nname = \"x\"\n").is_empty());
277    }
278
279    #[test]
280    fn the_document_names_its_subject_and_every_component_carries_a_purl() {
281        let packages = parse_lock(LOCK);
282        let doc = cyclonedx(&packages, "gunnar", &"a".repeat(64), "0000-serial");
283        assert!(doc.contains("\"bomFormat\": \"CycloneDX\""));
284        assert!(
285            doc.contains(&format!("\"content\": \"{}\"", "a".repeat(64))),
286            "the artifact's own digest must be in the document, or the SBOM is not about \
287             anything: {doc}"
288        );
289        assert!(doc.contains("\"purl\": \"pkg:cargo/anyhow@1.0.104\""));
290        assert!(doc.contains("\"purl\": \"pkg:cargo/serde@1.0.229\""));
291        // Valid JSON: the last component must not carry a trailing comma.
292        assert!(
293            !doc.contains(",\n  ]"),
294            "the components array ends with a trailing comma, so this is not JSON:\n{doc}"
295        );
296    }
297
298    #[test]
299    fn a_name_with_a_quote_in_it_does_not_break_the_document() {
300        let packages = vec![Package {
301            name: "we\"ird".into(),
302            version: "1.0".into(),
303            source: None,
304            checksum: None,
305        }];
306        let doc = cyclonedx(&packages, "x\"y", "0".repeat(64).as_str(), "s");
307        assert!(doc.contains("we\\\"ird"), "{doc}");
308        assert!(doc.contains("x\\\"y"), "{doc}");
309    }
310}