Skip to main content

merman_render/
resources.rs

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        };
467
468        let complexity = FlowchartComplexity::from_model(&model);
469        assert_eq!(complexity.nodes, 2);
470        assert_eq!(complexity.edges, 1);
471        assert_eq!(complexity.subgraphs, 1);
472        assert!(complexity.label_bytes >= "AlphaedgeCluster".len());
473    }
474}