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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion crates/socket-patch-cli/CLI_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -2631,7 +2631,7 @@ with `reason: <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. |

Expand Down
194 changes: 184 additions & 10 deletions crates/socket-patch-core/src/gradle/graph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
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<CustomLockFile> {
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<locks::LockState>)> {
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('<path>')` with a literal path (or `"$projectDir/<path>"`) 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<String>,
}

/// The path argument of `file(<string>)` 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<String> {
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
Expand Down Expand Up @@ -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<Option<String>> {
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",
Expand Down
7 changes: 7 additions & 0 deletions crates/socket-patch-core/src/gradle/locks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
10 changes: 4 additions & 6 deletions crates/socket-patch-core/src/patch/redirect/gradle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading