Parcourir la source

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.
MHSanaei il y a 1 jour
Parent
commit
de70ecb026
2 fichiers modifiés avec 128 ajouts et 0 suppressions
  1. 53 0
      internal/web/service/server.go
  2. 75 0
      internal/web/service/server_pg_restore_test.go

+ 53 - 0
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 {

+ 75 - 0
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)
+			}
+		})
+	}
+}