osbase/oci: add package

This adds the oci package, which contains types and tools for working
with OCI images.

Change-Id: Ie2a1d82c7ac007f5d1ad47666880dbf8a8bd931d
Reviewed-on: https://review.monogon.dev/c/monogon/+/4085
Tested-by: Jenkins CI
Reviewed-by: Lorenz Brun <lorenz@monogon.tech>
diff --git a/osbase/oci/layout.go b/osbase/oci/layout.go
new file mode 100644
index 0000000..128c4d1
--- /dev/null
+++ b/osbase/oci/layout.go
@@ -0,0 +1,152 @@
+// Copyright The Monogon Project Authors.
+// SPDX-License-Identifier: Apache-2.0
+
+package oci
+
+import (
+	"encoding/json"
+	"fmt"
+	"io"
+	"os"
+	"path"
+	"path/filepath"
+
+	"github.com/opencontainers/go-digest"
+	ocispec "github.com/opencontainers/image-spec/specs-go"
+	ocispecv1 "github.com/opencontainers/image-spec/specs-go/v1"
+
+	"source.monogon.dev/osbase/structfs"
+)
+
+// ReadLayout reads an image from an OS path to an OCI layout directory.
+func ReadLayout(path string) (*Image, error) {
+	// Read the oci-layout marker file.
+	layoutBytes, err := os.ReadFile(filepath.Join(path, "oci-layout"))
+	if err != nil {
+		return nil, err
+	}
+	layout := ocispecv1.ImageLayout{}
+	err = json.Unmarshal(layoutBytes, &layout)
+	if err != nil {
+		return nil, fmt.Errorf("failed to parse oci-layout: %w", err)
+	}
+	if layout.Version != "1.0.0" {
+		return nil, fmt.Errorf("unknown oci-layout version %q", layout.Version)
+	}
+
+	// Read the index.
+	imageIndexBytes, err := os.ReadFile(filepath.Join(path, "index.json"))
+	if err != nil {
+		return nil, err
+	}
+	imageIndex := ocispecv1.Index{}
+	err = json.Unmarshal(imageIndexBytes, &imageIndex)
+	if err != nil {
+		return nil, fmt.Errorf("failed to parse index.json: %w", err)
+	}
+	if imageIndex.MediaType != ocispecv1.MediaTypeImageIndex {
+		return nil, fmt.Errorf("unknown index.json mediaType %q", imageIndex.MediaType)
+	}
+	if len(imageIndex.Manifests) == 0 {
+		return nil, fmt.Errorf("index.json contains no manifests")
+	}
+	if len(imageIndex.Manifests) != 1 {
+		return nil, fmt.Errorf("index.json files containing multiple manifests are not supported")
+	}
+	manifestDescriptor := &imageIndex.Manifests[0]
+	if manifestDescriptor.MediaType != ocispecv1.MediaTypeImageManifest {
+		return nil, fmt.Errorf("unexpected manifest media type %q", manifestDescriptor.MediaType)
+	}
+
+	// Read the image manifest.
+	imageManifestPath, err := layoutBlobPath(path, manifestDescriptor)
+	if err != nil {
+		return nil, err
+	}
+	imageManifestBytes, err := os.ReadFile(imageManifestPath)
+	if err != nil {
+		return nil, fmt.Errorf("failed to read image manifest: %w", err)
+	}
+
+	blobs := &layoutBlobs{path: path}
+	return NewImage(imageManifestBytes, string(manifestDescriptor.Digest), blobs)
+}
+
+type layoutBlobs struct {
+	path string
+}
+
+func (r *layoutBlobs) Blob(descriptor *ocispecv1.Descriptor) (io.ReadCloser, error) {
+	blobPath, err := layoutBlobPath(r.path, descriptor)
+	if err != nil {
+		return nil, err
+	}
+	return os.Open(blobPath)
+}
+
+func layoutBlobPath(layoutPath string, descriptor *ocispecv1.Descriptor) (string, error) {
+	algorithm, encoded, err := ParseDigest(string(descriptor.Digest))
+	if err != nil {
+		return "", fmt.Errorf("failed to parse digest in image manifest: %w", err)
+	}
+	return filepath.Join(layoutPath, "blobs", algorithm, encoded), nil
+}
+
+// CreateLayout builds an OCI layout from an Image.
+func CreateLayout(image *Image) (structfs.Tree, error) {
+	// Build the index.
+	artifactType := image.Manifest.Config.MediaType
+	if artifactType == ocispecv1.MediaTypeImageConfig {
+		artifactType = ""
+	}
+	imageIndex := ocispecv1.Index{
+		Versioned: ocispec.Versioned{SchemaVersion: 2},
+		MediaType: ocispecv1.MediaTypeImageIndex,
+		Manifests: []ocispecv1.Descriptor{{
+			MediaType:    ocispecv1.MediaTypeImageManifest,
+			ArtifactType: artifactType,
+			Digest:       digest.Digest(image.ManifestDigest),
+			Size:         int64(len(image.RawManifest)),
+		}},
+	}
+	imageIndexBytes, err := json.MarshalIndent(imageIndex, "", "\t")
+	if err != nil {
+		return nil, fmt.Errorf("failed to marshal image index: %w", err)
+	}
+	imageIndexBytes = append(imageIndexBytes, '\n')
+
+	root := structfs.Tree{
+		structfs.File("oci-layout", structfs.Bytes(`{"imageLayoutVersion": "1.0.0"}`+"\n")),
+		structfs.File("index.json", structfs.Bytes(imageIndexBytes)),
+	}
+
+	algorithm, encoded, err := ParseDigest(image.ManifestDigest)
+	if err != nil {
+		return nil, fmt.Errorf("failed to parse manifest digest: %w", err)
+	}
+	imageManifestPath := path.Join("blobs", algorithm, encoded)
+	err = root.PlaceFile(imageManifestPath, structfs.Bytes(image.RawManifest))
+	if err != nil {
+		return nil, err
+	}
+
+	hasBlob := map[string]bool{}
+	for descriptor := range image.Descriptors() {
+		algorithm, encoded, err := ParseDigest(string(descriptor.Digest))
+		if err != nil {
+			return nil, fmt.Errorf("failed to parse digest in image manifest: %w", err)
+		}
+		blobPath := path.Join("blobs", algorithm, encoded)
+		if hasBlob[blobPath] {
+			// If multiple blobs have the same hash, we only need the first one.
+			continue
+		}
+		hasBlob[blobPath] = true
+		err = root.PlaceFile(blobPath, image.StructfsBlob(descriptor))
+		if err != nil {
+			return nil, err
+		}
+	}
+
+	return root, nil
+}