Skip to content

Commit 56ccbfc

Browse files
Fail closed on unreadable scala-cli Bloop evidence, as sbt does (#1270) (#1358)
* Start refactor for #1270 Assisted-by: Claude Code:claude-opus-5-5 * Fail closed on unreadable scala-cli evidence A truncated, oversized or non-Bloop project file under .scala-build/.bloop was skipped, so the vendored scala-cli gate judged the remaining projects alone. When the skipped file was the newest project's -test twin, a test.dep at another version went unseen and vendoring went ahead while the test classpath kept the old version. Such a file now means no evidence at all, the policy the sbt evidence reader already follows, so the gate skips with vendor_scala_cli_resolution_missing and asks for a fresh compile. Symlinks, FIFOs and other workspaces' projects are still ignored. Fixes #1270 Assisted-by: Claude Code:claude-opus-5-5 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 915cf1a commit 56ccbfc

1 file changed

Lines changed: 110 additions & 7 deletions

File tree

‎crates/socket-patch-core/src/crawlers/scala_evidence.rs‎

Lines changed: 110 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,10 @@
1717
//!
1818
//! Reads are FIFO-safe and capped ([`MAX_FILES`], [`MAX_FILE_BYTES`]);
1919
//! `.scala-build` and `.bloop` must be real directories (a symlink there is
20-
//! no evidence), and symlinked project files are skipped.
20+
//! no evidence), and symlinked project files are skipped. A project file
21+
//! that is over the cap, unreadable or not a Bloop project fails closed (no
22+
//! evidence at all), as the sbt reader does: it could be the record naming
23+
//! a conflicting version.
2124
2225
use std::collections::BTreeSet;
2326
use std::path::{Path, PathBuf};
@@ -33,7 +36,7 @@ use crate::utils::fs::read_regular_to_bytes_sync;
3336
pub const BLOOP_DIR: &str = ".scala-build/.bloop";
3437
/// Most project files read; over it there is no evidence at all.
3538
pub const MAX_FILES: usize = 256;
36-
/// Largest project file read; a bigger one is skipped.
39+
/// Largest project file read; a bigger one means no evidence at all.
3740
pub const MAX_FILE_BYTES: u64 = 8 << 20;
3841
/// Source extensions scala-cli compiles from a directory input.
3942
const SOURCE_EXTENSIONS: &[&str] = &["scala", "sc", "java"];
@@ -199,20 +202,34 @@ pub fn discover(root: &Path) -> Option<ScalaEvidence> {
199202
let Ok(meta) = std::fs::symlink_metadata(&path) else {
200203
continue;
201204
};
202-
if !meta.is_file() || meta.len() > MAX_FILE_BYTES {
203-
debug_log(&format!("scala-cli evidence: skipping {}", path.display()));
205+
if !meta.is_file() {
204206
continue;
205207
}
208+
// A project file that cannot be read whole (over the cap, a read
209+
// error, a truncated or foreign record) could be the one naming a
210+
// conflicting version: no evidence at all, as for sbt
211+
// (`formats::sbt::evidence::resolution`), never a partial one.
212+
if meta.len() > MAX_FILE_BYTES {
213+
debug_log(&format!(
214+
"scala-cli evidence: {} is over the {MAX_FILE_BYTES}-byte cap",
215+
path.display()
216+
));
217+
return None;
218+
}
206219
let (Ok(bytes), Ok(written)) = (read_regular_to_bytes_sync(&path), meta.modified()) else {
207-
continue;
220+
debug_log(&format!(
221+
"scala-cli evidence: cannot read {}",
222+
path.display()
223+
));
224+
return None;
208225
};
209226
let mtime = compiled_at(&dir.join(name.trim_end_matches(".json")), written);
210227
let Some(project) = parse_bloop(&bytes) else {
211228
debug_log(&format!(
212229
"scala-cli evidence: {} is not a Bloop project",
213230
path.display()
214231
));
215-
continue;
232+
return None;
216233
};
217234
if !project.workspace_dir.as_deref().is_some_and(same_workspace) {
218235
continue;
@@ -717,7 +734,18 @@ mod tests {
717734
&dir.join("big.json"),
718735
&" ".repeat(MAX_FILE_BYTES as usize + 1),
719736
);
720-
assert_eq!(discover(&root), None, "an oversized file is skipped");
737+
assert_eq!(discover(&root), None, "an oversized file is no evidence");
738+
write(
739+
&dir.join("p.json"),
740+
&bloop_json("p", &root, &[], &[("g", "a", "1", &[])]),
741+
);
742+
assert_eq!(
743+
discover(&root),
744+
None,
745+
"an oversized project file fails closed beside a readable one"
746+
);
747+
std::fs::remove_file(dir.join("big.json")).unwrap();
748+
assert!(discover(&root).is_some());
721749
for n in 0..=MAX_FILES {
722750
write(&dir.join(format!("p{n}.txt")), "");
723751
}
@@ -732,6 +760,81 @@ mod tests {
732760
);
733761
}
734762

763+
/// #1270: a truncated, oversized or foreign `-test` twin (or newest
764+
/// project) used to be skipped, so the gate judged the main project's
765+
/// resolution alone and passed a `test.dep` at another version. Every
766+
/// unreadable record now fails closed, as the sbt reader does.
767+
#[test]
768+
fn an_unreadable_project_file_hides_no_conflict() {
769+
use crate::vendor::jvm::coursier_gate::gate;
770+
use crate::vendor::jvm::sbt_gate::GateStop;
771+
let code = |e: Option<&ScalaEvidence>, root: &Path| match gate(root, e, "g", "a", "1.0.0") {
772+
Ok(_) => "ok",
773+
Err(GateStop::Skip(w)) => w.code,
774+
Err(GateStop::Refuse(r)) => r.code,
775+
Err(GateStop::Silent) => "silent",
776+
};
777+
let tmp = tempfile::tempdir().unwrap();
778+
let root = tmp.path().canonicalize().unwrap();
779+
let main = root.join("main.scala");
780+
let test_src = root.join("main.test.scala");
781+
write(&main, "//> using dep g:a:1.0.0\n");
782+
write(&test_src, "//> using test.dep g:a:2.0.0\n");
783+
let t0 = SystemTime::now() - Duration::from_secs(600);
784+
set_mtime(&main, t0);
785+
set_mtime(&test_src, t0);
786+
let dir = root.join(BLOOP_DIR);
787+
let p = dir.join("sc_p.json");
788+
let twin = dir.join("sc_p-test.json");
789+
let twin_body = bloop_json(
790+
"sc_p-test",
791+
&root,
792+
&[&main, &test_src],
793+
&[("g", "a", "2.0.0", &[("", "/c/a-2.jar")])],
794+
);
795+
write(
796+
&p,
797+
&bloop_json(
798+
"sc_p",
799+
&root,
800+
&[&main],
801+
&[("g", "a", "1.0.0", &[("", "/c/a-1.jar")])],
802+
),
803+
);
804+
let reset = |body: &str| {
805+
write(&twin, body);
806+
set_mtime(&p, t0 + Duration::from_secs(100));
807+
set_mtime(&twin, t0 + Duration::from_secs(90));
808+
};
809+
810+
reset(&twin_body);
811+
let e = discover(&root).unwrap();
812+
assert_eq!(e.files.len(), 2);
813+
assert_eq!(e.resolution.versions("g", "a"), ["1.0.0", "2.0.0"]);
814+
assert_eq!(code(Some(&e), &root), "vendor_scala_cli_version_conflict");
815+
816+
let oversized = format!("{twin_body}{}", " ".repeat(MAX_FILE_BYTES as usize));
817+
for (why, body) in [
818+
("truncated", &twin_body[..twin_body.len() / 2]),
819+
("not a Bloop project", "{\"version\":\"1.4.0\"}"),
820+
("oversized", oversized.as_str()),
821+
] {
822+
reset(body);
823+
let e = discover(&root);
824+
assert_eq!(e, None, "a {why} -test twin is no evidence");
825+
assert_eq!(
826+
code(e.as_ref(), &root),
827+
"vendor_scala_cli_resolution_missing",
828+
"a {why} -test twin never lets the gate pass"
829+
);
830+
}
831+
// The newest main project unreadable: no older project stands in.
832+
reset(&twin_body);
833+
write(&p, "{");
834+
set_mtime(&p, t0 + Duration::from_secs(100));
835+
assert_eq!(discover(&root), None);
836+
}
837+
735838
#[cfg(unix)]
736839
#[test]
737840
fn symlinks_and_fifos_are_not_evidence() {

0 commit comments

Comments
 (0)