Skip to content

Commit e609593

Browse files
committed
Add chroot support to embedfs using billy's chroot helper
- Remove memfs dependency and use local sorting for ReadDir - Implement chroot support by wrapping embedfs with chroot.New() in New() - Add comprehensive chroot tests covering read-only operations - Follow same pattern as memfs and osfs for consistent API - Maintain read-only behavior - write operations still return ErrReadOnly This enables embedfs to work with filesystem abstractions that require chroot support for path isolation.
1 parent ee801ff commit e609593

File tree

4 files changed

+303
-9
lines changed

4 files changed

+303
-9
lines changed

embedfs/chroot_test.go

Lines changed: 294 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,294 @@
1+
package embedfs
2+
3+
import (
4+
"io"
5+
"testing"
6+
7+
"github.com/go-git/go-billy/v6"
8+
"github.com/go-git/go-billy/v6/embedfs_testdata"
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
func TestChroot_Basic(t *testing.T) {
14+
t.Parallel()
15+
16+
fs := New(embedfs_testdata.GetTestData())
17+
18+
// Test chroot to existing directory
19+
chrootFS, err := fs.Chroot("testdata")
20+
require.NoError(t, err)
21+
require.NotNil(t, chrootFS)
22+
23+
// Test that we can access files in the chrooted filesystem
24+
f, err := chrootFS.Open("file1.txt")
25+
require.NoError(t, err)
26+
defer f.Close()
27+
28+
content, err := io.ReadAll(f)
29+
require.NoError(t, err)
30+
assert.Equal(t, "Hello from embedfs!", string(content))
31+
}
32+
33+
func TestChroot_NestedDirectory(t *testing.T) {
34+
t.Parallel()
35+
36+
fs := New(embedfs_testdata.GetTestData())
37+
38+
// Test chroot to nested directory
39+
chrootFS, err := fs.Chroot("testdata/subdir")
40+
require.NoError(t, err)
41+
require.NotNil(t, chrootFS)
42+
43+
// Test that we can access nested files from the chrooted root
44+
f, err := chrootFS.Open("nested.txt")
45+
require.NoError(t, err)
46+
defer f.Close()
47+
48+
content, err := io.ReadAll(f)
49+
require.NoError(t, err)
50+
assert.Equal(t, "Nested file content", string(content))
51+
}
52+
53+
func TestChroot_StatInChroot(t *testing.T) {
54+
t.Parallel()
55+
56+
fs := New(embedfs_testdata.GetTestData())
57+
58+
chrootFS, err := fs.Chroot("testdata")
59+
require.NoError(t, err)
60+
61+
// Test stat on files that exist in chrooted directory
62+
fi, err := chrootFS.Stat("file1.txt")
63+
require.NoError(t, err)
64+
assert.Equal(t, "file1.txt", fi.Name())
65+
assert.False(t, fi.IsDir())
66+
67+
// Test stat on directories that exist in chrooted directory
68+
fi, err = chrootFS.Stat("subdir")
69+
require.NoError(t, err)
70+
assert.Equal(t, "subdir", fi.Name())
71+
assert.True(t, fi.IsDir())
72+
73+
// Test stat with absolute path in chrooted filesystem
74+
fi, err = chrootFS.Stat("/file2.txt")
75+
require.NoError(t, err)
76+
assert.Equal(t, "file2.txt", fi.Name())
77+
assert.False(t, fi.IsDir())
78+
}
79+
80+
func TestChroot_ReadDirInChroot(t *testing.T) {
81+
t.Parallel()
82+
83+
fs := New(embedfs_testdata.GetTestData())
84+
85+
chrootFS, err := fs.Chroot("testdata")
86+
require.NoError(t, err)
87+
88+
// Test reading directory contents from chrooted root
89+
entries, err := chrootFS.ReadDir("/")
90+
require.NoError(t, err)
91+
92+
expectedFiles := []string{"empty.txt", "file1.txt", "file2.txt", "subdir"}
93+
assert.Len(t, entries, len(expectedFiles))
94+
95+
foundFiles := make(map[string]bool)
96+
for _, entry := range entries {
97+
foundFiles[entry.Name()] = true
98+
}
99+
100+
for _, expected := range expectedFiles {
101+
assert.True(t, foundFiles[expected], "Expected file %s not found", expected)
102+
}
103+
104+
// Test reading subdirectory from chrooted filesystem
105+
entries, err = chrootFS.ReadDir("subdir")
106+
require.NoError(t, err)
107+
assert.Len(t, entries, 1)
108+
assert.Equal(t, "nested.txt", entries[0].Name())
109+
}
110+
111+
func TestChroot_PathNormalization(t *testing.T) {
112+
t.Parallel()
113+
114+
fs := New(embedfs_testdata.GetTestData())
115+
116+
// Test chroot with different path formats
117+
tests := []struct {
118+
name string
119+
chrootPath string
120+
openPath string
121+
expectFile string
122+
}{
123+
{
124+
name: "absolute chroot path",
125+
chrootPath: "/testdata",
126+
openPath: "file1.txt",
127+
expectFile: "file1.txt",
128+
},
129+
{
130+
name: "relative chroot path",
131+
chrootPath: "testdata",
132+
openPath: "file1.txt",
133+
expectFile: "file1.txt",
134+
},
135+
{
136+
name: "absolute open path in chroot",
137+
chrootPath: "testdata",
138+
openPath: "/file1.txt",
139+
expectFile: "file1.txt",
140+
},
141+
{
142+
name: "nested chroot",
143+
chrootPath: "testdata/subdir",
144+
openPath: "nested.txt",
145+
expectFile: "nested.txt",
146+
},
147+
}
148+
149+
for _, tt := range tests {
150+
t.Run(tt.name, func(t *testing.T) {
151+
chrootFS, err := fs.Chroot(tt.chrootPath)
152+
require.NoError(t, err)
153+
154+
f, err := chrootFS.Open(tt.openPath)
155+
require.NoError(t, err)
156+
defer f.Close()
157+
158+
assert.Equal(t, tt.expectFile, f.Name())
159+
})
160+
}
161+
}
162+
163+
func TestChroot_NonExistentPath(t *testing.T) {
164+
t.Parallel()
165+
166+
fs := New(embedfs_testdata.GetTestData())
167+
168+
// Test chroot to non-existent directory - billy's chroot helper allows this
169+
chrootFS, err := fs.Chroot("nonexistent")
170+
require.NoError(t, err)
171+
require.NotNil(t, chrootFS)
172+
173+
// But accessing files within the non-existent chroot should fail
174+
_, err = chrootFS.Open("anyfile.txt")
175+
assert.Error(t, err)
176+
}
177+
178+
func TestChroot_Join(t *testing.T) {
179+
t.Parallel()
180+
181+
fs := New(embedfs_testdata.GetTestData())
182+
chrootFS, err := fs.Chroot("testdata")
183+
require.NoError(t, err)
184+
185+
// Test Join operation in chrooted filesystem
186+
joined := chrootFS.Join("subdir", "nested.txt")
187+
assert.Equal(t, "subdir/nested.txt", joined)
188+
189+
// Test that joined path can be used to open file
190+
f, err := chrootFS.Open(joined)
191+
require.NoError(t, err)
192+
defer f.Close()
193+
194+
content, err := io.ReadAll(f)
195+
require.NoError(t, err)
196+
assert.Equal(t, "Nested file content", string(content))
197+
}
198+
199+
func TestChroot_UnsupportedOperations(t *testing.T) {
200+
t.Parallel()
201+
202+
fs := New(embedfs_testdata.GetTestData())
203+
chrootFS, err := fs.Chroot("testdata")
204+
require.NoError(t, err)
205+
206+
// Test that write operations still fail in chrooted embedfs
207+
_, err = chrootFS.Create("newfile.txt")
208+
require.ErrorIs(t, err, billy.ErrReadOnly)
209+
210+
err = chrootFS.Remove("file1.txt")
211+
require.ErrorIs(t, err, billy.ErrReadOnly)
212+
213+
err = chrootFS.Rename("file1.txt", "renamed.txt")
214+
require.ErrorIs(t, err, billy.ErrReadOnly)
215+
216+
err = chrootFS.MkdirAll("newdir", 0755)
217+
require.ErrorIs(t, err, billy.ErrReadOnly)
218+
}
219+
220+
func TestChroot_NestedChroot(t *testing.T) {
221+
t.Parallel()
222+
223+
fs := New(embedfs_testdata.GetTestData())
224+
225+
// Test creating nested chrootfs
226+
firstChroot, err := fs.Chroot("testdata")
227+
require.NoError(t, err)
228+
229+
secondChroot, err := firstChroot.Chroot("subdir")
230+
require.NoError(t, err)
231+
232+
// Test that nested chroot works correctly
233+
f, err := secondChroot.Open("nested.txt")
234+
require.NoError(t, err)
235+
defer f.Close()
236+
237+
content, err := io.ReadAll(f)
238+
require.NoError(t, err)
239+
assert.Equal(t, "Nested file content", string(content))
240+
241+
// Test that we can't access parent directory from nested chroot
242+
entries, err := secondChroot.ReadDir("/")
243+
require.NoError(t, err)
244+
assert.Len(t, entries, 1)
245+
assert.Equal(t, "nested.txt", entries[0].Name())
246+
}
247+
248+
func TestChroot_FileOperations(t *testing.T) {
249+
t.Parallel()
250+
251+
fs := New(embedfs_testdata.GetTestData())
252+
chrootFS, err := fs.Chroot("testdata")
253+
require.NoError(t, err)
254+
255+
// Test file operations in chrooted filesystem
256+
f, err := chrootFS.Open("file2.txt")
257+
require.NoError(t, err)
258+
defer f.Close()
259+
260+
// Test Read
261+
buf := make([]byte, 10)
262+
n, err := f.Read(buf)
263+
require.NoError(t, err)
264+
assert.Equal(t, "Another te", string(buf[:n]))
265+
266+
// Test Seek
267+
_, err = f.Seek(0, io.SeekStart)
268+
require.NoError(t, err)
269+
270+
// Test ReadAt
271+
buf2 := make([]byte, 7)
272+
n, err = f.ReadAt(buf2, 8)
273+
require.NoError(t, err)
274+
assert.Equal(t, "test fi", string(buf2[:n]))
275+
276+
// Test that file position wasn't affected by ReadAt
277+
n, err = f.Read(buf)
278+
require.NoError(t, err)
279+
assert.Equal(t, "Another te", string(buf[:n]))
280+
}
281+
282+
func TestChroot_Lstat(t *testing.T) {
283+
t.Parallel()
284+
285+
fs := New(embedfs_testdata.GetTestData())
286+
chrootFS, err := fs.Chroot("testdata")
287+
require.NoError(t, err)
288+
289+
// Test Lstat in chrooted filesystem (should behave same as Stat for embedfs)
290+
fi, err := chrootFS.Lstat("file1.txt")
291+
require.NoError(t, err)
292+
assert.Equal(t, "file1.txt", fi.Name())
293+
assert.False(t, fi.IsDir())
294+
}

embedfs/embed.go

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import (
1313
"sync"
1414

1515
"github.com/go-git/go-billy/v6"
16-
"github.com/go-git/go-billy/v6/memfs"
16+
"github.com/go-git/go-billy/v6/helper/chroot"
1717
)
1818

1919
type Embed struct {
@@ -29,7 +29,7 @@ func New(efs *embed.FS) billy.Filesystem {
2929
fs.underlying = &embed.FS{}
3030
}
3131

32-
return fs
32+
return chroot.New(fs, "/")
3333
}
3434

3535
// normalizePath converts billy's absolute paths to embed.FS relative paths
@@ -120,17 +120,14 @@ func (fs *Embed) ReadDir(path string) ([]os.FileInfo, error) {
120120
entries = append(entries, fi)
121121
}
122122

123-
sort.Sort(memfs.ByName(entries))
123+
sort.Slice(entries, func(i, j int) bool {
124+
return entries[i].Name() < entries[j].Name()
125+
})
124126

125127
return entries, nil
126128
}
127129

128-
// Chroot is not supported.
129-
//
130-
// Calls will always return billy.ErrNotSupported.
131-
func (fs *Embed) Chroot(_ string) (billy.Filesystem, error) {
132-
return nil, billy.ErrNotSupported
133-
}
130+
134131

135132
// Lstat behaves the same as Stat for embedded filesystems since there are no symlinks.
136133
func (fs *Embed) Lstat(filename string) (os.FileInfo, error) {

go.mod

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,5 @@ require (
1414
github.com/pmezard/go-difflib v1.0.0 // indirect
1515
gopkg.in/yaml.v3 v3.0.1 // indirect
1616
)
17+
18+
replace github.com/go-git/go-billy/v6 => github.com/this-kirke/go-billy/v6 v6.0.0

go.sum

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22r
22
github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI=
33
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
44
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
5+
github.com/go-git/go-billy/v6 v6.0.0-20250711053805-c1f149aaab07/go.mod h1:Pa0/zeE0tC0GiZLFFtOYXOky9SgpNF+zkrj7aEJhBVg=
56
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
67
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
78
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=

0 commit comments

Comments
 (0)