feat: add -l (list) and -u (unmount) flags
Update PKGBUILD version / update-pkgver (push) Successful in 5s

A second sshfsc <alias> call only printed "Already mounted"; tearing
down a mount required ls + fusermount by hand. -l lists active mounts
verified via mountinfo.Mounted, -u <Host> unmounts and removes the
empty mountpoint dir. Flags are mutually exclusive.
This commit is contained in:
2026-05-04 09:25:55 +02:00
parent 3f3c631057
commit 8edddc5a28
4 changed files with 234 additions and 8 deletions
+43
View File
@@ -1,5 +1,48 @@
# Decisions # Decisions
## 2026-05-04 Add `-l` (list) and `-u` (unmount) flags
- **Who**: Dom, ClaudeCode
- **Why**: Mounts under `$XDG_RUNTIME_DIR/sshfs/<alias>` had to be listed and
torn down with `ls` and `fusermount -u` by hand. A second `sshfsc <alias>`
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 `<Host>` 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: <alias>` 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/` ## 2026-04-28 Move mount base from `~/Servers/` to `$XDG_RUNTIME_DIR/sshfs/`
- **Who**: Dom, ClaudeCode - **Who**: Dom, ClaudeCode
- **Why**: `~/Servers/` is non-standard. XDG Base Directory spec defines - **Why**: `~/Servers/` is non-standard. XDG Base Directory spec defines
+5 -1
View File
@@ -27,7 +27,9 @@ install -Dm755 sshfsc /usr/local/bin/sshfsc
# Usage # Usage
``` ```
sshfsc <Host> sshfsc <Host> # mount
sshfsc -u <Host> # unmount
sshfsc -l # list active mounts
``` ```
## Arguments ## Arguments
@@ -37,6 +39,8 @@ sshfsc <Host>
| `-e` | open mountpoint in your editor | | `-e` | open mountpoint in your editor |
| `-v` | verbose: print resolved ssh_config fields (HostName, User, Port, IdentityFile) | | `-v` | verbose: print resolved ssh_config fields (HostName, User, Port, IdentityFile) |
| `-r`, `--remote-dir <path>` | remote directory to mount (default: remote home) | | `-r`, `--remote-dir <path>` | remote directory to mount (default: remote home) |
| `-l` | list active mounts under `$XDG_RUNTIME_DIR/sshfs/` and exit |
| `-u` | unmount the given `<Host>` and exit (mutually exclusive with `-l`) |
By default only the resolved mount path is printed. Use `-v` for the full By default only the resolved mount path is printed. Use `-v` for the full
ssh_config dump. ssh_config dump.
+124 -7
View File
@@ -4,8 +4,11 @@
package main package main
import ( import (
"errors"
"flag" "flag"
"fmt" "fmt"
"io"
"io/fs"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
@@ -26,6 +29,8 @@ var (
var ( var (
eFlag = flag.Bool("e", false, "open mountpoint in your editor") eFlag = flag.Bool("e", false, "open mountpoint in your editor")
vFlag = flag.Bool("v", false, "verbose: print resolved ssh_config fields") 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 <Host> and exit")
rDir string rDir string
) )
@@ -38,13 +43,39 @@ func main() {
flag.Usage = func() { flag.Usage = func() {
fmt.Fprintf(os.Stderr, fmt.Fprintf(os.Stderr,
"usage: sshfsc [flags] <Host>\n\n"+ "usage: sshfsc [flags] <Host>\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 <Host> to unmount.\n\n"+
"Flags:\n") "Flags:\n")
flag.PrintDefaults() flag.PrintDefaults()
} }
flag.Parse() flag.Parse()
args := flag.Args() 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 <Host> 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 { if len(args) == 0 {
fmt.Fprintln(os.Stderr, "No hostname specified.") fmt.Fprintln(os.Stderr, "No hostname specified.")
os.Exit(2) os.Exit(2)
@@ -182,32 +213,118 @@ func run_editor(mount string) error {
return cmd.Start() 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") runtime := os.Getenv("XDG_RUNTIME_DIR")
if runtime == "" { if runtime == "" {
runtime = filepath.Join("/run/user", strconv.Itoa(os.Getuid())) runtime = filepath.Join("/run/user", strconv.Itoa(os.Getuid()))
} }
base := filepath.Join(runtime, "sshfs") base := filepath.Join(runtime, "sshfs")
if err := os.MkdirAll(base, 0700); err != nil { if create {
return "", fmt.Errorf("create base %q: %w", base, err) 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) realBase, err := filepath.EvalSymlinks(base)
if err != nil { if err != nil {
return "", fmt.Errorf("resolve base %q: %w", base, err) return "", fmt.Errorf("resolve base %q: %w", base, err)
} }
mount := filepath.Clean(filepath.Join(realBase, name)) return realBase, nil
if !strings.HasPrefix(mount, realBase+string(os.PathSeparator)) { }
return "", fmt.Errorf("name %q escapes mount base %q", name, realBase)
// 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 { if info, err := os.Lstat(mount); err == nil && info.Mode()&os.ModeSymlink != 0 {
return "", fmt.Errorf("mount path %q is a symlink", mount) 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 { if err := os.MkdirAll(mount, 0700); err != nil {
return "", fmt.Errorf("create mount dir %q: %w", mount, err) return "", fmt.Errorf("create mount dir %q: %w", mount, err)
} }
return mount, nil 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 { func mount_sshfs(alias string, user string, ifile string, port string, mount string, remoteDir string) error {
cmd := exec.Command("sshfs", "-p", port, cmd := exec.Command("sshfs", "-p", port,
"-o", "IdentityFile="+ifile, "-o", "IdentityFile="+ifile,
+62
View File
@@ -4,6 +4,7 @@
package main package main
import ( import (
"bytes"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -138,3 +139,64 @@ func TestVerifyMountDirRejectsSymlink(t *testing.T) {
t.Fatalf("want symlink rejection, got %v", err) 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)
}
}