//go:build linux package host import ( "context" "fmt" "os" "path/filepath" "strings" "testing" "github.com/carlosframework/platform/internal/release" ) type generationProcFixture struct { proc, task, unrelated, versions string reservation release.GenerationReservation } func generationProc(t *testing.T) generationProcFixture { t.Helper() f, disk, _ := generationLaunchFixture(t) if _, err := f.root.generations.reserve(t.Context(), f.root, f.peer, f.req); err != nil { t.Fatal(err) } g := generationProcFixture{versions: filepath.Join(f.root.generations.optDir, "versions"), proc: t.TempDir(), unrelated: filepath.Join(t.TempDir(), "unrelated"), reservation: readGenerationLaunch(t, disk)} if err := os.WriteFile(g.unrelated, []byte("other"), 0600); err != nil { t.Fatal(err) } g.task = filepath.Join(g.proc, "41", "task", "41") for _, p := range []string{g.task + "/fd", g.task + "/ns", g.proc + "/self/ns", g.proc + "/sys/kernel/random"} { if err := os.MkdirAll(p, 0700); err != nil { t.Fatal(err) } } generationWrite(t, g.proc+"/sys/kernel/random/boot_id", "boot-fixture\n") generationWrite(t, g.task+"/stat", "41 (test (child)) S 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 123 0\n") generationWrite(t, g.task+"/status", "Uid:\t0\t0\t0\t0\nKthread:\t0\n") generationWrite(t, g.task+"/maps", "") generationWrite(t, g.task+"/mountinfo", fmt.Sprintf("1 0 %d:%d / / rw - tmpfs tmpfs rw\n", generationMajor(g.reservation.Binding.TreeIdentity.Device), generationMinor(g.reservation.Binding.TreeIdentity.Device))) for _, ns := range []string{"mnt", "pid"} { generationLink(t, "namespace-"+ns, g.proc+"/self/ns/"+ns) generationLink(t, "namespace-"+ns, g.task+"/ns/"+ns) } for _, name := range []string{"exe", "cwd", "root"} { generationLink(t, g.unrelated, g.task+"/"+name) } return g } func generationWrite(t *testing.T, p, s string) { t.Helper() if err := os.WriteFile(p, []byte(s), 0600); err != nil { t.Fatal(err) } } func generationLink(t *testing.T, target, path string) { t.Helper() if err := os.Symlink(target, path); err != nil { t.Fatal(err) } } func generationRelink(t *testing.T, target, path string) { t.Helper() if err := os.Remove(path); err != nil { t.Fatal(err) } generationLink(t, target, path) } func TestPlan14GenerationKernelOwnDescriptors(t *testing.T) { f := generationProc(t) if err := os.Remove(f.task + "/fd"); err != nil { t.Fatal(err) } generationLink(t, "/proc/self/fd", f.task+"/fd") if _, err := (generationKernel{proc: f.proc}).absent(t.Context(), f.reservation); err != nil { t.Fatal("scanner's own descriptor disappeared during inspection", err) } file, err := os.Open(f.reservation.Binding.Path) if err != nil { t.Fatal(err) } defer file.Close() if _, err := (generationKernel{proc: f.proc}).absent(t.Context(), f.reservation); err == nil { t.Fatal("real open staged file ignored") } } func TestPlan14GenerationKernelReferences(t *testing.T) { for _, attack := range []string{"none", "exe", "fd", "deleted-fd", "cwd", "root", "mapped", "mount", "foreign-mount-ns", "foreign-pid-ns", "namespace-fd", "uncontained", "malformed-stat", "malformed-maps", "missing-status", "missing-fds", "missing-mounts", "missing-boot", "cancel", "thread"} { t.Run(attack, func(t *testing.T) { f := generationProc(t) b := f.reservation.Binding ctx := t.Context() switch attack { case "exe", "cwd", "root": generationRelink(t, b.Path, f.task+"/"+attack) case "fd": generationLink(t, b.Path, f.task+"/fd/3") case "deleted-fd": file, err := os.Open(b.Path) if err != nil { t.Fatal(err) } defer file.Close() if err := os.Remove(b.Path); err != nil { t.Fatal(err) } generationLink(t, fmt.Sprintf("/proc/%d/fd/%d", os.Getpid(), file.Fd()), f.task+"/fd/3") case "mapped": generationWrite(t, f.task+"/maps", fmt.Sprintf("1000-2000 r-xp 0000 %x:%x %d %s (deleted)\n", generationMajor(b.Executable.Device), generationMinor(b.Executable.Device), b.Executable.Inode, b.Path)) case "mount": generationWrite(t, f.task+"/mountinfo", "2 1 0:1 "+b.Tree+" /bound ro - tmpfs tmpfs ro\n") case "foreign-mount-ns": generationRelink(t, "foreign", f.task+"/ns/mnt") case "foreign-pid-ns": generationRelink(t, "foreign", f.task+"/ns/pid") case "namespace-fd": generationLink(t, "mnt:[12345]", f.task+"/fd/3") case "uncontained": generationWrite(t, f.task+"/status", "Uid:\t1001\t1001\t1001\t1001\nKthread:\t0\n") case "malformed-stat": generationWrite(t, f.task+"/stat", "broken") case "malformed-maps": generationWrite(t, f.task+"/maps", "broken") case "missing-status": if err := os.Remove(f.task + "/status"); err != nil { t.Fatal(err) } case "missing-fds": if err := os.Remove(f.task + "/fd"); err != nil { t.Fatal(err) } case "missing-mounts": if err := os.Remove(f.task + "/mountinfo"); err != nil { t.Fatal(err) } case "missing-boot": if err := os.Remove(f.proc + "/sys/kernel/random/boot_id"); err != nil { t.Fatal(err) } case "cancel": var cancel context.CancelFunc ctx, cancel = context.WithCancel(ctx) cancel() case "thread": other := filepath.Join(filepath.Dir(f.task), "42") if err := os.Mkdir(other, 0700); err != nil { t.Fatal(err) } // A separate descriptor table on a nonleader must not be omitted. for _, name := range []string{"stat", "status", "maps", "mountinfo", "ns", "exe", "cwd", "root"} { generationLink(t, filepath.Join(f.task, name), filepath.Join(other, name)) } if err := os.Mkdir(other+"/fd", 0700); err != nil { t.Fatal(err) } generationLink(t, b.Path, other+"/fd/7") } evidence, err := (generationKernel{proc: f.proc}).absent(ctx, f.reservation) if attack == "none" { if err != nil || len(evidence) != 64 { t.Fatalf("positive: %q %v", evidence, err) } } else if err == nil { t.Fatalf("%s released kernel reference: %s", attack, evidence) } }) } } func TestPlan14GenerationKernelMappingIdentity(t *testing.T) { f := generationProc(t) b := f.reservation.Binding // A renamed/deleted mapping is found by device/inode without its old path. generationWrite(t, f.task+"/maps", fmt.Sprintf("1000-2000 r-xp 0000 %x:%x %d /renamed (deleted)\n", generationMajor(b.Executable.Device), generationMinor(b.Executable.Device), b.Executable.Inode)) if _, err := (generationKernel{proc: f.proc}).absent(t.Context(), f.reservation); err == nil { t.Fatal("renamed mapping lost its pin") } } func TestPlan14GenerationKernelControllerIdentity(t *testing.T) { f := generationProc(t) b := f.reservation.Binding b.Target.BootID = "boot-fixture" b.ControllerPID = 41 b.ControllerStart = "123" generationLink(t, f.task+"/stat", f.proc+"/41/stat") for _, test := range []struct { name, start, boot string allow bool }{{"alive", "123", "boot-fixture", false}, {"pid-reused", "122", "boot-fixture", true}, {"reboot", "123", "old-boot", true}} { t.Run(test.name, func(t *testing.T) { b.ControllerStart, b.Target.BootID = test.start, test.boot if err := (generationKernel{proc: f.proc}).controllerGone(t.Context(), b); (err == nil) != test.allow { t.Fatalf("gone: %v", err) } }) } b.ControllerPID = 99 if err := (generationKernel{proc: f.proc}).controllerGone(t.Context(), b); err != nil { t.Fatal(err) } generationWrite(t, f.task+"/stat", strings.Repeat("x", 1<<20)) b.ControllerPID = 41 b.Target.BootID = "boot-fixture" if err := (generationKernel{proc: f.proc}).controllerGone(t.Context(), b); err == nil { t.Fatal("oversized stat accepted") } } func TestPlan14GenerationKernelRetainedFiles(t *testing.T) { for _, attack := range []string{"none", "path", "tree"} { t.Run(attack, func(t *testing.T) { f := generationProc(t) b := f.reservation.Binding boot, err := os.ReadFile("/proc/sys/kernel/random/boot_id") if err != nil { t.Fatal(err) } b.Target.BootID = strings.TrimSpace(string(boot)) switch attack { case "path": b.Executable.Inode++ case "tree": b.TreeIdentity.Inode++ } err = generationRetainedFiles(b, func(path string) (*os.File, error) { file, err := os.Open(path) if err != nil { return nil, err } info, err := file.Stat() if err != nil || !info.Mode().IsRegular() { file.Close() return nil, os.ErrPermission } return file, nil }) if (err == nil) != (attack == "none") { t.Fatalf("retained files: %v", err) } }) } } func TestPlan14GenerationKernelSiblingAlias(t *testing.T) { f := generationProc(t) asset := filepath.Join(f.reservation.Binding.Tree, "asset") generationWrite(t, asset, "sibling") alias := filepath.Join(t.TempDir(), "aliased-asset") if err := os.Link(asset, alias); err != nil { t.Fatal(err) } generationLink(t, alias, f.task+"/fd/8") if _, err := (generationKernel{proc: f.proc}).absent(t.Context(), f.reservation); err == nil { t.Fatal("aliased sibling did not pin its tree") } } func generationRetainedFiles(b release.GenerationBinding, open func(string) (*os.File, error)) error { _, err := generationCurrentFiles(b, open) return err } func TestPlan14GenerationServiceReferences(t *testing.T) { for _, mode := range []string{"service", "unknown", "fd", "mapped", "target mount"} { t.Run(mode, func(t *testing.T) { f := generationProc(t) b := f.reservation.Binding generationWrite(t, f.task+"/status", "Uid:\t1001\t1001\t1001\t1001\nKthread:\t0\n") generationRelink(t, "private-service", f.task+"/ns/mnt") switch mode { case "fd": generationLink(t, b.Path, f.task+"/fd/3") case "mapped": generationWrite(t, f.task+"/maps", fmt.Sprintf("1000-2000 r-xp 0000 %x:%x %d %s\n", generationMajor(b.Executable.Device), generationMinor(b.Executable.Device), b.Executable.Inode, b.Path)) case "target mount": generationWrite(t, f.task+"/mountinfo", fmt.Sprintf("1 0 %d:%d / / rw - ext4 disk rw\n2 1 %d:%d %s /bound ro - ext4 disk ro\n", generationMajor(b.Executable.Device), generationMinor(b.Executable.Device), generationMajor(b.Executable.Device), generationMinor(b.Executable.Device), b.Tree)) } k := generationKernel{proc: f.proc, service: func(string, []byte) (bool, error) { return mode != "unknown", nil }, recheck: func(string, bool) error { return nil }} _, err := k.absent(t.Context(), f.reservation) if (err == nil) != (mode == "service") { t.Fatalf("service reference boundary: %v", err) } }) } } func TestPlan14GenerationCensusRetriesWholeObservation(t *testing.T) { for _, refusal := range []error{os.ErrNotExist, os.ErrPermission} { calls := 0 boot, digest, err := generationCensusRetry(t.Context(), func(context.Context) (string, string, error) { calls++ if calls == 1 { return "", "", fmt.Errorf("task exited: %w", refusal) } return "boot", "digest", nil }) if refusal == os.ErrNotExist { if err != nil || calls != 2 || boot != "boot" || digest != "digest" { t.Fatal(calls, boot, digest, err) } } else if err == nil || calls != 1 { t.Fatal("retried unexplained task", calls, err) } } calls := 0 _, _, err := generationCensusRetry(t.Context(), func(context.Context) (string, string, error) { calls++; return "", "", os.ErrNotExist }) if err == nil || calls != 3 { t.Fatal("unbounded or incomplete scan accepted", calls, err) } } func TestPlan14GenerationServiceLookupKeepsStrictRootScan(t *testing.T) { for _, mode := range []string{"root", "nonroot", "private-namespace", "fd", "ancestor-mount"} { t.Run(mode, func(t *testing.T) { f := generationProc(t) switch mode { case "nonroot": generationWrite(t, f.task+"/status", "Uid:\t1001\t1001\t1001\t1001\nKthread:\t0\n") case "private-namespace": generationRelink(t, "private-service", f.task+"/ns/mnt") case "fd": generationLink(t, f.reservation.Binding.Path, f.task+"/fd/3") case "ancestor-mount": generationWrite(t, f.task+"/mountinfo", "2 1 0:1 "+filepath.Dir(f.reservation.Binding.Tree)+" /bound ro - tmpfs tmpfs ro\n") } k := generationKernel{proc: f.proc, service: func(string, []byte) (bool, error) { return false, os.ErrPermission }} _, err := k.absent(t.Context(), f.reservation) if (err == nil) != (mode == "root") { t.Fatalf("strict root scan: %v", err) } }) } } // journal-migrate's own service-user registry child (harness 8.2, // 2026-10-20): from an operator shell it sits in no service, so the root-task // rule refused it. A direct child of the census's parent passes that rule // only; it still shares the namespaces and passes every reference check. func TestPlan14GenerationCensusAcceptsOwnChildOnly(t *testing.T) { for _, mode := range []string{"own child", "no parent", "other parent", "exe", "fd", "mapped", "foreign-mount-ns", "target mount"} { t.Run(mode, func(t *testing.T) { f := generationProc(t) b := f.reservation.Binding // The fixture's stat names ppid 1. generationWrite(t, f.task+"/status", "Uid:\t989\t989\t989\t989\nKthread:\t0\n") k := generationKernel{proc: f.proc, parent: 1, service: func(string, []byte) (bool, error) { return false, os.ErrPermission }} switch mode { case "no parent": k.parent = 0 case "other parent": k.parent = 2 case "exe": generationRelink(t, b.Path, f.task+"/exe") case "fd": generationLink(t, b.Path, f.task+"/fd/3") case "mapped": generationWrite(t, f.task+"/maps", fmt.Sprintf("1000-2000 r-xp 0000 %x:%x %d %s\n", generationMajor(b.Executable.Device), generationMinor(b.Executable.Device), b.Executable.Inode, b.Path)) case "foreign-mount-ns": generationRelink(t, "foreign", f.task+"/ns/mnt") case "target mount": generationWrite(t, f.task+"/mountinfo", "2 1 0:1 "+b.Tree+" /bound ro - tmpfs tmpfs ro\n") } _, err := k.absent(t.Context(), f.reservation) if (err == nil) != (mode == "own child") { t.Fatalf("own-child boundary: %v", err) } }) } } func TestProcStatParent(t *testing.T) { for raw, want := range map[string]int{ "41 (test (child)) S 7 0 0": 7, "41 (a) b) c) R 916 1 1": 916, "41 (x\n) S 3 0": 3, } { if got, err := procStatParent([]byte(raw)); err != nil || got != want { t.Fatalf("%q = %d, %v; want %d", raw, got, err, want) } } for _, raw := range []string{"", "41 (x", "41 (x) S", "41 (x) S y"} { if _, err := procStatParent([]byte(raw)); err == nil { t.Fatalf("%q accepted", raw) } } }