Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions crates/socket-patch-cli/tests/e2e_nuget_dotnet_build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -752,6 +752,19 @@ fn nuget_hosted_dotnet_restore_then_manifestless_vex() {
let store_fx = sb.dir("store-fixture");
let registry = restore_fixture(&dn, &sb, &fixture, &store_fx);

// Regression #561/#585: templates often keep inactive sources/mappings.
// A forward rewrite must agree with NuGet and VEX about which XML is live.
let inactive = "<!-- <packageSources><add key=\"old\" value=\"https://old.invalid/v3/index.json\" /></packageSources><packageSourceMapping></packageSourceMapping> -->";
std::fs::write(
fixture.join("nuget.config"),
registry.0.replacen(
"<configuration>",
&format!("<configuration>\n {inactive}"),
1,
),
)
.unwrap();

let pristine = std::fs::read(pkg_dir(&store_fx).join(FILE_KEY)).unwrap();
let mut patched = pristine.clone();
patched.extend_from_slice(MARKER);
Expand Down Expand Up @@ -794,6 +807,10 @@ fn nuget_hosted_dotnet_restore_then_manifestless_vex() {
let doc: Value = serde_json::from_slice(&std::fs::read(&embedded).unwrap()).unwrap();
assert_attested(&doc, PURL, HOSTED_UUID, Marker::Redirected, &vulns());
let config = std::fs::read_to_string(fixture.join("nuget.config")).unwrap();
assert!(
config.contains(inactive),
"inactive XML left byte-exact: {config}"
);
assert!(
config.contains(&format!("socket-patch-{HOSTED_UUID}"))
&& config.contains(&format!("pattern=\"{ID}\"")),
Expand Down
73 changes: 71 additions & 2 deletions crates/socket-patch-core/src/formats/nuget/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
//! attribute or a mismatched close tag makes the whole file `None`.

use std::collections::BTreeSet;
use std::ops::Range;

// ── pure reader ──

Expand All @@ -28,6 +29,47 @@ pub(crate) struct NugetConfig {
pub(crate) mappings: Vec<(String, Vec<String>)>,
/// Keys `configuration/disabledPackageSources` turns off.
pub(crate) disabled: BTreeSet<String>,
/// Live XML locations for writers. Routing readers and writers share
/// the same treatment of comments, quoted attributes and element scope.
pub(crate) configuration: Option<ConfigSection>,
pub(crate) package_sources: Option<ConfigSection>,
pub(crate) source_mapping: Option<ConfigSection>,
/// Preserve the routing reader's behavior on repeated sections, but do
/// not let a writer guess which occurrence should receive an edit.
pub(crate) repeated_sections: bool,
}

#[derive(Debug)]
pub(crate) struct ConfigSection {
pub(crate) open: Range<usize>,
/// `None` for a self-closing element (unclosed XML fails parsing).
pub(crate) close_start: Option<usize>,
/// After the last direct `<clear>` child, or the opening tag otherwise.
pub(crate) insert_at: usize,
}

fn section_mut<'a>(
cfg: &'a mut NugetConfig,
parents: &[&str],
name: &str,
) -> Option<&'a mut Option<ConfigSection>> {
match (parents, name) {
([], "configuration") => Some(&mut cfg.configuration),
(["configuration"], "packageSources") => Some(&mut cfg.package_sources),
(["configuration"], "packageSourceMapping") => Some(&mut cfg.source_mapping),
_ => None,
}
}

fn record_clear(cfg: &mut NugetConfig, parents: &[&str], end: usize) {
let section = match parents {
["configuration", "packageSources"] => cfg.package_sources.as_mut(),
["configuration", "packageSourceMapping"] => cfg.source_mapping.as_mut(),
_ => None,
};
if let Some(section) = section {
section.insert_at = end;
}
}

/// One open (or self-closing) tag.
Expand Down Expand Up @@ -68,13 +110,33 @@ pub(crate) fn parse_config(text: &str) -> Option<NugetConfig> {
i = at + rest.find('>')? + 1;
} else if let Some(close) = rest.strip_prefix("</") {
let end = close.find('>')?;
if stack.pop()? != close[..end].trim() {
let name = stack.pop()?;
if name != close[..end].trim() {
return None;
}
i = at + 2 + end + 1;
if let Some(Some(section)) = section_mut(&mut cfg, &stack, name) {
section.close_start = Some(at);
}
if name == "clear" {
record_clear(&mut cfg, &stack, i);
}
} else {
let (tag, consumed) = parse_open_tag(&rest[1..])?;
i = at + 1 + consumed;
if let Some(slot) = section_mut(&mut cfg, &stack, tag.name) {
let repeated = slot
.replace(ConfigSection {
open: at..i,
close_start: None,
insert_at: i,
})
.is_some();
cfg.repeated_sections |= repeated;
}
if tag.name == "clear" && tag.self_closing {
record_clear(&mut cfg, &stack, i);
}
visit(&stack, &tag, &mut cfg, &mut open_mapping);
if !tag.self_closing {
if stack.len() >= MAX_XML_DEPTH {
Expand Down Expand Up @@ -170,7 +232,14 @@ fn parse_open_tag(s: &str) -> Option<(Tag<'_>, usize)> {
if raw.contains('<') {
return None;
}
attrs.push((attr, decode_entities(raw)));
// XML first normalizes literal CRLF to one line break, then literal
// attribute whitespace to spaces. Character references preserve their
// referenced whitespace, so normalize before decoding entities.
let normalized = raw
.bytes()
.any(|b| matches!(b, b'\t' | b'\r' | b'\n'))
.then(|| raw.replace("\r\n", "\n").replace(['\t', '\r', '\n'], " "));
attrs.push((attr, decode_entities(normalized.as_deref().unwrap_or(raw))));
j += 1 + close + 1;
}
}
Expand Down
Loading
Loading