diff --git a/crates/socket-patch-core/src/formats/maven/mod.rs b/crates/socket-patch-core/src/formats/maven/mod.rs
index 7270fa83b..cea11a5da 100644
--- a/crates/socket-patch-core/src/formats/maven/mod.rs
+++ b/crates/socket-patch-core/src/formats/maven/mod.rs
@@ -4,6 +4,8 @@
use std::collections::BTreeMap;
+use super::xml::{blank_non_markup, child_text, elements, open_tags, Element};
+
// ── pure reader ──
/// The hosted version suffix marker (` -socket.`).
@@ -182,64 +184,6 @@ pub(crate) fn is_maven_version_text(s: &str) -> bool {
.all(|b| b.is_ascii_graphic() && !b"<>&\"'${}".contains(&b))
}
-/// One element's byte offsets in the scanned text.
-#[derive(Debug, Clone, Copy)]
-struct Element {
- /// The `<` of the open tag.
- start: usize,
- /// Just past the open tag's `>`.
- inner_start: usize,
- /// The `<` of the close tag (== `inner_start` for ` `).
- inner_end: usize,
- /// Just past the close tag's `>`.
- end: usize,
-}
-
-impl Element {
- fn inner<'t>(&self, text: &'t str) -> &'t str {
- &text[self.inner_start..self.inner_end]
- }
-}
-
-/// `text` with every `` comment and `` section
-/// blanked byte-for-byte (offsets are kept), scanned in document order so a
-/// comment opener inside CDATA is text and vice versa. A `` or
-/// `` written inside CDATA (say, in a ``) is
-/// character data to Maven — no repository is configured and the original
-/// GAV resolves from Central — so it must never read as wiring. An
-/// unterminated comment blanks through EOF, like the vendor backend; an
-/// unterminated CDATA section is an error.
-fn blank_non_markup(text: &str) -> Result {
- const CDATA_OPEN: &str = " break,
- (Some(c), d) if d.is_none_or(|d| c < d) => {
- let end = text[c + 4..]
- .find("-->")
- .map_or(text.len(), |r| c + 4 + r + 3);
- (c, end)
- }
- (_, d) => {
- let d = d.expect("a CDATA opener comes first");
- let body = d + CDATA_OPEN.len();
- let end = text[body..]
- .find("]]>")
- .map(|r| body + r + 3)
- .ok_or_else(|| "unterminated …` element blanked (offsets kept).
fn blank_elements(text: &str, name: &str) -> Result {
let mut bytes = text.as_bytes().to_vec();
@@ -249,71 +193,6 @@ fn blank_elements(text: &str, name: &str) -> Result {
Ok(String::from_utf8(bytes).expect("blanking whole ASCII-delimited spans keeps UTF-8 valid"))
}
-/// `(start, past '>', self-closing)` of every real `` open tag — the
-/// next byte must be a tag boundary, so `` is not
-/// `` and `` is not ``.
-fn open_tags(text: &str, name: &str) -> Result, String> {
- let needle = format!("<{name}");
- let mut tags = Vec::new();
- let mut from = 0;
- while let Some(rel) = text[from..].find(&needle) {
- let start = from + rel;
- let after = start + needle.len();
- from = after;
- match text[after..].chars().next() {
- Some(c) if c == '>' || c == '/' || c.is_whitespace() => {}
- _ => continue,
- }
- let Some(gt) = text[after..].find('>').map(|r| after + r) else {
- return Err(format!("unterminated <{name}> tag"));
- };
- tags.push((start, gt + 1, text[..gt].ends_with('/')));
- from = gt + 1;
- }
- Ok(tags)
-}
-
-/// Every `` element, each closed by the next ` ` (none of the
-/// elements scanned here nest in themselves).
-fn elements(text: &str, name: &str) -> Result, String> {
- let close = format!("{name}>");
- let mut out = Vec::new();
- let mut resume = 0;
- for (start, inner_start, self_closing) in open_tags(text, name)? {
- if start < resume {
- continue; // inside the previous element (malformed nesting)
- }
- if self_closing {
- out.push(Element {
- start,
- inner_start,
- inner_end: inner_start,
- end: inner_start,
- });
- continue;
- }
- let Some(inner_end) = text[inner_start..].find(&close).map(|r| inner_start + r) else {
- return Err(format!("unterminated <{name}> element"));
- };
- let end = inner_end + close.len();
- out.push(Element {
- start,
- inner_start,
- inner_end,
- end,
- });
- resume = end;
- }
- Ok(out)
-}
-
-/// Trimmed text of the FIRST `` child in `body`.
-fn child_text(body: &str, tag: &str) -> Result, String> {
- Ok(elements(body, tag)?
- .first()
- .map(|e| e.inner(body).trim().to_string()))
-}
-
#[cfg(test)]
mod tests {
#[test]
diff --git a/crates/socket-patch-core/src/formats/mod.rs b/crates/socket-patch-core/src/formats/mod.rs
index 29dc4067f..294f0f436 100644
--- a/crates/socket-patch-core/src/formats/mod.rs
+++ b/crates/socket-patch-core/src/formats/mod.rs
@@ -37,6 +37,7 @@ pub mod pnpm;
pub mod registry;
pub mod sbt;
pub mod text;
+pub(crate) mod xml;
pub mod yarn;
pub use registry::registry;
diff --git a/crates/socket-patch-core/src/formats/xml.rs b/crates/socket-patch-core/src/formats/xml.rs
new file mode 100644
index 000000000..eb743a0d9
--- /dev/null
+++ b/crates/socket-patch-core/src/formats/xml.rs
@@ -0,0 +1,233 @@
+//! A bounded, dependency-free XML element scanner, shared by every reader
+//! of a small XML file the wirings touch: the root `pom.xml`
+//! ([`super::maven`]) and Gradle's `verification-metadata.xml`. Comments and
+//! CDATA sections are blanked first, keeping byte offsets, so commented-out
+//! or character-data markup never reads as an element. Pure: text in,
+//! offsets out.
+
+/// One element's byte offsets in the scanned text.
+#[derive(Debug, Clone, Copy)]
+pub(crate) struct Element {
+ /// The `<` of the open tag.
+ pub(crate) start: usize,
+ /// Just past the open tag's `>`.
+ pub(crate) inner_start: usize,
+ /// The `<` of the close tag (== `inner_start` for ` `).
+ pub(crate) inner_end: usize,
+ /// Just past the close tag's `>`.
+ pub(crate) end: usize,
+}
+
+impl Element {
+ pub(crate) fn inner<'t>(&self, text: &'t str) -> &'t str {
+ &text[self.inner_start..self.inner_end]
+ }
+
+ /// The open tag, `` (or ` `).
+ pub(crate) fn open_tag<'t>(&self, text: &'t str) -> &'t str {
+ &text[self.start..self.inner_start]
+ }
+}
+
+/// `text` with every `` comment and `` section
+/// blanked byte-for-byte (offsets are kept), scanned in document order so a
+/// comment opener inside CDATA is text and vice versa. A `` or
+/// `` written inside CDATA (say, in a ``) is
+/// character data to Maven — no repository is configured and the original
+/// GAV resolves from Central — so it must never read as wiring. An
+/// unterminated comment blanks through EOF, like the vendor backend; an
+/// unterminated CDATA section is an error.
+pub(crate) fn blank_non_markup(text: &str) -> Result {
+ const CDATA_OPEN: &str = " break,
+ (Some(c), d) if d.is_none_or(|d| c < d) => {
+ let end = text[c + 4..]
+ .find("-->")
+ .map_or(text.len(), |r| c + 4 + r + 3);
+ (c, end)
+ }
+ (_, d) => {
+ let d = d.expect("a CDATA opener comes first");
+ let body = d + CDATA_OPEN.len();
+ let end = text[body..]
+ .find("]]>")
+ .map(|r| body + r + 3)
+ .ok_or_else(|| "unterminated ', self-closing)` of every real `` open tag — the
+/// next byte must be a tag boundary, so `` is not
+/// `` and `` is not ``.
+pub(crate) fn open_tags(text: &str, name: &str) -> Result, String> {
+ let needle = format!("<{name}");
+ let mut tags = Vec::new();
+ let mut from = 0;
+ while let Some(rel) = text[from..].find(&needle) {
+ let start = from + rel;
+ let after = start + needle.len();
+ from = after;
+ match text[after..].chars().next() {
+ Some(c) if c == '>' || c == '/' || c.is_whitespace() => {}
+ _ => continue,
+ }
+ let Some(gt) = text[after..].find('>').map(|r| after + r) else {
+ return Err(format!("unterminated <{name}> tag"));
+ };
+ tags.push((start, gt + 1, text[..gt].ends_with('/')));
+ from = gt + 1;
+ }
+ Ok(tags)
+}
+
+/// Every `` element, each closed by the next ` ` (none of the
+/// elements scanned here nest in themselves).
+pub(crate) fn elements(text: &str, name: &str) -> Result, String> {
+ let close = format!("{name}>");
+ let mut out = Vec::new();
+ let mut resume = 0;
+ for (start, inner_start, self_closing) in open_tags(text, name)? {
+ if start < resume {
+ continue; // inside the previous element (malformed nesting)
+ }
+ if self_closing {
+ out.push(Element {
+ start,
+ inner_start,
+ inner_end: inner_start,
+ end: inner_start,
+ });
+ continue;
+ }
+ let Some(inner_end) = text[inner_start..].find(&close).map(|r| inner_start + r) else {
+ return Err(format!("unterminated <{name}> element"));
+ };
+ let end = inner_end + close.len();
+ out.push(Element {
+ start,
+ inner_start,
+ inner_end,
+ end,
+ });
+ resume = end;
+ }
+ Ok(out)
+}
+
+/// Trimmed text of the FIRST `` child in `body`.
+pub(crate) fn child_text(body: &str, tag: &str) -> Result, String> {
+ Ok(elements(body, tag)?
+ .first()
+ .map(|e| e.inner(body).trim().to_string()))
+}
+
+/// Every `` element inside `parent`'s content, with offsets into
+/// `text` (the text `parent` was scanned from).
+pub(crate) fn children(text: &str, parent: &Element, name: &str) -> Result, String> {
+ let base = parent.inner_start;
+ Ok(elements(parent.inner(text), name)?
+ .into_iter()
+ .map(|e| Element {
+ start: base + e.start,
+ inner_start: base + e.inner_start,
+ inner_end: base + e.inner_end,
+ end: base + e.end,
+ })
+ .collect())
+}
+
+/// The quoted value of attribute `name` in the open tag `tag`: the first
+/// whitespace-preceded `name =` followed by a `"` or `'` quoted value.
+pub(crate) fn attr<'t>(tag: &'t str, name: &str) -> Option<&'t str> {
+ let mut rest = tag;
+ loop {
+ let at = rest.find(name)?;
+ let before = rest[..at].chars().last();
+ let after = rest[at + name.len()..].trim_start();
+ rest = &rest[at + name.len()..];
+ if !before.is_some_and(char::is_whitespace) {
+ continue;
+ }
+ let Some(after) = after.strip_prefix('=') else {
+ continue;
+ };
+ let after = after.trim_start();
+ let quote = after.chars().next()?;
+ if quote != '"' && quote != '\'' {
+ return None;
+ }
+ let body = &after[1..];
+ return body.find(quote).map(|e| &body[..e]);
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn comments_and_cdata_are_blanked_in_document_order() {
+ let text = "` replaced by spaces (same byte offsets), so
-/// commented-out elements are never matched.
-fn mask_xml_comments(text: &str) -> String {
- let mut bytes = text.as_bytes().to_vec();
- let mut from = 0;
- while let Some(j) = text[from..].find("")
- .map_or(text.len(), |k| start + k + 3);
- for b in &mut bytes[start..end] {
- if *b != b'\n' && *b != b'\r' {
- *b = b' ';
- }
- }
- from = end;
- }
- String::from_utf8(bytes).unwrap_or_default()
-}
-
-/// The value of attribute `name` in the start tag `tag`.
-fn xml_attr<'a>(tag: &'a str, name: &str) -> Option<&'a str> {
- let mut rest = tag;
- loop {
- let at = rest.find(name)?;
- let before = rest[..at].chars().last();
- let after = rest[at + name.len()..].trim_start();
- rest = &rest[at + name.len()..];
- if !before.is_some_and(char::is_whitespace) {
- continue;
- }
- let Some(after) = after.strip_prefix('=') else {
- continue;
- };
- let after = after.trim_start();
- let quote = after.chars().next()?;
- if quote != '"' && quote != '\'' {
- return None;
- }
- let body = &after[1..];
- return body.find(quote).map(|e| &body[..e]);
- }
-}
-
-/// Elements named `name` inside `masked[from..to]`: (start, end of start
-/// tag, end of element). Self-closing elements end with their start tag.
-fn xml_elements(masked: &str, from: usize, to: usize, name: &str) -> Vec<(usize, usize, usize)> {
- let open = format!("<{name}");
- let close = format!("{name}>");
- let mut out = Vec::new();
- let mut i = from;
- while let Some(j) = masked[i..to].find(&open) {
- let s = i + j;
- let next = masked.as_bytes().get(s + open.len()).copied();
- if !matches!(next, Some(b' ' | b'\t' | b'\r' | b'\n' | b'>' | b'/')) {
- i = s + open.len();
- continue;
- }
- let Some(tag_end) = masked[s..to].find('>').map(|k| s + k + 1) else {
- break;
- };
- let end = if masked[..tag_end].ends_with("/>") {
- tag_end
- } else {
- match masked[tag_end..to].find(&close) {
- Some(k) => tag_end + k + close.len(),
- None => break,
- }
- };
- out.push((s, tag_end, end));
- i = end;
- }
- out
+ !top_elements(&vm, "component").iter().any(|comp| {
+ let tag = comp.open_tag(&vm);
+ xml::attr(tag, "group") == Some(g.as_str())
+ && xml::attr(tag, "name") == Some(a.as_str())
+ && xml::attr(tag, "version") == Some(v.as_str())
+ && child_elements(&vm, comp, "artifact").iter().any(|art| {
+ xml::attr(art.open_tag(&vm), "name") == Some(pom.as_str()) && has_checksum(&vm, art)
+ })
+ })
}
fn artifact_element(name: &str, sha: &str, indent: &str, unit: &str, nl: &str) -> String {
@@ -2277,30 +2204,31 @@ fn verification_artifact_edit(
format!("{VERIFICATION_REL}: {why}"),
)
};
- let masked = mask_xml_comments(text);
+ let masked = xml::blank_non_markup(text).map_err(|why| unparseable(&why))?;
let nl = newline_of(text);
const UNIT: &str = " ";
let (a, v) = (patch.artifact_id, patch.version);
let jar_name = file_name.to_string();
- let Some(&(cs_start, cs_tag_end, cs_end)) =
- xml_elements(&masked, 0, masked.len(), "components").first()
+ let Some(components) = xml::elements(&masked, "components")
+ .map_err(|why| unparseable(&why))?
+ .into_iter()
+ .next()
else {
return Err(unparseable("no element"));
};
- let self_closing = cs_tag_end == cs_end;
- let comps = if self_closing {
- Vec::new()
- } else {
- xml_elements(&masked, cs_tag_end, cs_end, "component")
- };
+ let (cs_start, cs_end) = (components.start, components.end);
+ let self_closing = components.inner_start == cs_end;
+ let comps =
+ xml::children(&masked, &components, "component").map_err(|why| unparseable(&why))?;
let key = (patch.group_id, a, v);
- for &(s, tag_end, end) in &comps {
- let tag = &masked[s..tag_end];
+ for comp in &comps {
+ let (s, tag_end, end) = (comp.start, comp.inner_start, comp.end);
+ let tag = comp.open_tag(&masked);
let (Some(g), Some(n), Some(ver)) = (
- xml_attr(tag, "group"),
- xml_attr(tag, "name"),
- xml_attr(tag, "version"),
+ xml::attr(tag, "group"),
+ xml::attr(tag, "name"),
+ xml::attr(tag, "version"),
) else {
return Err(unparseable("a lacks group, name or version"));
};
@@ -2310,17 +2238,18 @@ fn verification_artifact_edit(
if tag_end == end {
return Err(unparseable("the patched component is empty"));
}
- let arts = xml_elements(&masked, tag_end, end, "artifact");
+ let arts = xml::children(&masked, comp, "artifact").map_err(|why| unparseable(&why))?;
let comp_indent = line_indent(text, s);
- for &(as_, at_end, ae) in &arts {
- if xml_attr(&masked[as_..at_end], "name") == Some(jar_name.as_str()) {
+ for art in &arts {
+ let (as_, ae) = (art.start, art.end);
+ if xml::attr(art.open_tag(&masked), "name") == Some(jar_name.as_str()) {
if keep_user {
// The user's entry is kept; a pgp-only one gets the
// checksum Gradle needs for the vendored repository.
- if has_checksum(&masked, at_end, ae) {
+ if has_checksum(&masked, art) {
return Ok((as_, ae, text[as_..ae].to_string()));
}
- let el = with_sha256(text, &masked, (as_, at_end, ae), &h.jar);
+ let el = with_sha256(text, &masked, art, &h.jar);
return Ok((as_, ae, el));
}
let indent = line_indent(text, as_);
@@ -2333,10 +2262,10 @@ fn verification_artifact_edit(
let el = artifact_element(&jar_name, &h.jar, &indent, UNIT, nl);
let before = arts
.iter()
- .find(|&&(as_, at_end, _)| {
- xml_attr(&masked[as_..at_end], "name").is_some_and(|n| n > jar_name.as_str())
+ .find(|art| {
+ xml::attr(art.open_tag(&masked), "name").is_some_and(|n| n > jar_name.as_str())
})
- .map(|&(as_, _, _)| as_);
+ .map(|art| art.start);
let at = before.map_or_else(
|| line_start(text, end - " ".len()),
|as_| line_start(text, as_),
@@ -2347,7 +2276,7 @@ fn verification_artifact_edit(
let cs_indent = line_indent(text, cs_start);
let comp_indent = comps.first().map_or_else(
|| format!("{cs_indent}{UNIT}"),
- |&(s, _, _)| line_indent(text, s),
+ |comp| line_indent(text, comp.start),
);
let art_indent = format!("{comp_indent}{UNIT}");
let mut arts = vec![
@@ -2375,16 +2304,16 @@ fn verification_artifact_edit(
let el = format!("{nl}{comp}{cs_indent} ");
return Ok((cs_start, cs_end, el));
}
- let before = comps.iter().find(|&&(s, tag_end, _)| {
- let tag = &masked[s..tag_end];
+ let before = comps.iter().find(|comp| {
+ let tag = comp.open_tag(&masked);
(
- xml_attr(tag, "group").unwrap_or(""),
- xml_attr(tag, "name").unwrap_or(""),
- xml_attr(tag, "version").unwrap_or(""),
+ xml::attr(tag, "group").unwrap_or(""),
+ xml::attr(tag, "name").unwrap_or(""),
+ xml::attr(tag, "version").unwrap_or(""),
) > key
});
let at = match before {
- Some(&(s, _, _)) => line_start(text, s),
+ Some(comp) => line_start(text, comp.start),
None => line_start(text, cs_end - " ".len()),
};
Ok((at, at, comp))
@@ -2413,7 +2342,7 @@ pub(crate) fn metadata_record_present(text: &str, record: &WiringRecord) -> bool
if parts.len() != 3 && parts.len() != 4 {
return false;
}
- let masked = mask_xml_comments(text);
+ let masked = masked_or_blank(text);
let verifies = verifies_metadata(&masked);
let name = format!(
"{}-{}.{}",
@@ -2421,45 +2350,35 @@ pub(crate) fn metadata_record_present(text: &str, record: &WiringRecord) -> bool
parts[2],
parts.get(3).unwrap_or(&"pom")
);
- xml_elements(&masked, 0, masked.len(), "component")
- .iter()
- .any(|&(start, tag_end, end)| {
- let tag = &masked[start..tag_end];
- if xml_attr(tag, "group") != Some(parts[0])
- || xml_attr(tag, "name") != Some(parts[1])
- || xml_attr(tag, "version") != Some(parts[2])
- {
+ top_elements(&masked, "component").iter().any(|comp| {
+ let tag = comp.open_tag(&masked);
+ if xml::attr(tag, "group") != Some(parts[0])
+ || xml::attr(tag, "name") != Some(parts[1])
+ || xml::attr(tag, "version") != Some(parts[2])
+ {
+ return false;
+ }
+ child_elements(&masked, comp, "artifact").iter().any(|art| {
+ if xml::attr(art.open_tag(&masked), "name") != Some(name.as_str()) {
return false;
}
- xml_elements(&masked, tag_end, end, "artifact")
- .iter()
- .any(|&(s, t, e)| {
- if xml_attr(&masked[s..t], "name") != Some(name.as_str()) {
- return false;
- }
- // With metadata verification on, a pgp-only entry fails
- // the build (#487): trees vendored before the fix too.
- if verifies && !has_checksum(&masked, t, e) {
- return false;
- }
- match op_str(record, "to") {
- None => true,
- Some(to) => {
- xml_elements(to, 0, to.len(), "sha256")
- .iter()
- .all(|&(hs, ht, _)| {
- xml_attr(&to[hs..ht], "value").is_some_and(|hash| {
- xml_elements(&masked, t, e, "sha256").iter().any(
- |&(cs, ct, _)| {
- xml_attr(&masked[cs..ct], "value") == Some(hash)
- },
- )
- })
- })
- }
- }
- })
+ // With metadata verification on, a pgp-only entry fails
+ // the build (#487): trees vendored before the fix too.
+ if verifies && !has_checksum(&masked, art) {
+ return false;
+ }
+ match op_str(record, "to") {
+ None => true,
+ Some(to) => top_elements(to, "sha256").iter().all(|wrote| {
+ xml::attr(wrote.open_tag(to), "value").is_some_and(|hash| {
+ child_elements(&masked, art, "sha256")
+ .iter()
+ .any(|has| xml::attr(has.open_tag(&masked), "value") == Some(hash))
+ })
+ }),
+ }
})
+ })
}
/// Add only missing parent/BOM metadata to an existing verification file.
@@ -3447,6 +3366,71 @@ mod tests {
}
}
+ /// The shared scanner (`formats::xml`) fails closed where the private
+ /// one read up to the damage: an unterminated CDATA section, start tag
+ /// or element refuses the edit instead of planning against a prefix.
+ #[test]
+ fn malformed_verification_markup_is_refused() {
+ for bad in [
+ format!("{VM_HEAD} {VM_TAIL}"),
+ format!("{VM_HEAD} \n{VM_TAIL}"),
+ format!("{VM_HEAD} ` is character data to Gradle's XML
+ /// parser, so no reader treats it as a component, an artifact or a
+ /// parent: the same rule `formats::maven` applies to `pom.xml`.
+ #[test]
+ fn cdata_markup_is_never_an_element() {
+ let hidden = |inner: &str| format!("");
+ let listed = vm_component("org.apache", "apache", "27", &[("apache-27.pom", "aa")]);
+ let adopted = adopt(
+ VERIFICATION_REL,
+ VERIFICATION_FRAGMENT_KIND,
+ "metadata:org.apache:apache:27:pom",
+ );
+ let vm = format!("{VM_HEAD}{listed}{VM_TAIL}");
+ assert!(metadata_record_present(&vm, &adopted));
+ let cdata = format!("{VM_HEAD}{}{VM_TAIL}", hidden(&listed));
+ assert!(!metadata_record_present(&cdata, &adopted));
+
+ let parent = "org.apache apache 27 ";
+ let pom = format!("{parent} ");
+ assert!(!unverified_parent_chain(&vm, &pom, None), "listed parent");
+ assert!(
+ unverified_parent_chain(&cdata, &pom, None),
+ "parent hidden in CDATA is unlisted"
+ );
+ let no_parent = format!(
+ "{} ",
+ hidden(parent)
+ );
+ assert!(
+ !unverified_parent_chain(&cdata, &no_parent, None),
+ "no real parent"
+ );
+
+ let off = format!(
+ "{}{VM_TAIL}",
+ VM_HEAD.replace(
+ "true ",
+ &format!(
+ "false {}",
+ hidden("true ")
+ )
+ )
+ );
+ assert!(!verifies_metadata(&off));
+ }
+
#[test]
fn no_verification_file_is_created() {
let files = fs(&[("settings.gradle", "")]);