gunnar-core 2.0.0

gunnar's repository model, identity table, ACL and path sandbox. gunnar-ssh, gunnar-grpc and gunnar-cli all sit on it.
Documentation
//! **A CycloneDX SBOM out of a locked `Cargo.lock`** (roadmap phase 3).
//!
//! An artifact's provenance says *what was built and from what commit*. An SBOM
//! says *what was in it*. They answer different questions and a chain needs
//! both: "which build produced this binary" and "does this binary contain the
//! crate that was just advisory'd".
//!
//! # Why the lockfile and not `cargo metadata`
//!
//! Because the lockfile is what the **build** resolved, and it is a file, and
//! it is in the commit. `cargo metadata` re-resolves — against the registry
//! index as it stands *now* — so an SBOM produced that way describes a build
//! that might never have happened, and cannot be reproduced from the commit
//! alone. Reading the lock makes the SBOM a function of the source, which is
//! the only version of it that can be attested to.
//!
//! It also means no network, no `cargo`, and no toolchain: an SBOM can be made
//! on the machine that has the artifact, from the commit the provenance names.
//!
//! # What it does not claim
//!
//! * **Not the resolved graph for one binary.** A lockfile is the union over
//!   every target and every feature of the workspace, so this over-reports: a
//!   crate in the lock is not necessarily linked into the artifact. Narrowing it
//!   needs the per-artifact graph `cargo tree -e normal` produces, which is what
//!   `xtask guard` already measures, and joining the two is named here rather
//!   than fudged.
//! * **No licences.** A lockfile does not carry them. Emitting an empty licence
//!   field would be a field a consumer reads as "no licence", so there is none.
//! * **No vulnerabilities.** That is a scanner's job against this document; a
//!   generator that also judged would be two things that can disagree.

/// One resolved package, as the lockfile records it.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct Package {
    /// The crate name.
    pub name: String,
    /// The exact version the lock resolved to.
    pub version: String,
    /// The registry or path the lock recorded, if any.
    pub source: Option<String>,
    /// The registry checksum, if the lock carries one. Absent for path and git
    /// dependencies, which is a fact about them and not a gap here.
    pub checksum: Option<String>,
}

impl Package {
    /// The package URL a CycloneDX consumer joins on.
    ///
    /// `pkg:cargo/<name>@<version>` — the type is registered, and using it is
    /// what lets an existing scanner read this document without knowing
    /// anything about gunnar.
    pub fn purl(&self) -> String {
        format!("pkg:cargo/{}@{}", self.name, self.version)
    }
}

/// Parse a `Cargo.lock`.
///
/// A **small, total** reader rather than a TOML dependency: the lockfile's
/// grammar for the part that matters is `[[package]]` followed by `key = "…"`
/// lines, and `gunnar-core` adding a TOML parser to the appliance graph to read
/// four keys is not a trade worth making. Anything it does not understand is
/// skipped rather than guessed at, and a lock with no packages comes back empty
/// — which the caller must not render as a valid empty SBOM.
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('[') {
            // Any other table ends the package block. `[metadata]` at the end
            // of an old lockfile is exactly this case.
            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);
    }
    // A `[[package]]` block with no name is a lockfile this reader did not
    // understand, and a component with an empty name is worse than a missing
    // one: a scanner would key on it.
    out.retain(|package| !package.name.is_empty() && !package.version.is_empty());
    out.sort();
    out.dedup();
    out
}

/// Render CycloneDX 1.5 JSON for `packages`.
///
/// `subject` names what the document is *about* — the artifact these components
/// went into — so an SBOM is never a free-floating list of crates. It is
/// carried as the metadata component's `name`, and its digest as a hash, which
/// is what joins this document to a [`crate::BuildRecord`]'s subject.
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 {
            // The registry's own checksum, and it is the only hash a lockfile
            // has. It is the `.crate` archive's, not the built artifact's, and
            // CycloneDX's `SHA-256` on a library component means exactly that.
            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
}

/// Minimal JSON string escaping.
///
/// Hand-rolled for the same reason the lock reader is: this crate is in the
/// appliance graph and a serialiser for four field types is not worth a
/// dependency. It escapes what JSON requires and nothing else.
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"));

        // A path dependency has no checksum, and that absence must survive as
        // `None` rather than becoming an empty string a scanner would key on.
        let local = packages
            .iter()
            .find(|p| p.name == "gunnar-core")
            .expect("the workspace member");
        assert_eq!(local.checksum, None);
        assert_eq!(local.source, None);
    }

    /// `[metadata]` and any other table must end the package block, or the
    /// keys under it are read as the last package's.
    #[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());
        // A block with no version is not a package this reader understood.
        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\""));
        // Valid JSON: the last component must not carry a trailing comma.
        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}");
    }
}