Skip to content
Open
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
97 changes: 84 additions & 13 deletions src/bun_core/fmt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1136,16 +1136,29 @@ pub enum URLProto {

impl Display for URLFormatter<'_> {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
write!(
f,
"{}://",
match self.proto {
URLProto::Http => "http",
URLProto::Https => "https",
URLProto::Unix => "unix",
URLProto::Abstract => "abstract",
match self.proto {
URLProto::Unix => {
let path = self.hostname.unwrap_or(b"");
// Without a leading `/`, the first segment lands in the URL host slot.
let host_len = if path.first() == Some(&b'/') {
0
} else {
strings::index_of_char_usize(path, b'/').unwrap_or(path.len())
};
return write!(
f,
"unix://{}{}",
PercentEncoded::host(&path[..host_len]),
PercentEncoded::path(&path[host_len..])
Comment thread
coderabbitai[bot] marked this conversation as resolved.
);
}
URLProto::Abstract => {
let name = self.hostname.unwrap_or(b"");
return write!(f, "abstract://{}/", PercentEncoded::host(name));
}
)?;
URLProto::Http => f.write_str("http://")?,
URLProto::Https => f.write_str("https://")?,
}

if let Some(hostname) = self.hostname {
let needs_brackets =
Expand All @@ -1159,10 +1172,6 @@ impl Display for URLFormatter<'_> {
f.write_str("localhost")?;
}

if self.proto == URLProto::Unix {
return Ok(());
}

let is_port_optional = self.port.is_none()
|| (self.proto == URLProto::Https && self.port == Some(443))
|| (self.proto == URLProto::Http && self.port == Some(80));
Expand All @@ -1174,6 +1183,68 @@ impl Display for URLFormatter<'_> {
}
}

/// The URL component that raw bytes land in once they follow `scheme://`.
#[derive(Clone, Copy, PartialEq, Eq)]
enum URLSlot {
Host,
Path,
}

/// Socket path bytes percent-encoded for one URL slot, so the WHATWG parser takes them losslessly.
struct PercentEncoded<'a> {
bytes: &'a [u8],
slot: URLSlot,
}

impl<'a> PercentEncoded<'a> {
fn host(bytes: &'a [u8]) -> Self {
Self {
bytes,
slot: URLSlot::Host,
}
}

fn path(bytes: &'a [u8]) -> Self {
Self {
bytes,
slot: URLSlot::Path,
}
}
}

/// Only bytes the parser rejects, strips, reads as a delimiter or encodes itself, plus `%`.
fn needs_percent_encoding(b: u8, slot: URLSlot) -> bool {
// C0 controls, DEL and non-ASCII: the parser strips tab, LF, CR and encodes the rest.
if b < 0x20 || b >= 0x7F {
return true;
}
match b {
// Delimiters, bytes both slots reject, and `%` so the output decodes back.
b' ' | b'#' | b'%' | b'<' | b'>' | b'?' | b'^' => true,
// Forbidden host code points. A path keeps them as-is.
b'/' | b':' | b'@' | b'[' | b'\\' | b']' | b'|' => slot == URLSlot::Host,
// The path percent-encode set. An opaque host keeps them as-is.
b'"' | b'`' | b'{' | b'}' => slot == URLSlot::Path,
_ => false,
}
}

impl Display for PercentEncoded<'_> {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
for &b in self.bytes {
if needs_percent_encoding(b, self.slot) {
let h = hex_byte_upper(b);
f.write_char('%')?;
f.write_char(h[0] as char)?;
f.write_char(h[1] as char)?;
} else {
f.write_char(b as char)?;
}
}
Ok(())
}
}

// ───────────────────────────────────────────────────────────────────────────
// HostFormatter
// ───────────────────────────────────────────────────────────────────────────
Expand Down
65 changes: 64 additions & 1 deletion test/js/bun/http/bun-serve-args.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { serve } from "bun";
import { describe, expect, test } from "bun:test";
import { isWindows, tmpdirSync } from "../../../harness";
import { join } from "node:path";
import { isLinux, isWindows, tempDir, tmpdirSync } from "../../../harness";

const defaultHostname = "localhost";

Expand Down Expand Up @@ -306,6 +307,68 @@ describe("Bun.serve unix socket", () => {
});
});

describe("Bun.serve unix socket url", () => {
// https://github.com/oven-sh/bun/issues/40182
test("server.url parses for a path with a drive letter and backslashes", () => {
using dir = tempDir("unix-url-drive", {});
// On Windows the temp dir already starts with `C:\`. Elsewhere a relative
// socket file named `C:\test` puts the same bytes into the URL.
const cwd = process.cwd();
process.chdir(String(dir));
try {
const p = isWindows ? join(String(dir), "test.sock") : "C:\\test";
using server = serve({ unix: p, fetch: () => new Response("ok") });
const url = server.url;
expect(url).toBeInstanceOf(URL);
expect(url.protocol).toBe("unix:");
expect(decodeURIComponent(url.hostname)).toBe(p);
} finally {
process.chdir(cwd);
}
});

test.skipIf(isWindows)("server.url percent-encodes only what the URL parser cannot take", () => {
using dir = tempDir("unix-url-encode", { "sub dir": {} });
const cwd = process.cwd();
process.chdir(String(dir));
try {
// A relative path puts its first segment in the URL host slot, which
// forbids more bytes (`:`, `@`, `[`, `]`) than the path does.
const cases: [path: string, href: string][] = [
["my app.sock", "unix://my%20app.sock"],
["a#b?c.sock", "unix://a%23b%3Fc.sock"],
["a:b@c.sock", "unix://a%3Ab%40c.sock"],
["[weird name].sock", "unix://%5Bweird%20name%5D.sock"],
["a%b.sock", "unix://a%25b.sock"],
["a+b=c,d.sock", "unix://a+b=c,d.sock"],
["sub dir/a:b@c.sock", "unix://sub%20dir/a:b@c.sock"],
[`${dir}/a b#c%d.sock`, `unix://${dir}/a%20b%23c%25d.sock`],
[`${dir}/a+b=c@d:e.sock`, `unix://${dir}/a+b=c@d:e.sock`],
];
for (const [path, href] of cases) {
using server = serve({ unix: path, fetch: () => new Response("ok") });
const url = server.url;
expect({ path, href: url.href, decoded: decodeURIComponent(url.hostname + url.pathname) }).toEqual({
path,
href,
decoded: path,
});
}
} finally {
process.chdir(cwd);
}
});

test.skipIf(!isLinux)("server.url percent-encodes an abstract socket name", () => {
const suffix = Math.random().toString(36).slice(2);
const name = "bun url test/" + suffix;
using server = serve({ unix: "\0" + name, fetch: () => new Response("ok") });
const url = server.url;
expect(url.href).toBe("abstract://bun%20url%20test%2F" + suffix + "/");
expect(decodeURIComponent(url.hostname)).toBe(name);
});
});

describe("Bun.serve hostname and port validation", () => {
test("hostname with port 0 gets random port", () => {
using server = serve({
Expand Down
4 changes: 3 additions & 1 deletion test/js/bun/http/serve-listen.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,9 @@ describe.each([
? {
protocol: "unix:",
pathname: unix.substring(unix.indexOf(":") + 1),
hostname: unix.substring(0, unix.indexOf(":")),
// "C:" has no leading "/" so it lands in the host slot, where ":" is
// percent-encoded so that it does not read as a port separator.
hostname: unix.substring(0, unix.indexOf(":")) + "%3A",
port: "",
}
: {
Expand Down
12 changes: 7 additions & 5 deletions test/js/bun/http/server-url-invalid.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { expect, test } from "bun:test";
import { tempDir } from "harness";

test("server.url does not crash when unix socket path produces invalid URL", () => {
// Passing an object as the unix socket path causes the URL formatter to produce
// a string like "unix://[object Bun]" which is not a valid URL. Accessing
// server.url should throw a proper JS error instead of crashing.
test("server.url percent-encodes a unix socket path the URL parser would reject", () => {
// An object passed as the unix socket path is coerced to "[object Bun]". The
// URL formatter used to write it as-is after "unix://", which is not a valid
// URL, so server.url threw.
Comment thread
robobun marked this conversation as resolved.
//
// The socket path is relative, so run inside a fresh temp dir to avoid
// EADDRINUSE from a stale socket file left in cwd by an earlier run.
Expand All @@ -19,7 +19,9 @@ test("server.url does not crash when unix socket path produces invalid URL", ()
return new Response("ok");
},
});
expect(() => server.url).toThrow();
expect(server.url).toBeInstanceOf(URL);
expect(server.url.href).toBe("unix://%5Bobject%20Bun%5D");
expect(decodeURIComponent(server.url.hostname)).toBe("[object Bun]");
} finally {
process.chdir(prev);
}
Expand Down
Loading