Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@

All notable changes to Shuttle are documented here.

## [Unreleased]

### Fixed

- **SSH config parser: quoted values** ([#7](https://github.com/enwaiax/shuttle/issues/7)) — `IdentityFile "~/.ssh/id_rsa"` kept its surrounding quotes, so `resolve_key()` failed to find the key and `shuttle node import` silently skipped the host. Single quotes and quoted paths containing spaces are handled too.
- **SSH config parser: `Key = Value` separator** — a spaced `=` left the `=` inside the value, e.g. `Port = 2222` raised `ValueError` and aborted the entire parse, so no hosts could be imported.

## [0.2.2] - 2026-03-22

### Added
Expand Down
24 changes: 18 additions & 6 deletions src/shuttle/core/ssh_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import re
from dataclasses import dataclass
from pathlib import Path

Expand All @@ -11,6 +12,18 @@
"~/.ssh/id_ecdsa",
]

# Keyword and value are separated by whitespace or `=` (optionally spaced).
# The value captures the rest of the line so quoted paths with spaces survive.
_LINE_RE = re.compile(r"^(\S+?)(?:\s*=\s*|\s+)(.*)$")


def _unquote(value: str) -> str:
"""Strip a single pair of matching outer quotes, as ssh(1) does."""
value = value.strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'"):
return value[1:-1]
return value


@dataclass
class SSHConfigEntry:
Expand Down Expand Up @@ -54,14 +67,13 @@ def parse_ssh_config(path: Path | None = None) -> list[SSHConfigEntry]:
if not line or line.startswith("#"):
continue

# Split on first whitespace or =
parts = line.split(None, 1)
if len(parts) != 2:
parts = line.split("=", 1)
if len(parts) != 2:
match = _LINE_RE.match(line)
if not match:
continue

key, value = parts[0].strip(), parts[1].strip()
key, value = match.group(1), _unquote(match.group(2))
if not value:
continue

if key.lower() == "host":
# Save previous entry
Expand Down
109 changes: 109 additions & 0 deletions tests/test_core/test_ssh_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,79 @@ def test_equals_separator(self, tmp_path: Path) -> None:
assert entries[0].user == "testuser"
assert entries[0].port == 3022

def test_equals_separator_with_spaces(self, tmp_path: Path) -> None:
"""`Key = Value` is valid ssh_config syntax; the `=` must not leak into the value."""
config_file = tmp_path / "config"
config_file.write_text(
"Host eqhost\n"
" HostName = 10.0.0.10\n"
" User = testuser\n"
" Port = 3022\n"
)
entries = parse_ssh_config(config_file)
assert len(entries) == 1
assert entries[0].hostname == "10.0.0.10"
assert entries[0].user == "testuser"
assert entries[0].port == 3022

def test_double_quotes_stripped(self, tmp_path: Path) -> None:
config_file = tmp_path / "config"
config_file.write_text(
"Host quoted\n"
' HostName "10.0.0.1"\n'
' User "admin"\n'
' IdentityFile "~/.ssh/id_rsa"\n'
)
entries = parse_ssh_config(config_file)
assert len(entries) == 1
assert entries[0].hostname == "10.0.0.1"
assert entries[0].user == "admin"
assert entries[0].identity_file == "~/.ssh/id_rsa"

def test_single_quotes_stripped(self, tmp_path: Path) -> None:
config_file = tmp_path / "config"
config_file.write_text(
"Host quoted\n HostName '10.0.0.1'\n IdentityFile '~/.ssh/id_rsa'\n"
)
entries = parse_ssh_config(config_file)
assert len(entries) == 1
assert entries[0].hostname == "10.0.0.1"
assert entries[0].identity_file == "~/.ssh/id_rsa"

def test_quoted_path_with_spaces_preserved(self, tmp_path: Path) -> None:
config_file = tmp_path / "config"
config_file.write_text('Host spaced\n IdentityFile "~/.ssh/my key"\n')
entries = parse_ssh_config(config_file)
assert len(entries) == 1
assert entries[0].identity_file == "~/.ssh/my key"

def test_quoted_equals_separator(self, tmp_path: Path) -> None:
config_file = tmp_path / "config"
config_file.write_text('Host q\n IdentityFile = "~/.ssh/id_rsa"\n')
entries = parse_ssh_config(config_file)
assert len(entries) == 1
assert entries[0].identity_file == "~/.ssh/id_rsa"

def test_inner_quotes_preserved(self, tmp_path: Path) -> None:
"""Only one matching outer pair is stripped; quotes inside stay put."""
config_file = tmp_path / "config"
config_file.write_text('Host q\n IdentityFile ~/.ssh/we"ird\n')
entries = parse_ssh_config(config_file)
assert len(entries) == 1
assert entries[0].identity_file == '~/.ssh/we"ird'

def test_key_without_value_skipped(self, tmp_path: Path) -> None:
"""A bare keyword or an empty quoted value must not become an entry field."""
config_file = tmp_path / "config"
config_file.write_text(
'Host bare\n HostName 10.0.0.1\n IdentityFile\n User ""\n'
)
entries = parse_ssh_config(config_file)
assert len(entries) == 1
assert entries[0].hostname == "10.0.0.1"
assert entries[0].identity_file is None
assert entries[0].user == "root"


class TestSSHConfigEntry:
"""Test SSHConfigEntry.resolve_key method."""
Expand Down Expand Up @@ -199,3 +272,39 @@ def test_resolve_no_keys_at_all(
)
resolved = entry.resolve_key()
assert resolved is None

def test_resolve_quoted_key_from_config(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A quoted IdentityFile parsed from config resolves to the real key."""
ssh_dir = tmp_path / ".ssh"
ssh_dir.mkdir()
key_file = ssh_dir / "quoted_key"
key_file.write_text("KEY_CONTENT")
monkeypatch.setenv("HOME", str(tmp_path))

config_file = tmp_path / "config"
config_file.write_text(
'Host myserver\n HostName 10.0.0.1\n IdentityFile "~/.ssh/quoted_key"\n'
)

entries = parse_ssh_config(config_file)
assert entries[0].resolve_key() == key_file

def test_resolve_quoted_key_with_spaces_from_config(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A quoted IdentityFile containing spaces resolves to the real key."""
ssh_dir = tmp_path / ".ssh"
ssh_dir.mkdir()
key_file = ssh_dir / "my key"
key_file.write_text("KEY_CONTENT")
monkeypatch.setenv("HOME", str(tmp_path))

config_file = tmp_path / "config"
config_file.write_text(
'Host myserver\n HostName 10.0.0.1\n IdentityFile "~/.ssh/my key"\n'
)

entries = parse_ssh_config(config_file)
assert entries[0].resolve_key() == key_file
Loading