Skip to content

Materialize per-layer artifacts with explicit whiteout handling - #456

Draft
chruffins wants to merge 7 commits into
hypeship/manifest-layer-modelfrom
hypeship/layer-artifacts
Draft

Materialize per-layer artifacts with explicit whiteout handling#456
chruffins wants to merge 7 commits into
hypeship/manifest-layer-modelfrom
hypeship/layer-artifacts

Conversation

@chruffins

@chruffins chruffins commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

summary

Stacked stage of the image-storage project. Adds a per-layer artifact store at images/layers/<layer-blob-digest>/:

  • Materialization (materializeLayerArtifact): reads the compressed layer blob from the existing shared OCI cache (no new downloader), unpacks it into an isolated temp directory, converts to erofs, and installs layer.erofs atomically beside an artifact.json record. Keyed by layer blob digest plus format/options, so identical layers shared across image versions materialize once.
  • Whiteout model: .wh.<name> and .wh..wh..opq markers are recorded in the artifact record (dir, target, opaque) instead of being assumed to compose.
  • Composition primitive (applyLayerTree): merges one unpacked layer into a target tree with correct OCI semantics — whiteouts/opaque markers remove or mask lower-layer content first, then the layer's own entries are copied on top, with whiteout-then-recreate pairs resolved correctly. Raw tar whiteout files are interpreted explicitly; they are never passed to overlayfs. Symlinks are removed rather than followed during deletion; hardlinks within a layer stay linked; path traversal is confined.
  • Interrupted builds leave only temp files; the next materialization rebuilds cleanly. Completed artifacts are reused without rework.

validation

Synthetic OCI layouts and hand-built trees cover: materialization fields and reuse, missing-blob errors, whiteout/opaque inventory, whiteout/opaque/type-replacement/same-layer-recreate semantics, and symlink/hardlink edge cases. go test ./lib/images green including -race; Docker Hub-backed tests are intermittently rate-limited in this environment.


Note

Medium Risk
New tar unpack and layer-merge logic is security- and correctness-sensitive (path escapes, whiteout semantics, device nodes); it reuses the existing EROFS toolchain but is foundational for future image assembly.

Overview
Introduces a content-addressed per-layer store under images/layers/<layer-blob-digest>/, with path helpers for layer.erofs and artifact.json.

materializeLayerArtifact builds or reuses a layer artifact from the existing OCI cache blob: unpack (gzip/zstd tar) into a temp dir, record metadata including whiteout inventory (.wh.* and opaque .wh..wh..opq), convert to EROFS via existing convertToErofs, and install atomically. Missing blobs fail clearly; completed artifacts are not rebuilt.

applyLayerTree is the composition primitive that merges one unpacked layer onto a target tree with explicit OCI semantics—apply whiteouts/opaque dirs against lower content first, then copy layer entries (hardlinks, symlinks, type replacements) without leaking whiteout marker files. Tar extraction uses path confinement and full entry types where supported.

Tests cover materialization, reuse, whiteout recording, composition edge cases, and symlink/hardlink behavior.

Reviewed by Cursor Bugbot for commit 2ea330e. Configure here.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using default effort and found 5 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 2ea330e. Configure here.

Comment thread lib/images/layer_artifact.go Outdated
}
removePath(target)
file, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
if err != nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Tar extraction follows intermediate symlinks

High Severity

safeJoin only lexically cleans tar names, and extractTarEntry then creates files with os.OpenFile and parents with os.MkdirAll, both of which follow host symlinks. A layer that plants a symlink and later writes through it can escape the unpack directory onto the host. The repo already uses filepath-securejoin and O_NOFOLLOW for this in lib/volumes/archive.go.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 2ea330e. Configure here.


func removePath(path string) {
_ = os.Remove(path)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Directory whiteouts fail silently

High Severity

removePath uses os.Remove, which cannot delete a non-empty directory and ignores the error. OCI .wh.&lt;name&gt; markers hide files or directories, so a whiteout of a populated directory leaves lower-layer contents in place. The same helper also blocks replacing a directory with a symlink during copy.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 2ea330e. Configure here.

}
}
return nil
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Opaque whiteouts follow target symlinks

High Severity

clearDirContents is documented not to follow symlinks, but os.ReadDir does. If a lower layer left a symlink at the opaque directory path, phase 1 lists and deletes the symlink target’s contents instead of removing the link. That can destroy files outside the composed tree before phase 2 replaces the path.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 2ea330e. Configure here.

Comment thread lib/images/layer_artifact.go Outdated
if !ok {
return fmt.Errorf("unsupported entry type for %s", src)
}
return unix.Mknod(dst, uint32(info.Mode()), int(stat.Rdev))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Device copy uses wrong file mode

Medium Severity

copyEntryInto recreates devices and fifos with unix.Mknod using uint32(info.Mode()). Go fs.FileMode type bits are not Unix S_IFCHR/S_IFBLK/S_IFIFO, so mknod gets a mode with no file type and fails with EINVAL. Composition then aborts on layers that contain device nodes or fifos.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 2ea330e. Configure here.

layerHex := strings.TrimPrefix(desc.Digest, "sha256:")
if layerHex == "" || strings.Contains(layerHex, "/") {
return nil, fmt.Errorf("invalid layer digest: %s", desc.Digest)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Layer digest skips path validation

Medium Severity

New layer path helpers join layerHex into images/layers/ without paths.ValidatePathComponent. The call-site check only rejects empty strings and /, so values like .. still escape the layers directory and can write layer.erofs / artifact.json under images/.

Additional Locations (1)
Fix in Cursor Fix in Web

Triggered by learned rule: Filesystem-backed resource IDs must pass ValidatePathComponent before path construction

Reviewed by Cursor Bugbot for commit 2ea330e. Configure here.

@chruffins
chruffins force-pushed the hypeship/layer-artifacts branch from 2ea330e to 57b4c9c Compare August 26, 2026 18:44
@chruffins
chruffins force-pushed the hypeship/layer-artifacts branch from 57b4c9c to d2068dc Compare August 26, 2026 18:45
@chruffins
chruffins force-pushed the hypeship/layer-artifacts branch from a08da7b to 6e93aa4 Compare August 26, 2026 18:52
@chruffins
chruffins force-pushed the hypeship/layer-artifacts branch 2 times, most recently from 4a6cbdf to 85d7ec5 Compare August 26, 2026 18:54
@chruffins
chruffins force-pushed the hypeship/layer-artifacts branch from 85d7ec5 to 70c6422 Compare August 26, 2026 18:55
@chruffins
chruffins force-pushed the hypeship/layer-artifacts branch from de55693 to e8b5a05 Compare August 26, 2026 18:58
@chruffins
chruffins force-pushed the hypeship/layer-artifacts branch from e8b5a05 to 0374d70 Compare August 26, 2026 19:26
@chruffins
chruffins force-pushed the hypeship/layer-artifacts branch 2 times, most recently from 93d7a10 to 335b70d Compare August 26, 2026 19:31
@chruffins
chruffins force-pushed the hypeship/layer-artifacts branch from 3ad12a5 to eb2b97b Compare August 26, 2026 19:47
Layers are materialized into images/layers/<blob-digest>/ keyed by the
compressed layer blob digest, reading the blob from the shared OCI cache.
Each artifact is an erofs image of the unpacked layer plus an artifact.json
record carrying format, options, entry stats, and the whiteout inventory.
Builds happen in temp directories and are installed atomically; an
interrupted build simply rebuilds on the next attempt.

applyLayerTree interprets OCI whiteouts and opaque-directory markers
explicitly when merging a layer into a target tree: markers delete or mask
lower-layer content before the layer's own entries are copied on top.
Tar-level .wh. files are never relied upon to compose on overlayfs, and
symlinks are removed rather than followed during deletion.
@chruffins
chruffins force-pushed the hypeship/layer-artifacts branch from 68bf31c to d883208 Compare August 26, 2026 22:22
if err != nil {
return err
}
if _, err := io.Copy(file, tr); err != nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.unpackedBytes is accumulated but never compared against a limit, and header.Size is trusted for accounting only.
  • The diffID check in materializeLayerArtifact (lib/images/layer_artifact.go:173) runs after extraction finishes, and is skipped entirely when desc.DiffID == "", so it cannot bound the write.
  • Disk admission control in lib/resources/resource.go sums 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 // nosemgrep to silence all rules on that line)
  • or exclude the file by adding lib/images/layer_artifact.go to .semgrepignore

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.

1 participant