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
|
# 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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user