1use merman_core::diagrams::flowchart::FlowchartV2Model;
2use merman_core::models::class_diagram::ClassDiagram;
3
4const KIB: usize = 1024;
5const MIB: usize = 1024 * KIB;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum RenderResourceProfile {
9 Interactive,
10 TypstPackage,
11 TrustedNative,
12 UnboundedForTrustedInput,
13}
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub struct RenderResourceLimits {
17 pub max_source_bytes: Option<usize>,
18 pub max_svg_bytes: Option<usize>,
19 pub max_flowchart_nodes: Option<usize>,
20 pub max_flowchart_edges: Option<usize>,
21 pub max_flowchart_subgraphs: Option<usize>,
22 pub max_class_nodes: Option<usize>,
23 pub max_class_edges: Option<usize>,
24 pub max_class_namespaces: Option<usize>,
25 pub max_label_bytes: Option<usize>,
26}
27
28impl Default for RenderResourceLimits {
29 fn default() -> Self {
30 Self::interactive()
31 }
32}
33
34impl RenderResourceLimits {
35 pub const fn interactive() -> Self {
36 Self {
37 max_source_bytes: Some(2 * MIB),
38 max_svg_bytes: Some(24 * MIB),
39 max_flowchart_nodes: Some(8_000),
40 max_flowchart_edges: Some(16_000),
41 max_flowchart_subgraphs: Some(2_000),
42 max_class_nodes: Some(8_000),
43 max_class_edges: Some(16_000),
44 max_class_namespaces: Some(2_000),
45 max_label_bytes: Some(2 * MIB),
46 }
47 }
48
49 pub const fn typst_package() -> Self {
50 Self {
51 max_source_bytes: Some(MIB),
52 max_svg_bytes: Some(12 * MIB),
53 max_flowchart_nodes: Some(4_000),
54 max_flowchart_edges: Some(8_000),
55 max_flowchart_subgraphs: Some(1_000),
56 max_class_nodes: Some(4_000),
57 max_class_edges: Some(8_000),
58 max_class_namespaces: Some(1_000),
59 max_label_bytes: Some(MIB),
60 }
61 }
62
63 pub const fn trusted_native() -> Self {
64 Self {
65 max_source_bytes: Some(16 * MIB),
66 max_svg_bytes: Some(128 * MIB),
67 max_flowchart_nodes: Some(50_000),
68 max_flowchart_edges: Some(100_000),
69 max_flowchart_subgraphs: Some(10_000),
70 max_class_nodes: Some(50_000),
71 max_class_edges: Some(100_000),
72 max_class_namespaces: Some(10_000),
73 max_label_bytes: Some(16 * MIB),
74 }
75 }
76
77 pub const fn unbounded_for_trusted_input() -> Self {
78 Self {
79 max_source_bytes: None,
80 max_svg_bytes: None,
81 max_flowchart_nodes: None,
82 max_flowchart_edges: None,
83 max_flowchart_subgraphs: None,
84 max_class_nodes: None,
85 max_class_edges: None,
86 max_class_namespaces: None,
87 max_label_bytes: None,
88 }
89 }
90
91 pub const fn for_profile(profile: RenderResourceProfile) -> Self {
92 match profile {
93 RenderResourceProfile::Interactive => Self::interactive(),
94 RenderResourceProfile::TypstPackage => Self::typst_package(),
95 RenderResourceProfile::TrustedNative => Self::trusted_native(),
96 RenderResourceProfile::UnboundedForTrustedInput => Self::unbounded_for_trusted_input(),
97 }
98 }
99
100 pub fn check_source_bytes(&self, source: &str) -> Result<(), ResourceLimitExceeded> {
101 check_limit(
102 ResourceLimitPhase::Source,
103 "max_source_bytes",
104 source.len(),
105 self.max_source_bytes,
106 )
107 }
108
109 pub fn check_svg_bytes(
110 &self,
111 svg: &str,
112 phase: ResourceLimitPhase,
113 ) -> Result<(), ResourceLimitExceeded> {
114 check_limit(phase, "max_svg_bytes", svg.len(), self.max_svg_bytes)
115 }
116
117 pub fn check_flowchart_complexity(
118 &self,
119 model: &FlowchartV2Model,
120 ) -> Result<FlowchartComplexity, ResourceLimitExceeded> {
121 let complexity = FlowchartComplexity::from_model(model);
122 check_limit(
123 ResourceLimitPhase::LayoutModel,
124 "max_flowchart_nodes",
125 complexity.nodes,
126 self.max_flowchart_nodes,
127 )?;
128 check_limit(
129 ResourceLimitPhase::LayoutModel,
130 "max_flowchart_edges",
131 complexity.edges,
132 self.max_flowchart_edges,
133 )?;
134 check_limit(
135 ResourceLimitPhase::LayoutModel,
136 "max_flowchart_subgraphs",
137 complexity.subgraphs,
138 self.max_flowchart_subgraphs,
139 )?;
140 check_limit(
141 ResourceLimitPhase::LayoutModel,
142 "max_label_bytes",
143 complexity.label_bytes,
144 self.max_label_bytes,
145 )?;
146 Ok(complexity)
147 }
148
149 pub fn check_class_complexity(
150 &self,
151 model: &ClassDiagram,
152 ) -> Result<ClassComplexity, ResourceLimitExceeded> {
153 let complexity = ClassComplexity::from_model(model);
154 check_limit(
155 ResourceLimitPhase::LayoutModel,
156 "max_class_nodes",
157 complexity.nodes,
158 self.max_class_nodes,
159 )?;
160 check_limit(
161 ResourceLimitPhase::LayoutModel,
162 "max_class_edges",
163 complexity.edges,
164 self.max_class_edges,
165 )?;
166 check_limit(
167 ResourceLimitPhase::LayoutModel,
168 "max_class_namespaces",
169 complexity.namespaces,
170 self.max_class_namespaces,
171 )?;
172 check_limit(
173 ResourceLimitPhase::LayoutModel,
174 "max_label_bytes",
175 complexity.label_bytes,
176 self.max_label_bytes,
177 )?;
178 Ok(complexity)
179 }
180}
181
182#[derive(Debug, Clone, Copy, PartialEq, Eq)]
183pub struct FlowchartComplexity {
184 pub nodes: usize,
185 pub edges: usize,
186 pub subgraphs: usize,
187 pub label_bytes: usize,
188}
189
190impl FlowchartComplexity {
191 pub fn from_model(model: &FlowchartV2Model) -> Self {
192 let node_label_bytes = model
193 .nodes
194 .iter()
195 .map(|node| optional_str_len(node.label.as_deref()) + node.id.len())
196 .sum::<usize>();
197 let edge_label_bytes = model
198 .edges
199 .iter()
200 .map(|edge| {
201 optional_str_len(edge.label.as_deref())
202 + edge.id.len()
203 + edge.from.len()
204 + edge.to.len()
205 })
206 .sum::<usize>();
207 let subgraph_label_bytes = model
208 .subgraphs
209 .iter()
210 .map(|subgraph| subgraph.id.len() + subgraph.title.len())
211 .sum::<usize>();
212 let tooltip_bytes = model.tooltips.values().map(String::len).sum::<usize>();
213
214 Self {
215 nodes: model.nodes.len().saturating_add(model.subgraphs.len()),
216 edges: model.edges.len(),
217 subgraphs: model.subgraphs.len(),
218 label_bytes: node_label_bytes
219 .saturating_add(edge_label_bytes)
220 .saturating_add(subgraph_label_bytes)
221 .saturating_add(tooltip_bytes),
222 }
223 }
224}
225
226#[derive(Debug, Clone, Copy, PartialEq, Eq)]
227pub struct ClassComplexity {
228 pub nodes: usize,
229 pub edges: usize,
230 pub namespaces: usize,
231 pub label_bytes: usize,
232}
233
234impl ClassComplexity {
235 pub fn from_model(model: &ClassDiagram) -> Self {
236 let class_label_bytes = model
237 .classes
238 .values()
239 .map(|node| {
240 node.id
241 .len()
242 .saturating_add(node.label.len())
243 .saturating_add(node.text.len())
244 .saturating_add(node.type_param.len())
245 .saturating_add(node.css_classes.len())
246 .saturating_add(node.tooltip.as_deref().map(str::len).unwrap_or(0))
247 .saturating_add(node.link.as_deref().map(str::len).unwrap_or(0))
248 .saturating_add(
249 node.members
250 .iter()
251 .chain(node.methods.iter())
252 .map(|member| {
253 member
254 .display_text
255 .len()
256 .saturating_add(member.id.len())
257 .saturating_add(member.parameters.len())
258 .saturating_add(member.return_type.len())
259 })
260 .sum::<usize>(),
261 )
262 .saturating_add(node.annotations.iter().map(String::len).sum::<usize>())
263 .saturating_add(node.styles.iter().map(String::len).sum::<usize>())
264 })
265 .sum::<usize>();
266 let relation_label_bytes = model
267 .relations
268 .iter()
269 .map(|rel| {
270 rel.id
271 .len()
272 .saturating_add(rel.id1.len())
273 .saturating_add(rel.id2.len())
274 .saturating_add(rel.title.len())
275 .saturating_add(rel.relation_title_1.len())
276 .saturating_add(rel.relation_title_2.len())
277 })
278 .sum::<usize>();
279 let note_label_bytes = model
280 .notes
281 .iter()
282 .map(|note| {
283 note.id
284 .len()
285 .saturating_add(note.text.len())
286 .saturating_add(note.class_id.as_deref().map(str::len).unwrap_or(0))
287 })
288 .sum::<usize>();
289 let interface_label_bytes = model
290 .interfaces
291 .iter()
292 .map(|iface| {
293 iface
294 .id
295 .len()
296 .saturating_add(iface.label.len())
297 .saturating_add(iface.class_id.len())
298 })
299 .sum::<usize>();
300 let namespace_label_bytes = model
301 .namespaces
302 .values()
303 .map(|namespace| {
304 namespace
305 .id
306 .len()
307 .saturating_add(namespace.label.len())
308 .saturating_add(namespace.parent.as_deref().map(str::len).unwrap_or(0))
309 })
310 .sum::<usize>();
311
312 Self {
313 nodes: model
314 .classes
315 .len()
316 .saturating_add(model.notes.len())
317 .saturating_add(model.interfaces.len())
318 .saturating_add(model.namespaces.len()),
319 edges: model.relations.len().saturating_add(
320 model
321 .notes
322 .iter()
323 .filter(|note| note.class_id.is_some())
324 .count(),
325 ),
326 namespaces: model.namespaces.len(),
327 label_bytes: class_label_bytes
328 .saturating_add(relation_label_bytes)
329 .saturating_add(note_label_bytes)
330 .saturating_add(interface_label_bytes)
331 .saturating_add(namespace_label_bytes),
332 }
333 }
334}
335
336#[derive(Debug, Clone, Copy, PartialEq, Eq)]
337pub enum ResourceLimitPhase {
338 Source,
339 LayoutModel,
340 SvgOutput,
341 SvgPostprocess,
342}
343
344impl ResourceLimitPhase {
345 pub const fn as_str(self) -> &'static str {
346 match self {
347 Self::Source => "source",
348 Self::LayoutModel => "layout_model",
349 Self::SvgOutput => "svg_output",
350 Self::SvgPostprocess => "svg_postprocess",
351 }
352 }
353}
354
355impl std::fmt::Display for ResourceLimitPhase {
356 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
357 f.write_str(self.as_str())
358 }
359}
360
361#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
362#[error("resource limit exceeded during {phase}: {limit} actual={actual} max={max}")]
363pub struct ResourceLimitExceeded {
364 pub phase: ResourceLimitPhase,
365 pub limit: &'static str,
366 pub actual: usize,
367 pub max: usize,
368}
369
370fn optional_str_len(value: Option<&str>) -> usize {
371 value.map(str::len).unwrap_or(0)
372}
373
374fn check_limit(
375 phase: ResourceLimitPhase,
376 limit: &'static str,
377 actual: usize,
378 max: Option<usize>,
379) -> Result<(), ResourceLimitExceeded> {
380 let Some(max) = max else {
381 return Ok(());
382 };
383 if actual <= max {
384 return Ok(());
385 }
386 Err(ResourceLimitExceeded {
387 phase,
388 limit,
389 actual,
390 max,
391 })
392}
393
394#[cfg(test)]
395mod tests {
396 use super::*;
397 use merman_core::diagrams::flowchart::{FlowEdge, FlowNode, FlowSubgraph};
398
399 #[test]
400 fn source_limit_reports_structured_error() {
401 let err = RenderResourceLimits {
402 max_source_bytes: Some(4),
403 ..RenderResourceLimits::unbounded_for_trusted_input()
404 }
405 .check_source_bytes("12345")
406 .unwrap_err();
407
408 assert_eq!(err.phase, ResourceLimitPhase::Source);
409 assert_eq!(err.limit, "max_source_bytes");
410 assert_eq!(err.actual, 5);
411 assert_eq!(err.max, 4);
412 }
413
414 #[test]
415 fn flowchart_complexity_counts_layout_nodes_and_labels() {
416 let model = FlowchartV2Model {
417 acc_descr: None,
418 acc_title: None,
419 class_defs: Default::default(),
420 direction: None,
421 edge_defaults: None,
422 vertex_calls: Vec::new(),
423 nodes: vec![FlowNode {
424 id: "A".to_string(),
425 label: Some("Alpha".to_string()),
426 label_type: None,
427 layout_shape: None,
428 icon: None,
429 form: None,
430 pos: None,
431 img: None,
432 constraint: None,
433 asset_width: None,
434 asset_height: None,
435 classes: Vec::new(),
436 styles: Vec::new(),
437 link: None,
438 link_target: None,
439 have_callback: false,
440 }],
441 edges: vec![FlowEdge {
442 id: "L-A-B".to_string(),
443 from: "A".to_string(),
444 to: "B".to_string(),
445 label: Some("edge".to_string()),
446 label_type: None,
447 edge_type: None,
448 stroke: None,
449 interpolate: None,
450 classes: Vec::new(),
451 style: Vec::new(),
452 animate: None,
453 animation: None,
454 length: 1,
455 }],
456 subgraphs: vec![FlowSubgraph {
457 id: "cluster".to_string(),
458 title: "Cluster".to_string(),
459 dir: None,
460 label_type: None,
461 classes: Vec::new(),
462 styles: Vec::new(),
463 nodes: vec!["A".to_string()],
464 }],
465 tooltips: Default::default(),
466 warning_facts: Vec::new(),
467 };
468
469 let complexity = FlowchartComplexity::from_model(&model);
470 assert_eq!(complexity.nodes, 2);
471 assert_eq!(complexity.edges, 1);
472 assert_eq!(complexity.subgraphs, 1);
473 assert!(complexity.label_bytes >= "AlphaedgeCluster".len());
474 }
475}