Files
sshfs_connect/main_test.go
T
nevaforget 8edddc5a28
Update PKGBUILD version / update-pkgver (push) Successful in 5s
feat: add -l (list) and -u (unmount) flags
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

203 lines
6.5 KiB
Go

// 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 (
"bytes"
"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 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)
}
}