Files
Riccardo Schirone 9895dd9d5e Fix known_hosts removal for hashed entries (#28)
Replace manual file parsing with ssh-keygen -R which properly handles
both hashed (|1|...) and unhashed entries. macOS and many Linux systems
use HashKnownHosts by default, making the previous implementation unable
to remove entries.

Changes:
- Use ssh-keygen -R for each hostname instead of parsing the file
- Detect successful removal via "updated" in stdout
- Backup files are now .old (ssh-keygen default) instead of .bak
- Bracketed entries ([host]:port) now require exact format

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 15:58:05 -05:00

1050 lines
33 KiB
Python

"""Comprehensive tests for SSH config management."""
from pathlib import Path
import pytest
from dropkit.ssh_config import (
add_ssh_host,
get_ssh_host_ip,
remove_known_hosts_entry,
remove_ssh_host,
)
@pytest.fixture
def temp_ssh_dir(tmp_path):
"""Create a temporary SSH directory for testing."""
ssh_dir = tmp_path / ".ssh"
ssh_dir.mkdir(mode=0o700)
yield ssh_dir
# Cleanup is automatic with tmp_path
@pytest.fixture
def temp_config(temp_ssh_dir):
"""Create a temporary SSH config file path."""
config_path = temp_ssh_dir / "config"
return str(config_path)
class TestAddSSHHost:
"""Tests for add_ssh_host function."""
def test_empty_file(self, temp_config):
"""Test adding host to empty SSH config file."""
# Create empty file
Path(temp_config).touch(mode=0o600)
add_ssh_host(temp_config, "myhost", "192.168.1.1", "ubuntu")
content = Path(temp_config).read_text()
assert "Host myhost" in content
assert "HostName 192.168.1.1" in content
assert "User ubuntu" in content
def test_non_existent_file(self, temp_config):
"""Test creating new file and directory if they don't exist."""
# Ensure file doesn't exist
assert not Path(temp_config).exists()
add_ssh_host(temp_config, "myhost", "192.168.1.1", "ubuntu", "~/.ssh/id_ed25519")
assert Path(temp_config).exists()
content = Path(temp_config).read_text()
assert "Host myhost" in content
assert "IdentityFile ~/.ssh/id_ed25519" in content
def test_simple_addition(self, temp_config):
"""Test adding host to config with existing unrelated hosts."""
# Create config with existing host
existing = """Host other-server
HostName 10.0.0.1
User admin
Host another-host
HostName 10.0.0.2
User root
"""
Path(temp_config).write_text(existing)
add_ssh_host(temp_config, "myhost", "192.168.1.1", "ubuntu")
content = Path(temp_config).read_text()
assert "Host other-server" in content
assert "Host another-host" in content
assert "Host myhost" in content
# Count "Host " with space to avoid matching "HostName"
assert content.count("Host ") == 3
def test_update_existing_host(self, temp_config):
"""Test replacing existing host entry without duplication."""
# Create config with existing host
existing = """Host myhost
HostName 10.0.0.1
User old_user
Host other-host
HostName 10.0.0.2
User admin
"""
Path(temp_config).write_text(existing)
add_ssh_host(temp_config, "myhost", "192.168.1.1", "new_user")
content = Path(temp_config).read_text()
assert content.count("Host myhost") == 1 # Should not duplicate
assert "HostName 192.168.1.1" in content
assert "User new_user" in content
assert "old_user" not in content
assert "10.0.0.1" not in content
assert "Host other-host" in content # Other hosts preserved
def test_host_with_similar_names(self, temp_config):
"""Test that similar host names don't interfere."""
existing = """Host myhost
HostName 10.0.0.1
User user1
Host myhost-dev
HostName 10.0.0.2
User user2
Host myhost-prod
HostName 10.0.0.3
User user3
"""
Path(temp_config).write_text(existing)
add_ssh_host(temp_config, "myhost", "192.168.1.1", "newuser")
content = Path(temp_config).read_text()
assert "Host myhost-dev" in content
assert "10.0.0.2" in content
assert "Host myhost-prod" in content
assert "10.0.0.3" in content
# myhost should be updated
lines = content.split("\n")
myhost_line = [i for i, line in enumerate(lines) if line.strip() == "Host myhost"][-1]
# Check that the next few lines after myhost contain the new values
section = "\n".join(lines[myhost_line : myhost_line + 5])
assert "192.168.1.1" in section
assert "newuser" in section
def test_host_as_substring(self, temp_config):
"""Test that substring hosts are handled correctly."""
existing = """Host prod-server
HostName 10.0.0.1
User admin
Host prod
HostName 10.0.0.2
User root
"""
Path(temp_config).write_text(existing)
add_ssh_host(temp_config, "prod", "192.168.1.1", "ubuntu")
content = Path(temp_config).read_text()
assert "Host prod-server" in content
assert "10.0.0.1" in content
# prod should be updated
assert content.count("Host prod\n") >= 1
def test_multiple_blank_lines(self, temp_config):
"""Test handling config with various blank lines."""
existing = """Host server1
HostName 10.0.0.1
User admin
Host server2
HostName 10.0.0.2
User root
Host server3
HostName 10.0.0.3
User ubuntu
"""
Path(temp_config).write_text(existing)
add_ssh_host(temp_config, "newhost", "192.168.1.1", "user")
content = Path(temp_config).read_text()
assert "Host server1" in content
assert "Host server2" in content
assert "Host server3" in content
assert "Host newhost" in content
def test_comments_in_config(self, temp_config):
"""Test preserving comments in config."""
existing = """# This is a comment
Host server1
HostName 10.0.0.1
User admin
# Another comment
# Global settings
Host server2
HostName 10.0.0.2
User root
"""
Path(temp_config).write_text(existing)
add_ssh_host(temp_config, "newhost", "192.168.1.1", "user")
content = Path(temp_config).read_text()
assert "# This is a comment" in content
assert "# Another comment" in content
assert "# Global settings" in content
def test_mixed_indentation(self, temp_config):
"""Test handling tabs and spaces."""
existing = """Host server1
HostName 10.0.0.1
User admin
Host server2
HostName 10.0.0.2
User root
"""
Path(temp_config).write_text(existing)
add_ssh_host(temp_config, "newhost", "192.168.1.1", "user")
content = Path(temp_config).read_text()
# Original hosts should be preserved
assert "Host server1" in content
assert "Host server2" in content
# New host should be added
assert "Host newhost" in content
def test_host_at_beginning(self, temp_config):
"""Test updating a host that's the first entry."""
existing = """Host first-host
HostName 10.0.0.1
User admin
Host second-host
HostName 10.0.0.2
User root
"""
Path(temp_config).write_text(existing)
add_ssh_host(temp_config, "first-host", "192.168.1.1", "newuser")
content = Path(temp_config).read_text()
assert content.count("Host first-host") == 1
assert "192.168.1.1" in content
assert "10.0.0.1" not in content
assert "Host second-host" in content
def test_host_at_end(self, temp_config):
"""Test updating a host that's the last entry."""
existing = """Host first-host
HostName 10.0.0.1
User admin
Host last-host
HostName 10.0.0.2
User root
"""
Path(temp_config).write_text(existing)
add_ssh_host(temp_config, "last-host", "192.168.1.1", "newuser")
content = Path(temp_config).read_text()
assert "Host first-host" in content
assert content.count("Host last-host") == 1
assert "192.168.1.1" in content
assert "10.0.0.2" not in content
def test_host_in_middle(self, temp_config):
"""Test updating a host between others."""
existing = """Host first-host
HostName 10.0.0.1
User admin
Host middle-host
HostName 10.0.0.2
User root
Host last-host
HostName 10.0.0.3
User ubuntu
"""
Path(temp_config).write_text(existing)
add_ssh_host(temp_config, "middle-host", "192.168.1.1", "newuser")
content = Path(temp_config).read_text()
assert "Host first-host" in content
assert "Host last-host" in content
assert content.count("Host middle-host") == 1
assert "192.168.1.1" in content
assert "10.0.0.2" not in content
def test_complex_host_entries(self, temp_config):
"""Test host with many configuration options."""
existing = """Host complex-host
HostName 10.0.0.1
User admin
Port 2222
ForwardAgent yes
ProxyJump bastion
IdentityFile ~/.ssh/custom_key
StrictHostKeyChecking no
"""
Path(temp_config).write_text(existing)
add_ssh_host(temp_config, "complex-host", "192.168.1.1", "newuser", "~/.ssh/new_key")
content = Path(temp_config).read_text()
assert content.count("Host complex-host") == 1
assert "192.168.1.1" in content
assert "newuser" in content
# Old complex options should be replaced
assert "Port 2222" not in content
def test_host_with_extra_spaces(self, temp_config):
"""Test host declaration with multiple spaces."""
existing = """Host server-with-spaces
HostName 10.0.0.1
User admin
"""
Path(temp_config).write_text(existing)
# This won't match because our check is exact
add_ssh_host(temp_config, "server-with-spaces", "192.168.1.1", "newuser")
content = Path(temp_config).read_text()
# Should add a new entry since "Host server-with-spaces" != "Host server-with-spaces"
assert "Host server-with-spaces" in content
def test_host_patterns(self, temp_config):
"""Test existing wildcard hosts."""
existing = """Host *.example.com
User admin
IdentityFile ~/.ssh/example_key
Host myserver
HostName 10.0.0.1
User root
"""
Path(temp_config).write_text(existing)
add_ssh_host(temp_config, "newhost", "192.168.1.1", "ubuntu")
content = Path(temp_config).read_text()
assert "Host *.example.com" in content
assert "Host myserver" in content
assert "Host newhost" in content
def test_no_trailing_newline(self, temp_config):
"""Test config file without final newline."""
existing = """Host myhost
HostName 10.0.0.1
User admin""" # No trailing newline
Path(temp_config).write_text(existing)
add_ssh_host(temp_config, "newhost", "192.168.1.1", "ubuntu")
content = Path(temp_config).read_text()
assert "Host myhost" in content
assert "Host newhost" in content
# Verify Host newhost is on its own line (not concatenated to previous line)
lines = content.split("\n")
host_lines = [line for line in lines if "Host newhost" in line]
assert len(host_lines) == 1
assert host_lines[0].strip() == "Host newhost"
def test_permissions_preserved(self, temp_config):
"""Test that file permissions are set correctly."""
Path(temp_config).touch(mode=0o644)
add_ssh_host(temp_config, "myhost", "192.168.1.1", "ubuntu")
# File should now have 0600 permissions
mode = Path(temp_config).stat().st_mode & 0o777
assert mode == 0o600
def test_backup_created(self, temp_config):
"""Test that backup file is created."""
existing = """Host oldhost
HostName 10.0.0.1
User admin
"""
Path(temp_config).write_text(existing)
Path(temp_config).chmod(0o600)
add_ssh_host(temp_config, "newhost", "192.168.1.1", "ubuntu")
backup_path = Path(temp_config).parent / "config.bak"
assert backup_path.exists()
backup_content = backup_path.read_text()
assert "Host oldhost" in backup_content
assert "Host newhost" not in backup_content
# Check backup has same permissions
backup_mode = backup_path.stat().st_mode & 0o777
assert backup_mode == 0o600
class TestRemoveSSHHost:
"""Tests for remove_ssh_host function."""
def test_non_existent_file(self, temp_config):
"""Test removing from non-existent config."""
assert not Path(temp_config).exists()
result = remove_ssh_host(temp_config, "myhost")
assert result is False
def test_host_doesnt_exist(self, temp_config):
"""Test removing host that's not in config."""
existing = """Host server1
HostName 10.0.0.1
User admin
"""
Path(temp_config).write_text(existing)
result = remove_ssh_host(temp_config, "nonexistent")
assert result is False
content = Path(temp_config).read_text()
assert "Host server1" in content # Should be unchanged
def test_remove_first_host(self, temp_config):
"""Test removing the first host entry."""
existing = """Host first-host
HostName 10.0.0.1
User admin
Host second-host
HostName 10.0.0.2
User root
Host third-host
HostName 10.0.0.3
User ubuntu
"""
Path(temp_config).write_text(existing)
result = remove_ssh_host(temp_config, "first-host")
assert result is True
content = Path(temp_config).read_text()
assert "Host first-host" not in content
assert "Host second-host" in content
assert "Host third-host" in content
def test_remove_last_host(self, temp_config):
"""Test removing the last host entry."""
existing = """Host first-host
HostName 10.0.0.1
User admin
Host second-host
HostName 10.0.0.2
User root
Host last-host
HostName 10.0.0.3
User ubuntu
"""
Path(temp_config).write_text(existing)
result = remove_ssh_host(temp_config, "last-host")
assert result is True
content = Path(temp_config).read_text()
assert "Host first-host" in content
assert "Host second-host" in content
assert "Host last-host" not in content
def test_remove_middle_host(self, temp_config):
"""Test removing a host between others."""
existing = """Host first-host
HostName 10.0.0.1
User admin
Host middle-host
HostName 10.0.0.2
User root
Host last-host
HostName 10.0.0.3
User ubuntu
"""
Path(temp_config).write_text(existing)
result = remove_ssh_host(temp_config, "middle-host")
assert result is True
content = Path(temp_config).read_text()
assert "Host first-host" in content
assert "Host middle-host" not in content
assert "Host last-host" in content
def test_remove_only_host(self, temp_config):
"""Test removing the only host in config."""
existing = """Host only-host
HostName 10.0.0.1
User admin
"""
Path(temp_config).write_text(existing)
result = remove_ssh_host(temp_config, "only-host")
assert result is True
content = Path(temp_config).read_text()
assert "Host only-host" not in content
# File should be essentially empty or just whitespace
assert content.strip() == "" or "Host" not in content
def test_remove_similar_host_names(self, temp_config):
"""Test removing specific host when similar names exist."""
existing = """Host myhost
HostName 10.0.0.1
User user1
Host myhost-dev
HostName 10.0.0.2
User user2
Host myhost-prod
HostName 10.0.0.3
User user3
"""
Path(temp_config).write_text(existing)
result = remove_ssh_host(temp_config, "myhost")
assert result is True
content = Path(temp_config).read_text()
assert "Host myhost\n" not in content
assert "10.0.0.1" not in content
assert "Host myhost-dev" in content
assert "10.0.0.2" in content
assert "Host myhost-prod" in content
assert "10.0.0.3" in content
def test_remove_host_with_many_options(self, temp_config):
"""Test removing host with many configuration lines."""
existing = """Host simple-host
HostName 10.0.0.1
User admin
Host complex-host
HostName 10.0.0.2
User root
Port 2222
ForwardAgent yes
ProxyJump bastion
IdentityFile ~/.ssh/custom_key
StrictHostKeyChecking no
LocalForward 8080 localhost:80
Host another-host
HostName 10.0.0.3
User ubuntu
"""
Path(temp_config).write_text(existing)
result = remove_ssh_host(temp_config, "complex-host")
assert result is True
content = Path(temp_config).read_text()
assert "Host simple-host" in content
assert "Host complex-host" not in content
assert "Port 2222" not in content
assert "ProxyJump bastion" not in content
assert "Host another-host" in content
def test_backup_created_on_remove(self, temp_config):
"""Test that backup is created when removing host."""
existing = """Host myhost
HostName 10.0.0.1
User admin
Host otherhost
HostName 10.0.0.2
User root
"""
Path(temp_config).write_text(existing)
Path(temp_config).chmod(0o600)
remove_ssh_host(temp_config, "myhost")
backup_path = Path(temp_config).parent / "config.bak"
assert backup_path.exists()
backup_content = backup_path.read_text()
assert "Host myhost" in backup_content
assert "Host otherhost" in backup_content
# Check backup has same permissions
backup_mode = backup_path.stat().st_mode & 0o777
assert backup_mode == 0o600
def test_remove_preserves_comments(self, temp_config):
"""Test that comments are preserved when removing host."""
existing = """# Global comment
Host keephost
HostName 10.0.0.1
User admin
# This host will be removed
Host removehost
HostName 10.0.0.2
User root
# Another comment
Host anotherhost
HostName 10.0.0.3
User ubuntu
"""
Path(temp_config).write_text(existing)
result = remove_ssh_host(temp_config, "removehost")
assert result is True
content = Path(temp_config).read_text()
assert "# Global comment" in content
assert "# Another comment" in content
assert "Host keephost" in content
assert "Host removehost" not in content
assert "Host anotherhost" in content
class TestGetSSHHostIP:
"""Tests for get_ssh_host_ip function."""
def test_valid_host(self, temp_config):
"""Test getting IP for existing host."""
existing = """Host myhost
HostName 192.168.1.100
User ubuntu
"""
Path(temp_config).write_text(existing)
result = get_ssh_host_ip(temp_config, "myhost")
assert result == "192.168.1.100"
def test_host_not_found(self, temp_config):
"""Test getting IP for non-existent host."""
existing = """Host otherhost
HostName 192.168.1.100
User ubuntu
"""
Path(temp_config).write_text(existing)
result = get_ssh_host_ip(temp_config, "myhost")
assert result is None
def test_missing_hostname_field(self, temp_config):
"""Test host entry without HostName field."""
existing = """Host myhost
User ubuntu
IdentityFile ~/.ssh/id_rsa
"""
Path(temp_config).write_text(existing)
result = get_ssh_host_ip(temp_config, "myhost")
assert result is None
def test_non_existent_file(self, temp_config):
"""Test with non-existent config file."""
result = get_ssh_host_ip(temp_config, "myhost")
assert result is None
def test_tailscale_ip(self, temp_config):
"""Test getting Tailscale IP address."""
existing = """Host dropkit.myhost
HostName 100.80.123.45
User ubuntu
ForwardAgent yes
"""
Path(temp_config).write_text(existing)
result = get_ssh_host_ip(temp_config, "dropkit.myhost")
assert result == "100.80.123.45"
def test_multiple_hosts(self, temp_config):
"""Test getting IP from config with multiple hosts."""
existing = """Host firsthost
HostName 10.0.0.1
User admin
Host targethost
HostName 192.168.1.50
User ubuntu
Host thirdhost
HostName 10.0.0.3
User root
"""
Path(temp_config).write_text(existing)
result = get_ssh_host_ip(temp_config, "targethost")
assert result == "192.168.1.50"
def test_host_with_extra_whitespace(self, temp_config):
"""Test HostName with extra whitespace."""
existing = """Host myhost
HostName 192.168.1.100
User ubuntu
"""
Path(temp_config).write_text(existing)
result = get_ssh_host_ip(temp_config, "myhost")
assert result == "192.168.1.100"
def test_multiple_hosts_on_same_line(self, temp_config):
"""Test host with multiple aliases on same line."""
existing = """Host myhost myalias anotherhost
HostName 192.168.1.100
User ubuntu
"""
Path(temp_config).write_text(existing)
# Should work for any of the aliases
assert get_ssh_host_ip(temp_config, "myhost") == "192.168.1.100"
assert get_ssh_host_ip(temp_config, "myalias") == "192.168.1.100"
assert get_ssh_host_ip(temp_config, "anotherhost") == "192.168.1.100"
def test_hostname_vs_host(self, temp_config):
"""Test that we distinguish between Host directive and HostName option."""
existing = """Host realhost
HostName 192.168.1.100
User ubuntu
Host anotherreal
HostName 10.0.0.1
User admin
"""
Path(temp_config).write_text(existing)
# Should not find HostName as a host alias
assert get_ssh_host_ip(temp_config, "192.168.1.100") is None
# Should find the actual hosts
assert get_ssh_host_ip(temp_config, "realhost") == "192.168.1.100"
class TestRemoveKnownHostsEntry:
"""Tests for remove_known_hosts_entry function."""
@pytest.fixture
def temp_known_hosts(self, temp_ssh_dir):
"""Create a temporary known_hosts file path."""
known_hosts_path = temp_ssh_dir / "known_hosts"
return str(known_hosts_path)
def test_non_existent_file(self, temp_known_hosts):
"""Test removing from non-existent known_hosts."""
assert not Path(temp_known_hosts).exists()
result = remove_known_hosts_entry(temp_known_hosts, ["myhost"])
assert result == 0
def test_no_matching_entries(self, temp_known_hosts):
"""Test removing hostname that's not in known_hosts."""
existing = """otherhost ssh-ed25519 AAAA...
192.168.1.1 ssh-rsa AAAA...
"""
Path(temp_known_hosts).write_text(existing)
result = remove_known_hosts_entry(temp_known_hosts, ["myhost"])
assert result == 0
content = Path(temp_known_hosts).read_text()
assert "otherhost" in content
assert "192.168.1.1" in content
def test_remove_single_hostname(self, temp_known_hosts):
"""Test removing a single hostname entry."""
existing = """myhost ssh-ed25519 AAAA...
otherhost ssh-rsa AAAA...
"""
Path(temp_known_hosts).write_text(existing)
result = remove_known_hosts_entry(temp_known_hosts, ["myhost"])
assert result == 1
content = Path(temp_known_hosts).read_text()
assert "myhost" not in content
assert "otherhost" in content
def test_remove_ip_address(self, temp_known_hosts):
"""Test removing an IP address entry."""
existing = """192.168.1.100 ssh-ed25519 AAAA...
10.0.0.1 ssh-rsa AAAA...
"""
Path(temp_known_hosts).write_text(existing)
result = remove_known_hosts_entry(temp_known_hosts, ["192.168.1.100"])
assert result == 1
content = Path(temp_known_hosts).read_text()
assert "192.168.1.100" not in content
assert "10.0.0.1" in content
def test_remove_multiple_entries(self, temp_known_hosts):
"""Test removing hostname and IP address together."""
existing = """dropkit.myhost ssh-ed25519 AAAA...
100.80.123.45 ssh-ed25519 AAAA...
otherhost ssh-rsa AAAA...
"""
Path(temp_known_hosts).write_text(existing)
result = remove_known_hosts_entry(temp_known_hosts, ["dropkit.myhost", "100.80.123.45"])
assert result == 2
content = Path(temp_known_hosts).read_text()
assert "dropkit.myhost" not in content
assert "100.80.123.45" not in content
assert "otherhost" in content
def test_comma_separated_hostnames(self, temp_known_hosts):
"""Test removing entry with comma-separated hostnames."""
existing = """myhost,192.168.1.100 ssh-ed25519 AAAA...
otherhost ssh-rsa AAAA...
"""
Path(temp_known_hosts).write_text(existing)
# Should match by hostname
result = remove_known_hosts_entry(temp_known_hosts, ["myhost"])
assert result == 1
content = Path(temp_known_hosts).read_text()
assert "myhost" not in content
assert "192.168.1.100" not in content # Whole line removed
assert "otherhost" in content
def test_comma_separated_match_by_ip(self, temp_known_hosts):
"""Test removing entry by IP when comma-separated."""
existing = """myhost,192.168.1.100 ssh-ed25519 AAAA...
otherhost ssh-rsa AAAA...
"""
Path(temp_known_hosts).write_text(existing)
# Should match by IP
result = remove_known_hosts_entry(temp_known_hosts, ["192.168.1.100"])
assert result == 1
content = Path(temp_known_hosts).read_text()
assert "myhost" not in content
assert "192.168.1.100" not in content
assert "otherhost" in content
def test_bracketed_entry(self, temp_known_hosts):
"""Test removing bracketed entry like [hostname]:port.
Note: ssh-keygen -R requires the exact hostname format, so bracketed
entries must be passed as "[hostname]:port", not just "hostname".
"""
existing = """[myhost]:2222 ssh-ed25519 AAAA...
otherhost ssh-rsa AAAA...
"""
Path(temp_known_hosts).write_text(existing)
# Must use exact format for bracketed entries
result = remove_known_hosts_entry(temp_known_hosts, ["[myhost]:2222"])
assert result == 1
content = Path(temp_known_hosts).read_text()
assert "[myhost]:2222" not in content
assert "otherhost" in content
def test_bracketed_ip_entry(self, temp_known_hosts):
"""Test removing bracketed IP entry like [192.168.1.1]:2222.
Note: ssh-keygen -R requires the exact hostname format, so bracketed
entries must be passed as "[ip]:port", not just "ip".
"""
existing = """[192.168.1.100]:2222 ssh-ed25519 AAAA...
otherhost ssh-rsa AAAA...
"""
Path(temp_known_hosts).write_text(existing)
# Must use exact format for bracketed entries
result = remove_known_hosts_entry(temp_known_hosts, ["[192.168.1.100]:2222"])
assert result == 1
content = Path(temp_known_hosts).read_text()
assert "192.168.1.100" not in content
assert "otherhost" in content
def test_hashed_entries_preserved(self, temp_known_hosts):
"""Test that hashed entries (|1|...) are preserved when removing other hosts."""
import subprocess
# Create real hashed entries by hashing actual hostnames
existing = """other1.example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITest1
myhost ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQTest
other2.example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITest2
"""
Path(temp_known_hosts).write_text(existing)
# Hash the file to create real |1|... entries
subprocess.run(
["ssh-keygen", "-H", "-f", str(temp_known_hosts)],
capture_output=True,
check=True,
)
# Remove the .old backup file ssh-keygen creates
old_file = Path(temp_known_hosts + ".old")
if old_file.exists():
old_file.unlink()
# Verify we have hashed entries (all should be hashed now)
content = Path(temp_known_hosts).read_text()
lines = [line for line in content.strip().split("\n") if line]
assert len(lines) == 3
assert all(line.startswith("|1|") for line in lines)
# Now remove "myhost" - the other two hashed entries should remain
result = remove_known_hosts_entry(temp_known_hosts, ["myhost"])
assert result == 1
content = Path(temp_known_hosts).read_text()
lines = [line for line in content.strip().split("\n") if line]
# Should have exactly 2 entries remaining (other1 and other2)
assert len(lines) == 2
assert all(line.startswith("|1|") for line in lines)
def test_remove_hashed_entry(self, temp_known_hosts):
"""Test removing a hashed known_hosts entry by hostname.
This tests the actual bug: macOS/Linux often use HashKnownHosts yes,
which stores entries like |1|<salt>|<hash> instead of plaintext hostnames.
The function should still be able to remove these entries.
"""
import subprocess
hostname = "test.example.com"
# Create an unhashed entry first
unhashed = f"{hostname} ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITest\n"
Path(temp_known_hosts).write_text(unhashed)
# Hash it using ssh-keygen -H
subprocess.run(
["ssh-keygen", "-H", "-f", str(temp_known_hosts)],
capture_output=True,
check=True,
)
# Remove the .old backup file ssh-keygen creates
old_file = Path(temp_known_hosts + ".old")
if old_file.exists():
old_file.unlink()
# Verify entry is now hashed
content = Path(temp_known_hosts).read_text()
assert content.startswith("|1|"), f"Entry should be hashed, got: {content}"
assert hostname not in content # Hostname should not appear in plaintext
# Now try to remove by hostname - this is the bug test
result = remove_known_hosts_entry(temp_known_hosts, [hostname])
assert result == 1, "Should have removed the hashed entry"
content = Path(temp_known_hosts).read_text()
assert content.strip() == "", f"File should be empty after removal, got: {content}"
def test_comments_preserved(self, temp_known_hosts):
"""Test that comments are preserved."""
existing = """# This is a comment
myhost ssh-ed25519 AAAA...
# Another comment
otherhost ssh-rsa AAAA...
"""
Path(temp_known_hosts).write_text(existing)
result = remove_known_hosts_entry(temp_known_hosts, ["myhost"])
assert result == 1
content = Path(temp_known_hosts).read_text()
assert "# This is a comment" in content
assert "# Another comment" in content
assert "myhost" not in content
assert "otherhost" in content
def test_empty_lines_preserved(self, temp_known_hosts):
"""Test that empty lines are preserved."""
existing = """myhost ssh-ed25519 AAAA...
otherhost ssh-rsa AAAA...
"""
Path(temp_known_hosts).write_text(existing)
result = remove_known_hosts_entry(temp_known_hosts, ["myhost"])
assert result == 1
content = Path(temp_known_hosts).read_text()
assert "myhost" not in content
assert "otherhost" in content
# Should preserve structure
assert "\n\n" in content or content.count("\n") >= 2
def test_case_insensitive_matching(self, temp_known_hosts):
"""Test that hostname matching is case-insensitive."""
existing = """MyHost ssh-ed25519 AAAA...
otherhost ssh-rsa AAAA...
"""
Path(temp_known_hosts).write_text(existing)
result = remove_known_hosts_entry(temp_known_hosts, ["myhost"])
assert result == 1
content = Path(temp_known_hosts).read_text()
assert "MyHost" not in content
assert "otherhost" in content
def test_backup_created(self, temp_known_hosts):
"""Test that backup file is created."""
existing = """myhost ssh-ed25519 AAAA...
otherhost ssh-rsa AAAA...
"""
Path(temp_known_hosts).write_text(existing)
Path(temp_known_hosts).chmod(0o600)
remove_known_hosts_entry(temp_known_hosts, ["myhost"])
# ssh-keygen -R creates .old backup (not .bak)
backup_path = Path(temp_known_hosts + ".old")
assert backup_path.exists()
backup_content = backup_path.read_text()
assert "myhost" in backup_content
assert "otherhost" in backup_content
def test_remove_all_entries(self, temp_known_hosts):
"""Test removing all entries from known_hosts."""
existing = """myhost ssh-ed25519 AAAA...
192.168.1.100 ssh-rsa AAAA...
"""
Path(temp_known_hosts).write_text(existing)
result = remove_known_hosts_entry(temp_known_hosts, ["myhost", "192.168.1.100"])
assert result == 2
content = Path(temp_known_hosts).read_text()
assert content.strip() == ""
def test_dropkit_hostname_format(self, temp_known_hosts):
"""Test removing dropkit-style hostname."""
existing = """dropkit.my-droplet ssh-ed25519 AAAA...
100.80.123.45 ssh-ed25519 AAAA...
otherhost ssh-rsa AAAA...
"""
Path(temp_known_hosts).write_text(existing)
result = remove_known_hosts_entry(temp_known_hosts, ["dropkit.my-droplet", "100.80.123.45"])
assert result == 2
content = Path(temp_known_hosts).read_text()
assert "dropkit.my-droplet" not in content
assert "100.80.123.45" not in content
assert "otherhost" in content