Repository navigation
feat(sbom): record which files each component of an image consists of - #453
Conversation
An image's SBOM says a component is there; it does not say which files are that component's. A child image built FROM this one, or importing files out of it, inherits the component list wholesale and has nothing to check it against, so a component whose files never arrived stays in the child's SBOM and is reported as its attack surface. Every image whose SBOM werf generates now carries, for each component its ecosystems installed, the files that component put into it: evidence.occurrences with the path as location and what is known of the content as additionalContext — the digest of a regular file, "symlink" for a symlink. Four ecosystems record file lists: os-pm, python, javascript, lua. A component none of them names keeps no occurrences at all, which is what a reader has to read as "nothing known", and the same holds for a component that names no file of its own — a metapackage, a distribution whose record is empty. An empty occurrence list means the opposite: the files were looked for and none were found. The writing happens after the BOM is canonicalized, because canonicalization folds duplicate components together and drops an evidence list that comes out empty, which is exactly the "none were found" this records. The SBOM artifact format version goes to 11, without which every image with an SBOM already attached would keep serving an occurrence-free one from the cache. Signed-off-by: Radmir Khurum <radmir.khurum@flant.com>
Verification
Review focus
Follow-up
|
reyreavman
left a comment
There was a problem hiding this comment.
Code Review Report
Base: main (69de5842, #452 merged), head fac588fe
Diff: 13 files, +723/-22 — RecordOccurrences, recordShippedFiles wired into ConvergeWithMerge, OCIStore.ParentImage, Fileset.DeclaresFiles, format version 10 → 11.
Context: concept §3.1 (donor side), card F prerequisite. First PR of the series that touches the build pipeline and every user's SBOM.
Verdict
- Technical:
RecordOccurrencesis sound: keying by purl → ecosystem with canonical names,DeclaresFileskeeping "unknown" apart from "looked for, none found", deterministic output, nested components walked. Three mutations: two fail their spec; the third (symlink overriding a file at the same path) survives because theFileIndexmakes the case impossible — dead guard, not a defect. The one real problem is in the wiring:recordShippedFilesturns every failure of a new read of the built image into a failed build, which the concept and card F explicitly say must be fail-open. - Product: This is the first user-visible change of the series: every SBOM werf writes grows, every cached SBOM regenerates on upgrade (format 11), and SBOM generation now downloads the image a second time. All three are stated in the body as UNVERIFIED in cost. That honesty is good; shipping unmeasured is not — one number on a
node_modules-heavy image is cheap to get and belongs in the body before this leaves draft. - Risk: Medium. A
files.jsonthat does not parse, a layer whose digest does not verify, an unsafe tar path — each now fails a build that succeeded yesterday, on an image whose content did not change.
DoD Criteria
Inferred from the body and the concept (card F "Нет данных … fail-open с warning, сборка не падает").
| Criteria | Inferred? | Met? | Evidence |
|---|---|---|---|
occurrences per component for os-pm / pip / npm / luarocks, location + sha256:/symlink |
yes | ✅ | occurrences.go:136-161; spec records the files of a component of each ecosystem |
| digest from image, missing path omitted | yes | ✅ | via CollectFilesets (#435) |
| component no fileset names → untouched; fileset with no declared files → untouched | yes | ✅ | occurrences.go:55; M1 fails spec :95 |
| present-but-empty list = "looked for, none found" | yes | ✅ | spec :187 |
old pm (no files.json) → warning, components unmarked |
yes | ✅ | spec sbom_step_shipped_files_test.go:125 |
| multiple copies → union of files | yes | ✅ | spec :173 |
written after Canonicalize |
yes | ✅ | sbom_step.go:204-206 |
| format version bumped | yes | ✅ | sbom_step.go:493 |
| errors in the new read are fail-open | yes | ❌ | sbom_step.go:206-208, 237-245 — Major #1 |
| cost on a large image measured | yes | ❌ | body says UNVERIFIED twice — Major #2 |
task format/build/lint/test:unit green |
no | ✅ | build OK, lint 0, 135/135 shippedscope; pkg/build red only on the pre-existing LFS specs (same on main) |
Issues
Major
recordShippedFilesfails the build on any error —sbom_step.go:206-208,:237-245. Inline.- Size and traffic cost shipped unmeasured — PR body, two UNVERIFIED paragraphs. Inline on
recordShippedFiles.
Minor
- Whole-image read even when no component can receive occurrences —
sbom_step.go:238. Inline. - Symlink-vs-file guard in
setOccurrencesis unreachable —occurrences.go:143-145. Inline. pkg:genericis not only os-pm —occurrences.go:107. syft's binary cataloger emitspkg:generic/python@3.12.3,pkg:generic/node@20.xfor Dockerfile images. Collision needs the same(name, version)as afiles.jsonentry, in which case it is the same package and the occurrences are right — so this is a note for the godoc, not a bug.
Mutation log
| Mutation | Result |
|---|---|
M1: DeclaresFiles ignored |
✅ fails leaves a component that declares no files alone… |
M2: canonicalName identity |
✅ 2 fail — PyPI separator, npm case |
| M3: symlink overrides file context | green 135/135 — unreachable, Minor #4 |
Tree restored after each.
Risks
| № | Risk | Type | Likelihood | Severity | Location | Circumstances | Consequences | Recommendation |
|---|---|---|---|---|---|---|---|---|
| 1 | Build that succeeded yesterday fails after upgrade | Operational | Possible | High | sbom_step.go:206 |
Malformed files.json, unsafe tar path, registry blip during the second pull |
SBOM step aborts the build; user sees collect component filesets: … with no opt-out |
Major #1 |
| 2 | SBOM artifact size × N on JS/Python images | UX/Product | Likely | Medium | occurrences.go:149-155 |
node_modules with 100k files → 100k × ~120 B ≈ 12 MB per SBOM |
Registry storage, attestation signing time, scanner ingest | Major #2: measure; consider a cap or symlink-only for non-content paths |
| 3 | Image downloaded twice per SBOM | Operational | Likely | Low | store.go:52, sbom_step.go:106 |
Every build with SBOM enabled | Wall-clock + egress; multi-hundred-MB images | Minor #3 + measure |
| 4 | All cached SBOMs regenerate on upgrade | Operational | Likely | Low | sbom_step.go:493 |
Expected; card G | One slow build per image | State in the release notes |
Not verified
- e2e sbom suites — not run (Linux + kind). No e2e asserts on
evidence.occurrences, so none should break on content; the format bump invalidates their caches, not their assertions. - Actual size/traffic on a real image — the body says it is unmeasured and I did not measure it either.
- Unscoped
task test:unit—pkg/build/imageLFS specs are red here as onmain.
| if err := recordShippedFiles(ctx, resultBOM, store, parentDigest); err != nil { | ||
| return err | ||
| } |
There was a problem hiding this comment.
Major. Every error out of recordShippedFiles aborts the SBOM step and with it the build. The new read can fail for reasons that have nothing to do with the user's change:
CollectFilesets→osPMFilesetsreturns an error on afiles.jsonthat does not parse (os_pm_fileset.go:48);IndexImageFilesreturns an error on an unsafe tar path or a layer whose digest does not verify (feat(sbom): index the files an image actually ships #433, by design);ParentImageis a second registry round-trip that can time out independently of the pull at:106.
Yesterday those images built; after this PR they fail with collect component filesets: … and no flag to turn it off. Card F is explicit — «Нет данных … → fail-open с warning, сборка не падает» and «Fail-open нужен и при Warn индекса, и при его ошибке … причины в логе различать». The same rule applies on the donor side: an SBOM without occurrences is the pre-#453 SBOM, i.e. the consumer keeps everything — the safe direction. Failing the build buys nothing.
Suggest: Warn with the cause and return nil; keep the hard error only for programmer errors (nil BOM). The spec fails when the built image cannot be read then becomes warns and leaves the BOM unmarked when….
There was a problem hiding this comment.
Fixed in e8bb5cf — you are right, and the argument that convinced me is the one you made: an SBOM without these records is the pre-#453 SBOM, so the consumer keeps everything. Failing bought nothing. Both reads now warn and return: unable to read the built image; the SBOM records none of the files its components consist of and unable to read which files the components of the image consist of; the SBOM records none of them. recordShippedFiles no longer returns an error at all, so the call site cannot reintroduce the abort. The spec became warns and leaves the BOM as it was when the built image cannot be read, and a second one covers the unparseable files.json you named: it pushes an image whose /var/lib/pm/files.json is not json at all and asserts both the warning and that the component stays unmarked.
| func recordShippedFiles(ctx context.Context, bom *cdx.BOM, store *artifact.OCIStore, parentDigest string) error { | ||
| img, err := store.ParentImage(ctx, parentDigest) | ||
| if err != nil { | ||
| return fmt.Errorf("read built image: %w", err) | ||
| } | ||
|
|
||
| filesets, err := shippedscope.CollectFilesets(ctx, img) | ||
| if err != nil { | ||
| return fmt.Errorf("collect component filesets: %w", err) |
There was a problem hiding this comment.
Major (process). The body marks two things UNVERIFIED: artifact size on a node_modules-heavy image, and wall-clock/traffic of the second pull. Both are one werf build away on any image already in the e2e fixtures (test/e2e/sbom/_fixtures/inject/* has npm and pip projects). This is the first PR of the series that every user pays for on every build; the numbers should be in the body before it leaves draft, even if they are fine. If they are not fine, the obvious levers are a cap on occurrences per component (concept §5 measured ncurses at 2917 files / 424 KB alone) and reading the image from the backend the step already pulled into (:106) instead of the registry — though the latter has no Buildah path.
Minor (perf), same place. CollectFilesets flattens and hashes the whole rootfs even when the BOM has no component of the four purl types — a FROM scratch Go image, a Dockerfile image with only deb packages. A pre-check (walkComponents + keyOfComponent, both already here) that skips the read when nothing can receive occurrences costs nothing and saves the full download in exactly the images where it is pure overhead.
There was a problem hiding this comment.
Measured, numbers are in the body now. Per recorded file it is ~190 B of JSON, ~45 B gzipped. node:20-slim: 203 components, 1949 files, component list 26 KB → 400 KB (4 KB → 85 KB gzipped). python:3.12-slim: one component, 442 files, +79 KB (+19 KB gzipped). Extrapolating, a 100k-file node_modules lands around +19 MB, +4 MB gzipped — close to your estimate. Second read: 65 s on node:20-slim, 9 m 48 s on the ~1 GB node:20, both from Docker Hub over a laptop link, so that figure is layer download and scales with image size, not component count. Measured by driving CollectFilesets + RecordOccurrences over real images rather than a werf build, which isolates the artifact growth from everything else the step does. I have left the cap out of this PR and put it in the follow-up: the lever to pick depends on what the consumer in card F actually needs per component, and I would rather not guess that here.
Minor (perf) — fixed in the same commit. shippedscope.HasFileBackedComponents(bom) walks the BOM first and the image is read only if something could receive occurrences; spec does not read the image when no component could be given files asserts not even a warning is emitted for a BOM of Go modules and deb packages.
| for link := range fileset.Symlinks { | ||
| if _, isFile := contexts[link]; !isFile { | ||
| contexts[link] = symlinkContext | ||
| } |
There was a problem hiding this comment.
Minor. Mutation M3 (drop the isFile guard, always write symlinkContext) stays 135/135 green. The guard protects a path that is a regular file in one fileset and a symlink in another fileset of the same component — but a FileIndex holds a path either in Files or in Symlinks, never both (indexed() in file_index.go), and the filesets of one component come from the same index. So the branch cannot fire. Either drop it, or if you want it as a belt-and-braces invariant, say so in a comment so the next reader does not look for the case.
There was a problem hiding this comment.
Dropped in e8bb5cf. You traced it correctly — a FileIndex holds a path in Files or in Symlinks, never both, and the filesets of one component come from one index, so the branch could not fire. It is a plain assignment now, which is also why your M3 has nothing left to mutate.
…read Recording which files a component consists of is an enrichment: an SBOM without those records is the SBOM werf wrote before it kept them, and a reader that finds no record of a component's files keeps the component. A files.json that does not parse, a layer whose digest does not verify, a registry that times out on the second read — each of those failed a build that succeeded the day before, and failing bought nothing. They now warn and leave the BOM as it was. The image is read only when the BOM holds a component some ecosystem could record files for. An image whose components are all Go modules or deb packages — a scratch Go image, a plain Dockerfile build — paid a full download of its layers for nothing. Also from review: the guard that let a regular file keep its digest against a symlink of the same path in another fileset of the same component is gone, since an index holds a path as one or the other and the filesets of one component come from one index; and the godoc says why taking pkg:generic for os-pm is safe when syft gives that type to runtimes too. Signed-off-by: Radmir Khurum <radmir.khurum@flant.com>
VerificationSince the first comment, in response to the review:
Review focusUnchanged from the first comment, minus the fail-open question, which the review settled. One addition:
Follow-up
|
reyreavman
left a comment
There was a problem hiding this comment.
Code Review — follow-up on e8bb5cfb
Delta since fac588fe: 4 files, +129/-17; PR body now carries measurements.
Previous findings
| # | Finding | Status |
|---|---|---|
| Major 1 | errors fail the build | ✅ recordShippedFiles warns and returns (sbom_step.go:236-260); two specs pin it |
| Major 2 | cost unmeasured | ✅ measured and in the body — see below |
| Minor 3 | read even with nothing to record | ✅ HasFileBackedComponents gate; mutation (gate removed) fails does not read the image when no component could be given files |
| Minor 4 | unreachable symlink guard | ✅ removed |
| Minor 5 | pkg:generic note |
✅ godoc on purlEcosystems |
139/139 shippedscope; pkg/build red only on the pre-existing LFS specs.
Verdict
Code-wise nothing outstanding. The measurements, however, turn Major #2 from "unknown" into a concrete product decision that I do not think should be made implicitly:
65 s on
node:20-slim, 9 m 48 s onnode:20(~1 GB) — a second full download of the image per build, for every image with a pip/npm/os-pm/luarocks component, with no opt-out.
That is build time every user pays on every build of a large image, to produce evidence only a child image will ever read. Two observations:
-
The image is already on the machine.
ConvergeWithMergepulls it into the backend atsbom_step.go:106before anything else. Both backends implementSaveImageToStream(container_backend/interface.go:176; Docker:474, Buildah:1436) which yields a docker-save tar, and gcr'starball.Image(opener, nil)reads that into av1.Image— the same typeCollectFilesetstakes. That replaces a 10-minute network round-trip with a localdocker save(disk + CPU), backend-agnostic, no new dependency.OCIStore.ParentImagewould then be unused. -
If (1) is deferred, the body's "no opt-out" should not stand. A
WERF_SBOM_*env / flag that disables the record, or at least a size/time threshold, is the minimum for a change that adds minutes to a build. Card F's fail-open principle argues the same way: the feature is optional enrichment, so it should be optional.
I would not approve with the 9m48s figure in the body and neither (1) nor (2) in the PR. Everything else is ready.
Not verified
- e2e sbom suites — not run here.
- The
SaveImageToStream→tarball.Imagepath — I checked the types line up, not that it runs; worth a spike before committing to it.
Found by the second independent review of the fail-open: the SBOM cache is keyed on the stable checksum, which does not encode whether the files of the components were recorded. A transient registry failure on the second read therefore warned once and cached an SBOM without these records under the same checksum, and no later build of that stage retried. The warning now names the image and its digest, so it can be found in a flat CI log, and says the SBOM is cached as such until the stage is rebuilt. Signed-off-by: Radmir Khurum <radmir.khurum@flant.com>
Verification, after
|
reyreavman
left a comment
There was a problem hiding this comment.
Code Review — follow-up on cec7a642
Delta since e8bb5cfb: 2 files, +20/-14 — warning text only.
Verdict
Not approving yet. The commit responds to a finding from another review (a transient failure on the second read caches a degraded SBOM under the same checksum, never retried) by describing the defect in the warning rather than fixing it. My blocking item from the previous round — the second full pull, 9 m 48 s on a 1 GB image, no opt-out — is unchanged, and the body still says "no opt-out flag".
The two are one problem. The degraded-cache hazard exists only because the second read goes to the registry and can fail transiently; a deterministic failure (files.json that does not parse) is correctly cached, since rebuilding will not change it.
What I would need to approve
Either read the image locally — it is already in the backend after :106; SaveImageToStream on both backends → tarball.Image gives the v1.Image CollectFilesets wants. That removes the 10-minute pull and the transient-failure class, so what is left to cache is deterministic and the new warning text becomes unnecessary.
Or, if the registry read stays: make a degraded result a cache miss. PushSBOM carries annotations (io.werf.checksum at sbom_step.go:101); add e.g. io.werf.sbom.files-recorded=false when recordShippedFiles bailed out, and extend the hit check at :101 with && desc.Annotations[...] != "false". Next build retries; a healthy registry heals the SBOM without anyone deleting artifacts by hand. Plus an opt-out flag, since 10 minutes per build is not something to impose without one.
Telling the user in a log line that the only remedy is to delete the artifact manually is the weakest of the three options, and the body now documents it as the behaviour.
Everything else in the PR is ready; 139/139 shippedscope, pkg/build red only on the pre-existing LFS specs.
…again The step pulls the image into the backend before it does anything else, so reading it from the registry a second time to learn which files its components consist of was a full download of every layer for nothing: 65 s on node:20-slim, close to ten minutes on the 1 GB node:20, on every build of every image with a pip, npm, os-pm or luarocks component. It was also the only reason a degraded SBOM could be cached by accident. A registry read fails transiently; the SBOM written without these records then sat under the same checksum and no later build of that stage retried. A save out of the backend fails only for reasons the next build hits again — an image that is not there, a files.json that does not parse — and those are right to cache. The warning that said the SBOM is cached as such goes with it. SaveImageToStream is on both backends; the tarball goes to a file under the werf tmp dir because tarball.Image reads it once for the manifest and once per layer, the same way MutateAndPushImage does. OCIStore.ParentImage loses its only caller and is gone. Signed-off-by: Radmir Khurum <radmir.khurum@flant.com>
|
reyreavman
left a comment
There was a problem hiding this comment.
Code Review — follow-up on 5aa1c59d
Delta since cec7a642: 3 files, +148/-65.
Verdict
This is the fix, and it closes both blocking items in one move. The image is now read out of the backend the step already pulled it into (SaveImageToStream → temp tar → tarball.ImageFromPath), so:
- the second registry pull is gone — the 9m48s on a 1 GB image becomes a local save;
- the degraded-cache hazard is gone with it, correctly reasoned in the commit body: a local save fails only for deterministic reasons (image absent,
files.jsonunparseable, disk full), which are right to cache since the next build hits them again. The "cached as such until rebuilt" warning text is dropped because it no longer describes a transient-failure trap.
OCIStore.ParentImage loses its only caller and is removed — lint clean, no orphaned imports.
Verified
stageDesc.Info.Nameis the right reference: it is what the whole step addresses the per-platform image by — pulled atsbom_step.go:110withTargetPlatform, scanned at:123, os-pm at:151.SaveImageToStreamreads that same local image, so multi-platform resolves to the converging platform, asParentImage's digest did.- The Buildah path is covered directly:
DescribeTableruns the happy path over both a gzipped layer andstatic.NewLayer(..., DockerUncompressedLayer)— Buildah saves uncompressed, andtarball.ImageFromPathreading either is now pinned, not assumed. - Temp file is removed on every path:
cleanupon each error return insavedImage,defer cleanup()after success; specs assertsavedTarballs()is empty. task buildOK,lint0 issues,recordShippedFilesspecs pass;pkg/buildred only on the pre-existing LFS specs (same onmain).
Note, non-blocking
A ~1 GB image is now written to a tarball under the werf tmp dir before it is read. That trades the network pull for local disk + CPU, which is the right trade, but on a disk-constrained runner it is a new failure surface — correctly fail-open (warns, leaves the SBOM unmarked), and the commit comment already names "a disk that is full". Worth a line in the body's cost section alongside the download figures it replaces.
Nothing outstanding on this PR from my side — I would approve.
sbom_step.go carries a mockgen -source directive, so every interface declared in it gets a generated mock under test/mock, and mock:check in CI failed on the one imageSaver would have had. Its test double is a stub, not a mock; the interface moves to its own file. Signed-off-by: Radmir Khurum <radmir.khurum@flant.com>
recordShippedFiles is fail-open, so a green e2e suite only showed the record breaks nothing; nothing showed it happens. This spec builds inject/pip_simple through werf, reads the SBOM back out of the registry and asserts requests carries occurrences: absolute paths of its own files, each with the file digest or the symlink marker, among them requests/__init__.py. With the call to recordShippedFiles removed the spec fails on "requests carries no evidence"; with it in place it passes on the Docker backend. Signed-off-by: Radmir Khurum <radmir.khurum@flant.com>
|
🤖 I have created a release *beep* *boop* --- ## [3.11.0-dk.1](v3.10.0-dk.3...v3.11.0-dk.1) (2026-10-20) ### Features * **build:** publish built images to multiple final repositories ([werf#8087](https://github.com/deckhouse/delivery-kit/issues/8087)) ([9a30573](9a30573)) * **build:** publish cached images with require-built-images ([93776dc](93776dc)) * **sbom:** decide which components an image actually ships ([#452](#452)) ([69de584](69de584)) * **sbom:** read which modules and crates a produced binary carries ([#442](#442)) ([11a39f6](11a39f6)) * **sbom:** record which files each component of an image consists of ([#453](#453)) ([fcc8e93](fcc8e93)) ### Bug Fixes * **build:** avoid panic on cache-repo errors without final-repo ([werf#8089](https://github.com/deckhouse/delivery-kit/issues/8089)) ([f553a94](f553a94)) * **build:** reduce native build delays on populated image stores ([werf#8064](https://github.com/deckhouse/delivery-kit/issues/8064)) ([d7e8f38](d7e8f38)) * **build:** retain actionable check-mode diagnostics ([93dc456](93dc456)) * **cleanup:** limit registry credentials to deletion commands ([werf#8086](https://github.com/deckhouse/delivery-kit/issues/8086)) ([5fe00b3](5fe00b3)) * **deploy, plan:** respect `--releases-history-max` with `--use-plan` ([werf#8085](https://github.com/deckhouse/delivery-kit/issues/8085)) ([9bb995b](9bb995b)) * **deploy, tracking:** honor `$WERF_NO_POD_LOGS` ([werf#8091](https://github.com/deckhouse/delivery-kit/issues/8091)) ([97866b8](97866b8)) * **deploy:** respect `--no-pod-logs` with `--use-plan` ([werf#8092](https://github.com/deckhouse/delivery-kit/issues/8092)) ([ffe489e](ffe489e)) ### Miscellaneous Chores * **release:** pin upstream base to 3.11.0-dk.1 ([e955d96](e955d96)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please).
Summary
An image's SBOM says a component is there; it does not say which files are that component's. A child image built FROM this one, or importing files out of it, inherits the component list wholesale with nothing to check it against, so a component whose files never arrived stays in the child's SBOM and is reported as its attack surface. Every image whose SBOM werf generates now records, per component, the files that component put into the image, which is the evidence the shipped-scope rule (#452) needs to answer that question on the child.
What
evidence.occurrencesin the image's SBOM: one entry per file,location= the path in this image,additionalContext=sha256:<hex>for a regular file andsymlinkfor a symlink. Every occurrence werf writes carries one or the other, so a reader can tell them from occurrences that arrived in a merged document.evidence.occurrencesat all — a Go module, a deb/apk/rpm package, an egg-info install, a Yarn PnP cache. Nothing is known about its files and nothing is claimed.RECORDis empty, a rock whose manifest lists nothing./var/lib/pm/files.jsongets no os-pm occurrences and one warning line, as before; its os-pm components stay unmarked rather than marked absent.node:20-slim203 components and 1949 files take the component list from 26 KB to 400 KB (4 KB to 85 KB gzipped); onpython:3.12-slimone component with 442 files adds 79 KB (19 KB gzipped). A project with a 100k-filenode_moduleswould add on the order of 19 MB, 4 MB gzipped. There is no cap and no opt-out flag.SaveImageToStream, the same readMutateAndPushImagedoes — not from the registry a second time. The cost is a save of the uncompressed image to a file under the werf tmp dir for the duration of the read (two copies on Buildah, whoseSaveImageToStreamwrites its own), and no network. It happens only for an image that has a component of one of the four ecosystems.files.jsonthat does not parse, a disk that is full — not the network, so the SBOM that gets cached in its place is the one the next build would produce again. The warning names the werf image and the stage image:image app: unable to read the built image <stage ref>; the SBOM records none of the files its components consist of.Matchfrom feat(sbom): decide which components an image actually ships #452 still has no caller. Nothing relocates an inherited occurrence's path through animport'sto:; that is the consumer's, asOccurrence's contract states.Why
The shipped-scope rule landed in #452 as a pure function with no input: it takes components with the files they claim, and werf had nowhere to get those from. os-pm, pip, npm and luarocks each record what they installed, and the image's own layers give the content, so the donor is the only place where the two can be put together — by the time a child is built, the manifests may not have come along at all.
The alternative was to have the child derive the file list from the donor's manifests directly, which fails for exactly the case this is for: a distroless final gets the files and not
/var/lib/pm,.dist-infoornode_modules/*/package.json.Occurrences are written after
Canonicalizerather than before it. Canonicalization folds duplicate components together, and its evidence merge drops a list that comes out empty — which is precisely the "looked for, none found" signal here, so writing earlier would erase it on every component that appears twice after a merge.