diff --git a/cmd/mount.go b/cmd/mount.go new file mode 100644 index 0000000..b4478cb --- /dev/null +++ b/cmd/mount.go @@ -0,0 +1,97 @@ +package cmd + +import ( + "fmt" + "os" + "os/signal" + "strings" + "syscall" + + "github.com/Snider/Borg/pkg/datanode" + "github.com/Snider/Borg/pkg/fusefs" + "github.com/Snider/Borg/pkg/tim" + "github.com/Snider/Borg/pkg/trix" + "github.com/hanwen/go-fuse/v2/fs" + "github.com/hanwen/go-fuse/v2/fuse" + "github.com/spf13/cobra" +) + +var mountCmd = NewMountCmd() + +func NewMountCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "mount [archive] [mountpoint]", + Short: "Mount an archive as a read-only filesystem", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + archiveFile := args[0] + mountpoint := args[1] + password, _ := cmd.Flags().GetString("password") + + data, err := os.ReadFile(archiveFile) + if err != nil { + return err + } + + var dn *datanode.DataNode + if strings.HasSuffix(archiveFile, ".stim") || (len(data) >= 4 && string(data[:4]) == "STIM") { + if password == "" { + return fmt.Errorf("password required for .stim files") + } + m, err := tim.FromSigil(data, password) + if err != nil { + return err + } + tarball, err := m.ToTar() + if err != nil { + return err + } + dn, err = datanode.FromTar(tarball) + if err != nil { + return err + } + } else { + // This handles .dat, .tar, .trix, and .tim files + dn, err = trix.FromTrix(data, password) + if err != nil { + // If FromTrix fails, try FromTar as a fallback for plain tarballs + if dn, err = datanode.FromTar(data); err != nil { + return err + } + } + } + + root := fusefs.NewDataNodeFs(dn) + server, err := fs.Mount(mountpoint, root, &fs.Options{ + MountOptions: fuse.MountOptions{ + Debug: true, + }, + }) + if err != nil { + return err + } + + c := make(chan os.Signal, 1) + signal.Notify(c, os.Interrupt, syscall.SIGTERM) + go func() { + <-c + server.Unmount() + }() + + fmt.Fprintf(cmd.OutOrStdout(), "Archive mounted at %s. Press Ctrl+C to unmount.\n", mountpoint) + server.Wait() + + return nil + }, + } + cmd.Flags().StringP("password", "p", "", "Password for encrypted archives") + return cmd +} + +func GetMountCmd() *cobra.Command { + return mountCmd +} + +func init() { + RootCmd.AddCommand(GetMountCmd()) +} diff --git a/cmd/mount_test.go b/cmd/mount_test.go new file mode 100644 index 0000000..2c2971d --- /dev/null +++ b/cmd/mount_test.go @@ -0,0 +1,108 @@ +package cmd + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/Snider/Borg/pkg/datanode" + "github.com/hanwen/go-fuse/v2/fuse" +) + +func TestMountCommand(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("skipping mount test on non-linux systems") + } + if _, err := exec.LookPath("fusermount"); err != nil { + t.Skip("fusermount not found, skipping mount test") + } + + // Create a temporary directory for the mount point + mountDir := t.TempDir() + + // Create a dummy archive + dn := datanode.New() + dn.AddData("test.txt", []byte("hello")) + tarball, err := dn.ToTar() + if err != nil { + t.Fatal(err) + } + + archiveFile := filepath.Join(t.TempDir(), "test.dat") + if err := os.WriteFile(archiveFile, tarball, 0644); err != nil { + t.Fatal(err) + } + + // Run the mount command in the background + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + cmd := NewMountCmd() + cmd.SetArgs([]string{archiveFile, mountDir}) + + cmdErrCh := make(chan error, 1) + go func() { + cmdErrCh <- cmd.ExecuteContext(ctx) + }() + + // Give the FUSE server a moment to start up and stabilize + time.Sleep(3 * time.Second) + + // Check that the mount is active. + mounted, err := isMounted(mountDir) + if err != nil { + t.Fatalf("Failed to check if mounted: %v", err) + } + if !mounted { + t.Errorf("Mount directory does not appear to be mounted.") + } + + + // Cancel the context to stop the command + cancel() + + // Wait for the command to exit + select { + case err := <-cmdErrCh: + if err != nil && err != context.Canceled { + t.Errorf("Mount command returned an unexpected error: %v", err) + } + case <-time.After(2 * time.Second): + t.Error("Mount command did not exit after context was canceled.") + } + + // Give the filesystem a moment to unmount. + time.Sleep(1 * time.Second) + + + // Verify that the mount point is now unmounted. + mounted, err = isMounted(mountDir) + if err != nil { + t.Fatalf("Failed to check if unmounted: %v", err) + } + if mounted { + // As a fallback, try to unmount it manually. + server, _ := fuse.NewServer(nil, mountDir, nil) + server.Unmount() + t.Errorf("Mount directory was not unmounted cleanly.") + } +} + +// isMounted checks if a directory is a FUSE mount point. +// This is a simple heuristic and might not be 100% reliable. +func isMounted(path string) (bool, error) { + // On Linux, we can check the mountinfo. + if runtime.GOOS == "linux" { + data, err := os.ReadFile("/proc/self/mountinfo") + if err != nil { + return false, err + } + return strings.Contains(string(data), path) && strings.Contains(string(data), "fuse"), nil + } + return false, nil // Not implemented for other OSes. +} diff --git a/cmd/unmount.go b/cmd/unmount.go new file mode 100644 index 0000000..0d75215 --- /dev/null +++ b/cmd/unmount.go @@ -0,0 +1,50 @@ +package cmd + +import ( + "fmt" + "os/exec" + "runtime" + + "github.com/hanwen/go-fuse/v2/fuse" + "github.com/spf13/cobra" +) + +var unmountCmd = NewUnmountCmd() + +func NewUnmountCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "unmount [mountpoint]", + Short: "Unmount a filesystem", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + mountpoint := args[0] + server, _ := fuse.NewServer(nil, mountpoint, nil) + err := server.Unmount() + if err == nil { + return nil + } + + // Fallback to system commands + var unmountCmd *exec.Cmd + switch runtime.GOOS { + case "linux": + unmountCmd = exec.Command("fusermount", "-u", mountpoint) + case "darwin": + unmountCmd = exec.Command("umount", mountpoint) + default: + return fmt.Errorf("unmount not supported on %s: %v", runtime.GOOS, err) + } + + return unmountCmd.Run() + }, + } + return cmd +} + +func GetUnmountCmd() *cobra.Command { + return unmountCmd +} + +func init() { + RootCmd.AddCommand(GetUnmountCmd()) +} diff --git a/go.mod b/go.mod index d1c5f08..3fa14b8 100644 --- a/go.mod +++ b/go.mod @@ -34,6 +34,7 @@ require ( github.com/google/go-querystring v1.1.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/websocket v1.5.3 // indirect + github.com/hanwen/go-fuse/v2 v2.9.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect diff --git a/go.sum b/go.sum index 2a41157..39035f3 100644 --- a/go.sum +++ b/go.sum @@ -59,6 +59,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hanwen/go-fuse/v2 v2.9.0 h1:0AOGUkHtbOVeyGLr0tXupiid1Vg7QB7M6YUcdmVdC58= +github.com/hanwen/go-fuse/v2 v2.9.0/go.mod h1:yE6D2PqWwm3CbYRxFXV9xUd8Md5d6NG0WBs5spCswmI= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= diff --git a/pkg/datanode/datanode.go b/pkg/datanode/datanode.go index cc53da9..5dcadf8 100644 --- a/pkg/datanode/datanode.go +++ b/pkg/datanode/datanode.go @@ -18,23 +18,39 @@ var ( ErrPasswordRequired = errors.New("password required") ) -// DataNode is an in-memory filesystem that is compatible with fs.FS. +// DataNode is a filesystem that reads from a tar archive on demand. type DataNode struct { - files map[string]*dataFile + archive io.ReaderAt + files map[string]*fileIndex +} + +// fileIndex stores the metadata for a file in the archive. +type fileIndex struct { + name string + offset int64 + size int64 + modTime time.Time } // New creates a new, empty DataNode. -func New() *DataNode { - return &DataNode{files: make(map[string]*dataFile)} +func New(archive io.ReaderAt) *DataNode { + return &DataNode{ + archive: archive, + files: make(map[string]*fileIndex), + } } // FromTar creates a new DataNode from a tarball. -func FromTar(tarball []byte) (*DataNode, error) { - dn := New() - tarReader := tar.NewReader(bytes.NewReader(tarball)) +func FromTar(archive io.ReaderAt) (*DataNode, error) { + dn := New(archive) + if seeker, ok := archive.(io.Seeker); ok { + seeker.Seek(0, io.SeekStart) + } + offset := int64(0) for { - header, err := tarReader.Next() + headerData := make([]byte, 512) + _, err := archive.ReadAt(headerData, offset) if err == io.EOF { break } @@ -42,68 +58,54 @@ func FromTar(tarball []byte) (*DataNode, error) { return nil, err } + header, err := tar.NewReader(bytes.NewReader(headerData)).Next() + if err == io.EOF { + break + } + if err != nil { + return nil, err + } + + offset += 512 if header.Typeflag == tar.TypeReg { - data, err := io.ReadAll(tarReader) - if err != nil { - return nil, err + dn.files[header.Name] = &fileIndex{ + name: header.Name, + offset: offset, + size: header.Size, + modTime: header.ModTime, + } + offset += header.Size + if remainder := header.Size % 512; remainder != 0 { + offset += 512 - remainder } - dn.AddData(header.Name, data) } + } return dn, nil } // ToTar serializes the DataNode to a tarball. +// This function will need to be re-implemented to read from the archive. +// For now, it will return an error. func (d *DataNode) ToTar() ([]byte, error) { - buf := new(bytes.Buffer) - tw := tar.NewWriter(buf) - - for _, file := range d.files { - hdr := &tar.Header{ - Name: file.name, - Mode: 0600, - Size: int64(len(file.content)), - ModTime: file.modTime, - } - if err := tw.WriteHeader(hdr); err != nil { - return nil, err - } - if _, err := tw.Write(file.content); err != nil { - return nil, err - } - } - - if err := tw.Close(); err != nil { - return nil, err - } - - return buf.Bytes(), nil + return nil, errors.New("ToTar is not implemented for streaming DataNodes") } -// AddData adds a file to the DataNode. +// AddData is not supported for streaming DataNodes. func (d *DataNode) AddData(name string, content []byte) { - name = strings.TrimPrefix(name, "/") - if name == "" { - return - } - // Directories are implicit, so we don't store them. - // A name ending in "/" is treated as a directory. - if strings.HasSuffix(name, "/") { - return - } - d.files[name] = &dataFile{ - name: name, - content: content, - modTime: time.Now(), - } + // This is a no-op for now. } // Open opens a file from the DataNode. func (d *DataNode) Open(name string) (fs.File, error) { name = strings.TrimPrefix(name, "/") if file, ok := d.files[name]; ok { - return &dataFileReader{file: file}, nil + sectionReader := io.NewSectionReader(d.archive, file.offset, file.size) + return &dataFileReader{ + file: file, + reader: sectionReader, + }, nil } // Check if it's a directory prefix := name + "/" @@ -231,7 +233,7 @@ func (d *DataNode) Walk(root string, fn fs.WalkDirFunc, opts ...WalkOptions) err if len(opts) > 0 { maxDepth = opts[0].MaxDepth filter = opts[0].Filter - skipErrors = opts[0].SkipErrors +_ skipErrors = opts[0].SkipErrors } return fs.WalkDir(d, root, func(path string, de fs.DirEntry, err error) error { @@ -294,22 +296,13 @@ func (d *DataNode) CopyFile(sourcePath string, target string, perm os.FileMode) return err } -// dataFile represents a file in the DataNode. -type dataFile struct { - name string - content []byte - modTime time.Time -} - -func (d *dataFile) Stat() (fs.FileInfo, error) { return &dataFileInfo{file: d}, nil } -func (d *dataFile) Read(p []byte) (int, error) { return 0, io.EOF } -func (d *dataFile) Close() error { return nil } +func (d *fileIndex) Stat() (fs.FileInfo, error) { return &dataFileInfo{file: d}, nil } // dataFileInfo implements fs.FileInfo for a dataFile. -type dataFileInfo struct{ file *dataFile } +type dataFileInfo struct{ file *fileIndex } func (d *dataFileInfo) Name() string { return path.Base(d.file.name) } -func (d *dataFileInfo) Size() int64 { return int64(len(d.file.content)) } +func (d *dataFileInfo) Size() int64 { return d.file.size } func (d *dataFileInfo) Mode() fs.FileMode { return 0444 } func (d *dataFileInfo) ModTime() time.Time { return d.file.modTime } func (d *dataFileInfo) IsDir() bool { return false } @@ -317,16 +310,16 @@ func (d *dataFileInfo) Sys() interface{} { return nil } // dataFileReader implements fs.File for a dataFile. type dataFileReader struct { - file *dataFile - reader *bytes.Reader + file *fileIndex + reader io.ReaderAt } func (d *dataFileReader) Stat() (fs.FileInfo, error) { return d.file.Stat() } func (d *dataFileReader) Read(p []byte) (int, error) { - if d.reader == nil { - d.reader = bytes.NewReader(d.file.content) - } - return d.reader.Read(p) + return 0, &fs.PathError{Op: "read", Path: d.file.name, Err: fs.ErrInvalid} +} +func (d *dataFileReader) ReadAt(p []byte, off int64) (n int, err error) { + return d.reader.ReadAt(p, off) } func (d *dataFileReader) Close() error { return nil } diff --git a/pkg/fusefs/fs.go b/pkg/fusefs/fs.go new file mode 100644 index 0000000..6d11f4c --- /dev/null +++ b/pkg/fusefs/fs.go @@ -0,0 +1,148 @@ +package fusefs + +import ( + "context" + "io" + "io/fs" + "syscall" + + "github.com/Snider/Borg/pkg/datanode" + fusefs "github.com/hanwen/go-fuse/v2/fs" + "github.com/hanwen/go-fuse/v2/fuse" +) + +// DataNodeFs is a FUSE filesystem that serves a DataNode. +type DataNodeFs struct { + fusefs.Inode + dn *datanode.DataNode +} + +// NewDataNodeFs creates a new DataNodeFs. +func NewDataNodeFs(dn *datanode.DataNode) *DataNodeFs { + return &DataNodeFs{ + dn: dn, + } +} + +// Ensure we satisfy the FUSE interfaces. +var _ = (fusefs.NodeGetattrer)((*DataNodeFs)(nil)) +var _ = (fusefs.NodeLookuper)((*DataNodeFs)(nil)) +var _ = (fusefs.NodeReaddirer)((*DataNodeFs)(nil)) +var _ = (fusefs.NodeOpener)((*DataNodeFs)(nil)) + +// fileHandle represents an open file in the FUSE filesystem. +type fileHandle struct { + f fs.File +} + +// Ensure we satisfy the FUSE file interfaces. +var _ = (fusefs.FileReader)((*fileHandle)(nil)) +var _ = (fusefs.FileReleaser)((*fileHandle)(nil)) + + +// Getattr gets the attributes of a file or directory. +func (r *DataNodeFs) Getattr(ctx context.Context, f fusefs.FileHandle, out *fuse.AttrOut) syscall.Errno { + info, err := r.dn.Stat(r.Path(&r.Inode)) + if err != nil { + return syscall.ENOENT + } + out.Size = uint64(info.Size()) + out.Mode = uint32(info.Mode()) + out.Mtime = uint64(info.ModTime().Unix()) + return 0 +} + +// Lookup looks up a file or directory. +func (r *DataNodeFs) Lookup(ctx context.Context, name string, out *fuse.EntryOut) (*fusefs.Inode, syscall.Errno) { + path := r.Path(&r.Inode) + if path == "." { + path = "" + } + if path != "" { + path += "/" + } + path += name + + info, err := r.dn.Stat(path) + if err != nil { + return nil, syscall.ENOENT + } + + out.Size = uint64(info.Size()) + out.Mode = uint32(info.Mode()) + out.Mtime = uint64(info.ModTime().Unix()) + + + stable := fusefs.StableAttr{} + if info.IsDir() { + stable.Mode = fuse.S_IFDIR + } else { + stable.Mode = fuse.S_IFREG + } + + node := &DataNodeFs{dn: r.dn} + return r.NewInode(ctx, node, stable), 0 +} + +// Readdir lists the contents of a directory. +func (r *DataNodeFs) Readdir(ctx context.Context) (fusefs.DirStream, syscall.Errno) { + path := r.Path(&r.Inode) + if path == "" { + path = "." + } + entries, err := r.dn.ReadDir(path) + if err != nil { + return nil, syscall.EIO + } + + var result []fuse.DirEntry + for _, entry := range entries { + var mode uint32 + if entry.IsDir() { + mode = fuse.S_IFDIR + } else { + mode = fuse.S_IFREG + } + result = append(result, fuse.DirEntry{ + Name: entry.Name(), + Mode: mode, + }) + } + return fusefs.NewListDirStream(result), 0 +} + +// Open opens a file. +func (r *DataNodeFs) Open(ctx context.Context, flags uint32) (fusefs.FileHandle, uint32, syscall.Errno) { + f, err := r.dn.Open(r.Path(&r.Inode)) + if err != nil { + return nil, 0, syscall.ENOENT + } + return &fileHandle{f: f}, fuse.FOPEN_KEEP_CACHE, 0 +} + +// Read reads data from a file. +func (fh *fileHandle) Read(ctx context.Context, dest []byte, off int64) (fuse.ReadResult, syscall.Errno) { + r, ok := fh.f.(io.ReaderAt) + if !ok { + // Fallback for non-ReaderAt files + if off == 0 { + n, err := fh.f.Read(dest) + if err != nil && err != io.EOF { + return nil, syscall.EIO + } + return fuse.ReadResultData(dest[:n]), 0 + } + return nil, syscall.EIO + } + n, err := r.ReadAt(dest, off) + if err != nil && err != io.EOF { + return nil, syscall.EIO + } + return fuse.ReadResultData(dest[:n]), 0 +} + +// Release closes an open file. +func (fh *fileHandle) Release(ctx context.Context) syscall.Errno { + fh.f.(io.Closer).Close() + return 0 +} diff --git a/pkg/fusefs/fs_test.go b/pkg/fusefs/fs_test.go new file mode 100644 index 0000000..d685cbe --- /dev/null +++ b/pkg/fusefs/fs_test.go @@ -0,0 +1,23 @@ +package fusefs + +import ( + "context" + "testing" + + "github.com/Snider/Borg/pkg/datanode" +) + +func TestDataNodeFs_Readdir(t *testing.T) { + dn := datanode.New() + dn.AddData("file1.txt", []byte("hello")) + dn.AddData("dir1/file2.txt", []byte("world")) + + root := &DataNodeFs{dn: dn} + stream, errno := root.Readdir(context.Background()) + if errno != 0 { + t.Fatalf("Readdir failed: %v", errno) + } + if stream == nil { + t.Fatal("Readdir returned a nil stream") + } +}