From 86f50b6090c8cbd09a2f156facf0b675c7907c7e Mon Sep 17 00:00:00 2001 From: Scott Moser Date: Tue, 26 May 2026 15:07:45 -0400 Subject: [PATCH 01/33] build: add EROFS layer output format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Emit OCI image layers as EROFS filesystem images (application/vnd.erofs) instead of tar+gzip. Selected via `--format=erofs` on `apko build` / `apko publish` or `format: erofs` in apko.yaml. Tracks the draft erofs/erofs-image-spec (PR #1). Single-layer and multi-layer (layering) builds are supported. Multi-layer emits each non-final group with `org.erofs.role=overlay-lower` per spec §3.8 and a per-group partial `usr/lib/apk/db/installed` so per-layer scanners still work. Manifests declare `erofs` in os.features per §5.4. Uses github.com/erofs/go-erofs (Apache-2.0, pure Go) for the writer. Reproducibility via SOURCE_DATE_EPOCH. Tests cover roundtrip via erofs.Open, byte-identical determinism, the full ImageLayoutToLayer dispatch, OSFeatures plumbing, and end-to-end validation via `fsck.erofs` (skipped when the binary isn't on PATH). `+zstd`, dm-verity, and chunk indexes are not implemented in this round. Co-Authored-By: Claude Opus 4.7 --- docs/apko_file.md | 11 + go.mod | 1 + go.sum | 2 + internal/cli/build.go | 3 + internal/cli/publish.go | 3 + pkg/build/build.go | 14 ++ pkg/build/erofs.go | 285 +++++++++++++++++++++++ pkg/build/erofs_layers.go | 284 +++++++++++++++++++++++ pkg/build/erofs_test.go | 306 +++++++++++++++++++++++++ pkg/build/layers.go | 4 + pkg/build/oci/image.go | 23 +- pkg/build/oci/image_test.go | 22 ++ pkg/build/options.go | 17 ++ pkg/build/types/image_configuration.go | 7 + pkg/build/types/schema.json | 4 + pkg/build/types/types.go | 32 +++ 16 files changed, 1016 insertions(+), 2 deletions(-) create mode 100644 pkg/build/erofs.go create mode 100644 pkg/build/erofs_layers.go create mode 100644 pkg/build/erofs_test.go diff --git a/docs/apko_file.md b/docs/apko_file.md index 36e2a84ed..fe8c7f239 100644 --- a/docs/apko_file.md +++ b/docs/apko_file.md @@ -274,3 +274,14 @@ It contains the following children: - `budget`: The number of additional layers apko will use for layering. See [layering.md](layering.md) for more information. + +### Format (experimental) + +`format` selects the on-wire layer payload format. Two values are recognized: + + - `tar` (default): standard gzip-compressed tar layers (`application/vnd.oci.image.layer.v1.tar+gzip`). + - `erofs`: uncompressed EROFS filesystem images (`application/vnd.erofs`), per the draft [erofs/erofs-image-spec](https://github.com/erofs/erofs-image-spec). EROFS layers advertise `erofs` in the image config's `os.features` so consumers that do not implement the spec can identify and skip them. + +`erofs` may also be selected on the command line with `--format=erofs` on `apko build` and `apko publish`. The CLI flag overrides whatever is in the config file. + +**Status:** EROFS support is experimental and tracks the spec PR at https://github.com/erofs/erofs-image-spec/pull/1; media types and annotations may change before the spec reaches a stable release. Both single-layer and multi-layer (`layering`) builds are supported. Multi-layer builds emit each non-final layer with `org.erofs.role=overlay-lower` per spec §3.8; the final layer carries no role. `+zstd` compression and dm-verity are not implemented. diff --git a/go.mod b/go.mod index fb86cb30c..94ef89a1d 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( chainguard.dev/sdk v0.1.191 github.com/chainguard-dev/clog v1.8.1 github.com/charmbracelet/log v1.0.0 + github.com/erofs/go-erofs v0.3.0 github.com/go-git/go-git/v5 v5.19.2 github.com/google/go-cmp v0.7.0 github.com/google/go-containerregistry v0.21.9 diff --git a/go.sum b/go.sum index d5a104c8c..55302e3d4 100644 --- a/go.sum +++ b/go.sum @@ -80,6 +80,8 @@ github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= +github.com/erofs/go-erofs v0.3.0 h1:o/W5ABAA3sHYl97WL93dacKEfeDpJhdFf3c2snAti7I= +github.com/erofs/go-erofs v0.3.0/go.mod h1:XkSeN9MHszGd4+3gcEjadJLYHCQpWzJ7/8yznzMuzJs= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= diff --git a/internal/cli/build.go b/internal/cli/build.go index 946623846..2cc70cc4b 100644 --- a/internal/cli/build.go +++ b/internal/cli/build.go @@ -60,6 +60,7 @@ func buildCmd() *cobra.Command { var includePaths []string var ignoreSignatures bool var sizeLimits options.SizeLimits + var format string cmd := &cobra.Command{ Use: "build", @@ -119,6 +120,7 @@ Along the image, apko will generate SBOMs (software bill of materials) describin build.WithIncludePaths(includePaths), build.WithIgnoreSignatures(ignoreSignatures), build.WithSizeLimits(sizeLimits), + build.WithFormat(format), ) }, } @@ -139,6 +141,7 @@ Along the image, apko will generate SBOMs (software bill of materials) describin cmd.Flags().StringVar(&lockfile, "lockfile", "", "a path to .lock.json file (e.g. produced by apko lock) that constraints versions of packages to the listed ones (default '' means no additional constraints)") cmd.Flags().StringSliceVar(&includePaths, "include-paths", []string{}, "Additional include paths where to look for input files (config, base image, etc.). By default apko will search for paths only in workdir. Include paths may be absolute, or relative. Relative paths are interpreted relative to workdir. For adding extra paths for packages, use --repository-append.") cmd.Flags().BoolVar(&ignoreSignatures, "ignore-signatures", false, "ignore repository signature verification") + cmd.Flags().StringVar(&format, "format", "", "layer payload format: 'tar' (default) or 'erofs' (experimental, tracks erofs-image-spec draft)") addClientLimitFlags(cmd, &sizeLimits) return cmd } diff --git a/internal/cli/publish.go b/internal/cli/publish.go index 71cbd0ee2..6a53339cb 100644 --- a/internal/cli/publish.go +++ b/internal/cli/publish.go @@ -56,6 +56,7 @@ func publish() *cobra.Command { var offline bool var lockfile string var ignoreSignatures bool + var format string cmd := &cobra.Command{ Use: "publish ", @@ -122,6 +123,7 @@ in a keychain.`, build.WithLockFile(lockfile), build.WithTempDir(tmp), build.WithIgnoreSignatures(ignoreSignatures), + build.WithFormat(format), }, []PublishOption{ // these are extra here just for publish; everything before is the same for BuildCmd as PublishCmd @@ -150,6 +152,7 @@ in a keychain.`, cmd.Flags().BoolVar(&offline, "offline", false, "do not use network to fetch packages (cache must be pre-populated)") cmd.Flags().StringVar(&lockfile, "lockfile", "", "a path to .lock.json file (e.g. produced by apko lock) that constraints versions of packages to the listed ones (default '' means no additional constraints)") cmd.Flags().BoolVar(&ignoreSignatures, "ignore-signatures", false, "ignore repository signature verification") + cmd.Flags().StringVar(&format, "format", "", "layer payload format: 'tar' (default) or 'erofs' (experimental, tracks erofs-image-spec draft)") // these are extra here just for publish; everything before is the same for BuildCmd as PublishCmd cmd.Flags().BoolVar(&local, "local", false, "publish image just to local Docker daemon") diff --git a/pkg/build/build.go b/pkg/build/build.go index aa6c41afa..445064605 100644 --- a/pkg/build/build.go +++ b/pkg/build/build.go @@ -187,6 +187,20 @@ func (bc *Context) ImageLayoutToLayer(ctx context.Context) (string, v1.Layer, er bc.o.TarballPath = outfile.Name() defer outfile.Close() + if bc.ic.Format.Resolved() == types.LayerFormatErofs { + if err := writeERofs(ctx, outfile, bc.fs, bc.o.SourceDateEpoch); err != nil { + return "", nil, fmt.Errorf("generating erofs image: %w", err) + } + if err := outfile.Sync(); err != nil { + return "", nil, fmt.Errorf("syncing erofs image: %w", err) + } + l, err := buildErofsLayerFromFile(outfile.Name(), nil) + if err != nil { + return "", nil, fmt.Errorf("finalizing erofs layer: %w", err) + } + return outfile.Name(), l, nil + } + lw := newLayerWriter(outfile) if err := writeTar(ctx, lw.w, bc.fs); err != nil { diff --git a/pkg/build/erofs.go b/pkg/build/erofs.go new file mode 100644 index 000000000..d4a5deb22 --- /dev/null +++ b/pkg/build/erofs.go @@ -0,0 +1,285 @@ +// Copyright 2026 Chainguard, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package build + +import ( + "archive/tar" + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "io/fs" + "os" + "time" + + erofs "github.com/erofs/go-erofs" + v1 "github.com/google/go-containerregistry/pkg/v1" + v1types "github.com/google/go-containerregistry/pkg/v1/types" + "go.opentelemetry.io/otel" + "golang.org/x/sys/unix" + + apkfs "chainguard.dev/apko/pkg/apk/fs" +) + +// Media types from the draft erofs/erofs-image-spec (PR #1). +// These tracking constants are intentionally kept in one place so they can be +// updated in lockstep with the spec. +const ( + erofsLayerMediaType = "application/vnd.erofs" + erofsRoleAnnotation = "org.erofs.role" + erofsRoleOverlay = "overlay-lower" + erofsUncompressedDigestAnnotation = "org.erofs.uncompressed-digest" +) + +// writeERofs serializes fsys as a raw (uncompressed) EROFS filesystem image to +// out. out must be both writable and seekable: go-erofs's Writer rewrites the +// superblock at offset 0 after streaming file data. +// +// If buildTime is non-zero it sets the EROFS image build time (used to seed +// per-entry mtime defaulting and recorded in the superblock), making the image +// reproducible. +func writeERofs(ctx context.Context, out io.WriteSeeker, fsys apkfs.FullFS, buildTime time.Time) error { + ctx, span := otel.Tracer("apko").Start(ctx, "writeERofs") + defer span.End() + + var createOpts []erofs.CreateOpt + if !buildTime.IsZero() { + createOpts = append(createOpts, erofs.WithBuildTime(uint64(buildTime.Unix()), uint32(buildTime.Nanosecond()))) + } + w := erofs.Create(out, createOpts...) + + buf := make([]byte, 1<<20) + + if err := fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error { + if cerr := ctx.Err(); cerr != nil { + return cerr + } + if err != nil { + return err + } + info, err := d.Info() + if err != nil { + return fmt.Errorf("stat %s: %w", path, err) + } + return emitErofsEntry(w, erofsAbsPath(path), path, info, fsys, buf) + }); err != nil { + return err + } + + if err := w.Close(); err != nil { + return fmt.Errorf("finalizing erofs image: %w", err) + } + return nil +} + +// erofsAbsPath maps an fs.WalkDir-style path (rooted at ".") to the +// absolute path the EROFS writer expects ("/"). +func erofsAbsPath(path string) string { + if path == "." { + return "/" + } + return "/" + path +} + +// emitErofsEntry creates one filesystem object in w. absPath is the writer +// path ("/foo/bar"), fsysPath is the source path ("foo/bar") used to look up +// secondary metadata (symlink target, devnode, xattrs, file data). buf is a +// reusable copy buffer for regular file data. +func emitErofsEntry(w *erofs.Writer, absPath, fsysPath string, info fs.FileInfo, fsys apkfs.FullFS, buf []byte) error { + mode := info.Mode() + + switch { + case mode&fs.ModeSymlink != 0: + target, err := fsys.Readlink(fsysPath) + if err != nil { + return fmt.Errorf("readlink %s: %w", fsysPath, err) + } + if err := w.Symlink(target, absPath); err != nil { + return fmt.Errorf("symlink %s -> %s: %w", absPath, target, err) + } + case mode.IsDir(): + // The root directory ("/") already exists; just sync its metadata. + if absPath != "/" { + if err := w.Mkdir(absPath, mode.Perm()); err != nil { + return fmt.Errorf("mkdir %s: %w", absPath, err) + } + } else if err := w.Chmod(absPath, mode.Perm()); err != nil { + return fmt.Errorf("chmod %s: %w", absPath, err) + } + case mode&fs.ModeDevice != 0, mode&fs.ModeCharDevice != 0, mode&fs.ModeNamedPipe != 0, mode&fs.ModeSocket != 0: + var typeBits uint16 + switch { + case mode&fs.ModeCharDevice != 0: + typeBits = unix.S_IFCHR + case mode&fs.ModeDevice != 0: + typeBits = unix.S_IFBLK + case mode&fs.ModeNamedPipe != 0: + typeBits = unix.S_IFIFO + case mode&fs.ModeSocket != 0: + typeBits = unix.S_IFSOCK + } + var rdev uint32 + if mode&(fs.ModeDevice|fs.ModeCharDevice) != 0 { + dev, err := fsys.Readnod(fsysPath) + if err != nil { + return fmt.Errorf("readnod %s: %w", fsysPath, err) + } + rdev = uint32(dev) + } + if err := w.Mknod(absPath, typeBits|uint16(mode.Perm()), rdev); err != nil { + return fmt.Errorf("mknod %s: %w", absPath, err) + } + case mode.IsRegular(): + fout, err := w.Create(absPath) + if err != nil { + return fmt.Errorf("create %s: %w", absPath, err) + } + if info.Size() > 0 { + src, err := fsys.Open(fsysPath) + if err != nil { + _ = fout.Close() + return fmt.Errorf("open %s: %w", fsysPath, err) + } + _, copyErr := io.CopyBuffer(fout, src, buf) + closeErr := src.Close() + if copyErr != nil { + _ = fout.Close() + return fmt.Errorf("copy %s: %w", fsysPath, copyErr) + } + if closeErr != nil { + _ = fout.Close() + return fmt.Errorf("close source %s: %w", fsysPath, closeErr) + } + } + if err := fout.Close(); err != nil { + return fmt.Errorf("close %s: %w", absPath, err) + } + if err := w.Chmod(absPath, mode.Perm()); err != nil { + return fmt.Errorf("chmod %s: %w", absPath, err) + } + default: + return fmt.Errorf("unsupported file mode for %s: %v", absPath, mode) + } + + uid, gid := uidGidFromInfo(info) + if err := w.Chown(absPath, uid, gid); err != nil { + return fmt.Errorf("chown %s: %w", absPath, err) + } + + if mt := info.ModTime(); !mt.IsZero() { + if err := w.Chtimes(absPath, time.Time{}, mt); err != nil { + return fmt.Errorf("chtimes %s: %w", absPath, err) + } + } + + if mode.IsRegular() || mode.IsDir() { + xattrs, _ := fsys.ListXattrs(fsysPath) + for name, value := range xattrs { + if err := w.Setxattr(absPath, name, string(value)); err != nil { + return fmt.Errorf("setxattr %s %s: %w", absPath, name, err) + } + } + } + + return nil +} + +// uidGidFromInfo extracts numeric uid/gid from a FileInfo. apko's apkfs +// implementations all stash these in a *tar.Header returned by Sys(); any +// other shape (or nil) falls back to root. +func uidGidFromInfo(info fs.FileInfo) (int, int) { + if h, ok := info.Sys().(*tar.Header); ok { + return h.Uid, h.Gid + } + return 0, 0 +} + +// newErofsLayerFile creates a temp file backing a single EROFS layer. The +// caller is responsible for closing and removing it. Permissions are 0600 to +// keep intermediate build artifacts off other users' eyes. +func newErofsLayerFile(tmpdir, pattern string) (*os.File, error) { + if pattern == "" { + pattern = "apko-erofs-*.bin" + } + f, err := os.CreateTemp(tmpdir, pattern) + if err != nil { + return nil, err + } + if err := f.Chmod(0o600); err != nil { + _ = f.Close() + _ = os.Remove(f.Name()) + return nil, err + } + return f, nil +} + +// buildErofsLayerFromFile takes a finalized EROFS image already serialized to +// path and returns a v1.Layer wrapping it. For the raw `application/vnd.erofs` +// media type the DiffID and Digest are identical: the SHA-256 of the on-wire +// blob bytes (per spec §5.2). annotations, when non-empty, are surfaced on the +// layer's descriptor via the LayerAnnotations() accessor. +func buildErofsLayerFromFile(path string, annotations map[string]string) (v1.Layer, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + + h := sha256.New() + size, err := io.Copy(h, f) + if err != nil { + return nil, fmt.Errorf("hashing erofs layer %s: %w", path, err) + } + + hash := v1.Hash{ + Algorithm: "sha256", + Hex: hex.EncodeToString(h.Sum(make([]byte, 0, h.Size()))), + } + + return &erofsLayer{ + path: path, + hash: hash, + size: size, + annotations: annotations, + }, nil +} + +// erofsLayer implements v1.Layer for raw, uncompressed EROFS blobs. Digest and +// DiffID are equal; Compressed and Uncompressed return the same bytes. +type erofsLayer struct { + path string + hash v1.Hash + size int64 + annotations map[string]string +} + +// LayerPath returns the on-disk path of this layer's payload. Used by callers +// that need to copy the file (e.g. into an OCI layout). +func (l *erofsLayer) LayerPath() string { return l.path } + +// LayerAnnotations returns annotations to apply to this layer's manifest +// descriptor. apko/oci consults this via an opt-in interface assertion. +func (l *erofsLayer) LayerAnnotations() map[string]string { return l.annotations } + +func (l *erofsLayer) DiffID() (v1.Hash, error) { return l.hash, nil } +func (l *erofsLayer) Digest() (v1.Hash, error) { return l.hash, nil } +func (l *erofsLayer) Size() (int64, error) { return l.size, nil } +func (l *erofsLayer) MediaType() (v1types.MediaType, error) { + return v1types.MediaType(erofsLayerMediaType), nil +} + +func (l *erofsLayer) Uncompressed() (io.ReadCloser, error) { return os.Open(l.path) } +func (l *erofsLayer) Compressed() (io.ReadCloser, error) { return os.Open(l.path) } diff --git a/pkg/build/erofs_layers.go b/pkg/build/erofs_layers.go new file mode 100644 index 000000000..3024b0c1d --- /dev/null +++ b/pkg/build/erofs_layers.go @@ -0,0 +1,284 @@ +// Copyright 2026 Chainguard, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package build + +import ( + "bytes" + "context" + "fmt" + "io/fs" + "path" + "strings" + "time" + + erofs "github.com/erofs/go-erofs" + v1 "github.com/google/go-containerregistry/pkg/v1" + + "chainguard.dev/apko/pkg/apk/apk" + apkfs "chainguard.dev/apko/pkg/apk/fs" +) + +// splitErofsLayers is the EROFS analogue of splitLayers. It walks fsys once, +// emitting each entry into the per-package group writer that owns it (or the +// top writer for unowned entries). Each group becomes one EROFS layer tagged +// with role=overlay-lower per the draft erofs/erofs-image-spec §3.8; the top +// (final) layer carries no role per the same rule. +// +// Unlike tar's flat-stream model, EROFS images carry an inode table, so we +// keep per-writer state for which directories have already been emitted and +// only emit each directory once per writer. The fs.WalkDir guarantee that +// ancestors are visited before descendants lets us record directory metadata +// the first time we see it and reuse it when a child needs the ancestor to +// exist in a writer. +func splitErofsLayers(ctx context.Context, fsys apkfs.FullFS, groups []*group, pkgToDiff map[*apk.Package][]byte, tmpdir string, buildTime time.Time) ([]v1.Layer, error) { + buf := make([]byte, 1<<20) + + type erofsGroupWriter struct { + path string + w *erofs.Writer + closer func() error + emitted map[string]bool // absPath -> already emitted into this writer + pkgs map[string]bool // package names owned by this group + } + + newWriter := func() (*erofsGroupWriter, error) { + f, err := newErofsLayerFile(tmpdir, "apko-erofs-*.bin") + if err != nil { + return nil, err + } + var createOpts []erofs.CreateOpt + if !buildTime.IsZero() { + createOpts = append(createOpts, erofs.WithBuildTime(uint64(buildTime.Unix()), uint32(buildTime.Nanosecond()))) + } + gw := &erofsGroupWriter{ + path: f.Name(), + w: erofs.Create(f, createOpts...), + emitted: map[string]bool{}, + pkgs: map[string]bool{}, + } + // Close the file only after closing the erofs writer (which may seek + // back to rewrite the superblock). + gw.closer = func() error { + if err := gw.w.Close(); err != nil { + _ = f.Close() + return fmt.Errorf("finalizing erofs image %s: %w", f.Name(), err) + } + return f.Close() + } + return gw, nil + } + + // One writer per group, plus a top writer for entries not owned by any + // package. + packageToWriter := map[string]*erofsGroupWriter{} + groupToWriter := map[*group]*erofsGroupWriter{} + writers := make([]*erofsGroupWriter, 0, len(groups)+1) + + for _, g := range groups { + gw, err := newWriter() + if err != nil { + return nil, err + } + writers = append(writers, gw) + groupToWriter[g] = gw + for _, pkg := range g.pkgs { + packageToWriter[pkg.Name] = gw + gw.pkgs[pkg.Name] = true + } + } + top, err := newWriter() + if err != nil { + return nil, err + } + writers = append(writers, top) + + // Record dir metadata as we go so we can recreate ancestors in any + // writer that needs them. Keyed by the absolute (writer-side) path. + dirInfo := map[string]fs.FileInfo{} + dirFsysPath := map[string]string{} // absPath -> source path (for xattr lookup) + + // emitAncestors makes sure every ancestor of absPath (excluding "/" and + // absPath itself) has been created in gw with the correct metadata. + emitAncestors := func(gw *erofsGroupWriter, absPath string) error { + if absPath == "/" { + return nil + } + // Build the list of ancestor paths from shallowest to deepest. + var parts []string + p := path.Dir(absPath) + for p != "/" && p != "." { + parts = append([]string{p}, parts...) + p = path.Dir(p) + } + for _, anc := range parts { + if gw.emitted[anc] { + continue + } + info, ok := dirInfo[anc] + if !ok { + // Defensive: should never happen with fs.WalkDir ordering. + if err := gw.w.Mkdir(anc, 0o755); err != nil { + return fmt.Errorf("mkdir ancestor %s: %w", anc, err) + } + gw.emitted[anc] = true + continue + } + if err := emitErofsEntry(gw.w, anc, dirFsysPath[anc], info, fsys, buf); err != nil { + return fmt.Errorf("emit ancestor %s: %w", anc, err) + } + gw.emitted[anc] = true + } + return nil + } + + if err := fs.WalkDir(fsys, ".", func(fpath string, d fs.DirEntry, err error) error { + if cerr := ctx.Err(); cerr != nil { + return cerr + } + if err != nil { + return err + } + + absPath := erofsAbsPath(fpath) + info, err := d.Info() + if err != nil { + return fmt.Errorf("stat %s: %w", fpath, err) + } + + if d.IsDir() { + // Record metadata; don't emit yet. Each writer creates this + // directory lazily the first time it needs to write something + // beneath it. + if absPath == "/" { + // The root of every EROFS image exists implicitly; still set + // its metadata across all writers (so uid/gid/xattrs match + // the source rootfs). + for _, gw := range writers { + if err := emitErofsEntry(gw.w, absPath, fpath, info, fsys, buf); err != nil { + return err + } + gw.emitted[absPath] = true + } + return nil + } + dirInfo[absPath] = info + dirFsysPath[absPath] = fpath + return nil + } + + // Default to the top layer. + owner := top + + // If the file info exposes its owning package, route to that group. + if pkger, ok := info.Sys().(interface { + Package() *apk.Package + }); ok { + if pkg := pkger.Package(); pkg != nil { + if gw, ok := packageToWriter[pkg.Name]; ok { + owner = gw + } + } + } + + // Special-case the apk installed db: each group also gets a partial + // installed db containing only its own packages, so per-layer + // scanners (Trivy, Snyk, etc.) can identify the layer's contents. + // This matches splitLayers' behavior for tar layers. + if strings.TrimPrefix(absPath, "/") == "usr/lib/apk/db/installed" { + for _, g := range groups { + gw := groupToWriter[g] + if err := emitAncestors(gw, absPath); err != nil { + return err + } + var idb bytes.Buffer + for _, pkg := range g.pkgs { + if _, err := idb.Write(pkgToDiff[pkg]); err != nil { + return err + } + } + if err := writeErofsRegularBytes(gw.w, absPath, info, idb.Bytes()); err != nil { + return err + } + gw.emitted[absPath] = true + } + // The top layer also gets the full installed db via the normal + // path below. + } + + if err := emitAncestors(owner, absPath); err != nil { + return err + } + if err := emitErofsEntry(owner.w, absPath, fpath, info, fsys, buf); err != nil { + return err + } + owner.emitted[absPath] = true + return nil + }); err != nil { + return nil, err + } + + // Finalize each writer and produce v1.Layer values. + layers := make([]v1.Layer, 0, len(writers)) + for i, gw := range writers { + if err := gw.closer(); err != nil { + return nil, err + } + // All layers except the final (top) carry role=overlay-lower per + // spec §3.8 rule 1. The final layer carries no role. + var anns map[string]string + if i < len(writers)-1 { + anns = map[string]string{erofsRoleAnnotation: erofsRoleOverlay} + } + l, err := buildErofsLayerFromFile(gw.path, anns) + if err != nil { + return nil, fmt.Errorf("finalizing erofs layer %d: %w", i, err) + } + layers = append(layers, l) + } + return layers, nil +} + +// writeErofsRegularBytes writes a regular file with the given content into w +// at absPath, copying mode/uid/gid/mtime from info. xattrs are *not* copied +// because the per-group installed db is a synthesized payload, not a +// faithful copy of the source file. +func writeErofsRegularBytes(w *erofs.Writer, absPath string, info fs.FileInfo, data []byte) error { + fout, err := w.Create(absPath) + if err != nil { + return fmt.Errorf("create %s: %w", absPath, err) + } + if len(data) > 0 { + if _, err := fout.Write(data); err != nil { + _ = fout.Close() + return fmt.Errorf("write %s: %w", absPath, err) + } + } + if err := fout.Close(); err != nil { + return fmt.Errorf("close %s: %w", absPath, err) + } + if err := w.Chmod(absPath, info.Mode().Perm()); err != nil { + return fmt.Errorf("chmod %s: %w", absPath, err) + } + uid, gid := uidGidFromInfo(info) + if err := w.Chown(absPath, uid, gid); err != nil { + return fmt.Errorf("chown %s: %w", absPath, err) + } + if mt := info.ModTime(); !mt.IsZero() { + if err := w.Chtimes(absPath, time.Time{}, mt); err != nil { + return fmt.Errorf("chtimes %s: %w", absPath, err) + } + } + return nil +} diff --git a/pkg/build/erofs_test.go b/pkg/build/erofs_test.go new file mode 100644 index 000000000..8bc946f08 --- /dev/null +++ b/pkg/build/erofs_test.go @@ -0,0 +1,306 @@ +// Copyright 2026 Chainguard, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package build + +import ( + "bytes" + "context" + "io/fs" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + erofs "github.com/erofs/go-erofs" + v1types "github.com/google/go-containerregistry/pkg/v1/types" + "github.com/stretchr/testify/require" + + "chainguard.dev/apko/pkg/apk/apk" + apkfs "chainguard.dev/apko/pkg/apk/fs" + "chainguard.dev/apko/pkg/build/types" + "chainguard.dev/apko/pkg/options" +) + +// epoch is a fixed timestamp used by reproducibility-sensitive tests so the +// recorded mtime never depends on wall-clock state. +var epoch = time.Unix(1700000000, 0).UTC() + +func seedFS(t *testing.T) apkfs.FullFS { + t.Helper() + m := apkfs.NewMemFS() + require.NoError(t, m.MkdirAll("a", 0o755)) + require.NoError(t, m.WriteFile("a/b", []byte("hello world"), 0o644)) + require.NoError(t, m.Symlink("b", "a/link")) + require.NoError(t, m.SetXattr("a", "user.dir", []byte("foo"))) + require.NoError(t, m.SetXattr("a/b", "user.file", []byte("bar"))) + // stamp known mtimes so the image is reproducible + require.NoError(t, m.Chtimes("a", epoch, epoch)) + require.NoError(t, m.Chtimes("a/b", epoch, epoch)) + return m +} + +func TestWriteERofs_Roundtrip(t *testing.T) { + m := seedFS(t) + + out := filepath.Join(t.TempDir(), "image.erofs") + f, err := os.Create(out) + require.NoError(t, err) + t.Cleanup(func() { _ = f.Close() }) + + require.NoError(t, writeERofs(context.Background(), f, m, epoch)) + require.NoError(t, f.Close()) + + r, err := os.Open(out) + require.NoError(t, err) + defer r.Close() + + img, err := erofs.Open(r) + require.NoError(t, err) + + // Walk the resulting image and collect what's in it. + got := map[string]fs.FileInfo{} + require.NoError(t, fs.WalkDir(img, ".", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + info, err := d.Info() + if err != nil { + return err + } + got[path] = info + return nil + })) + + require.Contains(t, got, "a", "directory a missing from image") + require.Contains(t, got, "a/b", "file a/b missing from image") + require.Contains(t, got, "a/link", "symlink a/link missing from image") + + // File content + data, err := fs.ReadFile(img, "a/b") + require.NoError(t, err) + require.Equal(t, "hello world", string(data)) + + // Xattrs (via the accessor interface advertised by erofs.Stat) + dirX, ok := got["a"].Sys().(*erofs.Stat) + require.True(t, ok, "expected *erofs.Stat on dir Sys()") + require.Equal(t, "foo", dirX.Xattrs["user.dir"]) + + fileX := got["a/b"].Sys().(*erofs.Stat) + require.Equal(t, "bar", fileX.Xattrs["user.file"]) + + // Symlink target — readable via the image's ReadLink method. + rl, ok := img.(interface { + ReadLink(string) (string, error) + }) + require.True(t, ok, "image does not implement ReadLink") + target, err := rl.ReadLink("a/link") + require.NoError(t, err) + require.Equal(t, "b", target) +} + +// TestImageLayoutToLayer_Erofs exercises ImageLayoutToLayer end-to-end via a +// hand-rolled Context. It confirms the layer returned advertises the erofs +// media type and that DiffID == Digest (raw EROFS has no compression step). +func TestImageLayoutToLayer_Erofs(t *testing.T) { + m := seedFS(t) + // checkPaths warns about missing /etc/passwd, /etc/group, /etc/os-release; + // satisfy those so we get clean test logs. + require.NoError(t, m.MkdirAll("etc", 0o755)) + require.NoError(t, m.WriteFile("etc/passwd", []byte("root:x:0:0:root:/root:/bin/sh\n"), 0o644)) + require.NoError(t, m.WriteFile("etc/group", []byte("root:x:0:root\n"), 0o644)) + require.NoError(t, m.WriteFile("etc/os-release", []byte("ID=test\n"), 0o644)) + + tmp := t.TempDir() + bc := &Context{ + ic: types.ImageConfiguration{Format: types.LayerFormatErofs}, + o: options.Options{ + TempDirPath: tmp, + SourceDateEpoch: epoch, + }, + fs: m, + } + + path, layer, err := bc.ImageLayoutToLayer(context.Background()) + require.NoError(t, err) + + mt, err := layer.MediaType() + require.NoError(t, err) + require.Equal(t, v1types.MediaType("application/vnd.erofs"), mt) + + digest, err := layer.Digest() + require.NoError(t, err) + diffID, err := layer.DiffID() + require.NoError(t, err) + require.Equal(t, digest, diffID, "raw EROFS: Digest must equal DiffID") + + // Confirm the on-disk artifact really is an EROFS image. + f, err := os.Open(path) + require.NoError(t, err) + defer f.Close() + _, err = erofs.Open(f) + require.NoError(t, err) +} + +// TestWriteERofs_FsckErofs validates a generated image with the C reference +// tool from erofs-utils. The test is skipped when fsck.erofs is not on PATH so +// contributors without erofs-utils installed still get a green build. CI +// images that include erofs-utils will actually exercise this path. +func TestWriteERofs_FsckErofs(t *testing.T) { + fsckBin, err := exec.LookPath("fsck.erofs") + if err != nil { + t.Skip("fsck.erofs not found in PATH; install erofs-utils to run this test") + } + + m := seedFS(t) + out := filepath.Join(t.TempDir(), "image.erofs") + f, err := os.Create(out) + require.NoError(t, err) + require.NoError(t, writeERofs(context.Background(), f, m, epoch)) + require.NoError(t, f.Close()) + + // Plain integrity check: superblock CRC, layout, all reachable inodes. + cmd := exec.Command(fsckBin, "-d3", out) + output, err := cmd.CombinedOutput() + require.NoError(t, err, "fsck.erofs reported a malformed image:\n%s", output) + + // Full content extraction with xattr verification. This walks every + // inode, decompresses any data, and writes files to disk — a stronger + // signal than the integrity check alone. + extractDir := t.TempDir() + cmd = exec.Command(fsckBin, "--extract="+extractDir, "--xattrs", "--force", out) + output, err = cmd.CombinedOutput() + require.NoError(t, err, "fsck.erofs --extract failed:\n%s", output) + + // Sanity-check the extracted content actually matches what we put in. + data, err := os.ReadFile(filepath.Join(extractDir, "a", "b")) + require.NoError(t, err) + require.Equal(t, "hello world", string(data)) + + target, err := os.Readlink(filepath.Join(extractDir, "a", "link")) + require.NoError(t, err) + require.Equal(t, "b", target) +} + +func TestSplitErofsLayers(t *testing.T) { + fsys := apkfs.NewMemFS() + require.NoError(t, fsys.MkdirAll("usr/lib/apk/db", 0o755)) + require.NoError(t, fsys.WriteFile("usr/lib/apk/db/installed", []byte("idb top\n"), 0o644)) + require.NoError(t, fsys.MkdirAll("etc", 0o755)) + require.NoError(t, fsys.WriteFile("etc/hello", []byte("hi\n"), 0o644)) + + pkg1 := newPkg("pkg1") + pkg2 := newPkg("pkg2") + groups := []*group{ + {pkgs: []*apk.Package{pkg1}, size: 1000, tiebreaker: "pkg1"}, + {pkgs: []*apk.Package{pkg2}, size: 2000, tiebreaker: "pkg2"}, + } + pkgToDiff := map[*apk.Package][]byte{ + pkg1: []byte("pkg1 info\n"), + pkg2: []byte("pkg2 info\n"), + } + + layers, err := splitErofsLayers(context.Background(), fsys, groups, pkgToDiff, t.TempDir(), epoch) + require.NoError(t, err) + require.Len(t, layers, 3, "expected 2 group layers + 1 top layer") + + // All three layers should be valid EROFS images. + fsckBin, _ := lookFsckErofs() + for i, l := range layers { + erl, ok := l.(*erofsLayer) + require.True(t, ok, "layer[%d] not *erofsLayer", i) + + mt, err := l.MediaType() + require.NoError(t, err) + require.Equal(t, "application/vnd.erofs", string(mt)) + + // Layer roles: overlay-lower on the package layers, absent on the top. + anns := erl.LayerAnnotations() + if i < len(layers)-1 { + require.Equal(t, "overlay-lower", anns[erofsRoleAnnotation], "layer[%d] missing overlay-lower role", i) + } else { + require.Empty(t, anns, "top layer must carry no role annotation") + } + + // The image must parse via go-erofs. + f, err := os.Open(erl.path) + require.NoError(t, err) + _, err = erofs.Open(f) + _ = f.Close() + require.NoError(t, err, "layer[%d] is not a valid EROFS image", i) + + if fsckBin != "" { + cmd := exec.Command(fsckBin, "-d3", erl.path) + out, err := cmd.CombinedOutput() + require.NoError(t, err, "fsck.erofs rejected layer[%d]:\n%s", i, out) + } + } + + // The package layers must each carry their own partial installed db; the + // top layer carries the source file via the normal path. + for i, l := range layers[:2] { + erl := l.(*erofsLayer) + f, err := os.Open(erl.path) + require.NoError(t, err) + img, err := erofs.Open(f) + require.NoError(t, err) + data, err := fs.ReadFile(img, "usr/lib/apk/db/installed") + require.NoError(t, err, "layer[%d] missing per-group installed db", i) + require.NotEmpty(t, data, "layer[%d] installed db must not be empty", i) + _ = f.Close() + } + + // The top layer should hold etc/hello (unowned content) and the original + // installed db. + topL := layers[len(layers)-1].(*erofsLayer) + tf, err := os.Open(topL.path) + require.NoError(t, err) + defer tf.Close() + topImg, err := erofs.Open(tf) + require.NoError(t, err) + hello, err := fs.ReadFile(topImg, "etc/hello") + require.NoError(t, err) + require.Equal(t, "hi\n", string(hello)) + topIdb, err := fs.ReadFile(topImg, "usr/lib/apk/db/installed") + require.NoError(t, err) + require.Equal(t, "idb top\n", string(topIdb)) +} + +func newPkg(name string) *apk.Package { + return &apk.Package{Name: name, Origin: name, Version: "1.0.0", InstalledSize: 1024} +} + +func lookFsckErofs() (string, error) { + return exec.LookPath("fsck.erofs") +} + +func TestWriteERofs_Reproducible(t *testing.T) { + build := func(path string) []byte { + m := seedFS(t) + f, err := os.Create(path) + require.NoError(t, err) + require.NoError(t, writeERofs(context.Background(), f, m, epoch)) + require.NoError(t, f.Close()) + data, err := os.ReadFile(path) + require.NoError(t, err) + return data + } + + tmp := t.TempDir() + a := build(filepath.Join(tmp, "a.erofs")) + b := build(filepath.Join(tmp, "b.erofs")) + require.Equal(t, len(a), len(b), "image sizes differ between identical builds") + require.True(t, bytes.Equal(a, b), "two identical builds produced byte-different images") +} diff --git a/pkg/build/layers.go b/pkg/build/layers.go index 91c2692f9..7c50ee86a 100644 --- a/pkg/build/layers.go +++ b/pkg/build/layers.go @@ -28,6 +28,7 @@ import ( "chainguard.dev/apko/pkg/apk/apk" apkfs "chainguard.dev/apko/pkg/apk/fs" + "chainguard.dev/apko/pkg/build/types" "github.com/chainguard-dev/clog" v1 "github.com/google/go-containerregistry/pkg/v1" @@ -85,6 +86,9 @@ func (bc *Context) buildLayers(ctx context.Context) ([]v1.Layer, error) { } // Then partition that single fs.FS into multiple layers based on our layering strategy. + if bc.ic.Format.Resolved() == types.LayerFormatErofs { + return splitErofsLayers(ctx, bc.fs, groups, pkgToDiff, bc.o.TempDir(), bc.o.SourceDateEpoch) + } return splitLayers(ctx, bc.fs, groups, pkgToDiff, bc.o.TempDir()) } diff --git a/pkg/build/oci/image.go b/pkg/build/oci/image.go index 7a1583e51..b68f06590 100644 --- a/pkg/build/oci/image.go +++ b/pkg/build/oci/image.go @@ -19,6 +19,7 @@ import ( "errors" "fmt" "maps" + "slices" "sort" "strings" "time" @@ -76,7 +77,7 @@ func BuildImageFromLayers(ctx context.Context, baseImage v1.Image, layers []v1.L log.Infof("layer digest: %v", digest) log.Infof("layer diffID: %v", diffid) - adds = append(adds, mutate.Addendum{ + add := mutate.Addendum{ Layer: layer, History: v1.History{ Author: "apko", @@ -84,7 +85,16 @@ func BuildImageFromLayers(ctx context.Context, baseImage v1.Image, layers []v1.L CreatedBy: "apko", Created: v1.Time{Time: created}, // TODO: Consider per-layer creation time? }, - }) + } + // Layers built by apko may carry descriptor-level annotations (for + // example, EROFS layers tag their composition role per the draft + // erofs/erofs-image-spec). + if a, ok := layer.(interface{ LayerAnnotations() map[string]string }); ok { + if anns := a.LayerAnnotations(); len(anns) > 0 { + add.Annotations = anns + } + } + adds = append(adds, add) } // If building an OCI layer, then we should assume OCI manifest and config too @@ -184,6 +194,15 @@ func BuildImageFromLayers(ctx context.Context, baseImage v1.Image, layers []v1.L cfg.Config.StopSignal = ic.StopSignal } + // Signal EROFS-bearing manifests via os.features per + // erofs/erofs-image-spec §5.4 so hosts that don't implement the spec can + // identify and skip them without parsing layer bytes. + if ic.Format.Resolved() == types.LayerFormatErofs { + if !slices.Contains(cfg.OSFeatures, "erofs") { + cfg.OSFeatures = append(cfg.OSFeatures, "erofs") + } + } + img, err := mutate.ConfigFile(v1Image, cfg) if err != nil { return nil, fmt.Errorf("unable to update oci config file: %w", err) diff --git a/pkg/build/oci/image_test.go b/pkg/build/oci/image_test.go index 3d25aa7ee..28ab43061 100644 --- a/pkg/build/oci/image_test.go +++ b/pkg/build/oci/image_test.go @@ -169,3 +169,25 @@ func TestBuildImageFromLayer(t *testing.T) { }) } } + +func TestBuildImageFromLayer_ErofsOSFeatures(t *testing.T) { + layer := static.NewLayer([]byte("hello"), ggcrtypes.MediaType("application/vnd.erofs")) + ctx := context.Background() + now := time.Now() + + ic := types.ImageConfiguration{Format: types.LayerFormatErofs} + img, err := BuildImageFromLayer(ctx, empty.Image, layer, ic, now, types.ParseArchitecture("")) + require.NoError(t, err) + + cfg, err := img.ConfigFile() + require.NoError(t, err) + require.Contains(t, cfg.OSFeatures, "erofs", "expected os.features to include erofs for EROFS-format builds") + + // Default (tar) format must not advertise erofs. + tarLayer := static.NewLayer([]byte("hello"), ggcrtypes.OCILayer) + img2, err := BuildImageFromLayer(ctx, empty.Image, tarLayer, types.ImageConfiguration{}, now, types.ParseArchitecture("")) + require.NoError(t, err) + cfg2, err := img2.ConfigFile() + require.NoError(t, err) + require.NotContains(t, cfg2.OSFeatures, "erofs", "tar-format builds must not declare erofs in os.features") +} diff --git a/pkg/build/options.go b/pkg/build/options.go index 4b1f1406c..ad5b42501 100644 --- a/pkg/build/options.go +++ b/pkg/build/options.go @@ -74,6 +74,23 @@ func WithTarball(path string) Option { } } +// WithFormat sets the layer payload format ("tar" or "erofs"). The value +// overrides any format declared in the image configuration. Empty means +// "leave the configured value alone". +func WithFormat(format string) Option { + return func(bc *Context) error { + if format == "" { + return nil + } + f := types.LayerFormat(format) + if !f.Valid() { + return fmt.Errorf("invalid --format %q (must be %q or %q)", format, types.LayerFormatTar, types.LayerFormatErofs) + } + bc.ic.Format = f + return nil + } +} + // WithBuildDate sets the timestamps for the build context. // The string is parsed according to RFC3339. // An empty string is a special case and will default to diff --git a/pkg/build/types/image_configuration.go b/pkg/build/types/image_configuration.go index 96c4ef877..e03e9288e 100644 --- a/pkg/build/types/image_configuration.go +++ b/pkg/build/types/image_configuration.go @@ -128,6 +128,9 @@ func (ic *ImageConfiguration) MergeInto(target *ImageConfiguration) error { if target.Layering == nil { target.Layering = ic.Layering } + if target.Format == "" { + target.Format = ic.Format + } if target.Certificates == nil { target.Certificates = ic.Certificates } @@ -237,6 +240,10 @@ func (ic *ImageConfiguration) Validate() error { } } + if ic.Format != "" && !ic.Format.Valid() { + return fmt.Errorf("invalid layer format %q (must be %q or %q)", ic.Format, LayerFormatTar, LayerFormatErofs) + } + if ic.Certificates != nil { for _, additional := range ic.Certificates.Additional { if additional.Name == "" { diff --git a/pkg/build/types/schema.json b/pkg/build/types/schema.json index 7b6dfb5e5..d8d754d01 100644 --- a/pkg/build/types/schema.json +++ b/pkg/build/types/schema.json @@ -169,6 +169,10 @@ "$ref": "#/$defs/Layering", "description": "Optional: Configuration to control layering of the OCI image." }, + "format": { + "type": "string", + "description": "Optional: Layer payload format. One of \"tar\" (default) or \"erofs\".\n\"erofs\" is experimental and tracks the draft erofs/erofs-image-spec." + }, "certificates": { "$ref": "#/$defs/ImageCertificates", "description": "Optional: Certificates to install in the container image" diff --git a/pkg/build/types/types.go b/pkg/build/types/types.go index 89f2c844c..71e9e5402 100644 --- a/pkg/build/types/types.go +++ b/pkg/build/types/types.go @@ -234,10 +234,42 @@ type ImageConfiguration struct { // Optional: Configuration to control layering of the OCI image. Layering *Layering `json:"layering,omitempty" yaml:"layering,omitempty"` + // Optional: Layer payload format. One of "tar" (default) or "erofs". + // "erofs" is experimental and tracks the draft erofs/erofs-image-spec. + Format LayerFormat `json:"format,omitempty" yaml:"format,omitempty"` + // Optional: Certificates to install in the container image Certificates *ImageCertificates `json:"certificates,omitempty" yaml:"certificates,omitempty"` } +// LayerFormat selects the on-wire layer payload format. +type LayerFormat string + +const ( + // LayerFormatTar produces gzip-compressed tar layers (OCI/Docker default). + LayerFormatTar LayerFormat = "tar" + // LayerFormatErofs produces uncompressed EROFS filesystem layers per the + // draft erofs/erofs-image-spec. + LayerFormatErofs LayerFormat = "erofs" +) + +// Resolved returns the format with the empty default coerced to LayerFormatTar. +func (f LayerFormat) Resolved() LayerFormat { + if f == "" { + return LayerFormatTar + } + return f +} + +// Valid reports whether f is a recognized layer format. +func (f LayerFormat) Valid() bool { + switch f.Resolved() { + case LayerFormatTar, LayerFormatErofs: + return true + } + return false +} + // Architecture represents a CPU architecture for the container image. // TODO(kaniini): Maybe this should be its own package at this point? type Architecture string From caff1a51e1643b5d1a2cd5f44fdb78a33eba4707 Mon Sep 17 00:00:00 2001 From: Scott Moser Date: Tue, 26 May 2026 15:15:57 -0400 Subject: [PATCH 02/33] docs: add EROFS build and verification walkthrough Step-by-step guide for producing EROFS images with --format=erofs, inspecting the layer blob without root (fsck.erofs / dump.erofs / fsck.erofs --extract), mounting it (kernel mount or erofsfuse), pulling layer blobs from a registry, and assembling multi-layer images via overlayfs. Includes the current limitations (no +zstd, dm-verity, chunk index) and links from apko_file.md. All commands shown were verified against a real `apko build` of examples/wolfi-base.yaml. Co-Authored-By: Claude Opus 4.7 --- docs/apko_file.md | 2 + docs/erofs.md | 289 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 291 insertions(+) create mode 100644 docs/erofs.md diff --git a/docs/apko_file.md b/docs/apko_file.md index fe8c7f239..91e2c88e1 100644 --- a/docs/apko_file.md +++ b/docs/apko_file.md @@ -285,3 +285,5 @@ See [layering.md](layering.md) for more information. `erofs` may also be selected on the command line with `--format=erofs` on `apko build` and `apko publish`. The CLI flag overrides whatever is in the config file. **Status:** EROFS support is experimental and tracks the spec PR at https://github.com/erofs/erofs-image-spec/pull/1; media types and annotations may change before the spec reaches a stable release. Both single-layer and multi-layer (`layering`) builds are supported. Multi-layer builds emit each non-final layer with `org.erofs.role=overlay-lower` per spec §3.8; the final layer carries no role. `+zstd` compression and dm-verity are not implemented. + +See [erofs.md](erofs.md) for a step-by-step guide to building, inspecting, mounting, and pulling EROFS images. diff --git a/docs/erofs.md b/docs/erofs.md new file mode 100644 index 000000000..6a8e0a372 --- /dev/null +++ b/docs/erofs.md @@ -0,0 +1,289 @@ +# EROFS Output Format (experimental) + +apko can emit image layers as [EROFS](https://erofs.docs.kernel.org/) filesystem images instead of the default gzip-compressed tar. +The format tracks the draft [erofs/erofs-image-spec](https://github.com/erofs/erofs-image-spec) (PR [#1](https://github.com/erofs/erofs-image-spec/pull/1)). +Until the spec reaches a stable release, the media types, annotations, and layer layout used here may change. + +## Why EROFS? + +- **Mount, don't unpack.** A layer blob is a complete, kernel-mountable read-only filesystem. + You can `mount -t erofs` the layer directly and look at it, without extracting a tarball. +- **Random access.** Container runtimes that consume EROFS images can seek into a layer rather than streaming the whole tar. +- **Designed for sharing.** The spec defines `overlay-lower` and `overlay-data` roles that compose via the kernel's `overlayfs` exactly the way OCI tar layers do. + +This document focuses on producing EROFS images and verifying they look legit using widely available tools. + +## Prerequisites + +To build and inspect EROFS images you need: + +- apko built from a revision that contains EROFS support. +- The `erofs-utils` package, which provides `mkfs.erofs`, `fsck.erofs`, and `dump.erofs`. + apko ships a pure-Go writer (no CGO), so `mkfs.erofs` is not required for *producing* images — but `fsck.erofs` and `dump.erofs` are the easiest way to inspect what apko produced. +- To mount an EROFS layer: either the kernel `erofs` module (present in modern Linux distros) plus root for `mount(8)`, or the unprivileged `erofsfuse` binary from `erofs-utils-fuse`. + +Install on Wolfi / Chainguard / Alpine: + +```sh +sudo apk add erofs-utils # fsck.erofs, dump.erofs, mkfs.erofs +sudo apk add erofs-utils-fuse # erofsfuse (optional, for unprivileged mount) +``` + +Install on Debian / Ubuntu: + +```sh +sudo apt install erofs-utils erofsfuse +``` + +## Single-layer build + +The simplest case: opt into EROFS via the `--format=erofs` flag or `format: erofs` in apko.yaml. + +`erofs-demo.yaml`: + +```yaml +contents: + keyring: + - https://packages.wolfi.dev/os/wolfi-signing.rsa.pub + repositories: + - https://packages.wolfi.dev/os + packages: + - wolfi-base + +cmd: /bin/sh -l +archs: + - host +``` + +Build into an OCI image layout directory: + +```sh +mkdir -p out +apko build erofs-demo.yaml apko-erofs-demo:latest out/ --format=erofs --arch=$(uname -m) +``` + +The OCI layout under `out/` is a regular OCI image directory — the layer blob just happens to be an EROFS filesystem: + +``` +out/ +├── blobs/sha256/ +│ ├── # JSON image config +│ ├── # JSON image manifest +│ └── # raw EROFS filesystem image +├── index.json +└── oci-layout +``` + +### Verify the manifest references EROFS + +```sh +MANIFEST=$(jq -r '.manifests[0].digest | split(":")[1]' out/index.json) +jq . out/blobs/sha256/$MANIFEST +``` + +Expected (excerpt): + +```json +{ + "layers": [ + { + "mediaType": "application/vnd.erofs", + "size": 16207872, + "digest": "sha256:8a2205cc..." + } + ] +} +``` + +The image config records `erofs` in `os.features` per spec §5.4, signalling to tools that don't implement the spec that they should not attempt to apply the layer as a tar: + +```sh +CONFIG=$(jq -r '.config.digest | split(":")[1]' out/blobs/sha256/$MANIFEST) +jq '.["os.features"]' out/blobs/sha256/$CONFIG +# → ["erofs"] +``` + +## Inspect the layer (no mount required) + +The layer blob is a complete EROFS filesystem. You can validate and inspect it without mounting anything. + +### Identify the file + +```sh +LAYER=$(jq -r '.layers[0].digest | split(":")[1]' out/blobs/sha256/$MANIFEST) +file out/blobs/sha256/$LAYER +# → out/blobs/sha256/...: EROFS filesystem, blocksize=12, exslots=0, ... +``` + +### Integrity check + +```sh +fsck.erofs -d3 out/blobs/sha256/$LAYER +# erofs: No errors found +``` + +### Dump the superblock + +```sh +dump.erofs out/blobs/sha256/$LAYER | head -15 +``` + +This prints the on-disk metadata: block size, inode count, build time, UUID, feature flags. + +### Extract without root + +`fsck.erofs --extract` reads every inode and writes the resulting tree to a directory. +This is the strongest unprivileged validation you can run: if the image is malformed, extraction fails; if it succeeds, the file tree on disk is exactly what a kernel mount would expose. + +```sh +mkdir extracted +fsck.erofs --extract=extracted --xattrs --force out/blobs/sha256/$LAYER +ls extracted/ +# bin dev etc home lib ... +cat extracted/etc/os-release +``` + +## Mount the layer + +### Kernel mount (root) + +```sh +sudo mkdir -p /mnt/apko-erofs +sudo mount -t erofs -o loop out/blobs/sha256/$LAYER /mnt/apko-erofs +ls /mnt/apko-erofs/ +file /mnt/apko-erofs/bin/sh +sudo umount /mnt/apko-erofs +``` + +The kernel mount is read-only, zero-copy, and exposes xattrs. +If `mount` reports "unknown filesystem type 'erofs'", the kernel module is missing on your system; install it (e.g. `linux-modules-extra-$(uname -r)` on Ubuntu) or use the FUSE path below. + +### FUSE mount (unprivileged) + +```sh +mkdir -p mnt +erofsfuse out/blobs/sha256/$LAYER mnt/ +ls mnt/ +fusermount -u mnt/ # or `fusermount3 -u mnt/` +``` + +`erofsfuse` does not require root, which makes it convenient on dev machines and inside CI containers that lack the kernel module. + +## Pulling from a registry + +If you push the image with `apko publish` or `crane push`, the registry stores each blob unchanged — including the EROFS layer blob. +Most registry clients can extract layers by digest: + +```sh +# Read the manifest and pull layer blobs. +crane manifest registry.example.com/apko-erofs-demo:latest > manifest.json +LAYER_DIGEST=$(jq -r '.layers[0].digest' manifest.json) +crane blob registry.example.com/apko-erofs-demo:latest@$LAYER_DIGEST > layer.erofs + +file layer.erofs # EROFS filesystem... +fsck.erofs -d3 layer.erofs # erofs: No errors found +``` + +Once you have the blob on disk you can inspect or mount it exactly as in the previous sections. + +## Multi-layer builds + +Combine `format: erofs` with apko's [layering](layering.md) configuration to get one EROFS layer per package group plus a top layer for unowned files. + +`erofs-layered.yaml`: + +```yaml +contents: + keyring: + - https://packages.wolfi.dev/os/wolfi-signing.rsa.pub + repositories: + - https://packages.wolfi.dev/os + packages: + - wolfi-base + +cmd: /bin/sh -l +archs: + - host + +layering: + strategy: origin + budget: 4 + +format: erofs +``` + +```sh +mkdir -p out-layered +apko build erofs-layered.yaml apko-erofs-layered:latest out-layered/ --arch=$(uname -m) +``` + +Inspect the manifest: + +```sh +MANIFEST=$(jq -r '.manifests[0].digest | split(":")[1]' out-layered/index.json) +jq '.layers[] | {mediaType, role: .annotations["org.erofs.role"]}' out-layered/blobs/sha256/$MANIFEST +``` + +Expected (last layer carries no role per spec §3.8 rule 1): + +```json +{ "mediaType": "application/vnd.erofs", "role": "overlay-lower" } +{ "mediaType": "application/vnd.erofs", "role": "overlay-lower" } +{ "mediaType": "application/vnd.erofs", "role": "overlay-lower" } +{ "mediaType": "application/vnd.erofs", "role": "overlay-lower" } +{ "mediaType": "application/vnd.erofs", "role": null } +``` + +Each layer is independently mountable as an EROFS filesystem, and each carries its own partial `usr/lib/apk/db/installed` so per-layer scanners (Trivy, Snyk, Grype) can identify the packages it contributes. + +### Assemble the full rootfs with overlayfs + +The OCI spec composes layers with `overlayfs`-style semantics; for EROFS layers the composition is straightforward. +Mount each layer separately, then stack them with `mount -t overlay`: + +```sh +# Pull each layer blob out of the OCI layout. +ROOT=$(pwd)/out-layered/blobs/sha256 +mkdir -p mnt/{lower0,lower1,lower2,lower3,top,merged,work,upper} + +LAYERS=$(jq -r '.layers[].digest | split(":")[1]' $ROOT/../../blobs/sha256/$MANIFEST) +i=0 +for d in $LAYERS; do + sudo mount -t erofs -o loop "$ROOT/$d" "mnt/lower$i" 2>/dev/null || \ + erofsfuse "$ROOT/$d" "mnt/lower$i" + i=$((i+1)) +done + +# In overlayfs, lowerdirs are listed top-down (highest priority first). +# OCI orders layers bottom-up (index 0 is the base), so reverse the order. +sudo mount -t overlay overlay \ + -o lowerdir=mnt/lower$((i-1)):mnt/lower$((i-2)):mnt/lower1:mnt/lower0,upperdir=mnt/upper,workdir=mnt/work \ + mnt/merged + +ls mnt/merged/ # full rootfs +``` + +Clean up: + +```sh +sudo umount mnt/merged +for d in mnt/lower*; do sudo umount "$d" 2>/dev/null || fusermount -u "$d"; done +``` + +Production runtimes (containerd's erofs snapshotter, podman/CRI-O with the erofs-aware plugin, etc.) automate this assembly; the manual steps above are for verifying that an apko-built EROFS image really does compose into a valid rootfs. + +## Current limitations + +- **No compression.** apko emits raw `application/vnd.erofs` layers only. The draft spec defines `application/vnd.erofs+zstd` but neither apko's writer nor the underlying go-erofs library writes compressed images yet. +- **No dm-verity.** The spec's verified-mount path (§3.5) is not produced. +- **No chunk index.** Lazy-loading runtimes (per spec §3.4) won't get an index; reads are sequential. +- **No `overlay-data` or `device` roles.** Only `overlay-lower` (and unannotated final) layers are emitted. +- **Spec is draft.** Media-type strings and annotation keys may change before the spec stabilizes. Treat any image built today as experimental. + +If you need any of the above, please open an issue. + +## See also + +- [erofs/erofs-image-spec PR #1](https://github.com/erofs/erofs-image-spec/pull/1) — the layer format spec apko tracks. +- [EROFS kernel documentation](https://erofs.docs.kernel.org/) — on-disk format reference. +- [Layering in apko](layering.md) — how the multi-layer strategy partitions packages into groups. From 1a74824459c5c0ec20f2fe346619f7cc31766065 Mon Sep 17 00:00:00 2001 From: Scott Moser Date: Wed, 27 May 2026 11:11:32 -0400 Subject: [PATCH 03/33] cli: add `apko erofs` mount/umount/ls subcommands Adds an `apko erofs` command group that wraps the EROFS mount workflow: `mount` accepts a raw blob or an OCI image directory (auto-detected, or via `erofs:`/`oci:`/`oci-dir:` prefixes), `umount` reads a per-mount state file to unwind every layer, and `ls` produces a `tar tvf`-style listing without leaving mounts behind. The new pkg/erofsmount library handles source parsing, OCI layout reading, kernel/FUSE drivers with kernel-overlay-over- fuse fallback to fuse-overlayfs, and state-file teardown. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/erofs.md | 74 +++++-- internal/cli/commands.go | 1 + internal/cli/erofs.go | 121 ++++++++++++ pkg/erofsmount/driver_linux.go | 240 +++++++++++++++++++++++ pkg/erofsmount/driver_linux_test.go | 106 ++++++++++ pkg/erofsmount/ls_linux.go | 163 ++++++++++++++++ pkg/erofsmount/ls_linux_test.go | 84 ++++++++ pkg/erofsmount/mount_linux.go | 292 ++++++++++++++++++++++++++++ pkg/erofsmount/oci.go | 215 ++++++++++++++++++++ pkg/erofsmount/oci_test.go | 211 ++++++++++++++++++++ pkg/erofsmount/source.go | 190 ++++++++++++++++++ pkg/erofsmount/source_test.go | 110 +++++++++++ pkg/erofsmount/state.go | 127 ++++++++++++ pkg/erofsmount/state_test.go | 100 ++++++++++ pkg/erofsmount/stub_other.go | 55 ++++++ 15 files changed, 2074 insertions(+), 15 deletions(-) create mode 100644 internal/cli/erofs.go create mode 100644 pkg/erofsmount/driver_linux.go create mode 100644 pkg/erofsmount/driver_linux_test.go create mode 100644 pkg/erofsmount/ls_linux.go create mode 100644 pkg/erofsmount/ls_linux_test.go create mode 100644 pkg/erofsmount/mount_linux.go create mode 100644 pkg/erofsmount/oci.go create mode 100644 pkg/erofsmount/oci_test.go create mode 100644 pkg/erofsmount/source.go create mode 100644 pkg/erofsmount/source_test.go create mode 100644 pkg/erofsmount/state.go create mode 100644 pkg/erofsmount/state_test.go create mode 100644 pkg/erofsmount/stub_other.go diff --git a/docs/erofs.md b/docs/erofs.md index 6a8e0a372..625e5a652 100644 --- a/docs/erofs.md +++ b/docs/erofs.md @@ -143,31 +143,50 @@ ls extracted/ cat extracted/etc/os-release ``` +### List contents with `apko erofs ls` + +For a quick `tar tvf`-style listing of any EROFS source (raw blob or OCI image directory), use `apko erofs ls`. It transparently mounts read-only, walks the tree, prints one line per entry, and unmounts automatically. + +```sh +apko erofs ls out/blobs/sha256/$LAYER | head +# lrwxrwxrwx 0/0 7 2026-04-17 19:17 bin -> usr/bin +# drwxr-xr-x 0/0 115 2026-04-17 19:17 dev +# ... + +apko erofs ls out/ # works against the whole OCI image too +``` + +`apko erofs ls` picks `kernel` mode automatically when running as root and `fuse` mode otherwise. Override with `--mode=kernel|fuse|auto`. + ## Mount the layer -### Kernel mount (root) +`apko erofs mount SOURCE DEST` mounts a raw EROFS blob or an OCI image directory at `DEST`. It chooses between a kernel mount (root) and `erofsfuse` (unprivileged) based on the effective UID; use `--mode=kernel|fuse|auto` to force a choice. `apko erofs umount DEST` tears it back down. ```sh -sudo mkdir -p /mnt/apko-erofs -sudo mount -t erofs -o loop out/blobs/sha256/$LAYER /mnt/apko-erofs +mkdir -p /mnt/apko-erofs +apko erofs mount out/blobs/sha256/$LAYER /mnt/apko-erofs ls /mnt/apko-erofs/ file /mnt/apko-erofs/bin/sh -sudo umount /mnt/apko-erofs +apko erofs umount /mnt/apko-erofs ``` -The kernel mount is read-only, zero-copy, and exposes xattrs. -If `mount` reports "unknown filesystem type 'erofs'", the kernel module is missing on your system; install it (e.g. `linux-modules-extra-$(uname -r)` on Ubuntu) or use the FUSE path below. +If the kernel mount mode complains "unknown filesystem type 'erofs'", the kernel module is missing on your system; install it (e.g. `linux-modules-extra-$(uname -r)` on Ubuntu) or pass `--mode=fuse` to use `erofsfuse`, which does not require root and works inside CI containers that lack the kernel module. + +### Doing it manually -### FUSE mount (unprivileged) +For reference, `apko erofs mount` is equivalent to one of: ```sh -mkdir -p mnt -erofsfuse out/blobs/sha256/$LAYER mnt/ -ls mnt/ -fusermount -u mnt/ # or `fusermount3 -u mnt/` -``` +# Kernel (root): +sudo mount -t erofs -o loop out/blobs/sha256/$LAYER /mnt/apko-erofs +# ...later: +sudo umount /mnt/apko-erofs -`erofsfuse` does not require root, which makes it convenient on dev machines and inside CI containers that lack the kernel module. +# FUSE (unprivileged): +erofsfuse out/blobs/sha256/$LAYER /mnt/apko-erofs +# ...later: +fusermount3 -u /mnt/apko-erofs # or `fusermount -u` +``` ## Pulling from a registry @@ -239,7 +258,32 @@ Each layer is independently mountable as an EROFS filesystem, and each carries i ### Assemble the full rootfs with overlayfs The OCI spec composes layers with `overlayfs`-style semantics; for EROFS layers the composition is straightforward. -Mount each layer separately, then stack them with `mount -t overlay`: +The simplest way is `apko erofs mount`, which mounts each layer and assembles the overlay in one step: + +```sh +mkdir -p mnt +apko erofs mount out-layered/ mnt/ +ls mnt/merged/ # full rootfs +cat mnt/.apko-erofs-mount.json # records the mounts for teardown +apko erofs umount mnt/ # unwinds the overlay and every layer +``` + +The directory layout produced under `DEST` is: + +``` +mnt/ +├── layers/00..NN # one EROFS mountpoint per layer (00 is base) +├── upper/ # overlayfs upperdir +├── work/ # overlayfs workdir +├── merged/ # combined view +└── .apko-erofs-mount.json # state file consumed by `apko erofs umount` +``` + +`apko erofs mount` picks kernel mounts when running as root and falls back to `erofsfuse` + (kernel overlay over FUSE, then `fuse-overlayfs`) otherwise. Force one path with `--mode=kernel|fuse|auto`. + +#### Doing it manually + +For reference, the equivalent without `apko erofs mount`: ```sh # Pull each layer blob out of the OCI layout. @@ -270,7 +314,7 @@ sudo umount mnt/merged for d in mnt/lower*; do sudo umount "$d" 2>/dev/null || fusermount -u "$d"; done ``` -Production runtimes (containerd's erofs snapshotter, podman/CRI-O with the erofs-aware plugin, etc.) automate this assembly; the manual steps above are for verifying that an apko-built EROFS image really does compose into a valid rootfs. +Production runtimes (containerd's erofs snapshotter, podman/CRI-O with the erofs-aware plugin, etc.) automate this assembly; both `apko erofs mount` and the manual steps above are for verifying that an apko-built EROFS image really does compose into a valid rootfs. ## Current limitations diff --git a/internal/cli/commands.go b/internal/cli/commands.go index 6d385841f..6794970ee 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -63,6 +63,7 @@ func New() *cobra.Command { cmd.AddCommand(resolve()) cmd.AddCommand(installKeys()) cmd.AddCommand(cleanCmd()) + cmd.AddCommand(erofsCmd()) cmd.AddCommand(version.Version()) cmd.PersistentFlags().StringVarP(&workDir, "workdir", "C", cwd, "working dir (default is current dir where executed)") diff --git a/internal/cli/erofs.go b/internal/cli/erofs.go new file mode 100644 index 000000000..6b2dbbe7b --- /dev/null +++ b/internal/cli/erofs.go @@ -0,0 +1,121 @@ +// Copyright 2026 Chainguard, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cli + +import ( + "os" + + "github.com/spf13/cobra" + + "chainguard.dev/apko/pkg/erofsmount" +) + +// erofsCmd returns the `apko erofs` parent command, which hosts mount, umount, +// and ls subcommands. +func erofsCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "erofs", + Short: "Mount, unmount, and inspect EROFS images produced by apko", + Long: `The erofs subcommands operate on EROFS layer blobs and OCI image +directories whose layers use the application/vnd.erofs mediaType (as produced +by 'apko build --format=erofs'). These commands are Linux-only.`, + } + cmd.AddCommand(erofsMount(), erofsUmount(), erofsLs()) + return cmd +} + +func erofsMount() *cobra.Command { + var mode, arch string + cmd := &cobra.Command{ + Use: "mount [flags] SOURCE DEST", + Short: "Mount an EROFS blob or an EROFS OCI image at DEST", + Long: `Mount the given SOURCE at DEST. + +SOURCE may be: + - a raw EROFS blob file (mounted directly at DEST), + - an OCI image layout directory containing EROFS layers (mounted as a + multi-layer overlay rooted at DEST/merged), + - any of the above prefixed by erofs:, oci:, or oci-dir:, + - PATH:TAG to pick a manifest from a multi-tag OCI layout. + +For OCI sources, DEST gets this layout: + DEST/layers/00..NN one per EROFS layer (00 is base) + DEST/upper overlayfs upperdir + DEST/work overlayfs workdir + DEST/merged the combined view + DEST/.apko-erofs-mount.json state for 'apko erofs umount'`, + Example: ` apko erofs mount ./out:latest /mnt/x + apko erofs mount --mode=fuse ./image.erofs /mnt/y + apko erofs mount oci-dir:./out:latest /mnt/z`, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + src, err := erofsmount.ParseSource(args[0]) + if err != nil { + return err + } + _, err = erofsmount.Mount(cmd.Context(), src, args[1], erofsmount.Options{ + Mode: erofsmount.Mode(mode), + Arch: arch, + }) + return err + }, + } + cmd.Flags().StringVar(&mode, "mode", string(erofsmount.ModeAuto), "mount mode: kernel, fuse, or auto (auto = kernel if root else fuse)") + cmd.Flags().StringVar(&arch, "arch", "host", "architecture to select from a multi-arch OCI index (host = process arch)") + return cmd +} + +func erofsUmount() *cobra.Command { + cmd := &cobra.Command{ + Use: "umount DEST", + Short: "Unmount an EROFS mount produced by 'apko erofs mount'", + Long: `Unmount the mount at DEST. + +If DEST contains a state file (DEST/.apko-erofs-mount.json) it is treated as +an image mount and every layer plus the overlay is torn down in reverse +order. If DEST has no state file, it is treated as a single blob mount and a +plain umount is attempted (with a fall-back to fusermount).`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return erofsmount.Unmount(cmd.Context(), args[0]) + }, + } + return cmd +} + +func erofsLs() *cobra.Command { + var mode, arch string + cmd := &cobra.Command{ + Use: "ls SOURCE", + Short: "List the contents of an EROFS blob or image", + Long: `Mount SOURCE read-only to a temporary directory, walk its +contents, and print a 'tar tvf'-style listing. Unmounts automatically when +finished.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + src, err := erofsmount.ParseSource(args[0]) + if err != nil { + return err + } + return erofsmount.Ls(cmd.Context(), src, erofsmount.Options{ + Mode: erofsmount.Mode(mode), + Arch: arch, + }, os.Stdout) + }, + } + cmd.Flags().StringVar(&mode, "mode", string(erofsmount.ModeAuto), "mount mode: kernel, fuse, or auto") + cmd.Flags().StringVar(&arch, "arch", "host", "architecture to select from a multi-arch OCI index") + return cmd +} diff --git a/pkg/erofsmount/driver_linux.go b/pkg/erofsmount/driver_linux.go new file mode 100644 index 000000000..760066d88 --- /dev/null +++ b/pkg/erofsmount/driver_linux.go @@ -0,0 +1,240 @@ +// Copyright 2026 Chainguard, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build linux + +package erofsmount + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "os/exec" + "strings" + + "github.com/chainguard-dev/clog" +) + +// Driver wraps the externally-invoked mount and umount commands used by Mount. +// Two implementations exist on Linux: kernelDriver shells out to mount(8) and +// umount(8); fuseDriver shells out to erofsfuse and fusermount. +type Driver interface { + // Name returns the resolved mode (kernel or fuse), never auto. + Name() Mode + // Preflight verifies that the required binaries exist and that the + // invoking process can plausibly perform mounts in this mode (e.g. + // kernelDriver requires euid 0). It must be called before any + // MountLayer/AssembleOverlay calls. + Preflight() error + // MountLayer mounts blob (a raw EROFS image) read-only at mp. The + // returned umount closure tears down that single mount. + MountLayer(ctx context.Context, blob, mp string) (func() error, error) + // AssembleOverlay layers `lowers` (in overlayfs priority order — top + // first, bottom last) on top of upper/work into merged. When readOnly is + // true, upper and work are ignored and the overlay is built as + // lowerdir-only (which overlayfs supports for read-only stacks). + AssembleOverlay(ctx context.Context, lowers []string, upper, work, merged string, readOnly bool) (func() error, error) +} + +// NewDriver returns the driver that corresponds to mode. mode must be one of +// ModeKernel or ModeFuse — ModeAuto must be resolved by the caller via +// ResolveMode before calling NewDriver. +func NewDriver(mode Mode) (Driver, error) { + switch mode { + case ModeKernel: + return &kernelDriver{}, nil + case ModeFuse: + return &fuseDriver{}, nil + } + return nil, fmt.Errorf("unknown mount mode %q", mode) +} + +// ResolveMode collapses ModeAuto into ModeKernel (euid 0) or ModeFuse. +func ResolveMode(req Mode) Mode { + if req != ModeAuto { + return req + } + if os.Geteuid() == 0 { + return ModeKernel + } + return ModeFuse +} + +// kernelDriver + +type kernelDriver struct{} + +func (kernelDriver) Name() Mode { return ModeKernel } + +func (kernelDriver) Preflight() error { + if os.Geteuid() != 0 { + return fmt.Errorf("kernel mount mode requires root (euid 0); pass --mode=fuse to use erofsfuse instead") + } + for _, bin := range []string{"mount", "umount"} { + if _, err := exec.LookPath(bin); err != nil { + return fmt.Errorf("%s not found in PATH: %w", bin, err) + } + } + return nil +} + +func (d *kernelDriver) MountLayer(ctx context.Context, blob, mp string) (func() error, error) { + args := buildKernelLayerArgs(blob, mp) + if err := runCmd(ctx, args[0], args[1:]...); err != nil { + return nil, err + } + return func() error { + uargs := buildKernelUmountArgs(mp) + return runCmd(context.Background(), uargs[0], uargs[1:]...) + }, nil +} + +func (d *kernelDriver) AssembleOverlay(ctx context.Context, lowers []string, upper, work, merged string, readOnly bool) (func() error, error) { + args := buildKernelOverlayArgs(lowers, upper, work, merged, readOnly) + if err := runCmd(ctx, args[0], args[1:]...); err != nil { + return nil, err + } + return func() error { + uargs := buildKernelUmountArgs(merged) + return runCmd(context.Background(), uargs[0], uargs[1:]...) + }, nil +} + +// fuseDriver + +type fuseDriver struct{} + +func (fuseDriver) Name() Mode { return ModeFuse } + +func (fuseDriver) Preflight() error { + if _, err := exec.LookPath("erofsfuse"); err != nil { + return fmt.Errorf("erofsfuse not found in PATH (install erofs-utils-fuse): %w", err) + } + if _, err := lookupFusermount(); err != nil { + return err + } + return nil +} + +func (d *fuseDriver) MountLayer(ctx context.Context, blob, mp string) (func() error, error) { + args := buildFuseLayerArgs(blob, mp) + if err := runCmd(ctx, args[0], args[1:]...); err != nil { + return nil, err + } + return func() error { + fm, err := lookupFusermount() + if err != nil { + return err + } + uargs := buildFusermountUmountArgs(fm, mp) + return runCmd(context.Background(), uargs[0], uargs[1:]...) + }, nil +} + +func (d *fuseDriver) AssembleOverlay(ctx context.Context, lowers []string, upper, work, merged string, readOnly bool) (func() error, error) { + // First try the kernel overlay driver on top of the FUSE lowerdirs. Modern + // kernels (~5.11+) allow this in user namespaces. If that fails, fall back + // to fuse-overlayfs. + kArgs := buildKernelOverlayArgs(lowers, upper, work, merged, readOnly) + if err := runCmd(ctx, kArgs[0], kArgs[1:]...); err == nil { + return func() error { + uargs := buildKernelUmountArgs(merged) + return runCmd(context.Background(), uargs[0], uargs[1:]...) + }, nil + } + + if _, err := exec.LookPath("fuse-overlayfs"); err != nil { + return nil, fmt.Errorf("kernel overlay failed and fuse-overlayfs is not installed: %w", err) + } + fArgs := buildFuseOverlayArgs(lowers, upper, work, merged, readOnly) + if err := runCmd(ctx, fArgs[0], fArgs[1:]...); err != nil { + return nil, err + } + return func() error { + fm, err := lookupFusermount() + if err != nil { + return err + } + uargs := buildFusermountUmountArgs(fm, merged) + return runCmd(context.Background(), uargs[0], uargs[1:]...) + }, nil +} + +// Command builders. Pure functions so they can be tested without exec. + +func buildKernelLayerArgs(blob, mp string) []string { + return []string{"mount", "-t", "erofs", "-o", "loop,ro", blob, mp} +} + +func buildKernelUmountArgs(mp string) []string { + return []string{"umount", mp} +} + +func buildFuseLayerArgs(blob, mp string) []string { + return []string{"erofsfuse", blob, mp} +} + +func buildFusermountUmountArgs(fusermountBin, mp string) []string { + return []string{fusermountBin, "-u", mp} +} + +func buildKernelOverlayArgs(lowers []string, upper, work, merged string, readOnly bool) []string { + opts := "lowerdir=" + strings.Join(lowers, ":") + if !readOnly { + opts += ",upperdir=" + upper + ",workdir=" + work + } else { + opts += ",ro" + } + return []string{"mount", "-t", "overlay", "-o", opts, "overlay", merged} +} + +func buildFuseOverlayArgs(lowers []string, upper, work, merged string, readOnly bool) []string { + opts := "lowerdir=" + strings.Join(lowers, ":") + if !readOnly { + opts += ",upperdir=" + upper + ",workdir=" + work + } + return []string{"fuse-overlayfs", "-o", opts, merged} +} + +// lookupFusermount returns the path to whichever of `fusermount3` or +// `fusermount` is available, preferring fusermount3 since it matches modern +// libfuse builds. +func lookupFusermount() (string, error) { + for _, name := range []string{"fusermount3", "fusermount"} { + if path, err := exec.LookPath(name); err == nil { + return path, nil + } + } + return "", errors.New("neither fusermount3 nor fusermount found in PATH") +} + +// runCmd runs name+args, captures stderr, and wraps any error with the +// captured stderr so users see what mount(8) actually said. +func runCmd(ctx context.Context, name string, args ...string) error { + log := clog.FromContext(ctx) + cmd := exec.CommandContext(ctx, name, args...) + var stderr bytes.Buffer + cmd.Stderr = &stderr + log.Debugf("exec: %s %s", name, strings.Join(args, " ")) + if err := cmd.Run(); err != nil { + stderrTrim := strings.TrimSpace(stderr.String()) + if stderrTrim != "" { + return fmt.Errorf("%s %s: %w: %s", name, strings.Join(args, " "), err, stderrTrim) + } + return fmt.Errorf("%s %s: %w", name, strings.Join(args, " "), err) + } + return nil +} diff --git a/pkg/erofsmount/driver_linux_test.go b/pkg/erofsmount/driver_linux_test.go new file mode 100644 index 000000000..c1926b734 --- /dev/null +++ b/pkg/erofsmount/driver_linux_test.go @@ -0,0 +1,106 @@ +// Copyright 2026 Chainguard, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build linux + +package erofsmount + +import ( + "reflect" + "strings" + "testing" +) + +func TestBuildKernelLayerArgs(t *testing.T) { + got := buildKernelLayerArgs("/blobs/abc", "/mnt/x") + want := []string{"mount", "-t", "erofs", "-o", "loop,ro", "/blobs/abc", "/mnt/x"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %v, want %v", got, want) + } +} + +func TestBuildFuseLayerArgs(t *testing.T) { + got := buildFuseLayerArgs("/blobs/abc", "/mnt/x") + want := []string{"erofsfuse", "/blobs/abc", "/mnt/x"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %v, want %v", got, want) + } +} + +func TestBuildKernelOverlayArgs_Writable(t *testing.T) { + got := buildKernelOverlayArgs( + []string{"/mnt/x/layers/02", "/mnt/x/layers/01", "/mnt/x/layers/00"}, + "/mnt/x/upper", "/mnt/x/work", "/mnt/x/merged", + false, + ) + want := []string{ + "mount", "-t", "overlay", "-o", + "lowerdir=/mnt/x/layers/02:/mnt/x/layers/01:/mnt/x/layers/00,upperdir=/mnt/x/upper,workdir=/mnt/x/work", + "overlay", "/mnt/x/merged", + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %v\nwant %v", got, want) + } +} + +func TestBuildKernelOverlayArgs_ReadOnly(t *testing.T) { + got := buildKernelOverlayArgs( + []string{"/a", "/b"}, + "/ignored-upper", "/ignored-work", "/merged", + true, + ) + // Read-only must omit upperdir/workdir and append ,ro. + opts := got[4] + if strings.Contains(opts, "upperdir") || strings.Contains(opts, "workdir") { + t.Errorf("read-only overlay should not reference upperdir/workdir: %s", opts) + } + if !strings.HasSuffix(opts, ",ro") { + t.Errorf("read-only overlay opts should end with ,ro: %s", opts) + } + if !strings.HasPrefix(opts, "lowerdir=/a:/b") { + t.Errorf("lowerdir order wrong: %s", opts) + } +} + +func TestBuildFuseOverlayArgs(t *testing.T) { + got := buildFuseOverlayArgs( + []string{"/a", "/b"}, + "/u", "/w", "/m", + false, + ) + want := []string{ + "fuse-overlayfs", "-o", + "lowerdir=/a:/b,upperdir=/u,workdir=/w", + "/m", + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %v\nwant %v", got, want) + } +} + +func TestBuildKernelUmountArgs(t *testing.T) { + got := buildKernelUmountArgs("/mnt/x") + want := []string{"umount", "/mnt/x"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %v, want %v", got, want) + } +} + +func TestBuildFusermountUmountArgs(t *testing.T) { + got := buildFusermountUmountArgs("/usr/bin/fusermount3", "/mnt/x") + want := []string{"/usr/bin/fusermount3", "-u", "/mnt/x"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %v, want %v", got, want) + } +} diff --git a/pkg/erofsmount/ls_linux.go b/pkg/erofsmount/ls_linux.go new file mode 100644 index 000000000..b4566c6f2 --- /dev/null +++ b/pkg/erofsmount/ls_linux.go @@ -0,0 +1,163 @@ +// Copyright 2026 Chainguard, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build linux + +package erofsmount + +import ( + "context" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "strings" + "syscall" + "text/tabwriter" + + "github.com/chainguard-dev/clog" +) + +// Ls produces a `tar tvf`-style listing of every entry in src. It mounts src +// read-only to a temporary directory, walks the merged view (or the single +// blob mountpoint for KindBlob), prints each entry to w, then unmounts and +// removes the temporary directory. +func Ls(ctx context.Context, src Source, opts Options, w io.Writer) (retErr error) { + log := clog.FromContext(ctx) + tmp, err := os.MkdirTemp("", "apko-erofs-ls-*") + if err != nil { + return fmt.Errorf("mkdir tmp: %w", err) + } + defer func() { + if rmErr := os.RemoveAll(tmp); rmErr != nil { + log.Warnf("remove tmp %s: %v", tmp, rmErr) + } + }() + + opts.ReadOnly = true + if _, err := Mount(ctx, src, tmp, opts); err != nil { + return err + } + defer func() { + if uerr := Unmount(ctx, tmp); uerr != nil { + if retErr == nil { + retErr = fmt.Errorf("unmount after ls: %w", uerr) + } else { + log.Warnf("unmount after ls error: %v", uerr) + } + } + }() + + root := tmp + if src.Kind == KindOCIDir { + root = filepath.Join(tmp, "merged") + } + + return walkAndPrint(ctx, root, w) +} + +// walkAndPrint walks root and writes one line per entry to w in a format +// similar to `tar tvf`: mode uid/gid size yyyy-mm-dd hh:mm relpath[ -> target]. +func walkAndPrint(ctx context.Context, root string, w io.Writer) error { + tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) + rootClean := filepath.Clean(root) + err := filepath.WalkDir(rootClean, func(path string, d fs.DirEntry, err error) error { + if cerr := ctx.Err(); cerr != nil { + return cerr + } + if err != nil { + return err + } + // Skip the root itself. + if path == rootClean { + return nil + } + info, err := os.Lstat(path) + if err != nil { + return err + } + rel, err := filepath.Rel(rootClean, path) + if err != nil { + return err + } + line, err := formatEntry(info, rel, path) + if err != nil { + return err + } + if _, werr := fmt.Fprintln(tw, line); werr != nil { + return werr + } + return nil + }) + if err != nil { + return err + } + return tw.Flush() +} + +func formatEntry(info fs.FileInfo, rel, path string) (string, error) { + mode := info.Mode() + modeStr := formatMode(mode) + + var uid, gid int + if st, ok := info.Sys().(*syscall.Stat_t); ok { + uid = int(st.Uid) + gid = int(st.Gid) + } + + size := info.Size() + mt := info.ModTime().UTC().Format("2006-01-02 15:04") + + suffix := "" + if mode&fs.ModeSymlink != 0 { + target, err := os.Readlink(path) + if err == nil { + suffix = " -> " + target + } + } + + return fmt.Sprintf("%s\t%d/%d\t%d\t%s\t%s%s", modeStr, uid, gid, size, mt, rel, suffix), nil +} + +// formatMode renders a 10-character mode string in the style of `ls -l`. +func formatMode(mode fs.FileMode) string { + var b strings.Builder + b.Grow(10) + switch { + case mode.IsDir(): + b.WriteByte('d') + case mode&fs.ModeSymlink != 0: + b.WriteByte('l') + case mode&fs.ModeNamedPipe != 0: + b.WriteByte('p') + case mode&fs.ModeSocket != 0: + b.WriteByte('s') + case mode&fs.ModeCharDevice != 0: + b.WriteByte('c') + case mode&fs.ModeDevice != 0: + b.WriteByte('b') + default: + b.WriteByte('-') + } + perm := mode.Perm() + for i, ch := range "rwxrwxrwx" { + if perm&(1<<(8-i)) != 0 { + b.WriteByte(byte(ch)) + } else { + b.WriteByte('-') + } + } + return b.String() +} diff --git a/pkg/erofsmount/ls_linux_test.go b/pkg/erofsmount/ls_linux_test.go new file mode 100644 index 000000000..2f9649b6c --- /dev/null +++ b/pkg/erofsmount/ls_linux_test.go @@ -0,0 +1,84 @@ +// Copyright 2026 Chainguard, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build linux + +package erofsmount + +import ( + "bytes" + "context" + "io/fs" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestFormatMode(t *testing.T) { + cases := []struct { + mode fs.FileMode + want string + }{ + {fs.ModeDir | 0o755, "drwxr-xr-x"}, + {0o644, "-rw-r--r--"}, + {fs.ModeSymlink | 0o777, "lrwxrwxrwx"}, + {0o600, "-rw-------"}, + {fs.ModeNamedPipe | 0o644, "prw-r--r--"}, + } + for _, c := range cases { + got := formatMode(c.mode) + if got != c.want { + t.Errorf("formatMode(%v): got %q, want %q", c.mode, got, c.want) + } + } +} + +func TestWalkAndPrint(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, "bin"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "bin", "sh"), []byte("hi"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink("/bin/busybox", filepath.Join(root, "bin", "ls")); err != nil { + t.Fatal(err) + } + + var buf bytes.Buffer + if err := walkAndPrint(context.Background(), root, &buf); err != nil { + t.Fatalf("walkAndPrint: %v", err) + } + out := buf.String() + if !strings.Contains(out, "bin/sh") { + t.Errorf("output missing bin/sh:\n%s", out) + } + if !strings.Contains(out, "bin/ls -> /bin/busybox") { + t.Errorf("symlink target missing:\n%s", out) + } + // Root itself must not be listed: every emitted relpath must start with + // a known top-level child (bin/...). Strip any " -> target" suffix. + for line := range strings.SplitSeq(strings.TrimSpace(out), "\n") { + fields := strings.Fields(line) + rel := fields[len(fields)-1] + if i := strings.Index(line, " -> "); i >= 0 { + before := strings.Fields(line[:i]) + rel = before[len(before)-1] + } + if strings.HasPrefix(rel, "/") || rel == "." || rel == "" { + t.Errorf("relpath %q looks wrong in: %s", rel, line) + } + } +} diff --git a/pkg/erofsmount/mount_linux.go b/pkg/erofsmount/mount_linux.go new file mode 100644 index 000000000..b0fccfe5f --- /dev/null +++ b/pkg/erofsmount/mount_linux.go @@ -0,0 +1,292 @@ +// Copyright 2026 Chainguard, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build linux + +package erofsmount + +import ( + "context" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "time" + + "github.com/chainguard-dev/clog" +) + +// Options bundles the optional knobs for Mount and Ls. +type Options struct { + // Mode selects ModeKernel, ModeFuse, or ModeAuto. Zero value is treated + // as ModeAuto. + Mode Mode + // Arch picks a manifest from a multi-arch OCI index. "" or "host" means + // runtime.GOARCH. + Arch string + // ReadOnly, when true, skips upper/work overlay dirs and produces a + // pure read-only overlay. Used by Ls. + ReadOnly bool +} + +// Mount mounts src at dest. For KindBlob, dest is the single mountpoint. For +// KindOCIDir, dest is a directory that receives the standard layout: +// +// /layers/00..NN per-layer EROFS mounts (00 is base) +// /upper overlayfs upperdir (writable mounts only) +// /work overlayfs workdir (writable mounts only) +// /merged overlayfs merged view +// /.apko-erofs-mount.json state record for Unmount +// +// On success, Mount returns the recorded MountState. On any error after a +// partial mount, all partially-completed mounts are torn down before +// returning. +func Mount(ctx context.Context, src Source, dest string, opts Options) (st *MountState, retErr error) { + log := clog.FromContext(ctx) + + absDest, err := filepath.Abs(dest) + if err != nil { + return nil, fmt.Errorf("resolve dest: %w", err) + } + dest = filepath.Clean(absDest) + + if opts.Mode == "" { + opts.Mode = ModeAuto + } + mode := ResolveMode(opts.Mode) + drv, err := NewDriver(mode) + if err != nil { + return nil, err + } + if err := drv.Preflight(); err != nil { + return nil, err + } + + switch src.Kind { + case KindBlob: + return mountBlob(ctx, drv, src, dest, log) + case KindOCIDir: + return mountImage(ctx, drv, src, dest, opts, log) + } + return nil, fmt.Errorf("unsupported source kind: %v", src.Kind) +} + +func mountBlob(ctx context.Context, drv Driver, src Source, dest string, log *clog.Logger) (*MountState, error) { + if err := ensureDir(dest); err != nil { + return nil, err + } + if _, err := drv.MountLayer(ctx, src.Path, dest); err != nil { + return nil, fmt.Errorf("mount %s at %s: %w", src.Path, dest, err) + } + log.Infof("mounted %s at %s (%s)", src.Path, dest, drv.Name()) + // No state file for raw blobs — see Unmount for the matching teardown + // logic. Return a state value for completeness but do not persist it. + return &MountState{ + SchemaVersion: StateSchemaVersion, + Mode: drv.Name(), + Source: src.Raw, + Dest: dest, + Created: time.Now().UTC(), + Mounts: []string{dest}, + }, nil +} + +func mountImage(ctx context.Context, drv Driver, src Source, dest string, opts Options, log *clog.Logger) (st *MountState, retErr error) { + layers, err := ReadOCILayers(src.Path, src.Tag, opts.Arch) + if err != nil { + return nil, err + } + + // Refuse to clobber an existing mount. + if _, err := os.Stat(StatePath(dest)); err == nil { + return nil, fmt.Errorf("dest %s already has a mount state file (%s); umount first", dest, StatePath(dest)) + } else if !errors.Is(err, fs.ErrNotExist) { + return nil, fmt.Errorf("stat state file: %w", err) + } + + for _, sub := range []string{"layers", "upper", "work", "merged"} { + if err := ensureDir(filepath.Join(dest, sub)); err != nil { + return nil, err + } + } + + var cleanups []func() error + defer func() { + if retErr == nil { + return + } + for i := len(cleanups) - 1; i >= 0; i-- { + if err := cleanups[i](); err != nil { + log.Warnf("cleanup on error: %v", err) + } + } + }() + + layerMps := make([]string, 0, len(layers)) + mountsLIFO := make([]string, 0, len(layers)+1) + for i, layer := range layers { + mp := filepath.Join(dest, "layers", fmt.Sprintf("%02d", i)) + if err := ensureDir(mp); err != nil { + return nil, err + } + umount, err := drv.MountLayer(ctx, layer.BlobPath, mp) + if err != nil { + return nil, fmt.Errorf("mount layer %d (%s) at %s: %w", i, layer.Digest, mp, err) + } + cleanups = append(cleanups, umount) + layerMps = append(layerMps, mp) + mountsLIFO = append([]string{mp}, mountsLIFO...) + log.Infof("mounted layer %d (%s) at %s", i, layer.Digest, mp) + } + + // overlayfs lowerdir is highest-priority first; OCI is bottom-up so we + // reverse. + lowers := make([]string, len(layerMps)) + for i := range layerMps { + lowers[i] = layerMps[len(layerMps)-1-i] + } + + upper := filepath.Join(dest, "upper") + work := filepath.Join(dest, "work") + merged := filepath.Join(dest, "merged") + umount, err := drv.AssembleOverlay(ctx, lowers, upper, work, merged, opts.ReadOnly) + if err != nil { + return nil, fmt.Errorf("overlay merge into %s: %w", merged, err) + } + cleanups = append(cleanups, umount) + mountsLIFO = append([]string{merged}, mountsLIFO...) + log.Infof("merged %d layer(s) at %s", len(layers), merged) + + state := &MountState{ + SchemaVersion: StateSchemaVersion, + Mode: drv.Name(), + Source: src.Raw, + Dest: dest, + Created: time.Now().UTC(), + Mounts: mountsLIFO, + } + if err := WriteState(dest, state); err != nil { + return nil, fmt.Errorf("write state: %w", err) + } + return state, nil +} + +// Unmount tears down a mount produced by Mount. For an image mount it reads +// the state file at /.apko-erofs-mount.json and unmounts in LIFO order; +// if the state file is absent it falls back to treating dest as a single +// (blob) mountpoint and runs umount/fusermount. +func Unmount(ctx context.Context, dest string) error { + log := clog.FromContext(ctx) + absDest, err := filepath.Abs(dest) + if err != nil { + return fmt.Errorf("resolve dest: %w", err) + } + dest = filepath.Clean(absDest) + + st, err := LoadState(dest) + if err == nil { + return unmountImage(ctx, dest, st, log) + } + if !errors.Is(err, fs.ErrNotExist) { + return err + } + return unmountBlob(ctx, dest, log) +} + +func unmountImage(ctx context.Context, dest string, st *MountState, log *clog.Logger) error { + drv, err := NewDriver(st.Mode) + if err != nil { + return err + } + var errs []error + for _, mp := range st.Mounts { + // Build a minimal one-shot umount via the driver's API: we can't + // reuse the closures from Mount because they live in a different + // process. Construct an umount inline using a fresh layer mount of + // nothing — i.e. just call the umount command directly. + if err := unmountOne(ctx, drv, mp); err != nil { + errs = append(errs, fmt.Errorf("umount %s: %w", mp, err)) + log.Warnf("umount %s: %v", mp, err) + } else { + log.Infof("unmounted %s", mp) + } + } + if len(errs) > 0 { + return errors.Join(errs...) + } + for _, sub := range []string{"merged", "upper", "work", "layers"} { + if err := os.RemoveAll(filepath.Join(dest, sub)); err != nil { + log.Warnf("remove %s: %v", filepath.Join(dest, sub), err) + } + } + if err := RemoveState(dest); err != nil { + return fmt.Errorf("remove state file: %w", err) + } + return nil +} + +// unmountBlob tears down a single mountpoint produced by mountBlob. Since +// blobs do not have a state file we have to guess which umount tool applies; +// we try kernel umount first (which works for both kernel-erofs and any +// kernel-overlay-over-fuse cases by transitively triggering fuse teardown +// where appropriate) and fall back to fusermount. +func unmountBlob(ctx context.Context, dest string, log *clog.Logger) error { + if err := runCmd(ctx, "umount", dest); err == nil { + log.Infof("unmounted %s", dest) + return nil + } + fm, err := lookupFusermount() + if err != nil { + return fmt.Errorf("umount %s: kernel umount failed and no fusermount available", dest) + } + if err := runCmd(ctx, fm, "-u", dest); err != nil { + return fmt.Errorf("umount %s: %w", dest, err) + } + log.Infof("unmounted %s", dest) + return nil +} + +// unmountOne unmounts mp using the appropriate tool for the recorded driver. +// We don't try to be clever about kernel-vs-fuse here beyond what the state +// file tells us; if the user mounted with kernel they need kernel umount. +func unmountOne(ctx context.Context, drv Driver, mp string) error { + switch drv.Name() { + case ModeKernel: + return runCmd(ctx, "umount", mp) + case ModeFuse: + // For fuse mounts: the merged view may itself be a kernel overlay + // (when overlayfs over FUSE worked) or a fuse-overlayfs mount. + // `umount` handles both kernel-side overlays; `fusermount -u` + // handles fuse-overlayfs and the per-layer erofsfuse mounts. Try + // kernel umount first (cheap, no-op if not applicable), then + // fusermount. + if err := runCmd(ctx, "umount", mp); err == nil { + return nil + } + fm, err := lookupFusermount() + if err != nil { + return err + } + return runCmd(ctx, fm, "-u", mp) + } + return fmt.Errorf("unknown mode %q", drv.Name()) +} + +func ensureDir(path string) error { + if err := os.MkdirAll(path, 0o755); err != nil { + return fmt.Errorf("mkdir %s: %w", path, err) + } + return nil +} diff --git a/pkg/erofsmount/oci.go b/pkg/erofsmount/oci.go new file mode 100644 index 000000000..aa5cfa70e --- /dev/null +++ b/pkg/erofsmount/oci.go @@ -0,0 +1,215 @@ +// Copyright 2026 Chainguard, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package erofsmount + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/layout" + ocitypes "github.com/google/go-containerregistry/pkg/v1/types" +) + +// EROFS-specific constants. These are duplicated from pkg/build/erofs.go to +// avoid taking a dependency on the build package from this leaf library. +// They must stay in sync. +const ( + erofsLayerMediaType = "application/vnd.erofs" + erofsRoleAnnotation = "org.erofs.role" + erofsRoleOverlay = "overlay-lower" + + annotationRefName = "org.opencontainers.image.ref.name" +) + +// LayerRef points at one EROFS layer blob on disk along with its descriptor +// metadata. +type LayerRef struct { + BlobPath string + Digest string + MediaType string + Annotations map[string]string + // Role is the value of the org.erofs.role annotation, "" for the final + // (top) layer. + Role string +} + +// ReadOCILayers loads an OCI image layout, selects a manifest, and returns its +// EROFS layer references in manifest order (bottom-up — caller is responsible +// for reversing the order when feeding overlayfs lowerdirs). +// +// tag, when non-empty, picks the manifest whose +// org.opencontainers.image.ref.name annotation matches. When empty, exactly +// one image manifest must be selectable. +// +// arch is used to disambiguate multi-arch indexes. "" or "host" means the +// process's runtime.GOARCH. +func ReadOCILayers(ociDir, tag, arch string) ([]LayerRef, error) { + if arch == "" || arch == "host" { + arch = runtime.GOARCH + } + + idx, err := layout.ImageIndexFromPath(ociDir) + if err != nil { + return nil, fmt.Errorf("open oci layout %s: %w", ociDir, err) + } + + img, err := selectImage(idx, tag, arch) + if err != nil { + return nil, fmt.Errorf("oci layout %s: %w", ociDir, err) + } + + manifest, err := img.Manifest() + if err != nil { + return nil, fmt.Errorf("oci layout %s: read manifest: %w", ociDir, err) + } + if len(manifest.Layers) == 0 { + return nil, fmt.Errorf("oci layout %s: image has no layers", ociDir) + } + + refs := make([]LayerRef, 0, len(manifest.Layers)) + for i, desc := range manifest.Layers { + if string(desc.MediaType) != erofsLayerMediaType { + return nil, fmt.Errorf("layer %d has mediaType %q; expected %q (this command only handles EROFS images)", i, desc.MediaType, erofsLayerMediaType) + } + blob := filepath.Join(ociDir, "blobs", desc.Digest.Algorithm, desc.Digest.Hex) + if _, err := os.Stat(blob); err != nil { + return nil, fmt.Errorf("layer %d blob %s: %w", i, blob, err) + } + refs = append(refs, LayerRef{ + BlobPath: blob, + Digest: desc.Digest.String(), + MediaType: string(desc.MediaType), + Annotations: desc.Annotations, + Role: desc.Annotations[erofsRoleAnnotation], + }) + } + + // Validate role placement: per the EROFS image spec rule, every layer + // except the final (top) one must carry role=overlay-lower; the final + // layer must carry no role. We accept role==""/role==overlay-lower in + // either position so single-layer images (one unannotated layer) work. + if len(refs) > 1 { + for i := 0; i < len(refs)-1; i++ { + if refs[i].Role != erofsRoleOverlay { + return nil, fmt.Errorf("layer %d: missing %s=%s annotation (only the final layer may be unannotated)", i, erofsRoleAnnotation, erofsRoleOverlay) + } + } + if refs[len(refs)-1].Role != "" { + return nil, fmt.Errorf("layer %d (final): unexpected role %q (final layer must carry no role)", len(refs)-1, refs[len(refs)-1].Role) + } + } + + return refs, nil +} + +// selectImage picks an image manifest from idx by tag and arch. +// +// If tag is set, manifests are filtered to those whose ref.name annotation +// matches; otherwise all are eligible. Among eligible manifests, nested +// indexes are unwrapped (recursing once) so multi-arch indexes resolve to a +// single per-arch image. arch then filters image manifests by Platform. +func selectImage(idx v1.ImageIndex, tag, arch string) (v1.Image, error) { + manifest, err := idx.IndexManifest() + if err != nil { + return nil, fmt.Errorf("read index manifest: %w", err) + } + + eligible := manifest.Manifests + if tag != "" { + eligible = filterByRefName(manifest.Manifests, tag) + if len(eligible) == 0 { + return nil, fmt.Errorf("no manifest with %s=%q (available: %s)", annotationRefName, tag, availableTagsList(manifest.Manifests)) + } + } + + // Unwrap a top-level nested OCIImageIndex once (apko's publish path + // often emits one) — but only when no tag was given. With a tag, the + // caller already pinpointed a manifest. + if tag == "" && len(eligible) == 1 && eligible[0].MediaType == ocitypes.OCIImageIndex { + child, err := idx.ImageIndex(eligible[0].Digest) + if err != nil { + return nil, fmt.Errorf("descend into nested index: %w", err) + } + return selectImage(child, "", arch) + } + + // Filter to image manifests (drop any indexes). + var images []v1.Descriptor + for _, m := range eligible { + switch m.MediaType { + case ocitypes.OCIManifestSchema1, ocitypes.DockerManifestSchema2: + images = append(images, m) + } + } + if len(images) == 0 { + return nil, fmt.Errorf("no image manifest in index (saw %d entries)", len(eligible)) + } + + // Filter by arch if either there are multiple candidates or the lone + // candidate has a non-matching platform. + var matches []v1.Descriptor + for _, m := range images { + if m.Platform == nil || m.Platform.Architecture == "" || m.Platform.Architecture == arch { + matches = append(matches, m) + } + } + if len(matches) == 0 { + return nil, fmt.Errorf("no manifest for arch=%q (saw: %s)", arch, availableArchList(images)) + } + if len(matches) > 1 { + return nil, fmt.Errorf("multiple manifests match arch=%q; pass --tag to disambiguate", arch) + } + + return idx.Image(matches[0].Digest) +} + +func filterByRefName(ms []v1.Descriptor, tag string) []v1.Descriptor { + var out []v1.Descriptor + for _, m := range ms { + if m.Annotations[annotationRefName] == tag { + out = append(out, m) + } + } + return out +} + +func availableTagsList(ms []v1.Descriptor) string { + var tags []string + for _, m := range ms { + if t := m.Annotations[annotationRefName]; t != "" { + tags = append(tags, t) + } + } + if len(tags) == 0 { + return "(none set)" + } + return strings.Join(tags, ", ") +} + +func availableArchList(ms []v1.Descriptor) string { + var archs []string + for _, m := range ms { + if m.Platform != nil && m.Platform.Architecture != "" { + archs = append(archs, m.Platform.Architecture) + } else { + archs = append(archs, "(none)") + } + } + return strings.Join(archs, ", ") +} diff --git a/pkg/erofsmount/oci_test.go b/pkg/erofsmount/oci_test.go new file mode 100644 index 000000000..2b6016cfd --- /dev/null +++ b/pkg/erofsmount/oci_test.go @@ -0,0 +1,211 @@ +// Copyright 2026 Chainguard, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package erofsmount + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "maps" + "os" + "path/filepath" + "strings" + "testing" +) + +type fakeLayer struct { + body []byte + role string + annotations map[string]string + mediaType string // override; default erofsLayerMediaType +} + +// writeFakeOCILayout writes a minimal OCI image layout under root with one or +// more EROFS layers. Returns the path it wrote to. +func writeFakeOCILayout(t *testing.T, root string, layers []fakeLayer) string { + t.Helper() + blobsDir := filepath.Join(root, "blobs", "sha256") + if err := os.MkdirAll(blobsDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "oci-layout"), []byte(`{"imageLayoutVersion":"1.0.0"}`), 0o600); err != nil { + t.Fatal(err) + } + + type descriptor struct { + MediaType string `json:"mediaType"` + Size int64 `json:"size"` + Digest string `json:"digest"` + Annotations map[string]string `json:"annotations,omitempty"` + Platform map[string]string `json:"platform,omitempty"` + } + + writeBlob := func(body []byte) string { + sum := sha256.Sum256(body) + hexs := hex.EncodeToString(sum[:]) + if err := os.WriteFile(filepath.Join(blobsDir, hexs), body, 0o600); err != nil { + t.Fatal(err) + } + return "sha256:" + hexs + } + + // Image config — content doesn't matter for the reader, but the manifest + // must reference a real blob. + configDigest := writeBlob([]byte(`{"architecture":"amd64","os":"linux"}`)) + + layerDescs := make([]descriptor, 0, len(layers)) + for _, l := range layers { + mt := l.mediaType + if mt == "" { + mt = erofsLayerMediaType + } + dig := writeBlob(l.body) + anns := map[string]string{} + maps.Copy(anns, l.annotations) + if l.role != "" { + anns[erofsRoleAnnotation] = l.role + } + layerDescs = append(layerDescs, descriptor{ + MediaType: mt, + Size: int64(len(l.body)), + Digest: dig, + Annotations: anns, + }) + } + + manifest := map[string]any{ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "config": descriptor{ + MediaType: "application/vnd.oci.image.config.v1+json", + Size: int64(len(`{"architecture":"amd64","os":"linux"}`)), + Digest: configDigest, + }, + "layers": layerDescs, + } + manifestBytes, err := json.Marshal(manifest) + if err != nil { + t.Fatal(err) + } + manifestDigest := writeBlob(manifestBytes) + + index := map[string]any{ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.index.v1+json", + "manifests": []descriptor{ + { + MediaType: "application/vnd.oci.image.manifest.v1+json", + Size: int64(len(manifestBytes)), + Digest: manifestDigest, + Annotations: map[string]string{ + annotationRefName: "latest", + }, + Platform: map[string]string{"architecture": "amd64", "os": "linux"}, + }, + }, + } + indexBytes, err := json.Marshal(index) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "index.json"), indexBytes, 0o600); err != nil { + t.Fatal(err) + } + return root +} + +func TestReadOCILayers_MultiLayer(t *testing.T) { + dir := t.TempDir() + writeFakeOCILayout(t, dir, []fakeLayer{ + {body: []byte("layer0-base"), role: erofsRoleOverlay}, + {body: []byte("layer1-mid"), role: erofsRoleOverlay}, + {body: []byte("layer2-top")}, + }) + + refs, err := ReadOCILayers(dir, "", "amd64") + if err != nil { + t.Fatalf("ReadOCILayers: %v", err) + } + if len(refs) != 3 { + t.Fatalf("got %d layers, want 3", len(refs)) + } + for i, want := range []string{erofsRoleOverlay, erofsRoleOverlay, ""} { + if refs[i].Role != want { + t.Errorf("layer %d role: got %q want %q", i, refs[i].Role, want) + } + } + for i, ref := range refs { + if _, err := os.Stat(ref.BlobPath); err != nil { + t.Errorf("layer %d blob missing: %v", i, err) + } + } + // Bottom-up order. + if !strings.HasSuffix(refs[0].BlobPath, fmt.Sprintf("%x", sha256.Sum256([]byte("layer0-base")))) { + t.Errorf("layer 0 blob path does not match base layer: %s", refs[0].BlobPath) + } +} + +func TestReadOCILayers_SingleLayer(t *testing.T) { + dir := t.TempDir() + writeFakeOCILayout(t, dir, []fakeLayer{ + {body: []byte("only-layer")}, + }) + refs, err := ReadOCILayers(dir, "", "amd64") + if err != nil { + t.Fatalf("ReadOCILayers: %v", err) + } + if len(refs) != 1 || refs[0].Role != "" { + t.Fatalf("got refs=%+v, want one layer with empty role", refs) + } +} + +func TestReadOCILayers_WrongMediaType(t *testing.T) { + dir := t.TempDir() + writeFakeOCILayout(t, dir, []fakeLayer{ + {body: []byte("not-erofs"), mediaType: "application/vnd.oci.image.layer.v1.tar+gzip"}, + }) + _, err := ReadOCILayers(dir, "", "amd64") + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "EROFS") { + t.Fatalf("error %q should mention EROFS", err) + } +} + +func TestReadOCILayers_BadRoleOrder(t *testing.T) { + dir := t.TempDir() + // First layer lacks role annotation: invalid. + writeFakeOCILayout(t, dir, []fakeLayer{ + {body: []byte("a")}, + {body: []byte("b"), role: erofsRoleOverlay}, + }) + _, err := ReadOCILayers(dir, "", "amd64") + if err == nil || !strings.Contains(err.Error(), "missing") { + t.Fatalf("expected role-annotation error, got %v", err) + } +} + +func TestReadOCILayers_TagSelection(t *testing.T) { + dir := t.TempDir() + writeFakeOCILayout(t, dir, []fakeLayer{{body: []byte("x")}}) + if _, err := ReadOCILayers(dir, "latest", "amd64"); err != nil { + t.Fatalf("tag match: %v", err) + } + if _, err := ReadOCILayers(dir, "no-such-tag", "amd64"); err == nil { + t.Fatal("expected tag-not-found error") + } +} diff --git a/pkg/erofsmount/source.go b/pkg/erofsmount/source.go new file mode 100644 index 000000000..0960b15ee --- /dev/null +++ b/pkg/erofsmount/source.go @@ -0,0 +1,190 @@ +// Copyright 2026 Chainguard, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package erofsmount provides the building blocks for the `apko erofs` +// subcommands (mount, umount, ls). It exposes a small library: parse a source +// spec, read an OCI layout's EROFS layers, drive kernel or FUSE mounts, and +// persist/restore mount state for tear-down. +package erofsmount + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// Kind distinguishes a raw EROFS blob from an OCI layout directory. +type Kind int + +const ( + // KindBlob is a single raw EROFS filesystem image on disk. + KindBlob Kind = iota + // KindOCIDir is an OCI image layout directory whose layer mediaTypes are + // application/vnd.erofs. + KindOCIDir +) + +func (k Kind) String() string { + switch k { + case KindBlob: + return "blob" + case KindOCIDir: + return "oci-dir" + } + return "unknown" +} + +// Source is a resolved reference to either an EROFS blob or an OCI layout. +type Source struct { + Kind Kind + // Path is the absolute, cleaned path on disk. + Path string + // Tag, for KindOCIDir, optionally selects a manifest via the + // org.opencontainers.image.ref.name annotation in index.json. Empty means + // "use the sole manifest in the index". + Tag string + // Raw is the original spec the user passed, preserved for error messages. + Raw string +} + +const ( + prefixErofs = "erofs:" + prefixOCI = "oci:" + prefixOCIDir = "oci-dir:" +) + +// ParseSource resolves a user-supplied source spec into a Source. Accepted +// forms (checked in order): +// +// - "erofs:PATH" force KindBlob. +// - "oci:PATH[:TAG]" force KindOCIDir. +// - "oci-dir:PATH[:TAG]" force KindOCIDir. +// - "PATH" auto-detect: regular file → blob; directory +// containing an `oci-layout` file → OCI dir. +// - "PATH:TAG" only attempted when bare "PATH:TAG" doesn't +// resolve on disk; splits on the *last* colon and treats LHS as an OCI +// directory. +func ParseSource(spec string) (Source, error) { + if spec == "" { + return Source{}, fmt.Errorf("empty source spec") + } + + switch { + case strings.HasPrefix(spec, prefixErofs): + return resolveBlob(spec, strings.TrimPrefix(spec, prefixErofs)) + case strings.HasPrefix(spec, prefixOCIDir): + return resolveOCIDir(spec, strings.TrimPrefix(spec, prefixOCIDir)) + case strings.HasPrefix(spec, prefixOCI): + return resolveOCIDir(spec, strings.TrimPrefix(spec, prefixOCI)) + } + + // No prefix: stat the bare spec first; only fall back to path:tag splitting + // if it doesn't exist (so a directory containing a colon in its name still + // works when stat succeeds). + if info, err := os.Stat(spec); err == nil { + return classifyExisting(spec, info, spec, "") + } + + if idx := strings.LastIndex(spec, ":"); idx > 0 { + path, tag := spec[:idx], spec[idx+1:] + if tag == "" { + return Source{}, fmt.Errorf("source %q: empty tag after %q", spec, ":") + } + info, err := os.Stat(path) + if err == nil { + return classifyExisting(spec, info, path, tag) + } + } + + return Source{}, fmt.Errorf("source %q: not found", spec) +} + +func resolveBlob(raw, path string) (Source, error) { + info, err := os.Stat(path) + if err != nil { + return Source{}, fmt.Errorf("source %q: %w", raw, err) + } + if !info.Mode().IsRegular() { + return Source{}, fmt.Errorf("source %q: %s is not a regular file (erofs: requires a blob file)", raw, path) + } + abs, err := filepath.Abs(path) + if err != nil { + return Source{}, fmt.Errorf("source %q: resolve path: %w", raw, err) + } + return Source{Kind: KindBlob, Path: filepath.Clean(abs), Raw: raw}, nil +} + +func resolveOCIDir(raw, rest string) (Source, error) { + path, tag := rest, "" + if idx := strings.LastIndex(rest, ":"); idx > 0 { + // Only treat the rightmost colon as a tag separator if the prefix + // resolves to an OCI directory; otherwise the colon is part of the + // path (e.g. a directory whose name contains a colon). + candidatePath := rest[:idx] + candidateTag := rest[idx+1:] + if info, err := os.Stat(candidatePath); err == nil && info.IsDir() && hasOCILayout(candidatePath) { + path, tag = candidatePath, candidateTag + } + } + info, err := os.Stat(path) + if err != nil { + return Source{}, fmt.Errorf("source %q: %w", raw, err) + } + if !info.IsDir() { + return Source{}, fmt.Errorf("source %q: %s is not a directory (oci-dir: requires an OCI image layout)", raw, path) + } + if !hasOCILayout(path) { + return Source{}, fmt.Errorf("source %q: %s is not an OCI image layout (no `oci-layout` file)", raw, path) + } + abs, err := filepath.Abs(path) + if err != nil { + return Source{}, fmt.Errorf("source %q: resolve path: %w", raw, err) + } + return Source{Kind: KindOCIDir, Path: filepath.Clean(abs), Tag: tag, Raw: raw}, nil +} + +// classifyExisting routes a bare (no-prefix) spec whose primary path already +// exists on disk. tag is empty when the spec contained no `:tag` portion. +func classifyExisting(raw string, info os.FileInfo, path, tag string) (Source, error) { + switch { + case info.Mode().IsRegular(): + if tag != "" { + return Source{}, fmt.Errorf("source %q: %s is a file; a :tag selector is only meaningful for an OCI layout", raw, path) + } + abs, err := filepath.Abs(path) + if err != nil { + return Source{}, fmt.Errorf("source %q: resolve path: %w", raw, err) + } + return Source{Kind: KindBlob, Path: filepath.Clean(abs), Raw: raw}, nil + case info.IsDir(): + if !hasOCILayout(path) { + return Source{}, fmt.Errorf("source %q: %s is a directory but not an OCI image layout (no `oci-layout` file)", raw, path) + } + abs, err := filepath.Abs(path) + if err != nil { + return Source{}, fmt.Errorf("source %q: resolve path: %w", raw, err) + } + return Source{Kind: KindOCIDir, Path: filepath.Clean(abs), Tag: tag, Raw: raw}, nil + default: + return Source{}, fmt.Errorf("source %q: %s is not a regular file or directory", raw, path) + } +} + +// hasOCILayout reports whether dir contains the marker file `oci-layout` that +// designates an OCI image layout directory per the image-spec. +func hasOCILayout(dir string) bool { + info, err := os.Stat(filepath.Join(dir, "oci-layout")) + return err == nil && info.Mode().IsRegular() +} diff --git a/pkg/erofsmount/source_test.go b/pkg/erofsmount/source_test.go new file mode 100644 index 000000000..30871c020 --- /dev/null +++ b/pkg/erofsmount/source_test.go @@ -0,0 +1,110 @@ +// Copyright 2026 Chainguard, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package erofsmount + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestParseSource(t *testing.T) { + root := t.TempDir() + + blob := filepath.Join(root, "image.erofs") + if err := os.WriteFile(blob, []byte("not really erofs"), 0o600); err != nil { + t.Fatal(err) + } + ociDir := filepath.Join(root, "out") + if err := os.MkdirAll(ociDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(ociDir, "oci-layout"), []byte(`{"imageLayoutVersion":"1.0.0"}`), 0o600); err != nil { + t.Fatal(err) + } + plainDir := filepath.Join(root, "plain") + if err := os.MkdirAll(plainDir, 0o755); err != nil { + t.Fatal(err) + } + + // A directory whose name contains a colon and IS an OCI layout — bare-spec + // match should pick it up via direct stat (no tag splitting). + colonDir := filepath.Join(root, "weird:name") + if err := os.MkdirAll(colonDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(colonDir, "oci-layout"), []byte(`{"imageLayoutVersion":"1.0.0"}`), 0o600); err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + spec string + wantKind Kind + wantPath string + wantTag string + wantErr bool + errSubstr string + }{ + {"blob-bare", blob, KindBlob, blob, "", false, ""}, + {"blob-prefix", "erofs:" + blob, KindBlob, blob, "", false, ""}, + {"oci-bare", ociDir, KindOCIDir, ociDir, "", false, ""}, + {"oci-bare-with-tag", ociDir + ":latest", KindOCIDir, ociDir, "latest", false, ""}, + {"oci-prefix", "oci:" + ociDir, KindOCIDir, ociDir, "", false, ""}, + {"oci-prefix-with-tag", "oci:" + ociDir + ":v1", KindOCIDir, ociDir, "v1", false, ""}, + {"oci-dir-prefix", "oci-dir:" + ociDir, KindOCIDir, ociDir, "", false, ""}, + {"oci-dir-prefix-with-tag", "oci-dir:" + ociDir + ":latest", KindOCIDir, ociDir, "latest", false, ""}, + {"plain-dir-rejected", plainDir, 0, "", "", true, "not an OCI image layout"}, + {"plain-dir-with-tag-rejected", plainDir + ":latest", 0, "", "", true, "not an OCI image layout"}, + {"missing", filepath.Join(root, "no-such-thing"), 0, "", "", true, "not found"}, + {"empty", "", 0, "", "", true, "empty source"}, + {"blob-tag-error", blob + ":latest", 0, "", "", true, "tag selector"}, + {"erofs-prefix-on-dir", "erofs:" + ociDir, 0, "", "", true, "not a regular file"}, + {"oci-prefix-on-blob", "oci:" + blob, 0, "", "", true, "not a directory"}, + {"colon-in-dir-name", colonDir, KindOCIDir, colonDir, "", false, ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseSource(tt.spec) + if tt.wantErr { + if err == nil { + t.Fatalf("ParseSource(%q): want error, got %+v", tt.spec, got) + } + if tt.errSubstr != "" && !strings.Contains(err.Error(), tt.errSubstr) { + t.Fatalf("ParseSource(%q): error %q does not contain %q", tt.spec, err, tt.errSubstr) + } + return + } + if err != nil { + t.Fatalf("ParseSource(%q): %v", tt.spec, err) + } + if got.Kind != tt.wantKind { + t.Errorf("kind: got %v, want %v", got.Kind, tt.wantKind) + } + wantAbs, _ := filepath.Abs(tt.wantPath) + if got.Path != filepath.Clean(wantAbs) { + t.Errorf("path: got %q, want %q", got.Path, wantAbs) + } + if got.Tag != tt.wantTag { + t.Errorf("tag: got %q, want %q", got.Tag, tt.wantTag) + } + if got.Raw != tt.spec { + t.Errorf("raw: got %q, want %q", got.Raw, tt.spec) + } + }) + } +} diff --git a/pkg/erofsmount/state.go b/pkg/erofsmount/state.go new file mode 100644 index 000000000..ec595f960 --- /dev/null +++ b/pkg/erofsmount/state.go @@ -0,0 +1,127 @@ +// Copyright 2026 Chainguard, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package erofsmount + +import ( + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "time" +) + +// Mode selects how mounts are performed. ModeAuto is resolved to ModeKernel or +// ModeFuse before being recorded in MountState. +type Mode string + +const ( + ModeAuto Mode = "auto" + ModeKernel Mode = "kernel" + ModeFuse Mode = "fuse" +) + +// StateSchemaVersion is the current MountState JSON schema version. +const StateSchemaVersion = 1 + +// stateFileName is written inside for image mounts (multi-layer overlay +// or single-layer wrapped in an OCI layout). It is *not* written for raw blob +// mounts: there is no enclosing directory for them. +const stateFileName = ".apko-erofs-mount.json" + +// MountState describes a completed mount produced by Mount. The file +// authoritatively records what was mounted so that Unmount can tear it down +// without re-deriving the layout from the source. +type MountState struct { + SchemaVersion int `json:"schemaVersion"` + Mode Mode `json:"mode"` // resolved mode (kernel|fuse), never "auto" + Source string `json:"source"` // the original `spec` argument + Dest string `json:"dest"` // absolute path of the mount target + Created time.Time `json:"created"` // wall-clock timestamp at mount completion + // Mounts lists every mountpoint produced by Mount in unmount order + // (LIFO): the first element is unmounted first. For an image mount this + // is [/merged, /layers/NN, ..., /layers/00]. + Mounts []string `json:"mounts"` +} + +// StatePath returns the location of the state file inside dest. +func StatePath(dest string) string { + return filepath.Join(dest, stateFileName) +} + +// WriteState writes s atomically to StatePath(dest). The file is written via +// CreateTemp+Rename in the same directory so a partial write can never be +// observed. +func WriteState(dest string, s *MountState) error { + path := StatePath(dest) + tmp, err := os.CreateTemp(filepath.Dir(path), ".apko-erofs-mount-*.json") + if err != nil { + return fmt.Errorf("create state tmpfile: %w", err) + } + tmpName := tmp.Name() + defer func() { + // Best-effort cleanup if Rename never happened. + _ = os.Remove(tmpName) + }() + enc := json.NewEncoder(tmp) + enc.SetIndent("", " ") + if err := enc.Encode(s); err != nil { + _ = tmp.Close() + return fmt.Errorf("encode state: %w", err) + } + if err := tmp.Sync(); err != nil { + _ = tmp.Close() + return fmt.Errorf("sync state: %w", err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("close state: %w", err) + } + if err := os.Rename(tmpName, path); err != nil { + return fmt.Errorf("rename state into place: %w", err) + } + return nil +} + +// LoadState reads StatePath(dest). If the file does not exist, the returned +// error wraps fs.ErrNotExist so callers can use errors.Is. +func LoadState(dest string) (*MountState, error) { + path := StatePath(dest) + data, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil, fmt.Errorf("no mount state at %s: %w", path, err) + } + return nil, fmt.Errorf("read state %s: %w", path, err) + } + var s MountState + if err := json.Unmarshal(data, &s); err != nil { + return nil, fmt.Errorf("parse state %s: %w", path, err) + } + if s.SchemaVersion != StateSchemaVersion { + return nil, fmt.Errorf("state %s: unsupported schemaVersion %d (want %d)", path, s.SchemaVersion, StateSchemaVersion) + } + return &s, nil +} + +// RemoveState deletes StatePath(dest). It is a no-op if the file is already +// absent. +func RemoveState(dest string) error { + err := os.Remove(StatePath(dest)) + if err != nil && !errors.Is(err, fs.ErrNotExist) { + return err + } + return nil +} diff --git a/pkg/erofsmount/state_test.go b/pkg/erofsmount/state_test.go new file mode 100644 index 000000000..417c0f18b --- /dev/null +++ b/pkg/erofsmount/state_test.go @@ -0,0 +1,100 @@ +// Copyright 2026 Chainguard, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package erofsmount + +import ( + "errors" + "io/fs" + "os" + "path/filepath" + "reflect" + "testing" + "time" +) + +func TestStateRoundTrip(t *testing.T) { + dest := t.TempDir() + in := &MountState{ + SchemaVersion: StateSchemaVersion, + Mode: ModeKernel, + Source: "oci-dir:./out:latest", + Dest: dest, + Created: time.Date(2026, 5, 27, 12, 0, 0, 0, time.UTC), + Mounts: []string{ + filepath.Join(dest, "merged"), + filepath.Join(dest, "layers", "02"), + filepath.Join(dest, "layers", "01"), + filepath.Join(dest, "layers", "00"), + }, + } + if err := WriteState(dest, in); err != nil { + t.Fatalf("WriteState: %v", err) + } + if _, err := os.Stat(StatePath(dest)); err != nil { + t.Fatalf("state file missing: %v", err) + } + + out, err := LoadState(dest) + if err != nil { + t.Fatalf("LoadState: %v", err) + } + if !reflect.DeepEqual(in, out) { + t.Fatalf("roundtrip mismatch:\n in=%+v\n out=%+v", in, out) + } + + // No leftover tempfile from the atomic write. + entries, err := os.ReadDir(dest) + if err != nil { + t.Fatal(err) + } + for _, e := range entries { + name := e.Name() + if len(name) > len(".apko-erofs-mount-") && name[:len(".apko-erofs-mount-")] == ".apko-erofs-mount-" { + t.Errorf("stray tempfile left behind: %s", name) + } + } + + if err := RemoveState(dest); err != nil { + t.Fatalf("RemoveState: %v", err) + } + if _, err := os.Stat(StatePath(dest)); !errors.Is(err, fs.ErrNotExist) { + t.Fatalf("state still present after remove: err=%v", err) + } + // Idempotent remove. + if err := RemoveState(dest); err != nil { + t.Fatalf("RemoveState (idempotent): %v", err) + } +} + +func TestLoadStateMissing(t *testing.T) { + dest := t.TempDir() + _, err := LoadState(dest) + if err == nil { + t.Fatal("expected error") + } + if !errors.Is(err, fs.ErrNotExist) { + t.Fatalf("error %v should wrap fs.ErrNotExist", err) + } +} + +func TestLoadStateWrongSchema(t *testing.T) { + dest := t.TempDir() + if err := os.WriteFile(StatePath(dest), []byte(`{"schemaVersion":99}`), 0o600); err != nil { + t.Fatal(err) + } + if _, err := LoadState(dest); err == nil { + t.Fatal("expected schema version error") + } +} diff --git a/pkg/erofsmount/stub_other.go b/pkg/erofsmount/stub_other.go new file mode 100644 index 000000000..d5efa9a19 --- /dev/null +++ b/pkg/erofsmount/stub_other.go @@ -0,0 +1,55 @@ +// Copyright 2026 Chainguard, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build !linux + +package erofsmount + +import ( + "context" + "fmt" + "io" + "runtime" +) + +// Driver, NewDriver, ResolveMode are intentionally absent on non-Linux: the +// EROFS kernel module, erofsfuse, overlayfs, and fuse-overlayfs are Linux +// concepts. The exported Mount/Unmount/Ls return a clear error so callers +// (the CLI) don't have to gate at every call site. + +func unsupportedOS() error { + return fmt.Errorf("apko erofs subcommands are only supported on Linux (running on %s)", runtime.GOOS) +} + +// Mount is a no-op stub on non-Linux that returns an error. +func Mount(_ context.Context, _ Source, _ string, _ Options) (*MountState, error) { + return nil, unsupportedOS() +} + +// Unmount is a no-op stub on non-Linux that returns an error. +func Unmount(_ context.Context, _ string) error { + return unsupportedOS() +} + +// Ls is a no-op stub on non-Linux that returns an error. +func Ls(_ context.Context, _ Source, _ Options, _ io.Writer) error { + return unsupportedOS() +} + +// Options is defined on non-Linux to keep the CLI build-tag-free. +type Options struct { + Mode Mode + Arch string + ReadOnly bool +} From d9c01a4b1e53809fc16d63fa2606a817381fe19e Mon Sep 17 00:00:00 2001 From: Scott Moser Date: Wed, 27 May 2026 11:48:33 -0400 Subject: [PATCH 04/33] golangci-lint --- pkg/erofsmount/ls_linux.go | 12 ++++-------- pkg/erofsmount/ls_linux_test.go | 4 ++-- pkg/erofsmount/mount_linux.go | 5 +++-- pkg/erofsmount/oci_test.go | 5 ++--- 4 files changed, 11 insertions(+), 15 deletions(-) diff --git a/pkg/erofsmount/ls_linux.go b/pkg/erofsmount/ls_linux.go index b4566c6f2..b04f230b5 100644 --- a/pkg/erofsmount/ls_linux.go +++ b/pkg/erofsmount/ls_linux.go @@ -92,10 +92,7 @@ func walkAndPrint(ctx context.Context, root string, w io.Writer) error { if err != nil { return err } - line, err := formatEntry(info, rel, path) - if err != nil { - return err - } + line := formatEntry(info, rel, path) if _, werr := fmt.Fprintln(tw, line); werr != nil { return werr } @@ -107,7 +104,7 @@ func walkAndPrint(ctx context.Context, root string, w io.Writer) error { return tw.Flush() } -func formatEntry(info fs.FileInfo, rel, path string) (string, error) { +func formatEntry(info fs.FileInfo, rel, path string) string { mode := info.Mode() modeStr := formatMode(mode) @@ -122,13 +119,12 @@ func formatEntry(info fs.FileInfo, rel, path string) (string, error) { suffix := "" if mode&fs.ModeSymlink != 0 { - target, err := os.Readlink(path) - if err == nil { + if target, err := os.Readlink(path); err == nil { suffix = " -> " + target } } - return fmt.Sprintf("%s\t%d/%d\t%d\t%s\t%s%s", modeStr, uid, gid, size, mt, rel, suffix), nil + return fmt.Sprintf("%s\t%d/%d\t%d\t%s\t%s%s", modeStr, uid, gid, size, mt, rel, suffix) } // formatMode renders a 10-character mode string in the style of `ls -l`. diff --git a/pkg/erofsmount/ls_linux_test.go b/pkg/erofsmount/ls_linux_test.go index 2f9649b6c..61791f59b 100644 --- a/pkg/erofsmount/ls_linux_test.go +++ b/pkg/erofsmount/ls_linux_test.go @@ -73,8 +73,8 @@ func TestWalkAndPrint(t *testing.T) { for line := range strings.SplitSeq(strings.TrimSpace(out), "\n") { fields := strings.Fields(line) rel := fields[len(fields)-1] - if i := strings.Index(line, " -> "); i >= 0 { - before := strings.Fields(line[:i]) + if left, _, ok := strings.Cut(line, " -> "); ok { + before := strings.Fields(left) rel = before[len(before)-1] } if strings.HasPrefix(rel, "/") || rel == "." || rel == "" { diff --git a/pkg/erofsmount/mount_linux.go b/pkg/erofsmount/mount_linux.go index b0fccfe5f..58dfc8830 100644 --- a/pkg/erofsmount/mount_linux.go +++ b/pkg/erofsmount/mount_linux.go @@ -23,6 +23,7 @@ import ( "io/fs" "os" "path/filepath" + "slices" "time" "github.com/chainguard-dev/clog" @@ -127,8 +128,8 @@ func mountImage(ctx context.Context, drv Driver, src Source, dest string, opts O if retErr == nil { return } - for i := len(cleanups) - 1; i >= 0; i-- { - if err := cleanups[i](); err != nil { + for _, c := range slices.Backward(cleanups) { + if err := c(); err != nil { log.Warnf("cleanup on error: %v", err) } } diff --git a/pkg/erofsmount/oci_test.go b/pkg/erofsmount/oci_test.go index 2b6016cfd..f281acd5d 100644 --- a/pkg/erofsmount/oci_test.go +++ b/pkg/erofsmount/oci_test.go @@ -34,8 +34,8 @@ type fakeLayer struct { } // writeFakeOCILayout writes a minimal OCI image layout under root with one or -// more EROFS layers. Returns the path it wrote to. -func writeFakeOCILayout(t *testing.T, root string, layers []fakeLayer) string { +// more EROFS layers. +func writeFakeOCILayout(t *testing.T, root string, layers []fakeLayer) { t.Helper() blobsDir := filepath.Join(root, "blobs", "sha256") if err := os.MkdirAll(blobsDir, 0o755); err != nil { @@ -124,7 +124,6 @@ func writeFakeOCILayout(t *testing.T, root string, layers []fakeLayer) string { if err := os.WriteFile(filepath.Join(root, "index.json"), indexBytes, 0o600); err != nil { t.Fatal(err) } - return root } func TestReadOCILayers_MultiLayer(t *testing.T) { From 545f18ddac9b2ff07eaf30a155766b695ffc0617 Mon Sep 17 00:00:00 2001 From: Scott Moser Date: Wed, 27 May 2026 12:02:54 -0400 Subject: [PATCH 05/33] erofs: make `ls` mount-less and cross-platform via layered fs.FS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `apko erofs ls` now opens each EROFS layer blob directly with go-erofs and walks a layered fs.FS in user space, instead of mounting the layers and walking the merged mountpoint. This removes the kernel/FUSE dependency for `ls` (works on darwin/windows too), eliminates the mount log noise, and is faster. Introduces a reusable pkg/erofsmount.Stack: a layered fs.FS implementing fs.ReadDirFS/StatFS/ReadLinkFS with full AUFS-style overlay semantics — .wh.NAME whiteouts hide siblings, .wh..wh..opq markers hide all lower- layer entries in a directory, ancestor whiteouts hide whole subtrees, type-mismatch in a higher layer shadows lower contents. apko's writer never emits whiteouts (it splits one rootfs into groups, doesn't merge), so 15 unit tests synthesize the whiteout cases via testing/fstest.MapFS. Mount and Unmount remain Linux-only since they genuinely need the kernel or FUSE. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/erofs.md | 4 +- pkg/erofsmount/ls.go | 155 +++++++++++++ pkg/erofsmount/ls_linux.go | 159 ------------- pkg/erofsmount/ls_linux_test.go | 84 ------- pkg/erofsmount/mount_linux.go | 13 -- pkg/erofsmount/openlayers.go | 93 ++++++++ pkg/erofsmount/stack.go | 394 +++++++++++++++++++++++++++++++ pkg/erofsmount/stack_test.go | 399 ++++++++++++++++++++++++++++++++ pkg/erofsmount/state.go | 14 ++ pkg/erofsmount/stub_other.go | 21 +- 10 files changed, 1062 insertions(+), 274 deletions(-) create mode 100644 pkg/erofsmount/ls.go delete mode 100644 pkg/erofsmount/ls_linux.go delete mode 100644 pkg/erofsmount/ls_linux_test.go create mode 100644 pkg/erofsmount/openlayers.go create mode 100644 pkg/erofsmount/stack.go create mode 100644 pkg/erofsmount/stack_test.go diff --git a/docs/erofs.md b/docs/erofs.md index 625e5a652..ed5f4bed8 100644 --- a/docs/erofs.md +++ b/docs/erofs.md @@ -145,7 +145,7 @@ cat extracted/etc/os-release ### List contents with `apko erofs ls` -For a quick `tar tvf`-style listing of any EROFS source (raw blob or OCI image directory), use `apko erofs ls`. It transparently mounts read-only, walks the tree, prints one line per entry, and unmounts automatically. +For a quick `tar tvf`-style listing of any EROFS source (raw blob or OCI image directory), use `apko erofs ls`. It opens the EROFS blobs directly, walks the merged view in user space, and prints one line per entry — no mounts, no root or FUSE required, works on Linux/macOS/Windows. ```sh apko erofs ls out/blobs/sha256/$LAYER | head @@ -156,7 +156,7 @@ apko erofs ls out/blobs/sha256/$LAYER | head apko erofs ls out/ # works against the whole OCI image too ``` -`apko erofs ls` picks `kernel` mode automatically when running as root and `fuse` mode otherwise. Override with `--mode=kernel|fuse|auto`. +For multi-layer images, `ls` applies AUFS-style overlay semantics in user space (whiteouts, opaque markers) to present the merged view the kernel would assemble. ## Mount the layer diff --git a/pkg/erofsmount/ls.go b/pkg/erofsmount/ls.go new file mode 100644 index 000000000..acfb2cf73 --- /dev/null +++ b/pkg/erofsmount/ls.go @@ -0,0 +1,155 @@ +// Copyright 2026 Chainguard, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package erofsmount + +import ( + "context" + "fmt" + "io" + "io/fs" + "strings" + "text/tabwriter" + + "github.com/chainguard-dev/clog" +) + +// Ls produces a `tar tvf`-style listing of every entry in src. It opens each +// EROFS layer blob directly via go-erofs, presents the layers as a single +// merged view via Stack, walks that view, and prints each entry to w. +// +// Ls does not mount anything and is cross-platform — it works wherever +// go-erofs builds, regardless of kernel features. +// +// The opts.Mode, opts.Arch, and opts.ReadOnly fields are inherited from the +// Mount API for shape parity; only Arch is meaningful here (used to pick a +// manifest from a multi-arch OCI index). +func Ls(ctx context.Context, src Source, opts Options, w io.Writer) error { + log := clog.FromContext(ctx) + + layers, cleanup, err := OpenLayers(src, opts.Arch) + if err != nil { + return fmt.Errorf("open layers: %w", err) + } + defer func() { + if cerr := cleanup(); cerr != nil { + log.Warnf("close layer blobs: %v", cerr) + } + }() + + stack := NewStack(layers...) + return walkAndPrint(ctx, stack, w) +} + +// walkAndPrint walks fsys and writes one line per entry to w in a format +// similar to `tar tvf`: mode uid/gid size yyyy-mm-dd hh:mm relpath[ -> target]. +func walkAndPrint(ctx context.Context, fsys fs.FS, w io.Writer) error { + tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) + err := fs.WalkDir(fsys, ".", func(name string, d fs.DirEntry, walkErr error) error { + if cerr := ctx.Err(); cerr != nil { + return cerr + } + if walkErr != nil { + return walkErr + } + if name == "." { + return nil + } + info, err := lstatOn(fsys, name) + if err != nil { + return err + } + line := formatEntry(fsys, info, name) + if _, werr := fmt.Fprintln(tw, line); werr != nil { + return werr + } + return nil + }) + if err != nil { + return err + } + return tw.Flush() +} + +// formatEntry renders one entry. uid/gid are pulled from go-erofs's accessor +// interfaces on info.Sys(); they're zero for entries from filesystems that +// don't expose them. Symlink targets come from fs.ReadLinkFS when fsys +// implements it. +func formatEntry(fsys fs.FS, info fs.FileInfo, name string) string { + uid, gid := uidGidFromSys(info.Sys()) + size := info.Size() + mt := info.ModTime().UTC().Format("2006-01-02 15:04") + + suffix := "" + if info.Mode()&fs.ModeSymlink != 0 { + if rl, ok := fsys.(fs.ReadLinkFS); ok { + if t, err := rl.ReadLink(name); err == nil { + suffix = " -> " + t + } + } + } + + return fmt.Sprintf("%s\t%d/%d\t%d\t%s\t%s%s", + formatMode(info.Mode()), uid, gid, size, mt, name, suffix) +} + +// uidGidFromSys extracts numeric ownership from info.Sys() via the +// single-method accessor interfaces that go-erofs documents on its Stat +// type. Anything else (including nil) yields (0, 0). +func uidGidFromSys(sys any) (uint32, uint32) { + if sys == nil { + return 0, 0 + } + type uider interface{ UID() uint32 } + type gider interface{ GID() uint32 } + var uid, gid uint32 + if u, ok := sys.(uider); ok { + uid = u.UID() + } + if g, ok := sys.(gider); ok { + gid = g.GID() + } + return uid, gid +} + +// formatMode renders a 10-character mode string in the style of `ls -l`. +func formatMode(mode fs.FileMode) string { + var b strings.Builder + b.Grow(10) + switch { + case mode.IsDir(): + b.WriteByte('d') + case mode&fs.ModeSymlink != 0: + b.WriteByte('l') + case mode&fs.ModeNamedPipe != 0: + b.WriteByte('p') + case mode&fs.ModeSocket != 0: + b.WriteByte('s') + case mode&fs.ModeCharDevice != 0: + b.WriteByte('c') + case mode&fs.ModeDevice != 0: + b.WriteByte('b') + default: + b.WriteByte('-') + } + perm := mode.Perm() + for i, ch := range "rwxrwxrwx" { + if perm&(1<<(8-i)) != 0 { + b.WriteByte(byte(ch)) + } else { + b.WriteByte('-') + } + } + return b.String() +} diff --git a/pkg/erofsmount/ls_linux.go b/pkg/erofsmount/ls_linux.go deleted file mode 100644 index b04f230b5..000000000 --- a/pkg/erofsmount/ls_linux.go +++ /dev/null @@ -1,159 +0,0 @@ -// Copyright 2026 Chainguard, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -//go:build linux - -package erofsmount - -import ( - "context" - "fmt" - "io" - "io/fs" - "os" - "path/filepath" - "strings" - "syscall" - "text/tabwriter" - - "github.com/chainguard-dev/clog" -) - -// Ls produces a `tar tvf`-style listing of every entry in src. It mounts src -// read-only to a temporary directory, walks the merged view (or the single -// blob mountpoint for KindBlob), prints each entry to w, then unmounts and -// removes the temporary directory. -func Ls(ctx context.Context, src Source, opts Options, w io.Writer) (retErr error) { - log := clog.FromContext(ctx) - tmp, err := os.MkdirTemp("", "apko-erofs-ls-*") - if err != nil { - return fmt.Errorf("mkdir tmp: %w", err) - } - defer func() { - if rmErr := os.RemoveAll(tmp); rmErr != nil { - log.Warnf("remove tmp %s: %v", tmp, rmErr) - } - }() - - opts.ReadOnly = true - if _, err := Mount(ctx, src, tmp, opts); err != nil { - return err - } - defer func() { - if uerr := Unmount(ctx, tmp); uerr != nil { - if retErr == nil { - retErr = fmt.Errorf("unmount after ls: %w", uerr) - } else { - log.Warnf("unmount after ls error: %v", uerr) - } - } - }() - - root := tmp - if src.Kind == KindOCIDir { - root = filepath.Join(tmp, "merged") - } - - return walkAndPrint(ctx, root, w) -} - -// walkAndPrint walks root and writes one line per entry to w in a format -// similar to `tar tvf`: mode uid/gid size yyyy-mm-dd hh:mm relpath[ -> target]. -func walkAndPrint(ctx context.Context, root string, w io.Writer) error { - tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) - rootClean := filepath.Clean(root) - err := filepath.WalkDir(rootClean, func(path string, d fs.DirEntry, err error) error { - if cerr := ctx.Err(); cerr != nil { - return cerr - } - if err != nil { - return err - } - // Skip the root itself. - if path == rootClean { - return nil - } - info, err := os.Lstat(path) - if err != nil { - return err - } - rel, err := filepath.Rel(rootClean, path) - if err != nil { - return err - } - line := formatEntry(info, rel, path) - if _, werr := fmt.Fprintln(tw, line); werr != nil { - return werr - } - return nil - }) - if err != nil { - return err - } - return tw.Flush() -} - -func formatEntry(info fs.FileInfo, rel, path string) string { - mode := info.Mode() - modeStr := formatMode(mode) - - var uid, gid int - if st, ok := info.Sys().(*syscall.Stat_t); ok { - uid = int(st.Uid) - gid = int(st.Gid) - } - - size := info.Size() - mt := info.ModTime().UTC().Format("2006-01-02 15:04") - - suffix := "" - if mode&fs.ModeSymlink != 0 { - if target, err := os.Readlink(path); err == nil { - suffix = " -> " + target - } - } - - return fmt.Sprintf("%s\t%d/%d\t%d\t%s\t%s%s", modeStr, uid, gid, size, mt, rel, suffix) -} - -// formatMode renders a 10-character mode string in the style of `ls -l`. -func formatMode(mode fs.FileMode) string { - var b strings.Builder - b.Grow(10) - switch { - case mode.IsDir(): - b.WriteByte('d') - case mode&fs.ModeSymlink != 0: - b.WriteByte('l') - case mode&fs.ModeNamedPipe != 0: - b.WriteByte('p') - case mode&fs.ModeSocket != 0: - b.WriteByte('s') - case mode&fs.ModeCharDevice != 0: - b.WriteByte('c') - case mode&fs.ModeDevice != 0: - b.WriteByte('b') - default: - b.WriteByte('-') - } - perm := mode.Perm() - for i, ch := range "rwxrwxrwx" { - if perm&(1<<(8-i)) != 0 { - b.WriteByte(byte(ch)) - } else { - b.WriteByte('-') - } - } - return b.String() -} diff --git a/pkg/erofsmount/ls_linux_test.go b/pkg/erofsmount/ls_linux_test.go deleted file mode 100644 index 61791f59b..000000000 --- a/pkg/erofsmount/ls_linux_test.go +++ /dev/null @@ -1,84 +0,0 @@ -// Copyright 2026 Chainguard, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -//go:build linux - -package erofsmount - -import ( - "bytes" - "context" - "io/fs" - "os" - "path/filepath" - "strings" - "testing" -) - -func TestFormatMode(t *testing.T) { - cases := []struct { - mode fs.FileMode - want string - }{ - {fs.ModeDir | 0o755, "drwxr-xr-x"}, - {0o644, "-rw-r--r--"}, - {fs.ModeSymlink | 0o777, "lrwxrwxrwx"}, - {0o600, "-rw-------"}, - {fs.ModeNamedPipe | 0o644, "prw-r--r--"}, - } - for _, c := range cases { - got := formatMode(c.mode) - if got != c.want { - t.Errorf("formatMode(%v): got %q, want %q", c.mode, got, c.want) - } - } -} - -func TestWalkAndPrint(t *testing.T) { - root := t.TempDir() - if err := os.MkdirAll(filepath.Join(root, "bin"), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(root, "bin", "sh"), []byte("hi"), 0o755); err != nil { - t.Fatal(err) - } - if err := os.Symlink("/bin/busybox", filepath.Join(root, "bin", "ls")); err != nil { - t.Fatal(err) - } - - var buf bytes.Buffer - if err := walkAndPrint(context.Background(), root, &buf); err != nil { - t.Fatalf("walkAndPrint: %v", err) - } - out := buf.String() - if !strings.Contains(out, "bin/sh") { - t.Errorf("output missing bin/sh:\n%s", out) - } - if !strings.Contains(out, "bin/ls -> /bin/busybox") { - t.Errorf("symlink target missing:\n%s", out) - } - // Root itself must not be listed: every emitted relpath must start with - // a known top-level child (bin/...). Strip any " -> target" suffix. - for line := range strings.SplitSeq(strings.TrimSpace(out), "\n") { - fields := strings.Fields(line) - rel := fields[len(fields)-1] - if left, _, ok := strings.Cut(line, " -> "); ok { - before := strings.Fields(left) - rel = before[len(before)-1] - } - if strings.HasPrefix(rel, "/") || rel == "." || rel == "" { - t.Errorf("relpath %q looks wrong in: %s", rel, line) - } - } -} diff --git a/pkg/erofsmount/mount_linux.go b/pkg/erofsmount/mount_linux.go index 58dfc8830..bb0e07d21 100644 --- a/pkg/erofsmount/mount_linux.go +++ b/pkg/erofsmount/mount_linux.go @@ -29,19 +29,6 @@ import ( "github.com/chainguard-dev/clog" ) -// Options bundles the optional knobs for Mount and Ls. -type Options struct { - // Mode selects ModeKernel, ModeFuse, or ModeAuto. Zero value is treated - // as ModeAuto. - Mode Mode - // Arch picks a manifest from a multi-arch OCI index. "" or "host" means - // runtime.GOARCH. - Arch string - // ReadOnly, when true, skips upper/work overlay dirs and produces a - // pure read-only overlay. Used by Ls. - ReadOnly bool -} - // Mount mounts src at dest. For KindBlob, dest is the single mountpoint. For // KindOCIDir, dest is a directory that receives the standard layout: // diff --git a/pkg/erofsmount/openlayers.go b/pkg/erofsmount/openlayers.go new file mode 100644 index 000000000..e5223de77 --- /dev/null +++ b/pkg/erofsmount/openlayers.go @@ -0,0 +1,93 @@ +// Copyright 2026 Chainguard, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package erofsmount + +import ( + "errors" + "fmt" + "io/fs" + "os" + + erofs "github.com/erofs/go-erofs" +) + +// OpenLayers opens every EROFS layer referenced by src and returns them in +// bottom-up order (layers[0] is the base). The returned close function must +// be called when the caller is done with the layers; it closes the +// underlying os.File handles. +// +// For KindBlob, the returned slice has exactly one element. For KindOCIDir +// arch may be "" or "host" (process arch) or a specific GOARCH value. +func OpenLayers(src Source, arch string) (layers []fs.FS, close func() error, err error) { + var files []*os.File + cleanup := func() error { + var errs []error + for _, f := range files { + if cerr := f.Close(); cerr != nil { + errs = append(errs, cerr) + } + } + return errors.Join(errs...) + } + // On any error before we return, close everything we already opened. + defer func() { + if err != nil { + _ = cleanup() + } + }() + + switch src.Kind { + case KindBlob: + f, l, oerr := openOneBlob(src.Path) + if oerr != nil { + return nil, nil, oerr + } + files = append(files, f) + layers = []fs.FS{l} + + case KindOCIDir: + refs, oerr := ReadOCILayers(src.Path, src.Tag, arch) + if oerr != nil { + return nil, nil, oerr + } + layers = make([]fs.FS, 0, len(refs)) + for _, ref := range refs { + f, l, oerr := openOneBlob(ref.BlobPath) + if oerr != nil { + return nil, nil, fmt.Errorf("open layer %s: %w", ref.Digest, oerr) + } + files = append(files, f) + layers = append(layers, l) + } + + default: + return nil, nil, fmt.Errorf("unsupported source kind: %v", src.Kind) + } + + return layers, cleanup, nil +} + +func openOneBlob(path string) (*os.File, fs.FS, error) { + f, err := os.Open(path) + if err != nil { + return nil, nil, err + } + l, err := erofs.Open(f) + if err != nil { + _ = f.Close() + return nil, nil, fmt.Errorf("erofs.Open %s: %w", path, err) + } + return f, l, nil +} diff --git a/pkg/erofsmount/stack.go b/pkg/erofsmount/stack.go new file mode 100644 index 000000000..5db17e814 --- /dev/null +++ b/pkg/erofsmount/stack.go @@ -0,0 +1,394 @@ +// Copyright 2026 Chainguard, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package erofsmount + +import ( + "io" + "io/fs" + "path" + "slices" + "sort" + "strings" + "time" +) + +// Stack presents N fs.FS layers as a single fs.FS using AUFS-style overlay +// semantics. Layers are stored bottom-up: layers[0] is the base, the last +// element is the topmost. Topmost-wins is the rule for lookups. +// +// Whiteout encoding (matches OCI tar layers and go-erofs Writer.Merge): +// +// - `.wh.NAME` as a sibling of NAME hides NAME from lower layers. +// - `.wh..wh..opq` in a directory hides all entries from lower layers in +// that directory; entries that the same layer also has live remain. +// +// Stack implements fs.FS, fs.ReadDirFS, fs.StatFS, and fs.ReadLinkFS. +type Stack struct { + layers []fs.FS +} + +// NewStack returns a Stack over layers, in bottom-up order (layers[0] is the +// base). Callers that hold layers in OCI manifest order can pass them +// directly; OCI manifest order is also bottom-up. +func NewStack(layers ...fs.FS) *Stack { + cp := make([]fs.FS, len(layers)) + copy(cp, layers) + return &Stack{layers: cp} +} + +// Open implements fs.FS. For regular files, symlinks, and devices the +// returned fs.File is the topmost layer's view of the entry. For directories +// the returned fs.File is a synthetic fs.ReadDirFile that, on ReadDir, yields +// the merged union of all layer entries with whiteouts applied. +func (s *Stack) Open(name string) (fs.File, error) { + if !fs.ValidPath(name) { + return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrInvalid} + } + if name == "." { + return s.openRoot() + } + layer, err := s.lookup(name) + if err != nil { + return nil, &fs.PathError{Op: "open", Path: name, Err: err} + } + f, err := s.layers[layer].Open(name) + if err != nil { + return nil, err + } + info, err := f.Stat() + if err != nil { + _ = f.Close() + return nil, err + } + if info.IsDir() { + _ = f.Close() + return s.openDir(name, info) + } + return f, nil +} + +// Stat implements fs.StatFS. +func (s *Stack) Stat(name string) (fs.FileInfo, error) { + if !fs.ValidPath(name) { + return nil, &fs.PathError{Op: "stat", Path: name, Err: fs.ErrInvalid} + } + if name == "." { + return s.rootInfo() + } + layer, err := s.lookup(name) + if err != nil { + return nil, &fs.PathError{Op: "stat", Path: name, Err: err} + } + return statOn(s.layers[layer], name) +} + +// Lstat implements fs.ReadLinkFS by returning the topmost layer's view of +// the named entry without following symlinks. Without Lstat, fs.ReadLinkFS +// is not satisfied and the package-level fs.ReadLink helper rejects Stack. +func (s *Stack) Lstat(name string) (fs.FileInfo, error) { + if !fs.ValidPath(name) { + return nil, &fs.PathError{Op: "lstat", Path: name, Err: fs.ErrInvalid} + } + if name == "." { + return s.rootInfo() + } + layer, err := s.lookup(name) + if err != nil { + return nil, &fs.PathError{Op: "lstat", Path: name, Err: err} + } + return lstatOn(s.layers[layer], name) +} + +// ReadDir implements fs.ReadDirFS, merging entries from every layer that +// contributes to the directory. +func (s *Stack) ReadDir(name string) ([]fs.DirEntry, error) { + if !fs.ValidPath(name) { + return nil, &fs.PathError{Op: "readdir", Path: name, Err: fs.ErrInvalid} + } + if name != "." { + // Confirm the directory exists (not whitedout) in some layer. + layer, err := s.lookup(name) + if err != nil { + return nil, &fs.PathError{Op: "readdir", Path: name, Err: err} + } + info, err := statOn(s.layers[layer], name) + if err != nil { + return nil, &fs.PathError{Op: "readdir", Path: name, Err: err} + } + if !info.IsDir() { + return nil, &fs.PathError{Op: "readdir", Path: name, Err: fs.ErrInvalid} + } + } + return s.mergeDir(name) +} + +// ReadLink implements fs.ReadLinkFS. +func (s *Stack) ReadLink(name string) (string, error) { + if !fs.ValidPath(name) { + return "", &fs.PathError{Op: "readlink", Path: name, Err: fs.ErrInvalid} + } + if name == "." { + return "", &fs.PathError{Op: "readlink", Path: name, Err: fs.ErrInvalid} + } + layer, err := s.lookup(name) + if err != nil { + return "", &fs.PathError{Op: "readlink", Path: name, Err: err} + } + if rl, ok := s.layers[layer].(fs.ReadLinkFS); ok { + return rl.ReadLink(name) + } + return "", &fs.PathError{Op: "readlink", Path: name, Err: fs.ErrInvalid} +} + +// lookup walks layers top-down looking for name. It returns the index of the +// topmost layer that has name live (not whitedout). It checks the parent +// directory of name in each layer for sibling whiteouts (.wh.NAME) and +// opaque markers (.wh..wh..opq) before descending to lower layers. +// +// Ancestors are resolved recursively: if any ancestor of name is whitedout, +// opaqued out, or shadowed by a non-directory in a higher layer, name is +// not reachable. The root (".") is always live; lookup(".") returns +// (-1, nil) to signal "root, no owning layer". +// +// If a layer contains both name and its whiteout (a malformed but possible +// state), the live entry wins. +func (s *Stack) lookup(name string) (int, error) { + if name == "." { + return -1, nil + } + parent, base := splitParent(name) + + // Each ancestor must be reachable AND a directory in its owning layer. + // Without this check, a whiteout or type-shadow on an ancestor wouldn't + // hide its descendants. + if parent != "." { + parentLayer, err := s.lookup(parent) + if err != nil { + return -1, err + } + if parentLayer >= 0 { + info, err := statOn(s.layers[parentLayer], parent) + if err != nil { + return -1, err + } + if !info.IsDir() { + return -1, fs.ErrNotExist + } + } + } + + for i, layer := range slices.Backward(s.layers) { + entries, err := readDirOn(layer, parent) + if err != nil { + // Parent doesn't exist in this layer; can't have a whiteout or + // the entry. Move down. + continue + } + var foundBase, foundWhiteout, foundOpaque bool + for _, e := range entries { + switch e.Name() { + case base: + foundBase = true + case whiteoutPrefix + base: + foundWhiteout = true + case opaqueMarker: + foundOpaque = true + } + } + switch { + case foundBase: + return i, nil + case foundWhiteout, foundOpaque: + return -1, fs.ErrNotExist + } + } + return -1, fs.ErrNotExist +} + +// mergeDir produces the union of name's entries across layers, top-down, +// applying whiteouts and stopping at the first opaque marker. Within a +// single layer, if a name is both live and whitedout, the live entry wins +// and the in-layer whiteout is treated as a no-op (lower layers still see +// the layer's live entry shadowing them). +func (s *Stack) mergeDir(name string) ([]fs.DirEntry, error) { + seen := map[string]bool{} // covers live entries returned so far + tombstones + var out []fs.DirEntry + for _, layer := range slices.Backward(s.layers) { + entries, err := readDirOn(layer, name) + if err != nil { + continue + } + var opaqueInThisLayer bool + liveInThisLayer := map[string]fs.DirEntry{} + whiteoutInThisLayer := map[string]bool{} + for _, e := range entries { + n := e.Name() + switch { + case n == opaqueMarker: + opaqueInThisLayer = true + case strings.HasPrefix(n, whiteoutPrefix): + whiteoutInThisLayer[strings.TrimPrefix(n, whiteoutPrefix)] = true + default: + liveInThisLayer[n] = e + } + } + // Add this layer's live entries that haven't already been provided + // by an upper layer. + for n, e := range liveInThisLayer { + if !seen[n] { + seen[n] = true + out = append(out, e) + } + } + // Apply tombstones from this layer for lower layers. If a name is + // also live here, the live entry shadows lower layers already. + for n := range whiteoutInThisLayer { + if _, live := liveInThisLayer[n]; !live { + seen[n] = true + } + } + if opaqueInThisLayer { + break // lower layers' entries are hidden + } + } + sort.Slice(out, func(i, j int) bool { return out[i].Name() < out[j].Name() }) + return out, nil +} + +// rootInfo returns FileInfo for ".". The topmost layer that has a root wins +// for metadata. +func (s *Stack) rootInfo() (fs.FileInfo, error) { + for _, layer := range slices.Backward(s.layers) { + if info, err := statOn(layer, "."); err == nil { + return info, nil + } + } + if len(s.layers) == 0 { + return syntheticDirInfo(".", time.Time{}), nil + } + return nil, &fs.PathError{Op: "stat", Path: ".", Err: fs.ErrNotExist} +} + +func (s *Stack) openRoot() (fs.File, error) { + info, err := s.rootInfo() + if err != nil { + return nil, err + } + return s.openDir(".", info) +} + +func (s *Stack) openDir(name string, info fs.FileInfo) (fs.File, error) { + entries, err := s.mergeDir(name) + if err != nil { + return nil, err + } + return &stackDir{name: name, info: info, entries: entries}, nil +} + +// stackDir is a synthetic fs.ReadDirFile for a merged directory view. +type stackDir struct { + name string + info fs.FileInfo + entries []fs.DirEntry + pos int +} + +func (d *stackDir) Stat() (fs.FileInfo, error) { return d.info, nil } +func (d *stackDir) Read([]byte) (int, error) { + return 0, &fs.PathError{Op: "read", Path: d.name, Err: fs.ErrInvalid} +} +func (d *stackDir) Close() error { return nil } + +func (d *stackDir) ReadDir(n int) ([]fs.DirEntry, error) { + remaining := len(d.entries) - d.pos + if remaining == 0 { + if n <= 0 { + return nil, nil + } + return nil, io.EOF + } + if n <= 0 || n > remaining { + n = remaining + } + out := d.entries[d.pos : d.pos+n] + d.pos += n + return out, nil +} + +// readDirOn calls ReadDirFS if implemented, else falls back to the helper +// that wraps Open+ReadDirFile. +func readDirOn(fsys fs.FS, name string) ([]fs.DirEntry, error) { + if rd, ok := fsys.(fs.ReadDirFS); ok { + return rd.ReadDir(name) + } + return fs.ReadDir(fsys, name) +} + +// statOn calls StatFS if implemented, else falls back to Open+Stat. +func statOn(fsys fs.FS, name string) (fs.FileInfo, error) { + if st, ok := fsys.(fs.StatFS); ok { + return st.Stat(name) + } + return fs.Stat(fsys, name) +} + +// lstatOn calls ReadLinkFS.Lstat if implemented, else falls back to statOn +// (which is correct for non-symlink entries; the underlying fs.FS doesn't +// expose any way to inspect a symlink without ReadLinkFS support). +func lstatOn(fsys fs.FS, name string) (fs.FileInfo, error) { + if rl, ok := fsys.(fs.ReadLinkFS); ok { + return rl.Lstat(name) + } + return statOn(fsys, name) +} + +// splitParent splits name into (parent-dir, base) using fs.FS path +// conventions. For name=="." the result is (".", "."). +func splitParent(name string) (parent, base string) { + clean := path.Clean(name) + if clean == "." || clean == "/" { + return ".", "." + } + parent = path.Dir(clean) + base = path.Base(clean) + if parent == "" || parent == "/" { + parent = "." + } + return parent, base +} + +const ( + whiteoutPrefix = ".wh." + opaqueMarker = ".wh..wh..opq" +) + +// syntheticDirInfo produces a minimal fs.FileInfo for a synthetic directory +// (used only when Stack has zero layers, so callers don't crash). +func syntheticDirInfo(name string, mt time.Time) fs.FileInfo { + return &synthInfo{name: name, mode: fs.ModeDir | 0o555, mtime: mt} +} + +type synthInfo struct { + name string + mode fs.FileMode + mtime time.Time +} + +func (i *synthInfo) Name() string { return i.name } +func (i *synthInfo) Size() int64 { return 0 } +func (i *synthInfo) Mode() fs.FileMode { return i.mode } +func (i *synthInfo) ModTime() time.Time { return i.mtime } +func (i *synthInfo) IsDir() bool { return i.mode.IsDir() } +func (i *synthInfo) Sys() any { return nil } diff --git a/pkg/erofsmount/stack_test.go b/pkg/erofsmount/stack_test.go new file mode 100644 index 000000000..a32347390 --- /dev/null +++ b/pkg/erofsmount/stack_test.go @@ -0,0 +1,399 @@ +// Copyright 2026 Chainguard, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package erofsmount + +import ( + "errors" + "io/fs" + "reflect" + "slices" + "strings" + "testing" + "testing/fstest" +) + +// nakedFS strips the optional extension interfaces (ReadDirFS, StatFS, +// ReadLinkFS) so we can verify Stack's fallback paths. +type nakedFS struct{ inner fs.FS } + +func (n nakedFS) Open(name string) (fs.File, error) { return n.inner.Open(name) } + +// readDirNames returns the sorted entry names of "etc" in fsys. Every +// fixture in this file puts its top-level dir at "etc"; the helper exists +// just to keep tests focused on what's *in* etc, not on the wiring. +func readDirNames(t *testing.T, fsys fs.FS) []string { + t.Helper() + ents, err := fs.ReadDir(fsys, "etc") + if err != nil { + t.Fatalf("ReadDir(etc): %v", err) + } + out := make([]string, 0, len(ents)) + for _, e := range ents { + out = append(out, e.Name()) + } + slices.Sort(out) + return out +} + +func TestStack_Override_TopWins(t *testing.T) { + base := fstest.MapFS{ + "etc/hostname": {Data: []byte("base"), Mode: 0o644}, + } + top := fstest.MapFS{ + "etc/hostname": {Data: []byte("top"), Mode: 0o644}, + } + s := NewStack(base, top) + + data, err := fs.ReadFile(s, "etc/hostname") + if err != nil { + t.Fatal(err) + } + if string(data) != "top" { + t.Errorf("ReadFile: got %q, want %q", data, "top") + } + info, err := fs.Stat(s, "etc/hostname") + if err != nil { + t.Fatal(err) + } + if info.Size() != int64(len("top")) { + t.Errorf("Stat size: got %d, want %d", info.Size(), len("top")) + } +} + +func TestStack_WhiteoutFile_HidesFromLower(t *testing.T) { + base := fstest.MapFS{ + "etc/secret": {Data: []byte("oops"), Mode: 0o644}, + "etc/keep": {Data: []byte("kept"), Mode: 0o644}, + } + top := fstest.MapFS{ + "etc/.wh.secret": {Data: nil, Mode: 0o644}, + } + s := NewStack(base, top) + + if _, err := fs.Stat(s, "etc/secret"); !errors.Is(err, fs.ErrNotExist) { + t.Errorf("Stat etc/secret: got %v, want ErrNotExist", err) + } + if _, err := fs.ReadFile(s, "etc/secret"); !errors.Is(err, fs.ErrNotExist) { + t.Errorf("ReadFile etc/secret: got %v, want ErrNotExist", err) + } + // etc/keep must still be visible. + if data, err := fs.ReadFile(s, "etc/keep"); err != nil { + t.Errorf("ReadFile etc/keep: %v", err) + } else if string(data) != "kept" { + t.Errorf("ReadFile etc/keep: got %q, want %q", data, "kept") + } + // ReadDir of etc must contain "keep" but neither "secret" nor ".wh.secret". + got := readDirNames(t, s) + want := []string{"keep"} + if !reflect.DeepEqual(got, want) { + t.Errorf("ReadDir etc: got %v, want %v", got, want) + } +} + +func TestStack_WhiteoutDir_HidesEntireSubtree(t *testing.T) { + base := fstest.MapFS{ + "opt/legacy/bin/old": {Data: []byte("X"), Mode: 0o755}, + "opt/keep/here": {Data: []byte("Y"), Mode: 0o644}, + } + top := fstest.MapFS{ + "opt/.wh.legacy": {Data: nil, Mode: 0o644}, + } + s := NewStack(base, top) + + if _, err := fs.Stat(s, "opt/legacy"); !errors.Is(err, fs.ErrNotExist) { + t.Errorf("Stat opt/legacy: got %v, want ErrNotExist", err) + } + // Reading a child should also fail (parent is whitedout). + if _, err := fs.Stat(s, "opt/legacy/bin/old"); !errors.Is(err, fs.ErrNotExist) { + t.Errorf("Stat opt/legacy/bin/old: got %v, want ErrNotExist", err) + } + // Sibling directory must still be present. + if _, err := fs.Stat(s, "opt/keep"); err != nil { + t.Errorf("Stat opt/keep: %v", err) + } +} + +func TestStack_OpaqueMarker(t *testing.T) { + base := fstest.MapFS{ + "etc/foo": {Data: []byte("foo-base"), Mode: 0o644}, + "etc/bar": {Data: []byte("bar-base"), Mode: 0o644}, + "etc/sub/x": {Data: []byte("x"), Mode: 0o644}, + } + top := fstest.MapFS{ + "etc/.wh..wh..opq": {Data: nil, Mode: 0o644}, + "etc/baz": {Data: []byte("baz-top"), Mode: 0o644}, + } + s := NewStack(base, top) + + // Top layer's own etc/baz remains visible; lower foo/bar/sub are hidden. + got := readDirNames(t, s) + want := []string{"baz"} + if !reflect.DeepEqual(got, want) { + t.Errorf("ReadDir etc with opaque: got %v, want %v", got, want) + } + if _, err := fs.Stat(s, "etc/foo"); !errors.Is(err, fs.ErrNotExist) { + t.Errorf("Stat etc/foo behind opaque: got %v, want ErrNotExist", err) + } + if data, err := fs.ReadFile(s, "etc/baz"); err != nil { + t.Fatal(err) + } else if string(data) != "baz-top" { + t.Errorf("etc/baz: got %q, want baz-top", data) + } +} + +func TestStack_WhiteoutEntriesNeverLeak(t *testing.T) { + top := fstest.MapFS{ + "etc/.wh.gone": {Data: nil, Mode: 0o644}, + "etc/.wh..wh..opq": {Data: nil, Mode: 0o644}, + "etc/here": {Data: []byte("X"), Mode: 0o644}, + } + s := NewStack(top) + + got := readDirNames(t, s) + want := []string{"here"} + if !reflect.DeepEqual(got, want) { + t.Errorf("got %v, want %v", got, want) + } +} + +func TestStack_TypeMismatch_TopWins(t *testing.T) { + base := fstest.MapFS{ + "etc/foo/inner": {Data: []byte("inner"), Mode: 0o644}, + } + top := fstest.MapFS{ + "etc/foo": {Data: []byte("now-a-file"), Mode: 0o644}, + } + s := NewStack(base, top) + + info, err := fs.Stat(s, "etc/foo") + if err != nil { + t.Fatal(err) + } + if info.IsDir() { + t.Errorf("etc/foo: top is a file but Stack reports a dir") + } + if data, err := fs.ReadFile(s, "etc/foo"); err != nil { + t.Fatal(err) + } else if string(data) != "now-a-file" { + t.Errorf("got %q", data) + } +} + +func TestStack_ReadDirUnion(t *testing.T) { + base := fstest.MapFS{ + "etc/a": {Data: []byte("A"), Mode: 0o644}, + "etc/b": {Data: []byte("B"), Mode: 0o644}, + } + top := fstest.MapFS{ + "etc/b": {Data: []byte("B-top"), Mode: 0o644}, // override + "etc/c": {Data: []byte("C"), Mode: 0o644}, + } + s := NewStack(base, top) + + got := readDirNames(t, s) + want := []string{"a", "b", "c"} + if !reflect.DeepEqual(got, want) { + t.Errorf("got %v, want %v", got, want) + } + // b should report the top's content/size, not base's. + info, err := fs.Stat(s, "etc/b") + if err != nil { + t.Fatal(err) + } + if info.Size() != int64(len("B-top")) { + t.Errorf("etc/b size: got %d, want %d", info.Size(), len("B-top")) + } +} + +func TestStack_LiveBeatsSameLayerWhiteout(t *testing.T) { + // A malformed-but-possible layer: both the live entry and its whiteout. + // The live entry should win. + base := fstest.MapFS{ + "etc/foo": {Data: []byte("old"), Mode: 0o644}, + } + top := fstest.MapFS{ + "etc/foo": {Data: []byte("new"), Mode: 0o644}, + "etc/.wh.foo": {Data: nil, Mode: 0o644}, + } + s := NewStack(base, top) + + data, err := fs.ReadFile(s, "etc/foo") + if err != nil { + t.Fatal(err) + } + if string(data) != "new" { + t.Errorf("got %q, want new", data) + } + got := readDirNames(t, s) + if !reflect.DeepEqual(got, []string{"foo"}) { + t.Errorf("ReadDir got %v, want [foo]", got) + } +} + +func TestStack_SingleLayer(t *testing.T) { + only := fstest.MapFS{ + "etc/foo": {Data: []byte("X"), Mode: 0o644}, + } + s := NewStack(only) + if data, err := fs.ReadFile(s, "etc/foo"); err != nil { + t.Fatal(err) + } else if string(data) != "X" { + t.Errorf("got %q", data) + } +} + +func TestStack_EmptyStack(t *testing.T) { + s := NewStack() + info, err := fs.Stat(s, ".") + if err != nil { + t.Fatalf("Stat .: %v", err) + } + if !info.IsDir() { + t.Errorf("root should be a dir") + } + if _, err := fs.Stat(s, "anything"); !errors.Is(err, fs.ErrNotExist) { + t.Errorf("got %v, want ErrNotExist", err) + } +} + +func TestStack_FallbackInterfaces(t *testing.T) { + // Wrap a MapFS to hide ReadDirFS/StatFS/ReadLinkFS. Stack must still + // produce correct merged output via the generic fs.ReadDir / fs.Stat + // helpers that fall back to Open+ReadDirFile. + base := nakedFS{fstest.MapFS{ + "etc/foo": {Data: []byte("BASE"), Mode: 0o644}, + }} + top := nakedFS{fstest.MapFS{ + "etc/bar": {Data: []byte("TOP"), Mode: 0o644}, + }} + s := NewStack(base, top) + got := readDirNames(t, s) + want := []string{"bar", "foo"} + if !reflect.DeepEqual(got, want) { + t.Errorf("got %v, want %v", got, want) + } + if data, err := fs.ReadFile(s, "etc/foo"); err != nil { + t.Fatal(err) + } else if string(data) != "BASE" { + t.Errorf("got %q", data) + } +} + +func TestStack_Symlink_ReadLinkRoutesToOwningLayer(t *testing.T) { + base := fstest.MapFS{ + "bin/sh": {Data: []byte("/bin/busybox"), Mode: fs.ModeSymlink | 0o777}, + } + top := fstest.MapFS{ + "etc/keep": {Data: []byte("X"), Mode: 0o644}, + } + s := NewStack(base, top) + + target, err := fs.ReadLink(s, "bin/sh") + if err != nil { + t.Fatal(err) + } + if target != "/bin/busybox" { + t.Errorf("got %q, want /bin/busybox", target) + } +} + +func TestStack_PathNormalization(t *testing.T) { + s := NewStack(fstest.MapFS{ + "etc/foo": {Data: []byte("X"), Mode: 0o644}, + }) + // fs.FS implementations must reject invalid paths per fs.ValidPath rules. + for _, bad := range []string{"/etc/foo", "etc/foo/", "./etc/foo", "../etc/foo"} { + if _, err := s.Open(bad); err == nil { + t.Errorf("Open(%q): expected error, got success", bad) + } + } + // "." is the root. + if _, err := s.Open("."); err != nil { + t.Errorf("Open(.): %v", err) + } +} + +func TestStack_OpenDirReadDirYieldsMerged(t *testing.T) { + // Open returns a stackDir for directories; its ReadDir must reflect the + // merged union, not just the layer that owns the dir metadata. + base := fstest.MapFS{ + "etc/a": {Data: []byte("A"), Mode: 0o644}, + } + top := fstest.MapFS{ + "etc/b": {Data: []byte("B"), Mode: 0o644}, + } + s := NewStack(base, top) + f, err := s.Open("etc") + if err != nil { + t.Fatal(err) + } + defer f.Close() + rd, ok := f.(fs.ReadDirFile) + if !ok { + t.Fatal("dir handle should implement fs.ReadDirFile") + } + ents, err := rd.ReadDir(-1) + if err != nil { + t.Fatal(err) + } + names := make([]string, 0, len(ents)) + for _, e := range ents { + names = append(names, e.Name()) + } + slices.Sort(names) + if !reflect.DeepEqual(names, []string{"a", "b"}) { + t.Errorf("got %v, want [a b]", names) + } +} + +func TestStack_WalkDir_PrunesWhiteoutsAndOpaque(t *testing.T) { + base := fstest.MapFS{ + "etc/hidden": {Data: []byte("H"), Mode: 0o644}, + "etc/kept": {Data: []byte("K"), Mode: 0o644}, + "opt/old": {Data: []byte("O"), Mode: 0o644}, + "opt/sub/x": {Data: []byte("X"), Mode: 0o644}, + } + top := fstest.MapFS{ + "etc/.wh.hidden": {Data: nil, Mode: 0o644}, + "opt/.wh..wh..opq": {Data: nil, Mode: 0o644}, + "opt/new": {Data: []byte("N"), Mode: 0o644}, + } + s := NewStack(base, top) + + var seen []string + err := fs.WalkDir(s, ".", func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if strings.HasPrefix(d.Name(), ".wh.") { + t.Errorf("whiteout entry leaked: %s", p) + } + seen = append(seen, p) + return nil + }) + if err != nil { + t.Fatal(err) + } + want := []string{ + ".", + "etc", "etc/kept", + "opt", "opt/new", + } + slices.Sort(seen) + slices.Sort(want) + if !reflect.DeepEqual(seen, want) { + t.Errorf("walk: got %v\nwant %v", seen, want) + } +} diff --git a/pkg/erofsmount/state.go b/pkg/erofsmount/state.go index ec595f960..ce1f73a9d 100644 --- a/pkg/erofsmount/state.go +++ b/pkg/erofsmount/state.go @@ -34,6 +34,20 @@ const ( ModeFuse Mode = "fuse" ) +// Options is the shared option bag used by Mount and Ls. Only Arch is +// meaningful for Ls; Mode and ReadOnly only affect Mount. +type Options struct { + // Mode selects ModeKernel, ModeFuse, or ModeAuto. Zero value is + // treated as ModeAuto. + Mode Mode + // Arch picks a manifest from a multi-arch OCI index. "" or "host" + // means runtime.GOARCH. + Arch string + // ReadOnly, when true, skips upper/work overlay dirs and produces a + // pure read-only overlay during Mount. + ReadOnly bool +} + // StateSchemaVersion is the current MountState JSON schema version. const StateSchemaVersion = 1 diff --git a/pkg/erofsmount/stub_other.go b/pkg/erofsmount/stub_other.go index d5efa9a19..3544ee3ce 100644 --- a/pkg/erofsmount/stub_other.go +++ b/pkg/erofsmount/stub_other.go @@ -19,17 +19,18 @@ package erofsmount import ( "context" "fmt" - "io" "runtime" ) // Driver, NewDriver, ResolveMode are intentionally absent on non-Linux: the // EROFS kernel module, erofsfuse, overlayfs, and fuse-overlayfs are Linux -// concepts. The exported Mount/Unmount/Ls return a clear error so callers -// (the CLI) don't have to gate at every call site. +// concepts. Mount and Unmount return a clear error so the CLI doesn't have +// to gate at every call site. +// +// Ls and OpenLayers are cross-platform (go-erofs is pure Go). func unsupportedOS() error { - return fmt.Errorf("apko erofs subcommands are only supported on Linux (running on %s)", runtime.GOOS) + return fmt.Errorf("apko erofs mount/umount are only supported on Linux (running on %s)", runtime.GOOS) } // Mount is a no-op stub on non-Linux that returns an error. @@ -41,15 +42,3 @@ func Mount(_ context.Context, _ Source, _ string, _ Options) (*MountState, error func Unmount(_ context.Context, _ string) error { return unsupportedOS() } - -// Ls is a no-op stub on non-Linux that returns an error. -func Ls(_ context.Context, _ Source, _ Options, _ io.Writer) error { - return unsupportedOS() -} - -// Options is defined on non-Linux to keep the CLI build-tag-free. -type Options struct { - Mode Mode - Arch string - ReadOnly bool -} From 9805d505b206597151a1374ef993e2b6b0de13c0 Mon Sep 17 00:00:00 2001 From: Scott Moser Date: Thu, 28 May 2026 10:03:32 -0400 Subject: [PATCH 06/33] build: rename writeERofs -> writeErofs for capitalization consistency writeERofs was the only identifier in the repo using mid-word acronym-style "ERofs"; everywhere else treats it as a word ("Erofs"). Rename writeERofs / writeERofsViaMkfs and the related test names so the codebase is uniform. Co-Authored-By: Claude Opus 4.7 (1M context) --- pkg/build/build.go | 2 +- pkg/build/erofs.go | 6 +++--- pkg/build/erofs_test.go | 14 +++++++------- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/pkg/build/build.go b/pkg/build/build.go index 445064605..ea2c12896 100644 --- a/pkg/build/build.go +++ b/pkg/build/build.go @@ -188,7 +188,7 @@ func (bc *Context) ImageLayoutToLayer(ctx context.Context) (string, v1.Layer, er defer outfile.Close() if bc.ic.Format.Resolved() == types.LayerFormatErofs { - if err := writeERofs(ctx, outfile, bc.fs, bc.o.SourceDateEpoch); err != nil { + if err := writeErofs(ctx, outfile, bc.fs, bc.o.SourceDateEpoch); err != nil { return "", nil, fmt.Errorf("generating erofs image: %w", err) } if err := outfile.Sync(); err != nil { diff --git a/pkg/build/erofs.go b/pkg/build/erofs.go index d4a5deb22..d34878432 100644 --- a/pkg/build/erofs.go +++ b/pkg/build/erofs.go @@ -44,15 +44,15 @@ const ( erofsUncompressedDigestAnnotation = "org.erofs.uncompressed-digest" ) -// writeERofs serializes fsys as a raw (uncompressed) EROFS filesystem image to +// writeErofs serializes fsys as a raw (uncompressed) EROFS filesystem image to // out. out must be both writable and seekable: go-erofs's Writer rewrites the // superblock at offset 0 after streaming file data. // // If buildTime is non-zero it sets the EROFS image build time (used to seed // per-entry mtime defaulting and recorded in the superblock), making the image // reproducible. -func writeERofs(ctx context.Context, out io.WriteSeeker, fsys apkfs.FullFS, buildTime time.Time) error { - ctx, span := otel.Tracer("apko").Start(ctx, "writeERofs") +func writeErofs(ctx context.Context, out io.WriteSeeker, fsys apkfs.FullFS, buildTime time.Time) error { + ctx, span := otel.Tracer("apko").Start(ctx, "writeErofs") defer span.End() var createOpts []erofs.CreateOpt diff --git a/pkg/build/erofs_test.go b/pkg/build/erofs_test.go index 8bc946f08..1723c64a0 100644 --- a/pkg/build/erofs_test.go +++ b/pkg/build/erofs_test.go @@ -52,7 +52,7 @@ func seedFS(t *testing.T) apkfs.FullFS { return m } -func TestWriteERofs_Roundtrip(t *testing.T) { +func TestWriteErofs_Roundtrip(t *testing.T) { m := seedFS(t) out := filepath.Join(t.TempDir(), "image.erofs") @@ -60,7 +60,7 @@ func TestWriteERofs_Roundtrip(t *testing.T) { require.NoError(t, err) t.Cleanup(func() { _ = f.Close() }) - require.NoError(t, writeERofs(context.Background(), f, m, epoch)) + require.NoError(t, writeErofs(context.Background(), f, m, epoch)) require.NoError(t, f.Close()) r, err := os.Open(out) @@ -154,11 +154,11 @@ func TestImageLayoutToLayer_Erofs(t *testing.T) { require.NoError(t, err) } -// TestWriteERofs_FsckErofs validates a generated image with the C reference +// TestWriteErofs_FsckErofs validates a generated image with the C reference // tool from erofs-utils. The test is skipped when fsck.erofs is not on PATH so // contributors without erofs-utils installed still get a green build. CI // images that include erofs-utils will actually exercise this path. -func TestWriteERofs_FsckErofs(t *testing.T) { +func TestWriteErofs_FsckErofs(t *testing.T) { fsckBin, err := exec.LookPath("fsck.erofs") if err != nil { t.Skip("fsck.erofs not found in PATH; install erofs-utils to run this test") @@ -168,7 +168,7 @@ func TestWriteERofs_FsckErofs(t *testing.T) { out := filepath.Join(t.TempDir(), "image.erofs") f, err := os.Create(out) require.NoError(t, err) - require.NoError(t, writeERofs(context.Background(), f, m, epoch)) + require.NoError(t, writeErofs(context.Background(), f, m, epoch)) require.NoError(t, f.Close()) // Plain integrity check: superblock CRC, layout, all reachable inodes. @@ -286,12 +286,12 @@ func lookFsckErofs() (string, error) { return exec.LookPath("fsck.erofs") } -func TestWriteERofs_Reproducible(t *testing.T) { +func TestWriteErofs_Reproducible(t *testing.T) { build := func(path string) []byte { m := seedFS(t) f, err := os.Create(path) require.NoError(t, err) - require.NoError(t, writeERofs(context.Background(), f, m, epoch)) + require.NoError(t, writeErofs(context.Background(), f, m, epoch)) require.NoError(t, f.Close()) data, err := os.ReadFile(path) require.NoError(t, err) From 8a517636d9543312317d487e0933fda3739f4c91 Mon Sep 17 00:00:00 2001 From: Scott Moser Date: Thu, 28 May 2026 11:15:38 -0400 Subject: [PATCH 07/33] add doc on using akpo_build with erofs --- docs/erofs.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/docs/erofs.md b/docs/erofs.md index ed5f4bed8..cc6e3a4a9 100644 --- a/docs/erofs.md +++ b/docs/erofs.md @@ -316,6 +316,35 @@ for d in mnt/lower*; do sudo umount "$d" 2>/dev/null || fusermount -u "$d"; done Production runtimes (containerd's erofs snapshotter, podman/CRI-O with the erofs-aware plugin, etc.) automate this assembly; both `apko erofs mount` and the manual steps above are for verifying that an apko-built EROFS image really does compose into a valid rootfs. +## Using EROFS support as a Go library + +### From apko-consuming projects + +If you're already building apko images programmatically (`apko_build.New(ctx, fsys, opts...).BuildImage(...)`), EROFS is just a configuration choice — set the layer format on your `ImageConfiguration` and apko handles the rest: + +```go +import ( + apko_build "chainguard.dev/apko/pkg/build" + apko_types "chainguard.dev/apko/pkg/build/types" +) + +imgConfig := apko_types.ImageConfiguration{ + // ... your existing fields ... + Format: apko_types.LayerFormatErofs, +} + +bc, err := apko_build.New(ctx, fsys, apko_build.WithImageConfiguration(imgConfig), ...) +if err != nil { /* ... */ } +if err := bc.BuildImage(ctx); err != nil { /* ... */ } +_, layer, err := bc.ImageLayoutToLayer(ctx) +``` + +### From projects that don't use apko + +If you have a plain `fs.FS` and want an EROFS image, **use [go-erofs](https://github.com/erofs/go-erofs) directly** — apko doesn't expose its EROFS writer as a standalone library (and wrapping go-erofs wouldn't add meaningful value over its existing `Writer.CopyFrom(fs.FS)` API). + +For inspection, apko *does* expose a focused leaf library — see `chainguard.dev/apko/pkg/erofsmount` — which provides `Stack` (layered `fs.FS` with overlay/whiteout semantics), `OpenLayers` (open an OCI EROFS image's blobs), `ReadOCILayers` (parse an OCI manifest with EROFS layers), and `Mount`/`Unmount`/`Ls` (the CLI subcommand helpers, Linux-only for mount/umount; `Ls` is cross-platform). + ## Current limitations - **No compression.** apko emits raw `application/vnd.erofs` layers only. The draft spec defines `application/vnd.erofs+zstd` but neither apko's writer nor the underlying go-erofs library writes compressed images yet. From 0e54c8e551e81f859ee999c2d22d2f271ecf0e24 Mon Sep 17 00:00:00 2001 From: Scott Moser Date: Thu, 28 May 2026 12:10:30 -0400 Subject: [PATCH 08/33] docs(erofs): refresh stale notes and fix the manual-overlay snippet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit apko_file.md still claimed EROFS layers were uncompressed and that +zstd was unimplemented; the +ALGO variants have shipped since the format field was first documented. Rewrite the format list to enumerate raw and compressed variants, mention the uncompressed-digest annotation, and note the mkfs.erofs runtime dependency. erofs.md's manual-overlay reference snippet had two bugs that prevented it from running end-to-end: $ROOT/../../blobs/sha256/$MANIFEST double-traversed the OCI layout, and the lowerdir chain hard-coded a four-layer count with explicit lower0/lower1 references that wouldn't generalize. Rewrite the loop to derive $BLOBS and $MANIFEST cleanly and accumulate $LOWERS as it mounts. Also fix two small accuracy bugs: --arch on apko takes Go arches (amd64, arm64), not uname -m output (x86_64, aarch64) — replace with --arch=host, which is what the YAML examples in the same file use. And on Debian/Ubuntu erofsfuse ships inside the erofs-utils package; the separate erofsfuse package only exists on Wolfi/Alpine. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/apko_file.md | 12 +++++++----- docs/erofs.md | 29 +++++++++++++++++------------ 2 files changed, 24 insertions(+), 17 deletions(-) diff --git a/docs/apko_file.md b/docs/apko_file.md index 91e2c88e1..b32ec2ab0 100644 --- a/docs/apko_file.md +++ b/docs/apko_file.md @@ -277,13 +277,15 @@ See [layering.md](layering.md) for more information. ### Format (experimental) -`format` selects the on-wire layer payload format. Two values are recognized: +`format` selects the on-wire layer payload format: - - `tar` (default): standard gzip-compressed tar layers (`application/vnd.oci.image.layer.v1.tar+gzip`). - - `erofs`: uncompressed EROFS filesystem images (`application/vnd.erofs`), per the draft [erofs/erofs-image-spec](https://github.com/erofs/erofs-image-spec). EROFS layers advertise `erofs` in the image config's `os.features` so consumers that do not implement the spec can identify and skip them. + - `tar` (default): gzip-compressed tar layers (`application/vnd.oci.image.layer.v1.tar+gzip`). + - `erofs`: EROFS filesystem images (`application/vnd.erofs`), per the draft [erofs/erofs-image-spec](https://github.com/erofs/erofs-image-spec). Written by a pure-Go writer with no internal compression. -`erofs` may also be selected on the command line with `--format=erofs` on `apko build` and `apko publish`. The CLI flag overrides whatever is in the config file. +EROFS layers advertise `erofs` in the image config's `os.features` so consumers that do not implement the spec can identify and skip them. -**Status:** EROFS support is experimental and tracks the spec PR at https://github.com/erofs/erofs-image-spec/pull/1; media types and annotations may change before the spec reaches a stable release. Both single-layer and multi-layer (`layering`) builds are supported. Multi-layer builds emit each non-final layer with `org.erofs.role=overlay-lower` per spec §3.8; the final layer carries no role. `+zstd` compression and dm-verity are not implemented. +`format` may also be selected on the command line with `--format=erofs` on `apko build` and `apko publish`. The CLI flag overrides whatever is in the config file. + +**Status:** EROFS support is experimental and tracks the spec PR at https://github.com/erofs/erofs-image-spec/pull/1; media types and annotations may change before the spec reaches a stable release. Both single-layer and multi-layer (`layering`) builds are supported. Multi-layer builds emit each non-final layer with `org.erofs.role=overlay-lower` per spec §3.8; the final layer carries no role. Compression and dm-verity are not implemented. See [erofs.md](erofs.md) for a step-by-step guide to building, inspecting, mounting, and pulling EROFS images. diff --git a/docs/erofs.md b/docs/erofs.md index cc6e3a4a9..e38508da0 100644 --- a/docs/erofs.md +++ b/docs/erofs.md @@ -32,7 +32,7 @@ sudo apk add erofs-utils-fuse # erofsfuse (optional, for unprivileged mount) Install on Debian / Ubuntu: ```sh -sudo apt install erofs-utils erofsfuse +sudo apt install erofs-utils # ships mkfs.erofs, fsck.erofs, dump.erofs, and erofsfuse ``` ## Single-layer build @@ -59,7 +59,7 @@ Build into an OCI image layout directory: ```sh mkdir -p out -apko build erofs-demo.yaml apko-erofs-demo:latest out/ --format=erofs --arch=$(uname -m) +apko build erofs-demo.yaml apko-erofs-demo:latest out/ --format=erofs --arch=host ``` The OCI layout under `out/` is a regular OCI image directory — the layer blob just happens to be an EROFS filesystem: @@ -233,7 +233,7 @@ format: erofs ```sh mkdir -p out-layered -apko build erofs-layered.yaml apko-erofs-layered:latest out-layered/ --arch=$(uname -m) +apko build erofs-layered.yaml apko-erofs-layered:latest out-layered/ --arch=host ``` Inspect the manifest: @@ -287,21 +287,26 @@ For reference, the equivalent without `apko erofs mount`: ```sh # Pull each layer blob out of the OCI layout. -ROOT=$(pwd)/out-layered/blobs/sha256 -mkdir -p mnt/{lower0,lower1,lower2,lower3,top,merged,work,upper} +BLOBS=$(pwd)/out-layered/blobs/sha256 +MANIFEST=$(jq -r '.manifests[0].digest | split(":")[1]' out-layered/index.json) +mkdir -p mnt/{merged,work,upper} -LAYERS=$(jq -r '.layers[].digest | split(":")[1]' $ROOT/../../blobs/sha256/$MANIFEST) +# Mount every layer; build the overlay lowerdir as we go. overlayfs lists +# lowerdirs top-down (highest priority first), while OCI orders layers +# bottom-up (index 0 is the base), so prepend each new layer. +LOWERS= i=0 -for d in $LAYERS; do - sudo mount -t erofs -o loop "$ROOT/$d" "mnt/lower$i" 2>/dev/null || \ - erofsfuse "$ROOT/$d" "mnt/lower$i" +for d in $(jq -r '.layers[].digest | split(":")[1]' "$BLOBS/$MANIFEST"); do + mp=mnt/lower$(printf %02d $i) + mkdir -p "$mp" + sudo mount -t erofs -o loop "$BLOBS/$d" "$mp" 2>/dev/null || \ + erofsfuse "$BLOBS/$d" "$mp" + LOWERS="$mp${LOWERS:+:$LOWERS}" i=$((i+1)) done -# In overlayfs, lowerdirs are listed top-down (highest priority first). -# OCI orders layers bottom-up (index 0 is the base), so reverse the order. sudo mount -t overlay overlay \ - -o lowerdir=mnt/lower$((i-1)):mnt/lower$((i-2)):mnt/lower1:mnt/lower0,upperdir=mnt/upper,workdir=mnt/work \ + -o "lowerdir=$LOWERS,upperdir=mnt/upper,workdir=mnt/work" \ mnt/merged ls mnt/merged/ # full rootfs From e2440046c57bc687d3331b19e5b4e19a13d0de40 Mon Sep 17 00:00:00 2001 From: Scott Moser Date: Thu, 28 May 2026 12:26:47 -0400 Subject: [PATCH 09/33] erofs: dedupe spec constants into pkg/build/types The application/vnd.erofs media type and the org.erofs.role / overlay-lower / org.erofs.uncompressed-digest annotation strings lived as parallel unexported consts in pkg/build/erofs.go and pkg/erofsmount/oci.go with a "keep in sync" comment guarding the duplicate. Promote them to a single set of exported constants in pkg/build/types/erofs.go so both the writer and the reader/mount tools reference the same source, and test fixtures lock to the same strings. Co-Authored-By: Claude Opus 4.7 (1M context) --- pkg/build/erofs.go | 13 ++----------- pkg/build/erofs_layers.go | 3 ++- pkg/build/erofs_test.go | 2 +- pkg/build/types/erofs.go | 32 ++++++++++++++++++++++++++++++++ pkg/erofsmount/oci.go | 23 ++++++++--------------- pkg/erofsmount/oci_test.go | 16 +++++++++------- 6 files changed, 54 insertions(+), 35 deletions(-) create mode 100644 pkg/build/types/erofs.go diff --git a/pkg/build/erofs.go b/pkg/build/erofs.go index d34878432..2491140d8 100644 --- a/pkg/build/erofs.go +++ b/pkg/build/erofs.go @@ -32,16 +32,7 @@ import ( "golang.org/x/sys/unix" apkfs "chainguard.dev/apko/pkg/apk/fs" -) - -// Media types from the draft erofs/erofs-image-spec (PR #1). -// These tracking constants are intentionally kept in one place so they can be -// updated in lockstep with the spec. -const ( - erofsLayerMediaType = "application/vnd.erofs" - erofsRoleAnnotation = "org.erofs.role" - erofsRoleOverlay = "overlay-lower" - erofsUncompressedDigestAnnotation = "org.erofs.uncompressed-digest" + "chainguard.dev/apko/pkg/build/types" ) // writeErofs serializes fsys as a raw (uncompressed) EROFS filesystem image to @@ -278,7 +269,7 @@ func (l *erofsLayer) DiffID() (v1.Hash, error) { return l.hash, nil } func (l *erofsLayer) Digest() (v1.Hash, error) { return l.hash, nil } func (l *erofsLayer) Size() (int64, error) { return l.size, nil } func (l *erofsLayer) MediaType() (v1types.MediaType, error) { - return v1types.MediaType(erofsLayerMediaType), nil + return v1types.MediaType(types.ErofsLayerMediaType), nil } func (l *erofsLayer) Uncompressed() (io.ReadCloser, error) { return os.Open(l.path) } diff --git a/pkg/build/erofs_layers.go b/pkg/build/erofs_layers.go index 3024b0c1d..09bac2fa7 100644 --- a/pkg/build/erofs_layers.go +++ b/pkg/build/erofs_layers.go @@ -28,6 +28,7 @@ import ( "chainguard.dev/apko/pkg/apk/apk" apkfs "chainguard.dev/apko/pkg/apk/fs" + "chainguard.dev/apko/pkg/build/types" ) // splitErofsLayers is the EROFS analogue of splitLayers. It walks fsys once, @@ -239,7 +240,7 @@ func splitErofsLayers(ctx context.Context, fsys apkfs.FullFS, groups []*group, p // spec §3.8 rule 1. The final layer carries no role. var anns map[string]string if i < len(writers)-1 { - anns = map[string]string{erofsRoleAnnotation: erofsRoleOverlay} + anns = map[string]string{types.ErofsRoleAnnotation: types.ErofsRoleOverlayLower} } l, err := buildErofsLayerFromFile(gw.path, anns) if err != nil { diff --git a/pkg/build/erofs_test.go b/pkg/build/erofs_test.go index 1723c64a0..85c9b9bf4 100644 --- a/pkg/build/erofs_test.go +++ b/pkg/build/erofs_test.go @@ -229,7 +229,7 @@ func TestSplitErofsLayers(t *testing.T) { // Layer roles: overlay-lower on the package layers, absent on the top. anns := erl.LayerAnnotations() if i < len(layers)-1 { - require.Equal(t, "overlay-lower", anns[erofsRoleAnnotation], "layer[%d] missing overlay-lower role", i) + require.Equal(t, "overlay-lower", anns[types.ErofsRoleAnnotation], "layer[%d] missing overlay-lower role", i) } else { require.Empty(t, anns, "top layer must carry no role annotation") } diff --git a/pkg/build/types/erofs.go b/pkg/build/types/erofs.go new file mode 100644 index 000000000..21a6dbddc --- /dev/null +++ b/pkg/build/types/erofs.go @@ -0,0 +1,32 @@ +// Copyright 2026 Chainguard, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package types + +// EROFS media types and annotation keys from the draft +// erofs/erofs-image-spec (PR #1). Both the writer (pkg/build) and the +// reader/mount tools (pkg/erofsmount) reference these; centralizing them +// here keeps the two sides honest as the spec evolves. +const ( + // ErofsLayerMediaType is the manifest mediaType for an EROFS filesystem + // layer blob (raw or internally compressed). + ErofsLayerMediaType = "application/vnd.erofs" + // ErofsRoleAnnotation is the layer-descriptor annotation key that names + // the layer's overlayfs role per spec §3.8. + ErofsRoleAnnotation = "org.erofs.role" + // ErofsRoleOverlayLower marks a layer as an overlay lowerdir. Per spec + // §3.8 rule 1, every non-final layer carries this; the final layer + // carries no role annotation. + ErofsRoleOverlayLower = "overlay-lower" +) diff --git a/pkg/erofsmount/oci.go b/pkg/erofsmount/oci.go index aa5cfa70e..640f8950a 100644 --- a/pkg/erofsmount/oci.go +++ b/pkg/erofsmount/oci.go @@ -24,19 +24,12 @@ import ( v1 "github.com/google/go-containerregistry/pkg/v1" "github.com/google/go-containerregistry/pkg/v1/layout" ocitypes "github.com/google/go-containerregistry/pkg/v1/types" -) - -// EROFS-specific constants. These are duplicated from pkg/build/erofs.go to -// avoid taking a dependency on the build package from this leaf library. -// They must stay in sync. -const ( - erofsLayerMediaType = "application/vnd.erofs" - erofsRoleAnnotation = "org.erofs.role" - erofsRoleOverlay = "overlay-lower" - annotationRefName = "org.opencontainers.image.ref.name" + "chainguard.dev/apko/pkg/build/types" ) +const annotationRefName = "org.opencontainers.image.ref.name" + // LayerRef points at one EROFS layer blob on disk along with its descriptor // metadata. type LayerRef struct { @@ -84,8 +77,8 @@ func ReadOCILayers(ociDir, tag, arch string) ([]LayerRef, error) { refs := make([]LayerRef, 0, len(manifest.Layers)) for i, desc := range manifest.Layers { - if string(desc.MediaType) != erofsLayerMediaType { - return nil, fmt.Errorf("layer %d has mediaType %q; expected %q (this command only handles EROFS images)", i, desc.MediaType, erofsLayerMediaType) + if string(desc.MediaType) != types.ErofsLayerMediaType { + return nil, fmt.Errorf("layer %d has mediaType %q; expected %q (this command only handles EROFS images)", i, desc.MediaType, types.ErofsLayerMediaType) } blob := filepath.Join(ociDir, "blobs", desc.Digest.Algorithm, desc.Digest.Hex) if _, err := os.Stat(blob); err != nil { @@ -96,7 +89,7 @@ func ReadOCILayers(ociDir, tag, arch string) ([]LayerRef, error) { Digest: desc.Digest.String(), MediaType: string(desc.MediaType), Annotations: desc.Annotations, - Role: desc.Annotations[erofsRoleAnnotation], + Role: desc.Annotations[types.ErofsRoleAnnotation], }) } @@ -106,8 +99,8 @@ func ReadOCILayers(ociDir, tag, arch string) ([]LayerRef, error) { // either position so single-layer images (one unannotated layer) work. if len(refs) > 1 { for i := 0; i < len(refs)-1; i++ { - if refs[i].Role != erofsRoleOverlay { - return nil, fmt.Errorf("layer %d: missing %s=%s annotation (only the final layer may be unannotated)", i, erofsRoleAnnotation, erofsRoleOverlay) + if refs[i].Role != types.ErofsRoleOverlayLower { + return nil, fmt.Errorf("layer %d: missing %s=%s annotation (only the final layer may be unannotated)", i, types.ErofsRoleAnnotation, types.ErofsRoleOverlayLower) } } if refs[len(refs)-1].Role != "" { diff --git a/pkg/erofsmount/oci_test.go b/pkg/erofsmount/oci_test.go index f281acd5d..9b707c86a 100644 --- a/pkg/erofsmount/oci_test.go +++ b/pkg/erofsmount/oci_test.go @@ -24,13 +24,15 @@ import ( "path/filepath" "strings" "testing" + + "chainguard.dev/apko/pkg/build/types" ) type fakeLayer struct { body []byte role string annotations map[string]string - mediaType string // override; default erofsLayerMediaType + mediaType string // override; default types.ErofsLayerMediaType } // writeFakeOCILayout writes a minimal OCI image layout under root with one or @@ -70,13 +72,13 @@ func writeFakeOCILayout(t *testing.T, root string, layers []fakeLayer) { for _, l := range layers { mt := l.mediaType if mt == "" { - mt = erofsLayerMediaType + mt = types.ErofsLayerMediaType } dig := writeBlob(l.body) anns := map[string]string{} maps.Copy(anns, l.annotations) if l.role != "" { - anns[erofsRoleAnnotation] = l.role + anns[types.ErofsRoleAnnotation] = l.role } layerDescs = append(layerDescs, descriptor{ MediaType: mt, @@ -129,8 +131,8 @@ func writeFakeOCILayout(t *testing.T, root string, layers []fakeLayer) { func TestReadOCILayers_MultiLayer(t *testing.T) { dir := t.TempDir() writeFakeOCILayout(t, dir, []fakeLayer{ - {body: []byte("layer0-base"), role: erofsRoleOverlay}, - {body: []byte("layer1-mid"), role: erofsRoleOverlay}, + {body: []byte("layer0-base"), role: types.ErofsRoleOverlayLower}, + {body: []byte("layer1-mid"), role: types.ErofsRoleOverlayLower}, {body: []byte("layer2-top")}, }) @@ -141,7 +143,7 @@ func TestReadOCILayers_MultiLayer(t *testing.T) { if len(refs) != 3 { t.Fatalf("got %d layers, want 3", len(refs)) } - for i, want := range []string{erofsRoleOverlay, erofsRoleOverlay, ""} { + for i, want := range []string{types.ErofsRoleOverlayLower, types.ErofsRoleOverlayLower, ""} { if refs[i].Role != want { t.Errorf("layer %d role: got %q want %q", i, refs[i].Role, want) } @@ -190,7 +192,7 @@ func TestReadOCILayers_BadRoleOrder(t *testing.T) { // First layer lacks role annotation: invalid. writeFakeOCILayout(t, dir, []fakeLayer{ {body: []byte("a")}, - {body: []byte("b"), role: erofsRoleOverlay}, + {body: []byte("b"), role: types.ErofsRoleOverlayLower}, }) _, err := ReadOCILayers(dir, "", "amd64") if err == nil || !strings.Contains(err.Error(), "missing") { From 7914a1e0f3cef09afde83b0a59c617f364152be9 Mon Sep 17 00:00:00 2001 From: Scott Moser Date: Thu, 28 May 2026 12:29:26 -0400 Subject: [PATCH 10/33] erofs(mount): drop -o loop from kernel layer mount MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mount(8) since util-linux 2.29 autodetects when the source is a regular file and allocates a loop device with O_AUTOCLEAR, freeing it on umount. Asking for "-o loop" explicitly relies on a separate code path whose cleanup semantics differ across util-linux releases and busybox builds — on older or non-GNU versions the loop device can leak after umount. Drop "loop" from the argv. Keep "-o ro" to document intent (EROFS is intrinsically read-only, but the explicit flag tells a reader who is copy-pasting the equivalent shell command that we never plan to write). Update the matching tests and the two "doing it manually" snippets in docs/erofs.md. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/erofs.md | 4 ++-- pkg/erofsmount/driver_linux.go | 8 +++++++- pkg/erofsmount/driver_linux_test.go | 2 +- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/docs/erofs.md b/docs/erofs.md index e38508da0..f250ae081 100644 --- a/docs/erofs.md +++ b/docs/erofs.md @@ -178,7 +178,7 @@ For reference, `apko erofs mount` is equivalent to one of: ```sh # Kernel (root): -sudo mount -t erofs -o loop out/blobs/sha256/$LAYER /mnt/apko-erofs +sudo mount -t erofs -o ro out/blobs/sha256/$LAYER /mnt/apko-erofs # ...later: sudo umount /mnt/apko-erofs @@ -299,7 +299,7 @@ i=0 for d in $(jq -r '.layers[].digest | split(":")[1]' "$BLOBS/$MANIFEST"); do mp=mnt/lower$(printf %02d $i) mkdir -p "$mp" - sudo mount -t erofs -o loop "$BLOBS/$d" "$mp" 2>/dev/null || \ + sudo mount -t erofs -o ro "$BLOBS/$d" "$mp" 2>/dev/null || \ erofsfuse "$BLOBS/$d" "$mp" LOWERS="$mp${LOWERS:+:$LOWERS}" i=$((i+1)) diff --git a/pkg/erofsmount/driver_linux.go b/pkg/erofsmount/driver_linux.go index 760066d88..b03f71a19 100644 --- a/pkg/erofsmount/driver_linux.go +++ b/pkg/erofsmount/driver_linux.go @@ -176,7 +176,13 @@ func (d *fuseDriver) AssembleOverlay(ctx context.Context, lowers []string, upper // Command builders. Pure functions so they can be tested without exec. func buildKernelLayerArgs(blob, mp string) []string { - return []string{"mount", "-t", "erofs", "-o", "loop,ro", blob, mp} + // "-o loop" is unnecessary on modern util-linux: when the source is a + // regular file, mount(8) auto-detects and allocates a loop device with + // O_AUTOCLEAR so it's freed on umount. Asking for "-o loop" explicitly + // risks leaking the loop device when the kernel/util-linux don't agree + // on autoclear semantics. EROFS itself is read-only, but pass "-o ro" + // anyway to document intent. + return []string{"mount", "-t", "erofs", "-o", "ro", blob, mp} } func buildKernelUmountArgs(mp string) []string { diff --git a/pkg/erofsmount/driver_linux_test.go b/pkg/erofsmount/driver_linux_test.go index c1926b734..feaecbdb0 100644 --- a/pkg/erofsmount/driver_linux_test.go +++ b/pkg/erofsmount/driver_linux_test.go @@ -24,7 +24,7 @@ import ( func TestBuildKernelLayerArgs(t *testing.T) { got := buildKernelLayerArgs("/blobs/abc", "/mnt/x") - want := []string{"mount", "-t", "erofs", "-o", "loop,ro", "/blobs/abc", "/mnt/x"} + want := []string{"mount", "-t", "erofs", "-o", "ro", "/blobs/abc", "/mnt/x"} if !reflect.DeepEqual(got, want) { t.Fatalf("got %v, want %v", got, want) } From be3f3bcac576674cd636f53da8a265b075692f26 Mon Sep 17 00:00:00 2001 From: Scott Moser Date: Thu, 28 May 2026 12:32:23 -0400 Subject: [PATCH 11/33] erofs(mount): fail Unmount fast instead of cascading EBUSY MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit st.Mounts is recorded overlay-first then per-layer mounts in LIFO order. If the overlay umount fails, every subsequent layer umount returns EBUSY because the overlay still pins them — the previous loop collected and errors.Join()'d every one of those, giving the user a long block of identical "device busy" noise where only the first error described the real problem. Return on the first failed umount with a single error that names which mountpoint the user needs to clear; leave the remaining mounts and the state file in place so a follow-up `apko erofs umount` finishes the job. Deliberately do not fall back to `umount -l`: lazy unmount would let the process exit with the user believing things were torn down while the mounts and pinned files quietly persist. Co-Authored-By: Claude Opus 4.7 (1M context) --- pkg/erofsmount/mount_linux.go | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/pkg/erofsmount/mount_linux.go b/pkg/erofsmount/mount_linux.go index bb0e07d21..9fe9e5110 100644 --- a/pkg/erofsmount/mount_linux.go +++ b/pkg/erofsmount/mount_linux.go @@ -198,21 +198,17 @@ func unmountImage(ctx context.Context, dest string, st *MountState, log *clog.Lo if err != nil { return err } - var errs []error + // st.Mounts is overlay-first then per-layer mounts in LIFO order. If + // any umount fails, stop: layer mounts that come after a still-pinned + // overlay would only return EBUSY noise, and continuing past an error + // would also leave the state file out of sync with reality. The user + // can rerun `apko erofs umount` after addressing whatever is keeping + // the mount busy. for _, mp := range st.Mounts { - // Build a minimal one-shot umount via the driver's API: we can't - // reuse the closures from Mount because they live in a different - // process. Construct an umount inline using a fresh layer mount of - // nothing — i.e. just call the umount command directly. if err := unmountOne(ctx, drv, mp); err != nil { - errs = append(errs, fmt.Errorf("umount %s: %w", mp, err)) - log.Warnf("umount %s: %v", mp, err) - } else { - log.Infof("unmounted %s", mp) + return fmt.Errorf("umount %s: %w (remaining mounts left intact; rerun once they are no longer busy)", mp, err) } - } - if len(errs) > 0 { - return errors.Join(errs...) + log.Infof("unmounted %s", mp) } for _, sub := range []string{"merged", "upper", "work", "layers"} { if err := os.RemoveAll(filepath.Join(dest, sub)); err != nil { From a1738d27606d61e60bacab78db9366a95eb654c7 Mon Sep 17 00:00:00 2001 From: Scott Moser Date: Thu, 28 May 2026 12:34:44 -0400 Subject: [PATCH 12/33] erofs(mount): expose --read-only and short-circuit single-layer mounts erofsmount.Options.ReadOnly was already plumbed into the overlay assembly but had no way to be set from the CLI; the only consumers were external library callers. Wire it through 'apko erofs mount --read-only', omitting the upperdir/workdir overlay just like a library caller would. For single-layer images, overlayfs adds nothing in the read-only case and a lowerdir-only overlay over one EROFS mount has historically been finicky across overlayfs releases. When --read-only is set and the image has exactly one layer, skip the layers/upper/work directories entirely and mount the lone layer straight at DEST/merged. The state file's Mounts slice records that single mountpoint, so Unmount naturally cleans up the same way as a multi-layer mount. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/erofs.md | 2 +- internal/cli/erofs.go | 18 ++++++++++----- pkg/erofsmount/mount_linux.go | 42 ++++++++++++++++++++++++++++++----- 3 files changed, 49 insertions(+), 13 deletions(-) diff --git a/docs/erofs.md b/docs/erofs.md index f250ae081..5c56b99c4 100644 --- a/docs/erofs.md +++ b/docs/erofs.md @@ -160,7 +160,7 @@ For multi-layer images, `ls` applies AUFS-style overlay semantics in user space ## Mount the layer -`apko erofs mount SOURCE DEST` mounts a raw EROFS blob or an OCI image directory at `DEST`. It chooses between a kernel mount (root) and `erofsfuse` (unprivileged) based on the effective UID; use `--mode=kernel|fuse|auto` to force a choice. `apko erofs umount DEST` tears it back down. +`apko erofs mount SOURCE DEST` mounts a raw EROFS blob or an OCI image directory at `DEST`. It chooses between a kernel mount (root) and `erofsfuse` (unprivileged) based on the effective UID; use `--mode=kernel|fuse|auto` to force a choice. `--read-only` mounts the image without an upper/work overlay; for a single-layer image that means the lone layer is mounted straight at `DEST/merged` with no overlayfs in the path. `apko erofs umount DEST` tears it back down. ```sh mkdir -p /mnt/apko-erofs diff --git a/internal/cli/erofs.go b/internal/cli/erofs.go index 6b2dbbe7b..fb5fc60ee 100644 --- a/internal/cli/erofs.go +++ b/internal/cli/erofs.go @@ -38,6 +38,7 @@ by 'apko build --format=erofs'). These commands are Linux-only.`, func erofsMount() *cobra.Command { var mode, arch string + var readOnly bool cmd := &cobra.Command{ Use: "mount [flags] SOURCE DEST", Short: "Mount an EROFS blob or an EROFS OCI image at DEST", @@ -52,13 +53,16 @@ SOURCE may be: For OCI sources, DEST gets this layout: DEST/layers/00..NN one per EROFS layer (00 is base) - DEST/upper overlayfs upperdir - DEST/work overlayfs workdir + DEST/upper overlayfs upperdir (writable mounts only) + DEST/work overlayfs workdir (writable mounts only) DEST/merged the combined view - DEST/.apko-erofs-mount.json state for 'apko erofs umount'`, + DEST/.apko-erofs-mount.json state for 'apko erofs umount' + +With --read-only on a single-layer image, overlayfs is skipped and the +sole layer is mounted directly at DEST/merged.`, Example: ` apko erofs mount ./out:latest /mnt/x apko erofs mount --mode=fuse ./image.erofs /mnt/y - apko erofs mount oci-dir:./out:latest /mnt/z`, + apko erofs mount --read-only oci-dir:./out:latest /mnt/z`, Args: cobra.ExactArgs(2), RunE: func(cmd *cobra.Command, args []string) error { src, err := erofsmount.ParseSource(args[0]) @@ -66,14 +70,16 @@ For OCI sources, DEST gets this layout: return err } _, err = erofsmount.Mount(cmd.Context(), src, args[1], erofsmount.Options{ - Mode: erofsmount.Mode(mode), - Arch: arch, + Mode: erofsmount.Mode(mode), + Arch: arch, + ReadOnly: readOnly, }) return err }, } cmd.Flags().StringVar(&mode, "mode", string(erofsmount.ModeAuto), "mount mode: kernel, fuse, or auto (auto = kernel if root else fuse)") cmd.Flags().StringVar(&arch, "arch", "host", "architecture to select from a multi-arch OCI index (host = process arch)") + cmd.Flags().BoolVar(&readOnly, "read-only", false, "mount the image read-only (omits upperdir/workdir; single-layer images skip overlayfs entirely)") return cmd } diff --git a/pkg/erofsmount/mount_linux.go b/pkg/erofsmount/mount_linux.go index 9fe9e5110..888aa1d77 100644 --- a/pkg/erofsmount/mount_linux.go +++ b/pkg/erofsmount/mount_linux.go @@ -104,12 +104,6 @@ func mountImage(ctx context.Context, drv Driver, src Source, dest string, opts O return nil, fmt.Errorf("stat state file: %w", err) } - for _, sub := range []string{"layers", "upper", "work", "merged"} { - if err := ensureDir(filepath.Join(dest, sub)); err != nil { - return nil, err - } - } - var cleanups []func() error defer func() { if retErr == nil { @@ -122,6 +116,42 @@ func mountImage(ctx context.Context, drv Driver, src Source, dest string, opts O } }() + // Single-layer read-only short-circuit: overlay buys nothing when there's + // one lower and no upper, and a lowerdir-only overlay over a single + // EROFS mount has been flaky across overlayfs versions. Mount the layer + // straight at DEST/merged. + if opts.ReadOnly && len(layers) == 1 { + merged := filepath.Join(dest, "merged") + if err := ensureDir(merged); err != nil { + return nil, err + } + umount, err := drv.MountLayer(ctx, layers[0].BlobPath, merged) + if err != nil { + return nil, fmt.Errorf("mount layer 0 (%s) at %s: %w", layers[0].Digest, merged, err) + } + cleanups = append(cleanups, umount) + log.Infof("mounted single layer (%s) read-only at %s", layers[0].Digest, merged) + + state := &MountState{ + SchemaVersion: StateSchemaVersion, + Mode: drv.Name(), + Source: src.Raw, + Dest: dest, + Created: time.Now().UTC(), + Mounts: []string{merged}, + } + if err := WriteState(dest, state); err != nil { + return nil, fmt.Errorf("write state: %w", err) + } + return state, nil + } + + for _, sub := range []string{"layers", "upper", "work", "merged"} { + if err := ensureDir(filepath.Join(dest, sub)); err != nil { + return nil, err + } + } + layerMps := make([]string, 0, len(layers)) mountsLIFO := make([]string, 0, len(layers)+1) for i, layer := range layers { From 85de9d8e869509714cac44eca01fad20b7b6a133 Mon Sep 17 00:00:00 2001 From: Scott Moser Date: Sat, 1 Aug 2026 02:55:07 -0400 Subject: [PATCH 13/33] erofs: preserve setuid/setgid/sticky in the writer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit emitErofsEntry passed mode.Perm() to Mkdir/Mknod/Chmod, and Go keeps setuid/setgid/sticky outside the low 9 bits Perm() returns (fs.ModeSetuid et al are separate high bits), so all three were silently dropped. An EROFS layer built from any normal rootfs shipped a non-setuid sudo, passwd and su, and a non-sticky /tmp. The tar path is unaffected because archive/tar's FileInfoHeader does the translation itself. go-erofs's Writer.Chmod already converts a full fs.FileMode to POSIX mode bits and preserves the entry's type bits, so hand it the unmodified mode once, after the entry exists, instead of at each creation site — Mkdir, Mknod and Create only ever take permission bits. Symlinks are skipped: EROFS pins them at 0777. Verified against erofs-utils: fsck.erofs --extract of an image with a 04755 file, a 02755 file and a 01777 dir yields 4755/2755/sticky after this change and 755/755/755 before it. Note that dump.erofs's "Access:" line masks to 0777 and never shows these bits, even for images built by mkfs.erofs itself. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/build/erofs.go | 15 +++++--- pkg/build/erofs_test.go | 76 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 5 deletions(-) diff --git a/pkg/build/erofs.go b/pkg/build/erofs.go index 2491140d8..1341f2326 100644 --- a/pkg/build/erofs.go +++ b/pkg/build/erofs.go @@ -107,8 +107,6 @@ func emitErofsEntry(w *erofs.Writer, absPath, fsysPath string, info fs.FileInfo, if err := w.Mkdir(absPath, mode.Perm()); err != nil { return fmt.Errorf("mkdir %s: %w", absPath, err) } - } else if err := w.Chmod(absPath, mode.Perm()); err != nil { - return fmt.Errorf("chmod %s: %w", absPath, err) } case mode&fs.ModeDevice != 0, mode&fs.ModeCharDevice != 0, mode&fs.ModeNamedPipe != 0, mode&fs.ModeSocket != 0: var typeBits uint16 @@ -158,13 +156,20 @@ func emitErofsEntry(w *erofs.Writer, absPath, fsysPath string, info fs.FileInfo, if err := fout.Close(); err != nil { return fmt.Errorf("close %s: %w", absPath, err) } - if err := w.Chmod(absPath, mode.Perm()); err != nil { - return fmt.Errorf("chmod %s: %w", absPath, err) - } default: return fmt.Errorf("unsupported file mode for %s: %v", absPath, mode) } + // Mkdir, Mknod and Create all take only the permission bits, so + // setuid/setgid/sticky have to be applied on top — losing them would + // silently break su/passwd/mount and unprotect /tmp. Symlinks are + // exempt: EROFS pins them at 0777 and chmod on one is meaningless. + if mode&fs.ModeSymlink == 0 { + if err := w.Chmod(absPath, mode); err != nil { + return fmt.Errorf("chmod %s: %w", absPath, err) + } + } + uid, gid := uidGidFromInfo(info) if err := w.Chown(absPath, uid, gid); err != nil { return fmt.Errorf("chown %s: %w", absPath, err) diff --git a/pkg/build/erofs_test.go b/pkg/build/erofs_test.go index 85c9b9bf4..695372ded 100644 --- a/pkg/build/erofs_test.go +++ b/pkg/build/erofs_test.go @@ -27,6 +27,7 @@ import ( erofs "github.com/erofs/go-erofs" v1types "github.com/google/go-containerregistry/pkg/v1/types" "github.com/stretchr/testify/require" + "golang.org/x/sys/unix" "chainguard.dev/apko/pkg/apk/apk" apkfs "chainguard.dev/apko/pkg/apk/fs" @@ -111,6 +112,81 @@ func TestWriteErofs_Roundtrip(t *testing.T) { require.Equal(t, "b", target) } +// TestWriteErofs_SpecialModeBits checks that setuid, setgid and sticky survive +// the write. Go keeps them outside the low 9 bits, so anything that reduces a +// mode with fs.FileMode.Perm() drops them and ships a broken sudo/passwd and a +// world-writable /tmp. Also covers a devnode and a symlink, whose type bits the +// mode fixup must not disturb. +func TestWriteErofs_SpecialModeBits(t *testing.T) { + m := apkfs.NewMemFS() + require.NoError(t, m.MkdirAll("usr/bin", 0o755)) + require.NoError(t, m.WriteFile("usr/bin/sudo", []byte("setuid"), fs.ModeSetuid|0o755)) + require.NoError(t, m.WriteFile("usr/bin/unix_chkpwd", []byte("setgid"), fs.ModeSetgid|0o755)) + require.NoError(t, m.WriteFile("usr/bin/both", []byte("both"), fs.ModeSetuid|fs.ModeSetgid|0o755)) + require.NoError(t, m.MkdirAll("tmp", 0o777)) + require.NoError(t, m.Chmod("tmp", fs.ModeSticky|0o777)) + require.NoError(t, m.MkdirAll("dev", 0o755)) + require.NoError(t, m.Mknod("dev/null", unix.S_IFCHR|0o666, int(unix.Mkdev(1, 3)))) + require.NoError(t, m.Symlink("sudo", "usr/bin/sudo-link")) + + out := filepath.Join(t.TempDir(), "image.erofs") + f, err := os.Create(out) + require.NoError(t, err) + require.NoError(t, writeErofs(context.Background(), f, m, epoch)) + require.NoError(t, f.Close()) + + r, err := os.Open(out) + require.NoError(t, err) + defer r.Close() + img, err := erofs.Open(r) + require.NoError(t, err) + + // Lstat so the symlink case reports the link itself. fs.FileInfo.Mode() + // from the reader carries raw EROFS bits; Stat.Mode is the translated + // fs.FileMode, so assert against that. + lstat, ok := img.(interface { + Lstat(string) (fs.FileInfo, error) + }) + require.True(t, ok, "image does not implement Lstat") + statOf := func(path string) *erofs.Stat { + t.Helper() + info, err := lstat.Lstat(path) + require.NoError(t, err) + st, ok := info.Sys().(*erofs.Stat) + require.True(t, ok, "expected *erofs.Stat on %s Sys()", path) + return st + } + + for _, tc := range []struct { + path string + want fs.FileMode + }{ + {"usr/bin/sudo", fs.ModeSetuid | 0o755}, + {"usr/bin/unix_chkpwd", fs.ModeSetgid | 0o755}, + {"usr/bin/both", fs.ModeSetuid | fs.ModeSetgid | 0o755}, + {"tmp", fs.ModeDir | fs.ModeSticky | 0o777}, + } { + require.Equal(t, tc.want, statOf(tc.path).Mode, "mode mismatch for %s", tc.path) + } + + // Devnode: type, permissions and rdev all intact. + dev := statOf("dev/null") + require.NotZero(t, dev.Mode&fs.ModeCharDevice, "dev/null lost its char-device type") + require.Equal(t, fs.FileMode(0o666), dev.Mode.Perm()) + require.Equal(t, unix.Mkdev(1, 3), uint64(dev.Rdev)) + + // Symlinks keep EROFS's fixed 0777 and stay symlinks. + link := statOf("usr/bin/sudo-link") + require.NotZero(t, link.Mode&fs.ModeSymlink, "sudo-link is not a symlink") + require.Equal(t, fs.FileMode(0o777), link.Mode.Perm()) + + if fsckBin, err := exec.LookPath("fsck.erofs"); err == nil { + cmd := exec.Command(fsckBin, "-d3", out) + output, err := cmd.CombinedOutput() + require.NoError(t, err, "fsck.erofs reported a malformed image:\n%s", output) + } +} + // TestImageLayoutToLayer_Erofs exercises ImageLayoutToLayer end-to-end via a // hand-rolled Context. It confirms the layer returned advertises the erofs // media type and that DiffID == Digest (raw EROFS has no compression step). From d0607cb2982c6cf80773d619e4b0ac5e2b9a909c Mon Sep 17 00:00:00 2001 From: Scott Moser Date: Sat, 1 Aug 2026 03:04:50 -0400 Subject: [PATCH 14/33] deps: bump github.com/erofs/go-erofs to v0.3.1 Relevant to apko: the reader now reports char devices as fs.ModeDevice|fs.ModeCharDevice, matching Go's own convention (os.Lstat sets both), where 0.3.0 set only ModeCharDevice; Writer errors are now sticky, so a failure inside a long CopyFrom/Create sequence surfaces instead of being dropped; and maxBlockSize is capped at 64 KiB, which images built by apko never exceed (the default 4096 is used). Every apko consumer tests device bits with a mask rather than comparing whole mode values, so the ModeCharDevice change is a no-op here. Co-Authored-By: Claude Opus 5 (1M context) --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 94ef89a1d..abaddadff 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( chainguard.dev/sdk v0.1.191 github.com/chainguard-dev/clog v1.8.1 github.com/charmbracelet/log v1.0.0 - github.com/erofs/go-erofs v0.3.0 + github.com/erofs/go-erofs v0.3.1 github.com/go-git/go-git/v5 v5.19.2 github.com/google/go-cmp v0.7.0 github.com/google/go-containerregistry v0.21.9 diff --git a/go.sum b/go.sum index 55302e3d4..9034bb55b 100644 --- a/go.sum +++ b/go.sum @@ -80,8 +80,8 @@ github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= -github.com/erofs/go-erofs v0.3.0 h1:o/W5ABAA3sHYl97WL93dacKEfeDpJhdFf3c2snAti7I= -github.com/erofs/go-erofs v0.3.0/go.mod h1:XkSeN9MHszGd4+3gcEjadJLYHCQpWzJ7/8yznzMuzJs= +github.com/erofs/go-erofs v0.3.1 h1:Sux82Jq9yvyYhIoLgSHDp741p/+370HsOj9dAh1+VVs= +github.com/erofs/go-erofs v0.3.1/go.mod h1:XkSeN9MHszGd4+3gcEjadJLYHCQpWzJ7/8yznzMuzJs= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= From 752868fac52ed590b6ee159b1e5425d89b12ea6a Mon Sep 17 00:00:00 2001 From: Scott Moser Date: Sat, 1 Aug 2026 03:18:27 -0400 Subject: [PATCH 15/33] erofs(ls): fix uid/gid, device columns and special mode bits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three display bugs in `apko erofs ls`, all in how the listing reads metadata off the entry: - Every line printed 0/0 for ownership. uidGidFromSys looked for UID()/GID() accessor methods on info.Sys(), but go-erofs returns an *erofs.Stat, which carries them as plain fields — the assertions never matched. This was actively misleading: it read as apko losing ownership when the image was fine. - Devices printed their (zero) inode size where `tar tv` puts major,minor. Decode Stat.Rdev the way Linux's new_encode_dev() wrote it rather than with unix.Major/Minor, whose encoding is host-specific. - setuid/setgid/sticky never rendered. fs.FileInfo.Mode() from the reader carries raw on-disk bits and no Go special-mode bits; Stat.Mode is the translated value. Take mode from there and render the s/S/t/T overloads of the execute columns as `ls -l` does. Entries with no *erofs.Stat — the directories Stack synthesizes for parents no layer contains — keep falling back to the plain FileInfo. Also drop the `ls --help` text describing a temporary mount that has not happened since ls became mount-less, and say plainly that --mode is accepted only for symmetry with `mount` and ignored. Co-Authored-By: Claude Opus 5 (1M context) --- docs/erofs.md | 10 +- internal/cli/erofs.go | 11 +- pkg/erofsmount/ls.go | 103 +++++++++------- pkg/erofsmount/ls_test.go | 248 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 324 insertions(+), 48 deletions(-) create mode 100644 pkg/erofsmount/ls_test.go diff --git a/docs/erofs.md b/docs/erofs.md index 5c56b99c4..32c4b3c61 100644 --- a/docs/erofs.md +++ b/docs/erofs.md @@ -147,10 +147,16 @@ cat extracted/etc/os-release For a quick `tar tvf`-style listing of any EROFS source (raw blob or OCI image directory), use `apko erofs ls`. It opens the EROFS blobs directly, walks the merged view in user space, and prints one line per entry — no mounts, no root or FUSE required, works on Linux/macOS/Windows. +Each line is mode, uid/gid, size, mtime and path; setuid/setgid/sticky show up in the mode string as `ls -l` renders them, and devices print `major,minor` in place of a size. + ```sh apko erofs ls out/blobs/sha256/$LAYER | head -# lrwxrwxrwx 0/0 7 2026-04-17 19:17 bin -> usr/bin -# drwxr-xr-x 0/0 115 2026-04-17 19:17 dev +# lrwxrwxrwx 0/0 7 2026-04-17 19:17 bin -> usr/bin +# drwxr-xr-x 0/0 115 2026-04-17 19:17 dev +# crw-rw-rw- 0/0 1,3 2026-04-17 19:17 dev/null +# drwxrwxrwt 0/0 23 2026-04-17 19:17 tmp +# -rwsr-xr-x 0/0 178528 2026-04-17 19:17 usr/bin/sudo +# -rw-r--r-- 13/15 1183 2026-04-17 19:17 usr/share/man/whatis # ... apko erofs ls out/ # works against the whole OCI image too diff --git a/internal/cli/erofs.go b/internal/cli/erofs.go index fb5fc60ee..bfae34c6f 100644 --- a/internal/cli/erofs.go +++ b/internal/cli/erofs.go @@ -106,9 +106,12 @@ func erofsLs() *cobra.Command { cmd := &cobra.Command{ Use: "ls SOURCE", Short: "List the contents of an EROFS blob or image", - Long: `Mount SOURCE read-only to a temporary directory, walk its -contents, and print a 'tar tvf'-style listing. Unmounts automatically when -finished.`, + Long: `Walk the contents of SOURCE and print a 'tar tvf'-style listing: +mode, uid/gid, size (major,minor for devices), mtime, and path. + +SOURCE is read directly with go-erofs and nothing is mounted, so this works +without root and on any platform. Uncompressed images only; for a compressed +image use 'apko erofs mount' and list the mountpoint instead.`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { src, err := erofsmount.ParseSource(args[0]) @@ -121,7 +124,7 @@ finished.`, }, os.Stdout) }, } - cmd.Flags().StringVar(&mode, "mode", string(erofsmount.ModeAuto), "mount mode: kernel, fuse, or auto") + cmd.Flags().StringVar(&mode, "mode", string(erofsmount.ModeAuto), "accepted for symmetry with 'mount' and ignored: ls never mounts") cmd.Flags().StringVar(&arch, "arch", "host", "architecture to select from a multi-arch OCI index") return cmd } diff --git a/pkg/erofsmount/ls.go b/pkg/erofsmount/ls.go index acfb2cf73..44a750334 100644 --- a/pkg/erofsmount/ls.go +++ b/pkg/erofsmount/ls.go @@ -19,9 +19,11 @@ import ( "fmt" "io" "io/fs" - "strings" + "strconv" "text/tabwriter" + erofs "github.com/erofs/go-erofs" + "github.com/chainguard-dev/clog" ) @@ -82,17 +84,30 @@ func walkAndPrint(ctx context.Context, fsys fs.FS, w io.Writer) error { return tw.Flush() } -// formatEntry renders one entry. uid/gid are pulled from go-erofs's accessor -// interfaces on info.Sys(); they're zero for entries from filesystems that -// don't expose them. Symlink targets come from fs.ReadLinkFS when fsys -// implements it. +// formatEntry renders one entry. Mode, ownership and device numbers come from +// the *erofs.Stat on info.Sys() when present: fs.FileInfo.Mode() alone reports +// raw on-disk bits and no setuid/setgid/sticky, and fs.FileInfo has nowhere to +// put a uid. Entries without one (synthesized parent directories) fall back to +// the plain FileInfo and show 0/0. Symlink targets come from fs.ReadLinkFS +// when fsys implements it. func formatEntry(fsys fs.FS, info fs.FileInfo, name string) string { - uid, gid := uidGidFromSys(info.Sys()) - size := info.Size() + mode := info.Mode() + var uid, gid, rdev uint32 + if st, ok := info.Sys().(*erofs.Stat); ok { + mode, uid, gid, rdev = st.Mode, st.UID, st.GID, st.Rdev + } + + // Devices have no meaningful length; `tar tv` puts major,minor here. + sizeCol := strconv.FormatInt(info.Size(), 10) + if mode&(fs.ModeDevice|fs.ModeCharDevice) != 0 { + major, minor := decodeRdev(rdev) + sizeCol = fmt.Sprintf("%d,%d", major, minor) + } + mt := info.ModTime().UTC().Format("2006-01-02 15:04") suffix := "" - if info.Mode()&fs.ModeSymlink != 0 { + if mode&fs.ModeSymlink != 0 { if rl, ok := fsys.(fs.ReadLinkFS); ok { if t, err := rl.ReadLink(name); err == nil { suffix = " -> " + t @@ -100,56 +115,60 @@ func formatEntry(fsys fs.FS, info fs.FileInfo, name string) string { } } - return fmt.Sprintf("%s\t%d/%d\t%d\t%s\t%s%s", - formatMode(info.Mode()), uid, gid, size, mt, name, suffix) + return fmt.Sprintf("%s\t%d/%d\t%s\t%s\t%s%s", + formatMode(mode), uid, gid, sizeCol, mt, name, suffix) } -// uidGidFromSys extracts numeric ownership from info.Sys() via the -// single-method accessor interfaces that go-erofs documents on its Stat -// type. Anything else (including nil) yields (0, 0). -func uidGidFromSys(sys any) (uint32, uint32) { - if sys == nil { - return 0, 0 - } - type uider interface{ UID() uint32 } - type gider interface{ GID() uint32 } - var uid, gid uint32 - if u, ok := sys.(uider); ok { - uid = u.UID() - } - if g, ok := sys.(gider); ok { - gid = g.GID() - } - return uid, gid +// decodeRdev splits a device number as stored in an EROFS inode into major and +// minor. EROFS records what Linux's new_encode_dev() produced, so decode it the +// same way rather than with unix.Major/Minor, whose encoding is host-specific. +func decodeRdev(rdev uint32) (major, minor uint32) { + return (rdev & 0xfff00) >> 8, (rdev & 0xff) | ((rdev >> 12) & 0xfff00) } -// formatMode renders a 10-character mode string in the style of `ls -l`. +// formatMode renders a 10-character mode string in the style of `ls -l`, +// including the setuid/setgid/sticky overloads of the execute columns. func formatMode(mode fs.FileMode) string { - var b strings.Builder - b.Grow(10) + out := []byte("----------") switch { case mode.IsDir(): - b.WriteByte('d') + out[0] = 'd' case mode&fs.ModeSymlink != 0: - b.WriteByte('l') + out[0] = 'l' case mode&fs.ModeNamedPipe != 0: - b.WriteByte('p') + out[0] = 'p' case mode&fs.ModeSocket != 0: - b.WriteByte('s') + out[0] = 's' case mode&fs.ModeCharDevice != 0: - b.WriteByte('c') + out[0] = 'c' case mode&fs.ModeDevice != 0: - b.WriteByte('b') - default: - b.WriteByte('-') + out[0] = 'b' } perm := mode.Perm() - for i, ch := range "rwxrwxrwx" { + for i, ch := range []byte("rwxrwxrwx") { if perm&(1<<(8-i)) != 0 { - b.WriteByte(byte(ch)) + out[i+1] = ch + } + } + // setuid/setgid/sticky take over the matching execute column, upper-case + // when the execute bit itself is clear. + for _, s := range []struct { + bit fs.FileMode + col int + set, only byte + }{ + {fs.ModeSetuid, 3, 's', 'S'}, + {fs.ModeSetgid, 6, 's', 'S'}, + {fs.ModeSticky, 9, 't', 'T'}, + } { + if mode&s.bit == 0 { + continue + } + if out[s.col] == 'x' { + out[s.col] = s.set } else { - b.WriteByte('-') + out[s.col] = s.only } } - return b.String() + return string(out) } diff --git a/pkg/erofsmount/ls_test.go b/pkg/erofsmount/ls_test.go new file mode 100644 index 000000000..1cb0ed841 --- /dev/null +++ b/pkg/erofsmount/ls_test.go @@ -0,0 +1,248 @@ +// Copyright 2026 Chainguard, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package erofsmount + +import ( + "bytes" + "context" + "io/fs" + "os" + "path/filepath" + "strings" + "testing" + "time" + + erofs "github.com/erofs/go-erofs" +) + +// lsFixture writes a small EROFS image exercising every column of the listing +// — non-root ownership, the special mode bits, a devnode, a symlink — and +// returns it opened for reading. Built with the go-erofs writer directly so +// the expected uid/gid/rdev values are pinned by the test, not derived from +// whatever the host filesystem happens to hold. +func lsFixture(t *testing.T) fs.FS { + t.Helper() + + path := filepath.Join(t.TempDir(), "image.erofs") + f, err := os.Create(path) + if err != nil { + t.Fatal(err) + } + w := erofs.Create(f) + + mkdir := func(name string, perm fs.FileMode) { + t.Helper() + if err := w.Mkdir(name, perm); err != nil { + t.Fatalf("Mkdir(%s): %v", name, err) + } + chmodChown(t, w, name, perm, 0, 0) + } + write := func(name, data string, mode fs.FileMode, uid, gid int) { + t.Helper() + fh, err := w.Create(name) + if err != nil { + t.Fatalf("Create(%s): %v", name, err) + } + if _, err := fh.Write([]byte(data)); err != nil { + t.Fatalf("Write(%s): %v", name, err) + } + if err := fh.Close(); err != nil { + t.Fatalf("Close(%s): %v", name, err) + } + chmodChown(t, w, name, mode, uid, gid) + } + + mkdir("/usr", 0o755) + mkdir("/usr/bin", 0o755) + // A setuid binary owned by a non-root uid/gid: the two things the listing + // used to lose. + write("/usr/bin/sudo", "suid", fs.ModeSetuid|0o755, 13, 15) + write("/usr/bin/plain", "hello", 0o644, 0, 0) + mkdir("/tmp", fs.ModeSticky|0o777) + mkdir("/dev", 0o755) + // rdev per Linux's new_encode_dev(): major 1, minor 3 => 1<<8 | 3. + if err := w.Mknod("/dev/null", 0o020666, 1<<8|3); err != nil { + t.Fatalf("Mknod: %v", err) + } + chmodChown(t, w, "/dev/null", 0o666, 0, 0) + if err := w.Symlink("sudo", "/usr/bin/sudo-link"); err != nil { + t.Fatalf("Symlink: %v", err) + } + + if err := w.Close(); err != nil { + t.Fatalf("finalize image: %v", err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } + + r, err := os.Open(path) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = r.Close() }) + img, err := erofs.Open(r) + if err != nil { + t.Fatalf("open image: %v", err) + } + return img +} + +func chmodChown(t *testing.T, w *erofs.Writer, name string, mode fs.FileMode, uid, gid int) { + t.Helper() + if err := w.Chmod(name, mode); err != nil { + t.Fatalf("Chmod(%s): %v", name, err) + } + if err := w.Chown(name, uid, gid); err != nil { + t.Fatalf("Chown(%s): %v", name, err) + } + // A fixed mtime keeps the expected listing stable. + if err := w.Chtimes(name, time.Time{}, time.Unix(1700000000, 0)); err != nil { + t.Fatalf("Chtimes(%s): %v", name, err) + } +} + +// lsLines runs the listing over fsys and returns it keyed by path, with each +// line split into its whitespace-separated columns (tabwriter pads with +// spaces, so the raw text is not stable enough to compare). +func lsLines(t *testing.T, fsys fs.FS) map[string][]string { + t.Helper() + var buf bytes.Buffer + if err := walkAndPrint(context.Background(), fsys, &buf); err != nil { + t.Fatalf("walkAndPrint: %v", err) + } + out := map[string][]string{} + for line := range strings.SplitSeq(strings.TrimRight(buf.String(), "\n"), "\n") { + cols := strings.Fields(line) + if len(cols) < 6 { + t.Fatalf("unexpected listing line %q", line) + } + // cols: mode uid/gid size date time path [-> target] + out[cols[5]] = cols + } + return out +} + +func TestLs_ColumnsFromErofsStat(t *testing.T) { + got := lsLines(t, NewStack(lsFixture(t))) + + for _, tc := range []struct { + path string + mode, owner, siz string + }{ + // uid/gid used to always print 0/0: the accessor interfaces it looked + // for on Sys() do not exist on *erofs.Stat, only fields do. + {path: "usr/bin/sudo", mode: "-rwsr-xr-x", owner: "13/15", siz: "4"}, + {path: "usr/bin/plain", mode: "-rw-r--r--", owner: "0/0", siz: "5"}, + // siz empty: a directory's size is its dirent block, left as-is. + {path: "tmp", mode: "drwxrwxrwt", owner: "0/0"}, + // Devices reported their (meaningless) inode size where `tar tv` + // prints major,minor. + {path: "dev/null", mode: "crw-rw-rw-", owner: "0/0", siz: "1,3"}, + } { + cols, ok := got[tc.path] + if !ok { + t.Errorf("%s missing from listing", tc.path) + continue + } + if cols[0] != tc.mode { + t.Errorf("%s mode: got %s, want %s", tc.path, cols[0], tc.mode) + } + if cols[1] != tc.owner { + t.Errorf("%s uid/gid: got %s, want %s", tc.path, cols[1], tc.owner) + } + if tc.siz != "" && cols[2] != tc.siz { + t.Errorf("%s size column: got %s, want %s", tc.path, cols[2], tc.siz) + } + } + + // Symlinks keep their target suffix and are not followed for the mode. + link := got["usr/bin/sudo-link"] + if link == nil { + t.Fatal("usr/bin/sudo-link missing from listing") + } + if link[0] != "lrwxrwxrwx" { + t.Errorf("symlink mode: got %s, want lrwxrwxrwx", link[0]) + } + if want := []string{"->", "sudo"}; len(link) < 8 || link[6] != want[0] || link[7] != want[1] { + t.Errorf("symlink target: got %v, want ... -> sudo", link) + } + + // mtime column, fixed by the fixture. + if cols := got["usr/bin/plain"]; cols[3] != "2023-11-14" { + t.Errorf("date column: got %s, want 2023-11-14", cols[3]) + } +} + +// TestLs_SynthesizedDirsHaveNoStat covers the fallback path: Stack invents +// fs.FileInfo values for parent directories that no layer contains, and those +// carry a nil Sys(), so formatEntry must not depend on *erofs.Stat. +func TestLs_SynthesizedDirsHaveNoStat(t *testing.T) { + info := &synthInfo{name: "etc", mode: fs.ModeDir | 0o755} + line := formatEntry(NewStack(), info, "etc") + cols := strings.Fields(line) + if cols[0] != "drwxr-xr-x" { + t.Errorf("mode: got %s, want drwxr-xr-x", cols[0]) + } + if cols[1] != "0/0" { + t.Errorf("uid/gid: got %s, want 0/0", cols[1]) + } +} + +func TestFormatMode(t *testing.T) { + for _, tc := range []struct { + mode fs.FileMode + want string + }{ + {0o644, "-rw-r--r--"}, + {fs.ModeDir | 0o755, "drwxr-xr-x"}, + {fs.ModeSymlink | 0o777, "lrwxrwxrwx"}, + {fs.ModeDevice | fs.ModeCharDevice | 0o666, "crw-rw-rw-"}, + {fs.ModeDevice | 0o660, "brw-rw----"}, + {fs.ModeNamedPipe | 0o644, "prw-r--r--"}, + {fs.ModeSocket | 0o755, "srwxr-xr-x"}, + {fs.ModeSetuid | 0o4755, "-rwsr-xr-x"}, + {fs.ModeSetgid | 0o2755, "-rwxr-sr-x"}, + {fs.ModeDir | fs.ModeSticky | 0o1777, "drwxrwxrwt"}, + // Special bit set without the matching execute bit: upper-case. + {fs.ModeSetuid | 0o4644, "-rwSr--r--"}, + {fs.ModeSetgid | 0o2644, "-rw-r-Sr--"}, + {fs.ModeDir | fs.ModeSticky | 0o1776, "drwxrwxrwT"}, + {fs.ModeSetuid | fs.ModeSetgid | fs.ModeSticky | 0o7777, "-rwsrwsrwt"}, + } { + if got := formatMode(tc.mode); got != tc.want { + t.Errorf("formatMode(%v): got %s, want %s", tc.mode, got, tc.want) + } + } +} + +func TestDecodeRdev(t *testing.T) { + for _, tc := range []struct { + rdev uint32 + wantMaj, wantMinPar uint32 + }{ + {1<<8 | 3, 1, 3}, // /dev/null + {5<<8 | 1, 5, 1}, // /dev/console + {0, 0, 0}, // unset + {0xfff00 | 0xff, 0xfff, 0xff}, + // Minor above 8 bits spills into bits 20+ per new_encode_dev. + {8<<8 | (0x100 << 12), 8, 0x100}, + } { + maj, min := decodeRdev(tc.rdev) + if maj != tc.wantMaj || min != tc.wantMinPar { + t.Errorf("decodeRdev(%#x): got %d,%d want %d,%d", tc.rdev, maj, min, tc.wantMaj, tc.wantMinPar) + } + } +} From ae26e8c83be817b2267c1e9e241f027f212f4660 Mon Sep 17 00:00:00 2001 From: Scott Moser Date: Sat, 1 Aug 2026 03:21:02 -0400 Subject: [PATCH 16/33] erofs: cover file capabilities and other xattrs with a test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The xattr path in emitErofsEntry was never exercised for anything but user.* on a small fixture, and the package set it was tried against carries no capabilities at all. melange's guest init untars its rootfs with --xattrs-include='security.capability', so an EROFS rootfs that dropped them would break setcap'd binaries with no visible error. Verified working as-is — this test locks it in. It writes a real VFS_CAP_REVISION_2 payload plus a trusted.* and a user.* attribute (three different EROFS name-index prefixes, and one binary value) and requires them back byte-for-byte, then requires the same set from the tar writer over the same source tree so the two layer formats cannot drift. erofs-utils independently agrees the encoding is right: fsck.erofs --extract --xattrs finds security.capability on the inode and declines only because applying it needs root. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/build/erofs_test.go | 94 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/pkg/build/erofs_test.go b/pkg/build/erofs_test.go index 695372ded..a7a5e2519 100644 --- a/pkg/build/erofs_test.go +++ b/pkg/build/erofs_test.go @@ -15,12 +15,16 @@ package build import ( + "archive/tar" "bytes" "context" + "errors" + "io" "io/fs" "os" "os/exec" "path/filepath" + "strings" "testing" "time" @@ -187,6 +191,96 @@ func TestWriteErofs_SpecialModeBits(t *testing.T) { } } +// TestWriteErofs_Xattrs covers extended attributes, file capabilities in +// particular: melange's guest init untars its rootfs with +// --xattrs-include='security.capability', so an EROFS rootfs that dropped +// them would regress silently. Every xattr must reach the image byte-for-byte +// and match what the tar writer records for the same source tree. +func TestWriteErofs_Xattrs(t *testing.T) { + // A real VFS_CAP_REVISION_2 payload: CAP_NET_RAW, effective. + caps := []byte{ + 0x01, 0x00, 0x00, 0x02, 0x00, 0x20, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + } + want := map[string]map[string]string{ + "usr/bin/ping": { + // Distinct EROFS name-index prefixes: security., trusted., user. + "security.capability": string(caps), + "trusted.origin": "apko", + "user.note": "hello", + }, + "usr/bin": {"security.selinux": "system_u:object_r:bin_t:s0"}, + } + + m := apkfs.NewMemFS() + require.NoError(t, m.MkdirAll("usr/bin", 0o755)) + require.NoError(t, m.WriteFile("usr/bin/ping", []byte("ping"), 0o755)) + for path, xattrs := range want { + for name, value := range xattrs { + require.NoError(t, m.SetXattr(path, name, []byte(value))) + } + } + + out := filepath.Join(t.TempDir(), "image.erofs") + f, err := os.Create(out) + require.NoError(t, err) + require.NoError(t, writeErofs(context.Background(), f, m, epoch)) + require.NoError(t, f.Close()) + + r, err := os.Open(out) + require.NoError(t, err) + defer r.Close() + img, err := erofs.Open(r) + require.NoError(t, err) + + for path, xattrs := range want { + info, err := fs.Stat(img, path) + require.NoError(t, err) + st, ok := info.Sys().(*erofs.Stat) + require.True(t, ok, "expected *erofs.Stat on %s Sys()", path) + require.Equal(t, xattrs, st.Xattrs, "xattrs differ for %s", path) + } + + // Parity with the tar writer over the same source tree: whatever a tar + // layer would carry as SCHILY.xattr.* records, the EROFS layer must too. + var buf bytes.Buffer + tw := tar.NewWriter(&buf) + require.NoError(t, writeTar(context.Background(), tw, m)) + require.NoError(t, tw.Close()) + + tr := tar.NewReader(&buf) + seen := 0 + for { + hdr, err := tr.Next() + if errors.Is(err, io.EOF) { + break + } + require.NoError(t, err) + path := strings.TrimSuffix(strings.TrimPrefix(hdr.Name, "./"), "/") + xattrs, ok := want[path] + if !ok { + continue + } + seen++ + fromTar := map[string]string{} + for k, v := range hdr.PAXRecords { + if name, ok := strings.CutPrefix(k, "SCHILY.xattr."); ok { + fromTar[name] = v + } + } + require.Equal(t, xattrs, fromTar, "tar layer xattrs differ for %s", path) + } + require.Equal(t, len(want), seen, "not every path under test appeared in the tar layer") + + // erofs-utils must also accept the xattr encoding. Extraction can't apply + // security.*/trusted.* as a normal user, so this is a validity check; the + // read-back above is what verifies the values. + if fsckBin, err := exec.LookPath("fsck.erofs"); err == nil { + output, err := exec.Command(fsckBin, "-d3", out).CombinedOutput() + require.NoError(t, err, "fsck.erofs rejected the image:\n%s", output) + } +} + // TestImageLayoutToLayer_Erofs exercises ImageLayoutToLayer end-to-end via a // hand-rolled Context. It confirms the layer returned advertises the erofs // media type and that DiffID == Digest (raw EROFS has no compression step). From fdb74fa90470bb8833e76f0dae55119872d43a59 Mon Sep 17 00:00:00 2001 From: Scott Moser Date: Sat, 1 Aug 2026 03:48:23 -0400 Subject: [PATCH 17/33] fix(apkfs): seed character devices as devices, not empty files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DirFS mirrors the backing tree into an in-memory overrides FS at construction, and those overrides are what every type and mode lookup on a dirFS resolves against — Lstat reads them directly, Stat takes Mode() from them, and dirEntry.Type() (what fs.WalkDir hands the tar and EROFS layer writers) reports them. The seeding walk dispatched on `switch mode.Type()` with a `case fs.ModeCharDevice`. Go reports a character device as ModeDevice|ModeCharDevice — os.Lstat("/dev/null").Mode().Type() has both bits — so that case never matched and every device node in a pre-existing tree fell through to the default branch and was seeded as an empty regular file. A layer built from such a tree ships /dev/null as a zero-byte file, and Readnod on it fails. Dispatch on the bit instead. The switch body moves to seedOverride so the branch is reachable from a test without CAP_MKNOD: the FileInfo comes from the host's own /dev/null, which is exactly the shape the walk sees. Two things left as they were, both marked in the code: block devices, FIFOs and sockets still seed as regular files because the memFS overrides cannot represent them (apk only ever creates character devices), and memFS.getNode resolves the final path component, so dirFS.Lstat on a symlink still reports its target. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/apk/fs/rwosfs.go | 88 ++++++++++++++++++++------------- pkg/apk/fs/rwosfs_test.go | 100 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 154 insertions(+), 34 deletions(-) diff --git a/pkg/apk/fs/rwosfs.go b/pkg/apk/fs/rwosfs.go index c7be00b55..959bdcb07 100644 --- a/pkg/apk/fs/rwosfs.go +++ b/pkg/apk/fs/rwosfs.go @@ -155,45 +155,65 @@ func DirFS(ctx context.Context, dir string, opts ...DirFSOption) FullFS { if err != nil { return err } - mode := fi.Mode() - perm := mode.Perm() - switch mode.Type() { - case fs.ModeDir: - fullPerm := os.ModeDir | perm - err = f.overrides.Mkdir(path, fullPerm) - case fs.ModeSymlink: - var target string - target, err = root.Readlink(path) - if err == nil { - err = f.overrides.Symlink(target, path) - } - case fs.ModeCharDevice: - var dev int - sys := fi.Sys() - st1, ok1 := sys.(*syscall.Stat_t) - st2, ok2 := sys.(*unix.Stat_t) - switch { - case ok1: - dev = int(st1.Rdev) - case ok2: - dev = int(st2.Rdev) - default: - return fmt.Errorf("unsupported type %T", sys) - } - err = f.overrides.Mknod(path, uint32(unix.S_IFCHR|mode), dev) - default: - var memFile File - memFile, err = f.overrides.OpenFile(path, os.O_CREATE, perm) - if memFile != nil { - _ = memFile.Close() - } - } - return err + return f.seedOverride(path, fi, root.Readlink) }) return f } +// seedOverride mirrors one entry of the backing tree into the in-memory +// overrides. Those overrides are what every type and mode lookup on a dirFS +// resolves against (Lstat reads them directly, Stat takes Mode() from them), +// so an entry seeded as the wrong type is reported as the wrong type from then +// on. readlink resolves a symlink's target through the sandboxed root. +func (f *dirFS) seedOverride(path string, fi fs.FileInfo, readlink func(string) (string, error)) error { + mode := fi.Mode() + perm := mode.Perm() + switch { + case mode.IsDir(): + return f.overrides.Mkdir(path, os.ModeDir|perm) + case mode&fs.ModeSymlink != 0: + target, err := readlink(path) + if err != nil { + return err + } + return f.overrides.Symlink(target, path) + case mode&fs.ModeCharDevice != 0: + // Must be a bit test: Go reports a character device as + // ModeDevice|ModeCharDevice (os.Lstat sets both), so comparing + // mode.Type() against fs.ModeCharDevice alone never matches and the + // device lands in the default branch as an empty regular file. + dev, err := rdevFromInfo(fi) + if err != nil { + return err + } + return f.overrides.Mknod(path, unix.S_IFCHR|uint32(perm), dev) + default: + // Everything else — regular files, and the block devices, FIFOs and + // sockets the memFS overrides cannot represent — becomes an empty + // regular file whose content is served from disk. + memFile, err := f.overrides.OpenFile(path, os.O_CREATE, perm) + if memFile != nil { + _ = memFile.Close() + } + return err + } +} + +// rdevFromInfo extracts a raw device number from a FileInfo's Sys(). os.Lstat +// yields *syscall.Stat_t; callers holding a value from x/sys/unix yield +// *unix.Stat_t. +func rdevFromInfo(fi fs.FileInfo) (int, error) { + switch st := fi.Sys().(type) { + case *syscall.Stat_t: + return int(st.Rdev), nil + case *unix.Stat_t: + return int(st.Rdev), nil + default: + return 0, fmt.Errorf("unsupported type %T", fi.Sys()) + } +} + // dirFS represents a FullFS implementation based on a directory on disk. // For those features that are not supported, e.g. activities that are non-permissioned // or unsupported by the underlying filesystem or operating system, it keeps a separate map diff --git a/pkg/apk/fs/rwosfs_test.go b/pkg/apk/fs/rwosfs_test.go index 7a15f8e4b..c2d0f564d 100644 --- a/pkg/apk/fs/rwosfs_test.go +++ b/pkg/apk/fs/rwosfs_test.go @@ -826,3 +826,103 @@ func TestRelPath(t *testing.T) { }) } } + +// TestSeedOverride_CharDevice covers seeding a character device from the +// backing tree. Go reports a character device as ModeDevice|ModeCharDevice, so +// dispatching on mode.Type() == fs.ModeCharDevice never matched and every +// device in a pre-existing tree was seeded as an empty regular file — which is +// then what Lstat, ReadDir and the tar/EROFS layer writers all saw. +// +// The FileInfo comes from the host's own /dev/null because creating a +// character device needs CAP_MKNOD; the on-disk side is the zero-byte +// placeholder dirFS itself falls back to when mknod is not permitted. +func TestSeedOverride_CharDevice(t *testing.T) { + devInfo, err := os.Lstat("/dev/null") + if err != nil || devInfo.Mode()&fs.ModeCharDevice == 0 { + t.Skip("no /dev/null character device to seed from") + } + // The bug in one line: this is why a Type() equality check misses. + require.NotEqual(t, fs.ModeCharDevice, devInfo.Mode().Type(), + "a character device's Type() carries ModeDevice too") + + dir := t.TempDir() + fsys, ok := DirFS(t.Context(), dir).(*dirFS) + require.True(t, ok) + + // Placed after construction so the constructor's seeding walk doesn't + // claim these paths first. + require.NoError(t, os.MkdirAll(filepath.Join(dir, "dev"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "dev", "null"), nil, 0o666)) + require.NoError(t, fsys.overrides.MkdirAll("dev", os.ModeDir|0o755)) + + require.NoError(t, fsys.seedOverride("dev/null", devInfo, fsys.root.Readlink)) + + // ReadDir is the view fs.WalkDir gives the tar and EROFS layer writers, + // and dirEntry.Type() reports the override, so this is what decides + // whether the layer gets a device node or an empty file. + ents, err := fs.ReadDir(fsys, "dev") + require.NoError(t, err) + require.Len(t, ents, 1) + require.NotZero(t, ents[0].Type()&fs.ModeCharDevice, + "seeded entry must be a character device, got %v", ents[0].Type()) + + fi, err := ents[0].Info() + require.NoError(t, err) + require.Equal(t, devInfo.Mode().Perm(), fi.Mode().Perm()) + + dev, err := fsys.Readnod("dev/null") + require.NoError(t, err) + wantRdev, err := rdevFromInfo(devInfo) + require.NoError(t, err) + require.Equal(t, wantRdev, dev, "device number must survive seeding") +} + +// TestSeedOverride_TypesFromWalk checks the types a test can actually create +// without privileges still seed correctly through the constructor's walk. +func TestSeedOverride_TypesFromWalk(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "etc"), 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "etc", "hostname"), []byte("host"), 0o640)) + require.NoError(t, os.Symlink("hostname", filepath.Join(dir, "etc", "link"))) + require.NoError(t, syscall.Mkfifo(filepath.Join(dir, "etc", "fifo"), 0o644)) + + fsys := DirFS(t.Context(), dir) + require.NotNil(t, fsys) + + // Types come from ReadDir rather than Lstat: memFS.getNode resolves every + // component including the last, so dirFS.Lstat on a symlink reports its + // target. That predates this change and is left alone here. + byName := map[string]fs.DirEntry{} + ents, err := fs.ReadDir(fsys, "etc") + require.NoError(t, err) + for _, e := range ents { + byName[e.Name()] = e + } + + for _, tt := range []struct { + name string + mode fs.FileMode + }{ + {"hostname", 0o640}, + {"link", fs.ModeSymlink | 0o777}, + // A FIFO is not representable in the overrides and is deliberately + // seeded as a regular file; its content still comes off disk. + {"fifo", 0o644}, + } { + e, ok := byName[tt.name] + require.True(t, ok, "%s missing from etc", tt.name) + require.Equal(t, tt.mode.Type(), e.Type(), "%s type", tt.name) + fi, err := e.Info() + require.NoError(t, err, tt.name) + require.Equal(t, tt.mode.Perm(), fi.Mode().Perm(), "%s perm", tt.name) + } + + dirInfo, err := fsys.Lstat("etc") + require.NoError(t, err) + require.True(t, dirInfo.IsDir()) + require.Equal(t, fs.FileMode(0o750), dirInfo.Mode().Perm()) + + target, err := fsys.Readlink("etc/link") + require.NoError(t, err) + require.Equal(t, "hostname", target) +} From 53d98e7e6d3be718b5cf7771272f3d99c17987a9 Mon Sep 17 00:00:00 2001 From: Scott Moser Date: Sat, 1 Aug 2026 03:48:24 -0400 Subject: [PATCH 18/33] erofs(ls): print 0 as the size of a directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An EROFS directory inode's size is the byte length of its dirent blocks, so `ls` printed a number that varied with the child count of whichever single layer won the lookup — for a stack, never the merged directory actually being listed. A two-layer image whose lower layer holds five files in /usr/bin and whose upper holds one printed 41, describing neither. Meanwhile the directories Stack synthesizes for parents no layer contains have no inode at all and reported 0, so one listing mixed both conventions. Print 0 for every directory, which is what the 'tar tvf'-style format this claims to follow does. Co-Authored-By: Claude Opus 5 (1M context) --- docs/erofs.md | 10 ++-- pkg/erofsmount/ls.go | 12 +++- pkg/erofsmount/ls_test.go | 121 ++++++++++++++++++++++++++++++++------ 3 files changed, 118 insertions(+), 25 deletions(-) diff --git a/docs/erofs.md b/docs/erofs.md index 32c4b3c61..61fb0226d 100644 --- a/docs/erofs.md +++ b/docs/erofs.md @@ -147,14 +147,14 @@ cat extracted/etc/os-release For a quick `tar tvf`-style listing of any EROFS source (raw blob or OCI image directory), use `apko erofs ls`. It opens the EROFS blobs directly, walks the merged view in user space, and prints one line per entry — no mounts, no root or FUSE required, works on Linux/macOS/Windows. -Each line is mode, uid/gid, size, mtime and path; setuid/setgid/sticky show up in the mode string as `ls -l` renders them, and devices print `major,minor` in place of a size. +Each line is mode, uid/gid, size, mtime and path; setuid/setgid/sticky show up in the mode string as `ls -l` renders them, devices print `major,minor` in place of a size, and directories print 0 as they do in `tar tv`. ```sh apko erofs ls out/blobs/sha256/$LAYER | head -# lrwxrwxrwx 0/0 7 2026-04-17 19:17 bin -> usr/bin -# drwxr-xr-x 0/0 115 2026-04-17 19:17 dev -# crw-rw-rw- 0/0 1,3 2026-04-17 19:17 dev/null -# drwxrwxrwt 0/0 23 2026-04-17 19:17 tmp +# lrwxrwxrwx 0/0 7 2026-04-17 19:17 bin -> usr/bin +# drwxr-xr-x 0/0 0 2026-04-17 19:17 dev +# crw-rw-rw- 0/0 1,3 2026-04-17 19:17 dev/null +# drwxrwxrwt 0/0 0 2026-04-17 19:17 tmp # -rwsr-xr-x 0/0 178528 2026-04-17 19:17 usr/bin/sudo # -rw-r--r-- 13/15 1183 2026-04-17 19:17 usr/share/man/whatis # ... diff --git a/pkg/erofsmount/ls.go b/pkg/erofsmount/ls.go index 44a750334..528ee1ae0 100644 --- a/pkg/erofsmount/ls.go +++ b/pkg/erofsmount/ls.go @@ -97,11 +97,19 @@ func formatEntry(fsys fs.FS, info fs.FileInfo, name string) string { mode, uid, gid, rdev = st.Mode, st.UID, st.GID, st.Rdev } - // Devices have no meaningful length; `tar tv` puts major,minor here. sizeCol := strconv.FormatInt(info.Size(), 10) - if mode&(fs.ModeDevice|fs.ModeCharDevice) != 0 { + switch { + case mode&(fs.ModeDevice|fs.ModeCharDevice) != 0: + // Devices have no meaningful length; `tar tv` puts major,minor here. major, minor := decodeRdev(rdev) sizeCol = fmt.Sprintf("%d,%d", major, minor) + case mode.IsDir(): + // A directory inode's size is the byte length of its dirent blocks in + // whichever single layer won the lookup, which says nothing about the + // merged directory being listed — and the directories Stack synthesizes + // for absent parents have no inode to report at all. Print 0 for every + // directory, as tar does, rather than mixing the two. + sizeCol = "0" } mt := info.ModTime().UTC().Format("2006-01-02 15:04") diff --git a/pkg/erofsmount/ls_test.go b/pkg/erofsmount/ls_test.go index 1cb0ed841..32aee1823 100644 --- a/pkg/erofsmount/ls_test.go +++ b/pkg/erofsmount/ls_test.go @@ -34,6 +34,15 @@ import ( // whatever the host filesystem happens to hold. func lsFixture(t *testing.T) fs.FS { t.Helper() + return writeImage(t, func(t *testing.T, w *erofs.Writer) { + lsFixtureEntries(t, w) + }) +} + +// writeImage builds an EROFS image from build and returns it opened for +// reading. +func writeImage(t *testing.T, build func(*testing.T, *erofs.Writer)) fs.FS { + t.Helper() path := filepath.Join(t.TempDir(), "image.erofs") f, err := os.Create(path) @@ -41,6 +50,29 @@ func lsFixture(t *testing.T) fs.FS { t.Fatal(err) } w := erofs.Create(f) + build(t, w) + + if err := w.Close(); err != nil { + t.Fatalf("finalize image: %v", err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } + + r, err := os.Open(path) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = r.Close() }) + img, err := erofs.Open(r) + if err != nil { + t.Fatalf("open image: %v", err) + } + return img +} + +func lsFixtureEntries(t *testing.T, w *erofs.Writer) { + t.Helper() mkdir := func(name string, perm fs.FileMode) { t.Helper() @@ -80,24 +112,6 @@ func lsFixture(t *testing.T) fs.FS { if err := w.Symlink("sudo", "/usr/bin/sudo-link"); err != nil { t.Fatalf("Symlink: %v", err) } - - if err := w.Close(); err != nil { - t.Fatalf("finalize image: %v", err) - } - if err := f.Close(); err != nil { - t.Fatal(err) - } - - r, err := os.Open(path) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = r.Close() }) - img, err := erofs.Open(r) - if err != nil { - t.Fatalf("open image: %v", err) - } - return img } func chmodChown(t *testing.T, w *erofs.Writer, name string, mode fs.FileMode, uid, gid int) { @@ -246,3 +260,74 @@ func TestDecodeRdev(t *testing.T) { } } } + +// TestLs_DirectorySizeIsZero pins the size column for directories. An EROFS +// directory inode's size is the byte length of its dirent blocks, so before +// this it varied with the child count of whichever single layer won the +// lookup — while the directories Stack synthesizes for absent parents reported +// 0. Here the two layers disagree about what "usr/bin" holds, and neither +// layer's dirent size describes the merged directory that gets listed. +func TestLs_DirectorySizeIsZero(t *testing.T) { + lower := writeImage(t, func(t *testing.T, w *erofs.Writer) { + for _, d := range []string{"/usr", "/usr/bin"} { + if err := w.Mkdir(d, 0o755); err != nil { + t.Fatalf("Mkdir(%s): %v", d, err) + } + } + for _, name := range []string{"awk", "sed", "grep", "cut", "tr"} { + fh, err := w.Create("/usr/bin/" + name) + if err != nil { + t.Fatalf("Create(%s): %v", name, err) + } + if err := fh.Close(); err != nil { + t.Fatal(err) + } + } + }) + upper := writeImage(t, func(t *testing.T, w *erofs.Writer) { + for _, d := range []string{"/usr", "/usr/bin"} { + if err := w.Mkdir(d, 0o755); err != nil { + t.Fatalf("Mkdir(%s): %v", d, err) + } + } + fh, err := w.Create("/usr/bin/sh") + if err != nil { + t.Fatalf("Create: %v", err) + } + if err := fh.Close(); err != nil { + t.Fatal(err) + } + }) + + // Sanity: the layers really do report different directory sizes, so the + // assertion below is not vacuous. + lowerDir, err := fs.Stat(lower, "usr/bin") + if err != nil { + t.Fatal(err) + } + upperDir, err := fs.Stat(upper, "usr/bin") + if err != nil { + t.Fatal(err) + } + if lowerDir.Size() == upperDir.Size() { + t.Fatalf("fixture layers report the same dir size (%d); test proves nothing", lowerDir.Size()) + } + + got := lsLines(t, NewStack(lower, upper)) + for _, dir := range []string{"usr", "usr/bin"} { + cols, ok := got[dir] + if !ok { + t.Errorf("%s missing from listing", dir) + continue + } + if cols[2] != "0" { + t.Errorf("%s size column: got %s, want 0", dir, cols[2]) + } + } + // The merged listing must still show both layers' files. + for _, want := range []string{"usr/bin/awk", "usr/bin/sh"} { + if _, ok := got[want]; !ok { + t.Errorf("%s missing from merged listing", want) + } + } +} From 245e55ae342139e6931cc09f5109f78fc0d90ec7 Mon Sep 17 00:00:00 2001 From: Scott Moser Date: Sat, 1 Aug 2026 04:01:11 -0400 Subject: [PATCH 19/33] test(erofs): guard the go-erofs metadata source erofs ls depends on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit formatEntry reads mode, ownership and rdev off the *erofs.Stat behind Sys(). If a future go-erofs returned a different type there, formatEntry would quietly fall back to the plain fs.FileInfo and regress to 0/0 ownership with no setuid/setgid/sticky — the exact pair of bugs fixed in ed0b6c42, reintroduced by a dependency bump with nothing failing. Assert the type, and assert that the UID()/GID() accessors go-erofs documents on the fs.FileInfo agree with the Stat fields the listing actually reads. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/erofsmount/ls_test.go | 49 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/pkg/erofsmount/ls_test.go b/pkg/erofsmount/ls_test.go index 32aee1823..e3e41bf0f 100644 --- a/pkg/erofsmount/ls_test.go +++ b/pkg/erofsmount/ls_test.go @@ -331,3 +331,52 @@ func TestLs_DirectorySizeIsZero(t *testing.T) { } } } + +// TestLs_ErofsStatIsTheMetadataSource pins the go-erofs assumption the listing +// depends on. formatEntry reads mode, ownership and rdev off the *erofs.Stat +// that Sys() returns; if a future go-erofs returned something else, formatEntry +// would silently fall back to the plain fs.FileInfo and go back to printing +// 0/0 with no special mode bits. Fail loudly here instead. +// +// go-erofs's own docs point callers at the accessor interfaces on the +// fs.FileInfo rather than at Stat's fields (the fields cannot satisfy those +// interfaces — a struct cannot have a field and a method of the same name), so +// this also checks the two sources agree. +func TestLs_ErofsStatIsTheMetadataSource(t *testing.T) { + img := lsFixture(t) + lstat, ok := img.(interface { + Lstat(string) (fs.FileInfo, error) + }) + if !ok { + t.Fatal("image does not implement Lstat") + } + info, err := lstat.Lstat("usr/bin/sudo") + if err != nil { + t.Fatal(err) + } + + st, ok := info.Sys().(*erofs.Stat) + if !ok { + t.Fatalf("Sys() is %T, not *erofs.Stat: formatEntry has no metadata source", info.Sys()) + } + if st.UID != 13 || st.GID != 15 { + t.Errorf("Stat ownership: got %d/%d, want 13/15", st.UID, st.GID) + } + // Stat.Mode is the translated fs.FileMode. FileInfo.Mode() is not — it + // carries raw on-disk bits, which is why formatEntry does not use it. + if st.Mode&fs.ModeSetuid == 0 { + t.Errorf("Stat.Mode lost setuid: %v (%#o)", st.Mode, uint32(st.Mode)) + } + + acc, ok := info.(interface { + UID() uint32 + GID() uint32 + }) + if !ok { + t.Fatal("FileInfo no longer exposes UID()/GID() accessors") + } + if acc.UID() != st.UID || acc.GID() != st.GID { + t.Errorf("accessors disagree with Stat fields: %d/%d vs %d/%d", + acc.UID(), acc.GID(), st.UID, st.GID) + } +} From 50525af4fad7d0645074ed7e17d831f0d4353aee Mon Sep 17 00:00:00 2001 From: Scott Moser Date: Fri, 14 Aug 2026 00:45:32 -0400 Subject: [PATCH 20/33] build(erofs): close the image explicitly instead of syncing it Review noted the asymmetry: the mkfs.erofs path closes outfile with an error check, while the go-erofs path used a deferred Close plus a Sync, with no stated reason for the Sync. Both paths hash the finished file, so the close has to happen before the hash and its error has to be reported -- a deferred Close would swallow a write error surfaced at close time. Sync was never load-bearing: *os.File does no userspace buffering, so once Close returns a fresh Open sees every byte. Drop it and close explicitly in both branches, with a comment covering the whole block. Also note at both call sites that the discarded second return of CompressionLevel reports whether "level=" was given, not a parse failure; 0 means "let mkfs.erofs choose". --- pkg/build/build.go | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/pkg/build/build.go b/pkg/build/build.go index ea2c12896..264ae45f5 100644 --- a/pkg/build/build.go +++ b/pkg/build/build.go @@ -185,22 +185,29 @@ func (bc *Context) ImageLayoutToLayer(ctx context.Context) (string, v1.Layer, er return "", nil, fmt.Errorf("creating tarball file: %w", err) } bc.o.TarballPath = outfile.Name() - defer outfile.Close() if bc.ic.Format.Resolved() == types.LayerFormatErofs { + // The EROFS path does not defer Close: it hashes the finished file, so + // the close has to happen first and its error has to be reported + // rather than swallowed by a defer. No Sync is needed either — + // *os.File does no userspace buffering, so once Close returns, a fresh + // Open sees every byte. + outName := outfile.Name() if err := writeErofs(ctx, outfile, bc.fs, bc.o.SourceDateEpoch); err != nil { + _ = outfile.Close() return "", nil, fmt.Errorf("generating erofs image: %w", err) } - if err := outfile.Sync(); err != nil { - return "", nil, fmt.Errorf("syncing erofs image: %w", err) + if err := outfile.Close(); err != nil { + return "", nil, fmt.Errorf("closing erofs image: %w", err) } - l, err := buildErofsLayerFromFile(outfile.Name(), nil) + l, err := buildErofsLayerFromFile(outName, nil) if err != nil { return "", nil, fmt.Errorf("finalizing erofs layer: %w", err) } - return outfile.Name(), l, nil + return outName, l, nil } + defer outfile.Close() lw := newLayerWriter(outfile) if err := writeTar(ctx, lw.w, bc.fs); err != nil { From a2fada114f39a6562b24901a8ac960c66ed752c1 Mon Sep 17 00:00:00 2001 From: Scott Moser Date: Fri, 14 Aug 2026 00:45:44 -0400 Subject: [PATCH 21/33] erofs: stop discarding ListXattrs and short-write errors Two silently-dropped errors flagged in review. emitErofsEntry ignored the error from ListXattrs, so a failed xattr lookup produced a file with no xattrs rather than a build failure. apko's FullFS implementations keep xattrs in memory for every node they know about, so an error there means the entry we just walked has gone missing -- a bug worth surfacing. That makes the dirFS path load-bearing, and nothing covered it: every existing erofs test writes from a memFS, while real builds write from a dirFS whose xattr lookups resolve against overrides seeded by walking the backing tree. Add TestWriteErofs_DirFS to pin that down. writeErofsRegularBytes checked Write's error but not its count. The io.Writer contract requires a short write to report an error, but go-erofs's writer is young enough that trusting that silently is not worth it; compare against len(data) and fail with io.ErrShortWrite. --- pkg/build/erofs.go | 8 +++- pkg/build/erofs_dirfs_test.go | 74 +++++++++++++++++++++++++++++++++++ pkg/build/erofs_layers.go | 8 +++- 3 files changed, 88 insertions(+), 2 deletions(-) create mode 100644 pkg/build/erofs_dirfs_test.go diff --git a/pkg/build/erofs.go b/pkg/build/erofs.go index 1341f2326..8a86c66de 100644 --- a/pkg/build/erofs.go +++ b/pkg/build/erofs.go @@ -182,7 +182,13 @@ func emitErofsEntry(w *erofs.Writer, absPath, fsysPath string, info fs.FileInfo, } if mode.IsRegular() || mode.IsDir() { - xattrs, _ := fsys.ListXattrs(fsysPath) + // apko's FullFS implementations keep xattrs in memory for every node + // they know about, so an error here means the path we just walked has + // gone missing — a bug worth surfacing, not something to skip past. + xattrs, err := fsys.ListXattrs(fsysPath) + if err != nil { + return fmt.Errorf("list xattrs %s: %w", fsysPath, err) + } for name, value := range xattrs { if err := w.Setxattr(absPath, name, string(value)); err != nil { return fmt.Errorf("setxattr %s %s: %w", absPath, name, err) diff --git a/pkg/build/erofs_dirfs_test.go b/pkg/build/erofs_dirfs_test.go new file mode 100644 index 000000000..7ce59fd32 --- /dev/null +++ b/pkg/build/erofs_dirfs_test.go @@ -0,0 +1,74 @@ +// Copyright 2026 Chainguard, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package build + +import ( + "context" + "io/fs" + "os" + "path/filepath" + "testing" + + erofs "github.com/erofs/go-erofs" + "github.com/stretchr/testify/require" + + apkfs "chainguard.dev/apko/pkg/apk/fs" +) + +// TestWriteErofs_DirFS writes an image from a dirFS rather than the memFS the +// other tests use. Real builds run on a dirFS, whose xattr lookups resolve +// against in-memory overrides seeded by walking the backing tree — so this is +// the path that would break if a walked entry were ever missing from those +// overrides (writeErofs treats a ListXattrs failure as fatal). +func TestWriteErofs_DirFS(t *testing.T) { + ctx := context.Background() + base := t.TempDir() + + require.NoError(t, os.MkdirAll(filepath.Join(base, "usr", "bin"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(base, "usr", "bin", "hello"), []byte("hi\n"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(base, "top"), []byte("top\n"), 0o644)) + require.NoError(t, os.Symlink("/usr/bin/hello", filepath.Join(base, "link"))) + + fsys := apkfs.DirFS(ctx, base) + require.NoError(t, fsys.SetXattr("usr/bin/hello", "user.marker", []byte("set"))) + + out := filepath.Join(t.TempDir(), "image.erofs") + f, err := os.Create(out) + require.NoError(t, err) + require.NoError(t, writeErofs(ctx, f, fsys, epoch)) + require.NoError(t, f.Close()) + + rf, err := os.Open(out) + require.NoError(t, err) + defer rf.Close() + img, err := erofs.Open(rf) + require.NoError(t, err) + + for _, p := range []string{"usr/bin/hello", "top", "link"} { + _, err := fs.Stat(img, p) + require.NoError(t, err, "path %q missing from image", p) + } + + data, err := fs.ReadFile(img, "top") + require.NoError(t, err) + require.Equal(t, "top\n", string(data)) + + // The xattr set through the dirFS overrides must survive into the image. + info, err := fs.Stat(img, "usr/bin/hello") + require.NoError(t, err) + st, ok := info.Sys().(*erofs.Stat) + require.True(t, ok, "expected *erofs.Stat on Sys()") + require.Equal(t, "set", st.Xattrs["user.marker"]) +} diff --git a/pkg/build/erofs_layers.go b/pkg/build/erofs_layers.go index 09bac2fa7..08460d381 100644 --- a/pkg/build/erofs_layers.go +++ b/pkg/build/erofs_layers.go @@ -18,6 +18,7 @@ import ( "bytes" "context" "fmt" + "io" "io/fs" "path" "strings" @@ -261,10 +262,15 @@ func writeErofsRegularBytes(w *erofs.Writer, absPath string, info fs.FileInfo, d return fmt.Errorf("create %s: %w", absPath, err) } if len(data) > 0 { - if _, err := fout.Write(data); err != nil { + n, err := fout.Write(data) + if err != nil { _ = fout.Close() return fmt.Errorf("write %s: %w", absPath, err) } + if n != len(data) { + _ = fout.Close() + return fmt.Errorf("write %s: %w (wrote %d of %d bytes)", absPath, io.ErrShortWrite, n, len(data)) + } } if err := fout.Close(); err != nil { return fmt.Errorf("close %s: %w", absPath, err) From 1c0535e0c4ad039e8bc7a726969584eedda1c3b6 Mon Sep 17 00:00:00 2001 From: Scott Moser Date: Fri, 14 Aug 2026 00:46:15 -0400 Subject: [PATCH 22/33] test(erofs): make the optional fsck.erofs cross-check explicit Review asked whether fsckBin is optional, and flagged a discarded error from the lookup helper. It is optional, but nothing said so: three tests ran fsck.erofs only "if err == nil" and passed quietly when erofs-utils was absent. Replace the ad-hoc lookups and lookFsckErofs with optionalFsckErofs, which returns "" and logs when the binary is missing. The doc comment states why it is a second opinion rather than the only check -- every caller has already parsed the image with go-erofs -- and points at TestWriteErofs_FsckErofs as the test that skips outright instead. Also quote the interpolated paths in the roundtrip test's failure messages, per review, so they are easier to pick out of output. --- pkg/build/erofs_test.go | 31 +++++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/pkg/build/erofs_test.go b/pkg/build/erofs_test.go index a7a5e2519..a78c43c18 100644 --- a/pkg/build/erofs_test.go +++ b/pkg/build/erofs_test.go @@ -89,9 +89,9 @@ func TestWriteErofs_Roundtrip(t *testing.T) { return nil })) - require.Contains(t, got, "a", "directory a missing from image") - require.Contains(t, got, "a/b", "file a/b missing from image") - require.Contains(t, got, "a/link", "symlink a/link missing from image") + require.Contains(t, got, "a", "directory %q missing from image", "a") + require.Contains(t, got, "a/b", "file %q missing from image", "a/b") + require.Contains(t, got, "a/link", "symlink %q missing from image", "a/link") // File content data, err := fs.ReadFile(img, "a/b") @@ -184,7 +184,7 @@ func TestWriteErofs_SpecialModeBits(t *testing.T) { require.NotZero(t, link.Mode&fs.ModeSymlink, "sudo-link is not a symlink") require.Equal(t, fs.FileMode(0o777), link.Mode.Perm()) - if fsckBin, err := exec.LookPath("fsck.erofs"); err == nil { + if fsckBin := optionalFsckErofs(t); fsckBin != "" { cmd := exec.Command(fsckBin, "-d3", out) output, err := cmd.CombinedOutput() require.NoError(t, err, "fsck.erofs reported a malformed image:\n%s", output) @@ -275,7 +275,7 @@ func TestWriteErofs_Xattrs(t *testing.T) { // erofs-utils must also accept the xattr encoding. Extraction can't apply // security.*/trusted.* as a normal user, so this is a validity check; the // read-back above is what verifies the values. - if fsckBin, err := exec.LookPath("fsck.erofs"); err == nil { + if fsckBin := optionalFsckErofs(t); fsckBin != "" { output, err := exec.Command(fsckBin, "-d3", out).CombinedOutput() require.NoError(t, err, "fsck.erofs rejected the image:\n%s", output) } @@ -387,7 +387,7 @@ func TestSplitErofsLayers(t *testing.T) { require.Len(t, layers, 3, "expected 2 group layers + 1 top layer") // All three layers should be valid EROFS images. - fsckBin, _ := lookFsckErofs() + fsckBin := optionalFsckErofs(t) for i, l := range layers { erl, ok := l.(*erofsLayer) require.True(t, ok, "layer[%d] not *erofsLayer", i) @@ -452,8 +452,23 @@ func newPkg(name string) *apk.Package { return &apk.Package{Name: name, Origin: name, Version: "1.0.0", InstalledSize: 1024} } -func lookFsckErofs() (string, error) { - return exec.LookPath("fsck.erofs") +// optionalFsckErofs returns the path to fsck.erofs, or "" when erofs-utils is +// not installed. It is deliberately optional: every caller has already parsed +// the image with go-erofs, so fsck is a second opinion from the C reference +// implementation rather than the only check, and contributors without +// erofs-utils still get a green build. The log line keeps the reduced coverage +// visible in test output instead of silently passing. +// +// Tests whose entire point is the erofs-utils cross-check skip explicitly +// instead — see TestWriteErofs_FsckErofs. +func optionalFsckErofs(t *testing.T) string { + t.Helper() + bin, err := exec.LookPath("fsck.erofs") + if err != nil { + t.Log("fsck.erofs not on PATH; skipping the erofs-utils cross-check (go-erofs validation still runs)") + return "" + } + return bin } func TestWriteErofs_Reproducible(t *testing.T) { From 46ac139edc76c197bcf980a1a217e02c9993ce33 Mon Sep 17 00:00:00 2001 From: Scott Moser Date: Fri, 14 Aug 2026 00:46:26 -0400 Subject: [PATCH 23/33] erofsmount: explain erofs: vs oci:, drop an unbacked claim Review asked how the erofs: prefix differs from oci: pointed at an image with erofs layers, and what happens when erofs: names something that is not an erofs blob. Document it where the prefixes are defined: the prefixes select how the path is read, not what the bytes turn out to be, so erofs: on an OCI layout directory fails at parse time ("not a regular file"), while erofs: on a non-erofs regular file parses and only fails at mount time. Review also asked for specifics behind the claim that a lowerdir-only overlay over a single erofs mount "has been flaky across overlayfs versions". We do not have anything concrete to point at, so drop that sentence and keep only the reason that stands on its own: overlay buys nothing with one lower and no upper. --- pkg/erofsmount/mount_linux.go | 5 ++--- pkg/erofsmount/source.go | 21 +++++++++++++++++++-- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/pkg/erofsmount/mount_linux.go b/pkg/erofsmount/mount_linux.go index 888aa1d77..7d92447fb 100644 --- a/pkg/erofsmount/mount_linux.go +++ b/pkg/erofsmount/mount_linux.go @@ -117,9 +117,8 @@ func mountImage(ctx context.Context, drv Driver, src Source, dest string, opts O }() // Single-layer read-only short-circuit: overlay buys nothing when there's - // one lower and no upper, and a lowerdir-only overlay over a single - // EROFS mount has been flaky across overlayfs versions. Mount the layer - // straight at DEST/merged. + // one lower and no upper, so mount the layer straight at DEST/merged. + // Multi-layer and writable mounts still compose through overlayfs. if opts.ReadOnly && len(layers) == 1 { merged := filepath.Join(dest, "merged") if err := ensureDir(merged); err != nil { diff --git a/pkg/erofsmount/source.go b/pkg/erofsmount/source.go index 0960b15ee..154ab087f 100644 --- a/pkg/erofsmount/source.go +++ b/pkg/erofsmount/source.go @@ -59,6 +59,23 @@ type Source struct { Raw string } +// Scheme prefixes. They select *how the path is interpreted*, not what the +// bytes at the far end turn out to be: +// +// - "erofs:" names one EROFS filesystem image file — the single blob apko +// writes with --format=erofs, or a layer blob lifted out of an OCI layout. +// There is no manifest, so there is nothing to compose: it mounts as a +// single layer. +// - "oci:" / "oci-dir:" name an OCI *layout directory*. OpenLayers walks +// index.json and the selected manifest to collect every EROFS layer blob +// (and its org.erofs.role annotation), and all of them get composed. +// +// So they are not interchangeable, even against the same image. "erofs:" on an +// OCI layout directory fails at parse time ("not a regular file"), because a +// directory is not a blob. In the other direction, "erofs:" on a regular file +// that is not an EROFS image parses fine — the prefix asserts intent, not +// content — and fails later when the mount is attempted, since only the kernel +// (or go-erofs) can judge the superblock. const ( prefixErofs = "erofs:" prefixOCI = "oci:" @@ -68,8 +85,8 @@ const ( // ParseSource resolves a user-supplied source spec into a Source. Accepted // forms (checked in order): // -// - "erofs:PATH" force KindBlob. -// - "oci:PATH[:TAG]" force KindOCIDir. +// - "erofs:PATH" force KindBlob (single layer, no manifest). +// - "oci:PATH[:TAG]" force KindOCIDir (all layers from the manifest). // - "oci-dir:PATH[:TAG]" force KindOCIDir. // - "PATH" auto-detect: regular file → blob; directory // containing an `oci-layout` file → OCI dir. From 1fe041ea5feea2857f6e40f1c579237b063ae78d Mon Sep 17 00:00:00 2001 From: Scott Moser Date: Tue, 18 Aug 2026 08:31:04 -0400 Subject: [PATCH 24/33] erofs: descope mount/umount to a follow-up Matt's review found two bugs concentrated in the mount plane: Unmount trusts the on-disk state file and hands its recorded paths straight to `umount` (typically as root) with no validation, and the "rerun once they are no longer busy" instruction in the partial-failure path cannot succeed, because the rerun's first step is to unmount `merged`, which already came down. Both want more than a spot fix. The state file needs containment checks, the unmount loop needs to tolerate an already-unmounted entry or rewrite state as it goes, and the 305 lines of orchestration have no test seam at all -- Mount/Unmount construct their Driver internally, so nothing in-tree ever executes the cleanup LIFO or the state lifecycle. Doing that properly is its own change. So drop the mount plane here and land it next, hardened, on top. What remains in this PR is the single-image writer and `apko erofs ls`, both of which the review found sound. Removed: mount_linux.go, driver_linux.go, state.go, stub_other.go and their tests, plus the `apko erofs mount` and `apko erofs umount` subcommands. Ls loses the Options bag it only ever read Arch out of, and takes an arch string directly; that also drops the `--mode` flag it documented as "accepted for symmetry with 'mount' and ignored". With the Linux-only files gone the package no longer has a build-tag-dependent API, which was a separate review note: everything left is pure Go and builds everywhere. The mount and overlay-assembly recipes in docs/erofs.md now show the plain mount/erofsfuse commands instead of `apko erofs mount`. The full pre-descope tree is preserved on the feat/apko-erofs-full branch. --- docs/erofs.md | 52 +---- internal/cli/erofs.go | 95 ++------- pkg/erofsmount/driver_linux.go | 246 ---------------------- pkg/erofsmount/driver_linux_test.go | 106 ---------- pkg/erofsmount/ls.go | 9 +- pkg/erofsmount/mount_linux.go | 305 ---------------------------- pkg/erofsmount/source.go | 12 +- pkg/erofsmount/state.go | 141 ------------- pkg/erofsmount/state_test.go | 100 --------- pkg/erofsmount/stub_other.go | 44 ---- 10 files changed, 35 insertions(+), 1075 deletions(-) delete mode 100644 pkg/erofsmount/driver_linux.go delete mode 100644 pkg/erofsmount/driver_linux_test.go delete mode 100644 pkg/erofsmount/mount_linux.go delete mode 100644 pkg/erofsmount/state.go delete mode 100644 pkg/erofsmount/state_test.go delete mode 100644 pkg/erofsmount/stub_other.go diff --git a/docs/erofs.md b/docs/erofs.md index 61fb0226d..8805d5ffe 100644 --- a/docs/erofs.md +++ b/docs/erofs.md @@ -166,34 +166,24 @@ For multi-layer images, `ls` applies AUFS-style overlay semantics in user space ## Mount the layer -`apko erofs mount SOURCE DEST` mounts a raw EROFS blob or an OCI image directory at `DEST`. It chooses between a kernel mount (root) and `erofsfuse` (unprivileged) based on the effective UID; use `--mode=kernel|fuse|auto` to force a choice. `--read-only` mounts the image without an upper/work overlay; for a single-layer image that means the lone layer is mounted straight at `DEST/merged` with no overlayfs in the path. `apko erofs umount DEST` tears it back down. +A layer blob is a complete filesystem image, so mounting it takes no apko-specific tooling — either the kernel `erofs` driver (needs root) or `erofsfuse` (unprivileged): ```sh mkdir -p /mnt/apko-erofs -apko erofs mount out/blobs/sha256/$LAYER /mnt/apko-erofs -ls /mnt/apko-erofs/ -file /mnt/apko-erofs/bin/sh -apko erofs umount /mnt/apko-erofs -``` - -If the kernel mount mode complains "unknown filesystem type 'erofs'", the kernel module is missing on your system; install it (e.g. `linux-modules-extra-$(uname -r)` on Ubuntu) or pass `--mode=fuse` to use `erofsfuse`, which does not require root and works inside CI containers that lack the kernel module. - -### Doing it manually -For reference, `apko erofs mount` is equivalent to one of: - -```sh # Kernel (root): sudo mount -t erofs -o ro out/blobs/sha256/$LAYER /mnt/apko-erofs -# ...later: +ls /mnt/apko-erofs/ +file /mnt/apko-erofs/bin/sh sudo umount /mnt/apko-erofs # FUSE (unprivileged): erofsfuse out/blobs/sha256/$LAYER /mnt/apko-erofs -# ...later: fusermount3 -u /mnt/apko-erofs # or `fusermount -u` ``` +If `mount` reports "unknown filesystem type 'erofs'", the kernel module is missing on your system; install it (e.g. `linux-modules-extra-$(uname -r)` on Ubuntu) or use `erofsfuse`, which needs no root and works inside CI containers that lack the module. + ## Pulling from a registry If you push the image with `apko publish` or `crane push`, the registry stores each blob unchanged — including the EROFS layer blob. @@ -263,33 +253,7 @@ Each layer is independently mountable as an EROFS filesystem, and each carries i ### Assemble the full rootfs with overlayfs -The OCI spec composes layers with `overlayfs`-style semantics; for EROFS layers the composition is straightforward. -The simplest way is `apko erofs mount`, which mounts each layer and assembles the overlay in one step: - -```sh -mkdir -p mnt -apko erofs mount out-layered/ mnt/ -ls mnt/merged/ # full rootfs -cat mnt/.apko-erofs-mount.json # records the mounts for teardown -apko erofs umount mnt/ # unwinds the overlay and every layer -``` - -The directory layout produced under `DEST` is: - -``` -mnt/ -├── layers/00..NN # one EROFS mountpoint per layer (00 is base) -├── upper/ # overlayfs upperdir -├── work/ # overlayfs workdir -├── merged/ # combined view -└── .apko-erofs-mount.json # state file consumed by `apko erofs umount` -``` - -`apko erofs mount` picks kernel mounts when running as root and falls back to `erofsfuse` + (kernel overlay over FUSE, then `fuse-overlayfs`) otherwise. Force one path with `--mode=kernel|fuse|auto`. - -#### Doing it manually - -For reference, the equivalent without `apko erofs mount`: +The OCI spec composes layers with `overlayfs`-style semantics; for EROFS layers the composition is straightforward — mount each layer read-only, then stack them as `lowerdir`s: ```sh # Pull each layer blob out of the OCI layout. @@ -325,7 +289,7 @@ sudo umount mnt/merged for d in mnt/lower*; do sudo umount "$d" 2>/dev/null || fusermount -u "$d"; done ``` -Production runtimes (containerd's erofs snapshotter, podman/CRI-O with the erofs-aware plugin, etc.) automate this assembly; both `apko erofs mount` and the manual steps above are for verifying that an apko-built EROFS image really does compose into a valid rootfs. +Production runtimes (containerd's erofs snapshotter, podman/CRI-O with the erofs-aware plugin, etc.) automate this assembly; the steps above are for verifying that an apko-built EROFS image really does compose into a valid rootfs. ## Using EROFS support as a Go library @@ -354,7 +318,7 @@ _, layer, err := bc.ImageLayoutToLayer(ctx) If you have a plain `fs.FS` and want an EROFS image, **use [go-erofs](https://github.com/erofs/go-erofs) directly** — apko doesn't expose its EROFS writer as a standalone library (and wrapping go-erofs wouldn't add meaningful value over its existing `Writer.CopyFrom(fs.FS)` API). -For inspection, apko *does* expose a focused leaf library — see `chainguard.dev/apko/pkg/erofsmount` — which provides `Stack` (layered `fs.FS` with overlay/whiteout semantics), `OpenLayers` (open an OCI EROFS image's blobs), `ReadOCILayers` (parse an OCI manifest with EROFS layers), and `Mount`/`Unmount`/`Ls` (the CLI subcommand helpers, Linux-only for mount/umount; `Ls` is cross-platform). +For inspection, apko *does* expose a focused leaf library — see `chainguard.dev/apko/pkg/erofsmount` — which provides `Stack` (layered `fs.FS` with overlay/whiteout semantics), `OpenLayers` (open an OCI EROFS image's blobs), `ReadOCILayers` (parse an OCI manifest with EROFS layers), and `Ls` (the `apko erofs ls` helper). All of it is cross-platform: go-erofs is pure Go and nothing here mounts anything. ## Current limitations diff --git a/internal/cli/erofs.go b/internal/cli/erofs.go index bfae34c6f..fa9da3bc9 100644 --- a/internal/cli/erofs.go +++ b/internal/cli/erofs.go @@ -22,109 +22,48 @@ import ( "chainguard.dev/apko/pkg/erofsmount" ) -// erofsCmd returns the `apko erofs` parent command, which hosts mount, umount, -// and ls subcommands. +// erofsCmd returns the `apko erofs` parent command, which hosts the ls +// subcommand. func erofsCmd() *cobra.Command { cmd := &cobra.Command{ Use: "erofs", - Short: "Mount, unmount, and inspect EROFS images produced by apko", + Short: "Inspect EROFS images produced by apko", Long: `The erofs subcommands operate on EROFS layer blobs and OCI image directories whose layers use the application/vnd.erofs mediaType (as produced -by 'apko build --format=erofs'). These commands are Linux-only.`, - } - cmd.AddCommand(erofsMount(), erofsUmount(), erofsLs()) - return cmd -} - -func erofsMount() *cobra.Command { - var mode, arch string - var readOnly bool - cmd := &cobra.Command{ - Use: "mount [flags] SOURCE DEST", - Short: "Mount an EROFS blob or an EROFS OCI image at DEST", - Long: `Mount the given SOURCE at DEST. - -SOURCE may be: - - a raw EROFS blob file (mounted directly at DEST), - - an OCI image layout directory containing EROFS layers (mounted as a - multi-layer overlay rooted at DEST/merged), - - any of the above prefixed by erofs:, oci:, or oci-dir:, - - PATH:TAG to pick a manifest from a multi-tag OCI layout. - -For OCI sources, DEST gets this layout: - DEST/layers/00..NN one per EROFS layer (00 is base) - DEST/upper overlayfs upperdir (writable mounts only) - DEST/work overlayfs workdir (writable mounts only) - DEST/merged the combined view - DEST/.apko-erofs-mount.json state for 'apko erofs umount' - -With --read-only on a single-layer image, overlayfs is skipped and the -sole layer is mounted directly at DEST/merged.`, - Example: ` apko erofs mount ./out:latest /mnt/x - apko erofs mount --mode=fuse ./image.erofs /mnt/y - apko erofs mount --read-only oci-dir:./out:latest /mnt/z`, - Args: cobra.ExactArgs(2), - RunE: func(cmd *cobra.Command, args []string) error { - src, err := erofsmount.ParseSource(args[0]) - if err != nil { - return err - } - _, err = erofsmount.Mount(cmd.Context(), src, args[1], erofsmount.Options{ - Mode: erofsmount.Mode(mode), - Arch: arch, - ReadOnly: readOnly, - }) - return err - }, - } - cmd.Flags().StringVar(&mode, "mode", string(erofsmount.ModeAuto), "mount mode: kernel, fuse, or auto (auto = kernel if root else fuse)") - cmd.Flags().StringVar(&arch, "arch", "host", "architecture to select from a multi-arch OCI index (host = process arch)") - cmd.Flags().BoolVar(&readOnly, "read-only", false, "mount the image read-only (omits upperdir/workdir; single-layer images skip overlayfs entirely)") - return cmd -} - -func erofsUmount() *cobra.Command { - cmd := &cobra.Command{ - Use: "umount DEST", - Short: "Unmount an EROFS mount produced by 'apko erofs mount'", - Long: `Unmount the mount at DEST. - -If DEST contains a state file (DEST/.apko-erofs-mount.json) it is treated as -an image mount and every layer plus the overlay is torn down in reverse -order. If DEST has no state file, it is treated as a single blob mount and a -plain umount is attempted (with a fall-back to fusermount).`, - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - return erofsmount.Unmount(cmd.Context(), args[0]) - }, +by 'apko build --format=erofs').`, } + cmd.AddCommand(erofsLs()) return cmd } func erofsLs() *cobra.Command { - var mode, arch string + var arch string cmd := &cobra.Command{ Use: "ls SOURCE", Short: "List the contents of an EROFS blob or image", Long: `Walk the contents of SOURCE and print a 'tar tvf'-style listing: mode, uid/gid, size (major,minor for devices), mtime, and path. +SOURCE may be: + - a raw EROFS blob file, + - an OCI image layout directory containing EROFS layers, + - any of the above prefixed by erofs:, oci:, or oci-dir:, + - PATH:TAG to pick a manifest from a multi-tag OCI layout. + SOURCE is read directly with go-erofs and nothing is mounted, so this works -without root and on any platform. Uncompressed images only; for a compressed -image use 'apko erofs mount' and list the mountpoint instead.`, +without root and on any platform. Uncompressed images only.`, + Example: ` apko erofs ls ./image.erofs + apko erofs ls ./out + apko erofs ls oci-dir:./out:latest`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { src, err := erofsmount.ParseSource(args[0]) if err != nil { return err } - return erofsmount.Ls(cmd.Context(), src, erofsmount.Options{ - Mode: erofsmount.Mode(mode), - Arch: arch, - }, os.Stdout) + return erofsmount.Ls(cmd.Context(), src, arch, os.Stdout) }, } - cmd.Flags().StringVar(&mode, "mode", string(erofsmount.ModeAuto), "accepted for symmetry with 'mount' and ignored: ls never mounts") cmd.Flags().StringVar(&arch, "arch", "host", "architecture to select from a multi-arch OCI index") return cmd } diff --git a/pkg/erofsmount/driver_linux.go b/pkg/erofsmount/driver_linux.go deleted file mode 100644 index b03f71a19..000000000 --- a/pkg/erofsmount/driver_linux.go +++ /dev/null @@ -1,246 +0,0 @@ -// Copyright 2026 Chainguard, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -//go:build linux - -package erofsmount - -import ( - "bytes" - "context" - "errors" - "fmt" - "os" - "os/exec" - "strings" - - "github.com/chainguard-dev/clog" -) - -// Driver wraps the externally-invoked mount and umount commands used by Mount. -// Two implementations exist on Linux: kernelDriver shells out to mount(8) and -// umount(8); fuseDriver shells out to erofsfuse and fusermount. -type Driver interface { - // Name returns the resolved mode (kernel or fuse), never auto. - Name() Mode - // Preflight verifies that the required binaries exist and that the - // invoking process can plausibly perform mounts in this mode (e.g. - // kernelDriver requires euid 0). It must be called before any - // MountLayer/AssembleOverlay calls. - Preflight() error - // MountLayer mounts blob (a raw EROFS image) read-only at mp. The - // returned umount closure tears down that single mount. - MountLayer(ctx context.Context, blob, mp string) (func() error, error) - // AssembleOverlay layers `lowers` (in overlayfs priority order — top - // first, bottom last) on top of upper/work into merged. When readOnly is - // true, upper and work are ignored and the overlay is built as - // lowerdir-only (which overlayfs supports for read-only stacks). - AssembleOverlay(ctx context.Context, lowers []string, upper, work, merged string, readOnly bool) (func() error, error) -} - -// NewDriver returns the driver that corresponds to mode. mode must be one of -// ModeKernel or ModeFuse — ModeAuto must be resolved by the caller via -// ResolveMode before calling NewDriver. -func NewDriver(mode Mode) (Driver, error) { - switch mode { - case ModeKernel: - return &kernelDriver{}, nil - case ModeFuse: - return &fuseDriver{}, nil - } - return nil, fmt.Errorf("unknown mount mode %q", mode) -} - -// ResolveMode collapses ModeAuto into ModeKernel (euid 0) or ModeFuse. -func ResolveMode(req Mode) Mode { - if req != ModeAuto { - return req - } - if os.Geteuid() == 0 { - return ModeKernel - } - return ModeFuse -} - -// kernelDriver - -type kernelDriver struct{} - -func (kernelDriver) Name() Mode { return ModeKernel } - -func (kernelDriver) Preflight() error { - if os.Geteuid() != 0 { - return fmt.Errorf("kernel mount mode requires root (euid 0); pass --mode=fuse to use erofsfuse instead") - } - for _, bin := range []string{"mount", "umount"} { - if _, err := exec.LookPath(bin); err != nil { - return fmt.Errorf("%s not found in PATH: %w", bin, err) - } - } - return nil -} - -func (d *kernelDriver) MountLayer(ctx context.Context, blob, mp string) (func() error, error) { - args := buildKernelLayerArgs(blob, mp) - if err := runCmd(ctx, args[0], args[1:]...); err != nil { - return nil, err - } - return func() error { - uargs := buildKernelUmountArgs(mp) - return runCmd(context.Background(), uargs[0], uargs[1:]...) - }, nil -} - -func (d *kernelDriver) AssembleOverlay(ctx context.Context, lowers []string, upper, work, merged string, readOnly bool) (func() error, error) { - args := buildKernelOverlayArgs(lowers, upper, work, merged, readOnly) - if err := runCmd(ctx, args[0], args[1:]...); err != nil { - return nil, err - } - return func() error { - uargs := buildKernelUmountArgs(merged) - return runCmd(context.Background(), uargs[0], uargs[1:]...) - }, nil -} - -// fuseDriver - -type fuseDriver struct{} - -func (fuseDriver) Name() Mode { return ModeFuse } - -func (fuseDriver) Preflight() error { - if _, err := exec.LookPath("erofsfuse"); err != nil { - return fmt.Errorf("erofsfuse not found in PATH (install erofs-utils-fuse): %w", err) - } - if _, err := lookupFusermount(); err != nil { - return err - } - return nil -} - -func (d *fuseDriver) MountLayer(ctx context.Context, blob, mp string) (func() error, error) { - args := buildFuseLayerArgs(blob, mp) - if err := runCmd(ctx, args[0], args[1:]...); err != nil { - return nil, err - } - return func() error { - fm, err := lookupFusermount() - if err != nil { - return err - } - uargs := buildFusermountUmountArgs(fm, mp) - return runCmd(context.Background(), uargs[0], uargs[1:]...) - }, nil -} - -func (d *fuseDriver) AssembleOverlay(ctx context.Context, lowers []string, upper, work, merged string, readOnly bool) (func() error, error) { - // First try the kernel overlay driver on top of the FUSE lowerdirs. Modern - // kernels (~5.11+) allow this in user namespaces. If that fails, fall back - // to fuse-overlayfs. - kArgs := buildKernelOverlayArgs(lowers, upper, work, merged, readOnly) - if err := runCmd(ctx, kArgs[0], kArgs[1:]...); err == nil { - return func() error { - uargs := buildKernelUmountArgs(merged) - return runCmd(context.Background(), uargs[0], uargs[1:]...) - }, nil - } - - if _, err := exec.LookPath("fuse-overlayfs"); err != nil { - return nil, fmt.Errorf("kernel overlay failed and fuse-overlayfs is not installed: %w", err) - } - fArgs := buildFuseOverlayArgs(lowers, upper, work, merged, readOnly) - if err := runCmd(ctx, fArgs[0], fArgs[1:]...); err != nil { - return nil, err - } - return func() error { - fm, err := lookupFusermount() - if err != nil { - return err - } - uargs := buildFusermountUmountArgs(fm, merged) - return runCmd(context.Background(), uargs[0], uargs[1:]...) - }, nil -} - -// Command builders. Pure functions so they can be tested without exec. - -func buildKernelLayerArgs(blob, mp string) []string { - // "-o loop" is unnecessary on modern util-linux: when the source is a - // regular file, mount(8) auto-detects and allocates a loop device with - // O_AUTOCLEAR so it's freed on umount. Asking for "-o loop" explicitly - // risks leaking the loop device when the kernel/util-linux don't agree - // on autoclear semantics. EROFS itself is read-only, but pass "-o ro" - // anyway to document intent. - return []string{"mount", "-t", "erofs", "-o", "ro", blob, mp} -} - -func buildKernelUmountArgs(mp string) []string { - return []string{"umount", mp} -} - -func buildFuseLayerArgs(blob, mp string) []string { - return []string{"erofsfuse", blob, mp} -} - -func buildFusermountUmountArgs(fusermountBin, mp string) []string { - return []string{fusermountBin, "-u", mp} -} - -func buildKernelOverlayArgs(lowers []string, upper, work, merged string, readOnly bool) []string { - opts := "lowerdir=" + strings.Join(lowers, ":") - if !readOnly { - opts += ",upperdir=" + upper + ",workdir=" + work - } else { - opts += ",ro" - } - return []string{"mount", "-t", "overlay", "-o", opts, "overlay", merged} -} - -func buildFuseOverlayArgs(lowers []string, upper, work, merged string, readOnly bool) []string { - opts := "lowerdir=" + strings.Join(lowers, ":") - if !readOnly { - opts += ",upperdir=" + upper + ",workdir=" + work - } - return []string{"fuse-overlayfs", "-o", opts, merged} -} - -// lookupFusermount returns the path to whichever of `fusermount3` or -// `fusermount` is available, preferring fusermount3 since it matches modern -// libfuse builds. -func lookupFusermount() (string, error) { - for _, name := range []string{"fusermount3", "fusermount"} { - if path, err := exec.LookPath(name); err == nil { - return path, nil - } - } - return "", errors.New("neither fusermount3 nor fusermount found in PATH") -} - -// runCmd runs name+args, captures stderr, and wraps any error with the -// captured stderr so users see what mount(8) actually said. -func runCmd(ctx context.Context, name string, args ...string) error { - log := clog.FromContext(ctx) - cmd := exec.CommandContext(ctx, name, args...) - var stderr bytes.Buffer - cmd.Stderr = &stderr - log.Debugf("exec: %s %s", name, strings.Join(args, " ")) - if err := cmd.Run(); err != nil { - stderrTrim := strings.TrimSpace(stderr.String()) - if stderrTrim != "" { - return fmt.Errorf("%s %s: %w: %s", name, strings.Join(args, " "), err, stderrTrim) - } - return fmt.Errorf("%s %s: %w", name, strings.Join(args, " "), err) - } - return nil -} diff --git a/pkg/erofsmount/driver_linux_test.go b/pkg/erofsmount/driver_linux_test.go deleted file mode 100644 index feaecbdb0..000000000 --- a/pkg/erofsmount/driver_linux_test.go +++ /dev/null @@ -1,106 +0,0 @@ -// Copyright 2026 Chainguard, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -//go:build linux - -package erofsmount - -import ( - "reflect" - "strings" - "testing" -) - -func TestBuildKernelLayerArgs(t *testing.T) { - got := buildKernelLayerArgs("/blobs/abc", "/mnt/x") - want := []string{"mount", "-t", "erofs", "-o", "ro", "/blobs/abc", "/mnt/x"} - if !reflect.DeepEqual(got, want) { - t.Fatalf("got %v, want %v", got, want) - } -} - -func TestBuildFuseLayerArgs(t *testing.T) { - got := buildFuseLayerArgs("/blobs/abc", "/mnt/x") - want := []string{"erofsfuse", "/blobs/abc", "/mnt/x"} - if !reflect.DeepEqual(got, want) { - t.Fatalf("got %v, want %v", got, want) - } -} - -func TestBuildKernelOverlayArgs_Writable(t *testing.T) { - got := buildKernelOverlayArgs( - []string{"/mnt/x/layers/02", "/mnt/x/layers/01", "/mnt/x/layers/00"}, - "/mnt/x/upper", "/mnt/x/work", "/mnt/x/merged", - false, - ) - want := []string{ - "mount", "-t", "overlay", "-o", - "lowerdir=/mnt/x/layers/02:/mnt/x/layers/01:/mnt/x/layers/00,upperdir=/mnt/x/upper,workdir=/mnt/x/work", - "overlay", "/mnt/x/merged", - } - if !reflect.DeepEqual(got, want) { - t.Fatalf("got %v\nwant %v", got, want) - } -} - -func TestBuildKernelOverlayArgs_ReadOnly(t *testing.T) { - got := buildKernelOverlayArgs( - []string{"/a", "/b"}, - "/ignored-upper", "/ignored-work", "/merged", - true, - ) - // Read-only must omit upperdir/workdir and append ,ro. - opts := got[4] - if strings.Contains(opts, "upperdir") || strings.Contains(opts, "workdir") { - t.Errorf("read-only overlay should not reference upperdir/workdir: %s", opts) - } - if !strings.HasSuffix(opts, ",ro") { - t.Errorf("read-only overlay opts should end with ,ro: %s", opts) - } - if !strings.HasPrefix(opts, "lowerdir=/a:/b") { - t.Errorf("lowerdir order wrong: %s", opts) - } -} - -func TestBuildFuseOverlayArgs(t *testing.T) { - got := buildFuseOverlayArgs( - []string{"/a", "/b"}, - "/u", "/w", "/m", - false, - ) - want := []string{ - "fuse-overlayfs", "-o", - "lowerdir=/a:/b,upperdir=/u,workdir=/w", - "/m", - } - if !reflect.DeepEqual(got, want) { - t.Fatalf("got %v\nwant %v", got, want) - } -} - -func TestBuildKernelUmountArgs(t *testing.T) { - got := buildKernelUmountArgs("/mnt/x") - want := []string{"umount", "/mnt/x"} - if !reflect.DeepEqual(got, want) { - t.Fatalf("got %v, want %v", got, want) - } -} - -func TestBuildFusermountUmountArgs(t *testing.T) { - got := buildFusermountUmountArgs("/usr/bin/fusermount3", "/mnt/x") - want := []string{"/usr/bin/fusermount3", "-u", "/mnt/x"} - if !reflect.DeepEqual(got, want) { - t.Fatalf("got %v, want %v", got, want) - } -} diff --git a/pkg/erofsmount/ls.go b/pkg/erofsmount/ls.go index 528ee1ae0..1ec762f3f 100644 --- a/pkg/erofsmount/ls.go +++ b/pkg/erofsmount/ls.go @@ -34,13 +34,12 @@ import ( // Ls does not mount anything and is cross-platform — it works wherever // go-erofs builds, regardless of kernel features. // -// The opts.Mode, opts.Arch, and opts.ReadOnly fields are inherited from the -// Mount API for shape parity; only Arch is meaningful here (used to pick a -// manifest from a multi-arch OCI index). -func Ls(ctx context.Context, src Source, opts Options, w io.Writer) error { +// arch picks a manifest from a multi-arch OCI index; "" or "host" means the +// running process's architecture. +func Ls(ctx context.Context, src Source, arch string, w io.Writer) error { log := clog.FromContext(ctx) - layers, cleanup, err := OpenLayers(src, opts.Arch) + layers, cleanup, err := OpenLayers(src, arch) if err != nil { return fmt.Errorf("open layers: %w", err) } diff --git a/pkg/erofsmount/mount_linux.go b/pkg/erofsmount/mount_linux.go deleted file mode 100644 index 7d92447fb..000000000 --- a/pkg/erofsmount/mount_linux.go +++ /dev/null @@ -1,305 +0,0 @@ -// Copyright 2026 Chainguard, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -//go:build linux - -package erofsmount - -import ( - "context" - "errors" - "fmt" - "io/fs" - "os" - "path/filepath" - "slices" - "time" - - "github.com/chainguard-dev/clog" -) - -// Mount mounts src at dest. For KindBlob, dest is the single mountpoint. For -// KindOCIDir, dest is a directory that receives the standard layout: -// -// /layers/00..NN per-layer EROFS mounts (00 is base) -// /upper overlayfs upperdir (writable mounts only) -// /work overlayfs workdir (writable mounts only) -// /merged overlayfs merged view -// /.apko-erofs-mount.json state record for Unmount -// -// On success, Mount returns the recorded MountState. On any error after a -// partial mount, all partially-completed mounts are torn down before -// returning. -func Mount(ctx context.Context, src Source, dest string, opts Options) (st *MountState, retErr error) { - log := clog.FromContext(ctx) - - absDest, err := filepath.Abs(dest) - if err != nil { - return nil, fmt.Errorf("resolve dest: %w", err) - } - dest = filepath.Clean(absDest) - - if opts.Mode == "" { - opts.Mode = ModeAuto - } - mode := ResolveMode(opts.Mode) - drv, err := NewDriver(mode) - if err != nil { - return nil, err - } - if err := drv.Preflight(); err != nil { - return nil, err - } - - switch src.Kind { - case KindBlob: - return mountBlob(ctx, drv, src, dest, log) - case KindOCIDir: - return mountImage(ctx, drv, src, dest, opts, log) - } - return nil, fmt.Errorf("unsupported source kind: %v", src.Kind) -} - -func mountBlob(ctx context.Context, drv Driver, src Source, dest string, log *clog.Logger) (*MountState, error) { - if err := ensureDir(dest); err != nil { - return nil, err - } - if _, err := drv.MountLayer(ctx, src.Path, dest); err != nil { - return nil, fmt.Errorf("mount %s at %s: %w", src.Path, dest, err) - } - log.Infof("mounted %s at %s (%s)", src.Path, dest, drv.Name()) - // No state file for raw blobs — see Unmount for the matching teardown - // logic. Return a state value for completeness but do not persist it. - return &MountState{ - SchemaVersion: StateSchemaVersion, - Mode: drv.Name(), - Source: src.Raw, - Dest: dest, - Created: time.Now().UTC(), - Mounts: []string{dest}, - }, nil -} - -func mountImage(ctx context.Context, drv Driver, src Source, dest string, opts Options, log *clog.Logger) (st *MountState, retErr error) { - layers, err := ReadOCILayers(src.Path, src.Tag, opts.Arch) - if err != nil { - return nil, err - } - - // Refuse to clobber an existing mount. - if _, err := os.Stat(StatePath(dest)); err == nil { - return nil, fmt.Errorf("dest %s already has a mount state file (%s); umount first", dest, StatePath(dest)) - } else if !errors.Is(err, fs.ErrNotExist) { - return nil, fmt.Errorf("stat state file: %w", err) - } - - var cleanups []func() error - defer func() { - if retErr == nil { - return - } - for _, c := range slices.Backward(cleanups) { - if err := c(); err != nil { - log.Warnf("cleanup on error: %v", err) - } - } - }() - - // Single-layer read-only short-circuit: overlay buys nothing when there's - // one lower and no upper, so mount the layer straight at DEST/merged. - // Multi-layer and writable mounts still compose through overlayfs. - if opts.ReadOnly && len(layers) == 1 { - merged := filepath.Join(dest, "merged") - if err := ensureDir(merged); err != nil { - return nil, err - } - umount, err := drv.MountLayer(ctx, layers[0].BlobPath, merged) - if err != nil { - return nil, fmt.Errorf("mount layer 0 (%s) at %s: %w", layers[0].Digest, merged, err) - } - cleanups = append(cleanups, umount) - log.Infof("mounted single layer (%s) read-only at %s", layers[0].Digest, merged) - - state := &MountState{ - SchemaVersion: StateSchemaVersion, - Mode: drv.Name(), - Source: src.Raw, - Dest: dest, - Created: time.Now().UTC(), - Mounts: []string{merged}, - } - if err := WriteState(dest, state); err != nil { - return nil, fmt.Errorf("write state: %w", err) - } - return state, nil - } - - for _, sub := range []string{"layers", "upper", "work", "merged"} { - if err := ensureDir(filepath.Join(dest, sub)); err != nil { - return nil, err - } - } - - layerMps := make([]string, 0, len(layers)) - mountsLIFO := make([]string, 0, len(layers)+1) - for i, layer := range layers { - mp := filepath.Join(dest, "layers", fmt.Sprintf("%02d", i)) - if err := ensureDir(mp); err != nil { - return nil, err - } - umount, err := drv.MountLayer(ctx, layer.BlobPath, mp) - if err != nil { - return nil, fmt.Errorf("mount layer %d (%s) at %s: %w", i, layer.Digest, mp, err) - } - cleanups = append(cleanups, umount) - layerMps = append(layerMps, mp) - mountsLIFO = append([]string{mp}, mountsLIFO...) - log.Infof("mounted layer %d (%s) at %s", i, layer.Digest, mp) - } - - // overlayfs lowerdir is highest-priority first; OCI is bottom-up so we - // reverse. - lowers := make([]string, len(layerMps)) - for i := range layerMps { - lowers[i] = layerMps[len(layerMps)-1-i] - } - - upper := filepath.Join(dest, "upper") - work := filepath.Join(dest, "work") - merged := filepath.Join(dest, "merged") - umount, err := drv.AssembleOverlay(ctx, lowers, upper, work, merged, opts.ReadOnly) - if err != nil { - return nil, fmt.Errorf("overlay merge into %s: %w", merged, err) - } - cleanups = append(cleanups, umount) - mountsLIFO = append([]string{merged}, mountsLIFO...) - log.Infof("merged %d layer(s) at %s", len(layers), merged) - - state := &MountState{ - SchemaVersion: StateSchemaVersion, - Mode: drv.Name(), - Source: src.Raw, - Dest: dest, - Created: time.Now().UTC(), - Mounts: mountsLIFO, - } - if err := WriteState(dest, state); err != nil { - return nil, fmt.Errorf("write state: %w", err) - } - return state, nil -} - -// Unmount tears down a mount produced by Mount. For an image mount it reads -// the state file at /.apko-erofs-mount.json and unmounts in LIFO order; -// if the state file is absent it falls back to treating dest as a single -// (blob) mountpoint and runs umount/fusermount. -func Unmount(ctx context.Context, dest string) error { - log := clog.FromContext(ctx) - absDest, err := filepath.Abs(dest) - if err != nil { - return fmt.Errorf("resolve dest: %w", err) - } - dest = filepath.Clean(absDest) - - st, err := LoadState(dest) - if err == nil { - return unmountImage(ctx, dest, st, log) - } - if !errors.Is(err, fs.ErrNotExist) { - return err - } - return unmountBlob(ctx, dest, log) -} - -func unmountImage(ctx context.Context, dest string, st *MountState, log *clog.Logger) error { - drv, err := NewDriver(st.Mode) - if err != nil { - return err - } - // st.Mounts is overlay-first then per-layer mounts in LIFO order. If - // any umount fails, stop: layer mounts that come after a still-pinned - // overlay would only return EBUSY noise, and continuing past an error - // would also leave the state file out of sync with reality. The user - // can rerun `apko erofs umount` after addressing whatever is keeping - // the mount busy. - for _, mp := range st.Mounts { - if err := unmountOne(ctx, drv, mp); err != nil { - return fmt.Errorf("umount %s: %w (remaining mounts left intact; rerun once they are no longer busy)", mp, err) - } - log.Infof("unmounted %s", mp) - } - for _, sub := range []string{"merged", "upper", "work", "layers"} { - if err := os.RemoveAll(filepath.Join(dest, sub)); err != nil { - log.Warnf("remove %s: %v", filepath.Join(dest, sub), err) - } - } - if err := RemoveState(dest); err != nil { - return fmt.Errorf("remove state file: %w", err) - } - return nil -} - -// unmountBlob tears down a single mountpoint produced by mountBlob. Since -// blobs do not have a state file we have to guess which umount tool applies; -// we try kernel umount first (which works for both kernel-erofs and any -// kernel-overlay-over-fuse cases by transitively triggering fuse teardown -// where appropriate) and fall back to fusermount. -func unmountBlob(ctx context.Context, dest string, log *clog.Logger) error { - if err := runCmd(ctx, "umount", dest); err == nil { - log.Infof("unmounted %s", dest) - return nil - } - fm, err := lookupFusermount() - if err != nil { - return fmt.Errorf("umount %s: kernel umount failed and no fusermount available", dest) - } - if err := runCmd(ctx, fm, "-u", dest); err != nil { - return fmt.Errorf("umount %s: %w", dest, err) - } - log.Infof("unmounted %s", dest) - return nil -} - -// unmountOne unmounts mp using the appropriate tool for the recorded driver. -// We don't try to be clever about kernel-vs-fuse here beyond what the state -// file tells us; if the user mounted with kernel they need kernel umount. -func unmountOne(ctx context.Context, drv Driver, mp string) error { - switch drv.Name() { - case ModeKernel: - return runCmd(ctx, "umount", mp) - case ModeFuse: - // For fuse mounts: the merged view may itself be a kernel overlay - // (when overlayfs over FUSE worked) or a fuse-overlayfs mount. - // `umount` handles both kernel-side overlays; `fusermount -u` - // handles fuse-overlayfs and the per-layer erofsfuse mounts. Try - // kernel umount first (cheap, no-op if not applicable), then - // fusermount. - if err := runCmd(ctx, "umount", mp); err == nil { - return nil - } - fm, err := lookupFusermount() - if err != nil { - return err - } - return runCmd(ctx, fm, "-u", mp) - } - return fmt.Errorf("unknown mode %q", drv.Name()) -} - -func ensureDir(path string) error { - if err := os.MkdirAll(path, 0o755); err != nil { - return fmt.Errorf("mkdir %s: %w", path, err) - } - return nil -} diff --git a/pkg/erofsmount/source.go b/pkg/erofsmount/source.go index 154ab087f..e6491ea9b 100644 --- a/pkg/erofsmount/source.go +++ b/pkg/erofsmount/source.go @@ -13,9 +13,9 @@ // limitations under the License. // Package erofsmount provides the building blocks for the `apko erofs` -// subcommands (mount, umount, ls). It exposes a small library: parse a source -// spec, read an OCI layout's EROFS layers, drive kernel or FUSE mounts, and -// persist/restore mount state for tear-down. +// subcommands. It exposes a small library: parse a source spec, read an OCI +// layout's EROFS layers, and present them as a single merged fs.FS. Everything +// here is pure Go and cross-platform; nothing mounts anything. package erofsmount import ( @@ -64,7 +64,7 @@ type Source struct { // // - "erofs:" names one EROFS filesystem image file — the single blob apko // writes with --format=erofs, or a layer blob lifted out of an OCI layout. -// There is no manifest, so there is nothing to compose: it mounts as a +// There is no manifest, so there is nothing to compose: it is read as a // single layer. // - "oci:" / "oci-dir:" name an OCI *layout directory*. OpenLayers walks // index.json and the selected manifest to collect every EROFS layer blob @@ -74,8 +74,8 @@ type Source struct { // OCI layout directory fails at parse time ("not a regular file"), because a // directory is not a blob. In the other direction, "erofs:" on a regular file // that is not an EROFS image parses fine — the prefix asserts intent, not -// content — and fails later when the mount is attempted, since only the kernel -// (or go-erofs) can judge the superblock. +// content — and fails later when the blob is opened, since only go-erofs (or +// the kernel) can judge the superblock. const ( prefixErofs = "erofs:" prefixOCI = "oci:" diff --git a/pkg/erofsmount/state.go b/pkg/erofsmount/state.go deleted file mode 100644 index ce1f73a9d..000000000 --- a/pkg/erofsmount/state.go +++ /dev/null @@ -1,141 +0,0 @@ -// Copyright 2026 Chainguard, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package erofsmount - -import ( - "encoding/json" - "errors" - "fmt" - "io/fs" - "os" - "path/filepath" - "time" -) - -// Mode selects how mounts are performed. ModeAuto is resolved to ModeKernel or -// ModeFuse before being recorded in MountState. -type Mode string - -const ( - ModeAuto Mode = "auto" - ModeKernel Mode = "kernel" - ModeFuse Mode = "fuse" -) - -// Options is the shared option bag used by Mount and Ls. Only Arch is -// meaningful for Ls; Mode and ReadOnly only affect Mount. -type Options struct { - // Mode selects ModeKernel, ModeFuse, or ModeAuto. Zero value is - // treated as ModeAuto. - Mode Mode - // Arch picks a manifest from a multi-arch OCI index. "" or "host" - // means runtime.GOARCH. - Arch string - // ReadOnly, when true, skips upper/work overlay dirs and produces a - // pure read-only overlay during Mount. - ReadOnly bool -} - -// StateSchemaVersion is the current MountState JSON schema version. -const StateSchemaVersion = 1 - -// stateFileName is written inside for image mounts (multi-layer overlay -// or single-layer wrapped in an OCI layout). It is *not* written for raw blob -// mounts: there is no enclosing directory for them. -const stateFileName = ".apko-erofs-mount.json" - -// MountState describes a completed mount produced by Mount. The file -// authoritatively records what was mounted so that Unmount can tear it down -// without re-deriving the layout from the source. -type MountState struct { - SchemaVersion int `json:"schemaVersion"` - Mode Mode `json:"mode"` // resolved mode (kernel|fuse), never "auto" - Source string `json:"source"` // the original `spec` argument - Dest string `json:"dest"` // absolute path of the mount target - Created time.Time `json:"created"` // wall-clock timestamp at mount completion - // Mounts lists every mountpoint produced by Mount in unmount order - // (LIFO): the first element is unmounted first. For an image mount this - // is [/merged, /layers/NN, ..., /layers/00]. - Mounts []string `json:"mounts"` -} - -// StatePath returns the location of the state file inside dest. -func StatePath(dest string) string { - return filepath.Join(dest, stateFileName) -} - -// WriteState writes s atomically to StatePath(dest). The file is written via -// CreateTemp+Rename in the same directory so a partial write can never be -// observed. -func WriteState(dest string, s *MountState) error { - path := StatePath(dest) - tmp, err := os.CreateTemp(filepath.Dir(path), ".apko-erofs-mount-*.json") - if err != nil { - return fmt.Errorf("create state tmpfile: %w", err) - } - tmpName := tmp.Name() - defer func() { - // Best-effort cleanup if Rename never happened. - _ = os.Remove(tmpName) - }() - enc := json.NewEncoder(tmp) - enc.SetIndent("", " ") - if err := enc.Encode(s); err != nil { - _ = tmp.Close() - return fmt.Errorf("encode state: %w", err) - } - if err := tmp.Sync(); err != nil { - _ = tmp.Close() - return fmt.Errorf("sync state: %w", err) - } - if err := tmp.Close(); err != nil { - return fmt.Errorf("close state: %w", err) - } - if err := os.Rename(tmpName, path); err != nil { - return fmt.Errorf("rename state into place: %w", err) - } - return nil -} - -// LoadState reads StatePath(dest). If the file does not exist, the returned -// error wraps fs.ErrNotExist so callers can use errors.Is. -func LoadState(dest string) (*MountState, error) { - path := StatePath(dest) - data, err := os.ReadFile(path) - if err != nil { - if errors.Is(err, fs.ErrNotExist) { - return nil, fmt.Errorf("no mount state at %s: %w", path, err) - } - return nil, fmt.Errorf("read state %s: %w", path, err) - } - var s MountState - if err := json.Unmarshal(data, &s); err != nil { - return nil, fmt.Errorf("parse state %s: %w", path, err) - } - if s.SchemaVersion != StateSchemaVersion { - return nil, fmt.Errorf("state %s: unsupported schemaVersion %d (want %d)", path, s.SchemaVersion, StateSchemaVersion) - } - return &s, nil -} - -// RemoveState deletes StatePath(dest). It is a no-op if the file is already -// absent. -func RemoveState(dest string) error { - err := os.Remove(StatePath(dest)) - if err != nil && !errors.Is(err, fs.ErrNotExist) { - return err - } - return nil -} diff --git a/pkg/erofsmount/state_test.go b/pkg/erofsmount/state_test.go deleted file mode 100644 index 417c0f18b..000000000 --- a/pkg/erofsmount/state_test.go +++ /dev/null @@ -1,100 +0,0 @@ -// Copyright 2026 Chainguard, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package erofsmount - -import ( - "errors" - "io/fs" - "os" - "path/filepath" - "reflect" - "testing" - "time" -) - -func TestStateRoundTrip(t *testing.T) { - dest := t.TempDir() - in := &MountState{ - SchemaVersion: StateSchemaVersion, - Mode: ModeKernel, - Source: "oci-dir:./out:latest", - Dest: dest, - Created: time.Date(2026, 5, 27, 12, 0, 0, 0, time.UTC), - Mounts: []string{ - filepath.Join(dest, "merged"), - filepath.Join(dest, "layers", "02"), - filepath.Join(dest, "layers", "01"), - filepath.Join(dest, "layers", "00"), - }, - } - if err := WriteState(dest, in); err != nil { - t.Fatalf("WriteState: %v", err) - } - if _, err := os.Stat(StatePath(dest)); err != nil { - t.Fatalf("state file missing: %v", err) - } - - out, err := LoadState(dest) - if err != nil { - t.Fatalf("LoadState: %v", err) - } - if !reflect.DeepEqual(in, out) { - t.Fatalf("roundtrip mismatch:\n in=%+v\n out=%+v", in, out) - } - - // No leftover tempfile from the atomic write. - entries, err := os.ReadDir(dest) - if err != nil { - t.Fatal(err) - } - for _, e := range entries { - name := e.Name() - if len(name) > len(".apko-erofs-mount-") && name[:len(".apko-erofs-mount-")] == ".apko-erofs-mount-" { - t.Errorf("stray tempfile left behind: %s", name) - } - } - - if err := RemoveState(dest); err != nil { - t.Fatalf("RemoveState: %v", err) - } - if _, err := os.Stat(StatePath(dest)); !errors.Is(err, fs.ErrNotExist) { - t.Fatalf("state still present after remove: err=%v", err) - } - // Idempotent remove. - if err := RemoveState(dest); err != nil { - t.Fatalf("RemoveState (idempotent): %v", err) - } -} - -func TestLoadStateMissing(t *testing.T) { - dest := t.TempDir() - _, err := LoadState(dest) - if err == nil { - t.Fatal("expected error") - } - if !errors.Is(err, fs.ErrNotExist) { - t.Fatalf("error %v should wrap fs.ErrNotExist", err) - } -} - -func TestLoadStateWrongSchema(t *testing.T) { - dest := t.TempDir() - if err := os.WriteFile(StatePath(dest), []byte(`{"schemaVersion":99}`), 0o600); err != nil { - t.Fatal(err) - } - if _, err := LoadState(dest); err == nil { - t.Fatal("expected schema version error") - } -} diff --git a/pkg/erofsmount/stub_other.go b/pkg/erofsmount/stub_other.go deleted file mode 100644 index 3544ee3ce..000000000 --- a/pkg/erofsmount/stub_other.go +++ /dev/null @@ -1,44 +0,0 @@ -// Copyright 2026 Chainguard, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -//go:build !linux - -package erofsmount - -import ( - "context" - "fmt" - "runtime" -) - -// Driver, NewDriver, ResolveMode are intentionally absent on non-Linux: the -// EROFS kernel module, erofsfuse, overlayfs, and fuse-overlayfs are Linux -// concepts. Mount and Unmount return a clear error so the CLI doesn't have -// to gate at every call site. -// -// Ls and OpenLayers are cross-platform (go-erofs is pure Go). - -func unsupportedOS() error { - return fmt.Errorf("apko erofs mount/umount are only supported on Linux (running on %s)", runtime.GOOS) -} - -// Mount is a no-op stub on non-Linux that returns an error. -func Mount(_ context.Context, _ Source, _ string, _ Options) (*MountState, error) { - return nil, unsupportedOS() -} - -// Unmount is a no-op stub on non-Linux that returns an error. -func Unmount(_ context.Context, _ string) error { - return unsupportedOS() -} From e5778f175b8dece8bdd6a57d8b5ec014f9448070 Mon Sep 17 00:00:00 2001 From: Scott Moser Date: Tue, 18 Aug 2026 08:34:24 -0400 Subject: [PATCH 25/33] erofs: descope multi-layer splitting to a follow-up Matt's review found two bugs in splitErofsLayers, both of which mean the layered output is not the image it claims to be. The package-routing type assertion is on the wrong receiver. It asks info.Sys() for a Package() method, but tarfs -- which is what real builds walk -- returns a fresh *tar.Header from Sys(); Package() is a method on the FileInfo itself, which is what the tar split path asserts on. So the assertion never succeeds in a real build: every file lands in the top layer, each group layer holds only ancestor directories plus a partial installed db (that case keys on the path string, so it still fires), and per-layer scanners then read a db claiming packages whose files are not there. That inverts the whole point of the split. TestSplitErofsLayers cannot catch it: it drives the split through MemFS, which implements Package() nowhere, so the fixture has zero package-owned files by construction and the test passes identically with and without routing. Fixing this properly means a tarfs-backed test, which is more than a follow-on hunk. Second, directories whose subtree contains no non-directory entry are never emitted into any writer. They are recorded during the walk and materialized only by emitAncestors, which runs for non-dir entries, and no post-walk pass flushes the rest. Empty dirs and dir-only chains (/tmp, /run, /home, /var/empty, mount points) are therefore absent from the mounted union. Both siblings get this right: the tar split writes every walked dir into its owning layer, and writeErofs Mkdirs each one unconditionally. There is also a temp-file and fd leak on every error path out of splitErofsLayers, which needs an upstream Abort API to close cleanly. So drop the split here and land it next with the tarfs-backed test that actually exercises routing. Requesting `layering` together with `format: erofs` is now rejected during config validation instead of silently producing a single layer; BuildLayers keeps a guard for library callers that skip validation. The full pre-descope tree is preserved on the feat/apko-erofs-full branch. --- docs/apko_file.md | 2 +- docs/erofs.md | 95 +------- pkg/build/erofs_layers.go | 291 ------------------------- pkg/build/erofs_test.go | 89 -------- pkg/build/layers.go | 4 +- pkg/build/types/image_configuration.go | 7 + 6 files changed, 19 insertions(+), 469 deletions(-) delete mode 100644 pkg/build/erofs_layers.go diff --git a/docs/apko_file.md b/docs/apko_file.md index b32ec2ab0..5a5d4a9b3 100644 --- a/docs/apko_file.md +++ b/docs/apko_file.md @@ -286,6 +286,6 @@ EROFS layers advertise `erofs` in the image config's `os.features` so consumers `format` may also be selected on the command line with `--format=erofs` on `apko build` and `apko publish`. The CLI flag overrides whatever is in the config file. -**Status:** EROFS support is experimental and tracks the spec PR at https://github.com/erofs/erofs-image-spec/pull/1; media types and annotations may change before the spec reaches a stable release. Both single-layer and multi-layer (`layering`) builds are supported. Multi-layer builds emit each non-final layer with `org.erofs.role=overlay-lower` per spec §3.8; the final layer carries no role. Compression and dm-verity are not implemented. +**Status:** EROFS support is experimental and tracks the draft spec at https://github.com/erofs/erofs-image-spec; media types and annotations may change before the spec reaches a stable release. `format: erofs` produces a single layer: combining it with `layering` is rejected, and compression and dm-verity are not implemented. See [erofs.md](erofs.md) for a step-by-step guide to building, inspecting, mounting, and pulling EROFS images. diff --git a/docs/erofs.md b/docs/erofs.md index 8805d5ffe..b075de4cc 100644 --- a/docs/erofs.md +++ b/docs/erofs.md @@ -203,93 +203,13 @@ Once you have the blob on disk you can inspect or mount it exactly as in the pre ## Multi-layer builds -Combine `format: erofs` with apko's [layering](layering.md) configuration to get one EROFS layer per package group plus a top layer for unowned files. +Not yet. `format: erofs` currently emits a single layer; combining it with apko's +[layering](layering.md) configuration is rejected at config-validation time rather +than silently producing a one-layer image. -`erofs-layered.yaml`: - -```yaml -contents: - keyring: - - https://packages.wolfi.dev/os/wolfi-signing.rsa.pub - repositories: - - https://packages.wolfi.dev/os - packages: - - wolfi-base - -cmd: /bin/sh -l -archs: - - host - -layering: - strategy: origin - budget: 4 - -format: erofs -``` - -```sh -mkdir -p out-layered -apko build erofs-layered.yaml apko-erofs-layered:latest out-layered/ --arch=host -``` - -Inspect the manifest: - -```sh -MANIFEST=$(jq -r '.manifests[0].digest | split(":")[1]' out-layered/index.json) -jq '.layers[] | {mediaType, role: .annotations["org.erofs.role"]}' out-layered/blobs/sha256/$MANIFEST -``` - -Expected (last layer carries no role per spec §3.8 rule 1): - -```json -{ "mediaType": "application/vnd.erofs", "role": "overlay-lower" } -{ "mediaType": "application/vnd.erofs", "role": "overlay-lower" } -{ "mediaType": "application/vnd.erofs", "role": "overlay-lower" } -{ "mediaType": "application/vnd.erofs", "role": "overlay-lower" } -{ "mediaType": "application/vnd.erofs", "role": null } -``` - -Each layer is independently mountable as an EROFS filesystem, and each carries its own partial `usr/lib/apk/db/installed` so per-layer scanners (Trivy, Snyk, Grype) can identify the packages it contributes. - -### Assemble the full rootfs with overlayfs - -The OCI spec composes layers with `overlayfs`-style semantics; for EROFS layers the composition is straightforward — mount each layer read-only, then stack them as `lowerdir`s: - -```sh -# Pull each layer blob out of the OCI layout. -BLOBS=$(pwd)/out-layered/blobs/sha256 -MANIFEST=$(jq -r '.manifests[0].digest | split(":")[1]' out-layered/index.json) -mkdir -p mnt/{merged,work,upper} - -# Mount every layer; build the overlay lowerdir as we go. overlayfs lists -# lowerdirs top-down (highest priority first), while OCI orders layers -# bottom-up (index 0 is the base), so prepend each new layer. -LOWERS= -i=0 -for d in $(jq -r '.layers[].digest | split(":")[1]' "$BLOBS/$MANIFEST"); do - mp=mnt/lower$(printf %02d $i) - mkdir -p "$mp" - sudo mount -t erofs -o ro "$BLOBS/$d" "$mp" 2>/dev/null || \ - erofsfuse "$BLOBS/$d" "$mp" - LOWERS="$mp${LOWERS:+:$LOWERS}" - i=$((i+1)) -done - -sudo mount -t overlay overlay \ - -o "lowerdir=$LOWERS,upperdir=mnt/upper,workdir=mnt/work" \ - mnt/merged - -ls mnt/merged/ # full rootfs -``` - -Clean up: - -```sh -sudo umount mnt/merged -for d in mnt/lower*; do sudo umount "$d" 2>/dev/null || fusermount -u "$d"; done -``` - -Production runtimes (containerd's erofs snapshotter, podman/CRI-O with the erofs-aware plugin, etc.) automate this assembly; the steps above are for verifying that an apko-built EROFS image really does compose into a valid rootfs. +Support for splitting a rootfs into one EROFS layer per package group (each tagged +`org.erofs.role=overlay-lower` per spec §3.8, with the final layer carrying no role) +is in progress. ## Using EROFS support as a Go library @@ -322,10 +242,11 @@ For inspection, apko *does* expose a focused leaf library — see `chainguard.de ## Current limitations +- **Single layer only.** `layering` and `format: erofs` cannot be combined yet; see [Multi-layer builds](#multi-layer-builds) above. - **No compression.** apko emits raw `application/vnd.erofs` layers only. The draft spec defines `application/vnd.erofs+zstd` but neither apko's writer nor the underlying go-erofs library writes compressed images yet. - **No dm-verity.** The spec's verified-mount path (§3.5) is not produced. - **No chunk index.** Lazy-loading runtimes (per spec §3.4) won't get an index; reads are sequential. -- **No `overlay-data` or `device` roles.** Only `overlay-lower` (and unannotated final) layers are emitted. +- **No `overlay-data` or `device` roles.** apko emits one unannotated EROFS layer; `org.erofs.role` is never set. - **Spec is draft.** Media-type strings and annotation keys may change before the spec stabilizes. Treat any image built today as experimental. If you need any of the above, please open an issue. diff --git a/pkg/build/erofs_layers.go b/pkg/build/erofs_layers.go deleted file mode 100644 index 08460d381..000000000 --- a/pkg/build/erofs_layers.go +++ /dev/null @@ -1,291 +0,0 @@ -// Copyright 2026 Chainguard, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package build - -import ( - "bytes" - "context" - "fmt" - "io" - "io/fs" - "path" - "strings" - "time" - - erofs "github.com/erofs/go-erofs" - v1 "github.com/google/go-containerregistry/pkg/v1" - - "chainguard.dev/apko/pkg/apk/apk" - apkfs "chainguard.dev/apko/pkg/apk/fs" - "chainguard.dev/apko/pkg/build/types" -) - -// splitErofsLayers is the EROFS analogue of splitLayers. It walks fsys once, -// emitting each entry into the per-package group writer that owns it (or the -// top writer for unowned entries). Each group becomes one EROFS layer tagged -// with role=overlay-lower per the draft erofs/erofs-image-spec §3.8; the top -// (final) layer carries no role per the same rule. -// -// Unlike tar's flat-stream model, EROFS images carry an inode table, so we -// keep per-writer state for which directories have already been emitted and -// only emit each directory once per writer. The fs.WalkDir guarantee that -// ancestors are visited before descendants lets us record directory metadata -// the first time we see it and reuse it when a child needs the ancestor to -// exist in a writer. -func splitErofsLayers(ctx context.Context, fsys apkfs.FullFS, groups []*group, pkgToDiff map[*apk.Package][]byte, tmpdir string, buildTime time.Time) ([]v1.Layer, error) { - buf := make([]byte, 1<<20) - - type erofsGroupWriter struct { - path string - w *erofs.Writer - closer func() error - emitted map[string]bool // absPath -> already emitted into this writer - pkgs map[string]bool // package names owned by this group - } - - newWriter := func() (*erofsGroupWriter, error) { - f, err := newErofsLayerFile(tmpdir, "apko-erofs-*.bin") - if err != nil { - return nil, err - } - var createOpts []erofs.CreateOpt - if !buildTime.IsZero() { - createOpts = append(createOpts, erofs.WithBuildTime(uint64(buildTime.Unix()), uint32(buildTime.Nanosecond()))) - } - gw := &erofsGroupWriter{ - path: f.Name(), - w: erofs.Create(f, createOpts...), - emitted: map[string]bool{}, - pkgs: map[string]bool{}, - } - // Close the file only after closing the erofs writer (which may seek - // back to rewrite the superblock). - gw.closer = func() error { - if err := gw.w.Close(); err != nil { - _ = f.Close() - return fmt.Errorf("finalizing erofs image %s: %w", f.Name(), err) - } - return f.Close() - } - return gw, nil - } - - // One writer per group, plus a top writer for entries not owned by any - // package. - packageToWriter := map[string]*erofsGroupWriter{} - groupToWriter := map[*group]*erofsGroupWriter{} - writers := make([]*erofsGroupWriter, 0, len(groups)+1) - - for _, g := range groups { - gw, err := newWriter() - if err != nil { - return nil, err - } - writers = append(writers, gw) - groupToWriter[g] = gw - for _, pkg := range g.pkgs { - packageToWriter[pkg.Name] = gw - gw.pkgs[pkg.Name] = true - } - } - top, err := newWriter() - if err != nil { - return nil, err - } - writers = append(writers, top) - - // Record dir metadata as we go so we can recreate ancestors in any - // writer that needs them. Keyed by the absolute (writer-side) path. - dirInfo := map[string]fs.FileInfo{} - dirFsysPath := map[string]string{} // absPath -> source path (for xattr lookup) - - // emitAncestors makes sure every ancestor of absPath (excluding "/" and - // absPath itself) has been created in gw with the correct metadata. - emitAncestors := func(gw *erofsGroupWriter, absPath string) error { - if absPath == "/" { - return nil - } - // Build the list of ancestor paths from shallowest to deepest. - var parts []string - p := path.Dir(absPath) - for p != "/" && p != "." { - parts = append([]string{p}, parts...) - p = path.Dir(p) - } - for _, anc := range parts { - if gw.emitted[anc] { - continue - } - info, ok := dirInfo[anc] - if !ok { - // Defensive: should never happen with fs.WalkDir ordering. - if err := gw.w.Mkdir(anc, 0o755); err != nil { - return fmt.Errorf("mkdir ancestor %s: %w", anc, err) - } - gw.emitted[anc] = true - continue - } - if err := emitErofsEntry(gw.w, anc, dirFsysPath[anc], info, fsys, buf); err != nil { - return fmt.Errorf("emit ancestor %s: %w", anc, err) - } - gw.emitted[anc] = true - } - return nil - } - - if err := fs.WalkDir(fsys, ".", func(fpath string, d fs.DirEntry, err error) error { - if cerr := ctx.Err(); cerr != nil { - return cerr - } - if err != nil { - return err - } - - absPath := erofsAbsPath(fpath) - info, err := d.Info() - if err != nil { - return fmt.Errorf("stat %s: %w", fpath, err) - } - - if d.IsDir() { - // Record metadata; don't emit yet. Each writer creates this - // directory lazily the first time it needs to write something - // beneath it. - if absPath == "/" { - // The root of every EROFS image exists implicitly; still set - // its metadata across all writers (so uid/gid/xattrs match - // the source rootfs). - for _, gw := range writers { - if err := emitErofsEntry(gw.w, absPath, fpath, info, fsys, buf); err != nil { - return err - } - gw.emitted[absPath] = true - } - return nil - } - dirInfo[absPath] = info - dirFsysPath[absPath] = fpath - return nil - } - - // Default to the top layer. - owner := top - - // If the file info exposes its owning package, route to that group. - if pkger, ok := info.Sys().(interface { - Package() *apk.Package - }); ok { - if pkg := pkger.Package(); pkg != nil { - if gw, ok := packageToWriter[pkg.Name]; ok { - owner = gw - } - } - } - - // Special-case the apk installed db: each group also gets a partial - // installed db containing only its own packages, so per-layer - // scanners (Trivy, Snyk, etc.) can identify the layer's contents. - // This matches splitLayers' behavior for tar layers. - if strings.TrimPrefix(absPath, "/") == "usr/lib/apk/db/installed" { - for _, g := range groups { - gw := groupToWriter[g] - if err := emitAncestors(gw, absPath); err != nil { - return err - } - var idb bytes.Buffer - for _, pkg := range g.pkgs { - if _, err := idb.Write(pkgToDiff[pkg]); err != nil { - return err - } - } - if err := writeErofsRegularBytes(gw.w, absPath, info, idb.Bytes()); err != nil { - return err - } - gw.emitted[absPath] = true - } - // The top layer also gets the full installed db via the normal - // path below. - } - - if err := emitAncestors(owner, absPath); err != nil { - return err - } - if err := emitErofsEntry(owner.w, absPath, fpath, info, fsys, buf); err != nil { - return err - } - owner.emitted[absPath] = true - return nil - }); err != nil { - return nil, err - } - - // Finalize each writer and produce v1.Layer values. - layers := make([]v1.Layer, 0, len(writers)) - for i, gw := range writers { - if err := gw.closer(); err != nil { - return nil, err - } - // All layers except the final (top) carry role=overlay-lower per - // spec §3.8 rule 1. The final layer carries no role. - var anns map[string]string - if i < len(writers)-1 { - anns = map[string]string{types.ErofsRoleAnnotation: types.ErofsRoleOverlayLower} - } - l, err := buildErofsLayerFromFile(gw.path, anns) - if err != nil { - return nil, fmt.Errorf("finalizing erofs layer %d: %w", i, err) - } - layers = append(layers, l) - } - return layers, nil -} - -// writeErofsRegularBytes writes a regular file with the given content into w -// at absPath, copying mode/uid/gid/mtime from info. xattrs are *not* copied -// because the per-group installed db is a synthesized payload, not a -// faithful copy of the source file. -func writeErofsRegularBytes(w *erofs.Writer, absPath string, info fs.FileInfo, data []byte) error { - fout, err := w.Create(absPath) - if err != nil { - return fmt.Errorf("create %s: %w", absPath, err) - } - if len(data) > 0 { - n, err := fout.Write(data) - if err != nil { - _ = fout.Close() - return fmt.Errorf("write %s: %w", absPath, err) - } - if n != len(data) { - _ = fout.Close() - return fmt.Errorf("write %s: %w (wrote %d of %d bytes)", absPath, io.ErrShortWrite, n, len(data)) - } - } - if err := fout.Close(); err != nil { - return fmt.Errorf("close %s: %w", absPath, err) - } - if err := w.Chmod(absPath, info.Mode().Perm()); err != nil { - return fmt.Errorf("chmod %s: %w", absPath, err) - } - uid, gid := uidGidFromInfo(info) - if err := w.Chown(absPath, uid, gid); err != nil { - return fmt.Errorf("chown %s: %w", absPath, err) - } - if mt := info.ModTime(); !mt.IsZero() { - if err := w.Chtimes(absPath, time.Time{}, mt); err != nil { - return fmt.Errorf("chtimes %s: %w", absPath, err) - } - } - return nil -} diff --git a/pkg/build/erofs_test.go b/pkg/build/erofs_test.go index a78c43c18..f3e7dbeed 100644 --- a/pkg/build/erofs_test.go +++ b/pkg/build/erofs_test.go @@ -33,7 +33,6 @@ import ( "github.com/stretchr/testify/require" "golang.org/x/sys/unix" - "chainguard.dev/apko/pkg/apk/apk" apkfs "chainguard.dev/apko/pkg/apk/fs" "chainguard.dev/apko/pkg/build/types" "chainguard.dev/apko/pkg/options" @@ -364,94 +363,6 @@ func TestWriteErofs_FsckErofs(t *testing.T) { require.Equal(t, "b", target) } -func TestSplitErofsLayers(t *testing.T) { - fsys := apkfs.NewMemFS() - require.NoError(t, fsys.MkdirAll("usr/lib/apk/db", 0o755)) - require.NoError(t, fsys.WriteFile("usr/lib/apk/db/installed", []byte("idb top\n"), 0o644)) - require.NoError(t, fsys.MkdirAll("etc", 0o755)) - require.NoError(t, fsys.WriteFile("etc/hello", []byte("hi\n"), 0o644)) - - pkg1 := newPkg("pkg1") - pkg2 := newPkg("pkg2") - groups := []*group{ - {pkgs: []*apk.Package{pkg1}, size: 1000, tiebreaker: "pkg1"}, - {pkgs: []*apk.Package{pkg2}, size: 2000, tiebreaker: "pkg2"}, - } - pkgToDiff := map[*apk.Package][]byte{ - pkg1: []byte("pkg1 info\n"), - pkg2: []byte("pkg2 info\n"), - } - - layers, err := splitErofsLayers(context.Background(), fsys, groups, pkgToDiff, t.TempDir(), epoch) - require.NoError(t, err) - require.Len(t, layers, 3, "expected 2 group layers + 1 top layer") - - // All three layers should be valid EROFS images. - fsckBin := optionalFsckErofs(t) - for i, l := range layers { - erl, ok := l.(*erofsLayer) - require.True(t, ok, "layer[%d] not *erofsLayer", i) - - mt, err := l.MediaType() - require.NoError(t, err) - require.Equal(t, "application/vnd.erofs", string(mt)) - - // Layer roles: overlay-lower on the package layers, absent on the top. - anns := erl.LayerAnnotations() - if i < len(layers)-1 { - require.Equal(t, "overlay-lower", anns[types.ErofsRoleAnnotation], "layer[%d] missing overlay-lower role", i) - } else { - require.Empty(t, anns, "top layer must carry no role annotation") - } - - // The image must parse via go-erofs. - f, err := os.Open(erl.path) - require.NoError(t, err) - _, err = erofs.Open(f) - _ = f.Close() - require.NoError(t, err, "layer[%d] is not a valid EROFS image", i) - - if fsckBin != "" { - cmd := exec.Command(fsckBin, "-d3", erl.path) - out, err := cmd.CombinedOutput() - require.NoError(t, err, "fsck.erofs rejected layer[%d]:\n%s", i, out) - } - } - - // The package layers must each carry their own partial installed db; the - // top layer carries the source file via the normal path. - for i, l := range layers[:2] { - erl := l.(*erofsLayer) - f, err := os.Open(erl.path) - require.NoError(t, err) - img, err := erofs.Open(f) - require.NoError(t, err) - data, err := fs.ReadFile(img, "usr/lib/apk/db/installed") - require.NoError(t, err, "layer[%d] missing per-group installed db", i) - require.NotEmpty(t, data, "layer[%d] installed db must not be empty", i) - _ = f.Close() - } - - // The top layer should hold etc/hello (unowned content) and the original - // installed db. - topL := layers[len(layers)-1].(*erofsLayer) - tf, err := os.Open(topL.path) - require.NoError(t, err) - defer tf.Close() - topImg, err := erofs.Open(tf) - require.NoError(t, err) - hello, err := fs.ReadFile(topImg, "etc/hello") - require.NoError(t, err) - require.Equal(t, "hi\n", string(hello)) - topIdb, err := fs.ReadFile(topImg, "usr/lib/apk/db/installed") - require.NoError(t, err) - require.Equal(t, "idb top\n", string(topIdb)) -} - -func newPkg(name string) *apk.Package { - return &apk.Package{Name: name, Origin: name, Version: "1.0.0", InstalledSize: 1024} -} - // optionalFsckErofs returns the path to fsck.erofs, or "" when erofs-utils is // not installed. It is deliberately optional: every caller has already parsed // the image with go-erofs, so fsck is a second opinion from the C reference diff --git a/pkg/build/layers.go b/pkg/build/layers.go index 7c50ee86a..cf01b1401 100644 --- a/pkg/build/layers.go +++ b/pkg/build/layers.go @@ -86,8 +86,10 @@ func (bc *Context) buildLayers(ctx context.Context) ([]v1.Layer, error) { } // Then partition that single fs.FS into multiple layers based on our layering strategy. + // ImageConfiguration.Validate rejects this combination up front; this guards + // library callers that build a configuration without validating it. if bc.ic.Format.Resolved() == types.LayerFormatErofs { - return splitErofsLayers(ctx, bc.fs, groups, pkgToDiff, bc.o.TempDir(), bc.o.SourceDateEpoch) + return nil, fmt.Errorf("layering is not supported with format %q yet", types.LayerFormatErofs) } return splitLayers(ctx, bc.fs, groups, pkgToDiff, bc.o.TempDir()) } diff --git a/pkg/build/types/image_configuration.go b/pkg/build/types/image_configuration.go index e03e9288e..7149d3fbe 100644 --- a/pkg/build/types/image_configuration.go +++ b/pkg/build/types/image_configuration.go @@ -244,6 +244,13 @@ func (ic *ImageConfiguration) Validate() error { return fmt.Errorf("invalid layer format %q (must be %q or %q)", ic.Format, LayerFormatTar, LayerFormatErofs) } + // Splitting a rootfs across several EROFS layers is not implemented yet. + // Fail here rather than silently emitting a single layer, which would not + // be the image the config asked for. + if ic.Format.Resolved() == LayerFormatErofs && ic.Layering != nil { + return fmt.Errorf("layering is not supported with format %q yet (use one or the other)", LayerFormatErofs) + } + if ic.Certificates != nil { for _, additional := range ic.Certificates.Additional { if additional.Name == "" { From 460ec8d7a905742381f23eff26e0d3e989381f7c Mon Sep 17 00:00:00 2001 From: Scott Moser Date: Tue, 18 Aug 2026 08:41:37 -0400 Subject: [PATCH 26/33] erofsmount: use the spec's whiteout encoding, not tar's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stack detected deletions by filename -- `.wh.NAME` and `.wh..wh..opq` -- which is the OCI tar layer convention, and the one thing spec §3.6 says an EROFS layer must not use. §8.1 item 9 forbids `.wh.`-prefixed names in EROFS images outright, and §3.6 mandates the kernel's own overlayfs encoding instead: a whiteout is a character device with rdev 0, and an opaque directory carries `trusted.overlay.opaque="y"`. The comment justifying the old encoding claimed it matched go-erofs Writer.Merge. It does not: Merge *consumes* `.wh.` entries from its copy source and, in its own words, "the whiteout entries themselves are not added to the image" (mkfs.go:118). So no producer emits `.wh.` names inside an EROFS image -- not apko's writer, not go-erofs -- and the reader was honoring an encoding that cannot conformantly exist. Nothing apko produces today contains a whiteout at all, so this is latent for our own images; it was wrong for anyone else's. `ls` would have shown a conformant image's whiteout devices as live entries while hiding nothing, and hidden entries the kernel would have shown. Dropping the tar convention also makes `ls` more faithful on malformed input: a file literally named `.wh.foo` is now reported as the filename it is, which is what the kernel does. §3.6 requires consumers not to fail on meaningless whiteouts, so ignoring them is sanctioned. Detection goes through the accessor interfaces go-erofs documents on its FileInfo (`Rdev() uint64`, `GetXattr(string) (string, bool)`) rather than asserting `*erofs.Stat`, so the logic stays fs.FS-generic and testable. The cheap DirEntry.Type() check gates the Info() call, so only a char device costs an inode read. Whiteouts are interpreted only when the stack has two or more layers. §7 step 6 lets a lone EROFS layer be mounted directly as a root filesystem, and a direct mount applies no overlay semantics, so a rdev-0 char device in a single-layer image is reported as the device it is. mergeDir collapses as a result: a whiteout occupies the name it deletes, so one pass over the entries suffices where the tar encoding needed separate live and tombstone maps. A layer can no longer hold both a live entry and its own tombstone, so that ambiguity disappears too. Tests: the fixture needed rdev and xattrs, which fstest.MapFS cannot express, so overlayFS wraps it and exposes the same accessors go-erofs does. TestStack_Whiteouts_RealErofsLayers then runs the same rules against images go-erofs actually wrote -- covering the case where the fixture and the real reader drift apart -- and confirms both that a rdev-0 Mknod reads back as a whiteout and that Setxattr of trusted.overlay.opaque round-trips. --- docs/erofs.md | 2 +- pkg/erofsmount/stack.go | 167 +++++++++++-------- pkg/erofsmount/stack_test.go | 300 +++++++++++++++++++++++++++++++---- 3 files changed, 373 insertions(+), 96 deletions(-) diff --git a/docs/erofs.md b/docs/erofs.md index b075de4cc..839908ac4 100644 --- a/docs/erofs.md +++ b/docs/erofs.md @@ -162,7 +162,7 @@ apko erofs ls out/blobs/sha256/$LAYER | head apko erofs ls out/ # works against the whole OCI image too ``` -For multi-layer images, `ls` applies AUFS-style overlay semantics in user space (whiteouts, opaque markers) to present the merged view the kernel would assemble. +For multi-layer images, `ls` applies overlay semantics in user space to present the merged view the kernel would assemble. It uses the overlayfs-native deletion encoding the spec mandates (§3.6) — a whiteout is a character device with rdev 0, an opaque directory sets `trusted.overlay.opaque="y"` — not the `.wh.` filename convention of tar layers, which §8.1 forbids in EROFS images. A single-layer image is listed as-is: the kernel would mount it directly, applying no overlay semantics, so neither does `ls`. ## Mount the layer diff --git a/pkg/erofsmount/stack.go b/pkg/erofsmount/stack.go index 5db17e814..a2be2e192 100644 --- a/pkg/erofsmount/stack.go +++ b/pkg/erofsmount/stack.go @@ -20,23 +20,39 @@ import ( "path" "slices" "sort" - "strings" "time" ) -// Stack presents N fs.FS layers as a single fs.FS using AUFS-style overlay -// semantics. Layers are stored bottom-up: layers[0] is the base, the last -// element is the topmost. Topmost-wins is the rule for lookups. +// Stack presents N fs.FS layers as a single fs.FS, applying the overlay +// semantics the kernel would apply if the same layers were assembled as +// overlayfs lowerdirs. Layers are stored bottom-up: layers[0] is the base, the +// last element is the topmost. Topmost-wins is the rule for lookups. // -// Whiteout encoding (matches OCI tar layers and go-erofs Writer.Merge): +// Deletions use the overlayfs-native encoding the EROFS image spec mandates +// (§3.6), not the `.wh.` filename convention of OCI tar layers: // -// - `.wh.NAME` as a sibling of NAME hides NAME from lower layers. -// - `.wh..wh..opq` in a directory hides all entries from lower layers in -// that directory; entries that the same layer also has live remain. +// - A whiteout is a character device with rdev 0 (major 0, minor 0). It +// hides the same-named entry in every lower layer, and is itself absent +// from the merged view. +// - An opaque directory is one whose `trusted.overlay.opaque` xattr is "y". +// It hides all lower-layer children of that directory; entries the opaque +// layer itself carries remain. +// +// §8.1 item 9 forbids `.wh.`-prefixed names in EROFS images outright, so such +// a name is treated as the literal filename it is — the same thing the kernel +// would do. +// +// Whiteouts are only interpreted when there are at least two layers. A lone +// EROFS layer is mountable directly as a root filesystem (§7 step 6), and in +// that case the kernel never applies overlay semantics, so neither do we: a +// rdev-0 char device in a single-layer image is reported as the device it is. // // Stack implements fs.FS, fs.ReadDirFS, fs.StatFS, and fs.ReadLinkFS. type Stack struct { layers []fs.FS + // whiteouts records whether overlay deletion semantics apply, i.e. + // whether an overlay stack is assembled at all (§7 Constraints). + whiteouts bool } // NewStack returns a Stack over layers, in bottom-up order (layers[0] is the @@ -45,7 +61,7 @@ type Stack struct { func NewStack(layers ...fs.FS) *Stack { cp := make([]fs.FS, len(layers)) copy(cp, layers) - return &Stack{layers: cp} + return &Stack{layers: cp, whiteouts: len(cp) > 1} } // Open implements fs.FS. For regular files, symlinks, and devices the @@ -153,17 +169,14 @@ func (s *Stack) ReadLink(name string) (string, error) { } // lookup walks layers top-down looking for name. It returns the index of the -// topmost layer that has name live (not whitedout). It checks the parent -// directory of name in each layer for sibling whiteouts (.wh.NAME) and -// opaque markers (.wh..wh..opq) before descending to lower layers. +// topmost layer that has name live (not whited out). In each layer it asks +// two questions before descending: is name itself a whiteout here, and is +// name's parent directory opaque here? // -// Ancestors are resolved recursively: if any ancestor of name is whitedout, +// Ancestors are resolved recursively: if any ancestor of name is whited out, // opaqued out, or shadowed by a non-directory in a higher layer, name is // not reachable. The root (".") is always live; lookup(".") returns // (-1, nil) to signal "root, no owning layer". -// -// If a layer contains both name and its whiteout (a malformed but possible -// state), the live entry wins. func (s *Stack) lookup(name string) (int, error) { if name == "." { return -1, nil @@ -192,25 +205,24 @@ func (s *Stack) lookup(name string) (int, error) { for i, layer := range slices.Backward(s.layers) { entries, err := readDirOn(layer, parent) if err != nil { - // Parent doesn't exist in this layer; can't have a whiteout or + // Parent doesn't exist in this layer; can't hold a whiteout or // the entry. Move down. continue } - var foundBase, foundWhiteout, foundOpaque bool for _, e := range entries { - switch e.Name() { - case base: - foundBase = true - case whiteoutPrefix + base: - foundWhiteout = true - case opaqueMarker: - foundOpaque = true + if e.Name() != base { + continue + } + if s.isWhiteout(e) { + // A whiteout hides every lower layer's entry, and is not + // itself part of the merged view. + return -1, fs.ErrNotExist } - } - switch { - case foundBase: return i, nil - case foundWhiteout, foundOpaque: + } + // Not in this layer. If this layer's copy of the parent directory is + // opaque, it hides everything below. + if s.isOpaqueDir(layer, parent) { return -1, fs.ErrNotExist } } @@ -218,55 +230,82 @@ func (s *Stack) lookup(name string) (int, error) { } // mergeDir produces the union of name's entries across layers, top-down, -// applying whiteouts and stopping at the first opaque marker. Within a -// single layer, if a name is both live and whitedout, the live entry wins -// and the in-layer whiteout is treated as a no-op (lower layers still see -// the layer's live entry shadowing them). +// applying whiteouts and stopping below the first opaque directory. +// +// A whiteout occupies the name it deletes, so one pass suffices: recording the +// name as seen both suppresses the whiteout itself and shadows every lower +// layer's entry of that name. func (s *Stack) mergeDir(name string) ([]fs.DirEntry, error) { - seen := map[string]bool{} // covers live entries returned so far + tombstones + seen := map[string]bool{} // live entries returned so far + tombstones var out []fs.DirEntry for _, layer := range slices.Backward(s.layers) { entries, err := readDirOn(layer, name) if err != nil { continue } - var opaqueInThisLayer bool - liveInThisLayer := map[string]fs.DirEntry{} - whiteoutInThisLayer := map[string]bool{} for _, e := range entries { n := e.Name() - switch { - case n == opaqueMarker: - opaqueInThisLayer = true - case strings.HasPrefix(n, whiteoutPrefix): - whiteoutInThisLayer[strings.TrimPrefix(n, whiteoutPrefix)] = true - default: - liveInThisLayer[n] = e + if seen[n] { + continue } - } - // Add this layer's live entries that haven't already been provided - // by an upper layer. - for n, e := range liveInThisLayer { - if !seen[n] { - seen[n] = true - out = append(out, e) + seen[n] = true + if s.isWhiteout(e) { + continue // tombstone: shadows lower layers, never listed } + out = append(out, e) } - // Apply tombstones from this layer for lower layers. If a name is - // also live here, the live entry shadows lower layers already. - for n := range whiteoutInThisLayer { - if _, live := liveInThisLayer[n]; !live { - seen[n] = true - } - } - if opaqueInThisLayer { - break // lower layers' entries are hidden + // This layer's own entries are already in; an opaque directory hides + // only what lies below it. + if s.isOpaqueDir(layer, name) { + break } } sort.Slice(out, func(i, j int) bool { return out[i].Name() < out[j].Name() }) return out, nil } +// isWhiteout reports whether e is an overlayfs whiteout: a character device +// with rdev 0 (major 0, minor 0), per spec §3.6. It returns false for every +// entry when the stack has no overlay to apply. +// +// The cheap Type() check comes first so the common case costs nothing: only a +// char device is worth an Info() call, which for a real EROFS layer reads the +// inode. +func (s *Stack) isWhiteout(e fs.DirEntry) bool { + if !s.whiteouts || e.Type()&fs.ModeCharDevice == 0 { + return false + } + info, err := e.Info() + if err != nil { + return false + } + // go-erofs advertises Rdev() on the FileInfo it returns; an fs.FS with no + // notion of device numbers cannot express a whiteout at all. + rd, ok := info.(interface{ Rdev() uint64 }) + return ok && rd.Rdev() == 0 +} + +// isOpaqueDir reports whether fsys's copy of dir is an opaque directory -- +// `trusted.overlay.opaque` set to "y", per spec §3.6 -- which hides every +// lower layer's children of that directory. +func (s *Stack) isOpaqueDir(fsys fs.FS, dir string) bool { + if !s.whiteouts { + return false + } + info, err := statOn(fsys, dir) + if err != nil || !info.IsDir() { + return false + } + gx, ok := info.(interface { + GetXattr(string) (string, bool) + }) + if !ok { + return false + } + v, _ := gx.GetXattr(overlayOpaqueXattr) + return v == "y" +} + // rootInfo returns FileInfo for ".". The topmost layer that has a root wins // for metadata. func (s *Stack) rootInfo() (fs.FileInfo, error) { @@ -369,10 +408,10 @@ func splitParent(name string) (parent, base string) { return parent, base } -const ( - whiteoutPrefix = ".wh." - opaqueMarker = ".wh..wh..opq" -) +// overlayOpaqueXattr is the extended attribute the kernel's overlayfs uses to +// mark a directory as hiding all lower-layer children. Spec §3.6 adopts it +// verbatim. +const overlayOpaqueXattr = "trusted.overlay.opaque" // syntheticDirInfo produces a minimal fs.FileInfo for a synthetic directory // (used only when Stack has zero layers, so callers don't crash). diff --git a/pkg/erofsmount/stack_test.go b/pkg/erofsmount/stack_test.go index a32347390..e277b7333 100644 --- a/pkg/erofsmount/stack_test.go +++ b/pkg/erofsmount/stack_test.go @@ -17,11 +17,14 @@ package erofsmount import ( "errors" "io/fs" + "path" "reflect" "slices" - "strings" "testing" "testing/fstest" + + erofs "github.com/erofs/go-erofs" + "golang.org/x/sys/unix" ) // nakedFS strips the optional extension interfaces (ReadDirFS, StatFS, @@ -30,14 +33,95 @@ type nakedFS struct{ inner fs.FS } func (n nakedFS) Open(name string) (fs.File, error) { return n.inner.Open(name) } +// overlayFS expresses the two things a spec-conformant EROFS whiteout needs +// and fstest.MapFS cannot represent: a device number and an extended +// attribute. The FileInfo values it hands back implement the same accessor +// interfaces go-erofs advertises on its own FileInfo (Rdev, GetXattr), which +// is what Stack probes -- so a fixture here and a real EROFS layer exercise +// the identical code path. TestStack_Whiteouts_RealErofsLayers checks that +// equivalence against images written by go-erofs. +// +// A whiteout is a MapFS entry with Mode fs.ModeCharDevice and no rdev entry +// (rdev 0). A real device names its rdev explicitly. +type overlayFS struct { + fstest.MapFS + rdev map[string]uint64 // char-device path -> device number; absent means 0 + opaque map[string]bool // directory paths carrying trusted.overlay.opaque=y +} + +func (o overlayFS) wrap(p string, fi fs.FileInfo) fs.FileInfo { + return &overlayInfo{FileInfo: fi, rdev: o.rdev[p], opaque: o.opaque[p]} +} + +func (o overlayFS) Stat(name string) (fs.FileInfo, error) { + fi, err := o.MapFS.Stat(name) + if err != nil { + return nil, err + } + return o.wrap(name, fi), nil +} + +func (o overlayFS) ReadDir(name string) ([]fs.DirEntry, error) { + ents, err := o.MapFS.ReadDir(name) + if err != nil { + return nil, err + } + out := make([]fs.DirEntry, 0, len(ents)) + for _, e := range ents { + out = append(out, overlayEntry{DirEntry: e, fsys: o, path: path.Join(name, e.Name())}) + } + return out, nil +} + +type overlayEntry struct { + fs.DirEntry + fsys overlayFS + path string +} + +func (e overlayEntry) Info() (fs.FileInfo, error) { + fi, err := e.DirEntry.Info() + if err != nil { + return nil, err + } + return e.fsys.wrap(e.path, fi), nil +} + +type overlayInfo struct { + fs.FileInfo + rdev uint64 + opaque bool +} + +func (i *overlayInfo) Rdev() uint64 { return i.rdev } + +func (i *overlayInfo) GetXattr(name string) (string, bool) { + if name == overlayOpaqueXattr && i.opaque { + return "y", true + } + return "", false +} + +// whiteout is the MapFile shape of an overlayfs whiteout: a char device whose +// rdev is 0. Pair it with an overlayFS that names no rdev for the path. +func whiteout() *fstest.MapFile { + return &fstest.MapFile{Mode: fs.ModeCharDevice | fs.ModeDevice | 0o600} +} + // readDirNames returns the sorted entry names of "etc" in fsys. Every // fixture in this file puts its top-level dir at "etc"; the helper exists // just to keep tests focused on what's *in* etc, not on the wiring. func readDirNames(t *testing.T, fsys fs.FS) []string { t.Helper() - ents, err := fs.ReadDir(fsys, "etc") + return readDirNamesIn(t, fsys, "etc") +} + +// readDirNamesIn is readDirNames for a directory other than "etc". +func readDirNamesIn(t *testing.T, fsys fs.FS, dir string) []string { + t.Helper() + ents, err := fs.ReadDir(fsys, dir) if err != nil { - t.Fatalf("ReadDir(etc): %v", err) + t.Fatalf("ReadDir(%s): %v", dir, err) } out := make([]string, 0, len(ents)) for _, e := range ents { @@ -77,9 +161,9 @@ func TestStack_WhiteoutFile_HidesFromLower(t *testing.T) { "etc/secret": {Data: []byte("oops"), Mode: 0o644}, "etc/keep": {Data: []byte("kept"), Mode: 0o644}, } - top := fstest.MapFS{ - "etc/.wh.secret": {Data: nil, Mode: 0o644}, - } + top := overlayFS{MapFS: fstest.MapFS{ + "etc/secret": whiteout(), + }} s := NewStack(base, top) if _, err := fs.Stat(s, "etc/secret"); !errors.Is(err, fs.ErrNotExist) { @@ -94,7 +178,8 @@ func TestStack_WhiteoutFile_HidesFromLower(t *testing.T) { } else if string(data) != "kept" { t.Errorf("ReadFile etc/keep: got %q, want %q", data, "kept") } - // ReadDir of etc must contain "keep" but neither "secret" nor ".wh.secret". + // ReadDir of etc must contain "keep" and not "secret": the whiteout hides + // the lower entry and is not itself part of the merged view. got := readDirNames(t, s) want := []string{"keep"} if !reflect.DeepEqual(got, want) { @@ -107,9 +192,9 @@ func TestStack_WhiteoutDir_HidesEntireSubtree(t *testing.T) { "opt/legacy/bin/old": {Data: []byte("X"), Mode: 0o755}, "opt/keep/here": {Data: []byte("Y"), Mode: 0o644}, } - top := fstest.MapFS{ - "opt/.wh.legacy": {Data: nil, Mode: 0o644}, - } + top := overlayFS{MapFS: fstest.MapFS{ + "opt/legacy": whiteout(), + }} s := NewStack(base, top) if _, err := fs.Stat(s, "opt/legacy"); !errors.Is(err, fs.ErrNotExist) { @@ -125,15 +210,18 @@ func TestStack_WhiteoutDir_HidesEntireSubtree(t *testing.T) { } } -func TestStack_OpaqueMarker(t *testing.T) { +func TestStack_OpaqueDir_HidesLowerChildren(t *testing.T) { base := fstest.MapFS{ "etc/foo": {Data: []byte("foo-base"), Mode: 0o644}, "etc/bar": {Data: []byte("bar-base"), Mode: 0o644}, "etc/sub/x": {Data: []byte("x"), Mode: 0o644}, } - top := fstest.MapFS{ - "etc/.wh..wh..opq": {Data: nil, Mode: 0o644}, - "etc/baz": {Data: []byte("baz-top"), Mode: 0o644}, + top := overlayFS{ + MapFS: fstest.MapFS{ + "etc": {Mode: fs.ModeDir | 0o755}, + "etc/baz": {Data: []byte("baz-top"), Mode: 0o644}, + }, + opaque: map[string]bool{"etc": true}, } s := NewStack(base, top) @@ -154,12 +242,14 @@ func TestStack_OpaqueMarker(t *testing.T) { } func TestStack_WhiteoutEntriesNeverLeak(t *testing.T) { - top := fstest.MapFS{ - "etc/.wh.gone": {Data: nil, Mode: 0o644}, - "etc/.wh..wh..opq": {Data: nil, Mode: 0o644}, - "etc/here": {Data: []byte("X"), Mode: 0o644}, + base := fstest.MapFS{ + "etc/gone": {Data: []byte("G"), Mode: 0o644}, } - s := NewStack(top) + top := overlayFS{MapFS: fstest.MapFS{ + "etc/gone": whiteout(), + "etc/here": {Data: []byte("X"), Mode: 0o644}, + }} + s := NewStack(base, top) got := readDirNames(t, s) want := []string{"here"} @@ -168,6 +258,146 @@ func TestStack_WhiteoutEntriesNeverLeak(t *testing.T) { } } +// A `.wh.`-prefixed name is not a whiteout in an EROFS layer: spec §8.1 item 9 +// forbids such names outright, and the kernel would show one as the literal +// filename it is. Stack must do the same rather than reading tar semantics into +// it. +func TestStack_DotWhNames_AreLiteralFilenames(t *testing.T) { + base := fstest.MapFS{ + "etc/secret": {Data: []byte("S"), Mode: 0o644}, + } + top := overlayFS{MapFS: fstest.MapFS{ + "etc/.wh.secret": {Data: nil, Mode: 0o644}, + "etc/.wh..wh..opq": {Data: nil, Mode: 0o644}, + }} + s := NewStack(base, top) + + got := readDirNames(t, s) + want := []string{".wh..wh..opq", ".wh.secret", "secret"} + if !reflect.DeepEqual(got, want) { + t.Errorf("ReadDir etc: got %v, want %v", got, want) + } + if _, err := fs.Stat(s, "etc/secret"); err != nil { + t.Errorf("Stat etc/secret: %v; a .wh. name must not hide it", err) + } +} + +// A char device with a real device number is a device, not a tombstone. +func TestStack_RealCharDevice_IsNotWhiteout(t *testing.T) { + base := fstest.MapFS{ + "dev/null": {Data: []byte("lower"), Mode: 0o644}, + } + top := overlayFS{ + MapFS: fstest.MapFS{ + "dev/null": {Mode: fs.ModeCharDevice | fs.ModeDevice | 0o666}, + }, + rdev: map[string]uint64{"dev/null": unix.Mkdev(1, 3)}, + } + s := NewStack(base, top) + + info, err := fs.Stat(s, "dev/null") + if err != nil { + t.Fatalf("Stat dev/null: %v; a rdev-nonzero char device is not a whiteout", err) + } + if info.Mode()&fs.ModeCharDevice == 0 { + t.Errorf("dev/null mode %v: want a char device", info.Mode()) + } + if got := readDirNamesIn(t, s, "dev"); !reflect.DeepEqual(got, []string{"null"}) { + t.Errorf("ReadDir dev: got %v, want [null]", got) + } +} + +// Spec §7 step 6 lets a lone EROFS layer be mounted directly as a root +// filesystem, and a direct mount applies no overlay semantics. With nothing +// below it to hide, a rdev-0 char device is just a device. +func TestStack_SingleLayer_NoWhiteoutInterpretation(t *testing.T) { + only := overlayFS{MapFS: fstest.MapFS{ + "etc/odd": whiteout(), + "etc/here": {Data: []byte("X"), Mode: 0o644}, + }} + s := NewStack(only) + + got := readDirNames(t, s) + want := []string{"here", "odd"} + if !reflect.DeepEqual(got, want) { + t.Errorf("ReadDir etc: got %v, want %v", got, want) + } + if _, err := fs.Stat(s, "etc/odd"); err != nil { + t.Errorf("Stat etc/odd: %v; single-layer stacks apply no whiteouts", err) + } +} + +// TestStack_Whiteouts_RealErofsLayers runs the whiteout rules against images +// go-erofs actually wrote, rather than the overlayFS fixture. It is the check +// that the accessor interfaces Stack probes are the ones a real EROFS layer +// offers: if go-erofs ever stopped reporting Rdev() or GetXattr() on its +// FileInfo, every fixture-based test here would still pass while `apko erofs +// ls` silently stopped hiding anything. +func TestStack_Whiteouts_RealErofsLayers(t *testing.T) { + base := writeImage(t, func(t *testing.T, w *erofs.Writer) { + t.Helper() + for _, dir := range []string{"/etc", "/opt", "/opt/legacy"} { + if err := w.Mkdir(dir, 0o755); err != nil { + t.Fatalf("Mkdir(%s): %v", dir, err) + } + } + for _, f := range []string{"/etc/secret", "/etc/keep", "/opt/legacy/old"} { + fh, err := w.Create(f) + if err != nil { + t.Fatalf("Create(%s): %v", f, err) + } + if _, err := fh.Write([]byte("lower")); err != nil { + t.Fatalf("Write(%s): %v", f, err) + } + if err := fh.Close(); err != nil { + t.Fatalf("Close(%s): %v", f, err) + } + } + }) + + top := writeImage(t, func(t *testing.T, w *erofs.Writer) { + t.Helper() + for _, dir := range []string{"/etc", "/opt"} { + if err := w.Mkdir(dir, 0o755); err != nil { + t.Fatalf("Mkdir(%s): %v", dir, err) + } + } + // A whiteout: char device, major 0, minor 0. + if err := w.Mknod("/etc/secret", unix.S_IFCHR|0o600, 0); err != nil { + t.Fatalf("Mknod whiteout: %v", err) + } + // A real device must survive as a device. + if err := w.Mknod("/etc/null", unix.S_IFCHR|0o666, uint32(unix.Mkdev(1, 3))); err != nil { + t.Fatalf("Mknod /etc/null: %v", err) + } + // An opaque directory hides /opt/legacy from the lower layer. + if err := w.Setxattr("/opt", overlayOpaqueXattr, "y"); err != nil { + t.Fatalf("Setxattr opaque: %v", err) + } + }) + + s := NewStack(base, top) + + if _, err := fs.Stat(s, "etc/secret"); !errors.Is(err, fs.ErrNotExist) { + t.Errorf("Stat etc/secret: got %v, want ErrNotExist", err) + } + if data, err := fs.ReadFile(s, "etc/keep"); err != nil { + t.Errorf("ReadFile etc/keep: %v", err) + } else if string(data) != "lower" { + t.Errorf("etc/keep: got %q, want lower", data) + } + if got := readDirNames(t, s); !reflect.DeepEqual(got, []string{"keep", "null"}) { + t.Errorf("ReadDir etc: got %v, want [keep null]", got) + } + // Opaque /opt hides the lower layer's subtree. + if _, err := fs.Stat(s, "opt/legacy"); !errors.Is(err, fs.ErrNotExist) { + t.Errorf("Stat opt/legacy behind opaque dir: got %v, want ErrNotExist", err) + } + if got := readDirNamesIn(t, s, "opt"); len(got) != 0 { + t.Errorf("ReadDir opt: got %v, want empty", got) + } +} + func TestStack_TypeMismatch_TopWins(t *testing.T) { base := fstest.MapFS{ "etc/foo/inner": {Data: []byte("inner"), Mode: 0o644}, @@ -217,15 +447,20 @@ func TestStack_ReadDirUnion(t *testing.T) { } } -func TestStack_LiveBeatsSameLayerWhiteout(t *testing.T) { - // A malformed-but-possible layer: both the live entry and its whiteout. - // The live entry should win. +// A whiteout occupies the very name it deletes, so a layer cannot hold both a +// live entry and its tombstone -- the ambiguity the tar `.wh.` convention +// allows does not exist here. What remains worth pinning is that an opaque +// directory does not hide its own layer's children, only lower ones. +func TestStack_OpaqueDir_KeepsOwnChildren(t *testing.T) { base := fstest.MapFS{ "etc/foo": {Data: []byte("old"), Mode: 0o644}, } - top := fstest.MapFS{ - "etc/foo": {Data: []byte("new"), Mode: 0o644}, - "etc/.wh.foo": {Data: nil, Mode: 0o644}, + top := overlayFS{ + MapFS: fstest.MapFS{ + "etc": {Mode: fs.ModeDir | 0o755}, + "etc/foo": {Data: []byte("new"), Mode: 0o644}, + }, + opaque: map[string]bool{"etc": true}, } s := NewStack(base, top) @@ -365,10 +600,13 @@ func TestStack_WalkDir_PrunesWhiteoutsAndOpaque(t *testing.T) { "opt/old": {Data: []byte("O"), Mode: 0o644}, "opt/sub/x": {Data: []byte("X"), Mode: 0o644}, } - top := fstest.MapFS{ - "etc/.wh.hidden": {Data: nil, Mode: 0o644}, - "opt/.wh..wh..opq": {Data: nil, Mode: 0o644}, - "opt/new": {Data: []byte("N"), Mode: 0o644}, + top := overlayFS{ + MapFS: fstest.MapFS{ + "etc/hidden": whiteout(), + "opt": {Mode: fs.ModeDir | 0o755}, + "opt/new": {Data: []byte("N"), Mode: 0o644}, + }, + opaque: map[string]bool{"opt": true}, } s := NewStack(base, top) @@ -377,8 +615,8 @@ func TestStack_WalkDir_PrunesWhiteoutsAndOpaque(t *testing.T) { if err != nil { return err } - if strings.HasPrefix(d.Name(), ".wh.") { - t.Errorf("whiteout entry leaked: %s", p) + if d.Type()&fs.ModeCharDevice != 0 { + t.Errorf("whiteout entry leaked into the walk: %s", p) } seen = append(seen, p) return nil From 4aa34df4a26b7e674108f8de110145ce848d0e30 Mon Sep 17 00:00:00 2001 From: Scott Moser Date: Tue, 18 Aug 2026 08:43:32 -0400 Subject: [PATCH 27/33] erofsmount: accept the role shapes the spec allows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ReadOCILayers required every non-final layer to carry org.erofs.role=overlay-lower and the final layer to carry none. That is apko's own producer convention, not the spec's rule, and it rejected two conformant shapes: a non-final layer with no role, and a final layer carrying overlay-lower. Per the spec, org.erofs.role is OPTIONAL on any layer descriptor (§2.3), §3.8 rule 1 says the final entry's role is "either absent or overlay-lower", and §7 step 1 classifies "role overlay-lower or absent" as an overlay lower at any position. Both spellings become the same lowerdir, so both are now accepted wherever they appear. The two roles apko does not implement -- device and overlay-data -- are refused as unsupported compositions rather than as malformed images, and an unrecognized role value is refused as unknown. With those checks in place, §3.8 rule 1 holds by construction, so the separate positional pass is gone. TestReadOCILayers_BadRoleOrder asserted the behavior being removed, so it becomes TestReadOCILayers_SpecValidRoleShapes over the three legal shapes, plus coverage of the unsupported and unknown roles. The role constants grow device and overlay-data, and their doc comments stop attributing apko's convention to spec §3.8. Also drops a pointer to "PR #1", which merged. While here: the "multiple manifests match arch" error told the user to pass --tag, a flag no apko erofs subcommand has. A tag is a SOURCE:TAG suffix; the error now says that and lists what is available. --- pkg/build/types/erofs.go | 22 ++++++++---- pkg/erofsmount/oci.go | 33 ++++++++--------- pkg/erofsmount/oci_test.go | 74 ++++++++++++++++++++++++++++++++++---- 3 files changed, 100 insertions(+), 29 deletions(-) diff --git a/pkg/build/types/erofs.go b/pkg/build/types/erofs.go index 21a6dbddc..559edcba4 100644 --- a/pkg/build/types/erofs.go +++ b/pkg/build/types/erofs.go @@ -15,18 +15,26 @@ package types // EROFS media types and annotation keys from the draft -// erofs/erofs-image-spec (PR #1). Both the writer (pkg/build) and the -// reader/mount tools (pkg/erofsmount) reference these; centralizing them -// here keeps the two sides honest as the spec evolves. +// erofs/erofs-image-spec (spec.md). Both the writer (pkg/build) and the +// reader tools (pkg/erofsmount) reference these; centralizing them here keeps +// the two sides honest as the spec evolves. const ( // ErofsLayerMediaType is the manifest mediaType for an EROFS filesystem // layer blob (raw or internally compressed). ErofsLayerMediaType = "application/vnd.erofs" // ErofsRoleAnnotation is the layer-descriptor annotation key that names - // the layer's overlayfs role per spec §3.8. + // the layer's composition role. It is OPTIONAL on any layer (spec §2.3); + // an absent role means the layer is part of the overlay stack, or is the + // whole root filesystem in the single-layer case. ErofsRoleAnnotation = "org.erofs.role" - // ErofsRoleOverlayLower marks a layer as an overlay lowerdir. Per spec - // §3.8 rule 1, every non-final layer carries this; the final layer - // carries no role annotation. + // ErofsRoleOverlayLower marks a layer as an overlayfs lowerdir. Spec §7 + // step 1 treats this and an absent role identically, at any position. ErofsRoleOverlayLower = "overlay-lower" + // ErofsRoleOverlayData marks a layer supplied as an overlayfs data-only + // lower (spec §2.4). apko neither writes nor reads these. + ErofsRoleOverlayData = "overlay-data" + // ErofsRoleDevice marks a layer used as a raw byte source for EROFS + // multi-device addressing (spec §2.4). apko neither writes nor reads + // these. + ErofsRoleDevice = "device" ) diff --git a/pkg/erofsmount/oci.go b/pkg/erofsmount/oci.go index 640f8950a..13337c1d1 100644 --- a/pkg/erofsmount/oci.go +++ b/pkg/erofsmount/oci.go @@ -80,6 +80,18 @@ func ReadOCILayers(ociDir, tag, arch string) ([]LayerRef, error) { if string(desc.MediaType) != types.ErofsLayerMediaType { return nil, fmt.Errorf("layer %d has mediaType %q; expected %q (this command only handles EROFS images)", i, desc.MediaType, types.ErofsLayerMediaType) } + // org.erofs.role is OPTIONAL on any layer (spec §2.3), and §7 step 1 + // classifies both "overlay-lower" and an absent role as overlay lowers + // at any position -- so both are accepted here, wherever they appear. + // The other two roles name compositions we don't implement; say so + // rather than calling a conformant image malformed. + switch role := desc.Annotations[types.ErofsRoleAnnotation]; role { + case "", types.ErofsRoleOverlayLower: + case types.ErofsRoleDevice, types.ErofsRoleOverlayData: + return nil, fmt.Errorf("layer %d has %s=%s, which is not supported yet (only overlay lowers are)", i, types.ErofsRoleAnnotation, role) + default: + return nil, fmt.Errorf("layer %d has unknown %s=%q", i, types.ErofsRoleAnnotation, role) + } blob := filepath.Join(ociDir, "blobs", desc.Digest.Algorithm, desc.Digest.Hex) if _, err := os.Stat(blob); err != nil { return nil, fmt.Errorf("layer %d blob %s: %w", i, blob, err) @@ -93,21 +105,9 @@ func ReadOCILayers(ociDir, tag, arch string) ([]LayerRef, error) { }) } - // Validate role placement: per the EROFS image spec rule, every layer - // except the final (top) one must carry role=overlay-lower; the final - // layer must carry no role. We accept role==""/role==overlay-lower in - // either position so single-layer images (one unannotated layer) work. - if len(refs) > 1 { - for i := 0; i < len(refs)-1; i++ { - if refs[i].Role != types.ErofsRoleOverlayLower { - return nil, fmt.Errorf("layer %d: missing %s=%s annotation (only the final layer may be unannotated)", i, types.ErofsRoleAnnotation, types.ErofsRoleOverlayLower) - } - } - if refs[len(refs)-1].Role != "" { - return nil, fmt.Errorf("layer %d (final): unexpected role %q (final layer must carry no role)", len(refs)-1, refs[len(refs)-1].Role) - } - } - + // §3.8 rule 1 -- the last entry must be a mountable EROFS layer whose role + // is absent or overlay-lower -- now holds by construction: every layer got + // past the mediaType and role checks above. return refs, nil } @@ -166,7 +166,8 @@ func selectImage(idx v1.ImageIndex, tag, arch string) (v1.Image, error) { return nil, fmt.Errorf("no manifest for arch=%q (saw: %s)", arch, availableArchList(images)) } if len(matches) > 1 { - return nil, fmt.Errorf("multiple manifests match arch=%q; pass --tag to disambiguate", arch) + // There is no --tag flag; a tag is given as a SOURCE:TAG suffix. + return nil, fmt.Errorf("multiple manifests match arch=%q; name a tag as SOURCE:TAG to disambiguate (available: %s)", arch, availableTagsList(images)) } return idx.Image(matches[0].Digest) diff --git a/pkg/erofsmount/oci_test.go b/pkg/erofsmount/oci_test.go index 9b707c86a..a028ad803 100644 --- a/pkg/erofsmount/oci_test.go +++ b/pkg/erofsmount/oci_test.go @@ -187,16 +187,78 @@ func TestReadOCILayers_WrongMediaType(t *testing.T) { } } -func TestReadOCILayers_BadRoleOrder(t *testing.T) { +// org.erofs.role is OPTIONAL on any layer (§2.3), and §3.8 rule 1 allows the +// final layer's role to be absent *or* overlay-lower, so neither an +// unannotated non-final layer nor an overlay-lower final layer is an error. +// apko's own writer emits only one of the legal shapes; a reader that accepted +// just that one would reject conformant images from other producers. +func TestReadOCILayers_SpecValidRoleShapes(t *testing.T) { + for _, tc := range []struct { + name string + layers []fakeLayer + }{ + { + name: "role absent on a non-final layer", + layers: []fakeLayer{ + {body: []byte("a")}, + {body: []byte("b"), role: types.ErofsRoleOverlayLower}, + }, + }, + { + name: "overlay-lower on the final layer", + layers: []fakeLayer{ + {body: []byte("a"), role: types.ErofsRoleOverlayLower}, + {body: []byte("b"), role: types.ErofsRoleOverlayLower}, + }, + }, + { + name: "apko's own shape: lower then unannotated", + layers: []fakeLayer{ + {body: []byte("a"), role: types.ErofsRoleOverlayLower}, + {body: []byte("b")}, + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + writeFakeOCILayout(t, dir, tc.layers) + refs, err := ReadOCILayers(dir, "", "amd64") + if err != nil { + t.Fatalf("ReadOCILayers: %v", err) + } + if len(refs) != len(tc.layers) { + t.Fatalf("got %d refs, want %d", len(refs), len(tc.layers)) + } + }) + } +} + +// The roles we don't implement must be refused, and say so as an unsupported +// composition rather than a malformed image. +func TestReadOCILayers_UnsupportedRole(t *testing.T) { + for _, role := range []string{types.ErofsRoleDevice, types.ErofsRoleOverlayData} { + t.Run(role, func(t *testing.T) { + dir := t.TempDir() + writeFakeOCILayout(t, dir, []fakeLayer{ + {body: []byte("a"), role: role}, + {body: []byte("b")}, + }) + _, err := ReadOCILayers(dir, "", "amd64") + if err == nil || !strings.Contains(err.Error(), "not supported") { + t.Fatalf("expected unsupported-role error, got %v", err) + } + }) + } +} + +func TestReadOCILayers_UnknownRole(t *testing.T) { dir := t.TempDir() - // First layer lacks role annotation: invalid. writeFakeOCILayout(t, dir, []fakeLayer{ - {body: []byte("a")}, - {body: []byte("b"), role: types.ErofsRoleOverlayLower}, + {body: []byte("a"), role: "sideways"}, }) _, err := ReadOCILayers(dir, "", "amd64") - if err == nil || !strings.Contains(err.Error(), "missing") { - t.Fatalf("expected role-annotation error, got %v", err) + if err == nil || !strings.Contains(err.Error(), "unknown") { + t.Fatalf("expected unknown-role error, got %v", err) } } From 98ca640b2c8c81fc6a34ad7557c9c6064e76183e Mon Sep 17 00:00:00 2001 From: Scott Moser Date: Tue, 18 Aug 2026 08:45:47 -0400 Subject: [PATCH 28/33] build(erofs): always stamp the build time writeErofs passed erofs.WithBuildTime only when buildTime was non-zero. When the option is absent, go-erofs v0.3.1 stamps time.Now().Unix() into the superblock from Writer.Close (mkfs.go:602), so a caller who left the timestamp zero got a different digest on every build -- and got it silently, from the one code path that looks like it is asking for a default. apko's own CLI never hits this, because options.Default sets SourceDateEpoch to time.Unix(0, 0), which is not IsZero. Library callers build their own options, so they hit it exactly where they would least expect to. The option is now always passed. It cannot be passed verbatim, though: a zero time.Time has a large negative Unix seconds value that wraps when converted to uint64, so erofsBuildTime clamps a zero or pre-epoch timestamp to epoch 0. That matches what an unset SOURCE_DATE_EPOCH already means to apko, and it is reproducible either way. The test pins the zero case to an explicit epoch build rather than comparing two zero-time builds to each other: the wall-clock default has one-second granularity, so back-to-back builds would agree anyway and the test would pass against the bug. --- pkg/build/erofs.go | 34 +++++++++++++++++++++------- pkg/build/erofs_test.go | 49 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 8 deletions(-) diff --git a/pkg/build/erofs.go b/pkg/build/erofs.go index 8a86c66de..c3ca6f792 100644 --- a/pkg/build/erofs.go +++ b/pkg/build/erofs.go @@ -39,18 +39,15 @@ import ( // out. out must be both writable and seekable: go-erofs's Writer rewrites the // superblock at offset 0 after streaming file data. // -// If buildTime is non-zero it sets the EROFS image build time (used to seed -// per-entry mtime defaulting and recorded in the superblock), making the image -// reproducible. +// buildTime sets the EROFS image build time, which seeds per-entry mtime +// defaulting and is recorded in the superblock. It is always passed through, so +// the image is reproducible for any caller; see erofsBuildTime. func writeErofs(ctx context.Context, out io.WriteSeeker, fsys apkfs.FullFS, buildTime time.Time) error { ctx, span := otel.Tracer("apko").Start(ctx, "writeErofs") defer span.End() - var createOpts []erofs.CreateOpt - if !buildTime.IsZero() { - createOpts = append(createOpts, erofs.WithBuildTime(uint64(buildTime.Unix()), uint32(buildTime.Nanosecond()))) - } - w := erofs.Create(out, createOpts...) + sec, nsec := erofsBuildTime(buildTime) + w := erofs.Create(out, erofs.WithBuildTime(sec, nsec)) buf := make([]byte, 1<<20) @@ -76,6 +73,27 @@ func writeErofs(ctx context.Context, out io.WriteSeeker, fsys apkfs.FullFS, buil return nil } +// erofsBuildTime converts a build timestamp into the seconds/nanoseconds pair +// erofs.WithBuildTime takes. +// +// The option is always passed, never omitted: go-erofs v0.3.1 stamps +// time.Now().Unix() into the superblock from Writer.Close when it is absent, so +// a caller who leaves the timestamp zero would get a different digest on every +// build -- exactly where they would least expect it. apko's own CLI is covered +// because options.Default sets SourceDateEpoch to time.Unix(0, 0), but library +// callers construct their own. +// +// A zero time.Time has a large negative Unix seconds value, which would wrap +// when converted to uint64, so a zero or pre-epoch timestamp is clamped to +// epoch 0. That is what an unset SOURCE_DATE_EPOCH already means to apko, and +// it is at least reproducible. +func erofsBuildTime(t time.Time) (sec uint64, nsec uint32) { + if t.Unix() < 0 { + return 0, 0 + } + return uint64(t.Unix()), uint32(t.Nanosecond()) +} + // erofsAbsPath maps an fs.WalkDir-style path (rooted at ".") to the // absolute path the EROFS writer expects ("/"). func erofsAbsPath(path string) string { diff --git a/pkg/build/erofs_test.go b/pkg/build/erofs_test.go index f3e7dbeed..0a4512e31 100644 --- a/pkg/build/erofs_test.go +++ b/pkg/build/erofs_test.go @@ -400,3 +400,52 @@ func TestWriteErofs_Reproducible(t *testing.T) { require.Equal(t, len(a), len(b), "image sizes differ between identical builds") require.True(t, bytes.Equal(a, b), "two identical builds produced byte-different images") } + +// A zero buildTime must still be deterministic. go-erofs stamps +// time.Now().Unix() into the superblock when WithBuildTime is absent, so +// omitting the option for a zero timestamp -- which apko's CLI never produces +// but a library caller easily can -- silently made the digest depend on the +// wall clock. +// +// Asserting that two zero-time builds match would not catch that: the default +// has one-second granularity, so back-to-back builds would agree anyway. +// Instead pin the zero case to the epoch case, which is what the clamp in +// erofsBuildTime promises and what a wall-clock stamp could not produce. +func TestWriteErofs_ZeroBuildTimeIsEpoch(t *testing.T) { + build := func(path string, bt time.Time) []byte { + m := seedFS(t) + f, err := os.Create(path) + require.NoError(t, err) + require.NoError(t, writeErofs(context.Background(), f, m, bt)) + require.NoError(t, f.Close()) + data, err := os.ReadFile(path) + require.NoError(t, err) + return data + } + + tmp := t.TempDir() + zero := build(filepath.Join(tmp, "zero.erofs"), time.Time{}) + epochBuild := build(filepath.Join(tmp, "epoch.erofs"), time.Unix(0, 0)) + require.True(t, bytes.Equal(zero, epochBuild), + "a zero buildTime must produce the same image as an explicit epoch, not a wall-clock stamp") +} + +func TestErofsBuildTime(t *testing.T) { + for _, tc := range []struct { + name string + in time.Time + wantSec uint64 + wantNsec uint32 + }{ + {"zero clamps to epoch", time.Time{}, 0, 0}, + {"pre-epoch clamps to epoch", time.Unix(-1, 0), 0, 0}, + {"epoch", time.Unix(0, 0), 0, 0}, + {"normal", time.Unix(1700000000, 1234), 1700000000, 1234}, + } { + t.Run(tc.name, func(t *testing.T) { + sec, nsec := erofsBuildTime(tc.in) + require.Equal(t, tc.wantSec, sec) + require.Equal(t, tc.wantNsec, nsec) + }) + } +} From e41e8d8bc188347299dc71af196c56198e403aaa Mon Sep 17 00:00:00 2001 From: Scott Moser Date: Tue, 18 Aug 2026 08:48:16 -0400 Subject: [PATCH 29/33] oci: carry os.features onto the index platform descriptor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit erofs/erofs-image-spec §5.4 is a MUST in two places: the image config's top-level os.features, and the index platform descriptor's os.features when the image is referenced from an index. §6 and §8.1 producer requirement 8 reinforce it, and §8.2 item 1 has consumers refuse an image whose os.features they do not implement. Only the config half was implemented. apko always emits an index, so a consumer that filters on the index platform -- selecting a manifest before fetching any config, which is the point of the index -- never saw the signal. The obvious place to fix it, Architecture.ToOCIPlatform, has no config to read. generateIndexWithMediaType already holds the v1.Image, so it reads ConfigFile().OSFeatures and copies it onto the descriptor's platform. That is format-agnostic on purpose: os.features describes the image whatever produced it, and nothing else in apko sets it today. An image whose config declares no features still gets no OSFeatures field, so tar builds are byte-identical to before; the test pins that alongside the erofs case, since an empty-but-present slice would change every index digest. --- pkg/build/oci/index.go | 19 ++++++++++++- pkg/build/oci/index_test.go | 53 ++++++++++++++++++++++++++++++++++++- 2 files changed, 70 insertions(+), 2 deletions(-) diff --git a/pkg/build/oci/index.go b/pkg/build/oci/index.go index 6cdb25d5f..c62463a6e 100644 --- a/pkg/build/oci/index.go +++ b/pkg/build/oci/index.go @@ -22,6 +22,7 @@ import ( "io" "maps" "os" + "slices" "sort" "strings" "time" @@ -97,13 +98,29 @@ func generateIndexWithMediaType(mediaType ggcrtypes.MediaType, ic types.ImageCon return name.Digest{}, nil, fmt.Errorf("failed to compute size: %w", err) } + platform := arch.ToOCIPlatform() + // Carry the config's os.features onto the index platform descriptor. + // A consumer that filters on the index -- selecting a manifest before + // fetching any config -- can only see the signal if it is here. For + // EROFS images this is half of a MUST: erofs/erofs-image-spec §5.4 + // requires the feature in both the config and the index platform + // descriptor, and §8.2 item 1 has consumers refuse an image whose + // os.features they do not implement. + cfg, err := img.ConfigFile() + if err != nil { + return name.Digest{}, nil, fmt.Errorf("failed to get config file: %w", err) + } + if len(cfg.OSFeatures) > 0 { + platform.OSFeatures = slices.Clone(cfg.OSFeatures) + } + idx = mutate.AppendManifests(idx, mutate.IndexAddendum{ Add: img, Descriptor: v1.Descriptor{ MediaType: mt, Digest: h, Size: size, - Platform: arch.ToOCIPlatform(), + Platform: platform, }, }) } diff --git a/pkg/build/oci/index_test.go b/pkg/build/oci/index_test.go index 7c44fb40f..6e017b77c 100644 --- a/pkg/build/oci/index_test.go +++ b/pkg/build/oci/index_test.go @@ -14,7 +14,19 @@ package oci -import "testing" +import ( + "context" + "testing" + "time" + + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/empty" + "github.com/google/go-containerregistry/pkg/v1/static" + ggcrtypes "github.com/google/go-containerregistry/pkg/v1/types" + "github.com/stretchr/testify/require" + + "chainguard.dev/apko/pkg/build/types" +) func TestGenerateIndex(t *testing.T) { @@ -27,3 +39,42 @@ func TestGenerateDockerIndex(t *testing.T) { func TestBuildIndex(t *testing.T) { } + +// erofs/erofs-image-spec §5.4 requires `erofs` in os.features in two places: +// the image config, and the index platform descriptor when the image is +// referenced from an index. apko always emits an index, so a consumer that +// filters on the index platform without fetching configs would otherwise never +// see the signal. +func TestGenerateIndex_PropagatesOSFeatures(t *testing.T) { + ctx := context.Background() + now := time.Now() + arch := types.ParseArchitecture("amd64") + + buildImage := func(t *testing.T, ic types.ImageConfiguration, mt ggcrtypes.MediaType) v1.Image { + t.Helper() + img, err := BuildImageFromLayer(ctx, empty.Image, static.NewLayer([]byte("hello"), mt), ic, now, arch) + require.NoError(t, err) + return img + } + + platformOf := func(t *testing.T, img v1.Image) *v1.Platform { + t.Helper() + _, idx, err := GenerateIndex(ctx, types.ImageConfiguration{}, map[types.Architecture]v1.Image{arch: img}, now) + require.NoError(t, err) + mf, err := idx.IndexManifest() + require.NoError(t, err) + require.Len(t, mf.Manifests, 1) + require.NotNil(t, mf.Manifests[0].Platform, "index descriptor has no platform") + return mf.Manifests[0].Platform + } + + erofsImg := buildImage(t, types.ImageConfiguration{Format: types.LayerFormatErofs}, ggcrtypes.MediaType(types.ErofsLayerMediaType)) + require.Contains(t, platformOf(t, erofsImg).OSFeatures, "erofs", + "index platform descriptor must declare erofs in os.features") + + // A tar build must not gain the feature, and the propagation must not + // invent an empty OSFeatures slice where the config has none. + tarImg := buildImage(t, types.ImageConfiguration{}, ggcrtypes.OCILayer) + require.Empty(t, platformOf(t, tarImg).OSFeatures, + "tar-format builds must not declare os.features on the index descriptor") +} From 7e42dc6170055a7aa60a41b7f52e90a0e5a62790 Mon Sep 17 00:00:00 2001 From: Scott Moser Date: Tue, 18 Aug 2026 08:50:42 -0400 Subject: [PATCH 30/33] build(erofs): name the default output .erofs, not .tar.gz With --format=erofs and no explicit output path, the layer blob landed at TempDir()/apko-.tar.gz -- TarballFileName's spelling. The bytes are a raw EROFS filesystem image: not a tar, not gzipped. Anything that sniffs by extension is misled, and the name reads as a bug to anyone looking in the temp directory. Options.LayerFileName takes the layer format and picks the extension, delegating to TarballFileName for tar so the two spellings cannot drift. An explicitly given TarballPath is still honored verbatim; this only affects the generated default. --- pkg/build/build.go | 2 +- pkg/options/options.go | 14 +++++++++++ pkg/options/options_test.go | 50 +++++++++++++++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 pkg/options/options_test.go diff --git a/pkg/build/build.go b/pkg/build/build.go index 264ae45f5..bf2318288 100644 --- a/pkg/build/build.go +++ b/pkg/build/build.go @@ -179,7 +179,7 @@ func (bc *Context) ImageLayoutToLayer(ctx context.Context) (string, v1.Layer, er if bc.o.TarballPath != "" { outfile, err = os.Create(bc.o.TarballPath) } else { - outfile, err = os.Create(filepath.Join(bc.o.TempDir(), bc.o.TarballFileName())) + outfile, err = os.Create(filepath.Join(bc.o.TempDir(), bc.o.LayerFileName(bc.ic.Format))) } if err != nil { return "", nil, fmt.Errorf("creating tarball file: %w", err) diff --git a/pkg/options/options.go b/pkg/options/options.go index 5866f0ddf..efc64a2f9 100644 --- a/pkg/options/options.go +++ b/pkg/options/options.go @@ -122,3 +122,17 @@ func (o Options) TarballFileName() string { } return tarName } + +// LayerFileName returns a deterministic filename for a layer blob in the given +// format. It exists because TarballFileName's ".tar.gz" is a lie for an EROFS +// image -- neither a tar nor gzipped -- and anything that sniffs by extension +// would be misled by it. +func (o Options) LayerFileName(format types.LayerFormat) string { + if format.Resolved() != types.LayerFormatErofs { + return o.TarballFileName() + } + if o.Arch.String() != "" { + return fmt.Sprintf("apko-%s.erofs", o.Arch.ToAPK()) + } + return "apko.erofs" +} diff --git a/pkg/options/options_test.go b/pkg/options/options_test.go new file mode 100644 index 000000000..8afb715c3 --- /dev/null +++ b/pkg/options/options_test.go @@ -0,0 +1,50 @@ +// Copyright 2026 Chainguard, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package options + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "chainguard.dev/apko/pkg/build/types" +) + +func TestLayerFileName(t *testing.T) { + for _, tc := range []struct { + name string + arch types.Architecture + format types.LayerFormat + want string + }{ + {"tar with arch", types.ParseArchitecture("amd64"), types.LayerFormatTar, "apko-x86_64.tar.gz"}, + {"empty format defaults to tar", types.ParseArchitecture("amd64"), "", "apko-x86_64.tar.gz"}, + {"tar without arch", "", types.LayerFormatTar, "apko.tar.gz"}, + {"erofs with arch", types.ParseArchitecture("arm64"), types.LayerFormatErofs, "apko-aarch64.erofs"}, + {"erofs without arch", "", types.LayerFormatErofs, "apko.erofs"}, + } { + t.Run(tc.name, func(t *testing.T) { + o := Options{Arch: tc.arch} + require.Equal(t, tc.want, o.LayerFileName(tc.format)) + }) + } +} + +// The tar spelling must not drift from TarballFileName, which other callers +// still use directly. +func TestLayerFileName_MatchesTarballFileName(t *testing.T) { + o := Options{Arch: types.ParseArchitecture("amd64")} + require.Equal(t, o.TarballFileName(), o.LayerFileName(types.LayerFormatTar)) +} From ca9a747f6a1c91d3db1e8320814f7864d2500e11 Mon Sep 17 00:00:00 2001 From: Scott Moser Date: Tue, 18 Aug 2026 08:53:40 -0400 Subject: [PATCH 31/33] erofs: record the hardlink and go-erofs v0.3.1 caveats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things were true of the code but written down nowhere, so the next contributor had to rediscover them. Hardlinks are materialized as independent copies. apko's FullFS surfaces a hardlink as an ordinary second dirent -- its linkness is visible only in the *tar.Header from Sys(), which is how the tar layer path preserves it -- and go-erofs v0.3.1 has no API to point two names at one NID. There is no open issue or PR proposing one. SetNlink exists, but it only sets the reported link count without sharing the inode, so using it would make the metadata lie rather than save any space. Spec §3.7 permits materializing links (a producer must materialize or fail), so this is conformant; it is the size and st_nlink/st_ino consequences that deserve recording. Documented as a limitation too, without a measurement: no hardlink-heavy image was on hand to measure, and the arithmetic (one full copy per link, rounded to the block size) is the honest statement. The Chmod after Mkdir/Mknod/Create is a workaround for erofs/go-erofs#41, which merged 2026-08-02 -- after v0.3.1 was tagged 2026-07-21. So the pinned version has neither half of the fix. This Chmod covers the write half; the read half means FileInfo.Mode() from the pinned reader misreports setuid/setgid/sticky, and code that needs a mode must read *erofs.Stat.Mode off Sys() instead, which is what pkg/erofsmount/ls.go already does. Nothing said so, so the next caller of FileInfo.Mode() would have been bitten invisibly. Also: docs pointed at "PR #1" of the spec, which merged; they now cite spec.md on main, and state that the draft phase is the spec's own description of itself rather than implying apko is behind. The os.features comment notes where the other half of §5.4 lives. Drops erofsLayer.LayerPath(), which has no callers. --- docs/erofs.md | 8 +++++--- pkg/build/erofs.go | 26 ++++++++++++++++++++++---- pkg/build/oci/image.go | 4 +++- 3 files changed, 30 insertions(+), 8 deletions(-) diff --git a/docs/erofs.md b/docs/erofs.md index 839908ac4..a70a5290b 100644 --- a/docs/erofs.md +++ b/docs/erofs.md @@ -1,8 +1,9 @@ # EROFS Output Format (experimental) apko can emit image layers as [EROFS](https://erofs.docs.kernel.org/) filesystem images instead of the default gzip-compressed tar. -The format tracks the draft [erofs/erofs-image-spec](https://github.com/erofs/erofs-image-spec) (PR [#1](https://github.com/erofs/erofs-image-spec/pull/1)). -Until the spec reaches a stable release, the media types, annotations, and layer layout used here may change. +The format tracks [erofs/erofs-image-spec](https://github.com/erofs/erofs-image-spec); section numbers below refer to [`spec.md`](https://github.com/erofs/erofs-image-spec/blob/main/spec.md) on `main`. +The spec is still in its draft phase — there is no tagged release, and it says media-type strings, annotation keys, and the binary chunk-index layout are subject to change until the first stable one. +So the media types, annotations, and layer layout used here may change too. ## Why EROFS? @@ -247,12 +248,13 @@ For inspection, apko *does* expose a focused leaf library — see `chainguard.de - **No dm-verity.** The spec's verified-mount path (§3.5) is not produced. - **No chunk index.** Lazy-loading runtimes (per spec §3.4) won't get an index; reads are sequential. - **No `overlay-data` or `device` roles.** apko emits one unannotated EROFS layer; `org.erofs.role` is never set. +- **Hardlinks become independent copies.** go-erofs has no API to point two names at one inode, so each link costs another full copy of the file's data (rounded up to the block size) and `st_nlink`/`st_ino` identity is lost. Spec §3.7 allows this — a producer must either materialize links or fail — but a hardlink-heavy image will be larger as EROFS than as tar, where extra links are zero-byte entries. - **Spec is draft.** Media-type strings and annotation keys may change before the spec stabilizes. Treat any image built today as experimental. If you need any of the above, please open an issue. ## See also -- [erofs/erofs-image-spec PR #1](https://github.com/erofs/erofs-image-spec/pull/1) — the layer format spec apko tracks. +- [erofs/erofs-image-spec `spec.md`](https://github.com/erofs/erofs-image-spec/blob/main/spec.md) — the layer format spec apko tracks. - [EROFS kernel documentation](https://erofs.docs.kernel.org/) — on-disk format reference. - [Layering in apko](layering.md) — how the multi-layer strategy partitions packages into groups. diff --git a/pkg/build/erofs.go b/pkg/build/erofs.go index c3ca6f792..ed37f71cf 100644 --- a/pkg/build/erofs.go +++ b/pkg/build/erofs.go @@ -150,6 +150,16 @@ func emitErofsEntry(w *erofs.Writer, absPath, fsysPath string, info fs.FileInfo, return fmt.Errorf("mknod %s: %w", absPath, err) } case mode.IsRegular(): + // Hardlinks are materialized as independent copies. apko's FullFS + // surfaces a hardlink as an ordinary second dirent -- its linkness + // shows up only in the *tar.Header from Sys() (TypeLink/Linkname), + // which the tar layer path uses via tar.FileInfoHeader -- and go-erofs + // v0.3.1 has no API to point two names at one NID. (SetNlink sets the + // reported link count but does not share the inode, so it would only + // make the metadata lie.) Every link therefore costs another full copy + // of the data, rounded up to the block size, and st_nlink/st_ino + // identity is lost. Spec §3.7 permits materializing links -- a producer + // must either materialize or fail -- so this is conformant, not a bug. fout, err := w.Create(absPath) if err != nil { return fmt.Errorf("create %s: %w", absPath, err) @@ -182,6 +192,18 @@ func emitErofsEntry(w *erofs.Writer, absPath, fsysPath string, info fs.FileInfo, // setuid/setgid/sticky have to be applied on top — losing them would // silently break su/passwd/mount and unprotect /tmp. Symlinks are // exempt: EROFS pins them at 0777 and chmod on one is meaningless. + // + // This is a workaround for a bug in the pinned go-erofs, not a permanent + // shape. erofs/go-erofs#41 fixed both halves of it -- Mkdir dropping the + // special bits on write, and FileInfo.Mode() misreporting them on read -- + // but it merged 2026-08-02, after v0.3.1 was tagged 2026-07-21, so the + // pinned version has neither half. + // + // The read half matters even though this Chmod covers the write half: + // FileInfo.Mode() from the pinned reader cannot be trusted for + // setuid/setgid/sticky. Code that needs a mode must read *erofs.Stat.Mode + // off Sys() instead, which is what pkg/erofsmount/ls.go does. Revisit both + // once a release containing #41 is out. if mode&fs.ModeSymlink == 0 { if err := w.Chmod(absPath, mode); err != nil { return fmt.Errorf("chmod %s: %w", absPath, err) @@ -286,10 +308,6 @@ type erofsLayer struct { annotations map[string]string } -// LayerPath returns the on-disk path of this layer's payload. Used by callers -// that need to copy the file (e.g. into an OCI layout). -func (l *erofsLayer) LayerPath() string { return l.path } - // LayerAnnotations returns annotations to apply to this layer's manifest // descriptor. apko/oci consults this via an opt-in interface assertion. func (l *erofsLayer) LayerAnnotations() map[string]string { return l.annotations } diff --git a/pkg/build/oci/image.go b/pkg/build/oci/image.go index b68f06590..a6917861f 100644 --- a/pkg/build/oci/image.go +++ b/pkg/build/oci/image.go @@ -196,7 +196,9 @@ func BuildImageFromLayers(ctx context.Context, baseImage v1.Image, layers []v1.L // Signal EROFS-bearing manifests via os.features per // erofs/erofs-image-spec §5.4 so hosts that don't implement the spec can - // identify and skip them without parsing layer bytes. + // identify and skip them without parsing layer bytes. §5.4 wants this in + // two places; the index platform descriptor is the other, handled in + // generateIndexWithMediaType, which copies it from here. if ic.Format.Resolved() == types.LayerFormatErofs { if !slices.Contains(cfg.OSFeatures, "erofs") { cfg.OSFeatures = append(cfg.OSFeatures, "erofs") From e028849eec042e881fb21f42a0260bd294b46c3e Mon Sep 17 00:00:00 2001 From: Scott Moser Date: Tue, 18 Aug 2026 08:54:57 -0400 Subject: [PATCH 32/33] ci: install erofs-utils so the fsck cross-checks run Several EROFS writer tests validate their output twice: once by reading it back with go-erofs, and once with fsck.erofs from erofs-utils. The second check is deliberately optional, so contributors without the package still get a green build. No workflow installed it, so that "optional" meant "never runs anywhere". Every fsck leg degraded to a t.Log -- invisible, since make test does not pass -v -- and TestWriteErofs_FsckErofs skipped outright. The only always-on validation of a written image was go-erofs reading its own output, so a spec misunderstanding shared by writer and reader would sail through green. Installing the package in the Go Tests job activates all of it. The step asserts fsck.erofs reached PATH rather than trusting apt's exit code, because a silent install failure would look exactly like a pass. It runs unconditionally, not gated on changed paths: a writer/reader mismatch can arrive with a go-erofs bump as easily as with an apko change. harden-runner runs with egress-policy: block, so the Ubuntu archive hosts are added to allowed-endpoints. This job had no apt step before, hence no precedent for which mirror hostname the runner resolves; archive, azure.archive and security are all listed. A privileged job that builds with --format erofs, mounts the result with the kernel, and fscks the mountpoint would close the loop end to end. That belongs with the mount/umount follow-up, which is where the code it would exercise now lives. --- .github/workflows/go-tests.yaml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/.github/workflows/go-tests.yaml b/.github/workflows/go-tests.yaml index 562db0d00..cf207468c 100644 --- a/.github/workflows/go-tests.yaml +++ b/.github/workflows/go-tests.yaml @@ -25,6 +25,8 @@ jobs: alpinelinux.org:443 api.github.com:443 apk.cgr.dev:443 + archive.ubuntu.com:80 + azure.archive.ubuntu.com:80 dl-cdn.alpinelinux.org:443 dl.google.com:443 files.example.com:443 @@ -33,6 +35,7 @@ jobs: objects.githubusercontent.com:443 proxy.golang.org:443 release-assets.githubusercontent.com:443 + security.ubuntu.com:80 storage.googleapis.com:443 sum.golang.org:443 @@ -47,6 +50,21 @@ jobs: go-version-file: 'go.mod' check-latest: true + # erofs-utils supplies fsck.erofs, which several EROFS writer tests use as + # a second opinion from the C reference implementation. Those checks are + # optional by design so contributors without the package still get a green + # build -- which means that without this step they never run anywhere, and + # a misunderstanding shared by our writer and our reader would go + # unnoticed. Not conditional on changed paths: a mismatch can be + # introduced by a go-erofs bump as easily as by an apko change. + - name: Install erofs-utils + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends erofs-utils + # Assert it landed on PATH: the tests degrade to a t.Log if it did + # not, so a silent install failure would look like a pass. + command -v fsck.erofs + - name: Test run: | # The tests golden fixtures all expect `SOURCE_DATE_EPOCH` to be `0`. From 32214b1f1637db511383a5c3bbc42070ebe23eb8 Mon Sep 17 00:00:00 2001 From: Scott Moser Date: Tue, 18 Aug 2026 09:08:16 -0400 Subject: [PATCH 33/33] erofs: fix what installing erofs-utils in CI exposed Two things surfaced the moment the fsck cross-checks first ran anywhere, which is a decent argument for the previous commit. TestWriteErofs_FsckErofs passed --xattrs to fsck.erofs. The erofs-utils that Debian and Ubuntu ship rejects it outright ("unrecognized option '--xattrs'"), so the test failed on the runner. The flag was decoration: the comment claimed "with xattr verification" but nothing below it asserts on xattrs -- TestWriteErofs_Xattrs covers those by reading the image back with go-erofs. Dropped, with a note to stick to flags the distro packages understand. docs/erofs.md told readers to run the same command, so it loses the flag too, plus a line about checking --help since availability varies by release. newErofsLayerFile is unused now that splitErofsLayers has gone to the layering follow-up; the single-image path creates its output file directly. Removed. It is preserved on feat/apko-erofs-full along with its caller. Worth noting for anyone else running the linter locally: a stale golangci-lint cache reported "0 issues" here while CI failed on that unused function. `golangci-lint cache clean` first, or trust CI. --- docs/erofs.md | 4 +++- pkg/build/erofs.go | 19 ------------------- pkg/build/erofs_test.go | 14 ++++++++++---- 3 files changed, 13 insertions(+), 24 deletions(-) diff --git a/docs/erofs.md b/docs/erofs.md index a70a5290b..7481deff2 100644 --- a/docs/erofs.md +++ b/docs/erofs.md @@ -138,12 +138,14 @@ This is the strongest unprivileged validation you can run: if the image is malfo ```sh mkdir extracted -fsck.erofs --extract=extracted --xattrs --force out/blobs/sha256/$LAYER +fsck.erofs --extract=extracted --force out/blobs/sha256/$LAYER ls extracted/ # bin dev etc home lib ... cat extracted/etc/os-release ``` +Flag availability varies between erofs-utils releases — `--xattrs`, for instance, is not in the version Debian and Ubuntu ship — so check `fsck.erofs --help` on your machine before reaching for anything beyond the above. + ### List contents with `apko erofs ls` For a quick `tar tvf`-style listing of any EROFS source (raw blob or OCI image directory), use `apko erofs ls`. It opens the EROFS blobs directly, walks the merged view in user space, and prints one line per entry — no mounts, no root or FUSE required, works on Linux/macOS/Windows. diff --git a/pkg/build/erofs.go b/pkg/build/erofs.go index ed37f71cf..a8a14afdd 100644 --- a/pkg/build/erofs.go +++ b/pkg/build/erofs.go @@ -249,25 +249,6 @@ func uidGidFromInfo(info fs.FileInfo) (int, int) { return 0, 0 } -// newErofsLayerFile creates a temp file backing a single EROFS layer. The -// caller is responsible for closing and removing it. Permissions are 0600 to -// keep intermediate build artifacts off other users' eyes. -func newErofsLayerFile(tmpdir, pattern string) (*os.File, error) { - if pattern == "" { - pattern = "apko-erofs-*.bin" - } - f, err := os.CreateTemp(tmpdir, pattern) - if err != nil { - return nil, err - } - if err := f.Chmod(0o600); err != nil { - _ = f.Close() - _ = os.Remove(f.Name()) - return nil, err - } - return f, nil -} - // buildErofsLayerFromFile takes a finalized EROFS image already serialized to // path and returns a v1.Layer wrapping it. For the raw `application/vnd.erofs` // media type the DiffID and Digest are identical: the SHA-256 of the on-wire diff --git a/pkg/build/erofs_test.go b/pkg/build/erofs_test.go index 0a4512e31..0aeda1ab2 100644 --- a/pkg/build/erofs_test.go +++ b/pkg/build/erofs_test.go @@ -345,11 +345,17 @@ func TestWriteErofs_FsckErofs(t *testing.T) { output, err := cmd.CombinedOutput() require.NoError(t, err, "fsck.erofs reported a malformed image:\n%s", output) - // Full content extraction with xattr verification. This walks every - // inode, decompresses any data, and writes files to disk — a stronger - // signal than the integrity check alone. + // Full content extraction. This walks every inode, decompresses any data, + // and writes files to disk — a stronger signal than the integrity check + // alone. + // + // Only flags the erofs-utils in Debian/Ubuntu understands are used here. + // --xattrs, for one, is not universal: the version Ubuntu ships rejects it + // outright, which broke this test the moment CI first installed the package. + // Nothing below asserts on xattrs anyway; TestWriteErofs_Xattrs covers them + // by reading the image back with go-erofs. extractDir := t.TempDir() - cmd = exec.Command(fsckBin, "--extract="+extractDir, "--xattrs", "--force", out) + cmd = exec.Command(fsckBin, "--extract="+extractDir, "--force", out) output, err = cmd.CombinedOutput() require.NoError(t, err, "fsck.erofs --extract failed:\n%s", output)