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
6 changes: 4 additions & 2 deletions crates/socket-patch-cli/CLI_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -579,8 +579,8 @@ A reverted lockfile plus a leftover ledger or artifact therefore stops attesting
| `patched_ref_invalid` | A Socket-shaped reference failed validation or is not live wiring: unsafe coordinates, a non-canonical uuid, a leaf naming another package, a path escaping the root, an inert or orphaned entry. |
| `patched_ref_unattributable` | A Socket patch uuid cannot be tied to exactly one artifact / version. Examples: a maven repository no pin names, a nuget source without a mapping, a lock contested by another lock. |
| `sbt_owned_file_modified` | `socket-patch.sbt` / `socket-patch-vendor.sbt` is not byte-for-byte what socket-patch generates; none of its pins is discovered. |
| `sbt_resolution_unverified` | An sbt pin the build's local resolution evidence (`target/`, after `sbt update`) does not verify: the reference stays, without the lockfile basis. |
| `vendored_tree_missing` | A `socket-patch-vendor.sbt` pin whose `.socket/vendor/maven2/` file is missing or modified. |
| `sbt_resolution_unverified` | An sbt pin the build's local resolution evidence (`target/`, after `sbt update`) does not verify, or a build-wide blocker applies (stale or partial evidence, project definitions that cannot be read statically, a `dependencyOverrides` / `resolvers` reassignment, a `build.sbt.lock`); the detail names the cause and its remedy, or that it has none. A hosted reference stays, without the lockfile basis; a vendored pin is not attested (`vex_sbt_resolution_unverified`). |
| `vendored_tree_missing` | A `socket-patch-vendor.sbt` pin whose `.socket/vendor/maven2/` file is missing or modified; it is not attested (`vex_sbt_vendored_tree_missing`). |

Human mode also prints `Note:` lines: superseded records, fetch failures, `--offline` withholding fetches, conflict details, and why a claim is dead. While patch records are fetched, a terminal shows a transient `Fetching patch records... (n/N)` status line on stderr (never under `--json` / `--silent`).

Expand Down Expand Up @@ -2678,6 +2678,8 @@ match it, withholds the statement:
| `vex_deno_lock_copy` | run warning and `failed[].reason` | An npm pin is wired, but `deno.lock` locks the same `name@version`, which `deno install` installs without the wiring; no statement while it does. `vendor --check` and `scan` still treat the wiring as live. |
| `vex_maven_reactor_root` | run warning and `failed[].reason` | A hosted Maven pin sits in a root `pom.xml` that declares `<modules>` / `<subprojects>` (a profile's included), where a module's own literal `<version>` overrides it; only the root pom is read, so no statement (#261). `list`, `rollback` and `remove` still find the pin. |
| `vex_maven_classifier_unpatched` | run warning and `failed[].reason` | A hosted Maven pin's GA also has a live executable `<classifier>` copy (not `sources` / `javadoc`) at a version other than the pin, which the classifier-less pin does not retarget and the grant does not serve; no statement while it does. `list`, `rollback` and `remove` still find the pin. |
| `vex_sbt_resolution_unverified` | run warning and `failed[].reason` | A vendored sbt pin (`socket-patch-vendor.sbt`) whose resolution the build's local evidence does not verify, by the same check and build-wide blockers as a hosted sbt pin (no evidence or evidence older than a build source, a declared project with none, a `dependencyOverrides` / `resolvers` reassignment, a `build.sbt.lock`, project definitions that cannot be read statically, another version or other bytes resolved); no statement while it holds. The note names the cause and its remedy: `sbt update` clears stale or missing evidence, keeping the setting's existing entries (appending with `+=` / `++=` in place of a `:=`, `~=` or `--=`) clears a reassignment, removing what forces another version clears a version conflict, and a build whose projects cannot be read statically (a computed `Project(...)`, `projectMatrix`, `crossProject`) is never attested. `vendor --check` and `scan` still treat the wiring as live. |
| `vex_sbt_vendored_tree_missing` | run warning and `failed[].reason` | A vendored sbt pin whose committed `.socket/vendor/maven2/` file is missing or does not hash to the pinned sha256 (sbt's load-time check fails the build); no statement until the tree is restored. |
| `vex_gradle_derived_cache_unchecked` | run warning | The derived-cache walk was cut short (a very large transforms cache); the statement is still emitted, the copies the walk did reach were checked. |

A vendored Gradle entry attests only while its wiring is live (apply line, index rows,
Expand Down
109 changes: 94 additions & 15 deletions crates/socket-patch-cli/src/commands/vex_sources.rs
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,14 @@ const NOTE_MAVEN_REACTOR_ROOT: &str = "vex_maven_reactor_root";
/// classifier copy at another version, which the pin does not reach
/// (`vex::Unattested`).
const NOTE_MAVEN_CLASSIFIER_UNPATCHED: &str = "vex_maven_classifier_unpatched";
/// Omission tag and note: a vendored sbt pin the build's local resolution
/// evidence does not verify, or under a build-wide blocker
/// (`vex::Unattested`, `UnattestedKind::SbtResolutionUnverified`).
const NOTE_SBT_RESOLUTION_UNVERIFIED: &str = "vex_sbt_resolution_unverified";
/// Omission tag and note: a vendored sbt pin's committed tree file is
/// missing or modified (`vex::Unattested`,
/// `UnattestedKind::SbtVendoredTreeMissing`).
const NOTE_SBT_VENDORED_TREE_MISSING: &str = "vex_sbt_vendored_tree_missing";

/// The omission tag of an [`Unattested`](socket_patch_core::vex::Unattested)
/// ref, and the remedy its note ends with.
Expand Down Expand Up @@ -268,9 +276,53 @@ fn unattested_note(kind: UnattestedKind) -> (&'static str, &'static str) {
"not attested while that classifier copy resolves an unpatched version; the \
wiring itself is intact, so re-running `scan` does not change this",
),
UnattestedKind::SbtResolutionUnverified => (
NOTE_SBT_RESOLUTION_UNVERIFIED,
"not attested while that holds (the detail names the remedy, or that there is \
none); the wiring itself is intact, so `vendor --check` and `scan` still treat it \
as live",
),
UnattestedKind::SbtVendoredTreeMissing => (
NOTE_SBT_VENDORED_TREE_MISSING,
"not attested while the committed tree differs from the pin (restore \
`.socket/vendor/maven2` from version control, or re-run `socket-patch vendor`)",
),
}
}

/// The [`Unattested`](socket_patch_core::vex::Unattested) mark discovery
/// put on patch `uuid` for `purl`'s package, if any.
fn unattested_mark<'d>(
discovery: &'d Discovery,
purl: &str,
uuid: &str,
) -> Option<&'d socket_patch_core::vex::Unattested> {
let pkg = canonical_base_purl(purl);
discovery
.unattested
.iter()
.find(|u| u.uuid == uuid && PurlKey::same(&u.purl, &pkg))
}

/// Omit `key` for the mark `u`: its `failed[]` entry and the note naming
/// why and the remedy.
fn gate_unattested(
key: &str,
u: &socket_patch_core::vex::Unattested,
gated: &mut Vec<FailedPatch>,
notes: &mut Vec<PlanNote>,
) {
let (code, remedy) = unattested_note(u.kind);
gated.push(failed(key, code));
notes.push(PlanNote {
code,
detail: format!(
"{key}: patch {} is wired, but {}; {remedy}",
u.uuid, u.detail
),
});
}

/// One hosted-basis view purl's wiring (see [`Plan::hosted`]).
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct HostedWiring {
Expand Down Expand Up @@ -422,23 +474,10 @@ pub(crate) async fn plan(
// rollback, remove, list and the ledgers' liveness working, and the
// patch is omitted.
cands.retain(|c| {
let pkg = canonical_base_purl(&c.key);
let Some(u) = discovery
.unattested
.iter()
.find(|u| u.uuid == c.uuid && PurlKey::same(&u.purl, &pkg))
else {
let Some(u) = unattested_mark(&discovery, &c.key, &c.uuid) else {
return true;
};
let (code, remedy) = unattested_note(u.kind);
gated.push(failed(&c.key, code));
notes.push(PlanNote {
code,
detail: format!(
"{}: patch {} is wired, but {}; {remedy}",
c.key, c.uuid, u.detail
),
});
gate_unattested(&c.key, u, &mut gated, &mut notes);
false
});
for (key, old_uuid, wired) in &superseded {
Expand Down Expand Up @@ -471,6 +510,7 @@ pub(crate) async fn plan(
continue;
}
}
let mut took_over = false;
let basis = if let Some(vref) = cand
.discovered
.iter()
Expand Down Expand Up @@ -507,6 +547,7 @@ pub(crate) async fn plan(
cand.uuid = hosted.uuid.clone();
cand.record = Some(hosted);
cand.vendor_entry = None;
took_over = true;
Basis::Hosted {
lockfile_basis: false,
}
Expand Down Expand Up @@ -542,6 +583,21 @@ pub(crate) async fn plan(
} else {
Basis::Installed
};
// The vendored basis attests the ENTRY's patch, which need not be
// the candidate's uuid (a manifest record under another uuid keeps
// the key; a vendored sbt pin has no ref to swap it in): its mark
// is checked against what is actually attested.
// Likewise the hosted-over-vendored takeover attests the redirect
// ledger's uuid, swapped in after the early gate ran.
let attested = match &basis {
Basis::Vendored(entry) => Some((entry.base_purl.as_str(), entry.uuid.as_str())),
Basis::Hosted { .. } if took_over => Some((cand.key.as_str(), cand.uuid.as_str())),
_ => None,
};
if let Some(u) = attested.and_then(|(purl, uuid)| unattested_mark(&discovery, purl, uuid)) {
gate_unattested(&cand.key, u, &mut gated, &mut notes);
continue;
}
based.push((cand, basis));
}

Expand Down Expand Up @@ -1542,6 +1598,29 @@ mod tests {
let dead = plan(&common(tmp.path()), mk(), &[], &RunApiClient::new()).await;
assert!(dead.view.patches.is_empty());
assert_eq!(dead.gated, vec![failed(key, VENDOR_UNWIRED)]);

// The takeover attests the redirect ledger's uuid, swapped in after
// the early mark gate ran: a mark on it still gates.
let mut marked = mk();
marked
.discovery
.unattested
.push(socket_patch_core::vex::Unattested {
purl: key.into(),
uuid: U2.into(),
file: "package-lock.json".into(),
detail: "package-lock.json bypasses it".into(),
kind: UnattestedKind::LockAboveBase,
});
let gated = plan(
&common(tmp.path()),
marked,
&[key.to_string()],
&RunApiClient::new(),
)
.await;
assert!(gated.view.patches.is_empty(), "{:?}", gated.view.patches);
assert_eq!(gated.gated, vec![failed(key, NOTE_LOCK_ABOVE_BASE)]);
}

/// Core discover rule 11 through the plan: a vendor ledger entry and a
Expand Down
97 changes: 91 additions & 6 deletions crates/socket-patch-cli/tests/e2e_vex_lockfile/sbt_vendored.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,24 @@
//!
//! The suffixed tree path carries no full uuid, so the generated file is no
//! `.socket/vendor/<eco>/<uuid>/` reference: a vendored sbt pin is attested
//! through the vendor ledger `vendor` writes, and the extractor diagnoses
//! what would make that attestation a lie. Each cell vendors for real
//! through the vendor ledger `vendor` writes, and only when the extractor
//! finds nothing that would make that attestation a lie (the hosted sbt
//! evidence gate). Each cell vendors for real
//! (`vendor` over the sbt 1.13.0 probe build, the installed jar in an
//! isolated Coursier cache), drops `.socket/manifest.json`, and runs
//! `vex --offline` (the ledger embeds the record):
//!
//! | cell | state | expected |
//! |---|---|---|
//! | a | after `sbt update` (the evidence resolves the suffixed version from the tree) | attested, no sbt diagnostic |
//! | b | before `sbt update` (the evidence still resolves the base version) | `sbt_resolution_unverified` |
//! | b | before `sbt update` (the evidence still resolves the base version) | `sbt_resolution_unverified`, `vex_sbt_resolution_unverified`, not attested |
//! | c | a tree jar tampered | `vendored_tree_missing`, not attested |
//! | d | the generated file edited | `sbt_owned_file_modified` |
//! | e | no ledger | nothing attested; the tree check still runs |
//! | f | after `sbt update`, then a `dependencyOverrides :=` | `vex_sbt_resolution_unverified` naming it, not attested |
//! | g | before `sbt update`, the manifest back (same or another uuid) | `vex_sbt_resolution_unverified`, not attested |

use crate::common::envelope::all_codes;
use crate::common::envelope::{all_codes, warning_codes};
use crate::common::git_sha256;

use std::path::{Path, PathBuf};
Expand All @@ -42,6 +45,8 @@ const CENTRAL: &str = "repo1.maven.org/maven2";
const MEMBER: &str = "META-INF/NOTICE.txt";
const VULN: &str = "GHSA-sbtv-test-xxxx";
const VENDORED_FILE: &str = "socket-patch-vendor.sbt";
/// A manifest uuid other than the vendored one.
const OTHER_UUID: &str = "9e8d7c6b-5a49-4382-9f0e-2a3b4c5d6e7f";

fn tree_jar(proj: &Path) -> PathBuf {
proj.join(".socket/vendor/maven2/com/google/code/gson/gson")
Expand Down Expand Up @@ -69,6 +74,8 @@ struct Fx {
_tmp: tempfile::TempDir,
proj: PathBuf,
home: SbtHome,
/// The manifest `vendor` ran with.
manifest: Value,
}

impl Fx {
Expand Down Expand Up @@ -111,6 +118,7 @@ impl Fx {
_tmp: tmp,
proj,
home,
manifest,
};
let (code, env) = fx.run(&["vendor"]);
assert_eq!(code, Some(0), "vendor: {env}");
Expand All @@ -120,6 +128,17 @@ impl Fx {
fx
}

/// Put the manifest back, its gson record filed under `uuid`.
fn restore_manifest(&self, uuid: &str) {
let mut manifest = self.manifest.clone();
manifest["patches"][GSON.purl()]["uuid"] = uuid.into();
std::fs::write(
self.proj.join(".socket/manifest.json"),
serde_json::to_vec_pretty(&manifest).unwrap(),
)
.unwrap();
}

/// What `sbt update` records after vendoring.
fn sbt_update(&self) {
let changed = record_pinned_resolution(
Expand Down Expand Up @@ -197,6 +216,17 @@ fn attested(doc: Option<&Value>) -> bool {
})
}

/// The details of the top-level warnings coded `code`.
fn warning_details(env: &Value, code: &str) -> Vec<String> {
env["warnings"]
.as_array()
.into_iter()
.flatten()
.filter(|w| w["code"] == code)
.filter_map(|w| w["detail"].as_str().map(str::to_string))
.collect()
}

const SBT_DIAGS: &[&str] = &[
"sbt_resolution_unverified",
"vendored_tree_missing",
Expand All @@ -220,11 +250,66 @@ fn sbt_vendored_vex_attests_after_sbt_update() {
#[test]
fn sbt_vendored_vex_before_sbt_update_is_unverified() {
let fx = Fx::vendored();
let (_, env, _) = fx.vex();
let (_, env, doc) = fx.vex();
let got = all_codes(&env);
assert!(
got.contains(&"sbt_resolution_unverified".to_string()),
"{env}"
);
assert!(
got.contains(&"vex_sbt_resolution_unverified".to_string()),
"{env}"
);
assert!(
!attested(doc.as_ref()),
"an unverified resolution is never attested: {doc:?}"
);
}

#[test]
fn sbt_vendored_vex_withholds_under_an_overrides_reassignment() {
let fx = Fx::vendored();
fx.sbt_update();
// Dated back to its old time, the edit stays older than the evidence
// however slow the run (staleness would withhold the pin too).
let build = fx.proj.join("build.sbt");
let dated = std::fs::metadata(&build).unwrap().modified().unwrap();
let mut text = std::fs::read_to_string(&build).unwrap();
text.push_str("ThisBuild / dependencyOverrides := Seq()\n");
std::fs::write(&build, text).unwrap();
std::fs::File::options()
.write(true)
.open(&build)
.unwrap()
.set_modified(dated)
.unwrap();
let (_, env, doc) = fx.vex();
let details = warning_details(&env, "vex_sbt_resolution_unverified");
assert!(
all_codes(&env).contains(&"sbt_resolution_unverified".to_string()),
details.iter().any(|d| d.contains("`dependencyOverrides`")),
"{env}"
);
assert!(
!attested(doc.as_ref()),
"an overrides reassignment replaces the pin: {doc:?}"
);
}

/// #2008 review: the manifest still records gson, under the vendored uuid
/// or another one (the vendor entry, not the candidate's uuid, is what the
/// vendored basis attests). Before `sbt update` neither attests.
#[test]
fn sbt_vendored_vex_with_a_manifest_still_needs_evidence() {
let fx = Fx::vendored();
for uuid in [UUID, OTHER_UUID] {
fx.restore_manifest(uuid);
let (_, env, doc) = fx.vex();
assert!(
warning_codes(&env).contains(&"vex_sbt_resolution_unverified".to_string()),
"{uuid}: {env}"
);
assert!(!attested(doc.as_ref()), "{uuid}: {doc:?}");
}
}

#[test]
Expand Down
33 changes: 33 additions & 0 deletions crates/socket-patch-core/src/crawlers/sbt_evidence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -634,3 +634,36 @@ pub(crate) fn stage_fixture(from: &Path, to: &Path) {

#[cfg(test)]
mod tests;

/// Helpers for tests that stage sbt evidence on disk (this module's and
/// `vex::discover::sbt`'s).
#[cfg(test)]
pub(crate) mod testing {
use std::path::Path;
use std::time::SystemTime;

/// Date `path` at `t`.
pub(crate) fn set_mtime(path: &Path, t: SystemTime) {
std::fs::OpenOptions::new()
.write(true)
.open(path)
.unwrap()
.set_modified(t)
.unwrap();
}

/// The `file:` URI sbt writes for `path`: `file:///C:/x` on Windows (a
/// backslash is no URI separator, and an invalid JSON escape). `path`
/// must not be a verbatim (`\\?\`) path, which sbt never records.
pub(crate) fn file_uri(path: &Path) -> String {
let text = path
.to_string_lossy()
.replace('\\', "/")
.replace(' ', "%20");
if text.starts_with('/') {
format!("file://{text}")
} else {
format!("file:///{text}")
}
}
}
Loading
Loading