g1t/services/packages/src/xml.rs

235 lines9,163 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Merge branch 'worktree-agent-a6a121745e81f639f'1//! Just enough XML for package files: a Maven POM and a NuGet `.nuspec`
2//! are read into a tree of elements (names without their namespace
3//! prefix), and the metadata Maven reads is written with `escape`.
4//! Declarations, comments, doctypes and processing instructions are
5//! skipped; CDATA and the standard entities are read.
6
7#[derive(Clone, Debug, Default, PartialEq, Eq)]
8pub struct Element {
9 /// The local name: `package` for `<ns:package>`.
10 pub name: String,
11 pub attributes: Vec<(String, String)>,
12 pub children: Vec<Element>,
13 /// The element's own text, its parts joined.
14 pub text: String,
15}
16
17impl Element {
18 /// The first child named `name`.
19 pub fn child(&self, name: &str) -> Option<&Element> {
20 self.children.iter().find(|c| c.name == name)
21 }
22
23 pub fn children_named<'a>(&'a self, name: &'a str) -> impl Iterator<Item = &'a Element> + 'a {
24 self.children.iter().filter(move |c| c.name == name)
25 }
26
27 /// The trimmed text of the first child named `name`, when it has some.
28 pub fn child_text(&self, name: &str) -> Option<String> {
29 self.child(name).map(|c| c.text.trim().to_owned()).filter(|t| !t.is_empty())
30 }
31
32 pub fn attribute(&self, name: &str) -> Option<&str> {
33 self.attributes.iter().find(|(key, _)| key == name).map(|(_, value)| value.as_str())
34 }
35}
36
37fn local(name: &str) -> String {
38 name.rsplit(':').next().unwrap_or(name).to_owned()
39}
40
41/// Text with its entities read: `&amp;` is `&`, `&#65;` is `A`.
42fn unescape(text: &str) -> String {
43 let mut out = String::with_capacity(text.len());
44 let mut rest = text;
45 while let Some(at) = rest.find('&') {
46 out.push_str(&rest[..at]);
47 rest = &rest[at..];
48 let Some(end) = rest.find(';').filter(|end| *end <= 12) else {
49 out.push('&');
50 rest = &rest[1..];
51 continue;
52 };
53 let entity = &rest[1..end];
54 let read = match entity {
55 "amp" => Some('&'),
56 "lt" => Some('<'),
57 "gt" => Some('>'),
58 "quot" => Some('"'),
59 "apos" => Some('\''),
60 _ => entity
61 .strip_prefix("#x")
62 .or_else(|| entity.strip_prefix("#X"))
63 .and_then(|hex| u32::from_str_radix(hex, 16).ok())
64 .or_else(|| entity.strip_prefix('#').and_then(|n| n.parse().ok()))
65 .and_then(char::from_u32),
66 };
67 match read {
68 Some(c) => {
69 out.push(c);
70 rest = &rest[end + 1..];
71 }
72 None => {
73 out.push('&');
74 rest = &rest[1..];
75 }
76 }
77 }
78 out.push_str(rest);
79 out
80}
81
82/// Text made safe to put between tags or in an attribute.
83pub fn escape(text: &str) -> String {
84 text.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
85}
86
87fn attributes(text: &str) -> Result<Vec<(String, String)>, String> {
88 let mut list = Vec::new();
89 let mut rest = text.trim();
90 while !rest.is_empty() {
91 let eq = rest.find('=').ok_or("An attribute has no value.")?;
92 let key = rest[..eq].trim();
93 let after = rest[eq + 1..].trim_start();
94 let quote = after.chars().next().filter(|c| *c == '"' || *c == '\'').ok_or("An attribute's value is not quoted.")?;
95 let close = after[1..].find(quote).ok_or("An attribute's value is not closed.")? + 1;
96 list.push((local(key), unescape(&after[1..close])));
97 rest = after[close + 1..].trim_start();
98 }
99 Ok(list)
100}
101
102/// Reads a document into its root element.
103pub fn parse(text: &str) -> Result<Element, String> {
104 let text = text.trim_start_matches('\u{feff}');
105 let mut stack: Vec<Element> = Vec::new();
106 let mut root = None;
107 let mut rest = text;
108 while !rest.is_empty() {
109 let Some(open) = rest.find('<') else {
110 if let Some(top) = stack.last_mut() {
111 top.text.push_str(&unescape(rest));
112 }
113 break;
114 };
115 if open > 0
116 && let Some(top) = stack.last_mut() {
117 top.text.push_str(&unescape(&rest[..open]));
118 }
119 rest = &rest[open..];
120 let skip = |rest: &str, end: &str| rest.find(end).map(|at| at + end.len()).ok_or_else(|| "The XML ends early.".to_owned());
121 if rest.starts_with("<?") {
122 rest = &rest[skip(rest, "?>")?..];
123 } else if rest.starts_with("<!--") {
124 rest = &rest[skip(rest, "-->")?..];
125 } else if let Some(cdata) = rest.strip_prefix("<![CDATA[") {
126 let end = cdata.find("]]>").ok_or("The XML ends early.")?;
127 if let Some(top) = stack.last_mut() {
128 top.text.push_str(&cdata[..end]);
129 }
130 rest = &cdata[end + 3..];
131 } else if rest.starts_with("<!") {
132 rest = &rest[skip(rest, ">")?..];
133 } else if let Some(closing) = rest.strip_prefix("</") {
134 let end = closing.find('>').ok_or("The XML ends early.")?;
135 let name = local(closing[..end].trim());
136 let element = stack.pop().ok_or("A tag is closed that was not opened.")?;
137 if element.name != name {
138 return Err(format!("<{}> is closed by </{name}>.", element.name));
139 }
140 match stack.last_mut() {
141 Some(parent) => parent.children.push(element),
142 None => root = Some(element),
143 }
144 rest = &closing[end + 1..];
145 } else {
146 // An opening tag; `>` inside a quoted attribute does not end it.
147 let mut quote = None;
148 let end = rest
149 .char_indices()
150 .find(|&(_, c)| {
151 match quote {
152 Some(q) if c == q => quote = None,
153 None if c == '"' || c == '\'' => quote = Some(c),
154 None if c == '>' => return true,
155 _ => {}
156 }
157 false
158 })
159 .map(|(at, _)| at)
160 .ok_or("The XML ends early.")?;
161 let inner = &rest[1..end];
162 let empty = inner.ends_with('/');
163 let inner = inner.trim_end_matches('/');
164 let (name, attrs) = inner.split_once(char::is_whitespace).unwrap_or((inner, ""));
165 if name.is_empty() {
166 return Err("A tag has no name.".to_owned());
167 }
168 let element = Element { name: local(name), attributes: attributes(attrs)?, ..Element::default() };
169 if empty {
170 match stack.last_mut() {
171 Some(parent) => parent.children.push(element),
172 None => root = Some(element),
173 }
174 } else {
175 stack.push(element);
176 }
177 rest = &rest[end + 1..];
178 }
179 if root.is_some() && stack.is_empty() {
180 break;
181 }
182 }
183 if !stack.is_empty() {
184 return Err("The XML ends early.".to_owned());
185 }
186 root.ok_or_else(|| "There is no XML in it.".to_owned())
187}
188
189#[cfg(test)]
190mod tests {
191 use super::*;
192
193 #[test]
194 fn documents_read_into_elements() {
195 let doc = parse(
196 r#"<?xml version="1.0" encoding="utf-8"?>
197<!-- a comment -->
198<package xmlns="http://schemas.microsoft.com/packaging/2013/05/nuspec.xsd">
199 <metadata minClientVersion="4.0">
200 <id>Acme.Web</id>
201 <description><![CDATA[Fast & <small>]]></description>
202 <authors>Ada &amp; Bo</authors>
203 <repository type="git" url="https://g1t.sh/acme/web.git" />
204 <dependencies>
205 <group targetFramework="net8.0"><dependency id="Newtonsoft.Json" version="13.0.1" exclude="Build,Analyzers" /></group>
206 </dependencies>
207 <x:other xmlns:x="urn:x" note='a > b'>&#65;&#x42;</x:other>
208 </metadata>
209</package>"#,
210 )
211 .unwrap();
212 assert_eq!(doc.name, "package");
213 let metadata = doc.child("metadata").unwrap();
214 assert_eq!(metadata.attribute("minClientVersion"), Some("4.0"));
215 assert_eq!(metadata.child_text("id").as_deref(), Some("Acme.Web"));
216 assert_eq!(metadata.child_text("description").as_deref(), Some("Fast & <small>"));
217 assert_eq!(metadata.child_text("authors").as_deref(), Some("Ada & Bo"));
218 assert_eq!(metadata.child("repository").unwrap().attribute("url"), Some("https://g1t.sh/acme/web.git"));
219 let dep = metadata.child("dependencies").unwrap().child("group").unwrap().child("dependency").unwrap();
220 assert_eq!(dep.attribute("version"), Some("13.0.1"));
221 let other = metadata.child("other").unwrap();
222 assert_eq!(other.text, "AB");
223 assert_eq!(other.attribute("note"), Some("a > b"));
224 assert_eq!(metadata.child_text("missing"), None);
225 }
226
227 #[test]
228 fn broken_documents_are_refused() {
229 assert!(parse("<a><b></a>").is_err());
230 assert!(parse("<a>").is_err());
231 assert!(parse("just text").is_err());
232 assert!(parse("<a x=1/>").is_err());
233 assert_eq!(escape("a<b & \"c\""), "a&lt;b &amp; &quot;c&quot;");
234 }
235}

This file's history is long; its oldest lines are credited to the oldest commit read.