From bc776aee6f37e65d97cccdde05ffd415bdbd8197 Mon Sep 17 00:00:00 2001 From: Omry Yadan Date: Mon, 2 Nov 2026 22:42:34 +0800 Subject: [PATCH] F1.1.1: Declare managed metadata and intrinsic root policy Add optional mount owner, group and creation permissions with relative directory/file declarations. Resolve numeric ownership from the existing runtime context or declared policy, validate exact canonical mount associations, and preserve omitted-field generation identity. Represent the image root through environment.mounts.root without an external mount or command override. Keep metadata preparation and writable-root execution refused until their later qualification slices; public mapped activation remains gated. Validation: all 13 local test/build/CLI-smoke/distribution/docs commands passed. Focused regressions prove canonical association refusal fails without the correction and passes with it. Full current-agent review and a fresh independent Sol review are clean. The established WSL tmpfs exclusion remains; exact-head remote CI and PR review are still required. --- .../+managed-mount-declarations.yaml | 2 + internal/blueprint/model.go | 3 + internal/blueprint/mount_metadata.go | 249 ++++++++++ internal/blueprint/mount_metadata_test.go | 428 ++++++++++++++++++ internal/blueprint/resolve.go | 25 +- internal/blueprint/resolved_document_v1.go | 3 + internal/blueprint/syntax.go | 9 +- internal/dockerdeploy/execution_plan.go | 29 ++ .../execution_plan_metadata_test.go | 95 ++++ noxfile.py | 4 +- 10 files changed, 838 insertions(+), 9 deletions(-) create mode 100644 .changes/unreleased/+managed-mount-declarations.yaml create mode 100644 internal/blueprint/mount_metadata.go create mode 100644 internal/blueprint/mount_metadata_test.go create mode 100644 internal/dockerdeploy/execution_plan_metadata_test.go diff --git a/.changes/unreleased/+managed-mount-declarations.yaml b/.changes/unreleased/+managed-mount-declarations.yaml new file mode 100644 index 00000000..15fbc935 --- /dev/null +++ b/.changes/unreleased/+managed-mount-declarations.yaml @@ -0,0 +1,2 @@ +kind: Added +body: Add managed mount owner, group, creation permissions, and relative directory/file declarations, plus intrinsic image-root policy, to blueprint validation and canonical generation identity. Execution remains gated until their preparation and sandbox support is qualified. diff --git a/internal/blueprint/model.go b/internal/blueprint/model.go index c763b73a..e339ab11 100644 --- a/internal/blueprint/model.go +++ b/internal/blueprint/model.go @@ -157,6 +157,9 @@ type EnvironmentMount struct { Target string Writable bool UpdatePolicy UpdatePolicy + MountMetadata + Directories map[string]MountMetadata `json:",omitempty"` + Files map[string]MountMetadata `json:",omitempty"` } type UpdatePolicy string diff --git a/internal/blueprint/mount_metadata.go b/internal/blueprint/mount_metadata.go new file mode 100644 index 00000000..b1ce7223 --- /dev/null +++ b/internal/blueprint/mount_metadata.go @@ -0,0 +1,249 @@ +package blueprint + +import ( + "fmt" + "path" + "reflect" + "sort" + "strconv" + "strings" + + "github.com/omry/reploy/internal/runtimeidentity" +) + +// MountMetadata describes creation intent, never repair of an existing tree. +type MountMetadata struct { + Owner string `yaml:"owner" json:",omitempty"` + Group string `yaml:"group" json:",omitempty"` + Permissions string `yaml:"permissions" json:",omitempty"` +} + +type MountMetadataIdentity struct { + UID uint32 + GID uint32 + Permissions *uint32 +} + +func (mount EnvironmentMount) HasMetadata() bool { + return mount.MountMetadata != (MountMetadata{}) || len(mount.Directories) != 0 || len(mount.Files) != 0 +} + +func (environment Environment) ImageRootWritable() bool { + root := environment.Mounts["root"] + return root.Target == "/" && root.Writable +} + +func (metadata MountMetadata) validate(policy *IdentityPolicyV1) error { + for field, name := range map[string]string{"owner": metadata.Owner, "group": metadata.Group} { + if name == "" { + continue + } + if err := runtimeidentity.ValidateUserName(name); err != nil { + return fmt.Errorf("%s: %w", field, err) + } + if policy != nil { + kind := "user" + _, known := policy.Users[name] + if field == "group" { + kind = "group" + _, known = policy.Groups[name] + } + if !known { + return fmt.Errorf("%s %q must name a declared %s, not a role", field, name, kind) + } + } + } + if metadata.Permissions != "" { + if len(metadata.Permissions) != 4 { + return fmt.Errorf("permissions must contain four octal digits") + } + for _, digit := range metadata.Permissions { + if digit < '0' || digit > '7' { + return fmt.Errorf("permissions must contain four octal digits") + } + } + } + return nil +} + +// ResolveMountMetadata uses the already resolved container entry identity. +// It performs no NSS lookup and cannot turn an image role into a user. +func ResolveMountMetadata(metadata MountMetadata, policy *IdentityPolicyV1, context runtimeidentity.IdentityV1) (MountMetadataIdentity, error) { + if err := policy.Validate(); err != nil { + return MountMetadataIdentity{}, err + } + if err := metadata.validate(policy); err != nil { + return MountMetadataIdentity{}, err + } + if err := runtimeidentity.ValidateIdentityV1(context); err != nil { + return MountMetadataIdentity{}, fmt.Errorf("mount runtime context: %w", err) + } + uid, gid := context.UID, context.GID + owner := metadata.Owner + if owner == "" { + owner = context.Username + } + if policy != nil { + user, found := policy.Users[owner] + if !found { + return MountMetadataIdentity{}, fmt.Errorf("mount owner %q is not a declared user", owner) + } + uid, gid = user.UID, policy.Groups[user.PrimaryGroup] + if metadata.Owner == "" && (uid != context.UID || gid != context.GID) { + return MountMetadataIdentity{}, fmt.Errorf("mount default owner differs from the resolved runtime context") + } + } else if owner != context.Username { + return MountMetadataIdentity{}, fmt.Errorf("mount owner %q requires a declared identity policy", owner) + } + if metadata.Group != "" { + if policy == nil { + return MountMetadataIdentity{}, fmt.Errorf("explicit mount group requires a declared identity policy") + } + gid = policy.Groups[metadata.Group] + } + u, _ := strconv.ParseUint(uid, 10, 32) + g, _ := strconv.ParseUint(gid, 10, 32) + result := MountMetadataIdentity{UID: uint32(u), GID: uint32(g)} + if metadata.Permissions != "" { + mode, _ := strconv.ParseUint(metadata.Permissions, 8, 12) + value := uint32(mode) + result.Permissions = &value + } + return result, nil +} + +func validateMountChildren(mount EnvironmentMount, policy *IdentityPolicyV1) error { + if err := mount.MountMetadata.validate(policy); err != nil { + return err + } + for _, children := range []map[string]MountMetadata{mount.Directories, mount.Files} { + for _, name := range sortedKeys(children) { + if name == "" || name == "." || path.IsAbs(name) || path.Clean(name) != name || strings.ContainsAny(name, "\\\x00\r\n\t") || name == ".." || strings.HasPrefix(name, "../") { + return fmt.Errorf("object %q must be a normalized relative Linux path", name) + } + if err := children[name].validate(policy); err != nil { + return fmt.Errorf("object %q: %w", name, err) + } + if _, directory := mount.Directories[name]; directory { + if _, file := mount.Files[name]; file { + return fmt.Errorf("object %q cannot be both a directory and a file", name) + } + } + for parent := path.Dir(name); parent != "."; parent = path.Dir(parent) { + if _, file := mount.Files[parent]; file { + return fmt.Errorf("object %q has file ancestor %q", name, parent) + } + } + } + } + return nil +} + +func validateMetadataTarget(target string) error { + if !path.IsAbs(target) || path.Clean(target) != target || strings.Contains(target, `\`) { + return fmt.Errorf("managed metadata requires a normalized absolute Linux mount target") + } + return validateEnvironmentMountTarget(target) +} + +func (document Document) ValidateMountDeclarations() error { + policy := document.Environment.Runtime.IdentityPolicy + for _, name := range sortedKeys(document.Environment.Mounts) { + mount := document.Environment.Mounts[name] + if mount.Target == "/" { + if name != "root" || mount.UpdatePolicy != "" || mount.HasMetadata() { + return fmt.Errorf("intrinsic root requires name root and cannot have managed metadata or update_policy") + } + } + if mount.HasMetadata() { + if err := validateMetadataTarget(mount.Target); err != nil { + return err + } + } + if err := validateMountChildren(mount, policy); err != nil { + return fmt.Errorf("environment.mounts.%s: %w", name, err) + } + bindings := 0 + for _, binding := range document.Docker.Mounts { + if binding.Extends != environmentMountReferencePrefix+name { + continue + } + bindings++ + if mount.Target == "/" { + return fmt.Errorf("intrinsic root cannot have a Docker mount binding") + } + if mount.HasMetadata() || binding.Contract.HasMetadata() { + if !reflect.DeepEqual(mount, binding.Contract) || binding.Mode == MountBind { + return fmt.Errorf("managed metadata requires an identical managed mount contract") + } + if err := validateMount("managed metadata", binding); err != nil { + return err + } + } + } + if mount.HasMetadata() && bindings != 1 { + return fmt.Errorf("managed metadata requires exactly one Docker mount binding") + } + } + for _, binding := range document.Docker.Mounts { + if err := validateMountChildren(binding.Contract, policy); err != nil { + return err + } + if binding.Contract.HasMetadata() { + name := strings.TrimPrefix(binding.Extends, environmentMountReferencePrefix) + mount, exists := document.Environment.Mounts[name] + if !strings.HasPrefix(binding.Extends, environmentMountReferencePrefix) || !exists || !reflect.DeepEqual(mount, binding.Contract) { + return fmt.Errorf("managed metadata requires an exact environment mount contract") + } + } + if binding.Contract.Target == "/" { + return fmt.Errorf("intrinsic root cannot be an external Docker mount") + } + } + for _, command := range document.Environment.Commands { + for name := range command.Mounts { + if document.Environment.Mounts[name].Target == "/" { + return fmt.Errorf("command cannot override the intrinsic root policy") + } + } + } + return nil +} + +type ManagedMountObject struct { + Path string + Directory bool + Identity MountMetadataIdentity +} + +// ResolveManagedMountObjects freezes declarations only. It opens no paths. +func ResolveManagedMountObjects(mount EnvironmentMount, policy *IdentityPolicyV1, context runtimeidentity.IdentityV1) ([]ManagedMountObject, error) { + if err := validateMountChildren(mount, policy); err != nil { + return nil, err + } + if !mount.HasMetadata() { + return nil, nil + } + if err := validateMetadataTarget(mount.Target); err != nil { + return nil, err + } + root, err := ResolveMountMetadata(mount.MountMetadata, policy, context) + if err != nil { + return nil, err + } + result := []ManagedMountObject{{Path: ".", Directory: true, Identity: root}} + for index, children := range []map[string]MountMetadata{mount.Directories, mount.Files} { + names := sortedKeys(children) + if index == 0 { + sort.SliceStable(names, func(i, j int) bool { return strings.Count(names[i], "/") < strings.Count(names[j], "/") }) + } + for _, name := range names { + identity, err := ResolveMountMetadata(children[name], policy, context) + if err != nil { + return nil, fmt.Errorf("object %q: %w", name, err) + } + result = append(result, ManagedMountObject{Path: name, Directory: index == 0, Identity: identity}) + } + } + return result, nil +} diff --git a/internal/blueprint/mount_metadata_test.go b/internal/blueprint/mount_metadata_test.go new file mode 100644 index 00000000..ce4f8ca4 --- /dev/null +++ b/internal/blueprint/mount_metadata_test.go @@ -0,0 +1,428 @@ +package blueprint + +import ( + "encoding/json" + "reflect" + "strings" + "testing" + + "github.com/omry/reploy/internal/runtimeidentity" +) + +const mountPolicy = `{schema: 1, users: {root: 0, mail: {uid: 200, primary_group: staff}, postfix: 202}, groups: {root: 0, staff: 201, postfix: 202, shared: 203}, roles: {app: {user: mail}}}` + +func mountBlueprint(t *testing.T, fields string) Document { + t.Helper() + raw := strings.Replace(policyBlueprintV1(mountPolicy), " target: /data\n", " target: /data\n"+fields, 1) + source, err := Decode([]byte(raw)) + if err != nil { + t.Fatal(err) + } + document, err := Resolve(source) + if err != nil { + t.Fatal(err) + } + return document +} + +func mailMountContext() runtimeidentity.IdentityV1 { + return runtimeidentity.IdentityV1{Username: "mail", UID: "200", GID: "201", SupplementaryGIDs: []string{}} +} + +func TestManagedMountMetadataYAMLAndCanonicalIdentity(t *testing.T) { + document := mountBlueprint(t, ` owner: mail + permissions: "0750" + directories: + queue/active: {owner: postfix, permissions: "0700"} + queue: {} + files: + delivery.log: {group: shared, permissions: "0600"} +`) + mount := document.Environment.Mounts["data"] + if mount.Owner != "mail" || mount.Group != "" || mount.Permissions != "0750" || len(mount.Directories) != 2 || len(mount.Files) != 1 { + t.Fatalf("mount declarations changed: %#v", mount) + } + if !reflect.DeepEqual(mount, document.Docker.Mounts["data"].Contract) { + t.Fatal("Docker binding lost the environment metadata") + } + payload, err := EncodeResolvedDocumentV1(document) + if err != nil { + t.Fatal(err) + } + decoded, err := DecodeResolvedDocumentV1(payload) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(decoded.Environment.Mounts, document.Environment.Mounts) { + t.Fatal("canonical round trip lost metadata") + } + first, err := DocumentDigestV1(document) + if err != nil { + t.Fatal(err) + } + for _, mutate := range []func(*EnvironmentMount){ + func(m *EnvironmentMount) { m.Owner = "postfix" }, + func(m *EnvironmentMount) { m.Group = "shared" }, + func(m *EnvironmentMount) { m.Permissions = "0700" }, + func(m *EnvironmentMount) { m.Directories["queue"] = MountMetadata{Owner: "postfix"} }, + func(m *EnvironmentMount) { m.Files["second.log"] = MountMetadata{} }, + } { + candidate, err := DecodeResolvedDocumentV1(payload) + if err != nil { + t.Fatal(err) + } + m := candidate.Environment.Mounts["data"] + mutate(&m) + candidate.Environment.Mounts["data"] = m + binding := candidate.Docker.Mounts["data"] + binding.Contract = m + candidate.Docker.Mounts["data"] = binding + digest, err := DocumentDigestV1(candidate) + if err != nil || digest == first { + t.Fatalf("metadata change did not change locked identity: %v", err) + } + } +} + +func TestManagedMountContextAndOwnerPrimaryGroup(t *testing.T) { + policy := mountBlueprint(t, "").Environment.Runtime.IdentityPolicy + for _, test := range []struct { + name string + metadata MountMetadata + uid, gid uint32 + mode *uint32 + }{ + {name: "context", uid: 200, gid: 201}, + {name: "owner primary differs from context", metadata: MountMetadata{Owner: "postfix"}, uid: 202, gid: 202}, + {name: "explicit group", metadata: MountMetadata{Owner: "postfix", Group: "shared"}, uid: 202, gid: 203}, + {name: "root is a user", metadata: MountMetadata{Owner: "root"}, uid: 0, gid: 0}, + {name: "mode zero is present", metadata: MountMetadata{Permissions: "0000"}, uid: 200, gid: 201, mode: uint32Pointer(0)}, + {name: "Linux special mode bits", metadata: MountMetadata{Permissions: "2750"}, uid: 200, gid: 201, mode: uint32Pointer(0o2750)}, + } { + t.Run(test.name, func(t *testing.T) { + got, err := ResolveMountMetadata(test.metadata, policy, mailMountContext()) + if err != nil { + t.Fatal(err) + } + if got.UID != test.uid || got.GID != test.gid || !reflect.DeepEqual(got.Permissions, test.mode) { + t.Fatalf("resolved metadata = %#v", got) + } + }) + } + for _, context := range []runtimeidentity.IdentityV1{ + {Username: "root", UID: "0", GID: "0", SupplementaryGIDs: []string{}}, + {Username: "arbiter", UID: "1000", GID: "42", SupplementaryGIDs: []string{}}, + } { + got, err := ResolveMountMetadata(MountMetadata{}, nil, context) + if err != nil || got.Permissions != nil { + t.Fatalf("ordinary contextual metadata: %#v, %v", got, err) + } + if context.UID == "0" && (got.UID != 0 || got.GID != 0) || context.UID == "1000" && (got.UID != 1000 || got.GID != 42) { + t.Fatalf("ordinary runtime owner was changed: %#v", got) + } + } +} + +func uint32Pointer(value uint32) *uint32 { return &value } + +func TestManagedMountMetadataRefusesUnprovedPrincipalsAndContexts(t *testing.T) { + policy := mountBlueprint(t, "").Environment.Runtime.IdentityPolicy + for _, metadata := range []MountMetadata{ + {Owner: "app"}, {Owner: "unknown"}, {Group: "unknown"}, {Group: "app"}, + {Owner: "../mail"}, {Owner: "200"}, {Permissions: "750"}, {Permissions: "+123"}, + {Permissions: "0888"}, {Permissions: "-100"}, {Permissions: " 700"}, {Permissions: "10000"}, + } { + if _, err := ResolveMountMetadata(metadata, policy, mailMountContext()); err == nil { + t.Fatalf("invalid metadata accepted: %#v", metadata) + } + } + for _, mutate := range []func(*runtimeidentity.IdentityV1){ + func(c *runtimeidentity.IdentityV1) { c.Username = "unknown" }, + func(c *runtimeidentity.IdentityV1) { c.UID = "202" }, + func(c *runtimeidentity.IdentityV1) { c.GID = "203" }, + func(c *runtimeidentity.IdentityV1) { c.UID = "0200" }, + func(c *runtimeidentity.IdentityV1) { c.UID = "4294967295" }, + } { + context := mailMountContext() + mutate(&context) + if _, err := ResolveMountMetadata(MountMetadata{}, policy, context); err == nil { + t.Fatalf("unproved default context accepted: %#v", context) + } + } + for _, metadata := range []MountMetadata{{Owner: "postfix"}, {Group: "staff"}} { + if _, err := ResolveMountMetadata(metadata, nil, mailMountContext()); err == nil { + t.Fatalf("undeclared host lookup implicitly accepted: %#v", metadata) + } + } + got, err := ResolveMountMetadata(MountMetadata{Owner: "mail"}, nil, mailMountContext()) + if err != nil || got.UID != 200 || got.GID != 201 { + t.Fatalf("exact ordinary context was not reused: %#v, %v", got, err) + } +} + +func TestManagedMountObjectsHaveDeterministicPreparationOrder(t *testing.T) { + document := mountBlueprint(t, ` owner: postfix + directories: + z/a: {permissions: "0700"} + z: {} + a: {} + files: + z/first: {owner: postfix} + aa: {} +`) + objects, err := ResolveManagedMountObjects(document.Environment.Mounts["data"], document.Environment.Runtime.IdentityPolicy, mailMountContext()) + if err != nil { + t.Fatal(err) + } + want := []string{".", "a", "z", "z/a", "aa", "z/first"} + if len(objects) != len(want) { + t.Fatalf("objects = %#v", objects) + } + for i, object := range objects { + if object.Path != want[i] || object.Directory != (i < 4) { + t.Fatalf("preparation order = %#v", objects) + } + } + if objects[0].Identity.UID != 202 || objects[1].Identity.UID != 200 || objects[1].Identity.GID != 201 || objects[5].Identity.UID != 202 { + t.Fatal("an omitted child owner must use its runtime context, independently of an explicit mount owner") + } + if objects[3].Identity.Permissions == nil || *objects[3].Identity.Permissions != 0o700 || objects[1].Identity.Permissions != nil { + t.Fatal("omitted creation permissions were not preserved") + } +} + +func TestManagedMountPathsAndKindsRejectAmbiguousTrees(t *testing.T) { + for _, name := range []string{"", ".", "..", "../file", "a/../file", "a/./file", "/file", "a//b", "a/", `a\b`, "a\x00b", "a\nb"} { + for _, directory := range []bool{false, true} { + mount := EnvironmentMount{Target: "/data"} + if directory { + mount.Directories = map[string]MountMetadata{name: {}} + } else { + mount.Files = map[string]MountMetadata{name: {}} + } + if _, err := ResolveManagedMountObjects(mount, nil, mailMountContext()); err == nil { + t.Fatalf("unsafe child path accepted: %q", name) + } + } + } + for _, mount := range []EnvironmentMount{ + {Target: "/data", Directories: map[string]MountMetadata{"queue": {}}, Files: map[string]MountMetadata{"queue": {}}}, + {Target: "/data", Directories: map[string]MountMetadata{"queue/active": {}}, Files: map[string]MountMetadata{"queue": {}}}, + {Target: "/data", Files: map[string]MountMetadata{"queue": {}, "queue/file": {}}}, + } { + if _, err := ResolveManagedMountObjects(mount, nil, mailMountContext()); err == nil { + t.Fatalf("conflicting object tree accepted: %#v", mount) + } + } +} + +func TestManagedMountObjectResolverRejectsProtectedOrUnresolvedTargets(t *testing.T) { + for _, target := range []string{"", ".", "data", "/", "/data/../etc", "/data//x", `/data\x`, "/etc/passwd", "/proc", "/opt/reploy", "/mnt/reploy-home"} { + mount := EnvironmentMount{Target: target, Files: map[string]MountMetadata{"file": {}}} + if _, err := ResolveManagedMountObjects(mount, nil, mailMountContext()); err == nil { + t.Fatalf("resolver accepted a non-managed or protected target %q", target) + } + } +} + +func TestManagedMountMetadataStrictYAML(t *testing.T) { + for _, fields := range []string{ + " objects: {}\n", " owner: {name: mail}\n", " files: []\n", + " directories: {queue: {kind: directory}}\n", " files: {log: {content: secret}}\n", + " files: {log: {}, log: {owner: mail}}\n", + } { + raw := strings.Replace(policyBlueprintV1(mountPolicy), " target: /data\n", " target: /data\n"+fields, 1) + if _, err := Decode([]byte(raw)); err == nil { + t.Fatalf("unexpected public shape accepted: %s", fields) + } + } +} + +func TestIntrinsicRootPolicyIsNotAnExternalMount(t *testing.T) { + for _, writable := range []string{"false", "true"} { + raw := strings.Replace(minimalBlueprint, " mounts:\n data:", " mounts:\n root: {target: /, writable: "+writable+"}\n data:", 1) + source, err := Decode([]byte(raw)) + if err != nil { + t.Fatal(err) + } + document, err := Resolve(source) + if err != nil { + t.Fatal(err) + } + if document.Environment.ImageRootWritable() != (writable == "true") || len(document.Docker.Mounts) != 1 { + t.Fatal("intrinsic root changed external mount inventory or policy") + } + payload, err := EncodeResolvedDocumentV1(document) + if err != nil { + t.Fatal(err) + } + decoded, err := DecodeResolvedDocumentV1(payload) + if err != nil || decoded.Environment.ImageRootWritable() != (writable == "true") { + t.Fatalf("root policy round trip: %v", err) + } + } + for _, root := range []string{ + "root: {target: /, owner: root}", "root: {target: /, permissions: '0755'}", + "root: {target: /, update_policy: preserve}", "other: {target: /}", + "root: {target: /, directories: {var: {}}}", "root: {target: /, files: {x: {}}}", + } { + raw := strings.Replace(minimalBlueprint, " mounts:\n data:", " mounts:\n "+root+"\n data:", 1) + source, err := Decode([]byte(raw)) + if err != nil { + t.Fatal(err) + } + if _, err := Resolve(source); err == nil { + t.Fatalf("root acquired managed storage semantics: %s", root) + } + } +} + +func TestManagedMountCanonicalInputCannotBypassDeclarationChecks(t *testing.T) { + base := mountBlueprint(t, " owner: mail\n") + payload, err := EncodeResolvedDocumentV1(base) + if err != nil { + t.Fatal(err) + } + for _, mutate := range []func(*Document){ + func(d *Document) { + m := d.Environment.Mounts["data"] + m.Owner = "unknown" + d.Environment.Mounts["data"] = m + }, + func(d *Document) { + m := d.Docker.Mounts["data"] + m.Contract.Owner = "postfix" + d.Docker.Mounts["data"] = m + }, + func(d *Document) { m := d.Docker.Mounts["data"]; m.Mode = MountBind; d.Docker.Mounts["data"] = m }, + func(d *Document) { + m := d.Environment.Mounts["data"] + m.Target = "/data/../etc" + d.Environment.Mounts["data"] = m + }, + func(d *Document) { delete(d.Docker.Mounts, "data") }, + func(d *Document) { + m := d.Docker.Mounts["data"] + m.Contract.Files = map[string]MountMetadata{"../outside": {}} + d.Docker.Mounts["data"] = m + }, + } { + document, err := DecodeResolvedDocumentV1(payload) + if err != nil { + t.Fatal(err) + } + mutate(&document) + if _, err := EncodeResolvedDocumentV1(document); err == nil { + t.Fatal("inconsistent direct document accepted") + } + // This bypasses the encoder, as an on-disk or bundle attacker could. + raw, err := json.Marshal(resolvedDocumentEnvelopeV1{Schema: ResolvedDocumentSchemaV1, Document: document}) + if err != nil { + t.Fatal(err) + } + if _, err := DecodeResolvedDocumentV1(ResolvedDocumentV1(raw)); err == nil { + t.Fatal("tampered resolved input bypassed metadata validation") + } + } +} + +func TestManagedMountCanonicalContractRequiresExactReference(t *testing.T) { + for _, reference := range []string{"data", "", "environment.mounts.missing", "environment.mounts.data"} { + t.Run(reference, func(t *testing.T) { + document := mountBlueprint(t, "") + binding := document.Docker.Mounts["data"] + binding.Extends = reference + binding.Contract.Permissions = "0700" + document.Docker.Mounts["data"] = binding + if _, err := EncodeResolvedDocumentV1(document); err == nil { + t.Fatal("unmatched Docker-only metadata was accepted by the encoder") + } + raw, err := json.Marshal(resolvedDocumentEnvelopeV1{Schema: ResolvedDocumentSchemaV1, Document: document}) + if err != nil { + t.Fatal(err) + } + if _, err := DecodeResolvedDocumentV1(ResolvedDocumentV1(raw)); err == nil { + t.Fatal("unmatched Docker-only metadata bypassed canonical decoding") + } + }) + } +} + +func TestOrdinaryMountOmissionPreservesCanonicalWire(t *testing.T) { + source, err := Decode([]byte(minimalBlueprint)) + if err != nil { + t.Fatal(err) + } + document, err := Resolve(source) + if err != nil { + t.Fatal(err) + } + mount := document.Environment.Mounts["data"] + encoded, err := json.Marshal(mount) + if err != nil || string(encoded) != `{"Target":"/data","Writable":true,"UpdatePolicy":"preserve"}` { + t.Fatalf("ordinary mount wire changed: %s, %v", encoded, err) + } + if document.Environment.ImageRootWritable() { + t.Fatal("root policy omission became writable") + } + objects, err := ResolveManagedMountObjects(mount, nil, runtimeidentity.IdentityV1{}) + if err != nil || objects != nil { + t.Fatalf("ordinary omitted declarations acquired preparation or context requirements: %#v, %v", objects, err) + } +} + +func TestManagedMountMetadataUsesExistingGlobalVariables(t *testing.T) { + raw := strings.Replace(policyBlueprintV1(mountPolicy), " id: demo\n", " id: demo\n vars: {mount_owner: mail, mount_mode: '0750'}\n", 1) + raw = strings.Replace(raw, " target: /data\n", " target: /data\n owner: '{{ mount_owner }}'\n files: {log: {permissions: '{{ mount_mode }}'}}\n", 1) + source, err := Decode([]byte(raw)) + if err != nil { + t.Fatal(err) + } + document, err := Resolve(source) + if err != nil { + t.Fatal(err) + } + mount := document.Environment.Mounts["data"] + if mount.Owner != "mail" || mount.Files["log"].Permissions != "0750" { + t.Fatalf("existing global interpolation was not used: %#v", mount) + } +} + +func TestIntrinsicRootRejectsBindingsAndCommandOverrideInBothInputs(t *testing.T) { + raw := strings.Replace(minimalBlueprint, " mounts:\n data:", " mounts:\n root: {target: /}\n data:", 1) + for _, candidate := range []string{ + strings.Replace(raw, " executable: application.server\n", " executable: application.server\n mounts: {root: {writable: true}}\n", 1), + strings.Replace(raw, "docker:\n mounts:\n", "docker:\n mounts:\n root: {extends: environment.mounts.root, mode: bind, source: /tmp}\n", 1), + } { + source, err := Decode([]byte(candidate)) + if err != nil { + t.Fatal(err) + } + if _, err := Resolve(source); err == nil { + t.Fatal("root bind/command override accepted from YAML") + } + } + source, err := Decode([]byte(raw)) + if err != nil { + t.Fatal(err) + } + document, err := Resolve(source) + if err != nil { + t.Fatal(err) + } + for _, binding := range []bool{false, true} { + copy := document + if binding { + copy.Docker.Mounts = map[string]DockerMount{"root": {Extends: "environment.mounts.root", Mode: MountManagedBind, Source: "root", Contract: EnvironmentMount{Target: "/"}}} + } else { + copy.Environment.Commands = map[string]Command{"serve": {Mounts: map[string]CommandMountOverride{"root": {Writable: true}}}} + } + encoded, err := json.Marshal(resolvedDocumentEnvelopeV1{Schema: ResolvedDocumentSchemaV1, Document: copy}) + if err != nil { + t.Fatal(err) + } + if _, err := DecodeResolvedDocumentV1(ResolvedDocumentV1(encoded)); err == nil { + t.Fatal("resolved document bypassed intrinsic root restrictions") + } + } +} diff --git a/internal/blueprint/resolve.go b/internal/blueprint/resolve.go index 4fa075ce..13935301 100644 --- a/internal/blueprint/resolve.go +++ b/internal/blueprint/resolve.go @@ -458,18 +458,24 @@ func resolveMounts(source Syntax, extended extendedSyntax, document *Document) e if !path.IsAbs(target) || path.Clean(target) != target || strings.Contains(target, `\`) { return fmt.Errorf("environment.mounts.%s.target must be a normalized absolute Linux path", name) } - if err := validateEnvironmentMountTarget(target); err != nil { - return fmt.Errorf("environment.mounts.%s.target: %w", name, err) + if target == "/" && name != "root" { + return fmt.Errorf("environment.mounts.%s.target must not be the container filesystem root", name) + } + if target != "/" { + if err := validateEnvironmentMountTarget(target); err != nil { + return fmt.Errorf("environment.mounts.%s.target: %w", name, err) + } } update := UpdatePolicy(item.UpdatePolicy) - if update != UpdatePreserve && update != UpdateReplace && update != UpdateUnmanaged { + if target != "/" && update != UpdatePreserve && update != UpdateReplace && update != UpdateUnmanaged { return fmt.Errorf("environment.mounts.%s.update_policy must be preserve, replace, or unmanaged", name) } writable, err := resolveSyntaxBool(item.Writable, "environment.mounts."+name+".writable") if err != nil { return err } - document.Environment.Mounts[name] = EnvironmentMount{Target: target, Writable: writable, UpdatePolicy: update} + document.Environment.Mounts[name] = EnvironmentMount{Target: target, Writable: writable, UpdatePolicy: update, + MountMetadata: item.MountMetadata, Directories: item.Directories, Files: item.Files} } mountReferences := map[string]int{} for _, name := range sortedKeys(extended.Mounts) { @@ -477,6 +483,9 @@ func resolveMounts(source Syntax, extended extendedSyntax, document *Document) e contractName, _ := referencedName("extends", item.Docker.Extends, environmentMountReferencePrefix) mountReferences[contractName]++ contract := document.Environment.Mounts[contractName] + if contract.Target == "/" { + return fmt.Errorf("intrinsic root cannot have a Docker mount binding") + } mount := DockerMount{ Extends: item.Docker.Extends, Mode: MountMode(item.Docker.Mode), @@ -490,11 +499,14 @@ func resolveMounts(source Syntax, extended extendedSyntax, document *Document) e document.Docker.Mounts[name] = mount } for _, name := range sortedKeys(document.Environment.Mounts) { + if document.Environment.Mounts[name].Target == "/" { + continue + } if mountReferences[name] != 1 { return fmt.Errorf("environment mount %q must have exactly one Docker mount; found %d", name, mountReferences[name]) } } - return nil + return document.ValidateMountDeclarations() } func validateEnvironmentMountTarget(target string) error { @@ -657,6 +669,9 @@ func resolveCommandMountOverrides( if _, found := environmentMounts[name]; !found { return nil, fmt.Errorf("%s.%s references unknown environment mount %q", field, name, name) } + if environmentMounts[name].Target == "/" { + return nil, fmt.Errorf("%s.%s cannot override the intrinsic root policy", field, name) + } item := source[name] if item.Writable == nil { return nil, fmt.Errorf("%s.%s.writable is required", field, name) diff --git a/internal/blueprint/resolved_document_v1.go b/internal/blueprint/resolved_document_v1.go index 1d8f8910..02ebd064 100644 --- a/internal/blueprint/resolved_document_v1.go +++ b/internal/blueprint/resolved_document_v1.go @@ -23,6 +23,9 @@ type resolvedDocumentEnvelopeV1 struct { } func EncodeResolvedDocumentV1(document Document) (ResolvedDocumentV1, error) { + if err := document.ValidateMountDeclarations(); err != nil { + return "", err + } if err := document.Environment.Runtime.IdentityPolicy.Validate(); err != nil { return "", err } diff --git a/internal/blueprint/syntax.go b/internal/blueprint/syntax.go index f6c9b19a..8c394629 100644 --- a/internal/blueprint/syntax.go +++ b/internal/blueprint/syntax.go @@ -112,9 +112,12 @@ type APTPackageRequestSyntax struct { } type MountSyntax struct { - Target string `yaml:"target"` - Writable any `yaml:"writable"` - UpdatePolicy string `yaml:"update_policy"` + Target string `yaml:"target"` + Writable any `yaml:"writable"` + UpdatePolicy string `yaml:"update_policy"` + MountMetadata `yaml:",inline"` + Directories map[string]MountMetadata `yaml:"directories"` + Files map[string]MountMetadata `yaml:"files"` } type ExecutableSyntax struct { diff --git a/internal/dockerdeploy/execution_plan.go b/internal/dockerdeploy/execution_plan.go index efc50c70..97536106 100644 --- a/internal/dockerdeploy/execution_plan.go +++ b/internal/dockerdeploy/execution_plan.go @@ -11,6 +11,7 @@ import ( "github.com/omry/reploy/internal/blueprint" "github.com/omry/reploy/internal/deploy" + "github.com/omry/reploy/internal/runtimeidentity" ) type DockerPlanContext struct { @@ -88,6 +89,9 @@ type RuntimeUserPlan struct { } func PlanDockerExecution(document blueprint.Document, context DockerPlanContext) (DockerExecutionPlan, error) { + if err := document.ValidateMountDeclarations(); err != nil { + return DockerExecutionPlan{}, err + } if context.DeploymentDir == "" { return DockerExecutionPlan{}, fmt.Errorf("Docker plan deployment directory is required") } @@ -144,12 +148,37 @@ func PlanDockerExecution(document blueprint.Document, context DockerPlanContext) return DockerExecutionPlan{}, err } plan.Sandbox = newApplicationSandboxPlanWithNetworkV1(runtimeUser, document.Environment.Runtime.Network) + plan.Sandbox.ReadOnlyRoot = !document.Environment.ImageRootWritable() if err := ValidateApplicationSandboxPlanV1(plan.Sandbox); err != nil { return DockerExecutionPlan{}, err } + for _, name := range sortedEnvironmentMountNames(document.Environment.Mounts) { + mount := document.Environment.Mounts[name] + if !mount.HasMetadata() { + continue + } + _, err := blueprint.ResolveManagedMountObjects(mount, document.Environment.Runtime.IdentityPolicy, runtimeidentity.IdentityV1{ + Username: runtimeUser.LocalUser, UID: runtimeIDStringV1(runtimeUser.UID), GID: runtimeIDStringV1(runtimeUser.GID), SupplementaryGIDs: []string{}, + }) + if err != nil { + return DockerExecutionPlan{}, fmt.Errorf("mount %s metadata: %w", name, err) + } + // Declarations must not be silently ignored before F1.2/F1.3 connects + // their confined preparation consumer to actual execution. + return DockerExecutionPlan{}, fmt.Errorf("mount %s requires qualified managed metadata preparation", name) + } return plan, nil } +func sortedEnvironmentMountNames(mounts map[string]blueprint.EnvironmentMount) []string { + names := make([]string, 0, len(mounts)) + for name := range mounts { + names = append(names, name) + } + sort.Strings(names) + return names +} + func sortedMountPlans(plans map[string]MountExecutionPlan) []MountExecutionPlan { names := make([]string, 0, len(plans)) for name := range plans { diff --git a/internal/dockerdeploy/execution_plan_metadata_test.go b/internal/dockerdeploy/execution_plan_metadata_test.go new file mode 100644 index 00000000..f99304b1 --- /dev/null +++ b/internal/dockerdeploy/execution_plan_metadata_test.go @@ -0,0 +1,95 @@ +package dockerdeploy + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/omry/reploy/internal/blueprint" +) + +func TestIntrinsicRootPlansDoNotCreateRootBinds(t *testing.T) { + for _, host := range []blueprint.HostOS{blueprint.HostLinux, blueprint.HostMacOS, blueprint.HostWindows} { + for _, explicit := range []bool{false, true} { + document := blueprint.Document{Environment: blueprint.Environment{ID: "root-policy", Mounts: map[string]blueprint.EnvironmentMount{}}} + if explicit { + document.Environment.Mounts["root"] = blueprint.EnvironmentMount{Target: "/"} + } + plan, err := PlanDockerExecution(document, DockerPlanContext{ + DeploymentDir: t.TempDir(), Phase: blueprint.PhaseStaged, GeneratedImage: "pinned-image", Host: host, UID: 1000, GID: 1000, + }) + if err != nil { + t.Fatal(err) + } + if !plan.Sandbox.ReadOnlyRoot || len(plan.Mounts) != 0 { + t.Fatal("intrinsic read-only root became a bind or changed the sandbox") + } + rendered, err := RenderDockerInputs(plan, "appctl") + if err != nil || !strings.Contains(string(rendered.Compose), "read_only: true") { + t.Fatalf("read-only rendering: %s, %v", rendered.Compose, err) + } + } + } +} + +func TestIntrinsicWritableRootCannotBypassCurrentSandboxQualification(t *testing.T) { + document := blueprint.Document{Environment: blueprint.Environment{ID: "root-policy", Mounts: map[string]blueprint.EnvironmentMount{ + "root": {Target: "/", Writable: true}, + }}} + _, err := PlanDockerExecution(document, DockerPlanContext{ + DeploymentDir: t.TempDir(), Phase: blueprint.PhaseStaged, GeneratedImage: "pinned-image", UID: 1000, GID: 1000, + }) + if err == nil || !strings.Contains(err.Error(), "read-only container root") { + t.Fatalf("unqualified root-writable execution was accepted or silently changed to read-only: %v", err) + } +} + +func TestManagedMetadataExecutionRefusesBeforePreparationExists(t *testing.T) { + for _, mount := range []blueprint.EnvironmentMount{ + {MountMetadata: blueprint.MountMetadata{Owner: blueprint.DefaultRuntimeUser}}, + {MountMetadata: blueprint.MountMetadata{Permissions: "0750"}}, + {Directories: map[string]blueprint.MountMetadata{"queue": {}}}, + {Files: map[string]blueprint.MountMetadata{"file": {Permissions: "0000"}}}, + } { + mount.Target, mount.UpdatePolicy, mount.Writable = "/data", blueprint.UpdatePreserve, true + document := blueprint.Document{ + Environment: blueprint.Environment{ID: "metadata", Mounts: map[string]blueprint.EnvironmentMount{"data": mount}}, + Docker: blueprint.Docker{Mounts: map[string]blueprint.DockerMount{"data": { + Extends: "environment.mounts.data", Mode: blueprint.MountManagedBind, Source: "data", Contract: mount, + }}}, + } + root := t.TempDir() + _, err := PlanDockerExecution(document, DockerPlanContext{ + DeploymentDir: root, Phase: blueprint.PhaseStaged, GeneratedImage: "pinned-image", UID: 1000, GID: 1000, + }) + if err == nil || !strings.Contains(err.Error(), "requires qualified managed metadata preparation") { + t.Fatalf("metadata was ignored instead of refusing execution: %v", err) + } + if _, err := os.Stat(filepath.Join(root, "data")); !os.IsNotExist(err) { + t.Fatalf("refused metadata execution changed the managed root: %v", err) + } + } +} + +func TestManagedMetadataContractWithoutExactReferenceRefusesBeforeEffects(t *testing.T) { + mount := blueprint.EnvironmentMount{Target: "/data", UpdatePolicy: blueprint.UpdatePreserve, Writable: true} + contract := mount + contract.Permissions = "0700" + document := blueprint.Document{ + Environment: blueprint.Environment{ID: "metadata", Mounts: map[string]blueprint.EnvironmentMount{"data": mount}}, + Docker: blueprint.Docker{Mounts: map[string]blueprint.DockerMount{"data": { + Extends: "data", Mode: blueprint.MountManagedBind, Source: "data", Contract: contract, + }}}, + } + root := t.TempDir() + _, err := PlanDockerExecution(document, DockerPlanContext{ + DeploymentDir: root, Phase: blueprint.PhaseStaged, GeneratedImage: "pinned-image", UID: 1000, GID: 1000, + }) + if err == nil || !strings.Contains(err.Error(), "exact environment mount contract") { + t.Fatalf("unmatched metadata was ignored instead of refusing: %v", err) + } + if _, err := os.Stat(filepath.Join(root, "data")); !os.IsNotExist(err) { + t.Fatalf("refused unmatched metadata changed the managed root: %v", err) + } +} diff --git a/noxfile.py b/noxfile.py index 7ce06f82..30b85d53 100644 --- a/noxfile.py +++ b/noxfile.py @@ -37,7 +37,9 @@ def _go_test(session: nox.Session) -> None: env = {"GOCACHE": os.environ.get("GOCACHE", str(default_go_cache))} # Windows ARM64 and Intel macOS have slower filesystem-heavy fixture setup; this # bounds the whole package suite, independently of individual probe limits. - timeout = "5m" if _current_target_label() in {"windows-arm64", "darwin-amd64"} else "3m" + timeout = {"windows-arm64": "10m", "darwin-amd64": "5m"}.get( + _current_target_label(), "3m" + ) session.run("go", "test", "-timeout", timeout, "./...", env=env, external=True)