diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index a3ed909d6..696f603b1 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -2631,7 +2631,7 @@ with `reason: : `: |---|---|---| | `vendor_jvm_shape_unsupported` | refusal, nothing written | `gradle_below_6_8`, `android_or_kmp` (also `available-at` module redirects), `gradle_exclusive_content_conflict` (a user rule claiming the module, in any script), `gradle_range_excludes_vendored` (no declared selector admits the vendored version), `gradle_verification_unparseable`, `gradle_index_unreadable`, `build_file_unreadable` (including a non-UTF-8 settings file), `build_file_outside_root`, `not_build_root` (run from a directory an ancestor settings file includes or may include, from a project with no settings file of its own below one, or below an ancestor settings file that is not UTF-8; `repair` refuses there too), `no_build_file` (no `pom.xml`, Gradle, sbt or scala-cli build at the root), `legacy_maven_root` (the root's ledger still holds a pre-v5 single-POM `maven_pom_repository` entry; the whole root is refused until `vendor --revert`, then vendor again) | | `vendor_jvm_upstream_unavailable` | refusal | `classifier_unavailable` (a declared classifier jar no cache or registry has), `module_unavailable` (the pom declares Gradle module metadata that cannot be sourced), `pom_unavailable`, `verification_metadata_unavailable` | -| `vendor_jvm_degraded` | applied, VEX withheld | `gradle_unscanned_build_logic`, `unwired_build_logic` (a nonliteral included build), `settings_plugins_unwired`, `classifier_unpatched_copy` (a classifier jar carries an unpatched copy of a patched member), `verification_parent_chain_unhandled`, `conflicting_managed_version` / `management_unresolved` (Maven reactor: an imported BOM or an out-of-checkout parent manages the artifact at another version, or its POM is unavailable; that local root is not pinned) | +| `vendor_jvm_degraded` | applied, VEX withheld | `gradle_unscanned_build_logic`, `unwired_build_logic` (a nonliteral included build), `settings_plugins_unwired`, `classifier_unpatched_copy` (a classifier jar carries an unpatched copy of a patched member), `verification_parent_chain_unhandled`, `gradle_lock_overrides_vendored` (a dependency lock of the build, buildscript and settings classpath locks included, pins the module at a version no vendored row serves), `gradle_settings_lock_unrouted` (a `settings-gradle.lockfile` locks the module but that build's settings script does not hold the in-block repository entry for it), `gradle_lock_unreadable` (a lock file, or a followed custom `lockFile`, is missing, unreadable, outside the checkout or not UTF-8), `gradle_custom_lock_file` (a `lockFile` location that cannot be followed statically: anything but a literal `file(…)` in a project's own build script), `conflicting_managed_version` / `management_unresolved` (Maven reactor: an imported BOM or an out-of-checkout parent manages the artifact at another version, or its POM is unavailable; that local root is not pinned) | | `vendor_jvm_note` | applied, informational | `range_declared` (a range, prefix or rich selector lists versions from the derived `maven-metadata.xml`), `ide_sources_unavailable` | | `vendor_jvm_upstream_unverified` | warning | Upstream metadata was taken offline and not authenticated against registry checksums. | diff --git a/crates/socket-patch-core/src/gradle/graph.rs b/crates/socket-patch-core/src/gradle/graph.rs index f44d6022a..70a93c622 100644 --- a/crates/socket-patch-core/src/gradle/graph.rs +++ b/crates/socket-patch-core/src/gradle/graph.rs @@ -1354,19 +1354,135 @@ impl ScriptGraph { /// The first script setting a custom dependency-lock file location /// (`lockFile = …` / `lockFile.set(…)`). pub fn custom_lock_file(&self) -> Option { - self.scripts.iter().find_map(|s| { + self.custom_lock_files() + .into_iter() + .next() + .map(|c| c.script) + } + + /// Every custom dependency-lock file location (`lockFile = …` / + /// `lockFile.set(…)`), in script order. See [`CustomLockFile::path`] + /// for when the location can be followed. + pub fn custom_lock_files(&self) -> Vec { + let mut out = Vec::new(); + for s in &self.scripts { let toks = dsl::tokens(&s.text, s.dsl); - (0..toks.len()) - .any(|i| { - is_ident(toks.get(i), "lockFile") - && ((is_punct(toks.get(i + 1), b'=') && !is_punct(toks.get(i + 2), b'=')) - || (is_punct(toks.get(i + 1), b'.') - && is_ident(toks.get(i + 2), "set") - && is_punct(toks.get(i + 3), b'('))) + // A project's own build script: `file(…)` resolves against it. + let project_dir = (s.kind == ScriptKind::Build) + .then(|| { + self.builds + .iter() + .flat_map(|b| &b.projects) + .find(|p| p.build_script.as_deref() == Some(s.rel.as_str())) + .map(|p| p.dir.clone()) }) - .then(|| s.rel.clone()) - }) + .flatten(); + // Blocks that configure other projects resolve `file(…)` + // against those. + let foreign: Vec<(usize, usize)> = ["allprojects", "subprojects", "configure"] + .iter() + .flat_map(|name| all_blocks(&toks, name)) + .chain( + (0..toks.len()) + .filter(|&i| is_ident(toks.get(i), "project")) + .filter_map(|i| { + let open = (i + 1..toks.len().min(i + 8)) + .find(|&j| is_punct(toks.get(j), b'{'))?; + matching_close(&toks, open).map(|c| (open, c)) + }), + ) + .collect(); + for i in 0..toks.len() { + if !is_ident(toks.get(i), "lockFile") { + continue; + } + let value = if is_punct(toks.get(i + 1), b'=') && !is_punct(toks.get(i + 2), b'=') { + i + 2 + } else if is_punct(toks.get(i + 1), b'.') + && is_ident(toks.get(i + 2), "set") + && is_punct(toks.get(i + 3), b'(') + { + i + 4 + } else { + continue; + }; + let path = project_dir + .as_deref() + .filter(|_| !foreign.iter().any(|(o, c)| i > *o && i < *c)) + .and_then(|dir| { + let arg = file_call_arg(&toks, value)?; + resolve_rel("", dir, &arg) + }); + out.push(CustomLockFile { + script: s.rel.clone(), + line: line_of(&s.text, toks[i].start), + path, + }); + } + } + out + } + + /// Every lock file of every build ([`Self::lockfile_paths`]) with its + /// parsed content: `None` for a file the listing shows that `read` + /// cannot give as text (unreadable, not UTF-8, refused). The one lock + /// inventory the hosted and vendored backends both check. + pub fn read_locks( + &self, + list: ListFn<'_>, + read: TextReadFn<'_>, + ) -> Vec<(String, Option)> { + self.lockfile_paths(list) + .into_iter() + .map(|rel| { + let state = read(&rel).map(|text| locks::parse(&text)); + (rel, state) + }) + .collect() + } +} + +/// A custom dependency-lock file location ([`ScriptGraph::custom_lock_files`]). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CustomLockFile { + /// The script that sets it. + pub script: String, + /// 1-based line of `lockFile`. + pub line: usize, + /// The lock file, relative to the graph's read root, when the value is + /// `file('')` with a literal path (or `"$projectDir/"`) in + /// a project's own build script, outside any `allprojects`, + /// `subprojects`, `configure` or `project(…)` block, and the path stays + /// inside the read root. `None`: it cannot be followed statically. + pub path: Option, +} + +/// The path argument of `file()` at `toks[at..]`, the call +/// closing the value: a literal, or a `"$projectDir/…"` / +/// `"${projectDir}/…"` string with no other interpolation (made +/// project-relative). +fn file_call_arg(toks: &[Token], at: usize) -> Option { + if !is_ident(toks.get(at), "file") + || !is_punct(toks.get(at + 1), b'(') + || !is_punct(toks.get(at + 3), b')') + || is_punct(toks.get(at + 4), b'.') + { + return None; + } + if let Some(lit) = literal_of(toks.get(at + 2)) { + return Some(lit.to_string()); } + let Some(Token { + tok: Tok::Str { value, .. }, + .. + }) = toks.get(at + 2) + else { + return None; + }; + let rest = value + .strip_prefix("$projectDir/") + .or_else(|| value.strip_prefix("${projectDir}/"))?; + (!rest.contains('$') && !rest.contains('\\')).then(|| rest.to_string()) } /// The subproject relation of a directory to an ancestor build: whether @@ -2656,6 +2772,64 @@ other = { group = \"x\", name = \"y\", version = \"1\" } )]); assert_eq!(g.custom_lock_file(), None); + // The location is followed only from a project's own build script, + // as a literal (or `$projectDir/`) `file(…)` inside the root. + let path_of = |files: &[(&str, &str)]| -> Vec> { + graph(files) + .custom_lock_files() + .into_iter() + .map(|c| c.path) + .collect() + }; + let lock = |v: &str| format!("dependencyLocking {{ lockFile = {v} }}\n"); + for (value, want) in [ + ("file('locks/app.lockfile')", Some("app/locks/app.lockfile")), + ( + "file(\"$projectDir/locks/x.lockfile\")", + Some("app/locks/x.lockfile"), + ), + ("file(\"${projectDir}/x.lockfile\")", Some("app/x.lockfile")), + ("file('../shared.lockfile')", Some("shared.lockfile")), + ("file('../../outside.lockfile')", None), + ("file('/abs/x.lockfile')", None), + ("file(\"$rootDir/x.lockfile\")", None), + ("file(name)", None), + ("file('x.lockfile').absoluteFile", None), + ("layout.projectDirectory.file('x.lockfile')", None), + ] { + let text = lock(value); + let files = [ + ("settings.gradle", "include 'app'\n"), + ("app/build.gradle", text.as_str()), + ]; + assert_eq!(path_of(&files), vec![want.map(str::to_string)], "{value}"); + } + let kts = [( + "build.gradle.kts", + "dependencyLocking { lockFile.set(file(\"gradle/x.lockfile\")) }\n", + )]; + assert_eq!(path_of(&kts), vec![Some("gradle/x.lockfile".to_string())]); + // Configuring other projects, or from an applied script: unfollowed. + for text in [ + "subprojects { dependencyLocking { lockFile = file('x.lockfile') } }\n", + "allprojects { dependencyLocking { lockFile = file('x.lockfile') } }\n", + "project(':app') { dependencyLocking { lockFile = file('x.lockfile') } }\n", + ] { + let files = [ + ("settings.gradle", "include 'app'\n"), + ("build.gradle", text), + ]; + assert_eq!(path_of(&files), vec![None], "{text}"); + } + let applied = [ + ("build.gradle", "apply from: 'locks.gradle'\n"), + ( + "locks.gradle", + "dependencyLocking { lockFile = file('x.lockfile') }\n", + ), + ]; + assert_eq!(path_of(&applied), vec![None]); + let g = graph(&[ ( "settings.gradle", diff --git a/crates/socket-patch-core/src/gradle/locks.rs b/crates/socket-patch-core/src/gradle/locks.rs index 5b9eebe34..d97c275d1 100644 --- a/crates/socket-patch-core/src/gradle/locks.rs +++ b/crates/socket-patch-core/src/gradle/locks.rs @@ -20,6 +20,13 @@ pub const LOCKFILE_NAMES: &[&str] = &[ "settings-gradle.lockfile", ]; +/// Whether `rel` is a settings-classpath lock (`settings-gradle.lockfile` +/// of any build): it locks what the settings script's own classpath +/// resolves, before the script runs. +pub fn is_settings_lock(rel: &str) -> bool { + rel.rsplit('/').next() == Some("settings-gradle.lockfile") +} + /// The legacy per-configuration lock directory, relative to a project. pub const LEGACY_LOCK_DIR: &str = "gradle/dependency-locks"; diff --git a/crates/socket-patch-core/src/patch/redirect/gradle.rs b/crates/socket-patch-core/src/patch/redirect/gradle.rs index 24b38c6b8..73005ffd2 100644 --- a/crates/socket-patch-core/src/patch/redirect/gradle.rs +++ b/crates/socket-patch-core/src/patch/redirect/gradle.rs @@ -94,12 +94,10 @@ const VENDORED_INDEX_REL: &str = vendored::INDEX_REL; const VERIFICATION_REL: &str = vendored::VERIFICATION_REL; const WRAPPER_PROPERTIES_REL: &str = "gradle/wrapper/gradle-wrapper.properties"; -/// Whether `rel` is a settings-classpath lock (`settings-gradle.lockfile` -/// of any build). Gradle resolves that classpath before any settings -/// script runs, so the hosted script can never pin what it locks. -pub fn is_settings_lock(rel: &str) -> bool { - rel.rsplit('/').next() == Some("settings-gradle.lockfile") -} +/// Whether `rel` is a settings-classpath lock. Gradle resolves that +/// classpath before any settings script runs, so the hosted script can +/// never pin what it locks. +pub use crate::gradle::locks::is_settings_lock; /// Whether `files` (root-relative) hold a root Gradle settings or build /// script. diff --git a/crates/socket-patch-core/src/vendor/jvm/gradle.rs b/crates/socket-patch-core/src/vendor/jvm/gradle.rs index d95d23d89..5edab54f0 100644 --- a/crates/socket-patch-core/src/vendor/jvm/gradle.rs +++ b/crates/socket-patch-core/src/vendor/jvm/gradle.rs @@ -24,6 +24,7 @@ use crate::gradle::dsl::{ }; use crate::gradle::graph::{filter_claims_group, DeclKind, ScriptGraph, Site}; use crate::gradle::selector::{admits, gradle_version_cmp, parse_selector, Selector}; +use crate::gradle::{locks, parent_rel}; use super::super::state::{WiringAction, WiringRecord}; use super::{ @@ -257,6 +258,26 @@ pub fn plan( read: ReadFn<'_>, list: ListFn<'_>, patch: &JvmPatch<'_>, +) -> Result { + plan_as(read, list, patch, Committed::OrPlanned) +} + +/// Which settings wiring a check may count as routing the GAV. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Committed { + /// What the checkout holds, or what this plan writes: vendoring, where + /// the plan's own in-block entries will be committed. + OrPlanned, + /// Only what the checkout holds: liveness ([`wired`]) re-plans, and a + /// re-plan that would still add an entry proves nothing routed. + Only, +} + +fn plan_as( + read: ReadFn<'_>, + list: ListFn<'_>, + patch: &JvmPatch<'_>, + committed: Committed, ) -> Result { if super::wrapper_version(read, "gradle").is_some_and(|v| v < (6, 8, 0)) { return Err(shape_refusal( @@ -337,6 +358,7 @@ pub fn plan( }; let graph = ScriptGraph::collect(&text, list, "", &[]); check_graph(&graph, &undecodable.borrow(), patch, &mut warnings)?; + check_locks(read, list, &graph, patch, committed, &mut warnings); let mut targets = vec![root]; if let Some(bs) = read_buildsrc(read)? { @@ -774,6 +796,204 @@ fn check_graph( Ok(()) } +/// Gradle dependency locks: every lock file of every project of every +/// build (`gradle.lockfile`, `buildscript-gradle.lockfile`, +/// `settings-gradle.lockfile`, the legacy `gradle/dependency-locks/` +/// files), from the lock inventory hosted discovery checks too +/// ([`ScriptGraph::read_locks`]), plus every custom `lockFile` location +/// the graph can follow. Vendoring leaves locks unchanged, so a lock entry +/// of the patched GA wins over the vendored GAV: Gradle resolves the +/// locked version (or fails lock validation). Each finding below is +/// degraded, which also withholds VEX attestation ([`wired`] re-plans and +/// requires no degraded warning): +/// +/// - `gradle_lock_overrides_vendored`: an entry at a version no vendored +/// row serves (neither this patch's nor another vendored version of the +/// GA in the index, which the same script routes); +/// - `gradle_settings_lock_unrouted`: a settings-classpath entry whose +/// build's settings script does not hold the in-block `exclusiveContent` +/// entry for that GAV in every block that needs one; +/// - `gradle_lock_unreadable`: a listed lock file (or a followed custom +/// one) that cannot be read as UTF-8 text: unreadable, refused (outside +/// the checkout), missing, or not UTF-8; its entries are unknown; +/// - `gradle_custom_lock_file`: a `lockFile` location the graph cannot +/// follow ([`crate::gradle::graph::CustomLockFile::path`]). +/// +/// The mapping from the hosted rules (`vex::discover::gradle`): hosted +/// pins a suffixed version, so a lock must name the suffix (below the base +/// is invalid, above it an unattested upstream release). Vendored keeps the +/// ORIGINAL GAV, so a lock entry is served only at a vendored version; any +/// other version, above or below, is a build that does not consume the +/// patch. Hosted rejects every settings-classpath lock of the GA because +/// its script runs after that classpath resolves; vendored instead routes +/// that classpath with in-block entries, so a settings lock is accepted +/// only when the settings script holds them: when vendoring, as committed +/// or as this plan writes them ([`Committed::OrPlanned`]); for liveness, +/// as committed ([`Committed::Only`]). Anything else is rejected, as +/// hosted would. +/// +/// Scope: a lock file names the configurations of its own project that +/// resolve the GA, so only entries of the GA count; a project that locks +/// the GA at another version does resolve it there, and hosted withholds +/// the whole build for one such lock too. A lock file Gradle ignores (its +/// project never enables locking) cannot be told apart statically, so it +/// counts. +fn check_locks( + read: ReadFn<'_>, + list: ListFn<'_>, + graph: &ScriptGraph, + patch: &JvmPatch<'_>, + committed: Committed, + warnings: &mut Vec, +) { + let (g, a, v) = (patch.group_id, patch.artifact_id, patch.version); + let text = |rel: &str| read(rel).and_then(|bytes| dsl::decode(&bytes)); + let mut lock_files = graph.read_locks(list, &text); + let mut unfollowed = Vec::new(); + for c in graph.custom_lock_files() { + match c.path { + // Set explicitly, so expected to exist: a missing one is + // unreadable too (fail closed). + Some(path) => { + let state = text(&path).map(|t| locks::parse(&t)); + lock_files.push((path, state)); + } + None => unfollowed.push(format!("{}:{}", c.script, c.line)), + } + } + let prefix = format!("{g}:{a}:"); + let vendored: BTreeSet = read(INDEX_REL) + .and_then(|bytes| String::from_utf8(bytes).ok()) + .and_then(|index| index_rows(&index)) + .unwrap_or_default() + .iter() + .filter_map(|row| { + let gav = row.split('\t').next()?; + gav.strip_prefix(&prefix).map(str::to_string) + }) + .collect(); + let served = |w: &str| w == v || vendored.contains(w); + + let (mut overrides, mut unrouted, mut unreadable) = (Vec::new(), Vec::new(), Vec::new()); + for (rel, state) in &lock_files { + let Some(state) = state else { + unreadable.push(rel.clone()); + continue; + }; + let settings = locks::is_settings_lock(rel); + for e in state.entries_of(g, a) { + if !served(&e.version) { + overrides.push(format!( + "{rel}:{} locks {g}:{a} at {} for {}, not the vendored {v}", + e.line, + e.version, + if e.confs.is_empty() { + "its configuration".to_string() + } else { + e.confs.join(",") + } + )); + break; + } + let planned = committed == Committed::OrPlanned && e.version == v; + if settings && !settings_routes(graph, parent_rel(rel), g, a, &e.version, planned) { + unrouted.push(format!( + "{rel}:{} locks {g}:{a}:{} on the settings classpath", + e.line, e.version + )); + break; + } + } + } + let first_of = |found: &[String]| { + found.first().map(|first| match found.len() { + 1 => first.clone(), + n => format!("{first} (and {} more)", n - 1), + }) + }; + if let Some(found) = first_of(&overrides) { + warnings.push(degraded( + "gradle_lock_overrides_vendored", + format!( + "{found}; Gradle resolves the locked version (or fails lock validation), so \ + that build does not consume the patched {g}:{a}:{v}; lock {v} or vendor the \ + locked version" + ), + )); + } + if let Some(found) = first_of(&unrouted) { + warnings.push(degraded( + "gradle_settings_lock_unrouted", + format!( + "{found}, and that build's settings script does not route it to the vendored \ + repository (no in-block entry in its `buildscript` or `pluginManagement` \ + repositories); that classpath resolves before the apply line runs" + ), + )); + } + if let Some(found) = first_of(&unreadable) { + warnings.push(degraded( + "gradle_lock_unreadable", + format!( + "{found} cannot be read as UTF-8 text (missing, unreadable, outside the \ + checkout, or not UTF-8), so whether it locks {g}:{a} at another version is \ + unknown" + ), + )); + } + if let Some(found) = first_of(&unfollowed) { + warnings.push(degraded( + "gradle_custom_lock_file", + format!( + "{found} sets a custom dependency-lock file (`lockFile`) that cannot be \ + followed statically, so whether it locks {g}:{a} at another version than the \ + vendored {v} is unknown" + ), + )); + } +} + +/// Whether the settings script of the build at `dir` routes `g:a:w` on its +/// own classpath: every block that needs an in-block entry for the GAV +/// (`buildscript`, and `pluginManagement` when it has settings `plugins{}`) +/// holds it, and at least one does. `planned`: an entry this plan adds +/// counts (see [`Committed`]). +fn settings_routes( + graph: &ScriptGraph, + dir: &str, + g: &str, + a: &str, + w: &str, + planned: bool, +) -> bool { + let Some(script) = graph + .builds + .iter() + .find(|b| b.dir == dir) + .and_then(|b| b.settings.as_deref()) + .and_then(|rel| graph.scripts.iter().find(|s| s.rel == rel)) + else { + return false; + }; + let coords = Coords { + group_id: g, + artifact_id: a, + version: w, + uuid: "", + }; + let kotlin = script.dsl == Dsl::Kotlin; + let mut routed = false; + for scope in ["pluginManagement", "buildscript"] { + match scoped_in_block_entry(&script.text, kotlin, &prefix_of(dir), &coords, scope) { + InBlock::Unneeded => {} + InBlock::Present => routed = true, + InBlock::Edited { .. } if planned => routed = true, + InBlock::Edited { .. } | InBlock::Unwired(_) => return false, + } + } + routed +} + /// `../` once per segment of `dir`: the script path from that build. fn prefix_of(dir: &str) -> String { "../".repeat(if dir.is_empty() { @@ -1249,7 +1469,7 @@ pub fn wired(read: ReadFn<'_>, list: ListFn<'_>, c: &Coords<'_>) -> bool { extra_artifacts: &extras, patched_members: &patched, }; - plan(read, list, &patch).is_ok_and(|p| { + plan_as(read, list, &patch, Committed::Only).is_ok_and(|p| { p.warnings.iter().all(|w| { w.code != DEGRADED // Added by the caller from the upstream chain; `--check` @@ -4658,4 +4878,311 @@ mod tests { assert!(!live(&edited)); assert!(references(&|r: &str| edited.get(r).cloned(), &p.coords())); } + + const LOCK_AT: &str = "com.google.code.gson:gson:2.10.1=compileClasspath,runtimeClasspath\n"; + const LOCK_ABOVE: &str = "com.google.code.gson:gson:2.11.0=compileClasspath,runtimeClasspath\n"; + const LOCK_BELOW: &str = "com.google.code.gson:gson:2.9.0=runtimeClasspath\n"; + + fn live_in(files: &BTreeMap>, p: &JvmPatch<'_>) -> bool { + wired( + &|r: &str| files.get(r).cloned(), + &|d: &str| list_of(files, d), + &p.coords(), + ) + } + + fn with_files( + base: &BTreeMap>, + extra: &[(&str, &str)], + ) -> BTreeMap> { + let mut out = base.clone(); + for (rel, text) in extra { + out.insert(rel.to_string(), text.as_bytes().to_vec()); + } + out + } + + /// The degraded reasons of `plan`, in order. + fn degraded_reasons(plan: &JvmPlan) -> Vec { + plan.warnings + .iter() + .filter(|w| w.code == DEGRADED) + .filter_map(|w| { + Some( + w.detail + .strip_prefix("reason: ")? + .split(':') + .next()? + .to_string(), + ) + }) + .collect() + } + + /// `files` vendored with `p`. + fn vendored_with( + files: &BTreeMap>, + p: &JvmPatch<'_>, + ) -> BTreeMap> { + let plan = run(files, p).unwrap(); + applied(files, &plan) + } + + fn app_build() -> BTreeMap> { + fs(&[ + ("settings.gradle", "include 'app'\n"), + ( + "app/build.gradle", + "dependencies { implementation 'com.google.code.gson:gson:2.10.1' }\n", + ), + ]) + } + + /// A dependency lock of the build that pins the patched GA at another + /// version than the vendored one wins over the vendored GAV: Gradle + /// resolves the locked version, so liveness (and the planner) withhold + /// attestation. Every lock kind of every project of the build counts; + /// a lock at the vendored version, another GA's lock and a nested build + /// the root does not include do not. A settings-classpath lock is + /// accepted only where the settings script routes it (see + /// `settings_lock_needs_the_committed_in_block_entry`); here it does + /// not, so it is rejected as hosted rejects it. + #[test] + fn lock_at_another_version_withholds_liveness() { + let p = patch(); + let after = vendored_with(&app_build(), &p); + assert!(live_in(&after, &p)); + let other = "org.x:y:1=runtimeClasspath\nempty=\n"; + const OVERRIDES: Option<&str> = Some("gradle_lock_overrides_vendored"); + for (rel, text, reason) in [ + ("app/gradle.lockfile", LOCK_AT, None), + ("app/gradle.lockfile", other, None), + ("app/gradle.lockfile", LOCK_ABOVE, OVERRIDES), + ("app/gradle.lockfile", LOCK_BELOW, OVERRIDES), + ("gradle.lockfile", LOCK_ABOVE, OVERRIDES), + ("app/buildscript-gradle.lockfile", LOCK_ABOVE, OVERRIDES), + ( + "settings-gradle.lockfile", + LOCK_AT, + Some("gradle_settings_lock_unrouted"), + ), + ("settings-gradle.lockfile", LOCK_ABOVE, OVERRIDES), + ( + "app/gradle/dependency-locks/runtimeClasspath.lockfile", + "com.google.code.gson:gson:2.11.0\n", + OVERRIDES, + ), + // A nested build the root does not include governs nothing. + ("samples/demo/gradle.lockfile", LOCK_ABOVE, None), + ] { + let locked = with_files(&after, &[(rel, text)]); + assert_eq!(live_in(&locked, &p), reason.is_none(), "{rel}: {text}"); + assert_eq!( + wired_checked( + &|r: &str| locked.get(r).cloned(), + &|d: &str| list_of(&locked, d), + &p.coords() + ), + Ok(reason.is_none()), + "{rel}: {text}" + ); + let replan = run(&locked, &p).unwrap(); + assert_eq!( + degraded_reasons(&replan), + reason.into_iter().collect::>(), + "{rel}: {text}" + ); + assert!( + replan + .warnings + .iter() + .all(|w| w.code != DEGRADED || w.detail.contains(rel)), + "{rel}" + ); + // A lock never stops the vendoring itself. + assert!(replan.writes.is_empty(), "{rel}"); + } + // Two locks at the vendored version and one above: still not live. + assert!(!live_in( + &with_files( + &after, + &[ + ("gradle.lockfile", LOCK_AT), + ("app/gradle.lockfile", LOCK_ABOVE) + ] + ), + &p + )); + } + + /// A settings-classpath lock at the vendored version is accepted only + /// when the settings script holds the in-block `exclusiveContent` entry + /// for the GAV. Vendoring counts the entry it writes; liveness counts + /// only the committed file, never a re-plan that would add it. + #[test] + fn settings_lock_needs_the_committed_in_block_entry() { + let p = patch(); + let files = with_files( + &app_build(), + &[ + ( + "settings.gradle", + "buildscript { dependencies { classpath 'com.google.code.gson:gson:2.10.1' } }\ninclude 'app'\n", + ), + ("settings-gradle.lockfile", LOCK_AT), + ], + ); + let plan = run(&files, &p).unwrap(); + assert_eq!(degraded_reasons(&plan), Vec::::new()); + let after = applied(&files, &plan); + let entry = in_block_line(false, "", &p.coords()); + let settings = String::from_utf8(after["settings.gradle"].clone()).unwrap(); + assert_eq!(settings.matches(&entry).count(), 1, "{settings}"); + assert!(live_in(&after, &p)); + // Locked at another version: rejected like any lock. + let above = with_files(&after, &[("settings-gradle.lockfile", LOCK_ABOVE)]); + assert!(!live_in(&above, &p)); + // The committed entry is gone: a re-plan would write it again, but + // nothing routes that classpath now. + let stripped = with_files( + &after, + &[("settings.gradle", &settings.replace(&entry, ""))], + ); + assert!(!run(&stripped, &p).unwrap().writes.is_empty()); + assert!(!live_in(&stripped, &p)); + // Without the lock, liveness is unchanged by this check. + let mut unlocked = stripped.clone(); + unlocked.remove("settings-gradle.lockfile"); + assert_eq!( + degraded_reasons( + &plan_as( + &|r: &str| unlocked.get(r).cloned(), + &|d: &str| list_of(&unlocked, d), + &p, + Committed::Only + ) + .unwrap() + ), + Vec::::new() + ); + } + + /// Two vendored versions of one GA (the index is keyed by GAV, and the + /// script routes each): a lock at the other vendored version is served, + /// not an override; a lock at a version neither serves overrides both. + #[test] + fn two_vendored_versions_of_a_ga_do_not_override_each_other() { + let v1 = patch(); + let v2 = JvmPatch { + version: "2.11.0", + uuid: "6f708192-a3b4-4c5d-8e6f-708192a3b4c5", + jar: b"PATCHED-JAR-2", + ..patch() + }; + let files = fs(&[ + ("settings.gradle", "include 'app', 'tools'\n"), + ( + "app/build.gradle", + "dependencies { implementation 'com.google.code.gson:gson:2.10.1' }\n", + ), + ( + "tools/build.gradle", + "dependencies { implementation 'com.google.code.gson:gson:2.11.0' }\n", + ), + ]); + let after = vendored_with(&vendored_with(&files, &v1), &v2); + let locked = with_files( + &after, + &[ + ("app/gradle.lockfile", LOCK_AT), + ("tools/gradle.lockfile", LOCK_ABOVE), + ], + ); + assert!(live_in(&locked, &v1)); + assert!(live_in(&locked, &v2)); + let unserved = with_files( + &locked, + &[( + "tools/gradle.lockfile", + "com.google.code.gson:gson:2.12.0=runtimeClasspath\n", + )], + ); + assert!(!live_in(&unserved, &v1)); + assert!(!live_in(&unserved, &v2)); + } + + /// A lock file the listing shows but that cannot be read as UTF-8 text + /// (an I/O error, a refused symlink out of the checkout, non-UTF-8 + /// bytes) fails closed, with its own reason. + #[test] + fn unreadable_lock_files_fail_closed() { + let p = patch(); + let after = vendored_with(&app_build(), &p); + let mut bad = after.clone(); + bad.insert("app/gradle.lockfile".into(), b"\xff\xfe\n".to_vec()); + assert!(!live_in(&bad, &p)); + assert_eq!( + degraded_reasons(&run(&bad, &p).unwrap()), + ["gradle_lock_unreadable"] + ); + // Listed, but the reader refuses it. + let listed = with_files(&after, &[("app/gradle.lockfile", LOCK_AT)]); + let read = |r: &str| { + (r != "app/gradle.lockfile") + .then(|| listed.get(r).cloned()) + .flatten() + }; + let list = |d: &str| list_of(&listed, d); + assert!(!wired(&read, &list, &p.coords())); + let plan = plan(&read, &list, &p).unwrap(); + assert_eq!(degraded_reasons(&plan), ["gradle_lock_unreadable"]); + assert!(plan.warnings[0].detail.contains("app/gradle.lockfile")); + } + + /// A custom `lockFile` location the graph can follow (a literal + /// `file(…)` in the project's own build script) is read and checked + /// like any lock, and fails closed when missing; one it cannot follow + /// withholds attestation. + #[test] + fn custom_lock_file_is_followed_when_static() { + let p = patch(); + let after = vendored_with(&app_build(), &p); + let custom = with_files( + &after, + &[( + "app/build.gradle", + "dependencyLocking { lockFile = file('locks/app.lockfile') }\n\ + dependencies { implementation 'com.google.code.gson:gson:2.10.1' }\n", + )], + ); + let at = with_files(&custom, &[("app/locks/app.lockfile", LOCK_AT)]); + assert!(live_in(&at, &p)); + assert_eq!( + degraded_reasons(&run(&at, &p).unwrap()), + Vec::::new() + ); + let above = with_files(&custom, &[("app/locks/app.lockfile", LOCK_ABOVE)]); + assert!(!live_in(&above, &p)); + assert_eq!( + degraded_reasons(&run(&above, &p).unwrap()), + ["gradle_lock_overrides_vendored"] + ); + assert!(!live_in(&custom, &p)); + assert_eq!( + degraded_reasons(&run(&custom, &p).unwrap()), + ["gradle_lock_unreadable"] + ); + let unfollowed = with_files( + &after, + &[( + "build.gradle", + "subprojects { dependencyLocking { lockFile = file('x.lockfile') } }\n", + )], + ); + assert!(!live_in(&unfollowed, &p)); + assert_eq!( + degraded_reasons(&run(&unfollowed, &p).unwrap()), + ["gradle_custom_lock_file"] + ); + } } diff --git a/crates/socket-patch-core/src/vex/discover/gradle.rs b/crates/socket-patch-core/src/vex/discover/gradle.rs index 0ec3c6c7e..538691030 100644 --- a/crates/socket-patch-core/src/vex/discover/gradle.rs +++ b/crates/socket-patch-core/src/vex/discover/gradle.rs @@ -55,7 +55,7 @@ use super::{ }; use crate::gradle::locks; use crate::patch::redirect::gradle::{ - apply_line_digest, graph_of, index_digest, is_settings_lock, lockfile_paths, parse_index, + apply_line_digest, graph_of, index_digest, is_settings_lock, key_list, parse_index, settings_targets, GradleFiles, PinnedRowChecks, HOSTED_INDEX_REL, HOSTED_SCRIPT, HOSTED_SCRIPT_REL, MAX_ROUNDS, }; @@ -105,12 +105,15 @@ pub(crate) async fn extract(ctx: &DiscoverCtx<'_>, out: &mut Discovery) { .custom_lock_file() .map(|rel| format!("{rel} sets a custom dependency-lock file (`lockFile`)")) }); - let locks: Vec<(String, locks::LockState)> = lockfile_paths(&graph, &files) + // The shared lock inventory (`ScriptGraph::read_locks`, also the + // vendored backend's). The listing is the keys of `files`, so every + // listed lock reads; `read_build` leaves an unreadable one out. + let list = |dir: &str| key_list(&files, dir); + let read = |rel: &str| files.get(rel).cloned(); + let locks: Vec<(String, locks::LockState)> = graph + .read_locks(&list, &read) .into_iter() - .filter_map(|rel| { - let state = locks::parse(files.get(&rel)?); - Some((rel, state)) - }) + .filter_map(|(rel, state)| Some((rel, state?))) .collect(); let pinned_checks = PinnedRowChecks::new(&files, &graph); for row in rows { diff --git a/docs/design/maven-vendoring.md b/docs/design/maven-vendoring.md index 748d32231..f656ab5fb 100644 --- a/docs/design/maven-vendoring.md +++ b/docs/design/maven-vendoring.md @@ -165,6 +165,20 @@ files receive an apply line for `.socket/gradle/socket-patch.settings.gradle`. Settings plugin and buildscript classpaths receive an in-block exclusive repository entry because those classpaths resolve before the apply line runs. +Because lockfiles stay unchanged, a lock that pins the patched module at +another version wins over the vendored GAV: Gradle resolves the locked version +or fails lock validation. Every lock file of every project of the build is +read (`gradle.lockfile`, `buildscript-gradle.lockfile`, +`settings-gradle.lockfile`, the legacy `gradle/dependency-locks/`, and a custom +`lockFile` set as a literal `file(…)` in a project's own build script). The +following are degraded: an entry at a version no vendored row serves +(`gradle_lock_overrides_vendored`; another vendored version of the same module +is served), a settings classpath entry whose settings script lacks the in-block +entry for it (`gradle_settings_lock_unrouted`; liveness requires the committed +entry), a lock file that cannot be read (`gradle_lock_unreadable`), and a +custom `lockFile` that cannot be followed (`gradle_custom_lock_file`). The +patch is still applied, and `vex` does not attest it. + The script, the index, the derived metadata and the `.gitattributes` files are owned text and are compared line-ending blind, so a `core.autocrlf` checkout passes `--check` and reverts clean. `.socket/gradle/.gitattributes` (`* -text`,