Prefer exact backup tar extraction matches
- commit
- 33585b6835b7e6d1d40f36f5235f500c313669c0
- author
- brice · · 2026-05-05 06:54 UTC
- parent
- 474a203
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 ( | |||
| 11 | 11 | "fmt" | |
| 12 | 12 | "io" | |
| 13 | 13 | "math" | |
| 14 | + | "os" | |
| 14 | 15 | "path/filepath" | |
| 15 | 16 | "sort" | |
| 16 | 17 | "strconv" | |
| @@ -49,6 +50,11 @@ type pipeReadCloser struct { | |||
| 49 | 50 | once sync.Once | |
| 50 | 51 | } | |
| 51 | 52 | ||
| 53 | + | type removeOnCloseFile struct { | |
| 54 | + | *os.File | |
| 55 | + | path string | |
| 56 | + | } | |
| 57 | + | ||
| 52 | 58 | func (p *pipeReadCloser) Read(b []byte) (int, error) { | |
| 53 | 59 | return p.pr.Read(b) | |
| 54 | 60 | } | |
| @@ -71,6 +77,15 @@ func (p *pipeReadCloser) Close() error { | |||
| 71 | 77 | 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 | 89 | // NewRuntime creates a new Docker runtime instance. | |
| 75 | 90 | func NewRuntime() (*Runtime, error) { | |
| 76 | 91 | cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) | |
| @@ -1506,6 +1521,8 @@ func extractFileFromTar(reader io.ReadCloser, targetPath string) (io.ReadCloser, | |||
| 1506 | 1521 | tr := tar.NewReader(reader) | |
| 1507 | 1522 | targetName := normalizedTarPath(targetPath) | |
| 1508 | 1523 | targetBase := filepath.Base(targetName) | |
| 1524 | + | var fallback *removeOnCloseFile | |
| 1525 | + | fallbackMatches := 0 | |
| 1509 | 1526 | ||
| 1510 | 1527 | for { | |
| 1511 | 1528 | header, err := tr.Next() | |
| @@ -1513,32 +1530,77 @@ func extractFileFromTar(reader io.ReadCloser, targetPath string) (io.ReadCloser, | |||
| 1513 | 1530 | break | |
| 1514 | 1531 | } | |
| 1515 | 1532 | if err != nil { | |
| 1533 | + | cleanupFallback(fallback) | |
| 1534 | + | _ = reader.Close() | |
| 1516 | 1535 | 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) { | |
| 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 | |
| 1528 | 1553 | } | |
| 1529 | - | _ = pw.Close() | |
| 1530 | - | }() | |
| 1531 | - | return &pipeReadCloser{pr: pr, pw: pw, original: reader}, nil | |
| 1554 | + | } | |
| 1532 | 1555 | } | |
| 1533 | 1556 | } | |
| 1534 | 1557 | ||
| 1535 | 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 | 1570 | return nil, fmt.Errorf("file not found in container: %s", targetPath) | |
| 1537 | 1571 | } | |
| 1538 | 1572 | ||
| 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 | + | } | |
| 1542 | 1604 | } | |
| 1543 | 1605 | ||
| 1544 | 1606 | 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.Once | 50 | 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 closeErr | 77 | 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 | break | 1530 | break | ||
| 1514 | } | 1531 | } | ||
| 1515 | if err != nil { | 1532 | if err != nil { | ||
| 1533 | + | cleanupFallback(fallback) | |||
| 1534 | + | _ = reader.Close() | |||
| 1516 | return nil, err | 1535 | 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.Size | 1542 | + | 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 | - | return | 1549 | + | 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 == targetBase | 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 | + | } | |||
| 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) { | |||
| 82 | 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 | 114 | func TestExtractFileFromTarDoesNotMatchBasenameInNestedDirectory(t *testing.T) { | |
| 86 | 115 | var buf bytes.Buffer | |
| 87 | 116 | 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.Buffer | 115 | var buf bytes.Buffer | ||
| 87 | tw := tar.NewWriter(&buf) | 116 | tw := tar.NewWriter(&buf) | ||