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#""#.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('