| 1 | package hostfloor |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "crypto/ed25519" |
| 6 | "crypto/rand" |
| 7 | "encoding/json" |
| 8 | "errors" |
| 9 | "os" |
| 10 | "path/filepath" |
| 11 | "strings" |
| 12 | "syscall" |
| 13 | "testing" |
| 14 | |
| 15 | "github.com/carlosframework/platform/internal/release" |
| 16 | ) |
| 17 | |
| 18 | type plan9aOfflineStore struct{ release.Store } |
| 19 | |
| 20 | func (plan9aOfflineStore) Get(context.Context, string) ([]byte, string, error) { |
| 21 | return nil, "", context.DeadlineExceeded |
| 22 | } |
| 23 | |
| 24 | // I-1: an armed box before publication finds neither remote object. Its |
| 25 | // local policy already enforces the floor, so guard-exec accepts it. A half |
| 26 | // pair, a malformed object or a different digest still refuses, and only |
| 27 | // "armed" gets the absent-pair reading. |
| 28 | func TestPlan9ARemotePolicyArmedBeforePublication(t *testing.T) { |
| 29 | pub, key, _ := ed25519.GenerateKey(rand.Reader) |
| 30 | sg := release.RootSigner(key) |
| 31 | ctx := context.Background() |
| 32 | floor := Floor{1, "v1.2.3"} |
| 33 | raw, _ := json.Marshal(floor) |
| 34 | auth, err := release.SignRootEnvelope(sg, Authorization{1, "moves-protocol", "bucket", Key, Digest(raw)}) |
| 35 | if err != nil { |
| 36 | t.Fatal(err) |
| 37 | } |
| 38 | local, err := Verify(pub, "bucket", raw, auth) |
| 39 | if err != nil { |
| 40 | t.Fatal(err) |
| 41 | } |
| 42 | armed := Policy{1, "bucket", "armed", raw, auth} |
| 43 | enforced := Policy{1, "bucket", "enforced", raw, auth} |
| 44 | |
| 45 | empty := release.NewDirStore(t.TempDir()) |
| 46 | if err := CheckRemotePolicy(ctx, empty, pub, "bucket", armed, local); err != nil { |
| 47 | t.Fatalf("armed box refused before publication: %v", err) |
| 48 | } |
| 49 | if err := CheckRemotePolicy(ctx, empty, pub, "bucket", enforced, local); err == nil { |
| 50 | t.Fatal("enforced box accepted a missing remote floor") |
| 51 | } |
| 52 | |
| 53 | authOnly := release.NewDirStore(t.TempDir()) |
| 54 | if _, err := authOnly.Put(ctx, AuthKey, auth, ""); err != nil { |
| 55 | t.Fatal(err) |
| 56 | } |
| 57 | floorOnly := release.NewDirStore(t.TempDir()) |
| 58 | if _, err := floorOnly.Put(ctx, Key, raw, ""); err != nil { |
| 59 | t.Fatal(err) |
| 60 | } |
| 61 | for name, s := range map[string]release.Store{"auth-only": authOnly, "floor-only": floorOnly} { |
| 62 | if err := CheckRemotePolicy(ctx, s, pub, "bucket", armed, local); err == nil { |
| 63 | t.Fatalf("%s: half-written pair accepted", name) |
| 64 | } |
| 65 | } |
| 66 | |
| 67 | malformed := release.NewDirStore(t.TempDir()) |
| 68 | if _, err := malformed.Put(ctx, AuthKey, auth, ""); err != nil { |
| 69 | t.Fatal(err) |
| 70 | } |
| 71 | if _, err := malformed.Put(ctx, Key, []byte("{"), ""); err != nil { |
| 72 | t.Fatal(err) |
| 73 | } |
| 74 | if err := CheckRemotePolicy(ctx, malformed, pub, "bucket", armed, local); err == nil { |
| 75 | t.Fatal("malformed remote floor accepted") |
| 76 | } |
| 77 | |
| 78 | other := release.NewDirStore(t.TempDir()) |
| 79 | if err := Publish(ctx, other, sg, "bucket", Floor{2, "v1.2.4"}); err != nil { |
| 80 | t.Fatal(err) |
| 81 | } |
| 82 | if err := CheckRemotePolicy(ctx, other, pub, "bucket", armed, local); !errors.Is(err, ErrFloor) { |
| 83 | t.Fatalf("different remote digest = %v", err) |
| 84 | } |
| 85 | |
| 86 | same := release.NewDirStore(t.TempDir()) |
| 87 | if err := Publish(ctx, same, sg, "bucket", floor); err != nil { |
| 88 | t.Fatal(err) |
| 89 | } |
| 90 | for _, p := range []Policy{armed, enforced} { |
| 91 | if err := CheckRemotePolicy(ctx, same, pub, "bucket", p, local); err != nil { |
| 92 | t.Fatalf("%s: matching floor refused: %v", p.Mode, err) |
| 93 | } |
| 94 | } |
| 95 | if err := CheckRemotePolicy(ctx, plan9aOfflineStore{}, pub, "bucket", enforced, local); err != nil { |
| 96 | t.Fatalf("offline store refused (main accepts it): %v", err) |
| 97 | } |
| 98 | } |
| 99 | |
| 100 | type plan9aMixedStore struct { |
| 101 | release.Store |
| 102 | rawErr, authErr error |
| 103 | reads []string |
| 104 | } |
| 105 | |
| 106 | func (s *plan9aMixedStore) Get(_ context.Context, key string) ([]byte, string, error) { |
| 107 | s.reads = append(s.reads, key) |
| 108 | if key == Key { |
| 109 | return nil, "", s.rawErr |
| 110 | } |
| 111 | return nil, "", s.authErr |
| 112 | } |
| 113 | |
| 114 | func TestPlan9ARemotePolicyMixedErrors(t *testing.T) { |
| 115 | for _, mode := range []string{"armed", "enforced"} { |
| 116 | for _, tc := range []struct { |
| 117 | name string |
| 118 | raw, auth, want error |
| 119 | reads int |
| 120 | }{ |
| 121 | {"missing-then-offline", os.ErrNotExist, context.DeadlineExceeded, os.ErrNotExist, 1}, |
| 122 | {"denied-then-offline", os.ErrPermission, context.DeadlineExceeded, os.ErrPermission, 1}, |
| 123 | {"missing-then-denied", os.ErrNotExist, os.ErrPermission, os.ErrNotExist, 1}, |
| 124 | {"offline-then-denied", context.DeadlineExceeded, os.ErrPermission, nil, 1}, |
| 125 | {"ok-then-offline", nil, context.DeadlineExceeded, nil, 2}, |
| 126 | {"ok-then-denied", nil, os.ErrPermission, os.ErrPermission, 2}, |
| 127 | {"ok-then-missing", nil, os.ErrNotExist, os.ErrNotExist, 2}, |
| 128 | } { |
| 129 | t.Run(mode+"/"+tc.name, func(t *testing.T) { |
| 130 | s := &plan9aMixedStore{rawErr: tc.raw, authErr: tc.auth} |
| 131 | err := CheckRemotePolicy(context.Background(), s, nil, "bucket", Policy{Mode: mode}, Verified{}) |
| 132 | if !errors.Is(err, tc.want) { |
| 133 | t.Fatalf("got %v, want %v", err, tc.want) |
| 134 | } |
| 135 | wantReads := tc.reads |
| 136 | if mode == "armed" && errors.Is(tc.raw, os.ErrNotExist) { |
| 137 | wantReads = 2 |
| 138 | } |
| 139 | if len(s.reads) != wantReads || s.reads[0] != Key || (wantReads == 2 && s.reads[1] != AuthKey) { |
| 140 | t.Fatalf("reads = %v, want %d in Key/AuthKey order", s.reads, wantReads) |
| 141 | } |
| 142 | }) |
| 143 | } |
| 144 | } |
| 145 | } |
| 146 | |
| 147 | // M1-2: root creates the controller directory the armed path requires, |
| 148 | // owned by the controllers' account, without following a planted link. |
| 149 | func TestPlan9AControllerDirCreatedWithoutFollowingLinks(t *testing.T) { |
| 150 | uid, gid := os.Getuid(), os.Getgid() |
| 151 | check := func(t *testing.T, dir string) { |
| 152 | t.Helper() |
| 153 | fi, err := os.Lstat(dir) |
| 154 | if err != nil { |
| 155 | t.Fatal(err) |
| 156 | } |
| 157 | st := fi.Sys().(*syscall.Stat_t) |
| 158 | if !fi.IsDir() || fi.Mode().Perm() != 0o770 || fi.Mode()&os.ModeSetgid == 0 || int(st.Uid) != uid || int(st.Gid) != gid { |
| 159 | t.Fatalf("controller dir mode=%v uid=%d gid=%d", fi.Mode(), st.Uid, st.Gid) |
| 160 | } |
| 161 | } |
| 162 | t.Run("absent", func(t *testing.T) { |
| 163 | data := t.TempDir() |
| 164 | if err := CheckControllersIdle(t.Context(), data); err == nil { |
| 165 | t.Fatal("precondition: armed path accepted a missing directory") |
| 166 | } |
| 167 | if err := EnsureControllerDir(data, uid, gid); err != nil { |
| 168 | t.Fatal(err) |
| 169 | } |
| 170 | check(t, filepath.Join(data, ".hostexec")) |
| 171 | if err := (Disk{Participation: floorParticipationFixture(t)}).ControllersIdle(t.Context(), data); err != nil { |
| 172 | t.Fatalf("complete root census refused: %v", err) |
| 173 | } |
| 174 | if err := EnsureControllerDir(data, uid, gid); err != nil { |
| 175 | t.Fatalf("second run: %v", err) |
| 176 | } |
| 177 | }) |
| 178 | t.Run("existing-loose", func(t *testing.T) { |
| 179 | data := t.TempDir() |
| 180 | dir := filepath.Join(data, ".hostexec") |
| 181 | if err := os.Mkdir(dir, 0o777); err != nil { |
| 182 | t.Fatal(err) |
| 183 | } |
| 184 | if err := os.Chmod(dir, 0o777); err != nil { |
| 185 | t.Fatal(err) |
| 186 | } |
| 187 | if err := os.WriteFile(filepath.Join(dir, "host.json"), []byte("{}"), 0o600); err != nil { |
| 188 | t.Fatal(err) |
| 189 | } |
| 190 | if err := EnsureControllerDir(data, uid, gid); err != nil { |
| 191 | t.Fatal(err) |
| 192 | } |
| 193 | check(t, dir) |
| 194 | if _, err := os.Stat(filepath.Join(dir, "host.json")); err != nil { |
| 195 | t.Fatal("controller record discarded", err) |
| 196 | } |
| 197 | }) |
| 198 | t.Run("symlink", func(t *testing.T) { |
| 199 | data, elsewhere := t.TempDir(), t.TempDir() |
| 200 | if err := os.Chmod(elsewhere, 0o755); err != nil { |
| 201 | t.Fatal(err) |
| 202 | } |
| 203 | if err := os.Symlink(elsewhere, filepath.Join(data, ".hostexec")); err != nil { |
| 204 | t.Skip(err) |
| 205 | } |
| 206 | if err := EnsureControllerDir(data, uid, gid); err == nil { |
| 207 | t.Fatal("followed a planted symlink") |
| 208 | } |
| 209 | fi, err := os.Stat(elsewhere) |
| 210 | if err != nil || fi.Mode().Perm() != 0o755 || fi.Mode()&os.ModeSetgid != 0 { |
| 211 | t.Fatalf("link target changed: %v %v", fi.Mode(), err) |
| 212 | } |
| 213 | }) |
| 214 | t.Run("file", func(t *testing.T) { |
| 215 | data := t.TempDir() |
| 216 | if err := os.WriteFile(filepath.Join(data, ".hostexec"), nil, 0o600); err != nil { |
| 217 | t.Fatal(err) |
| 218 | } |
| 219 | if err := EnsureControllerDir(data, uid, gid); err == nil { |
| 220 | t.Fatal("accepted a regular file") |
| 221 | } |
| 222 | }) |
| 223 | t.Run("data-symlink", func(t *testing.T) { |
| 224 | parent := t.TempDir() |
| 225 | real := t.TempDir() |
| 226 | link := filepath.Join(parent, "data") |
| 227 | if err := os.Symlink(real, link); err != nil { |
| 228 | t.Skip(err) |
| 229 | } |
| 230 | if err := EnsureControllerDir(link, uid, gid); err == nil { |
| 231 | t.Fatal("followed a symlinked data directory") |
| 232 | } |
| 233 | if _, err := os.Lstat(filepath.Join(real, ".hostexec")); !errors.Is(err, os.ErrNotExist) { |
| 234 | t.Fatalf("created through the link: %v", err) |
| 235 | } |
| 236 | }) |
| 237 | t.Run("relative", func(t *testing.T) { |
| 238 | if err := EnsureControllerDir("data", uid, gid); err == nil { |
| 239 | t.Fatal("accepted a relative data directory") |
| 240 | } |
| 241 | }) |
| 242 | } |
| 243 | |
| 244 | // M1-4: a rollback is recorded in root's own marker before the journal goes, |
| 245 | // so completion can tell a finished rollback from an update that never |
| 246 | // changed the box. |
| 247 | func TestPlan9AMarkerRecordsRollback(t *testing.T) { |
| 248 | d := Disk{Dir: t.TempDir(), Participation: floorParticipationFixture(t)} |
| 249 | data := t.TempDir() |
| 250 | if err := os.Mkdir(filepath.Join(data, ".hostexec"), 0o700); err != nil { |
| 251 | t.Fatal(err) |
| 252 | } |
| 253 | if err := MarkRolledBack(d.Dir, "tx"); !errors.Is(err, os.ErrNotExist) { |
| 254 | t.Fatalf("absent marker = %v", err) |
| 255 | } |
| 256 | if _, err := os.Stat(filepath.Join(d.Dir, "update-begun.json")); !errors.Is(err, os.ErrNotExist) { |
| 257 | t.Fatal("marking created a marker", err) |
| 258 | } |
| 259 | if err := d.Begin(t.Context(), "tx", data); err != nil { |
| 260 | t.Fatal(err) |
| 261 | } |
| 262 | if b, err := ReadBegun(d.Dir); err != nil || b.ID != "tx" || b.RolledBack { |
| 263 | t.Fatalf("fresh marker = %+v, %v", b, err) |
| 264 | } |
| 265 | before, _ := os.ReadFile(filepath.Join(d.Dir, "update-begun.json")) |
| 266 | if err := MarkRolledBack(d.Dir, "other"); !errors.Is(err, ErrFloor) { |
| 267 | t.Fatalf("foreign marker = %v", err) |
| 268 | } |
| 269 | if after, _ := os.ReadFile(filepath.Join(d.Dir, "update-begun.json")); string(after) != string(before) { |
| 270 | t.Fatal("foreign marking rewrote the marker") |
| 271 | } |
| 272 | for range 2 { |
| 273 | if err := MarkRolledBack(d.Dir, "tx"); err != nil { |
| 274 | t.Fatal(err) |
| 275 | } |
| 276 | } |
| 277 | b, err := ReadBegun(d.Dir) |
| 278 | if err != nil || b.ID != "tx" || !b.RolledBack { |
| 279 | t.Fatalf("marked = %+v, %v", b, err) |
| 280 | } |
| 281 | raw, _ := os.ReadFile(filepath.Join(d.Dir, "update-begun.json")) |
| 282 | if !strings.Contains(string(raw), `"id":"tx"`) { |
| 283 | t.Fatalf("marker lost its transaction id: %s", raw) |
| 284 | } |
| 285 | if err := os.WriteFile(filepath.Join(d.Dir, "update-begun.json"), []byte(`{"id":"tx","rolled_back":"yes"}`), 0o644); err != nil { |
| 286 | t.Fatal(err) |
| 287 | } |
| 288 | if _, err := ReadBegun(d.Dir); err == nil { |
| 289 | t.Fatal("malformed marker read") |
| 290 | } |
| 291 | } |
| 292 | |