Skip to main content

djvu_rs/
component_graph.rs

1//! Validated dependency graph for bundled `FORM:DJVM` documents.
2//!
3//! This module is deliberately a read-only structural view.  It uses the same
4//! `DIRM` and IFF chunk walkers as the document reader, but does not change
5//! parsing or mutation behaviour elsewhere in the crate.
6
7use std::collections::{BTreeMap, btree_map::Entry};
8
9use crate::{
10    dirm::{DirmComponentKind, DirmPayload},
11    iff::{parse_form, parse_form_body},
12};
13
14/// Maximum graph depth retained by the iterative traversals.
15///
16/// A DIRM count is a `u16`, so this is larger than every possible simple path
17/// in one document while still providing an explicit stack bound.
18const MAX_GRAPH_DEPTH: usize = u16::MAX as usize + 1;
19
20/// Maximum nodes and edges examined by one graph traversal.
21///
22/// Parsing rejects inputs with more INCL edges than this; the same cap keeps
23/// every public traversal and cycle check bounded even for adversarial input.
24const MAX_GRAPH_VISITS: usize = 1_000_000;
25
26/// Classification of a document component node.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum ComponentNodeKind {
29    /// A renderable `FORM:DJVU` page.
30    Page,
31    /// A `FORM:DJVI` shared component containing a `Djbz` chunk.
32    Dictionary,
33    /// A `FORM:DJVI` shared component containing `ANTa` or `ANTz`, but no `Djbz`.
34    Annotation,
35    /// A `FORM:DJVI` shared component with neither a dictionary nor annotations.
36    SharedOther,
37    /// A `FORM:THUM` thumbnail component.
38    Thumbnail,
39}
40
41/// One node in the component graph.
42#[derive(Debug, Clone)]
43pub struct ComponentNode {
44    /// Identity from the corresponding DIRM entry.
45    pub id: String,
46    /// Component classification derived from the embedded FORM.
47    pub kind: ComponentNodeKind,
48    /// Position in DIRM directory order.
49    pub dirm_index: usize,
50    /// Outgoing INCL edges (node indices), in INCL chunk order.
51    pub includes: Vec<usize>,
52    /// Reverse edges, in ascending DIRM-index order without duplicates.
53    pub included_by: Vec<usize>,
54}
55
56/// Structural problems found while building or validating a component graph.
57#[derive(Debug, Clone, PartialEq, Eq)]
58#[non_exhaustive]
59pub enum GraphError {
60    /// An INCL payload names no DIRM component.
61    MissingTarget {
62        /// Component that carried the INCL chunk.
63        source: String,
64        /// Trimmed component identity named by the INCL chunk.
65        target: String,
66    },
67    /// More than one DIRM entry declares the same identity.
68    DuplicateIdentity {
69        /// The duplicated DIRM identity.
70        id: String,
71    },
72    /// A component FORM type is unknown or disagrees with its DIRM entry.
73    InvalidComponentType {
74        /// Identity from the DIRM entry.
75        id: String,
76        /// Embedded FORM type.
77        form: [u8; 4],
78    },
79    /// A directed INCL cycle, with the first node repeated at the end.
80    Cycle {
81        /// Component identities forming the directed cycle.
82        path: Vec<String>,
83    },
84    /// The DJVM container or its DIRM/component bodies could not be parsed.
85    Malformed(String),
86}
87
88/// A read-only component dependency graph for a bundled DjVu document.
89pub struct ComponentGraph {
90    nodes: Vec<ComponentNode>,
91    id_to_index: BTreeMap<String, usize>,
92    validation_errors: Vec<GraphError>,
93}
94
95impl ComponentGraph {
96    /// Parse a bundled `FORM:DJVM` document and build its component graph.
97    ///
98    /// A malformed IFF container, DIRM payload, or embedded component body
99    /// returns [`GraphError::Malformed`]. Missing INCL targets, duplicate DIRM
100    /// identities, and component-type disagreements are retained for
101    /// [`Self::validate`] instead.
102    pub fn parse(bytes: &[u8]) -> Result<ComponentGraph, GraphError> {
103        let document =
104            parse_form(bytes).map_err(|error| GraphError::Malformed(error.to_string()))?;
105        if document.form_type != *b"DJVM" {
106            return Err(GraphError::Malformed(
107                "component graphs require a FORM:DJVM document".to_string(),
108            ));
109        }
110
111        let dirm_chunk = document
112            .chunks
113            .iter()
114            .find(|chunk| chunk.id == *b"DIRM")
115            .ok_or_else(|| GraphError::Malformed("missing DIRM chunk".to_string()))?;
116        let dirm = DirmPayload::decode(dirm_chunk.data)
117            .map_err(|error| GraphError::Malformed(error.to_string()))?;
118        if !dirm.is_bundled() {
119            return Err(GraphError::Malformed(
120                "component graphs support bundled FORM:DJVM documents only".to_string(),
121            ));
122        }
123
124        let directory = dirm.components();
125        let component_forms: Vec<_> = document
126            .chunks
127            .iter()
128            .filter(|chunk| chunk.id == *b"FORM")
129            .collect();
130        if component_forms.len() < directory.len() {
131            return Err(GraphError::Malformed(
132                "DIRM entry count exceeds embedded FORM components".to_string(),
133            ));
134        }
135
136        let mut nodes = Vec::with_capacity(directory.len());
137        let mut id_to_index = BTreeMap::new();
138        let mut validation_errors = Vec::new();
139        let mut unresolved_edges = Vec::new();
140
141        for (dirm_index, entry) in directory.iter().enumerate() {
142            let component = component_forms[dirm_index];
143            let form = component
144                .data
145                .get(..4)
146                .and_then(|bytes| bytes.try_into().ok())
147                .ok_or_else(|| {
148                    GraphError::Malformed("component FORM body too short".to_string())
149                })?;
150            let body = component
151                .data
152                .get(4..)
153                .ok_or_else(|| GraphError::Malformed("component FORM body missing".to_string()))?;
154            let chunks =
155                parse_form_body(body).map_err(|error| GraphError::Malformed(error.to_string()))?;
156
157            if form != expected_form(entry.kind) || !is_component_form(form) {
158                validation_errors.push(GraphError::InvalidComponentType {
159                    id: entry.id.clone(),
160                    form,
161                });
162            }
163
164            let node_index = nodes.len();
165            match id_to_index.entry(entry.id.clone()) {
166                Entry::Occupied(_) => validation_errors.push(GraphError::DuplicateIdentity {
167                    id: entry.id.clone(),
168                }),
169                Entry::Vacant(slot) => {
170                    slot.insert(node_index);
171                }
172            }
173
174            for chunk in chunks.iter().filter(|chunk| chunk.id == *b"INCL") {
175                if unresolved_edges.len() == MAX_GRAPH_VISITS {
176                    return Err(GraphError::Malformed(
177                        "component graph INCL edge limit exceeded".to_string(),
178                    ));
179                }
180                let target = component_id_from_incl(chunk.data)?;
181                unresolved_edges.push((node_index, target));
182            }
183
184            nodes.push(ComponentNode {
185                id: entry.id.clone(),
186                kind: classify_component(form, &chunks, entry.kind),
187                dirm_index,
188                includes: Vec::new(),
189                included_by: Vec::new(),
190            });
191        }
192
193        for (source, target) in unresolved_edges {
194            if let Some(&target_index) = id_to_index.get(&target) {
195                nodes[source].includes.push(target_index);
196            } else {
197                validation_errors.push(GraphError::MissingTarget {
198                    source: nodes[source].id.clone(),
199                    target,
200                });
201            }
202        }
203
204        let mut reverse_edges = vec![Vec::new(); nodes.len()];
205        for (source, node) in nodes.iter().enumerate() {
206            for &target in &node.includes {
207                reverse_edges[target].push(source);
208            }
209        }
210        for (node, mut included_by) in nodes.iter_mut().zip(reverse_edges) {
211            included_by.sort_unstable();
212            included_by.dedup();
213            node.included_by = included_by;
214        }
215
216        Ok(ComponentGraph {
217            nodes,
218            id_to_index,
219            validation_errors,
220        })
221    }
222
223    /// All component nodes in DIRM directory order.
224    pub fn nodes(&self) -> &[ComponentNode] {
225        &self.nodes
226    }
227
228    /// Look up a component by its DIRM identity.
229    ///
230    /// When a DIRM identity is duplicated, this returns the first declaration;
231    /// [`Self::validate`] reports the duplicate.
232    pub fn node(&self, id: &str) -> Option<&ComponentNode> {
233        self.id_to_index
234            .get(id)
235            .and_then(|&index| self.nodes.get(index))
236    }
237
238    /// Outgoing INCL targets of `id`, in INCL chunk order.
239    pub fn includes(&self, id: &str) -> Vec<&ComponentNode> {
240        self.node(id)
241            .map(|node| {
242                node.includes
243                    .iter()
244                    .filter_map(|&index| self.nodes.get(index))
245                    .collect()
246            })
247            .unwrap_or_default()
248    }
249
250    /// Components that INCL `id`, in DIRM order without duplicates.
251    pub fn included_by(&self, id: &str) -> Vec<&ComponentNode> {
252        self.node(id)
253            .map(|node| {
254                node.included_by
255                    .iter()
256                    .filter_map(|&index| self.nodes.get(index))
257                    .collect()
258            })
259            .unwrap_or_default()
260    }
261
262    /// Transitive INCL closure of the given root identities, including roots.
263    ///
264    /// The traversal is iterative and bounded. Unknown roots are ignored, and
265    /// a resource cap returns the prefix discovered before that cap.
266    pub fn transitive_closure(&self, roots: &[&str]) -> Vec<usize> {
267        let root_indices = roots
268            .iter()
269            .filter_map(|id| self.id_to_index.get(*id).copied())
270            .collect();
271        self.bounded_closure(root_indices)
272    }
273
274    /// Nodes not reachable from any page, in DIRM directory order.
275    pub fn unreachable_components(&self) -> Vec<usize> {
276        let roots = self
277            .nodes
278            .iter()
279            .enumerate()
280            .filter_map(|(index, node)| (node.kind == ComponentNodeKind::Page).then_some(index))
281            .collect();
282        let reachable = self.bounded_closure(roots);
283        let mut seen = vec![false; self.nodes.len()];
284        for index in reachable {
285            seen[index] = true;
286        }
287        seen.into_iter()
288            .enumerate()
289            .filter_map(|(index, reachable)| (!reachable).then_some(index))
290            .collect()
291    }
292
293    /// All graph-shaped validation problems.
294    ///
295    /// This includes errors retained by [`Self::parse`] and every cycle found
296    /// by a bounded, iterative depth-first walk of the INCL graph.
297    pub fn validate(&self) -> Vec<GraphError> {
298        let mut errors = self.validation_errors.clone();
299        for error in self.cycles() {
300            push_unique(&mut errors, error);
301        }
302        errors
303    }
304
305    fn bounded_closure(&self, roots: Vec<usize>) -> Vec<usize> {
306        let mut seen = vec![false; self.nodes.len()];
307        let mut closure = Vec::new();
308        let mut stack = Vec::new();
309        for root in roots.into_iter().rev() {
310            if !seen[root] {
311                seen[root] = true;
312                stack.push((root, 0usize));
313            }
314        }
315
316        let mut visits = 0usize;
317        while let Some((index, depth)) = stack.pop() {
318            if !consume_visit(&mut visits) {
319                break;
320            }
321            closure.push(index);
322            if depth == MAX_GRAPH_DEPTH {
323                continue;
324            }
325
326            for &target in self.nodes[index].includes.iter().rev() {
327                if !consume_visit(&mut visits) {
328                    return closure;
329                }
330                if !seen[target] {
331                    seen[target] = true;
332                    stack.push((target, depth + 1));
333                }
334            }
335        }
336        closure
337    }
338
339    fn cycles(&self) -> Vec<GraphError> {
340        const UNSEEN: u8 = 0;
341        const ACTIVE: u8 = 1;
342        const COMPLETE: u8 = 2;
343
344        let mut colors = vec![UNSEEN; self.nodes.len()];
345        let mut errors = Vec::new();
346        let mut visits = 0usize;
347
348        for start in 0..self.nodes.len() {
349            if colors[start] != UNSEEN {
350                continue;
351            }
352            if !consume_visit(&mut visits) {
353                errors.push(GraphError::Malformed(
354                    "component graph validation visit limit exceeded".to_string(),
355                ));
356                break;
357            }
358
359            colors[start] = ACTIVE;
360            let mut stack = vec![(start, 0usize)];
361            while let Some((index, edge_index)) = stack.last_mut() {
362                if *edge_index == self.nodes[*index].includes.len() {
363                    colors[*index] = COMPLETE;
364                    stack.pop();
365                    continue;
366                }
367
368                let target = self.nodes[*index].includes[*edge_index];
369                *edge_index += 1;
370                if !consume_visit(&mut visits) {
371                    errors.push(GraphError::Malformed(
372                        "component graph validation visit limit exceeded".to_string(),
373                    ));
374                    return errors;
375                }
376
377                match colors[target] {
378                    UNSEEN => {
379                        if stack.len() == MAX_GRAPH_DEPTH {
380                            errors.push(GraphError::Malformed(
381                                "component graph validation depth limit exceeded".to_string(),
382                            ));
383                            return errors;
384                        }
385                        colors[target] = ACTIVE;
386                        stack.push((target, 0));
387                    }
388                    ACTIVE => {
389                        let cycle_start = stack
390                            .iter()
391                            .position(|(node, _)| *node == target)
392                            .expect("active node is always present in DFS stack");
393                        let mut path = stack[cycle_start..]
394                            .iter()
395                            .map(|(node, _)| self.nodes[*node].id.clone())
396                            .collect::<Vec<_>>();
397                        path.push(self.nodes[target].id.clone());
398                        push_unique(&mut errors, GraphError::Cycle { path });
399                    }
400                    COMPLETE => {}
401                    _ => unreachable!("color state is one of the three constants"),
402                }
403            }
404        }
405
406        errors
407    }
408}
409
410fn expected_form(kind: DirmComponentKind) -> [u8; 4] {
411    match kind {
412        DirmComponentKind::Page => *b"DJVU",
413        DirmComponentKind::Shared => *b"DJVI",
414        DirmComponentKind::Thumbnail => *b"THUM",
415    }
416}
417
418fn is_component_form(form: [u8; 4]) -> bool {
419    form == *b"DJVU" || form == *b"DJVI" || form == *b"THUM"
420}
421
422fn classify_component(
423    form: [u8; 4],
424    chunks: &[crate::iff::IffChunk<'_>],
425    directory_kind: DirmComponentKind,
426) -> ComponentNodeKind {
427    if form == *b"DJVU" {
428        ComponentNodeKind::Page
429    } else if form == *b"THUM" {
430        ComponentNodeKind::Thumbnail
431    } else if form == *b"DJVI" {
432        if chunks.iter().any(|chunk| chunk.id == *b"Djbz") {
433            ComponentNodeKind::Dictionary
434        } else if chunks
435            .iter()
436            .any(|chunk| chunk.id == *b"ANTa" || chunk.id == *b"ANTz")
437        {
438            ComponentNodeKind::Annotation
439        } else {
440            ComponentNodeKind::SharedOther
441        }
442    } else {
443        match directory_kind {
444            DirmComponentKind::Page => ComponentNodeKind::Page,
445            DirmComponentKind::Shared => ComponentNodeKind::SharedOther,
446            DirmComponentKind::Thumbnail => ComponentNodeKind::Thumbnail,
447        }
448    }
449}
450
451fn component_id_from_incl(data: &[u8]) -> Result<String, GraphError> {
452    let end = data
453        .iter()
454        .rposition(|byte| *byte != 0 && !byte.is_ascii_whitespace())
455        .map_or(0, |index| index + 1);
456    core::str::from_utf8(&data[..end])
457        .map(str::to_owned)
458        .map_err(|_| GraphError::Malformed("INCL component id is not valid UTF-8".to_string()))
459}
460
461fn consume_visit(visits: &mut usize) -> bool {
462    if *visits == MAX_GRAPH_VISITS {
463        false
464    } else {
465        *visits += 1;
466        true
467    }
468}
469
470fn push_unique(errors: &mut Vec<GraphError>, error: GraphError) {
471    if !errors.contains(&error) {
472        errors.push(error);
473    }
474}
475
476#[cfg(test)]
477mod tests {
478    use super::*;
479    use crate::{
480        dirm::DirmPayload,
481        iff::{self, Chunk, EmitPart},
482    };
483
484    struct FixtureComponent {
485        id: &'static str,
486        dirm_flag: u8,
487        form: [u8; 4],
488        chunks: Vec<([u8; 4], Vec<u8>)>,
489    }
490
491    fn component(
492        id: &'static str,
493        dirm_flag: u8,
494        form: [u8; 4],
495        chunks: Vec<([u8; 4], Vec<u8>)>,
496    ) -> FixtureComponent {
497        FixtureComponent {
498            id,
499            dirm_flag,
500            form,
501            chunks,
502        }
503    }
504
505    fn incl(id: &[u8]) -> ([u8; 4], Vec<u8>) {
506        (*b"INCL", id.to_vec())
507    }
508
509    fn component_body(component: &FixtureComponent) -> Vec<u8> {
510        let chunks = component
511            .chunks
512            .iter()
513            .map(|(id, data)| Chunk::Leaf {
514                id: *id,
515                data: data.clone(),
516            })
517            .collect::<Vec<_>>();
518        let parts = chunks.iter().map(EmitPart::Chunk).collect::<Vec<_>>();
519        let bytes = iff::partial_emit(component.form, &parts).expect("small fixture FORM");
520        let length = u32::from_be_bytes(bytes[8..12].try_into().unwrap()) as usize;
521        bytes[12..12 + length].to_vec()
522    }
523
524    fn bundled(components: Vec<FixtureComponent>) -> Vec<u8> {
525        let bodies = components.iter().map(component_body).collect::<Vec<_>>();
526        let ids = components
527            .iter()
528            .map(|component| component.id.to_string())
529            .collect::<Vec<_>>();
530        let flags = components
531            .iter()
532            .map(|component| component.dirm_flag)
533            .collect::<Vec<_>>();
534        let sizes = bodies
535            .iter()
536            .map(|body| u32::try_from(8 + body.len()).unwrap())
537            .collect::<Vec<_>>();
538        let mut dirm = DirmPayload::build_bundled(components.len(), &flags, &ids, &sizes);
539
540        let emit = |dirm: &DirmPayload| {
541            let dirm_chunk = Chunk::Leaf {
542                id: *b"DIRM",
543                data: dirm.encode(),
544            };
545            let mut parts = vec![EmitPart::Chunk(&dirm_chunk)];
546            parts.extend(bodies.iter().map(|body| EmitPart::Form(body)));
547            iff::partial_emit_with_offsets(*b"DJVM", &parts).expect("small bundled fixture")
548        };
549
550        let (_, offsets) = emit(&dirm);
551        dirm.offsets = offsets[1..]
552            .iter()
553            .map(|&offset| u32::try_from(offset).unwrap())
554            .collect();
555        emit(&dirm).0
556    }
557
558    fn page_dictionary_annotation_thumbnail() -> ComponentGraph {
559        ComponentGraph::parse(&bundled(vec![
560            component(
561                "page.djvu",
562                1,
563                *b"DJVU",
564                vec![incl(b"dict.djvi\0 \t"), incl(b"anno.djvi")],
565            ),
566            component("dict.djvi", 0, *b"DJVI", vec![(*b"Djbz", vec![1])]),
567            component("anno.djvi", 0, *b"DJVI", vec![(*b"ANTz", vec![2])]),
568            component("thumb.thum", 2, *b"THUM", vec![]),
569        ]))
570        .expect("fixture parses")
571    }
572
573    #[test]
574    fn classifies_components_and_builds_reverse_edges() {
575        let graph = page_dictionary_annotation_thumbnail();
576        assert_eq!(
577            graph
578                .nodes()
579                .iter()
580                .map(|node| node.kind)
581                .collect::<Vec<_>>(),
582            vec![
583                ComponentNodeKind::Page,
584                ComponentNodeKind::Dictionary,
585                ComponentNodeKind::Annotation,
586                ComponentNodeKind::Thumbnail,
587            ]
588        );
589        assert_eq!(graph.nodes()[0].includes, vec![1, 2]);
590        assert_eq!(graph.nodes()[1].included_by, vec![0]);
591        assert_eq!(graph.nodes()[2].included_by, vec![0]);
592        assert!(graph.nodes()[3].included_by.is_empty());
593        assert_eq!(
594            graph
595                .included_by("dict.djvi")
596                .into_iter()
597                .map(|node| node.id.as_str())
598                .collect::<Vec<_>>(),
599            vec!["page.djvu"]
600        );
601    }
602
603    #[test]
604    fn retains_every_incl_chunk_in_order() {
605        let graph = page_dictionary_annotation_thumbnail();
606        assert_eq!(
607            graph
608                .includes("page.djvu")
609                .into_iter()
610                .map(|node| node.id.as_str())
611                .collect::<Vec<_>>(),
612            vec!["dict.djvi", "anno.djvi"]
613        );
614    }
615
616    #[test]
617    fn validates_missing_incl_target() {
618        let graph = ComponentGraph::parse(&bundled(vec![component(
619            "page.djvu",
620            1,
621            *b"DJVU",
622            vec![incl(b"missing.djvi")],
623        )]))
624        .expect("container remains parseable");
625
626        assert!(graph.validate().contains(&GraphError::MissingTarget {
627            source: "page.djvu".to_string(),
628            target: "missing.djvi".to_string(),
629        }));
630    }
631
632    #[test]
633    fn validates_cyclic_shared_components_without_recursing() {
634        let graph = ComponentGraph::parse(&bundled(vec![
635            component("one.djvi", 0, *b"DJVI", vec![incl(b"two.djvi")]),
636            component("two.djvi", 0, *b"DJVI", vec![incl(b"one.djvi")]),
637        ]))
638        .expect("container remains parseable");
639
640        assert!(graph.validate().iter().any(|error| {
641            matches!(
642                error,
643                GraphError::Cycle { path }
644                    if path == &vec!["one.djvi".to_string(), "two.djvi".to_string(), "one.djvi".to_string()]
645            )
646        }));
647    }
648
649    #[test]
650    fn validates_duplicate_dirm_identity() {
651        let graph = ComponentGraph::parse(&bundled(vec![
652            component("same.djvu", 1, *b"DJVU", vec![]),
653            component("same.djvu", 1, *b"DJVU", vec![]),
654        ]))
655        .expect("container remains parseable");
656
657        assert!(graph.validate().contains(&GraphError::DuplicateIdentity {
658            id: "same.djvu".to_string(),
659        }));
660        assert_eq!(graph.node("same.djvu").unwrap().dirm_index, 0);
661    }
662
663    #[test]
664    fn finds_unreachable_shared_components() {
665        let graph = ComponentGraph::parse(&bundled(vec![
666            component("page.djvu", 1, *b"DJVU", vec![incl(b"used.djvi")]),
667            component("used.djvi", 0, *b"DJVI", vec![(*b"Djbz", vec![])]),
668            component("orphan.djvi", 0, *b"DJVI", vec![]),
669        ]))
670        .expect("fixture parses");
671
672        assert_eq!(graph.nodes()[2].kind, ComponentNodeKind::SharedOther);
673        assert_eq!(graph.unreachable_components(), vec![2]);
674    }
675
676    #[test]
677    fn computes_transitive_closure_including_roots() {
678        let graph = page_dictionary_annotation_thumbnail();
679        assert_eq!(graph.transitive_closure(&["page.djvu"]), vec![0, 1, 2]);
680    }
681
682    #[test]
683    fn records_component_form_mismatches_without_rejecting_the_container() {
684        let graph = ComponentGraph::parse(&bundled(vec![component(
685            "page.djvu",
686            1,
687            *b"DJVI",
688            vec![(*b"Djbz", vec![])],
689        )]))
690        .expect("container remains parseable");
691
692        assert_eq!(graph.nodes()[0].kind, ComponentNodeKind::Dictionary);
693        assert!(
694            graph
695                .validate()
696                .contains(&GraphError::InvalidComponentType {
697                    id: "page.djvu".to_string(),
698                    form: *b"DJVI",
699                })
700        );
701    }
702}