fix: confirm SSH alias updates and keep key handling consistent

This commit is contained in:
2026-10-19 16:57:44 +08:00
parent 59e2356f5c
commit 39cf5c6d19
2 changed files with 696 additions and 58 deletions
+206 -56
View File
@@ -22,9 +22,9 @@ function __ff_ssh_ensure_keys
echo "No SSH key found, generating ed25519: $key_path" echo "No SSH key found, generating ed25519: $key_path"
if test -t 0 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 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" echo "Non-interactive mode: created with empty passphrase"
end end
end end
@@ -313,7 +313,7 @@ function __ff_ssh_gval --argument-names key
end end
end end
# File that already declares `Host <alias>` (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 function __ff_ssh_find_alias_file --argument-names alias_name
set -l files $HOME/.ssh/config set -l files $HOME/.ssh/config
if test -d $HOME/.ssh/config.d if test -d $HOME/.ssh/config.d
@@ -321,21 +321,63 @@ function __ff_ssh_find_alias_file --argument-names alias_name
end end
for f in $files for f in $files
test -f $f; or continue test -f $f; or continue
if awk -v want=(string lower -- $alias_name) ' awk -v want=(string lower -- $alias_name) '
tolower($1) == "host" { tolower($1) == "host" {
for (i = 2; i <= NF; i++) for (i = 2; i <= NF && $i !~ /^#/; i++) {
if (tolower($i) == want) hit = 1 if (tolower($i) == want) { print FILENAME; break }
}
} }
END { exit(hit ? 0 : 1) }
' $f ' $f
echo $f
return 0
end end
end 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 function __ff_ssh_copy_id
set -l target $argv[1] set -l target $argv[1]
set -l port $argv[2] set -l port $argv[2]
@@ -344,27 +386,36 @@ function __ff_ssh_copy_id
set -l proxycommand $argv[5] set -l proxycommand $argv[5]
if not type -q ssh-copy-id 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 return 1
end end
set -l cmd ssh-copy-id set -l options -o ConnectTimeout=10 -o ConnectionAttempts=1
if test -n "$port"; and test "$port" != 22
set cmd $cmd -p $port
end
if test -n "$identity"
set cmd $cmd -i $identity
end
if test -n "$proxyjump" if test -n "$proxyjump"
set cmd $cmd -o "ProxyJump=$proxyjump" set options $options -o "ProxyJump=$proxyjump"
end end
if test -n "$proxycommand" if test -n "$proxycommand"
set cmd $cmd -o "ProxyCommand=$proxycommand" set options $options -o "ProxyCommand=$proxycommand"
end end
set cmd $cmd $target set -l cmd ssh-copy-id -i "$identity.pub" -p "$port" $options "$target"
echo "复制公钥: $cmd" 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 end
# `ff ssh a@b.com` / `ff ssh -p 2222 -i key root@host` # `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 set -l alias_name $host
if test -t 0 if test -t 0
read -P "SSH 别名 [$host]: " answer read -P "SSH 别名 [$host]: " answer
if test $status -eq 0; and test -n "$answer" if test $status -ne 0
set alias_name $answer echo "ff ssh: 已取消,未复制公钥或改写配置" >&2
return 1
end end
test -n "$answer"; and set alias_name $answer
end end
if not string match -qr '^[A-Za-z0-9][A-Za-z0-9._-]*$' -- $alias_name if not string match -qr '^[A-Za-z0-9][A-Za-z0-9._-]*$' -- $alias_name
echo "ff ssh: 非法别名 '$alias_name'(只允许字母、数字、点、下划线、连字符)" >&2 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) set -l existing (__ff_ssh_find_alias_file $alias_name)
if test -n "$existing" if test -n "$existing"
echo "SSH 别名 '$alias_name' 已存在于 $existing,跳过写入" if test (count $existing) -ne 1
return 0 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 end
# 用 ssh -G 解析最终生效的 HostName/User/Port/Proxy* # 用 ssh -G 解析最终生效的 HostName/User/Port/Proxy*
set -l resolved (ssh -G $args 2>/dev/null) 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 rhost (printf '%s\n' $resolved | __ff_ssh_gval hostname)
set -l ruser (printf '%s\n' $resolved | __ff_ssh_gval user) set -l ruser (printf '%s\n' $resolved | __ff_ssh_gval user)
set -l rport (printf '%s\n' $resolved | __ff_ssh_gval port) 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 "$proxyjump" = none; and set proxyjump ""
test "$proxycommand" = none; and set proxycommand "" test "$proxycommand" = none; and set proxycommand ""
# IdentityFile: ssh -G 会列出所有默认密钥,仅在显式指定时记录 # Explicit -i / -o IdentityFile must never silently fall back to another key.
set -l identity "" set -l explicit_identity ""
set -l explicit_identity (__ff_ssh_argv_value -i $args) 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" if test -n "$explicit_identity"
set identity $explicit_identity # ssh -G can omit an unreadable -i and print defaults instead.
else if string match -q '*IdentityFile*' -- $args set copy_identity (__ff_ssh_private_path "$explicit_identity")
set identity (printf '%s\n' $resolved | __ff_ssh_gval identityfile) set identities
end
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
if string match -q -- "$HOME/*" "$identity"
set identity "~/"(string replace -r "^$HOME/" '' -- $identity)
end end
# ssh-copy-id 也要用同一把密钥(-i 或 -o IdentityFile=),否则会复制默认 key # ssh-copy-id also supports nonstandard id_*.pub names. Among these, keep
set -l copy_identity $explicit_identity # its newest-public-key preference, but only select a complete readable pair.
if test -z "$copy_identity"; and test -n "$identity" if test -z "$copy_identity"; and test -z "$explicit_identity"; and test "$configured_identity" = 0
set copy_identity (string replace -r '^~/' "$HOME/" -- $identity) 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 end
# 先复制公钥,这样写完配置就能直接 ssh
if test "$do_copy" = 1 if test "$do_copy" = 1
__ff_ssh_ensure_keys if test -z "$copy_identity"; and test -z "$explicit_identity"; and test "$configured_identity" = 0
__ff_ssh_copy_id "$target" "$port" "$copy_identity" "$proxyjump" "$proxycommand" __ff_ssh_ensure_keys; or return 1
or echo "ff ssh: 公钥复制失败,已继续写入配置(可重试: ff ssh add $target)" >&2 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 end
set -l dir "$HOME/.ssh/config.d" set -l dir "$HOME/.ssh/config.d"
mkdir -p $dir
chmod 700 "$HOME/.ssh" $dir
set -l conf_file "$dir/$alias_name.conf" set -l conf_file "$dir/$alias_name.conf"
if test -f $conf_file test -n "$existing"; and set conf_file "$existing"
cp -p $conf_file "$conf_file.backup."(date +%Y%m%d%H%M%S)
end
set -l tmpdir /tmp set -l tmpdir /tmp
test -n "$TMPDIR"; and set tmpdir $TMPDIR test -n "$TMPDIR"; and set tmpdir $TMPDIR
@@ -482,7 +607,7 @@ function __ff_ssh_add_host
end end
printf ' Port %s\n' $port printf ' Port %s\n' $port
if test -n "$identity" if test -n "$identity"
printf ' IdentityFile %s\n' $identity printf ' IdentityFile "%s"\n' $identity
end end
if test -n "$proxyjump" if test -n "$proxyjump"
printf ' ProxyJump %s\n' $proxyjump printf ' ProxyJump %s\n' $proxyjump
@@ -491,8 +616,33 @@ function __ff_ssh_add_host
printf ' ProxyCommand %s\n' $proxycommand printf ' ProxyCommand %s\n' $proxycommand
end end
end > $tmp 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 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 配置: $conf_file"
echo "以后可直接运行: ssh $alias_name" echo "以后可直接运行: ssh $alias_name"
@@ -509,7 +659,10 @@ function __ff_ssh_usage
echo " help 显示此帮助" echo " help 显示此帮助"
echo "" echo ""
echo "选项:" echo "选项:"
echo " --no-copy 只写配置,不执行 ssh-copy-id" echo " --no-copy 只写配置,不执行 ssh-copy-id 或登录验证"
echo " 重复别名需交互确认覆盖(默认否),成功后备份并在原位置更新"
echo " 公钥复制后仅用所选密钥验证登录;连接超时 10 秒,失败或取消不写 Host"
echo " 验证使用 BatchMode;加密私钥请先用 ssh-add 解锁"
echo "" echo ""
echo "保留子命令: init / copy / add / run / help(同名主机请用 ssh 直接连接)" echo "保留子命令: init / copy / add / run / help(同名主机请用 ssh 直接连接)"
echo "" echo ""
@@ -530,7 +683,6 @@ function ff_ssh
__ff_ssh_ensure_keys __ff_ssh_ensure_keys
__ff_ssh_ensure_config_d __ff_ssh_ensure_config_d
case add case add
__ff_ssh_ensure_config_d
__ff_ssh_add_host $argv[2..-1] __ff_ssh_add_host $argv[2..-1]
case run case run
set -l run_args set -l run_args
@@ -538,7 +690,6 @@ function ff_ssh
test "$a" = --no-copy; and continue test "$a" = --no-copy; and continue
set run_args $run_args $a set run_args $run_args $a
end end
__ff_ssh_ensure_config_d
__ff_ssh_add_host $argv[2..-1]; or return 1 __ff_ssh_add_host $argv[2..-1]; or return 1
command ssh $run_args command ssh $run_args
case copy case copy
@@ -548,7 +699,6 @@ function ff_ssh
case '*' case '*'
# 裸参数与 ssh 原生参数一致:有主机就记录配置,否则透传给 ssh # 裸参数与 ssh 原生参数一致:有主机就记录配置,否则透传给 ssh
if __ff_ssh_find_target $argv >/dev/null if __ff_ssh_find_target $argv >/dev/null
__ff_ssh_ensure_config_d
__ff_ssh_add_host $argv __ff_ssh_add_host $argv
else else
command ssh $argv command ssh $argv
+488
View File
@@ -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)