Skip to content

tarfs: keep mode bits and ownership from tar headers - #2455

Open
smoser wants to merge 2 commits into
chainguard-dev:mainfrom
smoser:fix/tarfs-dir-metadata
Open

tarfs: keep mode bits and ownership from tar headers#2455
smoser wants to merge 2 commits into
chainguard-dev:mainfrom
smoser:fix/tarfs-dir-metadata

Conversation

@smoser

@smoser smoser commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Follow-up to a finding mattmoor made while reviewing #2418:

Found while probing, pre-existing and format-neutral (separate-issue material): tarfs WriteHeader(TypeDir) drops setgid/sticky and uid/gid from tar dir headers — both the tar and erofs paths serialize the degraded node state equally.

Confirmed, and it turned out to be broader than directories.

What was wrong

pkg/tarfs/fs.go never carried tar-header metadata onto its nodes:

before
dir mode MkdirAll(name, hdr.FileInfo().Mode().Perm()).Perm() masks to 0o777, so setuid/setgid/sticky were dropped
dir uid/gid never applied, so every package-owned directory landed 0:0
file and symlink uid/gid never applied either

The loss is format-neutral, as Matt said, because both writers read the same node: pkg/build/tarball.go builds output headers with tar.FileInfoHeader, which takes special bits from FileInfo.Mode() and Uid/Gid from FileInfo.Sys(), and pkg/build/erofs.go reads that same Sys() header.

Impact, measured

An apko build of a wolfi postfix image, diffed against what the apk actually declares:

  • 15 directories under /var/spool/postfix and /var/lib/postfix are uid 100 in the apk (four of them also gid 100 or 101). The built image had all 15 as 0:0.
  • /usr/bin/postdrop and /usr/bin/postqueue are mode 0o2755 gid 101 — setgid to the unprivileged postdrop group. apko emitted mode 0o2755 gid 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 ls on an --format erofs build of the same config agrees.

The change

Two commits, one per half:

  1. tarfs: keep dir mode bits and ownership from tar headersMkdirAll as before for the permission bits, then a separate Chmod for setuid/setgid/sticky, plus a Chown.
  2. tarfs: keep file and symlink ownership from tar headers — set uid/gid from the header when writeHeader builds the node.

One deliberate subtlety in the first commit: the Chmod and Chown apply only to directories that header actually created.

  • InitDB creates /tmp as 1777 before any package installs, and packages ship a tmp directory header with no sticky bit — wolfi-baselayout runs chmod 1777, but the published apk records plain 0777 (checked at the raw tar-header bytes). Applying dir modes unconditionally would therefore take the sticky bit off /tmp in every wolfi-based image.
  • Ancestors MkdirAll has 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/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 (verified by extracting and diffing the layer entries).

testdata/golden was rebuilt with the same options TestBuild passes, after first confirming that command reproduces the committed golden byte-identically when built against the unfixed code. testdata/top_image was rebuilt with the existing internal/cli/testdata/regenerate_golden_top_image.sh.

go test ./... and make lint are clean.

Deliberately not in this PR

  • The streaming install path has the same gap, filed as apk: streaming install path drops directory mode bits and all uid/gid #2456. installAPKFiles in pkg/apk/apk/install.go has the identical .Perm() mask and no chown at all, for files as well as directories. In-tree the consumer is apko build-cpio, which runs BuildLayer against apkfs.DirFS; melange's package builds are not affected, since those use tarfs.New() and are covered by this PR.
  • melange drops sticky/setgid on directory headers, filed as retrieveWorkspace drops setuid/setgid/sticky from directory headers melange#2642. wolfi-baselayout asks for 1777 on /tmp and /var/tmp; the published r29 apk records 0777 for both. /tmp gets 1777 in images today only because apko's baseDirectories sets it, which is why /var/tmp ships as 0777.
  • A latent bug in InitDB, also in apk: streaming install path drops directory mode bits and all uid/gid #2456: stat.Mode().Perm() != e.perms compares a masked Perm() against 0o777|fs.ModeSticky, so it can never be equal for /tmp. If /tmp ever pre-exists at InitDB time, that errors with "incorrect permissions". I did not establish a reachable caller, so I left it alone.

🤖 Generated with Claude Code

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant