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
74 changes: 74 additions & 0 deletions crates/socket-patch-core/src/utils/python_lock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -886,6 +886,37 @@ fn plan_python_lock_rewrite(
if pep751 && (package.contains_key("vcs") || package.contains_key("directory")) {
return Ok(None);
}
// An entry resolved from a direct URL (uv.lock `source = { url }`, a
// pylock `archive = { url }`) is the user's own source choice unless it
// is socket-patch's earlier hosted artifact for this release (#767).
let direct_url = if pep751 {
package
.get("archive")
.and_then(Item::as_table_like)
.and_then(|archive| archive.get("url"))
.and_then(Item::as_str)
} else {
// Both spellings: `source = { url = … }` and uv 0.2's string
// `source = "direct+…"`.
UvSource::of(package).and_then(UvSource::url)
};
Comment thread
mikolalysenko marked this conversation as resolved.
if let Some(url) = direct_url {
let ours = match artifact {
ArtifactSource::Url(location) => {
url == location
|| crate::utils::python_script::same_hosted_artifact(
url, location, &name, version,
)
}
ArtifactSource::Path(_) => false,
};
if !ours {
return Err(format!(
"the lock resolves {name}=={version} from the direct reference {url}; refusing \
to overwrite a user-authored source"
));
}
}
let location = artifact.location();
let filename = location
.split(['?', '#'])
Expand Down Expand Up @@ -1220,6 +1251,49 @@ source = { registry = "https://pypi.org/simple" }
wheels = [{ url = "https://pypi.org/urllib3-2.0.0-py3-none-any.whl", hash = "sha256:other" }]
"#;

/// #767: a lock entry resolved from the user's own direct URL (uv.lock
/// `source = { url }`, pylock `archive = { url }`) is refused in both
/// modes; the hosted rewrite's own earlier artifact is still replaced.
#[test]
fn user_direct_url_entries_are_refused() {
const USER: &str =
"https://files.pythonhosted.org/packages/d9/urllib3-1.26.18-py2.py3-none-any.whl";
let uv_lock = format!(
"version = 1\nrequires-python = \">=3.9\"\n\n[[package]]\nname = \"urllib3\"\n\
version = \"1.26.18\"\nsource = {{ url = \"{USER}\" }}\n\
wheels = [{{ url = \"{USER}\", hash = \"sha256:aa\" }}]\n"
);
let pylock = format!(
"lock-version = \"1.0\"\ncreated-by = \"uv\"\n\n[[packages]]\nname = \"urllib3\"\n\
version = \"1.26.18\"\narchive = {{ url = \"{USER}\", hashes = {{ sha256 = \"aa\" }} }}\n"
);
let legacy = format!(
"version = 1\nrequires-python = \">=3.9\"\n\n[[distribution]]\nname = \"urllib3\"\n\
version = \"1.26.18\"\nsource = \"direct+{USER}\"\n\
sdist = {{ url = \"{USER}\", hash = \"sha256:aa\" }}\n"
);
for lock in [&uv_lock, &pylock, &legacy] {
for artifact in [
ArtifactSource::Url(URL),
ArtifactSource::Path(".socket/vendor/pypi/u/urllib3-1.26.18-py2.py3-none-any.whl"),
] {
let err =
rewrite_python_lock(lock, "urllib3", "1.26.18", artifact, SHA256).unwrap_err();
assert!(err.contains("user-authored source"), "{err}");
}
// The hosted rewrite's own artifact (a re-scan) is not the user's.
let ours = lock.replace(USER, URL);
assert!(rewrite_python_lock(
&ours,
"urllib3",
"1.26.18",
ArtifactSource::Url(URL),
SHA256
)
.is_ok());
}
}

#[test]
fn hosted_native_uses_direct_source_and_keeps_other_versions() {
let rewritten = rewrite_python_lock(
Expand Down
106 changes: 104 additions & 2 deletions crates/socket-patch-core/src/utils/python_script.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use toml_edit::{Array, DocumentMut, InlineTable, Item, Table, Value};

use crate::crawlers::python_crawler::canonicalize_pypi_name;
use crate::utils::python_lock::ArtifactSource;
use crate::vendor::common::{pep508_name, pyproject_dependency_specs};
use crate::vendor::common::{is_pep508_direct_reference, pep508_name, pyproject_dependency_specs};

pub(crate) fn script_metadata(text: &str) -> Result<(Range<usize>, String), String> {
let mut offset = 0;
Expand Down Expand Up @@ -65,7 +65,12 @@ fn dependency_name(specifier: &str) -> String {
/// package that `current` may replace: a rotated grant token on the same
/// artifact path, or (the shared PyPI recognizer) a superseding patch uuid
/// for the same name and version on Socket's patch server.
fn same_hosted_artifact(previous: &str, current: &str, name: &str, version: &str) -> bool {
pub(crate) fn same_hosted_artifact(
previous: &str,
current: &str,
name: &str,
version: &str,
) -> bool {
if crate::vendor::lock_inventory::pypi::replaceable_hosted_pin(previous, current, name, version)
{
return true;
Expand Down Expand Up @@ -230,6 +235,28 @@ fn rewrite_sources(
Ok(())
}

/// Refuse when one of `specs` declares `name` (canonical) as a PEP 508
/// direct reference (`six @ https://…`, `six @ git+…`): that is the user's
/// own source, the `[project]` spelling of a `[tool.uv.sources]` entry, and
/// a source added beside it would silently replace it (hosted) or leave a
/// lock uv rejects (vendored) (#767).
fn refuse_direct_reference<'a>(
file: &str,
name: &str,
specs: impl IntoIterator<Item = &'a str>,
) -> Result<(), String> {
match specs
.into_iter()
.find(|spec| dependency_name(spec) == name && is_pep508_direct_reference(spec))
{
Some(spec) => Err(format!(
"{file} declares {name} as the PEP 508 direct reference {spec:?}; refusing to \
overwrite a user-authored source"
)),
None => Ok(()),
}
}

/// The comment the hosted rewrite puts on the line above each
/// `override-dependencies` entry it adds. v5 hosted mode keeps no ledger,
/// so this comment is the only evidence the upstream restore has that the
Expand Down Expand Up @@ -299,6 +326,20 @@ pub fn rewrite_project_metadata(
.and_then(Item::as_table_like)
.and_then(|tool| tool.get("uv"))
.and_then(Item::as_table_like);
let legacy_dev = tool_uv
.and_then(|uv| uv.get("dev-dependencies"))
.and_then(Item::as_array)
.into_iter()
.flatten()
.filter_map(Value::as_str);
refuse_direct_reference(
"pyproject.toml",
&name,
pyproject_dependency_specs(&document)
.into_iter()
.map(|(_, spec)| spec)
.chain(legacy_dev),
)?;
let direct = pyproject_dependency_specs(&document)
.into_iter()
.any(|(_, spec)| dependency_name(spec) == name)
Expand Down Expand Up @@ -337,6 +378,16 @@ pub fn rewrite_script_metadata(
.parse()
.map_err(|error| format!("invalid script metadata: {error}"))?;
let name = canonicalize_pypi_name(name);
refuse_direct_reference(
"the script metadata",
&name,
document
.get("dependencies")
.and_then(Item::as_array)
.into_iter()
.flatten()
.filter_map(Value::as_str),
)?;
let direct = contains_dependency(document.get("dependencies"), &name);
rewrite_sources(
&mut document,
Expand Down Expand Up @@ -367,6 +418,57 @@ mod tests {
assert!(out.contains("# dependencies = [\"a\"]\r\n# ///"), "{out:?}");
}

/// #767: a PEP 508 direct reference to the patched package, wherever
/// the project or script declares it, is the user's own source: the
/// rewrite refuses instead of adding a `[tool.uv.sources]` entry beside it.
#[test]
fn direct_references_to_the_package_are_refused() {
const URL: &str = "https://patch.socket.dev/six-1.16.0-py2.py3-none-any.whl";
const WHEEL: &str =
"https://files.pythonhosted.org/packages/d9/six-1.16.0-py2.py3-none-any.whl";
let head = "[project]\nname = \"app\"\nversion = \"0.1.0\"\n";
for (deps, tail) in [
(format!("[\"six @ {WHEEL}\", \"idna==3.7\"]"), String::new()),
(
"[\"Six[x]@git+https://github.com/benjaminp/six@1.16.0\"]".to_string(),
String::new(),
),
(
"[\"idna==3.7\"]".to_string(),
format!("\n[project.optional-dependencies]\nx = [\"six @ {WHEEL}\"]\n"),
),
(
"[\"idna==3.7\"]".to_string(),
format!("\n[dependency-groups]\ndev = [\"six @ {WHEEL}\"]\n"),
),
(
"[\"idna==3.7\"]".to_string(),
format!("\n[tool.uv]\ndev-dependencies = [\"six @ {WHEEL}\"]\n"),
),
] {
let text = format!("{head}dependencies = {deps}\n{tail}");
let err = rewrite_project_metadata(&text, "six", "1.16.0", ArtifactSource::Url(URL))
.unwrap_err();
assert!(err.contains("PEP 508 direct reference"), "{err}\n{text}");
}
let script =
format!("# /// script\n# dependencies = [\"six @ {WHEEL}\"]\n# ///\nimport six\n");
for artifact in [
ArtifactSource::Url(URL),
ArtifactSource::Path("w/six-1.16.0-py2.py3-none-any.whl"),
] {
let err = rewrite_script_metadata(&script, "six", "1.16.0", artifact).unwrap_err();
assert!(err.contains("PEP 508 direct reference"), "{err}");
}
// Another package's direct reference is not this one's source.
let other = format!("{head}dependencies = [\"six==1.16.0\", \"idna @ {WHEEL}\"]\n");
assert!(
rewrite_project_metadata(&other, "six", "1.16.0", ArtifactSource::Url(URL))
.unwrap()
.is_some()
);
}

#[test]
fn sources_are_paired_without_changing_the_script() {
let script = "#!/usr/bin/env python3\n# /// script\n# dependencies = [\"urllib3==1.26.18\"]\n# ///\nprint('preserved')\n";
Expand Down
21 changes: 21 additions & 0 deletions crates/socket-patch-core/src/vendor/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -609,6 +609,27 @@ pub(crate) fn pep508_name(spec: &str) -> &str {
&s[..end]
}

/// Whether a dependency spec is a PEP 508 direct reference
/// (`name[extras] @ <url>`: an `https://` / `file://` archive, a
/// `git+…` checkout, …) rather than a registry requirement. Such a
/// declaration is the user's own source choice, which neither writer may
/// overwrite (#767).
pub(crate) fn is_pep508_direct_reference(spec: &str) -> bool {
let spec = spec.trim_start();
let name = pep508_name(spec);
if name.is_empty() {
return false;
}
let mut rest = spec[name.len()..].trim_start();
if rest.starts_with('[') {
let Some(end) = rest.find(']') else {
return false;
};
rest = rest[end + 1..].trim_start();
}
rest.starts_with('@')
}

/// The lock's `[[package]]` tables whose `name` canonicalizes (PEP 503) to
/// `canon_name` — the poetry/pdm target-guard probe (uv records names
/// pre-canonicalized and counts them directly instead).
Expand Down
61 changes: 61 additions & 0 deletions crates/socket-patch-core/src/vendor/pypi_uv.rs
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,39 @@ pub(super) fn check_target_guards(
}
}

// A PEP 508 direct reference (`six @ https://…`, `six @ git+…`) in
// `[project]` / `[dependency-groups]` / the legacy `dev-dependencies` is
// the same user-authored source spelled in the requirement itself: a
// `[tool.uv.sources]` path beside it leaves the lock's root requirement
// carrying both `url` and `path`, which `uv sync --locked` rejects
// (#767).
let legacy_dev = p
.pyproject
.get("tool")
.and_then(|t| item_get(t, "uv"))
.and_then(|u| item_get(u, "dev-dependencies"))
.and_then(Item::as_array)
.into_iter()
.flatten()
.filter_map(Value::as_str);
if let Some(spec) = pyproject_dependency_specs(&p.pyproject)
.into_iter()
.map(|(_, spec)| spec)
.chain(legacy_dev)
.find(|spec| {
canonicalize_pypi_name(pep508_name(spec)) == canon_name
&& super::common::is_pep508_direct_reference(spec)
})
{
return Err((
"pypi_uv_source_already_exists",
format!(
"pyproject.toml declares {canon_name} as the PEP 508 direct reference {spec:?}; \
refusing to overwrite a user-authored source"
),
));
}

// A user override pins this package already; layering ours on top would
// change resolution behind the user's back.
if let Some(overrides) = p
Expand Down Expand Up @@ -2786,6 +2819,34 @@ wheels = [
assert_eq!(err.0, "pypi_uv_source_already_exists");
}

/// #767: a PEP 508 direct reference to the target in any declaration
/// table refuses before the wheel is built or anything is written.
#[tokio::test]
async fn guards_refuse_a_direct_reference_declaration() {
const WHEEL: &str = "https://files.pythonhosted.org/packages/d9/5a/e7c31adbe875f2abbb91bd84cf2dc52d792b5a01506781dbcf25c91daf11/six-1.16.0-py2.py3-none-any.whl";
for pyproject in [
DIRECT_REGISTRY_PYPROJECT.replace("\"six==1.16.0\"", &format!("\"six @ {WHEEL}\"")),
DIRECT_REGISTRY_PYPROJECT.replace(
"\"six==1.16.0\"",
"\"six @ git+https://github.com/benjaminp/six@1.16.0\"",
),
format!(
"{}\n[dependency-groups]\ndev = [\"six @ {WHEEL}\"]\n",
DIRECT_REGISTRY_PYPROJECT.replace("[\"six==1.16.0\"]", "[]")
),
format!(
"{}\n[project.optional-dependencies]\nx = [\"six @ {WHEEL}\"]\n",
DIRECT_REGISTRY_PYPROJECT.replace("[\"six==1.16.0\"]", "[]")
),
] {
let tmp = write_pair(&pyproject, DIRECT_REGISTRY_LOCK).await;
let p = load_uv_project(tmp.path()).await.unwrap();
let err = check_target_guards(&p, "six", UUID).unwrap_err();
assert_eq!(err.0, "pypi_uv_source_already_exists", "{pyproject}");
assert!(err.1.contains("PEP 508 direct reference"), "{}", err.1);
}
}

#[tokio::test]
async fn untested_lock_revision_is_a_warning_not_a_refusal() {
let tmp = write_pair(
Expand Down
Loading
Loading