seeds: ids, keys, states, the verify token and the row column (Plan 2 Task 1)
Key builders refuse an invalid host or id by returning a key under seeds/!invalid/ (nothing lives there); Check returns ErrBadHost/ErrBadID for doors that want a sentinel. Hosts use the repo's host-shape validator.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4 files changed,
+396
−0
internal/seeds/row.go+48 −0internal/seeds/seeds.go+136 −0internal/seeds/seeds_test.go+171 −0internal/seeds/token.go+41 −0
CI passed — run details
diff --git a/internal/seeds/row.go b/internal/seeds/row.go| new file mode 100644 |
| index 000000000..fecb874db |
| --- /dev/null |
| +++ b/internal/seeds/row.go |
| @@ -0,0 +1,48 @@ |
| +package seeds |
| + |
| +import ( |
| + "encoding/json" |
| + "fmt" |
| +) |
| + |
| +// RowSeed is what a registry row carries of its record's seed (column |
| +// seed_source): enough for the activator to place the snapshot on first |
| +// wake (spec §3.4) without reading the record. |
| +type RowSeed struct { |
| + ID string `json:"id"` |
| + Kind string `json:"kind"` |
| + SHA256 string `json:"sha256,omitempty"` |
| + Bytes int64 `json:"bytes,omitempty"` |
| + State string `json:"state"` |
| +} |
| + |
| +// Encode is the column's value: compact JSON in field order. |
| +func (r RowSeed) Encode() string { |
| + b, _ := json.Marshal(r) // strings and an int64 cannot fail to marshal |
| + return string(b) |
| +} |
| + |
| +// ParseRowSeed reads a seed_source column. "" is no seed: (nil, nil). |
| +func ParseRowSeed(s string) (*RowSeed, error) { |
| + if s == "" { |
| + return nil, nil |
| + } |
| + var r RowSeed |
| + if err := json.Unmarshal([]byte(s), &r); err != nil { |
| + return nil, fmt.Errorf("seeds: seed_source column: %w", err) |
| + } |
| + if !ValidID(r.ID) { |
| + return nil, fmt.Errorf("seeds: seed_source column: id %q is not a seed id", r.ID) |
| + } |
| + return &r, nil |
| +} |
| + |
| +// Placeable reports whether the restore fallback applies (spec §3.4: a row |
| +// whose record seed is ready, swapped or placed). |
| +func (r RowSeed) Placeable() bool { |
| + switch r.State { |
| + case StateReady, StateSwapped, StatePlaced: |
| + return true |
| + } |
| + return false |
| +} |
diff --git a/internal/seeds/seeds.go b/internal/seeds/seeds.go| new file mode 100644 |
| index 000000000..06874ee0f |
| --- /dev/null |
| +++ b/internal/seeds/seeds.go |
| @@ -0,0 +1,136 @@ |
| +// Package seeds is the shared vocabulary of instances that start from a |
| +// database (#260 --from, #261 import): seed ids, the snapshot's bucket keys, |
| +// the states a record's seed moves through, the verify token, and the |
| +// registry column the box reads. It holds no I/O. Spec: |
| +// docs/superpowers/specs/2026-10-08-seeded-instances-design.md. |
| +// |
| +// It must never import internal/moves (the moves copy gate inventories every |
| +// changed file that does). |
| +package seeds |
| + |
| +import ( |
| + "crypto/rand" |
| + "encoding/base32" |
| + "errors" |
| + "fmt" |
| + "io" |
| + "regexp" |
| + "strings" |
| + |
| + "github.com/carlosframework/platform/internal/controlname" |
| +) |
| + |
| +// The two kinds of seed (spec §3.2). |
| +const ( |
| + KindReplica = "replica" // --from: a sibling's replica |
| + KindImport = "import" // an uploaded snapshot |
| +) |
| + |
| +// The states of spec §3.2. |
| +const ( |
| + StateReserving = "reserving" |
| + StateAwaitingUpload = "awaiting-upload" |
| + StateUploaded = "uploaded" |
| + StateReady = "ready" |
| + StateSwapped = "swapped" |
| + StatePlaced = "placed" |
| + StateCommitted = "committed" |
| + StateRefused = "refused" |
| + StateAborted = "aborted" |
| + StateRolledBack = "rolled-back" |
| +) |
| + |
| +// MaxBytes is the largest database either door accepts (spec §4 step 5, |
| +// §5.1): 4 GiB, under S3's 5 GB single-PUT limit. |
| +const MaxBytes int64 = 4 << 30 |
| + |
| +// Prefix is the snapshots' prefix in a region's data bucket, beside |
| +// instances/ and moves/ (spec §3.1). |
| +const Prefix = "seeds/" |
| + |
| +const snapshotName = "snapshot.db" |
| + |
| +// invalidHost stands in for a host or id that failed validation. It is not a |
| +// host shape (the "!"), so no real seed lives under it: a read of such a key |
| +// finds nothing and a delete of its prefix removes nothing. |
| +const invalidHost = "!invalid" |
| + |
| +// ErrBadHost and ErrBadID are what Check returns. A door that takes a host |
| +// or id from a request or a record calls Check first and refuses with them. |
| +var ( |
| + ErrBadHost = errors.New("seeds: not a host") |
| + ErrBadID = errors.New("seeds: not a seed id") |
| +) |
| + |
| +var idShape = regexp.MustCompile(`^sd_[a-z2-7]{26}$`) |
| + |
| +var lowerBase32 = base32.NewEncoding("abcdefghijklmnopqrstuvwxyz234567").WithPadding(base32.NoPadding) |
| + |
| +// randReader is crypto/rand's reader, a variable so a test can fail it. |
| +var randReader io.Reader = rand.Reader |
| + |
| +// NewID mints a seed id: "sd_" and 128 random bits in lowercase base32. An |
| +// id is never reused (spec §3.2): every cleanup is scoped to one. Ids come |
| +// only from crypto/rand. |
| +func NewID() (string, error) { |
| + var b [16]byte |
| + if _, err := io.ReadFull(randReader, b[:]); err != nil { |
| + return "", fmt.Errorf("seeds: mint an id: %w", err) |
| + } |
| + return "sd_" + lowerBase32.EncodeToString(b[:]), nil |
| +} |
| + |
| +// ValidID reports whether id has NewID's shape. Every id that passes is 29 |
| +// bytes of [a-z2-7_] and so is one clean path element: no slash, backslash |
| +// or dot. |
| +func ValidID(id string) bool { return idShape.MatchString(id) } |
| + |
| +// Check reports ErrBadHost if host is not a host shape (lowercase DNS labels, |
| +// so one clean path element with no slash or "..") and ErrBadID if id is not |
| +// a seed id. |
| +func Check(host, id string) error { |
| + if !controlname.ValidHostShape(host) { |
| + return ErrBadHost |
| + } |
| + if !ValidID(id) { |
| + return ErrBadID |
| + } |
| + return nil |
| +} |
| + |
| +// HostPrefix is every seed of host: seeds/<host>/. A host that is not a host |
| +// shape yields seeds/!invalid/, which names nothing. |
| +func HostPrefix(host string) string { |
| + if !controlname.ValidHostShape(host) { |
| + host = invalidHost |
| + } |
| + return Prefix + host + "/" |
| +} |
| + |
| +// SnapshotPrefix is one seed's prefix: seeds/<host>/<id>/. Every delete of |
| +// a snapshot names it (codex R4-1). A host or id that fails Check yields |
| +// seeds/!invalid/invalid/, so no key built here leaves seeds/<host>/<id>/ and |
| +// no prefix built here covers more than one seed. |
| +func SnapshotPrefix(host, id string) string { |
| + if Check(host, id) != nil { |
| + return HostPrefix("") + "invalid/" |
| + } |
| + return HostPrefix(host) + id + "/" |
| +} |
| + |
| +// SnapshotKey is one seed's snapshot: seeds/<host>/<id>/snapshot.db. |
| +func SnapshotKey(host, id string) string { return SnapshotPrefix(host, id) + snapshotName } |
| + |
| +// ParseSnapshotKey is SnapshotKey's inverse. Anything else under seeds/ is |
| +// not a snapshot and does not parse. |
| +func ParseSnapshotKey(key string) (host, id string, ok bool) { |
| + rest, found := strings.CutPrefix(key, Prefix) |
| + if !found { |
| + return "", "", false |
| + } |
| + parts := strings.Split(rest, "/") |
| + if len(parts) != 3 || parts[2] != snapshotName || Check(parts[0], parts[1]) != nil { |
| + return "", "", false |
| + } |
| + return parts[0], parts[1], true |
| +} |
diff --git a/internal/seeds/seeds_test.go b/internal/seeds/seeds_test.go| new file mode 100644 |
| index 000000000..c33836e54 |
| --- /dev/null |
| +++ b/internal/seeds/seeds_test.go |
| @@ -0,0 +1,171 @@ |
| +package seeds |
| + |
| +import ( |
| + "errors" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +func TestNewIDShape(t *testing.T) { |
| + a, err := NewID() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + b, _ := NewID() |
| + if !ValidID(a) || !ValidID(b) || a == b { |
| + t.Fatalf("ids %q and %q: want two distinct valid ids", a, b) |
| + } |
| + if !strings.HasPrefix(a, "sd_") || len(a) != 29 { |
| + t.Fatalf("id %q: want sd_ and 26 characters", a) |
| + } |
| + for _, bad := range []string{"", "sd_", "mv_aaaaaaaaaaaaaaaaaaaaaaaaaa", "sd_AAAAAAAAAAAAAAAAAAAAAAAAAA", "sd_aaaaaaaaaaaaaaaaaaaaaaaaa1", "sd_aaaaaaaaaaaaaaaaaaaaaaaaaa/x"} { |
| + if ValidID(bad) { |
| + t.Errorf("ValidID(%q) = true", bad) |
| + } |
| + } |
| + old := randReader |
| + randReader = failingReader{} |
| + defer func() { randReader = old }() |
| + if _, err := NewID(); err == nil { |
| + t.Error("NewID with a failing random source: want an error") |
| + } |
| +} |
| + |
| +type failingReader struct{} |
| + |
| +func (failingReader) Read([]byte) (int, error) { return 0, errors.New("no entropy") } |
| + |
| +func TestSnapshotKeysRoundTrip(t *testing.T) { |
| + id, _ := NewID() |
| + key := SnapshotKey("canary.shop.example.com", id) |
| + if key != "seeds/canary.shop.example.com/"+id+"/snapshot.db" { |
| + t.Fatalf("key = %q", key) |
| + } |
| + if SnapshotPrefix("canary.shop.example.com", id) != "seeds/canary.shop.example.com/"+id+"/" { |
| + t.Fatalf("prefix = %q", SnapshotPrefix("canary.shop.example.com", id)) |
| + } |
| + if HostPrefix("canary.shop.example.com") != "seeds/canary.shop.example.com/" { |
| + t.Fatalf("host prefix = %q", HostPrefix("canary.shop.example.com")) |
| + } |
| + host, got, ok := ParseSnapshotKey(key) |
| + if !ok || host != "canary.shop.example.com" || got != id { |
| + t.Fatalf("ParseSnapshotKey(%q) = %q, %q, %v", key, host, got, ok) |
| + } |
| + for _, bad := range []string{ |
| + "seeds/canary.shop.example.com/" + id + "/other.db", |
| + "seeds/canary.shop.example.com/" + id + "/x/snapshot.db", |
| + "seeds/canary.shop.example.com/not-an-id/snapshot.db", |
| + "instances/canary.shop.example.com/" + id + "/snapshot.db", |
| + "seeds//" + id + "/snapshot.db", |
| + "seeds/../" + id + "/snapshot.db", |
| + "seeds/Shop.Example.com/" + id + "/snapshot.db", |
| + "seeds/" + id + "/snapshot.db", |
| + "", |
| + } { |
| + if _, _, ok := ParseSnapshotKey(bad); ok { |
| + t.Errorf("ParseSnapshotKey(%q) parsed", bad) |
| + } |
| + } |
| +} |
| + |
| +// A key builder is handed a host or id from a request or a record. An |
| +// invalid one must never produce a key that leaves seeds/<host>/<id>/, or a |
| +// prefix that names more than one seed. |
| +func TestKeyBuildersContainHostileInput(t *testing.T) { |
| + id, _ := NewID() |
| + const host = "shop.example.com" |
| + if err := Check(host, id); err != nil { |
| + t.Fatalf("Check of a valid pair: %v", err) |
| + } |
| + hosts := []string{"", "..", "../x", "a/b", "a/../b", "a\\b", "Shop.example.com", "shop.example.com/", ".", "a..b", "shop.example.com\x00", "*", "a b"} |
| + ids := []string{"", ".", "..", "../" + id, id + "/..", id + "/", "sd_x", strings.ToUpper(id)} |
| + for _, h := range hosts { |
| + if !errors.Is(Check(h, id), ErrBadHost) { |
| + t.Errorf("Check(%q, id) = %v; want ErrBadHost", h, Check(h, id)) |
| + } |
| + for _, k := range []string{HostPrefix(h), SnapshotPrefix(h, id), SnapshotKey(h, id)} { |
| + assertContained(t, k) |
| + } |
| + } |
| + for _, i := range ids { |
| + if !errors.Is(Check(host, i), ErrBadID) { |
| + t.Errorf("Check(host, %q) = %v; want ErrBadID", i, Check(host, i)) |
| + } |
| + for _, k := range []string{SnapshotPrefix(host, i), SnapshotKey(host, i)} { |
| + assertContained(t, k) |
| + } |
| + } |
| +} |
| + |
| +// assertContained: the key is under seeds/, carries no dot-dot or backslash, |
| +// names no real host or seed, and does not parse as a snapshot. |
| +func assertContained(t *testing.T, k string) { |
| + t.Helper() |
| + if !strings.HasPrefix(k, Prefix+invalidHost+"/") { |
| + t.Errorf("key %q: want a fail-closed key under %s%s/", k, Prefix, invalidHost) |
| + } |
| + if strings.Contains(k, "..") || strings.Contains(k, "\\") || strings.Contains(k, "\x00") { |
| + t.Errorf("key %q: has a traversal or control character", k) |
| + } |
| + if _, _, ok := ParseSnapshotKey(k); ok { |
| + t.Errorf("key %q parsed as a snapshot", k) |
| + } |
| +} |
| + |
| +func TestVerifyToken(t *testing.T) { |
| + tok, hash, err := NewVerifyToken() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(tok) != 43 || len(hash) != 64 { |
| + t.Fatalf("token %d chars, hash %d chars: want 43 and 64", len(tok), len(hash)) |
| + } |
| + if HashToken(tok) != hash || !TokenMatches(hash, tok) { |
| + t.Fatal("the token does not match its own hash") |
| + } |
| + if TokenMatches(hash, tok+"x") || TokenMatches(hash, "") { |
| + t.Error("a wrong or empty token matched") |
| + } |
| + if TokenMatches(ClosedGate, tok) || TokenMatches(ClosedGate, ClosedGate) || TokenMatches("", "") { |
| + t.Error("the closed gate or an empty hash matched a token") |
| + } |
| + // An uppercase spelling of the hash is not the hash. |
| + if TokenMatches(strings.ToUpper(hash), tok) { |
| + t.Error("an uppercase hash matched") |
| + } |
| + // Two tokens differ. |
| + tok2, hash2, _ := NewVerifyToken() |
| + if tok == tok2 || hash == hash2 || TokenMatches(hash, tok2) { |
| + t.Error("two minted tokens collided or cross-matched") |
| + } |
| + old := randReader |
| + randReader = failingReader{} |
| + defer func() { randReader = old }() |
| + if _, _, err := NewVerifyToken(); err == nil { |
| + t.Error("NewVerifyToken with a failing random source: want an error") |
| + } |
| +} |
| + |
| +func TestRowSeedEncoding(t *testing.T) { |
| + id, _ := NewID() |
| + r := RowSeed{ID: id, Kind: KindReplica, SHA256: strings.Repeat("a", 64), Bytes: 4096, State: StateReady} |
| + got, err := ParseRowSeed(r.Encode()) |
| + if err != nil || got == nil || *got != r { |
| + t.Fatalf("round trip = %+v, %v", got, err) |
| + } |
| + if got, err := ParseRowSeed(""); got != nil || err != nil { |
| + t.Fatalf("empty column = %+v, %v; want nil, nil", got, err) |
| + } |
| + if _, err := ParseRowSeed(`{"id":"nope","state":"ready"}`); err == nil { |
| + t.Error("a malformed id parsed") |
| + } |
| + if _, err := ParseRowSeed(`not json`); err == nil { |
| + t.Error("malformed json parsed") |
| + } |
| + for state, want := range map[string]bool{StateReady: true, StateSwapped: true, StatePlaced: true, |
| + StateReserving: false, StateCommitted: false, StateAborted: false, StateRefused: false} { |
| + if (RowSeed{State: state}).Placeable() != want { |
| + t.Errorf("Placeable(%s) != %v", state, want) |
| + } |
| + } |
| +} |
diff --git a/internal/seeds/token.go b/internal/seeds/token.go| new file mode 100644 |
| index 000000000..067d52a81 |
| --- /dev/null |
| +++ b/internal/seeds/token.go |
| @@ -0,0 +1,41 @@ |
| +package seeds |
| + |
| +import ( |
| + "crypto/sha256" |
| + "crypto/subtle" |
| + "encoding/base64" |
| + "encoding/hex" |
| + "fmt" |
| + "io" |
| +) |
| + |
| +// ClosedGate is the import gate a rollback installs (spec §5.6 step 1): a |
| +// value no token's hash can equal, so the edge refuses even verify requests. |
| +const ClosedGate = "closed" |
| + |
| +// NewVerifyToken mints an import's verify token (spec §5.1): 32 random |
| +// bytes, base64url without padding. The record and the box keep only hash. |
| +func NewVerifyToken() (token, hash string, err error) { |
| + var b [32]byte |
| + if _, err := io.ReadFull(randReader, b[:]); err != nil { |
| + return "", "", fmt.Errorf("seeds: mint a verify token: %w", err) |
| + } |
| + token = base64.RawURLEncoding.EncodeToString(b[:]) |
| + return token, HashToken(token), nil |
| +} |
| + |
| +// HashToken is the hex sha256 the record's verify_hash and the row's |
| +// import_gate hold. |
| +func HashToken(token string) string { |
| + sum := sha256.Sum256([]byte(token)) |
| + return hex.EncodeToString(sum[:]) |
| +} |
| + |
| +// TokenMatches reports whether token's hash is hash, in constant time. An |
| +// empty token, an empty hash and ClosedGate match nothing. |
| +func TokenMatches(hash, token string) bool { |
| + if token == "" || len(hash) != sha256.Size*2 { |
| + return false |
| + } |
| + return subtle.ConstantTimeCompare([]byte(HashToken(token)), []byte(hash)) == 1 |
| +} |