From 52d29aad5f5b7f89a1cccd063cb7b05cd0cf968e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 00:23:27 +0000 Subject: [PATCH 1/5] Start fix for #757 Assisted-by: Claude Code:claude-opus-5-5 From 0d5848f06285eece713363b2455721b81669be7c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 00:32:23 +0000 Subject: [PATCH 2/5] Pin cargo deps whose features list wraps lines Hosted cargo scan refused a Cargo.toml dependency written as an inline table whose array wraps onto later lines, the usual rustfmt style for a long feature list: serde = { version = "1.0.0", features = [ "derive", ] } It warned redirect_cargo_toml_dep_unrewritable and left the crate unpatched. The planner, the pin readers and upstream restore all assumed an inline table closes on its own line. A shared reader now follows such a table to its closing brace. It only lets a line break happen inside an array or a multi-line string, as TOML 1.0 does, so a break straight after "{" is still refused. The planner classifies the whole table text and pins it on the opening line, right after "{". The pin readers read that pin back, and restore removes exactly the inserted text, so rollback leaves the manifest byte-identical for LF, CRLF and BOM files. Refs #757 Assisted-by: Claude Code:claude-opus-5-5 --- .../src/patch/redirect/mod.rs | 288 +++++++++++++++++- .../src/patch/redirect/upstream/cargo.rs | 78 +++++ 2 files changed, 352 insertions(+), 14 deletions(-) diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 0ac790449..e021eb167 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -2839,7 +2839,12 @@ fn is_repinnable_registry_name(value: &str) -> bool { pub(crate) fn cargo_socket_registry_pin(content: &str, crate_name: &str) -> Option { let lines: Vec<&str> = content.split('\n').collect(); let mut section = CargoTomlSection::Other; + // Past the last line of a multi-line inline table (#757). + let mut skip_until = 0; for (idx, raw) in lines.iter().enumerate() { + if idx < skip_until { + continue; + } let trimmed = raw.trim_start(); if trimmed.is_empty() || trimmed.starts_with('#') { continue; @@ -2915,10 +2920,17 @@ pub(crate) fn cargo_socket_registry_pin(content: &str, crate_name: &str) -> Opti if !value.starts_with('{') { continue; } - let Some(close) = value.find('}') else { - continue; + let inner = match value.find('}') { + Some(close) => value[1..close].to_string(), + None => match cargo_multiline_inline_table(&lines, idx, value) { + Some((end, inner)) => { + skip_until = end + 1; + inner + } + None => continue, + }, }; - let inner = &value[1..close]; + let inner = inner.as_str(); let is_ours = match cargo_toml_inline_string(inner, "package") { Some(package) => package == crate_name, None => key == crate_name, @@ -2947,7 +2959,12 @@ impl CargoRegistryPins { let lines: Vec<&str> = content.split('\n').collect(); let mut pins = Vec::new(); let mut section = CargoTomlSection::Other; + // Past the last line of a multi-line inline table (#757). + let mut skip_until = 0; for (idx, raw) in lines.iter().enumerate() { + if idx < skip_until { + continue; + } let trimmed = raw.trim_start(); if trimmed.is_empty() || trimmed.starts_with('#') { continue; @@ -3013,10 +3030,17 @@ impl CargoRegistryPins { if !value.starts_with('{') { continue; } - let Some(close) = value.find('}') else { - continue; + let inner = match value.find('}') { + Some(close) => value[1..close].to_string(), + None => match cargo_multiline_inline_table(&lines, idx, value) { + Some((end, inner)) => { + skip_until = end + 1; + inner + } + None => continue, + }, }; - let inner = &value[1..close]; + let inner = inner.as_str(); if let Some(reg) = cargo_toml_inline_string(inner, "registry") .filter(|registry| is_socket_patch_registry_name(registry)) { @@ -3159,6 +3183,98 @@ fn cargo_toml_string(value: &str) -> Option { document.get("value")?.as_str().map(str::to_string) } +/// An inline table that opens at `value` (the rest of `lines[idx]` from its +/// `{`) and wraps onto later lines (#757): the index of the line holding its +/// closing `}` and the text between the braces, lines joined with `\n` (a +/// CRLF line keeps its `\r`). TOML 1.0 lets an inline table break a line +/// only inside a value (an array or a multi-line string), so a break +/// directly inside the braces, a mismatched bracket or an unterminated +/// string is `None`. +fn cargo_multiline_inline_table( + lines: &[&str], + idx: usize, + value: &str, +) -> Option<(usize, String)> { + #[derive(PartialEq)] + enum Str { + No, + Basic, + Literal, + MultiBasic, + MultiLiteral, + } + let mut state = Str::No; + let mut stack: Vec = Vec::new(); + let mut inner = String::new(); + for (line_idx, line) in (idx..lines.len()).map(|j| (j, if j == idx { value } else { lines[j] })) + { + let b = line.as_bytes(); + let start = usize::from(line_idx == idx); + if line_idx == idx { + stack.push(b'{'); + } + let mut i = start; + while i < b.len() { + let rest = &b[i..]; + match state { + Str::No => match b[i] { + b'#' => break, + b'"' if rest.starts_with(b"\"\"\"") => { + state = Str::MultiBasic; + i += 2; + } + b'"' => state = Str::Basic, + b'\'' if rest.starts_with(b"'''") => { + state = Str::MultiLiteral; + i += 2; + } + b'\'' => state = Str::Literal, + open @ (b'[' | b'{') => stack.push(open), + b']' => { + if stack.pop() != Some(b'[') { + return None; + } + } + b'}' => { + if stack.pop() != Some(b'{') { + return None; + } + if stack.is_empty() { + inner.push_str(&line[start..i]); + return Some((line_idx, inner)); + } + } + _ => {} + }, + Str::Basic | Str::MultiBasic if b[i] == b'\\' => i += 1, + Str::Basic if b[i] == b'"' => state = Str::No, + Str::Literal if b[i] == b'\'' => state = Str::No, + Str::MultiBasic if rest.starts_with(b"\"\"\"") => { + state = Str::No; + i += 2; + } + Str::MultiLiteral if rest.starts_with(b"'''") => { + state = Str::No; + i += 2; + } + _ => {} + } + i += 1; + } + let in_value = match state { + Str::No => stack.last() == Some(&b'['), + Str::Basic | Str::Literal => false, + Str::MultiBasic | Str::MultiLiteral => true, + }; + if !in_value { + return None; + } + inner.push_str(&line[start..]); + inner.push('\n'); + } + None +} + fn cargo_toml_inline_string(inner: &str, key: &str) -> Option { let document = format!("dependency = {{{inner}}}") .parse::() @@ -3439,7 +3555,12 @@ fn plan_cargo_toml( let mut excluded: Vec = Vec::new(); let mut section = CargoTomlSection::Other; + // Past the last line of a multi-line inline table (#757). + let mut skip_until = 0; for (idx, raw) in lines.iter().enumerate() { + if idx < skip_until { + continue; + } let trimmed = raw.trim_start(); if trimmed.is_empty() || trimmed.starts_with('#') { continue; @@ -3593,15 +3714,26 @@ fn plan_cargo_toml( // Inline table. Rename-aware: `package = ""` under our key // means this entry is NOT the patched crate; `package = // ""` under any key means it IS. - let Some(close) = value.find('}') else { - if key == crate_name { - pending.push(Pending::Refuse( - "inline table does not close on its line".to_string(), - )); - } - continue; + // A table whose array value wraps (#757) is classified by its + // whole text and pinned on its opening line. + let (inner, multi_line) = match value.find('}') { + Some(close) => (value[1..close].to_string(), false), + None => match cargo_multiline_inline_table(&lines, idx, value) { + Some((end, inner)) => { + skip_until = end + 1; + (inner, true) + } + None => { + if key == crate_name { + pending.push(Pending::Refuse( + "inline table does not close on its line".to_string(), + )); + } + continue; + } + }, }; - let inner = &value[1..close]; + let inner = inner.as_str(); if workspace_key_re.is_match(inner) { pending.push(Pending::NeedsWorkspacePin(key.clone())); continue; @@ -3642,6 +3774,12 @@ fn plan_cargo_toml( if workspace { ws_entries.insert(key.clone(), CargoWorkspaceEntry::Pinned); } + } else if multi_line { + // Upstream restore unpins only the opening-line spelling + // this planner writes. + pending.push(Pending::Refuse(format!( + "a multi-line inline table already names a registry (\"{value}\")" + ))); } else if is_repinnable_registry_name(&value) { let new_text = registry_val_re .replace(raw, format!("registry = \"{reg}\"").as_str()) @@ -3660,6 +3798,22 @@ fn plan_cargo_toml( } } else if registry_key_re.is_match(inner) || registry_index_re.is_match(inner) { pending.push(Pending::Refuse("pinned to another registry".to_string())); + } else if multi_line { + // The pin goes right after the opening `{`, so restore can + // drop exactly the text inserted here. + let brace = raw.find('{').unwrap_or_default(); + let new_text = format!( + "{} registry = \"{reg}\",{}", + &raw[..=brace], + &raw[brace + 1..] + ); + pending.push(Pending::Action(CargoTomlAction::ReplaceLine { + idx, + new_text, + })); + if workspace { + ws_entries.insert(key.clone(), CargoWorkspaceEntry::Pinned); + } } else { // Rebuild the line: everything through `{`, the trimmed // inner, the registry pin, then `}` + any trailing bytes @@ -14591,6 +14745,14 @@ mod tests { "renamed inline table", "[dependencies]\nlegacy = { package = \"serde\", version = \"1.0.190\" }\n", ), + ( + "multi-line inline table (#757)", + "[dependencies]\nserde = { version = \"1.0.190\", features = [\n \"derive\",\n] }\n", + ), + ( + "renamed multi-line inline table (#757)", + "[dependencies]\nlegacy = { package = \"serde\", features = [\n \"derive\",\n], version = \"1.0.190\" }\n", + ), ( "table form", "[dependencies.serde]\nversion = \"1.0.190\"\n", @@ -23922,6 +24084,104 @@ packages: assert!(r.confirmed_cargo_uuids.contains(CARGO_UUID)); } + /// #757: an inline table whose array value wraps lines (rustfmt's style + /// for a long feature list) is valid TOML 1.0 and is pinned on its + /// opening line, for LF and CRLF manifests, a comment inside the array, + /// and a rename. + #[test] + fn cargo_multiline_inline_table_is_pinned_on_its_opening_line() { + let reg = cargo_reg(); + for (entry, want) in [ + ( + "serde = { version = \"1.0.190\", features = [\n \"derive\",\n] }\n", + format!( + "serde = {{ registry = \"{reg}\", version = \"1.0.190\", features = [\n \"derive\",\n] }}\n" + ), + ), + ( + "serde = { version = \"1.0.190\", features = [ # wrapped\n \"derive\", # derive\n \"rc\",\n], default-features = false } # tail\n", + format!( + "serde = {{ registry = \"{reg}\", version = \"1.0.190\", features = [ # wrapped\n \"derive\", # derive\n \"rc\",\n], default-features = false }} # tail\n" + ), + ), + ( + "legacy = { package = \"serde\", version = \"1.0.190\", features = [\n \"derive\",\n] }\n", + format!( + "legacy = {{ registry = \"{reg}\", package = \"serde\", version = \"1.0.190\", features = [\n \"derive\",\n] }}\n" + ), + ), + ] { + for crlf in [false, true] { + let eol = |s: &str| if crlf { s.replace('\n', "\r\n") } else { s.to_string() }; + let manifest = eol(&format!( + "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n[dependencies]\n{entry}" + )); + let r = rewrite_registry_redirect(&cargo_files(&manifest), &[cargo_sparse_override()]); + assert!(r.warnings.is_empty(), "[{entry:?} crlf={crlf}] {:?}", r.warnings); + let toml = r.files.get("Cargo.toml").expect("Cargo.toml rewritten"); + assert!( + toml.ends_with(&eol(&want)), + "[{entry:?} crlf={crlf}] pinned on the opening line: {toml}" + ); + let doc: toml_edit::DocumentMut = toml.parse().expect("still valid TOML"); + let dep = doc["dependencies"].as_table().unwrap().iter().next().unwrap().1; + assert_eq!(dep["registry"].as_str(), Some(reg.as_str())); + assert!(r.confirmed_cargo_uuids.contains(CARGO_UUID)); + } + } + } + + /// #757: a multi-line inline table is classified by its whole text, so a + /// `workspace = true`, `path` or other-registry key on a later line is + /// seen, and another crate's wrapped table is not mistaken for this one. + #[test] + fn cargo_multiline_inline_table_is_classified_by_its_whole_text() { + let head = "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n[dependencies]\n"; + // Path dependency: the path key sits after the wrapped array. + let path = format!( + "{head}serde = {{ version = \"1.0.190\", features = [\n \"derive\",\n], path = \"../serde\" }}\n" + ); + let r = rewrite_registry_redirect(&cargo_files(&path), &[cargo_sparse_override()]); + assert!( + r.files.is_empty(), + "path dep must write nothing: {:?}", + r.files + ); + assert_eq!( + warning_codes(&r), + vec!["redirect_cargo_toml_dep_unrewritable"] + ); + // Another crate's multi-line table, renamed away from serde, is + // skipped and the real serde line is pinned. + let other = format!( + "{head}serde = {{ package = \"serde_json\", version = \"1\", features = [\n \"std\",\n] }}\n\ + real = {{ package = \"serde\", version = \"1.0.190\" }}\n" + ); + let r = rewrite_registry_redirect(&cargo_files(&other), &[cargo_sparse_override()]); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + let toml = r.files.get("Cargo.toml").expect("rewritten"); + assert!( + toml.contains("serde = { package = \"serde_json\", version = \"1\", features = [\n"), + "the renamed-away table is untouched: {toml}" + ); + assert!(toml.contains(&format!( + "real = {{ package = \"serde\", version = \"1.0.190\", registry = \"{}\" }}", + cargo_reg() + ))); + // Already pinned (the planner's own output): nothing to write. + let pinned = format!( + "{head}serde = {{ registry = \"{}\", version = \"1.0.190\", features = [\n \"derive\",\n] }}\n", + cargo_reg() + ); + let r = rewrite_registry_redirect(&cargo_files(&pinned), &[cargo_sparse_override()]); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + assert!( + r.files.get("Cargo.toml").is_none_or(|t| t == &pinned), + "an already-pinned manifest is left as is: {:?}", + r.files.get("Cargo.toml") + ); + } + /// Cargo.lock blocks missing their `checksum` (a `[patch]`-resolved or /// git-sourced entry) or missing BOTH `source` and `checksum` are rebuilt /// with the lines inserted in canonical order, and the neighbor blocks diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/cargo.rs b/crates/socket-patch-core/src/patch/redirect/upstream/cargo.rs index 8d95e5e4d..b777f324a 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/cargo.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/cargo.rs @@ -355,6 +355,16 @@ fn unpin_line(line: &str, reg: &str) -> Option> { } } let open = line.find('{')?; + // The opening line of a multi-line inline table, which the rewriter + // pins right after its `{` (#757). + let opening_pin = format!(" registry = \"{reg}\","); + if !line[open..].contains('}') && line[open + 1..].starts_with(&opening_pin) { + return Some(Some(format!( + "{}{}", + &line[..=open], + &line[open + 1 + opening_pin.len()..] + ))); + } let close = open + line[open..].find('}')?; let inner = &line[open + 1..close]; let pin_re = Regex::new(&format!( @@ -508,6 +518,74 @@ mod tests { ); } + /// #757: the rewriter pins a multi-line inline table on its opening + /// line; restore drops exactly the text it inserted. + #[test] + fn multiline_inline_table_opening_line_loses_only_the_pin() { + for (pinned, original) in [ + ( + format!("serde = {{ registry = \"{REG}\", version = \"1\", features = ["), + "serde = { version = \"1\", features = [", + ), + ( + format!("serde = {{ registry = \"{REG}\", features = [ # wrapped\r"), + "serde = { features = [ # wrapped\r", + ), + ] { + assert_eq!(unpin_line(&pinned, REG), Some(Some(original.to_string()))); + } + } + + /// #757 end to end: restoring a hosted pin on a multi-line inline + /// table returns the manifest byte-exact, for LF, CRLF and a BOM. + #[tokio::test] + async fn restore_unpins_a_multiline_inline_table_byte_exact() { + let head = "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n[dependencies]\n"; + let original = format!( + "{head}cfg-if = {{ version = \"1.0.4\", features = [\n \"core\", # why\n] }}\nlog = \"0.4\"\n" + ); + let pinned = format!( + "{head}cfg-if = {{ registry = \"socket-patch-{B}\", version = \"1.0.4\", features = [\n \"core\", # why\n] }}\nlog = \"0.4\"\n" + ); + for (crlf, bom) in [(false, false), (true, false), (false, true), (true, true)] { + let shape = |s: &str| { + let s = if crlf { + s.replace('\n', "\r\n") + } else { + s.to_string() + }; + if bom { + format!("\u{feff}{s}") + } else { + s + } + }; + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("Cargo.toml"), shape(&pinned)).unwrap(); + let pins = [super::super::HostedPin { + purl: "pkg:cargo/cfg-if@1.0.4".into(), + uuid: B.into(), + files: vec!["Cargo.toml".into()], + }]; + let opts = super::super::RestoreOptions { + offline: true, + ..Default::default() + }; + let outcome = super::super::restore_upstream(tmp.path(), &pins, &opts).await; + assert_eq!( + outcome.restored().count(), + 1, + "crlf={crlf} bom={bom}: {:?}", + outcome.pins + ); + assert_eq!( + std::fs::read_to_string(tmp.path().join("Cargo.toml")).unwrap(), + shape(&original), + "crlf={crlf} bom={bom}" + ); + } + } + #[test] fn table_form_line_is_dropped() { assert_eq!( From 20f1ef520e573d384cd949c6f969d79809c31d03 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 00:57:59 +0000 Subject: [PATCH 3/5] Read wrapped cargo tables past comments Follow-ups from review of the multi-line inline table support: - Keys or braces that appear only in a comment inside a wrapped features array no longer get the dependency refused as a path, workspace or other-registry entry, or pinned inside the comment. - Every inline table, one-line or wrapped, is now read by the same brace- and string-aware scanner. A "}" inside a string or comment no longer ends it, and a multi-line string may end in extra quotes. - A rescan that picks a newer patch moves an earlier pin on a wrapped table to the new registry, the same as it does for every other spelling. - Restore uses the same scanner to tell a wrapped table's opening line from a one-line table. - A real-cargo e2e shape covers the wrapped spelling for LF and CRLF: scan, offline locked build, vex, and a byte-exact remove. The migration test no longer lists it as a spelling hosted mode refuses. Refs #757 Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/e2e_redirect_cargo_shapes.rs | 36 ++++ .../tests/mode_migration_cargo.rs | 8 +- .../src/patch/redirect/mod.rs | 185 ++++++++++++------ .../src/patch/redirect/upstream/cargo.rs | 7 +- 4 files changed, 166 insertions(+), 70 deletions(-) diff --git a/crates/socket-patch-cli/tests/e2e_redirect_cargo_shapes.rs b/crates/socket-patch-cli/tests/e2e_redirect_cargo_shapes.rs index bf5de9309..5f01d8810 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_cargo_shapes.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_cargo_shapes.rs @@ -1109,6 +1109,42 @@ async fn cargo_hosted_explicit_crates_io_registry_is_pinned() { } } +/// #757: an inline table whose array value wraps lines (rustfmt's style for +/// a long feature list) was refused as "inline table does not close on its +/// line". It is pinned on its opening line, builds against the patched +/// copy, and `remove` restores every byte, for LF and CRLF manifests. +#[tokio::test(flavor = "multi_thread")] +async fn cargo_hosted_multiline_inline_table_is_pinned() { + for (tag, crlf) in [("multiline-inline", false), ("multiline-inline-crlf", true)] { + let shape = Shape { + tag, + files: vec![ + ( + "Cargo.toml", + consumer_manifest( + "cfg-if = { version = \"1.0.4\", features = [ # none needed\n] }\n", + ), + ), + ("src/main.rs", "fn main() {}\n".to_string()), + ], + patches: vec![CFG_IF_1], + oracle: vec![( + "src/main.rs", + "fn main() { println!(\"{}\", cfg_if::socket_patched()); }\n".to_string(), + )], + crlf, + lockless: false, + refused: None, + prefetch: "", + contest: None, + restored: vec![], + }; + if run_shape(shape).await.is_none() { + return; + } + } +} + /// Bug H: a config ending in a blank line comes back byte-for-byte (the /// appended block's removal once normalized the trailing newline run). A /// config without a final newline comes back with every byte but that diff --git a/crates/socket-patch-cli/tests/mode_migration_cargo.rs b/crates/socket-patch-cli/tests/mode_migration_cargo.rs index 2f38abde6..b035b8683 100644 --- a/crates/socket-patch-cli/tests/mode_migration_cargo.rs +++ b/crates/socket-patch-cli/tests/mode_migration_cargo.rs @@ -1343,12 +1343,8 @@ async fn takeover_over_unrewritable_spelling_keeps_vendored() { for (tag, spelling) in [ ("dotted", format!("{DEP}.version = \"1.0\"\n")), ("literal", format!("{DEP} = '1.0'\n")), - // An inline table spread over lines (#757). `registry = "crates-io"` - // was this case until #386 made hosted mode pin it. - ( - "multi-line-inline", - format!("{DEP} = {{ version = \"1.0\", features = [\n] }}\n"), - ), + // `registry = "crates-io"` (#386) and an inline table whose array + // wraps lines (#757) were cases here until hosted mode pinned them. ] { let tmp = tempfile::tempdir().unwrap(); let Some((proj, cargo_home, version, crate_dir)) = stage_fixture(tmp.path()) else { diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index e021eb167..7a90395cd 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -2920,17 +2920,11 @@ pub(crate) fn cargo_socket_registry_pin(content: &str, crate_name: &str) -> Opti if !value.starts_with('{') { continue; } - let inner = match value.find('}') { - Some(close) => value[1..close].to_string(), - None => match cargo_multiline_inline_table(&lines, idx, value) { - Some((end, inner)) => { - skip_until = end + 1; - inner - } - None => continue, - }, + let Some((end, inner)) = cargo_inline_table(&lines, idx, value) else { + continue; }; - let inner = inner.as_str(); + skip_until = end + 1; + let inner = inner.as_ref(); let is_ours = match cargo_toml_inline_string(inner, "package") { Some(package) => package == crate_name, None => key == crate_name, @@ -3030,17 +3024,11 @@ impl CargoRegistryPins { if !value.starts_with('{') { continue; } - let inner = match value.find('}') { - Some(close) => value[1..close].to_string(), - None => match cargo_multiline_inline_table(&lines, idx, value) { - Some((end, inner)) => { - skip_until = end + 1; - inner - } - None => continue, - }, + let Some((end, inner)) = cargo_inline_table(&lines, idx, value) else { + continue; }; - let inner = inner.as_str(); + skip_until = end + 1; + let inner = inner.as_ref(); if let Some(reg) = cargo_toml_inline_string(inner, "registry") .filter(|registry| is_socket_patch_registry_name(registry)) { @@ -3183,18 +3171,21 @@ fn cargo_toml_string(value: &str) -> Option { document.get("value")?.as_str().map(str::to_string) } -/// An inline table that opens at `value` (the rest of `lines[idx]` from its -/// `{`) and wraps onto later lines (#757): the index of the line holding its -/// closing `}` and the text between the braces, lines joined with `\n` (a -/// CRLF line keeps its `\r`). TOML 1.0 lets an inline table break a line -/// only inside a value (an array or a multi-line string), so a break -/// directly inside the braces, a mismatched bracket or an unterminated -/// string is `None`. -fn cargo_multiline_inline_table( +/// The inline table that opens at `value` (the rest of `lines[idx]` from +/// its `{`): the index of the line holding its closing `}` and the text +/// between the braces. A table that closes on its own line borrows that +/// text. One whose array value wraps onto later lines (#757) joins its +/// lines with `\n` (a CRLF line keeps its `\r`) and leaves out comments, +/// so a key named only in a comment is not read as one. TOML 1.0 lets an +/// inline table break a line only inside a value (an array or a +/// multi-line string), so a break directly inside the braces, a +/// mismatched bracket or an unterminated string is `None`. Braces and +/// brackets inside strings and comments do not count. +fn cargo_inline_table<'a>( lines: &[&str], idx: usize, - value: &str, -) -> Option<(usize, String)> { + value: &'a str, +) -> Option<(usize, Cow<'a, str>)> { #[derive(PartialEq)] enum Str { No, @@ -3203,22 +3194,32 @@ fn cargo_multiline_inline_table( MultiBasic, MultiLiteral, } + // A multi-line string closes on its first run of three or more quotes; + // up to two of them belong to the content (`"""a""""`). + let closing_run = |rest: &[u8], quote: u8| -> Option { + let n = rest.iter().take_while(|&&c| c == quote).count(); + (n >= 3).then_some(n.min(5)) + }; let mut state = Str::No; let mut stack: Vec = Vec::new(); let mut inner = String::new(); - for (line_idx, line) in (idx..lines.len()).map(|j| (j, if j == idx { value } else { lines[j] })) - { + for (line_idx, &line) in lines.iter().enumerate().skip(idx) { + let line = if line_idx == idx { value } else { line }; let b = line.as_bytes(); let start = usize::from(line_idx == idx); if line_idx == idx { stack.push(b'{'); } + let mut end = b.len(); let mut i = start; while i < b.len() { let rest = &b[i..]; match state { Str::No => match b[i] { - b'#' => break, + b'#' => { + end = i; + break; + } b'"' if rest.starts_with(b"\"\"\"") => { state = Str::MultiBasic; i += 2; @@ -3240,8 +3241,11 @@ fn cargo_multiline_inline_table( return None; } if stack.is_empty() { - inner.push_str(&line[start..i]); - return Some((line_idx, inner)); + if line_idx == idx { + return Some((idx, Cow::Borrowed(&value[1..i]))); + } + inner.push_str(&line[..i]); + return Some((line_idx, Cow::Owned(inner))); } } _ => {} @@ -3249,13 +3253,17 @@ fn cargo_multiline_inline_table( Str::Basic | Str::MultiBasic if b[i] == b'\\' => i += 1, Str::Basic if b[i] == b'"' => state = Str::No, Str::Literal if b[i] == b'\'' => state = Str::No, - Str::MultiBasic if rest.starts_with(b"\"\"\"") => { - state = Str::No; - i += 2; + Str::MultiBasic if b[i] == b'"' => { + if let Some(n) = closing_run(rest, b'"') { + state = Str::No; + i += n - 1; + } } - Str::MultiLiteral if rest.starts_with(b"'''") => { - state = Str::No; - i += 2; + Str::MultiLiteral if b[i] == b'\'' => { + if let Some(n) = closing_run(rest, b'\'') { + state = Str::No; + i += n - 1; + } } _ => {} } @@ -3269,7 +3277,7 @@ fn cargo_multiline_inline_table( if !in_value { return None; } - inner.push_str(&line[start..]); + inner.push_str(&line[start..end]); inner.push('\n'); } None @@ -3716,24 +3724,17 @@ fn plan_cargo_toml( // ""` under any key means it IS. // A table whose array value wraps (#757) is classified by its // whole text and pinned on its opening line. - let (inner, multi_line) = match value.find('}') { - Some(close) => (value[1..close].to_string(), false), - None => match cargo_multiline_inline_table(&lines, idx, value) { - Some((end, inner)) => { - skip_until = end + 1; - (inner, true) - } - None => { - if key == crate_name { - pending.push(Pending::Refuse( - "inline table does not close on its line".to_string(), - )); - } - continue; - } - }, + let Some((end, inner)) = cargo_inline_table(&lines, idx, value) else { + if key == crate_name { + pending.push(Pending::Refuse( + "inline table does not close on its line".to_string(), + )); + } + continue; }; - let inner = inner.as_str(); + skip_until = end + 1; + let multi_line = end > idx; + let inner = inner.as_ref(); if workspace_key_re.is_match(inner) { pending.push(Pending::NeedsWorkspacePin(key.clone())); continue; @@ -3776,10 +3777,30 @@ fn plan_cargo_toml( } } else if multi_line { // Upstream restore unpins only the opening-line spelling - // this planner writes. - pending.push(Pending::Refuse(format!( - "a multi-line inline table already names a registry (\"{value}\")" - ))); + // this planner writes, so only an earlier pin in that + // spelling is superseded in place. + let brace = raw.find('{').unwrap_or_default(); + let earlier = format!(" registry = \"{value}\","); + if is_socket_patch_registry_name(&value) + && raw[brace + 1..].starts_with(&earlier) + { + let new_text = format!( + "{} registry = \"{reg}\",{}", + &raw[..=brace], + &raw[brace + 1 + earlier.len()..] + ); + pending.push(Pending::Action(CargoTomlAction::ReplaceLine { + idx, + new_text, + })); + if workspace { + ws_entries.insert(key.clone(), CargoWorkspaceEntry::Pinned); + } + } else { + pending.push(Pending::Refuse(format!( + "a multi-line inline table already names a registry (\"{value}\")" + ))); + } } else if is_repinnable_registry_name(&value) { let new_text = registry_val_re .replace(raw, format!("registry = \"{reg}\"").as_str()) @@ -15483,6 +15504,28 @@ mod tests { assert!(r.confirmed_cargo_uuids.is_empty()); } + /// #757: an earlier hosted pin on a multi-line inline table moves to the + /// new registry in place, in the spelling restore unpins. + #[test] + fn cargo_supersede_replaces_previous_multiline_pin() { + const OLD_UUID: &str = "0a1b2c3d-4e5f-4a7b-8c9d-0e1f2a3b4c5d"; + let old_reg = format!("socket-patch-{OLD_UUID}"); + let manifest = format!( + "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n[dependencies]\n\ + serde = {{ registry = \"{old_reg}\", version = \"1.0.190\", features = [\n \"derive\",\n] }}\n" + ); + let r = rewrite_registry_redirect(&cargo_files(&manifest), &[cargo_sparse_override()]); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + let toml = r.files.get("Cargo.toml").expect("manifest re-pinned"); + assert!( + toml.ends_with(&format!( + "serde = {{ registry = \"{}\", version = \"1.0.190\", features = [\n \"derive\",\n] }}\n", + cargo_reg() + )), + "{toml}" + ); + } + /// A re-scan that selects a NEWER patch uuid over an existing redirect /// must supersede the old `registry = "socket-patch-"` pin in place /// — classifying it as a foreign registry would leave the manifest on @@ -24168,6 +24211,22 @@ packages: "real = {{ package = \"serde\", version = \"1.0.190\", registry = \"{}\" }}", cargo_reg() ))); + // Keys and braces named only in comments are not the table's. + for entry in [ + "serde = { version = \"1.0.190\", features = [\n \"derive\", # path = \"../x\"\n] }\n", + "serde = { version = \"1.0.190\", features = [\n \"derive\", # workspace = true\n] }\n", + "serde = { version = \"1.0.190\", features = [ # {x}\n \"derive\",\n] }\n", + "serde = { version = \"1.0.190\", features = [\"a}\", \"\"\"q\"\"\"\"\", \n \"derive\",\n] }\n", + ] { + let manifest = format!("{head}{entry}"); + let r = rewrite_registry_redirect(&cargo_files(&manifest), &[cargo_sparse_override()]); + assert!(r.warnings.is_empty(), "[{entry:?}] {:?}", r.warnings); + let toml = r.files.get("Cargo.toml").expect("rewritten"); + assert!( + toml.contains(&format!("serde = {{ registry = \"{}\", version", cargo_reg())), + "[{entry:?}] pinned on the opening line: {toml}" + ); + } // Already pinned (the planner's own output): nothing to write. let pinned = format!( "{head}serde = {{ registry = \"{}\", version = \"1.0.190\", features = [\n \"derive\",\n] }}\n", diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/cargo.rs b/crates/socket-patch-core/src/patch/redirect/upstream/cargo.rs index b777f324a..140ce2a7f 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/cargo.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/cargo.rs @@ -358,7 +358,8 @@ fn unpin_line(line: &str, reg: &str) -> Option> { // The opening line of a multi-line inline table, which the rewriter // pins right after its `{` (#757). let opening_pin = format!(" registry = \"{reg}\","); - if !line[open..].contains('}') && line[open + 1..].starts_with(&opening_pin) { + let wraps = super::super::cargo_inline_table(&[line], 0, &line[open..]).is_none(); + if wraps && line[open + 1..].starts_with(&opening_pin) { return Some(Some(format!( "{}{}", &line[..=open], @@ -531,6 +532,10 @@ mod tests { format!("serde = {{ registry = \"{REG}\", features = [ # wrapped\r"), "serde = { features = [ # wrapped\r", ), + ( + format!("serde = {{ registry = \"{REG}\", features = [ # see {{x}}"), + "serde = { features = [ # see {x}", + ), ] { assert_eq!(unpin_line(&pinned, REG), Some(Some(original.to_string()))); } From bde22cd6620a9916013b2b8018ff85b12ef23481 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 01:09:18 +0000 Subject: [PATCH 4/5] Pin wrapped cargo tables naming crates-io A wrapped inline table with an explicit registry = "crates-io" was still refused, while #386 pins the one-line form. Hosted scan now drops that redundant key, with one adjacent comma, and pins the table on its opening line in the spelling restore unpins. remove then leaves the declaration without the key, which cargo resolves the same way, as it does for the one-line form. Unit tests cover the key after "{", in the middle of the opening line and on the closing line. A real-cargo e2e row covers the closing-line spelling. Refs #757 Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/e2e_redirect_cargo_shapes.rs | 7 ++ .../src/patch/redirect/mod.rs | 104 ++++++++++++++++-- 2 files changed, 102 insertions(+), 9 deletions(-) diff --git a/crates/socket-patch-cli/tests/e2e_redirect_cargo_shapes.rs b/crates/socket-patch-cli/tests/e2e_redirect_cargo_shapes.rs index 5f01d8810..5a7808ef8 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_cargo_shapes.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_cargo_shapes.rs @@ -1073,6 +1073,13 @@ async fn cargo_hosted_explicit_crates_io_registry_is_pinned() { consumer_manifest("cfg-if = { version = \"1.0.4\", registry = \"crates-io\" }\n"), consumer_manifest("cfg-if = \"1.0.4\"\n"), ), + ( + "crates-io-wrapped", + consumer_manifest( + "cfg-if = { version = \"1.0.4\", features = [\n], registry = \"crates-io\" }\n", + ), + consumer_manifest("cfg-if = { version = \"1.0.4\", features = [\n] }\n"), + ), ( "crates-io-table", format!( diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 7a90395cd..1161609e9 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -3283,6 +3283,55 @@ fn cargo_inline_table<'a>( None } +/// The line rewrites that move a wrapped inline table (`lines[idx..=end]`) +/// from an explicit `registry = "crates-io"` to `reg`: the key is dropped +/// with one adjacent comma, and the pin goes right after the opening `{`, +/// the spelling upstream restore unpins. `None` when the key is not found +/// exactly once outside comments. +fn cargo_wrapped_crates_io_repin( + lines: &[&str], + idx: usize, + end: usize, + reg: &str, +) -> Option> { + let code = |line: &str| line.split('#').next().unwrap_or_default().len(); + let mut hits = (idx..=end).filter_map(|j| { + let line = lines[j]; + let start = if j == idx { line.find('{')? } else { 0 }; + CARGO_TOML_CRATES_IO_KEY_RE + .find(&line[start..code(line)]) + .map(|m| (j, start + m.start(), start + m.end())) + }); + let (j, from, to) = hits.next()?; + if hits.next().is_some() { + return None; + } + let line = lines[j]; + let after = &line[to..]; + let dropped = match after.trim_start().strip_prefix(',') { + Some(rest) => format!("{}{}", &line[..from], rest.trim_start()), + None => format!("{}{after}", line[..from].trim_end().strip_suffix(',')?), + }; + let opening = if j == idx { + dropped.as_str() + } else { + lines[idx] + }; + let brace = opening.find('{')?; + let mut rewrites = vec![( + idx, + format!( + "{} registry = \"{reg}\",{}", + &opening[..=brace], + &opening[brace + 1..] + ), + )]; + if j != idx { + rewrites.push((j, dropped)); + } + Some(rewrites) +} + fn cargo_toml_inline_string(inner: &str, key: &str) -> Option { let document = format!("dependency = {{{inner}}}") .parse::() @@ -3435,6 +3484,9 @@ static CARGO_TOML_REGISTRY_VAL_RE: LazyLock = LazyLock::new(|| { Regex::new(r#"(?:\bregistry|"registry"|'registry')\s*=\s*(?:"[^"]*"|'[^']*')"#) .expect("static registry-value regex is valid") }); +static CARGO_TOML_CRATES_IO_KEY_RE: LazyLock = LazyLock::new(|| { + Regex::new(r#"registry\s*=\s*"crates-io""#).expect("static crates-io key regex is valid") +}); static CARGO_TOML_REGISTRY_KEY_RE: LazyLock = LazyLock::new(|| { Regex::new(r"\bregistry\s*=").expect("static registry-key probe regex is valid") }); @@ -3779,20 +3831,35 @@ fn plan_cargo_toml( // Upstream restore unpins only the opening-line spelling // this planner writes, so only an earlier pin in that // spelling is superseded in place. + // An explicit `registry = "crates-io"` (#386) is dropped + // and the pin written in that spelling; restore then + // leaves the declaration without the redundant key, as + // it does for a one-line table. let brace = raw.find('{').unwrap_or_default(); let earlier = format!(" registry = \"{value}\","); - if is_socket_patch_registry_name(&value) + let rewrites = if is_repinnable_registry_name(&value) && raw[brace + 1..].starts_with(&earlier) { - let new_text = format!( - "{} registry = \"{reg}\",{}", - &raw[..=brace], - &raw[brace + 1 + earlier.len()..] - ); - pending.push(Pending::Action(CargoTomlAction::ReplaceLine { + Some(vec![( idx, - new_text, - })); + format!( + "{} registry = \"{reg}\",{}", + &raw[..=brace], + &raw[brace + 1 + earlier.len()..] + ), + )]) + } else if value == "crates-io" { + cargo_wrapped_crates_io_repin(&lines, idx, end, reg) + } else { + None + }; + if let Some(rewrites) = rewrites { + for (idx, new_text) in rewrites { + pending.push(Pending::Action(CargoTomlAction::ReplaceLine { + idx, + new_text, + })); + } if workspace { ws_entries.insert(key.clone(), CargoWorkspaceEntry::Pinned); } @@ -24227,6 +24294,25 @@ packages: "[{entry:?}] pinned on the opening line: {toml}" ); } + // An explicit `registry = "crates-io"` (#386) anywhere in the + // wrapped table is dropped and the pin lands on the opening line. + for entry in [ + "serde = { registry = \"crates-io\", version = \"1.0.190\", features = [\n \"derive\",\n] }\n", + "serde = { version = \"1.0.190\", registry = \"crates-io\", features = [\n \"derive\",\n] }\n", + "serde = { version = \"1.0.190\", features = [\n \"derive\",\n], registry = \"crates-io\" }\n", + ] { + let manifest = format!("{head}{entry}"); + let r = rewrite_registry_redirect(&cargo_files(&manifest), &[cargo_sparse_override()]); + assert!(r.warnings.is_empty(), "[{entry:?}] {:?}", r.warnings); + let toml = r.files.get("Cargo.toml").expect("rewritten"); + assert!( + toml.ends_with(&format!( + "serde = {{ registry = \"{}\", version = \"1.0.190\", features = [\n \"derive\",\n] }}\n", + cargo_reg() + )), + "[{entry:?}] crates-io dropped, pinned on the opening line: {toml}" + ); + } // Already pinned (the planner's own output): nothing to write. let pinned = format!( "{head}serde = {{ registry = \"{}\", version = \"1.0.190\", features = [\n \"derive\",\n] }}\n", From 319351f40787a2809e0b52c676510684051e1ac5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 01:18:23 +0000 Subject: [PATCH 5/5] Match quoted crates-io keys in wrapped tables The crates-io repin for a wrapped inline table found only the bare key with a double-quoted value. registry = 'crates-io' and "registry" = "crates-io" mean the same thing, and the one-line form already accepts them, but in a wrapped table they still got the dependency refused. The matcher now takes the same key and value spellings as the one-line rewrite. Refs #757 Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-core/src/patch/redirect/mod.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 1161609e9..0b1e6e400 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -3485,7 +3485,8 @@ static CARGO_TOML_REGISTRY_VAL_RE: LazyLock = LazyLock::new(|| { .expect("static registry-value regex is valid") }); static CARGO_TOML_CRATES_IO_KEY_RE: LazyLock = LazyLock::new(|| { - Regex::new(r#"registry\s*=\s*"crates-io""#).expect("static crates-io key regex is valid") + Regex::new(r#"(?:\bregistry|"registry"|'registry')\s*=\s*(?:"crates-io"|'crates-io')"#) + .expect("static crates-io key regex is valid") }); static CARGO_TOML_REGISTRY_KEY_RE: LazyLock = LazyLock::new(|| { Regex::new(r"\bregistry\s*=").expect("static registry-key probe regex is valid") @@ -24300,6 +24301,8 @@ packages: "serde = { registry = \"crates-io\", version = \"1.0.190\", features = [\n \"derive\",\n] }\n", "serde = { version = \"1.0.190\", registry = \"crates-io\", features = [\n \"derive\",\n] }\n", "serde = { version = \"1.0.190\", features = [\n \"derive\",\n], registry = \"crates-io\" }\n", + "serde = { version = \"1.0.190\", features = [\n \"derive\",\n], registry = 'crates-io' }\n", + "serde = { \"registry\" = \"crates-io\", version = \"1.0.190\", features = [\n \"derive\",\n] }\n", ] { let manifest = format!("{head}{entry}"); let r = rewrite_registry_redirect(&cargo_files(&manifest), &[cargo_sparse_override()]);