KiCad - the KiCad Bridge
Public Made by Adomby adom
Drive your own KiCad from an AI, in the background: open every editor, read the whole board as data (the EDA Bridge Standard board model), stack-up, part heights, netlists, place, route, pour, DRC and exports.
1.0.103: PCM verbs refuse an identifier that is a path #67
1.0.103: kicad_pcm_install and kicad_pcm_uninstall refuse an identifier that is a path.
The package identifier becomes a folder name that install and uninstall delete. An absolute path or a drive prefix replaces the whole path when it is joined, so an identifier that was a full folder path deleted that folder and reported success. A package zip chooses its own identifier in metadata.json, so a crafted zip could do this through kicad_pcm_install {zipPath}.
The identifier may now hold only letters, digits, '.', '-' and '_', which every valid KiCad package identifier already fits. Anything else answers errorCode invalid_identifier before anything is deleted or written. kicad_pcm_list reports onDisk false for such a row without looking on disk.
Found while reviewing the macOS port in PR #64, which carries the same logic.
Tested: a new test keeps a target folder through install from a zip, install by argument and uninstall, and fails with the check switched off. All kicad-core tests pass. Not yet run on KiCad on Windows.
🤖 Generated with Claude Code
Diff Skip to comments
@@ -1,1737 +1,1847 @@⋯ 865 unchanged lines ⋯ identifier.replace('.', "_") } +/// Can this identifier name a package folder under 3rdparty? It becomes a path segment that+/// install and uninstall delete: an absolute path or a drive prefix would replace the whole path+/// in Path::join, and a package zip's metadata.json chooses it. KiCad's schema allows letters,+/// digits, '-' and '.'; '_' is kept for packages already installed under it.+pub fn identifier_is_safe(identifier: &str) -> bool {+ !identifier.is_empty() && identifier.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_'))+}++fn invalid_identifier(identifier: &str) -> Value {+ json!({"success": false, "errorCode": "invalid_identifier", "identifier": identifier,+ "error": format!("'{identifier}' is not a package identifier: only letters, digits, '.', '-' and '_' are allowed"),+ "_hint": "A PCM identifier looks like inc.adom.wiki.<owner>.<page>. Nothing was deleted or written."})+}+ pub struct Extracted { pub files: usize, pub symbols: Vec<String>,⋯ 5 unchanged lines ⋯ /// PCM's extract(): <folder>/<contents> -> <3rdparty>/<folder>/<pkgid>/<contents>. /// Zip-slip guarded; entries outside the known folders are ignored (as PCM does). pub fn extract(zip_bytes: &[u8], third: &Path, identifier: &str) -> Result<Extracted, String> {+ if !identifier_is_safe(identifier) {+ return Err(format!("'{identifier}' is not a package identifier"));+ } let zf = ZipFile::parse(zip_bytes)?; let total: u64 = zf.entries.iter().map(|e| e.size).sum(); if total > MAX_ZIP {⋯ 159 unchanged lines ⋯ return json!({"success": false, "error": "identifier is required", "_hint": "identifier like com.adom.kicad-bridge.sample-lib; repositoryUrl is the repository.json URL (optional for an installed package: its own repository is used)."}); };+ if !identifier_is_safe(&ident) {+ return invalid_identifier(&ident);+ } let want = s("version"); let Some(paths) = paths(ctx) else { return json!({"success": false, "error": "KiCad user/config directories unknown",⋯ 162 unchanged lines ⋯ let p = e.get("package").cloned().unwrap_or(Value::Null); let id = p.get("identifier").and_then(Value::as_str).unwrap_or(""); let current = e.get("current_version").and_then(Value::as_str).unwrap_or("");- let on_disk = FOLDERS.iter().any(|f| paths.third.join(f).join(pkgdir(id)).exists());+ let on_disk = identifier_is_safe(id) && FOLDERS.iter().any(|f| paths.third.join(f).join(pkgdir(id)).exists()); let mut row = json!({"identifier": p.get("identifier"), "name": p.get("name"), "type": p.get("type"), "version": e.get("current_version"), "repository": e.get("repository_name"), "repositoryId": e.get("repository_id"), "pinned": e.get("pinned"), "onDisk": on_disk,⋯ 53 unchanged lines ⋯ let Some(ident) = args.get("identifier").and_then(Value::as_str).map(str::trim).filter(|s| !s.is_empty()) else { return json!({"success": false, "error": "identifier is required"}); };+ if !identifier_is_safe(ident) {+ return invalid_identifier(ident);+ } let Some(paths) = paths(ctx) else { return json!({"success": false, "error": "KiCad user/config directories unknown"}); };⋯ 198 unchanged lines ⋯ assert_eq!(u["recordRemoved"], json!(false)); } + /// A package zip holding only metadata.json, with the identifier given.+ fn zip_with_identifier(identifier: &str) -> Vec<u8> {+ let name = b"metadata.json";+ let body = json!({"identifier": identifier, "name": "x", "type": "library",+ "versions": [{"version": "1.0.0", "status": "stable", "kicad_version": "9.0"}]}).to_string().into_bytes();+ let crc = {+ let mut c = !0u32;+ for b in &body {+ c ^= *b as u32;+ for _ in 0..8 {+ c = if c & 1 == 1 { (c >> 1) ^ 0xEDB8_8320 } else { c >> 1 };+ }+ }+ !c+ };+ let size = (body.len() as u32).to_le_bytes();+ let header = |sig: &[u8], v: &mut Vec<u8>, version_made: bool| {+ v.extend_from_slice(sig);+ if version_made {+ v.extend_from_slice(&20u16.to_le_bytes());+ }+ v.extend_from_slice(&20u16.to_le_bytes());+ v.extend_from_slice(&[0; 8]); // flags, stored, time, date+ v.extend_from_slice(&crc.to_le_bytes());+ v.extend_from_slice(&size);+ v.extend_from_slice(&size);+ v.extend_from_slice(&(name.len() as u16).to_le_bytes());+ v.extend_from_slice(&[0; 2]);+ };+ let mut z = Vec::new();+ header(b"PK\x03\x04", &mut z, false);+ z.extend_from_slice(name);+ z.extend_from_slice(&body);+ let cd = z.len() as u32;+ header(b"PK\x01\x02", &mut z, true);+ z.extend_from_slice(&[0; 10]); // comment, disk, attributes+ z.extend_from_slice(&0u32.to_le_bytes()); // local header offset+ z.extend_from_slice(name);+ let cd_size = z.len() as u32 - cd;+ z.extend_from_slice(b"PK\x05\x06");+ z.extend_from_slice(&[0; 4]);+ z.extend_from_slice(&1u16.to_le_bytes());+ z.extend_from_slice(&1u16.to_le_bytes());+ z.extend_from_slice(&cd_size.to_le_bytes());+ z.extend_from_slice(&cd.to_le_bytes());+ z.extend_from_slice(&[0; 2]);+ z+ }+ #[test]+ fn a_path_as_identifier_deletes_nothing() {+ let t = tmp("identpath");+ let c = ctx(&t.0);+ let victim = t.0.join("victim");+ std::fs::create_dir_all(&victim).unwrap();+ std::fs::write(victim.join("keep.txt"), "keep").unwrap();+ let abs = victim.to_string_lossy().to_string();+ let unused = |u: &str, _t: Duration| -> Result<Vec<u8>, String> { Err(format!("no network in this test: {u}")) };++ // the identifier a package zip chooses for itself+ let zip = t.0.join("evil.zip");+ std::fs::write(&zip, zip_with_identifier(&abs)).unwrap();+ let r = install(&c, &json!({"zipPath": zip.to_string_lossy()}), &unused);+ assert_eq!(r["errorCode"], json!("invalid_identifier"), "{r}");++ // the same through the identifier argument+ let r = install(&c, &json!({"repositoryUrl": REPO_URL, "identifier": abs}), &unused);+ assert_eq!(r["errorCode"], json!("invalid_identifier"), "{r}");+ let r = uninstall(&c, &json!({"identifier": abs}));+ assert_eq!(r["errorCode"], json!("invalid_identifier"), "{r}");++ assert_eq!(std::fs::read_to_string(victim.join("keep.txt")).unwrap(), "keep", "the folder must survive");+ assert!(extract(ZIP, &t.0, &abs).is_err());+ assert!(!t.0.join("config/installed_packages.json").exists(), "nothing recorded");+ }++ #[test]+ fn identifier_rule() {+ for ok in ["inc.adom.wiki.adom.test-board", "com.adom.kicad-bridge.sample-lib", "with_underscore.lib", "A1"] {+ assert!(identifier_is_safe(ok), "{ok}");+ }+ for bad in ["", "/etc", "/Users/me/Documents", r"C:\Users\me", "C:foo", "a/b", r"a\b", "../up", "a b", "a\0b", "\u{e9}t\u{e9}"] {+ assert!(!identifier_is_safe(bad), "{bad}");+ }+ }++ #[test] fn install_refusals() { let t = tmp("refuse"); let c = ctx(&t.0);⋯ 254 unchanged lines ⋯ assert_eq!(r["errorCode"], json!("not_installed")); } }+@@ -1,27 +1,28 @@⋯ 2 unchanged lines ⋯ members = ["crates/*"] [workspace.package]-version = "1.0.102"+version = "1.0.103" edition = "2021" license = "MIT" publish = false⋯ 15 unchanged lines ⋯ codegen-units = 1 strip = true panic = "abort"+@@ -1,1307 +1,1308 @@⋯ 318 unchanged lines ⋯ [[package]] name = "kicad-bridge"-version = "1.0.102"+version = "1.0.103" dependencies = [ "adom-eda-checks", "kicad-core",⋯ 5 unchanged lines ⋯ [[package]] name = "kicad-core"-version = "1.0.102"+version = "1.0.103" dependencies = [ "kicad-ipc-rs", "kicad-platform",⋯ 20 unchanged lines ⋯ [[package]] name = "kicad-platform"-version = "1.0.102"+version = "1.0.103" dependencies = [ "serde", "serde_json",⋯ 940 unchanged lines ⋯ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"+
Comments
Log in to comment.
No comments yet.