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
13 changes: 13 additions & 0 deletions crates/socket-patch-cli/src/commands/scan/hosted.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,10 @@ const REDIRECT_CANDIDATE_FILES: &[&str] = &[
"requirements.txt",
"uv.lock",
"poetry.lock",
"pdm.lock",
"Pipfile.lock",
"pyproject.toml",
"hatch.toml",
"Cargo.toml",
"Cargo.lock",
".cargo/config.toml",
Expand Down Expand Up @@ -1682,6 +1685,16 @@ pub(crate) async fn run_redirect_selected(
.iter()
.filter(
|(purl, uuid, artifact_url, index_url, suffixed_version, go_module_path)| {
if rewrite.python_lock_uuids.contains(uuid) {
return rewrite.confirmed_python_lock_uuids.contains(uuid)
&& !rewrite.refused_python_lock_uuids.contains(uuid);
}
if rewrite.hatch_uuids.contains(uuid) {
return rewrite.confirmed_hatch_uuids.contains(uuid);
}
if purl.starts_with("pkg:pypi/") {
return rewrite.confirmed_requirements_uuids.contains(uuid);
}
if rewrite.refused_pnpm_uuids.contains(uuid) {
return false;
}
Expand Down
43 changes: 40 additions & 3 deletions crates/socket-patch-core/src/crawlers/python_crawler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};

use super::types::{CrawledPackage, CrawlerOptions};
use crate::utils::fs::read_regular_to_string;
use crate::utils::process::{CommandRunner, SystemCommandRunner};

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -530,7 +531,7 @@ fn expand_home(raw: &str, var: &impl Fn(&str) -> Option<String>) -> PathBuf {
pub async fn find_poetry_virtualenv_site_packages(cwd: &Path) -> Vec<PathBuf> {
let var = |name: &str| std::env::var(name).ok();
let has = |leaf: &str| cwd.join(leaf).is_file();
let pyproject = match tokio::fs::read_to_string(cwd.join("pyproject.toml")).await {
let pyproject = match read_regular_to_string(&cwd.join("pyproject.toml")).await {
Ok(text) => text,
Err(_) => return Vec::new(),
};
Expand All @@ -542,12 +543,12 @@ pub async fn find_poetry_virtualenv_site_packages(cwd: &Path) -> Vec<PathBuf> {
if names.is_empty() {
return Vec::new();
}
let local = match tokio::fs::read_to_string(cwd.join("poetry.toml")).await {
let local = match read_regular_to_string(&cwd.join("poetry.toml")).await {
Ok(text) => PoetryVirtualenvConfig::from_toml(&text),
Err(_) => PoetryVirtualenvConfig::default(),
};
let user = match poetry_user_config_path(&var) {
Some(path) => match tokio::fs::read_to_string(&path).await {
Some(path) => match read_regular_to_string(&path).await {
Ok(text) => PoetryVirtualenvConfig::from_toml(&text),
Err(_) => PoetryVirtualenvConfig::default(),
},
Expand Down Expand Up @@ -1055,6 +1056,42 @@ mod tests {
use super::*;
use crate::utils::purl::parse_pypi_purl;

#[cfg(unix)]
#[tokio::test]
async fn hatch_discovery_does_not_block_on_fifo_configuration() {
for filename in ["pyproject.toml", "poetry.toml"] {
let directory = tempfile::tempdir().unwrap();
let fifo = directory.path().join(filename);
if filename == "poetry.toml" {
std::fs::write(
directory.path().join("pyproject.toml"),
"[tool.poetry]\nname='hatch-project'\n",
)
.unwrap();
}
assert!(tokio::process::Command::new("mkfifo")
.arg(&fifo)
.status()
.await
.unwrap()
.success());
let result = tokio::time::timeout(
std::time::Duration::from_secs(2),
find_poetry_virtualenv_site_packages(directory.path()),
)
.await;
if result.is_err() {
let release = std::fs::OpenOptions::new()
.read(true)
.write(true)
.open(&fifo)
.unwrap();
drop(release);
}
assert!(result.unwrap().is_empty(), "{filename}");
}
}

// ── Poetry out-of-tree virtualenv discovery ─────────────────────────────

/// Known-answer vectors computed with Poetry's own algorithm
Expand Down
182 changes: 182 additions & 0 deletions crates/socket-patch-core/src/patch/redirect/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,12 @@ pub struct RewriteResult {
/// An incomplete pnpm rewrite must not be confirmed by finding its URL
/// in another instance, a comment, or another lockfile.
pub refused_pnpm_uuids: std::collections::BTreeSet<String>,
pub python_lock_uuids: std::collections::BTreeSet<String>,
pub confirmed_python_lock_uuids: std::collections::BTreeSet<String>,
pub refused_python_lock_uuids: std::collections::BTreeSet<String>,
pub hatch_uuids: std::collections::BTreeSet<String>,
pub confirmed_hatch_uuids: std::collections::BTreeSet<String>,
pub confirmed_requirements_uuids: std::collections::BTreeSet<String>,
}

/// Combined name as it appears in registry coordinates / lock keys.
Expand Down Expand Up @@ -215,6 +221,7 @@ pub fn rewrite_registry_redirect_with_python_metadata(
rewrite_yarn_berry(files, overrides, &mut result);
rewrite_bun_lock(files, overrides, &mut result);
rewrite_pypi_requirements(files, overrides, &mut result);
rewrite_hatch(files, overrides, &mut result);
rewrite_uv_lock(files, overrides, python_metadata, &mut result);
poetry::rewrite_poetry(files, overrides, &mut result);
rewrite_cargo(files, overrides, &mut result);
Expand All @@ -226,6 +233,72 @@ pub fn rewrite_registry_redirect_with_python_metadata(
result
}

fn rewrite_hatch(
files: &BTreeMap<String, String>,
overrides: &[DepOverride],
result: &mut RewriteResult,
) {
if !crate::utils::hatch::is_hatch(files) {
return;
}
result.hatch_uuids.extend(
overrides
.iter()
.filter(|dep| dep.ecosystem == "pypi")
.map(|dep| dep.patch_uuid.clone()),
);
if files.keys().any(|file| {
matches!(
file.as_str(),
"uv.lock" | "poetry.lock" | "pdm.lock" | "Pipfile.lock"
) || crate::utils::python_lock::is_python_lock_name(file)
}) {
return;
}
if files.contains_key("requirements.txt") {
result
.confirmed_hatch_uuids
.extend(result.confirmed_requirements_uuids.iter().cloned());
return;
}
let mut current = files.clone();
for dep in overrides.iter().filter(|dep| dep.ecosystem == "pypi") {
let Some(hash) =
dep.integrity.sha256.as_ref().filter(|hash| {
hash.len() == 64 && hash.bytes().all(|byte| byte.is_ascii_hexdigit())
})
else {
result.warnings.push(RewriteWarning {
code: "redirect_hatch_missing_sha256".into(),
detail: format!("{} has no valid wheel digest", dep.name),
});
continue;
};
let url = format!("{}#sha256={hash}", dep.artifact_url);
match crate::utils::hatch::rewrite(&current, &dep.name, &dep.version, &url) {
Ok(edits) => {
result.confirmed_hatch_uuids.insert(dep.patch_uuid.clone());
for (path, new) in edits {
result.edits.push(FileEdit {
path: path.clone(),
kind: "redirect_hatch_document".into(),
action: "rewritten".into(),
key: Some(format!("{}@{}", dep.name, dep.version)),
original: current.get(&path).cloned().map(Value::String),
new: Some(Value::String(new.clone())),
});
current.insert(path.clone(), new.clone());
result.files.insert(path, new);
}
}
Err(detail) => result.warnings.push(RewriteWarning {
code: "redirect_hatch_unsupported".into(),
detail,
}),
}
}
}

// ── npm package-lock.json / npm-shrinkwrap.json ─────────────────────────────
fn rewrite_npm_lock(
files: &BTreeMap<String, String>,
Expand Down Expand Up @@ -2587,6 +2660,7 @@ fn rewrite_uv_lock(
// missing-integrity warning three times.
let mut usable: Vec<(&DepOverride, &str)> = Vec::new();
for dep in overrides.iter().filter(|dep| dep.ecosystem == "pypi") {
result.python_lock_uuids.insert(dep.patch_uuid.clone());
match dep.integrity.sha256.as_deref() {
Some(sha256) => usable.push((dep, sha256)),
None => result.warnings.push(RewriteWarning {
Expand Down Expand Up @@ -2614,6 +2688,7 @@ fn rewrite_uv_lock(
continue;
}
Err(detail) => {
result.refused_python_lock_uuids.insert(dep.patch_uuid.clone());
result.warnings.push(RewriteWarning {
code: "redirect_uv_lock_unsupported".into(),
detail: format!("{path}: {detail}"),
Expand All @@ -2625,6 +2700,7 @@ fn rewrite_uv_lock(
match plan_python_metadata(path, &content, files, dep, result) {
Ok(plan) => plan,
Err(warning) => {
result.refused_python_lock_uuids.insert(dep.patch_uuid.clone());
result.warnings.push(warning);
continue;
}
Expand All @@ -2639,13 +2715,15 @@ fn rewrite_uv_lock(
) {
Ok(rewritten) => rewritten,
Err(detail) => {
result.refused_python_lock_uuids.insert(dep.patch_uuid.clone());
result.warnings.push(RewriteWarning {
code: "redirect_uv_metadata_unsupported".into(),
detail: format!("{path}: {detail}"),
});
continue;
}
};
result.confirmed_python_lock_uuids.insert(dep.patch_uuid.clone());
if let Some(edit) = metadata_edit {
record_python_metadata_edit(edit, dep, result);
}
Expand Down Expand Up @@ -13132,3 +13210,107 @@ mod python_lock_warning_tests {
);
}
}

#[cfg(test)]
mod hatch_tests {
use super::*;

fn patch() -> DepOverride {
DepOverride {
ecosystem: "pypi".into(),
name: "urllib3".into(),
namespace: None,
version: "1.26.18".into(),
token: String::new(),
patch_uuid: "test-uuid".into(),
artifact_url: "https://patch.test/urllib3-1.26.18-py2.py3-none-any.whl".into(),
berry_zip_url: None,
registry_override: None,
integrity: Integrity {
sha256: Some("a".repeat(64)),
..Default::default()
},
}
}

#[test]
fn hatch_confirmation_ignores_inactive_sources_and_comments() {
let dep = patch();
let files = [
("pyproject.toml".into(), format!("[project]\ndependencies=[]\n[tool.hatch.envs.default]\ndependencies=[\"urllib3 @ {}#sha256={}\"]\n", dep.artifact_url, "a".repeat(64))),
("hatch.toml".into(), format!("[envs.default]\ndependencies=[\"urllib3>=1\"]\n# {}\n", dep.artifact_url)),
].into_iter().collect();
let result = rewrite_registry_redirect(&files, &[dep]);
assert!(result.hatch_uuids.contains("test-uuid"));
assert!(result.confirmed_hatch_uuids.is_empty());
assert!(result.files.is_empty());
assert!(result
.warnings
.iter()
.any(|warning| warning.code == "redirect_hatch_unsupported"));
let mut files = files;
files.insert("requirements.txt".into(), String::new());
let result = rewrite_registry_redirect(&files, &[patch()]);
assert!(result.hatch_uuids.contains("test-uuid"));
assert!(result.confirmed_hatch_uuids.is_empty());
files.insert("requirements.txt".into(), "urllib3==1.26.18\n".into());
let result = rewrite_registry_redirect(&files, &[patch()]);
assert!(result.confirmed_hatch_uuids.contains("test-uuid"));
}

#[test]
fn hatch_confirmation_uses_successful_lock_writers() {
let base: BTreeMap<String, String> = [
("pyproject.toml".into(), format!("[project]\ndependencies=[]\n[tool.hatch.envs.default]\ndependencies=[\"urllib3 @ {}\"]\n", patch().artifact_url)),
("hatch.toml".into(), "[envs.default]\ndependencies=[\"urllib3>=1\"]\n".into()),
].into_iter().collect();
for (filename, text) in [
("uv.lock", "version = 2"),
("pylock.toml", "lock-version = '2.0'"),
("pdm.lock", "[metadata]\nlock_version = '4.5.1'"),
("Pipfile.lock", "{}"),
(
"poetry.lock",
include_str!("../../../tests/fixtures/poetry/0.12.17/poetry.lock"),
),
] {
let mut files = base.clone();
files.insert(filename.into(), text.into());
let result = rewrite_registry_redirect(&files, &[patch()]);
assert!(result.hatch_uuids.contains("test-uuid"), "{filename}");
assert!(result.confirmed_hatch_uuids.is_empty(), "{filename}");
assert!(result.confirmed_python_lock_uuids.is_empty(), "{filename}");
}
let mut files = base;
files.insert(
"poetry.lock".into(),
include_str!("../../../tests/fixtures/poetry/1.0.10/poetry.lock").into(),
);
let result = rewrite_registry_redirect(&files, &[patch()]);
assert!(result.confirmed_python_lock_uuids.contains("test-uuid"));
assert!(result.refused_python_lock_uuids.is_empty());
files.extend(result.files);
let result = rewrite_registry_redirect(&files, &[patch()]);
assert!(result.confirmed_python_lock_uuids.contains("test-uuid"));
assert!(result.files.is_empty());
files.insert("uv.lock".into(), "version = 2".into());
let result = rewrite_registry_redirect(&files, &[patch()]);
assert!(result.refused_python_lock_uuids.contains("test-uuid"));
}

#[test]
fn hatch_confirmation_requires_success_and_reruns_stay_confirmed() {
let files = [(
"pyproject.toml".into(),
"[project]\ndependencies=[\"urllib3==1.26.18\"]\n[tool.hatch.envs.default]\n".into(),
)]
.into_iter()
.collect();
let result = rewrite_registry_redirect(&files, &[patch()]);
assert!(result.confirmed_hatch_uuids.contains("test-uuid"));
let second = rewrite_registry_redirect(&result.files, &[patch()]);
assert!(second.confirmed_hatch_uuids.contains("test-uuid"));
assert!(second.files.is_empty());
}
}

18 changes: 13 additions & 5 deletions crates/socket-patch-core/src/patch/redirect/poetry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ pub(super) fn rewrite_poetry(
// Intake gate ONCE per dep, not once per lock file (uv parity).
let mut usable: Vec<(&DepOverride, &str)> = Vec::new();
for dep in overrides.iter().filter(|dep| dep.ecosystem == "pypi") {
result.python_lock_uuids.insert(dep.patch_uuid.clone());
match dep.integrity.sha256.as_deref() {
Some(sha256) => usable.push((dep, sha256)),
None => result.warnings.push(RewriteWarning {
Expand Down Expand Up @@ -83,13 +84,15 @@ pub(super) fn rewrite_poetry(
}
}
Err(detail) => {
result.refused_python_lock_uuids.insert(dep.patch_uuid.clone());
result.warnings.push(RewriteWarning {
code: "redirect_poetry_lock_unsupported".into(),
detail: format!("{path}: {detail}"),
});
continue;
}
}
result.confirmed_python_lock_uuids.insert(dep.patch_uuid.clone());
content = rewritten;
if !stale_warned {
if let Some(format) = pre_1_4_writer(&content) {
Expand Down Expand Up @@ -122,15 +125,20 @@ pub(super) fn rewrite_poetry(
}
}
// Already redirected to this artifact (idempotent re-scan).
Ok(Some(_)) => {}
Ok(Some(_)) => {
result.confirmed_python_lock_uuids.insert(dep.patch_uuid.clone());
}
Ok(None) => result.warnings.push(RewriteWarning {
code: "redirect_poetry_entry_not_found".into(),
detail: format!("no {path} entry for {}@{}", dep.name, dep.version),
}),
Err(detail) => result.warnings.push(RewriteWarning {
code: "redirect_poetry_lock_unsupported".into(),
detail: format!("{path}: {detail}"),
}),
Err(detail) => {
result.refused_python_lock_uuids.insert(dep.patch_uuid.clone());
result.warnings.push(RewriteWarning {
code: "redirect_poetry_lock_unsupported".into(),
detail: format!("{path}: {detail}"),
});
}
}
}
if content != *original {
Expand Down
Loading
Loading