Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changes/unreleased/+image-destination-evidence.yaml
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 8 additions & 1 deletion internal/deploy/build_lock.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
113 changes: 113 additions & 0 deletions internal/deploy/image_destination_evidence.go
Original file line number Diff line number Diff line change
@@ -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
}
145 changes: 145 additions & 0 deletions internal/deploy/image_destination_evidence_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
})
}
}
4 changes: 3 additions & 1 deletion internal/dockerdeploy/build_lock_assembly.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
7 changes: 4 additions & 3 deletions internal/dockerdeploy/controlled_session_supervisor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 3 additions & 3 deletions internal/dockerdeploy/identity_policy_lock.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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.
Expand All @@ -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)
Expand Down
Loading
Loading