Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion crates/socket-patch-cli/src/commands/vendor_orphans.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()))
Expand Down
159 changes: 146 additions & 13 deletions crates/socket-patch-cli/src/commands/vendored_backend/repair.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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/<eco>/<uuid>`
/// 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<usize> {
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
Expand Down Expand Up @@ -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:<path>:` and yaml mappings suffix the path with a
Expand All @@ -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()..];
Expand Down Expand Up @@ -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
Expand Down
42 changes: 42 additions & 0 deletions crates/socket-patch-core/src/utils/fs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
///
Expand Down
1 change: 1 addition & 0 deletions crates/socket-patch-core/src/utils/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading