1use 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}