From fad93dfe5659ea252fcb5879559e46487efbc4cf Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Mon, 31 Aug 2026 18:37:18 +0000 Subject: [PATCH 01/21] Materialize per-layer artifacts with explicit whiteout handling --- lib/images/layer_artifact.go | 838 ++++++++++++++++++++++++++++++ lib/images/layer_artifact_test.go | 390 ++++++++++++++ lib/paths/paths.go | 22 + 3 files changed, 1250 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..8ca313ea4 --- /dev/null +++ b/lib/images/layer_artifact.go @@ -0,0 +1,838 @@ +package images + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "strings" + "syscall" + "time" + + "github.com/kernel/hypeman/lib/paths" + "github.com/klauspost/compress/zstd" + "golang.org/x/sys/unix" +) + +// 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, 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"` + 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"` +} + +// validate checks a record read back from disk. The format fully determines +// the artifact options (erofs is always lz4-compressed, ext4 uncompressed), +// so only the format is stored. +func (a *layerArtifact) validate() error { + if a.SchemaVersion != layerRecordSchemaVersion { + return fmt.Errorf("unsupported schema version: %d", a.SchemaVersion) + } + if a.Digest == "" { + return fmt.Errorf("missing digest") + } + if a.Format != layerFormatErofs && a.Format != layerFormatExt4 { + return fmt.Errorf("invalid format: %s", a.Format) + } + if a.SizeBytes < 0 || a.UnpackedBytes < 0 || a.Entries < 0 { + return fmt.Errorf("invalid size or entry counts") + } + return nil +} + +// 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 { + if a.Digest != desc.Digest || a.Format != layerArtifactFormat() { + return false + } + return desc.DiffID == "" || a.DiffID == desc.DiffID +} + +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 +// 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) + } + if err := record.validate(); err != nil { + return nil, fmt.Errorf("invalid 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 the +// default image format, and installed atomically; an interrupted build leaves +// only temp files that the next attempt replaces. No production caller yet: +// pull integration and composition land in later changes. +func (m *manager) materializeLayerArtifact(desc layerDescriptor) (*layerArtifact, error) { + layerHex := strings.TrimPrefix(desc.Digest, "sha256:") + if err := paths.ValidatePathComponent(layerHex); err != nil { + 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(layerArtifactPath(m.paths, 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) + } + 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: layerArtifactFormat(), + UnpackedBytes: stats.unpackedBytes, + Entries: stats.entries, + // Nothing reads Whiteouts yet; composition re-derives whiteouts + // from the unpacked tree once it lands. + Whiteouts: stats.whiteouts, + CreatedAt: time.Now(), + } + + if err := installAtomically(layerArtifactPath(m.paths, layerHex), func(path string) error { + // The artifact intentionally retains .wh. marker files so + // composition can re-derive whiteouts from the tree itself. + size, convErr := ExportRootfs(unpackDir, path, DefaultImageFormat) + 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 { + _ = os.Remove(layerArtifactPath(m.paths, layerHex)) + return nil, fmt.Errorf("write layer record: %w", err) + } + return record, nil +} + +type unpackStats struct { + entries int + unpackedBytes int64 + diffID string + 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() + + hash := sha256.New() + stats := &unpackStats{whiteouts: make([]whiteoutRecord, 0)} + // Directory metadata is re-applied after extraction, once children + // exist, so tar directory mtimes are not overwritten by later writes. + var pendingDirs []pendingDir + hashedReader := io.TeeReader(reader, hash) + tr := tar.NewReader(hashedReader) + 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) { + 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 header.Typeflag == tar.TypeDir { + pendingDirs = append(pendingDirs, pendingDir{target: target, header: header}) + } + 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 + } + } + if _, err := io.Copy(io.Discard, hashedReader); err != nil { + return nil, fmt.Errorf("drain layer: %w", err) + } + for _, dir := range pendingDirs { + if err := applyTarMetadata(dir.target, dir.header); err != nil { + return nil, fmt.Errorf("restore dir metadata %s: %w", dir.target, err) + } + } + stats.diffID = fmt.Sprintf("sha256:%x", hash.Sum(nil)) + return stats, nil +} + +type pendingDir struct { + target string + header *tar.Header +} + +// decompressLayer wraps the blob in the reader for its layer media type. Both +// OCI-style suffixes (+gzip, +zstd) and docker-style media types (tar.gzip, +// tar.zstd) are matched so neither encoding falls through to the raw path. +func decompressLayer(blob *os.File, mediaType string) (io.Reader, io.Closer, error) { + switch { + case strings.HasSuffix(mediaType, "+zstd"), strings.Contains(mediaType, "tar.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 and rejects symlinked +// parents: extraction must never create an entry through a symlink an earlier +// tar entry planted, so existing parents are Lstat-walked and rejected rather +// than resolved. Symlink entries themselves may legitimately name paths that +// do not exist yet, so their targets are checked by resolve in +// validateSymlinkTarget instead. +func safeJoin(root, name string) (string, error) { + if filepath.IsAbs(name) { + return "", fmt.Errorf("tar entry escapes root: %s", name) + } + clean := filepath.Clean(name) + if clean == "." { + return filepath.Clean(root), nil + } + 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 { + return target, nil + } + if !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 := filepath.Clean(filepath.Join(filepath.Dir(target), linkname)) + 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: + return extractTarDir(target) + case tar.TypeReg: + return extractTarFile(tr, target, header) + case tar.TypeSymlink: + return extractTarSymlink(root, target, header) + case tar.TypeLink: + return extractTarHardlink(root, target, header) + case tar.TypeChar, tar.TypeBlock: + return extractTarDevice(target, header) + case tar.TypeFifo: + 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 removePath(target) +} + +func extractTarDir(target string) error { + if info, err := os.Lstat(target); err == nil && !info.IsDir() { + if err := removePath(target); err != nil { + return err + } + } + return os.MkdirAll(target, 0755) +} + +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 { + 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) +} + +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 { + 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 +} + +// removePath removes whatever entry occupies path, including non-empty +// directories, and tolerates a missing path. +func removePath(path string) error { + if err := os.RemoveAll(path); err != nil && !os.IsNotExist(err) { + return err + } + return nil +} + +// 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. No production caller yet; composition +// lands in a later change. +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, err := safeJoin(targetDir, filepath.Dir(rel)) + if err != nil { + return err + } + if base == opaqueWhiteout { + return clearDirContents(targetParent) + } + hidden := strings.TrimPrefix(base, whiteoutPrefix) + 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) + } + + // Phase 2: copy the layer's own entries, skipping whiteout markers. + // Directory metadata is deferred until all children are copied, so tar + // directory mtimes survive the merge. + var pendingDirs []dirMeta + 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 + } + target, err := safeJoin(targetDir, rel) + if err != nil { + return err + } + if entry.IsDir() { + info, err := entry.Info() + if err != nil { + return err + } + pendingDirs = append(pendingDirs, dirMeta{src: path, dst: target, info: info}) + } + return copyEntryInto(path, target, hardlinks) + }) + if err != nil { + return fmt.Errorf("copy layer tree: %w", err) + } + for _, dir := range pendingDirs { + if err := copyEntryMetadata(dir.src, dir.dst, dir.info); err != nil { + return fmt.Errorf("restore dir metadata %s: %w", dir.dst, err) + } + } + return nil +} + +type dirMeta struct { + src, dst string + info os.FileInfo +} + +// clearDirContents removes everything inside dir without removing dir itself, +// and without following symlinks. +func clearDirContents(dir string) error { + info, err := os.Lstat(dir) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + if !info.IsDir() { + return removePath(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 + } + } + 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: + return copyRegularEntry(src, dst, info, hardlinks) + case fs.ModeDir: + return copyDirectoryEntry(src, dst, info) + case fs.ModeSymlink: + 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(src, dst, info) +} + +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 + } + } + return os.MkdirAll(dst, info.Mode().Perm()) +} + +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: %w", src, err) + } + if err := unix.Mknod(dst, mode|uint32(info.Mode().Perm()), int(stat.Rdev)); err != nil { + if !errors.Is(err, unix.EPERM) { + return fmt.Errorf("mknod: %w", err) + } + file, openErr := os.OpenFile(dst, 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 copyEntryMetadata(src, 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: + return 0, fmt.Errorf("unsupported file mode") + } +} + +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 { + 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 _, attr := range bytes.Split(bytes.TrimSuffix(names, []byte{0}), []byte{0}) { + if len(attr) == 0 { + continue + } + name := string(attr) + 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 +} + +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|syscall.O_NOFOLLOW, 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..d92940b7a --- /dev/null +++ b/lib/images/layer_artifact_test.go @@ -0,0 +1,390 @@ +package images + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "crypto/sha256" + "errors" + "fmt" + "io" + "io/fs" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "golang.org/x/sys/unix" + + 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) { + if _, err := exec.LookPath("mkfs.erofs"); err != nil { + t.Skip("mkfs.erofs not available") + } + + 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, layerArtifactFormat(), 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.ImageLayerArtifactForFormat(layerHex, layerArtifactFormat())) + require.NoError(t, err, "layer.erofs must be installed") + + // A second materialization reuses the existing artifact. + artifactInfo, err := os.Stat(p.ImageLayerArtifactForFormat(layerHex, layerArtifactFormat())) + 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.ImageLayerArtifactForFormat(layerHex, layerArtifactFormat())) + 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) { + if _, err := exec.LookPath("mkfs.erofs"); err != nil { + t.Skip("mkfs.erofs not available") + } + + 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.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)) + + // 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, ".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)) + 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") + _, 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")) + 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 TestUnpackLayerBlobIncludesTrailingTarPaddingInDiffID(t *testing.T) { + root := t.TempDir() + blobPath := filepath.Join(root, "layer.tar.gz") + + var tarData bytes.Buffer + tw := tar.NewWriter(&tarData) + require.NoError(t, tw.WriteHeader(&tar.Header{Name: "file", Typeflag: tar.TypeReg, Mode: 0644, Size: 1})) + _, err := tw.Write([]byte("x")) + require.NoError(t, err) + require.NoError(t, tw.Close()) + _, err = tarData.Write(make([]byte, 512)) + require.NoError(t, err) + + var compressed bytes.Buffer + gzw := gzip.NewWriter(&compressed) + _, err = gzw.Write(tarData.Bytes()) + require.NoError(t, err) + require.NoError(t, gzw.Close()) + require.NoError(t, os.WriteFile(blobPath, compressed.Bytes(), 0644)) + + stats, err := unpackLayerBlob(blobPath, "application/vnd.oci.image.layer.v1.tar+gzip", filepath.Join(root, "dest")) + require.NoError(t, err) + want := sha256.Sum256(tarData.Bytes()) + require.Equal(t, "sha256:"+fmt.Sprintf("%x", want), stats.diffID) +} + +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") + 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) +} + +func TestCopyXattrs(t *testing.T) { + root := t.TempDir() + src := filepath.Join(root, "src") + dst := filepath.Join(root, "dst") + require.NoError(t, os.WriteFile(src, []byte("payload"), 0644)) + require.NoError(t, os.WriteFile(dst, []byte("payload"), 0644)) + + err := unix.Lsetxattr(src, "user.one", []byte("1"), 0) + if errors.Is(err, unix.ENOTSUP) || errors.Is(err, unix.EPERM) { + t.Skip("filesystem does not support user xattrs") + } + require.NoError(t, err) + require.NoError(t, unix.Lsetxattr(src, "user.two", []byte("22"), 0)) + + require.NoError(t, copyXattrs(src, dst)) + + for name, want := range map[string]string{"user.one": "1", "user.two": "22"} { + size, err := unix.Lgetxattr(dst, name, nil) + require.NoError(t, err, "xattr %s must be copied", name) + value := make([]byte, size) + n, err := unix.Lgetxattr(dst, name, value) + require.NoError(t, err) + require.Equal(t, want, string(value[:n])) + } +} + +func TestUnpackLayerBlobPreservesDirMtime(t *testing.T) { + root := t.TempDir() + blobPath := filepath.Join(root, "layer.tar") + dirTime := time.Now().Add(-time.Hour).Truncate(time.Second) + + var buf bytes.Buffer + tw := tar.NewWriter(&buf) + require.NoError(t, tw.WriteHeader(&tar.Header{Name: "d/", Typeflag: tar.TypeDir, Mode: 0755, ModTime: dirTime})) + require.NoError(t, tw.WriteHeader(&tar.Header{Name: "d/file.txt", Typeflag: tar.TypeReg, Mode: 0644, Size: 1})) + _, err := tw.Write([]byte("x")) + require.NoError(t, err) + require.NoError(t, tw.Close()) + require.NoError(t, os.WriteFile(blobPath, buf.Bytes(), 0644)) + + dest := filepath.Join(root, "dest") + _, err = unpackLayerBlob(blobPath, "application/vnd.oci.image.layer.v1.tar", dest) + require.NoError(t, err) + + info, err := os.Stat(filepath.Join(dest, "d")) + require.NoError(t, err) + require.True(t, info.ModTime().Equal(dirTime), "dir mtime must come from the tar header") +} + +func TestApplyLayerTreePreservesDirMtime(t *testing.T) { + root := t.TempDir() + targetDir := filepath.Join(root, "target") + layerDir := filepath.Join(root, "layer") + + old := time.Now().Add(-time.Hour).Truncate(time.Second) + require.NoError(t, os.MkdirAll(filepath.Join(layerDir, "d"), 0700)) + require.NoError(t, os.WriteFile(filepath.Join(layerDir, "d", "file.txt"), []byte("x"), 0644)) + require.NoError(t, os.Chtimes(filepath.Join(layerDir, "d"), old, old)) + + require.NoError(t, applyLayerTree(layerDir, targetDir)) + + info, err := os.Stat(filepath.Join(targetDir, "d")) + require.NoError(t, err) + require.True(t, info.ModTime().Equal(old), "dir mtime must survive the merge") +} diff --git a/lib/paths/paths.go b/lib/paths/paths.go index fc3f221eb..b47bdbbf1 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) +} + +// 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 +// 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 aa233961e8b6394423e9adc9ae430175391978a7 Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Tue, 1 Sep 2026 16:34:42 +0000 Subject: [PATCH 02/21] Harden layer artifact materialization --- lib/images/keyed_mutex.go | 39 +++++++++ lib/images/layer_artifact.go | 162 ++++++++++++++++++++++++++++++++--- lib/images/manager.go | 1 + 3 files changed, 192 insertions(+), 10 deletions(-) create mode 100644 lib/images/keyed_mutex.go diff --git a/lib/images/keyed_mutex.go b/lib/images/keyed_mutex.go new file mode 100644 index 000000000..609fd3017 --- /dev/null +++ b/lib/images/keyed_mutex.go @@ -0,0 +1,39 @@ +package images + +import "sync" + +type keyedMutex struct { + mu sync.Mutex + locks map[string]*keyedLock +} + +type keyedLock struct { + mu sync.Mutex + refs int +} + +func (m *keyedMutex) lock(key string) func() { + m.mu.Lock() + if m.locks == nil { + m.locks = make(map[string]*keyedLock) + } + lock := m.locks[key] + if lock == nil { + lock = &keyedLock{} + m.locks[key] = lock + } + lock.refs++ + m.mu.Unlock() + + lock.mu.Lock() + return func() { + lock.mu.Unlock() + + m.mu.Lock() + lock.refs-- + if lock.refs == 0 { + delete(m.locks, key) + } + m.mu.Unlock() + } +} diff --git a/lib/images/layer_artifact.go b/lib/images/layer_artifact.go index 8ca313ea4..dab913152 100644 --- a/lib/images/layer_artifact.go +++ b/lib/images/layer_artifact.go @@ -31,7 +31,11 @@ const ( opaqueWhiteout = ".wh..wh..opq" ) -const layerRecordSchemaVersion = 1 +const ( + layerRecordSchemaVersion = 1 + maxLayerEntries = 1_000_000 + maxLayerUnpackedBytes = 100 << 30 +) // layerArtifact is the persisted record for one materialized layer artifact. // The key is the compressed layer blob digest plus the artifact format, so the @@ -95,7 +99,7 @@ func layerArtifactFormat() string { case FormatExt4: return layerFormatExt4 default: - return layerFormatErofs + return "" } } @@ -131,6 +135,12 @@ func readLayerRecord(p *paths.Paths, layerHex string) (*layerArtifact, error) { // only temp files that the next attempt replaces. No production caller yet: // pull integration and composition land in later changes. func (m *manager) materializeLayerArtifact(desc layerDescriptor) (*layerArtifact, error) { + unlock := m.layerLocks.lock(desc.Digest) + defer unlock() + + if layerArtifactFormat() == "" { + return nil, fmt.Errorf("unsupported layer artifact format: %s", DefaultImageFormat) + } layerHex := strings.TrimPrefix(desc.Digest, "sha256:") if err := paths.ValidatePathComponent(layerHex); err != nil { return nil, fmt.Errorf("invalid layer digest: %s", desc.Digest) @@ -222,6 +232,9 @@ type unpackStats struct { // 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) { + if err := os.MkdirAll(dest, 0755); err != nil { + return nil, fmt.Errorf("create extraction root: %w", err) + } blob, err := os.Open(blobPath) if err != nil { return nil, fmt.Errorf("open blob: %w", err) @@ -239,7 +252,9 @@ func unpackLayerBlob(blobPath, mediaType, dest string) (*unpackStats, error) { // Directory metadata is re-applied after extraction, once children // exist, so tar directory mtimes are not overwritten by later writes. var pendingDirs []pendingDir - hashedReader := io.TeeReader(reader, hash) + var pendingHardlinks []pendingHardlink + limitedReader := &io.LimitedReader{R: reader, N: maxLayerUnpackedBytes + 1} + hashedReader := io.TeeReader(limitedReader, hash) tr := tar.NewReader(hashedReader) for { header, err := tr.Next() @@ -254,6 +269,12 @@ func unpackLayerBlob(blobPath, mediaType, dest string) (*unpackStats, error) { if err != nil { return nil, err } + if stats.entries >= maxLayerEntries { + return nil, fmt.Errorf("layer exceeds maximum entry count of %d", maxLayerEntries) + } + if header.Size > maxLayerUnpackedBytes-stats.unpackedBytes { + return nil, fmt.Errorf("layer exceeds maximum unpacked size of %d bytes", maxLayerUnpackedBytes) + } stats.entries++ dir, base := filepath.Dir(header.Name), filepath.Base(header.Name) @@ -273,16 +294,24 @@ func unpackLayerBlob(blobPath, mediaType, dest string) (*unpackStats, error) { if header.Typeflag == tar.TypeDir { pendingDirs = append(pendingDirs, pendingDir{target: target, header: header}) } - if err := extractTarEntry(tr, header, dest, target); err != nil { + if header.Typeflag == tar.TypeLink { + pendingHardlinks = append(pendingHardlinks, pendingHardlink{target: target, linkname: header.Linkname}) + } else if err := extractTarEntry(tr, header, dest, target); err != nil { return nil, fmt.Errorf("extract %s: %w", header.Name, err) } - if header.Typeflag == tar.TypeReg { + if header.Typeflag == tar.TypeReg || header.Typeflag == tar.TypeGNUSparse { stats.unpackedBytes += header.Size } } if _, err := io.Copy(io.Discard, hashedReader); err != nil { return nil, fmt.Errorf("drain layer: %w", err) } + if limitedReader.N == 0 { + return nil, fmt.Errorf("layer exceeds maximum unpacked size of %d bytes", maxLayerUnpackedBytes) + } + if err := resolveHardlinks(dest, pendingHardlinks); err != nil { + return nil, err + } for _, dir := range pendingDirs { if err := applyTarMetadata(dir.target, dir.header); err != nil { return nil, fmt.Errorf("restore dir metadata %s: %w", dir.target, err) @@ -297,6 +326,43 @@ type pendingDir struct { header *tar.Header } +type pendingHardlink struct { + target string + linkname string +} + +func resolveHardlinks(root string, pending []pendingHardlink) error { + for len(pending) > 0 { + resolved := 0 + remaining := make([]pendingHardlink, 0, len(pending)) + for _, link := range pending { + linkTarget, err := safeJoin(root, link.linkname) + if err != nil { + return err + } + if _, err := os.Lstat(linkTarget); err != nil { + if os.IsNotExist(err) { + remaining = append(remaining, link) + continue + } + return err + } + if err := prepareTarTarget(link.target); err != nil { + return err + } + if err := os.Link(linkTarget, link.target); err != nil { + return fmt.Errorf("create hardlink %s: %w", link.target, err) + } + resolved++ + } + if resolved == 0 { + return fmt.Errorf("hardlink target not found") + } + pending = remaining + } + return nil +} + // decompressLayer wraps the blob in the reader for its layer media type. Both // OCI-style suffixes (+gzip, +zstd) and docker-style media types (tar.gzip, // tar.zstd) are matched so neither encoding falls through to the raw path. @@ -338,6 +404,13 @@ func (c multiCloser) Close() error { // do not exist yet, so their targets are checked by resolve in // validateSymlinkTarget instead. func safeJoin(root, name string) (string, error) { + rootInfo, err := os.Lstat(root) + if err != nil { + return "", fmt.Errorf("inspect extraction root: %w", err) + } + if !rootInfo.IsDir() || rootInfo.Mode()&os.ModeSymlink != 0 { + return "", fmt.Errorf("extraction root is not a directory: %s", root) + } if filepath.IsAbs(name) { return "", fmt.Errorf("tar entry escapes root: %s", name) } @@ -390,7 +463,7 @@ func extractTarEntry(tr *tar.Reader, header *tar.Header, root, target string) er switch header.Typeflag { case tar.TypeDir: return extractTarDir(target) - case tar.TypeReg: + case tar.TypeReg, tar.TypeGNUSparse: return extractTarFile(tr, target, header) case tar.TypeSymlink: return extractTarSymlink(root, target, header) @@ -540,13 +613,23 @@ func removePath(path string) error { // whiteout files are interpreted here rather than passed through, because // overlayfs does not understand them. No production caller yet; composition // lands in a later change. -func applyLayerTree(layerDir, targetDir string) error { +func applyLayerTree(layerDir, targetDir string) (err error) { if err := os.MkdirAll(targetDir, 0755); err != nil { return fmt.Errorf("create target directory: %w", err) } + originalModes := make(map[string]fs.FileMode) + defer func() { + if restoreErr := restoreDirectoryModes(originalModes); restoreErr != nil { + if err == nil { + err = fmt.Errorf("restore directory modes: %w", restoreErr) + } else { + err = errors.Join(err, fmt.Errorf("restore directory modes: %w", restoreErr)) + } + } + }() // Phase 1: apply whiteouts against what is already in the target. - err := filepath.WalkDir(layerDir, func(path string, entry fs.DirEntry, err error) error { + err = filepath.WalkDir(layerDir, func(path string, entry fs.DirEntry, err error) error { if err != nil { return err } @@ -562,6 +645,9 @@ func applyLayerTree(layerDir, targetDir string) error { if err != nil { return err } + if err := makePathWritable(targetDir, targetParent, originalModes); err != nil { + return err + } if base == opaqueWhiteout { return clearDirContents(targetParent) } @@ -602,6 +688,9 @@ func applyLayerTree(layerDir, targetDir string) error { if err != nil { return err } + if err := makePathWritable(targetDir, filepath.Dir(target), originalModes); err != nil { + return err + } if entry.IsDir() { info, err := entry.Info() if err != nil { @@ -609,7 +698,13 @@ func applyLayerTree(layerDir, targetDir string) error { } pendingDirs = append(pendingDirs, dirMeta{src: path, dst: target, info: info}) } - return copyEntryInto(path, target, hardlinks) + if err := copyEntryInto(path, target, hardlinks); err != nil { + return err + } + if entry.IsDir() { + return makePathWritable(targetDir, target, originalModes) + } + return nil }) if err != nil { return fmt.Errorf("copy layer tree: %w", err) @@ -622,6 +717,49 @@ func applyLayerTree(layerDir, targetDir string) error { return nil } +func makePathWritable(root, path string, originalModes map[string]fs.FileMode) error { + root = filepath.Clean(root) + path = filepath.Clean(path) + if path != root && !strings.HasPrefix(path, root+string(filepath.Separator)) { + return fmt.Errorf("path is outside target root: %s", path) + } + for current := path; ; current = filepath.Dir(current) { + info, err := os.Lstat(current) + if err == nil { + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return fmt.Errorf("target parent is not a directory: %s", current) + } + if _, recorded := originalModes[current]; !recorded { + originalModes[current] = info.Mode() + if err := os.Chmod(current, info.Mode().Perm()|0700); err != nil { + return err + } + } + } else if !os.IsNotExist(err) { + return err + } + if current == root { + return nil + } + } +} + +func restoreDirectoryModes(originalModes map[string]fs.FileMode) error { + var restoreErr error + for path, mode := range originalModes { + if _, err := os.Lstat(path); os.IsNotExist(err) { + continue + } else if err != nil { + restoreErr = errors.Join(restoreErr, err) + continue + } + if err := os.Chmod(path, mode.Perm()|mode&(os.ModeSetuid|os.ModeSetgid|os.ModeSticky)); err != nil { + restoreErr = errors.Join(restoreErr, err) + } + } + return restoreErr +} + type dirMeta struct { src, dst string info os.FileInfo @@ -717,7 +855,11 @@ func copySymlinkEntry(src, dst string) error { if err := os.Symlink(linkTarget, dst); err != nil { return err } - return nil + info, err := os.Lstat(src) + if err != nil { + return err + } + return copyEntryMetadata(src, dst, info) } func copySpecialEntry(src, dst string, info os.FileInfo) error { diff --git a/lib/images/manager.go b/lib/images/manager.go index 8b905a417..a5ea4b732 100644 --- a/lib/images/manager.go +++ b/lib/images/manager.go @@ -75,6 +75,7 @@ type manager struct { ociClient *ociClient queue *queue.Queue createMu sync.Mutex + layerLocks keyedMutex diskUsageMu sync.RWMutex tagGenerations map[string]uint64 requestedTags map[string]string // newest pull's digest per requested tag From de5cf83cae12bcbb8a1b11489576eb6fee7d35d5 Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Tue, 1 Sep 2026 18:52:16 +0000 Subject: [PATCH 03/21] Address layer artifact review findings --- lib/images/layer_artifact.go | 35 +++++++++++++++---------------- lib/images/layer_artifact_test.go | 33 +++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 18 deletions(-) diff --git a/lib/images/layer_artifact.go b/lib/images/layer_artifact.go index dab913152..52d378829 100644 --- a/lib/images/layer_artifact.go +++ b/lib/images/layer_artifact.go @@ -251,8 +251,8 @@ func unpackLayerBlob(blobPath, mediaType, dest string) (*unpackStats, error) { stats := &unpackStats{whiteouts: make([]whiteoutRecord, 0)} // Directory metadata is re-applied after extraction, once children // exist, so tar directory mtimes are not overwritten by later writes. - var pendingDirs []pendingDir - var pendingHardlinks []pendingHardlink + pendingDirs := make([]pendingDir, 0) + pendingHardlinks := make([]pendingHardlink, 0) limitedReader := &io.LimitedReader{R: reader, N: maxLayerUnpackedBytes + 1} hashedReader := io.TeeReader(limitedReader, hash) tr := tar.NewReader(hashedReader) @@ -295,6 +295,8 @@ func unpackLayerBlob(blobPath, mediaType, dest string) (*unpackStats, error) { pendingDirs = append(pendingDirs, pendingDir{target: target, header: header}) } if header.Typeflag == tar.TypeLink { + // Hardlinks may reference entries that appear later in the tar, so + // resolve them after extracting all non-link entries. pendingHardlinks = append(pendingHardlinks, pendingHardlink{target: target, linkname: header.Linkname}) } else if err := extractTarEntry(tr, header, dest, target); err != nil { return nil, fmt.Errorf("extract %s: %w", header.Name, err) @@ -401,7 +403,7 @@ func (c multiCloser) Close() error { // parents: extraction must never create an entry through a symlink an earlier // tar entry planted, so existing parents are Lstat-walked and rejected rather // than resolved. Symlink entries themselves may legitimately name paths that -// do not exist yet, so their targets are checked by resolve in +// do not exist yet, so their targets are checked by // validateSymlinkTarget instead. func safeJoin(root, name string) (string, error) { rootInfo, err := os.Lstat(root) @@ -447,6 +449,10 @@ func safeJoin(root, name string) (string, error) { return target, nil } +// validateSymlinkTarget permits absolute targets because OCI images may use +// them and the image filesystem must preserve their link text. Extraction +// still rejects symlink traversal for later entries, while composition copies +// symlink text without following it. func validateSymlinkTarget(root, target, linkname string) error { if filepath.IsAbs(linkname) { return nil @@ -467,8 +473,6 @@ func extractTarEntry(tr *tar.Reader, header *tar.Header, root, target string) er return extractTarFile(tr, target, header) case tar.TypeSymlink: return extractTarSymlink(root, target, header) - case tar.TypeLink: - return extractTarHardlink(root, target, header) case tar.TypeChar, tar.TypeBlock: return extractTarDevice(target, header) case tar.TypeFifo: @@ -525,17 +529,6 @@ func extractTarSymlink(root, target string, header *tar.Header) error { 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 @@ -665,7 +658,7 @@ func applyLayerTree(layerDir, targetDir string) (err error) { // Phase 2: copy the layer's own entries, skipping whiteout markers. // Directory metadata is deferred until all children are copied, so tar // directory mtimes survive the merge. - var pendingDirs []dirMeta + pendingDirs := make([]dirMeta, 0) hardlinks := make(map[hardlinkIdentity]string) err = filepath.WalkDir(layerDir, func(path string, entry fs.DirEntry, err error) error { if err != nil { @@ -897,6 +890,8 @@ func specialFileMode(mode fs.FileMode) (uint32, error) { return syscall.S_IFBLK, nil case fs.ModeNamedPipe: return syscall.S_IFIFO, nil + case fs.ModeSocket: + return syscall.S_IFSOCK, nil default: return 0, fmt.Errorf("unsupported file mode") } @@ -913,7 +908,11 @@ func copyEntryMetadata(src, dst string, info os.FileInfo) error { if err := os.Chmod(dst, mode); err != nil { return err } - if err := os.Chtimes(dst, info.ModTime(), info.ModTime()); err != nil { + atime := info.ModTime() + if stat, ok := info.Sys().(*syscall.Stat_t); ok { + atime = time.Unix(stat.Atim.Sec, stat.Atim.Nsec) + } + if err := os.Chtimes(dst, atime, info.ModTime()); err != nil { return err } if err := copyXattrs(src, dst); err != nil { diff --git a/lib/images/layer_artifact_test.go b/lib/images/layer_artifact_test.go index d92940b7a..610b7d490 100644 --- a/lib/images/layer_artifact_test.go +++ b/lib/images/layer_artifact_test.go @@ -13,6 +13,7 @@ import ( "os/exec" "path/filepath" "strings" + "syscall" "testing" "time" @@ -287,6 +288,12 @@ func TestUnpackLayerBlobRejectsSymlinkTraversal(t *testing.T) { require.ErrorContains(t, err, "symlink") } +func TestValidateSymlinkTargetAllowsAbsoluteTargets(t *testing.T) { + root := t.TempDir() + target := filepath.Join(root, "link") + require.NoError(t, validateSymlinkTarget(root, target, "/etc/resolv.conf")) +} + func TestApplyLayerTreeSymlinksAndHardlinks(t *testing.T) { root := t.TempDir() targetDir := filepath.Join(root, "target") @@ -372,6 +379,32 @@ func TestUnpackLayerBlobPreservesDirMtime(t *testing.T) { require.True(t, info.ModTime().Equal(dirTime), "dir mtime must come from the tar header") } +func TestCopyEntryMetadataPreservesAccessTime(t *testing.T) { + root := t.TempDir() + src := filepath.Join(root, "src") + dst := filepath.Join(root, "dst") + require.NoError(t, os.WriteFile(src, []byte("payload"), 0644)) + require.NoError(t, os.WriteFile(dst, nil, 0644)) + + atime := time.Now().Add(-2 * time.Hour).Truncate(time.Second) + mtime := time.Now().Add(-time.Hour).Truncate(time.Second) + require.NoError(t, os.Chtimes(src, atime, mtime)) + info, err := os.Lstat(src) + require.NoError(t, err) + require.NoError(t, copyEntryMetadata(src, dst, info)) + + var stat unix.Stat_t + require.NoError(t, unix.Lstat(dst, &stat)) + require.True(t, time.Unix(stat.Atim.Sec, stat.Atim.Nsec).Equal(atime), "atime must be preserved") + require.True(t, time.Unix(stat.Mtim.Sec, stat.Mtim.Nsec).Equal(mtime), "mtime must be preserved") +} + +func TestSpecialFileModeSupportsSockets(t *testing.T) { + mode, err := specialFileMode(fs.ModeSocket) + require.NoError(t, err) + require.Equal(t, uint32(syscall.S_IFSOCK), mode) +} + func TestApplyLayerTreePreservesDirMtime(t *testing.T) { root := t.TempDir() targetDir := filepath.Join(root, "target") From 66f4a008ef80ae6af1c3cbdbd5322ac418ac216f Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:30:05 +0000 Subject: [PATCH 04/21] Fix layer artifact composition edge cases --- lib/images/disk_usage.go | 29 +- lib/images/disk_usage_test.go | 15 + lib/images/layer_artifact.go | 597 ++++++------------------------ lib/images/layer_artifact_test.go | 112 +++++- lib/images/layer_tree.go | 487 ++++++++++++++++++++++++ lib/images/manager.go | 2 +- lib/paths/paths.go | 8 +- 7 files changed, 740 insertions(+), 510 deletions(-) create mode 100644 lib/images/layer_tree.go diff --git a/lib/images/disk_usage.go b/lib/images/disk_usage.go index b65dba1ac..8d098a46d 100644 --- a/lib/images/disk_usage.go +++ b/lib/images/disk_usage.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "syscall" ) @@ -82,6 +83,28 @@ func totalReadyImageBytesFromMetadata(imagesDir string) (int64, error) { return total, nil } +// totalLayerArtifactBytesFromFilesystem sums materialized layer artifacts. +func totalLayerArtifactBytesFromFilesystem(layersDir string) (int64, error) { + var total int64 + err := filepath.Walk(layersDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + if info.IsDir() || !strings.HasPrefix(info.Name(), "layer.") { + return nil + } + total += info.Size() + return nil + }) + if err != nil && !os.IsNotExist(err) { + return 0, fmt.Errorf("walk layer artifacts: %w", err) + } + return total, nil +} + // totalOCICacheBlobBytesFromFilesystem sums blob sizes directly from the OCI cache blob store. // This counts the actual bytes on disk, including any blob files that are currently // present but no longer referenced by the OCI layout index. @@ -158,7 +181,11 @@ func (m *manager) computeDiskUsageTotals() (int64, int64, error) { if err != nil { return 0, 0, err } - return readyImageBytes, ociCacheBytes, nil + layerArtifactBytes, err := totalLayerArtifactBytesFromFilesystem(m.paths.ImageLayersDir()) + if err != nil { + return 0, 0, err + } + return readyImageBytes, ociCacheBytes + layerArtifactBytes, nil } func totalRootfsBytesInDigestDir(digestDir string) (int64, error) { diff --git a/lib/images/disk_usage_test.go b/lib/images/disk_usage_test.go index bd6056fef..5b6a441ed 100644 --- a/lib/images/disk_usage_test.go +++ b/lib/images/disk_usage_test.go @@ -64,6 +64,21 @@ func TestTotalReadyImageBytesFromMetadata_DeduplicatesMalformedAliases(t *testin require.Equal(t, int64(len("shared-rootfs")), total) } +func TestTotalLayerArtifactBytesFromFilesystem(t *testing.T) { + t.Parallel() + + layersDir := t.TempDir() + digestDir := filepath.Join(layersDir, "a", "b") + require.NoError(t, os.MkdirAll(digestDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(digestDir, "layer.erofs"), []byte("erofs"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(digestDir, "layer.ext4"), []byte("ext4"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(digestDir, "artifact.erofs.json"), []byte("record"), 0o644)) + + total, err := totalLayerArtifactBytesFromFilesystem(layersDir) + require.NoError(t, err) + require.Equal(t, int64(len("erofs")+len("ext4")), total) +} + func TestTotalReadyImageBytesFromMetadata_UsesRootfsFallbackForReadyImageWithoutSize(t *testing.T) { t.Parallel() diff --git a/lib/images/layer_artifact.go b/lib/images/layer_artifact.go index 52d378829..fa51003a2 100644 --- a/lib/images/layer_artifact.go +++ b/lib/images/layer_artifact.go @@ -2,14 +2,12 @@ package images import ( "archive/tar" - "bytes" "compress/gzip" "crypto/sha256" "encoding/json" "errors" "fmt" "io" - "io/fs" "os" "path/filepath" "strings" @@ -37,19 +35,20 @@ const ( maxLayerUnpackedBytes = 100 << 30 ) +var errCorruptLayerRecord = errors.New("corrupt layer record") + // layerArtifact is the persisted record for one materialized layer artifact. // The key is the compressed layer blob digest plus the artifact format, 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"` - 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"` + SchemaVersion int `json:"schema_version"` + Digest string `json:"digest"` // compressed layer blob digest, sha256:... + DiffID string `json:"diff_id,omitempty"` + Format string `json:"format"` + SizeBytes int64 `json:"size_bytes"` // artifact bytes on disk + UnpackedBytes int64 `json:"unpacked_bytes"` + Entries int `json:"entries"` + CreatedAt time.Time `json:"created_at"` } // validate checks a record read back from disk. The format fully determines @@ -62,7 +61,7 @@ func (a *layerArtifact) validate() error { if a.Digest == "" { return fmt.Errorf("missing digest") } - if a.Format != layerFormatErofs && a.Format != layerFormatExt4 { + if a.Format != string(FormatErofs) && a.Format != string(FormatExt4) { return fmt.Errorf("invalid format: %s", a.Format) } if a.SizeBytes < 0 || a.UnpackedBytes < 0 || a.Entries < 0 { @@ -71,15 +70,6 @@ func (a *layerArtifact) validate() error { return nil } -// 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 { if a.Digest != desc.Digest || a.Format != layerArtifactFormat() { return false @@ -87,17 +77,10 @@ func (a *layerArtifact) matches(desc layerDescriptor) bool { return desc.DiffID == "" || a.DiffID == desc.DiffID } -const ( - layerFormatErofs = "erofs" - layerFormatExt4 = "ext4" -) - func layerArtifactFormat() string { switch DefaultImageFormat { - case FormatErofs: - return layerFormatErofs - case FormatExt4: - return layerFormatExt4 + case FormatErofs, FormatExt4: + return string(DefaultImageFormat) default: return "" } @@ -107,11 +90,15 @@ func layerArtifactPath(p *paths.Paths, layerHex string) string { return p.ImageLayerArtifactForFormat(layerHex, layerArtifactFormat()) } +func layerArtifactRecordPath(p *paths.Paths, layerHex string) string { + return p.ImageLayerRecordForFormat(layerHex, layerArtifactFormat()) +} + // 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)) + data, err := os.ReadFile(layerArtifactRecordPath(p, layerHex)) if err != nil { if os.IsNotExist(err) { return nil, nil @@ -120,14 +107,26 @@ func readLayerRecord(p *paths.Paths, layerHex string) (*layerArtifact, error) { } var record layerArtifact if err := json.Unmarshal(data, &record); err != nil { - return nil, fmt.Errorf("unmarshal layer record: %w", err) + return nil, fmt.Errorf("%w: unmarshal layer record: %v", errCorruptLayerRecord, err) } if err := record.validate(); err != nil { - return nil, fmt.Errorf("invalid layer record: %w", err) + return nil, fmt.Errorf("%w: invalid layer record: %v", errCorruptLayerRecord, err) } return &record, nil } +func discardLayerCache(p *paths.Paths, layerHex string) error { + for _, path := range []string{ + layerArtifactRecordPath(p, layerHex), + layerArtifactPath(p, layerHex), + } { + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return err + } + } + return 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 the @@ -143,11 +142,16 @@ func (m *manager) materializeLayerArtifact(desc layerDescriptor) (*layerArtifact } layerHex := strings.TrimPrefix(desc.Digest, "sha256:") if err := paths.ValidatePathComponent(layerHex); err != nil { - return nil, fmt.Errorf("invalid layer digest: %s", desc.Digest) + return nil, fmt.Errorf("invalid layer digest %s: %w", desc.Digest, err) } if record, err := readLayerRecord(m.paths, layerHex); err != nil { - return nil, err + if !errors.Is(err, errCorruptLayerRecord) { + return nil, err + } + if discardErr := discardLayerCache(m.paths, layerHex); discardErr != nil { + return nil, fmt.Errorf("discard corrupt layer cache: %w", discardErr) + } } else if record != nil && record.matches(desc) { if _, statErr := os.Stat(layerArtifactPath(m.paths, layerHex)); statErr == nil { return record, nil @@ -192,10 +196,7 @@ func (m *manager) installLayerArtifact(desc layerDescriptor, layerHex, unpackDir Format: layerArtifactFormat(), UnpackedBytes: stats.unpackedBytes, Entries: stats.entries, - // Nothing reads Whiteouts yet; composition re-derives whiteouts - // from the unpacked tree once it lands. - Whiteouts: stats.whiteouts, - CreatedAt: time.Now(), + CreatedAt: time.Now(), } if err := installAtomically(layerArtifactPath(m.paths, layerHex), func(path string) error { @@ -215,7 +216,7 @@ func (m *manager) installLayerArtifact(desc layerDescriptor, layerHex, unpackDir if err != nil { return nil, fmt.Errorf("marshal layer record: %w", err) } - if err := writeJSONAtomic(m.paths.ImageLayerRecord(layerHex), data); err != nil { + if err := writeJSONAtomic(layerArtifactRecordPath(m.paths, layerHex), data); err != nil { _ = os.Remove(layerArtifactPath(m.paths, layerHex)) return nil, fmt.Errorf("write layer record: %w", err) } @@ -226,11 +227,12 @@ type unpackStats struct { entries int unpackedBytes int64 diffID string - whiteouts []whiteoutRecord } -// unpackLayerBlob extracts one compressed layer blob into dest, preserving -// whiteout marker files and recording them. Paths are confined to dest. +// unpackLayerBlob extracts one compressed layer blob into dest while preserving +// whiteout marker files. It intentionally does not use umoci's layer unpacker: +// umoci consumes whiteouts while this store must retain them for composition. +// Paths are confined to dest. func unpackLayerBlob(blobPath, mediaType, dest string) (*unpackStats, error) { if err := os.MkdirAll(dest, 0755); err != nil { return nil, fmt.Errorf("create extraction root: %w", err) @@ -248,7 +250,7 @@ func unpackLayerBlob(blobPath, mediaType, dest string) (*unpackStats, error) { defer closer.Close() hash := sha256.New() - stats := &unpackStats{whiteouts: make([]whiteoutRecord, 0)} + stats := &unpackStats{} // Directory metadata is re-applied after extraction, once children // exist, so tar directory mtimes are not overwritten by later writes. pendingDirs := make([]pendingDir, 0) @@ -277,18 +279,12 @@ func unpackLayerBlob(blobPath, mediaType, dest string) (*unpackStats, error) { } 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) { + base := filepath.Base(header.Name) + if strings.HasPrefix(base, whiteoutPrefix) && base != opaqueWhiteout { 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 header.Typeflag == tar.TypeDir { @@ -334,33 +330,42 @@ type pendingHardlink struct { } func resolveHardlinks(root string, pending []pendingHardlink) error { - for len(pending) > 0 { - resolved := 0 - remaining := make([]pendingHardlink, 0, len(pending)) - for _, link := range pending { - linkTarget, err := safeJoin(root, link.linkname) - if err != nil { - return err - } - if _, err := os.Lstat(linkTarget); err != nil { - if os.IsNotExist(err) { - remaining = append(remaining, link) - continue - } - return err - } - if err := prepareTarTarget(link.target); err != nil { - return err - } - if err := os.Link(linkTarget, link.target); err != nil { - return fmt.Errorf("create hardlink %s: %w", link.target, err) - } - resolved++ + waiting := make(map[string][]pendingHardlink) + ready := make([]pendingHardlink, 0, len(pending)) + for _, link := range pending { + linkTarget, err := safeJoin(root, link.linkname) + if err != nil { + return err } - if resolved == 0 { - return fmt.Errorf("hardlink target not found") + if _, err := os.Lstat(linkTarget); err == nil { + ready = append(ready, link) + } else if os.IsNotExist(err) { + waiting[linkTarget] = append(waiting[linkTarget], link) + } else { + return err + } + } + + resolved := 0 + for len(ready) > 0 { + link := ready[0] + ready = ready[1:] + if err := prepareTarTarget(link.target); err != nil { + return err + } + if linkTarget, err := safeJoin(root, link.linkname); err != nil { + return err + } else if err := os.Link(linkTarget, link.target); err != nil { + return fmt.Errorf("create hardlink %s -> %s: %w", link.target, link.linkname, err) + } + resolved++ + ready = append(ready, waiting[link.target]...) + delete(waiting, link.target) + } + if resolved != len(pending) { + for _, links := range waiting { + return fmt.Errorf("hardlink target not found for %s", links[0].linkname) } - pending = remaining } return nil } @@ -542,12 +547,8 @@ func extractTarDevice(target string, header *tar.Header) error { 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 + if err := createRootlessDevicePlaceholder(target); err != nil { + return err } } return applyTarMetadata(target, header) @@ -563,417 +564,37 @@ func extractTarFIFO(target string, header *tar.Header) error { return applyTarMetadata(target, header) } -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 -} - -// removePath removes whatever entry occupies path, including non-empty -// directories, and tolerates a missing path. -func removePath(path string) error { - if err := os.RemoveAll(path); err != nil && !os.IsNotExist(err) { - return err - } - return nil -} - -// 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. No production caller yet; composition -// lands in a later change. -func applyLayerTree(layerDir, targetDir string) (err error) { - if err := os.MkdirAll(targetDir, 0755); err != nil { - return fmt.Errorf("create target directory: %w", err) - } - originalModes := make(map[string]fs.FileMode) - defer func() { - if restoreErr := restoreDirectoryModes(originalModes); restoreErr != nil { - if err == nil { - err = fmt.Errorf("restore directory modes: %w", restoreErr) - } else { - err = errors.Join(err, fmt.Errorf("restore directory modes: %w", restoreErr)) - } - } - }() - - // 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, err := safeJoin(targetDir, filepath.Dir(rel)) - if err != nil { - return err - } - if err := makePathWritable(targetDir, targetParent, originalModes); err != nil { - return err - } - if base == opaqueWhiteout { - return clearDirContents(targetParent) - } - hidden := strings.TrimPrefix(base, whiteoutPrefix) - 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) - } - - // Phase 2: copy the layer's own entries, skipping whiteout markers. - // Directory metadata is deferred until all children are copied, so tar - // directory mtimes survive the merge. - pendingDirs := make([]dirMeta, 0) - 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 - } - target, err := safeJoin(targetDir, rel) - if err != nil { - return err - } - if err := makePathWritable(targetDir, filepath.Dir(target), originalModes); err != nil { - return err - } - if entry.IsDir() { - info, err := entry.Info() - if err != nil { - return err - } - pendingDirs = append(pendingDirs, dirMeta{src: path, dst: target, info: info}) - } - if err := copyEntryInto(path, target, hardlinks); err != nil { - return err - } - if entry.IsDir() { - return makePathWritable(targetDir, target, originalModes) - } - return nil - }) +func createRootlessDevicePlaceholder(path string) error { + file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|syscall.O_NOFOLLOW, 0644) if err != nil { - return fmt.Errorf("copy layer tree: %w", err) - } - for _, dir := range pendingDirs { - if err := copyEntryMetadata(dir.src, dir.dst, dir.info); err != nil { - return fmt.Errorf("restore dir metadata %s: %w", dir.dst, err) - } - } - return nil -} - -func makePathWritable(root, path string, originalModes map[string]fs.FileMode) error { - root = filepath.Clean(root) - path = filepath.Clean(path) - if path != root && !strings.HasPrefix(path, root+string(filepath.Separator)) { - return fmt.Errorf("path is outside target root: %s", path) - } - for current := path; ; current = filepath.Dir(current) { - info, err := os.Lstat(current) - if err == nil { - if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { - return fmt.Errorf("target parent is not a directory: %s", current) - } - if _, recorded := originalModes[current]; !recorded { - originalModes[current] = info.Mode() - if err := os.Chmod(current, info.Mode().Perm()|0700); err != nil { - return err - } - } - } else if !os.IsNotExist(err) { - return err - } - if current == root { - return nil - } - } -} - -func restoreDirectoryModes(originalModes map[string]fs.FileMode) error { - var restoreErr error - for path, mode := range originalModes { - if _, err := os.Lstat(path); os.IsNotExist(err) { - continue - } else if err != nil { - restoreErr = errors.Join(restoreErr, err) - continue - } - if err := os.Chmod(path, mode.Perm()|mode&(os.ModeSetuid|os.ModeSetgid|os.ModeSticky)); err != nil { - restoreErr = errors.Join(restoreErr, err) - } + return fmt.Errorf("create rootless device placeholder: %w", err) } - return restoreErr -} - -type dirMeta struct { - src, dst string - info os.FileInfo -} - -// clearDirContents removes everything inside dir without removing dir itself, -// and without following symlinks. -func clearDirContents(dir string) error { - info, err := os.Lstat(dir) - if err != nil { - if os.IsNotExist(err) { - return nil - } - return err - } - if !info.IsDir() { - return removePath(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 - } - } - 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: - return copyRegularEntry(src, dst, info, hardlinks) - case fs.ModeDir: - return copyDirectoryEntry(src, dst, info) - case fs.ModeSymlink: - 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(src, dst, info) -} - -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 - } - } - return os.MkdirAll(dst, info.Mode().Perm()) -} - -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 - } - info, err := os.Lstat(src) - if err != nil { - return err - } - return copyEntryMetadata(src, dst, info) -} - -func copySpecialEntry(src, dst string, info os.FileInfo) error { - if err := removePath(dst); err != nil { + if err := file.Close(); 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: %w", src, err) - } - if err := unix.Mknod(dst, mode|uint32(info.Mode().Perm()), int(stat.Rdev)); err != nil { - if !errors.Is(err, unix.EPERM) { - return fmt.Errorf("mknod: %w", err) - } - file, openErr := os.OpenFile(dst, 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 copyEntryMetadata(src, 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 - case fs.ModeSocket: - return syscall.S_IFSOCK, nil - default: - return 0, fmt.Errorf("unsupported file mode") - } -} - -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 { - mode := info.Mode().Perm() | info.Mode()&(os.ModeSetuid|os.ModeSetgid|os.ModeSticky) - if err := os.Chmod(dst, mode); err != nil { - return err - } - atime := info.ModTime() - if stat, ok := info.Sys().(*syscall.Stat_t); ok { - atime = time.Unix(stat.Atim.Sec, stat.Atim.Nsec) - } - if err := os.Chtimes(dst, atime, 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 _, attr := range bytes.Split(bytes.TrimSuffix(names, []byte{0}), []byte{0}) { - if len(attr) == 0 { - continue - } - name := string(attr) - 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 +func applyTarMetadata(path string, header *tar.Header) error { + mtime := header.ModTime + if mtime.IsZero() { + mtime = time.Now() + } + atime := header.AccessTime + if atime.IsZero() { + atime = mtime + } + return applyEntryMetadata(path, entryMetadata{ + uid: header.Uid, + gid: header.Gid, + hasOwner: true, + mode: os.FileMode(header.Mode), + symlink: header.Typeflag == tar.TypeSymlink, + atime: atime, + mtime: mtime, + xattrs: tarXattrs(header), + }) } -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|syscall.O_NOFOLLOW, 0644) - if err != nil { - return err - } - if _, err := io.Copy(out, in); err != nil { - out.Close() - return err - } - return out.Close() -} +// removePath removes whatever entry occupies path, including non-empty +// directories, and tolerates a missing path. diff --git a/lib/images/layer_artifact_test.go b/lib/images/layer_artifact_test.go index 610b7d490..924eaf091 100644 --- a/lib/images/layer_artifact_test.go +++ b/lib/images/layer_artifact_test.go @@ -5,6 +5,7 @@ import ( "bytes" "compress/gzip" "crypto/sha256" + "encoding/json" "errors" "fmt" "io" @@ -93,6 +94,43 @@ func TestMaterializeLayerArtifact(t *testing.T) { require.Equal(t, artifactInfo.ModTime(), artifactInfoAfter.ModTime(), "reuse must not rebuild") } +func TestMaterializeLayerArtifactRecoversCorruptRecord(t *testing.T) { + if _, err := exec.LookPath("mkfs.ext4"); err != nil { + t.Skip("mkfs.ext4 not available") + } + originalFormat := DefaultImageFormat + DefaultImageFormat = FormatExt4 + t.Cleanup(func() { DefaultImageFormat = originalFormat }) + + 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} + first, err := m.materializeLayerArtifact(desc) + require.NoError(t, err) + + layerHex := desc.Digest[len("sha256:"):] + require.NoError(t, os.WriteFile( + p.ImageLayerRecordForFormat(layerHex, layerArtifactFormat()), + []byte("{not valid json"), + 0600, + )) + + second, err := m.materializeLayerArtifact(desc) + require.NoError(t, err) + require.NotEqual(t, first.CreatedAt, second.CreatedAt, "corrupt record must trigger a rebuild") + require.FileExists(t, p.ImageLayerArtifactForFormat(layerHex, layerArtifactFormat())) + + data, err := os.ReadFile(p.ImageLayerRecordForFormat(layerHex, layerArtifactFormat())) + require.NoError(t, err) + var record layerArtifact + require.NoError(t, json.Unmarshal(data, &record)) + require.NoError(t, record.validate()) +} + func TestMaterializeLayerArtifactMissingBlob(t *testing.T) { p := paths.New(t.TempDir()) m := &manager{paths: p} @@ -141,7 +179,7 @@ func whiteoutLayer(t *testing.T) gcr.Layer { return layer } -func TestMaterializeLayerRecordsWhiteouts(t *testing.T) { +func TestMaterializeLayerDoesNotPersistWhiteoutInventory(t *testing.T) { if _, err := exec.LookPath("mkfs.erofs"); err != nil { t.Skip("mkfs.erofs not available") } @@ -154,22 +192,12 @@ func TestMaterializeLayerRecordsWhiteouts(t *testing.T) { desc := layerDescFromImage(t, img, 0) m := &manager{paths: p} - record, err := m.materializeLayerArtifact(desc) + _, 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) + data, err := os.ReadFile(p.ImageLayerRecordForFormat(desc.Digest[len("sha256:"):], layerArtifactFormat())) + require.NoError(t, err) + require.NotContains(t, string(data), "whiteouts") } func TestApplyLayerTreeWhiteoutSemantics(t *testing.T) { @@ -244,6 +272,38 @@ func TestApplyLayerTreeWhiteoutSemantics(t *testing.T) { require.Empty(t, leaked) } +func TestApplyLayerTreePreservesUpperDirectoryMode(t *testing.T) { + root := t.TempDir() + targetDir := filepath.Join(root, "target") + layerDir := filepath.Join(root, "layer") + + require.NoError(t, os.MkdirAll(filepath.Join(targetDir, "etc"), 0755)) + require.NoError(t, os.MkdirAll(filepath.Join(layerDir, "etc"), 0750)) + require.NoError(t, os.WriteFile(filepath.Join(layerDir, "etc", "new.txt"), []byte("new"), 0644)) + + require.NoError(t, applyLayerTree(layerDir, targetDir)) + + info, err := os.Stat(filepath.Join(targetDir, "etc")) + require.NoError(t, err) + require.Equal(t, os.FileMode(0750), info.Mode().Perm()) +} + +func TestApplyLayerTreeWhiteoutRemovesReadOnlyDirectory(t *testing.T) { + root := t.TempDir() + targetDir := filepath.Join(root, "target") + layerDir := filepath.Join(root, "layer") + + require.NoError(t, os.MkdirAll(filepath.Join(targetDir, "gone"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(targetDir, "gone", "inner.txt"), []byte("old"), 0644)) + require.NoError(t, os.Chmod(filepath.Join(targetDir, "gone"), 0555)) + require.NoError(t, os.MkdirAll(layerDir, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(layerDir, ".wh.gone"), nil, 0644)) + + require.NoError(t, applyLayerTree(layerDir, targetDir)) + _, err := os.Lstat(filepath.Join(targetDir, "gone")) + require.ErrorIs(t, err, os.ErrNotExist) +} + func TestUnpackLayerBlobIncludesTrailingTarPaddingInDiffID(t *testing.T) { root := t.TempDir() blobPath := filepath.Join(root, "layer.tar.gz") @@ -270,6 +330,24 @@ func TestUnpackLayerBlobIncludesTrailingTarPaddingInDiffID(t *testing.T) { require.Equal(t, "sha256:"+fmt.Sprintf("%x", want), stats.diffID) } +func TestResolveHardlinksResolvesDependencyChain(t *testing.T) { + root := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(root, "source"), []byte("content"), 0644)) + + pending := []pendingHardlink{ + {target: filepath.Join(root, "third"), linkname: "second"}, + {target: filepath.Join(root, "second"), linkname: "first"}, + {target: filepath.Join(root, "first"), linkname: "source"}, + } + require.NoError(t, resolveHardlinks(root, pending)) + + source, err := os.Stat(filepath.Join(root, "source")) + require.NoError(t, err) + third, err := os.Stat(filepath.Join(root, "third")) + require.NoError(t, err) + require.True(t, os.SameFile(source, third)) +} + func TestUnpackLayerBlobRejectsSymlinkTraversal(t *testing.T) { root := t.TempDir() blobPath := filepath.Join(root, "layer.tar.gz") @@ -344,7 +422,9 @@ func TestCopyXattrs(t *testing.T) { require.NoError(t, err) require.NoError(t, unix.Lsetxattr(src, "user.two", []byte("22"), 0)) - require.NoError(t, copyXattrs(src, dst)) + xattrs, err := readXattrs(src) + require.NoError(t, err) + require.NoError(t, applyXattrs(dst, xattrs)) for name, want := range map[string]string{"user.one": "1", "user.two": "22"} { size, err := unix.Lgetxattr(dst, name, nil) diff --git a/lib/images/layer_tree.go b/lib/images/layer_tree.go new file mode 100644 index 000000000..5ff887896 --- /dev/null +++ b/lib/images/layer_tree.go @@ -0,0 +1,487 @@ +package images + +import ( + "archive/tar" + "bytes" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "strings" + "syscall" + "time" + + "golang.org/x/sys/unix" +) + +func removePath(path string) error { + if err := makeTreeWritable(path); err != nil && !os.IsNotExist(err) { + return err + } + if err := os.RemoveAll(path); err != nil && !os.IsNotExist(err) { + return err + } + return nil +} + +func makeTreeWritable(path string) error { + info, err := os.Lstat(path) + if err != nil { + return err + } + if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return nil + } + if err := os.Chmod(path, info.Mode().Perm()|0700); err != nil { + return err + } + return filepath.WalkDir(path, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() && entry.Type()&os.ModeSymlink == 0 { + info, err := entry.Info() + if err != nil { + return err + } + return os.Chmod(path, info.Mode().Perm()|0700) + } + return nil + }) +} + +// 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. No production caller yet; composition +// lands in a later change. +func applyLayerTree(layerDir, targetDir string) (err error) { + if err := os.MkdirAll(targetDir, 0755); err != nil { + return fmt.Errorf("create target directory: %w", err) + } + originalModes := make(map[string]fs.FileMode) + defer func() { + if restoreErr := restoreDirectoryModes(originalModes); restoreErr != nil { + if err == nil { + err = fmt.Errorf("restore directory modes: %w", restoreErr) + } else { + err = errors.Join(err, fmt.Errorf("restore directory modes: %w", restoreErr)) + } + } + }() + + // 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, err := safeJoin(targetDir, filepath.Dir(rel)) + if err != nil { + return err + } + if err := makePathWritable(targetDir, targetParent, originalModes); err != nil { + return err + } + if base == opaqueWhiteout { + return clearDirContents(targetParent) + } + hidden := strings.TrimPrefix(base, whiteoutPrefix) + target, err := safeJoin(targetDir, filepath.Join(filepath.Dir(rel), hidden)) + if err != nil { + return err + } + if info, err := os.Lstat(target); err == nil && info.IsDir() && info.Mode()&os.ModeSymlink == 0 { + if err := makePathWritable(targetDir, target, originalModes); err != nil { + return err + } + } else if err != nil && !os.IsNotExist(err) { + return err + } + return removePath(target) + }) + if err != nil { + return fmt.Errorf("apply whiteouts: %w", err) + } + + // Phase 2: copy the layer's own entries, skipping whiteout markers. + // Directory metadata is deferred until all children are copied, so tar + // directory mtimes survive the merge. + pendingDirs := make([]dirMeta, 0) + 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 + } + target, err := safeJoin(targetDir, rel) + if err != nil { + return err + } + if err := makePathWritable(targetDir, filepath.Dir(target), originalModes); err != nil { + return err + } + if entry.IsDir() { + info, err := entry.Info() + if err != nil { + return err + } + pendingDirs = append(pendingDirs, dirMeta{src: path, dst: target, info: info}) + } + if err := copyEntryInto(path, target, hardlinks); err != nil { + return err + } + if entry.IsDir() { + return makePathWritable(targetDir, target, originalModes) + } + return nil + }) + if err != nil { + return fmt.Errorf("copy layer tree: %w", err) + } + for _, dir := range pendingDirs { + if err := copyEntryMetadata(dir.src, dir.dst, dir.info); err != nil { + return fmt.Errorf("restore dir metadata %s: %w", dir.dst, err) + } + delete(originalModes, dir.dst) + } + return nil +} + +func makePathWritable(root, path string, originalModes map[string]fs.FileMode) error { + root = filepath.Clean(root) + path = filepath.Clean(path) + if path != root && !strings.HasPrefix(path, root+string(filepath.Separator)) { + return fmt.Errorf("path is outside target root: %s", path) + } + for current := path; ; current = filepath.Dir(current) { + info, err := os.Lstat(current) + if err == nil { + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return fmt.Errorf("target parent is not a directory: %s", current) + } + if _, recorded := originalModes[current]; !recorded { + originalModes[current] = info.Mode() + if err := os.Chmod(current, info.Mode().Perm()|0700); err != nil { + return err + } + } + } else if !os.IsNotExist(err) { + return err + } + if current == root { + return nil + } + } +} + +func restoreDirectoryModes(originalModes map[string]fs.FileMode) error { + var restoreErr error + for path, mode := range originalModes { + if _, err := os.Lstat(path); os.IsNotExist(err) { + continue + } else if err != nil { + restoreErr = errors.Join(restoreErr, err) + continue + } + if err := os.Chmod(path, mode.Perm()|mode&(os.ModeSetuid|os.ModeSetgid|os.ModeSticky)); err != nil { + restoreErr = errors.Join(restoreErr, err) + } + } + return restoreErr +} + +type dirMeta struct { + src, dst string + info os.FileInfo +} + +// clearDirContents removes everything inside dir without removing dir itself, +// and without following symlinks. +func clearDirContents(dir string) error { + info, err := os.Lstat(dir) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + if !info.IsDir() { + return removePath(dir) + } + entries, err := os.ReadDir(dir) + if err != nil { + return err + } + for _, entry := range entries { + if err := removePath(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: + return copyRegularEntry(src, dst, info, hardlinks) + case fs.ModeDir: + return copyDirectoryEntry(src, dst, info) + case fs.ModeSymlink: + 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(src, dst, info) +} + +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 + } + } + return os.MkdirAll(dst, info.Mode().Perm()) +} + +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 + } + info, err := os.Lstat(src) + if err != nil { + return err + } + return copyEntryMetadata(src, dst, info) +} + +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: %w", src, err) + } + if err := unix.Mknod(dst, mode|uint32(info.Mode().Perm()), int(stat.Rdev)); err != nil { + if !errors.Is(err, unix.EPERM) { + return fmt.Errorf("mknod: %w", err) + } + if err := createRootlessDevicePlaceholder(dst); err != nil { + return err + } + } + return copyEntryMetadata(src, 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 + case fs.ModeSocket: + return syscall.S_IFSOCK, nil + default: + return 0, fmt.Errorf("unsupported file mode") + } +} + +type entryMetadata struct { + uid, gid int + hasOwner bool + mode fs.FileMode + symlink bool + atime time.Time + mtime time.Time + xattrs map[string][]byte +} + +func applyEntryMetadata(path string, metadata entryMetadata) error { + if metadata.hasOwner { + if err := os.Lchown(path, metadata.uid, metadata.gid); err != nil && !errors.Is(err, os.ErrPermission) && !errors.Is(err, unix.EPERM) { + return err + } + } + if metadata.symlink { + return nil + } + mode := metadata.mode.Perm() | metadata.mode&(os.ModeSetuid|os.ModeSetgid|os.ModeSticky) + if err := os.Chmod(path, mode); err != nil { + return err + } + if err := os.Chtimes(path, metadata.atime, metadata.mtime); err != nil { + return err + } + return applyXattrs(path, metadata.xattrs) +} + +func copyEntryMetadata(src, dst string, info os.FileInfo) error { + metadata := entryMetadata{ + mode: info.Mode(), + symlink: info.Mode()&os.ModeSymlink != 0, + atime: info.ModTime(), + mtime: info.ModTime(), + } + if stat, ok := info.Sys().(*syscall.Stat_t); ok { + metadata.uid = int(stat.Uid) + metadata.gid = int(stat.Gid) + metadata.hasOwner = true + metadata.atime = time.Unix(stat.Atim.Sec, stat.Atim.Nsec) + } + if !metadata.symlink { + xattrs, err := readXattrs(src) + if err != nil { + return err + } + metadata.xattrs = xattrs + } + return applyEntryMetadata(dst, metadata) +} + +func readXattrs(src string) (map[string][]byte, error) { + size, err := unix.Llistxattr(src, nil) + if err != nil { + if errors.Is(err, unix.ENOTSUP) || errors.Is(err, unix.EPERM) { + return nil, nil + } + return nil, err + } + names := make([]byte, size) + if size > 0 { + n, err := unix.Llistxattr(src, names) + if err != nil { + return nil, err + } + names = names[:n] + } + xattrs := make(map[string][]byte) + for _, attr := range bytes.Split(bytes.TrimSuffix(names, []byte{0}), []byte{0}) { + if len(attr) == 0 { + continue + } + name := string(attr) + 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 nil, err + } + value := make([]byte, size) + if _, err := unix.Lgetxattr(src, name, value); err != nil { + return nil, err + } + xattrs[name] = value + } + return xattrs, nil +} + +func tarXattrs(header *tar.Header) map[string][]byte { + const paxXattrPrefix = "SCHILY.xattr." + xattrs := make(map[string][]byte, len(header.Xattrs)) + for name, value := range header.Xattrs { + xattrs[name] = []byte(value) + } + for key, value := range header.PAXRecords { + if strings.HasPrefix(key, paxXattrPrefix) { + name := strings.TrimPrefix(key, paxXattrPrefix) + if _, exists := xattrs[name]; !exists { + xattrs[name] = []byte(value) + } + } + } + return xattrs +} + +func applyXattrs(path string, xattrs map[string][]byte) error { + for name, value := range xattrs { + if err := unix.Lsetxattr(path, name, 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 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|syscall.O_NOFOLLOW, 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/manager.go b/lib/images/manager.go index a5ea4b732..12fe1c244 100644 --- a/lib/images/manager.go +++ b/lib/images/manager.go @@ -55,7 +55,7 @@ type Manager interface { // TotalImageBytes returns the total size of all ready images on disk. // Used by the resource manager for disk capacity tracking. TotalImageBytes(ctx context.Context) (int64, error) - // TotalOCICacheBytes returns the total size of the OCI layer cache. + // TotalOCICacheBytes returns the total size of the OCI and materialized layer caches. // Used by the resource manager for disk capacity tracking. TotalOCICacheBytes(ctx context.Context) (int64, error) // WaitForReady blocks until the image identified by name reaches a terminal diff --git a/lib/paths/paths.go b/lib/paths/paths.go index b47bdbbf1..b294f2417 100644 --- a/lib/paths/paths.go +++ b/lib/paths/paths.go @@ -193,10 +193,10 @@ 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 -// materialized layer (format, options, whiteout inventory). -func (p *Paths) ImageLayerRecord(layerHex string) string { - return filepath.Join(p.ImageLayerDir(layerHex), "artifact.json") +// ImageLayerRecordForFormat returns the path to the artifact record for one +// materialized layer and format. +func (p *Paths) ImageLayerRecordForFormat(layerHex, format string) string { + return filepath.Join(p.ImageLayerDir(layerHex), "artifact."+format+".json") } // ImageDigestDir returns the directory for a specific image digest. From 6f771a742ff5ef06a098c47cff03a65d2543233c Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Wed, 2 Sep 2026 17:09:47 +0000 Subject: [PATCH 05/21] Fix layer artifact composition and metadata handling edge cases --- lib/images/disk_usage.go | 15 ++++- lib/images/disk_usage_test.go | 5 ++ lib/images/layer_artifact.go | 24 ++++--- lib/images/layer_artifact_test.go | 103 ++++++++++++++++++++++++++++++ lib/images/layer_tree.go | 78 +++++++++++++++------- 5 files changed, 187 insertions(+), 38 deletions(-) diff --git a/lib/images/disk_usage.go b/lib/images/disk_usage.go index 8d098a46d..b9b91bf19 100644 --- a/lib/images/disk_usage.go +++ b/lib/images/disk_usage.go @@ -3,6 +3,7 @@ package images import ( "encoding/json" "fmt" + "io/fs" "os" "path/filepath" "strings" @@ -86,16 +87,26 @@ func totalReadyImageBytesFromMetadata(imagesDir string) (int64, error) { // totalLayerArtifactBytesFromFilesystem sums materialized layer artifacts. func totalLayerArtifactBytesFromFilesystem(layersDir string) (int64, error) { var total int64 - err := filepath.Walk(layersDir, func(path string, info os.FileInfo, err error) error { + err := filepath.WalkDir(layersDir, func(path string, d fs.DirEntry, err error) error { if err != nil { if os.IsNotExist(err) { return nil } return err } - if info.IsDir() || !strings.HasPrefix(info.Name(), "layer.") { + if d.IsDir() { + if strings.HasPrefix(d.Name(), ".") && path != layersDir { + return filepath.SkipDir + } + return nil + } + if !strings.HasPrefix(d.Name(), "layer.") { return nil } + info, err := d.Info() + if err != nil { + return err + } total += info.Size() return nil }) diff --git a/lib/images/disk_usage_test.go b/lib/images/disk_usage_test.go index 5b6a441ed..7de35871b 100644 --- a/lib/images/disk_usage_test.go +++ b/lib/images/disk_usage_test.go @@ -74,6 +74,11 @@ func TestTotalLayerArtifactBytesFromFilesystem(t *testing.T) { require.NoError(t, os.WriteFile(filepath.Join(digestDir, "layer.ext4"), []byte("ext4"), 0o644)) require.NoError(t, os.WriteFile(filepath.Join(digestDir, "artifact.erofs.json"), []byte("record"), 0o644)) + // In-progress temp dirs should be skipped. + unpackDir := filepath.Join(digestDir, ".unpack-tmp") + require.NoError(t, os.MkdirAll(unpackDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(unpackDir, "layer.bin"), []byte("unpacked-content"), 0o644)) + total, err := totalLayerArtifactBytesFromFilesystem(layersDir) require.NoError(t, err) require.Equal(t, int64(len("erofs")+len("ext4")), total) diff --git a/lib/images/layer_artifact.go b/lib/images/layer_artifact.go index fa51003a2..572500c12 100644 --- a/lib/images/layer_artifact.go +++ b/lib/images/layer_artifact.go @@ -175,7 +175,7 @@ func (m *manager) materializeLayerArtifact(desc layerDescriptor) (*layerArtifact if err != nil { return nil, fmt.Errorf("create unpack directory: %w", err) } - defer os.RemoveAll(unpackDir) + defer removePath(unpackDir) stats, err := unpackLayerBlob(blobPath, desc.MediaType, unpackDir) if err != nil { @@ -310,7 +310,8 @@ func unpackLayerBlob(blobPath, mediaType, dest string) (*unpackStats, error) { if err := resolveHardlinks(dest, pendingHardlinks); err != nil { return nil, err } - for _, dir := range pendingDirs { + for i := len(pendingDirs) - 1; i >= 0; i-- { + dir := pendingDirs[i] if err := applyTarMetadata(dir.target, dir.header); err != nil { return nil, fmt.Errorf("restore dir metadata %s: %w", dir.target, err) } @@ -325,8 +326,9 @@ type pendingDir struct { } type pendingHardlink struct { - target string - linkname string + target string + linkname string + linkTarget string } func resolveHardlinks(root string, pending []pendingHardlink) error { @@ -337,6 +339,7 @@ func resolveHardlinks(root string, pending []pendingHardlink) error { if err != nil { return err } + link.linkTarget = linkTarget if _, err := os.Lstat(linkTarget); err == nil { ready = append(ready, link) } else if os.IsNotExist(err) { @@ -353,9 +356,7 @@ func resolveHardlinks(root string, pending []pendingHardlink) error { if err := prepareTarTarget(link.target); err != nil { return err } - if linkTarget, err := safeJoin(root, link.linkname); err != nil { - return err - } else if err := os.Link(linkTarget, link.target); err != nil { + if err := os.Link(link.linkTarget, link.target); err != nil { return fmt.Errorf("create hardlink %s -> %s: %w", link.target, link.linkname, err) } resolved++ @@ -411,6 +412,7 @@ func (c multiCloser) Close() error { // do not exist yet, so their targets are checked by // validateSymlinkTarget instead. func safeJoin(root, name string) (string, error) { + root = filepath.Clean(root) rootInfo, err := os.Lstat(root) if err != nil { return "", fmt.Errorf("inspect extraction root: %w", err) @@ -423,12 +425,11 @@ func safeJoin(root, name string) (string, error) { } clean := filepath.Clean(name) if clean == "." { - return filepath.Clean(root), nil + return root, nil } 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 { return target, nil @@ -588,13 +589,10 @@ func applyTarMetadata(path string, header *tar.Header) error { uid: header.Uid, gid: header.Gid, hasOwner: true, - mode: os.FileMode(header.Mode), + mode: header.FileInfo().Mode(), symlink: header.Typeflag == tar.TypeSymlink, atime: atime, mtime: mtime, xattrs: tarXattrs(header), }) } - -// removePath removes whatever entry occupies path, including non-empty -// directories, and tolerates a missing path. diff --git a/lib/images/layer_artifact_test.go b/lib/images/layer_artifact_test.go index 924eaf091..cc798bfeb 100644 --- a/lib/images/layer_artifact_test.go +++ b/lib/images/layer_artifact_test.go @@ -501,3 +501,106 @@ func TestApplyLayerTreePreservesDirMtime(t *testing.T) { require.NoError(t, err) require.True(t, info.ModTime().Equal(old), "dir mtime must survive the merge") } + +func TestSpecialFileModeSupportsCharDevice(t *testing.T) { + mode, err := specialFileMode(fs.ModeCharDevice | fs.ModeDevice) + require.NoError(t, err) + require.Equal(t, uint32(syscall.S_IFCHR), mode) +} + +func TestApplyTarMetadataPreservesSpecialBits(t *testing.T) { + root := t.TempDir() + target := filepath.Join(root, "suid-bin") + require.NoError(t, os.WriteFile(target, []byte("bin"), 0755)) + + header := &tar.Header{ + Name: "suid-bin", + Typeflag: tar.TypeReg, + Mode: 04755, + } + require.NoError(t, applyTarMetadata(target, header)) + + info, err := os.Stat(target) + require.NoError(t, err) + require.Equal(t, os.ModeSetuid, info.Mode()&os.ModeSetuid, "setuid bit must be preserved") +} + +func TestApplyLayerTreeSanitizesWhiteouts(t *testing.T) { + root := t.TempDir() + targetDir := filepath.Join(root, "target") + layerDir := filepath.Join(root, "layer") + + require.NoError(t, os.MkdirAll(filepath.Join(targetDir, "parent", "child"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(targetDir, "parent", "victim.txt"), []byte("v"), 0644)) + require.NoError(t, os.MkdirAll(filepath.Join(layerDir, "parent"), 0755)) + // Malformed whiteouts that must be safely ignored: + require.NoError(t, os.WriteFile(filepath.Join(layerDir, "parent", ".wh."), nil, 0644)) + require.NoError(t, os.WriteFile(filepath.Join(layerDir, "parent", ".wh.."), nil, 0644)) + require.NoError(t, os.WriteFile(filepath.Join(layerDir, "parent", ".wh..."), nil, 0644)) + + require.NoError(t, applyLayerTree(layerDir, targetDir)) + + // Parent directory must not be deleted by malformed whiteout paths. + info, err := os.Stat(filepath.Join(targetDir, "parent")) + require.NoError(t, err) + require.True(t, info.IsDir()) + require.FileExists(t, filepath.Join(targetDir, "parent", "victim.txt")) +} + +func TestApplyLayerTreeRestoresRestrictiveNestedDirModes(t *testing.T) { + root := t.TempDir() + t.Cleanup(func() { _ = makeTreeWritable(root) }) + targetDir := filepath.Join(root, "target") + layerDir := filepath.Join(root, "layer") + + require.NoError(t, os.MkdirAll(filepath.Join(layerDir, "outer", "inner"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(layerDir, "outer", "inner", "file.txt"), []byte("data"), 0644)) + require.NoError(t, os.Chmod(filepath.Join(layerDir, "outer", "inner"), 0500)) + require.NoError(t, os.Chmod(filepath.Join(layerDir, "outer"), 0500)) + + require.NoError(t, applyLayerTree(layerDir, targetDir)) + + infoOuter, err := os.Stat(filepath.Join(targetDir, "outer")) + require.NoError(t, err) + require.Equal(t, os.FileMode(0500), infoOuter.Mode().Perm()) + + infoInner, err := os.Stat(filepath.Join(targetDir, "outer", "inner")) + require.NoError(t, err) + require.Equal(t, os.FileMode(0500), infoInner.Mode().Perm()) +} + +func TestMakePathWritableTopDown(t *testing.T) { + root := t.TempDir() + t.Cleanup(func() { _ = makeTreeWritable(root) }) + parent := filepath.Join(root, "restricted") + child := filepath.Join(parent, "child") + require.NoError(t, os.MkdirAll(child, 0755)) + require.NoError(t, os.Chmod(parent, 0400)) // read-only, no execute + + originalModes := make(map[string]fs.FileMode) + err := makePathWritable(root, child, originalModes) + require.NoError(t, err) + + info, err := os.Stat(parent) + require.NoError(t, err) + require.True(t, info.Mode().Perm()&0700 == 0700) + + require.NoError(t, restoreDirectoryModes(originalModes)) + infoAfter, err := os.Stat(parent) + require.NoError(t, err) + require.Equal(t, os.FileMode(0400), infoAfter.Mode().Perm()) +} + +func TestSafeJoinCleanRootSymlink(t *testing.T) { + root := t.TempDir() + realDir := filepath.Join(root, "real") + symDir := filepath.Join(root, "sym") + require.NoError(t, os.Mkdir(realDir, 0755)) + require.NoError(t, os.Symlink("real", symDir)) + + // Calling safeJoin with trailing slash on symlink root must be rejected as symlink. + _, err := safeJoin(symDir+"/", "file.txt") + require.Error(t, err) + require.Contains(t, err.Error(), "extraction root is not a directory") +} + diff --git a/lib/images/layer_tree.go b/lib/images/layer_tree.go index 5ff887896..fd120d86b 100644 --- a/lib/images/layer_tree.go +++ b/lib/images/layer_tree.go @@ -9,6 +9,7 @@ import ( "io/fs" "os" "path/filepath" + "sort" "strings" "syscall" "time" @@ -37,7 +38,7 @@ func makeTreeWritable(path string) error { if err := os.Chmod(path, info.Mode().Perm()|0700); err != nil { return err } - return filepath.WalkDir(path, func(path string, entry fs.DirEntry, err error) error { + return filepath.WalkDir(path, func(p string, entry fs.DirEntry, err error) error { if err != nil { return err } @@ -46,7 +47,7 @@ func makeTreeWritable(path string) error { if err != nil { return err } - return os.Chmod(path, info.Mode().Perm()|0700) + return os.Chmod(p, info.Mode().Perm()|0700) } return nil }) @@ -97,6 +98,9 @@ func applyLayerTree(layerDir, targetDir string) (err error) { return clearDirContents(targetParent) } hidden := strings.TrimPrefix(base, whiteoutPrefix) + if hidden == "" || hidden == "." || hidden == ".." { + return nil + } target, err := safeJoin(targetDir, filepath.Join(filepath.Dir(rel), hidden)) if err != nil { return err @@ -161,7 +165,8 @@ func applyLayerTree(layerDir, targetDir string) (err error) { if err != nil { return fmt.Errorf("copy layer tree: %w", err) } - for _, dir := range pendingDirs { + for i := len(pendingDirs) - 1; i >= 0; i-- { + dir := pendingDirs[i] if err := copyEntryMetadata(dir.src, dir.dst, dir.info); err != nil { return fmt.Errorf("restore dir metadata %s: %w", dir.dst, err) } @@ -176,30 +181,57 @@ func makePathWritable(root, path string, originalModes map[string]fs.FileMode) e if path != root && !strings.HasPrefix(path, root+string(filepath.Separator)) { return fmt.Errorf("path is outside target root: %s", path) } - for current := path; ; current = filepath.Dir(current) { - info, err := os.Lstat(current) - if err == nil { - if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { - return fmt.Errorf("target parent is not a directory: %s", current) - } - if _, recorded := originalModes[current]; !recorded { - originalModes[current] = info.Mode() - if err := os.Chmod(current, info.Mode().Perm()|0700); err != nil { - return err - } - } - } else if !os.IsNotExist(err) { + rel, err := filepath.Rel(root, path) + if err != nil { + return err + } + if err := makeDirWritable(root, originalModes); err != nil { + return err + } + if rel == "." { + return nil + } + current := root + for _, part := range strings.Split(rel, string(filepath.Separator)) { + current = filepath.Join(current, part) + if err := makeDirWritable(current, originalModes); err != nil { return err } - if current == root { + } + return nil +} + +func makeDirWritable(dir string, originalModes map[string]fs.FileMode) error { + info, err := os.Lstat(dir) + if err != nil { + if os.IsNotExist(err) { return nil } + return err + } + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return fmt.Errorf("target parent is not a directory: %s", dir) } + if _, recorded := originalModes[dir]; !recorded { + originalModes[dir] = info.Mode() + if err := os.Chmod(dir, info.Mode().Perm()|0700); err != nil { + return err + } + } + return nil } func restoreDirectoryModes(originalModes map[string]fs.FileMode) error { + paths := make([]string, 0, len(originalModes)) + for path := range originalModes { + paths = append(paths, path) + } + sort.Slice(paths, func(i, j int) bool { + return len(paths[i]) > len(paths[j]) + }) var restoreErr error - for path, mode := range originalModes { + for _, path := range paths { + mode := originalModes[path] if _, err := os.Lstat(path); os.IsNotExist(err) { continue } else if err != nil { @@ -339,14 +371,14 @@ func copySpecialEntry(src, dst string, info os.FileInfo) error { } func specialFileMode(mode fs.FileMode) (uint32, error) { - switch mode { - case fs.ModeCharDevice: + switch { + case mode&fs.ModeCharDevice != 0: return syscall.S_IFCHR, nil - case fs.ModeDevice: + case mode&fs.ModeDevice != 0: return syscall.S_IFBLK, nil - case fs.ModeNamedPipe: + case mode&fs.ModeNamedPipe != 0: return syscall.S_IFIFO, nil - case fs.ModeSocket: + case mode&fs.ModeSocket != 0: return syscall.S_IFSOCK, nil default: return 0, fmt.Errorf("unsupported file mode") From 10c578dd6432ad065eb1d9bbaad2ccd72e5904e4 Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:43:00 +0000 Subject: [PATCH 06/21] Harden layer artifact composition --- lib/images/fs_entry.go | 33 +++++++++++++++++++++ lib/images/layer_artifact.go | 22 ++------------ lib/images/layer_artifact_test.go | 25 +++++++++++++++- lib/images/layer_tree.go | 48 ++++++++++++++++++++++++++----- lib/images/manager.go | 2 +- lib/resources/resource.go | 2 +- 6 files changed, 103 insertions(+), 29 deletions(-) create mode 100644 lib/images/fs_entry.go diff --git a/lib/images/fs_entry.go b/lib/images/fs_entry.go new file mode 100644 index 000000000..72443077f --- /dev/null +++ b/lib/images/fs_entry.go @@ -0,0 +1,33 @@ +package images + +import ( + "errors" + "fmt" + "os" + "syscall" + + "golang.org/x/sys/unix" +) + +func mknodWithRootlessFallback(path string, mode uint32, dev int) error { + if err := unix.Mknod(path, mode, dev); err != nil { + if !errors.Is(err, unix.EPERM) { + return fmt.Errorf("mknod: %w", err) + } + if err := createRootlessDevicePlaceholder(path); err != nil { + return err + } + } + return nil +} + +func createRootlessDevicePlaceholder(path string) error { + file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|syscall.O_NOFOLLOW, 0644) + if err != nil { + return fmt.Errorf("create rootless device placeholder: %w", err) + } + if err := file.Close(); err != nil { + return err + } + return nil +} diff --git a/lib/images/layer_artifact.go b/lib/images/layer_artifact.go index 572500c12..3c05383ad 100644 --- a/lib/images/layer_artifact.go +++ b/lib/images/layer_artifact.go @@ -192,7 +192,7 @@ func (m *manager) installLayerArtifact(desc layerDescriptor, layerHex, unpackDir record := &layerArtifact{ SchemaVersion: layerRecordSchemaVersion, Digest: desc.Digest, - DiffID: desc.DiffID, + DiffID: stats.diffID, Format: layerArtifactFormat(), UnpackedBytes: stats.unpackedBytes, Entries: stats.entries, @@ -544,13 +544,8 @@ func extractTarDevice(target string, header *tar.Header) error { 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 { - if !errors.Is(err, unix.EPERM) { - return fmt.Errorf("mknod: %w", err) - } - if err := createRootlessDevicePlaceholder(target); err != nil { - return err - } + if err := mknodWithRootlessFallback(target, mode|uint32(header.FileInfo().Mode().Perm()), dev); err != nil { + return err } return applyTarMetadata(target, header) } @@ -565,17 +560,6 @@ func extractTarFIFO(target string, header *tar.Header) error { return applyTarMetadata(target, header) } -func createRootlessDevicePlaceholder(path string) error { - file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|syscall.O_NOFOLLOW, 0644) - if err != nil { - return fmt.Errorf("create rootless device placeholder: %w", err) - } - if err := file.Close(); err != nil { - return err - } - return nil -} - func applyTarMetadata(path string, header *tar.Header) error { mtime := header.ModTime if mtime.IsZero() { diff --git a/lib/images/layer_artifact_test.go b/lib/images/layer_artifact_test.go index cc798bfeb..691124d21 100644 --- a/lib/images/layer_artifact_test.go +++ b/lib/images/layer_artifact_test.go @@ -272,6 +272,30 @@ func TestApplyLayerTreeWhiteoutSemantics(t *testing.T) { require.Empty(t, leaked) } +func TestApplyLayerTreeReplacesSymlinkBeforeNestedWhiteout(t *testing.T) { + root := t.TempDir() + targetDir := filepath.Join(root, "target") + layerDir := filepath.Join(root, "layer") + lowerDir := filepath.Join(root, "lower") + + require.NoError(t, os.MkdirAll(targetDir, 0755)) + require.NoError(t, os.MkdirAll(lowerDir, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(lowerDir, "old.txt"), []byte("old"), 0644)) + require.NoError(t, os.Symlink("../lower", filepath.Join(targetDir, "replaced"))) + require.NoError(t, os.MkdirAll(filepath.Join(layerDir, "replaced"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(layerDir, "replaced", ".wh.old.txt"), nil, 0644)) + require.NoError(t, os.WriteFile(filepath.Join(layerDir, "replaced", "new.txt"), []byte("new"), 0644)) + + require.NoError(t, applyLayerTree(layerDir, targetDir)) + + info, err := os.Lstat(filepath.Join(targetDir, "replaced")) + require.NoError(t, err) + require.True(t, info.IsDir()) + require.FileExists(t, filepath.Join(targetDir, "replaced", "new.txt")) + _, err = os.Lstat(filepath.Join(targetDir, "replaced", "old.txt")) + require.ErrorIs(t, err, os.ErrNotExist) +} + func TestApplyLayerTreePreservesUpperDirectoryMode(t *testing.T) { root := t.TempDir() targetDir := filepath.Join(root, "target") @@ -603,4 +627,3 @@ func TestSafeJoinCleanRootSymlink(t *testing.T) { require.Error(t, err) require.Contains(t, err.Error(), "extraction root is not a directory") } - diff --git a/lib/images/layer_tree.go b/lib/images/layer_tree.go index fd120d86b..8b941f601 100644 --- a/lib/images/layer_tree.go +++ b/lib/images/layer_tree.go @@ -74,6 +74,12 @@ func applyLayerTree(layerDir, targetDir string) (err error) { } }() + // Prepare directories before whiteouts so nested markers are applied after + // the layer has replaced any conflicting lower-layer entry. + if err := prepareLayerDirectories(layerDir, targetDir, originalModes); err != nil { + return fmt.Errorf("prepare layer directories: %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 { @@ -175,6 +181,39 @@ func applyLayerTree(layerDir, targetDir string) (err error) { return nil } +func prepareLayerDirectories(layerDir, targetDir string, originalModes map[string]fs.FileMode) error { + return filepath.WalkDir(layerDir, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if path == layerDir || !entry.IsDir() { + return nil + } + if strings.HasPrefix(entry.Name(), whiteoutPrefix) { + return filepath.SkipDir + } + rel, err := filepath.Rel(layerDir, path) + if err != nil { + return err + } + target, err := safeJoin(targetDir, rel) + if err != nil { + return err + } + if err := makePathWritable(targetDir, filepath.Dir(target), originalModes); err != nil { + return err + } + info, err := entry.Info() + if err != nil { + return err + } + if err := copyDirectoryEntry(path, target, info); err != nil { + return err + } + return makePathWritable(targetDir, target, originalModes) + }) +} + func makePathWritable(root, path string, originalModes map[string]fs.FileMode) error { root = filepath.Clean(root) path = filepath.Clean(path) @@ -359,13 +398,8 @@ func copySpecialEntry(src, dst string, info os.FileInfo) error { if err != nil { return fmt.Errorf("unsupported entry type for %s: %w", src, err) } - if err := unix.Mknod(dst, mode|uint32(info.Mode().Perm()), int(stat.Rdev)); err != nil { - if !errors.Is(err, unix.EPERM) { - return fmt.Errorf("mknod: %w", err) - } - if err := createRootlessDevicePlaceholder(dst); err != nil { - return err - } + if err := mknodWithRootlessFallback(dst, mode|uint32(info.Mode().Perm()), int(stat.Rdev)); err != nil { + return err } return copyEntryMetadata(src, dst, info) } diff --git a/lib/images/manager.go b/lib/images/manager.go index 12fe1c244..abd77a3fc 100644 --- a/lib/images/manager.go +++ b/lib/images/manager.go @@ -855,7 +855,7 @@ func (m *manager) TotalImageBytes(ctx context.Context) (int64, error) { return readyImageBytes, nil } -// TotalOCICacheBytes returns the total size of the OCI layer cache. +// TotalOCICacheBytes returns the total size of the OCI and materialized layer caches. func (m *manager) TotalOCICacheBytes(ctx context.Context) (int64, error) { _, ociCacheBytes, err := m.getDiskUsageTotals() if err != nil { diff --git a/lib/resources/resource.go b/lib/resources/resource.go index d674c5d01..5943cb0bc 100644 --- a/lib/resources/resource.go +++ b/lib/resources/resource.go @@ -132,7 +132,7 @@ type InstanceAllocation struct { type ImageLister interface { // TotalImageBytes returns the total size of all images on disk. TotalImageBytes(ctx context.Context) (int64, error) - // TotalOCICacheBytes returns the total size of the OCI layer cache. + // TotalOCICacheBytes returns the total size of the OCI and materialized layer caches. TotalOCICacheBytes(ctx context.Context) (int64, error) } From 599be21c1575f96fa462894f1f662b053c171a72 Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Thu, 3 Sep 2026 01:39:31 +0000 Subject: [PATCH 07/21] Harden layer tree portability and file copying --- lib/images/layer_artifact.go | 7 ++--- lib/images/layer_artifact_test.go | 41 +++++++++++++++++++++++++--- lib/images/layer_atime_darwin.go | 12 +++++++++ lib/images/layer_atime_linux.go | 12 +++++++++ lib/images/layer_tree.go | 44 +++++++++++++++++++++---------- 5 files changed, 96 insertions(+), 20 deletions(-) create mode 100644 lib/images/layer_atime_darwin.go create mode 100644 lib/images/layer_atime_linux.go diff --git a/lib/images/layer_artifact.go b/lib/images/layer_artifact.go index 3c05383ad..679c27b37 100644 --- a/lib/images/layer_artifact.go +++ b/lib/images/layer_artifact.go @@ -130,9 +130,10 @@ func discardLayerCache(p *paths.Paths, layerHex string) error { // 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 the -// default image format, and installed atomically; an interrupted build leaves -// only temp files that the next attempt replaces. No production caller yet: -// pull integration and composition land in later changes. +// default image format, and installed atomically. Normal failures remove the +// temp directory; lifecycle reconciliation removes stale temp directories after +// an interrupted build. No production caller yet: pull integration and +// composition land in later changes. func (m *manager) materializeLayerArtifact(desc layerDescriptor) (*layerArtifact, error) { unlock := m.layerLocks.lock(desc.Digest) defer unlock() diff --git a/lib/images/layer_artifact_test.go b/lib/images/layer_artifact_test.go index 691124d21..986609ffa 100644 --- a/lib/images/layer_artifact_test.go +++ b/lib/images/layer_artifact_test.go @@ -200,6 +200,28 @@ func TestMaterializeLayerDoesNotPersistWhiteoutInventory(t *testing.T) { require.NotContains(t, string(data), "whiteouts") } +func TestApplyLayerTreeCopiesRestrictiveFiles(t *testing.T) { + root := t.TempDir() + targetDir := filepath.Join(root, "target") + layerDir := filepath.Join(root, "layer") + file := filepath.Join(layerDir, "secret.txt") + + require.NoError(t, os.MkdirAll(layerDir, 0755)) + require.NoError(t, os.WriteFile(file, []byte("secret"), 0644)) + require.NoError(t, os.Chmod(file, 0000)) + + require.NoError(t, applyLayerTree(layerDir, targetDir)) + + target := filepath.Join(targetDir, "secret.txt") + info, err := os.Stat(target) + require.NoError(t, err) + require.Equal(t, os.FileMode(0000), info.Mode().Perm()) + require.NoError(t, os.Chmod(target, 0644)) + data, err := os.ReadFile(target) + require.NoError(t, err) + require.Equal(t, "secret", string(data)) +} + func TestApplyLayerTreeWhiteoutSemantics(t *testing.T) { root := t.TempDir() targetDir := filepath.Join(root, "target") @@ -549,7 +571,7 @@ func TestApplyTarMetadataPreservesSpecialBits(t *testing.T) { require.Equal(t, os.ModeSetuid, info.Mode()&os.ModeSetuid, "setuid bit must be preserved") } -func TestApplyLayerTreeSanitizesWhiteouts(t *testing.T) { +func TestApplyLayerTreeRejectsMalformedWhiteouts(t *testing.T) { root := t.TempDir() targetDir := filepath.Join(root, "target") layerDir := filepath.Join(root, "layer") @@ -557,12 +579,13 @@ func TestApplyLayerTreeSanitizesWhiteouts(t *testing.T) { require.NoError(t, os.MkdirAll(filepath.Join(targetDir, "parent", "child"), 0755)) require.NoError(t, os.WriteFile(filepath.Join(targetDir, "parent", "victim.txt"), []byte("v"), 0644)) require.NoError(t, os.MkdirAll(filepath.Join(layerDir, "parent"), 0755)) - // Malformed whiteouts that must be safely ignored: + // Malformed whiteouts must be rejected without deleting lower-layer entries. require.NoError(t, os.WriteFile(filepath.Join(layerDir, "parent", ".wh."), nil, 0644)) require.NoError(t, os.WriteFile(filepath.Join(layerDir, "parent", ".wh.."), nil, 0644)) require.NoError(t, os.WriteFile(filepath.Join(layerDir, "parent", ".wh..."), nil, 0644)) - require.NoError(t, applyLayerTree(layerDir, targetDir)) + err := applyLayerTree(layerDir, targetDir) + require.ErrorContains(t, err, "invalid whiteout entry") // Parent directory must not be deleted by malformed whiteout paths. info, err := os.Stat(filepath.Join(targetDir, "parent")) @@ -571,6 +594,18 @@ func TestApplyLayerTreeSanitizesWhiteouts(t *testing.T) { require.FileExists(t, filepath.Join(targetDir, "parent", "victim.txt")) } +func TestApplyLayerTreeRejectsEscapingSymlink(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)) + require.NoError(t, os.Symlink("../../outside", filepath.Join(layerDir, "link"))) + + err := applyLayerTree(layerDir, targetDir) + require.ErrorContains(t, err, "symlink target escapes root") +} + func TestApplyLayerTreeRestoresRestrictiveNestedDirModes(t *testing.T) { root := t.TempDir() t.Cleanup(func() { _ = makeTreeWritable(root) }) diff --git a/lib/images/layer_atime_darwin.go b/lib/images/layer_atime_darwin.go new file mode 100644 index 000000000..670259c4c --- /dev/null +++ b/lib/images/layer_atime_darwin.go @@ -0,0 +1,12 @@ +//go:build darwin + +package images + +import ( + "syscall" + "time" +) + +func statAtime(stat *syscall.Stat_t) time.Time { + return time.Unix(stat.Atimespec.Sec, stat.Atimespec.Nsec) +} diff --git a/lib/images/layer_atime_linux.go b/lib/images/layer_atime_linux.go new file mode 100644 index 000000000..e2912da06 --- /dev/null +++ b/lib/images/layer_atime_linux.go @@ -0,0 +1,12 @@ +//go:build linux + +package images + +import ( + "syscall" + "time" +) + +func statAtime(stat *syscall.Stat_t) time.Time { + return time.Unix(stat.Atim.Sec, stat.Atim.Nsec) +} diff --git a/lib/images/layer_tree.go b/lib/images/layer_tree.go index 8b941f601..58ae47bba 100644 --- a/lib/images/layer_tree.go +++ b/lib/images/layer_tree.go @@ -105,13 +105,13 @@ func applyLayerTree(layerDir, targetDir string) (err error) { } hidden := strings.TrimPrefix(base, whiteoutPrefix) if hidden == "" || hidden == "." || hidden == ".." { - return nil + return fmt.Errorf("invalid whiteout entry: %s", rel) } target, err := safeJoin(targetDir, filepath.Join(filepath.Dir(rel), hidden)) if err != nil { return err } - if info, err := os.Lstat(target); err == nil && info.IsDir() && info.Mode()&os.ModeSymlink == 0 { + if info, err := os.Lstat(target); err == nil && info.IsDir() { if err := makePathWritable(targetDir, target, originalModes); err != nil { return err } @@ -160,7 +160,7 @@ func applyLayerTree(layerDir, targetDir string) (err error) { } pendingDirs = append(pendingDirs, dirMeta{src: path, dst: target, info: info}) } - if err := copyEntryInto(path, target, hardlinks); err != nil { + if err := copyEntryInto(path, target, hardlinks, targetDir); err != nil { return err } if entry.IsDir() { @@ -321,22 +321,18 @@ type hardlinkIdentity struct { // 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 { +func copyEntryInto(src, dst string, hardlinks map[hardlinkIdentity]string, root 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: return copyRegularEntry(src, dst, info, hardlinks) case fs.ModeDir: return copyDirectoryEntry(src, dst, info) case fs.ModeSymlink: - return copySymlinkEntry(src, dst) + return copySymlinkEntry(src, dst, root) default: return copySpecialEntry(src, dst, info) } @@ -368,11 +364,14 @@ func copyDirectoryEntry(src, dst string, info os.FileInfo) error { return os.MkdirAll(dst, info.Mode().Perm()) } -func copySymlinkEntry(src, dst string) error { +func copySymlinkEntry(src, dst, root string) error { linkTarget, err := os.Readlink(src) if err != nil { return err } + if err := validateSymlinkTarget(root, dst, linkTarget); err != nil { + return err + } if err := removePath(dst); err != nil { return err } @@ -459,7 +458,7 @@ func copyEntryMetadata(src, dst string, info os.FileInfo) error { metadata.uid = int(stat.Uid) metadata.gid = int(stat.Gid) metadata.hasOwner = true - metadata.atime = time.Unix(stat.Atim.Sec, stat.Atim.Nsec) + metadata.atime = statAtime(stat) } if !metadata.symlink { xattrs, err := readXattrs(src) @@ -501,10 +500,11 @@ func readXattrs(src string) (map[string][]byte, error) { return nil, err } value := make([]byte, size) - if _, err := unix.Lgetxattr(src, name, value); err != nil { + n, err := unix.Lgetxattr(src, name, value) + if err != nil { return nil, err } - xattrs[name] = value + xattrs[name] = value[:n] } return xattrs, nil } @@ -535,7 +535,23 @@ func applyXattrs(path string, xattrs map[string][]byte) error { return nil } -func copyFileContents(src, dst string) error { +func copyFileContents(src, dst string) (err error) { + info, err := os.Lstat(src) + if err != nil { + return err + } + sourceMode := info.Mode().Perm() | info.Mode()&(os.ModeSetuid|os.ModeSetgid|os.ModeSticky) + if sourceMode&0400 == 0 { + if err := os.Chmod(src, sourceMode|0400); err != nil { + return err + } + defer func() { + if restoreErr := os.Chmod(src, sourceMode); err == nil { + err = restoreErr + } + }() + } + in, err := os.Open(src) if err != nil { return err From 30eb223a89b07ca2cce8af507aa93d47e7feca52 Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:50:20 +0000 Subject: [PATCH 08/21] Harden layer materialization and metadata restoration --- lib/images/context_reader.go | 18 ++++++++++++ lib/images/disk.go | 7 ++++- lib/images/layer_artifact.go | 49 +++++++++++++++++++++++++++---- lib/images/layer_artifact_test.go | 34 +++++++++++++++++++++ lib/images/layer_tree.go | 11 +++---- lib/images/manager.go | 3 +- 6 files changed, 109 insertions(+), 13 deletions(-) create mode 100644 lib/images/context_reader.go diff --git a/lib/images/context_reader.go b/lib/images/context_reader.go new file mode 100644 index 000000000..8aec8b2e4 --- /dev/null +++ b/lib/images/context_reader.go @@ -0,0 +1,18 @@ +package images + +import ( + "context" + "io" +) + +type contextReader struct { + ctx context.Context + reader io.Reader +} + +func (r contextReader) Read(p []byte) (int, error) { + if err := r.ctx.Err(); err != nil { + return 0, err + } + return r.reader.Read(p) +} diff --git a/lib/images/disk.go b/lib/images/disk.go index 896d10f2a..97e77db53 100644 --- a/lib/images/disk.go +++ b/lib/images/disk.go @@ -1,6 +1,7 @@ package images import ( + "context" "fmt" "os" "os/exec" @@ -194,6 +195,10 @@ func convertToExt4(rootfsDir, diskPath string) (int64, error) { // convertToErofs converts a rootfs directory to an erofs disk image using mkfs.erofs func convertToErofs(rootfsDir, diskPath string) (int64, error) { + return convertToErofsContext(context.Background(), rootfsDir, diskPath) +} + +func convertToErofsContext(ctx context.Context, rootfsDir, diskPath string) (int64, error) { // Ensure parent directory exists if err := os.MkdirAll(filepath.Dir(diskPath), 0755); err != nil { return 0, fmt.Errorf("create disk parent dir: %w", err) @@ -202,7 +207,7 @@ func convertToErofs(rootfsDir, diskPath string) (int64, error) { // Create erofs image with LZ4 fast compression // -zlz4: LZ4 fast compression (~20-25% space savings, faster builds) // erofs doesn't need pre-allocation, creates file directly - cmd := exec.Command("mkfs.erofs", "-zlz4", diskPath, rootfsDir) + cmd := exec.CommandContext(ctx, "mkfs.erofs", "-zlz4", diskPath, rootfsDir) output, err := cmd.CombinedOutput() if err != nil { return 0, fmt.Errorf("mkfs.erofs failed: %w, output: %s", err, output) diff --git a/lib/images/layer_artifact.go b/lib/images/layer_artifact.go index 679c27b37..20187e29f 100644 --- a/lib/images/layer_artifact.go +++ b/lib/images/layer_artifact.go @@ -3,6 +3,7 @@ package images import ( "archive/tar" "compress/gzip" + "context" "crypto/sha256" "encoding/json" "errors" @@ -135,9 +136,26 @@ func discardLayerCache(p *paths.Paths, layerHex string) error { // an interrupted build. No production caller yet: pull integration and // composition land in later changes. func (m *manager) materializeLayerArtifact(desc layerDescriptor) (*layerArtifact, error) { - unlock := m.layerLocks.lock(desc.Digest) - defer unlock() + return m.materializeLayerArtifactContext(context.Background(), desc) +} +func (m *manager) materializeLayerArtifactContext(ctx context.Context, desc layerDescriptor) (*layerArtifact, error) { + key := desc.Digest + "\x00" + layerArtifactFormat() + result := m.layerFlights.DoChan(key, func() (any, error) { + return m.materializeLayerArtifactOnce(ctx, desc) + }) + select { + case <-ctx.Done(): + return nil, ctx.Err() + case shared := <-result: + if shared.Err != nil { + return nil, shared.Err + } + return shared.Val.(*layerArtifact), nil + } +} + +func (m *manager) materializeLayerArtifactOnce(ctx context.Context, desc layerDescriptor) (*layerArtifact, error) { if layerArtifactFormat() == "" { return nil, fmt.Errorf("unsupported layer artifact format: %s", DefaultImageFormat) } @@ -178,7 +196,7 @@ func (m *manager) materializeLayerArtifact(desc layerDescriptor) (*layerArtifact } defer removePath(unpackDir) - stats, err := unpackLayerBlob(blobPath, desc.MediaType, unpackDir) + stats, err := unpackLayerBlobContext(ctx, blobPath, desc.MediaType, unpackDir) if err != nil { return nil, fmt.Errorf("unpack layer %s: %w", desc.Digest, err) } @@ -186,10 +204,14 @@ func (m *manager) materializeLayerArtifact(desc layerDescriptor) (*layerArtifact 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) + return m.installLayerArtifactContext(ctx, desc, layerHex, unpackDir, stats) } func (m *manager) installLayerArtifact(desc layerDescriptor, layerHex, unpackDir string, stats *unpackStats) (*layerArtifact, error) { + return m.installLayerArtifactContext(context.Background(), desc, layerHex, unpackDir, stats) +} + +func (m *manager) installLayerArtifactContext(ctx context.Context, desc layerDescriptor, layerHex, unpackDir string, stats *unpackStats) (*layerArtifact, error) { record := &layerArtifact{ SchemaVersion: layerRecordSchemaVersion, Digest: desc.Digest, @@ -203,7 +225,13 @@ func (m *manager) installLayerArtifact(desc layerDescriptor, layerHex, unpackDir if err := installAtomically(layerArtifactPath(m.paths, layerHex), func(path string) error { // The artifact intentionally retains .wh. marker files so // composition can re-derive whiteouts from the tree itself. - size, convErr := ExportRootfs(unpackDir, path, DefaultImageFormat) + var size int64 + var convErr error + if DefaultImageFormat == FormatErofs { + size, convErr = convertToErofsContext(ctx, unpackDir, path) + } else { + size, convErr = ExportRootfs(unpackDir, path, DefaultImageFormat) + } if convErr != nil { return convErr } @@ -235,6 +263,10 @@ type unpackStats struct { // umoci consumes whiteouts while this store must retain them for composition. // Paths are confined to dest. func unpackLayerBlob(blobPath, mediaType, dest string) (*unpackStats, error) { + return unpackLayerBlobContext(context.Background(), blobPath, mediaType, dest) +} + +func unpackLayerBlobContext(ctx context.Context, blobPath, mediaType, dest string) (*unpackStats, error) { if err := os.MkdirAll(dest, 0755); err != nil { return nil, fmt.Errorf("create extraction root: %w", err) } @@ -249,6 +281,7 @@ func unpackLayerBlob(blobPath, mediaType, dest string) (*unpackStats, error) { return nil, err } defer closer.Close() + reader = contextReader{ctx: ctx, reader: reader} hash := sha256.New() stats := &unpackStats{} @@ -336,7 +369,11 @@ func resolveHardlinks(root string, pending []pendingHardlink) error { waiting := make(map[string][]pendingHardlink) ready := make([]pendingHardlink, 0, len(pending)) for _, link := range pending { - linkTarget, err := safeJoin(root, link.linkname) + linkname := filepath.Clean(link.linkname) + if filepath.IsAbs(linkname) { + linkname = strings.TrimPrefix(linkname, string(filepath.Separator)) + } + linkTarget, err := safeJoin(root, linkname) if err != nil { return err } diff --git a/lib/images/layer_artifact_test.go b/lib/images/layer_artifact_test.go index 986609ffa..0654e8699 100644 --- a/lib/images/layer_artifact_test.go +++ b/lib/images/layer_artifact_test.go @@ -4,6 +4,7 @@ import ( "archive/tar" "bytes" "compress/gzip" + "context" "crypto/sha256" "encoding/json" "errors" @@ -222,6 +223,22 @@ func TestApplyLayerTreeCopiesRestrictiveFiles(t *testing.T) { require.Equal(t, "secret", string(data)) } +func TestRestoreDirectoryModesSkipsReplacedSymlink(t *testing.T) { + root := t.TempDir() + dir := filepath.Join(root, "dir") + target := filepath.Join(root, "target") + require.NoError(t, os.Mkdir(dir, 0750)) + require.NoError(t, os.WriteFile(target, []byte("target"), 0644)) + require.NoError(t, os.Remove(dir)) + require.NoError(t, os.Symlink("target", dir)) + + require.NoError(t, restoreDirectoryModes(map[string]fs.FileMode{dir: 0750})) + + info, err := os.Stat(target) + require.NoError(t, err) + require.Equal(t, os.FileMode(0644), info.Mode().Perm()) +} + func TestApplyLayerTreeWhiteoutSemantics(t *testing.T) { root := t.TempDir() targetDir := filepath.Join(root, "target") @@ -350,6 +367,23 @@ func TestApplyLayerTreeWhiteoutRemovesReadOnlyDirectory(t *testing.T) { require.ErrorIs(t, err, os.ErrNotExist) } +func TestUnpackLayerBlobContextHonorsCancellation(t *testing.T) { + root := t.TempDir() + blobPath := filepath.Join(root, "layer.tar") + var buf bytes.Buffer + tw := tar.NewWriter(&buf) + require.NoError(t, tw.WriteHeader(&tar.Header{Name: "file", Typeflag: tar.TypeReg, Mode: 0644, Size: 1})) + _, err := tw.Write([]byte("x")) + require.NoError(t, err) + require.NoError(t, tw.Close()) + require.NoError(t, os.WriteFile(blobPath, buf.Bytes(), 0644)) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err = unpackLayerBlobContext(ctx, blobPath, "application/vnd.oci.image.layer.v1.tar", filepath.Join(root, "dest")) + require.ErrorIs(t, err, context.Canceled) +} + func TestUnpackLayerBlobIncludesTrailingTarPaddingInDiffID(t *testing.T) { root := t.TempDir() blobPath := filepath.Join(root, "layer.tar.gz") diff --git a/lib/images/layer_tree.go b/lib/images/layer_tree.go index 58ae47bba..c48b1a2a1 100644 --- a/lib/images/layer_tree.go +++ b/lib/images/layer_tree.go @@ -271,7 +271,8 @@ func restoreDirectoryModes(originalModes map[string]fs.FileMode) error { var restoreErr error for _, path := range paths { mode := originalModes[path] - if _, err := os.Lstat(path); os.IsNotExist(err) { + info, err := os.Lstat(path) + if os.IsNotExist(err) || (err == nil && (info.Mode()&os.ModeSymlink != 0 || !info.IsDir())) { continue } else if err != nil { restoreErr = errors.Join(restoreErr, err) @@ -437,14 +438,14 @@ func applyEntryMetadata(path string, metadata entryMetadata) error { if metadata.symlink { return nil } - mode := metadata.mode.Perm() | metadata.mode&(os.ModeSetuid|os.ModeSetgid|os.ModeSticky) - if err := os.Chmod(path, mode); err != nil { + if err := applyXattrs(path, metadata.xattrs); err != nil { return err } - if err := os.Chtimes(path, metadata.atime, metadata.mtime); err != nil { + mode := metadata.mode.Perm() | metadata.mode&(os.ModeSetuid|os.ModeSetgid|os.ModeSticky) + if err := os.Chmod(path, mode); err != nil { return err } - return applyXattrs(path, metadata.xattrs) + return os.Chtimes(path, metadata.atime, metadata.mtime) } func copyEntryMetadata(src, dst string, info os.FileInfo) error { diff --git a/lib/images/manager.go b/lib/images/manager.go index abd77a3fc..bceeb0f05 100644 --- a/lib/images/manager.go +++ b/lib/images/manager.go @@ -19,6 +19,7 @@ import ( "github.com/kernel/hypeman/lib/queue" "github.com/kernel/hypeman/lib/tags" "go.opentelemetry.io/otel/metric" + "golang.org/x/sync/singleflight" ) var errStaleBuild = errors.New("stale image build") @@ -75,7 +76,7 @@ type manager struct { ociClient *ociClient queue *queue.Queue createMu sync.Mutex - layerLocks keyedMutex + layerFlights singleflight.Group diskUsageMu sync.RWMutex tagGenerations map[string]uint64 requestedTags map[string]string // newest pull's digest per requested tag From 5ee953a75b627b93b4b830a3018624addc61f7d7 Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:51:13 +0000 Subject: [PATCH 09/21] Share context-aware cached layer unpacking --- lib/images/layer_artifact.go | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/lib/images/layer_artifact.go b/lib/images/layer_artifact.go index 20187e29f..08fb080bf 100644 --- a/lib/images/layer_artifact.go +++ b/lib/images/layer_artifact.go @@ -262,6 +262,32 @@ type unpackStats struct { // whiteout marker files. It intentionally does not use umoci's layer unpacker: // umoci consumes whiteouts while this store must retain them for composition. // Paths are confined to dest. +func unpackCachedLayer(cacheDir string, desc layerDescriptor, dest string) (*unpackStats, error) { + return unpackCachedLayerContext(context.Background(), cacheDir, desc, dest) +} + +func unpackCachedLayerContext(ctx context.Context, cacheDir string, desc layerDescriptor, dest string) (*unpackStats, error) { + layerHex := strings.TrimPrefix(desc.Digest, "sha256:") + if err := paths.ValidatePathComponent(layerHex); err != nil { + return nil, fmt.Errorf("invalid layer digest: %s", desc.Digest) + } + blobPath := filepath.Join(cacheDir, "blobs", "sha256", 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) + } + stats, err := unpackLayerBlobContext(ctx, blobPath, desc.MediaType, dest) + 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 stats, nil +} + func unpackLayerBlob(blobPath, mediaType, dest string) (*unpackStats, error) { return unpackLayerBlobContext(context.Background(), blobPath, mediaType, dest) } From bee89eeed36add17c5163647c69d357c92cbc8e3 Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:52:37 +0000 Subject: [PATCH 10/21] Serialize layer store writes --- lib/images/keyed_mutex.go | 39 ------------------------------------ lib/images/layer_artifact.go | 3 +++ lib/images/manager.go | 1 + 3 files changed, 4 insertions(+), 39 deletions(-) delete mode 100644 lib/images/keyed_mutex.go diff --git a/lib/images/keyed_mutex.go b/lib/images/keyed_mutex.go deleted file mode 100644 index 609fd3017..000000000 --- a/lib/images/keyed_mutex.go +++ /dev/null @@ -1,39 +0,0 @@ -package images - -import "sync" - -type keyedMutex struct { - mu sync.Mutex - locks map[string]*keyedLock -} - -type keyedLock struct { - mu sync.Mutex - refs int -} - -func (m *keyedMutex) lock(key string) func() { - m.mu.Lock() - if m.locks == nil { - m.locks = make(map[string]*keyedLock) - } - lock := m.locks[key] - if lock == nil { - lock = &keyedLock{} - m.locks[key] = lock - } - lock.refs++ - m.mu.Unlock() - - lock.mu.Lock() - return func() { - lock.mu.Unlock() - - m.mu.Lock() - lock.refs-- - if lock.refs == 0 { - delete(m.locks, key) - } - m.mu.Unlock() - } -} diff --git a/lib/images/layer_artifact.go b/lib/images/layer_artifact.go index 08fb080bf..32a11cdbd 100644 --- a/lib/images/layer_artifact.go +++ b/lib/images/layer_artifact.go @@ -156,6 +156,9 @@ func (m *manager) materializeLayerArtifactContext(ctx context.Context, desc laye } func (m *manager) materializeLayerArtifactOnce(ctx context.Context, desc layerDescriptor) (*layerArtifact, error) { + m.layerStoreMu.Lock() + defer m.layerStoreMu.Unlock() + if layerArtifactFormat() == "" { return nil, fmt.Errorf("unsupported layer artifact format: %s", DefaultImageFormat) } diff --git a/lib/images/manager.go b/lib/images/manager.go index bceeb0f05..f18fe8a8d 100644 --- a/lib/images/manager.go +++ b/lib/images/manager.go @@ -77,6 +77,7 @@ type manager struct { queue *queue.Queue createMu sync.Mutex layerFlights singleflight.Group + layerStoreMu sync.Mutex diskUsageMu sync.RWMutex tagGenerations map[string]uint64 requestedTags map[string]string // newest pull's digest per requested tag From f24b4bb5157648befecacfcefab9041789adac15 Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Thu, 3 Sep 2026 14:01:13 +0000 Subject: [PATCH 11/21] Preserve explicit layer directory metadata --- lib/images/layer_artifact.go | 119 +++++++++++++++++++++++++----- lib/images/layer_artifact_test.go | 19 +++++ lib/images/layer_tree.go | 62 ++++++++++++---- 3 files changed, 167 insertions(+), 33 deletions(-) diff --git a/lib/images/layer_artifact.go b/lib/images/layer_artifact.go index 32a11cdbd..8733557f1 100644 --- a/lib/images/layer_artifact.go +++ b/lib/images/layer_artifact.go @@ -259,6 +259,7 @@ type unpackStats struct { entries int unpackedBytes int64 diffID string + explicitDirs map[string]struct{} } // unpackLayerBlob extracts one compressed layer blob into dest while preserving @@ -313,7 +314,7 @@ func unpackLayerBlobContext(ctx context.Context, blobPath, mediaType, dest strin reader = contextReader{ctx: ctx, reader: reader} hash := sha256.New() - stats := &unpackStats{} + stats := &unpackStats{explicitDirs: make(map[string]struct{})} // Directory metadata is re-applied after extraction, once children // exist, so tar directory mtimes are not overwritten by later writes. pendingDirs := make([]pendingDir, 0) @@ -351,6 +352,7 @@ func unpackLayerBlobContext(ctx context.Context, blobPath, mediaType, dest strin } if header.Typeflag == tar.TypeDir { + stats.explicitDirs[filepath.Clean(header.Name)] = struct{}{} pendingDirs = append(pendingDirs, pendingDir{target: target, header: header}) } if header.Typeflag == tar.TypeLink { @@ -472,13 +474,38 @@ func (c multiCloser) Close() error { return firstErr } -// safeJoin resolves a tar entry name inside root and rejects symlinked -// parents: extraction must never create an entry through a symlink an earlier -// tar entry planted, so existing parents are Lstat-walked and rejected rather -// than resolved. Symlink entries themselves may legitimately name paths that -// do not exist yet, so their targets are checked by -// validateSymlinkTarget instead. +// safeJoin resolves a tar entry inside root and rejects symlinked parents +// while extracting a layer. This prevents one tar entry from changing where +// a later entry is written. func safeJoin(root, name string) (string, error) { + resolved, err := safeJoinForComposition(root, name) + if err != nil { + return "", err + } + root = filepath.Clean(root) + clean := filepath.Clean(name) + target := filepath.Join(root, clean) + 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 resolved, nil +} + +// safeJoinForComposition resolves an entry through existing parent symlinks, +// interpreting absolute link targets relative to the image root. +func safeJoinForComposition(root, name string) (string, error) { root = filepath.Clean(root) rootInfo, err := os.Lstat(root) if err != nil { @@ -498,28 +525,84 @@ func safeJoin(root, name string) (string, error) { return "", fmt.Errorf("tar entry escapes root: %s", name) } target := filepath.Join(root, clean) - if target == root { - return target, nil - } if !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) + parent := filepath.Dir(target) + resolvedParent, err := resolveLayerPath(root, parent, 0) + if err != nil { + return "", fmt.Errorf("inspect tar entry parent: %w", err) + } + return filepath.Join(resolvedParent, filepath.Base(target)), nil +} + +func resolveLayerPath(root, path string, depth int) (string, error) { + if depth > 40 { + return "", fmt.Errorf("too many symlinks") + } + root = filepath.Clean(root) + path = filepath.Clean(path) + rel, err := filepath.Rel(root, path) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("path escapes root") + } + if rel == "." { + return root, nil + } + parts := strings.Split(rel, string(filepath.Separator)) + current := root + for i, part := range parts { + candidate := filepath.Join(current, part) + info, err := os.Lstat(candidate) if err != nil { if os.IsNotExist(err) { - continue + return filepath.Join(candidate, filepath.Join(parts[i+1:]...)), nil } - return "", fmt.Errorf("inspect tar entry parent: %w", err) + return "", err } if info.Mode()&os.ModeSymlink != 0 { - return "", fmt.Errorf("tar entry traverses symlink: %s", name) + link, err := os.Readlink(candidate) + if err != nil { + return "", err + } + var linkTarget string + if filepath.IsAbs(link) { + linkTarget = filepath.Join(root, strings.TrimPrefix(link, string(filepath.Separator))) + } else { + linkTarget = filepath.Join(filepath.Dir(candidate), link) + } + linkTarget = filepath.Clean(linkTarget) + if !pathWithinRoot(root, linkTarget) { + return "", fmt.Errorf("symlink target escapes root") + } + return resolveLayerPath(root, filepath.Join(linkTarget, filepath.Join(parts[i+1:]...)), depth+1) } - if !info.IsDir() { - return "", fmt.Errorf("tar entry parent is not a directory: %s", parent) + if i < len(parts)-1 && !info.IsDir() { + return "", fmt.Errorf("path parent is not a directory: %s", candidate) + } + current = candidate + } + return current, nil +} + +func pathWithinRoot(root, path string) bool { + rel, err := filepath.Rel(filepath.Clean(root), filepath.Clean(path)) + return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) +} + +func resolveCompositionDirTarget(root, target string) (string, error) { + resolved, err := resolveLayerPath(root, target, 0) + if err == nil { + return resolved, nil + } + info, statErr := os.Lstat(target) + if statErr == nil && info.Mode()&os.ModeSymlink != 0 { + if removeErr := removePath(target); removeErr != nil { + return "", removeErr } + return target, nil } - return target, nil + return "", err } // validateSymlinkTarget permits absolute targets because OCI images may use diff --git a/lib/images/layer_artifact_test.go b/lib/images/layer_artifact_test.go index 0654e8699..78a7bbcd7 100644 --- a/lib/images/layer_artifact_test.go +++ b/lib/images/layer_artifact_test.go @@ -452,6 +452,25 @@ func TestValidateSymlinkTargetAllowsAbsoluteTargets(t *testing.T) { require.NoError(t, validateSymlinkTarget(root, target, "/etc/resolv.conf")) } +func TestApplyLayerTreeResolvesInRootSymlinkParents(t *testing.T) { + root := t.TempDir() + targetDir := filepath.Join(root, "target") + layerDir := filepath.Join(root, "layer") + require.NoError(t, os.MkdirAll(filepath.Join(targetDir, "usr", "bin"), 0755)) + require.NoError(t, os.Symlink("usr/bin", filepath.Join(targetDir, "bin"))) + require.NoError(t, os.MkdirAll(filepath.Join(layerDir, "bin"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(layerDir, "bin", "tool"), []byte("tool"), 0644)) + + require.NoError(t, applyLayerTree(layerDir, targetDir)) + + data, err := os.ReadFile(filepath.Join(targetDir, "usr", "bin", "tool")) + require.NoError(t, err) + require.Equal(t, "tool", string(data)) + info, err := os.Lstat(filepath.Join(targetDir, "bin")) + require.NoError(t, err) + require.Equal(t, os.ModeSymlink, info.Mode()&os.ModeSymlink) +} + func TestApplyLayerTreeSymlinksAndHardlinks(t *testing.T) { root := t.TempDir() targetDir := filepath.Join(root, "target") diff --git a/lib/images/layer_tree.go b/lib/images/layer_tree.go index c48b1a2a1..03358d11e 100644 --- a/lib/images/layer_tree.go +++ b/lib/images/layer_tree.go @@ -59,7 +59,11 @@ func makeTreeWritable(path string) error { // whiteout files are interpreted here rather than passed through, because // overlayfs does not understand them. No production caller yet; composition // lands in a later change. -func applyLayerTree(layerDir, targetDir string) (err error) { +func applyLayerTree(layerDir, targetDir string) error { + return applyLayerTreeWithExplicitDirs(layerDir, targetDir, nil) +} + +func applyLayerTreeWithExplicitDirs(layerDir, targetDir string, explicitDirs map[string]struct{}) (err error) { if err := os.MkdirAll(targetDir, 0755); err != nil { return fmt.Errorf("create target directory: %w", err) } @@ -93,7 +97,7 @@ func applyLayerTree(layerDir, targetDir string) (err error) { if err != nil { return err } - targetParent, err := safeJoin(targetDir, filepath.Dir(rel)) + targetParent, err := safeJoinForComposition(targetDir, filepath.Dir(rel)) if err != nil { return err } @@ -107,7 +111,7 @@ func applyLayerTree(layerDir, targetDir string) (err error) { if hidden == "" || hidden == "." || hidden == ".." { return fmt.Errorf("invalid whiteout entry: %s", rel) } - target, err := safeJoin(targetDir, filepath.Join(filepath.Dir(rel), hidden)) + target, err := safeJoinForComposition(targetDir, filepath.Join(filepath.Dir(rel), hidden)) if err != nil { return err } @@ -146,19 +150,33 @@ func applyLayerTree(layerDir, targetDir string) (err error) { if err != nil { return err } - target, err := safeJoin(targetDir, rel) + target, err := safeJoinForComposition(targetDir, rel) if err != nil { return err } + if entry.IsDir() { + target, err = resolveCompositionDirTarget(targetDir, target) + if err != nil { + return err + } + } if err := makePathWritable(targetDir, filepath.Dir(target), originalModes); err != nil { return err } if entry.IsDir() { - info, err := entry.Info() - if err != nil { - return err + if explicitDirs == nil { + info, err := entry.Info() + if err != nil { + return err + } + pendingDirs = append(pendingDirs, dirMeta{src: path, dst: target, info: info}) + } else if _, explicit := explicitDirs[filepath.Clean(rel)]; explicit { + info, err := entry.Info() + if err != nil { + return err + } + pendingDirs = append(pendingDirs, dirMeta{src: path, dst: target, info: info}) } - pendingDirs = append(pendingDirs, dirMeta{src: path, dst: target, info: info}) } if err := copyEntryInto(path, target, hardlinks, targetDir); err != nil { return err @@ -196,7 +214,11 @@ func prepareLayerDirectories(layerDir, targetDir string, originalModes map[strin if err != nil { return err } - target, err := safeJoin(targetDir, rel) + target, err := safeJoinForComposition(targetDir, rel) + if err != nil { + return err + } + target, err = resolveCompositionDirTarget(targetDir, target) if err != nil { return err } @@ -207,7 +229,7 @@ func prepareLayerDirectories(layerDir, targetDir string, originalModes map[strin if err != nil { return err } - if err := copyDirectoryEntry(path, target, info); err != nil { + if err := copyDirectoryEntry(path, target, info, targetDir); err != nil { return err } return makePathWritable(targetDir, target, originalModes) @@ -331,7 +353,7 @@ func copyEntryInto(src, dst string, hardlinks map[hardlinkIdentity]string, root case 0: return copyRegularEntry(src, dst, info, hardlinks) case fs.ModeDir: - return copyDirectoryEntry(src, dst, info) + return copyDirectoryEntry(src, dst, info, root) case fs.ModeSymlink: return copySymlinkEntry(src, dst, root) default: @@ -356,10 +378,20 @@ func copyRegularEntry(src, dst string, info os.FileInfo, hardlinks map[hardlinkI return copyEntryMetadata(src, dst, info) } -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 +func copyDirectoryEntry(src, dst string, info os.FileInfo, root string) error { + if existing, err := os.Lstat(dst); err == nil { + if existing.Mode()&os.ModeSymlink != 0 { + resolved, resolveErr := resolveLayerPath(root, dst, 0) + if resolveErr == nil && pathWithinRoot(root, resolved) { + return nil + } + if err := removePath(dst); err != nil { + return err + } + } else if !existing.IsDir() { + if err := removePath(dst); err != nil { + return err + } } } return os.MkdirAll(dst, info.Mode().Perm()) From 21e863c66daffe675dd99e61cf4aab8a9931ed5e Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Thu, 3 Sep 2026 14:03:48 +0000 Subject: [PATCH 12/21] Use singleflight for layer materialization --- lib/images/layer_artifact.go | 3 --- lib/images/manager.go | 1 - 2 files changed, 4 deletions(-) diff --git a/lib/images/layer_artifact.go b/lib/images/layer_artifact.go index 8733557f1..4cae3d0e4 100644 --- a/lib/images/layer_artifact.go +++ b/lib/images/layer_artifact.go @@ -156,9 +156,6 @@ func (m *manager) materializeLayerArtifactContext(ctx context.Context, desc laye } func (m *manager) materializeLayerArtifactOnce(ctx context.Context, desc layerDescriptor) (*layerArtifact, error) { - m.layerStoreMu.Lock() - defer m.layerStoreMu.Unlock() - if layerArtifactFormat() == "" { return nil, fmt.Errorf("unsupported layer artifact format: %s", DefaultImageFormat) } diff --git a/lib/images/manager.go b/lib/images/manager.go index f18fe8a8d..bceeb0f05 100644 --- a/lib/images/manager.go +++ b/lib/images/manager.go @@ -77,7 +77,6 @@ type manager struct { queue *queue.Queue createMu sync.Mutex layerFlights singleflight.Group - layerStoreMu sync.Mutex diskUsageMu sync.RWMutex tagGenerations map[string]uint64 requestedTags map[string]string // newest pull's digest per requested tag From 2398f43e7cba9ba7992c6de43d681a786656feda Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Thu, 3 Sep 2026 14:07:03 +0000 Subject: [PATCH 13/21] Test synthesized directory metadata preservation --- lib/images/layer_artifact_test.go | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/lib/images/layer_artifact_test.go b/lib/images/layer_artifact_test.go index 78a7bbcd7..8f45d2476 100644 --- a/lib/images/layer_artifact_test.go +++ b/lib/images/layer_artifact_test.go @@ -452,6 +452,21 @@ func TestValidateSymlinkTargetAllowsAbsoluteTargets(t *testing.T) { require.NoError(t, validateSymlinkTarget(root, target, "/etc/resolv.conf")) } +func TestApplyLayerTreePreservesImplicitDirectoryMetadata(t *testing.T) { + root := t.TempDir() + targetDir := filepath.Join(root, "target") + layerDir := filepath.Join(root, "layer") + require.NoError(t, os.MkdirAll(filepath.Join(targetDir, "etc"), 0700)) + require.NoError(t, os.MkdirAll(filepath.Join(layerDir, "etc"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(layerDir, "etc", "app.conf"), []byte("config"), 0644)) + + require.NoError(t, applyLayerTreeWithExplicitDirs(layerDir, targetDir, map[string]struct{}{})) + + info, err := os.Stat(filepath.Join(targetDir, "etc")) + require.NoError(t, err) + require.Equal(t, os.FileMode(0700), info.Mode().Perm()) +} + func TestApplyLayerTreeResolvesInRootSymlinkParents(t *testing.T) { root := t.TempDir() targetDir := filepath.Join(root, "target") From 1496c230e96afcdc2b0ee84a54fec49837aa5ed3 Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Thu, 3 Sep 2026 14:27:35 +0000 Subject: [PATCH 14/21] Collapse layer helper wrappers and dead branches Drop the context-less wrapper pairs (materializeLayerArtifact, installLayerArtifact, unpackCachedLayer, unpackLayerBlob, applyLayerTree) in favor of single ctx-taking functions; two of the wrappers had no callers. Reuse unpackCachedLayer from materializeLayerArtifactOnce instead of duplicating the blob lookup and diff-id check. Split the lexical confinement out of safeJoinForComposition into confineToRoot so safeJoin no longer performs and discards a full symlink resolution per tar entry. safeJoin also returns early when the entry resolves to the root itself, which previously looped forever on a "." entry. Pass the walked DirEntry's FileInfo into the copy helpers instead of re-Lstat'ing, remove the unreachable symlink branch in copyDirectoryEntry (callers already resolve through resolveCompositionDirTarget), and lean on removePath's own tree-writability handling for whiteout removal. --- lib/images/disk_usage.go | 2 +- lib/images/layer_artifact.go | 114 ++++++++++++------------------ lib/images/layer_artifact_test.go | 44 ++++++------ lib/images/layer_tree.go | 90 +++++++---------------- 4 files changed, 97 insertions(+), 153 deletions(-) diff --git a/lib/images/disk_usage.go b/lib/images/disk_usage.go index b9b91bf19..0cb6a74d6 100644 --- a/lib/images/disk_usage.go +++ b/lib/images/disk_usage.go @@ -110,7 +110,7 @@ func totalLayerArtifactBytesFromFilesystem(layersDir string) (int64, error) { total += info.Size() return nil }) - if err != nil && !os.IsNotExist(err) { + if err != nil { return 0, fmt.Errorf("walk layer artifacts: %w", err) } return total, nil diff --git a/lib/images/layer_artifact.go b/lib/images/layer_artifact.go index 4cae3d0e4..761c62de4 100644 --- a/lib/images/layer_artifact.go +++ b/lib/images/layer_artifact.go @@ -135,11 +135,7 @@ func discardLayerCache(p *paths.Paths, layerHex string) error { // temp directory; lifecycle reconciliation removes stale temp directories after // an interrupted build. No production caller yet: pull integration and // composition land in later changes. -func (m *manager) materializeLayerArtifact(desc layerDescriptor) (*layerArtifact, error) { - return m.materializeLayerArtifactContext(context.Background(), desc) -} - -func (m *manager) materializeLayerArtifactContext(ctx context.Context, desc layerDescriptor) (*layerArtifact, error) { +func (m *manager) materializeLayerArtifact(ctx context.Context, desc layerDescriptor) (*layerArtifact, error) { key := desc.Digest + "\x00" + layerArtifactFormat() result := m.layerFlights.DoChan(key, func() (any, error) { return m.materializeLayerArtifactOnce(ctx, desc) @@ -178,14 +174,6 @@ func (m *manager) materializeLayerArtifactOnce(ctx context.Context, desc layerDe // 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) @@ -196,22 +184,14 @@ func (m *manager) materializeLayerArtifactOnce(ctx context.Context, desc layerDe } defer removePath(unpackDir) - stats, err := unpackLayerBlobContext(ctx, blobPath, desc.MediaType, unpackDir) + stats, err := unpackCachedLayer(ctx, m.paths.SystemOCICache(), desc, unpackDir) 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 nil, err } - - return m.installLayerArtifactContext(ctx, desc, layerHex, unpackDir, stats) + return m.installLayerArtifact(ctx, desc, layerHex, unpackDir, stats) } -func (m *manager) installLayerArtifact(desc layerDescriptor, layerHex, unpackDir string, stats *unpackStats) (*layerArtifact, error) { - return m.installLayerArtifactContext(context.Background(), desc, layerHex, unpackDir, stats) -} - -func (m *manager) installLayerArtifactContext(ctx context.Context, desc layerDescriptor, layerHex, unpackDir string, stats *unpackStats) (*layerArtifact, error) { +func (m *manager) installLayerArtifact(ctx context.Context, desc layerDescriptor, layerHex, unpackDir string, stats *unpackStats) (*layerArtifact, error) { record := &layerArtifact{ SchemaVersion: layerRecordSchemaVersion, Digest: desc.Digest, @@ -259,15 +239,9 @@ type unpackStats struct { explicitDirs map[string]struct{} } -// unpackLayerBlob extracts one compressed layer blob into dest while preserving -// whiteout marker files. It intentionally does not use umoci's layer unpacker: -// umoci consumes whiteouts while this store must retain them for composition. -// Paths are confined to dest. -func unpackCachedLayer(cacheDir string, desc layerDescriptor, dest string) (*unpackStats, error) { - return unpackCachedLayerContext(context.Background(), cacheDir, desc, dest) -} - -func unpackCachedLayerContext(ctx context.Context, cacheDir string, desc layerDescriptor, dest string) (*unpackStats, error) { +// unpackCachedLayer locates desc's blob in the shared OCI cache, unpacks it +// into dest, and verifies the diff ID when the descriptor carries one. +func unpackCachedLayer(ctx context.Context, cacheDir string, desc layerDescriptor, dest string) (*unpackStats, error) { layerHex := strings.TrimPrefix(desc.Digest, "sha256:") if err := paths.ValidatePathComponent(layerHex); err != nil { return nil, fmt.Errorf("invalid layer digest: %s", desc.Digest) @@ -279,7 +253,7 @@ func unpackCachedLayerContext(ctx context.Context, cacheDir string, desc layerDe } return nil, fmt.Errorf("stat layer blob: %w", err) } - stats, err := unpackLayerBlobContext(ctx, blobPath, desc.MediaType, dest) + stats, err := unpackLayerBlob(ctx, blobPath, desc.MediaType, dest) if err != nil { return nil, fmt.Errorf("unpack layer %s: %w", desc.Digest, err) } @@ -289,11 +263,11 @@ func unpackCachedLayerContext(ctx context.Context, cacheDir string, desc layerDe return stats, nil } -func unpackLayerBlob(blobPath, mediaType, dest string) (*unpackStats, error) { - return unpackLayerBlobContext(context.Background(), blobPath, mediaType, dest) -} - -func unpackLayerBlobContext(ctx context.Context, blobPath, mediaType, dest string) (*unpackStats, error) { +// unpackLayerBlob extracts one compressed layer blob into dest while preserving +// whiteout marker files. It intentionally does not use umoci's layer unpacker: +// umoci consumes whiteouts while this store must retain them for composition. +// Paths are confined to dest. +func unpackLayerBlob(ctx context.Context, blobPath, mediaType, dest string) (*unpackStats, error) { if err := os.MkdirAll(dest, 0755); err != nil { return nil, fmt.Errorf("create extraction root: %w", err) } @@ -471,17 +445,40 @@ func (c multiCloser) Close() error { return firstErr } +// confineToRoot lexically resolves an entry name inside root, rejecting +// absolute names and parent traversal. root must be an existing directory +// that is not itself a symlink. +func confineToRoot(root, name string) (string, error) { + root = filepath.Clean(root) + rootInfo, err := os.Lstat(root) + if err != nil { + return "", fmt.Errorf("inspect extraction root: %w", err) + } + if !rootInfo.IsDir() || rootInfo.Mode()&os.ModeSymlink != 0 { + return "", fmt.Errorf("extraction root is not a directory: %s", root) + } + if filepath.IsAbs(name) { + return "", fmt.Errorf("tar entry escapes root: %s", name) + } + target := filepath.Join(root, name) + if !pathWithinRoot(root, target) { + return "", fmt.Errorf("tar entry escapes root: %s", name) + } + return target, nil +} + // safeJoin resolves a tar entry inside root and rejects symlinked parents // while extracting a layer. This prevents one tar entry from changing where // a later entry is written. func safeJoin(root, name string) (string, error) { - resolved, err := safeJoinForComposition(root, name) + target, err := confineToRoot(root, name) if err != nil { return "", err } root = filepath.Clean(root) - clean := filepath.Clean(name) - target := filepath.Join(root, clean) + if target == root { + return root, nil + } for parent := filepath.Dir(target); parent != root; parent = filepath.Dir(parent) { info, err := os.Lstat(parent) if err != nil { @@ -497,36 +494,21 @@ func safeJoin(root, name string) (string, error) { return "", fmt.Errorf("tar entry parent is not a directory: %s", parent) } } - return resolved, nil + return target, nil } // safeJoinForComposition resolves an entry through existing parent symlinks, // interpreting absolute link targets relative to the image root. func safeJoinForComposition(root, name string) (string, error) { - root = filepath.Clean(root) - rootInfo, err := os.Lstat(root) + target, err := confineToRoot(root, name) if err != nil { - return "", fmt.Errorf("inspect extraction root: %w", err) - } - if !rootInfo.IsDir() || rootInfo.Mode()&os.ModeSymlink != 0 { - return "", fmt.Errorf("extraction root is not a directory: %s", root) - } - if filepath.IsAbs(name) { - return "", fmt.Errorf("tar entry escapes root: %s", name) + return "", err } - clean := filepath.Clean(name) - if clean == "." { + root = filepath.Clean(root) + if target == root { return root, nil } - if clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) { - return "", fmt.Errorf("tar entry escapes root: %s", name) - } - target := filepath.Join(root, clean) - if !strings.HasPrefix(target, root+string(filepath.Separator)) { - return "", fmt.Errorf("tar entry escapes root: %s", name) - } - parent := filepath.Dir(target) - resolvedParent, err := resolveLayerPath(root, parent, 0) + resolvedParent, err := resolveLayerPath(root, filepath.Dir(target), 0) if err != nil { return "", fmt.Errorf("inspect tar entry parent: %w", err) } @@ -610,9 +592,7 @@ func validateSymlinkTarget(root, target, linkname string) error { if filepath.IsAbs(linkname) { return nil } - resolved := filepath.Clean(filepath.Join(filepath.Dir(target), linkname)) - root = filepath.Clean(root) - if resolved != root && !strings.HasPrefix(resolved, root+string(filepath.Separator)) { + if !pathWithinRoot(root, filepath.Join(filepath.Dir(target), linkname)) { return fmt.Errorf("symlink target escapes root: %s", linkname) } return nil diff --git a/lib/images/layer_artifact_test.go b/lib/images/layer_artifact_test.go index 8f45d2476..96f900e92 100644 --- a/lib/images/layer_artifact_test.go +++ b/lib/images/layer_artifact_test.go @@ -71,7 +71,7 @@ func TestMaterializeLayerArtifact(t *testing.T) { desc := layerDescFromImage(t, img, 0) m := &manager{paths: p} - record, err := m.materializeLayerArtifact(desc) + record, err := m.materializeLayerArtifact(context.Background(), desc) require.NoError(t, err) require.Equal(t, desc.Digest, record.Digest) require.Equal(t, desc.DiffID, record.DiffID) @@ -87,7 +87,7 @@ func TestMaterializeLayerArtifact(t *testing.T) { // A second materialization reuses the existing artifact. artifactInfo, err := os.Stat(p.ImageLayerArtifactForFormat(layerHex, layerArtifactFormat())) require.NoError(t, err) - reused, err := m.materializeLayerArtifact(desc) + reused, err := m.materializeLayerArtifact(context.Background(), desc) require.NoError(t, err) require.True(t, record.CreatedAt.Equal(reused.CreatedAt), "reuse must return the stored record") artifactInfoAfter, err := os.Stat(p.ImageLayerArtifactForFormat(layerHex, layerArtifactFormat())) @@ -110,7 +110,7 @@ func TestMaterializeLayerArtifactRecoversCorruptRecord(t *testing.T) { desc := layerDescFromImage(t, img, 0) m := &manager{paths: p} - first, err := m.materializeLayerArtifact(desc) + first, err := m.materializeLayerArtifact(context.Background(), desc) require.NoError(t, err) layerHex := desc.Digest[len("sha256:"):] @@ -120,7 +120,7 @@ func TestMaterializeLayerArtifactRecoversCorruptRecord(t *testing.T) { 0600, )) - second, err := m.materializeLayerArtifact(desc) + second, err := m.materializeLayerArtifact(context.Background(), desc) require.NoError(t, err) require.NotEqual(t, first.CreatedAt, second.CreatedAt, "corrupt record must trigger a rebuild") require.FileExists(t, p.ImageLayerArtifactForFormat(layerHex, layerArtifactFormat())) @@ -136,7 +136,7 @@ func TestMaterializeLayerArtifactMissingBlob(t *testing.T) { p := paths.New(t.TempDir()) m := &manager{paths: p} - _, err := m.materializeLayerArtifact(layerDescriptor{ + _, err := m.materializeLayerArtifact(context.Background(), layerDescriptor{ Digest: "sha256:abababababababababababababababababababababababababababababababab", MediaType: "application/vnd.oci.image.layer.v1.tar+gzip", }) @@ -193,7 +193,7 @@ func TestMaterializeLayerDoesNotPersistWhiteoutInventory(t *testing.T) { desc := layerDescFromImage(t, img, 0) m := &manager{paths: p} - _, err = m.materializeLayerArtifact(desc) + _, err = m.materializeLayerArtifact(context.Background(), desc) require.NoError(t, err) data, err := os.ReadFile(p.ImageLayerRecordForFormat(desc.Digest[len("sha256:"):], layerArtifactFormat())) @@ -211,7 +211,7 @@ func TestApplyLayerTreeCopiesRestrictiveFiles(t *testing.T) { require.NoError(t, os.WriteFile(file, []byte("secret"), 0644)) require.NoError(t, os.Chmod(file, 0000)) - require.NoError(t, applyLayerTree(layerDir, targetDir)) + require.NoError(t, applyLayerTree(layerDir, targetDir, nil)) target := filepath.Join(targetDir, "secret.txt") info, err := os.Stat(target) @@ -267,7 +267,7 @@ func TestApplyLayerTreeWhiteoutSemantics(t *testing.T) { 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)) + require.NoError(t, applyLayerTree(layerDir, targetDir, nil)) // Whiteout removed the lower entry. _, err := os.Lstat(filepath.Join(targetDir, "victim.txt")) @@ -325,7 +325,7 @@ func TestApplyLayerTreeReplacesSymlinkBeforeNestedWhiteout(t *testing.T) { require.NoError(t, os.WriteFile(filepath.Join(layerDir, "replaced", ".wh.old.txt"), nil, 0644)) require.NoError(t, os.WriteFile(filepath.Join(layerDir, "replaced", "new.txt"), []byte("new"), 0644)) - require.NoError(t, applyLayerTree(layerDir, targetDir)) + require.NoError(t, applyLayerTree(layerDir, targetDir, nil)) info, err := os.Lstat(filepath.Join(targetDir, "replaced")) require.NoError(t, err) @@ -344,7 +344,7 @@ func TestApplyLayerTreePreservesUpperDirectoryMode(t *testing.T) { require.NoError(t, os.MkdirAll(filepath.Join(layerDir, "etc"), 0750)) require.NoError(t, os.WriteFile(filepath.Join(layerDir, "etc", "new.txt"), []byte("new"), 0644)) - require.NoError(t, applyLayerTree(layerDir, targetDir)) + require.NoError(t, applyLayerTree(layerDir, targetDir, nil)) info, err := os.Stat(filepath.Join(targetDir, "etc")) require.NoError(t, err) @@ -362,7 +362,7 @@ func TestApplyLayerTreeWhiteoutRemovesReadOnlyDirectory(t *testing.T) { require.NoError(t, os.MkdirAll(layerDir, 0755)) require.NoError(t, os.WriteFile(filepath.Join(layerDir, ".wh.gone"), nil, 0644)) - require.NoError(t, applyLayerTree(layerDir, targetDir)) + require.NoError(t, applyLayerTree(layerDir, targetDir, nil)) _, err := os.Lstat(filepath.Join(targetDir, "gone")) require.ErrorIs(t, err, os.ErrNotExist) } @@ -380,7 +380,7 @@ func TestUnpackLayerBlobContextHonorsCancellation(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() - _, err = unpackLayerBlobContext(ctx, blobPath, "application/vnd.oci.image.layer.v1.tar", filepath.Join(root, "dest")) + _, err = unpackLayerBlob(ctx, blobPath, "application/vnd.oci.image.layer.v1.tar", filepath.Join(root, "dest")) require.ErrorIs(t, err, context.Canceled) } @@ -404,7 +404,7 @@ func TestUnpackLayerBlobIncludesTrailingTarPaddingInDiffID(t *testing.T) { require.NoError(t, gzw.Close()) require.NoError(t, os.WriteFile(blobPath, compressed.Bytes(), 0644)) - stats, err := unpackLayerBlob(blobPath, "application/vnd.oci.image.layer.v1.tar+gzip", filepath.Join(root, "dest")) + stats, err := unpackLayerBlob(context.Background(), blobPath, "application/vnd.oci.image.layer.v1.tar+gzip", filepath.Join(root, "dest")) require.NoError(t, err) want := sha256.Sum256(tarData.Bytes()) require.Equal(t, "sha256:"+fmt.Sprintf("%x", want), stats.diffID) @@ -442,7 +442,7 @@ func TestUnpackLayerBlobRejectsSymlinkTraversal(t *testing.T) { 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")) + _, err = unpackLayerBlob(context.Background(), blobPath, "application/vnd.oci.image.layer.v1.tar+gzip", filepath.Join(root, "dest")) require.ErrorContains(t, err, "symlink") } @@ -460,7 +460,7 @@ func TestApplyLayerTreePreservesImplicitDirectoryMetadata(t *testing.T) { require.NoError(t, os.MkdirAll(filepath.Join(layerDir, "etc"), 0755)) require.NoError(t, os.WriteFile(filepath.Join(layerDir, "etc", "app.conf"), []byte("config"), 0644)) - require.NoError(t, applyLayerTreeWithExplicitDirs(layerDir, targetDir, map[string]struct{}{})) + require.NoError(t, applyLayerTree(layerDir, targetDir, map[string]struct{}{})) info, err := os.Stat(filepath.Join(targetDir, "etc")) require.NoError(t, err) @@ -476,7 +476,7 @@ func TestApplyLayerTreeResolvesInRootSymlinkParents(t *testing.T) { require.NoError(t, os.MkdirAll(filepath.Join(layerDir, "bin"), 0755)) require.NoError(t, os.WriteFile(filepath.Join(layerDir, "bin", "tool"), []byte("tool"), 0644)) - require.NoError(t, applyLayerTree(layerDir, targetDir)) + require.NoError(t, applyLayerTree(layerDir, targetDir, nil)) data, err := os.ReadFile(filepath.Join(targetDir, "usr", "bin", "tool")) require.NoError(t, err) @@ -503,7 +503,7 @@ func TestApplyLayerTreeSymlinksAndHardlinks(t *testing.T) { 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)) + require.NoError(t, applyLayerTree(layerDir, targetDir, nil)) _, err := os.Lstat(filepath.Join(targetDir, "alias")) require.True(t, os.IsNotExist(err), "symlink whiteout must remove the link itself") @@ -565,7 +565,7 @@ func TestUnpackLayerBlobPreservesDirMtime(t *testing.T) { require.NoError(t, os.WriteFile(blobPath, buf.Bytes(), 0644)) dest := filepath.Join(root, "dest") - _, err = unpackLayerBlob(blobPath, "application/vnd.oci.image.layer.v1.tar", dest) + _, err = unpackLayerBlob(context.Background(), blobPath, "application/vnd.oci.image.layer.v1.tar", dest) require.NoError(t, err) info, err := os.Stat(filepath.Join(dest, "d")) @@ -609,7 +609,7 @@ func TestApplyLayerTreePreservesDirMtime(t *testing.T) { require.NoError(t, os.WriteFile(filepath.Join(layerDir, "d", "file.txt"), []byte("x"), 0644)) require.NoError(t, os.Chtimes(filepath.Join(layerDir, "d"), old, old)) - require.NoError(t, applyLayerTree(layerDir, targetDir)) + require.NoError(t, applyLayerTree(layerDir, targetDir, nil)) info, err := os.Stat(filepath.Join(targetDir, "d")) require.NoError(t, err) @@ -652,7 +652,7 @@ func TestApplyLayerTreeRejectsMalformedWhiteouts(t *testing.T) { require.NoError(t, os.WriteFile(filepath.Join(layerDir, "parent", ".wh.."), nil, 0644)) require.NoError(t, os.WriteFile(filepath.Join(layerDir, "parent", ".wh..."), nil, 0644)) - err := applyLayerTree(layerDir, targetDir) + err := applyLayerTree(layerDir, targetDir, nil) require.ErrorContains(t, err, "invalid whiteout entry") // Parent directory must not be deleted by malformed whiteout paths. @@ -670,7 +670,7 @@ func TestApplyLayerTreeRejectsEscapingSymlink(t *testing.T) { require.NoError(t, os.MkdirAll(layerDir, 0755)) require.NoError(t, os.Symlink("../../outside", filepath.Join(layerDir, "link"))) - err := applyLayerTree(layerDir, targetDir) + err := applyLayerTree(layerDir, targetDir, nil) require.ErrorContains(t, err, "symlink target escapes root") } @@ -685,7 +685,7 @@ func TestApplyLayerTreeRestoresRestrictiveNestedDirModes(t *testing.T) { require.NoError(t, os.Chmod(filepath.Join(layerDir, "outer", "inner"), 0500)) require.NoError(t, os.Chmod(filepath.Join(layerDir, "outer"), 0500)) - require.NoError(t, applyLayerTree(layerDir, targetDir)) + require.NoError(t, applyLayerTree(layerDir, targetDir, nil)) infoOuter, err := os.Stat(filepath.Join(targetDir, "outer")) require.NoError(t, err) diff --git a/lib/images/layer_tree.go b/lib/images/layer_tree.go index 03358d11e..2d2d196a6 100644 --- a/lib/images/layer_tree.go +++ b/lib/images/layer_tree.go @@ -59,22 +59,18 @@ func makeTreeWritable(path string) error { // whiteout files are interpreted here rather than passed through, because // overlayfs does not understand them. No production caller yet; composition // lands in a later change. -func applyLayerTree(layerDir, targetDir string) error { - return applyLayerTreeWithExplicitDirs(layerDir, targetDir, nil) -} - -func applyLayerTreeWithExplicitDirs(layerDir, targetDir string, explicitDirs map[string]struct{}) (err error) { +// +// explicitDirs, when non-nil, names the directories the layer tar listed +// explicitly (relative, cleaned); only those have their metadata copied. +// A nil map treats every directory as explicit. +func applyLayerTree(layerDir, targetDir string, explicitDirs map[string]struct{}) (err error) { if err := os.MkdirAll(targetDir, 0755); err != nil { return fmt.Errorf("create target directory: %w", err) } originalModes := make(map[string]fs.FileMode) defer func() { if restoreErr := restoreDirectoryModes(originalModes); restoreErr != nil { - if err == nil { - err = fmt.Errorf("restore directory modes: %w", restoreErr) - } else { - err = errors.Join(err, fmt.Errorf("restore directory modes: %w", restoreErr)) - } + err = errors.Join(err, fmt.Errorf("restore directory modes: %w", restoreErr)) } }() @@ -115,13 +111,6 @@ func applyLayerTreeWithExplicitDirs(layerDir, targetDir string, explicitDirs map if err != nil { return err } - if info, err := os.Lstat(target); err == nil && info.IsDir() { - if err := makePathWritable(targetDir, target, originalModes); err != nil { - return err - } - } else if err != nil && !os.IsNotExist(err) { - return err - } return removePath(target) }) if err != nil { @@ -163,22 +152,16 @@ func applyLayerTreeWithExplicitDirs(layerDir, targetDir string, explicitDirs map if err := makePathWritable(targetDir, filepath.Dir(target), originalModes); err != nil { return err } + info, err := entry.Info() + if err != nil { + return err + } if entry.IsDir() { - if explicitDirs == nil { - info, err := entry.Info() - if err != nil { - return err - } - pendingDirs = append(pendingDirs, dirMeta{src: path, dst: target, info: info}) - } else if _, explicit := explicitDirs[filepath.Clean(rel)]; explicit { - info, err := entry.Info() - if err != nil { - return err - } + if _, explicit := explicitDirs[filepath.Clean(rel)]; explicitDirs == nil || explicit { pendingDirs = append(pendingDirs, dirMeta{src: path, dst: target, info: info}) } } - if err := copyEntryInto(path, target, hardlinks, targetDir); err != nil { + if err := copyEntryInto(path, target, info, hardlinks, targetDir); err != nil { return err } if entry.IsDir() { @@ -229,7 +212,7 @@ func prepareLayerDirectories(layerDir, targetDir string, originalModes map[strin if err != nil { return err } - if err := copyDirectoryEntry(path, target, info, targetDir); err != nil { + if err := copyDirectoryEntry(target, info); err != nil { return err } return makePathWritable(targetDir, target, originalModes) @@ -239,7 +222,7 @@ func prepareLayerDirectories(layerDir, targetDir string, originalModes map[strin func makePathWritable(root, path string, originalModes map[string]fs.FileMode) error { root = filepath.Clean(root) path = filepath.Clean(path) - if path != root && !strings.HasPrefix(path, root+string(filepath.Separator)) { + if !pathWithinRoot(root, path) { return fmt.Errorf("path is outside target root: %s", path) } rel, err := filepath.Rel(root, path) @@ -344,18 +327,14 @@ type hardlinkIdentity struct { // 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, root string) error { - info, err := os.Lstat(src) - if err != nil { - return err - } +func copyEntryInto(src, dst string, info os.FileInfo, hardlinks map[hardlinkIdentity]string, root string) error { switch info.Mode() & fs.ModeType { case 0: return copyRegularEntry(src, dst, info, hardlinks) case fs.ModeDir: - return copyDirectoryEntry(src, dst, info, root) + return copyDirectoryEntry(dst, info) case fs.ModeSymlink: - return copySymlinkEntry(src, dst, root) + return copySymlinkEntry(src, dst, info, root) default: return copySpecialEntry(src, dst, info) } @@ -372,32 +351,25 @@ func copyRegularEntry(src, dst string, info os.FileInfo, hardlinks map[hardlinkI } hardlinks[identity] = dst } - if err := copyFileContents(src, dst); err != nil { + if err := copyFileContents(src, dst, info); err != nil { return err } return copyEntryMetadata(src, dst, info) } -func copyDirectoryEntry(src, dst string, info os.FileInfo, root string) error { - if existing, err := os.Lstat(dst); err == nil { - if existing.Mode()&os.ModeSymlink != 0 { - resolved, resolveErr := resolveLayerPath(root, dst, 0) - if resolveErr == nil && pathWithinRoot(root, resolved) { - return nil - } - if err := removePath(dst); err != nil { - return err - } - } else if !existing.IsDir() { - if err := removePath(dst); err != nil { - return err - } +// copyDirectoryEntry creates dst as a directory, replacing a conflicting +// non-directory. Callers resolve dst through resolveCompositionDirTarget +// first, so dst is never an existing symlink here. +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 } } return os.MkdirAll(dst, info.Mode().Perm()) } -func copySymlinkEntry(src, dst, root string) error { +func copySymlinkEntry(src, dst string, info os.FileInfo, root string) error { linkTarget, err := os.Readlink(src) if err != nil { return err @@ -411,10 +383,6 @@ func copySymlinkEntry(src, dst, root string) error { if err := os.Symlink(linkTarget, dst); err != nil { return err } - info, err := os.Lstat(src) - if err != nil { - return err - } return copyEntryMetadata(src, dst, info) } @@ -568,11 +536,7 @@ func applyXattrs(path string, xattrs map[string][]byte) error { return nil } -func copyFileContents(src, dst string) (err error) { - info, err := os.Lstat(src) - if err != nil { - return err - } +func copyFileContents(src, dst string, info os.FileInfo) (err error) { sourceMode := info.Mode().Perm() | info.Mode()&(os.ModeSetuid|os.ModeSetgid|os.ModeSticky) if sourceMode&0400 == 0 { if err := os.Chmod(src, sourceMode|0400); err != nil { From 2c55549f8ff7ccb3ef15eda4b4e2b2f4bc3fc66f Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Thu, 3 Sep 2026 14:42:53 +0000 Subject: [PATCH 15/21] Drop access time preservation from layer metadata Restoring atime required per-platform Stat_t accessors for a value that tar headers almost never carry and that nothing reads back. Set atime to mtime on both the tar extraction and tree copy paths instead. --- lib/images/layer_artifact.go | 5 ----- lib/images/layer_artifact_test.go | 20 -------------------- lib/images/layer_atime_darwin.go | 12 ------------ lib/images/layer_atime_linux.go | 12 ------------ lib/images/layer_tree.go | 5 +---- 5 files changed, 1 insertion(+), 53 deletions(-) delete mode 100644 lib/images/layer_atime_darwin.go delete mode 100644 lib/images/layer_atime_linux.go diff --git a/lib/images/layer_artifact.go b/lib/images/layer_artifact.go index 761c62de4..dd156a6a2 100644 --- a/lib/images/layer_artifact.go +++ b/lib/images/layer_artifact.go @@ -692,17 +692,12 @@ func applyTarMetadata(path string, header *tar.Header) error { if mtime.IsZero() { mtime = time.Now() } - atime := header.AccessTime - if atime.IsZero() { - atime = mtime - } return applyEntryMetadata(path, entryMetadata{ uid: header.Uid, gid: header.Gid, hasOwner: true, mode: header.FileInfo().Mode(), symlink: header.Typeflag == tar.TypeSymlink, - atime: atime, mtime: mtime, xattrs: tarXattrs(header), }) diff --git a/lib/images/layer_artifact_test.go b/lib/images/layer_artifact_test.go index 96f900e92..eeff21fa5 100644 --- a/lib/images/layer_artifact_test.go +++ b/lib/images/layer_artifact_test.go @@ -573,26 +573,6 @@ func TestUnpackLayerBlobPreservesDirMtime(t *testing.T) { require.True(t, info.ModTime().Equal(dirTime), "dir mtime must come from the tar header") } -func TestCopyEntryMetadataPreservesAccessTime(t *testing.T) { - root := t.TempDir() - src := filepath.Join(root, "src") - dst := filepath.Join(root, "dst") - require.NoError(t, os.WriteFile(src, []byte("payload"), 0644)) - require.NoError(t, os.WriteFile(dst, nil, 0644)) - - atime := time.Now().Add(-2 * time.Hour).Truncate(time.Second) - mtime := time.Now().Add(-time.Hour).Truncate(time.Second) - require.NoError(t, os.Chtimes(src, atime, mtime)) - info, err := os.Lstat(src) - require.NoError(t, err) - require.NoError(t, copyEntryMetadata(src, dst, info)) - - var stat unix.Stat_t - require.NoError(t, unix.Lstat(dst, &stat)) - require.True(t, time.Unix(stat.Atim.Sec, stat.Atim.Nsec).Equal(atime), "atime must be preserved") - require.True(t, time.Unix(stat.Mtim.Sec, stat.Mtim.Nsec).Equal(mtime), "mtime must be preserved") -} - func TestSpecialFileModeSupportsSockets(t *testing.T) { mode, err := specialFileMode(fs.ModeSocket) require.NoError(t, err) diff --git a/lib/images/layer_atime_darwin.go b/lib/images/layer_atime_darwin.go deleted file mode 100644 index 670259c4c..000000000 --- a/lib/images/layer_atime_darwin.go +++ /dev/null @@ -1,12 +0,0 @@ -//go:build darwin - -package images - -import ( - "syscall" - "time" -) - -func statAtime(stat *syscall.Stat_t) time.Time { - return time.Unix(stat.Atimespec.Sec, stat.Atimespec.Nsec) -} diff --git a/lib/images/layer_atime_linux.go b/lib/images/layer_atime_linux.go deleted file mode 100644 index e2912da06..000000000 --- a/lib/images/layer_atime_linux.go +++ /dev/null @@ -1,12 +0,0 @@ -//go:build linux - -package images - -import ( - "syscall" - "time" -) - -func statAtime(stat *syscall.Stat_t) time.Time { - return time.Unix(stat.Atim.Sec, stat.Atim.Nsec) -} diff --git a/lib/images/layer_tree.go b/lib/images/layer_tree.go index 2d2d196a6..634a015b3 100644 --- a/lib/images/layer_tree.go +++ b/lib/images/layer_tree.go @@ -424,7 +424,6 @@ type entryMetadata struct { hasOwner bool mode fs.FileMode symlink bool - atime time.Time mtime time.Time xattrs map[string][]byte } @@ -445,21 +444,19 @@ func applyEntryMetadata(path string, metadata entryMetadata) error { if err := os.Chmod(path, mode); err != nil { return err } - return os.Chtimes(path, metadata.atime, metadata.mtime) + return os.Chtimes(path, metadata.mtime, metadata.mtime) } func copyEntryMetadata(src, dst string, info os.FileInfo) error { metadata := entryMetadata{ mode: info.Mode(), symlink: info.Mode()&os.ModeSymlink != 0, - atime: info.ModTime(), mtime: info.ModTime(), } if stat, ok := info.Sys().(*syscall.Stat_t); ok { metadata.uid = int(stat.Uid) metadata.gid = int(stat.Gid) metadata.hasOwner = true - metadata.atime = statAtime(stat) } if !metadata.symlink { xattrs, err := readXattrs(src) From 08091583f5a24b8f100fc9d4d2dc5a83ca3f4b77 Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Thu, 3 Sep 2026 14:54:20 +0000 Subject: [PATCH 16/21] Extract layers with umoci instead of a private tar walker Replace the hand-rolled tar extractor and tree merger with umoci's layer.UnpackLayer, which the pull path already depends on. umoci confines entries with filepath-securejoin, interprets OCI whiteouts, and follows the same directory-over-symlink rule as containerd and docker (replace, do not write through), so composition no longer diverges from other runtimes. Per-layer artifacts are extracted in OverlayfsRootfs form: whiteouts become 0:0 character devices and opaque directories carry the overlayfs opaque xattr, the on-disk representation an overlayfs mount of stacked layers understands. Composition applies each layer directly onto the staging tree with DirRootfs, removing the unpack-then-copy pass and the directory mode bookkeeping it required. Ownership is preserved when running as root; unprivileged runs use umoci's rootless mode as before. The record drops the entry count, which is no longer observable without a second pass over the tar, and reports the decompressed tar size as unpacked bytes. --- lib/images/fs_entry.go | 33 -- lib/images/layer_artifact.go | 460 +++---------------- lib/images/layer_artifact_test.go | 719 ++++++++---------------------- lib/images/layer_tree.go | 563 ----------------------- 4 files changed, 258 insertions(+), 1517 deletions(-) delete mode 100644 lib/images/fs_entry.go delete mode 100644 lib/images/layer_tree.go diff --git a/lib/images/fs_entry.go b/lib/images/fs_entry.go deleted file mode 100644 index 72443077f..000000000 --- a/lib/images/fs_entry.go +++ /dev/null @@ -1,33 +0,0 @@ -package images - -import ( - "errors" - "fmt" - "os" - "syscall" - - "golang.org/x/sys/unix" -) - -func mknodWithRootlessFallback(path string, mode uint32, dev int) error { - if err := unix.Mknod(path, mode, dev); err != nil { - if !errors.Is(err, unix.EPERM) { - return fmt.Errorf("mknod: %w", err) - } - if err := createRootlessDevicePlaceholder(path); err != nil { - return err - } - } - return nil -} - -func createRootlessDevicePlaceholder(path string) error { - file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|syscall.O_NOFOLLOW, 0644) - if err != nil { - return fmt.Errorf("create rootless device placeholder: %w", err) - } - if err := file.Close(); err != nil { - return err - } - return nil -} diff --git a/lib/images/layer_artifact.go b/lib/images/layer_artifact.go index dd156a6a2..6d67a9c56 100644 --- a/lib/images/layer_artifact.go +++ b/lib/images/layer_artifact.go @@ -1,7 +1,6 @@ package images import ( - "archive/tar" "compress/gzip" "context" "crypto/sha256" @@ -9,30 +8,25 @@ import ( "errors" "fmt" "io" + "io/fs" "os" "path/filepath" "strings" - "syscall" "time" "github.com/kernel/hypeman/lib/paths" "github.com/klauspost/compress/zstd" - "golang.org/x/sys/unix" + "github.com/opencontainers/umoci/oci/layer" ) -// 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" -) +// whiteoutPrefix marks OCI whiteout entries (".wh." and ".wh..wh..opq"). +// umoci interprets them during extraction: DirRootfs applies them against the +// tree being composed, OverlayfsRootfs converts them to overlayfs whiteout +// inodes and opaque xattrs so per-layer artifacts can later be stacked. +const whiteoutPrefix = ".wh." const ( layerRecordSchemaVersion = 1 - maxLayerEntries = 1_000_000 maxLayerUnpackedBytes = 100 << 30 ) @@ -46,9 +40,8 @@ type layerArtifact struct { Digest string `json:"digest"` // compressed layer blob digest, sha256:... DiffID string `json:"diff_id,omitempty"` Format string `json:"format"` - SizeBytes int64 `json:"size_bytes"` // artifact bytes on disk - UnpackedBytes int64 `json:"unpacked_bytes"` - Entries int `json:"entries"` + SizeBytes int64 `json:"size_bytes"` // artifact bytes on disk + UnpackedBytes int64 `json:"unpacked_bytes"` // decompressed tar stream bytes CreatedAt time.Time `json:"created_at"` } @@ -65,8 +58,8 @@ func (a *layerArtifact) validate() error { if a.Format != string(FormatErofs) && a.Format != string(FormatExt4) { return fmt.Errorf("invalid format: %s", a.Format) } - if a.SizeBytes < 0 || a.UnpackedBytes < 0 || a.Entries < 0 { - return fmt.Errorf("invalid size or entry counts") + if a.SizeBytes < 0 || a.UnpackedBytes < 0 { + return fmt.Errorf("invalid size") } return nil } @@ -95,6 +88,25 @@ func layerArtifactRecordPath(p *paths.Paths, layerHex string) string { return p.ImageLayerRecordForFormat(layerHex, layerArtifactFormat()) } +// layerMapOptions preserves tar ownership when running as root. Otherwise +// umoci's rootless mode skips chown and stands in empty files for device nodes. +func layerMapOptions() layer.MapOptions { + return layer.MapOptions{Rootless: os.Geteuid() != 0} +} + +// layerArtifactOnDiskFormat is the extraction format for per-layer artifacts. +// Whiteouts become overlayfs whiteout inodes and opaque xattrs, the form an +// overlayfs mount of stacked layers understands, so the artifact retains the +// layer's deletions without a private marker format. +func layerArtifactOnDiskFormat() layer.OnDiskFormat { + return layer.OverlayfsRootfs{MapOptions: layerMapOptions()} +} + +// composeOnDiskFormat applies whiteouts against the tree being composed. +func composeOnDiskFormat() layer.OnDiskFormat { + return layer.DirRootfs{MapOptions: layerMapOptions()} +} + // readLayerRecord loads the artifact record for a layer digest, if present. // A missing record returns (nil, nil): the layer simply was never // materialized. @@ -184,7 +196,7 @@ func (m *manager) materializeLayerArtifactOnce(ctx context.Context, desc layerDe } defer removePath(unpackDir) - stats, err := unpackCachedLayer(ctx, m.paths.SystemOCICache(), desc, unpackDir) + stats, err := unpackCachedLayer(ctx, m.paths.SystemOCICache(), desc, unpackDir, layerArtifactOnDiskFormat()) if err != nil { return nil, err } @@ -198,13 +210,10 @@ func (m *manager) installLayerArtifact(ctx context.Context, desc layerDescriptor DiffID: stats.diffID, Format: layerArtifactFormat(), UnpackedBytes: stats.unpackedBytes, - Entries: stats.entries, CreatedAt: time.Now(), } if err := installAtomically(layerArtifactPath(m.paths, layerHex), func(path string) error { - // The artifact intentionally retains .wh. marker files so - // composition can re-derive whiteouts from the tree itself. var size int64 var convErr error if DefaultImageFormat == FormatErofs { @@ -233,15 +242,13 @@ func (m *manager) installLayerArtifact(ctx context.Context, desc layerDescriptor } type unpackStats struct { - entries int unpackedBytes int64 diffID string - explicitDirs map[string]struct{} } // unpackCachedLayer locates desc's blob in the shared OCI cache, unpacks it // into dest, and verifies the diff ID when the descriptor carries one. -func unpackCachedLayer(ctx context.Context, cacheDir string, desc layerDescriptor, dest string) (*unpackStats, error) { +func unpackCachedLayer(ctx context.Context, cacheDir string, desc layerDescriptor, dest string, onDisk layer.OnDiskFormat) (*unpackStats, error) { layerHex := strings.TrimPrefix(desc.Digest, "sha256:") if err := paths.ValidatePathComponent(layerHex); err != nil { return nil, fmt.Errorf("invalid layer digest: %s", desc.Digest) @@ -253,7 +260,7 @@ func unpackCachedLayer(ctx context.Context, cacheDir string, desc layerDescripto } return nil, fmt.Errorf("stat layer blob: %w", err) } - stats, err := unpackLayerBlob(ctx, blobPath, desc.MediaType, dest) + stats, err := unpackLayerBlob(ctx, blobPath, desc.MediaType, dest, onDisk) if err != nil { return nil, fmt.Errorf("unpack layer %s: %w", desc.Digest, err) } @@ -263,11 +270,10 @@ func unpackCachedLayer(ctx context.Context, cacheDir string, desc layerDescripto return stats, nil } -// unpackLayerBlob extracts one compressed layer blob into dest while preserving -// whiteout marker files. It intentionally does not use umoci's layer unpacker: -// umoci consumes whiteouts while this store must retain them for composition. -// Paths are confined to dest. -func unpackLayerBlob(ctx context.Context, blobPath, mediaType, dest string) (*unpackStats, error) { +// unpackLayerBlob extracts one compressed layer blob into dest with umoci, +// which confines every entry to dest and interprets whiteouts per onDisk. +// The decompressed stream is hashed for the diff ID and capped in size. +func unpackLayerBlob(ctx context.Context, blobPath, mediaType, dest string, onDisk layer.OnDiskFormat) (*unpackStats, error) { if err := os.MkdirAll(dest, 0755); err != nil { return nil, fmt.Errorf("create extraction root: %w", err) } @@ -282,133 +288,23 @@ func unpackLayerBlob(ctx context.Context, blobPath, mediaType, dest string) (*un return nil, err } defer closer.Close() - reader = contextReader{ctx: ctx, reader: reader} hash := sha256.New() - stats := &unpackStats{explicitDirs: make(map[string]struct{})} - // Directory metadata is re-applied after extraction, once children - // exist, so tar directory mtimes are not overwritten by later writes. - pendingDirs := make([]pendingDir, 0) - pendingHardlinks := make([]pendingHardlink, 0) - limitedReader := &io.LimitedReader{R: reader, N: maxLayerUnpackedBytes + 1} - hashedReader := io.TeeReader(limitedReader, hash) - tr := tar.NewReader(hashedReader) - 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 - } - if stats.entries >= maxLayerEntries { - return nil, fmt.Errorf("layer exceeds maximum entry count of %d", maxLayerEntries) - } - if header.Size > maxLayerUnpackedBytes-stats.unpackedBytes { - return nil, fmt.Errorf("layer exceeds maximum unpacked size of %d bytes", maxLayerUnpackedBytes) - } - stats.entries++ - - base := filepath.Base(header.Name) - if strings.HasPrefix(base, whiteoutPrefix) && base != opaqueWhiteout { - targetName := strings.TrimPrefix(base, whiteoutPrefix) - if targetName == "" || targetName == "." || targetName == ".." { - return nil, fmt.Errorf("invalid whiteout entry: %s", header.Name) - } - } - - if header.Typeflag == tar.TypeDir { - stats.explicitDirs[filepath.Clean(header.Name)] = struct{}{} - pendingDirs = append(pendingDirs, pendingDir{target: target, header: header}) - } - if header.Typeflag == tar.TypeLink { - // Hardlinks may reference entries that appear later in the tar, so - // resolve them after extracting all non-link entries. - pendingHardlinks = append(pendingHardlinks, pendingHardlink{target: target, linkname: header.Linkname}) - } else if err := extractTarEntry(tr, header, dest, target); err != nil { - return nil, fmt.Errorf("extract %s: %w", header.Name, err) - } - if header.Typeflag == tar.TypeReg || header.Typeflag == tar.TypeGNUSparse { - stats.unpackedBytes += header.Size - } + limited := &io.LimitedReader{R: contextReader{ctx: ctx, reader: reader}, N: maxLayerUnpackedBytes + 1} + hashed := io.TeeReader(limited, hash) + if err := layer.UnpackLayer(dest, hashed, &layer.UnpackOptions{OnDiskFormat: onDisk}); err != nil { + return nil, err } - if _, err := io.Copy(io.Discard, hashedReader); err != nil { + if _, err := io.Copy(io.Discard, hashed); err != nil { return nil, fmt.Errorf("drain layer: %w", err) } - if limitedReader.N == 0 { + if limited.N == 0 { return nil, fmt.Errorf("layer exceeds maximum unpacked size of %d bytes", maxLayerUnpackedBytes) } - if err := resolveHardlinks(dest, pendingHardlinks); err != nil { - return nil, err - } - for i := len(pendingDirs) - 1; i >= 0; i-- { - dir := pendingDirs[i] - if err := applyTarMetadata(dir.target, dir.header); err != nil { - return nil, fmt.Errorf("restore dir metadata %s: %w", dir.target, err) - } - } - stats.diffID = fmt.Sprintf("sha256:%x", hash.Sum(nil)) - return stats, nil -} - -type pendingDir struct { - target string - header *tar.Header -} - -type pendingHardlink struct { - target string - linkname string - linkTarget string -} - -func resolveHardlinks(root string, pending []pendingHardlink) error { - waiting := make(map[string][]pendingHardlink) - ready := make([]pendingHardlink, 0, len(pending)) - for _, link := range pending { - linkname := filepath.Clean(link.linkname) - if filepath.IsAbs(linkname) { - linkname = strings.TrimPrefix(linkname, string(filepath.Separator)) - } - linkTarget, err := safeJoin(root, linkname) - if err != nil { - return err - } - link.linkTarget = linkTarget - if _, err := os.Lstat(linkTarget); err == nil { - ready = append(ready, link) - } else if os.IsNotExist(err) { - waiting[linkTarget] = append(waiting[linkTarget], link) - } else { - return err - } - } - - resolved := 0 - for len(ready) > 0 { - link := ready[0] - ready = ready[1:] - if err := prepareTarTarget(link.target); err != nil { - return err - } - if err := os.Link(link.linkTarget, link.target); err != nil { - return fmt.Errorf("create hardlink %s -> %s: %w", link.target, link.linkname, err) - } - resolved++ - ready = append(ready, waiting[link.target]...) - delete(waiting, link.target) - } - if resolved != len(pending) { - for _, links := range waiting { - return fmt.Errorf("hardlink target not found for %s", links[0].linkname) - } - } - return nil + return &unpackStats{ + unpackedBytes: maxLayerUnpackedBytes + 1 - limited.N, + diffID: fmt.Sprintf("sha256:%x", hash.Sum(nil)), + }, nil } // decompressLayer wraps the blob in the reader for its layer media type. Both @@ -445,260 +341,40 @@ func (c multiCloser) Close() error { return firstErr } -// confineToRoot lexically resolves an entry name inside root, rejecting -// absolute names and parent traversal. root must be an existing directory -// that is not itself a symlink. -func confineToRoot(root, name string) (string, error) { - root = filepath.Clean(root) - rootInfo, err := os.Lstat(root) - if err != nil { - return "", fmt.Errorf("inspect extraction root: %w", err) - } - if !rootInfo.IsDir() || rootInfo.Mode()&os.ModeSymlink != 0 { - return "", fmt.Errorf("extraction root is not a directory: %s", root) - } - if filepath.IsAbs(name) { - return "", fmt.Errorf("tar entry escapes root: %s", name) - } - target := filepath.Join(root, name) - if !pathWithinRoot(root, target) { - return "", fmt.Errorf("tar entry escapes root: %s", name) - } - return target, nil -} - -// safeJoin resolves a tar entry inside root and rejects symlinked parents -// while extracting a layer. This prevents one tar entry from changing where -// a later entry is written. -func safeJoin(root, name string) (string, error) { - target, err := confineToRoot(root, name) - if err != nil { - return "", err - } - root = filepath.Clean(root) - if target == root { - return root, nil +// removePath removes a tree that may contain read-only directories restored +// from layer metadata. +func removePath(path string) error { + if err := makeTreeWritable(path); err != nil && !os.IsNotExist(err) { + return err } - 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) - } + if err := os.RemoveAll(path); err != nil && !os.IsNotExist(err) { + return err } - return target, nil + return nil } -// safeJoinForComposition resolves an entry through existing parent symlinks, -// interpreting absolute link targets relative to the image root. -func safeJoinForComposition(root, name string) (string, error) { - target, err := confineToRoot(root, name) - if err != nil { - return "", err - } - root = filepath.Clean(root) - if target == root { - return root, nil - } - resolvedParent, err := resolveLayerPath(root, filepath.Dir(target), 0) +func makeTreeWritable(path string) error { + info, err := os.Lstat(path) if err != nil { - return "", fmt.Errorf("inspect tar entry parent: %w", err) - } - return filepath.Join(resolvedParent, filepath.Base(target)), nil -} - -func resolveLayerPath(root, path string, depth int) (string, error) { - if depth > 40 { - return "", fmt.Errorf("too many symlinks") + return err } - root = filepath.Clean(root) - path = filepath.Clean(path) - rel, err := filepath.Rel(root, path) - if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { - return "", fmt.Errorf("path escapes root") + if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return nil } - if rel == "." { - return root, nil + if err := os.Chmod(path, info.Mode().Perm()|0700); err != nil { + return err } - parts := strings.Split(rel, string(filepath.Separator)) - current := root - for i, part := range parts { - candidate := filepath.Join(current, part) - info, err := os.Lstat(candidate) + return filepath.WalkDir(path, func(p string, entry fs.DirEntry, err error) error { if err != nil { - if os.IsNotExist(err) { - return filepath.Join(candidate, filepath.Join(parts[i+1:]...)), nil - } - return "", err + return err } - if info.Mode()&os.ModeSymlink != 0 { - link, err := os.Readlink(candidate) + if entry.IsDir() && entry.Type()&os.ModeSymlink == 0 { + info, err := entry.Info() if err != nil { - return "", err - } - var linkTarget string - if filepath.IsAbs(link) { - linkTarget = filepath.Join(root, strings.TrimPrefix(link, string(filepath.Separator))) - } else { - linkTarget = filepath.Join(filepath.Dir(candidate), link) + return err } - linkTarget = filepath.Clean(linkTarget) - if !pathWithinRoot(root, linkTarget) { - return "", fmt.Errorf("symlink target escapes root") - } - return resolveLayerPath(root, filepath.Join(linkTarget, filepath.Join(parts[i+1:]...)), depth+1) - } - if i < len(parts)-1 && !info.IsDir() { - return "", fmt.Errorf("path parent is not a directory: %s", candidate) - } - current = candidate - } - return current, nil -} - -func pathWithinRoot(root, path string) bool { - rel, err := filepath.Rel(filepath.Clean(root), filepath.Clean(path)) - return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) -} - -func resolveCompositionDirTarget(root, target string) (string, error) { - resolved, err := resolveLayerPath(root, target, 0) - if err == nil { - return resolved, nil - } - info, statErr := os.Lstat(target) - if statErr == nil && info.Mode()&os.ModeSymlink != 0 { - if removeErr := removePath(target); removeErr != nil { - return "", removeErr + return os.Chmod(p, info.Mode().Perm()|0700) } - return target, nil - } - return "", err -} - -// validateSymlinkTarget permits absolute targets because OCI images may use -// them and the image filesystem must preserve their link text. Extraction -// still rejects symlink traversal for later entries, while composition copies -// symlink text without following it. -func validateSymlinkTarget(root, target, linkname string) error { - if filepath.IsAbs(linkname) { - return nil - } - if !pathWithinRoot(root, filepath.Join(filepath.Dir(target), linkname)) { - 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: - return extractTarDir(target) - case tar.TypeReg, tar.TypeGNUSparse: - return extractTarFile(tr, target, header) - case tar.TypeSymlink: - return extractTarSymlink(root, target, header) - case tar.TypeChar, tar.TypeBlock: - return extractTarDevice(target, header) - case tar.TypeFifo: - 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 removePath(target) -} - -func extractTarDir(target string) error { - if info, err := os.Lstat(target); err == nil && !info.IsDir() { - if err := removePath(target); err != nil { - return err - } - } - return os.MkdirAll(target, 0755) -} - -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 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 := mknodWithRootlessFallback(target, mode|uint32(header.FileInfo().Mode().Perm()), dev); err != nil { - return 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 { - mtime := header.ModTime - if mtime.IsZero() { - mtime = time.Now() - } - return applyEntryMetadata(path, entryMetadata{ - uid: header.Uid, - gid: header.Gid, - hasOwner: true, - mode: header.FileInfo().Mode(), - symlink: header.Typeflag == tar.TypeSymlink, - mtime: mtime, - xattrs: tarXattrs(header), }) } diff --git a/lib/images/layer_artifact_test.go b/lib/images/layer_artifact_test.go index eeff21fa5..528c91abd 100644 --- a/lib/images/layer_artifact_test.go +++ b/lib/images/layer_artifact_test.go @@ -7,29 +7,23 @@ import ( "context" "crypto/sha256" "encoding/json" - "errors" "fmt" - "io" - "io/fs" "os" "os/exec" "path/filepath" - "strings" - "syscall" "testing" - "time" - - "golang.org/x/sys/unix" 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" + "golang.org/x/sys/unix" ) +const testTarGzMediaType = "application/vnd.oci.image.layer.v1.tar+gzip" + // 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) { @@ -58,6 +52,84 @@ func layerDescFromImage(t *testing.T, img gcr.Image, index int) layerDescriptor } } +// tarGz builds a gzipped tar from the entries written by fn. +func tarGz(t *testing.T, fn func(tw *tar.Writer)) []byte { + t.Helper() + var buf bytes.Buffer + gzw := gzip.NewWriter(&buf) + tw := tar.NewWriter(gzw) + fn(tw) + require.NoError(t, tw.Close()) + require.NoError(t, gzw.Close()) + return buf.Bytes() +} + +func writeTarEntry(t *testing.T, tw *tar.Writer, header *tar.Header, content string) { + t.Helper() + if header.Typeflag == tar.TypeReg { + header.Size = int64(len(content)) + } + require.NoError(t, tw.WriteHeader(header)) + if content != "" { + _, err := tw.Write([]byte(content)) + require.NoError(t, err) + } +} + +func fileEntry(name, content string) func(*testing.T, *tar.Writer) { + return func(t *testing.T, tw *tar.Writer) { + writeTarEntry(t, tw, &tar.Header{Name: name, Typeflag: tar.TypeReg, Mode: 0644}, content) + } +} + +func dirEntry(name string) func(*testing.T, *tar.Writer) { + return func(t *testing.T, tw *tar.Writer) { + writeTarEntry(t, tw, &tar.Header{Name: name, Typeflag: tar.TypeDir, Mode: 0755}, "") + } +} + +func symlinkEntry(name, target string) func(*testing.T, *tar.Writer) { + return func(t *testing.T, tw *tar.Writer) { + writeTarEntry(t, tw, &tar.Header{Name: name, Typeflag: tar.TypeSymlink, Linkname: target}, "") + } +} + +func hardlinkEntry(name, target string) func(*testing.T, *tar.Writer) { + return func(t *testing.T, tw *tar.Writer) { + writeTarEntry(t, tw, &tar.Header{Name: name, Typeflag: tar.TypeLink, Linkname: target}, "") + } +} + +// writeLayerBlob writes a gzipped tar layer to a file and returns its path. +func writeLayerBlob(t *testing.T, dir, name string, entries ...func(*testing.T, *tar.Writer)) string { + t.Helper() + data := tarGz(t, func(tw *tar.Writer) { + for _, entry := range entries { + entry(t, tw) + } + }) + path := filepath.Join(dir, name) + require.NoError(t, os.WriteFile(path, data, 0644)) + return path +} + +// unpackInto applies a layer blob onto dest with compose semantics. +func unpackInto(t *testing.T, blobPath, dest string) *unpackStats { + t.Helper() + stats, err := unpackLayerBlob(context.Background(), blobPath, testTarGzMediaType, dest, composeOnDiskFormat()) + require.NoError(t, err) + return stats +} + +func requireNoWhiteoutMarkers(t *testing.T, root string) { + t.Helper() + require.NoError(t, filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error { + require.NoError(t, err) + require.NotContains(t, entry.Name(), whiteoutPrefix, "whiteout marker leaked into tree") + return nil + })) +} + func TestMaterializeLayerArtifact(t *testing.T) { if _, err := exec.LookPath("mkfs.erofs"); err != nil { t.Skip("mkfs.erofs not available") @@ -78,15 +150,12 @@ func TestMaterializeLayerArtifact(t *testing.T) { require.Equal(t, layerArtifactFormat(), 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.ImageLayerArtifactForFormat(layerHex, layerArtifactFormat())) + artifactInfo, err := os.Stat(p.ImageLayerArtifactForFormat(layerHex, layerArtifactFormat())) require.NoError(t, err, "layer.erofs must be installed") // A second materialization reuses the existing artifact. - artifactInfo, err := os.Stat(p.ImageLayerArtifactForFormat(layerHex, layerArtifactFormat())) - require.NoError(t, err) reused, err := m.materializeLayerArtifact(context.Background(), desc) require.NoError(t, err) require.True(t, record.CreatedAt.Equal(reused.CreatedAt), "reuse must return the stored record") @@ -138,263 +207,155 @@ func TestMaterializeLayerArtifactMissingBlob(t *testing.T) { _, err := m.materializeLayerArtifact(context.Background(), layerDescriptor{ Digest: "sha256:abababababababababababababababababababababababababababababababab", - MediaType: "application/vnd.oci.image.layer.v1.tar+gzip", + MediaType: testTarGzMediaType, }) 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 TestMaterializeLayerDoesNotPersistWhiteoutInventory(t *testing.T) { - if _, err := exec.LookPath("mkfs.erofs"); err != nil { - t.Skip("mkfs.erofs not available") +// TestUnpackLayerBlobArtifactFormatKeepsWhiteouts checks that the artifact +// extraction format records deletions in overlayfs form: a 0:0 character +// device for a whiteout and the opaque xattr for an opaque directory. +func TestUnpackLayerBlobArtifactFormatKeepsWhiteouts(t *testing.T) { + if os.Geteuid() != 0 { + t.Skip("overlayfs whiteouts need mknod and trusted xattrs") } - - 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} - - _, err = m.materializeLayerArtifact(context.Background(), desc) - require.NoError(t, err) - - data, err := os.ReadFile(p.ImageLayerRecordForFormat(desc.Digest[len("sha256:"):], layerArtifactFormat())) - require.NoError(t, err) - require.NotContains(t, string(data), "whiteouts") -} - -func TestApplyLayerTreeCopiesRestrictiveFiles(t *testing.T) { root := t.TempDir() - targetDir := filepath.Join(root, "target") - layerDir := filepath.Join(root, "layer") - file := filepath.Join(layerDir, "secret.txt") - - require.NoError(t, os.MkdirAll(layerDir, 0755)) - require.NoError(t, os.WriteFile(file, []byte("secret"), 0644)) - require.NoError(t, os.Chmod(file, 0000)) - - require.NoError(t, applyLayerTree(layerDir, targetDir, nil)) - - target := filepath.Join(targetDir, "secret.txt") - info, err := os.Stat(target) - require.NoError(t, err) - require.Equal(t, os.FileMode(0000), info.Mode().Perm()) - require.NoError(t, os.Chmod(target, 0644)) - data, err := os.ReadFile(target) + blob := writeLayerBlob(t, root, "layer.tar.gz", + fileEntry("keep.txt", "keep"), + dirEntry("gone/"), + fileEntry("gone/.wh.deleted.txt", ""), + dirEntry("opq/"), + fileEntry("opq/.wh..wh..opq", ""), + fileEntry("opq/fresh.txt", "fresh"), + ) + dest := filepath.Join(root, "dest") + _, err := unpackLayerBlob(context.Background(), blob, testTarGzMediaType, dest, layerArtifactOnDiskFormat()) require.NoError(t, err) - require.Equal(t, "secret", string(data)) -} - -func TestRestoreDirectoryModesSkipsReplacedSymlink(t *testing.T) { - root := t.TempDir() - dir := filepath.Join(root, "dir") - target := filepath.Join(root, "target") - require.NoError(t, os.Mkdir(dir, 0750)) - require.NoError(t, os.WriteFile(target, []byte("target"), 0644)) - require.NoError(t, os.Remove(dir)) - require.NoError(t, os.Symlink("target", dir)) - require.NoError(t, restoreDirectoryModes(map[string]fs.FileMode{dir: 0750})) + var stat unix.Stat_t + require.NoError(t, unix.Lstat(filepath.Join(dest, "gone", "deleted.txt"), &stat)) + require.Equal(t, uint32(unix.S_IFCHR), stat.Mode&unix.S_IFMT, "whiteout must be a character device") + require.Equal(t, uint64(0), uint64(stat.Rdev), "whiteout device must be 0:0") - info, err := os.Stat(target) + value := make([]byte, 8) + n, err := unix.Lgetxattr(filepath.Join(dest, "opq"), "trusted.overlay.opaque", value) require.NoError(t, err) - require.Equal(t, os.FileMode(0644), info.Mode().Perm()) + require.Equal(t, "y", string(value[:n])) + requireNoWhiteoutMarkers(t, dest) } -func TestApplyLayerTreeWhiteoutSemantics(t *testing.T) { +func TestUnpackLayerBlobAppliesWhiteoutsAcrossLayers(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.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)) - - // 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, ".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)) - 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, nil)) - - // 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") + base := writeLayerBlob(t, root, "base.tar.gz", + dirEntry("etc/"), + fileEntry("etc/config.txt", "original"), + dirEntry("data/"), + fileEntry("data/old.txt", "stale"), + dirEntry("replacedir/"), + fileEntry("replacedir/inner.txt", "inner"), + fileEntry("added/foo", "old"), + ) + top := writeLayerBlob(t, root, "top.tar.gz", + fileEntry("etc/.wh.config.txt", ""), + fileEntry("data/.wh..wh..opq", ""), + fileEntry("data/new.txt", "new"), + fileEntry("replacedir", "now a file"), + fileEntry("added/.wh.foo", ""), + fileEntry("added/foo", "new"), + ) + dest := filepath.Join(root, "dest") + unpackInto(t, base, dest) + unpackInto(t, top, dest) - // Regular file replacement. - data, err := os.ReadFile(filepath.Join(targetDir, "keep.txt")) + _, err := os.Lstat(filepath.Join(dest, "etc", "config.txt")) + require.True(t, os.IsNotExist(err), "whiteout must delete the lower entry") + _, err = os.Lstat(filepath.Join(dest, "data", "old.txt")) + require.True(t, os.IsNotExist(err), "opaque marker must mask lower contents") + data, err := os.ReadFile(filepath.Join(dest, "data", "new.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")) + info, err := os.Lstat(filepath.Join(dest, "replacedir")) 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.False(t, info.IsDir(), "file must replace lower directory") + data, err = os.ReadFile(filepath.Join(dest, "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) + require.Equal(t, "new", string(data), "whiteout then recreate in one layer keeps the new file") + requireNoWhiteoutMarkers(t, dest) } -func TestApplyLayerTreeReplacesSymlinkBeforeNestedWhiteout(t *testing.T) { +// TestUnpackLayerBlobReplacesDirectorySymlink checks the standard runtime +// behavior: a directory in an upper layer replaces a symlink from a lower one +// rather than writing through it. +func TestUnpackLayerBlobReplacesDirectorySymlink(t *testing.T) { root := t.TempDir() - targetDir := filepath.Join(root, "target") - layerDir := filepath.Join(root, "layer") - lowerDir := filepath.Join(root, "lower") - - require.NoError(t, os.MkdirAll(targetDir, 0755)) - require.NoError(t, os.MkdirAll(lowerDir, 0755)) - require.NoError(t, os.WriteFile(filepath.Join(lowerDir, "old.txt"), []byte("old"), 0644)) - require.NoError(t, os.Symlink("../lower", filepath.Join(targetDir, "replaced"))) - require.NoError(t, os.MkdirAll(filepath.Join(layerDir, "replaced"), 0755)) - require.NoError(t, os.WriteFile(filepath.Join(layerDir, "replaced", ".wh.old.txt"), nil, 0644)) - require.NoError(t, os.WriteFile(filepath.Join(layerDir, "replaced", "new.txt"), []byte("new"), 0644)) - - require.NoError(t, applyLayerTree(layerDir, targetDir, nil)) + base := writeLayerBlob(t, root, "base.tar.gz", + dirEntry("usr/"), + dirEntry("usr/bin/"), + fileEntry("usr/bin/sh", "sh"), + symlinkEntry("bin", "usr/bin"), + ) + top := writeLayerBlob(t, root, "top.tar.gz", + dirEntry("bin/"), + fileEntry("bin/tool", "tool"), + ) + dest := filepath.Join(root, "dest") + unpackInto(t, base, dest) + unpackInto(t, top, dest) - info, err := os.Lstat(filepath.Join(targetDir, "replaced")) + info, err := os.Lstat(filepath.Join(dest, "bin")) require.NoError(t, err) - require.True(t, info.IsDir()) - require.FileExists(t, filepath.Join(targetDir, "replaced", "new.txt")) - _, err = os.Lstat(filepath.Join(targetDir, "replaced", "old.txt")) - require.ErrorIs(t, err, os.ErrNotExist) + require.True(t, info.IsDir(), "upper directory must replace the lower symlink") + require.FileExists(t, filepath.Join(dest, "bin", "tool")) + require.FileExists(t, filepath.Join(dest, "usr", "bin", "sh")) + require.NoFileExists(t, filepath.Join(dest, "usr", "bin", "tool")) } -func TestApplyLayerTreePreservesUpperDirectoryMode(t *testing.T) { +func TestUnpackLayerBlobConfinesSymlinkTraversal(t *testing.T) { root := t.TempDir() - targetDir := filepath.Join(root, "target") - layerDir := filepath.Join(root, "layer") - - require.NoError(t, os.MkdirAll(filepath.Join(targetDir, "etc"), 0755)) - require.NoError(t, os.MkdirAll(filepath.Join(layerDir, "etc"), 0750)) - require.NoError(t, os.WriteFile(filepath.Join(layerDir, "etc", "new.txt"), []byte("new"), 0644)) - - require.NoError(t, applyLayerTree(layerDir, targetDir, nil)) + blob := writeLayerBlob(t, root, "layer.tar.gz", + symlinkEntry("link", "../../escape"), + fileEntry("link/pwned", "x"), + ) + dest := filepath.Join(root, "dest") + unpackInto(t, blob, dest) - info, err := os.Stat(filepath.Join(targetDir, "etc")) - require.NoError(t, err) - require.Equal(t, os.FileMode(0750), info.Mode().Perm()) + require.NoFileExists(t, filepath.Join(root, "escape", "pwned")) + require.FileExists(t, filepath.Join(dest, "escape", "pwned"), "escaping link must be resolved inside the root") } -func TestApplyLayerTreeWhiteoutRemovesReadOnlyDirectory(t *testing.T) { +func TestUnpackLayerBlobPreservesHardlinks(t *testing.T) { root := t.TempDir() - targetDir := filepath.Join(root, "target") - layerDir := filepath.Join(root, "layer") - - require.NoError(t, os.MkdirAll(filepath.Join(targetDir, "gone"), 0755)) - require.NoError(t, os.WriteFile(filepath.Join(targetDir, "gone", "inner.txt"), []byte("old"), 0644)) - require.NoError(t, os.Chmod(filepath.Join(targetDir, "gone"), 0555)) - require.NoError(t, os.MkdirAll(layerDir, 0755)) - require.NoError(t, os.WriteFile(filepath.Join(layerDir, ".wh.gone"), nil, 0644)) - - require.NoError(t, applyLayerTree(layerDir, targetDir, nil)) - _, err := os.Lstat(filepath.Join(targetDir, "gone")) - require.ErrorIs(t, err, os.ErrNotExist) + blob := writeLayerBlob(t, root, "layer.tar.gz", + fileEntry("a", "shared"), + hardlinkEntry("b", "a"), + ) + dest := filepath.Join(root, "dest") + unpackInto(t, blob, dest) + + a, err := os.Stat(filepath.Join(dest, "a")) + require.NoError(t, err) + b, err := os.Stat(filepath.Join(dest, "b")) + require.NoError(t, err) + require.True(t, os.SameFile(a, b)) } func TestUnpackLayerBlobContextHonorsCancellation(t *testing.T) { root := t.TempDir() - blobPath := filepath.Join(root, "layer.tar") - var buf bytes.Buffer - tw := tar.NewWriter(&buf) - require.NoError(t, tw.WriteHeader(&tar.Header{Name: "file", Typeflag: tar.TypeReg, Mode: 0644, Size: 1})) - _, err := tw.Write([]byte("x")) - require.NoError(t, err) - require.NoError(t, tw.Close()) - require.NoError(t, os.WriteFile(blobPath, buf.Bytes(), 0644)) + blob := writeLayerBlob(t, root, "layer.tar.gz", fileEntry("file", "x")) ctx, cancel := context.WithCancel(context.Background()) cancel() - _, err = unpackLayerBlob(ctx, blobPath, "application/vnd.oci.image.layer.v1.tar", filepath.Join(root, "dest")) + _, err := unpackLayerBlob(ctx, blob, testTarGzMediaType, filepath.Join(root, "dest"), composeOnDiskFormat()) require.ErrorIs(t, err, context.Canceled) } func TestUnpackLayerBlobIncludesTrailingTarPaddingInDiffID(t *testing.T) { root := t.TempDir() - blobPath := filepath.Join(root, "layer.tar.gz") - var tarData bytes.Buffer tw := tar.NewWriter(&tarData) - require.NoError(t, tw.WriteHeader(&tar.Header{Name: "file", Typeflag: tar.TypeReg, Mode: 0644, Size: 1})) - _, err := tw.Write([]byte("x")) - require.NoError(t, err) + writeTarEntry(t, tw, &tar.Header{Name: "file", Typeflag: tar.TypeReg, Mode: 0644}, "x") require.NoError(t, tw.Close()) - _, err = tarData.Write(make([]byte, 512)) + _, err := tarData.Write(make([]byte, 512)) require.NoError(t, err) var compressed bytes.Buffer @@ -402,311 +363,11 @@ func TestUnpackLayerBlobIncludesTrailingTarPaddingInDiffID(t *testing.T) { _, err = gzw.Write(tarData.Bytes()) require.NoError(t, err) require.NoError(t, gzw.Close()) - require.NoError(t, os.WriteFile(blobPath, compressed.Bytes(), 0644)) + blob := filepath.Join(root, "layer.tar.gz") + require.NoError(t, os.WriteFile(blob, compressed.Bytes(), 0644)) - stats, err := unpackLayerBlob(context.Background(), blobPath, "application/vnd.oci.image.layer.v1.tar+gzip", filepath.Join(root, "dest")) - require.NoError(t, err) + stats := unpackInto(t, blob, filepath.Join(root, "dest")) want := sha256.Sum256(tarData.Bytes()) require.Equal(t, "sha256:"+fmt.Sprintf("%x", want), stats.diffID) -} - -func TestResolveHardlinksResolvesDependencyChain(t *testing.T) { - root := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(root, "source"), []byte("content"), 0644)) - - pending := []pendingHardlink{ - {target: filepath.Join(root, "third"), linkname: "second"}, - {target: filepath.Join(root, "second"), linkname: "first"}, - {target: filepath.Join(root, "first"), linkname: "source"}, - } - require.NoError(t, resolveHardlinks(root, pending)) - - source, err := os.Stat(filepath.Join(root, "source")) - require.NoError(t, err) - third, err := os.Stat(filepath.Join(root, "third")) - require.NoError(t, err) - require.True(t, os.SameFile(source, third)) -} - -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(context.Background(), blobPath, "application/vnd.oci.image.layer.v1.tar+gzip", filepath.Join(root, "dest")) - require.ErrorContains(t, err, "symlink") -} - -func TestValidateSymlinkTargetAllowsAbsoluteTargets(t *testing.T) { - root := t.TempDir() - target := filepath.Join(root, "link") - require.NoError(t, validateSymlinkTarget(root, target, "/etc/resolv.conf")) -} - -func TestApplyLayerTreePreservesImplicitDirectoryMetadata(t *testing.T) { - root := t.TempDir() - targetDir := filepath.Join(root, "target") - layerDir := filepath.Join(root, "layer") - require.NoError(t, os.MkdirAll(filepath.Join(targetDir, "etc"), 0700)) - require.NoError(t, os.MkdirAll(filepath.Join(layerDir, "etc"), 0755)) - require.NoError(t, os.WriteFile(filepath.Join(layerDir, "etc", "app.conf"), []byte("config"), 0644)) - - require.NoError(t, applyLayerTree(layerDir, targetDir, map[string]struct{}{})) - - info, err := os.Stat(filepath.Join(targetDir, "etc")) - require.NoError(t, err) - require.Equal(t, os.FileMode(0700), info.Mode().Perm()) -} - -func TestApplyLayerTreeResolvesInRootSymlinkParents(t *testing.T) { - root := t.TempDir() - targetDir := filepath.Join(root, "target") - layerDir := filepath.Join(root, "layer") - require.NoError(t, os.MkdirAll(filepath.Join(targetDir, "usr", "bin"), 0755)) - require.NoError(t, os.Symlink("usr/bin", filepath.Join(targetDir, "bin"))) - require.NoError(t, os.MkdirAll(filepath.Join(layerDir, "bin"), 0755)) - require.NoError(t, os.WriteFile(filepath.Join(layerDir, "bin", "tool"), []byte("tool"), 0644)) - - require.NoError(t, applyLayerTree(layerDir, targetDir, nil)) - - data, err := os.ReadFile(filepath.Join(targetDir, "usr", "bin", "tool")) - require.NoError(t, err) - require.Equal(t, "tool", string(data)) - info, err := os.Lstat(filepath.Join(targetDir, "bin")) - require.NoError(t, err) - require.Equal(t, os.ModeSymlink, info.Mode()&os.ModeSymlink) -} - -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, nil)) - - _, 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) -} - -func TestCopyXattrs(t *testing.T) { - root := t.TempDir() - src := filepath.Join(root, "src") - dst := filepath.Join(root, "dst") - require.NoError(t, os.WriteFile(src, []byte("payload"), 0644)) - require.NoError(t, os.WriteFile(dst, []byte("payload"), 0644)) - - err := unix.Lsetxattr(src, "user.one", []byte("1"), 0) - if errors.Is(err, unix.ENOTSUP) || errors.Is(err, unix.EPERM) { - t.Skip("filesystem does not support user xattrs") - } - require.NoError(t, err) - require.NoError(t, unix.Lsetxattr(src, "user.two", []byte("22"), 0)) - - xattrs, err := readXattrs(src) - require.NoError(t, err) - require.NoError(t, applyXattrs(dst, xattrs)) - - for name, want := range map[string]string{"user.one": "1", "user.two": "22"} { - size, err := unix.Lgetxattr(dst, name, nil) - require.NoError(t, err, "xattr %s must be copied", name) - value := make([]byte, size) - n, err := unix.Lgetxattr(dst, name, value) - require.NoError(t, err) - require.Equal(t, want, string(value[:n])) - } -} - -func TestUnpackLayerBlobPreservesDirMtime(t *testing.T) { - root := t.TempDir() - blobPath := filepath.Join(root, "layer.tar") - dirTime := time.Now().Add(-time.Hour).Truncate(time.Second) - - var buf bytes.Buffer - tw := tar.NewWriter(&buf) - require.NoError(t, tw.WriteHeader(&tar.Header{Name: "d/", Typeflag: tar.TypeDir, Mode: 0755, ModTime: dirTime})) - require.NoError(t, tw.WriteHeader(&tar.Header{Name: "d/file.txt", Typeflag: tar.TypeReg, Mode: 0644, Size: 1})) - _, err := tw.Write([]byte("x")) - require.NoError(t, err) - require.NoError(t, tw.Close()) - require.NoError(t, os.WriteFile(blobPath, buf.Bytes(), 0644)) - - dest := filepath.Join(root, "dest") - _, err = unpackLayerBlob(context.Background(), blobPath, "application/vnd.oci.image.layer.v1.tar", dest) - require.NoError(t, err) - - info, err := os.Stat(filepath.Join(dest, "d")) - require.NoError(t, err) - require.True(t, info.ModTime().Equal(dirTime), "dir mtime must come from the tar header") -} - -func TestSpecialFileModeSupportsSockets(t *testing.T) { - mode, err := specialFileMode(fs.ModeSocket) - require.NoError(t, err) - require.Equal(t, uint32(syscall.S_IFSOCK), mode) -} - -func TestApplyLayerTreePreservesDirMtime(t *testing.T) { - root := t.TempDir() - targetDir := filepath.Join(root, "target") - layerDir := filepath.Join(root, "layer") - - old := time.Now().Add(-time.Hour).Truncate(time.Second) - require.NoError(t, os.MkdirAll(filepath.Join(layerDir, "d"), 0700)) - require.NoError(t, os.WriteFile(filepath.Join(layerDir, "d", "file.txt"), []byte("x"), 0644)) - require.NoError(t, os.Chtimes(filepath.Join(layerDir, "d"), old, old)) - - require.NoError(t, applyLayerTree(layerDir, targetDir, nil)) - - info, err := os.Stat(filepath.Join(targetDir, "d")) - require.NoError(t, err) - require.True(t, info.ModTime().Equal(old), "dir mtime must survive the merge") -} - -func TestSpecialFileModeSupportsCharDevice(t *testing.T) { - mode, err := specialFileMode(fs.ModeCharDevice | fs.ModeDevice) - require.NoError(t, err) - require.Equal(t, uint32(syscall.S_IFCHR), mode) -} - -func TestApplyTarMetadataPreservesSpecialBits(t *testing.T) { - root := t.TempDir() - target := filepath.Join(root, "suid-bin") - require.NoError(t, os.WriteFile(target, []byte("bin"), 0755)) - - header := &tar.Header{ - Name: "suid-bin", - Typeflag: tar.TypeReg, - Mode: 04755, - } - require.NoError(t, applyTarMetadata(target, header)) - - info, err := os.Stat(target) - require.NoError(t, err) - require.Equal(t, os.ModeSetuid, info.Mode()&os.ModeSetuid, "setuid bit must be preserved") -} - -func TestApplyLayerTreeRejectsMalformedWhiteouts(t *testing.T) { - root := t.TempDir() - targetDir := filepath.Join(root, "target") - layerDir := filepath.Join(root, "layer") - - require.NoError(t, os.MkdirAll(filepath.Join(targetDir, "parent", "child"), 0755)) - require.NoError(t, os.WriteFile(filepath.Join(targetDir, "parent", "victim.txt"), []byte("v"), 0644)) - require.NoError(t, os.MkdirAll(filepath.Join(layerDir, "parent"), 0755)) - // Malformed whiteouts must be rejected without deleting lower-layer entries. - require.NoError(t, os.WriteFile(filepath.Join(layerDir, "parent", ".wh."), nil, 0644)) - require.NoError(t, os.WriteFile(filepath.Join(layerDir, "parent", ".wh.."), nil, 0644)) - require.NoError(t, os.WriteFile(filepath.Join(layerDir, "parent", ".wh..."), nil, 0644)) - - err := applyLayerTree(layerDir, targetDir, nil) - require.ErrorContains(t, err, "invalid whiteout entry") - - // Parent directory must not be deleted by malformed whiteout paths. - info, err := os.Stat(filepath.Join(targetDir, "parent")) - require.NoError(t, err) - require.True(t, info.IsDir()) - require.FileExists(t, filepath.Join(targetDir, "parent", "victim.txt")) -} - -func TestApplyLayerTreeRejectsEscapingSymlink(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)) - require.NoError(t, os.Symlink("../../outside", filepath.Join(layerDir, "link"))) - - err := applyLayerTree(layerDir, targetDir, nil) - require.ErrorContains(t, err, "symlink target escapes root") -} - -func TestApplyLayerTreeRestoresRestrictiveNestedDirModes(t *testing.T) { - root := t.TempDir() - t.Cleanup(func() { _ = makeTreeWritable(root) }) - targetDir := filepath.Join(root, "target") - layerDir := filepath.Join(root, "layer") - - require.NoError(t, os.MkdirAll(filepath.Join(layerDir, "outer", "inner"), 0755)) - require.NoError(t, os.WriteFile(filepath.Join(layerDir, "outer", "inner", "file.txt"), []byte("data"), 0644)) - require.NoError(t, os.Chmod(filepath.Join(layerDir, "outer", "inner"), 0500)) - require.NoError(t, os.Chmod(filepath.Join(layerDir, "outer"), 0500)) - - require.NoError(t, applyLayerTree(layerDir, targetDir, nil)) - - infoOuter, err := os.Stat(filepath.Join(targetDir, "outer")) - require.NoError(t, err) - require.Equal(t, os.FileMode(0500), infoOuter.Mode().Perm()) - - infoInner, err := os.Stat(filepath.Join(targetDir, "outer", "inner")) - require.NoError(t, err) - require.Equal(t, os.FileMode(0500), infoInner.Mode().Perm()) -} - -func TestMakePathWritableTopDown(t *testing.T) { - root := t.TempDir() - t.Cleanup(func() { _ = makeTreeWritable(root) }) - parent := filepath.Join(root, "restricted") - child := filepath.Join(parent, "child") - require.NoError(t, os.MkdirAll(child, 0755)) - require.NoError(t, os.Chmod(parent, 0400)) // read-only, no execute - - originalModes := make(map[string]fs.FileMode) - err := makePathWritable(root, child, originalModes) - require.NoError(t, err) - - info, err := os.Stat(parent) - require.NoError(t, err) - require.True(t, info.Mode().Perm()&0700 == 0700) - - require.NoError(t, restoreDirectoryModes(originalModes)) - infoAfter, err := os.Stat(parent) - require.NoError(t, err) - require.Equal(t, os.FileMode(0400), infoAfter.Mode().Perm()) -} - -func TestSafeJoinCleanRootSymlink(t *testing.T) { - root := t.TempDir() - realDir := filepath.Join(root, "real") - symDir := filepath.Join(root, "sym") - require.NoError(t, os.Mkdir(realDir, 0755)) - require.NoError(t, os.Symlink("real", symDir)) - - // Calling safeJoin with trailing slash on symlink root must be rejected as symlink. - _, err := safeJoin(symDir+"/", "file.txt") - require.Error(t, err) - require.Contains(t, err.Error(), "extraction root is not a directory") + require.Equal(t, int64(tarData.Len()), stats.unpackedBytes) } diff --git a/lib/images/layer_tree.go b/lib/images/layer_tree.go deleted file mode 100644 index 634a015b3..000000000 --- a/lib/images/layer_tree.go +++ /dev/null @@ -1,563 +0,0 @@ -package images - -import ( - "archive/tar" - "bytes" - "errors" - "fmt" - "io" - "io/fs" - "os" - "path/filepath" - "sort" - "strings" - "syscall" - "time" - - "golang.org/x/sys/unix" -) - -func removePath(path string) error { - if err := makeTreeWritable(path); err != nil && !os.IsNotExist(err) { - return err - } - if err := os.RemoveAll(path); err != nil && !os.IsNotExist(err) { - return err - } - return nil -} - -func makeTreeWritable(path string) error { - info, err := os.Lstat(path) - if err != nil { - return err - } - if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { - return nil - } - if err := os.Chmod(path, info.Mode().Perm()|0700); err != nil { - return err - } - return filepath.WalkDir(path, func(p string, entry fs.DirEntry, err error) error { - if err != nil { - return err - } - if entry.IsDir() && entry.Type()&os.ModeSymlink == 0 { - info, err := entry.Info() - if err != nil { - return err - } - return os.Chmod(p, info.Mode().Perm()|0700) - } - return nil - }) -} - -// 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. No production caller yet; composition -// lands in a later change. -// -// explicitDirs, when non-nil, names the directories the layer tar listed -// explicitly (relative, cleaned); only those have their metadata copied. -// A nil map treats every directory as explicit. -func applyLayerTree(layerDir, targetDir string, explicitDirs map[string]struct{}) (err error) { - if err := os.MkdirAll(targetDir, 0755); err != nil { - return fmt.Errorf("create target directory: %w", err) - } - originalModes := make(map[string]fs.FileMode) - defer func() { - if restoreErr := restoreDirectoryModes(originalModes); restoreErr != nil { - err = errors.Join(err, fmt.Errorf("restore directory modes: %w", restoreErr)) - } - }() - - // Prepare directories before whiteouts so nested markers are applied after - // the layer has replaced any conflicting lower-layer entry. - if err := prepareLayerDirectories(layerDir, targetDir, originalModes); err != nil { - return fmt.Errorf("prepare layer directories: %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, err := safeJoinForComposition(targetDir, filepath.Dir(rel)) - if err != nil { - return err - } - if err := makePathWritable(targetDir, targetParent, originalModes); err != nil { - return err - } - if base == opaqueWhiteout { - return clearDirContents(targetParent) - } - hidden := strings.TrimPrefix(base, whiteoutPrefix) - if hidden == "" || hidden == "." || hidden == ".." { - return fmt.Errorf("invalid whiteout entry: %s", rel) - } - target, err := safeJoinForComposition(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) - } - - // Phase 2: copy the layer's own entries, skipping whiteout markers. - // Directory metadata is deferred until all children are copied, so tar - // directory mtimes survive the merge. - pendingDirs := make([]dirMeta, 0) - 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 - } - target, err := safeJoinForComposition(targetDir, rel) - if err != nil { - return err - } - if entry.IsDir() { - target, err = resolveCompositionDirTarget(targetDir, target) - if err != nil { - return err - } - } - if err := makePathWritable(targetDir, filepath.Dir(target), originalModes); err != nil { - return err - } - info, err := entry.Info() - if err != nil { - return err - } - if entry.IsDir() { - if _, explicit := explicitDirs[filepath.Clean(rel)]; explicitDirs == nil || explicit { - pendingDirs = append(pendingDirs, dirMeta{src: path, dst: target, info: info}) - } - } - if err := copyEntryInto(path, target, info, hardlinks, targetDir); err != nil { - return err - } - if entry.IsDir() { - return makePathWritable(targetDir, target, originalModes) - } - return nil - }) - if err != nil { - return fmt.Errorf("copy layer tree: %w", err) - } - for i := len(pendingDirs) - 1; i >= 0; i-- { - dir := pendingDirs[i] - if err := copyEntryMetadata(dir.src, dir.dst, dir.info); err != nil { - return fmt.Errorf("restore dir metadata %s: %w", dir.dst, err) - } - delete(originalModes, dir.dst) - } - return nil -} - -func prepareLayerDirectories(layerDir, targetDir string, originalModes map[string]fs.FileMode) error { - return filepath.WalkDir(layerDir, func(path string, entry fs.DirEntry, err error) error { - if err != nil { - return err - } - if path == layerDir || !entry.IsDir() { - return nil - } - if strings.HasPrefix(entry.Name(), whiteoutPrefix) { - return filepath.SkipDir - } - rel, err := filepath.Rel(layerDir, path) - if err != nil { - return err - } - target, err := safeJoinForComposition(targetDir, rel) - if err != nil { - return err - } - target, err = resolveCompositionDirTarget(targetDir, target) - if err != nil { - return err - } - if err := makePathWritable(targetDir, filepath.Dir(target), originalModes); err != nil { - return err - } - info, err := entry.Info() - if err != nil { - return err - } - if err := copyDirectoryEntry(target, info); err != nil { - return err - } - return makePathWritable(targetDir, target, originalModes) - }) -} - -func makePathWritable(root, path string, originalModes map[string]fs.FileMode) error { - root = filepath.Clean(root) - path = filepath.Clean(path) - if !pathWithinRoot(root, path) { - return fmt.Errorf("path is outside target root: %s", path) - } - rel, err := filepath.Rel(root, path) - if err != nil { - return err - } - if err := makeDirWritable(root, originalModes); err != nil { - return err - } - if rel == "." { - return nil - } - current := root - for _, part := range strings.Split(rel, string(filepath.Separator)) { - current = filepath.Join(current, part) - if err := makeDirWritable(current, originalModes); err != nil { - return err - } - } - return nil -} - -func makeDirWritable(dir string, originalModes map[string]fs.FileMode) error { - info, err := os.Lstat(dir) - if err != nil { - if os.IsNotExist(err) { - return nil - } - return err - } - if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { - return fmt.Errorf("target parent is not a directory: %s", dir) - } - if _, recorded := originalModes[dir]; !recorded { - originalModes[dir] = info.Mode() - if err := os.Chmod(dir, info.Mode().Perm()|0700); err != nil { - return err - } - } - return nil -} - -func restoreDirectoryModes(originalModes map[string]fs.FileMode) error { - paths := make([]string, 0, len(originalModes)) - for path := range originalModes { - paths = append(paths, path) - } - sort.Slice(paths, func(i, j int) bool { - return len(paths[i]) > len(paths[j]) - }) - var restoreErr error - for _, path := range paths { - mode := originalModes[path] - info, err := os.Lstat(path) - if os.IsNotExist(err) || (err == nil && (info.Mode()&os.ModeSymlink != 0 || !info.IsDir())) { - continue - } else if err != nil { - restoreErr = errors.Join(restoreErr, err) - continue - } - if err := os.Chmod(path, mode.Perm()|mode&(os.ModeSetuid|os.ModeSetgid|os.ModeSticky)); err != nil { - restoreErr = errors.Join(restoreErr, err) - } - } - return restoreErr -} - -type dirMeta struct { - src, dst string - info os.FileInfo -} - -// clearDirContents removes everything inside dir without removing dir itself, -// and without following symlinks. -func clearDirContents(dir string) error { - info, err := os.Lstat(dir) - if err != nil { - if os.IsNotExist(err) { - return nil - } - return err - } - if !info.IsDir() { - return removePath(dir) - } - entries, err := os.ReadDir(dir) - if err != nil { - return err - } - for _, entry := range entries { - if err := removePath(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, info os.FileInfo, hardlinks map[hardlinkIdentity]string, root string) error { - switch info.Mode() & fs.ModeType { - case 0: - return copyRegularEntry(src, dst, info, hardlinks) - case fs.ModeDir: - return copyDirectoryEntry(dst, info) - case fs.ModeSymlink: - return copySymlinkEntry(src, dst, info, root) - 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, info); err != nil { - return err - } - return copyEntryMetadata(src, dst, info) -} - -// copyDirectoryEntry creates dst as a directory, replacing a conflicting -// non-directory. Callers resolve dst through resolveCompositionDirTarget -// first, so dst is never an existing symlink here. -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 - } - } - return os.MkdirAll(dst, info.Mode().Perm()) -} - -func copySymlinkEntry(src, dst string, info os.FileInfo, root string) error { - linkTarget, err := os.Readlink(src) - if err != nil { - return err - } - if err := validateSymlinkTarget(root, dst, linkTarget); err != nil { - return err - } - if err := removePath(dst); err != nil { - return err - } - if err := os.Symlink(linkTarget, dst); err != nil { - return err - } - return copyEntryMetadata(src, dst, info) -} - -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: %w", src, err) - } - if err := mknodWithRootlessFallback(dst, mode|uint32(info.Mode().Perm()), int(stat.Rdev)); err != nil { - return err - } - return copyEntryMetadata(src, dst, info) -} - -func specialFileMode(mode fs.FileMode) (uint32, error) { - switch { - case mode&fs.ModeCharDevice != 0: - return syscall.S_IFCHR, nil - case mode&fs.ModeDevice != 0: - return syscall.S_IFBLK, nil - case mode&fs.ModeNamedPipe != 0: - return syscall.S_IFIFO, nil - case mode&fs.ModeSocket != 0: - return syscall.S_IFSOCK, nil - default: - return 0, fmt.Errorf("unsupported file mode") - } -} - -type entryMetadata struct { - uid, gid int - hasOwner bool - mode fs.FileMode - symlink bool - mtime time.Time - xattrs map[string][]byte -} - -func applyEntryMetadata(path string, metadata entryMetadata) error { - if metadata.hasOwner { - if err := os.Lchown(path, metadata.uid, metadata.gid); err != nil && !errors.Is(err, os.ErrPermission) && !errors.Is(err, unix.EPERM) { - return err - } - } - if metadata.symlink { - return nil - } - if err := applyXattrs(path, metadata.xattrs); err != nil { - return err - } - mode := metadata.mode.Perm() | metadata.mode&(os.ModeSetuid|os.ModeSetgid|os.ModeSticky) - if err := os.Chmod(path, mode); err != nil { - return err - } - return os.Chtimes(path, metadata.mtime, metadata.mtime) -} - -func copyEntryMetadata(src, dst string, info os.FileInfo) error { - metadata := entryMetadata{ - mode: info.Mode(), - symlink: info.Mode()&os.ModeSymlink != 0, - mtime: info.ModTime(), - } - if stat, ok := info.Sys().(*syscall.Stat_t); ok { - metadata.uid = int(stat.Uid) - metadata.gid = int(stat.Gid) - metadata.hasOwner = true - } - if !metadata.symlink { - xattrs, err := readXattrs(src) - if err != nil { - return err - } - metadata.xattrs = xattrs - } - return applyEntryMetadata(dst, metadata) -} - -func readXattrs(src string) (map[string][]byte, error) { - size, err := unix.Llistxattr(src, nil) - if err != nil { - if errors.Is(err, unix.ENOTSUP) || errors.Is(err, unix.EPERM) { - return nil, nil - } - return nil, err - } - names := make([]byte, size) - if size > 0 { - n, err := unix.Llistxattr(src, names) - if err != nil { - return nil, err - } - names = names[:n] - } - xattrs := make(map[string][]byte) - for _, attr := range bytes.Split(bytes.TrimSuffix(names, []byte{0}), []byte{0}) { - if len(attr) == 0 { - continue - } - name := string(attr) - 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 nil, err - } - value := make([]byte, size) - n, err := unix.Lgetxattr(src, name, value) - if err != nil { - return nil, err - } - xattrs[name] = value[:n] - } - return xattrs, nil -} - -func tarXattrs(header *tar.Header) map[string][]byte { - const paxXattrPrefix = "SCHILY.xattr." - xattrs := make(map[string][]byte, len(header.Xattrs)) - for name, value := range header.Xattrs { - xattrs[name] = []byte(value) - } - for key, value := range header.PAXRecords { - if strings.HasPrefix(key, paxXattrPrefix) { - name := strings.TrimPrefix(key, paxXattrPrefix) - if _, exists := xattrs[name]; !exists { - xattrs[name] = []byte(value) - } - } - } - return xattrs -} - -func applyXattrs(path string, xattrs map[string][]byte) error { - for name, value := range xattrs { - if err := unix.Lsetxattr(path, name, 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 copyFileContents(src, dst string, info os.FileInfo) (err error) { - sourceMode := info.Mode().Perm() | info.Mode()&(os.ModeSetuid|os.ModeSetgid|os.ModeSticky) - if sourceMode&0400 == 0 { - if err := os.Chmod(src, sourceMode|0400); err != nil { - return err - } - defer func() { - if restoreErr := os.Chmod(src, sourceMode); err == nil { - err = restoreErr - } - }() - } - - 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|syscall.O_NOFOLLOW, 0644) - if err != nil { - return err - } - if _, err := io.Copy(out, in); err != nil { - out.Close() - return err - } - return out.Close() -} From 75dee2817b84214de44fd47de0e40897e1ff6859 Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Thu, 3 Sep 2026 15:16:22 +0000 Subject: [PATCH 17/21] Detach shared layer builds from the initiating context The singleflight body ran with the first caller's context, so cancelling one pull failed materialization for every concurrent pull waiting on the same layer. Run the build under context.WithoutCancel; callers still return on their own cancellation. --- lib/images/layer_artifact.go | 8 +++++++- lib/images/layer_artifact_test.go | 32 +++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/lib/images/layer_artifact.go b/lib/images/layer_artifact.go index 6d67a9c56..2b93c1106 100644 --- a/lib/images/layer_artifact.go +++ b/lib/images/layer_artifact.go @@ -147,10 +147,16 @@ func discardLayerCache(p *paths.Paths, layerHex string) error { // temp directory; lifecycle reconciliation removes stale temp directories after // an interrupted build. No production caller yet: pull integration and // composition land in later changes. +// +// Concurrent callers share one build. The build itself is detached from the +// initiating caller's cancellation so one cancelled pull cannot fail every +// other pull waiting on the same layer; each caller still returns as soon as +// its own context is done. func (m *manager) materializeLayerArtifact(ctx context.Context, desc layerDescriptor) (*layerArtifact, error) { key := desc.Digest + "\x00" + layerArtifactFormat() + buildCtx := context.WithoutCancel(ctx) result := m.layerFlights.DoChan(key, func() (any, error) { - return m.materializeLayerArtifactOnce(ctx, desc) + return m.materializeLayerArtifactOnce(buildCtx, desc) }) select { case <-ctx.Done(): diff --git a/lib/images/layer_artifact_test.go b/lib/images/layer_artifact_test.go index 528c91abd..5e81a4624 100644 --- a/lib/images/layer_artifact_test.go +++ b/lib/images/layer_artifact_test.go @@ -12,6 +12,7 @@ import ( "os/exec" "path/filepath" "testing" + "time" gcr "github.com/google/go-containerregistry/pkg/v1" "github.com/google/go-containerregistry/pkg/v1/empty" @@ -201,6 +202,37 @@ func TestMaterializeLayerArtifactRecoversCorruptRecord(t *testing.T) { require.NoError(t, record.validate()) } +// TestMaterializeLayerArtifactOutlivesCancelledCaller checks that a caller +// abandoning a shared build does not abort the build for everyone else: the +// artifact still lands even though the initiating context was cancelled. +func TestMaterializeLayerArtifactOutlivesCancelledCaller(t *testing.T) { + if _, err := exec.LookPath("mkfs.ext4"); err != nil { + t.Skip("mkfs.ext4 not available") + } + originalFormat := DefaultImageFormat + DefaultImageFormat = FormatExt4 + t.Cleanup(func() { DefaultImageFormat = originalFormat }) + + 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} + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err = m.materializeLayerArtifact(ctx, desc) + require.ErrorIs(t, err, context.Canceled) + + layerHex := desc.Digest[len("sha256:"):] + require.Eventually(t, func() bool { + record, err := readLayerRecord(p, layerHex) + return err == nil && record != nil + }, 30*time.Second, 50*time.Millisecond, "detached build must still install the artifact") + require.FileExists(t, p.ImageLayerArtifactForFormat(layerHex, layerArtifactFormat())) +} + func TestMaterializeLayerArtifactMissingBlob(t *testing.T) { p := paths.New(t.TempDir()) m := &manager{paths: p} From 7459733998df220f999124f97acb3026a697153e Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Thu, 3 Sep 2026 15:30:58 +0000 Subject: [PATCH 18/21] Trim layer artifact plumbing Fold contextReader into layer_artifact.go, its only user. Drop the context-aware mkfs.erofs wrapper: the shared build now runs detached from caller cancellation, so the context could never fire during conversion, and ExportRootfs already dispatches on the format. --- lib/images/context_reader.go | 18 ------------------ lib/images/disk.go | 7 +------ lib/images/layer_artifact.go | 30 +++++++++++++++++++----------- 3 files changed, 20 insertions(+), 35 deletions(-) delete mode 100644 lib/images/context_reader.go diff --git a/lib/images/context_reader.go b/lib/images/context_reader.go deleted file mode 100644 index 8aec8b2e4..000000000 --- a/lib/images/context_reader.go +++ /dev/null @@ -1,18 +0,0 @@ -package images - -import ( - "context" - "io" -) - -type contextReader struct { - ctx context.Context - reader io.Reader -} - -func (r contextReader) Read(p []byte) (int, error) { - if err := r.ctx.Err(); err != nil { - return 0, err - } - return r.reader.Read(p) -} diff --git a/lib/images/disk.go b/lib/images/disk.go index 97e77db53..896d10f2a 100644 --- a/lib/images/disk.go +++ b/lib/images/disk.go @@ -1,7 +1,6 @@ package images import ( - "context" "fmt" "os" "os/exec" @@ -195,10 +194,6 @@ func convertToExt4(rootfsDir, diskPath string) (int64, error) { // convertToErofs converts a rootfs directory to an erofs disk image using mkfs.erofs func convertToErofs(rootfsDir, diskPath string) (int64, error) { - return convertToErofsContext(context.Background(), rootfsDir, diskPath) -} - -func convertToErofsContext(ctx context.Context, rootfsDir, diskPath string) (int64, error) { // Ensure parent directory exists if err := os.MkdirAll(filepath.Dir(diskPath), 0755); err != nil { return 0, fmt.Errorf("create disk parent dir: %w", err) @@ -207,7 +202,7 @@ func convertToErofsContext(ctx context.Context, rootfsDir, diskPath string) (int // Create erofs image with LZ4 fast compression // -zlz4: LZ4 fast compression (~20-25% space savings, faster builds) // erofs doesn't need pre-allocation, creates file directly - cmd := exec.CommandContext(ctx, "mkfs.erofs", "-zlz4", diskPath, rootfsDir) + cmd := exec.Command("mkfs.erofs", "-zlz4", diskPath, rootfsDir) output, err := cmd.CombinedOutput() if err != nil { return 0, fmt.Errorf("mkfs.erofs failed: %w, output: %s", err, output) diff --git a/lib/images/layer_artifact.go b/lib/images/layer_artifact.go index 2b93c1106..0f40a0960 100644 --- a/lib/images/layer_artifact.go +++ b/lib/images/layer_artifact.go @@ -206,10 +206,10 @@ func (m *manager) materializeLayerArtifactOnce(ctx context.Context, desc layerDe if err != nil { return nil, err } - return m.installLayerArtifact(ctx, desc, layerHex, unpackDir, stats) + return m.installLayerArtifact(desc, layerHex, unpackDir, stats) } -func (m *manager) installLayerArtifact(ctx context.Context, desc layerDescriptor, layerHex, unpackDir string, stats *unpackStats) (*layerArtifact, error) { +func (m *manager) installLayerArtifact(desc layerDescriptor, layerHex, unpackDir string, stats *unpackStats) (*layerArtifact, error) { record := &layerArtifact{ SchemaVersion: layerRecordSchemaVersion, Digest: desc.Digest, @@ -220,15 +220,9 @@ func (m *manager) installLayerArtifact(ctx context.Context, desc layerDescriptor } if err := installAtomically(layerArtifactPath(m.paths, layerHex), func(path string) error { - var size int64 - var convErr error - if DefaultImageFormat == FormatErofs { - size, convErr = convertToErofsContext(ctx, unpackDir, path) - } else { - size, convErr = ExportRootfs(unpackDir, path, DefaultImageFormat) - } - if convErr != nil { - return convErr + size, err := ExportRootfs(unpackDir, path, DefaultImageFormat) + if err != nil { + return err } record.SizeBytes = size return nil @@ -252,6 +246,20 @@ type unpackStats struct { diffID string } +// contextReader fails reads once ctx is done so a cancelled caller stops a +// long extraction instead of running it to completion. +type contextReader struct { + ctx context.Context + reader io.Reader +} + +func (r contextReader) Read(p []byte) (int, error) { + if err := r.ctx.Err(); err != nil { + return 0, err + } + return r.reader.Read(p) +} + // unpackCachedLayer locates desc's blob in the shared OCI cache, unpacks it // into dest, and verifies the diff ID when the descriptor carries one. func unpackCachedLayer(ctx context.Context, cacheDir string, desc layerDescriptor, dest string, onDisk layer.OnDiskFormat) (*unpackStats, error) { From 46d02f3dbb2bb50909466ea761c8b405d1eb6a4d Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Thu, 3 Sep 2026 16:06:03 +0000 Subject: [PATCH 19/21] Harden layer artifact builds and drop unused plumbing - Bound detached layer builds with a deadline so a hung cache-blob read cannot wedge the singleflight key forever - Verify the compressed blob digest during unpack; the pull path does not re-verify blobs after they land in the OCI cache - Resolve blob paths through paths.OCICacheBlob instead of hand-joining the cache layout, and drop the duplicated digest validation - Simplify decompressLayer and move composeOnDiskFormat/whiteoutPrefix to the tests that use them - Log failures to remove the unpack directory instead of discarding the error - Make the cancelled-caller test tolerant of a build that finishes before the caller's select runs --- lib/images/layer_artifact.go | 92 ++++++++++++++++--------------- lib/images/layer_artifact_test.go | 21 ++++++- 2 files changed, 68 insertions(+), 45 deletions(-) diff --git a/lib/images/layer_artifact.go b/lib/images/layer_artifact.go index 0f40a0960..d7b79fe19 100644 --- a/lib/images/layer_artifact.go +++ b/lib/images/layer_artifact.go @@ -9,6 +9,7 @@ import ( "fmt" "io" "io/fs" + "log/slog" "os" "path/filepath" "strings" @@ -19,15 +20,14 @@ import ( "github.com/opencontainers/umoci/oci/layer" ) -// whiteoutPrefix marks OCI whiteout entries (".wh." and ".wh..wh..opq"). -// umoci interprets them during extraction: DirRootfs applies them against the -// tree being composed, OverlayfsRootfs converts them to overlayfs whiteout -// inodes and opaque xattrs so per-layer artifacts can later be stacked. -const whiteoutPrefix = ".wh." - const ( layerRecordSchemaVersion = 1 maxLayerUnpackedBytes = 100 << 30 + + // layerBuildTimeout bounds a shared layer build end to end. Builds are + // detached from the initiating request's context, so this deadline is the + // only thing that can abort a hung unpack and free the singleflight key. + layerBuildTimeout = time.Hour ) var errCorruptLayerRecord = errors.New("corrupt layer record") @@ -64,6 +64,10 @@ func (a *layerArtifact) validate() error { return nil } +// matches decides whether the stored record satisfies a lookup. A descriptor +// without a DiffID (a manifest lookup that did not consult the image config) +// matches any record: the compressed digest content-addresses the blob, and +// unpackCachedLayer re-verifies the diff ID whenever one is supplied. func (a *layerArtifact) matches(desc layerDescriptor) bool { if a.Digest != desc.Digest || a.Format != layerArtifactFormat() { return false @@ -90,6 +94,9 @@ func layerArtifactRecordPath(p *paths.Paths, layerHex string) string { // layerMapOptions preserves tar ownership when running as root. Otherwise // umoci's rootless mode skips chown and stands in empty files for device nodes. +// Unlike unpackLayers in oci.go, which maps container root to the current +// user, this deliberately leaves ownership untouched as root: artifacts must +// keep the layer's on-disk ownership for later stacking. func layerMapOptions() layer.MapOptions { return layer.MapOptions{Rootless: os.Geteuid() != 0} } @@ -102,11 +109,6 @@ func layerArtifactOnDiskFormat() layer.OnDiskFormat { return layer.OverlayfsRootfs{MapOptions: layerMapOptions()} } -// composeOnDiskFormat applies whiteouts against the tree being composed. -func composeOnDiskFormat() layer.OnDiskFormat { - return layer.DirRootfs{MapOptions: layerMapOptions()} -} - // readLayerRecord loads the artifact record for a layer digest, if present. // A missing record returns (nil, nil): the layer simply was never // materialized. @@ -144,8 +146,9 @@ func discardLayerCache(p *paths.Paths, layerHex string) error { // 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 the // default image format, and installed atomically. Normal failures remove the -// temp directory; lifecycle reconciliation removes stale temp directories after -// an interrupted build. No production caller yet: pull integration and +// temp directory; a crash mid-build can leave a stale .unpack-* directory +// behind, which reconciliation landing with the pull integration is expected +// to sweep. No production caller yet: pull integration and // composition land in later changes. // // Concurrent callers share one build. The build itself is detached from the @@ -154,8 +157,14 @@ func discardLayerCache(p *paths.Paths, layerHex string) error { // its own context is done. func (m *manager) materializeLayerArtifact(ctx context.Context, desc layerDescriptor) (*layerArtifact, error) { key := desc.Digest + "\x00" + layerArtifactFormat() - buildCtx := context.WithoutCancel(ctx) + // The build outlives the initiating request, so the deadline below is its + // only bound: without it a hung cache-blob read would wedge the + // singleflight key, and every future caller for the layer, forever. + buildCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), layerBuildTimeout) result := m.layerFlights.DoChan(key, func() (any, error) { + // Cancel here, in the flight's frame: the initiating caller may return + // long before the detached build finishes. + defer cancel() return m.materializeLayerArtifactOnce(buildCtx, desc) }) select { @@ -200,9 +209,13 @@ func (m *manager) materializeLayerArtifactOnce(ctx context.Context, desc layerDe if err != nil { return nil, fmt.Errorf("create unpack directory: %w", err) } - defer removePath(unpackDir) + defer func() { + if err := removePath(unpackDir); err != nil { + slog.Warn("failed to remove layer unpack directory", "dir", unpackDir, "error", err) + } + }() - stats, err := unpackCachedLayer(ctx, m.paths.SystemOCICache(), desc, unpackDir, layerArtifactOnDiskFormat()) + stats, err := unpackCachedLayer(ctx, m.paths, desc, unpackDir, layerArtifactOnDiskFormat()) if err != nil { return nil, err } @@ -244,6 +257,7 @@ func (m *manager) installLayerArtifact(desc layerDescriptor, layerHex, unpackDir type unpackStats struct { unpackedBytes int64 diffID string + blobDigest string // sha256 of the compressed bytes as read } // contextReader fails reads once ctx is done so a cancelled caller stops a @@ -261,13 +275,10 @@ func (r contextReader) Read(p []byte) (int, error) { } // unpackCachedLayer locates desc's blob in the shared OCI cache, unpacks it -// into dest, and verifies the diff ID when the descriptor carries one. -func unpackCachedLayer(ctx context.Context, cacheDir string, desc layerDescriptor, dest string, onDisk layer.OnDiskFormat) (*unpackStats, error) { - layerHex := strings.TrimPrefix(desc.Digest, "sha256:") - if err := paths.ValidatePathComponent(layerHex); err != nil { - return nil, fmt.Errorf("invalid layer digest: %s", desc.Digest) - } - blobPath := filepath.Join(cacheDir, "blobs", "sha256", layerHex) +// into dest, and verifies both the blob digest and the diff ID when the +// descriptor carries one. The caller must have validated desc.Digest. +func unpackCachedLayer(ctx context.Context, p *paths.Paths, desc layerDescriptor, dest string, onDisk layer.OnDiskFormat) (*unpackStats, error) { + blobPath := p.OCICacheBlob(strings.TrimPrefix(desc.Digest, "sha256:")) if _, err := os.Stat(blobPath); err != nil { if os.IsNotExist(err) { return nil, fmt.Errorf("layer blob missing from oci cache: %s", desc.Digest) @@ -278,6 +289,9 @@ func unpackCachedLayer(ctx context.Context, cacheDir string, desc layerDescripto if err != nil { return nil, fmt.Errorf("unpack layer %s: %w", desc.Digest, err) } + if desc.Digest != "" && stats.blobDigest != desc.Digest { + return nil, fmt.Errorf("layer blob digest mismatch: got %s, want %s", stats.blobDigest, desc.Digest) + } 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) } @@ -286,7 +300,8 @@ func unpackCachedLayer(ctx context.Context, cacheDir string, desc layerDescripto // unpackLayerBlob extracts one compressed layer blob into dest with umoci, // which confines every entry to dest and interprets whiteouts per onDisk. -// The decompressed stream is hashed for the diff ID and capped in size. +// The compressed stream is hashed to verify the blob digest, the decompressed +// stream for the diff ID, and capped in size. func unpackLayerBlob(ctx context.Context, blobPath, mediaType, dest string, onDisk layer.OnDiskFormat) (*unpackStats, error) { if err := os.MkdirAll(dest, 0755); err != nil { return nil, fmt.Errorf("create extraction root: %w", err) @@ -297,7 +312,8 @@ func unpackLayerBlob(ctx context.Context, blobPath, mediaType, dest string, onDi } defer blob.Close() - reader, closer, err := decompressLayer(blob, mediaType) + blobHash := sha256.New() + reader, closer, err := decompressLayer(io.TeeReader(blob, blobHash), mediaType) if err != nil { return nil, err } @@ -318,41 +334,31 @@ func unpackLayerBlob(ctx context.Context, blobPath, mediaType, dest string, onDi return &unpackStats{ unpackedBytes: maxLayerUnpackedBytes + 1 - limited.N, diffID: fmt.Sprintf("sha256:%x", hash.Sum(nil)), + blobDigest: fmt.Sprintf("sha256:%x", blobHash.Sum(nil)), }, nil } // decompressLayer wraps the blob in the reader for its layer media type. Both // OCI-style suffixes (+gzip, +zstd) and docker-style media types (tar.gzip, // tar.zstd) are matched so neither encoding falls through to the raw path. -func decompressLayer(blob *os.File, mediaType string) (io.Reader, io.Closer, error) { +func decompressLayer(r io.Reader, mediaType string) (io.Reader, io.Closer, error) { switch { case strings.HasSuffix(mediaType, "+zstd"), strings.Contains(mediaType, "tar.zstd"): - decoder, err := zstd.NewReader(blob) + decoder, err := zstd.NewReader(r) if err != nil { return nil, nil, fmt.Errorf("zstd reader: %w", err) } - return decoder, multiCloser{decoder.IOReadCloser(), blob}, nil + decodeCloser := decoder.IOReadCloser() + return decodeCloser, decodeCloser, nil case strings.HasSuffix(mediaType, "+gzip"), strings.Contains(mediaType, "tar.gzip"): - gz, err := gzip.NewReader(blob) + gz, err := gzip.NewReader(r) if err != nil { return nil, nil, fmt.Errorf("gzip reader: %w", err) } - return gz, multiCloser{gz, blob}, nil + return gz, gz, 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 r, io.NopCloser(r), nil } - return firstErr } // removePath removes a tree that may contain read-only directories restored diff --git a/lib/images/layer_artifact_test.go b/lib/images/layer_artifact_test.go index 5e81a4624..2ba416031 100644 --- a/lib/images/layer_artifact_test.go +++ b/lib/images/layer_artifact_test.go @@ -19,10 +19,24 @@ import ( "github.com/google/go-containerregistry/pkg/v1/layout" "github.com/google/go-containerregistry/pkg/v1/mutate" "github.com/kernel/hypeman/lib/paths" + "github.com/opencontainers/umoci/oci/layer" "github.com/stretchr/testify/require" "golang.org/x/sys/unix" ) +// whiteoutPrefix marks OCI whiteout entries (".wh." and ".wh..wh..opq"). +// umoci interprets them during extraction: composeOnDiskFormat applies them +// against the tree being composed, layerArtifactOnDiskFormat converts them to +// overlayfs whiteout inodes and opaque xattrs so per-layer artifacts can +// later be stacked. +const whiteoutPrefix = ".wh." + +// composeOnDiskFormat applies whiteouts against the tree being composed. It +// belongs to the composition flow and moves to production with that change. +func composeOnDiskFormat() layer.OnDiskFormat { + return layer.DirRootfs{MapOptions: layerMapOptions()} +} + const testTarGzMediaType = "application/vnd.oci.image.layer.v1.tar+gzip" // writeLayerTestLayout writes img into the shared OCI cache of p tagged with @@ -222,8 +236,11 @@ func TestMaterializeLayerArtifactOutlivesCancelledCaller(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() - _, err = m.materializeLayerArtifact(ctx, desc) - require.ErrorIs(t, err, context.Canceled) + // The build usually loses the race against the cancelled context, but a + // fast build can finish first; either outcome is valid when detached. + if _, buildErr := m.materializeLayerArtifact(ctx, desc); buildErr != nil { + require.ErrorIs(t, buildErr, context.Canceled) + } layerHex := desc.Digest[len("sha256:"):] require.Eventually(t, func() bool { From 7b8f9effc2c038e5c462f030b192499749afaef5 Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Thu, 3 Sep 2026 16:06:03 +0000 Subject: [PATCH 20/21] Add layer artifact edge-case smoke tests Covers zstd and docker-style media types, traversal in layer digest hex, rebuild when the artifact file is missing, concurrent shared materialization, device and fifo entries (root only), and layer artifact disk-usage accounting. --- lib/images/layer_artifact_smoke_test.go | 239 ++++++++++++++++++++++++ 1 file changed, 239 insertions(+) create mode 100644 lib/images/layer_artifact_smoke_test.go diff --git a/lib/images/layer_artifact_smoke_test.go b/lib/images/layer_artifact_smoke_test.go new file mode 100644 index 000000000..9f0d30b5c --- /dev/null +++ b/lib/images/layer_artifact_smoke_test.go @@ -0,0 +1,239 @@ +package images + +import ( + "archive/tar" + "bytes" + "context" + "os" + "os/exec" + "path/filepath" + "sync" + "testing" + "time" + + "github.com/google/go-containerregistry/pkg/v1/empty" + "github.com/google/go-containerregistry/pkg/v1/mutate" + "github.com/kernel/hypeman/lib/paths" + "github.com/klauspost/compress/zstd" + "github.com/stretchr/testify/require" + "golang.org/x/sys/unix" +) + +// Smoke tests for PR #456 edge cases beyond the standard suite. +// Media types, path validation, cache-miss rebuilds, singleflight sharing, +// device/fifo entries (root only), and disk-usage accounting. + +func writeRawTarLayer(t *testing.T, dir, name string, entries ...func(*testing.T, *tar.Writer)) string { + t.Helper() + var buf bytes.Buffer + tw := tar.NewWriter(&buf) + for _, entry := range entries { + entry(t, tw) + } + require.NoError(t, tw.Close()) + path := filepath.Join(dir, name) + require.NoError(t, os.WriteFile(path, buf.Bytes(), 0644)) + return path +} + +func writeZstdTarLayer(t *testing.T, dir, name string, entries ...func(*testing.T, *tar.Writer)) string { + t.Helper() + var tarBuf bytes.Buffer + tw := tar.NewWriter(&tarBuf) + for _, entry := range entries { + entry(t, tw) + } + require.NoError(t, tw.Close()) + + var zstBuf bytes.Buffer + zw, err := zstd.NewWriter(&zstBuf) + require.NoError(t, err) + _, err = zw.Write(tarBuf.Bytes()) + require.NoError(t, err) + require.NoError(t, zw.Close()) + path := filepath.Join(dir, name) + require.NoError(t, os.WriteFile(path, zstBuf.Bytes(), 0644)) + return path +} + +const ( + ociZstdMediaType = "application/vnd.oci.image.layer.v1.tar+zstd" + dockerZstdMediaType = "application/vnd.docker.image.rootfs.diff.tar.zstd" + dockerGzipMediaType = "application/vnd.docker.image.rootfs.diff.tar.gzip" + ociPlainTarMediaType = "application/vnd.oci.image.layer.v1.tar" +) + +func TestSmokeUnpackZstdMediaTypes(t *testing.T) { + root := t.TempDir() + for name, tc := range map[string]struct { + mediaType string + blob string + }{ + "oci_suffix_zstd": {ociZstdMediaType, writeZstdTarLayer(t, root, "oci.tar.zst", fileEntry("f.txt", "zstd"))}, + "docker_tar_zstd": {dockerZstdMediaType, writeZstdTarLayer(t, root, "docker.tar.zstd", fileEntry("f.txt", "zstd"))}, + "docker_tar_gzip": {dockerGzipMediaType, writeLayerBlob(t, root, "docker.tar.gz", fileEntry("f.txt", "gzip"))}, + "plain_tar": {ociPlainTarMediaType, writeRawTarLayer(t, root, "plain.tar", fileEntry("f.txt", "raw"))}, + "unknown_media_raw": {"application/vnd.custom.tar", writeRawTarLayer(t, root, "custom.tar", fileEntry("f.txt", "raw"))}, + } { + t.Run(name, func(t *testing.T) { + dest := filepath.Join(root, t.Name()) + stats, err := unpackLayerBlob(context.Background(), tc.blob, tc.mediaType, dest, composeOnDiskFormat()) + require.NoError(t, err) + require.Greater(t, stats.unpackedBytes, int64(0)) + require.FileExists(t, filepath.Join(dest, "f.txt")) + }) + } +} + +func TestSmokeRejectsBadLayerHex(t *testing.T) { + p := paths.New(t.TempDir()) + m := &manager{paths: p} + for _, digest := range []string{ + "sha256:..", + "sha256:../escape", + "sha256:a/b", + "sha256:", + } { + _, err := m.materializeLayerArtifact(context.Background(), layerDescriptor{ + Digest: digest, + MediaType: testTarGzMediaType, + }) + require.ErrorContains(t, err, "invalid layer digest", "digest %q must be rejected", digest) + } + require.NoDirExists(t, filepath.Join(p.ImageLayersDir(), "..", "layers-escape")) + + // A non-digest string without the sha256: prefix passes path validation + // and fails at blob lookup instead — acceptable, since real descriptors + // always carry manifest-parsed digests. + _, err := m.materializeLayerArtifact(context.Background(), layerDescriptor{ + Digest: "notadigest", + MediaType: testTarGzMediaType, + }) + require.ErrorContains(t, err, "missing from oci cache") +} + +func TestSmokeRebuildWhenArtifactFileMissing(t *testing.T) { + if _, err := exec.LookPath("mkfs.ext4"); err != nil { + t.Skip("mkfs.ext4 not available") + } + originalFormat := DefaultImageFormat + DefaultImageFormat = FormatExt4 + t.Cleanup(func() { DefaultImageFormat = originalFormat }) + + p := paths.New(t.TempDir()) + img, err := mutate.AppendLayers(empty.Image, syntheticLayer(t, "f.txt", "content")) + require.NoError(t, err) + writeLayerTestLayout(t, p, img) + desc := layerDescFromImage(t, img, 0) + m := &manager{paths: p} + + first, err := m.materializeLayerArtifact(context.Background(), desc) + require.NoError(t, err) + layerHex := desc.Digest[len("sha256:"):] + + // Record survives but the artifact file is gone: must rebuild, not reuse. + require.NoError(t, os.Remove(p.ImageLayerArtifactForFormat(layerHex, layerArtifactFormat()))) + second, err := m.materializeLayerArtifact(context.Background(), desc) + require.NoError(t, err) + require.True(t, second.CreatedAt.After(first.CreatedAt), "missing artifact must trigger a rebuild") + require.FileExists(t, p.ImageLayerArtifactForFormat(layerHex, layerArtifactFormat())) + + // Truncated artifact with a valid record: reuse only checks existence, so + // this documents current behavior (record says nothing about integrity). + require.NoError(t, os.Truncate(p.ImageLayerArtifactForFormat(layerHex, layerArtifactFormat()), 10)) + third, err := m.materializeLayerArtifact(context.Background(), desc) + require.NoError(t, err) + require.True(t, third.CreatedAt.Equal(second.CreatedAt), "truncated artifact is currently reused; integrity is trusted from install-time atomicity") +} + +func TestSmokeConcurrentMaterializeSharesFlight(t *testing.T) { + if _, err := exec.LookPath("mkfs.ext4"); err != nil { + t.Skip("mkfs.ext4 not available") + } + originalFormat := DefaultImageFormat + DefaultImageFormat = FormatExt4 + t.Cleanup(func() { DefaultImageFormat = originalFormat }) + + p := paths.New(t.TempDir()) + img, err := mutate.AppendLayers(empty.Image, syntheticLayer(t, "f.txt", "shared")) + require.NoError(t, err) + writeLayerTestLayout(t, p, img) + desc := layerDescFromImage(t, img, 0) + m := &manager{paths: p} + + const callers = 8 + var wg sync.WaitGroup + records := make([]*layerArtifact, callers) + errs := make([]error, callers) + start := time.Now() + for i := 0; i < callers; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + records[i], errs[i] = m.materializeLayerArtifact(context.Background(), desc) + }(i) + } + wg.Wait() + require.NotEmpty(t, errs) + for i := range errs { + require.NoError(t, errs[i]) + require.Equal(t, records[0], records[i], "all callers must share one build result") + } + t.Logf("8 callers finished in %s sharing one flight", time.Since(start)) + layerHex := desc.Digest[len("sha256:"):] + require.FileExists(t, p.ImageLayerArtifactForFormat(layerHex, layerArtifactFormat())) +} + +func TestSmokeDeviceAndFifoEntries(t *testing.T) { + if os.Geteuid() != 0 { + t.Skip("device and fifo tar entries need root to mknod") + } + root := t.TempDir() + blob := writeRawTarLayer(t, root, "devices.tar", + fileEntry("regular.txt", "x"), + func(t *testing.T, tw *tar.Writer) { + require.NoError(t, tw.WriteHeader(&tar.Header{ + Name: "dev/null", Typeflag: tar.TypeChar, + Devmajor: 1, Devminor: 3, Mode: 0666, + })) + }, + func(t *testing.T, tw *tar.Writer) { + require.NoError(t, tw.WriteHeader(&tar.Header{ + Name: "pipes/fifo", Typeflag: tar.TypeFifo, Mode: 0644, + })) + }, + ) + dest := filepath.Join(root, "dest") + _, err := unpackLayerBlob(context.Background(), blob, ociPlainTarMediaType, dest, composeOnDiskFormat()) + require.NoError(t, err) + + require.FileExists(t, filepath.Join(dest, "regular.txt")) + var stat unix.Stat_t + require.NoError(t, unix.Lstat(filepath.Join(dest, "dev", "null"), &stat)) + require.Equal(t, uint32(unix.S_IFCHR), stat.Mode&unix.S_IFMT) + require.Equal(t, uint64(0x103), uint64(stat.Rdev), "char device 1:3") + require.NoError(t, unix.Lstat(filepath.Join(dest, "pipes", "fifo"), &stat)) + require.Equal(t, uint32(unix.S_IFIFO), stat.Mode&unix.S_IFMT) +} + +func TestSmokeDiskUsageCountsArtifactsOnly(t *testing.T) { + p := paths.New(t.TempDir()) + layersDir := p.ImageLayersDir() + require.NoError(t, os.MkdirAll(filepath.Join(layersDir, "abab", ".unpack-123"), 0755)) + write := func(rel string, size int) { + require.NoError(t, os.WriteFile(filepath.Join(layersDir, rel), make([]byte, size), 0644)) + } + write(filepath.Join("abab", "layer.erofs"), 100) + write(filepath.Join("abab", "artifact.erofs.json"), 50) + write(filepath.Join("abab", ".unpack-123", "layer.erofs"), 999) + write("stray.txt", 500) + + total, err := totalLayerArtifactBytesFromFilesystem(layersDir) + require.NoError(t, err) + require.Equal(t, int64(100), total, "only materialized artifacts count; temp dirs and records are excluded") + + // Missing directory is not an error. + total, err = totalLayerArtifactBytesFromFilesystem(filepath.Join(p.DataDir(), "nope")) + require.NoError(t, err) + require.Equal(t, int64(0), total) +} From 59eb8fb31f9a2bd95a5667a4d517e86f0dd90eb7 Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Thu, 3 Sep 2026 17:18:07 +0000 Subject: [PATCH 21/21] Trim redundant smoke tests Drop the disk-usage accounting case (covered by TestTotalLayerArtifactBytesFromFilesystem) and the truncated-artifact documentation case. --- lib/images/layer_artifact_smoke_test.go | 29 ------------------------- 1 file changed, 29 deletions(-) diff --git a/lib/images/layer_artifact_smoke_test.go b/lib/images/layer_artifact_smoke_test.go index 9f0d30b5c..ebbee631a 100644 --- a/lib/images/layer_artifact_smoke_test.go +++ b/lib/images/layer_artifact_smoke_test.go @@ -137,13 +137,6 @@ func TestSmokeRebuildWhenArtifactFileMissing(t *testing.T) { require.NoError(t, err) require.True(t, second.CreatedAt.After(first.CreatedAt), "missing artifact must trigger a rebuild") require.FileExists(t, p.ImageLayerArtifactForFormat(layerHex, layerArtifactFormat())) - - // Truncated artifact with a valid record: reuse only checks existence, so - // this documents current behavior (record says nothing about integrity). - require.NoError(t, os.Truncate(p.ImageLayerArtifactForFormat(layerHex, layerArtifactFormat()), 10)) - third, err := m.materializeLayerArtifact(context.Background(), desc) - require.NoError(t, err) - require.True(t, third.CreatedAt.Equal(second.CreatedAt), "truncated artifact is currently reused; integrity is trusted from install-time atomicity") } func TestSmokeConcurrentMaterializeSharesFlight(t *testing.T) { @@ -215,25 +208,3 @@ func TestSmokeDeviceAndFifoEntries(t *testing.T) { require.NoError(t, unix.Lstat(filepath.Join(dest, "pipes", "fifo"), &stat)) require.Equal(t, uint32(unix.S_IFIFO), stat.Mode&unix.S_IFMT) } - -func TestSmokeDiskUsageCountsArtifactsOnly(t *testing.T) { - p := paths.New(t.TempDir()) - layersDir := p.ImageLayersDir() - require.NoError(t, os.MkdirAll(filepath.Join(layersDir, "abab", ".unpack-123"), 0755)) - write := func(rel string, size int) { - require.NoError(t, os.WriteFile(filepath.Join(layersDir, rel), make([]byte, size), 0644)) - } - write(filepath.Join("abab", "layer.erofs"), 100) - write(filepath.Join("abab", "artifact.erofs.json"), 50) - write(filepath.Join("abab", ".unpack-123", "layer.erofs"), 999) - write("stray.txt", 500) - - total, err := totalLayerArtifactBytesFromFilesystem(layersDir) - require.NoError(t, err) - require.Equal(t, int64(100), total, "only materialized artifacts count; temp dirs and records are excluded") - - // Missing directory is not an error. - total, err = totalLayerArtifactBytesFromFilesystem(filepath.Join(p.DataDir(), "nope")) - require.NoError(t, err) - require.Equal(t, int64(0), total) -}