| 1 | //go:build linux |
| 2 | |
| 3 | package instanceexec |
| 4 | |
| 5 | import ( |
| 6 | "errors" |
| 7 | "os" |
| 8 | "path/filepath" |
| 9 | "strings" |
| 10 | "syscall" |
| 11 | "testing" |
| 12 | |
| 13 | "github.com/carlosframework/platform/internal/instancefs" |
| 14 | "github.com/carlosframework/platform/internal/provision" |
| 15 | ) |
| 16 | |
| 17 | // This is an opt-in kernel witness, not a mock or a go-test-on-the-host check. |
| 18 | // The external sandbox wrapper must build this package's test binary and run |
| 19 | // only this test through main's isolated tenant launcher as the tenant payload. |
| 20 | // It must first verify that the edge's delegated cgroup controls exist and are |
| 21 | // writable by carlos in the host namespace, and that all four journal paths |
| 22 | // below are existing regular files in that namespace. Use disposable sandbox |
| 23 | // fixtures, never create fixtures in a production journal directory. Set |
| 24 | // CARLOS_CLAIM_ISOLATION_HOST to the sandbox host, then set both switches below |
| 25 | // only after those host-side checks pass. The assertion cannot establish the |
| 26 | // existence of files deliberately hidden from its own namespace. |
| 27 | // |
| 28 | // A missing opt-in skips. Missing host fixture attestation or incorrect mount |
| 29 | // facts fail. Local CI skips do not prove isolation and cannot enable rollout. |
| 30 | // Opens have no O_CREATE, O_TRUNC or write calls, even if isolation is broken. |
| 31 | func TestClaimTenantCannotWriteContainmentOrJournals(t *testing.T) { |
| 32 | if os.Getenv("CARLOS_CLAIM_ISOLATION_KERNEL") != "1" { |
| 33 | t.Skip("requires sandbox fixtures and main's isolated tenant launcher") |
| 34 | } |
| 35 | if os.Getenv("CARLOS_CLAIM_ISOLATION_HOST_FIXTURES_VERIFIED") != "1" { |
| 36 | t.Fatal("external wrapper must verify host-side journal and delegated cgroup fixtures") |
| 37 | } |
| 38 | host := os.Getenv("CARLOS_CLAIM_ISOLATION_HOST") |
| 39 | if !provision.ValidHostShape(host) { |
| 40 | t.Fatal("sandbox host fixture is missing or invalid") |
| 41 | } |
| 42 | if os.Geteuid() == 0 || os.Getpid() != 1 { |
| 43 | t.Fatal("witness must run as the non-root tenant payload in its private PID namespace") |
| 44 | } |
| 45 | mounts, err := os.ReadFile("/proc/self/mountinfo") |
| 46 | if err != nil { |
| 47 | t.Fatal(err) |
| 48 | } |
| 49 | for mountpoint, filesystem := range map[string]string{"/data": "tmpfs", "/sys/fs/cgroup": "cgroup2"} { |
| 50 | readOnly, err := instancefs.MountOptionOf(mountpoint, "ro") |
| 51 | if err != nil || !readOnly { |
| 52 | t.Fatalf("%s must be a read-only mount: %v, %v", mountpoint, readOnly, err) |
| 53 | } |
| 54 | found := false |
| 55 | for _, line := range strings.Split(string(mounts), "\n") { |
| 56 | left, right, ok := strings.Cut(line, " - ") |
| 57 | fields, fs := strings.Fields(left), strings.Fields(right) |
| 58 | if ok && len(fields) >= 6 && fields[4] == mountpoint && len(fs) >= 1 { |
| 59 | found = fs[0] == filesystem |
| 60 | } |
| 61 | } |
| 62 | if !found { |
| 63 | t.Fatalf("%s must have filesystem %s in the tenant namespace", mountpoint, filesystem) |
| 64 | } |
| 65 | } |
| 66 | // The tenant's /data is a fresh read-only tmpfs exposing only instances. |
| 67 | // This is the hiding mechanism; ENOENT alone would be a vacuous assertion. |
| 68 | entries, err := os.ReadDir("/data") |
| 69 | if err != nil || len(entries) != 1 || entries[0].Name() != "instances" || !entries[0].IsDir() { |
| 70 | t.Fatalf("unexpected tenant data namespace: %+v, %v", entries, err) |
| 71 | } |
| 72 | for _, name := range []string{"cgroup.procs", "cgroup.threads", "cgroup.subtree_control"} { |
| 73 | path := filepath.Join("/sys/fs/cgroup/carlos.slice/carlos-edge.service", name) |
| 74 | info, err := os.Stat(path) |
| 75 | if err != nil || !info.Mode().IsRegular() { |
| 76 | t.Fatalf("delegated control fixture %s must exist: %v", path, err) |
| 77 | } |
| 78 | stat, ok := info.Sys().(*syscall.Stat_t) |
| 79 | if !ok || int(stat.Uid) != os.Geteuid() || info.Mode().Perm()&0200 == 0 { |
| 80 | t.Fatalf("%s must be delegated to the tenant UID before read-only mount enforcement", path) |
| 81 | } |
| 82 | f, err := os.OpenFile(path, os.O_WRONLY, 0) |
| 83 | if err == nil { |
| 84 | f.Close() |
| 85 | t.Fatalf("tenant can open %s for write", path) |
| 86 | } |
| 87 | if !errors.Is(err, syscall.EROFS) { |
| 88 | t.Fatalf("%s refusal must be EROFS, not missing fixture or DAC: %v", path, err) |
| 89 | } |
| 90 | } |
| 91 | // Opening cgroup.procs and cgroup.threads for write is necessary to move |
| 92 | // a process or thread. Their refusal proves migration is unavailable |
| 93 | // without attempting to move this process or alter another cgroup. |
| 94 | for _, path := range []string{ |
| 95 | "/data/.hostclaims/launches.db", |
| 96 | "/data/.hostclaims/inventory.db", |
| 97 | "/data/.hostguard/claim-isolation-fixture.json", |
| 98 | filepath.Join("/data/hostguard", host+".json"), |
| 99 | } { |
| 100 | f, err := os.OpenFile(path, os.O_WRONLY, 0) |
| 101 | if err == nil { |
| 102 | f.Close() |
| 103 | t.Fatalf("tenant can open journal %s for write", path) |
| 104 | } |
| 105 | if !errors.Is(err, syscall.ENOENT) { |
| 106 | t.Fatalf("journal %s must be hidden by the verified tenant data mount: %v", path, err) |
| 107 | } |
| 108 | } |
| 109 | } |
| 110 | |