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
29 changes: 24 additions & 5 deletions crates/socket-patch-cli/CLI_CONTRACT.md

Large diffs are not rendered by default.

12 changes: 12 additions & 0 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,12 @@ 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 hosted sbt pin whose bytes the build provably
/// does not use (a `dependencyOverrides` reassignment, an `onLoad` one
/// dropping the load-time verifier, or current evidence
/// resolving another version or other bytes), so the downloaded copy
/// proves nothing (`vex::Unattested`).
const NOTE_SBT_PIN_BYPASSED: &str = "vex_sbt_pin_bypassed";

/// The omission tag of an [`Unattested`](socket_patch_core::vex::Unattested)
/// ref, and the remedy its note ends with.
Expand Down Expand Up @@ -268,6 +274,12 @@ 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::SbtPinBypassed => (
NOTE_SBT_PIN_BYPASSED,
"not attested while the build bypasses the pin, even from the copy under \
`.socket/sbt-hosted`; the wiring itself is intact, so re-running `scan` does not \
change this (fix the build, then run `sbt update`)",
),
}
}

Expand Down
103 changes: 103 additions & 0 deletions crates/socket-patch-cli/tests/e2e_sbt_hosted.rs
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,109 @@ fn hosted_vex_without_evidence_is_unverified() {
);
}

/// The suffixed copy sbt downloads into `.socket/sbt-hosted` at load.
fn write_installed_copy(project: &Path) -> PathBuf {
let copy = project
.join(HOSTED_REPO_REL)
.join("com/google/code/gson/gson")
.join(SV);
std::fs::create_dir_all(&copy).unwrap();
std::fs::write(copy.join(format!("gson-{SV}.jar")), JAR).unwrap();
std::fs::write(copy.join(format!("gson-{SV}.pom")), POM).unwrap();
copy
}

/// `(attested, vex_sbt_pin_bypassed, envelope)` of a `vex` run over
/// `project`.
fn vex_verdict(home: &SbtHome, project: &Path) -> (bool, bool, serde_json::Value) {
let api = Api::start();
api.grant(UUID, GROUP, ARTIFACT, BASE);
let (_, json) = vex(home, project, &api.server.uri());
let doc = std::fs::read_to_string(project.join("vex.json")).unwrap_or_default();
let bypassed = all_codes(&json).contains(&"vex_sbt_pin_bypassed".to_string());
(doc.contains("\"not_affected\""), bypassed, json)
}

/// An unverified pin (no evidence) keeps the installed-copy basis: the
/// suffixed copy sbt downloaded into `.socket/sbt-hosted` at load attests
/// it. A `dependencyOverrides` reassignment replaces the generated override
/// where it applies, so the same copy proves nothing about what the build
/// runs: the patch is omitted with `vex_sbt_pin_bypassed`. A `resolvers`
/// reassignment does not: the override still forces the Socket-only
/// version and sbt's load-time verifier checks its bytes, so that build
/// runs the pinned bytes or fails — unless an `onLoad` reassignment drops
/// the installer that verifier lives in, which withdraws the pin too.
#[test]
fn hosted_vex_installed_copy_is_withdrawn_by_an_overrides_reassignment() {
for (edit, withdrawn) in [
(None, false),
(Some("dependencyOverrides := Seq()\n"), true),
(Some("resolvers := Seq(Resolver.mavenLocal)\n"), false),
// Dropping the generated installer removes that verifier.
(
Some("resolvers := Seq(Resolver.mavenLocal)\nGlobal / onLoad := identity\n"),
true,
),
(
Some("Global / onLoad := (Global / onLoad).value andThen (s => s)\n"),
false,
),
] {
let tmp = tempfile::tempdir().unwrap();
let home = SbtHome::new(tmp.path());
let project = committed_project(tmp.path());
if let Some(edit) = edit {
let mut build = std::fs::read_to_string(project.join("build.sbt")).unwrap();
build.push_str(edit);
std::fs::write(project.join("build.sbt"), build).unwrap();
}
write_installed_copy(&project);
let (attested, bypassed, json) = vex_verdict(&home, &project);
assert!(
all_codes(&json).contains(&"sbt_resolution_unverified".to_string()),
"{edit:?}: {json}"
);
assert_eq!(
(attested, bypassed),
(!withdrawn, withdrawn),
"{edit:?}: {json}"
);
}
}

/// Post-wiring evidence that verifies the pin is never overruled by a
/// static reading of the build: `sbt.override.build.repos=true` (no
/// evidence blocker, so the lockfile basis stands) or a self-referencing
/// `dependencyOverrides :=` (an evidence blocker, so the installed copy
/// attests).
#[test]
fn hosted_vex_never_withdraws_a_pin_current_evidence_verifies() {
for (file, edit) in [
(".sbtopts", "-Dsbt.override.build.repos=true\n"),
(
"build.sbt",
"dependencyOverrides := dependencyOverrides.value\n",
),
] {
let tmp = tempfile::tempdir().unwrap();
let home = SbtHome::new(tmp.path());
let project = evidence_project(tmp.path());
let mut text = std::fs::read_to_string(project.join(file)).unwrap_or_default();
text.push_str(edit);
std::fs::write(project.join(file), text).unwrap();
std::fs::write(project.join(HOSTED_FILE), expected_file(&project)).unwrap();
let copy = write_installed_copy(&project);
post_update_evidence(
&project,
&std::fs::canonicalize(&copy)
.unwrap()
.join(format!("gson-{SV}.jar")),
);
let (attested, bypassed, json) = vex_verdict(&home, &project);
assert!(attested && !bypassed, "{file}: {json}");
}
}

/// What `sbt update` records after wiring: `b` resolves the pinned version,
/// its jar at `jar` (absolute), dated after the generated file.
fn post_update_evidence(project: &Path, jar: &Path) {
Expand Down
53 changes: 42 additions & 11 deletions crates/socket-patch-core/src/crawlers/sbt_evidence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ use crate::formats::sbt::build::{
declared_projects, source_kind, SourceKind, BUILD_PROPERTIES, BUILD_SBT,
};
use crate::formats::sbt::evidence::{classify_path, is_meta_record, ResolutionDoc};
use crate::formats::sbt::owned_file::{HOSTED_FILE, VENDORED_FILE};
use crate::formats::sbt::owned_file::{is_foreign, HOSTED_FILE, VENDORED_FILE};
use crate::formats::sbt::JvmResolution;
use crate::utils::fs::read_regular_to_bytes_sync;

Expand Down Expand Up @@ -81,6 +81,17 @@ pub struct SbtEvidence {
/// A generated file ([`HOSTED_FILE`] / [`VENDORED_FILE`]) is newer than
/// the newest evidence: sbt has not resolved since the last wiring.
pub wiring_newer: bool,
/// Each library evidence file's date: its root-relative path (as its
/// evidence ids name it) to the newer of its own and its sibling
/// `inputs`' modification time. One record can then be judged against
/// the wiring and the sources, where [`Self::stale`] and
/// [`Self::wiring_newer`] judge whole projects or the whole build.
pub record_dates: std::collections::BTreeMap<String, SystemTime>,
/// The newest generated file's modification time.
pub generated: Option<SystemTime>,
/// The newest build source's modification time (what [`Self::stale`]
/// compares against).
pub newest_source: Option<SystemTime>,
}

/// The evidence under `root`, `None` when there is none (or a cap was hit,
Expand Down Expand Up @@ -131,6 +142,11 @@ pub fn scan(root: &Path) -> Result<SbtEvidence, String> {
continue;
}
newer(&mut out.newest_evidence, *modified);
if let Some(at) = *modified {
let file = output.clone().unwrap_or_else(|| rel.clone());
let date = out.record_dates.entry(file).or_insert(at);
*date = (*date).max(at);
}
newer(
per_project
.entry((record.project, record.project_is_id))
Expand All @@ -147,6 +163,8 @@ pub fn scan(root: &Path) -> Result<SbtEvidence, String> {
.is_some_and(|s| oldest_project.is_some_and(|o| s > o));
out.wiring_newer = walk.generated.is_some_and(|g| g > newest);
}
out.generated = walk.generated;
out.newest_source = walk.newest_source;
Ok(out)
}

Expand Down Expand Up @@ -513,17 +531,30 @@ impl Walk<'_> {
}

/// A non-evidence file above any `target/`: a build source, or a
/// generated file (dated only).
/// generated file (dated only). A file under a generated name without
/// socket-patch's header is not ours: sbt loads it like any root
/// `*.sbt`, so it is a build definition
/// (`formats::sbt::build::scan_build_sources` reads it as one). One
/// that cannot be read keeps the generated file's treatment.
fn file(&mut self, rel: &str, meta: &std::fs::Metadata) -> Result<(), String> {
if rel == HOSTED_FILE || rel == VENDORED_FILE {
newer(&mut self.generated, meta.modified().ok());
return Ok(());
}
let Some(kind) = source_kind(rel) else {
return Ok(());
};
let Some(bytes) = self.read(rel, meta, false)? else {
return Ok(());
let (kind, bytes) = if rel == HOSTED_FILE || rel == VENDORED_FILE {
match self.read(rel, meta, false).ok().flatten() {
Some(bytes) if is_foreign(&String::from_utf8_lossy(&bytes)) => {
(SourceKind::Definition, bytes)
}
_ => {
newer(&mut self.generated, meta.modified().ok());
return Ok(());
}
}
} else {
let Some(kind) = source_kind(rel) else {
return Ok(());
};
let Some(bytes) = self.read(rel, meta, false)? else {
return Ok(());
};
(kind, bytes)
};
if matches!(
kind,
Expand Down
16 changes: 14 additions & 2 deletions crates/socket-patch-core/src/crawlers/sbt_evidence/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -227,13 +227,25 @@ fn staleness_compares_sources_with_the_newest_output_or_inputs() {
assert!(scan(root).unwrap().stale);
// The generated files date the wiring, never the build.
set_mtime(&props, at(10));
let hosted = write(root, HOSTED_FILE, b"");
let hosted = write(root, HOSTED_FILE, b"// Generated by socket-patch.\n");
set_mtime(&hosted, at(60));
let e = scan(root).unwrap();
assert!(!e.stale && e.wiring_newer);
assert!(!e.build_sources.iter().any(|(r, _)| r == HOSTED_FILE));
assert_eq!((e.generated, e.newest_source), (Some(at(60)), Some(at(30))));
set_mtime(&hosted, at(1));
assert!(!scan(root).unwrap().wiring_newer);
// A user's own file under the name is a build definition sbt loads:
// a source (dating and scanned), never the wiring.
write(root, HOSTED_FILE, b"dependencyOverrides := Seq()\n");
set_mtime(&hosted, at(60));
let e = scan(root).unwrap();
assert!(e.stale && !e.wiring_newer && e.generated.is_none());
assert!(e.build_sources.iter().any(|(r, _)| r == HOSTED_FILE));
assert_eq!(
crate::formats::sbt::build::scan_build_sources(&e.build_sources).overrides_assignment,
[format!("{HOSTED_FILE}:1")]
);
}

#[test]
Expand Down Expand Up @@ -462,7 +474,7 @@ fn meta_build_records_never_date_the_evidence() {
set_mtime(&lib_out, at(40));
assert!(!scan(&root).unwrap().stale, "{root_name}: {meta}");
// The wiring is dated against the library records too.
let hosted = write(&root, HOSTED_FILE, b"");
let hosted = write(&root, HOSTED_FILE, b"// Generated by socket-patch.\n");
set_mtime(&hosted, at(45));
set_mtime(&meta_out, at(50));
assert!(scan(&root).unwrap().wiring_newer, "{root_name}: {meta}");
Expand Down
Loading
Loading