Skip to content

Introduce OCI image unpack feature - #381

Open
henrybear327 wants to merge 4 commits into
sysprog21:mainfrom
henrybear327:oci/unpack
Open

henrybear327 wants to merge 4 commits into
sysprog21:mainfrom
henrybear327:oci/unpack

Conversation

@henrybear327

@henrybear327 henrybear327 commented Sep 14, 2026

Copy link
Copy Markdown
Collaborator

Summary by cubic

Adds the unpack command to elfuse-oci so stored images can be extracted into a rootfs for use with elfuse --sysroot; previously images could only be pulled and stored.

  • Without --rootfs, the rootfs is cached in the store by manifest digest, staged in a sibling temp directory, and published by rename; completed entries are reused and abandoned staging trees are swept after a day.
  • With --rootfs DIR, an existing directory is merged in place, an absent one is staged and renamed, and destinations inside the store are refused, including case aliases of it.
  • Layer application uses moby/go-archive for whiteouts, hardlinks, compression, and metadata; ownership is never applied.
  • Device and FIFO entries, plus hardlinks to them, become whiteouts; absolute symlink targets are rebased relative to the link; setuid, setgid, and sticky bits are cleared.
  • Directory modes stay writable until all layers finish, then are restored, even on failure.
  • The unpack read path reuses pull's store format checks without creating or repairing the store.
  • Mid-layer cancellation is reported as cancellation even when decompression runs in a child process.

Written for commit dd87cca. Summary will update on new commits.

Review in cubic

Defer the temporary file's removal and close instead of repeating both
on every failure path, and sync the directory through syncDirectory
after the rename.
Reading a stored image is the reverse of pull: resolve a reference and
platform to a manifest digest, load that manifest, and derive the
directory its rootfs unpacks to.

digestFor shares pin's walk over the per-reference index through
nestedIndex. Cache directories are keyed by manifest digest under a
per-kind sha256 directory, and a symlink at either parent is refused.

openStoreForRead applies the format checks pull enforces without
creating or repairing anything, so a lookup cannot leave a store behind
at a mistyped path; checkLayout holds those checks for both callers.

Rootfs containment is checked by directory identity up the ancestor
chain, so a case alias of the store cannot be named as a destination.
blob and refuseRootfsInStore gain their callers with unpack.
Add unpack for stored images. Without --rootfs the rootfs is published
under the store by manifest digest through a rename, and only that
content-addressed entry may reuse another unpack's tree when the rename
is lost; a caller-named rootfs is merged in place or staged and renamed,
and a lost rename is an error. moby/go-archive owns layer application,
whiteouts, compression, containment, and metadata.

Each header is rewritten after the previous entry has been applied, so
parent symlinks already on disk resolve. Devices, FIFOs, and hardlinks
to them become whiteouts. Absolute symlink targets are rebased relative
to the link, and a hardlink to such a symlink is rebased at its own
location. Special mode bits are cleared because ownership is never
applied. Parent traversal above the root clamps there, as in go-archive
and the kernel. The size field of a header-only entry is ignored, as
archive/tar does, and a regular file named with a trailing slash is
renamed before the writer rejects it.

Directories stay accessible until all layers finish, then their modes
are restored, on failure too. Staging trees are removed without
following symlinks or failing on restrictive directory modes. A
cancelled read is reported as cancellation even when go-archive
decompresses through an unpigz child, whose exit status would otherwise
replace it.
Describe the rootfs cache, the --rootfs path, what moby/go-archive owns,
and the entry rewrites elfuse applies before it. Add the unpack syntax
and flags to the usage page, the offline coverage to the testing page,
and unpacking to the README summary.
@henrybear327 henrybear327 self-assigned this Sep 14, 2026
@henrybear327
henrybear327 requested a review from jserv September 14, 2026 16:33

@cubic-dev-ai cubic-dev-ai 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.

6 issues found across 16 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="cmd/oci/helpers_test.go">

<violation number="1" location="cmd/oci/helpers_test.go:59">
P3: When a tarEntry has an empty Name with Type==0 and Link=="", `e.Name[len(e.Name)-1]` indexes index -1 and panics the test. Guard for the empty Name before indexing, or produce a clear t.Fatal.</violation>
</file>

<file name="go.mod">

<violation number="1" location="go.mod:8">
P3: Removing gotest.tools/v3 from go.mod while its go.sum hashes remain means go.sum was not tidied. Run `go mod tidy` so the go.sum entry for the removed module is dropped, keeping go.mod/go.sum consistent.</violation>
</file>

<file name="cmd/oci/store.go">

<violation number="1" location="cmd/oci/store.go:78">
P2: When `oci-layout` is missing or malformed, `openStoreForRead` still accepts the store and `unpack` proceeds. Validate the required OCI layout metadata read-only before returning the store.</violation>
</file>

<file name="cmd/oci/unpack_test.go">

<violation number="1" location="cmd/oci/unpack_test.go:85">
P2: The dangling-symlink subtest always fails because it bypasses `unpackImage` and calls `unpackImageFresh`, whose atomic rename replaces the symlink successfully. Call `unpackImage` here so the test exercises the destination validation it intends to verify.</violation>
</file>

<file name="cmd/oci/tarfilter.go">

<violation number="1" location="cmd/oci/tarfilter.go:253">
P1: When an image uses an absolute target with leading `..`, `relativeTarget` turns `/../outside` into `../outside`, allowing a root-level symlink to escape the unpacked root. Remove leading `.` and `..` components before constructing the relative target while preserving traversal after a named component.</violation>
</file>

<file name="docs/usage.md">

<violation number="1" location="docs/usage.md:350">
P2: The quick-start guidance cannot provision the directory that `elfuse-oci unpack` just populated. `--create-sysroot PATH` mounts a separate sparsebundle over `PATH` for one `elfuse` run, hiding the unpacked files instead of making that tree case-sensitive. Tell users to unpack onto an already-mounted case-sensitive volume, or document the separate sparsebundle creation and mount workflow.</violation>
</file>

Tip: cubic can generate docs of your entire codebase and keep them up to date. Try it here.

Re-trigger cubic

Comment thread cmd/oci/tarfilter.go
prefix = strings.Repeat("../", strings.Count(dir, "/")+1)
}
// Keep target traversal intact: a/../b may cross a symlink at a.
target = strings.TrimLeft(target, "/")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1: When an image uses an absolute target with leading .., relativeTarget turns /../outside into ../outside, allowing a root-level symlink to escape the unpacked root. Remove leading . and .. components before constructing the relative target while preserving traversal after a named component.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At cmd/oci/tarfilter.go, line 253:

<comment>When an image uses an absolute target with leading `..`, `relativeTarget` turns `/../outside` into `../outside`, allowing a root-level symlink to escape the unpacked root. Remove leading `.` and `..` components before constructing the relative target while preserving traversal after a named component.</comment>

<file context>
@@ -0,0 +1,321 @@
+		prefix = strings.Repeat("../", strings.Count(dir, "/")+1)
+	}
+	// Keep target traversal intact: a/../b may cross a symlink at a.
+	target = strings.TrimLeft(target, "/")
+	if target == "" {
+		target = "."
</file context>

Comment thread cmd/oci/store.go
return nil, fmt.Errorf("store: %s is not a directory", root)
}
s := &store{root: root}
if err := s.checkLayout(); 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.

P2: When oci-layout is missing or malformed, openStoreForRead still accepts the store and unpack proceeds. Validate the required OCI layout metadata read-only before returning the store.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At cmd/oci/store.go, line 78:

<comment>When `oci-layout` is missing or malformed, `openStoreForRead` still accepts the store and `unpack` proceeds. Validate the required OCI layout metadata read-only before returning the store.</comment>

<file context>
@@ -53,6 +59,184 @@ func openStore(root string) (*store, error) {
+		return nil, fmt.Errorf("store: %s is not a directory", root)
+	}
+	s := &store{root: root}
+	if err := s.checkLayout(); err != nil {
+		if errors.Is(err, errNoMarker) {
+			return nil, fmt.Errorf("store: %s is not an elfuse OCI store", root)
</file context>

Comment thread cmd/oci/unpack_test.go
}
var err error
captureOutput(t, func() {
err = unpackImageFresh(context.Background(), s, manifestOf(t, s, d), dest)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: The dangling-symlink subtest always fails because it bypasses unpackImage and calls unpackImageFresh, whose atomic rename replaces the symlink successfully. Call unpackImage here so the test exercises the destination validation it intends to verify.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At cmd/oci/unpack_test.go, line 85:

<comment>The dangling-symlink subtest always fails because it bypasses `unpackImage` and calls `unpackImageFresh`, whose atomic rename replaces the symlink successfully. Call `unpackImage` here so the test exercises the destination validation it intends to verify.</comment>

<file context>
@@ -0,0 +1,648 @@
+			}
+			var err error
+			captureOutput(t, func() {
+				err = unpackImageFresh(context.Background(), s, manifestOf(t, s, d), dest)
+			})
+			if err != nil {
</file context>
Suggested change
err = unpackImageFresh(context.Background(), s, manifestOf(t, s, d), dest)
err = unpackImage(context.Background(), s, "bad:1", manifestOf(t, s, d), dest)

Comment thread docs/usage.md
Comment on lines +350 to +353
A default APFS volume folds case, which a Linux rootfs does not expect. Provision
the sysroot with `--create-sysroot` as described under
[Dynamic Linking And Sysroots](#dynamic-linking-and-sysroots) when unpacking a
distribution rootfs for real use.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: The quick-start guidance cannot provision the directory that elfuse-oci unpack just populated. --create-sysroot PATH mounts a separate sparsebundle over PATH for one elfuse run, hiding the unpacked files instead of making that tree case-sensitive. Tell users to unpack onto an already-mounted case-sensitive volume, or document the separate sparsebundle creation and mount workflow.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At docs/usage.md, line 350:

<comment>The quick-start guidance cannot provision the directory that `elfuse-oci unpack` just populated. `--create-sysroot PATH` mounts a separate sparsebundle over `PATH` for one `elfuse` run, hiding the unpacked files instead of making that tree case-sensitive. Tell users to unpack onto an already-mounted case-sensitive volume, or document the separate sparsebundle creation and mount workflow.</comment>

<file context>
@@ -343,26 +343,35 @@ does not require Go.
+build/elfuse --sysroot ~/debian-rootfs /bin/sh

+A default APFS volume folds case, which a Linux rootfs does not expect. Provision
+the sysroot with --create-sysroot as described under
+Dynamic Linking And Sysroots when unpacking a
</file context>


</details>

```suggestion
A default APFS volume folds case, which a Linux rootfs does not expect. Unpack
onto an already-mounted case-sensitive APFS volume (for example, a sparsebundle)
before using it as the sysroot; `--create-sysroot` provisions a separate volume
for an `elfuse` run and cannot convert this already-unpacked directory.

Comment thread cmd/oci/helpers_test.go
hdr.Typeflag = tar.TypeSymlink
hdr.Linkname = e.Link
hdr.Size = 0
case e.Name[len(e.Name)-1] == '/':

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: When a tarEntry has an empty Name with Type==0 and Link=="", e.Name[len(e.Name)-1] indexes index -1 and panics the test. Guard for the empty Name before indexing, or produce a clear t.Fatal.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At cmd/oci/helpers_test.go, line 59:

<comment>When a tarEntry has an empty Name with Type==0 and Link=="", `e.Name[len(e.Name)-1]` indexes index -1 and panics the test. Guard for the empty Name before indexing, or produce a clear t.Fatal.</comment>

<file context>
@@ -10,27 +10,59 @@ import (
+			hdr.Typeflag = tar.TypeSymlink
+			hdr.Linkname = e.Link
+			hdr.Size = 0
+		case e.Name[len(e.Name)-1] == '/':
+			hdr.Typeflag = tar.TypeDir
+			if e.Mode == 0 {
</file context>

Comment thread go.mod
require (
github.com/alecthomas/kong v1.16.1
github.com/google/go-containerregistry v0.21.7
github.com/moby/go-archive v0.3.3

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: Removing gotest.tools/v3 from go.mod while its go.sum hashes remain means go.sum was not tidied. Run go mod tidy so the go.sum entry for the removed module is dropped, keeping go.mod/go.sum consistent.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At go.mod, line 8:

<comment>Removing gotest.tools/v3 from go.mod while its go.sum hashes remain means go.sum was not tidied. Run `go mod tidy` so the go.sum entry for the removed module is dropped, keeping go.mod/go.sum consistent.</comment>

<file context>
@@ -5,16 +5,21 @@ go 1.25.0
 require (
 	github.com/alecthomas/kong v1.16.1
 	github.com/google/go-containerregistry v0.21.7
+	github.com/moby/go-archive v0.3.3
 	github.com/opencontainers/image-spec v1.1.1
 )
</file context>

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