Files

489 lines
22 KiB
Python

#!/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)