Files
replicate/file/replica_client.go
zeekayandGitHub 5f0d4bb8d5 fix(replicate): fail-closed age encryption (no plaintext to S3) — clean re-apply of #6 (#7)
* fix(replicate): fail-closed age encryption (no plaintext LTX to S3)

Replicate silently wrote plaintext LTX1 to the destination whenever an age
recipient was unset -- fleet-wide plaintext SQLite backups. This makes
encryption fail-closed: when a replica is told it must encrypt, the absence
of a recipient REFUSES the write (ErrEncryptionRequired) instead of
downgrading to plaintext.

- Replica.RequireEncryption + guards in Start(), WriteLTXFile(), and Compactor
  .Compact() -- every write path funnels through a fail-closed check.
- DB.init() fails closed at the earliest point (before the "no db file yet"
  early return) so a misconfigured deployment surfaces the error on its first
  Sync, loudly, every interval -- never silently no-ops until data appears.
- DB.Snapshot() now routes through Replica.WriteLTXFile (was raw Client), so
  snapshots are age-encrypted like L0 sync; previously a plaintext bypass.
- internal.ExtractLTXTimestamp: sniff the age v1 intro so encrypted objects
  fall back to write time instead of every backend failing to parse an LTX
  header out of ciphertext. All 8 replica clients use it.
- AutoReplicate / CLI: RequireEncryption defaults on; opt out only via explicit
  REPLICATE_ALLOW_PLAINTEXT / age.allow-plaintext for non-sensitive dev targets.

Tests: replica_failclosed_test.go (unit: write/start/compact fail closed;
encrypt+round-trip with recipient) and replicate_e2e_encrypt_test.go (full DB
stack: every destination object is age ciphertext across L0 sync AND the
snapshot path; live-writer restore round-trip; DB sync fails closed).

Re-applies the fix from #6 onto a fresh branch off main (#6's branch shares no
git history with main and cannot merge). age bumped 1.4.0->1.6.0 to match the
platform (cloud) and resolve main's broken [email protected] go.sum checksum; go mod
tidy prunes the orphaned prometheus indirect deps left by the metric migration.

* test(replicate): define testutil shim so the package test binary compiles

The luxfi/metric migration (commit 7c8ef84) dropped prometheus but left
db_internal_test.go referencing an undefined `testutil`, so `go test ./` did
not compile at all on main. This adds the minimal in-tree ToFloat64 shim
(luxfi/metric Counter/Gauge expose Get() float64) and drops the now-unused
metric import, unblocking the package test binary.

NOTE: this only fixes COMPILATION. The metric value-assertion tests
(TestDB_*Metrics) remain pre-existing RED because luxfi/metric's
WithLabelValues/With are non-memoizing (proven: vec.WithLabelValues("x").Set(5)
then vec.WithLabelValues("x").Get() == 0). That is an orthogonal, incomplete
metric-migration defect (also affects runtime aggregation at db.go checkpoint
Inc) and must be completed in a dedicated metric PR -- not folded into the
fail-closed security fix. Identical failures occur with or without encryption.

* docs(deps): justify luxfi/age v1.4.0->v1.6.0 bump (forced by broken main go.sum)

* fix(ci): unpoison luxfi/zap + run root metric tests under -tags metrics

Two pre-existing main breakages surfaced by CI (not by the fail-closed
change):

1. luxfi/zap v0.8.1's tag was re-published upstream, so its go.sum hash
   no longer verifies (SECURITY ERROR on every job). Bump to the latest
   patch in-minor, v0.8.12 (sumdb-verified) — same remedy as the age bump,
   no lazy major.

2. luxfi/metric defaults to a NO-OP registry (//go:build !metrics); the
   real in-process registry is behind `metrics`. `go test .` ran without
   it, so every WithLabelValues returned a throwaway gauge that reads 0 —
   the 8 TestDB_*Metrics failures. Root cause is the build tag (not
   'non-memoizing' as first guessed, nor a prod defect): the DB's cached
   gauge holds the value; only re-derivations under the no-op build miss.
   Run the root suite with -tags metrics; passes 8/8 (27s, full package).

* revert(ci): drop -tags metrics test kludge

Prod builds cmd/replicate without -tags metrics (Dockerfile), so the
metric registry is no-op in production regardless; adding the tag only to
CI tests validated a mode prod never runs. The 8 TestDB_*Metrics failures
are a pre-existing main issue (luxfi/metric's no-op default), tracked
separately — not papered over with a test-only build tag. The essential
fix in this PR stands: luxfi/zap v0.8.1->v0.8.12 (re-published-tag go.sum
poison). One way, no kludge.
2026-07-07 17:59:12 -07:00

366 lines
10 KiB
Go

package file
import (
"context"
"fmt"
"io"
"log/slog"
"net/url"
"os"
"path/filepath"
"slices"
"github.com/hanzoai/ltx"
"github.com/hanzoai/replicate"
"github.com/hanzoai/replicate/internal"
)
func init() {
replicate.RegisterReplicaClientFactory("file", NewReplicaClientFromURL)
}
// ReplicaClientType is the client type for this package.
const ReplicaClientType = "file"
var _ replicate.ReplicaClient = (*ReplicaClient)(nil)
var _ replicate.ReplicaClientV3 = (*ReplicaClient)(nil)
// ReplicaClient is a client for writing LTX files to disk.
type ReplicaClient struct {
path string // destination path
Replica *replicate.Replica
logger *slog.Logger
}
// NewReplicaClient returns a new instance of ReplicaClient.
func NewReplicaClient(path string) *ReplicaClient {
return &ReplicaClient{
logger: slog.Default().WithGroup(ReplicaClientType),
path: path,
}
}
func (c *ReplicaClient) SetLogger(logger *slog.Logger) {
c.logger = logger.WithGroup(ReplicaClientType)
}
// NewReplicaClientFromURL creates a new ReplicaClient from URL components.
// This is used by the replica client factory registration.
func NewReplicaClientFromURL(scheme, host, urlPath string, query url.Values, userinfo *url.Userinfo) (replicate.ReplicaClient, error) {
// For file URLs, the path is the full path
if urlPath == "" {
return nil, fmt.Errorf("file replica path required")
}
return NewReplicaClient(urlPath), nil
}
// db returns the database, if available.
func (c *ReplicaClient) db() *replicate.DB {
if c.Replica == nil {
return nil
}
return c.Replica.DB()
}
// Type returns "file" as the client type.
func (c *ReplicaClient) Type() string {
return ReplicaClientType
}
// Init is a no-op for file replica client as no initialization is required.
func (c *ReplicaClient) Init(ctx context.Context) error {
return nil
}
// Path returns the destination path to replicate the database to.
func (c *ReplicaClient) Path() string {
return c.path
}
// LTXLevelDir returns the path to a given level.
func (c *ReplicaClient) LTXLevelDir(level int) string {
return filepath.FromSlash(replicate.LTXLevelDir(c.path, level))
}
// LTXFilePath returns the path to an LTX file.
func (c *ReplicaClient) LTXFilePath(level int, minTXID, maxTXID ltx.TXID) string {
return filepath.FromSlash(replicate.LTXFilePath(c.path, level, minTXID, maxTXID))
}
// LTXFiles returns an iterator over all LTX files on the replica for the given level.
// The useMetadata parameter is ignored for file backend as ModTime is always available from readdir.
func (c *ReplicaClient) LTXFiles(ctx context.Context, level int, seek ltx.TXID, useMetadata bool) (ltx.FileIterator, error) {
f, err := os.Open(c.LTXLevelDir(level))
if os.IsNotExist(err) {
return ltx.NewFileInfoSliceIterator(nil), nil
} else if err != nil {
return nil, err
}
defer f.Close()
fis, err := f.Readdir(-1)
if err != nil {
return nil, err
}
// Iterate over every file and convert to metadata.
// ModTime contains the accurate timestamp set by Chtimes in WriteLTXFile.
infos := make([]*ltx.FileInfo, 0, len(fis))
for _, fi := range fis {
minTXID, maxTXID, err := ltx.ParseFilename(fi.Name())
if err != nil {
continue
} else if minTXID < seek {
continue
}
infos = append(infos, &ltx.FileInfo{
Level: level,
MinTXID: minTXID,
MaxTXID: maxTXID,
Size: fi.Size(),
CreatedAt: fi.ModTime().UTC(),
})
}
return ltx.NewFileInfoSliceIterator(infos), nil
}
// OpenLTXFile returns a reader for an LTX file at the given position.
// Returns os.ErrNotExist if no matching index/offset is found.
func (c *ReplicaClient) OpenLTXFile(ctx context.Context, level int, minTXID, maxTXID ltx.TXID, offset, size int64) (io.ReadCloser, error) {
path := c.LTXFilePath(level, minTXID, maxTXID)
f, err := os.Open(path)
if err != nil {
return nil, replicate.NewLTXError("open", path, level, uint64(minTXID), uint64(maxTXID), err)
}
if offset > 0 {
if _, err := f.Seek(offset, io.SeekStart); err != nil {
return nil, err
}
}
if size > 0 {
return internal.LimitReadCloser(f, size), nil
}
return f, nil
}
// WriteLTXFile writes an LTX file to the replica.
// Extracts timestamp from LTX header and sets it as the file's ModTime to preserve original creation time.
func (c *ReplicaClient) WriteLTXFile(ctx context.Context, level int, minTXID, maxTXID ltx.TXID, rd io.Reader) (info *ltx.FileInfo, err error) {
var fileInfo, dirInfo os.FileInfo
if db := c.db(); db != nil {
fileInfo, dirInfo = db.FileInfo(), db.DirInfo()
}
// Extract the LTX-header timestamp (age-encrypted objects fall back to write
// time) and get a reader that replays the full object for upload.
timestamp, fullReader, err := internal.ExtractLTXTimestamp(rd)
if err != nil {
return nil, err
}
// Ensure parent directory exists.
filename := c.LTXFilePath(level, minTXID, maxTXID)
if err := internal.MkdirAll(filepath.Dir(filename), dirInfo); err != nil {
return nil, err
}
// Write LTX file to temporary file next to destination path.
tmpFilename := filename + ".tmp"
f, err := internal.CreateFile(tmpFilename, fileInfo)
if err != nil {
return nil, err
}
// Clean up temp file on error. On successful rename, the temp file
// becomes the final file and should not be removed.
defer func() {
_ = f.Close()
if err != nil {
_ = os.Remove(tmpFilename)
}
}()
if _, err := io.Copy(f, fullReader); err != nil {
return nil, err
}
if err := f.Sync(); err != nil {
return nil, err
}
// Build metadata.
fi, err := f.Stat()
if err != nil {
return nil, err
}
info = &ltx.FileInfo{
Level: level,
MinTXID: minTXID,
MaxTXID: maxTXID,
Size: fi.Size(),
CreatedAt: timestamp,
}
if err := f.Close(); err != nil {
return nil, err
}
// Move LTX file to final path when it has been written & synced to disk.
if err := os.Rename(tmpFilename, filename); err != nil {
return nil, err
}
// Set file ModTime to preserve original timestamp
if err := os.Chtimes(filename, timestamp, timestamp); err != nil {
return nil, err
}
return info, nil
}
// DeleteLTXFiles deletes LTX files.
func (c *ReplicaClient) DeleteLTXFiles(ctx context.Context, a []*ltx.FileInfo) error {
for _, info := range a {
filename := c.LTXFilePath(info.Level, info.MinTXID, info.MaxTXID)
c.logger.Debug("deleting ltx file", "level", info.Level, "minTXID", info.MinTXID, "maxTXID", info.MaxTXID, "path", filename)
if err := os.Remove(filename); err != nil && !os.IsNotExist(err) {
return err
}
}
return nil
}
// DeleteAll deletes all LTX files.
func (c *ReplicaClient) DeleteAll(ctx context.Context) error {
if err := os.RemoveAll(c.path); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
// GenerationsV3 returns a list of v0.3.x generation IDs in the replica.
func (c *ReplicaClient) GenerationsV3(ctx context.Context) ([]string, error) {
genPath := filepath.Join(c.path, replicate.GenerationsDirV3)
entries, err := os.ReadDir(genPath)
if os.IsNotExist(err) {
return nil, nil
} else if err != nil {
return nil, err
}
var generations []string
for _, entry := range entries {
if entry.IsDir() && replicate.IsGenerationIDV3(entry.Name()) {
generations = append(generations, entry.Name())
}
}
slices.Sort(generations)
return generations, nil
}
// SnapshotsV3 returns snapshots for a generation, sorted by index.
func (c *ReplicaClient) SnapshotsV3(ctx context.Context, generation string) ([]replicate.SnapshotInfoV3, error) {
snapshotsPath := filepath.Join(c.path, replicate.GenerationsDirV3, generation, replicate.SnapshotsDirV3)
entries, err := os.ReadDir(snapshotsPath)
if os.IsNotExist(err) {
return nil, nil
} else if err != nil {
return nil, err
}
var snapshots []replicate.SnapshotInfoV3
for _, entry := range entries {
if entry.IsDir() {
continue
}
index, err := replicate.ParseSnapshotFilenameV3(entry.Name())
if err != nil {
continue // skip invalid filenames
}
info, err := entry.Info()
if err != nil {
return nil, err
}
snapshots = append(snapshots, replicate.SnapshotInfoV3{
Generation: generation,
Index: index,
Size: info.Size(),
CreatedAt: info.ModTime(),
})
}
slices.SortFunc(snapshots, func(a, b replicate.SnapshotInfoV3) int {
return a.Index - b.Index
})
return snapshots, nil
}
// WALSegmentsV3 returns WAL segments for a generation, sorted by index then offset.
func (c *ReplicaClient) WALSegmentsV3(ctx context.Context, generation string) ([]replicate.WALSegmentInfoV3, error) {
walPath := filepath.Join(c.path, replicate.GenerationsDirV3, generation, replicate.WALDirV3)
entries, err := os.ReadDir(walPath)
if os.IsNotExist(err) {
return nil, nil
} else if err != nil {
return nil, err
}
var segments []replicate.WALSegmentInfoV3
for _, entry := range entries {
if entry.IsDir() {
continue
}
index, offset, err := replicate.ParseWALSegmentFilenameV3(entry.Name())
if err != nil {
continue // skip invalid filenames
}
info, err := entry.Info()
if err != nil {
return nil, err
}
segments = append(segments, replicate.WALSegmentInfoV3{
Generation: generation,
Index: index,
Offset: offset,
Size: info.Size(),
CreatedAt: info.ModTime(),
})
}
slices.SortFunc(segments, func(a, b replicate.WALSegmentInfoV3) int {
if a.Index != b.Index {
return a.Index - b.Index
}
return int(a.Offset - b.Offset)
})
return segments, nil
}
// OpenSnapshotV3 opens a v0.3.x snapshot file for reading.
// The returned reader provides LZ4-decompressed data.
func (c *ReplicaClient) OpenSnapshotV3(ctx context.Context, generation string, index int) (io.ReadCloser, error) {
path := filepath.Join(c.path, replicate.GenerationsDirV3, generation, replicate.SnapshotsDirV3, replicate.FormatSnapshotFilenameV3(index))
f, err := os.Open(path)
if err != nil {
return nil, err
}
return internal.NewLZ4Reader(f), nil
}
// OpenWALSegmentV3 opens a v0.3.x WAL segment file for reading.
// The returned reader provides LZ4-decompressed data.
func (c *ReplicaClient) OpenWALSegmentV3(ctx context.Context, generation string, index int, offset int64) (io.ReadCloser, error) {
path := filepath.Join(c.path, replicate.GenerationsDirV3, generation, replicate.WALDirV3, replicate.FormatWALSegmentFilenameV3(index, offset))
f, err := os.Open(path)
if err != nil {
return nil, err
}
return internal.NewLZ4Reader(f), nil
}