diff --git a/crates/socket-patch-cli/src/commands/vendor_orphans.rs b/crates/socket-patch-cli/src/commands/vendor_orphans.rs index eb6eaaa63..2cf6cf779 100644 --- a/crates/socket-patch-cli/src/commands/vendor_orphans.rs +++ b/crates/socket-patch-cli/src/commands/vendor_orphans.rs @@ -68,7 +68,9 @@ pub(crate) async fn sweep_orphan_vendor_dirs_where( if candidates.is_empty() { return out; } - let references = crate::commands::vendored_backend::repair::scan_vendor_references(cwd).await; + // Keep-vs-delete: any case counts, as in the revert keep-checks. + let references = + crate::commands::vendored_backend::repair::scan_vendor_references_any_case(cwd).await; let wired: HashSet<(&str, &str)> = references .iter() .map(|(eco, uuid, _path)| (eco.as_str(), uuid.as_str())) diff --git a/crates/socket-patch-cli/src/commands/vendored_backend/repair.rs b/crates/socket-patch-cli/src/commands/vendored_backend/repair.rs index 31075b1fa..4a8f7ffca 100644 --- a/crates/socket-patch-cli/src/commands/vendored_backend/repair.rs +++ b/crates/socket-patch-cli/src/commands/vendored_backend/repair.rs @@ -37,7 +37,78 @@ struct Candidate { /// path parser. Used by repair (references the ledger does not cover), by /// the orphan sweeps (`vendor --revert`, `scan --prune`: a dir a lockfile /// still points at is never deleted) and by rollback. +/// +/// Every escape and separator spelling of a reference counts (an older +/// composer.lock's `\/`, any run of Windows backslashes, `\u` / +/// XML-entity escapes). This form REFUSES on what it finds (`vendor`'s +/// ledger-missing gate, rollback's ledger-less gate, `vendor --check`), so +/// it folds ASCII case only where the project's file system does: on a +/// case-sensitive disk another case names another directory. The orphan +/// sweep's keep-vs-delete reads [`scan_vendor_references_any_case`]. pub(crate) async fn scan_vendor_references(project_root: &Path) -> Vec<(String, String, String)> { + let fold_case = socket_patch_core::utils::fs::folds_case( + &project_root.join(socket_patch_core::constants::SOCKET_DIR), + ) + .await; + scan_vendor_references_folding(project_root, fold_case).await +} + +/// [`scan_vendor_references`] matching the `.socket/vendor//` +/// anchor in any ASCII case, as the revert keep-checks +/// ([`socket_patch_core::utils::path_spelling::PathNeedle::keep`]) do: the +/// orphan sweep only ever KEEPS a dir on what it finds, so erring toward +/// "referenced" is the safe direction and the two deletion paths agree. +pub(crate) async fn scan_vendor_references_any_case( + project_root: &Path, +) -> Vec<(String, String, String)> { + scan_vendor_references_folding(project_root, true).await +} + +/// Recover a vendored reference from a folded `candidate`, ASCII +/// case-insensitively when `fold_case` (eco and uuid lower-cased, the leaf +/// and the relpath kept as spelled). +fn parse_reference_folding(candidate: &str, fold_case: bool) -> Option<(String, String, String)> { + if let Some(parts) = parse_vendor_reference(candidate) { + let rel = candidate + .trim_start_matches("./") + .trim_end_matches('/') + .to_string(); + return Some((parts.eco, parts.uuid, rel)); + } + if !fold_case { + return None; + } + let parts = parse_vendor_reference(&candidate.to_ascii_lowercase())?; + // ASCII lower-casing keeps every byte offset, and the leaf is the + // candidate's own tail. + let trimmed = candidate.trim_end_matches('/'); + let leaf = &trimmed[trimmed.len() - parts.leaf.len()..]; + let mut rel = format!(".socket/vendor/{}/{}", parts.eco, parts.uuid); + if !leaf.is_empty() { + rel.push('/'); + rel.push_str(leaf); + } + Some((parts.eco, parts.uuid, rel)) +} + +/// The byte offset of the first `.socket` in `text`, ASCII +/// case-insensitively when `fold_case`. +fn find_socket_anchor(text: &str, fold_case: bool) -> Option { + const ANCHOR: &str = ".socket"; + if !fold_case { + return text.find(ANCHOR); + } + text.match_indices('.').map(|(i, _)| i).find(|&i| { + text.as_bytes() + .get(i..i + ANCHOR.len()) + .is_some_and(|s| s.eq_ignore_ascii_case(ANCHOR.as_bytes())) + }) +} + +async fn scan_vendor_references_folding( + project_root: &Path, + fold_case: bool, +) -> Vec<(String, String, String)> { let mut seen: HashSet<(String, String)> = HashSet::new(); let mut out = Vec::new(); // The binary lock is read unless a readable text lock shadows it: a @@ -72,8 +143,13 @@ pub(crate) async fn scan_vendor_references(project_root: &Path) -> Vec<(String, let Ok(text) = read_regular_to_string(&project_root.join(file)).await else { continue; }; - let mut rest = text.as_str(); - while let Some(idx) = rest.find(".socket") { + // Every spelling folded first: escapes decoded and every run of `/` + // and `\` collapsed to one `/` (an older composer.lock's `\/`, any + // number of Windows backslashes). Missing one here lets the orphan + // sweep delete a dir a lock still installs from. + let text = socket_patch_core::utils::path_spelling::canonical_path_text(&text); + let mut rest: &str = &text; + while let Some(idx) = find_socket_anchor(rest, fold_case) { let slice = &rest[idx..]; // `:` ends a reference too: pnpm snapshot keys are // `name@file::` and yaml mappings suffix the path with a @@ -84,18 +160,11 @@ pub(crate) async fn scan_vendor_references(project_root: &Path) -> Vec<(String, '"', '\'', '`', ' ', '\t', '\n', '\r', ',', ')', ']', '}', ';', ':', '<', ]) .unwrap_or(slice.len()); - let candidate = slice[..end].replace('\\', "/"); + let candidate = &slice[..end]; // NuGet's feed and Maven's repository name the uuid dir itself. - if let Some(parts) = parse_vendor_reference(&candidate) { - if seen.insert((parts.eco.to_string(), parts.uuid.clone())) { - out.push(( - parts.eco.to_string(), - parts.uuid.clone(), - candidate - .trim_start_matches("./") - .trim_end_matches('/') - .to_string(), - )); + if let Some((eco, uuid, rel)) = parse_reference_folding(candidate, fold_case) { + if seen.insert((eco.clone(), uuid.clone())) { + out.push((eco, uuid, rel)); } } rest = &rest[idx + ".socket".len()..]; @@ -1275,6 +1344,70 @@ mod tests { ); } + /// An older composer.lock spells slashes `\/` and a Windows checkout may + /// double (or quadruple) its backslashes: the scan folds every run to the + /// canonical path, so the orphan sweep keeps a dir such a lock still + /// installs from. A different uuid's dir is never this one's reference. + #[tokio::test] + async fn scan_recovers_escaped_and_doubled_backslash_references() { + let uuid = "33333333-3333-4333-8333-333333333333"; + let other = "44444444-3333-4333-8333-333333333333"; + let path = format!(".socket/vendor/composer/{uuid}/psr/log@3.0.2"); + for lock in [ + format!("{{\"url\": \"{}\"}}", path.replace('/', "\\/")), + format!("{{\"url\": \"{}\"}}", path.replace('/', "\\\\")), + format!("{{\"url\": \"{}\"}}", path.replace('/', "\\\\\\\\")), + format!("{{\"url\": \"{}\"}}", path.replace('/', "\\\\\\/")), + format!("{{\"url\": \"./{}\"}}", path.replace('/', "\\/")), + ] { + let tmp = tempfile::tempdir().unwrap(); + tokio::fs::write(tmp.path().join("composer.lock"), &lock) + .await + .unwrap(); + let refs = scan_vendor_references(tmp.path()).await; + assert_eq!( + refs, + vec![("composer".to_string(), uuid.to_string(), path.clone())], + "{lock}" + ); + assert!( + refs.iter().all(|(_, u, _)| u != other), + "a different uuid's dir is never this one's reference" + ); + assert_eq!(scan_vendor_references_any_case(tmp.path()).await, refs); + } + } + + /// The orphan sweep's scan matches the anchor in any case, as the revert + /// keep-checks do, so a re-cased reference keeps the dir (eco and uuid + /// reported canonical, the leaf as spelled). The refusal-side scan folds + /// case only where the disk does. + #[tokio::test] + async fn any_case_scan_recovers_a_recased_reference() { + let uuid = "55555555-3333-4333-8333-333333333333"; + let tmp = tempfile::tempdir().unwrap(); + let lock = format!( + "{{\"url\": \"{}\"}}", + format!( + ".Socket\\Vendor\\Composer\\{}\\Psr\\Log@3.0.2", + uuid.to_ascii_uppercase() + ) + ); + tokio::fs::write(tmp.path().join("composer.lock"), &lock) + .await + .unwrap(); + assert_eq!( + scan_vendor_references_any_case(tmp.path()).await, + vec![( + "composer".to_string(), + uuid.to_string(), + format!(".socket/vendor/composer/{uuid}/Psr/Log@3.0.2") + )] + ); + // No `.socket/` to probe: the refusal-side scan keeps case. + assert!(scan_vendor_references(tmp.path()).await.is_empty()); + } + /// The scanner's false-positive guard: a `.socket` mention that is NOT /// a parseable vendored-artifact path (the committed manifest, a /// non-uuid path segment) must never be reported as a vendor reference diff --git a/crates/socket-patch-core/src/utils/fs.rs b/crates/socket-patch-core/src/utils/fs.rs index 2e61091dc..62cee09b5 100644 --- a/crates/socket-patch-core/src/utils/fs.rs +++ b/crates/socket-patch-core/src/utils/fs.rs @@ -88,6 +88,48 @@ pub(crate) async fn is_dir(path: &Path) -> bool { .unwrap_or(false) } +/// Whether the file system holding `path` resolves names ASCII +/// case-insensitively, probed by looking `path` up with the case of its +/// last component flipped: the same file answers yes. `false` when that +/// cannot be shown (`path` missing, no ASCII letter in its name, a distinct +/// file under the flipped name). +pub async fn folds_case(path: &Path) -> bool { + let Some(name) = path.file_name().and_then(|n| n.to_str()) else { + return false; + }; + let flipped: String = name + .chars() + .map(|c| { + if c.is_ascii_lowercase() { + c.to_ascii_uppercase() + } else { + c.to_ascii_lowercase() + } + }) + .collect(); + if flipped == name { + return false; + } + let (Ok(a), Ok(b)) = ( + tokio::fs::symlink_metadata(path).await, + tokio::fs::symlink_metadata(path.with_file_name(&flipped)).await, + ) else { + return false; + }; + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + a.dev() == b.dev() && a.ino() == b.ino() + } + #[cfg(not(unix))] + { + // No stable file id on stable Rust here; Windows file systems fold + // case, and a second, distinct entry under the flipped name is + // practically never there. + a.file_type() == b.file_type() + } +} + /// Blocking twin of [`list_dir_entries`] for walkers that run whole on the /// blocking pool (one hop per crawl instead of one per filesystem call). /// diff --git a/crates/socket-patch-core/src/utils/mod.rs b/crates/socket-patch-core/src/utils/mod.rs index c3bfbe2df..bb414e162 100644 --- a/crates/socket-patch-core/src/utils/mod.rs +++ b/crates/socket-patch-core/src/utils/mod.rs @@ -20,6 +20,7 @@ pub mod lock_fragments; pub mod notice; pub(crate) mod npm_range; pub(crate) mod package_manager; +pub mod path_spelling; pub mod pdm_lock; pub(crate) mod pep440; pub mod pipenv; diff --git a/crates/socket-patch-core/src/utils/path_spelling.rs b/crates/socket-patch-core/src/utils/path_spelling.rs new file mode 100644 index 000000000..f5a795007 --- /dev/null +++ b/crates/socket-patch-core/src/utils/path_spelling.rs @@ -0,0 +1,315 @@ +//! The spellings a project file may give a path, folded to one: the +//! escapes a lock or config parser decodes (JSON / TOML `\uXXXX`, XML +//! character and named entities), JSON `\/` slash escapes (older +//! composer.lock), and Windows backslashes, however many of them. +//! +//! Every vendored check that asks "does this project file still name this +//! `.socket/vendor/` path?" answers through one [`PathNeedle`]. It folds +//! each escape, collapses every run of `/` and `\` to one `/`, and +//! optionally ignores ASCII case. Every check whose miss lets an artifact +//! be deleted folds case ([`PathNeedle::keep`], [`mentions_path`]), even +//! where it refuses on a hit: a committed file is installed on +//! case-folding disks too, and erring toward "mentioned" keeps the +//! artifact. Only a refusal whose miss is safe (lifting another refusal, a +//! report) folds case where the project's file system does +//! (`vendor::common::refusal_needle`). +//! +//! Discovery's identity sweep keeps its own [`fold_path_spellings`], which +//! leaves separator runs alone: its hosted-url recognition anchors on `://`. +//! Pure string functions: no filesystem access. + +use std::borrow::Cow; + +/// A forward-slashed relative path, folded once, to search texts for in +/// every spelling (see the module doc). `found_in` is one linear pass over +/// the text with no copy of it: an exact `contains` first, then a KMP +/// search over the folded byte stream. +#[derive(Debug, Clone)] +pub struct PathNeedle { + raw: String, + folded: Vec, + fail: Vec, + fold_case: bool, +} + +impl PathNeedle { + /// `rel`, matched ASCII case-insensitively when `fold_case`. + pub fn new(rel: &str, fold_case: bool) -> Self { + let mut folded = Vec::with_capacity(rel.len()); + walk_folded(rel, fold_case, |b| { + folded.push(b); + false + }); + let mut fail = vec![0; folded.len()]; + let mut k = 0; + for i in 1..folded.len() { + while k > 0 && folded[i] != folded[k] { + k = fail[k - 1]; + } + if folded[i] == folded[k] { + k += 1; + } + fail[i] = k; + } + PathNeedle { + raw: rel.to_string(), + folded, + fail, + fold_case, + } + } + + /// The broad needle a keep-vs-delete decision searches with: every + /// spelling, any ASCII case. A false "mentioned" only keeps an artifact. + pub fn keep(rel: &str) -> Self { + Self::new(rel, true) + } + + /// The path as the caller spelled it (for messages). + pub fn rel(&self) -> &str { + &self.raw + } + + /// Whether `text` names the path in any folded spelling. A `./`, `../`, + /// `file:` or `{root:uri}/` prefix is matched through, since the path + /// only has to occur somewhere in the text. + pub fn found_in(&self, text: &str) -> bool { + if text.contains(&self.raw) || self.folded.is_empty() { + return true; + } + let mut k = 0; + walk_folded(text, self.fold_case, |b| { + while k > 0 && b != self.folded[k] { + k = self.fail[k - 1]; + } + if b == self.folded[k] { + k += 1; + } + if k == self.folded.len() { + return true; + } + false + }) + } +} + +/// Whether `text` names the forward-slashed relative path `rel` in any +/// spelling, ASCII case-insensitively: [`PathNeedle::keep`] for a one-off +/// probe. Callers keep the artifact `rel` names while this is true. +pub fn mentions_path(text: &str, rel: &str) -> bool { + PathNeedle::keep(rel).found_in(text) +} + +/// `text` with every escape decoded and every run of `/` and `\` collapsed +/// to one `/` (case kept), borrowed when nothing changes. For a scan that +/// must slice references out of the text (the CLI's vendor-reference scan) +/// rather than ask about one path. +pub fn canonical_path_text(text: &str) -> Cow<'_, str> { + if !text.contains(['\\', '&']) && !text.contains("//") { + return Cow::Borrowed(text); + } + let mut out = Vec::with_capacity(text.len()); + walk_folded(text, false, |b| { + out.push(b); + false + }); + // Only whole UTF-8 sequences are ever emitted (copied runs of the input + // or encoded decoded chars), so this never falls back. + match String::from_utf8(out) { + Ok(s) => Cow::Owned(s), + Err(e) => Cow::Owned(String::from_utf8_lossy(e.as_bytes()).into_owned()), + } +} + +/// Feed `emit` the folded bytes of `text`: escapes decoded, every run of +/// `/` and `\` (raw or decoded) as one `/`, ASCII lower-cased when +/// `fold_case`. Stops, returning true, as soon as `emit` does. +fn walk_folded(text: &str, fold_case: bool, mut emit: impl FnMut(u8) -> bool) -> bool { + let bytes = text.as_bytes(); + let mut in_sep = false; + let mut i = 0; + while i < bytes.len() { + let b = bytes[i]; + let mut utf8 = [0u8; 4]; + let (chunk, used): (&[u8], usize) = match b { + b'\\' | b'&' => match decode_one_escape(&text[i..]) { + Some((ch, used)) => (ch.encode_utf8(&mut utf8).as_bytes(), used), + None => (&bytes[i..i + 1], 1), + }, + _ => (&bytes[i..i + 1], 1), + }; + i += used; + if matches!(chunk, [b'/'] | [b'\\']) { + if !in_sep { + in_sep = true; + if emit(b'/') { + return true; + } + } + continue; + } + in_sep = false; + for &c in chunk { + if emit(if fold_case { c.to_ascii_lowercase() } else { c }) { + return true; + } + } + } + false +} + +/// `text` with every escape decoded ([`decode_escapes`]) and every `\/`, +/// `\\` and `\` folded to `/` — discovery's identity-sweep fold. Separator +/// runs are deliberately kept (`https://` must stay a url); over-folding +/// only ever ADDS recognition. +pub fn fold_path_spellings(text: &str) -> Cow<'_, str> { + let decoded = decode_escapes(text); + // The folds only ever touch a backslash. + if decoded.contains('\\') { + Cow::Owned( + decoded + .replace("\\/", "/") + .replace("\\\\", "/") + .replace('\\', "/"), + ) + } else { + decoded + } +} + +/// Best-effort decode of `\uXXXX` / `\UXXXXXXXX` escapes and XML entities +/// (`/`, `/`, `&`, …) — over-decoding (an escaped backslash +/// before `u`) only ever ADDS recognition, which can only make a discovery +/// claim dead, never live, and only keeps a vendored artifact. +pub fn decode_escapes(text: &str) -> Cow<'_, str> { + if !text.contains("\\u") && !text.contains("\\U") && !text.contains('&') { + return Cow::Borrowed(text); + } + let mut out = String::with_capacity(text.len()); + let mut rest = text; + while let Some(i) = rest.find(['\\', '&']) { + out.push_str(&rest[..i]); + let tail = &rest[i..]; + match decode_one_escape(tail) { + Some((ch, used)) => { + out.push(ch); + rest = &tail[used..]; + } + None => { + // `\` and `&` are one byte each. + out.push_str(&tail[..1]); + rest = &tail[1..]; + } + } + } + out.push_str(rest); + Cow::Owned(out) +} + +/// The char one escape at the start of `tail` stands for, and its length. +fn decode_one_escape(tail: &str) -> Option<(char, usize)> { + let hex_char = |digits: &str| char::from_u32(u32::from_str_radix(digits, 16).ok()?); + if let Some(rest) = tail.strip_prefix("\\u") { + return Some((hex_char(rest.get(..4)?)?, 6)); + } + if let Some(rest) = tail.strip_prefix("\\U") { + return Some((hex_char(rest.get(..8)?)?, 10)); + } + if let Some(rest) = tail.strip_prefix("&#") { + let end = rest.get(..rest.len().min(10))?.find(';')?; + let body = &rest[..end]; + let ch = match body.strip_prefix(['x', 'X']) { + Some(hex) => hex_char(hex)?, + None => char::from_u32(body.parse().ok()?)?, + }; + return Some((ch, end + 3)); + } + [ + ("&", '&'), + (""", '"'), + ("'", '\''), + ("<", '<'), + (">", '>'), + ] + .into_iter() + .find(|(name, _)| tail.starts_with(name)) + .map(|(name, ch)| (ch, name.len())) +} + +#[cfg(test)] +mod tests { + use super::*; + + const REL: &str = ".socket/vendor/composer/11111111-2222-4333-8444-555555555555"; + + #[test] + fn mentions_path_matches_every_spelling() { + for text in [ + format!("\"url\": \"{REL}/m/m@1.0.0\""), + format!("\"url\": \"{}\\/m\\/m@1.0.0\"", REL.replace('/', "\\/")), + format!("value=\"{}\\m\"", REL.replace('/', "\\")), + format!("\"path\": \"{}\\\\m\"", REL.replace('/', "\\\\")), + format!("\"path\": \"./{REL}/m\""), + format!("\"version\": \"file:{REL}/m.tgz\""), + format!("\"url\": \"{}\"", REL.replace('/', "\\u002F")), + format!("{}", REL.replace('/', "/")), + format!("\"url\": \"{}\"", REL.to_ascii_uppercase()), + // Runs of any length: doubly-escaped JSON, `\\\/`, `//`. + format!("\"url\": \"{}\"", REL.replace('/', "\\\\\\\\")), + format!("\"url\": \"{}\"", REL.replace('/', "\\\\\\/")), + format!("\"url\": \"{}\"", REL.replace('/', "//")), + // An escape that decodes to a backslash is a separator too. + format!("\"url\": \"{}\"", REL.replace('/', "\\u005c")), + ] { + assert!(mentions_path(&text, REL), "{text}"); + assert!(PathNeedle::new(REL, false).found_in(&text) || text.contains("SOCKET")); + } + } + + /// A refusal needle keeps case: a differently-cased path on a + /// case-sensitive disk is another file. + #[test] + fn exact_case_needle_misses_another_case() { + let upper = format!("\"url\": \"{}\"", REL.to_ascii_uppercase()); + assert!(!PathNeedle::new(REL, false).found_in(&upper)); + assert!(PathNeedle::new(REL, true).found_in(&upper)); + let escaped = REL.replace('/', "\\/"); + assert!(PathNeedle::new(REL, false).found_in(&escaped)); + } + + /// The KMP fallback restarts correctly on a partial match. + #[test] + fn needle_found_after_a_partial_overlap() { + let needle = PathNeedle::new("a/a/b", false); + assert!(needle.found_in("a\\a\\a\\b")); + assert!(!needle.found_in("a\\a\\a\\c")); + } + + #[test] + fn canonical_text_collapses_runs_and_borrows_plain_text() { + assert!(matches!(canonical_path_text("a/b"), Cow::Borrowed("a/b"))); + assert_eq!(canonical_path_text("a\\\\\\/b//c\\u002Fd é"), "a/b/c/d é"); + } + + #[test] + fn mentions_path_misses_another_uuid() { + let other = ".socket/vendor/composer/99999999-2222-4333-8444-555555555555/m"; + for text in [ + other.to_string(), + other.replace('/', "\\/"), + other.replace('/', "\\"), + ] { + assert!(!mentions_path(&text, REL), "{text}"); + } + assert!(!mentions_path("", REL)); + } + + #[test] + fn fold_leaves_plain_text_borrowed() { + assert!(matches!( + fold_path_spellings("a/b/c"), + Cow::Borrowed("a/b/c") + )); + assert_eq!(fold_path_spellings("a\\/b\\\\c\\d"), "a/b/c/d"); + } +} diff --git a/crates/socket-patch-core/src/vendor/common.rs b/crates/socket-patch-core/src/vendor/common.rs index 7490ddf4d..45b3080b1 100644 --- a/crates/socket-patch-core/src/vendor/common.rs +++ b/crates/socket-patch-core/src/vendor/common.rs @@ -20,6 +20,7 @@ use crate::utils::fs::{ atomic_write_bytes_preserving_mode, first_symlink, open_regular_file_sync, read_regular_to_string, }; +use crate::utils::path_spelling::PathNeedle; use super::state::{VendorEntry, WiringAction, WiringRecord}; use super::{RevertOutcome, VendorOutcome, VendorServiceConfig, VendorWarning}; @@ -422,20 +423,51 @@ pub(crate) async fn prune_empty_vendor_levels(uuid_dir: &Path) { /// both safe (nothing a file still routes at is deleted) and live (the /// user can always undo the drift). A file that cannot be read (absent, /// unreadable, not a regular file) references nothing. +/// +/// Every spelling a file may give the path counts, in any case +/// ([`PathNeedle::keep`]): an older composer.lock's `\/`-escaped slashes, a +/// Windows backslashed path, `\u` / XML-entity escapes. A plain text match +/// misses those and would delete an artifact the lock still installs from. pub(crate) async fn any_live_file_references( root: &Path, files: &[&str], uuid_dir_rel: &str, ) -> bool { + first_file_naming(root, files.iter().copied(), &PathNeedle::keep(uuid_dir_rel)) + .await + .is_some() +} + +/// The first of `files` (root-relative) that is a readable regular file +/// naming `needle` in any of its spellings. An absent or unreadable file +/// names nothing: callers that must fail closed on an unreadable file read +/// it themselves. +pub(crate) async fn first_file_naming<'a>( + root: &Path, + files: impl IntoIterator, + needle: &PathNeedle, +) -> Option<&'a str> { for file in files { if read_regular_to_string(&root.join(file)) .await - .is_ok_and(|live| live.contains(uuid_dir_rel)) + .is_ok_and(|live| needle.found_in(&live)) { - return true; + return Some(file); } } - false + None +} + +/// The needle a REFUSAL whose miss is safe searches project files with for +/// `rel` (lifting another refusal, a report): every escape and separator +/// spelling, but ASCII case folded only where the project's file system +/// folds it (probed on its `.socket/` dir). Any check whose miss lets an +/// artifact be deleted uses the broad [`PathNeedle::keep`] instead: a +/// committed file is installed on case-folding disks too. +pub(crate) async fn refusal_needle(project_root: &Path, rel: &str) -> PathNeedle { + let folds = + crate::utils::fs::folds_case(&project_root.join(crate::constants::SOCKET_DIR)).await; + PathNeedle::new(rel, folds) } /// The repair remedy for wiring an earlier socket-patch vendoring left in a diff --git a/crates/socket-patch-core/src/vendor/composer_lock.rs b/crates/socket-patch-core/src/vendor/composer_lock.rs index 5a23aa60d..8298bc6a5 100644 --- a/crates/socket-patch-core/src/vendor/composer_lock.rs +++ b/crates/socket-patch-core/src/vendor/composer_lock.rs @@ -1944,6 +1944,59 @@ mod tests { ); } + /// A drifted entry in a `\/`-escaping lock that still routes composer + /// at the artifact keeps it: the keep-check matches the escaped + /// spelling, so the revert never deletes a dir the lock installs from. + #[tokio::test] + async fn drifted_escaped_slash_lock_still_naming_the_artifact_keeps_it() { + let lock = lock_value("psr/log", "3.0.2", false); + let (dir, blobs, installed, record) = fixture(&lock).await; + let root = dir.path(); + let text = String::from_utf8(composer_json_bytes(&lock).unwrap()).unwrap(); + tokio::fs::write(root.join(COMPOSER_LOCK), text.replace('/', "\\/")) + .await + .unwrap(); + let (result, entry, _w) = + unwrap_done(run_vendor(root, &blobs, &installed, &record, PURL, false).await); + assert!(result.success, "{:?}", result.error); + let vendored = tokio::fs::read_to_string(root.join(COMPOSER_LOCK)) + .await + .unwrap(); + assert!(vendored.contains(&format!(".socket\\/vendor\\/composer\\/{UUID}\\/"))); + + // A hand edit put the registry dist back (drift: the wired dist is + // gone) but moved the vendored path into `source`, which a + // `--prefer-source` install still reads. + let mut wired: Value = serde_json::from_str(&vendored).unwrap(); + let url = wired["packages"][0]["dist"]["url"].clone(); + wired["packages"][0]["dist"] = lock["packages"][0]["dist"].clone(); + wired["packages"][0]["source"] = serde_json::json!({"type": "path", "url": url}); + let drifted = String::from_utf8(composer_json_bytes(&wired).unwrap()) + .unwrap() + .replace('/', "\\/"); + assert!(!drifted.contains(&format!("/vendor/composer/{UUID}"))); + tokio::fs::write(root.join(COMPOSER_LOCK), &drifted) + .await + .unwrap(); + + let outcome = revert_composer(&entry.unwrap(), root, false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!( + outcome + .warnings + .iter() + .any(|w| w.code == "vendor_lock_entry_drifted"), + "{:?}", + outcome.warnings + ); + assert!(outcome.kept_artifact, "{:?}", outcome.warnings); + assert!( + root.join(format!(".socket/vendor/composer/{UUID}")) + .is_dir(), + "the escaped lock still installs from the artifact" + ); + } + /// A lock mixing CRLF and LF keeps every line outside the vendored /// entry as it was, and reverts byte-identically. #[tokio::test] diff --git a/crates/socket-patch-core/src/vendor/npm_flavor.rs b/crates/socket-patch-core/src/vendor/npm_flavor.rs index 06355d051..e3fa6644e 100644 --- a/crates/socket-patch-core/src/vendor/npm_flavor.rs +++ b/crates/socket-patch-core/src/vendor/npm_flavor.rs @@ -786,16 +786,24 @@ pub async fn lock_text_refusals( /// rule): npm 12 installs from package-lock.json beside a committed /// npm-shrinkwrap.json, so a mention in either lock can be the one an /// install resolves through. +/// +/// `Some(false)` lets a revert delete the artifact, so every spelling of +/// the path counts ([`crate::utils::path_spelling::PathNeedle::keep`]): `\/` JSON escapes, backslashed +/// Windows separators, `\u` escapes, and any ASCII case. A committed lock +/// is installed on case-folding disks (macOS, Windows) too, where a +/// re-cased path does resolve to this artifact, so a case-sensitive CI +/// disk must not delete it. pub(super) async fn lock_text_mentions_uuid( project_root: &Path, names: &[&str], uuid: &str, ) -> Option { - let needle = format!(".socket/vendor/npm/{uuid}/"); + let needle = + crate::utils::path_spelling::PathNeedle::keep(&format!(".socket/vendor/npm/{uuid}/")); let mut any_readable = false; for name in names { if let Ok(text) = read_regular_to_string(&project_root.join(name)).await { - if text.contains(&needle) { + if needle.found_in(&text) { return Some(true); } any_readable = true; @@ -1187,6 +1195,70 @@ mod tests { use super::*; use crate::hash::git_sha256::compute_git_sha256_from_bytes; + /// The post-restore and unwired-revert gates delete the artifact on + /// `Some(false)`, so a `\/`-escaped or backslashed spelling of the uuid + /// dir must read as a mention; another uuid's dir must not. + #[tokio::test] + async fn lock_text_mention_matches_every_spelling_of_the_uuid_dir() { + let uuid = "44444444-4444-4444-8444-444444444444"; + let path = format!(".socket/vendor/npm/{uuid}/left-pad-1.3.0.tgz"); + for (text, want) in [ + (format!("\"resolved\": \"file:{path}\""), true), + ( + format!("\"resolved\": \"file:{}\"", path.replace('/', "\\/")), + true, + ), + ( + format!("\"resolved\": \"file:{}\"", path.replace('/', "\\")), + true, + ), + ( + format!("\"resolved\": \"file:{}\"", path.replace('/', "\\\\")), + true, + ), + ( + format!( + "\"resolved\": \"{}\"", + path.replace(uuid, "55555555-4444-4444-8444-444444444444") + ), + false, + ), + ] { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("package-lock.json"), &text).unwrap(); + assert_eq!( + lock_text_mentions_uuid(dir.path(), &["package-lock.json"], uuid).await, + Some(want), + "{text}" + ); + } + } + + /// Review on #2022 (round 2): `Some(false)` lets the revert delete the + /// artifact, and a committed lock is installed on case-folding disks + /// too, so a re-cased reference counts on every disk. + #[tokio::test] + async fn lock_text_mention_folds_case_on_every_disk() { + let uuid = "44444444-4444-4444-8444-444444444444"; + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir(dir.path().join(".socket")).unwrap(); + let folds = crate::utils::fs::folds_case(&dir.path().join(".socket")).await; + let recased = format!( + ".Socket/Vendor/NPM/{}/left-pad-1.3.0.tgz", + uuid.to_ascii_uppercase() + ); + std::fs::write( + dir.path().join("package-lock.json"), + format!("\"resolved\": \"file:{recased}\""), + ) + .unwrap(); + let _ = folds; + assert_eq!( + lock_text_mentions_uuid(dir.path(), &["package-lock.json"], uuid).await, + Some(true) + ); + } + #[test] fn project_root_location_reads_as_a_place() { assert_eq!(project_root_location(Path::new(".")), "in the project root"); diff --git a/crates/socket-patch-core/src/vendor/npm_lock.rs b/crates/socket-patch-core/src/vendor/npm_lock.rs index c20288900..e0b704455 100644 --- a/crates/socket-patch-core/src/vendor/npm_lock.rs +++ b/crates/socket-patch-core/src/vendor/npm_lock.rs @@ -646,9 +646,11 @@ pub(crate) async fn preflight_packages( /// them — it cannot un-wire the lock — so removing the artifact while the /// lockfile still resolves through it bricks every subsequent install /// (ENOENT on the missing `file:` tarball). -/// The probe is textual and EXACT for these flavors (the uuid dir path -/// appears iff some resolution still points at the artifact), over every -/// lock in +/// The probe is textual for these flavors (the uuid dir path appears iff +/// some resolution still points at the artifact). It matches every spelling +/// of that path — `\/` JSON escapes, backslash separators, `\u` escapes — +/// and folds ASCII case only on a case-insensitive file system +/// ([`super::npm_flavor::lock_text_mentions_uuid`]), over every lock in /// `lock_names` (a mention in any of them counts — npm 12 installs from the /// package-lock.json beside a shrinkwrap). Mentioned ⇒ /// refuse; readable and provably absent ⇒ `None`, the caller's removal diff --git a/crates/socket-patch-core/src/vendor/nuget_feed.rs b/crates/socket-patch-core/src/vendor/nuget_feed.rs index b6252a5d1..fe95f7bf0 100644 --- a/crates/socket-patch-core/src/vendor/nuget_feed.rs +++ b/crates/socket-patch-core/src/vendor/nuget_feed.rs @@ -1473,13 +1473,14 @@ async fn revert_config_record( /// Whether the project-root config `file` (a recorded wiring basename) /// still names the vendored feed dir `uuid_dir_rel`. An unsafe or /// unreadable file answers yes: when in doubt the lock pin is kept with -/// the config that may route to it. +/// the config that may route to it. A backslashed or entity-escaped +/// spelling counts ([`crate::utils::path_spelling::mentions_path`]). async fn config_references(project_root: &Path, file: &str, uuid_dir_rel: &str) -> bool { if !is_safe_single_segment(file) { return true; } match read_regular_to_string(&project_root.join(file)).await { - Ok(text) => text.contains(uuid_dir_rel), + Ok(text) => crate::utils::path_spelling::mentions_path(&text, uuid_dir_rel), Err(e) => e.kind() != std::io::ErrorKind::NotFound, } } diff --git a/crates/socket-patch-core/src/vendor/pypi.rs b/crates/socket-patch-core/src/vendor/pypi.rs index 597720df8..00dd70d7e 100644 --- a/crates/socket-patch-core/src/vendor/pypi.rs +++ b/crates/socket-patch-core/src/vendor/pypi.rs @@ -18,6 +18,7 @@ use crate::manifest::schema::PatchRecord; use crate::patch::apply::{ApplyResult, PatchSources}; use crate::utils::fs::{atomic_write_artifact, read_regular_to_bytes, read_regular_to_string}; use crate::utils::group_commit::{self, GroupCommit}; +use crate::utils::path_spelling::PathNeedle; use crate::utils::purl::{parse_pypi_purl, strip_purl_qualifiers}; use crate::utils::socket_dir::remove_tree_and_prune; use crate::utils::toml_edit_ext::has_table; @@ -2722,16 +2723,14 @@ async fn superseded_entry( return None; } let files = superseded_files(&prev, flavor)?; - let needle = format!(".socket/vendor/pypi/{}/", prev.uuid); - for file in &files { - if read_regular_to_string(&project_root.join(file)) - .await - .is_ok_and(|text| text.contains(&needle)) - { - return Some(prev); - } - } - None + // Lifts a refusal, so case is folded only where the disk folds it. + let needle = + super::common::refusal_needle(project_root, &format!(".socket/vendor/pypi/{}/", prev.uuid)) + .await; + super::common::first_file_naming(project_root, files.iter().map(String::as_str), &needle) + .await + .is_some() + .then_some(prev) } /// Every project file a superseded entry's revert may write: the files its @@ -2871,17 +2870,17 @@ async fn unwire_superseded( }, ) .await; - let needle = format!(".socket/vendor/pypi/{}/", prev.uuid); - let mut residual = None; - for (file, _) in &snapshot { - if read_regular_to_string(&project_root.join(file)) - .await - .is_ok_and(|text| text.contains(&needle)) - { - residual = Some(file.clone()); - break; - } - } + // A residual reference in any spelling (`\/`-escaped, backslashed, any + // case) must refuse: past this point the old uuid dir is swept as + // stale, and a committed file is installed on case-folding disks too. + let needle = PathNeedle::keep(&format!(".socket/vendor/pypi/{}/", prev.uuid)); + let residual = super::common::first_file_naming( + project_root, + snapshot.iter().map(|(file, _)| file.as_str()), + &needle, + ) + .await; + let needle = needle.rel(); let why = if !reverted.success { Some( reverted @@ -3018,6 +3017,7 @@ async fn guard_unwired_pypi_revert( uuid: &str, uuid_dir_rel: &str, ) -> Option { + // A miss deletes the artifact: every spelling in any case counts. let clause = pypi_reference_clause(project_root, uuid, &[]).await?; let detail = format!( "refusing to remove {uuid_dir_rel}: the ledger entry records no pre-vendor wiring to \ @@ -3046,7 +3046,7 @@ async fn guard_unwired_pypi_revert( /// it. `vendor --check` reports it for an entry nothing installs from any /// more, so the verdict doesn't claim no file references the dir (#1374). pub async fn pypi_reference(project_root: &Path, uuid: &str) -> Option { - pypi_reference_clause(project_root, uuid, &[]).await + pypi_refusal_clause(project_root, uuid).await } /// The in-use probe behind [`guard_unwired_pypi_revert`] and the @@ -3067,9 +3067,28 @@ pub async fn pypi_reference(project_root: &Path, uuid: &str) -> Option { /// that cannot be read, or a listed file (a symlink included — lstat only, /// so an unreadable target is still probed) that exists but cannot be read /// all block the deletion, because none of them can prove the absence of a -/// reference. +/// reference. The mention matches every spelling of the uuid dir in any +/// case ([`PathNeedle::keep`]): this form gates the post-restore KEEP. async fn pypi_reference_clause(project_root: &Path, uuid: &str, skip: &[&str]) -> Option { - let needle = format!(".socket/vendor/pypi/{uuid}/"); + let needle = PathNeedle::keep(&format!(".socket/vendor/pypi/{uuid}/")); + pypi_reference_clause_for(project_root, &needle, skip).await +} + +/// [`pypi_reference_clause`] for a report (`vendor --check`): every +/// spelling, but case folded only where the disk folds it +/// ([`super::common::refusal_needle`]). Never a deletion gate. +async fn pypi_refusal_clause(project_root: &Path, uuid: &str) -> Option { + let needle = + super::common::refusal_needle(project_root, &format!(".socket/vendor/pypi/{uuid}/")).await; + pypi_reference_clause_for(project_root, &needle, &[]).await +} + +/// The probe behind [`pypi_reference_clause`] and [`pypi_refusal_clause`]. +async fn pypi_reference_clause_for( + project_root: &Path, + needle: &PathNeedle, + skip: &[&str], +) -> Option { let mut names: Vec = [ "pyproject.toml", "hatch.toml", @@ -3159,7 +3178,9 @@ async fn pypi_reference_clause(project_root: &Path, uuid: &str, skip: &[&str]) - // UTF-16 `pip freeze >` output is still probed instead of failing // closed on every revert with no way out. match read_regular_to_bytes(&path).await { - Ok(bytes) if probe_text(&bytes).contains(&needle) => { + // Any spelling of the path counts (a backslashed Windows + // requirement line, a `\/`-escaped Pipfile.lock). + Ok(bytes) if needle.found_in(&probe_text(&bytes)) => { return Some(format!("{name} still resolves through it")); } Ok(_) => {} @@ -7019,6 +7040,35 @@ wheels = [ } } + /// Review on #2022 (round 2): the unwired-revert guard's miss deletes + /// the wheel, and a committed lock is installed on case-folding disks + /// too, so a re-cased reference refuses on every disk. + #[tokio::test] + async fn unwired_python_entry_revert_refuses_on_a_recased_reference() { + let rel_wheel = format!(".socket/vendor/pypi/{UUID}/six-1.16.0-py2.py3-none-any.whl"); + let recased = format!( + ".Socket/Vendor/PyPI/{}/six-1.16.0-py2.py3-none-any.whl", + UUID.to_ascii_uppercase() + ); + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + tokio::fs::write( + root.join("pylock.toml"), + format!("lock-version = \"1.0\"\n\n[[packages]]\nname = \"six\"\nversion = \"1.16.0\"\narchive = {{ path = \"{recased}\" }}\n"), + ) + .await + .unwrap(); + let wheel = root.join(&rel_wheel); + tokio::fs::create_dir_all(wheel.parent().unwrap()) + .await + .unwrap(); + tokio::fs::write(&wheel, b"wheel bytes").await.unwrap(); + let entry = revert_entry("python-lock", &rel_wheel, Vec::new()); + let outcome = revert_pypi(&entry, root, false).await; + assert!(!outcome.success, "{outcome:?}"); + assert!(wheel.is_file(), "the artifact must survive"); + } + /// Nothing references the artifact any more (the user re-locked by /// hand): an unwired entry's revert is a plain orphan cleanup and may /// proceed — the guard is about live references, not about wiring. @@ -10795,6 +10845,57 @@ wheels = [{url = "https://files.pythonhosted.org/six.whl", hash = "sha256:upstre } } + /// Review on #2022: after unwinding the older uuid's wiring, a residual + /// reference to its dir in a backslashed or `\/`-escaped spelling still + /// refuses the re-vendor. Proceeding would let `vendor` sweep the old + /// uuid dir as stale while that line still installs from it. + #[tokio::test] + async fn superseding_uuid_refuses_over_an_escaped_residual_reference() { + let mut residual_refusals = 0; + for (flavor, files) in superseding_flavors() { + for spell in ["\\", "\\/", "\\\\"] { + let fx = e2e_fixture().await; + swap_to_lock_flavor(&fx, &files).await; + let sources = PatchSources::blobs_only(&fx.blobs); + let VendorOutcome::Done { result, entry, .. } = + vendor_six(&fx, &sources, None).await + else { + panic!("{flavor}: first vendor must be Done"); + }; + assert!(result.success, "{flavor}: {:?}", result.error); + let first = entry.expect("entry on success"); + save_ledger_entry(&fx.root, &first).await; + // A hand-added comment naming the old uuid dir, outside + // anything the wiring recorded. + let (name, _) = files[0]; + let wired = tokio::fs::read_to_string(fx.root.join(name)).await.unwrap(); + let residual = format!(".socket/vendor/pypi/{UUID}/six.whl").replace('/', spell); + touch(&fx.root, name, &format!("{wired}# mirror: {residual}\n")).await; + + let outcome = vendor_six_as_opts(&fx, &sources, SUPERSEDING_UUID, false).await; + let error = match &outcome { + VendorOutcome::Refused { detail, .. } => detail.clone(), + VendorOutcome::Done { result, .. } if !result.success => { + result.error.clone().unwrap_or_default() + } + other => panic!("{flavor} {spell:?}: expected a refusal, got {other:?}"), + }; + // A flavor whose revert reads the added comment as drift + // refuses for that reason instead; both keep the old dir. + assert!( + error.contains("still references") || error.contains("changed since"), + "{flavor} {spell:?}: {error}" + ); + residual_refusals += usize::from(error.contains("still references")); + assert!(fx.root.join(&first.artifact.path).is_file(), "{flavor}"); + } + } + assert!( + residual_refusals > 0, + "the residual-reference refusal itself must be exercised" + ); + } + /// A snapshot file that cannot be written back is named in the failure /// being reported, and the files after it are still restored. #[tokio::test] diff --git a/crates/socket-patch-core/src/vendor/pypi_hatch.rs b/crates/socket-patch-core/src/vendor/pypi_hatch.rs index 672381616..a2068ec27 100644 --- a/crates/socket-patch-core/src/vendor/pypi_hatch.rs +++ b/crates/socket-patch-core/src/vendor/pypi_hatch.rs @@ -329,6 +329,12 @@ pub(super) async fn revert(entry: &VendorEntry, root: &Path, dry_run: bool) -> R Err((_, error)) => return RevertOutcome::failed(error), }; let mut edits = BTreeMap::new(); + // A leftover reference FAILS this revert, and a miss lets the wheel be + // deleted: every escape, separator and case spelling counts, as in the + // pylock revert. + let needle = crate::utils::path_spelling::PathNeedle::keep( + &super::pypi_lock::artifact_needle_rel(&entry.uuid), + ); for record in entry .wiring .iter() @@ -347,11 +353,7 @@ pub(super) async fn revert(entry: &VendorEntry, root: &Path, dry_run: bool) -> R }; match super::pypi_lock::restore_document(live, original, new) { Ok((restored, false)) - if !super::pypi_lock::still_references_artifact( - &restored, - original, - &entry.uuid, - ) => + if !super::pypi_lock::still_references_artifact(&restored, original, &needle) => { edits.insert(record.file.clone(), restored); } diff --git a/crates/socket-patch-core/src/vendor/pypi_lock.rs b/crates/socket-patch-core/src/vendor/pypi_lock.rs index 726314b21..bbcadc256 100644 --- a/crates/socket-patch-core/src/vendor/pypi_lock.rs +++ b/crates/socket-patch-core/src/vendor/pypi_lock.rs @@ -1080,10 +1080,23 @@ fn allowed_file(file: &str, kind: &str) -> bool { /// directory although `original` did not. The merge keeps elements it did /// not record (a user's added sibling), and one of those may be a copy of /// the vendored requirement: reporting the revert complete would delete a -/// wheel that line still installs from. -pub(super) fn still_references_artifact(restored: &str, original: &str, uuid: &str) -> bool { - let needle = format!("vendor/pypi/{uuid}"); - restored.contains(&needle) && !original.contains(&needle) +/// wheel that line still installs from. `needle` is +/// [`artifact_needle_rel`]'s path, matched in every spelling; its case rule is +/// the caller's (a keep folds case, a refusal folds it only where the disk +/// does). +pub(super) fn still_references_artifact( + restored: &str, + original: &str, + needle: &crate::utils::path_spelling::PathNeedle, +) -> bool { + needle.found_in(restored) && !needle.found_in(original) +} + +/// The uuid-dir path [`still_references_artifact`] looks for (the +/// `{root:uri}/` / `file:////` prefix a pyproject spells varies, so +/// it starts below `.socket/`). +pub(super) fn artifact_needle_rel(uuid: &str) -> String { + format!("vendor/pypi/{uuid}") } /// Whether `name` (PEP 503) left the live lock `text` altogether: no unit @@ -1520,7 +1533,11 @@ pub(super) async fn revert_python_locks( record.file ), )); - } else if still_references_artifact(&restored, original, &entry.uuid) { + } else if still_references_artifact( + &restored, + original, + &crate::utils::path_spelling::PathNeedle::keep(&artifact_needle_rel(&entry.uuid)), + ) { warnings.push(VendorWarning::new( "vendor_lock_entry_drifted", format!( @@ -1586,6 +1603,7 @@ pub(super) async fn revert_python_locks( #[cfg(test)] mod tests { use super::*; + use crate::utils::path_spelling::PathNeedle; const LOCK: &str = "# original\nlock-version = \"1.0\"\n\n[[packages]]\nname = \"one\"\nversion = \"1\"\nwheels = [{url = \"https://example.test/one.whl\", hashes = {sha256 = \"one\"}}]\n\n[[packages]]\nname = \"two\"\nversion = \"2\"\nwheels = [{url = \"https://example.test/two.whl\", hashes = {sha256 = \"two\"}}]\n"; @@ -2290,10 +2308,38 @@ mod tests { let live = "dependencies = [\"six @ file:///p/.socket/vendor/pypi/u/six.whl ; python_version >= '3.8'\", \"six @ file:///p/.socket/vendor/pypi/u/six.whl\"]\n"; let (restored, drifted) = restore_document(live, original, new).unwrap(); assert!(!drifted, "{restored}"); - assert!(still_references_artifact(&restored, original, "u")); - assert!(!still_references_artifact(original, original, "u")); + let needle = |uuid: &str| PathNeedle::keep(&artifact_needle_rel(uuid)); + assert!(still_references_artifact(&restored, original, &needle("u"))); + assert!(!still_references_artifact(original, original, &needle("u"))); // Another patch's artifact (a stacked vendoring) is not this one's. - assert!(!still_references_artifact(&restored, original, "v")); + assert!(!still_references_artifact( + &restored, + original, + &needle("v") + )); + } + + /// A leftover copy spelled with backslashes (a Windows checkout) or + /// `\/` escapes is still the artifact: the revert must not report itself + /// complete and delete the wheel that line installs from. + #[test] + fn leftover_artifact_reference_is_detected_in_any_spelling() { + let uuid = "11111111-2222-4333-8444-555555555555"; + let original = "dependencies = [\"six==1.16.0\"]\n"; + let needle = PathNeedle::keep(&artifact_needle_rel(uuid)); + for leftover in [ + format!("dependencies = [\"six @ file:///C:\\\\p\\\\.socket\\\\vendor\\\\pypi\\\\{uuid}\\\\six.whl\"]\n"), + format!("dependencies = [\"six @ file:///p/.socket\\/vendor\\/pypi\\/{uuid}\\/six.whl\"]\n"), + format!("dependencies = [\"six @ file:///p/.socket\\vendor\\pypi\\{uuid}\\six.whl\"]\n"), + ] { + assert!(still_references_artifact(&leftover, original, &needle), "{leftover}"); + } + let other = PathNeedle::keep(&artifact_needle_rel("99999999-2222-4333-8444-555555555555")); + let leftover = format!( + "x = \"{}\"", + format!(".socket/vendor/pypi/{uuid}/six.whl").replace('/', "\\\\") + ); + assert!(!still_references_artifact(&leftover, original, &other)); } /// Appending to (or prepending to) a recorded array of strings keeps the diff --git a/crates/socket-patch-core/src/vendor/pypi_requirements.rs b/crates/socket-patch-core/src/vendor/pypi_requirements.rs index 490ef46ec..b364668f1 100644 --- a/crates/socket-patch-core/src/vendor/pypi_requirements.rs +++ b/crates/socket-patch-core/src/vendor/pypi_requirements.rs @@ -923,9 +923,11 @@ pub(super) async fn revert_requirements( // Residual-reference sweep over the reverted contents: a leftover line // pointing at the (about to be deleted) uuid dir would break installs. + // The warning KEEPS the artifact, so every spelling in any case counts. let needle = format!(".socket/vendor/pypi/{}", entry.uuid); + let probe = crate::utils::path_spelling::PathNeedle::keep(&needle); for (file, content) in &reverted { - if content.contains(&needle) { + if probe.found_in(content) { warnings.push(VendorWarning::new( "vendor_revert_residual_reference", format!("{file} still references {needle} after revert"), diff --git a/crates/socket-patch-core/src/vendor/pypi_uv.rs b/crates/socket-patch-core/src/vendor/pypi_uv.rs index c00b33934..ec969a3ff 100644 --- a/crates/socket-patch-core/src/vendor/pypi_uv.rs +++ b/crates/socket-patch-core/src/vendor/pypi_uv.rs @@ -886,8 +886,10 @@ pub(super) async fn revert_uv(entry: &VendorEntry, root: &Path, dry_run: bool) - .unwrap_or(false); // Every artifact-routing fragment embeds the uuid dir path — the // ALREADY-CONVERGED probes below key on it (see the LIVENESS CONTRACT - // on `RevertOutcome::drift_skipped`). + // on `RevertOutcome::drift_skipped`). A surviving reference keeps the + // artifact (drift), so `probe` matches every spelling in any case. let needle = format!(".socket/vendor/pypi/{}", entry.uuid); + let probe = crate::utils::path_spelling::PathNeedle::keep(&needle); // REMOVED, not drift (#1140): `uv remove ` drops the dependency // together with every fragment that routed through the wheel. When // neither file names this entry's uuid any more, a record whose written @@ -992,7 +994,7 @@ pub(super) async fn revert_uv(entry: &VendorEntry, root: &Path, dry_run: bool) - // root `requires-dist` can equal the user's overrides) would // otherwise splice the wrong array or fake convergence. "uv_lock_requires_dev" | "uv_lock_manifest_constraints" => { - match revert_array_elements(&lock_text, &rec.kind, new_text, original_text, &needle) + match revert_array_elements(&lock_text, &rec.kind, new_text, original_text, &probe) { ArrayRevert::Reverted(t) => lock_text = t, ArrayRevert::Converged => {} @@ -1051,7 +1053,7 @@ pub(super) async fn revert_uv(entry: &VendorEntry, root: &Path, dry_run: bool) - // uuid dir anywhere in the lock, satisfies it. // A reshaped fragment that still routes through // the artifact stays drift (fail-closed). - if !lock_text.contains(new) && !lock_text.contains(&needle) { + if !lock_text.contains(new) && !probe.found_in(&lock_text) { continue; } warnings.push(drifted("uv.lock")); @@ -1067,7 +1069,7 @@ pub(super) async fn revert_uv(entry: &VendorEntry, root: &Path, dry_run: bool) - &rec.kind, new_text, original_text, - &needle, + &probe, ) { ArrayRevert::Reverted(t) => lock_text = t, ArrayRevert::Converged => {} @@ -1095,7 +1097,7 @@ pub(super) async fn revert_uv(entry: &VendorEntry, root: &Path, dry_run: bool) - // longer referencing this entry's uuid dir satisfies // it (a hand-restored pyproject). A reshaped line // still routing through the artifact stays drift. - if !pyproject_text.contains(&needle) { + if !probe.found_in(&pyproject_text) { continue; } warnings.push(drifted("pyproject.toml")); @@ -1185,10 +1187,7 @@ pub(super) async fn revert_uv(entry: &VendorEntry, root: &Path, dry_run: bool) - // Never write a half-reverted pair: a lock that still routes through // the uuid dir after pyproject.toml stopped declaring any route to it // is what `uv sync --locked` rejects. Keep both files instead. - if !drift_kept - && lock_text.to_ascii_lowercase().contains(&needle_lower) - && !pyproject_text.to_ascii_lowercase().contains(&needle_lower) - { + if !drift_kept && probe.found_in(&lock_text) && !probe.found_in(&pyproject_text) { drift_kept = true; warnings.push(VendorWarning::new( "vendor_lock_entry_drifted", @@ -1467,7 +1466,7 @@ fn revert_array_elements( kind: &str, new: Option<&str>, orig: Option<&str>, - needle: &str, + needle: &crate::utils::path_spelling::PathNeedle, ) -> ArrayRevert { let (Some(new), Some(orig)) = (new, orig) else { return ArrayRevert::Drift; @@ -1510,7 +1509,7 @@ fn revert_array_elements( } else if new_els.len() == old_els.len() + 1 && new_els[..old_els.len()] == old_els[..] { edits.push((new_els[old_els.len()].clone(), None)); } - if edits.is_empty() || edits.iter().any(|(ours, _)| !ours.contains(needle)) { + if edits.is_empty() || edits.iter().any(|(ours, _)| !needle.found_in(ours)) { return ArrayRevert::Drift; } @@ -1542,7 +1541,7 @@ fn revert_array_elements( } // Our element was edited in place and still routes through the // artifact: drift, fail-closed. - None if live.iter().any(|el| el.contains(needle)) => return ArrayRevert::Drift, + None if live.iter().any(|el| needle.found_in(el)) => return ArrayRevert::Drift, // Our element is gone and nothing else in the array routes // through the artifact: that element's reverted state holds. None => {} diff --git a/crates/socket-patch-core/src/vendor/revert.rs b/crates/socket-patch-core/src/vendor/revert.rs index dd6c3de8a..2c0d93d13 100644 --- a/crates/socket-patch-core/src/vendor/revert.rs +++ b/crates/socket-patch-core/src/vendor/revert.rs @@ -235,6 +235,47 @@ mod tests { assert_eq!(outcome.warnings.len(), 1, "the drift warning is kept"); } + /// The keep-check reads every spelling a lock may give the uuid dir: an + /// older composer.lock's `\/`-escaped JSON slashes, single and doubled + /// Windows backslashes, `./` / `file:` prefixes. A plain text match + /// missed them and deleted an artifact the lock still installs from. + #[tokio::test] + async fn drift_keeps_while_a_live_file_names_the_uuid_dir_in_any_spelling() { + let policy = KeepPolicy::OnDriftWhileReferenced(&["composer.lock"]); + let rel = rel("composer"); + let spellings = [ + format!( + "{{\"dist\": {{\"type\": \"path\", \"url\": \"{}\\/psr\\/log@3.0.2\"}}}}", + rel.replace('/', "\\/") + ), + format!("{{\"url\": \"{}\\psr\"}}", rel.replace('/', "\\")), + format!("{{\"url\": \"{}\\\\psr\"}}", rel.replace('/', "\\\\")), + format!("{{\"url\": \"./{rel}/psr\"}}"), + format!("{{\"url\": \"file:{rel}/psr\"}}"), + ]; + for lock in spellings { + let dir = project("composer"); + std::fs::write(dir.path().join("composer.lock"), &lock).unwrap(); + let outcome = finish(drifted(), dir.path(), &rel, wet(), policy).await; + assert!(outcome.success && outcome.kept_artifact, "{lock}"); + assert!(dir.path().join(&rel).is_dir(), "{lock}"); + } + + // Another uuid's dir, in any spelling, is no reference to this one. + let other = rel.replace(UUID, "99999999-2222-4333-8444-555555555555"); + for lock in [ + format!("{{\"url\": \"{}\\/psr\"}}", other.replace('/', "\\/")), + format!("{{\"url\": \"{}\\psr\"}}", other.replace('/', "\\")), + format!("{{\"url\": \"{other}/psr\"}}"), + ] { + let dir = project("composer"); + std::fs::write(dir.path().join("composer.lock"), &lock).unwrap(); + let outcome = finish(drifted(), dir.path(), &rel, wet(), policy).await; + assert!(outcome.success && !outcome.kept_artifact, "{lock}"); + assert!(!dir.path().join(&rel).exists(), "{lock}"); + } + } + #[tokio::test] async fn npm_family_keeps_a_removed_entry_while_a_lock_names_the_uuid() { let policy = policies()[2]; diff --git a/crates/socket-patch-core/src/vex/discover/mod.rs b/crates/socket-patch-core/src/vex/discover/mod.rs index 815d4644d..5aa3aa549 100644 --- a/crates/socket-patch-core/src/vex/discover/mod.rs +++ b/crates/socket-patch-core/src/vex/discover/mod.rs @@ -2057,21 +2057,13 @@ pub(crate) fn hosted_uuids_in_text(text: &str, origins: &[String]) -> BTreeSet BTreeSet<(String, WiringMode)> { use aho_corasick::AhoCorasick; - use std::borrow::Cow; let mut found = BTreeSet::new(); - let decoded = decode_escapes(text); - // The folds only ever touch a backslash. - let norm: Cow<'_, str> = if decoded.contains('\\') { - Cow::Owned( - decoded - .replace("\\/", "/") - .replace("\\\\", "/") - .replace('\\', "/"), - ) - } else { - decoded - }; + // The escape fold the vendored revert's keep-checks share + // ([`crate::utils::path_spelling::mentions_path`] also collapses + // separator runs and ignores case; this sweep keeps both as written, + // since its hosted-url recognition anchors on `://`). + let norm = crate::utils::path_spelling::fold_path_spellings(text); let bytes = norm.as_bytes(); // Every anchor in one pass. None of them overlaps itself, so each @@ -2150,65 +2142,6 @@ fn socket_identities_inner(text: &str, origins: &[String]) -> BTreeSet<(String, found } -/// Best-effort decode of `\uXXXX` / `\UXXXXXXXX` escapes and XML entities -/// (`/`, `/`, `&`, …) for [`socket_identities`] — over-decoding -/// (an escaped backslash before `u`) only ever ADDS recognition, which can -/// only make a claim dead, never live. -fn decode_escapes(text: &str) -> std::borrow::Cow<'_, str> { - if !text.contains("\\u") && !text.contains("\\U") && !text.contains('&') { - return std::borrow::Cow::Borrowed(text); - } - let mut out = String::with_capacity(text.len()); - let mut rest = text; - while let Some(i) = rest.find(['\\', '&']) { - out.push_str(&rest[..i]); - let tail = &rest[i..]; - match decode_one_escape(tail) { - Some((ch, used)) => { - out.push(ch); - rest = &tail[used..]; - } - None => { - // `\` and `&` are one byte each. - out.push_str(&tail[..1]); - rest = &tail[1..]; - } - } - } - out.push_str(rest); - std::borrow::Cow::Owned(out) -} - -/// The char one escape at the start of `tail` stands for, and its length. -fn decode_one_escape(tail: &str) -> Option<(char, usize)> { - let hex_char = |digits: &str| char::from_u32(u32::from_str_radix(digits, 16).ok()?); - if let Some(rest) = tail.strip_prefix("\\u") { - return Some((hex_char(rest.get(..4)?)?, 6)); - } - if let Some(rest) = tail.strip_prefix("\\U") { - return Some((hex_char(rest.get(..8)?)?, 10)); - } - if let Some(rest) = tail.strip_prefix("&#") { - let end = rest.get(..rest.len().min(10))?.find(';')?; - let body = &rest[..end]; - let ch = match body.strip_prefix(['x', 'X']) { - Some(hex) => hex_char(hex)?, - None => char::from_u32(body.parse().ok()?)?, - }; - return Some((ch, end + 3)); - } - [ - ("&", '&'), - (""", '"'), - ("'", '\''), - ("<", '<'), - (">", '>'), - ] - .into_iter() - .find(|(name, _)| tail.starts_with(name)) - .map(|(name, ch)| (ch, name.len())) -} - /// Start of the url whose `://` (or `%3A%2F%2F`) is at byte `at`: the /// LONGEST http(s) scheme spelling ending there (cargo's `sparse+` / /// `registry+` kinds included) — only http(s) urls can be Socket-hosted, so