diff --git a/.changes/unreleased/+image-destination-evidence.yaml b/.changes/unreleased/+image-destination-evidence.yaml new file mode 100644 index 00000000..d2db081f --- /dev/null +++ b/.changes/unreleased/+image-destination-evidence.yaml @@ -0,0 +1,2 @@ +kind: Added +body: Bind mapped-image mount destination evidence to immutable build generations, with bounded layered-image projection and exact mount-policy reuse checks. Public mapped execution remains gated until host observation and launch qualification complete. diff --git a/internal/deploy/build_lock.go b/internal/deploy/build_lock.go index 9d622d2c..21a928f0 100644 --- a/internal/deploy/build_lock.go +++ b/internal/deploy/build_lock.go @@ -35,6 +35,7 @@ type BuildLockV1 struct { ValidationRecord providerstore.StoreObjectRef `json:"validation_record"` FinalImage providers.RealizedImageV1 `json:"final_image"` IdentityPolicy *IdentityPolicyLockV1 `json:"identity_policy,omitempty"` + ImageDestinations *ImageDestinationEvidenceV1 `json:"image_destinations,omitempty"` } type ProviderGraphLockV1 struct { @@ -188,7 +189,13 @@ func ValidateBuildLockV1(lock BuildLockV1, validateProfileOwner providers.Requir if err := lock.FinalImage.Validate(); err != nil { return fmt.Errorf("build lock final image: %w", err) } - return ValidateIdentityPolicyLockV1(lock.IdentityPolicy, lock.FinalImage, lock.Platform) + if err := ValidateIdentityPolicyLockV1(lock.IdentityPolicy, lock.FinalImage, lock.Platform); err != nil { + return err + } + if lock.ImageDestinations != nil && lock.IdentityPolicy == nil { + return fmt.Errorf("image destination evidence requires identity policy") + } + return ValidateImageDestinationEvidenceV1(lock.ImageDestinations, lock.FinalImage, lock.Platform) } func validateBuildLockPortableToolPlan(lock BuildLockV1) error { diff --git a/internal/deploy/image_destination_evidence.go b/internal/deploy/image_destination_evidence.go new file mode 100644 index 00000000..dea11a19 --- /dev/null +++ b/internal/deploy/image_destination_evidence.go @@ -0,0 +1,113 @@ +package deploy + +import ( + "fmt" + "path" + "slices" + "strings" + + "github.com/omry/reploy/internal/blueprint" + "github.com/omry/reploy/internal/canonical" + "github.com/omry/reploy/internal/providers" +) + +const ImageDestinationEvidenceSchemaV1 = "image-destination-evidence-v1" + +// ImageDestinationEvidenceV1 is portable preparation evidence, not host admission. +// The existing immutable build lock authenticates these producer-derived facts. +type ImageDestinationEvidenceV1 struct { + Schema string `json:"schema"` + Image providers.RealizedImageV1 `json:"image"` + Platform blueprint.Platform `json:"platform"` + SelectionDigest canonical.Digest `json:"selection_digest"` + RootWritable bool `json:"root_writable"` + Destinations []ImageDestinationV1 `json:"destinations"` +} + +type ImageDestinationV1 struct { + Name string `json:"name"` + Target string `json:"target"` + Resolved string `json:"resolved"` + Kind string `json:"kind"` + ContainsContent bool `json:"contains_content"` +} + +func ImageDestinationSelectionDigestV1(document blueprint.Document) (canonical.Digest, error) { + if err := document.ValidateMountDeclarations(); err != nil { + return "", err + } + return canonical.Sum("image-destination-selection", ImageDestinationEvidenceSchemaV1, struct { + RootWritable bool `json:"root_writable"` + Mounts map[string]blueprint.DockerMount `json:"mounts"` + }{document.Environment.ImageRootWritable(), document.Docker.Mounts}) +} + +func validImageDestinationPathV1(value string) bool { + return value != "/" && len(value) <= 4096 && path.IsAbs(value) && path.Clean(value) == value && !strings.ContainsAny(value, "\\\x00\r\n\t") +} + +func ValidateImageDestinationEvidenceV1(evidence *ImageDestinationEvidenceV1, image providers.RealizedImageV1, platform blueprint.Platform) error { + if evidence == nil { + return nil + } + fail := func() error { return fmt.Errorf("image destination evidence: invalid structure or binding") } + if image.Validate() != nil || platform.Validate() != nil || platform.OS != "linux" || evidence.Schema != ImageDestinationEvidenceSchemaV1 || evidence.Image != image || evidence.Platform != platform || evidence.SelectionDigest.Validate() != nil || evidence.Destinations == nil { + return fail() + } + for i, entry := range evidence.Destinations { + if entry.Name == "" || i > 0 && evidence.Destinations[i-1].Name >= entry.Name || !validImageDestinationPathV1(entry.Target) || !validImageDestinationPathV1(entry.Resolved) { + return fail() + } + switch entry.Kind { + case "absent": + if entry.ContainsContent { + return fail() + } + case "directory": + case "non-directory": + if !entry.ContainsContent { + return fail() + } + default: + return fail() + } + } + return nil +} + +// Require exact selected intent. Rebinding a blueprint cannot manufacture facts +// for a changed image, mount list, metadata contract or intrinsic root policy. +func ValidateSelectedImageDestinationsV1(document blueprint.Document, lock BuildLockV1) error { + evidence := lock.ImageDestinations + if document.Environment.Runtime.IdentityPolicy == nil { + if evidence != nil { + return fmt.Errorf("image destination evidence requires mapped identity policy") + } + return nil + } + digest, err := ImageDestinationSelectionDigestV1(document) + if err != nil { + return err + } + if evidence == nil || evidence.SelectionDigest != digest || evidence.RootWritable != document.Environment.ImageRootWritable() { + return fmt.Errorf("image destination evidence missing or stale; rebuild required") + } + if err := ValidateImageDestinationEvidenceV1(evidence, lock.FinalImage, lock.Platform); err != nil { + return err + } + names := make([]string, 0, len(document.Docker.Mounts)) + for name := range document.Docker.Mounts { + names = append(names, name) + } + slices.Sort(names) + if len(names) != len(evidence.Destinations) { + return fmt.Errorf("image destination inventory differs from selected mounts") + } + for i, name := range names { + entry := evidence.Destinations[i] + if entry.Name != name || entry.Target != document.Docker.Mounts[name].Contract.Target { + return fmt.Errorf("image destination inventory differs from selected mounts") + } + } + return nil +} diff --git a/internal/deploy/image_destination_evidence_test.go b/internal/deploy/image_destination_evidence_test.go new file mode 100644 index 00000000..5d4d550d --- /dev/null +++ b/internal/deploy/image_destination_evidence_test.go @@ -0,0 +1,145 @@ +package deploy + +import ( + "bytes" + "encoding/json" + "reflect" + "testing" + + "github.com/omry/reploy/internal/blueprint" + "github.com/omry/reploy/internal/canonical" +) + +func imageDestinationLockFixtureV1(t *testing.T) (blueprint.Document, BuildLockV1) { + t.Helper() + lock := identityPolicyBuildLockFixtureV1(t) + var document blueprint.Document + document.Environment.Runtime.IdentityPolicy = &lock.IdentityPolicy.Policy + mount := blueprint.EnvironmentMount{Target: "/data", Writable: true, UpdatePolicy: blueprint.UpdatePreserve} + document.Environment.Mounts = map[string]blueprint.EnvironmentMount{"data": mount} + document.Docker.Mounts = map[string]blueprint.DockerMount{"data": {Extends: "environment.mounts.data", Mode: blueprint.MountManagedBind, Source: "data", Contract: mount}} + digest, err := ImageDestinationSelectionDigestV1(document) + if err != nil { + t.Fatal(err) + } + lock.ImageDestinations = &ImageDestinationEvidenceV1{Schema: ImageDestinationEvidenceSchemaV1, Image: lock.FinalImage, Platform: lock.Platform, SelectionDigest: digest, Destinations: []ImageDestinationV1{{Name: "data", Target: "/data", Resolved: "/real-data", Kind: "directory"}}} + return document, lock +} +func TestImageDestinationEvidenceV1CanonicalLockAndOmission(t *testing.T) { + document, lock := imageDestinationLockFixtureV1(t) + if err := ValidateSelectedImageDestinationsV1(document, lock); err != nil { + t.Fatal(err) + } + before, err := EncodeBuildLockV1(lock, acceptBuildLockProfile) + if err != nil { + t.Fatal(err) + } + decoded, err := DecodeBuildLockV1(before, acceptBuildLockProfile) + if err != nil || !reflect.DeepEqual(decoded, lock) { + t.Fatal("round trip", err) + } + originalDigest, err := BuildLockDigestV1(lock, acceptBuildLockProfile) + if err != nil { + t.Fatal(err) + } + lock.ImageDestinations.Destinations[0].ContainsContent = true + changedDigest, err := BuildLockDigestV1(lock, acceptBuildLockProfile) + if err != nil || changedDigest == originalDigest { + t.Fatal("content fact is not bound to lock identity", err) + } + ordinary := validBuildLock(t) + without, err := EncodeBuildLockV1(ordinary, acceptBuildLockProfile) + if err != nil { + t.Fatal(err) + } + if bytes.Contains(without, []byte("image_destinations")) { + t.Fatal("ordinary lock acquired field") + } + // Compare to the exact legacy object shape, without round-tripping through the + // new Go struct: adding an omitted optional field must not alter wire bytes. + var legacy map[string]any + if err := json.Unmarshal(without, &legacy); err != nil { + t.Fatal(err) + } + expected, err := canonical.Marshal(legacy) + if err != nil || !bytes.Equal(expected, without) { + t.Fatal("ordinary canonical bytes changed", err) + } +} +func TestImageDestinationEvidenceV1ClosedStructure(t *testing.T) { + for name, mutate := range map[string]func(*ImageDestinationEvidenceV1){ + "schema": func(e *ImageDestinationEvidenceV1) { e.Schema = "other" }, + "image": func(e *ImageDestinationEvidenceV1) { e.Image.Digest = buildLockTestDigest("e") }, + "config": func(e *ImageDestinationEvidenceV1) { e.Image.ConfigDigest = buildLockTestDigest("e") }, + "rootfs": func(e *ImageDestinationEvidenceV1) { e.Image.RootFSSubject = buildLockTestDigest("e") }, + "platform": func(e *ImageDestinationEvidenceV1) { e.Platform.Architecture = "arm64" }, + "selection": func(e *ImageDestinationEvidenceV1) { e.SelectionDigest = "bad" }, + "null inventory": func(e *ImageDestinationEvidenceV1) { e.Destinations = nil }, + "duplicate": func(e *ImageDestinationEvidenceV1) { e.Destinations = append(e.Destinations, e.Destinations[0]) }, + "name": func(e *ImageDestinationEvidenceV1) { e.Destinations[0].Name = "" }, + "target": func(e *ImageDestinationEvidenceV1) { e.Destinations[0].Target = "relative" }, + "resolved root": func(e *ImageDestinationEvidenceV1) { e.Destinations[0].Resolved = "/" }, + "resolved traversal": func(e *ImageDestinationEvidenceV1) { e.Destinations[0].Resolved = "/a/../data" }, + "resolved host spelling": func(e *ImageDestinationEvidenceV1) { e.Destinations[0].Resolved = `C:\data` }, + "kind": func(e *ImageDestinationEvidenceV1) { e.Destinations[0].Kind = "empty" }, + "absent content": func(e *ImageDestinationEvidenceV1) { + e.Destinations[0].Kind = "absent" + e.Destinations[0].ContainsContent = true + }, + "non-directory empty": func(e *ImageDestinationEvidenceV1) { e.Destinations[0].Kind = "non-directory" }, + } { + t.Run(name, func(t *testing.T) { + _, lock := imageDestinationLockFixtureV1(t) + mutate(lock.ImageDestinations) + if err := ValidateBuildLockV1(lock, acceptBuildLockProfile); err == nil { + t.Fatal("malformed projection accepted") + } + }) + } + document, lock := imageDestinationLockFixtureV1(t) + lock.IdentityPolicy = nil + if err := ValidateBuildLockV1(lock, acceptBuildLockProfile); err == nil { + t.Fatal("projection without mapped policy accepted") + } + document.Environment.Runtime.IdentityPolicy = nil + if err := ValidateSelectedImageDestinationsV1(document, lock); err == nil { + t.Fatal("ordinary selected blueprint accepted mapped projection") + } + _, lock = imageDestinationLockFixtureV1(t) + raw, err := EncodeBuildLockV1(lock, acceptBuildLockProfile) + if err != nil { + t.Fatal(err) + } + raw = bytes.Replace(raw, []byte(`"kind":"directory"`), []byte(`"kind":"directory","trust_me":true`), 1) + if _, err := DecodeBuildLockV1(raw, acceptBuildLockProfile); err == nil { + t.Fatal("unknown nested projection field accepted") + } +} +func TestImageDestinationEvidenceV1SelectionInventory(t *testing.T) { + for _, change := range []string{"missing", "root", "selection", "target", "name", "extra", "fewer"} { + t.Run(change, func(t *testing.T) { + document, lock := imageDestinationLockFixtureV1(t) + switch change { + case "missing": + lock.ImageDestinations = nil + case "root": + lock.ImageDestinations.RootWritable = true + case "selection": + lock.ImageDestinations.SelectionDigest = buildLockTestDigest("e") + case "target": + lock.ImageDestinations.Destinations[0].Target = "/different" + case "name": + lock.ImageDestinations.Destinations[0].Name = "other" + case "extra": + e := lock.ImageDestinations.Destinations[0] + e.Name = "z" + lock.ImageDestinations.Destinations = append(lock.ImageDestinations.Destinations, e) + case "fewer": + lock.ImageDestinations.Destinations = []ImageDestinationV1{} + } + if err := ValidateSelectedImageDestinationsV1(document, lock); err == nil { + t.Fatal("stale selected projection accepted") + } + }) + } +} diff --git a/internal/dockerdeploy/build_lock_assembly.go b/internal/dockerdeploy/build_lock_assembly.go index 5eedbac9..3b5b7d9d 100644 --- a/internal/dockerdeploy/build_lock_assembly.go +++ b/internal/dockerdeploy/build_lock_assembly.go @@ -27,6 +27,7 @@ type BuildLockAssemblyInput struct { ValidationRecord providerstore.StoreObjectRef FinalImage providers.RealizedImageV1 IdentityPolicy *deploy.IdentityPolicyLockV1 + ImageDestinations *deploy.ImageDestinationEvidenceV1 } // AssembleBuildLock publishes the graph's canonical bundle manifests and @@ -170,7 +171,8 @@ func AssembleBuildLock( PortableRuntimeLayer: input.PortableRuntimeLayer, RuntimePolicy: input.RuntimePolicy, RuntimeLayer: input.RuntimeLayer, ValidationRecord: input.ValidationRecord, FinalImage: input.FinalImage, - IdentityPolicy: input.IdentityPolicy, + IdentityPolicy: input.IdentityPolicy, + ImageDestinations: input.ImageDestinations, } if input.PortableRuntimeLayer != nil { if portableTools == nil { diff --git a/internal/dockerdeploy/controlled_session_supervisor_test.go b/internal/dockerdeploy/controlled_session_supervisor_test.go index 2db28d18..8c629f2c 100644 --- a/internal/dockerdeploy/controlled_session_supervisor_test.go +++ b/internal/dockerdeploy/controlled_session_supervisor_test.go @@ -1213,12 +1213,13 @@ func TestRunControlledSessionV1CapsShutdownAtOutputDeadline(t *testing.T) { func TestRunControlledSessionV1AppliesControllerRequests(t *testing.T) { plan := controlledSessionControllerIntegrationPlanV1(t, "test-image", []string{"/controller"}) requests := make(chan controlledsession.RequestV1, 8) - requests <- controlledsession.RequestV1{Kind: controlledsession.RequestInputV1, Bytes: []byte{0, 1, 0xff}} - requests <- controlledsession.RequestV1{Kind: controlledsession.RequestResizeV1, Columns: 132, Rows: 43} - requests <- controlledsession.RequestV1{Kind: controlledsession.RequestTerminateV1} transport := &fakeControlledSessionTransportV1{requests: requests} transport.onEvent = func(event controlledsession.EventV1) { switch event.Kind { + case controlledsession.EventReadyV1: + requests <- controlledsession.RequestV1{Kind: controlledsession.RequestInputV1, Bytes: []byte{0, 1, 0xff}} + requests <- controlledsession.RequestV1{Kind: controlledsession.RequestResizeV1, Columns: 132, Rows: 43} + requests <- controlledsession.RequestV1{Kind: controlledsession.RequestTerminateV1} case controlledsession.EventWorkloadOutputsFinalizedV1: requests <- controlledsession.RequestV1{Kind: controlledsession.RequestCompleteV1} case controlledsession.EventTerminatedV1: diff --git a/internal/dockerdeploy/identity_policy_lock.go b/internal/dockerdeploy/identity_policy_lock.go index 97e9516b..6e440b05 100644 --- a/internal/dockerdeploy/identity_policy_lock.go +++ b/internal/dockerdeploy/identity_policy_lock.go @@ -15,7 +15,7 @@ var errIdentityPolicyBlueprintMismatchV1 = errors.New("identity policy selected func validateDocumentIdentityPolicyV1(document blueprint.Document, lock deploy.BuildLockV1) error { policy := document.Environment.Runtime.IdentityPolicy if policy == nil && lock.IdentityPolicy == nil { - return nil + return deploy.ValidateSelectedImageDestinationsV1(document, lock) } if policy == nil || lock.IdentityPolicy == nil { return fmt.Errorf("identity policy requires matching immutable-image identity evidence") @@ -41,7 +41,7 @@ func validateDocumentIdentityPolicyV1(document blueprint.Document, lock deploy.B if !bytes.Equal(wanted, locked) { return fmt.Errorf("identity policy differs from locked image evidence") } - return nil + return deploy.ValidateSelectedImageDestinationsV1(document, lock) } // Authenticate the selected bytes before consulting their policy or inventory. @@ -50,7 +50,7 @@ func validateSelectedIdentityPolicyV1(payload blueprint.ResolvedDocumentV1, lock if err != nil { return err } - if document.Environment.Runtime.IdentityPolicy == nil && lock.IdentityPolicy == nil { + if document.Environment.Runtime.IdentityPolicy == nil && lock.IdentityPolicy == nil && lock.ImageDestinations == nil { return nil } digest, err := blueprint.ResolvedDocumentDigestV1(payload) diff --git a/internal/dockerdeploy/identity_policy_lock_test.go b/internal/dockerdeploy/identity_policy_lock_test.go index f89a0acf..0ba876c8 100644 --- a/internal/dockerdeploy/identity_policy_lock_test.go +++ b/internal/dockerdeploy/identity_policy_lock_test.go @@ -50,6 +50,7 @@ func bindBuildIdentityFixtureV1(t *testing.T, current *CurrentBuild, document bl Schema: deploy.IdentityPolicyLockSchemaV1, Policy: *policy, Evidence: buildIdentityEvidenceV1(t, policy, current.Lock.FinalImage, current.Lock.Platform), } + current.Lock.ImageDestinations = emptyImageDestinationEvidenceV1(t, document, current.Lock.FinalImage, current.Lock.Platform) refreshCurrentBuildReuseGeneration(t, current) } @@ -68,19 +69,21 @@ func TestIdentityPolicyLockV1CompletionCollectsExactFinalImageBeforePublication( order := []string{} var session any var locked *deploy.IdentityPolicyLockV1 + var projected *deploy.ImageDestinationEvidenceV1 backend := providerBuildCompletionBackend{ validateAndFinalize: func(ctx context.Context, _ providerstore.Store, _ []FullImageValidationInput, _ FullImageValidationInput, _ providers.RequirementProfileOwnerValidator, _ FullImageValidationRunner, _ deploy.ApplicationStartupVerifierV1, _ deploy.ApplicationLocalAccountV1, _ RunOptions) (FinalizedBuildValidationResult, error) { order = append(order, "finalize") session = ctx.Value(generatedBuilderSessionKey{}) return FinalizedBuildValidationResult{Image: InspectedImageCandidate{Image: finalImage}, Candidate: candidate}, nil }, - identityEvidence: func(ctx context.Context, policy *blueprint.IdentityPolicyV1, image providers.RealizedImageV1, platform blueprint.Platform) (deploy.ImageIdentityEvidenceV1, error) { + identityEvidence: func(ctx context.Context, document blueprint.Document, image providers.RealizedImageV1, platform blueprint.Platform) (preparedImageEvidenceV1, error) { + policy := document.Environment.Runtime.IdentityPolicy order = append(order, "evidence") if session == nil || session != ctx.Value(generatedBuilderSessionKey{}) || image != finalImage || platform != input.ResolvedRequest.Platform || !reflect.DeepEqual(policy, input.Document.Environment.Runtime.IdentityPolicy) { t.Fatal("evidence did not use the exact final image, policy, platform and engine session") } if outcome == "export failure" { - return deploy.ImageIdentityEvidenceV1{}, errors.New("export failed") + return preparedImageEvidenceV1{}, errors.New("export failed") } if outcome == "upstream evidence" { image = input.Validation.Final.Image.Image @@ -88,26 +91,30 @@ func TestIdentityPolicyLockV1CompletionCollectsExactFinalImageBeforePublication( if outcome == "cancel" { cancel() } - return buildIdentityEvidenceV1(t, policy, image, platform), nil + return preparedImageEvidenceV1{Identity: buildIdentityEvidenceV1(t, policy, image, platform), Destinations: emptyImageDestinationEvidenceV1(t, document, image, platform)}, nil }, assemble: func(_ context.Context, _ providerstore.Store, got BuildLockAssemblyInput) (deploy.BuildLockV1, error) { order = append(order, "assemble") locked = got.IdentityPolicy + projected = got.ImageDestinations + if projected == nil || projected.Image != finalImage { + t.Fatal("assembly lost final-image destination evidence") + } if locked == nil || locked.Evidence.Image != finalImage || got.FinalImage != finalImage { t.Fatal("assembly lost final-image evidence") } - return deploy.BuildLockV1{IdentityPolicy: locked}, nil + return deploy.BuildLockV1{IdentityPolicy: locked, ImageDestinations: projected}, nil }, publish: func(_ context.Context, _ *deploy.OperationLock, _ providerstore.Store, got BuildPublicationInput) (deploy.StateV1, error) { order = append(order, "publish") - if got.Lock.IdentityPolicy != locked { + if got.Lock.IdentityPolicy != locked || got.Lock.ImageDestinations != projected { t.Fatal("publication lost evidence") } return deploy.StateV1{}, nil }, publishValidated: func(_ context.Context, _ *deploy.OperationLock, _ providerstore.Store, _, _ string, got deploy.BuildLockV1, _ ValidatedBuildInputsV1) (deploy.ValidatedBuildV1, error) { order = append(order, "publish") - if got.IdentityPolicy != locked { + if got.IdentityPolicy != locked || got.ImageDestinations != projected { t.Fatal("validated publication lost evidence") } return deploy.ValidatedBuildV1{}, nil @@ -207,6 +214,7 @@ func TestIdentityPolicyLockV1ReuseAcceptsExactEvidenceAndRejectsMissingEvidence( t.Fatalf("exact policy reuse: %v %v", match, err) } current.Lock.IdentityPolicy = nil + current.Lock.ImageDestinations = nil refreshCurrentBuildReuseGeneration(t, ¤t) match, err = CurrentBuildMatches(current, input) if err == nil || match { diff --git a/internal/dockerdeploy/image_destination_evidence_test.go b/internal/dockerdeploy/image_destination_evidence_test.go new file mode 100644 index 00000000..5a450e52 --- /dev/null +++ b/internal/dockerdeploy/image_destination_evidence_test.go @@ -0,0 +1,163 @@ +package dockerdeploy + +import ( + "context" + "fmt" + "io" + "reflect" + "sort" + "testing" + + "github.com/omry/reploy/internal/blueprint" + "github.com/omry/reploy/internal/deploy" + "github.com/omry/reploy/internal/providers" +) + +// Existing D controls exercise the same production reader and exporter, with +// no declared mount destinations. They retain their account-only test API. +func identityDestinationDocumentV1(policy *blueprint.IdentityPolicyV1) blueprint.Document { + var document blueprint.Document + document.Environment.Runtime.IdentityPolicy = policy + return document +} +func readDockerImageIdentityArchiveV1(ctx context.Context, input io.Reader, policy *blueprint.IdentityPolicyV1, image providers.RealizedImageV1, platform blueprint.Platform) (deploy.ImageIdentityEvidenceV1, error) { + result, err := readDockerPreparedImageArchiveV1(ctx, input, identityDestinationDocumentV1(policy), image, platform) + return result.Identity, err +} +func exportDockerImageIdentityV1(ctx context.Context, policy *blueprint.IdentityPolicyV1, image providers.RealizedImageV1, platform blueprint.Platform) (deploy.ImageIdentityEvidenceV1, error) { + result, err := exportDockerPreparedImageV1(ctx, identityDestinationDocumentV1(policy), image, platform) + return result.Identity, err +} + +// Unit fixtures model an image with absent data destinations. Production facts +// are produced only by the fully verified immutable archive reader. +func emptyImageDestinationEvidenceV1(t *testing.T, document blueprint.Document, image providers.RealizedImageV1, platform blueprint.Platform) *deploy.ImageDestinationEvidenceV1 { + t.Helper() + digest, err := deploy.ImageDestinationSelectionDigestV1(document) + if err != nil { + t.Fatal(err) + } + e := &deploy.ImageDestinationEvidenceV1{Schema: deploy.ImageDestinationEvidenceSchemaV1, Image: image, Platform: platform, SelectionDigest: digest, RootWritable: document.Environment.ImageRootWritable(), Destinations: []deploy.ImageDestinationV1{}} + names := []string{} + for name := range document.Docker.Mounts { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + target := document.Docker.Mounts[name].Contract.Target + e.Destinations = append(e.Destinations, deploy.ImageDestinationV1{Name: name, Target: target, Resolved: target, Kind: "absent"}) + } + return e +} + +func TestImageDestinationEvidenceV1ReuseAndRebind(t *testing.T) { + for _, change := range []string{"exact", "missing", "image", "root", "mount-source", "mount-target", "metadata", "fact", "invalid-fact-structure"} { + t.Run(change, func(t *testing.T) { + current, input := currentBuildReuseFixture(t) + input.Document = buildIdentityDocumentV1(input.Document) + mount := blueprint.EnvironmentMount{Target: "/data", Writable: true, UpdatePolicy: blueprint.UpdatePreserve} + input.Document.Environment.Mounts = map[string]blueprint.EnvironmentMount{"data": mount} + input.Document.Docker.Mounts = map[string]blueprint.DockerMount{"data": {Extends: "environment.mounts.data", Mode: blueprint.MountManagedBind, Source: "data", Contract: mount}} + bindBuildIdentityFixtureV1(t, ¤t, input.Document) + // The existing selected-byte, runtime-policy and generation checks remain in + // place; these mutations must not be repaired by automatic projection rebind. + switch change { + case "missing": + current.Lock.ImageDestinations = nil + case "image": + current.Lock.ImageDestinations.Image.RootFSSubject = current.Lock.FinalImage.ConfigDigest + case "root": + input.Document.Environment.Mounts["root"] = blueprint.EnvironmentMount{Target: "/", Writable: true} + case "mount-source": + m := input.Document.Docker.Mounts["data"] + m.Source = "different" + input.Document.Docker.Mounts["data"] = m + case "mount-target": + m := input.Document.Docker.Mounts["data"] + m.Contract.Target = "/different" + input.Document.Docker.Mounts["data"] = m + case "metadata": + m := input.Document.Environment.Mounts["data"] + m.Permissions = "0750" + input.Document.Environment.Mounts["data"] = m + b := input.Document.Docker.Mounts["data"] + b.Contract = m + input.Document.Docker.Mounts["data"] = b + case "fact": + current.Lock.ImageDestinations.Destinations[0].Kind = "directory" + case "invalid-fact-structure": + current.Lock.ImageDestinations.Destinations[0].Kind = "invented" // Structure validation is independent of the enclosing lock digest. + } + if change == "exact" { + rebound, err := rebindCurrentBuildLockV1(current, input.Document) + if err != nil || !reflect.DeepEqual(rebound.ImageDestinations, current.Lock.ImageDestinations) { + t.Fatal("exact projection rebind failed", err) + } + return + } + if change == "fact" { + if _, err := CurrentBuildMatches(current, input); err == nil { + t.Fatal("changed facts accepted under original generation digest") + } + return + } + if change == "invalid-fact-structure" { + if err := deploy.ValidateImageDestinationEvidenceV1(current.Lock.ImageDestinations, current.Lock.FinalImage, current.Lock.Platform); err == nil { + t.Fatal("unsupported fact kind accepted") + } + return + } + if _, err := rebindCurrentBuildLockV1(current, input.Document); err == nil { + t.Fatal("missing or changed projection rebound") + } + }) + } +} + +func TestImageDestinationEvidenceV1PublicationRefusesBeforeEffects(t *testing.T) { + for _, validated := range []bool{false, true} { + for _, change := range []string{"missing", "root", "selection", "extra"} { + t.Run(fmt.Sprint(validated)+"/"+change, func(t *testing.T) { + dir, operation, store, lock, state := currentBuildFixture(t, false) + defer operation.Unlock() + document, err := blueprint.DecodeResolvedDocumentV1(state.Blueprint) + if err != nil { + t.Fatal(err) + } + document = buildIdentityDocumentV1(document) + current := CurrentBuild{State: state, Generation: *state.Current, Lock: lock} + bindBuildIdentityFixtureV1(t, ¤t, document) + switch change { + case "missing": + current.Lock.ImageDestinations = nil + case "root": + current.Lock.ImageDestinations.RootWritable = true + case "selection": + current.Lock.ImageDestinations.SelectionDigest = current.Lock.FinalImage.ConfigDigest + case "extra": + current.Lock.ImageDestinations.Destinations = append(current.Lock.ImageDestinations.Destinations, deploy.ImageDestinationV1{Name: "extra", Target: "/extra", Resolved: "/extra", Kind: "absent"}) + } + before := identityGuardTreeV1(t, dir) + effects := 0 + if validated { + inputs, inputErr := ValidatedBuildInputs(document, state.Overlay, deploy.EmptyPackageOverridesV1("demo"), dir, state.Platform) + if inputErr != nil { + t.Fatal(inputErr) + } + _, err = publishValidatedBuild(t.Context(), operation, store, "demo", dir, current.Lock, inputs, publishValidatedBuildBackendV1{newReferences: func(string, string) (EnvironmentImageReferences, error) { + effects++ + return EnvironmentImageReferences{}, fmt.Errorf("unreachable") + }}) + } else { + _, err = publishBuild(t.Context(), operation, store, BuildPublicationInput{Environment: "demo", DeploymentDir: dir, Document: document, Lock: current.Lock}, buildPublicationBackend{newReferences: func(string, string) (EnvironmentImageReferences, error) { + effects++ + return EnvironmentImageReferences{}, fmt.Errorf("unreachable") + }}) + } + if err == nil || effects != 0 || !reflect.DeepEqual(before, identityGuardTreeV1(t, dir)) { + t.Fatalf("refusal changed deployment or reached engine: effects=%d err=%v", effects, err) + } + }) + } + } +} diff --git a/internal/dockerdeploy/image_destination_index.go b/internal/dockerdeploy/image_destination_index.go new file mode 100644 index 00000000..8489b75c --- /dev/null +++ b/internal/dockerdeploy/image_destination_index.go @@ -0,0 +1,188 @@ +package dockerdeploy + +import ( + "archive/tar" + "context" + "path" + "strings" +) + +const imageDestinationNodeLimitV1 = 1000000 +const imageDestinationMetadataLimitV1 = 64 << 20 + +type imageDestinationNodeV1 struct { + name, link string + kind byte +} + +// Shared by all retained layers and the working index. Charge before retaining +// entries, including implicit parents and extension-header payloads. +type imageDestinationBudgetV1 struct{ nodes, bytes int } + +func (b *imageDestinationBudgetV1) charge(nodes, size int) error { + if nodes < 0 || size < 0 || nodes > imageDestinationNodeLimitV1-b.nodes || size > imageDestinationMetadataLimitV1-b.bytes { + return invalidImageIdentityArchiveV1() + } + b.nodes += nodes + b.bytes += size + return nil +} + +func (b *imageDestinationBudgetV1) entry(header *tar.Header, name string) (imageDestinationNodeV1, error) { + if len(header.Linkname) > 4096 || strings.ContainsAny(header.Linkname, "\x00\\") { + return imageDestinationNodeV1{}, invalidImageIdentityArchiveV1() + } + base := path.Base(name) + if strings.HasPrefix(base, ".wh.") && (header.Typeflag != tar.TypeReg || header.Size != 0 || base == ".wh.") { + return imageDestinationNodeV1{}, invalidImageIdentityArchiveV1() + } + if base == ".wh.." || base == ".wh..." { + return imageDestinationNodeV1{}, invalidImageIdentityArchiveV1() + } + if err := b.charge(1, len(name)+len(header.Linkname)); err != nil { + return imageDestinationNodeV1{}, err + } + return imageDestinationNodeV1{name: name, link: header.Linkname, kind: header.Typeflag}, nil +} + +type imageDestinationIndexV1 map[string]imageDestinationNodeV1 + +func (index imageDestinationIndexV1) remove(ctx context.Context, prefix string, childrenOnly bool) error { + if err := ctx.Err(); err != nil { + return err + } + if !childrenOnly && prefix != "." { + if node, exists := index[prefix]; !exists || node.kind != tar.TypeDir { + delete(index, prefix) + return nil + } + } + for name := range index { + if err := ctx.Err(); err != nil { + return err + } + if !childrenOnly && name == prefix || prefix == "." && name != "." || strings.HasPrefix(name, prefix+"/") { + delete(index, name) + } + } + return nil +} + +func (index imageDestinationIndexV1) apply(ctx context.Context, entries []imageDestinationNodeV1, budget *imageDestinationBudgetV1) error { + // Whiteouts affect lower layers, regardless of their position relative to + // same-layer additions in the tar stream. + for _, entry := range entries { + if err := ctx.Err(); err != nil { + return err + } + base := path.Base(entry.name) + if strings.HasPrefix(base, ".wh.") { + for parent := path.Dir(entry.name); parent != "."; parent = path.Dir(parent) { + if node, exists := index[parent]; exists && node.kind != tar.TypeDir { + return invalidImageIdentityArchiveV1() + } + } + } + if base == ".wh..wh..opq" { + if err := index.remove(ctx, path.Dir(entry.name), true); err != nil { + return err + } + } else if strings.HasPrefix(base, ".wh.") { + if err := index.remove(ctx, path.Join(path.Dir(entry.name), base[4:]), false); err != nil { + return err + } + } + } + for _, entry := range entries { + if err := ctx.Err(); err != nil { + return err + } + if strings.HasPrefix(path.Base(entry.name), ".wh.") { + continue + } + for parent := path.Dir(entry.name); parent != "."; parent = path.Dir(parent) { + if node, exists := index[parent]; exists && node.kind != tar.TypeDir { + return invalidImageIdentityArchiveV1() + } + } + if previous, exists := index[entry.name]; exists && (previous.kind != tar.TypeDir || entry.kind != tar.TypeDir) { + if err := index.remove(ctx, entry.name, false); err != nil { + return err + } + } + index[entry.name] = entry + for parent := path.Dir(entry.name); parent != "."; parent = path.Dir(parent) { + if _, exists := index[parent]; !exists { + if err := budget.charge(1, len(parent)); err != nil { + return err + } + index[parent] = imageDestinationNodeV1{name: parent, kind: tar.TypeDir} + } + } + } + return nil +} + +func (index imageDestinationIndexV1) destination(ctx context.Context, target string) (resolved, kind string, content bool, err error) { + if !path.IsAbs(target) || path.Clean(target) != target || target == "/" || len(target) > 4096 || strings.ContainsAny(target, "\\\x00\r\n\t") { + return "", "", false, invalidImageIdentityArchiveV1() + } + resolved = "/" + parts := strings.Split(strings.TrimPrefix(target, "/"), "/") + hops := 0 + for len(parts) != 0 { + if err := ctx.Err(); err != nil { + return "", "", false, err + } + part := parts[0] + parts = parts[1:] + switch part { + case "", ".": + continue + case "..": + if resolved != "/" && index[strings.TrimPrefix(resolved, "/")].kind != tar.TypeDir { + return "", "", false, invalidImageIdentityArchiveV1() + } + resolved = path.Dir(resolved) + continue + } + next := path.Join(resolved, part) + node, exists := index[strings.TrimPrefix(next, "/")] + if exists && node.kind == tar.TypeSymlink { + hops++ + if node.link == "" || hops > 32 || len(resolved)+len(node.link)+len(strings.Join(parts, "/"))+2 > 4096 { + return "", "", false, invalidImageIdentityArchiveV1() + } + if path.IsAbs(node.link) { + resolved = "/" + } + // Resolve each link component before interpreting a later "..". + parts = append(strings.Split(node.link, "/"), parts...) + continue + } + resolved = next + if exists && node.kind != tar.TypeDir { + if len(parts) == 0 { + return resolved, "non-directory", true, nil + } + return "", "", false, invalidImageIdentityArchiveV1() + } + } + if resolved == "/" || len(resolved) > 4096 { + return "", "", false, invalidImageIdentityArchiveV1() + } + name := strings.TrimPrefix(resolved, "/") + kind = "absent" + if _, exists := index[name]; exists { + kind = "directory" + } + for candidate, node := range index { + if err := ctx.Err(); err != nil { + return "", "", false, err + } + if strings.HasPrefix(candidate, name+"/") && node.kind != tar.TypeDir { + return resolved, kind, true, nil + } + } + return resolved, kind, false, nil +} diff --git a/internal/dockerdeploy/image_destination_projection_test.go b/internal/dockerdeploy/image_destination_projection_test.go new file mode 100644 index 00000000..2b48c294 --- /dev/null +++ b/internal/dockerdeploy/image_destination_projection_test.go @@ -0,0 +1,330 @@ +package dockerdeploy + +import ( + "archive/tar" + "bytes" + "context" + "fmt" + "github.com/omry/reploy/internal/blueprint" + "github.com/omry/reploy/internal/canonical" + "github.com/omry/reploy/internal/deploy" + "reflect" + "strings" + "testing" +) + +func projectionDocumentV1(t *testing.T, target string) blueprint.Document { + t.Helper() + policy, _ := identitySourcePolicyV1(t) + d := identityDestinationDocumentV1(policy) + m := blueprint.EnvironmentMount{Target: target, Writable: true, UpdatePolicy: blueprint.UpdatePreserve} + d.Environment.Mounts = map[string]blueprint.EnvironmentMount{"data": m} + d.Docker.Mounts = map[string]blueprint.DockerMount{"data": {Extends: "environment.mounts.data", Mode: blueprint.MountManagedBind, Source: "data", Contract: m}} + return d +} +func TestImageDestinationProjectionV1LayerControls(t *testing.T) { + dir := func(p string) healthArchiveMember { return healthArchiveMember{name: p, kind: tar.TypeDir} } + file := func(p string) healthArchiveMember { + return healthArchiveMember{name: p, content: []byte("image-data-or-code")} + } + link := func(p, to string) healthArchiveMember { + return healthArchiveMember{name: p, kind: tar.TypeSymlink, linkname: to} + } + cases := []struct { + name, target string + layers [][]healthArchiveMember + empty, invalid bool + }{ + {"absent", "/var/lib/mail", nil, true, false}, + {"empty", "/var/lib/mail", [][]healthArchiveMember{{dir("var/lib/mail")}}, true, false}, + {"directories-only", "/var/lib/mail", [][]healthArchiveMember{{dir("var/lib/mail/queue")}}, true, false}, + {"data-is-protected-too", "/var/lib/mail", [][]healthArchiveMember{{file("var/lib/mail/default.conf")}}, false, false}, + {"zero-size-file", "/var/lib/mail", [][]healthArchiveMember{{{name: "var/lib/mail/empty"}}}, false, false}, + {"prefix-is-not-overlap", "/var/lib/mail", [][]healthArchiveMember{{file("var/lib/mailbox/code")}}, true, false}, + {"whiteout-removes-lower", "/var/lib/mail", [][]healthArchiveMember{{file("var/lib/mail/default.conf")}, {{name: "var/lib/mail/.wh.default.conf"}}}, true, false}, + {"whiteout-keeps-same-layer", "/var/lib/mail", [][]healthArchiveMember{{file("var/lib/mail/default.conf"), {name: "var/lib/mail/.wh.default.conf"}}}, false, false}, + {"opaque-removes-lower", "/var/lib/mail", [][]healthArchiveMember{{file("var/lib/mail/queue/code")}, {{name: "var/lib/mail/.wh..wh..opq"}}}, true, false}, + {"opaque-keeps-same-layer", "/var/lib/mail", [][]healthArchiveMember{{file("var/lib/mail/old")}, {file("var/lib/mail/new"), {name: "var/lib/mail/.wh..wh..opq"}}}, false, false}, + {"absolute-image-alias", "/data", [][]healthArchiveMember{{file("var/lib/mail/code"), link("data", "/var/lib/mail")}}, false, false}, + {"relative-image-alias", "/var/run/mail", [][]healthArchiveMember{{dir("run/mail"), link("var/run", "../run")}}, true, false}, + {"alias-to-populated-tree", "/var/run/mail", [][]healthArchiveMember{{file("run/mail/code"), link("var/run", "../run")}}, false, false}, + {"alias-cycle", "/a", [][]healthArchiveMember{{link("a", "b"), link("b", "a")}}, false, true}, + {"file-destination", "/data", [][]healthArchiveMember{{file("data")}}, false, true}, + {"layer-traverses-link", "/data", [][]healthArchiveMember{{link("alias", "data")}, {file("alias/code")}}, false, true}, + {"alias-to-root", "/data", [][]healthArchiveMember{{link("data", "/")}}, false, true}, + {"hardlink-is-file", "/data", [][]healthArchiveMember{{file("original"), {name: "data/alias", kind: tar.TypeLink, linkname: "original"}}}, false, false}, + {"link-descendant-protects-subtree", "/data", [][]healthArchiveMember{{dir("data"), link("data/alias", "/outside")}}, false, false}, + {"file-replaced-by-directory", "/data", [][]healthArchiveMember{{file("data")}, {dir("data")}}, true, false}, + {"directory-replaced-by-file", "/data", [][]healthArchiveMember{{file("data/old")}, {file("data")}}, false, true}, + {"intrinsic-root-not-data", "/", nil, false, true}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + layers := [][]byte{healthArchive(t, identitySourceMembersV1()...)} + for _, members := range tc.layers { + layers = append(layers, healthArchive(t, members...)) + } + image, archive := identitySourceArchiveV1(t, layers, nil) + _, platform := identitySourcePolicyV1(t) + document := projectionDocumentV1(t, tc.target) + result, err := readDockerPreparedImageArchiveV1(t.Context(), bytes.NewReader(archive), document, image, platform) + if (err != nil) != tc.invalid { + t.Fatalf("invalid=%v err=%v", tc.invalid, err) + } + if err == nil { + if result.Destinations == nil || len(result.Destinations.Destinations) != 1 { + t.Fatal("projection missing") + } + fact := result.Destinations.Destinations[0] + if fact.ContainsContent == tc.empty { + t.Fatalf("content=%v wanted empty=%v", fact.ContainsContent, tc.empty) + } + lock := deploy.BuildLockV1{FinalImage: image, Platform: platform, ImageDestinations: result.Destinations} + if err := deploy.ValidateSelectedImageDestinationsV1(document, lock); err != nil { + t.Fatal(err) + } + } else if !reflect.DeepEqual(result, preparedImageEvidenceV1{}) { + t.Fatal("partial evidence leaked") + } + image.RootFSSubject = canonical.Digest("sha256:" + strings.Repeat("a", 64)) + if _, err := readDockerPreparedImageArchiveV1(t.Context(), bytes.NewReader(archive), document, image, platform); err == nil { + t.Fatal("unbound rootfs accepted") + } + }) + } +} +func TestImageDestinationProjectionV1FileBindCompatibility(t *testing.T) { + for _, writable := range []bool{false, true} { + for _, target := range []string{"/data", "/data/child"} { + t.Run(fmt.Sprintf("writable=%t/target=%s", writable, target), func(t *testing.T) { + source, err := blueprint.Decode([]byte(fmt.Sprintf(`blueprint: {schema: 1, version: 0.1.0, requires_reploy: ">=NEXT", compatibility: {platforms: [linux/amd64]}} +environment: + id: file-bind-probe + base: {image: python:3.13-slim} + runtime: + identity_policy: + schema: 1 + groups: {worker: 200, shared: 300, unused: 400} + users: {worker: {uid: 200, supplementary_groups: [shared]}} + roles: {app: {user: worker}} + mounts: + data: {target: %s, writable: %t, update_policy: unmanaged} +docker: + mounts: + data: {extends: environment.mounts.data, mode: bind, source: /provided/config} +`, target, writable))) + if err != nil { + t.Fatal(err) + } + document, err := blueprint.Resolve(source) + if err != nil { + t.Fatal(err) + } + members := append(identitySourceMembersV1(), healthArchiveMember{name: "data", content: []byte("existing-image-config")}) + image, archive := identitySourceArchiveV1(t, [][]byte{healthArchive(t, members...)}, nil) + _, platform := identitySourcePolicyV1(t) + result, err := readDockerPreparedImageArchiveV1(t.Context(), bytes.NewReader(archive), document, image, platform) + if writable || target != "/data" { + if err == nil || !reflect.DeepEqual(result, preparedImageEvidenceV1{}) { + t.Fatal("file ancestor or writable file destination accepted", err) + } + return + } + if err != nil { + t.Fatal("approved read-only file bind rejected", err) + } + fact := result.Destinations.Destinations[0] + if fact.Kind != "non-directory" || !fact.ContainsContent || fact.Target != target || fact.Resolved != target { + t.Fatalf("incorrect retained file facts: %#v", fact) + } + if err := deploy.ValidateSelectedImageDestinationsV1(document, deploy.BuildLockV1{FinalImage: image, Platform: platform, ImageDestinations: result.Destinations}); err != nil { + t.Fatal(err) + } + }) + } + } +} + +func TestImageDestinationProjectionV1SymlinkTraversalOrder(t *testing.T) { + for _, tc := range []struct { + name, link, target, resolved string + content, invalid bool + }{ + {"relative-parent", "a/../safe", "/data", "/outside/safe", true, false}, + {"absolute-parent", "/a/../safe", "/data", "/outside/safe", true, false}, + {"suffix", "a/../safe", "/data/child", "/outside/safe/child", true, false}, + {"dot-before-parent", "a/./../safe", "/data", "/outside/safe", true, false}, + {"two-parents", "a/../../safe", "/data", "/safe", false, false}, + {"root-clamp", "a/../../../safe", "/data", "/safe", false, false}, + {"file-before-parent", "file/../safe", "/data", "", false, true}, + {"missing-before-parent", "missing/../safe", "/data", "", false, true}, + {"file-trailing-slash", "file/", "/data", "", false, true}, + {"file-trailing-dot", "file/.", "/data", "", false, true}, + } { + t.Run(tc.name, func(t *testing.T) { + members := append(identitySourceMembersV1(), + healthArchiveMember{name: "safe", kind: tar.TypeDir}, + healthArchiveMember{name: "outside/sub", kind: tar.TypeDir}, + healthArchiveMember{name: "outside/safe/child/code", content: []byte("image code")}, + healthArchiveMember{name: "file", content: []byte("image code")}, + healthArchiveMember{name: "a", kind: tar.TypeSymlink, linkname: "outside/sub"}, + healthArchiveMember{name: "data", kind: tar.TypeSymlink, linkname: tc.link}) + image, archive := identitySourceArchiveV1(t, [][]byte{healthArchive(t, members...)}, nil) + _, platform := identitySourcePolicyV1(t) + result, err := readDockerPreparedImageArchiveV1(t.Context(), bytes.NewReader(archive), projectionDocumentV1(t, tc.target), image, platform) + if tc.invalid { + if err == nil || !reflect.DeepEqual(result, preparedImageEvidenceV1{}) { + t.Fatal("unsupported traversal accepted or partial evidence leaked", err) + } + return + } + if err != nil { + t.Fatal(err) + } + fact := result.Destinations.Destinations[0] + if fact.Resolved != tc.resolved || fact.Kind != "directory" || fact.ContainsContent != tc.content { + t.Fatalf("incorrect filesystem facts: %#v", fact) + } + }) + } +} + +func TestImageDestinationProjectionV1Cancellation(t *testing.T) { + image, archive := identitySourceArchiveV1(t, [][]byte{healthArchive(t, identitySourceMembersV1()...)}, nil) + _, platform := identitySourcePolicyV1(t) + ctx, cancel := context.WithCancel(t.Context()) + cancel() + result, err := readDockerPreparedImageArchiveV1(ctx, bytes.NewReader(archive), projectionDocumentV1(t, "/data"), image, platform) + if err == nil || !reflect.DeepEqual(result, preparedImageEvidenceV1{}) { + t.Fatal("cancelled reader published evidence") + } + index := imageDestinationIndexV1{"data": {kind: tar.TypeDir}} + if _, _, _, err := index.destination(ctx, "/data"); err == nil { + t.Fatal("cancelled projection accepted") + } + if err := index.apply(ctx, []imageDestinationNodeV1{{name: "data", kind: tar.TypeDir}}, &imageDestinationBudgetV1{}); err == nil { + t.Fatal("cancelled layer accepted") + } +} + +func TestImageDestinationProjectionV1RetainedBudget(t *testing.T) { + for _, tc := range []struct { + name string + budget imageDestinationBudgetV1 + entry healthArchiveMember + }{ + {"node", imageDestinationBudgetV1{nodes: imageDestinationNodeLimitV1}, healthArchiveMember{name: "data"}}, + {"path-bytes", imageDestinationBudgetV1{bytes: imageDestinationMetadataLimitV1 - 3}, healthArchiveMember{name: "data"}}, + {"link-bytes", imageDestinationBudgetV1{bytes: imageDestinationMetadataLimitV1 - 5}, healthArchiveMember{name: "data", kind: tar.TypeSymlink, linkname: "long-destination"}}, + {"implicit-parent", imageDestinationBudgetV1{nodes: imageDestinationNodeLimitV1 - 1}, healthArchiveMember{name: "parent/data"}}, + } { + t.Run(tc.name, func(t *testing.T) { + layers := &imageIdentityLayersV1{projectionBudget: &tc.budget} + err := layers.add(t.Context(), "layer.tar", bytes.NewReader(healthArchive(t, tc.entry))) + if err == nil { + err = imageDestinationIndexV1{}.apply(t.Context(), layers.layers["layer.tar"].entries, layers.projectionBudget) + } + if err == nil { + t.Fatal("retained metadata limit was not enforced") + } + }) + } + // Both accounting fields reject before mutation, including the exact boundary. + budget := imageDestinationBudgetV1{nodes: imageDestinationNodeLimitV1 - 1, bytes: imageDestinationMetadataLimitV1 - 4} + if _, err := budget.entry(&tar.Header{Typeflag: tar.TypeDir}, "data"); err != nil { + t.Fatal(err) + } + before := budget + if _, err := budget.entry(&tar.Header{Typeflag: tar.TypeDir}, "x"); err == nil || budget != before { + t.Fatal("failed allocation changed accounting") + } +} + +func TestImageDestinationProjectionV1PhysicalMetadataCharge(t *testing.T) { + raw := healthArchive(t, healthArchiveMember{name: strings.Repeat("x", 200), format: tar.FormatPAX}) + if raw[156] != tar.TypeXHeader { + t.Fatal("fixture lacks extension header") + } + budget := &imageDestinationBudgetV1{bytes: imageDestinationMetadataLimitV1 - 512} + layers := &imageIdentityLayersV1{projectionBudget: budget} + if err := layers.add(t.Context(), "layer.tar", bytes.NewReader(raw)); err == nil { + t.Fatal("hidden extension metadata was not charged") + } + if len(layers.layers) != 0 { + t.Fatal("failed layer published partial entries") + } +} + +func TestImageDestinationProjectionV1MalformedLayers(t *testing.T) { + for _, entry := range []healthArchiveMember{ + {name: "data/.wh."}, {name: "data/.wh.."}, {name: "data/.wh..."}, + {name: "data/.wh.file", content: []byte("not-empty")}, + {name: "data/.wh.file", kind: tar.TypeSymlink, linkname: "elsewhere"}, + {name: "data", kind: tar.TypeSymlink, linkname: strings.Repeat("x", 4097)}, + {name: "data", kind: tar.TypeSymlink, linkname: ""}, + } { + t.Run(entry.name+entry.linkname[:min(len(entry.linkname), 8)], func(t *testing.T) { + image, archive := identitySourceArchiveV1(t, [][]byte{healthArchive(t, identitySourceMembersV1()...), healthArchive(t, entry)}, nil) + _, platform := identitySourcePolicyV1(t) + if _, err := readDockerPreparedImageArchiveV1(t.Context(), bytes.NewReader(archive), projectionDocumentV1(t, "/data"), image, platform); err == nil { + t.Fatal("malformed projection source accepted") + } + }) + } +} + +func TestImageDestinationProjectionV1AliasedFactsAndDeterminism(t *testing.T) { + base := healthArchive(t, append(identitySourceMembersV1(), healthArchiveMember{name: "run/mail", kind: tar.TypeDir}, healthArchiveMember{name: "var/run", kind: tar.TypeSymlink, linkname: "../run"})...) + image, archive := identitySourceArchiveV1(t, [][]byte{base}, nil) + _, platform := identitySourcePolicyV1(t) + document := projectionDocumentV1(t, "/var/run/mail") + first, err := readDockerPreparedImageArchiveV1(t.Context(), bytes.NewReader(archive), document, image, platform) + if err != nil { + t.Fatal(err) + } + fact := first.Destinations.Destinations[0] + if fact.Target != "/var/run/mail" || fact.Resolved != "/run/mail" || fact.Kind != "directory" || fact.ContainsContent { + t.Fatalf("bad alias facts: %#v", fact) + } + second, err := readDockerPreparedImageArchiveV1(t.Context(), bytes.NewReader(archive), document, image, platform) + if err != nil || !reflect.DeepEqual(first, second) { + t.Fatal("projection is not deterministic", err) + } + document.Environment.Mounts["root"] = blueprint.EnvironmentMount{Target: "/", Writable: true} + writable, err := readDockerPreparedImageArchiveV1(t.Context(), bytes.NewReader(archive), document, image, platform) + if err != nil || !writable.Destinations.RootWritable || writable.Destinations.SelectionDigest == first.Destinations.SelectionDigest { + t.Fatal("root policy was not bound", err) + } +} + +func TestImageDestinationProjectionV1AliasHopLimit(t *testing.T) { + for _, hops := range []int{31, 32, 33} { + t.Run(fmt.Sprint(hops), func(t *testing.T) { + members := identitySourceMembersV1() + for i := 0; i < hops; i++ { + members = append(members, healthArchiveMember{name: fmt.Sprintf("link-%d", i), kind: tar.TypeSymlink, linkname: fmt.Sprintf("link-%d", i+1)}) + } + members = append(members, healthArchiveMember{name: fmt.Sprintf("link-%d", hops), kind: tar.TypeDir}) + image, archive := identitySourceArchiveV1(t, [][]byte{healthArchive(t, members...)}, nil) + _, platform := identitySourcePolicyV1(t) + _, err := readDockerPreparedImageArchiveV1(t.Context(), bytes.NewReader(archive), projectionDocumentV1(t, "/link-0"), image, platform) + if (err != nil) != (hops > 32) { + t.Fatalf("hops=%d err=%v", hops, err) + } + }) + } +} + +func TestImageDestinationProjectionV1WhiteoutCannotTraverseAlias(t *testing.T) { + for _, marker := range []string{".wh.file", ".wh..wh..opq"} { + t.Run(marker, func(t *testing.T) { + members := append(identitySourceMembersV1(), healthArchiveMember{name: "alias", kind: tar.TypeSymlink, linkname: "outside"}) + image, archive := identitySourceArchiveV1(t, [][]byte{healthArchive(t, members...), healthArchive(t, healthArchiveMember{name: "alias/" + marker})}, nil) + _, platform := identitySourcePolicyV1(t) + if _, err := readDockerPreparedImageArchiveV1(t.Context(), bytes.NewReader(archive), projectionDocumentV1(t, "/data"), image, platform); err == nil { + t.Fatal("ambiguous whiteout traversal accepted") + } + }) + } +} diff --git a/internal/dockerdeploy/image_identity_archive.go b/internal/dockerdeploy/image_identity_archive.go index d443a5f0..914ad1dc 100644 --- a/internal/dockerdeploy/image_identity_archive.go +++ b/internal/dockerdeploy/image_identity_archive.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "os/exec" + "sort" "github.com/omry/reploy/internal/blueprint" "github.com/omry/reploy/internal/canonical" @@ -14,36 +15,47 @@ import ( "github.com/omry/reploy/internal/providers" ) +type preparedImageEvidenceV1 struct { + Identity deploy.ImageIdentityEvidenceV1 + Destinations *deploy.ImageDestinationEvidenceV1 +} + // Use the selected generated builder only. No tag, inspect, pull or image execution. -func exportDockerImageIdentityV1(ctx context.Context, policy *blueprint.IdentityPolicyV1, image providers.RealizedImageV1, platform blueprint.Platform) (deploy.ImageIdentityEvidenceV1, error) { +func exportDockerPreparedImageV1(ctx context.Context, document blueprint.Document, image providers.RealizedImageV1, platform blueprint.Platform) (preparedImageEvidenceV1, error) { + policy := document.Environment.Runtime.IdentityPolicy builder := currentGeneratedImageBuilder(ctx) if builder == nil || image.Validate() != nil || policy == nil || policy.Validate() != nil || platform.Validate() != nil || platform.OS != "linux" { - return deploy.ImageIdentityEvidenceV1{}, invalidImageIdentityArchiveV1() + return preparedImageEvidenceV1{}, invalidImageIdentityArchiveV1() } - var evidence deploy.ImageIdentityEvidenceV1 + var evidence preparedImageEvidenceV1 err := streamDockerImageArchiveV1(ctx, builder.endpoint, string(image.ConfigDigest), func(ctx context.Context, input io.Reader) error { var err error - evidence, err = readDockerImageIdentityArchiveV1(ctx, input, policy, image, platform) + evidence, err = readDockerPreparedImageArchiveV1(ctx, input, document, image, platform) return err }) if err != nil { var exit *exec.ExitError if errors.As(err, &exit) { - return deploy.ImageIdentityEvidenceV1{}, fmt.Errorf("image identity export failed (exit %d)", exit.ExitCode()) + return preparedImageEvidenceV1{}, fmt.Errorf("image identity export failed (exit %d)", exit.ExitCode()) } - return deploy.ImageIdentityEvidenceV1{}, fmt.Errorf("image identity export failed") + return preparedImageEvidenceV1{}, fmt.Errorf("image identity export failed") } return evidence, nil } -func readDockerImageIdentityArchiveV1(ctx context.Context, input io.Reader, policy *blueprint.IdentityPolicyV1, image providers.RealizedImageV1, platform blueprint.Platform) (deploy.ImageIdentityEvidenceV1, error) { - fail := func() (deploy.ImageIdentityEvidenceV1, error) { - return deploy.ImageIdentityEvidenceV1{}, invalidImageIdentityArchiveV1() +func readDockerPreparedImageArchiveV1(ctx context.Context, input io.Reader, document blueprint.Document, image providers.RealizedImageV1, platform blueprint.Platform) (preparedImageEvidenceV1, error) { + policy := document.Environment.Runtime.IdentityPolicy + fail := func() (preparedImageEvidenceV1, error) { + return preparedImageEvidenceV1{}, invalidImageIdentityArchiveV1() } if image.Validate() != nil || policy == nil || policy.Validate() != nil || platform.Validate() != nil || platform.OS != "linux" { return fail() } - layers := &imageIdentityLayersV1{} + selection, err := deploy.ImageDestinationSelectionDigestV1(document) + if err != nil { + return fail() + } + layers := &imageIdentityLayersV1{projectionBudget: &imageDestinationBudgetV1{}} archive, err := readDockerImageArchiveV1(ctx, input, string(image.ConfigDigest), layers) if err != nil { return fail() @@ -86,11 +98,15 @@ func readDockerImageIdentityArchiveV1(ctx context.Context, input io.Reader, poli return fail() } files := map[string][]byte{} + destinations := imageDestinationIndexV1{} for index, name := range ordered { layer, exists := layers.layers[name] if !exists || layer.digest != diffIDs[index] { return fail() } + if err := destinations.apply(ctx, layer.entries, layers.projectionBudget); err != nil { + return fail() + } for account, content := range layer.files { files[account] = content } @@ -99,5 +115,23 @@ func readDockerImageIdentityArchiveV1(ctx context.Context, input io.Reader, poli if err != nil { return fail() } - return evidence, nil + projection := &deploy.ImageDestinationEvidenceV1{Schema: deploy.ImageDestinationEvidenceSchemaV1, Image: image, Platform: platform, SelectionDigest: selection, RootWritable: document.Environment.ImageRootWritable(), Destinations: []deploy.ImageDestinationV1{}} + names := make([]string, 0, len(document.Docker.Mounts)) + for name := range document.Docker.Mounts { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + mount := document.Docker.Mounts[name] + target := mount.Contract.Target + resolved, kind, content, err := destinations.destination(ctx, target) + if err != nil || (mount.Contract.Writable && kind == "non-directory") { + return fail() + } + projection.Destinations = append(projection.Destinations, deploy.ImageDestinationV1{Name: name, Target: target, Resolved: resolved, Kind: kind, ContainsContent: content}) + } + if ctx.Err() != nil || deploy.ValidateImageDestinationEvidenceV1(projection, image, platform) != nil { + return fail() + } + return preparedImageEvidenceV1{Identity: evidence, Destinations: projection}, nil } diff --git a/internal/dockerdeploy/image_identity_layers.go b/internal/dockerdeploy/image_identity_layers.go index 0eee22ca..4fc82791 100644 --- a/internal/dockerdeploy/image_identity_layers.go +++ b/internal/dockerdeploy/image_identity_layers.go @@ -22,13 +22,15 @@ const imageIdentityFileLimitV1 = 1 << 20 var imageIdentityAccountPathsV1 = [...]string{"etc/passwd", "etc/group", "etc/nsswitch.conf"} type imageIdentityLayerV1 struct { - digest canonical.Digest - files map[string][]byte + digest canonical.Digest + files map[string][]byte + entries []imageDestinationNodeV1 } type imageIdentityLayersV1 struct { - layers map[string]imageIdentityLayerV1 - contentBytes int64 + layers map[string]imageIdentityLayerV1 + contentBytes int64 + projectionBudget *imageDestinationBudgetV1 } func invalidImageIdentityArchiveV1() error { @@ -92,12 +94,16 @@ func (layers *imageIdentityLayersV1) add(ctx context.Context, name string, input metadata := &io.LimitedReader{R: limited} reader := tar.NewReader(metadata) names, files := map[string]bool{}, map[string][]byte{} + var entries []imageDestinationNodeV1 nameBytes := 0 headerBytes, padding := int64(imageIdentityHeaderLimitV1*512), int64(0) for { // Next hides PAX/GNU headers. Bound the read itself and charge their // payload blocks too, excluding the previous regular file's padding. metadata.N = headerBytes + padding + 1024 + if budget := layers.projectionBudget; budget != nil { + metadata.N = min(metadata.N, int64(imageDestinationMetadataLimitV1-budget.bytes)+padding+1024) + } before := tracked.count header, err := reader.Next() consumed := tracked.count - before - padding @@ -121,6 +127,16 @@ func (layers *imageIdentityLayersV1) add(ctx context.Context, name string, input return invalidImageIdentityArchiveV1() } names[member] = true + if budget := layers.projectionBudget; budget != nil { + if err := budget.charge(0, int(consumed-512)); err != nil { + return err + } + entry, err := budget.entry(header, member) + if err != nil { + return err + } + entries = append(entries, entry) + } selected := false for _, account := range imageIdentityAccountPathsV1 { selected = selected || member == account @@ -150,6 +166,6 @@ func (layers *imageIdentityLayersV1) add(ctx context.Context, name string, input if layers.layers == nil { layers.layers = map[string]imageIdentityLayerV1{} } - layers.layers[name] = imageIdentityLayerV1{digest: canonical.Digest(fmt.Sprintf("sha256:%x", hash.Sum(nil))), files: files} + layers.layers[name] = imageIdentityLayerV1{digest: canonical.Digest(fmt.Sprintf("sha256:%x", hash.Sum(nil))), files: files, entries: entries} return nil } diff --git a/internal/dockerdeploy/mapped_entry_plan_test.go b/internal/dockerdeploy/mapped_entry_plan_test.go index 74464d0b..d4e5bddc 100644 --- a/internal/dockerdeploy/mapped_entry_plan_test.go +++ b/internal/dockerdeploy/mapped_entry_plan_test.go @@ -60,6 +60,7 @@ func mappedEntryPlanFixtureV1(t *testing.T, profile string) mappedEntryInputV1 { t.Fatal(err) } c.Lock.IdentityPolicy = &deploy.IdentityPolicyLockV1{Schema: deploy.IdentityPolicyLockSchemaV1, Policy: *d.Environment.Runtime.IdentityPolicy, Evidence: accounts} + c.Lock.ImageDestinations = emptyImageDestinationEvidenceV1(t, d, c.Lock.FinalImage, c.Lock.Platform) runtime := original.DockerPlan runtime.Phase = blueprint.PhaseStaged runtime.Workload = &WorkloadExecutionPlan{Command: "serve", Endpoints: map[string]EndpointExecutionPlan{}} @@ -362,6 +363,7 @@ func rebindMappedEntryFixtureV1(t *testing.T, i *mappedEntryInputV1, change func t.Fatal(err) } i.Current.Lock.IdentityPolicy = &deploy.IdentityPolicyLockV1{Schema: deploy.IdentityPolicyLockSchemaV1, Policy: *d.Environment.Runtime.IdentityPolicy, Evidence: accounts} + i.Current.Lock.ImageDestinations = emptyImageDestinationEvidenceV1(t, d, i.Current.Lock.FinalImage, i.Current.Lock.Platform) plans, err := RuntimePlansV1(d, i.Runtime) if err != nil { t.Fatal(err) diff --git a/internal/dockerdeploy/provider_build_completion.go b/internal/dockerdeploy/provider_build_completion.go index cc90e453..9f57b3e2 100644 --- a/internal/dockerdeploy/provider_build_completion.go +++ b/internal/dockerdeploy/provider_build_completion.go @@ -64,7 +64,7 @@ type providerBuildCompletionBackend struct { publish func(context.Context, *deploy.OperationLock, providerstore.Store, BuildPublicationInput) (deploy.StateV1, error) publishValidated func(context.Context, *deploy.OperationLock, providerstore.Store, string, string, deploy.BuildLockV1, ValidatedBuildInputsV1) (deploy.ValidatedBuildV1, error) removeFinalized func(context.Context, BuiltImageCandidate) error - identityEvidence func(context.Context, *blueprint.IdentityPolicyV1, providers.RealizedImageV1, blueprint.Platform) (deploy.ImageIdentityEvidenceV1, error) + identityEvidence func(context.Context, blueprint.Document, providers.RealizedImageV1, blueprint.Platform) (preparedImageEvidenceV1, error) } // CompleteProviderBuild is the canonical completion path from a materialized @@ -83,7 +83,7 @@ func CompleteProviderBuild( publish: PublishBuild, publishValidated: PublishValidatedBuild, removeFinalized: RemoveBuiltImageCandidate, - identityEvidence: exportDockerImageIdentityV1, + identityEvidence: exportDockerPreparedImageV1, }) } @@ -173,12 +173,17 @@ func completeProviderBuild( } }() var identityPolicy *deploy.IdentityPolicyLockV1 + var imageDestinations *deploy.ImageDestinationEvidenceV1 if portable := input.Document.Environment.Runtime.IdentityPolicy; portable != nil { - evidence, err := backend.identityEvidence(ctx, portable, finalized.Image.Image, input.ResolvedRequest.Platform) + evidence, err := backend.identityEvidence(ctx, input.Document, finalized.Image.Image, input.ResolvedRequest.Platform) if err != nil { return ProviderBuildCompletionResult{}, fmt.Errorf("collect final-image identity evidence: %w", err) } - identityPolicy = &deploy.IdentityPolicyLockV1{Schema: deploy.IdentityPolicyLockSchemaV1, Policy: *portable, Evidence: evidence} + identityPolicy = &deploy.IdentityPolicyLockV1{Schema: deploy.IdentityPolicyLockSchemaV1, Policy: *portable, Evidence: evidence.Identity} + imageDestinations = evidence.Destinations + if err := deploy.ValidateSelectedImageDestinationsV1(input.Document, deploy.BuildLockV1{IdentityPolicy: identityPolicy, ImageDestinations: imageDestinations, FinalImage: finalized.Image.Image, Platform: input.ResolvedRequest.Platform}); err != nil { + return ProviderBuildCompletionResult{}, err + } if err := deploy.ValidateIdentityPolicyLockV1(identityPolicy, finalized.Image.Image, input.ResolvedRequest.Platform); err != nil { return ProviderBuildCompletionResult{}, err } @@ -195,7 +200,8 @@ func completeProviderBuild( RuntimePolicy: policy, RuntimeLayer: finalized.RuntimeLayer, ValidationRecord: finalized.Validation.Final.Reference, FinalImage: finalized.Image.Image, - IdentityPolicy: identityPolicy, + IdentityPolicy: identityPolicy, + ImageDestinations: imageDestinations, }) endAssemble(err) if err != nil { diff --git a/internal/providers/python/interpreter_inspection_test.go b/internal/providers/python/interpreter_inspection_test.go index 45fef934..89432d83 100644 --- a/internal/providers/python/interpreter_inspection_test.go +++ b/internal/providers/python/interpreter_inspection_test.go @@ -242,6 +242,9 @@ def generated(arch,release,module): _manylinux._get_glibc_version=lambda:release if module is None: sys.modules.pop("_manylinux",None) else: sys.modules["_manylinux"]=module + cached_module=getattr(_manylinux,"_get_manylinux_module",None) + if cached_module is not None and hasattr(cached_module,"cache_clear"): + cached_module.cache_clear() parameters=inspect.signature(_manylinux.platform_tags).parameters if len(parameters)==1: return set(_manylinux.platform_tags([arch])) return set(_manylinux.platform_tags("linux_"+arch,arch))