#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct Package {
pub name: String,
pub version: String,
pub source: Option<String>,
pub checksum: Option<String>,
}
impl Package {
pub fn purl(&self) -> String {
format!("pkg:cargo/{}@{}", self.name, self.version)
}
}
pub fn parse_lock(text: &str) -> Vec<Package> {
let mut out = Vec::new();
let mut current: Option<Package> = None;
for line in text.lines() {
let line = line.trim();
if line == "[[package]]" {
if let Some(package) = current.take() {
out.push(package);
}
current = Some(Package {
name: String::new(),
version: String::new(),
source: None,
checksum: None,
});
continue;
}
if line.starts_with('[') {
if let Some(package) = current.take() {
out.push(package);
}
continue;
}
let Some(package) = current.as_mut() else {
continue;
};
let Some((key, value)) = line.split_once('=') else {
continue;
};
let value = value.trim().trim_matches('"');
match key.trim() {
"name" => package.name = value.to_owned(),
"version" => package.version = value.to_owned(),
"source" => package.source = Some(value.to_owned()),
"checksum" => package.checksum = Some(value.to_owned()),
_ => {}
}
}
if let Some(package) = current.take() {
out.push(package);
}
out.retain(|package| !package.name.is_empty() && !package.version.is_empty());
out.sort();
out.dedup();
out
}
pub fn cyclonedx(
packages: &[Package],
subject_name: &str,
subject_sha256: &str,
serial: &str,
) -> String {
let mut out = String::new();
out.push_str("{\n");
out.push_str(" \"bomFormat\": \"CycloneDX\",\n");
out.push_str(" \"specVersion\": \"1.5\",\n");
out.push_str(&format!(" \"serialNumber\": \"urn:uuid:{serial}\",\n"));
out.push_str(" \"version\": 1,\n");
out.push_str(" \"metadata\": {\n");
out.push_str(" \"tools\": [{ \"vendor\": \"nordisk\", \"name\": \"gunnar\" }],\n");
out.push_str(" \"component\": {\n");
out.push_str(" \"type\": \"application\",\n");
out.push_str(&format!(" \"name\": {},\n", json_string(subject_name)));
out.push_str(" \"hashes\": [\n");
out.push_str(&format!(
" {{ \"alg\": \"SHA-256\", \"content\": {} }}\n",
json_string(subject_sha256)
));
out.push_str(" ]\n");
out.push_str(" }\n");
out.push_str(" },\n");
out.push_str(" \"components\": [\n");
for (index, package) in packages.iter().enumerate() {
out.push_str(" {\n");
out.push_str(" \"type\": \"library\",\n");
out.push_str(&format!(
" \"name\": {},\n",
json_string(&package.name)
));
out.push_str(&format!(
" \"version\": {},\n",
json_string(&package.version)
));
if let Some(checksum) = &package.checksum {
out.push_str(" \"hashes\": [\n");
out.push_str(&format!(
" {{ \"alg\": \"SHA-256\", \"content\": {} }}\n",
json_string(checksum)
));
out.push_str(" ],\n");
}
out.push_str(&format!(
" \"purl\": {}\n",
json_string(&package.purl())
));
out.push_str(if index + 1 == packages.len() {
" }\n"
} else {
" },\n"
});
}
out.push_str(" ]\n");
out.push_str("}\n");
out
}
fn json_string(value: &str) -> String {
let mut out = String::with_capacity(value.len() + 2);
out.push('"');
for ch in value.chars() {
match ch {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
c => out.push(c),
}
}
out.push('"');
out
}
#[cfg(test)]
mod tests {
use super::*;
const LOCK: &str = r#"
version = 4
[[package]]
name = "anyhow"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "abc123"
[[package]]
name = "gunnar-core"
version = "1.1.3"
dependencies = [
"anyhow",
]
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "def456"
[metadata]
"something" = "else"
"#;
#[test]
fn the_reader_finds_every_package_and_keeps_the_checksum_where_there_is_one() {
let packages = parse_lock(LOCK);
assert_eq!(packages.len(), 3, "{packages:?}");
let anyhow = packages
.iter()
.find(|p| p.name == "anyhow")
.expect("anyhow");
assert_eq!(anyhow.version, "1.0.104");
assert_eq!(anyhow.checksum.as_deref(), Some("abc123"));
let local = packages
.iter()
.find(|p| p.name == "gunnar-core")
.expect("the workspace member");
assert_eq!(local.checksum, None);
assert_eq!(local.source, None);
}
#[test]
fn a_trailing_table_does_not_leak_into_the_last_package() {
let packages = parse_lock(LOCK);
let serde = packages.iter().find(|p| p.name == "serde").expect("serde");
assert_eq!(serde.version, "1.0.229");
assert_eq!(serde.checksum.as_deref(), Some("def456"));
}
#[test]
fn an_empty_or_unreadable_lock_yields_no_packages_rather_than_a_guess() {
assert!(parse_lock("").is_empty());
assert!(parse_lock("nothing to see").is_empty());
assert!(parse_lock("[[package]]\nname = \"x\"\n").is_empty());
}
#[test]
fn the_document_names_its_subject_and_every_component_carries_a_purl() {
let packages = parse_lock(LOCK);
let doc = cyclonedx(&packages, "gunnar", &"a".repeat(64), "0000-serial");
assert!(doc.contains("\"bomFormat\": \"CycloneDX\""));
assert!(
doc.contains(&format!("\"content\": \"{}\"", "a".repeat(64))),
"the artifact's own digest must be in the document, or the SBOM is not about \
anything: {doc}"
);
assert!(doc.contains("\"purl\": \"pkg:cargo/anyhow@1.0.104\""));
assert!(doc.contains("\"purl\": \"pkg:cargo/serde@1.0.229\""));
assert!(
!doc.contains(",\n ]"),
"the components array ends with a trailing comma, so this is not JSON:\n{doc}"
);
}
#[test]
fn a_name_with_a_quote_in_it_does_not_break_the_document() {
let packages = vec![Package {
name: "we\"ird".into(),
version: "1.0".into(),
source: None,
checksum: None,
}];
let doc = cyclonedx(&packages, "x\"y", "0".repeat(64).as_str(), "s");
assert!(doc.contains("we\\\"ird"), "{doc}");
assert!(doc.contains("x\\\"y"), "{doc}");
}
}