Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions cmd/mount.go
Original file line number Diff line number Diff line change
@@ -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
}
}
}
Comment on lines +31 to +62

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

This block reads the entire archive into memory with os.ReadFile, which contradicts the goal of supporting large archives via streaming. The logic should be reworked to use os.Open and operate on the resulting io.ReaderAt file stream. Furthermore, there are compilation errors because functions like datanode.FromTar are called with []byte instead of an io.ReaderAt as required by the new API. The entire file processing logic needs to be refactored for streaming.


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 changes: 108 additions & 0 deletions cmd/mount_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
Comment on lines +29 to +34

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

The test setup for creating a dummy archive is based on the old datanode API (New(), AddData(), ToTar()), which has been changed. datanode.New() now requires an io.ReaderAt, AddData() is a no-op, and ToTar() returns an error. This test will not compile. It needs to be updated to create a test tarball and then use the new streaming DataNode API.


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)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using time.Sleep to wait for the FUSE server to start can lead to flaky tests. A more robust approach would be to poll the mount point until it becomes active. The same issue exists on line 80 when waiting for the unmount.


// 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)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

fuse.NewServer returns a single value (*fuse.Server), but the code attempts to assign to two variables. This is a compilation error.

Suggested change
server, _ := fuse.NewServer(nil, mountDir, nil)
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 changes: 50 additions & 0 deletions cmd/unmount.go
Original file line number Diff line number Diff line change
@@ -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)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

fuse.NewServer returns a single value (*fuse.Server), but the code attempts to assign to two variables. This is a compilation error.

Suggested change
server, _ := fuse.NewServer(nil, mountpoint, nil)
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 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
Loading
Loading