diff --git a/crates/socket-patch-cli/tests/in_process_remote_ecosystems_apply.rs b/crates/socket-patch-cli/tests/in_process_remote_ecosystems_apply.rs index 69903f754..978a32ab4 100644 --- a/crates/socket-patch-cli/tests/in_process_remote_ecosystems_apply.rs +++ b/crates/socket-patch-cli/tests/in_process_remote_ecosystems_apply.rs @@ -663,6 +663,87 @@ async fn maven_handcrafted_discovery() { std::env::remove_var("MAVEN_REPO_LOCAL"); } +/// #397 (global mode): a `globalPackagesFolder` in the user-level +/// NuGet.Config relocates the folder NuGet restores into. `scan -g --sync` +/// patches that copy and leaves the stale default `~/.nuget/packages` one +/// (warmed by another project) alone. Red on main: the default copy was +/// patched and the configured one stayed unpatched. +#[tokio::test] +#[serial] +async fn nuget_global_apply_patches_the_user_configured_global_packages_folder() { + let _home = NoJvmCaches::new(); + let saved: Vec<_> = ["NUGET_PACKAGES", "DOTNET_CLI_HOME", "APPDATA"] + .into_iter() + .map(|k| (k, std::env::var_os(k))) + .collect(); + std::env::remove_var("NUGET_PACKAGES"); + std::env::remove_var("DOTNET_CLI_HOME"); + let home = PathBuf::from(std::env::var_os("HOME").unwrap()); + // NuGet's user config: %APPDATA%\NuGet\NuGet.Config on Windows, else + // ~/.nuget/NuGet/NuGet.Config. + let user_config = if cfg!(windows) { + let appdata = home.join("AppData").join("Roaming"); + std::env::set_var("APPDATA", &appdata); + appdata.join("NuGet").join("NuGet.Config") + } else { + home.join(".nuget").join("NuGet").join("NuGet.Config") + }; + let tmp = tempfile::tempdir().expect("tempdir"); + let original = b"MIT License\nCopyright (c) 2007 James Newton-King\n"; + let install = |root: &Path| { + let pkg_dir = root.join("newtonsoft.json").join("13.0.3"); + std::fs::create_dir_all(&pkg_dir).unwrap(); + std::fs::write(pkg_dir.join("newtonsoft.json.nuspec"), "").unwrap(); + std::fs::write(pkg_dir.join("LICENSE.md"), original).unwrap(); + pkg_dir.join("LICENSE.md") + }; + let default_copy = install(&home.join(".nuget").join("packages")); + let gpf = tmp.path().join("gpf"); + let configured_copy = install(&gpf); + std::fs::create_dir_all(user_config.parent().unwrap()).unwrap(); + std::fs::write( + &user_config, + format!( + r#""#, + gpf.display() + ), + ) + .unwrap(); + + let mut patched = original.to_vec(); + patched.extend_from_slice(b"\n# SOCKET-PATCH-E2E-MARKER\n"); + let server = MockServer::start().await; + setup_apply_mock( + &server, + "pkg:nuget/Newtonsoft.Json@13.0.3", + "39739739-7397-4397-8397-397397397397", + "package/LICENSE.md", + &git_sha256(original), + &git_sha256(&patched), + &patched, + ) + .await; + + let code = scan_run(default_scan_args(tmp.path(), "nuget", server.uri())).await; + for (k, v) in saved { + match v { + Some(v) => std::env::set_var(k, v), + None => std::env::remove_var(k), + } + } + assert_eq!(code, 0, "scan -g --sync should apply the nuget patch"); + assert_eq!( + std::fs::read(&configured_copy).unwrap(), + patched, + "the configured globalPackagesFolder copy must be patched" + ); + assert_eq!( + std::fs::read(&default_copy).unwrap(), + original, + "the unused default ~/.nuget/packages copy must stay untouched" + ); +} + #[tokio::test] #[serial] async fn nuget_handcrafted_discovery() { diff --git a/crates/socket-patch-core/src/crawlers/nuget_crawler.rs b/crates/socket-patch-core/src/crawlers/nuget_crawler.rs index 397104586..34837c618 100644 --- a/crates/socket-patch-core/src/crawlers/nuget_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/nuget_crawler.rs @@ -5,6 +5,7 @@ use super::listing::{list_dir_sync, ListedEntry}; use super::types::{CrawledPackage, CrawlerOptions}; use crate::patch::path_safety; use crate::utils::fs::{is_dir, is_dir_sync, run_blocking}; +use crate::vendor::nuget_config::{settings_chain, SettingsChain}; use crate::vendor::nuget_feed::normalize_nuget_version; #[cfg(test)] @@ -26,15 +27,22 @@ impl NuGetCrawler { /// Get NuGet package paths based on options. /// - /// In global mode, returns the global NuGet packages folder - /// (`NUGET_PACKAGES` env var or `~/.nuget/packages/`). + /// In global mode, returns the global packages folder NuGet uses + /// ([`global_packages_folder`] over the user config alone). /// /// In local mode, discovery is gated on `cwd` actually being a .NET /// project (see [`is_dotnet_project`]). When that gate passes, paths - /// are returned in priority order: - /// 1. `/packages/` folder (legacy packages.config layout) - /// 2. Global cache (`NUGET_PACKAGES` / `~/.nuget/packages/`) - /// 3. Paths discovered from `obj/project.assets.json` + /// are returned in priority order, the folders the project's own + /// restore uses first, since the first root to yield a purl wins + /// (#397, #398): + /// 1. The packages.config folder: nuget.config `repositoryPath`, else + /// `/packages/` + /// 2. The `packageFolders` of every `obj/project.assets.json` under + /// `cwd` (they record `RestorePackagesPath` and a configured + /// `globalPackagesFolder` as the restore resolved them) + /// 3. The global packages folder NuGet would use for `cwd` + /// ([`global_packages_folder`]); the default `~/.nuget/packages` + /// only when nothing relocates it pub async fn get_nuget_package_paths( &self, options: &CrawlerOptions, @@ -62,14 +70,13 @@ impl NuGetCrawler { if roots.paths.is_empty() { return Vec::new(); } - let local_packages = options.cwd.join("packages"); run_blocking(move || { let mut packages = Vec::new(); let mut seen = HashSet::new(); for pkg_path in &roots.paths { match &roots.scope { - Some(libraries) if *pkg_path != local_packages => { + Some(libraries) if roots.legacy.as_ref() != Some(pkg_path) => { packages.extend(locate_libraries(pkg_path, libraries, &mut seen)); } _ => packages.extend(scan_package_dir(pkg_path, &mut seen)), @@ -382,7 +389,29 @@ fn nuget_home() -> Option { } } - crate::utils::fs::home_dir().map(|home| home.join(".nuget").join("packages")) + // NuGet's home is `DOTNET_CLI_HOME` off Windows when set (the same + // rule `vendor::nuget_config` reads the user config by). + let cli_home = (!cfg!(windows)) + .then(|| std::env::var_os("DOTNET_CLI_HOME")) + .flatten() + .filter(|v| !v.is_empty()) + .map(PathBuf::from); + cli_home + .or_else(crate::utils::fs::home_dir) + .map(|home| home.join(".nuget").join("packages")) +} + +/// The global packages folder NuGet uses for the configs in `chain` +/// ([`settings_chain`]): `NUGET_PACKAGES`, else the nearest +/// `globalPackagesFolder` setting, else the default [`nuget_home`] +/// (NuGet's `SettingsUtility.GetGlobalPackagesFolder` order). A relocated +/// folder replaces the default one: NuGet never reads the default then, so +/// a warm copy left there by another project must not win (#397). +fn global_packages_folder(chain: &SettingsChain) -> Option { + if std::env::var_os("NUGET_PACKAGES").is_some_and(|v| !v.is_empty()) { + return nuget_home(); + } + chain.folder("globalPackagesFolder").or_else(nuget_home) } /// What `cwd` itself holds of a .NET build. @@ -481,8 +510,12 @@ struct PackageRoots { /// The roots, in priority order (see /// [`NuGetCrawler::get_nuget_package_paths`]). paths: Vec, + /// The packages.config folder among `paths` (`repositoryPath` or + /// `/packages`): always walked, never scoped, since a + /// packages.config project has no restore to scope it by. + legacy: Option, /// The `(id, version)` pairs every project under `cwd` restored - /// ([`restore_scope`]), when `cwd` is itself a project + /// ([`restore_walk`]), when `cwd` is itself a project /// ([`DotnetRoot::Project`]): the shared roots (global folder, /// `packageFolders`) are then looked up for these instead of walked. /// `None` keeps the walk: global mode, a root without a project file, @@ -496,7 +529,7 @@ async fn package_roots(options: &CrawlerOptions) -> PackageRoots { if options.global || options.global_prefix.is_some() { if let Some(ref custom) = options.global_prefix { roots.paths.push(custom.clone()); - } else if let Some(home) = nuget_home() { + } else if let Some(home) = global_packages_folder(&settings_chain(None).await) { if is_dir(&home).await { roots.paths.push(home); } @@ -520,60 +553,51 @@ async fn package_roots(options: &CrawlerOptions) -> PackageRoots { return roots; } - // 1. Check /packages/ (legacy packages.config layout) - let packages_dir = options.cwd.join("packages"); - if is_dir(&packages_dir).await && seen.insert(packages_dir.clone()) { - roots.paths.push(packages_dir); + let chain = settings_chain(Some(&options.cwd)).await; + let repository_path = chain.folder("repositoryPath"); + let global = global_packages_folder(&chain); + + // The restores under `cwd`, at any depth. The configured folders are + // package stores, never project trees: the walk skips them, so a large + // in-tree store cannot spend its directory budget. + let skip: Vec = repository_path.iter().chain(&global).cloned().collect(); + let cwd = options.cwd.clone(); + let walk = run_blocking(move || restore_walk(&cwd, &skip)).await; + + // 1. The packages.config folder: `repositoryPath` relocates + // `/packages/` (#398). NuGet reads `repositoryPath` only for + // packages.config projects, so a configured one (often a shared + // store set in a parent or user config) counts only when one lives + // under `cwd`. + let legacy = match repository_path { + Some(folder) if walk.packages_config => folder, + _ => options.cwd.join("packages"), + }; + if is_dir(&legacy).await && seen.insert(legacy.clone()) { + roots.paths.push(legacy.clone()); + roots.legacy = Some(legacy); } - // 2. Fall back to the global cache. - if let Some(home) = nuget_home() { - if is_dir(&home).await && seen.insert(home.clone()) { - roots.paths.push(home); + // 2. The folders the restores under `cwd` actually used. + for p in walk.package_folders { + if is_dir(&p).await && seen.insert(p.clone()) { + roots.paths.push(p); } } - // 3. Check obj/ dirs for project.assets.json - for p in discover_paths_from_assets(&options.cwd).await { - if is_dir(&p).await && seen.insert(p.clone()) { - roots.paths.push(p); + // 3. The global packages folder NuGet would restore into. + if let Some(home) = global { + if is_dir(&home).await && seen.insert(home.clone()) { + roots.paths.push(home); } } if root == DotnetRoot::Project { - let cwd = options.cwd.clone(); - roots.scope = run_blocking(move || restore_scope(&cwd)).await; + roots.scope = walk.libraries; } roots } -/// Discover additional package paths from `obj/project.assets.json` files: -/// `cwd`'s own and those of its direct subdirectories (multi-project -/// solutions). -async fn discover_paths_from_assets(cwd: &Path) -> Vec { - let mut paths = Vec::new(); - - // Look for obj/project.assets.json in cwd - if let Some(root) = read_project_assets(&cwd.join("obj").join("project.assets.json")).await { - paths.extend(root.package_folders); - } - - // Also check subdirectories one level deep for multi-project solutions - for entry in crate::utils::fs::list_dir_entries(cwd).await { - if !crate::utils::fs::entry_is_dir(&entry).await { - continue; - } - let sub_assets = cwd - .join(entry.file_name()) - .join("obj") - .join("project.assets.json"); - if let Some(sub) = read_project_assets(&sub_assets).await { - paths.extend(sub.package_folders); - } - } - paths -} - /// What one `project.assets.json` says. #[derive(Debug, Default)] struct ProjectAssets { @@ -586,13 +610,6 @@ struct ProjectAssets { libraries: Option>, } -/// Read and parse one `project.assets.json`; `None` when the file is -/// unreadable (or not a regular file) or not JSON. -async fn read_project_assets(path: &Path) -> Option { - let content = crate::utils::fs::read_regular_to_string(path).await.ok()?; - parse_project_assets(&content) -} - /// Parse one `project.assets.json` ([`ProjectAssets`]). fn parse_project_assets(content: &str) -> Option { let json: serde_json::Value = serde_json::from_str(content).ok()?; @@ -622,58 +639,117 @@ fn parse_project_assets(content: &str) -> Option { }) } -/// Directories [`restore_scope`] never descends into: build output, the +/// Directories [`restore_walk`] never descends into: build output, the /// restore's own `obj/`, a legacy `packages/` folder, and JS dependencies. const SCOPE_SKIPPED_DIRS: [&str; 4] = ["bin", "obj", "packages", "node_modules"]; -/// Directories [`restore_scope`] lists before giving up on a scope (the +/// Directories [`restore_walk`] lists before giving up on a scope (the /// crawl then walks the shared roots, as before). const SCOPE_DIR_BUDGET: usize = 10_000; -/// The packages every project under `cwd` restored: the union of the -/// `libraries` of each project directory's `obj/project.assets.json`. +/// What the restores under a directory say ([`restore_walk`]). +#[derive(Debug, Default)] +struct RestoreWalk { + /// The `packageFolders` of every `obj/project.assets.json` found, in + /// walk order (`cwd`'s own first): the folders those restores used. + package_folders: Vec, + /// The packages every project restored ([`PackageRoots::scope`]). + libraries: Option>, + /// A `packages.config` (any casing) was seen: a project under `cwd` + /// uses the packages.config layout. + packages_config: bool, +} + +/// Walk `cwd` for restores: every directory's `obj/project.assets.json` +/// contributes its `packageFolders` (best effort, whatever the walk could +/// see), and the projects' `libraries` form the scope. /// -/// `None` (keep the walk) unless EVERY project file found under `cwd` -/// (`.csproj`/`.fsproj`/`.vbproj`, at any depth) has a parsed assets file -/// with a `libraries` object, so a project the scope cannot see (not -/// restored yet, a relocated `obj/`, an unreadable directory) never loses -/// its packages. Symlinked directories, hidden ones and -/// [`SCOPE_SKIPPED_DIRS`] are not entered; more than +/// The scope is `None` (keep the walk) unless EVERY project file found +/// under `cwd` (`.csproj`/`.fsproj`/`.vbproj`, at any depth) has a parsed +/// assets file with a `libraries` object, so a project the scope cannot see +/// (not restored yet, a relocated `obj/`, an unreadable directory) never +/// loses its packages. Symlinked directories, hidden ones and +/// [`SCOPE_SKIPPED_DIRS`] are not entered, nor `skip` or a package folder +/// a restore already named (package stores, not project trees); more than /// [`SCOPE_DIR_BUDGET`] directories also means `None`. -fn restore_scope(cwd: &Path) -> Option> { - let mut libraries = Vec::new(); - let mut pending = vec![cwd.to_path_buf()]; +fn restore_walk(cwd: &Path, skip: &[PathBuf]) -> RestoreWalk { + let mut walk = RestoreWalk { + package_folders: Vec::new(), + libraries: Some(Vec::new()), + packages_config: false, + }; + // Absolute, so `skip` and the restores' (absolute) package folders + // compare against the walked paths. + let root = std::path::absolute(cwd).unwrap_or_else(|_| cwd.to_path_buf()); + let mut pending = vec![root]; let mut listed = 0usize; while let Some(dir) = pending.pop() { + // A store a restore named after `dir` was queued. + if walk.package_folders.contains(&dir) { + continue; + } listed += 1; if listed > SCOPE_DIR_BUDGET { - return None; + walk.libraries = None; + break; } let (entries, complete) = super::listing::list_dir_sync_complete(&dir); if !complete { - return None; + walk.libraries = None; } - let mut is_project = false; + let (mut is_project, mut has_obj) = (false, false); + // Queued only after this directory's own restore is read, so a + // store it names (`RestorePackagesPath` = `pkgs`) is never entered. + let mut subdirs = Vec::new(); for entry in &entries { let Some(name) = entry.name.to_str() else { continue; }; - let kind = entry.file_type?; + let Some(kind) = entry.file_type else { + walk.libraries = None; + continue; + }; if kind.is_dir() { - if !name.starts_with('.') && !SCOPE_SKIPPED_DIRS.contains(&name) { - pending.push(dir.join(name)); + has_obj |= name == "obj"; + let sub = dir.join(name); + if !name.starts_with('.') + && !SCOPE_SKIPPED_DIRS.contains(&name) + && !skip.contains(&sub) + { + subdirs.push(sub); } } else if PROJECT_EXTENSIONS.iter().any(|ext| name.ends_with(ext)) { is_project = true; + } else if name.eq_ignore_ascii_case("packages.config") { + walk.packages_config = true; } } - if is_project { + if is_project || has_obj { let assets = dir.join("obj").join("project.assets.json"); - let content = crate::utils::fs::read_regular_to_string_sync(&assets).ok()?; - libraries.extend(parse_project_assets(&content)?.libraries?); + let parsed = crate::utils::fs::read_regular_to_string_sync(&assets) + .ok() + .and_then(|content| parse_project_assets(&content)); + let libraries = match parsed { + Some(assets) => { + walk.package_folders.extend(assets.package_folders); + assets.libraries + } + None => None, + }; + if is_project { + match (walk.libraries.as_mut(), libraries) { + (Some(all), Some(own)) => all.extend(own), + _ => walk.libraries = None, + } + } } + pending.extend( + subdirs + .into_iter() + .filter(|sub| !walk.package_folders.contains(sub)), + ); } - Some(libraries) + walk } /// Look up each resolved `(id, version)` in a shared (global-layout) root, @@ -1029,7 +1105,12 @@ mod tests { .await .unwrap(); - let paths = discover_paths_from_assets(dir.path()).await; + let paths = run_blocking({ + let cwd = dir.path().to_path_buf(); + move || restore_walk(&cwd, &[]) + }) + .await + .package_folders; assert_eq!(paths.len(), 1); assert_eq!(paths[0], pkg_folder); } @@ -2180,4 +2261,383 @@ mod tests { "the readable sub-project assets file must still be discovered, got {paths:?}" ); } + + // ── Configured package folders (#397, #398) ─────────────────────── + + /// Points `HOME`/`USERPROFILE` at a temp dir, clears `NUGET_PACKAGES` + /// and installs `user_config` as the user-level NuGet config, restoring + /// all of it on drop. Callers are `#[serial]`. + struct NugetEnv { + saved: Vec<(&'static str, Option)>, + } + + impl NugetEnv { + fn new(home: &Path, user_config: Option) -> Self { + let saved = ["HOME", "USERPROFILE", "NUGET_PACKAGES", "DOTNET_CLI_HOME"] + .into_iter() + .map(|k| (k, std::env::var_os(k))) + .collect(); + std::env::set_var("HOME", home); + std::env::set_var("USERPROFILE", home); + std::env::remove_var("NUGET_PACKAGES"); + std::env::remove_var("DOTNET_CLI_HOME"); + crate::vendor::nuget_config::tests_support::USER_CONFIG + .with(|c| *c.borrow_mut() = user_config); + Self { saved } + } + } + + impl Drop for NugetEnv { + fn drop(&mut self) { + for (k, v) in &self.saved { + match v { + Some(v) => std::env::set_var(k, v), + None => std::env::remove_var(k), + } + } + crate::vendor::nuget_config::tests_support::USER_CONFIG + .with(|c| *c.borrow_mut() = None); + } + } + + /// Install `newtonsoft.json@13.0.3` under `root` in the global layout. + async fn install_global(root: &Path) -> PathBuf { + let dir = root.join("newtonsoft.json").join("13.0.3"); + tokio::fs::create_dir_all(&dir).await.unwrap(); + tokio::fs::write(dir.join("newtonsoft.json.nuspec"), "") + .await + .unwrap(); + dir + } + + /// A home whose default `~/.nuget/packages` another project warmed + /// with the same package. + async fn warm_home(root: &Path) -> (PathBuf, PathBuf) { + let home = root.join("home"); + let copy = install_global(&home.join(".nuget").join("packages")).await; + (home, copy) + } + + async fn write(path: &Path, text: &str) { + tokio::fs::create_dir_all(path.parent().unwrap()) + .await + .unwrap(); + tokio::fs::write(path, text).await.unwrap(); + } + + /// Where agent `apply` resolves the purl: the first root (in + /// `get_nuget_package_paths` order) holding it, as `merge_first_wins` + /// keeps it. + async fn applied_copy(cwd: &Path) -> Option { + let purl = "pkg:nuget/Newtonsoft.Json@13.0.3".to_string(); + for root in NuGetCrawler + .get_nuget_package_paths(&local(cwd)) + .await + .unwrap() + { + let found = NuGetCrawler + .find_by_purls(&root, std::slice::from_ref(&purl)) + .await + .unwrap(); + if let Some(pkg) = found.get(&purl) { + return Some(pkg.path.clone()); + } + } + None + } + + fn crawled_copy(packages: &[CrawledPackage]) -> Option { + packages + .iter() + .find(|p| p.purl == "pkg:nuget/newtonsoft.json@13.0.3") + .map(|p| p.path.clone()) + } + + /// The #397 repro: a solution root, `src/App/App.csproj`, and a + /// nuget.config `globalPackagesFolder` of `.nuget/packages`, restored. + async fn relocated_solution(root: &Path) -> (PathBuf, PathBuf) { + let proj = root.join("proj"); + write( + &proj.join("nuget.config"), + r#" + + + +"#, + ) + .await; + write(&proj.join("App.sln"), "").await; + let app = proj.join("src").join("App"); + write(&app.join("App.csproj"), "").await; + let folder = proj.join(".nuget").join("packages"); + let copy = install_global(&folder).await; + write_assets( + &app, + &folder, + serde_json::json!({ + "Newtonsoft.Json/13.0.3": { "type": "package", "path": "newtonsoft.json/13.0.3" } + }), + ) + .await; + (proj, copy) + } + + /// #397: with a warm default cache, the project's configured + /// `globalPackagesFolder` copy is the one patched and crawled, not the + /// user-wide one. Red on main (`~/.nuget/packages` won). + #[tokio::test] + #[serial_test::serial] + async fn configured_global_packages_folder_beats_a_warm_default_cache() { + let dir = tempfile::tempdir().unwrap(); + let (home, _default_copy) = warm_home(dir.path()).await; + let _env = NugetEnv::new(&home, None); + let (proj, copy) = relocated_solution(dir.path()).await; + + assert_eq!(applied_copy(&proj).await, Some(copy.clone())); + assert_eq!( + crawled_copy(&NuGetCrawler.crawl_all(&local(&proj)).await), + Some(copy.clone()) + ); + // From the project directory too (restore-scoped crawl). + let app = proj.join("src").join("App"); + assert_eq!(applied_copy(&app).await, Some(copy.clone())); + assert_eq!( + crawled_copy(&NuGetCrawler.crawl_all(&local(&app)).await), + Some(copy) + ); + } + + /// #397: with a cold default cache, a project two levels below the + /// solution root is still found through its restore. Red on main + /// (assets were read only one level deep: "matched no installed + /// package"). + #[tokio::test] + #[serial_test::serial] + async fn deep_project_restore_is_found_with_a_cold_default_cache() { + let dir = tempfile::tempdir().unwrap(); + let home = dir.path().join("home"); + tokio::fs::create_dir_all(&home).await.unwrap(); + let _env = NugetEnv::new(&home, None); + let (proj, copy) = relocated_solution(dir.path()).await; + // Without the config, only the restore names the folder. + tokio::fs::remove_file(proj.join("nuget.config")) + .await + .unwrap(); + + assert_eq!(applied_copy(&proj).await, Some(copy)); + } + + /// #397: `RestorePackagesPath` (MSBuild, recorded only in the + /// restore's `packageFolders`) beats a warm default cache. Red on main. + #[tokio::test] + #[serial_test::serial] + async fn restore_packages_path_beats_a_warm_default_cache() { + let dir = tempfile::tempdir().unwrap(); + let (home, _default_copy) = warm_home(dir.path()).await; + let _env = NugetEnv::new(&home, None); + let proj = dir.path().join("proj"); + write(&proj.join("App.csproj"), "").await; + let pkgs = proj.join("pkgs"); + let copy = install_global(&pkgs).await; + write_assets( + &proj, + &pkgs, + serde_json::json!({ + "Newtonsoft.Json/13.0.3": { "type": "package", "path": "newtonsoft.json/13.0.3" } + }), + ) + .await; + + assert_eq!(applied_copy(&proj).await, Some(copy.clone())); + assert_eq!( + crawled_copy(&NuGetCrawler.crawl_all(&local(&proj)).await), + Some(copy) + ); + } + + /// #397 (global mode): a `globalPackagesFolder` in the user-level + /// NuGet.Config relocates the folder `-g` scans. Red on main (only + /// `~/.nuget/packages` was crawled). + #[tokio::test] + #[serial_test::serial] + async fn global_mode_reads_the_user_config_global_packages_folder() { + let dir = tempfile::tempdir().unwrap(); + let (home, _default_copy) = warm_home(dir.path()).await; + let gpf = dir.path().join("gpf"); + let copy = install_global(&gpf).await; + let user_config = home.join(".nuget").join("NuGet").join("NuGet.Config"); + write( + &user_config, + &format!( + r#""#, + gpf.display() + ), + ) + .await; + let _env = NugetEnv::new(&home, Some(user_config)); + let options = CrawlerOptions { + cwd: dir.path().to_path_buf(), + global: true, + global_prefix: None, + }; + + assert_eq!( + NuGetCrawler + .get_nuget_package_paths(&options) + .await + .unwrap(), + [gpf] + ); + assert_eq!( + crawled_copy(&NuGetCrawler.crawl_all(&options).await), + Some(copy) + ); + } + + /// `NUGET_PACKAGES` still overrides a configured folder, as in NuGet. + #[tokio::test] + #[serial_test::serial] + async fn nuget_packages_env_overrides_the_configured_folder() { + let dir = tempfile::tempdir().unwrap(); + let home = dir.path().join("home"); + let _env = NugetEnv::new(&home, None); + let proj = dir.path().join("proj"); + write( + &proj.join("nuget.config"), + r#""#, + ) + .await; + write(&proj.join("App.sln"), "").await; + install_global(&proj.join("cfg")).await; + let env_folder = dir.path().join("env"); + let copy = install_global(&env_folder).await; + std::env::set_var("NUGET_PACKAGES", &env_folder); + + assert_eq!(applied_copy(&proj).await, Some(copy)); + } + + /// #398: a packages.config solution whose nuget.config sets + /// `repositoryPath` is patched in that folder, not in the global + /// folder nuget.exe also extracted into. Red on main (only + /// `/packages` was considered, so `~/.nuget/packages` won). + #[tokio::test] + #[serial_test::serial] + async fn repository_path_folder_beats_the_global_folder() { + let dir = tempfile::tempdir().unwrap(); + let (home, _default_copy) = warm_home(dir.path()).await; + let _env = NugetEnv::new(&home, None); + let repro = dir.path().join("repro"); + write( + &repro.join("nuget.config"), + r#" + +"#, + ) + .await; + write(&repro.join("App.sln"), "").await; + write( + &repro.join("App").join("packages.config"), + r#""#, + ) + .await; + write(&repro.join("App").join("App.csproj"), "").await; + let copy = repro + .join("lib") + .join("pkgs") + .join("Newtonsoft.Json.13.0.3"); + write(©.join("Newtonsoft.Json.nuspec"), "").await; + + assert_eq!(applied_copy(&repro).await, Some(copy.clone())); + let crawled: Vec<_> = NuGetCrawler + .crawl_all(&local(&repro)) + .await + .into_iter() + .filter(|p| p.name.eq_ignore_ascii_case("newtonsoft.json")) + .map(|p| p.path) + .collect(); + assert_eq!(crawled.first(), Some(©)); + // From the project directory, the parent's config still applies. + assert_eq!(applied_copy(&repro.join("App")).await, Some(copy)); + } + + /// A `repositoryPath` set for a shared store (here in a parent config) + /// is not a PackageReference project's folder: NuGet reads it only for + /// packages.config, so the project's restore folder still wins and the + /// store is not walked. + #[tokio::test] + #[serial_test::serial] + async fn repository_path_is_ignored_without_a_packages_config() { + let dir = tempfile::tempdir().unwrap(); + let home = dir.path().join("home"); + let _env = NugetEnv::new(&home, None); + let store = dir.path().join("store"); + install_global(&store).await; + write( + &dir.path().join("nuget.config"), + &format!( + r#""#, + store.display() + ), + ) + .await; + let proj = dir.path().join("proj"); + write(&proj.join("App.csproj"), "").await; + let pkgs = proj.join("pkgs"); + let copy = install_global(&pkgs).await; + write_assets( + &proj, + &pkgs, + serde_json::json!({ + "Newtonsoft.Json/13.0.3": { "type": "package", "path": "newtonsoft.json/13.0.3" } + }), + ) + .await; + + let paths = NuGetCrawler + .get_nuget_package_paths(&local(&proj)) + .await + .unwrap(); + assert!(!paths.contains(&store), "{paths:?}"); + assert_eq!(applied_copy(&proj).await, Some(copy)); + } + + /// A store the walked directory's own restore names (a solution-root + /// `RestorePackagesPath` of `pkgs/`, written with NuGet's trailing + /// separator) is not entered: here a project-shaped folder inside it + /// would otherwise drop the scope, and a large store would spend the + /// directory budget before deeper restores are seen. + #[test] + fn restore_walk_does_not_enter_a_store_its_restore_names() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::write(root.join("App.csproj"), "").unwrap(); + let pkgs = root.join("pkgs"); + std::fs::create_dir_all(pkgs.join("some.tool").join("1.0.0").join("content")).unwrap(); + std::fs::write( + pkgs.join("some.tool") + .join("1.0.0") + .join("content") + .join("Template.csproj"), + "", + ) + .unwrap(); + std::fs::create_dir_all(root.join("obj")).unwrap(); + let key = format!("{}{}", pkgs.display(), std::path::MAIN_SEPARATOR); + std::fs::write( + root.join("obj").join("project.assets.json"), + serde_json::to_string(&serde_json::json!({ + "libraries": { "Newtonsoft.Json/13.0.3": { "type": "package" } }, + "packageFolders": { key: {} } + })) + .unwrap(), + ) + .unwrap(); + + let walk = restore_walk(root, &[]); + assert_eq!(walk.package_folders, [pkgs]); + assert_eq!( + walk.libraries, + Some(vec![("Newtonsoft.Json".to_string(), "13.0.3".to_string())]) + ); + } } diff --git a/crates/socket-patch-core/src/formats/nuget/mod.rs b/crates/socket-patch-core/src/formats/nuget/mod.rs index 6a2e949fb..aed746512 100644 --- a/crates/socket-patch-core/src/formats/nuget/mod.rs +++ b/crates/socket-patch-core/src/formats/nuget/mod.rs @@ -5,8 +5,9 @@ //! The reader is a minimal, bounded XML tokenizer (no parser dependency) //! that records only what NuGet routes by: `` directly under //! `configuration/packageSources`, the mapping elements directly under -//! `configuration/packageSourceMapping`, and `disabledPackageSources` -//! entries whose `value` is `true`. Comments, CDATA, processing instructions +//! `configuration/packageSourceMapping`, `disabledPackageSources` +//! entries whose `value` is `true`, and the `` settings under +//! `configuration/config` (where the package folders live). Comments, CDATA, processing instructions //! and DOCTYPEs are skipped; an unterminated tag or comment, an unquoted //! attribute or a mismatched close tag makes the whole file `None`. @@ -36,6 +37,12 @@ pub(crate) struct NugetConfig { /// farther config (parent directories, the user config) defined are /// dropped, and only the ones after it count. pub(crate) sources_cleared: bool, + /// `configuration/config/add` `(key, value)`, document order: the + /// settings such as `globalPackagesFolder` and `repositoryPath`. + pub(crate) settings: Vec<(String, String)>, + /// `configuration/config` holds a ``: the settings every + /// farther config defined are dropped. + pub(crate) settings_cleared: bool, /// Where each `mappings` row sits (parallel to it): the whole /// `` element and each of its pattern tags, so a writer can /// set one aside and put it back byte-exact. @@ -87,6 +94,10 @@ fn record_clear(cfg: &mut NugetConfig, parents: &[&str], end: usize) { // NuGet drops what the file itself defined before the ``. cfg.sources.clear(); } + if parents == ["configuration", "config"] { + cfg.settings_cleared = true; + cfg.settings.clear(); + } let section = match parents { ["configuration", "packageSources"] => cfg.package_sources.as_mut(), ["configuration", "packageSourceMapping"] => cfg.source_mapping.as_mut(), @@ -217,6 +228,11 @@ fn visit(stack: &[&str], tag: &Tag<'_>, cfg: &mut NugetConfig, open_mapping: &mu cfg.sources.push((key.to_string(), value.to_string())); } } + (["configuration", "config"], "add") => { + if let (Some(key), Some(value)) = (tag.attr("key"), tag.attr("value")) { + cfg.settings.push((key.to_string(), value.to_string())); + } + } (["configuration", "disabledPackageSources"], "add") => { if let (Some(key), Some(value)) = (tag.attr("key"), tag.attr("value")) { if value.trim().eq_ignore_ascii_case("true") { @@ -372,6 +388,33 @@ pub(crate) fn effective_source_keys<'a>( keys } +/// The value of the `config` setting `key` (matched ignoring case, as NuGet +/// does) in `chain`, nearest config first: the nearest config defining it +/// wins, and a config whose `config` section holds a `` hides every +/// farther one. `(index into chain, value)` so the caller can resolve a +/// relative value against that config's directory. +pub(crate) fn effective_setting<'a>( + chain: impl IntoIterator, + key: &str, +) -> Option<(usize, &'a str)> { + for (idx, cfg) in chain.into_iter().enumerate() { + // A later `` of the same key overrides an earlier one in the + // same file. + if let Some((_, value)) = cfg + .settings + .iter() + .rev() + .find(|(k, _)| k.eq_ignore_ascii_case(key)) + { + return Some((idx, value.as_str())); + } + if cfg.settings_cleared { + return None; + } + } + None +} + /// The comment a writer sets a competing mapping element aside in while the /// Socket source `key` is wired: ``. fn set_aside_open(key: &str) -> String { @@ -508,6 +551,37 @@ mod tests { assert_eq!(super::effective_source_keys([&parent, &cfg]), ["new"]); } + /// #397/#398: the `config` settings are read, the nearest config + /// wins, and a `` hides farther ones. + #[test] + fn config_settings_resolve_nearest_first() { + let near = super::parse_config( + "", + ) + .unwrap(); + let far = super::parse_config( + "", + ) + .unwrap(); + let cleared = super::parse_config( + "", + ) + .unwrap(); + assert_eq!( + super::effective_setting([&near, &far], "repositorypath"), + Some((0, "lib/pkgs")) + ); + assert_eq!( + super::effective_setting([&near, &far], "globalPackagesFolder"), + Some((1, "/far")) + ); + assert!(cleared.settings.is_empty()); + assert_eq!( + super::effective_setting([&near, &cleared, &far], "globalPackagesFolder"), + None + ); + } + const COMPETING: &str = "\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n"; /// #462: another source's exact pattern for the id is set aside (the diff --git a/crates/socket-patch-core/src/vendor/nuget_config.rs b/crates/socket-patch-core/src/vendor/nuget_config.rs index de0322520..0aa773fd2 100644 --- a/crates/socket-patch-core/src/vendor/nuget_config.rs +++ b/crates/socket-patch-core/src/vendor/nuget_config.rs @@ -167,6 +167,151 @@ pub(crate) async fn inherited_source_keys_traced( ) } +// ── package folder settings ── + +/// The configs NuGet merges for a build in `start` (every directory from +/// `start` up to the filesystem root, then the user config), NEAREST +/// first, each with the directory its relative paths resolve against +/// ([`settings_chain`]). +pub(crate) struct SettingsChain(Vec<(std::path::PathBuf, crate::formats::nuget::NugetConfig)>); + +/// [`SettingsChain`] for `start`. A relative `start` (the default `--cwd` +/// is `.`) is made absolute first, so its parents' configs are read. A file +/// that cannot be read or parsed is skipped, as in +/// [`inherited_source_keys_traced`]. `None` reads the user config alone +/// (global mode: no project directory takes part). +pub(crate) async fn settings_chain(start: Option<&std::path::Path>) -> SettingsChain { + use crate::formats::nuget::parse_config; + async fn read(path: &std::path::Path) -> Option { + let text = crate::utils::fs::read_regular_to_string(path).await.ok()?; + parse_config(crate::formats::text::strip_bom(&text)) + } + let start = start.map(|s| std::path::absolute(s).unwrap_or_else(|_| s.to_path_buf())); + let mut chain = Vec::new(); + for dir in start.iter().flat_map(|s| s.ancestors()) { + for name in CONFIG_NAMES { + let path = dir.join(name); + if !crate::utils::fs::file_exists(&path).await { + continue; + } + if let Some(cfg) = read(&path).await { + chain.push((dir.to_path_buf(), cfg)); + } + break; + } + } + if let Some(path) = user_config_path() { + if let (Some(cfg), Some(dir)) = (read(&path).await, path.parent()) { + chain.push((dir.to_path_buf(), cfg)); + } + } + SettingsChain(chain) +} + +impl SettingsChain { + /// The folder the `config` setting `key` (`globalPackagesFolder`, + /// `repositoryPath`) names: the nearest config defining it wins, + /// `%VAR%` references are expanded as .NET does (NuGet expands them on + /// every OS; an unset one stays literal), and a relative value resolves + /// against the directory of the config that set it. `None` when no + /// config sets it or its value is empty. + pub(crate) fn folder(&self, key: &str) -> Option { + let (idx, value) = + crate::formats::nuget::effective_setting(self.0.iter().map(|(_, c)| c), key)?; + let value = expand_env_vars(value.trim()); + if value.is_empty() { + return None; + } + let path = std::path::PathBuf::from(value); + Some(if path.is_absolute() { + path + } else { + self.0[idx].0.join(path) + }) + } +} + +/// Expand every `%NAME%` naming a set variable in `value`, as .NET's +/// `Environment.ExpandEnvironmentVariables` does: an unset name is kept +/// literally, and its closing `%` may open the next reference. +fn expand_env_vars(value: &str) -> String { + let mut out = String::with_capacity(value.len()); + let mut rest = value; + while let Some(start) = rest.find('%') { + out.push_str(&rest[..start]); + let after = &rest[start + 1..]; + let Some(len) = after.find('%') else { + out.push_str(&rest[start..]); + return out; + }; + match std::env::var(&after[..len]).ok().filter(|_| len > 0) { + Some(v) => { + out.push_str(&v); + rest = &after[len + 1..]; + } + None => { + out.push('%'); + out.push_str(&after[..len]); + rest = &after[len..]; + } + } + } + out.push_str(rest); + out +} + +#[cfg(test)] +mod settings_tests { + /// A set `%VAR%` expands; an unset or empty-named one stays literal, + /// as in .NET. + #[test] + #[serial_test::serial] + fn env_vars_expand_like_dotnet() { + std::env::set_var("SP_NUGET_TEST_ROOT", "/r"); + std::env::remove_var("SP_NUGET_TEST_UNSET"); + assert_eq!( + super::expand_env_vars("%SP_NUGET_TEST_ROOT%/pkgs"), + "/r/pkgs" + ); + assert_eq!( + super::expand_env_vars("%SP_NUGET_TEST_UNSET%/x"), + "%SP_NUGET_TEST_UNSET%/x" + ); + assert_eq!( + super::expand_env_vars("a%SP_NUGET_TEST_UNSET%SP_NUGET_TEST_ROOT%"), + "a%SP_NUGET_TEST_UNSET/r" + ); + assert_eq!(super::expand_env_vars("100%%"), "100%%"); + assert_eq!(super::expand_env_vars("50%"), "50%"); + std::env::remove_var("SP_NUGET_TEST_ROOT"); + } + + /// The default `--cwd .` still reads its parents' configs. + #[tokio::test] + #[serial_test::serial] + async fn a_relative_start_reads_parent_configs() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("nuget.config"), + r#""#, + ) + .unwrap(); + let app = dir.path().join("App"); + std::fs::create_dir(&app).unwrap(); + let prev = std::env::current_dir().unwrap(); + std::env::set_current_dir(&app).unwrap(); + let folder = super::settings_chain(Some(std::path::Path::new("."))) + .await + .folder("repositoryPath"); + std::env::set_current_dir(prev).unwrap(); + let folder = folder.expect("the parent config's repositoryPath"); + assert!( + folder.is_absolute() && folder.ends_with("lib/pkgs"), + "{folder:?}" + ); + } +} + // ── project walk ── /// Directories the project walk never enters: build output, the restore's diff --git a/crates/socket-patch-core/tests/crawler_nuget_e2e.rs b/crates/socket-patch-core/tests/crawler_nuget_e2e.rs index 2228e9a8c..3014d3707 100644 --- a/crates/socket-patch-core/tests/crawler_nuget_e2e.rs +++ b/crates/socket-patch-core/tests/crawler_nuget_e2e.rs @@ -707,7 +707,7 @@ async fn get_nuget_package_paths_with_nuget_config_falls_back_to_global() { /// A staged `obj/project.assets.json` with a `packageFolders` map /// must surface those folders alongside the global cache. Covers -/// `discover_paths_from_assets` and `parse_project_assets_package_folders`. +/// the restore walk and `parse_project_assets`. #[tokio::test] #[serial] async fn get_nuget_package_paths_discovers_assets_json_package_folders() { @@ -759,7 +759,7 @@ async fn get_nuget_package_paths_discovers_assets_json_package_folders() { } /// `project.assets.json` exists in a subdirectory (multi-project -/// solution) — `discover_paths_from_assets` walks one level deep. +/// solution) — the restore walk finds it (at any depth). #[tokio::test] #[serial] async fn get_nuget_package_paths_discovers_assets_json_in_subproject() {