Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
6f38184
fix(redirect): pin and revert each cargo version separately
mikolalysenko Sep 24, 2026
f1042c3
fix(redirect): restore a cargo config's bytes on hosted remove
mikolalysenko Sep 24, 2026
113a894
fix(redirect): pin cargo workspace members' own declarations
mikolalysenko Sep 24, 2026
11273e4
test(redirect): attest every cargo shape post-install
mikolalysenko Sep 24, 2026
582a5f0
fix(redirect): redirect CRLF cargo projects in hosted mode
mikolalysenko Sep 24, 2026
082d422
fix(redirect): say why a transitive-only crate is not redirected
mikolalysenko Sep 24, 2026
8cb05fa
test(redirect): run the cargo shape suite in every lock format
mikolalysenko Sep 24, 2026
c97d77c
fix(redirect): refuse a cargo crate other crates depend on
mikolalysenko Sep 24, 2026
2d02c85
fix(redirect): never follow a symlink to a cargo member
mikolalysenko Sep 24, 2026
c6a2c23
fix(redirect): remove v1-lock cargo patches in any order
mikolalysenko Sep 24, 2026
33dbd88
fix(redirect): restore a cargo config's exact trailing bytes
mikolalysenko Sep 24, 2026
57267d5
fix(redirect): remove cargo patches across line-ending changes
mikolalysenko Sep 24, 2026
1ca3226
fix(redirect): remove each cargo version from older ledgers
mikolalysenko Sep 24, 2026
033a595
fix(redirect): keep identical hosted edits from one scan
mikolalysenko Sep 24, 2026
5949248
docs: describe the hosted cargo redirect fixes
mikolalysenko Sep 24, 2026
920767c
fix(redirect): refuse cargo requirements the patch misses
mikolalysenko Sep 24, 2026
8e0410b
fix(vendor): see a hosted pin in a workspace member
mikolalysenko Sep 25, 2026
4355140
fix(redirect): repoint a v1 lock's [root] table
mikolalysenko Sep 25, 2026
722cf8e
fix(redirect): spend one cargo edit per occurrence
mikolalysenko Sep 25, 2026
827aa2a
fix(redirect): never pin a manifest under target/
mikolalysenko Sep 25, 2026
2677e99
fix(redirect): refuse a lockless cargo pin
mikolalysenko Sep 25, 2026
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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1455,7 +1455,7 @@ jobs:
SOCKET_PATCH_CARGO_E2E_LOCK_VERSION: ${{ matrix.lock }}
run: |
set -euo pipefail
cargo test -p socket-patch-cli --test e2e_redirect_cargo_build --test e2e_vendor_cargo_build --test mode_migration_cargo
cargo test -p socket-patch-cli --test e2e_redirect_cargo_build --test e2e_redirect_cargo_shapes --test e2e_vendor_cargo_build --test mode_migration_cargo
cargo test -p socket-patch-cli --test e2e_safety_cargo_build -- --ignored

# Manifest `[patch]` + the tagged detached lock (the v5 vendored cargo
Expand Down
35 changes: 35 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -967,6 +967,41 @@ into the new version's section — see docs/releasing.md.
vendored rewrites now follow the `[metadata]` checksum table and rewrite
dependents' full-id references, so `cargo --locked` accepts the lock (and
the revert stays byte-identical).
- **Hosted cargo pins every declaration of the patched version.** Each
version of a multi-version crate is pinned only in the declarations whose
requirement selects it (a requirement matching several locked versions is
refused `redirect_cargo_toml_dep_unrewritable`), and workspace-member and
in-root path-dependency manifests are pinned beside the root, so
`cargo --locked` accepts the redirected lock. Member discovery never
follows a symbolic link, so nothing outside the project is rewritten.
- **Hosted cargo refuses crates a pin cannot reach.** A crate another
`Cargo.lock` package also depends on (a crates.io or git crate, or a path
package outside the project) now warns
`redirect_cargo_transitive_dependents` and is skipped instead of being
reported redirected while that package compiled the unpatched copy; a
transitive-only crate's `redirect_cargo_toml_dep_not_found` detail now
says so and points to `--mode vendored`. A crate declared only with
requirements the patched version does not satisfy (cargo resolves those
declarations to another version) is refused
`redirect_cargo_toml_dep_unrewritable`, the Socket backend's code for the
same shape, instead of `redirect_cargo_toml_dep_not_found`. A project with
NO `Cargo.lock` has no resolved graph to ask, so a crate declared beside
any other dependency — anything but a path dependency on a manifest the
same run pins — or beside a workspace member this run did not read (a
glob, a member outside the project or behind a symbolic link) is refused
`redirect_cargo_lockless_dependents` (commit a lockfile, or use `--mode
vendored`); a project whose only dependency is the patched crate has
nothing that could pull it in and still redirects.
- **CRLF cargo projects redirect in hosted mode.** All-CRLF `Cargo.toml`,
`Cargo.lock` and cargo configs are rewritten with their endings kept
(they were refused), and `remove` / rollback still find the recorded
edits after a checkout converts the line endings.
- **Hosted cargo `remove` restores every byte, in any order.** An appended
registry block leaves the user's config exactly as it was (trailing blank
lines or a missing final newline included) and a created config is
deleted with the last block; v1-lock and multi-version patches, ledgers
written by older CLIs included, can be removed in any order; and a crate
declared with the same line in two sections gets both pins reverted.
- **yarn 4.0.x checksums keep the lock's own spelling.** Vendored and hosted
berry rewrites write bare-hex `cacheKey: 10c0` checksums when the lock
does, so `yarn install --immutable` no longer fails with YN0028.
Expand Down
2 changes: 1 addition & 1 deletion crates/socket-patch-cli/CLI_CONTRACT.md

Large diffs are not rendered by default.

22 changes: 21 additions & 1 deletion crates/socket-patch-cli/src/commands/scan/hosted.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1709,6 +1709,19 @@ pub(crate) async fn run_redirect_selected(
}
}

// Cargo workspace members (and in-root path dependencies) declare
// dependencies of their own: a member's direct `cfg-if = "1"` must
// be pinned alongside the root's, or the redirected lock entry is
// unsatisfiable. Keyed `<dir>/Cargo.toml` for the cargo rewriter.
if files.contains_key("Cargo.toml") && candidates.iter().any(|c| c.dep.ecosystem == "cargo")
{
for rel in socket_patch_core::utils::cargo_workspace::member_manifests(&common.cwd) {
if let Ok(content) = read_regular_to_string(&common.cwd.join(&rel)).await {
files.insert(rel, content);
}
}
}

if let Ok(paths) = socket_patch_core::utils::python_lock::python_lock_paths(&common.cwd) {
for path in paths {
if let Some(script_path) =
Expand Down Expand Up @@ -2579,6 +2592,13 @@ pub(crate) async fn run_redirect_selected(
}
}
}
// Dedup against the ledger as this run found it, never within
// this run: one run legitimately records identical edits (a
// Cargo.toml declaring the crate with the same line in two
// sections), and each one reverts one occurrence — collapsing
// them made `remove` leave the second pin (and its registry
// block) in place while reporting success.
let recorded = ledger.edits.len();
for edit in &rewrite.edits {
let is_rebased = REBASE_KINDS.contains(&edit.kind.as_str())
&& rebased.iter().any(|&t| {
Expand All @@ -2588,7 +2608,7 @@ pub(crate) async fn run_redirect_selected(
&& old.key == edit.key
&& old.new == edit.new
});
if !is_rebased && !ledger.edits.contains(edit) {
if !is_rebased && !ledger.edits[..recorded].contains(edit) {
ledger.edits.push(edit.clone());
}
}
Expand Down
81 changes: 67 additions & 14 deletions crates/socket-patch-cli/tests/cargo_e2e_matrix/mod.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
//! Toolchain / Cargo.lock-format knobs shared by the real-cargo e2e suites
//! (`e2e_redirect_cargo_build`, `e2e_vendor_cargo_build`,
//! `mode_migration_cargo`, `e2e_safety_cargo_build`), so one local loop (or
//! (`e2e_redirect_cargo_build`, `e2e_redirect_cargo_shapes`,
//! `e2e_vendor_cargo_build`, `mode_migration_cargo`,
//! `e2e_safety_cargo_build`), so one local loop (or
//! one CI matrix leg per cell) drives every hosted + vendored flow through a
//! given cargo release and lock format:
//!
Expand Down Expand Up @@ -28,6 +29,7 @@
//! SOCKET_PATCH_CARGO_E2E_REQUIRED=1 SOCKET_PATCH_CARGO_E2E_TOOLCHAIN=$tc \
//! SOCKET_PATCH_CARGO_E2E_LOCK_VERSION=$lv \
//! cargo test -p socket-patch-cli --test e2e_redirect_cargo_build \
//! --test e2e_redirect_cargo_shapes \
//! --test e2e_vendor_cargo_build --test mode_migration_cargo
//! done; done
//! ```
Expand Down Expand Up @@ -145,10 +147,21 @@ pub struct LockPackage {
pub version: String,
pub source: Option<String>,
pub checksum: Option<String>,
/// Dependency NAMES (the fixtures never lock two versions of a crate).
/// Dependency package ids (`name version`, plus ` (source)` for a
/// sourced package), resolved from whichever short form the lock used —
/// so two locked versions of one crate stay distinct across formats.
pub dependencies: Vec<String>,
}

impl LockPackage {
fn id(&self) -> String {
match &self.source {
Some(src) => format!("{} {} ({src})", self.name, self.version),
None => format!("{} {}", self.name, self.version),
}
}
}

/// The lock format of `text`: `version = N` (3 / 4), else v1 when it has
/// a `[metadata]` table or `"name version (source)"` references, else v2.
pub fn lock_format(text: &str) -> u8 {
Expand Down Expand Up @@ -208,8 +221,7 @@ pub fn parse_lock(text: &str) -> Vec<LockPackage> {
if t == "]" {
in_deps = false;
} else if let Some(dep) = quoted(t.trim_end_matches(',')) {
let name = dep.split(' ').next().unwrap_or_default().to_string();
pkg.dependencies.push(name);
pkg.dependencies.push(dep);
}
continue;
}
Expand All @@ -235,6 +247,38 @@ pub fn parse_lock(text: &str) -> Vec<LockPackage> {
_ => {}
}
}
// A dependency is written as `name`, `name version` or the full
// `name version (source)`, whichever is unambiguous in its lock.
let ids: Vec<(String, String, Option<String>, String)> = pkgs
.iter()
.map(|p| (p.name.clone(), p.version.clone(), p.source.clone(), p.id()))
.collect();
for pkg in &mut pkgs {
for dep in &mut pkg.dependencies {
let (head, source) = match dep.split_once(" (") {
Some((head, rest)) => (head, rest.strip_suffix(')').map(str::to_string)),
None => (dep.as_str(), None),
};
let mut parts = head.split(' ');
let name = parts.next().unwrap_or_default();
let version = parts.next();
let matches: Vec<&String> = ids
.iter()
.filter(|(n, v, s, _)| {
n == name
&& version.is_none_or(|want| want == v)
&& (source.is_none() || *s == source)
})
.map(|(_, _, _, id)| id)
.collect();
assert_eq!(
matches.len(),
1,
"lock dependency {dep:?} must name exactly one locked package"
);
*dep = matches[0].clone();
}
}
for pkg in &mut pkgs {
if pkg.checksum.is_none() {
if let Some(src) = &pkg.source {
Expand Down Expand Up @@ -265,17 +309,26 @@ pub fn write_lock(pkgs: &[LockPackage], version: u8) -> String {
if version >= 3 {
out.push_str(&format!("version = {version}\n\n"));
}
let dep_ref = |name: &str| -> String {
if version >= 2 {
return name.to_string();
}
// v1 always writes the full id; v2+ the shortest unambiguous form.
let dep_ref = |id: &str| -> String {
let dep = sorted
.iter()
.find(|p| p.name == name)
.unwrap_or_else(|| panic!("lock dependency {name} is not a locked package"));
match &dep.source {
Some(src) => format!("{} {} ({src})", dep.name, dep.version),
None => format!("{} {}", dep.name, dep.version),
.find(|p| p.id() == id)
.unwrap_or_else(|| panic!("lock dependency {id} is not a locked package"));
if version == 1 {
return dep.id();
}
let same_name = sorted.iter().filter(|p| p.name == dep.name).count();
let same_version = sorted
.iter()
.filter(|p| p.name == dep.name && p.version == dep.version)
.count();
if same_name == 1 {
dep.name.clone()
} else if same_version == 1 {
format!("{} {}", dep.name, dep.version)
} else {
dep.id()
}
};
let blocks: Vec<String> = sorted
Expand Down
Loading
Loading