From d5643edc2f877b54a7aa76add720aabfdb7abe3f Mon Sep 17 00:00:00 2001 From: enwaiax Date: Sat, 22 Aug 2026 16:11:10 +0800 Subject: [PATCH] fix(ssh-config): strip quotes and handle spaced `=` separator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `IdentityFile "~/.ssh/id_rsa"` kept its surrounding quotes, so `resolve_key()` looked for a literal path containing `"` characters, found nothing, and silently fell back to the default key paths — the host was reported as key-less by `shuttle node import`. The same `split(None, 1)` logic also mishandled a spaced `=` separator: `Port = 2222` parsed as the value `"= 2222"`, and `_build_entry` then raised `ValueError` from `int()`, aborting the whole parse so no hosts could be imported at all. Replace the ad-hoc splitting with a line regex that accepts either whitespace or an optionally-spaced `=` and captures the rest of the line, then strip a single pair of matching outer quotes. Values that are empty after unquoting are skipped. Closes #7 --- CHANGELOG.md | 7 ++ src/shuttle/core/ssh_config.py | 24 +++++-- tests/test_core/test_ssh_config.py | 109 +++++++++++++++++++++++++++++ 3 files changed, 134 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a401b34..538d11d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/shuttle/core/ssh_config.py b/src/shuttle/core/ssh_config.py index ecad8fa..eac36e1 100644 --- a/src/shuttle/core/ssh_config.py +++ b/src/shuttle/core/ssh_config.py @@ -2,6 +2,7 @@ from __future__ import annotations +import re from dataclasses import dataclass from pathlib import Path @@ -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: @@ -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 diff --git a/tests/test_core/test_ssh_config.py b/tests/test_core/test_ssh_config.py index f151ef2..8bdca15 100644 --- a/tests/test_core/test_ssh_config.py +++ b/tests/test_core/test_ssh_config.py @@ -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.""" @@ -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