diff --git a/build/build.go b/build/build.go
index f1b90ea0230a..a8335e30c4d9 100644
--- a/build/build.go
+++ b/build/build.go
@@ -67,6 +67,8 @@ type Options struct {
Inputs Inputs
Ref string
+ Frontend string
+ FrontendAttrs map[string]string
Allow []string
Attests map[string]*string
BuildArgs map[string]string
diff --git a/build/opt.go b/build/opt.go
index cf9c1098be09..8b16f944d6bf 100644
--- a/build/opt.go
+++ b/build/opt.go
@@ -303,16 +303,23 @@ func toSolveOpt(ctx context.Context, np *noderesolver.ResolvedNode, multiDriver
cacheFrom = append(cacheFrom, e)
}
+ frontend := opt.Frontend
+ if frontend == "" {
+ frontend = "dockerfile.v0"
+ }
so := client.SolveOpt{
Ref: opt.Ref,
- Frontend: "dockerfile.v0",
- FrontendAttrs: map[string]string{},
+ Frontend: frontend,
+ FrontendAttrs: maps.Clone(opt.FrontendAttrs),
LocalMounts: map[string]fsutil.FS{},
CacheExports: cacheTo,
CacheImports: cacheFrom,
AllowedEntitlements: opt.Allow,
SourcePolicy: opt.SourcePolicy,
}
+ if so.FrontendAttrs == nil {
+ so.FrontendAttrs = map[string]string{}
+ }
if opt.CgroupParent != "" {
so.FrontendAttrs["cgroup-parent"] = opt.CgroupParent
@@ -667,11 +674,45 @@ func proxyArgKeyExists(buildArgs map[string]string, key string) bool {
return false
}
+// splitPolicyConfigs separates programmatic policy callbacks from file-based
+// policy configs. A config carries either a callback or policy files.
+func splitPolicyConfigs(configs []buildflags.PolicyConfig) ([]policysession.PolicyCallback, []buildflags.PolicyConfig, error) {
+ var callbacks []policysession.PolicyCallback
+ var fileConfigs []buildflags.PolicyConfig
+ for _, p := range configs {
+ if p.Callback == nil {
+ fileConfigs = append(fileConfigs, p)
+ continue
+ }
+ if len(p.Files) > 0 {
+ return nil, nil, errors.New("policy config cannot set both a callback and policy files")
+ }
+ callbacks = append(callbacks, p.Callback)
+ }
+ return callbacks, fileConfigs, nil
+}
+
func configureSourcePolicy(ctx context.Context, np *noderesolver.ResolvedNode, opt *Options, cfg *confutil.Config, bopts gateway.BuildOpts, so *client.SolveOpt, pw progress.Writer) (_ []func(error), err error) {
+ callbackOnly, fileConfigs, err := splitPolicyConfigs(opt.Policy)
+ if err != nil {
+ return nil, err
+ }
+
+ // Any callback-only entry requires the session policy capability, the
+ // same way a Strict declarative policy does.
+ if len(callbackOnly) > 0 {
+ if bopts.LLBCaps.Supports(pb.CapSourcePolicySession) != nil {
+ return nil, errors.New("session source policy is not supported by the current BuildKit daemon, please upgrade to version v0.27+")
+ }
+ }
if opt.Inputs.policy == nil {
- if len(opt.Policy) > 0 {
+ if len(fileConfigs) > 0 {
return nil, errors.New("policy file specified but no policy FS in build context")
}
+ if len(callbackOnly) > 0 {
+ so.SourcePolicyProvider = policysession.NewPolicyProvider(policy.MultiPolicyCallback(callbackOnly...))
+ return nil, nil
+ }
so.SourcePolicyProvider = nil
return nil, nil
}
@@ -687,7 +728,7 @@ func configureSourcePolicy(ctx context.Context, np *noderesolver.ResolvedNode, o
env.Target = opt.Target
env.Labels = opt.Labels
- popts, err := withPolicyConfig(*opt.Inputs.policy, opt.Policy)
+ popts, err := withPolicyConfig(*opt.Inputs.policy, fileConfigs)
if err != nil {
return nil, err
}
@@ -697,7 +738,7 @@ func configureSourcePolicy(ctx context.Context, np *noderesolver.ResolvedNode, o
// (docker/dockerfile, docker/dockerfile-upstream) that may be implicitly
// loaded during a build, and passes through any other source so user
// policies retain full control.
- if policy.DefaultPolicyEnabled() && !policyExplicitlyDisabled(opt.Policy) {
+ if policy.DefaultPolicyEnabled() && !policyExplicitlyDisabled(fileConfigs) {
builtin := policyOpt{
Files: []policyFileSpec{{
Filename: policy.DefaultPolicyFilename,
@@ -709,6 +750,10 @@ func configureSourcePolicy(ctx context.Context, np *noderesolver.ResolvedNode, o
}
if len(popts) == 0 {
+ if len(callbackOnly) > 0 {
+ so.SourcePolicyProvider = policysession.NewPolicyProvider(policy.MultiPolicyCallback(callbackOnly...))
+ return nil, nil
+ }
so.SourcePolicyProvider = nil
return nil, nil
}
@@ -738,6 +783,10 @@ func configureSourcePolicy(ctx context.Context, np *noderesolver.ResolvedNode, o
return nil, err
}
if len(loadedOpts) == 0 {
+ if len(callbackOnly) > 0 {
+ so.SourcePolicyProvider = policysession.NewPolicyProvider(policy.MultiPolicyCallback(callbackOnly...))
+ return defers, nil
+ }
so.SourcePolicyProvider = nil
return defers, nil
}
@@ -802,6 +851,9 @@ func configureSourcePolicy(ctx context.Context, np *noderesolver.ResolvedNode, o
policyLogger.Log("policy enabled network proxy")
}
}
+ // Callback-only policy entries compose as the last (most-strict)
+ // entries, allowing file-based policies to still run first.
+ cbs = append(cbs, callbackOnly...)
so.SourcePolicyProvider = policysession.NewPolicyProvider(policy.MultiPolicyCallback(cbs...))
return defers, nil
}
diff --git a/build/policy_test.go b/build/policy_test.go
index e1251a62b8fe..32c2f4777a0a 100644
--- a/build/policy_test.go
+++ b/build/policy_test.go
@@ -1,10 +1,13 @@
package build
import (
+ "context"
"testing"
"github.com/docker/buildx/policy"
"github.com/docker/buildx/util/buildflags"
+ gwpb "github.com/moby/buildkit/frontend/gateway/pb"
+ "github.com/moby/buildkit/sourcepolicy/policysession"
"github.com/sirupsen/logrus"
"github.com/stretchr/testify/require"
)
@@ -125,3 +128,18 @@ func TestWithPolicyConfigMultipleFilesAndOverrides(t *testing.T) {
require.False(t, out[2].Files[0].Optional)
require.True(t, out[2].Strict)
}
+
+func TestSplitPolicyConfigs(t *testing.T) {
+ cb := func(context.Context, *policysession.CheckPolicyRequest) (*policysession.DecisionResponse, *gwpb.ResolveSourceMetaRequest, error) {
+ return nil, nil, nil
+ }
+ file := buildflags.PolicyConfig{Files: []policy.File{{Filename: "policy.rego"}}}
+
+ callbacks, files, err := splitPolicyConfigs([]buildflags.PolicyConfig{file, {Callback: cb}})
+ require.NoError(t, err)
+ require.Len(t, callbacks, 1)
+ require.Equal(t, []buildflags.PolicyConfig{file}, files)
+
+ _, _, err = splitPolicyConfigs([]buildflags.PolicyConfig{{Files: file.Files, Callback: cb}})
+ require.ErrorContains(t, err, "cannot set both a callback and policy files")
+}
diff --git a/commands/replay/build.go b/commands/replay/build.go
new file mode 100644
index 000000000000..e22790e72802
--- /dev/null
+++ b/commands/replay/build.go
@@ -0,0 +1,615 @@
+package replay
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "os"
+ "sort"
+ "strings"
+ "text/tabwriter"
+ "time"
+
+ "github.com/containerd/platforms"
+ "github.com/docker/buildx/builder"
+ "github.com/docker/buildx/driver"
+ "github.com/docker/buildx/replay"
+ "github.com/docker/buildx/util/buildflags"
+ "github.com/docker/buildx/util/cobrautil/completion"
+ "github.com/docker/buildx/util/platformutil"
+ "github.com/docker/buildx/util/progress"
+ "github.com/docker/cli/cli"
+ "github.com/docker/cli/cli/command"
+ "github.com/moby/buildkit/client"
+ "github.com/moby/buildkit/util/progress/progressui"
+ ocispecs "github.com/opencontainers/image-spec/specs-go/v1"
+ "github.com/pkg/errors"
+ "github.com/spf13/cobra"
+)
+
+// buildOptions holds the parsed flags for `replay build`.
+type buildOptions struct {
+ commonOptions
+ mode string
+ outputs []string
+ tags []string
+ exportLoad bool
+ exportPush bool
+ dryRun bool
+ format string
+}
+
+func buildCmd(dockerCli command.Cli, rootOpts RootOptions) *cobra.Command {
+ var opts buildOptions
+
+ cmd := &cobra.Command{
+ Use: "build [OPTIONS] SUBJECT",
+ Short: "Rebuild an image from provenance and pinned materials",
+ Args: cli.ExactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ opts.builder = *rootOpts.Builder
+ return runBuild(cmd, dockerCli, &opts, args[0])
+ },
+ ValidArgsFunction: completion.Disable,
+ DisableFlagsInUseLine: true,
+ }
+
+ installCommonFlags(cmd, &opts.commonOptions)
+
+ flags := cmd.Flags()
+ // Only the default provenance materials are supported by replay build.
+ flags.MarkHidden("materials")
+ flags.StringVar(&opts.mode, "replay-mode", "materials", `Replay mode ("materials" | "frontend")`)
+ flags.StringArrayVarP(&opts.outputs, "output", "o", nil, `Output destination (format: "type=local,dest=path")`)
+ flags.StringArrayVarP(&opts.tags, "tag", "t", nil, `Image identifier (format: "[registry/]repository[:tag]")`)
+ flags.BoolVar(&opts.exportLoad, "load", false, `Shorthand for "--output=type=docker"`)
+ flags.BoolVar(&opts.exportPush, "push", false, `Shorthand for "--output=type=registry,unpack=false"`)
+ flags.BoolVar(&opts.dryRun, "dry-run", false, "Print a plan of the replay without solving or exporting")
+ flags.StringVar(&opts.format, "format", "pretty", `Format dry-run output ("pretty" | "json")`)
+
+ return cmd
+}
+
+// runBuild wires the CLI flags to the replay.Build entry point.
+func runBuild(cmd *cobra.Command, dockerCli command.Cli, opts *buildOptions, input string) error {
+ ctx := cmd.Context()
+
+ mode := replay.BuildMode(opts.mode)
+ switch mode {
+ case replay.BuildModeMaterials, replay.BuildModeFrontend:
+ // ok
+ default:
+ return errors.Errorf("unknown --replay-mode %q", opts.mode)
+ }
+ switch opts.format {
+ case "pretty", "json":
+ default:
+ return errors.Errorf("unknown --format %q", opts.format)
+ }
+ if cmd.Flags().Changed("format") && !opts.dryRun {
+ return errors.New("--format requires --dry-run")
+ }
+ switch opts.network {
+ case "", "default", "none":
+ default:
+ return errors.Errorf("unsupported --network %q (want default or none)", opts.network)
+ }
+ // Replay builds one platform at a time, so "all" is not accepted.
+ if _, all, err := parsePlatformFilter(opts.platforms); err != nil {
+ return err
+ } else if all {
+ return errors.New(`--platform "all" is not supported by replay build; select a single platform`)
+ }
+
+ // Materials resolver.
+ resolver, err := replay.NewMaterialsResolver(opts.materials)
+ if err != nil {
+ return err
+ }
+
+ // Parse flags.
+ secretSpecs, err := buildflags.ParseSecretSpecs(opts.secrets)
+ if err != nil {
+ return errors.Wrap(err, "parse --secret")
+ }
+ sshSpecs, err := buildflags.ParseSSHSpecs(opts.ssh)
+ if err != nil {
+ return errors.Wrap(err, "parse --ssh")
+ }
+ exportSpecs, err := buildflags.ParseExports(opts.outputs)
+ if err != nil {
+ return errors.Wrap(err, "parse --output")
+ }
+ exportSpecs = applyExportShorthands(exportSpecs, opts.exportPush, opts.exportLoad)
+
+ // Subject + predicate.
+ subjects, err := replay.LoadSubjects(ctx, dockerCli, opts.builder, input)
+ if err != nil {
+ return err
+ }
+
+ subjects, err = filterSubjectsByPlatform(subjects, opts.platforms, builderDefaultPlatform(ctx, dockerCli, opts.builder))
+ if err != nil {
+ return err
+ }
+ if len(subjects) == 0 {
+ return errors.New("no subjects matched the --platform filter")
+ }
+ if err := replay.VerifySignatures(ctx, dockerCli, subjects); err != nil {
+ return err
+ }
+
+ targets := make([]replay.Target, 0, len(subjects))
+ for _, s := range subjects {
+ pred, err := s.Predicate(ctx)
+ if err != nil {
+ return err
+ }
+ s = applyPredicateTargetPlatformFallback(s, pred, opts.platforms)
+ targets = append(targets, replay.Target{Subject: s, Predicate: pred})
+ }
+
+ req := &replay.BuildRequest{
+ Targets: targets,
+ Mode: mode,
+ Materials: resolver,
+ NetworkMode: opts.network,
+ Secrets: secretSpecs,
+ SSH: sshSpecs,
+ Exports: exportSpecs,
+ Tags: opts.tags,
+ Progress: progressui.DisplayMode(opts.progress),
+ }
+
+ if opts.dryRun {
+ plan, err := replay.MakeBuildPlan(req)
+ if err != nil {
+ return err
+ }
+ switch opts.format {
+ case "pretty":
+ return printBuildPlan(cmd.OutOrStdout(), plan)
+ case "json":
+ enc := json.NewEncoder(cmd.OutOrStdout())
+ enc.SetIndent("", " ")
+ return enc.Encode(plan)
+ default:
+ return errors.Errorf("unknown --format %q", opts.format)
+ }
+ }
+ return replay.Build(ctx, dockerCli, opts.builder, req)
+}
+
+func printBuildPlan(out io.Writer, plan *replay.BuildPlan) error {
+ if plan == nil {
+ return errors.New("nil build plan")
+ }
+ tw := tabwriter.NewWriter(out, 0, 4, 2, ' ', 0)
+ if _, err := fmt.Fprintln(tw, "Replay plan"); err != nil {
+ return errors.WithStack(err)
+ }
+ for i, subject := range plan.Subjects {
+ if _, err := fmt.Fprintf(tw, "\nSubject %d/%d\n", i+1, len(plan.Subjects)); err != nil {
+ return errors.WithStack(err)
+ }
+ if err := writePlanField(tw, "Platform", formatPlanPlatform(subject.Descriptor.Platform)); err != nil {
+ return err
+ }
+ if err := writePlanField(tw, "Digest", subject.Descriptor.Digest.String()); err != nil {
+ return err
+ }
+ if _, err := fmt.Fprintln(tw, "\nBuild configuration"); err != nil {
+ return errors.WithStack(err)
+ }
+ cfg := subject.BuildConfig
+ for _, field := range []struct{ name, value string }{
+ {"Frontend", cfg.Frontend},
+ {"Context", cfg.Context},
+ {"Dockerfile", cfg.Filename},
+ {"Target", cfg.Target},
+ {"Network", cfg.NetworkMode},
+ } {
+ if err := writePlanField(tw, field.name, field.value); err != nil {
+ return err
+ }
+ }
+ if err := writePlanMap(tw, "Build args", cfg.BuildArgs); err != nil {
+ return err
+ }
+ if len(cfg.Secrets) > 0 {
+ values := make([]string, 0, len(cfg.Secrets))
+ for _, secret := range cfg.Secrets {
+ value := secret.ID
+ if secret.Optional {
+ value += " (optional)"
+ }
+ values = append(values, value)
+ }
+ if err := writePlanField(tw, "Secrets", fmt.Sprintf("%v", values)); err != nil {
+ return err
+ }
+ }
+ if err := writePlanSignature(tw, subject.Signature); err != nil {
+ return err
+ }
+ if _, err := fmt.Fprintf(tw, "\nMaterials (%d)\n", len(subject.Materials)); err != nil {
+ return errors.WithStack(err)
+ }
+ for _, material := range subject.Materials {
+ platform := formatPlanPlatform(material.Platform)
+ if platform != "" {
+ platform = " [" + platform + "]"
+ }
+ if _, err := fmt.Fprintf(tw, " %s%s\t%s\n", material.Kind, platform, material.URI); err != nil {
+ return errors.WithStack(err)
+ }
+ if material.Digest != "" {
+ if _, err := fmt.Fprintf(tw, " \t%s\n", material.Digest); err != nil {
+ return errors.WithStack(err)
+ }
+ }
+ }
+ }
+ return errors.WithStack(tw.Flush())
+}
+
+func writePlanSignature(w io.Writer, signature *replay.SignatureVerification) error {
+ if signature == nil {
+ return nil
+ }
+ if _, err := fmt.Fprintf(w, "\n%s\n", signature.Type); err != nil {
+ return errors.WithStack(err)
+ }
+ for _, field := range []struct{ name, value string }{
+ {"Verified signer", signature.Identity},
+ {"Signer identity", signature.SubjectAlternativeName},
+ {"Certificate issuer", signature.CertificateIssuer},
+ {"OIDC issuer", signature.Issuer},
+ {"Runner environment", signature.RunnerEnvironment},
+ {"Source repository", signature.SourceRepositoryURI},
+ {"Source ref", signature.SourceRepositoryRef},
+ } {
+ if err := writePlanField(w, field.name, field.value); err != nil {
+ return err
+ }
+ }
+ if signature.BuildSignerURI != signature.SubjectAlternativeName {
+ if err := writePlanField(w, "Build signer", signature.BuildSignerURI); err != nil {
+ return err
+ }
+ }
+ if len(signature.Timestamps) > 0 {
+ if _, err := fmt.Fprintln(w, " TYPE\tTIME\tSOURCE"); err != nil {
+ return errors.WithStack(err)
+ }
+ }
+ for _, timestamp := range signature.Timestamps {
+ typeName := timestamp.Type
+ switch timestamp.Type {
+ case "Tlog":
+ typeName = "Transparency log"
+ case "TimestampAuthority":
+ typeName = "Timestamp authority"
+ }
+ value := timestamp.Timestamp.Format(time.RFC3339)
+ if _, err := fmt.Fprintf(w, " %s\t%s\t%s\n", typeName, value, timestamp.URI); err != nil {
+ return errors.WithStack(err)
+ }
+ }
+ if signature.TrustRootWarning != "" {
+ if err := writePlanField(w, "Trust root warning", signature.TrustRootWarning); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func writePlanField(w io.Writer, name, value string) error {
+ if value == "" {
+ return nil
+ }
+ _, err := fmt.Fprintf(w, " %s:\t%s\n", name, value)
+ return errors.WithStack(err)
+}
+
+func writePlanMap(w io.Writer, name string, values map[string]string) error {
+ if len(values) == 0 {
+ return nil
+ }
+ keys := make([]string, 0, len(values))
+ for key := range values {
+ keys = append(keys, key)
+ }
+ sort.Strings(keys)
+ for i, key := range keys {
+ label := ""
+ if i == 0 {
+ label = name + ":"
+ }
+ if _, err := fmt.Fprintf(w, " %s\t%s=%s\n", label, key, values[key]); err != nil {
+ return errors.WithStack(err)
+ }
+ }
+ return nil
+}
+
+func formatPlanPlatform(platform *ocispecs.Platform) string {
+ if platform == nil {
+ return ""
+ }
+ return platforms.Format(*platform)
+}
+
+// applyPredicateTargetPlatformFallback fills the platform metadata that a raw
+// provenance file cannot carry on its subject descriptor. Image subjects get
+// this metadata from their manifest index; attestation files can fall back to
+// TARGETPLATFORM inferred from the recorded LLB. An explicit --platform
+// has already been applied by filterSubjectsByPlatform and takes precedence.
+func applyPredicateTargetPlatformFallback(subject *replay.Subject, pred *replay.Predicate, platformFilter []string) *replay.Subject {
+ if subject == nil || pred == nil || subject.Descriptor.Platform != nil || len(platformFilter) != 0 {
+ return subject
+ }
+ platform, ok := pred.FallbackTargetPlatform()
+ if !ok {
+ return subject
+ }
+ clone := *subject
+ clone.Descriptor = subject.Descriptor
+ p := *platform
+ clone.Descriptor.Platform = &p
+ return &clone
+}
+
+// applyExportShorthands mirrors the --push / --load handling in
+// commands/build.go. --push sets push=true (+ unpack=false) on any
+// existing type=image export, or appends one; --load appends a
+// type=docker export unless an equivalent one is already present.
+// type=registry is only normalized to a pushing image export later by
+// build.CreateExports, so it already satisfies --push here.
+func applyExportShorthands(exports []*buildflags.ExportEntry, push, load bool) []*buildflags.ExportEntry {
+ if push {
+ var used bool
+ for _, e := range exports {
+ switch e.Type {
+ case "registry":
+ used = true
+ case "image":
+ if e.Attrs == nil {
+ e.Attrs = map[string]string{}
+ }
+ e.Attrs["push"] = "true"
+ if _, ok := e.Attrs["unpack"]; !ok {
+ e.Attrs["unpack"] = "false"
+ }
+ used = true
+ }
+ }
+ if !used {
+ exports = append(exports, &buildflags.ExportEntry{
+ Type: "image",
+ Attrs: map[string]string{"push": "true", "unpack": "false"},
+ })
+ }
+ }
+ if load {
+ var used bool
+ for _, e := range exports {
+ if e.Type == "docker" {
+ if _, ok := e.Attrs["dest"]; !ok {
+ used = true
+ break
+ }
+ }
+ }
+ if !used {
+ exports = append(exports, &buildflags.ExportEntry{
+ Type: "docker",
+ Attrs: map[string]string{},
+ })
+ }
+ }
+ return exports
+}
+
+// filterSubjectsByPlatform narrows a subject list to the requested platforms.
+//
+// Contract:
+// - platformFilter == ["all"] keeps every subject.
+// - Comma-separated and repeated entries are equivalent.
+// - platformFilter empty keeps the only subject when at most one subject
+// carries a platform. Otherwise it selects the subject matching
+// defaultPlatform (the builder's default platform) — replay is
+// single-platform by default. defaultPlatform is only called then.
+// - Otherwise each entry is matched strictly after normalization. Platform
+// selection identifies an artifact; it is not an execution-compatibility
+// check.
+//
+// An explicit --platform that does not match any subject is an error.
+// A sole subject with no descriptor platform inherits each explicit platform
+// because there is no index metadata to select from.
+func filterSubjectsByPlatform(subjects []*replay.Subject, platformFilter []string, defaultPlatform func() (ocispecs.Platform, error)) ([]*replay.Subject, error) {
+ explicit := len(platformFilter) > 0
+ wantPlatforms, all, err := parsePlatformFilter(platformFilter)
+ if err != nil {
+ return nil, err
+ }
+ if all {
+ return subjects, nil
+ }
+ var available []string
+ for _, s := range subjects {
+ if s.Descriptor.Platform != nil {
+ available = append(available, platforms.Format(*s.Descriptor.Platform))
+ }
+ }
+ if !explicit {
+ if len(available) <= 1 {
+ return subjects, nil
+ }
+ p, err := defaultPlatform()
+ if err != nil {
+ return nil, errors.Wrapf(err, "select default platform from %v — pass --platform
", available)
+ }
+ wantPlatforms = []ocispecs.Platform{p}
+ }
+
+ wantNames := make([]string, 0, len(wantPlatforms))
+ matchers := make([]platforms.MatchComparer, 0, len(wantPlatforms))
+ for _, platform := range wantPlatforms {
+ matchers = append(matchers, platforms.OnlyStrict(platform))
+ wantNames = append(wantNames, platforms.Format(platform))
+ }
+ if explicit && len(subjects) == 1 && subjects[0].Descriptor.Platform == nil {
+ out := make([]*replay.Subject, 0, len(wantPlatforms))
+ for _, platform := range wantPlatforms {
+ subject := *subjects[0]
+ subject.Descriptor = subjects[0].Descriptor
+ p := platform
+ subject.Descriptor.Platform = &p
+ out = append(out, &subject)
+ }
+ return out, nil
+ }
+
+ // For each requested platform pick the single best-matching subject —
+ // duplicate descriptors are collapsed to one target.
+ matchedAny := make([]bool, len(matchers))
+ chosen := make([]int, 0, len(matchers))
+ chosenSet := map[int]struct{}{}
+ for i, m := range matchers {
+ best := -1
+ for j, s := range subjects {
+ if s.Descriptor.Platform == nil {
+ continue
+ }
+ sp := *s.Descriptor.Platform
+ if !m.Match(sp) {
+ continue
+ }
+ if best < 0 || m.Less(sp, *subjects[best].Descriptor.Platform) {
+ best = j
+ }
+ }
+ if best >= 0 {
+ if _, exists := chosenSet[best]; !exists {
+ chosenSet[best] = struct{}{}
+ chosen = append(chosen, best)
+ }
+ matchedAny[i] = true
+ }
+ }
+
+ out := make([]*replay.Subject, 0, len(chosen))
+ for _, j := range chosen {
+ out = append(out, subjects[j])
+ }
+ for _, s := range subjects {
+ if s.Descriptor.Platform == nil {
+ out = append(out, s)
+ }
+ }
+
+ if explicit {
+ var missing []string
+ for i, w := range wantNames {
+ if !matchedAny[i] {
+ missing = append(missing, w)
+ }
+ }
+ if len(missing) > 0 {
+ return nil, errors.Errorf("requested platform(s) not present: %v (available: %v)", missing, available)
+ }
+ }
+ if len(out) == 0 {
+ return nil, errors.Errorf("no subject for builder default platform %v (available: %v) — pass --platform
", wantNames, available)
+ }
+ return out, nil
+}
+
+// builderDefaultPlatform returns a lookup of the platform `buildx build`
+// targets on the selected builder when no --platform is set: a platformless
+// build runs on the first node, where BuildKit defaults to the first
+// platform of its first worker. Platforms configured on the node are not
+// used as they may list a non-default platform first. Only the first node is
+// contacted, and booted when it is not running yet.
+func builderDefaultPlatform(ctx context.Context, dockerCli command.Cli, builderName string) func() (ocispecs.Platform, error) {
+ return func() (ocispecs.Platform, error) {
+ b, err := builder.New(dockerCli, builder.WithName(builderName))
+ if err != nil {
+ return ocispecs.Platform{}, err
+ }
+ nodes, err := b.LoadNodes(ctx)
+ if err != nil {
+ return ocispecs.Platform{}, err
+ }
+ if len(nodes) == 0 {
+ return ocispecs.Platform{}, errors.Errorf("builder %q has no nodes", b.Name)
+ }
+ n := nodes[0]
+ if n.Err != nil {
+ return ocispecs.Platform{}, errors.Wrapf(n.Err, "load builder %q", b.Name)
+ }
+ c, err := bootNode(ctx, n)
+ if err != nil {
+ return ocispecs.Platform{}, err
+ }
+ workers, err := c.ListWorkers(ctx)
+ if err != nil {
+ return ocispecs.Platform{}, errors.Wrap(err, "listing workers")
+ }
+ if len(workers) == 0 || len(workers[0].Platforms) == 0 {
+ return ocispecs.Platform{}, errors.Errorf("could not determine default platform of builder %q", b.Name)
+ }
+ return platforms.Normalize(workers[0].Platforms[0]), nil
+ }
+}
+
+// bootNode returns a client for a single builder node, booting it with the
+// same progress output as builder.Boot when it is not running.
+func bootNode(ctx context.Context, n builder.Node) (_ *client.Client, retErr error) {
+ info, err := n.Driver.Info(ctx)
+ if err != nil {
+ return nil, err
+ }
+ if info.Status == driver.Running {
+ return n.Driver.Client(ctx)
+ }
+ printer, err := progress.NewPrinter(context.TODO(), os.Stderr, progressui.AutoMode)
+ if err != nil {
+ return nil, err
+ }
+ defer func() {
+ if err := printer.Wait(); retErr == nil {
+ retErr = err
+ }
+ }()
+ return driver.Boot(ctx, ctx, n.Driver, printer)
+}
+
+func parsePlatformFilter(values []string) ([]ocispecs.Platform, bool, error) {
+ var flattened []string
+ for _, value := range values {
+ for part := range strings.SplitSeq(value, ",") {
+ part = strings.TrimSpace(part)
+ if part == "" {
+ return nil, false, errors.New("invalid empty --platform value")
+ }
+ flattened = append(flattened, part)
+ }
+ }
+ for _, value := range flattened {
+ if value != "all" {
+ continue
+ }
+ if len(flattened) != 1 {
+ return nil, false, errors.New(`--platform "all" cannot be combined with other platforms`)
+ }
+ return nil, true, nil
+ }
+ parsed, err := platformutil.Parse(flattened)
+ if err != nil {
+ return nil, false, errors.Wrap(err, "invalid --platform")
+ }
+ return platformutil.Dedupe(parsed), false, nil
+}
diff --git a/commands/replay/build_test.go b/commands/replay/build_test.go
new file mode 100644
index 000000000000..c3d9da2545a0
--- /dev/null
+++ b/commands/replay/build_test.go
@@ -0,0 +1,261 @@
+package replay
+
+import (
+ "bytes"
+ "io"
+ "testing"
+ "time"
+
+ "github.com/containerd/platforms"
+ "github.com/docker/buildx/build"
+ "github.com/docker/buildx/replay"
+ "github.com/docker/buildx/util/buildflags"
+ "github.com/moby/buildkit/client"
+ provenancetypes "github.com/moby/buildkit/solver/llbsolver/provenance/types"
+ solverpb "github.com/moby/buildkit/solver/pb"
+ ocispecs "github.com/opencontainers/image-spec/specs-go/v1"
+ "github.com/pkg/errors"
+ "github.com/stretchr/testify/require"
+)
+
+func TestFilterSubjectsByPlatform(t *testing.T) {
+ amd := &replay.Subject{Descriptor: ocispecs.Descriptor{Platform: &ocispecs.Platform{OS: "linux", Architecture: "amd64"}}}
+ arm := &replay.Subject{Descriptor: ocispecs.Descriptor{Platform: &ocispecs.Platform{OS: "linux", Architecture: "arm64"}}}
+ subjects := []*replay.Subject{amd, arm}
+ builderDefault := func(p string) func() (ocispecs.Platform, error) {
+ return func() (ocispecs.Platform, error) {
+ return platforms.MustParse(p), nil
+ }
+ }
+ noDefault := func() (ocispecs.Platform, error) {
+ t.Fatal("builder default platform must not be looked up")
+ return ocispecs.Platform{}, nil
+ }
+
+ // "all" keeps every subject.
+ out, err := filterSubjectsByPlatform(subjects, []string{"all"}, noDefault)
+ require.NoError(t, err)
+ require.Len(t, out, 2)
+
+ // An empty filter collapses to the builder's default platform, never
+ // the client's.
+ for _, arch := range []string{"amd64", "arm64"} {
+ out, err = filterSubjectsByPlatform(subjects, nil, builderDefault("linux/"+arch))
+ require.NoError(t, err)
+ require.Len(t, out, 1)
+ require.Equal(t, arch, out[0].Descriptor.Platform.Architecture)
+ }
+
+ // A builder default matching no subject is an error that lists the
+ // available platforms.
+ _, err = filterSubjectsByPlatform(subjects, nil, builderDefault("windows/amd64"))
+ require.ErrorContains(t, err, "linux/amd64 linux/arm64")
+
+ // A failing builder lookup is reported with the available platforms.
+ _, err = filterSubjectsByPlatform(subjects, nil, func() (ocispecs.Platform, error) {
+ return ocispecs.Platform{}, errors.New("builder unavailable")
+ })
+ require.ErrorContains(t, err, "builder unavailable")
+ require.ErrorContains(t, err, "linux/amd64 linux/arm64")
+
+ // A single platform subject is the default regardless of the builder.
+ out, err = filterSubjectsByPlatform([]*replay.Subject{arm}, nil, noDefault)
+ require.NoError(t, err)
+ require.Equal(t, []*replay.Subject{arm}, out)
+
+ // Explicit match on a single platform.
+ out, err = filterSubjectsByPlatform(subjects, []string{"linux/arm64"}, noDefault)
+ require.NoError(t, err)
+ require.Len(t, out, 1)
+ require.Equal(t, "arm64", out[0].Descriptor.Platform.Architecture)
+
+ // Comma-separated values are equivalent to repeating --platform.
+ out, err = filterSubjectsByPlatform(subjects, []string{"linux/amd64,linux/arm64"}, noDefault)
+ require.NoError(t, err)
+ require.Len(t, out, 2)
+ require.Equal(t, "amd64", out[0].Descriptor.Platform.Architecture)
+ require.Equal(t, "arm64", out[1].Descriptor.Platform.Architecture)
+ outRepeated, err := filterSubjectsByPlatform(subjects, []string{"linux/amd64", "linux/arm64"}, noDefault)
+ require.NoError(t, err)
+ require.Equal(t, outRepeated, out)
+
+ // Duplicate values collapse to one target, regardless of flag spelling.
+ out, err = filterSubjectsByPlatform(subjects, []string{"linux/amd64,linux/amd64"}, noDefault)
+ require.NoError(t, err)
+ require.Len(t, out, 1)
+ require.Equal(t, "amd64", out[0].Descriptor.Platform.Architecture)
+
+ _, err = filterSubjectsByPlatform(subjects, []string{"all,linux/amd64"}, noDefault)
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "cannot be combined")
+
+ // Explicit platform with no matching subject is an error.
+ _, err = filterSubjectsByPlatform([]*replay.Subject{amd}, []string{"linux/arm64"}, noDefault)
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "not present")
+
+ // Artifact selection is strict: execution compatibility must not select
+ // a different architecture or variant.
+ armv7 := &replay.Subject{Descriptor: ocispecs.Descriptor{Platform: &ocispecs.Platform{OS: "linux", Architecture: "arm", Variant: "v7"}}}
+ _, err = filterSubjectsByPlatform([]*replay.Subject{armv7}, []string{"linux/arm64"}, noDefault)
+ require.Error(t, err)
+
+ // A single manifest or attestation without platform metadata inherits an
+ // explicit requested platform so the solve itself is constrained.
+ platformless := &replay.Subject{}
+ out, err = filterSubjectsByPlatform([]*replay.Subject{platformless}, []string{"linux/arm64"}, noDefault)
+ require.NoError(t, err)
+ require.Len(t, out, 1)
+ require.Equal(t, "linux/arm64", platforms.Format(*out[0].Descriptor.Platform))
+ require.Nil(t, platformless.Descriptor.Platform, "filtering must not mutate the loaded subject")
+}
+
+func TestApplyPredicateTargetPlatformFallback(t *testing.T) {
+ subject := &replay.Subject{}
+ pred := &replay.Predicate{}
+ pred.BuildDefinition.InternalParameters.BuildConfig = &provenancetypes.BuildConfig{
+ Definition: []provenancetypes.BuildStep{{
+ Op: &solverpb.Op{Op: &solverpb.Op_Exec{Exec: &solverpb.ExecOp{Meta: &solverpb.Meta{
+ Env: []string{"BUILDPLATFORM=linux/amd64", "TARGETPLATFORM=linux/arm64"},
+ }}}},
+ }},
+ }
+
+ got := applyPredicateTargetPlatformFallback(subject, pred, nil)
+ require.NotSame(t, subject, got)
+ require.Equal(t, "linux/arm64", platforms.Format(*got.Descriptor.Platform))
+ require.Nil(t, subject.Descriptor.Platform, "defaulting must not mutate the loaded subject")
+
+ explicit, err := filterSubjectsByPlatform([]*replay.Subject{subject}, []string{"linux/amd64"}, func() (ocispecs.Platform, error) {
+ t.Fatal("builder default platform must not be looked up")
+ return ocispecs.Platform{}, nil
+ })
+ require.NoError(t, err)
+ require.Len(t, explicit, 1)
+ got = applyPredicateTargetPlatformFallback(explicit[0], pred, []string{"linux/amd64"})
+ require.Equal(t, "linux/amd64", platforms.Format(*got.Descriptor.Platform))
+}
+
+func TestBuildRejectsInvalidFlagsBeforeLoadingSubject(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ args []string
+ err string
+ }{
+ {name: "format-without-dry-run", args: []string{"--format=json"}, err: "--format requires --dry-run"},
+ {name: "unknown-format", args: []string{"--dry-run", "--format=yaml"}, err: `unknown --format "yaml"`},
+ {name: "unknown-replay-mode", args: []string{"--replay-mode=llb"}, err: `unknown --replay-mode "llb"`},
+ {name: "unknown-network", args: []string{"--network=host"}, err: `unsupported --network "host" (want default or none)`},
+ {name: "platform-all", args: []string{"--platform=all"}, err: `--platform "all" is not supported by replay build; select a single platform`},
+ {name: "invalid-platform", args: []string{"--platform=linux/amd64,"}, err: "invalid empty --platform value"},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ builderName := ""
+ cmd := buildCmd(nil, RootOptions{Builder: &builderName})
+ cmd.SetArgs(append(tc.args, "docker-image://example.com/foo:latest"))
+ cmd.SetOut(io.Discard)
+ cmd.SetErr(io.Discard)
+ require.EqualError(t, cmd.Execute(), tc.err)
+ })
+ }
+}
+
+func TestApplyExportShorthandsPush(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ outputs []string
+ }{
+ {name: "no-output"},
+ {name: "registry-output", outputs: []string{"type=registry,name=example.com/foo:bar"}},
+ {name: "image-output", outputs: []string{"type=image,name=example.com/foo:bar"}},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ specs, err := buildflags.ParseExports(tc.outputs)
+ require.NoError(t, err)
+ exports, _, err := build.CreateExports(applyExportShorthands(specs, true, false))
+ require.NoError(t, err)
+ require.Len(t, exports, 1, "--push must not add a second pushing exporter")
+ require.Equal(t, client.ExporterImage, exports[0].Type)
+ require.Equal(t, "true", exports[0].Attrs["push"])
+ require.Equal(t, "false", exports[0].Attrs["unpack"])
+ })
+ }
+}
+
+func TestPrintBuildPlan(t *testing.T) {
+ plan := &replay.BuildPlan{Subjects: []replay.SubjectBuildPlan{{
+ Descriptor: ocispecs.Descriptor{
+ Digest: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ Platform: &ocispecs.Platform{OS: "linux", Architecture: "arm64"},
+ },
+ Signature: &replay.SignatureVerification{
+ Verified: true,
+ Type: "Sigstore Bundle",
+ Identity: "Docker GitHub Builder (docker/buildx@v0.37.1)",
+ CertificateIssuer: "CN=sigstore-intermediate,O=sigstore.dev",
+ SubjectAlternativeName: "https://github.com/docker/github-builder/.github/workflows/build.yml@refs/heads/main",
+ Issuer: "https://token.actions.githubusercontent.com",
+ RunnerEnvironment: "github-hosted",
+ SourceRepositoryURI: "https://github.com/docker/buildx",
+ SourceRepositoryRef: "refs/tags/v0.37.1",
+ BuildSignerURI: "https://github.com/docker/github-builder/.github/workflows/build.yml@refs/heads/main",
+ Timestamps: []replay.SignatureTimestamp{{
+ Type: "Tlog",
+ URI: "https://rekor.sigstore.dev",
+ Timestamp: time.Date(2026, time.September, 18, 12, 0, 0, 0, time.UTC),
+ }, {
+ Type: "TimestampAuthority",
+ URI: "https://timestamp.sigstore.dev/api/v1/timestamp",
+ Timestamp: time.Date(2026, time.September, 18, 12, 0, 1, 0, time.UTC),
+ }},
+ },
+ BuildConfig: replay.BuildPlanConfig{
+ Frontend: "gateway.v0",
+ Context: "https://github.com/docker/buildx.git#refs/tags/v0.37.1",
+ Filename: "Dockerfile",
+ Target: "binaries",
+ BuildArgs: map[string]string{"ZED": "last", "ALPHA": "first"},
+ Secrets: []replay.PlanSecret{{ID: "GIT_AUTH_TOKEN", Optional: true}},
+ },
+ Materials: []replay.PlanMaterial{{
+ URI: "pkg:docker/golang@1.26-alpine3.23",
+ Platform: &ocispecs.Platform{OS: "linux", Architecture: "amd64"},
+ Digest: "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
+ Kind: "image",
+ }},
+ }}}
+
+ var out bytes.Buffer
+ require.NoError(t, printBuildPlan(&out, plan))
+ require.Equal(t, `Replay plan
+
+Subject 1/1
+ Platform: linux/arm64
+ Digest: sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+
+Build configuration
+ Frontend: gateway.v0
+ Context: https://github.com/docker/buildx.git#refs/tags/v0.37.1
+ Dockerfile: Dockerfile
+ Target: binaries
+ Build args: ALPHA=first
+ ZED=last
+ Secrets: [GIT_AUTH_TOKEN (optional)]
+
+Sigstore Bundle
+ Verified signer: Docker GitHub Builder (docker/buildx@v0.37.1)
+ Signer identity: https://github.com/docker/github-builder/.github/workflows/build.yml@refs/heads/main
+ Certificate issuer: CN=sigstore-intermediate,O=sigstore.dev
+ OIDC issuer: https://token.actions.githubusercontent.com
+ Runner environment: github-hosted
+ Source repository: https://github.com/docker/buildx
+ Source ref: refs/tags/v0.37.1
+ TYPE TIME SOURCE
+ Transparency log 2026-09-18T12:00:00Z https://rekor.sigstore.dev
+ Timestamp authority 2026-09-18T12:00:01Z https://timestamp.sigstore.dev/api/v1/timestamp
+
+Materials (1)
+ image [linux/amd64] pkg:docker/golang@1.26-alpine3.23
+ sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
+`, out.String())
+}
diff --git a/commands/replay/root.go b/commands/replay/root.go
new file mode 100644
index 000000000000..3475d89ea387
--- /dev/null
+++ b/commands/replay/root.go
@@ -0,0 +1,62 @@
+package replay
+
+import (
+ "github.com/docker/buildx/util/cobrautil"
+ "github.com/docker/buildx/util/cobrautil/completion"
+ "github.com/docker/cli/cli/command"
+ "github.com/spf13/cobra"
+)
+
+// RootOptions mirrors the shape used by history/policy/imagetools
+// (see commands/history/root.go).
+type RootOptions struct {
+ Builder *string
+}
+
+// commonOptions is the shared flag set for every replay subcommand.
+type commonOptions struct {
+ builder string
+ materials []string
+ network string
+ secrets []string
+ ssh []string
+ platforms []string
+ progress string
+}
+
+// installCommonFlags registers the shared flag set on the supplied
+// subcommand. Each subcommand owns its own flag registration so that
+// `--help` on any leaf prints the full contract.
+func installCommonFlags(cmd *cobra.Command, opts *commonOptions) {
+ flags := cmd.Flags()
+
+ flags.StringArrayVar(&opts.materials, "materials", nil, `Materials store (repeatable; format: "provenance" | "oci-layout://[:]" | "" | "=")`)
+ flags.StringVar(&opts.network, "network", "", `Network mode for RUN instructions ("default" | "none"; defaults to the mode of the original build)`)
+ flags.StringArrayVar(&opts.secrets, "secret", nil, `Secret to expose to the replayed build (format: "id=mysecret[,src=/local/secret]")`)
+ flags.StringArrayVar(&opts.ssh, "ssh", nil, `SSH agent socket or keys to expose (format: "default|[=|[,]]")`)
+ flags.StringArrayVar(&opts.platforms, "platform", nil, `Platform of the subject to replay (defaults to the only platform of the subject or the builder default platform)`)
+ flags.StringVar(&opts.progress, "progress", "auto", `Set type of progress output ("auto" | "plain" | "tty" | "quiet" | "rawjson")`)
+}
+
+// RootCmd returns the `buildx replay` root command. The rootcmd argument is
+// the buildx root; its RunE is reused when no subcommand is given, matching
+// the pattern in commands/history/root.go.
+func RootCmd(rootcmd *cobra.Command, dockerCli command.Cli, opts RootOptions) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "replay",
+ Short: "Replay a build from its provenance",
+ ValidArgsFunction: completion.Disable,
+ RunE: rootcmd.RunE,
+
+ DisableFlagsInUseLine: true,
+ }
+ cobrautil.MarkCommandExperimental(cmd)
+
+ cmd.AddCommand(
+ buildCmd(dockerCli, opts),
+ snapshotCmd(dockerCli, opts),
+ verifyCmd(dockerCli, opts),
+ )
+
+ return cmd
+}
diff --git a/commands/replay/snapshot.go b/commands/replay/snapshot.go
new file mode 100644
index 000000000000..5d0837bde45e
--- /dev/null
+++ b/commands/replay/snapshot.go
@@ -0,0 +1,202 @@
+package replay
+
+import (
+ "context"
+ "encoding/json"
+ "os"
+
+ "github.com/docker/buildx/replay"
+ "github.com/docker/buildx/util/buildflags"
+ "github.com/docker/buildx/util/cobrautil/completion"
+ "github.com/docker/buildx/util/progress"
+ "github.com/docker/cli/cli"
+ "github.com/docker/cli/cli/command"
+ "github.com/moby/buildkit/util/progress/progressui"
+ "github.com/pkg/errors"
+ "github.com/spf13/cobra"
+ "golang.org/x/term"
+)
+
+// snapshotOptions holds the parsed flags for `replay snapshot`.
+type snapshotOptions struct {
+ commonOptions
+ includeMaterials bool
+ outputs []string
+ dryRun bool
+}
+
+func snapshotCmd(dockerCli command.Cli, rootOpts RootOptions) *cobra.Command {
+ var opts snapshotOptions
+
+ cmd := &cobra.Command{
+ Use: "snapshot [OPTIONS] SUBJECT",
+ Short: "Export replay inputs for a subject as a reusable materials store",
+ Args: cli.ExactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ opts.builder = *rootOpts.Builder
+ return runSnapshot(cmd, dockerCli, &opts, args[0])
+ },
+ ValidArgsFunction: completion.Disable,
+ DisableFlagsInUseLine: true,
+ // Hidden until replay build can consume snapshots.
+ Hidden: true,
+ }
+
+ installCommonFlags(cmd, &opts.commonOptions)
+
+ flags := cmd.Flags()
+ flags.BoolVar(&opts.includeMaterials, "include-materials", true, "Include material content in the snapshot")
+ flags.StringArrayVarP(&opts.outputs, "output", "o", nil, `Output destination (default: "-" — oci tar to stdout; bare "" writes an oci-layout directory; "type=oci,dest=X[,tar=true|false]")`)
+ flags.BoolVar(&opts.dryRun, "dry-run", false, "Print a JSON plan of the snapshot without writing output")
+
+ return cmd
+}
+
+// runSnapshot wires the CLI flags to the replay.Snapshot entry point.
+func runSnapshot(cmd *cobra.Command, dockerCli command.Cli, opts *snapshotOptions, input string) error {
+ ctx := cmd.Context()
+
+ // Resolve --output → a normalized snapshot export spec. Dry-run does not
+ // write anything so we skip the TTY refusal and terminal checks there.
+ var exportSpec *buildflags.ExportEntry
+ if !opts.dryRun {
+ spec, err := resolveSnapshotOutput(opts.outputs)
+ if err != nil {
+ return err
+ }
+ exportSpec = spec
+ }
+
+ // Materials resolver — used to lookup pre-pinned content when
+ // --materials is supplied.
+ resolver, err := replay.NewMaterialsResolver(opts.materials)
+ if err != nil {
+ return err
+ }
+
+ subjects, err := replay.LoadSubjects(ctx, dockerCli, opts.builder, input)
+ if err != nil {
+ return err
+ }
+
+ subjects, err = filterSubjectsByPlatform(subjects, opts.platforms, builderDefaultPlatform(ctx, dockerCli, opts.builder))
+ if err != nil {
+ return err
+ }
+ if len(subjects) == 0 {
+ return errors.New("no subjects matched the --platform filter")
+ }
+ if err := replay.VerifySignatures(ctx, dockerCli, subjects); err != nil {
+ return err
+ }
+
+ targets := make([]replay.Target, 0, len(subjects))
+ for _, s := range subjects {
+ pred, err := s.Predicate(ctx)
+ if err != nil {
+ return err
+ }
+ s = applyPredicateTargetPlatformFallback(s, pred, opts.platforms)
+ targets = append(targets, replay.Target{Subject: s, Predicate: pred})
+ }
+
+ req := &replay.SnapshotRequest{
+ Targets: targets,
+ IncludeMaterials: opts.includeMaterials,
+ Materials: resolver,
+ Output: exportSpec,
+ }
+
+ // Both real-run and dry-run do the same staging work (dry-run just
+ // skips the final output), so both get a progress printer.
+ // Keep draining progress while cancellation propagates through material
+ // staging. Sharing ctx can strand a late writer after Ctrl-C.
+ printerCtx, cancelPrinter := context.WithCancelCause(context.TODO())
+ defer func() { cancelPrinter(errors.WithStack(context.Canceled)) }()
+ printer, err := progress.NewPrinter(printerCtx, os.Stderr, progressui.DisplayMode(opts.progress))
+ if err != nil {
+ return err
+ }
+ req.Progress = printer
+
+ if opts.dryRun {
+ plan, planErr := replay.MakeSnapshotPlan(ctx, dockerCli, opts.builder, req)
+ // Wait for the progress printer to drain before writing the JSON
+ // plan: in auto/tty mode the printer owns the terminal and its
+ // final redraw otherwise interleaves with stdout.
+ waitErr := printer.Wait()
+ if planErr != nil {
+ return planErr
+ }
+ if waitErr != nil {
+ return waitErr
+ }
+ enc := json.NewEncoder(cmd.OutOrStdout())
+ enc.SetIndent("", " ")
+ return enc.Encode(plan)
+ }
+
+ snapErr := replay.Snapshot(ctx, dockerCli, opts.builder, req)
+ if waitErr := printer.Wait(); snapErr == nil {
+ snapErr = waitErr
+ }
+ return snapErr
+}
+
+// resolveSnapshotOutput turns raw --output values into a normalized
+// ExportEntry with Type "oci". The command surface is:
+//
+// (unset) → type=oci, dest=- (stdout tar)
+// -o - → type=oci, dest=- (stdout tar)
+// -o → type=oci, dest=, tar=false (layout dir)
+// -o type=oci,dest=[,tar=...] → oci, defaults to tar=true
+//
+// A TTY on stdout with no --output (or -o -) is refused: writing a
+// multi-megabyte binary tar to a terminal is never what the user wants.
+func resolveSnapshotOutput(outputs []string) (*buildflags.ExportEntry, error) {
+ if len(outputs) > 1 {
+ return nil, errors.Errorf("snapshot: exactly one --output is required (got %d)", len(outputs))
+ }
+
+ var out buildflags.ExportEntry
+ if len(outputs) == 0 {
+ out = buildflags.ExportEntry{Type: "oci", Destination: "-"}
+ } else {
+ parsed, err := buildflags.ParseExports(outputs)
+ if err != nil {
+ return nil, errors.Wrap(err, "parse --output")
+ }
+ if len(parsed) != 1 {
+ return nil, errors.Errorf("snapshot: exactly one --output is required (got %d)", len(parsed))
+ }
+ out = *parsed[0]
+ }
+
+ // buildflags.ParseExports maps a bare "-" to type="tar" and a bare
+ // "" to type="local". Translate both into our oci surface.
+ switch out.Type {
+ case "tar":
+ out.Type = "oci"
+ case "local":
+ // Bare path → oci-layout directory.
+ out.Type = "oci"
+ if out.Attrs == nil {
+ out.Attrs = map[string]string{}
+ }
+ out.Attrs["tar"] = "false"
+ }
+
+ if out.Type == "oci" && out.Destination == "-" {
+ if term.IsTerminal(int(os.Stdout.Fd())) {
+ return nil, errors.New("refusing to write binary snapshot to terminal — set an --output file or directory")
+ }
+ }
+
+ if out.Type == "registry" {
+ return nil, replay.ErrNotImplemented("snapshot registry output")
+ }
+ if out.Type != "oci" {
+ return nil, errors.Errorf("snapshot: unsupported --output type %q (want oci)", out.Type)
+ }
+ return &out, nil
+}
diff --git a/commands/replay/snapshot_test.go b/commands/replay/snapshot_test.go
new file mode 100644
index 000000000000..e633c567e583
--- /dev/null
+++ b/commands/replay/snapshot_test.go
@@ -0,0 +1,15 @@
+package replay
+
+import (
+ "testing"
+
+ replaypkg "github.com/docker/buildx/replay"
+ "github.com/stretchr/testify/require"
+)
+
+func TestResolveSnapshotOutputRejectsRegistry(t *testing.T) {
+ _, err := resolveSnapshotOutput([]string{"type=registry,name=example.com/replay:test"})
+ require.Error(t, err)
+ var notImplemented *replaypkg.NotImplementedError
+ require.ErrorAs(t, err, ¬Implemented)
+}
diff --git a/commands/replay/verify.go b/commands/replay/verify.go
new file mode 100644
index 000000000000..1d45a65cd3e2
--- /dev/null
+++ b/commands/replay/verify.go
@@ -0,0 +1,127 @@
+package replay
+
+import (
+ "github.com/docker/buildx/replay"
+ "github.com/docker/buildx/util/buildflags"
+ "github.com/docker/buildx/util/cobrautil/completion"
+ "github.com/docker/cli/cli"
+ "github.com/docker/cli/cli/command"
+ "github.com/moby/buildkit/util/progress/progressui"
+ "github.com/pkg/errors"
+ "github.com/spf13/cobra"
+)
+
+// verifyOptions holds the parsed flags for `replay verify`.
+type verifyOptions struct {
+ commonOptions
+ compare string
+ outputs []string
+}
+
+func verifyCmd(dockerCli command.Cli, rootOpts RootOptions) *cobra.Command {
+ var opts verifyOptions
+
+ cmd := &cobra.Command{
+ Use: "verify [OPTIONS] SUBJECT",
+ Short: "Replay a subject and compare the result against the original artifact",
+ Args: cli.ExactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ opts.builder = *rootOpts.Builder
+ return runVerify(cmd, dockerCli, &opts, args[0])
+ },
+ ValidArgsFunction: completion.Disable,
+ DisableFlagsInUseLine: true,
+ // Hidden until the comparison is reliable: builder cache reuse can
+ // make a non-reproducible build compare equal.
+ Hidden: true,
+ }
+
+ installCommonFlags(cmd, &opts.commonOptions)
+
+ flags := cmd.Flags()
+ flags.StringVar(&opts.compare, "compare", "digest", `Comparison mode ("digest" | "artifact")`)
+ flags.StringArrayVarP(&opts.outputs, "output", "o", nil, `Output destination for the verification result (VSA) (format: "type=local,dest=path")`)
+
+ return cmd
+}
+
+// runVerify wires the CLI flags to replay.Verify. Verification runs on a
+// single platform subject.
+func runVerify(cmd *cobra.Command, dockerCli command.Cli, opts *verifyOptions, input string) error {
+ ctx := cmd.Context()
+
+ mode := opts.compare
+ switch mode {
+ case "", replay.CompareModeDigest, replay.CompareModeArtifact:
+ // ok
+ default:
+ return errors.Errorf("unknown --compare %q", opts.compare)
+ }
+
+ // Parse --output (optional).
+ var exportSpec *buildflags.ExportEntry
+ if len(opts.outputs) > 0 {
+ specs, err := buildflags.ParseExports(opts.outputs)
+ if err != nil {
+ return errors.Wrap(err, "parse --output")
+ }
+ if len(specs) != 1 {
+ return errors.Errorf("verify: exactly one --output is required (got %d)", len(specs))
+ }
+ exportSpec = specs[0]
+ }
+
+ resolver, err := replay.NewMaterialsResolver(opts.materials)
+ if err != nil {
+ return err
+ }
+ secretSpecs, err := buildflags.ParseSecretSpecs(opts.secrets)
+ if err != nil {
+ return errors.Wrap(err, "parse --secret")
+ }
+ sshSpecs, err := buildflags.ParseSSHSpecs(opts.ssh)
+ if err != nil {
+ return errors.Wrap(err, "parse --ssh")
+ }
+
+ subjects, err := replay.LoadSubjects(ctx, dockerCli, opts.builder, input)
+ if err != nil {
+ return err
+ }
+ subjects, err = filterSubjectsByPlatform(subjects, opts.platforms, builderDefaultPlatform(ctx, dockerCli, opts.builder))
+ if err != nil {
+ return err
+ }
+ if len(subjects) == 0 {
+ return errors.New("no subjects matched the --platform filter")
+ }
+ if len(subjects) > 1 {
+ return replay.ErrNotImplemented("multi-subject replay verify output aggregation")
+ }
+ if err := replay.VerifySignatures(ctx, dockerCli, subjects); err != nil {
+ return err
+ }
+
+ for _, s := range subjects {
+ pred, err := s.Predicate(ctx)
+ if err != nil {
+ return err
+ }
+ s = applyPredicateTargetPlatformFallback(s, pred, opts.platforms)
+ req := &replay.VerifyRequest{
+ Subject: s,
+ Predicate: pred,
+ Mode: mode,
+ Materials: resolver,
+ Network: opts.network,
+ Secrets: secretSpecs,
+ SSH: sshSpecs,
+ Output: exportSpec,
+ Progress: progressui.DisplayMode(opts.progress),
+ }
+ if _, err := replay.Verify(ctx, dockerCli, opts.builder, req); err != nil {
+ return err
+ }
+ }
+ return nil
+}
diff --git a/commands/root.go b/commands/root.go
index 880244db6862..38e3fd3108b1 100644
--- a/commands/root.go
+++ b/commands/root.go
@@ -8,6 +8,7 @@ import (
historycmd "github.com/docker/buildx/commands/history"
imagetoolscmd "github.com/docker/buildx/commands/imagetools"
policycmd "github.com/docker/buildx/commands/policy"
+ replaycmd "github.com/docker/buildx/commands/replay"
"github.com/docker/buildx/util/cobrautil/completion"
"github.com/docker/buildx/util/confutil"
"github.com/docker/buildx/util/logutil"
@@ -133,6 +134,7 @@ func addCommands(cmd *cobra.Command, opts *rootOptions, dockerCli command.Cli) {
duCmd(dockerCli, opts),
imagetoolscmd.RootCmd(cmd, dockerCli, imagetoolscmd.RootOptions{Builder: &opts.builder}),
historycmd.RootCmd(cmd, dockerCli, historycmd.RootOptions{Builder: &opts.builder}),
+ replaycmd.RootCmd(cmd, dockerCli, replaycmd.RootOptions{Builder: &opts.builder}),
dapCmd(dockerCli, opts),
)
if confutil.IsExperimental() {
diff --git a/docs/reference/buildx.md b/docs/reference/buildx.md
index 3aa0cb70f5a2..ffbe150a518c 100644
--- a/docs/reference/buildx.md
+++ b/docs/reference/buildx.md
@@ -9,25 +9,26 @@ Extended build capabilities with BuildKit
### Subcommands
-| Name | Description |
-|:-------------------------------------|:-------------------------------------------------|
-| [`bake`](buildx_bake.md) | Build from a file |
-| [`build`](buildx_build.md) | Start a build |
-| [`create`](buildx_create.md) | Create a new builder instance |
-| [`dap`](buildx_dap.md) | Start debug adapter protocol compatible debugger |
-| [`debug`](buildx_debug.md) | Start debugger (EXPERIMENTAL) |
-| [`dial-stdio`](buildx_dial-stdio.md) | Proxy current stdio streams to builder instance |
-| [`du`](buildx_du.md) | Disk usage |
-| [`history`](buildx_history.md) | Commands to work on build records |
-| [`imagetools`](buildx_imagetools.md) | Commands to work on images in registry |
-| [`inspect`](buildx_inspect.md) | Inspect current builder instance |
-| [`ls`](buildx_ls.md) | List builder instances |
-| [`policy`](buildx_policy.md) | Commands for working with build policies |
-| [`prune`](buildx_prune.md) | Remove build cache |
-| [`rm`](buildx_rm.md) | Remove one or more builder instances |
-| [`stop`](buildx_stop.md) | Stop builder instance |
-| [`use`](buildx_use.md) | Set the current builder instance |
-| [`version`](buildx_version.md) | Show buildx version information |
+| Name | Description |
+|:-------------------------------------|:--------------------------------------------------|
+| [`bake`](buildx_bake.md) | Build from a file |
+| [`build`](buildx_build.md) | Start a build |
+| [`create`](buildx_create.md) | Create a new builder instance |
+| [`dap`](buildx_dap.md) | Start debug adapter protocol compatible debugger |
+| [`debug`](buildx_debug.md) | Start debugger (EXPERIMENTAL) |
+| [`dial-stdio`](buildx_dial-stdio.md) | Proxy current stdio streams to builder instance |
+| [`du`](buildx_du.md) | Disk usage |
+| [`history`](buildx_history.md) | Commands to work on build records |
+| [`imagetools`](buildx_imagetools.md) | Commands to work on images in registry |
+| [`inspect`](buildx_inspect.md) | Inspect current builder instance |
+| [`ls`](buildx_ls.md) | List builder instances |
+| [`policy`](buildx_policy.md) | Commands for working with build policies |
+| [`prune`](buildx_prune.md) | Remove build cache |
+| [`replay`](buildx_replay.md) | Replay a build from its provenance (EXPERIMENTAL) |
+| [`rm`](buildx_rm.md) | Remove one or more builder instances |
+| [`stop`](buildx_stop.md) | Stop builder instance |
+| [`use`](buildx_use.md) | Set the current builder instance |
+| [`version`](buildx_version.md) | Show buildx version information |
### Options
diff --git a/docs/reference/buildx_replay.md b/docs/reference/buildx_replay.md
new file mode 100644
index 000000000000..f01728b69e47
--- /dev/null
+++ b/docs/reference/buildx_replay.md
@@ -0,0 +1,60 @@
+# docker buildx replay
+
+```text
+docker buildx replay [OPTIONS] COMMAND
+```
+
+
+Replay a build from its provenance (EXPERIMENTAL)
+
+### Subcommands
+
+| Name | Description |
+|:----------------------------------|:------------------------------------------------------|
+| [`build`](buildx_replay_build.md) | Rebuild an image from provenance and pinned materials |
+
+
+### Options
+
+| Name | Type | Default | Description |
+|:----------------|:---------|:--------|:-----------------------------------------|
+| `--builder` | `string` | | Override the configured builder instance |
+| `-D`, `--debug` | `bool` | | Enable debug logging |
+
+
+
+
+## Description
+
+`buildx replay` reads the SLSA provenance attestation of an existing build and
+reproduces the build with the recorded frontend, options, and source digests.
+Replay runs on the selected builder and requires BuildKit v0.27 or later.
+
+Subjects are accepted in three forms:
+
+- `docker-image://[` or a bare `][` — resolve through the registry.
+- `oci-layout://[:]` — read from a local OCI layout.
+- A local attestation file: an in-toto statement (`.intoto.jsonl`), an
+ unsigned DSSE envelope, a Sigstore bundle, or a bare SLSA provenance
+ predicate.
+
+A build can be replayed when:
+
+- its provenance was recorded with `mode=max`
+ (`--provenance=mode=max` or `--attest=type=provenance,mode=max`). `mode=min`
+ provenance omits the build arguments, secrets, and SSH needed for replay;
+- its build context was a Git repository or an HTTP(S) URL. Builds that used
+ local directories, stdin, OCI layouts, or other Bake targets as build
+ contexts cannot be replayed;
+- it did not use `--network=host`, unless a different `--network` is passed
+ to replay;
+- the recorded sources are still available.
+
+Replay rebuilds one platform at a time. For a multi-platform image, select the
+platform with `--platform`. By default, the only platform of the image or the
+default platform of the builder is used.
+
+## Related
+
+- [SLSA Provenance v1](https://slsa.dev/provenance/v1)
+- [`docker buildx history`](buildx_history.md) — inspect locally recorded builds
diff --git a/docs/reference/buildx_replay_build.md b/docs/reference/buildx_replay_build.md
new file mode 100644
index 000000000000..0d8f624a12ef
--- /dev/null
+++ b/docs/reference/buildx_replay_build.md
@@ -0,0 +1,75 @@
+# docker buildx replay build
+
+
+Rebuild an image from provenance and pinned materials
+
+### Options
+
+| Name | Type | Default | Description |
+|:-----------------|:--------------|:------------|:-----------------------------------------------------------------------------------------------------------------|
+| `--builder` | `string` | | Override the configured builder instance |
+| `-D`, `--debug` | `bool` | | Enable debug logging |
+| `--dry-run` | `bool` | | Print a plan of the replay without solving or exporting |
+| `--format` | `string` | `pretty` | Format dry-run output (`pretty` \| `json`) |
+| `--load` | `bool` | | Shorthand for `--output=type=docker` |
+| `--network` | `string` | | Network mode for RUN instructions (`default` \| `none`; defaults to the mode of the original build) |
+| `-o`, `--output` | `stringArray` | | Output destination (format: `type=local,dest=path`) |
+| `--platform` | `stringArray` | | Platform of the subject to replay (defaults to the only platform of the subject or the builder default platform) |
+| `--progress` | `string` | `auto` | Set type of progress output (`auto` \| `plain` \| `tty` \| `quiet` \| `rawjson`) |
+| `--push` | `bool` | | Shorthand for `--output=type=registry,unpack=false` |
+| `--replay-mode` | `string` | `materials` | Replay mode (`materials` \| `frontend`) |
+| `--secret` | `stringArray` | | Secret to expose to the replayed build (format: `id=mysecret[,src=/local/secret]`) |
+| `--ssh` | `stringArray` | | SSH agent socket or keys to expose (format: `default\|[=\|[,]]`) |
+| `-t`, `--tag` | `stringArray` | | Image identifier (format: `[registry/]repository[:tag]`) |
+
+
+
+
+## Description
+
+`replay build` reconstructs an image from the provenance attestation attached
+to an existing subject.
+
+The replay mode controls how sources are resolved:
+
+- `materials` (default) pins every source to the digest recorded in the
+ provenance. A source that is not recorded, or whose content changed, fails
+ the build.
+- `frontend` replays the recorded frontend and options, but resolves sources
+ again, so the result can differ from the original build.
+
+Replayed builds do not add new provenance or SBOM attestations. Local outputs
+with `mode=delete` are not supported.
+
+## Examples
+
+### Replay a registry image and export to an OCI tar
+
+```console
+docker buildx replay build docker-image://example.com/app@sha256:deadbeef \
+ --output=type=oci,dest=replay.oci.tar
+```
+
+### Dry-run a replay to inspect the plan
+
+```console
+docker buildx replay build docker-image://example.com/app@sha256:deadbeef --dry-run --format=json | jq
+```
+
+Dry-run runs the same checks as a real replay, so a subject that cannot be
+replayed fails before any build starts.
+
+## Signature verification
+
+For image subjects, replay discovers Sigstore signatures attached to the
+selected platform's provenance attestation through OCI referrers. Standalone
+Sigstore bundle files are also accepted. Replay verifies the certificate,
+transparency-log inclusion, observer timestamp, and signed payload before using
+the provenance. Pretty and JSON dry-run output report the verified identity.
+
+Unsigned provenance remains accepted. If a published signature is discovered
+but is invalid, replay fails instead of silently treating it as unsigned.
+There is not yet a `--require-signature` option or signer-authorization policy.
+For a standalone bundle, verification authenticates the signed statement and
+its claimed subject digest; it does not compare that digest with a separately
+supplied artifact.
diff --git a/go.mod b/go.mod
index d5a532ed2f39..a67f8432a6c5 100644
--- a/go.mod
+++ b/go.mod
@@ -39,6 +39,7 @@ require (
github.com/open-policy-agent/opa v1.19.0
github.com/opencontainers/go-digest v1.0.0
github.com/opencontainers/image-spec v1.1.1
+ github.com/package-url/packageurl-go v0.1.1
github.com/pelletier/go-toml/v2 v2.4.3
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
github.com/pkg/errors v0.9.1
@@ -177,7 +178,6 @@ require (
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/oklog/ulid/v2 v2.1.1 // indirect
- github.com/package-url/packageurl-go v0.1.1 // indirect
github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 // indirect
diff --git a/policy/funcs.go b/policy/funcs.go
index 3593504661ee..fc673b7af983 100644
--- a/policy/funcs.go
+++ b/policy/funcs.go
@@ -665,7 +665,7 @@ func (p *Policy) builtinLoadJSONImpl(bctx rego.BuiltinContext, a *ast.Term) (*as
return ast.NewTerm(astVal), nil
}
-func addPinToImage(src *pb.SourceOp, dgst digest.Digest) (*pb.SourceOp, error) {
+func AddPinToImage(src *pb.SourceOp, dgst digest.Digest) (*pb.SourceOp, error) {
id, ok := strings.CutPrefix(src.Identifier, "docker-image://")
if !ok {
return nil, errors.Errorf("cannot pin non-image source: %q", src.Identifier)
diff --git a/policy/input.go b/policy/input.go
index bc53ed58b58a..25a9ed8d5a6e 100644
--- a/policy/input.go
+++ b/policy/input.go
@@ -46,7 +46,7 @@ func sourceToInputRecursive(ctx context.Context, verifier PolicyVerifierProvider
materials := make([]Input, 0, len(inp.Image.Provenance.materialsRaw))
for _, m := range inp.Image.Provenance.materialsRaw {
- matSrc, matPlatform, err := parseSLSAMaterial(m)
+ matSrc, matPlatform, err := ParseSLSAMaterial(m)
if err != nil {
materials = append(materials, Input{})
continue
diff --git a/policy/materials.go b/policy/materials.go
index b705efa1db92..084110ff6894 100644
--- a/policy/materials.go
+++ b/policy/materials.go
@@ -34,7 +34,7 @@ func isMaterialKey(key string) (idx int, rest string, ok bool) {
return n, rest, true
}
-func parseSLSAMaterial(m slsa1.ResourceDescriptor) (*pb.SourceOp, *ocispecs.Platform, error) {
+func ParseSLSAMaterial(m slsa1.ResourceDescriptor) (*pb.SourceOp, *ocispecs.Platform, error) {
uri := m.URI
dgst := m.Digest
if strings.HasPrefix(uri, "pkg:docker/") {
diff --git a/policy/provenance.go b/policy/provenance.go
index b16221b66899..19c96b747b70 100644
--- a/policy/provenance.go
+++ b/policy/provenance.go
@@ -165,7 +165,7 @@ func rawMaterialsFromSLSA1(materials []slsa1.ResourceDescriptor, logf func(logru
URI: m.URI,
Digest: maps.Clone(m.Digest),
}
- if _, _, err := parseSLSAMaterial(rd); err != nil {
+ if _, _, err := ParseSLSAMaterial(rd); err != nil {
if logf != nil {
logf(logrus.WarnLevel, fmt.Sprintf("skipping unsupported provenance material %q: %v", m.URI, err))
}
@@ -186,7 +186,7 @@ func rawMaterialsFromSLSA02(materials []slsa02.ProvenanceMaterial, logf func(log
URI: m.URI,
Digest: maps.Clone(m.Digest),
}
- if _, _, err := parseSLSAMaterial(rd); err != nil {
+ if _, _, err := ParseSLSAMaterial(rd); err != nil {
if logf != nil {
logf(logrus.WarnLevel, fmt.Sprintf("skipping unsupported provenance material %q: %v", m.URI, err))
}
diff --git a/policy/resolve.go b/policy/resolve.go
index 84aa8c102714..d805fc0e0827 100644
--- a/policy/resolve.go
+++ b/policy/resolve.go
@@ -62,7 +62,7 @@ func resolveNodeUnknowns(ctx context.Context, node *Input, source *pb.SourceOp,
continue
}
raw := node.Image.Provenance.materialsRaw[idx]
- childSource, childNodePlatform, err := parseSLSAMaterial(raw)
+ childSource, childNodePlatform, err := ParseSLSAMaterial(raw)
if err != nil {
continue
}
diff --git a/policy/validate.go b/policy/validate.go
index 5bd41be4a7a0..6aed8ec07368 100644
--- a/policy/validate.go
+++ b/policy/validate.go
@@ -339,7 +339,7 @@ func (p *Policy) CheckPolicy(ctx context.Context, req *policysession.CheckPolicy
return nil, nil, errors.Errorf("multiple image pins set to %s: %v", sourceName(req), st.ImagePins)
}
if len(st.ImagePins) == 1 {
- newSrc, err := addPinToImage(req.Source.Source, slices.Collect(maps.Keys(st.ImagePins))[0])
+ newSrc, err := AddPinToImage(req.Source.Source, slices.Collect(maps.Keys(st.ImagePins))[0])
if err != nil {
return nil, nil, errors.Wrapf(err, "failed to add image pin to source")
}
diff --git a/replay/build.go b/replay/build.go
new file mode 100644
index 000000000000..3a789930f79c
--- /dev/null
+++ b/replay/build.go
@@ -0,0 +1,616 @@
+package replay
+
+import (
+ "context"
+ "fmt"
+ "maps"
+ "net/url"
+ "slices"
+ "sort"
+ "strings"
+
+ "github.com/containerd/platforms"
+ "github.com/docker/buildx/build"
+ "github.com/docker/buildx/builder"
+ "github.com/docker/buildx/util/buildflags"
+ "github.com/docker/buildx/util/confutil"
+ "github.com/docker/buildx/util/dockerutil"
+ "github.com/docker/buildx/util/progress"
+ "github.com/docker/cli/cli/command"
+ "github.com/moby/buildkit/client"
+ "github.com/moby/buildkit/identity"
+ provenancetypes "github.com/moby/buildkit/solver/llbsolver/provenance/types"
+ "github.com/moby/buildkit/util/progress/progressui"
+ ocispecs "github.com/opencontainers/image-spec/specs-go/v1"
+ "github.com/pkg/errors"
+ "github.com/tonistiigi/go-csvvalue"
+)
+
+// BuildMode is a replay mode.
+type BuildMode string
+
+const (
+ BuildModeMaterials BuildMode = "materials"
+ BuildModeFrontend BuildMode = "frontend"
+)
+
+// Target pairs one subject with its already-loaded predicate. A replay
+// operation spans N targets (one per platform, typically from a multi-
+// platform LoadSubjects fan-out).
+type Target struct {
+ Subject *Subject
+ Predicate *Predicate
+}
+
+// BuildRequest is a single replay-build invocation spanning one or more
+// targets that share the same user-supplied flags.
+type BuildRequest struct {
+ // Targets is the set of (subject, predicate) pairs to replay. For a
+ // single-platform subject this is len 1; multi-platform inputs fan
+ // out into multiple targets sharing the other fields below.
+ Targets []Target
+
+ // Mode selects a replay strategy. Empty defaults to BuildModeMaterials.
+ Mode BuildMode
+
+ // Materials resolves provenance materials to local content stores. May
+ // be nil, in which case the default sentinel-only resolver is used.
+ Materials *MaterialsResolver
+
+ // NetworkMode controls the network mode for RUN instructions in the
+ // replayed build (default | none). Empty uses the recorded mode.
+ // Material resolution is NOT affected.
+ NetworkMode string
+
+ // Secrets / SSH hold the user-supplied specs for the replayed solve.
+ // Cross-checked against each predicate via Secrets()/SSH() before any
+ // solve begins.
+ Secrets buildflags.Secrets
+ SSH []*buildflags.SSH
+
+ // Exports are the buildflags-parsed --output specs.
+ Exports []*buildflags.ExportEntry
+
+ // Tags are "--tag" values to apply to image/oci/docker exports. Flow
+ // matches `docker buildx build`: the tags become the `name=` attribute
+ // on each eligible export via build/opt.go toSolveOpt.
+ Tags []string
+
+ // Progress controls the display mode for replay progress output.
+ Progress progressui.DisplayMode
+}
+
+// checkBuildRequest runs the pre-solve checks shared by Build and
+// MakeBuildPlan so that a dry-run fails exactly like the real replay would.
+func checkBuildRequest(req *BuildRequest) error {
+ if len(req.Targets) == 0 {
+ return errors.New("no targets to replay")
+ }
+ if len(req.Targets) > 1 {
+ return ErrNotImplemented("replaying more than one platform at a time; select a single platform with --platform")
+ }
+ for _, t := range req.Targets {
+ if t.Subject == nil || t.Predicate == nil {
+ return errors.New("target has nil subject or predicate")
+ }
+ if err := checkReplayable(t.Predicate, req.Mode, req.Secrets, req.SSH); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// createExports parses the export specs. Local exports with mode=delete are
+// rejected: `buildx build` requires --allow=buildx.local.delete for most
+// destinations, and replay has no --allow flag.
+func createExports(specs []*buildflags.ExportEntry) ([]client.ExportEntry, error) {
+ exports, _, err := build.CreateExports(specs)
+ if err != nil {
+ return nil, errors.Wrap(err, "parse --output")
+ }
+ for _, e := range exports {
+ if e.Type != client.ExporterLocal {
+ continue
+ }
+ mode, err := client.ParseLocalExporterMode(e.Attrs["mode"])
+ if err != nil {
+ return nil, err
+ }
+ if mode == client.LocalExporterModeDelete {
+ return nil, errors.New("replay does not support local output mode=delete")
+ }
+ }
+ return exports, nil
+}
+
+// checkReplayable rejects provenance that cannot be replayed faithfully and
+// cross-checks the user-supplied secrets and SSH against the recorded ones.
+func checkReplayable(pred *Predicate, mode BuildMode, secrets buildflags.Secrets, ssh []*buildflags.SSH) error {
+ if names := localInputs(pred); len(names) > 0 {
+ return ErrUnreplayableLocalContext(names)
+ }
+ if pred.IsMinMode() {
+ return ErrMinModeProvenance()
+ }
+ if mode == "" || mode == BuildModeMaterials {
+ if err := checkContextPinned(pred); err != nil {
+ return err
+ }
+ }
+ if err := CheckSecrets(pred.Secrets(), secrets); err != nil {
+ return err
+ }
+ return CheckSSH(pred.SSH(), ssh)
+}
+
+// localInputs returns the names of recorded inputs that only existed on the
+// original client: local directories, a context read from stdin, and named
+// contexts that referenced an OCI layout store or another bake target.
+func localInputs(pred *Predicate) []string {
+ seen := map[string]struct{}{}
+ for _, l := range pred.Locals() {
+ seen[l.Name] = struct{}{}
+ }
+ attrs := pred.FrontendAttrs()
+ // A context read from stdin is uploaded through the client session and
+ // recorded as http://buildkit-session/.
+ for _, uri := range []string{pred.ConfigSource().URI, attrs["context"]} {
+ if u, err := url.Parse(uri); err == nil && u.Host == "buildkit-session" {
+ seen["context (stdin)"] = struct{}{}
+ }
+ }
+ for k, v := range attrs {
+ name, ok := strings.CutPrefix(k, "context:")
+ if !ok {
+ continue
+ }
+ for _, prefix := range []string{"oci-layout://", "local:", "input:", "target:"} {
+ if strings.HasPrefix(v, prefix) {
+ seen[name] = struct{}{}
+ break
+ }
+ }
+ }
+ return slices.Sorted(maps.Keys(seen))
+}
+
+// checkContextPinned rejects a Git subdirectory context whose commit is not
+// recorded. BuildKit before v0.25 recorded no digest for the build context
+// and recorded the Git material without the subdirectory, so the context
+// request cannot be matched to a pinned material and would only fail during
+// the solve.
+func checkContextPinned(pred *Predicate) error {
+ cfg := pred.ConfigSource()
+ if cfg.URI == "" || len(cfg.Digest) > 0 {
+ return nil
+ }
+ if _, fragment, ok := strings.Cut(cfg.URI, "#"); !ok || !strings.Contains(fragment, ":") {
+ return nil
+ }
+ if _, ok := NewPinIndex(pred).Lookup(cfg.URI); ok {
+ return nil
+ }
+ return ErrUnpinnedContext(cfg.URI)
+}
+
+// SubjectKey returns a stable identifier for a subject, used as the map key
+// for build.Build's map[string]Options input.
+func SubjectKey(s *Subject) string {
+ if s == nil {
+ return ""
+ }
+ if s.Descriptor.Platform != nil {
+ return fmt.Sprintf("%s@%s", s.Descriptor.Digest, platforms.Format(*s.Descriptor.Platform))
+ }
+ return s.Descriptor.Digest.String()
+}
+
+// Build executes the replay request against the supplied builder.
+//
+// Fail-fast: cross-check errors are reported per-subject with typed errors
+// (Missing/ExtraSecretError, Missing/ExtraSSHError) before any solve starts;
+// a local-context predicate fails with UnreplayableLocalContextError.
+//
+// Mode selection:
+//
+// - BuildModeMaterials (default): recorded frontend + strict source-policy
+// pinning via the session policy callback.
+// - BuildModeFrontend: recorded frontend + NO strict pinning (sources float).
+func Build(ctx context.Context, dockerCli command.Cli, builderName string, req *BuildRequest) (retErr error) {
+ if req == nil {
+ return errors.New("nil build request")
+ }
+ if err := checkBuildRequest(req); err != nil {
+ return err
+ }
+
+ // Parse exports once; shared across all targets.
+ exports, err := createExports(req.Exports)
+ if err != nil {
+ return err
+ }
+
+ // Build the map[string]build.Options keyed by subject key.
+ buildOpts := make(map[string]build.Options, len(req.Targets))
+ for _, t := range req.Targets {
+ opt, err := BuildOptionsFromPredicate(t.Subject, t.Predicate, req)
+ if err != nil {
+ return err
+ }
+ opt.Exports = exports
+ buildOpts[SubjectKey(t.Subject)] = opt
+ }
+
+ // Builder + printer wiring.
+ b, err := builder.New(dockerCli, builder.WithName(builderName))
+ if err != nil {
+ return err
+ }
+ nodes, err := b.LoadNodes(ctx)
+ if err != nil {
+ return err
+ }
+ mode := req.Mode
+ if mode == "" {
+ mode = BuildModeMaterials
+ }
+ warningMsg := ""
+ if mode == BuildModeMaterials {
+ warningMsg = materialsModePlatformWarning(req.Targets, nodes)
+ }
+
+ progressMode := req.Progress
+ if progressMode == "" {
+ progressMode = progressui.AutoMode
+ }
+ // Keep the printer alive while cancellation propagates through BuildKit.
+ // If it shares the solve context, Ctrl-C stops its reader before producers
+ // finish and a late progress write can block solve cleanup indefinitely.
+ printerCtx, cancelPrinter := context.WithCancelCause(context.TODO())
+ defer func() { cancelPrinter(errors.WithStack(context.Canceled)) }()
+ printer, err := progress.NewPrinter(printerCtx, dockerCli.Err(), progressMode,
+ progress.WithDesc(
+ fmt.Sprintf("rebuilding %d subject(s) with %q instance using %s driver", len(req.Targets), b.Name, b.Driver),
+ fmt.Sprintf("%s:%s", b.Driver, b.Name),
+ ),
+ )
+ if err != nil {
+ return err
+ }
+ defer func() {
+ werr := printer.Wait()
+ if retErr == nil {
+ retErr = werr
+ }
+ }()
+ if warningMsg != "" {
+ if err := progress.Wrap("check replay environment", printer.Write, func(sub progress.SubLogger) error {
+ sub.Log(2, []byte("warning: "+warningMsg+"\n"))
+ return nil
+ }); err != nil {
+ return err
+ }
+ }
+
+ if _, err := build.Build(ctx, nodes, buildOpts, dockerutil.NewClient(dockerCli), confutil.NewConfig(dockerCli), printer, nil); err != nil {
+ return errors.Wrap(err, "replay build")
+ }
+ return nil
+}
+
+func materialsModePlatformWarning(targets []Target, nodes []builder.Node) string {
+ hostPlat := platforms.Normalize(platforms.DefaultSpec())
+ instancePlat := &hostPlat
+ if len(nodes) == 0 {
+ instanceFmt := platforms.Format(*instancePlat)
+ for _, t := range targets {
+ if t.Predicate == nil {
+ continue
+ }
+ prov, ok := t.Predicate.DefaultPlatform()
+ if !ok || prov == nil {
+ continue
+ }
+ provFmt := platforms.Format(*prov)
+ if provFmt == instanceFmt {
+ continue
+ }
+ return fmt.Sprintf("provenance default platform %s does not match current builder instance default platform %s; materials-mode replay may be inefficient or fail", provFmt, instanceFmt)
+ }
+ return ""
+ }
+ matchedHost := false
+ for _, n := range nodes {
+ if n.Err != nil || len(n.Platforms) == 0 {
+ continue
+ }
+ for i := range n.Platforms {
+ p := platforms.Normalize(n.Platforms[i])
+ if platforms.Only(hostPlat).Match(p) {
+ matchedHost = true
+ break
+ }
+ }
+ if matchedHost {
+ break
+ }
+ }
+ if !matchedHost {
+ for _, n := range nodes {
+ if n.Err != nil || len(n.Platforms) == 0 {
+ continue
+ }
+ p := platforms.Normalize(n.Platforms[0])
+ instancePlat = &p
+ break
+ }
+ }
+ instanceFmt := platforms.Format(platforms.Normalize(*instancePlat))
+ for _, t := range targets {
+ if t.Predicate == nil {
+ continue
+ }
+ prov, ok := t.Predicate.DefaultPlatform()
+ if !ok || prov == nil {
+ continue
+ }
+ provFmt := platforms.Format(*prov)
+ if provFmt == instanceFmt {
+ continue
+ }
+ return fmt.Sprintf("provenance default platform %s does not match current builder instance default platform %s; materials-mode replay may be inefficient or fail", provFmt, instanceFmt)
+ }
+ return ""
+}
+
+// BuildOptionsFromPredicate maps a (subject, predicate) pair to a
+// build.Options. The resulting options have Exports left empty; Build
+// populates them from the request.
+func BuildOptionsFromPredicate(s *Subject, pred *Predicate, req *BuildRequest) (build.Options, error) {
+ if pred == nil {
+ return build.Options{}, errors.New("nil predicate")
+ }
+ if req == nil {
+ return build.Options{}, errors.New("nil build request")
+ }
+ if req.Materials.HasExplicitSources() {
+ return build.Options{}, ErrNotImplemented("replay build with explicit --materials sources")
+ }
+
+ attrs := pred.FrontendAttrs()
+ networkMode, err := networkModeForReplay(req.NetworkMode, attrs["force-network-mode"])
+ if err != nil {
+ return build.Options{}, err
+ }
+
+ cfgSrc := pred.ConfigSource()
+
+ labels := collectPrefixed(attrs, "label:")
+ buildArgs := collectPrefixed(attrs, "build-arg:")
+ var nocacheFilter []string
+ noCache := false
+ if v, ok := attrs["no-cache"]; ok {
+ if v == "" {
+ noCache = true
+ } else if fields, err := csvvalue.Fields(v, nil); err == nil {
+ nocacheFilter = fields
+ }
+ }
+
+ // NamedContexts from recorded "context:*" attrs.
+ namedContexts := map[string]build.NamedContext{}
+ for k, v := range attrs {
+ name, ok := strings.CutPrefix(k, "context:")
+ if !ok {
+ continue
+ }
+ namedContexts[name] = build.NamedContext{Path: v}
+ }
+
+ target := attrs["target"]
+ var extraHosts []string
+ if v := attrs["add-hosts"]; v != "" {
+ if fields, err := csvvalue.Fields(v, nil); err == nil {
+ extraHosts = fields
+ }
+ }
+
+ // Dockerfile path comes from configSource.path when present — that is the
+ // canonical provenance field for the build definition. The recorded
+ // frontend attr is only used as a compatibility fallback.
+ dockerfilePath := cfgSrc.Path
+ if dockerfilePath == "" {
+ dockerfilePath = attrs["filename"]
+ }
+
+ // The build context comes from configSource.uri when present — that is
+ // the canonical provenance field for the source location. The recorded
+ // frontend attr is only used as a compatibility fallback. Replay rejects
+ // local filesystem contexts up-front via the Locals check, so by the time
+ // we get here the predicate is expected to carry a remote source URL.
+ contextPath := cfgSrc.URI
+ if contextPath == "" {
+ contextPath = attrs["context"]
+ }
+ if contextPath == "" {
+ return build.Options{}, errors.Errorf("predicate has no recorded build context; replay requires a remote-source build (git / https)")
+ }
+
+ frontend := pred.Frontend()
+ frontendAttrs := map[string]string{}
+ if frontend == "gateway.v0" {
+ if source := attrs["source"]; source != "" {
+ frontendAttrs["source"] = source
+ }
+ if cmdline := strings.TrimSpace(attrs["cmdline"]); cmdline != "" {
+ frontendAttrs["cmdline"] = cmdline
+ if frontendAttrs["source"] == "" {
+ frontendAttrs["source"] = strings.Fields(cmdline)[0]
+ }
+ }
+ if frontendAttrs["source"] == "" {
+ return build.Options{}, errors.New("gateway.v0 predicate has no recorded frontend source")
+ }
+ }
+
+ opt := build.Options{
+ Ref: identity.NewID(),
+ Frontend: frontend,
+ FrontendAttrs: frontendAttrs,
+ Target: target,
+ Inputs: build.Inputs{
+ ContextPath: contextPath,
+ DockerfilePath: dockerfilePath,
+ NamedContexts: namedContexts,
+ },
+ BuildArgs: buildArgs,
+ Labels: labels,
+ NoCache: noCache,
+ NoCacheFilter: nocacheFilter,
+ ExtraHosts: extraHosts,
+ NetworkMode: networkMode,
+ SecretSpecs: req.Secrets,
+ SSHSpecs: req.SSH,
+ Tags: req.Tags,
+ // A replay provenance attestation would describe the replay operation,
+ // not the original build. Disable default provenance/SBOM attachment.
+ Attests: map[string]*string{"provenance": nil, "sbom": nil},
+ }
+
+ if s.Descriptor.Platform != nil {
+ opt.Platforms = []ocispecs.Platform{*s.Descriptor.Platform}
+ }
+
+ // Strict source pinning applies in materials mode only (the default).
+ // Attach via the shared Policy slot as a callback-only entry — composes
+ // with any file-based user policies the caller may have configured.
+ if req.Mode == "" || req.Mode == BuildModeMaterials {
+ opt.Policy = append(opt.Policy, buildflags.PolicyConfig{
+ Callback: ReplayPinCallback(NewPinIndex(pred)),
+ })
+ }
+
+ return opt, nil
+}
+
+// networkModeForReplay returns the network mode for RUN instructions. An
+// explicit mode takes precedence. Otherwise the recorded mode is kept. A
+// recorded host network needs an entitlement that replay cannot grant, so it
+// requires an explicit mode.
+func networkModeForReplay(mode, recorded string) (string, error) {
+ switch mode {
+ case "":
+ switch recorded {
+ case "", "default":
+ return "", nil
+ case "none":
+ return "none", nil
+ case "host":
+ return "", errors.New("the original build used --network=host, which replay does not support; pass --network=default or --network=none to replay with a different network mode")
+ }
+ return "", errors.Errorf("unsupported recorded network mode %q; pass --network=default or --network=none", recorded)
+ case "default":
+ return "", nil
+ case "none":
+ return "none", nil
+ default:
+ return "", errors.Errorf("unsupported replay network mode %q (want default or none)", mode)
+ }
+}
+
+// CheckSecrets enforces the provenance vs. user-supplied secret-ID cross
+// check: required (non-optional) IDs declared in provenance must be
+// provided; any provided IDs not declared in provenance are rejected.
+func CheckSecrets(declared []*provenancetypes.Secret, provided buildflags.Secrets) error {
+ required := map[string]struct{}{}
+ declaredAll := map[string]struct{}{}
+ for _, s := range declared {
+ if s == nil || s.ID == "" {
+ continue
+ }
+ declaredAll[s.ID] = struct{}{}
+ if !s.Optional {
+ required[s.ID] = struct{}{}
+ }
+ }
+
+ providedIDs := map[string]struct{}{}
+ for _, s := range provided {
+ if s == nil || s.ID == "" {
+ continue
+ }
+ providedIDs[s.ID] = struct{}{}
+ }
+
+ missing := setDiff(required, providedIDs)
+ extra := setDiff(providedIDs, declaredAll)
+ if len(missing) > 0 {
+ return ErrMissingSecret(missing)
+ }
+ if len(extra) > 0 {
+ return ErrExtraSecret(extra)
+ }
+ return nil
+}
+
+// CheckSSH enforces the provenance vs. user-supplied SSH cross check.
+func CheckSSH(declared []*provenancetypes.SSH, provided []*buildflags.SSH) error {
+ required := map[string]struct{}{}
+ declaredAll := map[string]struct{}{}
+ for _, s := range declared {
+ if s == nil || s.ID == "" {
+ continue
+ }
+ declaredAll[s.ID] = struct{}{}
+ if !s.Optional {
+ required[s.ID] = struct{}{}
+ }
+ }
+
+ providedIDs := map[string]struct{}{}
+ for _, s := range provided {
+ if s == nil || s.ID == "" {
+ continue
+ }
+ providedIDs[s.ID] = struct{}{}
+ }
+
+ missing := setDiff(required, providedIDs)
+ extra := setDiff(providedIDs, declaredAll)
+ if len(missing) > 0 {
+ return ErrMissingSSH(missing)
+ }
+ if len(extra) > 0 {
+ return ErrExtraSSH(extra)
+ }
+ return nil
+}
+
+// setDiff returns the ordered list of elements in a that are not in b.
+func setDiff(a, b map[string]struct{}) []string {
+ var out []string
+ for k := range a {
+ if _, ok := b[k]; !ok {
+ out = append(out, k)
+ }
+ }
+ sort.Strings(out)
+ return out
+}
+
+// collectPrefixed returns the keys from attrs whose key starts with prefix,
+// with the prefix stripped. Values are copied verbatim.
+func collectPrefixed(attrs map[string]string, prefix string) map[string]string {
+ out := map[string]string{}
+ for k, v := range attrs {
+ name, ok := strings.CutPrefix(k, prefix)
+ if !ok {
+ continue
+ }
+ out[name] = v
+ }
+ if len(out) == 0 {
+ return nil
+ }
+ return out
+}
diff --git a/replay/build_test.go b/replay/build_test.go
new file mode 100644
index 000000000000..7de4e3417037
--- /dev/null
+++ b/replay/build_test.go
@@ -0,0 +1,296 @@
+package replay
+
+import (
+ "context"
+ "testing"
+
+ "github.com/docker/buildx/builder"
+ "github.com/docker/buildx/util/buildflags"
+ slsa1 "github.com/in-toto/in-toto-golang/in_toto/slsa_provenance/v1"
+ provenancetypes "github.com/moby/buildkit/solver/llbsolver/provenance/types"
+ ocispecs "github.com/opencontainers/image-spec/specs-go/v1"
+ "github.com/stretchr/testify/require"
+)
+
+func TestCheckSecretsMissing(t *testing.T) {
+ declared := []*provenancetypes.Secret{
+ {ID: "required"},
+ {ID: "optional", Optional: true},
+ }
+ err := CheckSecrets(declared, nil)
+ require.Error(t, err)
+ var mse *MissingSecretError
+ require.ErrorAs(t, err, &mse)
+ require.Equal(t, []string{"required"}, mse.IDs)
+}
+
+func TestCheckSecretsExtra(t *testing.T) {
+ declared := []*provenancetypes.Secret{{ID: "a"}}
+ provided := buildflags.Secrets{{ID: "a"}, {ID: "rogue"}}
+ err := CheckSecrets(declared, provided)
+ require.Error(t, err)
+ var ese *ExtraSecretError
+ require.ErrorAs(t, err, &ese)
+ require.Equal(t, []string{"rogue"}, ese.IDs)
+}
+
+func TestCheckSecretsOptionalOmitted(t *testing.T) {
+ declared := []*provenancetypes.Secret{
+ {ID: "required"},
+ {ID: "optional", Optional: true},
+ }
+ provided := buildflags.Secrets{{ID: "required"}}
+ require.NoError(t, CheckSecrets(declared, provided))
+}
+
+func TestCheckSecretsOptionalProvidedAllowed(t *testing.T) {
+ declared := []*provenancetypes.Secret{
+ {ID: "required"},
+ {ID: "optional", Optional: true},
+ }
+ provided := buildflags.Secrets{{ID: "required"}, {ID: "optional"}}
+ require.NoError(t, CheckSecrets(declared, provided))
+}
+
+func TestCheckSSHMissing(t *testing.T) {
+ declared := []*provenancetypes.SSH{{ID: "default"}}
+ err := CheckSSH(declared, nil)
+ var mse *MissingSSHError
+ require.ErrorAs(t, err, &mse)
+ require.Equal(t, []string{"default"}, mse.IDs)
+}
+
+func TestCheckSSHExtra(t *testing.T) {
+ declared := []*provenancetypes.SSH{{ID: "default"}}
+ provided := []*buildflags.SSH{{ID: "default"}, {ID: "rogue"}}
+ err := CheckSSH(declared, provided)
+ var ese *ExtraSSHError
+ require.ErrorAs(t, err, &ese)
+ require.Equal(t, []string{"rogue"}, ese.IDs)
+}
+
+func predicateWithAttrs(attrs map[string]string) *Predicate {
+ // Ensure every fixture predicate carries a remote-source `context`
+ // so BuildOptionsFromPredicate passes the replay context check.
+ if attrs == nil {
+ attrs = map[string]string{}
+ }
+ if _, ok := attrs["context"]; !ok {
+ attrs["context"] = "https://github.com/docker/buildx.git"
+ }
+ pred := &Predicate{}
+ pred.BuildDefinition.ExternalParameters.Request.Args = attrs
+ return pred
+}
+
+func subjectWithPlatform(arch string) *Subject {
+ return &Subject{
+ Descriptor: ocispecs.Descriptor{
+ Platform: &ocispecs.Platform{OS: "linux", Architecture: arch},
+ },
+ }
+}
+
+func TestBuildOptionsFromPredicate(t *testing.T) {
+ s := subjectWithPlatform("amd64")
+
+ pred := predicateWithAttrs(map[string]string{
+ "target": "myapp",
+ "filename": "Dockerfile.prod",
+ "label:org.example.owner": "alice",
+ "build-arg:FOO": "bar",
+ "build-arg:BUILDKIT_INLINE_CACHE": "1",
+ "add-hosts": "foo:1.2.3.4,bar:5.6.7.8",
+ "no-cache": "stage1,stage2",
+ "context:app": "docker-image://alpine:3.18",
+ "attest:provenance": "mode=max",
+ "attest:sbom": "true",
+ })
+
+ req := &BuildRequest{Mode: BuildModeFrontend}
+ opt, err := BuildOptionsFromPredicate(s, pred, req)
+ require.NoError(t, err)
+
+ require.Equal(t, "myapp", opt.Target)
+ require.Equal(t, "Dockerfile.prod", opt.Inputs.DockerfilePath)
+ require.Equal(t, map[string]string{"org.example.owner": "alice"}, opt.Labels)
+ require.Equal(t, map[string]string{"FOO": "bar", "BUILDKIT_INLINE_CACHE": "1"}, opt.BuildArgs)
+ require.Equal(t, []string{"foo:1.2.3.4", "bar:5.6.7.8"}, opt.ExtraHosts)
+ require.Equal(t, []string{"stage1", "stage2"}, opt.NoCacheFilter)
+ require.False(t, opt.NoCache)
+
+ require.Contains(t, opt.Inputs.NamedContexts, "app")
+ require.Equal(t, "docker-image://alpine:3.18", opt.Inputs.NamedContexts["app"].Path)
+
+ // Replay must not attach fresh attestations that describe the replay
+ // operation rather than the original build.
+ require.Contains(t, opt.Attests, "provenance")
+ require.Nil(t, opt.Attests["provenance"])
+ require.Contains(t, opt.Attests, "sbom")
+ require.Nil(t, opt.Attests["sbom"])
+
+ // Platform should mirror the subject descriptor.
+ require.Len(t, opt.Platforms, 1)
+ require.Equal(t, "amd64", opt.Platforms[0].Architecture)
+
+ // In frontend mode, no pin callback is attached.
+ require.Empty(t, opt.Policy)
+}
+
+func TestBuildOptionsFromPredicateRejectsUnsupportedNetworkMode(t *testing.T) {
+ _, err := BuildOptionsFromPredicate(subjectWithPlatform("amd64"), predicateWithAttrs(nil), &BuildRequest{
+ Mode: BuildModeFrontend,
+ NetworkMode: "host",
+ })
+ require.ErrorContains(t, err, `unsupported replay network mode "host"`)
+}
+
+func TestBuildOptionsFromPredicateRejectsUnwiredExplicitMaterials(t *testing.T) {
+ resolver, err := NewMaterialsResolver([]string{t.TempDir()})
+ require.NoError(t, err)
+
+ for _, mode := range []BuildMode{BuildModeMaterials, BuildModeFrontend} {
+ t.Run(string(mode), func(t *testing.T) {
+ _, err := BuildOptionsFromPredicate(subjectWithPlatform("amd64"), predicateWithAttrs(nil), &BuildRequest{
+ Mode: mode,
+ Materials: resolver,
+ })
+ require.Error(t, err)
+ var notImplemented *NotImplementedError
+ require.ErrorAs(t, err, ¬Implemented)
+ })
+ }
+}
+
+func TestBuildRejectsMultipleSubjectsUntilOutputsAreAggregated(t *testing.T) {
+ req := &BuildRequest{Targets: []Target{
+ {Subject: subjectWithPlatform("amd64"), Predicate: predicateWithAttrs(nil)},
+ {Subject: subjectWithPlatform("arm64"), Predicate: predicateWithAttrs(nil)},
+ }}
+
+ err := Build(context.Background(), nil, "", req)
+ require.Error(t, err)
+ var notImplemented *NotImplementedError
+ require.ErrorAs(t, err, ¬Implemented)
+ require.ErrorContains(t, err, "select a single platform with --platform")
+}
+
+func TestBuildOptionsFromPredicateNoCacheAll(t *testing.T) {
+ pred := predicateWithAttrs(map[string]string{
+ "no-cache": "",
+ })
+ opt, err := BuildOptionsFromPredicate(subjectWithPlatform("amd64"), pred, &BuildRequest{Mode: BuildModeFrontend})
+ require.NoError(t, err)
+ require.True(t, opt.NoCache)
+ require.Nil(t, opt.NoCacheFilter)
+}
+
+func TestBuildOptionsFromPredicateMaterialsModeAttachesPinCallback(t *testing.T) {
+ pred := predicateWithAttrs(map[string]string{})
+ opt, err := BuildOptionsFromPredicate(subjectWithPlatform("amd64"), pred, &BuildRequest{Mode: BuildModeMaterials})
+ require.NoError(t, err)
+ require.Len(t, opt.Policy, 1, "materials mode must attach strict pin callback")
+ require.NotNil(t, opt.Policy[0].Callback, "Policy entry must carry a non-nil Callback")
+ require.Empty(t, opt.Policy[0].Files, "replay pin entry must not reference policy files")
+}
+
+func TestBuildOptionsFromPredicateUsesConfigSourceAsPrimary(t *testing.T) {
+ pred := predicateWithAttrs(map[string]string{
+ "context": "https://github.com/example/attrs.git",
+ "filename": "Dockerfile.attrs",
+ "target": "myapp",
+ })
+ pred.BuildDefinition.ExternalParameters.ConfigSource.URI = "https://github.com/moby/buildkit.git#refs/tags/v0.29.0"
+ pred.BuildDefinition.ExternalParameters.ConfigSource.Path = "Dockerfile"
+
+ opt, err := BuildOptionsFromPredicate(subjectWithPlatform("amd64"), pred, &BuildRequest{Mode: BuildModeFrontend})
+ require.NoError(t, err)
+ require.Equal(t, "https://github.com/moby/buildkit.git#refs/tags/v0.29.0", opt.Inputs.ContextPath)
+ require.Equal(t, "Dockerfile", opt.Inputs.DockerfilePath)
+}
+
+func TestBuildOptionsFromPredicateUsesConfigSourcePathForGatewayFrontend(t *testing.T) {
+ pred := predicateWithAttrs(map[string]string{
+ "source": "docker/dockerfile-upstream:master",
+ "cmdline": "docker/dockerfile-upstream:master",
+ })
+ pred.BuildDefinition.ExternalParameters.Request.Frontend = "gateway.v0"
+ pred.BuildDefinition.ExternalParameters.ConfigSource.URI = "https://github.com/moby/buildkit.git#refs/tags/dockerfile/1.23.0"
+ pred.BuildDefinition.ExternalParameters.ConfigSource.Path = "frontend/dockerfile/cmd/dockerfile-frontend/Dockerfile"
+
+ opt, err := BuildOptionsFromPredicate(subjectWithPlatform("arm64"), pred, &BuildRequest{Mode: BuildModeMaterials})
+ require.NoError(t, err)
+ require.Equal(t, "https://github.com/moby/buildkit.git#refs/tags/dockerfile/1.23.0", opt.Inputs.ContextPath)
+ require.Equal(t, "frontend/dockerfile/cmd/dockerfile-frontend/Dockerfile", opt.Inputs.DockerfilePath)
+ require.Equal(t, "gateway.v0", opt.Frontend)
+ require.Equal(t, map[string]string{
+ "source": "docker/dockerfile-upstream:master",
+ "cmdline": "docker/dockerfile-upstream:master",
+ }, opt.FrontendAttrs)
+}
+
+func TestMaterialsModePlatformWarningMismatch(t *testing.T) {
+ pred := predicateWithAttrs(map[string]string{})
+ pred.BuildDefinition.InternalParameters.BuilderPlatform = "linux/amd64"
+ msg := materialsModePlatformWarning([]Target{{
+ Subject: subjectWithPlatform("amd64"),
+ Predicate: pred,
+ }}, []builder.Node{{
+ Platforms: []ocispecs.Platform{{OS: "linux", Architecture: "arm64"}},
+ }})
+ require.Contains(t, msg, "provenance default platform linux/amd64")
+ require.Contains(t, msg, "current builder instance default platform linux/arm64")
+}
+
+func TestMaterialsModePlatformWarningMatch(t *testing.T) {
+ pred := predicateWithAttrs(map[string]string{})
+ pred.BuildDefinition.InternalParameters.BuilderPlatform = "linux/amd64"
+ msg := materialsModePlatformWarning([]Target{{
+ Subject: subjectWithPlatform("amd64"),
+ Predicate: pred,
+ }}, []builder.Node{{
+ Platforms: []ocispecs.Platform{{OS: "linux", Architecture: "amd64"}},
+ }})
+ require.Empty(t, msg)
+}
+
+func TestMaterialsModePlatformWarningInferredFromMaterials(t *testing.T) {
+ pred := predicateWithAttrs(map[string]string{})
+ pred.BuildDefinition.ResolvedDependencies = []slsa1.ResourceDescriptor{
+ {URI: "pkg:docker/golang@1.26-alpine3.23?platform=linux%2Famd64"},
+ {URI: "pkg:docker/tonistiigi/xx@1.9.0?platform=linux%2Famd64"},
+ }
+ msg := materialsModePlatformWarning([]Target{{
+ Subject: subjectWithPlatform("arm64"),
+ Predicate: pred,
+ }}, []builder.Node{{
+ Platforms: []ocispecs.Platform{{OS: "linux", Architecture: "arm64"}},
+ }})
+ require.Contains(t, msg, "provenance default platform linux/amd64")
+ require.Contains(t, msg, "current builder instance default platform linux/arm64")
+}
+
+func TestMaterialsModePlatformWarningPrefersInferredDefaultPlatform(t *testing.T) {
+ pred := predicateWithAttrs(map[string]string{})
+ pred.BuildDefinition.InternalParameters.BuilderPlatform = "linux/arm64"
+ pred.BuildDefinition.ResolvedDependencies = []slsa1.ResourceDescriptor{
+ {URI: "pkg:docker/golang@1.26-alpine3.23?platform=linux%2Famd64"},
+ {URI: "pkg:docker/tonistiigi/xx@1.9.0?platform=linux%2Famd64"},
+ }
+ msg := materialsModePlatformWarning([]Target{{
+ Subject: subjectWithPlatform("arm64"),
+ Predicate: pred,
+ }}, []builder.Node{{
+ Platforms: []ocispecs.Platform{{OS: "linux", Architecture: "arm64"}},
+ }})
+ require.Contains(t, msg, "provenance default platform linux/amd64")
+ require.Contains(t, msg, "current builder instance default platform linux/arm64")
+}
+
+func TestSubjectKeyWithPlatform(t *testing.T) {
+ s := &Subject{Descriptor: ocispecs.Descriptor{
+ Digest: "sha256:deadbeef",
+ Platform: &ocispecs.Platform{OS: "linux", Architecture: "arm64", Variant: "v8"},
+ }}
+ require.Equal(t, "sha256:deadbeef@linux/arm64/v8", SubjectKey(s))
+}
diff --git a/replay/dryrun.go b/replay/dryrun.go
new file mode 100644
index 000000000000..ec38b780b66c
--- /dev/null
+++ b/replay/dryrun.go
@@ -0,0 +1,429 @@
+package replay
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "sort"
+
+ "github.com/containerd/containerd/v2/core/content"
+ "github.com/docker/buildx/build"
+ "github.com/docker/buildx/builder"
+ "github.com/docker/buildx/util/buildflags"
+ "github.com/docker/buildx/util/imagetools"
+ "github.com/docker/buildx/util/progress"
+ "github.com/docker/cli/cli/command"
+ slsa1 "github.com/in-toto/in-toto-golang/in_toto/slsa_provenance/v1"
+ "github.com/moby/buildkit/client"
+ provenancetypes "github.com/moby/buildkit/solver/llbsolver/provenance/types"
+ "github.com/moby/buildkit/util/purl"
+ digest "github.com/opencontainers/go-digest"
+ ocispecs "github.com/opencontainers/image-spec/specs-go/v1"
+ "github.com/package-url/packageurl-go"
+ "github.com/pkg/errors"
+)
+
+// BuildPlan is the JSON-serializable dry-run payload for `replay build`.
+// Field names are stable and consumed by tests / tooling.
+type BuildPlan struct {
+ // Subjects is one SubjectBuildPlan per replay target.
+ Subjects []SubjectBuildPlan `json:"subjects"`
+}
+
+// SubjectBuildPlan is the per-subject build-mode dry-run plan.
+type SubjectBuildPlan struct {
+ // Descriptor is the subject descriptor (digest + mediaType + size).
+ Descriptor ocispecs.Descriptor `json:"descriptor"`
+ // Signature describes the verified signer of a standalone Sigstore bundle
+ // or image-attached provenance. It is absent for unsigned provenance.
+ Signature *SignatureVerification `json:"signature,omitempty"`
+ // BuildConfig summarises the solve parameters replay would use.
+ BuildConfig BuildPlanConfig `json:"buildConfig"`
+ // Materials lists the resolved provenance materials.
+ Materials []PlanMaterial `json:"materials"`
+}
+
+// BuildPlanConfig mirrors the build.Options fields replay derives from the
+// predicate — enough for a user to eyeball that the replay will run as
+// expected.
+type BuildPlanConfig struct {
+ Frontend string `json:"frontend"`
+ FrontendAttrs map[string]string `json:"frontendAttrs,omitempty"`
+ Context string `json:"context,omitempty"`
+ Filename string `json:"filename,omitempty"`
+ Target string `json:"target,omitempty"`
+ BuildArgs map[string]string `json:"buildArgs,omitempty"`
+ Labels map[string]string `json:"labels,omitempty"`
+ NoCache bool `json:"noCache,omitempty"`
+ NoCacheFilter []string `json:"noCacheFilter,omitempty"`
+ Secrets []PlanSecret `json:"secrets,omitempty"`
+ SSH []string `json:"ssh,omitempty"`
+ NetworkMode string `json:"networkMode,omitempty"`
+ Exports []string `json:"exports,omitempty"`
+}
+
+// PlanMaterial describes one provenance material. Different dry-run modes
+// populate different subsets of the fields, but the JSON shape stays stable.
+type PlanMaterial struct {
+ URI string `json:"uri,omitempty"`
+ // Platform is populated for image materials only — either parsed
+ // from the purl `?platform=` qualifier or, when the URI doesn't
+ // carry one, from the predicate's builder platform.
+ Platform *ocispecs.Platform `json:"platform,omitempty"`
+ Digest string `json:"digest,omitempty"`
+ // Kind is one of: "image", "image-blob" (container-blob), "http",
+ // "git", or "unknown".
+ Kind string `json:"kind"`
+ // Included reports whether `replay snapshot` would copy this
+ // material's bytes into the snapshot.
+ Included bool `json:"included,omitempty"`
+ // Size is the total byte size this material contributes to the
+ // snapshot — the root index plus the platform-matched manifest
+ // chain (config + all layer descriptor sizes). Only populated for
+ // image materials during snapshot dry-run; computed from manifest
+ // metadata alone (no layer bodies are fetched).
+ Size int64 `json:"size,omitempty"`
+}
+
+// PlanSecret describes one declared secret plus whether it is optional.
+type PlanSecret struct {
+ ID string `json:"id"`
+ Optional bool `json:"optional,omitempty"`
+}
+
+// SnapshotPlan is the JSON-serializable dry-run payload for
+// `replay snapshot` — one entry per snapshot target.
+type SnapshotPlan []SnapshotPlanTarget
+
+// SnapshotPlanTarget is the per-subject snapshot dry-run plan.
+type SnapshotPlanTarget struct {
+ // Subject is the subject descriptor (already carries platform).
+ Subject ocispecs.Descriptor `json:"subject"`
+ // Materials lists each recorded material and whether the snapshot
+ // would include its content.
+ Materials []PlanMaterial `json:"materials"`
+}
+
+// MakeBuildPlan constructs the dry-run plan for a BuildRequest.
+// It runs the same pre-solve checks as Build, so any condition that would
+// fail the replay before the solve fails the dry-run with the same error.
+func MakeBuildPlan(req *BuildRequest) (*BuildPlan, error) {
+ if req == nil {
+ return nil, errors.New("nil build request")
+ }
+ if err := checkBuildRequest(req); err != nil {
+ return nil, err
+ }
+ if _, err := createExports(req.Exports); err != nil {
+ return nil, err
+ }
+
+ plan := &BuildPlan{Subjects: make([]SubjectBuildPlan, 0, len(req.Targets))}
+
+ for _, t := range req.Targets {
+ opt, err := BuildOptionsFromPredicate(t.Subject, t.Predicate, req)
+ if err != nil {
+ return nil, err
+ }
+ plan.Subjects = append(plan.Subjects, subjectBuildPlan(t.Subject, t.Predicate, opt, req))
+ }
+ return plan, nil
+}
+
+// MakeSnapshotPlan constructs the dry-run plan for a SnapshotRequest.
+// For each image material, the root index + platform-matched manifest
+// bodies are fetched so their descriptor sizes can be summed — layer
+// bodies are not fetched.
+func MakeSnapshotPlan(ctx context.Context, dockerCli command.Cli, builderName string, req *SnapshotRequest) (SnapshotPlan, error) {
+ if req == nil {
+ return nil, errors.New("nil snapshot request")
+ }
+ if len(req.Targets) == 0 {
+ return nil, errors.New("no targets to snapshot")
+ }
+
+ // Register ref-key prefixes so MakeRefKey in containerd does not log
+ // warnings while we fetch manifest bodies.
+ ctx = withMediaTypeKeyPrefix(ctx)
+
+ // Lazily-constructed registry resolver for image materials, shared
+ // across targets — mirrors the real-run setup.
+ var registryResolver *imagetools.Resolver
+ lazyResolver := func() (*imagetools.Resolver, error) {
+ if registryResolver != nil {
+ return registryResolver, nil
+ }
+ if dockerCli == nil {
+ registryResolver = imagetools.New(imagetools.Opt{})
+ return registryResolver, nil
+ }
+ b, err := builder.New(dockerCli, builder.WithName(builderName))
+ if err != nil {
+ return nil, err
+ }
+ imgOpt, err := b.ImageOpt()
+ if err != nil {
+ return nil, err
+ }
+ registryResolver = imagetools.New(imgOpt)
+ return registryResolver, nil
+ }
+
+ plan := make(SnapshotPlan, 0, len(req.Targets))
+ var pwlog progress.Logger = func(*client.SolveStatus) {}
+ if req.Progress != nil {
+ pwlog = req.Progress.Write
+ }
+
+ for ti, t := range req.Targets {
+ s, pred := t.Subject, t.Predicate
+ if s == nil || pred == nil {
+ return nil, errors.New("target has nil subject or predicate")
+ }
+ if s.IsAttestationFile() {
+ return nil, ErrUnsupportedSubject("snapshot requires an image or oci-layout subject")
+ }
+ if s.AttestationManifest().Digest == "" {
+ return nil, ErrNoProvenance(s.InputRef())
+ }
+
+ var mats []PlanMaterial
+ targetName := fmt.Sprintf("[%d/%d] snapshot %s", ti+1, len(req.Targets), snapshotTargetLabel(s))
+ err := progress.Wrap(targetName, pwlog, func(sub progress.SubLogger) error {
+ var err error
+ mats, err = planMaterials(ctx, s, pred, req, lazyResolver, sub)
+ return err
+ })
+ if err != nil {
+ return nil, err
+ }
+ plan = append(plan, SnapshotPlanTarget{
+ Subject: s.Descriptor,
+ Materials: mats,
+ })
+ }
+ return plan, nil
+}
+
+// planMaterials builds the material list for the dry-run plan. For image
+// materials it resolves the root index + picks the platform-matched
+// manifest and sums every descriptor's size — layer bytes are not
+// fetched.
+func planMaterials(ctx context.Context, s *Subject, pred *Predicate, req *SnapshotRequest, lazyResolver func() (*imagetools.Resolver, error), sub progress.SubLogger) ([]PlanMaterial, error) {
+ builder := pred.BuilderPlatform()
+ var out []PlanMaterial
+ for _, m := range pred.ResolvedDependencies() {
+ entry := materialPlan(m, builder)
+ kind := classifyMaterial(m)
+ entry.Included = req.IncludeMaterials && kind != materialKindGit && kind != materialKindUnknown
+ if kind == materialKindImage {
+ if req.IncludeMaterials {
+ var size int64
+ err := sub.Wrap(fmt.Sprintf("plan image material %s", entry.URI), func() error {
+ var err error
+ size, err = imageMaterialSize(ctx, req.Materials, lazyResolver, m, preferredDigest(m.Digest), s.Descriptor.Platform, pred.BuilderPlatform())
+ return err
+ })
+ if err != nil {
+ return nil, errors.Wrapf(err, "size image material %s", m.URI)
+ }
+ entry.Size = size
+ }
+ }
+ out = append(out, entry)
+ }
+ return out, nil
+}
+
+// imageMaterialSize fetches only the manifests needed to size an image
+// material: root index → platform-matched manifest → its config + layers.
+// Returns the sum of every descriptor's declared size. Layer bodies are
+// never fetched.
+func imageMaterialSize(ctx context.Context, resolver *MaterialsResolver, lazyResolver func() (*imagetools.Resolver, error), m slsa1.ResourceDescriptor, rootDgst digest.Digest, subjectPlat *ocispecs.Platform, builderPlat ocispecs.Platform) (int64, error) {
+ rootDesc, provider, err := resolveImageMaterial(ctx, resolver, lazyResolver, m, rootDgst, WithPlatform(subjectPlat), WithBuilderPlatform(builderPlat))
+ if err != nil {
+ return 0, err
+ }
+ total := rootDesc.Size
+
+ platDesc, err := pickPlatformChild(ctx, provider, rootDesc, subjectPlat, builderPlat)
+ if err != nil {
+ return 0, err
+ }
+ // pickPlatformChild may return rootDesc unchanged for single-platform
+ // images; avoid double-counting.
+ if platDesc.Digest != rootDesc.Digest {
+ total += platDesc.Size
+ }
+
+ mfstData, err := content.ReadBlob(ctx, provider, platDesc)
+ if err != nil {
+ return 0, errors.Wrapf(err, "read platform manifest %s", platDesc.Digest)
+ }
+ var mfst ocispecs.Manifest
+ if err := json.Unmarshal(mfstData, &mfst); err != nil {
+ return 0, errors.Wrapf(err, "parse platform manifest %s", platDesc.Digest)
+ }
+ total += mfst.Config.Size
+ for _, l := range mfst.Layers {
+ total += l.Size
+ }
+ return total, nil
+}
+
+// stripImagePurlQualifiers removes the `digest` and `platform` purl
+// qualifiers from an image material URI — those values are already
+// reported as separate fields on the plan entry so carrying them in the
+// URI is pure noise. Falls back to the original URI on parse failure.
+func stripImagePurlQualifiers(uri string) string {
+ p, err := packageurl.FromString(uri)
+ if err != nil {
+ return uri
+ }
+ kept := p.Qualifiers[:0]
+ for _, q := range p.Qualifiers {
+ switch q.Key {
+ case "digest", "platform":
+ continue
+ }
+ kept = append(kept, q)
+ }
+ p.Qualifiers = kept
+ return p.ToString()
+}
+
+func subjectBuildPlan(s *Subject, pred *Predicate, opt build.Options, req *BuildRequest) SubjectBuildPlan {
+ networkMode := opt.NetworkMode
+ if networkMode == "" {
+ networkMode = "default"
+ }
+ cfg := BuildPlanConfig{
+ Frontend: opt.Frontend,
+ FrontendAttrs: opt.FrontendAttrs,
+ Context: opt.Inputs.ContextPath,
+ Filename: opt.Inputs.DockerfilePath,
+ Target: opt.Target,
+ BuildArgs: opt.BuildArgs,
+ Labels: opt.Labels,
+ NoCache: opt.NoCache,
+ NoCacheFilter: opt.NoCacheFilter,
+ Secrets: planSecrets(pred.Secrets()),
+ SSH: sshIDs(pred.SSH()),
+ NetworkMode: networkMode,
+ Exports: exportSummaries(req.Exports),
+ }
+ if len(cfg.FrontendAttrs) == 0 {
+ cfg.FrontendAttrs = nil
+ }
+
+ // Materials summary.
+ mats := make([]PlanMaterial, 0, len(pred.ResolvedDependencies()))
+ for _, m := range pred.ResolvedDependencies() {
+ mats = append(mats, materialPlan(m, pred.BuilderPlatform()))
+ }
+
+ return SubjectBuildPlan{
+ Descriptor: s.Descriptor,
+ Signature: s.Signature(),
+ BuildConfig: cfg,
+ Materials: mats,
+ }
+}
+
+// materialPlan builds the shared dry-run material summary shape.
+func materialPlan(m slsa1.ResourceDescriptor, builder ocispecs.Platform) PlanMaterial {
+ kind := classifyMaterial(m)
+ pm := PlanMaterial{
+ URI: m.URI,
+ Kind: materialKindString(kind),
+ }
+ d := preferredDigest(m.Digest)
+ if d != "" {
+ pm.Digest = d.String()
+ }
+ if kind == materialKindImage {
+ if _, p, err := purl.PURLToRef(m.URI); err == nil && p != nil {
+ pm.Platform = p
+ } else {
+ b := builder
+ pm.Platform = &b
+ }
+ pm.URI = stripImagePurlQualifiers(m.URI)
+ }
+ return pm
+}
+
+// materialKindString returns the stable JSON kind tag for a material kind.
+func materialKindString(k materialKind) string {
+ switch k {
+ case materialKindImage:
+ return "image"
+ case materialKindContainerBlob:
+ return "image-blob"
+ case materialKindHTTP:
+ return "http"
+ case materialKindGit:
+ return "git"
+ }
+ return "unknown"
+}
+
+// planSecrets returns the sorted unique declared secrets, preserving whether
+// any declaration of the secret marked it optional.
+func planSecrets(secrets []*provenancetypes.Secret) []PlanSecret {
+ seen := map[string]bool{}
+ for _, s := range secrets {
+ if s == nil || s.ID == "" {
+ continue
+ }
+ seen[s.ID] = seen[s.ID] || s.Optional
+ }
+ ids := make([]string, 0, len(seen))
+ for id := range seen {
+ ids = append(ids, id)
+ }
+ sort.Strings(ids)
+ out := make([]PlanSecret, 0, len(ids))
+ for _, id := range ids {
+ out = append(out, PlanSecret{ID: id, Optional: seen[id]})
+ }
+ return out
+}
+
+// sshIDs returns the sorted unique IDs of declared SSH entries.
+func sshIDs(entries []*provenancetypes.SSH) []string {
+ seen := map[string]struct{}{}
+ var out []string
+ for _, s := range entries {
+ if s == nil || s.ID == "" {
+ continue
+ }
+ if _, ok := seen[s.ID]; ok {
+ continue
+ }
+ seen[s.ID] = struct{}{}
+ out = append(out, s.ID)
+ }
+ sort.Strings(out)
+ return out
+}
+
+// exportSummaries renders --output specs into a short "type=..." list for
+// dry-run JSON output.
+func exportSummaries(exports []*buildflags.ExportEntry) []string {
+ out := make([]string, 0, len(exports))
+ for _, e := range exports {
+ if e == nil {
+ continue
+ }
+ s := "type=" + e.Type
+ if e.Destination != "" {
+ s += ",dest=" + e.Destination
+ }
+ if name, ok := e.Attrs["name"]; ok && name != "" {
+ s += ",name=" + name
+ }
+ out = append(out, s)
+ }
+ return out
+}
diff --git a/replay/dryrun_test.go b/replay/dryrun_test.go
new file mode 100644
index 000000000000..c442b64d51d7
--- /dev/null
+++ b/replay/dryrun_test.go
@@ -0,0 +1,333 @@
+package replay
+
+import (
+ "context"
+ "encoding/json"
+ "strings"
+ "testing"
+
+ "github.com/docker/buildx/util/buildflags"
+ slsa1 "github.com/in-toto/in-toto-golang/in_toto/slsa_provenance/v1"
+ provenancetypes "github.com/moby/buildkit/solver/llbsolver/provenance/types"
+ ocispecs "github.com/opencontainers/image-spec/specs-go/v1"
+ "github.com/stretchr/testify/require"
+)
+
+func testSubject(t *testing.T) *Subject {
+ t.Helper()
+ return &Subject{
+ Descriptor: ocispecs.Descriptor{
+ MediaType: ocispecs.MediaTypeImageManifest,
+ Digest: "sha256:aaaa",
+ Platform: &ocispecs.Platform{OS: "linux", Architecture: "amd64"},
+ },
+ attestManifest: ocispecs.Descriptor{
+ MediaType: ocispecs.MediaTypeImageManifest,
+ Digest: "sha256:bbbb",
+ },
+ }
+}
+
+func testPredicate(secretSpecs []struct {
+ id string
+ optional bool
+}, locals []string) *Predicate {
+ p := &Predicate{}
+ for _, spec := range secretSpecs {
+ p.BuildDefinition.ExternalParameters.Request.Secrets = append(
+ p.BuildDefinition.ExternalParameters.Request.Secrets,
+ &provenancetypes.Secret{ID: spec.id, Optional: spec.optional},
+ )
+ }
+ for _, name := range locals {
+ p.BuildDefinition.ExternalParameters.Request.Locals = append(
+ p.BuildDefinition.ExternalParameters.Request.Locals,
+ &provenancetypes.LocalSource{Name: name},
+ )
+ }
+ p.BuildDefinition.ExternalParameters.Request.Frontend = "dockerfile.v0"
+ p.BuildDefinition.ExternalParameters.Request.Args = map[string]string{
+ "context": "https://github.com/example/repo.git",
+ "source": "docker/dockerfile:1.8",
+ "cmdline": "docker/dockerfile:1.8",
+ "target": "default",
+ "build-arg:EXAMPLE": "1",
+ "label:org.example.test": "yes",
+ }
+ p.BuildDefinition.ExternalParameters.ConfigSource.URI = "https://github.com/example/repo.git"
+ p.BuildDefinition.ExternalParameters.ConfigSource.Path = "Dockerfile"
+ p.BuildDefinition.ResolvedDependencies = []slsa1.ResourceDescriptor{
+ {URI: "pkg:docker/alpine@3.20", Digest: map[string]string{"sha256": "deadbeef"}},
+ {URI: "https://example.com/foo.tar", Digest: map[string]string{"sha256": "feed"}},
+ }
+ // mode=max provenance records the build definition.
+ p.BuildDefinition.InternalParameters.BuildConfig = &provenancetypes.BuildConfig{
+ Definition: []provenancetypes.BuildStep{{ID: "step0"}},
+ }
+ return p
+}
+
+func TestMakeBuildPlanGatewayFrontend(t *testing.T) {
+ pred := testPredicate(nil, nil)
+ pred.BuildDefinition.ExternalParameters.Request.Frontend = "gateway.v0"
+ plan, err := MakeBuildPlan(&BuildRequest{Targets: []Target{{Subject: testSubject(t), Predicate: pred}}})
+ require.NoError(t, err)
+ require.Equal(t, "gateway.v0", plan.Subjects[0].BuildConfig.Frontend)
+ require.Equal(t, map[string]string{
+ "cmdline": "docker/dockerfile:1.8",
+ "source": "docker/dockerfile:1.8",
+ }, plan.Subjects[0].BuildConfig.FrontendAttrs)
+}
+
+func TestMakeBuildPlanRecordedNetworkMode(t *testing.T) {
+ pred := testPredicate(nil, nil)
+ pred.BuildDefinition.ExternalParameters.Request.Args["force-network-mode"] = "none"
+ target := []Target{{Subject: testSubject(t), Predicate: pred}}
+
+ plan, err := MakeBuildPlan(&BuildRequest{Targets: target})
+ require.NoError(t, err)
+ require.Equal(t, "none", plan.Subjects[0].BuildConfig.NetworkMode)
+
+ // An explicit --network takes precedence over the recorded mode.
+ plan, err = MakeBuildPlan(&BuildRequest{Targets: target, NetworkMode: "default"})
+ require.NoError(t, err)
+ require.Equal(t, "default", plan.Subjects[0].BuildConfig.NetworkMode)
+
+ // A recorded host network cannot be replayed without an explicit mode.
+ pred.BuildDefinition.ExternalParameters.Request.Args["force-network-mode"] = "host"
+ _, err = MakeBuildPlan(&BuildRequest{Targets: target})
+ require.ErrorContains(t, err, "--network=host")
+ plan, err = MakeBuildPlan(&BuildRequest{Targets: target, NetworkMode: "none"})
+ require.NoError(t, err)
+ require.Equal(t, "none", plan.Subjects[0].BuildConfig.NetworkMode)
+}
+
+func TestMakeBuildPlanRejectsLocalInputs(t *testing.T) {
+ for _, value := range []string{"oci-layout://abc123@sha256:" + strings.Repeat("a", 64), "input:foo", "target:foo", "local:foo"} {
+ t.Run(strings.SplitN(value, ":", 2)[0], func(t *testing.T) {
+ pred := testPredicate(nil, nil)
+ pred.BuildDefinition.ExternalParameters.Request.Args["context:foo"] = value
+ _, err := MakeBuildPlan(&BuildRequest{Targets: []Target{{Subject: testSubject(t), Predicate: pred}}})
+ var local *UnreplayableLocalContextError
+ require.ErrorAs(t, err, &local)
+ require.Equal(t, []string{"foo"}, local.LocalSources)
+ })
+ }
+
+ // A context read from stdin is uploaded through the client session.
+ pred := testPredicate(nil, nil)
+ pred.BuildDefinition.ExternalParameters.ConfigSource.URI = "http://buildkit-session/7ljrjpb29qglz425jejnuhlnx"
+ pred.BuildDefinition.ExternalParameters.Request.Args["context"] = "http://buildkit-session/7ljrjpb29qglz425jejnuhlnx"
+ _, err := MakeBuildPlan(&BuildRequest{Targets: []Target{{Subject: testSubject(t), Predicate: pred}}})
+ var local *UnreplayableLocalContextError
+ require.ErrorAs(t, err, &local)
+ require.Equal(t, []string{"context (stdin)"}, local.LocalSources)
+
+ // Remote named contexts are replayable.
+ pred = testPredicate(nil, nil)
+ pred.BuildDefinition.ExternalParameters.Request.Args["context:foo"] = "docker-image://alpine:3.20"
+ _, err = MakeBuildPlan(&BuildRequest{Targets: []Target{{Subject: testSubject(t), Predicate: pred}}})
+ require.NoError(t, err)
+}
+
+func TestMakeBuildPlanValidatesOutputs(t *testing.T) {
+ target := []Target{{Subject: testSubject(t), Predicate: testPredicate(nil, nil)}}
+
+ _, err := MakeBuildPlan(&BuildRequest{Targets: target, Exports: []*buildflags.ExportEntry{{Type: "local", Attrs: map[string]string{}}}})
+ require.ErrorContains(t, err, "dest is required")
+
+ _, err = MakeBuildPlan(&BuildRequest{Targets: target, Exports: []*buildflags.ExportEntry{{
+ Type: "local",
+ Destination: t.TempDir(),
+ Attrs: map[string]string{"mode": "delete"},
+ }}})
+ require.ErrorContains(t, err, "does not support local output mode=delete")
+}
+
+func TestMakeBuildPlanMinModeRejected(t *testing.T) {
+ pred := testPredicate(nil, nil)
+ pred.BuildDefinition.InternalParameters.BuildConfig = nil
+ // mode=min drops recorded secrets, so a user-supplied secret must not be
+ // reported as an extra secret.
+ _, err := MakeBuildPlan(&BuildRequest{
+ Targets: []Target{{Subject: testSubject(t), Predicate: pred}},
+ Secrets: buildflags.Secrets{&buildflags.Secret{ID: "mysecret"}},
+ })
+ var minMode *MinModeProvenanceError
+ require.ErrorAs(t, err, &minMode)
+ require.ErrorContains(t, err, "--provenance=mode=max")
+}
+
+func TestMakeBuildPlanRejectsMultipleSubjects(t *testing.T) {
+ _, err := MakeBuildPlan(&BuildRequest{Targets: []Target{
+ {Subject: testSubject(t), Predicate: testPredicate(nil, nil)},
+ {Subject: testSubject(t), Predicate: testPredicate(nil, nil)},
+ }})
+ var notImplemented *NotImplementedError
+ require.ErrorAs(t, err, ¬Implemented)
+ require.ErrorContains(t, err, "--platform")
+}
+
+func TestMakeBuildPlanUnpinnedGitSubdirContext(t *testing.T) {
+ // BuildKit before v0.25 recorded no digest for a Git subdirectory
+ // context and recorded the Git material without the subdirectory.
+ gitSubdirPredicate := func() *Predicate {
+ pred := testPredicate(nil, nil)
+ pred.BuildDefinition.ExternalParameters.ConfigSource.URI = "https://github.com/example/repo.git#main:sub"
+ pred.BuildDefinition.ExternalParameters.Request.Args["context"] = "https://github.com/example/repo.git#main:sub"
+ pred.BuildDefinition.ResolvedDependencies = append(pred.BuildDefinition.ResolvedDependencies, slsa1.ResourceDescriptor{
+ URI: "https://github.com/example/repo.git#main",
+ Digest: map[string]string{"sha1": "0123456789abcdef0123456789abcdef01234567"},
+ })
+ return pred
+ }
+
+ _, err := MakeBuildPlan(&BuildRequest{Targets: []Target{{Subject: testSubject(t), Predicate: gitSubdirPredicate()}}})
+ var unpinned *UnpinnedContextError
+ require.ErrorAs(t, err, &unpinned)
+ require.Equal(t, "https://github.com/example/repo.git#main:sub", unpinned.URI)
+
+ // Frontend mode does not pin sources, so the context is replayable.
+ _, err = MakeBuildPlan(&BuildRequest{
+ Targets: []Target{{Subject: testSubject(t), Predicate: gitSubdirPredicate()}},
+ Mode: BuildModeFrontend,
+ })
+ require.NoError(t, err)
+
+ // A recorded context digest pins the context.
+ pred := gitSubdirPredicate()
+ pred.BuildDefinition.ExternalParameters.ConfigSource.Digest = map[string]string{"sha1": "0123456789abcdef0123456789abcdef01234567"}
+ _, err = MakeBuildPlan(&BuildRequest{Targets: []Target{{Subject: testSubject(t), Predicate: pred}}})
+ require.NoError(t, err)
+
+ // A root context is requested with the same identifier as its Git
+ // material, so a missing context digest is not a problem.
+ pred = gitSubdirPredicate()
+ pred.BuildDefinition.ExternalParameters.ConfigSource.URI = "https://github.com/example/repo.git#main"
+ pred.BuildDefinition.ExternalParameters.Request.Args["context"] = "https://github.com/example/repo.git#main"
+ _, err = MakeBuildPlan(&BuildRequest{Targets: []Target{{Subject: testSubject(t), Predicate: pred}}})
+ require.NoError(t, err)
+}
+
+func TestMakeBuildPlanHappyPath(t *testing.T) {
+ s := testSubject(t)
+ pred := testPredicate([]struct {
+ id string
+ optional bool
+ }{
+ {id: "required"},
+ {id: "optional", optional: true},
+ }, nil)
+ resolver, err := NewMaterialsResolver(nil)
+ require.NoError(t, err)
+
+ req := &BuildRequest{
+ Targets: []Target{{Subject: s, Predicate: pred}},
+ Mode: BuildModeMaterials,
+ Materials: resolver,
+ Secrets: buildflags.Secrets{{ID: "required"}},
+ }
+ plan, err := MakeBuildPlan(req)
+ require.NoError(t, err)
+ require.Len(t, plan.Subjects, 1)
+ require.Equal(t, s.Descriptor.Digest, plan.Subjects[0].Descriptor.Digest)
+ require.Len(t, plan.Subjects[0].Materials, 2)
+ require.Equal(t, "https://github.com/example/repo.git", plan.Subjects[0].BuildConfig.Context)
+ require.Equal(t, "Dockerfile", plan.Subjects[0].BuildConfig.Filename)
+ // dockerfile.v0 does not use the recorded gateway source.
+ require.Equal(t, "dockerfile.v0", plan.Subjects[0].BuildConfig.Frontend)
+ require.Nil(t, plan.Subjects[0].BuildConfig.FrontendAttrs)
+ require.Equal(t, "default", plan.Subjects[0].BuildConfig.NetworkMode)
+ require.Equal(t, []PlanSecret{
+ {ID: "optional", Optional: true},
+ {ID: "required"},
+ }, plan.Subjects[0].BuildConfig.Secrets)
+ // First material is image-kind.
+ require.Equal(t, "image", plan.Subjects[0].Materials[0].Kind)
+ // Second material is http.
+ require.Equal(t, "http", plan.Subjects[0].Materials[1].Kind)
+ // JSON shape is stable.
+ dt, err := json.Marshal(plan)
+ require.NoError(t, err)
+ require.NotContains(t, string(dt), `"inputRef":`)
+ require.NotContains(t, string(dt), `"platform":"linux/amd64"`)
+ require.NotContains(t, string(dt), `"predicateType":`)
+ require.NotContains(t, string(dt), `"pins":`)
+ require.NotContains(t, string(dt), `"replayMode":`)
+ require.NotContains(t, string(dt), `"warnings":`)
+ require.NotContains(t, string(dt), `"build-arg:`)
+ require.NotContains(t, string(dt), `"label:`)
+}
+
+func TestMakeBuildPlanLocalContextRejected(t *testing.T) {
+ s := testSubject(t)
+ pred := testPredicate(nil, []string{"ctx"})
+ req := &BuildRequest{
+ Targets: []Target{{Subject: s, Predicate: pred}},
+ }
+ _, err := MakeBuildPlan(req)
+ require.Error(t, err)
+ var ulc *UnreplayableLocalContextError
+ require.ErrorAs(t, err, &ulc)
+}
+
+func TestMakeBuildPlanExtraSecretRejected(t *testing.T) {
+ s := testSubject(t)
+ pred := testPredicate(nil, nil)
+ req := &BuildRequest{
+ Targets: []Target{{Subject: s, Predicate: pred}},
+ Secrets: buildflags.Secrets{{ID: "rogue"}},
+ }
+ _, err := MakeBuildPlan(req)
+ require.Error(t, err)
+ var es *ExtraSecretError
+ require.ErrorAs(t, err, &es)
+ require.Equal(t, []string{"rogue"}, es.IDs)
+}
+
+func TestMakeBuildPlanMissingRecordedContextRejected(t *testing.T) {
+ s := testSubject(t)
+ pred := testPredicate(nil, nil)
+ delete(pred.BuildDefinition.ExternalParameters.Request.Args, "context")
+ pred.BuildDefinition.ExternalParameters.ConfigSource.URI = ""
+ req := &BuildRequest{
+ Targets: []Target{{Subject: s, Predicate: pred}},
+ }
+ _, err := MakeBuildPlan(req)
+ require.EqualError(t, err, "predicate has no recorded build context; replay requires a remote-source build (git / https)")
+}
+
+func TestMakeSnapshotPlanHappyPath(t *testing.T) {
+ fx := makeSnapshotFixture(t)
+
+ req := &SnapshotRequest{
+ Targets: []Target{{Subject: fx.subject, Predicate: fx.predicate}},
+ IncludeMaterials: true,
+ Materials: snapshotOverrideResolver(t, fx.httpURI, fx.httpBytes),
+ }
+ plan, err := MakeSnapshotPlan(context.Background(), nil, "", req)
+ require.NoError(t, err)
+ require.Len(t, plan, 1)
+ require.Equal(t, fx.subject.Descriptor.Digest, plan[0].Subject.Digest)
+ require.NotEmpty(t, plan[0].Materials)
+ // Fixture has an http material only; non-image entries must not
+ // carry a manifest-derived size.
+ for _, m := range plan[0].Materials {
+ if m.Kind != "image" {
+ require.Zero(t, m.Size, "non-image materials must not report a size")
+ }
+ }
+}
+
+func TestMakeSnapshotPlanAttestationFileRejected(t *testing.T) {
+ s := testSubject(t)
+ s.kind = subjectKindAttestationFile
+ pred := testPredicate(nil, nil)
+ req := &SnapshotRequest{
+ Targets: []Target{{Subject: s, Predicate: pred}},
+ }
+ _, err := MakeSnapshotPlan(context.Background(), nil, "", req)
+ require.Error(t, err)
+ var us *UnsupportedSubjectError
+ require.ErrorAs(t, err, &us)
+}
diff --git a/replay/errors.go b/replay/errors.go
new file mode 100644
index 000000000000..d59cba57a935
--- /dev/null
+++ b/replay/errors.go
@@ -0,0 +1,270 @@
+package replay
+
+import (
+ "fmt"
+ "sort"
+ "strings"
+
+ "github.com/pkg/errors"
+)
+
+// UnreplayableLocalContextError signals that the original build used a local
+// filesystem context which replay cannot reproduce.
+type UnreplayableLocalContextError struct {
+ LocalSources []string
+}
+
+func (e *UnreplayableLocalContextError) Error() string {
+ const hint = "only builds from a Git repository or HTTP(S) URL context can be replayed"
+ if len(e.LocalSources) == 0 {
+ return "image was built from local inputs that replay cannot fetch; " + hint
+ }
+ return fmt.Sprintf("image was built from local inputs that replay cannot fetch (%s); %s", strings.Join(e.LocalSources, ", "), hint)
+}
+
+// ErrUnreplayableLocalContext constructs an UnreplayableLocalContextError.
+func ErrUnreplayableLocalContext(sources []string) error {
+ sorted := append([]string(nil), sources...)
+ sort.Strings(sorted)
+ return errors.WithStack(&UnreplayableLocalContextError{LocalSources: sorted})
+}
+
+// MissingSecretError is returned when provenance declares required secrets
+// that the user did not provide.
+type MissingSecretError struct {
+ IDs []string
+}
+
+func (e *MissingSecretError) Error() string {
+ return fmt.Sprintf("missing required secrets: %s (pass them with --secret id=,src=)", strings.Join(e.IDs, ", "))
+}
+
+// ErrMissingSecret constructs a MissingSecretError.
+func ErrMissingSecret(ids []string) error {
+ sorted := append([]string(nil), ids...)
+ sort.Strings(sorted)
+ return errors.WithStack(&MissingSecretError{IDs: sorted})
+}
+
+// ExtraSecretError is returned when the user supplies secrets that the
+// provenance does not declare.
+type ExtraSecretError struct {
+ IDs []string
+}
+
+func (e *ExtraSecretError) Error() string {
+ return fmt.Sprintf("extra secrets not declared in provenance: %s", strings.Join(e.IDs, ", "))
+}
+
+// ErrExtraSecret constructs an ExtraSecretError.
+func ErrExtraSecret(ids []string) error {
+ sorted := append([]string(nil), ids...)
+ sort.Strings(sorted)
+ return errors.WithStack(&ExtraSecretError{IDs: sorted})
+}
+
+// MissingSSHError is returned when provenance declares required SSH agents
+// that the user did not provide.
+type MissingSSHError struct {
+ IDs []string
+}
+
+func (e *MissingSSHError) Error() string {
+ return fmt.Sprintf("missing required ssh entries: %s (pass them with --ssh =)", strings.Join(e.IDs, ", "))
+}
+
+// ErrMissingSSH constructs a MissingSSHError.
+func ErrMissingSSH(ids []string) error {
+ sorted := append([]string(nil), ids...)
+ sort.Strings(sorted)
+ return errors.WithStack(&MissingSSHError{IDs: sorted})
+}
+
+// ExtraSSHError is returned when the user supplies SSH agents that the
+// provenance does not declare.
+type ExtraSSHError struct {
+ IDs []string
+}
+
+func (e *ExtraSSHError) Error() string {
+ return fmt.Sprintf("extra ssh entries not declared in provenance: %s", strings.Join(e.IDs, ", "))
+}
+
+// ErrExtraSSH constructs an ExtraSSHError.
+func ErrExtraSSH(ids []string) error {
+ sorted := append([]string(nil), ids...)
+ sort.Strings(sorted)
+ return errors.WithStack(&ExtraSSHError{IDs: sorted})
+}
+
+// MaterialNotFoundError indicates a provenance material that the resolver
+// could not locate in any configured store.
+type MaterialNotFoundError struct {
+ URI string
+ Digest string
+}
+
+func (e *MaterialNotFoundError) Error() string {
+ return fmt.Sprintf("material not found: uri=%q digest=%q", e.URI, e.Digest)
+}
+
+// ErrMaterialNotFound constructs a MaterialNotFoundError.
+func ErrMaterialNotFound(uri, dgst string) error {
+ return errors.WithStack(&MaterialNotFoundError{URI: uri, Digest: dgst})
+}
+
+// CompareMismatchError is returned by `replay verify` when the replayed
+// artifact does not match the subject. The wrapped Report may be nil when no
+// structured diff is available (digest comparison).
+type CompareMismatchError struct {
+ // Report is typed as any so callers can surface either the basic compare
+ // tree or a future richer report format without breaking the error type.
+ Report any
+ Reason string
+}
+
+func (e *CompareMismatchError) Error() string {
+ if e.Reason != "" {
+ return fmt.Sprintf("replay mismatch: %s", e.Reason)
+ }
+ return "replay mismatch"
+}
+
+// ErrCompareMismatch constructs a CompareMismatchError.
+func ErrCompareMismatch(reason string, report any) error {
+ return errors.WithStack(&CompareMismatchError{Reason: reason, Report: report})
+}
+
+// NotImplementedError marks a feature that is not yet implemented.
+type NotImplementedError struct {
+ Feature string
+}
+
+func (e *NotImplementedError) Error() string {
+ return fmt.Sprintf("not implemented: %s", e.Feature)
+}
+
+// ErrNotImplemented constructs a NotImplementedError.
+func ErrNotImplemented(feature string) error {
+ return errors.WithStack(&NotImplementedError{Feature: feature})
+}
+
+// UnsupportedSubjectError signals that the supplied subject kind is not
+// compatible with the invoked subcommand.
+type UnsupportedSubjectError struct {
+ Kind string
+}
+
+func (e *UnsupportedSubjectError) Error() string {
+ return fmt.Sprintf("unsupported subject: %s", e.Kind)
+}
+
+// ErrUnsupportedSubject constructs an UnsupportedSubjectError.
+func ErrUnsupportedSubject(kind string) error {
+ return errors.WithStack(&UnsupportedSubjectError{Kind: kind})
+}
+
+// NoProvenanceError is returned when no SLSA provenance attestation could be
+// found for a subject.
+type NoProvenanceError struct {
+ Subject string
+ // Manifest is set when the subject is an image manifest referenced
+ // directly rather than through its image index.
+ Manifest bool
+}
+
+func (e *NoProvenanceError) Error() string {
+ hint := "build the image with --provenance=mode=max to make it replayable"
+ if e.Manifest {
+ hint = "if this is a platform manifest of a multi-platform image, use the image index reference instead; otherwise " + hint
+ }
+ if e.Subject == "" {
+ return "no SLSA provenance attestation found; " + hint
+ }
+ return fmt.Sprintf("no SLSA provenance attestation found for %s; %s", e.Subject, hint)
+}
+
+// ErrNoProvenance constructs a NoProvenanceError.
+func ErrNoProvenance(subject string) error {
+ return errors.WithStack(&NoProvenanceError{Subject: subject})
+}
+
+// ErrNoProvenanceForManifest constructs a NoProvenanceError for an image
+// manifest referenced directly.
+func ErrNoProvenanceForManifest(subject string) error {
+ return errors.WithStack(&NoProvenanceError{Subject: subject, Manifest: true})
+}
+
+// UnsupportedPredicateError signals that the attached predicate is not SLSA
+// provenance.
+type UnsupportedPredicateError struct {
+ PredicateType string
+}
+
+func (e *UnsupportedPredicateError) Error() string {
+ return fmt.Sprintf("unsupported predicate type %q; replay requires SLSA provenance (v0.2 or v1)", e.PredicateType)
+}
+
+// ErrUnsupportedPredicate constructs an UnsupportedPredicateError.
+func ErrUnsupportedPredicate(predicateType string) error {
+ return errors.WithStack(&UnsupportedPredicateError{PredicateType: predicateType})
+}
+
+// SignatureVerificationRequiredError is returned when a signed envelope
+// cannot be verified from the available trust material. Replay never silently
+// unwraps such an attestation.
+type SignatureVerificationRequiredError struct {
+ // Source is the user-visible input that carries the signed envelope
+ // (file path for attestation-file inputs).
+ Source string
+ // Envelope describes the detected envelope shape ("dsse" or
+ // "sigstore-bundle") so the user can tell what was rejected.
+ Envelope string
+}
+
+func (e *SignatureVerificationRequiredError) Error() string {
+ src := e.Source
+ if src == "" {
+ src = "attestation"
+ }
+ env := e.Envelope
+ if env == "" {
+ env = "signed envelope"
+ }
+ return fmt.Sprintf("%s for %s carries signatures but cannot be verified from the available trust material; refusing to accept unverified signed attestation", env, src)
+}
+
+// ErrSignatureVerificationRequired constructs a
+// SignatureVerificationRequiredError.
+func ErrSignatureVerificationRequired(source, envelope string) error {
+ return errors.WithStack(&SignatureVerificationRequiredError{Source: source, Envelope: envelope})
+}
+
+// MinModeProvenanceError is returned when the provenance was recorded with
+// mode=min, which omits the build arguments, secrets and SSH needed to
+// reconstruct the build.
+type MinModeProvenanceError struct{}
+
+func (e *MinModeProvenanceError) Error() string {
+ return "provenance was recorded with mode=min, which omits build arguments, secrets and SSH needed for replay; build the image with --provenance=mode=max to make it replayable"
+}
+
+// ErrMinModeProvenance constructs a MinModeProvenanceError.
+func ErrMinModeProvenance() error {
+ return errors.WithStack(&MinModeProvenanceError{})
+}
+
+// UnpinnedContextError is returned when the provenance does not record a
+// digest for a Git subdirectory build context, so replay cannot pin it.
+type UnpinnedContextError struct {
+ URI string
+}
+
+func (e *UnpinnedContextError) Error() string {
+ return fmt.Sprintf("cannot pin the Git build context %s: provenance from BuildKit before v0.25 does not record a digest for subdirectory contexts; use --replay-mode=frontend to replay without pinning sources", e.URI)
+}
+
+// ErrUnpinnedContext constructs an UnpinnedContextError.
+func ErrUnpinnedContext(uri string) error {
+ return errors.WithStack(&UnpinnedContextError{URI: uri})
+}
diff --git a/replay/errors_test.go b/replay/errors_test.go
new file mode 100644
index 000000000000..a68d03327f4c
--- /dev/null
+++ b/replay/errors_test.go
@@ -0,0 +1,89 @@
+package replay
+
+import (
+ "testing"
+
+ "github.com/pkg/errors"
+ "github.com/stretchr/testify/require"
+)
+
+// TestErrorTypes asserts that each replay constructor returns the typed error
+// expected by errors.As-based consumers.
+func TestErrorTypes(t *testing.T) {
+ tests := []struct {
+ name string
+ err error
+ want any
+ }{
+ {"local context", ErrUnreplayableLocalContext([]string{"default"}), &UnreplayableLocalContextError{}},
+ {"missing secret", ErrMissingSecret([]string{"mysecret"}), &MissingSecretError{}},
+ {"extra secret", ErrExtraSecret([]string{"mysecret"}), &ExtraSecretError{}},
+ {"missing ssh", ErrMissingSSH([]string{"default"}), &MissingSSHError{}},
+ {"extra ssh", ErrExtraSSH([]string{"default"}), &ExtraSSHError{}},
+ {"material not found", ErrMaterialNotFound("docker-image://foo", "sha256:aa"), &MaterialNotFoundError{}},
+ {"compare mismatch", ErrCompareMismatch("digest differs", nil), &CompareMismatchError{}},
+ {"not implemented", ErrNotImplemented("registry snapshot output"), &NotImplementedError{}},
+ {"unsupported subject", ErrUnsupportedSubject("attestation-file"), &UnsupportedSubjectError{}},
+ {"no provenance", ErrNoProvenance("foo:latest"), &NoProvenanceError{}},
+ {"unsupported predicate", ErrUnsupportedPredicate("https://slsa.dev/provenance/v0.2"), &UnsupportedPredicateError{}},
+ {"min mode provenance", ErrMinModeProvenance(), &MinModeProvenanceError{}},
+ {"unpinned context", ErrUnpinnedContext("https://example.com/repo.git#main:sub"), &UnpinnedContextError{}},
+ {"signature verification required", ErrSignatureVerificationRequired("./att.json", "dsse"), &SignatureVerificationRequiredError{}},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ require.Error(t, tt.err)
+ switch want := tt.want.(type) {
+ case *UnreplayableLocalContextError:
+ require.ErrorAs(t, tt.err, &want)
+ case *MissingSecretError:
+ require.ErrorAs(t, tt.err, &want)
+ case *ExtraSecretError:
+ require.ErrorAs(t, tt.err, &want)
+ case *MissingSSHError:
+ require.ErrorAs(t, tt.err, &want)
+ case *ExtraSSHError:
+ require.ErrorAs(t, tt.err, &want)
+ case *MaterialNotFoundError:
+ require.ErrorAs(t, tt.err, &want)
+ case *CompareMismatchError:
+ require.ErrorAs(t, tt.err, &want)
+ case *NotImplementedError:
+ require.ErrorAs(t, tt.err, &want)
+ case *UnsupportedSubjectError:
+ require.ErrorAs(t, tt.err, &want)
+ case *NoProvenanceError:
+ require.ErrorAs(t, tt.err, &want)
+ case *UnsupportedPredicateError:
+ require.ErrorAs(t, tt.err, &want)
+ case *MinModeProvenanceError:
+ require.ErrorAs(t, tt.err, &want)
+ case *UnpinnedContextError:
+ require.ErrorAs(t, tt.err, &want)
+ case *SignatureVerificationRequiredError:
+ require.ErrorAs(t, tt.err, &want)
+ default:
+ t.Fatalf("unexpected want type %T", tt.want)
+ }
+ })
+ }
+}
+
+// TestErrorsAsWrapped asserts that pkg/errors stack-wrapped and fmt-wrapped
+// replay errors remain errors.As-matchable.
+func TestErrorsAsWrapped(t *testing.T) {
+ e := ErrMaterialNotFound("foo", "sha256:a")
+ wrapped := errors.Wrap(e, "resolver failed")
+ var mnf *MaterialNotFoundError
+ require.ErrorAs(t, wrapped, &mnf)
+ require.Equal(t, "foo", mnf.URI)
+}
+
+// TestSignatureVerificationRequiredMessage asserts the error message carries
+// both the source and envelope kind so users know what was rejected.
+func TestSignatureVerificationRequiredMessage(t *testing.T) {
+ e := ErrSignatureVerificationRequired("/tmp/att.json", "dsse")
+ require.Contains(t, e.Error(), "/tmp/att.json")
+ require.Contains(t, e.Error(), "dsse")
+}
diff --git a/replay/materials.go b/replay/materials.go
new file mode 100644
index 000000000000..cc1a5e2de678
--- /dev/null
+++ b/replay/materials.go
@@ -0,0 +1,645 @@
+package replay
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/containerd/containerd/v2/core/content"
+ contentlocal "github.com/containerd/containerd/v2/plugins/content/local"
+ "github.com/containerd/platforms"
+ "github.com/docker/buildx/util/ocilayout"
+ "github.com/moby/buildkit/client/ociindex"
+ "github.com/moby/buildkit/util/contentutil"
+ digest "github.com/opencontainers/go-digest"
+ ocispecs "github.com/opencontainers/image-spec/specs-go/v1"
+ "github.com/pkg/errors"
+)
+
+// MaterialsResolver resolves provenance materials to a (descriptor, provider)
+// pair the replay pipeline can use to serve content locally. Lookup order:
+//
+// 1. overrides keyed by URI or "sha256:"
+// 2. explicit stores, in the order listed on `--materials`
+// 3. the `provenance` sentinel (fetch from the URI recorded in provenance)
+//
+// Explicit stores and overrides are only consumed by `replay snapshot`;
+// `replay build` and `replay verify` support the provenance sentinel only.
+type MaterialsResolver struct {
+ overrides map[string]materialOverride
+ stores []materialStore
+ sentinel bool // true when `provenance` sentinel is enabled
+}
+
+type materialOverride struct {
+ // spec is the raw right-hand side of a `key=value` override. Resolved
+ // lazily so a bad override doesn't poison the whole resolver build.
+ spec string
+}
+
+// materialStore is an entry on the --materials list that is NOT an override.
+// Exactly one of path/ociLayout is populated.
+type materialStore struct {
+ // path is a filesystem directory laid out as a raw content store
+ // (blobs//).
+ path string
+ // ociLayout is an absolute path to an OCI image layout directory.
+ // Lookup is by descriptor digest only.
+ ociLayout string
+}
+
+// NewMaterialsResolver parses the --materials list and returns a resolver.
+// Spec forms accepted:
+//
+// - "provenance" — sentinel (default when the
+// --materials list is empty)
+// - "oci-layout://[:]" — OCI layout store
+// - "" — raw content store
+// (blobs//)
+// - "=" — override: is the URI or
+// "sha256:"; is any of
+// the above narrowed to one blob.
+func NewMaterialsResolver(specs []string) (*MaterialsResolver, error) {
+ r := &MaterialsResolver{
+ overrides: map[string]materialOverride{},
+ }
+
+ // Default behavior when the user supplied no stores: use the provenance
+ // sentinel (fetch from the URIs recorded on each material).
+ if len(specs) == 0 {
+ r.sentinel = true
+ return r, nil
+ }
+
+ for _, raw := range specs {
+ spec := strings.TrimSpace(raw)
+ if spec == "" {
+ continue
+ }
+
+ // "=" override. The key is a URI that may itself contain
+ // '=' (purl qualifiers, query strings), so splitOverride looks for
+ // the '=' that is followed by a valid store.
+ if isOverrideSpec(spec) {
+ key, val, ok := splitOverride(spec)
+ if !ok {
+ return nil, errors.Errorf("malformed --materials override %q (want =)", spec)
+ }
+ r.overrides[key] = materialOverride{spec: val}
+ continue
+ }
+
+ switch {
+ case spec == "provenance":
+ r.sentinel = true
+ case strings.HasPrefix(spec, "oci-layout://"):
+ ref, _, err := ocilayout.Parse(spec)
+ if err != nil {
+ return nil, errors.Wrapf(err, "invalid --materials oci-layout spec %q", spec)
+ }
+ r.stores = append(r.stores, materialStore{ociLayout: ref.Path})
+ case filepath.IsAbs(spec):
+ if fi, err := os.Stat(spec); err != nil || !fi.IsDir() {
+ return nil, errors.Errorf("--materials path %q is not a directory", spec)
+ }
+ r.stores = append(r.stores, materialStore{path: spec})
+ default:
+ return nil, errors.Errorf("unrecognized --materials spec %q", spec)
+ }
+ }
+
+ return r, nil
+}
+
+// isOverrideSpec decides whether a raw --materials token is shaped like
+// "=". A token that starts with a known store sentinel/prefix or
+// is an absolute path is never an override, even if it happens to contain an
+// '=' somewhere inside (e.g. "oci-layout:///path/to/layout:tag=foo").
+func isOverrideSpec(spec string) bool {
+ switch {
+ case spec == "provenance":
+ return false
+ case strings.HasPrefix(spec, "oci-layout://"):
+ return false
+ case filepath.IsAbs(spec):
+ return false
+ }
+ return strings.Contains(spec, "=")
+}
+
+func splitOverride(spec string) (key, val string, ok bool) {
+ for offset := 0; offset < len(spec); {
+ rel := strings.IndexByte(spec[offset:], '=')
+ if rel < 0 {
+ break
+ }
+ i := offset + rel
+ if i > 0 && i < len(spec)-1 && isOverrideValue(spec[i+1:]) {
+ return spec[:i], spec[i+1:], true
+ }
+ offset = i + 1
+ }
+ return "", "", false
+}
+
+func isOverrideValue(spec string) bool {
+ return strings.HasPrefix(spec, "oci-layout://") || filepath.IsAbs(spec)
+}
+
+// Sentinel reports whether the `provenance` sentinel is enabled. The sentinel
+// authorises a fallback fetch from the URI recorded in the provenance.
+func (r *MaterialsResolver) Sentinel() bool {
+ return r != nil && r.sentinel
+}
+
+// Overrides returns an iteration-stable copy of the configured overrides for
+// tests and dry-run inspection.
+func (r *MaterialsResolver) Overrides() map[string]string {
+ if r == nil {
+ return nil
+ }
+ out := make(map[string]string, len(r.overrides))
+ for k, v := range r.overrides {
+ out[k] = v.spec
+ }
+ return out
+}
+
+// HasStores reports whether any explicit stores are configured. The primary
+// use is driving behavior when the resolver has only a sentinel.
+func (r *MaterialsResolver) HasStores() bool {
+ return r != nil && len(r.stores) > 0
+}
+
+// HasExplicitSources reports whether replay would need to inject locally
+// resolved material content into a solve. The provenance sentinel alone does
+// not require injection because BuildKit fetches those sources normally.
+func (r *MaterialsResolver) HasExplicitSources() bool {
+ return r != nil && (len(r.stores) > 0 || len(r.overrides) > 0)
+}
+
+// ResolveOption customises a Resolve call.
+type ResolveOption func(*resolveOptions)
+
+type resolveOptions struct {
+ // platform is used when resolving image materials out of a
+ // snapshot-backed store: the materials-manifest carries the original
+ // root-index blob, and the snapshot index's manifests[] holds the
+ // per-platform children. The caller supplies the platform it is
+ // replaying.
+ platform *ocispecs.Platform
+ // builderPlatform is the platform the original builder ran on
+ // (predicate InternalParameters.builderPlatform). Used as the
+ // fall-back when no child matches the subject platform — frontend
+ // images and cross-compile toolchains run on the builder host, not
+ // the target.
+ builderPlatform *ocispecs.Platform
+}
+
+// WithPlatform attaches a target platform to a Resolve call. When resolving
+// an image material against a snapshot-backed store this selects the
+// per-platform child to return.
+func WithPlatform(p *ocispecs.Platform) ResolveOption {
+ return func(o *resolveOptions) { o.platform = p }
+}
+
+// WithBuilderPlatform attaches the original builder's platform (recorded in
+// provenance) so the snapshot-backed store can fall back to it when the
+// subject platform has no match in an image material's root index.
+func WithBuilderPlatform(p ocispecs.Platform) ResolveOption {
+ return func(o *resolveOptions) { o.builderPlatform = &p }
+}
+
+// Resolve returns the descriptor and content.Provider that serve the material
+// with the given (uri, dgst). Exactly one of uri / dgst may be empty; when
+// both are empty an error is returned.
+//
+// Strict by default: materials not covered by the configured stores / overrides
+// and not reachable via the sentinel produce MaterialNotFoundError. The
+// provenance sentinel is NOT a network fetch — it signals that
+// BuildKit may resolve the material itself, subject to the source-policy
+// pin callback. A caller that requires a concrete (descriptor, provider)
+// pair for a sentinel-only material should use the store-backed
+// resolution path.
+//
+// Snapshot-backed stores are detected at lookup time. When `dgst` matches a
+// snapshot's materials-manifest layer AND the layer is an image-material root
+// manifest/index stashed as opaque bytes by `replay snapshot`,
+// Resolve returns the platform-specific child manifest descriptor reachable
+// through the snapshot index's `manifests[]`. The caller should pass
+// WithPlatform so the correct child can be picked.
+func (r *MaterialsResolver) Resolve(ctx context.Context, uri string, dgst digest.Digest, opts ...ResolveOption) (ocispecs.Descriptor, content.Provider, error) {
+ if r == nil {
+ return ocispecs.Descriptor{}, nil, ErrMaterialNotFound(uri, dgst.String())
+ }
+ if uri == "" && dgst == "" {
+ return ocispecs.Descriptor{}, nil, errors.New("resolve called with empty uri and empty digest")
+ }
+
+ var ro resolveOptions
+ for _, opt := range opts {
+ opt(&ro)
+ }
+
+ // 1. Overrides.
+ if key, o, ok := r.lookupOverride(uri, dgst); ok {
+ desc, provider, err := r.resolveOverride(ctx, o, dgst, &ro)
+ if err != nil {
+ return ocispecs.Descriptor{}, nil, errors.Wrapf(err, "override %s", key)
+ }
+ return desc, provider, nil
+ }
+
+ // 2. Explicit stores, in order. Snapshot-backed lookup is preferred
+ // when a store root carries a snapshot index at its root.
+ for _, s := range r.stores {
+ if dgst == "" {
+ continue
+ }
+ if desc, provider, ok, err := s.lookupSnapshot(ctx, dgst, &ro); err != nil {
+ return ocispecs.Descriptor{}, nil, err
+ } else if ok {
+ return desc, provider, nil
+ }
+ desc, provider, ok, err := s.lookupByDigest(ctx, dgst)
+ if err != nil {
+ return ocispecs.Descriptor{}, nil, err
+ }
+ if ok {
+ return desc, provider, nil
+ }
+ }
+
+ // 3. Sentinel fallback. Replay relies on BuildKit fetching the material
+ // over the network subject to the policy callback; we cannot
+ // materialise the content locally without going online, so we
+ // surface a sentinel-only descriptor (empty provider) that the
+ // caller may use to signal "let BuildKit resolve".
+ if r.sentinel {
+ return ocispecs.Descriptor{Digest: dgst}, nil, nil
+ }
+
+ return ocispecs.Descriptor{}, nil, ErrMaterialNotFound(uri, dgst.String())
+}
+
+func (r *MaterialsResolver) lookupOverride(uri string, dgst digest.Digest) (string, materialOverride, bool) {
+ if uri != "" {
+ if o, ok := r.overrides[uri]; ok {
+ return uri, o, true
+ }
+ }
+ if dgst != "" {
+ if o, ok := r.overrides[dgst.String()]; ok {
+ return dgst.String(), o, true
+ }
+ }
+ return "", materialOverride{}, false
+}
+
+// resolveOverride resolves an override value to a concrete (descriptor,
+// provider) pair. Override values accept the same forms as non-override
+// specs: "oci-layout://[:]", "".
+func (r *MaterialsResolver) resolveOverride(ctx context.Context, o materialOverride, dgst digest.Digest, ro *resolveOptions) (ocispecs.Descriptor, content.Provider, error) {
+ spec := strings.TrimSpace(o.spec)
+ switch {
+ case strings.HasPrefix(spec, "oci-layout://"):
+ ref, _, err := ocilayout.Parse(spec)
+ if err != nil {
+ return ocispecs.Descriptor{}, nil, err
+ }
+ store := materialStore{ociLayout: ref.Path}
+ if dgst == "" {
+ return ocispecs.Descriptor{}, nil, errors.New("override oci-layout requires a material digest")
+ }
+ if desc, provider, ok, err := store.lookupSnapshot(ctx, dgst, ro); err != nil {
+ return ocispecs.Descriptor{}, nil, err
+ } else if ok {
+ return desc, provider, nil
+ }
+ desc, provider, ok, err := store.lookupByDigest(ctx, dgst)
+ if err != nil {
+ return ocispecs.Descriptor{}, nil, err
+ }
+ if !ok {
+ return ocispecs.Descriptor{}, nil, ErrMaterialNotFound("", dgst.String())
+ }
+ return desc, provider, nil
+ case filepath.IsAbs(spec):
+ fi, err := os.Stat(spec)
+ if err != nil {
+ return ocispecs.Descriptor{}, nil, errors.WithStack(err)
+ }
+ if fi.IsDir() {
+ store := materialStore{path: spec}
+ if dgst == "" {
+ return ocispecs.Descriptor{}, nil, errors.New("override path requires a material digest")
+ }
+ if desc, provider, ok, err := store.lookupSnapshot(ctx, dgst, ro); err != nil {
+ return ocispecs.Descriptor{}, nil, err
+ } else if ok {
+ return desc, provider, nil
+ }
+ desc, provider, ok, err := store.lookupByDigest(ctx, dgst)
+ if err != nil {
+ return ocispecs.Descriptor{}, nil, err
+ }
+ if !ok {
+ return ocispecs.Descriptor{}, nil, ErrMaterialNotFound("", dgst.String())
+ }
+ return desc, provider, nil
+ }
+ // A file override addresses exactly one blob. We expose it as a
+ // synthetic provider rooted at the file's bytes.
+ dt, err := os.ReadFile(spec)
+ if err != nil {
+ return ocispecs.Descriptor{}, nil, errors.WithStack(err)
+ }
+ actual := digest.FromBytes(dt)
+ if dgst != "" && actual != dgst {
+ return ocispecs.Descriptor{}, nil, errors.Errorf("override file %s has digest %s, want %s", spec, actual, dgst)
+ }
+ desc := ocispecs.Descriptor{Digest: actual, Size: int64(len(dt))}
+ buf := contentutil.NewBuffer()
+ if err := content.WriteBlob(ctx, buf, actual.String(), bytes.NewReader(dt), desc); err != nil {
+ return ocispecs.Descriptor{}, nil, errors.WithStack(err)
+ }
+ return desc, buf, nil
+ }
+ return ocispecs.Descriptor{}, nil, errors.Errorf("unsupported override value %q", spec)
+}
+
+// lookupByDigest serves a blob by digest from a filesystem or oci-layout
+// store. The returned descriptor has Digest+Size populated; MediaType is
+// left empty — callers that need it must inspect the bytes.
+func (s materialStore) lookupByDigest(ctx context.Context, dgst digest.Digest) (ocispecs.Descriptor, content.Provider, bool, error) {
+ if dgst == "" {
+ return ocispecs.Descriptor{}, nil, false, nil
+ }
+ root := s.path
+ if root == "" {
+ root = s.ociLayout
+ }
+ if root == "" {
+ return ocispecs.Descriptor{}, nil, false, nil
+ }
+ blobPath := filepath.Join(root, "blobs", dgst.Algorithm().String(), dgst.Encoded())
+ fi, err := os.Stat(blobPath)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return ocispecs.Descriptor{}, nil, false, nil
+ }
+ return ocispecs.Descriptor{}, nil, false, errors.WithStack(err)
+ }
+ if fi.IsDir() {
+ return ocispecs.Descriptor{}, nil, false, errors.Errorf("blob path %s is a directory", blobPath)
+ }
+ desc := ocispecs.Descriptor{Digest: dgst, Size: fi.Size()}
+
+ // An OCI-layout store is expected to be a real containerd content
+ // store; use contentlocal.NewStore so Readers are proper.
+ provider, err := contentlocal.NewStore(root)
+ if err != nil {
+ return ocispecs.Descriptor{}, nil, false, errors.Wrapf(err, "store at %s", root)
+ }
+ // Ensure the blob is actually readable (guards against partial layouts).
+ ra, err := provider.ReaderAt(ctx, desc)
+ if err != nil {
+ return ocispecs.Descriptor{}, nil, false, errors.WithStack(err)
+ }
+ _ = ra.Close()
+ return desc, provider, true, nil
+}
+
+// lookupSnapshot attempts to serve `dgst` through a snapshot-backed view of
+// the store. The store's root is inspected once per call;
+// if it carries a snapshot index (artifactType = ArtifactTypeSnapshot) the
+// lookup proceeds by:
+//
+// 1. Scanning every per-platform snapshot index's materials-manifest
+// layers for a layer whose digest matches `dgst`. When found, the
+// layer descriptor and a filesystem-backed provider are returned. If
+// the matched layer's media type is an OCI image index (i.e. the
+// original image-material root index kept opaque in the snapshot),
+// the function parses the root index, selects the platform child
+// matching ro.platform, and returns that child's descriptor as
+// reachable from the per-platform snapshot index's manifests[].
+// 2. Scanning the per-platform snapshot index's manifests[] directly so
+// a caller that already has the platform manifest's digest can
+// resolve it without going through the root.
+//
+// Returns ok == false when the store is not snapshot-shaped or when the
+// digest is not covered. In both cases the caller falls back to a plain
+// digest lookup.
+func (s materialStore) lookupSnapshot(ctx context.Context, dgst digest.Digest, ro *resolveOptions) (ocispecs.Descriptor, content.Provider, bool, error) {
+ if s.ociLayout == "" {
+ return ocispecs.Descriptor{}, nil, false, nil
+ }
+ root, roots, err := readSnapshotRoots(s.ociLayout)
+ if err != nil {
+ // A store that just isn't a snapshot: fall back to plain lookup.
+ return ocispecs.Descriptor{}, nil, false, nil
+ }
+ if root.ArtifactType != ArtifactTypeSnapshot && !anyIsSnapshot(roots) {
+ return ocispecs.Descriptor{}, nil, false, nil
+ }
+
+ store, err := contentlocal.NewStore(s.ociLayout)
+ if err != nil {
+ return ocispecs.Descriptor{}, nil, false, errors.Wrapf(err, "store at %s", s.ociLayout)
+ }
+
+ perPlatformDescs, err := collectPerPlatformSnapshotIndexes(ctx, store, root, roots)
+ if err != nil {
+ return ocispecs.Descriptor{}, nil, false, err
+ }
+
+ var (
+ wantPlat *ocispecs.Platform
+ builderPlat = platforms.DefaultSpec()
+ )
+ if ro != nil {
+ wantPlat = ro.platform
+ if ro.builderPlatform != nil {
+ builderPlat = *ro.builderPlatform
+ }
+ }
+
+ for _, ppDesc := range perPlatformDescs {
+ ppDt, err := content.ReadBlob(ctx, store, ppDesc)
+ if err != nil {
+ return ocispecs.Descriptor{}, nil, false, errors.WithStack(err)
+ }
+ var pp ocispecs.Index
+ if err := json.Unmarshal(ppDt, &pp); err != nil {
+ return ocispecs.Descriptor{}, nil, false, errors.WithStack(err)
+ }
+
+ // Load the materials manifest (first manifest with artifactType
+ // ArtifactTypeMaterials — may be absent when the snapshot was
+ // created with --include-materials=false).
+ var materialsLayers []ocispecs.Descriptor
+ for _, m := range pp.Manifests {
+ if m.ArtifactType != ArtifactTypeMaterials {
+ continue
+ }
+ mData, err := content.ReadBlob(ctx, store, m)
+ if err != nil {
+ return ocispecs.Descriptor{}, nil, false, errors.WithStack(err)
+ }
+ var mm ocispecs.Manifest
+ if err := json.Unmarshal(mData, &mm); err != nil {
+ return ocispecs.Descriptor{}, nil, false, errors.WithStack(err)
+ }
+ materialsLayers = mm.Layers
+ break
+ }
+
+ // 1a. Direct hit on a materials-manifest layer.
+ for _, l := range materialsLayers {
+ if l.Digest != dgst {
+ continue
+ }
+ if isIndexMediaType(l.MediaType) {
+ // Image material root — pick the platform-specific child
+ // from the per-platform index's manifests[].
+ child, err := pickPerPlatformChild(ctx, store, l, pp, wantPlat, builderPlat)
+ if err != nil {
+ return ocispecs.Descriptor{}, nil, false, err
+ }
+ return child, store, true, nil
+ }
+ return l, store, true, nil
+ }
+
+ // 1b. Direct hit on a manifests[] entry (a platform-specific image
+ // manifest descriptor that the caller already looked up).
+ for _, m := range pp.Manifests {
+ if m.Digest == dgst {
+ return m, store, true, nil
+ }
+ }
+ }
+
+ return ocispecs.Descriptor{}, nil, false, nil
+}
+
+// readSnapshotRoots loads the root manifest references from the store's
+// index.json. Returns the single-descriptor "root" when only one is present
+// (the per-platform case) plus the full list for multi-platform snapshots.
+func readSnapshotRoots(path string) (ocispecs.Descriptor, []ocispecs.Descriptor, error) {
+ idx, err := ociindex.NewStoreIndex(path).Read()
+ if err != nil {
+ return ocispecs.Descriptor{}, nil, err
+ }
+ if len(idx.Manifests) == 0 {
+ return ocispecs.Descriptor{}, nil, errors.New("empty index")
+ }
+ return idx.Manifests[0], idx.Manifests, nil
+}
+
+// anyIsSnapshot reports whether any descriptor in roots carries the snapshot
+// artifact type. A top-level multi-platform snapshot's descriptor may itself
+// carry artifactType = ArtifactTypeSnapshot; so will each per-platform child.
+func anyIsSnapshot(roots []ocispecs.Descriptor) bool {
+ for _, r := range roots {
+ if r.ArtifactType == ArtifactTypeSnapshot {
+ return true
+ }
+ }
+ return false
+}
+
+// collectPerPlatformSnapshotIndexes traverses the supplied roots and returns
+// every per-platform snapshot index descriptor reachable from them. A
+// per-platform snapshot index is identified by mediaType=image index and
+// artifactType = ArtifactTypeSnapshot with a `subject`. The
+// single-platform case returns the root itself; the multi-platform case
+// unwraps the top-level index and returns its children.
+func collectPerPlatformSnapshotIndexes(ctx context.Context, store content.Provider, root ocispecs.Descriptor, roots []ocispecs.Descriptor) ([]ocispecs.Descriptor, error) {
+ candidates := roots
+ if len(candidates) == 0 {
+ candidates = []ocispecs.Descriptor{root}
+ }
+ var out []ocispecs.Descriptor
+ for _, c := range candidates {
+ if !isIndexMediaType(c.MediaType) {
+ continue
+ }
+ dt, err := content.ReadBlob(ctx, store, c)
+ if err != nil {
+ return nil, errors.WithStack(err)
+ }
+ var idx ocispecs.Index
+ if err := json.Unmarshal(dt, &idx); err != nil {
+ return nil, errors.WithStack(err)
+ }
+ // A per-platform snapshot index carries a non-nil Subject.
+ if idx.Subject != nil {
+ out = append(out, c)
+ continue
+ }
+ // Top-level multi-platform index — unwrap one level.
+ for _, child := range idx.Manifests {
+ if child.ArtifactType == ArtifactTypeSnapshot && isIndexMediaType(child.MediaType) {
+ out = append(out, child)
+ }
+ }
+ }
+ return out, nil
+}
+
+// pickPerPlatformChild selects the platform-specific manifest from a
+// per-platform snapshot index's manifests[] that corresponds to the
+// recorded image-material root `rootLayer`. The matcher prefers the
+// subject's platform then falls back to the builder platform; when the
+// root index has a single child it is returned unconditionally.
+func pickPerPlatformChild(ctx context.Context, store content.Provider, rootLayer ocispecs.Descriptor, pp ocispecs.Index, wantPlat *ocispecs.Platform, builderPlat ocispecs.Platform) (ocispecs.Descriptor, error) {
+ dt, err := content.ReadBlob(ctx, store, rootLayer)
+ if err != nil {
+ return ocispecs.Descriptor{}, errors.WithStack(err)
+ }
+ var rootIdx ocispecs.Index
+ if err := json.Unmarshal(dt, &rootIdx); err != nil {
+ return ocispecs.Descriptor{}, errors.WithStack(err)
+ }
+ matcher := replayPlatformMatcher(wantPlat, builderPlat)
+ var best *ocispecs.Descriptor
+ for i := range rootIdx.Manifests {
+ c := rootIdx.Manifests[i]
+ if c.Platform == nil || !matcher.Match(*c.Platform) {
+ continue
+ }
+ if best == nil || matcher.Less(*c.Platform, *best.Platform) {
+ best = &c
+ }
+ }
+ var wantDgst digest.Digest
+ switch {
+ case best != nil:
+ wantDgst = best.Digest
+ case len(rootIdx.Manifests) == 1:
+ wantDgst = rootIdx.Manifests[0].Digest
+ default:
+ return ocispecs.Descriptor{}, errors.Errorf("snapshot lookup: root %s has no child matching subject platform %s or builder %s", rootLayer.Digest, formatPlatformPtr(wantPlat), platforms.Format(builderPlat))
+ }
+ // Resolve against manifests[] for the concrete descriptor (includes
+ // size / mediaType as recorded by the snapshot).
+ for _, m := range pp.Manifests {
+ if m.Digest == wantDgst {
+ return m, nil
+ }
+ }
+ // Not present in the snapshot's manifests[] — return a synthetic
+ // descriptor so the caller can still address content-by-digest.
+ return ocispecs.Descriptor{
+ MediaType: ocispecs.MediaTypeImageManifest,
+ Digest: wantDgst,
+ }, nil
+}
+
+func isIndexMediaType(mt string) bool {
+ return mt == ocispecs.MediaTypeImageIndex || mt == "application/vnd.docker.distribution.manifest.list.v2+json"
+}
diff --git a/replay/materials_test.go b/replay/materials_test.go
new file mode 100644
index 000000000000..23d23c962369
--- /dev/null
+++ b/replay/materials_test.go
@@ -0,0 +1,216 @@
+package replay
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/containerd/containerd/v2/core/content"
+ "github.com/docker/buildx/util/buildflags"
+ "github.com/opencontainers/go-digest"
+ ocispecs "github.com/opencontainers/image-spec/specs-go/v1"
+ "github.com/stretchr/testify/require"
+)
+
+func writeBlob(t *testing.T, root string, dt []byte) digest.Digest {
+ t.Helper()
+ d := digest.FromBytes(dt)
+ dir := filepath.Join(root, "blobs", d.Algorithm().String())
+ require.NoError(t, os.MkdirAll(dir, 0o755))
+ require.NoError(t, os.WriteFile(filepath.Join(dir, d.Encoded()), dt, 0o644))
+ return d
+}
+
+// seedOCILayout creates a minimum OCI layout skeleton: a `oci-layout` marker
+// file plus the blobs/sha256 dir. The caller writes blobs on top.
+func seedOCILayout(t *testing.T, root string) {
+ t.Helper()
+ require.NoError(t, os.MkdirAll(filepath.Join(root, "blobs", "sha256"), 0o755))
+ require.NoError(t, os.WriteFile(filepath.Join(root, "oci-layout"), []byte(`{"imageLayoutVersion":"1.0.0"}`), 0o644))
+ require.NoError(t, os.WriteFile(filepath.Join(root, "index.json"), []byte(`{"schemaVersion":2,"manifests":[]}`), 0o644))
+}
+
+func TestMaterialsResolverProvenanceDefault(t *testing.T) {
+ r, err := NewMaterialsResolver(nil)
+ require.NoError(t, err)
+ require.True(t, r.Sentinel())
+ require.False(t, r.HasStores())
+}
+
+func TestMaterialsResolverProvenanceSentinelExplicit(t *testing.T) {
+ r, err := NewMaterialsResolver([]string{"provenance"})
+ require.NoError(t, err)
+ require.True(t, r.Sentinel())
+}
+
+func TestMaterialsResolverOCILayoutDigestLookup(t *testing.T) {
+ dir := t.TempDir()
+ seedOCILayout(t, dir)
+ payload := []byte(`{"mediaType":"application/vnd.oci.image.manifest.v1+json"}`)
+ d := writeBlob(t, dir, payload)
+
+ r, err := NewMaterialsResolver([]string{"oci-layout://" + dir})
+ require.NoError(t, err)
+ require.False(t, r.Sentinel())
+ require.True(t, r.HasStores())
+
+ desc, provider, err := r.Resolve(context.Background(), "pkg:docker/alpine@3.18", d)
+ require.NoError(t, err)
+ require.Equal(t, d, desc.Digest)
+ require.NotNil(t, provider)
+
+ // Verify that the returned provider serves the blob.
+ dt, err := content.ReadBlob(context.Background(), provider, ocispecs.Descriptor{Digest: d, Size: int64(len(payload))})
+ require.NoError(t, err)
+ require.Equal(t, payload, dt)
+}
+
+func TestMaterialsResolverOCILayoutMiss(t *testing.T) {
+ dir := t.TempDir()
+ seedOCILayout(t, dir)
+
+ r, err := NewMaterialsResolver([]string{"oci-layout://" + dir})
+ require.NoError(t, err)
+
+ absent := digest.FromBytes([]byte("nope"))
+ _, _, err = r.Resolve(context.Background(), "pkg:docker/missing@1.0", absent)
+ require.Error(t, err)
+ var mnf *MaterialNotFoundError
+ require.ErrorAs(t, err, &mnf)
+ require.Equal(t, absent.String(), mnf.Digest)
+}
+
+func TestMaterialsResolverOverridesByURI(t *testing.T) {
+ dir := t.TempDir()
+ seedOCILayout(t, dir)
+ payload := []byte("hello-override")
+ d := writeBlob(t, dir, payload)
+
+ uri := "https://example.com/whatever.tar"
+ specs := []string{uri + "=oci-layout://" + dir}
+ r, err := NewMaterialsResolver(specs)
+ require.NoError(t, err)
+
+ overrides := r.Overrides()
+ require.Contains(t, overrides, uri)
+
+ desc, provider, err := r.Resolve(context.Background(), uri, d)
+ require.NoError(t, err)
+ require.Equal(t, d, desc.Digest)
+ require.NotNil(t, provider)
+}
+
+func TestMaterialsResolverOverrideURIContainingEquals(t *testing.T) {
+ dir := t.TempDir()
+ for _, uri := range []string{
+ "pkg:docker/alpine@3.18?platform=linux%2Famd64",
+ "https://example.com/archive.tgz?token=abc",
+ } {
+ r, err := NewMaterialsResolver([]string{uri + "=" + dir})
+ require.NoError(t, err)
+ require.Equal(t, map[string]string{uri: dir}, r.Overrides())
+ }
+}
+
+func TestMaterialsResolverOverridesByDigest(t *testing.T) {
+ dir := t.TempDir()
+ seedOCILayout(t, dir)
+ payload := []byte("bytes-for-digest-override")
+ d := writeBlob(t, dir, payload)
+
+ specs := []string{d.String() + "=oci-layout://" + dir}
+ r, err := NewMaterialsResolver(specs)
+ require.NoError(t, err)
+
+ desc, provider, err := r.Resolve(context.Background(), "", d)
+ require.NoError(t, err)
+ require.Equal(t, d, desc.Digest)
+ require.NotNil(t, provider)
+}
+
+func TestMaterialsResolverOverrideFile(t *testing.T) {
+ dir := t.TempDir()
+ payload := []byte("standalone-file-override")
+ path := filepath.Join(dir, "payload.bin")
+ require.NoError(t, os.WriteFile(path, payload, 0o644))
+
+ d := digest.FromBytes(payload)
+ uri := "https://example.com/a.tar"
+ r, err := NewMaterialsResolver([]string{uri + "=" + path})
+ require.NoError(t, err)
+
+ desc, provider, err := r.Resolve(context.Background(), uri, d)
+ require.NoError(t, err)
+ require.Equal(t, d, desc.Digest)
+ require.Equal(t, int64(len(payload)), desc.Size)
+ require.NotNil(t, provider)
+
+ dt, err := content.ReadBlob(context.Background(), provider, desc)
+ require.NoError(t, err)
+ require.Equal(t, payload, dt)
+}
+
+func TestMaterialsResolverMalformedOverride(t *testing.T) {
+ _, err := NewMaterialsResolver([]string{"=oci-layout:///foo"})
+ require.Error(t, err)
+}
+
+func TestMaterialsResolverUnknownSpec(t *testing.T) {
+ _, err := NewMaterialsResolver([]string{"gopher://x"})
+ require.Error(t, err)
+}
+
+func TestMaterialsResolverOverrideIsNotConfusedWithPath(t *testing.T) {
+ // An absolute path that happens to contain '=' characters must still be
+ // recognised as a path, not as an override.
+ dir := t.TempDir()
+ oddDir := filepath.Join(dir, "has=equals")
+ require.NoError(t, os.MkdirAll(filepath.Join(oddDir, "blobs", "sha256"), 0o755))
+
+ r, err := NewMaterialsResolver([]string{oddDir})
+ require.NoError(t, err)
+ require.True(t, r.HasStores())
+}
+
+// TestMaterialsResolverSnapshotBackedLookup builds a snapshot via Snapshot(),
+// then points a fresh MaterialsResolver at the resulting OCI layout and
+// resolves both an http and (synthetic) image material. The snapshot-backed
+// lookup path (materials.go lookupSnapshot) is exercised.
+func TestMaterialsResolverSnapshotBackedLookup(t *testing.T) {
+ fx := makeSnapshotFixture(t)
+ dest := t.TempDir()
+ exp := buildflags.ExportEntry{Type: "oci", Destination: dest, Attrs: map[string]string{"tar": "false"}}
+ req := &SnapshotRequest{
+ Targets: []Target{{Subject: fx.subject, Predicate: fx.predicate}},
+ IncludeMaterials: true,
+ Materials: snapshotOverrideResolver(t, fx.httpURI, fx.httpBytes),
+ Output: &exp,
+ }
+ require.NoError(t, Snapshot(context.Background(), nil, "", req))
+
+ layout := "oci-layout://" + dest
+ r, err := NewMaterialsResolver([]string{layout})
+ require.NoError(t, err)
+
+ // 1. Http material: resolves by digest into the materials manifest's
+ // layer set.
+ desc, provider, err := r.Resolve(context.Background(), fx.httpURI, fx.httpDigest)
+ require.NoError(t, err)
+ require.NotNil(t, provider)
+ require.Equal(t, fx.httpDigest, desc.Digest)
+ got, err := content.ReadBlob(context.Background(), provider, desc)
+ require.NoError(t, err)
+ require.Equal(t, fx.httpBytes, got)
+
+ // 2. The subject manifest itself is reachable by its digest: Snapshot
+ // does not store it in the materials manifest, but the per-platform
+ // snapshot index's `manifests[]` references the attestation-manifest
+ // chain which CopyChain has copied into the layout. A lookup by the
+ // attestation manifest's digest must resolve (direct content-by-
+ // digest path).
+ attestDesc, attestProvider, err := r.Resolve(context.Background(), "", fx.attestDigest)
+ require.NoError(t, err)
+ require.NotNil(t, attestProvider)
+ require.Equal(t, fx.attestDigest, attestDesc.Digest)
+}
diff --git a/replay/policy.go b/replay/policy.go
new file mode 100644
index 000000000000..2deb75ed466c
--- /dev/null
+++ b/replay/policy.go
@@ -0,0 +1,495 @@
+package replay
+
+import (
+ "context"
+ "fmt"
+ "sort"
+ "strings"
+
+ "github.com/containerd/platforms"
+ "github.com/distribution/reference"
+ "github.com/docker/buildx/policy"
+ slsa1 "github.com/in-toto/in-toto-golang/in_toto/slsa_provenance/v1"
+ gwpb "github.com/moby/buildkit/frontend/gateway/pb"
+ solverpb "github.com/moby/buildkit/solver/pb"
+ spb "github.com/moby/buildkit/sourcepolicy/pb"
+ "github.com/moby/buildkit/sourcepolicy/policysession"
+ "github.com/moby/buildkit/util/purl"
+ "github.com/opencontainers/go-digest"
+ ocispecs "github.com/opencontainers/image-spec/specs-go/v1"
+ "github.com/pkg/errors"
+)
+
+// PinIndex is a resolved, URI/digest-keyed view of the predicate's
+// ResolvedDependencies suitable for fast policy-callback lookup.
+//
+// Exact material identifiers and lossy aliases are kept separate. An alias can
+// name more than one pin (for example the same image tag at two digests), so it
+// must never use last-write-wins selection.
+type PinIndex struct {
+ byURI map[string][]sourcePin
+ byAlias map[string][]sourcePin
+ materials []string
+}
+
+type sourcePin struct {
+ digest digest.Digest
+ platform *ocispecs.Platform
+}
+
+// NewPinIndex builds a PinIndex from the predicate's ResolvedDependencies.
+// When a material has multiple digest entries (e.g. sha256 + sha512), the
+// sha256 entry is preferred; otherwise the first entry wins. Materials without
+// a usable digest are skipped.
+func NewPinIndex(p *Predicate) *PinIndex {
+ idx := &PinIndex{
+ byURI: map[string][]sourcePin{},
+ byAlias: map[string][]sourcePin{},
+ }
+ if p == nil {
+ return idx
+ }
+ materials := p.ResolvedDependencies()
+ if cfg := p.ConfigSource(); cfg.URI != "" && len(cfg.Digest) > 0 {
+ materials = append(materials, slsa1.ResourceDescriptor{
+ URI: cfg.URI,
+ Digest: cfg.Digest,
+ })
+ }
+ seenMaterials := map[string]struct{}{}
+ for _, m := range materials {
+ if disp := formatPinMaterial(m); disp != "" {
+ if _, ok := seenMaterials[disp]; !ok {
+ seenMaterials[disp] = struct{}{}
+ idx.materials = append(idx.materials, disp)
+ }
+ }
+ d := preferredDigest(m.Digest)
+ if d == "" {
+ continue
+ }
+ pin := sourcePin{digest: d}
+ if m.URI != "" {
+ if canon, platform, ok := canonicalMaterialIdentifier(m); ok {
+ pin.platform = platform
+ idx.byURI[canon] = appendPin(idx.byURI[canon], pin)
+ for _, alias := range canonicalIdentifierAliases(canon) {
+ idx.byAlias[alias] = appendPin(idx.byAlias[alias], pin)
+ }
+ }
+ idx.byURI[m.URI] = appendPin(idx.byURI[m.URI], pin)
+ }
+ }
+ sort.Strings(idx.materials)
+ return idx
+}
+
+// preferredDigest picks a single digest from an in-toto DigestSet. sha256 is
+// preferred so it aligns with how BuildKit's source-meta responses return
+// image and http digests. Other algorithms are considered in lexical order
+// so provenance with multiple non-sha256 entries is deterministic.
+func preferredDigest(set map[string]string) digest.Digest {
+ if set == nil {
+ return ""
+ }
+ if v, ok := set["sha256"]; ok && v != "" {
+ return digest.NewDigestFromEncoded(digest.SHA256, v)
+ }
+ algorithms := make([]string, 0, len(set))
+ for alg, v := range set {
+ if v == "" {
+ continue
+ }
+ algorithms = append(algorithms, alg)
+ }
+ sort.Strings(algorithms)
+ for _, alg := range algorithms {
+ return digest.NewDigestFromEncoded(digest.Algorithm(alg), set[alg])
+ }
+ return ""
+}
+
+// Lookup resolves a URI to its pinned digest. Returns ("", false) when the
+// URI is not covered by the index.
+func (p *PinIndex) Lookup(uri string) (digest.Digest, bool) {
+ if p == nil {
+ return "", false
+ }
+ if pins, ok := p.byURI[uri]; ok {
+ return uniquePinDigest(pins)
+ }
+ return uniquePinDigest(p.byAlias[uri])
+}
+
+func appendPin(pins []sourcePin, pin sourcePin) []sourcePin {
+ for _, existing := range pins {
+ if existing.digest == pin.digest && platformEqual(existing.platform, pin.platform) {
+ return pins
+ }
+ }
+ return append(pins, pin)
+}
+
+func platformEqual(a, b *ocispecs.Platform) bool {
+ if a == nil || b == nil {
+ return a == nil && b == nil
+ }
+ return platforms.OnlyStrict(platforms.Normalize(*a)).Match(platforms.Normalize(*b))
+}
+
+func uniquePinDigest(pins []sourcePin) (digest.Digest, bool) {
+ var found digest.Digest
+ for _, pin := range pins {
+ if found == "" {
+ found = pin.digest
+ continue
+ }
+ if found != pin.digest {
+ return "", false
+ }
+ }
+ return found, found != ""
+}
+
+// ReplayPinCallback returns a policysession.PolicyCallback that enforces the
+// pin index. Sources covered by the index are ALLOWed when their requested
+// digest matches; unknown sources are DENY (fail-closed); covered sources
+// with wrong digest are DENY with a DenyMessage.
+func ReplayPinCallback(idx *PinIndex) policysession.PolicyCallback {
+ return func(ctx context.Context, req *policysession.CheckPolicyRequest) (*policysession.DecisionResponse, *gwpb.ResolveSourceMetaRequest, error) {
+ uri, observed := extractSourceIdentity(req)
+ if uri == "" {
+ return denyResponse("replay pin: request carried no source identifier"), nil, nil
+ }
+
+ // URI-matched: rewrite the source operation so BuildKit's source
+ // implementation enforces the recorded pin. Metadata is not present on
+ // BuildKit's initial policy request, so allowing a covered URI before
+ // adding these attributes would leave HTTP and Git sources unpinned.
+ if idx != nil {
+ if pinned, covered, reason := idx.resolve(req, uri, observed); covered {
+ if reason != "" {
+ return denyResponse(reason), nil, nil
+ }
+ decision, supported, err := convertPinnedSource(req, pinned)
+ if err != nil {
+ return nil, nil, err
+ } else if decision != nil {
+ return decision, nil, nil
+ }
+ if observed == "" {
+ if supported {
+ return allowResponse(), nil, nil
+ }
+ return denyResponse(fmt.Sprintf("replay pin: cannot enforce recorded digest for %s", uri)), nil, nil
+ }
+ if pinned == observed {
+ return allowResponse(), nil, nil
+ }
+ return denyResponse(fmt.Sprintf("replay pin mismatch for %s: expected %s, got %s", uri, pinned, observed)), nil, nil
+ }
+ }
+
+ // Source is not covered by the pin index. Fail closed.
+ return denyResponse(notCoveredMessage(idx, req, displaySource(uri, observed))), nil, nil
+ }
+}
+
+func (p *PinIndex) resolve(req *policysession.CheckPolicyRequest, uri string, observed digest.Digest) (digest.Digest, bool, string) {
+ keys := []string{uri}
+ if canon := canonicalRequestSource(req); canon != "" && canon != uri {
+ keys = append(keys, canon)
+ }
+ for _, key := range keys {
+ if pins, ok := p.byURI[key]; ok {
+ return selectPin(pins, requestPlatform(req), observed, key)
+ }
+ }
+ for _, key := range keys {
+ aliases := append([]string{key}, canonicalIdentifierAliases(key)...)
+ for _, alias := range aliases {
+ if pins, ok := p.byAlias[alias]; ok {
+ return selectPin(pins, requestPlatform(req), observed, alias)
+ }
+ }
+ }
+ return "", false, ""
+}
+
+func selectPin(pins []sourcePin, platform *ocispecs.Platform, observed digest.Digest, source string) (digest.Digest, bool, string) {
+ // A platform-qualified material still pins the same immutable image index.
+ // If every matching provenance entry agrees on that digest, the request
+ // platform cannot make the pin ambiguous and must not exclude it. This is
+ // important when replaying on a builder whose BUILDPLATFORM differs from
+ // the builder that produced the provenance.
+ if pinned, ok := uniquePinDigest(pins); ok {
+ return pinned, true, ""
+ }
+
+ // Platform is only a tie-breaker when the source name has multiple recorded
+ // digests. In that case it prevents silently selecting another platform's
+ // pin while retaining the fail-closed behavior for unknown platforms.
+ candidates := pins
+ if platform != nil {
+ candidates = nil
+ matcher := platforms.OnlyStrict(platforms.Normalize(*platform))
+ for _, pin := range pins {
+ if pin.platform == nil || matcher.Match(platforms.Normalize(*pin.platform)) {
+ candidates = append(candidates, pin)
+ }
+ }
+ if len(candidates) == 0 {
+ return "", true, fmt.Sprintf("replay pin: no recorded pin for %s on platform %s", source, platforms.Format(*platform))
+ }
+ }
+ if pinned, ok := uniquePinDigest(candidates); ok {
+ return pinned, true, ""
+ }
+ if observed != "" {
+ for _, pin := range candidates {
+ if pin.digest == observed {
+ return observed, true, ""
+ }
+ }
+ }
+ return "", true, fmt.Sprintf("replay pin: ambiguous provenance pins for %s", source)
+}
+
+func convertPinnedSource(req *policysession.CheckPolicyRequest, pinned digest.Digest) (*policysession.DecisionResponse, bool, error) {
+ if req == nil || req.Source == nil || req.Source.GetSource() == nil {
+ return nil, false, nil
+ }
+ src := req.Source.GetSource()
+ if canon := canonicalRequestSource(req); canon != "" && canon != src.Identifier {
+ clone := src.CloneVT()
+ clone.Identifier = canon
+ src = clone
+ }
+ switch {
+ case strings.HasPrefix(src.Identifier, "docker-image://"):
+ newSrc, err := policy.AddPinToImage(src, pinned)
+ if err != nil {
+ return nil, false, errors.Wrap(err, "failed to pin covered image source")
+ }
+ if newSrc.Identifier == src.Identifier {
+ return nil, true, nil
+ }
+ return convertResponse(newSrc), true, nil
+ case strings.HasPrefix(src.Identifier, "http://"), strings.HasPrefix(src.Identifier, "https://"):
+ return convertPinnedAttr(src, solverpb.AttrHTTPChecksum, pinned.String()), true, nil
+ case strings.HasPrefix(src.Identifier, "git://"):
+ return convertPinnedAttr(src, solverpb.AttrGitChecksum, pinned.Encoded()), true, nil
+ default:
+ return nil, false, nil
+ }
+}
+
+func convertPinnedAttr(src *solverpb.SourceOp, key, value string) *policysession.DecisionResponse {
+ if src.Attrs[key] == value {
+ return nil
+ }
+ newSrc := src.CloneVT()
+ if newSrc.Attrs == nil {
+ newSrc.Attrs = map[string]string{}
+ }
+ newSrc.Attrs[key] = value
+ return convertResponse(newSrc)
+}
+
+func convertResponse(src *solverpb.SourceOp) *policysession.DecisionResponse {
+ return &policysession.DecisionResponse{
+ Action: spb.PolicyAction_CONVERT,
+ Update: src,
+ }
+}
+
+// extractSourceIdentity pulls the URI and the digest (if known on this call)
+// from a CheckPolicyRequest. Supports the three source shapes BuildKit emits
+// on a session policy request: image, http, git.
+func extractSourceIdentity(req *policysession.CheckPolicyRequest) (uri string, dgst digest.Digest) {
+ if req == nil || req.Source == nil {
+ return "", ""
+ }
+ src := req.Source.GetSource()
+ if src != nil {
+ uri = src.GetIdentifier()
+ }
+
+ // Image response carries the resolved digest once BuildKit has asked the
+ // registry. First invocation (before resolution) comes without a digest —
+ // that is the expected path for the ALLOW-pending branch.
+ if img := req.Source.GetImage(); img != nil {
+ if v := img.GetDigest(); v != "" {
+ dgst = digest.Digest(v)
+ }
+ }
+ if h := req.Source.GetHTTP(); h != nil {
+ if v := h.GetChecksum(); v != "" {
+ dgst = digest.Digest(v)
+ }
+ }
+ if g := req.Source.GetGit(); g != nil {
+ if v := g.GetCommitChecksum(); v != "" && dgst == "" {
+ dgst = gitDigest(v)
+ }
+ }
+ if uri != "" {
+ uri, dgst = normalizeRequestSourceIdentity(uri, dgst)
+ }
+ return uri, dgst
+}
+
+func gitDigest(v string) digest.Digest {
+ if _, err := digest.Parse(v); err == nil {
+ return digest.Digest(v)
+ }
+ switch len(v) {
+ case 40:
+ return digest.NewDigestFromEncoded(digest.Algorithm("sha1"), v)
+ case 64:
+ return digest.NewDigestFromEncoded(digest.SHA256, v)
+ default:
+ return digest.Digest(v)
+ }
+}
+
+func displaySource(uri string, dgst digest.Digest) string {
+ switch {
+ case uri != "" && dgst != "":
+ return fmt.Sprintf("%s@%s", uri, dgst)
+ case uri != "":
+ return uri
+ case dgst != "":
+ return dgst.String()
+ default:
+ return ""
+ }
+}
+
+func canonicalMaterialIdentifier(m slsa1.ResourceDescriptor) (string, *ocispecs.Platform, bool) {
+ src, platform, err := policy.ParseSLSAMaterial(m)
+ if err != nil || src == nil || src.Identifier == "" {
+ return "", nil, false
+ }
+ return src.Identifier, platform, true
+}
+
+func canonicalIdentifierAliases(id string) []string {
+ refStr, ok := strings.CutPrefix(id, "docker-image://")
+ if !ok {
+ return nil
+ }
+ if refBase, _, ok := strings.Cut(refStr, "@"); ok {
+ return []string{"docker-image://" + refBase}
+ }
+ if _, err := reference.ParseNormalizedNamed(refStr); err != nil {
+ return nil
+ }
+ return nil
+}
+
+func normalizeRequestSourceIdentity(uri string, dgst digest.Digest) (string, digest.Digest) {
+ refStr, ok := strings.CutPrefix(uri, "docker-image://")
+ if !ok {
+ return uri, dgst
+ }
+ if _, refDigest, ok := strings.Cut(refStr, "@"); ok {
+ if dgst == "" {
+ if parsed, err := digest.Parse(refDigest); err == nil {
+ dgst = parsed
+ }
+ }
+ return uri, dgst
+ }
+ return uri, dgst
+}
+
+func notCoveredMessage(idx *PinIndex, req *policysession.CheckPolicyRequest, src string) string {
+ if idx == nil || len(idx.materials) == 0 {
+ return fmt.Sprintf("replay pin: no provenance material matched requested source\n target: %s", formatTargetSource(req, src))
+ }
+ var b strings.Builder
+ b.WriteString("replay pin: no provenance material matched requested source")
+ b.WriteString("\n target: ")
+ b.WriteString(formatTargetSource(req, src))
+ b.WriteString("\n provenance materials:")
+ for _, m := range idx.materials {
+ b.WriteString("\n - ")
+ b.WriteString(m)
+ }
+ return b.String()
+}
+
+func requestPlatform(req *policysession.CheckPolicyRequest) *ocispecs.Platform {
+ if req == nil || req.Platform == nil {
+ return nil
+ }
+ p := ocispecs.Platform{
+ OS: req.Platform.OS,
+ Architecture: req.Platform.Architecture,
+ Variant: req.Platform.Variant,
+ }
+ norm := platforms.Normalize(p)
+ return &norm
+}
+
+func formatPinMaterial(m slsa1.ResourceDescriptor) string {
+ if m.URI == "" {
+ return ""
+ }
+ src, p, err := policy.ParseSLSAMaterial(m)
+ switch {
+ case err != nil || src == nil || src.Identifier == "":
+ return m.URI
+ case p != nil:
+ return fmt.Sprintf("uri=%s canonical=%s platform=%s", m.URI, src.Identifier, platforms.Format(*p))
+ case src.Identifier != m.URI:
+ return fmt.Sprintf("uri=%s canonical=%s", m.URI, src.Identifier)
+ default:
+ return "uri=" + m.URI
+ }
+}
+
+func formatTargetSource(req *policysession.CheckPolicyRequest, src string) string {
+ parts := []string{"uri=" + src}
+ if canon := canonicalRequestSource(req); canon != "" && canon != src {
+ parts = append(parts, "canonical="+canon)
+ }
+ if p := requestPlatform(req); p != nil {
+ parts = append(parts, "platform="+platforms.Format(*p))
+ }
+ return strings.Join(parts, " ")
+}
+
+func canonicalRequestSource(req *policysession.CheckPolicyRequest) string {
+ if req == nil || req.Source == nil || req.Source.Source == nil {
+ return ""
+ }
+ uri := req.Source.Source.Identifier
+ if uri == "" {
+ return ""
+ }
+ if strings.HasPrefix(uri, "pkg:docker/") {
+ refStr, _, err := purl.PURLToRef(uri)
+ if err != nil {
+ return ""
+ }
+ named, err := reference.ParseNormalizedNamed(refStr)
+ if err != nil {
+ return ""
+ }
+ return "docker-image://" + named.String()
+ }
+ return uri
+}
+
+func allowResponse() *policysession.DecisionResponse {
+ return &policysession.DecisionResponse{Action: spb.PolicyAction_ALLOW}
+}
+
+func denyResponse(msg string) *policysession.DecisionResponse {
+ return &policysession.DecisionResponse{
+ Action: spb.PolicyAction_DENY,
+ DenyMessages: []*policysession.DenyMessage{{Message: msg}},
+ }
+}
diff --git a/replay/policy_test.go b/replay/policy_test.go
new file mode 100644
index 000000000000..5bd9a1738cdd
--- /dev/null
+++ b/replay/policy_test.go
@@ -0,0 +1,397 @@
+package replay
+
+import (
+ "context"
+ "strings"
+ "testing"
+
+ buildxpolicy "github.com/docker/buildx/policy"
+ slsacommon "github.com/in-toto/in-toto-golang/in_toto/slsa_provenance/common"
+ slsa1 "github.com/in-toto/in-toto-golang/in_toto/slsa_provenance/v1"
+ gwpb "github.com/moby/buildkit/frontend/gateway/pb"
+ provenancetypes "github.com/moby/buildkit/solver/llbsolver/provenance/types"
+ solverpb "github.com/moby/buildkit/solver/pb"
+ spb "github.com/moby/buildkit/sourcepolicy/pb"
+ "github.com/moby/buildkit/sourcepolicy/policysession"
+ "github.com/stretchr/testify/require"
+)
+
+func TestPreferredDigestDeterministicFallback(t *testing.T) {
+ set := map[string]string{
+ "sha512": strings.Repeat("5", 128),
+ "sha384": strings.Repeat("3", 96),
+ }
+ require.Equal(t, "sha384:"+strings.Repeat("3", 96), preferredDigest(set).String())
+
+ set["sha256"] = strings.Repeat("2", 64)
+ require.Equal(t, "sha256:"+strings.Repeat("2", 64), preferredDigest(set).String())
+}
+
+const (
+ imageURIAlpine = "pkg:docker/alpine@3.18?platform=linux%2Famd64"
+ imageSHA = "sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"
+ imageSHAOther = "sha256:bbbbbb0123456789abcdef0123456789abcdef0123456789abcdef0123456789"
+
+ httpURI = "https://example.com/payload.tar"
+ httpSHA = "sha256:1111111111111111111111111111111111111111111111111111111111111111"
+
+ gitURIHTTPS = "https://github.com/moby/buildkit.git#refs/tags/v0.29.0"
+ gitURIGit = "git://github.com/moby/buildkit.git#refs/tags/v0.29.0"
+ gitCommit = "sha1:8543ce4428265d547cb009e5ad62348284497a88"
+)
+
+func predicateWithMaterials(mats ...slsa1.ResourceDescriptor) *Predicate {
+ pred := &Predicate{}
+ pred.BuildDefinition = provenancetypes.ProvenanceBuildDefinitionSLSA1{}
+ pred.BuildDefinition.ResolvedDependencies = mats
+ return pred
+}
+
+func imageMaterial(uri, sha string) slsa1.ResourceDescriptor {
+ return slsa1.ResourceDescriptor{
+ URI: uri,
+ Digest: slsacommon.DigestSet{"sha256": stripSHA256(sha)},
+ }
+}
+
+func stripSHA256(s string) string {
+ if i := len("sha256:"); len(s) > i && s[:i] == "sha256:" {
+ return s[i:]
+ }
+ if i := len("sha1:"); len(s) > i && s[:i] == "sha1:" {
+ return s[i:]
+ }
+ return s
+}
+
+func imageCheckRequest(uri, observed string) *policysession.CheckPolicyRequest {
+ return &policysession.CheckPolicyRequest{
+ Source: &gwpb.ResolveSourceMetaResponse{
+ Source: &solverpb.SourceOp{Identifier: uri},
+ Image: &gwpb.ResolveSourceImageResponse{Digest: observed},
+ },
+ }
+}
+
+func httpCheckRequest(uri, observed string) *policysession.CheckPolicyRequest {
+ return &policysession.CheckPolicyRequest{
+ Source: &gwpb.ResolveSourceMetaResponse{
+ Source: &solverpb.SourceOp{Identifier: uri},
+ HTTP: &gwpb.ResolveSourceHTTPResponse{Checksum: observed},
+ },
+ }
+}
+
+func gitCheckRequest(uri, observed string) *policysession.CheckPolicyRequest {
+ return &policysession.CheckPolicyRequest{
+ Source: &gwpb.ResolveSourceMetaResponse{
+ Source: &solverpb.SourceOp{Identifier: uri},
+ Git: &gwpb.ResolveSourceGitResponse{CommitChecksum: observed},
+ },
+ }
+}
+
+func sourceCheckRequest(uri string, attrs map[string]string) *policysession.CheckPolicyRequest {
+ return &policysession.CheckPolicyRequest{
+ Source: &gwpb.ResolveSourceMetaResponse{
+ Source: &solverpb.SourceOp{Identifier: uri, Attrs: attrs},
+ },
+ }
+}
+
+func TestPinIndexURIAllowAndDeny(t *testing.T) {
+ pred := predicateWithMaterials(imageMaterial(imageURIAlpine, imageSHA))
+ idx := NewPinIndex(pred)
+ cb := ReplayPinCallback(idx)
+
+ // Covered image URI is converted to the pinned digest from provenance.
+ resp, _, err := cb(context.Background(), imageCheckRequest(imageURIAlpine, imageSHA))
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+ require.Equal(t, spb.PolicyAction_CONVERT, resp.Action)
+ require.NotNil(t, resp.Update)
+ require.Equal(t, "docker-image://docker.io/library/alpine:3.18@"+imageSHA, resp.Update.Identifier)
+
+ // Digest drift on a covered image still converts to the provenance pin.
+ wrongSHA := "sha256:0000000000000000000000000000000000000000000000000000000000000000"
+ resp, _, err = cb(context.Background(), imageCheckRequest(imageURIAlpine, wrongSHA))
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+ require.Equal(t, spb.PolicyAction_CONVERT, resp.Action)
+ require.NotNil(t, resp.Update)
+ require.Equal(t, "docker-image://docker.io/library/alpine:3.18@"+imageSHA, resp.Update.Identifier)
+}
+
+func TestPinIndexImagePURLCanonicalIdentifierAllowed(t *testing.T) {
+ mat := imageMaterial(imageURIAlpine, imageSHA)
+ src, _, err := buildxpolicy.ParseSLSAMaterial(mat)
+ require.NoError(t, err)
+ require.NotNil(t, src)
+
+ idx := NewPinIndex(predicateWithMaterials(mat))
+ cb := ReplayPinCallback(idx)
+
+ resp, _, err := cb(context.Background(), imageCheckRequest(src.Identifier, imageSHA))
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+ require.Equal(t, spb.PolicyAction_ALLOW, resp.Action)
+}
+
+func TestPinIndexImageCanonicalUnpinnedIdentifierAllowed(t *testing.T) {
+ mat := imageMaterial("pkg:docker/docker/dockerfile-upstream@master", "sha256:02bce6c486f5bbd7b2eb6b9a16e3734110face1c70a6bacd827dcdb80c3f9a24")
+ idx := NewPinIndex(predicateWithMaterials(mat))
+ cb := ReplayPinCallback(idx)
+
+ resp, _, err := cb(context.Background(), imageCheckRequest("docker-image://docker.io/docker/dockerfile-upstream:master", ""))
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+ require.Equal(t, spb.PolicyAction_CONVERT, resp.Action)
+ require.NotNil(t, resp.Update)
+ require.Equal(t, "docker-image://docker.io/docker/dockerfile-upstream:master@sha256:02bce6c486f5bbd7b2eb6b9a16e3734110face1c70a6bacd827dcdb80c3f9a24", resp.Update.Identifier)
+}
+
+func TestPinIndexImageCanonicalResolvedDigestMismatchConverted(t *testing.T) {
+ mat := imageMaterial("pkg:docker/docker/dockerfile-upstream@master", "sha256:02bce6c486f5bbd7b2eb6b9a16e3734110face1c70a6bacd827dcdb80c3f9a24")
+ idx := NewPinIndex(predicateWithMaterials(mat))
+ cb := ReplayPinCallback(idx)
+
+ resp, _, err := cb(context.Background(), imageCheckRequest("docker-image://docker.io/docker/dockerfile-upstream:master", "sha256:a7308cdb4411614c503aee073f5cb4caa5245b8e89fceb41887129219da0b267"))
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+ require.Equal(t, spb.PolicyAction_CONVERT, resp.Action)
+ require.NotNil(t, resp.Update)
+ require.Equal(t, "docker-image://docker.io/docker/dockerfile-upstream:master@sha256:02bce6c486f5bbd7b2eb6b9a16e3734110face1c70a6bacd827dcdb80c3f9a24", resp.Update.Identifier)
+}
+
+func TestPinIndexImageCanonicalIdentifierCarriesDigestInSource(t *testing.T) {
+ mat := imageMaterial("pkg:docker/docker/dockerfile-upstream@master", "sha256:02bce6c486f5bbd7b2eb6b9a16e3734110face1c70a6bacd827dcdb80c3f9a24")
+ idx := NewPinIndex(predicateWithMaterials(mat))
+ cb := ReplayPinCallback(idx)
+
+ resp, _, err := cb(context.Background(), imageCheckRequest("docker-image://docker.io/docker/dockerfile-upstream:master@sha256:a7308cdb4411614c503aee073f5cb4caa5245b8e89fceb41887129219da0b267", ""))
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+ require.Equal(t, spb.PolicyAction_CONVERT, resp.Action)
+ require.NotNil(t, resp.Update)
+ require.Equal(t, "docker-image://docker.io/docker/dockerfile-upstream:master@sha256:02bce6c486f5bbd7b2eb6b9a16e3734110face1c70a6bacd827dcdb80c3f9a24", resp.Update.Identifier)
+}
+
+func TestPinIndexDigestQualifiedImagesDoNotCollide(t *testing.T) {
+ matA := imageMaterial("pkg:docker/alpine?digest="+imageSHA+"&platform=linux%2Famd64", imageSHA)
+ matB := imageMaterial("pkg:docker/alpine?digest="+imageSHAOther+"&platform=linux%2Famd64", imageSHAOther)
+ cb := ReplayPinCallback(NewPinIndex(predicateWithMaterials(matA, matB)))
+
+ for _, pinned := range []string{imageSHA, imageSHAOther} {
+ resp, _, err := cb(context.Background(), imageCheckRequest("docker-image://docker.io/library/alpine@"+pinned, ""))
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+ require.Equal(t, spb.PolicyAction_ALLOW, resp.Action)
+ }
+
+ resp, _, err := cb(context.Background(), imageCheckRequest("docker-image://docker.io/library/alpine", ""))
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+ require.Equal(t, spb.PolicyAction_DENY, resp.Action)
+ require.Contains(t, resp.DenyMessages[0].Message, "ambiguous provenance pins")
+}
+
+func TestPinIndexUsesRequestPlatformToDisambiguateImage(t *testing.T) {
+ matAMD64 := imageMaterial("pkg:docker/alpine?digest="+imageSHA+"&platform=linux%2Famd64", imageSHA)
+ matARM64 := imageMaterial("pkg:docker/alpine?digest="+imageSHAOther+"&platform=linux%2Farm64", imageSHAOther)
+ cb := ReplayPinCallback(NewPinIndex(predicateWithMaterials(matAMD64, matARM64)))
+ req := imageCheckRequest("docker-image://docker.io/library/alpine", "")
+ req.Platform = &solverpb.Platform{OS: "linux", Architecture: "amd64"}
+
+ resp, _, err := cb(context.Background(), req)
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+ require.Equal(t, spb.PolicyAction_CONVERT, resp.Action)
+ require.Equal(t, "docker-image://docker.io/library/alpine@"+imageSHA, resp.Update.Identifier)
+}
+
+func TestPinIndexUniqueDigestIgnoresRequestPlatform(t *testing.T) {
+ mat := imageMaterial("pkg:docker/alpine?digest="+imageSHA+"&platform=linux%2Famd64", imageSHA)
+ cb := ReplayPinCallback(NewPinIndex(predicateWithMaterials(mat)))
+ req := imageCheckRequest("docker-image://docker.io/library/alpine", "")
+ req.Platform = &solverpb.Platform{OS: "linux", Architecture: "arm64"}
+
+ resp, _, err := cb(context.Background(), req)
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+ require.Equal(t, spb.PolicyAction_CONVERT, resp.Action)
+ require.Equal(t, "docker-image://docker.io/library/alpine@"+imageSHA, resp.Update.Identifier)
+}
+
+func TestPinIndexAmbiguousDigestsRejectUnknownRequestPlatform(t *testing.T) {
+ matAMD64 := imageMaterial("pkg:docker/alpine?digest="+imageSHA+"&platform=linux%2Famd64", imageSHA)
+ matARM64 := imageMaterial("pkg:docker/alpine?digest="+imageSHAOther+"&platform=linux%2Farm64", imageSHAOther)
+ cb := ReplayPinCallback(NewPinIndex(predicateWithMaterials(matAMD64, matARM64)))
+ req := imageCheckRequest("docker-image://docker.io/library/alpine", "")
+ req.Platform = &solverpb.Platform{OS: "linux", Architecture: "s390x"}
+
+ resp, _, err := cb(context.Background(), req)
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+ require.Equal(t, spb.PolicyAction_DENY, resp.Action)
+ require.Contains(t, resp.DenyMessages[0].Message, "no recorded pin")
+ require.Contains(t, resp.DenyMessages[0].Message, "linux/s390x")
+}
+
+func TestPinIndexUnknownSourceDenied(t *testing.T) {
+ idx := NewPinIndex(predicateWithMaterials(imageMaterial(imageURIAlpine, imageSHA)))
+ cb := ReplayPinCallback(idx)
+
+ // Unknown URI, unknown digest → DENY (fail-closed).
+ other := "pkg:docker/ubuntu@22.04?platform=linux%2Famd64"
+ resp, _, err := cb(context.Background(), imageCheckRequest(other, "sha256:deadbeef"))
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+ require.Equal(t, spb.PolicyAction_DENY, resp.Action)
+ require.NotEmpty(t, resp.DenyMessages)
+ require.Contains(t, resp.DenyMessages[0].Message, "no provenance material matched requested source")
+ require.Contains(t, resp.DenyMessages[0].Message, "\n target: uri="+other+"@sha256:deadbeef")
+ require.Contains(t, resp.DenyMessages[0].Message, "\n provenance materials:")
+ require.Contains(t, resp.DenyMessages[0].Message, "\n - uri="+imageURIAlpine)
+ require.Contains(t, resp.DenyMessages[0].Message, "canonical=docker-image://docker.io/library/alpine:3.18@"+imageSHA)
+ require.Contains(t, resp.DenyMessages[0].Message, "platform=linux/amd64")
+}
+
+func TestPinIndexHTTPConvertedToRecordedChecksum(t *testing.T) {
+ pred := predicateWithMaterials(slsa1.ResourceDescriptor{
+ URI: httpURI,
+ Digest: slsacommon.DigestSet{"sha256": stripSHA256(httpSHA)},
+ })
+ idx := NewPinIndex(pred)
+ cb := ReplayPinCallback(idx)
+
+ resp, _, err := cb(context.Background(), sourceCheckRequest(httpURI, nil))
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+ require.Equal(t, spb.PolicyAction_CONVERT, resp.Action)
+ require.NotNil(t, resp.Update)
+ require.Equal(t, httpSHA, resp.Update.Attrs[solverpb.AttrHTTPChecksum])
+
+ resp, _, err = cb(context.Background(), sourceCheckRequest(httpURI, resp.Update.Attrs))
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+ require.Equal(t, spb.PolicyAction_ALLOW, resp.Action)
+}
+
+func TestPinIndexUnrecordedURIWithRecordedDigestDenied(t *testing.T) {
+ pred := predicateWithMaterials(slsa1.ResourceDescriptor{
+ URI: httpURI,
+ Digest: slsacommon.DigestSet{"sha256": stripSHA256(httpSHA)},
+ })
+ cb := ReplayPinCallback(NewPinIndex(pred))
+
+ resp, _, err := cb(context.Background(), httpCheckRequest("https://unrecorded.example.com/file.tar", httpSHA))
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+ require.Equal(t, spb.PolicyAction_DENY, resp.Action)
+
+ // A request without a source identifier is denied.
+ resp, _, err = cb(context.Background(), httpCheckRequest("", httpSHA))
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+ require.Equal(t, spb.PolicyAction_DENY, resp.Action)
+}
+
+func TestPinIndexIncludesRecordedConfigSource(t *testing.T) {
+ pred := predicateWithMaterials()
+ pred.BuildDefinition.ExternalParameters.ConfigSource.URI = httpURI
+ pred.BuildDefinition.ExternalParameters.ConfigSource.Digest = slsacommon.DigestSet{
+ "sha256": stripSHA256(httpSHA),
+ }
+ cb := ReplayPinCallback(NewPinIndex(pred))
+
+ resp, _, err := cb(context.Background(), sourceCheckRequest(httpURI, nil))
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+ require.Equal(t, spb.PolicyAction_CONVERT, resp.Action)
+ require.Equal(t, httpSHA, resp.Update.Attrs[solverpb.AttrHTTPChecksum])
+}
+
+func TestPinIndexGitSchemeNormalizationConvertedToRecordedCommit(t *testing.T) {
+ pred := predicateWithMaterials(slsa1.ResourceDescriptor{
+ URI: gitURIHTTPS,
+ Digest: slsacommon.DigestSet{"sha1": stripSHA256(gitCommit)},
+ })
+ idx := NewPinIndex(pred)
+ cb := ReplayPinCallback(idx)
+
+ resp, _, err := cb(context.Background(), sourceCheckRequest(gitURIGit, nil))
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+ require.Equal(t, spb.PolicyAction_CONVERT, resp.Action)
+ require.NotNil(t, resp.Update)
+ require.Equal(t, stripSHA256(gitCommit), resp.Update.Attrs[solverpb.AttrGitChecksum])
+
+ resp, _, err = cb(context.Background(), sourceCheckRequest(gitURIGit, resp.Update.Attrs))
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+ require.Equal(t, spb.PolicyAction_ALLOW, resp.Action)
+}
+
+func TestPinIndexHTTPResolvedDigestMismatchDenied(t *testing.T) {
+ pred := predicateWithMaterials(slsa1.ResourceDescriptor{
+ URI: httpURI,
+ Digest: slsacommon.DigestSet{"sha256": stripSHA256(httpSHA)},
+ })
+ cb := ReplayPinCallback(NewPinIndex(pred))
+ req := httpCheckRequest(httpURI, "sha256:2222222222222222222222222222222222222222222222222222222222222222")
+ req.Source.Source.Attrs = map[string]string{solverpb.AttrHTTPChecksum: httpSHA}
+
+ resp, _, err := cb(context.Background(), req)
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+ require.Equal(t, spb.PolicyAction_DENY, resp.Action)
+ require.Contains(t, resp.DenyMessages[0].Message, "replay pin mismatch")
+}
+
+func TestPinIndexGitResolvedCommitMismatchDenied(t *testing.T) {
+ pred := predicateWithMaterials(slsa1.ResourceDescriptor{
+ URI: gitURIHTTPS,
+ Digest: slsacommon.DigestSet{"sha1": stripSHA256(gitCommit)},
+ })
+ cb := ReplayPinCallback(NewPinIndex(pred))
+ req := gitCheckRequest(gitURIGit, "1111111111111111111111111111111111111111")
+ req.Source.Source.Attrs = map[string]string{solverpb.AttrGitChecksum: stripSHA256(gitCommit)}
+
+ resp, _, err := cb(context.Background(), req)
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+ require.Equal(t, spb.PolicyAction_DENY, resp.Action)
+ require.Contains(t, resp.DenyMessages[0].Message, "replay pin mismatch")
+}
+
+func TestPinIndexAllowPending(t *testing.T) {
+ // First callback invocation on an image source often arrives without
+ // an observed digest (the source-meta roundtrip produces it). The
+ // replay callback must not fail-closed on that pending shape when the
+ // URI is covered.
+ idx := NewPinIndex(predicateWithMaterials(imageMaterial(imageURIAlpine, imageSHA)))
+ cb := ReplayPinCallback(idx)
+
+ resp, _, err := cb(context.Background(), imageCheckRequest(imageURIAlpine, ""))
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+ require.Equal(t, spb.PolicyAction_CONVERT, resp.Action)
+ require.NotNil(t, resp.Update)
+ require.Equal(t, "docker-image://docker.io/library/alpine:3.18@"+imageSHA, resp.Update.Identifier)
+}
+
+func TestMultiPolicyCallbackReplayStrict(t *testing.T) {
+ // A permissive user-defined overlay must not defeat the strict replay
+ // callback. Compose a pass-through first and the replay pin last.
+ passthrough := policysession.PolicyCallback(func(ctx context.Context, req *policysession.CheckPolicyRequest) (*policysession.DecisionResponse, *gwpb.ResolveSourceMetaRequest, error) {
+ return &policysession.DecisionResponse{Action: spb.PolicyAction_ALLOW}, nil, nil
+ })
+ idx := NewPinIndex(predicateWithMaterials(imageMaterial(imageURIAlpine, imageSHA)))
+ combined := buildxpolicy.MultiPolicyCallback(passthrough, ReplayPinCallback(idx))
+
+ unknown := "pkg:docker/ubuntu@22.04?platform=linux%2Famd64"
+ resp, _, err := combined(context.Background(), imageCheckRequest(unknown, "sha256:deadbeef"))
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+ require.Equal(t, spb.PolicyAction_DENY, resp.Action)
+}
diff --git a/replay/predicate.go b/replay/predicate.go
new file mode 100644
index 000000000000..777cf2d581f2
--- /dev/null
+++ b/replay/predicate.go
@@ -0,0 +1,172 @@
+package replay
+
+import (
+ "strings"
+
+ "github.com/containerd/platforms"
+ "github.com/docker/buildx/policy"
+ slsa1 "github.com/in-toto/in-toto-golang/in_toto/slsa_provenance/v1"
+ provenancetypes "github.com/moby/buildkit/solver/llbsolver/provenance/types"
+ ocispecs "github.com/opencontainers/image-spec/specs-go/v1"
+)
+
+// Predicate is a named type over ProvenancePredicateSLSA1 so replay code can
+// attach accessors without copying or wrapping. The receiver is never nil:
+// callers must have obtained a non-nil *Predicate from Subject.Predicate.
+type Predicate provenancetypes.ProvenancePredicateSLSA1
+
+// defaultFrontend matches the frontend buildx uses when none is recorded on
+// the request.
+const defaultFrontend = "dockerfile.v0"
+
+// Frontend returns the frontend id recorded on the predicate, falling back
+// to dockerfile.v0 when the predicate does not record one.
+func (p *Predicate) Frontend() string {
+ if f := p.BuildDefinition.ExternalParameters.Request.Frontend; f != "" {
+ return f
+ }
+ return defaultFrontend
+}
+
+// FrontendAttrs returns the recorded frontend attrs with attestation-related
+// keys stripped. Returns a fresh map so callers can mutate it.
+func (p *Predicate) FrontendAttrs() map[string]string {
+ src := p.BuildDefinition.ExternalParameters.Request.Args
+ out := make(map[string]string, len(src))
+ for k, v := range src {
+ if strings.HasPrefix(k, "attest:") {
+ continue
+ }
+ out[k] = v
+ }
+ return out
+}
+
+// ConfigSource returns the configSource descriptor recorded on the predicate.
+func (p *Predicate) ConfigSource() provenancetypes.ProvenanceConfigSourceSLSA1 {
+ return p.BuildDefinition.ExternalParameters.ConfigSource
+}
+
+// Secrets returns the declared secrets from the predicate's request.
+func (p *Predicate) Secrets() []*provenancetypes.Secret {
+ return p.BuildDefinition.ExternalParameters.Request.Secrets
+}
+
+// SSH returns the declared SSH entries from the predicate's request.
+func (p *Predicate) SSH() []*provenancetypes.SSH {
+ return p.BuildDefinition.ExternalParameters.Request.SSH
+}
+
+// Locals returns the local-context sources recorded on the predicate. A
+// non-empty result should cause replay to fail with
+// UnreplayableLocalContextError.
+func (p *Predicate) Locals() []*provenancetypes.LocalSource {
+ return p.BuildDefinition.ExternalParameters.Request.Locals
+}
+
+// BuilderPlatform returns the platform the original builder ran on, parsed
+// from InternalParameters.builderPlatform. Falls back to the runtime host
+// platform when the field is missing or malformed.
+func (p *Predicate) BuilderPlatform() ocispecs.Platform {
+ if plat, ok := p.RecordedBuilderPlatform(); ok {
+ return *plat
+ }
+ return platforms.DefaultSpec()
+}
+
+// RecordedBuilderPlatform returns the platform recorded in
+// InternalParameters.builderPlatform when present and valid.
+func (p *Predicate) RecordedBuilderPlatform() (*ocispecs.Platform, bool) {
+ s := p.BuildDefinition.InternalParameters.BuilderPlatform
+ if s == "" {
+ return nil, false
+ }
+ plat, err := platforms.Parse(s)
+ if err != nil {
+ return nil, false
+ }
+ norm := platforms.Normalize(plat)
+ return &norm, true
+}
+
+// DefaultPlatform returns the effective provenance default platform for
+// resolving host-side image sources during replay. It prefers the recorded
+// platform-qualified image materials when they all agree, and otherwise
+// falls back to the recorded builderPlatform field.
+func (p *Predicate) DefaultPlatform() (*ocispecs.Platform, bool) {
+ var inferred *ocispecs.Platform
+ for _, m := range p.ResolvedDependencies() {
+ _, mp, err := policy.ParseSLSAMaterial(m)
+ if err != nil || mp == nil {
+ continue
+ }
+ norm := platforms.Normalize(*mp)
+ if inferred == nil {
+ inferred = &norm
+ continue
+ }
+ if platforms.Format(*inferred) != platforms.Format(norm) {
+ return nil, false
+ }
+ }
+ if inferred != nil {
+ return inferred, true
+ }
+ if plat, ok := p.RecordedBuilderPlatform(); ok {
+ return plat, true
+ }
+ return nil, false
+}
+
+// FallbackTargetPlatform infers the target platform from the build's LLB when
+// the provenance subject has no platform metadata. BuildKit injects
+// TARGETPLATFORM into Dockerfile exec environments. A single provenance
+// statement must agree on that value; mixed or malformed values are not safe
+// to use as an implicit replay target.
+//
+// This is a compatibility fallback, not an authoritative provenance field.
+// TODO: Prefer an explicit target platform once BuildKit records one in the
+// provenance build parameters or subject metadata.
+func (p *Predicate) FallbackTargetPlatform() (*ocispecs.Platform, bool) {
+ buildConfig := p.BuildDefinition.InternalParameters.BuildConfig
+ if buildConfig == nil {
+ return nil, false
+ }
+ var target *ocispecs.Platform
+ for _, step := range buildConfig.Definition {
+ if step.Op == nil || step.Op.GetExec() == nil || step.Op.GetExec().Meta == nil {
+ continue
+ }
+ for _, env := range step.Op.GetExec().Meta.Env {
+ value, ok := strings.CutPrefix(env, "TARGETPLATFORM=")
+ if !ok || value == "" {
+ continue
+ }
+ platform, err := platforms.Parse(value)
+ if err != nil {
+ return nil, false
+ }
+ norm := platforms.Normalize(platform)
+ if target != nil && platforms.Format(*target) != platforms.Format(norm) {
+ return nil, false
+ }
+ target = &norm
+ }
+ }
+ return target, target != nil
+}
+
+// ResolvedDependencies returns every material recorded on the predicate.
+// Classification by URI scheme is left to the caller (see MaterialsResolver).
+func (p *Predicate) ResolvedDependencies() []slsa1.ResourceDescriptor {
+ return p.BuildDefinition.ResolvedDependencies
+}
+
+// IsMinMode reports whether the provenance was recorded with mode=min. Only
+// mode=max records the build definition. mode=min also drops build arguments,
+// labels, secrets and SSH from the recorded request, so the original build
+// cannot be reconstructed from it.
+func (p *Predicate) IsMinMode() bool {
+ bc := p.BuildDefinition.InternalParameters.BuildConfig
+ return bc == nil || len(bc.Definition) == 0
+}
diff --git a/replay/predicate_test.go b/replay/predicate_test.go
new file mode 100644
index 000000000000..ef361c3df2fb
--- /dev/null
+++ b/replay/predicate_test.go
@@ -0,0 +1,152 @@
+package replay
+
+import (
+ "testing"
+
+ "github.com/containerd/platforms"
+ slsa "github.com/in-toto/in-toto-golang/in_toto/slsa_provenance/common"
+ slsa1 "github.com/in-toto/in-toto-golang/in_toto/slsa_provenance/v1"
+ provenancetypes "github.com/moby/buildkit/solver/llbsolver/provenance/types"
+ solverpb "github.com/moby/buildkit/solver/pb"
+ "github.com/stretchr/testify/require"
+)
+
+func TestPredicateMethods(t *testing.T) {
+ raw := provenancetypes.ProvenancePredicateSLSA1{
+ BuildDefinition: provenancetypes.ProvenanceBuildDefinitionSLSA1{
+ ExternalParameters: provenancetypes.ProvenanceExternalParametersSLSA1{
+ ConfigSource: provenancetypes.ProvenanceConfigSourceSLSA1{
+ URI: "https://example.com/build",
+ Digest: slsa.DigestSet{"sha256": "deadbeef"},
+ Path: "Dockerfile",
+ },
+ Request: provenancetypes.Parameters{
+ Frontend: "gateway.v0",
+ Args: map[string]string{
+ "target": "release",
+ "build-arg:FOO": "bar",
+ "label:maintainer": "me",
+ "attest:sbom": "generator=scanner",
+ "attest:provenance": "mode=max",
+ },
+ Secrets: []*provenancetypes.Secret{{ID: "github_token"}, {ID: "npm_token", Optional: true}},
+ SSH: []*provenancetypes.SSH{{ID: "default"}},
+ Locals: []*provenancetypes.LocalSource{{Name: "context"}},
+ },
+ },
+ InternalParameters: provenancetypes.ProvenanceInternalParametersSLSA1{
+ BuildConfig: &provenancetypes.BuildConfig{
+ Definition: []provenancetypes.BuildStep{{ID: "step-0"}},
+ },
+ },
+ ProvenanceBuildDefinition: slsa1.ProvenanceBuildDefinition{
+ ResolvedDependencies: []slsa1.ResourceDescriptor{
+ {URI: "docker-image://alpine:latest", Digest: slsa.DigestSet{"sha256": "aaaa"}},
+ {URI: "https://example.com/pkg.tar.gz", Digest: slsa.DigestSet{"sha256": "bbbb"}},
+ },
+ },
+ },
+ }
+ pred := (*Predicate)(&raw)
+
+ t.Run("Frontend returns recorded frontend", func(t *testing.T) {
+ require.Equal(t, "gateway.v0", pred.Frontend())
+ })
+
+ t.Run("Frontend falls back to dockerfile.v0", func(t *testing.T) {
+ empty := &Predicate{}
+ require.Equal(t, defaultFrontend, empty.Frontend())
+ })
+
+ t.Run("FrontendAttrs strips attestation attrs", func(t *testing.T) {
+ attrs := pred.FrontendAttrs()
+ require.Equal(t, "release", attrs["target"])
+ require.Equal(t, "bar", attrs["build-arg:FOO"])
+ require.Equal(t, "me", attrs["label:maintainer"])
+ _, hasSBOM := attrs["attest:sbom"]
+ require.False(t, hasSBOM, "attest:sbom should be filtered out")
+ _, hasProv := attrs["attest:provenance"]
+ require.False(t, hasProv, "attest:provenance should be filtered out")
+ })
+
+ t.Run("FrontendAttrs returns fresh map", func(t *testing.T) {
+ attrs := pred.FrontendAttrs()
+ attrs["injected"] = "yes"
+ // The predicate's underlying map should be untouched.
+ require.NotContains(t, pred.BuildDefinition.ExternalParameters.Request.Args, "injected")
+ })
+
+ t.Run("ConfigSource", func(t *testing.T) {
+ cs := pred.ConfigSource()
+ require.Equal(t, "https://example.com/build", cs.URI)
+ require.Equal(t, "Dockerfile", cs.Path)
+ })
+
+ t.Run("Secrets", func(t *testing.T) {
+ secrets := pred.Secrets()
+ require.Len(t, secrets, 2)
+ require.Equal(t, "github_token", secrets[0].ID)
+ require.False(t, secrets[0].Optional)
+ require.True(t, secrets[1].Optional)
+ })
+
+ t.Run("SSH", func(t *testing.T) {
+ ssh := pred.SSH()
+ require.Len(t, ssh, 1)
+ require.Equal(t, "default", ssh[0].ID)
+ })
+
+ t.Run("Locals", func(t *testing.T) {
+ locals := pred.Locals()
+ require.Len(t, locals, 1)
+ require.Equal(t, "context", locals[0].Name)
+ })
+
+ t.Run("ResolvedDependencies", func(t *testing.T) {
+ deps := pred.ResolvedDependencies()
+ require.Len(t, deps, 2)
+ require.Equal(t, "docker-image://alpine:latest", deps[0].URI)
+ })
+
+ t.Run("IsMinMode false with build definition", func(t *testing.T) {
+ require.False(t, pred.IsMinMode())
+ })
+
+ t.Run("IsMinMode true when empty", func(t *testing.T) {
+ empty := &Predicate{}
+ require.True(t, empty.IsMinMode())
+ })
+
+ t.Run("IsMinMode true when BuildConfig has no steps", func(t *testing.T) {
+ p := &Predicate{}
+ p.BuildDefinition.InternalParameters.BuildConfig = &provenancetypes.BuildConfig{}
+ require.True(t, p.IsMinMode())
+ })
+}
+
+func TestPredicateFallbackTargetPlatform(t *testing.T) {
+ execStep := func(env ...string) provenancetypes.BuildStep {
+ return provenancetypes.BuildStep{
+ Op: &solverpb.Op{Op: &solverpb.Op_Exec{Exec: &solverpb.ExecOp{Meta: &solverpb.Meta{Env: env}}}},
+ }
+ }
+
+ pred := &Predicate{}
+ pred.BuildDefinition.InternalParameters.BuilderPlatform = "linux/amd64"
+ pred.BuildDefinition.InternalParameters.BuildConfig = &provenancetypes.BuildConfig{
+ Definition: []provenancetypes.BuildStep{
+ execStep("BUILDPLATFORM=linux/amd64", "TARGETPLATFORM=linux/arm64"),
+ execStep("TARGETPLATFORM=linux/arm64"),
+ },
+ }
+ platform, ok := pred.FallbackTargetPlatform()
+ require.True(t, ok)
+ require.Equal(t, "linux/arm64", platforms.Format(*platform))
+
+ pred.BuildDefinition.InternalParameters.BuildConfig.Definition = append(
+ pred.BuildDefinition.InternalParameters.BuildConfig.Definition,
+ execStep("TARGETPLATFORM=linux/amd64"),
+ )
+ _, ok = pred.FallbackTargetPlatform()
+ require.False(t, ok, "conflicting target platforms must not be guessed")
+}
diff --git a/replay/snapshot.go b/replay/snapshot.go
new file mode 100644
index 000000000000..58c40be62704
--- /dev/null
+++ b/replay/snapshot.go
@@ -0,0 +1,989 @@
+package replay
+
+import (
+ "archive/tar"
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "io/fs"
+ "maps"
+ "net/url"
+ "os"
+ "path/filepath"
+ "slices"
+ "strings"
+ "sync"
+
+ "github.com/containerd/containerd/v2/core/content"
+ "github.com/containerd/containerd/v2/core/images"
+ "github.com/containerd/containerd/v2/core/remotes"
+ contentlocal "github.com/containerd/containerd/v2/plugins/content/local"
+ "github.com/containerd/errdefs"
+ "github.com/containerd/platforms"
+ "github.com/distribution/reference"
+ "github.com/docker/buildx/builder"
+ "github.com/docker/buildx/util/buildflags"
+ "github.com/docker/buildx/util/imagetools"
+ "github.com/docker/buildx/util/progress"
+ "github.com/docker/cli/cli/command"
+ slsa1 "github.com/in-toto/in-toto-golang/in_toto/slsa_provenance/v1"
+ "github.com/moby/buildkit/client"
+ "github.com/moby/buildkit/client/ociindex"
+ "github.com/moby/buildkit/util/contentutil"
+ "github.com/moby/buildkit/util/purl"
+ digest "github.com/opencontainers/go-digest"
+ ocispecs "github.com/opencontainers/image-spec/specs-go/v1"
+ "github.com/pkg/errors"
+)
+
+// withMediaTypeKeyPrefix registers ref-key prefixes for non-standard media
+// types that the snapshot chain walker will encounter. Without this,
+// containerd's remotes.MakeRefKey falls to its default branch and logs
+// "reference for unknown type: …" when copying the OCI 1.1 empty-config
+// blob that sits inside buildx attestation manifests.
+func withMediaTypeKeyPrefix(ctx context.Context) context.Context {
+ return remotes.WithMediaTypeKeyPrefix(ctx, "application/vnd.oci.empty.v1+json", "empty")
+}
+
+// SnapshotRequest is the input to Snapshot.
+type SnapshotRequest struct {
+ // Targets are the per-platform (subject, predicate) pairs to snapshot.
+ // Each subject must carry a non-empty AttestationManifest descriptor
+ // (image / oci-layout subjects only; attestation-file inputs are
+ // rejected upstream).
+ Targets []Target
+ // IncludeMaterials controls whether material content is copied and the
+ // materials artifact manifest is emitted.
+ IncludeMaterials bool
+ // Materials resolves image / http / container-blob materials to a local
+ // (descriptor, provider) pair. Required when IncludeMaterials is true.
+ Materials *MaterialsResolver
+ // Output is the parsed --output spec. Exactly one form is allowed
+ // (local / oci / registry).
+ Output *buildflags.ExportEntry
+ // Progress receives step events and non-fatal warnings. May be nil —
+ // in that case events are silently dropped.
+ Progress progress.Writer
+}
+
+// Snapshot produces a replay snapshot for the supplied subjects/predicates and
+// writes it through the configured --output target. This function does NOT
+// invoke build.Build — snapshot is pure content movement plus manifest
+// assembly.
+//
+// dockerCli + builderName are consumed only to construct a buildx image
+// resolver so that image materials can be fetched from their recorded
+// registries. They may be zero when all materials are resolvable purely via
+// req.Materials (e.g. tests that pre-pin an --materials=oci-layout store).
+func Snapshot(ctx context.Context, dockerCli command.Cli, builderName string, req *SnapshotRequest) error {
+ if req == nil {
+ return errors.New("nil snapshot request")
+ }
+ if req.Output == nil {
+ return errors.New("snapshot: --output is required")
+ }
+ stage, root, _, err := assembleSnapshot(ctx, dockerCli, builderName, req)
+ if err != nil {
+ return err
+ }
+ return writeSnapshotOutput(ctx, stage, root, req.Output)
+}
+
+// assembleSnapshot runs the staging phase shared by real-run and dry-run:
+// validates targets, stages every blob the snapshot would emit, and
+// returns the root descriptor plus per-target staging stores (for dry-run
+// consumers that want per-target artifact lists). The real run only cares
+// about the first store and root.
+func assembleSnapshot(ctx context.Context, dockerCli command.Cli, builderName string, req *SnapshotRequest) (*stagingStore, ocispecs.Descriptor, []*stagingStore, error) {
+ if req == nil {
+ return nil, ocispecs.Descriptor{}, nil, errors.New("nil snapshot request")
+ }
+ if len(req.Targets) == 0 {
+ return nil, ocispecs.Descriptor{}, nil, errors.New("no targets to snapshot")
+ }
+
+ // Register ref-key prefixes for non-standard media types so
+ // containerd does not log spurious "reference for unknown type"
+ // warnings while copying the attestation chain.
+ ctx = withMediaTypeKeyPrefix(ctx)
+
+ // progress logger — nop when the caller did not supply a Writer.
+ var pwlog progress.Logger = func(*client.SolveStatus) {}
+ if req.Progress != nil {
+ pwlog = req.Progress.Write
+ }
+
+ // Shared warn-once ledger: each (category, key) pair prints at most
+ // one warning across the whole snapshot (e.g. the same git URI
+ // referenced from every platform should not flood output).
+ warn := newWarnOnce()
+
+ // One staging store per target — gives dry-run a clean per-target
+ // descriptor list and keeps the real-run assembly deterministic.
+ stages := make([]*stagingStore, 0, len(req.Targets))
+ // Merged stage for real-run output. Each target's stage is folded in
+ // after assembly so a single flush writes everything.
+ merged := newStagingStore()
+
+ // Lazily-constructed registry resolver for image materials (reused across
+ // subjects). buildx resolver is nil-safe so we keep the builder/auth setup
+ // deferred until an image material is seen.
+ var registryResolver *imagetools.Resolver
+ lazyResolver := func() (*imagetools.Resolver, error) {
+ if registryResolver != nil {
+ return registryResolver, nil
+ }
+ if dockerCli == nil {
+ registryResolver = imagetools.New(imagetools.Opt{})
+ return registryResolver, nil
+ }
+ b, err := builder.New(dockerCli, builder.WithName(builderName))
+ if err != nil {
+ return nil, err
+ }
+ imgOpt, err := b.ImageOpt()
+ if err != nil {
+ return nil, err
+ }
+ registryResolver = imagetools.New(imgOpt)
+ return registryResolver, nil
+ }
+
+ emptyConfigDesc := ocispecs.Descriptor{
+ MediaType: ociEmptyConfigMediaType,
+ Digest: digest.Digest(ociEmptyConfigDigest),
+ Size: ociEmptyConfigSize,
+ }
+
+ perPlatformDescs := make([]ocispecs.Descriptor, 0, len(req.Targets))
+
+ for ti, t := range req.Targets {
+ s, pred := t.Subject, t.Predicate
+ if s == nil || pred == nil {
+ return nil, ocispecs.Descriptor{}, nil, errors.New("target has nil subject or predicate")
+ }
+ if s.IsAttestationFile() {
+ return nil, ocispecs.Descriptor{}, nil, ErrUnsupportedSubject("snapshot requires an image or oci-layout subject")
+ }
+ if s.AttestationManifest().Digest == "" {
+ return nil, ocispecs.Descriptor{}, nil, ErrNoProvenance(s.InputRef())
+ }
+
+ stage := newStagingStore()
+ if err := stage.writeRaw(ctx, emptyConfigDesc, OCIEmptyConfigBytes()); err != nil {
+ return nil, ocispecs.Descriptor{}, nil, errors.Wrap(err, "write empty config")
+ }
+
+ var ppDesc ocispecs.Descriptor
+ targetName := fmt.Sprintf("[%d/%d] snapshot %s", ti+1, len(req.Targets), snapshotTargetLabel(s))
+ err := progress.Wrap(targetName, pwlog, func(sub progress.SubLogger) error {
+ d, err := snapshotOneTarget(ctx, stage, s, pred, req, lazyResolver, warn, sub)
+ if err != nil {
+ return err
+ }
+ ppDesc = d
+ return nil
+ })
+ if err != nil {
+ return nil, ocispecs.Descriptor{}, nil, err
+ }
+ if s.Descriptor.Platform != nil {
+ p := *s.Descriptor.Platform
+ ppDesc.Platform = &p
+ }
+ perPlatformDescs = append(perPlatformDescs, ppDesc)
+ stages = append(stages, stage)
+
+ if err := mergeStage(ctx, merged, stage); err != nil {
+ return nil, ocispecs.Descriptor{}, nil, err
+ }
+ }
+
+ // Root descriptor that the output writer addresses.
+ var root ocispecs.Descriptor
+ if len(perPlatformDescs) == 1 {
+ root = perPlatformDescs[0]
+ } else {
+ _, rootDesc, rootData, err := MultiPlatformSnapshotIndex(perPlatformDescs)
+ if err != nil {
+ return nil, ocispecs.Descriptor{}, nil, err
+ }
+ if err := merged.writeRaw(ctx, ocispecs.Descriptor{
+ MediaType: ocispecs.MediaTypeImageIndex,
+ Digest: rootDesc.Digest,
+ Size: rootDesc.Size,
+ }, rootData); err != nil {
+ return nil, ocispecs.Descriptor{}, nil, errors.Wrap(err, "write multi-platform snapshot index")
+ }
+ root = rootDesc
+ }
+
+ return merged, root, stages, nil
+}
+
+// mergeStage copies every blob from src into dst. Used to fold per-target
+// stages into the merged stage that the real-run output writer flushes.
+func mergeStage(ctx context.Context, dst, src *stagingStore) error {
+ src.mu.Lock()
+ order := slices.Clone(src.order)
+ descs := make(map[digest.Digest]ocispecs.Descriptor, len(src.descs))
+ maps.Copy(descs, src.descs)
+ src.mu.Unlock()
+ for _, dgst := range order {
+ desc := descs[dgst]
+ ra, err := src.ReaderAt(ctx, desc)
+ if err != nil {
+ return errors.Wrapf(err, "read %s", dgst)
+ }
+ err = content.WriteBlob(ctx, dst, "snapshot-"+dgst.String(), content.NewReader(ra), desc)
+ ra.Close()
+ if err != nil && !errdefs.IsAlreadyExists(err) {
+ return errors.Wrapf(err, "merge %s", dgst)
+ }
+ dst.record(desc)
+ }
+ return nil
+}
+
+// snapshotOneTarget copies the attestation chain and each material for a
+// single (subject, predicate) target into the staging buffer and assembles
+// the per-platform snapshot index. Each unit of work is wrapped in a
+// progress sub-step so the caller's printer can render what is happening.
+func snapshotOneTarget(
+ ctx context.Context,
+ stage *stagingStore,
+ s *Subject,
+ pred *Predicate,
+ req *SnapshotRequest,
+ lazyResolver func() (*imagetools.Resolver, error),
+ warn *warnOnce,
+ sub progress.SubLogger,
+) (ocispecs.Descriptor, error) {
+ attestMfst := s.AttestationManifest()
+ if err := sub.Wrap(fmt.Sprintf("copy attestation manifest %s", attestMfst.Digest), func() error {
+ return contentutil.CopyChain(ctx, stage, s.Provider, attestMfst)
+ }); err != nil {
+ return ocispecs.Descriptor{}, errors.Wrapf(err, "copy attestation manifest chain for %s", s.InputRef())
+ }
+
+ var (
+ materialsLayers []ocispecs.Descriptor
+ imageMfsts []ocispecs.Descriptor
+ )
+ seenLayerDigest := map[digest.Digest]struct{}{}
+ seenImageMfst := map[digest.Digest]struct{}{}
+
+ for _, m := range pred.ResolvedDependencies() {
+ switch classifyMaterial(m) {
+ case materialKindImage:
+ if !req.IncludeMaterials {
+ continue
+ }
+ rootDgst := resourceDigest(m)
+ if rootDgst == "" {
+ return ocispecs.Descriptor{}, errors.Errorf("image material %q has no sha256 digest", m.URI)
+ }
+ if err := sub.Wrap(fmt.Sprintf("image material %s", m.URI), func() error {
+ rootDesc, rootProvider, err := resolveImageMaterial(ctx, req.Materials, lazyResolver, m, rootDgst, WithPlatform(s.Descriptor.Platform), WithBuilderPlatform(pred.BuilderPlatform()))
+ if err != nil {
+ return err
+ }
+ if err := stage.copyBlob(ctx, rootProvider, rootDesc); err != nil {
+ return errors.Wrapf(err, "copy image root %s", rootDesc.Digest)
+ }
+ if _, ok := seenLayerDigest[rootDesc.Digest]; !ok {
+ seenLayerDigest[rootDesc.Digest] = struct{}{}
+ materialsLayers = append(materialsLayers, ocispecs.Descriptor{
+ MediaType: rootDesc.MediaType,
+ Digest: rootDesc.Digest,
+ Size: rootDesc.Size,
+ })
+ }
+ platDesc, err := pickPlatformChild(ctx, rootProvider, rootDesc, s.Descriptor.Platform, pred.BuilderPlatform())
+ if err != nil {
+ return errors.Wrapf(err, "pick platform child for %s", m.URI)
+ }
+ if err := contentutil.CopyChain(ctx, stage, rootProvider, platDesc); err != nil {
+ return errors.Wrapf(err, "copy image material chain for %s", m.URI)
+ }
+ if _, ok := seenImageMfst[platDesc.Digest]; !ok {
+ seenImageMfst[platDesc.Digest] = struct{}{}
+ imageMfsts = append(imageMfsts, platDesc)
+ }
+ return nil
+ }); err != nil {
+ return ocispecs.Descriptor{}, err
+ }
+
+ case materialKindHTTP:
+ if !req.IncludeMaterials {
+ continue
+ }
+ dgst := resourceDigest(m)
+ if dgst == "" {
+ return ocispecs.Descriptor{}, errors.Errorf("http material %q has no sha256 digest", m.URI)
+ }
+ if err := sub.Wrap(fmt.Sprintf("http material %s", m.URI), func() error {
+ desc, provider, err := req.Materials.Resolve(ctx, m.URI, dgst)
+ if err != nil {
+ return errors.Wrapf(err, "resolve http material %s", m.URI)
+ }
+ if provider == nil {
+ return ErrMaterialNotFound(m.URI, dgst.String())
+ }
+ desc.MediaType = layerMediaTypeHTTP
+ if err := stage.copyBlob(ctx, provider, desc); err != nil {
+ return errors.Wrapf(err, "copy http material %s", desc.Digest)
+ }
+ if _, ok := seenLayerDigest[desc.Digest]; !ok {
+ seenLayerDigest[desc.Digest] = struct{}{}
+ materialsLayers = append(materialsLayers, desc)
+ }
+ return nil
+ }); err != nil {
+ return ocispecs.Descriptor{}, err
+ }
+
+ case materialKindContainerBlob:
+ if !req.IncludeMaterials {
+ continue
+ }
+ dgst := resourceDigest(m)
+ if dgst == "" {
+ return ocispecs.Descriptor{}, errors.Errorf("container-blob material has no sha256 digest")
+ }
+ if err := sub.Wrap(fmt.Sprintf("container-blob material %s", dgst), func() error {
+ desc, provider, err := req.Materials.Resolve(ctx, m.URI, dgst)
+ if err != nil {
+ return errors.Wrapf(err, "resolve container-blob material %s", dgst)
+ }
+ if provider == nil {
+ return ErrMaterialNotFound(m.URI, dgst.String())
+ }
+ if desc.MediaType == "" {
+ desc.MediaType = layerMediaTypeContainerBlob
+ }
+ if err := stage.copyBlob(ctx, provider, desc); err != nil {
+ return errors.Wrapf(err, "copy container-blob material %s", desc.Digest)
+ }
+ if _, ok := seenLayerDigest[desc.Digest]; !ok {
+ seenLayerDigest[desc.Digest] = struct{}{}
+ materialsLayers = append(materialsLayers, desc)
+ }
+ return nil
+ }); err != nil {
+ return ocispecs.Descriptor{}, err
+ }
+
+ case materialKindGit:
+ // Git packfile snapshotting is not implemented. Dedup so a
+ // single URI referenced across many platforms warns once.
+ warn.Log(sub, "git:"+m.URI, fmt.Sprintf("git material %q is not included in the snapshot (not yet supported)", m.URI))
+
+ case materialKindUnknown:
+ warn.Log(sub, "unknown:"+m.URI, fmt.Sprintf("material with URI %q and no recognised scheme is ignored", m.URI))
+ }
+ }
+
+ var materialsManifestDesc ocispecs.Descriptor
+ if req.IncludeMaterials {
+ _, mDesc, mData, err := MaterialsManifest(materialsLayers)
+ if err != nil {
+ return ocispecs.Descriptor{}, err
+ }
+ if err := stage.writeRaw(ctx, ocispecs.Descriptor{
+ MediaType: ocispecs.MediaTypeImageManifest,
+ Digest: mDesc.Digest,
+ Size: mDesc.Size,
+ }, mData); err != nil {
+ return ocispecs.Descriptor{}, errors.Wrap(err, "write materials manifest")
+ }
+ materialsManifestDesc = mDesc
+ }
+
+ attestDesc := attestMfst
+ // The attestation manifest's descriptor in the original image index
+ // carries Docker reference annotations (vnd.docker.reference.*) that
+ // are meaningless for the snapshot-subject role — strip them. Preserve
+ // the manifest's own artifactType so consumers can tell this is a
+ // Docker attestation manifest without fetching the body.
+ attestDesc.Annotations = nil
+ attestMfstData, err := content.ReadBlob(ctx, stage, attestMfst)
+ if err != nil {
+ return ocispecs.Descriptor{}, errors.Wrapf(err, "read attestation manifest %s", attestMfst.Digest)
+ }
+ var attestMfstBody ocispecs.Manifest
+ if err := json.Unmarshal(attestMfstData, &attestMfstBody); err != nil {
+ return ocispecs.Descriptor{}, errors.Wrapf(err, "parse attestation manifest %s", attestMfst.Digest)
+ }
+ attestDesc.ArtifactType = attestMfstBody.ArtifactType
+
+ _, ppDesc, ppData, err := PerPlatformSnapshotIndex(attestDesc, materialsManifestDesc, imageMfsts)
+ if err != nil {
+ return ocispecs.Descriptor{}, err
+ }
+ if err := stage.writeRaw(ctx, ocispecs.Descriptor{
+ MediaType: ocispecs.MediaTypeImageIndex,
+ Digest: ppDesc.Digest,
+ Size: ppDesc.Size,
+ }, ppData); err != nil {
+ return ocispecs.Descriptor{}, errors.Wrap(err, "write per-platform snapshot index")
+ }
+ return ppDesc, nil
+}
+
+// stagingStore wraps a contentutil.Buffer with a few helpers and also
+// tracks every digest written into the buffer so the final flush can emit
+// exactly that set (contentutil.Buffer.Walk is a stub, so we maintain our
+// own digest list). Any ingester-facing code that bypasses the helpers
+// (e.g. contentutil.CopyChain) must be invoked with stagingStore itself —
+// it implements content.Ingester — so those writes are tracked too.
+type stagingStore struct {
+ buffer contentutil.Buffer
+ mu sync.Mutex
+ order []digest.Digest
+ descs map[digest.Digest]ocispecs.Descriptor
+}
+
+func newStagingStore() *stagingStore {
+ return &stagingStore{
+ buffer: contentutil.NewBuffer(),
+ descs: map[digest.Digest]ocispecs.Descriptor{},
+ }
+}
+
+// record captures the full descriptor for each blob written into the stage.
+// Later writes merge fields (e.g. a FetchHandler Writer has the MediaType
+// from content.WithDescriptor; writeRaw and copyBlob pass full descriptors).
+func (s *stagingStore) record(desc ocispecs.Descriptor) {
+ if desc.Digest == "" {
+ return
+ }
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if prev, ok := s.descs[desc.Digest]; ok {
+ if prev.MediaType == "" && desc.MediaType != "" {
+ prev.MediaType = desc.MediaType
+ }
+ if prev.Size == 0 && desc.Size > 0 {
+ prev.Size = desc.Size
+ }
+ s.descs[desc.Digest] = prev
+ return
+ }
+ s.descs[desc.Digest] = desc
+ s.order = append(s.order, desc.Digest)
+}
+
+// ReaderAt satisfies content.Provider, delegating to the wrapped buffer.
+func (s *stagingStore) ReaderAt(ctx context.Context, desc ocispecs.Descriptor) (content.ReaderAt, error) {
+ return s.buffer.ReaderAt(ctx, desc)
+}
+
+// Writer satisfies content.Ingester; commits are tracked so the final dump
+// can enumerate exactly what the snapshot added.
+func (s *stagingStore) Writer(ctx context.Context, opts ...content.WriterOpt) (content.Writer, error) {
+ w, err := s.buffer.Writer(ctx, opts...)
+ if err != nil {
+ return nil, err
+ }
+ var desc ocispecs.Descriptor
+ var wOpts content.WriterOpts
+ for _, o := range opts {
+ _ = o(&wOpts)
+ }
+ desc = wOpts.Desc
+ return &stagingWriter{Writer: w, store: s, desc: desc}, nil
+}
+
+type stagingWriter struct {
+ content.Writer
+ store *stagingStore
+ desc ocispecs.Descriptor
+}
+
+func (w *stagingWriter) Commit(ctx context.Context, size int64, expected digest.Digest, opts ...content.Opt) error {
+ if err := w.Writer.Commit(ctx, size, expected, opts...); err != nil {
+ return err
+ }
+ d := expected
+ if d == "" {
+ d = w.Digest()
+ }
+ recorded := w.desc
+ recorded.Digest = d
+ if recorded.Size == 0 {
+ recorded.Size = size
+ }
+ w.store.record(recorded)
+ return nil
+}
+
+// writeRaw writes the bytes dt under desc.Digest. Already-present content is
+// treated as a no-op (the staging buffer is content-addressable).
+func (s *stagingStore) writeRaw(ctx context.Context, desc ocispecs.Descriptor, dt []byte) error {
+ if desc.Digest == "" {
+ return errors.New("writeRaw: empty digest")
+ }
+ if desc.Size == 0 {
+ desc.Size = int64(len(dt))
+ }
+ ref := "snapshot-" + desc.Digest.String()
+ err := content.WriteBlob(ctx, s, ref, bytes.NewReader(dt), desc)
+ if err != nil && !errdefs.IsAlreadyExists(err) {
+ return errors.WithStack(err)
+ }
+ s.record(desc)
+ return nil
+}
+
+// copyBlob copies one blob from src through into the staging buffer. This is
+// a content-only copy — no children / manifest walk.
+func (s *stagingStore) copyBlob(ctx context.Context, src content.Provider, desc ocispecs.Descriptor) error {
+ if desc.Digest == "" {
+ return errors.New("copyBlob: empty digest")
+ }
+ if err := contentutil.Copy(ctx, s, src, desc, "snapshot-"+desc.Digest.String(), nil); err != nil {
+ return err
+ }
+ s.record(desc)
+ return nil
+}
+
+// resolveImageMaterial looks up a provenance image material by its recorded
+// URI + root digest. Prefers a locally-configured MaterialsResolver (which
+// may be snapshot-backed) and falls back to fetching from the registry
+// derived from the pkg:docker purl.
+func resolveImageMaterial(
+ ctx context.Context,
+ resolver *MaterialsResolver,
+ lazyResolver func() (*imagetools.Resolver, error),
+ m slsa1.ResourceDescriptor,
+ rootDgst digest.Digest,
+ opts ...ResolveOption,
+) (ocispecs.Descriptor, content.Provider, error) {
+ if resolver != nil {
+ desc, provider, err := resolver.Resolve(ctx, m.URI, rootDgst, opts...)
+ if err == nil && provider != nil {
+ if desc.MediaType == "" {
+ desc.MediaType = ocispecs.MediaTypeImageIndex
+ }
+ return desc, provider, nil
+ }
+ if err != nil {
+ var mnf *MaterialNotFoundError
+ if !errors.As(err, &mnf) {
+ return ocispecs.Descriptor{}, nil, err
+ }
+ if !resolver.Sentinel() {
+ return ocispecs.Descriptor{}, nil, err
+ }
+ }
+ }
+
+ // Fall back to a registry fetch driven off the pkg:docker purl.
+ ref, _, err := purl.PURLToRef(m.URI)
+ if err != nil {
+ return ocispecs.Descriptor{}, nil, errors.Wrapf(err, "invalid image material URI %q", m.URI)
+ }
+ named, err := reference.ParseNormalizedNamed(ref)
+ if err != nil {
+ return ocispecs.Descriptor{}, nil, errors.Wrapf(err, "invalid image material reference %q", ref)
+ }
+ // A digest embedded in the purl must agree with the separately recorded
+ // material digest, otherwise the snapshot would package a different
+ // material from the one the provenance describes.
+ if d, ok := named.(reference.Digested); ok && d.Digest() != rootDgst {
+ return ocispecs.Descriptor{}, nil, errors.Errorf("image material %s digest %s does not match recorded digest %s", m.URI, d.Digest(), rootDgst)
+ }
+ // Address the root directly by digest so we fetch the exact recorded
+ // index regardless of tag mutations since the original build.
+ fetchRef := reference.TrimNamed(named).String() + "@" + rootDgst.String()
+ imgResolver, err := lazyResolver()
+ if err != nil {
+ return ocispecs.Descriptor{}, nil, err
+ }
+ // Resolve the fetchRef first so we learn the root descriptor's size +
+ // mediaType. Without a size, contentutil.FromFetcher's ReaderAt reports
+ // size=0 and ReadBlob returns an empty payload (silently succeeding with
+ // a JSON decode error downstream).
+ _, rootDesc, err := imgResolver.Resolve(ctx, fetchRef)
+ if err != nil {
+ return ocispecs.Descriptor{}, nil, errors.Wrapf(err, "resolve image material %s", m.URI)
+ }
+ fetcher, err := imgResolver.Fetcher(ctx, fetchRef)
+ if err != nil {
+ return ocispecs.Descriptor{}, nil, errors.Wrapf(err, "fetch image material %s", m.URI)
+ }
+ provider := contentutil.FromFetcher(fetcher)
+ if rootDesc.Digest == "" {
+ rootDesc.Digest = rootDgst
+ }
+ if rootDesc.MediaType == "" {
+ // If the resolver left the mediaType empty, probe it by
+ // reading the manifest payload.
+ dt, err := content.ReadBlob(ctx, provider, rootDesc)
+ if err != nil {
+ return ocispecs.Descriptor{}, nil, errors.Wrapf(err, "read image material root %s", rootDgst)
+ }
+ mt, err := detectManifestMediaType(dt)
+ if err != nil {
+ return ocispecs.Descriptor{}, nil, err
+ }
+ rootDesc.MediaType = mt
+ if rootDesc.Size == 0 {
+ rootDesc.Size = int64(len(dt))
+ }
+ }
+ return rootDesc, provider, nil
+}
+
+// pickPlatformChild turns a "root" descriptor for an image material into the
+// platform-specific manifest descriptor to store in the snapshot. Provenance
+// only records the root index digest, so replay has to guess which child
+// BuildKit actually resolved. The matcher prefers the subject's platform
+// (FROM-style base images resolve at TARGETPLATFORM) and falls back to the
+// builder platform recorded on the predicate (frontend images and
+// cross-compile toolchains run on the build host). An index with a single
+// child is always returned as-is.
+func pickPlatformChild(ctx context.Context, provider content.Provider, root ocispecs.Descriptor, subjectPlat *ocispecs.Platform, builderPlat ocispecs.Platform) (ocispecs.Descriptor, error) {
+ switch root.MediaType {
+ case ocispecs.MediaTypeImageManifest, images.MediaTypeDockerSchema2Manifest:
+ return root, nil
+ case ocispecs.MediaTypeImageIndex, images.MediaTypeDockerSchema2ManifestList:
+ dt, err := content.ReadBlob(ctx, provider, root)
+ if err != nil {
+ return ocispecs.Descriptor{}, errors.WithStack(err)
+ }
+ var idx ocispecs.Index
+ if err := json.Unmarshal(dt, &idx); err != nil {
+ return ocispecs.Descriptor{}, errors.WithStack(err)
+ }
+
+ matcher := replayPlatformMatcher(subjectPlat, builderPlat)
+ var best *ocispecs.Descriptor
+ for i := range idx.Manifests {
+ c := idx.Manifests[i]
+ if c.Platform == nil || !matcher.Match(*c.Platform) {
+ continue
+ }
+ if best == nil || matcher.Less(*c.Platform, *best.Platform) {
+ best = &c
+ }
+ }
+ if best != nil {
+ return *best, nil
+ }
+ if len(idx.Manifests) == 1 {
+ return idx.Manifests[0], nil
+ }
+ return ocispecs.Descriptor{}, errors.Errorf("image material index %s has no child matching subject platform %s or builder %s", root.Digest, formatPlatformPtr(subjectPlat), platforms.Format(builderPlat))
+ default:
+ return ocispecs.Descriptor{}, errors.Errorf("unsupported image material root media type %q", root.MediaType)
+ }
+}
+
+// replayPlatformMatcher returns a MatchComparer that prefers the subject's
+// platform then falls back to the builder platform recorded on the
+// predicate. When the subject has no platform, the builder platform is used
+// directly (buildx records builderPlatform as the effective default for any
+// subject produced by that build).
+func replayPlatformMatcher(subjectPlat *ocispecs.Platform, builderPlat ocispecs.Platform) platforms.MatchComparer {
+ if subjectPlat == nil || platforms.Only(*subjectPlat).Match(builderPlat) {
+ return platforms.Only(builderPlat)
+ }
+ return platforms.Ordered(*subjectPlat, builderPlat)
+}
+
+func formatPlatformPtr(p *ocispecs.Platform) string {
+ if p == nil {
+ return ""
+ }
+ return platforms.Format(*p)
+}
+
+// snapshotTargetLabel produces a human-readable label for progress output:
+// " ()" when the subject has a platform, otherwise just
+// the digest.
+func snapshotTargetLabel(s *Subject) string {
+ d := s.Descriptor.Digest.String()
+ if s.Descriptor.Platform != nil {
+ return platforms.Format(*s.Descriptor.Platform) + " (" + d + ")"
+ }
+ return d
+}
+
+// detectManifestMediaType inspects the first bytes of a manifest blob and
+// returns the OCI media type — shared with util/imagetools but re-implemented
+// here to avoid importing a single helper.
+func detectManifestMediaType(dt []byte) (string, error) {
+ var probe struct {
+ MediaType string `json:"mediaType"`
+ Manifests []json.RawMessage `json:"manifests,omitempty"`
+ Config json.RawMessage `json:"config,omitempty"`
+ }
+ if err := json.Unmarshal(dt, &probe); err != nil {
+ return "", errors.WithStack(err)
+ }
+ if probe.MediaType != "" {
+ return probe.MediaType, nil
+ }
+ if len(probe.Manifests) > 0 {
+ return ocispecs.MediaTypeImageIndex, nil
+ }
+ if len(probe.Config) > 0 {
+ return ocispecs.MediaTypeImageManifest, nil
+ }
+ return "", errors.Errorf("cannot detect media type from manifest payload")
+}
+
+// materialKind classifies a provenance material by its URI scheme.
+type materialKind int
+
+const (
+ materialKindUnknown materialKind = iota
+ materialKindImage
+ materialKindHTTP
+ materialKindContainerBlob
+ materialKindGit
+)
+
+// classifyMaterial picks a materialKind from a ResourceDescriptor's URI.
+func classifyMaterial(m slsa1.ResourceDescriptor) materialKind {
+ switch {
+ case strings.HasPrefix(m.URI, "pkg:docker/"):
+ return materialKindImage
+ case strings.HasPrefix(m.URI, "https://") || strings.HasPrefix(m.URI, "http://"):
+ if looksLikeGitURL(m.URI) {
+ return materialKindGit
+ }
+ return materialKindHTTP
+ case strings.HasPrefix(m.URI, "git+") ||
+ strings.HasPrefix(m.URI, "git://") ||
+ strings.HasPrefix(m.URI, "ssh://"):
+ return materialKindGit
+ case m.URI == "" && resourceDigest(m) != "":
+ // No URI but a digest: treat as a container-blob material. Layers
+ // pulled directly by digest during a build show up this way.
+ return materialKindContainerBlob
+ }
+ return materialKindUnknown
+}
+
+// looksLikeGitURL returns true when an http(s) URL appears to name a git repo
+// (the provenance recorder sometimes uses a bare https url for a git remote).
+func looksLikeGitURL(raw string) bool {
+ u, err := url.Parse(raw)
+ if err != nil {
+ return false
+ }
+ return strings.HasSuffix(strings.ToLower(u.Path), ".git")
+}
+
+// resourceDigest extracts the preferred digest from a SLSA ResourceDescriptor
+// (sha256 first, then any other algorithm). Mirrors policy.preferredDigest.
+func resourceDigest(m slsa1.ResourceDescriptor) digest.Digest {
+ if m.Digest == nil {
+ return ""
+ }
+ if v, ok := m.Digest["sha256"]; ok && v != "" {
+ return digest.NewDigestFromEncoded(digest.SHA256, v)
+ }
+ for alg, v := range m.Digest {
+ if v == "" {
+ continue
+ }
+ return digest.NewDigestFromEncoded(digest.Algorithm(alg), v)
+ }
+ return ""
+}
+
+// writeSnapshotOutput materialises the staged snapshot content through the
+// selected --output target. oci / registry are supported.
+func writeSnapshotOutput(
+ ctx context.Context,
+ stage *stagingStore,
+ root ocispecs.Descriptor,
+ exp *buildflags.ExportEntry,
+) error {
+ switch exp.Type {
+ case "oci":
+ if exp.Destination == "" {
+ return errors.New("snapshot: type=oci requires dest=")
+ }
+ // tar defaults to true — "tar=false" selects the oci-layout
+ // directory form. The TTY refusal for dest=- lives in the
+ // command layer; this function trusts its caller.
+ if exp.Attrs["tar"] == "false" {
+ return writeOCILayoutDir(ctx, stage, root, exp.Destination)
+ }
+ return writeOCILayoutTar(ctx, stage, root, exp.Destination)
+ case "registry":
+ return ErrNotImplemented("snapshot registry output")
+ }
+ return errors.Errorf("snapshot: unsupported --output type %q (want oci | registry)", exp.Type)
+}
+
+// writeOCILayoutDir writes the snapshot as an OCI layout tree at dest.
+// The staging store already contains exactly the content that belongs in
+// the snapshot (each target loop staged precisely what was needed), so we
+// flush every blob verbatim — no graph walk is required.
+func writeOCILayoutDir(ctx context.Context, stage *stagingStore, root ocispecs.Descriptor, dest string) error {
+ if err := os.MkdirAll(dest, 0o755); err != nil {
+ return errors.WithStack(err)
+ }
+ store, err := contentlocal.NewStore(dest)
+ if err != nil {
+ return errors.Wrapf(err, "open store at %s", dest)
+ }
+ if err := flushStage(ctx, store, stage); err != nil {
+ return errors.Wrap(err, "copy snapshot into oci-layout")
+ }
+ idx := ociindex.NewStoreIndex(dest)
+ if err := idx.Put(root, ociindex.Tag("latest")); err != nil {
+ return errors.Wrapf(err, "update oci-layout index at %s", dest)
+ }
+ return nil
+}
+
+// flushStage copies every tracked blob from stage into ingester using the
+// complete descriptor recorded when the blob was staged. Registry pushers
+// require the media type to distinguish manifests from ordinary blobs.
+func flushStage(ctx context.Context, ingester content.Ingester, stage *stagingStore) error {
+ stage.mu.Lock()
+ order := slices.Clone(stage.order)
+ descs := make(map[digest.Digest]ocispecs.Descriptor, len(stage.descs))
+ maps.Copy(descs, stage.descs)
+ stage.mu.Unlock()
+
+ // Registries validate references when manifests and indexes are uploaded.
+ // Preserve staging order within each tier, but always send blobs first,
+ // manifests second, and indexes last.
+ ordered := make([]digest.Digest, 0, len(order))
+ for priority := range 3 {
+ for _, dgst := range order {
+ if descriptorPushPriority(descs[dgst]) == priority {
+ ordered = append(ordered, dgst)
+ }
+ }
+ }
+ for _, dgst := range ordered {
+ info, err := stage.buffer.Info(ctx, dgst)
+ if err != nil {
+ return errors.Wrapf(err, "lookup %s", dgst)
+ }
+ desc := descs[dgst]
+ desc.Digest = info.Digest
+ desc.Size = info.Size
+ ra, err := stage.ReaderAt(ctx, desc)
+ if err != nil {
+ return errors.Wrapf(err, "read %s", dgst)
+ }
+ err = content.WriteBlob(ctx, ingester, "snapshot-"+dgst.String(), content.NewReader(ra), desc)
+ ra.Close()
+ if err != nil && !errdefs.IsAlreadyExists(err) {
+ return errors.Wrapf(err, "write %s (%s)", dgst, desc.MediaType)
+ }
+ }
+ return nil
+}
+
+func descriptorPushPriority(desc ocispecs.Descriptor) int {
+ if images.IsManifestType(desc.MediaType) {
+ return 1
+ }
+ if images.IsIndexType(desc.MediaType) {
+ return 2
+ }
+ return 0
+}
+
+// writeOCILayoutTar writes the snapshot as an OCI layout tar at dest.
+//
+// containerd's imgarchive.Export walks the tree via images.Children, which
+// returns only config+layers for manifests and manifests[] for indexes —
+// it never follows `subject`. Our per-platform snapshot index reaches the
+// attestation manifest only through `subject`, so an imgarchive-driven
+// export would leave the attestation chain out of the tar. We instead
+// materialise the snapshot into a temp oci-layout directory using our
+// own walker (which does follow `subject`) and tar that directory.
+func writeOCILayoutTar(ctx context.Context, stage *stagingStore, root ocispecs.Descriptor, dest string) error {
+ tmp, err := os.MkdirTemp("", "buildx-snapshot-tar-")
+ if err != nil {
+ return errors.WithStack(err)
+ }
+ defer os.RemoveAll(tmp)
+ if err := writeOCILayoutDir(ctx, stage, root, tmp); err != nil {
+ return err
+ }
+
+ var w io.Writer = os.Stdout
+ if dest != "-" {
+ if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
+ return errors.WithStack(err)
+ }
+ f, err := os.Create(dest)
+ if err != nil {
+ return errors.WithStack(err)
+ }
+ defer f.Close()
+ w = f
+ }
+ return tarDirectory(tmp, w)
+}
+
+// tarDirectory writes the contents of srcDir into w as a tar archive. Entries
+// are rooted at the directory's contents (not the directory name itself) so
+// the resulting tar matches the OCI image-layout v1 spec: index.json,
+// oci-layout and blobs/ at the archive root.
+//
+// The walk is confined by os.Root so that symlinks or relative components
+// inside srcDir cannot escape it and leak unrelated files into the tar.
+func tarDirectory(srcDir string, w io.Writer) error {
+ root, err := os.OpenRoot(srcDir)
+ if err != nil {
+ return errors.WithStack(err)
+ }
+ defer root.Close()
+
+ tw := tar.NewWriter(w)
+ defer tw.Close()
+
+ return fs.WalkDir(root.FS(), ".", func(path string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ if path == "." {
+ return nil
+ }
+ info, err := d.Info()
+ if err != nil {
+ return errors.WithStack(err)
+ }
+ hdr, err := tar.FileInfoHeader(info, "")
+ if err != nil {
+ return errors.WithStack(err)
+ }
+ hdr.Name = filepath.ToSlash(path)
+ if info.IsDir() {
+ hdr.Name += "/"
+ }
+ if err := tw.WriteHeader(hdr); err != nil {
+ return errors.WithStack(err)
+ }
+ if !info.Mode().IsRegular() {
+ return nil
+ }
+ rf, err := root.Open(path)
+ if err != nil {
+ return errors.WithStack(err)
+ }
+ defer rf.Close()
+ if _, err := io.Copy(tw, rf); err != nil {
+ return errors.WithStack(err)
+ }
+ return nil
+ })
+}
diff --git a/replay/snapshot_format.go b/replay/snapshot_format.go
new file mode 100644
index 000000000000..338714d113c6
--- /dev/null
+++ b/replay/snapshot_format.go
@@ -0,0 +1,176 @@
+package replay
+
+import (
+ "encoding/json"
+
+ "github.com/opencontainers/go-digest"
+ ocispecsgo "github.com/opencontainers/image-spec/specs-go"
+ ocispecs "github.com/opencontainers/image-spec/specs-go/v1"
+ "github.com/pkg/errors"
+)
+
+// Media types and artifact types that identify buildx snapshots and the
+// materials manifest child.
+const (
+ // ArtifactTypeSnapshot is the artifactType for both the per-platform
+ // snapshot index and the top-level multi-platform snapshot index.
+ ArtifactTypeSnapshot = "application/vnd.docker.buildx.snapshots.v1+json"
+ // ArtifactTypeMaterials is the artifactType for the materials artifact
+ // manifest nested inside a per-platform snapshot index.
+ ArtifactTypeMaterials = "application/vnd.docker.buildx.snapshots.materials.v1+json"
+
+ // ociEmptyConfigMediaType is the OCI 1.1 empty descriptor for use as the
+ // `config:` of an artifact manifest that carries no image configuration.
+ // See the image-spec manifest guidance for the empty descriptor.
+ ociEmptyConfigMediaType = "application/vnd.oci.empty.v1+json"
+ // ociEmptyConfigDigest is the canonical sha256 of the two-byte `{}`
+ // OCI empty config.
+ ociEmptyConfigDigest = "sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a"
+ // ociEmptyConfigSize is the size in bytes of the OCI empty config (`{}`).
+ ociEmptyConfigSize = 2
+
+ // layerMediaTypeHTTP is the mediaType written for http material blob
+ // layers (raw bytes served by an http/https material).
+ layerMediaTypeHTTP = "application/octet-stream"
+ // layerMediaTypeContainerBlob is the default mediaType used for a
+ // container-blob layer material copied into the snapshot.
+ layerMediaTypeContainerBlob = "application/vnd.oci.image.layer.v1.tar+gzip"
+)
+
+// OCIEmptyConfigDescriptor returns the descriptor buildx uses for the empty
+// config on the materials artifact manifest. The two bytes of the empty
+// config are inlined via the descriptor's `data` field (OCI 1.1) so a
+// consumer never has to fetch the empty-config blob separately.
+func OCIEmptyConfigDescriptor() ocispecs.Descriptor {
+ return ocispecs.Descriptor{
+ MediaType: ociEmptyConfigMediaType,
+ Digest: digest.Digest(ociEmptyConfigDigest),
+ Size: ociEmptyConfigSize,
+ Data: OCIEmptyConfigBytes(),
+ }
+}
+
+// OCIEmptyConfigBytes returns the raw bytes of the OCI empty config (`{}`)
+// that callers must write into the snapshot content store so the materials
+// artifact manifest has a valid content-addressable config blob.
+func OCIEmptyConfigBytes() []byte {
+ return []byte("{}")
+}
+
+// MaterialsManifest builds the materials artifact manifest descriptor (the
+// image-manifest document plus its serialized bytes and descriptor) from the
+// ordered list of layer descriptors. The caller owns the task of copying the
+// referenced layer bytes (and the empty config) into the snapshot store; this
+// function produces only the manifest document and its addressable
+// descriptor.
+//
+// The layers parameter is used verbatim and must already include (in order):
+//
+// 1. http material layers (mediaType application/octet-stream)
+// 2. container-blob layers (mediaType vnd.oci.image.layer.v1.tar+gzip or
+// the recorded equivalent)
+// 3. image-material root-index blobs kept opaque
+// (mediaType vnd.oci.image.index.v1+json)
+func MaterialsManifest(layers []ocispecs.Descriptor) (ocispecs.Manifest, ocispecs.Descriptor, []byte, error) {
+ if layers == nil {
+ layers = []ocispecs.Descriptor{}
+ }
+ mfst := ocispecs.Manifest{
+ Versioned: ocispecsgo.Versioned{SchemaVersion: 2},
+ MediaType: ocispecs.MediaTypeImageManifest,
+ ArtifactType: ArtifactTypeMaterials,
+ Config: OCIEmptyConfigDescriptor(),
+ Layers: layers,
+ }
+ dt, err := json.Marshal(mfst)
+ if err != nil {
+ return ocispecs.Manifest{}, ocispecs.Descriptor{}, nil, errors.Wrap(err, "marshal materials manifest")
+ }
+ desc := ocispecs.Descriptor{
+ MediaType: ocispecs.MediaTypeImageManifest,
+ ArtifactType: ArtifactTypeMaterials,
+ Digest: digest.FromBytes(dt),
+ Size: int64(len(dt)),
+ }
+ return mfst, desc, dt, nil
+}
+
+// PerPlatformSnapshotIndex builds a per-platform snapshot index.
+// `attestManifest` is the ORIGINAL provenance attestation manifest
+// descriptor; it becomes the index's `subject` and its blob must be copied
+// into the snapshot content store by the caller.
+//
+// `materialsManifestDesc` — descriptor of the materials artifact manifest —
+// is included as the first entry in `manifests[]` when non-zero. Passing a
+// zero descriptor omits the materials manifest entirely (used when
+// `--include-materials=false`).
+//
+// `imageMaterialManifests` — platform-specific image manifests for each image
+// material — follow the materials manifest. Each descriptor's `Digest`
+// addresses the platform-specific manifest that the original build actually
+// used; its chain (manifest + config + layers) is expected to be present in
+// the snapshot store.
+func PerPlatformSnapshotIndex(
+ attestManifest ocispecs.Descriptor,
+ materialsManifestDesc ocispecs.Descriptor,
+ imageMaterialManifests []ocispecs.Descriptor,
+) (ocispecs.Index, ocispecs.Descriptor, []byte, error) {
+ manifests := make([]ocispecs.Descriptor, 0, 1+len(imageMaterialManifests))
+ if materialsManifestDesc.Digest != "" {
+ manifests = append(manifests, materialsManifestDesc)
+ }
+ manifests = append(manifests, imageMaterialManifests...)
+
+ idx := ocispecs.Index{
+ Versioned: ocispecsgo.Versioned{SchemaVersion: 2},
+ MediaType: ocispecs.MediaTypeImageIndex,
+ ArtifactType: ArtifactTypeSnapshot,
+ Subject: descriptorPtr(attestManifest),
+ Manifests: manifests,
+ }
+ dt, err := json.Marshal(idx)
+ if err != nil {
+ return ocispecs.Index{}, ocispecs.Descriptor{}, nil, errors.Wrap(err, "marshal per-platform snapshot index")
+ }
+ desc := ocispecs.Descriptor{
+ MediaType: ocispecs.MediaTypeImageIndex,
+ ArtifactType: ArtifactTypeSnapshot,
+ Digest: digest.FromBytes(dt),
+ Size: int64(len(dt)),
+ }
+ return idx, desc, dt, nil
+}
+
+// MultiPlatformSnapshotIndex wraps N per-platform snapshot index descriptors
+// into a top-level index. Each input descriptor must already carry its
+// `Platform` field so consumers can pick the right child.
+func MultiPlatformSnapshotIndex(perPlatform []ocispecs.Descriptor) (ocispecs.Index, ocispecs.Descriptor, []byte, error) {
+ idx := ocispecs.Index{
+ Versioned: ocispecsgo.Versioned{SchemaVersion: 2},
+ MediaType: ocispecs.MediaTypeImageIndex,
+ ArtifactType: ArtifactTypeSnapshot,
+ Manifests: perPlatform,
+ }
+ dt, err := json.Marshal(idx)
+ if err != nil {
+ return ocispecs.Index{}, ocispecs.Descriptor{}, nil, errors.Wrap(err, "marshal multi-platform snapshot index")
+ }
+ desc := ocispecs.Descriptor{
+ MediaType: ocispecs.MediaTypeImageIndex,
+ ArtifactType: ArtifactTypeSnapshot,
+ Digest: digest.FromBytes(dt),
+ Size: int64(len(dt)),
+ }
+ return idx, desc, dt, nil
+}
+
+// descriptorPtr returns a pointer to desc, or nil when desc is the zero
+// descriptor (so callers that pass no subject do not end up with an empty
+// stub).
+func descriptorPtr(desc ocispecs.Descriptor) *ocispecs.Descriptor {
+ if desc.Digest == "" {
+ return nil
+ }
+ out := desc
+ return &out
+}
diff --git a/replay/snapshot_format_test.go b/replay/snapshot_format_test.go
new file mode 100644
index 000000000000..ce872356b5a6
--- /dev/null
+++ b/replay/snapshot_format_test.go
@@ -0,0 +1,170 @@
+package replay
+
+import (
+ "encoding/json"
+ "testing"
+
+ digest "github.com/opencontainers/go-digest"
+ ocispecs "github.com/opencontainers/image-spec/specs-go/v1"
+ "github.com/stretchr/testify/require"
+)
+
+// Round-trip tests for the snapshot-format writers. Each test builds a
+// concrete document through the writer, serializes it back through json,
+// and asserts the expected shape.
+
+func TestPerPlatformSnapshotIndex_WithMaterials(t *testing.T) {
+ // Fake attestation manifest descriptor (subject chain into the snapshot).
+ attest := ocispecs.Descriptor{
+ MediaType: ocispecs.MediaTypeImageManifest,
+ ArtifactType: "application/vnd.in-toto+json",
+ Digest: digest.FromBytes([]byte("attest")),
+ Size: 12,
+ }
+
+ // Two materials layers: one http blob, one image-material root index.
+ httpBytes := []byte("http-material-bytes")
+ rootBytes := []byte("root-index-bytes")
+ layers := []ocispecs.Descriptor{
+ {
+ MediaType: "application/octet-stream",
+ Digest: digest.FromBytes(httpBytes),
+ Size: int64(len(httpBytes)),
+ },
+ {
+ MediaType: ocispecs.MediaTypeImageIndex,
+ Digest: digest.FromBytes(rootBytes),
+ Size: int64(len(rootBytes)),
+ },
+ }
+ _, matDesc, matData, err := MaterialsManifest(layers)
+ require.NoError(t, err)
+ require.Equal(t, ArtifactTypeMaterials, matDesc.ArtifactType)
+ require.Equal(t, ocispecs.MediaTypeImageManifest, matDesc.MediaType)
+ require.NotZero(t, matDesc.Size)
+ require.Equal(t, digest.FromBytes(matData), matDesc.Digest)
+
+ // Parse the materials manifest and assert its structure.
+ var mm ocispecs.Manifest
+ require.NoError(t, json.Unmarshal(matData, &mm))
+ require.Equal(t, 2, mm.SchemaVersion)
+ require.Equal(t, ocispecs.MediaTypeImageManifest, mm.MediaType)
+ require.Equal(t, ArtifactTypeMaterials, mm.ArtifactType)
+ require.Equal(t, OCIEmptyConfigDescriptor().Digest, mm.Config.Digest)
+ require.Equal(t, OCIEmptyConfigDescriptor().MediaType, mm.Config.MediaType)
+ require.Equal(t, int64(2), mm.Config.Size)
+ require.Len(t, mm.Layers, 2)
+ require.Equal(t, layers[0].Digest, mm.Layers[0].Digest)
+ require.Equal(t, layers[1].Digest, mm.Layers[1].Digest)
+ require.Nil(t, mm.Subject, "materials manifest MUST NOT carry a subject")
+
+ // Build the per-platform snapshot index on top.
+ imgMfst := ocispecs.Descriptor{
+ MediaType: ocispecs.MediaTypeImageManifest,
+ Digest: digest.FromBytes([]byte("platform-manifest")),
+ Size: 42,
+ Platform: &ocispecs.Platform{Architecture: "amd64", OS: "linux"},
+ }
+ _, ppDesc, ppData, err := PerPlatformSnapshotIndex(attest, matDesc, []ocispecs.Descriptor{imgMfst})
+ require.NoError(t, err)
+ require.Equal(t, ArtifactTypeSnapshot, ppDesc.ArtifactType)
+ require.Equal(t, ocispecs.MediaTypeImageIndex, ppDesc.MediaType)
+ require.Equal(t, digest.FromBytes(ppData), ppDesc.Digest)
+
+ var pp ocispecs.Index
+ require.NoError(t, json.Unmarshal(ppData, &pp))
+ require.Equal(t, 2, pp.SchemaVersion)
+ require.Equal(t, ocispecs.MediaTypeImageIndex, pp.MediaType)
+ require.Equal(t, ArtifactTypeSnapshot, pp.ArtifactType)
+
+ require.NotNil(t, pp.Subject, "per-platform snapshot index MUST carry a subject")
+ require.Equal(t, attest.Digest, pp.Subject.Digest)
+ require.Equal(t, attest.MediaType, pp.Subject.MediaType)
+ require.Equal(t, attest.Size, pp.Subject.Size)
+
+ require.Len(t, pp.Manifests, 2)
+ // First entry MUST be the materials manifest when it is present.
+ require.Equal(t, matDesc.Digest, pp.Manifests[0].Digest)
+ require.Equal(t, ArtifactTypeMaterials, pp.Manifests[0].ArtifactType)
+ // Remaining entries are the per-material platform manifests.
+ require.Equal(t, imgMfst.Digest, pp.Manifests[1].Digest)
+ require.NotNil(t, pp.Manifests[1].Platform)
+ require.Equal(t, "amd64", pp.Manifests[1].Platform.Architecture)
+
+ // Snapshot indexes are deterministic — no creation timestamp annotation.
+ require.Empty(t, pp.Annotations[ocispecs.AnnotationCreated])
+}
+
+func TestPerPlatformSnapshotIndex_WithoutMaterials(t *testing.T) {
+ // When --include-materials=false the materials manifest is omitted:
+ // the caller passes a zero descriptor.
+ attest := ocispecs.Descriptor{
+ MediaType: ocispecs.MediaTypeImageManifest,
+ Digest: digest.FromBytes([]byte("attest-noop")),
+ Size: 10,
+ }
+ imgMfst := ocispecs.Descriptor{
+ MediaType: ocispecs.MediaTypeImageManifest,
+ Digest: digest.FromBytes([]byte("platform-manifest-noop")),
+ Size: 11,
+ }
+
+ _, _, ppData, err := PerPlatformSnapshotIndex(attest, ocispecs.Descriptor{}, []ocispecs.Descriptor{imgMfst})
+ require.NoError(t, err)
+
+ var pp ocispecs.Index
+ require.NoError(t, json.Unmarshal(ppData, &pp))
+ require.Len(t, pp.Manifests, 1, "zero materials descriptor should be dropped")
+ require.Equal(t, imgMfst.Digest, pp.Manifests[0].Digest)
+ require.NotEqual(t, ArtifactTypeMaterials, pp.Manifests[0].ArtifactType)
+}
+
+func TestMultiPlatformSnapshotIndex(t *testing.T) {
+ // Build two dummy per-platform snapshot descriptors with populated
+ // Platform fields; wrap via MultiPlatformSnapshotIndex.
+ amd := ocispecs.Descriptor{
+ MediaType: ocispecs.MediaTypeImageIndex,
+ ArtifactType: ArtifactTypeSnapshot,
+ Digest: digest.FromBytes([]byte("pp-amd64")),
+ Size: 100,
+ Platform: &ocispecs.Platform{Architecture: "amd64", OS: "linux"},
+ }
+ arm := ocispecs.Descriptor{
+ MediaType: ocispecs.MediaTypeImageIndex,
+ ArtifactType: ArtifactTypeSnapshot,
+ Digest: digest.FromBytes([]byte("pp-arm64")),
+ Size: 110,
+ Platform: &ocispecs.Platform{Architecture: "arm64", OS: "linux"},
+ }
+ _, desc, data, err := MultiPlatformSnapshotIndex([]ocispecs.Descriptor{amd, arm})
+ require.NoError(t, err)
+ require.Equal(t, ArtifactTypeSnapshot, desc.ArtifactType)
+ require.Equal(t, ocispecs.MediaTypeImageIndex, desc.MediaType)
+ require.Equal(t, digest.FromBytes(data), desc.Digest)
+
+ var idx ocispecs.Index
+ require.NoError(t, json.Unmarshal(data, &idx))
+ require.Equal(t, 2, idx.SchemaVersion)
+ require.Equal(t, ocispecs.MediaTypeImageIndex, idx.MediaType)
+ require.Equal(t, ArtifactTypeSnapshot, idx.ArtifactType)
+ require.Nil(t, idx.Subject, "top-level multi-platform snapshot index MUST NOT carry a subject")
+ require.Len(t, idx.Manifests, 2)
+
+ for i, child := range idx.Manifests {
+ require.Equal(t, ArtifactTypeSnapshot, child.ArtifactType, "child %d", i)
+ require.Equal(t, ocispecs.MediaTypeImageIndex, child.MediaType, "child %d", i)
+ require.NotNil(t, child.Platform, "child %d must carry platform", i)
+ }
+ require.Equal(t, "amd64", idx.Manifests[0].Platform.Architecture)
+ require.Equal(t, "arm64", idx.Manifests[1].Platform.Architecture)
+ require.Empty(t, idx.Annotations[ocispecs.AnnotationCreated])
+}
+
+func TestOCIEmptyConfig(t *testing.T) {
+ // Round-trip the empty config constants: digest of the bytes must equal
+ // the exported descriptor digest.
+ bytes := OCIEmptyConfigBytes()
+ require.Equal(t, "{}", string(bytes))
+ require.Equal(t, digest.FromBytes(bytes).String(), string(OCIEmptyConfigDescriptor().Digest))
+ require.Equal(t, int64(len(bytes)), OCIEmptyConfigDescriptor().Size)
+}
diff --git a/replay/snapshot_test.go b/replay/snapshot_test.go
new file mode 100644
index 000000000000..6206ab949b9a
--- /dev/null
+++ b/replay/snapshot_test.go
@@ -0,0 +1,418 @@
+package replay
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/containerd/containerd/v2/core/content"
+ "github.com/docker/buildx/util/buildflags"
+ "github.com/docker/buildx/util/imagetools"
+ slsa1 "github.com/in-toto/in-toto-golang/in_toto/slsa_provenance/v1"
+ "github.com/moby/buildkit/client/ociindex"
+ provenancetypes "github.com/moby/buildkit/solver/llbsolver/provenance/types"
+ "github.com/moby/buildkit/util/contentutil"
+ "github.com/moby/buildkit/util/purl"
+ digest "github.com/opencontainers/go-digest"
+ ocispecsgo "github.com/opencontainers/image-spec/specs-go"
+ ocispecs "github.com/opencontainers/image-spec/specs-go/v1"
+ "github.com/pkg/errors"
+ "github.com/stretchr/testify/require"
+)
+
+// snapshotFixture produces a Subject + Predicate pair backed by an in-memory
+// content provider. The subject carries a synthetic image manifest; the
+// attestation manifest references an in-toto SLSA v1 provenance statement
+// whose ResolvedDependencies name one http material.
+//
+// Returned slice of material bytes gives tests a handle on the http material
+// payload so they can be pinned via a MaterialsResolver override.
+type snapshotFixture struct {
+ subject *Subject
+ predicate *Predicate
+ httpBytes []byte
+ httpDigest digest.Digest
+ httpURI string
+ attestDigest digest.Digest
+ subjectDesc ocispecs.Descriptor
+ attestDesc ocispecs.Descriptor
+ provider contentutil.Buffer
+}
+
+type descriptorRecordingIngester struct {
+ content.Ingester
+ descriptors []ocispecs.Descriptor
+}
+
+func (i *descriptorRecordingIngester) Writer(ctx context.Context, opts ...content.WriterOpt) (content.Writer, error) {
+ var wopts content.WriterOpts
+ for _, opt := range opts {
+ if err := opt(&wopts); err != nil {
+ return nil, err
+ }
+ }
+ i.descriptors = append(i.descriptors, wopts.Desc)
+ return i.Ingester.Writer(ctx, opts...)
+}
+
+func makeSnapshotFixture(t *testing.T) *snapshotFixture {
+ t.Helper()
+ ctx := context.Background()
+
+ buf := contentutil.NewBuffer()
+
+ // 1. Synthetic subject image: config + single layer + manifest.
+ configBytes := []byte(`{"architecture":"amd64","os":"linux","rootfs":{"type":"layers","diff_ids":[]}}`)
+ configDesc := writeBlobBuf(ctx, t, buf, ocispecs.MediaTypeImageConfig, configBytes)
+ layerBytes := []byte("fake-image-layer")
+ layerDesc := writeBlobBuf(ctx, t, buf, ocispecs.MediaTypeImageLayerGzip, layerBytes)
+
+ subjectManifest := ocispecs.Manifest{
+ Versioned: ocispecsgo.Versioned{SchemaVersion: 2},
+ MediaType: ocispecs.MediaTypeImageManifest,
+ Config: configDesc,
+ Layers: []ocispecs.Descriptor{layerDesc},
+ }
+ subjectBytes, err := json.Marshal(subjectManifest)
+ require.NoError(t, err)
+ subjectDesc := writeBlobBuf(ctx, t, buf, ocispecs.MediaTypeImageManifest, subjectBytes)
+ subjectDesc.Platform = &ocispecs.Platform{OS: "linux", Architecture: "amd64"}
+
+ // 2. http material (reachable later by digest).
+ httpBytes := []byte("hello-from-http-material")
+ httpDigest := digest.FromBytes(httpBytes)
+ _ = writeBlobBuf(ctx, t, buf, "application/octet-stream", httpBytes)
+
+ httpURI := "https://example.com/payload.tgz"
+
+ // 3. SLSA v1 provenance predicate.
+ pred := provenancetypes.ProvenancePredicateSLSA1{}
+ pred.BuildDefinition.ExternalParameters.Request.Frontend = "dockerfile.v0"
+ pred.BuildDefinition.ResolvedDependencies = []slsa1.ResourceDescriptor{
+ {
+ URI: httpURI,
+ Digest: map[string]string{
+ "sha256": httpDigest.Encoded(),
+ },
+ },
+ }
+
+ // 4. in-toto Statement wrapping the predicate.
+ predBytes, err := json.Marshal(pred)
+ require.NoError(t, err)
+ stmt := map[string]any{
+ "_type": "https://in-toto.io/Statement/v1",
+ "predicateType": "https://slsa.dev/provenance/v1",
+ "subject": []map[string]any{
+ {
+ "name": "synthetic",
+ "digest": map[string]string{"sha256": subjectDesc.Digest.Encoded()},
+ },
+ },
+ "predicate": json.RawMessage(predBytes),
+ }
+ stmtBytes, err := json.Marshal(stmt)
+ require.NoError(t, err)
+
+ stmtDesc := writeBlobBufWithAnnotations(
+ ctx, t, buf,
+ "application/vnd.in-toto+json",
+ stmtBytes,
+ map[string]string{"in-toto.io/predicate-type": "https://slsa.dev/provenance/v1"},
+ )
+
+ // 5. Attestation manifest referencing the in-toto statement layer.
+ attestManifest := ocispecs.Manifest{
+ Versioned: ocispecsgo.Versioned{SchemaVersion: 2},
+ MediaType: ocispecs.MediaTypeImageManifest,
+ ArtifactType: "application/vnd.in-toto+json",
+ Config: OCIEmptyConfigDescriptor(),
+ Layers: []ocispecs.Descriptor{stmtDesc},
+ }
+ // Write empty config (required by the attestation manifest chain).
+ _ = writeBlobBufRaw(ctx, t, buf, OCIEmptyConfigDescriptor(), OCIEmptyConfigBytes())
+
+ attestBytes, err := json.Marshal(attestManifest)
+ require.NoError(t, err)
+ attestDesc := writeBlobBuf(ctx, t, buf, ocispecs.MediaTypeImageManifest, attestBytes)
+ attestDesc.ArtifactType = "application/vnd.in-toto+json"
+
+ // 6. Build the Subject pointing at the image manifest.
+ s := &Subject{
+ Descriptor: subjectDesc,
+ Provider: buf,
+ inputRef: "synthetic://subject",
+ kind: subjectKindImage,
+ attestManifest: attestDesc,
+ }
+ p := Predicate(pred)
+
+ return &snapshotFixture{
+ subject: s,
+ predicate: &p,
+ httpBytes: httpBytes,
+ httpDigest: httpDigest,
+ httpURI: httpURI,
+ attestDigest: attestDesc.Digest,
+ subjectDesc: subjectDesc,
+ attestDesc: attestDesc,
+ provider: buf,
+ }
+}
+
+// writeBlobBuf stores dt in buf under a mediaType and returns the descriptor.
+func writeBlobBuf(ctx context.Context, t *testing.T, buf contentutil.Buffer, mediaType string, dt []byte) ocispecs.Descriptor {
+ t.Helper()
+ d := digest.FromBytes(dt)
+ desc := ocispecs.Descriptor{MediaType: mediaType, Digest: d, Size: int64(len(dt))}
+ require.NoError(t, content.WriteBlob(ctx, buf, d.String(), bytes.NewReader(dt), desc))
+ return desc
+}
+
+func writeBlobBufWithAnnotations(ctx context.Context, t *testing.T, buf contentutil.Buffer, mediaType string, dt []byte, ann map[string]string) ocispecs.Descriptor {
+ desc := writeBlobBuf(ctx, t, buf, mediaType, dt)
+ desc.Annotations = ann
+ return desc
+}
+
+func writeBlobBufRaw(ctx context.Context, t *testing.T, buf contentutil.Buffer, desc ocispecs.Descriptor, dt []byte) ocispecs.Descriptor {
+ t.Helper()
+ require.NoError(t, content.WriteBlob(ctx, buf, desc.Digest.String(), bytes.NewReader(dt), desc))
+ return desc
+}
+
+// snapshotOverrideResolver builds a MaterialsResolver whose single override
+// points at a temp file carrying the material bytes. That lets Snapshot
+// resolve the material without any network or registry access.
+func snapshotOverrideResolver(t *testing.T, uri string, dt []byte) *MaterialsResolver {
+ t.Helper()
+ tmp := filepath.Join(t.TempDir(), "override.bin")
+ require.NoError(t, os.WriteFile(tmp, dt, 0o644))
+ r, err := NewMaterialsResolver([]string{uri + "=" + tmp})
+ require.NoError(t, err)
+ return r
+}
+
+func TestSnapshotWritesOCILayout(t *testing.T) {
+ fx := makeSnapshotFixture(t)
+
+ dest := t.TempDir()
+ req := &SnapshotRequest{
+ Targets: []Target{{Subject: fx.subject, Predicate: fx.predicate}},
+ IncludeMaterials: true,
+ Materials: snapshotOverrideResolver(t, fx.httpURI, fx.httpBytes),
+ Output: &buildflags.ExportEntry{
+ Type: "oci",
+ Destination: dest,
+ Attrs: map[string]string{"tar": "false"},
+ },
+ }
+ require.NoError(t, Snapshot(context.Background(), nil, "", req))
+
+ // OCI layout skeleton present.
+ _, err := os.Stat(filepath.Join(dest, "oci-layout"))
+ require.NoError(t, err)
+ _, err = os.Stat(filepath.Join(dest, "index.json"))
+ require.NoError(t, err)
+ _, err = os.Stat(filepath.Join(dest, "blobs", "sha256"))
+ require.NoError(t, err)
+
+ // Index.json points at the per-platform snapshot index (single-platform
+ // case — Snapshot does not wrap in a multi-platform index).
+ idx, err := ociindex.NewStoreIndex(dest).Read()
+ require.NoError(t, err)
+ require.Len(t, idx.Manifests, 1)
+ root := idx.Manifests[0]
+ require.Equal(t, ArtifactTypeSnapshot, root.ArtifactType)
+
+ // Walk the root and assert its subject is the attestation manifest.
+ rootBlobPath := filepath.Join(dest, "blobs", root.Digest.Algorithm().String(), root.Digest.Encoded())
+ rootData, err := os.ReadFile(rootBlobPath)
+ require.NoError(t, err)
+ var ppIdx ocispecs.Index
+ require.NoError(t, json.Unmarshal(rootData, &ppIdx))
+ require.Equal(t, ArtifactTypeSnapshot, ppIdx.ArtifactType)
+ require.NotNil(t, ppIdx.Subject)
+ require.Equal(t, fx.attestDigest, ppIdx.Subject.Digest)
+
+ // First manifests[] entry must be the materials artifact manifest.
+ require.NotEmpty(t, ppIdx.Manifests)
+ matDesc := ppIdx.Manifests[0]
+ require.Equal(t, ArtifactTypeMaterials, matDesc.ArtifactType)
+
+ // The http material layer lives inside the materials manifest.
+ matPath := filepath.Join(dest, "blobs", matDesc.Digest.Algorithm().String(), matDesc.Digest.Encoded())
+ matRaw, err := os.ReadFile(matPath)
+ require.NoError(t, err)
+ var mm ocispecs.Manifest
+ require.NoError(t, json.Unmarshal(matRaw, &mm))
+ require.Equal(t, ArtifactTypeMaterials, mm.ArtifactType)
+
+ var gotHTTPLayer bool
+ for _, l := range mm.Layers {
+ if l.Digest == fx.httpDigest {
+ gotHTTPLayer = true
+ // Blob on disk must equal the pinned material bytes.
+ lp := filepath.Join(dest, "blobs", l.Digest.Algorithm().String(), l.Digest.Encoded())
+ got, err := os.ReadFile(lp)
+ require.NoError(t, err)
+ require.Equal(t, fx.httpBytes, got)
+ }
+ }
+ require.True(t, gotHTTPLayer, "materials manifest must include the http material layer")
+
+ // Round-trip: a MaterialsResolver pointed at oci-layout:// must
+ // find the http material through the snapshot-backed lookup path.
+ layout := fmt.Sprintf("oci-layout://%s", dest)
+ rr, err := NewMaterialsResolver([]string{layout})
+ require.NoError(t, err)
+ desc, provider, err := rr.Resolve(context.Background(), fx.httpURI, fx.httpDigest)
+ require.NoError(t, err)
+ require.NotNil(t, provider)
+ require.Equal(t, fx.httpDigest, desc.Digest)
+
+ gotBytes, err := content.ReadBlob(context.Background(), provider, desc)
+ require.NoError(t, err)
+ require.Equal(t, fx.httpBytes, gotBytes)
+}
+
+func TestFlushStagePreservesDescriptorMediaType(t *testing.T) {
+ ctx := context.Background()
+ stage := newStagingStore()
+ dt := []byte(`{"schemaVersion":2}`)
+ desc := ocispecs.Descriptor{
+ MediaType: ocispecs.MediaTypeImageManifest,
+ Digest: digest.FromBytes(dt),
+ Size: int64(len(dt)),
+ }
+ require.NoError(t, stage.writeRaw(ctx, desc, dt))
+
+ dest := &descriptorRecordingIngester{Ingester: contentutil.NewBuffer()}
+ require.NoError(t, flushStage(ctx, dest, stage))
+ require.Equal(t, []ocispecs.Descriptor{desc}, dest.descriptors)
+}
+
+func TestResolveImageMaterialDoesNotFallbackWithoutProvenanceSentinel(t *testing.T) {
+ resolver, err := NewMaterialsResolver([]string{t.TempDir()})
+ require.NoError(t, err)
+
+ registryCalled := false
+ _, _, err = resolveImageMaterial(
+ context.Background(),
+ resolver,
+ func() (*imagetools.Resolver, error) {
+ registryCalled = true
+ return nil, errors.New("unexpected registry fallback")
+ },
+ slsa1.ResourceDescriptor{URI: imageURIAlpine},
+ digest.Digest(imageSHA),
+ )
+ require.Error(t, err)
+ var notFound *MaterialNotFoundError
+ require.ErrorAs(t, err, ¬Found)
+ require.False(t, registryCalled)
+}
+
+func TestResolveImageMaterialRejectsPURLDigestMismatch(t *testing.T) {
+ purlDgst := digest.FromString("purl")
+ recordedDgst := digest.FromString("recorded")
+ uri, err := purl.RefToPURL("docker", "docker.io/library/alpine@"+purlDgst.String(), nil)
+ require.NoError(t, err)
+
+ registryCalled := false
+ _, _, err = resolveImageMaterial(
+ context.Background(),
+ nil,
+ func() (*imagetools.Resolver, error) {
+ registryCalled = true
+ return nil, errors.New("unexpected registry fallback")
+ },
+ slsa1.ResourceDescriptor{URI: uri},
+ recordedDgst,
+ )
+ require.ErrorContains(t, err, "does not match recorded digest")
+ require.False(t, registryCalled)
+}
+
+func TestPickPlatformChildPrefersSubjectPlatform(t *testing.T) {
+ ctx := context.Background()
+ subjectPlat := ocispecs.Platform{OS: "linux", Architecture: "arm64"}
+ builderPlat := ocispecs.Platform{OS: "linux", Architecture: "amd64"}
+ subjectChild := ocispecs.Descriptor{
+ MediaType: ocispecs.MediaTypeImageManifest,
+ Digest: digest.FromString("subject"),
+ Size: 1,
+ Platform: &subjectPlat,
+ }
+ builderChild := ocispecs.Descriptor{
+ MediaType: ocispecs.MediaTypeImageManifest,
+ Digest: digest.FromString("builder"),
+ Size: 1,
+ Platform: &builderPlat,
+ }
+
+ for _, tc := range []struct {
+ name string
+ manifests []ocispecs.Descriptor
+ }{
+ {name: "builder-first", manifests: []ocispecs.Descriptor{builderChild, subjectChild}},
+ {name: "subject-first", manifests: []ocispecs.Descriptor{subjectChild, builderChild}},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ dt, err := json.Marshal(ocispecs.Index{
+ Versioned: ocispecsgo.Versioned{SchemaVersion: 2},
+ MediaType: ocispecs.MediaTypeImageIndex,
+ Manifests: tc.manifests,
+ })
+ require.NoError(t, err)
+ root := ocispecs.Descriptor{
+ MediaType: ocispecs.MediaTypeImageIndex,
+ Digest: digest.FromBytes(dt),
+ Size: int64(len(dt)),
+ }
+ buf := contentutil.NewBuffer()
+ require.NoError(t, content.WriteBlob(ctx, buf, "index", bytes.NewReader(dt), root))
+
+ got, err := pickPlatformChild(ctx, buf, root, &subjectPlat, builderPlat)
+ require.NoError(t, err)
+ require.Equal(t, subjectChild.Digest, got.Digest)
+
+ // Without the subject platform in the index, the builder platform
+ // is the fallback.
+ got, err = pickPlatformChild(ctx, buf, root, &ocispecs.Platform{OS: "linux", Architecture: "s390x"}, builderPlat)
+ require.NoError(t, err)
+ require.Equal(t, builderChild.Digest, got.Digest)
+ })
+ }
+}
+
+func TestSnapshotRejectsAttestationFileSubject(t *testing.T) {
+ fx := makeSnapshotFixture(t)
+ fx.subject.kind = subjectKindAttestationFile
+
+ req := &SnapshotRequest{
+ Targets: []Target{{Subject: fx.subject, Predicate: fx.predicate}},
+ Output: &buildflags.ExportEntry{
+ Type: "oci",
+ Destination: t.TempDir(),
+ Attrs: map[string]string{"tar": "false"},
+ },
+ }
+ err := Snapshot(context.Background(), nil, "", req)
+ require.Error(t, err)
+ var unsup *UnsupportedSubjectError
+ require.ErrorAs(t, err, &unsup)
+}
+
+func TestSnapshotMissingOutputRejected(t *testing.T) {
+ fx := makeSnapshotFixture(t)
+
+ err := Snapshot(context.Background(), nil, "", &SnapshotRequest{
+ Targets: []Target{{Subject: fx.subject, Predicate: fx.predicate}},
+ })
+ require.Error(t, err)
+}
diff --git a/replay/subject.go b/replay/subject.go
new file mode 100644
index 000000000000..9f66fbf31db1
--- /dev/null
+++ b/replay/subject.go
@@ -0,0 +1,761 @@
+package replay
+
+import (
+ "bytes"
+ "context"
+ "encoding/base64"
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/containerd/containerd/v2/core/content"
+ "github.com/containerd/containerd/v2/core/images"
+ "github.com/containerd/containerd/v2/core/remotes"
+ "github.com/distribution/reference"
+ "github.com/docker/buildx/builder"
+ "github.com/docker/buildx/policy"
+ "github.com/docker/buildx/util/confutil"
+ "github.com/docker/buildx/util/imagetools"
+ "github.com/docker/cli/cli/command"
+ slsa02 "github.com/in-toto/in-toto-golang/in_toto/slsa_provenance/v0.2"
+ slsa1 "github.com/in-toto/in-toto-golang/in_toto/slsa_provenance/v1"
+ provenancetypes "github.com/moby/buildkit/solver/llbsolver/provenance/types"
+ "github.com/moby/buildkit/util/attestation"
+ "github.com/moby/buildkit/util/contentutil"
+ policyverifier "github.com/moby/policy-helpers"
+ policyimage "github.com/moby/policy-helpers/image"
+ policytypes "github.com/moby/policy-helpers/types"
+ "github.com/opencontainers/go-digest"
+ ocispecs "github.com/opencontainers/image-spec/specs-go/v1"
+ "github.com/pkg/errors"
+)
+
+const (
+ dockerImagePrefix = "docker-image://"
+ ociLayoutPrefix = "oci-layout://"
+)
+
+// subjectKind classifies how a Subject was produced; downstream code only
+// reads the exported fields, but the kind guides error messages.
+type subjectKind int
+
+const (
+ // subjectKindImage covers both remote image refs and OCI-layout
+ // inputs — both resolve to a (descriptor, content.Provider) tuple
+ // with identical semantics.
+ subjectKindImage subjectKind = iota
+ subjectKindAttestationFile
+)
+
+// Subject is one replayable unit: a single manifest-level descriptor plus a
+// content.Provider that serves that descriptor, its referrers, and the
+// predicate blob.
+//
+// For image and oci-layout inputs, Descriptor is the produced artifact's
+// manifest descriptor. For an attestation-file input, Descriptor points at
+// the predicate blob in an in-memory content.Provider and there is no
+// produced artifact.
+type Subject struct {
+ Descriptor ocispecs.Descriptor
+ Provider content.Provider
+
+ // inputRef is the user-visible string for error messages.
+ inputRef string
+ kind subjectKind
+ // attestManifest is the in-toto attestation manifest descriptor that
+ // referrers this subject (image / oci-layout kinds). Empty for
+ // attestation-file subjects, whose predicate blob is directly at
+ // Descriptor.
+ attestManifest ocispecs.Descriptor
+ // predicateType caches the predicate type URI for attestation-file
+ // subjects so Predicate() can reject non-SLSA predicates without re-reading.
+ predicateType string
+ // rootDescriptor is the image index from which this manifest subject was
+ // selected. Image signature verification binds the index, selected
+ // platform, attestation manifest, and its signature referrer together.
+ rootDescriptor ocispecs.Descriptor
+ // signature is populated only after a Sigstore bundle has passed
+ // certificate, transparency-log, timestamp, and payload verification.
+ signature *SignatureVerification
+}
+
+// SignatureVerification describes a cryptographically verified Sigstore
+// bundle. Identity is informational: replay accepts unsigned provenance too,
+// so verification does not imply that a separate authorization policy has
+// approved this signer.
+type SignatureVerification struct {
+ Verified bool `json:"verified"`
+ Type string `json:"type"`
+ Identity string `json:"identity"`
+ CertificateIssuer string `json:"certificateIssuer,omitempty"`
+ SubjectAlternativeName string `json:"subjectAlternativeName,omitempty"`
+ Issuer string `json:"issuer,omitempty"`
+ SourceRepositoryURI string `json:"sourceRepositoryURI,omitempty"`
+ SourceRepositoryRef string `json:"sourceRepositoryRef,omitempty"`
+ BuildSignerURI string `json:"buildSignerURI,omitempty"`
+ RunnerEnvironment string `json:"runnerEnvironment,omitempty"`
+ Timestamps []SignatureTimestamp `json:"timestamps,omitempty"`
+ TrustRootLastUpdated *time.Time `json:"trustRootLastUpdated,omitempty"`
+ TrustRootWarning string `json:"trustRootWarning,omitempty"`
+}
+
+// SignatureTimestamp is one verified observer timestamp from a Sigstore
+// bundle, typically a transparency-log or timestamp-authority observation.
+type SignatureTimestamp struct {
+ Type string `json:"type"`
+ URI string `json:"uri,omitempty"`
+ Timestamp time.Time `json:"timestamp"`
+}
+
+// IsAttestationFile reports whether this subject was loaded from a local
+// attestation file (no produced artifact is available).
+func (s *Subject) IsAttestationFile() bool { return s != nil && s.kind == subjectKindAttestationFile }
+
+// Signature returns verified signature metadata, or nil for unsigned
+// provenance and images.
+func (s *Subject) Signature() *SignatureVerification {
+ if s == nil {
+ return nil
+ }
+ return s.signature
+}
+
+// VerifySignatures verifies signatures attached to the selected image
+// subjects' provenance attestations. One verifier provider is shared across
+// all platforms so its trust-root state is initialized only once. Unsigned
+// images remain valid replay inputs; a published but invalid signature fails
+// closed. Standalone Sigstore bundles are already verified while loading.
+func VerifySignatures(ctx context.Context, dockerCli command.Cli, subjects []*Subject) error {
+ var candidates []*Subject
+ for _, s := range subjects {
+ if s != nil && s.kind == subjectKindImage && s.rootDescriptor.MediaType == ocispecs.MediaTypeImageIndex && s.attestManifest.Digest != "" {
+ candidates = append(candidates, s)
+ }
+ }
+ if len(candidates) == 0 {
+ return nil
+ }
+ if dockerCli == nil {
+ return errors.New("docker CLI is required to verify image signatures")
+ }
+ getVerifier := policy.SignatureVerifier(confutil.NewConfig(dockerCli))
+ return verifySubjectSignatures(ctx, candidates, func(ctx context.Context, provider policyimage.ReferrersProvider, root ocispecs.Descriptor, platform *ocispecs.Platform) (*policytypes.SignatureInfo, error) {
+ verifier, err := getVerifier()
+ if err != nil {
+ return nil, err
+ }
+ return verifier.VerifyImage(ctx, provider, root, platform)
+ })
+}
+
+type imageSignatureVerifier func(context.Context, policyimage.ReferrersProvider, ocispecs.Descriptor, *ocispecs.Platform) (*policytypes.SignatureInfo, error)
+
+func verifySubjectSignatures(ctx context.Context, subjects []*Subject, verify imageSignatureVerifier) error {
+ for _, s := range subjects {
+ if err := s.verifyImageSignature(ctx, verify); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func (s *Subject) verifyImageSignature(ctx context.Context, verify imageSignatureVerifier) error {
+ provider, ok := s.Provider.(policyimage.ReferrersProvider)
+ if !ok {
+ return errors.Errorf("image provider for %s does not support referrers", s.inputRef)
+ }
+ si, err := verify(ctx, provider, s.rootDescriptor, s.Descriptor.Platform)
+ if err != nil {
+ var noSignature *policyverifier.NoSigChainError
+ if errors.As(err, &noSignature) {
+ return nil
+ }
+ return errors.Wrapf(err, "verify image signature for %s", s.inputRef)
+ }
+ if si == nil {
+ return errors.Errorf("signature verifier returned no verification result for %s", s.inputRef)
+ }
+ s.signature = signatureVerification(si)
+ return nil
+}
+
+// InputRef returns the user-supplied input string that produced this
+// subject. Used for diagnostics.
+func (s *Subject) InputRef() string {
+ if s == nil {
+ return ""
+ }
+ return s.inputRef
+}
+
+// AttestationManifest returns the attestation manifest descriptor associated
+// with this subject, or the zero descriptor if none was found (or the subject
+// was loaded from a local attestation file).
+func (s *Subject) AttestationManifest() ocispecs.Descriptor {
+ if s == nil {
+ return ocispecs.Descriptor{}
+ }
+ return s.attestManifest
+}
+
+// LoadSubjects parses a user-supplied input and returns one Subject per
+// manifest to replay. An image index expands into N subjects (one per child
+// manifest); a single image manifest or attestation file returns 1.
+//
+// Input forms:
+// - docker-image://][ — explicit remote reference.
+// - oci-layout://[:] — explicit OCI layout directory.
+// - — local attestation file (in-toto / DSSE).
+// - — treated as an OCI layout.
+// - — valid image reference (docker-image).
+func LoadSubjects(ctx context.Context, dockerCli command.Cli, builderName, input string) ([]*Subject, error) {
+ trimmed := strings.TrimSpace(input)
+ if trimmed == "" {
+ return nil, errors.New("empty subject input")
+ }
+
+ // Already-prefixed inputs go straight through.
+ if strings.HasPrefix(trimmed, dockerImagePrefix) || strings.HasPrefix(trimmed, ociLayoutPrefix) {
+ return loadImageSubjects(ctx, dockerCli, builderName, trimmed)
+ }
+
+ // A local filesystem path: regular file = attestation, directory =
+ // OCI layout.
+ fi, err := os.Stat(trimmed)
+ if err == nil {
+ if fi.IsDir() {
+ return loadImageSubjects(ctx, dockerCli, builderName, ociLayoutPrefix+trimmed)
+ }
+ return loadAttestationFileSubject(ctx, dockerCli, trimmed)
+ }
+ if isPathLike(trimmed) {
+ return nil, errors.WithStack(err)
+ }
+
+ // Fall through: treat as a remote image reference. Validation happens
+ // inside loadImageSubjects.
+ return loadImageSubjects(ctx, dockerCli, builderName, trimmed)
+}
+
+// loadAttestationFileSubject reads a local attestation file (in-toto
+// Statement JSON, DSSE envelope, or an intoto.jsonl line-per-envelope file)
+// and synthesizes a Subject whose Descriptor points at the predicate blob
+// inside an in-memory content.Provider.
+func loadAttestationFileSubject(ctx context.Context, dockerCli command.Cli, path string) ([]*Subject, error) {
+ dt, err := os.ReadFile(path)
+ if err != nil {
+ return nil, errors.WithStack(err)
+ }
+ dt = bytes.TrimSpace(dt)
+ if len(dt) == 0 {
+ return nil, errors.Errorf("attestation file %s is empty", path)
+ }
+
+ var verify artifactBundleVerifier
+ if dockerCli != nil {
+ getVerifier := policy.SignatureVerifier(confutil.NewConfig(dockerCli))
+ verify = func(ctx context.Context, dgst digest.Digest, bundle []byte) (*policytypes.SignatureInfo, error) {
+ verifier, err := getVerifier()
+ if err != nil {
+ return nil, err
+ }
+ return verifier.VerifyArtifact(ctx, dgst, bundle)
+ }
+ }
+ dt, signature, isBundle, err := verifySigstoreBundle(ctx, dt, path, verify)
+ if err != nil {
+ return nil, err
+ }
+
+ // .intoto.jsonl is line-delimited JSON statements. Every line is
+ // validated before the first provenance statement is picked, so a signed
+ // entry that cannot be verified is never skipped in favor of another
+ // entry.
+ if strings.HasSuffix(path, ".intoto.jsonl") {
+ var provenance *Subject
+ lineNum := 0
+ for line := range bytes.SplitSeq(dt, []byte("\n")) {
+ lineNum++
+ line = bytes.TrimSpace(line)
+ if len(line) == 0 {
+ continue
+ }
+ s, err := subjectFromAttestationBytes(line, path)
+ if err != nil {
+ return nil, errors.Wrapf(err, "line %d", lineNum)
+ }
+ if provenance == nil && isProvenancePredicateType(s.predicateType) {
+ provenance = s
+ }
+ }
+ if provenance == nil {
+ return nil, errors.Errorf("no SLSA provenance statement found in %s", path)
+ }
+ provenance.signature = signature
+ return []*Subject{provenance}, nil
+ }
+
+ s, err := subjectFromAttestationBytes(dt, path)
+ if err != nil {
+ return nil, err
+ }
+ if isBundle {
+ s.signature = signature
+ }
+ return []*Subject{s}, nil
+}
+
+type artifactBundleVerifier func(context.Context, digest.Digest, []byte) (*policytypes.SignatureInfo, error)
+
+func verifySigstoreBundle(ctx context.Context, dt []byte, inputRef string, verify artifactBundleVerifier) ([]byte, *SignatureVerification, bool, error) {
+ var bundle struct {
+ MediaType string `json:"mediaType"`
+ VerificationMaterial json.RawMessage `json:"verificationMaterial,omitempty"`
+ DSSEEnvelope struct {
+ Payload string `json:"payload"`
+ } `json:"dsseEnvelope"`
+ MessageSignature json.RawMessage `json:"messageSignature,omitempty"`
+ }
+ if err := json.Unmarshal(dt, &bundle); err != nil {
+ return dt, nil, false, nil
+ }
+ isBundle := strings.HasPrefix(bundle.MediaType, "application/vnd.dev.sigstore.bundle.") ||
+ (len(bundle.VerificationMaterial) > 0 && (bundle.DSSEEnvelope.Payload != "" || len(bundle.MessageSignature) > 0))
+ if !isBundle {
+ return dt, nil, false, nil
+ }
+ if verify == nil {
+ return nil, nil, true, ErrSignatureVerificationRequired(inputRef, "sigstore-bundle")
+ }
+ if bundle.DSSEEnvelope.Payload == "" {
+ return nil, nil, true, errors.Errorf("sigstore bundle %s does not contain a DSSE provenance envelope", inputRef)
+ }
+ payload, err := decodeDSSEPayload(bundle.DSSEEnvelope.Payload)
+ if err != nil {
+ return nil, nil, true, errors.Wrap(err, "decode sigstore DSSE payload")
+ }
+ var stmt struct {
+ Subject []struct {
+ Digest map[string]string `json:"digest"`
+ } `json:"subject"`
+ }
+ if err := json.Unmarshal(payload, &stmt); err != nil {
+ return nil, nil, true, errors.Wrap(err, "parse sigstore in-toto statement")
+ }
+ var lastErr error
+ for _, subject := range stmt.Subject {
+ dgst := preferredDigest(subject.Digest)
+ if dgst == "" {
+ continue
+ }
+ if err := dgst.Validate(); err != nil {
+ lastErr = errors.WithStack(err)
+ continue
+ }
+ si, err := verify(ctx, dgst, dt)
+ if err != nil {
+ lastErr = err
+ continue
+ }
+ if si == nil {
+ lastErr = errors.New("signature verifier returned no verification result")
+ continue
+ }
+ return payload, signatureVerification(si), true, nil
+ }
+ if lastErr != nil {
+ return nil, nil, true, errors.Wrap(lastErr, "verify sigstore bundle")
+ }
+ return nil, nil, true, errors.Errorf("sigstore bundle %s has no verifiable subject digest", inputRef)
+}
+
+func signatureVerification(si *policytypes.SignatureInfo) *SignatureVerification {
+ if si == nil {
+ return nil
+ }
+ out := &SignatureVerification{
+ Verified: true,
+ Type: si.SignatureType.String(),
+ Identity: si.Name(),
+ TrustRootLastUpdated: si.TrustRootStatus.LastUpdated,
+ TrustRootWarning: si.TrustRootStatus.Error,
+ }
+ if si.Signer != nil {
+ out.CertificateIssuer = si.Signer.CertificateIssuer
+ out.SubjectAlternativeName = si.Signer.SubjectAlternativeName
+ out.Issuer = si.Signer.Issuer
+ out.SourceRepositoryURI = si.Signer.SourceRepositoryURI
+ out.SourceRepositoryRef = si.Signer.SourceRepositoryRef
+ out.BuildSignerURI = si.Signer.BuildSignerURI
+ out.RunnerEnvironment = si.Signer.RunnerEnvironment
+ }
+ for _, ts := range si.Timestamps {
+ out.Timestamps = append(out.Timestamps, SignatureTimestamp{
+ Type: ts.Type,
+ URI: ts.URI,
+ Timestamp: ts.Timestamp,
+ })
+ }
+ return out
+}
+
+// subjectFromAttestationBytes parses a single in-toto Statement (or DSSE
+// envelope around one) and returns a Subject whose Descriptor addresses the
+// predicate bytes inside an in-memory content.Provider. Signed DSSE
+// envelopes or Sigstore bundles are rejected with
+// SignatureVerificationRequiredError — replay never silently accepts a
+// signed attestation without a trust anchor. Standalone Sigstore bundles are
+// verified and unwrapped by loadAttestationFileSubject before reaching here.
+func subjectFromAttestationBytes(dt []byte, inputRef string) (*Subject, error) {
+ // Sigstore bundle detection: a bundle carries mediaType
+ // "application/vnd.dev.sigstore.bundle.v0.3+json" (or a v0.X variant)
+ // and a non-empty verificationMaterial / dsseEnvelope pair. Detect
+ // conservatively before the DSSE probe.
+ var bundleProbe struct {
+ MediaType string `json:"mediaType"`
+ VerificationMaterial json.RawMessage `json:"verificationMaterial,omitempty"`
+ DSSEEnvelope json.RawMessage `json:"dsseEnvelope,omitempty"`
+ MessageSignature json.RawMessage `json:"messageSignature,omitempty"`
+ }
+ if err := json.Unmarshal(dt, &bundleProbe); err == nil {
+ if strings.HasPrefix(bundleProbe.MediaType, "application/vnd.dev.sigstore.bundle.") ||
+ (len(bundleProbe.VerificationMaterial) > 0 && (len(bundleProbe.DSSEEnvelope) > 0 || len(bundleProbe.MessageSignature) > 0)) {
+ return nil, ErrSignatureVerificationRequired(inputRef, "sigstore-bundle")
+ }
+ }
+
+ // DSSE envelopes have a "payload" field with base64 content plus an
+ // optional "signatures" array. Detect by shape rather than media type
+ // (files carry no MIME).
+ var env struct {
+ Payload string `json:"payload"`
+ PayloadType string `json:"payloadType"`
+ Signatures []struct {
+ Sig string `json:"sig"`
+ KeyID string `json:"keyid,omitempty"`
+ } `json:"signatures"`
+ }
+ if err := json.Unmarshal(dt, &env); err == nil && env.Payload != "" {
+ // A signed DSSE envelope carries at least one non-empty signature.
+ // Reject a bare signed envelope: unlike a Sigstore bundle, it carries
+ // no verification material from which replay can establish trust.
+ for _, sig := range env.Signatures {
+ if sig.Sig != "" {
+ return nil, ErrSignatureVerificationRequired(inputRef, "dsse")
+ }
+ }
+ decoded, err := decodeDSSEPayload(env.Payload)
+ if err != nil {
+ return nil, errors.Wrap(err, "failed to decode DSSE payload")
+ }
+ dt = decoded
+ }
+
+ var stmt struct {
+ Type string `json:"_type"`
+ PredicateType string `json:"predicateType"`
+ Predicate json.RawMessage `json:"predicate"`
+ }
+ if err := json.Unmarshal(dt, &stmt); err != nil {
+ return nil, errors.Errorf("%s is not an in-toto statement, DSSE envelope or Sigstore bundle", inputRef)
+ }
+ if stmt.PredicateType != "" && !strings.HasPrefix(stmt.Type, "https://in-toto.io/Statement/") {
+ return nil, errors.Errorf("%s is not an in-toto statement, DSSE envelope or Sigstore bundle", inputRef)
+ }
+ if stmt.PredicateType == "" {
+ // A bare provenance predicate, as printed by
+ // `imagetools inspect --format '{{json .Provenance.SLSA}}'`.
+ predType, ok := bareProvenancePredicateType(dt)
+ if !ok {
+ return nil, errors.Errorf("attestation file %s has no predicateType", inputRef)
+ }
+ stmt.PredicateType = predType
+ stmt.Predicate = dt
+ }
+
+ predBytes := []byte(stmt.Predicate)
+ dgst := digest.FromBytes(predBytes)
+ buf := contentutil.NewBuffer()
+ if err := content.WriteBlob(context.Background(), buf, dgst.String(), bytes.NewReader(predBytes), ocispecs.Descriptor{Digest: dgst, Size: int64(len(predBytes))}); err != nil {
+ return nil, errors.WithStack(err)
+ }
+
+ desc := ocispecs.Descriptor{
+ MediaType: "application/json",
+ Digest: dgst,
+ Size: int64(len(predBytes)),
+ }
+ return &Subject{
+ Descriptor: desc,
+ Provider: buf,
+ inputRef: inputRef,
+ kind: subjectKindAttestationFile,
+ predicateType: stmt.PredicateType,
+ }, nil
+}
+
+// decodeDSSEPayload base64-decodes the DSSE payload. Tries the standard
+// Base64 alphabet first, then URL alphabet (some implementations use the URL
+// variant for JSON-in-JSON safety).
+func decodeDSSEPayload(payload string) ([]byte, error) {
+ if dt, err := base64.StdEncoding.DecodeString(payload); err == nil {
+ return dt, nil
+ }
+ if dt, err := base64.URLEncoding.DecodeString(payload); err == nil {
+ return dt, nil
+ }
+ return nil, errors.New("payload is neither std nor url base64")
+}
+
+// loadImageSubjects resolves a remote ref or an oci-layout://[:]
+// input into one Subject per child manifest, fanning out only if the
+// resolved descriptor is itself an index. Both shapes use the same
+// util/imagetools.Resolver path as `imagetools inspect`; the resolver
+// internally dispatches on the location shape (see
+// util/imagetools/inspect.go:80).
+func loadImageSubjects(ctx context.Context, dockerCli command.Cli, builderName, input string) ([]*Subject, error) {
+ var (
+ resolver *imagetools.Resolver
+ )
+
+ if strings.HasPrefix(input, ociLayoutPrefix) {
+ // oci-layout:// does not need a builder / auth provider. Drive
+ // Resolve + Fetcher through a default resolver backed purely by
+ // the local layout store.
+ resolver = imagetools.New(imagetools.Opt{})
+ } else {
+ trimmed := strings.TrimPrefix(input, dockerImagePrefix)
+ if _, err := reference.ParseNormalizedNamed(trimmed); err != nil {
+ return nil, errors.Wrapf(err, "invalid image reference %q", trimmed)
+ }
+ input = trimmed
+
+ if dockerCli == nil {
+ return nil, errors.New("docker CLI is required to resolve remote image subjects")
+ }
+ b, err := builder.New(dockerCli, builder.WithName(builderName))
+ if err != nil {
+ return nil, err
+ }
+ imageOpt, err := b.ImageOpt()
+ if err != nil {
+ return nil, err
+ }
+ resolver = imagetools.New(imageOpt)
+ }
+
+ _, desc, err := resolver.Resolve(ctx, input)
+ if err != nil {
+ return nil, errors.Wrapf(err, "failed to resolve %s", input)
+ }
+
+ fetcher, err := resolver.Fetcher(ctx, input)
+ if err != nil {
+ return nil, err
+ }
+ loc, err := imagetools.ParseLocation(input)
+ if err != nil {
+ return nil, err
+ }
+ provider := &imageSubjectProvider{
+ Provider: contentutil.FromFetcher(fetcher),
+ resolver: resolver,
+ location: loc,
+ }
+
+ return fanOutSubjects(ctx, provider, desc, input)
+}
+
+// imageSubjectProvider combines the ordinary content provider used to read
+// manifests and blobs with imagetools' registry/OCI-layout referrer lookup.
+// policy-helpers can therefore verify the exact signature chain without a
+// second resolver implementation in replay.
+type imageSubjectProvider struct {
+ content.Provider
+ resolver *imagetools.Resolver
+ location *imagetools.Location
+}
+
+func (p *imageSubjectProvider) FetchReferrers(ctx context.Context, dgst digest.Digest, opts ...remotes.FetchReferrersOpt) ([]ocispecs.Descriptor, error) {
+ return p.resolver.FetchReferrers(ctx, p.location, dgst, opts...)
+}
+
+// fanOutSubjects walks an OCI index (if the root descriptor is an index) and
+// returns one Subject per non-attestation child manifest. When the root is
+// itself a manifest, a single subject is returned.
+func fanOutSubjects(ctx context.Context, provider content.Provider, root ocispecs.Descriptor, inputRef string) ([]*Subject, error) {
+ switch root.MediaType {
+ case ocispecs.MediaTypeImageIndex, images.MediaTypeDockerSchema2ManifestList:
+ dt, err := imagetools.ReadBlobVerified(ctx, provider, root)
+ if err != nil {
+ return nil, err
+ }
+ var idx ocispecs.Index
+ if err := json.Unmarshal(dt, &idx); err != nil {
+ return nil, errors.WithStack(err)
+ }
+
+ // Buildx snapshot layouts carry their own artifactType and reach
+ // the attestation chain via the index's `subject`, not through a
+ // sibling attestation manifest. Loading them as subjects requires
+ // a different walk that is not yet implemented.
+ if idx.ArtifactType == ArtifactTypeSnapshot {
+ return nil, errors.Errorf("%s is a buildx snapshot layout; loading snapshots as subjects is not yet supported", inputRef)
+ }
+
+ // Partition entries: attestation manifests (via the Docker ref
+ // annotation) vs. real image manifests. The attestation manifest
+ // for a given subject carries `vnd.docker.reference.digest` /
+ // `com.docker.reference.digest` pointing at the subject's digest.
+ attestFor := map[digest.Digest]ocispecs.Descriptor{}
+ var imageManifests []ocispecs.Descriptor
+ for _, m := range idx.Manifests {
+ if ref := attestationReferenceDigest(m); ref != "" {
+ if d, err := digest.Parse(ref); err == nil {
+ if _, ok := attestFor[d]; !ok {
+ attestFor[d] = m
+ }
+ continue
+ }
+ }
+ imageManifests = append(imageManifests, m)
+ }
+
+ out := make([]*Subject, 0, len(imageManifests))
+ for _, m := range imageManifests {
+ s := &Subject{
+ Descriptor: m,
+ Provider: provider,
+ inputRef: inputRef,
+ kind: subjectKindImage,
+ rootDescriptor: root,
+ }
+ if att, ok := attestFor[m.Digest]; ok {
+ s.attestManifest = att
+ }
+ out = append(out, s)
+ }
+ if len(out) == 0 {
+ return nil, errors.Errorf("index %s has no image manifests", root.Digest)
+ }
+ return out, nil
+ case ocispecs.MediaTypeImageManifest, images.MediaTypeDockerSchema2Manifest:
+ return []*Subject{{
+ Descriptor: root,
+ Provider: provider,
+ inputRef: inputRef,
+ kind: subjectKindImage,
+ rootDescriptor: root,
+ }}, nil
+ case images.MediaTypeDockerSchema1Manifest:
+ return nil, errors.Errorf("%s is a legacy Docker schema1 image, which cannot carry provenance", inputRef)
+ default:
+ return nil, errors.Errorf("unsupported root media type %q", root.MediaType)
+ }
+}
+
+// isPathLike reports whether a subject input is clearly meant as a local file
+// rather than an image reference.
+func isPathLike(input string) bool {
+ return filepath.IsAbs(input) || strings.HasPrefix(input, "./") || strings.HasPrefix(input, "../") ||
+ strings.HasSuffix(input, ".json") || strings.HasSuffix(input, ".jsonl")
+}
+
+// bareProvenancePredicateType detects a SLSA provenance predicate that is not
+// wrapped in an in-toto statement.
+func bareProvenancePredicateType(dt []byte) (string, bool) {
+ var probe struct {
+ BuildDefinition json.RawMessage `json:"buildDefinition"`
+ BuildType string `json:"buildType"`
+ Invocation json.RawMessage `json:"invocation"`
+ }
+ if err := json.Unmarshal(dt, &probe); err != nil {
+ return "", false
+ }
+ switch {
+ case len(probe.BuildDefinition) > 0:
+ return slsa1.PredicateSLSAProvenance, true
+ case probe.BuildType != "" && len(probe.Invocation) > 0:
+ return slsa02.PredicateSLSAProvenance, true
+ }
+ return "", false
+}
+
+// attestationReferenceDigest returns the subject digest recorded on a BuildKit
+// attestation manifest, or "" for an unrelated descriptor. Selection exactly
+// matches policy-helpers' signature-chain resolver: correct reference type,
+// correct digest, and first matching descriptor wins in index order.
+func attestationReferenceDigest(d ocispecs.Descriptor) string {
+ if d.Annotations == nil || d.Annotations[attestation.DockerAnnotationReferenceType] != attestation.DockerAnnotationReferenceTypeDefault {
+ return ""
+ }
+ return d.Annotations[attestation.DockerAnnotationReferenceDigest]
+}
+
+// Predicate locates and parses the SLSA v1 provenance predicate attached to
+// Descriptor via Provider. Returns UnsupportedPredicateError on a non-v1
+// predicateType and NoProvenanceError when none is found.
+func (s *Subject) Predicate(ctx context.Context) (*Predicate, error) {
+ if s == nil {
+ return nil, errors.New("nil subject")
+ }
+
+ switch s.kind {
+ case subjectKindAttestationFile:
+ dt, err := content.ReadBlob(ctx, s.Provider, s.Descriptor)
+ if err != nil {
+ return nil, errors.WithStack(err)
+ }
+ return decodeProvenancePredicate(dt, s.predicateType)
+
+ case subjectKindImage:
+ if s.attestManifest.Digest == "" {
+ if s.rootDescriptor.Digest == s.Descriptor.Digest {
+ // BuildKit attaches provenance through the image index, so a
+ // platform manifest referenced directly has none.
+ return nil, ErrNoProvenanceForManifest(s.inputRef)
+ }
+ return nil, ErrNoProvenance(s.inputRef)
+ }
+ predDt, predType, err := imagetools.ReadProvenancePredicate(ctx, s.Provider, s.attestManifest)
+ if err != nil {
+ return nil, err
+ }
+ if predType == "" {
+ return nil, ErrNoProvenance(s.inputRef)
+ }
+ return decodeProvenancePredicate(predDt, predType)
+ }
+
+ return nil, ErrUnsupportedSubject("unknown")
+}
+
+func isProvenancePredicateType(predType string) bool {
+ return predType == slsa1.PredicateSLSAProvenance || predType == slsa02.PredicateSLSAProvenance
+}
+
+// decodeProvenancePredicate unmarshals a provenance predicate in its
+// native form. SLSA v1 is used as-is; SLSA v0.2 is converted to v1 via
+// provenancetypes.ProvenancePredicateSLSA02.ConvertToSLSA1 so the rest of
+// the replay code only has to understand one shape.
+func decodeProvenancePredicate(dt []byte, predType string) (*Predicate, error) {
+ switch predType {
+ case slsa1.PredicateSLSAProvenance:
+ var pred Predicate
+ if err := json.Unmarshal(dt, &pred); err != nil {
+ return nil, errors.Wrap(err, "failed to unmarshal SLSA v1 predicate")
+ }
+ return &pred, nil
+ case slsa02.PredicateSLSAProvenance:
+ var old provenancetypes.ProvenancePredicateSLSA02
+ if err := json.Unmarshal(dt, &old); err != nil {
+ return nil, errors.Wrap(err, "failed to unmarshal SLSA v0.2 predicate")
+ }
+ converted := old.ConvertToSLSA1()
+ pred := Predicate(*converted)
+ return &pred, nil
+ }
+ return nil, ErrUnsupportedPredicate(predType)
+}
diff --git a/replay/subject_test.go b/replay/subject_test.go
new file mode 100644
index 000000000000..d33347aabd21
--- /dev/null
+++ b/replay/subject_test.go
@@ -0,0 +1,750 @@
+package replay
+
+import (
+ "bytes"
+ "context"
+ "encoding/base64"
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "sort"
+ "testing"
+ "time"
+
+ "github.com/containerd/containerd/v2/core/content"
+ contentlocal "github.com/containerd/containerd/v2/plugins/content/local"
+ "github.com/moby/buildkit/client/ociindex"
+ "github.com/moby/buildkit/util/attestation"
+ "github.com/moby/buildkit/util/contentutil"
+ policyverifier "github.com/moby/policy-helpers"
+ policyimage "github.com/moby/policy-helpers/image"
+ policytypes "github.com/moby/policy-helpers/types"
+ "github.com/opencontainers/go-digest"
+ ocispecs "github.com/opencontainers/image-spec/specs-go/v1"
+ "github.com/pkg/errors"
+ "github.com/sigstore/sigstore-go/pkg/fulcio/certificate"
+ "github.com/stretchr/testify/require"
+)
+
+// TestSubjectPredicateAcceptsSLSA02 asserts that a SLSA v0.2 attestation
+// file is accepted and converted to the v1 shape used internally.
+func TestSubjectPredicateAcceptsSLSA02(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "provenance.intoto.json")
+
+ stmt := map[string]any{
+ "_type": "https://in-toto.io/Statement/v0.1",
+ "predicateType": "https://slsa.dev/provenance/v0.2",
+ "subject": []any{},
+ "predicate": map[string]any{
+ "builder": map[string]string{"id": "buildkit"},
+ "buildType": "https://mobyproject.org/buildkit@v1",
+ "invocation": map[string]any{
+ "configSource": map[string]any{"uri": "https://example.com/dockerfile"},
+ "parameters": map[string]any{},
+ "environment": map[string]any{"platform": "linux/amd64"},
+ },
+ },
+ }
+ dt, err := json.Marshal(stmt)
+ require.NoError(t, err)
+ require.NoError(t, os.WriteFile(path, dt, 0644))
+
+ subjects, err := LoadSubjects(context.Background(), nil, "", path)
+ require.NoError(t, err)
+ require.Len(t, subjects, 1)
+ require.True(t, subjects[0].IsAttestationFile())
+
+ _, err = subjects[0].Predicate(context.Background())
+ require.NoError(t, err)
+}
+
+// TestSubjectPredicateRejectsUnknown asserts that a predicateType outside
+// the SLSA v1 / v0.2 set is rejected with UnsupportedPredicateError.
+// TestSubjectPredicateAcceptsBarePredicate asserts that a provenance
+// predicate that is not wrapped in an in-toto statement, as printed by
+// `imagetools inspect --format '{{json .Provenance.SLSA}}'`, is accepted.
+func TestSubjectPredicateAcceptsBarePredicate(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ pred map[string]any
+ }{
+ {
+ name: "slsa-v1",
+ pred: map[string]any{
+ "buildDefinition": map[string]any{
+ "buildType": "https://github.com/moby/buildkit/blob/master/docs/attestations/slsa-definitions.md",
+ "externalParameters": map[string]any{"configSource": map[string]any{"uri": "https://example.com/repo.git"}},
+ },
+ "runDetails": map[string]any{"builder": map[string]any{"id": ""}},
+ },
+ },
+ {
+ name: "slsa-v0.2",
+ pred: map[string]any{
+ "builder": map[string]string{"id": "buildkit"},
+ "buildType": "https://mobyproject.org/buildkit@v1",
+ "invocation": map[string]any{
+ "configSource": map[string]any{"uri": "https://example.com/repo.git"},
+ },
+ },
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "provenance.json")
+ dt, err := json.Marshal(tc.pred)
+ require.NoError(t, err)
+ require.NoError(t, os.WriteFile(path, dt, 0o644))
+
+ subjects, err := LoadSubjects(context.Background(), nil, "", path)
+ require.NoError(t, err)
+ require.Len(t, subjects, 1)
+ pred, err := subjects[0].Predicate(context.Background())
+ require.NoError(t, err)
+ require.Equal(t, "https://example.com/repo.git", pred.ConfigSource().URI)
+ })
+ }
+}
+
+func TestLoadSubjectsJSONL(t *testing.T) {
+ provenance, err := json.Marshal(map[string]any{
+ "_type": "https://in-toto.io/Statement/v1",
+ "predicateType": "https://slsa.dev/provenance/v1",
+ "subject": []any{},
+ "predicate": map[string]any{
+ "buildDefinition": map[string]any{
+ "externalParameters": map[string]any{"configSource": map[string]any{"uri": "https://example.com/repo.git"}},
+ },
+ },
+ })
+ require.NoError(t, err)
+ sbom, err := json.Marshal(map[string]any{
+ "_type": "https://in-toto.io/Statement/v1",
+ "predicateType": "https://spdx.dev/Document",
+ "subject": []any{},
+ "predicate": map[string]any{},
+ })
+ require.NoError(t, err)
+ signed, err := json.Marshal(map[string]any{
+ "payloadType": "application/vnd.in-toto+json",
+ "payload": base64.StdEncoding.EncodeToString(provenance),
+ "signatures": []map[string]string{{"sig": "MEUCIQDinvalid==", "keyid": "test-key"}},
+ })
+ require.NoError(t, err)
+
+ write := func(t *testing.T, lines ...[]byte) string {
+ path := filepath.Join(t.TempDir(), "attestations.intoto.jsonl")
+ require.NoError(t, os.WriteFile(path, bytes.Join(lines, []byte("\n")), 0o644))
+ return path
+ }
+
+ t.Run("skips-other-predicates", func(t *testing.T) {
+ subjects, err := LoadSubjects(context.Background(), nil, "", write(t, sbom, provenance))
+ require.NoError(t, err)
+ require.Len(t, subjects, 1)
+ pred, err := subjects[0].Predicate(context.Background())
+ require.NoError(t, err)
+ require.Equal(t, "https://example.com/repo.git", pred.ConfigSource().URI)
+ })
+
+ t.Run("signed-entry-does-not-fall-back", func(t *testing.T) {
+ _, err := LoadSubjects(context.Background(), nil, "", write(t, signed, provenance))
+ var sigErr *SignatureVerificationRequiredError
+ require.ErrorAs(t, err, &sigErr)
+ })
+
+ t.Run("signed-entry-after-unsigned", func(t *testing.T) {
+ _, err := LoadSubjects(context.Background(), nil, "", write(t, provenance, signed))
+ var sigErr *SignatureVerificationRequiredError
+ require.ErrorAs(t, err, &sigErr)
+ require.ErrorContains(t, err, "line 2")
+ })
+
+ t.Run("invalid-line-after-provenance", func(t *testing.T) {
+ _, err := LoadSubjects(context.Background(), nil, "", write(t, provenance, []byte("not json")))
+ require.ErrorContains(t, err, "line 2")
+ })
+
+ t.Run("signed-only", func(t *testing.T) {
+ _, err := LoadSubjects(context.Background(), nil, "", write(t, signed))
+ var sigErr *SignatureVerificationRequiredError
+ require.ErrorAs(t, err, &sigErr)
+ })
+}
+
+func TestLoadSubjectsInputErrors(t *testing.T) {
+ dir := t.TempDir()
+
+ _, err := LoadSubjects(context.Background(), nil, "", filepath.Join(dir, "missing.json"))
+ require.ErrorIs(t, err, os.ErrNotExist)
+
+ notStatement := filepath.Join(dir, "array.json")
+ require.NoError(t, os.WriteFile(notStatement, []byte(`[1, 2]`), 0o644))
+ _, err = LoadSubjects(context.Background(), nil, "", notStatement)
+ require.EqualError(t, err, notStatement+" is not an in-toto statement, DSSE envelope or Sigstore bundle")
+
+ // An object with a predicate but without the in-toto statement type is
+ // not a statement.
+ untyped := filepath.Join(dir, "untyped.json")
+ require.NoError(t, os.WriteFile(untyped, []byte(`{"predicateType":"https://slsa.dev/provenance/v1","predicate":{}}`), 0o644))
+ _, err = LoadSubjects(context.Background(), nil, "", untyped)
+ require.EqualError(t, err, untyped+" is not an in-toto statement, DSSE envelope or Sigstore bundle")
+
+ noType := filepath.Join(dir, "object.json")
+ require.NoError(t, os.WriteFile(noType, []byte(`{"foo": "bar"}`), 0o644))
+ _, err = LoadSubjects(context.Background(), nil, "", noType)
+ require.ErrorContains(t, err, "has no predicateType")
+}
+
+func TestSubjectPredicateRejectsUnknown(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "provenance.intoto.json")
+
+ stmt := map[string]any{
+ "_type": "https://in-toto.io/Statement/v0.1",
+ "predicateType": "https://example.com/custom/v1",
+ "subject": []any{},
+ "predicate": map[string]any{},
+ }
+ dt, err := json.Marshal(stmt)
+ require.NoError(t, err)
+ require.NoError(t, os.WriteFile(path, dt, 0644))
+
+ subjects, err := LoadSubjects(context.Background(), nil, "", path)
+ require.NoError(t, err)
+ _, err = subjects[0].Predicate(context.Background())
+ require.Error(t, err)
+ var unsup *UnsupportedPredicateError
+ require.ErrorAs(t, err, &unsup)
+ require.Equal(t, "https://example.com/custom/v1", unsup.PredicateType)
+}
+
+// TestSubjectPredicateAttestationFileSLSA1 asserts that an unsigned DSSE-less
+// in-toto Statement carrying a SLSA v1 predicate round-trips through
+// LoadSubjects + Predicate without error.
+func TestSubjectPredicateAttestationFileSLSA1(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "provenance.intoto.json")
+ stmt := map[string]any{
+ "_type": "https://in-toto.io/Statement/v0.1",
+ "predicateType": "https://slsa.dev/provenance/v1",
+ "subject": []any{},
+ "predicate": map[string]any{
+ "buildDefinition": map[string]any{
+ "externalParameters": map[string]any{
+ "request": map[string]any{
+ "frontend": "dockerfile.v0",
+ },
+ },
+ },
+ },
+ }
+ dt, err := json.Marshal(stmt)
+ require.NoError(t, err)
+ require.NoError(t, os.WriteFile(path, dt, 0644))
+
+ subjects, err := LoadSubjects(context.Background(), nil, "", path)
+ require.NoError(t, err)
+ require.Len(t, subjects, 1)
+
+ pred, err := subjects[0].Predicate(context.Background())
+ require.NoError(t, err)
+ require.Equal(t, "dockerfile.v0", pred.Frontend())
+}
+
+// TestSubjectPredicateRejectsSignedDSSE asserts that a DSSE envelope with
+// non-empty signatures is rejected with SignatureVerificationRequiredError —
+// a bare DSSE envelope carries no verification material, so replay never
+// silently accepts it.
+func TestSubjectPredicateRejectsSignedDSSE(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "signed.dsse.json")
+
+ inner := map[string]any{
+ "_type": "https://in-toto.io/Statement/v0.1",
+ "predicateType": "https://slsa.dev/provenance/v1",
+ "subject": []any{},
+ "predicate": map[string]any{},
+ }
+ innerDt, err := json.Marshal(inner)
+ require.NoError(t, err)
+
+ env := map[string]any{
+ "payload": base64.StdEncoding.EncodeToString(innerDt),
+ "payloadType": "application/vnd.in-toto+json",
+ "signatures": []map[string]string{
+ {"sig": "MEUCIQDstubbedsignaturebytes==", "keyid": "test-key"},
+ },
+ }
+ dt, err := json.Marshal(env)
+ require.NoError(t, err)
+ require.NoError(t, os.WriteFile(path, dt, 0644))
+
+ _, err = LoadSubjects(context.Background(), nil, "", path)
+ require.Error(t, err)
+ var sig *SignatureVerificationRequiredError
+ require.ErrorAs(t, err, &sig)
+ require.Equal(t, path, sig.Source)
+ require.Equal(t, "dsse", sig.Envelope)
+}
+
+// TestSubjectPredicateRejectsSigstoreBundle asserts that a Sigstore bundle
+// shape is rejected without signature verification support.
+func TestSubjectPredicateRejectsSigstoreBundle(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "bundle.sigstore.json")
+
+ bundle := map[string]any{
+ "mediaType": "application/vnd.dev.sigstore.bundle.v0.3+json",
+ "verificationMaterial": map[string]any{
+ "tlogEntries": []any{},
+ },
+ "dsseEnvelope": map[string]any{
+ "payload": base64.StdEncoding.EncodeToString([]byte(`{}`)),
+ "payloadType": "application/vnd.in-toto+json",
+ "signatures": []any{},
+ },
+ }
+ dt, err := json.Marshal(bundle)
+ require.NoError(t, err)
+ require.NoError(t, os.WriteFile(path, dt, 0644))
+
+ _, err = LoadSubjects(context.Background(), nil, "", path)
+ require.Error(t, err)
+ var sig *SignatureVerificationRequiredError
+ require.ErrorAs(t, err, &sig)
+ require.Equal(t, "sigstore-bundle", sig.Envelope)
+}
+
+func TestVerifySigstoreBundle(t *testing.T) {
+ artifactDigest := digest.FromString("signed artifact")
+ statement := map[string]any{
+ "_type": "https://in-toto.io/Statement/v1",
+ "predicateType": "https://slsa.dev/provenance/v1",
+ "subject": []any{map[string]any{
+ "name": "buildx",
+ "digest": map[string]string{
+ artifactDigest.Algorithm().String(): artifactDigest.Encoded(),
+ },
+ }},
+ "predicate": map[string]any{},
+ }
+ statementBytes, err := json.Marshal(statement)
+ require.NoError(t, err)
+ bundle := map[string]any{
+ "mediaType": "application/vnd.dev.sigstore.bundle.v0.3+json",
+ "verificationMaterial": map[string]any{"tlogEntries": []any{}},
+ "dsseEnvelope": map[string]any{
+ "payload": base64.StdEncoding.EncodeToString(statementBytes),
+ "payloadType": "application/vnd.in-toto+json",
+ "signatures": []any{map[string]string{"sig": "verified-by-test-double"}},
+ },
+ }
+ bundleBytes, err := json.Marshal(bundle)
+ require.NoError(t, err)
+
+ verifiedAt := time.Date(2026, time.September, 18, 12, 0, 0, 0, time.UTC)
+ trustRootUpdatedAt := verifiedAt.Add(-time.Hour)
+ var verifierCalled bool
+ payload, signature, isBundle, err := verifySigstoreBundle(context.Background(), bundleBytes, "buildx.sigstore.json", func(ctx context.Context, gotDigest digest.Digest, gotBundle []byte) (*policytypes.SignatureInfo, error) {
+ verifierCalled = true
+ require.Equal(t, artifactDigest, gotDigest)
+ require.Equal(t, bundleBytes, gotBundle)
+ return &policytypes.SignatureInfo{
+ Kind: policytypes.KindSelfSignedGithubRepo,
+ SignatureType: policytypes.SignatureBundleV03,
+ Signer: &certificate.Summary{
+ CertificateIssuer: "CN=sigstore-intermediate,O=sigstore.dev",
+ SubjectAlternativeName: "https://github.com/docker/buildx/.github/workflows/release.yml@refs/tags/v0.37.1",
+ Extensions: certificate.Extensions{
+ Issuer: "https://token.actions.githubusercontent.com",
+ SourceRepositoryURI: "https://github.com/docker/buildx",
+ SourceRepositoryRef: "refs/tags/v0.37.1",
+ BuildSignerURI: "https://github.com/docker/buildx/.github/workflows/release.yml",
+ RunnerEnvironment: "github-hosted",
+ },
+ },
+ Timestamps: []policytypes.TimestampVerificationResult{{
+ Type: "Tlog",
+ URI: "https://rekor.sigstore.dev",
+ Timestamp: verifiedAt,
+ }},
+ TrustRootStatus: policytypes.TrustRootStatus{LastUpdated: &trustRootUpdatedAt},
+ }, nil
+ })
+ require.NoError(t, err)
+ require.True(t, verifierCalled)
+ require.True(t, isBundle)
+ require.JSONEq(t, string(statementBytes), string(payload))
+ require.NotNil(t, signature)
+ require.True(t, signature.Verified)
+ require.Equal(t, "Sigstore Bundle", signature.Type)
+ require.Equal(t, "GitHub Self-Signed (docker/buildx)", signature.Identity)
+ require.Equal(t, "refs/tags/v0.37.1", signature.SourceRepositoryRef)
+ require.Equal(t, verifiedAt, signature.Timestamps[0].Timestamp)
+ require.Equal(t, trustRootUpdatedAt, *signature.TrustRootLastUpdated)
+
+ payload, signature, isBundle, err = verifySigstoreBundle(context.Background(), bundleBytes, "buildx.sigstore.json", func(context.Context, digest.Digest, []byte) (*policytypes.SignatureInfo, error) {
+ return nil, errors.New("invalid signature")
+ })
+ require.ErrorContains(t, err, "verify sigstore bundle")
+ require.ErrorContains(t, err, "invalid signature")
+ require.True(t, isBundle)
+ require.Nil(t, payload)
+ require.Nil(t, signature)
+
+ payload, signature, isBundle, err = verifySigstoreBundle(context.Background(), bundleBytes, "buildx.sigstore.json", func(context.Context, digest.Digest, []byte) (*policytypes.SignatureInfo, error) {
+ return nil, nil
+ })
+ require.ErrorContains(t, err, "signature verifier returned no verification result")
+ require.True(t, isBundle)
+ require.Nil(t, payload)
+ require.Nil(t, signature)
+
+ missingSubjectStatement, err := json.Marshal(map[string]any{
+ "_type": "https://in-toto.io/Statement/v1",
+ "predicateType": "https://slsa.dev/provenance/v1",
+ "subject": []any{},
+ "predicate": map[string]any{},
+ })
+ require.NoError(t, err)
+ bundle["dsseEnvelope"].(map[string]any)["payload"] = base64.StdEncoding.EncodeToString(missingSubjectStatement)
+ missingSubjectBundle, err := json.Marshal(bundle)
+ require.NoError(t, err)
+ payload, signature, isBundle, err = verifySigstoreBundle(context.Background(), missingSubjectBundle, "buildx.sigstore.json", func(context.Context, digest.Digest, []byte) (*policytypes.SignatureInfo, error) {
+ require.Fail(t, "verifier must not be called without a subject digest")
+ return nil, nil
+ })
+ require.ErrorContains(t, err, "no verifiable subject digest")
+ require.True(t, isBundle)
+ require.Nil(t, payload)
+ require.Nil(t, signature)
+}
+
+func TestVerifyImageSignature(t *testing.T) {
+ platform := &ocispecs.Platform{OS: "linux", Architecture: "amd64"}
+ root := ocispecs.Descriptor{
+ MediaType: ocispecs.MediaTypeImageIndex,
+ Digest: digest.FromString("image index"),
+ }
+ subject := &Subject{
+ Descriptor: ocispecs.Descriptor{
+ MediaType: ocispecs.MediaTypeImageManifest,
+ Digest: digest.FromString("image manifest"),
+ Platform: platform,
+ },
+ Provider: &imageSubjectProvider{Provider: contentutil.NewBuffer()},
+ inputRef: "docker/buildx-bin:0.37.1",
+ kind: subjectKindImage,
+ attestManifest: ocispecs.Descriptor{
+ Digest: digest.FromString("attestation manifest"),
+ },
+ rootDescriptor: root,
+ }
+ verifiedAt := time.Date(2026, time.September, 18, 12, 0, 0, 0, time.UTC)
+ err := subject.verifyImageSignature(context.Background(), func(_ context.Context, provider policyimage.ReferrersProvider, gotRoot ocispecs.Descriptor, gotPlatform *ocispecs.Platform) (*policytypes.SignatureInfo, error) {
+ require.Same(t, subject.Provider, provider)
+ require.Equal(t, root, gotRoot)
+ require.Equal(t, platform, gotPlatform)
+ return &policytypes.SignatureInfo{
+ Kind: policytypes.KindDockerGithubBuilder,
+ SignatureType: policytypes.SignatureBundleV03,
+ Signer: &certificate.Summary{
+ CertificateIssuer: "CN=sigstore-intermediate,O=sigstore.dev",
+ Extensions: certificate.Extensions{
+ SourceRepositoryURI: "https://github.com/docker/buildx",
+ SourceRepositoryRef: "refs/tags/v0.37.1",
+ },
+ },
+ Timestamps: []policytypes.TimestampVerificationResult{{
+ Type: "Tlog",
+ Timestamp: verifiedAt,
+ }},
+ }, nil
+ })
+ require.NoError(t, err)
+ require.NotNil(t, subject.Signature())
+ require.True(t, subject.Signature().Verified)
+ require.Equal(t, "Sigstore Bundle", subject.Signature().Type)
+ require.Equal(t, "refs/tags/v0.37.1", subject.Signature().SourceRepositoryRef)
+ require.Equal(t, verifiedAt, subject.Signature().Timestamps[0].Timestamp)
+
+ unsigned := *subject
+ unsigned.signature = nil
+ err = unsigned.verifyImageSignature(context.Background(), func(context.Context, policyimage.ReferrersProvider, ocispecs.Descriptor, *ocispecs.Platform) (*policytypes.SignatureInfo, error) {
+ return nil, errors.WithStack(&policyverifier.NoSigChainError{Target: root.Digest, HasAttestation: true})
+ })
+ require.NoError(t, err)
+ require.Nil(t, unsigned.Signature())
+
+ invalid := *subject
+ invalid.signature = nil
+ err = invalid.verifyImageSignature(context.Background(), func(context.Context, policyimage.ReferrersProvider, ocispecs.Descriptor, *ocispecs.Platform) (*policytypes.SignatureInfo, error) {
+ return nil, errors.New("invalid image signature")
+ })
+ require.ErrorContains(t, err, "verify image signature")
+ require.ErrorContains(t, err, "invalid image signature")
+ require.Nil(t, invalid.Signature())
+
+ second := *subject
+ second.signature = nil
+ second.Descriptor = subject.Descriptor
+ second.Descriptor.Platform = &ocispecs.Platform{OS: "linux", Architecture: "arm64"}
+ var verifiedPlatforms []string
+ err = verifySubjectSignatures(context.Background(), []*Subject{subject, &second}, func(_ context.Context, _ policyimage.ReferrersProvider, _ ocispecs.Descriptor, platform *ocispecs.Platform) (*policytypes.SignatureInfo, error) {
+ verifiedPlatforms = append(verifiedPlatforms, platform.Architecture)
+ return &policytypes.SignatureInfo{Kind: policytypes.KindUntrusted, SignatureType: policytypes.SignatureBundleV03}, nil
+ })
+ require.NoError(t, err)
+ require.Equal(t, []string{"amd64", "arm64"}, verifiedPlatforms)
+ require.NotNil(t, second.Signature())
+}
+
+// TestSubjectPredicateAcceptsUnsignedDSSE asserts that a DSSE envelope with
+// an empty (or missing) signatures array is still accepted — the rejection
+// is gated on actual signatures being present.
+func TestSubjectPredicateAcceptsUnsignedDSSE(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "unsigned.dsse.json")
+
+ inner := map[string]any{
+ "_type": "https://in-toto.io/Statement/v0.1",
+ "predicateType": "https://slsa.dev/provenance/v1",
+ "subject": []any{},
+ "predicate": map[string]any{},
+ }
+ innerDt, err := json.Marshal(inner)
+ require.NoError(t, err)
+
+ env := map[string]any{
+ "payload": base64.StdEncoding.EncodeToString(innerDt),
+ "payloadType": "application/vnd.in-toto+json",
+ "signatures": []any{},
+ }
+ dt, err := json.Marshal(env)
+ require.NoError(t, err)
+ require.NoError(t, os.WriteFile(path, dt, 0644))
+
+ subjects, err := LoadSubjects(context.Background(), nil, "", path)
+ require.NoError(t, err)
+ require.Len(t, subjects, 1)
+ require.True(t, subjects[0].IsAttestationFile())
+}
+
+// TestFanOutSubjectsVerifiesIndexDigest asserts that an image index whose
+// stored content does not match its digest is rejected, so the attestation
+// manifest is never selected from unverified content.
+func TestFanOutSubjectsVerifiesIndexDigest(t *testing.T) {
+ ctx := context.Background()
+ dir := t.TempDir()
+ store, err := contentlocal.NewStore(dir)
+ require.NoError(t, err)
+
+ mfst := putManifest(ctx, t, store, ocispecs.Manifest{MediaType: ocispecs.MediaTypeImageManifest}, &ocispecs.Platform{Architecture: "amd64", OS: "linux"})
+ idx := ocispecs.Index{
+ MediaType: ocispecs.MediaTypeImageIndex,
+ Manifests: []ocispecs.Descriptor{mfst},
+ }
+ idx.SchemaVersion = 2
+ idxDt, err := json.Marshal(idx)
+ require.NoError(t, err)
+ idxDgst, idxSize := putBlob(ctx, t, store, idxDt, ocispecs.MediaTypeImageIndex)
+ root := ocispecs.Descriptor{MediaType: ocispecs.MediaTypeImageIndex, Digest: idxDgst, Size: idxSize}
+
+ subjects, err := fanOutSubjects(ctx, store, root, "test")
+ require.NoError(t, err)
+ require.Len(t, subjects, 1)
+
+ // Same size, different content.
+ tampered := bytes.Replace(idxDt, []byte("amd64"), []byte("arm64"), 1)
+ require.Len(t, tampered, len(idxDt))
+ blobPath := filepath.Join(dir, "blobs", idxDgst.Algorithm().String(), idxDgst.Encoded())
+ require.NoError(t, os.Chmod(blobPath, 0o644))
+ require.NoError(t, os.WriteFile(blobPath, tampered, 0o644))
+
+ _, err = fanOutSubjects(ctx, store, root, "test")
+ require.ErrorContains(t, err, "digest mismatch")
+}
+
+// TestSubjectPredicateManifestHint asserts that a platform manifest
+// referenced directly gets a hint to use the image index reference.
+func TestSubjectPredicateManifestHint(t *testing.T) {
+ ctx := context.Background()
+ store, err := contentlocal.NewStore(t.TempDir())
+ require.NoError(t, err)
+ mfst := putManifest(ctx, t, store, ocispecs.Manifest{MediaType: ocispecs.MediaTypeImageManifest}, nil)
+
+ subjects, err := fanOutSubjects(ctx, store, mfst, "example.com/app@"+mfst.Digest.String())
+ require.NoError(t, err)
+ require.Len(t, subjects, 1)
+ _, err = subjects[0].Predicate(ctx)
+ var noProv *NoProvenanceError
+ require.ErrorAs(t, err, &noProv)
+ require.True(t, noProv.Manifest)
+ require.ErrorContains(t, err, "use the image index reference")
+}
+
+// TestLoadSubjectsIndexFanout builds an OCI layout with a two-platform
+// image index (amd64 + arm64) and asserts LoadSubjects returns two subjects
+// with distinct Descriptor.Platform.
+func TestLoadSubjectsIndexFanout(t *testing.T) {
+ dir := t.TempDir()
+
+ store, err := contentlocal.NewStore(dir)
+ require.NoError(t, err)
+
+ ctx := context.Background()
+
+ cfgAmd64 := []byte(`{"architecture":"amd64","os":"linux"}`)
+ cfgAmd64Dgst, cfgAmd64Sz := putBlob(ctx, t, store, cfgAmd64, "application/vnd.oci.image.config.v1+json")
+ cfgArm64 := []byte(`{"architecture":"arm64","os":"linux"}`)
+ cfgArm64Dgst, cfgArm64Sz := putBlob(ctx, t, store, cfgArm64, "application/vnd.oci.image.config.v1+json")
+
+ amd64Desc := putManifest(ctx, t, store, ocispecs.Manifest{
+ MediaType: ocispecs.MediaTypeImageManifest,
+ Config: ocispecs.Descriptor{
+ MediaType: "application/vnd.oci.image.config.v1+json",
+ Digest: cfgAmd64Dgst,
+ Size: cfgAmd64Sz,
+ },
+ }, &ocispecs.Platform{Architecture: "amd64", OS: "linux"})
+
+ arm64Desc := putManifest(ctx, t, store, ocispecs.Manifest{
+ MediaType: ocispecs.MediaTypeImageManifest,
+ Config: ocispecs.Descriptor{
+ MediaType: "application/vnd.oci.image.config.v1+json",
+ Digest: cfgArm64Dgst,
+ Size: cfgArm64Sz,
+ },
+ }, &ocispecs.Platform{Architecture: "arm64", OS: "linux"})
+
+ idx := ocispecs.Index{
+ MediaType: ocispecs.MediaTypeImageIndex,
+ Manifests: []ocispecs.Descriptor{amd64Desc, arm64Desc},
+ }
+ idx.SchemaVersion = 2
+
+ idxDt, err := json.Marshal(idx)
+ require.NoError(t, err)
+ idxDgst, idxSize := putBlob(ctx, t, store, idxDt, ocispecs.MediaTypeImageIndex)
+
+ storeIdx := ociindex.NewStoreIndex(dir)
+ require.NoError(t, storeIdx.Put(ocispecs.Descriptor{
+ MediaType: ocispecs.MediaTypeImageIndex,
+ Digest: idxDgst,
+ Size: idxSize,
+ }, ociindex.Tag("latest")))
+
+ subjects, err := LoadSubjects(ctx, nil, "", "oci-layout://"+dir+":latest")
+ require.NoError(t, err)
+ require.Len(t, subjects, 2, "expected two fan-out subjects")
+
+ sort.Slice(subjects, func(i, j int) bool {
+ return subjects[i].Descriptor.Platform.Architecture < subjects[j].Descriptor.Platform.Architecture
+ })
+ require.Equal(t, "amd64", subjects[0].Descriptor.Platform.Architecture)
+ require.Equal(t, "arm64", subjects[1].Descriptor.Platform.Architecture)
+ require.NotEqual(t, subjects[0].Descriptor.Digest, subjects[1].Descriptor.Digest)
+}
+
+// TestLoadSubjectsFanoutSkipsAttestation exercises the attestation-manifest
+// filtering path: an index with an attestation manifest annotated via
+// vnd.docker.reference.digest must not produce a bonus subject for the
+// attestation.
+func TestLoadSubjectsFanoutSkipsAttestation(t *testing.T) {
+ dir := t.TempDir()
+ store, err := contentlocal.NewStore(dir)
+ require.NoError(t, err)
+ ctx := context.Background()
+
+ cfgDt := []byte(`{"architecture":"amd64","os":"linux"}`)
+ cfgDgst, cfgSize := putBlob(ctx, t, store, cfgDt, "application/vnd.oci.image.config.v1+json")
+ imgDesc := putManifest(ctx, t, store, ocispecs.Manifest{
+ MediaType: ocispecs.MediaTypeImageManifest,
+ Config: ocispecs.Descriptor{
+ MediaType: "application/vnd.oci.image.config.v1+json",
+ Digest: cfgDgst,
+ Size: cfgSize,
+ },
+ }, &ocispecs.Platform{Architecture: "amd64", OS: "linux"})
+
+ // Synthesize a bare "attestation manifest" that references imgDesc.
+ attestManifest := ocispecs.Manifest{
+ MediaType: ocispecs.MediaTypeImageManifest,
+ Config: ocispecs.Descriptor{
+ MediaType: "application/vnd.oci.image.config.v1+json",
+ Digest: cfgDgst,
+ Size: cfgSize,
+ },
+ }
+ attestDt, err := json.Marshal(attestManifest)
+ require.NoError(t, err)
+ attestDgst, attestSize := putBlob(ctx, t, store, attestDt, ocispecs.MediaTypeImageManifest)
+
+ attestDesc := ocispecs.Descriptor{
+ MediaType: ocispecs.MediaTypeImageManifest,
+ Digest: attestDgst,
+ Size: attestSize,
+ Annotations: map[string]string{
+ attestation.DockerAnnotationReferenceType: attestation.DockerAnnotationReferenceTypeDefault,
+ attestation.DockerAnnotationReferenceDigest: imgDesc.Digest.String(),
+ },
+ }
+ duplicateAttestDesc := attestDesc
+ duplicateAttestDesc.Digest = digest.FromString("later duplicate attestation")
+ duplicateAttestDesc.Size = 1
+
+ idx := ocispecs.Index{
+ MediaType: ocispecs.MediaTypeImageIndex,
+ Manifests: []ocispecs.Descriptor{imgDesc, attestDesc, duplicateAttestDesc},
+ }
+ idx.SchemaVersion = 2
+ idxDt, err := json.Marshal(idx)
+ require.NoError(t, err)
+ idxDgst, idxSize := putBlob(ctx, t, store, idxDt, ocispecs.MediaTypeImageIndex)
+
+ storeIdx := ociindex.NewStoreIndex(dir)
+ require.NoError(t, storeIdx.Put(ocispecs.Descriptor{
+ MediaType: ocispecs.MediaTypeImageIndex,
+ Digest: idxDgst,
+ Size: idxSize,
+ }, ociindex.Tag("latest")))
+
+ subjects, err := LoadSubjects(ctx, nil, "", "oci-layout://"+dir+":latest")
+ require.NoError(t, err)
+ require.Len(t, subjects, 1, "attestation manifest should not expand to a subject")
+ require.Equal(t, imgDesc.Digest, subjects[0].Descriptor.Digest)
+ require.Equal(t, attestDgst, subjects[0].AttestationManifest().Digest, "subject should record its attestation manifest")
+
+ untyped := attestDesc
+ untyped.Annotations = map[string]string{
+ attestation.DockerAnnotationReferenceDigest: imgDesc.Digest.String(),
+ }
+ require.Empty(t, attestationReferenceDigest(untyped), "a digest annotation alone must not select an attestation manifest")
+}
+
+// putBlob writes raw bytes to the content store and returns the digest/size.
+func putBlob(ctx context.Context, t *testing.T, store content.Ingester, dt []byte, mediaType string) (digest.Digest, int64) {
+ t.Helper()
+ dgst := digest.FromBytes(dt)
+ desc := ocispecs.Descriptor{MediaType: mediaType, Digest: dgst, Size: int64(len(dt))}
+ err := content.WriteBlob(ctx, store, dgst.String(), bytes.NewReader(dt), desc)
+ require.NoError(t, err)
+ return dgst, int64(len(dt))
+}
+
+// putManifest marshals an OCI manifest and writes it to the store. Returns
+// the descriptor (with optional platform).
+func putManifest(ctx context.Context, t *testing.T, store content.Ingester, mfst ocispecs.Manifest, plat *ocispecs.Platform) ocispecs.Descriptor {
+ t.Helper()
+ mfst.SchemaVersion = 2
+ dt, err := json.Marshal(mfst)
+ require.NoError(t, err)
+ dgst, sz := putBlob(ctx, t, store, dt, ocispecs.MediaTypeImageManifest)
+ return ocispecs.Descriptor{
+ MediaType: ocispecs.MediaTypeImageManifest,
+ Digest: dgst,
+ Size: sz,
+ Platform: plat,
+ }
+}
diff --git a/replay/verify.go b/replay/verify.go
new file mode 100644
index 000000000000..052d5a6e4000
--- /dev/null
+++ b/replay/verify.go
@@ -0,0 +1,285 @@
+package replay
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "time"
+
+ "github.com/containerd/containerd/v2/core/content"
+ contentlocal "github.com/containerd/containerd/v2/plugins/content/local"
+ "github.com/docker/buildx/util/buildflags"
+ "github.com/docker/cli/cli/command"
+ "github.com/moby/buildkit/client/ociindex"
+ "github.com/moby/buildkit/util/progress/progressui"
+ digest "github.com/opencontainers/go-digest"
+ ocispecs "github.com/opencontainers/image-spec/specs-go/v1"
+ "github.com/pkg/errors"
+)
+
+// Compare modes accepted by Verify.
+const (
+ CompareModeDigest = "digest"
+ CompareModeArtifact = "artifact"
+)
+
+// VerifyVSAPredicateType is the in-toto predicate type for a SLSA
+// Verification Summary Attestation.
+const VerifyVSAPredicateType = "https://slsa.dev/verification_summary/v1"
+
+// VerifyRequest is the library-level input to Verify.
+type VerifyRequest struct {
+ // Subject is the loaded subject (exactly one — multi-platform subjects
+ // are verified one at a time by the caller).
+ Subject *Subject
+ // Predicate is the subject's provenance predicate.
+ Predicate *Predicate
+ // Mode selects the comparison strategy: "digest" (default) or
+ // "artifact" (descriptor tree walk).
+ Mode string
+ // Materials resolver, same semantics as BuildRequest.Materials.
+ Materials *MaterialsResolver
+ // Network controls the replayed build's RUN-network mode.
+ Network string
+ // Secrets / SSH mirror the BuildRequest shape for secret pass-through.
+ Secrets buildflags.Secrets
+ SSH []*buildflags.SSH
+ // Output is an optional type=local --output spec for the VSA and diff
+ // report.
+ Output *buildflags.ExportEntry
+ // Progress is the progress display mode for the replayed build.
+ Progress progressui.DisplayMode
+}
+
+// VerifyResult is the library-level result of a verification.
+type VerifyResult struct {
+ Matched bool
+ DiffReport *CompareReport
+ // VSABytes is the in-toto Statement bytes written when req.Output is
+ // set; empty otherwise.
+ VSABytes []byte
+}
+
+// Verify replays the subject to an ephemeral OCI layout, compares, and
+// optionally writes a VSA + diff report to req.Output.
+//
+// On a mismatch the returned error is a typed CompareMismatchError wrapping
+// the diff report; callers should not attempt to interpret Matched=false with
+// nil error.
+func Verify(ctx context.Context, dockerCli command.Cli, builderName string, req *VerifyRequest) (_ *VerifyResult, retErr error) {
+ if req == nil {
+ return nil, errors.New("nil verify request")
+ }
+ if req.Subject == nil {
+ return nil, errors.New("nil subject")
+ }
+ if req.Predicate == nil {
+ return nil, errors.New("nil predicate")
+ }
+
+ mode := req.Mode
+ if mode == "" {
+ mode = CompareModeDigest
+ }
+ switch mode {
+ case CompareModeDigest, CompareModeArtifact:
+ // ok
+ default:
+ return nil, errors.Errorf("unknown --compare mode %q", mode)
+ }
+ if req.Output != nil && req.Output.Type != "local" {
+ return nil, errors.Errorf("unsupported verify --output type %q (want local)", req.Output.Type)
+ }
+
+ // Attestation-file subjects have no produced artifact to verify against.
+ if req.Subject.IsAttestationFile() {
+ return nil, ErrUnsupportedSubject("verify requires an image or oci-layout subject")
+ }
+ if err := checkReplayable(req.Predicate, BuildModeMaterials, req.Secrets, req.SSH); err != nil {
+ return nil, err
+ }
+
+ // Prepare ephemeral OCI layout for the replay output.
+ tmpDir, err := os.MkdirTemp("", "buildx-replay-verify-")
+ if err != nil {
+ return nil, errors.WithStack(err)
+ }
+ defer os.RemoveAll(tmpDir)
+
+ layoutDir := filepath.Join(tmpDir, "replay-oci")
+ if err := os.MkdirAll(layoutDir, 0o755); err != nil {
+ return nil, errors.WithStack(err)
+ }
+
+ // Run the replay build into the layout.
+ if err := verifyReplay(ctx, dockerCli, builderName, req, layoutDir); err != nil {
+ return nil, errors.Wrap(err, "replay for verify")
+ }
+
+ replayDesc, replayProvider, err := openVerifyReplayLayout(layoutDir)
+ if err != nil {
+ return nil, errors.Wrap(err, "open replay layout")
+ }
+
+ result := &VerifyResult{}
+ switch mode {
+ case CompareModeDigest:
+ result.Matched = CompareDigest(req.Subject.Descriptor, replayDesc)
+ case CompareModeArtifact:
+ replaySubj := &Subject{Descriptor: replayDesc, Provider: replayProvider}
+ rep, err := CompareArtifact(ctx, req.Subject, replaySubj)
+ if err != nil {
+ return nil, err
+ }
+ result.DiffReport = rep
+ result.Matched = ReportMatched(rep)
+ }
+
+ // VSA + output.
+ vsa, err := buildVSA(req, replayDesc, result, mode)
+ if err != nil {
+ return nil, err
+ }
+ result.VSABytes = vsa
+
+ if req.Output != nil {
+ if err := writeVerifyOutput(req, result, vsa); err != nil {
+ return nil, err
+ }
+ }
+
+ if !result.Matched {
+ reason := fmt.Sprintf("verify --compare=%s failed", mode)
+ return result, ErrCompareMismatch(reason, result.DiffReport)
+ }
+ return result, nil
+}
+
+// verifyReplay replays the subject into an OCI layout directory.
+func verifyReplay(ctx context.Context, dockerCli command.Cli, builderName string, req *VerifyRequest, layoutDir string) error {
+ exportEntry := &buildflags.ExportEntry{
+ Type: "oci",
+ Destination: layoutDir,
+ Attrs: map[string]string{
+ // tar=false forces the oci exporter to emit an OCI layout tree
+ // (blobs/, index.json) which we can then open with
+ // contentlocal.NewStore.
+ "tar": "false",
+ },
+ }
+ exportSpecs := []*buildflags.ExportEntry{exportEntry}
+
+ breq := &BuildRequest{
+ Targets: []Target{{Subject: req.Subject, Predicate: req.Predicate}},
+ Mode: BuildModeMaterials,
+ Materials: req.Materials,
+ NetworkMode: req.Network,
+ Secrets: req.Secrets,
+ SSH: req.SSH,
+ Exports: exportSpecs,
+ Progress: req.Progress,
+ }
+
+ return Build(ctx, dockerCli, builderName, breq)
+}
+
+// openVerifyReplayLayout reads the root descriptor from an OCI-layout
+// directory that buildx just populated via type=oci export.
+func openVerifyReplayLayout(dir string) (ocispecs.Descriptor, content.Provider, error) {
+ store, err := contentlocal.NewStore(dir)
+ if err != nil {
+ return ocispecs.Descriptor{}, nil, errors.Wrap(err, "open layout store")
+ }
+ idx, err := ociindex.NewStoreIndex(dir).Read()
+ if err != nil {
+ return ocispecs.Descriptor{}, nil, errors.Wrap(err, "read layout index")
+ }
+ if len(idx.Manifests) == 0 {
+ return ocispecs.Descriptor{}, nil, errors.New("empty layout index")
+ }
+ return idx.Manifests[0], store, nil
+}
+
+// buildVSA returns an in-toto Statement containing a SLSA VSA predicate as a
+// single-line JSON document.
+func buildVSA(req *VerifyRequest, replayDesc ocispecs.Descriptor, result *VerifyResult, mode string) ([]byte, error) {
+ status := "PASSED"
+ if !result.Matched {
+ status = "FAILED"
+ }
+ subjectName := req.Subject.InputRef()
+ if subjectName == "" {
+ subjectName = req.Subject.Descriptor.Digest.String()
+ }
+ statement := map[string]any{
+ "_type": "https://in-toto.io/Statement/v1",
+ "predicateType": VerifyVSAPredicateType,
+ "subject": []map[string]any{
+ {
+ "name": subjectName,
+ "digest": digestToDigestSet(req.Subject.Descriptor.Digest),
+ },
+ },
+ "predicate": map[string]any{
+ "verifier": map[string]any{
+ "id": "https://github.com/docker/buildx",
+ },
+ "timeVerified": time.Now().UTC().Format(time.RFC3339),
+ "resourceUri": subjectName,
+ "policy": map[string]any{"uri": ""},
+ "verificationResult": status,
+ "verifiedLevels": []string{},
+ "dependencyLevels": map[string]any{},
+ "inputAttestations": []map[string]any{
+ {
+ "uri": subjectName,
+ "digest": digestToDigestSet(req.Subject.AttestationManifest().Digest),
+ },
+ },
+ // Buildx-specific fields that have no VSA equivalent.
+ "buildx": map[string]any{
+ "replayMode": string(BuildModeMaterials),
+ "compareMode": mode,
+ "replayDigest": replayDesc.Digest.String(),
+ "subjectDigest": req.Subject.Descriptor.Digest.String(),
+ },
+ },
+ }
+ dt, err := json.Marshal(statement)
+ return dt, errors.WithStack(err)
+}
+
+// digestToDigestSet turns an OCI digest into the in-toto DigestSet shape.
+func digestToDigestSet(d digest.Digest) map[string]string {
+ if d == "" {
+ return map[string]string{}
+ }
+ return map[string]string{d.Algorithm().String(): d.Encoded()}
+}
+
+// writeVerifyOutput writes the VSA and diff report to a type=local
+// destination directory.
+func writeVerifyOutput(req *VerifyRequest, result *VerifyResult, vsa []byte) error {
+ dest := req.Output.Destination
+ if dest == "" {
+ return errors.New("verify output type=local requires dest=")
+ }
+ if err := os.MkdirAll(dest, 0o755); err != nil {
+ return errors.WithStack(err)
+ }
+ if err := os.WriteFile(filepath.Join(dest, "vsa.intoto.jsonl"), append(vsa, '\n'), 0o644); err != nil {
+ return errors.WithStack(err)
+ }
+ if result.DiffReport != nil {
+ dt, err := ReportJSON(result.DiffReport)
+ if err != nil {
+ return err
+ }
+ if err := os.WriteFile(filepath.Join(dest, "diff.json"), dt, 0o644); err != nil {
+ return errors.WithStack(err)
+ }
+ }
+ return nil
+}
diff --git a/replay/verify_compare.go b/replay/verify_compare.go
new file mode 100644
index 000000000000..261f2ef73c1b
--- /dev/null
+++ b/replay/verify_compare.go
@@ -0,0 +1,257 @@
+package replay
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+
+ "github.com/containerd/containerd/v2/core/content"
+ "github.com/containerd/containerd/v2/core/images"
+ ocispecs "github.com/opencontainers/image-spec/specs-go/v1"
+ "github.com/pkg/errors"
+)
+
+const (
+ EventTypeNone = ""
+ EventTypeDescriptorMismatch = "DescriptorMismatch"
+ EventTypeIndexBlobMismatch = "IndexBlobMismatch"
+ EventTypeConfigBlobMismatch = "ConfigBlobMismatch"
+ EventTypeLayerBlobMismatch = "LayerBlobMismatch"
+)
+
+// CompareEventInput carries the relevant object for one side of a mismatch.
+type CompareEventInput struct {
+ Descriptor *ocispecs.Descriptor `json:"descriptor,omitempty"`
+ Index *ocispecs.Index `json:"index,omitempty"`
+ Manifest *ocispecs.Manifest `json:"manifest,omitempty"`
+}
+
+// CompareEvent records a single divergence at one tree node.
+type CompareEvent struct {
+ Type string `json:"type,omitempty"`
+ Inputs [2]CompareEventInput `json:"inputs,omitempty"`
+ Diff string `json:"diff,omitempty"`
+}
+
+// CompareReport is the basic per-node event tree emitted by replay verify.
+type CompareReport struct {
+ CompareEvent
+ Context string `json:"context,omitempty"`
+ Children []*CompareReport `json:"children,omitempty"`
+}
+
+// CompareDigest returns whether subject and replay descriptors share the same
+// manifest digest. This is the fastest check and is the default for
+// `replay verify`.
+func CompareDigest(subject, replay ocispecs.Descriptor) bool {
+ return subject.Digest != "" && subject.Digest == replay.Digest
+}
+
+// CompareArtifact walks both subject and replay stores and returns a
+// CompareReport describing any divergence.
+//
+// The comparison is content-addressed: it reports which descriptors differ,
+// not how their contents differ. A manifest-digest match short-circuits the
+// walk and returns an empty report (no divergence). A mismatch at any level
+// surfaces as an event node populated with inputs referring to the two sides.
+func CompareArtifact(ctx context.Context, subject, replay *Subject) (*CompareReport, error) {
+ if subject == nil || replay == nil {
+ return nil, errors.New("compare: nil subject or replay")
+ }
+ if subject.Provider == nil || replay.Provider == nil {
+ return nil, errors.New("compare: nil content provider")
+ }
+
+ // Short-circuit when both descriptors already have the same digest.
+ if CompareDigest(subject.Descriptor, replay.Descriptor) {
+ return &CompareReport{Context: "root"}, nil
+ }
+
+ root := &CompareReport{Context: "root"}
+ if err := compareDescriptor(ctx, root, subject.Provider, subject.Descriptor, replay.Provider, replay.Descriptor); err != nil {
+ return root, err
+ }
+ return root, nil
+}
+
+// compareDescriptor does a recursive content compare between two descriptors.
+// A changed container descriptor is reported and then inspected further when
+// both sides have the same index or manifest media type. Leaf changes and
+// incompatible media types stop at the descriptor event.
+func compareDescriptor(ctx context.Context, parent *CompareReport, pa content.Provider, da ocispecs.Descriptor, pb content.Provider, db ocispecs.Descriptor) error {
+ if da.Digest == db.Digest {
+ return nil
+ }
+
+ descA, descB := da, db
+ parent.Children = append(parent.Children, &CompareReport{
+ Context: fmt.Sprintf("descriptor %s", displayMediaType(da.MediaType)),
+ CompareEvent: CompareEvent{
+ Type: EventTypeDescriptorMismatch,
+ Inputs: [2]CompareEventInput{
+ {Descriptor: &descA},
+ {Descriptor: &descB},
+ },
+ Diff: fmt.Sprintf("digest mismatch: %s vs %s", da.Digest, db.Digest),
+ },
+ })
+
+ if da.MediaType != db.MediaType {
+ return nil
+ }
+
+ switch da.MediaType {
+ case ocispecs.MediaTypeImageIndex, images.MediaTypeDockerSchema2ManifestList:
+ return compareIndex(ctx, parent, pa, da, pb, db)
+ case ocispecs.MediaTypeImageManifest, images.MediaTypeDockerSchema2Manifest:
+ return compareManifest(ctx, parent, pa, da, pb, db)
+ }
+ return nil
+}
+
+func compareIndex(ctx context.Context, parent *CompareReport, pa content.Provider, da ocispecs.Descriptor, pb content.Provider, db ocispecs.Descriptor) error {
+ ia, err := readIndex(ctx, pa, da)
+ if err != nil {
+ return err
+ }
+ ib, err := readIndex(ctx, pb, db)
+ if err != nil {
+ return err
+ }
+ if len(ia.Manifests) != len(ib.Manifests) {
+ parent.Children = append(parent.Children, &CompareReport{
+ Context: "index",
+ CompareEvent: CompareEvent{
+ Type: EventTypeIndexBlobMismatch,
+ Inputs: [2]CompareEventInput{
+ {Index: ia},
+ {Index: ib},
+ },
+ Diff: fmt.Sprintf("child count mismatch: %d vs %d", len(ia.Manifests), len(ib.Manifests)),
+ },
+ })
+ return nil
+ }
+ for i := range ia.Manifests {
+ node := &CompareReport{Context: fmt.Sprintf("index/manifests[%d]", i)}
+ if err := compareDescriptor(ctx, node, pa, ia.Manifests[i], pb, ib.Manifests[i]); err != nil {
+ return err
+ }
+ if len(node.Children) > 0 || node.Type != EventTypeNone {
+ parent.Children = append(parent.Children, node)
+ }
+ }
+ return nil
+}
+
+func compareManifest(ctx context.Context, parent *CompareReport, pa content.Provider, da ocispecs.Descriptor, pb content.Provider, db ocispecs.Descriptor) error {
+ ma, err := readManifest(ctx, pa, da)
+ if err != nil {
+ return err
+ }
+ mb, err := readManifest(ctx, pb, db)
+ if err != nil {
+ return err
+ }
+ if ma.Config.Digest != mb.Config.Digest {
+ parent.Children = append(parent.Children, &CompareReport{
+ Context: "manifest/config",
+ CompareEvent: CompareEvent{
+ Type: EventTypeConfigBlobMismatch,
+ Inputs: [2]CompareEventInput{
+ {Manifest: ma},
+ {Manifest: mb},
+ },
+ Diff: fmt.Sprintf("config digest mismatch: %s vs %s", ma.Config.Digest, mb.Config.Digest),
+ },
+ })
+ }
+ if len(ma.Layers) != len(mb.Layers) {
+ parent.Children = append(parent.Children, &CompareReport{
+ Context: "manifest/layers",
+ CompareEvent: CompareEvent{
+ Type: EventTypeLayerBlobMismatch,
+ Inputs: [2]CompareEventInput{
+ {Manifest: ma},
+ {Manifest: mb},
+ },
+ Diff: fmt.Sprintf("layer count mismatch: %d vs %d", len(ma.Layers), len(mb.Layers)),
+ },
+ })
+ return nil
+ }
+ for i := range ma.Layers {
+ if ma.Layers[i].Digest != mb.Layers[i].Digest {
+ la, lb := ma.Layers[i], mb.Layers[i]
+ parent.Children = append(parent.Children, &CompareReport{
+ Context: fmt.Sprintf("manifest/layers[%d]", i),
+ CompareEvent: CompareEvent{
+ Type: EventTypeLayerBlobMismatch,
+ Inputs: [2]CompareEventInput{
+ {Descriptor: &la},
+ {Descriptor: &lb},
+ },
+ Diff: fmt.Sprintf("layer[%d] digest mismatch: %s vs %s", i, la.Digest, lb.Digest),
+ },
+ })
+ }
+ }
+ return nil
+}
+
+func readIndex(ctx context.Context, p content.Provider, desc ocispecs.Descriptor) (*ocispecs.Index, error) {
+ dt, err := content.ReadBlob(ctx, p, desc)
+ if err != nil {
+ return nil, errors.Wrapf(err, "read index %s", desc.Digest)
+ }
+ var idx ocispecs.Index
+ if err := json.Unmarshal(dt, &idx); err != nil {
+ return nil, errors.Wrapf(err, "unmarshal index %s", desc.Digest)
+ }
+ return &idx, nil
+}
+
+func readManifest(ctx context.Context, p content.Provider, desc ocispecs.Descriptor) (*ocispecs.Manifest, error) {
+ dt, err := content.ReadBlob(ctx, p, desc)
+ if err != nil {
+ return nil, errors.Wrapf(err, "read manifest %s", desc.Digest)
+ }
+ var mfst ocispecs.Manifest
+ if err := json.Unmarshal(dt, &mfst); err != nil {
+ return nil, errors.Wrapf(err, "unmarshal manifest %s", desc.Digest)
+ }
+ return &mfst, nil
+}
+
+func displayMediaType(mt string) string {
+ if mt == "" {
+ return ""
+ }
+ return mt
+}
+
+// ReportMatched reports whether a CompareReport represents a successful
+// compare (no divergence events). An empty tree counts as matched.
+func ReportMatched(r *CompareReport) bool {
+ if r == nil {
+ return true
+ }
+ if r.Type != EventTypeNone {
+ return false
+ }
+ for _, c := range r.Children {
+ if !ReportMatched(c) {
+ return false
+ }
+ }
+ return true
+}
+
+// ReportJSON serializes a CompareReport to JSON.
+// A nil report is emitted as an empty object.
+func ReportJSON(r *CompareReport) ([]byte, error) {
+ if r == nil {
+ return []byte("{}"), nil
+ }
+ return json.MarshalIndent(r, "", " ")
+}
diff --git a/replay/verify_compare_test.go b/replay/verify_compare_test.go
new file mode 100644
index 000000000000..a1d9db3bc563
--- /dev/null
+++ b/replay/verify_compare_test.go
@@ -0,0 +1,150 @@
+package replay
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "testing"
+
+ "github.com/containerd/containerd/v2/core/content"
+ "github.com/moby/buildkit/util/contentutil"
+ digest "github.com/opencontainers/go-digest"
+ ocispecsgo "github.com/opencontainers/image-spec/specs-go"
+ ocispecs "github.com/opencontainers/image-spec/specs-go/v1"
+ "github.com/stretchr/testify/require"
+)
+
+func TestCompareDigestMatch(t *testing.T) {
+ d := digest.FromBytes([]byte("hello"))
+ subject := ocispecs.Descriptor{Digest: d, MediaType: ocispecs.MediaTypeImageManifest}
+ replay := ocispecs.Descriptor{Digest: d, MediaType: ocispecs.MediaTypeImageManifest}
+ require.True(t, CompareDigest(subject, replay))
+}
+
+func TestCompareDigestMismatch(t *testing.T) {
+ subject := ocispecs.Descriptor{Digest: digest.FromBytes([]byte("a")), MediaType: ocispecs.MediaTypeImageManifest}
+ replay := ocispecs.Descriptor{Digest: digest.FromBytes([]byte("b")), MediaType: ocispecs.MediaTypeImageManifest}
+ require.False(t, CompareDigest(subject, replay))
+ // Empty subject digest is never considered a match.
+ require.False(t, CompareDigest(ocispecs.Descriptor{}, replay))
+}
+
+// writeManifestTree writes a minimal OCI manifest with the given config +
+// layer blob contents into buf and returns the manifest descriptor.
+func writeManifestTree(t *testing.T, buf contentutil.Buffer, configBytes []byte, layerBytes []byte) ocispecs.Descriptor {
+ t.Helper()
+ ctx := context.Background()
+
+ configDesc := ocispecs.Descriptor{
+ MediaType: ocispecs.MediaTypeImageConfig,
+ Digest: digest.FromBytes(configBytes),
+ Size: int64(len(configBytes)),
+ }
+ require.NoError(t, content.WriteBlob(ctx, buf, configDesc.Digest.String(), bytes.NewReader(configBytes), configDesc))
+
+ layerDesc := ocispecs.Descriptor{
+ MediaType: ocispecs.MediaTypeImageLayerGzip,
+ Digest: digest.FromBytes(layerBytes),
+ Size: int64(len(layerBytes)),
+ }
+ require.NoError(t, content.WriteBlob(ctx, buf, layerDesc.Digest.String(), bytes.NewReader(layerBytes), layerDesc))
+
+ mfst := ocispecs.Manifest{
+ Versioned: ocispecsgo.Versioned{SchemaVersion: 2},
+ MediaType: ocispecs.MediaTypeImageManifest,
+ Config: configDesc,
+ Layers: []ocispecs.Descriptor{layerDesc},
+ }
+ dt, err := json.Marshal(mfst)
+ require.NoError(t, err)
+ desc := ocispecs.Descriptor{
+ MediaType: ocispecs.MediaTypeImageManifest,
+ Digest: digest.FromBytes(dt),
+ Size: int64(len(dt)),
+ }
+ require.NoError(t, content.WriteBlob(ctx, buf, desc.Digest.String(), bytes.NewReader(dt), desc))
+ return desc
+}
+
+func TestCompareArtifactIdenticalTrees(t *testing.T) {
+ // Two identical in-memory content stores (structurally identical
+ // manifest trees). CompareArtifact should return without error and
+ // the resulting report should indicate no divergence.
+ bufA := contentutil.NewBuffer()
+ bufB := contentutil.NewBuffer()
+
+ configBytes := []byte(`{"architecture":"amd64","os":"linux"}`)
+ layerBytes := []byte("dummy-layer-content")
+
+ descA := writeManifestTree(t, bufA, configBytes, layerBytes)
+ descB := writeManifestTree(t, bufB, configBytes, layerBytes)
+
+ // Both trees have the same bytes → same digests.
+ require.Equal(t, descA.Digest, descB.Digest)
+
+ subject := &Subject{Descriptor: descA, Provider: bufA}
+ replay := &Subject{Descriptor: descB, Provider: bufB}
+
+ report, err := CompareArtifact(context.Background(), subject, replay)
+ require.NoError(t, err)
+ require.NotNil(t, report)
+ require.True(t, ReportMatched(report), "identical stores should report no divergence")
+}
+
+func TestCompareArtifactMismatch(t *testing.T) {
+ bufA := contentutil.NewBuffer()
+ bufB := contentutil.NewBuffer()
+
+ descA := writeManifestTree(t, bufA, []byte(`{"os":"linux"}`), []byte("a"))
+ descB := writeManifestTree(t, bufB, []byte(`{"os":"linux"}`), []byte("b"))
+ require.NotEqual(t, descA.Digest, descB.Digest)
+
+ subject := &Subject{Descriptor: descA, Provider: bufA}
+ replay := &Subject{Descriptor: descB, Provider: bufB}
+
+ report, err := CompareArtifact(context.Background(), subject, replay)
+ require.NoError(t, err)
+ require.NotNil(t, report)
+ require.False(t, ReportMatched(report), "mismatching stores should produce divergence events")
+ require.True(t, reportHasEvent(report, EventTypeDescriptorMismatch))
+ require.True(t, reportHasEvent(report, EventTypeLayerBlobMismatch), "artifact comparison should descend into changed manifests")
+
+ // JSON should round-trip.
+ raw, err := ReportJSON(report)
+ require.NoError(t, err)
+ require.NotEmpty(t, raw)
+ var parsed CompareReport
+ require.NoError(t, json.Unmarshal(raw, &parsed))
+}
+
+func TestCompareArtifactConfigMismatch(t *testing.T) {
+ bufA := contentutil.NewBuffer()
+ bufB := contentutil.NewBuffer()
+
+ descA := writeManifestTree(t, bufA, []byte(`{"os":"linux","architecture":"amd64"}`), []byte("same"))
+ descB := writeManifestTree(t, bufB, []byte(`{"os":"linux","architecture":"arm64"}`), []byte("same"))
+
+ report, err := CompareArtifact(context.Background(),
+ &Subject{Descriptor: descA, Provider: bufA},
+ &Subject{Descriptor: descB, Provider: bufB},
+ )
+ require.NoError(t, err)
+ require.False(t, ReportMatched(report))
+ require.True(t, reportHasEvent(report, EventTypeConfigBlobMismatch), "artifact comparison should identify the changed config")
+ require.False(t, reportHasEvent(report, EventTypeLayerBlobMismatch), "identical layers should not be reported")
+}
+
+func reportHasEvent(report *CompareReport, eventType string) bool {
+ if report == nil {
+ return false
+ }
+ if report.Type == eventType {
+ return true
+ }
+ for _, child := range report.Children {
+ if reportHasEvent(child, eventType) {
+ return true
+ }
+ }
+ return false
+}
diff --git a/replay/verify_test.go b/replay/verify_test.go
new file mode 100644
index 000000000000..26b4e05b2971
--- /dev/null
+++ b/replay/verify_test.go
@@ -0,0 +1,92 @@
+package replay
+
+import (
+ "context"
+ "encoding/json"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestVerifyRejectsUnknownMode(t *testing.T) {
+ req := &VerifyRequest{
+ Subject: &Subject{},
+ Predicate: &Predicate{},
+ Mode: "bogus",
+ }
+ _, err := Verify(context.Background(), nil, "", req)
+ require.Error(t, err)
+}
+
+func TestVerifyRejectsAttestationFileSubject(t *testing.T) {
+ s := &Subject{kind: subjectKindAttestationFile}
+ req := &VerifyRequest{
+ Subject: s,
+ Predicate: &Predicate{},
+ Mode: CompareModeDigest,
+ }
+ _, err := Verify(context.Background(), nil, "", req)
+ require.Error(t, err)
+ var us *UnsupportedSubjectError
+ require.ErrorAs(t, err, &us)
+}
+
+func TestVerifyRejectsLocalContext(t *testing.T) {
+ pred := testPredicate(nil, []string{"ctx"})
+ req := &VerifyRequest{
+ Subject: testSubject(t),
+ Predicate: pred,
+ Mode: CompareModeDigest,
+ }
+ _, err := Verify(context.Background(), nil, "", req)
+ require.Error(t, err)
+ var ulc *UnreplayableLocalContextError
+ require.ErrorAs(t, err, &ulc)
+}
+
+func TestBuildVSASchema(t *testing.T) {
+ // Exercise the VSA builder directly — it's deterministic given inputs
+ // and does not require a live daemon.
+ s := testSubject(t)
+ s.inputRef = "docker-image://example.test/app:v1"
+
+ pred := testPredicate(nil, nil)
+ pred.BuildDefinition.ExternalParameters.ConfigSource.Digest = map[string]string{"sha256": "cafef00d"}
+
+ req := &VerifyRequest{
+ Subject: s,
+ Predicate: pred,
+ }
+ result := &VerifyResult{Matched: true}
+ dt, err := buildVSA(req, s.Descriptor, result, CompareModeDigest)
+ require.NoError(t, err)
+
+ var parsed map[string]any
+ require.NoError(t, json.Unmarshal(dt, &parsed))
+
+ require.Equal(t, "https://in-toto.io/Statement/v1", parsed["_type"])
+ require.Equal(t, VerifyVSAPredicateType, parsed["predicateType"])
+ subjects := parsed["subject"].([]any)
+ require.Len(t, subjects, 1)
+ require.Equal(t, "docker-image://example.test/app:v1", subjects[0].(map[string]any)["name"])
+
+ pred0 := parsed["predicate"].(map[string]any)
+ require.Equal(t, "PASSED", pred0["verificationResult"])
+ verifier := pred0["verifier"].(map[string]any)
+ require.Equal(t, "https://github.com/docker/buildx", verifier["id"])
+ buildxSide := pred0["buildx"].(map[string]any)
+ require.Equal(t, string(CompareModeDigest), buildxSide["compareMode"])
+}
+
+func TestBuildVSAFailedStatus(t *testing.T) {
+ s := testSubject(t)
+ pred := testPredicate(nil, nil)
+ req := &VerifyRequest{Subject: s, Predicate: pred}
+ result := &VerifyResult{Matched: false}
+ dt, err := buildVSA(req, s.Descriptor, result, CompareModeArtifact)
+ require.NoError(t, err)
+ var parsed map[string]any
+ require.NoError(t, json.Unmarshal(dt, &parsed))
+ pred0 := parsed["predicate"].(map[string]any)
+ require.Equal(t, "FAILED", pred0["verificationResult"])
+}
diff --git a/replay/warn.go b/replay/warn.go
new file mode 100644
index 000000000000..ca8ca0c75f72
--- /dev/null
+++ b/replay/warn.go
@@ -0,0 +1,40 @@
+package replay
+
+import (
+ "sync"
+
+ "github.com/docker/buildx/util/progress"
+)
+
+// warnOnce routes non-fatal warnings through a progress sub-logger
+// and drops duplicates by key. It is shared across snapshot targets so a
+// predicate that references the same material from every platform warns
+// at most once.
+type warnOnce struct {
+ mu sync.Mutex
+ visited map[string]struct{}
+}
+
+func newWarnOnce() *warnOnce {
+ return &warnOnce{visited: map[string]struct{}{}}
+}
+
+// Log emits msg (prefixed with "warning: ") on sub's stderr stream the
+// first time key is seen. Subsequent calls with the same key are silent.
+// sub may be nil, in which case the message is dropped entirely.
+func (w *warnOnce) Log(sub progress.SubLogger, key, msg string) {
+ if w == nil {
+ return
+ }
+ w.mu.Lock()
+ if _, ok := w.visited[key]; ok {
+ w.mu.Unlock()
+ return
+ }
+ w.visited[key] = struct{}{}
+ w.mu.Unlock()
+ if sub == nil {
+ return
+ }
+ sub.Log(2, []byte("warning: "+msg+"\n"))
+}
diff --git a/tests/integration_test.go b/tests/integration_test.go
index d2bcfec2c000..c38d4a774802 100644
--- a/tests/integration_test.go
+++ b/tests/integration_test.go
@@ -31,6 +31,7 @@ func TestIntegration(t *testing.T) {
tests = append(tests, policyTestTests...)
tests = append(tests, bakeTests...)
tests = append(tests, historyTests...)
+ tests = append(tests, replayTests...)
tests = append(tests, inspectTests...)
tests = append(tests, lsTests...)
tests = append(tests, imagetoolsTests...)
diff --git a/tests/replay.go b/tests/replay.go
new file mode 100644
index 000000000000..d5736fee3f95
--- /dev/null
+++ b/tests/replay.go
@@ -0,0 +1,560 @@
+package tests
+
+import (
+ "archive/tar"
+ "bytes"
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+
+ "github.com/containerd/continuity/fs/fstest"
+ "github.com/containerd/platforms"
+ "github.com/docker/buildx/util/gitutil"
+ "github.com/docker/buildx/util/gitutil/gittestutil"
+ "github.com/moby/buildkit/identity"
+ bkgitutil "github.com/moby/buildkit/util/gitutil"
+ "github.com/moby/buildkit/util/testutil/integration"
+ ocispecs "github.com/opencontainers/image-spec/specs-go/v1"
+ "github.com/stretchr/testify/require"
+)
+
+// replayTests exercises the buildx replay subcommands.
+//
+// All tests require a registry sandbox for the build-to-replay round-trip
+// because replay resolves a subject through the registry resolver. Tests
+// that need a writable registry skip when `sb.RegistryAddress()` is empty.
+var replayTests = []func(t *testing.T, sb integration.Sandbox){
+ testReplayBuildRoundTrip,
+ testReplayGitContextRoundTrip,
+ testReplayRejectsChangedHTTPContext,
+ testReplaySnapshotExportAndRejectsOfflineReplay,
+ testReplayVerifyDigest,
+ testReplayRejectsLocalContext,
+ testReplayRejectsIncompleteProvenance,
+ testReplaySecretRoundTrip,
+ testReplayMultiPlatformRoundTrip,
+ testReplayDefaultPlatformUsesWorkerDefault,
+}
+
+// replayRegistry skips workers that cannot produce or replay provenance and
+// returns a registry for the round-trip.
+func replayRegistry(t *testing.T, sb integration.Sandbox) string {
+ t.Helper()
+ if isMobyWorker(sb) {
+ t.Skip("attestations are not supported by the docker worker")
+ }
+ skipNoCompatBuildKit(t, sb, ">= 0.27.0-0", "replay requires session source policy")
+ registry, err := sb.NewRegistry()
+ if err != nil {
+ t.Skipf("skipping: registry not available: %v", err)
+ }
+ return registry
+}
+
+func replayTestTag(t *testing.T) string {
+ return strings.Map(func(r rune) rune {
+ switch {
+ case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '_', r == '.', r == '-':
+ return r
+ default:
+ return '-'
+ }
+ }, t.Name())
+}
+
+// replayTestDockerfile is a minimal Dockerfile that COPYs from a named
+// context so the build's provenance records no local filesystem context —
+// the default buildx build always records a local context which replay
+// correctly refuses.
+const replayTestDockerfile = `# syntax=docker/dockerfile:1
+FROM scratch
+COPY --from=ctx /etc/hosts /hosts
+`
+
+func buildReplayContext(t *testing.T, dockerfile string) (string, string) {
+ t.Helper()
+ dt := replayContextArchive(t, dockerfile)
+ contextPath := filepath.Join(t.TempDir(), "context.tar")
+ require.NoError(t, os.WriteFile(contextPath, dt, 0o600))
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "application/x-tar")
+ _, _ = w.Write(dt)
+ }))
+ t.Cleanup(server.Close)
+ return server.URL + "/context.tar", contextPath
+}
+
+func replayContextArchive(t *testing.T, dockerfile string) []byte {
+ t.Helper()
+ var buf bytes.Buffer
+ tw := tar.NewWriter(&buf)
+ require.NoError(t, tw.WriteHeader(&tar.Header{Name: "Dockerfile", Mode: 0o600, Size: int64(len(dockerfile))}))
+ _, err := tw.Write([]byte(dockerfile))
+ require.NoError(t, err)
+ require.NoError(t, tw.Close())
+ return buf.Bytes()
+}
+
+// buildReplayableImage does a `buildx build` against a registry using a
+// remotely served context, so the resulting image has valid SLSA v1
+// provenance without a local context. It returns the registry reference and
+// both forms of the remote context needed by snapshot tests.
+func buildReplayableImage(t *testing.T, sb integration.Sandbox, extra ...string) (string, string, string) {
+ t.Helper()
+ registry := replayRegistry(t, sb)
+ ref := registry + "/buildx-replay:" + replayTestTag(t)
+ contextRef, contextPath := buildReplayContext(t, replayTestDockerfile)
+
+ args := []string{
+ "--output=type=registry,name=" + ref,
+ "--build-context=ctx=docker-image://alpine:latest",
+ "--attest=type=provenance,mode=max",
+ contextRef,
+ }
+ args = append(args, extra...)
+ out, err := buildCmd(sb, withArgs(args...))
+ require.NoError(t, err, out)
+ return ref, contextRef, contextPath
+}
+
+func testReplayBuildRoundTrip(t *testing.T, sb integration.Sandbox) {
+ ref, _, _ := buildReplayableImage(t, sb)
+
+ dest := filepath.Join(t.TempDir(), "replay-out")
+ cmd := buildxCmd(sb, withArgs(
+ "replay", "build",
+ "docker-image://"+ref,
+ "--output=type=oci,dest="+filepath.Join(dest, "replay.oci.tar"),
+ ))
+ out, err := cmd.CombinedOutput()
+ require.NoError(t, err, string(out))
+ require.FileExists(t, filepath.Join(dest, "replay.oci.tar"))
+}
+
+// testReplayGitContextRoundTrip replays a build from a Git context and checks
+// that the replay stays pinned to the recorded commit after the branch moves.
+func testReplayGitContextRoundTrip(t *testing.T, sb integration.Sandbox) {
+ registry := replayRegistry(t, sb)
+ ref := registry + "/buildx-replay-git:" + replayTestTag(t)
+
+ dir := tmpdir(t,
+ fstest.CreateFile("Dockerfile", []byte("FROM scratch\nCOPY foo /foo\n"), 0o600),
+ fstest.CreateFile("foo", []byte("recorded"), 0o600),
+ )
+ git, err := gitutil.New(bkgitutil.WithDir(dir))
+ require.NoError(t, err)
+ gittestutil.GitInit(git, t)
+ gittestutil.GitAdd(git, t, "Dockerfile", "foo")
+ gittestutil.GitCommit(git, t, "initial commit")
+ contextRef := gittestutil.GitServeHTTP(git, t) + "#main"
+
+ out, err := buildCmd(sb, withArgs(
+ "--output=type=registry,name="+ref,
+ "--attest=type=provenance,mode=max",
+ contextRef,
+ ))
+ require.NoError(t, err, out)
+ prune := buildxCmd(sb, withArgs("prune", "--all", "--force"))
+ pruneOut, err := prune.CombinedOutput()
+ require.NoError(t, err, string(pruneOut))
+
+ dest := filepath.Join(t.TempDir(), "replay.oci.tar")
+ cmd := buildxCmd(sb, withArgs(
+ "replay", "build",
+ "docker-image://"+ref,
+ "--output=type=oci,dest="+dest,
+ ))
+ bout, err := cmd.CombinedOutput()
+ require.NoError(t, err, string(bout))
+ require.FileExists(t, dest)
+
+ // Move the branch. The replay must not build the new commit.
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "foo"), []byte("changed"), 0o600))
+ gittestutil.GitAdd(git, t, "foo")
+ gittestutil.GitCommit(git, t, "move branch")
+ updateInfo := exec.CommandContext(context.TODO(), "git", "update-server-info")
+ updateInfo.Dir = dir
+ infoOut, err := updateInfo.CombinedOutput()
+ require.NoError(t, err, string(infoOut))
+
+ cmd = buildxCmd(sb, withArgs(
+ "replay", "build",
+ "docker-image://"+ref,
+ "--output=type=oci,dest="+filepath.Join(t.TempDir(), "replay.oci.tar"),
+ ))
+ bout, err = cmd.CombinedOutput()
+ require.Error(t, err, string(bout))
+ require.Contains(t, string(bout), "expected checksum to match")
+}
+
+func testReplayRejectsChangedHTTPContext(t *testing.T, sb integration.Sandbox) {
+ registry := replayRegistry(t, sb)
+ ref := registry + "/buildx-replay:" + replayTestTag(t)
+
+ var mu sync.RWMutex
+ contextBytes := replayContextArchive(t, replayTestDockerfile)
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ mu.RLock()
+ dt := bytes.Clone(contextBytes)
+ mu.RUnlock()
+ w.Header().Set("Content-Type", "application/x-tar")
+ _, _ = w.Write(dt)
+ }))
+ t.Cleanup(server.Close)
+ contextRef := server.URL + "/context.tar"
+
+ out, err := buildCmd(sb, withArgs(
+ "--output=type=registry,name="+ref,
+ "--build-context=ctx=docker-image://alpine:latest",
+ "--attest=type=provenance,mode=max",
+ contextRef,
+ ))
+ require.NoError(t, err, out)
+ prune := buildxCmd(sb, withArgs("prune", "--all", "--force"))
+ pruneOut, err := prune.CombinedOutput()
+ require.NoError(t, err, string(pruneOut))
+
+ changedContext := replayContextArchive(t, replayTestDockerfile+"# changed after provenance was recorded\n")
+ mu.Lock()
+ contextBytes = changedContext
+ mu.Unlock()
+
+ cmd := buildxCmd(sb, withArgs(
+ "replay", "build",
+ "docker-image://"+ref,
+ "--output=type=oci,dest="+filepath.Join(t.TempDir(), "replay.oci.tar"),
+ ))
+ outBytes, err := cmd.CombinedOutput()
+ require.Error(t, err, string(outBytes))
+ require.Contains(t, string(outBytes), "digest mismatch")
+}
+
+func testReplaySnapshotExportAndRejectsOfflineReplay(t *testing.T, sb integration.Sandbox) {
+ ref, contextRef, contextPath := buildReplayableImage(t, sb)
+
+ dest := filepath.Join(t.TempDir(), "snap")
+ cmd := buildxCmd(sb, withArgs(
+ "replay", "snapshot",
+ "docker-image://"+ref,
+ "--materials=provenance",
+ "--materials="+contextRef+"="+contextPath,
+ "--output=type=local,dest="+dest,
+ ))
+ out, err := cmd.CombinedOutput()
+ require.NoError(t, err, string(out))
+ require.FileExists(t, filepath.Join(dest, "oci-layout"))
+
+ // Snapshot-backed input injection is not implemented yet. Reject the
+ // explicit store rather than silently replaying from the network.
+ outDir := filepath.Join(t.TempDir(), "replay-from-snapshot")
+ cmd = buildxCmd(sb, withArgs(
+ "replay", "build",
+ "docker-image://"+ref,
+ "--materials=oci-layout://"+dest,
+ "--output=type=oci,dest="+filepath.Join(outDir, "replay.oci.tar"),
+ ))
+ out, err = cmd.CombinedOutput()
+ require.Error(t, err, string(out))
+ require.Contains(t, string(out), "not implemented")
+}
+
+func testReplayVerifyDigest(t *testing.T, sb integration.Sandbox) {
+ // The replay runs on the builder that produced the image, so cached
+ // steps reproduce the original digest.
+ ref, _, _ := buildReplayableImage(t, sb)
+
+ cmd := buildxCmd(sb, withArgs(
+ "replay", "verify",
+ "docker-image://"+ref,
+ "--compare=digest",
+ "--progress=rawjson",
+ ))
+ out, err := cmd.CombinedOutput()
+ require.NoError(t, err, string(out))
+ require.Contains(t, string(out), `"vertexes"`, "--progress must reach the verify build printer")
+}
+
+func testReplayRejectsLocalContext(t *testing.T, sb integration.Sandbox) {
+ // A default `buildx build` records a local filesystem context. Replay
+ // must refuse this case.
+ registry := replayRegistry(t, sb)
+ ref := registry + "/buildx-replay-local:" + replayTestTag(t)
+
+ dir := createTestProject(t)
+ out, err := buildCmd(sb, withArgs(
+ "--output=type=registry,name="+ref,
+ "--attest=type=provenance,mode=max",
+ dir,
+ ))
+ require.NoError(t, err, out)
+
+ cmd := buildxCmd(sb, withArgs(
+ "replay", "build",
+ "docker-image://"+ref,
+ ))
+ bout, err := cmd.CombinedOutput()
+ require.Error(t, err, string(bout))
+ require.Contains(t, string(bout), "image was built from local inputs that replay cannot fetch")
+
+ // A context read from stdin is uploaded through the client session and
+ // cannot be fetched again. Dry-run must reject it too.
+ stdinRef := registry + "/buildx-replay-stdin:" + replayTestTag(t)
+ build := buildxCmd(sb, withArgs(
+ "build", "--progress=quiet",
+ "--output=type=registry,name="+stdinRef,
+ "--build-context=ctx=docker-image://alpine:latest",
+ "--attest=type=provenance,mode=max",
+ "-",
+ ))
+ build.Stdin = bytes.NewReader(replayContextArchive(t, replayTestDockerfile))
+ bout, err = build.CombinedOutput()
+ require.NoError(t, err, string(bout))
+
+ cmd = buildxCmd(sb, withArgs(
+ "replay", "build",
+ "docker-image://"+stdinRef,
+ "--dry-run",
+ ))
+ bout, err = cmd.CombinedOutput()
+ require.Error(t, err, string(bout))
+ require.Contains(t, string(bout), "context (stdin)")
+}
+
+// testReplayRejectsIncompleteProvenance checks that images without provenance
+// or with mode=min provenance fail in dry-run with an explanation.
+func testReplayRejectsIncompleteProvenance(t *testing.T, sb integration.Sandbox) {
+ registry := replayRegistry(t, sb)
+ contextRef, _ := buildReplayContext(t, replayTestDockerfile)
+
+ for _, tc := range []struct {
+ name string
+ attrs []string
+ err string
+ }{
+ {name: "no-provenance", attrs: []string{"--provenance=false"}, err: "no SLSA provenance attestation found"},
+ {name: "min-mode", attrs: []string{"--attest=type=provenance,mode=min"}, err: "provenance was recorded with mode=min"},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ ref := registry + "/buildx-replay-" + tc.name + ":" + replayTestTag(t)
+ args := append([]string{
+ "--output=type=registry,name=" + ref,
+ "--build-context=ctx=docker-image://alpine:latest",
+ contextRef,
+ }, tc.attrs...)
+ out, err := buildCmd(sb, withArgs(args...))
+ require.NoError(t, err, out)
+
+ cmd := buildxCmd(sb, withArgs("replay", "build", "docker-image://"+ref, "--dry-run"))
+ bout, err := cmd.CombinedOutput()
+ require.Error(t, err, string(bout))
+ require.Contains(t, string(bout), tc.err)
+ require.Contains(t, string(bout), "--provenance=mode=max")
+ })
+ }
+}
+
+func testReplaySecretRoundTrip(t *testing.T, sb integration.Sandbox) {
+ // Build with a declared secret so the provenance records a required
+ // secret ID. Replay without --secret must fail; with --secret passes.
+ registry := replayRegistry(t, sb)
+ ref := registry + "/buildx-replay-secret:" + replayTestTag(t)
+
+ secretFile := filepath.Join(t.TempDir(), "secret.txt")
+ require.NoError(t, os.WriteFile(secretFile, []byte("hunter2"), 0o600))
+
+ dockerfile := `# syntax=docker/dockerfile:1
+FROM alpine:latest
+RUN --mount=type=secret,id=api,required=true cp /run/secrets/api /secret
+`
+ contextRef, _ := buildReplayContext(t, dockerfile)
+
+ out, err := buildCmd(sb, withArgs(
+ "--output=type=registry,name="+ref,
+ "--secret=id=api,src="+secretFile,
+ "--attest=type=provenance,mode=max",
+ contextRef,
+ ))
+ require.NoError(t, err, out)
+
+ // Without --secret: fail with missing-secret exit code.
+ cmd := buildxCmd(sb, withArgs(
+ "replay", "build",
+ "docker-image://"+ref,
+ "--output=type=oci,dest="+filepath.Join(t.TempDir(), "out.oci.tar"),
+ ))
+ bout, err := cmd.CombinedOutput()
+ require.Error(t, err, string(bout))
+ require.Contains(t, string(bout), "missing required secrets", string(bout))
+
+ // With --secret: succeed.
+ cmd = buildxCmd(sb, withArgs(
+ "replay", "build",
+ "docker-image://"+ref,
+ "--secret=id=api,src="+secretFile,
+ "--output=type=oci,dest="+filepath.Join(t.TempDir(), "out.oci.tar"),
+ ))
+ bout, err = cmd.CombinedOutput()
+ require.NoError(t, err, string(bout))
+}
+
+func testReplayMultiPlatformRoundTrip(t *testing.T, sb integration.Sandbox) {
+ if !isRemoteMultiNodeWorker(sb) {
+ t.Skip("only testing with remote multi-node worker")
+ }
+ registry := replayRegistry(t, sb)
+ ref := registry + "/buildx-replay-mp:" + replayTestTag(t)
+
+ contextRef, _ := buildReplayContext(t, replayTestDockerfile)
+
+ out, err := buildCmd(sb, withArgs(
+ "--output=type=registry,name="+ref,
+ "--build-context=ctx=docker-image://alpine:latest",
+ "--attest=type=provenance,mode=max",
+ "--platform=linux/amd64,linux/arm64",
+ contextRef,
+ ))
+ require.NoError(t, err, out)
+
+ // Each platform of the index can be selected for replay.
+ for _, platform := range []string{"linux/amd64", "linux/arm64"} {
+ cmd := buildxCmd(sb, withArgs(
+ "replay", "build",
+ "docker-image://"+ref,
+ "--platform="+platform,
+ "--dry-run",
+ "--format=json",
+ ))
+ var stdout, stderr bytes.Buffer
+ cmd.Stdout = &stdout
+ cmd.Stderr = &stderr
+ require.NoError(t, cmd.Run(), stderr.String())
+ var plan struct {
+ Subjects []struct {
+ Descriptor ocispecs.Descriptor `json:"descriptor"`
+ } `json:"subjects"`
+ }
+ require.NoError(t, json.Unmarshal(stdout.Bytes(), &plan), "dry-run must emit JSON plan")
+ require.Len(t, plan.Subjects, 1)
+ require.NotNil(t, plan.Subjects[0].Descriptor.Platform)
+ require.Equal(t, platform, platforms.Format(*plan.Subjects[0].Descriptor.Platform))
+ }
+
+ // A selected platform replays.
+ dest := filepath.Join(t.TempDir(), "replay.oci.tar")
+ replay := buildxCmd(sb, withArgs(
+ "replay", "build",
+ "docker-image://"+ref,
+ "--platform=linux/arm64",
+ "--output=type=oci,dest="+dest,
+ ))
+ rout, err := replay.CombinedOutput()
+ require.NoError(t, err, string(rout))
+ require.FileExists(t, dest)
+
+ // Replaying every platform at once is rejected up front, including in
+ // dry-run.
+ cmd := buildxCmd(sb, withArgs(
+ "replay", "build",
+ "docker-image://"+ref,
+ "--platform=all",
+ "--dry-run",
+ ))
+ bout, err := cmd.CombinedOutput()
+ require.Error(t, err, string(bout))
+ require.Contains(t, string(bout), `--platform "all" is not supported by replay build`)
+}
+
+// testReplayDefaultPlatformUsesWorkerDefault checks that replay without
+// --platform selects the subject for the platform BuildKit builds by default,
+// not the first platform configured on the builder node.
+func testReplayDefaultPlatformUsesWorkerDefault(t *testing.T, sb integration.Sandbox) {
+ if !isRemoteWorker(sb) {
+ t.Skip("only testing with remote workers")
+ }
+ native := platforms.Format(platforms.Normalize(platforms.DefaultSpec()))
+ var other string
+ switch native {
+ case "linux/amd64":
+ other = "linux/arm64"
+ case "linux/arm64":
+ other = "linux/amd64"
+ default:
+ t.Skipf("unsupported host platform %s", native)
+ }
+ registry := replayRegistry(t, sb)
+ ref := registry + "/buildx-replay-default-platform:" + replayTestTag(t)
+
+ contextRef, _ := buildReplayContext(t, replayTestDockerfile)
+ out, err := buildCmd(sb, withArgs(
+ "--output=type=registry,name="+ref,
+ "--build-context=ctx=docker-image://alpine:latest",
+ "--attest=type=provenance,mode=max",
+ "--platform=linux/amd64,linux/arm64",
+ contextRef,
+ ))
+ require.NoError(t, err, out)
+
+ out, err = inspectCmd(sb)
+ require.NoError(t, err, out)
+ var endpoint string
+ for line := range strings.Lines(out) {
+ if v, ok := strings.CutPrefix(line, "Endpoint:"); ok {
+ endpoint = strings.TrimSpace(v)
+ break
+ }
+ }
+ require.NotEmpty(t, endpoint, out)
+
+ // Configure the same worker with the non-native platform listed first.
+ // BuildKit still builds for the worker's native platform by default.
+ name := "replay-default-platform-" + identity.NewID()
+ out, err = createCmd(sb, withArgs(
+ "--name="+name,
+ "--driver=remote",
+ "--platform="+other+","+native,
+ endpoint,
+ ))
+ require.NoError(t, err, out)
+ t.Cleanup(func() {
+ out, err := rmCmd(sb, withArgs(name))
+ require.NoError(t, err, out)
+ })
+ // Only the first node decides the default platform. A secondary node
+ // that cannot be booted must not be touched.
+ out, err = createCmd(sb, withArgs(
+ "--append",
+ "--name="+name,
+ "--driver=remote",
+ "tcp://127.0.0.1:1",
+ ))
+ require.NoError(t, err, out)
+
+ cmd := buildxCmd(sb, withArgs(
+ "replay", "build",
+ "docker-image://"+ref,
+ "--builder="+name,
+ "--dry-run",
+ "--format=json",
+ ))
+ var stdout, stderr bytes.Buffer
+ cmd.Stdout = &stdout
+ cmd.Stderr = &stderr
+ require.NoError(t, cmd.Run(), stderr.String())
+ var plan struct {
+ Subjects []struct {
+ Descriptor ocispecs.Descriptor `json:"descriptor"`
+ } `json:"subjects"`
+ }
+ require.NoError(t, json.Unmarshal(stdout.Bytes(), &plan), "dry-run must emit JSON plan")
+ require.Len(t, plan.Subjects, 1)
+ require.NotNil(t, plan.Subjects[0].Descriptor.Platform)
+ require.Equal(t, native, platforms.Format(*plan.Subjects[0].Descriptor.Platform))
+}
diff --git a/util/buildflags/policy.go b/util/buildflags/policy.go
index 0d1abb694b53..601d3494e6e2 100644
--- a/util/buildflags/policy.go
+++ b/util/buildflags/policy.go
@@ -5,6 +5,7 @@ import (
"strings"
"github.com/docker/buildx/policy"
+ "github.com/moby/buildkit/sourcepolicy/policysession"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"github.com/tonistiigi/go-csvvalue"
@@ -16,6 +17,13 @@ type PolicyConfig struct {
Disabled bool
Strict *bool
LogLevel *logrus.Level
+
+ // Callback, when non-nil, is a programmatic policy evaluator. Set in
+ // code (not from CLI flags) for cases like `buildx replay` that
+ // enforce pinning without loading a policy file. A config with a
+ // Callback must not set Files; callbacks run after the file-based
+ // policies of other configs.
+ Callback policysession.PolicyCallback `json:"-"`
}
func ParsePolicyConfigs(in []string) ([]PolicyConfig, error) {
diff --git a/util/buildflags/policy_test.go b/util/buildflags/policy_test.go
index 9359c7690dab..fbc7b27c7f53 100644
--- a/util/buildflags/policy_test.go
+++ b/util/buildflags/policy_test.go
@@ -1,16 +1,33 @@
package buildflags
import (
+ "context"
+ "encoding/json"
"os"
"path/filepath"
"testing"
"github.com/docker/buildx/policy"
+ gwpb "github.com/moby/buildkit/frontend/gateway/pb"
+ "github.com/moby/buildkit/sourcepolicy/policysession"
"github.com/sirupsen/logrus"
"github.com/stretchr/testify/require"
"github.com/zclconf/go-cty/cty"
)
+func TestPolicyConfigMarshalJSON(t *testing.T) {
+ for _, cfg := range []PolicyConfig{
+ {},
+ {Callback: policysession.PolicyCallback(func(_ context.Context, _ *policysession.CheckPolicyRequest) (*policysession.DecisionResponse, *gwpb.ResolveSourceMetaRequest, error) {
+ return nil, nil, nil
+ })},
+ } {
+ dt, err := json.Marshal(cfg)
+ require.NoError(t, err)
+ require.NotContains(t, string(dt), "Callback")
+ }
+}
+
func TestPolicyConfigs_FromCtyValue(t *testing.T) {
policyDir := t.TempDir()
policyPath := filepath.Join(policyDir, "policy.rego")
diff --git a/util/imagetools/inspect_test.go b/util/imagetools/inspect_test.go
index 67f10841b51a..b4e75434d6d3 100644
--- a/util/imagetools/inspect_test.go
+++ b/util/imagetools/inspect_test.go
@@ -51,3 +51,27 @@ func TestFetchReferrersOCILayoutArtifactTypeFilter(t *testing.T) {
require.Equal(t, attestation.Digest, refs[0].Digest)
require.Equal(t, attestation.ArtifactType, refs[0].ArtifactType)
}
+
+func TestFetchReferrersOCILayoutEmpty(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ idx := ociindex.NewStoreIndex(dir)
+ reachable := ocispecs.Descriptor{
+ MediaType: ocispecs.MediaTypeImageManifest,
+ Digest: digest.FromString("reachable manifest"),
+ Size: 123,
+ Annotations: map[string]string{
+ images.AnnotationManifestSubject: digest.FromString("different subject").String(),
+ },
+ }
+ require.NoError(t, idx.Put(reachable, ociindex.Tag("latest")))
+
+ loc, err := ParseLocation("oci-layout://" + dir + ":latest")
+ require.NoError(t, err)
+
+ r := New(Opt{})
+ refs, err := r.FetchReferrers(context.Background(), loc, digest.FromString("unsigned subject"))
+ require.NoError(t, err)
+ require.Empty(t, refs)
+}
diff --git a/util/imagetools/loader.go b/util/imagetools/loader.go
index d30fd49b615d..537bdaa60349 100644
--- a/util/imagetools/loader.go
+++ b/util/imagetools/loader.go
@@ -466,3 +466,81 @@ func withIntotoMediaTypes(ctx context.Context) context.Context {
}
return ctx
}
+
+// ReadBlobVerified reads a blob and checks that its content matches the size
+// and digest of the descriptor. Providers backed by a registry fetcher or an
+// OCI layout do not verify the content they return.
+func ReadBlobVerified(ctx context.Context, provider content.Provider, desc ocispecs.Descriptor) ([]byte, error) {
+ if err := desc.Digest.Validate(); err != nil {
+ return nil, errors.WithStack(err)
+ }
+ dt, err := content.ReadBlob(ctx, provider, desc)
+ if err != nil {
+ return nil, errors.WithStack(err)
+ }
+ if int64(len(dt)) != desc.Size {
+ return nil, errors.Errorf("blob %s size mismatch: expected %d, got %d", desc.Digest, desc.Size, len(dt))
+ }
+ if got := desc.Digest.Algorithm().FromBytes(dt); got != desc.Digest {
+ return nil, errors.Errorf("blob digest mismatch: expected %s, got %s", desc.Digest, got)
+ }
+ return dt, nil
+}
+
+// ReadProvenancePredicate loads the SLSA provenance predicate payload from the
+// attestation manifest referenced by attestManifest, reading blobs through the
+// supplied content provider and verifying them against their digests. Returns
+// the raw predicate JSON bytes and the predicate type URI. When the manifest
+// has no provenance layer both return values are empty without error so
+// callers can distinguish "no provenance" from a hard failure.
+//
+// This exposes the provenance scan + DSSE unwrap logic used internally by
+// scanProvenance for reuse by `buildx replay`.
+func ReadProvenancePredicate(ctx context.Context, provider content.Provider, attestManifest ocispecs.Descriptor) ([]byte, string, error) {
+ ctx = withIntotoMediaTypes(ctx)
+ dt, err := ReadBlobVerified(ctx, provider, attestManifest)
+ if err != nil {
+ return nil, "", errors.Wrap(err, "failed to read attestation manifest")
+ }
+ var mfst ocispecs.Manifest
+ if err := json.Unmarshal(dt, &mfst); err != nil {
+ return nil, "", errors.Wrap(err, "failed to unmarshal attestation manifest")
+ }
+ var (
+ layer ocispecs.Descriptor
+ predType string
+ )
+ for _, l := range mfst.Layers {
+ annot := l.Annotations["in-toto.io/predicate-type"]
+ if (l.MediaType == inTotoGenericMime || isInTotoDSSE(l.MediaType)) &&
+ strings.HasPrefix(annot, "https://slsa.dev/provenance/") {
+ layer = l
+ predType = annot
+ break
+ }
+ }
+ if predType == "" {
+ return nil, "", nil
+ }
+ layerDt, err := ReadBlobVerified(ctx, provider, layer)
+ if err != nil {
+ return nil, "", errors.Wrapf(err, "failed to read provenance layer %s", layer.Digest)
+ }
+ layerDt, err = decodeDSSE(layerDt, layer.MediaType)
+ if err != nil {
+ return nil, "", errors.Wrap(err, "failed to decode DSSE envelope")
+ }
+ var stmt struct {
+ Predicate json.RawMessage `json:"predicate"`
+ PredicateType string `json:"predicateType"`
+ }
+ if err := json.Unmarshal(layerDt, &stmt); err != nil {
+ return nil, "", errors.Wrap(err, "failed to unmarshal in-toto statement")
+ }
+ // Prefer the in-toto Statement's predicateType over the annotation when
+ // both are set — the annotation is a hint; the payload is canonical.
+ if stmt.PredicateType != "" {
+ predType = stmt.PredicateType
+ }
+ return stmt.Predicate, predType, nil
+}
diff --git a/util/imagetools/loader_test.go b/util/imagetools/loader_test.go
index b96e266354e6..32e36fb5ebfe 100644
--- a/util/imagetools/loader_test.go
+++ b/util/imagetools/loader_test.go
@@ -1,13 +1,19 @@
package imagetools
import (
+ "bytes"
"context"
"encoding/base64"
+ "encoding/json"
"fmt"
"reflect"
"testing"
+ "github.com/containerd/containerd/v2/core/content"
+ "github.com/containerd/errdefs"
"github.com/opencontainers/go-digest"
+ ocispecs "github.com/opencontainers/image-spec/specs-go/v1"
+ "github.com/pkg/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -192,3 +198,58 @@ func Test_decodeDSSE(t *testing.T) {
_, err = decodeDSSE([]byte("{\"payload\": \"not base64\"}"), "application/vnd.in-toto.spdx+dsse")
require.Error(t, err)
}
+
+// blobMap is a content.Provider that serves whatever bytes are stored for a
+// digest, like a registry fetcher or an OCI layout store would.
+type blobMap map[digest.Digest][]byte
+
+func (m blobMap) ReaderAt(_ context.Context, desc ocispecs.Descriptor) (content.ReaderAt, error) {
+ dt, ok := m[desc.Digest]
+ if !ok {
+ return nil, errors.WithStack(errdefs.ErrNotFound)
+ }
+ return &bytesReaderAt{Reader: bytes.NewReader(dt)}, nil
+}
+
+type bytesReaderAt struct {
+ *bytes.Reader
+}
+
+func (r *bytesReaderAt) Close() error { return nil }
+
+func TestReadProvenancePredicateVerifiesDigests(t *testing.T) {
+ stmt := []byte(`{"_type":"https://in-toto.io/Statement/v1","predicateType":"https://slsa.dev/provenance/v1","predicate":{"buildDefinition":{"buildType":"recorded"}}}`)
+ layer := ocispecs.Descriptor{
+ MediaType: inTotoGenericMime,
+ Digest: digest.FromBytes(stmt),
+ Size: int64(len(stmt)),
+ Annotations: map[string]string{"in-toto.io/predicate-type": "https://slsa.dev/provenance/v1"},
+ }
+ mfstDt, err := json.Marshal(ocispecs.Manifest{MediaType: ocispecs.MediaTypeImageManifest, Layers: []ocispecs.Descriptor{layer}})
+ require.NoError(t, err)
+ mfst := ocispecs.Descriptor{
+ MediaType: ocispecs.MediaTypeImageManifest,
+ Digest: digest.FromBytes(mfstDt),
+ Size: int64(len(mfstDt)),
+ }
+
+ provider := blobMap{mfst.Digest: mfstDt, layer.Digest: stmt}
+ pred, predType, err := ReadProvenancePredicate(context.Background(), provider, mfst)
+ require.NoError(t, err)
+ require.Equal(t, "https://slsa.dev/provenance/v1", predType)
+ require.JSONEq(t, `{"buildDefinition":{"buildType":"recorded"}}`, string(pred))
+
+ // A provider serving different content of the same size for the recorded
+ // digest must not be trusted.
+ tampered := bytes.Replace(stmt, []byte("recorded"), []byte("replaced"), 1)
+ require.Len(t, tampered, len(stmt))
+ _, _, err = ReadProvenancePredicate(context.Background(), blobMap{mfst.Digest: mfstDt, layer.Digest: tampered}, mfst)
+ require.ErrorContains(t, err, "digest mismatch")
+
+ tamperedMfst := bytes.Replace(mfstDt, []byte(`"layers"`), []byte(`"Layers"`), 1)
+ _, _, err = ReadProvenancePredicate(context.Background(), blobMap{mfst.Digest: tamperedMfst, layer.Digest: stmt}, mfst)
+ require.ErrorContains(t, err, "digest mismatch")
+
+ _, err = ReadBlobVerified(context.Background(), blobMap{layer.Digest: stmt[:len(stmt)-1]}, layer)
+ require.ErrorContains(t, err, "size mismatch")
+}
diff --git a/util/imagetools/ocilayout_referrers.go b/util/imagetools/ocilayout_referrers.go
index 6d186218add4..dc9ef66fda18 100644
--- a/util/imagetools/ocilayout_referrers.go
+++ b/util/imagetools/ocilayout_referrers.go
@@ -8,7 +8,6 @@ import (
"github.com/containerd/containerd/v2/core/images"
"github.com/containerd/containerd/v2/core/remotes"
- "github.com/containerd/errdefs"
"github.com/moby/buildkit/client/ociindex"
"github.com/moby/buildkit/util/attestation"
"github.com/opencontainers/go-digest"
@@ -79,7 +78,10 @@ func fetchOCILayoutReferrers(ctx context.Context, getDescriptor func(context.Con
}
if len(out) == 0 {
- return nil, errors.WithStack(errdefs.ErrNotFound)
+ // ReferrersFetcher treats an absent referrer set as a successful empty
+ // result. Match registry behavior so callers can distinguish an
+ // unsigned subject from a failure to read the OCI layout itself.
+ return nil, nil
}
refs := make([]ocispecs.Descriptor, 0, len(out))
]