Skip to content

Commit 0a3bb5a

Browse files
committed
feat: quote SSH config values so special characters save reliably
The SSH config writer now wraps single-token directive values (HostName, User, IdentityFile, ProxyJump, CertificateFile) in double quotes when they contain whitespace or a quote, escaping embedded backslash and quote. The parser unwraps and unescapes symmetrically, so values with spaces, a "#" or quotes round-trip exactly instead of being split or truncated on the next read. Multi-argument directives such as LocalForward stay unquoted. Every cloud provider now exposes an injectable HTTP base so the production fetch path (URL construction, auth header, error mapping, pagination and host mapping) runs end to end against a mock, for all 16 providers including the signed (AWS, Oracle, OVH) and multi-host (Azure) paths. The Azure nextLink follow is anchored to the API host so the bearer token cannot leak to a look-alike host. Bump to 3.18.5 and add the changelog entry.
1 parent 74edfb5 commit 0a3bb5a

33 files changed

Lines changed: 2474 additions & 316 deletions

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
## 3.18.5 - 2026-05-27
2+
3+
- feat: Special characters in your SSH config save reliably.
4+
- feat: Paths, hostnames and proxy jumps that contain spaces, "#" or quotes are written and read back exactly, whether you edit them yourself or provider sync writes them.
5+
- change: Provider sync is now verified end to end for every supported cloud, so a broken request, header or page cursor is caught before it ever reaches your host list.
6+
17
## 3.18.4 - 2026-05-27
28

39
- change: Safer foundations under every sync, sign and connect.

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "purple-ssh"
3-
version = "3.18.4"
3+
version = "3.18.5"
44
edition = "2024"
55
description = "Open-source terminal SSH manager that keeps ~/.ssh/config in sync with your cloud infra. Spin up a VM on AWS, GCP, Azure, Hetzner or 12 other cloud providers and it appears in your host list. Destroy it and the entry dims. Search hundreds of hosts, transfer files, manage Docker and Podman over SSH, sign Vault SSH certs. Rust TUI, MIT licensed."
66
license = "MIT"

fuzz/Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
Host space-in-path
2+
HostName 10.0.0.1
3+
IdentityFile "~/my key/id_ed25519"
4+
CertificateFile "~/.purple/certs/space host-cert.pub"
5+
6+
Host hash-in-value
7+
HostName 10.0.0.2
8+
IdentityFile "~/id #note"
9+
10+
Host internal-quotes
11+
HostName 10.0.0.3
12+
ProxyCommand ssh -W "%h:%p" gateway
13+
14+
Host mixed
15+
HostName "a b"
16+
User "deploy user"
17+
ProxyJump "jump host"

src/providers/aws.rs

Lines changed: 58 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -312,9 +312,18 @@ fn ec2_get(
312312
agent: &ureq::Agent,
313313
creds: &AwsCredentials,
314314
region: &str,
315+
endpoint: &str,
315316
params: Vec<(String, String)>,
316317
) -> Result<String, ProviderError> {
317-
let host = format!("ec2.{}.amazonaws.com", region);
318+
// Host used for SigV4 signing and the request URL. Derived from the
319+
// injected endpoint so tests can point the signed request at a mock; the
320+
// authority is everything after the scheme (e.g. "ec2.us-east-1.amazonaws.com"
321+
// or "127.0.0.1:1234").
322+
let host = endpoint
323+
.split_once("://")
324+
.map(|(_, authority)| authority)
325+
.unwrap_or(endpoint)
326+
.to_string();
318327
let epoch = std::time::SystemTime::now()
319328
.duration_since(std::time::UNIX_EPOCH)
320329
.unwrap_or_default()
@@ -334,7 +343,7 @@ fn ec2_get(
334343
.join("&");
335344

336345
let auth = sign_request(creds, region, &host, &query_string, &timestamp, &datestamp);
337-
let url = format!("https://{}/?{}", host, query_string);
346+
let url = format!("{}/?{}", endpoint, query_string);
338347

339348
let mut resp = agent
340349
.get(&url)
@@ -353,6 +362,7 @@ fn describe_instances(
353362
agent: &ureq::Agent,
354363
creds: &AwsCredentials,
355364
region: &str,
365+
endpoint: &str,
356366
cancel: &AtomicBool,
357367
) -> Result<Vec<Ec2Instance>, ProviderError> {
358368
let mut all = Vec::new();
@@ -376,7 +386,7 @@ fn describe_instances(
376386
params.push(param("NextToken", token));
377387
}
378388

379-
let body = ec2_get(agent, creds, region, params)?;
389+
let body = ec2_get(agent, creds, region, endpoint, params)?;
380390
let resp: DescribeInstancesResponse = quick_xml::de::from_str(&body)
381391
.map_err(|e| ProviderError::Parse(format!("{}: {}", region, e)))?;
382392

@@ -408,6 +418,7 @@ fn fetch_image_names(
408418
agent: &ureq::Agent,
409419
creds: &AwsCredentials,
410420
region: &str,
421+
endpoint: &str,
411422
image_ids: &[String],
412423
) -> Result<HashMap<String, String>, ProviderError> {
413424
if image_ids.is_empty() {
@@ -424,7 +435,7 @@ fn fetch_image_names(
424435
params.push(param(&format!("ImageId.{}", i + 1), id));
425436
}
426437

427-
let body = ec2_get(agent, creds, region, params)?;
438+
let body = ec2_get(agent, creds, region, endpoint, params)?;
428439
let resp: DescribeImagesResponse = quick_xml::de::from_str(&body)
429440
.map_err(|e| ProviderError::Parse(format!("{}: {}", region, e)))?;
430441

@@ -455,26 +466,20 @@ fn extract_tags(tag_set: &[Ec2Tag]) -> (String, Vec<String>) {
455466

456467
// --- Provider trait ---
457468

458-
impl Provider for Aws {
459-
fn name(&self) -> &str {
460-
"aws"
461-
}
462-
463-
fn short_label(&self) -> &str {
464-
"aws"
469+
impl Aws {
470+
/// Real EC2 endpoint for a region. Overridable via `fetch_with_endpoint`
471+
/// so tests can point the signed request at a mock server.
472+
fn region_endpoint(region: &str) -> String {
473+
format!("https://ec2.{}.amazonaws.com", region)
465474
}
466475

467-
fn fetch_hosts_cancellable(
468-
&self,
469-
token: &str,
470-
cancel: &AtomicBool,
471-
env: &crate::runtime::env::Env,
472-
) -> Result<Vec<ProviderHost>, ProviderError> {
473-
self.fetch_hosts_with_progress(token, cancel, env, &|_| {})
474-
}
475-
476-
fn fetch_hosts_with_progress(
476+
/// Per-region fetch pipeline against caller-supplied endpoints. Production
477+
/// resolves the real EC2 host per region; tests pass a closure returning a
478+
/// mock URL so SigV4 signing, DescribeInstances + DescribeImages, XML
479+
/// deserialize and `ProviderHost` mapping all run end to end.
480+
fn fetch_with_endpoint(
477481
&self,
482+
resolve_endpoint: impl Fn(&str) -> String,
478483
token: &str,
479484
cancel: &AtomicBool,
480485
env: &crate::runtime::env::Env,
@@ -514,7 +519,8 @@ impl Provider for Aws {
514519
total_regions
515520
));
516521

517-
let instances = match describe_instances(&agent, &creds, region, cancel) {
522+
let endpoint = resolve_endpoint(region);
523+
let instances = match describe_instances(&agent, &creds, region, &endpoint, cancel) {
518524
Ok(instances) => instances,
519525
Err(ProviderError::Cancelled) => return Err(ProviderError::Cancelled),
520526
Err(ProviderError::AuthFailed) => return Err(ProviderError::AuthFailed),
@@ -539,7 +545,7 @@ impl Provider for Aws {
539545
// Fetch AMI names (best effort)
540546
let ami_names = if !ami_ids.is_empty() {
541547
progress(&format!("Resolving AMIs for {}...", region));
542-
fetch_image_names(&agent, &creds, region, &ami_ids).unwrap_or_default()
548+
fetch_image_names(&agent, &creds, region, &endpoint, &ami_ids).unwrap_or_default()
543549
} else {
544550
HashMap::new()
545551
};
@@ -610,6 +616,35 @@ impl Provider for Aws {
610616
}
611617
}
612618

619+
impl Provider for Aws {
620+
fn name(&self) -> &str {
621+
"aws"
622+
}
623+
624+
fn short_label(&self) -> &str {
625+
"aws"
626+
}
627+
628+
fn fetch_hosts_cancellable(
629+
&self,
630+
token: &str,
631+
cancel: &AtomicBool,
632+
env: &crate::runtime::env::Env,
633+
) -> Result<Vec<ProviderHost>, ProviderError> {
634+
self.fetch_hosts_with_progress(token, cancel, env, &|_| {})
635+
}
636+
637+
fn fetch_hosts_with_progress(
638+
&self,
639+
token: &str,
640+
cancel: &AtomicBool,
641+
env: &crate::runtime::env::Env,
642+
progress: &dyn Fn(&str),
643+
) -> Result<Vec<ProviderHost>, ProviderError> {
644+
self.fetch_with_endpoint(Self::region_endpoint, token, cancel, env, progress)
645+
}
646+
}
647+
613648
#[cfg(test)]
614649
#[path = "aws_tests.rs"]
615650
mod tests;

src/providers/aws_tests.rs

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -858,3 +858,111 @@ fn test_http_describe_images_auth_failure() {
858858
}
859859
mock.assert();
860860
}
861+
862+
#[test]
863+
fn fetch_from_drives_full_pipeline_against_mock() {
864+
// Exercises the production per-region pipeline end to end through the
865+
// endpoint seam: SigV4 signing, DescribeInstances + DescribeImages,
866+
// XML deserialize and ProviderHost mapping. Before the seam these were
867+
// only covered by re-issuing equivalent requests inline.
868+
let mut server = mockito::Server::new();
869+
let instances = server
870+
.mock("GET", "/")
871+
.match_query(mockito::Matcher::UrlEncoded(
872+
"Action".into(),
873+
"DescribeInstances".into(),
874+
))
875+
.match_header("Authorization", mockito::Matcher::Any)
876+
.with_status(200)
877+
.with_header("content-type", "text/xml")
878+
.with_body(
879+
r#"<DescribeInstancesResponse xmlns="http://ec2.amazonaws.com/doc/2016-11-15/">
880+
<reservationSet><item><instancesSet><item>
881+
<instanceId>i-1234567890</instanceId>
882+
<instanceState><name>running</name></instanceState>
883+
<privateIpAddress>10.0.0.1</privateIpAddress>
884+
<ipAddress>54.1.2.3</ipAddress>
885+
<imageId>ami-12345678</imageId>
886+
<instanceType>t3.micro</instanceType>
887+
<tagSet><item><key>Name</key><value>web-1</value></item></tagSet>
888+
</item></instancesSet></item></reservationSet>
889+
</DescribeInstancesResponse>"#,
890+
)
891+
.create();
892+
let images = server
893+
.mock("GET", "/")
894+
.match_query(mockito::Matcher::UrlEncoded(
895+
"Action".into(),
896+
"DescribeImages".into(),
897+
))
898+
.with_status(200)
899+
.with_header("content-type", "text/xml")
900+
.with_body(
901+
r#"<DescribeImagesResponse xmlns="http://ec2.amazonaws.com/doc/2016-11-15/">
902+
<imagesSet><item><imageId>ami-12345678</imageId><name>amzn2-ami-hvm-2.0</name></item></imagesSet>
903+
</DescribeImagesResponse>"#,
904+
)
905+
.create();
906+
907+
let aws = Aws {
908+
regions: vec!["us-east-1".to_string()],
909+
profile: String::new(),
910+
};
911+
let url = server.url();
912+
let hosts = aws
913+
.fetch_with_endpoint(
914+
|_region| url.clone(),
915+
"AKID:SECRET",
916+
&AtomicBool::new(false),
917+
&crate::runtime::env::Env::empty(),
918+
&|_| {},
919+
)
920+
.expect("fetch_with_endpoint must succeed against the mock");
921+
instances.assert();
922+
images.assert();
923+
924+
assert_eq!(hosts.len(), 1);
925+
assert_eq!(hosts[0].server_id, "i-1234567890");
926+
assert_eq!(hosts[0].name, "web-1");
927+
assert_eq!(hosts[0].ip, "54.1.2.3");
928+
assert!(
929+
hosts[0]
930+
.metadata
931+
.contains(&("region".to_string(), "us-east-1".to_string()))
932+
);
933+
assert!(
934+
hosts[0]
935+
.metadata
936+
.contains(&("os".to_string(), "amzn2-ami-hvm-2.0".to_string()))
937+
);
938+
}
939+
940+
#[test]
941+
fn fetch_from_maps_auth_failure_to_provider_error() {
942+
let mut server = mockito::Server::new();
943+
let mock = server
944+
.mock("GET", "/")
945+
.match_query(mockito::Matcher::Any)
946+
.with_status(401)
947+
.with_header("content-type", "text/xml")
948+
.with_body("<Error><Code>AuthFailure</Code></Error>")
949+
.create();
950+
951+
let aws = Aws {
952+
regions: vec!["us-east-1".to_string()],
953+
profile: String::new(),
954+
};
955+
let url = server.url();
956+
let result = aws.fetch_with_endpoint(
957+
|_region| url.clone(),
958+
"AKID:SECRET",
959+
&AtomicBool::new(false),
960+
&crate::runtime::env::Env::empty(),
961+
&|_| {},
962+
);
963+
mock.assert();
964+
assert!(
965+
matches!(result, Err(ProviderError::AuthFailed)),
966+
"a 401 from the region must surface as AuthFailed, got {result:?}"
967+
);
968+
}

0 commit comments

Comments
 (0)