Merge branch 'overnight-content-sealing' into main
22 files changed,
+1526
−24
PRODUCT.md+1 −4cmd/docs/content_maintenance.go+26 −0cmd/docs/content_maintenance_test.go+53 −0cmd/docs/main.go+8 −0docs/superpowers/specs/2026-09-07-content-sealing.md+93 −0internal/docs/app.go+22 −1internal/docs/content_cipher.go+95 −0internal/docs/content_cipher_test.go+92 −0internal/docs/content_maintenance.go+52 −0internal/docs/content_maintenance_test.go+96 −0internal/docs/content_migration.go+147 −0internal/docs/content_migration_test.go+169 −0internal/docs/content_recovery_test.go+133 −0internal/docs/content_rows.go+47 −0internal/docs/content_runtime.go+67 −0internal/docs/content_runtime_test.go+329 −0internal/docs/content_scrub.go+52 −0internal/docs/handlers.go+8 −0internal/docs/migrations/0007_content_encoding.sql+10 −0internal/docs/models.go+5 −13internal/docs/snapshots.go+10 −0internal/docs/store.go+11 −6
CI passed — run details
diff --git a/PRODUCT.md b/PRODUCT.md| index f3ac319..f95de50 100644 |
| --- a/PRODUCT.md |
| +++ b/PRODUCT.md |
| @@ -71,10 +71,7 @@ its foundations: |
| held by the running instance, enforced by a test that greps raw SQLite bytes |
| and bucket objects for fixture plaintext, and it enumerates the six things |
| that *do* leak (§2.1) rather than eliding them. |
| - **None of that is implemented today.** `Store.Text` |
| - (`internal/docs/store.go`) says so in its own doc comment — sealing is out |
| - of scope for the plan that built it — and no plaintext-grep test exists in |
| - the repo. Do not describe this instance as encrypting content at rest, and |
| + Do not describe this instance as encrypting content at rest, and |
| do not let this entry be read as a shipped property; it is the position the |
| product is being built toward, and the thing that makes the position |
| defensible is the leak list, which is honest about what the trade costs. |
diff --git a/cmd/docs/content_maintenance.go b/cmd/docs/content_maintenance.go| new file mode 100644 |
| index 0000000..f9a2618 |
| --- /dev/null |
| +++ b/cmd/docs/content_maintenance.go |
| @@ -0,0 +1,26 @@ |
| +package main |
| + |
| +import ( |
| + "context" |
| + "flag" |
| + "fmt" |
| + "io" |
| + "log/slog" |
| + |
| + "github.com/carlosframework/docs/internal/docs" |
| +) |
| + |
| +func runContentMaintenance(ctx context.Context, args []string, getenv func(string) string, logger *slog.Logger) error { |
| + flags := flag.NewFlagSet("docs-seal", flag.ContinueOnError) |
| + flags.SetOutput(io.Discard) |
| + path := flags.String("db", "", "") |
| + fingerprint := flags.String("expect-fingerprint", "", "") |
| + scrub := flags.Bool("scrub", false, "") |
| + if err := flags.Parse(args); err != nil { |
| + return err |
| + } |
| + if flags.NArg() != 0 || *path == "" || *fingerprint == "" { |
| + return fmt.Errorf("docs-seal: -db, -expect-fingerprint") |
| + } |
| + return docs.MaintainContent(ctx, docs.ContentMaintenanceConfig{DBPath: *path, ContentKey: getenv("DOCS_CONTENT_KEY"), ExpectedFingerprint: *fingerprint, Scrub: *scrub, Logger: logger}) |
| +} |
diff --git a/cmd/docs/content_maintenance_test.go b/cmd/docs/content_maintenance_test.go| new file mode 100644 |
| index 0000000..3cfce77 |
| --- /dev/null |
| +++ b/cmd/docs/content_maintenance_test.go |
| @@ -0,0 +1,53 @@ |
| +package main |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "crypto/sha256" |
| + "encoding/base64" |
| + "encoding/hex" |
| + "log/slog" |
| + "path/filepath" |
| + "testing" |
| + |
| + "github.com/carlosframework/docs/internal/docs" |
| +) |
| + |
| +func TestContentMaintenanceCommandActivatesExistingDatabase(t *testing.T) { |
| + path := filepath.Join(t.TempDir(), "docs.db") |
| + key := bytes.Repeat([]byte{91}, 32) |
| + cfg := docs.Config{DBPath: path, Env: "development", ContentKey: base64.StdEncoding.EncodeToString(key)} |
| + app, err := docs.New(cfg) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := app.Close(); err != nil { |
| + t.Fatal(err) |
| + } |
| + fingerprint := sha256.Sum256(key) |
| + env := func(name string) string { |
| + if name != "DOCS_CONTENT_KEY" { |
| + t.Fatalf("unexpected env: %s", name) |
| + } |
| + return cfg.ContentKey |
| + } |
| + if err := runContentMaintenance(context.Background(), []string{"-db", path, "-expect-fingerprint", hex.EncodeToString(fingerprint[:]), "-scrub"}, env, slog.Default()); err != nil { |
| + t.Fatal(err) |
| + } |
| + cfg.ContentKey = base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{92}, 32)) |
| + app, err = docs.New(cfg) |
| + if app != nil { |
| + app.Close() |
| + } |
| + if err == nil { |
| + t.Fatal("maintenance did not bind database to key") |
| + } |
| +} |
| + |
| +func TestContentMaintenanceCommandRequiresExplicitInputs(t *testing.T) { |
| + for _, args := range [][]string{nil, {"-db", "somewhere"}, {"-expect-fingerprint", "something"}, {"-deactivate"}, {"-db", "somewhere", "-expect-fingerprint", "something", "extra"}} { |
| + if err := runContentMaintenance(context.Background(), args, func(string) string { t.Fatal("invalid CLI reached key loading"); return "" }, slog.Default()); err == nil { |
| + t.Fatalf("accepted args: %v", args) |
| + } |
| + } |
| +} |
diff --git a/cmd/docs/main.go b/cmd/docs/main.go| index 7f68336..69cf7d6 100644 |
| --- a/cmd/docs/main.go |
| +++ b/cmd/docs/main.go |
| @@ -135,6 +135,13 @@ func checkSeedGate(seed bool, env string) error { |
| func main() { |
| logger := slog.Default() |
| + if len(os.Args) > 1 && os.Args[1] == "docs-seal" { |
| + if err := runContentMaintenance(context.Background(), os.Args[2:], os.Getenv, logger); err != nil { |
| + logger.Error("docs: new", "err", err) |
| + os.Exit(1) |
| + } |
| + return |
| + } |
| // -env (chooseEnv above; $DOCS_ENV is consulted too, but never for |
| // "development" — see chooseEnv's own doc comment, fix round 2) |
| @@ -264,6 +271,7 @@ func main() { |
| // already owns. |
| cfg := docs.Config{ |
| DBPath: opts.DBPath, |
| + ContentKey: os.Getenv("DOCS_CONTENT_KEY"), |
| Env: env, |
| Origin: origin, |
| InstanceKey: instanceKey, |
diff --git a/docs/superpowers/specs/2026-09-07-content-sealing.md b/docs/superpowers/specs/2026-09-07-content-sealing.md| new file mode 100644 |
| index 0000000..2828f5c |
| --- /dev/null |
| +++ b/docs/superpowers/specs/2026-09-07-content-sealing.md |
| @@ -0,0 +1,93 @@ |
| +# Document body sealing implementation spec |
| + |
| +Status: approved for implementation by Claude Fable 5.1 |
| +(`claude-fable-5-1`), session |
| +`8aa40554-38b1-4597-9cb8-439ab594e268`. The final rollout amendment and |
| +approval are recorded in Amadan Docs discussion 39. Whole-branch review, |
| +exact-head CI, release integration, canary and deployment evidence are |
| +separate gates. |
| + |
| +## Scope and encoding |
| + |
| +Seal `doc_updates.update_bin` and `doc_snapshots.state`. Titles and other |
| +metadata are outside this slice. Historical plaintext backups remain |
| +preserved and are not claimed encrypted by this change. |
| + |
| +Use a separate `DOCS_CONTENT_KEY`: strict standard-base64 encoding of 32 |
| +random bytes. Require it in production before opening the database, even |
| +before activation. Only explicit development mode may use the fixed public |
| +development key; maintenance always validates as production. |
| + |
| +Encoding 1 uses AES-256-GCM with a random 12-byte nonce prepended to the |
| +ciphertext and tag. Associated data binds the fixed content domain, row |
| +kind, immutable document UUID, sequence and snapshot ABI version. Allocate |
| +the sequence inside the writer transaction before sealing the update. |
| +Never send ciphertext to the CRDT core or sync clients. Decrypt and |
| +validate all response rows before writing HTTP success headers. |
| + |
| +The additive migration creates encoding columns defaulting to 0 and a |
| +singleton activation marker containing encoding, key fingerprint, |
| +authenticated keycheck and scrubbed bookkeeping. Keycheck binds a fixed |
| +sentinel and remains meaningful for an empty database. Wrong-key errors |
| +are distinct from authentication failures and never include content bytes. |
| + |
| +## Runtime states |
| + |
| +A database without the marker is in legacy compatibility mode. Refuse |
| +startup if it already contains nonzero encoding rows. Log an Error on |
| +every legacy boot with structured encoding and key-fingerprint fields. |
| +Legacy reads accept only encoding 0. Both update and snapshot inserts |
| +re-read the marker through the same writer transaction as the insert and |
| +refuse if activation has occurred. A process never switches modes live. |
| + |
| +A database with the marker verifies its keycheck and refuses startup if |
| +an EXISTS query finds any update or snapshot with encoding other than 1. |
| +Activated reads reject other encodings and authenticate every body used. |
| +An unusable snapshot may be abandoned for the retained update log; it is |
| +an accelerator and is never the only copy of document history. |
| + |
| +## Explicit maintenance |
| + |
| +Only the `docs-seal` subcommand invokes conversion or scrubbing. It requires |
| +an existing regular database path and the expected key fingerprint. Read |
| +the key only from `DOCS_CONTENT_KEY`. Reuse the exact server `BootSchema`. |
| +Never resolve serving options, bind a listener or start replication. |
| +There is no deactivate or marker-removal operation. |
| + |
| +Conversion is one transaction. Process bounded batches, bind bodies to |
| +their stored document UUID, verify already-sealed rows and reject unknown |
| +encodings or orphaned rows. On first conversion write the keycheck marker |
| +atomically with the converted rows. On explicit repair of an activated |
| +mixed database, convert remaining encoding-0 rows while leaving the marker, |
| +fingerprint and keycheck unchanged. Clear only scrubbed bookkeeping when |
| +new legacy bytes have been converted. Any failure rolls back all changes. |
| + |
| +Scrubbing is an explicit operation after conversion. The operator stops |
| +the server and replication first. Set scrubbed false, use a pinned writer |
| +connection, check the complete TRUNCATE-checkpoint result, VACUUM, check a |
| +second TRUNCATE checkpoint, and set scrubbed true only on success. A busy |
| +checkpoint is failure. Never put conversion or VACUUM on normal startup. |
| +Every restored copy needs another explicit scrub; its stored scrubbed bit |
| +is not evidence about the restored file's pages or WAL. |
| + |
| +## Rollout constraints |
| + |
| +Deploy and smoke-test the compatibility-capable release with the real |
| +production key before activation. Retain that exact release as the rollback |
| +target. Verify key fingerprint and backup/key recovery before conversion. |
| +Stop serving and replication for maintenance, then restart the same |
| +compatibility-capable release after activation. Never roll back to an older |
| +plaintext-only binary once sealed rows exist. Preserve historical backups. |
| +A restored pre-conversion backup is visibly legacy and needs activation; |
| +it must never be silently presented as encrypted. |
| + |
| +## Required evidence |
| + |
| +Use actual Yjs fixtures and real SQLite. Cover authenticated row identity, |
| +wrong keys and tampering; encrypted HTTP read/write and snapshots; stale |
| +legacy reads and writes; both tables' mixed-state startup checks; explicit |
| +maintenance inputs; atomic repair and marker preservation; raw database |
| +and WAL canaries; incomplete checkpoints; process-exit recovery; and an |
| +independent restored-copy rehearsal. Observe meaningful tests fail when |
| +their safeguards are removed, then restore the code. Full repository CI |
| +and an independent complete-diff review remain required before merge. |
diff --git a/internal/docs/app.go b/internal/docs/app.go| index 5308573..a5ddb45 100644 |
| --- a/internal/docs/app.go |
| +++ b/internal/docs/app.go |
| @@ -33,7 +33,8 @@ import ( |
| // Config configures New. |
| type Config struct { |
| // DBPath is the SQLite file New opens via rastrillo's db package. |
| - DBPath string |
| + DBPath string |
| + ContentKey string |
| // Env is "development" or "production" ("" defaults to |
| // "production"). It gates two fail-closed defaults below — Origin |
| @@ -361,6 +362,11 @@ func New(cfg Config) (*App, error) { |
| return nil, err |
| } |
| + cipher, err := newContentCipher(cfg.ContentKey, cfg.Env) |
| + if err != nil { |
| + return nil, err |
| + } |
| + |
| d, err := db.Open(cfg.DBPath, logger) |
| if err != nil { |
| return nil, fmt.Errorf("docs: open database: %w", err) |
| @@ -371,6 +377,18 @@ func New(cfg Config) (*App, error) { |
| return nil, fmt.Errorf("docs: apply migrations: %w", err) |
| } |
| + state, err := readContentSealing(context.Background(), d.G, cipher) |
| + if err == nil && state != nil { |
| + err = requireSealedContent(context.Background(), d.G) |
| + } |
| + if err != nil { |
| + d.Close() |
| + return nil, err |
| + } |
| + if state == nil { |
| + logger.Error("docs: content sealing not activated", "content_encoding", 0, "key_fingerprint", cipher.fingerprint) |
| + } |
| + |
| // One Runtime for the process (core.New compiles the wasm module |
| // once — expensive; instantiating a per-document Handle from it is |
| // not), shared by every document's Store.replay. |
| @@ -380,6 +398,9 @@ func New(cfg Config) (*App, error) { |
| return nil, fmt.Errorf("docs: start core runtime: %w", err) |
| } |
| store := NewStore(d.G, rt, parseSampleDocuments(cfg.SampleDocuments), logger) |
| + if state != nil { |
| + store.content = cipher |
| + } |
| // A grant admits its holder. Wired here rather than inside |
| // resolveAllowlist because the allowlist is resolved before the store |
diff --git a/internal/docs/content_cipher.go b/internal/docs/content_cipher.go| new file mode 100644 |
| index 0000000..fc837cc |
| --- /dev/null |
| +++ b/internal/docs/content_cipher.go |
| @@ -0,0 +1,95 @@ |
| +package docs |
| + |
| +import ( |
| + "crypto/aes" |
| + "crypto/cipher" |
| + "crypto/rand" |
| + "crypto/sha256" |
| + "encoding/base64" |
| + "encoding/binary" |
| + "encoding/hex" |
| + "errors" |
| + "strings" |
| + |
| + "github.com/google/uuid" |
| +) |
| + |
| +const contentEncoding = 1 |
| +const contentUpdateKind byte = 1 |
| +const contentSnapshotKind byte = 2 |
| +const contentKeyCheckKind byte = 3 |
| + |
| +var developmentContentKey = sha256.Sum256([]byte("docs-development-content-key-not-a-secret")) |
| +var errContentKey = errors.New("docs: invalid content key") |
| +var errContentAuthentication = errors.New("docs: content authentication failed") |
| +var errContentEncoding = errors.New("docs: unsupported content encoding") |
| + |
| +type contentCipher struct { |
| + aead cipher.AEAD |
| + fingerprint string |
| +} |
| + |
| +func newContentCipher(encoded, env string) (*contentCipher, error) { |
| + encoded = strings.TrimSpace(encoded) |
| + var key []byte |
| + if encoded == "" && env == "development" { |
| + key = developmentContentKey[:] |
| + } else { |
| + var err error |
| + key, err = base64.StdEncoding.Strict().DecodeString(encoded) |
| + if err != nil || len(key) != 32 { |
| + return nil, errContentKey |
| + } |
| + } |
| + fingerprint := sha256.Sum256(key) |
| + devFingerprint := sha256.Sum256(developmentContentKey[:]) |
| + if env != "development" && fingerprint == devFingerprint { |
| + return nil, errContentKey |
| + } |
| + block, err := aes.NewCipher(key) |
| + if err != nil { |
| + return nil, errContentKey |
| + } |
| + aead, err := cipher.NewGCM(block) |
| + if err != nil { |
| + return nil, err |
| + } |
| + return &contentCipher{aead: aead, fingerprint: hex.EncodeToString(fingerprint[:])}, nil |
| +} |
| + |
| +func contentAAD(kind byte, guid string, seq int64, abi uint32) ([]byte, error) { |
| + id, err := uuid.Parse(guid) |
| + if err != nil || seq < 0 { |
| + return nil, errContentAuthentication |
| + } |
| + aad := append([]byte("oficina/docs/content/v1"), kind) |
| + aad = append(aad, id[:]...) |
| + aad = binary.BigEndian.AppendUint64(aad, uint64(seq)) |
| + aad = binary.BigEndian.AppendUint32(aad, abi) |
| + return aad, nil |
| +} |
| + |
| +func (c *contentCipher) seal(kind byte, guid string, seq int64, abi uint32, plaintext []byte) ([]byte, error) { |
| + aad, err := contentAAD(kind, guid, seq, abi) |
| + if err != nil { |
| + return nil, err |
| + } |
| + nonce := make([]byte, c.aead.NonceSize()) |
| + if _, err := rand.Read(nonce); err != nil { |
| + return nil, err |
| + } |
| + return c.aead.Seal(nonce, nonce, plaintext, aad), nil |
| +} |
| + |
| +func (c *contentCipher) open(kind byte, guid string, seq int64, abi uint32, sealed []byte) ([]byte, error) { |
| + aad, err := contentAAD(kind, guid, seq, abi) |
| + if err != nil || len(sealed) < c.aead.NonceSize()+c.aead.Overhead() { |
| + return nil, errContentAuthentication |
| + } |
| + n := c.aead.NonceSize() |
| + plaintext, err := c.aead.Open(nil, sealed[:n], sealed[n:], aad) |
| + if err != nil { |
| + return nil, errContentAuthentication |
| + } |
| + return plaintext, nil |
| +} |
diff --git a/internal/docs/content_cipher_test.go b/internal/docs/content_cipher_test.go| new file mode 100644 |
| index 0000000..d0578b8 |
| --- /dev/null |
| +++ b/internal/docs/content_cipher_test.go |
| @@ -0,0 +1,92 @@ |
| +package docs |
| + |
| +import ( |
| + "bytes" |
| + "encoding/base64" |
| + "errors" |
| + "testing" |
| + |
| + "github.com/google/uuid" |
| +) |
| + |
| +func TestContentCipherAuthenticatesRowIdentity(t *testing.T) { |
| + c, err := newContentCipher(base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{41}, 32)), "production") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + guid := uuid.NewString() |
| + body := []byte("private body not shared with another document") |
| + sealed, err := c.seal(contentSnapshotKind, guid, 42, 4, body) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + got, err := c.open(contentSnapshotKind, guid, 42, 4, sealed) |
| + if err != nil || !bytes.Equal(got, body) { |
| + t.Fatalf("round trip: %q, %v", got, err) |
| + } |
| + if bytes.Contains(sealed, body) { |
| + t.Fatal("ciphertext contains plaintext") |
| + } |
| + for _, tc := range []struct { |
| + name string |
| + kind byte |
| + guid string |
| + seq int64 |
| + abi uint32 |
| + }{ |
| + {"document", contentSnapshotKind, uuid.NewString(), 42, 4}, |
| + {"sequence", contentSnapshotKind, guid, 43, 4}, |
| + {"kind", contentUpdateKind, guid, 42, 4}, |
| + {"abi", contentSnapshotKind, guid, 42, 5}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + if _, err := c.open(tc.kind, tc.guid, tc.seq, tc.abi, sealed); !errors.Is(err, errContentAuthentication) { |
| + t.Fatalf("authentication: %v", err) |
| + } |
| + }) |
| + } |
| + sealed[len(sealed)-1] ^= 1 |
| + if _, err := c.open(contentSnapshotKind, guid, 42, 4, sealed); !errors.Is(err, errContentAuthentication) { |
| + t.Fatalf("tamper: %v", err) |
| + } |
| + for n := 0; n < c.aead.NonceSize()+c.aead.Overhead(); n++ { |
| + if _, err := c.open(contentSnapshotKind, guid, 42, 4, sealed[:n]); !errors.Is(err, errContentAuthentication) { |
| + t.Fatalf("truncation %d: %v", n, err) |
| + } |
| + } |
| +} |
| + |
| +func TestContentCipherKeysAndNonces(t *testing.T) { |
| + for _, encoded := range []string{"", "not-base64", base64.StdEncoding.EncodeToString([]byte("short")), base64.StdEncoding.EncodeToString(developmentContentKey[:])} { |
| + if _, err := newContentCipher(encoded, "production"); !errors.Is(err, errContentKey) { |
| + t.Fatalf("invalid production key accepted: %v", err) |
| + } |
| + } |
| + a, err := newContentCipher("", "development") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + b, err := newContentCipher(base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{42}, 32)), "development") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + guid := uuid.NewString() |
| + seen := make(map[string]bool) |
| + for i := 0; i < 100; i++ { |
| + sealed, err := a.seal(contentUpdateKind, guid, 1, 0, []byte("body")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + nonce := string(sealed[:a.aead.NonceSize()]) |
| + if seen[nonce] { |
| + t.Fatal("nonce repeated") |
| + } |
| + seen[nonce] = true |
| + if _, err := b.open(contentUpdateKind, guid, 1, 0, sealed); !errors.Is(err, errContentAuthentication) { |
| + t.Fatalf("wrong key: %v", err) |
| + } |
| + } |
| + if _, err := a.seal(contentUpdateKind, "broken-guid", 1, 0, nil); !errors.Is(err, errContentAuthentication) { |
| + t.Fatalf("invalid guid: %v", err) |
| + } |
| +} |
diff --git a/internal/docs/content_maintenance.go b/internal/docs/content_maintenance.go| new file mode 100644 |
| index 0000000..af3f84d |
| --- /dev/null |
| +++ b/internal/docs/content_maintenance.go |
| @@ -0,0 +1,52 @@ |
| +package docs |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "log/slog" |
| + "os" |
| + |
| + "github.com/carlosframework/rastrillo/db" |
| + "github.com/carlosframework/rastrillo/migrate" |
| +) |
| + |
| +type ContentMaintenanceConfig struct { |
| + DBPath string |
| + ContentKey string |
| + ExpectedFingerprint string |
| + Scrub bool |
| + Logger *slog.Logger |
| +} |
| + |
| +func MaintainContent(ctx context.Context, cfg ContentMaintenanceConfig) error { |
| + c, err := newContentCipher(cfg.ContentKey, "production") |
| + if err != nil { |
| + return err |
| + } |
| + if cfg.ExpectedFingerprint == "" || cfg.ExpectedFingerprint != c.fingerprint { |
| + return errContentKey |
| + } |
| + info, err := os.Stat(cfg.DBPath) |
| + if err != nil { |
| + return err |
| + } |
| + if !info.Mode().IsRegular() { |
| + return errContentState |
| + } |
| + d, err := db.Open(cfg.DBPath, cfg.Logger) |
| + if err != nil { |
| + return err |
| + } |
| + if _, err := migrate.Apply(ctx, d, BootSchema); err != nil { |
| + return errors.Join(err, d.Close()) |
| + } |
| + if err := convertContent(ctx, d.G, c); err != nil { |
| + return errors.Join(err, d.Close()) |
| + } |
| + if cfg.Scrub { |
| + if err := scrubContent(ctx, d, c); err != nil { |
| + return errors.Join(err, d.Close()) |
| + } |
| + } |
| + return d.Close() |
| +} |
diff --git a/internal/docs/content_maintenance_test.go b/internal/docs/content_maintenance_test.go| new file mode 100644 |
| index 0000000..bf52cac |
| --- /dev/null |
| +++ b/internal/docs/content_maintenance_test.go |
| @@ -0,0 +1,96 @@ |
| +package docs |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "encoding/base64" |
| + "errors" |
| + "os" |
| + "path/filepath" |
| + "testing" |
| + |
| + "github.com/carlosframework/rastrillo/db" |
| +) |
| + |
| +func TestContentMaintenanceRequiresExpectedKeyBeforeOpeningDatabase(t *testing.T) { |
| + path := filepath.Join(t.TempDir(), "missing.db") |
| + encoded := base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{62}, 32)) |
| + c, err := newContentCipher(encoded, "production") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, fingerprint := range []string{"", "incorrect"} { |
| + err := MaintainContent(context.Background(), ContentMaintenanceConfig{DBPath: path, ContentKey: encoded, ExpectedFingerprint: fingerprint}) |
| + if !errors.Is(err, errContentKey) { |
| + t.Fatalf("fingerprint %q: %v", fingerprint, err) |
| + } |
| + } |
| + err = MaintainContent(context.Background(), ContentMaintenanceConfig{DBPath: path, ContentKey: encoded, ExpectedFingerprint: c.fingerprint}) |
| + if !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("missing database: %v", err) |
| + } |
| + if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("maintenance created missing database: %v", err) |
| + } |
| +} |
| + |
| +func TestContentMaintenancePreservesContentInRestoredCopy(t *testing.T) { |
| + ctx := context.Background() |
| + path := filepath.Join(t.TempDir(), "source.db") |
| + app, err := New(Config{Env: "development", DBPath: path}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + doc, err := app.Store().Create(ctx, app.TestPerson(t, "owner@example.com"), "Restore") |
| + if err != nil { |
| + app.Close() |
| + t.Fatal(err) |
| + } |
| + if _, err := app.Store().ApplyUpdate(ctx, doc, app.TestActorFor(t, doc), validUpdate(t)); err != nil { |
| + app.Close() |
| + t.Fatal(err) |
| + } |
| + if err := app.Close(); err != nil { |
| + t.Fatal(err) |
| + } |
| + encoded := base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{63}, 32)) |
| + c, err := newContentCipher(encoded, "production") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + cfg := ContentMaintenanceConfig{DBPath: path, ContentKey: encoded, ExpectedFingerprint: c.fingerprint, Scrub: true} |
| + if err := MaintainContent(ctx, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + snapshot, err := os.ReadFile(path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + restoredPath := filepath.Join(t.TempDir(), "restored.db") |
| + if err := os.WriteFile(restoredPath, snapshot, 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + cfg.DBPath = restoredPath |
| + if err := MaintainContent(ctx, cfg); err != nil { |
| + t.Fatalf("restored maintenance: %v", err) |
| + } |
| + d, err := db.Open(restoredPath, nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer d.Close() |
| + state, err := readContentSealing(ctx, d.G, c) |
| + if err != nil || state == nil || !state.Scrubbed { |
| + t.Fatalf("restored state: %#v, %v", state, err) |
| + } |
| + var row DocUpdate |
| + if err := d.G.Where("doc_id = ?", doc.ID).First(&row).Error; err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := c.openUpdate(doc.GUID, &row); err != nil { |
| + t.Fatal(err) |
| + } |
| + if !bytes.Equal(row.UpdateBin, validUpdate(t)) { |
| + t.Fatal("restored document changed") |
| + } |
| +} |
diff --git a/internal/docs/content_migration.go b/internal/docs/content_migration.go| new file mode 100644 |
| index 0000000..7b82759 |
| --- /dev/null |
| +++ b/internal/docs/content_migration.go |
| @@ -0,0 +1,147 @@ |
| +package docs |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "errors" |
| + "fmt" |
| + |
| + "github.com/google/uuid" |
| + "gorm.io/gorm" |
| +) |
| + |
| +var errContentState = errors.New("docs: invalid content sealing state") |
| +var contentKeyCheck = []byte("oficina/docs/content-key-check/v1") |
| + |
| +type contentSealing struct { |
| + ID int |
| + Encoding int |
| + KeyFingerprint string |
| + KeyCheck []byte |
| + Scrubbed bool |
| +} |
| + |
| +func (contentSealing) TableName() string { return "content_sealing" } |
| + |
| +func readContentSealing(ctx context.Context, g *gorm.DB, c *contentCipher) (*contentSealing, error) { |
| + var state contentSealing |
| + err := g.WithContext(ctx).First(&state, 1).Error |
| + if errors.Is(err, gorm.ErrRecordNotFound) { |
| + var count int64 |
| + if err := g.WithContext(ctx).Raw("SELECT (SELECT COUNT(*) FROM doc_updates WHERE content_encoding != 0) + (SELECT COUNT(*) FROM doc_snapshots WHERE content_encoding != 0)").Scan(&count).Error; err != nil { |
| + return nil, err |
| + } |
| + if count != 0 { |
| + return nil, errContentState |
| + } |
| + return nil, nil |
| + } |
| + if err != nil { |
| + return nil, err |
| + } |
| + if c == nil || state.KeyFingerprint != c.fingerprint { |
| + return nil, errContentKeyMismatch |
| + } |
| + if state.Encoding != contentEncoding { |
| + return nil, errContentState |
| + } |
| + plain, err := c.open(contentKeyCheckKind, uuid.Nil.String(), 0, uint32(state.Encoding), state.KeyCheck) |
| + if err != nil || !bytes.Equal(plain, contentKeyCheck) { |
| + return nil, errContentAuthentication |
| + } |
| + return &state, nil |
| +} |
| + |
| +func convertContent(ctx context.Context, g *gorm.DB, c *contentCipher) error { |
| + if c == nil { |
| + return errContentKey |
| + } |
| + return g.WithContext(ctx).Transaction(func(tx *gorm.DB) error { |
| + if err := tx.Exec("PRAGMA secure_delete=ON").Error; err != nil { |
| + return err |
| + } |
| + state, err := readContentSealing(ctx, tx, c) |
| + if err != nil { |
| + return err |
| + } |
| + var hadLegacy bool |
| + if state != nil { |
| + if err := tx.Raw("SELECT EXISTS(SELECT 1 FROM doc_updates WHERE content_encoding = 0 LIMIT 1) OR EXISTS(SELECT 1 FROM doc_snapshots WHERE content_encoding = 0 LIMIT 1)").Scan(&hadLegacy).Error; err != nil { |
| + return err |
| + } |
| + } |
| + for _, snapshot := range []bool{false, true} { |
| + if err := convertContentRows(ctx, tx, c, snapshot, state != nil); err != nil { |
| + return err |
| + } |
| + } |
| + if state != nil { |
| + if hadLegacy { |
| + return tx.Model(&contentSealing{}).Where("id = 1").Update("scrubbed", false).Error |
| + } |
| + return nil |
| + } |
| + check, err := c.seal(contentKeyCheckKind, uuid.Nil.String(), 0, contentEncoding, contentKeyCheck) |
| + if err != nil { |
| + return err |
| + } |
| + return tx.Create(&contentSealing{ID: 1, Encoding: contentEncoding, KeyFingerprint: c.fingerprint, KeyCheck: check}).Error |
| + }) |
| +} |
| + |
| +type contentMigrationRow struct { |
| + ID int64 |
| + DocID int64 |
| + Seq int64 |
| + ABIVersion uint32 |
| + ContentEncoding int |
| + Body []byte |
| + GUID string |
| +} |
| + |
| +func convertContentRows(ctx context.Context, tx *gorm.DB, c *contentCipher, snapshot, active bool) error { |
| + table, column, abi, kind := "doc_updates", "update_bin", "0", contentUpdateKind |
| + if snapshot { |
| + table, column, abi, kind = "doc_snapshots", "state", "r.abi_version", contentSnapshotKind |
| + } |
| + last := int64(0) |
| + first := true |
| + for { |
| + var rows []contentMigrationRow |
| + query := "SELECT r.id, r.doc_id, r.seq, " + abi + " AS abi_version, r.content_encoding, r." + column + " AS body, d.guid FROM " + table + " r LEFT JOIN documents d ON d.id=r.doc_id WHERE (? OR r.id > ?) ORDER BY r.id LIMIT 100" |
| + if err := tx.WithContext(ctx).Raw(query, first, last).Scan(&rows).Error; err != nil { |
| + return err |
| + } |
| + if len(rows) == 0 { |
| + return nil |
| + } |
| + first = false |
| + for _, row := range rows { |
| + if err := ctx.Err(); err != nil { |
| + return err |
| + } |
| + last = row.ID |
| + if active && row.ContentEncoding == contentEncoding { |
| + if _, err := c.open(kind, row.GUID, row.Seq, row.ABIVersion, row.Body); err != nil { |
| + return err |
| + } |
| + continue |
| + } |
| + if row.ContentEncoding != 0 { |
| + return errContentEncoding |
| + } |
| + sealed, err := c.seal(kind, row.GUID, row.Seq, row.ABIVersion, row.Body) |
| + if err != nil { |
| + return err |
| + } |
| + result := tx.Table(table).Where("id = ? AND content_encoding = 0", row.ID). |
| + Updates(map[string]any{column: sealed, "content_encoding": contentEncoding}) |
| + if result.Error != nil { |
| + return result.Error |
| + } |
| + if result.RowsAffected != 1 { |
| + return fmt.Errorf("%w: content row %d", errContentState, row.ID) |
| + } |
| + } |
| + } |
| +} |
diff --git a/internal/docs/content_migration_test.go b/internal/docs/content_migration_test.go| new file mode 100644 |
| index 0000000..aac080f |
| --- /dev/null |
| +++ b/internal/docs/content_migration_test.go |
| @@ -0,0 +1,169 @@ |
| +package docs |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "encoding/base64" |
| + "errors" |
| + "os" |
| + "path/filepath" |
| + "testing" |
| + "time" |
| +) |
| + |
| +func contentMigrationFixture(t *testing.T) (*App, *Document, *contentCipher) { |
| + t.Helper() |
| + app, err := NewForTest(t) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + doc, err := app.Store().Create(context.Background(), app.TestPerson(t, "owner@example.com"), "Migration") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + c, err := newContentCipher(base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{53}, 32)), "production") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return app, doc, c |
| +} |
| + |
| +func TestContentMigrationPreservesBodiesAndIsIdempotent(t *testing.T) { |
| + app, doc, c := contentMigrationFixture(t) |
| + ctx := context.Background() |
| + update := DocUpdate{DocID: doc.ID, Seq: 1, UpdateBin: validUpdate(t), CreatedAt: time.Now().UTC()} |
| + snapshot := DocSnapshot{DocID: doc.ID, Seq: 1, State: validUpdate(t), ABIVersion: coreABIVersion, CreatedAt: time.Now().UTC()} |
| + if err := app.db.G.Create(&update).Error; err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := app.db.G.Create(&snapshot).Error; err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := convertContent(ctx, app.db.G, c); err != nil { |
| + t.Fatal(err) |
| + } |
| + state, err := readContentSealing(ctx, app.db.G, c) |
| + if err != nil || state == nil || state.Scrubbed { |
| + t.Fatalf("state: %#v, %v", state, err) |
| + } |
| + if err := app.db.G.First(&update, update.ID).Error; err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := app.db.G.First(&snapshot, snapshot.ID).Error; err != nil { |
| + t.Fatal(err) |
| + } |
| + sealed := append([]byte(nil), update.UpdateBin...) |
| + if bytes.Equal(update.UpdateBin, validUpdate(t)) || bytes.Equal(snapshot.State, validUpdate(t)) { |
| + t.Fatal("migration left plaintext") |
| + } |
| + if err := c.openUpdate(doc.GUID, &update); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := c.openSnapshot(doc.GUID, &snapshot); err != nil { |
| + t.Fatal(err) |
| + } |
| + if !bytes.Equal(update.UpdateBin, validUpdate(t)) || !bytes.Equal(snapshot.State, validUpdate(t)) { |
| + t.Fatal("migration changed content") |
| + } |
| + if err := convertContent(ctx, app.db.G, c); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := app.db.G.First(&update, update.ID).Error; err != nil { |
| + t.Fatal(err) |
| + } |
| + if !bytes.Equal(update.UpdateBin, sealed) { |
| + t.Fatal("repeat migration rewrote encrypted content") |
| + } |
| + wrong, _ := newContentCipher("", "development") |
| + if _, err := readContentSealing(ctx, app.db.G, wrong); err == nil { |
| + t.Fatal("wrong key accepted") |
| + } |
| +} |
| + |
| +func TestContentMigrationRollsBackOnInvalidDocument(t *testing.T) { |
| + app, doc, c := contentMigrationFixture(t) |
| + rows := []DocUpdate{ |
| + {DocID: doc.ID, Seq: 1, UpdateBin: []byte("valid first row"), CreatedAt: time.Now().UTC()}, |
| + {DocID: doc.ID + 999, Seq: 1, UpdateBin: []byte("orphan second row"), CreatedAt: time.Now().UTC()}, |
| + } |
| + if err := app.db.G.Create(&rows).Error; err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := convertContent(context.Background(), app.db.G, c); err == nil { |
| + t.Fatal("orphan was accepted") |
| + } |
| + var got DocUpdate |
| + if err := app.db.G.First(&got, rows[0].ID).Error; err != nil { |
| + t.Fatal(err) |
| + } |
| + if got.ContentEncoding != 0 || !bytes.Equal(got.UpdateBin, rows[0].UpdateBin) { |
| + t.Fatal("failed migration changed first row") |
| + } |
| + var count int64 |
| + if err := app.db.G.Model(&contentSealing{}).Count(&count).Error; err != nil { |
| + t.Fatal(err) |
| + } |
| + if count != 0 { |
| + t.Fatal("failed migration activated sealing") |
| + } |
| +} |
| + |
| +func TestContentRowsRefusePlaintextDowngrade(t *testing.T) { |
| + c, err := newContentCipher("", "development") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, encoding := range []int{0, 2, -1} { |
| + if err := c.openUpdate("", &DocUpdate{ContentEncoding: encoding, UpdateBin: []byte("plaintext")}); !errors.Is(err, errContentEncoding) { |
| + t.Fatalf("update encoding %d: %v", encoding, err) |
| + } |
| + if err := c.openSnapshot("", &DocSnapshot{ContentEncoding: encoding, State: []byte("plaintext")}); !errors.Is(err, errContentEncoding) { |
| + t.Fatalf("snapshot encoding %d: %v", encoding, err) |
| + } |
| + } |
| +} |
| + |
| +func TestContentScrubRemovesLegacyBytesFromDatabaseAndWAL(t *testing.T) { |
| + path := filepath.Join(t.TempDir(), "docs.db") |
| + app, err := NewForTestWith(t, Config{DBPath: path}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + doc, err := app.Store().Create(context.Background(), app.TestPerson(t, "owner@example.com"), "Scrub") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + canary := []byte("CONTENT-SCRUB-CANARY-55de8635-70b3-488a") |
| + row := DocUpdate{DocID: doc.ID, Seq: 1, UpdateBin: canary, CreatedAt: time.Now().UTC()} |
| + if err := app.db.G.Create(&row).Error; err != nil { |
| + t.Fatal(err) |
| + } |
| + wal, err := os.ReadFile(path + "-wal") |
| + if err != nil || !bytes.Contains(wal, canary) { |
| + t.Fatalf("legacy WAL canary absent: %v", err) |
| + } |
| + c, err := newContentCipher("", "development") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + ctx := context.Background() |
| + if err := convertContent(ctx, app.db.G, c); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := scrubContent(ctx, app.db, c); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, filename := range []string{path, path + "-wal"} { |
| + body, err := os.ReadFile(filename) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if bytes.Contains(body, canary) { |
| + t.Fatalf("plaintext retained in %s", filename) |
| + } |
| + } |
| + state, err := readContentSealing(ctx, app.db.G, c) |
| + if err != nil || state == nil || !state.Scrubbed { |
| + t.Fatalf("scrub state: %#v, %v", state, err) |
| + } |
| +} |
diff --git a/internal/docs/content_recovery_test.go b/internal/docs/content_recovery_test.go| new file mode 100644 |
| index 0000000..b87e3f3 |
| --- /dev/null |
| +++ b/internal/docs/content_recovery_test.go |
| @@ -0,0 +1,133 @@ |
| +package docs |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "database/sql" |
| + "errors" |
| + "os" |
| + "os/exec" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/rastrillo/db" |
| + "gorm.io/gorm" |
| +) |
| + |
| +func TestContentScrubRefusesPinnedWALReader(t *testing.T) { |
| + path := filepath.Join(t.TempDir(), "docs.db") |
| + app, err := NewForTestWith(t, Config{DBPath: path}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + c, err := newContentCipher("", "development") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + ctx := context.Background() |
| + if err := convertContent(ctx, app.db.G, c); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := scrubContent(ctx, app.db, c); err != nil { |
| + t.Fatal(err) |
| + } |
| + reader, err := sql.Open("sqlite", path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer reader.Close() |
| + tx, err := reader.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer tx.Rollback() |
| + var scrubbed int |
| + if err := tx.QueryRow("SELECT scrubbed FROM content_sealing WHERE id=1").Scan(&scrubbed); err != nil { |
| + t.Fatal(err) |
| + } |
| + if scrubbed != 1 { |
| + t.Fatal("reader did not pin scrubbed state") |
| + } |
| + if _, err := app.db.Writer().Exec("PRAGMA busy_timeout=1"); err != nil { |
| + t.Fatal(err) |
| + } |
| + err = scrubContent(ctx, app.db, c) |
| + if err == nil || !strings.Contains(err.Error(), "checkpoint incomplete") { |
| + t.Fatalf("pinned reader: %v", err) |
| + } |
| + state, err := readContentSealing(ctx, app.db.G, c) |
| + if err != nil || state == nil || state.Scrubbed { |
| + t.Fatalf("failed scrub marker: %#v, %v", state, err) |
| + } |
| +} |
| + |
| +func TestContentMigrationRecoversAfterProcessExit(t *testing.T) { |
| + ctx := context.Background() |
| + c, err := newContentCipher("", "development") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if path := os.Getenv("DOCS_SEAL_CRASH_FIXTURE"); path != "" { |
| + d, err := db.Open(path, nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer d.Close() |
| + if err := d.G.Callback().Update().After("gorm:commit_or_rollback_transaction").Register("docs:test-crash", func(tx *gorm.DB) { |
| + if tx.Statement.Table == "doc_updates" && tx.Error == nil && tx.RowsAffected == 1 { |
| + os.Exit(73) |
| + } |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := convertContent(ctx, d.G, c); err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Fatal("conversion never reached crash point") |
| + } |
| + path := filepath.Join(t.TempDir(), "docs.db") |
| + app, err := NewForTestWith(t, Config{DBPath: path}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + doc, err := app.Store().Create(ctx, app.TestPerson(t, "owner@example.com"), "Crash recovery") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + row := DocUpdate{DocID: doc.ID, Seq: 1, UpdateBin: validUpdate(t), CreatedAt: time.Now().UTC()} |
| + if err := app.db.G.Create(&row).Error; err != nil { |
| + t.Fatal(err) |
| + } |
| + command := exec.Command(os.Args[0], "-test.run=^TestContentMigrationRecoversAfterProcessExit$", "-test.count=1") |
| + command.Env = append(os.Environ(), "DOCS_SEAL_CRASH_FIXTURE="+path) |
| + output, err := command.CombinedOutput() |
| + var exited *exec.ExitError |
| + if !errors.As(err, &exited) || exited.ExitCode() != 73 { |
| + t.Fatalf("child did not exit at write: %v\n%s", err, output) |
| + } |
| + var recovered DocUpdate |
| + if err := app.db.G.First(&recovered, row.ID).Error; err != nil { |
| + t.Fatal(err) |
| + } |
| + if recovered.ContentEncoding != 0 || !bytes.Equal(recovered.UpdateBin, row.UpdateBin) { |
| + t.Fatal("process exit committed a partial conversion") |
| + } |
| + state, err := readContentSealing(ctx, app.db.G, c) |
| + if err != nil || state != nil { |
| + t.Fatalf("crash activated sealing: %#v, %v", state, err) |
| + } |
| + if err := convertContent(ctx, app.db.G, c); err != nil { |
| + t.Fatalf("retry: %v", err) |
| + } |
| + if err := app.db.G.First(&recovered, row.ID).Error; err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := c.openUpdate(doc.GUID, &recovered); err != nil { |
| + t.Fatal(err) |
| + } |
| + if !bytes.Equal(recovered.UpdateBin, row.UpdateBin) { |
| + t.Fatal("retry lost content") |
| + } |
| +} |
diff --git a/internal/docs/content_rows.go b/internal/docs/content_rows.go| new file mode 100644 |
| index 0000000..9076e32 |
| --- /dev/null |
| +++ b/internal/docs/content_rows.go |
| @@ -0,0 +1,47 @@ |
| +package docs |
| + |
| +import "fmt" |
| + |
| +func (c *contentCipher) sealUpdate(guid string, row *DocUpdate) error { |
| + sealed, err := c.seal(contentUpdateKind, guid, row.Seq, 0, row.UpdateBin) |
| + if err != nil { |
| + return fmt.Errorf("docs: seal update %d: %w", row.ID, err) |
| + } |
| + row.UpdateBin = sealed |
| + row.ContentEncoding = contentEncoding |
| + return nil |
| +} |
| + |
| +func (c *contentCipher) openUpdate(guid string, row *DocUpdate) error { |
| + if row.ContentEncoding != contentEncoding { |
| + return errContentEncoding |
| + } |
| + plain, err := c.open(contentUpdateKind, guid, row.Seq, 0, row.UpdateBin) |
| + if err != nil { |
| + return fmt.Errorf("docs: open update %d: %w", row.ID, err) |
| + } |
| + row.UpdateBin = plain |
| + return nil |
| +} |
| + |
| +func (c *contentCipher) sealSnapshot(guid string, row *DocSnapshot) error { |
| + sealed, err := c.seal(contentSnapshotKind, guid, row.Seq, row.ABIVersion, row.State) |
| + if err != nil { |
| + return fmt.Errorf("docs: seal snapshot %d: %w", row.ID, err) |
| + } |
| + row.State = sealed |
| + row.ContentEncoding = contentEncoding |
| + return nil |
| +} |
| + |
| +func (c *contentCipher) openSnapshot(guid string, row *DocSnapshot) error { |
| + if row.ContentEncoding != contentEncoding { |
| + return errContentEncoding |
| + } |
| + plain, err := c.open(contentSnapshotKind, guid, row.Seq, row.ABIVersion, row.State) |
| + if err != nil { |
| + return fmt.Errorf("docs: open snapshot %d: %w", row.ID, err) |
| + } |
| + row.State = plain |
| + return nil |
| +} |
diff --git a/internal/docs/content_runtime.go b/internal/docs/content_runtime.go| new file mode 100644 |
| index 0000000..ce5d239 |
| --- /dev/null |
| +++ b/internal/docs/content_runtime.go |
| @@ -0,0 +1,67 @@ |
| +package docs |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + |
| + "gorm.io/gorm" |
| +) |
| + |
| +var errContentKeyMismatch = errors.New("docs: content key does not match this database") |
| +var errContentMixed = errors.New("docs: mixed content encodings; run docs-seal offline") |
| + |
| +func requireSealedContent(ctx context.Context, g *gorm.DB) error { |
| + var mixed bool |
| + if err := g.WithContext(ctx).Raw(`SELECT EXISTS(SELECT 1 FROM doc_updates WHERE content_encoding != 1 LIMIT 1) OR EXISTS(SELECT 1 FROM doc_snapshots WHERE content_encoding != 1 LIMIT 1)`).Scan(&mixed).Error; err != nil { |
| + return err |
| + } |
| + if mixed { |
| + return errContentMixed |
| + } |
| + return nil |
| +} |
| + |
| +func refuseActivatedLegacyWrite(tx *gorm.DB) error { |
| + var count int64 |
| + if err := tx.Model(&contentSealing{}).Where("id = 1").Count(&count).Error; err != nil { |
| + return err |
| + } |
| + if count != 0 { |
| + return errContentState |
| + } |
| + return nil |
| +} |
| + |
| +func (s *Store) prepareContentUpdate(tx *gorm.DB, guid string, row *DocUpdate) error { |
| + if s.content == nil { |
| + return refuseActivatedLegacyWrite(tx) |
| + } |
| + return s.content.sealUpdate(guid, row) |
| +} |
| + |
| +func (s *Store) prepareContentSnapshot(tx *gorm.DB, guid string, row *DocSnapshot) error { |
| + if s.content == nil { |
| + return refuseActivatedLegacyWrite(tx) |
| + } |
| + return s.content.sealSnapshot(guid, row) |
| +} |
| + |
| +func (s *Store) openContentUpdate(guid string, row *DocUpdate) error { |
| + if s.content == nil { |
| + if row.ContentEncoding != 0 { |
| + return errContentEncoding |
| + } |
| + return nil |
| + } |
| + return s.content.openUpdate(guid, row) |
| +} |
| + |
| +func (s *Store) openContentSnapshot(guid string, row *DocSnapshot) error { |
| + if s.content == nil { |
| + if row.ContentEncoding != 0 { |
| + return errContentEncoding |
| + } |
| + return nil |
| + } |
| + return s.content.openSnapshot(guid, row) |
| +} |
diff --git a/internal/docs/content_runtime_test.go b/internal/docs/content_runtime_test.go| new file mode 100644 |
| index 0000000..17b081a |
| --- /dev/null |
| +++ b/internal/docs/content_runtime_test.go |
| @@ -0,0 +1,329 @@ |
| +package docs |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "encoding/base64" |
| + "errors" |
| + "log/slog" |
| + "net/http" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "gorm.io/gorm" |
| +) |
| + |
| +func activatedContentApp(t *testing.T) (*App, string) { |
| + t.Helper() |
| + key := base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{71}, 32)) |
| + cfg := Config{DBPath: filepath.Join(t.TempDir(), "docs.db"), Env: "development", ContentKey: key} |
| + app, err := New(cfg) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := app.Close(); err != nil { |
| + t.Fatal(err) |
| + } |
| + cipher, err := newContentCipher(key, "production") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := MaintainContent(context.Background(), ContentMaintenanceConfig{DBPath: cfg.DBPath, ContentKey: key, ExpectedFingerprint: cipher.fingerprint}); err != nil { |
| + t.Fatal(err) |
| + } |
| + app, err = New(cfg) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { |
| + if err := app.Close(); err != nil { |
| + t.Error(err) |
| + } |
| + }) |
| + return app, key |
| +} |
| + |
| +func TestActivatedContentPersistsSealedAndServesPlainUpdates(t *testing.T) { |
| + app, _ := activatedContentApp(t) |
| + ctx := context.Background() |
| + owner := app.TestPerson(t, "owner@example.com") |
| + doc, err := app.Store().Create(ctx, owner, "Sealed") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + req := app.TestRequest(t, "owner@example.com", http.MethodPost, "/d/"+doc.Sqid+"/updates", bytes.NewReader(validUpdate(t))) |
| + if rec := serve(app, req); rec.Code != http.StatusNoContent { |
| + t.Fatalf("POST: %d %s", rec.Code, rec.Body.String()) |
| + } |
| + var row DocUpdate |
| + if err := app.store.db.First(&row).Error; err != nil { |
| + t.Fatal(err) |
| + } |
| + if row.ContentEncoding != 1 || bytes.Contains(row.UpdateBin, []byte("First paragraph.")) || bytes.Equal(row.UpdateBin, validUpdate(t)) { |
| + t.Fatal("persisted content is not sealed") |
| + } |
| + for _, suffix := range []string{"/updates", "/updates?raw=1"} { |
| + rec := serve(app, app.TestRequest(t, "owner@example.com", http.MethodGet, "/d/"+doc.Sqid+suffix, nil)) |
| + if rec.Code != 200 || !bytes.Equal(rec.Body.Bytes(), validUpdate(t)) { |
| + t.Fatalf("GET %s: %d", suffix, rec.Code) |
| + } |
| + } |
| + h, seq, _, err := app.store.replay(ctx, doc) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer h.Close(ctx) |
| + if err := app.store.writeSnapshot(ctx, doc, h, seq); err != nil { |
| + t.Fatal(err) |
| + } |
| + var snap DocSnapshot |
| + if err := app.store.db.First(&snap).Error; err != nil { |
| + t.Fatal(err) |
| + } |
| + if snap.ContentEncoding != 1 || bytes.Contains(snap.State, []byte("First paragraph.")) { |
| + t.Fatal("snapshot is not sealed") |
| + } |
| + rec := serve(app, app.TestRequest(t, "owner@example.com", http.MethodGet, "/d/"+doc.Sqid+"/updates", nil)) |
| + if rec.Code != 200 || bytes.Equal(rec.Body.Bytes(), snap.State) { |
| + t.Fatalf("snapshot GET: %d", rec.Code) |
| + } |
| + if text, err := app.store.Text(ctx, doc); err != nil || !strings.Contains(text, "First paragraph.") { |
| + t.Fatalf("sealed replay text: %q %v", text, err) |
| + } |
| + replayed, _, _, err := app.store.replay(ctx, doc) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + replayed.Close(ctx) |
| + row.UpdateBin[len(row.UpdateBin)-1] ^= 1 |
| + if err := app.store.db.Model(&row).Update("update_bin", row.UpdateBin).Error; err != nil { |
| + t.Fatal(err) |
| + } |
| + rec = serve(app, app.TestRequest(t, "owner@example.com", http.MethodGet, "/d/"+doc.Sqid+"/updates?raw=1", nil)) |
| + if rec.Code != 500 || strings.Contains(rec.Body.String(), "First paragraph.") || rec.Header().Get("Docs-Seq") != "" { |
| + t.Fatal("corrupt update was exposed or acknowledged") |
| + } |
| +} |
| + |
| +func TestLegacyContentWriterRefusesAfterActivation(t *testing.T) { |
| + app, err := NewForTest(t) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + ctx := context.Background() |
| + owner := app.TestPerson(t, "owner@example.com") |
| + doc, err := app.store.Create(ctx, owner, "Legacy") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + h, err := app.core.NewDoc(ctx, doc.GUID, doc.ClientID) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer h.Close(ctx) |
| + if err := h.Apply(ctx, validUpdate(t)); err != nil { |
| + t.Fatal(err) |
| + } |
| + cipher, err := newContentCipher("", "development") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := convertContent(ctx, app.store.db, cipher); err != nil { |
| + t.Fatal(err) |
| + } |
| + if seq, err := app.store.ApplyUpdate(ctx, doc, Actor{PersonID: owner, Subject: "owner@example.com"}, validUpdate(t)); !errors.Is(err, errContentState) || seq != 0 { |
| + t.Fatalf("stale Store.ApplyUpdate committed: seq %d error %v", seq, err) |
| + } |
| + update := &DocUpdate{DocID: doc.ID, Seq: 1, UpdateBin: validUpdate(t), CreatedAt: time.Now().UTC()} |
| + err = app.store.db.Transaction(func(tx *gorm.DB) error { |
| + if err := app.store.prepareContentUpdate(tx, doc.GUID, update); err != nil { |
| + return err |
| + } |
| + return tx.Create(update).Error |
| + }) |
| + if !errors.Is(err, errContentState) { |
| + t.Fatalf("legacy update: %v", err) |
| + } |
| + if err := app.store.writeSnapshot(ctx, doc, h, 1); !errors.Is(err, errContentState) { |
| + t.Fatalf("legacy snapshot: %v", err) |
| + } |
| + var count int64 |
| + if err := app.store.db.Model(&DocUpdate{}).Count(&count).Error; err != nil || count != 0 { |
| + t.Fatalf("legacy write committed: %d %v", count, err) |
| + } |
| +} |
| + |
| +func TestContentStartupRejectsMixedStateAndMaintenanceRepairsWithoutMarkerRewrite(t *testing.T) { |
| + app, key := activatedContentApp(t) |
| + ctx := context.Background() |
| + doc, err := app.store.Create(ctx, app.TestPerson(t, "owner@example.com"), "Mixed") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var before contentSealing |
| + if err := app.store.db.First(&before).Error; err != nil { |
| + t.Fatal(err) |
| + } |
| + row := DocUpdate{DocID: doc.ID, Seq: 1, UpdateBin: validUpdate(t), CreatedAt: time.Now().UTC()} |
| + if err := app.store.db.Create(&row).Error; err != nil { |
| + t.Fatal(err) |
| + } |
| + candidate, err := New(app.cfg) |
| + if candidate != nil { |
| + candidate.Close() |
| + } |
| + if !errors.Is(err, errContentMixed) { |
| + t.Fatalf("mixed boot: %v", err) |
| + } |
| + if err := convertContent(ctx, app.store.db, app.store.content); err != nil { |
| + t.Fatal(err) |
| + } |
| + var after contentSealing |
| + if err := app.store.db.First(&after).Error; err != nil { |
| + t.Fatal(err) |
| + } |
| + if before.KeyFingerprint != after.KeyFingerprint || !bytes.Equal(before.KeyCheck, after.KeyCheck) { |
| + t.Fatal("repair rewrote activation marker") |
| + } |
| + if err := app.store.db.First(&row).Error; err != nil { |
| + t.Fatal(err) |
| + } |
| + if row.ContentEncoding != 1 { |
| + t.Fatal("repair retained legacy row") |
| + } |
| + cfg := app.cfg |
| + cfg.ContentKey = base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{72}, 32)) |
| + candidate, err = New(cfg) |
| + if candidate != nil { |
| + candidate.Close() |
| + } |
| + if !errors.Is(err, errContentKeyMismatch) { |
| + t.Fatalf("wrong-key boot: %v", err) |
| + } |
| + cfg.ContentKey = key |
| + candidate, err = New(cfg) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + candidate.Close() |
| + if err := app.store.db.Delete(&contentSealing{}, 1).Error; err != nil { |
| + t.Fatal(err) |
| + } |
| + candidate, err = New(cfg) |
| + if candidate != nil { |
| + candidate.Close() |
| + } |
| + if !errors.Is(err, errContentState) { |
| + t.Fatalf("missing-marker boot: %v", err) |
| + } |
| +} |
| + |
| +func TestLegacyBootDiagnosesUnactivatedContent(t *testing.T) { |
| + var logs bytes.Buffer |
| + app, err := NewForTestWith(t, Config{Logger: slog.New(slog.NewJSONHandler(&logs, nil))}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if app.store.content != nil { |
| + t.Fatal("legacy boot activated content") |
| + } |
| + if !strings.Contains(logs.String(), `"level":"ERROR"`) || !strings.Contains(logs.String(), `"content_encoding":0`) { |
| + t.Fatalf("legacy diagnosis absent: %s", logs.String()) |
| + } |
| +} |
| + |
| +func TestContentRepairFailurePreservesActivatedMarkerAndLegacyRows(t *testing.T) { |
| + app, _ := activatedContentApp(t) |
| + doc, err := app.store.Create(context.Background(), app.TestPerson(t, "owner@example.com"), "Repair") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var before contentSealing |
| + if err := app.store.db.First(&before).Error; err != nil { |
| + t.Fatal(err) |
| + } |
| + rows := []DocUpdate{{DocID: doc.ID, Seq: 1, UpdateBin: validUpdate(t), CreatedAt: time.Now().UTC()}, {DocID: doc.ID, Seq: 2, UpdateBin: validUpdate(t), ContentEncoding: 99, CreatedAt: time.Now().UTC()}} |
| + if err := app.store.db.Create(&rows).Error; err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := convertContent(context.Background(), app.store.db, app.store.content); !errors.Is(err, errContentEncoding) { |
| + t.Fatalf("repair failure: %v", err) |
| + } |
| + var after contentSealing |
| + if err := app.store.db.First(&after).Error; err != nil { |
| + t.Fatal(err) |
| + } |
| + if before.Encoding != after.Encoding || before.KeyFingerprint != after.KeyFingerprint || !bytes.Equal(before.KeyCheck, after.KeyCheck) || before.Scrubbed != after.Scrubbed { |
| + t.Fatal("failed repair changed marker") |
| + } |
| + var row DocUpdate |
| + if err := app.store.db.First(&row, rows[0].ID).Error; err != nil { |
| + t.Fatal(err) |
| + } |
| + if row.ContentEncoding != 0 || !bytes.Equal(row.UpdateBin, validUpdate(t)) { |
| + t.Fatal("failed repair partially converted legacy row") |
| + } |
| +} |
| + |
| +func TestProductionBootRequiresContentKeyBeforeOpeningLegacyDatabase(t *testing.T) { |
| + path := filepath.Join(t.TempDir(), "docs.db") |
| + cfg := Config{DBPath: path, Env: "production", Origin: "https://docs.example.com", InstanceKey: "independent auth key", Mailer: &recordingMailer{}, AllowedAddresses: "owner@example.com"} |
| + app, err := New(cfg) |
| + if app != nil { |
| + app.Close() |
| + } |
| + if !errors.Is(err, errContentKey) { |
| + t.Fatalf("missing production content key: %v", err) |
| + } |
| +} |
| + |
| +func TestStaleLegacyStoreRefusesSealedUpdatesAndSnapshots(t *testing.T) { |
| + app, _ := activatedContentApp(t) |
| + ctx := context.Background() |
| + owner := app.TestPerson(t, "owner@example.com") |
| + doc, err := app.store.Create(ctx, owner, "Stale") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := app.store.ApplyUpdate(ctx, doc, Actor{PersonID: owner, Subject: "owner@example.com"}, validUpdate(t)); err != nil { |
| + t.Fatal(err) |
| + } |
| + h, seq, _, err := app.store.replay(ctx, doc) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := app.store.writeSnapshot(ctx, doc, h, seq); err != nil { |
| + h.Close(ctx) |
| + t.Fatal(err) |
| + } |
| + h.Close(ctx) |
| + app.store.content = nil |
| + if _, err := app.store.newestSnapshot(ctx, doc.ID); !errors.Is(err, errContentEncoding) { |
| + t.Fatalf("legacy snapshot read: %v", err) |
| + } |
| + rec := serve(app, app.TestRequest(t, "owner@example.com", http.MethodGet, "/d/"+doc.Sqid+"/updates?raw=1", nil)) |
| + if rec.Code != http.StatusInternalServerError || rec.Header().Get("Docs-Seq") != "" { |
| + t.Fatalf("legacy update GET: %d, seq %q", rec.Code, rec.Header().Get("Docs-Seq")) |
| + } |
| +} |
| + |
| +func TestContentStartupRejectsPlaintextSnapshotWithoutLegacyUpdates(t *testing.T) { |
| + app, _ := activatedContentApp(t) |
| + doc, err := app.store.Create(context.Background(), app.TestPerson(t, "owner@example.com"), "Snapshot") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + snapshot := DocSnapshot{DocID: doc.ID, Seq: 1, State: validUpdate(t), ABIVersion: coreABIVersion, CreatedAt: time.Now().UTC()} |
| + if err := app.store.db.Create(&snapshot).Error; err != nil { |
| + t.Fatal(err) |
| + } |
| + candidate, err := New(app.cfg) |
| + if candidate != nil { |
| + candidate.Close() |
| + } |
| + if !errors.Is(err, errContentMixed) { |
| + t.Fatalf("plaintext snapshot boot: %v", err) |
| + } |
| +} |
diff --git a/internal/docs/content_scrub.go b/internal/docs/content_scrub.go| new file mode 100644 |
| index 0000000..be113f7 |
| --- /dev/null |
| +++ b/internal/docs/content_scrub.go |
| @@ -0,0 +1,52 @@ |
| +package docs |
| + |
| +import ( |
| + "context" |
| + "database/sql" |
| + "fmt" |
| + |
| + "github.com/carlosframework/rastrillo/db" |
| +) |
| + |
| +func scrubContent(ctx context.Context, d *db.DB, c *contentCipher) error { |
| + state, err := readContentSealing(ctx, d.G, c) |
| + if err != nil { |
| + return err |
| + } |
| + if state == nil { |
| + return errContentState |
| + } |
| + if err := convertContent(ctx, d.G, c); err != nil { |
| + return err |
| + } |
| + conn, err := d.Writer().Conn(ctx) |
| + if err != nil { |
| + return err |
| + } |
| + defer conn.Close() |
| + if _, err := conn.ExecContext(ctx, "UPDATE content_sealing SET scrubbed = 0 WHERE id = 1"); err != nil { |
| + return err |
| + } |
| + if err := truncateContentWAL(ctx, conn); err != nil { |
| + return err |
| + } |
| + if _, err := conn.ExecContext(ctx, "VACUUM"); err != nil { |
| + return err |
| + } |
| + if err := truncateContentWAL(ctx, conn); err != nil { |
| + return err |
| + } |
| + _, err = conn.ExecContext(ctx, "UPDATE content_sealing SET scrubbed = 1 WHERE id = 1") |
| + return err |
| +} |
| + |
| +func truncateContentWAL(ctx context.Context, conn *sql.Conn) error { |
| + var busy, frames, checkpointed int |
| + if err := conn.QueryRowContext(ctx, "PRAGMA wal_checkpoint(TRUNCATE)").Scan(&busy, &frames, &checkpointed); err != nil { |
| + return err |
| + } |
| + if busy != 0 || frames != checkpointed { |
| + return fmt.Errorf("docs: checkpoint incomplete (%d, %d, %d)", busy, frames, checkpointed) |
| + } |
| + return nil |
| +} |
diff --git a/internal/docs/handlers.go b/internal/docs/handlers.go| index 6c14a80..53061c8 100644 |
| --- a/internal/docs/handlers.go |
| +++ b/internal/docs/handlers.go |
| @@ -738,6 +738,14 @@ func (h *handlers) updatesGet(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| + for i := range updates { |
| + if err := h.store.openContentUpdate(doc.GUID, &updates[i]); err != nil { |
| + h.logger.Error("docs: load update log", "sqid", doc.Sqid, "err", err) |
| + http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError) |
| + return |
| + } |
| + } |
| + |
| total := 0 |
| if snapshot != nil { |
| total = len(snapshot.State) |
diff --git a/internal/docs/migrations/0007_content_encoding.sql b/internal/docs/migrations/0007_content_encoding.sql| new file mode 100644 |
| index 0000000..8b90a14 |
| --- /dev/null |
| +++ b/internal/docs/migrations/0007_content_encoding.sql |
| @@ -0,0 +1,10 @@ |
| +ALTER TABLE doc_updates ADD COLUMN content_encoding INTEGER NOT NULL DEFAULT 0; |
| +ALTER TABLE doc_snapshots ADD COLUMN content_encoding INTEGER NOT NULL DEFAULT 0; |
| + |
| +CREATE TABLE IF NOT EXISTS content_sealing ( |
| + id INTEGER PRIMARY KEY CHECK (id = 1), |
| + encoding INTEGER NOT NULL, |
| + key_fingerprint TEXT NOT NULL, |
| + key_check BLOB NOT NULL, |
| + scrubbed INTEGER NOT NULL DEFAULT 0 |
| +); |
diff --git a/internal/docs/models.go b/internal/docs/models.go| index c95c8d6..6758361 100644 |
| --- a/internal/docs/models.go |
| +++ b/internal/docs/models.go |
| @@ -78,18 +78,13 @@ type Person struct { |
| // DocUpdate is one Yjs update in a document's log, applied in Seq |
| // order to rebuild the document's CRDT state (Store.replay). |
| -// |
| -// UpdateBin is plaintext in this table. Sealing it (spec §2 — per-row |
| -// AES-GCM, same as doc titles and everything else that is document |
| -// content) is out of scope for this plan; this comment exists so the |
| -// next reader does not mistake that for a decision that it stays |
| -// plaintext. |
| type DocUpdate struct { |
| ID int64 `gorm:"primaryKey"` |
| DocID int64 `gorm:"column:doc_id;not null;index"` |
| Seq int64 `gorm:"column:seq;not null"` |
| - UpdateBin []byte `gorm:"column:update_bin;not null"` |
| + UpdateBin []byte `gorm:"column:update_bin;not null"` |
| + ContentEncoding int `gorm:"column:content_encoding;not null"` |
| CreatedAt time.Time `gorm:"column:created_at;not null"` |
| } |
| @@ -100,10 +95,6 @@ type DocUpdate struct { |
| // a snapshot that cannot be read is discarded in favour of replaying |
| // them (the snapshots design, D2). |
| // |
| -// State is plaintext in this table, exactly as DocUpdate.UpdateBin is. |
| -// Worth one caveat the sealing work should carry: a doc_updates row is a |
| -// fragment of an edit, while this is the entire document in one blob. |
| -// |
| // ABIVersion records the core that produced State. Without it, "discard |
| // a snapshot from an older ABI" is unenforceable prose. |
| type DocSnapshot struct { |
| @@ -111,8 +102,9 @@ type DocSnapshot struct { |
| DocID int64 `gorm:"column:doc_id;not null;index"` |
| Seq int64 `gorm:"column:seq;not null"` |
| - State []byte `gorm:"column:state;not null"` |
| - ABIVersion uint32 `gorm:"column:abi_version;not null"` |
| + State []byte `gorm:"column:state;not null"` |
| + ContentEncoding int `gorm:"column:content_encoding;not null"` |
| + ABIVersion uint32 `gorm:"column:abi_version;not null"` |
| CreatedAt time.Time `gorm:"column:created_at;not null"` |
| } |
diff --git a/internal/docs/snapshots.go b/internal/docs/snapshots.go| index e632f65..3ffe465 100644 |
| --- a/internal/docs/snapshots.go |
| +++ b/internal/docs/snapshots.go |
| @@ -49,6 +49,13 @@ func (s *Store) newestSnapshot(ctx context.Context, docID int64) (*DocSnapshot, |
| if err != nil { |
| return nil, fmt.Errorf("docs: newest snapshot for document %d: %w", docID, err) |
| } |
| + var doc Document |
| + if err := s.db.WithContext(ctx).Select("guid").First(&doc, docID).Error; err != nil { |
| + return nil, err |
| + } |
| + if err := s.openContentSnapshot(doc.GUID, &row); err != nil { |
| + return nil, err |
| + } |
| return &row, nil |
| } |
| @@ -102,6 +109,9 @@ func (s *Store) writeSnapshot(ctx context.Context, doc *Document, h *core.Handle |
| CreatedAt: time.Now().UTC(), |
| } |
| return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { |
| + if err := s.prepareContentSnapshot(tx, doc.GUID, row); err != nil { |
| + return err |
| + } |
| if err := tx.Create(row).Error; err != nil { |
| return fmt.Errorf("docs: persist snapshot for document %d at seq %d: %w", doc.ID, seq, err) |
| } |
diff --git a/internal/docs/store.go b/internal/docs/store.go| index 5ef83f9..5abefeb 100644 |
| --- a/internal/docs/store.go |
| +++ b/internal/docs/store.go |
| @@ -21,8 +21,9 @@ import ( |
| // the update log, and the document's actual text read back through |
| // the real CRDT core rather than any column here — see Text. |
| type Store struct { |
| - db *gorm.DB |
| - core *core.Runtime |
| + db *gorm.DB |
| + core *core.Runtime |
| + content *contentCipher |
| // sampleDocuments mirrors Config.SampleDocuments, already parsed — |
| // see EnsurePerson's own doc comment for what it gates and why the |
| @@ -451,6 +452,10 @@ func (s *Store) replay(ctx context.Context, doc *Document) (h *core.Handle, repl |
| } |
| for _, u := range updates { |
| + if err := s.openContentUpdate(doc.GUID, &u); err != nil { |
| + h.Close(ctx) |
| + return nil, 0, 0, err |
| + } |
| applyErr := h.Apply(ctx, u.UpdateBin) |
| if errors.Is(applyErr, core.ErrUpdateLogFull) { |
| // This handle has retained core.MaxRetainedUpdates updates |
| @@ -571,10 +576,7 @@ func (s *Store) reseedForReplay(ctx context.Context, doc *Document, full *core.H |
| } |
| // Text returns doc's current text — read from the real CRDT core by |
| -// replaying its update log, never from a database column. Sealing |
| -// (spec §2 — per-row encryption of update_bin, same as document |
| -// titles) is out of scope for this plan; this plaintext replay is not |
| -// a decision that content stays unsealed. |
| +// replaying its update log, never from a database column. |
| func (s *Store) Text(ctx context.Context, doc *Document) (string, error) { |
| h, _, _, err := s.replay(ctx, doc) |
| if err != nil { |
| @@ -853,6 +855,9 @@ func (s *Store) ApplyUpdate(ctx context.Context, doc *Document, actor Actor, upd |
| UpdateBin: update, |
| CreatedAt: time.Now().UTC(), |
| } |
| + if err := s.prepareContentUpdate(tx, doc.GUID, row); err != nil { |
| + return err |
| + } |
| if err := tx.Create(row).Error; err != nil { |
| return fmt.Errorf("docs: persist update for document %d: %w", doc.ID, err) |
| } |