Skip to main content

zenmeta/xmp/container/
directory.rs

1//! Validated directory editing using the same RDF traversal as inspection.
2
3use super::{ContainerItem, ItemSemantic};
4use crate::xmp::{Error, Packet, SpannedProperty};
5use alloc::{
6    string::{String, ToString},
7    vec::Vec,
8};
9use core::ops::Range;
10
11const MAX_ITEMS: usize = 256;
12
13struct Entry {
14    item: ContainerItem,
15    length: Option<Range<usize>>,
16    padding: Option<Range<usize>>,
17}
18
19/// A validated GContainer directory and its source packet.
20///
21/// Supports Google Photos and Dynamic Depth namespaces, including aliases.
22/// A directory must start with exactly one primary, and each secondary must
23/// have a unique semantic, MIME type and decimal length. At most 256 items
24/// are accepted. Unknown fields remain untouched during editing.
25pub struct Directory<'a> {
26    xml: &'a str,
27    entries: Vec<Entry>,
28}
29
30impl<'a> Directory<'a> {
31    /// Parse a complete namespace-bound XMP packet. A packet without a
32    /// directory is valid and has no items. Malformed or ambiguous XML fails.
33    pub fn parse(xml: &'a str) -> Result<Self, Error> {
34        let packet = Packet::parse(xml)?;
35        let mut entries = Vec::new();
36        for (container_ns, item_ns) in [
37            (
38                super::super::gainmap::CONTAINER_NAMESPACE,
39                super::super::gainmap::ITEM_NAMESPACE,
40            ),
41            (
42                "http://ns.google.com/photos/dd/1.0/container/",
43                "http://ns.google.com/photos/dd/1.0/item/",
44            ),
45        ] {
46            let count = packet
47                .doc
48                .descendants()
49                .filter(|n| n.has_tag_name((container_ns, "Directory")))
50                .count();
51            if count > 1
52                || packet
53                    .doc
54                    .descendants()
55                    .any(|n| n.attribute((container_ns, "Directory")).is_some())
56            {
57                return Err(Error::Duplicate("Directory".into()));
58            }
59            let items = packet.resource_sequence_spanned(
60                container_ns,
61                "Directory",
62                container_ns,
63                "Item",
64                MAX_ITEMS,
65            )?;
66            if count != 0 && items.is_empty() {
67                return Err(Error::Structure("empty or misplaced Directory".into()));
68            }
69            if !entries.is_empty() && !items.is_empty() {
70                return Err(Error::Duplicate("Directory namespaces".into()));
71            }
72            for fields in items {
73                let field = |name| {
74                    fields
75                        .iter()
76                        .find(|f| f.property.namespace() == item_ns && f.property.name() == name)
77                };
78                let required = |name| {
79                    field(name)
80                        .map(|f| f.property.value())
81                        .filter(|s| !s.is_empty())
82                        .ok_or_else(|| Error::Structure(alloc::format!("missing item {name}")))
83                };
84                let semantic = required("Semantic")?;
85                let mime = required("Mime")?;
86                if entries
87                    .iter()
88                    .any(|e: &Entry| e.item.semantic.as_xmp_str() == semantic)
89                {
90                    return Err(Error::Duplicate("item semantic".into()));
91                }
92                if (semantic == "Primary") != entries.is_empty() {
93                    return Err(Error::Structure(
94                        "primary must be the first directory item".into(),
95                    ));
96                }
97                let number = |name| -> Result<Option<usize>, Error> {
98                    field(name)
99                        .map(|f| {
100                            let value = f.property.value();
101                            if value.is_empty() || !value.bytes().all(|b| b.is_ascii_digit()) {
102                                return Err(Error::Structure(alloc::format!(
103                                    "invalid item {name}"
104                                )));
105                            }
106                            value.parse().map_err(|_| {
107                                Error::Structure(alloc::format!("invalid item {name}"))
108                            })
109                        })
110                        .transpose()
111                };
112                let length = number("Length")?;
113                let padding = number("Padding")?;
114                if semantic != "Primary" && length.is_none() {
115                    return Err(Error::Structure("secondary item requires Length".into()));
116                }
117                entries.push(Entry {
118                    item: ContainerItem {
119                        semantic: ItemSemantic::from_xmp(semantic),
120                        mime: mime.into(),
121                        length,
122                        padding,
123                    },
124                    length: field("Length").map(|f| value_range(xml, f)).transpose()?,
125                    padding: field("Padding").map(|f| value_range(xml, f)).transpose()?,
126                });
127            }
128        }
129        Ok(Self { xml, entries })
130    }
131
132    /// Items in directory order, including the primary.
133    pub fn items(&self) -> impl ExactSizeIterator<Item = &ContainerItem> {
134        self.entries.iter().map(|e| &e.item)
135    }
136
137    /// Replace secondary lengths by semantic and set secondary padding to zero.
138    ///
139    /// The caller supplies the final payload lengths. Unlisted payloads (such
140    /// as MPF thumbnails) are allowed, but every directory secondary must match
141    /// exactly one supplied semantic. Primary fields and all bytes outside the
142    /// selected numeric values are retained. Element content, including CDATA,
143    /// is replaced by decimal text; attribute quote style is retained.
144    pub fn rewrite_item_lengths(&self, secondaries: &[(&str, usize)]) -> Result<String, Error> {
145        let mut edits = Vec::new();
146        for entry in &self.entries {
147            if entry.item.semantic == ItemSemantic::Primary {
148                continue;
149            }
150            let mut matches = secondaries
151                .iter()
152                .filter(|(semantic, _)| *semantic == entry.item.semantic.as_xmp_str());
153            let (_, length) = matches
154                .next()
155                .ok_or_else(|| Error::Structure("directory item has no carried payload".into()))?;
156            if matches.next().is_some() {
157                return Err(Error::Duplicate("carried item semantic".into()));
158            }
159            edits.push((
160                entry
161                    .length
162                    .clone()
163                    .ok_or_else(|| Error::Structure("secondary Length".into()))?,
164                length.to_string(),
165            ));
166            if let Some(range) = &entry.padding {
167                edits.push((range.clone(), "0".into()));
168            }
169        }
170        edits.sort_unstable_by_key(|(r, _)| r.start);
171        let mut size = self.xml.len();
172        let mut last = 0;
173        for (range, replacement) in &edits {
174            if range.start < last || range.end > self.xml.len() || range.start > range.end {
175                return Err(Error::Structure("overlapping directory edits".into()));
176            }
177            size = size
178                .checked_sub(range.len())
179                .and_then(|s| s.checked_add(replacement.len()))
180                .ok_or(Error::Limit)?;
181            last = range.end;
182        }
183        if size > super::super::gainmap::MAX_XMP_LENGTH {
184            return Err(Error::Limit);
185        }
186        let mut out = String::new();
187        out.try_reserve_exact(size).map_err(|_| Error::Limit)?;
188        last = 0;
189        for (range, replacement) in edits {
190            out.push_str(&self.xml[last..range.start]);
191            out.push_str(&replacement);
192            last = range.end;
193        }
194        out.push_str(&self.xml[last..]);
195        Ok(out)
196    }
197}
198
199// The XML parser already validated each source range. Locate only its value
200// boundary: Attribute::range_value truncates unusually long names/whitespace
201// in roxmltree 0.21, so use its full, non-truncated attribute range instead.
202fn value_range(xml: &str, field: &SpannedProperty) -> Result<Range<usize>, Error> {
203    let source = &xml[field.source.clone()];
204    let invalid = || Error::Structure("directory value span".into());
205    if field.attribute {
206        let first = source
207            .bytes()
208            .position(|b| b == b'\'' || b == b'"')
209            .ok_or_else(invalid)?;
210        if source.as_bytes().last() != source.as_bytes().get(first) {
211            return Err(invalid());
212        }
213        Ok(field.source.start + first + 1..field.source.end - 1)
214    } else {
215        let mut quote = None;
216        let mut begin = None;
217        for (i, b) in source.bytes().enumerate() {
218            if let Some(q) = quote {
219                if b == q {
220                    quote = None;
221                }
222            } else if b == b'\'' || b == b'"' {
223                quote = Some(b);
224            } else if b == b'>' {
225                begin = Some(i + 1);
226                break;
227            }
228        }
229        let start = begin.ok_or_else(invalid)?;
230        let end = source.rfind("</").ok_or_else(invalid)?;
231        if start > end {
232            return Err(invalid());
233        }
234        Ok(field.source.start + start..field.source.start + end)
235    }
236}