package http import ( "context" "os" "path/filepath" "testing" "github.com/containerd/containerd/v2/core/diff/apply" ctdmetadata "github.com/containerd/containerd/v2/core/metadata" "github.com/containerd/containerd/v2/core/snapshots" "github.com/containerd/containerd/v2/plugins/content/local" "github.com/containerd/containerd/v2/plugins/diff/walking" "github.com/containerd/containerd/v2/plugins/snapshots/native" "github.com/moby/buildkit/cache" "github.com/moby/buildkit/cache/metadata" "github.com/moby/buildkit/client" "github.com/moby/buildkit/identity" "github.com/moby/buildkit/session" "github.com/moby/buildkit/snapshot" containerdsnapshot "github.com/moby/buildkit/snapshot/containerd" "github.com/moby/buildkit/solver" "github.com/moby/buildkit/source" "github.com/moby/buildkit/util/leaseutil" "github.com/moby/buildkit/util/testutil/httpserver" "github.com/moby/buildkit/util/winlayers" digest "github.com/opencontainers/go-digest" "github.com/stretchr/testify/require" bolt "go.etcd.io/bbolt" ) const signFixturesPathEnv = "BUILDKIT_TEST_SIGN_FIXTURES" func TestHTTPSource(t *testing.T) { t.Parallel() ctx := context.TODO() hs, err := newHTTPSource(t) require.NoError(t, err) resp := &httpserver.Response{ Etag: identity.NewID(), Content: []byte("content1"), } server := httpserver.NewTestServer(map[string]*httpserver.Response{ "/foo": resp, }) defer server.Close() id := &HTTPIdentifier{URL: server.URL + "/foo"} h, err := hs.Resolve(ctx, id, nil, nil) require.NoError(t, err) k, p, _, _, err := h.CacheKey(ctx, nil, 0) require.NoError(t, err) expectedPin1 := digest.FromBytes(resp.Content).String() plaintext1 := `{"Filename":"foo","Perm":0,"UID":0,"GID":0,"Checksum":"` + expectedPin1 + `"}` expectedContent1 := digest.FromBytes([]byte(plaintext1)).String() require.Equal(t, expectedContent1, k) require.Equal(t, expectedPin1, p) require.Equal(t, 1, server.Stats("/foo").AllRequests) require.Equal(t, 0, server.Stats("/foo").CachedRequests) ref, err := h.Snapshot(ctx, nil) require.NoError(t, err) defer func() { if ref != nil { ref.Release(context.WithoutCancel(ctx)) ref = nil } }() dt, err := readFile(ctx, ref, "foo") require.NoError(t, err) require.Equal(t, dt, []byte("content1")) ref.Release(context.TODO()) ref = nil // repeat, should use the etag h, err = hs.Resolve(ctx, id, nil, nil) require.NoError(t, err) k, p, _, _, err = h.CacheKey(ctx, nil, 0) require.NoError(t, err) require.Equal(t, expectedContent1, k) require.Equal(t, expectedPin1, p) require.Equal(t, 2, server.Stats("/foo").AllRequests) require.Equal(t, 1, server.Stats("/foo").CachedRequests) ref, err = h.Snapshot(ctx, nil) require.NoError(t, err) defer func() { if ref != nil { ref.Release(context.WithoutCancel(ctx)) ref = nil } }() dt, err = readFile(ctx, ref, "foo") require.NoError(t, err) require.Equal(t, dt, []byte("content1")) ref.Release(context.TODO()) ref = nil resp2 := &httpserver.Response{ Etag: identity.NewID(), Content: []byte("content2"), } expectedPin2 := digest.FromBytes(resp2.Content).String() plaintext2 := `{"Filename":"foo","Perm":0,"UID":0,"GID":0,"Checksum":"` + expectedPin2 + `"}` expectedContent2 := digest.FromBytes([]byte(plaintext2)).String() // update etag, downloads again server.SetRoute("/foo", resp2) h, err = hs.Resolve(ctx, id, nil, nil) require.NoError(t, err) k, p, _, _, err = h.CacheKey(ctx, nil, 0) require.NoError(t, err) require.Equal(t, expectedContent2, k) require.Equal(t, expectedPin2, p) require.Equal(t, 4, server.Stats("/foo").AllRequests) require.Equal(t, 1, server.Stats("/foo").CachedRequests) ref, err = h.Snapshot(ctx, nil) require.NoError(t, err) defer func() { if ref != nil { ref.Release(context.WithoutCancel(ctx)) ref = nil } }() dt, err = readFile(ctx, ref, "foo") require.NoError(t, err) require.Equal(t, dt, []byte("content2")) ref.Release(context.TODO()) ref = nil } func TestHTTPDefaultName(t *testing.T) { t.Parallel() ctx := context.TODO() hs, err := newHTTPSource(t) require.NoError(t, err) resp := &httpserver.Response{ Etag: identity.NewID(), Content: []byte("content1"), } server := httpserver.NewTestServer(map[string]*httpserver.Response{ "/": resp, }) defer server.Close() id := &HTTPIdentifier{URL: server.URL} h, err := hs.Resolve(ctx, id, nil, nil) require.NoError(t, err) k, p, _, _, err := h.CacheKey(ctx, nil, 0) require.NoError(t, err) expectedPin := digest.FromBytes(resp.Content).String() plaintext := `{"Filename":"download","Perm":0,"UID":0,"GID":0,"Checksum":"` + expectedPin + `"}` expectedContent := digest.FromBytes([]byte(plaintext)).String() require.Equal(t, expectedContent, k) require.Equal(t, expectedPin, p) require.Equal(t, 1, server.Stats("/").AllRequests) require.Equal(t, 0, server.Stats("/").CachedRequests) ref, err := h.Snapshot(ctx, nil) require.NoError(t, err) defer func() { if ref != nil { ref.Release(context.WithoutCancel(ctx)) ref = nil } }() dt, err := readFile(ctx, ref, "download") require.NoError(t, err) require.Equal(t, dt, []byte("content1")) ref.Release(context.TODO()) ref = nil } func TestHTTPInvalidURL(t *testing.T) { t.Parallel() ctx := context.TODO() hs, err := newHTTPSource(t) require.NoError(t, err) server := httpserver.NewTestServer(map[string]*httpserver.Response{}) defer server.Close() id := &HTTPIdentifier{URL: server.URL + "/foo"} h, err := hs.Resolve(ctx, id, nil, nil) require.NoError(t, err) _, _, _, _, err = h.CacheKey(ctx, nil, 0) require.Error(t, err) require.Contains(t, err.Error(), "invalid response") } func TestHTTPChecksum(t *testing.T) { t.Parallel() ctx := context.TODO() hs, err := newHTTPSource(t) require.NoError(t, err) resp := &httpserver.Response{ Etag: identity.NewID(), Content: []byte("content-correct"), } server := httpserver.NewTestServer(map[string]*httpserver.Response{ "/foo": resp, }) defer server.Close() expectedPinDifferent := digest.FromBytes([]byte("content-different")) id := &HTTPIdentifier{URL: server.URL + "/foo", Checksum: expectedPinDifferent} h, err := hs.Resolve(ctx, id, nil, nil) require.NoError(t, err) k, p, _, _, err := h.CacheKey(ctx, nil, 0) require.NoError(t, err) plaintextDifferent := `{"Filename":"foo","Perm":0,"UID":0,"GID":0,"Checksum":"` + expectedPinDifferent + `"}` expectedContentDifferent := digest.FromBytes([]byte(plaintextDifferent)).String() expectedPinCorrect := digest.FromBytes(resp.Content).String() plaintextCorrect := `{"Filename":"foo","Perm":0,"UID":0,"GID":0,"Checksum":"` + expectedPinCorrect + `"}` expectedContentCorrect := digest.FromBytes([]byte(plaintextCorrect)).String() require.Equal(t, expectedContentDifferent, k) require.Equal(t, expectedPinDifferent.String(), p) require.Equal(t, 0, server.Stats("/foo").AllRequests) require.Equal(t, 0, server.Stats("/foo").CachedRequests) _, err = h.Snapshot(ctx, nil) require.Error(t, err) require.Equal(t, expectedContentDifferent, k) require.Equal(t, expectedPinDifferent.String(), p) require.Equal(t, 1, server.Stats("/foo").AllRequests) require.Equal(t, 0, server.Stats("/foo").CachedRequests) id = &HTTPIdentifier{URL: server.URL + "/foo", Checksum: digest.FromBytes([]byte("content-correct"))} h, err = hs.Resolve(ctx, id, nil, nil) require.NoError(t, err) k, p, _, _, err = h.CacheKey(ctx, nil, 0) require.NoError(t, err) require.Equal(t, expectedContentCorrect, k) require.Equal(t, expectedPinCorrect, p) require.Equal(t, 1, server.Stats("/foo").AllRequests) require.Equal(t, 0, server.Stats("/foo").CachedRequests) ref, err := h.Snapshot(ctx, nil) require.NoError(t, err) defer func() { if ref != nil { ref.Release(context.WithoutCancel(ctx)) ref = nil } }() dt, err := readFile(ctx, ref, "foo") require.NoError(t, err) require.Equal(t, dt, []byte("content-correct")) require.Equal(t, expectedContentCorrect, k) require.Equal(t, expectedPinCorrect, p) require.Equal(t, 2, server.Stats("/foo").AllRequests) require.Equal(t, 0, server.Stats("/foo").CachedRequests) ref.Release(context.TODO()) ref = nil } func TestHTTPSignatureVerification(t *testing.T) { t.Parallel() ctx := context.TODO() fixturesPath, ok := os.LookupEnv(signFixturesPathEnv) if !ok { t.Skip("missing BUILDKIT_TEST_SIGN_FIXTURES") } payload := readSignFixture(t, fixturesPath, "user1.http.artifact") signature := readSignFixture(t, fixturesPath, "user1.http.artifact.asc") pubKeyUser1 := readSignFixture(t, fixturesPath, "user1.gpg.pub") pubKeyUser2 := readSignFixture(t, fixturesPath, "user2.gpg.pub") hs, err := newHTTPSource(t) require.NoError(t, err) server := httpserver.NewTestServer(map[string]*httpserver.Response{ "/artifact": { Etag: identity.NewID(), Content: payload, }, }) defer server.Close() t.Run("valid-signature", func(t *testing.T) { id := &HTTPIdentifier{ URL: server.URL + "/artifact", VerifySignature: &HTTPSignatureVerifyOptions{ PubKey: pubKeyUser1, Signature: signature, }, } h, err := hs.Resolve(ctx, id, nil, nil) require.NoError(t, err) _, _, _, _, err = h.CacheKey(ctx, nil, 0) require.NoError(t, err) }) t.Run("wrong-pubkey", func(t *testing.T) { id := &HTTPIdentifier{ URL: server.URL + "/artifact", VerifySignature: &HTTPSignatureVerifyOptions{ PubKey: pubKeyUser2, Signature: signature, }, } h, err := hs.Resolve(ctx, id, nil, nil) require.NoError(t, err) _, _, _, _, err = h.CacheKey(ctx, nil, 0) require.Error(t, err) require.ErrorContains(t, err, "failed to verify pgp signature") }) t.Run("multiblock-pubkey", func(t *testing.T) { merged := append(append([]byte{}, pubKeyUser2...), '\n') merged = append(merged, pubKeyUser1...) id := &HTTPIdentifier{ URL: server.URL + "/artifact", VerifySignature: &HTTPSignatureVerifyOptions{ PubKey: merged, Signature: signature, }, } h, err := hs.Resolve(ctx, id, nil, nil) require.NoError(t, err) _, _, _, _, err = h.CacheKey(ctx, nil, 0) require.NoError(t, err) }) t.Run("missing-signature", func(t *testing.T) { id := &HTTPIdentifier{ URL: server.URL + "/artifact", VerifySignature: &HTTPSignatureVerifyOptions{ PubKey: pubKeyUser1, }, } h, err := hs.Resolve(ctx, id, nil, nil) require.NoError(t, err) _, _, _, _, err = h.CacheKey(ctx, nil, 0) require.Error(t, err) require.ErrorContains(t, err, "requires both pubkey and signature") }) } func TestPruneAfterCacheKey(t *testing.T) { t.Parallel() ctx := context.TODO() cm, err := newCacheManager(t) require.NoError(t, err) hs, err := NewSource(Opt{ CacheAccessor: cm, }) require.NoError(t, err) resp := &httpserver.Response{ Etag: identity.NewID(), Content: []byte("content-correct"), } server := httpserver.NewTestServer(map[string]*httpserver.Response{ "/foo": resp, }) defer server.Close() id := &HTTPIdentifier{URL: server.URL + "/foo"} h, err := hs.Resolve(ctx, id, nil, nil) require.NoError(t, err) jc := &simpleJobContext{} k, p, _, _, err := h.CacheKey(ctx, jc, 0) require.NoError(t, err) expectedPin := digest.FromBytes(resp.Content).String() plaintextCacheKey := `{"Filename":"foo","Perm":0,"UID":0,"GID":0,"Checksum":"` + expectedPin + `"}` expectedCacheKey := digest.FromBytes([]byte(plaintextCacheKey)).String() require.Equal(t, expectedCacheKey, k) require.Equal(t, expectedPin, p) require.Equal(t, 1, server.Stats("/foo").AllRequests) require.Equal(t, 0, server.Stats("/foo").CachedRequests) err = cm.Prune(ctx, nil, client.PruneInfo{ All: true, }) require.NoError(t, err) du, err := cm.DiskUsage(ctx, client.DiskUsageInfo{}) require.NoError(t, err) require.Len(t, du, 1) for _, u := range du { require.Equal(t, "http url "+id.URL, u.Description) } resp.Etag = identity.NewID() resp.Content = []byte("content-updated") ref, err := h.Snapshot(ctx, jc) require.NoError(t, err) ref.Release(context.TODO()) dt, err := readFile(ctx, ref, "foo") require.NoError(t, err) require.Equal(t, dt, []byte("content-correct")) require.Equal(t, 1, server.Stats("/foo").AllRequests) require.Equal(t, 0, server.Stats("/foo").CachedRequests) du, err = cm.DiskUsage(ctx, client.DiskUsageInfo{}) require.NoError(t, err) require.Len(t, du, 1) err = cm.Prune(ctx, nil, client.PruneInfo{ All: true, }) require.NoError(t, err) du, err = cm.DiskUsage(ctx, client.DiskUsageInfo{}) require.NoError(t, err) require.Len(t, du, 1) err = jc.Release() require.NoError(t, err) err = cm.Prune(ctx, nil, client.PruneInfo{ All: true, }) require.NoError(t, err) du, err = cm.DiskUsage(ctx, client.DiskUsageInfo{}) require.NoError(t, err) for _, u := range du { t.Logf("du: %+v", *u) } require.Len(t, du, 0) } func readFile(ctx context.Context, ref cache.ImmutableRef, fp string) ([]byte, error) { mount, err := ref.Mount(ctx, true, nil) if err != nil { return nil, err } lm := snapshot.LocalMounter(mount) dir, err := lm.Mount() if err != nil { return nil, err } defer lm.Unmount() dt, err := os.ReadFile(filepath.Join(dir, fp)) if err != nil { return nil, err } return dt, nil } func newHTTPSource(t *testing.T) (source.Source, error) { cm, err := newCacheManager(t) if err != nil { return nil, err } return NewSource(Opt{ CacheAccessor: cm, }) } func newCacheManager(t *testing.T) (cache.Manager, error) { tmpdir := t.TempDir() snapshotter, err := native.NewSnapshotter(filepath.Join(tmpdir, "snapshots")) if err != nil { return nil, err } t.Cleanup(func() { require.NoError(t, snapshotter.Close()) }) store, err := local.NewStore(tmpdir) if err != nil { return nil, err } db, err := bolt.Open(filepath.Join(tmpdir, "containerdmeta.db"), 0644, nil) if err != nil { return nil, err } t.Cleanup(func() { require.NoError(t, db.Close()) }) mdb := ctdmetadata.NewDB(db, store, map[string]snapshots.Snapshotter{ "native": snapshotter, }) md, err := metadata.NewStore(filepath.Join(tmpdir, "metadata.db")) if err != nil { return nil, err } t.Cleanup(func() { require.NoError(t, md.Close()) }) lm := leaseutil.WithNamespace(ctdmetadata.NewLeaseManager(mdb), "buildkit") c := mdb.ContentStore() applier := winlayers.NewFileSystemApplierWithWindows(c, apply.NewFileSystemApplier(c)) differ := winlayers.NewWalkingDiffWithWindows(c, walking.NewWalkingDiff(c)) cm, err := cache.NewManager(cache.ManagerOpt{ Snapshotter: snapshot.FromContainerdSnapshotter("native", containerdsnapshot.NSSnapshotter("buildkit", mdb.Snapshotter("native")), nil), MetadataStore: md, LeaseManager: lm, ContentStore: c, Applier: applier, Differ: differ, GarbageCollect: mdb.GarbageCollect, Root: tmpdir, MountPoolRoot: filepath.Join(tmpdir, "cachemounts"), }) if err != nil { return nil, err } t.Cleanup(func() { require.NoError(t, cm.Close()) }) return cm, nil } type simpleJobContext struct { releasers []func() error } func (s *simpleJobContext) Session() session.Group { return nil } func (s *simpleJobContext) Cleanup(f func() error) error { s.releasers = append(s.releasers, f) return nil } func (s *simpleJobContext) Release() error { var firstErr error for _, r := range s.releasers { if err := r(); err != nil && firstErr == nil { firstErr = err } } s.releasers = nil return firstErr } func (s *simpleJobContext) ResolverCache() solver.ResolverCache { return nil } func (s *simpleJobContext) CompatibilityVersion() (int, error) { return 0, nil } func readSignFixture(t *testing.T, fixturesPath, name string) []byte { t.Helper() p := filepath.Join(fixturesPath, name) dt, err := os.ReadFile(p) if err != nil { if os.IsNotExist(err) { t.Skipf("missing signing fixture at %s", p) } require.NoError(t, err) } return dt }