diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d6675b..b13e392 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ All notable changes to Cosy will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed +- Findings from a Cora scan (#136): `--stdin`/`--json` temp input is now + unique, `O_EXCL`-created and removed afterwards (was a fixed `/tmp` name); + empty `slides` is an error instead of a panic; remote-image DNS resolution + has a timeout and local image reads are capped at 10 MB; the `b64` and + `wordwrap` filters now work on unescaped text (URLs with `&` were broken, + wrapping could split an XML entity); out-of-range numeric XML references + are escaped; `/api/templates` returns 500 if listing fails; JSON envelope + `width`/`height` now match the image; `Bearer` and `HTTP://` are + case-insensitive; CLI `--scale` must be in (0, 16]; CSV UTF-8 BOM is + stripped; `is_public_ip` rejects `2001:db8::/32` and NAT64 targets. + ## [0.5.1] — 2026-10-07 ### Security diff --git a/src/batch.rs b/src/batch.rs index 5adbd21..5e791bd 100644 --- a/src/batch.rs +++ b/src/batch.rs @@ -477,6 +477,8 @@ pub fn parse_csv(input: String) -> anyhow::Result>> { let mut row: Vec = Vec::new(); let mut cell = String::new(); let mut in_quotes = false; + // Excel and friends prepend a UTF-8 BOM; it would corrupt the first header. + let input = input.strip_prefix('\u{feff}').unwrap_or(&input).to_string(); let mut chars = input.chars().peekable(); while let Some(c) = chars.next() { diff --git a/src/cli.rs b/src/cli.rs index cd02a3c..f2d88ab 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -64,7 +64,7 @@ pub enum Command { output: PathBuf, /// Scale factor (1 = normal, 2 = retina/2x). - #[arg(long, default_value = "2")] + #[arg(long, default_value = "2", value_parser = parse_scale)] scale: f32, /// Additional font directory to load. @@ -214,7 +214,10 @@ impl Cli { } // Resolve input data source - let resolved_data = match Self::resolve_input(data, stdin, json)? { + // `_temp_input` removes the stdin/--json temp file when this arm + // returns, on every path. + let (resolved, _temp_input) = Self::resolve_input(data, stdin, json)?; + let resolved_data = match resolved { Some(d) => d, None => { eprintln!("✗ Error: must provide one of --data, --stdin, or --json"); @@ -224,7 +227,7 @@ impl Cli { // Validate input against the template schema before rendering. // Fails fast with a clear field-level message instead of a - // confusing Tera "invalid float literal" engine error. + // confusing minijinja "invalid float literal" engine error. let tmpl_def = match crate::template::load_template(&template) { Ok(t) => t, Err(e) => { @@ -420,43 +423,110 @@ impl Cli { } /// Resolve input data from --data (file), --stdin, or --json (inline string). - /// Returns Some(json_string) or None if no source provided. + /// Returns the data file path (None if no source was provided) plus a guard + /// that deletes the temp file, when one had to be written. fn resolve_input( data: Option, stdin: bool, json: Option, - ) -> anyhow::Result> { + ) -> anyhow::Result<(Option, Option)> { if let Some(path) = data { // --data: treat as file path - Ok(Some(path)) + Ok((Some(path), None)) } else if stdin { // --stdin: read from stdin, write to temp file for from_file compatibility let mut buffer = String::new(); std::io::stdin().read_to_string(&mut buffer)?; - let tmp = std::env::temp_dir().join("cosy-stdin-input.json"); - std::fs::write(&tmp, &buffer)?; - Ok(Some(tmp.to_string_lossy().to_string())) + let tmp = TempInput::create(&buffer)?; + Ok((Some(tmp.path_string()), Some(tmp))) } else if let Some(json_str) = json { // --json: write inline JSON to temp file - let tmp = std::env::temp_dir().join("cosy-json-input.json"); - std::fs::write(&tmp, &json_str)?; - Ok(Some(tmp.to_string_lossy().to_string())) + let tmp = TempInput::create(&json_str)?; + Ok((Some(tmp.path_string()), Some(tmp))) } else { - Ok(None) + Ok((None, None)) } } } +/// Clap value parser for `--scale`: finite and positive, capped so a typo +/// can't ask for a multi-gigabyte pixmap. +fn parse_scale(s: &str) -> Result { + let v: f32 = s.parse().map_err(|_| format!("'{s}' is not a number"))?; + if v.is_finite() && v > 0.0 && v <= 16.0 { + Ok(v) + } else { + Err("scale must be greater than 0 and at most 16".into()) + } +} + +/// Temp file holding inline/stdin JSON for the duration of a render. +/// +/// Uniquely named (pid + clock + counter) and created with `create_new` +/// (`O_EXCL`), so concurrent runs never clobber each other and a pre-planted +/// symlink in the temp dir is not followed. Removed on drop. +struct TempInput(PathBuf); + +impl TempInput { + fn create(contents: &str) -> anyhow::Result { + use std::io::Write; + use std::sync::atomic::{AtomicU32, Ordering}; + static COUNTER: AtomicU32 = AtomicU32::new(0); + for _ in 0..16 { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + let path = std::env::temp_dir().join(format!( + "cosy-input-{}-{}-{}.json", + std::process::id(), + nanos, + COUNTER.fetch_add(1, Ordering::Relaxed) + )); + let mut opts = std::fs::OpenOptions::new(); + opts.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + opts.mode(0o600); + } + match opts.open(&path) { + Ok(mut f) => { + f.write_all(contents.as_bytes())?; + return Ok(Self(path)); + } + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(e) => return Err(e.into()), + } + } + anyhow::bail!("could not create a unique temp file for input data") + } + + fn path_string(&self) -> String { + self.0.to_string_lossy().to_string() + } +} + +impl Drop for TempInput { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.0); + } +} + /// Debug helper: dump the processed SVG after minijinja rendering. fn dump_processed_svg(template_name: &str, data_path: &str) -> anyhow::Result { let template = crate::template::load_template(template_name)?; let dir = crate::template::find_template_dir_for(template_name)?; let data = crate::schema::InputData::from_file(data_path)?; + let slide = data + .slides + .first() + .ok_or_else(|| anyhow::anyhow!("Input data must contain at least one slide"))?; let svg = crate::template::process_template( &template, &dir, &data.brand, - &data.slides[0], + slide, crate::text::ImagePolicy::UNRESTRICTED, )?; println!("{}", svg); diff --git a/src/format.rs b/src/format.rs index 1dccaf9..c9e82b9 100644 --- a/src/format.rs +++ b/src/format.rs @@ -74,8 +74,8 @@ impl OutputFormat { /// `pixels` is premultiplied-alpha-independent RGBA8 from tiny-skia's /// `pixmap.data()`; `w`/`h` are the final (scaled) dimensions. /// - /// WebP is lossy at quality 90: visually indistinguishable for social - /// cards while cutting file size ~3-5x vs PNG. + /// WebP is encoded losslessly (the `image` crate has no lossy encoder), + /// so it is pixel-identical to PNG and usually somewhat smaller. pub fn encode(&self, pixels: &[u8], w: u32, h: u32) -> anyhow::Result> { self.encode_owned(pixels.to_vec(), w, h) } diff --git a/src/render.rs b/src/render.rs index a5124a9..47d07ca 100644 --- a/src/render.rs +++ b/src/render.rs @@ -39,6 +39,10 @@ pub fn render_template_data( ) -> anyhow::Result { let start = Instant::now(); + if data.slides.is_empty() { + anyhow::bail!("Input data must contain at least one slide"); + } + // 1. Load template let template = crate::template::load_template(template_name)?; log::info!( @@ -173,6 +177,18 @@ fn output_height(dims: &crate::schema::Dimensions, scale: f32) -> u32 { // ─── Render Single Slide ──────────────────────────────────────────── +/// Slide lookup that reports a bad index as an error instead of panicking +/// (`panic = "abort"` in release would kill the whole process). +fn slide_at(data: &InputData, slide_index: usize) -> anyhow::Result<&serde_json::Value> { + data.slides.get(slide_index).ok_or_else(|| { + anyhow::anyhow!( + "slide {} out of range (input has {} slide(s))", + slide_index, + data.slides.len() + ) + }) +} + /// Render a single slide to raw RGBA8 pixels (public API for /// format-aware callers — pair with `OutputFormat::encode`). pub fn render_slide_to_pixels( @@ -184,7 +200,7 @@ pub fn render_slide_to_pixels( font_db: &std::sync::Arc, image_policy: crate::text::ImagePolicy, ) -> anyhow::Result> { - let slide_data = &data.slides[slide_index]; + let slide_data = slide_at(data, slide_index)?; // 1. Process minijinja template → SVG string let svg_string = crate::template::process_template( @@ -241,7 +257,7 @@ pub fn render_slide_to_svg( font_db: &std::sync::Arc, image_policy: crate::text::ImagePolicy, ) -> anyhow::Result { - let slide_data = &data.slides[slide_index]; + let slide_data = slide_at(data, slide_index)?; // 1. Process minijinja template → SVG string let svg_string = crate::template::process_template( diff --git a/src/server.rs b/src/server.rs index 7777f49..9747604 100644 --- a/src/server.rs +++ b/src/server.rs @@ -435,23 +435,29 @@ async fn auth_middleware( .get(header::AUTHORIZATION) .and_then(|v| v.to_str().ok()); - match auth_header { - Some(header_val) if header_val.starts_with("Bearer ") => { - let token = &header_val[7..]; + match auth_header.and_then(bearer_credentials) { + Some(presented) => { // Constant-time comparison to prevent timing attacks - if constant_time_eq(token.as_bytes(), expected.as_bytes()) { + if constant_time_eq(presented.as_bytes(), expected.as_bytes()) { next.run(req).await } else { error_response(StatusCode::UNAUTHORIZED, "Invalid API key".into()) } } - _ => error_response( + None => error_response( StatusCode::UNAUTHORIZED, "Missing or invalid Authorization header. Expected: Bearer ".into(), ), } } +/// Credentials part of an `Authorization: Bearer ` header. +/// RFC 7235: the auth scheme is case-insensitive. +fn bearer_credentials(header_value: &str) -> Option<&str> { + let (scheme, rest) = header_value.split_once(' ')?; + scheme.eq_ignore_ascii_case("bearer").then_some(rest) +} + /// Constant-time byte comparison to prevent timing side-channel attacks. /// Compares all bytes regardless of match position, accumulating differences. fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { @@ -476,14 +482,19 @@ async fn health(State(state): State>) -> Json { }) } -async fn list_templates() -> Json> { +async fn list_templates() -> Response { // Reads every schema.json from disk — keep it off the async workers. - let templates = tokio::task::spawn_blocking(|| { + match tokio::task::spawn_blocking(|| { template::list_templates(std::path::Path::new("./templates")) }) .await - .unwrap_or_default(); - Json(templates) + { + Ok(templates) => Json(templates).into_response(), + Err(e) => error_response( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Template listing failed: {e}"), + ), + } } async fn render_handler( @@ -687,8 +698,8 @@ async fn render_handler( Json(RenderResponse { template: template_id, slides: slides_json.len(), - width: (dims.width as f32 * scale) as u32, - height: (dims.height as f32 * scale) as u32, + width: out_w, + height: out_h, data: slides_json, metadata: req.metadata, }), diff --git a/src/template.rs b/src/template.rs index ecaaa31..6e2190c 100644 --- a/src/template.rs +++ b/src/template.rs @@ -490,27 +490,91 @@ fn escape_xml_preserving_entities(s: &str) -> String { fn is_valid_entity(name: &str) -> bool { const PREDEFINED: [&str; 5] = ["amp", "lt", "gt", "quot", "apos"]; if let Some(hex) = name.strip_prefix("#x").or_else(|| name.strip_prefix("#X")) { - return !hex.is_empty() && hex.bytes().all(|b| b.is_ascii_hexdigit()); + return !hex.is_empty() + && hex.bytes().all(|b| b.is_ascii_hexdigit()) + && u32::from_str_radix(hex, 16).is_ok_and(is_xml_char); } if let Some(dec) = name.strip_prefix('#') { - return !dec.is_empty() && dec.bytes().all(|b| b.is_ascii_digit()); + return !dec.is_empty() + && dec.bytes().all(|b| b.is_ascii_digit()) + && dec.parse::().is_ok_and(is_xml_char); } PREDEFINED.contains(&name) } +/// XML 1.0 `Char` production: a numeric reference outside it (e.g. `�`, +/// `�`, `�`) makes the whole document malformed. +fn is_xml_char(c: u32) -> bool { + matches!(c, 0x9 | 0xA | 0xD | 0x20..=0xD7FF | 0xE000..=0xFFFD | 0x10000..=0x10FFFF) +} + +/// Inverse of [`escape_xml_preserving_entities`] for the entities it can emit +/// (predefined + numeric). Filters receive context values that were already +/// escaped; they must work on the real text and escape their own output. +fn unescape_xml_entities(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut rest = s; + while let Some(i) = rest.find('&') { + out.push_str(&rest[..i]); + let tail = &rest[i..]; + let decoded = tail.find(';').and_then(|end| { + let name = &tail[1..end]; + let ch = match name { + "amp" => Some('&'), + "lt" => Some('<'), + "gt" => Some('>'), + "quot" => Some('"'), + "apos" => Some('\''), + _ => name + .strip_prefix("#x") + .or_else(|| name.strip_prefix("#X")) + .and_then(|h| u32::from_str_radix(h, 16).ok()) + .or_else(|| name.strip_prefix('#').and_then(|d| d.parse().ok())) + .and_then(char::from_u32), + }; + ch.map(|c| (c, end + 1)) + }); + match decoded { + Some((c, len)) => { + out.push(c); + rest = &tail[len..]; + } + None => { + out.push('&'); + rest = &tail[1..]; + } + } + } + out.push_str(rest); + out +} + // ─── Custom minijinja Filters ─────────────────────────────────────── /// wordwrap filter: wrap text to N chars per line, returns joined string. /// Usage: `{{ slide.body|wordwrap(40) }}` +/// +/// The input is the already-escaped context value: wrapping it as-is would +/// count `&` as 5 characters and could split an entity across lines +/// (malformed XML). Wrap the real text, then escape the result again. fn filter_wordwrap(text: String, width: usize) -> String { - let lines = crate::text::wrap_text(&text, width); - lines.join("\n") + let lines = crate::text::wrap_text(&unescape_xml_entities(&text), width); + escape_xml_preserving_entities(&lines.join("\n")) } /// b64 filter: convert file path to base64 data URI. /// Usage: `{{ slide.image|b64 }}` fn filter_b64(path: String, policy: crate::text::ImagePolicy) -> String { - crate::text::image_to_data_uri(&path, policy).unwrap_or_default() + // The context value is XML-escaped; a URL like `?a=1&b=2` arrives as + // `?a=1&b=2` and must be restored before fetching. + let path = unescape_xml_entities(&path); + match crate::text::image_to_data_uri(&path, policy) { + Ok(uri) => uri, + Err(e) => { + log::warn!("b64 filter failed to load '{}': {}", path, e); + String::new() + } + } } // ─── Template Listing ─────────────────────────────────────────────── @@ -587,7 +651,7 @@ pub fn validate_input(template: &TemplateDef, data: &InputData) -> Vec { } // Type check: numeric/boolean fields must not be strings. // Templates use these fields in arithmetic expressions; a string - // value fails late inside the Tera engine with a confusing + // value fails late inside the minijinja engine with a confusing // "invalid float literal" error instead of a validation message. if let Some(value) = slide.get(name) { match spec.field_type { @@ -775,6 +839,89 @@ mod filter_tests { assert_eq!(load_svg(dir.path()).unwrap().as_str(), "two"); } + #[test] + fn test_unescape_roundtrip() { + for raw in [ + "a & b < c > d \"q\" 'p'", + "R&D", + "& literal", + "&", + "a&b&c", + ] { + let escaped = escape_xml_preserving_entities(raw); + let back = unescape_xml_entities(&escaped); + assert_eq!( + escape_xml_preserving_entities(&back), + escaped, + "raw={raw:?}" + ); + } + assert_eq!(unescape_xml_entities("a=1&b=2"), "a=1&b=2"); + assert_eq!(unescape_xml_entities("AB"), "AB"); + assert_eq!(unescape_xml_entities("bare & text"), "bare & text"); + } + + #[test] + fn test_wordwrap_never_splits_entities() { + let escaped = escape_xml_preserving_entities("AT&T & Sons & Co & more & more & more"); + for width in 2..12 { + let wrapped = filter_wordwrap(escaped.clone(), width); + for line in wrapped.lines() { + // every '&' on a line must start a complete valid entity + for (i, _) in line.match_indices('&') { + let end = line[i..] + .find(';') + .unwrap_or_else(|| panic!("entity split at width {width}: {line:?}")); + assert!(is_valid_entity(&line[i + 1..i + end]), "line {line:?}"); + } + } + } + } + + #[test] + fn test_b64_filter_restores_escaped_query_string() { + use std::io::{Read, Write}; + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let handle = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut buf = [0u8; 2048]; + let n = stream.read(&mut buf).unwrap(); + let req = String::from_utf8_lossy(&buf[..n]).to_string(); + stream + .write_all(b"HTTP/1.1 200 OK\r\nContent-Type: image/png\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok") + .unwrap(); + req + }); + let uri = filter_b64( + format!("http://{addr}/i.png?a=1&b=2"), + crate::text::ImagePolicy::UNRESTRICTED, + ); + assert!(uri.starts_with("data:image/png;base64,"), "got {uri:?}"); + let req = handle.join().unwrap(); + assert!(req.starts_with("GET /i.png?a=1&b=2 "), "request was: {req}"); + } + + #[test] + fn test_escape_xml_rejects_out_of_range_numeric_refs() { + for bad in [ + "�", + "�", + "�", + "�", + "�", + ] { + assert!( + escape_xml_preserving_entities(bad).starts_with("&#"), + "{bad} must be escaped" + ); + } + assert_eq!( + escape_xml_preserving_entities(" 😀"), + " 😀" + ); + } + #[test] fn test_filter_wordwrap_returns_wrapped_text() { let result = filter_wordwrap("Hello World Foo Bar".into(), 5); diff --git a/src/text.rs b/src/text.rs index 48c4f0f..55dac80 100644 --- a/src/text.rs +++ b/src/text.rs @@ -105,7 +105,17 @@ pub fn image_to_data_uri_with_size(path: &str, policy: ImagePolicy) -> anyhow::R .unwrap_or("png"), ); - let bytes = std::fs::read(path)?; + // Capped read: a device/endless file (`/dev/zero`) must not exhaust memory. + let mut bytes = Vec::new(); + std::fs::File::open(path)? + .take(MAX_REMOTE_IMAGE_BYTES + 1) + .read_to_end(&mut bytes)?; + if bytes.len() as u64 > MAX_REMOTE_IMAGE_BYTES { + anyhow::bail!( + "local image exceeds the {} MB size limit", + MAX_REMOTE_IMAGE_BYTES / 1024 / 1024 + ); + } let (width, height) = decode_dimensions(&bytes); Ok(LoadedImage { data_uri: encode_data_uri(&bytes, mime)?, @@ -128,7 +138,12 @@ fn decode_dimensions(bytes: &[u8]) -> (Option, Option) { /// True if the value looks like an http(s) URL rather than a file path. pub fn is_remote_url(value: &str) -> bool { - value.starts_with("http://") || value.starts_with("https://") + let has_prefix = |prefix: &str| { + value + .get(..prefix.len()) + .is_some_and(|head| head.eq_ignore_ascii_case(prefix)) + }; + has_prefix("http://") || has_prefix("https://") } fn encode_data_uri(bytes: &[u8], mime: &str) -> anyhow::Result { @@ -309,6 +324,33 @@ fn fetch_image_bytes_uncached( Ok((bytes, mime)) } +/// `getaddrinfo` has no timeout of its own, so a black-holed resolver would +/// stall the (blocking) render thread far beyond [`FETCH_TIMEOUT`]. Resolve on +/// a helper thread and give up after `timeout`; the helper finishes (or fails) +/// on its own and is then discarded. +fn resolve_with_timeout( + host: &str, + port: u16, + timeout: Duration, +) -> anyhow::Result> { + let (tx, rx) = std::sync::mpsc::channel(); + let host = host.to_string(); + std::thread::spawn(move || { + let _ = tx.send( + (host.as_str(), port) + .to_socket_addrs() + .map(Iterator::collect), + ); + }); + match rx.recv_timeout(timeout) { + Ok(result) => Ok(result?), + Err(_) => { + log::warn!("DNS resolution timed out"); + anyhow::bail!("failed to load remote image") + } + } +} + /// Resolve the URL host and return its first globally routable address. /// Rejects URLs whose hosts resolve only to non-public targets. fn validated_public_addr(url: &reqwest::Url) -> anyhow::Result { @@ -316,8 +358,8 @@ fn validated_public_addr(url: &reqwest::Url) -> anyhow::Result { .host_str() .ok_or_else(|| anyhow::anyhow!("failed to load remote image"))?; let port = url.port_or_known_default().unwrap_or(80); - let addr = (host, port) - .to_socket_addrs()? + let addr = resolve_with_timeout(host, port, FETCH_TIMEOUT)? + .into_iter() .find(|addr| is_public_ip(addr.ip())); match addr { Some(addr) => Ok(addr), @@ -353,6 +395,19 @@ fn is_public_ip(ip: IpAddr) -> bool { if let Some(v4) = v6.to_ipv4() { return is_public_ip(IpAddr::V4(v4)); } + let seg = v6.segments(); + // NAT64 well-known prefix 64:ff9b::/96 embeds an IPv4 target + if seg[0] == 0x64 && seg[1] == 0xff9b && seg[2..6] == [0, 0, 0, 0] { + let [a, b] = seg[6..8] else { unreachable!() }; + let v4 = std::net::Ipv4Addr::new((a >> 8) as u8, a as u8, (b >> 8) as u8, b as u8); + return is_public_ip(IpAddr::V4(v4)); + } + // 64:ff9b:1::/48 (local-use NAT64) and 2001:db8::/32 (documentation) + if (seg[0] == 0x64 && seg[1] == 0xff9b && seg[2] == 1) + || (seg[0] == 0x2001 && seg[1] == 0x0db8) + { + return false; + } !(v6.is_loopback() || v6.is_unspecified() || (v6.segments()[0] & 0xfe00) == 0xfc00 @@ -434,6 +489,31 @@ mod tests { assert_eq!(second.0, b"cachedbytes"); } + #[test] + fn test_is_remote_url_scheme_case_insensitive() { + assert!(is_remote_url("HTTP://example.com/a.png")); + assert!(is_remote_url("Https://example.com/a.png")); + assert!(!is_remote_url("httpx://example.com")); + assert!(!is_remote_url("é")); + } + + #[test] + fn test_local_image_read_is_capped() { + let path = std::env::temp_dir().join(format!("cosy_big_{}.png", std::process::id())); + let f = std::fs::File::create(&path).unwrap(); + f.set_len(MAX_REMOTE_IMAGE_BYTES + 1).unwrap(); // sparse + let err = image_to_data_uri(path.to_str().unwrap(), ImagePolicy::UNRESTRICTED) + .expect_err("oversized local file must be rejected"); + assert!(err.to_string().contains("size limit"), "got: {err}"); + let _ = std::fs::remove_file(&path); + } + + #[test] + fn test_resolve_with_timeout_resolves_literal() { + let addrs = resolve_with_timeout("127.0.0.1", 80, Duration::from_secs(5)).unwrap(); + assert_eq!(addrs[0].ip(), "127.0.0.1".parse::().unwrap()); + } + #[test] fn test_is_remote_url() { assert!(is_remote_url("https://example.com/a.png")); @@ -564,12 +644,22 @@ mod tests { "::127.0.0.1", "::169.254.169.254", "::10.0.0.1", + "2001:db8::1", + "64:ff9b::7f00:1", // NAT64 → 127.0.0.1 + "64:ff9b::a9fe:a9fe", // NAT64 → 169.254.169.254 + "64:ff9b:1::1", ] { let ip: IpAddr = ip.parse().unwrap(); assert!(!is_public_ip(ip), "{ip} must not be public"); } // globally routable → public - for ip in ["8.8.8.8", "1.1.1.1", "172.32.0.1", "2606:4700::1111"] { + for ip in [ + "8.8.8.8", + "1.1.1.1", + "172.32.0.1", + "2606:4700::1111", + "64:ff9b::808:808", // NAT64 → 8.8.8.8 + ] { let ip: IpAddr = ip.parse().unwrap(); assert!(is_public_ip(ip), "{ip} must be public"); } diff --git a/tests/api_server.rs b/tests/api_server.rs index ff7eb52..ad8cbdc 100644 --- a/tests/api_server.rs +++ b/tests/api_server.rs @@ -1067,3 +1067,40 @@ fn test_signed_get_exp_cannot_be_stripped_or_added() { let injected = format!("{legacy}&exp={}", now_secs() + 3600); assert_eq!(http_client().get(&injected).send().unwrap().status(), 403); } + +// ─── Cora scan findings (#136) ────────────────────────────────────── + +#[test] +fn test_bearer_scheme_is_case_insensitive() { + let url = start_server_with_key(Some("k".into())); + for scheme in ["Bearer", "bearer", "BEARER"] { + let resp = http_client() + .get(format!("{}/api/templates", url)) + .header("Authorization", format!("{scheme} k")) + .send() + .unwrap(); + assert_eq!(resp.status(), 200, "scheme {scheme}"); + } + let basic = http_client() + .get(format!("{}/api/templates", url)) + .header("Authorization", "Basic k") + .send() + .unwrap(); + assert_eq!(basic.status(), 401); +} + +#[test] +fn test_json_envelope_dimensions_match_image() { + use base64::Engine; + let url = start_server(); + // 0.3333 × canvas width has a fractional part ≥ .5 → truncation ≠ rounding + let body = render_body(serde_json::json!({ "scale": 0.3333, "response_format": "json" })); + let json: serde_json::Value = post_render(&url, &body).json().unwrap(); + let png = base64::engine::general_purpose::STANDARD + .decode(json["data"][0]["png_base64"].as_str().unwrap()) + .unwrap(); + let w = u32::from_be_bytes(png[16..20].try_into().unwrap()); + let h = u32::from_be_bytes(png[20..24].try_into().unwrap()); + assert_eq!(json["width"], w); + assert_eq!(json["height"], h); +} diff --git a/tests/cli_render.rs b/tests/cli_render.rs index f8d9015..476e7d3 100644 --- a/tests/cli_render.rs +++ b/tests/cli_render.rs @@ -957,3 +957,99 @@ fn test_render_dataset_webp_format_propagates() { assert!(produced[0].ends_with(".webp"), "{produced:?}"); assert_valid_webp(&out_dir.join(&produced[0])); } + +// ─── Cora scan findings (#136) ────────────────────────────────────── + +fn cosy() -> Command { + let mut cmd = Command::cargo_bin("cosy").unwrap(); + cmd.current_dir(env!("CARGO_MANIFEST_DIR")); + cmd +} + +const STAT_JSON: &str = + r#"{"brand":{"brand_name":"T"},"slides":[{"stat_number":"1%","stat_label":"x","source":"y"}]}"#; + +#[test] +fn test_inline_json_temp_file_is_cleaned_up() { + let tmp = tempfile::tempdir().unwrap(); + let out = tempfile::NamedTempFile::new().unwrap(); + cosy() + .env("TMPDIR", tmp.path()) + .args(["render", "-t", "stat-card", "--json", STAT_JSON, "-o"]) + .arg(out.path()) + .args(["--scale", "0.5"]) + .assert() + .success(); + let leftovers: Vec<_> = fs::read_dir(tmp.path()).unwrap().flatten().collect(); + assert!( + leftovers.is_empty(), + "temp input left behind: {leftovers:?}" + ); +} + +#[test] +fn test_concurrent_inline_json_runs_do_not_clobber() { + // Fixed temp names used to let parallel runs overwrite each other's input. + let handles: Vec<_> = (0..4) + .map(|i| { + std::thread::spawn(move || { + let json = STAT_JSON.replace("\"1%\"", &format!("\"{i}00%\"")); + let out = tempfile::NamedTempFile::new().unwrap(); + cosy() + .args(["render", "-t", "stat-card", "--json", &json, "-o"]) + .arg(out.path()) + .args(["--scale", "0.5"]) + .assert() + .success(); + assert_valid_png(out.path()); + }) + }) + .collect(); + for h in handles { + h.join().unwrap(); + } +} + +#[test] +fn test_empty_slides_is_an_error_not_a_panic() { + let out = tempfile::NamedTempFile::new().unwrap(); + for extra in [&[][..], &["--dump-svg"][..]] { + let assert = cosy() + .args([ + "render", + "-t", + "stat-card", + "--json", + r#"{"brand":{"brand_name":"T"},"slides":[]}"#, + "-o", + ]) + .arg(out.path()) + .args(extra) + .assert() + .failure(); + // exit code (not a signal/abort) and a readable message + let output = assert.get_output(); + assert!( + output.status.code().is_some(), + "process was killed: {output:?}" + ); + assert!( + String::from_utf8_lossy(&output.stderr).contains("at least one slide"), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + } +} + +#[test] +fn test_invalid_scale_rejected() { + let out = tempfile::NamedTempFile::new().unwrap(); + for bad in ["0", "-1", "NaN", "inf", "17", "abc"] { + cosy() + .args(["render", "-t", "stat-card", "--json", STAT_JSON, "-o"]) + .arg(out.path()) + .args(["--scale", bad]) + .assert() + .failure(); + } +}