use std::path::Path;
use serde::Serialize;
use super::error::PackageError;
#[derive(Debug, Serialize)]
pub(crate) struct SpdxDocument {
#[serde(rename = "spdxVersion")]
pub spdx_version: String,
#[serde(rename = "dataLicense")]
pub data_license: String,
#[serde(rename = "SPDXID")]
pub spdx_id: String,
pub name: String,
#[serde(rename = "documentNamespace")]
pub document_namespace: String,
#[serde(rename = "creationInfo")]
pub creation_info: SpdxCreationInfo,
pub packages: Vec<SpdxPackage>,
pub relationships: Vec<SpdxRelationship>,
}
#[derive(Debug, Serialize)]
pub(crate) struct SpdxCreationInfo {
pub created: String,
pub creators: Vec<String>,
#[serde(rename = "licenseListVersion")]
pub license_list_version: String,
}
#[derive(Debug, Serialize)]
pub(crate) struct SpdxPackage {
pub name: String,
#[serde(rename = "SPDXID")]
pub spdx_id: String,
#[serde(rename = "versionInfo")]
pub version_info: String,
#[serde(rename = "downloadLocation")]
pub download_location: String,
#[serde(rename = "filesAnalyzed")]
pub files_analyzed: bool,
#[serde(rename = "licenseConcluded")]
pub license_concluded: String,
#[serde(rename = "licenseDeclared")]
pub license_declared: String,
#[serde(rename = "copyrightText")]
pub copyright_text: String,
pub supplier: String,
}
#[derive(Debug, Serialize)]
pub(crate) struct SpdxRelationship {
#[serde(rename = "spdxElementId")]
pub spdx_element_id: String,
#[serde(rename = "relationshipType")]
pub relationship_type: String,
#[serde(rename = "relatedSpdxElement")]
pub related_spdx_element: String,
}
impl SpdxDocument {
pub(crate) fn new(
application: &str,
framework_version: &str,
created: &str,
dependencies: &[(String, String)],
) -> Self {
let doc_id = "SPDXRef-DOCUMENT".to_owned();
let app_id = "SPDXRef-Package-Application".to_owned();
let mut packages = Vec::new();
packages.push(SpdxPackage {
name: application.to_owned(),
spdx_id: app_id.clone(),
version_info: "UNKNOWN".to_owned(),
download_location: "NOASSERTION".to_owned(),
files_analyzed: false,
license_concluded: "NOASSERTION".to_owned(),
license_declared: "NOASSERTION".to_owned(),
copyright_text: "NOASSERTION".to_owned(),
supplier: "Organization: Application".to_owned(),
});
let fw_id = "SPDXRef-Package-Arcature".to_owned();
packages.push(SpdxPackage {
name: "arcature".to_owned(),
spdx_id: fw_id.clone(),
version_info: framework_version.to_owned(),
download_location: "NOASSERTION".to_owned(),
files_analyzed: false,
license_concluded: "Apache-2.0".to_owned(),
license_declared: "Apache-2.0".to_owned(),
copyright_text: "NOASSERTION".to_owned(),
supplier: "Organization: ArcatureLabs".to_owned(),
});
let mut sorted_deps: Vec<&(String, String)> = dependencies.iter().collect();
sorted_deps.sort_by(|a, b| a.0.cmp(&b.0));
sorted_deps.dedup_by(|a, b| a.0 == b.0);
for (idx, (name, version)) in sorted_deps.iter().enumerate() {
let dep_id = format!("SPDXRef-Package-Dep-{idx}");
packages.push(SpdxPackage {
name: name.clone(),
spdx_id: dep_id,
version_info: version.clone(),
download_location: "NOASSERTION".to_owned(),
files_analyzed: false,
license_concluded: "NOASSERTION".to_owned(),
license_declared: "NOASSERTION".to_owned(),
copyright_text: "NOASSERTION".to_owned(),
supplier: "NOASSERTION".to_owned(),
});
}
let relationships = vec![
SpdxRelationship {
spdx_element_id: doc_id.clone(),
relationship_type: "DESCRIBES".to_owned(),
related_spdx_element: app_id.clone(),
},
SpdxRelationship {
spdx_element_id: app_id.clone(),
relationship_type: "DEPENDS_ON".to_owned(),
related_spdx_element: fw_id,
},
];
SpdxDocument {
spdx_version: "SPDX-2.3".to_owned(),
data_license: "CC0-1.0".to_owned(),
spdx_id: doc_id,
name: format!("{application} production bundle"),
document_namespace: format!("https://arcature.dev/spdx/{application}/{created}"),
creation_info: SpdxCreationInfo {
created: created.to_owned(),
creators: vec![format!(
"Tool: arc package (arcature {})",
framework_version
)],
license_list_version: "3.23".to_owned(),
},
packages,
relationships,
}
}
pub(crate) fn to_json(&self) -> Result<String, PackageError> {
serde_json::to_string_pretty(self)
.map_err(|source| PackageError::Serialize {
what: "spdx sbom",
source,
})
.map(|json| format!("{json}\n"))
}
pub(crate) fn write(&self, path: &Path) -> Result<(), PackageError> {
let json = self.to_json()?;
std::fs::write(path, json).map_err(|source| PackageError::Write {
path: path.to_path_buf(),
source,
})
}
}
#[cfg(test)]
mod tests {
use super::SpdxDocument;
#[test]
fn sbom_has_required_spdx_23_top_level_fields() {
let doc = SpdxDocument::new(
"demo",
"2026.1.0",
"2026-08-17T00:00:00Z",
&[("serde".to_owned(), "1.0.229".to_owned())],
);
let json = doc.to_json().expect("serialize");
assert!(json.contains("\"spdxVersion\": \"SPDX-2.3\""));
assert!(json.contains("\"dataLicense\": \"CC0-1.0\""));
assert!(json.contains("\"SPDXID\": \"SPDXRef-DOCUMENT\""));
assert!(json.contains("\"name\": \"demo production bundle\""));
assert!(json.contains("\"documentNamespace\""));
assert!(json.contains("\"creationInfo\""));
assert!(json.contains("\"packages\""));
assert!(json.contains("\"relationships\""));
assert!(json.contains("\"DESCRIBES\""));
assert!(json.contains("\"DEPENDS_ON\""));
assert!(json.contains("\"licenseListVersion\": \"3.23\""));
assert!(json.contains("\"Tool: arc package"));
}
#[test]
fn sbom_declares_application_arcature_and_sorted_deps() {
let doc = SpdxDocument::new(
"demo",
"2026.1.0",
"2026-08-17T00:00:00Z",
&[
("zstd".to_owned(), "0.13".to_owned()),
("axum".to_owned(), "0.8.9".to_owned()),
("axum".to_owned(), "0.8.9".to_owned()), ],
);
let json = doc.to_json().expect("serialize");
let parsed: serde_json::Value = serde_json::from_str(&json).expect("parse sbom");
let packages = parsed["packages"].as_array().expect("packages array");
assert_eq!(packages.len(), 4);
assert!(json.contains("\"name\": \"arcature\""));
assert!(json.contains("\"licenseDeclared\": \"Apache-2.0\""));
assert!(json.contains("\"versionInfo\": \"2026.1.0\""));
let axum_idx = json.find("\"name\": \"axum\"").expect("axum");
let zstd_idx = json.find("\"name\": \"zstd\"").expect("zstd");
assert!(axum_idx < zstd_idx);
assert!(json.contains("\"licenseDeclared\": \"NOASSERTION\""));
}
#[test]
fn sbom_is_deterministic_for_same_inputs() {
let make = || {
SpdxDocument::new(
"demo",
"2026.1.0",
"2026-08-17T00:00:00Z",
&[("serde".to_owned(), "1.0.229".to_owned())],
)
.to_json()
.expect("serialize")
};
assert_eq!(make(), make());
}
#[test]
fn sbom_trailing_newline() {
let doc = SpdxDocument::new("demo", "2026.1.0", "2026-08-17T00:00:00Z", &[]);
let json = doc.to_json().expect("serialize");
assert!(json.ends_with("}\n"));
}
}