Skip to content

Commit 70f895f

Browse files
JAORMXclaude
andcommitted
Preserve uid/gid best-effort during tar extraction
Add bestEffortLchown() that calls os.Lchown after extracting each tar entry (dirs, files, symlinks, hardlinks). When running as non-root, EPERM is silently ignored so extraction still succeeds. This fixes rootfs ownership issues where all files inherited the extracting user's uid/gid instead of preserving tar header values. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent ec36445 commit 70f895f

2 files changed

Lines changed: 87 additions & 0 deletions

File tree

image/pull.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,8 +316,13 @@ func extractTarEntry(tr *tar.Reader, hdr *tar.Header, target, rootDir string) er
316316
"name", hdr.Name,
317317
"type", hdr.Typeflag,
318318
)
319+
return nil
319320
}
320321

322+
// Best-effort ownership preservation from tar headers.
323+
// When running as non-root, Lchown will fail with EPERM — that's fine.
324+
bestEffortLchown(target, hdr.Uid, hdr.Gid)
325+
321326
return nil
322327
}
323328

@@ -399,6 +404,16 @@ func extractSymlink(hdr *tar.Header, target, rootDir string) error {
399404
return nil
400405
}
401406

407+
// bestEffortLchown attempts to set uid/gid on a path without following symlinks.
408+
// It silently ignores EPERM (non-root can't chown) and logs other errors at debug level.
409+
func bestEffortLchown(path string, uid, gid int) {
410+
if err := os.Lchown(path, uid, gid); err != nil {
411+
if !os.IsPermission(err) {
412+
slog.Debug("lchown failed", "path", path, "uid", uid, "gid", gid, "err", err)
413+
}
414+
}
415+
}
416+
402417
// extractHardlink creates a hard link, validating that both source and target
403418
// remain within the rootfs directory.
404419
func extractHardlink(hdr *tar.Header, target, rootDir string) error {

image/pull_test.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"os"
1313
"path/filepath"
1414
"strings"
15+
"syscall"
1516
"testing"
1617

1718
v1 "github.com/google/go-containerregistry/pkg/v1"
@@ -103,6 +104,8 @@ func createTarBuffer(t *testing.T, entries []tarEntry) *bytes.Buffer {
103104
Mode: e.mode,
104105
Size: int64(len(e.content)),
105106
Linkname: e.linkname,
107+
Uid: e.uid,
108+
Gid: e.gid,
106109
}
107110

108111
err := tw.WriteHeader(hdr)
@@ -126,6 +129,8 @@ type tarEntry struct {
126129
mode int64
127130
content string
128131
linkname string
132+
uid int
133+
gid int
129134
}
130135

131136
func TestExtractTar_DirectoriesAndFiles(t *testing.T) {
@@ -309,6 +314,73 @@ func TestExtractTar_SkipsPathTraversal(t *testing.T) {
309314
assert.Equal(t, "good content", string(data))
310315
}
311316

317+
func TestExtractTar_PreservesOwnershipBestEffort(t *testing.T) {
318+
t.Parallel()
319+
320+
entries := []tarEntry{
321+
{
322+
name: "root-owned/",
323+
typeflag: tar.TypeDir,
324+
mode: 0o755,
325+
uid: 0,
326+
gid: 0,
327+
},
328+
{
329+
name: "root-owned/file.txt",
330+
typeflag: tar.TypeReg,
331+
mode: 0o644,
332+
content: "owned by root",
333+
uid: 0,
334+
gid: 0,
335+
},
336+
{
337+
name: "user-owned/",
338+
typeflag: tar.TypeDir,
339+
mode: 0o755,
340+
uid: 1000,
341+
gid: 1000,
342+
},
343+
{
344+
name: "user-owned/file.txt",
345+
typeflag: tar.TypeReg,
346+
mode: 0o644,
347+
content: "owned by user",
348+
uid: 1000,
349+
gid: 1000,
350+
},
351+
}
352+
353+
buf := createTarBuffer(t, entries)
354+
dst := t.TempDir()
355+
356+
err := extractTar(buf, dst)
357+
require.NoError(t, err)
358+
359+
// Verify files were extracted regardless of uid/gid.
360+
data, err := os.ReadFile(filepath.Join(dst, "root-owned", "file.txt"))
361+
require.NoError(t, err)
362+
assert.Equal(t, "owned by root", string(data))
363+
364+
data, err = os.ReadFile(filepath.Join(dst, "user-owned", "file.txt"))
365+
require.NoError(t, err)
366+
assert.Equal(t, "owned by user", string(data))
367+
368+
// When running as root, verify ownership is actually preserved.
369+
if os.Geteuid() == 0 {
370+
info, err := os.Lstat(filepath.Join(dst, "root-owned", "file.txt"))
371+
require.NoError(t, err)
372+
stat := info.Sys().(*syscall.Stat_t)
373+
assert.Equal(t, uint32(0), stat.Uid)
374+
assert.Equal(t, uint32(0), stat.Gid)
375+
376+
info, err = os.Lstat(filepath.Join(dst, "user-owned", "file.txt"))
377+
require.NoError(t, err)
378+
stat = info.Sys().(*syscall.Stat_t)
379+
assert.Equal(t, uint32(1000), stat.Uid)
380+
assert.Equal(t, uint32(1000), stat.Gid)
381+
}
382+
}
383+
312384
// mockFetcher is a test double for ImageFetcher.
313385
type mockFetcher struct {
314386
img v1.Image

0 commit comments

Comments
 (0)