Skip to content

Commit 4914dac

Browse files
committed
feat: Add SBOM pre-cursor files
Adds a new option `build.sbom` that adds generation of a JSON file containing dependency information alongside compiled artifacts.
1 parent 392d68b commit 4914dac

File tree

13 files changed

+1051
-15
lines changed

13 files changed

+1051
-15
lines changed

Cargo.lock

+1-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/cargo-test-support/Cargo.toml

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "cargo-test-support"
3-
version = "0.7.2"
3+
version = "0.7.3"
44
edition.workspace = true
55
rust-version = "1.85" # MSRV:1
66
license.workspace = true

crates/cargo-test-support/src/lib.rs

+10
Original file line numberDiff line numberDiff line change
@@ -415,6 +415,16 @@ impl Project {
415415
.join(paths::get_lib_filename(name, kind))
416416
}
417417

418+
/// Path to a dynamic library.
419+
/// ex: `/path/to/cargo/target/cit/t0/foo/target/debug/examples/libex.dylib`
420+
pub fn dylib(&self, name: &str) -> PathBuf {
421+
self.target_debug_dir().join(format!(
422+
"{}{name}{}",
423+
env::consts::DLL_PREFIX,
424+
env::consts::DLL_SUFFIX
425+
))
426+
}
427+
418428
/// Path to a debug binary.
419429
///
420430
/// ex: `$CARGO_TARGET_TMPDIR/cit/t0/foo/target/debug/foo`

src/cargo/core/compiler/build_config.rs

+14
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ pub struct BuildConfig {
4848
pub future_incompat_report: bool,
4949
/// Which kinds of build timings to output (empty if none).
5050
pub timing_outputs: Vec<TimingOutput>,
51+
/// Output SBOM precursor files.
52+
pub sbom: bool,
5153
}
5254

5355
fn default_parallelism() -> CargoResult<u32> {
@@ -99,6 +101,17 @@ impl BuildConfig {
99101
},
100102
};
101103

104+
// If sbom flag is set, it requires the unstable feature
105+
let sbom = match (cfg.sbom, gctx.cli_unstable().sbom) {
106+
(Some(sbom), true) => sbom,
107+
(Some(_), false) => {
108+
gctx.shell()
109+
.warn("ignoring 'sbom' config, pass `-Zsbom` to enable it")?;
110+
false
111+
}
112+
(None, _) => false,
113+
};
114+
102115
Ok(BuildConfig {
103116
requested_kinds,
104117
jobs,
@@ -115,6 +128,7 @@ impl BuildConfig {
115128
export_dir: None,
116129
future_incompat_report: false,
117130
timing_outputs: Vec::new(),
131+
sbom,
118132
})
119133
}
120134

src/cargo/core/compiler/build_runner/mod.rs

+37
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
//! [`BuildRunner`] is the mutable state used during the build process.
22
33
use std::collections::{BTreeSet, HashMap, HashSet};
4+
use std::io::BufWriter;
45
use std::path::{Path, PathBuf};
56
use std::sync::{Arc, Mutex};
67

@@ -10,6 +11,7 @@ use crate::core::PackageId;
1011
use crate::util::cache_lock::CacheLockMode;
1112
use crate::util::errors::CargoResult;
1213
use anyhow::{bail, Context as _};
14+
use cargo_util::paths;
1315
use filetime::FileTime;
1416
use itertools::Itertools;
1517
use jobserver::Client;
@@ -291,6 +293,14 @@ impl<'a, 'gctx> BuildRunner<'a, 'gctx> {
291293
}
292294

293295
super::output_depinfo(&mut self, unit)?;
296+
297+
if self.bcx.build_config.sbom {
298+
let sbom = super::build_sbom(&mut self, unit)?;
299+
for sbom_output_file in self.sbom_output_files(unit)? {
300+
let outfile = BufWriter::new(paths::create(sbom_output_file)?);
301+
serde_json::to_writer(outfile, &sbom)?;
302+
}
303+
}
294304
}
295305

296306
for (script_meta, output) in self.build_script_outputs.lock().unwrap().iter() {
@@ -446,6 +456,33 @@ impl<'a, 'gctx> BuildRunner<'a, 'gctx> {
446456
self.files().metadata(unit).unit_id()
447457
}
448458

459+
/// Returns the list of SBOM output file paths for a given [`Unit`].
460+
pub fn sbom_output_files(&self, unit: &Unit) -> CargoResult<Vec<PathBuf>> {
461+
let mut sbom_files = Vec::new();
462+
if !self.bcx.build_config.sbom || !self.bcx.gctx.cli_unstable().sbom {
463+
return Ok(sbom_files);
464+
}
465+
for output in self.outputs(unit)?.iter() {
466+
if matches!(output.flavor, FileFlavor::Normal | FileFlavor::Linkable) {
467+
if let Some(path) = &output.hardlink {
468+
sbom_files.push(Self::append_sbom_suffix(path));
469+
}
470+
if let Some(path) = &output.export_path {
471+
sbom_files.push(Self::append_sbom_suffix(path));
472+
}
473+
}
474+
}
475+
Ok(sbom_files)
476+
}
477+
478+
/// Append the SBOM suffix to the file name.
479+
fn append_sbom_suffix(link: &PathBuf) -> PathBuf {
480+
const SBOM_FILE_EXTENSION: &str = ".cargo-sbom.json";
481+
let mut link_buf = link.clone().into_os_string();
482+
link_buf.push(SBOM_FILE_EXTENSION);
483+
PathBuf::from(link_buf)
484+
}
485+
449486
pub fn is_primary_package(&self, unit: &Unit) -> bool {
450487
self.primary_packages.contains(&unit.pkg.package_id())
451488
}

src/cargo/core/compiler/mod.rs

+15-2
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ pub(crate) mod layout;
4747
mod links;
4848
mod lto;
4949
mod output_depinfo;
50+
mod output_sbom;
5051
pub mod rustdoc;
5152
pub mod standard_lib;
5253
mod timings;
@@ -60,7 +61,7 @@ use std::env;
6061
use std::ffi::{OsStr, OsString};
6162
use std::fmt::Display;
6263
use std::fs::{self, File};
63-
use std::io::{BufRead, Write};
64+
use std::io::{BufRead, BufWriter, Write};
6465
use std::path::{Path, PathBuf};
6566
use std::sync::Arc;
6667

@@ -85,6 +86,7 @@ use self::job_queue::{Job, JobQueue, JobState, Work};
8586
pub(crate) use self::layout::Layout;
8687
pub use self::lto::Lto;
8788
use self::output_depinfo::output_depinfo;
89+
use self::output_sbom::build_sbom;
8890
use self::unit_graph::UnitDep;
8991
use crate::core::compiler::future_incompat::FutureIncompatReport;
9092
pub use crate::core::compiler::unit::{Unit, UnitInterner};
@@ -307,6 +309,8 @@ fn rustc(
307309
let script_metadata = build_runner.find_build_script_metadata(unit);
308310
let is_local = unit.is_local();
309311
let artifact = unit.artifact;
312+
let sbom_files = build_runner.sbom_output_files(unit)?;
313+
let sbom = build_sbom(build_runner, unit)?;
310314

311315
let hide_diagnostics_for_scrape_unit = build_runner.bcx.unit_can_fail_for_docscraping(unit)
312316
&& !matches!(
@@ -392,6 +396,12 @@ fn rustc(
392396
if build_plan {
393397
state.build_plan(buildkey, rustc.clone(), outputs.clone());
394398
} else {
399+
for file in sbom_files {
400+
tracing::debug!("writing sbom to {}", file.display());
401+
let outfile = BufWriter::new(paths::create(&file)?);
402+
serde_json::to_writer(outfile, &sbom)?;
403+
}
404+
395405
let result = exec
396406
.exec(
397407
&rustc,
@@ -685,6 +695,7 @@ where
685695
/// completion of other units will be added later in runtime, such as flags
686696
/// from build scripts.
687697
fn prepare_rustc(build_runner: &BuildRunner<'_, '_>, unit: &Unit) -> CargoResult<ProcessBuilder> {
698+
let gctx = build_runner.bcx.gctx;
688699
let is_primary = build_runner.is_primary_package(unit);
689700
let is_workspace = build_runner.bcx.ws.is_member(&unit.pkg);
690701

@@ -700,7 +711,7 @@ fn prepare_rustc(build_runner: &BuildRunner<'_, '_>, unit: &Unit) -> CargoResult
700711
base.args(args);
701712
}
702713
base.args(&unit.rustflags);
703-
if build_runner.bcx.gctx.cli_unstable().binary_dep_depinfo {
714+
if gctx.cli_unstable().binary_dep_depinfo {
704715
base.arg("-Z").arg("binary-dep-depinfo");
705716
}
706717
if build_runner.bcx.gctx.cli_unstable().checksum_freshness {
@@ -709,6 +720,8 @@ fn prepare_rustc(build_runner: &BuildRunner<'_, '_>, unit: &Unit) -> CargoResult
709720

710721
if is_primary {
711722
base.env("CARGO_PRIMARY_PACKAGE", "1");
723+
let file_list = std::env::join_paths(build_runner.sbom_output_files(unit)?)?;
724+
base.env("CARGO_SBOM_PATH", file_list);
712725
}
713726

714727
if unit.target.is_test() || unit.target.is_bench() {
+192
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
//! cargo-sbom precursor files for external tools to create SBOM files from.
2+
//! See [`build_sbom_graph`] for more.
3+
4+
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
5+
use std::path::PathBuf;
6+
7+
use cargo_util_schemas::core::PackageIdSpec;
8+
use itertools::Itertools;
9+
use serde::Serialize;
10+
11+
use crate::core::TargetKind;
12+
use crate::util::interning::InternedString;
13+
use crate::util::Rustc;
14+
use crate::CargoResult;
15+
16+
use super::{BuildOutput, CompileMode};
17+
use super::{BuildRunner, Unit};
18+
19+
/// Typed version of a SBOM format version number.
20+
#[derive(Serialize, Copy, Clone, Debug, Ord, PartialOrd, Eq, PartialEq)]
21+
pub struct SbomFormatVersion(u32);
22+
23+
#[derive(Debug, Copy, Clone, PartialEq, PartialOrd, Eq, Ord, Serialize)]
24+
#[serde(rename_all = "snake_case")]
25+
enum SbomDependencyType {
26+
/// A dependency linked to the artifact produced by this unit.
27+
Normal,
28+
/// A dependency needed to run the build for this unit (e.g. a build script or proc-macro).
29+
/// The dependency is not linked to the artifact produced by this unit.
30+
Build,
31+
}
32+
33+
#[derive(Serialize, Copy, Clone, Debug, Ord, PartialOrd, Eq, PartialEq)]
34+
struct SbomIndex(usize);
35+
36+
#[derive(Serialize, Clone, Debug)]
37+
#[serde(rename_all = "snake_case")]
38+
struct SbomDependency {
39+
index: SbomIndex,
40+
kind: SbomDependencyType,
41+
}
42+
43+
#[derive(Serialize, Clone, Debug)]
44+
#[serde(rename_all = "snake_case")]
45+
struct SbomPackage {
46+
id: PackageIdSpec,
47+
features: Vec<String>,
48+
cfgs: Vec<String>,
49+
dependencies: Vec<SbomDependency>,
50+
kind: TargetKind,
51+
}
52+
53+
impl SbomPackage {
54+
pub fn new(unit: &Unit, build_script_output: Option<&BuildOutput>) -> Self {
55+
let package_id = unit.pkg.package_id().to_spec();
56+
let features = unit.features.iter().map(|f| f.to_string()).collect_vec();
57+
let cfgs = build_script_output
58+
.map(|b| b.cfgs.clone())
59+
.unwrap_or_default();
60+
Self {
61+
id: package_id,
62+
features,
63+
cfgs,
64+
dependencies: Vec::new(),
65+
kind: unit.target.kind().clone(),
66+
}
67+
}
68+
}
69+
70+
#[derive(Serialize, Clone)]
71+
#[serde(rename_all = "snake_case")]
72+
struct SbomRustc {
73+
version: String,
74+
wrapper: Option<PathBuf>,
75+
workspace_wrapper: Option<PathBuf>,
76+
commit_hash: Option<String>,
77+
host: String,
78+
verbose_version: String,
79+
}
80+
81+
impl From<&Rustc> for SbomRustc {
82+
fn from(rustc: &Rustc) -> Self {
83+
Self {
84+
version: rustc.version.to_string(),
85+
wrapper: rustc.wrapper.clone(),
86+
workspace_wrapper: rustc.workspace_wrapper.clone(),
87+
commit_hash: rustc.commit_hash.clone(),
88+
host: rustc.host.to_string(),
89+
verbose_version: rustc.verbose_version.clone(),
90+
}
91+
}
92+
}
93+
94+
#[derive(Serialize)]
95+
#[serde(rename_all = "snake_case")]
96+
pub struct Sbom {
97+
version: SbomFormatVersion,
98+
root: SbomIndex,
99+
packages: Vec<SbomPackage>,
100+
rustc: SbomRustc,
101+
target: InternedString,
102+
}
103+
104+
/// Build an [`Sbom`] for the given [`Unit`].
105+
pub fn build_sbom(build_runner: &BuildRunner<'_, '_>, root: &Unit) -> CargoResult<Sbom> {
106+
let bcx = build_runner.bcx;
107+
let rustc: SbomRustc = bcx.rustc().into();
108+
109+
let mut packages = Vec::new();
110+
let build_script_outputs = build_runner.build_script_outputs.lock().unwrap();
111+
let sbom_graph = build_sbom_graph(build_runner, root);
112+
113+
// Build set of indicies for each node in the graph for fast lookup.
114+
let indicies: HashMap<&Unit, SbomIndex> = sbom_graph
115+
.keys()
116+
.enumerate()
117+
.map(|(i, dep)| (*dep, SbomIndex(i)))
118+
.collect();
119+
120+
// Add a item to the packages list for each node in the graph.
121+
for (unit, edges) in sbom_graph {
122+
let build_script_output = build_runner
123+
.find_build_script_metadata(unit)
124+
.and_then(|meta| build_script_outputs.get(meta));
125+
let mut package = SbomPackage::new(unit, build_script_output);
126+
for (dep, kind) in edges {
127+
package.dependencies.push(SbomDependency {
128+
index: indicies[dep],
129+
kind: kind,
130+
});
131+
}
132+
packages.push(package);
133+
}
134+
let target = match root.kind {
135+
super::CompileKind::Host => build_runner.bcx.host_triple(),
136+
super::CompileKind::Target(target) => target.rustc_target(),
137+
};
138+
Ok(Sbom {
139+
version: SbomFormatVersion(1),
140+
packages,
141+
root: indicies[root],
142+
rustc,
143+
target,
144+
})
145+
}
146+
147+
/// List all dependencies, including transitive ones. A dependency can also appear multiple times
148+
/// if it's using different settings, e.g. profile, features or crate versions.
149+
///
150+
/// Returns a graph of dependencies.
151+
fn build_sbom_graph<'a>(
152+
build_runner: &'a BuildRunner<'_, '_>,
153+
root: &'a Unit,
154+
) -> BTreeMap<&'a Unit, BTreeSet<(&'a Unit, SbomDependencyType)>> {
155+
tracing::trace!("building sbom graph for {}", root.pkg.package_id());
156+
157+
let mut queue = Vec::new();
158+
let mut sbom_graph: BTreeMap<&Unit, BTreeSet<(&Unit, SbomDependencyType)>> = BTreeMap::new();
159+
let mut visited = HashSet::new();
160+
161+
// Search to collect all dependencies of the root unit.
162+
queue.push((root, root, false));
163+
while let Some((node, parent, is_build_dep)) = queue.pop() {
164+
let dependencies = sbom_graph.entry(parent).or_default();
165+
for dep in build_runner.unit_deps(node) {
166+
let dep = &dep.unit;
167+
let (next_parent, next_is_build_dep) = if dep.mode == CompileMode::RunCustomBuild
168+
{
169+
// Nodes in the SBOM graph for building/running build scripts are moved on to their parent as build dependencies.
170+
(parent, true)
171+
} else {
172+
// Proc-macros and build scripts are marked as build dependencies.
173+
let dep_type = match is_build_dep || dep.target.proc_macro() {
174+
false => SbomDependencyType::Normal,
175+
true => SbomDependencyType::Build,
176+
};
177+
dependencies.insert((dep, dep_type));
178+
tracing::trace!(
179+
"adding sbom edge {} -> {} ({:?})",
180+
parent.pkg.package_id(),
181+
dep.pkg.package_id(),
182+
dep_type,
183+
);
184+
(dep, false)
185+
};
186+
if visited.insert(dep) {
187+
queue.push((dep, next_parent, next_is_build_dep));
188+
}
189+
}
190+
}
191+
sbom_graph
192+
}

0 commit comments

Comments
 (0)