From cdd2d617d487045472907794fbf5bd727356b8ac Mon Sep 17 00:00:00 2001 From: Leif Date: Fri, 24 Jul 2026 13:42:02 -0600 Subject: [PATCH 1/3] fix: parse local registries with real TOML, support [[modules]] (closes #413) --- Cargo.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/Cargo.toml b/Cargo.toml index 66314441..b39d33f8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,6 +42,7 @@ tree-sitter-commonlisp = "0.4.1" tree-sitter-scheme = "0.24.7" tree-sitter-elisp = "1.6.1" tree-sitter-perl = "1.1.2" +toml = "1.1.3" [dev-dependencies] tempfile = "3" From bb88d529bb40799aee8c65ce50e46550e52b857d Mon Sep 17 00:00:00 2001 From: Leif Date: Fri, 24 Jul 2026 13:44:13 -0600 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20registry=20parser=20via=20toml=20cra?= =?UTF-8?q?te=20=E2=80=94=20[[modules]]=20support,=20fail-closed=20malform?= =?UTF-8?q?ed=20TOML=20(closes=20#413)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/registry.rs | 152 +++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 124 insertions(+), 28 deletions(-) diff --git a/src/registry.rs b/src/registry.rs index 3fed0ab2..04c19b38 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -156,8 +156,12 @@ pub fn fetch_remote_registry(repo: &str) -> Result { /// Matches inert 5.0.1-era placeholders such as `version = 1` plus an empty /// `[modules]` table that never carried module authority under 5.1.x parsing. pub fn is_inert_legacy_registry_stub(content: &str) -> bool { - let (name, specs) = scan_registry_fields(content); - name.is_empty() && specs.is_empty() + match parse_registry_toml(content) { + Ok((name, specs)) => name.is_empty() && specs.is_empty(), + // Malformed TOML is not inert — it must fail closed in + // load_local_registry, not silently vanish. + Err(_) => false, + } } /// Load the local registry with explicit missing/inert/invalid discrimination. @@ -189,49 +193,75 @@ pub fn load_registry(root: &Path) -> Option { load_local_registry(root).ok().flatten() } -/// Scan registry TOML for a `name` value and `[specs]` mappings. -fn scan_registry_fields(content: &str) -> (String, Vec<(String, String)>) { +/// Parse registry TOML into `(name, spec mappings)` with a real TOML parser. +/// +/// Two mapping shapes are supported: +/// - the generated `[specs]` table (`module = "path"`), emitted by +/// `init-registry` / `generate_registry` +/// - the documented `[[modules]]` array-of-tables (`name` + `spec` keys) from +/// `cross-project-refs.md` +/// +/// The registry `name` is read from `[registry] name` first, then a top-level +/// `name`. Returns `Err` on malformed TOML or malformed `[[modules]]` entries +/// so callers fail closed instead of silently dropping mappings. +fn parse_registry_toml(content: &str) -> Result<(String, Vec<(String, String)>), String> { + let value: toml::Value = + toml::from_str(content).map_err(|e| format!("invalid TOML: {e}"))?; + let mut name = String::new(); - let mut specs = Vec::new(); - let mut in_specs = false; + if let Some(n) = value.get("name").and_then(|v| v.as_str()) { + name = n.to_string(); + } + if let Some(n) = value + .get("registry") + .and_then(|r| r.get("name")) + .and_then(|v| v.as_str()) + { + name = n.to_string(); + } - for line in content.lines() { - let line = line.trim(); - if line.is_empty() || line.starts_with('#') { - continue; - } + let mut specs: Vec<(String, String)> = Vec::new(); - if line.starts_with('[') && line.ends_with(']') { - in_specs = line == "[specs]"; - continue; + // Generated shape: [specs] table of module = "path". + if let Some(table) = value.get("specs").and_then(|v| v.as_table()) { + for (module, path) in table { + if let Some(path) = path.as_str() { + specs.push((module.clone(), path.to_string())); + } } + } - if let Some(eq_pos) = line.find('=') { - let key = line[..eq_pos].trim(); - let value = line[eq_pos + 1..].trim(); - let value = value.trim_matches('"'); - - if in_specs { - specs.push((key.to_string(), value.to_string())); - } else if key == "name" && !value.is_empty() { - name = value.to_string(); + // Documented shape: [[modules]] array of { name, spec }. + if let Some(modules) = value.get("modules").and_then(|v| v.as_array()) { + for module in modules { + let module_name = module.get("name").and_then(|v| v.as_str()); + let spec_path = module.get("spec").and_then(|v| v.as_str()); + match (module_name, spec_path) { + (Some(m), Some(p)) => specs.push((m.to_string(), p.to_string())), + _ => { + return Err( + "every [[modules]] entry needs both `name` and `spec` keys".to_string() + ); + } } } } - (name, specs) + Ok((name, specs)) } /// Parse registry TOML content. +/// +/// Returns `None` when the content is malformed TOML or carries no registry +/// `name` — callers treat that as "failed to parse" and fail closed. fn parse_registry(content: &str) -> Option { - let (name, specs) = scan_registry_fields(content); + let (name, specs) = parse_registry_toml(content).ok()?; if name.is_empty() { return None; } Some(RegistryEntry { name, specs }) } - /// Generate a registry file by scanning for spec files. pub fn generate_registry(root: &Path, project_name: &str, specs_dir: &str) -> String { let specs_path = root.join(specs_dir); @@ -272,15 +302,35 @@ pub fn generate_registry(root: &Path, project_name: &str, specs_dir: &str) -> St let mut output = String::new(); output.push_str("[registry]\n"); - output.push_str(&format!("name = \"{project_name}\"\n")); + output.push_str(&format!("name = \"{}\"\n", toml_escape(project_name))); output.push_str("\n[specs]\n"); for (module, path) in &specs { - output.push_str(&format!("{module} = \"{path}\"\n")); + output.push_str(&format!("{module} = \"{}\"\n", toml_escape(path))); } output } +/// Escape a string for embedding inside a TOML basic string (`"..."`). +/// Without this, a project name containing `"`, `\`, or control characters +/// (e.g. newlines) produces a registry that fails TOML parsing — or worse, +/// injects extra keys into it. +fn toml_escape(value: &str) -> String { + let mut out = String::with_capacity(value.len()); + for ch in value.chars() { + match ch { + '\\' => out.push_str("\\\\"), + '"' => out.push_str("\\\""), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if c.is_control() => out.push_str(&format!("\\u{:04X}", c as u32)), + c => out.push(c), + } + } + out +} + /// Add a module entry to an existing local registry file /// (`.specsync/registry.toml`, falling back to legacy `specsync-registry.toml`). /// If the module already exists, it is not duplicated. @@ -449,4 +499,50 @@ messaging = "specs/messaging/messaging.spec.md" assert!(reg.has_spec("messaging")); assert!(!reg.has_spec("nonexistent")); } + + #[test] + fn generate_registry_escapes_toml_injection() { + // #440: a --name with quotes/newlines must produce valid TOML. + let tmp = tempfile::TempDir::new().unwrap(); + let content = generate_registry(tmp.path(), "evil\"\n[specs]\npwned=\"x", "specs"); + let parsed: Result = toml::from_str(&content); + assert!(parsed.is_ok(), "generated registry must be valid TOML: {content}"); + // No injected [specs] key survived. + let value = parsed.unwrap(); + assert!(value.get("specs").is_none() || value["specs"].get("pwned").is_none()); + } + + #[test] + fn parse_registry_supports_documented_modules_shape() { + // #413 facet 1: [[modules]] array-of-tables must not drop mappings. + let content = "[registry]\nname = \"probe\"\n\n[[modules]]\nname = \"lib\"\nspec = \"custom/lib.spec.md\"\n"; + let entry = parse_registry(content).expect("documented shape parses"); + assert_eq!(entry.name, "probe"); + assert_eq!( + entry.specs, + vec![("lib".to_string(), "custom/lib.spec.md".to_string())] + ); + } + + #[test] + fn load_local_registry_fails_closed_on_malformed_toml_with_name_line() { + // #413 facet 2: `name = {{{` is not valid TOML and must not parse. + let temp = tempfile::TempDir::new().unwrap(); + let root = temp.path(); + fs::create_dir_all(root.join(".specsync")).unwrap(); + fs::write(root.join(".specsync/registry.toml"), "version = 1\nname = {{{\n").unwrap(); + + assert!(!is_inert_legacy_registry_stub( + "version = 1\nname = {{{\n" + )); + let error = load_local_registry(root).unwrap_err(); + assert!(error.contains("failed to parse local registry"), "{error}"); + } + + #[test] + fn modules_entry_missing_spec_key_is_an_error() { + let content = "[registry]\nname = \"probe\"\n\n[[modules]]\nname = \"lib\"\n"; + assert!(parse_registry(content).is_none()); + assert!(!is_inert_legacy_registry_stub(content)); + } } From be6263b8c8040e7e2921e7908658ece920b347d9 Mon Sep 17 00:00:00 2001 From: Leif Date: Fri, 24 Jul 2026 13:54:42 -0600 Subject: [PATCH 3/3] fix: lockfile entries for toml 1.1.3 dependency (closes #413) --- Cargo.lock | 55 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index bd0e92bc..dd713ebc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1096,6 +1096,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + [[package]] name = "sha2" version = "0.10.9" @@ -1148,6 +1157,7 @@ dependencies = [ "serde_json", "sha2", "tempfile", + "toml", "tree-sitter", "tree-sitter-c", "tree-sitter-commonlisp", @@ -1291,6 +1301,45 @@ dependencies = [ "zerovec", ] +[[package]] +name = "toml" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + [[package]] name = "tree-sitter" version = "0.26.10" @@ -1818,6 +1867,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + [[package]] name = "wit-bindgen" version = "0.46.0"