From 851f76b2c8df61f7735b682e8e4356183ae90804 Mon Sep 17 00:00:00 2001 From: AWD private candidate <> Date: Wed, 4 Nov 2026 21:49:07 +0800 Subject: [PATCH] F2.1.1: Inspect retained managed mount paths without mutation Add a read-only operation to the existing subordinate setup helper. Verify current root-relative paths against retained descriptors and exact declared metadata through the existing confined lookup. Reuse current authorization, sealed requests, sparse maps, descriptor transport and helper retirement. Qualify protected subordinate directories and files, parent traversal denial, replacement/missing/link/type/mount refusals, unchanged metadata/content, authorization withdrawal and cancellation. Existing preparation and recipient checks remain valid. No public mapped activation. --- .../dockerdeploy/managed_mount_setup_linux.go | 90 ++++- .../managed_mount_setup_linux_test.go | 324 ++++++++++++++++++ 2 files changed, 408 insertions(+), 6 deletions(-) diff --git a/internal/dockerdeploy/managed_mount_setup_linux.go b/internal/dockerdeploy/managed_mount_setup_linux.go index dcdb63a1..c4c24550 100644 --- a/internal/dockerdeploy/managed_mount_setup_linux.go +++ b/internal/dockerdeploy/managed_mount_setup_linux.go @@ -18,6 +18,7 @@ import ( "strconv" "syscall" "time" + "unicode/utf8" "github.com/omry/reploy/internal/blueprint" "github.com/omry/reploy/internal/deploy" @@ -33,6 +34,7 @@ type managedMountSetupManifestV1 struct { Objects []blueprint.ManagedMountObject Recorded []string Parent int + Inspect bool } func managedMountSetupHostIDV1(id uint32, rows []identitymapping.Interval) (uint32, bool) { @@ -63,12 +65,27 @@ func managedMountSetupSocketV1(file *os.File) (*net.UnixConn, error) { // remain held throughout. Success transfers the returned FDs only after helper // retirement; any failure leaves all effects inhibited for explicit recovery. func prepareManagedMountSetupV1(ctx context.Context, ops deploy.IdentityMappingOperationsV1, expected deploy.StateV1, active identitymapping.Geometry, operation string, root, staging *os.File, objects []blueprint.ManagedMountObject, recorded map[string]*os.File) (result map[string]preparedManagedMountObjectV1, err error) { - if ctx == nil || root == nil || staging == nil { - return nil, fmt.Errorf("setup requires context and held storage descriptors") + return runManagedMountSetupV1(ctx, active, root, staging, objects, recorded, false, func(ctx context.Context) error { + _, err := ops.ObserveEffects(ctx, expected, active, operation) + return err + }) +} + +// inspectManagedMountSetupV1 verifies every declared path against its retained +// descriptor without creating or changing an object. authorize must freshly +// check the selected owner under its held operation lock, without recursing +// through this observer. Returned descriptors transfer only after retirement. +func inspectManagedMountSetupV1(ctx context.Context, active identitymapping.Geometry, root *os.File, objects []blueprint.ManagedMountObject, recorded map[string]*os.File, authorize func(context.Context) error) (map[string]preparedManagedMountObjectV1, error) { + return runManagedMountSetupV1(ctx, active, root, nil, objects, recorded, true, authorize) +} + +func runManagedMountSetupV1(ctx context.Context, active identitymapping.Geometry, root, staging *os.File, objects []blueprint.ManagedMountObject, recorded map[string]*os.File, inspect bool, authorize func(context.Context) error) (result map[string]preparedManagedMountObjectV1, err error) { + if ctx == nil || root == nil || authorize == nil || !inspect && staging == nil { + return nil, fmt.Errorf("setup requires context, current authorization and held storage descriptors") } ctx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() - if _, err = ops.ObserveEffects(ctx, expected, active, operation); err != nil { + if err = authorize(ctx); err != nil { return nil, err } ordered, err := orderManagedMountObjectsV1(objects) @@ -76,14 +93,28 @@ func prepareManagedMountSetupV1(ctx context.Context, ops deploy.IdentityMappingO return nil, err } for _, object := range ordered { + if !utf8.ValidString(object.Path) { + return nil, fmt.Errorf("setup declaration path contains invalid UTF-8") + } _, uid := managedMountSetupHostIDV1(object.Identity.UID, active.UIDs) _, gid := managedMountSetupHostIDV1(object.Identity.GID, active.GIDs) if !uid || !gid { return nil, fmt.Errorf("setup declaration contains an unmapped identity") } } - manifest := managedMountSetupManifestV1{Geometry: active, Objects: ordered, Parent: os.Getpid()} + if inspect && len(recorded) != len(ordered) { + return nil, fmt.Errorf("inspection requires complete retained descriptor provenance") + } + for _, object := range ordered { + if inspect && recorded[object.Path] == nil { + return nil, fmt.Errorf("inspection lacks retained object %q", object.Path) + } + } + manifest := managedMountSetupManifestV1{Geometry: active, Objects: ordered, Parent: os.Getpid(), Inspect: inspect} for name, file := range recorded { + if !utf8.ValidString(name) { + return nil, fmt.Errorf("setup recorded path contains invalid UTF-8") + } if file == nil { return nil, fmt.Errorf("nil recorded descriptor") } @@ -182,7 +213,7 @@ func prepareManagedMountSetupV1(ctx context.Context, ops deploy.IdentityMappingO } } // Neither the manifest nor an earlier successful check is current authority. - if _, err = ops.ObserveEffects(ctx, expected, active, operation); err != nil { + if err = authorize(ctx); err != nil { return nil, err } if _, err = channel.Write([]byte{2}); err != nil { @@ -305,7 +336,12 @@ func runManagedMountSetupChildV1() error { } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - prepared, err := prepareManagedMountObjectsV1(ctx, 5, 6, manifest.Objects, recorded) + var prepared map[string]preparedManagedMountObjectV1 + if manifest.Inspect { + prepared, err = inspectManagedMountObjectsV1(ctx, 5, manifest.Objects, recorded) + } else { + prepared, err = prepareManagedMountObjectsV1(ctx, 5, 6, manifest.Objects, recorded) + } if err != nil { return err } @@ -326,3 +362,45 @@ func runManagedMountSetupChildV1() error { } return nil } + +// Inspection reuses the confined lookup and exact recorded-object verifier. +// Its only filesystem operations open paths and read descriptor metadata. +func inspectManagedMountObjectsV1(ctx context.Context, root int, objects []blueprint.ManagedMountObject, recorded map[string]int) (result map[string]preparedManagedMountObjectV1, err error) { + ordered, err := orderManagedMountObjectsV1(objects) + if err != nil { + return nil, err + } + identity, err := managedMountObjectIdentityForFDV1(root) + if err != nil || identity.Mode&unix.S_IFMT != unix.S_IFDIR || len(recorded) != len(ordered) { + return nil, fmt.Errorf("inspection requires a held root and complete recorded objects: %v", err) + } + observed := make(map[string]preparedManagedMountObjectV1, len(ordered)) + defer func() { + if err != nil { + for _, object := range observed { + unix.Close(object.FD) + } + result = nil + } + }() + for _, declaration := range ordered { + if err = ctx.Err(); err != nil { + return nil, err + } + expected, present := recorded[declaration.Path] + if !present { + return nil, fmt.Errorf("inspection lacks retained object %q", declaration.Path) + } + fd, openErr := openManagedMountObjectV1(root, declaration.Path, unix.O_PATH) + if openErr != nil { + return nil, openErr + } + actual, checkErr := verifyManagedMountObjectV1(fd, identity.Mount, declaration, expected) + if checkErr != nil { + unix.Close(fd) + return nil, checkErr + } + observed[declaration.Path] = preparedManagedMountObjectV1{FD: fd, Identity: actual} + } + return observed, ctx.Err() +} diff --git a/internal/dockerdeploy/managed_mount_setup_linux_test.go b/internal/dockerdeploy/managed_mount_setup_linux_test.go index 7c3c4afb..9530fc5e 100644 --- a/internal/dockerdeploy/managed_mount_setup_linux_test.go +++ b/internal/dockerdeploy/managed_mount_setup_linux_test.go @@ -453,3 +453,327 @@ func TestManagedMountSetupBarrierV1(t *testing.T) { assertManagedMountSetupRetiredV1(t) t.Log("barrier", mode, "refused and retired:", err) } + +func managedMountInspectFixtureV1(t *testing.T) (string, []blueprint.ManagedMountObject, map[string]int) { + t.Helper() + root := t.TempDir() + if err := os.Chmod(root, 0o750); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(filepath.Join(root, "private"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "private/data"), []byte("retained data\n"), 0o600); err != nil { + t.Fatal(err) + } + objects := managedMountSetupDeclarationsV1() + recorded := map[string]int{} + for i := range objects { + objects[i].Identity.UID, objects[i].Identity.GID = uint32(os.Geteuid()), uint32(os.Getegid()) + fd, err := unix.Open(filepath.Join(root, objects[i].Path), unix.O_PATH|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0) + if err != nil { + t.Fatal(err) + } + recorded[objects[i].Path] = fd + t.Cleanup(func() { unix.Close(fd) }) + } + return root, objects, recorded +} + +func TestManagedMountInspectCurrentPathsV1(t *testing.T) { + for _, mode := range []string{"exact", "replaced", "missing", "symlink", "type", "hardlink", "wrong-metadata", "missing-provenance", "invalid-descriptor", "canceled"} { + t.Run(mode, func(t *testing.T) { + root, objects, recorded := managedMountInspectFixtureV1(t) + data := filepath.Join(root, "private/data") + protected := filepath.Join(t.TempDir(), "protected") + if err := os.WriteFile(protected, []byte("foreign target\n"), 0o600); err != nil { + t.Fatal(err) + } + if mode == "replaced" || mode == "missing" || mode == "symlink" || mode == "type" || mode == "hardlink" { + if err := os.Rename(data, data+".retained"); err != nil { + t.Fatal(err) + } + } + switch mode { + case "replaced": + if err := os.WriteFile(data, []byte("replacement data\n"), 0o600); err != nil { + t.Fatal(err) + } + case "symlink": + if err := os.Symlink(protected, data); err != nil { + t.Fatal(err) + } + case "type": + if err := os.Mkdir(data, 0o600); err != nil { + t.Fatal(err) + } + case "hardlink": + if err := os.Link(protected, data); err != nil { + t.Fatal(err) + } + case "wrong-metadata": + if err := os.Chmod(data, 0o640); err != nil { + t.Fatal(err) + } + } + before := map[string]managedMountObjectIdentityV1{} + for name, fd := range recorded { + var err error + before[name], err = managedMountObjectIdentityForFDV1(fd) + if err != nil { + t.Fatal(err) + } + } + proof := make(map[string]int, len(recorded)) + for name, fd := range recorded { + proof[name] = fd + } + if mode == "missing-provenance" { + delete(proof, "private/data") + } else if mode == "invalid-descriptor" { + proof["private/data"] = -1 + } + ctx := t.Context() + if mode == "canceled" { + var cancel context.CancelFunc + ctx, cancel = context.WithCancel(ctx) + cancel() + } + pathsBefore := map[string]unix.Stat_t{} + pathsMissing := map[string]bool{} + for _, name := range []string{root, filepath.Join(root, "private"), data, protected} { + var stat unix.Stat_t + if err := unix.Lstat(name, &stat); err == unix.ENOENT { + pathsMissing[name] = true + } else if err != nil { + t.Fatal(err) + } else { + pathsBefore[name] = stat + } + } + fdsBefore, err := os.ReadDir("/proc/self/fd") + if err != nil { + t.Fatal(err) + } + got, err := inspectManagedMountObjectsV1(ctx, recorded["."], objects, proof) + if mode == "exact" { + if err != nil || len(got) != len(objects) { + t.Fatal("exact retained paths refused", err) + } + for name, object := range got { + if object.Identity != before[name] { + t.Error("inspection changed object identity", name) + } + if _, err := unix.Write(object.FD, []byte("must not write")); err == nil { + t.Error("inspection returned a writable descriptor") + } + unix.Close(object.FD) + } + } else if err == nil || got != nil { + t.Fatal("unproved current path admitted", mode, err) + } + fdsAfter, err := os.ReadDir("/proc/self/fd") + if err != nil || len(fdsBefore) != len(fdsAfter) { + t.Fatal("inspection leaked a descriptor", len(fdsBefore), len(fdsAfter), err) + } + for name, fd := range recorded { + after, err := managedMountObjectIdentityForFDV1(fd) + if err != nil || after != before[name] { + t.Fatal("inspection changed retained metadata", name, err) + } + } + for name, before := range pathsBefore { + var after unix.Stat_t + if err := unix.Lstat(name, &after); err != nil || before.Dev != after.Dev || before.Ino != after.Ino || + before.Mode != after.Mode || before.Uid != after.Uid || before.Gid != after.Gid || before.Nlink != after.Nlink || + before.Size != after.Size || before.Mtim != after.Mtim || before.Ctim != after.Ctim { + t.Fatal("inspection mutated a declared or foreign path", name, err) + } + } + for name := range pathsMissing { + var stat unix.Stat_t + if err := unix.Lstat(name, &stat); err != unix.ENOENT { + t.Fatal("inspection created an absent path", name, err) + } + } + content, err := os.ReadFile(protected) + if err != nil || string(content) != "foreign target\n" { + t.Fatal("inspection changed foreign content", err) + } + if mode == "missing" { + if _, err := os.Lstat(data); !os.IsNotExist(err) { + t.Fatal("inspection created an absent object", err) + } + } else if mode == "replaced" || mode == "exact" || mode == "wrong-metadata" { + content, err := os.ReadFile(data) + want := "retained data\n" + if mode == "replaced" { + want = "replacement data\n" + } + if err != nil || string(content) != want { + t.Fatal("inspection changed path content", err) + } + } + }) + } +} + +func TestManagedMountInspectRequiresAuthorizationV1(t *testing.T) { + root := managedMountSetupHeldV1(t, t.TempDir()) + objects := managedMountSetupDeclarationsV1() + for _, authorize := range []func(context.Context) error{nil, func(context.Context) error { return fmt.Errorf("owner changed") }} { + if got, err := inspectManagedMountSetupV1(t.Context(), managedMountSetupGeometryV1(), root, objects, nil, authorize); err == nil || got != nil { + t.Fatal("inspection accepted missing current authorization") + } + } + calls := 0 + authorize := func(context.Context) error { calls++; return nil } + if got, err := inspectManagedMountSetupV1(t.Context(), managedMountSetupGeometryV1(), root, objects, map[string]*os.File{".": root}, authorize); err == nil || got != nil || calls != 1 { + t.Fatal("inspection accepted incomplete retained provenance", calls, err) + } +} + +// The disposable guest oracle changes protected paths only after the real +// preparation helper has retired. It independently checks the replacement and +// foreign target before/after observation. The operator cannot traverse this tree. +func TestManagedMountInspectNativeV1(t *testing.T) { + dir, mode := os.Getenv("REPLOY_TEST_MANAGED_SETUP_ROOT"), os.Getenv("REPLOY_TEST_MANAGED_INSPECT_MODE") + if dir == "" || mode == "" { + t.Skip("requires disposable subordinate-owned private-tree inspection fixture") + } + ops, state := managedMountSetupOwnerV1(t) + root := managedMountSetupHeldV1(t, filepath.Join(dir, "root")) + staging := managedMountSetupHeldV1(t, filepath.Join(dir, "staging")) + objects, geometry := managedMountSetupDeclarationsV1(), managedMountSetupGeometryV1() + prepared, err := prepareManagedMountSetupV1(t.Context(), ops, state, geometry, "private-setup", root, staging, objects, map[string]*os.File{".": root}) + if err != nil { + t.Fatal(err) + } + recorded := map[string]*os.File{} + for name, object := range prepared { + recorded[name] = os.NewFile(uintptr(object.FD), name) + defer recorded[name].Close() + } + payload := []byte("native-inspection-preserved\n") + if _, err := unix.Pwrite(prepared["private/data"].FD, payload, 0); err != nil { + t.Fatal(err) + } + if fd, err := unix.Openat(int(root.Fd()), "private/data", unix.O_PATH|unix.O_CLOEXEC, 0); err == nil { + unix.Close(fd) + t.Fatal("fixture failed to deny parent traversal") + } else if err != unix.EACCES { + t.Fatal("fixture traversal failed for an unexpected reason", err) + } + if mode != "exact" && mode != "authorization" && mode != "canceled" { + if err := os.WriteFile(filepath.Join(dir, "inspect-ready"), []byte(mode), 0o600); err != nil { + t.Fatal(err) + } + deadline := time.Now().Add(15 * time.Second) + for { + if _, err := os.Stat(filepath.Join(dir, "inspect-done")); err == nil { + break + } + if time.Now().After(deadline) { + t.Fatal("disposable path oracle did not complete") + } + time.Sleep(10 * time.Millisecond) + } + } + before := map[string]managedMountObjectIdentityV1{} + for name, file := range recorded { + before[name], err = managedMountObjectIdentityForFDV1(int(file.Fd())) + if err != nil { + t.Fatal(err) + } + } + calls := 0 + authorize := func(ctx context.Context) error { + calls++ + if mode == "authorization" && calls == 2 { + return fmt.Errorf("fresh owner authority was withdrawn") + } + _, err := ops.ObserveEffects(ctx, state, geometry, "private-setup") + return err + } + ctx := t.Context() + if mode == "canceled" { + var cancel context.CancelFunc + ctx, cancel = context.WithCancel(ctx) + cancel() + } + observed, err := inspectManagedMountSetupV1(ctx, geometry, root, objects, recorded, authorize) + if mode == "exact" { + if err != nil || len(observed) != len(prepared) || calls != 2 { + t.Fatal("protected current paths did not inspect", calls, err) + } + for name, object := range observed { + if object.Identity != prepared[name].Identity { + t.Error("inspection returned a different retained object", name) + } + flags, flagErr := unix.FcntlInt(uintptr(object.FD), unix.F_GETFD, 0) + if flagErr != nil || flags&unix.FD_CLOEXEC == 0 { + t.Error("inspection returned an inheritable descriptor", flagErr) + } + unix.Close(object.FD) + } + } else if err == nil || observed != nil { + t.Fatal("changed or unauthorized protected path admitted", mode, err) + } + for name, file := range recorded { + after, err := managedMountObjectIdentityForFDV1(int(file.Fd())) + if err != nil || after != before[name] { + t.Fatal("inspection changed retained metadata", name, err) + } + } + content := make([]byte, len(payload)) + if n, err := unix.Pread(prepared["private/data"].FD, content, 0); err != nil || n != len(payload) || !reflect.DeepEqual(content, payload) { + t.Fatal("inspection changed retained content", n, err) + } + assertManagedMountSetupRetiredV1(t) + t.Log("native read-only inspection", mode, "retired; protected metadata/content unchanged; authorization checks", calls) +} + +// Malformed path bytes must fail before JSON can rewrite them into a different +// existing path, and before a namespace or setup helper can be created. +func TestManagedMountSetupRejectsInvalidUTF8V1(t *testing.T) { + for _, malformed := range []string{"private/\xff", "private/\xc3", "private/\xed\xa0\x80"} { + for _, mode := range []string{"inspect-declaration", "prepare-declaration", "prepare-recorded"} { + t.Run(fmt.Sprintf("%s/%x", mode, malformed), func(t *testing.T) { + root := managedMountSetupHeldV1(t, t.TempDir()) + objects := managedMountSetupDeclarationsV1() + recorded := map[string]*os.File{".": root, "private": root, "private/data": root} + if mode == "prepare-recorded" { + recorded[malformed] = root + } else { + objects[2].Path = malformed + delete(recorded, "private/data") + recorded[malformed] = root + } + calls := 0 + authorize := func(ctx context.Context) error { + calls++ + return ctx.Err() + } + var got map[string]preparedManagedMountObjectV1 + var err error + if mode == "inspect-declaration" { + got, err = inspectManagedMountSetupV1(t.Context(), managedMountSetupGeometryV1(), root, objects, recorded, authorize) + } else { + // Exercise the shared preparation boundary without requiring native + // host mapping admission for a pre-serialization rejection. + got, err = runManagedMountSetupV1(t.Context(), managedMountSetupGeometryV1(), root, root, objects, recorded, false, authorize) + } + if calls != 1 { + t.Fatalf("helper reached fresh-release authorization: %d", calls) + } + if err == nil || !strings.Contains(err.Error(), "invalid UTF-8") || got != nil { + for _, object := range got { + unix.Close(object.FD) + } + t.Fatalf("malformed path did not refuse before helper creation: %v", err) + } + assertManagedMountSetupRetiredV1(t) + }) + } + } +}