Skip to content

Commit d69a672

Browse files
mikolalysenkoclaude
andcommitted
Gradle agent: restore unread m2 copies, judge partly-held copies, probe hash dirs
- rollback/remove restore a ~/.m2 copy a Gradle-only build no longer reads (patched by an earlier apply); skipping it reported success while the shared jar stayed patched and remove dropped the record. - A Gradle version dir holding only some of a leaf record's files is an install: apply patches the held files and fails the missing ones as not found (as on ~/.m2), and vex keeps the copy so it withholds. - mismatch_blob_gaps expands Gradle version dirs into their hash dirs, so a drifted Gradle copy queues the afterHash blob the Warn policy needs. - New gradle_m2_may_be_unconsumed warning when a Gradle-only build reading mavenLocal() has only the ~/.m2 copy patched. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 664a616 commit d69a672

7 files changed

Lines changed: 250 additions & 48 deletions

File tree

‎crates/socket-patch-cli/CLI_CONTRACT.md‎

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1690,13 +1690,18 @@ never filters.
16901690
`apply` writes every copy a build consumes: each `~/.m2` version directory the build
16911691
reads and each Gradle hash directory that holds the record's files. A Gradle version
16921692
directory holding none of the record's files is not an install of it
1693-
(`package_not_installed`). A record keyed by jar members (`<a>-<v>.jar/<member>`,
1693+
(`package_not_installed`). One holding only some of them is: the held files are
1694+
patched and the missing ones fail that copy as not found, as on `~/.m2` (the build
1695+
still loads the held jar). A record keyed by jar members (`<a>-<v>.jar/<member>`,
16941696
#264) swaps the whole jar for the patch service's build of it, in one transaction
16951697
across every consumed copy: one download, one backup per distinct original under
16961698
`.socket/jvm-originals/`, and a failed write puts every copy already swapped back.
1697-
`rollback` restores only copies `apply` writes (never the read-only cache, never a
1698-
`~/.m2` copy a Gradle-only build does not read), from the backup, else, online and
1699-
for a Gradle copy only, from an upstream download that hashes to the copy's hash
1699+
`rollback` (and `remove`) restores every writable copy that still holds the record's
1700+
patched bytes: never the read-only cache, but also a `~/.m2` copy a Gradle-only build
1701+
no longer reads (an earlier apply wrote it while the build declared `mavenLocal()`,
1702+
or before v5.0 gated `~/.m2`; leaving it would strand the shared jar patched once
1703+
`remove` drops the record). It restores from the backup, else, online and for a
1704+
Gradle copy only, from an upstream download that hashes to the copy's hash
17001705
directory.
17011706

17021707
Run-level `warnings[]` codes (a refusal is also a `failed` event whose `error`
@@ -1706,6 +1711,7 @@ starts with the code):
17061711
|---|---|---|
17071712
| `gradle_verification_metadata_present` | refused, exit 1 | `gradle/verification-metadata.xml` exists; rewritten cache bytes would fail (or, with key-only trust, slip past) Gradle's dependency verification. Nothing is written; use `--mode vendored` or `--mode hosted`. |
17081713
| `gradle_build_ignores_m2` | refused, exit 1 | The only installed copy is in `~/.m2`, which this Gradle-only build never reads. Nothing is patched; build once so Gradle caches it, then apply again. |
1714+
| `gradle_m2_may_be_unconsumed` | warning | A Gradle-only build that declares `mavenLocal()` (or may) has no Gradle cache copy, so only the `~/.m2` copy was patched. Gradle takes a module from the first declared repository that has it: with another repository before `mavenLocal()`, the next build downloads the unpatched jar. Run the build once and apply again. |
17091715
| `gradle_ro_cache_shadows` | exit 1 | The read-only cache holds a copy that is never written and that Gradle may read first. Writable copies are still patched. |
17101716
| `gradle_copy_unexpected_bytes` | refused, exit 1 | A hash directory's file is the pristine download (its sha1 names the directory) but neither side of the record, or a hash directory holds a variant's files whose bytes no variant was made for. That copy is left unpatched and the run fails (changed in v5.0: it used to only warn), since the build still loads it. |
17111717
| `gradle_transform_copy_stale` | that copy fails | After the write, Gradle still holds a copy derived from the pristine jar (`caches/transforms-*`, `caches/jars-*`, instrumented jars). Run `gradle --stop`, delete those directories, apply again. |
@@ -1844,7 +1850,8 @@ CLI.
18441850
`vex` re-hashes every copy a build consumes (`~/.m2` copies the build reads, every
18451851
Gradle hash directory holding the record's files, and the suffixed copies of a
18461852
hosted pin) and attests only when all of them carry the patch. A Gradle version
1847-
directory that holds none of the record's files is ignored. A derived copy
1853+
directory that holds none of the record's files is ignored; one that holds only some
1854+
of them is judged, and its missing files withhold the statement. A derived copy
18481855
(`caches/transforms-*`, `caches/jars-*`, instrumented jars) proven to come from the
18491856
pristine jar, or a same-named one that is older than the patched jar and does not
18501857
match it, withholds the statement:

‎crates/socket-patch-cli/src/commands/apply.rs‎

Lines changed: 139 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -209,10 +209,13 @@ fn format_mismatch_fetch_result(downloaded: usize, needed: usize) -> String {
209209
/// mismatched files still need their afterHash blobs. The variant gate
210210
/// mirrors the apply loop's representative check PER COPY for gem and
211211
/// PyPI (which patch every copy, and two envs can hold different wheels
212-
/// of one release), and against the FIRST copy otherwise (Maven's
213-
/// Gradle copies are version dirs whose files sit in hash dirs, gated per
214-
/// hash dir by `apply_maven_base`): a variant's files are probed only on
215-
/// the copies it is attempted on.
212+
/// of one release), and against the FIRST copy otherwise: a variant's
213+
/// files are probed only on the copies it is attempted on. Maven's Gradle
214+
/// copies are version dirs whose files sit in hash dirs, so each one is
215+
/// first expanded into the hash dirs holding the record's files (as
216+
/// `apply_maven_base` does) and gated and probed per hash dir; probing the
217+
/// version dir itself would only ever find nothing, and a drifted Gradle
218+
/// copy would never queue the afterHash blob its write needs.
216219
///
217220
/// Only a mismatched file whose afterHash blob is NOT staged can queue a
218221
/// fetch, so the probe first decides that with metadata probes alone and
@@ -265,21 +268,40 @@ async fn mismatch_blob_gaps(
265268
|| records
266269
.first()
267270
.is_some_and(|(key, _)| key.as_str() != stripped));
268-
// The copies the apply loop gates per copy: gem and PyPI patch
269-
// every copy, each against its own representative check; the
270-
// rest gate on the first.
271-
let gate_copies: &[PathBuf] = if matches!(
272-
Ecosystem::from_purl(purl),
273-
Some(Ecosystem::Gem | Ecosystem::Pypi)
274-
) {
275-
pkg_paths.as_slice()
276-
} else {
277-
std::slice::from_ref(first_path)
278-
};
271+
let maven = Ecosystem::from_purl(purl) == Some(Ecosystem::Maven);
279272
for (_, record) in records {
280273
if !can_queue(record) {
281274
continue;
282275
}
276+
// Maven: every Gradle version dir expanded into the hash dirs
277+
// holding the record's files.
278+
let expanded: Vec<PathBuf> = if maven {
279+
pkg_paths
280+
.iter()
281+
.flat_map(|p| {
282+
socket_patch_core::crawlers::gradle_cache::installed_copies(
283+
p,
284+
&record.files,
285+
)
286+
.into_iter()
287+
.map(|(dir, _)| dir)
288+
})
289+
.collect()
290+
} else {
291+
Vec::new()
292+
};
293+
let pkg_paths: &[PathBuf] = if maven { &expanded } else { pkg_paths };
294+
// The copies the apply loop gates per copy: gem and PyPI patch
295+
// every copy, each against its own representative check, and
296+
// Maven each hash dir; the rest gate on the first.
297+
let gate_copies: &[PathBuf] = if matches!(
298+
Ecosystem::from_purl(purl),
299+
Some(Ecosystem::Gem | Ecosystem::Pypi | Ecosystem::Maven)
300+
) {
301+
pkg_paths
302+
} else {
303+
std::slice::from_ref(first_path)
304+
};
283305
// Copies this variant is attempted on: a copy whose installed
284306
// distribution is another variant (two envs can hold different
285307
// wheels of one release) is skipped there by the apply loop.
@@ -2527,6 +2549,35 @@ async fn apply_maven_base(m: &MavenBase<'_>) -> MavenApplied {
25272549
return out;
25282550
}
25292551

2552+
// A Gradle-only build that reads `~/.m2` (mavenLocal() declared or
2553+
// undetermined) but has no Gradle cache copy: the m2 copy is patched,
2554+
// yet Gradle takes a module from the FIRST declared repository that
2555+
// has it, so when another repository comes before mavenLocal() the
2556+
// next build downloads the pristine jar instead. Gradle never caches
2557+
// a mavenLocal() artifact in files-2.1, so this is also exactly what a
2558+
// build reading the module from mavenLocal() looks like: warn, not
2559+
// refuse. `vex` re-hashes the Gradle cache copy that build makes.
2560+
if matches!(
2561+
m.scope.gate,
2562+
Some(socket_patch_core::crawlers::maven_crawler::M2Gate::Declared(_))
2563+
| Some(socket_patch_core::crawlers::maven_crawler::M2Gate::Undetermined(_))
2564+
) && copies.consumed.iter().all(|c| !is_gradle_version_dir(c))
2565+
{
2566+
out.warn(
2567+
"gradle_m2_may_be_unconsumed",
2568+
format!(
2569+
"{}: the only patched copy is in the Maven local repository ({}). This Gradle build reads it only when no repository declared before mavenLocal() has the module; otherwise its next build downloads the unpatched jar. Run the build once and apply again so the Gradle cache copy is patched too.",
2570+
normalize_purl(m.base_purl),
2571+
copies
2572+
.consumed
2573+
.iter()
2574+
.map(|p| p.display().to_string())
2575+
.collect::<Vec<_>>()
2576+
.join(", ")
2577+
),
2578+
);
2579+
}
2580+
25302581
let multi = variants.len() > 1 || variants.first().is_some_and(|v| **v != m.base_purl);
25312582
let gate_variants = !args.force && multi;
25322583
let mut attempted = false;
@@ -2589,22 +2640,33 @@ async fn apply_maven_base(m: &MavenBase<'_>) -> MavenApplied {
25892640

25902641
for copy in &copies.consumed {
25912642
// Leaf record: the hash dirs holding its files (the copy itself
2592-
// for `~/.m2`). A key no hash dir holds means this Gradle copy
2593-
// does not hold the variant.
2594-
let targets = if is_gradle_version_dir(copy) {
2643+
// for `~/.m2`). A Gradle copy holding NONE of the record's
2644+
// files is not an install of it. One holding only some of them
2645+
// is: its held files are patched, and the keys no hash dir
2646+
// holds are applied against the version dir, where they are
2647+
// not found and fail the copy as they would on `~/.m2` (the
2648+
// build still loads the held jar, so a silent skip would leave
2649+
// it unpatched behind a clean exit).
2650+
let (targets, absent) = if is_gradle_version_dir(copy) {
25952651
let detailed = gradle_cache::installed_copies_detailed(copy, &patch.files);
2596-
if !detailed.missing.is_empty() {
2652+
if detailed.targets.is_empty() {
25972653
continue;
25982654
}
25992655
for (dir, _) in &detailed.targets {
26002656
held.entry(dir.clone())
26012657
.or_default()
26022658
.push((*variant).clone());
26032659
}
2604-
detailed.targets
2660+
let absent: HashMap<String, PatchFileInfo> = detailed
2661+
.missing
2662+
.iter()
2663+
.filter_map(|k| patch.files.get(k).map(|info| (k.clone(), info.clone())))
2664+
.collect();
2665+
(detailed.targets, absent)
26052666
} else {
2606-
vec![(copy.clone(), patch.files.clone())]
2667+
(vec![(copy.clone(), patch.files.clone())], HashMap::new())
26072668
};
2669+
let mut copy_attempted = false;
26082670
for (dir, files) in targets {
26092671
if gate_variants {
26102672
let status = match representative_file(&files) {
@@ -2619,6 +2681,7 @@ async fn apply_maven_base(m: &MavenBase<'_>) -> MavenApplied {
26192681
}
26202682
hit.insert(dir.clone());
26212683
out.matched.push((*variant).clone());
2684+
copy_attempted = true;
26222685
if let Some(detail) = unexpected_gradle_bytes(&dir, &files).await {
26232686
out.refuse(variant, &dir, "gradle_copy_unexpected_bytes", detail);
26242687
continue;
@@ -2639,6 +2702,24 @@ async fn apply_maven_base(m: &MavenBase<'_>) -> MavenApplied {
26392702
}
26402703
out.record(args, result);
26412704
}
2705+
// The record's keys this Gradle copy lacks, once the variant
2706+
// was attempted on its held files: not found there (a failure
2707+
// under the default and strict policies, a skip under
2708+
// `--force`), as on a `~/.m2` copy missing them.
2709+
if copy_attempted && !absent.is_empty() {
2710+
attempted = true;
2711+
let result = apply_package_patch(
2712+
variant,
2713+
copy,
2714+
&absent,
2715+
m.sources,
2716+
Some(&patch.uuid),
2717+
args.common.dry_run,
2718+
m.policy,
2719+
)
2720+
.await;
2721+
out.record(args, result);
2722+
}
26422723
}
26432724
}
26442725
// A Gradle hash dir that holds a variant's files but whose bytes no
@@ -3336,6 +3417,43 @@ mod tests {
33363417
);
33373418
}
33383419

3420+
/// #646 review: a Gradle copy is a `files-2.1` version dir whose files
3421+
/// sit one level down in `<sha1>/` hash dirs. A drifted jar there (not
3422+
/// pristine, not the record's beforeHash) must queue its afterHash
3423+
/// blob as a drifted `~/.m2` copy does: the default Warn policy
3424+
/// overwrites it with the full blob.
3425+
#[tokio::test]
3426+
async fn mismatch_blob_gaps_probes_gradle_hash_dirs() {
3427+
let dir = tempfile::tempdir().unwrap();
3428+
let version = dir
3429+
.path()
3430+
.join(".gradle/caches/modules-2/files-2.1/com.example/victim/1.0");
3431+
let hash = version.join("0123456789abcdef0123456789abcdef01234567");
3432+
tokio::fs::create_dir_all(&hash).await.unwrap();
3433+
tokio::fs::write(hash.join("victim-1.0.jar"), b"older patch bytes")
3434+
.await
3435+
.unwrap();
3436+
let blobs = dir.path().join("blobs");
3437+
tokio::fs::create_dir_all(&blobs).await.unwrap();
3438+
let mut files = HashMap::new();
3439+
files.insert(
3440+
"package/victim-1.0.jar".to_string(),
3441+
PatchFileInfo {
3442+
before_hash: "4".repeat(64),
3443+
after_hash: "5".repeat(64),
3444+
},
3445+
);
3446+
let manifest = manifest_with_record("pkg:maven/com.example/victim@1.0", files);
3447+
let mut all_packages = HashMap::new();
3448+
all_packages.insert(
3449+
"pkg:maven/com.example/victim@1.0".to_string(),
3450+
vec![version.clone()],
3451+
);
3452+
let needed =
3453+
mismatch_blob_gaps(&manifest, &all_packages, &HashSet::new(), &blobs, false).await;
3454+
assert_eq!(needed, HashSet::from(["5".repeat(64)]));
3455+
}
3456+
33393457
/// A QUALIFIED singleton (`?platform=`…) keeps the
33403458
/// installed-distribution gate — it names one specific distribution,
33413459
/// and the apply loop skips it when the representative file

‎crates/socket-patch-cli/src/commands/rollback.rs‎

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2296,11 +2296,17 @@ pub(crate) async fn rollback_patches_inner(
22962296
.as_ref()
22972297
.filter(|_| Ecosystem::from_purl(purl) == Some(Ecosystem::Maven))
22982298
{
2299-
// Only the copies apply writes (`JvmScope::split`'s consumed
2300-
// ones): the read-only cache is never written, and a `~/.m2`
2301-
// copy this Gradle-only build never reads belongs to some
2302-
// other build — restoring it would unpatch that build.
2303-
for pkg_path in &scope.split(pkg_paths).consumed {
2299+
// Every writable copy: the read-only cache is never written,
2300+
// but a `~/.m2` copy this Gradle-only build no longer reads
2301+
// (`m2_ignored`) is still restored. An earlier apply wrote it
2302+
// (before the gate existed, or while `mavenLocal()` was
2303+
// declared); skipping it would leave the shared jar patched
2304+
// with no record to restore it from once `remove` drops the
2305+
// entry. Only bytes that verify as this record's afterHash
2306+
// are ever put back, and a Maven build that wants the patch
2307+
// re-applies it from its own manifest.
2308+
let copies = scope.split(pkg_paths);
2309+
for pkg_path in copies.consumed.iter().chain(&copies.m2_ignored) {
23042310
let key = (strip_purl_qualifiers(purl).to_string(), pkg_path.clone());
23052311
match maven_groups.iter_mut().find(|(k, _)| *k == key) {
23062312
Some((_, purls)) => purls.push(purl),

‎crates/socket-patch-cli/src/commands/vex.rs‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -811,7 +811,9 @@ async fn generate_vex(
811811
/// declared or undetermined keeps it), each Gradle cache and the read-only
812812
/// cache, all re-hashed at VEX time. A Gradle version dir holding none of
813813
/// the record's files (a pom-only entry, another classifier) is not an
814-
/// install of it and is dropped, as apply does.
814+
/// install of it and is dropped, as apply does. One holding only some of
815+
/// them is kept: the keys it lacks verify as not found, so the statement
816+
/// is withheld while the build loads the held (unpatched) jar.
815817
async fn vex_copy_sets(
816818
common: &GlobalArgs,
817819
manifest: &PatchManifest,
@@ -830,10 +832,9 @@ async fn vex_copy_sets(
830832
}
831833
match jvm_jar::classify(purl, &record.files) {
832834
RecordShape::Members { jar_leaf } => !jvm_jar::jar_copies(path, &jar_leaf).is_empty(),
833-
RecordShape::Leaf => {
834-
let detailed = installed_copies_detailed(path, &record.files);
835-
detailed.missing.is_empty() && !detailed.targets.is_empty()
836-
}
835+
RecordShape::Leaf => !installed_copies_detailed(path, &record.files)
836+
.targets
837+
.is_empty(),
837838
}
838839
};
839840
let maven = copies.keys().any(|p| p.starts_with("pkg:maven/"));

‎crates/socket-patch-cli/tests/e2e_redirect_gradle_build.rs‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -824,8 +824,8 @@ fn gradle_hosted_selector_port_matches_golden_tables() {
824824
#[ignore = "real Gradle; run with --ignored"]
825825
fn gradle_hosted_selector_golden_tables_match_real_gradle() {
826826
use socket_patch_core::gradle::selector::{
827-
admits_for, gradle_version_cmp_for, parse_selector, GOLDEN_ADMITS,
828-
GOLDEN_ADMITS_BY_MAJOR, GOLDEN_ORDERING, GOLDEN_ORDERING_BY_MAJOR,
827+
admits_for, gradle_version_cmp_for, parse_selector, GOLDEN_ADMITS, GOLDEN_ADMITS_BY_MAJOR,
828+
GOLDEN_ORDERING, GOLDEN_ORDERING_BY_MAJOR,
829829
};
830830
let groovy = |s: &str| format!("'{}'", s.replace('\\', "\\\\").replace('\'', "\\'"));
831831
let mut ords: Vec<(&str, &str)> = GOLDEN_ORDERING.iter().map(|(a, b, _)| (*a, *b)).collect();
@@ -865,7 +865,10 @@ for (r in adm) {{
865865
let Some(c) = cell(
866866
Dsl::Groovy,
867867
&[
868-
("settings.gradle".into(), "rootProject.name = 'probe'\n".into()),
868+
(
869+
"settings.gradle".into(),
870+
"rootProject.name = 'probe'\n".into(),
871+
),
869872
("build.gradle".into(), probe),
870873
],
871874
) else {

0 commit comments

Comments
 (0)