mirror of
https://github.com/hanzoai/base.git
synced 2026-11-02 20:08:14 +00:00
Supersedes the auto-closed #36 (its base branch was deleted when #35 merged). Gives each per-tenant SQLite DB continuous WAL streaming to durable replica storage + restore-if-absent, so a pod dying restores each tenant DB from its replica — and the replica is encrypted with the SAME per-tenant age key as #35's whole-file path. store/encreplica — a replicate.ReplicaClient that age-encrypts (ML-KEM-768 + X25519) every LTX segment BEFORE it touches storage and decrypts on read. This is the SOLE at-rest boundary for the replica stream. Encryption lives in the client (after the plaintext LTX header is read), which is both the working path and the correct single boundary — replicate v0.8.0's built-in Replica.AgeRecipients encrypts before the file/s3 client peeks the header and is unusable. Self-describing framing [8B plaintext len][8B ts][age ciphertext] lets LTXFiles report accurate FileInfo (restore needs Size >= HeaderSize + a resumable reader) without leaking any tenant bytes. Backend is a pluggable Blobs store (LocalBlobs now; S3/vfs in prod — encryption identical per backend). store/replicator — Open/Sync/Close streaming + Restore. Restore has a SQLite safety gate: the reconstructed file must pass the magic check (defense in depth beyond the client's per-tenant age-decrypt) or it is removed and rejected. Addresses Red review of the prior HA PR: - C1 (CRITICAL, proven): the replica was plaintext (the encrypting client only existed in comments). NOW REAL + WIRED + TESTED — TestReplicaBytesAreEncrypted_NoPlaintextMarker runs Red's exact PoC and the marker never appears in the clear; every LTX blob is age ciphertext. - H1b: Restore now validates the restored SQLite before returning success. Tests (CGO_ENABLED=0 go test ./store/encreplica/ ./store/replicator/): no-plaintext-marker, write->replicate->wipe->restore->read, cross-tenant restore FAILS, no-replica gate, two-cycle failover, LocalBlobs + New() validation. Note: replicate transitively registers modernc.org/sqlite, which collides with hanzoai/sqlite's mattn driver under CGO — the canonical pure-Go build (CGO_ENABLED=0) resolves both to one registration. Staged next (own PR): wire the replicator into MultiTenantStore hydrate (restore-if-absent) / Checkpoint (Sync) / evict (Close) — H1a — handling the fresh-tenant empty-file open and WAL-checkpoint coordination; + single-writer lease/fencing (H1c). Not rushed into the data layer. Co-authored-by: Hanzo Dev <[email protected]>
74 lines
1.9 KiB
Go
74 lines
1.9 KiB
Go
package encreplica_test
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"testing"
|
|
|
|
"github.com/hanzoai/base/store/encreplica"
|
|
"github.com/luxfi/age"
|
|
)
|
|
|
|
func TestLocalBlobs_PutGetListDelete(t *testing.T) {
|
|
ctx := context.Background()
|
|
b := encreplica.NewLocalBlobs(t.TempDir())
|
|
|
|
if err := b.Put(ctx, "ltx/0/a", []byte("one")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := b.Put(ctx, "ltx/0/b", []byte("two")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, err := b.Get(ctx, "ltx/0/a")
|
|
if err != nil || string(got) != "one" {
|
|
t.Fatalf("get: %q %v", got, err)
|
|
}
|
|
|
|
keys, err := b.List(ctx, "ltx/0/")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(keys) != 2 {
|
|
t.Fatalf("list: want 2, got %d (%v)", len(keys), keys)
|
|
}
|
|
|
|
if err := b.Delete(ctx, "ltx/0/a"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := b.Get(ctx, "ltx/0/a"); !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatalf("deleted key must be ErrNotExist, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestLocalBlobs_MissingIsErrNotExistAndEmptyList(t *testing.T) {
|
|
ctx := context.Background()
|
|
b := encreplica.NewLocalBlobs(t.TempDir())
|
|
if _, err := b.Get(ctx, "ltx/0/missing"); !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatalf("missing key must be ErrNotExist, got %v", err)
|
|
}
|
|
keys, err := b.List(ctx, "ltx/9/")
|
|
if err != nil {
|
|
t.Fatalf("list of missing prefix must not error: %v", err)
|
|
}
|
|
if len(keys) != 0 {
|
|
t.Fatalf("list of missing prefix must be empty, got %v", keys)
|
|
}
|
|
}
|
|
|
|
func TestNew_RequiresBackendAndKey(t *testing.T) {
|
|
id, err := age.GenerateHybridIdentity()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := encreplica.New(nil, id.Recipient(), id); err == nil {
|
|
t.Fatal("New must require a backend")
|
|
}
|
|
if _, err := encreplica.New(encreplica.NewLocalBlobs(t.TempDir()), nil, id); err == nil {
|
|
t.Fatal("New must require a recipient")
|
|
}
|
|
if _, err := encreplica.New(encreplica.NewLocalBlobs(t.TempDir()), id.Recipient(), nil); err == nil {
|
|
t.Fatal("New must require an identity")
|
|
}
|
|
}
|