diff --git a/crates/koyori-arc-core/src/backend/svg.rs b/crates/koyori-arc-core/src/backend/svg.rs index ad380b0..ede9bf4 100644 --- a/crates/koyori-arc-core/src/backend/svg.rs +++ b/crates/koyori-arc-core/src/backend/svg.rs @@ -241,6 +241,8 @@ fn render_text(svg: &mut String, t: &TextPrim, palette: &Palette) { fn escape_xml(s: &str) -> String { s.replace('&', "&") + .replace('"', """) + .replace('\'', "'") .replace('<', "<") .replace('>', ">") } @@ -248,3 +250,28 @@ fn escape_xml(s: &str) -> String { pub fn empty_svg() -> String { r#"Empty Gantt chartNo tasks to display"#.to_string() } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn escape_xml_quotes_in_attribute_context() { + let malicious = "x\" onmouseover=\"alert(1)\""; + let escaped = escape_xml(malicious); + assert!(!escaped.contains('"')); + assert!(escaped.contains(""")); + assert_eq!( + escaped, + "x" onmouseover="alert(1)"" + ); + } + + #[test] + fn data_task_id_attribute_is_safe() { + let id = "x\" onmouseover=\"alert(1)\""; + let fragment = format!(r#""#, escape_xml(id)); + assert!(fragment.contains("data-task-id=\"x" onmouseover="alert(1)"\"")); + assert!(!fragment.contains(r#"onmouseover="alert"#)); + } +} diff --git a/crates/koyori-arc-core/src/render.rs b/crates/koyori-arc-core/src/render.rs index f4af517..5ba2b2c 100644 --- a/crates/koyori-arc-core/src/render.rs +++ b/crates/koyori-arc-core/src/render.rs @@ -1,10 +1,123 @@ -use chrono::NaiveDate; +use chrono::{Duration, NaiveDate}; use wasm_bindgen::prelude::*; +use crate::backend::svg::empty_svg; use crate::backend::{BackendOutput, CanvasBackend, CommandBuffer, RenderBackend, SvgBackend}; +use crate::display_list::constants::{HEADER_H, LABEL_W, LEGEND_H, PX_PER_DAY, ROW_H}; use crate::display_list::{build_display_list, types::Palette, ScrollViewport}; use crate::graph::{GanttDep, GanttGraph, GanttTask}; +/// Upper bounds enforced at Wasm entry points to limit memory/CPU abuse. +pub const MAX_TASKS: usize = 10_000; +pub const MAX_DEPS: usize = 100_000; +/// Allows about 839 bytes per task at `MAX_TASKS`, enough for realistic IDs and +/// titles plus JSON overhead while rejecting multi-megabyte individual fields. +pub const MAX_TASKS_JSON_BYTES: usize = 8 * 1024 * 1024; +/// Allows about 167 bytes per dependency at `MAX_DEPS`, leaving ample room for +/// two realistic task IDs and JSON overhead without accepting unbounded input. +pub const MAX_DEPS_JSON_BYTES: usize = 16 * 1024 * 1024; +pub const MAX_DATE_SPAN_DAYS: i64 = 3_650; + +/// Conservative cross-browser Canvas2D backing-store edge limit. Browser and +/// GPU limits vary, but 16,384px is the lowest commonly supported maximum edge; +/// rejecting larger buffers avoids browser-specific blank canvases/context loss. +pub const MAX_CANVAS_SIDE_PX: usize = 16_384; +/// Keep the RGBA backing store near 128 MiB even when both dimensions are +/// individually supported. This leaves headroom for browser/GPU copies and +/// avoids a 16,384 x 16,384 canvas allocating roughly 1 GiB per buffer. +pub const MAX_CANVAS_AREA_PX: usize = 32 * 1024 * 1024; +const CHART_RIGHT_PADDING_PX: f64 = 20.0; +const CHART_BOTTOM_PADDING_PX: f64 = 10.0; +pub const MAX_CANVAS_ROWS: usize = ((MAX_CANVAS_SIDE_PX as f64 + - HEADER_H + - LEGEND_H + - CHART_BOTTOM_PADDING_PX) + / ROW_H) as usize; +pub const MAX_CANVAS_DATE_SPAN_DAYS: i64 = ((MAX_CANVAS_SIDE_PX as f64 + - LABEL_W + - CHART_RIGHT_PADDING_PX) + / PX_PER_DAY) as i64; + +fn json_error(msg: impl Into) -> String { + serde_json::json!({ "error": msg.into() }).to_string() +} + +fn raw_json_limit_error(tasks_json: &str, deps_json: &str) -> Option { + if tasks_json.len() > MAX_TASKS_JSON_BYTES { + return Some(format!( + "tasks JSON byte size exceeds limit ({MAX_TASKS_JSON_BYTES})" + )); + } + if deps_json.len() > MAX_DEPS_JSON_BYTES { + return Some(format!( + "dependencies JSON byte size exceeds limit ({MAX_DEPS_JSON_BYTES})" + )); + } + None +} + +fn common_graph_limit_error(tasks: &[GanttTask], deps: &[GanttDep]) -> Option { + if tasks.len() > MAX_TASKS { + return Some(format!("task count exceeds limit ({MAX_TASKS})")); + } + if deps.len() > MAX_DEPS { + return Some(format!("dependency count exceeds limit ({MAX_DEPS})")); + } + None +} + +fn rendered_date_span_days(tasks: &[GanttTask]) -> i64 { + if tasks.is_empty() { + return 0; + } + let min_start = tasks.iter().map(|t| t.start).min().unwrap(); + let max_date = tasks + .iter() + .map(|t| t.end.unwrap_or_else(|| t.start + Duration::days(1))) + .max() + .unwrap(); + (max_date - min_start).num_days().max(0) +} + +fn svg_graph_limit_error(tasks: &[GanttTask], deps: &[GanttDep]) -> Option { + if let Some(msg) = common_graph_limit_error(tasks, deps) { + return Some(msg); + } + if rendered_date_span_days(tasks) > MAX_DATE_SPAN_DAYS { + return Some(format!("date range exceeds limit ({MAX_DATE_SPAN_DAYS} days)")); + } + None +} + +fn canvas_graph_limit_error(tasks: &[GanttTask], deps: &[GanttDep]) -> Option { + if let Some(msg) = common_graph_limit_error(tasks, deps) { + return Some(msg); + } + if tasks.len() > MAX_CANVAS_ROWS { + return Some(format!( + "canvas row count exceeds limit ({MAX_CANVAS_ROWS} rows / {MAX_CANVAS_SIDE_PX}px)" + )); + } + if rendered_date_span_days(tasks) > MAX_CANVAS_DATE_SPAN_DAYS { + return Some(format!( + "canvas date range exceeds limit ({MAX_CANVAS_DATE_SPAN_DAYS} days / {MAX_CANVAS_SIDE_PX}px)" + )); + } + let width_px = rendered_date_span_days(tasks) as f64 * PX_PER_DAY + + LABEL_W + + CHART_RIGHT_PADDING_PX; + let height_px = tasks.len() as f64 * ROW_H + + HEADER_H + + LEGEND_H + + CHART_BOTTOM_PADDING_PX; + if width_px * height_px > MAX_CANVAS_AREA_PX as f64 { + return Some(format!( + "canvas area exceeds limit ({MAX_CANVAS_AREA_PX} pixels)" + )); + } + None +} + /// Native entry point — accepts typed structs directly. pub fn render( tasks: &[GanttTask], @@ -66,14 +179,20 @@ pub fn render_svg( today_iso: Option, viewport_json: Option, ) -> String { + if raw_json_limit_error(tasks_json, deps_json).is_some() { + return empty_svg(); + } let tasks: Vec = match serde_json::from_str(tasks_json) { Ok(v) => v, - Err(e) => return format!(""), + Err(_) => return empty_svg(), }; let deps: Vec = match serde_json::from_str(deps_json) { Ok(v) => v, - Err(e) => return format!(""), + Err(_) => return empty_svg(), }; + if svg_graph_limit_error(&tasks, &deps).is_some() { + return empty_svg(); + } let today = today_iso.and_then(|s| NaiveDate::parse_from_str(&s, "%Y-%m-%d").ok()); let scroll_viewport = viewport_json.and_then(|s| serde_json::from_str(&s).ok()); render(&tasks, &deps, today, scroll_viewport) @@ -88,18 +207,24 @@ pub fn render_canvas_commands( today_iso: Option, viewport_json: Option, ) -> String { + if let Some(msg) = raw_json_limit_error(tasks_json, deps_json) { + return json_error(msg); + } let tasks: Vec = match serde_json::from_str(tasks_json) { Ok(v) => v, - Err(e) => return format!(r#"{{"error":"parse error: {e}"}}"#), + Err(e) => return json_error(format!("parse error: {e}")), }; let deps: Vec = match serde_json::from_str(deps_json) { Ok(v) => v, - Err(e) => return format!(r#"{{"error":"parse error: {e}"}}"#), + Err(e) => return json_error(format!("parse error: {e}")), }; + if let Some(msg) = canvas_graph_limit_error(&tasks, &deps) { + return json_error(msg); + } let today = today_iso.and_then(|s| NaiveDate::parse_from_str(&s, "%Y-%m-%d").ok()); let scroll_viewport = viewport_json.and_then(|s| serde_json::from_str(&s).ok()); let buffer = render_canvas(&tasks, &deps, today, scroll_viewport); - serde_json::to_string(&buffer).unwrap_or_else(|e| format!(r#"{{"error":"serialize error: {e}"}}"#)) + serde_json::to_string(&buffer).unwrap_or_else(|e| json_error(format!("serialize error: {e}"))) } #[cfg(test)] @@ -239,9 +364,246 @@ mod tests { } #[test] - fn parse_error_returns_comment() { + fn parse_error_returns_safe_empty_svg() { let svg = render_svg("not json", "[]", None, None); - assert!(svg.starts_with(" -
+
@@ -188,6 +263,10 @@ function onCanvasClick(e: MouseEvent) { position: relative; width: 100%; } +.koyori-gantt-error { + padding: 12px; + color: #991b1b; +} .koyori-gantt-svg { position: absolute; top: 0; diff --git a/packages/arc-vue/src/canvasFallback.test.ts b/packages/arc-vue/src/canvasFallback.test.ts new file mode 100644 index 0000000..6a1c6d1 --- /dev/null +++ b/packages/arc-vue/src/canvasFallback.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { chartHeightForTaskCount, resolveCanvasFailure } from './canvasFallback'; + +describe('resolveCanvasFailure', () => { + it.each([408, 1_000, 10_000])( + 'uses a non-empty SVG fallback when %i tasks exceed Canvas capacity', + (taskCount) => { + const height = chartHeightForTaskCount(taskCount); + const svg = ``; + expect(resolveCanvasFailure( + 'canvas row count exceeds limit (407 rows / 16384px)', + svg, + )).toEqual({ mode: 'svg', svg }); + expect(svg).toContain(`height="${height}"`); + }, + ); + + it('resets the scroll domain for empty content', () => { + expect(chartHeightForTaskCount(0)).toBe(0); + expect(chartHeightForTaskCount(408)).toBe(16_400); + }); + + it('shows an error when the shared task limit rejects SVG too', () => { + expect(resolveCanvasFailure( + 'task count exceeds limit (10000)', + '', + )).toEqual({ mode: 'error', message: 'task count exceeds limit (10000)' }); + }); + + it('shows an error when SVG fallback unexpectedly renders empty', () => { + expect(resolveCanvasFailure( + 'canvas area exceeds limit (33554432 pixels)', + '', + )).toEqual({ + mode: 'error', + message: 'canvas area exceeds limit (33554432 pixels)', + }); + }); +}); diff --git a/packages/arc-vue/src/canvasFallback.ts b/packages/arc-vue/src/canvasFallback.ts new file mode 100644 index 0000000..eac7ab5 --- /dev/null +++ b/packages/arc-vue/src/canvasFallback.ts @@ -0,0 +1,26 @@ +export type CanvasFailureResolution = + | { mode: 'svg'; svg: string } + | { mode: 'error'; message: string }; + +export function chartHeightForTaskCount(taskCount: number): number { + return taskCount === 0 ? 0 : taskCount * 40 + 30 + 40 + 10; +} + +/** Canvas capacity errors can use the independently bounded SVG renderer. */ +export function isCanvasCapacityError(message: string): boolean { + return /^canvas (row count|date range|area) exceeds limit/.test(message); +} + +export function resolveCanvasFailure( + message: string, + fallbackSvg: string, +): CanvasFailureResolution { + if ( + isCanvasCapacityError(message) + && fallbackSvg.includes(' ({ clearRect })), + }; + return { canvas, clearRect }; +} + +describe('resetCanvasElement', () => { + it.each(['empty', 'error'])('removes bitmap and CSS dimensions on success -> %s', () => { + const { canvas, clearRect } = paintedCanvas(); + + resetCanvasElement(canvas as unknown as HTMLCanvasElement); + + expect(clearRect).toHaveBeenCalledWith(0, 0, 1200, 800); + expect(canvas.width).toBe(0); + expect(canvas.height).toBe(0); + expect(canvas.style.width).toBe('0px'); + expect(canvas.style.height).toBe('0px'); + }); +}); diff --git a/packages/arc-vue/src/canvasLifecycle.ts b/packages/arc-vue/src/canvasLifecycle.ts new file mode 100644 index 0000000..4cbe8ad --- /dev/null +++ b/packages/arc-vue/src/canvasLifecycle.ts @@ -0,0 +1,14 @@ +type ResettableCanvas = Pick; + +/** Clear both the drawing buffer and its layout footprint after empty/error output. */ +export function resetCanvasElement(canvas: ResettableCanvas | null): void { + if (!canvas) return; + const ctx = canvas.getContext('2d'); + if (ctx) { + ctx.clearRect(0, 0, canvas.width, canvas.height); + } + canvas.width = 0; + canvas.height = 0; + canvas.style.width = '0px'; + canvas.style.height = '0px'; +} diff --git a/packages/arc-vue/src/replayCommands.test.ts b/packages/arc-vue/src/replayCommands.test.ts index 91a670d..2726bf3 100644 --- a/packages/arc-vue/src/replayCommands.test.ts +++ b/packages/arc-vue/src/replayCommands.test.ts @@ -75,6 +75,12 @@ function createMockCtx() { } describe('replayCommands', () => { + it('parseCommandBuffer returns error buffer for invalid json', () => { + const buffer = parseCommandBuffer('not json'); + expect(buffer.error).toBe('invalid json'); + expect(buffer.ops).toEqual([]); + }); + it('parses golden CommandBuffer JSON', () => { const buffer = parseCommandBuffer(goldenJson); expect(buffer.viewport_width).toBe(350); diff --git a/packages/arc-vue/src/replayCommands.ts b/packages/arc-vue/src/replayCommands.ts index 6cd9118..65097fe 100644 --- a/packages/arc-vue/src/replayCommands.ts +++ b/packages/arc-vue/src/replayCommands.ts @@ -249,7 +249,17 @@ export function replayCommands( } export function parseCommandBuffer(json: string): CommandBuffer { - return JSON.parse(json) as CommandBuffer; + try { + return JSON.parse(json) as CommandBuffer; + } catch { + return { + viewport_width: 0, + viewport_height: 0, + ops: [], + palette: { colors: [] }, + error: 'invalid json', + }; + } } export function findTaskAtPoint(