tarfs: keep mode bits and ownership from tar headers - #2455
Open
smoser wants to merge 2 commits into
Open
Conversation
WriteHeader's TypeDir branch created the directory with
hdr.FileInfo().Mode().Perm(), which masks to 0o777 and so dropped
setuid/setgid/sticky, and it never applied hdr.Uid/hdr.Gid at all.
Every package-owned directory landed as root:root with its special
bits gone.
The loss is format-neutral, because both writers read the same node:
pkg/build/tarball.go builds output headers with tar.FileInfoHeader,
which takes the special bits from FileInfo.Mode() and Uid/Gid from
FileInfo.Sys(), and pkg/build/erofs.go reads that same Sys() header.
Wolfi's postfix is a live example. Its apk ships 15 directories under
/var/spool/postfix and /var/lib/postfix owned by uid 100, four of them
also gid 100 or 101; an `apko build` of it emitted all 15 as 0:0.
MkdirAll carries only permission bits, so the special bits need a
separate Chmod. Both the Chmod and the Chown are restricted to
directories this header actually created:
- Ancestors MkdirAll had to invent are not described by this header,
so they must not inherit its ownership.
- InitDB creates /tmp as 1777 before any package installs, and
packages ship a tmp directory header with no sticky bit
(wolfi-baselayout intends 1777 but the apk records 0777), so
applying dir modes unconditionally would take the sticky bit off
/tmp in every wolfi-based image.
Reported by mattmoor in chainguard-dev#2418.
The same gap the previous commit fixed for directories applies to everything writeHeader creates: the node was built from the tar header's mode but never its Uid/Gid, so every packaged file and symlink was serialized as root:root. For a setgid binary that is a privilege change, not just cosmetics. Wolfi's postfix ships /usr/bin/postdrop and /usr/bin/postqueue as mode 0o2755 gid 101, i.e. setgid to the unprivileged "postdrop" group. An `apko build` of it emitted mode 0o2755 gid 0 -- setgid to root. The golden fixtures move because of this. The apks under internal/cli/testdata/packages declare their files as uid 501 gid 20, the account on the machine that built them, and apko now reproduces that faithfully; /etc/os-release is the only entry whose metadata changes in either golden image. testdata/golden was rebuilt with the same options TestBuild passes (verified byte-identical to the committed copy when built against the unfixed code), and testdata/top_image with internal/cli/testdata/regenerate_golden_top_image.sh.
This was referenced Sep 2, 2026
smoser
enabled auto-merge (squash)
September 3, 2026 02:20
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Follow-up to a finding mattmoor made while reviewing #2418:
Confirmed, and it turned out to be broader than directories.
What was wrong
pkg/tarfs/fs.gonever carried tar-header metadata onto its nodes:MkdirAll(name, hdr.FileInfo().Mode().Perm())—.Perm()masks to 0o777, so setuid/setgid/sticky were dropped0:0The loss is format-neutral, as Matt said, because both writers read the same node:
pkg/build/tarball.gobuilds output headers withtar.FileInfoHeader, which takes special bits fromFileInfo.Mode()and Uid/Gid fromFileInfo.Sys(), andpkg/build/erofs.goreads that sameSys()header.Impact, measured
An
apko buildof a wolfi postfix image, diffed against what the apk actually declares:/var/spool/postfixand/var/lib/postfixare uid 100 in the apk (four of them also gid 100 or 101). The built image had all 15 as0:0./usr/bin/postdropand/usr/bin/postqueueare mode0o2755gid 101 — setgid to the unprivilegedpostdropgroup. apko emitted mode0o2755gid 0, i.e. setgid to root. That is a privilege change rather than cosmetics, which is why this PR does not stop at the directory half.After the fix all 17 entries match the apk, and nothing else in the image moves.
apko erofs lson an--format erofsbuild of the same config agrees.The change
Two commits, one per half:
tarfs: keep dir mode bits and ownership from tar headers—MkdirAllas before for the permission bits, then a separateChmodfor setuid/setgid/sticky, plus aChown.tarfs: keep file and symlink ownership from tar headers— setuid/gidfrom the header whenwriteHeaderbuilds the node.One deliberate subtlety in the first commit: the
ChmodandChownapply only to directories that header actually created.InitDBcreates/tmpas 1777 before any package installs, and packages ship atmpdirectory header with no sticky bit —wolfi-baselayoutrunschmod 1777, but the published apk records plain0777(checked at the raw tar-header bytes). Applying dir modes unconditionally would therefore take the sticky bit off/tmpin every wolfi-based image.MkdirAllhas to invent are not described by the header, so they must not inherit its ownership.Both cases are covered by tests. Each of the four new assertions was mutation-checked — back out either half of the fix, or the newly-created-only condition, and the tests fail.
Golden fixtures
The second commit moves the golden images, and the reason is worth stating: the apks under
internal/cli/testdata/packagesdeclare their files as uid 501 gid 20, the account on the machine that built them, and apko now reproduces that faithfully./etc/os-releaseis the only entry whose metadata changes in either golden image (verified by extracting and diffing the layer entries).testdata/goldenwas rebuilt with the same optionsTestBuildpasses, after first confirming that command reproduces the committed golden byte-identically when built against the unfixed code.testdata/top_imagewas rebuilt with the existinginternal/cli/testdata/regenerate_golden_top_image.sh.go test ./...andmake lintare clean.Deliberately not in this PR
installAPKFilesinpkg/apk/apk/install.gohas the identical.Perm()mask and no chown at all, for files as well as directories. In-tree the consumer isapko build-cpio, which runsBuildLayeragainstapkfs.DirFS; melange's package builds are not affected, since those usetarfs.New()and are covered by this PR.wolfi-baselayoutasks for 1777 on/tmpand/var/tmp; the published r29 apk records0777for both./tmpgets 1777 in images today only because apko'sbaseDirectoriessets it, which is why/var/tmpships as0777.InitDB, also in apk: streaming install path drops directory mode bits and all uid/gid #2456:stat.Mode().Perm() != e.permscompares a maskedPerm()against0o777|fs.ModeSticky, so it can never be equal for/tmp. If/tmpever pre-exists atInitDBtime, that errors with "incorrect permissions". I did not establish a reachable caller, so I left it alone.🤖 Generated with Claude Code