mirror of
https://github.com/oven-sh/bun
synced 2026-02-19 23:31:45 +00:00
Compare commits
1 Commits
claude/fix
...
claude/fix
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be0bda83e1 |
@@ -109,6 +109,45 @@ static EncodedJSValue assignHeadersFromFetchHeaders(FetchHeaders& impl, JSObject
|
||||
return JSValue::encode(tuple);
|
||||
}
|
||||
|
||||
// Duplicate header handling policy, matching Node.js behavior per RFC 9110.
|
||||
// See: https://github.com/nodejs/node/blob/main/lib/_http_incoming.js (matchKnownFields)
|
||||
enum class DuplicateHeaderPolicy : uint8_t {
|
||||
DropDuplicate, // Keep first value only (e.g., Content-Type, Host)
|
||||
JoinComma, // Join with ", " (e.g., Accept, Cache-Control, unknown headers)
|
||||
JoinSemicolon, // Join with "; " (Cookie only)
|
||||
// Set-Cookie is handled separately as an array
|
||||
};
|
||||
|
||||
static DuplicateHeaderPolicy duplicateHeaderPolicy(WebCore::HTTPHeaderName name)
|
||||
{
|
||||
switch (name) {
|
||||
// Headers where only the first value should be kept:
|
||||
case WebCore::HTTPHeaderName::Age:
|
||||
case WebCore::HTTPHeaderName::Authorization:
|
||||
case WebCore::HTTPHeaderName::ContentLength:
|
||||
case WebCore::HTTPHeaderName::ContentType:
|
||||
case WebCore::HTTPHeaderName::ETag:
|
||||
case WebCore::HTTPHeaderName::Expires:
|
||||
case WebCore::HTTPHeaderName::Host:
|
||||
case WebCore::HTTPHeaderName::IfModifiedSince:
|
||||
case WebCore::HTTPHeaderName::IfUnmodifiedSince:
|
||||
case WebCore::HTTPHeaderName::LastModified:
|
||||
case WebCore::HTTPHeaderName::Location:
|
||||
case WebCore::HTTPHeaderName::ProxyAuthorization:
|
||||
case WebCore::HTTPHeaderName::Referer:
|
||||
case WebCore::HTTPHeaderName::UserAgent:
|
||||
return DuplicateHeaderPolicy::DropDuplicate;
|
||||
|
||||
// Cookie is joined with "; "
|
||||
case WebCore::HTTPHeaderName::Cookie:
|
||||
return DuplicateHeaderPolicy::JoinSemicolon;
|
||||
|
||||
// All other known headers are joined with ", "
|
||||
default:
|
||||
return DuplicateHeaderPolicy::JoinComma;
|
||||
}
|
||||
}
|
||||
|
||||
static void assignHeadersFromUWebSocketsForCall(uWS::HttpRequest* request, JSValue methodString, MarkedArgumentBuffer& args, JSC::JSGlobalObject* globalObject, JSC::VM& vm)
|
||||
{
|
||||
auto scope = DECLARE_THROW_SCOPE(vm);
|
||||
@@ -148,7 +187,7 @@ static void assignHeadersFromUWebSocketsForCall(uWS::HttpRequest* request, JSVal
|
||||
if (pair.second.length() > 0)
|
||||
memcpy(data.data(), pair.second.data(), pair.second.length());
|
||||
|
||||
HTTPHeaderName name;
|
||||
HTTPHeaderName name = WebCore::HTTPHeaderName::Age; // initialized to avoid warnings
|
||||
|
||||
JSString* jsValue = jsString(vm, value);
|
||||
|
||||
@@ -156,7 +195,8 @@ static void assignHeadersFromUWebSocketsForCall(uWS::HttpRequest* request, JSVal
|
||||
Identifier nameIdentifier;
|
||||
JSString* nameString = nullptr;
|
||||
|
||||
if (WebCore::findHTTPHeaderName(nameView, name)) {
|
||||
bool isKnownHeader = WebCore::findHTTPHeaderName(nameView, name);
|
||||
if (isKnownHeader) {
|
||||
nameString = identifiers.stringFor(globalObject, name);
|
||||
nameIdentifier = identifiers.identifierFor(vm, name);
|
||||
} else {
|
||||
@@ -165,7 +205,7 @@ static void assignHeadersFromUWebSocketsForCall(uWS::HttpRequest* request, JSVal
|
||||
nameIdentifier = Identifier::fromString(vm, wtfString.convertToASCIILowercase());
|
||||
}
|
||||
|
||||
if (name == WebCore::HTTPHeaderName::SetCookie) {
|
||||
if (isKnownHeader && name == WebCore::HTTPHeaderName::SetCookie) {
|
||||
if (!setCookiesHeaderArray) {
|
||||
setCookiesHeaderArray = constructEmptyArray(globalObject, nullptr);
|
||||
RETURN_IF_EXCEPTION(scope, );
|
||||
@@ -179,11 +219,38 @@ static void assignHeadersFromUWebSocketsForCall(uWS::HttpRequest* request, JSVal
|
||||
RETURN_IF_EXCEPTION(scope, void());
|
||||
|
||||
} else {
|
||||
headersObject->putDirectMayBeIndex(globalObject, nameIdentifier, jsValue);
|
||||
RETURN_IF_EXCEPTION(scope, void());
|
||||
arrayValues.append(nameString);
|
||||
arrayValues.append(jsValue);
|
||||
RETURN_IF_EXCEPTION(scope, void());
|
||||
// Check if this header already exists (duplicate header handling per Node.js/RFC 9110)
|
||||
JSValue existingValue = headersObject->getDirect(vm, nameIdentifier);
|
||||
if (existingValue) {
|
||||
DuplicateHeaderPolicy policy = isKnownHeader ? duplicateHeaderPolicy(name) : DuplicateHeaderPolicy::JoinComma;
|
||||
|
||||
if (policy == DuplicateHeaderPolicy::DropDuplicate) {
|
||||
// Keep first value, but still add to rawHeaders array
|
||||
arrayValues.append(nameString);
|
||||
arrayValues.append(jsValue);
|
||||
RETURN_IF_EXCEPTION(scope, void());
|
||||
} else {
|
||||
// Join with separator: ", " for most headers, "; " for cookie
|
||||
JSString* existingString = existingValue.toString(globalObject);
|
||||
RETURN_IF_EXCEPTION(scope, void());
|
||||
auto existingStringValue = existingString->value(globalObject);
|
||||
RETURN_IF_EXCEPTION(scope, void());
|
||||
WTF::String joinedValue = (policy == DuplicateHeaderPolicy::JoinSemicolon)
|
||||
? makeString(WTF::String(existingStringValue), "; "_s, value)
|
||||
: makeString(WTF::String(existingStringValue), ", "_s, value);
|
||||
JSString* jsJoinedValue = jsString(vm, joinedValue);
|
||||
headersObject->putDirect(vm, nameIdentifier, jsJoinedValue, 0);
|
||||
arrayValues.append(nameString);
|
||||
arrayValues.append(jsValue);
|
||||
RETURN_IF_EXCEPTION(scope, void());
|
||||
}
|
||||
} else {
|
||||
headersObject->putDirectMayBeIndex(globalObject, nameIdentifier, jsValue);
|
||||
RETURN_IF_EXCEPTION(scope, void());
|
||||
arrayValues.append(nameString);
|
||||
arrayValues.append(jsValue);
|
||||
RETURN_IF_EXCEPTION(scope, void());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -334,11 +401,12 @@ static EncodedJSValue assignHeadersFromUWebSockets(uWS::HttpRequest* request, JS
|
||||
if (pair.second.length() > 0)
|
||||
memcpy(data.data(), pair.second.data(), pair.second.length());
|
||||
|
||||
HTTPHeaderName name;
|
||||
HTTPHeaderName name = WebCore::HTTPHeaderName::Age; // initialized to avoid warnings
|
||||
bool isKnownHeader = WebCore::findHTTPHeaderName(nameView, name);
|
||||
WTF::String nameString;
|
||||
WTF::String lowercasedNameString;
|
||||
|
||||
if (WebCore::findHTTPHeaderName(nameView, name)) {
|
||||
if (isKnownHeader) {
|
||||
nameString = WTF::httpHeaderNameStringImpl(name);
|
||||
lowercasedNameString = nameString;
|
||||
} else {
|
||||
@@ -348,7 +416,7 @@ static EncodedJSValue assignHeadersFromUWebSockets(uWS::HttpRequest* request, JS
|
||||
|
||||
JSString* jsValue = jsString(vm, value);
|
||||
|
||||
if (name == WebCore::HTTPHeaderName::SetCookie) {
|
||||
if (isKnownHeader && name == WebCore::HTTPHeaderName::SetCookie) {
|
||||
if (!setCookiesHeaderArray) {
|
||||
setCookiesHeaderArray = constructEmptyArray(globalObject, nullptr);
|
||||
RETURN_IF_EXCEPTION(scope, {});
|
||||
@@ -362,10 +430,39 @@ static EncodedJSValue assignHeadersFromUWebSockets(uWS::HttpRequest* request, JS
|
||||
RETURN_IF_EXCEPTION(scope, {});
|
||||
|
||||
} else {
|
||||
headersObject->putDirect(vm, Identifier::fromString(vm, lowercasedNameString), jsValue, 0);
|
||||
array->putDirectIndex(globalObject, i++, jsString(vm, nameString));
|
||||
array->putDirectIndex(globalObject, i++, jsValue);
|
||||
RETURN_IF_EXCEPTION(scope, {});
|
||||
auto identifier = Identifier::fromString(vm, lowercasedNameString);
|
||||
|
||||
// Check if this header already exists (duplicate header handling per Node.js/RFC 9110)
|
||||
JSValue existingValue = headersObject->getDirect(vm, identifier);
|
||||
if (existingValue) {
|
||||
DuplicateHeaderPolicy policy = isKnownHeader ? duplicateHeaderPolicy(name) : DuplicateHeaderPolicy::JoinComma;
|
||||
|
||||
if (policy == DuplicateHeaderPolicy::DropDuplicate) {
|
||||
// Keep first value, but still add to rawHeaders array
|
||||
array->putDirectIndex(globalObject, i++, jsString(vm, nameString));
|
||||
array->putDirectIndex(globalObject, i++, jsValue);
|
||||
RETURN_IF_EXCEPTION(scope, {});
|
||||
} else {
|
||||
// Join with separator: ", " for most headers, "; " for cookie
|
||||
JSString* existingString = existingValue.toString(globalObject);
|
||||
RETURN_IF_EXCEPTION(scope, {});
|
||||
auto existingStringValue = existingString->value(globalObject);
|
||||
RETURN_IF_EXCEPTION(scope, {});
|
||||
WTF::String joinedValue = (policy == DuplicateHeaderPolicy::JoinSemicolon)
|
||||
? makeString(WTF::String(existingStringValue), "; "_s, value)
|
||||
: makeString(WTF::String(existingStringValue), ", "_s, value);
|
||||
JSString* jsJoinedValue = jsString(vm, joinedValue);
|
||||
headersObject->putDirect(vm, identifier, jsJoinedValue, 0);
|
||||
array->putDirectIndex(globalObject, i++, jsString(vm, nameString));
|
||||
array->putDirectIndex(globalObject, i++, jsValue);
|
||||
RETURN_IF_EXCEPTION(scope, {});
|
||||
}
|
||||
} else {
|
||||
headersObject->putDirect(vm, identifier, jsValue, 0);
|
||||
array->putDirectIndex(globalObject, i++, jsString(vm, nameString));
|
||||
array->putDirectIndex(globalObject, i++, jsValue);
|
||||
RETURN_IF_EXCEPTION(scope, {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,79 +12,27 @@ state: union(enum) {
|
||||
|
||||
pub fn start(this: *Echo) Yield {
|
||||
var args = this.bltn().argsSlice();
|
||||
const no_newline = args.len >= 1 and std.mem.eql(u8, bun.sliceTo(args[0], 0), "-n");
|
||||
|
||||
// Parse flags: echo accepts -n, -e, -E in any combination.
|
||||
// Flag parsing stops at the first arg that doesn't start with '-'
|
||||
// or contains an invalid flag character.
|
||||
var no_newline = false;
|
||||
var escape_sequences = false;
|
||||
var flags_done = false;
|
||||
var args_start: usize = 0;
|
||||
|
||||
for (args) |arg| {
|
||||
if (flags_done) break;
|
||||
const flag = std.mem.span(arg);
|
||||
if (flag.len < 2 or flag[0] != '-') {
|
||||
flags_done = true;
|
||||
break;
|
||||
}
|
||||
// Validate all characters are valid echo flags
|
||||
var valid = true;
|
||||
for (flag[1..]) |c| {
|
||||
switch (c) {
|
||||
'n', 'e', 'E' => {},
|
||||
else => {
|
||||
valid = false;
|
||||
break;
|
||||
},
|
||||
}
|
||||
}
|
||||
if (!valid) {
|
||||
flags_done = true;
|
||||
break;
|
||||
}
|
||||
// Apply flags (last -e/-E wins)
|
||||
for (flag[1..]) |c| {
|
||||
switch (c) {
|
||||
'n' => no_newline = true,
|
||||
'e' => escape_sequences = true,
|
||||
'E' => escape_sequences = false,
|
||||
else => unreachable,
|
||||
}
|
||||
}
|
||||
args_start += 1;
|
||||
}
|
||||
|
||||
args = args[args_start..];
|
||||
args = args[if (no_newline) 1 else 0..];
|
||||
const args_len = args.len;
|
||||
var has_leading_newline: bool = false;
|
||||
var stop_output = false;
|
||||
|
||||
// TODO: Should flush buffer after it gets to a certain size
|
||||
for (args, 0..) |arg, i| {
|
||||
if (stop_output) break;
|
||||
const thearg = std.mem.span(arg);
|
||||
const is_last = i == args_len - 1;
|
||||
|
||||
if (escape_sequences) {
|
||||
stop_output = appendWithEscapes(&this.output, thearg);
|
||||
} else {
|
||||
if (is_last) {
|
||||
if (thearg.len > 0 and thearg[thearg.len - 1] == '\n') {
|
||||
has_leading_newline = true;
|
||||
}
|
||||
bun.handleOom(this.output.appendSlice(bun.strings.trimSubsequentLeadingChars(thearg, '\n')));
|
||||
} else {
|
||||
bun.handleOom(this.output.appendSlice(thearg));
|
||||
}
|
||||
}
|
||||
|
||||
if (!stop_output and !is_last) {
|
||||
if (i < args_len - 1) {
|
||||
bun.handleOom(this.output.appendSlice(thearg));
|
||||
bun.handleOom(this.output.append(' '));
|
||||
} else {
|
||||
if (thearg.len > 0 and thearg[thearg.len - 1] == '\n') {
|
||||
has_leading_newline = true;
|
||||
}
|
||||
bun.handleOom(this.output.appendSlice(bun.strings.trimSubsequentLeadingChars(thearg, '\n')));
|
||||
}
|
||||
}
|
||||
|
||||
if (!stop_output and !has_leading_newline and !no_newline) bun.handleOom(this.output.append('\n'));
|
||||
if (!has_leading_newline and !no_newline) bun.handleOom(this.output.append('\n'));
|
||||
|
||||
if (this.bltn().stdout.needsIO()) |safeguard| {
|
||||
this.state = .waiting;
|
||||
@@ -95,109 +43,6 @@ pub fn start(this: *Echo) Yield {
|
||||
return this.bltn().done(0);
|
||||
}
|
||||
|
||||
/// Appends `input` to `output`, interpreting backslash escape sequences.
|
||||
/// Returns true if a \c escape was encountered (meaning stop all output).
|
||||
fn appendWithEscapes(output: *std.array_list.Managed(u8), input: []const u8) bool {
|
||||
var i: usize = 0;
|
||||
while (i < input.len) {
|
||||
if (input[i] == '\\' and i + 1 < input.len) {
|
||||
switch (input[i + 1]) {
|
||||
'\\' => {
|
||||
bun.handleOom(output.append('\\'));
|
||||
i += 2;
|
||||
},
|
||||
'a' => {
|
||||
bun.handleOom(output.append('\x07'));
|
||||
i += 2;
|
||||
},
|
||||
'b' => {
|
||||
bun.handleOom(output.append('\x08'));
|
||||
i += 2;
|
||||
},
|
||||
'c' => {
|
||||
// \c: produce no further output
|
||||
return true;
|
||||
},
|
||||
'e', 'E' => {
|
||||
bun.handleOom(output.append('\x1b'));
|
||||
i += 2;
|
||||
},
|
||||
'f' => {
|
||||
bun.handleOom(output.append('\x0c'));
|
||||
i += 2;
|
||||
},
|
||||
'n' => {
|
||||
bun.handleOom(output.append('\n'));
|
||||
i += 2;
|
||||
},
|
||||
'r' => {
|
||||
bun.handleOom(output.append('\r'));
|
||||
i += 2;
|
||||
},
|
||||
't' => {
|
||||
bun.handleOom(output.append('\t'));
|
||||
i += 2;
|
||||
},
|
||||
'v' => {
|
||||
bun.handleOom(output.append('\x0b'));
|
||||
i += 2;
|
||||
},
|
||||
'0' => {
|
||||
// \0nnn: octal value (up to 3 octal digits)
|
||||
i += 2; // skip \0
|
||||
var val: u8 = 0;
|
||||
var digits: usize = 0;
|
||||
while (digits < 3 and i < input.len and input[i] >= '0' and input[i] <= '7') {
|
||||
val = val *% 8 +% (input[i] - '0');
|
||||
i += 1;
|
||||
digits += 1;
|
||||
}
|
||||
bun.handleOom(output.append(val));
|
||||
},
|
||||
'x' => {
|
||||
// \xHH: hex value (up to 2 hex digits)
|
||||
i += 2; // skip \x
|
||||
var val: u8 = 0;
|
||||
var digits: usize = 0;
|
||||
while (digits < 2 and i < input.len) {
|
||||
const hex_val = hexDigitValue(input[i]);
|
||||
if (hex_val) |hv| {
|
||||
val = val *% 16 +% hv;
|
||||
i += 1;
|
||||
digits += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (digits > 0) {
|
||||
bun.handleOom(output.append(val));
|
||||
} else {
|
||||
// No valid hex digits: output \x literally
|
||||
bun.handleOom(output.appendSlice("\\x"));
|
||||
}
|
||||
},
|
||||
else => {
|
||||
// Unknown escape: output backslash and the character as-is
|
||||
bun.handleOom(output.append('\\'));
|
||||
bun.handleOom(output.append(input[i + 1]));
|
||||
i += 2;
|
||||
},
|
||||
}
|
||||
} else {
|
||||
bun.handleOom(output.append(input[i]));
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
fn hexDigitValue(c: u8) ?u8 {
|
||||
if (c >= '0' and c <= '9') return c - '0';
|
||||
if (c >= 'a' and c <= 'f') return c - 'a' + 10;
|
||||
if (c >= 'A' and c <= 'F') return c - 'A' + 10;
|
||||
return null;
|
||||
}
|
||||
|
||||
pub fn onIOWriterChunk(this: *Echo, _: usize, e: ?jsc.SystemError) Yield {
|
||||
if (comptime bun.Environment.allow_assert) {
|
||||
assert(this.state == .waiting or this.state == .waiting_write_err);
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
import { $ } from "bun";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
describe("echo -e flag support", () => {
|
||||
test("echo -e does not output -e as literal text", async () => {
|
||||
const result = await $`echo -e hello`.text();
|
||||
expect(result).toBe("hello\n");
|
||||
});
|
||||
|
||||
test("echo -e interprets backslash-n", async () => {
|
||||
const result = await $`echo -e ${"hello\\nworld"}`.text();
|
||||
expect(result).toBe("hello\nworld\n");
|
||||
});
|
||||
|
||||
test("echo -e interprets backslash-t", async () => {
|
||||
const result = await $`echo -e ${"hello\\tworld"}`.text();
|
||||
expect(result).toBe("hello\tworld\n");
|
||||
});
|
||||
|
||||
test("echo -e interprets backslash-backslash", async () => {
|
||||
const result = await $`echo -e ${"hello\\\\world"}`.text();
|
||||
expect(result).toBe("hello\\world\n");
|
||||
});
|
||||
|
||||
test("echo -e interprets \\a (bell)", async () => {
|
||||
const result = await $`echo -e ${"\\a"}`.text();
|
||||
expect(result).toBe("\x07\n");
|
||||
});
|
||||
|
||||
test("echo -e interprets \\b (backspace)", async () => {
|
||||
const result = await $`echo -e ${"a\\bb"}`.text();
|
||||
expect(result).toBe("a\bb\n");
|
||||
});
|
||||
|
||||
test("echo -e interprets \\r (carriage return)", async () => {
|
||||
const result = await $`echo -e ${"hello\\rworld"}`.text();
|
||||
expect(result).toBe("hello\rworld\n");
|
||||
});
|
||||
|
||||
test("echo -e interprets \\f (form feed)", async () => {
|
||||
const result = await $`echo -e ${"\\f"}`.text();
|
||||
expect(result).toBe("\f\n");
|
||||
});
|
||||
|
||||
test("echo -e interprets \\v (vertical tab)", async () => {
|
||||
const result = await $`echo -e ${"\\v"}`.text();
|
||||
expect(result).toBe("\v\n");
|
||||
});
|
||||
|
||||
test("echo -e interprets \\0nnn (octal)", async () => {
|
||||
// \0101 = 'A' (65 decimal)
|
||||
const result = await $`echo -e ${"\\0101"}`.text();
|
||||
expect(result).toBe("A\n");
|
||||
});
|
||||
|
||||
test("echo -e interprets \\xHH (hex)", async () => {
|
||||
// \x41 = 'A'
|
||||
const result = await $`echo -e ${"\\x41\\x42\\x43"}`.text();
|
||||
expect(result).toBe("ABC\n");
|
||||
});
|
||||
|
||||
test("echo -e \\c stops output", async () => {
|
||||
const result = await $`echo -e ${"hello\\cworld"}`.text();
|
||||
expect(result).toBe("hello");
|
||||
});
|
||||
|
||||
test("echo -e with \\e (escape character)", async () => {
|
||||
const result = await $`echo -e ${"\\e"}`.text();
|
||||
expect(result).toBe("\x1b\n");
|
||||
});
|
||||
|
||||
test("echo -E disables escape interpretation", async () => {
|
||||
const result = await $`echo -E ${"hello\\nworld"}`.text();
|
||||
expect(result).toBe("hello\\nworld\n");
|
||||
});
|
||||
|
||||
test("echo -eE (last wins: -E disables)", async () => {
|
||||
const result = await $`echo -eE ${"hello\\tworld"}`.text();
|
||||
expect(result).toBe("hello\\tworld\n");
|
||||
});
|
||||
|
||||
test("echo -Ee (last wins: -e enables)", async () => {
|
||||
const result = await $`echo -Ee ${"hello\\tworld"}`.text();
|
||||
expect(result).toBe("hello\tworld\n");
|
||||
});
|
||||
|
||||
test("echo -ne (no newline + escapes)", async () => {
|
||||
const result = await $`echo -ne ${"hello\\tworld"}`.text();
|
||||
expect(result).toBe("hello\tworld");
|
||||
});
|
||||
|
||||
test("echo -en (same as -ne)", async () => {
|
||||
const result = await $`echo -en ${"hello\\tworld"}`.text();
|
||||
expect(result).toBe("hello\tworld");
|
||||
});
|
||||
|
||||
test("echo -n still works (no newline)", async () => {
|
||||
const result = await $`echo -n hello`.text();
|
||||
expect(result).toBe("hello");
|
||||
});
|
||||
|
||||
test("echo with invalid flag outputs literally", async () => {
|
||||
const result = await $`echo -x hello`.text();
|
||||
expect(result).toBe("-x hello\n");
|
||||
});
|
||||
|
||||
test("echo -e piped to cat (original issue scenario)", async () => {
|
||||
const pw = "mypassword";
|
||||
const result = await $`echo -e ${pw} | cat`.text();
|
||||
expect(result).toBe("mypassword\n");
|
||||
});
|
||||
|
||||
test("echo without -e still works normally", async () => {
|
||||
const result = await $`echo hello world`.text();
|
||||
expect(result).toBe("hello world\n");
|
||||
});
|
||||
});
|
||||
119
test/regression/issue/19372.test.ts
Normal file
119
test/regression/issue/19372.test.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { bunEnv, bunExe } from "harness";
|
||||
|
||||
test("duplicate headers are joined per Node.js/RFC 9110 behavior", async () => {
|
||||
await using server = Bun.spawn({
|
||||
cmd: [
|
||||
bunExe(),
|
||||
"-e",
|
||||
`
|
||||
const http = require('http');
|
||||
const server = http.createServer((req, res) => {
|
||||
const result = JSON.stringify({
|
||||
// Custom headers: should be joined with ", "
|
||||
'x-test': req.headers['x-test'],
|
||||
// Known joinable header: should be joined with ", "
|
||||
'accept': req.headers['accept'],
|
||||
// Cookie: should be joined with "; "
|
||||
'cookie': req.headers['cookie'],
|
||||
// Content-Type: should keep first value only
|
||||
'content-type': req.headers['content-type'],
|
||||
// Host: should keep first value only
|
||||
'host': req.headers['host'],
|
||||
// Set-Cookie: already tested as array (not applicable for request headers typically)
|
||||
// rawHeaders should preserve all original headers
|
||||
'rawHeadersLength': req.rawHeaders.length,
|
||||
});
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(result);
|
||||
server.close();
|
||||
});
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
console.log(server.address().port);
|
||||
});
|
||||
`,
|
||||
],
|
||||
env: bunEnv,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
|
||||
const reader = server.stdout.getReader();
|
||||
let portStr = "";
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
portStr += new TextDecoder().decode(value);
|
||||
if (portStr.includes("\n")) break;
|
||||
}
|
||||
reader.releaseLock();
|
||||
const port = parseInt(portStr.trim(), 10);
|
||||
|
||||
// Send request with duplicate headers using raw TCP to ensure they're sent as separate lines
|
||||
const socket = await Bun.connect({
|
||||
hostname: "127.0.0.1",
|
||||
port,
|
||||
socket: {
|
||||
data(socket, data) {
|
||||
socket.data += new TextDecoder().decode(data);
|
||||
},
|
||||
open(socket) {
|
||||
socket.data = "";
|
||||
const request = [
|
||||
"GET / HTTP/1.1",
|
||||
"Host: localhost",
|
||||
"Host: otherhost",
|
||||
"X-Test: Hello",
|
||||
"X-Test: World",
|
||||
"Accept: text/html",
|
||||
"Accept: application/json",
|
||||
"Cookie: a=1",
|
||||
"Cookie: b=2",
|
||||
"Content-Type: text/plain",
|
||||
"Content-Type: application/json",
|
||||
"Connection: close",
|
||||
"",
|
||||
"",
|
||||
].join("\r\n");
|
||||
socket.write(request);
|
||||
},
|
||||
close() {},
|
||||
error() {},
|
||||
connectError() {},
|
||||
},
|
||||
});
|
||||
|
||||
// Wait for the response
|
||||
const deadline = Date.now() + 5000;
|
||||
while (!socket.data?.includes("\r\n\r\n") || !socket.data?.includes("}")) {
|
||||
if (Date.now() > deadline) break;
|
||||
await Bun.sleep(50);
|
||||
}
|
||||
socket.end();
|
||||
|
||||
// Parse the response body
|
||||
const body = socket.data.split("\r\n\r\n").slice(1).join("\r\n\r\n");
|
||||
const result = JSON.parse(body);
|
||||
|
||||
// Custom headers (x-*): joined with ", "
|
||||
expect(result["x-test"]).toBe("Hello, World");
|
||||
|
||||
// Known joinable headers: joined with ", "
|
||||
expect(result["accept"]).toBe("text/html, application/json");
|
||||
|
||||
// Cookie: joined with "; "
|
||||
expect(result["cookie"]).toBe("a=1; b=2");
|
||||
|
||||
// Content-Type: first value wins (drop duplicate)
|
||||
expect(result["content-type"]).toBe("text/plain");
|
||||
|
||||
// Host: first value wins (drop duplicate)
|
||||
expect(result["host"]).toBe("localhost");
|
||||
|
||||
// rawHeaders should contain all headers (including duplicates)
|
||||
// We sent 11 headers (Host x2, X-Test x2, Accept x2, Cookie x2, Content-Type x2, Connection x1)
|
||||
// Each header has name+value pair = 22 entries
|
||||
expect(result["rawHeadersLength"]).toBe(22);
|
||||
|
||||
await server.exited;
|
||||
});
|
||||
Reference in New Issue
Block a user