From f584353b9710f5b5111467ff1cdad8a09e1a4e9d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 08:56:55 +0000 Subject: [PATCH 1/5] =?UTF-8?q?Start=20sweep=20of=20core=20utils=20redact,?= =?UTF-8?q?=20repo=5Froot,=20=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assisted-by: Claude Code:claude-opus-5-5 From 6c0344ff2bc85e545fc33707698d79890202e7df Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 09:00:22 +0000 Subject: [PATCH 2/5] One URL userinfo stripper, in utils::redact Move vex::product::strip_url_userinfo into utils::redact, where upstream npm and the VEX product id now both reach it, and delete the dead strip_url_credentials and url_userinfo. The moved code is byte-identical, so VEX @id and fallback warnings are unchanged. Fold query_span and query_pairs into their one caller, secret_query_spans. Move the vendor_prebuilt_downloaded builder lint to service_fetch, the file it guards. Fixes #1848 Assisted-by: Claude Code:claude-opus-5-5 --- .../src/patch/redirect/upstream/npm.rs | 2 +- crates/socket-patch-core/src/utils/redact.rs | 199 +++++------------- .../src/vendor/service_fetch.rs | 42 ++++ crates/socket-patch-core/src/vex/product.rs | 28 +-- 4 files changed, 99 insertions(+), 172 deletions(-) diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs b/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs index 7dad81435..e58d2af32 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs @@ -155,7 +155,7 @@ pub(super) async fn fetch_dists_on>( if let Some((base, why)) = fell_back { // The detail is printed and persisted in `--json` // output (CI logs): never the URL's credentials. - let clean = crate::vex::product::strip_url_userinfo(&base); + let clean = crate::utils::redact::strip_url_userinfo(&base); let why = why.replace(base.trim_end_matches('/'), &clean); let base = clean; result.warnings.push(( diff --git a/crates/socket-patch-core/src/utils/redact.rs b/crates/socket-patch-core/src/utils/redact.rs index a28b5cb35..cd2e41285 100644 --- a/crates/socket-patch-core/src/utils/redact.rs +++ b/crates/socket-patch-core/src/utils/redact.rs @@ -19,9 +19,9 @@ //! path levels, the uuid and the file name stay, so the text still says //! where the request went. //! -//! [`strip_url_credentials`] is the identifier form: it drops userinfo and -//! secret query parameters outright, for a URL that is recorded as data -//! (a VEX product `@id`) rather than shown. +//! [`strip_url_userinfo`] is the identifier form: it drops the userinfo +//! outright, for a URL that is recorded as data (a VEX product `@id`, an +//! upstream registry named in `--json` output) rather than shown. //! //! Split by hand because the URLs here are often not well-formed enough //! for a parser to accept (scp-like remotes, text that merely contains a @@ -149,46 +149,24 @@ fn grant_token_span(url: &str) -> Option> { .filter(|r| !r.is_empty()) } -/// Byte range of `url`'s query (after the `?`, before any `#`). -fn query_span(url: &str) -> Option> { - let path_end = url.find(['?', '#'])?; - if url.as_bytes()[path_end] != b'?' { - return None; - } - let end = url[path_end..] - .find('#') - .map_or(url.len(), |i| path_end + i); - Some(path_end + 1..end) -} - -/// The `name=value` pairs of `url`'s query as `(pair, name, value)` byte -/// ranges. -fn query_pairs(url: &str) -> Vec<(Range, Range, Range)> { - let Some(query) = query_span(url) else { +/// Byte ranges of the secret query values of `url` (the query runs from +/// the first `?`, when no `#` precedes it, to the next `#`). +fn secret_query_spans(url: &str) -> Vec> { + let Some(q) = url.find(['?', '#']).filter(|&i| url.as_bytes()[i] == b'?') else { return Vec::new(); }; - let mut pairs = Vec::new(); - let mut pos = query.start; - for pair in url[query.clone()].split('&') { - let name_len = pair.find('=').unwrap_or(pair.len()); - let value_start = (pos + name_len + 1).min(pos + pair.len()); - pairs.push(( - pos..pos + pair.len(), - pos..pos + name_len, - value_start..pos + pair.len(), - )); + let end = url[q..].find('#').map_or(url.len(), |i| q + i); + let mut spans = Vec::new(); + let mut pos = q + 1; + for pair in url[q + 1..end].split('&') { + if let Some(eq) = pair.find('=') { + if eq + 1 < pair.len() && is_secret_param(&pair[..eq]) { + spans.push(pos + eq + 1..pos + pair.len()); + } + } pos += pair.len() + 1; } - pairs -} - -/// Byte ranges of the secret query values of `url`. -fn secret_query_spans(url: &str) -> Vec> { - query_pairs(url) - .into_iter() - .filter(|(_, name, value)| !value.is_empty() && is_secret_param(&url[name.clone()])) - .map(|(_, _, value)| value) - .collect() + spans } /// `url` with each span replaced by `with` (spans must not overlap). @@ -229,54 +207,30 @@ pub fn redact_url(url: &str) -> Cow<'_, str> { replace_spans(url, spans, REDACTED) } -/// The userinfo of a `scheme://` URL (without the `@`), when it has one. -pub fn url_userinfo(url: &str) -> Option<&str> { - if !url.contains("://") { - return None; - } - userinfo_span(url).map(|r| &url[r]) -} - -/// `url` as an identifier: userinfo and secret query parameters dropped -/// outright (no marker), everything else as written. Only a `scheme://` -/// URL is touched: in an scp-like `user@host:path` the `user` is a login -/// name, and the text has no query. -pub fn strip_url_credentials(url: &str) -> Cow<'_, str> { - if !url.contains("://") { - return Cow::Borrowed(url); - } - let pairs = query_pairs(url); - let secret: Vec = pairs - .iter() - .map(|(_, name, _)| is_secret_param(&url[name.clone()])) - .collect(); - let userinfo = userinfo_span(url); - if userinfo.is_none() && !secret.contains(&true) { - return Cow::Borrowed(url); - } - let path_end = url.find(['?', '#']).unwrap_or(url.len()); - let mut out = String::with_capacity(url.len()); - match userinfo { - // The `@` goes too. - Some(r) => { - out.push_str(&url[..r.start]); - out.push_str(&url[r.end + 1..path_end]); - } - None => out.push_str(&url[..path_end]), +/// Remove a `user[:password]@` prefix from a remote URL. +/// +/// * `scheme://[userinfo@]host[:port]/path` — the userinfo is everything +/// up to the LAST `@` of the authority (the part before the first `/` +/// after `://`), since an unencoded `@` in a password still precedes +/// the host's. +/// * scp-style `[user@]host:path` (git treats a `:` before any `/` as +/// this form) — everything up to the last `@` before the first `/`, +/// but only when a `:` follows it; otherwise the `@` belongs to the +/// path (`host:repo@v1`) or the string is not a remote URL at all. +pub(crate) fn strip_url_userinfo(url: &str) -> String { + if let Some(scheme_end) = url.find("://") { + let (scheme, rest) = url.split_at(scheme_end + 3); + let authority_end = rest.find('/').unwrap_or(rest.len()); + return match rest[..authority_end].rfind('@') { + Some(at) => format!("{scheme}{}", &rest[at + 1..]), + None => url.to_string(), + }; } - let kept: Vec<&str> = pairs - .iter() - .zip(&secret) - .filter(|(_, secret)| !**secret) - .map(|((pair, _, _), _)| &url[pair.clone()]) - .collect(); - if !kept.is_empty() { - out.push('?'); - out.push_str(&kept.join("&")); + let prefix_end = url.find('/').unwrap_or(url.len()); + match url[..prefix_end].rfind('@') { + Some(at) if url[at + 1..prefix_end].contains(':') => url[at + 1..].to_string(), + _ => url.to_string(), } - let fragment_start = query_span(url).map_or(path_end, |q| q.end); - out.push_str(&url[fragment_start..]); - Cow::Owned(out) } /// A byte that cannot be part of a URL quoted in free text. @@ -430,24 +384,23 @@ mod tests { } #[test] - fn credentials_are_stripped_from_an_identifier() { - assert_eq!( - strip_url_credentials("https://gitlab-ci-token:glcbt-SECRET@gitlab.corp/g/s/app.git"), - "https://gitlab.corp/g/s/app.git" - ); - assert_eq!( - strip_url_credentials("https://u@h/r.git?private_token=x&ref=main#frag"), - "https://h/r.git?ref=main#frag" - ); - assert_eq!( - strip_url_credentials("https://h/r.git?token=x"), - "https://h/r.git" - ); - for plain in [ - "https://git.example.com/team/repo.git", - "git@git.corp:team/repo.git", + fn userinfo_is_stripped_from_an_identifier() { + for (url, want) in [ + ( + "https://gitlab-ci-token:glcbt-SECRET@gitlab.corp/g/s/app.git", + "https://gitlab.corp/g/s/app.git", + ), + ("https://u:p@host/x?y#z", "https://host/x?y#z"), + ("https://u@host?x", "https://host?x"), + ("https://u:p@w@host/x@y", "https://host/x@y"), + ("git@git.corp:team/repo.git", "git.corp:team/repo.git"), + ("host:repo@v1", "host:repo@v1"), + ( + "https://git.example.com/team/repo.git", + "https://git.example.com/team/repo.git", + ), ] { - assert_eq!(strip_url_credentials(plain), plain); + assert_eq!(strip_url_userinfo(url), want, "{url}"); } } @@ -473,48 +426,6 @@ mod tests { assert!(matches!(redact_urls_in(none), Cow::Borrowed(_))); } - /// The `vendor_prebuilt_downloaded` advisory, which quotes the - /// grant-tokenized service URL, is built in exactly one place - /// (`VerifiedArchive::downloaded_warning`); a backend that spells its - /// own copy again fails here. - #[test] - fn the_service_download_advisory_has_one_builder() { - fn walk(dir: &std::path::Path, out: &mut Vec) { - for entry in std::fs::read_dir(dir).unwrap() { - let path = entry.unwrap().path(); - if path.is_dir() { - walk(&path, out); - } else if path.extension().is_some_and(|e| e == "rs") { - out.push(path); - } - } - } - let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); - let mut files = Vec::new(); - walk(&src, &mut files); - let mut builders = Vec::new(); - for path in files { - let rel = path - .strip_prefix(&src) - .unwrap() - .to_string_lossy() - .replace('\\', "/"); - if rel.ends_with("tests.rs") || rel.contains("test_support") { - continue; - } - let text = std::fs::read_to_string(&path) - .unwrap() - .replace("\r\n", "\n"); - let production = text - .find("#[cfg(test)]\nmod tests {") - .map_or(text.as_str(), |at| &text[..at]); - for _ in production.matches("\"vendor_prebuilt_downloaded\",") { - builders.push(rel.clone()); - } - } - assert_eq!(builders, vec!["vendor/service_fetch.rs".to_string()]); - } - /// A comma-joined URL list (GOPROXY) is redacted element by element; /// a URL nested in a query is still part of its URL. #[test] diff --git a/crates/socket-patch-core/src/vendor/service_fetch.rs b/crates/socket-patch-core/src/vendor/service_fetch.rs index 2749ec56a..fca75828d 100644 --- a/crates/socket-patch-core/src/vendor/service_fetch.rs +++ b/crates/socket-patch-core/src/vendor/service_fetch.rs @@ -824,4 +824,46 @@ mod tests { ); } } + + /// The `vendor_prebuilt_downloaded` advisory, which quotes the + /// grant-tokenized service URL, is built in exactly one place + /// (`VerifiedArchive::downloaded_warning`); a backend that spells its + /// own copy again fails here. + #[test] + fn the_service_download_advisory_has_one_builder() { + fn walk(dir: &std::path::Path, out: &mut Vec) { + for entry in std::fs::read_dir(dir).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + walk(&path, out); + } else if path.extension().is_some_and(|e| e == "rs") { + out.push(path); + } + } + } + let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let mut files = Vec::new(); + walk(&src, &mut files); + let mut builders = Vec::new(); + for path in files { + let rel = path + .strip_prefix(&src) + .unwrap() + .to_string_lossy() + .replace('\\', "/"); + if rel.ends_with("tests.rs") || rel.contains("test_support") { + continue; + } + let text = std::fs::read_to_string(&path) + .unwrap() + .replace("\r\n", "\n"); + let production = text + .find("#[cfg(test)]\nmod tests {") + .map_or(text.as_str(), |at| &text[..at]); + for _ in production.matches("\"vendor_prebuilt_downloaded\",") { + builders.push(rel.clone()); + } + } + assert_eq!(builders, vec!["vendor/service_fetch.rs".to_string()]); + } } diff --git a/crates/socket-patch-core/src/vex/product.rs b/crates/socket-patch-core/src/vex/product.rs index 15a7fe290..1f98df002 100644 --- a/crates/socket-patch-core/src/vex/product.rs +++ b/crates/socket-patch-core/src/vex/product.rs @@ -710,7 +710,7 @@ fn remote_url_to_purl(url: &str) -> String { } } } - strip_url_userinfo(url) + crate::utils::redact::strip_url_userinfo(url) } /// Cut a URL at its first `?` or `#`. A trailing bare `?`/`#` goes too. @@ -721,32 +721,6 @@ fn strip_url_query_fragment(url: &str) -> &str { } } -/// Remove a `user[:password]@` prefix from a remote URL. -/// -/// * `scheme://[userinfo@]host[:port]/path` — the userinfo is everything -/// up to the LAST `@` of the authority (the part before the first `/` -/// after `://`), since an unencoded `@` in a password still precedes -/// the host's. -/// * scp-style `[user@]host:path` (git treats a `:` before any `/` as -/// this form) — everything up to the last `@` before the first `/`, -/// but only when a `:` follows it; otherwise the `@` belongs to the -/// path (`host:repo@v1`) or the string is not a remote URL at all. -pub(crate) fn strip_url_userinfo(url: &str) -> String { - if let Some(scheme_end) = url.find("://") { - let (scheme, rest) = url.split_at(scheme_end + 3); - let authority_end = rest.find('/').unwrap_or(rest.len()); - return match rest[..authority_end].rfind('@') { - Some(at) => format!("{scheme}{}", &rest[at + 1..]), - None => url.to_string(), - }; - } - let prefix_end = url.find('/').unwrap_or(url.len()); - match url[..prefix_end].rfind('@') { - Some(at) if url[at + 1..prefix_end].contains(':') => url[at + 1..].to_string(), - _ => url.to_string(), - } -} - /// Pull `(host, path)` out of a git remote URL. Returns `None` for /// shapes we don't recognize — the caller falls back to raw-URL mode. fn split_remote_host_path(url: &str) -> Option<(&str, &str)> { From 3749abcf9334fa188876fe940cb605492bfcd628 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 09:00:22 +0000 Subject: [PATCH 3/5] Sweep core repo_root, serde, uv_workspace docs Inline GitRepo::dot_git into its two callers, trim the module docs of repo_root, serde, toml_edit_ext and uv_workspace to what is true today, and drop the encode_uri_component asserts that yarn_berry_lock copied byte for byte from uri.rs. Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-core/src/policy/mod.rs | 2 +- .../socket-patch-core/src/utils/repo_root.rs | 20 ++++++------------- crates/socket-patch-core/src/utils/serde.rs | 11 ++-------- .../src/utils/toml_edit_ext.rs | 3 +-- .../src/utils/uv_workspace.rs | 14 +++---------- .../src/vendor/yarn_berry_lock.rs | 10 ---------- 6 files changed, 13 insertions(+), 47 deletions(-) diff --git a/crates/socket-patch-core/src/policy/mod.rs b/crates/socket-patch-core/src/policy/mod.rs index 070599590..e3d9b61ca 100644 --- a/crates/socket-patch-core/src/policy/mod.rs +++ b/crates/socket-patch-core/src/policy/mod.rs @@ -808,7 +808,7 @@ pub fn find_repo_root_with_warnings(cwd: &Path) -> (PathBuf, Vec) code: SOCKET_YML_REPO_UNTRUSTED, detail: format!( "{} is owned by another user; using {} as the repository root", - repo.dot_git().display(), + repo.root.join(".git").display(), cwd.display() ), }), diff --git a/crates/socket-patch-core/src/utils/repo_root.rs b/crates/socket-patch-core/src/utils/repo_root.rs index 65e0b418b..504268698 100644 --- a/crates/socket-patch-core/src/utils/repo_root.rs +++ b/crates/socket-patch-core/src/utils/repo_root.rs @@ -1,9 +1,6 @@ -//! The one walk from a directory up to its enclosing git checkout. +//! The walk from a directory up to its enclosing git checkout. //! -//! Every feature that needs "the repository this directory belongs to" -//! (`socket.yml` lookup, VEX product detection, the JVM build-root check) -//! goes through [`search_dirs`] / [`find_git_repo`], so they agree on the -//! rules git itself uses: +//! [`search_dirs`] / [`find_git_repo`] follow the rules git itself uses: //! //! - the nearest ancestor (inclusive) holding `.git` wins — a directory, //! or a FILE as in linked worktrees and submodules (`gitdir: …`), or a @@ -27,11 +24,6 @@ pub struct GitRepo { } impl GitRepo { - /// The `.git` entry at the root. - pub fn dot_git(&self) -> PathBuf { - self.root.join(".git") - } - /// The checkout's git `config` file: `.git/config` for a plain /// checkout; for a `.git` FILE (`gitdir: `, relative to the /// root), the config of that git dir — or, for a linked worktree whose @@ -39,7 +31,7 @@ impl GitRepo { /// is where `git worktree add` keeps the remotes. `None` when it /// cannot be resolved to a regular file. pub fn config_path(&self) -> Option { - let dot_git = self.dot_git(); + let dot_git = self.root.join(".git"); let git_dir = if dot_git.is_dir() { dot_git } else { @@ -237,8 +229,8 @@ mod tests { assert_eq!(find(&plain, Some(&base), &[]), None); } - /// B22: a dotfiles repository at `$HOME` must not become the - /// repository of every project below it. + /// A dotfiles repository at `$HOME` must not become the repository of + /// every project below it. #[test] fn walk_never_enters_home_unless_it_starts_there() { let tmp = tempfile::tempdir().unwrap(); @@ -344,7 +336,7 @@ mod tests { fs::canonicalize(main_git.join("config")).unwrap() ); // A dangling pointer resolves to nothing. - fs::write(repo.dot_git(), "gitdir: /nonexistent/x\n").unwrap(); + fs::write(repo.root.join(".git"), "gitdir: /nonexistent/x\n").unwrap(); assert_eq!(repo.config_path(), None); } diff --git a/crates/socket-patch-core/src/utils/serde.rs b/crates/socket-patch-core/src/utils/serde.rs index 7f5f288c5..5f9ae1bd6 100644 --- a/crates/socket-patch-core/src/utils/serde.rs +++ b/crates/socket-patch-core/src/utils/serde.rs @@ -3,15 +3,8 @@ use serde::{Serialize, Serializer}; use std::collections::{BTreeMap, HashMap}; -/// Serialize a `HashMap` with its keys in sorted order so the emitted JSON -/// is deterministic across runs. Used by every git-committed ledger the -/// tool writes (`.socket/manifest.json`, `.socket/vendor/state.json`): -/// `HashMap`'s randomized iteration order would otherwise re-shuffle the -/// keys on every write, producing spurious diffs and merge conflicts. This -/// mirrors the `BTreeMap` choice in `vex::schema`, made for the same -/// "easier diffing across runs" reason. The public field type stays -/// `HashMap` (so callers and deserialization are unaffected); only the -/// on-the-wire ordering is pinned. +/// Serialize a `HashMap` with its keys sorted, so a git-committed ledger +/// does not re-shuffle (and conflict) on every write. pub fn serialize_sorted(map: &HashMap, serializer: S) -> Result where S: Serializer, diff --git a/crates/socket-patch-core/src/utils/toml_edit_ext.rs b/crates/socket-patch-core/src/utils/toml_edit_ext.rs index fcd85f7dc..3a9fb8413 100644 --- a/crates/socket-patch-core/src/utils/toml_edit_ext.rs +++ b/crates/socket-patch-core/src/utils/toml_edit_ext.rs @@ -1,5 +1,4 @@ -//! Small structured-TOML helpers shared by every module that edits or sniffs -//! TOML (`vendor::cargo_config`, `vendor::pypi`, `vendor::pypi_uv`). +//! Small structured-TOML helpers for `vendor::pypi` and `vendor::pypi_uv`. use toml_edit::{Item, Table}; diff --git a/crates/socket-patch-core/src/utils/uv_workspace.rs b/crates/socket-patch-core/src/utils/uv_workspace.rs index eb3284ff2..77e56eae6 100644 --- a/crates/socket-patch-core/src/utils/uv_workspace.rs +++ b/crates/socket-patch-core/src/utils/uv_workspace.rs @@ -1,18 +1,10 @@ -//! The uv workspace that governs a member directory from above (#1138). +//! The uv workspace that governs a member directory from above. //! //! In a uv workspace, `uv.lock` lives at the workspace root and governs //! every member listed by the root `pyproject.toml`'s //! `[tool.uv.workspace] members` globs (minus its `exclude` globs). A run -//! whose project directory is such a member reads only the member: its -//! Python flavor routing never sees the ancestor lock, and a member with -//! any Hatch configuration (the hatchling build backend `uv init --package` -//! scaffolds before uv 0.8, a `hatch.toml`, a `[tool.hatch.*]` table) was -//! rewritten as a lockless Hatch project. The root `uv.lock` then went -//! stale, `uv sync --frozen` installed the unpatched release, and vendored -//! VEX attested it `not_affected`. -//! -//! Both modes refuse the layout instead (hosted through the governing-root -//! pre-check, vendored in the PyPI flavor routing), the way they already +//! from a member reads only the member, so rewriting it would leave the +//! root `uv.lock` stale; both modes refuse the layout instead, as they //! refuse a run from the workspace root itself. use std::path::{Path, PathBuf}; diff --git a/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs b/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs index f2ae19f3b..789771e34 100644 --- a/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs +++ b/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs @@ -4900,16 +4900,6 @@ __metadata: #[test] fn helper_grammar() { - // encodeURIComponent semantics, incl. a scoped workspace name. - assert_eq!( - encode_uri_component("vendor-spike@workspace:."), - "vendor-spike%40workspace%3A." - ); - assert_eq!( - encode_uri_component("@acme/root@workspace:."), - "%40acme%2Froot%40workspace%3A." - ); - // Root workspace name extraction + berry field reads. let blocks = scan_blocks(B3_BEFORE_LOCK); assert_eq!( From e56fb48cd33627029be17d5d94448e24d1f159f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 09:15:51 +0000 Subject: [PATCH 4/5] Test that an empty secret query value is kept Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-core/src/utils/redact.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/socket-patch-core/src/utils/redact.rs b/crates/socket-patch-core/src/utils/redact.rs index cd2e41285..93ae7b6b3 100644 --- a/crates/socket-patch-core/src/utils/redact.rs +++ b/crates/socket-patch-core/src/utils/redact.rs @@ -366,6 +366,8 @@ mod tests { ); let plain = "https://h/x?author=a&keyword=b&design=c&monkey=d"; assert_eq!(redact_url(plain), plain, "a marker inside another word"); + let empty = "https://h/x?token=&key&v=1"; + assert_eq!(redact_url(empty), empty, "no value to redact"); } /// An ssh login name is not a secret; an ssh password is. From 8f0433c756022984d69a9acf159a3c4fbdd60d8a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 09:27:44 +0000 Subject: [PATCH 5/5] End the stripped userinfo at ? and # too strip_url_userinfo now finds the userinfo with the module's own userinfo_span, so an @ in a query or fragment (https://h?scope=@acme) is no longer taken for one. The VEX @id is unchanged: it drops the query and fragment before stripping. Unlink the pub(crate) helper from the public module doc. Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-core/src/utils/redact.rs | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/crates/socket-patch-core/src/utils/redact.rs b/crates/socket-patch-core/src/utils/redact.rs index 93ae7b6b3..5d9c10503 100644 --- a/crates/socket-patch-core/src/utils/redact.rs +++ b/crates/socket-patch-core/src/utils/redact.rs @@ -19,9 +19,9 @@ //! path levels, the uuid and the file name stay, so the text still says //! where the request went. //! -//! [`strip_url_userinfo`] is the identifier form: it drops the userinfo -//! outright, for a URL that is recorded as data (a VEX product `@id`, an -//! upstream registry named in `--json` output) rather than shown. +//! `strip_url_userinfo` is the identifier form: it drops the userinfo +//! outright, for a URL that is recorded as data (a VEX product `@id`) +//! rather than shown. //! //! Split by hand because the URLs here are often not well-formed enough //! for a parser to accept (scp-like remotes, text that merely contains a @@ -210,7 +210,7 @@ pub fn redact_url(url: &str) -> Cow<'_, str> { /// Remove a `user[:password]@` prefix from a remote URL. /// /// * `scheme://[userinfo@]host[:port]/path` — the userinfo is everything -/// up to the LAST `@` of the authority (the part before the first `/` +/// up to the LAST `@` of the authority (up to the first `/`, `?` or `#` /// after `://`), since an unencoded `@` in a password still precedes /// the host's. /// * scp-style `[user@]host:path` (git treats a `:` before any `/` as @@ -218,11 +218,9 @@ pub fn redact_url(url: &str) -> Cow<'_, str> { /// but only when a `:` follows it; otherwise the `@` belongs to the /// path (`host:repo@v1`) or the string is not a remote URL at all. pub(crate) fn strip_url_userinfo(url: &str) -> String { - if let Some(scheme_end) = url.find("://") { - let (scheme, rest) = url.split_at(scheme_end + 3); - let authority_end = rest.find('/').unwrap_or(rest.len()); - return match rest[..authority_end].rfind('@') { - Some(at) => format!("{scheme}{}", &rest[at + 1..]), + if url.contains("://") { + return match userinfo_span(url) { + Some(r) => format!("{}{}", &url[..r.start], &url[r.end + 1..]), None => url.to_string(), }; } @@ -394,6 +392,8 @@ mod tests { ), ("https://u:p@host/x?y#z", "https://host/x?y#z"), ("https://u@host?x", "https://host?x"), + ("https://host?scope=@acme", "https://host?scope=@acme"), + ("https://host#a@b", "https://host#a@b"), ("https://u:p@w@host/x@y", "https://host/x@y"), ("git@git.corp:team/repo.git", "git.corp:team/repo.git"), ("host:repo@v1", "host:repo@v1"),