use std::path::{Path, PathBuf};
use std::process::Command;
use crate::meta::Project;
use crate::targets::Target;
pub const SCHEMA: &str = "1.0";
#[derive(Debug, Clone)]
pub struct ToolRecord {
pub key: String,
pub name: String,
pub version: String,
pub install_hint: String,
}
#[derive(Debug, Clone)]
pub struct BuildInfo {
pub schema: String,
pub target: String,
pub profile: String,
pub host_os: String,
pub host_arch: String,
pub tools: Vec<ToolRecord>,
pub artifacts: Vec<(String, String)>,
pub inputs: Vec<(String, String)>,
pub payload: Vec<(String, String)>,
}
#[derive(Debug, Clone)]
pub struct Sbom {
pub schema: String,
pub app_id: String,
pub app_name: String,
pub app_version: String,
pub app_build: u64,
pub repository: Option<String>,
pub commit: Option<String>,
pub project_path: Option<String>,
pub dirty: bool,
pub components: Vec<Component>,
}
#[derive(Debug, Clone)]
pub struct Component {
pub name: String,
pub version: String,
pub license: Option<String>,
pub purl: String,
pub ecosystem: String,
}
fn probe(bin: &str, args: &[&str]) -> Option<String> {
let out = Command::new(bin).args(args).output().ok()?;
if !out.status.success() {
return None;
}
let text = String::from_utf8_lossy(&out.stdout);
let text = if text.trim().is_empty() {
String::from_utf8_lossy(&out.stderr).to_string()
} else {
text.to_string()
};
text.lines().next().map(|l| l.trim().to_string())
}
pub fn normalize_license(raw: &str) -> String {
let joined = raw
.split('/')
.map(str::trim)
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join(" OR ");
joined.split_whitespace().collect::<Vec<_>>().join(" ")
}
fn git_source(root: &Path) -> (Option<String>, Option<String>, bool, Option<String>) {
let git = |args: &[&str]| -> Option<String> {
let out = Command::new("git")
.current_dir(root)
.args(args)
.output()
.ok()?;
out.status
.success()
.then(|| String::from_utf8_lossy(&out.stdout).trim().to_string())
.filter(|s| !s.is_empty())
};
let remote = git(&["remote", "get-url", "origin"]).map(|u| {
if let Some(rest) = u.strip_prefix("git@") {
let rest = rest.replacen(':', "/", 1);
format!("https://{}", rest.trim_end_matches(".git"))
} else {
u.trim_end_matches(".git").to_string()
}
});
let commit = git(&["rev-parse", "HEAD"]);
let dirty = git(&["status", "--porcelain"]).is_some_and(|s| !s.is_empty());
let project_path = git(&["rev-parse", "--show-prefix"])
.map(|p| p.trim_end_matches('/').to_string())
.or_else(|| commit.as_ref().map(|_| String::new()));
(remote, commit, dirty, project_path)
}
fn cargo_components(root: &Path) -> Vec<Component> {
let Ok(out) = Command::new("cargo")
.current_dir(root)
.args(["metadata", "--format-version", "1"])
.output()
else {
return Vec::new();
};
if !out.status.success() {
return Vec::new();
}
let Ok(meta) = serde_json::from_slice::<serde_json::Value>(&out.stdout) else {
return Vec::new();
};
let mut comps: Vec<Component> = meta
.get("packages")
.and_then(|p| p.as_array())
.map(|pkgs| {
pkgs.iter()
.filter_map(|p| {
let name = p.get("name")?.as_str()?.to_string();
let version = p.get("version")?.as_str()?.to_string();
let license = p
.get("license")
.and_then(|l| l.as_str())
.map(normalize_license);
Some(Component {
purl: format!("pkg:cargo/{name}@{version}"),
name,
version,
license,
ecosystem: "cargo".into(),
})
})
.collect()
})
.unwrap_or_default();
comps.sort_by(|a, b| (&a.name, &a.version).cmp(&(&b.name, &b.version)));
comps.dedup_by(|a, b| a.purl == b.purl);
comps
}
pub fn collect_sbom(project: &Project) -> Sbom {
let (repository, commit, dirty, project_path) = git_source(&project.root);
Sbom {
schema: SCHEMA.into(),
app_id: project.manifest.app.id.clone(),
app_name: project.manifest.app.name.clone(),
app_version: project.manifest.app.version.clone(),
app_build: project.manifest.app.build,
repository,
commit,
dirty,
project_path,
components: cargo_components(&project.root),
}
}
fn tool_table(toolkit: &str) -> Vec<(&'static str, &'static str, &'static str)> {
let mut t: Vec<(&str, &str, &str)> = vec![
(
"rust",
"rustc",
"rustup toolchain install <version> && rustup override set <version>",
),
(
"cargo",
"cargo",
"ships with the matching rustc toolchain (rustup)",
),
(
"day",
"day CLI",
"cargo install day-cli --version <version>",
),
];
match toolkit {
"appkit" | "uikit" => t.extend([
(
"xcode",
"Xcode",
"https://developer.apple.com/download/all/?q=Xcode — install, then \
sudo xcode-select -s /Applications/Xcode_<version>.app",
),
(
"clang",
"Apple clang",
"ships with Xcode; selected by xcode-select",
),
]),
"mdc" => t.extend([
(
"gradle",
"Gradle",
"https://gradle.org/releases/ (or the project's ./gradlew wrapper)",
),
(
"java",
"JDK",
"brew install openjdk@<major> / apt install openjdk-<major>-jdk",
),
(
"ndk",
"Android NDK",
"sdkmanager 'ndk;<version>' — https://developer.android.com/ndk/downloads",
),
]),
"arkui" => t.extend([
(
"ohos-sdk",
"OpenHarmony SDK",
"https://gitee.com/openharmony/docs — see docs/harmonyos.md for the layout",
),
(
"hvigor",
"hvigor",
"ships with the OpenHarmony command-line-tools",
),
]),
"xaml" => t.extend([
(
"msvc",
"MSVC toolchain",
"https://visualstudio.microsoft.com/downloads/ (Desktop development with C++)",
),
(
"nsis",
"NSIS",
"choco install nsis / winget install NSIS.NSIS",
),
]),
"gtk" | "qt" => t.extend([
(
"flatpak-builder",
"flatpak-builder",
"apt install flatpak-builder / dnf install flatpak-builder",
),
("cc", "C compiler", "apt install build-essential"),
]),
_ => {}
}
t
}
pub fn tool_version(key: &str) -> Option<String> {
match key {
"rust" => probe("rustc", &["--version"]),
"cargo" => probe("cargo", &["--version"]),
"day" => Some(env!("DAY_VERSION_LONG").to_string()),
"xcode" => probe("xcodebuild", &["-version"]),
"clang" => probe("clang", &["--version"]),
"gradle" => probe("gradle", &["--version"]).or_else(|| probe("gradle", &["-v"])),
"java" => probe("javac", &["-version"]),
"ndk" => std::env::var("ANDROID_NDK_HOME")
.ok()
.and_then(|p| ndk_version(&p)),
"ohos-sdk" => std::env::var("OHOS_BASE_SDK_HOME")
.ok()
.and_then(|p| ohos_sdk_version(&p)),
"hvigor" => probe("hvigorw", &["--version"]),
"msvc" => probe("cl", &["/?"]),
"nsis" => probe("makensis", &["/VERSION"]).or_else(|| probe("makensis", &["-VERSION"])),
"flatpak-builder" => probe("flatpak-builder", &["--version"]),
"cc" => probe("cc", &["--version"]),
_ => None,
}
}
pub fn collect_buildinfo(target: &Target, profile: &str) -> BuildInfo {
let tools = tool_table(target.toolkit)
.into_iter()
.filter_map(|(key, name, hint)| {
Some(ToolRecord {
key: key.into(),
name: name.into(),
version: tool_version(key)?,
install_hint: hint.into(),
})
})
.collect();
BuildInfo {
schema: SCHEMA.into(),
target: target.name.into(),
profile: profile.into(),
host_os: crate::targets::host_os().into(),
host_arch: std::env::consts::ARCH.into(),
tools,
artifacts: Vec::new(),
inputs: Vec::new(),
payload: Vec::new(),
}
}
fn ohos_sdk_version(base: &str) -> Option<String> {
let mut apis: Vec<(u32, PathBuf)> = std::fs::read_dir(base)
.ok()?
.flatten()
.filter_map(|e| {
let n = e.file_name().to_string_lossy().parse::<u32>().ok()?;
Some((n, e.path()))
})
.collect();
apis.sort_by_key(|(n, _)| *n);
let (api, dir) = apis.pop()?;
let text = std::fs::read_to_string(dir.join("toolchains/oh-uni-package.json")).ok()?;
let doc: serde_json::Value = serde_json::from_str(&text).ok()?;
let version = doc.get("version")?.as_str()?;
Some(format!("{version} (API {api})"))
}
fn ndk_version(ndk_home: &str) -> Option<String> {
let text = std::fs::read_to_string(Path::new(ndk_home).join("source.properties")).ok()?;
text.lines()
.find_map(|l| l.strip_prefix("Pkg.Revision"))
.map(|v| v.trim_start_matches([' ', '=']).trim().to_string())
}
pub fn cyclonedx(sbom: &Sbom) -> serde_json::Value {
let components: Vec<serde_json::Value> = sbom
.components
.iter()
.map(|c| {
let mut o = serde_json::json!({
"type": "library",
"name": c.name,
"version": c.version,
"purl": c.purl,
});
if let Some(l) = &c.license {
o["licenses"] = serde_json::json!([{ "expression": l }]);
}
o["properties"] =
serde_json::json!([{ "name": "day:ecosystem", "value": c.ecosystem }]);
o
})
.collect();
serde_json::json!({
"bomFormat": "CycloneDX",
"specVersion": "1.5",
"version": 1,
"metadata": {
"component": {
"type": "application",
"bom-ref": sbom.app_id,
"name": sbom.app_name,
"version": sbom.app_version,
},
"properties": source_properties(sbom),
},
"components": components,
})
}
pub fn spdx(sbom: &Sbom) -> serde_json::Value {
let source_info = format!(
"repository={} commit={} dirty={} project={} app-id={}",
sbom.repository.as_deref().unwrap_or("NOASSERTION"),
sbom.commit.as_deref().unwrap_or("NOASSERTION"),
sbom.dirty,
sbom.project_path.as_deref().unwrap_or("NOASSERTION"),
sbom.app_id,
);
let mut packages = vec![serde_json::json!({
"SPDXID": "SPDXRef-Application",
"name": sbom.app_name,
"versionInfo": sbom.app_version,
"downloadLocation": sbom.repository.clone().unwrap_or_else(|| "NOASSERTION".into()),
"sourceInfo": source_info,
"filesAnalyzed": false,
"licenseConcluded": "NOASSERTION",
"licenseDeclared": "NOASSERTION",
})];
packages.extend(sbom.components.iter().enumerate().map(|(i, c)| {
serde_json::json!({
"SPDXID": format!("SPDXRef-Package-{i}"),
"name": c.name,
"versionInfo": c.version,
"downloadLocation": "NOASSERTION",
"filesAnalyzed": false,
"licenseConcluded": "NOASSERTION",
"licenseDeclared": c.license.clone().unwrap_or_else(|| "NOASSERTION".into()),
"externalRefs": [{
"referenceCategory": "PACKAGE-MANAGER",
"referenceType": "purl",
"referenceLocator": c.purl,
}],
})
}));
serde_json::json!({
"spdxVersion": "SPDX-2.3",
"dataLicense": "CC0-1.0",
"SPDXID": "SPDXRef-DOCUMENT",
"name": format!("{}-{}", sbom.app_name, sbom.app_version),
"documentNamespace": format!("https://daybrite.dev/spdx/{}/{}", sbom.app_id, sbom.app_version),
"creationInfo": {
"creators": [format!("Tool: day-cli-{}", env!("CARGO_PKG_VERSION"))],
"created": "1970-01-01T00:00:00Z",
},
"packages": packages,
})
}
fn source_properties(sbom: &Sbom) -> Vec<serde_json::Value> {
let mut props = vec![
serde_json::json!({"name": "day:schema", "value": sbom.schema}),
serde_json::json!({"name": "day:app-id", "value": sbom.app_id}),
serde_json::json!({"name": "day:app-build", "value": sbom.app_build.to_string()}),
serde_json::json!({"name": "day:dirty", "value": sbom.dirty.to_string()}),
serde_json::json!({"name": "day:native-deps", "value": "not-collected"}),
];
if let Some(r) = &sbom.repository {
props.push(serde_json::json!({"name": "day:repository", "value": r}));
}
if let Some(c) = &sbom.commit {
props.push(serde_json::json!({"name": "day:commit", "value": c}));
}
if let Some(p) = &sbom.project_path {
props.push(serde_json::json!({"name": "day:project", "value": p}));
}
props
}
pub fn buildinfo_json(info: &BuildInfo) -> serde_json::Value {
serde_json::json!({
"schema": info.schema,
"target": info.target,
"profile": info.profile,
"host": { "os": info.host_os, "arch": info.host_arch },
"tools": info.tools.iter().map(|t| serde_json::json!({
"key": t.key,
"name": t.name,
"version": t.version,
"install": t.install_hint,
})).collect::<Vec<_>>(),
"artifacts": info.artifacts.iter().map(|(n, d)| serde_json::json!({
"name": n, "sha256": d,
})).collect::<Vec<_>>(),
"inputs": info.inputs.iter().map(|(k, v)| serde_json::json!({
"name": k, "value": v,
})).collect::<Vec<_>>(),
"payload": info.payload.iter().map(|(n, d)| serde_json::json!({
"path": n, "sha256": d,
})).collect::<Vec<_>>(),
})
}
pub fn write_sbom(
dir: &Path,
sbom: &Sbom,
formats: &[crate::meta::SbomFormat],
) -> Result<Vec<PathBuf>, String> {
use crate::meta::SbomFormat;
std::fs::create_dir_all(dir).map_err(|e| format!("mkdir {}: {e}", dir.display()))?;
let mut out = Vec::new();
for f in formats {
let doc = match f {
SbomFormat::Cyclonedx => cyclonedx(sbom),
SbomFormat::Spdx => spdx(sbom),
};
let path = dir.join(f.file_name());
let text =
serde_json::to_string_pretty(&doc).map_err(|e| format!("serializing SBOM: {e}"))?;
std::fs::write(&path, text).map_err(|e| format!("write {}: {e}", path.display()))?;
out.push(path);
}
Ok(out)
}
pub fn embed_into(sbom_dir: &Path, dest: &Path) -> Result<(), String> {
if !sbom_dir.is_dir() {
return Ok(());
}
let dest = dest.join("sbom");
std::fs::create_dir_all(&dest).map_err(|e| format!("mkdir {}: {e}", dest.display()))?;
for entry in std::fs::read_dir(sbom_dir)
.map_err(|e| format!("reading {}: {e}", sbom_dir.display()))?
.flatten()
{
let from = entry.path();
if from.is_file() {
let to = dest.join(entry.file_name());
std::fs::copy(&from, &to)
.map_err(|e| format!("copy {} -> {}: {e}", from.display(), to.display()))?;
}
}
Ok(())
}
pub fn write_buildinfo(path: &Path, info: &BuildInfo) -> Result<(), String> {
let text = serde_json::to_string_pretty(&buildinfo_json(info))
.map_err(|e| format!("serializing buildinfo: {e}"))?;
std::fs::write(path, text).map_err(|e| format!("write {}: {e}", path.display()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_ohos_sdk_version_comes_from_its_package_manifest() {
let tmp = std::env::temp_dir().join(format!("day-ohos-sdk-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);
for (api, version) in [("18", "5.1.0.107"), ("12", "4.1.0.500")] {
let dir = tmp.join(api).join("toolchains");
std::fs::create_dir_all(&dir).expect("mkdir");
std::fs::write(
dir.join("oh-uni-package.json"),
format!("{{\"apiVersion\": \"{api}\", \"version\": \"{version}\"}}"),
)
.expect("write");
}
assert_eq!(
ohos_sdk_version(&tmp.to_string_lossy()).as_deref(),
Some("5.1.0.107 (API 18)")
);
assert_eq!(ohos_sdk_version("/nonexistent/sdk"), None);
let _ = std::fs::remove_dir_all(&tmp);
if let Ok(real) = std::env::var("OHOS_BASE_SDK_HOME")
&& Path::new(&real).is_dir()
{
assert!(
ohos_sdk_version(&real).is_some(),
"the SDK at {real} did not parse"
);
}
}
#[test]
fn cargo_slash_separators_become_spdx_or() {
assert_eq!(normalize_license("MIT/Apache-2.0"), "MIT OR Apache-2.0");
assert_eq!(normalize_license("Apache-2.0 / MIT"), "Apache-2.0 OR MIT");
assert_eq!(normalize_license("MIT OR Apache-2.0"), "MIT OR Apache-2.0");
assert_eq!(normalize_license("Unicode-3.0"), "Unicode-3.0");
assert_eq!(
normalize_license("Apache-2.0 WITH LLVM-exception"),
"Apache-2.0 WITH LLVM-exception"
);
}
#[test]
fn the_spdx_document_carries_no_build_timestamp() {
let sbom = Sbom {
schema: SCHEMA.into(),
app_id: "dev.example.app".into(),
app_name: "App".into(),
app_version: "1.0.0".into(),
app_build: 1,
repository: None,
commit: None,
dirty: false,
project_path: Some("apps/showcase".into()),
components: Vec::new(),
};
let doc = spdx(&sbom);
assert_eq!(doc["creationInfo"]["created"], "1970-01-01T00:00:00Z");
}
}
fn debian_arch() -> Option<&'static str> {
match std::env::consts::ARCH {
"x86_64" => Some("amd64"),
"aarch64" => Some("arm64"),
"arm" => Some("armhf"),
"x86" => Some("i386"),
"riscv64" => Some("riscv64"),
"powerpc64" => Some("ppc64el"),
"s390x" => Some("s390x"),
_ => None,
}
}
fn dpkg_installed() -> Option<Vec<String>> {
let out = Command::new("dpkg-query")
.args(["-W", "-f=${binary:Package} (= ${Version})\n"])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let mut list: Vec<String> = String::from_utf8_lossy(&out.stdout)
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.map(str::to_string)
.collect();
list.sort();
list.dedup();
(!list.is_empty()).then_some(list)
}
pub fn debian_buildinfo(
sbom: &Sbom,
info: &BuildInfo,
build_path: &Path,
runtime: Option<(&str, String)>,
) -> String {
let arch = debian_arch().unwrap_or("unknown");
let mut out = String::new();
let field = |out: &mut String, k: &str, v: &str| {
out.push_str(&format!("{k}: {v}\n"));
};
field(&mut out, "Format", "1.0");
field(&mut out, "Source", &sbom.app_name);
field(&mut out, "Binary", &sbom.app_name);
field(&mut out, "Architecture", arch);
field(&mut out, "Version", &sbom.app_version);
field(&mut out, "Build-Origin", "Day");
field(&mut out, "Build-Architecture", arch);
field(&mut out, "Build-Path", &build_path.display().to_string());
if let Some(k) = probe("uname", &["-sr"]) {
field(&mut out, "Build-Kernel-Version", &k);
}
out.push_str("Checksums-Sha256:\n");
for (name, digest) in &info.artifacts {
let size = build_path
.join("build/day/dist")
.join(name)
.metadata()
.map(|m| m.len())
.unwrap_or(0);
out.push_str(&format!(" {digest} {size} {name}\n"));
}
out.push_str("Installed-Build-Depends:\n");
match dpkg_installed() {
Some(pkgs) => {
for (i, p) in pkgs.iter().enumerate() {
let sep = if i + 1 == pkgs.len() { "" } else { "," };
out.push_str(&format!(" {p}{sep}\n"));
}
}
None => out.push_str(" # not a dpkg host — see X-Day-Tools for the toolchain versions\n"),
}
if let Some((id, version)) = runtime {
field(
&mut out,
"X-Day-Flatpak-Runtime",
&format!("{id}//{version}"),
);
}
if let Some(r) = &sbom.repository {
field(&mut out, "X-Day-Repository", r);
}
if let Some(c) = &sbom.commit {
field(&mut out, "X-Day-Commit", c);
}
field(&mut out, "X-Day-Dirty", &sbom.dirty.to_string());
field(&mut out, "X-Day-Target", &info.target);
field(&mut out, "X-Day-Profile", &info.profile);
let tools = info
.tools
.iter()
.map(|t| format!("{} (= {})", t.key, t.version))
.collect::<Vec<_>>()
.join(", ");
field(&mut out, "X-Day-Tools", &tools);
out
}
pub fn debian_buildinfo_name(sbom: &Sbom) -> String {
let source = sbom.app_name.replace(' ', "-").to_lowercase();
format!(
"{source}_{}_{}.buildinfo",
sbom.app_version,
debian_arch().unwrap_or("unknown")
)
}
#[cfg(test)]
mod debian_tests {
use super::*;
fn fixture() -> (Sbom, BuildInfo) {
let sbom = Sbom {
schema: SCHEMA.into(),
app_id: "dev.daybrite.showcase".into(),
app_name: "showcase".into(),
app_version: "0.1.2".into(),
app_build: 1,
repository: Some("https://github.com/daybrite/day".into()),
commit: Some("3cf799cb9c9dc4bc045bc9f1457aed2838bf5e1d".into()),
dirty: false,
project_path: Some("apps/showcase".into()),
components: Vec::new(),
};
let info = BuildInfo {
schema: SCHEMA.into(),
target: "linux-gtk".into(),
profile: "release".into(),
host_os: "linux".into(),
host_arch: "x86_64".into(),
tools: vec![ToolRecord {
key: "rust".into(),
name: "rustc".into(),
version: "1.97.0".into(),
install_hint: "rustup".into(),
}],
inputs: vec![("DAY_ANDROID_ABI".into(), "arm64-v8a,x86_64".into())],
payload: Vec::new(),
artifacts: vec![("showcase-gtk-x86_64.flatpak".into(), "abc123".into())],
};
(sbom, info)
}
#[test]
fn the_buildinfo_json_carries_inputs_and_payload_digests() {
let (_, mut info) = fixture();
info.payload = vec![("bin/showcase-bin".into(), "ab".repeat(32))];
let json = buildinfo_json(&info);
assert_eq!(json["inputs"][0]["name"], "DAY_ANDROID_ABI");
assert_eq!(json["inputs"][0]["value"], "arm64-v8a,x86_64");
assert_eq!(json["payload"][0]["path"], "bin/showcase-bin");
assert_eq!(json["payload"][0]["sha256"], "ab".repeat(32));
}
#[test]
fn carries_every_required_field() {
let (sbom, info) = fixture();
let text = debian_buildinfo(&sbom, &info, Path::new("/build"), None);
for field in [
"Format: 1.0",
"Source:",
"Binary:",
"Architecture:",
"Version:",
"Build-Architecture:",
"Checksums-Sha256:",
"Installed-Build-Depends:",
] {
assert!(
text.contains(field),
"missing required field {field}:\n{text}"
);
}
}
#[test]
fn folded_fields_are_indented() {
let (sbom, info) = fixture();
let text = debian_buildinfo(&sbom, &info, Path::new("/build"), None);
let mut in_folded = false;
for line in text.lines() {
if line.ends_with(':') && !line.starts_with(' ') {
in_folded = true;
continue;
}
if in_folded && !line.starts_with(' ') {
in_folded = false;
}
if in_folded {
assert!(line.starts_with(' '), "continuation not indented: {line:?}");
}
}
assert!(
text.contains(" abc123 0 showcase-gtk-x86_64.flatpak"),
"{text}"
);
}
#[test]
fn the_flatpak_runtime_is_recorded() {
let (sbom, info) = fixture();
let text = debian_buildinfo(
&sbom,
&info,
Path::new("/build"),
Some(("org.gnome.Platform", "48".into())),
);
assert!(
text.contains("X-Day-Flatpak-Runtime: org.gnome.Platform//48"),
"{text}"
);
assert!(text.contains("X-Day-Commit: 3cf799cb"), "{text}");
}
#[test]
fn the_filename_follows_debian_convention() {
let (sbom, _) = fixture();
let name = debian_buildinfo_name(&sbom);
assert!(name.starts_with("showcase_0.1.2_"), "{name}");
assert!(name.ends_with(".buildinfo"), "{name}");
}
}