Skip to content

commands/sbom: compose per-runtime and per-extension SBOMs - #238

Open
hiagofranco wants to merge 1 commit into
avocado-linux:mainfrom
hiagofranco:hfranco-eng-2219
Open

commands/sbom: compose per-runtime and per-extension SBOMs#238
hiagofranco wants to merge 1 commit into
avocado-linux:mainfrom
hiagofranco:hfranco-eng-2219

Conversation

@hiagofranco

Copy link
Copy Markdown
Collaborator

Summary

ENG-2405 emitted one software_Sbom covering every sysroot at once, so no
runtime or extension had an artifact of its own. This adds one per runtime,
per extension, and — with --include-sdk — one for the build host, and
attaches the runtime's own document to connect upload.

  • More software_Sbom elements in the one graph, not one file per extension.
    A package shared between rootfs and a runtime stays one element with two
    contains edges; split across files it becomes two, and a scanner counting
    packages or matching CVEs counts the same exposure twice. A standalone
    extension file also could not name its parent runtime without SPDX imports
    machinery nothing here emits.
  • build_document was already a pure function of the scope slice handed to
    it, so connect upload builds a runtime-scoped document by filtering
    rather than through any new CLI surface.
  • The attach never fails an upload: a build error warns and sends nothing,
    the sbom field is skipped when absent (so a server that doesn't read it
    yet, ENG-2284, sees today's request unchanged), and a 4xx retries once
    without it — likeliest cause is 413, since ~400 packages of SPDX is
    megabytes into a body that's otherwise kilobytes. AVOCADO_UPLOAD_NO_SBOM=1
    skips the build entirely.
  • An includes:<n> scope is a remote extension, not a project include, so it
    gets its own document too.
  • The device-level SBOM stays first in the graph (existing tests take the
    first software_Sbom match) and now states in prose, not only via
    software_sbomType, what it describes.

Closes ENG-2219.

Test plan

  • cargo test green (1517+9+2+6+8+135 passed; the one clean test
    failure locally is an unrelated podman volume-cleanup race, not
    touched by this change)
  • Verified push order: device SBOM remains the first software_Sbom
    element in the graph

ENG-2405 emitted one `software_Sbom` covering every sysroot at once, so
no runtime or extension had an artifact of its own. Add one per runtime,
per extension, and — with --include-sdk — one for the build host, and
attach the runtime's own document to `connect upload`.

More elements in the one graph rather than one file each. A package in
both rootfs and a runtime is one element with two `contains` edges;
across files it becomes two, and a scanner counting packages or matching
CVEs counts the same exposure twice. A standalone extension file also
could not name its parent runtime without an SPDX `imports` map nothing
here emits.

`build_document` was already a pure function of the scope slice handed
to it, so upload builds a runtime-scoped document by filtering rather
than by any new CLI surface. `in_runtime` is the one predicate both that
filter and the grouping use.

A group is skipped when its own defining scope is empty, not when every
member is: an empty scope gets no element, so such a group would be
named for something the graph never names.

The attach never fails an upload. A build error warns and sends nothing,
the field is skipped when absent, and any 4xx retries once without it —
gated on the status via a new `HttpStatus` error rather than on message
text. Any 4xx and not an enumerated list, because the likeliest refusal
is 413: ~400 packages of SPDX is megabytes into a body that is otherwise
kilobytes. A 4xx also means the first attempt created nothing, so the
retry leaves no duplicate runtime. Both notices use `print_warning_above`,
since `print_warning` is suppressed under --output json — the one path
where a silently SBOM-less upload would go unnoticed.
`AVOCADO_UPLOAD_NO_SBOM=1` skips the build.

An `includes:<n>` scope is a remote extension, not a project include, so
it gets a document too. It and a legacy `ext:<n>` name no runtime, so no
runtime group claims them.

The device SBOM stays first in the graph — existing tests take the first
`software_Sbom` they find — and now states in prose, not only in
`software_sbomType`, what it describes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@hiagofranco
hiagofranco requested a review from jetm September 4, 2026 15:37
@hiagofranco hiagofranco self-assigned this Sep 4, 2026

@jetm jetm left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Read this with the artifact's consumer in mind rather than the code alone: an SBOM that is quietly incomplete is worse than one that fails to build, because nothing downstream can tell the difference. Four inline comments, and three of them are that shape - a document that looks whole and is not.

The composition mechanics themselves hold up. A package in two extensions collapses to one entry, conflicting versions stay two distinct elements, the slug-collision path is covered by the SHA-256 suffix, and is_client_error survives two .context() layers - I compiled a probe for that last one rather than reading it off the types.

cargo test --all-targets passes, 3277 tests across 15 suites, and cargo fmt --check is clean. cargo clippy -D warnings exits 101 on result_large_err at src/utils/container_dev/registry.rs:424, a file this PR does not touch and which last changed in #184 - pre-existing, not yours.

One thing I did not put inline because it is a judgement rather than a defect: CHANGELOG.md:96-99, already shipped, says of this same document that "the command never transmits it... the operator's call, on a document they can read first." This PR transmits it by default. That may well be the intended change, but the shipped promise now contradicts the behaviour and one of them should move.

Ten smaller notes held back - a doubled HTTP status in one error render, retry firing on any 4xx, and some test gaps around the new grouping. Say the word and I'll add them.

.into_iter()
.filter(|s| in_runtime(&s.name, &self.runtime))
.collect();
Ok(cmd.build_document(&kept, &target, snapshot.as_ref()))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

uploaded runtime SBOM is named "device SBOM"

scan_runtime_sbom filters scopes with in_runtime and then calls the same build_document, which unconditionally emits "name": "avocado {target} device SBOM" with the comment "Describes what this project installs, and so what runs on the device" (generate.rs:1081-1090), and makes it the sole SpdxDocument.rootElement (generate.rs:1235).

Failure: a project with runtimes dev and prod. avocado connect upload dev attaches a document whose SPDX entry point asserts device-wide coverage while holding only rootfs, initramfs, runtime:dev and ext:dev/*. A consumer traversing rootElement - or taking the first software_Sbom, which is the pattern this PR's own the_device_sbom_is_the_first_one_in_the_graph test pins - CVE-matches a one-runtime inventory as the device inventory, under-reporting every package unique to prod.

The artifact is what a customer consumes, so a name that overstates its scope is worse than a smaller one that is honest about it.


for scope in scopes {
// `includes:<n>` is a remote extension, not a project include.
if (scope.name.starts_with("ext:") || scope.name.starts_with("includes:"))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nested-layout remote extensions are dropped from every group

sysroot_scan.rs:85 emits a scope named plainly includes - the shared installroot an avocado-ext-layout(nested) extension installs into (ext_fetch.rs:190,207). But groups() keys on includes: and ext:, and in_runtime matches rootfs, initramfs, runtime: and ext:<rt>/. A bare includes matches neither.

Failure: a project whose remote extensions are nested-layout. Their rpmdb lives only in the shared root, so query_root "includes:<n>" finds no database and drops out (sysroot_scan.rs:50,98-101). Those packages then get no per-extension SBOM, appear in no runtime SBOM, and are filtered out of the uploaded runtime.sbom entirely - a shipped extension missing from the artifact, with nothing on the console to say so.

let (scopes, target, snapshot) = cmd.scan().await?;
let kept: Vec<_> = scopes
.into_iter()
.filter(|s| in_runtime(&s.name, &self.runtime))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nothing checks the runtime's own scope survived the filter

in_runtime returns true for rootfs and initramfs unconditionally, so kept is non-empty even when no runtime:<name> scope was scanned, or its packages were fully subtracted. build_document then succeeds and build_sbom returns Some with no warning.

Failure: the runtime installroot holds no rpmdb, or its transaction was pure seed - exactly the case a_group_whose_own_scope_is_empty_gets_no_document already pins. groups() emits no Group::Runtime, so Connect stores a document containing not one package that runtime installs, and the operator sees a green upload.

}

Ok(())
Ok((scopes, target, snapshot))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

incomplete-scan warnings are invisible on the JSON path this diff adds

(Anchored at scan()'s return; the two sites are :676 and :684 above.)

scan()'s unreadable-row warning at :676 and seeding_warnings at :684 go out as bare eprintln!, while build_sbom (upload.rs:462) deliberately uses print_warning_above because it also emits {"event":"warning"} on the NDJSON stream.

Failure: avocado connect upload --output json, driven by Avocado Desktop, on a project where three rpm rows carry an embedded tab. An SBOM three packages short is uploaded while the NDJSON stream shows create-runtime: running -> success with no warning event at all.

Worth noting the constraint rather than a straight swap: print_warning_above uses println!, so moving these onto it would corrupt avocado sbom > sbom.json.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants