Shuttle's SSH config parser (src/shuttle/core/ssh_config.py) leaves surrounding quotes on IdentityFile values. Because the quotes are not removed, SSHConfigEntry.resolve_key() tries to resolve a literal path that still contains the quote characters, and fails to find the key file.
Reproduction
With the following ~/.ssh/config:
Host myserver
HostName 10.0.0.1
IdentityFile "~/.ssh/id_rsa"
Run:
from pathlib import Path
from shuttle.core.ssh_config import parse_ssh_config
# Make sure the key file exists
key = Path.home() / ".ssh" / "id_rsa"
key.write_text("KEY")
entries = parse_ssh_config()
print(entries[0].identity_file) # '"~/.ssh/id_rsa"' (includes double quotes)
print(entries[0].resolve_key()) # None
Expected behavior
entry.identity_file should be ~/.ssh/id_rsa (without the surrounding double quotes), and entry.resolve_key() should return the actual key path.
Actual behavior
entry.identity_file is "~/.ssh/id_rsa" (the quotes are part of the string), and resolve_key() returns None.
Additional notes
- The same problem occurs with single quotes:
IdentityFile '~/.ssh/id_rsa'.
- Paths containing spaces, e.g.
IdentityFile "~/.ssh/my key", are also mishandled. The current parser uses str.split(None, 1), which splits at the first whitespace inside the quoted value and drops the rest of the path.
Environment
- macOS
- Python 3.12+
- Shuttle main branch
Proposed solution
Replace the ad-hoc split(None, 1) / split("=", 1) logic with a regex that captures the whole rest of the line, so quoted values containing spaces are preserved. Then strip a single pair of matching outer quotes (" or ') from every value.
In src/shuttle/core/ssh_config.py:
-
Add a line regex that treats whitespace or = as the key/value separator, allowing optional spaces around =:
_LINE_RE = re.compile(r"^(\S+?)(?:\s*=\s*|\s+)(.*)$")
-
Add a small _unquote helper:
def _unquote(value: str) -> str:
value = value.strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'"):
return value[1:-1]
return value
-
In the parse loop, match the line with _LINE_RE, then unquote the captured value and skip empty values:
match = _LINE_RE.match(line)
if not match:
continue
key, value = match.group(1).strip(), _unquote(match.group(2))
if not value:
continue
This handles:
IdentityFile "~/.ssh/id_rsa" → ~/.ssh/id_rsa
IdentityFile "~/.ssh/my key" → ~/.ssh/my key
IdentityFile = "~/.ssh/id_rsa" → ~/.ssh/id_rsa
Tests to add
In tests/test_core/test_ssh_config.py, add cases that:
- Parse
IdentityFile "~/.ssh/quoted_key" and verify the quotes are stripped and resolve_key() finds the file.
- Parse
IdentityFile "~/.ssh/my key" (with spaces inside the quotes) and verify the full path is kept and resolve_key() finds the file.
Offer to contribute
If the maintainers accept this approach, I’m happy to open a PR with the proposed fix and the corresponding tests.
Shuttle's SSH config parser (
src/shuttle/core/ssh_config.py) leaves surrounding quotes onIdentityFilevalues. Because the quotes are not removed,SSHConfigEntry.resolve_key()tries to resolve a literal path that still contains the quote characters, and fails to find the key file.Reproduction
With the following
~/.ssh/config:Run:
Expected behavior
entry.identity_fileshould be~/.ssh/id_rsa(without the surrounding double quotes), andentry.resolve_key()should return the actual key path.Actual behavior
entry.identity_fileis"~/.ssh/id_rsa"(the quotes are part of the string), andresolve_key()returnsNone.Additional notes
IdentityFile '~/.ssh/id_rsa'.IdentityFile "~/.ssh/my key", are also mishandled. The current parser usesstr.split(None, 1), which splits at the first whitespace inside the quoted value and drops the rest of the path.Environment
Proposed solution
Replace the ad-hoc
split(None, 1)/split("=", 1)logic with a regex that captures the whole rest of the line, so quoted values containing spaces are preserved. Then strip a single pair of matching outer quotes ("or') from every value.In
src/shuttle/core/ssh_config.py:Add a line regex that treats whitespace or
=as the key/value separator, allowing optional spaces around=:Add a small
_unquotehelper:In the parse loop, match the line with
_LINE_RE, then unquote the captured value and skip empty values:This handles:
IdentityFile "~/.ssh/id_rsa"→~/.ssh/id_rsaIdentityFile "~/.ssh/my key"→~/.ssh/my keyIdentityFile = "~/.ssh/id_rsa"→~/.ssh/id_rsaTests to add
In
tests/test_core/test_ssh_config.py, add cases that:IdentityFile "~/.ssh/quoted_key"and verify the quotes are stripped andresolve_key()finds the file.IdentityFile "~/.ssh/my key"(with spaces inside the quotes) and verify the full path is kept andresolve_key()finds the file.Offer to contribute
If the maintainers accept this approach, I’m happy to open a PR with the proposed fix and the corresponding tests.