diff --git a/fish/functions/ff-ssh.fish b/fish/functions/ff-ssh.fish index d75493e..41ce04e 100644 --- a/fish/functions/ff-ssh.fish +++ b/fish/functions/ff-ssh.fish @@ -22,9 +22,9 @@ function __ff_ssh_ensure_keys echo "No SSH key found, generating ed25519: $key_path" if test -t 0 - ssh-keygen -t ed25519 -f $key_path -C $comment + ssh-keygen -t ed25519 -f $key_path -C $comment; or return 1 else - ssh-keygen -t ed25519 -f $key_path -C $comment -N "" + ssh-keygen -t ed25519 -f $key_path -C $comment -N ""; or return 1 echo "Non-interactive mode: created with empty passphrase" end end @@ -313,7 +313,7 @@ function __ff_ssh_gval --argument-names key end end -# File that already declares `Host ` (main config or config.d) +# Files declaring an exact alias; repeated declarations need manual disambiguation. function __ff_ssh_find_alias_file --argument-names alias_name set -l files $HOME/.ssh/config if test -d $HOME/.ssh/config.d @@ -321,21 +321,63 @@ function __ff_ssh_find_alias_file --argument-names alias_name end for f in $files test -f $f; or continue - if awk -v want=(string lower -- $alias_name) ' - tolower($1) == "host" { - for (i = 2; i <= NF; i++) - if (tolower($i) == want) hit = 1 + awk -v want=(string lower -- $alias_name) ' + tolower($1) == "host" { + for (i = 2; i <= NF && $i !~ /^#/; i++) { + if (tolower($i) == want) { print FILENAME; break } } - END { exit(hit ? 0 : 1) } - ' $f - echo $f - return 0 - end + } + ' $f end - return 1 end -# 复制公钥到远端(ssh-copy-id),失败不阻断配置写入 +# Replace managed values in place; split shared Host declarations for other aliases. +function __ff_ssh_replace_alias --argument-names alias_name original replacement + awk -v want=(string lower -- $alias_name) -v replacement="$replacement" ' + BEGIN { + while ((getline line < replacement) > 0) block = block line "\n" + close(replacement) + } + tolower($1) == "host" || tolower($1) == "match" { + updating = 0 + if (tolower($1) == "host") { + hit = 0; others = ""; comment = "" + for (i = 2; i <= NF; i++) { + if ($i ~ /^#/) { + for (; i <= NF; i++) comment = comment " " $i + break + } + if (tolower($i) == want) hit = 1 + else others = others " " $i + } + if (hit) { + printf "%s", block + if (others != "") print "Host" others " !" want comment + else { + updating = 1 + if (comment != "") print " " comment + } + next + } + } + } + updating && tolower($1) ~ /^(hostname|user|port|identityfile|proxyjump|proxycommand)$/ { next } + { print } + ' "$original" +end + +# Normalize ssh -G paths (including explicit .pub) to a private-key path. +function __ff_ssh_private_path --argument-names identity + set identity (string replace -r '^~/' "$HOME/" -- $identity) + set identity (string replace -a '%d' "$HOME" -- $identity) + set identity (string replace -r '\.pub$' '' -- $identity) + if not string match -q '/*' -- $identity + set identity "$PWD/$identity" + end + printf '%s\n' "$identity" +end + +# Copy only the selected key; upstream can return 0 after an interrupted filter. function __ff_ssh_copy_id set -l target $argv[1] set -l port $argv[2] @@ -344,27 +386,36 @@ function __ff_ssh_copy_id set -l proxycommand $argv[5] if not type -q ssh-copy-id - echo "ff ssh: 未找到 ssh-copy-id,跳过公钥复制(可手动执行 ssh-copy-id $target)" >&2 + echo "ff ssh: 未找到 ssh-copy-id;未写入 Host(可用 --no-copy 只写配置)" >&2 return 1 end - set -l cmd ssh-copy-id - if test -n "$port"; and test "$port" != 22 - set cmd $cmd -p $port - end - if test -n "$identity" - set cmd $cmd -i $identity - end + set -l options -o ConnectTimeout=10 -o ConnectionAttempts=1 if test -n "$proxyjump" - set cmd $cmd -o "ProxyJump=$proxyjump" + set options $options -o "ProxyJump=$proxyjump" end if test -n "$proxycommand" - set cmd $cmd -o "ProxyCommand=$proxycommand" + set options $options -o "ProxyCommand=$proxycommand" end - set cmd $cmd $target - + set -l cmd ssh-copy-id -i "$identity.pub" -p "$port" $options "$target" echo "复制公钥: $cmd" - $cmd + # The wrapper records SIGINT even if ssh-copy-id's cleanup trap exits 0. + command sh -c 'trap "exit 130" INT; trap "exit 143" TERM; "$@"' ff-ssh $cmd + if test $status -ne 0 + echo "ff ssh: 公钥复制失败或已取消,未写入 Host" >&2 + return 1 + end + + # A skipped-key warning alone proves nothing. Exclude inherited identities, + # certificates and multiplexed sessions; the agent may unlock this key only. + command ssh -F /dev/null -i "$identity" -p "$port" $options \ + -o BatchMode=yes -o PreferredAuthentications=publickey \ + -o IdentitiesOnly=yes -o CertificateFile=none -o ControlPath=none \ + "$target" exit + if test $status -ne 0 + echo "ff ssh: 公钥可能已复制,但所选密钥登录验证失败,未写入 Host。加密私钥请先用 ssh-add 解锁,或用 --no-copy 只写配置" >&2 + return 1 + end end # `ff ssh a@b.com` / `ff ssh -p 2222 -i key root@host` @@ -409,9 +460,11 @@ function __ff_ssh_add_host set -l alias_name $host if test -t 0 read -P "SSH 别名 [$host]: " answer - if test $status -eq 0; and test -n "$answer" - set alias_name $answer + if test $status -ne 0 + echo "ff ssh: 已取消,未复制公钥或改写配置" >&2 + return 1 end + test -n "$answer"; and set alias_name $answer end if not string match -qr '^[A-Za-z0-9][A-Za-z0-9._-]*$' -- $alias_name echo "ff ssh: 非法别名 '$alias_name'(只允许字母、数字、点、下划线、连字符)" >&2 @@ -420,12 +473,35 @@ function __ff_ssh_add_host set -l existing (__ff_ssh_find_alias_file $alias_name) if test -n "$existing" - echo "SSH 别名 '$alias_name' 已存在于 $existing,跳过写入" - return 0 + if test (count $existing) -ne 1 + echo "ff ssh: 别名 '$alias_name' 有多个 Host 声明($existing),请先手动合并;未复制公钥或改写配置" >&2 + return 1 + end + echo "SSH 别名 '$alias_name' 已存在于 $existing" + if not test -t 0 + echo "ff ssh: 非交互模式无法确认覆盖,未复制公钥或改写配置" >&2 + return 1 + end + read -l -P "覆盖更新 '$alias_name'?[y/N]: " overwrite + if test $status -ne 0 + echo "ff ssh: 已取消覆盖,未复制公钥或改写配置" >&2 + return 1 + end + if not contains -- (string lower -- (string trim -- "$overwrite")) y yes + echo "ff ssh: 未确认覆盖,未复制公钥或改写配置" >&2 + return 1 + end + else if test -e "$HOME/.ssh/config.d/$alias_name.conf" + echo "ff ssh: 目标文件已存在但未声明 '$alias_name',请先手动整理;未复制公钥或改写配置" >&2 + return 1 end # 用 ssh -G 解析最终生效的 HostName/User/Port/Proxy* set -l resolved (ssh -G $args 2>/dev/null) + if test $status -ne 0 + echo "ff ssh: 无法解析 SSH 配置,未写入 Host" >&2 + return 1 + end set -l rhost (printf '%s\n' $resolved | __ff_ssh_gval hostname) set -l ruser (printf '%s\n' $resolved | __ff_ssh_gval user) set -l rport (printf '%s\n' $resolved | __ff_ssh_gval port) @@ -438,38 +514,87 @@ function __ff_ssh_add_host test "$proxyjump" = none; and set proxyjump "" test "$proxycommand" = none; and set proxycommand "" - # IdentityFile: ssh -G 会列出所有默认密钥,仅在显式指定时记录 - set -l identity "" - set -l explicit_identity (__ff_ssh_argv_value -i $args) + # Explicit -i / -o IdentityFile must never silently fall back to another key. + set -l explicit_identity "" + for i in (seq (count $args)) + if test "$args[$i]" = -i; and test $i -lt (count $args) + set explicit_identity $args[(math $i + 1)] + break + else if string match -q -- '-i*' "$args[$i]" + set explicit_identity (string sub -s 3 -- "$args[$i]") + break + end + set -l option "" + if test "$args[$i]" = -o; and test $i -lt (count $args) + set option $args[(math $i + 1)] + else if string match -q -- '-o*' "$args[$i]" + set option (string sub -s 3 -- "$args[$i]") + end + if string match -qri '^identityfile[=[:space:]]' -- "$option" + set explicit_identity (string replace -ri '^identityfile[=[:space:]]+' '' -- "$option") + break + end + end + + set -l identities (printf '%s\n' $resolved | string match -ri '^identityfile[[:space:]]+.*$' | string replace -r '^\S+\s+' '') + set -l copy_identity "" + set -l configured_identity 0 if test -n "$explicit_identity" - set identity $explicit_identity - else if string match -q '*IdentityFile*' -- $args - set identity (printf '%s\n' $resolved | __ff_ssh_gval identityfile) + # ssh -G can omit an unreadable -i and print defaults instead. + set copy_identity (__ff_ssh_private_path "$explicit_identity") + set identities end - if string match -q -- "$HOME/*" "$identity" - set identity "~/"(string replace -r "^$HOME/" '' -- $identity) + for candidate in $identities + set -l private (__ff_ssh_private_path "$candidate") + set -l name (string replace -- "$HOME/.ssh/" '' "$private") + if not contains -- "$name" id_rsa id_dsa id_ecdsa id_ecdsa_sk id_ed25519 id_ed25519_sk id_xmss + set configured_identity 1 + end + if test -r "$private"; and test -r "$private.pub" + set copy_identity "$private" + break + end end - # ssh-copy-id 也要用同一把密钥(-i 或 -o IdentityFile=),否则会复制默认 key - set -l copy_identity $explicit_identity - if test -z "$copy_identity"; and test -n "$identity" - set copy_identity (string replace -r '^~/' "$HOME/" -- $identity) + # ssh-copy-id also supports nonstandard id_*.pub names. Among these, keep + # its newest-public-key preference, but only select a complete readable pair. + if test -z "$copy_identity"; and test -z "$explicit_identity"; and test "$configured_identity" = 0 + for pub in (command ls -t "$HOME"/.ssh/id_*.pub 2>/dev/null) + string match -q '*-cert.pub' -- "$pub"; and continue + set -l private (__ff_ssh_private_path "$pub") + if test -r "$private"; and test -r "$pub" + set copy_identity "$private" + break + end + end end - # 先复制公钥,这样写完配置就能直接 ssh if test "$do_copy" = 1 - __ff_ssh_ensure_keys - __ff_ssh_copy_id "$target" "$port" "$copy_identity" "$proxyjump" "$proxycommand" - or echo "ff ssh: 公钥复制失败,已继续写入配置(可重试: ff ssh add $target)" >&2 + if test -z "$copy_identity"; and test -z "$explicit_identity"; and test "$configured_identity" = 0 + __ff_ssh_ensure_keys; or return 1 + set -l generated "$HOME/.ssh/id_ed25519" + if test -r "$generated"; and test -r "$generated.pub" + set copy_identity "$generated" + end + end + if test -z "$copy_identity"; or not test -r "$copy_identity"; or not test -r "$copy_identity.pub" + echo "ff ssh: 未找到可读的公私钥对,请用 -i 指定密钥;未写入 Host" >&2 + return 1 + end + echo "所选 SSH 密钥: $copy_identity" + set -l copy_target "$rhost" + test -n "$user"; and set copy_target "$user@$rhost" + __ff_ssh_copy_id "$copy_target" "$port" "$copy_identity" "$proxyjump" "$proxycommand"; or return 1 + end + + set -l identity "$copy_identity" + if string match -q -- "$HOME/*" "$identity" + set identity "~/"(string sub -s (math (string length -- "$HOME") + 2) -- "$identity") end set -l dir "$HOME/.ssh/config.d" - mkdir -p $dir - chmod 700 "$HOME/.ssh" $dir set -l conf_file "$dir/$alias_name.conf" - if test -f $conf_file - cp -p $conf_file "$conf_file.backup."(date +%Y%m%d%H%M%S) - end + test -n "$existing"; and set conf_file "$existing" set -l tmpdir /tmp test -n "$TMPDIR"; and set tmpdir $TMPDIR @@ -482,7 +607,7 @@ function __ff_ssh_add_host end printf ' Port %s\n' $port if test -n "$identity" - printf ' IdentityFile %s\n' $identity + printf ' IdentityFile "%s"\n' $identity end if test -n "$proxyjump" printf ' ProxyJump %s\n' $proxyjump @@ -491,8 +616,33 @@ function __ff_ssh_add_host printf ' ProxyCommand %s\n' $proxycommand end end > $tmp + if test -n "$existing" + set -l updated (mktemp "$tmpdir/ff-ssh.XXXXXX"); or begin + rm -f $tmp + return 1 + end + __ff_ssh_replace_alias "$alias_name" "$conf_file" "$tmp" > $updated; or begin + rm -f $tmp $updated + return 1 + end + rm -f $tmp + set tmp $updated + # config.d/* would load backups too, so keep them outside that directory. + set -l backup_dir "$HOME/.ssh/config.backups" + mkdir -p "$backup_dir"; or return 1 + chmod 700 "$backup_dir" + set -l backup (mktemp "$backup_dir/"(basename "$conf_file")".backup.XXXXXX"); or return 1 + cp -p "$conf_file" "$backup"; or begin + rm -f $tmp $backup + return 1 + end + echo "原配置备份: $backup" + end + mkdir -p $dir; or return 1 + chmod 700 "$HOME/.ssh" $dir chmod 600 $tmp - mv $tmp $conf_file + mv $tmp $conf_file; or return 1 + __ff_ssh_ensure_config_d; or return 1 echo "已写入 SSH 配置: $conf_file" echo "以后可直接运行: ssh $alias_name" @@ -509,7 +659,10 @@ function __ff_ssh_usage echo " help 显示此帮助" echo "" echo "选项:" - echo " --no-copy 只写配置,不执行 ssh-copy-id" + echo " --no-copy 只写配置,不执行 ssh-copy-id 或登录验证" + echo " 重复别名需交互确认覆盖(默认否),成功后备份并在原位置更新" + echo " 公钥复制后仅用所选密钥验证登录;连接超时 10 秒,失败或取消不写 Host" + echo " 验证使用 BatchMode;加密私钥请先用 ssh-add 解锁" echo "" echo "保留子命令: init / copy / add / run / help(同名主机请用 ssh 直接连接)" echo "" @@ -530,7 +683,6 @@ function ff_ssh __ff_ssh_ensure_keys __ff_ssh_ensure_config_d case add - __ff_ssh_ensure_config_d __ff_ssh_add_host $argv[2..-1] case run set -l run_args @@ -538,7 +690,6 @@ function ff_ssh test "$a" = --no-copy; and continue set run_args $run_args $a end - __ff_ssh_ensure_config_d __ff_ssh_add_host $argv[2..-1]; or return 1 command ssh $run_args case copy @@ -548,7 +699,6 @@ function ff_ssh case '*' # 裸参数与 ssh 原生参数一致:有主机就记录配置,否则透传给 ssh if __ff_ssh_find_target $argv >/dev/null - __ff_ssh_ensure_config_d __ff_ssh_add_host $argv else command ssh $argv diff --git a/fish/tests/ff-ssh.py b/fish/tests/ff-ssh.py new file mode 100644 index 0000000..19c534d --- /dev/null +++ b/fish/tests/ff-ssh.py @@ -0,0 +1,488 @@ +#!/usr/bin/env python3 +"""Isolated ff ssh regressions: dummy keys and mock commands, no network access.""" + +import fcntl +import json +import os +import pty +import selectors +import shutil +from pathlib import Path +import signal +import subprocess +import tempfile +import termios +import time +import unittest + +ROOT = Path(__file__).resolve().parents[2] +REAL_SSH = shutil.which("ssh") +SOURCE = Path(os.environ.get("FF_SSH_TEST_SOURCE", ROOT / "fish/functions/ff-ssh.fish")) +MOCK = r'''#!/usr/bin/env python3 +import json, os, pathlib, signal, sys, time +home = pathlib.Path(os.environ["HOME"]) +name = pathlib.Path(sys.argv[0]).name +with (home / "calls.jsonl").open("a") as f: + f.write(json.dumps([name, sys.argv[1:]]) + "\n") +if name == "ssh": + if "-G" in sys.argv: + print((home / "resolved").read_text(), end="") + sys.exit(int(os.environ.get("RESOLVE_STATUS", "0"))) + sys.exit(int(os.environ.get("VERIFY_STATUS", "0"))) +if name == "ssh-keygen": + if os.environ.get("KEYGEN_FAIL"): + sys.exit(1) + key = pathlib.Path(sys.argv[sys.argv.index("-f") + 1]) + key.write_text("dummy private\n") + key.with_name(key.name + ".pub").write_text("dummy public\n") + sys.exit(0) +mode = os.environ.get("COPY_MODE", "success") +if mode == "cancel-zero": + def cancel(*_): + print("ERROR: Assertion failure: in filter_ids(): scratch_dir was not correctly set up", file=sys.stderr) + print("WARNING: All keys were skipped because they already exist", file=sys.stderr) + sys.exit(0) + signal.signal(signal.SIGINT, cancel) + (home / "ready").touch() + time.sleep(15) +if mode == "false-zero": + print("ERROR: Assertion failure: in filter_ids(): scratch_dir was not correctly set up", file=sys.stderr) + print("WARNING: All keys were skipped because they already exist", file=sys.stderr) +if mode in ("failure", "timeout"): + print("Connection timed out" if mode == "timeout" else "Permission denied", file=sys.stderr) + sys.exit(1) +''' + + +class SshTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix="ff-ssh-test-") + self.addCleanup(self.temp.cleanup) + self.home = Path(self.temp.name) + self.ssh = self.home / ".ssh" + self.ssh.mkdir() + bindir = self.home / "bin" + bindir.mkdir() + for name in ("ssh", "ssh-copy-id", "ssh-keygen"): + path = bindir / name + path.write_text(MOCK) + path.chmod(0o755) + self.env = dict(os.environ, HOME=str(self.home), + XDG_CONFIG_HOME=str(self.home / ".config"), + PATH=f"{bindir}:{os.environ['PATH']}", USER="test") + self.resolve("~/.ssh/id_rsa", "~/.ssh/id_ed25519") + + def resolve(self, *identities): + (self.home / "resolved").write_text( + "hostname machine.invalid\nuser testuser\nport 2222\nproxyjump none\nproxycommand none\n" + + "".join(f"identityfile {key}\n" for key in identities)) + + def key(self, name): + private = self.ssh / name + private.write_text("dummy private\n") + private.with_name(name + ".pub").write_text("dummy public\n") + return private + + def launch(self, args): + return subprocess.Popen( + ["fish", "--no-config", "-c", 'source "$argv[1]"; ff_ssh $argv[2..-1]', + str(SOURCE), *args], env=self.env, stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, start_new_session=True) + + def run_ff(self, *args): + proc = self.launch(args) + try: + out, err = proc.communicate(timeout=10) + except subprocess.TimeoutExpired: + os.killpg(proc.pid, signal.SIGKILL) + proc.communicate() + raise + return proc.returncode, out + err + + def calls(self, name): + path = self.home / "calls.jsonl" + return [args for cmd, args in map(json.loads, path.read_text().splitlines()) if cmd == name] if path.exists() else [] + + def assert_selected(self, private): + copy = self.calls("ssh-copy-id") + self.assertEqual(len(copy), 1) + chosen = copy[0][copy[0].index("-i") + 1] + self.assertEqual(str(private), chosen.removesuffix(".pub")) + config = (self.ssh / "config.d/machine.invalid.conf").read_text() + identities = [line.strip().split(maxsplit=1)[1].strip('"') for line in config.splitlines() if line.strip().startswith("IdentityFile ")] + self.assertEqual(identities, [str(private).replace(str(self.home), "~", 1)]) + + def assert_no_host(self): + self.assertFalse((self.ssh / "config.d/machine.invalid.conf").exists()) + + def test_default_matches_resolved_order_with_multiple_keys(self): + selected = self.key("id_rsa") + self.key("id_ed25519") + status, output = self.run_ff("testuser@machine.invalid") + self.assertEqual(status, 0, output) + self.assert_selected(selected) + + def test_nonstandard_default_key(self): + selected = self.key("id_hx_new") + status, output = self.run_ff("testuser@machine.invalid") + self.assertEqual(status, 0, output) + self.assert_selected(selected) + + def test_explicit_private_and_public_paths(self): + for suffix in ("", ".pub"): + with self.subTest(suffix=suffix): + self.setUp() + self.key("id_rsa") + selected = self.key("custom") + self.resolve(str(selected) + suffix, "~/.ssh/id_rsa") + status, output = self.run_ff("-i", str(selected) + suffix, "testuser@machine.invalid") + self.assertEqual(status, 0, output) + self.assert_selected(selected) + + def test_identity_option_case_and_forms(self): + for option in ("IdentityFile=", "identityfile "): + with self.subTest(option=option): + self.setUp() + self.key("id_rsa") + selected = self.key("custom") + self.resolve(str(selected), "~/.ssh/id_rsa") + status, output = self.run_ff("-o", option + str(selected), "testuser@machine.invalid") + self.assertEqual(status, 0, output) + self.assert_selected(selected) + + def test_existing_config_identity(self): + selected = self.key("custom") + self.key("id_rsa") + self.resolve(str(selected)) + status, output = self.run_ff("testuser@machine.invalid") + self.assertEqual(status, 0, output) + self.assert_selected(selected) + + def test_invalid_explicit_identity_never_falls_back(self): + self.key("id_rsa") + # OpenSSH omits an unreadable -i from ssh -G, leaving defaults. + self.resolve("~/.ssh/id_rsa") + status, output = self.run_ff("-i", "/missing/key", "testuser@machine.invalid") + self.assertNotEqual(status, 0, output) + self.assert_no_host() + self.assertFalse(self.calls("ssh-copy-id")) + + def test_unavailable_config_identity_never_falls_back(self): + self.key("id_rsa") + self.resolve("~/.ssh/custom_missing") + status, output = self.run_ff("machine.invalid") + self.assertNotEqual(status, 0, output) + self.assert_no_host() + self.assertFalse(self.calls("ssh-copy-id")) + + def test_unpaired_default_key_is_not_overwritten(self): + path = self.ssh / "id_ed25519" + path.write_text("existing private") + status, output = self.run_ff("machine.invalid") + self.assertNotEqual(status, 0, output) + self.assertEqual(path.read_text(), "existing private") + self.assert_no_host() + + def test_generation_then_copy_same_key(self): + status, output = self.run_ff("machine.invalid") + self.assertEqual(status, 0, output) + self.assert_selected(self.ssh / "id_ed25519") + + def test_generation_failure_aborts(self): + self.env["KEYGEN_FAIL"] = "1" + status, output = self.run_ff("machine.invalid") + self.assertNotEqual(status, 0, output) + self.assert_no_host() + self.assertFalse(self.calls("ssh-copy-id")) + + def test_copy_failure_and_timeout_abort(self): + for mode in ("failure", "timeout"): + with self.subTest(mode=mode): + self.setUp() + self.key("id_rsa") + self.env["COPY_MODE"] = mode + status, output = self.run_ff("machine.invalid") + self.assertNotEqual(status, 0, output) + self.assert_no_host() + + def test_false_success_requires_selected_key_verification(self): + self.key("id_rsa") + self.env.update(COPY_MODE="false-zero", VERIFY_STATUS="255") + status, output = self.run_ff("machine.invalid") + self.assertNotEqual(status, 0, output) + self.assert_no_host() + + def test_ctrl_c_with_upstream_exit_zero_aborts(self): + self.key("id_rsa") + self.env["COPY_MODE"] = "cancel-zero" + proc = self.launch(["machine.invalid"]) + try: + deadline = time.monotonic() + 5 + while not (self.home / "ready").exists() and proc.poll() is None and time.monotonic() < deadline: + time.sleep(.02) + self.assertTrue((self.home / "ready").exists(), "mock copy must reach cancellable stage") + os.killpg(proc.pid, signal.SIGINT) + out, err = proc.communicate(timeout=5) + self.assertNotEqual(proc.returncode, 0, out + err) + self.assert_no_host() + finally: + if proc.poll() is None: + os.killpg(proc.pid, signal.SIGKILL) + proc.communicate() + + def test_no_copy_never_generates_or_connects(self): + status, output = self.run_ff("--no-copy", "machine.invalid") + self.assertEqual(status, 0, output) + self.assertTrue((self.ssh / "config.d/machine.invalid.conf").exists()) + self.assertFalse(self.calls("ssh-copy-id")) + self.assertFalse(self.calls("ssh-keygen")) + self.assertTrue(all("-G" in call for call in self.calls("ssh"))) + + def test_no_copy_public_identity_records_private(self): + private = self.key("custom") + self.resolve(str(private) + ".pub") + status, output = self.run_ff("--no-copy", "-i", str(private) + ".pub", "machine.invalid") + self.assertEqual(status, 0, output) + config = (self.ssh / "config.d/machine.invalid.conf").read_text() + self.assertIn("~/.ssh/custom", config) + self.assertNotIn("custom.pub", config) + + def existing(self, location="config.d/machine.invalid.conf", shared=False): + path = self.ssh / location + path.parent.mkdir(parents=True, exist_ok=True) + content = ("# original settings\nHost " + ("machine.invalid neighbor" if shared else "machine.invalid") + + "\n HostName old.invalid\n User olduser\n Port 22\n" + " IdentityFile ~/.ssh/old\n ForwardAgent no\n" + "Host untouched\n HostName untouched.invalid\n") + path.write_text(content) + if location != "config": + (self.ssh / "config").write_text(f"Include {self.ssh}/config.d/*\n") + return path, content + + def snapshot(self): + return {str(p.relative_to(self.ssh)): p.read_bytes() + for p in self.ssh.rglob("*") if p.is_file()} + + def interactive(self, *args, answer="y", cancel_at=None): + master, slave = pty.openpty() + proc = subprocess.Popen( + ["fish", "--no-config", "-c", 'source "$argv[1]"; ff_ssh $argv[2..-1]', + str(SOURCE), *args], env=self.env, stdin=slave, + stdout=slave, stderr=slave, start_new_session=True, + preexec_fn=lambda: fcntl.ioctl(slave, termios.TIOCSCTTY, 0)) + os.close(slave) + output = b"" + selector = selectors.DefaultSelector() + selector.register(master, selectors.EVENT_READ) + + def until(prompt): + nonlocal output + deadline = time.monotonic() + 5 + while prompt.encode() not in output and time.monotonic() < deadline: + for key, _ in selector.select(.1): + chunk = os.read(key.fileobj, 65536) + if not chunk: + self.fail("process ended before prompt: " + output.decode(errors="replace")) + output += chunk + self.assertIn(prompt, output.decode(errors="replace")) + + try: + until("SSH 别名") + time.sleep(.1) # let fish finish entering terminal input mode + if cancel_at == "alias": + os.write(master, b"\x04") + else: + os.write(master, b"\r") + until("[y/N]") + time.sleep(.1) + if cancel_at == "interrupt": + os.killpg(proc.pid, signal.SIGINT) + elif cancel_at == "eof": + os.write(master, b"\x04") + else: + os.write(master, (answer + "\r").encode()) + deadline = time.monotonic() + 10 + while time.monotonic() < deadline: + ready = selector.select(.1) + if ready: + try: + chunk = os.read(master, 65536) + except OSError: + break + if not chunk: + break + output += chunk + elif proc.poll() is not None: + break + proc.wait(timeout=1) + return proc.returncode, output.decode(errors="replace") + finally: + selector.close() + os.close(master) + if proc.poll() is None: + os.killpg(proc.pid, signal.SIGKILL) + proc.communicate() + + def assert_unchanged(self, before): + self.assertEqual(self.snapshot(), before) + self.assertFalse(self.calls("ssh-copy-id")) + self.assertFalse(self.calls("ssh-keygen")) + + def assert_backup(self, content): + backups = list((self.ssh / "config.backups").glob("*.backup.*")) + self.assertEqual(len(backups), 1) + self.assertEqual(backups[0].read_text(), content) + self.assertEqual((self.ssh / "config.backups").stat().st_mode & 0o777, 0o700) + self.assertFalse(list((self.ssh / "config.d").glob("*.backup.*"))) + + def effective(self, host): + # -G is local only; absolute Include plus -F avoid the real SSH config. + result = subprocess.run([REAL_SSH, "-G", "-F", str(self.ssh / "config"), host], + env=self.env, capture_output=True, text=True, timeout=5) + self.assertEqual(result.returncode, 0, result.stderr) + return dict(line.split(" ", 1) for line in result.stdout.splitlines()) + + def test_existing_alias_is_actionable_failure_and_preserved(self): + self.existing(location="config") + before = self.snapshot() + status, output = self.run_ff("machine.invalid") + self.assertNotEqual(status, 0, output) + self.assertIn("非交互", output) + self.assert_unchanged(before) + self.assertFalse(self.calls("ssh")) + + def test_overwrite_accepts_only_explicit_yes(self): + for answer in ("n", "", "sure", "yes please"): + with self.subTest(answer=answer): + self.setUp() + self.existing(location="config") + before = self.snapshot() + status, output = self.interactive("machine.invalid", answer=answer) + self.assertNotEqual(status, 0, output) + self.assertIn("未确认覆盖", output) + self.assert_unchanged(before) + self.assertFalse(self.calls("ssh")) + + def test_overwrite_eof_and_ctrl_c_preserve_everything(self): + for cancel in ("eof", "interrupt", "alias"): + with self.subTest(cancel=cancel): + self.setUp() + self.existing(location="config") + before = self.snapshot() + status, output = self.interactive("machine.invalid", cancel_at=cancel) + self.assertNotEqual(status, 0, output) + self.assertIn("取消", output) + self.assert_unchanged(before) + + def test_overwrite_yes_copies_then_backs_up_and_updates(self): + for answer in ("y", "YES"): + with self.subTest(answer=answer): + self.setUp() + selected = self.key("custom") + self.resolve(str(selected)) + path, original = self.existing() + status, output = self.interactive("newuser@machine.invalid", "-p", "2200", answer=answer) + self.assertEqual(status, 0, output) + self.assertIn(str(path), output) + self.assert_selected(selected) + self.assert_backup(original) + updated = path.read_text() + for value in ("HostName machine.invalid", "User newuser", "Port 2200", "ForwardAgent no", + "Host untouched\n HostName untouched.invalid"): + self.assertIn(value, updated) + self.assertNotIn("old.invalid", updated) + self.assertNotIn("~/.ssh/old", updated) + + def test_overwrite_no_copy_in_shared_nonmatching_filename(self): + path, original = self.existing("config.d/20-shared.conf", shared=True) + status, output = self.interactive("--no-copy", "machine.invalid") + self.assertEqual(status, 0, output) + self.assert_backup(original) + self.assertFalse((self.ssh / "config.d/machine.invalid.conf").exists()) + self.assertFalse(self.calls("ssh-copy-id")) + self.assertFalse(self.calls("ssh-keygen")) + self.assertTrue(all("-G" in call for call in self.calls("ssh"))) + self.assertEqual(self.effective("machine.invalid")["hostname"], "machine.invalid") + self.assertEqual(self.effective("neighbor")["hostname"], "old.invalid") + self.assertEqual(self.effective("neighbor")["identityfile"], "~/.ssh/old") + self.assertEqual(self.effective("untouched")["hostname"], "untouched.invalid") + self.assertIn("Host neighbor !machine.invalid", path.read_text()) + + def test_overwrite_shared_wildcard_excludes_target(self): + path, original = self.existing("config.d/20-shared.conf", shared=True) + original = original.replace("machine.invalid neighbor", "machine.invalid neighbor *") + path.write_text(original) + status, output = self.interactive("--no-copy", "machine.invalid") + self.assertEqual(status, 0, output) + self.assert_backup(original) + self.assertEqual(self.effective("machine.invalid")["hostname"], "machine.invalid") + self.assertNotIn("~/.ssh/old", self.effective("machine.invalid")["identityfile"]) + self.assertEqual(self.effective("neighbor")["hostname"], "old.invalid") + + def test_overwrite_main_config_preserves_neighbors(self): + path, original = self.existing("config") + status, output = self.interactive("--no-copy", "machine.invalid") + self.assertEqual(status, 0, output) + self.assert_backup(original) + self.assertIn("HostName machine.invalid", path.read_text()) + self.assertIn("Host untouched\n HostName untouched.invalid", path.read_text()) + self.assertFalse((self.ssh / "config.d/machine.invalid.conf").exists()) + + def test_overwrite_copy_or_verification_failure_preserves_original(self): + for mode in ("failure", "false-zero"): + with self.subTest(mode=mode): + self.setUp() + self.key("id_rsa") + self.existing(location="config") + before = self.snapshot() + self.env.update(COPY_MODE=mode, VERIFY_STATUS="255") + status, output = self.interactive("machine.invalid") + self.assertNotEqual(status, 0, output) + self.assertEqual(self.snapshot(), before) + self.assertEqual(len(self.calls("ssh-copy-id")), 1) + + def test_ambiguous_declarations_are_not_partially_updated(self): + self.existing() + (self.ssh / "config.d/20-second.conf").write_text("Host machine.invalid\n HostName second.invalid\n") + before = self.snapshot() + status, output = self.run_ff("machine.invalid") + self.assertNotEqual(status, 0, output) + self.assertIn("多个 Host", output) + self.assert_unchanged(before) + + def test_filename_collision_does_not_replace_other_alias(self): + path = self.ssh / "config.d/machine.invalid.conf" + path.parent.mkdir() + path.write_text("Host other\n HostName other.invalid\n") + before = self.snapshot() + status, output = self.run_ff("machine.invalid") + self.assertNotEqual(status, 0, output) + self.assertIn("目标文件已存在", output) + self.assert_unchanged(before) + + def test_connect_timeout_and_verification_identity_isolation(self): + selected = self.key("id_rsa") + status, output = self.run_ff("machine.invalid") + self.assertEqual(status, 0, output) + copy = self.calls("ssh-copy-id")[0] + self.assertIn("ConnectTimeout=10", copy) + self.assertIn("ConnectionAttempts=1", copy) + verify = [call for call in self.calls("ssh") if "-G" not in call] + self.assertEqual(len(verify), 1) + for option in ("ConnectTimeout=10", "ConnectionAttempts=1", "BatchMode=yes", "IdentitiesOnly=yes", "CertificateFile=none", "ControlPath=none", "PreferredAuthentications=publickey"): + self.assertIn(option, verify[0]) + self.assertEqual(verify[0][verify[0].index("-F") + 1], "/dev/null") + self.assertEqual(verify[0][verify[0].index("-i") + 1], str(selected)) + self.assertNotIn("StrictHostKeyChecking=no", verify[0]) + + def test_resolve_failure_aborts(self): + self.key("id_rsa") + self.env["RESOLVE_STATUS"] = "255" + status, output = self.run_ff("machine.invalid") + self.assertNotEqual(status, 0, output) + self.assert_no_host() + self.assertFalse(self.calls("ssh-copy-id")) + + +if __name__ == "__main__": + unittest.main(verbosity=2)