Skip to content

Commit deeba13

Browse files
committed
Merge remote-tracking branch 'origin/agent/v5-nuget-member-locks' into agent/v5-nuget-gpf-shadow
2 parents 93557ed + d99550f commit deeba13

5 files changed

Lines changed: 195 additions & 91 deletions

File tree

‎crates/socket-patch-core/src/hosted/engine.rs‎

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -633,32 +633,32 @@ pub async fn read_candidate_files(
633633
}
634634

635635
// NuGet: the root config routes every project under the root, so each
636-
// project's lock is pinned with it (#353, #514). The project files ride
637-
// along as advisory input (never rewritten) so the pure rewriter
638-
// re-derives which keys are locks; a walk that cannot see the whole
639-
// tree rides a synthetic key and the rewriter skips the redirect.
640-
// NuGet is disk-only (the in-memory engine refuses it).
636+
// project's lock is pinned with it (#353, #514). The walk's answer rides
637+
// a synthetic key (the lock paths, an unevaluable NuGetLockFilePath, or
638+
// why the tree could not be listed) and every lock is read. The project
639+
// files themselves stay out of the candidate texts. NuGet is disk-only
640+
// (the in-memory engine refuses it).
641641
if candidates.iter().any(|c| c.dep.ecosystem == "nuget") {
642642
if let Some(root) = view.disk_root() {
643-
match crate::vendor::nuget_config::project_files(root) {
644-
Ok(projects) => {
645-
let governed = crate::formats::nuget::lock::governed_locks(&projects, |rel| {
646-
crate::vendor::nuget_config::lock_present(root, rel)
647-
});
648-
for (rel, text) in projects {
649-
out.files.entry(rel).or_insert(text);
643+
let mut lines: Vec<String> = Vec::new();
644+
match crate::vendor::nuget_config::governed_locks_on_disk(root) {
645+
Ok(governed) => {
646+
for (project, detail) in &governed.unresolved {
647+
lines.push(format!("unresolved\t{project}\t{detail}"));
650648
}
651649
for rel in governed.locks {
652650
if !out.files.contains_key(&rel) {
653651
out.read(view, unreadable, &rel).await;
654652
}
653+
lines.push(format!("lock\t{rel}"));
655654
}
656655
}
657-
Err(why) => {
658-
out.files
659-
.insert(crate::patch::redirect::NUGET_LOCK_WALK_KEY.to_string(), why);
660-
}
656+
Err(why) => lines.push(format!("error\t{why}")),
661657
}
658+
out.files.insert(
659+
crate::patch::redirect::NUGET_LOCKS_KEY.to_string(),
660+
lines.join("\n"),
661+
);
662662
}
663663
}
664664

‎crates/socket-patch-core/src/patch/redirect/mod.rs‎

Lines changed: 68 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -5760,11 +5760,13 @@ fn nuget_xml_attribute(value: &str) -> String {
57605760
.replace('\r', "&#xD;")
57615761
}
57625762

5763-
/// The synthetic candidate key carrying why the engine could not list the
5764-
/// project's NuGet locks (an unreadable directory or project file): the
5765-
/// rewriter then skips the nuget redirect rather than leave a lock it never
5766-
/// saw on its upstream hash. Never a path (see [`sbt::SYNTHETIC_KEY_PREFIX`]).
5767-
pub const NUGET_LOCK_WALK_KEY: &str = "<socket-patch:nuget-lock-walk>";
5763+
/// The synthetic candidate key carrying the locks the engine found every
5764+
/// project under the root restoring into (#353, #514), one per line:
5765+
/// `lock\t<rel>`, `unresolved\t<project>\t<why>` for a `NuGetLockFilePath`
5766+
/// it cannot evaluate, or `error\t<why>` when it could not list the tree.
5767+
/// Absent (the in-memory engine, unit tests), the root `packages.lock.json`
5768+
/// is the one lock. Never a path (see [`sbt::SYNTHETIC_KEY_PREFIX`]).
5769+
pub const NUGET_LOCKS_KEY: &str = "<socket-patch:nuget-locks>";
57685770

57695771
fn rewrite_nuget(
57705772
files: &BTreeMap<String, String>,
@@ -5808,41 +5810,45 @@ fn rewrite_nuget(
58085810
// routes them all, so each is pinned with it (#353, #514). The engine
58095811
// reads the project files and their locks; the same pure discovery
58105812
// re-derives which keys are locks here.
5811-
if let Some(why) = files.get(NUGET_LOCK_WALK_KEY) {
5812-
result.warnings.push(RewriteWarning {
5813-
code: "redirect_nuget_lock_unreadable".into(),
5814-
detail: format!(
5815-
"cannot list the project's NuGet locks ({why}); nuget redirect skipped"
5816-
),
5817-
});
5818-
return;
5819-
}
5820-
let projects: Vec<(String, String)> = files
5821-
.iter()
5822-
.filter(|(rel, _)| {
5823-
!sbt::is_synthetic_key(rel)
5824-
&& crate::formats::nuget::lock::is_project_file(
5825-
rel.rsplit('/').next().unwrap_or(rel),
5826-
)
5827-
})
5828-
.map(|(rel, text)| (rel.clone(), text.clone()))
5829-
.collect();
5830-
let governed =
5831-
crate::formats::nuget::lock::governed_locks(&projects, |rel| files.contains_key(rel));
5832-
if let Some((project, detail)) = governed.unresolved.first() {
5833-
result.warnings.push(RewriteWarning {
5834-
code: "redirect_nuget_lock_path_unresolved".into(),
5835-
detail: format!(
5836-
"{project}: {detail}; the lock it restores into cannot be pinned, so nuget \
5837-
redirect is skipped (set a literal NuGetLockFilePath, or remove it)"
5838-
),
5839-
});
5840-
return;
5841-
}
5813+
let lock_rels: Vec<String> = match files.get(NUGET_LOCKS_KEY) {
5814+
None => vec![crate::formats::nuget::lock::PACKAGES_LOCK.to_string()],
5815+
Some(found) => {
5816+
let mut rels = Vec::new();
5817+
for line in found.lines() {
5818+
let mut fields = line.splitn(3, '\t');
5819+
match (fields.next(), fields.next(), fields.next()) {
5820+
(Some("lock"), Some(rel), None) => rels.push(rel.to_string()),
5821+
(Some("unresolved"), Some(project), Some(detail)) => {
5822+
result.warnings.push(RewriteWarning {
5823+
code: "redirect_nuget_lock_path_unresolved".into(),
5824+
detail: format!(
5825+
"{project}: {detail}; the lock it restores into cannot be \
5826+
pinned, so nuget redirect is skipped (set a literal \
5827+
NuGetLockFilePath, or remove it)"
5828+
),
5829+
});
5830+
return;
5831+
}
5832+
(Some("error"), Some(why), _) => {
5833+
result.warnings.push(RewriteWarning {
5834+
code: "redirect_nuget_lock_unreadable".into(),
5835+
detail: format!(
5836+
"cannot list the project's NuGet locks ({why}); nuget redirect \
5837+
skipped"
5838+
),
5839+
});
5840+
return;
5841+
}
5842+
_ => {}
5843+
}
5844+
}
5845+
rels
5846+
}
5847+
};
58425848
// Read past a UTF-8 BOM the way dotnet does (#623); the write below
58435849
// keeps it.
58445850
let mut locks: Vec<(String, &String, Value, bool)> = Vec::new();
5845-
for rel in governed.locks {
5851+
for rel in lock_rels {
58465852
let Some(text) = files.get(&rel) else {
58475853
continue;
58485854
};
@@ -22644,21 +22650,24 @@ packages:
2264422650
}
2264522651

2264622652
/// #353 / #514: member-project locks and named locks the root config
22647-
/// governs are re-pinned under their own paths with the config.
22653+
/// governs (the engine's walk, carried by [`NUGET_LOCKS_KEY`]) are
22654+
/// re-pinned under their own paths with the config.
2264822655
#[test]
2264922656
fn nuget_member_and_named_locks_are_repinned() {
2265022657
let mut files = BTreeMap::new();
2265122658
files.insert("nuget.config".to_string(), default_nuget_config());
22652-
files.insert("src/App/App.csproj".to_string(), "<Project />".to_string());
2265322659
files.insert(
2265422660
"src/App/packages.lock.json".to_string(),
2265522661
simple_lock("ORIGINALHASH=="),
2265622662
);
22657-
files.insert("src/Lib/Lib.csproj".to_string(), "<Project />".to_string());
2265822663
files.insert(
2265922664
"src/Lib/packages.Lib.lock.json".to_string(),
2266022665
simple_lock("ORIGINALHASH=="),
2266122666
);
22667+
files.insert(
22668+
NUGET_LOCKS_KEY.to_string(),
22669+
"lock\tsrc/App/packages.lock.json\nlock\tsrc/Lib/packages.Lib.lock.json".to_string(),
22670+
);
2266222671
let r = rewrite_registry_redirect(&files, &[nuget_override()]);
2266322672
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
2266422673
for rel in [
@@ -22671,7 +22680,6 @@ packages:
2267122680
"{rel}"
2267222681
);
2267322682
}
22674-
assert!(!r.files.contains_key("src/App/App.csproj"));
2267522683
let lock_paths: Vec<&str> = r
2267622684
.edits
2267722685
.iter()
@@ -22685,35 +22693,31 @@ packages:
2268522693
"src/Lib/packages.Lib.lock.json"
2268622694
]
2268722695
);
22696+
// Without the walk (in memory, unit tests) only the root lock is one:
22697+
// a member lock is never mistaken for one.
22698+
files.remove(NUGET_LOCKS_KEY);
22699+
let r = rewrite_registry_redirect(&files, &[nuget_override()]);
22700+
assert!(!r.files.contains_key("src/App/packages.lock.json"));
2268822701
}
2268922702

2269022703
/// #514: an unresolvable `NuGetLockFilePath`, or a project tree the
2269122704
/// engine could not list, skips the nuget redirect with nothing written.
2269222705
#[test]
2269322706
fn nuget_unknowable_locks_skip_the_redirect() {
22694-
let mut files = BTreeMap::new();
22695-
files.insert("nuget.config".to_string(), default_nuget_config());
22696-
files.insert(
22697-
"app.csproj".to_string(),
22698-
"<Project><PropertyGroup><NuGetLockFilePath>$(X)/l.json</NuGetLockFilePath></PropertyGroup></Project>"
22699-
.to_string(),
22700-
);
22701-
let r = rewrite_registry_redirect(&files, &[nuget_override()]);
22702-
assert!(r.files.is_empty() && r.edits.is_empty(), "{:?}", r.files);
22703-
assert_eq!(
22704-
warning_codes(&r),
22705-
vec!["redirect_nuget_lock_path_unresolved"]
22706-
);
22707-
22708-
let mut files = BTreeMap::new();
22709-
files.insert("nuget.config".to_string(), default_nuget_config());
22710-
files.insert(
22711-
NUGET_LOCK_WALK_KEY.to_string(),
22712-
"unreadable src".to_string(),
22713-
);
22714-
let r = rewrite_registry_redirect(&files, &[nuget_override()]);
22715-
assert!(r.files.is_empty() && r.edits.is_empty(), "{:?}", r.files);
22716-
assert_eq!(warning_codes(&r), vec!["redirect_nuget_lock_unreadable"]);
22707+
for (walk, code) in [
22708+
(
22709+
"lock\tpackages.lock.json\nunresolved\tapp.csproj\tNuGetLockFilePath `$(X)/l.json` references MSBuild properties or items",
22710+
"redirect_nuget_lock_path_unresolved",
22711+
),
22712+
("error\tunreadable src", "redirect_nuget_lock_unreadable"),
22713+
] {
22714+
let mut files = BTreeMap::new();
22715+
files.insert("nuget.config".to_string(), default_nuget_config());
22716+
files.insert(NUGET_LOCKS_KEY.to_string(), walk.to_string());
22717+
let r = rewrite_registry_redirect(&files, &[nuget_override()]);
22718+
assert!(r.files.is_empty() && r.edits.is_empty(), "{:?}", r.files);
22719+
assert_eq!(warning_codes(&r), vec![code]);
22720+
}
2271722721
}
2271822722

2271922723
/// #593: a multi-targeting lock resolving the patched id at another

‎crates/socket-patch-core/src/vendor/nuget_config.rs‎

Lines changed: 58 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -86,22 +86,41 @@ pub(crate) fn project_files(root: &std::path::Path) -> Result<Vec<(String, Strin
8686
std::fs::read_dir(&dir).map_err(|e| format!("unreadable {}: {e}", dir.display()))?;
8787
for entry in entries {
8888
let entry = entry.map_err(|e| format!("unreadable {}: {e}", dir.display()))?;
89-
let Some(name) = entry.file_name().to_str().map(str::to_string) else {
89+
let raw = entry.file_name();
90+
let Some(name) = raw.to_str().map(str::to_string) else {
91+
// A project (or a directory that may hold one) this walk
92+
// cannot name is a lock it would miss: fail closed.
93+
let lossy = raw.to_string_lossy();
94+
if crate::formats::nuget::lock::is_project_file(&lossy)
95+
|| entry.file_type().is_ok_and(|k| k.is_dir())
96+
{
97+
return Err(format!(
98+
"{} has a name that is not UTF-8",
99+
dir.join(&raw).display()
100+
));
101+
}
90102
continue;
91103
};
92104
let child = if rel.is_empty() {
93105
name.clone()
94106
} else {
95107
format!("{rel}/{name}")
96108
};
97-
let Ok(kind) = entry.file_type() else {
98-
continue;
99-
};
109+
let kind = entry
110+
.file_type()
111+
.map_err(|e| format!("unreadable {}: {e}", entry.path().display()))?;
112+
// A symlinked directory is not entered (it may lead outside the
113+
// tree, or back into it); a symlinked project file is read
114+
// through the link, like MSBuild opens it.
115+
let is_project = crate::formats::nuget::lock::is_project_file(&name)
116+
&& (kind.is_file()
117+
|| (kind.is_symlink()
118+
&& std::fs::metadata(entry.path()).is_ok_and(|m| m.is_file())));
100119
if kind.is_dir() {
101120
if !name.starts_with('.') && !SKIPPED_DIRS.contains(&name.as_str()) {
102121
pending.push(child);
103122
}
104-
} else if kind.is_file() && crate::formats::nuget::lock::is_project_file(&name) {
123+
} else if is_project {
105124
let path = entry.path();
106125
if std::fs::metadata(&path).is_ok_and(|m| m.len() > MAX_PROJECT_BYTES) {
107126
return Err(format!("{} is too large to read", path.display()));
@@ -167,4 +186,38 @@ mod tests {
167186
std::fs::create_dir(&dir).unwrap();
168187
assert_eq!(same_file(&dir, &dir).await, cfg!(unix));
169188
}
189+
190+
/// The walk reads a symlinked project file, never enters build output
191+
/// or hidden dirs, and finds projects at any depth.
192+
#[cfg(unix)]
193+
#[test]
194+
fn project_walk_follows_project_links_and_skips_output() {
195+
let tmp = tempfile::tempdir().unwrap();
196+
let root = tmp.path();
197+
for dir in ["src/App/obj", "src/Lib", ".git", "shared"] {
198+
std::fs::create_dir_all(root.join(dir)).unwrap();
199+
}
200+
std::fs::write(root.join("src/App/App.csproj"), "<Project />").unwrap();
201+
std::fs::write(root.join("src/App/obj/Gen.csproj"), "<Project />").unwrap();
202+
std::fs::write(root.join(".git/X.csproj"), "<Project />").unwrap();
203+
std::fs::write(root.join("shared/Lib.csproj"), "<Project>lib</Project>").unwrap();
204+
std::os::unix::fs::symlink(
205+
root.join("shared/Lib.csproj"),
206+
root.join("src/Lib/Lib.csproj"),
207+
)
208+
.unwrap();
209+
let found: Vec<String> = super::project_files(root)
210+
.unwrap()
211+
.into_iter()
212+
.map(|(rel, _)| rel)
213+
.collect();
214+
assert_eq!(
215+
found,
216+
[
217+
"shared/Lib.csproj",
218+
"src/App/App.csproj",
219+
"src/Lib/Lib.csproj"
220+
]
221+
);
222+
}
170223
}

‎crates/socket-patch-core/src/vendor/nuget_feed.rs‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -444,6 +444,10 @@ pub async fn vendor_nuget(
444444
// re-attaches the untouched config records.
445445
let mut wiring: Vec<WiringRecord> = Vec::new();
446446
let new_hash = sha512_base64_of(&bytes);
447+
// Locks already re-pinned, put back if a later one fails: the
448+
// projects must agree on one nupkg (the rebuilt artifact stays,
449+
// the config routes to it).
450+
let mut written: Vec<(&LockFile, &str)> = Vec::new();
447451
for lock in &locks {
448452
match edit_lock(&lock.text, name, &version_norm, &new_hash) {
449453
Ok(Some(edit)) => {
@@ -452,10 +456,12 @@ pub async fn vendor_nuget(
452456
atomic_write_bytes_preserving_mode(&lock.path, edit.text.as_bytes())
453457
.await
454458
{
459+
unwind_locks(&written).await;
455460
result.success = false;
456461
result.error = Some(format!("failed to rewrite {}: {e}", lock.rel));
457462
return done(result, None, warnings);
458463
}
464+
written.push((lock, lock.text.as_str()));
459465
wiring.push(WiringRecord {
460466
file: lock.rel.clone(),
461467
kind: LOCK_WIRING_KIND.to_string(),
@@ -467,6 +473,7 @@ pub async fn vendor_nuget(
467473
}
468474
Ok(None) => {}
469475
Err(detail) => {
476+
unwind_locks(&written).await;
470477
result.success = false;
471478
result.error = Some(format!("{}: {detail}", lock.rel));
472479
return done(result, None, warnings);

0 commit comments

Comments
 (0)