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::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}