diff --git a/cmd/reploy/main.go b/cmd/reploy/main.go index f7d5152f..343d2522 100644 --- a/cmd/reploy/main.go +++ b/cmd/reploy/main.go @@ -8,6 +8,9 @@ import ( ) func main() { + if code, handled := dockerdeploy.RunManagedMountSetupChildV1(os.Args[1:], os.Stderr); handled { + os.Exit(code) + } if code, handled := dockerdeploy.RunControlledSessionWatchdogChild(os.Args[1:], os.Stderr); handled { os.Exit(code) } diff --git a/internal/deploy/identity_mapping_effects.go b/internal/deploy/identity_mapping_effects.go index f7952640..69402b04 100644 --- a/internal/deploy/identity_mapping_effects.go +++ b/internal/deploy/identity_mapping_effects.go @@ -22,7 +22,7 @@ func (ops IdentityMappingOperationsV1) ObserveEffects(ctx context.Context, expec } state := status.State m := state.IdentityMapping - if state.Current == nil || m == nil || m.PendingOperation == nil || m.PendingOperation.Phase != "effects-pending" { + if state.Current == nil || m == nil || m.Revoked || m.PendingOperation == nil || m.PendingOperation.Phase != "effects-pending" { return StateV1{}, fmt.Errorf("mapped create requires its recorded effects-pending operation") } canonical, err := identitymapping.ValidateGeometry(active) diff --git a/internal/deploy/identity_mapping_effects_test.go b/internal/deploy/identity_mapping_effects_test.go index 93e43076..e5a0dfa9 100644 --- a/internal/deploy/identity_mapping_effects_test.go +++ b/internal/deploy/identity_mapping_effects_test.go @@ -11,6 +11,32 @@ import ( "github.com/omry/reploy/internal/identitymapping" ) +func TestMappingObserveEffectsRefusesRevokedPreparationV1(t *testing.T) { + path, ops, state, _ := operationFixtureV1(t, false, true) + active := operationGeometryV1(100000) + intent, err := ops.Configure(t.Context(), state, active, "revoked-setup") + if err != nil { + t.Fatal(err) + } + pending, err := ops.BeginEffects(t.Context(), intent, "revoked-setup") + if err != nil { + t.Fatal(err) + } + pending.IdentityMapping.Revoked = true + writeMappingFixtureV1(t, path, pending) + before, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if _, err := ops.ObserveEffects(t.Context(), pending, active, "revoked-setup"); err == nil { + t.Fatal("revoked mapping authorized preparation") + } + after, err := os.ReadFile(path) + if err != nil || !reflect.DeepEqual(before, after) { + t.Fatal("refused preparation changed the retained journal", err) + } +} + func TestMappingObserveEffectsBindsExactPendingAuthorityV1(t *testing.T) { for _, installed := range []bool{false, true} { t.Run(map[bool]string{false: "staged", true: "installed"}[installed], func(t *testing.T) { diff --git a/internal/dockerdeploy/managed_mount_setup.go b/internal/dockerdeploy/managed_mount_setup.go new file mode 100644 index 00000000..3de7c270 --- /dev/null +++ b/internal/dockerdeploy/managed_mount_setup.go @@ -0,0 +1,23 @@ +package dockerdeploy + +import ( + "fmt" + "io" +) + +const managedMountSetupArgumentV1 = "__reploy-managed-mount-setup-v1" + +// RunManagedMountSetupChildV1 handles only the fixed private setup operation. +// Its complete input comes from sealed and inherited descriptors, never argv. +func RunManagedMountSetupChildV1(args []string, stderr io.Writer) (int, bool) { + if len(args) != 1 || args[0] != managedMountSetupArgumentV1 { + return 0, false + } + if err := runManagedMountSetupChildV1(); err != nil { + if stderr != nil { + _, _ = fmt.Fprintf(stderr, "managed mount setup: %v\n", err) + } + return 1, true + } + return 0, true +} diff --git a/internal/dockerdeploy/managed_mount_setup_linux.go b/internal/dockerdeploy/managed_mount_setup_linux.go new file mode 100644 index 00000000..dcdb63a1 --- /dev/null +++ b/internal/dockerdeploy/managed_mount_setup_linux.go @@ -0,0 +1,328 @@ +//go:build linux + +package dockerdeploy + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "os" + "os/exec" + "reflect" + "runtime" + "sort" + "strconv" + "syscall" + "time" + + "github.com/omry/reploy/internal/blueprint" + "github.com/omry/reploy/internal/deploy" + "github.com/omry/reploy/internal/identitymapping" + "golang.org/x/sys/unix" +) + +const managedMountSetupLimitV1 = 1 << 20 +const managedMountSetupSealsV1 = unix.F_SEAL_SEAL | unix.F_SEAL_SHRINK | unix.F_SEAL_GROW | unix.F_SEAL_WRITE + +type managedMountSetupManifestV1 struct { + Geometry identitymapping.Geometry + Objects []blueprint.ManagedMountObject + Recorded []string + Parent int +} + +func managedMountSetupHostIDV1(id uint32, rows []identitymapping.Interval) (uint32, bool) { + for _, row := range rows { + if id >= row.ContainerStart && uint64(id) < uint64(row.ContainerStart)+uint64(row.Count) { + return row.HostStart + (id - row.ContainerStart), true + } + } + return 0, false +} + +func managedMountSetupSocketV1(file *os.File) (*net.UnixConn, error) { + conn, err := net.FileConn(file) + if err != nil { + return nil, err + } + packet, ok := conn.(*net.UnixConn) + if !ok { + conn.Close() + return nil, fmt.Errorf("setup handoff is not a Unix socket") + } + return packet, nil +} + +// prepareManagedMountSetupV1 borrows setup-owned, exclusive, unattached storage +// descriptors and their provenance. F2 must supply that resource authority; +// matching ownership is insufficient. The existing mapping operation lock must +// 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") + } + ctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + if _, err = ops.ObserveEffects(ctx, expected, active, operation); err != nil { + return nil, err + } + ordered, err := orderManagedMountObjectsV1(objects) + if err != nil { + return nil, err + } + for _, object := range ordered { + _, 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()} + for name, file := range recorded { + if file == nil { + return nil, fmt.Errorf("nil recorded descriptor") + } + manifest.Recorded = append(manifest.Recorded, name) + } + sort.Strings(manifest.Recorded) + content, err := json.Marshal(manifest) + if err != nil || len(content) > managedMountSetupLimitV1 { + return nil, fmt.Errorf("setup manifest exceeds its bounded contract: %v", err) + } + fd, err := unix.MemfdCreate("reploy-mount-setup", unix.MFD_CLOEXEC|unix.MFD_ALLOW_SEALING) + if err != nil { + return nil, err + } + frozen := os.NewFile(uintptr(fd), "setup-manifest") + defer frozen.Close() + if _, err = frozen.Write(content); err != nil { + return nil, err + } + if _, err = unix.FcntlInt(frozen.Fd(), unix.F_ADD_SEALS, managedMountSetupSealsV1); err != nil { + return nil, err + } + if _, err = frozen.Seek(0, io.SeekStart); err != nil { + return nil, err + } + pair, err := unix.Socketpair(unix.AF_UNIX, unix.SOCK_SEQPACKET|unix.SOCK_CLOEXEC, 0) + if err != nil { + return nil, err + } + parent, child := os.NewFile(uintptr(pair[0]), "setup-parent"), os.NewFile(uintptr(pair[1]), "setup-child") + defer parent.Close() + defer child.Close() + channel, err := managedMountSetupSocketV1(parent) + if err != nil { + return nil, err + } + defer channel.Close() + deadline, _ := ctx.Deadline() + if err = channel.SetDeadline(deadline); err != nil { + return nil, err + } + executable, err := os.Open("/proc/self/exe") + if err != nil { + return nil, err + } + defer executable.Close() + files := []*os.File{frozen, child, root, staging} + for _, name := range manifest.Recorded { + files = append(files, recorded[name]) + } + files = append(files, executable) + for _, name := range []string{"unshare", "newuidmap", "newgidmap"} { + var stat unix.Stat_t + if err = unix.Stat("/usr/bin/"+name, &stat); err != nil || stat.Uid != 0 || stat.Mode&unix.S_IFMT != unix.S_IFREG || stat.Mode&0o022 != 0 { + return nil, fmt.Errorf("setup requires trusted /usr/bin/%s: %v", name, err) + } + } + // Keep the creator thread alive: Linux associates Pdeathsig with that thread. + runtime.LockOSThread() + defer runtime.UnlockOSThread() + command := exec.CommandContext(ctx, "/usr/bin/unshare", "--user", "--keep-caps", "/proc/self/fd/"+strconv.Itoa(2+len(files)), managedMountSetupArgumentV1) + command.ExtraFiles, command.Env, command.Stderr = files, []string{"PATH=/usr/bin:/bin", "LC_ALL=C"}, os.Stderr + command.SysProcAttr = &syscall.SysProcAttr{Pdeathsig: syscall.SIGKILL} + if err = command.Start(); err != nil { + return nil, err + } + child.Close() + retired := false + defer func() { + if !retired { + _ = command.Process.Kill() + _ = command.Wait() + } + if err != nil { + for _, object := range result { + unix.Close(object.FD) + } + result = nil + } + }() + stop := context.AfterFunc(ctx, func() { channel.Close() }) + defer stop() + var signal [1]byte + if _, err = io.ReadFull(channel, signal[:]); err != nil || signal[0] != 1 { + return nil, fmt.Errorf("setup readiness failed: %v", err) + } + for i, rows := range [][]identitymapping.Interval{active.UIDs, active.GIDs} { + args := []string{strconv.Itoa(command.Process.Pid)} + for _, row := range rows { + args = append(args, strconv.FormatUint(uint64(row.ContainerStart), 10), strconv.FormatUint(uint64(row.HostStart), 10), strconv.FormatUint(uint64(row.Count), 10)) + } + mapper := exec.CommandContext(ctx, []string{"/usr/bin/newuidmap", "/usr/bin/newgidmap"}[i], args...) + mapper.Env, mapper.Stderr = command.Env, os.Stderr + if err = mapper.Run(); err != nil { + return nil, fmt.Errorf("install complete setup map: %w", err) + } + } + // Neither the manifest nor an earlier successful check is current authority. + if _, err = ops.ObserveEffects(ctx, expected, active, operation); err != nil { + return nil, err + } + if _, err = channel.Write([]byte{2}); err != nil { + return nil, err + } + result = make(map[string]preparedManagedMountObjectV1, len(ordered)) + for _, object := range ordered { + var data [512]byte + rights := make([]byte, unix.CmsgSpace(4)) + n, oobn, flags, _, readErr := channel.ReadMsgUnix(data[:], rights) + messages, parseErr := unix.ParseSocketControlMessage(rights[:max(0, oobn)]) + var received []int + for _, message := range messages { + fds, e := unix.ParseUnixRights(&message) + parseErr = errors.Join(parseErr, e) + received = append(received, fds...) + } + var identity managedMountObjectIdentityV1 + decodeErr := json.Unmarshal(data[:max(0, n)], &identity) + if readErr != nil || parseErr != nil || decodeErr != nil || flags&(unix.MSG_TRUNC|unix.MSG_CTRUNC) != 0 || len(received) != 1 { + for _, fd := range received { + unix.Close(fd) + } + return result, fmt.Errorf("invalid setup descriptor response: %v %v %v", readErr, parseErr, decodeErr) + } + unix.CloseOnExec(received[0]) + result[object.Path] = preparedManagedMountObjectV1{FD: received[0], Identity: identity} + actual, checkErr := managedMountObjectIdentityForFDV1(received[0]) + translated := identity + for i, rows := range [][]identitymapping.Interval{active.UIDs, active.GIDs} { + id := &translated.UID + if i == 1 { + id = &translated.GID + } + translatedID, mapped := managedMountSetupHostIDV1(*id, rows) + if !mapped { + return result, fmt.Errorf("setup returned an unmapped identity") + } + *id = translatedID + } + if checkErr != nil || actual != translated { + return result, fmt.Errorf("setup handoff identity differs from held descriptor: %v", checkErr) + } + } + err = command.Wait() + retired = true + if err != nil { + return result, fmt.Errorf("setup helper did not complete: %w", err) + } + return result, ctx.Err() +} + +func runManagedMountSetupChildV1() error { + runtime.LockOSThread() + defer runtime.UnlockOSThread() + frozen := os.NewFile(3, "setup-manifest") + defer frozen.Close() + seals, err := unix.FcntlInt(frozen.Fd(), unix.F_GET_SEALS, 0) + if err != nil || seals&managedMountSetupSealsV1 != managedMountSetupSealsV1 { + return fmt.Errorf("setup requires a sealed manifest") + } + data, err := io.ReadAll(io.LimitReader(frozen, managedMountSetupLimitV1+1)) + if err != nil || len(data) > managedMountSetupLimitV1 { + return fmt.Errorf("invalid bounded setup manifest") + } + var manifest managedMountSetupManifestV1 + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err = decoder.Decode(&manifest); err != nil { + return err + } + geometry, err := identitymapping.ValidateGeometry(manifest.Geometry) + if err != nil || !reflect.DeepEqual(geometry, manifest.Geometry) || manifest.Parent != os.Getppid() { + return fmt.Errorf("invalid setup geometry or parent") + } + socket := os.NewFile(4, "setup-channel") + defer socket.Close() + channel, err := managedMountSetupSocketV1(socket) + if err != nil { + return err + } + defer channel.Close() + if err = channel.SetDeadline(time.Now().Add(30 * time.Second)); err != nil { + return err + } + if _, err = channel.Write([]byte{1}); err != nil { + return err + } + var signal [1]byte + if _, err = io.ReadFull(channel, signal[:]); err != nil || signal[0] != 2 { + return fmt.Errorf("setup release refused: %v", err) + } + for i, rows := range [][]identitymapping.Interval{geometry.UIDs, geometry.GIDs} { + data, readErr := os.ReadFile([]string{"/proc/self/uid_map", "/proc/self/gid_map"}[i]) + actual, parseErr := mappedKernelRowsV1(data) + if readErr != nil || parseErr != nil || !reflect.DeepEqual(actual, rows) { + return fmt.Errorf("setup kernel map differs from selected geometry") + } + } + if err = syscall.Setgroups(nil); err != nil { + return err + } + if err = syscall.Setresgid(0, 0, 0); err != nil { + return err + } + if err = syscall.Setresuid(0, 0, 0); err != nil { + return err + } + // Credential changes clear Pdeathsig. Re-arm and check the original parent + // before any storage effect, closing the parent-death race during transition. + if err = unix.Prctl(unix.PR_SET_PDEATHSIG, uintptr(unix.SIGKILL), 0, 0, 0); err != nil || manifest.Parent != os.Getppid() { + return fmt.Errorf("setup lost its original parent: %v", err) + } + if err = unix.Prctl(unix.PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0); err != nil { + return err + } + recorded := make(map[string]int, len(manifest.Recorded)) + for i, name := range manifest.Recorded { + recorded[name] = 7 + i + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + prepared, err := prepareManagedMountObjectsV1(ctx, 5, 6, manifest.Objects, recorded) + if err != nil { + return err + } + defer func() { + for _, object := range prepared { + unix.Close(object.FD) + } + }() + for _, declaration := range manifest.Objects { + object := prepared[declaration.Path] + data, err := json.Marshal(object.Identity) + if err != nil { + return err + } + if _, _, err = channel.WriteMsgUnix(data, unix.UnixRights(object.FD), nil); err != nil { + return err + } + } + return nil +} diff --git a/internal/dockerdeploy/managed_mount_setup_linux_test.go b/internal/dockerdeploy/managed_mount_setup_linux_test.go new file mode 100644 index 00000000..7c3c4afb --- /dev/null +++ b/internal/dockerdeploy/managed_mount_setup_linux_test.go @@ -0,0 +1,455 @@ +//go:build linux + +package dockerdeploy + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "reflect" + "strconv" + "strings" + "syscall" + "testing" + "time" + + "github.com/omry/reploy/internal/blueprint" + "github.com/omry/reploy/internal/canonical" + "github.com/omry/reploy/internal/deploy" + "github.com/omry/reploy/internal/identitymapping" + "golang.org/x/sys/unix" +) + +type managedMountSetupEvidenceV1 struct{} + +func (managedMountSetupEvidenceV1) Plan(context.Context, deploy.StateV1, identitymapping.Geometry) ([]string, error) { + return []string{"private-setup-fixture"}, nil +} +func (managedMountSetupEvidenceV1) Unused(context.Context, deploy.StateV1) error { return nil } +func (managedMountSetupEvidenceV1) Verify(context.Context, deploy.StateV1) error { return nil } + +func managedMountSetupGeometryV1() identitymapping.Geometry { + return identitymapping.Geometry{Profile: "exact", UIDs: []identitymapping.Interval{ + {ContainerStart: 0, HostStart: 100000, Count: 1}, + {ContainerStart: 200, HostStart: 100200, Count: 2}, + }, GIDs: []identitymapping.Interval{ + {ContainerStart: 0, HostStart: 100000, Count: 1}, + {ContainerStart: 300, HostStart: 100300, Count: 1}, + }} +} + +func managedMountSetupOwnerV1(t *testing.T) (deploy.IdentityMappingOperationsV1, deploy.StateV1) { + t.Helper() + dir, _ := stageProviderBuildRunState(t, false) + op, err := deploy.AcquireOperationLock(t.Context(), dir) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = op.Unlock() }) + state, found, err := op.ReadStateV1() + if err != nil || !found { + t.Fatal("missing staged fixture", err) + } + state.Staging = &deploy.StagingStateV1{Schema: deploy.StagingStateSchemaV1} + state.BlueprintSource = "private helper fixture; no runtime admission" + digest := canonical.Digest("sha256:" + strings.Repeat("a", 64)) + state.Current = &deploy.EnvironmentGenerationState{Reference: "reploy/env/demo:g-setup-fixture", ImageDigest: digest, + RootFSSubject: digest, BuildLockDigest: digest, Platform: state.Platform, RuntimePolicyDigest: digest} + if err := op.CommitStateV1(nil, state); err != nil { + t.Fatal(err) + } + ops := deploy.IdentityMappingOperationsV1{Operation: op, Evidence: managedMountSetupEvidenceV1{}} + intent, err := ops.Configure(t.Context(), state, managedMountSetupGeometryV1(), "private-setup") + if err != nil { + t.Fatal(err) + } + pending, err := ops.BeginEffects(t.Context(), intent, "private-setup") + if err != nil { + t.Fatal(err) + } + return ops, pending +} + +func managedMountSetupHeldV1(t *testing.T, name string) *os.File { + t.Helper() + fd, err := unix.Open(name, unix.O_PATH|unix.O_DIRECTORY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0) + if err != nil { + t.Fatal(err) + } + file := os.NewFile(uintptr(fd), name) + t.Cleanup(func() { file.Close() }) + return file +} + +func managedMountSetupDeclarationsV1() []blueprint.ManagedMountObject { + root, directory, file := uint32(0o750), uint32(0o700), uint32(0o600) + return []blueprint.ManagedMountObject{ + {Path: ".", Directory: true, Identity: blueprint.MountMetadataIdentity{UID: 0, GID: 300, Permissions: &root}}, + {Path: "private", Directory: true, Identity: blueprint.MountMetadataIdentity{UID: 200, GID: 300, Permissions: &directory}}, + {Path: "private/data", Identity: blueprint.MountMetadataIdentity{UID: 200, GID: 300, Permissions: &file}}, + } +} + +// The guest harness supplies fresh setup-owned directories under a private +// operator ancestor. This models F2's handoff preconditions, not its authority. +// Host checks, subordinate map installation, child credentials and F1.2 effects +// are all real. Opt-in prevents a development host's identity setup being changed. +func TestManagedMountSetupNativeV1(t *testing.T) { + dir := os.Getenv("REPLOY_TEST_MANAGED_SETUP_ROOT") + if dir == "" { + t.Skip("requires disposable files-only host with private100000-165535 delegation and fresh setup fixture") + } + if os.Geteuid() == 0 { + t.Fatal("setup caller must be the unprivileged operator") + } + ops, state := managedMountSetupOwnerV1(t) + root := managedMountSetupHeldV1(t, filepath.Join(dir, "root")) + staging := managedMountSetupHeldV1(t, filepath.Join(dir, "staging")) + before, err := os.ReadFile(filepath.Join(dir, "protected")) + if err != nil { + t.Fatal(err) + } + var protectedBefore unix.Stat_t + if err := unix.Stat(filepath.Join(dir, "protected"), &protectedBefore); err != nil { + t.Fatal(err) + } + rootBefore, err := managedMountObjectIdentityForFDV1(int(root.Fd())) + if err != nil { + t.Fatal(err) + } + objects := managedMountSetupDeclarationsV1() + prepared, err := prepareManagedMountSetupV1(t.Context(), ops, state, managedMountSetupGeometryV1(), "private-setup", root, staging, objects, map[string]*os.File{".": root}) + if err != nil { + t.Fatal(err) + } + defer func() { + for _, object := range prepared { + unix.Close(object.FD) + } + }() + if len(prepared) != len(objects) { + t.Fatal("incomplete descriptor handoff") + } + for _, declaration := range objects { + object := prepared[declaration.Path] + actual, err := managedMountObjectIdentityForFDV1(object.FD) + wantUID := uint32(100200) + if declaration.Path == "." { + wantUID = 100000 + } + if err != nil || actual.UID != wantUID || actual.GID != 100300 || actual.Mode&0o7777 != *declaration.Identity.Permissions || + object.Identity.UID != declaration.Identity.UID || object.Identity.GID != declaration.Identity.GID { + t.Fatalf("%s: actual=%+v response=%+v err=%v", declaration.Path, actual, object.Identity, err) + } + flags, err := unix.FcntlInt(uintptr(object.FD), unix.F_GETFD, 0) + if err != nil || flags&unix.FD_CLOEXEC == 0 { + t.Fatal("handoff leaked an inheritable descriptor", err) + } + } + if _, err := unix.Write(prepared["private/data"].FD, []byte("private-recipient-data\n")); err != nil { + t.Fatal(err) + } + retained := map[string]*os.File{} + for name, object := range prepared { + fd, err := unix.FcntlInt(uintptr(object.FD), unix.F_DUPFD_CLOEXEC, 0) + if err != nil { + t.Fatal(err) + } + retained[name] = os.NewFile(uintptr(fd), name) + defer retained[name].Close() + } + again, err := prepareManagedMountSetupV1(t.Context(), ops, state, managedMountSetupGeometryV1(), "private-setup", root, staging, objects, retained) + if err != nil { + t.Fatal("exact recorded objects did not verify", err) + } + for name, object := range again { + if object.Identity != prepared[name].Identity { + t.Error("recorded object identity changed") + } + unix.Close(object.FD) + } + for name, change := range map[string]func(*deploy.StateV1, *identitymapping.Geometry){ + "revision": func(s *deploy.StateV1, _ *identitymapping.Geometry) { + m := *s.IdentityMapping + m.Revision = "999" + s.IdentityMapping = &m + }, + "geometry": func(_ *deploy.StateV1, g *identitymapping.Geometry) { + g.UIDs = append([]identitymapping.Interval(nil), g.UIDs...) + g.UIDs[1].HostStart++ + }, + } { + t.Run(name, func(t *testing.T) { + observation, active := state, managedMountSetupGeometryV1() + change(&observation, &active) + if got, err := prepareManagedMountSetupV1(t.Context(), ops, observation, active, "private-setup", root, staging, objects, retained); err == nil || got != nil { + t.Fatal("stale authority admitted") + } + }) + } + unmapped := append(append([]blueprint.ManagedMountObject(nil), objects...), blueprint.ManagedMountObject{Path: "unmapped", Identity: blueprint.MountMetadataIdentity{UID: 65534, GID: 300}}) + if got, err := prepareManagedMountSetupV1(t.Context(), ops, state, managedMountSetupGeometryV1(), "private-setup", root, staging, unmapped, retained); err == nil || got != nil { + t.Fatal("unmapped declaration admitted") + } + canceled, cancel := context.WithCancel(t.Context()) + cancel() + if got, err := prepareManagedMountSetupV1(canceled, ops, state, managedMountSetupGeometryV1(), "private-setup", root, staging, objects, retained); err == nil || got != nil { + t.Fatal("canceled caller admitted") + } + // The helper must reject this symlink before any target mutation, even + // though setup capabilities would otherwise make a chown possible. + bad := append(append([]blueprint.ManagedMountObject(nil), objects...), blueprint.ManagedMountObject{Path: "foreign", Identity: blueprint.MountMetadataIdentity{UID: 200, GID: 300}}) + if got, err := prepareManagedMountSetupV1(t.Context(), ops, state, managedMountSetupGeometryV1(), "private-setup", root, staging, bad, retained); err == nil || got != nil { + t.Fatal("foreign symlink accepted") + } + rootAfter, err := managedMountObjectIdentityForFDV1(int(root.Fd())) + if err != nil || rootAfter != rootBefore { + t.Fatal("recorded root metadata changed", err) + } + after, err := os.ReadFile(filepath.Join(dir, "protected")) + if err != nil || !reflect.DeepEqual(before, after) { + t.Fatal("foreign target changed", err) + } + var protectedAfter unix.Stat_t + if err := unix.Stat(filepath.Join(dir, "protected"), &protectedAfter); err != nil || protectedBefore.Uid != protectedAfter.Uid || + protectedBefore.Gid != protectedAfter.Gid || protectedBefore.Mode != protectedAfter.Mode || protectedBefore.Ino != protectedAfter.Ino { + t.Fatal("foreign target metadata changed", err) + } + assertManagedMountSetupRetiredV1(t) + t.Log("real sparse maps, private subordinate metadata, exact reuse, stale/canceled refusal, foreign target preservation and helper retirement passed") +} + +func assertManagedMountSetupRetiredV1(t *testing.T) { + t.Helper() + entries, err := os.ReadDir("/proc") + if err != nil { + t.Fatal(err) + } + for _, entry := range entries { + if _, err := strconv.Atoi(entry.Name()); err != nil { + continue + } + status, err := os.ReadFile("/proc/" + entry.Name() + "/status") + if err != nil || !strings.Contains(string(status), "PPid:\t"+strconv.Itoa(os.Getpid())+"\n") { + continue + } + argv, _ := os.ReadFile("/proc/" + entry.Name() + "/cmdline") + if strings.Contains(string(argv), managedMountSetupArgumentV1) || strings.Contains(string(argv), "newuidmap") || strings.Contains(string(argv), "newgidmap") { + t.Fatalf("setup process survived return: pid%s", entry.Name()) + } + } +} + +// Root is used only by the disposable fixture driver to establish a recipient +// map. The recipient itself runs with a nonzero UID and no capabilities. It +// opens by path relative to the held resource, not through a privileged file FD. +func TestManagedMountSetupRecipientV1(t *testing.T) { + if role := os.Getenv("REPLOY_TEST_MANAGED_RECIPIENT"); role != "" { + if os.Geteuid() != map[string]int{"allow": 200, "deny": 201}[role] || os.Getegid() != 300 { + t.Fatal("wrong recipient credentials") + } + status, err := os.ReadFile("/proc/self/status") + if err != nil || !strings.Contains(string(status), "CapEff:\t0000000000000000\n") { + t.Fatal("recipient retained setup capability", err) + } + fd, err := unix.Openat(3, "private/data", unix.O_RDWR|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0) + if role == "deny" { + if err == nil { + unix.Close(fd) + t.Fatal("unrelated subordinate recipient accessed private data") + } + return + } + if err != nil { + t.Fatal("selected recipient could not access private data", err) + } + file := os.NewFile(uintptr(fd), "recipient-data") + defer file.Close() + data := make([]byte, 64) + n, err := file.Read(data) + if err != nil || string(data[:n]) != "private-recipient-data\n" { + t.Fatalf("recipient read %q: %v", data[:n], err) + } + return + } + dir := os.Getenv("REPLOY_TEST_MANAGED_RECIPIENT_ROOT") + if dir == "" { + t.Skip("requires prepared disposable guest fixture") + } + if os.Geteuid() != 0 { + t.Fatal("only the disposable fixture driver may establish recipient maps") + } + executable, err := os.Executable() + if err != nil { + t.Fatal(err) + } + root := managedMountSetupHeldV1(t, filepath.Join(dir, "root")) + for _, role := range []string{"allow", "deny"} { + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + command := exec.CommandContext(ctx, executable, "-test.run=^TestManagedMountSetupRecipientV1$", "-test.v") + command.ExtraFiles = []*os.File{root} + command.Env = []string{"REPLOY_TEST_MANAGED_RECIPIENT=" + role} + uid := uint32(200) + if role == "deny" { + uid++ + } + command.SysProcAttr = &syscall.SysProcAttr{Cloneflags: unix.CLONE_NEWUSER, + UidMappings: []syscall.SysProcIDMap{{ContainerID: 200, HostID: 100200, Size: 2}}, + GidMappings: []syscall.SysProcIDMap{{ContainerID: 300, HostID: 100300, Size: 1}}, + Credential: &syscall.Credential{Uid: uid, Gid: 300}} + data, err := command.CombinedOutput() + cancel() + if err != nil { + t.Fatalf("recipient %s: %v\n%s", role, err, data) + } + t.Log(fmt.Sprintf("recipient %s: %s", role, data)) + } +} + +func TestManagedMountSetupRefusesMissingAuthorityV1(t *testing.T) { + file := managedMountSetupHeldV1(t, t.TempDir()) + if _, err := prepareManagedMountSetupV1(t.Context(), deploy.IdentityMappingOperationsV1{}, deploy.StateV1{}, identitymapping.Geometry{}, "foreign", file, file, managedMountSetupDeclarationsV1(), nil); err == nil { + t.Fatal("missing owner/operation admitted") + } + if code, handled := RunManagedMountSetupChildV1([]string{managedMountSetupArgumentV1, "command"}, nil); handled || code != 0 { + t.Fatal("private child accepted argv inputs") + } +} + +func TestManagedMountSetupChildRejectsInvalidHandoffV1(t *testing.T) { + executable, err := os.Executable() + if err != nil { + t.Fatal(err) + } + valid, err := json.Marshal(managedMountSetupManifestV1{Geometry: managedMountSetupGeometryV1(), Objects: managedMountSetupDeclarationsV1(), Parent: os.Getpid()}) + if err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + name string + content []byte + sealed bool + release bool + }{ + {"unsealed", valid, false, false}, + {"malformed", []byte("{"), true, false}, + {"unknown-field", append(append([]byte(nil), valid[:len(valid)-1]...), []byte(`,"command":"application"}`)...), true, false}, + {"missing-geometry", []byte(`{}`), true, false}, + {"over-bound", []byte(strings.Repeat(" ", managedMountSetupLimitV1+1)), true, false}, + {"wrong-kernel-map", valid, true, true}, + } { + t.Run(tc.name, func(t *testing.T) { + rootDir, stagingDir := t.TempDir(), t.TempDir() + before, err := os.Stat(rootDir) + if err != nil { + t.Fatal(err) + } + root, staging := managedMountSetupHeldV1(t, rootDir), managedMountSetupHeldV1(t, stagingDir) + fd, err := unix.MemfdCreate("invalid-setup-fixture", unix.MFD_CLOEXEC|unix.MFD_ALLOW_SEALING) + if err != nil { + t.Fatal(err) + } + manifest := os.NewFile(uintptr(fd), "manifest") + defer manifest.Close() + if _, err := manifest.Write(tc.content); err != nil { + t.Fatal(err) + } + if _, err := manifest.Seek(0, io.SeekStart); err != nil { + t.Fatal(err) + } + if tc.sealed { + if _, err := unix.FcntlInt(manifest.Fd(), unix.F_ADD_SEALS, managedMountSetupSealsV1); err != nil { + t.Fatal(err) + } + } + pair, err := unix.Socketpair(unix.AF_UNIX, unix.SOCK_SEQPACKET|unix.SOCK_CLOEXEC, 0) + if err != nil { + t.Fatal(err) + } + parent, child := os.NewFile(uintptr(pair[0]), "parent"), os.NewFile(uintptr(pair[1]), "child") + defer parent.Close() + defer child.Close() + channel, err := managedMountSetupSocketV1(parent) + if err != nil { + t.Fatal(err) + } + defer channel.Close() + if err := channel.SetDeadline(time.Now().Add(5 * time.Second)); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + command := exec.CommandContext(ctx, executable, managedMountSetupArgumentV1) + command.ExtraFiles = []*os.File{manifest, child, root, staging} + if err := command.Start(); err != nil { + t.Fatal(err) + } + child.Close() + if tc.release { + var signal [1]byte + if _, err := io.ReadFull(channel, signal[:]); err != nil || signal[0] != 1 { + _ = command.Process.Kill() + _ = command.Wait() + t.Fatal("no readiness for kernel-map check", err) + } + if _, err := channel.Write([]byte{2}); err != nil { + _ = command.Process.Kill() + _ = command.Wait() + t.Fatal(err) + } + } + if err := command.Wait(); err == nil || ctx.Err() != nil { + t.Fatal("invalid handoff did not refuse promptly", err, ctx.Err()) + } + entries, err := os.ReadDir(rootDir) + after, statErr := os.Stat(rootDir) + if err != nil || statErr != nil || len(entries) != 0 || !os.SameFile(before, after) || before.Mode() != after.Mode() { + t.Fatal("invalid handoff changed held storage", err, statErr) + } + }) + } +} + +// The isolated guest driver pauses the real mapper after it installs its map. +// That barrier exercises cancellation and a fresh authority change before the +// production parent can release effects. No production hook controls this path. +func TestManagedMountSetupBarrierV1(t *testing.T) { + mode := os.Getenv("REPLOY_TEST_MANAGED_SETUP_BARRIER") + if mode == "" { + t.Skip("requires disposable guest mapper barrier") + } + dir := os.Getenv("REPLOY_TEST_MANAGED_SETUP_ROOT") + ops, state := managedMountSetupOwnerV1(t) + root := managedMountSetupHeldV1(t, filepath.Join(dir, "root")) + staging := managedMountSetupHeldV1(t, filepath.Join(dir, "staging")) + ctx := t.Context() + if strings.HasPrefix(mode, "cancel") { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, 5*time.Second) + defer cancel() + } + start := time.Now() + objects := managedMountSetupDeclarationsV1() + if strings.HasSuffix(mode, "effects") { + objects = objects[:1] + for i := 0; i < 4000; i++ { + objects = append(objects, blueprint.ManagedMountObject{Path: fmt.Sprintf("item-%05d", i), Identity: blueprint.MountMetadataIdentity{UID: 200, GID: 300}}) + } + } + got, err := prepareManagedMountSetupV1(ctx, ops, state, managedMountSetupGeometryV1(), "private-setup", root, staging, objects, map[string]*os.File{".": root}) + if strings.HasPrefix(mode, "parent-death") { + t.Fatalf("parent-death fixture unexpectedly returned: %v", err) + } + if err == nil || got != nil { + t.Fatal("barrier failed to inhibit preparation") + } + if strings.HasPrefix(mode, "cancel") && time.Since(start) > 10*time.Second { + t.Fatal("in-flight cancellation exceeded its bound") + } + assertManagedMountSetupRetiredV1(t) + t.Log("barrier", mode, "refused and retired:", err) +} diff --git a/internal/dockerdeploy/managed_mount_setup_unsupported.go b/internal/dockerdeploy/managed_mount_setup_unsupported.go new file mode 100644 index 00000000..dafb2fa6 --- /dev/null +++ b/internal/dockerdeploy/managed_mount_setup_unsupported.go @@ -0,0 +1,9 @@ +//go:build !linux + +package dockerdeploy + +import "fmt" + +func runManagedMountSetupChildV1() error { + return fmt.Errorf("managed mount setup requires Linux") +} diff --git a/internal/dockerdeploy/platform_test.go b/internal/dockerdeploy/platform_test.go index 00134253..8ee4a289 100644 --- a/internal/dockerdeploy/platform_test.go +++ b/internal/dockerdeploy/platform_test.go @@ -13,6 +13,9 @@ import ( ) func TestMain(m *testing.M) { + if code, handled := RunManagedMountSetupChildV1(os.Args[1:], os.Stderr); handled { + os.Exit(code) + } if len(os.Args) > 1 && os.Args[1] == "_control" { os.Exit(runFakeEmbeddedReployControl(os.Args[2:])) }