Skip to content

Commit ef71b7a

Browse files
solvingjclaude
andauthored
Fix hardlink support for archive creation in FilesFromDisk() by tracking inodes. (#64)
* Fix hardlink support for archive creation in FilesFromDisk() by tracking inodes. * Fix Windows build: move syscall.Stat_t usage to platform-specific files syscall.Stat_t is undefined on Windows, causing the package and its tests to fail to compile. Extract hardlink detection into hardlink_unix.go (!windows) and a no-op stub hardlink_windows.go, and guard the three disk-based hardlink tests behind the same !windows build tag. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 5b66a29 commit ef71b7a

7 files changed

Lines changed: 395 additions & 9 deletions

File tree

archives.go

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,9 @@ func (f FileInfo) Stat() (fs.FileInfo, error) { return f.FileInfo, nil }
7272
// an archive.
7373
func FilesFromDisk(ctx context.Context, options *FromDiskOptions, filenames map[string]string) ([]FileInfo, error) {
7474
var files []FileInfo
75+
76+
inodeMap := make(map[inodeKey]string)
77+
7578
for rootOnDisk, rootInArchive := range filenames {
7679
if err := ctx.Err(); err != nil {
7780
return nil, err
@@ -96,9 +99,20 @@ func FilesFromDisk(ctx context.Context, options *FromDiskOptions, filenames map[
9699
return nil
97100
}
98101

99-
// handle symbolic links
102+
// handle hardlinks for regular files
100103
var linkTarget string
101-
if isSymlink(info) {
104+
if info.Mode().IsRegular() {
105+
if key, ok := hardlinkKey(info); ok {
106+
if firstPath, exists := inodeMap[key]; exists {
107+
linkTarget = firstPath
108+
} else {
109+
inodeMap[key] = nameInArchive
110+
}
111+
}
112+
}
113+
114+
// handle symbolic links (only if not already a hardlink)
115+
if linkTarget == "" && isSymlink(info) {
102116
if options != nil && options.FollowSymlinks {
103117
originalFilename := filename
104118
filename, info, err = followSymlink(filename)

hardlink_test.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
package archives
2+
3+
import (
4+
"archive/tar"
5+
"context"
6+
"os"
7+
"testing"
8+
)
9+
10+
// TestGnuHardlinksExtraction tests extraction of the gnu-hardlinks.tar test
11+
// archive from the original archiver project (PR #171). This verifies that
12+
// hardlinks are correctly preserved during extraction.
13+
func TestGnuHardlinksExtraction(t *testing.T) {
14+
archivePath := "testdata/gnu-hardlinks.tar"
15+
16+
archiveFile, err := os.Open(archivePath)
17+
if err != nil {
18+
t.Fatalf("Failed to open archive: %v", err)
19+
}
20+
defer archiveFile.Close()
21+
22+
ctx := context.Background()
23+
format := Tar{}
24+
25+
var fileA, fileB *FileInfo
26+
err = format.Extract(ctx, archiveFile, func(ctx context.Context, file FileInfo) error {
27+
t.Logf("Extracted: Name=%s, LinkTarget=%q", file.Name(), file.LinkTarget)
28+
if hdr, ok := file.Header.(*tar.Header); ok {
29+
t.Logf(" Typeflag=%d (%c)", hdr.Typeflag, hdr.Typeflag)
30+
}
31+
32+
if file.NameInArchive == "dir-1/dir-2/file-a" {
33+
fileCopy := file
34+
fileA = &fileCopy
35+
} else if file.NameInArchive == "dir-1/dir-2/file-b" {
36+
fileCopy := file
37+
fileB = &fileCopy
38+
}
39+
return nil
40+
})
41+
42+
if err != nil {
43+
t.Fatalf("Extract failed: %v", err)
44+
}
45+
46+
if fileA == nil {
47+
t.Fatal("file-a not found in archive")
48+
}
49+
if fileB == nil {
50+
t.Fatal("file-b not found in archive")
51+
}
52+
53+
if fileA.LinkTarget != "" {
54+
t.Errorf("file-a should be regular file, but has LinkTarget=%q", fileA.LinkTarget)
55+
}
56+
57+
if fileB.LinkTarget == "" {
58+
t.Error("file-b should have LinkTarget set (is a hardlink)")
59+
}
60+
if fileB.LinkTarget != "dir-1/dir-2/file-a" {
61+
t.Errorf("file-b LinkTarget = %q, want %q", fileB.LinkTarget, "dir-1/dir-2/file-a")
62+
}
63+
64+
if hdr, ok := fileB.Header.(*tar.Header); ok {
65+
if hdr.Typeflag != tar.TypeLink && hdr.Typeflag != '1' {
66+
t.Errorf("file-b Typeflag = %d, expected hardlink type", hdr.Typeflag)
67+
}
68+
}
69+
}

hardlink_unix.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
//go:build !windows
2+
3+
package archives
4+
5+
import (
6+
"io/fs"
7+
"syscall"
8+
)
9+
10+
type inodeKey struct {
11+
dev uint64
12+
ino uint64
13+
}
14+
15+
// hardlinkKey returns an inodeKey and true if info is a regular file with
16+
// multiple hard links. Returns false on systems where Stat_t is unavailable
17+
// or when the file has only one link.
18+
func hardlinkKey(info fs.FileInfo) (inodeKey, bool) {
19+
stat, ok := info.Sys().(*syscall.Stat_t)
20+
if !ok || stat.Nlink <= 1 {
21+
return inodeKey{}, false
22+
}
23+
return inodeKey{dev: uint64(stat.Dev), ino: stat.Ino}, true
24+
}

hardlink_unix_test.go

Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
//go:build !windows
2+
3+
package archives
4+
5+
import (
6+
"archive/tar"
7+
"context"
8+
"io"
9+
"os"
10+
"path/filepath"
11+
"syscall"
12+
"testing"
13+
)
14+
15+
// TestHardlinkDetection verifies that hardlinks are detected and properly
16+
// tagged when gathering files from disk.
17+
func TestHardlinkDetection(t *testing.T) {
18+
tmpDir := t.TempDir()
19+
20+
originalPath := filepath.Join(tmpDir, "original.txt")
21+
content := []byte("test content for hardlink")
22+
if err := os.WriteFile(originalPath, content, 0644); err != nil {
23+
t.Fatalf("Failed to create original file: %v", err)
24+
}
25+
26+
hardlinkPath := filepath.Join(tmpDir, "hardlink.txt")
27+
if err := os.Link(originalPath, hardlinkPath); err != nil {
28+
t.Fatalf("Failed to create hardlink: %v", err)
29+
}
30+
31+
origInfo, _ := os.Stat(originalPath)
32+
linkInfo, _ := os.Stat(hardlinkPath)
33+
origStat := origInfo.Sys().(*syscall.Stat_t)
34+
linkStat := linkInfo.Sys().(*syscall.Stat_t)
35+
36+
if origStat.Ino != linkStat.Ino {
37+
t.Fatalf("Files are not hardlinked: orig inode=%d, link inode=%d", origStat.Ino, linkStat.Ino)
38+
}
39+
40+
ctx := context.Background()
41+
files, err := FilesFromDisk(ctx, nil, map[string]string{tmpDir + string(filepath.Separator): ""})
42+
if err != nil {
43+
t.Fatalf("FilesFromDisk failed: %v", err)
44+
}
45+
46+
if len(files) != 2 {
47+
for i, f := range files {
48+
t.Logf("File %d: Name=%s, NameInArchive=%s, LinkTarget=%q, IsRegular=%v",
49+
i, f.Name(), f.NameInArchive, f.LinkTarget, f.Mode().IsRegular())
50+
}
51+
t.Fatalf("Expected 2 files, got %d", len(files))
52+
}
53+
54+
var originalEntry, hardlinkEntry *FileInfo
55+
for i := range files {
56+
t.Logf("File %d: Name=%s, NameInArchive=%s, LinkTarget=%q, IsRegular=%v, Nlink=%d",
57+
i, files[i].Name(), files[i].NameInArchive, files[i].LinkTarget, files[i].Mode().IsRegular(),
58+
files[i].Sys().(*syscall.Stat_t).Nlink)
59+
if files[i].LinkTarget == "" {
60+
originalEntry = &files[i]
61+
} else {
62+
hardlinkEntry = &files[i]
63+
}
64+
}
65+
66+
if originalEntry == nil {
67+
t.Fatal("Could not find original (first occurrence) entry")
68+
}
69+
if hardlinkEntry == nil {
70+
t.Fatal("Could not find hardlink (second occurrence) entry")
71+
}
72+
73+
if hardlinkEntry.LinkTarget != originalEntry.NameInArchive {
74+
t.Errorf("LinkTarget = %q, want %q", hardlinkEntry.LinkTarget, originalEntry.NameInArchive)
75+
}
76+
}
77+
78+
// TestHardlinkInTarArchive verifies that hardlinks are correctly written
79+
// to tar archives with TypeLink headers.
80+
func TestHardlinkInTarArchive(t *testing.T) {
81+
tmpDir := t.TempDir()
82+
83+
originalPath := filepath.Join(tmpDir, "file1.txt")
84+
content := []byte("shared content")
85+
if err := os.WriteFile(originalPath, content, 0644); err != nil {
86+
t.Fatalf("Failed to create original file: %v", err)
87+
}
88+
89+
link2Path := filepath.Join(tmpDir, "file2.txt")
90+
link3Path := filepath.Join(tmpDir, "file3.txt")
91+
if err := os.Link(originalPath, link2Path); err != nil {
92+
t.Fatalf("Failed to create hardlink 2: %v", err)
93+
}
94+
if err := os.Link(originalPath, link3Path); err != nil {
95+
t.Fatalf("Failed to create hardlink 3: %v", err)
96+
}
97+
98+
archivePath := filepath.Join(t.TempDir(), "test.tar")
99+
archiveFile, err := os.Create(archivePath)
100+
if err != nil {
101+
t.Fatalf("Failed to create archive file: %v", err)
102+
}
103+
defer archiveFile.Close()
104+
105+
ctx := context.Background()
106+
files, err := FilesFromDisk(ctx, nil, map[string]string{tmpDir: "testdir"})
107+
if err != nil {
108+
t.Fatalf("FilesFromDisk failed: %v", err)
109+
}
110+
111+
format := Tar{}
112+
if err := format.Archive(ctx, archiveFile, files); err != nil {
113+
t.Fatalf("Archive failed: %v", err)
114+
}
115+
archiveFile.Close()
116+
117+
archiveFile, err = os.Open(archivePath)
118+
if err != nil {
119+
t.Fatalf("Failed to open archive: %v", err)
120+
}
121+
defer archiveFile.Close()
122+
123+
tr := tar.NewReader(archiveFile)
124+
125+
var regularFiles, hardlinks int
126+
for {
127+
hdr, err := tr.Next()
128+
if err == io.EOF {
129+
break
130+
}
131+
if err != nil {
132+
t.Fatalf("Failed to read tar header: %v", err)
133+
}
134+
135+
switch hdr.Typeflag {
136+
case tar.TypeReg:
137+
regularFiles++
138+
if hdr.Name != "testdir/file1.txt" {
139+
t.Errorf("Unexpected regular file: %s", hdr.Name)
140+
}
141+
if hdr.Size == 0 {
142+
t.Error("Regular file has zero size")
143+
}
144+
case tar.TypeLink:
145+
hardlinks++
146+
if hdr.Name != "testdir/file2.txt" && hdr.Name != "testdir/file3.txt" {
147+
t.Errorf("Unexpected hardlink: %s", hdr.Name)
148+
}
149+
if hdr.Size != 0 {
150+
t.Errorf("Hardlink %s has non-zero size: %d", hdr.Name, hdr.Size)
151+
}
152+
if hdr.Linkname != "testdir/file1.txt" {
153+
t.Errorf("Hardlink %s points to %s, want testdir/file1.txt", hdr.Name, hdr.Linkname)
154+
}
155+
}
156+
}
157+
158+
if regularFiles != 1 {
159+
t.Errorf("Expected 1 regular file, got %d", regularFiles)
160+
}
161+
if hardlinks != 2 {
162+
t.Errorf("Expected 2 hardlinks, got %d", hardlinks)
163+
}
164+
}
165+
166+
// TestHardlinkExtraction verifies that hardlinks can be extracted and
167+
// the LinkTarget field is properly populated.
168+
func TestHardlinkExtraction(t *testing.T) {
169+
srcDir := t.TempDir()
170+
171+
originalPath := filepath.Join(srcDir, "aaa.txt")
172+
content := []byte("test content")
173+
if err := os.WriteFile(originalPath, content, 0644); err != nil {
174+
t.Fatalf("Failed to create original file: %v", err)
175+
}
176+
177+
hardlinkPath := filepath.Join(srcDir, "zzz.txt")
178+
if err := os.Link(originalPath, hardlinkPath); err != nil {
179+
t.Fatalf("Failed to create hardlink: %v", err)
180+
}
181+
182+
archivePath := filepath.Join(t.TempDir(), "test.tar")
183+
archiveFile, err := os.Create(archivePath)
184+
if err != nil {
185+
t.Fatalf("Failed to create archive file: %v", err)
186+
}
187+
188+
ctx := context.Background()
189+
files, err := FilesFromDisk(ctx, nil, map[string]string{srcDir: ""})
190+
if err != nil {
191+
t.Fatalf("FilesFromDisk failed: %v", err)
192+
}
193+
194+
format := Tar{}
195+
if err := format.Archive(ctx, archiveFile, files); err != nil {
196+
t.Fatalf("Archive failed: %v", err)
197+
}
198+
archiveFile.Close()
199+
200+
archiveFile, err = os.Open(archivePath)
201+
if err != nil {
202+
t.Fatalf("Failed to open archive: %v", err)
203+
}
204+
defer archiveFile.Close()
205+
206+
var foundOriginal, foundHardlink bool
207+
err = format.Extract(ctx, archiveFile, func(ctx context.Context, file FileInfo) error {
208+
t.Logf("Extracted: Name=%s, LinkTarget=%q", file.Name(), file.LinkTarget)
209+
if hdr, ok := file.Header.(*tar.Header); ok {
210+
t.Logf(" Header: Typeflag=%d (%c), Linkname=%s", hdr.Typeflag, hdr.Typeflag, hdr.Linkname)
211+
}
212+
213+
if file.Name() == "aaa.txt" {
214+
foundOriginal = true
215+
if file.LinkTarget != "" {
216+
t.Errorf("First occurrence should not have LinkTarget, got %q", file.LinkTarget)
217+
}
218+
if hdr, ok := file.Header.(*tar.Header); ok {
219+
if hdr.Typeflag != tar.TypeReg {
220+
t.Errorf("First occurrence should be TypeReg, got %d", hdr.Typeflag)
221+
}
222+
}
223+
}
224+
225+
if file.Name() == "zzz.txt" {
226+
foundHardlink = true
227+
if file.LinkTarget == "" {
228+
t.Error("LinkTarget not set for hardlink during extraction")
229+
} else {
230+
t.Logf("Hardlink LinkTarget: %s", file.LinkTarget)
231+
}
232+
if hdr, ok := file.Header.(*tar.Header); ok {
233+
if hdr.Typeflag != tar.TypeLink && hdr.Typeflag != tar.TypeReg+1 {
234+
t.Errorf("Expected hardlink type, got %d (%c)", hdr.Typeflag, hdr.Typeflag)
235+
}
236+
}
237+
}
238+
return nil
239+
})
240+
241+
if err != nil {
242+
t.Fatalf("Extract failed: %v", err)
243+
}
244+
245+
if !foundOriginal {
246+
t.Error("Original file was not found during extraction")
247+
}
248+
if !foundHardlink {
249+
t.Error("Hardlink was not found during extraction")
250+
}
251+
}

hardlink_windows.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
//go:build windows
2+
3+
package archives
4+
5+
import "io/fs"
6+
7+
type inodeKey struct{}
8+
9+
func hardlinkKey(_ fs.FileInfo) (inodeKey, bool) {
10+
return inodeKey{}, false
11+
}

0 commit comments

Comments
 (0)