Skip to content

Commit 930885e

Browse files
committed
fix: address CodeRabbit PR review findings
Implemented (real bugs / correctness / security): - sync: pw.CloseWithError(backupErr) so a failed backup aborts the destructive Clean:true restore instead of looking like clean EOF (data corruption). [app/sync.go] - backup: remove the renamed dump if WriteMeta fails (else invisible orphan, never pruned). [app/backup.go] - backup driver: discard pg_dump stderr directly instead of StderrPipe + drain goroutine, which raced with cmd.Wait(). [driver/postgres/backup.go] - DSN: single-quote + escape libpq keyword values so passwords with spaces/quotes/backslashes parse correctly. [driver/postgres/driver.go] - dumps: validate dump IDs (reject path separators / ..) before joining into catalog paths — prevents path traversal from restore/inspect/verify; Path/MetaPath also use filepath.Base defensively. [dumps/catalog.go] - profile show: mask literal passwords; secret refs (env:) shown verbatim. - dumps prune --apply: surface Delete errors instead of printing 'deleted' on failure. [cli/dumps.go] - root buildDeps: filepath.Join for the default dump dir + propagate the UserHomeDir error instead of building a non-portable path. [cli/root.go] - integration test: check Host/MappedPort errors (fail fast). - doc comments: sync (always-stream in Phase B) and Capabilities.Parallel (not wired yet) now match the code. Regression tests: DSN special-char round-trip via pgx.ParseConfig; catalog rejects traversal IDs. Skipped (with reason): - CI hardening (persist-credentials / SHA-pin): declined per maintainer (keep floating @latest tags). - secrets/resolver colon-in-literal parse: internal/secrets is permission- restricted in this session; flagged for follow-up. - config.Path() UserHomeDir error: would require an API signature change across many callers for a near-impossible failure; XDG/APPDATA override it anyway. Deferred.
1 parent 30e0095 commit 930885e

11 files changed

Lines changed: 137 additions & 31 deletions

File tree

internal/app/backup.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,9 @@ func Backup(parent context.Context, d Deps, opt BackupOpts) (<-chan jobs.Event,
127127
DumpFormat: "custom",
128128
}
129129
if writeErr := d.Dumps.WriteMeta(meta); writeErr != nil {
130+
// The catalog enumerates by sidecar metadata, so a dump without
131+
// its meta would be an invisible orphan that never gets pruned.
132+
_ = os.Remove(finalPath)
130133
return writeErr
131134
}
132135

internal/app/sync.go

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,10 @@ type SyncOpts struct {
1616
Tables []string
1717
}
1818

19-
// Sync backs up From and restores into To. If Stream==true and both
20-
// drivers report NativeStream, the data flows through an in-memory pipe
21-
// (no temp file). Otherwise it falls back to Backup-then-Restore via
22-
// the catalog (Phase B implementation).
19+
// Sync backs up From and restores into To. In Phase B the data always flows
20+
// through an in-memory io.Pipe (no temp file, no catalog entry). The opt.Stream
21+
// flag and Capabilities().NativeStream gating — and a catalog fallback for
22+
// drivers that can't stream — are planned for Phase F; today Stream is unused.
2323
func Sync(parent context.Context, d Deps, opt SyncOpts) (<-chan jobs.Event, string, error) {
2424
src, err := d.Profiles.Resolve(opt.From)
2525
if err != nil {
@@ -57,8 +57,13 @@ func Sync(parent context.Context, d Deps, opt SyncOpts) (<-chan jobs.Event, stri
5757
errCh := make(chan error, 1)
5858

5959
go func() {
60-
errCh <- srcConn.Backup(ctx, driver.BackupOpts{IncludeTables: opt.Tables}, pw)
61-
_ = pw.Close()
60+
bErr := srcConn.Backup(ctx, driver.BackupOpts{IncludeTables: opt.Tables}, pw)
61+
// CloseWithError propagates a backup failure to the reader as a
62+
// read error instead of a clean io.EOF. Without this, a truncated
63+
// dump looks complete to Restore — which, with Clean:true, has
64+
// already dropped the target and would commit partial data.
65+
_ = pw.CloseWithError(bErr)
66+
errCh <- bErr
6267
}()
6368

6469
restoreErr := dstConn.Restore(ctx, driver.RestoreOpts{TargetTables: opt.Tables, Clean: true}, pr)

internal/cli/dumps.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,10 @@ func dumpsPruneCmd() *cobra.Command {
7171
}
7272
for _, m := range rep.Would {
7373
if apply {
74-
_ = deps.Dumps.Delete(m.ID)
74+
if delErr := deps.Dumps.Delete(m.ID); delErr != nil {
75+
_, _ = fmt.Fprintf(c.OutOrStdout(), " ! failed to delete %s: %v\n", m.ID, delErr)
76+
continue
77+
}
7578
_, _ = fmt.Fprintf(c.OutOrStdout(), " ✗ deleted %s\n", m.ID)
7679
} else {
7780
_, _ = fmt.Fprintf(c.OutOrStdout(), " would delete %s\n", m.ID)

internal/cli/profile.go

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package cli
22

33
import (
44
"fmt"
5+
"strings"
56

67
"github.com/spf13/cobra"
78

@@ -77,7 +78,7 @@ func profileRmCmd() *cobra.Command {
7778

7879
func profileShowCmd() *cobra.Command {
7980
return &cobra.Command{
80-
Use: "show <name>", Short: "Show a profile (with secret refs)", Args: cobra.ExactArgs(1),
81+
Use: "show <name>", Short: "Show a profile (secret refs shown; literal passwords masked)", Args: cobra.ExactArgs(1),
8182
RunE: func(c *cobra.Command, args []string) error {
8283
deps, err := buildDeps()
8384
if err != nil {
@@ -88,8 +89,23 @@ func profileShowCmd() *cobra.Command {
8889
return err
8990
}
9091
_, _ = fmt.Fprintf(c.OutOrStdout(), "driver: %s\nhost: %s\nport: %d\nuser: %s\ndatabase: %s\nsslmode: %s\npassword: %s\n",
91-
p.Driver, p.Host, p.Port, p.User, p.Database, p.SSLMode, p.Password)
92+
p.Driver, p.Host, p.Port, p.User, p.Database, p.SSLMode, maskSecret(p.Password))
9293
return nil
9394
},
9495
}
9596
}
97+
98+
// maskSecret returns ref-style secrets (e.g. "env:VAR") verbatim — they are
99+
// pointers, not the secret itself — but masks literal passwords so `show`
100+
// never leaks a cleartext credential to stdout / shell scrollback.
101+
func maskSecret(v string) string {
102+
if v == "" {
103+
return ""
104+
}
105+
for _, scheme := range []string{"env:", "keychain:", "vault:"} {
106+
if strings.HasPrefix(v, scheme) {
107+
return v
108+
}
109+
}
110+
return "••••••••"
111+
}

internal/cli/root.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"fmt"
88
"io"
99
"os"
10+
"path/filepath"
1011

1112
"github.com/spf13/cobra"
1213

@@ -68,8 +69,11 @@ func buildDeps() (app.Deps, error) {
6869

6970
dumpDir := cfg.Defaults.DumpDir
7071
if dumpDir == "" {
71-
home, _ := os.UserHomeDir()
72-
dumpDir = fmt.Sprintf("%s/.local/share/siphon/dumps", home)
72+
home, homeErr := os.UserHomeDir()
73+
if homeErr != nil {
74+
return app.Deps{}, fmt.Errorf("resolve home dir for default dump_dir: %w", homeErr)
75+
}
76+
dumpDir = filepath.Join(home, ".local", "share", "siphon", "dumps")
7377
}
7478
cat, err := dumps.NewCatalog(dumpDir)
7579
if err != nil {

internal/driver/postgres/backup.go

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,18 +19,15 @@ func (c *Conn) Backup(ctx context.Context, opt driver.BackupOpts, w io.Writer) e
1919
cmd := exec.CommandContext(ctx, "pg_dump", args...)
2020
cmd.Env = append(os.Environ(), "PGPASSWORD="+c.p.Password)
2121
cmd.Stdout = w
22+
// Discard stderr directly. Using StderrPipe + a drain goroutine would race
23+
// with cmd.Wait() (Wait closes the pipe once the process exits, which the
24+
// os/exec docs warn must not happen before reads complete). Phase F will
25+
// wire stderr through progress.go's parser; until then, discarding is safe.
26+
cmd.Stderr = io.Discard
2227

23-
stderr, err := cmd.StderrPipe()
24-
if err != nil {
25-
return err
26-
}
2728
if err := cmd.Start(); err != nil {
2829
return toolMissingOrSystemErr("postgres.backup", err)
2930
}
30-
31-
// Drain stderr (parsed into progress events later — see progress.go).
32-
go func() { _, _ = io.Copy(io.Discard, stderr) }()
33-
3431
if err := cmd.Wait(); err != nil {
3532
return &errs.Error{Op: "postgres.backup", Code: errs.CodeSystem, Cause: err}
3633
}

internal/driver/postgres/driver.go

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ func (Driver) Capabilities() driver.Capabilities {
2828
PerTable: true,
2929
SchemaOnly: true,
3030
DataOnly: true,
31-
Parallel: true, // pg_restore supports -j; pg_dump parallelism needs -Fd (see backup.go), so backups are single-stream in Phase B
31+
Parallel: true, // capability exists in the engine, but not wired in Phase B: pg_dump -j needs -Fd (see backup.go) and RestoreOpts carries no Parallel field yet — both land in a later phase
3232
Compression: true,
3333
BinaryFormat: true,
3434
CrossEngineSource: false, // Phase F
@@ -59,24 +59,34 @@ func (Driver) Connect(ctx context.Context, p driver.Profile) (driver.Conn, error
5959
func buildDSN(p driver.Profile) string {
6060
parts := make([]string, 0, 6)
6161
if p.Host != "" {
62-
parts = append(parts, "host="+p.Host)
62+
parts = append(parts, kv("host", p.Host))
6363
}
6464
if p.Port != 0 {
65-
parts = append(parts, "port="+strconv.Itoa(p.Port))
65+
parts = append(parts, kv("port", strconv.Itoa(p.Port)))
6666
}
6767
if p.User != "" {
68-
parts = append(parts, "user="+p.User)
68+
parts = append(parts, kv("user", p.User))
6969
}
7070
if p.Password != "" {
71-
parts = append(parts, "password="+p.Password)
71+
parts = append(parts, kv("password", p.Password))
7272
}
7373
if p.Database != "" {
74-
parts = append(parts, "dbname="+p.Database)
74+
parts = append(parts, kv("dbname", p.Database))
7575
}
76-
parts = append(parts, "sslmode="+defaultSSL(p.SSLMode))
76+
parts = append(parts, kv("sslmode", defaultSSL(p.SSLMode)))
7777
return strings.Join(parts, " ")
7878
}
7979

80+
// kv formats one libpq keyword DSN pair. Values containing spaces, quotes, or
81+
// backslashes must be single-quoted with ' and \ escaped (per libpq rules),
82+
// otherwise a value like "p@ss word" would be split across tokens and misparse.
83+
func kv(key, val string) string {
84+
if strings.ContainsAny(val, " '\\") {
85+
val = "'" + strings.NewReplacer(`\`, `\\`, `'`, `\'`).Replace(val) + "'"
86+
}
87+
return key + "=" + val
88+
}
89+
8090
func defaultSSL(s string) string {
8191
if s == "" {
8292
return "prefer"

internal/driver/postgres/driver_test.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,27 @@ func TestBuildDSN_FullProfile(t *testing.T) {
6565
}
6666
}
6767

68+
// TestBuildDSN_QuotesSpecialValues is the regression for libpq keyword escaping:
69+
// a password containing spaces/quotes/backslashes must be single-quoted (with '
70+
// and \ escaped) so pgx parses it back to the exact original, not split tokens.
71+
func TestBuildDSN_QuotesSpecialValues(t *testing.T) {
72+
for _, pw := range []string{"p@ss word", `quote'd`, `back\slash`, "a b c"} {
73+
dsn := buildDSN(driver.Profile{
74+
Host: "localhost", Port: 5432, User: "u", Password: pw, Database: "d", SSLMode: "disable",
75+
})
76+
cfg, err := pgx.ParseConfig(dsn)
77+
if err != nil {
78+
t.Fatalf("password %q: ParseConfig(%q): %v", pw, dsn, err)
79+
}
80+
if cfg.Password != pw {
81+
t.Fatalf("password %q round-tripped as %q via DSN %q", pw, cfg.Password, dsn)
82+
}
83+
if cfg.Database != "d" {
84+
t.Fatalf("password %q broke dbname: got %q", pw, cfg.Database)
85+
}
86+
}
87+
}
88+
6889
func TestBuildDSN_DefaultSSL(t *testing.T) {
6990
dsn := buildDSN(driver.Profile{
7091
Host: "localhost",

internal/driver/postgres/integration_test.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,14 @@ func startPostgres(t *testing.T) (driver.Profile, func()) {
2727
t.Fatalf("start postgres container: %v", err)
2828
}
2929

30-
host, _ := c.Host(ctx)
31-
port, _ := c.MappedPort(ctx, "5432/tcp")
30+
host, err := c.Host(ctx)
31+
if err != nil {
32+
t.Fatalf("container host: %v", err)
33+
}
34+
port, err := c.MappedPort(ctx, "5432/tcp")
35+
if err != nil {
36+
t.Fatalf("container mapped port: %v", err)
37+
}
3238

3339
return driver.Profile{
3440
Driver: "postgres",

internal/dumps/catalog.go

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,25 @@ func NewCatalog(root string) (*Catalog, error) {
2424
return &Catalog{root: root}, nil
2525
}
2626

27-
// Path returns the dump file path for the given ID.
28-
func (c *Catalog) Path(id string) string { return filepath.Join(c.root, id+".dump") }
27+
// validID rejects IDs that could escape the catalog root when joined into a
28+
// path. Dump IDs are ULIDs, but restore/inspect/verify take the ID from user
29+
// input, so a crafted value like "../../etc/passwd" must not traverse out.
30+
func validID(id string) error {
31+
if id == "" || id == "." || id == ".." ||
32+
strings.ContainsAny(id, `/\`) || strings.Contains(id, "..") {
33+
return &errs.Error{Op: "dumps.id", Code: errs.CodeUser, Cause: errors.New("invalid dump id: " + id), Hint: "dump ids contain no path separators"}
34+
}
35+
return nil
36+
}
37+
38+
// Path returns the dump file path for the given ID. filepath.Base defensively
39+
// strips any path components so a stray separator can never escape root.
40+
func (c *Catalog) Path(id string) string { return filepath.Join(c.root, filepath.Base(id)+".dump") }
2941

3042
// MetaPath returns the sidecar JSON path for the given ID.
31-
func (c *Catalog) MetaPath(id string) string { return filepath.Join(c.root, id+".meta.json") }
43+
func (c *Catalog) MetaPath(id string) string {
44+
return filepath.Join(c.root, filepath.Base(id)+".meta.json")
45+
}
3246

3347
// Root returns the catalog's root directory.
3448
func (c *Catalog) Root() string { return c.root }
@@ -44,6 +58,9 @@ func (c *Catalog) WriteMeta(m *Meta) error {
4458

4559
// ReadMeta loads <id>.meta.json from disk.
4660
func (c *Catalog) ReadMeta(id string) (*Meta, error) {
61+
if err := validID(id); err != nil {
62+
return nil, err
63+
}
4764
body, err := os.ReadFile(c.MetaPath(id))
4865
if errors.Is(err, os.ErrNotExist) {
4966
return nil, &errs.Error{Op: "dumps.read_meta", Code: errs.CodeUser, Cause: errs.ErrDumpCorrupt, Hint: "no metadata for " + id}
@@ -80,6 +97,9 @@ func (c *Catalog) List() ([]Meta, error) {
8097

8198
// Delete removes both the dump file and its sidecar.
8299
func (c *Catalog) Delete(id string) error {
100+
if err := validID(id); err != nil {
101+
return err
102+
}
83103
_ = os.Remove(c.Path(id))
84104
return os.Remove(c.MetaPath(id))
85105
}

0 commit comments

Comments
 (0)