Skip to content

Commit 0168763

Browse files
mikolalysenkoclaude
andcommitted
Pin every NuGet lock the root config governs
vendor / scan --mode vendored and scan --mode hosted wired the root nuget.config, which every project under the root inherits, but only re-pinned <root>/packages.lock.json. A member project's own lock (#353) or a per-project packages.<Project>.lock.json (#514) kept the upstream contentHash, so every fresh restore failed NU1403 while the run reported success, and vendored even claimed the lockfile setting was off. Both modes now discover the locks the projects under the root restore into (formats::nuget::lock::governed_locks over a walk of the project files) and pin all of them: the root lock, member locks, named locks and a literal NuGetLockFilePath. A NuGetLockFilePath that cannot be evaluated is refused (vendor_ / redirect_nuget_lock_path_unresolved) instead of leaving a lock unpinned. Vendored records one wiring entry per lock path and reverts each; hosted rewrites them under their own paths; the hosted unwind and VEX read the same set. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent e514fc9 commit 0168763

9 files changed

Lines changed: 1071 additions & 152 deletions

File tree

‎crates/socket-patch-cli/CLI_CONTRACT.md‎

Lines changed: 3 additions & 3 deletions
Large diffs are not rendered by default.

‎crates/socket-patch-cli/tests/e2e_nuget_dotnet_build.rs‎

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -967,3 +967,107 @@ fn nuget_vendored_dotnet_restore_then_manifestless_vex() {
967967
None,
968968
);
969969
}
970+
971+
// ── solution layout: member and named locks (#353, #514) ──────────────
972+
973+
/// A solution layout: `nuget.config` at the root, one project under
974+
/// `src/App/` with its own `packages.lock.json`, and one under `src/Named/`
975+
/// whose lock is the per-project `packages.named.lock.json`. Vendoring from
976+
/// the root must pin BOTH locks, so a fresh checkout restores each project
977+
/// (`--locked-mode`, cold cache) with the patched bytes instead of NU1403.
978+
#[test]
979+
#[ignore = "real .NET SDK + nuget.org: run with --ignored (CI e2e matrix pins each SDK major)"]
980+
fn nuget_vendored_solution_member_and_named_locks_restore() {
981+
let sb = Sandbox::new();
982+
let Some(dn) = Dotnet::probe("vendored-solution", &sb) else {
983+
return;
984+
};
985+
let sdk = dn.version.clone();
986+
let root = sb.dir("solution");
987+
let store_fx = sb.dir("store-solution");
988+
std::fs::write(root.join("nuget.config"), REGISTRY_CONFIG).unwrap();
989+
let projects = [("src/App", "app"), ("src/Named", "named")];
990+
for (dir, name) in projects {
991+
let d = root.join(dir);
992+
std::fs::create_dir_all(&d).unwrap();
993+
std::fs::write(d.join(format!("{name}.csproj")), dn.csproj()).unwrap();
994+
dn.restore_ok(&sb, &d, &store_fx, &[], "member restore from nuget.org");
995+
}
996+
// NuGet reads (and writes) the per-project name once it exists.
997+
std::fs::rename(
998+
root.join("src/Named/packages.lock.json"),
999+
root.join("src/Named/packages.named.lock.json"),
1000+
)
1001+
.unwrap();
1002+
assert!(!root.join("packages.lock.json").exists());
1003+
1004+
let pristine = std::fs::read(pkg_dir(&store_fx).join(FILE_KEY)).unwrap();
1005+
let mut patched = pristine.clone();
1006+
patched.extend_from_slice(MARKER);
1007+
let upstream = std::fs::read(pkg_dir(&store_fx).join(NUPKG_NAME)).unwrap();
1008+
let nupkg = patched_nupkg(&upstream, &patched);
1009+
let backend = Backend::start(VENDORED_UUID, &pristine, &patched, Some(&nupkg));
1010+
let uri = backend.uri();
1011+
1012+
let (code, env, stderr) = socket_patch(
1013+
&root,
1014+
&store_fx,
1015+
&[
1016+
"scan",
1017+
"--mode",
1018+
"vendored",
1019+
"--vendor-source",
1020+
"service",
1021+
"--json",
1022+
"--yes",
1023+
"--api-url",
1024+
&uri,
1025+
"--org",
1026+
ORG,
1027+
"--api-token",
1028+
"fake-token",
1029+
],
1030+
);
1031+
assert_eq!(code, Some(0), "SDK {sdk}: {env:#}\n{stderr}");
1032+
assert!(
1033+
!env.to_string().contains("vendor_nuget_no_lockfile"),
1034+
"the member locks were found: {env:#}"
1035+
);
1036+
let artifact = root.join(format!(".socket/vendor/nuget/{VENDORED_UUID}/{NUPKG_NAME}"));
1037+
let pin = content_hash(&std::fs::read(&artifact).unwrap());
1038+
for lock in [
1039+
"src/App/packages.lock.json",
1040+
"src/Named/packages.named.lock.json",
1041+
] {
1042+
let text = std::fs::read_to_string(root.join(lock)).unwrap();
1043+
assert!(text.contains(&pin), "SDK {sdk}: {lock} re-pinned: {text}");
1044+
}
1045+
1046+
// Fresh checkout of the committable files, cold cache, each project.
1047+
let checkout = sb.dir("solution-checkout");
1048+
std::fs::copy(root.join("nuget.config"), checkout.join("nuget.config")).unwrap();
1049+
copy_tree(&root.join(".socket"), &checkout.join(".socket"));
1050+
strip_manifest(&checkout);
1051+
let blobs = checkout.join(".socket/blobs");
1052+
if blobs.exists() {
1053+
std::fs::remove_dir_all(blobs).unwrap();
1054+
}
1055+
for (dir, name) in projects {
1056+
let (from, to) = (root.join(dir), checkout.join(dir));
1057+
std::fs::create_dir_all(&to).unwrap();
1058+
for entry in std::fs::read_dir(&from).unwrap() {
1059+
let entry = entry.unwrap();
1060+
let file = entry.file_name().to_string_lossy().into_owned();
1061+
if file.ends_with(".csproj") || file.ends_with(".lock.json") {
1062+
std::fs::copy(entry.path(), to.join(&file)).unwrap();
1063+
}
1064+
}
1065+
let store = sb.dir(&format!("store-checkout-{name}"));
1066+
dn.restore_ok(&sb, &to, &store, &["--locked-mode"], "member fresh restore");
1067+
assert_eq!(
1068+
std::fs::read(pkg_dir(&store).join(FILE_KEY)).unwrap(),
1069+
patched,
1070+
"SDK {sdk}: {dir} restored the PATCHED {FILE_KEY}"
1071+
);
1072+
}
1073+
}

‎crates/socket-patch-core/src/formats/nuget/lock.rs‎

Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,179 @@ pub(crate) fn other_versions_detail(
127127
)
128128
}
129129

130+
/// MSBuild project files NuGet restores `PackageReference`s for.
131+
const PROJECT_EXTENSIONS: [&str; 3] = [".csproj", ".fsproj", ".vbproj"];
132+
133+
/// Whether `name` (a basename) is an MSBuild project file.
134+
pub(crate) fn is_project_file(name: &str) -> bool {
135+
let lower = name.to_ascii_lowercase();
136+
PROJECT_EXTENSIONS
137+
.iter()
138+
.any(|ext| lower.len() > ext.len() && lower.ends_with(ext))
139+
}
140+
141+
/// The locks a project tree restores into, as root-relative `/` paths
142+
/// (#353, #514). Every project under the root inherits the root
143+
/// `nuget.config`, so the Socket source and mapping wired there reach every
144+
/// one of them, and each lock they restore must be pinned with it:
145+
///
146+
/// * the root `packages.lock.json`, when present (a project file NuGet
147+
/// restores from the root, or a lock with no project beside it);
148+
/// * per project, the lock NuGet reads: a literal `NuGetLockFilePath`
149+
/// (relative to the project), else `packages.<ProjectName>.lock.json`
150+
/// beside it (spaces in the name become `_`) when that file exists, else
151+
/// `packages.lock.json` beside it.
152+
///
153+
/// Only existing locks are returned. A `NuGetLockFilePath` this reader cannot
154+
/// evaluate (an MSBuild property or item reference, a `Condition`, an
155+
/// absolute path or one leaving the root) is returned in `unresolved` as
156+
/// `(project, detail)`: the writers refuse rather than leave a lock they
157+
/// cannot find on its upstream hash.
158+
#[derive(Debug, Default, PartialEq, Eq)]
159+
pub(crate) struct GovernedLocks {
160+
pub(crate) locks: Vec<String>,
161+
pub(crate) unresolved: Vec<(String, String)>,
162+
}
163+
164+
/// [`GovernedLocks`] of `projects` (`(root-relative project path, text)`),
165+
/// asking `exists` whether a root-relative file exists.
166+
pub(crate) fn governed_locks(
167+
projects: &[(String, String)],
168+
exists: impl Fn(&str) -> bool,
169+
) -> GovernedLocks {
170+
let mut out = GovernedLocks::default();
171+
if exists(PACKAGES_LOCK) {
172+
out.locks.push(PACKAGES_LOCK.to_string());
173+
}
174+
for (project, text) in projects {
175+
let (dir, file) = match project.rsplit_once('/') {
176+
Some((dir, file)) => (dir, file),
177+
None => ("", project.as_str()),
178+
};
179+
let join = |leaf: &str| {
180+
if dir.is_empty() {
181+
leaf.to_string()
182+
} else {
183+
format!("{dir}/{leaf}")
184+
}
185+
};
186+
let lock = match lock_file_path_property(text) {
187+
Some(Err(detail)) => {
188+
out.unresolved.push((project.clone(), detail));
189+
continue;
190+
}
191+
Some(Ok(value)) => {
192+
match resolve_relative(dir, &value) {
193+
Some(rel) => rel,
194+
None => {
195+
out.unresolved.push((
196+
project.clone(),
197+
format!("NuGetLockFilePath `{value}` is absolute or leaves the project root"),
198+
));
199+
continue;
200+
}
201+
}
202+
}
203+
None => {
204+
let stem = &file[..file.rfind('.').unwrap_or(file.len())];
205+
let named = join(&format!("packages.{}.lock.json", stem.replace(' ', "_")));
206+
if exists(&named) {
207+
named
208+
} else {
209+
join(PACKAGES_LOCK)
210+
}
211+
}
212+
};
213+
if exists(&lock) && !out.locks.contains(&lock) {
214+
out.locks.push(lock);
215+
}
216+
}
217+
out
218+
}
219+
220+
/// The project's literal `NuGetLockFilePath`: `None` when it sets none,
221+
/// `Some(Err)` when it sets one this reader cannot evaluate.
222+
fn lock_file_path_property(text: &str) -> Option<Result<String, String>> {
223+
const OPEN: &str = "<NuGetLockFilePath";
224+
const CLOSE: &str = "</NuGetLockFilePath>";
225+
let text = strip_xml_comments(text);
226+
let mut value: Option<Result<String, String>> = None;
227+
let mut rest = text.as_str();
228+
while let Some(at) = rest.find(OPEN) {
229+
let after = &rest[at + OPEN.len()..];
230+
// `<NuGetLockFilePathX>` is another property.
231+
if !after.starts_with(['>', ' ', '\t', '\r', '\n', '/']) {
232+
rest = after;
233+
continue;
234+
}
235+
let Some(gt) = after.find('>') else {
236+
return Some(Err("an unterminated NuGetLockFilePath element".to_string()));
237+
};
238+
let attrs = after[..gt].trim();
239+
if attrs.ends_with('/') {
240+
// `<NuGetLockFilePath />`: an empty value, NuGet's default.
241+
value = None;
242+
rest = &after[gt + 1..];
243+
continue;
244+
}
245+
let Some(end) = after[gt + 1..].find(CLOSE) else {
246+
return Some(Err("an unterminated NuGetLockFilePath element".to_string()));
247+
};
248+
let raw = after[gt + 1..gt + 1 + end].trim();
249+
rest = &after[gt + 1 + end + CLOSE.len()..];
250+
if raw.is_empty() && attrs.is_empty() {
251+
value = None;
252+
continue;
253+
}
254+
value = Some(if !attrs.is_empty() {
255+
Err(format!(
256+
"NuGetLockFilePath `{raw}` is conditional ({attrs}); its value depends on the build"
257+
))
258+
} else if raw.contains("$(") || raw.contains("@(") || raw.contains("%(") {
259+
Err(format!(
260+
"NuGetLockFilePath `{raw}` references MSBuild properties or items"
261+
))
262+
} else {
263+
Ok(raw.to_string())
264+
});
265+
}
266+
value
267+
}
268+
269+
fn strip_xml_comments(text: &str) -> String {
270+
let mut out = String::with_capacity(text.len());
271+
let mut rest = text;
272+
while let Some(at) = rest.find("<!--") {
273+
out.push_str(&rest[..at]);
274+
match rest[at + 4..].find("-->") {
275+
Some(end) => rest = &rest[at + 4 + end + 3..],
276+
None => return out,
277+
}
278+
}
279+
out.push_str(rest);
280+
out
281+
}
282+
283+
/// `value` (a project-relative path, either separator) resolved against
284+
/// the root-relative `dir`; `None` when it is absolute or leaves the root.
285+
fn resolve_relative(dir: &str, value: &str) -> Option<String> {
286+
let value = value.replace('\\', "/");
287+
if value.starts_with('/') || value.as_bytes().get(1) == Some(&b':') {
288+
return None;
289+
}
290+
let mut parts: Vec<&str> = dir.split('/').filter(|p| !p.is_empty()).collect();
291+
for seg in value.split('/') {
292+
match seg {
293+
"" | "." => {}
294+
".." => {
295+
parts.pop()?;
296+
}
297+
seg => parts.push(seg),
298+
}
299+
}
300+
(!parts.is_empty()).then(|| parts.join("/"))
301+
}
302+
130303
#[cfg(test)]
131304
mod tests {
132305
use super::*;
@@ -176,4 +349,77 @@ mod tests {
176349
let doc = parse_lock(&MULTI.replace("\"12.0.3\"", "\"13.0.3.0\"")).unwrap();
177350
assert!(other_versions(&doc, "Newtonsoft.Json", "13.0.3").is_empty());
178351
}
352+
353+
fn exists_in(files: &'static [&'static str]) -> impl Fn(&str) -> bool {
354+
move |p| files.contains(&p)
355+
}
356+
357+
#[test]
358+
fn member_and_named_locks_are_governed() {
359+
let projects = vec![
360+
("src/App/App.csproj".to_string(), "<Project />".to_string()),
361+
(
362+
"src/Lib/My Lib.fsproj".to_string(),
363+
"<Project />".to_string(),
364+
),
365+
("tests/T/T.vbproj".to_string(), "<Project />".to_string()),
366+
];
367+
let got = governed_locks(
368+
&projects,
369+
exists_in(&[
370+
"packages.lock.json",
371+
"src/App/packages.lock.json",
372+
"src/Lib/packages.My_Lib.lock.json",
373+
"src/Lib/packages.lock.json",
374+
]),
375+
);
376+
assert_eq!(
377+
got.locks,
378+
[
379+
"packages.lock.json",
380+
"src/App/packages.lock.json",
381+
"src/Lib/packages.My_Lib.lock.json"
382+
]
383+
);
384+
assert!(got.unresolved.is_empty());
385+
}
386+
387+
#[test]
388+
fn lock_file_path_property_is_honored_or_refused() {
389+
let project = |body: &str| {
390+
vec![(
391+
"src/App/App.csproj".to_string(),
392+
format!("<Project><PropertyGroup>{body}</PropertyGroup></Project>"),
393+
)]
394+
};
395+
let got = governed_locks(
396+
&project("<NuGetLockFilePath>..\\locks/app.lock.json</NuGetLockFilePath>"),
397+
exists_in(&["src/locks/app.lock.json", "src/App/packages.lock.json"]),
398+
);
399+
assert_eq!(got.locks, ["src/locks/app.lock.json"]);
400+
// A commented-out property is not set.
401+
let got = governed_locks(
402+
&project("<!-- <NuGetLockFilePath>x.json</NuGetLockFilePath> -->"),
403+
exists_in(&["src/App/packages.lock.json"]),
404+
);
405+
assert_eq!(got.locks, ["src/App/packages.lock.json"]);
406+
for body in [
407+
"<NuGetLockFilePath>$(MSBuildProjectDirectory)/l.json</NuGetLockFilePath>",
408+
"<NuGetLockFilePath Condition=\"'$(CI)'=='true'\">l.json</NuGetLockFilePath>",
409+
"<NuGetLockFilePath>../../../outside.json</NuGetLockFilePath>",
410+
"<NuGetLockFilePath>/abs/l.json</NuGetLockFilePath>",
411+
] {
412+
let got = governed_locks(&project(body), exists_in(&[]));
413+
assert!(got.locks.is_empty(), "{body}");
414+
assert_eq!(got.unresolved.len(), 1, "{body}");
415+
}
416+
}
417+
418+
#[test]
419+
fn project_files_are_recognized_by_extension() {
420+
assert!(is_project_file("App.csproj"));
421+
assert!(is_project_file("App.FSPROJ"));
422+
assert!(!is_project_file(".csproj"));
423+
assert!(!is_project_file("App.sln"));
424+
}
179425
}

0 commit comments

Comments
 (0)