Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ pub(super) async fn fetch_dists_on<R: Into<ProjectRegistry>>(
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((
Expand Down
2 changes: 1 addition & 1 deletion crates/socket-patch-core/src/policy/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -808,7 +808,7 @@ pub fn find_repo_root_with_warnings(cwd: &Path) -> (PathBuf, Vec<PolicyWarning>)
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()
),
}),
Expand Down
201 changes: 57 additions & 144 deletions crates/socket-patch-core/src/utils/redact.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
//! 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
Expand Down Expand Up @@ -149,46 +149,24 @@ fn grant_token_span(url: &str) -> Option<Range<usize>> {
.filter(|r| !r.is_empty())
}

/// Byte range of `url`'s query (after the `?`, before any `#`).
fn query_span(url: &str) -> Option<Range<usize>> {
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<usize>, Range<usize>, Range<usize>)> {
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<Range<usize>> {
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<Range<usize>> {
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).
Expand Down Expand Up @@ -229,54 +207,28 @@ 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<bool> = 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 (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
/// 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 url.contains("://") {
return match userinfo_span(url) {
Some(r) => format!("{}{}", &url[..r.start], &url[r.end + 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.
Expand Down Expand Up @@ -412,6 +364,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.
Expand All @@ -430,24 +384,25 @@ 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://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"),
(
"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}");
}
}

Expand All @@ -473,48 +428,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<std::path::PathBuf>) {
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]
Expand Down
20 changes: 6 additions & 14 deletions crates/socket-patch-core/src/utils/repo_root.rs
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -27,19 +24,14 @@ 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: <dir>`, relative to the
/// root), the config of that git dir — or, for a linked worktree whose
/// git dir names a `commondir`, the shared repository's config, which
/// is where `git worktree add` keeps the remotes. `None` when it
/// cannot be resolved to a regular file.
pub fn config_path(&self) -> Option<PathBuf> {
let dot_git = self.dot_git();
let dot_git = self.root.join(".git");
let git_dir = if dot_git.is_dir() {
dot_git
} else {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);
}

Expand Down
11 changes: 2 additions & 9 deletions crates/socket-patch-core/src/utils/serde.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<S, V>(map: &HashMap<String, V>, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
Expand Down
3 changes: 1 addition & 2 deletions crates/socket-patch-core/src/utils/toml_edit_ext.rs
Original file line number Diff line number Diff line change
@@ -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};

Expand Down
14 changes: 3 additions & 11 deletions crates/socket-patch-core/src/utils/uv_workspace.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down
Loading
Loading