From de70ecb02635b95151f4d8d190869523b3bc8bd3 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Mon, 6 Jul 2026 09:01:19 +0200 Subject: [PATCH] fix(db): probe dump readability before PostgreSQL import pg_restore cannot read archives newer than itself, so importing a dump made by pg_dump from PostgreSQL 17+ into a panel with an older postgresql-client failed with a raw 'unsupported version (1.16) in file header' - and only after Xray had already been stopped for the restore. Probe the uploaded file with pg_restore --list first, which reads only the archive TOC without touching the database, so an unreadable dump is rejected before Xray is interrupted. When the failure is a dump-format version mismatch, translate it into a message naming the PostgreSQL version that produced the dump and the client version to install. --- internal/web/service/server.go | 53 +++++++++++++ .../web/service/server_pg_restore_test.go | 75 +++++++++++++++++++ 2 files changed, 128 insertions(+) create mode 100644 internal/web/service/server_pg_restore_test.go diff --git a/internal/web/service/server.go b/internal/web/service/server.go index afd9e99d0..d6d4ac3ee 100644 --- a/internal/web/service/server.go +++ b/internal/web/service/server.go @@ -1608,6 +1608,55 @@ func (s *ServerService) exportPostgresDB() ([]byte, error) { return out.Bytes(), nil } +var ( + pgUnsupportedDumpVersionPattern = regexp.MustCompile(`unsupported version \((\d+\.\d+)\) in file header`) + pgToolVersionPattern = regexp.MustCompile(`\d+(?:\.\d+)+`) +) + +var pgArchiveVersionIntroducedIn = map[string]int{ + "1.15": 16, + "1.16": 17, +} + +// checkPgRestoreCanRead probes the dump with pg_restore --list (reads only the +// TOC, no database connection) so an unreadable file fails before Xray is stopped. +func checkPgRestoreCanRead(bin, dumpPath string) error { + cmd := exec.CommandContext(context.Background(), bin, "--list", dumpPath) + cmd.Stdout = io.Discard + var stderr bytes.Buffer + cmd.Stderr = &stderr + if cmd.Run() == nil { + return nil + } + return pgRestoreReadFailureError(strings.TrimSpace(stderr.String()), pgRestoreVersion(bin)) +} + +func pgRestoreReadFailureError(probeOutput, localVersion string) error { + m := pgUnsupportedDumpVersionPattern.FindStringSubmatch(probeOutput) + if m == nil { + return common.NewErrorf("pg_restore cannot read this dump file: %s", probeOutput) + } + if localVersion == "" { + localVersion = "unknown" + } + if major, known := pgArchiveVersionIntroducedIn[m[1]]; known { + return common.NewErrorf("This backup was created by pg_dump from PostgreSQL %d or newer, but the server's pg_restore is version %s and cannot read it; upgrade the postgresql-client package to version %d or newer and retry the import", major, localVersion, major) + } + return common.NewErrorf("This backup was created by a newer pg_dump than the server's pg_restore (version %s) can read; upgrade the postgresql-client package and retry the import", localVersion) +} + +func pgRestoreVersion(bin string) string { + out, err := exec.CommandContext(context.Background(), bin, "--version").Output() + if err != nil { + return "" + } + return parsePgToolVersion(string(out)) +} + +func parsePgToolVersion(versionOutput string) string { + return pgToolVersionPattern.FindString(versionOutput) +} + func (s *ServerService) importPostgresDB(file multipart.File) error { header := make([]byte, 5) if _, err := file.ReadAt(header, 0); err != nil { @@ -1643,6 +1692,10 @@ func (s *ServerService) importPostgresDB(file multipart.File) error { return common.NewErrorf("Error closing temporary dump file: %v", err) } + if err := checkPgRestoreCanRead(bin, tempPath); err != nil { + return err + } + xrayStopped := true defer func() { if xrayStopped { diff --git a/internal/web/service/server_pg_restore_test.go b/internal/web/service/server_pg_restore_test.go new file mode 100644 index 000000000..ccd28e9c4 --- /dev/null +++ b/internal/web/service/server_pg_restore_test.go @@ -0,0 +1,75 @@ +package service + +import "testing" + +func TestPgRestoreReadFailureError(t *testing.T) { + cases := []struct { + name string + probeOutput string + localVersion string + want string + }{ + { + name: "dump from postgres 17 on older client", + probeOutput: "pg_restore: error: unsupported version (1.16) in file header", + localVersion: "16.4", + want: "This backup was created by pg_dump from PostgreSQL 17 or newer, but the server's pg_restore is version 16.4 and cannot read it; upgrade the postgresql-client package to version 17 or newer and retry the import", + }, + { + name: "dump from postgres 16 on older client", + probeOutput: "pg_restore: error: unsupported version (1.15) in file header", + localVersion: "15.8", + want: "This backup was created by pg_dump from PostgreSQL 16 or newer, but the server's pg_restore is version 15.8 and cannot read it; upgrade the postgresql-client package to version 16 or newer and retry the import", + }, + { + name: "archive version newer than any known mapping", + probeOutput: "pg_restore: error: unsupported version (1.17) in file header", + localVersion: "17.2", + want: "This backup was created by a newer pg_dump than the server's pg_restore (version 17.2) can read; upgrade the postgresql-client package and retry the import", + }, + { + name: "local version could not be determined", + probeOutput: "pg_restore: error: unsupported version (1.16) in file header", + localVersion: "", + want: "This backup was created by pg_dump from PostgreSQL 17 or newer, but the server's pg_restore is version unknown and cannot read it; upgrade the postgresql-client package to version 17 or newer and retry the import", + }, + { + name: "unrelated read failure passes through", + probeOutput: "pg_restore: error: could not read from input file: end of file", + localVersion: "16.4", + want: "pg_restore cannot read this dump file: pg_restore: error: could not read from input file: end of file", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := pgRestoreReadFailureError(tc.probeOutput, tc.localVersion) + if err == nil { + t.Fatal("pgRestoreReadFailureError returned nil, want error") + } + if err.Error() != tc.want { + t.Errorf("pgRestoreReadFailureError(%q, %q) = %q, want %q", tc.probeOutput, tc.localVersion, err.Error(), tc.want) + } + }) + } +} + +func TestParsePgToolVersion(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + {"plain", "pg_restore (PostgreSQL) 17.2\n", "17.2"}, + {"debian packaging suffix", "pg_restore (PostgreSQL) 16.10 (Debian 16.10-1.pgdg120+1)\n", "16.10"}, + {"three component version", "pg_restore (PostgreSQL) 9.6.24\n", "9.6.24"}, + {"no version present", "pg_restore malfunction\n", ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := parsePgToolVersion(tc.in) + if got != tc.want { + t.Errorf("parsePgToolVersion(%q) = %q, want %q", tc.in, got, tc.want) + } + }) + } +}