Skip to content

Commit 04bc4a8

Browse files
committed
fix: username in scp-like url is no longer percent-encoded (#2056)
Since Git doesn't percent-decode characters in scp-like URL, we shouldn't encode username at all. https://github.com/git/git/blob/v2.50.0/connect.c#L1081 I've split write_to() function to clarify that any non-path components that should be separated by ":" cannot be serialized in alternative form. I've made it fall back to the URL syntax if password or port number was set. Maybe we can also check if the user or host includes ":", but I'm not sure how much foolproof we should add here.
1 parent dd5f0a4 commit 04bc4a8

File tree

2 files changed

+81
-7
lines changed

2 files changed

+81
-7
lines changed

gix-url/src/lib.rs

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -315,11 +315,23 @@ fn percent_encode(s: &str) -> Cow<'_, str> {
315315
/// Serialization
316316
impl Url {
317317
/// Write this URL losslessly to `out`, ready to be parsed again.
318-
pub fn write_to(&self, mut out: &mut dyn std::io::Write) -> std::io::Result<()> {
319-
if !(self.serialize_alternative_form && (self.scheme == Scheme::File || self.scheme == Scheme::Ssh)) {
320-
out.write_all(self.scheme.as_str().as_bytes())?;
321-
out.write_all(b"://")?;
318+
pub fn write_to(&self, out: &mut dyn std::io::Write) -> std::io::Result<()> {
319+
// Since alternative form doesn't employ any escape syntax, password and
320+
// port number cannot be encoded.
321+
if self.serialize_alternative_form
322+
&& (self.scheme == Scheme::File || self.scheme == Scheme::Ssh)
323+
&& self.password.is_none()
324+
&& self.port.is_none()
325+
{
326+
self.write_alternative_form_to(out)
327+
} else {
328+
self.write_canonical_form_to(out)
322329
}
330+
}
331+
332+
fn write_canonical_form_to(&self, out: &mut dyn std::io::Write) -> std::io::Result<()> {
333+
out.write_all(self.scheme.as_str().as_bytes())?;
334+
out.write_all(b"://")?;
323335
match (&self.user, &self.host) {
324336
(Some(user), Some(host)) => {
325337
out.write_all(percent_encode(user).as_bytes())?;
@@ -337,9 +349,31 @@ impl Url {
337349
(Some(_user), None) => unreachable!("BUG: should not be possible to have a user but no host"),
338350
}
339351
if let Some(port) = &self.port {
340-
write!(&mut out, ":{port}")?;
352+
write!(out, ":{port}")?;
353+
}
354+
out.write_all(&self.path)?;
355+
Ok(())
356+
}
357+
358+
fn write_alternative_form_to(&self, out: &mut dyn std::io::Write) -> std::io::Result<()> {
359+
match (&self.user, &self.host) {
360+
(Some(user), Some(host)) => {
361+
out.write_all(user.as_bytes())?;
362+
assert!(
363+
self.password.is_none(),
364+
"BUG: cannot serialize password in alternative form"
365+
);
366+
out.write_all(b"@")?;
367+
out.write_all(host.as_bytes())?;
368+
}
369+
(None, Some(host)) => {
370+
out.write_all(host.as_bytes())?;
371+
}
372+
(None, None) => {}
373+
(Some(_user), None) => unreachable!("BUG: should not be possible to have a user but no host"),
341374
}
342-
if self.serialize_alternative_form && self.scheme == Scheme::Ssh {
375+
assert!(self.port.is_none(), "BUG: cannot serialize port in alternative form");
376+
if self.scheme == Scheme::Ssh {
343377
out.write_all(b":")?;
344378
}
345379
out.write_all(&self.path)?;

gix-url/tests/url/parse/ssh.rs

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use gix_url::Scheme;
22

3-
use crate::parse::{assert_url, assert_url_roundtrip, url, url_alternate};
3+
use crate::parse::{assert_url, assert_url_roundtrip, url, url_alternate, url_with_pass};
44

55
#[test]
66
fn without_user_and_without_port() -> crate::Result {
@@ -184,6 +184,30 @@ fn scp_like_with_windows_path_and_port_thinks_port_is_part_of_path() -> crate::R
184184
Ok(())
185185
}
186186

187+
#[test]
188+
fn scp_like_with_non_alphanumeric_username() -> crate::Result {
189+
let url = assert_url(
190+
"_user.name@host.xz:C:/path",
191+
url_alternate(Scheme::Ssh, "_user.name", "host.xz", None, b"C:/path"),
192+
)?
193+
.to_bstring();
194+
assert_eq!(url, "_user.name@host.xz:C:/path");
195+
Ok(())
196+
}
197+
198+
// Git passes the non-path part "user@name@host.xz" to OpenSSH, and the ssh
199+
// command interprets it as user = "user@name", host = "host.xz".
200+
#[test]
201+
fn scp_like_with_username_including_at() -> crate::Result {
202+
let url = assert_url(
203+
"user@name@host.xz:path",
204+
url_alternate(Scheme::Ssh, "user@name", "host.xz", None, b"path"),
205+
)?
206+
.to_bstring();
207+
assert_eq!(url, "user@name@host.xz:path");
208+
Ok(())
209+
}
210+
187211
// Git does not care that the host is named `file`, it still treats it as an SCP url.
188212
// I btw tested this, yes you can really clone a repository from there, just `git init`
189213
// in the directory above your home directory on the remote machine.
@@ -193,3 +217,19 @@ fn strange_scp_like_with_host_named_file() -> crate::Result {
193217
assert_eq!(url.to_bstring(), "file:..");
194218
Ok(())
195219
}
220+
221+
#[test]
222+
fn bad_alternative_form_with_password() -> crate::Result {
223+
let url = url_with_pass(Scheme::Ssh, "user", "password", "host.xz", None, b"/")
224+
.serialize_alternate_form(true)
225+
.to_bstring();
226+
assert_eq!(url, "ssh://user:password@host.xz/");
227+
Ok(())
228+
}
229+
230+
#[test]
231+
fn bad_alternative_form_with_port() -> crate::Result {
232+
let url = url_alternate(Scheme::Ssh, None, "host.xz", 21, b"/").to_bstring();
233+
assert_eq!(url, "ssh://host.xz:21/");
234+
Ok(())
235+
}

0 commit comments

Comments
 (0)