|
| 1 | +//! Integration coverage for `crawlers::nuget_crawler`. The |
| 2 | +//! apply-CLI suite drives the global-cache `find_by_purls` happy |
| 3 | +//! path with `SOCKET_EXPERIMENTAL_NUGET=1`; everything else here — |
| 4 | +//! legacy `Packages/<Name>.<Version>` layout, case-insensitive |
| 5 | +//! lookup, `crawl_all` directory scanning, `scan_package_dir`'s |
| 6 | +//! hidden-dir skip, `get_nuget_package_paths` discovery branches — |
| 7 | +//! goes uncovered without these tests. |
| 8 | +
|
| 9 | +#![cfg(feature = "nuget")] |
| 10 | + |
| 11 | +use std::path::Path; |
| 12 | + |
| 13 | +use serial_test::serial; |
| 14 | +use socket_patch_core::crawlers::types::CrawlerOptions; |
| 15 | +use socket_patch_core::crawlers::NuGetCrawler; |
| 16 | + |
| 17 | +const ORG_PURL_A: &str = "pkg:nuget/Newtonsoft.Json@13.0.3"; |
| 18 | +const ORG_PURL_B: &str = "pkg:nuget/Serilog@4.0.0"; |
| 19 | + |
| 20 | +fn options_at(root: &Path) -> CrawlerOptions { |
| 21 | + CrawlerOptions { |
| 22 | + cwd: root.to_path_buf(), |
| 23 | + global: false, |
| 24 | + global_prefix: None, |
| 25 | + batch_size: 100, |
| 26 | + } |
| 27 | +} |
| 28 | + |
| 29 | +/// Stage a global-cache layout: <root>/<lowercase>/<version>/ with |
| 30 | +/// a minimal `.nuspec` so verify_nuget_package returns true. |
| 31 | +async fn stage_global_cache_pkg(root: &Path, name: &str, version: &str) -> std::path::PathBuf { |
| 32 | + let pkg_dir = root.join(name.to_lowercase()).join(version); |
| 33 | + tokio::fs::create_dir_all(&pkg_dir).await.unwrap(); |
| 34 | + tokio::fs::write( |
| 35 | + pkg_dir.join(format!("{}.nuspec", name.to_lowercase())), |
| 36 | + format!( |
| 37 | + r#"<?xml version="1.0"?><package><metadata><id>{name}</id><version>{version}</version></metadata></package>"# |
| 38 | + ), |
| 39 | + ) |
| 40 | + .await |
| 41 | + .unwrap(); |
| 42 | + pkg_dir |
| 43 | +} |
| 44 | + |
| 45 | +/// Stage a legacy <Name>.<Version> layout. Used by older |
| 46 | +/// `packages.config` projects. |
| 47 | +async fn stage_legacy_pkg(root: &Path, name: &str, version: &str) -> std::path::PathBuf { |
| 48 | + let pkg_dir = root.join(format!("{name}.{version}")); |
| 49 | + tokio::fs::create_dir_all(pkg_dir.join("lib")).await.unwrap(); |
| 50 | + tokio::fs::write( |
| 51 | + pkg_dir.join(format!("{name}.nuspec")), |
| 52 | + format!( |
| 53 | + r#"<?xml version="1.0"?><package><metadata><id>{name}</id><version>{version}</version></metadata></package>"# |
| 54 | + ), |
| 55 | + ) |
| 56 | + .await |
| 57 | + .unwrap(); |
| 58 | + pkg_dir |
| 59 | +} |
| 60 | + |
| 61 | +// ── find_by_purls ────────────────────────────────────────────── |
| 62 | + |
| 63 | +#[tokio::test] |
| 64 | +async fn find_by_purls_global_cache_layout_finds_package() { |
| 65 | + let tmp = tempfile::tempdir().unwrap(); |
| 66 | + let pkg_dir = stage_global_cache_pkg(tmp.path(), "Newtonsoft.Json", "13.0.3").await; |
| 67 | + |
| 68 | + let crawler = NuGetCrawler; |
| 69 | + let result = crawler |
| 70 | + .find_by_purls(tmp.path(), &[ORG_PURL_A.to_string()]) |
| 71 | + .await |
| 72 | + .unwrap(); |
| 73 | + assert_eq!(result.len(), 1); |
| 74 | + let pkg = result.get(ORG_PURL_A).expect("must find by purl"); |
| 75 | + assert_eq!(pkg.path, pkg_dir); |
| 76 | + assert_eq!(pkg.name, "Newtonsoft.Json"); |
| 77 | + assert_eq!(pkg.version, "13.0.3"); |
| 78 | +} |
| 79 | + |
| 80 | +#[tokio::test] |
| 81 | +async fn find_by_purls_legacy_layout_finds_package() { |
| 82 | + let tmp = tempfile::tempdir().unwrap(); |
| 83 | + let pkg_dir = stage_legacy_pkg(tmp.path(), "Newtonsoft.Json", "13.0.3").await; |
| 84 | + |
| 85 | + let crawler = NuGetCrawler; |
| 86 | + let result = crawler |
| 87 | + .find_by_purls(tmp.path(), &[ORG_PURL_A.to_string()]) |
| 88 | + .await |
| 89 | + .unwrap(); |
| 90 | + assert_eq!(result.len(), 1); |
| 91 | + assert_eq!(result.get(ORG_PURL_A).unwrap().path, pkg_dir); |
| 92 | +} |
| 93 | + |
| 94 | +/// PURL with a case-mismatched name. NuGet package names are |
| 95 | +/// case-insensitive — the case-insensitive legacy scan must locate |
| 96 | +/// the package even when only a differently-cased dir exists. |
| 97 | +/// |
| 98 | +/// On case-insensitive filesystems (default macOS APFS), this exercises |
| 99 | +/// the same fast-path `legacy_dir` branch since the filesystem itself |
| 100 | +/// folds names. On case-sensitive filesystems (Linux ext4), the |
| 101 | +/// case-insensitive scan branch fires. |
| 102 | +#[tokio::test] |
| 103 | +async fn find_by_purls_case_insensitive_legacy_layout() { |
| 104 | + let tmp = tempfile::tempdir().unwrap(); |
| 105 | + let _pkg_dir = stage_legacy_pkg(tmp.path(), "newtonsoft.json", "13.0.3").await; |
| 106 | + |
| 107 | + let crawler = NuGetCrawler; |
| 108 | + let result = crawler |
| 109 | + .find_by_purls(tmp.path(), &[ORG_PURL_A.to_string()]) |
| 110 | + .await |
| 111 | + .unwrap(); |
| 112 | + assert_eq!(result.len(), 1, "package must be found via either fast or case-insensitive path"); |
| 113 | + let found = result.get(ORG_PURL_A).unwrap(); |
| 114 | + // Either casing is acceptable; the contract is "matched something". |
| 115 | + assert!(found.path.exists(), "returned path must exist; got {:?}", found.path); |
| 116 | +} |
| 117 | + |
| 118 | +#[tokio::test] |
| 119 | +async fn find_by_purls_no_match_returns_empty() { |
| 120 | + let tmp = tempfile::tempdir().unwrap(); |
| 121 | + // Empty dir — no packages. |
| 122 | + let crawler = NuGetCrawler; |
| 123 | + let result = crawler |
| 124 | + .find_by_purls(tmp.path(), &[ORG_PURL_A.to_string()]) |
| 125 | + .await |
| 126 | + .unwrap(); |
| 127 | + assert!(result.is_empty()); |
| 128 | +} |
| 129 | + |
| 130 | +#[tokio::test] |
| 131 | +async fn find_by_purls_invalid_purl_skipped() { |
| 132 | + let tmp = tempfile::tempdir().unwrap(); |
| 133 | + stage_global_cache_pkg(tmp.path(), "Newtonsoft.Json", "13.0.3").await; |
| 134 | + let crawler = NuGetCrawler; |
| 135 | + let result = crawler |
| 136 | + .find_by_purls( |
| 137 | + tmp.path(), |
| 138 | + &["pkg:not-nuget/Foo@1.0".to_string()], |
| 139 | + ) |
| 140 | + .await |
| 141 | + .unwrap(); |
| 142 | + assert!(result.is_empty(), "non-nuget PURLs must be skipped"); |
| 143 | +} |
| 144 | + |
| 145 | +// ── crawl_all (scan_package_dir) ─────────────────────────────── |
| 146 | + |
| 147 | +#[tokio::test] |
| 148 | +async fn crawl_all_discovers_global_cache_layout() { |
| 149 | + let tmp = tempfile::tempdir().unwrap(); |
| 150 | + stage_global_cache_pkg(tmp.path(), "Newtonsoft.Json", "13.0.3").await; |
| 151 | + stage_global_cache_pkg(tmp.path(), "Serilog", "4.0.0").await; |
| 152 | + |
| 153 | + let crawler = NuGetCrawler; |
| 154 | + // Use --global-prefix to point at our staged root. |
| 155 | + let opts = CrawlerOptions { |
| 156 | + cwd: tmp.path().to_path_buf(), |
| 157 | + global: true, |
| 158 | + global_prefix: Some(tmp.path().to_path_buf()), |
| 159 | + batch_size: 100, |
| 160 | + }; |
| 161 | + let result = crawler.crawl_all(&opts).await; |
| 162 | + assert_eq!(result.len(), 2); |
| 163 | + // The crawler lowercases the discovered name from the directory. |
| 164 | + let purls: Vec<String> = result |
| 165 | + .iter() |
| 166 | + .map(|p| p.purl.to_ascii_lowercase()) |
| 167 | + .collect(); |
| 168 | + assert!(purls.iter().any(|p| p.contains("newtonsoft.json"))); |
| 169 | + assert!(purls.iter().any(|p| p.contains("serilog"))); |
| 170 | +} |
| 171 | + |
| 172 | +#[tokio::test] |
| 173 | +async fn crawl_all_discovers_legacy_layout() { |
| 174 | + let tmp = tempfile::tempdir().unwrap(); |
| 175 | + stage_legacy_pkg(tmp.path(), "Newtonsoft.Json", "13.0.3").await; |
| 176 | + stage_legacy_pkg(tmp.path(), "Serilog", "4.0.0").await; |
| 177 | + |
| 178 | + let crawler = NuGetCrawler; |
| 179 | + let opts = CrawlerOptions { |
| 180 | + cwd: tmp.path().to_path_buf(), |
| 181 | + global: true, |
| 182 | + global_prefix: Some(tmp.path().to_path_buf()), |
| 183 | + batch_size: 100, |
| 184 | + }; |
| 185 | + let result = crawler.crawl_all(&opts).await; |
| 186 | + assert!(result.len() >= 2, "legacy layout must be discovered; got {result:?}"); |
| 187 | +} |
| 188 | + |
| 189 | +#[tokio::test] |
| 190 | +async fn crawl_all_skips_hidden_directories() { |
| 191 | + let tmp = tempfile::tempdir().unwrap(); |
| 192 | + // Real package. |
| 193 | + stage_global_cache_pkg(tmp.path(), "Newtonsoft.Json", "13.0.3").await; |
| 194 | + // Hidden dir that mimics a package layout — must be skipped. |
| 195 | + let hidden = tmp.path().join(".cache").join("13.0.3"); |
| 196 | + tokio::fs::create_dir_all(&hidden).await.unwrap(); |
| 197 | + tokio::fs::write(hidden.join(".cache.nuspec"), b"<package/>").await.unwrap(); |
| 198 | + |
| 199 | + let crawler = NuGetCrawler; |
| 200 | + let opts = CrawlerOptions { |
| 201 | + cwd: tmp.path().to_path_buf(), |
| 202 | + global: true, |
| 203 | + global_prefix: Some(tmp.path().to_path_buf()), |
| 204 | + batch_size: 100, |
| 205 | + }; |
| 206 | + let result = crawler.crawl_all(&opts).await; |
| 207 | + // Only the real package should show up. |
| 208 | + assert_eq!(result.len(), 1); |
| 209 | + assert!( |
| 210 | + result[0].purl.to_ascii_lowercase().contains("newtonsoft.json"), |
| 211 | + "expected newtonsoft.json; got {:?}", |
| 212 | + result[0].purl |
| 213 | + ); |
| 214 | +} |
| 215 | + |
| 216 | +// ── get_nuget_package_paths ───────────────────────────────────── |
| 217 | + |
| 218 | +#[tokio::test] |
| 219 | +#[serial] |
| 220 | +async fn get_nuget_package_paths_with_global_prefix_returns_only_prefix() { |
| 221 | + let tmp = tempfile::tempdir().unwrap(); |
| 222 | + let crawler = NuGetCrawler; |
| 223 | + let opts = CrawlerOptions { |
| 224 | + cwd: tmp.path().to_path_buf(), |
| 225 | + global: true, |
| 226 | + global_prefix: Some(tmp.path().to_path_buf()), |
| 227 | + batch_size: 100, |
| 228 | + }; |
| 229 | + let paths = crawler.get_nuget_package_paths(&opts).await.unwrap(); |
| 230 | + assert_eq!(paths, vec![tmp.path().to_path_buf()]); |
| 231 | +} |
| 232 | + |
| 233 | +#[tokio::test] |
| 234 | +#[serial] |
| 235 | +async fn get_nuget_package_paths_local_discovers_packages_dir() { |
| 236 | + let tmp = tempfile::tempdir().unwrap(); |
| 237 | + let pkg = tmp.path().join("packages"); |
| 238 | + tokio::fs::create_dir_all(&pkg).await.unwrap(); |
| 239 | + |
| 240 | + let crawler = NuGetCrawler; |
| 241 | + let paths = crawler.get_nuget_package_paths(&options_at(tmp.path())).await.unwrap(); |
| 242 | + assert!(paths.iter().any(|p| p == &pkg), "packages/ must be discovered; got {paths:?}"); |
| 243 | +} |
| 244 | + |
| 245 | +#[tokio::test] |
| 246 | +#[serial] |
| 247 | +async fn get_nuget_package_paths_local_with_csproj_falls_back_to_global() { |
| 248 | + let tmp = tempfile::tempdir().unwrap(); |
| 249 | + // Marker file that triggers .NET-project detection. |
| 250 | + tokio::fs::write( |
| 251 | + tmp.path().join("MyProj.csproj"), |
| 252 | + r#"<Project Sdk="Microsoft.NET.Sdk"></Project>"#, |
| 253 | + ) |
| 254 | + .await |
| 255 | + .unwrap(); |
| 256 | + // Stub NUGET_PACKAGES to a writable temp location. |
| 257 | + let nuget_root = tempfile::tempdir().unwrap(); |
| 258 | + let prev = std::env::var("NUGET_PACKAGES").ok(); |
| 259 | + std::env::set_var("NUGET_PACKAGES", nuget_root.path()); |
| 260 | + |
| 261 | + let crawler = NuGetCrawler; |
| 262 | + let paths = crawler.get_nuget_package_paths(&options_at(tmp.path())).await.unwrap(); |
| 263 | + |
| 264 | + std::env::remove_var("NUGET_PACKAGES"); |
| 265 | + if let Some(v) = prev { |
| 266 | + std::env::set_var("NUGET_PACKAGES", v); |
| 267 | + } |
| 268 | + |
| 269 | + assert!( |
| 270 | + paths.iter().any(|p| p == nuget_root.path()), |
| 271 | + "csproj must trigger global-cache fallback; got {paths:?}" |
| 272 | + ); |
| 273 | +} |
| 274 | + |
| 275 | +#[tokio::test] |
| 276 | +#[serial] |
| 277 | +async fn get_nuget_package_paths_local_no_project_returns_empty() { |
| 278 | + let tmp = tempfile::tempdir().unwrap(); |
| 279 | + // No `packages/`, no `.csproj`, no `.sln`, no `obj/`. |
| 280 | + let crawler = NuGetCrawler; |
| 281 | + let paths = crawler.get_nuget_package_paths(&options_at(tmp.path())).await.unwrap(); |
| 282 | + assert!(paths.is_empty(), "non-.NET dir must return empty paths"); |
| 283 | +} |
| 284 | + |
| 285 | +#[tokio::test] |
| 286 | +#[serial] |
| 287 | +async fn get_nuget_package_paths_with_sln_falls_back_to_global() { |
| 288 | + let tmp = tempfile::tempdir().unwrap(); |
| 289 | + tokio::fs::write(tmp.path().join("MySolution.sln"), b"Microsoft Visual Studio Solution File") |
| 290 | + .await |
| 291 | + .unwrap(); |
| 292 | + let nuget_root = tempfile::tempdir().unwrap(); |
| 293 | + let prev = std::env::var("NUGET_PACKAGES").ok(); |
| 294 | + std::env::set_var("NUGET_PACKAGES", nuget_root.path()); |
| 295 | + |
| 296 | + let crawler = NuGetCrawler; |
| 297 | + let paths = crawler.get_nuget_package_paths(&options_at(tmp.path())).await.unwrap(); |
| 298 | + |
| 299 | + std::env::remove_var("NUGET_PACKAGES"); |
| 300 | + if let Some(v) = prev { |
| 301 | + std::env::set_var("NUGET_PACKAGES", v); |
| 302 | + } |
| 303 | + |
| 304 | + assert!( |
| 305 | + paths.iter().any(|p| p == nuget_root.path()), |
| 306 | + ".sln must trigger global-cache fallback" |
| 307 | + ); |
| 308 | +} |
| 309 | + |
| 310 | +// ── verify_nuget_package indirectly via find_by_purls ─────────── |
| 311 | + |
| 312 | +#[tokio::test] |
| 313 | +async fn find_by_purls_rejects_dir_without_nuspec_or_lib() { |
| 314 | + let tmp = tempfile::tempdir().unwrap(); |
| 315 | + // Create a global-cache-shaped dir but with neither .nuspec nor lib/ — verify fails. |
| 316 | + let pkg_dir = tmp.path().join("newtonsoft.json").join("13.0.3"); |
| 317 | + tokio::fs::create_dir_all(&pkg_dir).await.unwrap(); |
| 318 | + // No .nuspec, no lib/ — just an unrelated file. |
| 319 | + tokio::fs::write(pkg_dir.join("README.md"), b"hello").await.unwrap(); |
| 320 | + |
| 321 | + let crawler = NuGetCrawler; |
| 322 | + let result = crawler |
| 323 | + .find_by_purls(tmp.path(), &[ORG_PURL_A.to_string()]) |
| 324 | + .await |
| 325 | + .unwrap(); |
| 326 | + assert!(result.is_empty(), "dir without nuspec or lib/ must not match"); |
| 327 | +} |
| 328 | + |
| 329 | +#[tokio::test] |
| 330 | +async fn find_by_purls_with_lib_dir_marker_succeeds() { |
| 331 | + let tmp = tempfile::tempdir().unwrap(); |
| 332 | + let pkg_dir = tmp.path().join("newtonsoft.json").join("13.0.3"); |
| 333 | + tokio::fs::create_dir_all(pkg_dir.join("lib")).await.unwrap(); |
| 334 | + // No .nuspec but lib/ is present — verify accepts it. |
| 335 | + |
| 336 | + let crawler = NuGetCrawler; |
| 337 | + let result = crawler |
| 338 | + .find_by_purls(tmp.path(), &[ORG_PURL_A.to_string()]) |
| 339 | + .await |
| 340 | + .unwrap(); |
| 341 | + assert_eq!(result.len(), 1); |
| 342 | +} |
| 343 | + |
| 344 | +// Marker so ORG_PURL_B import isn't unused. |
| 345 | +#[allow(dead_code)] |
| 346 | +fn _used_in_doc() -> &'static str { |
| 347 | + ORG_PURL_B |
| 348 | +} |
0 commit comments