diff --git a/lib/images/layer_artifact.go b/lib/images/layer_artifact.go new file mode 100644 index 000000000..41d7201e8 --- /dev/null +++ b/lib/images/layer_artifact.go @@ -0,0 +1,818 @@ +package images + +import ( + "archive/tar" + "compress/gzip" + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "strings" + "syscall" + "time" + + securejoin "github.com/cyphar/filepath-securejoin" + "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 and +// options, so the same layer can coexist in several materializations. +type layerArtifact struct { + SchemaVersion int `json:"schema_version"` + Digest string `json:"digest"` // compressed layer blob digest, sha256:... + DiffID string `json:"diff_id,omitempty"` + Format string `json:"format"` + Options layerArtifactOptions `json:"options,omitempty"` + SizeBytes int64 `json:"size_bytes"` // artifact bytes on disk + UnpackedBytes int64 `json:"unpacked_bytes"` + Entries int `json:"entries"` + Whiteouts []whiteoutRecord `json:"whiteouts,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +type layerArtifactOptions struct { + Compression string `json:"compression,omitempty"` +} + +// whiteoutRecord describes one whiteout marker found in a layer. Dir is the +// directory containing the marker relative to the layer root ("" for root). +// For opaque markers, Target is empty and the whole directory is hidden. +type whiteoutRecord struct { + Dir string `json:"dir"` + Target string `json:"target,omitempty"` + Opaque bool `json:"opaque,omitempty"` +} + +func (a *layerArtifact) matches(desc layerDescriptor) bool { + 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 erofs, +// and installed atomically; an interrupted build leaves only temp files that +// the next attempt replaces. +func (a *layerArtifact) validate() error { + if a.SchemaVersion != layerRecordSchemaVersion { + return fmt.Errorf("unsupported schema version: %d", a.SchemaVersion) + } + if a.Digest == "" || a.Format != layerFormatErofs && a.Format != layerFormatExt4 { + return fmt.Errorf("invalid digest or format") + } + if a.SizeBytes < 0 || a.UnpackedBytes < 0 || a.Entries < 0 { + return fmt.Errorf("invalid size or entry counts") + } + if a.Format == layerFormatExt4 && a.Options.Compression != "" { + return fmt.Errorf("ext4 artifact has compression options") + } + if a.Format == layerFormatErofs && a.Options.Compression != "lz4" { + return fmt.Errorf("erofs artifact has invalid compression options") + } + return nil +} + +func (m *manager) materializeLayerArtifact(desc layerDescriptor) (*layerArtifact, error) { + layerHex := strings.TrimPrefix(desc.Digest, "sha256:") + if 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 artifactOptions(format string) layerArtifactOptions { + if format == layerFormatErofs { + return layerArtifactOptions{Compression: "lz4"} + } + return layerArtifactOptions{} +} + +func (m *manager) installLayerArtifact(desc layerDescriptor, layerHex, unpackDir string, stats *unpackStats) (*layerArtifact, error) { + record := &layerArtifact{ + SchemaVersion: layerRecordSchemaVersion, + Digest: desc.Digest, + DiffID: desc.DiffID, + Format: layerArtifactFormat(), + Options: artifactOptions(layerArtifactFormat()), + UnpackedBytes: stats.unpackedBytes, + Entries: stats.entries, + Whiteouts: stats.whiteouts, + CreatedAt: time.Now(), + } + + if err := installAtomically(layerArtifactPath(m.paths, layerHex), func(path string) error { + var size int64 + var convErr error + switch layerArtifactFormat() { + case layerFormatExt4: + size, convErr = convertToExt4(unpackDir, path) + default: + size, convErr = convertToErofs(unpackDir, path) + } + if convErr != nil { + return convErr + } + 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)} + tr := tar.NewReader(io.TeeReader(reader, hash)) + 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 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, reader); err != nil { + return nil, fmt.Errorf("drain layer: %w", err) + } + stats.diffID = fmt.Sprintf("sha256:%x", hash.Sum(nil)) + return stats, nil +} + +// decompressLayer wraps the blob in the reader for its layer media type. +func decompressLayer(blob *os.File, mediaType string) (io.Reader, io.Closer, error) { + switch { + case strings.HasSuffix(mediaType, "+zstd"): + decoder, err := zstd.NewReader(blob) + if err != nil { + return nil, nil, fmt.Errorf("zstd reader: %w", err) + } + return decoder, multiCloser{decoder.IOReadCloser(), blob}, nil + case strings.HasSuffix(mediaType, "+gzip"), strings.Contains(mediaType, "tar.gzip"): + gz, err := gzip.NewReader(blob) + if err != nil { + return nil, nil, fmt.Errorf("gzip reader: %w", err) + } + return gz, multiCloser{gz, blob}, nil + default: + return blob, blob, nil + } +} + +type multiCloser []io.Closer + +func (c multiCloser) Close() error { + var firstErr error + for _, closer := range c { + if err := closer.Close(); err != nil && firstErr == nil { + firstErr = err + } + } + return firstErr +} + +// safeJoin resolves a tar entry name inside root and rejects symlinked parents. +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 == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("tar entry escapes root: %s", name) + } + root = filepath.Clean(root) + target := filepath.Join(root, clean) + if target != root && !strings.HasPrefix(target, root+string(filepath.Separator)) { + return "", fmt.Errorf("tar entry escapes root: %s", name) + } + for parent := filepath.Dir(target); parent != root; parent = filepath.Dir(parent) { + info, err := os.Lstat(parent) + if err != nil { + if os.IsNotExist(err) { + continue + } + return "", fmt.Errorf("inspect tar entry parent: %w", err) + } + if info.Mode()&os.ModeSymlink != 0 { + return "", fmt.Errorf("tar entry traverses symlink: %s", name) + } + if !info.IsDir() { + return "", fmt.Errorf("tar entry parent is not a directory: %s", parent) + } + } + return target, nil +} + +func validateSymlinkTarget(root, target, linkname string) error { + if filepath.IsAbs(linkname) { + return nil + } + resolved, err := securejoin.SecureJoin(filepath.Dir(target), linkname) + if err != nil { + return fmt.Errorf("resolve symlink target: %w", err) + } + root = filepath.Clean(root) + if resolved != root && !strings.HasPrefix(resolved, root+string(filepath.Separator)) { + return fmt.Errorf("symlink target escapes root: %s", linkname) + } + return nil +} + +func extractTarEntry(tr *tar.Reader, header *tar.Header, root, target string) error { + switch header.Typeflag { + case tar.TypeDir: + return extractTarDir(target, header) + 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 clearExisting(target) +} + +func extractTarDir(target string, header *tar.Header) error { + if info, err := os.Lstat(target); err == nil && !info.IsDir() { + if err := clearExisting(target); err != nil { + return err + } + } + if err := os.MkdirAll(target, 0755); err != nil { + return err + } + return applyTarMetadata(target, header) +} + +func extractTarFile(tr *tar.Reader, target string, header *tar.Header) error { + if err := prepareTarTarget(target); err != nil { + return err + } + file, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC|syscall.O_NOFOLLOW, 0644) + if err != nil { + return err + } + if _, err := io.Copy(file, tr); err != nil { + _ = file.Close() + return err + } + if err := file.Close(); err != nil { + return err + } + return applyTarMetadata(target, header) +} + +func extractTarSymlink(root, target string, header *tar.Header) error { + if err := validateSymlinkTarget(root, target, header.Linkname); err != nil { + return err + } + if err := prepareTarTarget(target); err != nil { + return err + } + if err := os.Symlink(header.Linkname, target); err != nil { + return err + } + return applyTarMetadata(target, header) +} + +func extractTarHardlink(root, target string, header *tar.Header) error { + linkTarget, err := safeJoin(root, header.Linkname) + if err != nil { + return err + } + if err := prepareTarTarget(target); err != nil { + return err + } + return os.Link(linkTarget, target) +} + +func extractTarDevice(target string, header *tar.Header) error { + if err := prepareTarTarget(target); err != nil { + return err + } + mode := uint32(syscall.S_IFCHR) + if header.Typeflag == tar.TypeBlock { + mode = uint32(syscall.S_IFBLK) + } + dev := int(unix.Mkdev(uint32(header.Devmajor), uint32(header.Devminor))) + if err := unix.Mknod(target, mode|uint32(header.FileInfo().Mode().Perm()), dev); err != nil { + 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 +} + +func removePath(path string) error { + if err := os.RemoveAll(path); err != nil && !os.IsNotExist(err) { + return err + } + return nil +} + +// clearExisting removes whatever entry occupies path, including non-empty +// directories, so a layer entry of a different type can replace it. +func clearExisting(path string) error { + info, err := os.Lstat(path) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + if info.IsDir() { + return os.RemoveAll(path) + } + return os.Remove(path) +} + +// applyLayerTree merges one unpacked layer directory into targetDir following +// OCI whiteout semantics: whiteouts and opaque markers remove what lower layers +// contributed, then the layer's own entries are copied on top. Raw tar +// whiteout files are interpreted here rather than passed through, because +// overlayfs does not understand them. +func applyLayerTree(layerDir, targetDir string) error { + if err := os.MkdirAll(targetDir, 0755); err != nil { + return fmt.Errorf("create target directory: %w", err) + } + + // Phase 1: apply whiteouts against what is already in the target. + err := filepath.WalkDir(layerDir, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + base := entry.Name() + if path == layerDir || !strings.HasPrefix(base, whiteoutPrefix) { + return nil + } + rel, err := filepath.Rel(layerDir, path) + if err != nil { + return err + } + targetParent, err := safeJoin(targetDir, filepath.Dir(rel)) + if 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", filepath.Join(filepath.Dir(rel), base)) + } + 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. + 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 + } + return copyEntryInto(path, target, hardlinks) + }) + if err != nil { + return fmt.Errorf("copy layer tree: %w", err) + } + return nil +} + +// clearDirContents removes everything inside dir without removing dir itself, +// and without following symlinks. +func clearDirContents(dir string) error { + 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 + } + } + if err := os.MkdirAll(dst, info.Mode().Perm()); err != nil { + return err + } + return copyEntryMetadata(src, dst, info) +} + +func copySymlinkEntry(src, dst string) error { + linkTarget, err := os.Readlink(src) + if err != nil { + return err + } + if err := removePath(dst); err != nil { + return err + } + if err := os.Symlink(linkTarget, dst); err != nil { + return err + } + return nil +} + +func copySpecialEntry(src, dst string, info os.FileInfo) error { + if err := removePath(dst); err != nil { + return err + } + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return fmt.Errorf("unsupported entry type for %s", src) + } + mode, err := specialFileMode(info.Mode() & fs.ModeType) + if err != nil { + return fmt.Errorf("unsupported entry type for %s", src) + } + if err := unix.Mknod(dst, mode|uint32(info.Mode().Perm()), int(stat.Rdev)); err != nil { + return err + } + return copyEntryMetadata(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 _, name := range strings.Split(strings.TrimSuffix(string(names), "\\x00"), "\\x00") { + if name == "" { + continue + } + size, err := unix.Lgetxattr(src, name, nil) + if err != nil { + if errors.Is(err, unix.ENOTSUP) || errors.Is(err, unix.EPERM) || errors.Is(err, unix.ENODATA) { + continue + } + return err + } + value := make([]byte, size) + if _, err := unix.Lgetxattr(src, name, value); err != nil { + return err + } + if err := unix.Lsetxattr(dst, name, value, 0); err != nil && !errors.Is(err, unix.ENOTSUP) && !errors.Is(err, unix.EPERM) { + return err + } + } + return nil +} + +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..79d00e01b --- /dev/null +++ b/lib/images/layer_artifact_test.go @@ -0,0 +1,283 @@ +package images + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "io" + "io/fs" + "os" + "path/filepath" + "strings" + "testing" + + gcr "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/empty" + "github.com/google/go-containerregistry/pkg/v1/layout" + "github.com/google/go-containerregistry/pkg/v1/mutate" + "github.com/google/go-containerregistry/pkg/v1/tarball" + "github.com/kernel/hypeman/lib/paths" + "github.com/stretchr/testify/require" +) + +// writeLayerTestLayout writes img into the shared OCI cache of p tagged with +// the image's digest, mirroring pullToOCILayout. +func writeLayerTestLayout(t *testing.T, p *paths.Paths, img gcr.Image) { + t.Helper() + digest, err := img.Digest() + require.NoError(t, err) + layoutPath, err := layout.Write(p.SystemOCICache(), empty.Index) + require.NoError(t, err) + require.NoError(t, layoutPath.AppendImage(img, layout.WithAnnotations(map[string]string{ + "org.opencontainers.image.ref.name": digestToLayoutTag(digest.String()), + }))) +} + +func layerDescFromImage(t *testing.T, img gcr.Image, index int) layerDescriptor { + t.Helper() + manifest, err := img.Manifest() + require.NoError(t, err) + configFile, err := img.ConfigFile() + require.NoError(t, err) + layer := manifest.Layers[index] + return layerDescriptor{ + Digest: layer.Digest.String(), + Size: layer.Size, + MediaType: string(layer.MediaType), + DiffID: configFile.RootFS.DiffIDs[index].String(), + } +} + +func TestMaterializeLayerArtifact(t *testing.T) { + p := paths.New(t.TempDir()) + img, err := mutate.AppendLayers(empty.Image, syntheticLayer(t, "base.txt", "base layer content")) + require.NoError(t, err) + writeLayerTestLayout(t, p, img) + + desc := layerDescFromImage(t, img, 0) + m := &manager{paths: p} + + record, err := m.materializeLayerArtifact(desc) + require.NoError(t, err) + require.Equal(t, desc.Digest, record.Digest) + require.Equal(t, desc.DiffID, record.DiffID) + require.Equal(t, layerFormatErofs, record.Format) + require.Greater(t, record.SizeBytes, int64(0)) + require.Greater(t, record.UnpackedBytes, int64(0)) + require.Greater(t, record.Entries, 0) + + layerHex := desc.Digest[len("sha256:"):] + _, err = os.Stat(p.ImageLayerArtifact(layerHex)) + require.NoError(t, err, "layer.erofs must be installed") + + // A second materialization reuses the existing artifact. + artifactInfo, err := os.Stat(p.ImageLayerArtifact(layerHex)) + require.NoError(t, err) + reused, err := m.materializeLayerArtifact(desc) + require.NoError(t, err) + require.True(t, record.CreatedAt.Equal(reused.CreatedAt), "reuse must return the stored record") + artifactInfoAfter, err := os.Stat(p.ImageLayerArtifact(layerHex)) + require.NoError(t, err) + require.Equal(t, artifactInfo.ModTime(), artifactInfoAfter.ModTime(), "reuse must not rebuild") +} + +func TestMaterializeLayerArtifactMissingBlob(t *testing.T) { + p := paths.New(t.TempDir()) + m := &manager{paths: p} + + _, err := m.materializeLayerArtifact(layerDescriptor{ + Digest: "sha256:abababababababababababababababababababababababababababababababab", + MediaType: "application/vnd.oci.image.layer.v1.tar+gzip", + }) + require.ErrorContains(t, err, "missing from oci cache") +} + +// whiteoutLayer builds a gzipped tar layer exercising whiteouts: a plain file, +// a whiteout marker, an opaque directory marker, and a whiteout+recreate pair. +func whiteoutLayer(t *testing.T) gcr.Layer { + t.Helper() + + var buf bytes.Buffer + gzw := gzip.NewWriter(&buf) + tw := tar.NewWriter(gzw) + + writeEntry := func(header *tar.Header, content string) { + require.NoError(t, tw.WriteHeader(header)) + if content != "" { + _, err := tw.Write([]byte(content)) + require.NoError(t, err) + } + } + writeEntry(&tar.Header{Name: "keep.txt", Typeflag: tar.TypeReg, Mode: 0644, Size: 4}, "keep") + writeEntry(&tar.Header{Name: "gone/", Typeflag: tar.TypeDir, Mode: 0755}, "") + writeEntry(&tar.Header{Name: "gone/.wh.deleted.txt", Typeflag: tar.TypeReg, Mode: 0644, Size: 0}, "") + writeEntry(&tar.Header{Name: "opq/", Typeflag: tar.TypeDir, Mode: 0755}, "") + writeEntry(&tar.Header{Name: "opq/.wh..wh..opq", Typeflag: tar.TypeReg, Mode: 0644, Size: 0}, "") + writeEntry(&tar.Header{Name: "opq/fresh.txt", Typeflag: tar.TypeReg, Mode: 0644, Size: 5}, "fresh") + writeEntry(&tar.Header{Name: "added/", Typeflag: tar.TypeDir, Mode: 0755}, "") + writeEntry(&tar.Header{Name: "added/.wh.foo", Typeflag: tar.TypeReg, Mode: 0644, Size: 0}, "") + writeEntry(&tar.Header{Name: "added/foo", Typeflag: tar.TypeReg, Mode: 0644, Size: 3}, "new") + + require.NoError(t, tw.Close()) + require.NoError(t, gzw.Close()) + + data := buf.Bytes() + layer, err := tarball.LayerFromOpener(func() (io.ReadCloser, error) { + return io.NopCloser(bytes.NewReader(data)), nil + }) + require.NoError(t, err) + return layer +} + +func TestMaterializeLayerRecordsWhiteouts(t *testing.T) { + p := paths.New(t.TempDir()) + img, err := mutate.AppendLayers(empty.Image, whiteoutLayer(t)) + require.NoError(t, err) + writeLayerTestLayout(t, p, img) + + desc := layerDescFromImage(t, img, 0) + m := &manager{paths: p} + + record, err := m.materializeLayerArtifact(desc) + require.NoError(t, err) + + require.Contains(t, record.Whiteouts, whiteoutRecord{Dir: "gone", Target: "deleted.txt"}) + require.Contains(t, record.Whiteouts, whiteoutRecord{Dir: "opq", Opaque: true}) + require.Contains(t, record.Whiteouts, whiteoutRecord{Dir: "added", Target: "foo"}) + + // Opaque and whiteout markers are recorded distinctly. + opaqueCount := 0 + for _, whiteout := range record.Whiteouts { + if whiteout.Opaque { + opaqueCount++ + require.Empty(t, whiteout.Target) + } + } + require.Equal(t, 1, opaqueCount) +} + +func TestApplyLayerTreeWhiteoutSemantics(t *testing.T) { + root := t.TempDir() + targetDir := filepath.Join(root, "target") + layerDir := filepath.Join(root, "layer") + + // Lower state contributed by earlier layers. + require.NoError(t, os.MkdirAll(filepath.Join(targetDir, "opqdir"), 0755)) + require.NoError(t, os.MkdirAll(filepath.Join(targetDir, "swapdir"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(targetDir, "victim.txt"), []byte("old"), 0644)) + require.NoError(t, os.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 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) +} diff --git a/lib/paths/paths.go b/lib/paths/paths.go index bc9403cd6..0d747b023 100644 --- a/lib/paths/paths.go +++ b/lib/paths/paths.go @@ -177,6 +177,33 @@ func (p *Paths) ImageRepositoryTagSymlink(repository, tag string) string { return filepath.Join(p.ImageRepositoriesDir(), repository, tag) } +// ImageLayersDir returns the root directory of the per-layer artifact store. +// Layer artifacts are content-addressed by the compressed layer blob digest. +func (p *Paths) ImageLayersDir() string { + return filepath.Join(p.dataDir, "images", "layers") +} + +// ImageLayerDir returns the artifact directory for one layer digest. +func (p *Paths) ImageLayerDir(layerHex string) string { + return filepath.Join(p.ImageLayersDir(), layerHex) +} + +// ImageLayerArtifact returns the path to the default materialized layer artifact. +func (p *Paths) ImageLayerArtifact(layerHex string) string { + return p.ImageLayerArtifactForFormat(layerHex, "erofs") +} + +// ImageLayerArtifactForFormat returns the path to a materialized layer artifact. +func (p *Paths) ImageLayerArtifactForFormat(layerHex, format string) string { + return filepath.Join(p.ImageLayerDir(layerHex), "layer."+format) +} + +// ImageLayerRecord returns the path to the artifact record describing one +// 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)