detached 3a157b3

Merge pull request #190 from bnema/fix/podman-backup-copy-basename

Fix backup extraction with basename tar entries
commit
3a157b3a5800f5515adbb8437b6e3392ef870fa5
author
bnema · · 2026-05-05 07:01 UTC
committer
GitHub · · 2026-05-05 07:01 UTC
merge of
521b9c0 + 33585b6 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
2 files · +145 -17
M internal/adapters/out/docker/runtime.go +80 -12
@@ -11,6 +11,7 @@ import (
1111 "fmt"
1212 "io"
1313 "math"
14+ "os"
1415 "path/filepath"
1516 "sort"
1617 "strconv"
@@ -49,6 +50,11 @@ type pipeReadCloser struct {
4950 once sync.Once
5051 }
5152
53+type removeOnCloseFile struct {
54+ *os.File
55+ path string
56+}
57+
5258 func (p *pipeReadCloser) Read(b []byte) (int, error) {
5359 return p.pr.Read(b)
5460 }
@@ -71,6 +77,15 @@ func (p *pipeReadCloser) Close() error {
7177 return closeErr
7278 }
7379
80+func (f *removeOnCloseFile) Close() error {
81+ closeErr := f.File.Close()
82+ removeErr := os.Remove(f.path)
83+ if closeErr != nil {
84+ return closeErr
85+ }
86+ return removeErr
87+}
88+
7489 // NewRuntime creates a new Docker runtime instance.
7590 func NewRuntime() (*Runtime, error) {
7691 cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
@@ -1505,6 +1520,9 @@ func (r *Runtime) CopyFromContainer(ctx context.Context, containerID, srcPath st
15051520 func extractFileFromTar(reader io.ReadCloser, targetPath string) (io.ReadCloser, error) {
15061521 tr := tar.NewReader(reader)
15071522 targetName := normalizedTarPath(targetPath)
1523+ targetBase := filepath.Base(targetName)
1524+ var fallback *removeOnCloseFile
1525+ fallbackMatches := 0
15081526
15091527 for {
15101528 header, err := tr.Next()
@@ -1512,29 +1530,79 @@ func extractFileFromTar(reader io.ReadCloser, targetPath string) (io.ReadCloser,
15121530 break
15131531 }
15141532 if err != nil {
1533+ cleanupFallback(fallback)
1534+ _ = reader.Close()
15151535 return nil, err
15161536 }
1537+ if header.Typeflag != tar.TypeReg {
1538+ continue
1539+ }
15171540
1518- if header.Typeflag == tar.TypeReg && normalizedTarPath(header.Name) == targetName {
1519- size := header.Size
1520- pr, pw := io.Pipe()
1521- go func() {
1522- defer reader.Close()
1523- _, copyErr := io.CopyN(pw, tr, size)
1524- if copyErr != nil {
1525- _ = pw.CloseWithError(copyErr)
1526- return
1541+ headerName := normalizedTarPath(header.Name)
1542+ if headerName == targetName {
1543+ cleanupFallback(fallback)
1544+ return streamCurrentTarFile(reader, tr, header.Size), nil
1545+ }
1546+ if headerName == targetBase {
1547+ fallbackMatches++
1548+ if fallbackMatches == 1 {
1549+ fallback, err = bufferCurrentTarFile(tr, header.Size)
1550+ if err != nil {
1551+ _ = reader.Close()
1552+ return nil, err
15271553 }
1528- _ = pw.Close()
1529- }()
1530- return &pipeReadCloser{pr: pr, pw: pw, original: reader}, nil
1554+ }
15311555 }
15321556 }
15331557
15341558 _ = reader.Close()
1559+ if fallbackMatches == 1 {
1560+ if _, err := fallback.Seek(0, io.SeekStart); err != nil {
1561+ cleanupFallback(fallback)
1562+ return nil, err
1563+ }
1564+ return fallback, nil
1565+ }
1566+ cleanupFallback(fallback)
1567+ if fallbackMatches > 1 {
1568+ return nil, fmt.Errorf("ambiguous basename matches in container archive for: %s", targetPath)
1569+ }
15351570 return nil, fmt.Errorf("file not found in container: %s", targetPath)
15361571 }
15371572
1573+func streamCurrentTarFile(reader io.ReadCloser, tr *tar.Reader, size int64) io.ReadCloser {
1574+ pr, pw := io.Pipe()
1575+ go func() {
1576+ defer reader.Close()
1577+ _, copyErr := io.CopyN(pw, tr, size)
1578+ if copyErr != nil {
1579+ _ = pw.CloseWithError(copyErr)
1580+ return
1581+ }
1582+ _ = pw.Close()
1583+ }()
1584+ return &pipeReadCloser{pr: pr, pw: pw, original: reader}
1585+}
1586+
1587+func bufferCurrentTarFile(tr *tar.Reader, size int64) (*removeOnCloseFile, error) {
1588+ file, err := os.CreateTemp("", "gordon-copy-from-container-*")
1589+ if err != nil {
1590+ return nil, err
1591+ }
1592+ fallback := &removeOnCloseFile{File: file, path: file.Name()}
1593+ if _, err := io.CopyN(fallback, tr, size); err != nil {
1594+ cleanupFallback(fallback)
1595+ return nil, err
1596+ }
1597+ return fallback, nil
1598+}
1599+
1600+func cleanupFallback(fallback *removeOnCloseFile) {
1601+ if fallback != nil {
1602+ _ = fallback.Close()
1603+ }
1604+}
1605+
15381606 func normalizedTarPath(path string) string {
15391607 path = filepath.ToSlash(filepath.Clean(path))
15401608 return strings.TrimPrefix(path, "/")
@@ -11,6 +11,7 @@ import (
11 "fmt"11 "fmt"
12 "io"12 "io"
13 "math"13 "math"
14+ "os"
14 "path/filepath"15 "path/filepath"
15 "sort"16 "sort"
16 "strconv"17 "strconv"
@@ -49,6 +50,11 @@ type pipeReadCloser struct {
49 once sync.Once50 once sync.Once
50 }51 }
51 52
53+type removeOnCloseFile struct {
54+ *os.File
55+ path string
56+}
57+
52 func (p *pipeReadCloser) Read(b []byte) (int, error) {58 func (p *pipeReadCloser) Read(b []byte) (int, error) {
53 return p.pr.Read(b)59 return p.pr.Read(b)
54 }60 }
@@ -71,6 +77,15 @@ func (p *pipeReadCloser) Close() error {
71 return closeErr77 return closeErr
72 }78 }
73 79
80+func (f *removeOnCloseFile) Close() error {
81+ closeErr := f.File.Close()
82+ removeErr := os.Remove(f.path)
83+ if closeErr != nil {
84+ return closeErr
85+ }
86+ return removeErr
87+}
88+
74 // NewRuntime creates a new Docker runtime instance.89 // NewRuntime creates a new Docker runtime instance.
75 func NewRuntime() (*Runtime, error) {90 func NewRuntime() (*Runtime, error) {
76 cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())91 cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
@@ -1505,6 +1520,9 @@ func (r *Runtime) CopyFromContainer(ctx context.Context, containerID, srcPath st
1505 func extractFileFromTar(reader io.ReadCloser, targetPath string) (io.ReadCloser, error) {1520 func extractFileFromTar(reader io.ReadCloser, targetPath string) (io.ReadCloser, error) {
1506 tr := tar.NewReader(reader)1521 tr := tar.NewReader(reader)
1507 targetName := normalizedTarPath(targetPath)1522 targetName := normalizedTarPath(targetPath)
1523+ targetBase := filepath.Base(targetName)
1524+ var fallback *removeOnCloseFile
1525+ fallbackMatches := 0
1508 1526
1509 for {1527 for {
1510 header, err := tr.Next()1528 header, err := tr.Next()
@@ -1512,29 +1530,79 @@ func extractFileFromTar(reader io.ReadCloser, targetPath string) (io.ReadCloser,
1512 break1530 break
1513 }1531 }
1514 if err != nil {1532 if err != nil {
1533+ cleanupFallback(fallback)
1534+ _ = reader.Close()
1515 return nil, err1535 return nil, err
1516 }1536 }
1537+ if header.Typeflag != tar.TypeReg {
1538+ continue
1539+ }
1517 1540
1518- if header.Typeflag == tar.TypeReg && normalizedTarPath(header.Name) == targetName {1541+ headerName := normalizedTarPath(header.Name)
1519- size := header.Size1542+ if headerName == targetName {
1520- pr, pw := io.Pipe()1543+ cleanupFallback(fallback)
1521- go func() {1544+ return streamCurrentTarFile(reader, tr, header.Size), nil
1522- defer reader.Close()1545+ }
1523- _, copyErr := io.CopyN(pw, tr, size)1546+ if headerName == targetBase {
1524- if copyErr != nil {1547+ fallbackMatches++
1525- _ = pw.CloseWithError(copyErr)1548+ if fallbackMatches == 1 {
1526- return1549+ fallback, err = bufferCurrentTarFile(tr, header.Size)
1550+ if err != nil {
1551+ _ = reader.Close()
1552+ return nil, err
1527 }1553 }
1528- _ = pw.Close()1554+ }
1529- }()
1530- return &pipeReadCloser{pr: pr, pw: pw, original: reader}, nil
1531 }1555 }
1532 }1556 }
1533 1557
1534 _ = reader.Close()1558 _ = reader.Close()
1559+ if fallbackMatches == 1 {
1560+ if _, err := fallback.Seek(0, io.SeekStart); err != nil {
1561+ cleanupFallback(fallback)
1562+ return nil, err
1563+ }
1564+ return fallback, nil
1565+ }
1566+ cleanupFallback(fallback)
1567+ if fallbackMatches > 1 {
1568+ return nil, fmt.Errorf("ambiguous basename matches in container archive for: %s", targetPath)
1569+ }
1535 return nil, fmt.Errorf("file not found in container: %s", targetPath)1570 return nil, fmt.Errorf("file not found in container: %s", targetPath)
1536 }1571 }
1537 1572
1573+func streamCurrentTarFile(reader io.ReadCloser, tr *tar.Reader, size int64) io.ReadCloser {
1574+ pr, pw := io.Pipe()
1575+ go func() {
1576+ defer reader.Close()
1577+ _, copyErr := io.CopyN(pw, tr, size)
1578+ if copyErr != nil {
1579+ _ = pw.CloseWithError(copyErr)
1580+ return
1581+ }
1582+ _ = pw.Close()
1583+ }()
1584+ return &pipeReadCloser{pr: pr, pw: pw, original: reader}
1585+}
1586+
1587+func bufferCurrentTarFile(tr *tar.Reader, size int64) (*removeOnCloseFile, error) {
1588+ file, err := os.CreateTemp("", "gordon-copy-from-container-*")
1589+ if err != nil {
1590+ return nil, err
1591+ }
1592+ fallback := &removeOnCloseFile{File: file, path: file.Name()}
1593+ if _, err := io.CopyN(fallback, tr, size); err != nil {
1594+ cleanupFallback(fallback)
1595+ return nil, err
1596+ }
1597+ return fallback, nil
1598+}
1599+
1600+func cleanupFallback(fallback *removeOnCloseFile) {
1601+ if fallback != nil {
1602+ _ = fallback.Close()
1603+ }
1604+}
1605+
1538 func normalizedTarPath(path string) string {1606 func normalizedTarPath(path string) string {
1539 path = filepath.ToSlash(filepath.Clean(path))1607 path = filepath.ToSlash(filepath.Clean(path))
1540 return strings.TrimPrefix(path, "/")1608 return strings.TrimPrefix(path, "/")
M internal/adapters/out/docker/runtime_exec_test.go +65 -5
@@ -54,15 +54,42 @@ func frameDockerStream(streamID byte, payload []byte) []byte {
5454 func TestExtractFileFromTarMatchesFullCleanPath(t *testing.T) {
5555 var buf bytes.Buffer
5656 tw := tar.NewWriter(&buf)
57- require.NoError(t, tw.WriteHeader(&tar.Header{Name: "other/.env", Typeflag: tar.TypeReg, Size: int64(len("wrong"))}))
58- _, err := tw.Write([]byte("wrong"))
57+ require.NoError(t, writeTarFile(tw, "other/.env", "wrong"))
58+ require.NoError(t, writeTarFile(tw, "app/.env", "right"))
59+ require.NoError(t, tw.Close())
60+
61+ rc, err := extractFileFromTar(io.NopCloser(bytes.NewReader(buf.Bytes())), "/app/.env")
5962 require.NoError(t, err)
60- require.NoError(t, tw.WriteHeader(&tar.Header{Name: "app/.env", Typeflag: tar.TypeReg, Size: int64(len("right"))}))
61- _, err = tw.Write([]byte("right"))
63+ defer rc.Close()
64+
65+ data, err := io.ReadAll(rc)
6266 require.NoError(t, err)
67+ assert.Equal(t, "right", string(data))
68+}
69+
70+func TestExtractFileFromTarMatchesBasenameForCopiedFile(t *testing.T) {
71+ var buf bytes.Buffer
72+ tw := tar.NewWriter(&buf)
73+ require.NoError(t, writeTarFile(tw, "gordon-backup-test.bak", "backup data"))
6374 require.NoError(t, tw.Close())
6475
65- rc, err := extractFileFromTar(io.NopCloser(bytes.NewReader(buf.Bytes())), "/app/.env")
76+ rc, err := extractFileFromTar(io.NopCloser(bytes.NewReader(buf.Bytes())), "/tmp/gordon-backup-test.bak")
77+ require.NoError(t, err)
78+ defer rc.Close()
79+
80+ data, err := io.ReadAll(rc)
81+ require.NoError(t, err)
82+ assert.Equal(t, "backup data", string(data))
83+}
84+
85+func TestExtractFileFromTarPrefersExactPathOverBasename(t *testing.T) {
86+ var buf bytes.Buffer
87+ tw := tar.NewWriter(&buf)
88+ require.NoError(t, writeTarFile(tw, "gordon-backup-test.bak", "wrong"))
89+ require.NoError(t, writeTarFile(tw, "tmp/gordon-backup-test.bak", "right"))
90+ require.NoError(t, tw.Close())
91+
92+ rc, err := extractFileFromTar(io.NopCloser(bytes.NewReader(buf.Bytes())), "/tmp/gordon-backup-test.bak")
6693 require.NoError(t, err)
6794 defer rc.Close()
6895
@@ -71,6 +98,39 @@ func TestExtractFileFromTarMatchesFullCleanPath(t *testing.T) {
7198 assert.Equal(t, "right", string(data))
7299 }
73100
101+func TestExtractFileFromTarRejectsAmbiguousBasenameFallback(t *testing.T) {
102+ var buf bytes.Buffer
103+ tw := tar.NewWriter(&buf)
104+ require.NoError(t, writeTarFile(tw, "gordon-backup-test.bak", "first"))
105+ require.NoError(t, writeTarFile(tw, "gordon-backup-test.bak", "second"))
106+ require.NoError(t, tw.Close())
107+
108+ rc, err := extractFileFromTar(io.NopCloser(bytes.NewReader(buf.Bytes())), "/tmp/gordon-backup-test.bak")
109+ require.Error(t, err)
110+ assert.Contains(t, err.Error(), "ambiguous")
111+ assert.Nil(t, rc)
112+}
113+
114+func TestExtractFileFromTarDoesNotMatchBasenameInNestedDirectory(t *testing.T) {
115+ var buf bytes.Buffer
116+ tw := tar.NewWriter(&buf)
117+ require.NoError(t, writeTarFile(tw, "other/gordon-backup-test.bak", "wrong"))
118+ require.NoError(t, tw.Close())
119+
120+ rc, err := extractFileFromTar(io.NopCloser(bytes.NewReader(buf.Bytes())), "/tmp/gordon-backup-test.bak")
121+ require.Error(t, err)
122+ assert.Contains(t, err.Error(), "file not found")
123+ assert.Nil(t, rc)
124+}
125+
126+func writeTarFile(tw *tar.Writer, name, content string) error {
127+ if err := tw.WriteHeader(&tar.Header{Name: name, Typeflag: tar.TypeReg, Size: int64(len(content))}); err != nil {
128+ return err
129+ }
130+ _, err := tw.Write([]byte(content))
131+ return err
132+}
133+
74134 func TestReadExtractedEnvFileRejectsOversizedContent(t *testing.T) {
75135 data, err := readExtractedEnvFile(io.NopCloser(strings.NewReader(strings.Repeat("a", maxExtractedEnvFileSize+1))))
76136 require.Error(t, err)
@@ -54,15 +54,42 @@ func frameDockerStream(streamID byte, payload []byte) []byte {
54 func TestExtractFileFromTarMatchesFullCleanPath(t *testing.T) {54 func TestExtractFileFromTarMatchesFullCleanPath(t *testing.T) {
55 var buf bytes.Buffer55 var buf bytes.Buffer
56 tw := tar.NewWriter(&buf)56 tw := tar.NewWriter(&buf)
57- require.NoError(t, tw.WriteHeader(&tar.Header{Name: "other/.env", Typeflag: tar.TypeReg, Size: int64(len("wrong"))}))57+ require.NoError(t, writeTarFile(tw, "other/.env", "wrong"))
58- _, err := tw.Write([]byte("wrong"))58+ require.NoError(t, writeTarFile(tw, "app/.env", "right"))
59+ require.NoError(t, tw.Close())
60+
61+ rc, err := extractFileFromTar(io.NopCloser(bytes.NewReader(buf.Bytes())), "/app/.env")
59 require.NoError(t, err)62 require.NoError(t, err)
60- require.NoError(t, tw.WriteHeader(&tar.Header{Name: "app/.env", Typeflag: tar.TypeReg, Size: int64(len("right"))}))63+ defer rc.Close()
61- _, err = tw.Write([]byte("right"))64+
65+ data, err := io.ReadAll(rc)
62 require.NoError(t, err)66 require.NoError(t, err)
67+ assert.Equal(t, "right", string(data))
68+}
69+
70+func TestExtractFileFromTarMatchesBasenameForCopiedFile(t *testing.T) {
71+ var buf bytes.Buffer
72+ tw := tar.NewWriter(&buf)
73+ require.NoError(t, writeTarFile(tw, "gordon-backup-test.bak", "backup data"))
63 require.NoError(t, tw.Close())74 require.NoError(t, tw.Close())
64 75
65- rc, err := extractFileFromTar(io.NopCloser(bytes.NewReader(buf.Bytes())), "/app/.env")76+ rc, err := extractFileFromTar(io.NopCloser(bytes.NewReader(buf.Bytes())), "/tmp/gordon-backup-test.bak")
77+ require.NoError(t, err)
78+ defer rc.Close()
79+
80+ data, err := io.ReadAll(rc)
81+ require.NoError(t, err)
82+ assert.Equal(t, "backup data", string(data))
83+}
84+
85+func TestExtractFileFromTarPrefersExactPathOverBasename(t *testing.T) {
86+ var buf bytes.Buffer
87+ tw := tar.NewWriter(&buf)
88+ require.NoError(t, writeTarFile(tw, "gordon-backup-test.bak", "wrong"))
89+ require.NoError(t, writeTarFile(tw, "tmp/gordon-backup-test.bak", "right"))
90+ require.NoError(t, tw.Close())
91+
92+ rc, err := extractFileFromTar(io.NopCloser(bytes.NewReader(buf.Bytes())), "/tmp/gordon-backup-test.bak")
66 require.NoError(t, err)93 require.NoError(t, err)
67 defer rc.Close()94 defer rc.Close()
68 95
@@ -71,6 +98,39 @@ func TestExtractFileFromTarMatchesFullCleanPath(t *testing.T) {
71 assert.Equal(t, "right", string(data))98 assert.Equal(t, "right", string(data))
72 }99 }
73 100
101+func TestExtractFileFromTarRejectsAmbiguousBasenameFallback(t *testing.T) {
102+ var buf bytes.Buffer
103+ tw := tar.NewWriter(&buf)
104+ require.NoError(t, writeTarFile(tw, "gordon-backup-test.bak", "first"))
105+ require.NoError(t, writeTarFile(tw, "gordon-backup-test.bak", "second"))
106+ require.NoError(t, tw.Close())
107+
108+ rc, err := extractFileFromTar(io.NopCloser(bytes.NewReader(buf.Bytes())), "/tmp/gordon-backup-test.bak")
109+ require.Error(t, err)
110+ assert.Contains(t, err.Error(), "ambiguous")
111+ assert.Nil(t, rc)
112+}
113+
114+func TestExtractFileFromTarDoesNotMatchBasenameInNestedDirectory(t *testing.T) {
115+ var buf bytes.Buffer
116+ tw := tar.NewWriter(&buf)
117+ require.NoError(t, writeTarFile(tw, "other/gordon-backup-test.bak", "wrong"))
118+ require.NoError(t, tw.Close())
119+
120+ rc, err := extractFileFromTar(io.NopCloser(bytes.NewReader(buf.Bytes())), "/tmp/gordon-backup-test.bak")
121+ require.Error(t, err)
122+ assert.Contains(t, err.Error(), "file not found")
123+ assert.Nil(t, rc)
124+}
125+
126+func writeTarFile(tw *tar.Writer, name, content string) error {
127+ if err := tw.WriteHeader(&tar.Header{Name: name, Typeflag: tar.TypeReg, Size: int64(len(content))}); err != nil {
128+ return err
129+ }
130+ _, err := tw.Write([]byte(content))
131+ return err
132+}
133+
74 func TestReadExtractedEnvFileRejectsOversizedContent(t *testing.T) {134 func TestReadExtractedEnvFileRejectsOversizedContent(t *testing.T) {
75 data, err := readExtractedEnvFile(io.NopCloser(strings.NewReader(strings.Repeat("a", maxExtractedEnvFileSize+1))))135 data, err := readExtractedEnvFile(io.NopCloser(strings.NewReader(strings.Repeat("a", maxExtractedEnvFileSize+1))))
76 require.Error(t, err)136 require.Error(t, err)

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close