diff --git a/crates/socket-patch-cli/src/commands/vex.rs b/crates/socket-patch-cli/src/commands/vex.rs index 6fbc6a594..909766f82 100644 --- a/crates/socket-patch-cli/src/commands/vex.rs +++ b/crates/socket-patch-cli/src/commands/vex.rs @@ -1219,7 +1219,9 @@ async fn generate_vex_with_cleanup( /// Removal errors are swallowed: the non-zero exit is the contract, the /// deletion is hygiene. Returns whether a document was actually removed. async fn remove_stale_vex_doc(path: &Path) -> bool { - let Ok(bytes) = tokio::fs::read(path).await else { + // FIFO-safe read: `--vex` can name any path, and a plain read of a FIFO + // there would block this failure path forever. + let Ok(bytes) = socket_patch_core::utils::fs::read_regular_to_bytes(path).await else { return false; }; let is_openvex = serde_json::from_slice::(&bytes) diff --git a/crates/socket-patch-cli/tests/covgap_commands_vex.rs b/crates/socket-patch-cli/tests/covgap_commands_vex.rs index 5ceb58eda..65cb456db 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_vex.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_vex.rs @@ -223,6 +223,68 @@ fn corrupt_manifest_json_envelope_carries_code_and_removes_stale_doc() { ); } +/// A failed run's stale-doc cleanup reads `--output` to check it is +/// OpenVEX before deleting it. A FIFO there must not block that read: the +/// run still fails promptly with its own error and leaves the FIFO alone. +#[cfg(unix)] +#[test] +fn failed_run_does_not_block_on_a_fifo_at_output() { + use std::os::unix::fs::FileTypeExt; + use std::time::{Duration, Instant}; + let tmp = tempfile::tempdir().unwrap(); + let cwd = tmp.path(); + let dir = cwd.join(".socket"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("manifest.json"), "{not json").unwrap(); + let fifo = cwd.join("out.vex.json"); + assert!(Command::new("mkfifo") + .arg(&fifo) + .status() + .unwrap() + .success()); + + let mut child = cli() + .args([ + "vex", + "--cwd", + cwd.to_str().unwrap(), + "--json", + "--output", + fifo.to_str().unwrap(), + "--product", + "pkg:npm/app@1.0.0", + ]) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .expect("invoke vex"); + let started = Instant::now(); + while child.try_wait().unwrap().is_none() { + if started.elapsed() > Duration::from_secs(60) { + let _ = child.kill(); + panic!("vex blocked reading the FIFO at --output"); + } + std::thread::sleep(Duration::from_millis(25)); + } + let out = child.wait_with_output().unwrap(); + assert_eq!( + out.status.code(), + Some(2), + "stdout:\n{}", + String::from_utf8_lossy(&out.stdout) + ); + let env: Value = serde_json::from_slice(&out.stdout).expect("envelope JSON on stdout"); + assert_eq!(env["error"]["code"], "manifest_unreadable", "{env}"); + assert!( + std::fs::symlink_metadata(&fifo) + .unwrap() + .file_type() + .is_fifo(), + "a FIFO is not an OpenVEX document and must not be removed" + ); +} + // ────────────────────────────────────────────────────────────────────── // corrupt pre-v5 redirect ledger → `redirect_ledger_corrupt` WARNING // diff --git a/crates/socket-patch-core/src/crawlers/python_crawler.rs b/crates/socket-patch-core/src/crawlers/python_crawler.rs index 576b25744..a4dcfaf8e 100644 --- a/crates/socket-patch-core/src/crawlers/python_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/python_crawler.rs @@ -2797,7 +2797,8 @@ fn uv_dir_candidates(home_dir: &Path, override_var: &str, bucket: &str) -> Vec

/pdm` on Linux, /// `~/Library/Application Support/pdm` on macOS, and /// `%LOCALAPPDATA%\pdm\pdm` on Windows. -#[cfg_attr(windows, allow(unused_variables))] +// `unix_default` is Linux-only, `var` / `xdg_var` unused on Windows. +#[cfg_attr(any(windows, target_os = "macos"), allow(unused_variables))] fn pdm_dir_candidates( home_dir: Option<&Path>, var: &impl Fn(&str) -> Option, diff --git a/crates/socket-patch-core/src/vex/product.rs b/crates/socket-patch-core/src/vex/product.rs index 897d1de41..0192229db 100644 --- a/crates/socket-patch-core/src/vex/product.rs +++ b/crates/socket-patch-core/src/vex/product.rs @@ -5,7 +5,8 @@ //! identifier when the repo IS the product. GitHub/GitLab/ //! Bitbucket URLs are normalized to //! `pkg://`; anything else -//! is returned as the raw URL. +//! is returned as the URL with any credentials (userinfo), query +//! and fragment removed. //! 2. `package.json` (npm) → `pkg:npm/@` //! 3. `pyproject.toml` (PyPI) → `pkg:pypi/@` //! 4. `Cargo.toml` (Cargo) → `pkg:cargo/@` @@ -552,8 +553,18 @@ fn parse_git_config_value(raw: &str) -> String { /// * `https://github.com/owner/repo` → `pkg:github/owner/repo` /// * Same shapes for `gitlab.com` (→ `pkg:gitlab`) and `bitbucket.org` /// (→ `pkg:bitbucket`). -/// * Anything else (self-hosted gitea, generic SSH, etc.) → URL as-is. +/// * Anything else (self-hosted gitea, generic SSH, etc.) → the URL with +/// its userinfo, query and fragment removed. +/// +/// The identifier lands in a VEX document that is meant to be committed, +/// so it must never carry credentials: a CI-style origin such as +/// `https://gitlab-ci-token:@gitlab.example.com/g/r.git` would +/// otherwise publish the token. The query and fragment are dropped up +/// front (they are not part of a repository's identity, and a token can +/// ride there too), the userinfo on the fallback path +/// ([`split_remote_host_path`] already skips it for the normalized hosts). fn remote_url_to_purl(url: &str) -> String { + let url = strip_url_query_fragment(url); if let Some((host, path)) = split_remote_host_path(url) { // Trim slashes BEFORE stripping `.git`: a URL like // `https://github.com/owner/repo.git/` carries a trailing @@ -578,7 +589,41 @@ fn remote_url_to_purl(url: &str) -> String { } } } - url.to_string() + strip_url_userinfo(url) +} + +/// Cut a URL at its first `?` or `#`. A trailing bare `?`/`#` goes too. +fn strip_url_query_fragment(url: &str) -> &str { + match url.find(['?', '#']) { + Some(idx) => &url[..idx], + None => url, + } +} + +/// 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. +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 @@ -812,6 +857,98 @@ mod tests { assert_eq!(remote_url_to_purl(raw), raw); } + /// A CI-style origin on a self-hosted forge carries a token in its + /// userinfo. The raw-URL fallback must drop it: the product `@id` + /// lands in a committed VEX document. + #[test] + fn remote_url_unknown_host_strips_credentials() { + assert_eq!( + remote_url_to_purl("https://gitlab-ci-token:glcbt-SECRET@gitlab.example.com/g/r.git"), + "https://gitlab.example.com/g/r.git" + ); + // Username only. + assert_eq!( + remote_url_to_purl("https://deploy@git.example.com/team/repo.git"), + "https://git.example.com/team/repo.git" + ); + // An unencoded `@` inside the password: the LAST `@` of the + // authority ends the userinfo. + assert_eq!( + remote_url_to_purl("https://user:p@ss@git.example.com:8443/team/repo.git"), + "https://git.example.com:8443/team/repo.git" + ); + // ssh:// with a port, and git+https. + assert_eq!( + remote_url_to_purl("ssh://git@git.example.com:2222/team/repo.git"), + "ssh://git.example.com:2222/team/repo.git" + ); + assert_eq!( + remote_url_to_purl("git+https://oauth2:tok@git.example.com/team/repo"), + "git+https://git.example.com/team/repo" + ); + } + + /// The same token on a NORMALIZED host never reaches the output + /// either (the purl drops the authority entirely), even when an `@` + /// inside the password defeats `split_remote_host_path`'s + /// first-`@` split. + #[test] + fn remote_url_normalized_host_never_leaks_credentials() { + assert_eq!( + remote_url_to_purl("https://x-access-token:ghs_SECRET@github.com/o/r.git"), + "pkg:github/o/r" + ); + let out = remote_url_to_purl("https://user:p@ss@github.com/o/r.git"); + assert!(!out.contains("p@ss") && !out.contains("user"), "{out}"); + } + + /// scp-style remotes on unknown hosts lose their `user@` too; an `@` + /// that belongs to the path (no `:` after it) is left alone. + #[test] + fn remote_url_unknown_host_scp_style_userinfo() { + assert_eq!( + remote_url_to_purl("git@git.example.com:team/repo.git"), + "git.example.com:team/repo.git" + ); + assert_eq!( + remote_url_to_purl("deploy@git.example.com:team/repo.git"), + "git.example.com:team/repo.git" + ); + assert_eq!(remote_url_to_purl("host:repo@v1"), "host:repo@v1"); + // An `@` after the first `/` is path, not userinfo. + assert_eq!( + remote_url_to_purl("https://git.example.com/team/repo@v2"), + "https://git.example.com/team/repo@v2" + ); + assert_eq!( + remote_url_to_purl("/srv/git/repo@v2.git"), + "/srv/git/repo@v2.git" + ); + } + + /// Query strings and fragments are not part of a repository's + /// identity and can carry tokens: dropped on every path, including + /// the normalized hosts (where they used to leak into the purl name). + #[test] + fn remote_url_query_and_fragment_are_dropped() { + assert_eq!( + remote_url_to_purl("https://git.example.com/team/repo.git?private_token=SECRET#frag"), + "https://git.example.com/team/repo.git" + ); + assert_eq!( + remote_url_to_purl("https://git.example.com/team/repo#readme"), + "https://git.example.com/team/repo" + ); + assert_eq!( + remote_url_to_purl("https://github.com/o/r.git?token=SECRET"), + "pkg:github/o/r" + ); + assert_eq!( + remote_url_to_purl("git@gitlab.com:foo/bar.git#main"), + "pkg:gitlab/foo/bar" + ); + } + #[test] fn remote_url_ssh_protocol_form() { assert_eq!(