From 8edddc5a28b23042b980fd070445ffc41ecc7f6d Mon Sep 17 00:00:00 2001 From: nevaforget Date: Mon, 4 May 2026 09:25:55 +0200 Subject: [PATCH] feat: add -l (list) and -u (unmount) flags A second sshfsc call only printed "Already mounted"; tearing down a mount required ls + fusermount by hand. -l lists active mounts verified via mountinfo.Mounted, -u unmounts and removes the empty mountpoint dir. Flags are mutually exclusive. --- DECISIONS.md | 43 +++++++++++++++++ README.md | 6 ++- main.go | 131 ++++++++++++++++++++++++++++++++++++++++++++++++--- main_test.go | 62 ++++++++++++++++++++++++ 4 files changed, 234 insertions(+), 8 deletions(-) diff --git a/DECISIONS.md b/DECISIONS.md index 397a524..f6541aa 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1,5 +1,48 @@ # Decisions +## 2026-05-04 – Add `-l` (list) and `-u` (unmount) flags +- **Who**: Dom, ClaudeCode +- **Why**: Mounts under `$XDG_RUNTIME_DIR/sshfs/` had to be listed and + torn down with `ls` and `fusermount -u` by hand. A second `sshfsc ` + call only prints `!!! Already mounted` and exits, so the tool gave no first- + class way to undo what it set up. `-l` and `-u` close that gap without + changing the default mount flow. +- **Tradeoffs**: + - Flag-based UX over subcommands keeps the existing `-e`/`-v`/`-r` style. + `-l` and `-u` are mutually exclusive (exit 2 when both passed). `-u` reuses + the positional `` argument; no separate value flag needed. + - `-l` only lists *real* mounts (verified via `mountinfo.Mounted`). Stale + empty dirs left behind by failed mounts are silently filtered, not + surfaced — keeps the output a true list of teardownable targets. Cleanup + of stale dirs is not implemented; tmpfs reclaims them on logout. + - `-u` shells out to `fusermount -u` and best-effort `os.Remove`s the empty + mount dir afterwards. A failed `Remove` warns but does not fail the + command — the unmount itself already succeeded. + - `mount_base(create bool)` returns `fs.ErrNotExist` when `create=false` and + the base is missing. `list_mounts` swallows that into "no mounts"; + `unmount_sshfs` translates it into `not mounted: ` so the caller + sees a single coherent error regardless of whether the alias-dir, the + base, or `XDG_RUNTIME_DIR` was missing. +- **How**: + - `verify_mount_dir` split into `mount_base(create bool)` (XDG resolve + + optional mkdir + EvalSymlinks) and `mount_path(base, name)` (join + + traversal + symlink guard, no mkdir). The original `verify_mount_dir` + becomes a thin wrapper that composes both and creates the mountpoint — + only the mount path needs that mkdir. + - `list_mounts(io.Writer)` reads the base dir, runs `mountinfo.Mounted` per + entry, prints alias names that pass. Takes a writer so tests can capture + output without stdout redirection. + - `unmount_sshfs(alias)` validates against `rxHostUser`, resolves the + mountpoint, checks `mountinfo.Mounted`, then `exec.Command("fusermount", + "-u", mount).Run()` and `os.Remove(mount)`. + - `main()` flow: after `flag.Parse()`, branch on `*lFlag` / `*uFlag` before + the existing mount path. Mutex check up front. Exit codes 8 (list) and 9 + (unmount) extend the existing 2..7 range. + - Tests: `TestListMountsMissingBase`, `TestListMountsFiltersUnmounted`, + `TestUnmountRejectsBadAlias`, `TestUnmountNotMountedMissingBase`, + `TestUnmountNotMountedExistingDir`. The real `fusermount` path is not + exercised in unit tests — the not-mounted check returns before the exec. + ## 2026-04-28 – Move mount base from `~/Servers/` to `$XDG_RUNTIME_DIR/sshfs/` - **Who**: Dom, ClaudeCode - **Why**: `~/Servers/` is non-standard. XDG Base Directory spec defines diff --git a/README.md b/README.md index a4e0269..b5d97d0 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,9 @@ install -Dm755 sshfsc /usr/local/bin/sshfsc # Usage ``` -sshfsc +sshfsc # mount +sshfsc -u # unmount +sshfsc -l # list active mounts ``` ## Arguments @@ -37,6 +39,8 @@ sshfsc | `-e` | open mountpoint in your editor | | `-v` | verbose: print resolved ssh_config fields (HostName, User, Port, IdentityFile) | | `-r`, `--remote-dir ` | remote directory to mount (default: remote home) | +| `-l` | list active mounts under `$XDG_RUNTIME_DIR/sshfs/` and exit | +| `-u` | unmount the given `` and exit (mutually exclusive with `-l`) | By default only the resolved mount path is printed. Use `-v` for the full ssh_config dump. diff --git a/main.go b/main.go index 9a77466..70a396c 100644 --- a/main.go +++ b/main.go @@ -4,8 +4,11 @@ package main import ( + "errors" "flag" "fmt" + "io" + "io/fs" "os" "os/exec" "path/filepath" @@ -26,6 +29,8 @@ var ( var ( eFlag = flag.Bool("e", false, "open mountpoint in your editor") vFlag = flag.Bool("v", false, "verbose: print resolved ssh_config fields") + lFlag = flag.Bool("l", false, "list active mounts and exit") + uFlag = flag.Bool("u", false, "unmount the given and exit") rDir string ) @@ -38,13 +43,39 @@ func main() { flag.Usage = func() { fmt.Fprintf(os.Stderr, "usage: sshfsc [flags] \n\n"+ - "Mount a remote home directory via sshfs based on ~/.ssh/config entries.\n\n"+ + "Mount a remote home directory via sshfs based on ~/.ssh/config entries.\n"+ + "Use -l to list active mounts; -u to unmount.\n\n"+ "Flags:\n") flag.PrintDefaults() } flag.Parse() args := flag.Args() + if *lFlag && *uFlag { + fmt.Fprintln(os.Stderr, "-l and -u are mutually exclusive") + os.Exit(2) + } + + if *lFlag { + if err := list_mounts(os.Stdout); err != nil { + fmt.Fprintln(os.Stderr, "list_mounts() failed:", err) + os.Exit(8) + } + return + } + + if *uFlag { + if len(args) == 0 { + fmt.Fprintln(os.Stderr, "-u requires argument") + os.Exit(2) + } + if err := unmount_sshfs(args[0]); err != nil { + fmt.Fprintln(os.Stderr, "unmount_sshfs() failed:", err) + os.Exit(9) + } + return + } + if len(args) == 0 { fmt.Fprintln(os.Stderr, "No hostname specified.") os.Exit(2) @@ -182,32 +213,118 @@ func run_editor(mount string) error { return cmd.Start() } -func verify_mount_dir(name string) (string, error) { +// mount_base resolves the per-user sshfs mount base under $XDG_RUNTIME_DIR. +// When create is true, the base directory is mkdir'd; otherwise a missing +// base bubbles up as fs.ErrNotExist so callers can decide how to react. +func mount_base(create bool) (string, error) { runtime := os.Getenv("XDG_RUNTIME_DIR") if runtime == "" { runtime = filepath.Join("/run/user", strconv.Itoa(os.Getuid())) } base := filepath.Join(runtime, "sshfs") - if err := os.MkdirAll(base, 0700); err != nil { - return "", fmt.Errorf("create base %q: %w", base, err) + if create { + if err := os.MkdirAll(base, 0700); err != nil { + return "", fmt.Errorf("create base %q: %w", base, err) + } + } else if _, err := os.Stat(base); err != nil { + return "", err } realBase, err := filepath.EvalSymlinks(base) if err != nil { return "", fmt.Errorf("resolve base %q: %w", base, err) } - mount := filepath.Clean(filepath.Join(realBase, name)) - if !strings.HasPrefix(mount, realBase+string(os.PathSeparator)) { - return "", fmt.Errorf("name %q escapes mount base %q", name, realBase) + return realBase, nil +} + +// mount_path joins base + name with traversal and symlink guards. +// It does not create the mountpoint directory. +func mount_path(base, name string) (string, error) { + mount := filepath.Clean(filepath.Join(base, name)) + if !strings.HasPrefix(mount, base+string(os.PathSeparator)) { + return "", fmt.Errorf("name %q escapes mount base %q", name, base) } if info, err := os.Lstat(mount); err == nil && info.Mode()&os.ModeSymlink != 0 { return "", fmt.Errorf("mount path %q is a symlink", mount) } + return mount, nil +} + +func verify_mount_dir(name string) (string, error) { + base, err := mount_base(true) + if err != nil { + return "", err + } + mount, err := mount_path(base, name) + if err != nil { + return "", err + } if err := os.MkdirAll(mount, 0700); err != nil { return "", fmt.Errorf("create mount dir %q: %w", mount, err) } return mount, nil } +func list_mounts(out io.Writer) error { + base, err := mount_base(false) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil + } + return err + } + entries, err := os.ReadDir(base) + if err != nil { + return fmt.Errorf("read base %q: %w", base, err) + } + for _, entry := range entries { + if !entry.IsDir() { + continue + } + path := filepath.Join(base, entry.Name()) + ok, merr := mountinfo.Mounted(path) + if merr != nil || !ok { + continue + } + fmt.Fprintln(out, entry.Name()) + } + return nil +} + +func unmount_sshfs(alias string) error { + if err := validate_ssh_field("alias", alias, rxHostUser); err != nil { + return err + } + base, err := mount_base(false) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("not mounted: %s", alias) + } + return err + } + mount, err := mount_path(base, alias) + if err != nil { + return err + } + ok, err := mountinfo.Mounted(mount) + if err != nil { + return fmt.Errorf("mountinfo.Mounted(%q): %w", mount, err) + } + if !ok { + return fmt.Errorf("not mounted: %s", alias) + } + cmd := exec.Command("fusermount", "-u", mount) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("fusermount -u %q: %w", mount, err) + } + if err := os.Remove(mount); err != nil { + fmt.Fprintf(os.Stderr, "warning: remove empty mount dir %q: %v\n", mount, err) + } + fmt.Println("Unmounted:", alias) + return nil +} + func mount_sshfs(alias string, user string, ifile string, port string, mount string, remoteDir string) error { cmd := exec.Command("sshfs", "-p", port, "-o", "IdentityFile="+ifile, diff --git a/main_test.go b/main_test.go index b84e986..0b90d1b 100644 --- a/main_test.go +++ b/main_test.go @@ -4,6 +4,7 @@ package main import ( + "bytes" "os" "path/filepath" "strings" @@ -138,3 +139,64 @@ func TestVerifyMountDirRejectsSymlink(t *testing.T) { t.Fatalf("want symlink rejection, got %v", err) } } + +func TestListMountsMissingBase(t *testing.T) { + runtime := t.TempDir() + t.Setenv("XDG_RUNTIME_DIR", runtime) + var buf bytes.Buffer + if err := list_mounts(&buf); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if buf.Len() != 0 { + t.Fatalf("want empty output, got %q", buf.String()) + } +} + +func TestListMountsFiltersUnmounted(t *testing.T) { + runtime := t.TempDir() + t.Setenv("XDG_RUNTIME_DIR", runtime) + base := filepath.Join(runtime, "sshfs") + for _, name := range []string{"stale1", "stale2"} { + if err := os.MkdirAll(filepath.Join(base, name), 0700); err != nil { + t.Fatalf("setup stale dir: %v", err) + } + } + var buf bytes.Buffer + if err := list_mounts(&buf); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if buf.Len() != 0 { + t.Fatalf("want stale dirs filtered, got %q", buf.String()) + } +} + +func TestUnmountRejectsBadAlias(t *testing.T) { + runtime := t.TempDir() + t.Setenv("XDG_RUNTIME_DIR", runtime) + err := unmount_sshfs("../etc") + if err == nil || !strings.Contains(err.Error(), "disallowed characters") { + t.Fatalf("want validation error, got %v", err) + } +} + +func TestUnmountNotMountedMissingBase(t *testing.T) { + runtime := t.TempDir() + t.Setenv("XDG_RUNTIME_DIR", runtime) + err := unmount_sshfs("myhost") + if err == nil || !strings.Contains(err.Error(), "not mounted") { + t.Fatalf("want not-mounted error, got %v", err) + } +} + +func TestUnmountNotMountedExistingDir(t *testing.T) { + runtime := t.TempDir() + t.Setenv("XDG_RUNTIME_DIR", runtime) + base := filepath.Join(runtime, "sshfs") + if err := os.MkdirAll(filepath.Join(base, "myhost"), 0700); err != nil { + t.Fatalf("setup mount dir: %v", err) + } + err := unmount_sshfs("myhost") + if err == nil || !strings.Contains(err.Error(), "not mounted") { + t.Fatalf("want not-mounted error, got %v", err) + } +}