Skip to content

Commit abb3ff8

Browse files
committed
feat: restore fs.FS implementation backed by IPFS
- Add system/ipfs.go with complete fs.FS interface implementation - Add system/ipfs_test.go with comprehensive test coverage - Support directory traversal, file reading, and sub-filesystem creation - Integrate with IPFS UnixFS for distributed file access - Add github.com/pkg/errors dependency for error handling This restores the IPFS filesystem functionality that was previously removed, enabling guest filesystem access to IPFS content.
1 parent 4c882dc commit abb3ff8

File tree

3 files changed

+335
-0
lines changed

3 files changed

+335
-0
lines changed

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ require (
1111
github.com/lthibault/go-libp2p-inproc-transport v0.4.1
1212
github.com/mr-tron/base58 v1.2.0
1313
github.com/multiformats/go-multiaddr v0.16.0
14+
github.com/pkg/errors v0.9.1
1415
github.com/stretchr/testify v1.11.1
1516
github.com/tetratelabs/wazero v1.9.0
1617
github.com/urfave/cli/v2 v2.27.5

system/ipfs.go

Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
package system
2+
3+
import (
4+
"context"
5+
"io"
6+
"io/fs"
7+
"log/slog"
8+
"runtime"
9+
"time"
10+
11+
"github.com/ipfs/boxo/files"
12+
"github.com/ipfs/boxo/path"
13+
iface "github.com/ipfs/kubo/core/coreiface"
14+
"github.com/pkg/errors"
15+
)
16+
17+
var _ fs.FS = (*IPFS)(nil)
18+
19+
// An IPFS provides access to a hierarchical file system.
20+
//
21+
// The IPFS interface is the minimum implementation required of the file system.
22+
// A file system may implement additional interfaces,
23+
// such as [ReadFileFS], to provide additional or optimized functionality.
24+
//
25+
// [testing/fstest.TestFS] may be used to test implementations of an IPFS for
26+
// correctness.
27+
type IPFS struct {
28+
Ctx context.Context
29+
Root path.Path
30+
Unix iface.UnixfsAPI
31+
}
32+
33+
// Open opens the named file.
34+
//
35+
// When Open returns an error, it should be of type *PathError
36+
// with the Op field set to "open", the Path field set to name,
37+
// and the Err field describing the problem.
38+
//
39+
// Open should reject attempts to open names that do not satisfy
40+
// fs.ValidPath(name), returning a *fs.PathError with Err set to
41+
// fs.ErrInvalid or fs.ErrNotExist.
42+
func (f IPFS) Open(name string) (fs.File, error) {
43+
path, node, err := f.Resolve(f.Ctx, name)
44+
if err != nil {
45+
return nil, &fs.PathError{
46+
Op: "open",
47+
Path: name,
48+
Err: err,
49+
}
50+
}
51+
52+
return &ipfsNode{
53+
Path: path,
54+
Node: node,
55+
}, nil
56+
}
57+
58+
func (f IPFS) Resolve(ctx context.Context, name string) (path.Path, files.Node, error) {
59+
if pathInvalid(name) {
60+
return nil, nil, fs.ErrInvalid
61+
}
62+
63+
p, err := path.Join(f.Root, name)
64+
if err != nil {
65+
return nil, nil, err
66+
}
67+
68+
node, err := f.Unix.Get(ctx, p)
69+
return p, node, err
70+
}
71+
72+
func pathInvalid(name string) bool {
73+
return !fs.ValidPath(name)
74+
}
75+
76+
func (f IPFS) Sub(dir string) (fs.FS, error) {
77+
var root path.Path
78+
var err error
79+
if (f == IPFS{}) {
80+
root, err = path.NewPath(dir)
81+
} else {
82+
root, err = path.Join(f.Root, dir)
83+
}
84+
85+
return &IPFS{
86+
Ctx: f.Ctx,
87+
Root: root,
88+
Unix: f.Unix,
89+
}, err
90+
}
91+
92+
var (
93+
_ fs.FileInfo = (*ipfsNode)(nil)
94+
_ fs.ReadDirFile = (*ipfsNode)(nil)
95+
_ fs.DirEntry = (*ipfsNode)(nil)
96+
)
97+
98+
// ipfsNode provides access to a single file. The fs.File interface is the minimum
99+
// implementation required of the file. Directory files should also implement [ReadDirFile].
100+
// A file may implement io.ReaderAt or io.Seeker as optimizations.
101+
type ipfsNode struct {
102+
Path path.Path
103+
files.Node
104+
}
105+
106+
// base name of the file
107+
func (n ipfsNode) Name() string {
108+
segs := n.Path.Segments()
109+
return segs[len(segs)-1] // last segment is name
110+
}
111+
112+
func (n *ipfsNode) Stat() (fs.FileInfo, error) {
113+
return n, nil
114+
}
115+
116+
// length in bytes for regular files; system-dependent for others
117+
func (n ipfsNode) Size() int64 {
118+
size, err := n.Node.Size()
119+
if err != nil {
120+
slog.Error("failed to obtain file size",
121+
"path", n.Path,
122+
"reason", err)
123+
}
124+
125+
return size
126+
}
127+
128+
// file mode bits
129+
func (n ipfsNode) Mode() fs.FileMode {
130+
switch n.Node.(type) {
131+
case files.Directory:
132+
return fs.ModeDir
133+
default:
134+
return 0 // regular read-only file
135+
}
136+
}
137+
138+
// modification time
139+
func (n ipfsNode) ModTime() time.Time {
140+
return time.Time{} // zero-value time
141+
}
142+
143+
// abbreviation for Mode().IsDir()
144+
func (n ipfsNode) IsDir() bool {
145+
return n.Mode().IsDir()
146+
}
147+
148+
// underlying data source (never returns nil)
149+
func (n ipfsNode) Sys() any {
150+
return n.Node
151+
}
152+
153+
func (n ipfsNode) Read(b []byte) (int, error) {
154+
switch node := n.Node.(type) {
155+
case io.Reader:
156+
return node.Read(b)
157+
default:
158+
return 0, errors.New("unreadable node")
159+
}
160+
}
161+
162+
// ReadDir reads the contents of the directory and returns
163+
// a slice of up to max DirEntry values in directory order.
164+
// Subsequent calls on the same file will yield further DirEntry values.
165+
//
166+
// If max > 0, ReadDir returns at most max DirEntry structures.
167+
// In this case, if ReadDir returns an empty slice, it will return
168+
// a non-nil error explaining why.
169+
// At the end of a directory, the error is io.EOF.
170+
// (ReadDir must return io.EOF itself, not an error wrapping io.EOF.)
171+
//
172+
// If max <= 0, ReadDir returns all the DirEntry values from the directory
173+
// in a single slice. In this case, if ReadDir succeeds (reads all the way
174+
// to the end of the directory), it returns the slice and a nil error.
175+
// If it encounters an error before the end of the directory,
176+
// ReadDir returns the DirEntry list read until that point and a non-nil error.
177+
func (n ipfsNode) ReadDir(max int) (entries []fs.DirEntry, err error) {
178+
root, ok := n.Node.(files.Directory)
179+
if !ok {
180+
return nil, errors.New("not a directory")
181+
}
182+
183+
iter := root.Entries()
184+
for iter.Next() {
185+
name := iter.Name()
186+
node := iter.Node()
187+
188+
// Callers will typically discard entries if they get a non-nill
189+
// error, so we make sure nodes are eventually closed.
190+
runtime.SetFinalizer(node, func(c io.Closer) {
191+
if err := c.Close(); err != nil {
192+
slog.Warn("unable to close node",
193+
"name", name,
194+
"reason", err)
195+
}
196+
})
197+
198+
var subpath path.Path
199+
if subpath, err = path.Join(n.Path, name); err != nil {
200+
return
201+
}
202+
203+
entries = append(entries, &ipfsNode{
204+
Path: subpath,
205+
Node: node})
206+
207+
// got max items?
208+
if max--; max == 0 {
209+
return
210+
}
211+
}
212+
213+
// If we get here, it's because the iterator stopped. It either
214+
// failed or is exhausted. Any other error has already caused us
215+
// to return.
216+
if iter.Err() != nil {
217+
err = iter.Err() // failed
218+
} else if max >= 0 {
219+
err = io.EOF // exhausted
220+
}
221+
222+
return
223+
}
224+
225+
// Info returns the FileInfo for the file or subdirectory described by the entry.
226+
// The returned FileInfo may be from the time of the original directory read
227+
// or from the time of the call to Info. If the file has been removed or renamed
228+
// since the directory read, Info may return an error satisfying errors.Is(err, ErrNotExist).
229+
// If the entry denotes a symbolic link, Info reports the information about the link itself,
230+
// not the link's target.
231+
func (n *ipfsNode) Info() (fs.FileInfo, error) {
232+
return n, nil
233+
}
234+
235+
// Type returns the type bits for the entry.
236+
// The type bits are a subset of the usual FileMode bits, those returned by the FileMode.Type method.
237+
func (n ipfsNode) Type() fs.FileMode {
238+
if n.Mode().IsDir() {
239+
return fs.ModeDir
240+
}
241+
242+
return 0
243+
}
244+
245+
func (n ipfsNode) Write(b []byte) (int, error) {
246+
dst, ok := n.Node.(io.Writer)
247+
if ok {
248+
return dst.Write(b)
249+
}
250+
251+
return 0, errors.New("not writeable")
252+
}

system/ipfs_test.go

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
package system_test
2+
3+
import (
4+
"context"
5+
"io/fs"
6+
"testing"
7+
"testing/fstest"
8+
9+
"github.com/ipfs/boxo/path"
10+
"github.com/ipfs/kubo/client/rpc"
11+
"github.com/stretchr/testify/require"
12+
"github.com/wetware/go/system"
13+
)
14+
15+
const IPFS_ROOT = "/ipfs/QmRecDLNaESeNY3oUFYZKK9ftdANBB8kuLaMdAXMD43yon" // go/system/testdata/fs
16+
17+
// TestIPFS_Env verifies that an IPFS node is available in the
18+
// host environment, and that it exports IPFS_ROOT. It then
19+
// checks that IPFS_ROOT contains the expected directory structure.
20+
//
21+
// Other tests in this file will likely fail if TestIPFS_Env fails.
22+
func TestIPFS_Env(t *testing.T) {
23+
t.Parallel()
24+
25+
root, err := path.NewPath(IPFS_ROOT)
26+
require.NoError(t, err)
27+
28+
ipfs, err := rpc.NewLocalApi()
29+
require.NoError(t, err)
30+
31+
dir, err := ipfs.Unixfs().Ls(context.Background(), root)
32+
require.NoError(t, err)
33+
34+
var names []string
35+
for entry := range dir {
36+
names = append(names, entry.Name)
37+
}
38+
39+
expect := []string{"testdata", "main.go", "main.wasm"}
40+
require.ElementsMatch(t, names, expect,
41+
"unexpected file path")
42+
}
43+
44+
func TestIPFS_FS(t *testing.T) {
45+
t.Parallel()
46+
47+
root, err := path.NewPath(IPFS_ROOT)
48+
require.NoError(t, err)
49+
50+
ipfs, err := rpc.NewLocalApi()
51+
require.NoError(t, err)
52+
53+
fs := system.IPFS{Ctx: context.Background(), Unix: ipfs.Unixfs(), Root: root}
54+
err = fstest.TestFS(fs,
55+
"main.go",
56+
"main.wasm",
57+
"testdata")
58+
require.NoError(t, err)
59+
}
60+
61+
// TestIPFS_SubFS ensures that the filesystem retunred by fs.Sub correctly
62+
// handles the '.' path. The returned filesystem MUST ensure that '.'
63+
// resolves to the root IPFS path.
64+
func TestIPFS_SubFS(t *testing.T) {
65+
t.Parallel()
66+
67+
root, err := path.NewPath("/ipfs/QmSAyttKvYkSCBTghuMxAJaBZC3jD2XLRCQ5FB3CTrb9rE") // go/system/testdata
68+
require.NoError(t, err)
69+
70+
ipfs, err := rpc.NewLocalApi()
71+
require.NoError(t, err)
72+
73+
fs, err := fs.Sub(system.IPFS{Ctx: context.Background(), Unix: ipfs.Unixfs(), Root: root}, "fs")
74+
require.NoError(t, err)
75+
require.NotNil(t, fs)
76+
77+
err = fstest.TestFS(fs,
78+
"main.go",
79+
"main.wasm",
80+
"testdata")
81+
require.NoError(t, err)
82+
}

0 commit comments

Comments
 (0)