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
14 changes: 5 additions & 9 deletions crates/socket-patch-core/src/utils/notice.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,19 +37,15 @@ pub fn is_quiet() -> bool {
SILENT.load(Ordering::Relaxed) || JSON.load(Ordering::Relaxed)
}

/// Whether an advisory of `level` is suppressed in the current mode.
pub(crate) fn is_muted(level: Notice) -> bool {
match level {
Notice::Info => is_quiet(),
Notice::Warning => SILENT.load(Ordering::Relaxed),
}
}

/// Print `msg()` to stderr unless `level` is muted or `shown` already
/// fired. The message is built lazily so a suppressed advisory costs
/// nothing, and a muted call does not consume the once.
pub(crate) fn notice_once(level: Notice, shown: &AtomicBool, msg: impl FnOnce() -> String) {
if is_muted(level) {
let muted = match level {
Notice::Info => is_quiet(),
Notice::Warning => SILENT.load(Ordering::Relaxed),
};
if muted {
return;
}
if !shown.swap(true, Ordering::Relaxed) {
Expand Down
11 changes: 6 additions & 5 deletions crates/socket-patch-core/src/utils/npm_range.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,16 +30,17 @@ pub(crate) fn npm_range_admits(range: &str, version: &str) -> Option<bool> {
Some(admits)
}

fn is_operator(c: char) -> bool {
matches!(c, '^' | '~' | '>' | '<' | '=')
}

/// The comparators of one npm range alternative, an operator npm allows
/// to stand apart (`>= 1.2.0`) joined to its version.
fn glue_operators(alternative: &str) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
let mut pending = String::new();
for token in alternative.split_whitespace() {
if token
.chars()
.all(|c| matches!(c, '^' | '~' | '>' | '<' | '='))
{
if token.chars().all(is_operator) {
pending.push_str(token);
} else {
out.push(format!("{}{token}", std::mem::take(&mut pending)));
Expand All @@ -53,7 +54,7 @@ fn glue_operators(alternative: &str) -> Vec<String> {
/// crate reads both as caret ranges.
fn npm_comparator(comparator: &str) -> Option<String> {
let op_len = comparator
.find(|c: char| !matches!(c, '^' | '~' | '>' | '<' | '='))
.find(|c: char| !is_operator(c))
.unwrap_or(comparator.len());
let (op, version) = comparator.split_at(op_len);
let version = version.strip_prefix('v').unwrap_or(version);
Expand Down
22 changes: 11 additions & 11 deletions crates/socket-patch-core/src/utils/pep440.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@ fn number(digits: &str) -> String {
if trimmed.is_empty() { "0" } else { trimmed }.to_owned()
}

/// Drop trailing `0` release segments, keeping at least one.
fn trim_trailing_zeros(release: &mut Vec<String>) {
while release.len() > 1 && release.last().is_some_and(|segment| segment == "0") {
release.pop();
}
}

#[derive(Debug, PartialEq, Eq)]
enum LocalPart {
Number(String),
Expand Down Expand Up @@ -107,13 +114,8 @@ pub(crate) fn versions_equal(a: &str, b: &str) -> bool {
match (parse(a), parse(b)) {
(Some(mut a), Some(mut b)) => {
// Equality ignores trailing `.0` release segments.
for version in [&mut a, &mut b] {
while version.release.len() > 1
&& version.release.last().is_some_and(|segment| segment == "0")
{
version.release.pop();
}
}
trim_trailing_zeros(&mut a.release);
trim_trailing_zeros(&mut b.release);
a == b
}
_ => false,
Expand Down Expand Up @@ -192,9 +194,7 @@ pub(crate) fn equivalent_release_spellings(version: &str) -> Vec<String> {
}
let segments: Vec<String> = version.split('.').map(number).collect();
let mut trimmed = segments.clone();
while trimmed.len() > 1 && trimmed.last().is_some_and(|segment| segment == "0") {
trimmed.pop();
}
trim_trailing_zeros(&mut trimmed);
let mut out = Vec::new();
for len in trimmed.len()..=segments.len().max(3) {
let mut release = trimmed.clone();
Expand Down Expand Up @@ -286,7 +286,7 @@ mod tests {
}

/// What `str(packaging.version.Version(v))` prints, which is what
/// uv.lock, wheel METADATA and PyPI record (#1589).
/// uv.lock, wheel METADATA and PyPI record.
#[test]
fn normalize_matches_packaging() {
for (raw, normalized) in [
Expand Down
113 changes: 4 additions & 109 deletions crates/socket-patch-core/src/utils/pipenv.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,7 @@
//! the vendored backend refuses installers older than 2018, so both ask
//! [`installed_major`] once per command.

use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::path::Path;

/// Pins the answer without spawning anything: CI images without pipenv on
/// PATH, or a project installed with a different release than the machine's
Expand Down Expand Up @@ -39,38 +38,24 @@ fn parse_major(output: &str) -> Option<u32> {
major.parse().ok()
}

/// The `pipenv` executable, searched on ABSOLUTE `PATH` entries only: the
/// CLI runs inside the project being scanned, so a relative entry (`.`, an
/// empty component) would execute a `pipenv` planted in that repository. On Windows every `PATHEXT` extension is tried,
/// so `pipenv.exe` and the `pipenv.bat` / `pipenv.cmd` shims (pyenv-win,
/// hand-written wrappers) are both found. The rule lives in
/// [`crate::utils::process::resolve_tool_with`], shared with every other
/// tool the CLI spawns inside a project (`git`, `vlt`, `node`).
fn resolve_on_path(var: &impl Fn(&str) -> Option<OsString>) -> Option<PathBuf> {
crate::utils::process::resolve_tool_with("pipenv", var)
}

/// The major of the `pipenv` on PATH (`11`, `2018`, `2026`, …), or `None`
/// when none is found, it does not answer within 10 s, exits non-zero or
/// prints something unrecognizable. [`MAJOR_OVERRIDE_ENV`] short-circuits
/// the probe.
pub async fn installed_major(root: &Path) -> Option<u32> {
pub async fn installed_major(_root: &Path) -> Option<u32> {
if let Some(forced) = std::env::var(MAJOR_OVERRIDE_ENV)
.ok()
.and_then(|value| parse_override(&value))
{
return Some(forced);
}
let program = resolve_on_path(&|name| std::env::var_os(name))?;
// The RESOLVED path is spawned; a Windows `.bat` / `.cmd` shim is run by
// `std` itself through cmd.exe with correct quoting (see the shared
// launcher's docs).
// Absolute PATH entries only, so a repo-planted `pipenv` never runs.
let program = crate::utils::process::resolve_tool("pipenv")?;
let mut command = crate::utils::process::command_for(&program);
// The version banner does not depend on a project, so the probe runs in a
// NEUTRAL directory: with the scanned repository as cwd, Pipenv would read
// its `.env`, `Pipfile` and `.venv` pointer — committed, attacker-shaped
// inputs that must not influence (or slow down) a version check.
let _ = root;
command
.arg("--version")
.current_dir(std::env::temp_dir())
Expand Down Expand Up @@ -121,68 +106,6 @@ mod tests {
);
}

#[test]
fn resolve_on_path_skips_relative_entries_and_finds_absolute_ones() {
let tmp = tempfile::tempdir().unwrap();
let bin = tmp.path().join("bin");
std::fs::create_dir_all(&bin).unwrap();
let leaf = if cfg!(windows) {
"pipenv.exe"
} else {
"pipenv"
};
std::fs::write(bin.join(leaf), b"").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(bin.join(leaf), std::fs::Permissions::from_mode(0o755))
.unwrap();
}
// A repo-planted `pipenv` under a RELATIVE entry must never win.
let planted = tmp.path().join("planted");
std::fs::create_dir_all(&planted).unwrap();
std::fs::write(planted.join(leaf), b"").unwrap();
let joined = std::env::join_paths([
std::path::PathBuf::from("."),
std::path::PathBuf::from(""),
std::path::PathBuf::from("planted"),
bin.clone(),
])
.unwrap();
let var = |name: &str| (name == "PATH").then(|| joined.clone());
assert_eq!(resolve_on_path(&var), Some(bin.join(leaf)));

let only_relative = std::env::join_paths([
std::path::PathBuf::from("."),
std::path::PathBuf::from("planted"),
])
.unwrap();
let var = |name: &str| (name == "PATH").then(|| only_relative.clone());
assert_eq!(resolve_on_path(&var), None);
let none = |_: &str| None::<OsString>;
assert_eq!(resolve_on_path(&none), None);
}

#[cfg(unix)]
#[test]
fn resolve_on_path_skips_non_executable_files() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let data = tmp.path().join("data");
let bin = tmp.path().join("bin");
std::fs::create_dir_all(&data).unwrap();
std::fs::create_dir_all(&bin).unwrap();
std::fs::write(data.join("pipenv"), b"not a program").unwrap();
std::fs::set_permissions(data.join("pipenv"), std::fs::Permissions::from_mode(0o644))
.unwrap();
std::fs::write(bin.join("pipenv"), b"").unwrap();
std::fs::set_permissions(bin.join("pipenv"), std::fs::Permissions::from_mode(0o755))
.unwrap();
let joined = std::env::join_paths([data.clone(), bin.clone()]).unwrap();
let var = |name: &str| (name == "PATH").then(|| joined.clone());
assert_eq!(resolve_on_path(&var), Some(bin.join("pipenv")));
}

#[test]
fn override_accepts_a_major_or_a_full_release() {
assert_eq!(parse_override("11"), Some(11));
Expand All @@ -193,32 +116,4 @@ mod tests {
assert_eq!(parse_override(""), None);
assert_eq!(parse_override("-11"), None);
}

#[cfg(windows)]
#[test]
fn resolve_on_path_honours_pathext_shims() {
let tmp = tempfile::tempdir().unwrap();
let bin = tmp.path().join("bin");
std::fs::create_dir_all(&bin).unwrap();
std::fs::write(bin.join("pipenv.bat"), b"@echo pipenv, version 2024.4.1").unwrap();
let joined = std::env::join_paths([bin.clone()]).unwrap();
let var = |name: &str| match name {
"PATH" => Some(joined.clone()),
"PATHEXT" => Some(OsString::from(".COM;.EXE;.BAT;.CMD")),
_ => None,
};
let found = resolve_on_path(&var).unwrap();
assert_eq!(found, bin.join("pipenv.bat"));
// Spawned directly: std runs the .bat through cmd.exe itself, and the
// banner parses like a real pipenv's.
let out = crate::utils::process::command_for(&found)
.arg("--version")
.output()
.expect("std spawns a .bat shim");
assert!(out.status.success(), "{out:?}");
assert_eq!(
parse_major(&String::from_utf8_lossy(&out.stdout)),
Some(2024)
);
}
}
Loading
Loading