This commit is contained in:
Snider 2026-02-12 01:37:03 +01:00 committed by GitHub
commit 00ad1600fc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 494 additions and 72 deletions

97
cmd/mount.go Normal file
View file

@ -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())
}

108
cmd/mount_test.go Normal file
View file

@ -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.
}

50
cmd/unmount.go Normal file
View file

@ -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())
}

1
go.mod
View file

@ -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

2
go.sum
View file

@ -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=

View file

@ -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
}
if header.Typeflag == tar.TypeReg {
data, err := io.ReadAll(tarReader)
if err != nil {
return nil, err
}
dn.AddData(header.Name, data)
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 {
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
}
}
}
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 }

148
pkg/fusefs/fs.go Normal file
View file

@ -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
}

23
pkg/fusefs/fs_test.go Normal file
View file

@ -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")
}
}