diff --git a/cargo-sbom/README.md b/cargo-sbom/README.md index 8d65d00..5ffe1f5 100644 --- a/cargo-sbom/README.md +++ b/cargo-sbom/README.md @@ -44,7 +44,7 @@ Options: --cargo-package The specific package (in a Cargo workspace) to generate an SBOM for. If not specified this is all packages in the workspace. --output-format - The SBOM output format. [default: spdx_json_2_3] [possible values: spdx_json_2_3, cyclone_dx_json_1_4, cyclone_dx_json_1_6] + The SBOM output format. [default: spdx_json_2_3] [possible values: spdx_json_2_3, spdx_json_3_0_1, cyclone_dx_json_1_4, cyclone_dx_json_1_5, cyclone_dx_json_1_6] --project-directory The directory to the Cargo project. [default: .] -h, --help @@ -76,6 +76,25 @@ $ cargo sbom ``` +### Create a SPDX 3.0.1 SBOM for a Cargo project + +```shell +$ cargo sbom --output-format=spdx_json_3_0_1 +{ + "@context": "https://spdx.org/rdf/3.0.1/spdx-context.jsonld", + "@graph": [ + { + "type": "CreationInfo", + "@id": "_:creationinfo", + "createdBy": ["http://spdx.example.com/Agent/cargo-sbom-v0.10.0"], + "specVersion": "3.0.1", + "created": "2024-03-06T00:00:00Z" + }, + + ] +} +``` + ### Create a CycloneDx SBOM in Github Actions In a Github Actions workflow: diff --git a/cargo-sbom/src/main.rs b/cargo-sbom/src/main.rs index 969525f..2264a24 100644 --- a/cargo-sbom/src/main.rs +++ b/cargo-sbom/src/main.rs @@ -44,7 +44,7 @@ //! --cargo-package //! The specific package (in a Cargo workspace) to generate an SBOM for. If not specified this is all packages in the workspace. //! --output-format -//! The SBOM output format. [default: spdx_json_2_3] [possible values: spdx_json_2_3, cyclone_dx_json_1_4, cyclone_dx_json_1_5, cyclone_dx_json_1_6] +//! The SBOM output format. [default: spdx_json_2_3] [possible values: spdx_json_2_3, spdx_json_3_0_1, cyclone_dx_json_1_4, cyclone_dx_json_1_5, cyclone_dx_json_1_6] //! --project-directory //! The directory to the Cargo project. [default: .] //! -h, --help @@ -76,6 +76,24 @@ //! //! ``` //! +//! ### Create a SPDX 3.0.1 SBOM for a Cargo project +//! +//! ```shell +//! $ cargo sbom --output-format=spdx_json_3_0_1 +//! { +//! "@context": "https://spdx.org/rdf/3.0.1/spdx-context.jsonld", +//! "@graph": [ +//! { +//! "type": "CreationInfo", +//! "@id": "_:creationinfo", +//! "createdBy": ["http://spdx.example.com/Agent/cargo-sbom-v0.10.0"], +//! "specVersion": "3.0.1", +//! "created": "2024-03-06T00:00:00Z" +//! } +//! ] +//! } +//! ``` +//! //! ### Create a CycloneDx SBOM in Github Actions //! //! In a Github Actions workflow: @@ -205,6 +223,7 @@ pub mod built_info { #[allow(non_camel_case_types)] enum OutputFormat { SpdxJson_2_3, + SpdxJson_3_0_1, CycloneDxJson_1_4, CycloneDxJson_1_5, CycloneDxJson_1_6, @@ -327,6 +346,18 @@ fn try_main() -> Result<()> { "{}", serde_json::to_string_pretty(&spdx)? )?; + } else if matches!(opt.output_format, OutputFormat::SpdxJson_3_0_1) { + let spdx = util::spdx::convert_3_0_1( + opt.cargo_package, + &opt.project_directory, + &cargo_manifest_path, + &graph, + )?; + writeln!( + std::io::stdout(), + "{}", + serde_json::to_string_pretty(&spdx)? + )?; } Ok(()) diff --git a/cargo-sbom/src/util/spdx/mod.rs b/cargo-sbom/src/util/spdx/mod.rs index 3e33e45..eaf4f28 100644 --- a/cargo-sbom/src/util/spdx/mod.rs +++ b/cargo-sbom/src/util/spdx/mod.rs @@ -266,3 +266,174 @@ pub fn convert( .build()?, ) } + +/// Generate SPDX 3.0.1 element ID +fn generate_spdx_3_0_1_id(package: &Package) -> String { + format!( + "http://spdx.example.com/Package/{}/{}", + package.name, package.version + ) +} + +pub fn convert_3_0_1( + cargo_package: Option, + project_directory: &Path, + _cargo_manifest_path: &Path, + graph: &Graph, +) -> Result { + use serde_json::json; + + let mut graph_elements = vec![]; + let current_time = + chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(); + + // Create CreationInfo element + let creation_info_json = json!({ + "type": "CreationInfo", + "@id": "_:creationinfo", + "createdBy": [format!("http://spdx.example.com/Agent/{}-v{}", built_info::PKG_NAME, built_info::PKG_VERSION)], + "specVersion": "3.0.1", + "created": current_time + }); + + graph_elements.push(creation_info_json); + + // Create Person element for the tool + let person_json = json!({ + "type": "Person", + "spdxId": format!("http://spdx.example.com/Agent/{}-v{}", built_info::PKG_NAME, built_info::PKG_VERSION), + "name": format!("{} v{}", built_info::PKG_NAME, built_info::PKG_VERSION), + "creationInfo": "_:creationinfo" + }); + + graph_elements.push(person_json); + + let mut package_ids = vec![]; + let mut relationship_elements = vec![]; + + // Process packages + for root_package_id in graph.root_packages.iter() { + let root_node_index = graph + .nodes + .get(root_package_id) + .ok_or(anyhow!("No root node. Shouldn't reach here."))?; + let root = graph.graph[*root_node_index]; + if let Some(r) = cargo_package.as_ref() { + if r != &root.name { + continue; + } + } + + let mut dfs = petgraph::visit::Dfs::new(&graph.graph, *root_node_index); + while let Some(nx) = dfs.next(&graph.graph) { + let edges = graph.graph.edges(nx); + let package = graph.graph[nx]; + + let package_id = generate_spdx_3_0_1_id(package); + package_ids.push(package_id.clone()); + + let normalized_license = package + .license + .as_ref() + .and_then(|license| license::normalize_license_string(license).ok()); + + let mut package_json = json!({ + "type": "software_Package", + "spdxId": package_id, + "name": package.name, + "downloadLocation": package + .source + .as_ref() + .map(|source| source.to_string()) + .unwrap_or("NONE".to_string()), + "filesAnalyzed": false, + "licenseConcluded": normalized_license.as_deref().unwrap_or("NOASSERTION"), + "copyrightText": "NOASSERTION", + "creationInfo": "_:creationinfo", + "versionInfo": package.version.to_string() + }); + + if let Some(license_declared) = normalized_license { + package_json["licenseDeclared"] = json!(license_declared); + } + + if let Some(description) = package.description.as_ref() { + package_json["description"] = json!(description); + } + + graph_elements.push(package_json); + + // Create relationship elements + edges.for_each(|e| { + let source = &graph.graph[e.source()]; + let target = &graph.graph[e.target()]; + let relationship_id = + format!("_:relationship-{}-{}", source.name, target.name); + + let relationship_json = json!({ + "type": "Relationship", + "spdxId": relationship_id, + "relationshipType": "dependsOn", + "from": generate_spdx_3_0_1_id(source), + "to": [generate_spdx_3_0_1_id(target)], + "creationInfo": "_:creationinfo" + }); + + relationship_elements.push(relationship_json); + }); + } + } + + // Add relationships to graph + graph_elements.extend(relationship_elements); + + // Create SBOM element + let absolute_project_directory = project_directory.canonicalize()?; + let manifest_folder = absolute_project_directory + .file_name() + .ok_or(anyhow!("Failed to determine parent folder of Cargo.toml. Unable to assign a SPDX document name."))?; + let name = cargo_package + .unwrap_or_else(|| manifest_folder.to_string_lossy().to_string()); + + let sbom_id = format!("http://spdx.example.com/Sbom/{}", name); + let sbom_json = json!({ + "type": "software_Sbom", + "spdxId": sbom_id, + "creationInfo": "_:creationinfo", + "rootElement": package_ids + }); + + graph_elements.push(sbom_json); + + // Create SpdxDocument + let document_id = format!("http://spdx.example.com/Document/{}", name); + let mut element_ids = vec![sbom_id]; + element_ids.extend(package_ids); + element_ids.push(format!( + "http://spdx.example.com/Agent/{}-v{}", + built_info::PKG_NAME, + built_info::PKG_VERSION + )); + + let document_json = json!({ + "type": "SpdxDocument", + "spdxId": document_id, + "creationInfo": "_:creationinfo", + "rootElement": [format!("http://spdx.example.com/Sbom/{}", name)], + "element": element_ids, + "profileConformance": ["core", "software"] + }); + + graph_elements.push(document_json); + + // Create the final SPDX structure + let spdx_json = json!({ + "@context": "https://spdx.org/rdf/3.0.1/spdx-context.jsonld", + "@graph": graph_elements + }); + + // Convert to our struct + let spdx: serde_spdx::spdx::v_3_0_1::Spdx = + serde_json::from_value(spdx_json)?; + Ok(spdx) +} diff --git a/serde-spdx/Cargo.toml b/serde-spdx/Cargo.toml index 6aa77de..8d619de 100644 --- a/serde-spdx/Cargo.toml +++ b/serde-spdx/Cargo.toml @@ -22,10 +22,12 @@ default = [] [dependencies] anyhow = "1.0.98" derive_builder = "0.20.0" -serde = "1.0.204" +serde = { version = "1.0.204", features = ["derive"] } serde_json = "1.0.120" thiserror = "2.0.12" +[dev-dependencies] + [build-dependencies] anyhow = "1.0.98" prettyplease = "0.2.25" diff --git a/serde-spdx/README.md b/serde-spdx/README.md index 21cf647..5b57040 100644 --- a/serde-spdx/README.md +++ b/serde-spdx/README.md @@ -3,8 +3,8 @@ # serde-spdx This crate provides a type safe [serde](https://serde.rs/) compatible -[SPDX](https://spdx.dev/) format. It is intended for use -in Rust code which may need to read or write SPDX files. +[SPDX](https://spdx.dev/) format. It supports both SPDX 2.3 and SPDX 3.0.1 formats. +It is intended for use in Rust code which may need to read or write SPDX files. The latest [documentation can be found here](https://docs.rs/serde_spdx). @@ -18,10 +18,11 @@ the official website: ## Usage -For most cases, simply use the root [spdx::v_2_3::Spdx] struct with [serde] to read -and write to and from the struct. +For most cases, simply use the root [spdx::v_2_3::Spdx] struct for SPDX 2.3 or +[spdx::v_3_0_1::Spdx] struct for SPDX 3.0.1 with [serde] to read and write to and +from the struct. -## Example +## SPDX 2.3 Example ```rust use serde_spdx::spdx::v_2_3::Spdx; @@ -30,11 +31,20 @@ let data = fs::read_to_string("sbom.spdx.json"); let spdx: Spdx = serde_json::from_str(&data).unwrap(); ``` +## SPDX 3.0.1 Example + +```rust +use serde_spdx::spdx::v_3_0_1::Spdx; + +let data = fs::read_to_string("sbom.spdx.jsonld"); +let spdx: Spdx = serde_json::from_str(&data).unwrap(); +``` + Because many of the [spdx::v_2_3::Spdx] structures contain a lot of optional fields, it is often convenient to use the builder pattern to contstruct these structs. Each structure has a builder with a default. -## Example +## Builder Example ```rust use serde_spdx::spdx::v_2_3::SpdxCreationInfoBuilder; diff --git a/serde-spdx/build.rs b/serde-spdx/build.rs index 319299c..825d9ce 100644 --- a/serde-spdx/build.rs +++ b/serde-spdx/build.rs @@ -98,6 +98,7 @@ fn generate_schema(version_str: &str) -> Result<()> { fn main() -> Result<()> { generate_schema("2_3")?; + generate_schema("3_0_1")?; Ok(()) } diff --git a/serde-spdx/schemas/spdx_3_0_1.json b/serde-spdx/schemas/spdx_3_0_1.json new file mode 100644 index 0000000..ddc2f31 --- /dev/null +++ b/serde-spdx/schemas/spdx_3_0_1.json @@ -0,0 +1,240 @@ +{ + "$schema": "https://json-schema.org/draft/2019-09/schema#", + "$id": "http://spdx.org/rdf/terms/3.0.1", + "title": "SPDX 3.0.1", + "type": "object", + "properties": { + "@context": { + "type": "string", + "description": "JSON-LD context for SPDX 3.0.1", + "default": "https://spdx.org/rdf/3.0.1/spdx-context.jsonld" + }, + "@graph": { + "type": "array", + "description": "Array of SPDX elements", + "items": { + "$ref": "#/definitions/SpdxElement" + } + } + }, + "required": ["@context", "@graph"], + "definitions": { + "SpdxElement": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Type of the SPDX element" + }, + "spdxId": { + "type": "string", + "description": "Unique identifier for the element" + }, + "@id": { + "type": "string", + "description": "JSON-LD identifier" + }, + "creationInfo": { + "oneOf": [ + {"type": "string"}, + {"$ref": "#/definitions/CreationInfo"} + ] + } + }, + "oneOf": [ + {"$ref": "#/definitions/CreationInfo"}, + {"$ref": "#/definitions/Person"}, + {"$ref": "#/definitions/SpdxDocument"}, + {"$ref": "#/definitions/Sbom"}, + {"$ref": "#/definitions/Package"}, + {"$ref": "#/definitions/Relationship"}, + {"$ref": "#/definitions/ExternalIdentifier"} + ] + }, + "CreationInfo": { + "type": "object", + "properties": { + "type": { + "const": "CreationInfo" + }, + "@id": { + "type": "string" + }, + "createdBy": { + "type": "array", + "items": { + "type": "string" + } + }, + "specVersion": { + "type": "string", + "const": "3.0.1" + }, + "created": { + "type": "string", + "format": "date-time" + } + }, + "required": ["type", "specVersion", "created", "createdBy"] + }, + "Person": { + "type": "object", + "properties": { + "type": { + "const": "Person" + }, + "spdxId": { + "type": "string" + }, + "name": { + "type": "string" + }, + "creationInfo": { + "type": "string" + }, + "externalIdentifier": { + "type": "array", + "items": { + "$ref": "#/definitions/ExternalIdentifier" + } + } + }, + "required": ["type", "spdxId", "name"] + }, + "SpdxDocument": { + "type": "object", + "properties": { + "type": { + "const": "SpdxDocument" + }, + "spdxId": { + "type": "string" + }, + "creationInfo": { + "type": "string" + }, + "rootElement": { + "type": "array", + "items": { + "type": "string" + } + }, + "element": { + "type": "array", + "items": { + "type": "string" + } + }, + "profileConformance": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["type", "spdxId"] + }, + "Sbom": { + "type": "object", + "properties": { + "type": { + "enum": ["software_Sbom", "Sbom"] + }, + "spdxId": { + "type": "string" + }, + "creationInfo": { + "type": "string" + }, + "rootElement": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["type", "spdxId"] + }, + "Package": { + "type": "object", + "properties": { + "type": { + "enum": ["software_Package", "Package"] + }, + "spdxId": { + "type": "string" + }, + "name": { + "type": "string" + }, + "downloadLocation": { + "type": "string" + }, + "filesAnalyzed": { + "type": "boolean" + }, + "licenseConcluded": { + "type": "string" + }, + "licenseDeclared": { + "type": "string" + }, + "copyrightText": { + "type": "string" + }, + "creationInfo": { + "type": "string" + }, + "suppliedBy": { + "type": "string" + }, + "versionInfo": { + "type": "string" + } + }, + "required": ["type", "spdxId", "name"] + }, + "Relationship": { + "type": "object", + "properties": { + "type": { + "const": "Relationship" + }, + "spdxId": { + "type": "string" + }, + "relationshipType": { + "type": "string" + }, + "from": { + "type": "string" + }, + "to": { + "type": "array", + "items": { + "type": "string" + } + }, + "creationInfo": { + "type": "string" + } + }, + "required": ["type", "spdxId", "relationshipType", "from", "to"] + }, + "ExternalIdentifier": { + "type": "object", + "properties": { + "type": { + "const": "ExternalIdentifier" + }, + "externalIdentifierType": { + "type": "string" + }, + "identifier": { + "type": "string" + } + }, + "required": ["type", "externalIdentifierType", "identifier"] + } + } +} \ No newline at end of file diff --git a/serde-spdx/src/lib.rs b/serde-spdx/src/lib.rs index 943699d..97af023 100644 --- a/serde-spdx/src/lib.rs +++ b/serde-spdx/src/lib.rs @@ -19,10 +19,11 @@ //! //! ## Usage //! -//! For most cases, simply use the root [spdx::v_2_3::Spdx] struct with [serde] to read -//! and write to and from the struct. +//! For most cases, simply use the root [spdx::v_2_3::Spdx] struct for SPDX 2.3 or +//! [spdx::v_3_0_1::Spdx] struct for SPDX 3.0.1 with [serde] to read and write to and +//! from the struct. //! -//! ## Example +//! ## SPDX 2.3 Example //! //! ```rust //! use serde_spdx::spdx::v_2_3::Spdx; @@ -42,11 +43,30 @@ //! }"#).unwrap(); //! ``` //! +//! ## SPDX 3.0.1 Example +//! +//! ```rust +//! use serde_spdx::spdx::v_3_0_1::Spdx; +//! +//! let spdx: Spdx = serde_json::from_str(r#"{ +//! "@context": "https://spdx.org/rdf/3.0.1/spdx-context.jsonld", +//! "@graph": [ +//! { +//! "type": "CreationInfo", +//! "@id": "_:creationinfo", +//! "createdBy": ["http://spdx.example.com/Agent/Tool"], +//! "specVersion": "3.0.1", +//! "created": "2024-03-06T00:00:00Z" +//! } +//! ] +//! }"#).unwrap(); +//! ``` +//! //! Because many of the [spdx::v_2_3::Spdx] structures contain a lot of optional fields, //! it is often convenient to use the builder pattern to contstruct these structs. //! Each structure has a builder with a default. //! -//! ## Example +//! ## Builder Example //! //! ```rust //! use serde_spdx::spdx::v_2_3::SpdxCreationInfoBuilder; diff --git a/serde-spdx/src/spdx.rs b/serde-spdx/src/spdx.rs index 3cc86dd..c603803 100644 --- a/serde-spdx/src/spdx.rs +++ b/serde-spdx/src/spdx.rs @@ -2,3 +2,8 @@ pub mod v_2_3 { include!(concat!(env!("OUT_DIR"), "/spdx_2_3.rs")); } + +#[allow(clippy::all)] +pub mod v_3_0_1 { + include!(concat!(env!("OUT_DIR"), "/spdx_3_0_1.rs")); +} diff --git a/serde-spdx/tests/test_3_0_1.rs b/serde-spdx/tests/test_3_0_1.rs new file mode 100644 index 0000000..a390672 --- /dev/null +++ b/serde-spdx/tests/test_3_0_1.rs @@ -0,0 +1,66 @@ +use serde_spdx::spdx::v_3_0_1::Spdx; + +#[test] +fn test_spdx_3_0_1_basic_structure() { + let spdx_json = r#"{ + "@context": "https://spdx.org/rdf/3.0.1/spdx-context.jsonld", + "@graph": [ + { + "type": "CreationInfo", + "@id": "_:creationinfo", + "createdBy": ["http://spdx.example.com/Agent/JoshuaWatt"], + "specVersion": "3.0.1", + "created": "2024-03-06T00:00:00Z" + } + ] + }"#; + + let spdx: Result = serde_json::from_str(spdx_json); + assert!(spdx.is_ok(), "Failed to parse SPDX 3.0.1: {:?}", spdx); + + let spdx = spdx.unwrap(); + assert_eq!( + spdx._context, + "https://spdx.org/rdf/3.0.1/spdx-context.jsonld" + ); + assert_eq!(spdx._graph.len(), 1); +} + +#[test] +fn test_spdx_3_0_1_package_sbom() { + let spdx_json = r#"{ + "@context": "https://spdx.org/rdf/3.0.1/spdx-context.jsonld", + "@graph": [ + { + "type": "CreationInfo", + "@id": "_:creationinfo", + "createdBy": ["http://spdx.example.com/Agent/JoshuaWatt"], + "specVersion": "3.0.1", + "created": "2024-03-06T00:00:00Z" + }, + { + "type": "Person", + "spdxId": "http://spdx.example.com/Agent/JoshuaWatt", + "name": "Joshua Watt", + "creationInfo": "_:creationinfo" + }, + { + "type": "SpdxDocument", + "spdxId": "http://spdx.example.com/Document1", + "creationInfo": "_:creationinfo", + "rootElement": ["http://spdx.example.com/BOM1"], + "profileConformance": ["core", "software"] + } + ] + }"#; + + let spdx: Result = serde_json::from_str(spdx_json); + assert!( + spdx.is_ok(), + "Failed to parse SPDX 3.0.1 package SBOM: {:?}", + spdx + ); + + let spdx = spdx.unwrap(); + assert_eq!(spdx._graph.len(), 3); +} diff --git a/tests/Cargo.toml b/tests/Cargo.toml index daaf719..b8a64f3 100644 --- a/tests/Cargo.toml +++ b/tests/Cargo.toml @@ -8,6 +8,10 @@ publish = false name = "test_examples" path = "examples_test.rs" +[[test]] +name = "spdx_3_0_1_integration_test" +path = "spdx_3_0_1_integration_test.rs" + [dev-dependencies] anyhow = "1.0.98" assert_cmd = "2.0.17" diff --git a/tests/spdx_3_0_1_integration_test.rs b/tests/spdx_3_0_1_integration_test.rs new file mode 100644 index 0000000..91417ef --- /dev/null +++ b/tests/spdx_3_0_1_integration_test.rs @@ -0,0 +1,59 @@ +use std::process::Command; + +#[test] +fn test_cargo_sbom_spdx_3_0_1_output() { + let output = Command::new("cargo") + .args(&[ + "run", + "-p", + "cargo-sbom", + "--", + "sbom", + "--output-format=spdx_json_3_0_1", + ]) + .current_dir(env!("CARGO_MANIFEST_DIR")) + .output() + .expect("Failed to execute cargo-sbom"); + + assert!( + output.status.success(), + "cargo-sbom command failed. stdout: {}, stderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + let stdout = String::from_utf8(output.stdout).expect("Invalid UTF-8 output"); + + // Parse the JSON output to verify it's valid SPDX 3.0.1 + let spdx_value: serde_json::Value = serde_json::from_str(&stdout) + .expect("Failed to parse SPDX 3.0.1 JSON output"); + + // Verify it has the correct context + assert_eq!( + spdx_value["@context"], + "https://spdx.org/rdf/3.0.1/spdx-context.jsonld" + ); + + // Verify it has a graph + assert!(spdx_value["@graph"].is_array()); + let graph = spdx_value["@graph"].as_array().unwrap(); + assert!(!graph.is_empty()); + + // Verify we have a CreationInfo element + let has_creation_info = graph + .iter() + .any(|element| element["type"] == "CreationInfo"); + assert!( + has_creation_info, + "SPDX 3.0.1 output should contain a CreationInfo element" + ); + + // Verify we have software_Package elements + let has_package = graph + .iter() + .any(|element| element["type"] == "software_Package"); + assert!( + has_package, + "SPDX 3.0.1 output should contain software_Package elements" + ); +}