Add erofs support to apko. - #2249
Conversation
4516981 to
268396a
Compare
2101db3 to
3af7df1
Compare
|
I think this is ready for some eyes to review and some testing of it. See the description and linked PRs that i put together to show its usefulness. |
raharper
left a comment
There was a problem hiding this comment.
Looks really solid and complete with extensive testing. a couple comments/questions I'm interested in see feedback.
1ee8a5b to
2628747
Compare
Emit OCI image layers as EROFS filesystem images (application/vnd.erofs) instead of tar+gzip. Selected via `--format=erofs` on `apko build` / `apko publish` or `format: erofs` in apko.yaml. Tracks the draft erofs/erofs-image-spec (PR chainguard-dev#1). Single-layer and multi-layer (layering) builds are supported. Multi-layer emits each non-final group with `org.erofs.role=overlay-lower` per spec §3.8 and a per-group partial `usr/lib/apk/db/installed` so per-layer scanners still work. Manifests declare `erofs` in os.features per §5.4. Uses github.com/erofs/go-erofs (Apache-2.0, pure Go) for the writer. Reproducibility via SOURCE_DATE_EPOCH. Tests cover roundtrip via erofs.Open, byte-identical determinism, the full ImageLayoutToLayer dispatch, OSFeatures plumbing, and end-to-end validation via `fsck.erofs` (skipped when the binary isn't on PATH). `+zstd`, dm-verity, and chunk indexes are not implemented in this round. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Step-by-step guide for producing EROFS images with --format=erofs, inspecting the layer blob without root (fsck.erofs / dump.erofs / fsck.erofs --extract), mounting it (kernel mount or erofsfuse), pulling layer blobs from a registry, and assembling multi-layer images via overlayfs. Includes the current limitations (no +zstd, dm-verity, chunk index) and links from apko_file.md. All commands shown were verified against a real `apko build` of examples/wolfi-base.yaml. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds an `apko erofs` command group that wraps the EROFS mount workflow: `mount` accepts a raw blob or an OCI image directory (auto-detected, or via `erofs:`/`oci:`/`oci-dir:` prefixes), `umount` reads a per-mount state file to unwind every layer, and `ls` produces a `tar tvf`-style listing without leaving mounts behind. The new pkg/erofsmount library handles source parsing, OCI layout reading, kernel/FUSE drivers with kernel-overlay-over- fuse fallback to fuse-overlayfs, and state-file teardown. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`apko erofs ls` now opens each EROFS layer blob directly with go-erofs and walks a layered fs.FS in user space, instead of mounting the layers and walking the merged mountpoint. This removes the kernel/FUSE dependency for `ls` (works on darwin/windows too), eliminates the mount log noise, and is faster. Introduces a reusable pkg/erofsmount.Stack: a layered fs.FS implementing fs.ReadDirFS/StatFS/ReadLinkFS with full AUFS-style overlay semantics — .wh.NAME whiteouts hide siblings, .wh..wh..opq markers hide all lower- layer entries in a directory, ancestor whiteouts hide whole subtrees, type-mismatch in a higher layer shadows lower contents. apko's writer never emits whiteouts (it splits one rootfs into groups, doesn't merge), so 15 unit tests synthesize the whiteout cases via testing/fstest.MapFS. Mount and Unmount remain Linux-only since they genuinely need the kernel or FUSE. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
writeERofs was the only identifier in the repo using mid-word
acronym-style "ERofs"; everywhere else treats it as a word ("Erofs").
Rename writeERofs / writeERofsViaMkfs and the related test names so the
codebase is uniform.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
apko_file.md still claimed EROFS layers were uncompressed and that +zstd was unimplemented; the +ALGO variants have shipped since the format field was first documented. Rewrite the format list to enumerate raw and compressed variants, mention the uncompressed-digest annotation, and note the mkfs.erofs runtime dependency. erofs.md's manual-overlay reference snippet had two bugs that prevented it from running end-to-end: $ROOT/../../blobs/sha256/$MANIFEST double-traversed the OCI layout, and the lowerdir chain hard-coded a four-layer count with explicit lower0/lower1 references that wouldn't generalize. Rewrite the loop to derive $BLOBS and $MANIFEST cleanly and accumulate $LOWERS as it mounts. Also fix two small accuracy bugs: --arch on apko takes Go arches (amd64, arm64), not uname -m output (x86_64, aarch64) — replace with --arch=host, which is what the YAML examples in the same file use. And on Debian/Ubuntu erofsfuse ships inside the erofs-utils package; the separate erofsfuse package only exists on Wolfi/Alpine. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The application/vnd.erofs media type and the org.erofs.role / overlay-lower / org.erofs.uncompressed-digest annotation strings lived as parallel unexported consts in pkg/build/erofs.go and pkg/erofsmount/oci.go with a "keep in sync" comment guarding the duplicate. Promote them to a single set of exported constants in pkg/build/types/erofs.go so both the writer and the reader/mount tools reference the same source, and test fixtures lock to the same strings. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
mount(8) since util-linux 2.29 autodetects when the source is a regular file and allocates a loop device with O_AUTOCLEAR, freeing it on umount. Asking for "-o loop" explicitly relies on a separate code path whose cleanup semantics differ across util-linux releases and busybox builds — on older or non-GNU versions the loop device can leak after umount. Drop "loop" from the argv. Keep "-o ro" to document intent (EROFS is intrinsically read-only, but the explicit flag tells a reader who is copy-pasting the equivalent shell command that we never plan to write). Update the matching tests and the two "doing it manually" snippets in docs/erofs.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
st.Mounts is recorded overlay-first then per-layer mounts in LIFO order. If the overlay umount fails, every subsequent layer umount returns EBUSY because the overlay still pins them — the previous loop collected and errors.Join()'d every one of those, giving the user a long block of identical "device busy" noise where only the first error described the real problem. Return on the first failed umount with a single error that names which mountpoint the user needs to clear; leave the remaining mounts and the state file in place so a follow-up `apko erofs umount` finishes the job. Deliberately do not fall back to `umount -l`: lazy unmount would let the process exit with the user believing things were torn down while the mounts and pinned files quietly persist. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
I pulled the compression out. it is now separate at #2406. |
mattmoor
left a comment
There was a problem hiding this comment.
I took a deep pass over this at head 46ac139. The single-image writer path (--format=erofs producing one layer) held up well under scrutiny: close-before-hash ordering, xattr and special-bit handling, and byte-determinism all check out, and the fixes from the earlier review round are all present at head. apko erofs ls is also in good shape.
I found two significant bugs in the multi-layer split path (package routing never matches on the real tarfs path, and dir-only subtrees are dropped from every layer) and two in the umount path (state-file trust, and a rerun instruction that cannot work). Details inline. Given where those are concentrated, one option is to descope: land the single-image format plus ls now, and follow up with the layering split and mount/umount hardening.
Cross-cutting notes that don't anchor to one line:
- melange coordination: melange#2605 currently pins a fork of apko and passes
erofs+zstd[,level=N]format strings that this head rejects after the #2406 split; its compressed mode is dead until #2406 merges and melange re-pins. Worth a note on that PR. - Exported surface:
StatePath/WriteState/LoadState/RemoveState/StateSchemaVersion(state.go) andDriver/NewDriver/ResolveMode(driver_linux.go) have no callers outside the package, and the linux-only exports give the package a build-tag-dependent API. Consider unexporting before this ships as a library surface. SimilarlyerofsLayer.LayerPath()has no callers. - Test seam for the mount plane:
Mount/Unmountconstruct theirDriverinternally, so the 305 lines of orchestration in mount_linux.go (cleanup LIFO, state lifecycle, unmount policy) can't be unit-tested with a fake driver. driver_linux_test.go covers only the argv builders; nothing anywhere executes the orchestration.
Things I checked that came back clean, so others can skip them: determinism of the single-image path (go-erofs sorts xattr keys before serialization; no UUIDs or randomness; SOURCE_DATE_EPOCH plumbed through), OCI blob path traversal in erofsmount (ggcr's Hash parsing forecloses it), regressions to tar-path users from the shared-file changes (the rwosfs char-device seeding fix only changes behavior for DirFS over trees that already contain real device nodes, which no in-tree flow constructs), and the go-erofs supply chain (official erofs org, sumdb-verified tag, containerd pins the identical v0.3.1).
Matt's review found two bugs concentrated in the mount plane: Unmount trusts the on-disk state file and hands its recorded paths straight to `umount` (typically as root) with no validation, and the "rerun once they are no longer busy" instruction in the partial-failure path cannot succeed, because the rerun's first step is to unmount `merged`, which already came down. Both want more than a spot fix. The state file needs containment checks, the unmount loop needs to tolerate an already-unmounted entry or rewrite state as it goes, and the 305 lines of orchestration have no test seam at all -- Mount/Unmount construct their Driver internally, so nothing in-tree ever executes the cleanup LIFO or the state lifecycle. Doing that properly is its own change. So drop the mount plane here and land it next, hardened, on top. What remains in this PR is the single-image writer and `apko erofs ls`, both of which the review found sound. Removed: mount_linux.go, driver_linux.go, state.go, stub_other.go and their tests, plus the `apko erofs mount` and `apko erofs umount` subcommands. Ls loses the Options bag it only ever read Arch out of, and takes an arch string directly; that also drops the `--mode` flag it documented as "accepted for symmetry with 'mount' and ignored". With the Linux-only files gone the package no longer has a build-tag-dependent API, which was a separate review note: everything left is pure Go and builds everywhere. The mount and overlay-assembly recipes in docs/erofs.md now show the plain mount/erofsfuse commands instead of `apko erofs mount`. The full pre-descope tree is preserved on the feat/apko-erofs-full branch.
Matt's review found two bugs in splitErofsLayers, both of which mean the layered output is not the image it claims to be. The package-routing type assertion is on the wrong receiver. It asks info.Sys() for a Package() method, but tarfs -- which is what real builds walk -- returns a fresh *tar.Header from Sys(); Package() is a method on the FileInfo itself, which is what the tar split path asserts on. So the assertion never succeeds in a real build: every file lands in the top layer, each group layer holds only ancestor directories plus a partial installed db (that case keys on the path string, so it still fires), and per-layer scanners then read a db claiming packages whose files are not there. That inverts the whole point of the split. TestSplitErofsLayers cannot catch it: it drives the split through MemFS, which implements Package() nowhere, so the fixture has zero package-owned files by construction and the test passes identically with and without routing. Fixing this properly means a tarfs-backed test, which is more than a follow-on hunk. Second, directories whose subtree contains no non-directory entry are never emitted into any writer. They are recorded during the walk and materialized only by emitAncestors, which runs for non-dir entries, and no post-walk pass flushes the rest. Empty dirs and dir-only chains (/tmp, /run, /home, /var/empty, mount points) are therefore absent from the mounted union. Both siblings get this right: the tar split writes every walked dir into its owning layer, and writeErofs Mkdirs each one unconditionally. There is also a temp-file and fd leak on every error path out of splitErofsLayers, which needs an upstream Abort API to close cleanly. So drop the split here and land it next with the tarfs-backed test that actually exercises routing. Requesting `layering` together with `format: erofs` is now rejected during config validation instead of silently producing a single layer; BuildLayers keeps a guard for library callers that skip validation. The full pre-descope tree is preserved on the feat/apko-erofs-full branch.
Stack detected deletions by filename -- `.wh.NAME` and `.wh..wh..opq` -- which is the OCI tar layer convention, and the one thing spec §3.6 says an EROFS layer must not use. §8.1 item 9 forbids `.wh.`-prefixed names in EROFS images outright, and §3.6 mandates the kernel's own overlayfs encoding instead: a whiteout is a character device with rdev 0, and an opaque directory carries `trusted.overlay.opaque="y"`. The comment justifying the old encoding claimed it matched go-erofs Writer.Merge. It does not: Merge *consumes* `.wh.` entries from its copy source and, in its own words, "the whiteout entries themselves are not added to the image" (mkfs.go:118). So no producer emits `.wh.` names inside an EROFS image -- not apko's writer, not go-erofs -- and the reader was honoring an encoding that cannot conformantly exist. Nothing apko produces today contains a whiteout at all, so this is latent for our own images; it was wrong for anyone else's. `ls` would have shown a conformant image's whiteout devices as live entries while hiding nothing, and hidden entries the kernel would have shown. Dropping the tar convention also makes `ls` more faithful on malformed input: a file literally named `.wh.foo` is now reported as the filename it is, which is what the kernel does. §3.6 requires consumers not to fail on meaningless whiteouts, so ignoring them is sanctioned. Detection goes through the accessor interfaces go-erofs documents on its FileInfo (`Rdev() uint64`, `GetXattr(string) (string, bool)`) rather than asserting `*erofs.Stat`, so the logic stays fs.FS-generic and testable. The cheap DirEntry.Type() check gates the Info() call, so only a char device costs an inode read. Whiteouts are interpreted only when the stack has two or more layers. §7 step 6 lets a lone EROFS layer be mounted directly as a root filesystem, and a direct mount applies no overlay semantics, so a rdev-0 char device in a single-layer image is reported as the device it is. mergeDir collapses as a result: a whiteout occupies the name it deletes, so one pass over the entries suffices where the tar encoding needed separate live and tombstone maps. A layer can no longer hold both a live entry and its own tombstone, so that ambiguity disappears too. Tests: the fixture needed rdev and xattrs, which fstest.MapFS cannot express, so overlayFS wraps it and exposes the same accessors go-erofs does. TestStack_Whiteouts_RealErofsLayers then runs the same rules against images go-erofs actually wrote -- covering the case where the fixture and the real reader drift apart -- and confirms both that a rdev-0 Mknod reads back as a whiteout and that Setxattr of trusted.overlay.opaque round-trips.
ReadOCILayers required every non-final layer to carry org.erofs.role=overlay-lower and the final layer to carry none. That is apko's own producer convention, not the spec's rule, and it rejected two conformant shapes: a non-final layer with no role, and a final layer carrying overlay-lower. Per the spec, org.erofs.role is OPTIONAL on any layer descriptor (§2.3), §3.8 rule 1 says the final entry's role is "either absent or overlay-lower", and §7 step 1 classifies "role overlay-lower or absent" as an overlay lower at any position. Both spellings become the same lowerdir, so both are now accepted wherever they appear. The two roles apko does not implement -- device and overlay-data -- are refused as unsupported compositions rather than as malformed images, and an unrecognized role value is refused as unknown. With those checks in place, §3.8 rule 1 holds by construction, so the separate positional pass is gone. TestReadOCILayers_BadRoleOrder asserted the behavior being removed, so it becomes TestReadOCILayers_SpecValidRoleShapes over the three legal shapes, plus coverage of the unsupported and unknown roles. The role constants grow device and overlay-data, and their doc comments stop attributing apko's convention to spec §3.8. Also drops a pointer to "PR chainguard-dev#1", which merged. While here: the "multiple manifests match arch" error told the user to pass --tag, a flag no apko erofs subcommand has. A tag is a SOURCE:TAG suffix; the error now says that and lists what is available.
writeErofs passed erofs.WithBuildTime only when buildTime was non-zero. When the option is absent, go-erofs v0.3.1 stamps time.Now().Unix() into the superblock from Writer.Close (mkfs.go:602), so a caller who left the timestamp zero got a different digest on every build -- and got it silently, from the one code path that looks like it is asking for a default. apko's own CLI never hits this, because options.Default sets SourceDateEpoch to time.Unix(0, 0), which is not IsZero. Library callers build their own options, so they hit it exactly where they would least expect to. The option is now always passed. It cannot be passed verbatim, though: a zero time.Time has a large negative Unix seconds value that wraps when converted to uint64, so erofsBuildTime clamps a zero or pre-epoch timestamp to epoch 0. That matches what an unset SOURCE_DATE_EPOCH already means to apko, and it is reproducible either way. The test pins the zero case to an explicit epoch build rather than comparing two zero-time builds to each other: the wall-clock default has one-second granularity, so back-to-back builds would agree anyway and the test would pass against the bug.
erofs/erofs-image-spec §5.4 is a MUST in two places: the image config's top-level os.features, and the index platform descriptor's os.features when the image is referenced from an index. §6 and §8.1 producer requirement 8 reinforce it, and §8.2 item 1 has consumers refuse an image whose os.features they do not implement. Only the config half was implemented. apko always emits an index, so a consumer that filters on the index platform -- selecting a manifest before fetching any config, which is the point of the index -- never saw the signal. The obvious place to fix it, Architecture.ToOCIPlatform, has no config to read. generateIndexWithMediaType already holds the v1.Image, so it reads ConfigFile().OSFeatures and copies it onto the descriptor's platform. That is format-agnostic on purpose: os.features describes the image whatever produced it, and nothing else in apko sets it today. An image whose config declares no features still gets no OSFeatures field, so tar builds are byte-identical to before; the test pins that alongside the erofs case, since an empty-but-present slice would change every index digest.
With --format=erofs and no explicit output path, the layer blob landed at TempDir()/apko-<arch>.tar.gz -- TarballFileName's spelling. The bytes are a raw EROFS filesystem image: not a tar, not gzipped. Anything that sniffs by extension is misled, and the name reads as a bug to anyone looking in the temp directory. Options.LayerFileName takes the layer format and picks the extension, delegating to TarballFileName for tar so the two spellings cannot drift. An explicitly given TarballPath is still honored verbatim; this only affects the generated default.
Three things were true of the code but written down nowhere, so the next contributor had to rediscover them. Hardlinks are materialized as independent copies. apko's FullFS surfaces a hardlink as an ordinary second dirent -- its linkness is visible only in the *tar.Header from Sys(), which is how the tar layer path preserves it -- and go-erofs v0.3.1 has no API to point two names at one NID. There is no open issue or PR proposing one. SetNlink exists, but it only sets the reported link count without sharing the inode, so using it would make the metadata lie rather than save any space. Spec §3.7 permits materializing links (a producer must materialize or fail), so this is conformant; it is the size and st_nlink/st_ino consequences that deserve recording. Documented as a limitation too, without a measurement: no hardlink-heavy image was on hand to measure, and the arithmetic (one full copy per link, rounded to the block size) is the honest statement. The Chmod after Mkdir/Mknod/Create is a workaround for erofs/go-erofs#41, which merged 2026-08-02 -- after v0.3.1 was tagged 2026-07-21. So the pinned version has neither half of the fix. This Chmod covers the write half; the read half means FileInfo.Mode() from the pinned reader misreports setuid/setgid/sticky, and code that needs a mode must read *erofs.Stat.Mode off Sys() instead, which is what pkg/erofsmount/ls.go already does. Nothing said so, so the next caller of FileInfo.Mode() would have been bitten invisibly. Also: docs pointed at "PR chainguard-dev#1" of the spec, which merged; they now cite spec.md on main, and state that the draft phase is the spec's own description of itself rather than implying apko is behind. The os.features comment notes where the other half of §5.4 lives. Drops erofsLayer.LayerPath(), which has no callers.
Several EROFS writer tests validate their output twice: once by reading it back with go-erofs, and once with fsck.erofs from erofs-utils. The second check is deliberately optional, so contributors without the package still get a green build. No workflow installed it, so that "optional" meant "never runs anywhere". Every fsck leg degraded to a t.Log -- invisible, since make test does not pass -v -- and TestWriteErofs_FsckErofs skipped outright. The only always-on validation of a written image was go-erofs reading its own output, so a spec misunderstanding shared by writer and reader would sail through green. Installing the package in the Go Tests job activates all of it. The step asserts fsck.erofs reached PATH rather than trusting apt's exit code, because a silent install failure would look exactly like a pass. It runs unconditionally, not gated on changed paths: a writer/reader mismatch can arrive with a go-erofs bump as easily as with an apko change. harden-runner runs with egress-policy: block, so the Ubuntu archive hosts are added to allowed-endpoints. This job had no apt step before, hence no precedent for which mirror hostname the runner resolves; archive, azure.archive and security are all listed. A privileged job that builds with --format erofs, mounts the result with the kernel, and fscks the mountpoint would close the loop end to end. That belongs with the mount/umount follow-up, which is where the code it would exercise now lives.
|
Thanks for the depth here, @mattmoor — the two multi-layer findings were both Per-comment replies are inline. The three cross-cutting notes: Exported surface. All of Test seam for the mount plane. Agreed, and it's a prerequisite in #2408 rather melange coordination. Not done yet — I'd rather not comment on melange#2605 Also worth flagging two things you didn't ask about but that changed:
Verified before pushing: |
Two things surfaced the moment the fsck cross-checks first ran anywhere,
which is a decent argument for the previous commit.
TestWriteErofs_FsckErofs passed --xattrs to fsck.erofs. The erofs-utils
that Debian and Ubuntu ship rejects it outright ("unrecognized option
'--xattrs'"), so the test failed on the runner. The flag was decoration:
the comment claimed "with xattr verification" but nothing below it
asserts on xattrs -- TestWriteErofs_Xattrs covers those by reading the
image back with go-erofs. Dropped, with a note to stick to flags the
distro packages understand. docs/erofs.md told readers to run the same
command, so it loses the flag too, plus a line about checking --help
since availability varies by release.
newErofsLayerFile is unused now that splitErofsLayers has gone to the
layering follow-up; the single-image path creates its output file
directly. Removed. It is preserved on feat/apko-erofs-full along with
its caller.
Worth noting for anyone else running the linter locally: a stale
golangci-lint cache reported "0 issues" here while CI failed on that
unused function. `golangci-lint cache clean` first, or trust CI.
mattmoor
left a comment
There was a problem hiding this comment.
Approving the descoped scope. All in-scope fixes from the review verified at 32214b1: always-stamped build time with the pre-epoch clamp, os.features on both the config and the index platform descriptor, spec-legal role shapes accepted, the §3.6 whiteout encoding in Stack pinned against real go-erofs layers, the .erofs default name, the documented hardlink and go-erofs v0.3.1 caveats, and erofs-utils running the fsck cross-checks in CI (which caught the --xattrs portability issue on their first run, a good sign the check earns its keep). The descope is clean: no dangling references to the mount plane or the splitter anywhere, and #2408 accurately tracks the deferred findings.
Two non-blocking notes left on reopened threads: the index digest change for tar builds atop base images whose config carries os.features deserves an explicit ack or a pinning test, and the ls merged view has two pre-existing kernel-overlayfs divergences worth a docs sentence or a #2408 item.
erofs: first batch of #2408 follow-ups The #2408 follow-ups that touch merged `main` only, so none of them wait on the descoped mount/umount or layering code coming back. Two behavior changes, two tests, two rewords. Both overlay tombstone checks now fail closed `isOpaqueDir` returned false whenever `statOn` failed, so a layer whose xattr region could not be read was treated as one that hides nothing. That fails *open*: the entries an opaque directory was meant to hide from lower layers get listed anyway, and nothing tells the caller the answer was a guess. Layers come from an OCI image that may be untrusted, so a damaged or hostile one should not be able to widen the merged view by being unreadable. It now returns `(bool, error)` and propagates anything that is not `fs.ErrNotExist` -- absent is the one benign case, since a layer that lacks the directory genuinely is not opaque there. `isWhiteout`, a screen above it, had the same shape: false on an `e.Info()` error. That failure mode is milder -- an unreadable char device is treated as live, so it occupies the name rather than leaking lower entries -- but it is still a guess about what the merged view shows, made from an inode that could not be read. It gets the same `(bool, error)` treatment, so the two checks no longer sit next to each other with opposite error philosophies. Both callers, `lookup` and `mergeDir`, already returned errors. The opacity test puts the unreadable layer in the *middle* of three on purpose: that is the only position where `isOpaqueDir` is the first thing to fail. With the broken layer on top, `lookup`'s ancestor stat raises first and the fixture would pass either way. Against the old code it reports `ReadDir(etc) = [foo secret], <nil>` -- the lower layer's `secret` leaking into the merged view. The whiteout test fails the matching way, listing the char device as a live `secret`. `application/vnd.erofs+zstd` gets an accurate error A compressed layer is a spec-legal EROFS image apko cannot read yet, but it hit the mediaType check and was told the command "only handles EROFS images". Now any `application/vnd.erofs+<codec>` names its codec and points at #2406. Matching on the suffix rather than adding a media-type constant is deliberate: the draft spec's set of codecs isn't something apko should pin down in `pkg/build/types` to produce one error message. `os.features` propagation from a base image config, pinned `generateIndexWithMediaType` copies the finished config's `os.features` onto the index platform descriptor for every format, and `BuildImageFromLayers` DeepCopies the base image's config. So a plain *tar* build on a base image that already declares `os.features` surfaces them on the index descriptor, changing that index's digest. Spec §5.4 asks for this and #2249's squash message calls it out, but the test added there covered only the empty-base case. Also `require.Empty` -> `require.Nil` for the "tar builds declare nothing" assertion, which is what it means to check -- that the propagation doesn't invent an empty slice. Harmless either way: `os.features` carries `omitempty` in go-containerregistry, so an empty-but-present slice can't change a digest. Two claims softened to match reality §3.7 was cited too strongly. Its materialize-or-fail rule governs *cross-layer* hardlinks; for links within one layer the spec is silent, so apko materializing them is conformant but not blessed by that section. #2249's squash message has this right; `pkg/build/erofs.go` and `docs/erofs.md` did not. `docs/erofs.md` promised kernel parity for `apko erofs ls` ("the merged view the kernel would assemble"). It approximates it, and diverges in two corners: a middle-layer whiteout at a directory's own name doesn't cut off lower layers when a higher layer recreates the directory, and opacity isn't inherited by descendant directories. Both need 2+ layers, so neither is reachable for an image apko produces today. The doc now names them and links #2408, where the algorithm fix (a per-directory layer horizon) is tracked. `go test ./...` passes, `golangci-lint run -n` reports 0 issues, `gofmt -l` is clean. Refs #2408 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
First of the section 1 items from #2408, and the one that makes the rest of them testable. ### Why Every EROFS check that exists today runs unprivileged and reads the image back with go-erofs — the same library that wrote it. That arrangement cannot see a misunderstanding shared by our writer and our reader. `fsck.erofs` appears in `pkg/build/erofs_test.go` as an optional second opinion, but nothing anywhere has ever handed an apko-produced EROFS layer to the **kernel** driver. The pre-descope tree in #2249 didn't either — grepping `.github/` on `feat/apko-erofs-full` for "erofs" returns nothing. So this is new work rather than a restore. ### What it does `hack/test-erofs.sh` follows the recipe `docs/erofs.md` already publishes: build into an OCI layout with `--format=erofs`, resolve the layer blob through `index.json` and the manifest, then 1. assert the layer mediaType is `application/vnd.erofs`, and that the blob's filename matches sha256 of its own bytes — the layer is stored uncompressed, so `digest == diffID`, and a compression step creeping into this path fails here; 2. `fsck.erofs -d3` and `dump.erofs` — the C implementation's opinion; 3. `mount -t erofs -o ro` — the kernel's opinion; 4. compare `apko erofs ls` against the mounted tree: path, mode string, uid/gid and symlink target, for every entry. Step 4 is the point of the job. On `examples/wolfi-base` it covers **395 entries**, including a sticky directory (`tmp`), five char devices with rdev, 237 symlinks and several non-root uid/gid pairs. setuid and setgid aren't present in that fixture; the writer's handling of those stays covered by the optional `fsck.erofs` checks in `pkg/build/erofs_test.go`. The comparison is verified to have teeth: fed a listing with the sticky bit rewritten to `drwxrwxrwx`, the job fails with ``` -drwxrwxrwt 0/0 tmp +drwxrwxrwx 0/0 tmp 'apko erofs ls' disagrees with the kernel about the layer contents ``` That is the class of bug the go-erofs bump in #2412 was about — `Mkdir` dropping setuid/setgid/sticky on write and `FileInfo.Mode()` misreporting them on read. ### Job details `erofs` is a module rather than built in, and the runner image doesn't ship every module, so the workflow tries `modprobe` first and only downloads `linux-modules-extra-$(uname -r)` if the module is genuinely absent. It fails in that step rather than inside the script, where an absent driver would read as a mount bug. There is no silent skip — an unavailable driver is a red job, not a quiet pass. The script installs nothing itself, so it stays usable locally. Package installation lives in the workflow. `sudo` on `ubuntu-latest` is already established practice here: `go-tests.yaml` installs `erofs-utils` the same way. ### Verification Ran end-to-end in a privileged `ubuntu:24.04` container against a real kernel mount, not just dry-read: `fsck.erofs` reports "No errors found", 395 entries agree, umount is clean. Plus the negative test above. `shellcheck -e SC2129` clean (matching the actionlint config), `actionlint` clean, `zizmor --persona pedantic --config .github/zizmor.yml` reports no findings. ### Not in this PR The rest of section 1 of #2408: restoring `apko erofs mount`/`umount` with state-file containment, partial-unmount recovery, a read-only default and a `Driver` test seam. That work is deliberately second — this job only exercises what is already on `main`, so it can land green now, and once it is here the restored `mount` has somewhere to be tested. It will extend this job rather than add another. Refs #2408 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Restores the `apko erofs mount` and `apko erofs umount` surface that 1fe041e descoped so chainguard-dev#2249 could land as a writer plus `ls`: the mount_linux.go orchestration, the kernel/fuse driver, the mount state file, the non-linux stubs, and their tests. Nothing here had callers outside the package, so it comes back private: the driver interface, newDriver, resolveMode, the mode type and its constants, mountState, stateSchemaVersion, statePath, writeState, loadState and removeState. Options is replaced by an exported MountOptions holding plain strings, which is all the CLI needs to say: Mount(ctx, src, dest string, opts MountOptions) error Mount returns only an error now. It used to hand back *MountState, but that type is private and the CLI discarded it anyway. Ls keeps the signature it has on main -- a plain arch string, not an Options bag -- and does not get its `--mode` flag back. That flag was documented as "accepted for symmetry with mount and ignored", which is not worth restoring. This commit deliberately restores behavior that is known to be wrong; the rest of the PR fixes it. In this state Unmount still passes whatever paths the state file names to umount as root without checking them, a partial unmount cannot be recovered by rerunning the command, and the default mount is writable and discards what was written on umount. The docs' overlayfs section is not restored: it was removed by e5778f1, the layering descope, and its recipe only works once multi-layer splitting returns. Refs chainguard-dev#2408 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Section 1 of #2408: restores the mount/umount subcommands descoped from #2249, with the four bugs that review found fixed, and four more found in review here. `apko erofs mount SOURCE DEST` mounts a raw EROFS blob or an OCI image directory, choosing a kernel mount or erofsfuse by effective UID, and `apko erofs umount DEST` tears it back down from a state file it wrote. The restored surface is package-private: Mount, Unmount and MountOptions are the only new exports, with the driver interface, the mode type and the state-file helpers all unexported. A driverFactory seam lets the ~300 lines of orchestration run against a fake, which nothing could do before. Fixes carried over the descope: - Validate the state file before unmounting. Unmount handed every path it named to umount as root, having checked only JSON shape and schema version, so anyone able to write to dest could have root unmount /home. Entries are now whitelisted to the paths a mount creates, and unmounting goes through umount(2) with UMOUNT_NOFOLLOW, so a symlink planted at <dest>/merged cannot redirect it either -- umount(8) canonicalizes, which no check-then-exec can defend against. A symlinked parent is caught by a separate check whose residual race is documented rather than papered over. - Make a partial unmount recoverable. Entries are dropped as they come down and the file is rewritten with what is left, so a rerun does not restart at a mountpoint that already came down and fail there. - Mount read-only by default. --read-only becomes --rw, so the zero value is the safe one, and umount uses os.Remove on upper rather than os.RemoveAll: it cannot delete a non-empty directory, so writes made through a mount survive by construction. And from review here: overlayfs option paths are escaped, fuse-mode umount reports both failures instead of swallowing the kernel one, and dest is claimed with O_CREATE|O_EXCL rather than stat-then-write. hack/test-erofs.sh drives all of it against a real kernel in the privileged EROFS job -- read-only and --rw round trips, a raw blob, and three tampered-state cases including a symlink aimed at a live tmpfs, which is the only thing that actually exercises UMOUNT_NOFOLLOW. Refs #2408
go-erofs grew Writer.Link, which gives a second name the same fsInode as the first and keeps nlink for us. apko was still writing every link as an independent copy, because when chainguard-dev#2249 landed there was no API for it -- SetNlink sets the reported count without sharing the inode, so it would only have made the metadata lie. A hardlink reaches the writer as an ordinary second dirent; its linkness is only in the *tar.Header from Sys(), which pkg/tarfs fills in from the apk it unpacked. hardlinkTarget reads it there, the same place the tar layer path finds it via tar.FileInfoHeader. A rootfs read back off disk (apkfs.MemFS, rwosfs) records nothing, so those keep getting a copy per name, as before. Links are held back until the walk finishes. Writer.Link needs the target to exist, and fs.WalkDir is lexicographic, so /usr/bin/[ arrives long before /usr/bin/coreutils. A link whose target is missing from the image falls back to a copy: that is the cross-layer case, where §3.7 requires materialize-or-fail and apko materializes. It cannot happen in a single-layer image, but layer splitting is what puts a link and its target in different writers. Nothing is re-applied to a shared inode -- mode, ownership, timestamps and xattrs came with it. Tests build their fixture with pkg/tarfs, because apkfs.MemFS cannot express a hardlink, and cover the shared inode and nlink, the materialize fallback against a writer that lacks the target, and Linkname cleaning. Against the previous code the three names report nlink 1 with distinct inodes, and three extra names for a 64K file grow the image by exactly 192K. Refs chainguard-dev#2408
#2418) Section 2 of #2408: brings back the EROFS layer split, descoped in e5778f1 so #2249 could land as a single-layer writer plus `ls`, with the five bugs review found fixed on top. #2415 has since merged, and this is rebased on it. The code is still disjoint — that PR is `pkg/erofsmount` and the CLI, this one is `pkg/build` — they meet only in `hack/test-erofs.sh`. ### 1. Restore, unchanged `pkg/build/erofs_layers.go` and its test come back exactly as they were, and the `layering` + `format: erofs` rejection leaves `ImageConfiguration.Validate` and `buildLayers`. This commit deliberately restores the known-broken behavior; the four after it are the fix, and that delta is the point of the PR. ### 2. Package routing — the bug that inverted the whole thing The type assertion asked `info.Sys()` for a `Package()` method. tarfs, which is what a real build walks, returns a fresh `*tar.Header` from `Sys()` and hangs `Package()` off the FileInfo itself — the receiver `splitLayers` asserts on. So it never succeeded outside a fixture: every file landed in the top layer, each group layer held only ancestor directories plus a partial installed db (that case keys on the path string, so it still fired), and per-layer scanners read a db naming packages whose files were not there. Assert on `info`, and turn a `packageToWriter` miss into an error rather than a silent fall back to `top`. `TestSplitErofsLayers` could not catch this: it drove the split through `apkfs.NewMemFS()`, which implements `Package()` nowhere, so the fixture had zero package-owned files by construction and passed identically with and without routing. It is replaced by `erofs_layers_test.go`, which builds its fixture with `pkg/tarfs` and installs files through `WriteHeader` with an `*apk.Package`, the way an apk install does. Its routing assertions fail against the previous code. ### 3. Directory-only subtrees Directories were recorded during the walk and materialized only by `emitAncestors`, which runs for non-directory entries. A directory whose subtree holds no file was therefore never written anywhere: `/tmp`, `/run`, `/var/empty`, every empty dir and every mount point were absent from the merged view. Each directory is now emitted into its owning writer as it is walked, which is what both siblings already do. ### 4. Temp files and fds No error return out of `splitErofsLayers` closed or removed the per-layer temp files, and each go-erofs `Writer` also holds an unlinked spool fd that only `Close` releases. The CLI happened to be bounded by its `MkdirTemp`/`RemoveAll` wrapper; a library caller accumulated both until process exit. The per-writer state moves into an `erofsGroupWriter` type with `finish`/`discard`, and a deferred sweep discards every writer unless the function reaches its return. Still no upstream abort API, so `discard` writes an image it then removes. ### 5. Build time `newErofsGroupWriter` passed `WithBuildTime` only for a non-zero build time — the exact case `erofsBuildTime`'s comment describes, where go-erofs stamps `time.Now()` from `Close` and a library caller gets different layer digests every build. It now uses `erofsBuildTime`, same as `writeErofs`. apko's own CLI was never affected. ### 6. Drive it from `hack/test-erofs.sh` The privileged job from #2414, extended by #2415, only built and mounted a single layer, because that was all apko could produce. The script now builds the same config again with `layering` and adds, after the existing comparisons: - layer roles and mediaTypes across the manifest, and `digest` == the sha256 of each blob; - `fsck.erofs` on every layer, and a kernel mount of every layer stacked as read-only overlayfs lowerdirs; - `apko erofs ls` on the layered OCI directory diffed against that overlay mount — `Stack`'s merge only becomes reachable for apko's own output now that splitting is back, and this is the first thing to compare it with what the kernel assembles; - every non-final layer must hold a regular file other than the partial installed db, which is exactly the shape the routing bug produced; - the merged tree diffed against a **tar** build of the same config unpacked in layer order. The tar split is the reference for what splitting must preserve, and a directory that reaches no layer shows up here and nowhere else. Both builds resolve from one `apko lock` output so a package published between them cannot make them differ. The unpack uses `--numeric-owner`; without it GNU tar resolves each header's uname/gname against the runner's `/etc/passwd` and invents ids for `lp`, `mail`, `news` and `uucp`. The layered section runs last, after #2415's `apko erofs mount` / `apko erofs umount` checks, and reuses their helpers — `fail`, `assert_mounted`, `normalize_ls`, `tree_listing`. Their `cleanup` already unmounts everything under the workdir deepest-first, so the layer and overlay mounts need no separate bookkeeping. The one existing line this touches is the whitespace-in-paths guard, lifted into a `check_ls_whitespace` function so the layered listing gets it too. ### Not in this PR Whiteout support in `Stack` — the last item in section 2 — is really the layer-horizon fix from section 5, and is reader-side and independently testable. Left for its own PR. ### Verification `gofmt -l` clean, `golangci-lint run -n` reports 0 issues, `go build ./...` and `GOOS=darwin go build ./...` both succeed, and `SOURCE_DATE_EPOCH=0 go test ./...` passes. `shellcheck` is clean on the script. Each fix was checked to fail without its change: the four new tests are red against the restore commit and green after their own commit. Locally, a layered `wolfi-base` build produces five layers whose merged `apko erofs ls` listing is identical to the single-layer build's, and whose non-top layers are 0.7–7.4 MB rather than the directories-only skeletons the routing bug produced. Refs #2408 🤖 Generated with [Claude Code](https://claude.com/claude-code)
go-erofs grew Writer.Link, which gives a second name the same fsInode as the first and keeps nlink for us. apko was still writing every link as an independent copy, because when chainguard-dev#2249 landed there was no API for it -- SetNlink sets the reported count without sharing the inode, so it would only have made the metadata lie. A hardlink reaches the writer as an ordinary second dirent; its linkness is only in the *tar.Header from Sys(), which pkg/tarfs fills in from the apk it unpacked. hardlinkTarget reads it there, the same place the tar layer path finds it via tar.FileInfoHeader. A rootfs read back off disk (apkfs.MemFS, rwosfs) records nothing, so those keep getting a copy per name, as before. Links are held back until the walk finishes. Writer.Link needs the target to exist, and fs.WalkDir is lexicographic, so /usr/bin/[ arrives long before /usr/bin/coreutils. A link whose target is missing from the image falls back to a copy: that is the cross-layer case, where §3.7 requires materialize-or-fail and apko materializes. It cannot happen in a single-layer image, but layer splitting is what puts a link and its target in different writers. Nothing is re-applied to a shared inode -- mode, ownership, timestamps and xattrs came with it. Tests build their fixture with pkg/tarfs, because apkfs.MemFS cannot express a hardlink, and cover the shared inode and nlink, the materialize fallback against a writer that lacks the target, and Linkname cleaning. Against the previous code the three names report nlink 1 with distinct inodes, and three extra names for a 64K file grow the image by exactly 192K. Refs chainguard-dev#2408
…nts (#2422) Two loose ends from #2408, both falling out of the go-erofs bump in #2412. Rebased onto main now that #2418 (multi-layer splitting) has landed; the two overlap only in one `docs/erofs.md` bullet, merged here. ### 1. The mode-bit comments are stale `pkg/build/erofs.go` described the `Chmod` after `Mkdir`/`Mknod`/ `Create` as a workaround for the pinned go-erofs, to revisit "once a release containing erofs/go-erofs#41 is out". #2412 bumped the pin past that merge, so both halves of the claim are now wrong. Probing the pinned version: - `Mkdir` with setuid/setgid/sticky: claimed to drop them, actually keeps them. - `FileInfo.Mode()` on read: claimed untrustworthy for those bits, actually reports them. **The `Chmod` still has to stay**, for a reason that has nothing to do with #41 and is not going away: `Writer.Create` takes no mode argument at all, so every regular file starts life `0644`. `Mkdir` and `Mknod` do take one, but apko hands them `mode.Perm()` and lets the single `Chmod` cover all three rather than splitting the rule across three call sites. So this is a comment change, not a code change. `pkg/erofsmount/ls.go` keeps reading `*erofs.Stat` off `Sys()` -- `fs.FileInfo` has nowhere to put a uid or a device number -- but its comment justified that with the setuid claim, which no longer holds. `TestWriteErofs_SpecialModeBits` now also asserts `FileInfo.Mode() == Stat.Mode` for every case it covers (setuid and setgid regular files, a sticky directory, a char device, a symlink), which pins the half that changed. ### 2. Hardlinks point at one inode go-erofs grew `Writer.Link(oldname, newname)`, which gives a second name the same `fsInode` as the first and maintains `nlink`. apko was still materializing every hardlink as an independent copy, because when #2249 landed there was no API for it -- `SetNlink` sets the reported count without sharing the inode, so it would only have made the metadata lie. A hardlink reaches the writer as an ordinary second dirent; its linkness lives only in the `*tar.Header` from `Sys()`, which `pkg/tarfs` fills in from the apk it unpacked. `hardlinkTarget` reads it from there -- the same place the tar layer path finds it via `tar.FileInfoHeader`. A rootfs read back off disk (`apkfs.MemFS`, `rwosfs`) records nothing, so those keep getting a copy per name, exactly as before. Four details worth review: - **Links are held back until the walk finishes.** `Writer.Link` needs the target to already exist and `fs.WalkDir` is lexicographic, so `/usr/bin/[` arrives long before `/usr/bin/coreutils`. - **A link whose target the writer cannot find under the Linkname it was handed falls back to a copy.** Spec §3.7 leaves materialize-or-fail to the producer and apko materializes. This is *not* a cross-layer case: the #2418 split walk never calls into this path, so a layered build materializes every hardlink in every layer regardless. What reaches the fallback in a single-layer build: a target a `paths` directive removed, a Linkname routing through a symlinked directory component (the writer's lookup is a flat path map and follows nothing, while tarfs resolved the name at unpack), and a chain of links, whose middle name is deferred rather than emitted and so may not exist yet when the last one is linked. - **A directory target aborts the build, deliberately.** `Writer.Link` returns `ErrIsDirectory`, and `pkg/tarfs`'s `link()` has no type check on the target, so a crafted apk gets that far. `link(2)` refuses a directory hardlink too, and the copy fallback would duplicate the whole subtree. - **A link name binds to the target path in the image, not to the node the rootfs resolved.** A Linkname landing on a symlink shares the symlink; a target a later package replaced binds to the replacement. That is what `link(2)` does when the same rootfs is replayed from a tar layer, so the two layer formats agree -- the independent copies apko used to write were the outlier. A Linkname that still holds `..` after the Clean is rejected outright and written from `fsys` instead; nothing downstream catches it (go-erofs's `cleanPath` re-roots `/../etc/passwd` to `/etc/passwd`, and `checkPath` only rejects duplicates). Nothing is re-applied to a shared inode -- mode, ownership, timestamps and xattrs came with it, and the tests assert mode, uid, gid and an xattr through every link name. ### Verification `gofmt -l` clean, `golangci-lint run` reports 0 issues, `go build ./...` and `GOOS=darwin go build ./...` both succeed, `SOURCE_DATE_EPOCH=0 go test ./...` passes. `git rebase --exec` confirms every commit in the series builds and vets on its own. The behavioural tests were checked to fail without their fix. Making `hardlinkTarget` always return false: ``` Error: Not equal: Messages: link count Error: "196608" is not less than "32768" three hardlinks grew the image by 196608 bytes, which looks like copied data ``` 196608 is exactly three more copies of the 64K fixture file. With the change the three names share one inode, report `nlink` 3, and `fsck.erofs` accepts the image. Likewise: reverting the `ErrNotDirectory` arm fails `TestWriteErofs_HardlinkThroughSymlinkedDirIsCopied` with `link ...: not a directory`; dropping the `..` rejection fails `TestHardlinkTarget`; and adding `ErrIsDirectory` to the fallback gate fails `TestEmitErofsHardlinks_DirectoryTargetAborts`. Refs #2408 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Implement apko writing of erofs images according to the spec at
https://github.com/erofs/erofs-image-spec (
spec.mdonmain; section numbersin the code and docs refer to it). The spec is still in its draft phase — no
tagged release, and it says media types, annotation keys and the chunk-index
layout may change before the first stable one — so EROFS support here is
experimental.
The docs/erofs.md file in the pr explains the use and erofs image layout.
Scope: this PR adds the single-image writer (
--format=erofs, one layer) andapko erofs ls. Multi-layer splitting andapko erofs mount/umountweredescoped after review — see #2408 — and land next, in that order. Combining
layeringwithformat: erofsis rejected during config validation rather thansilently producing a single layer.
I've opened a draft PR showing the value of erofs in images.
See chainguard-dev/melange#2605 for example use of erofs support in apko by melange.
Related:
Review round 2 (2026-08-18)
Addressing @mattmoor's review at 46ac139. Descoped, per his suggestion, the two
areas where the findings were concentrated; the rest is fixed here.
Fixed in this PR:
Stackdetecteddeletions by filename (
.wh.NAME,.wh..wh..opq), which §8.1 item 9 forbidsin EROFS images outright; §3.6 requires a char device with rdev 0 plus
trusted.overlay.opaque="y". The comment justifying the old encoding claimedit matched go-erofs
Writer.Merge, butMergeconsumes.wh.entries fromits source and never writes them into an image — so no producer emits them and
the reader was honoring an encoding that cannot conformantly exist. Whiteouts
are interpreted only when the stack has two or more layers, since §7 step 6
lets a lone layer be mounted directly, applying no overlay semantics.
ReadOCILayersaccepts the role shapes the spec allows. Role-absentnon-final layers and
overlay-lowerfinal layers are both conformant (§2.3,§3.8 rule 1, §7 step 1) and were both rejected.
deviceandoverlay-dataarenow refused as unsupported compositions rather than as malformed images.
WithBuildTimewas passed only for anon-zero timestamp, and go-erofs stamps
time.Now().Unix()when it is absent —so a library caller passing a zero
time.Timegot a different digest on everybuild. It cannot be passed verbatim (a zero
time.Timehas a negative Unixseconds value that wraps through
uint64), so zero and pre-epoch clamp toepoch 0.
os.featuresreaches the index platform descriptor. §5.4 is a MUST in twoplaces and only the config half was implemented, so consumers filtering on the
index — before fetching any config — never saw the signal.
.erofs, notapko-<arch>.tar.gz.erofs-utils, so thefsck.erofscross-checks actually run.Without it every one degraded to a
t.LogandTestWriteErofs_FsckErofsskipped, leaving go-erofs reading its own output as the only always-on
validation.
pinned version predates Fix setuid/setgid/sticky bits dropped on read and in Mkdir erofs/go-erofs#41, so
FileInfo.Mode()misreportssetuid/setgid/sticky and callers must read
*erofs.Stat.ModeoffSys().erofsLayer.LayerPath(), which had no callers. The exportedstate/driver surface went away with the mount descope, which also means the
package no longer has a build-tag-dependent API — everything left is pure Go
and builds everywhere.
spec.md, and four places that presented apko's producer convention as a specrule now say which is which.
Moved to #2408, with the code preserved on the
feat/apko-erofs-fullbranch ofsmoser/apko so nothing is reconstructed from scratch:
being dropped from every layer — plus the
tarfs-backed test that would havecaught the first, and the temp-file/fd leak on error paths.
Unmount, the rerun instruction that cannot work, thewritable-by-default mount that discards
upper, and aDriverseam so theorchestration is testable at all.
Not done here: a size measurement on a hardlink-heavy image. I had no such image
on hand, so
docs/erofs.mdstates the arithmetic — one full copy per link,rounded to the block size — rather than a number I did not measure.