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!(""); - 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!(""); + 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!(""); - 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.apacheapache27"; + 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", "")]);