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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion crates/socket-patch-cli/CLI_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -2280,7 +2280,14 @@ reference to `.socket/vendor/<eco>/<uuid>/` that no ledger entry owns (the
artifact-level event repair emits: `uuid` plus `details.{ecosystem,path}`, no
purl), so a checkout whose ledger was ignored or dropped from the commit no
longer passes with nothing to check. Offline upstream metadata is reported as the run warning
`vendor_jvm_upstream_unverified`. The check never starts an API client or writes
`vendor_jvm_upstream_unverified`. A Maven reactor entry that is intact and wired
but that `vex` does not attest — a declaration may still build the base release:
an executable classifier or type copy of the patched GA (`tests`, `test-jar`, a
native build), a version a property, a profile or an unpinned local root may
move, or a base declaration added since vendoring that the re-plan cannot take
— stays `verified` and adds the run warning `vendor_jvm_unattested`, whose
detail names the entry and the declarations. Like the other unattested
references, re-vendoring cannot clear it, so it is not drift. The check never starts an API client or writes
lock/recovery files. `--check` conflicts with `--revert`.

`vendor --check --local-repo <path>` additionally checks existing suffixed Maven
Expand Down Expand Up @@ -2634,6 +2641,7 @@ with `reason: <reason>: `:
| `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_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. |
| `vendor_jvm_unattested` | warning (`vendor --check`) | A Maven reactor entry is intact and wired, but a declaration may still build the base release (an executable classifier or type copy, a version a property, profile or unpinned local root may move), so `vex` does not attest it. The entry stays `verified`; the detail names the purl and the declarations (`<pom>:<line>`). |

The Maven half of a root (single-module, reactor or mixed) also reports
`vendor_jvm_degraded` with `reason: maven_f_outside_root` (Maven 3.9.2–3.9.8
Expand Down
127 changes: 121 additions & 6 deletions crates/socket-patch-cli/src/commands/scan/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1070,15 +1070,18 @@ pub(super) async fn classify_overlap_takeover_with(
Some(uuid) => liveness.redirect_record(&purl, uuid).await,
None => discovery.wires_package(&purl, WiringMode::Hosted),
};
// `None`: undecided (a JVM pom that cannot be read, or a tree still
// referenced without attestable wiring) — neither live nor dead.
let vendored_live = match vendor_by_purl.get(&PurlKey::new(&purl)) {
Some(entry) => liveness.vendor_entry(entry).await,
None => false,
Some(entry) => liveness.vendor_entry_state(entry).await,
None => Some(false),
};
match (hosted_live, vendored_live) {
(true, false) => out.redirect.push(purl),
(false, true) => out.vendored.push(purl),
// Both (a half-migrated lock naming both) or neither (no rewire /
// unreadable) does not prove a single direction — stay silent.
(true, Some(false)) => out.redirect.push(purl),
(false, Some(true)) => out.vendored.push(purl),
// Both (a half-migrated lock naming both), neither (no rewire)
// or an undecided vendored side (unreadable) does not prove a
// single direction — stay silent.
_ => {}
}
}
Expand Down Expand Up @@ -4671,6 +4674,118 @@ mod tests {
);
}

// ---- maven: an undecided vendored claim is not a dead one -------------

const MAVEN_PURL: &str = "pkg:maven/org.slf4j/slf4j-api@1.7.36";
/// The vendored patch: another uuid than the hosted pin's, so its
/// suffixed version tells the two wirings apart.
const MAVEN_VENDORED_UUID: &str = "5c0ffee0-1d3a-4f6b-8c2d-7e5a9b1c3d5f";

fn maven_dep(version: &str) -> String {
format!(
"<dependency><groupId>org.slf4j</groupId><artifactId>slf4j-api</artifactId>\
<version>{version}</version></dependency>"
)
}

/// A root pom pinning slf4j-api to the hosted [`TAKEOVER_UUID`] patch,
/// with `extra` (a `<build>` section) inside the project.
async fn write_maven_hosted_pom(root: &Path, extra: &str) {
let pom = format!(
"<?xml version=\"1.0\"?>\n<project xmlns=\"http://maven.apache.org/POM/4.0.0\">\n \
<modelVersion>4.0.0</modelVersion>\n <groupId>com.example</groupId>\n \
<artifactId>app</artifactId>\n <version>1.0.0</version>\n \
<dependencies>\n {}\n </dependencies>\n <repositories>\n <repository>\n \
<id>socket-patch-{TAKEOVER_UUID}</id>\n \
<url>https://patch.socket.dev/patch-registry/maven/11111111-2222-4333-8444-555555555555/{TAKEOVER_UUID}/maven2</url>\n \
<releases>\n <enabled>true</enabled>\n \
<checksumPolicy>fail</checksumPolicy>\n </releases>\n </repository>\n \
</repositories>\n {extra}\n</project>\n",
maven_dep(&format!("1.7.36-socket.{}", &TAKEOVER_UUID[..8])),
);
tokio::fs::write(root.join("pom.xml"), pom).await.unwrap();
}

/// A vendored ledger whose one entry is a Maven reactor vendoring of
/// slf4j-api at [`MAVEN_VENDORED_UUID`].
async fn write_maven_vendor_ledger(root: &Path) {
let sv = format!("1.7.36-socket.{}", &MAVEN_VENDORED_UUID[..8]);
let state = serde_json::json!({
"version": 1,
"entries": {
MAVEN_PURL: {
"ecosystem": "maven",
"basePurl": MAVEN_PURL,
"uuid": MAVEN_VENDORED_UUID,
"artifact": {
"path": format!(
".socket/vendor/maven2/org/slf4j/slf4j-api/{sv}/slf4j-api-{sv}.jar"
),
},
"wiring": [{
"file": "pom.xml",
"kind": "maven_pom_fragment",
"action": "added",
}],
},
},
});
let dir = root.join(".socket/vendor");
tokio::fs::create_dir_all(&dir).await.unwrap();
tokio::fs::write(
dir.join("state.json"),
serde_json::to_string_pretty(&state).unwrap(),
)
.await
.unwrap();
}

/// A vendored Maven claim the reader cannot decide — a project file it
/// cannot read, or the vendored version left only where the build may
/// still resolve it from the tree (a build plugin's dependencies) —
/// is neither live nor dead: the `scan --prune` GC keeps it, so the
/// takeover classifier must not report it superseded (and tell the
/// user to remove it). Only a claim nothing references any more is.
#[tokio::test]
async fn maven_takeover_stays_silent_on_an_undecided_vendored_claim() {
let vendored = format!("1.7.36-socket.{}", &MAVEN_VENDORED_UUID[..8]);
let plugin = format!(
"<build><plugins><plugin><groupId>p</groupId><artifactId>p</artifactId>\
<dependencies>{}</dependencies></plugin></plugins></build>",
maven_dep(&vendored)
);
// Control: nothing references the vendored version — superseded.
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write_maven_vendor_ledger(root).await;
write_maven_hosted_pom(root, "").await;
let takeover = classify_overlap_takeover(&common_at(root), root).await;
assert_eq!(
takeover.redirect,
vec![MAVEN_PURL.to_string()],
"{takeover:?}"
);

// Left only in a build plugin's dependencies.
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write_maven_vendor_ledger(root).await;
write_maven_hosted_pom(root, &plugin).await;
let takeover = classify_overlap_takeover(&common_at(root), root).await;
assert_eq!(takeover, OverlapTakeover::default());

// A Maven config the reader cannot read (a directory).
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write_maven_vendor_ledger(root).await;
write_maven_hosted_pom(root, "").await;
tokio::fs::create_dir_all(root.join(".mvn/maven.config"))
.await
.unwrap();
let takeover = classify_overlap_takeover(&common_at(root), root).await;
assert_eq!(takeover, OverlapTakeover::default());
}

// ---- takeover DIRECTION follows the live lock, not the command ---------

/// Like [`write_vendor_ledger`] but each entry records wiring the
Expand Down
19 changes: 17 additions & 2 deletions crates/socket-patch-cli/src/commands/vendor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -174,10 +174,14 @@ async fn keep_older_vendored_patch(
};
if let Some(kept) = lookup_entry(&state.entries, purl).filter(|e| e.uuid != uuid) {
let root = common.project_root();
// Still in force, not attestable: a JVM tree that is still wired
// or referenced (an executable classifier copy beside it, say) is
// kept; only a provably dead one gives way.
if crate::commands::discover_wiring(common, &root)
.await
.vendor_entry_live(&root, kept)
.vendor_entry_liveness(&root, kept)
.await
!= Some(false)
{
return Some(VendorOutcome::Refused {
code,
Expand Down Expand Up @@ -1334,7 +1338,18 @@ async fn run_check(args: &VendorArgs) -> i32 {
None => Some("patch record missing; restore the manifest or vendor ledger".to_string()),
};
if failure.is_none() && vendor::jvm::apply::is_jvm_entry(entry) {
failure = vendor::jvm::apply::check_entry(root, entry, local_repo.as_deref()).err();
match vendor::jvm::apply::check_entry(root, entry, local_repo.as_deref()) {
Ok(None) => {}
// Intact and wired, but `vex` will not attest it: a
// declaration may still build the base. Re-vendoring cannot
// clear that, so it warns rather than fails (the unattested
// references rule).
Ok(Some(why)) => env.warnings.push(RunWarning {
code: "vendor_jvm_unattested".into(),
detail: format!("{key}: {why}; vex does not attest it"),
}),
Err(e) => failure = Some(e),
}
}
// The npm check names the exact unwired lock entry, so it runs
// before the generic liveness rule below.
Expand Down
63 changes: 63 additions & 0 deletions crates/socket-patch-core/src/formats/maven/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -551,6 +551,69 @@ pub(crate) fn may_shadow(
&& may_name(dep_artifact, artifact)
}

/// The classifiers whose jars never run on a classpath: `sources` and
/// `javadoc`. Every other classifier copy of a patched GA (tests, a native
/// or JDK-specific build) carries code, so it must resolve the patched
/// release too. The hosted rewriter, VEX discovery and the vendored
/// reactor's attestation share this rule.
pub(crate) const NON_EXECUTABLE_CLASSIFIERS: &[&str] = &["sources", "javadoc"];

/// Whether a `<classifier>` copy of a GA carries code onto a classpath
/// (see [`NON_EXECUTABLE_CLASSIFIERS`]).
pub(crate) fn is_executable_classifier(classifier: &str) -> bool {
!NON_EXECUTABLE_CLASSIFIERS.contains(&classifier)
}

/// Whether a declaration of a GA is a sibling copy of its main jar that
/// Maven resolves apart from it (and that the main-jar pin does not
/// reach): a classifier, or a type other than `jar` / `pom`. The `<type>`
/// a `${...}` leaves unknown counts. The hosted rewriter, VEX discovery and
/// the vendored reactor share this rule with [`is_executable_variant`].
pub(crate) fn is_variant(type_text: Option<&str>, classifier: Option<&str>) -> bool {
classifier.is_some_and(|c| !c.is_empty())
|| type_text.is_some_and(|t| !t.is_empty() && t != "jar" && t != "pom")
}

/// The label a variant ([`is_variant`]) is reported by: its classifier,
/// else its type.
pub(crate) fn variant_label<'a>(
type_text: Option<&'a str>,
classifier: Option<&'a str>,
) -> &'a str {
classifier
.filter(|c| !c.is_empty())
.or(type_text)
.unwrap_or_default()
}

/// Whether a declaration of a GA's main jar: untyped or `jar`-typed, with
/// no classifier.
pub(crate) fn is_main_jar(type_text: Option<&str>, classifier: Option<&str>) -> bool {
type_text.is_none_or(|t| t.is_empty() || t == "jar") && classifier.is_none_or(str::is_empty)
}

/// Whether a declaration of a GA that is not its main jar
/// ([`is_main_jar`]) is a copy that carries code onto a classpath. Maven
/// derives a classifier from some types (`test-jar` is the `tests` jar,
/// `ejb-client` the `client` one, `java-source` / `javadoc` the
/// non-executable ones), so a type counts like the classifier it implies.
/// Of the rest only `pom` holds no code; every other type (`ejb`,
/// `maven-plugin`, `bundle`, an unknown or `${...}` one) is executable.
pub(crate) fn is_executable_variant(type_text: Option<&str>, classifier: Option<&str>) -> bool {
let type_text = type_text.filter(|t| !t.is_empty()).unwrap_or("jar");
let implied = match type_text {
"test-jar" => Some("tests"),
"ejb-client" => Some("client"),
"java-source" => Some("sources"),
"javadoc" => Some("javadoc"),
_ => None,
};
match classifier.filter(|c| !c.is_empty()).or(implied) {
Some(c) => is_executable_classifier(c),
None => type_text != "pom",
}
}

fn is_property_name(name: &str) -> bool {
!name.is_empty()
&& name
Expand Down
26 changes: 21 additions & 5 deletions crates/socket-patch-core/src/patch/redirect/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8783,9 +8783,9 @@ fn rewrite_maven_pom(
// (sources, tests, a native build) is its own artifact — managed and
// resolved apart from the main jar — so it is left alone, and the
// main jar's literal-vs-depMgmt decision is made without it.
let (variants, live): (Vec<_>, Vec<_>) = all_matches
.into_iter()
.partition(|m| m.classifier.is_some());
let (variants, live): (Vec<_>, Vec<_>) = all_matches.into_iter().partition(|m| {
crate::formats::maven::is_variant(m.type_text.as_deref(), m.classifier.as_deref())
});
// A variant that carries code onto a classpath (tests, a native
// build, a JDK-specific jar) at the patched release would keep the
// unpatched bytes there while the GA counts as redirected and VEX
Expand All @@ -8800,8 +8800,14 @@ fn rewrite_maven_pom(
|| crate::formats::maven::split_socket_version(v)
.is_some_and(|(base, _)| base == dep.version);
if names_release {
let classifier = variant.classifier.as_deref().unwrap_or_default();
let executable = !matches!(classifier, "sources" | "javadoc");
let classifier = crate::formats::maven::variant_label(
variant.type_text.as_deref(),
variant.classifier.as_deref(),
);
let executable = crate::formats::maven::is_executable_variant(
variant.type_text.as_deref(),
variant.classifier.as_deref(),
);
executable_variant |= executable;
result.warnings.push(RewriteWarning {
code: "redirect_maven_classifier_unsupported".into(),
Expand Down Expand Up @@ -10345,6 +10351,16 @@ mod tests {
);
assert!(warning_codes(&r).contains(&"redirect_maven_classifier_unsupported"));
}
// A type implying an executable classifier (`test-jar` is the
// `tests` jar) is the same copy, as the vendored reactor judges it.
let typed = pom_with_dep("\n <version>1.7.36</version>", "").replace(
" </dependencies>",
" <dependency>\n <groupId>org.slf4j</groupId>\n <artifactId>slf4j-api</artifactId>\n <version>1.7.36</version>\n <type>test-jar</type>\n </dependency>\n </dependencies>",
);
let files = BTreeMap::from([("pom.xml".to_string(), typed)]);
let r = rewrite_registry_redirect(&files, &[maven_override()]);
assert!(r.files.is_empty(), "test-jar: {:?}", r.files);
assert!(warning_codes(&r).contains(&"redirect_maven_classifier_unsupported"));
}

/// Fail-closed literal pin: the `<version>` is rewritten to the suffixed
Expand Down
Loading
Loading