Skip to main content

djvu_rs/
validate.rs

1//! Layered, non-rendering validation for DjVu byte streams.
2//!
3//! The validator implements the structural, dependency, codec, and resource
4//! layers. [`Layer::Semantic`] is intentionally part of the public schema now,
5//! but has no checks until a later validator slice.
6//! By default codec validation is probe-level: it checks IW44 headers and BZZ
7//! streams but never renders pixels. Set [`ValidateOptions::decode_pages`] to
8//! decode IW44 coefficients and JB2 symbols; that still does not convert IW44
9//! data to RGB or composite a page.
10//!
11//! The resource layer is header-only: it derives cheap estimates (file size,
12//! page and component counts, per-page pixel areas from INFO chunks) and, when
13//! [`ValidateOptions::limits`] is set, reports configured-limit violations
14//! *before* any expensive per-page decode is attempted. A decode-cost limit
15//! violation additionally suppresses the opt-in [`ValidateOptions::decode_pages`]
16//! work so the validator never performs the very decode the limit forbids.
17
18use std::collections::BTreeMap;
19
20use crate::{
21    ComponentGraph, ComponentNodeKind, DjVuDocument, GraphError,
22    dirm::DirmPayload,
23    iff::{self, ChunkRecord},
24    info::PageInfo,
25};
26
27/// Bytes a decoded page occupies while it is rendered, used to turn a
28/// pixel-area estimate into a peak-memory estimate. A composite render holds an
29/// RGBA buffer, so four bytes per pixel is the dominant per-page allocation.
30const DECODED_BYTES_PER_PIXEL: u64 = 4;
31
32/// The outcome category of a validation finding.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum Severity {
35    /// A definite validity failure.
36    Error,
37    /// A suspicious but tolerated condition.
38    Warning,
39    /// A recognized extension accepted without interpretation.
40    Tolerated,
41    /// Input recovered through a bounded, documented fallback.
42    Recovery,
43}
44
45impl Severity {
46    /// Stable lowercase name used by the CLI JSON schema.
47    pub const fn as_str(self) -> &'static str {
48        match self {
49            Self::Error => "error",
50            Self::Warning => "warning",
51            Self::Tolerated => "tolerated",
52            Self::Recovery => "recovery",
53        }
54    }
55}
56
57/// The validator layer which produced a finding.
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum Layer {
60    /// IFF framing, FORM layout, and directory shape.
61    Structural,
62    /// DIRM identities and `INCL` dependency graph validation.
63    Dependency,
64    /// Header and compressed-stream validation without page rendering.
65    Codec,
66    /// Reserved for later checks of cross-page/document meaning.
67    Semantic,
68    /// Header-only resource estimates and configured-limit violations.
69    Resource,
70}
71
72impl Layer {
73    /// Stable lowercase name used by the CLI JSON schema.
74    pub const fn as_str(self) -> &'static str {
75        match self {
76            Self::Structural => "structural",
77            Self::Dependency => "dependency",
78            Self::Codec => "codec",
79            Self::Semantic => "semantic",
80            Self::Resource => "resource",
81        }
82    }
83}
84
85/// One stable, machine-readable validation observation.
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct Finding {
88    /// Finding category.
89    pub severity: Severity,
90    /// Validator layer which produced this finding.
91    pub layer: Layer,
92    /// Stable machine code, such as `dep.missing-target`.
93    pub code: &'static str,
94    /// DIRM component identity, when available.
95    pub component: Option<String>,
96    /// Four-byte chunk identifier, when available.
97    pub chunk: Option<String>,
98    /// Absolute IFF chunk-header byte offset, when available.
99    pub offset: Option<usize>,
100    /// Human explanation.
101    pub message: String,
102}
103
104/// Counts grouped by [`Severity`].
105#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
106pub struct ValidationSummary {
107    /// Number of error findings.
108    pub errors: usize,
109    /// Number of warning findings.
110    pub warnings: usize,
111    /// Number of tolerated-extension findings.
112    pub tolerated: usize,
113    /// Number of recovery findings.
114    pub recovery: usize,
115}
116
117pub use crate::resource_limits::{
118    DEFAULT_MAX_RENDER_PIXELS, ParseOptions, ResourceLimitAxis, ResourceLimitExceeded,
119    ResourceLimits,
120};
121
122/// Cheap, pre-decode resource estimates derived from container headers only.
123///
124/// Every field is computed by walking the IFF chunk tree and parsing INFO
125/// chunk headers — no page is decoded or rendered — so the estimate is always
126/// available, even for documents whose pages cannot be decoded.
127#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
128pub struct ResourceEstimate {
129    /// Total input size in bytes.
130    pub file_bytes: u64,
131    /// Number of pages (INFO-bearing `FORM:DJVU` components).
132    pub pages: u64,
133    /// Number of embedded components (`1` for a single-page document).
134    pub components: u64,
135    /// Largest single-page pixel area (`width * height`).
136    pub max_page_pixels: u64,
137    /// Sum of every page's pixel area.
138    pub total_pixels: u64,
139    /// Estimated peak decoded-page memory in bytes: the largest page's area
140    /// times [`DECODED_BYTES_PER_PIXEL`].
141    pub peak_decoded_bytes: u64,
142}
143
144/// Complete result of one [`validate`] call.
145#[derive(Debug, Clone, Default, PartialEq, Eq)]
146pub struct ValidationReport {
147    /// Findings in deterministic structural/dependency/codec/resource order.
148    pub findings: Vec<Finding>,
149    /// Severity counts captured when the report was produced.
150    pub summary: ValidationSummary,
151    /// Header-only resource estimate computed for every validated document.
152    pub resources: ResourceEstimate,
153}
154
155impl ValidationReport {
156    fn from_findings(findings: Vec<Finding>) -> Self {
157        Self::from_findings_with_resources(findings, ResourceEstimate::default())
158    }
159
160    fn from_findings_with_resources(findings: Vec<Finding>, resources: ResourceEstimate) -> Self {
161        let summary = count_findings(&findings);
162        Self {
163            findings,
164            summary,
165            resources,
166        }
167    }
168
169    /// Severity counts derived from [`Self::findings`].
170    pub fn summary(&self) -> ValidationSummary {
171        count_findings(&self.findings)
172    }
173
174    /// Whether no error-severity finding was emitted.
175    pub fn is_valid(&self) -> bool {
176        self.summary().errors == 0
177    }
178}
179
180/// Controls the cost of [`validate`].
181#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
182pub struct ValidateOptions {
183    /// Retained for callers and consumed by the CLI exit policy.
184    ///
185    /// The library always records warnings as warnings; `djvu validate
186    /// --strict` decides whether they make the process fail.
187    pub strict: bool,
188    /// Decode page codec streams without rendering or RGB conversion.
189    ///
190    /// The default `false` checks only cheap IW44 headers and BZZ streams.
191    pub decode_pages: bool,
192    /// Configured processing limits for the resource layer.
193    ///
194    /// When set, the resource layer reports [`Severity::Error`] findings for
195    /// every exceeded limit before any per-page decode is attempted. A
196    /// decode-cost limit violation also suppresses the [`Self::decode_pages`]
197    /// work so the validator never performs the decode a limit forbids.
198    pub limits: Option<ResourceLimits>,
199}
200
201/// Validate a writer's planned output before it is committed (#696).
202///
203/// Runs the cheap validation layers (structural, dependency, codec probes —
204/// never a render or full page decode) over bytes a writer is about to commit
205/// and returns the error-severity findings if any exist. Warnings and
206/// tolerated extensions do not block a commit. Intended to be called between
207/// producing output bytes and atomically replacing the destination, as
208/// `DocumentEditor::apply_to_path` does.
209pub fn validate_planned_output(data: &[u8]) -> Result<(), Vec<Finding>> {
210    let report = validate(data, &ValidateOptions::default());
211    if report.is_valid() {
212        return Ok(());
213    }
214    Err(report
215        .findings
216        .into_iter()
217        .filter(|finding| finding.severity == Severity::Error)
218        .collect())
219}
220
221/// Validate a DjVu byte stream without rendering it.
222pub fn validate(data: &[u8], opts: &ValidateOptions) -> ValidationReport {
223    let mut findings = Vec::new();
224    let records = match iff::walk_chunks(data) {
225        Ok(records) => records,
226        Err(error) => {
227            findings.push(finding(
228                Severity::Error,
229                Layer::Structural,
230                iff_error_code(&error),
231                None,
232                iff_error_chunk(&error),
233                Some(iff_error_offset(data, &error)),
234                format!("IFF chunk walk failed: {error}"),
235            ));
236            return ValidationReport::from_findings(findings);
237        }
238    };
239
240    validate_structural(data, &records, &mut findings);
241    let component_offsets = validate_dependencies(data, &records, &mut findings);
242
243    // Resource estimates and configured-limit checks run before the codec
244    // layer so a limit violation is reported — and the expensive decode is
245    // skipped — before any per-page decode work (#696).
246    let estimate = estimate_resources(data, &records);
247    let mut decode_pages = opts.decode_pages;
248    if let Some(limits) = opts.limits.filter(|limits| !limits.is_empty()) {
249        let exceeds_decode_cost =
250            validate_resources(data, &records, &estimate, &limits, &mut findings);
251        if exceeds_decode_cost && decode_pages {
252            decode_pages = false;
253            findings.push(finding(
254                Severity::Recovery,
255                Layer::Resource,
256                "resource.decode-skipped",
257                None,
258                None,
259                None,
260                "per-page decode was skipped because a configured resource limit was exceeded"
261                    .to_string(),
262            ));
263        }
264    }
265
266    let codec_opts = ValidateOptions {
267        decode_pages,
268        ..*opts
269    };
270    validate_codecs(
271        data,
272        &records,
273        &component_offsets,
274        &codec_opts,
275        &mut findings,
276    );
277
278    ValidationReport::from_findings_with_resources(findings, estimate)
279}
280
281fn count_findings(findings: &[Finding]) -> ValidationSummary {
282    let mut summary = ValidationSummary::default();
283    for finding in findings {
284        match finding.severity {
285            Severity::Error => summary.errors += 1,
286            Severity::Warning => summary.warnings += 1,
287            Severity::Tolerated => summary.tolerated += 1,
288            Severity::Recovery => summary.recovery += 1,
289        }
290    }
291    summary
292}
293
294fn validate_structural(data: &[u8], records: &[ChunkRecord], findings: &mut Vec<Finding>) {
295    for record in records {
296        if record.id == *b"FORM" {
297            if record.form_type.is_some_and(|form| !is_known_form(form)) {
298                findings.push(finding(
299                    Severity::Tolerated,
300                    Layer::Structural,
301                    "iff.tolerated-extension-form",
302                    None,
303                    Some("FORM".to_string()),
304                    Some(record.offset),
305                    format!(
306                        "tolerated extension FORM:{}",
307                        chunk_id_text(record.form_type.expect("checked above"))
308                    ),
309                ));
310            }
311        } else if !is_known_chunk(record.id) {
312            let chunk = chunk_id_text(record.id);
313            let (severity, code, message) = if record.id.is_ascii() {
314                (
315                    Severity::Tolerated,
316                    "iff.tolerated-extension-chunk",
317                    format!("tolerated extension chunk {chunk}"),
318                )
319            } else {
320                (
321                    Severity::Error,
322                    "iff.invalid-chunk-id",
323                    format!("chunk identifier {chunk} is not four ASCII bytes"),
324                )
325            };
326            findings.push(finding(
327                severity,
328                Layer::Structural,
329                code,
330                None,
331                Some(chunk),
332                Some(record.offset),
333                message,
334            ));
335        }
336    }
337
338    validate_form_layout(records, findings);
339
340    let Ok(form) = iff::parse_form(data) else {
341        // `walk_chunks` is already the authoritative framing check. This can
342        // only be a parser-policy difference and is recorded once below by the
343        // document parse, where it has richer context.
344        return;
345    };
346
347    if form.form_type == *b"DJVM" {
348        let dirm_chunk = form.chunks.iter().find(|chunk| chunk.id == *b"DIRM");
349        if let Some(dirm_chunk) = dirm_chunk {
350            match DirmPayload::decode(dirm_chunk.data) {
351                Ok(dirm) if dirm.is_bundled() => {
352                    let children = form
353                        .chunks
354                        .iter()
355                        .filter(|chunk| chunk.id == *b"FORM")
356                        .count();
357                    if usize::from(dirm.nfiles) != children {
358                        findings.push(finding(
359                            Severity::Error,
360                            Layer::Structural,
361                            "struct.dirm-component-count-mismatch",
362                            None,
363                            Some("DIRM".to_string()),
364                            record_offset(records, &[], *b"DIRM"),
365                            format!(
366                                "DIRM declares {} components but the DJVM contains {children} embedded FORM children",
367                                dirm.nfiles
368                            ),
369                        ));
370                    }
371                }
372                Ok(_) => {}
373                Err(error) => findings.push(finding(
374                    Severity::Error,
375                    Layer::Structural,
376                    "struct.dirm-decode-failed",
377                    None,
378                    Some("DIRM".to_string()),
379                    record_offset(records, &[], *b"DIRM"),
380                    format!("DIRM decode failed: {error}"),
381                )),
382            }
383        } else {
384            findings.push(finding(
385                Severity::Error,
386                Layer::Structural,
387                "struct.missing-dirm",
388                None,
389                Some("DIRM".to_string()),
390                None,
391                "FORM:DJVM is missing its DIRM chunk".to_string(),
392            ));
393        }
394    }
395
396    for record in records
397        .iter()
398        .filter(|record| record.form_type == Some(*b"DJVU"))
399    {
400        let has_info = records.iter().any(|child| {
401            child.path.len() == record.path.len() + 1
402                && child.path.starts_with(&record.path)
403                && child.id == *b"INFO"
404        });
405        if !has_info {
406            findings.push(finding(
407                Severity::Error,
408                Layer::Structural,
409                "struct.missing-info",
410                None,
411                Some("INFO".to_string()),
412                Some(record.offset),
413                "FORM:DJVU is missing its required INFO chunk".to_string(),
414            ));
415        }
416    }
417
418    match DjVuDocument::parse(data) {
419        Ok(_) => {}
420        Err(error) if form.form_type == *b"DJVM" && is_indirect_djvm(&form) => {
421            findings.push(finding(
422                Severity::Warning,
423                Layer::Structural,
424                "struct.indirect-document-not-resolved",
425                None,
426                None,
427                None,
428                format!(
429                    "indirect FORM:DJVM was structurally checked without resolving \
430                     external components (document parse without a resolver: {error})"
431                ),
432            ))
433        }
434        Err(error) => {
435            let has_navm = form.chunks.iter().any(|chunk| chunk.id == *b"NAVM");
436            findings.push(finding(
437                Severity::Error,
438                if has_navm {
439                    Layer::Codec
440                } else {
441                    Layer::Structural
442                },
443                if has_navm {
444                    "codec.bookmarks-parse-failed"
445                } else {
446                    "struct.document-parse-failed"
447                },
448                None,
449                has_navm.then(|| "NAVM".to_string()),
450                None,
451                format!("document parse failed: {error}"),
452            ));
453        }
454    }
455}
456
457fn validate_form_layout(records: &[ChunkRecord], findings: &mut Vec<Finding>) {
458    for parent in records.iter().filter(|record| record.id == *b"FORM") {
459        let parent_end = parent
460            .offset
461            .saturating_add(8)
462            .saturating_add(parent.length);
463        let children = records
464            .iter()
465            .filter(|child| {
466                child.path.len() == parent.path.len() + 1 && child.path.starts_with(&parent.path)
467            })
468            .collect::<Vec<_>>();
469        for (index, child) in children.iter().enumerate() {
470            if child.length & 1 == 0 {
471                continue;
472            }
473            let payload_end = child.offset.saturating_add(8).saturating_add(child.length);
474            let next_offset = children.get(index + 1).map(|next| next.offset);
475            if next_offset == Some(payload_end)
476                || (next_offset.is_none() && payload_end == parent_end)
477            {
478                findings.push(finding(
479                    Severity::Warning,
480                    Layer::Structural,
481                    "iff.missing-odd-padding",
482                    None,
483                    Some(chunk_id_text(child.id)),
484                    Some(child.offset),
485                    format!(
486                        "odd-length {} chunk is not followed by its required alignment padding byte",
487                        chunk_id_text(child.id)
488                    ),
489                ));
490            }
491        }
492
493        let body_start = parent.offset.saturating_add(12);
494        let last_end = children.last().map_or(body_start, |child| {
495            child
496                .offset
497                .saturating_add(8)
498                .saturating_add(child.length)
499                .saturating_add(child.length & 1)
500        });
501        if parent_end > last_end && parent_end - last_end < 8 {
502            findings.push(finding(
503                Severity::Recovery,
504                Layer::Structural,
505                "iff.trailing-fragment-recovered",
506                None,
507                Some("FORM".to_string()),
508                Some(last_end),
509                format!(
510                    "ignored {} trailing byte(s) shorter than an IFF chunk header",
511                    parent_end - last_end
512                ),
513            ));
514        }
515    }
516}
517
518fn validate_dependencies(
519    data: &[u8],
520    records: &[ChunkRecord],
521    findings: &mut Vec<Finding>,
522) -> BTreeMap<usize, String> {
523    let mut component_offsets = BTreeMap::new();
524    let Ok(form) = iff::parse_form(data) else {
525        return component_offsets;
526    };
527    if form.form_type != *b"DJVM" || !is_bundled_djvm(&form) {
528        return component_offsets;
529    }
530
531    match ComponentGraph::parse(data) {
532        Ok(graph) => {
533            let component_forms = records
534                .iter()
535                .filter(|record| record.id == *b"FORM" && record.depth == 1)
536                .collect::<Vec<_>>();
537            for node in graph.nodes() {
538                if let Some(record) = component_forms.get(node.dirm_index) {
539                    component_offsets.insert(record.offset, node.id.clone());
540                }
541            }
542            for error in graph.validate() {
543                findings.push(graph_error_finding(error));
544            }
545            for index in graph.unreachable_components() {
546                let Some(node) = graph.nodes().get(index) else {
547                    continue;
548                };
549                if matches!(
550                    node.kind,
551                    ComponentNodeKind::Page | ComponentNodeKind::Thumbnail
552                ) {
553                    continue;
554                }
555                findings.push(finding(
556                    Severity::Warning,
557                    Layer::Dependency,
558                    "dep.unreachable-component",
559                    Some(node.id.clone()),
560                    None,
561                    component_forms
562                        .get(node.dirm_index)
563                        .map(|record| record.offset),
564                    format!(
565                        "shared component '{}' is unreachable from every page",
566                        node.id
567                    ),
568                ));
569            }
570        }
571        Err(error) => findings.push(graph_error_finding(error)),
572    }
573    component_offsets
574}
575
576fn graph_error_finding(error: GraphError) -> Finding {
577    match error {
578        GraphError::MissingTarget { source, target } => finding(
579            Severity::Error,
580            Layer::Dependency,
581            "dep.missing-target",
582            Some(source.clone()),
583            Some("INCL".to_string()),
584            None,
585            format!("component '{source}' includes missing target '{target}'"),
586        ),
587        GraphError::DuplicateIdentity { id } => finding(
588            Severity::Error,
589            Layer::Dependency,
590            "dep.duplicate-identity",
591            Some(id.clone()),
592            None,
593            None,
594            format!("DIRM declares component identity '{id}' more than once"),
595        ),
596        GraphError::InvalidComponentType { id, form } => finding(
597            Severity::Error,
598            Layer::Dependency,
599            "dep.invalid-component-type",
600            Some(id.clone()),
601            Some("FORM".to_string()),
602            None,
603            format!(
604                "component '{id}' has FORM:{} incompatible with its DIRM type",
605                chunk_id_text(form)
606            ),
607        ),
608        GraphError::Cycle { path } => finding(
609            Severity::Error,
610            Layer::Dependency,
611            "dep.cycle",
612            path.first().cloned(),
613            Some("INCL".to_string()),
614            None,
615            format!("component include cycle: {}", path.join(" -> ")),
616        ),
617        GraphError::Malformed(message) => finding(
618            Severity::Error,
619            Layer::Dependency,
620            "dep.malformed-graph",
621            None,
622            None,
623            None,
624            format!("component graph parse failed: {message}"),
625        ),
626    }
627}
628
629/// Derive cheap, header-only resource estimates by walking the chunk tree and
630/// parsing INFO headers. No page is decoded, so this is safe to run before any
631/// resource-limit gate on untrusted input.
632fn estimate_resources(data: &[u8], records: &[ChunkRecord]) -> ResourceEstimate {
633    let mut estimate = ResourceEstimate {
634        file_bytes: data.len() as u64,
635        ..ResourceEstimate::default()
636    };
637
638    let root_is_djvm = records
639        .first()
640        .is_some_and(|record| record.form_type == Some(*b"DJVM"));
641    estimate.components = if root_is_djvm {
642        records
643            .iter()
644            .filter(|record| record.id == *b"FORM" && record.depth == 1)
645            .count() as u64
646    } else {
647        1
648    };
649
650    for record in records.iter().filter(|record| record.id == *b"INFO") {
651        let Ok(info) = PageInfo::parse(record_data(data, record)) else {
652            continue;
653        };
654        // u16 * u16 overflows u32 on the largest pages; u64 keeps the estimate
655        // exact on 32-bit targets too.
656        let pixels = u64::from(info.width) * u64::from(info.height);
657        estimate.pages += 1;
658        estimate.total_pixels = estimate.total_pixels.saturating_add(pixels);
659        estimate.max_page_pixels = estimate.max_page_pixels.max(pixels);
660    }
661    estimate.peak_decoded_bytes = estimate
662        .max_page_pixels
663        .saturating_mul(DECODED_BYTES_PER_PIXEL);
664    estimate
665}
666
667/// Check header-only resource estimates against configured limits.
668///
669/// Returns the first exceeded limit as a typed error naming
670/// `operation` (for example `"document.parse"`). When the IFF walk fails,
671/// returns `Ok(None)` so the caller can surface the structural parse error.
672pub fn check_document_limits(
673    data: &[u8],
674    limits: &ResourceLimits,
675    operation: &'static str,
676) -> Result<Option<ResourceEstimate>, ResourceLimitExceeded> {
677    if limits.is_empty() {
678        return Ok(None);
679    }
680
681    let records = match iff::walk_chunks(data) {
682        Ok(records) => records,
683        Err(_) => return Ok(None),
684    };
685    let estimate = estimate_resources(data, &records);
686    first_resource_violation(data, &records, &estimate, limits, operation)?;
687    Ok(Some(estimate))
688}
689
690/// Return the first configured limit violation, if any.
691fn first_resource_violation(
692    data: &[u8],
693    records: &[ChunkRecord],
694    estimate: &ResourceEstimate,
695    limits: &ResourceLimits,
696    operation: &'static str,
697) -> Result<(), ResourceLimitExceeded> {
698    if let Some(max) = limits.max_file_bytes
699        && estimate.file_bytes > max
700    {
701        return Err(ResourceLimitExceeded {
702            operation,
703            axis: ResourceLimitAxis::FileBytes,
704            found: estimate.file_bytes,
705            limit: max,
706            page_number: None,
707            width: None,
708            height: None,
709        });
710    }
711    if let Some(max) = limits.max_pages
712        && estimate.pages > max
713    {
714        return Err(ResourceLimitExceeded {
715            operation,
716            axis: ResourceLimitAxis::PageCount,
717            found: estimate.pages,
718            limit: max,
719            page_number: None,
720            width: None,
721            height: None,
722        });
723    }
724    if let Some(max) = limits.max_components
725        && estimate.components > max
726    {
727        return Err(ResourceLimitExceeded {
728            operation,
729            axis: ResourceLimitAxis::ComponentCount,
730            found: estimate.components,
731            limit: max,
732            page_number: None,
733            width: None,
734            height: None,
735        });
736    }
737    if let Some(max) = limits.max_page_pixels {
738        let mut page_number = 0usize;
739        for record in records.iter().filter(|record| record.id == *b"INFO") {
740            page_number += 1;
741            let Ok(info) = PageInfo::parse(record_data(data, record)) else {
742                continue;
743            };
744            let pixels = u64::from(info.width) * u64::from(info.height);
745            if pixels > max {
746                return Err(ResourceLimitExceeded {
747                    operation,
748                    axis: ResourceLimitAxis::PagePixels,
749                    found: pixels,
750                    limit: max,
751                    page_number: Some(page_number),
752                    width: Some(u32::from(info.width)),
753                    height: Some(u32::from(info.height)),
754                });
755            }
756        }
757    }
758    if let Some(max) = limits.max_total_pixels
759        && estimate.total_pixels > max
760    {
761        return Err(ResourceLimitExceeded {
762            operation,
763            axis: ResourceLimitAxis::TotalPixels,
764            found: estimate.total_pixels,
765            limit: max,
766            page_number: None,
767            width: None,
768            height: None,
769        });
770    }
771    if let Some(max) = limits.max_decoded_bytes
772        && estimate.peak_decoded_bytes > max
773    {
774        return Err(ResourceLimitExceeded {
775            operation,
776            axis: ResourceLimitAxis::DecodedBytes,
777            found: estimate.peak_decoded_bytes,
778            limit: max,
779            page_number: None,
780            width: None,
781            height: None,
782        });
783    }
784    Ok(())
785}
786
787/// Compare a resource estimate against configured limits, emitting one
788/// [`Severity::Error`] finding per exceeded limit. Returns whether a limit that
789/// bounds decode cost (per-page pixels, total pixels, or peak decoded memory)
790/// was exceeded, so the caller can skip the expensive decode entirely.
791fn validate_resources(
792    data: &[u8],
793    records: &[ChunkRecord],
794    estimate: &ResourceEstimate,
795    limits: &ResourceLimits,
796    findings: &mut Vec<Finding>,
797) -> bool {
798    if let Some(max) = limits.max_file_bytes
799        && estimate.file_bytes > max
800    {
801        findings.push(finding(
802            Severity::Error,
803            Layer::Resource,
804            "resource.file-too-large",
805            None,
806            None,
807            None,
808            format!(
809                "file is {} bytes, exceeding the configured limit of {max}",
810                estimate.file_bytes
811            ),
812        ));
813    }
814    if let Some(max) = limits.max_pages
815        && estimate.pages > max
816    {
817        findings.push(finding(
818            Severity::Error,
819            Layer::Resource,
820            "resource.too-many-pages",
821            None,
822            None,
823            None,
824            format!(
825                "document has {} pages, exceeding the configured limit of {max}",
826                estimate.pages
827            ),
828        ));
829    }
830    if let Some(max) = limits.max_components
831        && estimate.components > max
832    {
833        findings.push(finding(
834            Severity::Error,
835            Layer::Resource,
836            "resource.too-many-components",
837            None,
838            None,
839            None,
840            format!(
841                "document has {} components, exceeding the configured limit of {max}",
842                estimate.components
843            ),
844        ));
845    }
846
847    let mut exceeds_decode_cost = false;
848    if let Some(max) = limits.max_page_pixels {
849        let mut page_number = 0usize;
850        for record in records.iter().filter(|record| record.id == *b"INFO") {
851            page_number += 1;
852            let Ok(info) = PageInfo::parse(record_data(data, record)) else {
853                continue;
854            };
855            let pixels = u64::from(info.width) * u64::from(info.height);
856            if pixels > max {
857                exceeds_decode_cost = true;
858                findings.push(finding(
859                    Severity::Error,
860                    Layer::Resource,
861                    "resource.page-too-large",
862                    None,
863                    Some("INFO".to_string()),
864                    Some(record.offset),
865                    format!(
866                        "page {page_number} is {}x{} = {pixels} pixels, exceeding the configured per-page limit of {max}",
867                        info.width, info.height
868                    ),
869                ));
870            }
871        }
872    }
873    if let Some(max) = limits.max_total_pixels
874        && estimate.total_pixels > max
875    {
876        exceeds_decode_cost = true;
877        findings.push(finding(
878            Severity::Error,
879            Layer::Resource,
880            "resource.total-pixels-exceeded",
881            None,
882            None,
883            None,
884            format!(
885                "document totals {} pixels, exceeding the configured limit of {max}",
886                estimate.total_pixels
887            ),
888        ));
889    }
890    if let Some(max) = limits.max_decoded_bytes
891        && estimate.peak_decoded_bytes > max
892    {
893        exceeds_decode_cost = true;
894        findings.push(finding(
895            Severity::Error,
896            Layer::Resource,
897            "resource.decoded-memory-exceeded",
898            None,
899            None,
900            None,
901            format!(
902                "peak decoded page memory is an estimated {} bytes, exceeding the configured limit of {max}",
903                estimate.peak_decoded_bytes
904            ),
905        ));
906    }
907    exceeds_decode_cost
908}
909
910fn validate_codecs(
911    data: &[u8],
912    records: &[ChunkRecord],
913    component_offsets: &BTreeMap<usize, String>,
914    opts: &ValidateOptions,
915    findings: &mut Vec<Finding>,
916) {
917    let component_for =
918        |record: &ChunkRecord| component_for_record(record, records, component_offsets);
919    let mut iw44_streams: BTreeMap<(Vec<usize>, [u8; 4]), Vec<&ChunkRecord>> = BTreeMap::new();
920
921    for record in records {
922        let chunk = record.id;
923        let payload = record_data(data, record);
924        if is_iw44_chunk(chunk) {
925            // TH44 chunks in a THUM component are independent thumbnail
926            // images, not progressive refinements of one image. Each starts
927            // at serial zero, so its full path is its stream identity.
928            let stream_path = if chunk == *b"TH44" {
929                record.path.clone()
930            } else {
931                record.path[..record.path.len().saturating_sub(1)].to_vec()
932            };
933            iw44_streams
934                .entry((stream_path, chunk))
935                .or_default()
936                .push(record);
937        }
938        if is_bzz_chunk(chunk)
939            && let Err(error) = crate::bzz::bzz_decode(payload)
940        {
941            findings.push(finding(
942                Severity::Error,
943                Layer::Codec,
944                "codec.bzz-decode-failed",
945                component_for(record),
946                Some(chunk_id_text(chunk)),
947                Some(record.offset),
948                format!("{} BZZ stream decode failed: {error}", chunk_id_text(chunk)),
949            ));
950        }
951    }
952
953    for (_, stream) in iw44_streams {
954        validate_iw44_stream(data, &stream, &component_for, opts.decode_pages, findings);
955    }
956
957    let Ok(document) = DjVuDocument::parse(data) else {
958        return;
959    };
960    for page_index in 0..document.page_count() {
961        let Ok(page) = document.page(page_index) else {
962            continue;
963        };
964        let component = page_component_id(page_index, records, component_offsets);
965        if let Err(error) = page.text_layer() {
966            findings.push(finding(
967                Severity::Error,
968                Layer::Codec,
969                "codec.text-parse-failed",
970                component.clone(),
971                Some("TXTz".to_string()),
972                None,
973                format!("page {} text layer parse failed: {error}", page_index + 1),
974            ));
975        }
976        if let Err(error) = page.annotations() {
977            findings.push(finding(
978                Severity::Error,
979                Layer::Codec,
980                "codec.annotation-parse-failed",
981                component.clone(),
982                Some("ANTz".to_string()),
983                None,
984                format!("page {} annotation parse failed: {error}", page_index + 1),
985            ));
986        }
987        if opts.decode_pages
988            && let Err(error) = page.extract_mask()
989        {
990            findings.push(finding(
991                Severity::Error,
992                Layer::Codec,
993                "codec.jb2-decode-failed",
994                component,
995                Some("Sjbz".to_string()),
996                None,
997                format!("page {} JB2 symbol decode failed: {error}", page_index + 1),
998            ));
999        }
1000    }
1001    if let Err(error) = document.metadata() {
1002        findings.push(finding(
1003            Severity::Error,
1004            Layer::Codec,
1005            "codec.metadata-parse-failed",
1006            None,
1007            Some("METz".to_string()),
1008            None,
1009            format!("metadata parse failed: {error}"),
1010        ));
1011    }
1012
1013    if opts.decode_pages {
1014        for record in records.iter().filter(|record| record.id == *b"Djbz") {
1015            if let Err(error) = crate::jb2::decode_dict(record_data(data, record), None) {
1016                findings.push(finding(
1017                    Severity::Error,
1018                    Layer::Codec,
1019                    "codec.jb2-dictionary-decode-failed",
1020                    component_for(record),
1021                    Some("Djbz".to_string()),
1022                    Some(record.offset),
1023                    format!("JB2 dictionary decode failed: {error}"),
1024                ));
1025            }
1026        }
1027    }
1028}
1029
1030fn validate_iw44_stream(
1031    data: &[u8],
1032    stream: &[&ChunkRecord],
1033    component_for: &impl Fn(&ChunkRecord) -> Option<String>,
1034    decode_pages: bool,
1035    findings: &mut Vec<Finding>,
1036) {
1037    let Some(first) = stream.first() else {
1038        return;
1039    };
1040    let chunk = chunk_id_text(first.id);
1041    let mut expected_serial = 0u8;
1042    let mut header_valid = true;
1043    for record in stream {
1044        let payload = record_data(data, record);
1045        if payload.len() < 2 {
1046            findings.push(finding(
1047                Severity::Error,
1048                Layer::Codec,
1049                "codec.iw44-short-header",
1050                component_for(record),
1051                Some(chunk.clone()),
1052                Some(record.offset),
1053                format!("{chunk} IW44 chunk is shorter than its two-byte header"),
1054            ));
1055            header_valid = false;
1056            continue;
1057        }
1058        if payload[0] != expected_serial {
1059            findings.push(finding(
1060                Severity::Error,
1061                Layer::Codec,
1062                "codec.iw44-bad-serial",
1063                component_for(record),
1064                Some(chunk.clone()),
1065                Some(record.offset),
1066                format!(
1067                    "{chunk} IW44 serial is {}, expected {expected_serial}",
1068                    payload[0]
1069                ),
1070            ));
1071            header_valid = false;
1072        }
1073        expected_serial = expected_serial.wrapping_add(1);
1074        if payload[1] == 0 {
1075            findings.push(finding(
1076                Severity::Error,
1077                Layer::Codec,
1078                "codec.iw44-zero-slices",
1079                component_for(record),
1080                Some(chunk.clone()),
1081                Some(record.offset),
1082                format!("{chunk} IW44 chunk declares zero slices"),
1083            ));
1084            header_valid = false;
1085        }
1086        if payload[0] == 0 {
1087            if payload.len() < 9 {
1088                findings.push(finding(
1089                    Severity::Error,
1090                    Layer::Codec,
1091                    "codec.iw44-short-first-header",
1092                    component_for(record),
1093                    Some(chunk.clone()),
1094                    Some(record.offset),
1095                    format!("{chunk} first IW44 chunk is shorter than its nine-byte header"),
1096                ));
1097                header_valid = false;
1098                continue;
1099            }
1100            if payload[2] & 0x7f != 1 || payload[3] > 2 {
1101                findings.push(finding(
1102                    Severity::Error,
1103                    Layer::Codec,
1104                    "codec.iw44-bad-version",
1105                    component_for(record),
1106                    Some(chunk.clone()),
1107                    Some(record.offset),
1108                    format!(
1109                        "{chunk} IW44 version {}.{} is unsupported",
1110                        payload[2] & 0x7f,
1111                        payload[3]
1112                    ),
1113                ));
1114                header_valid = false;
1115            }
1116            let width = u16::from_be_bytes([payload[4], payload[5]]);
1117            let height = u16::from_be_bytes([payload[6], payload[7]]);
1118            if width == 0 || height == 0 {
1119                findings.push(finding(
1120                    Severity::Error,
1121                    Layer::Codec,
1122                    "codec.iw44-zero-dimensions",
1123                    component_for(record),
1124                    Some(chunk.clone()),
1125                    Some(record.offset),
1126                    format!("{chunk} IW44 header has zero dimensions ({width}x{height})"),
1127                ));
1128                header_valid = false;
1129            }
1130        }
1131    }
1132    if decode_pages && header_valid {
1133        let mut image = crate::iw44::Iw44Image::new();
1134        for record in stream {
1135            if let Err(error) = image.decode_chunk(record_data(data, record)) {
1136                findings.push(finding(
1137                    Severity::Error,
1138                    Layer::Codec,
1139                    "codec.iw44-decode-failed",
1140                    component_for(record),
1141                    Some(chunk.clone()),
1142                    Some(record.offset),
1143                    format!("{chunk} IW44 coefficient decode failed: {error}"),
1144                ));
1145                break;
1146            }
1147        }
1148    }
1149}
1150
1151fn component_for_record(
1152    record: &ChunkRecord,
1153    records: &[ChunkRecord],
1154    component_offsets: &BTreeMap<usize, String>,
1155) -> Option<String> {
1156    records
1157        .iter()
1158        .filter(|ancestor| {
1159            ancestor.id == *b"FORM"
1160                && ancestor.depth == 1
1161                && record.path.starts_with(&ancestor.path)
1162        })
1163        .max_by_key(|ancestor| ancestor.path.len())
1164        .and_then(|ancestor| component_offsets.get(&ancestor.offset))
1165        .cloned()
1166}
1167
1168fn page_component_id(
1169    page_index: usize,
1170    records: &[ChunkRecord],
1171    component_offsets: &BTreeMap<usize, String>,
1172) -> Option<String> {
1173    records
1174        .iter()
1175        .filter(|record| {
1176            record.id == *b"FORM" && record.depth == 1 && record.form_type == Some(*b"DJVU")
1177        })
1178        .nth(page_index)
1179        .and_then(|record| component_offsets.get(&record.offset))
1180        .cloned()
1181}
1182
1183fn is_bundled_djvm(form: &iff::Form<'_>) -> bool {
1184    form.chunks
1185        .iter()
1186        .find(|chunk| chunk.id == *b"DIRM")
1187        .and_then(|chunk| DirmPayload::decode(chunk.data).ok())
1188        .is_some_and(|dirm| dirm.is_bundled())
1189}
1190
1191fn is_indirect_djvm(form: &iff::Form<'_>) -> bool {
1192    form.chunks
1193        .iter()
1194        .find(|chunk| chunk.id == *b"DIRM")
1195        .and_then(|chunk| DirmPayload::decode(chunk.data).ok())
1196        .is_some_and(|dirm| !dirm.is_bundled())
1197}
1198
1199fn record_data<'a>(data: &'a [u8], record: &ChunkRecord) -> &'a [u8] {
1200    let start = record.offset.saturating_add(8);
1201    let end = start.saturating_add(record.length);
1202    data.get(start..end).unwrap_or_default()
1203}
1204
1205fn record_offset(records: &[ChunkRecord], parent_path: &[usize], id: [u8; 4]) -> Option<usize> {
1206    records
1207        .iter()
1208        .find(|record| record.path.starts_with(parent_path) && record.id == id)
1209        .map(|record| record.offset)
1210}
1211
1212fn finding(
1213    severity: Severity,
1214    layer: Layer,
1215    code: &'static str,
1216    component: Option<String>,
1217    chunk: Option<String>,
1218    offset: Option<usize>,
1219    message: String,
1220) -> Finding {
1221    Finding {
1222        severity,
1223        layer,
1224        code,
1225        component,
1226        chunk,
1227        offset,
1228        message,
1229    }
1230}
1231
1232fn is_known_form(form: [u8; 4]) -> bool {
1233    form == *b"DJVU"
1234        || form == *b"DJVM"
1235        || form == *b"DJVI"
1236        || form == *b"THUM"
1237        || form == *b"BM44"
1238        || form == *b"PM44"
1239}
1240
1241fn is_known_chunk(id: [u8; 4]) -> bool {
1242    [
1243        *b"INFO", *b"DIRM", *b"INCL", *b"NAVM", *b"BG44", *b"FG44", *b"TH44", *b"BM44", *b"PM44",
1244        *b"Sjbz", *b"Djbz", *b"Smmr", *b"FGbz", *b"FGjp", *b"TXTz", *b"TXTa", *b"ANTz", *b"ANTa",
1245        *b"METz", *b"METa", *b"CIDa", *b"WMRM",
1246    ]
1247    .contains(&id)
1248}
1249
1250fn is_iw44_chunk(id: [u8; 4]) -> bool {
1251    [*b"BG44", *b"FG44", *b"TH44", *b"BM44", *b"PM44"].contains(&id)
1252}
1253
1254fn is_bzz_chunk(id: [u8; 4]) -> bool {
1255    [*b"TXTz", *b"ANTz", *b"METz", *b"NAVM"].contains(&id)
1256}
1257
1258fn chunk_id_text(id: [u8; 4]) -> String {
1259    String::from_utf8_lossy(&id).into_owned()
1260}
1261
1262fn iff_error_code(error: &iff::IffError) -> &'static str {
1263    match error {
1264        iff::IffError::ChunkTooLong { .. } => "iff.truncated-chunk",
1265        iff::IffError::Truncated | iff::IffError::TooShort => "iff.truncated",
1266        iff::IffError::BadMagic { .. } => "iff.bad-magic",
1267        iff::IffError::UnknownFormType { .. } => "iff.unknown-form",
1268        iff::IffError::DepthLimitExceeded { .. } => "iff.depth-limit",
1269        iff::IffError::UnsupportedVersion { .. } => "iff.unsupported-version",
1270        // `IffError` is `#[non_exhaustive]` (see `docs/api-compatibility.md`
1271        // §1): a variant added in `djvu-iff` must not fail this crate's build.
1272        // A new parse failure is still a malformed container, so it reports as
1273        // the generic IFF code rather than being silently dropped from the
1274        // report.
1275        _ => "iff.invalid",
1276    }
1277}
1278
1279fn iff_error_chunk(error: &iff::IffError) -> Option<String> {
1280    match error {
1281        iff::IffError::ChunkTooLong { id, .. } => Some(chunk_id_text(*id)),
1282        _ => None,
1283    }
1284}
1285
1286fn iff_error_offset(data: &[u8], error: &iff::IffError) -> usize {
1287    match error {
1288        iff::IffError::TooShort | iff::IffError::BadMagic { .. } => 0,
1289        iff::IffError::ChunkTooLong { id, .. } => find_chunk_header(data, *id).unwrap_or(4),
1290        _ => 4.min(data.len()),
1291    }
1292}
1293
1294fn find_chunk_header(data: &[u8], id: [u8; 4]) -> Option<usize> {
1295    data.windows(4).position(|window| window == id)
1296}
1297
1298#[cfg(test)]
1299mod tests {
1300    use super::*;
1301    use crate::iff::{Chunk, EmitPart};
1302
1303    fn fixture(name: &str) -> Vec<u8> {
1304        std::fs::read(format!("tests/fixtures/{name}")).expect("fixture exists")
1305    }
1306
1307    fn with_extra_chunk(data: &[u8], id: [u8; 4], payload: Vec<u8>) -> Vec<u8> {
1308        let form = iff::parse_form(data).expect("fixture parses");
1309        let mut chunks = form
1310            .chunks
1311            .iter()
1312            .map(|chunk| Chunk::Leaf {
1313                id: chunk.id,
1314                data: chunk.data.to_vec(),
1315            })
1316            .collect::<Vec<_>>();
1317        chunks.push(Chunk::Leaf { id, data: payload });
1318        let parts = chunks.iter().map(EmitPart::Chunk).collect::<Vec<_>>();
1319        iff::partial_emit(form.form_type, &parts).expect("fixture remains small")
1320    }
1321
1322    fn with_replaced_chunk(data: &[u8], target: [u8; 4], payload: Vec<u8>) -> Vec<u8> {
1323        let form = iff::parse_form(data).expect("fixture parses");
1324        let chunks = form
1325            .chunks
1326            .iter()
1327            .map(|chunk| Chunk::Leaf {
1328                id: chunk.id,
1329                data: if chunk.id == target {
1330                    payload.clone()
1331                } else {
1332                    chunk.data.to_vec()
1333                },
1334            })
1335            .collect::<Vec<_>>();
1336        let parts = chunks.iter().map(EmitPart::Chunk).collect::<Vec<_>>();
1337        iff::partial_emit(form.form_type, &parts).expect("fixture remains small")
1338    }
1339
1340    fn bundled_with_missing_incl_target() -> Vec<u8> {
1341        let page_chunks = [Chunk::Leaf {
1342            id: *b"INCL",
1343            data: b"missing.djvi".to_vec(),
1344        }];
1345        let page_parts = page_chunks.iter().map(EmitPart::Chunk).collect::<Vec<_>>();
1346        let page = iff::partial_emit(*b"DJVU", &page_parts).expect("small page");
1347        let page_len = u32::from_be_bytes(page[8..12].try_into().expect("FORM length")) as usize;
1348        let page_body = page[12..12 + page_len].to_vec();
1349        let ids = ["page.djvu".to_string()];
1350        let flags = [1u8];
1351        let sizes = [u32::try_from(8 + page_body.len()).expect("small page")];
1352        let mut dirm = DirmPayload::build_bundled(1, &flags, &ids, &sizes);
1353        let emit = |dirm: &DirmPayload| {
1354            let dirm = Chunk::Leaf {
1355                id: *b"DIRM",
1356                data: dirm.encode(),
1357            };
1358            iff::partial_emit_with_offsets(
1359                *b"DJVM",
1360                &[EmitPart::Chunk(&dirm), EmitPart::Form(&page_body)],
1361            )
1362            .expect("small bundle")
1363        };
1364        let (_, offsets) = emit(&dirm);
1365        dirm.offsets = vec![u32::try_from(offsets[1]).expect("small offset")];
1366        emit(&dirm).0
1367    }
1368
1369    #[test]
1370    fn real_fixtures_have_no_errors_at_probe_level() {
1371        for name in ["boy.djvu", "boy_jb2.djvu", "DjVu3Spec_bundled.djvu"] {
1372            let report = validate(&fixture(name), &ValidateOptions::default());
1373            assert!(report.is_valid(), "{name}: {:#?}", report.findings);
1374        }
1375    }
1376
1377    #[test]
1378    fn truncated_chunk_is_a_structural_error_with_offset() {
1379        let mut data = fixture("boy.djvu");
1380        let bg44 = data
1381            .windows(4)
1382            .position(|window| window == b"BG44")
1383            .expect("fixture has BG44");
1384        data[bg44 + 4..bg44 + 8].copy_from_slice(&u32::MAX.to_be_bytes());
1385        let report = validate(&data, &ValidateOptions::default());
1386        assert!(report.findings.iter().any(|finding| {
1387            finding.code == "iff.truncated-chunk"
1388                && finding.layer == Layer::Structural
1389                && finding.offset == Some(bg44)
1390        }));
1391    }
1392
1393    #[test]
1394    fn corrupt_bzz_metadata_is_a_codec_error() {
1395        let data = with_extra_chunk(&fixture("boy.djvu"), *b"METz", vec![0]);
1396        let report = validate(&data, &ValidateOptions::default());
1397        assert!(
1398            report
1399                .findings
1400                .iter()
1401                .any(|finding| finding.code == "codec.bzz-decode-failed"
1402                    && finding.chunk.as_deref() == Some("METz"))
1403        );
1404    }
1405
1406    #[test]
1407    fn missing_incl_target_reuses_component_graph_validation() {
1408        let report = validate(
1409            &bundled_with_missing_incl_target(),
1410            &ValidateOptions::default(),
1411        );
1412        assert!(report.findings.iter().any(|finding| {
1413            finding.code == "dep.missing-target"
1414                && finding.layer == Layer::Dependency
1415                && finding.component.as_deref() == Some("page.djvu")
1416        }));
1417    }
1418
1419    #[test]
1420    fn every_component_graph_error_kind_has_a_stable_dependency_code() {
1421        let codes = [
1422            graph_error_finding(GraphError::MissingTarget {
1423                source: "page.djvu".to_string(),
1424                target: "missing.djvi".to_string(),
1425            })
1426            .code,
1427            graph_error_finding(GraphError::DuplicateIdentity {
1428                id: "dup.djvi".to_string(),
1429            })
1430            .code,
1431            graph_error_finding(GraphError::InvalidComponentType {
1432                id: "page.djvu".to_string(),
1433                form: *b"DJVI",
1434            })
1435            .code,
1436            graph_error_finding(GraphError::Cycle {
1437                path: vec!["a.djvi".to_string(), "a.djvi".to_string()],
1438            })
1439            .code,
1440        ];
1441        assert_eq!(
1442            codes,
1443            [
1444                "dep.missing-target",
1445                "dep.duplicate-identity",
1446                "dep.invalid-component-type",
1447                "dep.cycle",
1448            ]
1449        );
1450    }
1451
1452    #[test]
1453    fn jb2_full_decode_is_opt_in() {
1454        let corrupt = with_replaced_chunk(&fixture("boy_jb2.djvu"), *b"Sjbz", vec![0]);
1455        let probe = validate(&corrupt, &ValidateOptions::default());
1456        assert!(
1457            probe
1458                .findings
1459                .iter()
1460                .all(|finding| finding.code != "codec.jb2-decode-failed")
1461        );
1462        let full = validate(
1463            &corrupt,
1464            &ValidateOptions {
1465                strict: false,
1466                decode_pages: true,
1467                limits: None,
1468            },
1469        );
1470        assert!(
1471            full.findings
1472                .iter()
1473                .any(|finding| finding.code == "codec.jb2-decode-failed")
1474        );
1475    }
1476
1477    #[test]
1478    fn planned_output_helper_accepts_valid_and_rejects_broken_bytes() {
1479        // A real fixture is a valid planned output.
1480        assert!(validate_planned_output(&fixture("boy.djvu")).is_ok());
1481        // Truncated bytes carry error-severity findings and block a commit.
1482        let broken = &fixture("boy.djvu")[..32];
1483        let findings = validate_planned_output(broken).expect_err("broken bytes rejected");
1484        assert!(!findings.is_empty());
1485        assert!(
1486            findings
1487                .iter()
1488                .all(|finding| finding.severity == Severity::Error)
1489        );
1490    }
1491
1492    #[test]
1493    fn editor_commit_validates_planned_output() {
1494        use crate::editor::{DocumentEditor, EditOperation, EditRequest};
1495        use crate::metadata::DjVuMetadata;
1496
1497        let dir = std::env::temp_dir().join(format!("djvu_planned_{}", std::process::id()));
1498        std::fs::create_dir_all(&dir).expect("temp dir");
1499        let input = dir.join("in.djvu");
1500        let output = dir.join("out.djvu");
1501        std::fs::write(&input, fixture("boy.djvu")).expect("write input");
1502
1503        // A valid edit passes pre-commit validation and lands on disk.
1504        DocumentEditor::apply_to_path(
1505            &input,
1506            &output,
1507            &EditRequest::new(vec![EditOperation::SetDocumentMetadata {
1508                metadata: DjVuMetadata {
1509                    title: Some("validated".to_string()),
1510                    ..Default::default()
1511                },
1512            }]),
1513        )
1514        .expect("valid edit commits");
1515        assert!(validate_planned_output(&std::fs::read(&output).expect("read output")).is_ok());
1516
1517        let _ = std::fs::remove_dir_all(&dir);
1518    }
1519
1520    #[test]
1521    fn unknown_ascii_chunk_is_tolerated_and_file_stays_valid() {
1522        let data = with_extra_chunk(&fixture("boy.djvu"), *b"Xtra", vec![1, 2, 3]);
1523        let report = validate(&data, &ValidateOptions::default());
1524        assert!(report.is_valid(), "{:#?}", report.findings);
1525        assert!(report.findings.iter().any(|finding| {
1526            finding.code == "iff.tolerated-extension-chunk"
1527                && finding.severity == Severity::Tolerated
1528                && finding.chunk.as_deref() == Some("Xtra")
1529        }));
1530    }
1531
1532    #[test]
1533    fn summary_counts_match_findings() {
1534        let data = with_extra_chunk(&fixture("boy.djvu"), *b"Xtra", vec![1, 2, 3]);
1535        let report = validate(&data, &ValidateOptions::default());
1536        let summary = report.summary();
1537        assert_eq!(
1538            summary.errors + summary.warnings + summary.tolerated + summary.recovery,
1539            report.findings.len()
1540        );
1541    }
1542
1543    #[test]
1544    fn resource_estimate_is_populated_without_limits() {
1545        let data = fixture("boy.djvu");
1546        let report = validate(&data, &ValidateOptions::default());
1547        let estimate = report.resources;
1548        assert_eq!(estimate.file_bytes, data.len() as u64);
1549        assert_eq!(estimate.pages, 1);
1550        assert_eq!(estimate.components, 1);
1551        assert!(estimate.max_page_pixels > 0);
1552        assert_eq!(estimate.total_pixels, estimate.max_page_pixels);
1553        assert_eq!(
1554            estimate.peak_decoded_bytes,
1555            estimate.max_page_pixels * DECODED_BYTES_PER_PIXEL
1556        );
1557        // No limits configured means no resource-layer findings.
1558        assert!(
1559            report
1560                .findings
1561                .iter()
1562                .all(|finding| finding.layer != Layer::Resource)
1563        );
1564    }
1565
1566    #[test]
1567    fn empty_limits_never_produce_resource_findings() {
1568        let report = validate(
1569            &fixture("boy.djvu"),
1570            &ValidateOptions {
1571                limits: Some(ResourceLimits::default()),
1572                ..Default::default()
1573            },
1574        );
1575        assert!(report.is_valid());
1576        assert!(
1577            report
1578                .findings
1579                .iter()
1580                .all(|finding| finding.layer != Layer::Resource)
1581        );
1582    }
1583
1584    #[test]
1585    fn each_exceeded_limit_is_reported_as_a_resource_error() {
1586        let report = validate(
1587            &fixture("boy.djvu"),
1588            &ValidateOptions {
1589                limits: Some(ResourceLimits {
1590                    max_file_bytes: Some(1),
1591                    max_pages: Some(0),
1592                    max_components: Some(0),
1593                    max_page_pixels: Some(1),
1594                    max_total_pixels: Some(1),
1595                    max_decoded_bytes: Some(1),
1596                    max_render_pixels: None,
1597                }),
1598                ..Default::default()
1599            },
1600        );
1601        assert!(!report.is_valid());
1602        let codes: Vec<_> = report
1603            .findings
1604            .iter()
1605            .filter(|finding| finding.layer == Layer::Resource)
1606            .map(|finding| finding.code)
1607            .collect();
1608        for expected in [
1609            "resource.file-too-large",
1610            "resource.too-many-pages",
1611            "resource.too-many-components",
1612            "resource.page-too-large",
1613            "resource.total-pixels-exceeded",
1614            "resource.decoded-memory-exceeded",
1615        ] {
1616            assert!(codes.contains(&expected), "missing {expected}: {codes:?}");
1617        }
1618        // Every resource finding is an error and the per-page one carries an offset.
1619        assert!(
1620            report
1621                .findings
1622                .iter()
1623                .filter(|finding| finding.layer == Layer::Resource)
1624                .all(|finding| finding.severity == Severity::Error)
1625        );
1626        assert!(report.findings.iter().any(|finding| {
1627            finding.code == "resource.page-too-large" && finding.offset.is_some()
1628        }));
1629    }
1630
1631    #[test]
1632    fn generous_limits_stay_valid() {
1633        let report = validate(
1634            &fixture("boy.djvu"),
1635            &ValidateOptions {
1636                limits: Some(ResourceLimits {
1637                    max_file_bytes: Some(u64::MAX),
1638                    max_pages: Some(u64::MAX),
1639                    max_components: Some(u64::MAX),
1640                    max_page_pixels: Some(u64::MAX),
1641                    max_total_pixels: Some(u64::MAX),
1642                    max_decoded_bytes: Some(u64::MAX),
1643                    max_render_pixels: Some(u64::MAX),
1644                }),
1645                ..Default::default()
1646            },
1647        );
1648        assert!(report.is_valid(), "{:#?}", report.findings);
1649    }
1650
1651    #[test]
1652    fn decode_cost_limit_skips_the_expensive_page_decode() {
1653        // A corrupt JB2 mask would surface as codec.jb2-decode-failed under
1654        // decode_pages, but a per-page pixel limit must short-circuit the
1655        // decode before that expensive work happens.
1656        let corrupt = with_replaced_chunk(&fixture("boy_jb2.djvu"), *b"Sjbz", vec![0]);
1657        let report = validate(
1658            &corrupt,
1659            &ValidateOptions {
1660                strict: false,
1661                decode_pages: true,
1662                limits: Some(ResourceLimits {
1663                    max_page_pixels: Some(1),
1664                    ..ResourceLimits::default()
1665                }),
1666            },
1667        );
1668        assert!(
1669            report
1670                .findings
1671                .iter()
1672                .all(|finding| finding.code != "codec.jb2-decode-failed"),
1673            "decode must be skipped: {:#?}",
1674            report.findings
1675        );
1676        assert!(
1677            report
1678                .findings
1679                .iter()
1680                .any(|finding| finding.code == "resource.decode-skipped"
1681                    && finding.severity == Severity::Recovery)
1682        );
1683    }
1684
1685    #[test]
1686    fn check_document_limits_reports_typed_page_pixel_violation() {
1687        let data = fixture("boy.djvu");
1688        let err = super::check_document_limits(
1689            &data,
1690            &ResourceLimits {
1691                max_page_pixels: Some(1),
1692                ..ResourceLimits::default()
1693            },
1694            "document.parse",
1695        )
1696        .expect_err("limit should fail");
1697        assert_eq!(err.operation, "document.parse");
1698        assert_eq!(err.axis, ResourceLimitAxis::PagePixels);
1699    }
1700
1701    /// [`check_document_limits`] is the typed-error API surfaced to library
1702    /// callers (`ParseOptions::limits`), distinct from the `Finding`-based
1703    /// `validate_resources` path exercised above. Each axis must be checked
1704    /// independently and report itself, not fall through to a later one.
1705    #[test]
1706    fn check_document_limits_reports_typed_file_bytes_violation() {
1707        let data = fixture("boy.djvu");
1708        let err = super::check_document_limits(
1709            &data,
1710            &ResourceLimits {
1711                max_file_bytes: Some(1),
1712                ..ResourceLimits::default()
1713            },
1714            "document.parse",
1715        )
1716        .expect_err("limit should fail");
1717        assert_eq!(err.axis, ResourceLimitAxis::FileBytes);
1718        assert_eq!(err.limit, 1);
1719        assert!(err.found > 1);
1720    }
1721
1722    #[test]
1723    fn check_document_limits_reports_typed_page_count_violation() {
1724        let data = fixture("boy.djvu");
1725        let err = super::check_document_limits(
1726            &data,
1727            &ResourceLimits {
1728                max_pages: Some(0),
1729                ..ResourceLimits::default()
1730            },
1731            "document.parse",
1732        )
1733        .expect_err("limit should fail");
1734        assert_eq!(err.axis, ResourceLimitAxis::PageCount);
1735        assert_eq!(err.limit, 0);
1736    }
1737
1738    #[test]
1739    fn check_document_limits_reports_typed_component_count_violation() {
1740        let data = fixture("boy.djvu");
1741        let err = super::check_document_limits(
1742            &data,
1743            &ResourceLimits {
1744                max_components: Some(0),
1745                ..ResourceLimits::default()
1746            },
1747            "document.parse",
1748        )
1749        .expect_err("limit should fail");
1750        assert_eq!(err.axis, ResourceLimitAxis::ComponentCount);
1751        assert_eq!(err.limit, 0);
1752    }
1753
1754    #[test]
1755    fn check_document_limits_reports_typed_total_pixels_violation() {
1756        let data = fixture("boy.djvu");
1757        let err = super::check_document_limits(
1758            &data,
1759            &ResourceLimits {
1760                max_total_pixels: Some(1),
1761                ..ResourceLimits::default()
1762            },
1763            "document.parse",
1764        )
1765        .expect_err("limit should fail");
1766        assert_eq!(err.axis, ResourceLimitAxis::TotalPixels);
1767        assert_eq!(err.limit, 1);
1768    }
1769
1770    #[test]
1771    fn check_document_limits_reports_typed_decoded_bytes_violation() {
1772        let data = fixture("boy.djvu");
1773        let err = super::check_document_limits(
1774            &data,
1775            &ResourceLimits {
1776                max_decoded_bytes: Some(1),
1777                ..ResourceLimits::default()
1778            },
1779            "document.parse",
1780        )
1781        .expect_err("limit should fail");
1782        assert_eq!(err.axis, ResourceLimitAxis::DecodedBytes);
1783        assert_eq!(err.limit, 1);
1784    }
1785
1786    /// The first-violated axis wins, in the documented FileBytes → PageCount →
1787    /// ComponentCount → PagePixels → TotalPixels → DecodedBytes check order —
1788    /// not e.g. the smallest overage.
1789    #[test]
1790    fn check_document_limits_reports_first_violated_axis_in_order() {
1791        let data = fixture("boy.djvu");
1792        let err = super::check_document_limits(
1793            &data,
1794            &ResourceLimits {
1795                max_file_bytes: Some(1),
1796                max_pages: Some(0),
1797                max_total_pixels: Some(1),
1798                ..ResourceLimits::default()
1799            },
1800            "document.parse",
1801        )
1802        .expect_err("limit should fail");
1803        assert_eq!(err.axis, ResourceLimitAxis::FileBytes);
1804    }
1805
1806    #[test]
1807    fn inherited_limits_set_render_ceiling_only() {
1808        let inherited = ResourceLimits::inherited();
1809        assert_eq!(inherited.max_render_pixels, Some(DEFAULT_MAX_RENDER_PIXELS));
1810        assert!(inherited.max_pages.is_none());
1811    }
1812}