1use std::collections::BTreeMap;
19
20use crate::{
21 ComponentGraph, ComponentNodeKind, DjVuDocument, GraphError,
22 dirm::DirmPayload,
23 iff::{self, ChunkRecord},
24 info::PageInfo,
25};
26
27const DECODED_BYTES_PER_PIXEL: u64 = 4;
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum Severity {
35 Error,
37 Warning,
39 Tolerated,
41 Recovery,
43}
44
45impl Severity {
46 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum Layer {
60 Structural,
62 Dependency,
64 Codec,
66 Semantic,
68 Resource,
70}
71
72impl Layer {
73 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#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct Finding {
88 pub severity: Severity,
90 pub layer: Layer,
92 pub code: &'static str,
94 pub component: Option<String>,
96 pub chunk: Option<String>,
98 pub offset: Option<usize>,
100 pub message: String,
102}
103
104#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
106pub struct ValidationSummary {
107 pub errors: usize,
109 pub warnings: usize,
111 pub tolerated: usize,
113 pub recovery: usize,
115}
116
117pub use crate::resource_limits::{
118 DEFAULT_MAX_RENDER_PIXELS, ParseOptions, ResourceLimitAxis, ResourceLimitExceeded,
119 ResourceLimits,
120};
121
122#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
128pub struct ResourceEstimate {
129 pub file_bytes: u64,
131 pub pages: u64,
133 pub components: u64,
135 pub max_page_pixels: u64,
137 pub total_pixels: u64,
139 pub peak_decoded_bytes: u64,
142}
143
144#[derive(Debug, Clone, Default, PartialEq, Eq)]
146pub struct ValidationReport {
147 pub findings: Vec<Finding>,
149 pub summary: ValidationSummary,
151 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 pub fn summary(&self) -> ValidationSummary {
171 count_findings(&self.findings)
172 }
173
174 pub fn is_valid(&self) -> bool {
176 self.summary().errors == 0
177 }
178}
179
180#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
182pub struct ValidateOptions {
183 pub strict: bool,
188 pub decode_pages: bool,
192 pub limits: Option<ResourceLimits>,
199}
200
201pub 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
221pub 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 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 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
629fn 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 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
667pub 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
690fn 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
787fn 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 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 _ => "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 assert!(validate_planned_output(&fixture("boy.djvu")).is_ok());
1481 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 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 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 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 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 #[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 #[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}