osbase/oci: add end-to-end tests
This adds some end-to-end tests of the OCI OS image generation and
consumption implementations.
Change-Id: Id9f4e3ab5b2c959807657e06990525810d4979ff
Reviewed-on: https://review.monogon.dev/c/monogon/+/4092
Reviewed-by: Tim Windelschmidt <tim@monogon.tech>
Tested-by: Jenkins CI
diff --git a/osbase/oci/osimage/osimage_test.go b/osbase/oci/osimage/osimage_test.go
new file mode 100644
index 0000000..6ee8afa
--- /dev/null
+++ b/osbase/oci/osimage/osimage_test.go
@@ -0,0 +1,270 @@
+// Copyright The Monogon Project Authors.
+// SPDX-License-Identifier: Apache-2.0
+
+package osimage
+
+import (
+ "bytes"
+ "context"
+ "crypto/sha256"
+ "fmt"
+ "io"
+ "net"
+ "net/http"
+ "os"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/bazelbuild/rules_go/go/runfiles"
+ "github.com/cenkalti/backoff/v4"
+ ocispecv1 "github.com/opencontainers/image-spec/specs-go/v1"
+
+ "source.monogon.dev/osbase/oci"
+ "source.monogon.dev/osbase/oci/registry"
+)
+
+var (
+ // These are filled by bazel at linking time with the canonical path of
+ // their corresponding file. Inside the init function we resolve it
+ // with the rules_go runfiles package to the real path.
+ xImagePath string
+ xImageUncompressedPath string
+ xTestPayloadPath string
+)
+
+func init() {
+ var err error
+ for _, path := range []*string{
+ &xImagePath, &xImageUncompressedPath, &xTestPayloadPath,
+ } {
+ *path, err = runfiles.Rlocation(*path)
+ if err != nil {
+ panic(err)
+ }
+ }
+}
+
+var expectedPayloadHash [32]byte
+var expectedPayloadLen int64
+
+func init() {
+ expectedPayload, err := os.ReadFile(xTestPayloadPath)
+ if err != nil {
+ panic(err)
+ }
+ expectedPayloadHash = sha256.Sum256(expectedPayload)
+ expectedPayloadLen = int64(len(expectedPayload))
+}
+
+func TestRead(t *testing.T) {
+ testCases := []struct {
+ desc string
+ path string
+ }{
+ {"compressed", xImagePath},
+ {"uncompressed", xImageUncompressedPath},
+ }
+ for _, tC := range testCases {
+ t.Run(tC.desc, func(t *testing.T) {
+ image, err := oci.ReadLayout(tC.path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ osImage, err := Read(image)
+ if err != nil {
+ t.Fatal(err)
+ }
+ payload, err := osImage.Payload("test")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got, want := payload.Size(), expectedPayloadLen; got != want {
+ t.Errorf("payload has size %d, expected %d", got, want)
+ }
+ reader, err := payload.Open()
+ if err != nil {
+ t.Fatal(err)
+ }
+ content, err := io.ReadAll(reader)
+ if err != nil {
+ t.Fatal(err)
+ }
+ contentHash := sha256.Sum256(content)
+ if contentHash != expectedPayloadHash {
+ t.Errorf("Payload read through Image does not match expected content, expected %x, got %x", expectedPayloadHash, contentHash)
+ }
+ if err := reader.Close(); err != nil {
+ t.Error(err)
+ }
+ })
+ }
+}
+
+func TestVerification(t *testing.T) {
+ server := registry.NewServer()
+ srcImage, err := oci.ReadLayout(xImageUncompressedPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ server.AddImage("test/repo", "test-tag", srcImage)
+ corrupter := &corruptingServer{handler: server}
+
+ listener, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer listener.Close()
+ go http.Serve(listener, corrupter)
+
+ client := ®istry.Client{
+ GetBackOff: func() backoff.BackOff {
+ return backoff.NewExponentialBackOff()
+ },
+ RetryNotify: func(err error, _ time.Duration) {
+ t.Errorf("Unexpected retry; verification errors should not trigger retries: %v", err)
+ },
+ Scheme: "http",
+ Host: listener.Addr().String(),
+ Repository: "test/repo",
+ }
+
+ // Test manifest verification
+ corrupter.affectedPath = "/v2/test/repo/manifests/test-tag"
+ _, err = client.Read(context.Background(), "test-tag", "")
+ if err != nil {
+ t.Errorf("Expected reading manifest to succeed when digest not given: %v", err)
+ }
+ _, err = client.Read(context.Background(), "test-tag", srcImage.ManifestDigest)
+ if !strings.Contains(fmt.Sprintf("%v", err), "failed verification") {
+ t.Errorf("Expected failed verification, got %v", err)
+ }
+
+ // Test config verification
+ corrupter.affectedPath = fmt.Sprintf("/v2/test/repo/blobs/%s", srcImage.Manifest.Config.Digest)
+ image, err := client.Read(context.Background(), "test-tag", srcImage.ManifestDigest)
+ if err != nil {
+ t.Fatal(err)
+ }
+ _, err = Read(image)
+ if !strings.Contains(fmt.Sprintf("%v", err), "failed verification") {
+ t.Errorf("Expected failed verification, got %v", err)
+ }
+
+ // Test payload verification
+ corrupter.affectedPath = fmt.Sprintf("/v2/test/repo/blobs/%s", srcImage.Manifest.Layers[0].Digest)
+ image, err = client.Read(context.Background(), "test-tag", srcImage.ManifestDigest)
+ if err != nil {
+ t.Fatal(err)
+ }
+ osImage, err := Read(image)
+ if err != nil {
+ t.Fatal(err)
+ }
+ payload, err := osImage.Payload("test")
+ if err != nil {
+ t.Fatal(err)
+ }
+ reader, err := payload.Open()
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer reader.Close()
+ content, err := io.ReadAll(reader)
+ if !strings.Contains(fmt.Sprintf("%v", err), "payload failed verification") {
+ t.Errorf("Expected failed verification, got %v", err)
+ }
+ if len(content) != 0 {
+ t.Errorf("Did not expect to read any content, got %d bytes", len(content))
+ }
+}
+
+type corruptingServer struct {
+ affectedPath string
+ handler http.Handler
+}
+
+func (s *corruptingServer) ServeHTTP(w http.ResponseWriter, req *http.Request) {
+ if req.URL.Path == s.affectedPath {
+ w = &corruptingResponseWriter{ResponseWriter: w}
+ }
+ s.handler.ServeHTTP(w, req)
+}
+
+// corruptingResponseWriter replaces the first newline in the response with a
+// space. This means that JSON parsing will still succeed, but digest
+// verification should fail.
+type corruptingResponseWriter struct {
+ http.ResponseWriter
+ corrupted bool
+}
+
+func (w *corruptingResponseWriter) Write(b []byte) (n int, err error) {
+ index := bytes.IndexByte(b, '\n')
+ if w.corrupted || index == -1 {
+ return w.ResponseWriter.Write(b)
+ }
+ b = bytes.Clone(b)
+ b[index] = ' '
+ n, err = w.ResponseWriter.Write(b)
+ if n > index {
+ w.corrupted = true
+ }
+ return
+}
+
+func TestTruncation(t *testing.T) {
+ srcImage, err := oci.ReadLayout(xImageUncompressedPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ blobs := &truncatedBlobs{
+ image: srcImage,
+ length: srcImage.Manifest.Config.Size,
+ }
+ truncatedImage, err := oci.NewImage(srcImage.RawManifest, "", blobs)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ osImage, err := Read(truncatedImage)
+ if err != nil {
+ t.Fatal(err)
+ }
+ blobs.length = osImage.Config.Payloads[0].HashChunkSize
+ payload, err := osImage.Payload("test")
+ if err != nil {
+ t.Fatal(err)
+ }
+ reader, err := payload.Open()
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer reader.Close()
+ _, err = io.ReadAll(reader)
+ if err == nil {
+ t.Error("Expected to get an error, got nil")
+ }
+}
+
+type truncatedBlobs struct {
+ image *oci.Image
+ length int64
+}
+
+func (b *truncatedBlobs) Blob(d *ocispecv1.Descriptor) (io.ReadCloser, error) {
+ reader, err := b.image.Blob(d)
+ if err != nil {
+ return nil, err
+ }
+ reader = &readCloser{
+ Reader: io.LimitReader(reader, b.length),
+ Closer: reader,
+ }
+ return reader, nil
+}
+
+type readCloser struct {
+ io.Reader
+ io.Closer
+}