From 7105e295825f0550842f0f937eb52a13cad1c1bf Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Wed, 26 Aug 2026 16:35:20 +0000 Subject: [PATCH 1/7] Materialize per-layer artifacts with explicit whiteout handling Layers are materialized into images/layers// keyed by the compressed layer blob digest, reading the blob from the shared OCI cache. Each artifact is an erofs image of the unpacked layer plus an artifact.json record carrying format, options, entry stats, and the whiteout inventory. Builds happen in temp directories and are installed atomically; an interrupted build simply rebuilds on the next attempt. applyLayerTree interprets OCI whiteouts and opaque-directory markers explicitly when merging a layer into a target tree: markers delete or mask lower-layer content before the layer's own entries are copied on top. Tar-level .wh. files are never relied upon to compose on overlayfs, and symlinks are removed rather than followed during deletion. --- lib/images/layer_artifact.go | 495 ++++++++++++++++++++++++++++++ lib/images/layer_artifact_test.go | 260 ++++++++++++++++ lib/paths/paths.go | 22 ++ 3 files changed, 777 insertions(+) create mode 100644 lib/images/layer_artifact.go create mode 100644 lib/images/layer_artifact_test.go diff --git a/lib/images/layer_artifact.go b/lib/images/layer_artifact.go new file mode 100644 index 000000000..f2858649e --- /dev/null +++ b/lib/images/layer_artifact.go @@ -0,0 +1,495 @@ +package images + +import ( + "archive/tar" + "compress/gzip" + "encoding/json" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "strings" + "syscall" + "time" + + "github.com/kernel/hypeman/lib/paths" + "golang.org/x/sys/unix" + + "github.com/klauspost/compress/zstd" +) + +// OCI whiteout marker files. A ".wh." entry in directory D removes +// "" from D as inherited from lower layers; a ".wh..wh..opq" entry marks +// its directory opaque, hiding everything below it from lower layers. These +// are tar-level conventions: they do not compose on overlayfs by themselves, +// so composition must interpret them explicitly (see applyLayerTree). +const ( + whiteoutPrefix = ".wh." + opaqueWhiteout = ".wh..wh..opq" +) + +const layerRecordSchemaVersion = 1 + +// layerArtifact is the persisted record for one materialized layer artifact. +// The key is the compressed layer blob digest plus the artifact format and +// options, so the same layer can coexist in several materializations. +type layerArtifact struct { + SchemaVersion int `json:"schema_version"` + Digest string `json:"digest"` // compressed layer blob digest, sha256:... + DiffID string `json:"diff_id,omitempty"` + Format string `json:"format"` + Options layerArtifactOptions `json:"options,omitempty"` + SizeBytes int64 `json:"size_bytes"` // artifact bytes on disk + UnpackedBytes int64 `json:"unpacked_bytes"` + Entries int `json:"entries"` + Whiteouts []whiteoutRecord `json:"whiteouts,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +type layerArtifactOptions struct { + Compression string `json:"compression,omitempty"` +} + +// whiteoutRecord describes one whiteout marker found in a layer. Dir is the +// directory containing the marker relative to the layer root ("" for root). +// For opaque markers, Target is empty and the whole directory is hidden. +type whiteoutRecord struct { + Dir string `json:"dir"` + Target string `json:"target,omitempty"` + Opaque bool `json:"opaque,omitempty"` +} + +func (a *layerArtifact) matches(desc layerDescriptor) bool { + return a.Digest == desc.Digest && a.Format == layerFormatErofs +} + +const layerFormatErofs = "erofs" + +// readLayerRecord loads the artifact record for a layer digest, if present. +// A missing record returns (nil, nil): the layer simply was never +// materialized. +func readLayerRecord(p *paths.Paths, layerHex string) (*layerArtifact, error) { + data, err := os.ReadFile(p.ImageLayerRecord(layerHex)) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("read layer record: %w", err) + } + var record layerArtifact + if err := json.Unmarshal(data, &record); err != nil { + return nil, fmt.Errorf("unmarshal layer record: %w", err) + } + return &record, nil +} + +// materializeLayerArtifact ensures a layer has a materialized artifact keyed +// by its blob digest, building it from the shared OCI cache blob when absent. +// The layer is unpacked into an isolated temp directory, converted to erofs, +// and installed atomically; an interrupted build leaves only temp files that +// the next attempt replaces. +func (m *manager) materializeLayerArtifact(desc layerDescriptor) (*layerArtifact, error) { + layerHex := strings.TrimPrefix(desc.Digest, "sha256:") + if layerHex == "" || strings.Contains(layerHex, "/") { + return nil, fmt.Errorf("invalid layer digest: %s", desc.Digest) + } + + if record, err := readLayerRecord(m.paths, layerHex); err != nil { + return nil, err + } else if record != nil && record.matches(desc) { + if _, statErr := os.Stat(m.paths.ImageLayerArtifact(layerHex)); statErr == nil { + return record, nil + } + // Record without artifact: rebuild below. + } + + blobPath := m.paths.OCICacheBlob(layerHex) + if _, err := os.Stat(blobPath); err != nil { + if os.IsNotExist(err) { + return nil, fmt.Errorf("layer blob missing from oci cache: %s", desc.Digest) + } + return nil, fmt.Errorf("stat layer blob: %w", err) + } + + layerDir := m.paths.ImageLayerDir(layerHex) + if err := os.MkdirAll(layerDir, 0755); err != nil { + return nil, fmt.Errorf("create layer directory: %w", err) + } + unpackDir, err := os.MkdirTemp(layerDir, ".unpack-*") + if err != nil { + return nil, fmt.Errorf("create unpack directory: %w", err) + } + defer os.RemoveAll(unpackDir) + + stats, err := unpackLayerBlob(blobPath, desc.MediaType, unpackDir) + if err != nil { + return nil, fmt.Errorf("unpack layer %s: %w", desc.Digest, err) + } + + record := &layerArtifact{ + SchemaVersion: layerRecordSchemaVersion, + Digest: desc.Digest, + DiffID: desc.DiffID, + Format: layerFormatErofs, + Options: layerArtifactOptions{Compression: "lz4"}, + UnpackedBytes: stats.unpackedBytes, + Entries: stats.entries, + Whiteouts: stats.whiteouts, + CreatedAt: time.Now(), + } + + if err := installAtomically(m.paths.ImageLayerArtifact(layerHex), func(path string) error { + size, convErr := convertToErofs(unpackDir, path) + if convErr != nil { + return convErr + } + record.SizeBytes = size + return nil + }); err != nil { + return nil, fmt.Errorf("install layer artifact %s: %w", desc.Digest, err) + } + + data, err := json.MarshalIndent(record, "", " ") + if err != nil { + return nil, fmt.Errorf("marshal layer record: %w", err) + } + if err := writeJSONAtomic(m.paths.ImageLayerRecord(layerHex), data); err != nil { + return nil, fmt.Errorf("write layer record: %w", err) + } + return record, nil +} + +type unpackStats struct { + entries int + unpackedBytes int64 + whiteouts []whiteoutRecord +} + +// unpackLayerBlob extracts one compressed layer blob into dest, preserving +// whiteout marker files and recording them. Paths are confined to dest. +func unpackLayerBlob(blobPath, mediaType, dest string) (*unpackStats, error) { + blob, err := os.Open(blobPath) + if err != nil { + return nil, fmt.Errorf("open blob: %w", err) + } + defer blob.Close() + + reader, closer, err := decompressLayer(blob, mediaType) + if err != nil { + return nil, err + } + defer closer.Close() + + stats := &unpackStats{whiteouts: make([]whiteoutRecord, 0)} + tr := tar.NewReader(reader) + for { + header, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return nil, fmt.Errorf("read tar entry: %w", err) + } + + target, err := safeJoin(dest, header.Name) + if err != nil { + return nil, err + } + stats.entries++ + + dir, base := filepath.Dir(header.Name), filepath.Base(header.Name) + if dir == "." { + dir = "" + } + if base == opaqueWhiteout { + stats.whiteouts = append(stats.whiteouts, whiteoutRecord{Dir: dir, Opaque: true}) + } else if strings.HasPrefix(base, whiteoutPrefix) { + stats.whiteouts = append(stats.whiteouts, whiteoutRecord{Dir: dir, Target: strings.TrimPrefix(base, whiteoutPrefix)}) + } + + if err := extractTarEntry(tr, header, dest, target); err != nil { + return nil, fmt.Errorf("extract %s: %w", header.Name, err) + } + if header.Typeflag == tar.TypeReg { + stats.unpackedBytes += header.Size + } + } + return stats, nil +} + +// decompressLayer wraps the blob in the reader for its layer media type. +func decompressLayer(blob *os.File, mediaType string) (io.Reader, io.Closer, error) { + switch { + case strings.HasSuffix(mediaType, "+zstd"): + decoder, err := zstd.NewReader(blob) + if err != nil { + return nil, nil, fmt.Errorf("zstd reader: %w", err) + } + return decoder, multiCloser{decoder.IOReadCloser(), blob}, nil + case strings.HasSuffix(mediaType, "+gzip"), strings.Contains(mediaType, "tar.gzip"): + gz, err := gzip.NewReader(blob) + if err != nil { + return nil, nil, fmt.Errorf("gzip reader: %w", err) + } + return gz, multiCloser{gz, blob}, nil + default: + return blob, blob, nil + } +} + +type multiCloser []io.Closer + +func (c multiCloser) Close() error { + var firstErr error + for _, closer := range c { + if err := closer.Close(); err != nil && firstErr == nil { + firstErr = err + } + } + return firstErr +} + +// safeJoin resolves a tar entry name inside root, rejecting escapes. +func safeJoin(root, name string) (string, error) { + clean := filepath.Clean("/" + name) + target := filepath.Join(root, clean) + if target != root && !strings.HasPrefix(target, root+string(filepath.Separator)) { + return "", fmt.Errorf("tar entry escapes root: %s", name) + } + return target, nil +} + +func extractTarEntry(tr *tar.Reader, header *tar.Header, root, target string) error { + switch header.Typeflag { + case tar.TypeDir: + if err := os.MkdirAll(target, 0755); err != nil { + return err + } + return os.Chmod(target, header.FileInfo().Mode().Perm()) + case tar.TypeReg: + if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil { + return err + } + removePath(target) + file, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) + if err != nil { + return err + } + if _, err := io.Copy(file, tr); err != nil { + file.Close() + return err + } + if err := file.Close(); err != nil { + return err + } + return os.Chmod(target, header.FileInfo().Mode().Perm()) + case tar.TypeSymlink: + if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil { + return err + } + removePath(target) + return os.Symlink(header.Linkname, target) + case tar.TypeLink: + linkTarget, err := safeJoin(root, header.Linkname) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil { + return err + } + removePath(target) + return os.Link(linkTarget, target) + case tar.TypeChar, tar.TypeBlock: + if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil { + return err + } + removePath(target) + mode := uint32(syscall.S_IFCHR) + if header.Typeflag == tar.TypeBlock { + mode = uint32(syscall.S_IFBLK) + } + dev := int(unix.Mkdev(uint32(header.Devmajor), uint32(header.Devminor))) + if err := unix.Mknod(target, mode|uint32(header.FileInfo().Mode().Perm()), dev); err != nil { + return fmt.Errorf("mknod (requires device-node privileges): %w", err) + } + return nil + case tar.TypeFifo: + if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil { + return err + } + removePath(target) + return syscall.Mkfifo(target, uint32(header.FileInfo().Mode().Perm())) + default: + // Global/extended headers and other metadata entries carry no content. + return nil + } +} + +func removePath(path string) { + _ = os.Remove(path) +} + +// applyLayerTree merges one unpacked layer directory into targetDir following +// OCI whiteout semantics: whiteouts and opaque markers remove what lower layers +// contributed, then the layer's own entries are copied on top. Raw tar +// whiteout files are interpreted here rather than passed through, because +// overlayfs does not understand them. +func applyLayerTree(layerDir, targetDir string) error { + if err := os.MkdirAll(targetDir, 0755); err != nil { + return fmt.Errorf("create target directory: %w", err) + } + + // Phase 1: apply whiteouts against what is already in the target. + err := filepath.WalkDir(layerDir, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + base := entry.Name() + if path == layerDir || !strings.HasPrefix(base, whiteoutPrefix) { + return nil + } + rel, err := filepath.Rel(layerDir, path) + if err != nil { + return err + } + targetParent := filepath.Join(targetDir, filepath.Dir(rel)) + if base == opaqueWhiteout { + return clearDirContents(targetParent) + } + hidden := strings.TrimPrefix(base, whiteoutPrefix) + if hidden == "." || hidden == ".." { + return nil + } + removePath(filepath.Join(targetDir, filepath.Dir(rel), hidden)) + return nil + }) + if err != nil { + return fmt.Errorf("apply whiteouts: %w", err) + } + + // Phase 2: copy the layer's own entries, skipping whiteout markers. + hardlinks := make(map[hardlinkIdentity]string) + err = filepath.WalkDir(layerDir, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if path == layerDir { + return nil + } + if strings.HasPrefix(entry.Name(), whiteoutPrefix) { + if entry.IsDir() { + return fs.SkipDir + } + return nil + } + rel, err := filepath.Rel(layerDir, path) + if err != nil { + return err + } + return copyEntryInto(path, filepath.Join(targetDir, rel), hardlinks) + }) + if err != nil { + return fmt.Errorf("copy layer tree: %w", err) + } + return nil +} + +// clearDirContents removes everything inside dir without removing dir itself, +// and without following symlinks. +func clearDirContents(dir string) error { + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + for _, entry := range entries { + if err := os.RemoveAll(filepath.Join(dir, entry.Name())); err != nil { + return err + } + } + return nil +} + +type hardlinkIdentity struct { + dev uint64 + ino uint64 +} + +// copyEntryInto copies one filesystem entry from src to dst, replacing any +// conflicting entry and preserving hardlinks within the layer. +func copyEntryInto(src, dst string, hardlinks map[hardlinkIdentity]string) error { + info, err := os.Lstat(src) + if err != nil { + return err + } + + if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil { + return err + } + + switch info.Mode() & fs.ModeType { + case 0: // regular file + if existing, statErr := os.Lstat(dst); statErr == nil { + if existing.IsDir() { + if err := os.RemoveAll(dst); err != nil { + return err + } + } else { + removePath(dst) + } + } + stat, ok := info.Sys().(*syscall.Stat_t) + if ok && stat.Nlink > 1 { + identity := hardlinkIdentity{dev: uint64(stat.Dev), ino: uint64(stat.Ino)} + if first, seen := hardlinks[identity]; seen { + return os.Link(first, dst) + } + hardlinks[identity] = dst + } + if err := copyFileContents(src, dst); err != nil { + return err + } + return os.Chmod(dst, info.Mode().Perm()) + case fs.ModeDir: + existing, statErr := os.Lstat(dst) + if statErr == nil && !existing.IsDir() { + removePath(dst) + } + return os.MkdirAll(dst, info.Mode().Perm()) + case fs.ModeSymlink: + linkTarget, err := os.Readlink(src) + if err != nil { + return err + } + removePath(dst) + return os.Symlink(linkTarget, dst) + default: + // Devices and fifos are recreated from their stat info. + removePath(dst) + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return fmt.Errorf("unsupported entry type for %s", src) + } + return unix.Mknod(dst, uint32(info.Mode()), int(stat.Rdev)) + } +} + +func copyFileContents(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) + if err != nil { + return err + } + if _, err := io.Copy(out, in); err != nil { + out.Close() + return err + } + return out.Close() +} diff --git a/lib/images/layer_artifact_test.go b/lib/images/layer_artifact_test.go new file mode 100644 index 000000000..753ac760b --- /dev/null +++ b/lib/images/layer_artifact_test.go @@ -0,0 +1,260 @@ +package images + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "io" + "io/fs" + "os" + "path/filepath" + "strings" + "testing" + + gcr "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/empty" + "github.com/google/go-containerregistry/pkg/v1/layout" + "github.com/google/go-containerregistry/pkg/v1/mutate" + "github.com/google/go-containerregistry/pkg/v1/tarball" + "github.com/kernel/hypeman/lib/paths" + "github.com/stretchr/testify/require" +) + +// writeLayerTestLayout writes img into the shared OCI cache of p tagged with +// the image's digest, mirroring pullToOCILayout. +func writeLayerTestLayout(t *testing.T, p *paths.Paths, img gcr.Image) { + t.Helper() + digest, err := img.Digest() + require.NoError(t, err) + layoutPath, err := layout.Write(p.SystemOCICache(), empty.Index) + require.NoError(t, err) + require.NoError(t, layoutPath.AppendImage(img, layout.WithAnnotations(map[string]string{ + "org.opencontainers.image.ref.name": digestToLayoutTag(digest.String()), + }))) +} + +func layerDescFromImage(t *testing.T, img gcr.Image, index int) layerDescriptor { + t.Helper() + manifest, err := img.Manifest() + require.NoError(t, err) + configFile, err := img.ConfigFile() + require.NoError(t, err) + layer := manifest.Layers[index] + return layerDescriptor{ + Digest: layer.Digest.String(), + Size: layer.Size, + MediaType: string(layer.MediaType), + DiffID: configFile.RootFS.DiffIDs[index].String(), + } +} + +func TestMaterializeLayerArtifact(t *testing.T) { + p := paths.New(t.TempDir()) + img, err := mutate.AppendLayers(empty.Image, syntheticLayer(t, "base.txt", "base layer content")) + require.NoError(t, err) + writeLayerTestLayout(t, p, img) + + desc := layerDescFromImage(t, img, 0) + m := &manager{paths: p} + + record, err := m.materializeLayerArtifact(desc) + require.NoError(t, err) + require.Equal(t, desc.Digest, record.Digest) + require.Equal(t, desc.DiffID, record.DiffID) + require.Equal(t, layerFormatErofs, record.Format) + require.Greater(t, record.SizeBytes, int64(0)) + require.Greater(t, record.UnpackedBytes, int64(0)) + require.Greater(t, record.Entries, 0) + + layerHex := desc.Digest[len("sha256:"):] + _, err = os.Stat(p.ImageLayerArtifact(layerHex)) + require.NoError(t, err, "layer.erofs must be installed") + + // A second materialization reuses the existing artifact. + artifactInfo, err := os.Stat(p.ImageLayerArtifact(layerHex)) + require.NoError(t, err) + reused, err := m.materializeLayerArtifact(desc) + require.NoError(t, err) + require.True(t, record.CreatedAt.Equal(reused.CreatedAt), "reuse must return the stored record") + artifactInfoAfter, err := os.Stat(p.ImageLayerArtifact(layerHex)) + require.NoError(t, err) + require.Equal(t, artifactInfo.ModTime(), artifactInfoAfter.ModTime(), "reuse must not rebuild") +} + +func TestMaterializeLayerArtifactMissingBlob(t *testing.T) { + p := paths.New(t.TempDir()) + m := &manager{paths: p} + + _, err := m.materializeLayerArtifact(layerDescriptor{ + Digest: "sha256:abababababababababababababababababababababababababababababababab", + MediaType: "application/vnd.oci.image.layer.v1.tar+gzip", + }) + require.ErrorContains(t, err, "missing from oci cache") +} + +// whiteoutLayer builds a gzipped tar layer exercising whiteouts: a plain file, +// a whiteout marker, an opaque directory marker, and a whiteout+recreate pair. +func whiteoutLayer(t *testing.T) gcr.Layer { + t.Helper() + + var buf bytes.Buffer + gzw := gzip.NewWriter(&buf) + tw := tar.NewWriter(gzw) + + writeEntry := func(header *tar.Header, content string) { + require.NoError(t, tw.WriteHeader(header)) + if content != "" { + _, err := tw.Write([]byte(content)) + require.NoError(t, err) + } + } + writeEntry(&tar.Header{Name: "keep.txt", Typeflag: tar.TypeReg, Mode: 0644, Size: 4}, "keep") + writeEntry(&tar.Header{Name: "gone/", Typeflag: tar.TypeDir, Mode: 0755}, "") + writeEntry(&tar.Header{Name: "gone/.wh.deleted.txt", Typeflag: tar.TypeReg, Mode: 0644, Size: 0}, "") + writeEntry(&tar.Header{Name: "opq/", Typeflag: tar.TypeDir, Mode: 0755}, "") + writeEntry(&tar.Header{Name: "opq/.wh..wh..opq", Typeflag: tar.TypeReg, Mode: 0644, Size: 0}, "") + writeEntry(&tar.Header{Name: "opq/fresh.txt", Typeflag: tar.TypeReg, Mode: 0644, Size: 5}, "fresh") + writeEntry(&tar.Header{Name: "added/", Typeflag: tar.TypeDir, Mode: 0755}, "") + writeEntry(&tar.Header{Name: "added/.wh.foo", Typeflag: tar.TypeReg, Mode: 0644, Size: 0}, "") + writeEntry(&tar.Header{Name: "added/foo", Typeflag: tar.TypeReg, Mode: 0644, Size: 3}, "new") + + require.NoError(t, tw.Close()) + require.NoError(t, gzw.Close()) + + data := buf.Bytes() + layer, err := tarball.LayerFromOpener(func() (io.ReadCloser, error) { + return io.NopCloser(bytes.NewReader(data)), nil + }) + require.NoError(t, err) + return layer +} + +func TestMaterializeLayerRecordsWhiteouts(t *testing.T) { + p := paths.New(t.TempDir()) + img, err := mutate.AppendLayers(empty.Image, whiteoutLayer(t)) + require.NoError(t, err) + writeLayerTestLayout(t, p, img) + + desc := layerDescFromImage(t, img, 0) + m := &manager{paths: p} + + record, err := m.materializeLayerArtifact(desc) + require.NoError(t, err) + + require.Contains(t, record.Whiteouts, whiteoutRecord{Dir: "gone", Target: "deleted.txt"}) + require.Contains(t, record.Whiteouts, whiteoutRecord{Dir: "opq", Opaque: true}) + require.Contains(t, record.Whiteouts, whiteoutRecord{Dir: "added", Target: "foo"}) + + // Opaque and whiteout markers are recorded distinctly. + opaqueCount := 0 + for _, whiteout := range record.Whiteouts { + if whiteout.Opaque { + opaqueCount++ + require.Empty(t, whiteout.Target) + } + } + require.Equal(t, 1, opaqueCount) +} + +func TestApplyLayerTreeWhiteoutSemantics(t *testing.T) { + root := t.TempDir() + targetDir := filepath.Join(root, "target") + layerDir := filepath.Join(root, "layer") + + // Lower state contributed by earlier layers. + require.NoError(t, os.MkdirAll(filepath.Join(targetDir, "opqdir"), 0755)) + require.NoError(t, os.MkdirAll(filepath.Join(targetDir, "swapdir"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(targetDir, "victim.txt"), []byte("old"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(targetDir, "keep.txt"), []byte("old"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(targetDir, "opqdir", "stale.txt"), []byte("stale"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(targetDir, "swapdir", "inner.txt"), []byte("inner"), 0644)) + + // Layer: whiteout victim.txt, opaque opqdir, replace swapdir with a file, + // and whiteout-then-recreate added/foo within the same layer. + require.NoError(t, os.MkdirAll(filepath.Join(layerDir, "opqdir"), 0755)) + require.NoError(t, os.MkdirAll(filepath.Join(layerDir, "added"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(layerDir, ".wh.victim.txt"), nil, 0644)) + require.NoError(t, os.WriteFile(filepath.Join(layerDir, "keep.txt"), []byte("new"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(layerDir, "opqdir", ".wh..wh..opq"), nil, 0644)) + require.NoError(t, os.WriteFile(filepath.Join(layerDir, "opqdir", "fresh.txt"), []byte("fresh"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(layerDir, "swapdir"), []byte("now a file"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(layerDir, "added", ".wh.foo"), nil, 0644)) + require.NoError(t, os.WriteFile(filepath.Join(layerDir, "added", "foo"), []byte("new"), 0644)) + + require.NoError(t, applyLayerTree(layerDir, targetDir)) + + // Whiteout removed the lower entry. + _, err := os.Lstat(filepath.Join(targetDir, "victim.txt")) + require.True(t, os.IsNotExist(err), "whiteout must delete the lower entry") + + // Regular file replacement. + data, err := os.ReadFile(filepath.Join(targetDir, "keep.txt")) + require.NoError(t, err) + require.Equal(t, "new", string(data)) + + // Opaque directory: stale content gone, layer content present. + _, err = os.Lstat(filepath.Join(targetDir, "opqdir", "stale.txt")) + require.True(t, os.IsNotExist(err), "opaque dir must hide lower contents") + data, err = os.ReadFile(filepath.Join(targetDir, "opqdir", "fresh.txt")) + require.NoError(t, err) + require.Equal(t, "fresh", string(data)) + + // Directory replaced by a file. + info, err := os.Lstat(filepath.Join(targetDir, "swapdir")) + require.NoError(t, err) + require.False(t, info.IsDir()) + + // Whiteout followed by recreate in the same layer keeps the new entry. + data, err = os.ReadFile(filepath.Join(targetDir, "added", "foo")) + require.NoError(t, err) + require.Equal(t, "new", string(data)) + + // Whiteout marker files never leak into the composed tree. + leaked := make([]string, 0) + require.NoError(t, filepath.WalkDir(targetDir, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if strings.HasPrefix(entry.Name(), whiteoutPrefix) { + leaked = append(leaked, path) + } + return nil + })) + require.Empty(t, leaked) +} + +func TestApplyLayerTreeSymlinksAndHardlinks(t *testing.T) { + root := t.TempDir() + targetDir := filepath.Join(root, "target") + layerDir := filepath.Join(root, "layer") + require.NoError(t, os.MkdirAll(targetDir, 0755)) + require.NoError(t, os.MkdirAll(layerDir, 0755)) + + // A symlink in the lower tree pointing at a file the new layer deletes: + // the symlink itself must be removed, never followed. + require.NoError(t, os.WriteFile(filepath.Join(targetDir, "real.txt"), []byte("real"), 0644)) + require.NoError(t, os.Symlink("real.txt", filepath.Join(targetDir, "alias"))) + + require.NoError(t, os.WriteFile(filepath.Join(layerDir, ".wh.alias"), nil, 0644)) + require.NoError(t, os.WriteFile(filepath.Join(layerDir, "a.txt"), []byte("content"), 0644)) + require.NoError(t, os.Link(filepath.Join(layerDir, "a.txt"), filepath.Join(layerDir, "b.txt"))) + require.NoError(t, os.Symlink("a.txt", filepath.Join(layerDir, "link-to-a"))) + + require.NoError(t, applyLayerTree(layerDir, targetDir)) + + _, err := os.Lstat(filepath.Join(targetDir, "alias")) + require.True(t, os.IsNotExist(err), "symlink whiteout must remove the link itself") + _, err = os.Lstat(filepath.Join(targetDir, "real.txt")) + require.NoError(t, err, "symlink target must survive an unrelated whiteout") + + infoA, err := os.Stat(filepath.Join(targetDir, "a.txt")) + require.NoError(t, err) + infoB, err := os.Stat(filepath.Join(targetDir, "b.txt")) + require.NoError(t, err) + require.Equal(t, int64(7), infoA.Size()) + require.True(t, os.SameFile(infoA, infoB), "hardlinks within the layer must stay linked") + + linkTarget, err := os.Readlink(filepath.Join(targetDir, "link-to-a")) + require.NoError(t, err) + require.Equal(t, "a.txt", linkTarget) +} diff --git a/lib/paths/paths.go b/lib/paths/paths.go index bc9403cd6..d14c765b9 100644 --- a/lib/paths/paths.go +++ b/lib/paths/paths.go @@ -177,6 +177,28 @@ func (p *Paths) ImageRepositoryTagSymlink(repository, tag string) string { return filepath.Join(p.ImageRepositoriesDir(), repository, tag) } +// ImageLayersDir returns the root directory of the per-layer artifact store. +// Layer artifacts are content-addressed by the compressed layer blob digest. +func (p *Paths) ImageLayersDir() string { + return filepath.Join(p.dataDir, "images", "layers") +} + +// ImageLayerDir returns the artifact directory for one layer digest. +func (p *Paths) ImageLayerDir(layerHex string) string { + return filepath.Join(p.ImageLayersDir(), layerHex) +} + +// ImageLayerArtifact returns the path to a materialized layer artifact. +func (p *Paths) ImageLayerArtifact(layerHex string) string { + return filepath.Join(p.ImageLayerDir(layerHex), "layer.erofs") +} + +// ImageLayerRecord returns the path to the artifact record describing one +// materialized layer (format, options, whiteout inventory). +func (p *Paths) ImageLayerRecord(layerHex string) string { + return filepath.Join(p.ImageLayerDir(layerHex), "artifact.json") +} + // ImageDigestDir returns the directory for a specific image digest. func (p *Paths) ImageDigestDir(repository, digestHex string) string { return filepath.Join(p.dataDir, "images", repository, digestHex) From f3231c0f7d727275c75eb80be8ae2fbb18ba1d4c Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Wed, 26 Aug 2026 18:43:59 +0000 Subject: [PATCH 2/7] Harden layer extraction and artifacts --- lib/images/layer_artifact.go | 291 +++++++++++++++++++++++++----- lib/images/layer_artifact_test.go | 23 +++ lib/paths/paths.go | 9 +- 3 files changed, 279 insertions(+), 44 deletions(-) diff --git a/lib/images/layer_artifact.go b/lib/images/layer_artifact.go index f2858649e..c094bc4a3 100644 --- a/lib/images/layer_artifact.go +++ b/lib/images/layer_artifact.go @@ -3,7 +3,9 @@ package images import ( "archive/tar" "compress/gzip" + "crypto/sha256" "encoding/json" + "errors" "fmt" "io" "io/fs" @@ -13,10 +15,10 @@ import ( "syscall" "time" + securejoin "github.com/cyphar/filepath-securejoin" "github.com/kernel/hypeman/lib/paths" - "golang.org/x/sys/unix" - "github.com/klauspost/compress/zstd" + "golang.org/x/sys/unix" ) // OCI whiteout marker files. A ".wh." entry in directory D removes @@ -61,10 +63,28 @@ type whiteoutRecord struct { } func (a *layerArtifact) matches(desc layerDescriptor) bool { - return a.Digest == desc.Digest && a.Format == layerFormatErofs + return a.Digest == desc.Digest && a.Format == layerArtifactFormat() } -const layerFormatErofs = "erofs" +const ( + layerFormatErofs = "erofs" + layerFormatExt4 = "ext4" +) + +func layerArtifactFormat() string { + switch DefaultImageFormat { + case FormatErofs: + return layerFormatErofs + case FormatExt4: + return layerFormatExt4 + default: + return layerFormatErofs + } +} + +func layerArtifactPath(p *paths.Paths, layerHex string) string { + return p.ImageLayerArtifactForFormat(layerHex, layerArtifactFormat()) +} // readLayerRecord loads the artifact record for a layer digest, if present. // A missing record returns (nil, nil): the layer simply was never @@ -91,14 +111,14 @@ func readLayerRecord(p *paths.Paths, layerHex string) (*layerArtifact, error) { // the next attempt replaces. func (m *manager) materializeLayerArtifact(desc layerDescriptor) (*layerArtifact, error) { layerHex := strings.TrimPrefix(desc.Digest, "sha256:") - if layerHex == "" || strings.Contains(layerHex, "/") { + if layerHex == "" || strings.Contains(layerHex, "/") || layerHex == "." || strings.Contains(layerHex, "..") { return nil, fmt.Errorf("invalid layer digest: %s", desc.Digest) } if record, err := readLayerRecord(m.paths, layerHex); err != nil { return nil, err } else if record != nil && record.matches(desc) { - if _, statErr := os.Stat(m.paths.ImageLayerArtifact(layerHex)); statErr == nil { + if _, statErr := os.Stat(layerArtifactPath(m.paths, layerHex)); statErr == nil { return record, nil } // Record without artifact: rebuild below. @@ -126,12 +146,19 @@ func (m *manager) materializeLayerArtifact(desc layerDescriptor) (*layerArtifact if err != nil { return nil, fmt.Errorf("unpack layer %s: %w", desc.Digest, err) } + if desc.DiffID != "" && stats.diffID != desc.DiffID { + return nil, fmt.Errorf("layer %s diff id mismatch: got %s, want %s", desc.Digest, stats.diffID, desc.DiffID) + } + return m.installLayerArtifact(desc, layerHex, unpackDir, stats) +} + +func (m *manager) installLayerArtifact(desc layerDescriptor, layerHex, unpackDir string, stats *unpackStats) (*layerArtifact, error) { record := &layerArtifact{ SchemaVersion: layerRecordSchemaVersion, Digest: desc.Digest, DiffID: desc.DiffID, - Format: layerFormatErofs, + Format: layerArtifactFormat(), Options: layerArtifactOptions{Compression: "lz4"}, UnpackedBytes: stats.unpackedBytes, Entries: stats.entries, @@ -139,8 +166,15 @@ func (m *manager) materializeLayerArtifact(desc layerDescriptor) (*layerArtifact CreatedAt: time.Now(), } - if err := installAtomically(m.paths.ImageLayerArtifact(layerHex), func(path string) error { - size, convErr := convertToErofs(unpackDir, path) + if err := installAtomically(layerArtifactPath(m.paths, layerHex), func(path string) error { + var size int64 + var convErr error + switch layerArtifactFormat() { + case layerFormatExt4: + size, convErr = convertToExt4(unpackDir, path) + default: + size, convErr = convertToErofs(unpackDir, path) + } if convErr != nil { return convErr } @@ -155,6 +189,7 @@ func (m *manager) materializeLayerArtifact(desc layerDescriptor) (*layerArtifact return nil, fmt.Errorf("marshal layer record: %w", err) } if err := writeJSONAtomic(m.paths.ImageLayerRecord(layerHex), data); err != nil { + _ = os.Remove(layerArtifactPath(m.paths, layerHex)) return nil, fmt.Errorf("write layer record: %w", err) } return record, nil @@ -163,6 +198,7 @@ func (m *manager) materializeLayerArtifact(desc layerDescriptor) (*layerArtifact type unpackStats struct { entries int unpackedBytes int64 + diffID string whiteouts []whiteoutRecord } @@ -181,8 +217,9 @@ func unpackLayerBlob(blobPath, mediaType, dest string) (*unpackStats, error) { } defer closer.Close() + hash := sha256.New() stats := &unpackStats{whiteouts: make([]whiteoutRecord, 0)} - tr := tar.NewReader(reader) + tr := tar.NewReader(io.TeeReader(reader, hash)) for { header, err := tr.Next() if err == io.EOF { @@ -205,7 +242,11 @@ func unpackLayerBlob(blobPath, mediaType, dest string) (*unpackStats, error) { if base == opaqueWhiteout { stats.whiteouts = append(stats.whiteouts, whiteoutRecord{Dir: dir, Opaque: true}) } else if strings.HasPrefix(base, whiteoutPrefix) { - stats.whiteouts = append(stats.whiteouts, whiteoutRecord{Dir: dir, Target: strings.TrimPrefix(base, whiteoutPrefix)}) + targetName := strings.TrimPrefix(base, whiteoutPrefix) + if targetName == "" || targetName == "." || targetName == ".." { + return nil, fmt.Errorf("invalid whiteout entry: %s", header.Name) + } + stats.whiteouts = append(stats.whiteouts, whiteoutRecord{Dir: dir, Target: targetName}) } if err := extractTarEntry(tr, header, dest, target); err != nil { @@ -215,6 +256,7 @@ func unpackLayerBlob(blobPath, mediaType, dest string) (*unpackStats, error) { stats.unpackedBytes += header.Size } } + stats.diffID = fmt.Sprintf("sha256:%x", hash.Sum(nil)) return stats, nil } @@ -250,29 +292,73 @@ func (c multiCloser) Close() error { return firstErr } -// safeJoin resolves a tar entry name inside root, rejecting escapes. +// safeJoin resolves a tar entry name inside root and rejects symlinked parents. func safeJoin(root, name string) (string, error) { - clean := filepath.Clean("/" + name) + if filepath.IsAbs(name) { + return "", fmt.Errorf("tar entry escapes root: %s", name) + } + clean := filepath.Clean(name) + if clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("tar entry escapes root: %s", name) + } + root = filepath.Clean(root) target := filepath.Join(root, clean) if target != root && !strings.HasPrefix(target, root+string(filepath.Separator)) { return "", fmt.Errorf("tar entry escapes root: %s", name) } + for parent := filepath.Dir(target); parent != root; parent = filepath.Dir(parent) { + info, err := os.Lstat(parent) + if err != nil { + if os.IsNotExist(err) { + continue + } + return "", fmt.Errorf("inspect tar entry parent: %w", err) + } + if info.Mode()&os.ModeSymlink != 0 { + return "", fmt.Errorf("tar entry traverses symlink: %s", name) + } + if !info.IsDir() { + return "", fmt.Errorf("tar entry parent is not a directory: %s", parent) + } + } return target, nil } +func validateSymlinkTarget(root, target, linkname string) error { + if filepath.IsAbs(linkname) { + return nil + } + resolved, err := securejoin.SecureJoin(filepath.Dir(target), linkname) + if err != nil { + return fmt.Errorf("resolve symlink target: %w", err) + } + root = filepath.Clean(root) + if resolved != root && !strings.HasPrefix(resolved, root+string(filepath.Separator)) { + return fmt.Errorf("symlink target escapes root: %s", linkname) + } + return nil +} + func extractTarEntry(tr *tar.Reader, header *tar.Header, root, target string) error { switch header.Typeflag { case tar.TypeDir: + if info, statErr := os.Lstat(target); statErr == nil && !info.IsDir() { + if err := clearExisting(target); err != nil { + return err + } + } if err := os.MkdirAll(target, 0755); err != nil { return err } - return os.Chmod(target, header.FileInfo().Mode().Perm()) + return applyTarMetadata(target, header) case tar.TypeReg: if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil { return err } - removePath(target) - file, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) + if err := clearExisting(target); err != nil { + return err + } + file, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC|syscall.O_NOFOLLOW, 0644) if err != nil { return err } @@ -283,13 +369,21 @@ func extractTarEntry(tr *tar.Reader, header *tar.Header, root, target string) er if err := file.Close(); err != nil { return err } - return os.Chmod(target, header.FileInfo().Mode().Perm()) + return applyTarMetadata(target, header) case tar.TypeSymlink: + if err := validateSymlinkTarget(root, target, header.Linkname); err != nil { + return err + } if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil { return err } - removePath(target) - return os.Symlink(header.Linkname, target) + if err := clearExisting(target); err != nil { + return err + } + if err := os.Symlink(header.Linkname, target); err != nil { + return err + } + return applyTarMetadata(target, header) case tar.TypeLink: linkTarget, err := safeJoin(root, header.Linkname) if err != nil { @@ -298,13 +392,17 @@ func extractTarEntry(tr *tar.Reader, header *tar.Header, root, target string) er if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil { return err } - removePath(target) + if err := clearExisting(target); err != nil { + return err + } return os.Link(linkTarget, target) case tar.TypeChar, tar.TypeBlock: if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil { return err } - removePath(target) + if err := clearExisting(target); err != nil { + return err + } mode := uint32(syscall.S_IFCHR) if header.Typeflag == tar.TypeBlock { mode = uint32(syscall.S_IFBLK) @@ -313,21 +411,73 @@ func extractTarEntry(tr *tar.Reader, header *tar.Header, root, target string) er if err := unix.Mknod(target, mode|uint32(header.FileInfo().Mode().Perm()), dev); err != nil { return fmt.Errorf("mknod (requires device-node privileges): %w", err) } - return nil + return applyTarMetadata(target, header) case tar.TypeFifo: if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil { return err } - removePath(target) - return syscall.Mkfifo(target, uint32(header.FileInfo().Mode().Perm())) + if err := clearExisting(target); err != nil { + return err + } + if err := syscall.Mkfifo(target, uint32(header.FileInfo().Mode().Perm())); err != nil { + return err + } + return applyTarMetadata(target, header) default: // Global/extended headers and other metadata entries carry no content. return nil } } -func removePath(path string) { - _ = os.Remove(path) +func applyTarMetadata(path string, header *tar.Header) error { + if err := os.Lchown(path, header.Uid, header.Gid); err != nil && !errors.Is(err, os.ErrPermission) && !errors.Is(err, unix.EPERM) { + return fmt.Errorf("restore ownership: %w", err) + } + if header.Typeflag != tar.TypeSymlink { + if err := os.Chmod(path, os.FileMode(header.Mode)); err != nil { + return fmt.Errorf("restore mode: %w", err) + } + mtime := header.ModTime + if mtime.IsZero() { + mtime = time.Now() + } + atime := header.AccessTime + if atime.IsZero() { + atime = mtime + } + if err := os.Chtimes(path, atime, mtime); err != nil { + return fmt.Errorf("restore timestamps: %w", err) + } + } + for name, value := range header.Xattrs { + if err := unix.Lsetxattr(path, name, []byte(value), 0); err != nil && !errors.Is(err, unix.ENOTSUP) && !errors.Is(err, unix.EPERM) { + return fmt.Errorf("restore xattr %s: %w", name, err) + } + } + return nil +} + +func removePath(path string) error { + if err := os.RemoveAll(path); err != nil && !os.IsNotExist(err) { + return err + } + return nil +} + +// clearExisting removes whatever entry occupies path, including non-empty +// directories, so a layer entry of a different type can replace it. +func clearExisting(path string) error { + info, err := os.Lstat(path) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + if info.IsDir() { + return os.RemoveAll(path) + } + return os.Remove(path) } // applyLayerTree merges one unpacked layer directory into targetDir following @@ -358,11 +508,14 @@ func applyLayerTree(layerDir, targetDir string) error { return clearDirContents(targetParent) } hidden := strings.TrimPrefix(base, whiteoutPrefix) - if hidden == "." || hidden == ".." { - return nil + if hidden == "" || hidden == "." || hidden == ".." { + return fmt.Errorf("invalid whiteout entry: %s", filepath.Join(filepath.Dir(rel), base)) } - removePath(filepath.Join(targetDir, filepath.Dir(rel), hidden)) - return nil + target, err := safeJoin(targetDir, filepath.Join(filepath.Dir(rel), hidden)) + if err != nil { + return err + } + return removePath(target) }) if err != nil { return fmt.Errorf("apply whiteouts: %w", err) @@ -387,7 +540,11 @@ func applyLayerTree(layerDir, targetDir string) error { if err != nil { return err } - return copyEntryInto(path, filepath.Join(targetDir, rel), hardlinks) + target, err := safeJoin(targetDir, rel) + if err != nil { + return err + } + return copyEntryInto(path, target, hardlinks) }) if err != nil { return fmt.Errorf("copy layer tree: %w", err) @@ -398,13 +555,20 @@ func applyLayerTree(layerDir, targetDir string) error { // clearDirContents removes everything inside dir without removing dir itself, // and without following symlinks. func clearDirContents(dir string) error { - entries, err := os.ReadDir(dir) + info, err := os.Lstat(dir) if err != nil { if os.IsNotExist(err) { return nil } return err } + if !info.IsDir() { + return fmt.Errorf("opaque whiteout target is not a directory: %s", dir) + } + entries, err := os.ReadDir(dir) + if err != nil { + return err + } for _, entry := range entries { if err := os.RemoveAll(filepath.Join(dir, entry.Name())); err != nil { return err @@ -437,8 +601,8 @@ func copyEntryInto(src, dst string, hardlinks map[hardlinkIdentity]string) error if err := os.RemoveAll(dst); err != nil { return err } - } else { - removePath(dst) + } else if err := removePath(dst); err != nil { + return err } } stat, ok := info.Sys().(*syscall.Stat_t) @@ -452,29 +616,72 @@ func copyEntryInto(src, dst string, hardlinks map[hardlinkIdentity]string) error if err := copyFileContents(src, dst); err != nil { return err } - return os.Chmod(dst, info.Mode().Perm()) + return copyEntryMetadata(dst, info) case fs.ModeDir: existing, statErr := os.Lstat(dst) if statErr == nil && !existing.IsDir() { - removePath(dst) + if err := removePath(dst); err != nil { + return err + } } - return os.MkdirAll(dst, info.Mode().Perm()) + if err := os.MkdirAll(dst, info.Mode().Perm()); err != nil { + return err + } + return copyEntryMetadata(dst, info) case fs.ModeSymlink: linkTarget, err := os.Readlink(src) if err != nil { return err } - removePath(dst) - return os.Symlink(linkTarget, dst) + if err := removePath(dst); err != nil { + return err + } + if err := os.Symlink(linkTarget, dst); err != nil { + return err + } + return nil default: // Devices and fifos are recreated from their stat info. - removePath(dst) + if err := removePath(dst); err != nil { + return err + } stat, ok := info.Sys().(*syscall.Stat_t) if !ok { return fmt.Errorf("unsupported entry type for %s", src) } - return unix.Mknod(dst, uint32(info.Mode()), int(stat.Rdev)) + mode := uint32(info.Mode().Perm()) + switch info.Mode() & fs.ModeType { + case fs.ModeCharDevice: + mode |= syscall.S_IFCHR + case fs.ModeDevice: + mode |= syscall.S_IFBLK + case fs.ModeNamedPipe: + mode |= syscall.S_IFIFO + default: + return fmt.Errorf("unsupported entry type for %s", src) + } + if err := unix.Mknod(dst, mode, int(stat.Rdev)); err != nil { + return err + } + return copyEntryMetadata(dst, info) + } +} + +func copyEntryMetadata(dst string, info os.FileInfo) error { + if stat, ok := info.Sys().(*syscall.Stat_t); ok { + if err := os.Lchown(dst, int(stat.Uid), int(stat.Gid)); err != nil && !errors.Is(err, os.ErrPermission) && !errors.Is(err, unix.EPERM) { + return err + } } + if info.Mode()&os.ModeSymlink == 0 { + if err := os.Chmod(dst, info.Mode().Perm()); err != nil { + return err + } + if err := os.Chtimes(dst, info.ModTime(), info.ModTime()); err != nil { + return err + } + } + return nil } func copyFileContents(src, dst string) error { @@ -483,7 +690,7 @@ func copyFileContents(src, dst string) error { return err } defer in.Close() - out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) + out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC|syscall.O_NOFOLLOW, 0644) if err != nil { return err } diff --git a/lib/images/layer_artifact_test.go b/lib/images/layer_artifact_test.go index 753ac760b..79d00e01b 100644 --- a/lib/images/layer_artifact_test.go +++ b/lib/images/layer_artifact_test.go @@ -165,6 +165,8 @@ func TestApplyLayerTreeWhiteoutSemantics(t *testing.T) { require.NoError(t, os.MkdirAll(filepath.Join(targetDir, "opqdir"), 0755)) require.NoError(t, os.MkdirAll(filepath.Join(targetDir, "swapdir"), 0755)) require.NoError(t, os.WriteFile(filepath.Join(targetDir, "victim.txt"), []byte("old"), 0644)) + require.NoError(t, os.MkdirAll(filepath.Join(targetDir, "removedir"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(targetDir, "removedir", "inner.txt"), []byte("old"), 0644)) require.NoError(t, os.WriteFile(filepath.Join(targetDir, "keep.txt"), []byte("old"), 0644)) require.NoError(t, os.WriteFile(filepath.Join(targetDir, "opqdir", "stale.txt"), []byte("stale"), 0644)) require.NoError(t, os.WriteFile(filepath.Join(targetDir, "swapdir", "inner.txt"), []byte("inner"), 0644)) @@ -174,6 +176,7 @@ func TestApplyLayerTreeWhiteoutSemantics(t *testing.T) { require.NoError(t, os.MkdirAll(filepath.Join(layerDir, "opqdir"), 0755)) require.NoError(t, os.MkdirAll(filepath.Join(layerDir, "added"), 0755)) require.NoError(t, os.WriteFile(filepath.Join(layerDir, ".wh.victim.txt"), nil, 0644)) + require.NoError(t, os.WriteFile(filepath.Join(layerDir, ".wh.removedir"), nil, 0644)) require.NoError(t, os.WriteFile(filepath.Join(layerDir, "keep.txt"), []byte("new"), 0644)) require.NoError(t, os.WriteFile(filepath.Join(layerDir, "opqdir", ".wh..wh..opq"), nil, 0644)) require.NoError(t, os.WriteFile(filepath.Join(layerDir, "opqdir", "fresh.txt"), []byte("fresh"), 0644)) @@ -186,6 +189,8 @@ func TestApplyLayerTreeWhiteoutSemantics(t *testing.T) { // Whiteout removed the lower entry. _, err := os.Lstat(filepath.Join(targetDir, "victim.txt")) require.True(t, os.IsNotExist(err), "whiteout must delete the lower entry") + _, err = os.Lstat(filepath.Join(targetDir, "removedir")) + require.True(t, os.IsNotExist(err), "directory whiteout must delete the complete lower directory") // Regular file replacement. data, err := os.ReadFile(filepath.Join(targetDir, "keep.txt")) @@ -223,6 +228,24 @@ func TestApplyLayerTreeWhiteoutSemantics(t *testing.T) { require.Empty(t, leaked) } +func TestUnpackLayerBlobRejectsSymlinkTraversal(t *testing.T) { + root := t.TempDir() + blobPath := filepath.Join(root, "layer.tar.gz") + var buf bytes.Buffer + gzw := gzip.NewWriter(&buf) + tw := tar.NewWriter(gzw) + require.NoError(t, tw.WriteHeader(&tar.Header{Name: "link", Typeflag: tar.TypeSymlink, Linkname: "outside"})) + require.NoError(t, tw.WriteHeader(&tar.Header{Name: "link/escape", Typeflag: tar.TypeReg, Mode: 0644, Size: 1})) + _, err := tw.Write([]byte("x")) + require.NoError(t, err) + require.NoError(t, tw.Close()) + require.NoError(t, gzw.Close()) + require.NoError(t, os.WriteFile(blobPath, buf.Bytes(), 0644)) + + _, err = unpackLayerBlob(blobPath, "application/vnd.oci.image.layer.v1.tar+gzip", filepath.Join(root, "dest")) + require.ErrorContains(t, err, "symlink") +} + func TestApplyLayerTreeSymlinksAndHardlinks(t *testing.T) { root := t.TempDir() targetDir := filepath.Join(root, "target") diff --git a/lib/paths/paths.go b/lib/paths/paths.go index d14c765b9..0d747b023 100644 --- a/lib/paths/paths.go +++ b/lib/paths/paths.go @@ -188,9 +188,14 @@ func (p *Paths) ImageLayerDir(layerHex string) string { return filepath.Join(p.ImageLayersDir(), layerHex) } -// ImageLayerArtifact returns the path to a materialized layer artifact. +// ImageLayerArtifact returns the path to the default materialized layer artifact. func (p *Paths) ImageLayerArtifact(layerHex string) string { - return filepath.Join(p.ImageLayerDir(layerHex), "layer.erofs") + return p.ImageLayerArtifactForFormat(layerHex, "erofs") +} + +// ImageLayerArtifactForFormat returns the path to a materialized layer artifact. +func (p *Paths) ImageLayerArtifactForFormat(layerHex, format string) string { + return filepath.Join(p.ImageLayerDir(layerHex), "layer."+format) } // ImageLayerRecord returns the path to the artifact record describing one From 78b2ecd1876f2aede02dbb39ea4f1f34a53c5162 Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Wed, 26 Aug 2026 18:50:45 +0000 Subject: [PATCH 3/7] Split tar entry extraction handlers --- lib/images/layer_artifact.go | 167 +++++++++++++++++++---------------- 1 file changed, 91 insertions(+), 76 deletions(-) diff --git a/lib/images/layer_artifact.go b/lib/images/layer_artifact.go index c094bc4a3..ab2ef8cc2 100644 --- a/lib/images/layer_artifact.go +++ b/lib/images/layer_artifact.go @@ -342,91 +342,106 @@ func validateSymlinkTarget(root, target, linkname string) error { func extractTarEntry(tr *tar.Reader, header *tar.Header, root, target string) error { switch header.Typeflag { case tar.TypeDir: - if info, statErr := os.Lstat(target); statErr == nil && !info.IsDir() { - if err := clearExisting(target); err != nil { - return err - } - } - if err := os.MkdirAll(target, 0755); err != nil { - return err - } - return applyTarMetadata(target, header) + return extractTarDir(target, header) case tar.TypeReg: - if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil { - return err - } - if err := clearExisting(target); err != nil { - return err - } - file, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC|syscall.O_NOFOLLOW, 0644) - if err != nil { - return err - } - if _, err := io.Copy(file, tr); err != nil { - file.Close() - return err - } - if err := file.Close(); err != nil { - return err - } - return applyTarMetadata(target, header) + return extractTarFile(tr, target, header) case tar.TypeSymlink: - if err := validateSymlinkTarget(root, target, header.Linkname); err != nil { - return err - } - if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil { - return err - } - if err := clearExisting(target); err != nil { - return err - } - if err := os.Symlink(header.Linkname, target); err != nil { - return err - } - return applyTarMetadata(target, header) + return extractTarSymlink(root, target, header) case tar.TypeLink: - linkTarget, err := safeJoin(root, header.Linkname) - if err != nil { - return err - } - if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil { - return err - } - if err := clearExisting(target); err != nil { - return err - } - return os.Link(linkTarget, target) + return extractTarHardlink(root, target, header) case tar.TypeChar, tar.TypeBlock: - if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil { - return err - } - if err := clearExisting(target); err != nil { - return err - } - mode := uint32(syscall.S_IFCHR) - if header.Typeflag == tar.TypeBlock { - mode = uint32(syscall.S_IFBLK) - } - dev := int(unix.Mkdev(uint32(header.Devmajor), uint32(header.Devminor))) - if err := unix.Mknod(target, mode|uint32(header.FileInfo().Mode().Perm()), dev); err != nil { - return fmt.Errorf("mknod (requires device-node privileges): %w", err) - } - return applyTarMetadata(target, header) + return extractTarDevice(target, header) case tar.TypeFifo: - if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil { - return err - } + return extractTarFIFO(target, header) + default: + return nil + } +} + +func prepareTarTarget(target string) error { + if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil { + return err + } + return clearExisting(target) +} + +func extractTarDir(target string, header *tar.Header) error { + if info, err := os.Lstat(target); err == nil && !info.IsDir() { if err := clearExisting(target); err != nil { return err } - if err := syscall.Mkfifo(target, uint32(header.FileInfo().Mode().Perm())); err != nil { - return err - } - return applyTarMetadata(target, header) - default: - // Global/extended headers and other metadata entries carry no content. - return nil } + if err := os.MkdirAll(target, 0755); err != nil { + return err + } + return applyTarMetadata(target, header) +} + +func extractTarFile(tr *tar.Reader, target string, header *tar.Header) error { + if err := prepareTarTarget(target); err != nil { + return err + } + file, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC|syscall.O_NOFOLLOW, 0644) + if err != nil { + return err + } + if _, err := io.Copy(file, tr); err != nil { + _ = file.Close() + return err + } + if err := file.Close(); err != nil { + return err + } + return applyTarMetadata(target, header) +} + +func extractTarSymlink(root, target string, header *tar.Header) error { + if err := validateSymlinkTarget(root, target, header.Linkname); err != nil { + return err + } + if err := prepareTarTarget(target); err != nil { + return err + } + if err := os.Symlink(header.Linkname, target); err != nil { + return err + } + return applyTarMetadata(target, header) +} + +func extractTarHardlink(root, target string, header *tar.Header) error { + linkTarget, err := safeJoin(root, header.Linkname) + if err != nil { + return err + } + if err := prepareTarTarget(target); err != nil { + return err + } + return os.Link(linkTarget, target) +} + +func extractTarDevice(target string, header *tar.Header) error { + if err := prepareTarTarget(target); err != nil { + return err + } + mode := uint32(syscall.S_IFCHR) + if header.Typeflag == tar.TypeBlock { + mode = uint32(syscall.S_IFBLK) + } + dev := int(unix.Mkdev(uint32(header.Devmajor), uint32(header.Devminor))) + if err := unix.Mknod(target, mode|uint32(header.FileInfo().Mode().Perm()), dev); err != nil { + return fmt.Errorf("mknod (requires device-node privileges): %w", err) + } + return applyTarMetadata(target, header) +} + +func extractTarFIFO(target string, header *tar.Header) error { + if err := prepareTarTarget(target); err != nil { + return err + } + if err := syscall.Mkfifo(target, uint32(header.FileInfo().Mode().Perm())); err != nil { + return err + } + return applyTarMetadata(target, header) } func applyTarMetadata(path string, header *tar.Header) error { From 9613737d0af9073c9a2733e2db17374db74ec5be Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Wed, 26 Aug 2026 18:56:20 +0000 Subject: [PATCH 4/7] Split filesystem entry copy handlers --- lib/images/layer_artifact.go | 138 +++++++++++++++++++---------------- 1 file changed, 75 insertions(+), 63 deletions(-) diff --git a/lib/images/layer_artifact.go b/lib/images/layer_artifact.go index ab2ef8cc2..ad58cb1f8 100644 --- a/lib/images/layer_artifact.go +++ b/lib/images/layer_artifact.go @@ -604,81 +604,93 @@ func copyEntryInto(src, dst string, hardlinks map[hardlinkIdentity]string) error if err != nil { return err } - if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil { return err } switch info.Mode() & fs.ModeType { - case 0: // regular file - if existing, statErr := os.Lstat(dst); statErr == nil { - if existing.IsDir() { - if err := os.RemoveAll(dst); err != nil { - return err - } - } else if err := removePath(dst); err != nil { - return err - } - } - stat, ok := info.Sys().(*syscall.Stat_t) - if ok && stat.Nlink > 1 { - identity := hardlinkIdentity{dev: uint64(stat.Dev), ino: uint64(stat.Ino)} - if first, seen := hardlinks[identity]; seen { - return os.Link(first, dst) - } - hardlinks[identity] = dst - } - if err := copyFileContents(src, dst); err != nil { - return err - } - return copyEntryMetadata(dst, info) + case 0: + return copyRegularEntry(src, dst, info, hardlinks) case fs.ModeDir: - existing, statErr := os.Lstat(dst) - if statErr == nil && !existing.IsDir() { - if err := removePath(dst); err != nil { - return err - } - } - if err := os.MkdirAll(dst, info.Mode().Perm()); err != nil { - return err - } - return copyEntryMetadata(dst, info) + return copyDirectoryEntry(dst, info) case fs.ModeSymlink: - linkTarget, err := os.Readlink(src) - if err != nil { - return err + return copySymlinkEntry(src, dst) + default: + return copySpecialEntry(src, dst, info) + } +} + +func copyRegularEntry(src, dst string, info os.FileInfo, hardlinks map[hardlinkIdentity]string) error { + if err := removePath(dst); err != nil { + return err + } + if stat, ok := info.Sys().(*syscall.Stat_t); ok && stat.Nlink > 1 { + identity := hardlinkIdentity{dev: uint64(stat.Dev), ino: uint64(stat.Ino)} + if first, seen := hardlinks[identity]; seen { + return os.Link(first, dst) } + hardlinks[identity] = dst + } + if err := copyFileContents(src, dst); err != nil { + return err + } + return copyEntryMetadata(dst, info) +} + +func copyDirectoryEntry(dst string, info os.FileInfo) error { + if existing, err := os.Lstat(dst); err == nil && !existing.IsDir() { if err := removePath(dst); err != nil { return err } - if err := os.Symlink(linkTarget, dst); err != nil { - return err - } - return nil + } + if err := os.MkdirAll(dst, info.Mode().Perm()); err != nil { + return err + } + return copyEntryMetadata(dst, info) +} + +func copySymlinkEntry(src, dst string) error { + linkTarget, err := os.Readlink(src) + if err != nil { + return err + } + if err := removePath(dst); err != nil { + return err + } + if err := os.Symlink(linkTarget, dst); err != nil { + return err + } + return nil +} + +func copySpecialEntry(src, dst string, info os.FileInfo) error { + if err := removePath(dst); err != nil { + return err + } + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return fmt.Errorf("unsupported entry type for %s", src) + } + mode, err := specialFileMode(info.Mode() & fs.ModeType) + if err != nil { + return fmt.Errorf("unsupported entry type for %s", src) + } + if err := unix.Mknod(dst, mode|uint32(info.Mode().Perm()), int(stat.Rdev)); err != nil { + return err + } + return copyEntryMetadata(dst, info) +} + +func specialFileMode(mode fs.FileMode) (uint32, error) { + switch mode { + case fs.ModeCharDevice: + return syscall.S_IFCHR, nil + case fs.ModeDevice: + return syscall.S_IFBLK, nil + case fs.ModeNamedPipe: + return syscall.S_IFIFO, nil default: - // Devices and fifos are recreated from their stat info. - if err := removePath(dst); err != nil { - return err - } - stat, ok := info.Sys().(*syscall.Stat_t) - if !ok { - return fmt.Errorf("unsupported entry type for %s", src) - } - mode := uint32(info.Mode().Perm()) - switch info.Mode() & fs.ModeType { - case fs.ModeCharDevice: - mode |= syscall.S_IFCHR - case fs.ModeDevice: - mode |= syscall.S_IFBLK - case fs.ModeNamedPipe: - mode |= syscall.S_IFIFO - default: - return fmt.Errorf("unsupported entry type for %s", src) - } - if err := unix.Mknod(dst, mode, int(stat.Rdev)); err != nil { - return err - } - return copyEntryMetadata(dst, info) + return 0, fmt.Errorf("unsupported file mode") } } From 7e0688cc064473f05391153ce03872e3290b9677 Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Wed, 26 Aug 2026 19:37:38 +0000 Subject: [PATCH 5/7] Preserve layer metadata during composition --- lib/images/layer_artifact.go | 99 ++++++++++++++++++++++++++++++++---- 1 file changed, 88 insertions(+), 11 deletions(-) diff --git a/lib/images/layer_artifact.go b/lib/images/layer_artifact.go index ad58cb1f8..afd3168b6 100644 --- a/lib/images/layer_artifact.go +++ b/lib/images/layer_artifact.go @@ -63,7 +63,10 @@ type whiteoutRecord struct { } func (a *layerArtifact) matches(desc layerDescriptor) bool { - return a.Digest == desc.Digest && a.Format == layerArtifactFormat() + if a.Digest != desc.Digest || a.Format != layerArtifactFormat() { + return false + } + return desc.DiffID == "" || a.DiffID == desc.DiffID } const ( @@ -101,6 +104,9 @@ func readLayerRecord(p *paths.Paths, layerHex string) (*layerArtifact, error) { if err := json.Unmarshal(data, &record); err != nil { return nil, fmt.Errorf("unmarshal layer record: %w", err) } + if err := record.validate(); err != nil { + return nil, fmt.Errorf("invalid layer record: %w", err) + } return &record, nil } @@ -109,6 +115,25 @@ func readLayerRecord(p *paths.Paths, layerHex string) (*layerArtifact, error) { // The layer is unpacked into an isolated temp directory, converted to erofs, // and installed atomically; an interrupted build leaves only temp files that // the next attempt replaces. +func (a *layerArtifact) validate() error { + if a.SchemaVersion != layerRecordSchemaVersion { + return fmt.Errorf("unsupported schema version: %d", a.SchemaVersion) + } + if a.Digest == "" || a.Format != layerFormatErofs && a.Format != layerFormatExt4 { + return fmt.Errorf("invalid digest or format") + } + if a.SizeBytes < 0 || a.UnpackedBytes < 0 || a.Entries < 0 { + return fmt.Errorf("invalid size or entry counts") + } + if a.Format == layerFormatExt4 && a.Options.Compression != "" { + return fmt.Errorf("ext4 artifact has compression options") + } + if a.Format == layerFormatErofs && a.Options.Compression != "lz4" { + return fmt.Errorf("erofs artifact has invalid compression options") + } + return nil +} + func (m *manager) materializeLayerArtifact(desc layerDescriptor) (*layerArtifact, error) { layerHex := strings.TrimPrefix(desc.Digest, "sha256:") if layerHex == "" || strings.Contains(layerHex, "/") || layerHex == "." || strings.Contains(layerHex, "..") { @@ -153,13 +178,20 @@ func (m *manager) materializeLayerArtifact(desc layerDescriptor) (*layerArtifact return m.installLayerArtifact(desc, layerHex, unpackDir, stats) } +func artifactOptions(format string) layerArtifactOptions { + if format == layerFormatErofs { + return layerArtifactOptions{Compression: "lz4"} + } + return layerArtifactOptions{} +} + func (m *manager) installLayerArtifact(desc layerDescriptor, layerHex, unpackDir string, stats *unpackStats) (*layerArtifact, error) { record := &layerArtifact{ SchemaVersion: layerRecordSchemaVersion, Digest: desc.Digest, DiffID: desc.DiffID, Format: layerArtifactFormat(), - Options: layerArtifactOptions{Compression: "lz4"}, + Options: artifactOptions(layerArtifactFormat()), UnpackedBytes: stats.unpackedBytes, Entries: stats.entries, Whiteouts: stats.whiteouts, @@ -518,7 +550,10 @@ func applyLayerTree(layerDir, targetDir string) error { if err != nil { return err } - targetParent := filepath.Join(targetDir, filepath.Dir(rel)) + targetParent, err := safeJoin(targetDir, filepath.Dir(rel)) + if err != nil { + return err + } if base == opaqueWhiteout { return clearDirContents(targetParent) } @@ -578,7 +613,7 @@ func clearDirContents(dir string) error { return err } if !info.IsDir() { - return fmt.Errorf("opaque whiteout target is not a directory: %s", dir) + return removePath(dir) } entries, err := os.ReadDir(dir) if err != nil { @@ -612,7 +647,7 @@ func copyEntryInto(src, dst string, hardlinks map[hardlinkIdentity]string) error case 0: return copyRegularEntry(src, dst, info, hardlinks) case fs.ModeDir: - return copyDirectoryEntry(dst, info) + return copyDirectoryEntry(src, dst, info) case fs.ModeSymlink: return copySymlinkEntry(src, dst) default: @@ -634,10 +669,10 @@ func copyRegularEntry(src, dst string, info os.FileInfo, hardlinks map[hardlinkI if err := copyFileContents(src, dst); err != nil { return err } - return copyEntryMetadata(dst, info) + return copyEntryMetadata(src, dst, info) } -func copyDirectoryEntry(dst string, info os.FileInfo) error { +func copyDirectoryEntry(src, dst string, info os.FileInfo) error { if existing, err := os.Lstat(dst); err == nil && !existing.IsDir() { if err := removePath(dst); err != nil { return err @@ -646,7 +681,7 @@ func copyDirectoryEntry(dst string, info os.FileInfo) error { if err := os.MkdirAll(dst, info.Mode().Perm()); err != nil { return err } - return copyEntryMetadata(dst, info) + return copyEntryMetadata(src, dst, info) } func copySymlinkEntry(src, dst string) error { @@ -678,7 +713,7 @@ func copySpecialEntry(src, dst string, info os.FileInfo) error { if err := unix.Mknod(dst, mode|uint32(info.Mode().Perm()), int(stat.Rdev)); err != nil { return err } - return copyEntryMetadata(dst, info) + return copyEntryMetadata(src, dst, info) } func specialFileMode(mode fs.FileMode) (uint32, error) { @@ -694,19 +729,61 @@ func specialFileMode(mode fs.FileMode) (uint32, error) { } } -func copyEntryMetadata(dst string, info os.FileInfo) error { +func copyEntryMetadata(src, dst string, info os.FileInfo) error { if stat, ok := info.Sys().(*syscall.Stat_t); ok { if err := os.Lchown(dst, int(stat.Uid), int(stat.Gid)); err != nil && !errors.Is(err, os.ErrPermission) && !errors.Is(err, unix.EPERM) { return err } } if info.Mode()&os.ModeSymlink == 0 { - if err := os.Chmod(dst, info.Mode().Perm()); err != nil { + mode := info.Mode().Perm() | info.Mode()&(os.ModeSetuid|os.ModeSetgid|os.ModeSticky) + if err := os.Chmod(dst, mode); err != nil { return err } if err := os.Chtimes(dst, info.ModTime(), info.ModTime()); err != nil { return err } + if err := copyXattrs(src, dst); err != nil { + return err + } + } + return nil +} + +func copyXattrs(src, dst string) error { + size, err := unix.Llistxattr(src, nil) + if err != nil { + if errors.Is(err, unix.ENOTSUP) || errors.Is(err, unix.EPERM) { + return nil + } + return err + } + names := make([]byte, size) + if size > 0 { + n, err := unix.Llistxattr(src, names) + if err != nil { + return err + } + names = names[:n] + } + for _, name := range strings.Split(strings.TrimSuffix(string(names), "\\x00"), "\\x00") { + if name == "" { + continue + } + size, err := unix.Lgetxattr(src, name, nil) + if err != nil { + if errors.Is(err, unix.ENOTSUP) || errors.Is(err, unix.EPERM) || errors.Is(err, unix.ENODATA) { + continue + } + return err + } + value := make([]byte, size) + if _, err := unix.Lgetxattr(src, name, value); err != nil { + return err + } + if err := unix.Lsetxattr(dst, name, value, 0); err != nil && !errors.Is(err, unix.ENOTSUP) && !errors.Is(err, unix.EPERM) { + return err + } } return nil } From 564867664417e462f536cf978073c5f27dea28ca Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Wed, 26 Aug 2026 19:40:56 +0000 Subject: [PATCH 6/7] Handle rootless layer device entries --- lib/images/layer_artifact.go | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/lib/images/layer_artifact.go b/lib/images/layer_artifact.go index afd3168b6..22ac4db1f 100644 --- a/lib/images/layer_artifact.go +++ b/lib/images/layer_artifact.go @@ -288,6 +288,9 @@ func unpackLayerBlob(blobPath, mediaType, dest string) (*unpackStats, error) { stats.unpackedBytes += header.Size } } + if _, err := io.Copy(io.Discard, reader); err != nil { + return nil, fmt.Errorf("drain layer: %w", err) + } stats.diffID = fmt.Sprintf("sha256:%x", hash.Sum(nil)) return stats, nil } @@ -461,7 +464,16 @@ func extractTarDevice(target string, header *tar.Header) error { } dev := int(unix.Mkdev(uint32(header.Devmajor), uint32(header.Devminor))) if err := unix.Mknod(target, mode|uint32(header.FileInfo().Mode().Perm()), dev); err != nil { - return fmt.Errorf("mknod (requires device-node privileges): %w", err) + if !errors.Is(err, unix.EPERM) { + return fmt.Errorf("mknod: %w", err) + } + file, openErr := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|syscall.O_NOFOLLOW, 0644) + if openErr != nil { + return fmt.Errorf("create rootless device placeholder: %w", openErr) + } + if closeErr := file.Close(); closeErr != nil { + return closeErr + } } return applyTarMetadata(target, header) } From d8832083ada731f4997431118dfbd03e7478e833 Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Wed, 26 Aug 2026 19:50:22 +0000 Subject: [PATCH 7/7] Validate layer paths before storage --- lib/images/layer_artifact.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/images/layer_artifact.go b/lib/images/layer_artifact.go index 22ac4db1f..41d7201e8 100644 --- a/lib/images/layer_artifact.go +++ b/lib/images/layer_artifact.go @@ -136,7 +136,7 @@ func (a *layerArtifact) validate() error { func (m *manager) materializeLayerArtifact(desc layerDescriptor) (*layerArtifact, error) { layerHex := strings.TrimPrefix(desc.Digest, "sha256:") - if layerHex == "" || strings.Contains(layerHex, "/") || layerHex == "." || strings.Contains(layerHex, "..") { + if err := paths.ValidatePathComponent(layerHex); err != nil { return nil, fmt.Errorf("invalid layer digest: %s", desc.Digest) }