detached 33585b6

Prefer exact backup tar extraction matches

commit
33585b6835b7e6d1d40f36f5235f500c313669c0
author
brice · · 2026-05-05 06:54 UTC
parent
474a203
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +106 -15
M internal/adapters/out/docker/runtime.go +77 -15
@@ -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())
@@ -1506,6 +1521,8 @@ func extractFileFromTar(reader io.ReadCloser, targetPath string) (io.ReadCloser,
15061521 tr := tar.NewReader(reader)
15071522 targetName := normalizedTarPath(targetPath)
15081523 targetBase := filepath.Base(targetName)
1524+ var fallback *removeOnCloseFile
1525+ fallbackMatches := 0
15091526
15101527 for {
15111528 header, err := tr.Next()
@@ -1513,32 +1530,77 @@ func extractFileFromTar(reader io.ReadCloser, targetPath string) (io.ReadCloser,
15131530 break
15141531 }
15151532 if err != nil {
1533+ cleanupFallback(fallback)
1534+ _ = reader.Close()
15161535 return nil, err
15171536 }
1537+ if header.Typeflag != tar.TypeReg {
1538+ continue
1539+ }
15181540
1519- if header.Typeflag == tar.TypeReg && tarHeaderMatchesTarget(header.Name, targetName, targetBase) {
1520- size := header.Size
1521- pr, pw := io.Pipe()
1522- go func() {
1523- defer reader.Close()
1524- _, copyErr := io.CopyN(pw, tr, size)
1525- if copyErr != nil {
1526- _ = pw.CloseWithError(copyErr)
1527- 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
15281553 }
1529- _ = pw.Close()
1530- }()
1531- return &pipeReadCloser{pr: pr, pw: pw, original: reader}, nil
1554+ }
15321555 }
15331556 }
15341557
15351558 _ = 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+ }
15361570 return nil, fmt.Errorf("file not found in container: %s", targetPath)
15371571 }
15381572
1539-func tarHeaderMatchesTarget(headerName, targetName, targetBase string) bool {
1540- headerName = normalizedTarPath(headerName)
1541- return headerName == targetName || headerName == targetBase
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+ }
15421604 }
15431605
15441606 func normalizedTarPath(path string) string {
@@ -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())
@@ -1506,6 +1521,8 @@ func extractFileFromTar(reader io.ReadCloser, targetPath string) (io.ReadCloser,
1506 tr := tar.NewReader(reader)1521 tr := tar.NewReader(reader)
1507 targetName := normalizedTarPath(targetPath)1522 targetName := normalizedTarPath(targetPath)
1508 targetBase := filepath.Base(targetName)1523 targetBase := filepath.Base(targetName)
1524+ var fallback *removeOnCloseFile
1525+ fallbackMatches := 0
1509 1526
1510 for {1527 for {
1511 header, err := tr.Next()1528 header, err := tr.Next()
@@ -1513,32 +1530,77 @@ func extractFileFromTar(reader io.ReadCloser, targetPath string) (io.ReadCloser,
1513 break1530 break
1514 }1531 }
1515 if err != nil {1532 if err != nil {
1533+ cleanupFallback(fallback)
1534+ _ = reader.Close()
1516 return nil, err1535 return nil, err
1517 }1536 }
1537+ if header.Typeflag != tar.TypeReg {
1538+ continue
1539+ }
1518 1540
1519- if header.Typeflag == tar.TypeReg && tarHeaderMatchesTarget(header.Name, targetName, targetBase) {1541+ headerName := normalizedTarPath(header.Name)
1520- size := header.Size1542+ if headerName == targetName {
1521- pr, pw := io.Pipe()1543+ cleanupFallback(fallback)
1522- go func() {1544+ return streamCurrentTarFile(reader, tr, header.Size), nil
1523- defer reader.Close()1545+ }
1524- _, copyErr := io.CopyN(pw, tr, size)1546+ if headerName == targetBase {
1525- if copyErr != nil {1547+ fallbackMatches++
1526- _ = pw.CloseWithError(copyErr)1548+ if fallbackMatches == 1 {
1527- return1549+ fallback, err = bufferCurrentTarFile(tr, header.Size)
1550+ if err != nil {
1551+ _ = reader.Close()
1552+ return nil, err
1528 }1553 }
1529- _ = pw.Close()1554+ }
1530- }()
1531- return &pipeReadCloser{pr: pr, pw: pw, original: reader}, nil
1532 }1555 }
1533 }1556 }
1534 1557
1535 _ = 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+ }
1536 return nil, fmt.Errorf("file not found in container: %s", targetPath)1570 return nil, fmt.Errorf("file not found in container: %s", targetPath)
1537 }1571 }
1538 1572
1539-func tarHeaderMatchesTarget(headerName, targetName, targetBase string) bool {1573+func streamCurrentTarFile(reader io.ReadCloser, tr *tar.Reader, size int64) io.ReadCloser {
1540- headerName = normalizedTarPath(headerName)1574+ pr, pw := io.Pipe()
1541- return headerName == targetName || headerName == targetBase1575+ 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+ }
1542 }1604 }
1543 1605
1544 func normalizedTarPath(path string) string {1606 func normalizedTarPath(path string) string {
M internal/adapters/out/docker/runtime_exec_test.go +29 -0
@@ -82,6 +82,35 @@ func TestExtractFileFromTarMatchesBasenameForCopiedFile(t *testing.T) {
8282 assert.Equal(t, "backup data", string(data))
8383 }
8484
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")
93+ require.NoError(t, err)
94+ defer rc.Close()
95+
96+ data, err := io.ReadAll(rc)
97+ require.NoError(t, err)
98+ assert.Equal(t, "right", string(data))
99+}
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+
85114 func TestExtractFileFromTarDoesNotMatchBasenameInNestedDirectory(t *testing.T) {
86115 var buf bytes.Buffer
87116 tw := tar.NewWriter(&buf)
@@ -82,6 +82,35 @@ func TestExtractFileFromTarMatchesBasenameForCopiedFile(t *testing.T) {
82 assert.Equal(t, "backup data", string(data))82 assert.Equal(t, "backup data", string(data))
83 }83 }
84 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")
93+ require.NoError(t, err)
94+ defer rc.Close()
95+
96+ data, err := io.ReadAll(rc)
97+ require.NoError(t, err)
98+ assert.Equal(t, "right", string(data))
99+}
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+
85 func TestExtractFileFromTarDoesNotMatchBasenameInNestedDirectory(t *testing.T) {114 func TestExtractFileFromTarDoesNotMatchBasenameInNestedDirectory(t *testing.T) {
86 var buf bytes.Buffer115 var buf bytes.Buffer
87 tw := tar.NewWriter(&buf)116 tw := tar.NewWriter(&buf)

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