14 Commits

Author SHA1 Message Date
nevaforget 59027ae005 ci: switch update-pkgver to tag-trigger (no-suffix pkgname) 2026-06-10 20:54:13 +02:00
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
nevaforget d01a358f35 refactor: harden ssh_config handling, mount path, and CLI UX from audit findings
Update PKGBUILD version / update-pkgver (push) Successful in 3s
Three rounds of audit-driven hardening, fully documented in DECISIONS.md:

- argv hardening: validate HostName/User/IdentityFile via allowlist regexes,
  parse Port via strconv.Atoi, surface ssh_config parse errors instead of
  silently swallowing them. Switch -o kernel_cache to auto_cache for network-
  FS correctness, pin StrictHostKeyChecking=accept-new.
- LOW-severity cleanup: -v verbose flag (default output is just the mount
  path), run_editor returns errors and main exits 7 on failure, ABOUTME
  headers, golang.org/x/sys v0.43.0 (go 1.25.0).
- Defense-in-depth + UX: rxIdentityFile first-character anchor rejects
  leading "-"/"."/":"/etc., verify_mount_dir resolves base via EvalSymlinks
  and refuses pre-existing symlinks at the mount path, flag.Usage shows the
  positional <Host> argument, run_editor uses cmd.Start() so cold-start
  Sublime does not block the terminal.
- CI: empty-PKGVER guard in update-pkgver workflow.
- Tests: verify_mount_dir path-traversal + symlink-reject coverage,
  rxHostUser/rxIdentityFile boundary cases.
2026-04-26 11:24:45 +02:00
nevaforget 967d5d74cc chore: ignore sshfsc build artifact 2026-04-26 10:38:06 +02:00
nevaforget 64a65031b8 feat: mount remote home dir by default
Less surprising than mounting / — users typically want their own
files, and accessing system paths still works via absolute paths.
2026-04-26 10:37:19 +02:00
nevaforget 70181d9215 fix: propagate errors and harden mount path handling
Addresses audit findings from 2026-04-19:

- Q-H1: replace Println with Printf for %s-formatted error (line 42)
- Q-H2/Q-M2/Q-M3: verify_mount_dir and mount_sshfs now return error;
  main exits on failure instead of continuing with invalid state
- Q-M1: default Port to "22" when ssh_config has no entry
- S-M1: create mount dir with 0700 instead of 0777
- S-M2: filepath.Clean + base-prefix check rejects HostName values
  that would escape ~/Servers/
- Q-L1: correct "~/.ssh_config" typo to "~/.ssh/config"

Also: use os.Exit(2) for usage error (was 80), route user-facing
errors to stderr.
2026-04-19 15:41:33 +02:00
nevaforget ba895624c3 ci: retrigger update-pkgver
Update PKGBUILD version / update-pkgver (push) Successful in 2s
2026-04-19 15:30:12 +02:00
nevaforget af3375b343 ci: harden update-pkgver workflow with fail-loud checks
Update PKGBUILD version / update-pkgver (push) Failing after 0s
2026-04-19 15:27:54 +02:00
nevaforget eb93777faa feat: package as sshfsc for Moonarch repo
Update PKGBUILD version / update-pkgver (push) Has been cancelled
- Add MIT LICENSE
- Rename Go module sshfs_connect -> sshfsc
- Update README with Moonarch install instructions
- Add Gitea workflow to auto-bump pkgver in moonarch-pkgbuilds
2026-04-19 14:29:57 +02:00
nevaforget 40961b10c0 Add SSHFS performance options
Replace minimal mount flags (dir_cache=no) with aggressive caching
and connection tuning: kernel_cache, attr/entry/negative timeouts,
fast cipher (aes128-gcm), reconnect with keepalive.
2026-04-14 11:50:33 +02:00
9 changed files with 923 additions and 73 deletions
+63
View File
@@ -0,0 +1,63 @@
# ABOUTME: Updates pkgver in moonarch-pkgbuilds when a new sshfsc tag is pushed.
# ABOUTME: Reads the latest version tag and bumps the PKGBUILD + .SRCINFO.
name: Update PKGBUILD version
on:
push:
tags:
- 'v*'
jobs:
update-pkgver:
runs-on: moonarch
steps:
- name: Determine pkgver from latest tag
shell: bash
run: |
set -euo pipefail
git clone --bare http://gitea:3000/nevaforget/sshfs_connect.git source.git
cd source.git
PKGVER=$(git describe --tags --abbrev=0 | sed 's/^v//')
echo "New pkgver: $PKGVER"
echo "$PKGVER" > /tmp/pkgver
- name: Update PKGBUILD
shell: bash
env:
PKGBUILD_TOKEN: ${{ secrets.PKGBUILD_TOKEN }}
run: |
set -euo pipefail
if [ -z "${PKGBUILD_TOKEN:-}" ]; then
echo "ERROR: PKGBUILD_TOKEN secret is empty or unset."
echo "Set it under Repo Settings -> Actions -> Secrets."
exit 1
fi
PKGVER=$(cat /tmp/pkgver)
if [ -z "$PKGVER" ]; then
echo "ERROR: PKGVER from previous step is empty."
exit 1
fi
git clone http://gitea:3000/nevaforget/moonarch-pkgbuilds.git pkgbuilds
cd pkgbuilds
OLD_VER=$(grep '^pkgver=' sshfsc/PKGBUILD | cut -d= -f2)
if [ "$OLD_VER" = "$PKGVER" ]; then
echo "pkgver already up to date ($PKGVER)"
exit 0
fi
sed -i "s/^pkgver=.*/pkgver=$PKGVER/" sshfsc/PKGBUILD
sed -i "s/^\tpkgver = .*/\tpkgver = $PKGVER/" sshfsc/.SRCINFO
echo "Updated pkgver: $OLD_VER -> $PKGVER"
git config user.name "pkgver-bot"
git config user.email "gitea@moonarch.de"
git add sshfsc/PKGBUILD sshfsc/.SRCINFO
git commit -m "chore(sshfsc): bump pkgver to $PKGVER"
echo "--- pushing ---"
git -c http.extraHeader="Authorization: token ${PKGBUILD_TOKEN}" push --verbose origin HEAD:main
echo "--- push done ---"
+1
View File
@@ -1 +1,2 @@
sshfs_connect sshfs_connect
sshfsc
+266
View File
@@ -0,0 +1,266 @@
# Decisions
## 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);
`ssh_config`-sourced strings flowed into the sshfs argv without validation,
allowing comma- or leading-dash injection if `~/.ssh/config` is attacker-influenced.
- **Tradeoffs**:
- `auto_cache` invalidates the page cache on `open(2)` when mtime/size
differ → marginal perf hit vs. correctness on a network FS.
- `accept-new` for `StrictHostKeyChecking` preserves first-connection UX
(TOFU) but locks subsequent reconnects to the recorded key.
- Allowlist regexes for HostName/User/IdentityFile are restrictive but match
real-world SSH naming. Rejected inputs surface a clear error.
- **How**:
- Replaced `-o kernel_cache` with `-o auto_cache` in `mount_sshfs`.
- Added explicit `-o StrictHostKeyChecking=accept-new`.
- Added `lookup_ssh_field` (uses `GetStrict`, surfaces parse errors) and
`validate_ssh_field` with per-field allowlists; Port parsed via `strconv.Atoi`.
- Added `main_test.go` covering `verify_mount_dir` and the allowlist regexes.
- Hardened `.gitea/workflows/update-pkgver.yaml` with an empty-PKGVER guard.
## 2026-04-26 LOW-severity audit findings: cleanup pass + one Won't-Fix
- **Who**: Dom, ClaudeCode
- **Why**: Followup on the LOW-severity findings from the same audit.
- **How**:
- Q-L1: dead `if len(port) == 0 { port = "22" }` block already removed by Q-M1
refactor (port now flows through `lookup_ssh_field` → `strconv.Atoi`).
- Q-L2: `run_editor` returns `error` and `main` exits 7 on editor failure
instead of swallowing it.
- Q-L3: ABOUTME header added to `main.go` per global CLAUDE.md convention.
- P-L1: `golang.org/x/sys` bumped `v0.1.0 → v0.43.0` via `go get … && go mod tidy`.
`go` directive moved from 1.23.4 to 1.25.0 (required by the new dep).
- S-L2: added `-v` flag; default output now prints only the mount path.
`-v` restores the previous full HostName/User/Port/IdentityFile/Mount block.
- **Won't-Fix**:
- **S-L1** — `.gitea/workflows/update-pkgver.yaml` clones over
`http://gitea:3000/...`. This URL only resolves inside the Docker network
between the Gitea-act-runner container and the Gitea container. External
traffic still terminates TLS at the reverse proxy. An attacker on the
internal Docker bridge has already compromised the host; TLS between
containers does not help in that scenario. Documenting as accepted risk.
## 2026-04-26 Second-round audit: defense-in-depth + UX
- **Who**: Dom, ClaudeCode
- **Why**: Re-audit (Quality + Performance + Security in parallel) surfaced five
remaining LOW findings after agent-output gegencheck. Two MEDIUM claims were
filtered out as false positives: missing `Port` already defaults to `"22"` via
`ssh_config.Default()` (verified in library source), and `lookup_ssh_field`
does not parse `~/.ssh/config` four times — `kevinburke/ssh_config` caches via
`sync.Once`, so four `GetStrict` calls = one parse + three in-memory walks.
An additional MEDIUM claim about a regex range bug (`%-` interpreted as range)
was empirically refuted (only `-` matched, not the implied range), but a
legitimate defense-in-depth gap remained: the regex still allowed leading `-`
in IdentityFile values.
- **Tradeoffs**:
- `run_editor` switched to `cmd.Start()` — drops editor exit-status
propagation in exchange for non-blocking UX on Sublime cold-start. Errors
from missing binary or fork failure are still returned.
- `verify_mount_dir` now `MkdirAll`s the base separately before
`EvalSymlinks`. `EvalSymlinks` requires the path to exist; without the
upfront `MkdirAll`, first run on a fresh system would fail.
- `rxIdentityFile` first-character class restricted to `[A-Za-z0-9/~]`. Plain
relative paths like `.ssh/id` are rejected — unusual but technically valid
in `ssh_config`. If a real config uses relative IdentityFile values, the
user gets a clear error and can switch to absolute or `~`-prefixed.
- **How**:
- **S-L1**: anchored `rxIdentityFile` to `^[A-Za-z0-9/~][A-Za-z0-9._@/:+=~%-]*$`.
- **S-L2**: `verify_mount_dir` resolves base via `filepath.EvalSymlinks` and
rejects existing mount paths that are symlinks (`os.Lstat`).
- **Q-L1**: `flag.Usage` override now prints the positional `<Host>` argument
in the help output.
- **Q-L2**: inline comment at the `run_editor` call site documents the
deliberate fall-through on already-mounted hosts.
- **P-L2**: `run_editor` uses `cmd.Start()` instead of `cmd.Run()`.
- Tests extended: rxIdentityFile boundary cases, `TestVerifyMountDirRejectsSymlink`,
existing `TestVerifyMountDir` paths now compared against `EvalSymlinks(base)`.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Dominik Kressler
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+33 -6
View File
@@ -1,25 +1,52 @@
Quickly mount remote systems via SSHFS based on your ssh_config 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
## Arch Linux (Moonarch repo)
Available in the Moonarch package repository as `sshfsc-git`:
```
paru -S sshfsc-git
```
## From source
```
go build -o sshfsc
install -Dm755 sshfsc /usr/local/bin/sshfsc
```
# Dependencies # Dependencies
- [SSHFS](https://wiki.archlinux.org/title/SSHFS) - [SSHFS](https://wiki.archlinux.org/title/SSHFS)
- [Go](https://wiki.archlinux.org/title/Go) - `findmnt` from `util-linux` (for `-l`)
- [Go](https://wiki.archlinux.org/title/Go) >= 1.25 (build-time)
# Usage # Usage
``` ```
sshfs_connect <Host> sshfsc <Host> # mount
sshfsc -u <Host> # unmount
sshfsc -l # list active mounts
``` ```
## Arguments ## Arguments
| Flag | Description | | Flag | Description |
| ------------- | ------------- | | ---- | ----------- |
| -e | open mountpoint in your editor | | `-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`) |
Editor Sublime-Text (subl) is currently hardcoded. [See](https://gitea.moonarch.de/nevaforget/sshfs_connect/issues/1) By default only the resolved mount path is printed. Use `-v` for the full
ssh_config dump.
Editor Sublime-Text (`subl`) is currently hardcoded. [See](https://gitea.moonarch.de/nevaforget/sshfs_connect/issues/1)
# Example ssh config # Example ssh config
+6 -6
View File
@@ -1,10 +1,10 @@
module sshfs_connect module sshfsc
go 1.23.4 go 1.25.0
require github.com/kevinburke/ssh_config v1.2.0
require ( require (
github.com/moby/sys/mountinfo v0.7.2 // indirect github.com/kevinburke/ssh_config v1.2.0
golang.org/x/sys v0.1.0 // indirect github.com/moby/sys/mountinfo v0.7.2
) )
require golang.org/x/sys v0.43.0 // indirect
+2 -2
View File
@@ -2,5 +2,5 @@ github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4
github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
github.com/moby/sys/mountinfo v0.7.2 h1:1shs6aH5s4o5H2zQLn796ADW1wMrIwHsyJ2v9KouLrg= github.com/moby/sys/mountinfo v0.7.2 h1:1shs6aH5s4o5H2zQLn796ADW1wMrIwHsyJ2v9KouLrg=
github.com/moby/sys/mountinfo v0.7.2/go.mod h1:1YOa8w8Ih7uW0wALDUgT1dTTSBrZ+HiBLGws92L2RU4= github.com/moby/sys/mountinfo v0.7.2/go.mod h1:1YOa8w8Ih7uW0wALDUgT1dTTSBrZ+HiBLGws92L2RU4=
golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+319 -40
View File
@@ -1,85 +1,364 @@
// ABOUTME: CLI tool that mounts remote filesystems via sshfs based on
// ABOUTME: ~/.ssh/config entries, with optional editor launch on the mountpoint.
package main package main
import ( import (
"errors"
"flag"
"fmt" "fmt"
"io"
"io/fs"
"os" "os"
"os/exec" "os/exec"
"github.com/moby/sys/mountinfo" "path/filepath"
"regexp"
"strconv"
"strings"
"github.com/kevinburke/ssh_config" "github.com/kevinburke/ssh_config"
"flag" "github.com/moby/sys/mountinfo"
) )
var eFlag = flag.Bool("e", false, "open mountpoint in your editor") 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() { 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"+
"Use -l to list active mounts; -u <Host> to unmount.\n\n"+
"Flags:\n")
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 { if len(args) == 0 {
fmt.Println("No hostname specified.") fmt.Fprintln(os.Stderr, "-u requires <Host> argument")
os.Exit(80) os.Exit(2)
} else { }
hostname := ssh_config.Get(args[0], "HostName") if err := unmount_sshfs(args[0]); err != nil {
user := ssh_config.Get(args[0], "User") fmt.Fprintln(os.Stderr, "unmount_sshfs() failed:", err)
port := ssh_config.Get(args[0], "Port") os.Exit(9)
ifile := ssh_config.Get(args[0], "IdentityFile") }
return
}
if len(hostname) == 0 || len(user) == 0 || len(ifile) == 0 { if len(args) == 0 {
fmt.Println("Hostname not found in ~/.ssh_config") 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) os.Exit(3)
} else { }
mount := verify_mount_dir(hostname)
hostname, err := lookup_ssh_field(args[0], "HostName")
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(3)
}
user, err := lookup_ssh_field(args[0], "User")
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(3)
}
port, err := lookup_ssh_field(args[0], "Port")
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(3)
}
ifile, err := lookup_ssh_field(args[0], "IdentityFile")
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(3)
}
if hostname == "" || user == "" || ifile == "" {
var missing []string
if hostname == "" {
missing = append(missing, "HostName")
}
if user == "" {
missing = append(missing, "User")
}
if ifile == "" {
missing = append(missing, "IdentityFile")
}
fmt.Fprintf(os.Stderr, "ssh config %q missing required field(s): %s\n",
args[0], strings.Join(missing, ", "))
os.Exit(3)
}
if err := validate_ssh_field("HostName", hostname, rxHostUser); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(3)
}
if err := validate_ssh_field("User", user, rxHostUser); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(3)
}
if err := validate_ssh_field("IdentityFile", ifile, rxIdentityFile); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(3)
}
if p, perr := strconv.Atoi(port); perr != nil || p < 1 || p > 65535 {
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(alias)
if err != nil {
fmt.Fprintln(os.Stderr, "verify_mount_dir() failed:", err)
os.Exit(4)
}
if *vFlag {
fmt.Println("Hostname: ", hostname) fmt.Println("Hostname: ", hostname)
fmt.Println("User: ", user) fmt.Println("User: ", user)
fmt.Println("Port: ", port) fmt.Println("Port: ", port)
fmt.Println("Ifile: ", ifile) fmt.Println("Ifile: ", ifile)
fmt.Println("Mount: ", mount) fmt.Println("Mount: ", mount)
fmt.Println("---") if rDir != "" {
fmt.Println("Remote: ", rDir)
chkmount, chkmount_err := mountinfo.Mounted(mount) }
if chkmount_err != nil { fmt.Println("---")
fmt.Println("mountinfo.Mounted() failed with %s\n", chkmount_err) } else {
fmt.Println("Mount: ", mount)
}
chkmount, chkmount_err := is_mounted_at(mount)
if chkmount_err != nil {
fmt.Fprintf(os.Stderr, "is_mounted_at() failed with %s\n", chkmount_err)
os.Exit(5)
}
if !chkmount {
if err := mount_sshfs(alias, user, ifile, port, mount, rDir); err != nil {
fmt.Fprintln(os.Stderr, "mount_sshfs() failed:", err)
os.Exit(6)
} }
if chkmount == false {
mount_sshfs(hostname, user, ifile, port, mount)
} else { } else {
fmt.Println("!!! Already mounted") fmt.Println("!!! Already mounted")
} }
run_editor(mount) // run_editor fires on -e regardless of mount state: re-invoking with -e
} // re-opens the editor on an already-mounted server.
if err := run_editor(mount); err != nil {
fmt.Fprintln(os.Stderr, "run_editor() failed:", err)
os.Exit(7)
} }
} }
func run_editor(mount string) { func lookup_ssh_field(alias, field string) (string, error) {
if(*eFlag == true) { v, err := ssh_config.GetStrict(alias, field)
if err != nil {
return "", fmt.Errorf("parse ~/.ssh/config: %w", err)
}
return v, nil
}
func validate_ssh_field(name, value string, allow *regexp.Regexp) error {
if !allow.MatchString(value) {
return fmt.Errorf("ssh config %s %q contains disallowed characters", name, value)
}
return nil
}
func run_editor(mount string) error {
if !*eFlag {
return nil
}
cmd := exec.Command("subl", mount) cmd := exec.Command("subl", mount)
cmd.Stdout = os.Stdout cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr cmd.Stderr = os.Stderr
err := cmd.Run() return cmd.Start()
}
// 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 { if err != nil {
fmt.Println("run_editor() failed with\n",err) return "", fmt.Errorf("resolve base %q: %w", base, err)
}
} }
return realBase, nil
} }
func verify_mount_dir(hostname string)(mount string) { // mount_path joins base + name with traversal and symlink guards.
homedir, homedirerr := os.UserHomeDir() // It does not create the mountpoint directory.
if homedirerr != nil { func mount_path(base, name string) (string, error) {
fmt.Println( homedirerr ) 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)
} }
mount = homedir+"/Servers/"+hostname if info, err := os.Lstat(mount); err == nil && info.Mode()&os.ModeSymlink != 0 {
os.MkdirAll(mount, os.ModePerm) return "", fmt.Errorf("mount path %q is a symlink", mount)
}
return return mount, nil
} }
func mount_sshfs(hostname string, user string, ifile string, port string, mount string) { func verify_mount_dir(name string) (string, error) {
cmd := exec.Command("sshfs", "-p", port, "-o", "IdentityFile="+ifile+",idmap=user,dir_cache=no", user+"@"+hostname+":/", mount) base, err := mount_base(true)
cmd.Stdout = os.Stdout 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
}
// 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 cmd.Stderr = os.Stderr
err := cmd.Run() 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 err != nil {
fmt.Println("mount_sshfs() failed with\n",err) 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",
"-o", "cache=yes",
"-o", "auto_cache",
"-o", "attr_timeout=60",
"-o", "entry_timeout=60",
"-o", "negative_timeout=20",
"-o", "Ciphers=aes128-gcm@openssh.com",
"-o", "Compression=no",
"-o", "reconnect",
"-o", "StrictHostKeyChecking=accept-new",
"-o", "ServerAliveInterval=15",
"-o", "ServerAliveCountMax=3",
user+"@"+alias+":"+remoteDir, mount)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
+193
View File
@@ -0,0 +1,193 @@
// ABOUTME: Unit tests for sshfsc helpers — path-traversal guard in
// ABOUTME: verify_mount_dir (XDG_RUNTIME_DIR-rooted) and field allowlist regexes.
package main
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestVerifyMountDir(t *testing.T) {
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)
}
realBase, err := filepath.EvalSymlinks(base)
if err != nil {
t.Fatalf("resolve base: %v", err)
}
cases := []struct {
name string
hostname string
wantErr string // substring; "" means no error
wantPath string // only checked when wantErr == ""
}{
{"normal alias", "server1", "", filepath.Join(realBase, "server1")},
{"trailing slash", "server1/", "", filepath.Join(realBase, "server1")},
{"dot in name", "db.prod", "", filepath.Join(realBase, "db.prod")},
{"parent traversal", "../etc", "escapes mount base", ""},
{"deep traversal", "../../tmp/x", "escapes mount base", ""},
{"empty string", "", "escapes mount base", ""},
{"single dot", ".", "escapes mount base", ""},
// nested path lands under base — accepted by guard. The hostname allowlist
// (rxHostUser) rejects slashes upstream; this test pins guard behaviour.
{"nested path stays under base", "sub/dir", "", filepath.Join(realBase, "sub/dir")},
// absolute-looking input does not escape because filepath.Join keeps it
// under base. Same upstream allowlist applies.
{"absolute-looking input", "/etc/passwd", "", filepath.Join(realBase, "etc/passwd")},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := verify_mount_dir(tc.hostname)
if tc.wantErr != "" {
if err == nil {
t.Fatalf("want error containing %q, got nil (path=%q)", tc.wantErr, got)
}
if !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("error %q does not contain %q", err.Error(), tc.wantErr)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tc.wantPath {
t.Fatalf("path = %q, want %q", got, tc.wantPath)
}
})
}
}
func TestValidateSSHFieldRegexes(t *testing.T) {
cases := []struct {
name string
value string
rx string
ok bool
}{
{"plain host", "myserver", "host", true},
{"host with dots", "db.prod.example.com", "host", true},
{"host with hyphen", "edge-01", "host", true},
{"host leading dash rejected", "-rogue", "host", false},
{"host with comma rejected", "a,b", "host", false},
{"host with space rejected", "a b", "host", false},
{"host with slash rejected", "a/b", "host", false},
{"host empty rejected", "", "host", false},
{"identityfile tilde", "~/.ssh/id_ed25519", "ifile", true},
{"identityfile absolute", "/home/dom/.ssh/id_rsa", "ifile", true},
{"identityfile letter start", "id_rsa", "ifile", true},
{"identityfile digit start", "0key", "ifile", true},
{"identityfile dash mid accepted", "~/.ssh/id-host", "ifile", true},
{"identityfile with comma rejected", "~/.ssh/id,allow_other", "ifile", false},
{"identityfile with space rejected", "~/.ssh/id rsa", "ifile", false},
{"identityfile leading dash rejected", "-Ffoo", "ifile", false},
{"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
switch tc.rx {
case "ifile":
rx = rxIdentityFile
case "rdir":
rx = rxRemoteDir
}
err := validate_ssh_field("X", tc.value, rx)
if tc.ok && err != nil {
t.Fatalf("want accept, got error: %v", err)
}
if !tc.ok && err == nil {
t.Fatalf("want reject, got accept")
}
})
}
}
func TestVerifyMountDirRejectsSymlink(t *testing.T) {
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)
}
target := t.TempDir()
if err := os.Symlink(target, filepath.Join(base, "evil")); err != nil {
t.Fatalf("create symlink: %v", err)
}
_, err := verify_mount_dir("evil")
if err == nil || !strings.Contains(err.Error(), "is a symlink") {
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")
}
}