diff --git a/lib/images/README.md b/lib/images/README.md index eb2d422be..d2b4aba47 100644 --- a/lib/images/README.md +++ b/lib/images/README.md @@ -65,6 +65,10 @@ Content-addressable storage with tag symlinks (similar to Docker/Unikraft): rootfs.erofs latest -> abc123def456... # Tag symlink to digest 3.18 -> def456abc123... # Another tag + layers/ # Content-addressed per-layer erofs artifacts (layer_artifacts.go) + / # sha256 of the layer's uncompressed tar (rootfs.diff_ids) + layer.erofs # Layer contribution converted with mkfs.erofs -zlz4 + metadata.json # Source blob digest, diff id, format, size system/ oci-cache/ # Shared OCI layout for all images index.json # Manifest index with digest-based tags @@ -93,6 +97,48 @@ Content-addressable storage with tag symlinks (similar to Docker/Unikraft): - Orphaned digests are automatically deleted when the last tag referencing them is removed - Symlinks only created after successful build (status: ready) +## Layer Artifacts (layer_artifacts.go) + +`ExportLayerArtifacts` converts individual OCI layers from the shared OCI +cache into content-addressed erofs artifacts under `images/layers//`, +keyed by the layer's diff ID (the sha256 of its uncompressed tar, from the +image config's `rootfs.diff_ids`). A layer shared by any number of images +converts once; the unpacked stream is hashed and verified against the config's +diff ID during export. + +Scope and behavior: + +- Reusable exporter only: the flattened image build path (`buildImage` -> one + rootfs per image digest) and VM boot are unchanged, and nothing is recorded + in image metadata yet. +- Layers with an unsupported media type (e.g. zstd) or that cannot be + unpacked standalone (e.g. hardlinks into an earlier layer) are reported as + skipped, not errors. +- OCI deletion semantics are handled explicitly: a layer containing whiteout + entries (`.wh.`) or opaque-directory markers (`.wh..wh..opq`) deletes + content that lives in earlier layers, which a standalone read-only artifact + cannot express. Such layers are skipped rather than converted, because a + raw tar-to-erofs conversion would silently drop the deletions. +- Each artifact's `metadata.json` is the contract for reading it back: source + blob digest, diff ID, filesystem format and the options it was built with + (erofs `-z` compression, sector alignment), size. +- Requires Linux and `mkfs.erofs`; otherwise returns + `ErrLayerArtifactsUnsupported`. + +Known limitations a future layer-composition step must resolve: + +1. Artifacts hold only what a layer adds; layers that delete are skipped. + Composing images from per-layer artifacts needs a format that can carry + deletions (overlayfs-style or custom), which hasn't been chosen yet. +2. The image -> ordered artifact mapping is returned to the caller but not + persisted; `imageMetadata` gains layer fields once a composition consumer + exists. No separate manifest-metadata change exists yet; the exporter + derives everything it needs from the manifest and config blobs already in + the OCI cache. +3. Artifacts are not reference-counted against the OCI cache GC. Safe today + (an artifact is self-contained once installed), but a GC policy for + `images/layers` is future work. + ## Reference Handling (reference.go) Two types for type-safe image reference handling: diff --git a/lib/images/disk.go b/lib/images/disk.go index 896d10f2a..28a678d84 100644 --- a/lib/images/disk.go +++ b/lib/images/disk.go @@ -119,6 +119,11 @@ func convertToCpio(rootfsDir, outputPath string) (int64, error) { // sectorSize is the block size for disk images (required by Virtualization.framework) const sectorSize = 4096 +// ErofsCompression is the compression algorithm passed to mkfs.erofs via -z. +// It is part of the on-disk contract for erofs images: readers (and future +// layer-artifact consumers) rely on the kernel supporting it at mount time. +const ErofsCompression = "lz4" + // alignToSector rounds size up to the nearest sector boundary func alignToSector(size int64) int64 { if size%sectorSize == 0 { @@ -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.Command("mkfs.erofs", "-z"+ErofsCompression, 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_artifacts.go b/lib/images/layer_artifacts.go new file mode 100644 index 000000000..7992f1de3 --- /dev/null +++ b/lib/images/layer_artifacts.go @@ -0,0 +1,381 @@ +// Per-layer artifact export. +// +// ExportLayerArtifacts reads an image's OCI layers from the shared OCI cache +// and converts each supported layer into a content-addressed erofs artifact +// under images/layers//. Artifacts are keyed by the layer's diff ID +// (the sha256 of its uncompressed tar, from the image config's rootfs.diff_ids), +// which is the canonical identity of a layer's content: the same layer shared +// by any number of images converts once. +// +// Scope: this is the reusable exporter only. It does not change the flattened +// image build path (buildImage still produces one rootfs per image digest), +// does not record anything in image metadata, and does not compose artifacts +// at runtime. Known limitations that a future composition step must resolve +// are documented on ExportLayerArtifacts. +package images + +import ( + "archive/tar" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "os" + "os/exec" + "path" + "runtime" + "strings" + "time" + + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/types" + "github.com/kernel/hypeman/lib/ocicache" + "github.com/kernel/hypeman/lib/paths" + "github.com/opencontainers/umoci/oci/layer" +) + +// ErrLayerArtifactsUnsupported is returned when this host cannot produce +// erofs layer artifacts (non-Linux host or mkfs.erofs not installed). +// Callers may treat it as a soft skip. +var ErrLayerArtifactsUnsupported = errors.New("layer artifact export unsupported") + +// LayerArtifact describes one converted layer. +type LayerArtifact struct { + Index int // position of the layer in the image manifest + LayerDigest string // sha256:... of the compressed layer blob + DiffID string // sha256:... of the uncompressed layer content (artifact key) + ArtifactPath string + SizeBytes int64 + Reused bool // artifact was already present from a previous export +} + +// SkippedLayer describes a layer that was not converted, with the reason. +type SkippedLayer struct { + Index int + LayerDigest string + Reason string +} + +// LayerExportReport is the result of exporting one image's layers. +type LayerExportReport struct { + ImageDigest string + Artifacts []LayerArtifact + Skipped []SkippedLayer +} + +// layerArtifactMetadata is persisted beside each artifact and is the +// contract for reading it back: the source blob identity plus the filesystem +// format and the options it was built with, so a consumer knows how to mount +// or interpret the artifact without re-deriving anything. +type layerArtifactMetadata struct { + LayerDigest string `json:"layer_digest"` + DiffID string `json:"diff_id"` + Format ExportFormat `json:"format"` + Compression string `json:"compression"` // erofs -z algorithm + SectorSize int64 `json:"sector_size"` // artifact padded to this alignment + SizeBytes int64 `json:"size_bytes"` + CreatedAt time.Time `json:"created_at"` +} + +// ExportLayerArtifacts converts the layers of the cached image identified by +// imageDigest into content-addressed erofs artifacts. +// +// Behavior: +// - Layers are identified by diff ID; the uncompressed stream is hashed +// during unpack and must match the config's rootfs.diff_ids entry, the +// same integrity check umoci applies during a full unpack. +// - Layers with an unsupported media type (anything ocicache cannot +// decompress, e.g. zstd) are reported in Skipped, not errors. +// - Layers carrying OCI deletion semantics are reported in Skipped, not +// converted: whiteout entries (.wh.) and opaque-directory markers +// (.wh..wh..opq) delete content that lives in earlier layers, and a +// standalone read-only artifact cannot express that. A raw tar-to-erofs +// conversion of such a layer would silently drop the deletions, so the +// exporter refuses it explicitly instead. +// - Layers that cannot be unpacked standalone are also reported in +// Skipped. This happens when a layer's tar references state from earlier +// layers, most commonly hardlinks to files introduced by a lower layer. +// Their unpacked content is removed; no partial artifact is installed. +// - All other failures (missing blobs, diff ID mismatch, mkfs.erofs errors) +// abort the export. Artifacts installed before the failure are valid and +// reusable because they are content-addressed. +// +// Known limitations, deferred until layer composition is designed: +// - Artifacts hold only what a layer adds; layers that delete are skipped +// (above). Composing images from per-layer artifacts needs a format that +// can carry deletions (overlayfs-style or custom), which has not been +// chosen yet. +// - The mapping from an image to its ordered artifact list is returned to +// the caller but not persisted; imageMetadata gains layer fields once a +// composition consumer exists. +// - Artifacts are not reference-counted against the OCI cache GC. This is +// safe today because an artifact is self-contained once written, but a +// GC policy for images/layers is future work. +func ExportLayerArtifacts(ctx context.Context, p *paths.Paths, imageDigest string) (*LayerExportReport, error) { + if runtime.GOOS != "linux" { + return nil, fmt.Errorf("%w: erofs artifacts require a Linux guest kernel", ErrLayerArtifactsUnsupported) + } + if _, err := exec.LookPath("mkfs.erofs"); err != nil { + return nil, fmt.Errorf("%w: mkfs.erofs not installed: %s", ErrLayerArtifactsUnsupported, err) + } + + img, err := ocicache.ImageFromCache(p, imageDigest) + if err != nil { + return nil, err + } + manifest, err := img.Manifest() + if err != nil { + return nil, fmt.Errorf("read manifest: %w", err) + } + config, err := img.ConfigFile() + if err != nil { + return nil, fmt.Errorf("read config: %w", err) + } + if config.RootFS.Type != "layers" { + return nil, fmt.Errorf("unsupported rootfs.type: %s", config.RootFS.Type) + } + if len(config.RootFS.DiffIDs) != len(manifest.Layers) { + return nil, fmt.Errorf( + "config rootfs.diff_ids has %d entries but manifest has %d layers", + len(config.RootFS.DiffIDs), + len(manifest.Layers), + ) + } + + digestHex := normalizeDigestHex(imageDigest) + report := &LayerExportReport{ + ImageDigest: "sha256:" + digestHex, + Artifacts: make([]LayerArtifact, 0, len(manifest.Layers)), + Skipped: make([]SkippedLayer, 0), + } + + for i, desc := range manifest.Layers { + if err := ctx.Err(); err != nil { + return nil, err + } + diffID := config.RootFS.DiffIDs[i] + + if reason, ok := supportedLayerMediaType(desc.MediaType); !ok { + report.Skipped = append(report.Skipped, SkippedLayer{ + Index: i, + LayerDigest: desc.Digest.String(), + Reason: reason, + }) + continue + } + + artifact, reused, err := exportLayerArtifact(p, img, i, desc, diffID) + if err != nil { + var skipErr *layerSkipError + if errors.As(err, &skipErr) { + report.Skipped = append(report.Skipped, SkippedLayer{ + Index: i, + LayerDigest: desc.Digest.String(), + Reason: skipErr.Error(), + }) + continue + } + return nil, fmt.Errorf("export layer %d (%s): %w", i, desc.Digest, err) + } + artifact.Reused = reused + report.Artifacts = append(report.Artifacts, artifact) + } + + return report, nil +} + +// layerSkipError marks a layer that cannot be represented as a standalone +// artifact (deletion semantics, cross-layer references, unsupported media). +// The exporter reports these in Skipped instead of aborting the whole image. +type layerSkipError struct { + reason string + cause error +} + +func (e *layerSkipError) Error() string { + if e.cause != nil { + return e.reason + ": " + e.cause.Error() + } + return e.reason +} + +func (e *layerSkipError) Unwrap() error { return e.cause } + +// whiteoutPrefix and opaqueWhiteout follow the OCI layer conventions: +// ".wh." deletes from lower layers and ".wh..wh..opq" marks its +// containing directory opaque (all lower-layer children deleted). +const ( + whiteoutPrefix = ".wh." + opaqueWhiteout = ".wh..wh..opq" +) + +// firstWhiteoutMarker scans the uncompressed layer tar and returns the name +// of the first OCI whiteout or opaque-directory entry, or "" when the layer +// carries no deletion semantics. The blob is local; the unpack reads it again +// afterwards. The scan short-circuits on the first marker. +func firstWhiteoutMarker(l v1.Layer) (string, error) { + rc, err := l.Uncompressed() + if err != nil { + return "", err + } + defer rc.Close() + tr := tar.NewReader(rc) + for { + hdr, err := tr.Next() + if err == io.EOF { + return "", nil + } + if err != nil { + return "", fmt.Errorf("read layer tar: %w", err) + } + if base := path.Base(hdr.Name); strings.HasPrefix(base, whiteoutPrefix) { + return hdr.Name, nil + } + } +} + +// supportedLayerMediaType reports whether the OCI cache can serve a layer +// media type uncompressed; when not, it returns the skip reason. Docker v2 +// types are already converted to OCI by ocicache, but both families are +// accepted here. +func supportedLayerMediaType(mediaType types.MediaType) (string, bool) { + switch mediaType { + case types.OCILayer, types.OCIUncompressedLayer, types.OCIRestrictedLayer, + types.DockerLayer, types.DockerUncompressedLayer: + return "", true + default: + return fmt.Sprintf("unsupported layer media type %s", mediaType), false + } +} + +func normalizeDigestHex(digest string) string { + return strings.TrimPrefix(digest, "sha256:") +} + +// exportLayerArtifact converts one layer, returning the installed artifact. +// The bool result reports whether an existing artifact was reused. +func exportLayerArtifact(p *paths.Paths, img v1.Image, index int, desc v1.Descriptor, diffID v1.Hash) (LayerArtifact, bool, error) { + artifactPath := p.LayerArtifactPath(diffID.Hex) + if info, err := os.Stat(artifactPath); err == nil { + // Content-addressed and installed atomically: presence means complete. + // Heal metadata lost to a crash between the artifact and metadata + // installs, but keep an existing file so CreatedAt stays truthful. + metadataPath := p.LayerArtifactMetadata(diffID.Hex) + if _, err := os.Stat(metadataPath); err != nil { + if err := writeLayerArtifactMetadata(p, desc, diffID, info.Size()); err != nil { + return LayerArtifact{}, false, err + } + } + return LayerArtifact{ + Index: index, + LayerDigest: desc.Digest.String(), + DiffID: diffID.String(), + ArtifactPath: artifactPath, + SizeBytes: info.Size(), + }, true, nil + } + + layerReader, err := img.LayerByDigest(desc.Digest) + if err != nil { + return LayerArtifact{}, false, fmt.Errorf("find layer in cache: %w", err) + } + + // Refuse layers with OCI deletion semantics up front: unpacking them + // standalone would apply their whiteouts against an empty root and drop + // the deletions silently. + marker, err := firstWhiteoutMarker(layerReader) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return LayerArtifact{}, false, fmt.Errorf("layer blob missing from OCI cache: %w", err) + } + return LayerArtifact{}, false, fmt.Errorf("scan layer for whiteouts: %w", err) + } + if marker != "" { + return LayerArtifact{}, false, &layerSkipError{ + reason: fmt.Sprintf( + "layer contains OCI whiteout or opaque-directory marker %q: deletions of lower-layer content cannot be represented in a standalone artifact", + marker, + ), + } + } + + uncompressed, err := layerReader.Uncompressed() + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return LayerArtifact{}, false, fmt.Errorf("layer blob missing from OCI cache: %w", err) + } + return LayerArtifact{}, false, fmt.Errorf("open layer: %w", err) + } + + if err := os.MkdirAll(p.LayerArtifactsDir(), 0755); err != nil { + uncompressed.Close() + return LayerArtifact{}, false, fmt.Errorf("create layer artifacts dir: %w", err) + } + unpackDir, err := os.MkdirTemp(p.LayerArtifactsDir(), ".unpack-*") + if err != nil { + uncompressed.Close() + return LayerArtifact{}, false, fmt.Errorf("create unpack dir: %w", err) + } + defer os.RemoveAll(unpackDir) + + // Hash the uncompressed stream as it unpacks so the result can be + // verified against the diff ID declared in the image config. + hasher := sha256.New() + if err := layer.UnpackLayer(unpackDir, io.TeeReader(uncompressed, hasher), rootlessUnpackOptions()); err != nil { + uncompressed.Close() + return LayerArtifact{}, false, &layerSkipError{reason: "cannot unpack layer standalone", cause: err} + } + if err := uncompressed.Close(); err != nil { + return LayerArtifact{}, false, fmt.Errorf("read layer: %w", err) + } + + if got := hex.EncodeToString(hasher.Sum(nil)); got != diffID.Hex { + return LayerArtifact{}, false, fmt.Errorf("diff ID mismatch: unpacked sha256:%s, config declares %s", got, diffID.String()) + } + + var sizeBytes int64 + if err := installAtomically(artifactPath, func(tempPath string) error { + var err error + sizeBytes, err = ExportRootfs(unpackDir, tempPath, FormatErofs) + return err + }); err != nil { + return LayerArtifact{}, false, fmt.Errorf("convert to erofs: %w", err) + } + + if err := writeLayerArtifactMetadata(p, desc, diffID, sizeBytes); err != nil { + return LayerArtifact{}, false, fmt.Errorf("install artifact metadata: %w", err) + } + + return LayerArtifact{ + Index: index, + LayerDigest: desc.Digest.String(), + DiffID: diffID.String(), + ArtifactPath: artifactPath, + SizeBytes: sizeBytes, + }, false, nil +} + +// writeLayerArtifactMetadata installs metadata.json beside the artifact it +// describes. It serves both the fresh-install and the reuse-heal paths. +func writeLayerArtifactMetadata(p *paths.Paths, desc v1.Descriptor, diffID v1.Hash, sizeBytes int64) error { + meta := layerArtifactMetadata{ + LayerDigest: desc.Digest.String(), + DiffID: diffID.String(), + Format: FormatErofs, + Compression: ErofsCompression, + SectorSize: sectorSize, + SizeBytes: sizeBytes, + CreatedAt: time.Now(), + } + return installAtomically(p.LayerArtifactMetadata(diffID.Hex), func(tempPath string) error { + data, err := json.MarshalIndent(&meta, "", " ") + if err != nil { + return fmt.Errorf("marshal artifact metadata: %w", err) + } + return os.WriteFile(tempPath, data, 0644) + }) +} diff --git a/lib/images/layer_artifacts_test.go b/lib/images/layer_artifacts_test.go new file mode 100644 index 000000000..e9b6d2720 --- /dev/null +++ b/lib/images/layer_artifacts_test.go @@ -0,0 +1,450 @@ +package images + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/empty" + "github.com/google/go-containerregistry/pkg/v1/mutate" + "github.com/google/go-containerregistry/pkg/v1/types" + "github.com/kernel/hypeman/lib/ocicache" + "github.com/kernel/hypeman/lib/ocicache/testutil" + "github.com/kernel/hypeman/lib/paths" + "github.com/stretchr/testify/require" +) + +// testLayer is a v1.Layer over explicit byte slices so tests control the +// exact compressed and uncompressed content and media type. +type testLayer struct { + uncompressed []byte + compressed []byte + mediaType types.MediaType +} + +func hashOf(b []byte) v1.Hash { + sum := sha256.Sum256(b) + return v1.Hash{Algorithm: "sha256", Hex: fmt.Sprintf("%x", sum)} +} + +func (l *testLayer) Digest() (v1.Hash, error) { return hashOf(l.compressed), nil } +func (l *testLayer) DiffID() (v1.Hash, error) { return hashOf(l.uncompressed), nil } +func (l *testLayer) Size() (int64, error) { return int64(len(l.compressed)), nil } +func (l *testLayer) MediaType() (types.MediaType, error) { return l.mediaType, nil } +func (l *testLayer) Compressed() (io.ReadCloser, error) { + return io.NopCloser(bytes.NewReader(l.compressed)), nil +} +func (l *testLayer) Uncompressed() (io.ReadCloser, error) { + return io.NopCloser(bytes.NewReader(l.uncompressed)), nil +} + +type tarEntry struct { + name string + body string + typeflag byte + linkname string +} + +func buildTar(t *testing.T, entries []tarEntry) []byte { + t.Helper() + var buf bytes.Buffer + tw := tar.NewWriter(&buf) + for _, e := range entries { + hdr := &tar.Header{ + Name: e.name, + Typeflag: e.typeflag, + Linkname: e.linkname, + ModTime: time.Unix(0, 0), + } + switch e.typeflag { + case tar.TypeDir: + hdr.Mode = 0755 + case tar.TypeReg: + hdr.Mode = 0644 + hdr.Size = int64(len(e.body)) + } + require.NoError(t, tw.WriteHeader(hdr)) + if e.typeflag == tar.TypeReg { + _, err := tw.Write([]byte(e.body)) + require.NoError(t, err) + } + } + require.NoError(t, tw.Close()) + return buf.Bytes() +} + +func gzipBytes(t *testing.T, b []byte) []byte { + t.Helper() + var buf bytes.Buffer + zw := gzip.NewWriter(&buf) + _, err := zw.Write(b) + require.NoError(t, err) + require.NoError(t, zw.Close()) + return buf.Bytes() +} + +func newGzipLayer(t *testing.T, entries []tarEntry) *testLayer { + t.Helper() + uncompressed := buildTar(t, entries) + return &testLayer{ + uncompressed: uncompressed, + compressed: gzipBytes(t, uncompressed), + mediaType: types.OCILayer, + } +} + +func buildTestImage(t *testing.T, layers ...v1.Layer) v1.Image { + t.Helper() + img, err := mutate.AppendLayers(empty.Image, layers...) + require.NoError(t, err) + img = mutate.ConfigMediaType(img, types.OCIConfigJSON) + img = mutate.MediaType(img, types.OCIManifestSchema1) + return img +} + +// writeTestImage stores an image in the OCI cache under p and returns its +// manifest digest and the config diff IDs in layer order. +func writeTestImage(t *testing.T, p *paths.Paths, layers ...v1.Layer) (string, []v1.Hash) { + t.Helper() + img := buildTestImage(t, layers...) + digest, err := testutil.WriteImage(p, img) + require.NoError(t, err) + config, err := img.ConfigFile() + require.NoError(t, err) + return digest, config.RootFS.DiffIDs +} + +func requireErofsTooling(t *testing.T) { + t.Helper() + if runtime.GOOS != "linux" { + t.Skip("erofs layer artifacts require a Linux host") + } + if _, err := exec.LookPath("mkfs.erofs"); err != nil { + t.Skip("mkfs.erofs not installed") + } +} + +// extractErofs extracts an erofs artifact into a fresh directory using +// fsck.erofs. Skips the test when the tool is unavailable. +func extractErofs(t *testing.T, artifactPath string) string { + t.Helper() + if _, err := exec.LookPath("fsck.erofs"); err != nil { + t.Skip("fsck.erofs not installed") + } + outDir := t.TempDir() + cmd := exec.Command("fsck.erofs", "--extract="+outDir, artifactPath) + output, err := cmd.CombinedOutput() + require.NoError(t, err, "fsck.erofs extract: %s", output) + return outDir +} + +func TestExportLayerArtifactsUnsupportedTooling(t *testing.T) { + if runtime.GOOS == "linux" { + if _, err := exec.LookPath("mkfs.erofs"); err == nil { + t.Skip("erofs tooling is available on this host") + } + } + p := paths.New(t.TempDir()) + _, err := ExportLayerArtifacts(context.Background(), p, "sha256:"+strings.Repeat("a", 64)) + require.ErrorIs(t, err, ErrLayerArtifactsUnsupported) +} + +func TestExportLayerArtifactsImageNotFound(t *testing.T) { + requireErofsTooling(t) + p := paths.New(t.TempDir()) + _, err := ExportLayerArtifacts(context.Background(), p, "sha256:"+strings.Repeat("a", 64)) + require.ErrorIs(t, err, ocicache.ErrNotFound) +} + +func TestExportLayerArtifactsHappyPath(t *testing.T) { + requireErofsTooling(t) + p := paths.New(t.TempDir()) + + layer1 := newGzipLayer(t, []tarEntry{ + {name: "base/", typeflag: tar.TypeDir}, + {name: "base/hello.txt", body: "hello", typeflag: tar.TypeReg}, + }) + layer2 := newGzipLayer(t, []tarEntry{ + {name: "upper/", typeflag: tar.TypeDir}, + {name: "upper/world.txt", body: "world", typeflag: tar.TypeReg}, + }) + digest, diffIDs := writeTestImage(t, p, layer1, layer2) + + report, err := ExportLayerArtifacts(context.Background(), p, digest) + require.NoError(t, err) + + require.Equal(t, "sha256:"+strings.TrimPrefix(digest, "sha256:"), report.ImageDigest) + require.Empty(t, report.Skipped) + require.Len(t, report.Artifacts, 2) + + for i, artifact := range report.Artifacts { + diffID := diffIDs[i] + require.Equal(t, i, artifact.Index) + require.Equal(t, diffID.String(), artifact.DiffID) + require.Equal(t, p.LayerArtifactPath(diffID.Hex), artifact.ArtifactPath) + require.False(t, artifact.Reused) + + info, err := os.Stat(artifact.ArtifactPath) + require.NoError(t, err) + require.Equal(t, info.Size(), artifact.SizeBytes) + require.Greater(t, artifact.SizeBytes, int64(0)) + + data, err := os.ReadFile(p.LayerArtifactMetadata(diffID.Hex)) + require.NoError(t, err) + var meta layerArtifactMetadata + require.NoError(t, json.Unmarshal(data, &meta)) + require.Equal(t, artifact.LayerDigest, meta.LayerDigest) + require.Equal(t, diffID.String(), meta.DiffID) + require.Equal(t, FormatErofs, meta.Format) + require.Equal(t, ErofsCompression, meta.Compression) + require.Equal(t, int64(sectorSize), meta.SectorSize) + require.Equal(t, artifact.SizeBytes, meta.SizeBytes) + } + + // Artifact content matches the layer's contribution. + layer1Root := extractErofs(t, report.Artifacts[0].ArtifactPath) + content, err := os.ReadFile(filepath.Join(layer1Root, "base", "hello.txt")) + require.NoError(t, err) + require.Equal(t, "hello", string(content)) + layer2Root := extractErofs(t, report.Artifacts[1].ArtifactPath) + content, err = os.ReadFile(filepath.Join(layer2Root, "upper", "world.txt")) + require.NoError(t, err) + require.Equal(t, "world", string(content)) + + // The exporter must not touch the flattened image layouts. + _, err = os.Stat(filepath.Join(p.ImagesDir(), "content")) + require.True(t, os.IsNotExist(err)) +} + +func TestExportLayerArtifactsReusesExistingArtifacts(t *testing.T) { + requireErofsTooling(t) + p := paths.New(t.TempDir()) + + layer := newGzipLayer(t, []tarEntry{ + {name: "a.txt", body: "a", typeflag: tar.TypeReg}, + }) + digest, _ := writeTestImage(t, p, layer) + + first, err := ExportLayerArtifacts(context.Background(), p, digest) + require.NoError(t, err) + require.Len(t, first.Artifacts, 1) + require.False(t, first.Artifacts[0].Reused) + + // Reuse heals metadata lost between the artifact and metadata installs. + require.NoError(t, os.Remove(p.LayerArtifactMetadata(strings.TrimPrefix(first.Artifacts[0].DiffID, "sha256:")))) + + second, err := ExportLayerArtifacts(context.Background(), p, digest) + require.NoError(t, err) + require.Len(t, second.Artifacts, 1) + require.True(t, second.Artifacts[0].Reused) + require.Equal(t, first.Artifacts[0].SizeBytes, second.Artifacts[0].SizeBytes) + _, err = os.Stat(p.LayerArtifactMetadata(strings.TrimPrefix(first.Artifacts[0].DiffID, "sha256:"))) + require.NoError(t, err) + + // No unpack scratch directories are left behind. + entries, err := os.ReadDir(p.LayerArtifactsDir()) + require.NoError(t, err) + for _, entry := range entries { + require.False(t, strings.HasPrefix(entry.Name(), ".unpack-"), "leftover scratch dir %s", entry.Name()) + } +} + +func TestExportLayerArtifactsSkipsUnsupportedMediaType(t *testing.T) { + requireErofsTooling(t) + p := paths.New(t.TempDir()) + + supported := newGzipLayer(t, []tarEntry{ + {name: "a.txt", body: "a", typeflag: tar.TypeReg}, + }) + // The exporter decides by media type before reading the blob, so the + // body never needs to be valid zstd. + zstdUncompressed := buildTar(t, []tarEntry{ + {name: "b.txt", body: "b", typeflag: tar.TypeReg}, + }) + zstd := &testLayer{ + uncompressed: zstdUncompressed, + compressed: zstdUncompressed, + mediaType: types.OCILayerZStd, + } + digest, _ := writeTestImage(t, p, supported, zstd) + + report, err := ExportLayerArtifacts(context.Background(), p, digest) + require.NoError(t, err) + require.Len(t, report.Artifacts, 1) + require.Equal(t, 0, report.Artifacts[0].Index) + _, err = os.Stat(report.Artifacts[0].ArtifactPath) + require.NoError(t, err) + + require.Len(t, report.Skipped, 1) + require.Equal(t, 1, report.Skipped[0].Index) + require.Contains(t, report.Skipped[0].Reason, string(types.OCILayerZStd)) +} + +func TestExportLayerArtifactsSkipsCrossLayerHardlink(t *testing.T) { + requireErofsTooling(t) + p := paths.New(t.TempDir()) + + layer1 := newGzipLayer(t, []tarEntry{ + {name: "base.txt", body: "base", typeflag: tar.TypeReg}, + }) + // Hardlinks resolve against the unpack root only; the target lives in an + // earlier layer, so this layer cannot be unpacked standalone. + layer2 := newGzipLayer(t, []tarEntry{ + {name: "link.txt", typeflag: tar.TypeLink, linkname: "base.txt"}, + }) + digest, diffIDs := writeTestImage(t, p, layer1, layer2) + + report, err := ExportLayerArtifacts(context.Background(), p, digest) + require.NoError(t, err) + + require.Len(t, report.Artifacts, 1) + require.Equal(t, 0, report.Artifacts[0].Index) + require.Len(t, report.Skipped, 1) + require.Equal(t, 1, report.Skipped[0].Index) + require.Contains(t, report.Skipped[0].Reason, "cannot unpack layer standalone") + + // A failed layer must not leave a partial artifact behind. + _, err = os.Stat(p.LayerArtifactPath(diffIDs[1].Hex)) + require.True(t, os.IsNotExist(err)) +} + +func TestExportLayerArtifactsSkipsWhiteoutLayer(t *testing.T) { + requireErofsTooling(t) + p := paths.New(t.TempDir()) + + layer1 := newGzipLayer(t, []tarEntry{ + {name: "base.txt", body: "base", typeflag: tar.TypeReg}, + }) + // Whiteout of a path introduced by the lower layer. The deletion applies + // to layer 1's content, which a standalone artifact of layer 2 cannot + // express, so the layer must be skipped rather than silently losing it. + layer2 := newGzipLayer(t, []tarEntry{ + {name: ".wh.base.txt", typeflag: tar.TypeReg}, + {name: "new.txt", body: "new", typeflag: tar.TypeReg}, + }) + digest, diffIDs := writeTestImage(t, p, layer1, layer2) + + report, err := ExportLayerArtifacts(context.Background(), p, digest) + require.NoError(t, err) + + require.Len(t, report.Artifacts, 1) + require.Equal(t, 0, report.Artifacts[0].Index) + require.Len(t, report.Skipped, 1) + require.Equal(t, 1, report.Skipped[0].Index) + require.Contains(t, report.Skipped[0].Reason, ".wh.base.txt") + require.Contains(t, report.Skipped[0].Reason, "whiteout") + + _, err = os.Stat(p.LayerArtifactPath(diffIDs[1].Hex)) + require.True(t, os.IsNotExist(err)) +} + +func TestExportLayerArtifactsSkipsOpaqueDirectoryLayer(t *testing.T) { + requireErofsTooling(t) + p := paths.New(t.TempDir()) + + layer1 := newGzipLayer(t, []tarEntry{ + {name: "conf/", typeflag: tar.TypeDir}, + {name: "conf/old.txt", body: "old", typeflag: tar.TypeReg}, + }) + // An opaque-directory marker clears every lower-layer child of conf/. + layer2 := newGzipLayer(t, []tarEntry{ + {name: "conf/", typeflag: tar.TypeDir}, + {name: "conf/.wh..wh..opq", typeflag: tar.TypeReg}, + {name: "conf/new.txt", body: "new", typeflag: tar.TypeReg}, + }) + digest, diffIDs := writeTestImage(t, p, layer1, layer2) + + report, err := ExportLayerArtifacts(context.Background(), p, digest) + require.NoError(t, err) + + require.Len(t, report.Artifacts, 1) + require.Equal(t, 0, report.Artifacts[0].Index) + require.Len(t, report.Skipped, 1) + require.Equal(t, 1, report.Skipped[0].Index) + require.Contains(t, report.Skipped[0].Reason, opaqueWhiteout) + + _, err = os.Stat(p.LayerArtifactPath(diffIDs[1].Hex)) + require.True(t, os.IsNotExist(err)) +} + +func TestExportLayerArtifactsSkipsSingleLayerWithWhiteout(t *testing.T) { + requireErofsTooling(t) + p := paths.New(t.TempDir()) + + // A single-layer image whose only layer carries a whiteout still has + // deletion semantics recorded in the tar, so it is skipped too: the + // exporter classifies by marker presence, not by whether the target + // happens to exist. + layer := newGzipLayer(t, []tarEntry{ + {name: ".wh.gone.txt", typeflag: tar.TypeReg}, + {name: "a.txt", body: "a", typeflag: tar.TypeReg}, + }) + digest, diffIDs := writeTestImage(t, p, layer) + + report, err := ExportLayerArtifacts(context.Background(), p, digest) + require.NoError(t, err) + require.Empty(t, report.Artifacts) + require.Len(t, report.Skipped, 1) + require.Contains(t, report.Skipped[0].Reason, ".wh.gone.txt") + + _, err = os.Stat(p.LayerArtifactPath(diffIDs[0].Hex)) + require.True(t, os.IsNotExist(err)) +} + +func TestExportLayerArtifactsDiffIDMismatch(t *testing.T) { + requireErofsTooling(t) + p := paths.New(t.TempDir()) + + layer := newGzipLayer(t, []tarEntry{ + {name: "a.txt", body: "a", typeflag: tar.TypeReg}, + }) + digest, diffIDs := writeTestImage(t, p, layer) + + // Corrupt the cached blob with different-but-valid layer content: the + // unpack succeeds but the stream no longer hashes to the config's diff ID. + other := newGzipLayer(t, []tarEntry{ + {name: "b.txt", body: "b", typeflag: tar.TypeReg}, + }) + layerDigest, err := layer.Digest() + require.NoError(t, err) + require.NoError(t, os.WriteFile(p.OCICacheBlob(layerDigest.Hex), other.compressed, 0644)) + + _, err = ExportLayerArtifacts(context.Background(), p, digest) + require.Error(t, err) + require.Contains(t, err.Error(), "diff ID mismatch") + + // A failed verification must not install an artifact. + _, err = os.Stat(p.LayerArtifactPath(diffIDs[0].Hex)) + require.True(t, os.IsNotExist(err)) +} + +func TestExportLayerArtifactsMissingLayerBlob(t *testing.T) { + requireErofsTooling(t) + p := paths.New(t.TempDir()) + + layer := newGzipLayer(t, []tarEntry{ + {name: "a.txt", body: "a", typeflag: tar.TypeReg}, + }) + digest, _ := writeTestImage(t, p, layer) + + layerDigest, err := layer.Digest() + require.NoError(t, err) + require.NoError(t, os.Remove(p.OCICacheBlob(layerDigest.Hex))) + + _, err = ExportLayerArtifacts(context.Background(), p, digest) + require.Error(t, err) + require.Contains(t, err.Error(), "layer blob missing") +} diff --git a/lib/images/oci.go b/lib/images/oci.go index 33206d134..1d044867d 100644 --- a/lib/images/oci.go +++ b/lib/images/oci.go @@ -510,10 +510,23 @@ func (c *ociClient) unpackLayers(ctx context.Context, layoutTag, targetDir strin // Unpack layers using umoci's layer package with rootless mode // Map container UIDs to current user's UID (identity mapping) + unpackOpts := rootlessUnpackOptions() + + err = layer.UnpackRootfs(ctx, casEngine, targetDir, ociManifest, unpackOpts) + if err != nil { + return fmt.Errorf("unpack rootfs: %w", err) + } + + return nil +} + +// rootlessUnpackOptions builds the umoci unpack options hypeman uses for OCI +// layer extraction: rootless, with container root mapped to the current user +// so chown is never required. +func rootlessUnpackOptions() *layer.UnpackOptions { uid := uint32(os.Getuid()) gid := uint32(os.Getgid()) - - unpackOpts := &layer.UnpackOptions{ + return &layer.UnpackOptions{ OnDiskFormat: layer.DirRootfs{ MapOptions: layer.MapOptions{ Rootless: true, // Don't fail on chown errors @@ -526,13 +539,6 @@ func (c *ociClient) unpackLayers(ctx context.Context, layoutTag, targetDir strin }, }, } - - err = layer.UnpackRootfs(ctx, casEngine, targetDir, ociManifest, unpackOpts) - if err != nil { - return fmt.Errorf("unpack rootfs: %w", err) - } - - return nil } // validateConfigFileForUnpack rejects malformed image configs before calling diff --git a/lib/paths/paths.go b/lib/paths/paths.go index 814dc1432..a44d2ec2c 100644 --- a/lib/paths/paths.go +++ b/lib/paths/paths.go @@ -161,6 +161,27 @@ func (p *Paths) ImageContentMetadata(digestHex string) string { return filepath.Join(p.ImageContentDir(digestHex), "metadata.json") } +// LayerArtifactsDir returns the root directory for content-addressed layer artifacts. +func (p *Paths) LayerArtifactsDir() string { + return filepath.Join(p.dataDir, "images", "layers") +} + +// LayerArtifactDir returns the directory for one layer artifact, keyed by the +// layer's diff ID hex (the sha256 of its uncompressed tar). +func (p *Paths) LayerArtifactDir(diffHex string) string { + return filepath.Join(p.LayerArtifactsDir(), diffHex) +} + +// LayerArtifactPath returns the path to a layer's erofs artifact file. +func (p *Paths) LayerArtifactPath(diffHex string) string { + return filepath.Join(p.LayerArtifactDir(diffHex), "layer.erofs") +} + +// LayerArtifactMetadata returns the path to a layer artifact's metadata.json. +func (p *Paths) LayerArtifactMetadata(diffHex string) string { + return filepath.Join(p.LayerArtifactDir(diffHex), "metadata.json") +} + // ImageRepositoriesDir returns the root directory for repository tag references. func (p *Paths) ImageRepositoriesDir() string { return filepath.Join(p.dataDir, "images", "repositories")