From e61102dfed00965b75d12450dbc8340ec5a3e718 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 16:24:00 +0000 Subject: [PATCH 1/4] Start fix for #757 (dotted, literal pins) Assisted-by: Claude Code:claude-opus-5-5 From 98d3b07134f6538a4efd44f748580467cf553a05 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 16:50:52 +0000 Subject: [PATCH 2/4] Pin cargo dotted-key and literal-string deps Hosted cargo scan refused two valid Cargo.toml spellings and left the crate unpatched with redirect_cargo_toml_dep_unrewritable: serde.version = "1.0.0" # dotted keys serde = '1.0.0' # literal-string version Dotted keys are now gathered per dependency across their table, since TOML lets them sit anywhere in it, and classified like the table form: rename, workspace inheritance, path/git, registry-index and an earlier or crates-io registry. The pin is a new .registry line right after the version line, using that line's key spelling, indent and line ending. A rescan moves an earlier pin in place. A literal-string version is pinned as { version = '...', registry = "..." }, keeping the user's quotes. The pin readers resolve a dotted rename's owning crate, and restore drops the added dotted line and collapses the literal table back to '...', so remove and rollback leave the manifest byte-identical for LF, CRLF and BOM files. Spellings still refused are multi-line string versions and keys nested deeper than .. Fixes #757 Assisted-by: Claude Code:claude-opus-5-5 --- .../src/patch/redirect/mod.rs | 541 +++++++++++++++--- .../src/patch/redirect/upstream/cargo.rs | 108 +++- 2 files changed, 579 insertions(+), 70 deletions(-) diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 0b1e6e400..e5792df4e 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -2841,6 +2841,16 @@ pub(crate) fn cargo_socket_registry_pin(content: &str, crate_name: &str) -> Opti let mut section = CargoTomlSection::Other; // Past the last line of a multi-line inline table (#757). let mut skip_until = 0; + // The current dependency table's dotted keys, read at its end (#757). + let mut dotted = CargoDottedDecls::default(); + let dotted_pin = |dotted: &mut CargoDottedDecls| { + dotted + .take() + .into_iter() + .filter_map(|(key, fields)| cargo_dotted_pin(key, &fields)) + .find(|(owner, _)| owner == crate_name) + .map(|(_, registry)| registry) + }; for (idx, raw) in lines.iter().enumerate() { if idx < skip_until { continue; @@ -2850,6 +2860,9 @@ pub(crate) fn cargo_socket_registry_pin(content: &str, crate_name: &str) -> Opti continue; } if trimmed.starts_with('[') && !trimmed.starts_with("[[") { + if let Some(registry) = dotted_pin(&mut dotted) { + return Some(registry); + } section = match CARGO_TOML_HEADER_RE.captures(trimmed) { Some(c) => classify_cargo_section( c.get(1) @@ -2899,19 +2912,10 @@ pub(crate) fn cargo_socket_registry_pin(content: &str, crate_name: &str) -> Opti continue; }; let rest_trim = rest.trim_start(); - if let Some(dotted) = rest_trim.strip_prefix('.') { - // `.registry = "socket-patch-…"`: a spelling this rewriter - // refuses to write, but a hand edit can leave one behind. - if key == crate_name { - let registry = parse_cargo_entry_key(dotted) - .filter(|(key, _)| key == "registry") - .and_then(|(_, rest)| rest.trim_start().strip_prefix('=')) - .and_then(cargo_toml_string) - .filter(|registry| is_socket_patch_registry_name(registry)); - if registry.is_some() { - return registry; - } - } + if let Some(rest) = rest_trim.strip_prefix('.') { + // `.registry = "socket-patch-…"`, owned by `.package` + // when there is one (#757). + dotted.push(idx, key, rest); continue; } let Some(value) = rest_trim.strip_prefix('=').map(str::trim_start) else { @@ -2937,7 +2941,7 @@ pub(crate) fn cargo_socket_registry_pin(content: &str, crate_name: &str) -> Opti } } } - None + dotted_pin(&mut dotted) } /// Every Socket registry pin a manifest carries, as `(crate, registry)` in @@ -2955,6 +2959,16 @@ impl CargoRegistryPins { let mut section = CargoTomlSection::Other; // Past the last line of a multi-line inline table (#757). let mut skip_until = 0; + // The current dependency table's dotted keys, read at its end (#757). + let mut dotted = CargoDottedDecls::default(); + let dotted_pins = |dotted: &mut CargoDottedDecls, pins: &mut Vec<(String, String)>| { + pins.extend( + dotted + .take() + .into_iter() + .filter_map(|(key, fields)| cargo_dotted_pin(key, &fields)), + ); + }; for (idx, raw) in lines.iter().enumerate() { if idx < skip_until { continue; @@ -2964,6 +2978,7 @@ impl CargoRegistryPins { continue; } if trimmed.starts_with('[') && !trimmed.starts_with("[[") { + dotted_pins(&mut dotted, &mut pins); section = match CARGO_TOML_HEADER_RE.captures(trimmed) { Some(c) => classify_cargo_section( c.get(1) @@ -3007,15 +3022,8 @@ impl CargoRegistryPins { continue; }; let rest_trim = rest.trim_start(); - if let Some(dotted) = rest_trim.strip_prefix('.') { - let registry = parse_cargo_entry_key(dotted) - .filter(|(k, _)| k == "registry") - .and_then(|(_, rest)| rest.trim_start().strip_prefix('=')) - .and_then(cargo_toml_string) - .filter(|registry| is_socket_patch_registry_name(registry)); - if let Some(reg) = registry { - pins.push((key, reg)); - } + if let Some(rest) = rest_trim.strip_prefix('.') { + dotted.push(idx, key, rest); continue; } let Some(value) = rest_trim.strip_prefix('=').map(str::trim_start) else { @@ -3036,6 +3044,7 @@ impl CargoRegistryPins { pins.push((owner, reg)); } } + dotted_pins(&mut dotted, &mut pins); CargoRegistryPins(pins) } @@ -3164,6 +3173,65 @@ fn parse_cargo_entry_key(line: &str) -> Option<(String, &str)> { } } +/// One dotted-key line of a dependency (`serde.version = "1"`, #757): its +/// line index, the key after the dot (`None` when it can't be read) and +/// the text after that key (`= "1"`, or `.x = …` for a deeper key). +struct CargoDottedField<'a> { + idx: usize, + sub: Option, + rest: &'a str, +} + +/// A dependency table's dotted-key declarations, gathered per dependency +/// key in first-seen order: TOML lets a key's dotted lines sit anywhere in +/// its table, between other dependencies. +#[derive(Default)] +struct CargoDottedDecls<'a>(Vec<(String, Vec>)>); + +impl<'a> CargoDottedDecls<'a> { + /// Record line `idx`, `key` followed by `dotted` (the text after the dot). + fn push(&mut self, idx: usize, key: String, dotted: &'a str) { + let (sub, rest) = match parse_cargo_entry_key(dotted.trim_start()) { + Some((sub, rest)) => (Some(sub), rest), + None => (None, dotted), + }; + let field = CargoDottedField { idx, sub, rest }; + match self.0.iter_mut().find(|(k, _)| *k == key) { + Some((_, fields)) => fields.push(field), + None => self.0.push((key, vec![field])), + } + } + + fn take(&mut self) -> Vec<(String, Vec>)> { + std::mem::take(&mut self.0) + } +} + +/// The dotted `. = …` field: its line index and its value when +/// that is a one-line string. +fn cargo_dotted_value(fields: &[CargoDottedField], name: &str) -> Option<(usize, Option)> { + fields.iter().find_map(|field| { + if field.sub.as_deref() != Some(name) { + return None; + } + let value = field.rest.trim_start().strip_prefix('=')?; + Some((field.idx, cargo_toml_string(value))) + }) +} + +/// The `socket-patch-` pin a dependency's dotted keys carry, as +/// `(owning crate, registry)`: the owner is its `package` rename, else its +/// key. +fn cargo_dotted_pin(key: String, fields: &[CargoDottedField]) -> Option<(String, String)> { + let registry = cargo_dotted_value(fields, "registry")? + .1 + .filter(|registry| is_socket_patch_registry_name(registry))?; + let owner = cargo_dotted_value(fields, "package") + .and_then(|(_, package)| package) + .unwrap_or(key); + Some((owner, registry)) +} + fn cargo_toml_string(value: &str) -> Option { let document = format!("value = {value}") .parse::() @@ -3618,6 +3686,11 @@ fn plan_cargo_toml( let mut section = CargoTomlSection::Other; // Past the last line of a multi-line inline table (#757). let mut skip_until = 0; + // Dotted-key declarations (#757), gathered per dependency table and + // classified once the whole file is read: `(in [workspace.dependencies], + // key, fields)`. + let mut dotted = CargoDottedDecls::default(); + let mut dotted_decls: Vec<(bool, String, Vec)> = Vec::new(); for (idx, raw) in lines.iter().enumerate() { if idx < skip_until { continue; @@ -3627,6 +3700,9 @@ fn plan_cargo_toml( continue; } if trimmed.starts_with('[') && !trimmed.starts_with("[[") { + if let CargoTomlSection::DepTable { workspace } = section { + dotted_decls.extend(dotted.take().into_iter().map(|(k, f)| (workspace, k, f))); + } section = match header_re.captures(trimmed) { Some(c) => classify_cargo_section( c.get(1) @@ -3747,24 +3823,11 @@ fn plan_cargo_toml( continue; }; let rest_trim = rest.trim_start(); - if let Some(dotted) = rest_trim.strip_prefix('.') { + if let Some(rest) = rest_trim.strip_prefix('.') { // Dotted entry (`serde.workspace = true`, `serde.version = "1"`, - // `alias.package = "serde"`, …). - let sub = parse_cargo_entry_key(dotted); - if sub.as_ref().is_some_and(|(key, _)| key == "workspace") { - pending.push(Pending::NeedsWorkspacePin(key.clone())); - } else if key == crate_name - || sub - .filter(|(key, _)| key == "package") - .and_then(|(_, rest)| rest.trim_start().strip_prefix('=')) - .and_then(cargo_toml_string) - .as_deref() - == Some(crate_name) - { - pending.push(Pending::Refuse( - "declared with dotted keys this rewriter does not edit".to_string(), - )); - } + // `alias.package = "serde"`, …), classified with the rest of + // its key's dotted lines. + dotted.push(idx, key, rest); continue; } let Some(value) = rest_trim.strip_prefix('=') else { @@ -3929,16 +3992,18 @@ fn plan_cargo_toml( ws_entries.insert(key.clone(), CargoWorkspaceEntry::Pinned); } } - } else if value.starts_with('"') { + } else if value.starts_with('"') || value.starts_with('\'') { if key != crate_name { continue; } - // Plain version: `crate = "1.0"` (+ optional trailing comment). - // The rewrite is line-scoped, so the trailing newline / blank - // line after the entry is untouched. + // Plain version: `crate = "1.0"` or the literal `crate = '1.0'` + // (#757), plus an optional trailing comment. The rewrite is + // line-scoped, so the trailing newline / blank line after the + // entry is untouched, and keeps the version's quotes so restore + // can hand the same spelling back. let c = regex::escape(crate_name); let line_re = Regex::new(&format!( - r#"^(\s*(?:{c}|"{c}")\s*=\s*)"([^"]+)"([ \t]*(?:#.*)?)$"# + r#"^(\s*(?:{c}|"{c}"|'{c}')\s*=\s*)("[^"]+"|'[^']+')([ \t]*(?:#.*)?)$"# )) .expect("line regex from the escaped crate name is valid"); let Some(m) = line_re.captures(raw) else { @@ -3947,10 +4012,11 @@ fn plan_cargo_toml( )); continue; }; - let req = m + let quoted = m .get(2) .expect("line_re always captures group 2 (version)") .as_str(); + let req = "ed[1..quoted.len() - 1]; match cargo_req_selects(Some(req), version, other_versions) { CargoReqMatch::NotOurs => { excluded.push(req.to_string()); @@ -3966,13 +4032,10 @@ fn plan_cargo_toml( CargoReqMatch::Ours => {} } let new_text = format!( - "{}{{ version = \"{}\", registry = \"{reg}\" }}{}", + "{}{{ version = {quoted}, registry = \"{reg}\" }}{}", m.get(1) .expect("line_re always captures group 1 (key prefix)") .as_str(), - m.get(2) - .expect("line_re always captures group 2 (version)") - .as_str(), m.get(3) .expect("line_re always captures group 3 (trailing comment)") .as_str() @@ -3991,6 +4054,111 @@ fn plan_cargo_toml( } } + if let CargoTomlSection::DepTable { workspace } = section { + dotted_decls.extend(dotted.take().into_iter().map(|(k, f)| (workspace, k, f))); + } + // A dotted-key declaration (#757) is classified like the table form, + // and pinned with a `.registry` line right after its version line, + // which restore drops whole. + let unreadable_dotted = || "declared with dotted keys this rewriter does not edit".to_string(); + for (workspace, key, fields) in dotted_decls { + let assigns = |name: &str| { + fields.iter().any(|field| { + field.sub.as_deref() == Some(name) && field.rest.trim_start().starts_with('=') + }) + }; + if assigns("workspace") { + pending.push(Pending::NeedsWorkspacePin(key)); + continue; + } + let package = cargo_dotted_value(&fields, "package"); + let is_ours = match &package { + Some((_, package)) => package.as_deref() == Some(crate_name), + None => key == crate_name, + }; + if !is_ours { + if workspace { + ws_entries.insert(key, CargoWorkspaceEntry::OtherPackage); + } + continue; + } + let requirement = cargo_dotted_value(&fields, "version"); + let deeper = fields + .iter() + .any(|field| field.sub.is_none() || !field.rest.trim_start().starts_with('=')); + if deeper || requirement.as_ref().is_some_and(|(_, req)| req.is_none()) { + pending.push(Pending::Refuse(unreadable_dotted())); + continue; + } + let req = requirement.as_ref().and_then(|(_, req)| req.clone()); + match cargo_req_selects(req.as_deref(), version, other_versions) { + CargoReqMatch::NotOurs => { + excluded.extend(req); + if workspace { + ws_entries.insert(key, CargoWorkspaceEntry::OtherVersion); + } + continue; + } + CargoReqMatch::Ambiguous => { + pending.push(Pending::Refuse(ambiguous())); + continue; + } + CargoReqMatch::Ours => {} + } + if assigns("path") || assigns("git") { + pending.push(Pending::Refuse( + "declared as a path/git dependency".to_string(), + )); + continue; + } + if assigns("registry-index") { + pending.push(Pending::Refuse("pinned to another registry".to_string())); + continue; + } + match cargo_dotted_value(&fields, "registry") { + Some((_, Some(value))) if value == reg => { + pending.push(Pending::Action(CargoTomlAction::Already)); + } + Some((idx, Some(value))) if is_repinnable_registry_name(&value) => { + let new_text = registry_val_re + .replace(lines[idx], format!("registry = \"{reg}\"").as_str()) + .into_owned(); + pending.push(Pending::Action(CargoTomlAction::ReplaceLine { + idx, + new_text, + })); + } + Some((_, value)) => { + pending.push(Pending::Refuse(format!( + "pinned to another registry (\"{}\")", + value.unwrap_or_default() + ))); + continue; + } + None => { + // After the version line (else the rename's), spelled with + // that line's key, indent and line ending. + let Some((idx, _)) = requirement.or(package) else { + pending.push(Pending::Refuse(unreadable_dotted())); + continue; + }; + let raw = lines[idx]; + let trimmed = raw.trim_start(); + let indent = &raw[..raw.len() - trimmed.len()]; + let after_key = parse_cargo_entry_key(trimmed).map_or("", |(_, rest)| rest); + let dot = trimmed.len() - after_key.len() + after_key.find('.').unwrap_or(0); + let eol = if raw.ends_with('\r') { "\r" } else { "" }; + pending.push(Pending::Action(CargoTomlAction::InsertAfterHeader { + idx, + inserted: format!("{indent}{}registry = \"{reg}\"{eol}", &trimmed[..=dot]), + })); + } + } + if workspace { + ws_entries.insert(key, CargoWorkspaceEntry::Pinned); + } + } + let not_found = |ws_entries: BTreeMap| CargoTomlPlan { content: content.to_string(), edits: Vec::new(), @@ -14862,6 +15030,18 @@ mod tests { "workspace dependencies table form", "[workspace.dependencies.serde]\nversion = \"1.0.190\"\n", ), + ( + "literal-string version (#757)", + "[dependencies]\nserde = '1.0.190'\n", + ), + ( + "dotted keys (#757)", + "[dependencies]\nserde.version = \"1.0.190\"\n", + ), + ( + "renamed dotted keys (#757)", + "[dependencies]\nlegacy.package = \"serde\"\nlog = \"0.4\"\nlegacy.version = \"1.0.190\"\n", + ), ]; for (shape, body) in shapes { let pristine = format!("{head}{body}"); @@ -15469,8 +15649,11 @@ mod tests { })); } + /// #757: a dotted-key rename with literal strings and quoted keys pins + /// beside the crate's own declaration, so both resolve from the + /// managed registry. #[test] - fn cargo_dotted_literal_renames_refuse_every_declaration() { + fn cargo_dotted_literal_renames_pin_every_declaration() { for alias in ["alias", "\"alias\"", "'alias'"] { for package_key in ["package", "\"package\"", "'package'"] { let manifest = format!( @@ -15481,13 +15664,31 @@ mod tests { assert!(manifest.parse::().is_ok()); let result = rewrite_registry_redirect(&cargo_files(&manifest), &[cargo_sparse_override()]); - assert!(result.files.is_empty(), "{manifest}"); - assert!(result.edits.is_empty(), "{manifest}"); - assert!(result.confirmed_cargo_uuids.is_empty(), "{manifest}"); - assert!(result - .warnings - .iter() - .any(|warning| warning.code == "redirect_cargo_toml_dep_unrewritable")); + assert!( + result.warnings.is_empty(), + "{manifest}: {:?}", + result.warnings + ); + let toml = result + .files + .get("Cargo.toml") + .expect("Cargo.toml rewritten"); + assert!( + toml.ends_with(&format!( + "{alias}.version = '1.0.190'\n{alias}.registry = \"{}\"\n", + cargo_reg() + )), + "{toml}" + ); + let doc: toml_edit::DocumentMut = toml.parse().expect("still valid TOML"); + for key in ["serde", "alias"] { + assert_eq!( + doc["dependencies"][key]["registry"].as_str(), + Some(cargo_reg().as_str()), + "{toml}" + ); + } + assert!(result.confirmed_cargo_uuids.contains(CARGO_UUID)); } } } @@ -15724,7 +15925,8 @@ mod tests { let files = cargo_files( "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n\ [dependencies]\nserde = \"1.0.190\"\n\n\ - [dev-dependencies]\nserde.version = \"1.0.190\"\n", + [dev-dependencies]\nserde.version = \"1.0.190\"\n\ + serde.registry-index = \"sparse+https://index.example/\"\n", ); let r = rewrite_registry_redirect(&files, &[cargo_sparse_override()]); assert!( @@ -24137,15 +24339,20 @@ packages: #[test] fn cargo_unsupported_spellings_refuse_whole_dep() { for entry in [ - // Dotted keys (both the crate's own and an alias renaming it). - "serde.version = \"1.0.190\"\n", - "iffy.package = \"serde\"\n", // Inline table that does not close on its line. "serde = {\n version = \"1.0.190\" }\n", - // registry-index beside a would-be registry pin is ambiguous. + // registry-index beside a would-be registry pin is ambiguous, + // in every spelling. "serde = { version = \"1.0.190\", registry-index = \"sparse+https://index.example/\" }\n", - // Single-quoted and non-string values. - "serde = '1.0.190'\n", + "serde.version = \"1.0.190\"\nserde.registry-index = \"sparse+https://index.example/\"\n", + // Path/git and another registry, as dotted keys. + "serde.version = \"1.0.190\"\nserde.path = \"../serde\"\n", + "serde.git = \"https://example.com/serde\"\nserde.version = \"1.0.190\"\n", + "serde.version = \"1.0.190\"\nserde.registry = \"corp-mirror\"\n", + // A dotted version that is not a one-line string. + "serde.version = \"\"\"\n1.0.190\"\"\"\n", + // Multi-line literal and non-string values. + "serde = '''1.0.190'''\n", "serde = 13\n", // A quoted version with trailing junk the line grammar rejects. "serde = \"1.0.190\" trailing junk\n", @@ -24173,6 +24380,210 @@ packages: } } + /// #757: a literal-string version (`serde = '1.0.190'`) is pinned like + /// a basic-string one, keeping the user's quotes inside the table so + /// restore can hand the same spelling back. + #[test] + fn cargo_literal_string_version_is_pinned() { + let reg = cargo_reg(); + for (entry, want) in [ + ( + "serde = '1.0.190'\n", + format!("serde = {{ version = '1.0.190', registry = \"{reg}\" }}\n"), + ), + ( + "serde = '1.0.190' # why\n", + format!("serde = {{ version = '1.0.190', registry = \"{reg}\" }} # why\n"), + ), + ( + "'serde' = '^1.0'\n", + format!("'serde' = {{ version = '^1.0', registry = \"{reg}\" }}\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 in place: {toml}" + ); + let doc: toml_edit::DocumentMut = toml.parse().expect("still valid TOML"); + assert_eq!( + doc["dependencies"]["serde"]["registry"].as_str(), + Some(reg.as_str()) + ); + assert!(r.confirmed_cargo_uuids.contains(CARGO_UUID)); + } + } + } + + /// #757: a dependency declared with dotted keys (`serde.version = …`) + /// gains a `.registry` line right after its version line, keeping + /// the key's own spelling and the line's ending. A rename, a wrapped + /// features array, an indent, other dependencies between the dotted + /// lines and an explicit `registry = "crates-io"` (#386) all pin. + #[test] + fn cargo_dotted_keys_are_pinned() { + let reg = cargo_reg(); + let pin = |key: &str| format!("{key}.registry = \"{reg}\"\n"); + for (entry, want) in [ + ( + "serde.version = \"1.0.190\"\n".to_string(), + format!("serde.version = \"1.0.190\"\n{}", pin("serde")), + ), + ( + "serde.version = \"1.0.190\" # why\nserde.features = [\n \"derive\",\n]\n" + .to_string(), + format!( + "serde.version = \"1.0.190\" # why\n{}serde.features = [\n \"derive\",\n]\n", + pin("serde") + ), + ), + ( + "serde.features = [\"derive\"]\nlog = \"0.4\"\nserde.version = '1.0.190'\n" + .to_string(), + format!( + "serde.features = [\"derive\"]\nlog = \"0.4\"\nserde.version = '1.0.190'\n{}", + pin("serde") + ), + ), + ( + "legacy.package = \"serde\"\nlegacy.version = \"1.0.190\"\n".to_string(), + format!( + "legacy.package = \"serde\"\nlegacy.version = \"1.0.190\"\n{}", + pin("legacy") + ), + ), + ( + " \"serde\" . version = \"1.0.190\"\n".to_string(), + format!( + " \"serde\" . version = \"1.0.190\"\n {}", + pin("\"serde\" ") + ), + ), + ( + "serde.version = \"1.0.190\"\nserde.registry = \"crates-io\" # explicit\n" + .to_string(), + format!("serde.version = \"1.0.190\"\nserde.registry = \"{reg}\" # explicit\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 after the version line: {toml}" + ); + let doc: toml_edit::DocumentMut = toml.parse().expect("still valid TOML"); + let pinned = doc["dependencies"] + .as_table() + .unwrap() + .iter() + .filter_map(|(_, dep)| dep.get("registry")?.as_str().map(str::to_string)) + .collect::>(); + assert_eq!(pinned, vec![reg.clone()], "[{entry:?} crlf={crlf}]"); + assert!(r.confirmed_cargo_uuids.contains(CARGO_UUID)); + // A rescan of the planner's own output writes nothing. + let again = + rewrite_registry_redirect(&cargo_files(toml), &[cargo_sparse_override()]); + assert!( + again.warnings.is_empty(), + "[{entry:?}] {:?}", + again.warnings + ); + assert!( + again.files.get("Cargo.toml").is_none_or(|t| t == toml), + "[{entry:?} crlf={crlf}] an already-pinned manifest is left as is" + ); + } + } + } + + /// #757: `[workspace.dependencies]` declared with dotted keys is pinned, + /// and a member's `serde.workspace = true` inherits it; a dotted entry + /// naming another version of the crate is left alone. + #[test] + fn cargo_dotted_workspace_dependency_is_pinned() { + let reg = cargo_reg(); + let manifest = "[workspace]\nmembers = []\n\n[workspace.dependencies]\n\ + serde.version = \"1.0.190\"\n\n[package]\nname = \"app\"\n\ + version = \"0.1.0\"\n\n[dependencies]\nserde.workspace = true\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("Cargo.toml rewritten"); + assert!( + toml.contains(&format!( + "[workspace.dependencies]\nserde.version = \"1.0.190\"\nserde.registry = \"{reg}\"\n" + )), + "{toml}" + ); + assert!(toml.ends_with("serde.workspace = true\n"), "{toml}"); + assert!(r.confirmed_cargo_uuids.contains(CARGO_UUID)); + + let other = "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n[dependencies]\n\ + serde.version = \"2\"\n"; + let r = rewrite_registry_redirect(&cargo_files(other), &[cargo_sparse_override()]); + assert!( + r.files.is_empty(), + "another version is not pinned: {:?}", + r.files + ); + assert!(r.confirmed_cargo_uuids.is_empty()); + } + + /// #757: a rescan that picks a newer patch moves an earlier dotted pin + /// to the new registry in place, as it does for every other spelling. + #[test] + fn cargo_dotted_pin_is_superseded_in_place() { + let old = "socket-patch-00000000-0000-4000-8000-000000000000"; + let manifest = format!( + "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n[dependencies]\n\ + serde.version = \"1.0.190\"\nserde.registry = \"{old}\"\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("Cargo.toml rewritten"); + assert!( + toml.ends_with(&format!( + "serde.version = \"1.0.190\"\nserde.registry = \"{}\"\n", + cargo_reg() + )), + "{toml}" + ); + } + /// An inline table whose inner already ends with a trailing comma gains /// the registry pin without doubling the separator. #[test] 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 140ce2a7f..3ed3364b4 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/cargo.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/cargo.rs @@ -326,20 +326,32 @@ fn lock_names_index(text: &str, uuid: &str) -> bool { static SHORTHAND_RE: std::sync::LazyLock = std::sync::LazyLock::new(|| { Regex::new( - r#"^(\s*[^=\s][^=]*?\s*=\s*)\{ version = "([^"]+)", registry = "(socket-patch-[0-9a-fA-F-]{36})" \}(.*)$"#, + r#"^(\s*[^=\s][^=]*?\s*=\s*)\{ version = ("[^"]+"|'[^']+'), registry = "(socket-patch-[0-9a-fA-F-]{36})" \}(.*)$"#, ) .expect("static shorthand regex is valid") }); +/// Whether `body` (a line without its comment) is a dotted-key pin, +/// `.registry = ""` (#757). +fn is_dotted_pin(body: &str, reg: &str) -> bool { + let pinned = super::super::parse_cargo_entry_key(body) + .and_then(|(_, rest)| rest.trim_start().strip_prefix('.')) + .and_then(|rest| super::super::parse_cargo_entry_key(rest.trim_start())) + .filter(|(sub, _)| sub == "registry") + .and_then(|(_, rest)| rest.trim_start().strip_prefix('=')); + pinned.is_some_and(|value| value.trim() == format!("\"{reg}\"")) +} + /// The line with its `registry = ""` pin removed: `Some(None)` drops -/// the whole line (the table form's `registry = …` line; a trailing -/// comment on it survives as a comment-only line), +/// the whole line (the table form's `registry = …` line, or a dotted +/// `.registry = …` one; a trailing comment on it survives as a +/// comment-only line), /// `Some(Some(line))` is the unpinned declaration, `None` a spelling this /// restore does not recognize. fn unpin_line(line: &str, reg: &str) -> Option> { let trimmed = line.trim(); let body = trimmed.split('#').next().unwrap_or(trimmed).trim(); - if body == format!("registry = \"{reg}\"") { + if body == format!("registry = \"{reg}\"") || is_dotted_pin(body, reg) { // The pin may have overwritten a user's commented line in place // (a superseded pin, or `registry = "crates-io"`): the comment // stays, on its own line. @@ -351,7 +363,7 @@ fn unpin_line(line: &str, reg: &str) -> Option> { } if let Some(c) = SHORTHAND_RE.captures(line) { if &c[3] == reg { - return Some(Some(format!("{}\"{}\"{}", &c[1], &c[2], &c[4]))); + return Some(Some(format!("{}{}{}", &c[1], &c[2], &c[4]))); } } let open = line.find('{')?; @@ -591,6 +603,92 @@ mod tests { } } + /// #757: the rewriter pins a dotted-key dependency with an added + /// `.registry` line, and a literal-string version as a table + /// that keeps the user's quotes; restore drops or collapses exactly + /// that. + #[test] + fn dotted_and_literal_pins_unpin_to_the_original_spelling() { + for (pinned, original) in [ + (format!("serde.registry = \"{REG}\""), None), + (format!(" \"serde\" .registry = \"{REG}\"\r"), None), + (format!("'serde'.registry = \"{REG}\""), None), + ( + format!("serde.registry = \"{REG}\" # explicit"), + Some("# explicit".to_string()), + ), + ( + format!("serde = {{ version = '1.0.190', registry = \"{REG}\" }} # why\r"), + Some("serde = '1.0.190' # why\r".to_string()), + ), + ( + format!("'serde' = {{ version = '^1.0', registry = \"{REG}\" }}"), + Some("'serde' = '^1.0'".to_string()), + ), + ] { + assert_eq!(unpin_line(&pinned, REG), Some(original), "{pinned:?}"); + } + // Another crate's own `registry` key is not a dotted pin. + assert_eq!( + unpin_line(&format!("serde.registry.x = \"{REG}\""), REG), + None + ); + } + + /// #757 end to end: restoring hosted pins written as dotted keys and + /// as a literal-string version returns the manifest byte-exact, for + /// LF, CRLF and a BOM. + #[tokio::test] + async fn restore_unpins_dotted_and_literal_pins_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\" # why\ncfg-if.features = [\n \"core\",\n]\n\ + log = '0.4.20'\n" + ); + let pinned = format!( + "{head}cfg-if.version = \"1.0.4\" # why\ncfg-if.registry = \"socket-patch-{B}\"\n\ + cfg-if.features = [\n \"core\",\n]\n\ + log = {{ version = '0.4.20', registry = \"socket-patch-{B}\" }}\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 94c47d63d6590dc8ffa82f1b8ab613fa2d6ecdc4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 16:50:52 +0000 Subject: [PATCH 3/4] Cover dotted and literal cargo pins end to end A real-cargo shape pins dotted keys (with a wrapped features array and a rename) and a literal-string version for LF and CRLF manifests, builds against the patched copy offline, and checks remove restores every byte. The vendored-to-hosted takeover test needs spellings hosted mode still refuses, so it now uses multi-line string versions instead of the two spellings that hosted mode pins now. Refs #757 Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/e2e_redirect_cargo_shapes.rs | 55 +++++++++++++++++++ .../tests/mode_migration_cargo.rs | 9 +-- 2 files changed, 60 insertions(+), 4 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 5a7808ef8..e8631fac6 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_cargo_shapes.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_cargo_shapes.rs @@ -1152,6 +1152,61 @@ async fn cargo_hosted_multiline_inline_table_is_pinned() { } } +/// #757: dotted keys (`cfg-if.version = …`, here with a wrapped features +/// array after the version line) and a literal-string version +/// (`cfg-if = '…'`) were refused as spellings the line grammar does not +/// edit. Each is pinned, builds against the patched copy, and `remove` +/// restores every byte, for LF and CRLF manifests. +#[tokio::test(flavor = "multi_thread")] +async fn cargo_hosted_dotted_and_literal_spellings_are_pinned() { + for (tag, dep, crlf) in [ + ( + "dotted", + "cfg-if.version = \"1.0.4\" # pinned\ncfg-if.features = [\n]\n", + false, + ), + ( + "dotted-crlf", + "cfg-if.version = \"1.0.4\" # pinned\ncfg-if.features = [\n]\n", + true, + ), + ( + "dotted-renamed", + "legacy.package = \"cfg-if\"\nlegacy.version = '1.0.4'\n", + false, + ), + ("literal", "cfg-if = '1.0.4' # pinned\n", false), + ("literal-crlf", "cfg-if = '1.0.4' # pinned\n", true), + ] { + let crate_ref = if tag == "dotted-renamed" { + "legacy" + } else { + "cfg_if" + }; + let shape = Shape { + tag, + files: vec![ + ("Cargo.toml", consumer_manifest(dep)), + ("src/main.rs", "fn main() {}\n".to_string()), + ], + patches: vec![CFG_IF_1], + oracle: vec![( + "src/main.rs", + format!("fn main() {{ println!(\"{{}}\", {crate_ref}::socket_patched()); }}\n"), + )], + 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 b035b8683..e9b28c34c 100644 --- a/crates/socket-patch-cli/tests/mode_migration_cargo.rs +++ b/crates/socket-patch-cli/tests/mode_migration_cargo.rs @@ -1341,10 +1341,11 @@ fn hosted_pinned(env: &serde_json::Value) -> u64 { #[tokio::test(flavor = "multi_thread")] 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")), - // `registry = "crates-io"` (#386) and an inline table whose array - // wraps lines (#757) were cases here until hosted mode pinned them. + ("multi-line-literal", format!("{DEP} = '''1.0'''\n")), + ("multi-line-basic", format!("{DEP} = \"\"\"1.0\"\"\"\n")), + // `registry = "crates-io"` (#386), an inline table whose array + // wraps lines, dotted keys and a literal-string version (#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 { From b29ceb4fab7a468522bfca322c6a5ddd2e557400 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 17:06:31 +0000 Subject: [PATCH 4/4] Keep literal cargo tables byte-exact on restore Review follow-ups for the dotted-key and literal-string pins: - A literal version is now pinned registry-first. The earlier spelling matched what the one-line rewrite writes for a user's own { version = '...' } table, so remove collapsed that table into a bare string. That table now comes back byte-exact. - A dotted rename whose package value can't be read falls back to the key, as the table form and the pin readers do. - A dotted registry value that can't be read is reported as an unreadable spelling, not as a registry named "". - Doc comments name the dotted shape and the readers' scan order. Refs #757 Assisted-by: Claude Code:claude-opus-5-5 --- .../src/patch/redirect/mod.rs | 76 ++++++++++++++++--- .../src/patch/redirect/upstream/cargo.rs | 27 ++++++- 2 files changed, 88 insertions(+), 15 deletions(-) diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index e5792df4e..0f9104add 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -2827,7 +2827,8 @@ fn is_repinnable_registry_name(value: &str) -> bool { /// The `socket-patch-` registry name pinning `crate_name` in this /// manifest, in EVERY declaration shape [`plan_cargo_toml`] writes: a /// `[…dependencies.]` header table (whose pin is a standalone -/// `registry = …` line), an inline table, a quoted key, and a rename +/// `registry = …` line), an inline table, dotted keys (`.registry`, +/// read at the end of their table, #757), a quoted key, and a rename /// (`package = ""` under any key). Readers that probe for a LIVE /// hosted redirect use this rather than a single-line regex, which saw only /// the inline spelling and read the other three as "not redirected". @@ -2945,7 +2946,9 @@ pub(crate) fn cargo_socket_registry_pin(content: &str, crate_name: &str) -> Opti } /// Every Socket registry pin a manifest carries, as `(crate, registry)` in -/// file order — [`cargo_socket_registry_pin`] for all crates at once. That +/// scan order (file order, except that a table's dotted-key pins come at +/// the end of that table) — [`cargo_socket_registry_pin`] for all crates +/// at once. That /// scan's per-crate test is always "the declaration's owning crate (its /// `package` rename, else its key) equals the crate asked about", and /// everything else it computes is independent of the crate asked about, so @@ -4031,8 +4034,16 @@ fn plan_cargo_toml( } CargoReqMatch::Ours => {} } + // A literal version is pinned registry-first, a spelling the + // one-line inline rewrite never writes, so restore can tell it + // from a user's own `{ version = '…' }` table. + let fields = if quoted.starts_with('\'') { + format!("registry = \"{reg}\", version = {quoted}") + } else { + format!("version = {quoted}, registry = \"{reg}\"") + }; let new_text = format!( - "{}{{ version = {quoted}, registry = \"{reg}\" }}{}", + "{}{{ {fields} }}{}", m.get(1) .expect("line_re always captures group 1 (key prefix)") .as_str(), @@ -4072,8 +4083,10 @@ fn plan_cargo_toml( continue; } let package = cargo_dotted_value(&fields, "package"); - let is_ours = match &package { - Some((_, package)) => package.as_deref() == Some(crate_name), + // An unreadable rename falls back to the key, as the table form + // and the pin readers do. + let is_ours = match package.as_ref().and_then(|(_, package)| package.as_deref()) { + Some(package) => package == crate_name, None => key == crate_name, }; if !is_ours { @@ -4128,13 +4141,16 @@ fn plan_cargo_toml( new_text, })); } - Some((_, value)) => { + Some((_, Some(value))) => { pending.push(Pending::Refuse(format!( - "pinned to another registry (\"{}\")", - value.unwrap_or_default() + "pinned to another registry (\"{value}\")" ))); continue; } + Some((_, None)) => { + pending.push(Pending::Refuse(unreadable_dotted())); + continue; + } None => { // After the version line (else the rename's), spelled with // that line's key, indent and line ending. @@ -24349,6 +24365,8 @@ packages: "serde.version = \"1.0.190\"\nserde.path = \"../serde\"\n", "serde.git = \"https://example.com/serde\"\nserde.version = \"1.0.190\"\n", "serde.version = \"1.0.190\"\nserde.registry = \"corp-mirror\"\n", + // A dotted registry that is not a one-line string. + "serde.version = \"1.0.190\"\nserde.registry = \"\"\"\ncorp\"\"\"\n", // A dotted version that is not a one-line string. "serde.version = \"\"\"\n1.0.190\"\"\"\n", // Multi-line literal and non-string values. @@ -24389,15 +24407,15 @@ packages: for (entry, want) in [ ( "serde = '1.0.190'\n", - format!("serde = {{ version = '1.0.190', registry = \"{reg}\" }}\n"), + format!("serde = {{ registry = \"{reg}\", version = '1.0.190' }}\n"), ), ( "serde = '1.0.190' # why\n", - format!("serde = {{ version = '1.0.190', registry = \"{reg}\" }} # why\n"), + format!("serde = {{ registry = \"{reg}\", version = '1.0.190' }} # why\n"), ), ( "'serde' = '^1.0'\n", - format!("'serde' = {{ version = '^1.0', registry = \"{reg}\" }}\n"), + format!("'serde' = {{ registry = \"{reg}\", version = '^1.0' }}\n"), ), ] { for crlf in [false, true] { @@ -24563,6 +24581,42 @@ packages: assert!(r.confirmed_cargo_uuids.is_empty()); } + /// #757: a dotted rename whose `package` value can't be read falls back + /// to the key, as the table form does, so the crate's declarations in + /// every dependency kind are pinned together, never partially. + #[test] + fn cargo_dotted_unreadable_package_falls_back_to_the_key() { + let manifest = "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n\ + [dependencies]\nserde = \"1.0.190\"\n\n[dev-dependencies]\n\ + serde.package = \"\"\"\nserde\"\"\"\nserde.version = \"1.0.190\"\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("Cargo.toml rewritten"); + let doc: toml_edit::DocumentMut = toml.parse().expect("still valid TOML"); + for kind in ["dependencies", "dev-dependencies"] { + assert_eq!( + doc[kind]["serde"]["registry"].as_str(), + Some(cargo_reg().as_str()), + "{kind}: {toml}" + ); + } + let r = rewrite_registry_redirect( + &cargo_files(&format!( + "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n[dependencies]\n\ + serde.version = \"1.0.190\"\nserde.registry = \"\"\"\ncorp\"\"\"\n" + )), + &[cargo_sparse_override()], + ); + assert!(r.files.is_empty()); + assert!( + r.warnings + .iter() + .any(|w| w.detail.contains("dotted keys this rewriter does not edit")), + "an unreadable registry is not named as another registry: {:?}", + r.warnings + ); + } + /// #757: a rescan that picks a newer patch moves an earlier dotted pin /// to the new registry in place, as it does for every other spelling. #[test] 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 3ed3364b4..fe2b39946 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/cargo.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/cargo.rs @@ -326,11 +326,20 @@ fn lock_names_index(text: &str, uuid: &str) -> bool { static SHORTHAND_RE: std::sync::LazyLock = std::sync::LazyLock::new(|| { Regex::new( - r#"^(\s*[^=\s][^=]*?\s*=\s*)\{ version = ("[^"]+"|'[^']+'), registry = "(socket-patch-[0-9a-fA-F-]{36})" \}(.*)$"#, + r#"^(\s*[^=\s][^=]*?\s*=\s*)\{ version = ("[^"]+"), registry = "(socket-patch-[0-9a-fA-F-]{36})" \}(.*)$"#, ) .expect("static shorthand regex is valid") }); +/// The rewriter's pin of a literal-string version (#757), written +/// registry-first so it is never a user's own `{ version = '…' }` table. +static LITERAL_SHORTHAND_RE: std::sync::LazyLock = std::sync::LazyLock::new(|| { + Regex::new( + r#"^(\s*[^=\s][^=]*?\s*=\s*)\{ registry = "(socket-patch-[0-9a-fA-F-]{36})", version = ('[^']+') \}(.*)$"#, + ) + .expect("static literal shorthand regex is valid") +}); + /// Whether `body` (a line without its comment) is a dotted-key pin, /// `.registry = ""` (#757). fn is_dotted_pin(body: &str, reg: &str) -> bool { @@ -366,6 +375,11 @@ fn unpin_line(line: &str, reg: &str) -> Option> { return Some(Some(format!("{}{}{}", &c[1], &c[2], &c[4]))); } } + if let Some(c) = LITERAL_SHORTHAND_RE.captures(line) { + if &c[2] == reg { + return Some(Some(format!("{}{}{}", &c[1], &c[3], &c[4]))); + } + } let open = line.find('{')?; // The opening line of a multi-line inline table, which the rewriter // pins right after its `{` (#757). @@ -618,13 +632,18 @@ mod tests { Some("# explicit".to_string()), ), ( - format!("serde = {{ version = '1.0.190', registry = \"{REG}\" }} # why\r"), + format!("serde = {{ registry = \"{REG}\", version = '1.0.190' }} # why\r"), Some("serde = '1.0.190' # why\r".to_string()), ), ( - format!("'serde' = {{ version = '^1.0', registry = \"{REG}\" }}"), + format!("'serde' = {{ registry = \"{REG}\", version = '^1.0' }}"), Some("'serde' = '^1.0'".to_string()), ), + // A user's own one-line literal table keeps its table. + ( + format!("serde = {{ version = '1.0.190', registry = \"{REG}\" }}"), + Some("serde = { version = '1.0.190' }".to_string()), + ), ] { assert_eq!(unpin_line(&pinned, REG), Some(original), "{pinned:?}"); } @@ -648,7 +667,7 @@ mod tests { let pinned = format!( "{head}cfg-if.version = \"1.0.4\" # why\ncfg-if.registry = \"socket-patch-{B}\"\n\ cfg-if.features = [\n \"core\",\n]\n\ - log = {{ version = '0.4.20', registry = \"socket-patch-{B}\" }}\n" + log = {{ registry = \"socket-patch-{B}\", version = '0.4.20' }}\n" ); for (crlf, bom) in [(false, false), (true, false), (false, true), (true, true)] { let shape = |s: &str| {