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`. diff --git a/docs/apko_file.md b/docs/apko_file.md index 36e2a84ed..5a5d4a9b3 100644 --- a/docs/apko_file.md +++ b/docs/apko_file.md @@ -274,3 +274,18 @@ 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: + + - `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 layers advertise `erofs` in the image config's `os.features` so consumers that do not implement the spec can identify and skip them. + +`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 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 new file mode 100644 index 000000000..7481deff2 --- /dev/null +++ b/docs/erofs.md @@ -0,0 +1,262 @@ +# 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 [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? + +- **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 # ships mkfs.erofs, fsck.erofs, dump.erofs, and 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=host +``` + +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 --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. + +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 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 +# ... + +apko erofs ls out/ # works against the whole OCI image too +``` + +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 + +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 + +# Kernel (root): +sudo mount -t erofs -o ro out/blobs/sha256/$LAYER /mnt/apko-erofs +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 +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. +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 + +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. + +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 + +### 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 `Ls` (the `apko erofs ls` helper). All of it is cross-platform: go-erofs is pure Go and nothing here mounts anything. + +## 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.** 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 `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/go.mod b/go.mod index fb86cb30c..abaddadff 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.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 d5a104c8c..9034bb55b 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.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= 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/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..fa9da3bc9 --- /dev/null +++ b/internal/cli/erofs.go @@ -0,0 +1,69 @@ +// 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 the ls +// subcommand. +func erofsCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "erofs", + 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').`, + } + cmd.AddCommand(erofsLs()) + return cmd +} + +func erofsLs() *cobra.Command { + 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.`, + 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, arch, os.Stdout) + }, + } + cmd.Flags().StringVar(&arch, "arch", "host", "architecture to select from a multi-arch OCI index") + 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/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) +} diff --git a/pkg/build/build.go b/pkg/build/build.go index aa6c41afa..bf2318288 100644 --- a/pkg/build/build.go +++ b/pkg/build/build.go @@ -179,14 +179,35 @@ 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) } 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.Close(); err != nil { + return "", nil, fmt.Errorf("closing erofs image: %w", err) + } + l, err := buildErofsLayerFromFile(outName, nil) + if err != nil { + return "", nil, fmt.Errorf("finalizing erofs layer: %w", err) + } + return outName, l, nil + } + + defer outfile.Close() 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..a8a14afdd --- /dev/null +++ b/pkg/build/erofs.go @@ -0,0 +1,304 @@ +// 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" + "chainguard.dev/apko/pkg/build/types" +) + +// 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. +// +// 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() + + sec, nsec := erofsBuildTime(buildTime) + w := erofs.Create(out, erofs.WithBuildTime(sec, nsec)) + + 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 +} + +// 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 { + 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) + } + } + 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(): + // 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) + } + 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) + } + 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. + // + // 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) + } + } + + 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() { + // 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) + } + } + } + + 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 +} + +// 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 +} + +// 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(types.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_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_test.go b/pkg/build/erofs_test.go new file mode 100644 index 000000000..0aeda1ab2 --- /dev/null +++ b/pkg/build/erofs_test.go @@ -0,0 +1,457 @@ +// 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" + "bytes" + "context" + "errors" + "io" + "io/fs" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + 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" + + 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 %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") + 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) +} + +// 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 := 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) + } +} + +// 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 := optionalFsckErofs(t); fsckBin != "" { + 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). +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. 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, "--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) +} + +// 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) { + 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") +} + +// 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) + }) + } +} diff --git a/pkg/build/layers.go b/pkg/build/layers.go index 91c2692f9..cf01b1401 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,11 @@ 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 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/oci/image.go b/pkg/build/oci/image.go index 7a1583e51..a6917861f 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,17 @@ 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. §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") + } + } + 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/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") +} 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/erofs.go b/pkg/build/types/erofs.go new file mode 100644 index 000000000..559edcba4 --- /dev/null +++ b/pkg/build/types/erofs.go @@ -0,0 +1,40 @@ +// 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 (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 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 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/build/types/image_configuration.go b/pkg/build/types/image_configuration.go index 96c4ef877..7149d3fbe 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,17 @@ 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) + } + + // 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 == "" { 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 diff --git a/pkg/erofsmount/ls.go b/pkg/erofsmount/ls.go new file mode 100644 index 000000000..1ec762f3f --- /dev/null +++ b/pkg/erofsmount/ls.go @@ -0,0 +1,181 @@ +// 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" + "strconv" + "text/tabwriter" + + erofs "github.com/erofs/go-erofs" + + "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. +// +// 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, 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. 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 { + 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 + } + + sizeCol := strconv.FormatInt(info.Size(), 10) + 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") + + suffix := "" + if 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%s\t%s\t%s%s", + formatMode(mode), uid, gid, sizeCol, mt, name, suffix) +} + +// 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`, +// including the setuid/setgid/sticky overloads of the execute columns. +func formatMode(mode fs.FileMode) string { + out := []byte("----------") + switch { + case mode.IsDir(): + out[0] = 'd' + case mode&fs.ModeSymlink != 0: + out[0] = 'l' + case mode&fs.ModeNamedPipe != 0: + out[0] = 'p' + case mode&fs.ModeSocket != 0: + out[0] = 's' + case mode&fs.ModeCharDevice != 0: + out[0] = 'c' + case mode&fs.ModeDevice != 0: + out[0] = 'b' + } + perm := mode.Perm() + for i, ch := range []byte("rwxrwxrwx") { + if perm&(1<<(8-i)) != 0 { + 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 { + out[s.col] = s.only + } + } + return string(out) +} diff --git a/pkg/erofsmount/ls_test.go b/pkg/erofsmount/ls_test.go new file mode 100644 index 000000000..e3e41bf0f --- /dev/null +++ b/pkg/erofsmount/ls_test.go @@ -0,0 +1,382 @@ +// 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() + 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) + if err != nil { + 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() + 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) + } +} + +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) + } + } +} + +// 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) + } + } +} + +// 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) + } +} diff --git a/pkg/erofsmount/oci.go b/pkg/erofsmount/oci.go new file mode 100644 index 000000000..13337c1d1 --- /dev/null +++ b/pkg/erofsmount/oci.go @@ -0,0 +1,209 @@ +// 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" + + "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 { + 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) != 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) + } + refs = append(refs, LayerRef{ + BlobPath: blob, + Digest: desc.Digest.String(), + MediaType: string(desc.MediaType), + Annotations: desc.Annotations, + Role: desc.Annotations[types.ErofsRoleAnnotation], + }) + } + + // §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 +} + +// 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 { + // 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) +} + +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..a028ad803 --- /dev/null +++ b/pkg/erofsmount/oci_test.go @@ -0,0 +1,274 @@ +// 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" + + "chainguard.dev/apko/pkg/build/types" +) + +type fakeLayer struct { + body []byte + role string + annotations map[string]string + mediaType string // override; default types.ErofsLayerMediaType +} + +// writeFakeOCILayout writes a minimal OCI image layout under root with one or +// 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 { + 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 = types.ErofsLayerMediaType + } + dig := writeBlob(l.body) + anns := map[string]string{} + maps.Copy(anns, l.annotations) + if l.role != "" { + anns[types.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) + } +} + +func TestReadOCILayers_MultiLayer(t *testing.T) { + dir := t.TempDir() + writeFakeOCILayout(t, dir, []fakeLayer{ + {body: []byte("layer0-base"), role: types.ErofsRoleOverlayLower}, + {body: []byte("layer1-mid"), role: types.ErofsRoleOverlayLower}, + {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{types.ErofsRoleOverlayLower, types.ErofsRoleOverlayLower, ""} { + 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) + } +} + +// 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() + writeFakeOCILayout(t, dir, []fakeLayer{ + {body: []byte("a"), role: "sideways"}, + }) + _, err := ReadOCILayers(dir, "", "amd64") + if err == nil || !strings.Contains(err.Error(), "unknown") { + t.Fatalf("expected unknown-role 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/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/source.go b/pkg/erofsmount/source.go new file mode 100644 index 000000000..e6491ea9b --- /dev/null +++ b/pkg/erofsmount/source.go @@ -0,0 +1,207 @@ +// 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. 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 ( + "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 +} + +// 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 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 +// (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 blob is opened, since only go-erofs (or +// the kernel) can judge the superblock. +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 (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. +// - "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/stack.go b/pkg/erofsmount/stack.go new file mode 100644 index 000000000..a2be2e192 --- /dev/null +++ b/pkg/erofsmount/stack.go @@ -0,0 +1,433 @@ +// 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" + "time" +) + +// 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. +// +// Deletions use the overlayfs-native encoding the EROFS image spec mandates +// (§3.6), not the `.wh.` filename convention of OCI tar layers: +// +// - 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 +// 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, whiteouts: len(cp) > 1} +} + +// 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 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 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". +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 hold a whiteout or + // the entry. Move down. + continue + } + for _, e := range entries { + 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 + } + return i, nil + } + // 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 + } + } + return -1, fs.ErrNotExist +} + +// mergeDir produces the union of name's entries across layers, top-down, +// 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{} // 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 + } + for _, e := range entries { + n := e.Name() + if seen[n] { + continue + } + seen[n] = true + if s.isWhiteout(e) { + continue // tombstone: shadows lower layers, never listed + } + out = append(out, e) + } + // 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) { + 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 +} + +// 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). +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..e277b7333 --- /dev/null +++ b/pkg/erofsmount/stack_test.go @@ -0,0 +1,637 @@ +// 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" + "path" + "reflect" + "slices" + "testing" + "testing/fstest" + + erofs "github.com/erofs/go-erofs" + "golang.org/x/sys/unix" +) + +// 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) } + +// 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() + 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(%s): %v", dir, 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 := overlayFS{MapFS: fstest.MapFS{ + "etc/secret": whiteout(), + }} + 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" 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) { + 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 := overlayFS{MapFS: fstest.MapFS{ + "opt/legacy": whiteout(), + }} + 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_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 := 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) + + // 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) { + base := fstest.MapFS{ + "etc/gone": {Data: []byte("G"), Mode: 0o644}, + } + 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"} + if !reflect.DeepEqual(got, want) { + t.Errorf("got %v, want %v", got, want) + } +} + +// 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}, + } + 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")) + } +} + +// 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 := 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) + + 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 := 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) + + var seen []string + err := fs.WalkDir(s, ".", func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.Type()&fs.ModeCharDevice != 0 { + t.Errorf("whiteout entry leaked into the walk: %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/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)) +}