Skip to main content

caj2pdf_core/hnc8/
appinfo.rs

1// SPDX-License-Identifier: MIT
2
3//! The independently observed C8 application-info package at the end of the
4//! source; see docs/research/c8-native-records.md. Only the DOI, DURL and the number of
5//! `NoteItems` entries are exposed publicly by a small scanner rather than a
6//! general XML parser. Native composition additionally reads the measured link
7//! appearances. A malformed tail is located but does not fail page conversion.
8
9use super::inflate::{ExactInflate, InflateFault, InflateFaultKind};
10use super::{Hnc8Reader, Location, Variant, read_fixed};
11use crate::fallible::{len_u64, reserve_exact};
12use crate::hnc8::TEXT_DECODER_RESERVATION_BYTES;
13use crate::{Cancellation, Error, ErrorKind, RangedSource, Result};
14use std::fmt;
15
16pub(super) mod links;
17
18/// Ceiling for both the compressed and the inflated package.
19pub const MAX_APPLICATION_INFO_BYTES: u32 = 1024 * 1024;
20/// Ceiling for one decoded DOI or URL value.
21pub const MAX_APPLICATION_INFO_FIELD_BYTES: usize = 4096;
22
23const MARKER: &[u8] = b"APPINFOSIGN ";
24const MARKER_NAME: &[u8] = b"APPINFOSIGN";
25/// The marker plus at most 20 decimal digits of a 64-bit offset.
26const TRAILER_WINDOW: u64 = 32;
27const HEADER_BYTES: u64 = 8;
28const CHUNK_BYTES: usize = 64 * 1024;
29const MAX_DEPTH: usize = 16;
30const BOM: &str = "\u{feff}";
31/// The trailer defects that make the application-info tail absent rather
32/// than a failure.
33pub(super) const TRAILER_DEFECTS: [&str; 2] = [
34    "application-info trailer: marker is not followed by a final decimal offset",
35    "application-info trailer: offset overflows 64 bits",
36];
37
38/// Values read from a C8 application-info package. Text is trimmed of XML
39/// white space and entity-decoded; an empty value is `None`.
40#[derive(Clone, Debug, Default, Eq, PartialEq)]
41pub struct ApplicationInfo {
42    /// `Package/FileProperty-Package/DOI`. The observed values are CNKI
43    /// identifiers; they are not verified as registered DOIs.
44    pub doi: Option<String>,
45    /// `Package/FileProperty-Package/DURL`. Never followed.
46    pub url: Option<String>,
47    /// `Item` children of every `Package/Note-Package/NoteItems`.
48    pub note_count: u32,
49}
50
51/// Why a present application-info package was ignored.
52#[derive(Clone, Copy, Debug, Eq, PartialEq)]
53pub struct ApplicationInfoDefect {
54    /// Absolute byte offset in the source.
55    pub offset: u64,
56    pub field: &'static str,
57    pub reason: &'static str,
58}
59
60/// A malformed package names its field before the first `": "` of the
61/// reason; any other failure names the field or resource it concerns.
62impl From<&Error> for ApplicationInfoDefect {
63    fn from(error: &Error) -> Self {
64        let (field, reason) = match &error.kind {
65            ErrorKind::Malformed => error
66                .reason
67                .split_once(": ")
68                .unwrap_or((error.reason, "malformed")),
69            ErrorKind::UnsupportedFormat => (error.reason, "unsupported"),
70            ErrorKind::Encrypted => (error.reason, "encrypted"),
71            ErrorKind::Truncated { .. } => (error.reason, "truncated"),
72            ErrorKind::LimitExceeded { resource, .. } => (*resource, "limit"),
73            ErrorKind::Io(_) => (error.reason, "source"),
74            ErrorKind::Cancelled => (error.reason, "cancelled"),
75        };
76        Self {
77            offset: error.offset.unwrap_or(0),
78            field,
79            reason,
80        }
81    }
82}
83
84impl fmt::Display for ApplicationInfoDefect {
85    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
86        write!(
87            f,
88            "ignored C8 application-info package at byte {}: {}: {}",
89            self.offset, self.field, self.reason
90        )
91    }
92}
93
94/// What conversion did with a C8 application-info package.
95#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
96pub enum ApplicationInfoStatus {
97    /// No package, or not a C8 source.
98    #[default]
99    Absent,
100    /// The package was read; its DOI and URL went to the PDF `/Info`. Native
101    /// C8 composition also preserves admitted link annotations.
102    Read,
103    /// The package was defective and ignored; pages are unaffected.
104    Ignored(ApplicationInfoDefect),
105}
106
107/// A lenient read: the package when it was read, and what happened to it.
108#[derive(Clone, Debug, Default, Eq, PartialEq)]
109pub struct ApplicationInfoReport {
110    pub info: Option<ApplicationInfo>,
111    pub status: ApplicationInfoStatus,
112}
113
114impl<S: RangedSource, C: Cancellation> Hnc8Reader<'_, S, C> {
115    /// Like [`Self::application_info`], but a defect becomes
116    /// [`ApplicationInfoStatus::Ignored`]. Only cancellation is an error.
117    pub fn application_info_report(&mut self) -> Result<ApplicationInfoReport> {
118        Ok(match self.application_info() {
119            Ok(Some(info)) => ApplicationInfoReport {
120                info: Some(info),
121                status: ApplicationInfoStatus::Read,
122            },
123            Ok(None) => ApplicationInfoReport::default(),
124            Err(error) if matches!(error.kind, ErrorKind::Cancelled) => return Err(error),
125            Err(error) => ApplicationInfoReport {
126                info: None,
127                status: ApplicationInfoStatus::Ignored(ApplicationInfoDefect::from(&error)),
128            },
129        })
130    }
131
132    /// Read the trailing application-info package of a C8 source.
133    ///
134    /// Returns `None` for other variants and when the file does not end with
135    /// the `APPINFOSIGN` trailer. Declared lengths must exactly frame one zlib
136    /// stream ending at the trailer. At most [`MAX_APPLICATION_INFO_BYTES`]
137    /// are inflated, within `Limits::max_allocation_bytes`. Callers should
138    /// treat every error except cancellation as a warning. Independent of the
139    /// page cursor.
140    pub fn application_info(&mut self) -> Result<Option<ApplicationInfo>> {
141        let Some((xml, loc)) = self.application_info_xml()? else {
142            return Ok(None);
143        };
144        scan(&xml, loc).map(Some)
145    }
146
147    fn application_info_xml(&mut self) -> Result<Option<(Vec<u8>, Location)>> {
148        if self.header.variant != Variant::C8 {
149            return Ok(None);
150        }
151        let loc = Location {
152            variant: Some(Variant::C8),
153            offset: 0,
154            page: None,
155            image: None,
156        };
157        if self.cancellation.is_cancelled() {
158            return Err(loc.cancelled());
159        }
160        let Some(package) = self.find_package(loc)? else {
161            return Ok(None);
162        };
163        let xml = self.inflate(package, loc.at(package.offset))?;
164        Ok(Some((xml, loc.at(package.offset))))
165    }
166
167    /// Locate a final `APPINFOSIGN <decimal start>` trailer in the last 32
168    /// bytes of the source, for any variant. `None` when that window holds no
169    /// marker name; a marker that is not followed by a final decimal offset
170    /// fitting 64 bits is malformed. Shared by the package reader and the
171    /// structure report. Independent of the page cursor.
172    pub(super) fn application_info_trailer(&mut self) -> Result<Option<Trailer>> {
173        let loc = Location {
174            variant: Some(self.header.variant),
175            offset: 0,
176            page: None,
177            image: None,
178        };
179        let size = self.source.size();
180        let window_start = size.saturating_sub(TRAILER_WINDOW);
181        let mut window = [0; TRAILER_WINDOW as usize];
182        let window = &mut window[..(size - window_start) as usize];
183        let field = "application-info trailer";
184        read_fixed(
185            self.source,
186            self.limits,
187            self.cancellation,
188            window_start,
189            window,
190            loc.at(window_start),
191            field,
192        )?;
193        let digits = window
194            .iter()
195            .rev()
196            .take_while(|byte| byte.is_ascii_digit())
197            .count();
198        let marker_at = window.len().checked_sub(digits + MARKER.len());
199        if digits == 0 || marker_at.is_none_or(|at| &window[at..at + MARKER.len()] != MARKER) {
200            return match window
201                .windows(MARKER_NAME.len())
202                .position(|part| part == MARKER_NAME)
203            {
204                Some(at) => Err(loc
205                    .at(window_start + len_u64(at))
206                    .malformed(TRAILER_DEFECTS[0])),
207                None => Ok(None),
208            };
209        }
210        let marker = window_start + len_u64(marker_at.expect("checked marker position"));
211        let start = std::str::from_utf8(&window[window.len() - digits..])
212            .expect("ASCII digits")
213            .parse::<u64>()
214            .map_err(|_| loc.at(marker).malformed(TRAILER_DEFECTS[1]))?;
215        Ok(Some(Trailer { marker, start }))
216    }
217
218    /// Locate the trailer and return the zlib stream it frames.
219    fn find_package(&mut self, loc: Location) -> Result<Option<Package>> {
220        let Some(Trailer { marker, start }) = self.application_info_trailer()? else {
221            return Ok(None);
222        };
223        let lengths = "application-info lengths";
224        if start < self.header.page_index.offset + self.header.page_index.length {
225            return Err(loc.at(marker).malformed(
226                "application-info lengths: package starts inside the header or page index",
227            ));
228        }
229        if start > marker || marker - start < HEADER_BYTES {
230            return Err(loc.at(marker).truncated(
231                lengths,
232                HEADER_BYTES,
233                marker.saturating_sub(start),
234            ));
235        }
236        let mut header = [0; HEADER_BYTES as usize];
237        read_fixed(
238            self.source,
239            self.limits,
240            self.cancellation,
241            start,
242            &mut header,
243            loc.at(start),
244            lengths,
245        )?;
246        let decoded = u32::from_le_bytes(header[..4].try_into().expect("fixed field width"));
247        let compressed = u32::from_le_bytes(header[4..].try_into().expect("fixed field width"));
248        for (resource, value) in [
249            ("application-info decoded bytes", decoded),
250            ("application-info compressed bytes", compressed),
251        ] {
252            if value > MAX_APPLICATION_INFO_BYTES {
253                return Err(loc.at(start).limit(
254                    resource,
255                    u64::from(MAX_APPLICATION_INFO_BYTES),
256                    u64::from(value),
257                ));
258            }
259        }
260        if decoded == 0 {
261            return Err(loc
262                .at(start)
263                .malformed("application-info lengths: declared empty package"));
264        }
265        let offset = start + HEADER_BYTES;
266        if offset + u64::from(compressed) != marker {
267            return Err(loc.at(start).malformed(
268                "application-info lengths: compressed length does not end at the marker",
269            ));
270        }
271        Ok(Some(Package {
272            offset,
273            compressed: u64::from(compressed),
274            decoded: u64::from(decoded),
275        }))
276    }
277
278    /// Inflate exactly the declared stream into exactly the declared length.
279    fn inflate(&mut self, package: Package, loc: Location) -> Result<Vec<u8>> {
280        let Package {
281            compressed,
282            decoded,
283            ..
284        } = package;
285        let field = "application-info zlib stream";
286        let chunk = len_u64(self.limits.io_chunk_bytes.min(CHUNK_BYTES)).min(compressed);
287        // One sentinel byte detects output beyond the declared length.
288        let planned = (decoded + 1) + chunk + TEXT_DECODER_RESERVATION_BYTES;
289        let resource = "application-info allocation bytes";
290        if planned > self.limits.max_allocation_bytes {
291            return Err(loc.limit(resource, self.limits.max_allocation_bytes, planned));
292        }
293        let refused = || loc.limit(resource, self.limits.max_allocation_bytes, planned);
294        let mut input = Vec::new();
295        reserve_exact(&mut input, chunk as usize, refused())?;
296        input.resize(chunk as usize, 0);
297        let mut output = Vec::new();
298        reserve_exact(&mut output, decoded as usize + 1, refused())?;
299        output.resize(decoded as usize + 1, 0);
300        let mut inflate = ExactInflate::new(package.offset, compressed, decoded);
301        let fault = |fault: InflateFault| {
302            let reason = match fault.kind {
303                InflateFaultKind::Invalid => {
304                    "application-info zlib stream: invalid stream or checksum"
305                }
306                InflateFaultKind::Excess => {
307                    "application-info zlib stream: output exceeds the declared length"
308                }
309                InflateFaultKind::EndMismatch => {
310                    "application-info zlib stream: stream ends before the declared lengths"
311                }
312                InflateFaultKind::Stalled => "application-info zlib stream: truncated stream",
313            };
314            loc.at(fault.offset).malformed(reason)
315        };
316        loop {
317            if self.cancellation.is_cancelled() {
318                return Err(loc.at(inflate.position()).cancelled());
319            }
320            if let Some((at, length)) = inflate.next_read(input.len()) {
321                read_fixed(
322                    self.source,
323                    self.limits,
324                    self.cancellation,
325                    at,
326                    &mut input[..length],
327                    loc.at(at),
328                    field,
329                )?;
330            }
331            let window = &mut output[inflate.total_out() as usize..];
332            let step = inflate.step(&input, window).map_err(fault)?;
333            inflate.check_length(&step).map_err(fault)?;
334            if inflate.finished(&step).map_err(fault)? {
335                output.truncate(decoded as usize);
336                return Ok(output);
337            }
338        }
339    }
340}
341
342/// A final `APPINFOSIGN <start>` trailer: the marker's offset and the
343/// declared package start, which is not checked against the source.
344#[derive(Clone, Copy)]
345pub(super) struct Trailer {
346    pub(super) marker: u64,
347    pub(super) start: u64,
348}
349
350/// A checked zlib stream that ends exactly at the trailer marker.
351#[derive(Clone, Copy)]
352struct Package {
353    offset: u64,
354    compressed: u64,
355    decoded: u64,
356}
357
358#[derive(Clone, Copy, Eq, PartialEq)]
359enum Field {
360    Doi,
361    Url,
362}
363
364fn is_space(byte: u8) -> bool {
365    matches!(byte, b' ' | b'\t' | b'\r' | b'\n')
366}
367
368fn is_name_byte(byte: u8) -> bool {
369    byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':') || byte >= 0x80
370}
371
372/// Extract the selected fields from a complete UTF-8 package. Element nesting
373/// is checked; attributes are skipped after a quoting check; declarations,
374/// unknown entities and text outside the root are rejected.
375fn scan(bytes: &[u8], loc: Location) -> Result<ApplicationInfo> {
376    scan_with(bytes, loc, &mut |_| Ok(()))
377}
378
379/// Borrowed events from the same bounded, structure-checking metadata scanner.
380enum Event<'a> {
381    Start {
382        parents: &'a [&'a str],
383        name: &'a str,
384        empty: bool,
385        tag: &'a str,
386    },
387    End {
388        path: &'a [&'a str],
389    },
390    Text {
391        path: &'a [&'a str],
392        text: &'a str,
393    },
394}
395
396fn scan_with(
397    bytes: &[u8],
398    loc: Location,
399    observe: &mut impl FnMut(Event<'_>) -> Result<()>,
400) -> Result<ApplicationInfo> {
401    let bad = |reason| loc.malformed(reason);
402    let xml = std::str::from_utf8(bytes)
403        .map_err(|_| bad("application-info XML: package is not UTF-8"))?;
404    let body = xml.strip_prefix(BOM).unwrap_or(xml);
405    let mut info = ApplicationInfo::default();
406    let mut stack = [""; MAX_DEPTH];
407    let mut depth = 0;
408    let mut root_closed = false;
409    let mut seen = [false; 2];
410    let mut capture: Option<(Field, String)> = None;
411    let mut rest = body;
412    while !rest.is_empty() {
413        if !rest.starts_with('<') {
414            let end = rest.find('<').unwrap_or(rest.len());
415            let text = &rest[..end];
416            observe(Event::Text {
417                path: &stack[..depth],
418                text,
419            })?;
420            if let Some((_, value)) = &mut capture {
421                decode_text(text, value, loc)?;
422            } else if depth == 0 && !text.bytes().all(is_space) {
423                return Err(bad("application-info XML: text outside the root element"));
424            }
425            rest = &rest[end..];
426        } else if let Some(tail) = rest.strip_prefix("<?") {
427            let end = tail.find("?>").ok_or(bad(
428                "application-info XML: unterminated processing instruction",
429            ))?;
430            if rest.len() == body.len() && tail.starts_with("xml") {
431                check_encoding(&tail[3..end])
432                    .ok_or(bad("application-info XML: declared encoding is not UTF-8"))?;
433            }
434            rest = &tail[end + 2..];
435        } else if let Some(tail) = rest.strip_prefix("<!--") {
436            let end = tail
437                .find("-->")
438                .ok_or(bad("application-info XML: unterminated comment"))?;
439            rest = &tail[end + 3..];
440        } else if let Some(tail) = rest.strip_prefix("<![CDATA[") {
441            let end = tail
442                .find("]]>")
443                .ok_or(bad("application-info XML: unterminated character data"))?;
444            if depth == 0 {
445                return Err(bad(
446                    "application-info XML: character data outside the root element",
447                ));
448            }
449            if let Some((_, value)) = &mut capture {
450                push(value, &tail[..end], loc)?;
451            }
452            observe(Event::Text {
453                path: &stack[..depth],
454                text: &tail[..end],
455            })?;
456            rest = &tail[end + 3..];
457        } else if rest.starts_with("<!") {
458            return Err(bad("application-info XML: declarations are not accepted"));
459        } else if let Some(tail) = rest.strip_prefix("</") {
460            let end = tail
461                .find('>')
462                .ok_or(bad("application-info XML: unterminated end tag"))?;
463            let name = tail[..end].trim_end_matches([' ', '\t', '\r', '\n']);
464            if depth == 0 || stack[depth - 1] != name {
465                return Err(bad(
466                    "application-info XML: end tag does not match the open element",
467                ));
468            }
469            observe(Event::End {
470                path: &stack[..depth],
471            })?;
472            depth -= 1;
473            root_closed = depth == 0;
474            if let Some((field, value)) = capture.take() {
475                store(&mut info, field, value);
476            }
477            rest = &tail[end + 1..];
478        } else {
479            let (name, empty, length) =
480                start_tag(rest).ok_or(bad("application-info XML: malformed start tag"))?;
481            if capture.is_some() {
482                return Err(bad(
483                    "application-info XML: unexpected element inside DOI or DURL",
484                ));
485            }
486            if depth == 0 && (root_closed || name != "Package") {
487                return Err(bad("application-info XML: root element is not one Package"));
488            }
489            let path = &stack[..depth];
490            observe(Event::Start {
491                parents: path,
492                name,
493                empty,
494                tag: &rest[..length],
495            })?;
496            let field = match name {
497                "DOI" => Some(Field::Doi),
498                "DURL" => Some(Field::Url),
499                _ => None,
500            };
501            if let Some(field) = field.filter(|_| path == ["Package", "FileProperty-Package"]) {
502                if std::mem::replace(&mut seen[field as usize], true) {
503                    return Err(bad("application-info XML: duplicate DOI or DURL"));
504                }
505                if !empty {
506                    capture = Some((field, String::new()));
507                }
508            } else if name == "Item" && path == ["Package", "Note-Package", "NoteItems"] {
509                // A 1 MiB package holds fewer than 2^32 elements.
510                info.note_count += 1;
511            }
512            if !empty {
513                if depth == MAX_DEPTH {
514                    return Err(bad("application-info XML: elements nest too deeply"));
515                }
516                stack[depth] = name;
517                depth += 1;
518            } else if depth == 0 {
519                root_closed = true;
520            }
521            rest = &rest[length..];
522        }
523    }
524    if !root_closed || depth != 0 {
525        return Err(bad(
526            "application-info XML: root element is missing or unclosed",
527        ));
528    }
529    Ok(info)
530}
531
532fn store(info: &mut ApplicationInfo, field: Field, value: String) {
533    let trimmed = value.trim_matches([' ', '\t', '\r', '\n']);
534    let value = (!trimmed.is_empty()).then(|| trimmed.to_owned());
535    match field {
536        Field::Doi => info.doi = value,
537        Field::Url => info.url = value,
538    }
539}
540
541/// Parse one start tag, returning its name, whether it is empty-element, and
542/// its byte length. Attribute names and quoted values are checked, not kept.
543fn start_tag(tag: &str) -> Option<(&str, bool, usize)> {
544    let bytes = tag.as_bytes();
545    let mut at = 1;
546    while bytes.get(at).copied().is_some_and(is_name_byte) {
547        at += 1;
548    }
549    let name = &tag[1..at];
550    if name.is_empty() {
551        return None;
552    }
553    loop {
554        let spaced = bytes.get(at).copied().is_some_and(is_space);
555        while bytes.get(at).copied().is_some_and(is_space) {
556            at += 1;
557        }
558        match *bytes.get(at)? {
559            b'>' => return Some((name, false, at + 1)),
560            b'/' => return (bytes.get(at + 1) == Some(&b'>')).then_some((name, true, at + 2)),
561            _ if !spaced => return None,
562            _ => {}
563        }
564        let attribute = at;
565        while bytes.get(at).copied().is_some_and(is_name_byte) {
566            at += 1;
567        }
568        while bytes.get(at).copied().is_some_and(is_space) {
569            at += 1;
570        }
571        if at == attribute || bytes.get(at) != Some(&b'=') {
572            return None;
573        }
574        at += 1;
575        while bytes.get(at).copied().is_some_and(is_space) {
576            at += 1;
577        }
578        let quote = *bytes.get(at).filter(|byte| matches!(byte, b'"' | b'\''))?;
579        let value = tag[at + 1..].find(char::from(quote))?;
580        if tag[at + 1..at + 1 + value].contains('<') {
581            return None;
582        }
583        at += value + 2;
584    }
585}
586
587/// Accept an XML declaration whose `encoding`, if present, names UTF-8.
588fn check_encoding(declaration: &str) -> Option<()> {
589    let Some(at) = declaration.find("encoding") else {
590        return Some(());
591    };
592    let value = declaration[at + 8..]
593        .trim_start()
594        .strip_prefix('=')?
595        .trim_start();
596    let quote = value.chars().next().filter(|c| matches!(c, '"' | '\''))?;
597    let value = &value[1..];
598    let end = value.find(quote)?;
599    value[..end].eq_ignore_ascii_case("UTF-8").then_some(())
600}
601
602fn push(value: &mut String, text: &str, loc: Location) -> Result<()> {
603    let total = value.len() + text.len();
604    if total > MAX_APPLICATION_INFO_FIELD_BYTES {
605        return Err(loc.limit(
606            "application-info field bytes",
607            len_u64(MAX_APPLICATION_INFO_FIELD_BYTES),
608            len_u64(total),
609        ));
610    }
611    value.push_str(text);
612    Ok(())
613}
614
615/// Append text with the five predefined entities and numeric character
616/// references decoded. Any other reference is rejected.
617fn decode_text(mut text: &str, value: &mut String, loc: Location) -> Result<()> {
618    while let Some(at) = text.find('&') {
619        push(value, &text[..at], loc)?;
620        let tail = &text[at + 1..];
621        let end = tail
622            .find(';')
623            .ok_or(loc.malformed("application-info XML: unterminated entity reference"))?;
624        let decoded = match &tail[..end] {
625            "lt" => Some('<'),
626            "gt" => Some('>'),
627            "amp" => Some('&'),
628            "quot" => Some('"'),
629            "apos" => Some('\''),
630            reference => character_reference(reference),
631        }
632        .ok_or(loc.malformed("application-info XML: unknown entity or character reference"))?;
633        push(value, decoded.encode_utf8(&mut [0; 4]), loc)?;
634        text = &tail[end + 1..];
635    }
636    push(value, text, loc)
637}
638
639/// Decode `#NNN` or `#xHH`, rejecting signs, empty digits and NUL.
640fn character_reference(reference: &str) -> Option<char> {
641    let (digits, radix) = match reference.strip_prefix("#x") {
642        Some(hex) => (hex, 16),
643        None => (reference.strip_prefix('#')?, 10),
644    };
645    if digits.is_empty() || !digits.chars().all(|c| c.is_digit(radix)) {
646        return None;
647    }
648    u32::from_str_radix(digits, radix)
649        .ok()
650        .and_then(char::from_u32)
651        .filter(|&c| c != '\0')
652}
653
654#[cfg(test)]
655mod tests;