| 1 | //go:build linux |
| 2 | |
| 3 | package host |
| 4 | |
| 5 | import ( |
| 6 | "bytes" |
| 7 | "errors" |
| 8 | "os" |
| 9 | "path/filepath" |
| 10 | "reflect" |
| 11 | "strings" |
| 12 | "testing" |
| 13 | |
| 14 | "github.com/carlosframework/platform/internal/hostbox" |
| 15 | "golang.org/x/sys/unix" |
| 16 | ) |
| 17 | |
| 18 | const servingOtherBoot = "01234567-89ab-4cde-8fab-0123456789ab" |
| 19 | |
| 20 | func TestServingParkBootIdentity(t *testing.T) { |
| 21 | f := newServingParkFS("/data", nil) |
| 22 | id, err := f.currentBoot() |
| 23 | if err != nil || id == "" || f.checkBoot(id) != nil { |
| 24 | t.Fatal("kernel boot identity", id, err) |
| 25 | } |
| 26 | for _, id := range []string{"", "not-a-boot", strings.Repeat("0", 36), "00000000-0000-0000-0000-000000000000", strings.ToUpper(servingOtherBoot), servingOtherBoot + "\n", "01234567-89ab-4cde-8fab-0123456789ag"} { |
| 27 | t.Run(id, func(t *testing.T) { |
| 28 | f.boot = func() (string, error) { return id, nil } |
| 29 | if _, err := f.currentBoot(); !errors.Is(err, hostbox.ErrChanged) { |
| 30 | t.Fatal("malformed boot accepted", err) |
| 31 | } |
| 32 | }) |
| 33 | } |
| 34 | f.boot = func() (string, error) { return "", unix.EIO } |
| 35 | if _, err := f.currentBoot(); !errors.Is(err, unix.EIO) { |
| 36 | t.Fatal("boot read failure hidden", err) |
| 37 | } |
| 38 | f.boot = nil |
| 39 | if _, err := f.currentBoot(); !errors.Is(err, hostbox.ErrUnsupported) { |
| 40 | t.Fatal("missing boot dependency accepted", err) |
| 41 | } |
| 42 | } |
| 43 | |
| 44 | // Change only the boot observation. Device, inode and mount number stay equal, |
| 45 | // reproducing the equality that a recycled mount ID can produce after reboot. |
| 46 | func TestServingParkBootReuseRefusesEffects(t *testing.T) { |
| 47 | for _, fault := range []string{"reboot", "missing-boot", "read-error", "malformed"} { |
| 48 | t.Run(fault, func(t *testing.T) { |
| 49 | f := newServingParkDiskFixture(t) |
| 50 | a, receipts := f.evidence(1, true, "missing") |
| 51 | f.put("", "last writer") |
| 52 | r, err := f.park(a, receipts) |
| 53 | if err != nil { |
| 54 | t.Fatal(err) |
| 55 | } |
| 56 | f.complete(a) |
| 57 | before, err := f.disk.Read("namespace.json") |
| 58 | if err != nil { |
| 59 | t.Fatal(err) |
| 60 | } |
| 61 | var n servingParkNamespace |
| 62 | if servingParkDecode(before, &n) != nil || n.BootID == "" || n.BootID == servingOtherBoot { |
| 63 | t.Fatal("fixture did not capture the current boot") |
| 64 | } |
| 65 | parent, id, err := f.fs.parent() |
| 66 | if err != nil { |
| 67 | t.Fatal(err) |
| 68 | } |
| 69 | parent.Close() |
| 70 | if id != r.Source.Parent { |
| 71 | t.Fatal("test changed more than the boot") |
| 72 | } |
| 73 | switch fault { |
| 74 | case "reboot": |
| 75 | f.fs.boot = func() (string, error) { return servingOtherBoot, nil } |
| 76 | case "missing-boot": |
| 77 | n.BootID = "" |
| 78 | if err := f.ledger.write("namespace.json", n); err != nil { |
| 79 | t.Fatal(err) |
| 80 | } |
| 81 | case "read-error": |
| 82 | f.fs.boot = func() (string, error) { return "", unix.EIO } |
| 83 | case "malformed": |
| 84 | f.fs.boot = func() (string, error) { return "invalid", nil } |
| 85 | } |
| 86 | before, err = f.disk.Read("namespace.json") |
| 87 | if err != nil { |
| 88 | t.Fatal(err) |
| 89 | } |
| 90 | writes := 0 |
| 91 | save := func() error { writes++; return nil } |
| 92 | for name, call := range map[string]func(*servingParkRecord) error{ |
| 93 | "inspect": f.fs.CheckParked, |
| 94 | "prepare": func(r *servingParkRecord) error { return f.fs.Prepare(r, save) }, |
| 95 | "move": func(r *servingParkRecord) error { return f.fs.Move(r, save) }, |
| 96 | "delete": func(r *servingParkRecord) error { return f.fs.Delete(r, save) }, |
| 97 | } { |
| 98 | copy := r |
| 99 | if err := call(©); err == nil || !reflect.DeepEqual(r, copy) || writes != 0 { |
| 100 | t.Fatal(name, "reused boot authority", err, writes) |
| 101 | } |
| 102 | } |
| 103 | after, err := f.disk.Read("namespace.json") |
| 104 | if err != nil || !bytes.Equal(before, after) { |
| 105 | t.Fatal("refusal rewrote namespace", err) |
| 106 | } |
| 107 | body, err := os.ReadFile(f.path(a, "")) |
| 108 | if err != nil || string(body) != "last writer" { |
| 109 | t.Fatal("refusal changed database", err) |
| 110 | } |
| 111 | }) |
| 112 | } |
| 113 | } |
| 114 | |
| 115 | func TestServingNamespaceBootRecoveryRefusesAdoption(t *testing.T) { |
| 116 | for _, boundary := range []string{"parent", "namespace", "quarantine", "old-parent", "old-namespace", "old-quarantine"} { |
| 117 | t.Run(boundary, func(t *testing.T) { |
| 118 | f := newServingRootFixture(t, true) |
| 119 | fs := f.p.ledger.fs.(*servingParkFS) |
| 120 | path := filepath.Join(fs.data, "instances", ".serving-parked") |
| 121 | if strings.Contains(boundary, "quarantine") { |
| 122 | if err := os.Mkdir(path, 0700); err != nil { |
| 123 | t.Fatal(err) |
| 124 | } |
| 125 | if err := os.WriteFile(filepath.Join(path, "unknown"), []byte("retain"), 0600); err != nil { |
| 126 | t.Fatal(err) |
| 127 | } |
| 128 | } |
| 129 | a, err := f.park() |
| 130 | if err != nil { |
| 131 | t.Fatal(err) |
| 132 | } |
| 133 | anchor, err := fs.namespaceParent() |
| 134 | if err != nil || anchor.BootID == "" { |
| 135 | t.Fatal("missing boot anchor", err) |
| 136 | } |
| 137 | name := "namespace-parent.json" |
| 138 | switch boundary { |
| 139 | case "namespace", "old-namespace": |
| 140 | name = "namespace.json" |
| 141 | case "quarantine", "old-quarantine": |
| 142 | name = "namespace-quarantine.json" |
| 143 | } |
| 144 | if strings.HasPrefix(boundary, "old-") { |
| 145 | body, err := f.disk.Read(name) |
| 146 | if err != nil { |
| 147 | t.Fatal(err) |
| 148 | } |
| 149 | body = bytes.ReplaceAll(body, []byte(`"BootID":"`+anchor.BootID+`",`), nil) |
| 150 | if err := f.disk.Write(name, body); err != nil { |
| 151 | t.Fatal(err) |
| 152 | } |
| 153 | } else { |
| 154 | fs.boot = func() (string, error) { return servingOtherBoot, nil } |
| 155 | } |
| 156 | before := servingBootSnapshot(t, f.disk.dir) |
| 157 | claim := f.f.read() |
| 158 | if err := fs.recoverRecordedNamespace(t.Context()); err == nil { |
| 159 | t.Fatal("startup accepted missing or changed boot") |
| 160 | } |
| 161 | if _, err := fs.recoverNamespace(t.Context(), anchor.Parent); err == nil { |
| 162 | t.Fatal("quarantine accepted missing or changed boot") |
| 163 | } |
| 164 | if err := fs.recordNamespaceParent(t.Context(), a.Successor, a.Directory); err == nil { |
| 165 | // A valid existing anchor need not examine the namespace, but it |
| 166 | // must never overwrite the earlier boot or infer a missing one. |
| 167 | if boundary != "old-namespace" && boundary != "old-quarantine" { |
| 168 | t.Fatal("authenticated parent adopted old boot") |
| 169 | } |
| 170 | } |
| 171 | if after := servingBootSnapshot(t, f.disk.dir); !reflect.DeepEqual(before, after) || !reflect.DeepEqual(claim, f.f.read()) { |
| 172 | t.Fatal("recovery refusal changed private evidence or claim") |
| 173 | } |
| 174 | }) |
| 175 | } |
| 176 | } |
| 177 | |
| 178 | func servingBootSnapshot(t *testing.T, root string) map[string]string { |
| 179 | t.Helper() |
| 180 | names, err := os.ReadDir(root) |
| 181 | if err != nil { |
| 182 | t.Fatal(err) |
| 183 | } |
| 184 | out := make(map[string]string) |
| 185 | for _, name := range names { |
| 186 | if name.IsDir() { |
| 187 | continue |
| 188 | } |
| 189 | body, err := os.ReadFile(filepath.Join(root, name.Name())) |
| 190 | if err != nil { |
| 191 | t.Fatal(err) |
| 192 | } |
| 193 | out[name.Name()] = string(body) |
| 194 | } |
| 195 | return out |
| 196 | } |
| 197 | |