feat: add -l (list) and -u (unmount) flags
Update PKGBUILD version / update-pkgver (push) Successful in 5s
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:
@@ -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/<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/`
|
||||
- **Who**: Dom, ClaudeCode
|
||||
- **Why**: `~/Servers/` is non-standard. XDG Base Directory spec defines
|
||||
|
||||
@@ -27,7 +27,9 @@ install -Dm755 sshfsc /usr/local/bin/sshfsc
|
||||
# Usage
|
||||
|
||||
```
|
||||
sshfsc <Host>
|
||||
sshfsc <Host> # mount
|
||||
sshfsc -u <Host> # unmount
|
||||
sshfsc -l # list active mounts
|
||||
```
|
||||
|
||||
## Arguments
|
||||
@@ -37,6 +39,8 @@ sshfsc <Host>
|
||||
| `-e` | open mountpoint in your editor |
|
||||
| `-v` | verbose: print resolved ssh_config fields (HostName, User, Port, IdentityFile) |
|
||||
| `-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
|
||||
ssh_config dump.
|
||||
|
||||
@@ -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 <Host> and exit")
|
||||
rDir string
|
||||
)
|
||||
|
||||
@@ -38,13 +43,39 @@ func main() {
|
||||
flag.Usage = func() {
|
||||
fmt.Fprintf(os.Stderr,
|
||||
"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")
|
||||
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 <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 {
|
||||
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 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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user