From e777b3420e5eef25d9391105ce0fe15e5f20120e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 14:02:56 +0000 Subject: [PATCH 1/2] feat: serve binary content over the WFL web server (#573) The web server was text-only: WflHttpResponse.content and WflHttpRequest.body were String, so binary values passed to `respond to` were rendered via `format!("{:?}", ...)` (emitting "[Binary: N bytes]") and inbound bodies were flattened with from_utf8_lossy. Fonts, images, and other non-UTF-8 assets could not be served or received losslessly. Keep bytes end-to-end (additive, backward-compatible): - Response: WflHttpResponse.content is now Vec. `respond to req with` carries Value::Binary through as raw bytes; text/number/bool keep their UTF-8 rendering. Binary responses default to application/octet-stream when no content_type is given. The warp reply already emitted bytes. - Request: WflHttpRequest.body is now Vec. `body` stays a lossy-UTF-8 text variable (unchanged behavior); a new `body_bytes` binding exposes the raw bytes for binary uploads (write binary / echo). - New `mime_type of ` stdlib helper maps a file name/path to a content type by extension (fonts, images, common web types), falling back to application/octet-stream. - `length of ` now returns the byte count. - Analyzer/typechecker updated: register body_bytes and mime_type, allow Binary as response content. Verified end-to-end: a WFL server reading a real Alegreya .ttf with `read binary` and responding serves it byte-identical (matching SHA-256, content-type font/ttf). Adds tests/web_server_binary_test.rs (lossless serve, octet-stream default, inbound roundtrip, text unchanged), mime_type unit tests, and TestPrograms/binary_file_and_mime_test.wfl. Docs: binary file I/O section and binary-serving + body_bytes + mime_type web-server docs. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019kn1nQnJKZmhXDYZ3DKSv6 --- Docs/04-advanced-features/file-io.md | 61 ++++++ Docs/04-advanced-features/web-servers.md | 58 ++++++ TestPrograms/binary_file_and_mime_test.wfl | 71 +++++++ src/analyzer/mod.rs | 1 + src/builtins.rs | 3 + src/interpreter/mod.rs | 58 ++++-- src/stdlib/list.rs | 8 +- src/stdlib/web.rs | 93 +++++++++ src/typechecker/mod.rs | 8 +- tests/web_server_binary_test.rs | 231 +++++++++++++++++++++ 10 files changed, 572 insertions(+), 20 deletions(-) create mode 100644 TestPrograms/binary_file_and_mime_test.wfl create mode 100644 tests/web_server_binary_test.rs diff --git a/Docs/04-advanced-features/file-io.md b/Docs/04-advanced-features/file-io.md index 3f3e0042..bb79086c 100644 --- a/Docs/04-advanced-features/file-io.md +++ b/Docs/04-advanced-features/file-io.md @@ -143,6 +143,67 @@ display updated display "=== Demo Complete ===" ``` +## Binary Files + +Text reads (`read content`) require valid UTF-8, so they corrupt or reject +non-text files such as fonts, images, PDFs, or compressed archives. For those, +use WFL's **binary** file operations, which preserve every byte exactly. + +### Reading Binary Content + +```wfl +open file at "logo.png" for reading binary as image +store bytes as read binary from image +close file image + +display "Read " with length of bytes with " bytes" +``` + +**Syntax:** +```wfl +open file at "" for reading binary as +store as read binary from +close file +``` + +Read only the first N bytes (for example, to sniff a file header): + +```wfl +open file at "logo.png" for reading binary as image +store header as read 8 bytes from image +close file image +``` + +### Writing Binary Content + +`write binary` accepts either a binary value (from `read binary`) or a list of +byte numbers (each 0–255): + +```wfl +create list bytes: + add 137 + add 80 + add 78 + add 71 +end list + +open file at "signature.bin" for writing binary as out_file +write binary bytes into out_file +close file out_file +``` + +**Syntax:** +```wfl +open file at "" for writing binary as +write binary into +close file +``` + +> **Note:** `data` is a reserved keyword, so choose another variable name +> (e.g. `bytes`, `payload`, `content_bytes`) when storing binary content. + +Binary reads and writes are capped at 50 MB per operation as a safety limit. + ## Directory Operations ### Listing Files diff --git a/Docs/04-advanced-features/web-servers.md b/Docs/04-advanced-features/web-servers.md index f16ab046..ce7f0d70 100644 --- a/Docs/04-advanced-features/web-servers.md +++ b/Docs/04-advanced-features/web-servers.md @@ -174,6 +174,26 @@ store user_agent as header "User-Agent" from req display "User agent: " with user_agent ``` +### Request Body + +The request body is available two ways: + +- `body` — the body decoded as text (lossy UTF-8). Use this for form posts, + JSON, and other text payloads. +- `body_bytes` — the **raw bytes** of the body, preserved exactly. Use this for + binary uploads (file uploads, images, etc.); it can be written straight to + disk with `write binary` or echoed back with `respond to`. + +```wfl +wait for request comes in on server as req + +open file at "uploads/received.bin" for writing binary as out_file +write binary body_bytes into out_file +close file out_file + +respond to req with "Uploaded" +``` + ## Response Options ### With Status Code @@ -217,6 +237,44 @@ respond to with and content_type - `text/css` - CSS stylesheets - `application/javascript` - JavaScript files +### Serving Binary Files (Fonts, Images, etc.) + +`respond to` carries **binary** content losslessly, so you can serve fonts, +images, favicons, PDFs, and other non-text assets straight from disk. Read the +file with `read binary` (see [File I/O](file-io.md#binary-files)) and respond +with the resulting bytes: + +```wfl +open file at "public/fonts/Alegreya-Regular.ttf" for reading binary as f +store font_bytes as read binary from f +close file f + +respond to req with font_bytes and content_type "font/ttf" +``` + +If you omit `content_type` for binary content, it defaults to +`application/octet-stream` (rather than `text/plain`). + +To pick the content type automatically from a file name, use the `mime_type` +helper — handy for a static-file route: + +```wfl +store asset_path as "public/fonts/Alegreya-Regular.ttf" +open file at asset_path for reading binary as f +store asset_bytes as read binary from f +close file f + +respond to req with asset_bytes and content_type (mime_type of asset_path) +``` + +`mime_type of ` maps a file name or path to a content type by its +extension (`.ttf`→`font/ttf`, `.woff2`→`font/woff2`, `.png`→`image/png`, +`.svg`→`image/svg+xml`, `.ico`→`image/x-icon`, `.css`, `.js`, `.json`, …), +falling back to `application/octet-stream` for unknown extensions. + +> **Note:** `data` is a reserved keyword — name the variable holding the bytes +> something else (e.g. `font_bytes`, `payload`). + ### With Custom Headers Set extra response headers by passing a map to `and headers`. This mirrors the diff --git a/TestPrograms/binary_file_and_mime_test.wfl b/TestPrograms/binary_file_and_mime_test.wfl new file mode 100644 index 00000000..c71428c1 --- /dev/null +++ b/TestPrograms/binary_file_and_mime_test.wfl @@ -0,0 +1,71 @@ +// Binary file I/O and MIME type support (issue #573) +// Demonstrates that WFL handles binary content losslessly at the language +// level and can pick content types for static assets. Self-terminating +// (no web server): the end-to-end serving path is covered by the Rust +// integration test tests/web_server_binary_test.rs. + +display "=== Binary file + MIME test ===" + +// Build a small binary payload as a list of byte values (0-255), including +// values above 0x7F that are not valid standalone UTF-8. +create list payload: + add 0 + add 127 + add 128 + add 200 + add 255 +end list + +// Write the raw bytes, then read them back. +open file at "binary_roundtrip.bin" for writing binary as out_file +write binary payload into out_file +close file out_file + +open file at "binary_roundtrip.bin" for reading binary as in_file +store loaded as read binary from in_file +store loaded_size as file size of in_file +store loaded_length as length of loaded +close file in_file + +check if loaded_size is equal to 5: + display "PASS: round-tripped 5 bytes losslessly (file size)" +otherwise: + display "FAIL: expected 5 bytes, got " with loaded_size +end check + +check if loaded_length is equal to 5: + display "PASS: read binary returned 5 bytes" +otherwise: + display "FAIL: expected 5 bytes from read binary, got " with loaded_length +end check + +// The mime_type helper maps a file name/path to an HTTP content type so +// static assets (`respond to req with and content_type ...`) serve +// with the right type out of the box. +store ttf_type as mime_type of "Alegreya-Regular.ttf" +store png_type as mime_type of "favicon.png" +store unknown_type as mime_type of "notes" + +display "mime_type of Alegreya-Regular.ttf: " with ttf_type +check if ttf_type is equal to "font/ttf": + display "PASS: .ttf maps to font/ttf" +otherwise: + display "FAIL: .ttf mapped to " with ttf_type +end check + +check if png_type is equal to "image/png": + display "PASS: .png maps to image/png" +otherwise: + display "FAIL: .png mapped to " with png_type +end check + +check if unknown_type is equal to "application/octet-stream": + display "PASS: unknown extension falls back to application/octet-stream" +otherwise: + display "FAIL: unknown mapped to " with unknown_type +end check + +// Clean up the temporary file. +delete file at "binary_roundtrip.bin" + +display "=== Binary file + MIME test complete ===" diff --git a/src/analyzer/mod.rs b/src/analyzer/mod.rs index 39872d05..3b96639c 100644 --- a/src/analyzer/mod.rs +++ b/src/analyzer/mod.rs @@ -1578,6 +1578,7 @@ impl Analyzer { ("path", Type::Text), ("client_ip", Type::Text), ("body", Type::Text), + ("body_bytes", Type::Binary), ("headers", Type::Custom("Headers".to_string())), ]; diff --git a/src/builtins.rs b/src/builtins.rs index 03bea2bb..015b8703 100644 --- a/src/builtins.rs +++ b/src/builtins.rs @@ -49,6 +49,7 @@ const BUILTIN_FUNCTIONS: &[&str] = &[ // Web routing helpers (implemented in stdlib/web.rs) "path_params", "path_matches", + "mime_type", // Math functions (implemented in stdlib/math.rs) "min", "max", @@ -285,6 +286,8 @@ pub fn get_function_arity(name: &str) -> usize { // === WEB ROUTING HELPERS === // Two argument functions: (path, template) "path_params" | "path_matches" => 2, + // Single argument: (name) + "mime_type" => 1, // === TEXT FUNCTIONS === // Single argument functions diff --git a/src/interpreter/mod.rs b/src/interpreter/mod.rs index 9d6570f0..cca5a4fe 100644 --- a/src/interpreter/mod.rs +++ b/src/interpreter/mod.rs @@ -72,14 +72,20 @@ pub struct WflHttpRequest { pub method: String, pub path: String, pub client_ip: String, - pub body: String, + /// Raw request body bytes. Exposed to WFL both as a lossy-UTF-8 `body` + /// text variable (backward compatible) and as a lossless `body_bytes` + /// binary value, so binary uploads survive intact. + pub body: Vec, pub headers: HashMap, pub response_sender: Arc>>>, } #[derive(Debug, Clone)] pub struct WflHttpResponse { - pub content: String, + /// Raw response body bytes. Text responses store their UTF-8 encoding; + /// binary responses (`Value::Binary`) store their bytes verbatim, so + /// fonts/images/etc. are served losslessly. + pub content: Vec, pub status: u16, pub content_type: String, pub headers: HashMap, @@ -5147,8 +5153,10 @@ impl Interpreter { } } - // Convert body to string - let body_str = String::from_utf8_lossy(&body).to_string(); + // Keep the raw body bytes so binary uploads survive; + // WFL exposes both a lossy-text `body` and a lossless + // `body_bytes` view of these bytes. + let body_bytes = body.to_vec(); // Create response channel let (response_sender, response_receiver) = @@ -5160,7 +5168,7 @@ impl Interpreter { method: method.to_string(), path: path.as_str().to_string(), client_ip, - body: body_str, + body: body_bytes, headers: header_map, response_sender: Arc::new(tokio::sync::Mutex::new(Some( response_sender, @@ -5181,9 +5189,11 @@ impl Interpreter { warp::http::StatusCode::from_u16(response.status) .unwrap_or(warp::http::StatusCode::OK); - // Convert content to bytes for accurate Content-Length calculation - // HTTP Content-Length must match exact byte count of body - let content_bytes = response.content.into_bytes(); + // Content is already raw bytes (text responses + // stored their UTF-8 encoding, binary responses + // their verbatim bytes), so Content-Length is the + // exact byte count of the body. + let content_bytes = response.content; let content_length = content_bytes.len(); let mut reply_builder = warp::http::Response::builder() @@ -5643,10 +5653,15 @@ impl Interpreter { "client_ip".to_string(), Value::Text(Arc::from(request.client_ip.clone())), ); + // `body` is a lossy-UTF-8 text view (backward compatible); + // `body_bytes` is the lossless binary view for binary uploads. + let body_text = String::from_utf8_lossy(&request.body).into_owned(); + let body_binary = Value::Binary(Arc::from(request.body.as_slice())); request_properties.insert( "body".to_string(), - Value::Text(Arc::from(request.body.clone())), + Value::Text(Arc::from(body_text.as_str())), ); + request_properties.insert("body_bytes".to_string(), body_binary.clone()); request_properties.insert("headers".to_string(), headers_object.clone()); let request_object = Value::Object(Rc::new(RefCell::new(request_properties))); @@ -5664,7 +5679,8 @@ impl Interpreter { Value::Text(Arc::from(request.client_ip.clone())), ); - env_mut.define_or_replace("body", Value::Text(Arc::from(request.body.clone()))); + env_mut.define_or_replace("body", Value::Text(Arc::from(body_text.as_str()))); + env_mut.define_or_replace("body_bytes", body_binary); env_mut.define_or_replace("headers", headers_object); @@ -5715,13 +5731,17 @@ impl Interpreter { } }; - // Evaluate response content + // Evaluate response content. Binary values are carried through + // as raw bytes so fonts/images/etc. serve losslessly; text and + // scalar values keep their existing UTF-8 rendering. let content_val = self.evaluate_expression(content, Rc::clone(&env)).await?; - let content_str = match &content_val { - Value::Text(text) => text.as_ref().to_string(), - Value::Number(n) => n.to_string(), - Value::Bool(b) => b.to_string(), - _ => format!("{:?}", content_val), + let is_binary = matches!(content_val, Value::Binary(_)); + let content_bytes: Vec = match &content_val { + Value::Text(text) => text.as_bytes().to_vec(), + Value::Number(n) => n.to_string().into_bytes(), + Value::Bool(b) => b.to_string().into_bytes(), + Value::Binary(bytes) => bytes.to_vec(), + _ => format!("{content_val:?}").into_bytes(), }; // Evaluate status code (optional) @@ -5756,6 +5776,10 @@ impl Interpreter { )); } } + } else if is_binary { + // Binary responses default to a generic binary media type + // rather than text/plain so browsers don't misinterpret them. + "application/octet-stream".to_string() } else { "text/plain".to_string() // Default content type }; @@ -5819,7 +5843,7 @@ impl Interpreter { // Create response let response = WflHttpResponse { - content: content_str, + content: content_bytes, status: status_code, content_type: content_type_str, headers: custom_headers, diff --git a/src/stdlib/list.rs b/src/stdlib/list.rs index 5fb420fb..8b1cc0c7 100644 --- a/src/stdlib/list.rs +++ b/src/stdlib/list.rs @@ -14,8 +14,14 @@ pub fn native_length(args: Vec) -> Result { match &args[0] { Value::List(list) => Ok(Value::Number(list.borrow().len() as f64)), Value::Text(text) => Ok(Value::Number(text.chars().count() as f64)), + // Binary length is the number of bytes, letting `length of bytes` + // report the size of binary content (e.g. from `read binary`). + Value::Binary(bytes) => Ok(Value::Number(bytes.len() as f64)), _ => Err(RuntimeError::new( - format!("length expects a list or text, got {}", args[0].type_name()), + format!( + "length expects a list, text, or binary, got {}", + args[0].type_name() + ), 0, 0, )), diff --git a/src/stdlib/web.rs b/src/stdlib/web.rs index 64c91b11..fe69483d 100644 --- a/src/stdlib/web.rs +++ b/src/stdlib/web.rs @@ -95,9 +95,66 @@ pub fn native_path_matches(args: Vec) -> Result { Ok(Value::Bool(match_path_template(&path, &template).is_some())) } +/// Map a file name or path to an HTTP content type based on its extension. +/// +/// Returns a best-guess media type for common static-web assets (fonts, +/// images, and text formats). Unknown or extension-less names fall back to +/// `application/octet-stream`, the safe generic binary type. Matching is +/// case-insensitive and only the final `.ext` is considered. +fn content_type_for_extension(ext: &str) -> &'static str { + match ext.to_ascii_lowercase().as_str() { + // Fonts + "ttf" => "font/ttf", + "otf" => "font/otf", + "woff" => "font/woff", + "woff2" => "font/woff2", + // Images + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "gif" => "image/gif", + "webp" => "image/webp", + "svg" => "image/svg+xml", + "ico" => "image/x-icon", + "bmp" => "image/bmp", + // Text / web + "html" | "htm" => "text/html; charset=utf-8", + "css" => "text/css; charset=utf-8", + "js" | "mjs" => "text/javascript; charset=utf-8", + "json" => "application/json", + "xml" => "application/xml", + "txt" | "text" => "text/plain; charset=utf-8", + "csv" => "text/csv; charset=utf-8", + "md" => "text/markdown; charset=utf-8", + // Documents / binary + "pdf" => "application/pdf", + "wasm" => "application/wasm", + "zip" => "application/zip", + "gz" => "application/gzip", + _ => "application/octet-stream", + } +} + +/// mime_type(name) -> text content type for a file name or path. +/// Usage: mime_type of "Alegreya-Regular.ttf" -> "font/ttf" +pub fn native_mime_type(args: Vec) -> Result { + check_arg_count("mime_type", &args, 1)?; + let name = expect_text(&args[0])?; + + // Take the basename, then the substring after the final '.'. An empty or + // leading-dot-only name (e.g. ".gitignore") has no usable extension. + let base = name.rsplit(['/', '\\']).next().unwrap_or(""); + let ext = match base.rsplit_once('.') { + Some((stem, ext)) if !stem.is_empty() => ext, + _ => "", + }; + + Ok(Value::Text(Arc::from(content_type_for_extension(ext)))) +} + pub fn register_web(env: &mut Environment) { env.define_native("path_params", native_path_params); env.define_native("path_matches", native_path_matches); + env.define_native("mime_type", native_mime_type); } #[cfg(test)] @@ -151,4 +208,40 @@ mod tests { fn wildcard_must_be_final_segment() { assert!(match_path_template("/a/b/c", "/a/*rest/c").is_none()); } + + fn mime(name: &str) -> String { + match native_mime_type(vec![Value::Text(Arc::from(name))]).unwrap() { + Value::Text(t) => t.to_string(), + other => panic!("expected text, got {other:?}"), + } + } + + #[test] + fn mime_type_known_fonts_and_images() { + assert_eq!(mime("Alegreya-Regular.ttf"), "font/ttf"); + assert_eq!(mime("font.woff2"), "font/woff2"); + assert_eq!(mime("logo.png"), "image/png"); + assert_eq!(mime("photo.jpeg"), "image/jpeg"); + assert_eq!(mime("icon.svg"), "image/svg+xml"); + assert_eq!(mime("favicon.ico"), "image/x-icon"); + } + + #[test] + fn mime_type_is_case_insensitive() { + assert_eq!(mime("STYLE.CSS"), "text/css; charset=utf-8"); + assert_eq!(mime("Photo.JPG"), "image/jpeg"); + } + + #[test] + fn mime_type_uses_final_extension_and_basename() { + assert_eq!(mime("archive.tar.gz"), "application/gzip"); + assert_eq!(mime("/var/www/public/assets/fonts/x.ttf"), "font/ttf"); + } + + #[test] + fn mime_type_unknown_and_extensionless_fall_back() { + assert_eq!(mime("data.unknownext"), "application/octet-stream"); + assert_eq!(mime("README"), "application/octet-stream"); + assert_eq!(mime(".gitignore"), "application/octet-stream"); + } } diff --git a/src/typechecker/mod.rs b/src/typechecker/mod.rs index d05927e0..798a35ca 100644 --- a/src/typechecker/mod.rs +++ b/src/typechecker/mod.rs @@ -211,6 +211,7 @@ impl TypeChecker { // Web routing helpers "path_params" => Type::Map(Box::new(Type::Text), Box::new(Type::Text)), "path_matches" => Type::Boolean, + "mime_type" => Type::Text, // Text functions registered under stdlib-specific names "string_split" => Type::List(Box::new(Type::Text)), @@ -1900,14 +1901,17 @@ impl TypeChecker { line: _line, column: _column, } => { - // Check content type (should be text) + // Check content type (text or binary). Binary content is served + // losslessly as raw bytes (e.g. fonts, images); text content keeps + // its UTF-8 encoding. let content_type_result = self.infer_expression_type(content); if content_type_result != Type::Text + && content_type_result != Type::Binary && content_type_result != Type::Unknown && content_type_result != Type::Error { self.type_error( - "Response content must be text".to_string(), + "Response content must be text or binary".to_string(), Some(Type::Text), Some(content_type_result), *_line, diff --git a/tests/web_server_binary_test.rs b/tests/web_server_binary_test.rs new file mode 100644 index 00000000..f6f8e004 --- /dev/null +++ b/tests/web_server_binary_test.rs @@ -0,0 +1,231 @@ +// Integration tests for serving and receiving BINARY content over the WFL +// web server (issue #573). Verifies bytes survive end-to-end: file -> read +// binary -> respond -> warp -> client, and client -> request body -> body_bytes. + +use std::time::Duration; +use wfl::Interpreter; +use wfl::lexer::lex_wfl_with_positions; +use wfl::parser::Parser; + +/// Start a WFL server in a separate thread with its own Tokio runtime. +fn start_server_thread(code: String) -> std::thread::JoinHandle<()> { + std::thread::spawn(move || { + let rt = tokio::runtime::Runtime::new().expect("Failed to create runtime"); + rt.block_on(async { + let tokens = lex_wfl_with_positions(&code); + let mut parser = Parser::new(&tokens); + let ast = parser.parse().expect("Failed to parse WFL code"); + let mut interpreter = Interpreter::new(); + let _ = interpreter.interpret(&ast).await; + }); + }) +} + +/// A 1024-byte fixture spanning every byte value 0x00..=0xFF (repeated), which +/// contains invalid-UTF-8 sequences (e.g. lone 0xFF/0x80). Any lossy String +/// round-trip would corrupt it, so byte-identical output proves losslessness. +fn binary_fixture() -> Vec { + let mut bytes = Vec::with_capacity(1024); + for _ in 0..4 { + bytes.extend(0u8..=255); + } + bytes +} + +/// Absolute path (forward slashes) to a fresh temp file, safe to embed in a +/// WFL string literal on any platform. +fn temp_path(tag: &str) -> String { + let mut p = std::env::temp_dir(); + p.push(format!("wfl_binary_test_{tag}.bin")); + p.to_string_lossy().replace('\\', "/") +} + +#[tokio::test] +async fn test_serve_binary_font_bytes_lossless() { + let port = 8112; + let fixture = binary_fixture(); + let path = temp_path(&format!("serve_{port}")); + std::fs::write(&path, &fixture).expect("write fixture"); + + let server_code = format!( + r#" + listen on port {port} as test_server + wait for request comes in on test_server as req with timeout 10000 + open file at "{path}" for reading binary as f + store payload as read binary from f + close file f + respond to req with payload and content_type "font/ttf" + close server test_server + "# + ); + + let server_handle = start_server_thread(server_code); + tokio::time::sleep(Duration::from_millis(300)).await; + + let client = reqwest::Client::new(); + let response = client + .post(format!("http://127.0.0.1:{port}/font")) + .header("Content-Length", "0") + .body("") + .send() + .await + .expect("Failed to send request"); + + let content_type = response + .headers() + .get("content-type") + .expect("Content-Type header missing") + .to_str() + .unwrap() + .to_string(); + let content_length = response + .headers() + .get("content-length") + .expect("Content-Length header missing") + .to_str() + .unwrap() + .to_string(); + + let body = response.bytes().await.expect("read body"); + + assert_eq!(content_type, "font/ttf"); + assert_eq!( + content_length, + fixture.len().to_string(), + "Content-Length must equal the exact byte count" + ); + assert_eq!( + body.as_ref(), + fixture.as_slice(), + "served bytes must be byte-identical to the source file" + ); + + let _ = server_handle.join(); + let _ = std::fs::remove_file(&path); +} + +#[tokio::test] +async fn test_serve_binary_defaults_to_octet_stream() { + let port = 8113; + let fixture = binary_fixture(); + let path = temp_path(&format!("serve_{port}")); + std::fs::write(&path, &fixture).expect("write fixture"); + + // No content_type clause -> binary content should default to + // application/octet-stream (not text/plain). + let server_code = format!( + r#" + listen on port {port} as test_server + wait for request comes in on test_server as req with timeout 10000 + open file at "{path}" for reading binary as f + store payload as read binary from f + close file f + respond to req with payload + close server test_server + "# + ); + + let server_handle = start_server_thread(server_code); + tokio::time::sleep(Duration::from_millis(300)).await; + + let client = reqwest::Client::new(); + let response = client + .post(format!("http://127.0.0.1:{port}/asset")) + .header("Content-Length", "0") + .body("") + .send() + .await + .expect("Failed to send request"); + + let content_type = response + .headers() + .get("content-type") + .expect("Content-Type header missing") + .to_str() + .unwrap() + .to_string(); + let body = response.bytes().await.expect("read body"); + + assert_eq!(content_type, "application/octet-stream"); + assert_eq!(body.as_ref(), fixture.as_slice()); + + let _ = server_handle.join(); + let _ = std::fs::remove_file(&path); +} + +#[tokio::test] +async fn test_inbound_binary_body_roundtrip() { + let port = 8114; + let fixture = binary_fixture(); + + // Echo the raw request bytes straight back via body_bytes. If the inbound + // path were still text-only, non-UTF-8 bytes would be mangled. + let server_code = format!( + r#" + listen on port {port} as test_server + wait for request comes in on test_server as req with timeout 10000 + respond to req with body_bytes and content_type "application/octet-stream" + close server test_server + "# + ); + + let server_handle = start_server_thread(server_code); + tokio::time::sleep(Duration::from_millis(300)).await; + + let client = reqwest::Client::new(); + let response = client + .post(format!("http://127.0.0.1:{port}/upload")) + .body(fixture.clone()) + .send() + .await + .expect("Failed to send request"); + + let body = response.bytes().await.expect("read body"); + assert_eq!( + body.as_ref(), + fixture.as_slice(), + "echoed request body must be byte-identical (inbound binary preserved)" + ); + + let _ = server_handle.join(); +} + +/// Text responses must be entirely unchanged by the bytes migration. +#[tokio::test] +async fn test_text_response_unchanged() { + let port = 8115; + let server_code = format!( + r#" + listen on port {port} as test_server + wait for request comes in on test_server as req with timeout 10000 + respond to req with "Hello, 世界!" + close server test_server + "# + ); + + let server_handle = start_server_thread(server_code); + tokio::time::sleep(Duration::from_millis(300)).await; + + let client = reqwest::Client::new(); + let response = client + .post(format!("http://127.0.0.1:{port}/text")) + .header("Content-Length", "0") + .body("") + .send() + .await + .expect("Failed to send request"); + + let content_length = response + .headers() + .get("content-length") + .unwrap() + .to_str() + .unwrap() + .to_string(); + let body = response.text().await.expect("read body"); + + assert_eq!(body, "Hello, 世界!"); + assert_eq!(content_length, "14", "UTF-8 byte length preserved"); + + let _ = server_handle.join(); +} From 649d0e9b1ec81c69bbe2f42e9978ffddd96cd4f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 14:21:31 +0000 Subject: [PATCH 2/2] fix: don't report Text as the only expected type for binary-capable responses Address PR review: `respond to ... with ...` now accepts text or binary content, but the typechecker still passed Type::Text as the `expected` type to type_error, making the diagnostic claim only text was allowed. Pass None so the message ("Response content must be text or binary") stands on its own without a misleading "Expected Text" clause. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019kn1nQnJKZmhXDYZ3DKSv6 --- src/typechecker/mod.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/typechecker/mod.rs b/src/typechecker/mod.rs index 798a35ca..063b62db 100644 --- a/src/typechecker/mod.rs +++ b/src/typechecker/mod.rs @@ -1912,7 +1912,9 @@ impl TypeChecker { { self.type_error( "Response content must be text or binary".to_string(), - Some(Type::Text), + // No single expected type: both Text and Binary are + // accepted, so `None` avoids a misleading "expected Text". + None, Some(content_type_result), *_line, *_column,