package hostfloor import ( "context" "crypto/ed25519" "crypto/rand" "encoding/json" "errors" "os" "path/filepath" "strings" "syscall" "testing" "github.com/carlosframework/platform/internal/release" ) type plan9aOfflineStore struct{ release.Store } func (plan9aOfflineStore) Get(context.Context, string) ([]byte, string, error) { return nil, "", context.DeadlineExceeded } // I-1: an armed box before publication finds neither remote object. Its // local policy already enforces the floor, so guard-exec accepts it. A half // pair, a malformed object or a different digest still refuses, and only // "armed" gets the absent-pair reading. func TestPlan9ARemotePolicyArmedBeforePublication(t *testing.T) { pub, key, _ := ed25519.GenerateKey(rand.Reader) sg := release.RootSigner(key) ctx := context.Background() floor := Floor{1, "v1.2.3"} raw, _ := json.Marshal(floor) auth, err := release.SignRootEnvelope(sg, Authorization{1, "moves-protocol", "bucket", Key, Digest(raw)}) if err != nil { t.Fatal(err) } local, err := Verify(pub, "bucket", raw, auth) if err != nil { t.Fatal(err) } armed := Policy{1, "bucket", "armed", raw, auth} enforced := Policy{1, "bucket", "enforced", raw, auth} empty := release.NewDirStore(t.TempDir()) if err := CheckRemotePolicy(ctx, empty, pub, "bucket", armed, local); err != nil { t.Fatalf("armed box refused before publication: %v", err) } if err := CheckRemotePolicy(ctx, empty, pub, "bucket", enforced, local); err == nil { t.Fatal("enforced box accepted a missing remote floor") } authOnly := release.NewDirStore(t.TempDir()) if _, err := authOnly.Put(ctx, AuthKey, auth, ""); err != nil { t.Fatal(err) } floorOnly := release.NewDirStore(t.TempDir()) if _, err := floorOnly.Put(ctx, Key, raw, ""); err != nil { t.Fatal(err) } for name, s := range map[string]release.Store{"auth-only": authOnly, "floor-only": floorOnly} { if err := CheckRemotePolicy(ctx, s, pub, "bucket", armed, local); err == nil { t.Fatalf("%s: half-written pair accepted", name) } } malformed := release.NewDirStore(t.TempDir()) if _, err := malformed.Put(ctx, AuthKey, auth, ""); err != nil { t.Fatal(err) } if _, err := malformed.Put(ctx, Key, []byte("{"), ""); err != nil { t.Fatal(err) } if err := CheckRemotePolicy(ctx, malformed, pub, "bucket", armed, local); err == nil { t.Fatal("malformed remote floor accepted") } other := release.NewDirStore(t.TempDir()) if err := Publish(ctx, other, sg, "bucket", Floor{2, "v1.2.4"}); err != nil { t.Fatal(err) } if err := CheckRemotePolicy(ctx, other, pub, "bucket", armed, local); !errors.Is(err, ErrFloor) { t.Fatalf("different remote digest = %v", err) } same := release.NewDirStore(t.TempDir()) if err := Publish(ctx, same, sg, "bucket", floor); err != nil { t.Fatal(err) } for _, p := range []Policy{armed, enforced} { if err := CheckRemotePolicy(ctx, same, pub, "bucket", p, local); err != nil { t.Fatalf("%s: matching floor refused: %v", p.Mode, err) } } if err := CheckRemotePolicy(ctx, plan9aOfflineStore{}, pub, "bucket", enforced, local); err != nil { t.Fatalf("offline store refused (main accepts it): %v", err) } } type plan9aMixedStore struct { release.Store rawErr, authErr error reads []string } func (s *plan9aMixedStore) Get(_ context.Context, key string) ([]byte, string, error) { s.reads = append(s.reads, key) if key == Key { return nil, "", s.rawErr } return nil, "", s.authErr } func TestPlan9ARemotePolicyMixedErrors(t *testing.T) { for _, mode := range []string{"armed", "enforced"} { for _, tc := range []struct { name string raw, auth, want error reads int }{ {"missing-then-offline", os.ErrNotExist, context.DeadlineExceeded, os.ErrNotExist, 1}, {"denied-then-offline", os.ErrPermission, context.DeadlineExceeded, os.ErrPermission, 1}, {"missing-then-denied", os.ErrNotExist, os.ErrPermission, os.ErrNotExist, 1}, {"offline-then-denied", context.DeadlineExceeded, os.ErrPermission, nil, 1}, {"ok-then-offline", nil, context.DeadlineExceeded, nil, 2}, {"ok-then-denied", nil, os.ErrPermission, os.ErrPermission, 2}, {"ok-then-missing", nil, os.ErrNotExist, os.ErrNotExist, 2}, } { t.Run(mode+"/"+tc.name, func(t *testing.T) { s := &plan9aMixedStore{rawErr: tc.raw, authErr: tc.auth} err := CheckRemotePolicy(context.Background(), s, nil, "bucket", Policy{Mode: mode}, Verified{}) if !errors.Is(err, tc.want) { t.Fatalf("got %v, want %v", err, tc.want) } wantReads := tc.reads if mode == "armed" && errors.Is(tc.raw, os.ErrNotExist) { wantReads = 2 } if len(s.reads) != wantReads || s.reads[0] != Key || (wantReads == 2 && s.reads[1] != AuthKey) { t.Fatalf("reads = %v, want %d in Key/AuthKey order", s.reads, wantReads) } }) } } } // M1-2: root creates the controller directory the armed path requires, // owned by the controllers' account, without following a planted link. func TestPlan9AControllerDirCreatedWithoutFollowingLinks(t *testing.T) { uid, gid := os.Getuid(), os.Getgid() check := func(t *testing.T, dir string) { t.Helper() fi, err := os.Lstat(dir) if err != nil { t.Fatal(err) } st := fi.Sys().(*syscall.Stat_t) if !fi.IsDir() || fi.Mode().Perm() != 0o770 || fi.Mode()&os.ModeSetgid == 0 || int(st.Uid) != uid || int(st.Gid) != gid { t.Fatalf("controller dir mode=%v uid=%d gid=%d", fi.Mode(), st.Uid, st.Gid) } } t.Run("absent", func(t *testing.T) { data := t.TempDir() if err := CheckControllersIdle(t.Context(), data); err == nil { t.Fatal("precondition: armed path accepted a missing directory") } if err := EnsureControllerDir(data, uid, gid); err != nil { t.Fatal(err) } check(t, filepath.Join(data, ".hostexec")) if err := (Disk{Participation: floorParticipationFixture(t)}).ControllersIdle(t.Context(), data); err != nil { t.Fatalf("complete root census refused: %v", err) } if err := EnsureControllerDir(data, uid, gid); err != nil { t.Fatalf("second run: %v", err) } }) t.Run("existing-loose", func(t *testing.T) { data := t.TempDir() dir := filepath.Join(data, ".hostexec") if err := os.Mkdir(dir, 0o777); err != nil { t.Fatal(err) } if err := os.Chmod(dir, 0o777); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dir, "host.json"), []byte("{}"), 0o600); err != nil { t.Fatal(err) } if err := EnsureControllerDir(data, uid, gid); err != nil { t.Fatal(err) } check(t, dir) if _, err := os.Stat(filepath.Join(dir, "host.json")); err != nil { t.Fatal("controller record discarded", err) } }) t.Run("symlink", func(t *testing.T) { data, elsewhere := t.TempDir(), t.TempDir() if err := os.Chmod(elsewhere, 0o755); err != nil { t.Fatal(err) } if err := os.Symlink(elsewhere, filepath.Join(data, ".hostexec")); err != nil { t.Skip(err) } if err := EnsureControllerDir(data, uid, gid); err == nil { t.Fatal("followed a planted symlink") } fi, err := os.Stat(elsewhere) if err != nil || fi.Mode().Perm() != 0o755 || fi.Mode()&os.ModeSetgid != 0 { t.Fatalf("link target changed: %v %v", fi.Mode(), err) } }) t.Run("file", func(t *testing.T) { data := t.TempDir() if err := os.WriteFile(filepath.Join(data, ".hostexec"), nil, 0o600); err != nil { t.Fatal(err) } if err := EnsureControllerDir(data, uid, gid); err == nil { t.Fatal("accepted a regular file") } }) t.Run("data-symlink", func(t *testing.T) { parent := t.TempDir() real := t.TempDir() link := filepath.Join(parent, "data") if err := os.Symlink(real, link); err != nil { t.Skip(err) } if err := EnsureControllerDir(link, uid, gid); err == nil { t.Fatal("followed a symlinked data directory") } if _, err := os.Lstat(filepath.Join(real, ".hostexec")); !errors.Is(err, os.ErrNotExist) { t.Fatalf("created through the link: %v", err) } }) t.Run("relative", func(t *testing.T) { if err := EnsureControllerDir("data", uid, gid); err == nil { t.Fatal("accepted a relative data directory") } }) } // M1-4: a rollback is recorded in root's own marker before the journal goes, // so completion can tell a finished rollback from an update that never // changed the box. func TestPlan9AMarkerRecordsRollback(t *testing.T) { d := Disk{Dir: t.TempDir(), Participation: floorParticipationFixture(t)} data := t.TempDir() if err := os.Mkdir(filepath.Join(data, ".hostexec"), 0o700); err != nil { t.Fatal(err) } if err := MarkRolledBack(d.Dir, "tx"); !errors.Is(err, os.ErrNotExist) { t.Fatalf("absent marker = %v", err) } if _, err := os.Stat(filepath.Join(d.Dir, "update-begun.json")); !errors.Is(err, os.ErrNotExist) { t.Fatal("marking created a marker", err) } if err := d.Begin(t.Context(), "tx", data); err != nil { t.Fatal(err) } if b, err := ReadBegun(d.Dir); err != nil || b.ID != "tx" || b.RolledBack { t.Fatalf("fresh marker = %+v, %v", b, err) } before, _ := os.ReadFile(filepath.Join(d.Dir, "update-begun.json")) if err := MarkRolledBack(d.Dir, "other"); !errors.Is(err, ErrFloor) { t.Fatalf("foreign marker = %v", err) } if after, _ := os.ReadFile(filepath.Join(d.Dir, "update-begun.json")); string(after) != string(before) { t.Fatal("foreign marking rewrote the marker") } for range 2 { if err := MarkRolledBack(d.Dir, "tx"); err != nil { t.Fatal(err) } } b, err := ReadBegun(d.Dir) if err != nil || b.ID != "tx" || !b.RolledBack { t.Fatalf("marked = %+v, %v", b, err) } raw, _ := os.ReadFile(filepath.Join(d.Dir, "update-begun.json")) if !strings.Contains(string(raw), `"id":"tx"`) { t.Fatalf("marker lost its transaction id: %s", raw) } if err := os.WriteFile(filepath.Join(d.Dir, "update-begun.json"), []byte(`{"id":"tx","rolled_back":"yes"}`), 0o644); err != nil { t.Fatal(err) } if _, err := ReadBegun(d.Dir); err == nil { t.Fatal("malformed marker read") } }