| // 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 |
| } |