package pgdump import ( "context" "fmt" "io" "os" "os/exec" "regexp" "strings" "git.tswf.io/infra/go-synapse-backupper/pkg/domain/pgdump" ) var ( passwordPattern = regexp.MustCompile(`(?i)\bpassword=[^\s]*`) hostPattern = regexp.MustCompile(`(?i)\bhost=[^\s]*`) // excludeTablePattern accepts unquoted PostgreSQL identifiers or the // schema.table form. It rejects shell-special characters and injection // payloads while still allowing the default Synapse table name. excludeTablePattern = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_$]*(\.[a-zA-Z_][a-zA-Z0-9_$]*)?$`) ) // adapter provides pg_dump functionality using the system's pg_dump binary. type adapter struct { commandContext func(ctx context.Context, name string, arg ...string) *exec.Cmd } // New creates a new pg_dump adapter. func New() pgdump.Dumper { return &adapter{ commandContext: exec.CommandContext, } } // Dump executes pg_dump and writes the output to sink. // // The password is never passed through the PGPASSWORD environment variable; // instead a temporary .pgpass file with 0o600 permissions is created and // pointed to via PGPASSFILE. func (a *adapter) Dump( ctx context.Context, opts pgdump.Options, sink io.Writer, ) (retErr error) { defer func() { if pipeWriter, ok := sink.(*io.PipeWriter); ok { if retErr != nil { _ = pipeWriter.CloseWithError(retErr) } else { _ = pipeWriter.Close() } } }() args, err := buildArgs(opts) if err != nil { return err } cmd := a.commandContext(ctx, "pg_dump", args...) env := os.Environ() pgpassPath, cleanupPgpass, err := writePgPassFile(opts) if err != nil { return err } defer cleanupPgpass() if pgpassPath != "" { env = append(env, fmt.Sprintf("PGPASSFILE=%s", pgpassPath)) } if opts.Host != "" { env = append(env, fmt.Sprintf("PGHOST=%s", opts.Host)) } if opts.Port != 0 { env = append(env, fmt.Sprintf("PGPORT=%d", opts.Port)) } if opts.User != "" { env = append(env, fmt.Sprintf("PGUSER=%s", opts.User)) } if opts.Database != "" { env = append(env, fmt.Sprintf("PGDATABASE=%s", opts.Database)) } cmd.Env = env var stderrBuilder strings.Builder cmd.Stderr = &stderrBuilder cmd.Stdout = sink runErr := cmd.Run() stderr := sanitizeStderr(strings.TrimSpace(stderrBuilder.String())) if runErr != nil { if exitErr, ok := runErr.(*exec.ExitError); ok { return fmt.Errorf("%w (exit %d): %s", pgdump.ErrPgDumpFailed(exitErr.ExitCode()), exitErr.ExitCode(), stderr) } return runErr } if cmd.ProcessState.ExitCode() != 0 { return fmt.Errorf("%w (exit %d): %s", pgdump.ErrPgDumpFailed(cmd.ProcessState.ExitCode()), cmd.ProcessState.ExitCode(), stderr) } return nil } // writePgPassFile creates a temporary .pgpass file when a password is provided. // The returned cleanup function removes the file; callers should defer it. func writePgPassFile(opts pgdump.Options) (string, func(), error) { if opts.Password == "" { return "", func() {}, nil } passFile, err := os.CreateTemp("", "pgpass-*.conf") if err != nil { return "", nil, fmt.Errorf("create temporary pgpass file: %w", err) } path := passFile.Name() cleanup := func() { _ = os.Remove(path) } line := fmt.Sprintf( "%s:%d:%s:%s:%s\n", opts.Host, opts.Port, opts.Database, opts.User, opts.Password, ) if _, err := passFile.WriteString(line); err != nil { _ = passFile.Close() cleanup() return "", nil, fmt.Errorf("write temporary pgpass file: %w", err) } if err := passFile.Close(); err != nil { cleanup() return "", nil, fmt.Errorf("close temporary pgpass file: %w", err) } if err := os.Chmod(path, 0o600); err != nil { cleanup() return "", nil, fmt.Errorf("chmod temporary pgpass file: %w", err) } return path, cleanup, nil } // sanitizeStderr removes sensitive connection-string fragments from pg_dump // diagnostics before they are logged. func sanitizeStderr(input string) string { if input == "" { return "" } out := passwordPattern.ReplaceAllString(input, "password=***") out = hostPattern.ReplaceAllString(out, "host=***") return out } func buildArgs(opts pgdump.Options) ([]string, error) { excludeTables := opts.ExcludeTables if len(excludeTables) == 0 { excludeTables = make([]string, 1) excludeTables[0] = "e2e_one_time_keys_json" } args := make([]string, 0, len(excludeTables)+1) args = append(args, "--format=custom") for _, table := range excludeTables { if !excludeTablePattern.MatchString(table) { return nil, fmt.Errorf("invalid exclude-table identifier %q", table) } args = append(args, "--exclude-table="+table) } return args, nil }