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

A second sshfsc <alias> call only printed "Already mounted"; tearing
down a mount required ls + fusermount by hand. -l lists active mounts
verified via mountinfo.Mounted, -u <Host> unmounts and removes the
empty mountpoint dir. Flags are mutually exclusive.
This commit is contained in:
2026-05-04 09:25:55 +02:00
parent 3f3c631057
commit 8edddc5a28
4 changed files with 234 additions and 8 deletions
+124 -7
View File
@@ -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 err := os.MkdirAll(base, 0700); err != nil {
return "", fmt.Errorf("create base %q: %w", base, err)
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,