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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,9 @@ limits, and required install commands.
- Python rewrites preserve supported markers, groups, extras, source metadata, and
integrity pins. Relocks, out-of-tree environments, and lock-only VEX are handled
consistently with each installer's supported behavior.
- Hosted Pipenv scans read the `Pipfile`, so a conflicting `Pipfile.lock` entry
refuses the patch project-wide instead of half-redirecting a sibling
`requirements.txt` (#333).
- Vendoring reuses valid committed artifacts during service outages. Updates do
not build from a previous patch's modified bytes. Verified service artifacts
keep their identity; integrity failures do not fall through to a local rebuild.
Expand Down
1 change: 1 addition & 0 deletions crates/socket-patch-cli/src/commands/scan/hosted.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3531,6 +3531,7 @@ mod tests {
"poetry.lock",
"pdm.lock",
"Pipfile.lock",
"Pipfile",
"pyproject.toml",
"hatch.toml",
"Cargo.toml",
Expand Down
67 changes: 67 additions & 0 deletions crates/socket-patch-cli/tests/in_process_redirect_pipenv.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@
//! installer probe would otherwise need a real Pipenv 7–11 on PATH for;
//! * a stale `Pipfile.lock` that does not pin the package does not veto
//! the sibling `requirements.txt` redirect;
//! * a conflicting entry in a live `Pipfile.lock` (a `Pipfile` beside it)
//! vetoes the sibling `requirements.txt` redirect, while the same
//! conflict in an abandoned lock (no `Pipfile`) does not (#333);
//! * a venv still holding the UPSTREAM release is reported stale and kept
//! out of the same-run attestation.
//!
Expand Down Expand Up @@ -473,6 +476,70 @@ async fn stale_pipfile_lock_does_not_veto_the_requirements_redirect() {
assert_eq!(read(&tmp.path().join("Pipfile.lock")), stale);
}

/// The lock entry repointed at the user's own wheel: a `file` source that
/// is not Socket's, which the Pipenv planner refuses as a conflict.
fn lock_with_user_file_source() -> String {
LOCK.replace(
"\"version\": \"==1.26.18\"",
"\"file\": \"wheels/urllib3-1.26.18-py2.py3-none-any.whl\"",
)
}

/// #333: a conflicting entry in a LIVE Pipfile.lock (a Pipfile beside it)
/// means Pipenv never installs the patch, so the patch is refused for the
/// whole project. The hosted scan must therefore see the Pipfile: the
/// sibling requirements.txt stays untouched instead of being
/// half-redirected.
#[tokio::test]
#[serial]
async fn live_pipfile_lock_conflict_vetoes_the_requirements_redirect() {
let _major = MajorGuard::set("2026");
let server = MockServer::start().await;
mock_api(&server).await;
let tmp = tempfile::tempdir().unwrap();
write_project(tmp.path());
let lock = lock_with_user_file_source();
std::fs::write(tmp.path().join("Pipfile.lock"), &lock).unwrap();
const REQS: &str = "urllib3==1.26.18\nrequests==2.31.0\n";
std::fs::write(tmp.path().join("requirements.txt"), REQS).unwrap();

run(hosted_args(tmp.path(), server.uri(), None)).await;
assert_eq!(
read(&tmp.path().join("requirements.txt")),
REQS,
"a live Pipfile.lock conflict must veto the sibling requirements.txt"
);
assert_eq!(read(&tmp.path().join("Pipfile.lock")), lock);
assert_eq!(read(&tmp.path().join("Pipfile")), PIPFILE);
}

/// The same conflict in an ABANDONED lock (no Pipfile beside it) says
/// nothing about the project's install files: the sibling requirements.txt
/// is still redirected.
#[tokio::test]
#[serial]
async fn abandoned_pipfile_lock_conflict_does_not_veto_the_requirements_redirect() {
let _major = MajorGuard::set("2026");
let server = MockServer::start().await;
mock_api(&server).await;
let tmp = tempfile::tempdir().unwrap();
write_project(tmp.path());
std::fs::remove_file(tmp.path().join("Pipfile")).unwrap();
let lock = lock_with_user_file_source();
std::fs::write(tmp.path().join("Pipfile.lock"), &lock).unwrap();
const REQS: &str = "urllib3==1.26.18\nrequests==2.31.0\n";
std::fs::write(tmp.path().join("requirements.txt"), REQS).unwrap();

let code = run(hosted_args(tmp.path(), server.uri(), None)).await;
assert_eq!(code, 0);
let requirements = read(&tmp.path().join("requirements.txt"));
assert!(
requirements.contains(HOSTED_URL),
"an abandoned lock must not veto requirements.txt: {requirements}"
);
assert_eq!(read(&tmp.path().join("Pipfile.lock")), lock);
}

#[tokio::test]
#[serial]
async fn warm_venv_with_the_upstream_release_is_not_attested() {
Expand Down
5 changes: 5 additions & 0 deletions crates/socket-patch-core/src/formats/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,10 @@ const REGISTRY: &[FormatFile] = &[
row("poetry.lock", "pypi", HOSTED | VENDORED | PROBE | ROOT),
row("pdm.lock", "pypi", HOSTED | VENDORED | PROBE | ROOT),
row("Pipfile.lock", "pypi", HOSTED | VENDORED | PROBE | ROOT),
// Never edited — its presence tells the Pipenv planner a Pipfile.lock is
// live (a conflict there vetoes the sibling Python rewriters) rather
// than abandoned.
row("Pipfile", "pypi", HOSTED | PRESENCE_ONLY),
row("pyproject.toml", "pypi", HOSTED | VENDORED | PROBE),
row("hatch.toml", "pypi", HOSTED | PROBE),
// ── cargo ──
Expand Down Expand Up @@ -217,6 +221,7 @@ mod tests {
assert_eq!(hosted_file_ecosystem(".cargo/config"), Some("cargo"));
assert_eq!(hosted_file_ecosystem("checksums.sha256"), Some("maven"));
assert_eq!(hosted_file_ecosystem("build.gradle"), None);
assert_eq!(hosted_file_ecosystem("Pipfile"), None);
assert_eq!(hosted_file_ecosystem("package.json"), None);
assert_eq!(hosted_file_ecosystem("NuGet.Config"), Some("nuget"));
}
Expand Down
59 changes: 58 additions & 1 deletion crates/socket-patch-core/src/hosted/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,22 @@ fn rush_repo(view: &ProjectView<'_>) -> bool {
}
}

/// Whether `rel` is a [`PRESENCE_ONLY`](crate::formats::registry::PRESENCE_ONLY)
/// row the in-memory host lists without usable content (a symbolic link,
/// an oversize or presence-only entry). Its planners only ask whether it
/// exists — the Pipenv planner tells a live `Pipfile.lock` from an
/// abandoned one by the `Pipfile` beside it — so it is recorded as present
/// (empty) rather than dropped. Disk reads such a file through any link.
fn presence_only_present(view: &ProjectView<'_>, rel: &str) -> bool {
let ProjectView::Memory(project) = view else {
return false;
};
project.contains(rel)
&& crate::formats::registry::registry()
.iter()
.any(|f| f.path == rel && f.has(crate::formats::registry::PRESENCE_ONLY))
}

/// Read the project's candidate files: [`REDIRECT_CANDIDATE_FILES`], the
/// Cargo workspace members (when a cargo candidate meets a root
/// `Cargo.toml`), the Python locks and their scripts, and the Rush locks.
Expand All @@ -399,7 +415,9 @@ pub async fn read_candidate_files(
}
continue;
}
out.read(view, unreadable, name).await;
if !out.read(view, unreadable, name).await && presence_only_present(view, name) {
out.files.insert((*name).to_string(), String::new());
}
}

// Cargo workspace members (and in-root path dependencies) declare
Expand Down Expand Up @@ -1556,6 +1574,44 @@ mod tests {
assert!(read.unreadable_reads.is_empty());
}

/// #333: the Pipenv planner keys a live lock on the `Pipfile` beside
/// it, so the candidate reads must carry it — read from disk, and kept
/// as present in memory even when the host has no content for it.
#[tokio::test]
async fn the_pipfile_is_read_for_its_presence() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("Pipfile"), "[packages]\n").unwrap();
std::fs::write(tmp.path().join("Pipfile.lock"), "{}").unwrap();
let read =
read_candidate_files(&ProjectView::Disk(tmp.path()), &BTreeSet::new(), &[]).await;
assert_eq!(
read.files.get("Pipfile").map(String::as_str),
Some("[packages]\n")
);

for entry in [MemoryEntry::Symlink, MemoryEntry::Present] {
let mut p = MemoryProject::new();
p.insert_text("Pipfile.lock", "{}");
p.insert("Pipfile", entry.clone());
let unreadable = match entry {
MemoryEntry::Present => BTreeSet::from(["Pipfile".to_string()]),
_ => BTreeSet::new(),
};
let read = read_candidate_files(&ProjectView::Memory(&p), &unreadable, &[]).await;
assert_eq!(
read.files.get("Pipfile").map(String::as_str),
Some(""),
"{entry:?}"
);
}

// Absent stays absent: a lone Pipfile.lock is abandoned.
let mut p = MemoryProject::new();
p.insert_text("Pipfile.lock", "{}");
let read = read_candidate_files(&ProjectView::Memory(&p), &BTreeSet::new(), &[]).await;
assert!(!read.files.contains_key("Pipfile"));
}

#[test]
fn file_ecosystems_cover_the_rewrite_targets() {
assert_eq!(file_ecosystem("package-lock.json"), Some("npm"));
Expand All @@ -1566,6 +1622,7 @@ mod tests {
assert_eq!(file_ecosystem("tool.py.lock"), Some("pypi"));
assert_eq!(file_ecosystem("crates/a/Cargo.toml"), Some("cargo"));
assert_eq!(file_ecosystem("build.gradle"), None);
assert_eq!(file_ecosystem("Pipfile"), None);
}
}

Expand Down
Loading