Skip to content

Commit 4925543

Browse files
authored
Merge pull request #143 from drassi-run/xfs-test
test `xfs.Read` and `xfs.Write` helper function
2 parents 1d5dece + 04c6356 commit 4925543

2 files changed

Lines changed: 235 additions & 3 deletions

File tree

‎core/util/fs/transfer.go‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,15 @@ import (
2222
"github.com/go-git/go-billy/v5/util"
2323
)
2424

25+
// Read archives the directory or file at src within fsys into a tar stream and returns an io.ReadCloser.
26+
//
27+
// NOTE (Entry naming convention):
28+
// - If src is "." or "", all files in fsys are archived relative to the root (e.g. "file.txt", "sub/file.txt").
29+
// - If src is a directory (e.g. "app/config"), the base folder name is preserved in tar headers
30+
// (e.g. "config/settings.json", "config/app.conf"), matching Docker container archive API behavior.
31+
// - If src is a single file (e.g. "app/config/settings.json"), the tar entry name is the file's basename ("settings.json").
32+
//
33+
// docker cp logic: https://docs.docker.com/reference/cli/docker/container/cp/
2534
func Read(ctx context.Context, fsys billy.Filesystem, src string) io.ReadCloser {
2635
reader, writer := io.Pipe()
2736
go readPipe(ctx, fsys, src, writer)
@@ -60,10 +69,10 @@ func readPipe(ctx context.Context, fsys billy.Filesystem, src string, writer *io
6069
var hdr *tar.Header
6170
if hdr, err = tar.FileInfoHeader(info, link); err != nil {
6271
return err
63-
} else {
64-
// info only contains file's base, but we want the path
65-
hdr.Name = strings.TrimPrefix(path, dir)
6672
}
73+
74+
// info only contains file's base, but we want the path
75+
hdr.Name = strings.TrimPrefix(path, dir)
6776
if err = tw.WriteHeader(hdr); err != nil {
6877
return err
6978
}
@@ -88,6 +97,7 @@ func readPipe(ctx context.Context, fsys billy.Filesystem, src string, writer *io
8897
_ = writer.CloseWithError(err)
8998
}
9099

100+
// Write extracts a tar archive from reader into the dest directory on fsys.
91101
func Write(ctx context.Context, fsys billy.Filesystem, reader io.Reader, dest string) error {
92102
h := writeHandler(ctx, fsys, dest)
93103
return xtar.Untar(ctx, reader, h)

‎core/util/fs/transfer_test.go‎

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
/*
2+
* SPDX-FileCopyrightText: (c) 2024 The Drassi Authors
3+
*
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
package xfs
8+
9+
import (
10+
"archive/tar"
11+
"bytes"
12+
"context"
13+
"errors"
14+
"io"
15+
"os"
16+
"path/filepath"
17+
"testing"
18+
19+
"github.com/go-git/go-billy/v5/osfs"
20+
"github.com/stretchr/testify/require"
21+
)
22+
23+
func assertTar(t *testing.T, r io.Reader, expectedEntries, expectedSymlinks map[string]string) {
24+
t.Helper()
25+
entries := make(map[string]string)
26+
symlinks := make(map[string]string)
27+
tr := tar.NewReader(r)
28+
29+
for {
30+
hdr, err := tr.Next()
31+
if errors.Is(err, io.EOF) {
32+
break
33+
}
34+
require.NoError(t, err)
35+
36+
if hdr.Typeflag == tar.TypeSymlink {
37+
symlinks[hdr.Name] = hdr.Linkname
38+
} else if hdr.Typeflag == tar.TypeReg {
39+
content, err := io.ReadAll(tr)
40+
require.NoError(t, err)
41+
entries[hdr.Name] = string(content)
42+
}
43+
}
44+
45+
if expectedEntries != nil {
46+
require.Equal(t, expectedEntries, entries)
47+
} else {
48+
require.Empty(t, entries)
49+
}
50+
51+
if expectedSymlinks != nil {
52+
require.Equal(t, expectedSymlinks, symlinks)
53+
} else {
54+
require.Empty(t, symlinks)
55+
}
56+
}
57+
58+
func TestRead(t *testing.T) {
59+
t.Run("root directory", func(t *testing.T) {
60+
tempDir := t.TempDir()
61+
require.NoError(t, os.WriteFile(filepath.Join(tempDir, "file1.txt"), []byte("content1"), 0o644))
62+
require.NoError(t, os.MkdirAll(filepath.Join(tempDir, "sub"), 0o755))
63+
require.NoError(t, os.WriteFile(filepath.Join(tempDir, "sub", "file2.txt"), []byte("content2"), 0o644))
64+
require.NoError(t, os.Symlink("file1.txt", filepath.Join(tempDir, "link1")))
65+
66+
fsys := osfs.New(tempDir)
67+
rc := Read(t.Context(), fsys, ".")
68+
defer rc.Close()
69+
70+
entries := map[string]string{
71+
"file1.txt": "content1",
72+
"sub/file2.txt": "content2",
73+
}
74+
symlinks := map[string]string{
75+
"link1": "file1.txt",
76+
}
77+
assertTar(t, rc, entries, symlinks)
78+
})
79+
80+
t.Run("subpath directory preserves folder name", func(t *testing.T) {
81+
tempDir := t.TempDir()
82+
appConfigDir := filepath.Join(tempDir, "app", "config")
83+
require.NoError(t, os.MkdirAll(appConfigDir, 0o755))
84+
require.NoError(t, os.WriteFile(filepath.Join(appConfigDir, "settings.json"), []byte(`{"port":8080}`), 0o644))
85+
require.NoError(t, os.WriteFile(filepath.Join(appConfigDir, "app.conf"), []byte("key=val"), 0o644))
86+
87+
fsys := osfs.New(tempDir)
88+
rc := Read(t.Context(), fsys, "app/config")
89+
defer rc.Close()
90+
91+
entries := map[string]string{
92+
"config/settings.json": `{"port":8080}`,
93+
"config/app.conf": "key=val",
94+
}
95+
assertTar(t, rc, entries, nil)
96+
})
97+
98+
t.Run("single file subpath", func(t *testing.T) {
99+
tempDir := t.TempDir()
100+
appDir := filepath.Join(tempDir, "app")
101+
require.NoError(t, os.MkdirAll(appDir, 0o755))
102+
require.NoError(t, os.WriteFile(filepath.Join(appDir, "config.yaml"), []byte("env: test"), 0o644))
103+
104+
fsys := osfs.New(tempDir)
105+
rc := Read(t.Context(), fsys, "app/config.yaml")
106+
defer rc.Close()
107+
108+
entries := map[string]string{
109+
"config.yaml": "env: test",
110+
}
111+
assertTar(t, rc, entries, nil)
112+
})
113+
114+
t.Run("context cancellation stops read", func(t *testing.T) {
115+
tempDir := t.TempDir()
116+
require.NoError(t, os.WriteFile(filepath.Join(tempDir, "file.txt"), []byte("data"), 0o644))
117+
118+
ctx, cancel := context.WithCancel(t.Context())
119+
cancel()
120+
121+
fsys := osfs.New(tempDir)
122+
rc := Read(ctx, fsys, ".")
123+
defer rc.Close()
124+
125+
_, err := io.ReadAll(rc)
126+
require.Error(t, err)
127+
require.True(t, errors.Is(err, context.Canceled) || errors.Is(err, io.ErrClosedPipe))
128+
})
129+
}
130+
131+
func TestWrite(t *testing.T) {
132+
t.Run("extracts directory structure regular files and symlinks", func(t *testing.T) {
133+
destDir := t.TempDir()
134+
destFsys := osfs.New(destDir)
135+
136+
buf := new(bytes.Buffer)
137+
tw := tar.NewWriter(buf)
138+
139+
// Directory entry
140+
require.NoError(t, tw.WriteHeader(&tar.Header{
141+
Name: "extracted/sub/",
142+
Typeflag: tar.TypeDir,
143+
Mode: 0o755,
144+
}))
145+
146+
// Regular file entry
147+
fileContent := []byte("hello extracted")
148+
require.NoError(t, tw.WriteHeader(&tar.Header{
149+
Name: "extracted/sub/file.txt",
150+
Typeflag: tar.TypeReg,
151+
Mode: 0o644,
152+
Size: int64(len(fileContent)),
153+
}))
154+
_, err := tw.Write(fileContent)
155+
require.NoError(t, err)
156+
157+
// Symlink entry
158+
require.NoError(t, tw.WriteHeader(&tar.Header{
159+
Name: "extracted/sub/link.txt",
160+
Typeflag: tar.TypeSymlink,
161+
Linkname: "file.txt",
162+
}))
163+
require.NoError(t, tw.Close())
164+
165+
err = Write(t.Context(), destFsys, buf, "")
166+
require.NoError(t, err)
167+
168+
// Verify extracted files on disk
169+
content, err := os.ReadFile(filepath.Join(destDir, "extracted", "sub", "file.txt"))
170+
require.NoError(t, err)
171+
require.Equal(t, "hello extracted", string(content))
172+
173+
target, err := os.Readlink(filepath.Join(destDir, "extracted", "sub", "link.txt"))
174+
require.NoError(t, err)
175+
require.Equal(t, "file.txt", target)
176+
})
177+
178+
t.Run("unsupported entry type returns error", func(t *testing.T) {
179+
destDir := t.TempDir()
180+
destFsys := osfs.New(destDir)
181+
182+
buf := new(bytes.Buffer)
183+
tw := tar.NewWriter(buf)
184+
185+
require.NoError(t, tw.WriteHeader(&tar.Header{
186+
Name: "fifo_pipe",
187+
Typeflag: tar.TypeFifo,
188+
}))
189+
require.NoError(t, tw.Close())
190+
191+
err := Write(t.Context(), destFsys, buf, "")
192+
require.Error(t, err)
193+
require.Contains(t, err.Error(), "unsupported fifo file")
194+
})
195+
}
196+
197+
func TestReadWriteRoundTrip(t *testing.T) {
198+
srcDir := t.TempDir()
199+
destDir := t.TempDir()
200+
201+
require.NoError(t, os.MkdirAll(filepath.Join(srcDir, "nested", "dir"), 0o755))
202+
require.NoError(t, os.WriteFile(filepath.Join(srcDir, "nested", "file.txt"), []byte("payload data"), 0o644))
203+
require.NoError(t, os.Symlink("file.txt", filepath.Join(srcDir, "nested", "symlink.txt")))
204+
205+
srcFsys := osfs.New(srcDir)
206+
destFsys := osfs.New(destDir)
207+
208+
rc := Read(t.Context(), srcFsys, "nested")
209+
defer rc.Close()
210+
211+
err := Write(t.Context(), destFsys, rc, "target")
212+
require.NoError(t, err)
213+
214+
extractedFile := filepath.Join(destDir, "target", "nested", "file.txt")
215+
content, err := os.ReadFile(extractedFile)
216+
require.NoError(t, err)
217+
require.Equal(t, "payload data", string(content))
218+
219+
linkTarget, err := os.Readlink(filepath.Join(destDir, "target", "nested", "symlink.txt"))
220+
require.NoError(t, err)
221+
require.Equal(t, "file.txt", linkTarget)
222+
}

0 commit comments

Comments
 (0)