1#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
35pub struct Package {
36 pub name: String,
38 pub version: String,
40 pub source: Option<String>,
42 pub checksum: Option<String>,
45}
46
47impl Package {
48 pub fn purl(&self) -> String {
54 format!("pkg:cargo/{}@{}", self.name, self.version)
55 }
56}
57
58pub 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 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 out.retain(|package| !package.name.is_empty() && !package.version.is_empty());
113 out.sort();
114 out.dedup();
115 out
116}
117
118pub 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 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
187fn 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 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 #[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 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 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}