Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **CycloneDX SBOM emission** — `synth compile --sbom` writes a CycloneDX 1.5
JSON SBOM (`<output>.cdx.json`, or an explicit path) documenting the synth
compiler, the input WASM module (SHA-256 + size), the output ELF (SHA-256,
size, target triple, backend), and the WASM module's imports as a
dependency graph. This is the artifact consumed by `rivet import --format
cyclonedx` for rivet #107's `sbom-record`. See `docs/sbom.md`.

## [0.3.1] - 2026-05-21

First release built and published by the automated release pipeline:
Expand Down
12 changes: 12 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
toml = "0.8"

# Hashing — SHA-256 for CycloneDX SBOM component digests
sha2 = "0.10"

# Error handling
anyhow = "1.0"
thiserror = "1.0"
Expand Down
3 changes: 3 additions & 0 deletions MODULE.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ crate.spec(package = "serde", version = "1.0", features = ["derive"])
crate.spec(package = "serde_json", version = "1.0")
crate.spec(package = "toml", version = "0.8")

# Hashing (CycloneDX SBOM component digests)
crate.spec(package = "sha2", version = "0.10")

# Error handling
crate.spec(package = "thiserror", version = "1.0")

Expand Down
2 changes: 2 additions & 0 deletions crates/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ rust_library(
deps = [
"@crates//:anyhow",
"@crates//:serde",
"@crates//:serde_json",
"@crates//:sha2",
"@crates//:thiserror",
"@crates//:wasmparser",
"@crates//:wasm-encoder",
Expand Down
152 changes: 152 additions & 0 deletions crates/synth-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ use tracing::{Level, info};
use wast::parser::{self, ParseBuffer};
use wast::{Wast, WastDirective};

/// Sentinel value clap substitutes when `--sbom` is given without a path.
/// Resolved to `<output>.cdx.json` by [`resolve_sbom_path`]. Unlikely to
/// collide with a real path the user would pass.
const SBOM_DEFAULT_SENTINEL: &str = "\u{0}sbom-default\u{0}";

#[derive(Parser)]
#[command(name = "synth")]
#[command(about = "WebAssembly-to-ARM Cortex-M AOT compiler")]
Expand Down Expand Up @@ -186,6 +191,19 @@ enum Commands {
/// — for linking into a host build system.
#[arg(long)]
relocatable: bool,

/// Emit a CycloneDX 1.5 SBOM for the compiled ELF. With a path, writes
/// there; as a bare flag (`--sbom`) writes `<output>.cdx.json` next to
/// the ELF. The SBOM documents the synth compiler, the input WASM, the
/// output ELF (hashes + sizes), and the WASM module's imports. It is
/// the artifact consumed by `rivet import --format cyclonedx`.
#[arg(
long,
value_name = "PATH",
num_args = 0..=1,
default_missing_value = SBOM_DEFAULT_SENTINEL
)]
sbom: Option<PathBuf>,
},

/// Disassemble an ARM ELF file (e.g., synth disasm output.elf)
Expand Down Expand Up @@ -299,6 +317,7 @@ fn main() -> Result<()> {
link,
builtins,
relocatable,
sbom,
} => {
// Resolve target spec: --target overrides, --cortex-m is backwards compat
let target_spec = resolve_target_spec(target.as_deref(), cortex_m)?;
Expand All @@ -314,6 +333,11 @@ fn main() -> Result<()> {
let resolved_safety_bounds =
resolve_safety_bounds(safety_bounds.as_deref(), bounds_check)?;

// Resolve the CycloneDX SBOM destination. `--sbom` with no value
// means "next to the ELF" (`<output>.cdx.json`); `--sbom PATH`
// writes there; absent means no SBOM.
let sbom_path = resolve_sbom_path(sbom, &output);

compile_command(
input,
output.clone(),
Expand All @@ -330,6 +354,7 @@ fn main() -> Result<()> {
verify,
&target_spec,
relocatable,
sbom_path,
)?;

// If --link requested, invoke the cross-linker
Expand Down Expand Up @@ -798,6 +823,63 @@ fn maybe_emit_safety_manifest(
Ok(())
}

/// Resolve the `--sbom` flag into a concrete destination path (or `None`).
///
/// clap substitutes [`SBOM_DEFAULT_SENTINEL`] when the flag is given with no
/// value, so:
/// - flag absent -> `None` (no SBOM)
/// - bare `--sbom` -> `Some(<sentinel>)` -> `<output>.cdx.json`
/// - `--sbom path.cdx.json` -> `Some("path...")` -> that path verbatim
fn resolve_sbom_path(sbom: Option<PathBuf>, output: &std::path::Path) -> Option<PathBuf> {
match sbom {
None => None,
Some(p) if p.as_os_str() == SBOM_DEFAULT_SENTINEL => {
Some(synth_core::CycloneDxSbom::sidecar_path(output))
}
Some(p) => Some(p),
}
}

/// Emit a CycloneDX 1.5 SBOM next to the compiled ELF when `--sbom` was
/// requested. The SBOM documents the synth compiler, the input WASM module
/// (hash + size), the output ELF (hash + size + target + backend), and the
/// WASM module's imports as a CycloneDX dependency graph. It is the artifact
/// consumed by rivet #107's `sbom-record` (`rivet import --format cyclonedx`).
///
/// `input_wasm_bytes` is the post-WAT-decode WASM (the bytes synth actually
/// compiled). When the input was a built-in demo (no file), `input_path` is a
/// synthetic name.
fn emit_sbom(
sbom_path: &std::path::Path,
input_path: &std::path::Path,
input_wasm_bytes: &[u8],
output_path: &std::path::Path,
output_elf_bytes: &[u8],
target_spec: &TargetSpec,
backend_name: &str,
imports: &[ImportEntry],
) -> Result<()> {
let inputs = synth_core::SbomInputs {
synth_version: env!("CARGO_PKG_VERSION"),
input_path,
input_bytes: input_wasm_bytes,
output_path,
output_bytes: output_elf_bytes,
target_triple: &target_spec.triple,
backend: backend_name,
imports,
};
let sbom = synth_core::CycloneDxSbom::new(&inputs, synth_core::sbom::now_rfc3339());
std::fs::write(sbom_path, sbom.to_json())
.with_context(|| format!("Failed to write SBOM: {}", sbom_path.display()))?;
info!(
"Wrote CycloneDX SBOM ({} components): {}",
sbom.components.len(),
sbom_path.display()
);
Ok(())
}

#[allow(clippy::too_many_arguments)]
fn compile_command(
input: Option<PathBuf>,
Expand All @@ -815,6 +897,7 @@ fn compile_command(
verify: bool,
target_spec: &TargetSpec,
relocatable: bool,
sbom_path: Option<PathBuf>,
) -> Result<()> {
// Validate backend exists
let registry = build_backend_registry();
Expand Down Expand Up @@ -858,11 +941,16 @@ fn compile_command(
verify,
target_spec,
relocatable,
sbom_path,
);
}

// Single function compilation (when --func-index or --func-name is specified)
let func_index = func_index.unwrap_or(0);
// Captured for SBOM emission: the WASM bytes synth actually compiled and
// the module's imports. `None` for the demo path (no input module).
let mut sbom_wasm_bytes: Option<Vec<u8>> = None;
let mut sbom_imports: Vec<ImportEntry> = Vec::new();
let (wasm_ops, func_name): (Vec<WasmOp>, String) = match (&input, &demo) {
(Some(path), _) => {
info!("Compiling WASM file: {}", path.display());
Expand All @@ -887,6 +975,15 @@ fn compile_command(
// Run Loom WASM optimizer if --loom is enabled
let wasm_bytes = maybe_run_loom(loom, wasm_bytes)?;

// Capture the WASM bytes + imports for the SBOM (the bytes synth
// actually compiles, after WAT decode and any Loom pass).
if sbom_path.is_some() {
if let Ok(module) = decode_wasm_module(&wasm_bytes) {
sbom_imports = module.imports;
}
sbom_wasm_bytes = Some(wasm_bytes.clone());
}

let functions =
decode_wasm_functions(&wasm_bytes).context("Failed to decode WASM functions")?;

Expand Down Expand Up @@ -994,6 +1091,31 @@ fn compile_command(
// has the module context and threads through the real value.
maybe_emit_safety_manifest(&output, target_spec, safety_bounds, 0)?;

// Emit a CycloneDX SBOM when requested. Only possible when synth compiled
// an actual WASM module (not a built-in demo, which has no input file).
if let Some(ref sbom_dest) = sbom_path {
match (sbom_wasm_bytes.as_deref(), input.as_deref()) {
(Some(wasm), Some(in_path)) => {
emit_sbom(
sbom_dest,
in_path,
wasm,
&output,
&elf_data,
target_spec,
backend_name,
&sbom_imports,
)?;
}
_ => {
eprintln!(
"warning: --sbom requires a WASM/WAT input file; \
skipping SBOM for demo compilation"
);
}
}
}

println!("Compiled {} to {}", func_name, output.display());
println!(" Code size: {} bytes", code.len());
println!(" ELF size: {} bytes", elf_data.len());
Expand Down Expand Up @@ -1494,6 +1616,7 @@ fn compile_all_exports(
verify: bool,
target_spec: &TargetSpec,
relocatable: bool,
sbom_path: Option<PathBuf>,
) -> Result<()> {
let path = input.context("--all-exports requires an input file")?;

Expand All @@ -1502,6 +1625,11 @@ fn compile_all_exports(
let file_bytes =
std::fs::read(&path).context(format!("Failed to read input file: {}", path.display()))?;

// WASM bytes captured for the SBOM (the post-decode bytes synth compiles).
// For a WAST file with multiple modules this holds the representative
// module (the one whose imports were merged), set inside the match below.
let mut sbom_wasm_bytes: Option<Vec<u8>> = None;

// Decode module(s) — for WAST files we merge exports across all modules
let (all_exports, all_memories, all_imports, max_num_imported_funcs) =
if path.extension().is_some_and(|ext| ext == "wast") {
Expand All @@ -1522,6 +1650,11 @@ fn compile_all_exports(
for (idx, wasm_bytes) in module_binaries.iter().enumerate() {
// Run Loom optimizer on each module if --loom is enabled
let wasm_bytes = maybe_run_loom(loom, wasm_bytes.clone())?;
// First decoded module is the SBOM default; refined below to
// the module whose imports get merged.
if sbom_wasm_bytes.is_none() {
sbom_wasm_bytes = Some(wasm_bytes.clone());
}
match decode_wasm_module(&wasm_bytes) {
Ok(module) => {
let export_count = module
Expand Down Expand Up @@ -1559,6 +1692,9 @@ fn compile_all_exports(
if module.num_imported_funcs > max_imports {
max_imports = module.num_imported_funcs;
merged_imports = module.imports.clone();
// Keep the SBOM input aligned with the merged
// imports (the module the dependency graph reflects).
sbom_wasm_bytes = Some(wasm_bytes.clone());
}
}
Err(e) => {
Expand All @@ -1583,6 +1719,7 @@ fn compile_all_exports(
let wasm_bytes = maybe_run_loom(loom, wasm_bytes)?;

let module = decode_wasm_module(&wasm_bytes).context("Failed to decode WASM module")?;
sbom_wasm_bytes = Some(wasm_bytes);

let exports: Vec<_> = module
.functions
Expand Down Expand Up @@ -1738,6 +1875,21 @@ fn compile_all_exports(
let linear_mem_bytes = all_memories.first().map(|m| m.initial_bytes()).unwrap_or(0);
maybe_emit_safety_manifest(&output, target_spec, safety_bounds, linear_mem_bytes)?;

// Emit a CycloneDX SBOM when requested.
if let Some(ref sbom_dest) = sbom_path {
let wasm = sbom_wasm_bytes.as_deref().unwrap_or(&[]);
emit_sbom(
sbom_dest,
&path,
wasm,
&output,
&elf_data,
target_spec,
backend.name(),
&all_imports,
)?;
}

let total_code: usize = compiled_funcs.iter().map(|f| f.code.len()).sum();
let total_relocs: usize = compiled_funcs.iter().map(|f| f.relocations.len()).sum();
println!(
Expand Down
1 change: 1 addition & 0 deletions crates/synth-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ description = "Core types, error handling, and backend trait for the Synth compi
[dependencies]
serde.workspace = true
serde_json.workspace = true
sha2.workspace = true
thiserror.workspace = true
anyhow.workspace = true
wasmparser.workspace = true
Expand Down
2 changes: 2 additions & 0 deletions crates/synth-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ pub mod component;
pub mod error;
pub mod ir;
pub mod safety_manifest;
pub mod sbom;
pub mod target;
pub mod wasm_decoder;
pub mod wasm_op;
Expand All @@ -19,6 +20,7 @@ pub use component::*;
pub use error::{Error, Result};
pub use ir::*;
pub use safety_manifest::SafetyManifest;
pub use sbom::{CycloneDxSbom, SbomInputs};
pub use target::*;
pub use wasm_decoder::{
DecodedModule, FunctionOps, ImportEntry, ImportKind, WasmMemory, decode_wasm_functions,
Expand Down
Loading
Loading