Skip to main content

document_svg/document/
marc.rs

1//! Bounded MARC21 ISO 2709 record previews.
2//!
3//! ISO 2709 records contain a 24-byte leader, a directory and a field area.
4//! This adapter validates those boundaries and renders field/subfield metadata
5//! without exposing URL payloads or contacting catalog services.
6
7use std::path::Path;
8
9use crate::convert::{ConvertOptions, PageConsumer, read_limited_file};
10use crate::document::html::{HtmlBlock, render_blocks_to_pages};
11use crate::error::{Error, Result};
12use crate::table::{TableAlign, TableData};
13
14const MAX_MARC_BYTES: u64 = 64 * 1024 * 1024;
15const MAX_MARC_RECORDS: usize = 100_000;
16const MAX_MARC_FIELDS: usize = 500_000;
17const MAX_MARC_SUBFIELDS: usize = 1_000_000;
18const MAX_MARC_ROWS: usize = 200_000;
19const MAX_MARC_DISPLAY_BYTES: usize = 512;
20
21pub(crate) fn looks_like_prefix(bytes: &[u8]) -> bool {
22    if bytes.len() < 24 || !bytes[..5].iter().all(u8::is_ascii_digit) {
23        return false;
24    }
25    let record_length = parse_digits(&bytes[..5]);
26    let base = parse_digits(&bytes[12..17]);
27    if !(25..=MAX_MARC_BYTES as usize).contains(&record_length)
28        || !(24..record_length).contains(&base)
29        || record_length < base
30    {
31        return false;
32    }
33    let directory_end = bytes
34        .get(24..bytes.len().min(base))
35        .and_then(|directory| directory.iter().position(|byte| *byte == 0x1e))
36        .map(|offset| offset + 24);
37    directory_end.is_some_and(|end| end > 24 && (end - 24) % 12 == 0)
38}
39
40struct MarcPageSink<'a> {
41    inner: &'a mut dyn PageConsumer,
42    warnings: &'a [String],
43}
44
45impl PageConsumer for MarcPageSink<'_> {
46    fn consume(&mut self, mut page: crate::ir::Page) -> Result<()> {
47        page.source_format = "marc".into();
48        if page.title.is_empty() {
49            page.title = "MARC21 ISO 2709 record".into();
50        }
51        page.description =
52            "MARC21 ISO 2709 fields and subfields are rendered inertly; URL payloads and catalog services are not resolved".into();
53        for warning in self.warnings {
54            page.warn(warning.clone());
55        }
56        self.inner.consume(page)
57    }
58}
59
60#[derive(Default)]
61struct Summary {
62    records: usize,
63    leaders: usize,
64    fields: usize,
65    subfields: usize,
66    rows: Vec<Vec<String>>,
67}
68
69pub(crate) fn convert(
70    path: &Path,
71    options: &ConvertOptions,
72    sink: &mut dyn PageConsumer,
73) -> Result<Vec<String>> {
74    let bytes = read_limited_file(
75        path,
76        options.max_input_bytes.min(MAX_MARC_BYTES),
77        "MARC21 ISO 2709 input",
78    )?;
79    let mut summary = Summary::default();
80    let mut offset = 0usize;
81    while offset < bytes.len() {
82        if summary.records >= MAX_MARC_RECORDS {
83            return Err(Error::LimitExceeded(format!(
84                "MARC records exceed {MAX_MARC_RECORDS}"
85            )));
86        }
87        let remaining = &bytes[offset..];
88        if remaining.len() < 24 {
89            return Err(Error::InvalidInput(
90                "MARC record has a truncated leader".into(),
91            ));
92        }
93        let record_length = parse_digits(&remaining[..5]);
94        if !(25..=MAX_MARC_BYTES as usize).contains(&record_length)
95            || record_length > remaining.len()
96        {
97            return Err(Error::InvalidInput(
98                "MARC record length is invalid or exceeds the input".into(),
99            ));
100        }
101        parse_record(
102            &remaining[..record_length],
103            summary.records + 1,
104            &mut summary,
105        )?;
106        summary.records = summary.records.saturating_add(1);
107        offset = offset.saturating_add(record_length);
108    }
109    if summary.records == 0 {
110        return Err(Error::InvalidInput("MARC input contains no records".into()));
111    }
112    let metadata = format!(
113        "Records: {}\nLeaders: {}\nFields: {}\nSubfields: {}",
114        summary.records, summary.leaders, summary.fields, summary.subfields
115    );
116    let blocks = vec![
117        HtmlBlock::Heading {
118            level: 1,
119            text: "MARC21 ISO 2709 record preview".into(),
120        },
121        HtmlBlock::Paragraph { text: metadata },
122        HtmlBlock::Table(TableData {
123            headers: vec!["Record".into(), "Tag".into(), "Code".into(), "Value".into()],
124            rows: summary.rows,
125            alignments: vec![
126                TableAlign::Right,
127                TableAlign::Left,
128                TableAlign::Left,
129                TableAlign::Left,
130            ],
131            raw_source: String::new(),
132        }),
133    ];
134    let warnings = vec![
135        "MARC21 leader, directory and field/subfield metadata are shown; URL values, catalog references and unrecognized payloads are omitted or redacted".into(),
136        "ISO 2709 record boundaries and directory offsets are validated; no catalog, schema, script or network operation runs".into(),
137    ];
138    let mut page_sink = MarcPageSink {
139        inner: sink,
140        warnings: &warnings,
141    };
142    render_blocks_to_pages(&blocks, &mut page_sink, options)?;
143    Ok(warnings)
144}
145
146fn parse_record(record: &[u8], number: usize, summary: &mut Summary) -> Result<()> {
147    if record.len() < 25 || record[record.len() - 1] != 0x1d {
148        return Err(Error::InvalidInput(
149            "MARC record must end with a record terminator".into(),
150        ));
151    }
152    let base = parse_digits(&record[12..17]);
153    if !(24..record.len()).contains(&base) {
154        return Err(Error::InvalidInput(
155            "MARC base address is outside the record".into(),
156        ));
157    }
158    let directory_end = record[24..base]
159        .iter()
160        .position(|byte| *byte == 0x1e)
161        .map(|offset| offset + 24)
162        .ok_or_else(|| Error::InvalidInput("MARC directory terminator is missing".into()))?;
163    if (directory_end - 24) % 12 != 0 {
164        return Err(Error::InvalidInput(
165            "MARC directory size is not 12-byte aligned".into(),
166        ));
167    }
168    let mut directory = 24usize;
169    summary.leaders = summary.leaders.saturating_add(1);
170    push_row(summary, number, "leader", "—", display_bytes(&record[..24]))?;
171    while directory < directory_end {
172        let entry = &record[directory..directory + 12];
173        let tag = display_bytes(&entry[..3]);
174        let length = parse_digits(&entry[3..7]);
175        let start = parse_digits(&entry[7..12]);
176        if length == 0 || start > record.len().saturating_sub(base) {
177            return Err(Error::InvalidInput(
178                "MARC directory field range is invalid".into(),
179            ));
180        }
181        let field_start = base.saturating_add(start);
182        let field_end = field_start.saturating_add(length);
183        if field_end > record.len() || record[field_end - 1] != 0x1e {
184            return Err(Error::InvalidInput(
185                "MARC directory field exceeds record".into(),
186            ));
187        }
188        let field = &record[field_start..field_end - 1];
189        if summary.fields >= MAX_MARC_FIELDS {
190            return Err(Error::LimitExceeded(format!(
191                "MARC fields exceed {MAX_MARC_FIELDS}"
192            )));
193        }
194        summary.fields = summary.fields.saturating_add(1);
195        if tag.as_bytes().starts_with(b"00") {
196            push_row(
197                summary,
198                number,
199                &tag,
200                "—",
201                safe_value(&field_bytes(field), &tag),
202            )?;
203        } else if field.len() < 2 {
204            push_row(summary, number, &tag, "—", "missing indicators".into())?;
205        } else {
206            let indicators = display_bytes(&field[..2]);
207            let payload = &field[2..];
208            let parts = payload.split(|byte| *byte == 0x1f);
209            let mut saw_subfield = false;
210            for part in parts {
211                if part.is_empty() {
212                    continue;
213                }
214                saw_subfield = true;
215                let code = display_bytes(&part[..1]);
216                let value = safe_value(&field_bytes(&part[1..]), &tag);
217                if summary.subfields >= MAX_MARC_SUBFIELDS {
218                    return Err(Error::LimitExceeded(format!(
219                        "MARC subfields exceed {MAX_MARC_SUBFIELDS}"
220                    )));
221                }
222                summary.subfields = summary.subfields.saturating_add(1);
223                push_row(summary, number, &tag, &code, value)?;
224            }
225            if !saw_subfield {
226                push_row(summary, number, &tag, &indicators, "no subfields".into())?;
227            }
228        }
229        directory += 12;
230    }
231    Ok(())
232}
233
234fn parse_digits(bytes: &[u8]) -> usize {
235    bytes.iter().fold(0usize, |value, byte| {
236        value
237            .saturating_mul(10)
238            .saturating_add(byte.saturating_sub(b'0') as usize)
239    })
240}
241
242fn field_bytes(bytes: &[u8]) -> String {
243    String::from_utf8_lossy(bytes).trim().to_owned()
244}
245
246fn display_bytes(bytes: &[u8]) -> String {
247    String::from_utf8_lossy(bytes).trim().to_owned()
248}
249
250fn safe_value(value: &str, tag: &str) -> String {
251    if tag == "856" || value.contains("://") {
252        "[URL omitted]".into()
253    } else {
254        truncate(value)
255    }
256}
257
258fn push_row(
259    summary: &mut Summary,
260    record: usize,
261    tag: &str,
262    code: &str,
263    value: String,
264) -> Result<()> {
265    if summary.rows.len() >= MAX_MARC_ROWS {
266        return Err(Error::LimitExceeded(format!(
267            "MARC rendered rows exceed {MAX_MARC_ROWS}"
268        )));
269    }
270    summary.rows.push(vec![
271        record.to_string(),
272        truncate(tag),
273        truncate(code),
274        truncate(&value),
275    ]);
276    Ok(())
277}
278
279fn truncate(value: &str) -> String {
280    if value.len() <= MAX_MARC_DISPLAY_BYTES {
281        return value.to_owned();
282    }
283    let mut end = MAX_MARC_DISPLAY_BYTES;
284    while !value.is_char_boundary(end) {
285        end -= 1;
286    }
287    format!("{}…", &value[..end])
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293
294    #[test]
295    fn recognizes_iso2709_leader_and_directory() {
296        let record = minimal_record();
297        assert!(looks_like_prefix(&record));
298        assert!(!looks_like_prefix(b"not a MARC record"));
299    }
300
301    #[test]
302    fn parses_directory_and_redacts_urls() {
303        let record = minimal_record();
304        let mut summary = Summary::default();
305        parse_record(&record, 1, &mut summary).unwrap();
306        assert_eq!(summary.fields, 2);
307        assert_eq!(summary.subfields, 1);
308        assert!(
309            summary
310                .rows
311                .iter()
312                .flatten()
313                .any(|value| value == "[URL omitted]")
314        );
315    }
316
317    fn minimal_record() -> Vec<u8> {
318        build_record(vec![
319            ("001", b"record-001".to_vec()),
320            (
321                "856",
322                [
323                    b"40".as_slice(),
324                    &[0x1f, b'u'],
325                    b"https://private.example.invalid",
326                ]
327                .concat(),
328            ),
329        ])
330    }
331
332    fn build_record(fields: Vec<(&str, Vec<u8>)>) -> Vec<u8> {
333        let mut directory = Vec::new();
334        let mut field_area = Vec::new();
335        for (tag, value) in fields {
336            let start = field_area.len();
337            field_area.extend_from_slice(&value);
338            field_area.push(0x1e);
339            directory.extend_from_slice(tag.as_bytes());
340            directory.extend_from_slice(format!("{:04}", value.len() + 1).as_bytes());
341            directory.extend_from_slice(format!("{:05}", start).as_bytes());
342        }
343        directory.push(0x1e);
344        let base = 24 + directory.len();
345        let length = base + field_area.len() + 1;
346        let mut leader = format!("{:05}nam a2200000 i 4500", length).into_bytes();
347        leader[12..17].copy_from_slice(format!("{:05}", base).as_bytes());
348        let mut record = leader;
349        record.extend_from_slice(&directory);
350        record.extend_from_slice(&field_area);
351        record.push(0x1d);
352        record
353    }
354}