5 Commits

Author SHA1 Message Date
nevaforget 4306170626 refactor: delegate -l output to findmnt
Update PKGBUILD version / update-pkgver (push) Successful in 2s
The custom one-alias-per-line output was useless — no mountpoint, no
source, no options. Reinventing a table format when findmnt from
util-linux already produces a familiar fuse.sshfs view was the wrong
call. -l now shells out to findmnt -t fuse.sshfs.
2026-05-04 10:24:53 +02:00
nevaforget e6a02e5bf7 fix: detect mounts via /proc/self/mountinfo so stale FUSE works
Update PKGBUILD version / update-pkgver (push) Successful in 5s
mountinfo.Mounted lstats the path. When the sshfs link dies, every stat
on the mountpoint returns EIO, so -l filtered the dead mount out, mount
failed in MkdirAll, and -u failed before fusermount. Switch detection to
mountinfo.GetMounts (no stat) and add a fusermount -uz fallback so a
stale mount can actually be torn down.
2026-05-04 10:08:13 +02:00
nevaforget 8edddc5a28 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.
2026-05-04 09:25:55 +02:00
nevaforget 3f3c631057 feat: mount under \$XDG_RUNTIME_DIR/sshfs/ instead of ~/Servers/
Update PKGBUILD version / update-pkgver (push) Successful in 3s
Aligns with XDG Base Directory spec: $XDG_RUNTIME_DIR is the defined
location for non-essential runtime files (ephemeral, user-owned,
session-scoped). sshfs mounts fit that definition exactly, and the
tmpfs backing means orphaned mountpoint dirs vanish on logout instead
of accumulating.

- verify_mount_dir reads $XDG_RUNTIME_DIR, falls back to
  /run/user/<uid>/ via os.Getuid().
- Existing path-traversal guard and symlink rejection carry over
  unchanged.
- Tests switched from t.Setenv("HOME") to t.Setenv("XDG_RUNTIME_DIR").

File-manager sidebar visibility is unaffected — gvfs surfaces FUSE
mounts via /proc/mounts regardless of mountpoint location.
2026-04-28 15:48:54 +02:00
nevaforget afb51f1d61 feat: add --remote-dir flag and use ssh alias as mount label
Update PKGBUILD version / update-pkgver (push) Successful in 3s
- New `-r` / `--remote-dir` flag to mount a specific remote subdirectory;
  empty default preserves prior home-dir behaviour.
- Validate the flag value via a dedicated `rxRemoteDir` allowlist before it
  reaches the sshfs argv.
- Use the ssh_config alias (not the resolved HostName) as the local
  mountpoint name and as the sshfs source. File managers now show the
  human-readable label instead of the raw IP.
- Validate `args[0]` against `rxHostUser` since it now flows into argv.
- Rename `verify_mount_dir` parameter `hostname -> name` and `mount_sshfs`
  first parameter `hostname -> alias` for clarity.
2026-04-28 15:39:17 +02:00
4 changed files with 444 additions and 29 deletions
+189 -1
View File
@@ -1,6 +1,194 @@
# Decisions
## 2026-04-26 Audit remediation: cache flag, host-key policy, argv hardening
## 2026-05-04 Delegate `-l` output to `findmnt -t fuse.sshfs`
- **Who**: Dom, ClaudeCode
- **Why**: The custom `-l` output ("just the alias, one per line") was
uselessly minimal — no mountpoint, no source, no options. Reinventing a
table format for a single command was the wrong call when `findmnt` from
`util-linux` already produces a familiar, well-formatted view of fuse.sshfs
mounts.
- **Tradeoffs**:
- External dependency on `findmnt` (part of `util-linux`, present on every
Arch and most other Linux systems by default — no real adoption cost).
- Output is mountpoint-first, not alias-first. The alias is only visible as
the last path segment under the SOURCE column / TARGET basename.
- Lists *all* fuse.sshfs mounts on the system, not just sshfsc-managed
ones under `$XDG_RUNTIME_DIR/sshfs/`. In practice this is what the user
wants ("show me all sshfs mounts"); a future `-r` (restrict) flag could
narrow it if needed.
- `findmnt` exits 1 when nothing matches its filter — we treat that as
success-with-empty-output, not a failure.
- **How**:
- `list_mounts` rewritten as a thin wrapper around `exec.Command("findmnt",
"-t", "fuse.sshfs")` with stdout piped to the caller's writer.
- Exit-code 1 from `findmnt` is swallowed via `errors.As(err, *exec.ExitError)`
+ `ExitCode() == 1`.
- `errors.Is(err, exec.ErrNotFound)` produces a clear "install util-linux"
message instead of a cryptic exec error.
- Tests `TestListMountsMissingBase` and `TestListMountsFiltersUnmounted`
removed: they covered the old in-Go enumeration logic that no longer
exists. Mocking `findmnt` would be alibi-testing — the wrapper is two
branches over `cmd.Run()`'s error.
## 2026-05-04 Detect mounts via `/proc/self/mountinfo` instead of `stat`
- **Who**: Dom, ClaudeCode
- **Why**: `mountinfo.Mounted(path)` from `github.com/moby/sys/mountinfo` works
by `lstat`-ing the path. When a sshfs connection dies, the FUSE endpoint
stays in `/proc/mounts` but every `stat` on the mountpoint returns EIO.
All three sshfsc paths broke as a result:
- `-l` silently filtered the dead mount out (`Mounted` returned an error,
code skipped on `merr != nil`)
- mount path failed with `mkdir … file exists` because `MkdirAll` stats
before creating
- `-u` failed with `lstat … input/output error` before reaching
`fusermount`
Net effect: a stale mount was a dead end — couldn't be listed, couldn't be
unmounted, blocked re-mount.
- **Tradeoffs**:
- Switched mount detection to `mountinfo.GetMounts(SingleEntryFilter(path))`,
which parses `/proc/self/mountinfo` and never touches the path. Robust to
EIO on the mount target.
- `list_mounts` now uses `PrefixFilter(base)` and trims the base prefix.
Direct children only — nested mounts under an alias dir would be skipped
on purpose; sshfsc only manages one level.
- `verify_mount_dir` skips `MkdirAll` when the path is already a mountpoint.
Re-running `sshfsc <alias>` against a stale mount no longer errors on
mkdir; main's existing `is_mounted_at` check then prints "Already mounted"
and returns. The user can `sshfsc -u <alias>` to recover.
- `unmount_sshfs` falls back to `fusermount -uz` (lazy) when `-u` fails.
Lazy unmount detaches the FS from the tree immediately and lets dangling
refs settle later — exactly what stale FUSE needs. Plain `-u` first
keeps the clean path noise-free.
- **How**:
- New helper `is_mounted_at(path)`; replaces `mountinfo.Mounted` at every
call site (`main`, `verify_mount_dir`, `unmount_sshfs`).
- `list_mounts` rewritten around `PrefixFilter`. Trims `base + "/"` and
skips entries containing further separators.
- `run_fusermount(flag, mount)` extracted to keep `-u` and `-uz` calls
tidy. `unmount_sshfs` retries with `-uz` on first-call error.
- New tests: `TestIsMountedAtFalseOnPlainDir`,
`TestIsMountedAtFalseOnMissingPath`. Existing tests stay green —
`mountinfo.GetMounts` returns empty under the test tmpdirs.
## 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
`$XDG_RUNTIME_DIR` (= `/run/user/<uid>/`) for "non-essential runtime files"
— ephemeral, user-owned, session-bezogen. sshfs mounts fit that definition
exactly. systemd-logind creates the directory at login with mode 0700, so
permissions are correct by construction.
- **Tradeoffs**:
- Filemanager sidebar visibility is unchanged — gvfs surfaces FUSE mounts
via `/proc/mounts` regardless of mountpoint location.
- Tab-completion in `~/` no longer reaches mounts; users targeting the
mount via shell need the explicit `$XDG_RUNTIME_DIR/sshfs/<alias>` path.
Acceptable given Dom's primary access is the filemanager sidebar.
- tmpfs-backed → mounts and their containing dir vanish on logout/reboot.
No more orphaned empty dirs. Tradeoff: bookmarks pinned to the absolute
path stay valid because UID is stable across sessions, but the directory
is gone between logins until the next mount recreates it.
- Fallback to `/run/user/<uid>/` when `$XDG_RUNTIME_DIR` is unset (e.g.
cron, non-login shells without systemd-logind). Falls auf einem System
ohne systemd `/run/user/<uid>/` nicht existiert, schlägt `MkdirAll` mit
klarem Fehler fehl — kein Silent-Fallback auf `~/Servers/`.
- **How**:
- `verify_mount_dir` reads `$XDG_RUNTIME_DIR`, falls back to
`/run/user/<uid>/` via `os.Getuid()` + `strconv.Itoa`.
- Base = `<runtime>/sshfs`, created with `MkdirAll(0700)`.
- Existing path-traversal guard (`EvalSymlinks` + `HasPrefix`) and symlink
rejection (`Lstat`) carry over unchanged.
- Tests switched from `t.Setenv("HOME", ...)` to
`t.Setenv("XDG_RUNTIME_DIR", ...)`.
## 2026-04-28 Use ssh_config alias as label for mountpoint and sshfs source
- **Who**: Dom, ClaudeCode
- **Why**: File managers showed the resolved `HostName` (often a raw IP) for
both the mount source (`user@10.0.0.5:`) and the mountpoint directory
(`~/Servers/10.0.0.5/`). The ssh_config alias is the human-readable label;
using it makes mounts identifiable in Nautilus/Dolphin/etc.
- **Tradeoffs**:
- sshfs now resolves the alias internally via `~/.ssh/config` instead of
receiving a pre-resolved IP. Our explicit `-o IdentityFile=` and `-p port`
still win as overrides — the resolved values were redundant but kept as a
belt-and-suspenders sanity check (early failure if config is malformed).
- The CLI argument flows into the sshfs argv, so it must be validated.
Added `validate_ssh_field("alias", args[0], rxHostUser)` immediately after
arg parsing to gate injection-shaped input.
- Existing mounts under `~/Servers/<ip>/` are now orphaned. User must
manually `fusermount -u` and `rmdir` the IP-named directories. Documented
in the README pointer.
- **How**:
- `args[0]` captured into `alias`, validated via `rxHostUser`.
- `verify_mount_dir(alias)` instead of `(hostname)`; param renamed to `name`
for clarity since it no longer represents a resolved hostname.
- `mount_sshfs` first arg renamed `hostname → alias`; argv source string
becomes `user+"@"+alias+":"+remoteDir`.
## 2026-04-28 Add `-r` / `--remote-dir` flag for custom remote path
- **Who**: Dom, ClaudeCode
- **Why**: Mounting only the remote home was too restrictive; sometimes a
specific subdirectory (e.g. `/var/www`, `~/projects`) is the actual target.
- **Tradeoffs**:
- Two flag aliases (`-r` and `--remote-dir`) registered against the same
target via `flag.StringVar`. Doubles the `flag.PrintDefaults` output but
matches typical short/long convention.
- `rxRemoteDir` deliberately stricter than `rxIdentityFile` — no `:`, `@`,
`+`, `=`, `%` since remote paths rarely need them and looser allowlists
invite injection-shaped surprises in the `user@host:path` argv slot.
- Local mount path stays `~/Servers/<host>` regardless of remote dir — one
host = one mountpoint, even if `-r` differs between invocations. Re-mount
requires `fusermount -u` first.
- **How**:
- `rDir` package-level `string`, registered in `init()` as `r` and
`remote-dir`; validated against `rxRemoteDir = ^[A-Za-z0-9/~][A-Za-z0-9._/~-]*$`.
- `mount_sshfs` signature gains `remoteDir string`; appended to the
`user@host:` argv slot. Empty string preserves the previous home-dir
default.
- `-v` output includes `Remote:` line when `rDir` is set.
- Tests extended with eight `rxRemoteDir` boundary cases.
- **Who**: Dom, ClaudeCode
- **Why**: Audit found `kernel_cache` causes stale reads on a network FS;
`StrictHostKeyChecking` was implicit (depended on system default);
+8 -2
View File
@@ -1,6 +1,6 @@
Quickly mount remote systems via SSHFS based on your ssh_config
Static mount dir is currently `~/Servers/<Host>`
Mounts land under `$XDG_RUNTIME_DIR/sshfs/<Host>` (typically `/run/user/$UID/sshfs/<Host>`), using the ssh_config alias as label, not the resolved IP. The directory is auto-cleaned on logout (tmpfs).
# Install
@@ -22,12 +22,15 @@ install -Dm755 sshfsc /usr/local/bin/sshfsc
# Dependencies
- [SSHFS](https://wiki.archlinux.org/title/SSHFS)
- `findmnt` from `util-linux` (for `-l`)
- [Go](https://wiki.archlinux.org/title/Go) >= 1.25 (build-time)
# Usage
```
sshfsc <Host>
sshfsc <Host> # mount
sshfsc -u <Host> # unmount
sshfsc -l # list active mounts
```
## Arguments
@@ -36,6 +39,9 @@ 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 fuse.sshfs mounts via `findmnt` 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.
+173 -17
View File
@@ -4,8 +4,11 @@
package main
import (
"errors"
"flag"
"fmt"
"io"
"io/fs"
"os"
"os/exec"
"path/filepath"
@@ -20,29 +23,70 @@ import (
var (
rxHostUser = regexp.MustCompile(`^[A-Za-z0-9_][A-Za-z0-9._-]*$`)
rxIdentityFile = regexp.MustCompile(`^[A-Za-z0-9/~][A-Za-z0-9._@/:+=~%-]*$`)
rxRemoteDir = regexp.MustCompile(`^[A-Za-z0-9/~][A-Za-z0-9._/~-]*$`)
)
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
)
func init() {
flag.StringVar(&rDir, "r", "", "remote directory to mount (default: remote home)")
flag.StringVar(&rDir, "remote-dir", "", "remote directory to mount (default: remote home)")
}
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)
}
alias := args[0]
if err := validate_ssh_field("alias", alias, rxHostUser); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(3)
}
hostname, err := lookup_ssh_field(args[0], "HostName")
if err != nil {
fmt.Fprintln(os.Stderr, err)
@@ -96,8 +140,14 @@ func main() {
fmt.Fprintf(os.Stderr, "ssh config Port %q is not a valid port number\n", port)
os.Exit(3)
}
if rDir != "" {
if err := validate_ssh_field("remote-dir", rDir, rxRemoteDir); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(3)
}
}
mount, err := verify_mount_dir(hostname)
mount, err := verify_mount_dir(alias)
if err != nil {
fmt.Fprintln(os.Stderr, "verify_mount_dir() failed:", err)
os.Exit(4)
@@ -109,18 +159,21 @@ func main() {
fmt.Println("Port: ", port)
fmt.Println("Ifile: ", ifile)
fmt.Println("Mount: ", mount)
if rDir != "" {
fmt.Println("Remote: ", rDir)
}
fmt.Println("---")
} else {
fmt.Println("Mount: ", mount)
}
chkmount, chkmount_err := mountinfo.Mounted(mount)
chkmount, chkmount_err := is_mounted_at(mount)
if chkmount_err != nil {
fmt.Fprintf(os.Stderr, "mountinfo.Mounted() failed with %s\n", chkmount_err)
fmt.Fprintf(os.Stderr, "is_mounted_at() failed with %s\n", chkmount_err)
os.Exit(5)
}
if !chkmount {
if err := mount_sshfs(hostname, user, ifile, port, mount); err != nil {
if err := mount_sshfs(alias, user, ifile, port, mount, rDir); err != nil {
fmt.Fprintln(os.Stderr, "mount_sshfs() failed:", err)
os.Exit(6)
}
@@ -160,33 +213,136 @@ func run_editor(mount string) error {
return cmd.Start()
}
func verify_mount_dir(hostname string) (string, error) {
homedir, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("resolve home dir: %w", err)
// 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(homedir, "Servers")
if err := os.MkdirAll(base, 0700); err != nil {
return "", fmt.Errorf("create base %q: %w", base, err)
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, hostname))
if !strings.HasPrefix(mount, realBase+string(os.PathSeparator)) {
return "", fmt.Errorf("hostname %q escapes mount base %q", hostname, 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 the path is already a mountpoint (possibly stale), skip MkdirAll —
// it would stat the target, which fails with EIO on stale FUSE mounts.
if ok, _ := is_mounted_at(mount); ok {
return mount, nil
}
if err := os.MkdirAll(mount, 0700); err != nil {
return "", fmt.Errorf("create mount dir %q: %w", mount, err)
}
return mount, nil
}
func mount_sshfs(hostname string, user string, ifile string, port string, mount string) error {
// is_mounted_at reports whether path is a mountpoint per /proc/self/mountinfo.
// Unlike mountinfo.Mounted, this does not stat the path, so stale FUSE mounts
// (whose targets return EIO on stat) are still detected.
func is_mounted_at(path string) (bool, error) {
mounts, err := mountinfo.GetMounts(mountinfo.SingleEntryFilter(path))
if err != nil {
return false, err
}
return len(mounts) > 0, nil
}
func list_mounts(out io.Writer) error {
cmd := exec.Command("findmnt", "-t", "fuse.sshfs")
cmd.Stdout = out
cmd.Stderr = os.Stderr
err := cmd.Run()
if err == nil {
return nil
}
// findmnt exits 1 when no matching mount exists — not an error here.
var ee *exec.ExitError
if errors.As(err, &ee) && ee.ExitCode() == 1 {
return nil
}
if errors.Is(err, exec.ErrNotFound) {
return fmt.Errorf("findmnt not found (install util-linux)")
}
return fmt.Errorf("findmnt: %w", err)
}
func run_fusermount(flag, mount string) error {
cmd := exec.Command("fusermount", flag, mount)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
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 := is_mounted_at(mount)
if err != nil {
return fmt.Errorf("check mount %q: %w", mount, err)
}
if !ok {
return fmt.Errorf("not mounted: %s", alias)
}
if err := run_fusermount("-u", mount); err != nil {
// stale FUSE mounts often need lazy unmount; -u fails with
// "Transport endpoint is not connected" on those.
if err2 := run_fusermount("-uz", mount); err2 != nil {
return fmt.Errorf("fusermount -u %q: %w (lazy retry: %v)", mount, err, err2)
}
}
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,
"-o", "idmap=user",
@@ -201,7 +357,7 @@ func mount_sshfs(hostname string, user string, ifile string, port string, mount
"-o", "StrictHostKeyChecking=accept-new",
"-o", "ServerAliveInterval=15",
"-o", "ServerAliveCountMax=3",
user+"@"+hostname+":", mount)
user+"@"+alias+":"+remoteDir, mount)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
+74 -9
View File
@@ -1,5 +1,5 @@
// ABOUTME: Unit tests for sshfsc helpers, primarily the path-traversal guard
// ABOUTME: in verify_mount_dir and the ssh_config field allowlist regexes.
// ABOUTME: Unit tests for sshfsc helpers path-traversal guard in
// ABOUTME: verify_mount_dir (XDG_RUNTIME_DIR-rooted) and field allowlist regexes.
package main
@@ -11,9 +11,9 @@ import (
)
func TestVerifyMountDir(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
base := filepath.Join(home, "Servers")
runtime := t.TempDir()
t.Setenv("XDG_RUNTIME_DIR", runtime)
base := filepath.Join(runtime, "sshfs")
if err := os.MkdirAll(base, 0700); err != nil {
t.Fatalf("setup base: %v", err)
}
@@ -92,12 +92,24 @@ func TestValidateSSHFieldRegexes(t *testing.T) {
{"identityfile leading dot rejected", ".ssh/id", "ifile", false},
{"identityfile leading colon rejected", ":weird", "ifile", false},
{"identityfile empty rejected", "", "ifile", false},
{"remote-dir absolute", "/var/www", "rdir", true},
{"remote-dir tilde", "~/work", "rdir", true},
{"remote-dir relative", "projects/foo", "rdir", true},
{"remote-dir leading dash rejected", "-rf", "rdir", false},
{"remote-dir leading dot rejected", "./x", "rdir", false},
{"remote-dir space rejected", "/var /www", "rdir", false},
{"remote-dir comma rejected", "/a,b", "rdir", false},
{"remote-dir empty rejected", "", "rdir", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var rx = rxHostUser
if tc.rx == "ifile" {
switch tc.rx {
case "ifile":
rx = rxIdentityFile
case "rdir":
rx = rxRemoteDir
}
err := validate_ssh_field("X", tc.value, rx)
if tc.ok && err != nil {
@@ -111,9 +123,9 @@ func TestValidateSSHFieldRegexes(t *testing.T) {
}
func TestVerifyMountDirRejectsSymlink(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
base := filepath.Join(home, "Servers")
runtime := t.TempDir()
t.Setenv("XDG_RUNTIME_DIR", runtime)
base := filepath.Join(runtime, "sshfs")
if err := os.MkdirAll(base, 0700); err != nil {
t.Fatalf("setup base: %v", err)
}
@@ -126,3 +138,56 @@ func TestVerifyMountDirRejectsSymlink(t *testing.T) {
t.Fatalf("want symlink rejection, got %v", err)
}
}
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)
}
}
func TestIsMountedAtFalseOnPlainDir(t *testing.T) {
dir := t.TempDir()
ok, err := is_mounted_at(dir)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ok {
t.Fatalf("plain tempdir should not be a mountpoint")
}
}
func TestIsMountedAtFalseOnMissingPath(t *testing.T) {
dir := t.TempDir()
ok, err := is_mounted_at(filepath.Join(dir, "nope"))
if err != nil {
t.Fatalf("unexpected error (no stat should happen): %v", err)
}
if ok {
t.Fatalf("missing path should not be a mountpoint")
}
}