| 1 | //go:build linux |
| 2 | |
| 3 | package host |
| 4 | |
| 5 | import ( |
| 6 | "context" |
| 7 | "fmt" |
| 8 | "os" |
| 9 | "path/filepath" |
| 10 | "strings" |
| 11 | "testing" |
| 12 | |
| 13 | "github.com/carlosframework/platform/internal/release" |
| 14 | ) |
| 15 | |
| 16 | type generationProcFixture struct { |
| 17 | proc, task, unrelated, versions string |
| 18 | reservation release.GenerationReservation |
| 19 | } |
| 20 | |
| 21 | func generationProc(t *testing.T) generationProcFixture { |
| 22 | t.Helper() |
| 23 | f, disk, _ := generationLaunchFixture(t) |
| 24 | if _, err := f.root.generations.reserve(t.Context(), f.root, f.peer, f.req); err != nil { |
| 25 | t.Fatal(err) |
| 26 | } |
| 27 | g := generationProcFixture{versions: filepath.Join(f.root.generations.optDir, "versions"), proc: t.TempDir(), unrelated: filepath.Join(t.TempDir(), "unrelated"), reservation: readGenerationLaunch(t, disk)} |
| 28 | if err := os.WriteFile(g.unrelated, []byte("other"), 0600); err != nil { |
| 29 | t.Fatal(err) |
| 30 | } |
| 31 | g.task = filepath.Join(g.proc, "41", "task", "41") |
| 32 | for _, p := range []string{g.task + "/fd", g.task + "/ns", g.proc + "/self/ns", g.proc + "/sys/kernel/random"} { |
| 33 | if err := os.MkdirAll(p, 0700); err != nil { |
| 34 | t.Fatal(err) |
| 35 | } |
| 36 | } |
| 37 | generationWrite(t, g.proc+"/sys/kernel/random/boot_id", "boot-fixture\n") |
| 38 | 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") |
| 39 | generationWrite(t, g.task+"/status", "Uid:\t0\t0\t0\t0\nKthread:\t0\n") |
| 40 | generationWrite(t, g.task+"/maps", "") |
| 41 | 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))) |
| 42 | for _, ns := range []string{"mnt", "pid"} { |
| 43 | generationLink(t, "namespace-"+ns, g.proc+"/self/ns/"+ns) |
| 44 | generationLink(t, "namespace-"+ns, g.task+"/ns/"+ns) |
| 45 | } |
| 46 | for _, name := range []string{"exe", "cwd", "root"} { |
| 47 | generationLink(t, g.unrelated, g.task+"/"+name) |
| 48 | } |
| 49 | return g |
| 50 | } |
| 51 | func generationWrite(t *testing.T, p, s string) { |
| 52 | t.Helper() |
| 53 | if err := os.WriteFile(p, []byte(s), 0600); err != nil { |
| 54 | t.Fatal(err) |
| 55 | } |
| 56 | } |
| 57 | func generationLink(t *testing.T, target, path string) { |
| 58 | t.Helper() |
| 59 | if err := os.Symlink(target, path); err != nil { |
| 60 | t.Fatal(err) |
| 61 | } |
| 62 | } |
| 63 | func generationRelink(t *testing.T, target, path string) { |
| 64 | t.Helper() |
| 65 | if err := os.Remove(path); err != nil { |
| 66 | t.Fatal(err) |
| 67 | } |
| 68 | generationLink(t, target, path) |
| 69 | } |
| 70 | |
| 71 | func TestPlan14GenerationKernelOwnDescriptors(t *testing.T) { |
| 72 | f := generationProc(t) |
| 73 | if err := os.Remove(f.task + "/fd"); err != nil { |
| 74 | t.Fatal(err) |
| 75 | } |
| 76 | generationLink(t, "/proc/self/fd", f.task+"/fd") |
| 77 | if _, err := (generationKernel{proc: f.proc}).absent(t.Context(), f.reservation); err != nil { |
| 78 | t.Fatal("scanner's own descriptor disappeared during inspection", err) |
| 79 | } |
| 80 | file, err := os.Open(f.reservation.Binding.Path) |
| 81 | if err != nil { |
| 82 | t.Fatal(err) |
| 83 | } |
| 84 | defer file.Close() |
| 85 | if _, err := (generationKernel{proc: f.proc}).absent(t.Context(), f.reservation); err == nil { |
| 86 | t.Fatal("real open staged file ignored") |
| 87 | } |
| 88 | } |
| 89 | |
| 90 | func TestPlan14GenerationKernelReferences(t *testing.T) { |
| 91 | 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"} { |
| 92 | t.Run(attack, func(t *testing.T) { |
| 93 | f := generationProc(t) |
| 94 | b := f.reservation.Binding |
| 95 | ctx := t.Context() |
| 96 | switch attack { |
| 97 | case "exe", "cwd", "root": |
| 98 | generationRelink(t, b.Path, f.task+"/"+attack) |
| 99 | case "fd": |
| 100 | generationLink(t, b.Path, f.task+"/fd/3") |
| 101 | case "deleted-fd": |
| 102 | file, err := os.Open(b.Path) |
| 103 | if err != nil { |
| 104 | t.Fatal(err) |
| 105 | } |
| 106 | defer file.Close() |
| 107 | if err := os.Remove(b.Path); err != nil { |
| 108 | t.Fatal(err) |
| 109 | } |
| 110 | generationLink(t, fmt.Sprintf("/proc/%d/fd/%d", os.Getpid(), file.Fd()), f.task+"/fd/3") |
| 111 | case "mapped": |
| 112 | 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)) |
| 113 | case "mount": |
| 114 | generationWrite(t, f.task+"/mountinfo", "2 1 0:1 "+b.Tree+" /bound ro - tmpfs tmpfs ro\n") |
| 115 | case "foreign-mount-ns": |
| 116 | generationRelink(t, "foreign", f.task+"/ns/mnt") |
| 117 | case "foreign-pid-ns": |
| 118 | generationRelink(t, "foreign", f.task+"/ns/pid") |
| 119 | case "namespace-fd": |
| 120 | generationLink(t, "mnt:[12345]", f.task+"/fd/3") |
| 121 | case "uncontained": |
| 122 | generationWrite(t, f.task+"/status", "Uid:\t1001\t1001\t1001\t1001\nKthread:\t0\n") |
| 123 | case "malformed-stat": |
| 124 | generationWrite(t, f.task+"/stat", "broken") |
| 125 | case "malformed-maps": |
| 126 | generationWrite(t, f.task+"/maps", "broken") |
| 127 | case "missing-status": |
| 128 | if err := os.Remove(f.task + "/status"); err != nil { |
| 129 | t.Fatal(err) |
| 130 | } |
| 131 | case "missing-fds": |
| 132 | if err := os.Remove(f.task + "/fd"); err != nil { |
| 133 | t.Fatal(err) |
| 134 | } |
| 135 | case "missing-mounts": |
| 136 | if err := os.Remove(f.task + "/mountinfo"); err != nil { |
| 137 | t.Fatal(err) |
| 138 | } |
| 139 | case "missing-boot": |
| 140 | if err := os.Remove(f.proc + "/sys/kernel/random/boot_id"); err != nil { |
| 141 | t.Fatal(err) |
| 142 | } |
| 143 | case "cancel": |
| 144 | var cancel context.CancelFunc |
| 145 | ctx, cancel = context.WithCancel(ctx) |
| 146 | cancel() |
| 147 | case "thread": |
| 148 | other := filepath.Join(filepath.Dir(f.task), "42") |
| 149 | if err := os.Mkdir(other, 0700); err != nil { |
| 150 | t.Fatal(err) |
| 151 | } |
| 152 | // A separate descriptor table on a nonleader must not be omitted. |
| 153 | for _, name := range []string{"stat", "status", "maps", "mountinfo", "ns", "exe", "cwd", "root"} { |
| 154 | generationLink(t, filepath.Join(f.task, name), filepath.Join(other, name)) |
| 155 | } |
| 156 | if err := os.Mkdir(other+"/fd", 0700); err != nil { |
| 157 | t.Fatal(err) |
| 158 | } |
| 159 | generationLink(t, b.Path, other+"/fd/7") |
| 160 | } |
| 161 | evidence, err := (generationKernel{proc: f.proc}).absent(ctx, f.reservation) |
| 162 | if attack == "none" { |
| 163 | if err != nil || len(evidence) != 64 { |
| 164 | t.Fatalf("positive: %q %v", evidence, err) |
| 165 | } |
| 166 | } else if err == nil { |
| 167 | t.Fatalf("%s released kernel reference: %s", attack, evidence) |
| 168 | } |
| 169 | }) |
| 170 | } |
| 171 | } |
| 172 | |
| 173 | func TestPlan14GenerationKernelMappingIdentity(t *testing.T) { |
| 174 | f := generationProc(t) |
| 175 | b := f.reservation.Binding |
| 176 | // A renamed/deleted mapping is found by device/inode without its old path. |
| 177 | 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)) |
| 178 | if _, err := (generationKernel{proc: f.proc}).absent(t.Context(), f.reservation); err == nil { |
| 179 | t.Fatal("renamed mapping lost its pin") |
| 180 | } |
| 181 | } |
| 182 | |
| 183 | func TestPlan14GenerationKernelControllerIdentity(t *testing.T) { |
| 184 | f := generationProc(t) |
| 185 | b := f.reservation.Binding |
| 186 | b.Target.BootID = "boot-fixture" |
| 187 | b.ControllerPID = 41 |
| 188 | b.ControllerStart = "123" |
| 189 | generationLink(t, f.task+"/stat", f.proc+"/41/stat") |
| 190 | for _, test := range []struct { |
| 191 | name, start, boot string |
| 192 | allow bool |
| 193 | }{{"alive", "123", "boot-fixture", false}, {"pid-reused", "122", "boot-fixture", true}, {"reboot", "123", "old-boot", true}} { |
| 194 | t.Run(test.name, func(t *testing.T) { |
| 195 | b.ControllerStart, b.Target.BootID = test.start, test.boot |
| 196 | if err := (generationKernel{proc: f.proc}).controllerGone(t.Context(), b); (err == nil) != test.allow { |
| 197 | t.Fatalf("gone: %v", err) |
| 198 | } |
| 199 | }) |
| 200 | } |
| 201 | b.ControllerPID = 99 |
| 202 | if err := (generationKernel{proc: f.proc}).controllerGone(t.Context(), b); err != nil { |
| 203 | t.Fatal(err) |
| 204 | } |
| 205 | generationWrite(t, f.task+"/stat", strings.Repeat("x", 1<<20)) |
| 206 | b.ControllerPID = 41 |
| 207 | b.Target.BootID = "boot-fixture" |
| 208 | if err := (generationKernel{proc: f.proc}).controllerGone(t.Context(), b); err == nil { |
| 209 | t.Fatal("oversized stat accepted") |
| 210 | } |
| 211 | } |
| 212 | |
| 213 | func TestPlan14GenerationKernelRetainedFiles(t *testing.T) { |
| 214 | for _, attack := range []string{"none", "path", "tree"} { |
| 215 | t.Run(attack, func(t *testing.T) { |
| 216 | f := generationProc(t) |
| 217 | b := f.reservation.Binding |
| 218 | boot, err := os.ReadFile("/proc/sys/kernel/random/boot_id") |
| 219 | if err != nil { |
| 220 | t.Fatal(err) |
| 221 | } |
| 222 | b.Target.BootID = strings.TrimSpace(string(boot)) |
| 223 | switch attack { |
| 224 | case "path": |
| 225 | b.Executable.Inode++ |
| 226 | case "tree": |
| 227 | b.TreeIdentity.Inode++ |
| 228 | } |
| 229 | err = generationRetainedFiles(b, func(path string) (*os.File, error) { |
| 230 | file, err := os.Open(path) |
| 231 | if err != nil { |
| 232 | return nil, err |
| 233 | } |
| 234 | info, err := file.Stat() |
| 235 | if err != nil || !info.Mode().IsRegular() { |
| 236 | file.Close() |
| 237 | return nil, os.ErrPermission |
| 238 | } |
| 239 | return file, nil |
| 240 | }) |
| 241 | if (err == nil) != (attack == "none") { |
| 242 | t.Fatalf("retained files: %v", err) |
| 243 | } |
| 244 | }) |
| 245 | } |
| 246 | } |
| 247 | |
| 248 | func TestPlan14GenerationKernelSiblingAlias(t *testing.T) { |
| 249 | f := generationProc(t) |
| 250 | asset := filepath.Join(f.reservation.Binding.Tree, "asset") |
| 251 | generationWrite(t, asset, "sibling") |
| 252 | alias := filepath.Join(t.TempDir(), "aliased-asset") |
| 253 | if err := os.Link(asset, alias); err != nil { |
| 254 | t.Fatal(err) |
| 255 | } |
| 256 | generationLink(t, alias, f.task+"/fd/8") |
| 257 | if _, err := (generationKernel{proc: f.proc}).absent(t.Context(), f.reservation); err == nil { |
| 258 | t.Fatal("aliased sibling did not pin its tree") |
| 259 | } |
| 260 | } |
| 261 | |
| 262 | func generationRetainedFiles(b release.GenerationBinding, open func(string) (*os.File, error)) error { |
| 263 | _, err := generationCurrentFiles(b, open) |
| 264 | return err |
| 265 | } |
| 266 | |
| 267 | func TestPlan14GenerationServiceReferences(t *testing.T) { |
| 268 | for _, mode := range []string{"service", "unknown", "fd", "mapped", "target mount"} { |
| 269 | t.Run(mode, func(t *testing.T) { |
| 270 | f := generationProc(t) |
| 271 | b := f.reservation.Binding |
| 272 | generationWrite(t, f.task+"/status", "Uid:\t1001\t1001\t1001\t1001\nKthread:\t0\n") |
| 273 | generationRelink(t, "private-service", f.task+"/ns/mnt") |
| 274 | switch mode { |
| 275 | case "fd": |
| 276 | generationLink(t, b.Path, f.task+"/fd/3") |
| 277 | case "mapped": |
| 278 | 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)) |
| 279 | case "target mount": |
| 280 | 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)) |
| 281 | } |
| 282 | k := generationKernel{proc: f.proc, service: func(string, []byte) (bool, error) { return mode != "unknown", nil }, recheck: func(string, bool) error { return nil }} |
| 283 | _, err := k.absent(t.Context(), f.reservation) |
| 284 | if (err == nil) != (mode == "service") { |
| 285 | t.Fatalf("service reference boundary: %v", err) |
| 286 | } |
| 287 | }) |
| 288 | } |
| 289 | } |
| 290 | |
| 291 | func TestPlan14GenerationCensusRetriesWholeObservation(t *testing.T) { |
| 292 | for _, refusal := range []error{os.ErrNotExist, os.ErrPermission} { |
| 293 | calls := 0 |
| 294 | boot, digest, err := generationCensusRetry(t.Context(), func(context.Context) (string, string, error) { |
| 295 | calls++ |
| 296 | if calls == 1 { |
| 297 | return "", "", fmt.Errorf("task exited: %w", refusal) |
| 298 | } |
| 299 | return "boot", "digest", nil |
| 300 | }) |
| 301 | if refusal == os.ErrNotExist { |
| 302 | if err != nil || calls != 2 || boot != "boot" || digest != "digest" { |
| 303 | t.Fatal(calls, boot, digest, err) |
| 304 | } |
| 305 | } else if err == nil || calls != 1 { |
| 306 | t.Fatal("retried unexplained task", calls, err) |
| 307 | } |
| 308 | } |
| 309 | calls := 0 |
| 310 | _, _, err := generationCensusRetry(t.Context(), func(context.Context) (string, string, error) { calls++; return "", "", os.ErrNotExist }) |
| 311 | if err == nil || calls != 3 { |
| 312 | t.Fatal("unbounded or incomplete scan accepted", calls, err) |
| 313 | } |
| 314 | } |
| 315 | |
| 316 | func TestPlan14GenerationServiceLookupKeepsStrictRootScan(t *testing.T) { |
| 317 | for _, mode := range []string{"root", "nonroot", "private-namespace", "fd", "ancestor-mount"} { |
| 318 | t.Run(mode, func(t *testing.T) { |
| 319 | f := generationProc(t) |
| 320 | switch mode { |
| 321 | case "nonroot": |
| 322 | generationWrite(t, f.task+"/status", "Uid:\t1001\t1001\t1001\t1001\nKthread:\t0\n") |
| 323 | case "private-namespace": |
| 324 | generationRelink(t, "private-service", f.task+"/ns/mnt") |
| 325 | case "fd": |
| 326 | generationLink(t, f.reservation.Binding.Path, f.task+"/fd/3") |
| 327 | case "ancestor-mount": |
| 328 | generationWrite(t, f.task+"/mountinfo", "2 1 0:1 "+filepath.Dir(f.reservation.Binding.Tree)+" /bound ro - tmpfs tmpfs ro\n") |
| 329 | } |
| 330 | k := generationKernel{proc: f.proc, service: func(string, []byte) (bool, error) { return false, os.ErrPermission }} |
| 331 | _, err := k.absent(t.Context(), f.reservation) |
| 332 | if (err == nil) != (mode == "root") { |
| 333 | t.Fatalf("strict root scan: %v", err) |
| 334 | } |
| 335 | }) |
| 336 | } |
| 337 | } |
| 338 | |
| 339 | // journal-migrate's own service-user registry child (harness 8.2, |
| 340 | // 2026-10-20): from an operator shell it sits in no service, so the root-task |
| 341 | // rule refused it. A direct child of the census's parent passes that rule |
| 342 | // only; it still shares the namespaces and passes every reference check. |
| 343 | func TestPlan14GenerationCensusAcceptsOwnChildOnly(t *testing.T) { |
| 344 | for _, mode := range []string{"own child", "no parent", "other parent", "exe", "fd", "mapped", "foreign-mount-ns", "target mount"} { |
| 345 | t.Run(mode, func(t *testing.T) { |
| 346 | f := generationProc(t) |
| 347 | b := f.reservation.Binding |
| 348 | // The fixture's stat names ppid 1. |
| 349 | generationWrite(t, f.task+"/status", "Uid:\t989\t989\t989\t989\nKthread:\t0\n") |
| 350 | k := generationKernel{proc: f.proc, parent: 1, service: func(string, []byte) (bool, error) { return false, os.ErrPermission }} |
| 351 | switch mode { |
| 352 | case "no parent": |
| 353 | k.parent = 0 |
| 354 | case "other parent": |
| 355 | k.parent = 2 |
| 356 | case "exe": |
| 357 | generationRelink(t, b.Path, f.task+"/exe") |
| 358 | case "fd": |
| 359 | generationLink(t, b.Path, f.task+"/fd/3") |
| 360 | case "mapped": |
| 361 | 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)) |
| 362 | case "foreign-mount-ns": |
| 363 | generationRelink(t, "foreign", f.task+"/ns/mnt") |
| 364 | case "target mount": |
| 365 | generationWrite(t, f.task+"/mountinfo", "2 1 0:1 "+b.Tree+" /bound ro - tmpfs tmpfs ro\n") |
| 366 | } |
| 367 | _, err := k.absent(t.Context(), f.reservation) |
| 368 | if (err == nil) != (mode == "own child") { |
| 369 | t.Fatalf("own-child boundary: %v", err) |
| 370 | } |
| 371 | }) |
| 372 | } |
| 373 | } |
| 374 | |
| 375 | func TestProcStatParent(t *testing.T) { |
| 376 | for raw, want := range map[string]int{ |
| 377 | "41 (test (child)) S 7 0 0": 7, |
| 378 | "41 (a) b) c) R 916 1 1": 916, |
| 379 | "41 (x\n) S 3 0": 3, |
| 380 | } { |
| 381 | if got, err := procStatParent([]byte(raw)); err != nil || got != want { |
| 382 | t.Fatalf("%q = %d, %v; want %d", raw, got, err, want) |
| 383 | } |
| 384 | } |
| 385 | for _, raw := range []string{"", "41 (x", "41 (x) S", "41 (x) S y"} { |
| 386 | if _, err := procStatParent([]byte(raw)); err == nil { |
| 387 | t.Fatalf("%q accepted", raw) |
| 388 | } |
| 389 | } |
| 390 | } |
| 391 | |