Gunnar

mikronova/modgunn

.nornir/manifests-design.md

# manifests — design

> **Class:** `design` · **Function axis:** `f0` the seam · `f1` the parsers · `f2` the
> dispatch. Strict: only the shape we run. The system view is in [`design.md`](design.md);
> the emit counterpart is [`sbom-design.md`](sbom-design.md).

![manifest ingest flow](assets/manifests-ingest.svg)

## f0 — the seam

`manifests` (module `manifests.rs`, feature `manifests`) is the **ingest** direction: it reads a
project's dependency manifest / lockfile and turns it into the SAME shared component model the
crate emits — [`sbom::SbomComponent`](../src/sbom.rs) (`{ ecosystem, name, version, license }`).
`sbom` *emits* that model as CycloneDX; `manifests` *produces* it from source. The model is never
forked: **cargo (`Cargo.lock`) is the template and every other ecosystem lands its components in
that exact struct**, so a downstream verdict / SBOM emit never learns which ecosystem it came from.

The single seam is [`EcosystemParser`](../src/manifests.rs): `ecosystem() -> &'static str`,
`manifests() -> &'static [&'static str]` (the basenames it owns), `handles(filename) -> bool`
(default: exact-match the basenames), and `parse(filename, content) -> Result<Vec<SbomComponent>,
ParseError>`. It is object-safe, so [`all()`](../src/manifests.rs) holds `Box<dyn
EcosystemParser>` and [`parse_manifest`](../src/manifests.rs) dispatches dynamically. Malformed
input never panics — it returns [`ParseError`](../src/manifests.rs).

**Reuse:** `manifests.uses = [sbom]`. Pure parsing — no warehouse, no network, **zero shell**: no
parser ever runs the target project's `pip` / `npm` / `go` / `mvn` tooling.

## f1 — one parser per ecosystem

Seven ecosystems, one impl each; several own more than one format and disambiguate inside `parse`
by filename:

| ecosystem | struct | manifests | parse route |
|-----------|--------|-----------|-------------|
| cargo | `CargoParser` | `Cargo.lock` | TOML `[[package]]` |
| pip | `PipParser` | `requirements.txt`, `poetry.lock`, `Pipfile.lock` | `==` pins · TOML · JSON `default`/`develop` |
| npm | `NpmParser` | `package-lock.json`, `pnpm-lock.yaml` | JSON `packages`/v1 `dependencies` · hand-parsed `packages:` keys |
| maven | `MavenParser` | `pom.xml` | roxmltree `<dependency>` → `groupId:artifactId` |
| go | `GoParser` | `go.mod`, `go.sum` | `require` directives · checksum lines (deduped) |
| nuget | `NugetParser` | `packages.lock.json`, `*.csproj` | JSON `resolved` · roxmltree `<PackageReference>` |
| gem | `GemParser` | `Gemfile.lock` | `GEM … specs:` 4-indent `name (version)` |

`Cargo.lock` and `poetry.lock` share the `[[package]] name/version` shape, so both fold through one
`toml_packages` helper — the parse, like the model, stays single-sourced. Only **resolved** versions
are emitted: a `requirements.txt` range, a Maven `${property}`, a NuGet central-management reference,
or a Gemfile constraint line is skipped (not a pinned version). JSON reuses `serde_json`, TOML the
`toml` dep, XML `roxmltree`; text formats (requirements, go, gem, pnpm) are hand-parsed with no dep.

## f2 — dispatch + introspection

[`parse_manifest(filename, content)`](../src/manifests.rs) matches the path's basename against each
parser's `handles`, returning `None` for an unknown filename and `Some(Result<…>)` otherwise. Under
feature `testmatrix` it lights one `modgunn/manifest-ingest-{ecosystem}` functional-status row per
parse (GREEN = N components, RED = malformed), so `nornir test` SEES the seam per ecosystem.

## f3 — the driveable surface (`modgunn ingest`)

The seam is also a CLI verb — [`cmd_ingest`](../src/main.rs), `modgunn ingest --path
<manifest-file|dir> [--sbom] [--subject <name>] [--ecosystem <eco>]` — so a consumer can drive the
ingest without writing Rust. It reads ONE explicit manifest, or walks a **directory** for every
recognised manifest (VCS/build/vendored trees — `.git`/`target`/`node_modules`/`vendor`/… — skipped,
via [`collect_manifests`](../src/main.rs)), folds all components through `parse_manifest`, then
**dedups + sorts** by `(ecosystem, name, version)` and prints either the component rows with a
per-ecosystem tally (default) or a CycloneDX 1.5 SBOM (`--sbom`, straight into
[`sbom::to_cyclonedx`](sbom-design.md)). `--ecosystem` narrows to one ecosystem. An explicit file no
parser owns is a hard usage error (lists the supported manifests); a malformed manifest is named on
stderr and counted, never a panic. Offline, zero-shell, no warehouse — the read-side counterpart to
`sbom` emit. Emits a `modgunn/cli-ingest` functional-status row under `testmatrix`; e2e-proven by
[`tests/cli_ingest.rs`](../tests/cli_ingest.rs) driving the compiled binary.

**Feature gating:** `manifests = ["scan", "dep:toml", "dep:roxmltree"]`, OFF by default — the lean
`scan` gate (holger / static-holger) never pulls a TOML/XML parser; nornir's SBOM capture opts in.
The `ingest` verb is gated on `manifests` with a rebuild-hint stub when absent (mirroring the
`verify-provenance` gate); the binary itself still requires `warehouse` for its other verbs, so the
consumer build is `cargo run --features warehouse,manifests -- ingest …`. The existing cargo
warehouse-read + `sbom` emit path is untouched — this is purely additive.