diff --git a/internal/dockerdeploy/managed_mount_objects_linux.go b/internal/dockerdeploy/managed_mount_objects_linux.go new file mode 100644 index 00000000..51a74980 --- /dev/null +++ b/internal/dockerdeploy/managed_mount_objects_linux.go @@ -0,0 +1,320 @@ +//go:build linux + +package dockerdeploy + +import ( + "context" + "crypto/rand" + "errors" + "fmt" + "os" + "path" + "sort" + "strings" + + "github.com/omry/reploy/internal/blueprint" + "golang.org/x/sys/unix" +) + +type managedMountObjectIdentityV1 struct { + Device, Inode, Mount uint64 + UID, GID, Mode uint32 +} + +type preparedManagedMountObjectV1 struct { + FD int + Identity managedMountObjectIdentityV1 +} + +// prepareManagedMountObjectsV1 is an inert Linux primitive, not admission. +// The caller must hold exclusive, unattached managed-storage authority and run +// inside the selected setup mapping. root, staging and recorded descriptors are borrowed; +// recorded descriptors must come from that resource's retained provenance. +// staging must be a distinct, private, setup-owned directory on the same mount, +// inaccessible to workload writers for the entire call. F1.3/F2 supply and fence +// that authority; matching UID alone does not establish its provenance. +// Success transfers ownership of every returned descriptor to the caller. +// Failure closes owned descriptors, leaves any newly created inert objects in +// place (including private staging entries) and requires the caller to retain +// inhibition for explicit recovery. Staging's default ACL is temporary setup state. +// Existing objects are never chowned, chmodded, truncated or adopted by path. +func prepareManagedMountObjectsV1(ctx context.Context, root, staging int, objects []blueprint.ManagedMountObject, recorded map[string]int) (result map[string]preparedManagedMountObjectV1, err error) { + ordered, err := orderManagedMountObjectsV1(objects) + if err != nil { + return nil, err + } + rootIdentity, err := managedMountObjectIdentityForFDV1(root) + if err != nil || rootIdentity.Mode&unix.S_IFMT != unix.S_IFDIR { + return nil, fmt.Errorf("managed root must be a held directory: %v", err) + } + stagingIdentity, err := managedMountObjectIdentityForFDV1(staging) + if err != nil || stagingIdentity.Mode&unix.S_IFMT != unix.S_IFDIR || stagingIdentity.Mode&0o7777 != 0o700 || + stagingIdentity.UID != uint32(os.Geteuid()) || stagingIdentity.Mount != rootIdentity.Mount || + stagingIdentity.Device == rootIdentity.Device && stagingIdentity.Inode == rootIdentity.Inode { + return nil, fmt.Errorf("managed staging must be a distinct private setup-owned directory on the managed mount: %v", err) + } + prepared := make(map[string]preparedManagedMountObjectV1, len(ordered)) + defer func() { + if err != nil { + for _, object := range prepared { + unix.Close(object.FD) + } + } + }() + for _, declaration := range ordered { + if err = ctx.Err(); err != nil { + return nil, err + } + object, prepareErr := prepareManagedMountObjectV1(root, staging, rootIdentity.Mount, declaration, recorded, prepared) + if prepareErr != nil { + return nil, fmt.Errorf("prepare managed object %q: %w", declaration.Path, prepareErr) + } + prepared[declaration.Path] = object + } + if err = ctx.Err(); err != nil { + return nil, err + } + return prepared, nil +} + +func orderManagedMountObjectsV1(objects []blueprint.ManagedMountObject) ([]blueprint.ManagedMountObject, error) { + if len(objects) == 0 { + return nil, fmt.Errorf("managed preparation requires an explicit root declaration") + } + ordered := append([]blueprint.ManagedMountObject(nil), objects...) + seen := make(map[string]bool, len(objects)) + for _, object := range ordered { + name := object.Path + if name == "" || path.IsAbs(name) || path.Clean(name) != name || name == ".." || strings.HasPrefix(name, "../") || strings.ContainsAny(name, "\\\x00\r\n\t") { + return nil, fmt.Errorf("managed object requires a normalized relative Linux path") + } + if _, duplicate := seen[name]; duplicate { + return nil, fmt.Errorf("duplicate managed object %q", name) + } + if name == "." && !object.Directory || object.Identity.Permissions != nil && *object.Identity.Permissions > 0o7777 { + return nil, fmt.Errorf("invalid managed object declaration %q", name) + } + seen[name] = object.Directory + } + if directory, exists := seen["."]; !exists || !directory { + return nil, fmt.Errorf("managed preparation requires a directory root") + } + for _, object := range ordered { + for parent := path.Dir(object.Path); parent != "."; parent = path.Dir(parent) { + if directory, declared := seen[parent]; declared && !directory { + return nil, fmt.Errorf("managed object %q has a file ancestor", object.Path) + } + } + } + sort.Slice(ordered, func(i, j int) bool { + a, b := ordered[i], ordered[j] + if a.Path == "." || b.Path == "." { + return a.Path == "." + } + if a.Directory != b.Directory { + return a.Directory + } + if depthA, depthB := strings.Count(a.Path, "/"), strings.Count(b.Path, "/"); depthA != depthB { + return depthA < depthB + } + return a.Path < b.Path + }) + return ordered, nil +} + +func openManagedMountObjectV1(root int, name string, flags int) (int, error) { + return unix.Openat2(root, name, &unix.OpenHow{ + Flags: uint64(flags | unix.O_CLOEXEC | unix.O_NOFOLLOW), + Resolve: unix.RESOLVE_BENEATH | unix.RESOLVE_NO_SYMLINKS | + unix.RESOLVE_NO_MAGICLINKS | unix.RESOLVE_NO_XDEV, + }) +} + +func managedMountObjectIdentityForFDV1(fd int) (managedMountObjectIdentityV1, error) { + var stat unix.Stat_t + if err := unix.Fstat(fd, &stat); err != nil { + return managedMountObjectIdentityV1{}, err + } + if kind := stat.Mode & unix.S_IFMT; kind != unix.S_IFDIR && (kind != unix.S_IFREG || stat.Nlink != 1) { + return managedMountObjectIdentityV1{}, fmt.Errorf("managed object must be a directory or unaliased regular file") + } + var mount unix.Statx_t + if err := unix.Statx(fd, "", unix.AT_EMPTY_PATH, unix.STATX_MNT_ID, &mount); err != nil { + return managedMountObjectIdentityV1{}, err + } + if mount.Mask&unix.STATX_MNT_ID == 0 { + return managedMountObjectIdentityV1{}, fmt.Errorf("kernel did not report the held object's mount identity") + } + return managedMountObjectIdentityV1{Device: uint64(stat.Dev), Inode: stat.Ino, Mount: mount.Mnt_id, UID: stat.Uid, GID: stat.Gid, Mode: stat.Mode}, nil +} + +func verifyManagedMountObjectV1(fd int, mount uint64, declaration blueprint.ManagedMountObject, expected int) (managedMountObjectIdentityV1, error) { + actual, err := managedMountObjectIdentityForFDV1(fd) + if err != nil { + return actual, err + } + retained, err := managedMountObjectIdentityForFDV1(expected) + if err != nil { + return actual, fmt.Errorf("recorded descriptor: %w", err) + } + kind := uint32(unix.S_IFREG) + if declaration.Directory { + kind = unix.S_IFDIR + } + identity := declaration.Identity + if actual != retained || actual.Mount != mount || actual.Mode&unix.S_IFMT != kind || actual.UID != identity.UID || actual.GID != identity.GID || + identity.Permissions != nil && actual.Mode&0o7777 != *identity.Permissions { + return actual, fmt.Errorf("managed object identity, type or declared metadata differs from recorded authority") + } + return actual, nil +} + +func prepareManagedMountObjectV1(root, staging int, mount uint64, declaration blueprint.ManagedMountObject, recorded map[string]int, prepared map[string]preparedManagedMountObjectV1) (preparedManagedMountObjectV1, error) { + name := declaration.Path + fd, err := openManagedMountObjectV1(root, name, unix.O_PATH) + if err == nil { + expected, exists := recorded[name] + if !exists { + unix.Close(fd) + return preparedManagedMountObjectV1{}, fmt.Errorf("existing object has no retained descriptor provenance") + } + identity, err := verifyManagedMountObjectV1(fd, mount, declaration, expected) + if err != nil { + unix.Close(fd) + return preparedManagedMountObjectV1{}, err + } + return preparedManagedMountObjectV1{FD: fd, Identity: identity}, nil + } + if !errors.Is(err, unix.ENOENT) || name == "." { + return preparedManagedMountObjectV1{}, err + } + if _, exists := recorded[name]; exists { + return preparedManagedMountObjectV1{}, fmt.Errorf("recorded managed object is missing") + } + parentName := path.Dir(name) + parent, err := openManagedMountObjectV1(root, parentName, unix.O_PATH|unix.O_DIRECTORY) + if err != nil { + return preparedManagedMountObjectV1{}, err + } + defer unix.Close(parent) + expected, exists := recorded[parentName] + if object, ready := prepared[parentName]; ready { + expected, exists = object.FD, true + } + if !exists { + return preparedManagedMountObjectV1{}, fmt.Errorf("parent has no retained descriptor provenance") + } + parentIdentity, err := managedMountObjectIdentityForFDV1(parent) + if err != nil { + return preparedManagedMountObjectV1{}, err + } + retained, err := managedMountObjectIdentityForFDV1(expected) + if err != nil || parentIdentity != retained || parentIdentity.Mount != mount { + return preparedManagedMountObjectV1{}, fmt.Errorf("managed parent was substituted") + } + return createManagedMountObjectV1(parent, staging, path.Base(name), mount, declaration) +} + +// Reuse kernel ACL inheritance rather than interpreting or rebuilding ACLs. +// Only the private staging directory's default ACL changes, never the parent. +func inheritManagedMountDirectoryACLV1(parent, staging int) error { + fd, err := openManagedMountObjectV1(parent, ".", unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NONBLOCK) + if err != nil { + return err + } + defer unix.Close(fd) + acl := make([]byte, 65536) // Linux's maximum extended-attribute value size. + n, err := unix.Fgetxattr(fd, "system.posix_acl_default", acl) + if errors.Is(err, unix.ENODATA) || errors.Is(err, unix.EOPNOTSUPP) { + err = unix.Fremovexattr(staging, "system.posix_acl_default") + if errors.Is(err, unix.ENODATA) || errors.Is(err, unix.EOPNOTSUPP) { + return nil + } + return err + } + if err != nil { + return err + } + return unix.Fsetxattr(staging, "system.posix_acl_default", acl[:n], 0) +} + +func createManagedMountObjectV1(parent, staging int, leaf string, mount uint64, declaration blueprint.ManagedMountObject) (result preparedManagedMountObjectV1, err error) { + mode := uint32(0o644) + if declaration.Directory { + mode = 0o755 + } + // Files return their creation FD atomically. Directories must be pinned and + // receive metadata privately before their destination name becomes visible. + var fd int + var inheritedMode uint32 + var stagingName string + var stagingFD int + if declaration.Directory { + parentIdentity, statErr := managedMountObjectIdentityForFDV1(parent) + if statErr != nil { + return result, statErr + } + inheritedMode = parentIdentity.Mode & unix.S_ISGID + stagingFD, err = openManagedMountObjectV1(staging, ".", unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NONBLOCK) + if err != nil { + return result, err + } + defer unix.Close(stagingFD) + if err = inheritManagedMountDirectoryACLV1(parent, stagingFD); err != nil { + return result, err + } + stagingName = "reploy-dir-" + rand.Text() + if err = unix.Mkdirat(stagingFD, stagingName, mode); err != nil { + return result, err + } + fd, err = openManagedMountObjectV1(stagingFD, stagingName, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NONBLOCK) + } else { + fd, err = unix.Openat(parent, leaf, unix.O_RDWR|unix.O_CREAT|unix.O_EXCL|unix.O_NOFOLLOW|unix.O_CLOEXEC|unix.O_NONBLOCK, mode) + } + if err != nil { + return result, err + } + defer func() { + if err != nil { + unix.Close(fd) + } + }() + identity, err := managedMountObjectIdentityForFDV1(fd) + if err != nil { + return result, err + } + if identity.Mount != mount || identity.UID != uint32(os.Geteuid()) { + return result, fmt.Errorf("new object differs from exclusive setup creation") + } + // Preserve the creation mode after umask when no mode was requested. + mode = identity.Mode&0o7777 | inheritedMode + if declaration.Identity.Permissions != nil { + mode = *declaration.Identity.Permissions + } + if err = unix.Fchown(fd, int(declaration.Identity.UID), int(declaration.Identity.GID)); err != nil { + return result, err + } + if err = unix.Fchmod(fd, mode); err != nil { + return result, err + } + if declaration.Directory { + if err = unix.Renameat2(stagingFD, stagingName, parent, leaf, unix.RENAME_NOREPLACE); err != nil { + return result, err + } + } + identity, err = managedMountObjectIdentityForFDV1(fd) + if err != nil { + return result, err + } + observed, err := openManagedMountObjectV1(parent, leaf, unix.O_PATH) + if err != nil { + return result, err + } + defer unix.Close(observed) + declared := declaration + declared.Identity.Permissions = &mode + if _, err = verifyManagedMountObjectV1(observed, mount, declared, fd); err != nil { + return result, err + } + return preparedManagedMountObjectV1{FD: fd, Identity: identity}, nil +} diff --git a/internal/dockerdeploy/managed_mount_objects_linux_test.go b/internal/dockerdeploy/managed_mount_objects_linux_test.go new file mode 100644 index 00000000..7219f5c1 --- /dev/null +++ b/internal/dockerdeploy/managed_mount_objects_linux_test.go @@ -0,0 +1,489 @@ +//go:build linux + +package dockerdeploy + +import ( + "context" + "encoding/binary" + "errors" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/omry/reploy/internal/blueprint" + "golang.org/x/sys/unix" +) + +type managedObjectCancelAtCompletion struct { + context.Context + checks int +} + +func (ctx *managedObjectCancelAtCompletion) Err() error { + ctx.checks++ + if ctx.checks >= 3 { + return context.Canceled + } + return nil +} + +func managedObjectTestFD(t *testing.T, name string) int { + t.Helper() + fd, err := unix.Open(name, unix.O_PATH|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { unix.Close(fd) }) + return fd +} + +func managedObjectTestStagingFD(t *testing.T) int { + t.Helper() + name := t.TempDir() + if err := os.Chmod(name, 0o700); err != nil { + t.Fatal(err) + } + return managedObjectTestFD(t, name) +} + +func managedObjectTestDeclaration(name string, directory bool, mode *uint32) blueprint.ManagedMountObject { + return blueprint.ManagedMountObject{Path: name, Directory: directory, Identity: blueprint.MountMetadataIdentity{ + UID: uint32(os.Geteuid()), GID: uint32(os.Getegid()), Permissions: mode, + }} +} + +func managedObjectTestMode(mode uint32) *uint32 { return &mode } + +func closePreparedManagedObjects(t *testing.T, objects map[string]preparedManagedMountObjectV1) { + t.Helper() + for name, object := range objects { + if err := unix.Close(object.FD); err != nil { + t.Errorf("close %s: %v", name, err) + } + } +} + +func TestManagedMountObjectsCreateAndVerify(t *testing.T) { + stagingFD := managedObjectTestStagingFD(t) + root := t.TempDir() + fd := managedObjectTestFD(t, root) + objects := []blueprint.ManagedMountObject{ + managedObjectTestDeclaration("queue/active/log", false, managedObjectTestMode(0o640)), + managedObjectTestDeclaration("queue/active", true, managedObjectTestMode(0o750)), + managedObjectTestDeclaration(".", true, nil), + managedObjectTestDeclaration("queue", true, managedObjectTestMode(0o2750)), + managedObjectTestDeclaration("zero", false, managedObjectTestMode(0)), + } + result, err := prepareManagedMountObjectsV1(context.Background(), fd, stagingFD, objects, map[string]int{".": fd}) + if err != nil { + t.Fatal(err) + } + defer closePreparedManagedObjects(t, result) + if len(result) != len(objects) { + t.Fatalf("prepared %d objects, want %d", len(result), len(objects)) + } + for _, object := range objects { + got := result[object.Path] + if got.Identity.UID != object.Identity.UID || got.Identity.GID != object.Identity.GID { + t.Errorf("%s: wrong ownership: %+v", object.Path, got.Identity) + } + if object.Identity.Permissions != nil && got.Identity.Mode&0o7777 != *object.Identity.Permissions { + t.Errorf("%s: mode %#o", object.Path, got.Identity.Mode) + } + flags, err := unix.FcntlInt(uintptr(got.FD), unix.F_GETFD, 0) + if err != nil || flags&unix.FD_CLOEXEC == 0 { + t.Errorf("%s: descriptor not close-on-exec: %d %v", object.Path, flags, err) + } + } + log := filepath.Join(root, "queue/active/log") + if err := os.WriteFile(log, []byte("application data survives verification"), 0o600); err != nil { + t.Fatal(err) + } + recorded := make(map[string]int) + for name, object := range result { + recorded[name] = object.FD + } + verified, err := prepareManagedMountObjectsV1(context.Background(), fd, stagingFD, objects, recorded) + if err != nil { + t.Fatal(err) + } + closePreparedManagedObjects(t, verified) + data, err := os.ReadFile(log) + if err != nil || string(data) != "application data survives verification" { + t.Fatalf("verification changed file contents: %q %v", data, err) + } +} + +func TestManagedMountObjectsRefuseMalformedManifestBeforeEffects(t *testing.T) { + stagingFD := managedObjectTestStagingFD(t) + for _, name := range []string{"", "/escape", "..", "../escape", "a/../b", "a//b", "a/", "a\\b", "a\x00b", "a\nb", "a\tb"} { + t.Run(strings.ReplaceAll(name, "/", "_"), func(t *testing.T) { + root := t.TempDir() + fd := managedObjectTestFD(t, root) + objects := []blueprint.ManagedMountObject{managedObjectTestDeclaration(".", true, nil), managedObjectTestDeclaration("first", true, nil), managedObjectTestDeclaration(name, false, nil)} + if result, err := prepareManagedMountObjectsV1(context.Background(), fd, stagingFD, objects, map[string]int{".": fd}); err == nil || result != nil { + t.Fatalf("accepted malformed manifest: %v", result) + } + entries, err := os.ReadDir(root) + if err != nil || len(entries) != 0 { + t.Fatalf("invalid manifest had effects: %v %v", entries, err) + } + }) + } + root := managedObjectTestDeclaration(".", true, nil) + for _, objects := range [][]blueprint.ManagedMountObject{ + nil, + {managedObjectTestDeclaration("file", false, nil)}, + {managedObjectTestDeclaration(".", false, nil)}, + {root, root}, + {root, managedObjectTestDeclaration("a", false, nil), managedObjectTestDeclaration("a/b", false, nil)}, + {root, managedObjectTestDeclaration("mode", false, managedObjectTestMode(0o10000))}, + } { + if _, err := orderManagedMountObjectsV1(objects); err == nil { + t.Fatalf("accepted invalid declarations: %+v", objects) + } + } +} + +func TestManagedMountObjectsExistingObjectsRequireExactAuthority(t *testing.T) { + stagingFD := managedObjectTestStagingFD(t) + for _, variant := range []string{"no root provenance", "no file provenance", "wrong root", "wrong file", "missing recorded file", "wrong owner", "wrong group", "wrong mode", "wrong type", "hardlink", "symlink", "fifo", "socket"} { + t.Run(variant, func(t *testing.T) { + root, foreign := t.TempDir(), t.TempDir() + file, canary := filepath.Join(root, "file"), filepath.Join(foreign, "canary") + for _, p := range []string{file, canary} { + if err := os.WriteFile(p, []byte("retained data"), 0o600); err != nil { + t.Fatal(err) + } + } + fd := managedObjectTestFD(t, root) + fileFD := managedObjectTestFD(t, file) + recorded := map[string]int{".": fd, "file": fileFD} + objects := []blueprint.ManagedMountObject{managedObjectTestDeclaration(".", true, nil), managedObjectTestDeclaration("file", false, managedObjectTestMode(0o600))} + switch variant { + case "no root provenance": + delete(recorded, ".") + case "no file provenance": + delete(recorded, "file") + case "wrong root": + recorded["."] = managedObjectTestFD(t, foreign) + case "wrong file": + recorded["file"] = managedObjectTestFD(t, canary) + case "missing recorded file": + if err := os.Remove(file); err != nil { + t.Fatal(err) + } + case "wrong owner": + objects[1].Identity.UID++ + case "wrong group": + objects[1].Identity.GID++ + case "wrong mode": + objects[1].Identity.Permissions = managedObjectTestMode(0o644) + case "wrong type": + objects[1].Directory = true + case "hardlink": + if err := os.Link(file, filepath.Join(foreign, "alias")); err != nil { + t.Fatal(err) + } + case "symlink", "fifo", "socket": + if err := os.Remove(file); err != nil { + t.Fatal(err) + } + var err error + switch variant { + case "symlink": + err = os.Symlink(canary, file) + case "fifo": + err = unix.Mkfifo(file, 0o600) + case "socket": + s, socketErr := unix.Socket(unix.AF_UNIX, unix.SOCK_STREAM|unix.SOCK_CLOEXEC, 0) + if socketErr != nil { + t.Fatal(socketErr) + } + defer unix.Close(s) + err = unix.Bind(s, &unix.SockaddrUnix{Name: file}) + } + if err != nil { + t.Fatal(err) + } + } + before, err := os.Stat(canary) + if err != nil { + t.Fatal(err) + } + result, err := prepareManagedMountObjectsV1(context.Background(), fd, stagingFD, objects, recorded) + if err == nil || result != nil { + t.Fatalf("accepted %s: %+v", variant, result) + } + after, err := os.Stat(canary) + data, readErr := os.ReadFile(canary) + if err != nil || readErr != nil || string(data) != "retained data" || before.Mode() != after.Mode() || !before.ModTime().Equal(after.ModTime()) { + t.Fatalf("foreign canary changed: %q %v %v", data, err, readErr) + } + }) + } +} + +func TestManagedMountObjectsDoNotCreateOrAdoptImplicitParents(t *testing.T) { + stagingFD := managedObjectTestStagingFD(t) + root := t.TempDir() + fd := managedObjectTestFD(t, root) + objects := []blueprint.ManagedMountObject{managedObjectTestDeclaration(".", true, nil), managedObjectTestDeclaration("parent/child", false, nil)} + if _, err := prepareManagedMountObjectsV1(context.Background(), fd, stagingFD, objects, map[string]int{".": fd}); err == nil { + t.Fatal("created implicit parent") + } + if err := os.Mkdir(filepath.Join(root, "parent"), 0o700); err != nil { + t.Fatal(err) + } + if _, err := prepareManagedMountObjectsV1(context.Background(), fd, stagingFD, objects, map[string]int{".": fd}); err == nil { + t.Fatal("adopted unrecorded parent") + } + parent := managedObjectTestFD(t, filepath.Join(root, "parent")) + result, err := prepareManagedMountObjectsV1(context.Background(), fd, stagingFD, objects, map[string]int{".": fd, "parent": parent}) + if err != nil { + t.Fatal(err) + } + closePreparedManagedObjects(t, result) +} + +func TestManagedMountObjectsRefuseNestedMountWithoutHostMutation(t *testing.T) { + root := managedObjectTestFD(t, "/") + proc := managedObjectTestFD(t, "/proc") + rootStat, err := managedMountObjectIdentityForFDV1(root) + if err != nil { + t.Fatal(err) + } + procStat, err := managedMountObjectIdentityForFDV1(proc) + if err != nil { + t.Fatal(err) + } + if procStat.Mount == rootStat.Mount { + t.Fatal("test requires Linux procfs to be a distinct mount") + } + // Isolate the confined lookup from preparation's private staging prerequisite. + // The filesystem containing the test temporary directory is irrelevant here. + fd, err := openManagedMountObjectV1(root, "proc", unix.O_PATH|unix.O_DIRECTORY) + if fd >= 0 { + unix.Close(fd) + } + if !errors.Is(err, unix.EXDEV) { + t.Fatalf("nested mount lookup did not refuse crossing: %v", err) + } +} + +func TestManagedMountObjectsCancelAndCloseOwnedDescriptors(t *testing.T) { + stagingFD := managedObjectTestStagingFD(t) + root := t.TempDir() + fd := managedObjectTestFD(t, root) + objects := []blueprint.ManagedMountObject{managedObjectTestDeclaration(".", true, nil), managedObjectTestDeclaration("a", true, nil), managedObjectTestDeclaration("missing-parent/failure", false, nil)} + count := func() int { + fds, err := os.ReadDir("/proc/self/fd") + if err != nil { + t.Fatal(err) + } + return len(fds) + } + before := count() + for i := 0; i < 20; i++ { + // First call creates a; later calls refuse its missing provenance. + if result, err := prepareManagedMountObjectsV1(context.Background(), fd, stagingFD, objects, map[string]int{".": fd}); err == nil || result != nil { + t.Fatalf("failure returned successful descriptors: %v %v", result, err) + } + } + if after := count(); after != before { + t.Fatalf("owned descriptors leaked: before %d after %d", before, after) + } + if _, err := unix.FcntlInt(uintptr(fd), unix.F_GETFD, 0); err != nil { + t.Fatalf("borrowed root was closed: %v", err) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if result, err := prepareManagedMountObjectsV1(ctx, fd, stagingFD, objects, map[string]int{".": fd}); !errors.Is(err, context.Canceled) || result != nil { + t.Fatalf("ignored cancellation: %v %v", result, err) + } + entries, err := os.ReadDir(filepath.Join(root, "a")) + if err != nil || len(entries) != 0 { + t.Fatalf("failed preparation changed inert directory contents: %v %v", entries, err) + } +} + +func TestManagedMountObjectsOrderIsStableAndDoesNotMutateInput(t *testing.T) { + objects := []blueprint.ManagedMountObject{ + managedObjectTestDeclaration("z/file", false, nil), managedObjectTestDeclaration("z/b", true, nil), + managedObjectTestDeclaration("z", true, nil), managedObjectTestDeclaration(".", true, nil), + managedObjectTestDeclaration("!first", true, nil), managedObjectTestDeclaration("a", true, nil), managedObjectTestDeclaration("a/file", false, nil), + } + before := append([]blueprint.ManagedMountObject(nil), objects...) + ordered, err := orderManagedMountObjectsV1(objects) + if err != nil { + t.Fatal(err) + } + names := make([]string, len(ordered)) + for i, object := range ordered { + names[i] = object.Path + } + if !reflect.DeepEqual(names, []string{".", "!first", "a", "z", "z/b", "a/file", "z/file"}) || !reflect.DeepEqual(objects, before) { + t.Fatalf("bad order %v or mutated input", names) + } +} + +func TestManagedMountObjectsInvalidDescriptorsNeverAcquireAuthority(t *testing.T) { + stagingFD := managedObjectTestStagingFD(t) + root, foreign := t.TempDir(), t.TempDir() + file := filepath.Join(root, "file") + if err := os.WriteFile(file, []byte("unchanged"), 0o600); err != nil { + t.Fatal(err) + } + rootFD, fileFD := managedObjectTestFD(t, root), managedObjectTestFD(t, file) + objects := []blueprint.ManagedMountObject{managedObjectTestDeclaration(".", true, nil)} + for _, badRoot := range []int{-1, fileFD} { + if result, err := prepareManagedMountObjectsV1(context.Background(), badRoot, stagingFD, objects, map[string]int{".": rootFD}); err == nil || result != nil { + t.Fatalf("accepted invalid root %d: %v %v", badRoot, result, err) + } + } + if result, err := prepareManagedMountObjectsV1(context.Background(), rootFD, stagingFD, objects, map[string]int{".": -1}); err == nil || result != nil { + t.Fatalf("accepted invalid retained root: %v %v", result, err) + } + if err := os.Mkdir(filepath.Join(root, "parent"), 0o700); err != nil { + t.Fatal(err) + } + objects = append(objects, managedObjectTestDeclaration("parent/new", false, nil)) + for _, badParent := range []int{-1, managedObjectTestFD(t, foreign)} { + if result, err := prepareManagedMountObjectsV1(context.Background(), rootFD, stagingFD, objects, map[string]int{".": rootFD, "parent": badParent}); err == nil || result != nil { + t.Fatalf("accepted invalid retained parent: %v %v", result, err) + } + } + entries, err := os.ReadDir(filepath.Join(root, "parent")) + if err != nil || len(entries) != 0 { + t.Fatalf("invalid parent authority had effects: %v %v", entries, err) + } +} + +func TestManagedMountObjectsCancellationBeforeReceiptClosesPartialSuccess(t *testing.T) { + stagingFD := managedObjectTestStagingFD(t) + root := t.TempDir() + fd := managedObjectTestFD(t, root) + ctx := &managedObjectCancelAtCompletion{Context: context.Background()} + objects := []blueprint.ManagedMountObject{managedObjectTestDeclaration(".", true, nil), managedObjectTestDeclaration("new", false, nil)} + result, err := prepareManagedMountObjectsV1(ctx, fd, stagingFD, objects, map[string]int{".": fd}) + if !errors.Is(err, context.Canceled) || result != nil { + t.Fatalf("completion cancellation returned success: %v %v", result, err) + } + info, err := os.Stat(filepath.Join(root, "new")) + if err != nil || !info.Mode().IsRegular() || info.Size() != 0 { + t.Fatalf("partial inert object was removed or changed: %v %v", info, err) + } + if _, err := unix.FcntlInt(uintptr(fd), unix.F_GETFD, 0); err != nil { + t.Fatalf("borrowed root closed: %v", err) + } +} + +func TestManagedMountObjectsExclusiveCreationCannotAdoptExistingLeaf(t *testing.T) { + stagingFD := managedObjectTestStagingFD(t) + root := t.TempDir() + fd := managedObjectTestFD(t, root) + for _, directory := range []bool{false, true} { + name := "file" + if directory { + name = "dir" + if err := os.Mkdir(filepath.Join(root, name), 0o700); err != nil { + t.Fatal(err) + } + } else if err := os.WriteFile(filepath.Join(root, name), []byte("retained"), 0o600); err != nil { + t.Fatal(err) + } + identity, err := managedMountObjectIdentityForFDV1(fd) + if err != nil { + t.Fatal(err) + } + for _, parent := range []int{fd, -1} { + _, err := createManagedMountObjectV1(parent, stagingFD, name, identity.Mount, managedObjectTestDeclaration(name, directory, managedObjectTestMode(0o777))) + if parent == fd && !errors.Is(err, unix.EEXIST) || parent == -1 && !errors.Is(err, unix.EBADF) { + t.Fatalf("exclusive creation %s parent %d: %v", name, parent, err) + } + } + } + data, err := os.ReadFile(filepath.Join(root, "file")) + if err != nil || string(data) != "retained" { + t.Fatalf("exclusive creation changed existing file: %q %v", data, err) + } +} + +func TestManagedMountObjectsStagingAuthorityRequiredBeforeEffects(t *testing.T) { + root, permissive := t.TempDir(), t.TempDir() + if err := os.Chmod(permissive, 0o755); err != nil { + t.Fatal(err) + } + rootFD := managedObjectTestFD(t, root) + objects := []blueprint.ManagedMountObject{managedObjectTestDeclaration(".", true, nil), managedObjectTestDeclaration("new", true, nil)} + for _, staging := range []int{-1, rootFD, managedObjectTestFD(t, permissive), managedObjectTestFD(t, "/proc")} { + result, err := prepareManagedMountObjectsV1(context.Background(), rootFD, staging, objects, map[string]int{".": rootFD}) + if err == nil || result != nil { + t.Fatalf("invalid staging %d accepted: %v %v", staging, result, err) + } + } + entries, err := os.ReadDir(root) + if err != nil || len(entries) != 0 { + t.Fatalf("invalid staging had effects: %v %v", entries, err) + } +} + +func TestManagedMountObjectsDirectoryStagingPreservesInheritedPolicy(t *testing.T) { + root, staging := t.TempDir(), t.TempDir() + if err := os.Chmod(staging, 0o700); err != nil { + t.Fatal(err) + } + // A minimal default ACL; the kernel, not the preparer, interprets it. + acl := make([]byte, 4+3*8) + binary.LittleEndian.PutUint32(acl, 2) + for i, entry := range [][2]uint16{{1, 7}, {4, 5}, {32, 1}} { + binary.LittleEndian.PutUint16(acl[4+i*8:], entry[0]) + binary.LittleEndian.PutUint16(acl[6+i*8:], entry[1]) + binary.LittleEndian.PutUint32(acl[8+i*8:], ^uint32(0)) + } + if err := unix.Setxattr(root, "system.posix_acl_default", acl, 0); err != nil { + if errors.Is(err, unix.EOPNOTSUPP) { + t.Skip("test filesystem does not support POSIX ACLs") + } + t.Fatal(err) + } + if err := unix.Chmod(root, 0o2700); err != nil { + t.Fatal(err) + } + control := filepath.Join(root, "control") + if err := os.Mkdir(control, 0o755); err != nil { + t.Fatal(err) + } + rootFD, stagingFD := managedObjectTestFD(t, root), managedObjectTestFD(t, staging) + objects := []blueprint.ManagedMountObject{managedObjectTestDeclaration(".", true, nil), managedObjectTestDeclaration("staged", true, nil)} + result, err := prepareManagedMountObjectsV1(context.Background(), rootFD, stagingFD, objects, map[string]int{".": rootFD}) + if err != nil { + t.Fatal(err) + } + defer closePreparedManagedObjects(t, result) + var expected unix.Stat_t + if err := unix.Stat(control, &expected); err != nil { + t.Fatal(err) + } + if got := result["staged"].Identity.Mode & 0o7777; got != expected.Mode&0o7777 { + t.Fatalf("staging changed inherited permissions: got %#o, want %#o", got, expected.Mode&0o7777) + } + got := make([]byte, len(acl)) + n, err := unix.Getxattr(filepath.Join(root, "staged"), "system.posix_acl_default", got) + if err != nil || n != len(acl) || !reflect.DeepEqual(got, acl) { + t.Fatalf("staging lost the inherited default ACL: %v %v", got, err) + } + // Reusing staging for a parent without a default ACL must not retain it. + plain := t.TempDir() + plainFD := managedObjectTestFD(t, plain) + result2, err := prepareManagedMountObjectsV1(context.Background(), plainFD, stagingFD, objects, map[string]int{".": plainFD}) + if err != nil { + t.Fatal(err) + } + defer closePreparedManagedObjects(t, result2) + if _, err := unix.Getxattr(filepath.Join(plain, "staged"), "system.posix_acl_default", got); !errors.Is(err, unix.ENODATA) { + t.Fatalf("staging leaked the previous parent's default ACL: %v", err) + } +} diff --git a/internal/dockerdeploy/managed_mount_objects_race_linux_test.go b/internal/dockerdeploy/managed_mount_objects_race_linux_test.go new file mode 100644 index 00000000..56ddfea1 --- /dev/null +++ b/internal/dockerdeploy/managed_mount_objects_race_linux_test.go @@ -0,0 +1,251 @@ +//go:build linux + +package dockerdeploy + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/omry/reploy/internal/blueprint" + "golang.org/x/sys/unix" +) + +func TestManagedMountObjectsPinnedRootSurvivesNameSubstitution(t *testing.T) { + stagingFD := managedObjectTestStagingFD(t) + base := t.TempDir() + root, held, foreign := filepath.Join(base, "root"), filepath.Join(base, "held"), filepath.Join(base, "foreign") + for _, name := range []string{root, foreign} { + if err := os.Mkdir(name, 0o700); err != nil { + t.Fatal(err) + } + } + fd := managedObjectTestFD(t, root) + if err := os.Rename(root, held); err != nil { + t.Fatal(err) + } + if err := os.Symlink(foreign, root); err != nil { + t.Fatal(err) + } + objects := []blueprint.ManagedMountObject{managedObjectTestDeclaration(".", true, nil), managedObjectTestDeclaration("new", false, managedObjectTestMode(0o640))} + result, err := prepareManagedMountObjectsV1(context.Background(), fd, stagingFD, objects, map[string]int{".": fd}) + if err != nil { + t.Fatal(err) + } + closePreparedManagedObjects(t, result) + if _, err := os.Stat(filepath.Join(held, "new")); err != nil { + t.Fatalf("held root not used: %v", err) + } + entries, err := os.ReadDir(foreign) + if err != nil || len(entries) != 0 { + t.Fatalf("foreign replacement was changed: %v %v", entries, err) + } +} + +func TestManagedMountObjectsPinnedLeafSurvivesNameSubstitution(t *testing.T) { + stagingFD := managedObjectTestStagingFD(t) + root, foreign := t.TempDir(), t.TempDir() + leaf, canary := filepath.Join(root, "file"), filepath.Join(foreign, "canary") + if err := os.WriteFile(canary, []byte("foreign"), 0o600); err != nil { + t.Fatal(err) + } + fd := managedObjectTestFD(t, root) + objects := []blueprint.ManagedMountObject{managedObjectTestDeclaration(".", true, nil), managedObjectTestDeclaration("file", false, nil)} + result, err := prepareManagedMountObjectsV1(context.Background(), fd, stagingFD, objects, map[string]int{".": fd}) + if err != nil { + t.Fatal(err) + } + defer closePreparedManagedObjects(t, result) + if err := os.Rename(leaf, filepath.Join(root, "retained")); err != nil { + t.Fatal(err) + } + if err := os.Symlink(canary, leaf); err != nil { + t.Fatal(err) + } + // The returned FD is the created inode, not a later lookup of its name. + if _, err := unix.Write(result["file"].FD, []byte("held inode")); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(canary) + if err != nil || string(data) != "foreign" { + t.Fatalf("foreign target changed: %q %v", data, err) + } + data, err = os.ReadFile(filepath.Join(root, "retained")) + if err != nil || string(data) != "held inode" { + t.Fatalf("returned descriptor lost its inode: %q %v", data, err) + } + recorded := map[string]int{".": fd, "file": result["file"].FD} + if retry, err := prepareManagedMountObjectsV1(context.Background(), fd, stagingFD, objects, recorded); err == nil || retry != nil { + t.Fatalf("later preparation accepted substituted leaf: %v %v", retry, err) + } +} + +func TestManagedMountObjectsParentReplacementRaceCannotWriteForeignTree(t *testing.T) { + stagingFD := managedObjectTestStagingFD(t) + root, foreign := t.TempDir(), t.TempDir() + parent, held := filepath.Join(root, "parent"), filepath.Join(root, "held") + if err := os.Mkdir(parent, 0o700); err != nil { + t.Fatal(err) + } + canary := filepath.Join(foreign, "canary") + if err := os.WriteFile(canary, []byte("unchanged"), 0o600); err != nil { + t.Fatal(err) + } + rootFD, parentFD := managedObjectTestFD(t, root), managedObjectTestFD(t, parent) + recorded := map[string]int{".": rootFD, "parent": parentFD} + ready, stop, finished := make(chan struct{}), make(chan struct{}), make(chan struct{}) + var once sync.Once + go func() { + defer close(finished) + defer once.Do(func() { close(ready) }) + for { + select { + case <-stop: + return + default: + } + if err := os.Rename(parent, held); err != nil { + t.Error(err) + return + } + if err := os.Symlink(foreign, parent); err != nil { + t.Error(err) + return + } + once.Do(func() { close(ready) }) + if err := os.Remove(parent); err != nil { + t.Error(err) + return + } + if err := os.Rename(held, parent); err != nil { + t.Error(err) + return + } + } + }() + <-ready + for i := 0; i < 100; i++ { + objects := []blueprint.ManagedMountObject{managedObjectTestDeclaration(".", true, nil), managedObjectTestDeclaration(fmt.Sprintf("parent/file-%03d", i), false, managedObjectTestMode(0o600))} + result, err := prepareManagedMountObjectsV1(context.Background(), rootFD, stagingFD, objects, recorded) + if err == nil { + closePreparedManagedObjects(t, result) + } else if result != nil { + t.Error("failure returned partial success") + } + } + close(stop) + <-finished + entries, err := os.ReadDir(foreign) + if err != nil || len(entries) != 1 || entries[0].Name() != "canary" { + t.Fatalf("preparation escaped to foreign tree: %v %v", entries, err) + } + data, err := os.ReadFile(canary) + if err != nil || string(data) != "unchanged" { + t.Fatalf("foreign canary changed: %q %v", data, err) + } +} + +func TestManagedMountObjectsCreationHonorsUmask(t *testing.T) { + stagingFD := managedObjectTestStagingFD(t) + if os.Getenv("REPLOY_TEST_MANAGED_OBJECT_UMASK") != "1" { + command := exec.Command(os.Args[0], "-test.run=^TestManagedMountObjectsCreationHonorsUmask$") + command.Env = append(os.Environ(), "REPLOY_TEST_MANAGED_OBJECT_UMASK=1") + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("isolated umask proof: %v\n%s", err, output) + } + return + } + // Isolate process-wide umask from all other tests and the invoking agent. + unix.Umask(0o077) + root := t.TempDir() + fd := managedObjectTestFD(t, root) + objects := []blueprint.ManagedMountObject{ + managedObjectTestDeclaration(".", true, nil), managedObjectTestDeclaration("dir", true, nil), + managedObjectTestDeclaration("dir/file", false, nil), managedObjectTestDeclaration("explicit", false, managedObjectTestMode(0o640)), + } + result, err := prepareManagedMountObjectsV1(context.Background(), fd, stagingFD, objects, map[string]int{".": fd}) + if err != nil { + t.Fatal(err) + } + defer closePreparedManagedObjects(t, result) + for name, mode := range map[string]uint32{"dir": 0o700, "dir/file": 0o600, "explicit": 0o640} { + if got := result[name].Identity.Mode & 0o7777; got != mode { + t.Errorf("%s: mode %#o, want %#o", name, got, mode) + } + } +} + +func TestManagedMountObjectsDirectoryReplacementCannotChangeForeignMetadata(t *testing.T) { + old := runtime.GOMAXPROCS(2) + defer runtime.GOMAXPROCS(old) + root, outside, staging := t.TempDir(), t.TempDir(), t.TempDir() + if err := os.Chmod(staging, 0o700); err != nil { + t.Fatal(err) + } + foreign := filepath.Join(outside, "foreign") + if err := os.Mkdir(foreign, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(foreign, "canary"), []byte("retained"), 0o600); err != nil { + t.Fatal(err) + } + rfd, ofd := managedObjectTestFD(t, root), managedObjectTestFD(t, outside) + ffd, sfd := managedObjectTestFD(t, foreign), managedObjectTestFD(t, staging) + deadline := time.Now().Add(5 * time.Second) + exchanges := 0 + for i := 0; i < 2000 && time.Now().Before(deadline); i++ { + name := fmt.Sprintf("new-%d", i) + var stop atomic.Bool + ready, done := make(chan struct{}), make(chan error, 1) + go func() { + close(ready) + for !stop.Load() { + err := unix.Renameat2(rfd, name, ofd, "foreign", unix.RENAME_EXCHANGE) + if err == nil || err != unix.ENOENT { + done <- err + return + } + runtime.Gosched() + } + done <- unix.ENOENT + }() + <-ready + objects := []blueprint.ManagedMountObject{managedObjectTestDeclaration(".", true, nil), managedObjectTestDeclaration(name, true, managedObjectTestMode(0o711))} + result, prepareErr := prepareManagedMountObjectsV1(context.Background(), rfd, sfd, objects, map[string]int{".": rfd}) + stop.Store(true) + exchangeErr := <-done + closePreparedManagedObjects(t, result) + var actual unix.Stat_t + if err := unix.Fstat(ffd, &actual); err != nil { + t.Fatal(err) + } + if exchangeErr == nil { + exchanges++ + if err := unix.Renameat2(rfd, name, ofd, "foreign", unix.RENAME_EXCHANGE); err != nil { + t.Fatal(err) + } + } else if exchangeErr != unix.ENOENT { + t.Fatal(exchangeErr) + } + if actual.Mode&0o7777 != 0o700 { + t.Fatalf("foreign metadata changed at iteration %d: mode %#o, preparation error %v", i, actual.Mode&0o7777, prepareErr) + } + if err := unix.Unlinkat(rfd, name, unix.AT_REMOVEDIR); err != nil && err != unix.ENOENT { + t.Fatal(err) + } + } + if exchanges == 0 { + t.Fatal("replacement control never exchanged a published directory") + } + data, err := os.ReadFile(filepath.Join(foreign, "canary")) + if err != nil || string(data) != "retained" { + t.Fatalf("foreign contents changed: %q %v", data, err) + } +}