Skip to main content

document_svg/document/
premis.rs

1//! Bounded PREMIS preservation-metadata previews.
2//!
3//! PREMIS models Objects, Events, Agents and Rights. This adapter renders
4//! entity types, safe dates/names and relationship counts while omitting
5//! checksums, URIs, detailed payloads and external resources.
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::geospatial::xml_tree::{XmlElement, XmlLimits, parse_xml_tree};
13use crate::table::{TableAlign, TableData};
14
15const MAX_PREMIS_BYTES: u64 = 64 * 1024 * 1024;
16const MAX_PREMIS_XML_EVENTS: usize = 1_000_000;
17const MAX_PREMIS_XML_NODES: usize = 500_000;
18const MAX_PREMIS_XML_DEPTH: usize = 96;
19const MAX_PREMIS_TEXT_BYTES: usize = 32 * 1024 * 1024;
20const MAX_PREMIS_ROWS: usize = 200_000;
21const MAX_PREMIS_DISPLAY_BYTES: usize = 512;
22
23pub(crate) fn looks_like_prefix(bytes: &[u8]) -> bool {
24    let root = crate::geospatial::xml_tree::looks_like_root(bytes, b"premis", None);
25    if !root {
26        return false;
27    }
28    let text = String::from_utf8_lossy(bytes).to_ascii_lowercase();
29    text.contains("<object")
30        || text.contains("<event")
31        || text.contains("<agent")
32        || text.contains("<rights")
33}
34
35struct PremisPageSink<'a> {
36    inner: &'a mut dyn PageConsumer,
37    warnings: &'a [String],
38}
39
40impl PageConsumer for PremisPageSink<'_> {
41    fn consume(&mut self, mut page: crate::ir::Page) -> Result<()> {
42        page.source_format = "premis".into();
43        if page.title.is_empty() {
44            page.title = "PREMIS preservation metadata".into();
45        }
46        page.description =
47            "PREMIS entity and relationship metadata is rendered inertly; checksums, URIs, detailed payloads and external resources are not resolved".into();
48        for warning in self.warnings {
49            page.warn(warning.clone());
50        }
51        self.inner.consume(page)
52    }
53}
54
55#[derive(Default)]
56struct Summary {
57    version: String,
58    objects: usize,
59    events: usize,
60    agents: usize,
61    rights: usize,
62    rows: Vec<Vec<String>>,
63}
64
65pub(crate) fn convert(
66    path: &Path,
67    options: &ConvertOptions,
68    sink: &mut dyn PageConsumer,
69) -> Result<Vec<String>> {
70    let bytes = read_limited_file(
71        path,
72        options.max_input_bytes.min(MAX_PREMIS_BYTES),
73        "PREMIS input",
74    )?;
75    let root = parse_xml_tree(
76        &bytes,
77        &XmlLimits {
78            max_events: options.max_xml_events.min(MAX_PREMIS_XML_EVENTS),
79            max_nodes: MAX_PREMIS_XML_NODES,
80            max_depth: MAX_PREMIS_XML_DEPTH,
81            max_text_bytes: MAX_PREMIS_TEXT_BYTES,
82        },
83        "PREMIS",
84    )?;
85    if !root.name.eq_ignore_ascii_case("premis") {
86        return Err(Error::InvalidInput("PREMIS XML root must premis".into()));
87    }
88    let mut summary = Summary {
89        version: root
90            .attribute("version")
91            .or_else(|| root.attribute("VERSION"))
92            .unwrap_or("—")
93            .to_owned(),
94        ..Summary::default()
95    };
96    for object in descendants_named(&root, "object") {
97        summary.objects = summary.objects.saturating_add(1);
98        let kind = first_text(object, "objectCategory");
99        let identifier = first_text(object, "objectIdentifierValue");
100        push_row(
101            &mut summary,
102            "object",
103            &kind,
104            "identifier omitted",
105            &identifier,
106        )?;
107    }
108    for event in descendants_named(&root, "event") {
109        summary.events = summary.events.saturating_add(1);
110        let event_type = first_text(event, "eventType");
111        let date = first_text(event, "eventDateTime");
112        let links = descendants_named(event, "linkingObjectIdentifier").len()
113            + descendants_named(event, "linkingAgentIdentifier").len();
114        push_row(
115            &mut summary,
116            "event",
117            &event_type,
118            &format!("date {}, links {links}", display_or_dash(&date)),
119            "detail omitted",
120        )?;
121    }
122    for agent in descendants_named(&root, "agent") {
123        summary.agents = summary.agents.saturating_add(1);
124        let agent_type = first_text(agent, "agentType");
125        let name = first_text(agent, "agentName");
126        push_row(&mut summary, "agent", &agent_type, &name, "name shown")?;
127    }
128    for rights in descendants_named(&root, "rightsStatement") {
129        summary.rights = summary.rights.saturating_add(1);
130        let basis = first_text(rights, "rightsBasis");
131        let acts = descendants_named(rights, "act").len();
132        push_row(
133            &mut summary,
134            "rights",
135            &basis,
136            &format!("acts {acts}"),
137            "statement omitted",
138        )?;
139    }
140    if summary.objects + summary.events + summary.agents + summary.rights == 0 {
141        return Err(Error::InvalidInput(
142            "PREMIS document contains no object, event, agent or rightsStatement entities".into(),
143        ));
144    }
145    let metadata = format!(
146        "Version: {}\nObjects: {}\nEvents: {}\nAgents: {}\nRights statements: {}",
147        summary.version, summary.objects, summary.events, summary.agents, summary.rights
148    );
149    let blocks = vec![
150        HtmlBlock::Heading {
151            level: 1,
152            text: "PREMIS preservation metadata".into(),
153        },
154        HtmlBlock::Paragraph { text: metadata },
155        HtmlBlock::Table(TableData {
156            headers: vec![
157                "Entity".into(),
158                "Type".into(),
159                "Detail".into(),
160                "Value".into(),
161            ],
162            rows: summary.rows,
163            alignments: vec![TableAlign::Left; 4],
164            raw_source: String::new(),
165        }),
166    ];
167    let warnings = vec![
168        "PREMIS object/event/agent/rights types and relationship counts are shown; identifiers, checksums, URIs, rights text and detailed payloads are omitted or redacted".into(),
169        "PREMIS XML traversal is bounded; external entities, linked resources, applications and preservation actions are never executed".into(),
170    ];
171    let mut page_sink = PremisPageSink {
172        inner: sink,
173        warnings: &warnings,
174    };
175    render_blocks_to_pages(&blocks, &mut page_sink, options)?;
176    Ok(warnings)
177}
178
179fn descendants_named<'a>(element: &'a XmlElement, name: &str) -> Vec<&'a XmlElement> {
180    let mut result = Vec::new();
181    for child in &element.children {
182        if child.name.eq_ignore_ascii_case(name) {
183            result.push(child);
184        }
185        result.extend(descendants_named(child, name));
186    }
187    result
188}
189
190fn first_text(parent: &XmlElement, name: &str) -> String {
191    parent
192        .children_named(name)
193        .next()
194        .map(|child| safe_text(child.text.trim()))
195        .unwrap_or_default()
196}
197
198fn safe_text(value: &str) -> String {
199    if value.contains("://") {
200        "[URL omitted]".into()
201    } else {
202        truncate(value)
203    }
204}
205
206fn push_row(
207    summary: &mut Summary,
208    entity: &str,
209    type_name: &str,
210    detail: &str,
211    value: &str,
212) -> Result<()> {
213    if summary.rows.len() >= MAX_PREMIS_ROWS {
214        return Err(Error::LimitExceeded(format!(
215            "PREMIS rendered rows exceed {MAX_PREMIS_ROWS}"
216        )));
217    }
218    summary.rows.push(vec![
219        truncate(entity),
220        truncate(type_name),
221        truncate(detail),
222        truncate(value),
223    ]);
224    Ok(())
225}
226
227fn truncate(value: &str) -> String {
228    if value.len() <= MAX_PREMIS_DISPLAY_BYTES {
229        return value.to_owned();
230    }
231    let mut end = MAX_PREMIS_DISPLAY_BYTES;
232    while !value.is_char_boundary(end) {
233        end -= 1;
234    }
235    format!("{}…", &value[..end])
236}
237
238fn display_or_dash(value: &str) -> &str {
239    if value.is_empty() { "—" } else { value }
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245
246    #[test]
247    fn recognizes_premis_root() {
248        assert!(looks_like_prefix(
249            br#"<premis xmlns="http://www.loc.gov/premis/v3"><object><objectCategory>file</objectCategory></object></premis>"#
250        ));
251        assert!(!looks_like_prefix(br#"<premis><extension/></premis>"#));
252    }
253
254    #[test]
255    fn redacts_uri_values() {
256        assert_eq!(
257            safe_text("https://private.example.invalid/hash"),
258            "[URL omitted]"
259        );
260        assert_eq!(safe_text("file"), "file");
261    }
262}