From 26d9d3b777f4e96fe7f38b19f6931d36c3310462 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 4 Nov 2026 11:38:52 -0600 Subject: [PATCH 1/3] Read Maven checksums.sha256 one way and never drop user lines The hosted Maven merge re-parsed .mvn/checksums/checksums.sha256 on a two-space separator, rebuilt the file from that map and silently dropped every line it could not split that way: a user's single-space or `*` (binary marker) sha256sum line, a tab-separated line, a comment, any stray text. It also normalized a CRLF file to LF and re-sorted it. The file and the maven.config switch were read four different ways (discovery, the hosted merge, its drop filter, the unenforced-pin warning) plus a fifth in the upstream unwind. They now share one pure reader in formats::maven::trusted_checksums: - parse_line / entries: tolerant read of what sha256sum-style tools emit (any run of spaces/tabs, an optional `*`, CRLF, surrounding blanks; a 64-digit hex hash). Each entry records whether it is in the exact ` ` shape maven-resolver's own reader honours. - pinned: path -> hash of the lines Maven enforces, a later line winning (as in SummaryFileTrustedChecksumsSource). - edit: rewrites owned paths in place, drops named paths, inserts new entries in sorted position (else appends) with the file's own line ending, and keeps every other line byte for byte. - switch_on: the trusted-checksums switch rule, decided once: read maven.config with the existing shared maven_config_properties reader (one argument per line, `#` comments, -Dk / --define, later wins) and judge the value as the resolver does (Boolean.parseBoolean). Discovery, the hosted merge/drop/warning and the unwind all use it. Discovery now attests integrity only from lines Maven itself enforces; a tolerated spelling is recognised (and replaced or dropped when it names a path hosted owns) but pins nothing. Co-Authored-By: Claude Opus 5.5 --- .../src/formats/maven/mod.rs | 5 +- .../src/formats/maven/trusted_checksums.rs | 307 ++++++++++++++++++ .../src/patch/redirect/mod.rs | 124 +++---- .../src/patch/redirect/upstream/maven.rs | 75 +++-- .../src/vex/discover/maven.rs | 78 +++-- 5 files changed, 480 insertions(+), 109 deletions(-) create mode 100644 crates/socket-patch-core/src/formats/maven/trusted_checksums.rs diff --git a/crates/socket-patch-core/src/formats/maven/mod.rs b/crates/socket-patch-core/src/formats/maven/mod.rs index 66a5db09d..d95ebccdd 100644 --- a/crates/socket-patch-core/src/formats/maven/mod.rs +++ b/crates/socket-patch-core/src/formats/maven/mod.rs @@ -1,11 +1,14 @@ //! The root `pom.xml` reader (repositories and dependencies, see //! [`parse_pom`]), the Maven coordinate grammars and the hosted -//! `-socket.` version split. Pure: text in, structure out. +//! `-socket.` version split, and the Trusted Checksums files +//! ([`trusted_checksums`]). Pure: text in, structure out. use std::collections::BTreeMap; use super::xml::{blank_non_markup, child_text, elements, open_tags, Element}; +pub(crate) mod trusted_checksums; + // ── pure reader ── /// The hosted version suffix marker (`-socket.`). diff --git a/crates/socket-patch-core/src/formats/maven/trusted_checksums.rs b/crates/socket-patch-core/src/formats/maven/trusted_checksums.rs new file mode 100644 index 000000000..51bad2354 --- /dev/null +++ b/crates/socket-patch-core/src/formats/maven/trusted_checksums.rs @@ -0,0 +1,307 @@ +//! Maven Trusted Checksums: the `.mvn/checksums/checksums.sha256` summary +//! file and the `.mvn/maven.config` switch that makes it a pin. Pure: text +//! in, text out. Discovery, the hosted rewriter and its unwind all read +//! both files through here, so what one writes the others read alike. +//! +//! The summary file is written in Maven's own grammar, ` ` +//! (two spaces; maven-resolver's `SummaryFileTrustedChecksumsSource` splits +//! on exactly that, skips `#` and empty lines, and lets a later line for a +//! path win). It is READ tolerantly, as `sha256sum`-style tools emit it: +//! any run of spaces/tabs, an optional `*` binary marker, CRLF. A line that +//! is not an entry (a comment, a BSD-style line, anything else) is kept +//! byte-for-byte by [`edit`], never dropped. + +use std::collections::{BTreeMap, BTreeSet}; + +use super::maven_config_properties; +use crate::utils::digest::sha256_hex; + +/// The resolver property that switches the Trusted Checksums +/// post-processor on. +pub(crate) const SWITCH_KEY: &str = "aether.artifactResolver.postProcessor.trustedChecksums"; + +/// Whether `.mvn/maven.config` switches Trusted Checksums on, i.e. whether +/// the summary file is a pin at all. +/// +/// The one rule discovery, the hosted rewriter and its unwind share: +/// `maven.config` is read the way [`maven_config_properties`] reads it +/// (Maven 3.9+: one argument per line, `#` comments, `-Dk=v` / +/// `--define`, `-Dk` alone meaning `true`, a later definition winning), +/// and the switch's value is then judged as the resolver judges it +/// (`ConfigUtils.getBoolean` → Java `Boolean.parseBoolean`: `true` in any +/// case, nothing else). Only Maven 3.9.4+ enforces the pin at all, and that +/// line reads `maven.config` per line, so a token the whitespace split of +/// older Maven finds counts only where no line sets the key. +pub(crate) fn switch_on(config: &str) -> bool { + maven_config_properties(config) + .get(SWITCH_KEY) + .is_some_and(|v| v.eq_ignore_ascii_case("true")) +} + +/// One entry line of the summary file. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Entry<'a> { + /// The hex sha256 as written (either case). + pub(crate) hash: &'a str, + /// The local-repository-relative path, without a `*` binary marker. + pub(crate) path: &'a str, + /// The line is exactly ` `, the only shape Maven itself + /// honours: any other spelling Maven skips, or reads as another path. + pub(crate) maven_reads: bool, +} + +/// `line` (its `\n` excluded; a trailing `\r` is tolerated) as an entry: +/// a 64-digit hex hash, a run of spaces/tabs, an optional `*`, a non-empty +/// path. Surrounding blanks are ignored. `None` for anything else. +pub(crate) fn parse_line(line: &str) -> Option> { + let line = line.strip_suffix('\r').unwrap_or(line); + let blank = |c: char| c == ' ' || c == '\t'; + let trimmed = line.trim_matches(blank); + let (hash, rest) = trimmed.split_at(trimmed.find(blank)?); + let rest = rest.trim_start_matches(blank); + let path = rest.strip_prefix('*').unwrap_or(rest); + if path.is_empty() || sha256_hex(hash).is_none() { + return None; + } + let maven_reads = line.strip_prefix(hash).and_then(|r| r.strip_prefix(" ")) == Some(path); + Some(Entry { + hash, + path, + maven_reads, + }) +} + +/// Every entry of `text`, in file order (tolerant: see [`parse_line`]). +pub(crate) fn entries(text: &str) -> impl Iterator> { + text.split('\n').filter_map(parse_line) +} + +/// `path -> lowercase sha256` of the entries Maven enforces: only lines it +/// reads ([`Entry::maven_reads`]), a later line for a path winning, as in +/// the resolver. A tolerated spelling is recognised (and replaced, or +/// dropped, by [`edit`]) but pins nothing, so nothing attests it. +pub(crate) fn pinned(text: &str) -> BTreeMap { + entries(text) + .filter(|e| e.maven_reads) + .filter_map(|e| Some((e.path.to_string(), sha256_hex(e.hash)?))) + .collect() +} + +/// `text` with every entry for a path in `set` rewritten in place to +/// ` ` (its own line ending kept), every other entry whose +/// path `drop` names removed, and the `set` paths it lacks inserted in +/// ` ` form: each before the first later entry whose path +/// sorts after it (so a sorted file stays sorted), else appended, with the +/// file's line ending (`\n` for a new file). Every other line (a comment, +/// a blank line, an unparseable line, another path's entry) is kept +/// byte-for-byte. +pub(crate) fn edit(text: &str, drop: impl Fn(&str) -> bool, set: &[(String, String)]) -> String { + let set: BTreeMap<&str, &str> = set.iter().map(|(p, h)| (p.as_str(), h.as_str())).collect(); + let present: BTreeSet<&str> = entries(text) + .map(|e| e.path) + .filter(|p| set.contains_key(p)) + .collect(); + let mut pending: BTreeMap<&str, &str> = set + .iter() + .filter(|(p, _)| !present.contains(*p)) + .map(|(p, h)| (*p, *h)) + .collect(); + let eol = match text.find('\n') { + Some(at) if text[..at].ends_with('\r') => "\r\n", + _ => "\n", + }; + let mut out = String::with_capacity(text.len() + 160 * pending.len()); + let mut flush_before = |out: &mut String, path: Option<&str>| { + while let Some((p, h)) = pending.first_key_value() { + if path.is_some_and(|path| path <= *p) { + break; + } + if !out.is_empty() && !out.ends_with('\n') { + out.push_str(eol); + } + out.push_str(&format!("{h} {p}{eol}")); + pending.pop_first(); + } + }; + for line in text.split_inclusive('\n') { + let body = line.strip_suffix('\n').unwrap_or(line); + let Some(entry) = parse_line(body) else { + out.push_str(line); + continue; + }; + if let Some(hash) = set.get(entry.path) { + flush_before(&mut out, Some(entry.path)); + let ending = &line[body.trim_end_matches('\r').len()..]; + out.push_str(&format!("{hash} {}{ending}", entry.path)); + } else if !drop(entry.path) { + flush_before(&mut out, Some(entry.path)); + out.push_str(line); + } + } + flush_before(&mut out, None); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + const A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const B: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + + fn set(entries: &[(&str, &str)]) -> Vec<(String, String)> { + entries + .iter() + .map(|(p, h)| (p.to_string(), h.to_string())) + .collect() + } + + #[test] + fn parse_line_accepts_sha256sum_spellings() { + let upper = A.to_uppercase(); + for (line, path, maven_reads) in [ + (format!("{A} a/b.jar"), "a/b.jar", true), + (format!("{A} a/b.jar\r"), "a/b.jar", true), + (format!("{A} a/b.jar"), "a/b.jar", false), + (format!("{A} *a/b.jar"), "a/b.jar", false), + (format!("{A} *a/b.jar"), "a/b.jar", false), + (format!("{A}\ta/b.jar"), "a/b.jar", false), + (format!("{A} a/b.jar"), "a/b.jar", false), + (format!(" {A} a/b.jar "), "a/b.jar", false), + (format!("{upper} a/b.jar"), "a/b.jar", true), + ] { + let entry = parse_line(&line).unwrap_or_else(|| panic!("{line:?}")); + assert_eq!(entry.path, path, "{line:?}"); + assert_eq!(entry.maven_reads, maven_reads, "{line:?}"); + } + for line in [ + String::new(), + "# comment".into(), + format!("#{A} a/b.jar"), + A.to_string(), + format!("{A} "), + format!("{A} *"), + format!("{} a/b.jar", &A[1..]), + format!("SHA256 (a/b.jar) = {A}"), + ] { + assert_eq!(parse_line(&line), None, "{line:?}"); + } + } + + #[test] + fn pinned_keeps_only_what_maven_reads_last_line_winning() { + let upper = B.to_uppercase(); + let text = + format!("# header\r\n{A} x.jar\r\n{A} y.jar\n{A} *z.jar\n{B} x.jar\n{upper} w.jar"); + assert_eq!( + pinned(&text), + BTreeMap::from([ + ("w.jar".to_string(), B.to_string()), + ("x.jar".to_string(), B.to_string()), + ]) + ); + assert_eq!( + entries(&text).map(|e| e.path).collect::>(), + ["x.jar", "y.jar", "z.jar", "x.jar", "w.jar"] + ); + } + + #[test] + fn edit_round_trips_untouched_text_byte_for_byte() { + for text in [ + String::new(), + "\n\n".into(), + format!("# keep\r\n{A} *one.jar\r\n\r\nnot an entry\r\n{B}\ttwo.jar"), + format!("{B} z.jar\n{A} a.jar\n"), + ] { + assert_eq!(edit(&text, |_| false, &[]), text); + assert_eq!(edit(&text, |p| p == "absent.jar", &[]), text); + } + } + + #[test] + fn edit_keeps_every_line_it_does_not_own() { + // A user's single-space, `*` and tab lines, a comment and a stray + // line, around one of ours being replaced and one being added. + let text = format!( + "# pins\n{A} c/user.jar\n{A} *d/user.jar\n{A}\te/user.jar\ngarbage\n{A} m/ours.jar\n" + ); + let out = edit( + &text, + |p| p == "never.jar", + &set(&[("m/ours.jar", B), ("b/new.jar", B)]), + ); + assert_eq!( + out, + format!( + "# pins\n{B} b/new.jar\n{A} c/user.jar\n{A} *d/user.jar\n{A}\te/user.jar\n\ + garbage\n{B} m/ours.jar\n" + ) + ); + // Idempotent: a second pass changes nothing. + assert_eq!( + edit( + &out, + |_| false, + &set(&[("m/ours.jar", B), ("b/new.jar", B)]) + ), + out + ); + } + + #[test] + fn edit_replaces_a_tolerated_spelling_of_an_owned_path_in_place() { + let text = format!("{A} *m/ours.jar\r\nkeep\r\n"); + assert_eq!( + edit(&text, |_| false, &set(&[("m/ours.jar", B)])), + format!("{B} m/ours.jar\r\nkeep\r\n") + ); + } + + #[test] + fn edit_drops_only_named_entries_and_keeps_line_endings() { + let text = format!("{A} gone.jar\r\n# note\r\n{A} kept.jar\r\n{B} *gone.jar\r\n"); + assert_eq!( + edit(&text, |p| p == "gone.jar", &[]), + format!("# note\r\n{A} kept.jar\r\n") + ); + } + + #[test] + fn edit_appends_with_the_file_line_ending_after_an_unterminated_line() { + assert_eq!( + edit( + &format!("{A} a.jar\r\nlast"), + |_| false, + &set(&[("z.jar", B)]) + ), + format!("{A} a.jar\r\nlast\r\n{B} z.jar\r\n") + ); + assert_eq!( + edit("", |_| false, &set(&[("z.jar", B), ("a.jar", A)])), + format!("{A} a.jar\n{B} z.jar\n") + ); + } + + #[test] + fn switch_follows_maven_config_reading() { + let on = format!("-D{SWITCH_KEY}=true"); + for (config, want) in [ + (on.clone(), true), + (format!("{on}\r\n"), true), + (format!(" {on} "), true), + (format!("-D{SWITCH_KEY}"), true), + (format!("-D{SWITCH_KEY}=TRUE"), true), + (format!("--define={SWITCH_KEY}=true"), true), + (format!("-B -D{SWITCH_KEY}=true"), true), + (format!("{on}\n-D{SWITCH_KEY}=false"), false), + (format!("-D{SWITCH_KEY}=false\n{on}"), true), + (format!("# {on}"), false), + (format!("-D{SWITCH_KEY}=yes"), false), + (format!("-D{SWITCH_KEY}.failIfMissing=true"), false), + (String::new(), false), + ] { + assert_eq!(switch_on(&config), want, "{config:?}"); + } + } +} diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 0c778ac74..235c9ffea 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -69,6 +69,7 @@ use crate::formats::gem::hosted::{ refresh_merged_gem_lock_remote, }; use crate::formats::gem::{lock_lists_direct_dependency, locked_specs as gem_locked_specs}; +use crate::formats::maven::trusted_checksums; #[cfg(test)] use crate::formats::pnpm::hosted::pnpm_unrewritten_instances; use crate::formats::pnpm::plan_hosted; @@ -8491,9 +8492,9 @@ const MVN_CONFIG_ARGS: &[&str] = &[ ]; /// The resolver switch (the first [`MVN_CONFIG_ARGS`] line) that makes the -/// checksums file an enforced pin; without it the file is inert. -pub(crate) const TRUSTED_CHECKSUMS_ON: &str = - "-Daether.artifactResolver.postProcessor.trustedChecksums=true"; +/// checksums file an enforced pin; without it the file is inert. Read back +/// by [`trusted_checksums::switch_on`], as every mode reads it. +const TRUSTED_CHECKSUMS_ON: &str = "-Daether.artifactResolver.postProcessor.trustedChecksums=true"; pub(crate) const MVN_CONFIG: &str = ".mvn/maven.config"; pub(crate) const MVN_CHECKSUMS: &str = ".mvn/checksums/checksums.sha256"; @@ -9250,32 +9251,21 @@ fn rewrite_maven_pom( if mvn_config_changed { result.files.insert(MVN_CONFIG.into(), mvn_config); } - // LF-normalized, so the drop check and the filter below split lines the - // same way (a CRLF file's paths would otherwise keep a trailing `\r`). - let existing_checksums = files - .get(MVN_CHECKSUMS) - .map(|text| text.replace("\r\n", "\n")) - .unwrap_or_default(); - let names_dropped_path = |line: &str| { - line.split_once(" ") - .is_some_and(|(_, p)| checksum_drops.iter().any(|d| d == p)) - }; - let drops_checksums = existing_checksums.split('\n').any(names_dropped_path); - if !checksum_entries.is_empty() || drops_checksums { - let action = if files.contains_key(MVN_CHECKSUMS) { + // Every line this run does not own (another path's entry in any + // spelling, a comment, an unparseable line) keeps its bytes and place. + let existing_checksums = files.get(MVN_CHECKSUMS).map(String::as_str); + let merged_checksums = trusted_checksums::edit( + existing_checksums.unwrap_or_default(), + |path| checksum_drops.iter().any(|d| d == path), + &checksum_entries, + ); + if merged_checksums != existing_checksums.unwrap_or_default() { + let action = if existing_checksums.is_some() { "rewritten" } else { "added" }; - let kept: String = existing_checksums - .split('\n') - .filter(|l| !names_dropped_path(l)) - .collect::>() - .join("\n"); - result.files.insert( - MVN_CHECKSUMS.into(), - merge_checksums(&kept, &checksum_entries), - ); + result.files.insert(MVN_CHECKSUMS.into(), merged_checksums); result.edits.push(FileEdit { path: MVN_CHECKSUMS.into(), kind: "redirect_maven_trusted_checksums".into(), @@ -9292,13 +9282,10 @@ fn rewrite_maven_pom( // content pinning. Judged on the files as they stand after this run, so a // re-scan of an already-pinned project warns as well. let final_text = |rel: &str| result.files.get(rel).or_else(|| files.get(rel)); - let pin_in_place = final_text(MVN_CONFIG) - .is_some_and(|c| c.lines().any(|l| l.trim() == TRUSTED_CHECKSUMS_ON)) + let pin_in_place = final_text(MVN_CONFIG).is_some_and(|c| trusted_checksums::switch_on(c)) && final_text(MVN_CHECKSUMS).is_some_and(|c| { - pinned_jar_paths.iter().any(|p| { - c.lines() - .any(|l| l.split_whitespace().nth(1) == Some(p.as_str())) - }) + let pinned = trusted_checksums::pinned(c); + pinned_jar_paths.iter().any(|p| pinned.contains_key(p)) }); if let Some(version) = files .get(MVN_WRAPPER_PROPERTIES) @@ -9411,35 +9398,6 @@ fn merge_mvn_config(existing: &str, coordinate: &str) -> (String, Vec) { (format!("{base}{}\n", appended.join("\n")), conflicts) } -/// Merge trusted-checksum entries into `.mvn/checksums/checksums.sha256` (GNU -/// coreutils format: ``). -/// Parse existing entries, replace/add by path, re-sort by path, trailing -/// newline. A malformed line (no double-space separator) is dropped. Twin of -/// the TS `mergeChecksums`. -fn merge_checksums(existing: &str, entries: &[(String, String)]) -> String { - let mut by_path: BTreeMap = BTreeMap::new(); - if !existing.is_empty() { - for line in existing.split('\n') { - if line.trim().is_empty() { - continue; - } - if let Some(sep) = line.find(" ") { - by_path.insert(line[sep + 2..].to_string(), line[..sep].to_string()); - } - } - } - for (path, sha256) in entries { - by_path.insert(path.clone(), sha256.clone()); - } - // BTreeMap iterates keys in sorted (byte) order — matching JS's default - // sort on the ASCII paths. - let body: Vec = by_path - .iter() - .map(|(path, sha)| format!("{sha} {path}")) - .collect(); - format!("{}\n", body.join("\n")) -} - // ── golang (go.mod fork-replace + go.sum pin) ──────────────────────────────── /// go.mod and go.sum are whitespace-delimited line formats, and the golang /// rewriter interpolates server-controlled strings into both — any embedded @@ -30328,6 +30286,52 @@ mod owned_pin_generation_matrix { let (repinned, _) = plan(&wired, &maven(B, tok)); let checksums = &repinned[MVN_CHECKSUMS]; assert!(!checksums.contains("aaaaaaaa"), "{checksums}"); + // The file stays CRLF: nothing is normalized to LF. + assert!( + checksums + .split_inclusive('\n') + .all(|line| line.ends_with("\r\n")), + "{checksums:?}" + ); + let (_, again) = plan(&repinned, &maven(B, tok)); + assert!(again.files.is_empty(), "{:?}", again.files.keys()); + } + + /// A user's checksums lines survive the pin and the re-pin byte for + /// byte, in any spelling `sha256sum`-style tools write (one space, a + /// `*` binary marker, a tab) and as comments or stray text. The hosted + /// merge used to re-parse the file on two spaces and silently drop + /// every line it could not split that way. + #[test] + fn maven_checksums_merge_keeps_every_user_line() { + let tok = "22222222-3333-4444-8555-666666666666"; + let h = "0123456789abcdef".repeat(4); + let user = [ + "# team pins".to_string(), + format!("{h} com/acme/single/1.0/single-1.0.jar"), + format!("{h} *com/acme/binary/1.0/binary-1.0.jar"), + format!("{h}\tcom/acme/tab/1.0/tab-1.0.jar"), + format!("{h} com/acme/canonical/1.0/canonical-1.0.jar"), + "not a checksum line".to_string(), + ]; + let mut files = pom_project(); + files.insert(MVN_CHECKSUMS.into(), format!("{}\n", user.join("\n"))); + let (wired, result) = plan(&files, &maven(A, tok)); + assert!(result.warnings.is_empty(), "{:?}", result.warnings); + let (repinned, _) = plan(&wired, &maven(B, tok)); + for (stage, tree) in [("pin", &wired), ("re-pin", &repinned)] { + let checksums = &tree[MVN_CHECKSUMS]; + let lines: Vec<&str> = checksums.lines().collect(); + for line in &user { + assert!( + lines.contains(&line.as_str()), + "{stage} lost {line:?}:\n{checksums}" + ); + } + assert_eq!(lines.len(), user.len() + 2, "{stage}:\n{checksums}"); + } + assert!(repinned[MVN_CHECKSUMS].contains("3.12.0-socket.bbbbbbbb")); + assert!(!repinned[MVN_CHECKSUMS].contains("socket.aaaaaaaa")); let (_, again) = plan(&repinned, &maven(B, tok)); assert!(again.files.is_empty(), "{:?}", again.files.keys()); } diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/maven.rs b/crates/socket-patch-core/src/patch/redirect/upstream/maven.rs index 453046e91..5fa9f7797 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/maven.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/maven.rs @@ -18,14 +18,15 @@ //! was the user's). The `.mvn` files are removed only when they hold //! nothing but what hosted mode writes; otherwise the resolver lines stay //! and a warning says so (whether they pre-existed is not recorded). -//! Checksum lines keep the file's remaining order (the rewriter re-sorted -//! and dropped malformed lines; that is not recoverable). +//! Every other checksums line keeps its bytes and place (the rewriter +//! leaves them so too; see `trusted_checksums::edit`). use std::collections::BTreeMap; use regex::Regex; use super::{by_uuid, read_or_refuse, refuse_all_in, Ctx, FormatResult, HostedPin, View}; +use crate::formats::maven::{maven_config_properties, trusted_checksums}; use crate::patch::redirect::{ generation, maven_repositories_with_id, maven_tag_inner_range, maven_tag_text_in, remove_maven_repository, MAVEN_DEPENDENCY_BLOCK_RE, MVN_CHECKSUMS, MVN_CONFIG, MVN_CONFIG_ARGS, @@ -409,26 +410,25 @@ async fn restore_mvn( let Ok(Some(sums)) = view.read(&sums_rel).await else { return; }; - let path_of = |line: &str| { - line.trim_end_matches('\r') - .split_once(" ") - .map(|(_, p)| p.to_string()) - }; - let kept: Vec<&str> = sums - .split('\n') - .filter(|line| { - !path_of(line).is_some_and(|p| checksum_dirs.iter().any(|d| p.starts_with(d.as_str()))) - }) - .collect(); - let rest = kept.join("\n"); + let rest = trusted_checksums::edit( + &sums, + |p| checksum_dirs.iter().any(|d| p.starts_with(d.as_str())), + &[], + ); if rest == sums { return; } let config = view.read(&config_rel).await.ok().flatten(); let written_config = format!("{}\n", MVN_CONFIG_ARGS.join("\n")); + // Read as every mode reads `maven.config` (the switch rule's reader, + // see `trusted_checksums::switch_on`): any resolver setting hosted mode + // writes counts, whatever its value or spelling. let has_resolver_lines = config.as_deref().is_some_and(|c| { - c.lines() - .any(|l| MVN_CONFIG_ARGS.contains(&l.trim_end_matches('\r'))) + let props = maven_config_properties(c); + MVN_CONFIG_ARGS + .iter() + .filter_map(|arg| arg.strip_prefix("-D")?.split_once('=')) + .any(|(key, _)| props.contains_key(key)) }); if rest.trim().is_empty() { view.remove(&sums_rel); @@ -439,11 +439,10 @@ async fn restore_mvn( } else { view.write(&sums_rel, rest.clone()); } - let other_hosted = rest.lines().any(|l| { - path_of(l).is_some_and(|p| { - p.split('/') - .any(|seg| crate::formats::maven::split_socket_version(seg).is_some()) - }) + let other_hosted = trusted_checksums::entries(&rest).any(|e| { + e.path + .split('/') + .any(|seg| crate::formats::maven::split_socket_version(seg).is_some()) }); if has_resolver_lines && !other_hosted { result.warnings.push(( @@ -863,4 +862,38 @@ mod tests { assert_eq!(after[".mvn/checksums/checksums.sha256"], other); assert!(after.contains_key(".mvn/maven.config")); } + + /// The unwind reads the checksums file as the rewriter does: the pin's + /// lines go in any spelling a tool rewrote them to, every other line + /// (one space, a `*` marker, a comment) keeps its bytes and CRLF, and + /// a resolver switch in another spelling still counts as left behind. + #[tokio::test] + async fn checksums_unwind_reads_every_spelling_and_keeps_crlf() { + let other = format!( + "# team pins\r\n{} zz/other/1.0/other-1.0.jar\r\n{} *aa/first/1.0/first-1.0.jar\r\n", + "e".repeat(64), + "f".repeat(64) + ); + let ours = fixture("basic", "expected", ".mvn/checksums/checksums.sha256") + .replace(" ", " *") + .replace('\n', "\r\n"); + let config = "-Daether.artifactResolver.postProcessor.trustedChecksums\r\n"; + let files = vec![ + ("pom.xml", fixture("basic", "expected", "pom.xml")), + (".mvn/maven.config", config.to_string()), + (".mvn/checksums/checksums.sha256", format!("{other}{ours}")), + ]; + let (outcome, after) = run(&files).await; + assert_eq!(outcome.pins[0].status, PinStatus::Restored); + assert_eq!(after[".mvn/checksums/checksums.sha256"], other); + assert_eq!(after[".mvn/maven.config"], config); + assert!( + outcome + .warnings + .iter() + .any(|(code, _)| *code == "maven_trusted_checksums_left"), + "{:?}", + outcome.warnings + ); + } } diff --git a/crates/socket-patch-core/src/vex/discover/maven.rs b/crates/socket-patch-core/src/vex/discover/maven.rs index cc4c69fe8..52ce91c1f 100644 --- a/crates/socket-patch-core/src/vex/discover/maven.rs +++ b/crates/socket-patch-core/src/vex/discover/maven.rs @@ -105,10 +105,9 @@ use super::{ }; use crate::formats::maven::{ is_maven_coordinate, is_maven_version_text, maven_config_properties, may_shadow, - parse_pom_with, split_socket_version, Pom, PomDep, PomRepo, + parse_pom_with, split_socket_version, trusted_checksums, Pom, PomDep, PomRepo, }; -use crate::patch::redirect::{MVN_CHECKSUMS, MVN_CONFIG, TRUSTED_CHECKSUMS_ON}; -use crate::utils::digest::sha256_hex; +use crate::patch::redirect::{MVN_CHECKSUMS, MVN_CONFIG}; use crate::vendor::jvm::layout; use crate::vendor::lock_inventory::LockIntegrity; use crate::vendor::maven_repo::{sha1_sidecar_matches, VENDOR_REPO_URL_PREFIX}; @@ -378,7 +377,7 @@ async fn extract_hosted( continue; }; if checksums.is_none() { - checksums = Some(trusted_checksums(ctx, out).await); + checksums = Some(pinned_checksums(ctx, out).await); } let jar = layout::artifact_path(group, artifact, pinned, None, "jar"); let integrity = checksums @@ -398,32 +397,20 @@ async fn extract_hosted( /// `path -> sha256 hex` from `.mvn/checksums/checksums.sha256`, or empty /// when `.mvn/maven.config` does not switch the trusted-checksums resolver -/// on (the file is then inert and pins nothing). Malformed lines are -/// skipped, like the rewriter's own merge. -async fn trusted_checksums(ctx: &DiscoverCtx<'_>, out: &mut Discovery) -> BTreeMap { - let mut map = BTreeMap::new(); +/// on (the file is then inert and pins nothing). Both files are read as the +/// hosted rewriter reads them ([`trusted_checksums`]): only a line Maven +/// itself enforces attests. +async fn pinned_checksums(ctx: &DiscoverCtx<'_>, out: &mut Discovery) -> BTreeMap { let Some(config) = ctx.read_text(MVN_CONFIG, out).await else { - return map; + return BTreeMap::new(); }; - if !config - .split_whitespace() - .any(|arg| arg == TRUSTED_CHECKSUMS_ON) - { - return map; - } - let Some(text) = ctx.read_text(MVN_CHECKSUMS, out).await else { - return map; - }; - for line in text.lines() { - let Some((hash, path)) = line.trim().split_once(char::is_whitespace) else { - continue; - }; - let path = path.trim_start().trim_start_matches('*').trim_end(); - if let Some(hash) = sha256_hex(hash).filter(|_| !path.is_empty()) { - map.insert(path.to_string(), hash); - } + if !trusted_checksums::switch_on(&config) { + return BTreeMap::new(); } - map + ctx.read_text(MVN_CHECKSUMS, out) + .await + .map(|text| trusted_checksums::pinned(&text)) + .unwrap_or_default() } // ── vendored ───────────────────────────────────────────────────────────── @@ -904,6 +891,43 @@ mod tests { ); } + /// The switch and the summary file are read as the hosted rewriter + /// reads them: a bare or CRLF `-D` switch is on, and only a jar line in + /// the ` ` shape Maven enforces attests. A tolerated + /// spelling (one space, a `*` marker) is a line Maven skips, so it + /// pins nothing. + #[tokio::test] + async fn checksums_attest_only_lines_maven_enforces() { + let jar = "org/slf4j/slf4j-api/1.7.36-socket.77777777/slf4j-api-1.7.36-socket.77777777.jar"; + let switch = format!("-D{}", crate::formats::maven::trusted_checksums::SWITCH_KEY); + for (config, sums, attests) in [ + ( + format!("{switch}\r\n"), + format!("{FX_JAR_SHA} {jar}\r\n"), + true, + ), + (switch.clone(), format!("# pins\n{FX_JAR_SHA} {jar}"), true), + ( + format!("{switch}=false"), + format!("{FX_JAR_SHA} {jar}\n"), + false, + ), + (switch.clone(), format!("{FX_JAR_SHA} {jar}\n"), false), + (switch.clone(), format!("{FX_JAR_SHA} *{jar}\n"), false), + ] { + let p = fixture("basic"); + p.write(".mvn/maven.config", config.clone()); + p.write(".mvn/checksums/checksums.sha256", sums.clone()); + let out = run(&p).await; + assert_refs(&out, &[(FX_PURL, FX_UUID, WiringMode::Hosted)]); + assert_eq!( + out.refs[0].locked_integrity, + attests.then(|| LockIntegrity::Sha256Hex(FX_JAR_SHA.into())), + "{config:?} / {sums:?}" + ); + } + } + #[tokio::test] async fn hosted_pin_resolved_through_a_property_and_crlf() { let p = Project::new(); From ec69ff3261dbb6289040b7007337977af8cbd05f Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Thu, 5 Nov 2026 15:54:50 -0600 Subject: [PATCH 2/3] Judge Maven Trusted Checksums enforcement one way in every mode Review fixes on the shared trusted_checksums reader: - The enforcement rule (config_enforces) now needs every setting Maven reads .mvn/checksums/checksums.sha256 by, with the resolver's defaults: the post-processor on, checksumAlgorithms naming SHA-256, the summary-file source on, its basedir at .mvn/checksums and originAware off. Settings are whole-line arguments only (Maven 3.9+, the only line that enforces), never a -Dk=v token inside a longer line; the per-line reading is split out of maven_config_properties as maven_config_line_properties. - enforced() adds the wrapper rule the hosted rewriter already warned on (an Apache Maven < 3.9.4 ignores the files). Discovery attests only through it, so VEX never claims integrity Maven does not check: the mvn-config-merge fixture keeps the user's originAware=true and its golden loses its integrity. - An uppercase-hex line no longer counts as read by Maven (it compares to its own lowercase digest). - Lines for a path hosted mode owns are recognised whatever their hash, so a stale SRI or truncated line is replaced, dropped on re-pin and dropped by the unwind; a grant hash that is not 64 hex digits writes no checksum line (redirect_maven_trusted_checksums_invalid_hash). - edit collapses an owned path to one line, inserts ahead of the comment block directly above the line it lands before, and inserts with the file's dominant line ending; it writes into one buffer. - The written maven.config args and the keys read come from one literal per settings prefix (CONFIG_ARGS, re-exported as MVN_CONFIG_ARGS). Co-Authored-By: Claude Opus 5.5 --- crates/socket-patch-cli/CLI_CONTRACT.md | 2 +- .../src/formats/maven/mod.rs | 41 +- .../src/formats/maven/trusted_checksums.rs | 483 ++++++++++++++---- .../src/patch/redirect/mod.rs | 174 ++++--- .../src/patch/redirect/upstream/maven.rs | 43 +- .../src/vex/discover/maven.rs | 98 ++-- .../vex-discover-golden/redirect-maven.json | 2 +- 7 files changed, 588 insertions(+), 255 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index a3ed909d6..34107916c 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -329,7 +329,7 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc **Attribution gate (v5.0).** A hosted run never leaves wiring that lockfile discovery calls contested: before the rewrite writes any lockfile, the same discovery `vex`, `list`, `rollback`, `remove` and `vendor` read runs over the project as the rewrite would leave it. A candidate whose pin discovery reads but cannot attribute to one package version (a requirements `-r` include resolving the same version from the registry beside a rewired `Pipfile.lock`, #567; a Maven pin in a ``, #260) is left out of the rewrite and reported as a `skipped` event with `errorCode` `redirect_unattributable` (nothing written for it; exit code unchanged). Unchanged: a pin in a file discovery does not read (a pre-2.6 bundler `Gemfile`, locked by the next `bundle install`) keeps the rewriter's verdict, and so does a deliberate partial redirect the run already reports (a bundled or `bun patch`-ed copy left on the registry, a yarn `npm:` alias entry left on the registry with `redirect_yarn_classic_alias_skipped` / `redirect_yarn_berry_alias_skipped` while the package's direct entry is pinned, a dep withheld from the vlt rewrite while a sibling lock takes it), and so does a vendored→hosted takeover: its vendored wiring is reverted in the run's staged overlay before the rewrite plans, and it is redirected when the rewriters pin it (otherwise it is retracted and stays vendored), in a `--dry-run` preview the same. A pin whose own pnpm or yarn lock also installs an unpatched copy of the same `name@version` (a pnpm `file:` directory or tarball dependency; a yarn `file:` / URL / berry `portal:` / `link:` dependency declared under another name) is written too, with a `redirect_copy_unpatched` warning naming that entry, and is not attested. A lockless NuGet / Cargo pin (an exclusive Socket source mapping without `packages.lock.json`, a Cargo registry pin without `Cargo.lock`) is still written, with a `redirect_pin_lockless` warning: no lockfile records its version, so `vex` cannot attest it and `rollback` / `remove` / `vendor` refuse it until the lockfile exists (whether such pins should be written at all is an open decision). The rollout's recorded view uses the same discovery: a uuid a file merely mentions (a stale `package.json` field, an inactive `pdm.lock`, a comment) is not a pin and does not count as already patched. -The rewriter reads a fixed set of candidate files from the project root: the npm-family locks (`package-lock.json`, `npm-shrinkwrap.json`, `pnpm-lock.yaml`, `shrinkwrap.yaml`, `yarn.lock`, plus `.yarnrc.yml` for the berry cache-config gate, `bun.lock` / `bun.lockb`, and `vlt-lock.json` with `vlt.json` and `node_modules/.vlt-lock.json` read only), `requirements.txt` / `uv.lock` / `Pipfile.lock` (pipfile-spec 6; see the Pipenv section below) / `poetry.lock` (every Poetry lock generation from 1.0 on — the 0.12 `[metadata.hashes]` layout is refused because that installer ignores URL sources; a Poetry < 1.4 writer additionally gets `redirect_poetry_stale_install_risk`, see `docs/testing/poetry-compatibility.md`) / `pdm.lock` (PDM lock formats `2` and `4.3`–`4.5.1`; the identity-losing `3.1` / `4.0`–`4.2` formats and unknown future formats are refused with `redirect_pdm_refused`, and a lock-format-`2` writer additionally gets `redirect_pdm_legacy_sync_required`, see `docs/testing/pdm-compatibility.md`; when `uv.lock` or `poetry.lock` sits beside it they drive and `pdm.lock` is left alone), `Cargo.toml` / `Cargo.lock` / `.cargo/config.toml` (plus the legacy extensionless `.cargo/config` — cargo reads that spelling in preference when both exist, so the managed `[registries.…]` block is written into whichever one is present; the hosted redirect is a `sparse+` registry, which cargo loads only from **1.68**, so `rust-toolchain.toml` / `rust-toolchain` are read too (never edited) and a project whose declared floor — the lowest `rust-version` of the root and the member manifests read here (a `rust-version.workspace = true` inheritor reads `[workspace.package]`), else the channel rustup picks from the root (the legacy `rust-toolchain` over `rust-toolchain.toml`; a dated `nightly-`/`beta-YYYY-MM-DD` maps to its release train) — is below 1.68 is refused `redirect_cargo_old_cargo` per crate (nothing written, an earlier run's pins left in place with the `rollback` that drops them named; `--mode vendored` builds on cargo 1.41+), while an undeclared or rolling-channel floor still redirects (toolchain files in ancestor directories are not read); **cargo also reads every workspace-member manifest** — the `[workspace] members` globs minus `exclude` — and every in-root path-dependency manifest, recursively, reached without crossing a symbolic link and never under `.socket/`, and pins the crate in each one that declares it, so those `/Cargo.toml` files can appear in `rewrittenFiles`. A crate is redirected only when every declaration pins and every other `Cargo.lock` package depending on it is a planned member: one a registry or git crate — or a path package outside the root or behind a link — also depends on is refused `redirect_cargo_transitive_dependents` (a pin reaches only the declarations it sits on), a crate no manifest declares keeps `redirect_cargo_toml_dep_not_found` with a transitive-only detail naming `--mode vendored`, a crate every declaration of which requires another version (no requirement accepts the patched version) is refused `redirect_cargo_toml_dep_unrewritable`, and so is a requirement that also matches another locked version of the crate — each a transactional skip, never recorded or attested. With NO `Cargo.lock` there is no resolved graph to ask, so the dependents question is answered from the manifests instead: a crate declared beside any other dependency — anything but a path dependency on a manifest this run also pins, or a `workspace = true` inheritor of a table it scans — or beside a workspace member this run did not read (a `members` glob, or a member outside the project or behind a symbolic link, which member discovery drops) is refused `redirect_cargo_lockless_dependents`, whose detail names the remedies (commit a lockfile, or `--mode vendored`); a project whose only dependency is the patched crate has nothing that could pull it in and still redirects. All-CRLF manifests, locks and configs are rewritten with CRLF kept (mixed endings keep refusing where the grammar does not match), and `remove` / rollback match the recorded fragments across a later CRLF↔LF checkout conversion), `composer.lock`, `nuget.config` / `packages.lock.json` (plus, v5.0 #353/#514, every lock a project under the root restores into: a member project's `packages.lock.json`, a per-project `packages..lock.json`, a literal `NuGetLockFilePath` — each pinned with the root config; a `NuGetLockFilePath` it cannot evaluate warns `redirect_nuget_lock_path_unresolved` and a project tree it cannot list warns `redirect_nuget_lock_unreadable`, both skipping the nuget redirect with nothing written), `Gemfile` / `Gemfile.lock`, `pom.xml` (+ `.mvn/maven.config` / `.mvn/checksums/checksums.sha256` for maven Trusted Checksums merge, and, for a Gradle build, every settings, build, `buildSrc`, included-build, applied and plugin-source script, version catalog and lock file the script graph reaches, plus `gradle/verification-metadata.xml`, `gradle/wrapper/gradle-wrapper.properties` and the owned `.socket/gradle/` files), and the sbt build files (`socket-patch.sbt`, `socket-patch-vendor.sbt`, `build.sbt`, `project/build.properties`, `.sbtopts`, `.jvmopts`; `build.sbt.lock` and the Mill / scala-cli build files `build.mill`, `build.mill.yaml`, `build.sc`, `.mill-version`, `project.scala` for their presence only) — read, never edited; `socket-patch.sbt` is the only sbt file hosted mode writes (see **Hosted sbt** below). **npm-family flavor coverage**: package-lock / npm-shrinkwrap (a package the project patches itself with npm ≥ 12.1's native `npm patch` — a root `patchedDependencies` key, or the lock entry's `patched` record — is left on its registry entry in every npm lock, `redirect_npm_patched_dependency_skipped`), pnpm (root OR any nested `*/pnpm-lock.yaml`), yarn classic (a yarn 2+ install migrates a v1 `yarn.lock` and drops its pins, so a run whose v1 lock carries a hosted pin warns `redirect_yarn_classic_berry_migration_risk` — the hosted twin of the vendored `yarn_classic_berry_migration_risk` — unless the root `package.json`, read as advisory input, declares `"packageManager": "yarn@1…"`), **yarn berry** (the pin yarn writes for a root `resolutions` entry: the root `package.json` — edited only beside a berry `yarn.lock` — gains one `"@npm:": ""` selector per locked range (`redirect_yarn_berry_resolution` edits), and only that `yarn.lock` entry is re-keyed `"@"` with the same `resolution:` + `yarnBerry10c0` checksum (`redirect_yarn_berry_entry`), moved to yarn's key order; never an `npm:` locator, whose fetcher sends npm registry auth to the patch host, nor a tarball locator under an `npm:` key, which hardened mode rejects (YN0078). An older release's `npm:::__archiveUrl=` pin is still recognized and is re-pinned on the next run; rollback rebuilds the key from the selectors and drops them. Refused, nothing written: a user-authored `resolutions` entry for the package `redirect_yarn_berry_resolutions_conflict`, no root manifest `redirect_yarn_berry_manifest_missing`, a builtin `patch:` entry wrapping the same descriptor `redirect_yarn_berry_shared_descriptor`, an artifact URL yarn cannot fetch as a tarball `redirect_yarn_berry_artifact_url_unsupported`; cacheKey `10c0` and `.yarnrc.yml compressionLevel 0` gated by `redirect_yarn_berry_cache_unsupported`), and **bun** (text `bun.lock` lockfileVersion 0, 1 or 2 — 0 is the `--save-text-lockfile` opt-in lock of Bun 1.1.39–1.1.45, 1 the 1.2–1.3 default, 2 the 1.4+ default; all three emit one `packages` grammar, so the registry 4-tuple → URL 3-tuple rewrite is version-independent and the lock's own version line is kept. Any other or missing version, or a `packages` section outside bun's single-line grammar, is refused `redirect_bun_lock_unsupported` — the detail is the shared version gate's text (a newer version: update socket-patch, re-locking would reproduce it; no integer: re-lock with Bun ≥ 1.2), identical to the vendored refusal. A version-0 lock holding `workspace:` packages is refused `redirect_bun_workspace_unsupported` (its 2-tuple workspace grammar cannot keep the hosted tuple through a frozen install); the remedy is to delete `bun.lock` and re-run `bun install` with Bun ≥ 1.2, which writes lockfileVersion 1 (accepted). A plain in-place `bun install` bumps the version only when a workspace depends on another workspace (e.g. root → member — the shape the matrix measured); otherwise Bun 1.2.0 keeps version 0 and Bun 1.2.23+ fail to resolve, so the in-place bump is not the documented remedy. Bun lock version, grammar and workspace compatibility are checked before a vendored takeover, including during dry-run: these refusals preserve the existing lock, artifact and vendor ledger. Version-1 and version-2 workspace locks are rewritten, nested versions included. A granted dep with no rewritable entry warns `redirect_bun_entry_not_found` (or `redirect_bun_non_registry_entry_skipped` when the only same-version entry is a user URL / `file:` tarball, #497), a grant without a sha512 `redirect_bun_missing_sha512`; a CRLF lock keeps `\r\n` on the rewritten line, and a hosted URL left by an earlier grant of the same `name@version` is re-pinned in place. **Digest-less re-saves (Bun 1.1.39–1.3.9)**: every text-lock Bun below 1.3.10 re-saves a URL tuple WITHOUT its `sha512` whenever the lock is re-saved for another reason (`bun add`, `bun install` after a package.json or workspace change), leaving the 2-tuple `["name@", {meta}]` — the spec Bun installs from is intact. The CLI treats that spelling as its own wiring: a repeat hosted run counts the dep as redirected (no `redirect_bun_entry_not_found`) and HEALS the line back to the 3-tuple with the current `sha512`, recording the heal as a further `redirect_bun_lock_package` edit whose `original` is the 2-tuple (a stale URL is re-pinned from either spelling); `rollback`, scoped `rollback ` / `remove ` and the vendored takeover accept the digest-less spelling of a recorded `new` line (same key, spec and meta, only the trailing `"sha512-…"` missing) and restore the recorded original over it, so the chain always unwinds to the pristine registry line. Anything else — another uuid/token, another version, a re-laid meta object — is still drift. **Native `bun.lockb`**: when no text `bun.lock` exists, binary format versions 1, 2 and 3 are read and rewritten directly. Socket Patch does not invoke Bun or convert the project to a text lockfile. Exact matching package records are rewritten to hosted tarballs with the granted integrity, preserving dependency resolution IDs, workspace/dependency topology and unrelated package metadata; binary pointers and the package metadata hash are updated. Per-package `redirect_bun_lockb_package` snapshots support scoped rollback, repeat runs, superseding grants and hosted ↔ vendored takeover. A regular binary lock is discoverable even with no Bun runtime or `node_modules`; a dry run previews the same binary edits without writing them. A malformed, unreadable, unsupported or unverified binary structure is `redirect_bun_lockb_invalid` (exit 0, `redirected: 0`), and it refuses the npm rewrite before any takeover or sibling npm-family lock mutation. A symlinked binary write target is `redirect_symlinked_file_unsupported` (exit 1, including dry-run). `bun.lock` wins when both spellings exist. Binary-only projects do not receive `redirect_npm_no_lockfile`. Measured boundaries and the real-Bun matrix: `docs/testing/bun-compatibility.md`), and **vlt** (`vlt-lock.json` without `lockfileVersion`, `0` or `1`; see the vlt hosted-mode contract below). **Rush monorepos**: when `rush.json` is present the rewriter also reads `common/config/rush/pnpm-lock.yaml` and each `common/config/subspaces//pnpm-lock.yaml` (sorted for determinism) under their repo-relative keys and repoints them in place; editing them emits `redirect_rush_repo_state_stale` when the `repo-state.json` beside a rewritten lock exists (`common/config/rush/repo-state.json` for the common lock, `common/config/subspaces//repo-state.json` for a subspace lock; the `pnpmShrinkwrapHash` desync is refreshed by `rush update`, which the redirect survives), and `redirect_pnpm_trust_lockfile` carries the Rush pnpm >=11 install remedy (see the trust paragraph above). **maven** is fail-closed via version suffixing: a `mavenSuffixedVersion` + `mavenPomSha256` override pins the Socket-only `-socket.` by rewriting the literal `` (`redirect_maven_dep_version`) or adding a `` entry (`redirect_maven_dep_management_added`), plus optional Trusted Checksums (`redirect_maven_trusted_checksums`, conflicts as `redirect_maven_trusted_checksums_conflict`; when `.mvn/wrapper/maven-wrapper.properties` pins a Maven older than 3.9.4, which ignores those files, the additive warning `redirect_maven_trusted_checksums_unenforced`); a `${property}` version is refused (`redirect_maven_dep_unpinned`), a reactor root (a `` / `` declaration, a profile's included, or a Maven 4 root that lists none but whose subprojects Maven discovers: model version not `4.0.0`, `pom` packaging, a direct subdirectory holding a `pom.xml`, and `maven.project.discoverSubprojects` not turned off in `.mvn/maven.config`; each such subdirectory's `pom.xml` is read for this, never edited) is refused whole with `redirect_maven_multimodule_unsupported`: `pom.xml` and `.mvn/` are left untouched and the dep is not counted as redirected (a Gradle build beside it is still planned by the Gradle rewriter, but a dep in a root with a `pom.xml` is confirmed only when the pom pins it too), since a module's own literal `` would shadow a root pin (use `--mode vendored`; `vex` omits a hosted pin in a reactor root as `vex_maven_reactor_root`), a non-matching literal skipped (`redirect_maven_dep_version_mismatch`), a `` variant (sources, tests, a native build) left at its version (`redirect_maven_classifier_unsupported`; the grant serves the main jar only, and the dep is skipped when a variant other than `sources` / `javadoc` names the patched release), a versioned declaration inside `` (a literal or a `${property}`) left as-is (`redirect_maven_profile_dependency_unpatched`; the dep is skipped when a profile holds its only declaration), a `${property}` groupId / artifactId resolved from the pom's own `` before matching (one the pom does not define, which may be the patched GA and whose own version would then win, refuses the dep with nothing written: `redirect_maven_coordinate_unresolved`), and a pom not readable as one left untouched (`redirect_maven_pom_unreadable`). Matches and anchors skip comments, CDATA, ``, ``, ``, `` and `` (the scope `vex` reads), and an existing self-closed `` / `` is expanded in place, never duplicated; an override without a suffixed version falls back to same-GAV repository injection (`redirect_maven_same_gav_fallback`, NOT fail-closed). **gradle** (v5.0) is automated wiring, no longer a pasted snippet: the owned settings script `.socket/gradle/socket-patch.hosted.settings.gradle` with its index `.socket/gradle/hosted-index.tsv`, one apply line per build's settings file, every lock entry of the GA moved to the suffixed version, and the suffixed component in an existing `gradle/verification-metadata.xml`. A refused dep writes nothing and keeps `redirect_gradle_manual_snippet` as its fallback; same-GAV grants are refused (`redirect_gradle_same_gav_unsupported`). Rules, refusals and codes: [Gradle builds](#gradle-builds-v50). +The rewriter reads a fixed set of candidate files from the project root: the npm-family locks (`package-lock.json`, `npm-shrinkwrap.json`, `pnpm-lock.yaml`, `shrinkwrap.yaml`, `yarn.lock`, plus `.yarnrc.yml` for the berry cache-config gate, `bun.lock` / `bun.lockb`, and `vlt-lock.json` with `vlt.json` and `node_modules/.vlt-lock.json` read only), `requirements.txt` / `uv.lock` / `Pipfile.lock` (pipfile-spec 6; see the Pipenv section below) / `poetry.lock` (every Poetry lock generation from 1.0 on — the 0.12 `[metadata.hashes]` layout is refused because that installer ignores URL sources; a Poetry < 1.4 writer additionally gets `redirect_poetry_stale_install_risk`, see `docs/testing/poetry-compatibility.md`) / `pdm.lock` (PDM lock formats `2` and `4.3`–`4.5.1`; the identity-losing `3.1` / `4.0`–`4.2` formats and unknown future formats are refused with `redirect_pdm_refused`, and a lock-format-`2` writer additionally gets `redirect_pdm_legacy_sync_required`, see `docs/testing/pdm-compatibility.md`; when `uv.lock` or `poetry.lock` sits beside it they drive and `pdm.lock` is left alone), `Cargo.toml` / `Cargo.lock` / `.cargo/config.toml` (plus the legacy extensionless `.cargo/config` — cargo reads that spelling in preference when both exist, so the managed `[registries.…]` block is written into whichever one is present; the hosted redirect is a `sparse+` registry, which cargo loads only from **1.68**, so `rust-toolchain.toml` / `rust-toolchain` are read too (never edited) and a project whose declared floor — the lowest `rust-version` of the root and the member manifests read here (a `rust-version.workspace = true` inheritor reads `[workspace.package]`), else the channel rustup picks from the root (the legacy `rust-toolchain` over `rust-toolchain.toml`; a dated `nightly-`/`beta-YYYY-MM-DD` maps to its release train) — is below 1.68 is refused `redirect_cargo_old_cargo` per crate (nothing written, an earlier run's pins left in place with the `rollback` that drops them named; `--mode vendored` builds on cargo 1.41+), while an undeclared or rolling-channel floor still redirects (toolchain files in ancestor directories are not read); **cargo also reads every workspace-member manifest** — the `[workspace] members` globs minus `exclude` — and every in-root path-dependency manifest, recursively, reached without crossing a symbolic link and never under `.socket/`, and pins the crate in each one that declares it, so those `/Cargo.toml` files can appear in `rewrittenFiles`. A crate is redirected only when every declaration pins and every other `Cargo.lock` package depending on it is a planned member: one a registry or git crate — or a path package outside the root or behind a link — also depends on is refused `redirect_cargo_transitive_dependents` (a pin reaches only the declarations it sits on), a crate no manifest declares keeps `redirect_cargo_toml_dep_not_found` with a transitive-only detail naming `--mode vendored`, a crate every declaration of which requires another version (no requirement accepts the patched version) is refused `redirect_cargo_toml_dep_unrewritable`, and so is a requirement that also matches another locked version of the crate — each a transactional skip, never recorded or attested. With NO `Cargo.lock` there is no resolved graph to ask, so the dependents question is answered from the manifests instead: a crate declared beside any other dependency — anything but a path dependency on a manifest this run also pins, or a `workspace = true` inheritor of a table it scans — or beside a workspace member this run did not read (a `members` glob, or a member outside the project or behind a symbolic link, which member discovery drops) is refused `redirect_cargo_lockless_dependents`, whose detail names the remedies (commit a lockfile, or `--mode vendored`); a project whose only dependency is the patched crate has nothing that could pull it in and still redirects. All-CRLF manifests, locks and configs are rewritten with CRLF kept (mixed endings keep refusing where the grammar does not match), and `remove` / rollback match the recorded fragments across a later CRLF↔LF checkout conversion), `composer.lock`, `nuget.config` / `packages.lock.json` (plus, v5.0 #353/#514, every lock a project under the root restores into: a member project's `packages.lock.json`, a per-project `packages..lock.json`, a literal `NuGetLockFilePath` — each pinned with the root config; a `NuGetLockFilePath` it cannot evaluate warns `redirect_nuget_lock_path_unresolved` and a project tree it cannot list warns `redirect_nuget_lock_unreadable`, both skipping the nuget redirect with nothing written), `Gemfile` / `Gemfile.lock`, `pom.xml` (+ `.mvn/maven.config` / `.mvn/checksums/checksums.sha256` for maven Trusted Checksums merge, and, for a Gradle build, every settings, build, `buildSrc`, included-build, applied and plugin-source script, version catalog and lock file the script graph reaches, plus `gradle/verification-metadata.xml`, `gradle/wrapper/gradle-wrapper.properties` and the owned `.socket/gradle/` files), and the sbt build files (`socket-patch.sbt`, `socket-patch-vendor.sbt`, `build.sbt`, `project/build.properties`, `.sbtopts`, `.jvmopts`; `build.sbt.lock` and the Mill / scala-cli build files `build.mill`, `build.mill.yaml`, `build.sc`, `.mill-version`, `project.scala` for their presence only) — read, never edited; `socket-patch.sbt` is the only sbt file hosted mode writes (see **Hosted sbt** below). **npm-family flavor coverage**: package-lock / npm-shrinkwrap (a package the project patches itself with npm ≥ 12.1's native `npm patch` — a root `patchedDependencies` key, or the lock entry's `patched` record — is left on its registry entry in every npm lock, `redirect_npm_patched_dependency_skipped`), pnpm (root OR any nested `*/pnpm-lock.yaml`), yarn classic (a yarn 2+ install migrates a v1 `yarn.lock` and drops its pins, so a run whose v1 lock carries a hosted pin warns `redirect_yarn_classic_berry_migration_risk` — the hosted twin of the vendored `yarn_classic_berry_migration_risk` — unless the root `package.json`, read as advisory input, declares `"packageManager": "yarn@1…"`), **yarn berry** (the pin yarn writes for a root `resolutions` entry: the root `package.json` — edited only beside a berry `yarn.lock` — gains one `"@npm:": ""` selector per locked range (`redirect_yarn_berry_resolution` edits), and only that `yarn.lock` entry is re-keyed `"@"` with the same `resolution:` + `yarnBerry10c0` checksum (`redirect_yarn_berry_entry`), moved to yarn's key order; never an `npm:` locator, whose fetcher sends npm registry auth to the patch host, nor a tarball locator under an `npm:` key, which hardened mode rejects (YN0078). An older release's `npm:::__archiveUrl=` pin is still recognized and is re-pinned on the next run; rollback rebuilds the key from the selectors and drops them. Refused, nothing written: a user-authored `resolutions` entry for the package `redirect_yarn_berry_resolutions_conflict`, no root manifest `redirect_yarn_berry_manifest_missing`, a builtin `patch:` entry wrapping the same descriptor `redirect_yarn_berry_shared_descriptor`, an artifact URL yarn cannot fetch as a tarball `redirect_yarn_berry_artifact_url_unsupported`; cacheKey `10c0` and `.yarnrc.yml compressionLevel 0` gated by `redirect_yarn_berry_cache_unsupported`), and **bun** (text `bun.lock` lockfileVersion 0, 1 or 2 — 0 is the `--save-text-lockfile` opt-in lock of Bun 1.1.39–1.1.45, 1 the 1.2–1.3 default, 2 the 1.4+ default; all three emit one `packages` grammar, so the registry 4-tuple → URL 3-tuple rewrite is version-independent and the lock's own version line is kept. Any other or missing version, or a `packages` section outside bun's single-line grammar, is refused `redirect_bun_lock_unsupported` — the detail is the shared version gate's text (a newer version: update socket-patch, re-locking would reproduce it; no integer: re-lock with Bun ≥ 1.2), identical to the vendored refusal. A version-0 lock holding `workspace:` packages is refused `redirect_bun_workspace_unsupported` (its 2-tuple workspace grammar cannot keep the hosted tuple through a frozen install); the remedy is to delete `bun.lock` and re-run `bun install` with Bun ≥ 1.2, which writes lockfileVersion 1 (accepted). A plain in-place `bun install` bumps the version only when a workspace depends on another workspace (e.g. root → member — the shape the matrix measured); otherwise Bun 1.2.0 keeps version 0 and Bun 1.2.23+ fail to resolve, so the in-place bump is not the documented remedy. Bun lock version, grammar and workspace compatibility are checked before a vendored takeover, including during dry-run: these refusals preserve the existing lock, artifact and vendor ledger. Version-1 and version-2 workspace locks are rewritten, nested versions included. A granted dep with no rewritable entry warns `redirect_bun_entry_not_found` (or `redirect_bun_non_registry_entry_skipped` when the only same-version entry is a user URL / `file:` tarball, #497), a grant without a sha512 `redirect_bun_missing_sha512`; a CRLF lock keeps `\r\n` on the rewritten line, and a hosted URL left by an earlier grant of the same `name@version` is re-pinned in place. **Digest-less re-saves (Bun 1.1.39–1.3.9)**: every text-lock Bun below 1.3.10 re-saves a URL tuple WITHOUT its `sha512` whenever the lock is re-saved for another reason (`bun add`, `bun install` after a package.json or workspace change), leaving the 2-tuple `["name@", {meta}]` — the spec Bun installs from is intact. The CLI treats that spelling as its own wiring: a repeat hosted run counts the dep as redirected (no `redirect_bun_entry_not_found`) and HEALS the line back to the 3-tuple with the current `sha512`, recording the heal as a further `redirect_bun_lock_package` edit whose `original` is the 2-tuple (a stale URL is re-pinned from either spelling); `rollback`, scoped `rollback ` / `remove ` and the vendored takeover accept the digest-less spelling of a recorded `new` line (same key, spec and meta, only the trailing `"sha512-…"` missing) and restore the recorded original over it, so the chain always unwinds to the pristine registry line. Anything else — another uuid/token, another version, a re-laid meta object — is still drift. **Native `bun.lockb`**: when no text `bun.lock` exists, binary format versions 1, 2 and 3 are read and rewritten directly. Socket Patch does not invoke Bun or convert the project to a text lockfile. Exact matching package records are rewritten to hosted tarballs with the granted integrity, preserving dependency resolution IDs, workspace/dependency topology and unrelated package metadata; binary pointers and the package metadata hash are updated. Per-package `redirect_bun_lockb_package` snapshots support scoped rollback, repeat runs, superseding grants and hosted ↔ vendored takeover. A regular binary lock is discoverable even with no Bun runtime or `node_modules`; a dry run previews the same binary edits without writing them. A malformed, unreadable, unsupported or unverified binary structure is `redirect_bun_lockb_invalid` (exit 0, `redirected: 0`), and it refuses the npm rewrite before any takeover or sibling npm-family lock mutation. A symlinked binary write target is `redirect_symlinked_file_unsupported` (exit 1, including dry-run). `bun.lock` wins when both spellings exist. Binary-only projects do not receive `redirect_npm_no_lockfile`. Measured boundaries and the real-Bun matrix: `docs/testing/bun-compatibility.md`), and **vlt** (`vlt-lock.json` without `lockfileVersion`, `0` or `1`; see the vlt hosted-mode contract below). **Rush monorepos**: when `rush.json` is present the rewriter also reads `common/config/rush/pnpm-lock.yaml` and each `common/config/subspaces//pnpm-lock.yaml` (sorted for determinism) under their repo-relative keys and repoints them in place; editing them emits `redirect_rush_repo_state_stale` when the `repo-state.json` beside a rewritten lock exists (`common/config/rush/repo-state.json` for the common lock, `common/config/subspaces//repo-state.json` for a subspace lock; the `pnpmShrinkwrapHash` desync is refreshed by `rush update`, which the redirect survives), and `redirect_pnpm_trust_lockfile` carries the Rush pnpm >=11 install remedy (see the trust paragraph above). **maven** is fail-closed via version suffixing: a `mavenSuffixedVersion` + `mavenPomSha256` override pins the Socket-only `-socket.` by rewriting the literal `` (`redirect_maven_dep_version`) or adding a `` entry (`redirect_maven_dep_management_added`), plus optional Trusted Checksums (`redirect_maven_trusted_checksums`, conflicts as `redirect_maven_trusted_checksums_conflict`; when `.mvn/wrapper/maven-wrapper.properties` pins a Maven older than 3.9.4, which ignores those files, the additive warning `redirect_maven_trusted_checksums_unenforced`; a grant sha256 that is not 64 hex digits writes no checksum line, `redirect_maven_trusted_checksums_invalid_hash`); a `${property}` version is refused (`redirect_maven_dep_unpinned`), a reactor root (a `` / `` declaration, a profile's included, or a Maven 4 root that lists none but whose subprojects Maven discovers: model version not `4.0.0`, `pom` packaging, a direct subdirectory holding a `pom.xml`, and `maven.project.discoverSubprojects` not turned off in `.mvn/maven.config`; each such subdirectory's `pom.xml` is read for this, never edited) is refused whole with `redirect_maven_multimodule_unsupported`: `pom.xml` and `.mvn/` are left untouched and the dep is not counted as redirected (a Gradle build beside it is still planned by the Gradle rewriter, but a dep in a root with a `pom.xml` is confirmed only when the pom pins it too), since a module's own literal `` would shadow a root pin (use `--mode vendored`; `vex` omits a hosted pin in a reactor root as `vex_maven_reactor_root`), a non-matching literal skipped (`redirect_maven_dep_version_mismatch`), a `` variant (sources, tests, a native build) left at its version (`redirect_maven_classifier_unsupported`; the grant serves the main jar only, and the dep is skipped when a variant other than `sources` / `javadoc` names the patched release), a versioned declaration inside `` (a literal or a `${property}`) left as-is (`redirect_maven_profile_dependency_unpatched`; the dep is skipped when a profile holds its only declaration), a `${property}` groupId / artifactId resolved from the pom's own `` before matching (one the pom does not define, which may be the patched GA and whose own version would then win, refuses the dep with nothing written: `redirect_maven_coordinate_unresolved`), and a pom not readable as one left untouched (`redirect_maven_pom_unreadable`). Matches and anchors skip comments, CDATA, ``, ``, ``, `` and `` (the scope `vex` reads), and an existing self-closed `` / `` is expanded in place, never duplicated; an override without a suffixed version falls back to same-GAV repository injection (`redirect_maven_same_gav_fallback`, NOT fail-closed). **gradle** (v5.0) is automated wiring, no longer a pasted snippet: the owned settings script `.socket/gradle/socket-patch.hosted.settings.gradle` with its index `.socket/gradle/hosted-index.tsv`, one apply line per build's settings file, every lock entry of the GA moved to the suffixed version, and the suffixed component in an existing `gradle/verification-metadata.xml`. A refused dep writes nothing and keeps `redirect_gradle_manual_snippet` as its fallback; same-GAV grants are refused (`redirect_gradle_same_gav_unsupported`). Rules, refusals and codes: [Gradle builds](#gradle-builds-v50). **Hosted sbt (v5.0, additive)**: an sbt build root (`project/build.properties` naming an `sbt.version`, 0.13.18 or later) is wired through ONE generated root file, `socket-patch.sbt` — no user file is edited. It pins every granted Maven patch build-wide (a `ThisBuild` `dependencyOverrides +=` of the Socket-only `-socket.` version plus a `file:` resolver over `.socket/sbt-hosted/maven2/`, moved ahead of the default repositories on sbt 0.13 / 1.x so an unreachable one never blocks it offline), downloads the pinned pom and jar there on the first sbt load (sha256-checked, gitignored by the file itself), and installs a load-time verifier that fails `update` when any project resolves another version or a pinned artifact whose bytes are not pinned. Edits: `redirect_sbt_pin` (added), `redirect_sbt_pin_updated` (an existing row replaced: same GA and base under a new uuid, or the same uuid with new served values; `original` names the previous uuid and version), `redirect_sbt_pin_rechecked` (an existing row re-verified after the build's dependencies changed: its dependency digest is recorded anew, `original`/`new` are `{deps}`). The load-time verifier also fails `update` when a project declares a pinned GA at a version newer than the pin's base (the build-wide override would otherwise force it back down). A new pin is gated on sbt's own resolution records under `target/` (never the machine-wide cache): run-level stops wire nothing, warn once and exit 0 — `redirect_sbt_no_resolution_evidence` (none; run `sbt update` first; always the in-memory engine's answer), `redirect_sbt_resolution_incomplete` (a declared project left no evidence, or the project definitions cannot be read statically), `redirect_sbt_resolution_stale` (a build source is newer than some project's evidence: each project is dated by its own newest record, so a partial `sbt /update` does not vouch for the others). Per-patch refusals (never confirmed): `redirect_sbt_missing_override` (no `maven2` override or no suffixed version), `redirect_sbt_integrity_missing` (jar or pom sha256 missing), `redirect_sbt_unsafe_value` (a value unsafe in a Scala literal, or an index URL not naming the uuid), `redirect_sbt_version_conflict` (some project resolves another version, or a build source declares the GA newer than the patch's base), `redirect_sbt_override_conflict` (two patches for one GA in a run, or another base already pinned), `redirect_sbt_vendored_conflict` (the GA is pinned by `socket-patch-vendor.sbt`, or that file cannot be parsed — then every Maven patch), `redirect_sbt_owned_file_modified` / `redirect_sbt_owned_file_foreign` (`socket-patch.sbt` edited, or not socket-patch's — every Maven patch), `redirect_sbt_owned_file_unreadable` (a whole-run refusal: `socket-patch.sbt` is on disk but cannot be read as UTF-8 text, so writing it would replace it; nothing is written), `redirect_sbt_unsupported_version`, `redirect_sbt_build_root_unknown` (sbt files but no versioned build root — every Maven patch), `redirect_sbt_overrides_assignment` / `redirect_sbt_resolvers_assignment` (a build source reassigns `dependencyOverrides` / `resolvers` with `:=`, `~=` or `--=`), `redirect_sbt_dependency_lock_present` (a `build.sbt.lock`), `redirect_sbt_scala_runtime_unsupported` (`org.scala-lang`), `redirect_sbt_classifier_unsupported`; a GA no library configuration resolves is skipped silently (`redirect_sbt_meta_build_only` when only the meta-build resolves it). Advisories: `redirect_sbt_version_untested` (sbt 2.1+, still wired), `redirect_sbt_override_build_repos` (`sbt.override.build.repos=true`), `redirect_maven_pom_ignored_sbt_build` (a `pom.xml` beside the sbt build, which sbt never reads; the Maven rewriter still edits it for the Maven build). A re-run keeps an existing row and re-checks it. When the build's dependency digest changed since the pin, evidence resolved after the change (fresh, newer than the generated file) re-verifies it and the row's digest is refreshed (`redirect_sbt_pin_rechecked`); the uuid is NOT confirmed on `redirect_sbt_pin_declared_newer` (a build source now declares the GA newer than the pin's base; the row stays, sbt's load-time verifier fails the build, and the remedy is `socket-patch rollback` or declaring the base again), `redirect_sbt_pin_unverifiable` (the digest changed and the evidence predates the change, or the digest cannot be computed: run `sbt update`, then re-run socket-patch), `redirect_sbt_override_shadowed` (the evidence still resolves the base version) or `redirect_sbt_resolved_elsewhere` (the pinned version resolves from outside the pin repository from a file whose sha256 is not the pinned jar's; a copy holding the pinned bytes, such as the Ivy cache a second checkout reads, is fine — at most 64 pinned artifact files of up to 256 MiB are hashed, anything else counts as elsewhere), and also when a build source now reassigns `dependencyOverrides` / `resolvers` or a `build.sbt.lock` appeared (the same `redirect_sbt_overrides_assignment` / `redirect_sbt_resolvers_assignment` / `redirect_sbt_dependency_lock_present` codes; the row stays and sbt's load-time verifier fails the build). For a pure sbt root (no `pom.xml` / Gradle script beside it), maven confirmation is decided only by the sbt rewriter's report; on a mixed root a uuid the sbt rewriter refused is still confirmed by the Maven rewriter's own `pom.xml` pin (the generated sbt files never prove a pin by substring). **Mill and scala-cli** are guidance only: per Maven patch `redirect_mill_manual_snippet` / `redirect_scala_cli_manual_snippet` carry a paste-able snippet (repository + forced suffixed version), nothing is written or confirmed, and a pure Mill / scala-cli root gets no `redirect_maven_no_pom`; there, a Maven patch the server sent without a `maven2` registry override gets `redirect_maven_missing_override` instead of a snippet (with a `pom.xml` beside the Mill / scala-cli files the pom rewriter reports it). `rollback` / `remove` restore `socket-patch.sbt` offline (the rows removed, the file deleted with its last pin; the gitignored downloads are left). Manifest-less VEX reads every strictly parsed pin as a hosted reference but grants it the lockfile basis only when the local evidence shows every recorded version of the GA is the pinned one and every recorded artifact hashes to a pinned sha256 (else `sbt_resolution_unverified`). diff --git a/crates/socket-patch-core/src/formats/maven/mod.rs b/crates/socket-patch-core/src/formats/maven/mod.rs index d95ebccdd..d22942801 100644 --- a/crates/socket-patch-core/src/formats/maven/mod.rs +++ b/crates/socket-patch-core/src/formats/maven/mod.rs @@ -482,14 +482,35 @@ pub(crate) fn may_name(coordinate: &str, want: &str) -> bool { /// the file on whitespace instead, so a `-Dk=v` token that only that reading /// finds (`-B -Dk=v` on one line) is kept too, unless a line defines `k`. pub(crate) fn maven_config_properties(config: &str) -> BTreeMap { - // Java's `String.trim`: every char up to U+0020, nothing beyond. - fn trim(s: &str) -> &str { - s.trim_matches(|c: char| c <= ' ') + let mut props = maven_config_line_properties(config); + let tokens = config + .lines() + .filter(|line| !java_trim(line).starts_with('#')) + .flat_map(str::split_whitespace) + .filter_map(|t| t.strip_prefix("-D")?.split_once('=')); + let mut legacy = BTreeMap::new(); + for (k, v) in tokens { + if !k.is_empty() && !props.contains_key(k) { + legacy.insert(k.to_string(), v.to_string()); + } } + props.extend(legacy); + props +} + +/// Java's `String.trim`: every char up to U+0020, nothing beyond. +fn java_trim(s: &str) -> &str { + s.trim_matches(|c: char| c <= ' ') +} + +/// [`maven_config_properties`] as Maven 3.9+ alone reads it: only the +/// argument a whole line holds, never a token of the older whitespace split. +pub(crate) fn maven_config_line_properties(config: &str) -> BTreeMap { + let trim = java_trim; fn unquote(s: &str) -> &str { s.strip_prefix('"') .and_then(|s| s.strip_suffix('"')) - .map_or(s, trim) + .map_or(s, java_trim) } let mut args = config .lines() @@ -519,18 +540,6 @@ pub(crate) fn maven_config_properties(config: &str) -> BTreeMap props.insert(k.to_string(), v.to_string()); } } - let tokens = config - .lines() - .filter(|line| !trim(line).starts_with('#')) - .flat_map(str::split_whitespace) - .filter_map(|t| t.strip_prefix("-D")?.split_once('=')); - let mut legacy = BTreeMap::new(); - for (k, v) in tokens { - if !k.is_empty() && !props.contains_key(k) { - legacy.insert(k.to_string(), v.to_string()); - } - } - props.extend(legacy); props } diff --git a/crates/socket-patch-core/src/formats/maven/trusted_checksums.rs b/crates/socket-patch-core/src/formats/maven/trusted_checksums.rs index 51bad2354..bb105ecd7 100644 --- a/crates/socket-patch-core/src/formats/maven/trusted_checksums.rs +++ b/crates/socket-patch-core/src/formats/maven/trusted_checksums.rs @@ -1,69 +1,203 @@ //! Maven Trusted Checksums: the `.mvn/checksums/checksums.sha256` summary -//! file and the `.mvn/maven.config` switch that makes it a pin. Pure: text -//! in, text out. Discovery, the hosted rewriter and its unwind all read -//! both files through here, so what one writes the others read alike. +//! file, the `.mvn/maven.config` settings that make Maven read it, and the +//! Maven release that enforces it. Pure: text in, text out. Discovery, the +//! hosted rewriter and its unwind all judge both files through here, so +//! what one writes, the others read and judge alike. //! //! The summary file is written in Maven's own grammar, ` ` -//! (two spaces; maven-resolver's `SummaryFileTrustedChecksumsSource` splits -//! on exactly that, skips `#` and empty lines, and lets a later line for a -//! path win). It is READ tolerantly, as `sha256sum`-style tools emit it: -//! any run of spaces/tabs, an optional `*` binary marker, CRLF. A line that -//! is not an entry (a comment, a BSD-style line, anything else) is kept -//! byte-for-byte by [`edit`], never dropped. +//! (lowercase hex, two spaces). maven-resolver's +//! `SummaryFileTrustedChecksumsSource` splits on exactly two spaces, skips +//! `#` and empty lines, lets a later line for a path win, and compares the +//! hash to its own lowercase hex. It is READ tolerantly, as +//! `sha256sum`-style tools write it: any run of spaces/tabs, an optional +//! `*` binary marker, CRLF. [`edit`] keeps a line it does not own +//! byte-for-byte, never drops it. use std::collections::{BTreeMap, BTreeSet}; -use super::maven_config_properties; -use crate::utils::digest::sha256_hex; +use super::maven_config_line_properties; +use crate::utils::digest::{is_hex64_lower, sha256_hex}; + +// One literal per settings prefix, so the args written and the keys read +// cannot drift apart. +macro_rules! post_processor { + () => { + "aether.artifactResolver.postProcessor.trustedChecksums" + }; +} +macro_rules! summary_file { + () => { + "aether.trustedChecksumsSource.summaryFile" + }; +} +macro_rules! basedir { + () => { + "${session.rootDirectory}/.mvn/checksums" + }; +} /// The resolver property that switches the Trusted Checksums /// post-processor on. -pub(crate) const SWITCH_KEY: &str = "aether.artifactResolver.postProcessor.trustedChecksums"; +pub(crate) const SWITCH_KEY: &str = post_processor!(); +const ALGORITHMS_KEY: &str = concat!(post_processor!(), ".checksumAlgorithms"); +const SUMMARY_FILE_KEY: &str = summary_file!(); +const BASEDIR_KEY: &str = concat!(summary_file!(), ".basedir"); +const ORIGIN_AWARE_KEY: &str = concat!(summary_file!(), ".originAware"); + +/// The `.mvn/maven.config` lines hosted mode writes, one argument each: +/// the post-processor on, checking SHA-256, `failIfMissing=false` (a +/// dependency without a committed checksum still resolves; only a MISMATCH +/// fails), and the summary-file source on, at `.mvn/checksums`, origin +/// unaware (one checksum matches the artifact from any repository). +pub(crate) const CONFIG_ARGS: &[&str] = &[ + concat!("-D", post_processor!(), "=true"), + concat!("-D", post_processor!(), ".checksumAlgorithms=SHA-256"), + concat!("-D", post_processor!(), ".failIfMissing=false"), + concat!("-D", summary_file!(), "=true"), + concat!("-D", summary_file!(), ".basedir=", basedir!()), + concat!("-D", summary_file!(), ".originAware=false"), +]; -/// Whether `.mvn/maven.config` switches Trusted Checksums on, i.e. whether -/// the summary file is a pin at all. +/// Whether `.mvn/maven.config` makes Maven check artifacts against +/// `.mvn/checksums/checksums.sha256` — the one rule every mode shares. /// -/// The one rule discovery, the hosted rewriter and its unwind share: -/// `maven.config` is read the way [`maven_config_properties`] reads it -/// (Maven 3.9+: one argument per line, `#` comments, `-Dk=v` / -/// `--define`, `-Dk` alone meaning `true`, a later definition winning), -/// and the switch's value is then judged as the resolver judges it -/// (`ConfigUtils.getBoolean` → Java `Boolean.parseBoolean`: `true` in any -/// case, nothing else). Only Maven 3.9.4+ enforces the pin at all, and that -/// line reads `maven.config` per line, so a token the whitespace split of -/// older Maven finds counts only where no line sets the key. -pub(crate) fn switch_on(config: &str) -> bool { - maven_config_properties(config) - .get(SWITCH_KEY) - .is_some_and(|v| v.eq_ignore_ascii_case("true")) +/// `maven.config` is read as Maven 3.9+ reads it (the only line that +/// enforces Trusted Checksums at all): one argument per LINE, `#` +/// comments, `-Dk=v` / `--define`, `-Dk` alone meaning `true`, a later +/// definition winning ([`maven_config_line_properties`]); a `-Dk=v` token +/// inside a longer line is not an argument there. Each setting is then +/// judged as the resolver judges it, with its default when unset: +/// - the post-processor on (`ConfigUtils.getBoolean`, i.e. Java +/// `Boolean.parseBoolean`: `true` in any case; default off); +/// - its comma-separated `checksumAlgorithms` naming `SHA-256` (default +/// `SHA-1`, which never reads a `.sha256` file); +/// - the summary-file source on (default off); +/// - its `basedir` exactly the `.mvn/checksums` hosted mode writes (the +/// default is `.checksums` under the local repository; any other value is +/// not this file); +/// - `originAware` off (default on, which reads a per-repository +/// `checksums-.sha256` instead). +/// +/// `failIfMissing` does not decide whether a committed line is checked. +pub(crate) fn config_enforces(config: &str) -> bool { + let props = maven_config_line_properties(config); + let get = |key: &str| props.get(key).map(String::as_str); + let is_true = |key: &str| get(key).is_some_and(|v| v.eq_ignore_ascii_case("true")); + is_true(SWITCH_KEY) + && get(ALGORITHMS_KEY).is_some_and(|v| v.split(',').any(|a| a.trim() == "SHA-256")) + && is_true(SUMMARY_FILE_KEY) + && get(BASEDIR_KEY) == Some(basedir!()) + && get(ORIGIN_AWARE_KEY).is_some_and(|v| !v.eq_ignore_ascii_case("true")) +} + +/// Whether any setting hosted mode writes is present in `config` (read as +/// [`config_enforces`] reads it), whatever its value. +pub(crate) fn config_has_written_settings(config: &str) -> bool { + let props = maven_config_line_properties(config); + CONFIG_ARGS + .iter() + .filter_map(|arg| arg.strip_prefix("-D")?.split_once('=')) + .any(|(key, _)| props.contains_key(key)) +} + +/// The first Maven release that enforces the `.mvn/*` Trusted Checksums +/// files (#258). 3.9.0 / 3.9.1 do not interpolate `${session.rootDirectory}` +/// in `maven.config`, so the summary file is never found; 3.9.2 / 3.9.3 +/// ignore `checksumAlgorithms=SHA-256` and check SHA-1 only. Older lines have +/// no Trusted Checksums post-processor at all. +const MIN_MAVEN: [u32; 3] = [3, 9, 4]; + +/// The Apache Maven release a `maven-wrapper.properties` pins, taken from its +/// `distributionUrl` (`…/apache-maven--bin.zip` or `.tar.gz`). None +/// for a missing key or any other distribution (e.g. mvnd), which is then +/// taken to enforce. +pub(crate) fn wrapper_maven_version(props: &str) -> Option { + props.lines().find_map(|line| { + let line = line.trim(); + if line.starts_with('#') || line.starts_with('!') { + return None; + } + let rest = line.strip_prefix("distributionUrl")?; + if !rest.starts_with(|c: char| c == '=' || c == ':' || c.is_whitespace()) { + return None; + } + let value = rest + .trim_start() + .strip_prefix(['=', ':']) + .unwrap_or(rest) + .trim(); + let file = value.rsplit('/').next()?; + let version = file + .strip_prefix("apache-maven-")? + .strip_suffix(".zip") + .or_else(|| file.strip_prefix("apache-maven-")?.strip_suffix(".tar.gz"))? + .strip_suffix("-bin")?; + (!version.is_empty()).then(|| version.to_string()) + }) } -/// One entry line of the summary file. +/// Whether Maven `version` enforces the Trusted Checksums files (≥ 3.9.4; +/// pre-release suffixes such as `-rc-6` compare as their release). +pub(crate) fn maven_enforces(version: &str) -> bool { + let mut parts = [0u32; 3]; + for (slot, part) in parts.iter_mut().zip(version.split(['.', '-'])) { + *slot = part.parse().unwrap_or(0); + } + parts >= MIN_MAVEN +} + +/// Whether the summary file is an enforced pin: [`config_enforces`], and a +/// Maven Wrapper (`wrapper`, its properties) that names no Apache Maven +/// release older than 3.9.4. No wrapper, or one naming another +/// distribution, is taken to enforce — the hosted rewriter's rule, which +/// warns only on a release it can name. +pub(crate) fn enforced(config: &str, wrapper: Option<&str>) -> bool { + config_enforces(config) + && wrapper + .and_then(wrapper_maven_version) + .is_none_or(|v| maven_enforces(&v)) +} + +/// One non-comment line of the summary file, read as two fields. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct Entry<'a> { - /// The hex sha256 as written (either case). + /// The first field as written (a hash, or whatever stands there). pub(crate) hash: &'a str, /// The local-repository-relative path, without a `*` binary marker. pub(crate) path: &'a str, - /// The line is exactly ` `, the only shape Maven itself - /// honours: any other spelling Maven skips, or reads as another path. + /// The line is exactly `<64 lowercase hex> `, the only shape + /// Maven checks an artifact against: any other spelling Maven skips, + /// reads as another path, or fails the build on (an uppercase hash never + /// equals its lowercase digest). pub(crate) maven_reads: bool, } +/// Whether `line` (its `\n` excluded) is a comment, which Maven skips. +fn is_comment(line: &str) -> bool { + line.trim_start_matches([' ', '\t']).starts_with('#') +} + /// `line` (its `\n` excluded; a trailing `\r` is tolerated) as an entry: -/// a 64-digit hex hash, a run of spaces/tabs, an optional `*`, a non-empty -/// path. Surrounding blanks are ignored. `None` for anything else. +/// a first field, a run of spaces/tabs, an optional `*`, a non-empty path; +/// surrounding blanks ignored. The first field need not be a valid hash, so +/// a line for a path hosted mode owns is recognised (and replaced or +/// dropped) whatever its hash. `None` for a comment, a blank line, or a +/// single field. pub(crate) fn parse_line(line: &str) -> Option> { let line = line.strip_suffix('\r').unwrap_or(line); + if is_comment(line) { + return None; + } let blank = |c: char| c == ' ' || c == '\t'; let trimmed = line.trim_matches(blank); let (hash, rest) = trimmed.split_at(trimmed.find(blank)?); let rest = rest.trim_start_matches(blank); let path = rest.strip_prefix('*').unwrap_or(rest); - if path.is_empty() || sha256_hex(hash).is_none() { + if path.is_empty() { return None; } - let maven_reads = line.strip_prefix(hash).and_then(|r| r.strip_prefix(" ")) == Some(path); + let maven_reads = is_hex64_lower(hash) + && line.strip_prefix(hash).and_then(|r| r.strip_prefix(" ")) == Some(path); Some(Entry { hash, path, @@ -76,27 +210,45 @@ pub(crate) fn entries(text: &str) -> impl Iterator> { text.split('\n').filter_map(parse_line) } -/// `path -> lowercase sha256` of the entries Maven enforces: only lines it -/// reads ([`Entry::maven_reads`]), a later line for a path winning, as in -/// the resolver. A tolerated spelling is recognised (and replaced, or -/// dropped, by [`edit`]) but pins nothing, so nothing attests it. +/// `path -> sha256` of the entries Maven checks: only lines it reads +/// ([`Entry::maven_reads`]), a later line for a path winning, as in the +/// resolver. A tolerated spelling is recognised (and replaced, or dropped, +/// by [`edit`]) but pins nothing, so nothing attests it. pub(crate) fn pinned(text: &str) -> BTreeMap { entries(text) .filter(|e| e.maven_reads) - .filter_map(|e| Some((e.path.to_string(), sha256_hex(e.hash)?))) + .map(|e| (e.path.to_string(), e.hash.to_string())) .collect() } -/// `text` with every entry for a path in `set` rewritten in place to -/// ` ` (its own line ending kept), every other entry whose -/// path `drop` names removed, and the `set` paths it lacks inserted in -/// ` ` form: each before the first later entry whose path -/// sorts after it (so a sorted file stays sorted), else appended, with the -/// file's line ending (`\n` for a new file). Every other line (a comment, -/// a blank line, an unparseable line, another path's entry) is kept -/// byte-for-byte. +/// The line ending most of `text`'s lines use (`\n` on a tie or none), so +/// an inserted line never adds an ending the file does not mostly use. +fn dominant_eol(text: &str) -> &'static str { + let crlf = text.matches("\r\n").count(); + if crlf * 2 > text.matches('\n').count() { + "\r\n" + } else { + "\n" + } +} + +/// `text` with the entries for the paths in `set` (`(path, sha256)`, the +/// hash 64 lowercase hex) made ` `, and every other entry +/// whose path `drop` names removed. +/// +/// A `set` path the file already names is rewritten where it first stands +/// (that line's ending kept) and any later line for it removed. One it +/// lacks is inserted before the first later checksum line whose path sorts +/// after it — ahead of the comment block directly above that line, which +/// stays with it — so a sorted file stays sorted; else appended. An +/// inserted line takes the file's dominant line ending. Every other line (a +/// comment, a blank line, an unparseable line, another path's entry in any +/// spelling) is kept byte-for-byte. pub(crate) fn edit(text: &str, drop: impl Fn(&str) -> bool, set: &[(String, String)]) -> String { + debug_assert!(set.iter().all(|(_, h)| is_hex64_lower(h)), "{set:?}"); let set: BTreeMap<&str, &str> = set.iter().map(|(p, h)| (p.as_str(), h.as_str())).collect(); + // Which owned paths the file already names decides where each goes, so + // it is known before the first line is written. let present: BTreeSet<&str> = entries(text) .map(|e| e.path) .filter(|p| set.contains_key(p)) @@ -106,39 +258,50 @@ pub(crate) fn edit(text: &str, drop: impl Fn(&str) -> bool, set: &[(String, Stri .filter(|(p, _)| !present.contains(*p)) .map(|(p, h)| (*p, *h)) .collect(); - let eol = match text.find('\n') { - Some(at) if text[..at].ends_with('\r') => "\r\n", - _ => "\n", + let eol = dominant_eol(text); + let push_entry = |out: &mut String, hash: &str, path: &str, ending: &str| { + out.push_str(hash); + out.push_str(" "); + out.push_str(path); + out.push_str(ending); }; let mut out = String::with_capacity(text.len() + 160 * pending.len()); - let mut flush_before = |out: &mut String, path: Option<&str>| { - while let Some((p, h)) = pending.first_key_value() { - if path.is_some_and(|path| path <= *p) { - break; - } - if !out.is_empty() && !out.ends_with('\n') { - out.push_str(eol); - } - out.push_str(&format!("{h} {p}{eol}")); - pending.pop_first(); - } - }; + let mut written: BTreeSet<&str> = BTreeSet::new(); + // The comment lines directly above the line not yet reached. + let mut held = String::new(); for line in text.split_inclusive('\n') { let body = line.strip_suffix('\n').unwrap_or(line); - let Some(entry) = parse_line(body) else { - out.push_str(line); + if is_comment(body) { + held.push_str(line); continue; - }; - if let Some(hash) = set.get(entry.path) { - flush_before(&mut out, Some(entry.path)); - let ending = &line[body.trim_end_matches('\r').len()..]; - out.push_str(&format!("{hash} {}{ending}", entry.path)); - } else if !drop(entry.path) { - flush_before(&mut out, Some(entry.path)); - out.push_str(line); } + let entry = parse_line(body); + if let Some(e) = entry.as_ref().filter(|e| sha256_hex(e.hash).is_some()) { + while let Some((p, h)) = pending.first_key_value().filter(|(p, _)| **p < e.path) { + push_entry(&mut out, h, p, eol); + pending.pop_first(); + } + } + out.push_str(&held); + held.clear(); + match entry { + Some(e) if set.contains_key(e.path) => { + if written.insert(e.path) { + let ending = &line[body.trim_end_matches('\r').len()..]; + push_entry(&mut out, set[e.path], e.path, ending); + } + } + Some(e) if drop(e.path) => {} + _ => out.push_str(line), + } + } + out.push_str(&held); + for (p, h) in pending { + if !out.is_empty() && !out.ends_with('\n') { + out.push_str(eol); + } + push_entry(&mut out, h, p, eol); } - flush_before(&mut out, None); out } @@ -148,6 +311,7 @@ mod tests { const A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; const B: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + const OURS: &str = "org/x/1.0-socket.aaaaaaaa/x-1.0-socket.aaaaaaaa.jar"; fn set(entries: &[(&str, &str)]) -> Vec<(String, String)> { entries @@ -168,7 +332,12 @@ mod tests { (format!("{A}\ta/b.jar"), "a/b.jar", false), (format!("{A} a/b.jar"), "a/b.jar", false), (format!(" {A} a/b.jar "), "a/b.jar", false), - (format!("{upper} a/b.jar"), "a/b.jar", true), + // maven-resolver compares against its lowercase digest: an + // uppercase line fails the build, it pins nothing. + (format!("{upper} a/b.jar"), "a/b.jar", false), + // A truncated or SRI hash is still a line for its path. + (format!("{} a/b.jar", &A[1..]), "a/b.jar", false), + ("sha256-q83vEjRWeJA= a/b.jar".to_string(), "a/b.jar", false), ] { let entry = parse_line(&line).unwrap_or_else(|| panic!("{line:?}")); assert_eq!(entry.path, path, "{line:?}"); @@ -178,11 +347,10 @@ mod tests { String::new(), "# comment".into(), format!("#{A} a/b.jar"), + format!(" # {A} a/b.jar"), A.to_string(), format!("{A} "), format!("{A} *"), - format!("{} a/b.jar", &A[1..]), - format!("SHA256 (a/b.jar) = {A}"), ] { assert_eq!(parse_line(&line), None, "{line:?}"); } @@ -195,10 +363,7 @@ mod tests { format!("# header\r\n{A} x.jar\r\n{A} y.jar\n{A} *z.jar\n{B} x.jar\n{upper} w.jar"); assert_eq!( pinned(&text), - BTreeMap::from([ - ("w.jar".to_string(), B.to_string()), - ("x.jar".to_string(), B.to_string()), - ]) + BTreeMap::from([("x.jar".to_string(), B.to_string())]) ); assert_eq!( entries(&text).map(|e| e.path).collect::>(), @@ -226,27 +391,17 @@ mod tests { let text = format!( "# pins\n{A} c/user.jar\n{A} *d/user.jar\n{A}\te/user.jar\ngarbage\n{A} m/ours.jar\n" ); - let out = edit( - &text, - |p| p == "never.jar", - &set(&[("m/ours.jar", B), ("b/new.jar", B)]), - ); + let owned = set(&[("m/ours.jar", B), ("b/new.jar", B)]); + let out = edit(&text, |p| p == "never.jar", &owned); assert_eq!( out, format!( - "# pins\n{B} b/new.jar\n{A} c/user.jar\n{A} *d/user.jar\n{A}\te/user.jar\n\ + "{B} b/new.jar\n# pins\n{A} c/user.jar\n{A} *d/user.jar\n{A}\te/user.jar\n\ garbage\n{B} m/ours.jar\n" ) ); // Idempotent: a second pass changes nothing. - assert_eq!( - edit( - &out, - |_| false, - &set(&[("m/ours.jar", B), ("b/new.jar", B)]) - ), - out - ); + assert_eq!(edit(&out, |_| false, &owned), out); } #[test] @@ -258,6 +413,34 @@ mod tests { ); } + /// An owned line goes whatever its first field holds: a truncated or + /// SRI hash a past writer left must not outlive the pin. + #[test] + fn edit_owns_a_path_whatever_its_hash() { + for stale in [ + format!("sha256-q83vEjRWeJA= {OURS}\n"), + format!("{} {OURS}\n", &A[1..]), + format!("{} {OURS}\n", A.to_uppercase()), + ] { + let text = format!("# keep\n{stale}"); + assert_eq!(edit(&text, |p| p == OURS, &[]), "# keep\n", "{stale:?}"); + assert_eq!( + edit(&text, |_| false, &set(&[(OURS, B)])), + format!("# keep\n{B} {OURS}\n"), + "{stale:?}" + ); + } + } + + #[test] + fn edit_collapses_an_owned_path_to_one_line() { + let text = format!("{A} {OURS}\nkeep\n{A} *{OURS}\r\n{B} {OURS}"); + assert_eq!( + edit(&text, |_| false, &set(&[(OURS, B)])), + format!("{B} {OURS}\nkeep\n") + ); + } + #[test] fn edit_drops_only_named_entries_and_keeps_line_endings() { let text = format!("{A} gone.jar\r\n# note\r\n{A} kept.jar\r\n{B} *gone.jar\r\n"); @@ -267,8 +450,24 @@ mod tests { ); } + /// A comment block directly above a line stays with it when an entry + /// is inserted before that line; a blank line ends a block. #[test] - fn edit_appends_with_the_file_line_ending_after_an_unterminated_line() { + fn edit_inserts_ahead_of_the_comment_block_above_a_line() { + let text = format!("# about z\n# more\n{A} z.jar\n"); + assert_eq!( + edit(&text, |_| false, &set(&[("m.jar", B)])), + format!("{B} m.jar\n# about z\n# more\n{A} z.jar\n") + ); + let text = format!("# header\n\n{A} z.jar\n# trailing\n"); + assert_eq!( + edit(&text, |_| false, &set(&[("m.jar", B), ("zz.jar", B)])), + format!("# header\n\n{B} m.jar\n{A} z.jar\n# trailing\n{B} zz.jar\n") + ); + } + + #[test] + fn edit_inserts_with_the_dominant_line_ending() { assert_eq!( edit( &format!("{A} a.jar\r\nlast"), @@ -277,31 +476,93 @@ mod tests { ), format!("{A} a.jar\r\nlast\r\n{B} z.jar\r\n") ); + // One CRLF line among LF lines does not make the file CRLF. + let text = format!("{A} a.jar\r\n{A} b.jar\n{A} c.jar\n"); + assert_eq!( + edit(&text, |_| false, &set(&[("z.jar", B)])), + format!("{text}{B} z.jar\n") + ); + let text = format!("x\r\n{A} b.jar\r\n{A} c.jar\n"); + assert_eq!( + edit(&text, |_| false, &set(&[("a.jar", B)])), + format!("x\r\n{B} a.jar\r\n{A} b.jar\r\n{A} c.jar\n") + ); assert_eq!( edit("", |_| false, &set(&[("z.jar", B), ("a.jar", A)])), format!("{A} a.jar\n{B} z.jar\n") ); } + /// The rule decides on whole-line arguments only, and only when every + /// setting Maven reads this file by is set as hosted mode writes it (or + /// to an equivalent value). #[test] - fn switch_follows_maven_config_reading() { - let on = format!("-D{SWITCH_KEY}=true"); + fn config_enforces_needs_every_setting_maven_reads_the_file_by() { + let all = CONFIG_ARGS.join("\n"); + assert!(config_enforces(&all)); + assert!(config_enforces(&format!( + "{}\r\n", + CONFIG_ARGS.join("\r\n") + ))); + let switch_line = format!("-D{SWITCH_KEY}=true"); + let with = |from: &str, to: &str| { + assert!(all.contains(from), "{from}"); + all.replacen(from, to, 1) + }; for (config, want) in [ - (on.clone(), true), - (format!("{on}\r\n"), true), - (format!(" {on} "), true), - (format!("-D{SWITCH_KEY}"), true), - (format!("-D{SWITCH_KEY}=TRUE"), true), - (format!("--define={SWITCH_KEY}=true"), true), - (format!("-B -D{SWITCH_KEY}=true"), true), - (format!("{on}\n-D{SWITCH_KEY}=false"), false), - (format!("-D{SWITCH_KEY}=false\n{on}"), true), - (format!("# {on}"), false), - (format!("-D{SWITCH_KEY}=yes"), false), - (format!("-D{SWITCH_KEY}.failIfMissing=true"), false), + (with(&switch_line, &format!("-D{SWITCH_KEY}")), true), + (with(&switch_line, &format!("-D{SWITCH_KEY}=TRUE")), true), + ( + with( + "checksumAlgorithms=SHA-256", + "checksumAlgorithms=SHA-1, SHA-256", + ), + true, + ), + (with("failIfMissing=false", "failIfMissing=true"), true), + (with("originAware=false", "originAware=no"), true), + (format!("{all}\n-D{SWITCH_KEY}=false"), false), + (with(&switch_line, &format!("-D{SWITCH_KEY}=yes")), false), + ( + with("checksumAlgorithms=SHA-256", "checksumAlgorithms=SHA-1"), + false, + ), + (with("summaryFile=true", "summaryFile=false"), false), + (with("/.mvn/checksums", "/.mvn/sums"), false), + (with("originAware=false", "originAware=true"), false), + // Maven 3.9+ reads an argument per line: a token inside a + // longer line, or a commented line, is no setting. + (with(&switch_line, &format!("-B {switch_line}")), false), + (with(&switch_line, &format!("# {switch_line}")), false), + (switch_line.clone(), false), (String::new(), false), ] { - assert_eq!(switch_on(&config), want, "{config:?}"); + assert_eq!(config_enforces(&config), want, "{config}"); + } + // Each setting but failIfMissing defaults to "not this file". + for skip in CONFIG_ARGS.iter().filter(|a| !a.contains("failIfMissing")) { + let config: Vec<&str> = CONFIG_ARGS.iter().copied().filter(|a| a != skip).collect(); + assert!(!config_enforces(&config.join("\n")), "without {skip}"); } + assert!(config_has_written_settings(&format!( + "-D{SWITCH_KEY}=false" + ))); + assert!(!config_has_written_settings(&format!("-B {switch_line}"))); + } + + #[test] + fn enforced_follows_the_wrapper_maven() { + let all = CONFIG_ARGS.join("\n"); + let wrapper = |v: &str| { + format!("distributionUrl=https://repo/apache-maven/{v}/apache-maven-{v}-bin.zip\n") + }; + assert!(enforced(&all, None)); + assert!(enforced(&all, Some(&wrapper("3.9.4")))); + assert!(enforced( + &all, + Some("distributionUrl=https://x/maven-mvnd-1.0.2.zip\n") + )); + assert!(!enforced(&all, Some(&wrapper("3.9.3")))); + assert!(!enforced("", None)); } } diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 235c9ffea..c2e63f283 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -69,7 +69,7 @@ use crate::formats::gem::hosted::{ refresh_merged_gem_lock_remote, }; use crate::formats::gem::{lock_lists_direct_dependency, locked_specs as gem_locked_specs}; -use crate::formats::maven::trusted_checksums; +use crate::formats::maven::trusted_checksums::{self, CONFIG_ARGS as MVN_CONFIG_ARGS}; #[cfg(test)] use crate::formats::pnpm::hosted::pnpm_unrewritten_instances; use crate::formats::pnpm::plan_hosted; @@ -8477,25 +8477,6 @@ pub(crate) fn no_maven_or_gradle(files: &BTreeMap) -> bool { !files.contains_key("pom.xml") && !gradle::gradle_build_present(files) } -/// The six `-Daether.*` args that enable Maven's Trusted Checksums resolver -/// post-processor (twin of the TS `MVN_CONFIG_ARGS`), one per `.mvn/maven.config` -/// line. `failIfMissing=false` so a dependency without a committed checksum -/// still resolves (only a MISMATCH fails); origin-unaware so one checksum -/// matches the artifact from any repository. -const MVN_CONFIG_ARGS: &[&str] = &[ - TRUSTED_CHECKSUMS_ON, - "-Daether.artifactResolver.postProcessor.trustedChecksums.checksumAlgorithms=SHA-256", - "-Daether.artifactResolver.postProcessor.trustedChecksums.failIfMissing=false", - "-Daether.trustedChecksumsSource.summaryFile=true", - "-Daether.trustedChecksumsSource.summaryFile.basedir=${session.rootDirectory}/.mvn/checksums", - "-Daether.trustedChecksumsSource.summaryFile.originAware=false", -]; - -/// The resolver switch (the first [`MVN_CONFIG_ARGS`] line) that makes the -/// checksums file an enforced pin; without it the file is inert. Read back -/// by [`trusted_checksums::switch_on`], as every mode reads it. -const TRUSTED_CHECKSUMS_ON: &str = "-Daether.artifactResolver.postProcessor.trustedChecksums=true"; - pub(crate) const MVN_CONFIG: &str = ".mvn/maven.config"; pub(crate) const MVN_CHECKSUMS: &str = ".mvn/checksums/checksums.sha256"; /// The Maven Wrapper's settings file. Read only, never edited: its @@ -8503,52 +8484,6 @@ pub(crate) const MVN_CHECKSUMS: &str = ".mvn/checksums/checksums.sha256"; /// rewriter can tell when the Trusted Checksums files it writes are inert. pub(crate) const MVN_WRAPPER_PROPERTIES: &str = ".mvn/wrapper/maven-wrapper.properties"; -/// The first Maven release that enforces the `.mvn/*` Trusted Checksums -/// files (#258). 3.9.0 / 3.9.1 do not interpolate `${session.rootDirectory}` -/// in `maven.config`, so the summary file is never found; 3.9.2 / 3.9.3 -/// ignore `checksumAlgorithms=SHA-256` and check SHA-1 only. Older lines have -/// no Trusted Checksums post-processor at all. -const TRUSTED_CHECKSUMS_MIN_MAVEN: [u32; 3] = [3, 9, 4]; - -/// The Apache Maven release a `maven-wrapper.properties` pins, taken from its -/// `distributionUrl` (`…/apache-maven--bin.zip` or `.tar.gz`). None -/// for a missing key or any other distribution (e.g. mvnd), which then goes -/// unwarned. -fn maven_wrapper_version(props: &str) -> Option { - props.lines().find_map(|line| { - let line = line.trim(); - if line.starts_with('#') || line.starts_with('!') { - return None; - } - let rest = line.strip_prefix("distributionUrl")?; - if !rest.starts_with(|c: char| c == '=' || c == ':' || c.is_whitespace()) { - return None; - } - let value = rest - .trim_start() - .strip_prefix(['=', ':']) - .unwrap_or(rest) - .trim(); - let file = value.rsplit('/').next()?; - let version = file - .strip_prefix("apache-maven-")? - .strip_suffix(".zip") - .or_else(|| file.strip_prefix("apache-maven-")?.strip_suffix(".tar.gz"))? - .strip_suffix("-bin")?; - (!version.is_empty()).then(|| version.to_string()) - }) -} - -/// Whether Maven `version` enforces the Trusted Checksums files (≥ 3.9.4; -/// pre-release suffixes such as `-rc-6` compare as their release). -fn maven_enforces_trusted_checksums(version: &str) -> bool { - let mut parts = [0u32; 3]; - for (slot, part) in parts.iter_mut().zip(version.split(['.', '-'])) { - *slot = part.parse().unwrap_or(0); - } - parts >= TRUSTED_CHECKSUMS_MIN_MAVEN -} - /// Strip any `sha256-`/`sha256:` SRI-style prefix off a stored hash, leaving the /// bare lowercase hex Maven's trusted-checksums summary file expects (twin of /// the TS `bareSha256Hex`). @@ -8745,8 +8680,19 @@ fn rewrite_maven_pom( .clone() .unwrap_or_else(|| dep.name.clone()); let suffixed_version = ov.identifiers.maven_suffixed_version.clone(); - let pom_sha256 = ov.identifiers.maven_pom_sha256.clone(); - let jar_sha256 = dep.integrity.sha256.clone(); + // The jar and served-pom sha256 a Trusted Checksums pin needs, as + // the bare lowercase hex Maven compares; a grant hash of any other + // shape (an SRI base64 digest, a truncated hex) writes no pin. + let checksum_hashes = dep + .integrity + .sha256 + .as_deref() + .zip(ov.identifiers.maven_pom_sha256.as_deref()) + .map(|(jar, pom)| (bare_sha256_hex(jar), bare_sha256_hex(pom))); + let bad_checksum_hashes = checksum_hashes + .as_ref() + .is_some_and(|(jar, pom)| !is_hex64_lower(jar) || !is_hex64_lower(pom)); + let checksum_hashes = checksum_hashes.filter(|_| !bad_checksum_hashes); // The pom for the rest of this iteration; edits land in place. let Some(pom_text) = pom.as_mut() else { @@ -9100,7 +9046,7 @@ fn rewrite_maven_pom( }); } - if jar_sha256.is_some() && pom_sha256.is_some() { + if checksum_hashes.is_some() { pinned_jar_paths.push(crate::vendor::jvm::layout::artifact_path( &group_id, &artifact_id, @@ -9194,7 +9140,17 @@ fn rewrite_maven_pom( // Trusted Checksums: only when BOTH the jar sha256 and the served pom // sha256 are known. Two entries per dep — the jar and the pom — under // the SUFFIXED version's local-repo path. - if let (Some(jar), Some(pom_hash)) = (&jar_sha256, &pom_sha256) { + if bad_checksum_hashes { + result.warnings.push(RewriteWarning { + code: "redirect_maven_trusted_checksums_invalid_hash".into(), + detail: format!( + "{group_id}:{artifact_id}: the grant's sha256 is not 64 hex digits; \ + no Trusted Checksums pin written to {MVN_CHECKSUMS} (the -socket \ + version suffix still pins the patched artifact)" + ), + }); + } + if let Some((jar, pom_hash)) = &checksum_hashes { let (merged, conflicts) = merge_mvn_config(&mvn_config, &format!("{group_id}:{artifact_id}")); for conflict in conflicts { @@ -9228,7 +9184,7 @@ fn rewrite_maven_pom( None, "jar", ), - bare_sha256_hex(jar), + jar.clone(), )); checksum_entries.push(( crate::vendor::jvm::layout::artifact_path( @@ -9238,7 +9194,7 @@ fn rewrite_maven_pom( None, "pom", ), - bare_sha256_hex(pom_hash), + pom_hash.clone(), )); } } @@ -9282,15 +9238,16 @@ fn rewrite_maven_pom( // content pinning. Judged on the files as they stand after this run, so a // re-scan of an already-pinned project warns as well. let final_text = |rel: &str| result.files.get(rel).or_else(|| files.get(rel)); - let pin_in_place = final_text(MVN_CONFIG).is_some_and(|c| trusted_checksums::switch_on(c)) + let pin_in_place = final_text(MVN_CONFIG) + .is_some_and(|c| trusted_checksums::config_enforces(c)) && final_text(MVN_CHECKSUMS).is_some_and(|c| { let pinned = trusted_checksums::pinned(c); pinned_jar_paths.iter().any(|p| pinned.contains_key(p)) }); if let Some(version) = files .get(MVN_WRAPPER_PROPERTIES) - .and_then(|props| maven_wrapper_version(props)) - .filter(|v| pin_in_place && !maven_enforces_trusted_checksums(v)) + .and_then(|props| trusted_checksums::wrapper_maven_version(props)) + .filter(|v| pin_in_place && !trusted_checksums::maven_enforces(v)) { result.warnings.push(RewriteWarning { code: "redirect_maven_trusted_checksums_unenforced".into(), @@ -10917,33 +10874,38 @@ mod tests { #[test] fn maven_wrapper_version_parses_distribution_url() { assert_eq!( - maven_wrapper_version(&maven_wrapper("3.9.3")).as_deref(), + trusted_checksums::wrapper_maven_version(&maven_wrapper("3.9.3")).as_deref(), Some("3.9.3") ); assert_eq!( - maven_wrapper_version( + trusted_checksums::wrapper_maven_version( "distributionUrl : https://archive.apache.org/dist/maven/maven-3/3.9.2/binaries/apache-maven-3.9.2-bin.tar.gz\r\n" ) .as_deref(), Some("3.9.2") ); assert_eq!( - maven_wrapper_version( + trusted_checksums::wrapper_maven_version( "! comment\r\ndistributionUrl=https\\://repo/apache-maven-4.0.0-rc-6-bin.zip\r\n" ) .as_deref(), Some("4.0.0-rc-6") ); - assert_eq!(maven_wrapper_version("wrapperVersion=3.3.2\n"), None); assert_eq!( - maven_wrapper_version("distributionUrlOld=https://x/apache-maven-3.9.3-bin.zip\n"), + trusted_checksums::wrapper_maven_version("wrapperVersion=3.3.2\n"), None ); - assert!(!maven_enforces_trusted_checksums("3.9.3")); - assert!(maven_enforces_trusted_checksums("3.9.4")); - assert!(maven_enforces_trusted_checksums("3.10.0-rc-1")); - assert!(maven_enforces_trusted_checksums("4.0.0")); - assert!(!maven_enforces_trusted_checksums("3.8.9")); + assert_eq!( + trusted_checksums::wrapper_maven_version( + "distributionUrlOld=https://x/apache-maven-3.9.3-bin.zip\n" + ), + None + ); + assert!(!trusted_checksums::maven_enforces("3.9.3")); + assert!(trusted_checksums::maven_enforces("3.9.4")); + assert!(trusted_checksums::maven_enforces("3.10.0-rc-1")); + assert!(trusted_checksums::maven_enforces("4.0.0")); + assert!(!trusted_checksums::maven_enforces("3.8.9")); } #[test] @@ -30336,6 +30298,48 @@ mod owned_pin_generation_matrix { assert!(again.files.is_empty(), "{:?}", again.files.keys()); } + /// A superseded generation's checksum line goes on re-pin whatever its + /// hash holds (a past writer's SRI or truncated digest included), and a + /// grant whose hash is not 64 hex digits writes no checksum line at all. + #[test] + fn maven_checksums_owned_lines_go_whatever_their_hash() { + let tok = "22222222-3333-4444-8555-666666666666"; + let (clean, _) = plan(&pom_project(), &maven(A, tok)); + for stale in ["sha256-q83vEjRWeJA=", "a".repeat(63).as_str()] { + let mut wired = clean.clone(); + let mangled = wired[MVN_CHECKSUMS].replace(&"a".repeat(64), stale); + wired.insert(MVN_CHECKSUMS.to_string(), mangled); + let (repinned, _) = plan(&wired, &maven(B, tok)); + let checksums = &repinned[MVN_CHECKSUMS]; + assert!( + !checksums.contains("socket.aaaaaaaa"), + "{stale}: {checksums}" + ); + assert_eq!(checksums.lines().count(), 2, "{stale}: {checksums}"); + } + + let mut bad = maven(A, tok); + bad.integrity.sha256 = Some("sha256-q83vEjRWeJA=".into()); + let (wired, result) = plan(&pom_project(), &bad); + assert!( + wired["pom.xml"].contains("3.12.0-socket.aaaaaaaa"), + "the version pin still lands" + ); + assert!( + !wired.contains_key(MVN_CHECKSUMS), + "{:?}", + wired.get(MVN_CHECKSUMS) + ); + assert!( + result + .warnings + .iter() + .any(|w| w.code == "redirect_maven_trusted_checksums_invalid_hash"), + "{:?}", + result.warnings + ); + } + /// #266: a rotated grant token (same uuid, new token path segment) /// refreshes the repository URL in place. #[test] diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/maven.rs b/crates/socket-patch-core/src/patch/redirect/upstream/maven.rs index 5fa9f7797..ff2e815d9 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/maven.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/maven.rs @@ -26,7 +26,7 @@ use std::collections::BTreeMap; use regex::Regex; use super::{by_uuid, read_or_refuse, refuse_all_in, Ctx, FormatResult, HostedPin, View}; -use crate::formats::maven::{maven_config_properties, trusted_checksums}; +use crate::formats::maven::trusted_checksums; use crate::patch::redirect::{ generation, maven_repositories_with_id, maven_tag_inner_range, maven_tag_text_in, remove_maven_repository, MAVEN_DEPENDENCY_BLOCK_RE, MVN_CHECKSUMS, MVN_CONFIG, MVN_CONFIG_ARGS, @@ -420,16 +420,12 @@ async fn restore_mvn( } let config = view.read(&config_rel).await.ok().flatten(); let written_config = format!("{}\n", MVN_CONFIG_ARGS.join("\n")); - // Read as every mode reads `maven.config` (the switch rule's reader, - // see `trusted_checksums::switch_on`): any resolver setting hosted mode - // writes counts, whatever its value or spelling. - let has_resolver_lines = config.as_deref().is_some_and(|c| { - let props = maven_config_properties(c); - MVN_CONFIG_ARGS - .iter() - .filter_map(|arg| arg.strip_prefix("-D")?.split_once('=')) - .any(|(key, _)| props.contains_key(key)) - }); + // Whether settings hosted mode may have written are left over, read as + // `trusted_checksums::config_enforces` reads them but by presence: a + // partial or switched-off set is still lines the user may want gone. + let has_resolver_lines = config + .as_deref() + .is_some_and(trusted_checksums::config_has_written_settings); if rest.trim().is_empty() { view.remove(&sums_rel); if config.as_deref() == Some(written_config.as_str()) { @@ -863,6 +859,31 @@ mod tests { assert!(after.contains_key(".mvn/maven.config")); } + /// The pin's lines go whatever hash a past writer left on them (an SRI + /// or truncated digest): the unwind must not stop early and leave them. + #[tokio::test] + async fn checksums_unwind_drops_owned_lines_whatever_their_hash() { + let sums = fixture("basic", "expected", ".mvn/checksums/checksums.sha256") + .replace(&"c".repeat(64), "sha256-q83vEjRWeJA=") + .replace(&"d".repeat(64), &"d".repeat(40)); + let files = vec![ + ("pom.xml", fixture("basic", "expected", "pom.xml")), + ( + ".mvn/maven.config", + fixture("basic", "expected", ".mvn/maven.config"), + ), + (".mvn/checksums/checksums.sha256", sums), + ]; + let (outcome, after) = run(&files).await; + assert_eq!(outcome.pins[0].status, PinStatus::Restored); + assert!( + !after.contains_key(".mvn/checksums/checksums.sha256"), + "{:?}", + after.get(".mvn/checksums/checksums.sha256") + ); + assert!(!after.contains_key(".mvn/maven.config")); + } + /// The unwind reads the checksums file as the rewriter does: the pin's /// lines go in any spelling a tool rewrote them to, every other line /// (one space, a `*` marker, a comment) keeps its bytes and CRLF, and diff --git a/crates/socket-patch-core/src/vex/discover/maven.rs b/crates/socket-patch-core/src/vex/discover/maven.rs index 52ce91c1f..eab078b24 100644 --- a/crates/socket-patch-core/src/vex/discover/maven.rs +++ b/crates/socket-patch-core/src/vex/discover/maven.rs @@ -105,9 +105,9 @@ use super::{ }; use crate::formats::maven::{ is_maven_coordinate, is_maven_version_text, maven_config_properties, may_shadow, - parse_pom_with, split_socket_version, trusted_checksums, Pom, PomDep, PomRepo, + parse_pom_with, split_socket_version, Pom, PomDep, PomRepo, }; -use crate::patch::redirect::{MVN_CHECKSUMS, MVN_CONFIG}; +use crate::patch::redirect::{MVN_CHECKSUMS, MVN_CONFIG, MVN_WRAPPER_PROPERTIES}; use crate::vendor::jvm::layout; use crate::vendor::lock_inventory::LockIntegrity; use crate::vendor::maven_repo::{sha1_sidecar_matches, VENDOR_REPO_URL_PREFIX}; @@ -377,7 +377,7 @@ async fn extract_hosted( continue; }; if checksums.is_none() { - checksums = Some(pinned_checksums(ctx, out).await); + checksums = Some(trusted_checksums(ctx, out).await); } let jar = layout::artifact_path(group, artifact, pinned, None, "jar"); let integrity = checksums @@ -396,20 +396,22 @@ async fn extract_hosted( } /// `path -> sha256 hex` from `.mvn/checksums/checksums.sha256`, or empty -/// when `.mvn/maven.config` does not switch the trusted-checksums resolver -/// on (the file is then inert and pins nothing). Both files are read as the -/// hosted rewriter reads them ([`trusted_checksums`]): only a line Maven -/// itself enforces attests. -async fn pinned_checksums(ctx: &DiscoverCtx<'_>, out: &mut Discovery) -> BTreeMap { +/// when Maven does not enforce it: `.mvn/maven.config` lacks a setting it +/// reads the file by, or the Maven Wrapper pins a Maven older than 3.9.4. +/// Judged by the hosted rewriter's own rule, and only a line Maven checks +/// attests (`formats::maven::trusted_checksums`). +async fn trusted_checksums(ctx: &DiscoverCtx<'_>, out: &mut Discovery) -> BTreeMap { + use crate::formats::maven::trusted_checksums::{enforced, pinned}; let Some(config) = ctx.read_text(MVN_CONFIG, out).await else { return BTreeMap::new(); }; - if !trusted_checksums::switch_on(&config) { + let wrapper = ctx.read_text(MVN_WRAPPER_PROPERTIES, out).await; + if !enforced(&config, wrapper.as_deref()) { return BTreeMap::new(); } ctx.read_text(MVN_CHECKSUMS, out) .await - .map(|text| trusted_checksums::pinned(&text)) + .map(|text| pinned(&text)) .unwrap_or_default() } @@ -827,11 +829,12 @@ mod tests { assert!(out.diagnostics.is_empty(), "{case}: {:?}", out.diagnostics); let r = &out.refs[0]; assert_eq!(r.source_file, std::path::PathBuf::from("pom.xml"), "{case}"); - assert_eq!( - r.locked_integrity, - Some(LockIntegrity::Sha256Hex(FX_JAR_SHA.into())), - "{case}" - ); + // mvn-config-merge keeps the user's `originAware=true`, under + // which Maven reads a per-repository summary file, not this one: + // its checksums pin nothing. + let integrity = + (case != "mvn-config-merge").then(|| LockIntegrity::Sha256Hex(FX_JAR_SHA.into())); + assert_eq!(r.locked_integrity, integrity, "{case}"); assert!(!r.integrity_required, "{case}"); assert!(r.lockfile_basis_ok(), "{case}"); assert!( @@ -891,39 +894,74 @@ mod tests { ); } - /// The switch and the summary file are read as the hosted rewriter - /// reads them: a bare or CRLF `-D` switch is on, and only a jar line in - /// the ` ` shape Maven enforces attests. A tolerated - /// spelling (one space, a `*` marker) is a line Maven skips, so it - /// pins nothing. + /// Integrity attests only what Maven enforces, judged by the hosted + /// rewriter's own rule: every `maven.config` setting Maven reads the + /// summary file by, each a whole-line argument (CRLF and a bare `-Dk` + /// switch count); no wrapper Maven older than 3.9.4; and a jar line in + /// the exact lowercase ` ` shape. A tolerated spelling + /// (one space, a `*` marker, uppercase hex) is a line Maven skips or + /// fails on, so it pins nothing. #[tokio::test] - async fn checksums_attest_only_lines_maven_enforces() { + async fn checksums_attest_only_what_maven_enforces() { + use crate::formats::maven::trusted_checksums::{CONFIG_ARGS, SWITCH_KEY}; let jar = "org/slf4j/slf4j-api/1.7.36-socket.77777777/slf4j-api-1.7.36-socket.77777777.jar"; - let switch = format!("-D{}", crate::formats::maven::trusted_checksums::SWITCH_KEY); - for (config, sums, attests) in [ + let all = CONFIG_ARGS.join("\n"); + let switch = format!("-D{SWITCH_KEY}=true"); + let wrapper = |v: &str| { + format!("distributionUrl=https://repo/apache-maven/{v}/apache-maven-{v}-bin.zip\n") + }; + let line = format!("{FX_JAR_SHA} {jar}\n"); + let upper = FX_JAR_SHA.to_uppercase(); + for (config, wrapper, sums, attests) in [ ( - format!("{switch}\r\n"), + CONFIG_ARGS.join("\r\n"), + None, format!("{FX_JAR_SHA} {jar}\r\n"), true, ), - (switch.clone(), format!("# pins\n{FX_JAR_SHA} {jar}"), true), ( - format!("{switch}=false"), - format!("{FX_JAR_SHA} {jar}\n"), + all.replace(&switch, &format!("-D{SWITCH_KEY}")), + None, + format!("# pins\n{FX_JAR_SHA} {jar}"), + true, + ), + (all.clone(), Some(wrapper("3.9.4")), line.clone(), true), + (all.clone(), Some(wrapper("3.9.3")), line.clone(), false), + (switch.clone(), None, line.clone(), false), + ( + all.replace(&switch, &format!("-B {switch}")), + None, + line.clone(), false, ), - (switch.clone(), format!("{FX_JAR_SHA} {jar}\n"), false), - (switch.clone(), format!("{FX_JAR_SHA} *{jar}\n"), false), + ( + all.replace("originAware=false", "originAware=true"), + None, + line.clone(), + false, + ), + ( + format!("{all}\n-D{SWITCH_KEY}=false"), + None, + line.clone(), + false, + ), + (all.clone(), None, format!("{FX_JAR_SHA} {jar}\n"), false), + (all.clone(), None, format!("{FX_JAR_SHA} *{jar}\n"), false), + (all.clone(), None, format!("{upper} {jar}\n"), false), ] { let p = fixture("basic"); p.write(".mvn/maven.config", config.clone()); + if let Some(props) = &wrapper { + p.write(".mvn/wrapper/maven-wrapper.properties", props.clone()); + } p.write(".mvn/checksums/checksums.sha256", sums.clone()); let out = run(&p).await; assert_refs(&out, &[(FX_PURL, FX_UUID, WiringMode::Hosted)]); assert_eq!( out.refs[0].locked_integrity, attests.then(|| LockIntegrity::Sha256Hex(FX_JAR_SHA.into())), - "{config:?} / {sums:?}" + "{config:?} / {wrapper:?} / {sums:?}" ); } } diff --git a/crates/socket-patch-core/tests/fixtures/vex-discover-golden/redirect-maven.json b/crates/socket-patch-core/tests/fixtures/vex-discover-golden/redirect-maven.json index 5d05aa7e7..9d433d65e 100644 --- a/crates/socket-patch-core/tests/fixtures/vex-discover-golden/redirect-maven.json +++ b/crates/socket-patch-core/tests/fixtures/vex-discover-golden/redirect-maven.json @@ -367,7 +367,7 @@ "mode": "hosted", "source_file": "pom.xml", "artifact_rel": null, - "locked_integrity": "Sha256Hex(\"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\")", + "locked_integrity": null, "integrity_required": false, "url": "https://patch.socket.dev/patch-registry/maven/11111111-1111-1111-1111-111111111111/77777777-7777-7777-7777-777777777777/maven2", "lockfile_basis_ok": true From a53a0567642b3bc86ca1d9ae85ed6df86d49f7c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 16:26:03 +0000 Subject: [PATCH 3/3] Read maven.config one way in the writer too; drop broken own lines Second review round on the Trusted Checksums change. - The hosted writer (`merge_mvn_config`) still keyed settings by the raw `line[..=eq]` prefix, so a user's indented, `--define k=v` or quoted setting was not seen, hosted appended its own line after it, and that later line won, silently overriding the user's value. It now reads the file with `maven_config_line_properties`, the reader discovery judges enforcement with, and reports such a setting as `redirect_maven_trusted_checksums_conflict`. - A grant whose sha256 is not 64 hex digits writes no checksum line, but a line an earlier release wrote for the same suffixed version (its hash mangled) stayed, and Maven fails the build on a mismatched line. Those paths are now dropped, on a fresh pin and on a re-run alike. - The rollback's leftover-settings warning keys on presence again (`maven_config_properties`, which also sees a `-Dk=v` token joined onto a longer line), not on Maven 3.9's enforcement reading. - The discovery module doc and docs/ecosystems.md describe the stricter attestation rule and the invalid-hash warning. Co-Authored-By: Claude Opus 5.5 --- .../src/formats/maven/trusted_checksums.rs | 13 +- .../src/patch/redirect/mod.rs | 119 ++++++++++++++---- .../src/vex/discover/maven.rs | 5 +- docs/ecosystems.md | 7 +- 4 files changed, 112 insertions(+), 32 deletions(-) diff --git a/crates/socket-patch-core/src/formats/maven/trusted_checksums.rs b/crates/socket-patch-core/src/formats/maven/trusted_checksums.rs index bb105ecd7..8216ce34a 100644 --- a/crates/socket-patch-core/src/formats/maven/trusted_checksums.rs +++ b/crates/socket-patch-core/src/formats/maven/trusted_checksums.rs @@ -90,10 +90,12 @@ pub(crate) fn config_enforces(config: &str) -> bool { && get(ORIGIN_AWARE_KEY).is_some_and(|v| !v.eq_ignore_ascii_case("true")) } -/// Whether any setting hosted mode writes is present in `config` (read as -/// [`config_enforces`] reads it), whatever its value. +/// Whether any setting hosted mode writes is present in `config`, whatever +/// its value, in any spelling some Maven reads (a whole line, or a token of +/// the pre-3.9 whitespace split): the rollback's "leftover settings" +/// warning keys on presence, not on enforcement. pub(crate) fn config_has_written_settings(config: &str) -> bool { - let props = maven_config_line_properties(config); + let props = crate::formats::maven::maven_config_properties(config); CONFIG_ARGS .iter() .filter_map(|arg| arg.strip_prefix("-D")?.split_once('=')) @@ -547,7 +549,10 @@ mod tests { assert!(config_has_written_settings(&format!( "-D{SWITCH_KEY}=false" ))); - assert!(!config_has_written_settings(&format!("-B {switch_line}"))); + // Presence, not enforcement: a token joined onto a longer line is + // still a leftover the rollback warns about. + assert!(config_has_written_settings(&format!("-B {switch_line}"))); + assert!(!config_has_written_settings(&format!("# {switch_line}"))); } #[test] diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index c2e63f283..04668a346 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -8693,6 +8693,22 @@ fn rewrite_maven_pom( .as_ref() .is_some_and(|(jar, pom)| !is_hex64_lower(jar) || !is_hex64_lower(pom)); let checksum_hashes = checksum_hashes.filter(|_| !bad_checksum_hashes); + if let Some(suffixed) = suffixed_version.as_deref().filter(|_| bad_checksum_hashes) { + // No pin is written for the suffixed version, so none may stay + // either: an earlier release wrote such a grant's hash mangled + // (lowercased base64), and Maven fails the build on a line + // whose hash does not match. Dropped whether or not the version + // pin lands this run (a re-run with the pin present too). + for ext in ["jar", "pom"] { + checksum_drops.push(crate::vendor::jvm::layout::artifact_path( + &group_id, + &artifact_id, + suffixed, + None, + ext, + )); + } + } // The pom for the rest of this iteration; edits land in place. let Some(pom_text) = pom.as_mut() else { @@ -9306,37 +9322,28 @@ pub(crate) fn maven_repository_removal(pom: &str, id: &str) -> Option<(usize, us } /// Merge trusted-checksums resolver args into `.mvn/maven.config` (one arg per -/// line). Dedupe by the `-Dkey=` prefix: an arg whose key is already present is -/// left untouched (existing value wins). Returns the merged text + any conflict -/// messages (a pre-existing SAME key with a DIFFERENT value). Twin of the TS -/// `mergeMvnConfig`. +/// line). A setting is present when Maven 3.9+ reads it from the file +/// ([`crate::formats::maven::maven_config_line_properties`], the reader +/// discovery's [`trusted_checksums::config_enforces`] uses: `-Dk=v`, +/// `--define k=v`, indented or quoted lines, the last definition winning). +/// A present setting is left untouched (existing value wins); a different +/// value is reported as a conflict. Returns the merged text + those +/// conflict messages. Twin of the TS `mergeMvnConfig`. fn merge_mvn_config(existing: &str, coordinate: &str) -> (String, Vec) { - let lines: Vec<&str> = if existing.is_empty() { - vec![] - } else { - existing.split('\n').collect() - }; + let present = crate::formats::maven::maven_config_line_properties(existing); let mut conflicts = vec![]; - let key_of = - |line: &str| -> Option { line.find('=').map(|eq| line[..=eq].to_string()) }; - let mut present: std::collections::HashMap = std::collections::HashMap::new(); - for line in &lines { - if let Some(key) = key_of(line) { - present.insert(key, (*line).to_string()); - } - } let mut appended: Vec<&str> = vec![]; for arg in MVN_CONFIG_ARGS { - let key = key_of(arg).expect("every MVN_CONFIG_ARGS entry contains '='"); - match present.get(&key) { - None => { - appended.push(arg); - present.insert(key, (*arg).to_string()); - } - Some(existing_line) if existing_line.trim() != *arg => { + let (key, value) = arg + .strip_prefix("-D") + .and_then(|def| def.split_once('=')) + .expect("every MVN_CONFIG_ARGS entry is -Dkey=value"); + match present.get(key) { + None => appended.push(arg), + Some(existing_value) if existing_value != value => { conflicts.push(format!( - "{coordinate}: {MVN_CONFIG} already sets {key} to a different value ({}); leaving it as-is", - existing_line.trim() + "{coordinate}: {MVN_CONFIG} already sets -D{key}= to a different value \ + (-D{key}={existing_value}); leaving it as-is" )); } Some(_) => {} @@ -10929,6 +10936,38 @@ mod tests { assert!(warning_codes(&r).contains(&"redirect_maven_trusted_checksums_conflict")); } + /// The writer sees a user setting in every spelling Maven 3.9+ reads + /// (the reader discovery judges enforcement with): an indented line, + /// `--define k=v`, `-D k=v`, a quoted argument. Appending ours after + /// one would silently override the user's value (the later line wins). + #[test] + fn maven_config_conflict_in_any_spelling_keeps_the_user_value() { + for user in [ + " -Daether.trustedChecksumsSource.summaryFile.originAware=true", + "--define aether.trustedChecksumsSource.summaryFile.originAware=true", + "--define=aether.trustedChecksumsSource.summaryFile.originAware=true", + "\"-Daether.trustedChecksumsSource.summaryFile.originAware=true\"", + ] { + let mut files = BTreeMap::new(); + files.insert( + "pom.xml".to_string(), + pom_with_dep("\n 1.7.36", ""), + ); + files.insert(".mvn/maven.config".to_string(), format!("{user}\n")); + let r = rewrite_registry_redirect(&files, &[maven_override()]); + let config = r.files.get(".mvn/maven.config").expect("config"); + assert!(!config.contains("originAware=false"), "{user}: {config}"); + assert!( + warning_codes(&r).contains(&"redirect_maven_trusted_checksums_conflict"), + "{user}" + ); + assert!( + !trusted_checksums::config_enforces(config), + "{user}: the user's originAware=true still governs" + ); + } + } + /// Legacy same-GAV fallback (no suffixed version): only the repository is /// added, no `.mvn` files, and the same_gav_fallback warning is emitted. #[test] @@ -30338,6 +30377,34 @@ mod owned_pin_generation_matrix { "{:?}", result.warnings ); + + // An earlier release wrote such a grant's hash mangled for the same + // suffixed version: a re-run (the version pin already present) and + // a fresh pin both drop it, since Maven fails on a mismatched line. + let mut wired = clean.clone(); + let mangled = wired[MVN_CHECKSUMS].replace(&"a".repeat(64), "q83vejrweja="); + wired.insert(MVN_CHECKSUMS.to_string(), mangled); + let (rerun, _) = plan(&wired, &bad); + assert!( + !rerun + .get(MVN_CHECKSUMS) + .is_some_and(|c| c.contains("socket.aaaaaaaa")), + "{:?}", + rerun.get(MVN_CHECKSUMS) + ); + let mut fresh = pom_project(); + fresh.insert( + MVN_CHECKSUMS.to_string(), + clean[MVN_CHECKSUMS].replace(&"a".repeat(64), "q83vejrweja="), + ); + let (fresh, _) = plan(&fresh, &bad); + assert!( + !fresh + .get(MVN_CHECKSUMS) + .is_some_and(|c| c.contains("socket.aaaaaaaa")), + "{:?}", + fresh.get(MVN_CHECKSUMS) + ); } /// #266: a rotated grant token (same uuid, new token path segment) diff --git a/crates/socket-patch-core/src/vex/discover/maven.rs b/crates/socket-patch-core/src/vex/discover/maven.rs index eab078b24..e69e5b2f5 100644 --- a/crates/socket-patch-core/src/vex/discover/maven.rs +++ b/crates/socket-patch-core/src/vex/discover/maven.rs @@ -57,7 +57,10 @@ //! ` ///-.jar` under the SUFFIXED version, made //! effective by `-Daether.artifactResolver.postProcessor.trustedChecksums=true` //! in `.mvn/maven.config`. That jar line becomes -//! [`LockIntegrity::Sha256Hex`] (only while the resolver switch is on). +//! [`LockIntegrity::Sha256Hex`] only while Maven enforces the file +//! ([`crate::formats::maven::trusted_checksums::enforced`]: every setting +//! hosted writes, read as Maven 3.9+ reads `maven.config`, and no wrapper +//! pinning a Maven older than 3.9.4). //! `integrity_required` is FALSE: the rewriter writes checksums only when //! both hashes are known, and the suffixed version itself is the //! fail-closed pin (no other repository serves it; the Socket repository is diff --git a/docs/ecosystems.md b/docs/ecosystems.md index b03dc6665..762915850 100644 --- a/docs/ecosystems.md +++ b/docs/ecosystems.md @@ -881,7 +881,12 @@ Honest limits of the Maven and NuGet flows — documented behavior, not bugs: ([MNG-8182](https://issues.apache.org/jira/browse/MNG-8182)). The args are `originAware=false` and `failIfMissing=false`, so one checksum matches the artifact from any repository and a dependency with no committed checksum still resolves — only a - *mismatch* fails. + *mismatch* fails. A grant whose sha256 is not 64 hex digits writes no checksum line + (and drops any line an earlier run wrote for that suffixed version) and warns + `redirect_maven_trusted_checksums_invalid_hash`. `vex` attests a pin's checksum only + when `maven.config` enforces the file as Maven 3.9+ reads it (all five settings above + with the values hosted writes; `failIfMissing` aside) and the wrapper, if any, pins + Maven 3.9.4 or later. * **A Maven project's scan is scoped to its dependency graph.** The Maven local repository is shared by every project on the machine, so in project mode (no `--global` / `--global-prefix`) a Maven build's crawl (a `pom.xml`, no Gradle or