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
98 changes: 96 additions & 2 deletions crates/socket-patch-core/src/crawlers/cargo_crawler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,7 @@ impl CargoCrawler {
/// name and version.
async fn verify_crate_at_path(&self, path: &Path, name: &str, version: &str) -> bool {
let cargo_toml_path = path.join("Cargo.toml");
let content = match tokio::fs::read_to_string(&cargo_toml_path).await {
let content = match crate::utils::fs::read_regular_to_string(&cargo_toml_path).await {
Ok(c) => c,
Err(_) => return false,
};
Expand Down Expand Up @@ -398,7 +398,7 @@ fn scan_crate_source(src_path: &Path, seen: &mut HashSet<String>) -> Vec<Crawled
/// when the Cargo.toml has `version.workspace = true`.
fn read_crate_cargo_toml(crate_path: &Path, dir_name: &str) -> Option<(String, String)> {
let cargo_toml_path = crate_path.join("Cargo.toml");
let content = std::fs::read_to_string(&cargo_toml_path).ok()?;
let content = crate::utils::fs::read_regular_to_string_sync(&cargo_toml_path).ok()?;

// Fallback: parse directory name as <name>-<version>
parse_cargo_toml_name_version(&content)
Expand Down Expand Up @@ -1231,4 +1231,98 @@ version = "fake"
assert_eq!(rows(&new), rows(&old));
}
}

/// mkfifo(2) directly (no child process; spawning `mkfifo` flakes
/// under heavy parallel load).
#[cfg(unix)]
fn make_fifo(path: &Path) {
use std::os::unix::ffi::OsStrExt;
let c_path =
std::ffi::CString::new(path.as_os_str().as_bytes()).expect("fifo path has no NUL");
let rc = unsafe { libc::mkfifo(c_path.as_ptr(), 0o644) };
assert_eq!(
rc,
0,
"mkfifo(2) failed: {}",
std::io::Error::last_os_error()
);
}

/// On timeout the open is wedged in a blocking-pool thread that the
/// runtime waits for on shutdown; connecting a writer releases it so
/// the test FAILS instead of hanging the whole suite.
#[cfg(unix)]
async fn within_deadline<F: std::future::Future>(fifo: &Path, what: &str, fut: F) -> F::Output {
match tokio::time::timeout(std::time::Duration::from_secs(5), fut).await {
Ok(out) => out,
Err(_) => {
// O_NONBLOCK: with no reader blocked on the FIFO (the
// timeout had another cause) a blocking writer open would
// itself hang; non-blocking it just fails with ENXIO.
use std::os::unix::fs::OpenOptionsExt;
let _ = std::fs::OpenOptions::new()
.write(true)
.custom_flags(libc::O_NONBLOCK)
.open(fifo);
panic!("{what} must not block on a FIFO at {}", fifo.display());
}
}
Comment thread
mikolalysenko marked this conversation as resolved.
}

/// Regression (#592): a FIFO at `vendor/<crate>/Cargo.toml` in the
/// project tree used to wedge `crawl_all` (blocking-pool
/// `read_crate_cargo_toml`) and `find_by_purls` (`verify_crate_at_path`)
/// in open(2). Both now go through the FIFO-safe reader and skip the
/// unreadable crate, exactly like any other unreadable manifest.
#[cfg(unix)]
#[tokio::test]
async fn fifo_crate_manifest_is_skipped_not_blocked_on() {
let dir = tempfile::tempdir().unwrap();
tokio::fs::write(
dir.path().join("Cargo.toml"),
"[package]\nname = \"root\"\nversion = \"0.1.0\"\n",
)
.await
.unwrap();
let vendor = dir.path().join("vendor");
let left = vendor.join("left-1.0.0");
tokio::fs::create_dir_all(&left).await.unwrap();
let fifo = left.join("Cargo.toml");
make_fifo(&fifo);
// A readable crate beside the FIFO proves the crawl continues.
let serde_dir = vendor.join("serde");
tokio::fs::create_dir_all(&serde_dir).await.unwrap();
tokio::fs::write(
serde_dir.join("Cargo.toml"),
"[package]\nname = \"serde\"\nversion = \"1.0.200\"\n",
)
.await
.unwrap();

let crawler = CargoCrawler::new();
let options = CrawlerOptions {
cwd: dir.path().to_path_buf(),
global: false,
global_prefix: None,
};
let packages = within_deadline(&fifo, "crawl_all", crawler.crawl_all(&options)).await;
let purls: Vec<_> = packages.iter().map(|p| p.purl.as_str()).collect();
assert_eq!(purls, vec!["pkg:cargo/serde@1.0.200"]);

let found = within_deadline(
&fifo,
"find_by_purls",
crawler.find_by_purls(
&vendor,
&[
"pkg:cargo/left@1.0.0".to_string(),
"pkg:cargo/serde@1.0.200".to_string(),
],
),
)
.await
.unwrap();
assert!(!found.contains_key("pkg:cargo/left@1.0.0"));
assert!(found.contains_key("pkg:cargo/serde@1.0.200"));
}
}
60 changes: 60 additions & 0 deletions crates/socket-patch-core/src/crawlers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,63 @@ pub use pkg_managers::{detect_npm_pkg_manager, NpmPkgManager};
pub use python_crawler::PythonCrawler;
pub use ruby_crawler::RubyCrawler;
pub use types::*;

/// ARCHITECTURE GUARD (#592): crawlers read project-tree files only through
/// the FIFO-safe `utils::fs::read_regular_*` readers, so a FIFO planted in a
/// checkout can never wedge `scan`, `get` or `apply` in open(2).
#[cfg(test)]
mod architecture_tests {
use std::path::Path;

const BARE_READS: [&str; 3] = ["fs::read_to_string(", "fs::read(", "File::open("];

/// Reads of the machine-wide Maven repository (not the project tree),
/// out of scope for #592: file name and number of allowed bare reads.
const ALLOWED: [(&str, usize); 1] = [("maven_crawler.rs", 2)];

#[test]
fn crawlers_read_project_files_fifo_safely() {
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/crawlers");
let mut checked = 0;
for entry in std::fs::read_dir(&dir).expect("read crawlers dir") {
let path = entry.expect("dir entry").path();
if path.extension().is_none_or(|e| e != "rs") {
continue;
}
let name = path.file_name().unwrap().to_string_lossy().into_owned();
// A Windows (core.autocrlf) checkout has CRLF lines; normalize so
// the `\n`-joined test-module markers below still match.
let src = std::fs::read_to_string(&path)
.expect("read crawler source")
.replace("\r\n", "\n");
// Production code ends at the first in-file test module
// (`mod tests`, or this guard in `mod.rs`); earlier
// `#[cfg(test)] mod oracle;` declarations are only one line.
let prod_end = [
"#[cfg(test)]\nmod tests",
"#[cfg(test)]\nmod architecture_tests",
]
.iter()
.filter_map(|marker| src.find(marker))
.min()
.unwrap_or(src.len());
let prod = &src[..prod_end];
let bare = prod
.lines()
.filter(|l| !l.trim_start().starts_with("//"))
.filter(|l| BARE_READS.iter().any(|r| l.contains(r)))
.count();
let allowed = ALLOWED
.iter()
.find(|(file, _)| *file == name)
.map_or(0, |(_, n)| *n);
assert_eq!(
bare, allowed,
"{name} has {bare} bare file reads in production code (allowed {allowed}); \
use utils::fs::read_regular_to_string{{,_sync}} instead"
);
checked += 1;
}
assert!(checked >= 10, "only {checked} crawler files found");
}
}
89 changes: 88 additions & 1 deletion crates/socket-patch-core/src/crawlers/nuget_crawler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -503,7 +503,7 @@ async fn discover_paths_from_assets(cwd: &Path) -> Vec<PathBuf> {
/// The file is a JSON object with a `packageFolders` key containing
/// folder paths as keys, e.g.: `{"packageFolders": {"/home/user/.nuget/packages/": {}}}`.
async fn parse_project_assets_package_folders(path: &Path) -> Option<Vec<PathBuf>> {
let content = tokio::fs::read_to_string(path).await.ok()?;
let content = crate::utils::fs::read_regular_to_string(path).await.ok()?;
let json: serde_json::Value = serde_json::from_str(&content).ok()?;
let folders = json.get("packageFolders")?.as_object()?;
Some(folders.keys().map(PathBuf::from).collect())
Expand Down Expand Up @@ -1513,4 +1513,91 @@ mod tests {
);
}
}

/// mkfifo(2) directly (no child process; spawning `mkfifo` flakes
/// under heavy parallel load).
#[cfg(unix)]
fn make_fifo(path: &Path) {
use std::os::unix::ffi::OsStrExt;
let c_path =
std::ffi::CString::new(path.as_os_str().as_bytes()).expect("fifo path has no NUL");
let rc = unsafe { libc::mkfifo(c_path.as_ptr(), 0o644) };
assert_eq!(
rc,
0,
"mkfifo(2) failed: {}",
std::io::Error::last_os_error()
);
}

/// On timeout the open is wedged in a blocking-pool thread that the
/// runtime waits for on shutdown; connecting a writer releases it so
/// the test FAILS instead of hanging the whole suite.
#[cfg(unix)]
async fn within_deadline<F: std::future::Future>(fifo: &Path, what: &str, fut: F) -> F::Output {
match tokio::time::timeout(std::time::Duration::from_secs(5), fut).await {
Ok(out) => out,
Err(_) => {
// O_NONBLOCK: with no reader blocked on the FIFO (the
// timeout had another cause) a blocking writer open would
// itself hang; non-blocking it just fails with ENXIO.
use std::os::unix::fs::OpenOptionsExt;
let _ = std::fs::OpenOptions::new()
.write(true)
.custom_flags(libc::O_NONBLOCK)
.open(fifo);
panic!("{what} must not block on a FIFO at {}", fifo.display());
}
}
}

/// Regression (#592): a FIFO at `obj/project.assets.json` used to
/// wedge `get_nuget_package_paths` (and so every NuGet crawl) in
/// open(2). It now goes through the FIFO-safe reader and is skipped
/// like any other unreadable assets file; a sibling sub-project's
/// readable assets file is still discovered.
#[cfg(unix)]
#[tokio::test]
async fn fifo_project_assets_is_skipped_not_blocked_on() {
let dir = tempfile::tempdir().unwrap();
tokio::fs::write(dir.path().join("App.csproj"), "<Project />")
.await
.unwrap();
let obj_dir = dir.path().join("obj");
tokio::fs::create_dir_all(&obj_dir).await.unwrap();
let fifo = obj_dir.join("project.assets.json");
make_fifo(&fifo);

let pkg_folder = dir.path().join("nuget-cache");
tokio::fs::create_dir_all(&pkg_folder).await.unwrap();
let sub_obj = dir.path().join("Lib").join("obj");
tokio::fs::create_dir_all(&sub_obj).await.unwrap();
tokio::fs::write(
sub_obj.join("project.assets.json"),
serde_json::to_string(&serde_json::json!({
"packageFolders": { pkg_folder.to_string_lossy().to_string(): {} }
}))
.unwrap(),
)
.await
.unwrap();

let crawler = NuGetCrawler::new();
let options = CrawlerOptions {
cwd: dir.path().to_path_buf(),
global: false,
global_prefix: None,
};
let paths = within_deadline(
&fifo,
"get_nuget_package_paths",
crawler.get_nuget_package_paths(&options),
)
.await
.unwrap();
assert!(
paths.contains(&pkg_folder),
"the readable sub-project assets file must still be discovered, got {paths:?}"
);
}
}
2 changes: 1 addition & 1 deletion crates/socket-patch-core/src/crawlers/python_crawler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1212,7 +1212,7 @@ async fn find_pipenv_virtualenv_site_packages_with(
// name under WORKON_HOME; an empty file means the default placement.
let dot_venv = cwd.join(".venv");
if dot_venv.is_file() {
if let Ok(text) = std::fs::read_to_string(&dot_venv) {
if let Ok(text) = crate::utils::fs::read_regular_to_string_sync(&dot_venv) {
let name = text.trim();
if !name.is_empty() {
if name.contains('/') || name.contains('\\') {
Expand Down
Loading