use std::fs;
use std::path::PathBuf;
use std::process::{Command, Output};
use std::sync::atomic::{AtomicU64, Ordering};
static NEXT_FIXTURE: AtomicU64 = AtomicU64::new(0);
struct Fixture {
root: PathBuf,
}
impl Fixture {
fn new(label: &str) -> Self {
let ordinal = NEXT_FIXTURE.fetch_add(1, Ordering::Relaxed);
let root = std::env::temp_dir().join(format!(
"noxid-wo44-{label}-{}-{ordinal}",
std::process::id()
));
fs::create_dir_all(&root).expect("create WO-44 fixture");
fs::write(root.join("package.json"), "{\"type\":\"module\"}\n")
.expect("write module marker");
Self { root }
}
fn write(&self, relative: &str, contents: &str) {
let path = self.root.join(relative);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).expect("create fixture parent");
}
fs::write(path, contents).expect("write fixture file");
}
fn build(&self) -> Output {
Command::new(env!("CARGO_BIN_EXE_noxid"))
.arg("build")
.arg(&self.root)
.arg("--out-dir")
.arg(self.root.join("dist"))
.output()
.expect("build WO-44 fixture")
}
fn run_node(&self, name: &str, source: &str) -> Output {
let script = self.root.join("dist").join(format!("{name}.mjs"));
fs::write(&script, source).expect("write WO-44 Node script");
Command::new("node")
.arg(script.file_name().expect("script filename"))
.current_dir(self.root.join("dist"))
.output()
.expect("execute generated upload handler")
}
fn test_scenarios(&self) -> Output {
Command::new(env!("CARGO_BIN_EXE_noxid"))
.args(["test", ".", "--gate", "--json"])
.current_dir(&self.root)
.output()
.expect("execute upload scenarios")
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.root);
}
}
fn assert_success(output: &Output, context: &str) {
assert!(
output.status.success(),
"{context}:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
#[test]
fn multipart_handler_streams_validates_and_stores_opaque_file_refs() {
let fixture = Fixture::new("handler");
fixture.write(
"Noxid.toml",
"[app]\ntitle = \"WO-44 uploads\"\n\n[server]\nblob_dir = \"blob-data\"\n",
);
fixture.write(
"server/api/upload.post.nox",
r#"endpoint Upload {
body {
caption: String
count: Int
avatar: File(maxSize: 32b, types: [image/png])
pages: Array<File(maxSize: 32b, types: [application/pdf])>
}
result: String
}
"#,
);
fixture.write(
"server/api/plain.post.nox",
"endpoint Plain { body { caption: String } result: String }\n",
);
fixture.write(
"server/host.js",
r#"let uploadCalls = 0;
export const endpoints = Object.freeze({
"endpoint:Upload@1": async ({ caption, count, avatar, pages }) => {
uploadCalls += 1;
const bytes = await avatar.bytes();
const reader = avatar.stream().getReader();
let streamed = 0;
while (true) { const { done, value } = await reader.read(); if (done) break; streamed += value.byteLength; }
const stored = await avatar.store("avatars");
return JSON.stringify({
caption, count, uploadCalls,
avatar: { sniffedType: avatar.sniffedType, size: avatar.size, sha256: avatar.sha256, name: avatar.name },
pageTypes: pages.map((page) => page.sniffedType),
bytes: bytes.byteLength, streamed, stored,
keys: Object.keys(avatar), methods: [typeof avatar.stream, typeof avatar.bytes, typeof avatar.store],
});
},
"endpoint:Plain@1": async ({ caption }) => caption,
});
"#,
);
assert_success(&fixture.build(), "build upload handler fixture");
let manifest = fs::read_to_string(fixture.root.join("dist/server/security.manifest.json"))
.expect("read generated security manifest");
let avatar = format!(
"{{\"field\":\"avatar\",\"maxSizeBytes\":32,\"types\":[\"image/png\"],\"multiple\":false,\"maxParts\":1,\"aggregateMaxSizeBytes\":32,\"partHeaderMaxBytes\":{},\"scalarFieldsMaxBytes\":{},\"filenameMaxBytes\":{}}}",
noxid_ir::MULTIPART_PART_HEADER_MAX_BYTES,
noxid_ir::MULTIPART_SCALAR_MAX_BYTES,
noxid_ir::UPLOAD_FILENAME_MAX_BYTES,
);
let pages = format!(
"{{\"field\":\"pages\",\"maxSizeBytes\":32,\"types\":[\"application/pdf\"],\"multiple\":true,\"maxParts\":{},\"aggregateMaxSizeBytes\":{},\"partHeaderMaxBytes\":{},\"scalarFieldsMaxBytes\":{},\"filenameMaxBytes\":{}}}",
noxid_ir::MULTIPART_MAX_PARTS,
32 * noxid_ir::MULTIPART_MAX_PARTS,
noxid_ir::MULTIPART_PART_HEADER_MAX_BYTES,
noxid_ir::MULTIPART_SCALAR_MAX_BYTES,
noxid_ir::UPLOAD_FILENAME_MAX_BYTES,
);
assert!(
manifest.contains(&format!("\"uploads\":[{avatar},{pages}]")),
"upload security contract missing: {manifest}"
);
let emitted = fs::read_to_string(fixture.root.join("dist/server/handler.js"))
.expect("read generated server handler");
for expected in [
format!(
"const ENDPOINT_MULTIPART_MAX_PARTS = {};",
noxid_ir::MULTIPART_MAX_PARTS
),
format!(
"const ENDPOINT_MULTIPART_HEADER_MAX_BYTES = {};",
noxid_ir::MULTIPART_PART_HEADER_MAX_BYTES
),
format!(
"const ENDPOINT_MULTIPART_SCALAR_MAX_BYTES = {};",
noxid_ir::MULTIPART_SCALAR_MAX_BYTES
),
format!(
"const ENDPOINT_UPLOAD_NAME_MAX_BYTES = {};",
noxid_ir::UPLOAD_FILENAME_MAX_BYTES
),
] {
assert!(
emitted.contains(&expected),
"the emitted multipart parser must be built from {expected}"
);
}
let openapi = fs::read_to_string(fixture.root.join("dist/api.openapi.json"))
.expect("read generated OpenAPI");
for expected in [
"\"multipart/form-data\"",
"\"x-noxid-max-size-bytes\": 32",
"\"x-noxid-type-verification\": \"magic-bytes\"",
"\"image/png\"",
"\"application/pdf\"",
"\"x-noxid-max-parts\": 256",
"\"x-noxid-part-header-max-bytes\": 16384",
"\"x-noxid-scalar-fields-max-bytes\": 1048576",
"\"x-noxid-filename-max-bytes\": 255",
"\"x-noxid-aggregate-max-size-bytes\": 8192",
"\"maxItems\": 256",
"\"encoding\"",
"\"contentType\": \"image/png\"",
"\"contentType\": \"application/pdf\"",
] {
assert!(
openapi.contains(expected),
"OpenAPI omitted {expected}: {openapi}"
);
}
let node = fixture.run_node(
"uploads",
r#"import { fetch as handle } from "./server/handler.js";
import { createHash } from "node:crypto";
import { readFile, readdir } from "node:fs/promises";
const encoder = new TextEncoder();
const concat = (...chunks) => {
const size = chunks.reduce((total, chunk) => total + chunk.byteLength, 0);
const result = new Uint8Array(size);
let offset = 0;
for (const chunk of chunks) { result.set(chunk, offset); offset += chunk.byteLength; }
return result;
};
const text = (value) => encoder.encode(value);
const png = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const pdf = text("%PDF-1.7\n");
function multipart(boundary, parts) {
const chunks = [];
for (const part of parts) {
chunks.push(text(`--${boundary}\r\nContent-Disposition: form-data; name="${part.name}"${part.filename === undefined ? "" : `; filename="${part.filename}"`}\r\n${part.mime ? `Content-Type: ${part.mime}\r\n` : ""}\r\n`));
chunks.push(typeof part.body === "string" ? text(part.body) : part.body, text("\r\n"));
}
chunks.push(text(`--${boundary}--\r\n`));
return concat(...chunks);
}
const request = (path, boundary, parts, body = multipart(boundary, parts)) => new Request(`http://noxid.test${path}`, {
method: "POST", headers: { "content-type": `multipart/form-data; boundary="${boundary}"` }, body,
...(body instanceof ReadableStream ? { duplex: "half" } : {}),
});
const validParts = [
{ name: "caption", body: "trusted sibling" },
{ name: "count", body: "7" },
{ name: "avatar", filename: "../../etc/passwd", mime: "application/pdf", body: png },
{ name: "pages", filename: "one.pdf", mime: "image/png", body: pdf },
{ name: "pages", filename: "two.pdf", mime: "text/plain", body: pdf },
];
let response = await handle(request("/api/upload", "valid-boundary", validParts));
let envelope = await response.json();
if (response.status !== 200) throw new Error(`valid upload refused: ${response.status} ${JSON.stringify(envelope)}`);
const result = JSON.parse(envelope.value);
const sha256 = createHash("sha256").update(png).digest("hex");
if (result.caption !== "trusted sibling" || result.count !== 7 || result.uploadCalls !== 1) throw new Error(`scalar siblings changed: ${JSON.stringify(result)}`);
if (result.avatar.sniffedType !== "image/png" || result.avatar.size !== 8 || result.avatar.sha256 !== sha256 || result.avatar.name !== "passwd") throw new Error(`FileRef metadata changed: ${JSON.stringify(result.avatar)}`);
if (result.bytes !== 8 || result.streamed !== 8 || result.stored.namespace !== "avatars" || result.stored.key !== sha256) throw new Error(`FileRef access failed: ${JSON.stringify(result)}`);
if (JSON.stringify(result.pageTypes) !== JSON.stringify(["application/pdf", "application/pdf"])) throw new Error(`Array<File> was not per-part: ${JSON.stringify(result)}`);
if (JSON.stringify(result.keys) !== JSON.stringify(["sniffedType", "size", "sha256", "name"]) || result.methods.some((kind) => kind !== "function")) throw new Error("FileRef was not opaque");
const blobNames = await readdir("blob-data/avatars");
if (JSON.stringify(blobNames) !== JSON.stringify([sha256]) || Buffer.compare(await readFile(`blob-data/avatars/${sha256}`), Buffer.from(png)) !== 0) throw new Error(`stored blob was not content-addressed: ${JSON.stringify(blobNames)}`);
const tooLarge = concat(png, new Uint8Array(25));
const oversizedWire = multipart("limit-boundary", [
{ name: "caption", body: "x" }, { name: "count", body: "1" },
{ name: "avatar", filename: "x.png", body: tooLarge }, { name: "pages", filename: "x.pdf", body: pdf },
]);
let produced = 0;
let cancelled = false;
const slowBody = new ReadableStream({
pull(controller) { if (produced === oversizedWire.length) controller.close(); else controller.enqueue(oversizedWire.slice(produced, produced += 1)); },
cancel() { cancelled = true; },
});
response = await handle(request("/api/upload", "limit-boundary", [], slowBody));
envelope = await response.json();
if (response.status !== 413 || envelope.error?.code !== "FILE_SIZE_LIMIT_EXCEEDED" || !cancelled || produced >= oversizedWire.length) throw new Error(`limit+1 did not cut stream: ${response.status} ${produced}/${oversizedWire.length} cancelled=${cancelled} ${JSON.stringify(envelope)}`);
response = await handle(request("/api/upload", "magic-boundary", [
{ name: "caption", body: "x" }, { name: "count", body: "1" },
{ name: "avatar", filename: "x.png", body: png }, { name: "pages", filename: "claimed.pdf", mime: "application/pdf", body: png },
]));
envelope = await response.json();
if (response.status !== 422 || envelope.error?.code !== "FILE_TYPE_MISMATCH" || envelope.error.details?.sniffedType !== "image/png" || envelope.error.details?.expectedTypes?.[0] !== "application/pdf") throw new Error(`magic mismatch lacked structured detail: ${response.status} ${JSON.stringify(envelope)}`);
response = await handle(request("/api/plain", "plain-boundary", [{ name: "caption", body: "no" }]));
envelope = await response.json();
if (response.status !== 415 || envelope.error?.code !== "ENDPOINT_MULTIPART_UNDECLARED") throw new Error(`non-File endpoint accepted multipart: ${response.status} ${JSON.stringify(envelope)}`);
response = await handle(request("/api/upload", "scalar-boundary", [
{ name: "caption", body: "x" }, { name: "count", body: "not-an-int" },
{ name: "avatar", filename: "x.png", body: png }, { name: "pages", filename: "x.pdf", body: pdf },
]));
envelope = await response.json();
if (response.status !== 422 || envelope.error?.code !== "ENDPOINT_BODY_TYPE") throw new Error(`scalar sibling validation drifted: ${response.status} ${JSON.stringify(envelope)}`);
// Scalar-sibling refusal is positional, and deliberately so: a streaming
// parser cannot know a later part's contents. The refusal is identical either
// way; only how much was ingested first differs. Both orderings are pinned so
// nobody "fixes" this into a buffering parser by accident.
for (const order of ["scalar-first", "file-first"]) {
const parts = order === "scalar-first"
? [{ name: "caption", body: "x" }, { name: "count", body: "nope" }, { name: "avatar", filename: "x.png", body: png }, { name: "pages", filename: "x.pdf", body: pdf }]
: [{ name: "avatar", filename: "x.png", body: png }, { name: "pages", filename: "x.pdf", body: pdf }, { name: "caption", body: "x" }, { name: "count", body: "nope" }];
response = await handle(request("/api/upload", `order-${order}`, parts));
envelope = await response.json();
if (response.status !== 422 || envelope.error?.code !== "ENDPOINT_BODY_TYPE" || envelope.error.details?.field !== "count") throw new Error(`${order} scalar refusal drifted: ${response.status} ${JSON.stringify(envelope)}`);
}
// RFC 2046 permits a preamble before the first boundary. It carries no field
// and is discarded, so a client that sends one is served rather than refused.
const preambled = concat(text("this is a MIME preamble\r\nignored by RFC 2046\r\n"), multipart("preamble-boundary", validParts));
response = await handle(request("/api/upload", "preamble-boundary", [], preambled));
envelope = await response.json();
if (response.status !== 200) throw new Error(`an RFC 2046 preamble was refused: ${response.status} ${JSON.stringify(envelope)}`);
if (JSON.parse(envelope.value).avatar.size !== 8) throw new Error(`preamble bled into the first part: ${envelope.value}`);
// The preamble is bounded like a part header block, so it is not an ingest
// path of its own.
const hugePreamble = concat(text("x".repeat(20_000) + "\r\n"), multipart("huge-preamble", validParts));
response = await handle(request("/api/upload", "huge-preamble", [], hugePreamble));
envelope = await response.json();
if (response.status !== 413 || envelope.error?.code !== "MULTIPART_PREAMBLE_TOO_LARGE") throw new Error(`an unbounded preamble was accepted: ${response.status} ${JSON.stringify(envelope)}`);
// A body with no boundary at all still reads as a bad boundary, not as an
// endless preamble.
response = await handle(request("/api/upload", "absent-boundary", [], text("no boundary anywhere in this body")));
envelope = await response.json();
if (response.status !== 400 || envelope.error?.code !== "MULTIPART_BOUNDARY_INVALID") throw new Error(`a boundaryless body was not refused: ${response.status} ${JSON.stringify(envelope)}`);
// A zero-byte part has no magic bytes, so there is nothing to verify the
// declared allow-list against. It is refused before the sniffer runs rather
// than admitted as `text/plain` on the strength of an empty prefix.
response = await handle(request("/api/upload", "empty-boundary", [
{ name: "caption", body: "x" }, { name: "count", body: "1" },
{ name: "avatar", filename: "x.png", body: new Uint8Array(0) }, { name: "pages", filename: "x.pdf", body: pdf },
]));
envelope = await response.json();
if (response.status !== 422 || envelope.error?.code !== "UPLOAD_EMPTY_FILE" || envelope.error.details?.observedBytes !== 0) throw new Error(`an empty part was sniffed instead of refused: ${response.status} ${JSON.stringify(envelope)}`);
"#,
);
assert_success(&node, "execute generated multipart and blob boundaries");
}
#[test]
fn upload_scenarios_supply_deterministic_bytes_and_assert_refusals() {
let fixture = Fixture::new("scenarios");
fixture.write(
"Noxid.toml",
"[app]\ntitle = \"WO-44 upload scenarios\"\n\n[server]\nblob_dir = \"scenario-blobs\"\ntracing = \"off\"\n",
);
fixture.write(
"server/api/document.post.nox",
r#"endpoint UploadDocument {
body {
caption: String
document: File(maxSize: 8b, types: [application/pdf])
}
result: String
scenario ValidPdf {
description: "fixture bytes reach the real upload host"
given: ["file document bytes JVBERi0= as application/pdf"]
when: request(body: Shape(caption = "valid", document = FileRef(sniffedType = "fixture", size = 0, sha256 = "0000000000000000000000000000000000000000000000000000000000000000", name = "fixture")))
expect: status == 200, value == "valid:application/pdf:5:document", refusal == ""
}
scenario WrongMagic {
description: "the MIME claim cannot overrule PNG magic bytes"
given: ["file document bytes iVBORw0KGgo= as application/pdf"]
when: request(body: Shape(caption = "wrong", document = FileRef(sniffedType = "fixture", size = 0, sha256 = "0000000000000000000000000000000000000000000000000000000000000000", name = "fixture")))
expect: status == 422, refusal == "FILE_TYPE_MISMATCH"
}
scenario LimitPlusOne {
description: "the ninth byte crosses the declared eight-byte cap"
given: ["file document bytes JVBERi0xMjM0 as application/pdf"]
when: request(body: Shape(caption = "large", document = FileRef(sniffedType = "fixture", size = 0, sha256 = "0000000000000000000000000000000000000000000000000000000000000000", name = "fixture")))
expect: status == 413, refusal == "FILE_SIZE_LIMIT_EXCEEDED"
}
}
"#,
);
fixture.write(
"server/host.js",
r#"export const endpoints = Object.freeze({
"endpoint:UploadDocument@1": async ({ caption, document }) => {
const bytes = await document.bytes();
await document.store("scenario-documents");
return `${caption}:${document.sniffedType}:${bytes.byteLength}:${document.name}`;
},
});
"#,
);
let scenarios = fixture.test_scenarios();
assert_success(
&scenarios,
"run deterministic upload scenarios without Docker",
);
let report = String::from_utf8_lossy(&scenarios.stdout);
assert!(
report.starts_with("{\"schemaVersion\":"),
"scenario stdout must remain deterministic JSON: {report}"
);
for name in ["ValidPdf", "WrongMagic", "LimitPlusOne"] {
assert!(
report.contains(name),
"scenario report omitted {name}: {report}"
);
}
assert!(
report.contains("\"passed\":3") && report.contains("\"failed\":0"),
"upload scenarios did not all pass: {report}"
);
}
#[test]
fn multipart_ceilings_are_measured_on_the_span_not_the_transport_chunking() {
let fixture = Fixture::new("ceilings");
fixture.write(
"Noxid.toml",
"[app]\ntitle = \"WO-44 multipart ceilings\"\n\n[server]\nblob_dir = \"ceiling-blobs\"\ntracing = \"off\"\n",
);
fixture.write(
"server/api/header.post.nox",
"endpoint Header { body { file: File(maxSize: 32b, types: [image/png]) } result: String }\n",
);
fixture.write(
"server/api/many.post.nox",
"endpoint Many { body { files: Array<File(maxSize: 8b, types: [image/png])> } result: String }\n",
);
fixture.write(
"server/api/scalar.post.nox",
"endpoint Scalar { body { payload: String file: File(maxSize: 8b, types: [image/png]) } result: String }\n",
);
fixture.write(
"server/host.js",
r#"export const endpoints = Object.freeze({
"endpoint:Header@1": async ({ file }) => String(file.size),
"endpoint:Many@1": async ({ files }) => String(files.length),
"endpoint:Scalar@1": async ({ payload, file }) => `${payload.length}:${file.size}`,
});
"#,
);
assert_success(&fixture.build(), "build multipart ceiling fixture");
let driver = format!(
r#"import {{ fetch as handle }} from "./server/handler.js";
const encoder = new TextEncoder();
const CRLF = "\r\n";
const PNG = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const HEADER_MAX = {header_max};
const SCALAR_MAX = {scalar_max};
const MAX_PARTS = {max_parts};
const text = (value) => encoder.encode(value);
const width = (value) => Buffer.byteLength(value, "utf8");
const concat = (...chunks) => {{
const total = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0);
const joined = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {{ joined.set(chunk, offset); offset += chunk.byteLength; }}
return joined;
}};
const check = (label, condition, detail) => {{ if (!condition) throw new Error(`${{label}}: ${{JSON.stringify(detail)}}`); }};
// A pull-driven stream hands the parser exactly these pieces, so the cut
// points below are the reads the decoder actually sees.
function streamOf(pieces) {{
let index = 0;
return new ReadableStream({{
pull(controller) {{ if (index === pieces.length) controller.close(); else controller.enqueue(pieces[index++]); }},
}});
}}
function cutAt(bytes, offsets) {{
const cuts = [0, ...offsets, bytes.byteLength];
const pieces = [];
for (let index = 0; index + 1 < cuts.length; index += 1) if (cuts[index + 1] > cuts[index]) pieces.push(bytes.slice(cuts[index], cuts[index + 1]));
return pieces;
}}
function cutEvery(bytes, size) {{
const pieces = [];
for (let offset = 0; offset < bytes.byteLength; offset += size) pieces.push(bytes.slice(offset, Math.min(bytes.byteLength, offset + size)));
return pieces;
}}
const post = async (path, boundary, body) => {{
const streamed = body instanceof ReadableStream;
const response = await handle(new Request(`http://noxid.test${{path}}`, {{
method: "POST",
headers: {{ "content-type": `multipart/form-data; boundary="${{boundary}}"` }},
body,
...(streamed ? {{ duplex: "half" }} : {{}}),
}}));
const envelope = await response.json();
return {{ status: response.status, code: envelope.error?.code ?? null }};
}};
async function bothDeliveries(label, path, boundary, bytes, pieces, expected) {{
const single = await post(path, boundary, bytes);
const split = await post(path, boundary, streamOf(pieces));
check(`${{label}} in one write`, single.status === expected.status && single.code === expected.code, {{ single, expected }});
check(`${{label}} split across writes`, split.status === expected.status && split.code === expected.code, {{ split, expected }});
}}
// --- part header block -------------------------------------------------
// `target` counts the bytes handed to the header parser: after the opening
// boundary line, before the terminating CRLFCRLF.
function headerCase(boundary, target) {{
const required = `Content-Disposition: form-data; name="file"; filename="x.png"${{CRLF}}Content-Type: image/png`;
const pad = `${{CRLF}}X-Pad: `;
const headers = required + pad + "a".repeat(target - width(required + pad));
check("header block construction", width(headers) === target, {{ target, built: width(headers) }});
const opening = `--${{boundary}}${{CRLF}}`;
const bytes = concat(text(opening), text(headers), text(CRLF + CRLF), PNG, text(CRLF), text(`--${{boundary}}--${{CRLF}}`));
// Stop the first read three bytes into the four-byte terminator: the block
// is complete, but the decoder cannot see its end yet.
return {{ bytes, pieces: cutAt(bytes, [width(opening) + target + 3]) }};
}}
let probe = headerCase("header-exact", HEADER_MAX);
await bothDeliveries("part header at the ceiling", "/api/header", "header-exact", probe.bytes, probe.pieces, {{ status: 200, code: null }});
probe = headerCase("header-over", HEADER_MAX + 1);
await bothDeliveries("part header one byte past the ceiling", "/api/header", "header-over", probe.bytes, probe.pieces, {{ status: 413, code: "MULTIPART_HEADERS_TOO_LARGE" }});
// --- preamble ----------------------------------------------------------
function preambleCase(boundary, target) {{
const part = concat(
text(`--${{boundary}}${{CRLF}}Content-Disposition: form-data; name="file"; filename="x.png"${{CRLF}}Content-Type: image/png${{CRLF}}${{CRLF}}`),
PNG,
text(CRLF),
text(`--${{boundary}}--${{CRLF}}`),
);
const bytes = concat(text("p".repeat(target) + CRLF), part);
// Stop one byte short of the whole `CRLF--boundary` delimiter.
return {{ bytes, pieces: cutAt(bytes, [target + width(`${{CRLF}}--${{boundary}}`) - 1]) }};
}}
probe = preambleCase("preamble-exact", HEADER_MAX);
await bothDeliveries("preamble at the ceiling", "/api/header", "preamble-exact", probe.bytes, probe.pieces, {{ status: 200, code: null }});
probe = preambleCase("preamble-over", HEADER_MAX + 1);
await bothDeliveries("preamble one byte past the ceiling", "/api/header", "preamble-over", probe.bytes, probe.pieces, {{ status: 413, code: "MULTIPART_PREAMBLE_TOO_LARGE" }});
// --- aggregate scalar bytes --------------------------------------------
function scalarCase(boundary, target) {{
const bytes = concat(
text(`--${{boundary}}${{CRLF}}Content-Disposition: form-data; name="payload"${{CRLF}}${{CRLF}}`),
text("s".repeat(target)),
text(`${{CRLF}}--${{boundary}}${{CRLF}}Content-Disposition: form-data; name="file"; filename="x.png"${{CRLF}}Content-Type: image/png${{CRLF}}${{CRLF}}`),
PNG,
text(CRLF),
text(`--${{boundary}}--${{CRLF}}`),
);
return {{ bytes, pieces: cutEvery(bytes, 4_096) }};
}}
probe = scalarCase("scalar-exact", SCALAR_MAX);
await bothDeliveries("scalar fields at the ceiling", "/api/scalar", "scalar-exact", probe.bytes, probe.pieces, {{ status: 200, code: null }});
probe = scalarCase("scalar-over", SCALAR_MAX + 1);
await bothDeliveries("scalar fields one byte past the ceiling", "/api/scalar", "scalar-over", probe.bytes, probe.pieces, {{ status: 413, code: "ENDPOINT_BODY_TOO_LARGE" }});
// --- part count --------------------------------------------------------
function partsCase(boundary, count) {{
const parts = [];
for (let index = 0; index < count; index += 1) {{
parts.push(text(`--${{boundary}}${{CRLF}}Content-Disposition: form-data; name="files"; filename="${{index}}.png"${{CRLF}}Content-Type: image/png${{CRLF}}${{CRLF}}`), PNG, text(CRLF));
}}
const bytes = concat(...parts, text(`--${{boundary}}--${{CRLF}}`));
return {{ bytes, pieces: cutEvery(bytes, 29) }};
}}
probe = partsCase("parts-exact", MAX_PARTS);
await bothDeliveries("part count at the ceiling", "/api/many", "parts-exact", probe.bytes, probe.pieces, {{ status: 200, code: null }});
probe = partsCase("parts-over", MAX_PARTS + 1);
await bothDeliveries("part count one past the ceiling", "/api/many", "parts-over", probe.bytes, probe.pieces, {{ status: 413, code: "MULTIPART_PART_LIMIT" }});
"#,
header_max = noxid_ir::MULTIPART_PART_HEADER_MAX_BYTES,
scalar_max = noxid_ir::MULTIPART_SCALAR_MAX_BYTES,
max_parts = noxid_ir::MULTIPART_MAX_PARTS,
);
let node = fixture.run_node("ceilings", &driver);
assert_success(&node, "multipart ceilings under both deliveries");
}
#[test]
fn an_upload_filename_is_bounded_in_utf8_bytes_at_a_code_point_boundary() {
let fixture = Fixture::new("filename");
fixture.write(
"Noxid.toml",
"[app]\ntitle = \"WO-44 filename ceiling\"\n\n[server]\nblob_dir = \"name-blobs\"\ntracing = \"off\"\n",
);
fixture.write(
"server/api/name.post.nox",
"endpoint Named { body { file: File(maxSize: 8b, types: [image/png]) } result: String }\n",
);
fixture.write(
"server/host.js",
r#"export const endpoints = Object.freeze({
"endpoint:Named@1": async ({ file }) => file.name,
});
"#,
);
assert_success(&fixture.build(), "build filename ceiling fixture");
let driver = r#"import { fetch as handle } from "./server/handler.js";
const NAME_MAX = __NAME_MAX__;
const encoder = new TextEncoder();
const PNG = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const CRLF = "\r\n";
const check = (label, condition, detail) => { if (!condition) throw new Error(`${label}: ${JSON.stringify(detail)}`); };
const concat = (...chunks) => {
const total = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0);
const joined = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) { joined.set(chunk, offset); offset += chunk.byteLength; }
return joined;
};
let ordinal = 0;
async function nameOf(filename) {
const boundary = `filename-${ordinal++}`;
const body = concat(
encoder.encode(`--${boundary}${CRLF}Content-Disposition: form-data; name="file"; filename="${filename}"${CRLF}Content-Type: image/png${CRLF}${CRLF}`),
PNG,
encoder.encode(CRLF),
encoder.encode(`--${boundary}--${CRLF}`),
);
const response = await handle(new Request("http://noxid.test/api/name", {
method: "POST",
headers: { "content-type": `multipart/form-data; boundary="${boundary}"` },
body,
}));
const envelope = await response.json();
check(`filename ${filename.length} chars accepted`, response.status === 200, envelope);
return envelope.value;
}
const bytesOf = (value) => encoder.encode(value).byteLength;
const half = Math.floor(NAME_MAX / 2);
// name, expected result. Each case is the same published ceiling read as
// bytes rather than as string positions.
const cases = [
// A name at the ceiling in one-byte characters is untouched.
["a".repeat(NAME_MAX), "a".repeat(NAME_MAX)],
// Two-byte characters at exactly the ceiling are likewise untouched.
["é".repeat(half) + "a", "é".repeat(half) + "a"],
// One `é` past it is 256 bytes in 128 string positions: the old code-unit
// slice returned all of them.
["é".repeat(half + 1), "é".repeat(half)],
// A four-byte astral character that ends exactly on the ceiling survives.
["a".repeat(NAME_MAX - 4) + "\u{1f600}", "a".repeat(NAME_MAX - 4) + "\u{1f600}"],
// One that straddles the cut is dropped whole, never split into a lone
// surrogate or a partial UTF-8 sequence.
["a".repeat(NAME_MAX - 3) + "\u{1f600}", "a".repeat(NAME_MAX - 3)],
["a".repeat(NAME_MAX - 2) + "\u{1f600}", "a".repeat(NAME_MAX - 2)],
];
for (const [filename, expected] of cases) {
const observed = await nameOf(filename);
check("filename truncation", observed === expected, { sent: filename.length, observed, expectedLength: expected.length });
check("filename stays within the published byte ceiling", bytesOf(observed) <= NAME_MAX, { observed, bytes: bytesOf(observed) });
check("filename is never cut mid-sequence", !observed.includes("�"), { observed });
for (let index = 0; index < observed.length; index += 1) {
const code = observed.charCodeAt(index);
check("filename never ends on a lone surrogate", !(code >= 0xd800 && code <= 0xdbff) || index + 1 < observed.length, { observed, index });
}
}
// The ceiling is metadata-only and still a leaf: a long traversal name is
// reduced to its final component before it is measured.
check("traversal is still reduced to a leaf", await nameOf("../../" + "z".repeat(NAME_MAX + 40)) === "z".repeat(NAME_MAX), "traversal");
"#
.replace(
"__NAME_MAX__",
&noxid_ir::UPLOAD_FILENAME_MAX_BYTES.to_string(),
);
let node = fixture.run_node("filename", &driver);
assert_success(&node, "UTF-8 filename ceiling at a code-point boundary");
}
#[test]
fn upload_ceilings_agree_across_guide_reference_manifest_and_handler() {
let max_parts = noxid_ir::MULTIPART_MAX_PARTS;
let header_max = noxid_ir::MULTIPART_PART_HEADER_MAX_BYTES;
let scalar_max = noxid_ir::MULTIPART_SCALAR_MAX_BYTES;
let filename_max = noxid_ir::UPLOAD_FILENAME_MAX_BYTES;
let guide = Command::new(env!("CARGO_BIN_EXE_noxid"))
.args(["agent-guide", "routing"])
.output()
.expect("run noxid agent-guide routing");
assert_success(&guide, "noxid agent-guide routing");
let guide = String::from_utf8_lossy(&guide.stdout).into_owned();
let upload_line = guide
.lines()
.find(|line| line.contains("Uploads are declared"))
.expect("agent guide upload section")
.to_string();
for expected in [
format!("maxParts: {max_parts}"),
format!("partHeaderMaxBytes: {header_max}"),
format!("scalarFieldsMaxBytes: {scalar_max}"),
format!("filenameMaxBytes: {filename_max}"),
"aggregateMaxSizeBytes = maxSizeBytes x maxParts".to_string(),
] {
assert!(
upload_line.contains(&expected),
"the generated agent guide omitted `{expected}`:\n{upload_line}"
);
}
let reference = fs::read_to_string(
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../docs/language-reference.md"),
)
.expect("read the language reference");
for row in [
format!(
"| `maxParts` | `x-noxid-max-parts` | `{max_parts}` for `Array<File>`, `1` otherwise |"
),
format!("| `partHeaderMaxBytes` | `x-noxid-part-header-max-bytes` | `{header_max}` |"),
format!("| `scalarFieldsMaxBytes` | `x-noxid-scalar-fields-max-bytes` | `{scalar_max}` |"),
format!("| `filenameMaxBytes` | `x-noxid-filename-max-bytes` | `{filename_max}` |"),
] {
assert!(
reference.contains(&row),
"the language reference upload table drifted from noxid_ir; expected row:\n{row}"
);
}
let fixture = Fixture::new("published-ceilings");
fixture.write(
"Noxid.toml",
"[app]\ntitle = \"WO-44 published ceilings\"\n\n[server]\nblob_dir = \"published-blobs\"\ntracing = \"off\"\n",
);
fixture.write(
"server/api/many.post.nox",
"endpoint Many { body { files: Array<File(maxSize: 8b, types: [image/png])> } result: String }\n",
);
fixture.write(
"server/host.js",
"export const endpoints = Object.freeze({ \"endpoint:Many@1\": async ({ files }) => String(files.length) });\n",
);
assert_success(&fixture.build(), "build published-ceiling fixture");
let manifest = fs::read_to_string(fixture.root.join("dist/server/security.manifest.json"))
.expect("read the generated security manifest");
let entry = format!(
"\"field\":\"files\",\"maxSizeBytes\":8,\"types\":[\"image/png\"],\"multiple\":true,\"maxParts\":{max_parts},\"aggregateMaxSizeBytes\":{},\"partHeaderMaxBytes\":{header_max},\"scalarFieldsMaxBytes\":{scalar_max},\"filenameMaxBytes\":{filename_max}",
8 * max_parts,
);
assert!(
manifest.contains(&entry),
"the security manifest drifted from noxid_ir:\n{manifest}"
);
let handler = fs::read_to_string(fixture.root.join("dist/server/handler.js"))
.expect("read the generated server handler");
for declaration in [
format!("const ENDPOINT_MULTIPART_MAX_PARTS = {max_parts};"),
format!("const ENDPOINT_MULTIPART_HEADER_MAX_BYTES = {header_max};"),
format!("const ENDPOINT_MULTIPART_SCALAR_MAX_BYTES = {scalar_max};"),
format!("const ENDPOINT_UPLOAD_NAME_MAX_BYTES = {filename_max};"),
] {
assert!(
handler.contains(&declaration),
"the emitted parser drifted from noxid_ir: {declaration}"
);
}
let openapi = fs::read_to_string(fixture.root.join("dist/api.openapi.json"))
.expect("read the generated OpenAPI document");
for extension in [
format!("\"x-noxid-max-parts\": {max_parts}"),
format!("\"x-noxid-part-header-max-bytes\": {header_max}"),
format!("\"x-noxid-scalar-fields-max-bytes\": {scalar_max}"),
format!("\"x-noxid-filename-max-bytes\": {filename_max}"),
] {
assert!(
openapi.contains(&extension),
"OpenAPI drifted from noxid_ir: {extension}"
);
}
}