Skip to content

Commit e8d815c

Browse files
committed
test(crawlers): chmod-based unreadable-dir coverage across crawlers
Adds a shared `tests/common/mod.rs` helper with `uid_is_root()` and `chmod_{unreadable,readable}` so each crawler test file can drive the `read_dir(...).await` Err arm without depending on an installed binary or specific filesystem layout. Per-crawler tests skip under uid 0 because chmod is a no-op for root. Coverage added: * cargo: scan_crate_source short-circuits on unreadable src_path * composer: read_installed_json short-circuits on unreadable file * go: scan_dir_recursive short-circuits on unreadable cache_path * npm: scan_node_modules + find_workspace_node_modules both short- circuit on unreadable dirs; the workspace test stages a readable and an unreadable workspace side-by-side to prove the readable one is still discovered. * nuget: scan_package_dir + scan_global_cache_package both short- circuit on unreadable dirs (the latter via an unreadable per-name version directory). * python: find_by_purls + scan_site_packages short-circuit on unreadable site-packages. * ruby: scan_gem_dir short-circuits on unreadable gem dir. Assisted-by: Claude Code:claude-opus-4-7
1 parent c988d98 commit e8d815c

8 files changed

Lines changed: 350 additions & 0 deletions

File tree

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
//! Shared helpers for integration tests. Crate-private.
2+
//!
3+
//! `tests/<name>/mod.rs` is treated by cargo as a non-test module
4+
//! that other integration test files can pull in via
5+
//! `#[path = "common/mod.rs"] mod common;` — keeping helpers out of
6+
//! the crate's compile path but reusable across the test suite.
7+
8+
use std::process::Command;
9+
10+
/// True when the current process is running as uid 0 (root).
11+
///
12+
/// Used by `read_dir`/`file_type` permission-error tests to skip
13+
/// themselves under root, because `chmod` of any mode against a
14+
/// directory has no effect for root (root can always read anything),
15+
/// so the Err arm we're trying to drive doesn't fire.
16+
#[cfg(unix)]
17+
pub fn uid_is_root() -> bool {
18+
Command::new("id")
19+
.arg("-u")
20+
.output()
21+
.ok()
22+
.and_then(|o| {
23+
String::from_utf8(o.stdout)
24+
.ok()
25+
.map(|s| s.trim().to_string())
26+
})
27+
.map(|s| s == "0")
28+
.unwrap_or(false)
29+
}
30+
31+
#[cfg(not(unix))]
32+
pub fn uid_is_root() -> bool {
33+
false
34+
}
35+
36+
/// Set mode 0o000 on a directory so subsequent `read_dir` returns Err.
37+
/// Used by permission-error tests; must call `chmod_readable` to
38+
/// restore before the tempdir is dropped or cleanup will fail.
39+
#[cfg(unix)]
40+
pub fn chmod_unreadable(path: &std::path::Path) {
41+
use std::os::unix::fs::PermissionsExt;
42+
let perms = std::fs::Permissions::from_mode(0o000);
43+
std::fs::set_permissions(path, perms).expect("chmod 000 must succeed");
44+
}
45+
46+
#[cfg(unix)]
47+
pub fn chmod_readable(path: &std::path::Path) {
48+
use std::os::unix::fs::PermissionsExt;
49+
let perms = std::fs::Permissions::from_mode(0o700);
50+
let _ = std::fs::set_permissions(path, perms);
51+
}

crates/socket-patch-core/tests/crawler_cargo_e2e.rs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -528,6 +528,41 @@ async fn crawl_all_skips_crate_with_unparseable_toml_and_no_version_dir_name() {
528528
assert!(result.is_empty(), "unparseable + no-version dir name must be skipped");
529529
}
530530

531+
#[cfg(unix)]
532+
#[path = "common/mod.rs"]
533+
mod common;
534+
535+
/// `scan_crate_source` short-circuits when `read_dir` returns Err.
536+
/// Drive by chmod 000-ing a tempdir then asking the crawler to scan
537+
/// it. Skipped under root because chmod has no effect on uid 0.
538+
#[cfg(unix)]
539+
#[tokio::test]
540+
async fn crawl_all_handles_unreadable_src_path() {
541+
if common::uid_is_root() {
542+
eprintln!("SKIP: chmod 000 is a no-op under root");
543+
return;
544+
}
545+
let tmp = tempfile::tempdir().unwrap();
546+
let unreadable = tmp.path().join("blocked");
547+
tokio::fs::create_dir_all(&unreadable).await.unwrap();
548+
// Put a "crate" inside so we can prove the scan really stopped at
549+
// the unreadable barrier rather than just finding nothing.
550+
stage_registry_crate(&unreadable, "would-be-found", "1.0.0").await;
551+
common::chmod_unreadable(&unreadable);
552+
553+
let crawler = CargoCrawler;
554+
let opts = CrawlerOptions {
555+
cwd: tmp.path().to_path_buf(),
556+
global: true,
557+
global_prefix: Some(unreadable.clone()),
558+
batch_size: 100,
559+
};
560+
let result = crawler.crawl_all(&opts).await;
561+
common::chmod_readable(&unreadable);
562+
563+
assert!(result.is_empty(), "unreadable src_path must yield empty");
564+
}
565+
531566
/// `verify_crate_at_path` returns false when neither the Cargo.toml
532567
/// parses NOR the dir-name parses — exercises the `else { false }`
533568
/// arm at line 345-346.

crates/socket-patch-core/tests/crawler_composer_e2e.rs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -377,6 +377,38 @@ async fn get_vendor_paths_global_no_composer_no_home_layout_returns_empty() {
377377
assert!(paths.is_empty(), "no composer source anywhere must yield empty; got {paths:?}");
378378
}
379379

380+
#[cfg(unix)]
381+
#[path = "common/mod.rs"]
382+
mod common;
383+
384+
/// `read_installed_json` short-circuits when the file can't be read —
385+
/// chmod 000 the installed.json and assert the crawler returns empty
386+
/// rather than panicking.
387+
#[cfg(unix)]
388+
#[tokio::test]
389+
async fn find_by_purls_handles_unreadable_installed_json() {
390+
if common::uid_is_root() {
391+
eprintln!("SKIP: chmod 000 is a no-op under root");
392+
return;
393+
}
394+
let tmp = tempfile::tempdir().unwrap();
395+
let vendor = tmp.path().join("vendor");
396+
let composer = vendor.join("composer");
397+
tokio::fs::create_dir_all(&composer).await.unwrap();
398+
let installed = composer.join("installed.json");
399+
tokio::fs::write(&installed, r#"{"packages":[]}"#).await.unwrap();
400+
common::chmod_unreadable(&installed);
401+
402+
let crawler = ComposerCrawler;
403+
let result = crawler
404+
.find_by_purls(&vendor, &[ORG_PURL.to_string()])
405+
.await
406+
.unwrap();
407+
common::chmod_readable(&installed);
408+
409+
assert!(result.is_empty(), "unreadable installed.json must yield empty");
410+
}
411+
380412
/// `crawl_all` should dedup packages discovered across multiple
381413
/// vendor paths sharing the same installed package — exercises the
382414
/// `seen.contains` early-continue arm.

crates/socket-patch-core/tests/crawler_go_e2e.rs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,37 @@ async fn get_module_cache_paths_with_go_mod_returns_cache() {
178178
);
179179
}
180180

181+
#[cfg(unix)]
182+
#[path = "common/mod.rs"]
183+
mod common;
184+
185+
/// `scan_dir_recursive` short-circuits when read_dir returns Err.
186+
#[cfg(unix)]
187+
#[tokio::test]
188+
async fn crawl_all_handles_unreadable_cache_path() {
189+
if common::uid_is_root() {
190+
eprintln!("SKIP: chmod 000 is a no-op under root");
191+
return;
192+
}
193+
let tmp = tempfile::tempdir().unwrap();
194+
let cache = tmp.path().join("blocked-cache");
195+
tokio::fs::create_dir(&cache).await.unwrap();
196+
let _ = stage_go_module(&cache, "github.com/foo/bar", "v1.0.0").await;
197+
common::chmod_unreadable(&cache);
198+
199+
let crawler = GoCrawler;
200+
let opts = CrawlerOptions {
201+
cwd: tmp.path().to_path_buf(),
202+
global: true,
203+
global_prefix: Some(cache.clone()),
204+
batch_size: 100,
205+
};
206+
let result = crawler.crawl_all(&opts).await;
207+
common::chmod_readable(&cache);
208+
209+
assert!(result.is_empty(), "unreadable cache must yield empty");
210+
}
211+
181212
/// `GoCrawler::default()` should forward to `new()`.
182213
#[test]
183214
fn go_crawler_default_and_new_construct_cleanly() {

crates/socket-patch-core/tests/crawler_npm_e2e.rs

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -507,6 +507,61 @@ async fn crawl_all_skips_hidden_and_skip_dirs() {
507507
assert!(!names.contains(&"also-not"), "SKIP_DIRS dir must be skipped");
508508
}
509509

510+
#[cfg(unix)]
511+
#[path = "common/mod.rs"]
512+
mod common;
513+
514+
/// `scan_node_modules` short-circuits when read_dir returns Err.
515+
#[cfg(unix)]
516+
#[tokio::test]
517+
async fn crawl_all_handles_unreadable_node_modules() {
518+
if common::uid_is_root() {
519+
eprintln!("SKIP: chmod 000 is a no-op under root");
520+
return;
521+
}
522+
let tmp = tempfile::tempdir().unwrap();
523+
let nm = tmp.path().join("node_modules");
524+
stage_npm_pkg(&nm, "would-be-found", "1.0.0").await;
525+
common::chmod_unreadable(&nm);
526+
527+
let crawler = NpmCrawler;
528+
let opts = options_at(tmp.path());
529+
let result = crawler.crawl_all(&opts).await;
530+
common::chmod_readable(&nm);
531+
532+
assert!(result.is_empty(), "unreadable node_modules must yield empty");
533+
}
534+
535+
/// `find_workspace_node_modules` short-circuits cleanly when it
536+
/// encounters an unreadable workspace subdir — drives the read_dir
537+
/// Err arm at npm_crawler.rs:440-441 by chmod 000-ing one workspace
538+
/// while leaving a readable one alongside.
539+
#[cfg(unix)]
540+
#[tokio::test]
541+
async fn crawl_all_handles_unreadable_workspace_dir() {
542+
if common::uid_is_root() {
543+
eprintln!("SKIP: chmod 000 is a no-op under root");
544+
return;
545+
}
546+
let tmp = tempfile::tempdir().unwrap();
547+
// Readable workspace.
548+
stage_npm_pkg(&tmp.path().join("readable").join("node_modules"), "ok", "1.0.0").await;
549+
// Unreadable workspace.
550+
let blocked = tmp.path().join("blocked");
551+
tokio::fs::create_dir(&blocked).await.unwrap();
552+
stage_npm_pkg(&blocked.join("node_modules"), "hidden", "2.0.0").await;
553+
common::chmod_unreadable(&blocked);
554+
555+
let crawler = NpmCrawler;
556+
let opts = options_at(tmp.path());
557+
let result = crawler.crawl_all(&opts).await;
558+
common::chmod_readable(&blocked);
559+
560+
let names: Vec<&str> = result.iter().map(|p| p.name.as_str()).collect();
561+
assert!(names.contains(&"ok"));
562+
assert!(!names.contains(&"hidden"), "unreadable workspace must be skipped");
563+
}
564+
510565
/// Drives scoped-package scanning + nested node_modules recursion +
511566
/// the hidden-and-file-entries skip arms inside `scan_scoped_packages`
512567
/// and `scan_nested_node_modules`. Covers L552, 581-604, 619-665.

crates/socket-patch-core/tests/crawler_nuget_e2e.rs

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,65 @@ async fn find_by_purls_with_lib_dir_marker_succeeds() {
341341
assert_eq!(result.len(), 1);
342342
}
343343

344+
#[cfg(unix)]
345+
#[path = "common/mod.rs"]
346+
mod common;
347+
348+
/// `scan_package_dir` short-circuits when read_dir returns Err.
349+
#[cfg(unix)]
350+
#[tokio::test]
351+
async fn crawl_all_handles_unreadable_pkg_path() {
352+
if common::uid_is_root() {
353+
eprintln!("SKIP: chmod 000 is a no-op under root");
354+
return;
355+
}
356+
let tmp = tempfile::tempdir().unwrap();
357+
let pkg = tmp.path().join("blocked");
358+
tokio::fs::create_dir(&pkg).await.unwrap();
359+
let _ = stage_global_cache_pkg(&pkg, "newtonsoft.json", "13.0.3").await;
360+
common::chmod_unreadable(&pkg);
361+
362+
let crawler = NuGetCrawler;
363+
let opts = CrawlerOptions {
364+
cwd: tmp.path().to_path_buf(),
365+
global: true,
366+
global_prefix: Some(pkg.clone()),
367+
batch_size: 100,
368+
};
369+
let result = crawler.crawl_all(&opts).await;
370+
common::chmod_readable(&pkg);
371+
372+
assert!(result.is_empty(), "unreadable pkg_path must yield empty");
373+
}
374+
375+
/// `scan_global_cache_package` returns None when the per-name version
376+
/// directory is unreadable — drives the inner read_dir Err arm at
377+
/// nuget_crawler.rs:236.
378+
#[cfg(unix)]
379+
#[tokio::test]
380+
async fn crawl_all_handles_unreadable_version_dir() {
381+
if common::uid_is_root() {
382+
eprintln!("SKIP: chmod 000 is a no-op under root");
383+
return;
384+
}
385+
let tmp = tempfile::tempdir().unwrap();
386+
let pkg_name_dir = tmp.path().join("blocked-name");
387+
tokio::fs::create_dir(&pkg_name_dir).await.unwrap();
388+
common::chmod_unreadable(&pkg_name_dir);
389+
390+
let crawler = NuGetCrawler;
391+
let opts = CrawlerOptions {
392+
cwd: tmp.path().to_path_buf(),
393+
global: true,
394+
global_prefix: Some(tmp.path().to_path_buf()),
395+
batch_size: 100,
396+
};
397+
let result = crawler.crawl_all(&opts).await;
398+
common::chmod_readable(&pkg_name_dir);
399+
400+
assert!(result.is_empty(), "unreadable version dir must yield empty");
401+
}
402+
344403
/// `scan_package_dir` skips entries that are not directories — covers
345404
/// the `if !ft.is_dir()` continue arm at L183. Drive this by staging
346405
/// a plain file alongside a valid global-cache package.

crates/socket-patch-core/tests/crawler_python_e2e.rs

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,61 @@ async fn read_python_metadata_missing_name_returns_none() {
275275
assert_eq!(result, None);
276276
}
277277

278+
#[cfg(unix)]
279+
#[path = "common/mod.rs"]
280+
mod common;
281+
282+
/// `find_by_purls` short-circuits when the site-packages dir is
283+
/// unreadable. Drives the python_crawler.rs:530 read_dir Err arm.
284+
#[cfg(unix)]
285+
#[tokio::test]
286+
async fn find_by_purls_handles_unreadable_site_packages() {
287+
if common::uid_is_root() {
288+
eprintln!("SKIP: chmod 000 is a no-op under root");
289+
return;
290+
}
291+
let tmp = tempfile::tempdir().unwrap();
292+
let site_packages = tmp.path().join("sp");
293+
tokio::fs::create_dir(&site_packages).await.unwrap();
294+
common::chmod_unreadable(&site_packages);
295+
296+
let crawler = PythonCrawler;
297+
let result = crawler
298+
.find_by_purls(&site_packages, &["pkg:pypi/requests@2.28.0".to_string()])
299+
.await
300+
.unwrap();
301+
common::chmod_readable(&site_packages);
302+
303+
assert!(result.is_empty());
304+
}
305+
306+
/// `scan_site_packages` short-circuits when site-packages is
307+
/// unreadable — drives python_crawler.rs:584 read_dir Err arm.
308+
#[cfg(unix)]
309+
#[tokio::test]
310+
async fn crawl_all_handles_unreadable_site_packages() {
311+
if common::uid_is_root() {
312+
eprintln!("SKIP: chmod 000 is a no-op under root");
313+
return;
314+
}
315+
let tmp = tempfile::tempdir().unwrap();
316+
let site_packages = tmp.path().join("sp");
317+
tokio::fs::create_dir(&site_packages).await.unwrap();
318+
common::chmod_unreadable(&site_packages);
319+
320+
let crawler = PythonCrawler;
321+
let opts = CrawlerOptions {
322+
cwd: tmp.path().to_path_buf(),
323+
global: true,
324+
global_prefix: Some(site_packages.clone()),
325+
batch_size: 100,
326+
};
327+
let result = crawler.crawl_all(&opts).await;
328+
common::chmod_readable(&site_packages);
329+
330+
assert!(result.is_empty());
331+
}
332+
278333
/// `PythonCrawler::default()` should forward to `new()`.
279334
#[test]
280335
fn python_crawler_default_and_new_construct_cleanly() {

crates/socket-patch-core/tests/crawler_ruby_e2e.rs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,38 @@ async fn global_gem_discovery_via_home_dotgem_layout() {
221221
);
222222
}
223223

224+
#[cfg(unix)]
225+
#[path = "common/mod.rs"]
226+
mod common;
227+
228+
/// `scan_gem_dir` short-circuits when the gem path is unreadable —
229+
/// drives ruby_crawler.rs:270 read_dir Err arm.
230+
#[cfg(unix)]
231+
#[tokio::test]
232+
async fn crawl_all_handles_unreadable_gem_dir() {
233+
if common::uid_is_root() {
234+
eprintln!("SKIP: chmod 000 is a no-op under root");
235+
return;
236+
}
237+
let tmp = tempfile::tempdir().unwrap();
238+
let gem_dir = tmp.path().join("blocked-gems");
239+
tokio::fs::create_dir(&gem_dir).await.unwrap();
240+
let _ = stage_gem(&gem_dir, "rails", "7.1.0").await;
241+
common::chmod_unreadable(&gem_dir);
242+
243+
let crawler = RubyCrawler;
244+
let opts = CrawlerOptions {
245+
cwd: tmp.path().to_path_buf(),
246+
global: true,
247+
global_prefix: Some(gem_dir.clone()),
248+
batch_size: 100,
249+
};
250+
let result = crawler.crawl_all(&opts).await;
251+
common::chmod_readable(&gem_dir);
252+
253+
assert!(result.is_empty(), "unreadable gem dir must yield empty");
254+
}
255+
224256
/// `RubyCrawler::default()` should forward to `new()`.
225257
#[test]
226258
fn ruby_crawler_default_and_new_construct_cleanly() {

0 commit comments

Comments
 (0)