| 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)] |
| 8 | pub 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 | |
| 17 | impl 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 | |
| 37 | fn local(name: &str) -> String { |
| 38 | name.rsplit(':').next().unwrap_or(name).to_owned() |
| 39 | } |
| 40 | |
| 41 | /// Text with its entities read: `&` is `&`, `A` is `A`. |
| 42 | fn 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. |
| 83 | pub fn escape(text: &str) -> String { |
| 84 | text.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """) |
| 85 | } |
| 86 | |
| 87 | fn 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. |
| 103 | pub 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)] |
| 190 | mod 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 & 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'>AB</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<b & "c""); |
| 234 | } |
| 235 | } |