Materialize per-layer artifacts with explicit whiteout handling - #456
Materialize per-layer artifacts with explicit whiteout handling#456chruffins wants to merge 21 commits into
Conversation
2ea330e to
57b4c9c
Compare
57b4c9c to
d2068dc
Compare
a08da7b to
6e93aa4
Compare
4a6cbdf to
85d7ec5
Compare
85d7ec5 to
70c6422
Compare
de55693 to
e8b5a05
Compare
e8b5a05 to
0374d70
Compare
93d7a10 to
335b70d
Compare
3ad12a5 to
eb2b97b
Compare
68bf31c to
d883208
Compare
| if err != nil { | ||
| return err | ||
| } | ||
| if _, err := io.Copy(file, tr); err != nil { |
There was a problem hiding this comment.
Decompression bomb: layer extraction has no cumulative size cap
Semgrep rule: go.lang.security.decompression_bomb.potential-dos-via-decompression-bomb
The rule fired on line 291 (io.Copy(io.Discard, reader)), but that copy targets io.Discard — it burns CPU, not disk. The real instance of the same class is here: every tar.TypeReg entry is written through this unbounded io.Copy(file, tr), and neither extractTarFile nor the loop in unpackLayerBlob enforces any ceiling on total unpacked bytes or entry count.
Why this is a true positive
- Layer blobs and media types come from a remote OCI manifest (
lib/images/oci.go:474), i.e. a customer-supplied image reference — the compressed input is attacker-controlled. stats.unpackedBytesis accumulated but never compared against a limit, andheader.Sizeis trusted for accounting only.- The diffID check in
materializeLayerArtifact(lib/images/layer_artifact.go:173) runs after extraction finishes, and is skipped entirely whendesc.DiffID == "", so it cannot bound the write. - Disk admission control in
lib/resources/resource.gosums images already marked ready; it does not throttle an in-progress unpack. On a shared hypervisor host, a ~1 KB gzip layer expanding to hundreds of GB fills the data partition for every co-tenant VM.
Note this path is currently reached only from layer_artifact_test.go — nothing else calls materializeLayerArtifact yet — so this is latent, and worth fixing before it is wired into the pull path.
Recommended fix — thread an explicit budget through the unpack loop and bound the per-file copy:
const (
maxUnpackedBytes = 32 << 30 // 32 GiB per layer
maxEntries = 1 << 20
)
// unpackLayerBlob: budget := int64(maxUnpackedBytes), and in the loop
if stats.entries++; stats.entries > maxEntries {
return nil, fmt.Errorf("layer exceeds %d entries", maxEntries)
}
// extractTarFile: refuse to write past the remaining budget
func extractTarFile(tr *tar.Reader, target string, header *tar.Header, budget *int64) error {
// ...
n, err := io.CopyN(file, tr, *budget+1)
if err != nil && err != io.EOF {
_ = file.Close()
return err
}
if n > *budget {
_ = file.Close()
return fmt.Errorf("layer exceeds unpacked size limit of %d bytes", maxUnpackedBytes)
}
*budget -= n
// ...
}Do not apply Semgrep's suggested autofix at line 291 (io.CopyN(io.Discard, reader, 1024*1024*256)). Truncating that drain leaves the TeeReader hash incomplete, yielding a wrong stats.diffID and spurious "diff id mismatch" failures for any layer over 256 MB. If you want the trailing-data read bounded too, cap it well above the largest expected layer and treat hitting the cap as an error rather than silently stopping.
If you consider this an accepted risk, suppress with either:
- inline on the flagged line:
// nosemgrep: go.lang.security.decompression_bomb.potential-dos-via-decompression-bomb(or bare// nosemgrepto silence all rules on that line) - or exclude the file by adding
lib/images/layer_artifact.goto.semgrepignore
04e6138 to
36ccb28
Compare
36ccb28 to
06f8b54
Compare
06f8b54 to
041b07a
Compare
14cee93 to
4c270b0
Compare
4c270b0 to
fcd3558
Compare
5248289 to
328ebcf
Compare
|
@cursor review |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Want higher recall? High effort reviews run extra passes and find more bugs. A team admin can switch effort levels in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit d4ed105. Configure here.
Drop the context-less wrapper pairs (materializeLayerArtifact, installLayerArtifact, unpackCachedLayer, unpackLayerBlob, applyLayerTree) in favor of single ctx-taking functions; two of the wrappers had no callers. Reuse unpackCachedLayer from materializeLayerArtifactOnce instead of duplicating the blob lookup and diff-id check. Split the lexical confinement out of safeJoinForComposition into confineToRoot so safeJoin no longer performs and discards a full symlink resolution per tar entry. safeJoin also returns early when the entry resolves to the root itself, which previously looped forever on a "." entry. Pass the walked DirEntry's FileInfo into the copy helpers instead of re-Lstat'ing, remove the unreachable symlink branch in copyDirectoryEntry (callers already resolve through resolveCompositionDirTarget), and lean on removePath's own tree-writability handling for whiteout removal.
Restoring atime required per-platform Stat_t accessors for a value that tar headers almost never carry and that nothing reads back. Set atime to mtime on both the tar extraction and tree copy paths instead.
Replace the hand-rolled tar extractor and tree merger with umoci's layer.UnpackLayer, which the pull path already depends on. umoci confines entries with filepath-securejoin, interprets OCI whiteouts, and follows the same directory-over-symlink rule as containerd and docker (replace, do not write through), so composition no longer diverges from other runtimes. Per-layer artifacts are extracted in OverlayfsRootfs form: whiteouts become 0:0 character devices and opaque directories carry the overlayfs opaque xattr, the on-disk representation an overlayfs mount of stacked layers understands. Composition applies each layer directly onto the staging tree with DirRootfs, removing the unpack-then-copy pass and the directory mode bookkeeping it required. Ownership is preserved when running as root; unprivileged runs use umoci's rootless mode as before. The record drops the entry count, which is no longer observable without a second pass over the tar, and reports the decompressed tar size as unpacked bytes.
The singleflight body ran with the first caller's context, so cancelling one pull failed materialization for every concurrent pull waiting on the same layer. Run the build under context.WithoutCancel; callers still return on their own cancellation.
Fold contextReader into layer_artifact.go, its only user. Drop the context-aware mkfs.erofs wrapper: the shared build now runs detached from caller cancellation, so the context could never fire during conversion, and ExportRootfs already dispatches on the format.
- Bound detached layer builds with a deadline so a hung cache-blob read cannot wedge the singleflight key forever - Verify the compressed blob digest during unpack; the pull path does not re-verify blobs after they land in the OCI cache - Resolve blob paths through paths.OCICacheBlob instead of hand-joining the cache layout, and drop the duplicated digest validation - Simplify decompressLayer and move composeOnDiskFormat/whiteoutPrefix to the tests that use them - Log failures to remove the unpack directory instead of discarding the error - Make the cancelled-caller test tolerant of a build that finishes before the caller's select runs
Covers zstd and docker-style media types, traversal in layer digest hex, rebuild when the artifact file is missing, concurrent shared materialization, device and fifo entries (root only), and layer artifact disk-usage accounting.
cb96be2 to
7b8f9ef
Compare
Drop the disk-usage accounting case (covered by TestTotalLayerArtifactBytesFromFilesystem) and the truncated-artifact documentation case.

tldr
Content-addressed per-layer artifact store at
images/layers/<layer-blob-digest>/. Pulls and composition (next PR in the stack) will share it. Not yet called by production code — no behavior change today.what this adds
materializeLayerArtifact) — unpacks a layer blob from the existing shared OCI cache with umoci, converts to erofs, installslayer.erofs+ anartifact.jsonrecord atomically. Same layer shared across images materializes once; concurrent callers share one build (singleflight)..wh.<name>→0:0char device,.wh..wh..opq→trusted.overlay.opaquexattr. Standard representation; no private marker format.TotalOCICacheBytes(conservative: blob + artifact of the same layer may transiently coexist).review status: done
Two independent review passes (correctness + code-quality). All actionable findings fixed in
46d02f3. Notables:composeOnDiskFormat+whiteoutPrefixmoved to tests,multiCloserdeleted, blob path viapaths.OCICacheBlobVerified empirically on a dev host:
mkfs.erofs1.8.10 andmkfs.ext4both preserve whiteout char devices and opaque xattrs throughExportRootfs— the stacking design's premise holds. Depends on host erofs-utils version; check it in the pull-integration PR.reviewer focus (3 things)
layerBuildTimeout= 1h is a judgment call — flag if hypeman has a convention for bounding background work.unpack-*dirs, decides on blob retirement (double-counting), and decomposesmanager.go(~1000 lines)validation
Full
lib/images+lib/pathsgreen on a dev host, plus a root pass for the root-gated tests (overlayfs whiteout inodes, device/fifo nodes). Tests cover: materialize/reuse/rebuild, corrupt-record recovery, invalid digest rejection (sha256:..,sha256:a/b), whiteout/opaque output and cross-layer semantics, symlink confinement and replacement, hardlinks, cancellation, zstd + docker media types, concurrent singleflight (8 callers, one build), trailing-padding diff IDs. Disk-usage exclusions covered by existingTestTotalLayerArtifactBytesFromFilesystem.CI note: the
testworkflow is flaky on this branch — failures are unrelated integration tests (TestEgressProxyRewritesHTTPSHeaderstimeout, docker/network tests needingnginx:alpine), failing before the latest commits too. Layer-artifact tests pass consistently.next step
Review the 3 focus items above, then approve. The pull-integration PR is where the store gets its production caller.
Note
Medium Risk
New layer unpack and filesystem materialization touches tar extraction and external mkfs tools; incorrect whiteout or path handling could affect future image composition, though pull behavior is unchanged until wired in.
Overview
Introduces a content-addressed per-layer artifact store under
images/layers/<blob-digest>/, keyed by compressed layer digest plus format (erofs/ext4).materializeLayerArtifactreads blobs from the existing OCI cache, unpacks with umoci into a temp dir, converts to the default disk format, and installslayer.*plus anartifact.*.jsonrecord atomically; duplicate work is deduped with singleflight and valid on-disk records are reused.Whiteouts are modeled explicitly: artifact extraction uses umoci
OverlayfsRootfs(character-device whiteouts and opaque xattrs) so stacked layers can be mounted later; compose-oriented unpacking usesDirRootfs. Layer unpack supports gzip/zstd, verifies diff IDs, caps unpacked size at 100 GiB, and honors context cancellation via acontextReaderandCommandContextformkfs.erofs.Disk accounting now walks
layer.*files (skipping.unpack-*temps) and folds that intoTotalOCICacheBytes/ resource admission alongside OCI blobs. New path helpers live inlib/paths. The materialization API is implemented and tested but not yet called from the image pull/build path (follow-up PR).Reviewed by Cursor Bugbot for commit d4ed105. Configure here.