Рыба проекта. Минимальная функциональность

This commit is contained in:
2026-08-03 22:22:24 +03:00
commit 8c8631ac9c
80 changed files with 10618 additions and 0 deletions
+75
View File
@@ -0,0 +1,75 @@
package backup
import (
"context"
"crypto/rand"
"fmt"
"time"
"git.tswf.io/infra/go-synapse-backupper/pkg/adapters/config"
"git.tswf.io/infra/go-synapse-backupper/pkg/adapters/crypto/composite"
"git.tswf.io/infra/go-synapse-backupper/pkg/adapters/crypto/keymanager"
"git.tswf.io/infra/go-synapse-backupper/pkg/adapters/crypto/mlkem768"
"git.tswf.io/infra/go-synapse-backupper/pkg/adapters/crypto/x25519"
adapterpgdump "git.tswf.io/infra/go-synapse-backupper/pkg/adapters/pgdump"
"git.tswf.io/infra/go-synapse-backupper/pkg/adapters/pipeline"
"git.tswf.io/infra/go-synapse-backupper/pkg/adapters/retention"
"git.tswf.io/infra/go-synapse-backupper/pkg/adapters/storage/local"
domaincrypto "git.tswf.io/infra/go-synapse-backupper/pkg/domain/crypto"
domainpgdump "git.tswf.io/infra/go-synapse-backupper/pkg/domain/pgdump"
)
func RunOnce(ctx context.Context, cfg *config.Config) error {
registry := domaincrypto.NewRegistry()
if err := registry.Register(0x0006, func() domaincrypto.KEM { return mlkem768.New() }); err != nil {
return fmt.Errorf("register mlkem768: %w", err)
}
if err := registry.Register(0x0007, func() domaincrypto.KEM { return x25519.New() }); err != nil {
return fmt.Errorf("register x25519: %w", err)
}
keyMgr := keymanager.NewKeyManager(registry)
pqPub, err := keyMgr.LoadPub(cfg.PQPublicKeyPath, cfg.PQScheme)
if err != nil {
return fmt.Errorf("load pq public key: %w", err)
}
classicalPub, err := keyMgr.LoadPub(cfg.ClassicalPublicKeyPath, cfg.ClassicalScheme)
if err != nil {
return fmt.Errorf("load classical public key: %w", err)
}
recipients := []domaincrypto.RecipientPub{pqPub, classicalPub}
encryptor := composite.NewEncryptor(registry)
dumper := adapterpgdump.New()
sink := local.NewLocalSink(cfg.Backup.Dir)
runner := pipeline.NewRunner(
pipeline.WithDumper(dumper),
pipeline.WithEncryptor(encryptor),
)
now := time.Now()
timestamp := now.UTC().Format("20060102-150405")
pgDumpOpts := domainpgdump.Options{
Host: cfg.PG.Host,
Port: cfg.PG.Port,
Database: cfg.PG.Database,
User: cfg.PG.User,
Password: cfg.PG.Password,
ExcludeTables: cfg.PG.ExcludeTables,
Key: fmt.Sprintf("synapse-%s.dump.pqenc", timestamp),
}
if err := runner.Run(ctx, pgDumpOpts, recipients, sink, rand.Reader); err != nil {
return fmt.Errorf("backup pipeline failed: %w", err)
}
if _, err := retention.PruneByAge(ctx, cfg.Backup.Dir, cfg.Backup.RetentionDays, now); err != nil {
return fmt.Errorf("retention pruning failed: %w", err)
}
return nil
}
+129
View File
@@ -0,0 +1,129 @@
package backup
import (
"context"
"os"
"strings"
"testing"
"git.tswf.io/infra/go-synapse-backupper/pkg/adapters/config"
"git.tswf.io/infra/go-synapse-backupper/pkg/adapters/crypto/keymanager"
"git.tswf.io/infra/go-synapse-backupper/pkg/adapters/crypto/mlkem768"
"git.tswf.io/infra/go-synapse-backupper/pkg/adapters/crypto/x25519"
"git.tswf.io/infra/go-synapse-backupper/pkg/domain/crypto"
)
// TestRunOnce_MissingKeys verifies that RunOnce surfaces a configuration error
// when the public key paths are not provided, exercising the early validation
// path (registry build, keymanager construction, key load).
func TestRunOnce_MissingKeys(t *testing.T) {
cfg := &config.Config{}
// PQPublicKeyPath and ClassicalPublicKeyPath intentionally left empty.
err := RunOnce(context.Background(), cfg)
if err == nil {
t.Fatal("RunOnce returned nil error with empty config, want error")
}
if !strings.Contains(err.Error(), "load pq public key") {
t.Fatalf("RunOnce error = %q, want it to mention %q", err, "load pq public key")
}
}
// TestRunOnce_PQKeyNotFound verifies the error returned when the post-quantum
// public key path is set but the file does not exist on disk.
func TestRunOnce_PQKeyNotFound(t *testing.T) {
cfg := &config.Config{
PQScheme: 0x0006,
// Path points to a file that does not exist; use t.TempDir() to keep
// the test hermetic regardless of the working directory.
PQPublicKeyPath: t.TempDir() + "/does-not-exist-pq.pem",
ClassicalPublicKeyPath: t.TempDir() + "/does-not-exist-classical.pem",
}
err := RunOnce(context.Background(), cfg)
if err == nil {
t.Fatal("RunOnce returned nil error when keys are missing on disk, want error")
}
if !strings.Contains(err.Error(), "load pq public key") {
t.Fatalf("RunOnce error = %q, want it to mention %q", err, "load pq public key")
}
}
// TestRunOnce_ValidKeys_PgDumpMissing generates real dual-KEM keys, builds a
// fully valid config, and calls RunOnce. Since pg_dump is not installed in the
// test environment, the pipeline fails at the dump step, giving coverage of
// the full orchestration path (registry, keymanager, encryptor, sink, runner,
// pgDumpOpts assembly) while still asserting the expected error.
func TestRunOnce_ValidKeys_PgDumpMissing(t *testing.T) {
dir := t.TempDir()
keyDir := dir + "/keys"
backupDir := dir + "/backups"
_ = os.MkdirAll(keyDir, 0o755)
_ = os.MkdirAll(backupDir, 0o755)
reg := crypto.NewRegistry()
_ = reg.Register(0x0006, func() crypto.KEM { return mlkem768.New() })
_ = reg.Register(0x0007, func() crypto.KEM { return x25519.New() })
km := keymanager.NewKeyManager(reg)
pqPubFile, _ := os.Create(keyDir + "/pq.pub.pem")
pqPrivFile, _ := os.Create(keyDir + "/pq.priv.pem")
_ = km.Generate(0x0006, pqPubFile, pqPrivFile, nil)
_ = pqPubFile.Close()
_ = pqPrivFile.Close()
classicalPubFile, _ := os.Create(keyDir + "/classical.pub.pem")
classicalPrivFile, _ := os.Create(keyDir + "/classical.priv.pem")
_ = km.Generate(0x0007, classicalPubFile, classicalPrivFile, nil)
_ = classicalPubFile.Close()
_ = classicalPrivFile.Close()
cfg := &config.Config{
PQScheme: 0x0006,
ClassicalScheme: 0x0007,
PQPublicKeyPath: keyDir + "/pq.pub.pem",
ClassicalPublicKeyPath: keyDir + "/classical.pub.pem",
Backup: struct {
Dir string `mapstructure:"dir"`
RetentionDays int `mapstructure:"retention_days"`
Cron string `mapstructure:"cron"`
}{
Dir: backupDir,
RetentionDays: 180,
},
PG: struct {
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
User string `mapstructure:"user"`
Password string `mapstructure:"password"`
Database string `mapstructure:"database"`
SSLMode string `mapstructure:"sslmode"`
ExcludeTables []string `mapstructure:"exclude_tables"`
}{
Host: "localhost",
Port: 5432,
User: "test",
Password: "test",
Database: "test",
ExcludeTables: []string{"e2e_one_time_keys_json"},
},
}
err := RunOnce(context.Background(), cfg)
if err == nil {
t.Fatal("expected error because pg_dump is not installed in the test environment")
}
if !strings.Contains(err.Error(), "backup pipeline failed") {
t.Fatalf("expected error containing 'backup pipeline failed', got: %v", err)
}
entries, _ := os.ReadDir(backupDir)
for _, e := range entries {
if strings.HasSuffix(e.Name(), ".tmp") {
t.Fatalf("unexpected .tmp file after failed backup: %s", e.Name())
}
}
}
+12
View File
@@ -0,0 +1,12 @@
package crypto
import "io"
// Decryptor defines the contract for decrypting ciphertext using private keys.
type Decryptor interface {
Decrypt(
src io.Reader,
privs []RecipientPriv,
plaintext io.Writer,
) error
}
+13
View File
@@ -0,0 +1,13 @@
package crypto
import "io"
// Encryptor defines the contract for encrypting plaintext to multiple recipients.
type Encryptor interface {
Encrypt(
plaintext io.Reader,
recipients []RecipientPub,
sink io.Writer,
rand io.Reader,
) error
}
+50
View File
@@ -0,0 +1,50 @@
package crypto
import "io"
// KEM defines the contract for a Key Encapsulation Mechanism.
type KEM interface {
SchemeID() uint16
GenerateKeyPair(
rand io.Reader,
) (
RecipientPub,
RecipientPriv,
error,
)
Encapsulate(
pub RecipientPub,
rand io.Reader,
) (
ciphertext []byte,
sharedSecret []byte,
err error,
)
Decapsulate(
priv RecipientPriv,
ciphertext []byte,
) (
sharedSecret []byte,
err error,
)
LoadPriv(
raw []byte,
) (
RecipientPriv,
error,
)
}
// RecipientPub represents a public recipient key.
type RecipientPub interface {
SchemeID() uint16
KeyID() []byte
Raw() []byte
}
// RecipientPriv represents a private recipient key.
type RecipientPriv interface {
SchemeID() uint16
KeyID() []byte
Raw() []byte
}
+27
View File
@@ -0,0 +1,27 @@
package crypto
import "io"
// KeyManager defines the contract for loading and generating recipient keys.
type KeyManager interface {
LoadPub(
path string,
schemeID uint16,
) (
RecipientPub,
error,
)
LoadPriv(
path string,
schemeID uint16,
) (
RecipientPriv,
error,
)
Generate(
schemeID uint16,
pubOut io.Writer,
privOut io.Writer,
rand io.Reader,
) error
}
+84
View File
@@ -0,0 +1,84 @@
package crypto
import (
"errors"
"fmt"
"sync"
)
// Sentinel errors for registry operations.
var (
ErrUnknownScheme = errors.New("unknown KEM scheme")
ErrDuplicateScheme = errors.New("duplicate KEM scheme registration")
)
// KEMFactory is a constructor function that returns a fresh KEM instance.
type KEMFactory func() KEM
// Registry is a concurrent-safe map of KEM scheme IDs to their factories.
type Registry interface {
Register(
schemeID uint16,
factory KEMFactory,
) error
Lookup(
schemeID uint16,
) (
KEMFactory,
error,
)
}
// registry is the private implementation of Registry.
type registry struct {
mutex sync.RWMutex
factories map[uint16]KEMFactory
}
// NewRegistry creates a new empty Registry.
func NewRegistry() Registry {
return &registry{
factories: make(map[uint16]KEMFactory),
}
}
// Register adds a KEMFactory for the given schemeID.
// Returns ErrDuplicateScheme if the schemeID is already registered.
func (r *registry) Register(schemeID uint16, factory KEMFactory) error {
r.mutex.Lock()
defer r.mutex.Unlock()
if _, exists := r.factories[schemeID]; exists {
return fmt.Errorf(
"scheme 0x%04x: %w",
schemeID,
ErrDuplicateScheme,
)
}
r.factories[schemeID] = factory
return nil
}
// Lookup retrieves the KEMFactory for the given schemeID.
// Returns ErrUnknownScheme if the schemeID is not registered.
func (r *registry) Lookup(
schemeID uint16,
) (
KEMFactory,
error,
) {
r.mutex.RLock()
defer r.mutex.RUnlock()
factory, exists := r.factories[schemeID]
if !exists {
return nil, fmt.Errorf(
"scheme 0x%04x: %w",
schemeID,
ErrUnknownScheme,
)
}
return factory, nil
}
+173
View File
@@ -0,0 +1,173 @@
package crypto
import (
"errors"
"io"
"sync"
"testing"
)
// mockKEM is a minimal KEM implementation for testing the registry.
type mockKEM struct {
schemeID uint16
}
func (m *mockKEM) SchemeID() uint16 {
return m.schemeID
}
func (m *mockKEM) GenerateKeyPair(
rand io.Reader,
) (
RecipientPub,
RecipientPriv,
error,
) {
return nil, nil, nil
}
func (m *mockKEM) Encapsulate(
pub RecipientPub,
rand io.Reader,
) (
ciphertext []byte,
sharedSecret []byte,
err error,
) {
return nil, nil, nil
}
func (m *mockKEM) Decapsulate(
priv RecipientPriv,
ciphertext []byte,
) (
sharedSecret []byte,
err error,
) {
return nil, nil
}
func (m *mockKEM) LoadPriv(
raw []byte,
) (
RecipientPriv,
error,
) {
return &mockRecipient{schemeIDValue: m.schemeID, rawBytes: raw}, nil
}
// mockRecipient implements RecipientPub and RecipientPriv for tests.
type mockRecipient struct {
schemeIDValue uint16
rawBytes []byte
}
func (r *mockRecipient) SchemeID() uint16 {
return r.schemeIDValue
}
func (r *mockRecipient) KeyID() []byte {
return r.rawBytes
}
func (r *mockRecipient) Raw() []byte {
return r.rawBytes
}
func TestRegistry_RegisterAndLookup(t *testing.T) {
registry := NewRegistry()
factory := func() KEM {
return &mockKEM{schemeID: 0x0001}
}
err := registry.Register(0x0001, factory)
if err != nil {
t.Fatalf("unexpected error registering scheme: %v", err)
}
foundFactory, err := registry.Lookup(0x0001)
if err != nil {
t.Fatalf("unexpected error looking up scheme: %v", err)
}
kem := foundFactory()
if kem.SchemeID() != 0x0001 {
t.Errorf("expected schemeID 0x0001, got 0x%04x", kem.SchemeID())
}
}
func TestRegistry_LookupUnknownScheme(t *testing.T) {
registry := NewRegistry()
_, err := registry.Lookup(0x9999)
if err == nil {
t.Fatal("expected error for unknown scheme, got nil")
}
if !errors.Is(err, ErrUnknownScheme) {
t.Errorf("expected ErrUnknownScheme, got %v", err)
}
}
func TestRegistry_RegisterDuplicateScheme(t *testing.T) {
registry := NewRegistry()
factory := func() KEM {
return &mockKEM{schemeID: 0x0001}
}
err := registry.Register(0x0001, factory)
if err != nil {
t.Fatalf("unexpected error on first register: %v", err)
}
err = registry.Register(0x0001, factory)
if err == nil {
t.Fatal("expected error for duplicate scheme, got nil")
}
if !errors.Is(err, ErrDuplicateScheme) {
t.Errorf("expected ErrDuplicateScheme, got %v", err)
}
}
func TestRegistry_ConcurrentAccess(t *testing.T) {
registry := NewRegistry()
const goroutines = 100
var waitGroup sync.WaitGroup
waitGroup.Add(goroutines)
for index := range goroutines {
go func(schemeID uint16) {
defer waitGroup.Done()
factory := func() KEM {
return &mockKEM{schemeID: schemeID}
}
_ = registry.Register(schemeID, factory)
_, _ = registry.Lookup(schemeID)
}(uint16(index + 1))
}
waitGroup.Wait()
}
func TestRegistry_LookupReturnsIndependentInstances(t *testing.T) {
registry := NewRegistry()
factory := func() KEM {
return &mockKEM{schemeID: 0x0001}
}
_ = registry.Register(0x0001, factory)
factoryOne, _ := registry.Lookup(0x0001)
factoryTwo, _ := registry.Lookup(0x0001)
kemOne := factoryOne()
kemTwo := factoryTwo()
if kemOne == kemTwo {
t.Error("expected independent KEM instances from factory")
}
}
+50
View File
@@ -0,0 +1,50 @@
package pgdump
import (
"context"
"fmt"
"io"
)
// Options holds configuration for the pg_dump invocation.
type Options struct {
Host string
Port int
Database string
User string
Password string
Key string
ExcludeTables []string
}
// Dumper defines the contract for executing pg_dump.
type Dumper interface {
Dump(
ctx context.Context,
opts Options,
sink io.Writer,
) error
}
// pgDumpFailedError is returned when pg_dump exits with a non-zero status.
type pgDumpFailedError struct {
exitCode int
}
func (e *pgDumpFailedError) Error() string {
return fmt.Sprintf("pg_dump failed with exit code %d", e.exitCode)
}
// Is reports whether target is a pgDumpFailedError with the same exit code.
func (e *pgDumpFailedError) Is(target error) bool {
other, ok := target.(*pgDumpFailedError)
if !ok {
return false
}
return e.exitCode == other.exitCode
}
// ErrPgDumpFailed creates a new pg_dump failed error with the given exit code.
func ErrPgDumpFailed(exitCode int) error {
return &pgDumpFailedError{exitCode: exitCode}
}
+72
View File
@@ -0,0 +1,72 @@
package pgdump
import (
"errors"
"fmt"
"testing"
)
func TestErrPgDumpFailed_Error(t *testing.T) {
cases := []struct {
name string
exitCode int
want string
}{
{name: "zero", exitCode: 0, want: "pg_dump failed with exit code 0"},
{name: "one", exitCode: 1, want: "pg_dump failed with exit code 1"},
{name: "large", exitCode: 137, want: "pg_dump failed with exit code 137"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := ErrPgDumpFailed(tc.exitCode)
if err == nil {
t.Fatalf("ErrPgDumpFailed(%d) returned nil, want error", tc.exitCode)
}
if got := err.Error(); got != tc.want {
t.Fatalf("Error() = %q, want %q", got, tc.want)
}
})
}
}
func TestErrPgDumpFailed_Is(t *testing.T) {
base := ErrPgDumpFailed(1)
cases := []struct {
name string
target error
want bool
}{
{
name: "same exit code",
target: ErrPgDumpFailed(1),
want: true,
},
{
name: "different exit code",
target: ErrPgDumpFailed(2),
want: false,
},
{
name: "unrelated error type",
target: errors.New("something else"),
want: false,
},
{
// Is does not unwrap target: a fmt.Errorf-wrapped pgDumpFailedError
// fails the exact type check, so errors.Is reports false here.
name: "wrapped same-type error",
target: fmt.Errorf("wrapped: %w", ErrPgDumpFailed(1)),
want: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := errors.Is(base, tc.target); got != tc.want {
t.Fatalf("errors.Is(%v, %v) = %v, want %v", base, tc.target, got, tc.want)
}
})
}
}
+12
View File
@@ -0,0 +1,12 @@
package domain
import "context"
// Scheduler is the abstraction for a recurring job scheduler.
type Scheduler interface {
// Start begins executing the scheduled job.
Start()
// Stop halts the scheduler and returns a context that is cancelled
// once all in-flight jobs have completed.
Stop() (stoppedCtx context.Context)
}
+17
View File
@@ -0,0 +1,17 @@
package domain
import "io"
// Sink defines the contract for a storage sink that supports atomic two-phase writes.
type Sink interface {
Begin(key string) (SinkTx, error)
List(prefix string) ([]string, error)
Remove(key string) error
}
// SinkTx represents an in-progress write transaction.
type SinkTx interface {
io.Writer
Commit() error
Abort() error
}