Skip to content

Commit 79b68b4

Browse files
authored
fix symlink file handling and add tests for tarball create (#543)
* fix symlink file handling and add tests for tarball uploads * add nanpa
1 parent 817558d commit 79b68b4

File tree

3 files changed

+245
-1
lines changed

3 files changed

+245
-1
lines changed

.nanpa/agent-hosting-uploads.kdl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
minor type="fixed" "uploading issues with symlinks"

pkg/agentfs/tar.go

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ func UploadTarball(directory string, presignedUrl string, excludeFiles []string)
154154
}
155155
}
156156

157-
// Handle symlinks and get the real FileInfo if it's a symlink
157+
// Follow symlinks and include the actual file contents
158158
if info.Mode()&os.ModeSymlink != 0 {
159159
realPath, err := filepath.EvalSymlinks(path)
160160
if err != nil {
@@ -164,6 +164,28 @@ func UploadTarball(directory string, presignedUrl string, excludeFiles []string)
164164
if err != nil {
165165
return fmt.Errorf("failed to stat %s: %w", realPath, err)
166166
}
167+
// Open the real file instead of the symlink
168+
file, err := os.Open(realPath)
169+
if err != nil {
170+
return fmt.Errorf("failed to open file %s: %w", realPath, err)
171+
}
172+
defer file.Close()
173+
174+
header, err := tar.FileInfoHeader(info, "")
175+
if err != nil {
176+
return fmt.Errorf("failed to create tar header for file %s: %w", path, err)
177+
}
178+
header.Name = relPath
179+
if err := tarWriter.WriteHeader(header); err != nil {
180+
return fmt.Errorf("failed to write tar header for file %s: %w", path, err)
181+
}
182+
183+
// Copy file contents directly without progress bar
184+
_, err = io.Copy(tarWriter, file)
185+
if err != nil {
186+
return fmt.Errorf("failed to copy file content for %s: %w", path, err)
187+
}
188+
return nil
167189
}
168190

169191
// Handle directories

pkg/agentfs/tar_test.go

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
package agentfs
2+
3+
import (
4+
"archive/tar"
5+
"bytes"
6+
"compress/gzip"
7+
"io"
8+
"net/http"
9+
"net/http/httptest"
10+
"os"
11+
"path/filepath"
12+
"testing"
13+
14+
"github.com/stretchr/testify/require"
15+
)
16+
17+
func TestUploadTarball(t *testing.T) {
18+
tmpDir, err := os.MkdirTemp("", "tarball-test-*")
19+
require.NoError(t, err)
20+
defer os.RemoveAll(tmpDir)
21+
22+
subDir := filepath.Join(tmpDir, "subdir")
23+
err = os.MkdirAll(subDir, 0755)
24+
require.NoError(t, err)
25+
26+
normalFiles := []string{
27+
filepath.Join(subDir, "normal1.txt"),
28+
filepath.Join(subDir, "normal2.txt"),
29+
filepath.Join(tmpDir, "root.txt"),
30+
}
31+
for _, path := range normalFiles {
32+
err = os.WriteFile(path, []byte("normal content"), 0644)
33+
require.NoError(t, err)
34+
}
35+
36+
largeFile := filepath.Join(tmpDir, "large.bin")
37+
f, err := os.Create(largeFile)
38+
require.NoError(t, err)
39+
40+
fileSize := int64(1024 * 1024 * 1024) // 1GB
41+
err = f.Truncate(fileSize)
42+
require.NoError(t, err)
43+
f.Close()
44+
45+
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
46+
w.WriteHeader(http.StatusOK)
47+
}))
48+
defer mockServer.Close()
49+
50+
err = UploadTarball(tmpDir, mockServer.URL, []string{})
51+
require.NoError(t, err)
52+
}
53+
54+
func TestUploadTarballFilePermissions(t *testing.T) {
55+
tmpDir, err := os.MkdirTemp("", "tarball-test-*")
56+
require.NoError(t, err)
57+
defer os.RemoveAll(tmpDir)
58+
59+
subDir := filepath.Join(tmpDir, "subdir")
60+
err = os.MkdirAll(subDir, 0755)
61+
require.NoError(t, err)
62+
63+
normalFiles := []string{
64+
filepath.Join(subDir, "normal1.txt"),
65+
filepath.Join(subDir, "normal2.txt"),
66+
}
67+
for _, path := range normalFiles {
68+
err = os.WriteFile(path, []byte("normal content"), 0644)
69+
require.NoError(t, err)
70+
}
71+
72+
restrictedFile := filepath.Join(subDir, "restricted.txt")
73+
err = os.WriteFile(restrictedFile, []byte("restricted content"), 0000)
74+
require.NoError(t, err)
75+
76+
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
77+
w.WriteHeader(http.StatusOK)
78+
}))
79+
defer mockServer.Close()
80+
81+
err = UploadTarball(tmpDir, mockServer.URL, []string{})
82+
require.Error(t, err)
83+
require.Contains(t, err.Error(), "permission denied")
84+
85+
err = os.Remove(restrictedFile)
86+
require.NoError(t, err)
87+
88+
err = UploadTarball(tmpDir, mockServer.URL, []string{})
89+
require.NoError(t, err)
90+
}
91+
92+
type tarContents struct {
93+
Name string
94+
Size int64
95+
IsDir bool
96+
IsSymlink bool
97+
LinkTarget string
98+
}
99+
100+
func readTarContents(t *testing.T, tarData []byte) []tarContents {
101+
t.Helper()
102+
var contents []tarContents
103+
104+
gzipReader, err := gzip.NewReader(bytes.NewReader(tarData))
105+
require.NoError(t, err)
106+
defer gzipReader.Close()
107+
108+
tarReader := tar.NewReader(gzipReader)
109+
for {
110+
header, err := tarReader.Next()
111+
if err == io.EOF {
112+
break
113+
}
114+
require.NoError(t, err)
115+
116+
content := tarContents{
117+
Name: header.Name,
118+
Size: header.Size,
119+
IsDir: header.Typeflag == tar.TypeDir,
120+
IsSymlink: header.Typeflag == tar.TypeSymlink,
121+
LinkTarget: header.Linkname,
122+
}
123+
contents = append(contents, content)
124+
}
125+
return contents
126+
}
127+
128+
func TestUploadTarballDotfiles(t *testing.T) {
129+
tmpDir, err := os.MkdirTemp("", "tarball-test-*")
130+
require.NoError(t, err)
131+
defer os.RemoveAll(tmpDir)
132+
133+
subDir := filepath.Join(tmpDir, "src")
134+
err = os.MkdirAll(subDir, 0755)
135+
require.NoError(t, err)
136+
137+
files := []struct {
138+
path string
139+
content string
140+
}{
141+
{filepath.Join(tmpDir, "regular.txt"), "regular file"},
142+
{filepath.Join(subDir, "code.go"), "package main"},
143+
}
144+
for _, f := range files {
145+
err = os.WriteFile(f.path, []byte(f.content), 0644)
146+
require.NoError(t, err)
147+
}
148+
149+
dotfiles := []struct {
150+
path string
151+
content string
152+
}{
153+
{filepath.Join(tmpDir, ".gitignore"), "*.env\nnode_modules/"},
154+
{filepath.Join(tmpDir, ".env"), "SECRET=123"},
155+
{filepath.Join(tmpDir, ".config"), "config data"},
156+
{filepath.Join(subDir, ".DS_Store"), "mac file"},
157+
}
158+
for _, f := range dotfiles {
159+
err = os.WriteFile(f.path, []byte(f.content), 0644)
160+
require.NoError(t, err)
161+
}
162+
163+
// symlinks
164+
err = os.Symlink(files[0].path, filepath.Join(tmpDir, "link_to_regular.txt"))
165+
require.NoError(t, err)
166+
err = os.Symlink(dotfiles[2].path, filepath.Join(tmpDir, ".link_to_config"))
167+
require.NoError(t, err)
168+
169+
nodeModules := filepath.Join(tmpDir, "node_modules")
170+
err = os.MkdirAll(nodeModules, 0755)
171+
require.NoError(t, err)
172+
err = os.WriteFile(filepath.Join(nodeModules, "package.json"), []byte("{}"), 0644)
173+
require.NoError(t, err)
174+
175+
var tarBuffer bytes.Buffer
176+
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
177+
_, err := io.Copy(&tarBuffer, r.Body)
178+
require.NoError(t, err)
179+
w.WriteHeader(http.StatusOK)
180+
}))
181+
defer mockServer.Close()
182+
183+
err = UploadTarball(tmpDir, mockServer.URL, []string{})
184+
require.NoError(t, err)
185+
186+
contents := readTarContents(t, tarBuffer.Bytes())
187+
188+
expectedFiles := map[string]struct{}{
189+
"regular.txt": {},
190+
"src/code.go": {},
191+
".config": {},
192+
"link_to_regular.txt": {},
193+
".link_to_config": {},
194+
}
195+
196+
excludedFiles := map[string]struct{}{
197+
".env": {},
198+
".gitignore": {},
199+
"node_modules/": {},
200+
".DS_Store": {},
201+
}
202+
203+
for _, content := range contents {
204+
if _, ok := expectedFiles[content.Name]; ok {
205+
delete(expectedFiles, content.Name)
206+
207+
switch content.Name {
208+
case "link_to_regular.txt":
209+
require.Equal(t, int64(len("regular file")), content.Size)
210+
require.False(t, content.IsSymlink)
211+
case ".link_to_config":
212+
require.Equal(t, int64(len("config data")), content.Size)
213+
require.False(t, content.IsSymlink)
214+
}
215+
}
216+
_, shouldBeExcluded := excludedFiles[content.Name]
217+
require.False(t, shouldBeExcluded, "excluded file was found in tar: %s", content.Name)
218+
}
219+
220+
require.Empty(t, expectedFiles, "some expected files were not found in tar: %v", expectedFiles)
221+
}

0 commit comments

Comments
 (0)