diff --git a/crates/socket-patch-cli/tests/e2e_vex_lockfile/pip.rs b/crates/socket-patch-cli/tests/e2e_vex_lockfile/pip.rs index 9552d95fb..c3ee23161 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_lockfile/pip.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_lockfile/pip.rs @@ -341,3 +341,40 @@ fn vendored_extras_pin_is_refused_and_nothing_is_attested() { assert_ne!(out.code, Some(0), "{out}"); assert_no_statement(&out, "vendored extras"); } + +/// #1549: a Pipfile.lock whose entry installs the same release from a +/// non-Socket `file` url or `path` wheel contests the requirements wiring, +/// as a registry `version: "==1.2.3"` entry there already does. +#[test] +fn pipfile_lock_direct_reference_contests_the_requirements_wiring() { + let lock = |entry: serde_json::Value| { + serde_json::json!({ + "_meta": { "pipfile-spec": 6, "hash": { "sha256": "x" }, "requires": {}, "sources": [] }, + "default": { NAME: entry }, + "develop": {}, + }) + .to_string() + }; + let hashes = serde_json::json!([format!("sha256:{}", "c".repeat(64))]); + contested_by_a_non_socket_direct_reference( + &flavors()[..1], + "Pipfile.lock", + &[ + ( + "file url", + lock(serde_json::json!({ "file": upstream_wheel_url(), "hashes": hashes })), + ), + ( + "path wheel", + lock(serde_json::json!({ + "path": format!("./wheels/{NAME}-{VERSION}-py3-none-any.whl"), + "hashes": hashes, + })), + ), + ( + "registry pin (control)", + lock(serde_json::json!({ "version": format!("=={VERSION}"), "hashes": hashes })), + ), + ], + ); +} diff --git a/crates/socket-patch-cli/tests/e2e_vex_lockfile/pipenv.rs b/crates/socket-patch-cli/tests/e2e_vex_lockfile/pipenv.rs index 002db0a5d..f922cfb4e 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_lockfile/pipenv.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_lockfile/pipenv.rs @@ -346,3 +346,24 @@ fn vendored_legacy_installer_is_refused_and_nothing_is_attested() { assert_ne!(out.code, Some(0), "{out}"); assert_no_statement(&out, "legacy vendored"); } + +/// #1549 (mirror case): a requirements.txt line that installs the same +/// release from a non-Socket url or wheel path contests the Pipfile.lock +/// wiring, as a `vexdemo==1.2.3` registry pin there already does. +#[test] +fn requirements_direct_reference_contests_the_lock_wiring() { + let url = upstream_wheel_url(); + contested_by_a_non_socket_direct_reference( + &flavors()[..1], + "requirements.txt", + &[ + ("name @ url", format!("{NAME} @ {url}\n")), + ("bare url", format!("{url}\n")), + ( + "bare wheel path", + format!("./wheels/{NAME}-{VERSION}-py3-none-any.whl\n"), + ), + ("registry pin (control)", format!("{NAME}=={VERSION}\n")), + ], + ); +} diff --git a/crates/socket-patch-cli/tests/vex_pipenv_pip_common/mod.rs b/crates/socket-patch-cli/tests/vex_pipenv_pip_common/mod.rs index 60ddd3d25..524c44296 100644 --- a/crates/socket-patch-cli/tests/vex_pipenv_pip_common/mod.rs +++ b/crates/socket-patch-cli/tests/vex_pipenv_pip_common/mod.rs @@ -1349,3 +1349,53 @@ pub fn embedded_apply_and_vendor_vex_attest_a_manifest_less_checkout(flavors: &[ } } } + +/// #1549: beside the wired lock, ANOTHER install source (`other`, written +/// to `other_rel`) resolves the same release from a non-Socket artifact (a +/// Pipfile.lock `file` url / `path` wheel, a requirements `name @ url` +/// line; a contesting Pipfile.lock gets its Pipfile). Pipenv / pip install +/// that upstream artifact, so the wiring is +/// contested: never attested, `--no-verify` included, and the contest is +/// explained. Without `other` the same checkout attests. +pub fn contested_by_a_non_socket_direct_reference( + flavors: &[Flavor], + other_rel: &str, + others: &[(&str, String)], +) { + for (flavor, mode) in cells(flavors) { + let wired = wired(&flavor, mode); + for (label, other) in others { + let what = format!("{} + {other_rel} {label}", wired.what()); + let (_tmp, cwd) = fresh(); + wired.restore(&cwd, NOTHING); + let api = api_with(mode.uuid(), PURL); + assert_ok_attested(&vex(&cwd, &vex_run(Some(&api))), mode, &what); + put(&cwd, other_rel, other.as_bytes()); + if other_rel == "Pipfile.lock" { + // Pipenv installs from the lock only with its Pipfile. + put(&cwd, "Pipfile", PIPFILE.as_bytes()); + } + for no_verify in [false, true] { + let run = crate::vex_e2e_common::VexRun { + no_verify, + ..vex_run(Some(&api)) + }; + let out = vex(&cwd, &run); + let what = format!("{what} no_verify={no_verify}"); + assert_ne!(out.code, Some(0), "{what}: {out}"); + assert_no_statement(&out, &what); + let text = out.stdout.clone() + &out.stderr; + assert!( + text.contains("patched_ref_unattributable") + && text.contains(&format!("but {other_rel} resolves the same version")), + "{what}: the contested wiring must be explained: {out}" + ); + } + } + } +} + +/// The upstream wheel of the fictitious package on a non-Socket host. +pub fn upstream_wheel_url() -> String { + format!("https://files.pythonhosted.org/packages/ab/cd/{NAME}-{VERSION}-py3-none-any.whl") +} diff --git a/crates/socket-patch-core/src/vex/discover/mod.rs b/crates/socket-patch-core/src/vex/discover/mod.rs index ec1c7b265..fe2c998c9 100644 --- a/crates/socket-patch-core/src/vex/discover/mod.rs +++ b/crates/socket-patch-core/src/vex/discover/mod.rs @@ -453,12 +453,36 @@ impl UnlockedPin { /// [`Discovery::resolved_elsewhere`]). #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] pub struct ResolvedElsewhere { - /// Canonical base purl ([`canonical_base_purl`]). + /// Canonical base purl ([`canonical_base_purl`]). Versionless + /// (`pkg:pypi/six`) for an entry whose version can't be read: it may + /// resolve any version of that package + /// ([`Discovery::resolved_elsewhere_any_version`]). pub purl: String, /// Root-relative lock file. pub file: PathBuf, } +impl ResolvedElsewhere { + /// Whether this entry resolves the canonical base `purl`: the same + /// `name@version`, or any version of `name` for a versionless entry. + fn covers(&self, purl: &str) -> bool { + self.purl == purl || purl_without_version(purl) == Some(self.purl.as_str()) + } + + /// Whether the entry names no version, so may resolve any. + fn any_version(&self) -> bool { + purl_without_version(&self.purl).is_none() + } +} + +/// `pkg:/` of a versioned `pkg:/@`; +/// `None` for a purl with no version (an npm scope's `@` is part of the +/// name). +fn purl_without_version(purl: &str) -> Option<&str> { + let (head, _) = purl.rsplit_once('@')?; + (head.contains('/') && !head.ends_with('/')).then_some(head) +} + /// A lock entry that installs its OWN copy of a package — a `file:` /// directory or tarball, a user url, git, a registry block no Socket rewrite /// reached — beside a Socket wiring of the same `name@version` in the SAME @@ -901,6 +925,24 @@ impl Discovery { }); } + /// Record that lock `file` resolves the package `name_purl` + /// (`pkg:/`, no version; `None` is ignored) from a + /// non-Socket source whose version can't be read: a source directory, a + /// VCS checkout, an artifact named for no release. It may install any + /// version, so it is [`Discovery::resolved_elsewhere`] evidence against + /// every version of that package (#1549). + pub(crate) fn resolved_elsewhere_any_version(&mut self, file: &str, name_purl: Option) { + let Some(name_purl) = name_purl else { return }; + let purl = canonical_base_purl(&name_purl); + if purl_without_version(&purl).is_some() { + return; + } + self.elsewhere.push(ResolvedElsewhere { + purl, + file: PathBuf::from(file), + }); + } + /// Record that lock `file`'s entry `key` installs its own copy of `purl` /// (`None` is ignored), `how` saying from where. The cross-lock contest /// only weighs OTHER locks (a lock that wires a package is never its @@ -1051,14 +1093,14 @@ impl Discovery { let tree = self.tree_of(&r.source_file); self.elsewhere.iter().find(|e| { let other = self.tree_of(&e.file); - e.purl == r.purl + e.covers(&r.purl) && other != tree - && !wiring.contains(&(e.purl.clone(), other.to_path_buf())) + && !wiring.contains(&(r.purl.clone(), other.to_path_buf())) }) }) .flatten(); match other { - Some(e) => contested.push((r, e.file.clone())), + Some(e) => contested.push((r, e.clone())), None => self.refs.push(r), } } @@ -1068,21 +1110,25 @@ impl Discovery { uuid: r.uuid.clone(), mode: r.mode, file: r.source_file.clone(), - other: other.clone(), + other: other.file.clone(), }); let file = r.source_file.to_string_lossy().into_owned(); + let resolves = if other.any_version() { + "resolves the package, at a version it doesn't name, from elsewhere" + } else { + "resolves the same version from elsewhere" + }; self.diag( DIAG_REF_UNATTRIBUTABLE, &file, format!( - "{file}: {} is wired to Socket patch {}, but {} resolves the same version \ - from elsewhere (not a Socket patch) — which lock the build installs from \ - depends on the package manager that runs, so the patch is not attested; \ - rewire both locks (re-run `socket-patch scan` / `vendor`) or delete the \ - stale one", + "{file}: {} is wired to Socket patch {}, but {} {resolves} (not a Socket \ + patch) — which lock the build installs from depends on the package \ + manager that runs, so the patch is not attested; rewire both locks \ + (re-run `socket-patch scan` / `vendor`) or delete the stale one", r.purl, r.uuid, - other.display() + other.file.display() ), ); self.withhold_rewirable(r); diff --git a/crates/socket-patch-core/src/vex/discover/pypi_other.rs b/crates/socket-patch-core/src/vex/discover/pypi_other.rs index e9dee4f2f..c097ad022 100644 --- a/crates/socket-patch-core/src/vex/discover/pypi_other.rs +++ b/crates/socket-patch-core/src/vex/discover/pypi_other.rs @@ -29,8 +29,14 @@ //! //! Anything else Socket-SHAPED (a `.socket/vendor/` path that escapes the //! root or is not a pypi wheel, a patch-server url with no patch uuid) is a -//! [`DIAG_REF_INVALID`]; registry pins and users' own urls/paths are -//! skipped silently. +//! [`DIAG_REF_INVALID`]. Registry pins and, in `Pipfile.lock` and the +//! requirements files, users' own urls / paths / VCS checkouts wire +//! nothing: they are evidence that the lock installs the package from +//! elsewhere ([`Discovery::resolved_elsewhere`]), which contests another +//! lock's wiring of the same release (#1549). Such a reference's version +//! is an exact `version` pin beside it or its artifact's own wheel / sdist +//! filename; one with neither may install any version +//! ([`Discovery::resolved_elsewhere_any_version`]). //! //! ## Pipfile.lock //! @@ -118,6 +124,7 @@ use crate::vendor::common::DeclTable; use crate::vendor::lock_inventory::pypi::{hosted_artifact_url, parse_pipfile_lock}; use crate::vendor::lock_inventory::{pipfile_lock_entries, LockIntegrity}; +const PIPFILE: &str = "Pipfile"; const PIPFILE_LOCK: &str = "Pipfile.lock"; const ROOT_REQUIREMENTS: &str = "requirements.txt"; const PYPROJECT: &str = "pyproject.toml"; @@ -163,14 +170,24 @@ async fn extract_pipfile_lock(ctx: &DiscoverCtx<'_>, out: &mut Discovery) { ); return; }; + // Pipenv installs from the lock only with its Pipfile beside it. A + // user's own reference in an abandoned lock (no Pipfile) installs + // nothing, so it contests no other lock's wiring (#333, #1549); the + // hosted planner leaves such an entry alone. + let live = ctx.exists(PIPFILE).await; for pkg in entries { let (category, name) = (pkg.category, pkg.name); let references = pkg.references(); let location = match references.as_slice() { [] => { - // A registry pin (`version: "==X"`, no VCS / editable - // source): evidence against another lock's wiring. - if let (false, Some(version)) = (pkg.is_vcs(), pkg.exact_pin()) { + // A registry pin (`version: "==X"`): evidence against + // another lock's wiring. A VCS / editable source names no + // release, so it may be any version (#1549). + if pkg.is_vcs() { + if live { + out.resolved_elsewhere_any_version(PIPFILE_LOCK, pypi_name_purl(name)); + } + } else if let Some(version) = pkg.exact_pin() { out.resolved_elsewhere(PIPFILE_LOCK, pypi_purl(name, version)); } continue; @@ -208,6 +225,7 @@ async fn extract_pipfile_lock(ctx: &DiscoverCtx<'_>, out: &mut Discovery) { pinned_version: pkg.exact_pin(), tag: None, root_uri: false, + foreign_is_evidence: live, }, location, integrity_of(location, hashes), @@ -251,7 +269,19 @@ async fn extract_requirements(ctx: &DiscoverCtx<'_>, out: &mut Discovery) { fn requirements_line(ctx: &DiscoverCtx<'_>, file: &str, line: &str, out: &mut Discovery) { let (code, comment) = split_comment(line); let code = code.trim(); - // Blank lines, comments, and option lines (`-r`, `--index-url`, `-e`). + if let Some(location) = editable_location(code) { + // pip installs an editable source checkout of whatever package it + // holds, never a Socket artifact: evidence for the package its + // `#egg=` names, at any version (#1549). + if !mentions_vendor_dir(location) { + if let Some(name) = egg_name(location) { + out.resolved_elsewhere_any_version(file, pypi_name_purl(name)); + } + } + return; + } + // Blank lines, comments, and the other option lines (`-r`, + // `--index-url`). if code.is_empty() || code.starts_with('-') { return; } @@ -270,6 +300,7 @@ fn requirements_line(ctx: &DiscoverCtx<'_>, file: &str, line: &str, out: &mut Di pinned_version: None, tag, root_uri: false, + foreign_is_evidence: true, }, location, integrity_of(location, hash_options(code)), @@ -277,6 +308,40 @@ fn requirements_line(ctx: &DiscoverCtx<'_>, file: &str, line: &str, out: &mut Di ); } +/// The location of an editable requirement (`-e `, `-e`, +/// `--editable `, `--editable=`), as pip's option parser reads it. +fn editable_location(code: &str) -> Option<&str> { + let rest = if let Some(rest) = code.strip_prefix("--editable") { + match rest.strip_prefix('=') { + Some(rest) => rest, + None if rest.starts_with(char::is_whitespace) => rest, + None => return None, + } + } else { + // A short option takes its value attached or after a space. + let rest = code.strip_prefix("-e")?; + rest.strip_prefix('=').unwrap_or(rest) + }; + rest.split_whitespace().next() +} + +/// The package a VCS / url requirement names in its `#egg=` fragment. +/// A legacy `#egg=-` fragment names the package before its +/// first `-`, as setuptools and older pip read it. +fn egg_name(location: &str) -> Option<&str> { + let (_, fragment) = location.split_once('#')?; + let egg = fragment.split('&').find_map(|p| p.strip_prefix("egg="))?; + let end = egg + .find(|c: char| !(c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))) + .unwrap_or(egg.len()); + let egg = &egg[..end]; + let end = egg + .match_indices('-') + .find(|(i, _)| egg[i + 1..].starts_with(|c: char| c.is_ascii_digit())) + .map_or(egg.len(), |(i, _)| i); + Some(&egg[..end]).filter(|name| !name.is_empty()) +} + /// The `pypi_purl` of an exact `name[extras]==X` registry requirement /// ([`exact_pin`]). fn registry_pin(code: &str) -> Option { @@ -384,6 +449,7 @@ fn hatch_spec( pinned_version: None, tag: None, root_uri: expanded, + foreign_is_evidence: false, }, location, integrity_of(location, Vec::new()), @@ -408,6 +474,12 @@ struct Entry<'a> { /// URI, so a `#sha256=…` after the path is a fragment, not part of the /// file name. root_uri: bool, + /// A non-Socket reference here installs the package from elsewhere, + /// evidence against another lock's wiring ([`foreign_reference`]): + /// `Pipfile.lock` and requirements files, which install exactly what + /// they reference. Hatch's dependency tables are declarations a + /// resolver reads, not an install source of their own. + foreign_is_evidence: bool, } impl Entry<'_> { @@ -469,7 +541,12 @@ fn emit( ) { let uri = entry.root_uri || url_form(location); let result = match classify(ctx, location, uri) { - Ok(None) => return, + Ok(None) => { + if entry.foreign_is_evidence { + foreign_reference(entry, location, out); + } + return; + } Ok(Some(Wired::Hosted(uuid))) => hosted_ref(entry, location, uuid, integrity), Ok(Some(Wired::Vendored(vref))) => vendored_ref(entry, &vref, integrity), Err(detail) => Err(detail), @@ -484,6 +561,57 @@ fn emit( } } +/// Record a user's own (non-Socket) reference `location` as evidence that +/// `entry`'s file installs its package from elsewhere (#1549): pip and +/// Pipenv install that artifact, unpatched, whichever lock wires the same +/// release to a Socket patch. The release is the artifact's wheel / sdist +/// filename (when it names the declared package), else an exact `version` +/// pin beside the reference. With neither, it may be any version. A bare +/// requirements line names its package only through its filename or an +/// `#egg=` fragment, so one with neither (a source directory) is left out. +fn foreign_reference(entry: &Entry<'_>, location: &str, out: &mut Discovery) { + let leaf = artifact_leaf(location).and_then(|leaf| vendored_leaf_purl("pypi", &leaf)); + let leaf = leaf + .as_deref() + .and_then(|purl| purl.strip_prefix("pkg:pypi/")) + .and_then(|nv| nv.rsplit_once('@')); + let Some(name) = entry + .name + .or(leaf.map(|(name, _)| name)) + .or_else(|| egg_name(location)) + else { + return; + }; + let version = leaf + .filter(|(leaf_name, _)| *leaf_name == canonicalize_pypi_name(name)) + .map(|(_, version)| version) + .or_else(|| { + entry + .pinned_version + .filter(|v| !v.is_empty() && !v.contains('*')) + }); + match version { + Some(version) => out.resolved_elsewhere(entry.file, pypi_purl(name, version)), + None => out.resolved_elsewhere_any_version(entry.file, pypi_name_purl(name)), + } +} + +/// The percent-decoded last path segment of a url or path, its `#` +/// fragment and `?` query cut: the artifact's filename. +fn artifact_leaf(location: &str) -> Option { + let path = location.split(['#', '?']).next()?; + let leaf = path.rsplit(['/', '\\']).next()?; + (!leaf.is_empty()).then(|| crate::utils::purl::percent_decode_purl_component(leaf).into_owned()) +} + +/// `pkg:pypi/` (PEP 503-canonical, no version) for +/// [`Discovery::resolved_elsewhere_any_version`]; `None` for a name a purl +/// can't safely carry. +fn pypi_name_purl(name: &str) -> Option { + let name = canonicalize_pypi_name(name); + crate::patch::path_safety::is_safe_single_segment(&name).then(|| format!("pkg:pypi/{name}")) +} + fn hosted_ref( entry: &Entry<'_>, url: &str, @@ -1527,4 +1655,240 @@ mod tests { .all(|r| r.purl == "pkg:pypi/urllib3@1.26.18" && r.uuid == UUID_A)); assert!(out.diagnostics.is_empty(), "{:?}", out.diagnostics); } + + /// REGRESSION (#1549): a Pipfile.lock `file` url or `path` wheel, or a + /// requirements `name @ ` line, that installs the same release + /// from a non-Socket artifact contests another lock's wiring exactly + /// like a registry `==` pin does: Pipenv / pip install the upstream + /// artifact, so the ref is withheld (`patched_ref_unattributable`, + /// naming both files) instead of attested. The version comes from an + /// exact `version` pin or the artifact's own wheel / sdist filename. + #[tokio::test] + async fn issue_1549_non_socket_direct_references_contest_wiring() { + const WHL: &str = "six-1.16.0-py2.py3-none-any.whl"; + let upstream = format!("https://files.pythonhosted.org/packages/d9/5a/{WHL}"); + let vendored = vendored_wheel(UUID_A, WHL); + let vendored_requirements = + format!("./{vendored} --hash=sha256:{SHA} # socket-patch vendor: six==1.16.0\n"); + let vendored_pipfile = pipfile_lock(serde_json::json!({ "default": { + "six": { "file": format!("./{vendored}"), "hashes": [format!("sha256:{SHA}")] }, + }})); + let pipfile = |entry: serde_json::Value| { + pipfile_lock(serde_json::json!({ "default": { "six": entry } })) + }; + type Case = (&'static str, (&'static str, String), (&'static str, String)); + let cases: Vec = vec![ + ( + "Pipfile.lock file url", + ("requirements.txt", vendored_requirements.clone()), + ( + "Pipfile.lock", + pipfile(serde_json::json!({ + "file": upstream, "hashes": [format!("sha256:{SHA_B}")], + })), + ), + ), + ( + "Pipfile.lock path wheel", + ("requirements.txt", vendored_requirements.clone()), + ( + "Pipfile.lock", + pipfile(serde_json::json!({ + "path": format!("./wheels/{WHL}"), "hashes": [format!("sha256:{SHA_B}")], + })), + ), + ), + ( + "Pipfile.lock file:// url", + ("requirements.txt", vendored_requirements.clone()), + ( + "Pipfile.lock", + pipfile(serde_json::json!({ "file": format!("file:///srv/wheels/{WHL}") })), + ), + ), + ( + "Pipfile.lock wheel beside a wildcard version", + ("requirements.txt", vendored_requirements.clone()), + ( + "Pipfile.lock", + pipfile(serde_json::json!({ "file": upstream, "version": "==1.16.*" })), + ), + ), + ( + "Pipfile.lock path with an exact version", + ("requirements.txt", vendored_requirements.clone()), + ( + "Pipfile.lock", + pipfile(serde_json::json!({ "path": "./src/six", "version": "==1.16.0" })), + ), + ), + ( + "requirements name @ url (mirror)", + ("Pipfile.lock", vendored_pipfile.clone()), + ( + "requirements.txt", + format!("six @ {upstream} --hash=sha256:{SHA_B}\n"), + ), + ), + ( + "requirements bare url line", + ("Pipfile.lock", vendored_pipfile.clone()), + ("requirements.txt", format!("{upstream}#sha256={SHA_B}\n")), + ), + ( + "requirements bare path line", + ("Pipfile.lock", vendored_pipfile.clone()), + ("requirements.txt", format!("./wheels/{WHL}\n")), + ), + ]; + for (name, wired, other) in cases { + let p = Project::new(); + p.write(wired.0, &wired.1) + .write(other.0, &other.1) + .write("Pipfile", "[packages]\nsix = \"*\"\n"); + let out = p.discover().await; + assert!(out.refs.is_empty(), "{name}: {:#?}", out.refs); + assert!( + out.diagnostics + .iter() + .any(|d| d.code == DIAG_REF_UNATTRIBUTABLE + && d.file == std::path::Path::new(wired.0) + && d.detail.contains(UUID_A) + && d.detail.contains(other.0)), + "{name}: {:#?}", + out.diagnostics + ); + let c = out + .vendored_contest("pkg:pypi/six@1.16.0", UUID_A) + .expect(name); + assert_eq!(c.other, std::path::Path::new(other.0), "{name}"); + + // Without the contesting file the same wiring is a ref. + let alone = Project::new(); + alone.write(wired.0, &wired.1); + assert_eq!(alone.discover().await.refs.len(), 1, "{name} alone"); + } + } + + /// REGRESSION (#1549): a non-Socket direct reference whose version + /// can't be read (a source directory, a VCS url, an artifact named + /// for no release) may install any version of its package, so it + /// contests another lock's wiring of that package at every version; + /// one of ANOTHER package, or of another release, contests nothing. + #[tokio::test] + async fn issue_1549_unversioned_direct_references_contest_every_version() { + const WHL: &str = "six-1.16.0-py2.py3-none-any.whl"; + let vendored = vendored_wheel(UUID_A, WHL); + let vendored_requirements = + format!("./{vendored} --hash=sha256:{SHA} # socket-patch vendor: six==1.16.0\n"); + let pipfile = |entry: serde_json::Value| { + pipfile_lock(serde_json::json!({ "default": { "six": entry } })) + }; + for (name, other) in [ + ( + "path dir", + pipfile(serde_json::json!({ "path": "./src/six" })), + ), + ( + "file sdist url with no version", + pipfile(serde_json::json!({ "file": "https://example.com/dl/six.zip" })), + ), + ( + "vcs entry", + pipfile(serde_json::json!({ + "git": "https://github.com/benjaminp/six.git", "ref": "abc123", + })), + ), + ] { + let p = Project::new(); + p.write("requirements.txt", &vendored_requirements) + .write("Pipfile.lock", &other) + .write("Pipfile", "[packages]\nsix = \"*\"\n"); + let out = p.discover().await; + assert!(out.refs.is_empty(), "{name}: {:#?}", out.refs); + assert!( + out.vendored_contest("pkg:pypi/six@1.16.0", UUID_A) + .is_some(), + "{name}: {:#?}", + out.diagnostics + ); + } + for (name, line) in [ + ( + "vcs requirement", + "six @ git+https://github.com/benjaminp/six.git@abc123\n", + ), + ("directory requirement", "six @ file:///srv/src/six\n"), + ( + "bare vcs line named by #egg", + "git+https://github.com/benjaminp/six.git@1.16.0#egg=six\n", + ), + ( + "editable vcs", + "-e git+https://github.com/benjaminp/six.git#egg=six\n", + ), + ("editable dir", "--editable=./src/six#egg=Six\n"), + ("editable dir, long flag", "--editable ./src/six#egg=six\n"), + ("editable dir, attached short flag", "-e./src/six#egg=six\n"), + ( + "legacy name-version egg", + "-e git+https://github.com/benjaminp/six.git#egg=six-1.16.0\n", + ), + ] { + let p = Project::new(); + p.write( + "Pipfile.lock", + pipfile(serde_json::json!({ + "file": format!("./{vendored}"), "hashes": [format!("sha256:{SHA}")], + })), + ) + .write("requirements.txt", line); + let out = p.discover().await; + assert!(out.refs.is_empty(), "{name}: {:#?}", out.refs); + assert!( + out.vendored_contest("pkg:pypi/six@1.16.0", UUID_A) + .is_some(), + "{name}" + ); + } + + // Another package, or another release of this one, leaves the + // wiring alone. + for (name, other) in [ + ( + "other package dir", + pipfile_lock(serde_json::json!({ "default": { + "idna": { "path": "./src/idna" }, + }})), + ), + ( + "other release wheel", + pipfile(serde_json::json!({ + "file": "https://files.pythonhosted.org/packages/six-1.15.0-py2.py3-none-any.whl", + })), + ), + ] { + let p = Project::new(); + p.write("requirements.txt", &vendored_requirements) + .write("Pipfile.lock", &other) + .write("Pipfile", "[packages]\nsix = \"*\"\n"); + let out = p.discover().await; + assert_eq!(out.refs.len(), 1, "{name}: {:#?}", out.diagnostics); + } + + // #333: with no Pipfile beside it, Pipenv installs nothing from the + // lock, so a user's own reference there contests nothing. + for other in [ + pipfile(serde_json::json!({ + "file": "https://files.pythonhosted.org/packages/six-1.16.0-py2.py3-none-any.whl", + })), + pipfile(serde_json::json!({ "git": "https://github.com/benjaminp/six.git" })), + ] { + let p = Project::new(); + p.write("requirements.txt", &vendored_requirements) + .write("Pipfile.lock", &other); + let out = p.discover().await; + assert_eq!(out.refs.len(), 1, "abandoned: {:#?}", out.diagnostics); + } + } }