app
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.
master
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846
//! KiCad Plugin and Content Manager (PCM) automation: install packages from a PCM
//! repository WITHOUT the dialog, the way PCM itself does it on disk.
//!
//! Port of handlers/pcm.py. Three verbs: pcm_install, pcm_list, pcm_uninstall. PCM has
//! no CLI and its dialog is an owned window the bridge's input verbs cannot target, but
//! PCM is only files:
//!
//! 1. extract <zip>/<folder>/<contents> -> <3rdparty>/<folder>/<pkgid>/<contents>
//! (kicad/pcm/pcm_task_manager.cpp extract(): folder must be one of
//! plugins/footprints/symbols/3dmodels/resources/colors; identifier dots become
//! underscores on disk so Python packages import flat)
//! 2. record <config>/installed_packages.json {"packages":[entry...]}
//! (pcm_data.cpp to_json(PCM_INSTALLATION_ENTRY): package, current_version,
//! repository_id, repository_name, install_timestamp, pinned)
//! 3. NOTHING ELSE. KiCad registers the libraries itself: PCM_SYM_LIB_TRAVERSER and
//! PCM_FP_LIB_TRAVERSER scan $KICADn_3RD_PARTY/symbols/<pkgid>/*.kicad_sym and
//! footprints/<pkgid>/*.pretty when the GLOBAL TABLE LOADS and insert rows named
//! <lib_prefix><name> (default "PCM_"), as long as pcm.lib_auto_add is on (the
//! default). That scan happens at editor load, which is why a running editor needs
//! a restart to see a new package: reported here as restartNeeded, never done.
//!
//! repository_id is sha256(repository url)[:16] (pcm.cpp SetRepositoryList), so KiCad's
//! own PCM dialog shows a bridge-installed package as Installed, offers updates for it,
//! and can uninstall it. The bridge is an automation of PCM, not a parallel mechanism.
//!
//! Repositories are the other half of PCM's on-disk contract: kicad.json
//! `pcm.repositories` is `[{"name": ..., "url": ...}]` (kicad_settings.cpp), read at
//! KiCad start. `add_repository` / `remove_repository` edit it the way the Manage
//! dialog does, and `list` uses it to answer "is there an update" per installed
//! package by fetching each package's own repository (matched by repository_id) and
//! comparing versions the way PCM orders them. `install {update:true}` is the headless
//! Update button: it replaces the installed version and its registry row.
//!
//! Neither `ring` nor `flate2` is a declared dependency of this crate, so the SHA-256
//! and the zip reader (stored and deflate entries, no zip64) live here on std alone.
use std::cmp::Ordering;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::time::Duration;
use serde_json::{json, Map, Value};
use crate::model_check::{norm, Ctx};
pub const FOLDERS: &[&str] = &["plugins", "footprints", "symbols", "3dmodels", "resources", "colors"];
pub const MAX_ZIP: u64 = 512 * 1024 * 1024;
const UA: &str = "adom-kicad-bridge pcm";
/// KiCad's built-in repository list (kicad_settings.cpp PCM_DEFAULT_REPOSITORY_NAME/URL):
/// what `pcm.repositories` means when kicad.json does not carry the key at all.
pub const DEFAULT_REPOSITORY_NAME: &str = "KiCad official repository";
pub const DEFAULT_REPOSITORY_URL: &str = "https://repository.kicad.org/repository.json";
/// Bytes fetcher: (url, timeout) -> bytes. Production uses `http_fetch`; tests hand in a table.
pub type Fetch<'a> = &'a dyn Fn(&str, Duration) -> Result<Vec<u8>, String>;
/// GET a URL over HTTPS (rustls, bundled roots) with the PCM user agent.
#[cfg(not(feature = "net"))]
pub fn http_fetch(url: &str, _timeout: Duration) -> Result<Vec<u8>, String> {
if let Some(p) = file_url_path(url) {
return std::fs::read(&p).map_err(|e| format!("OSError: {}: {e}", p.display()));
}
Err("the net feature is off in this build: no HTTPS client".into())
}
/// `file:///C:/x/repository.json` or `file:///home/x/repository.json` as a local path (wiki #132).
/// KiCad's own PCM accepts file:// repositories and download URLs.
pub fn file_url_path(url: &str) -> Option<PathBuf> {
let rest = url.strip_prefix("file://")?;
let rest = rest.strip_prefix("localhost").unwrap_or(rest);
let decoded = percent_decode(rest);
// file:///C:/x -> "/C:/x": drop the slash before a drive letter.
let b = decoded.as_bytes();
let path = if b.len() > 2 && b[0] == b'/' && b[2] == b':' && b[1].is_ascii_alphabetic() { decoded[1..].to_string() } else { decoded };
Some(PathBuf::from(path))
}
fn percent_decode(s: &str) -> String {
let b = s.as_bytes();
let mut out = Vec::with_capacity(b.len());
let mut i = 0;
while i < b.len() {
if b[i] == b'%' && i + 2 < b.len() {
if let Ok(v) = u8::from_str_radix(&s[i + 1..i + 3], 16) {
out.push(v);
i += 3;
continue;
}
}
out.push(b[i]);
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
/// A wiki.adom.inc URL that answered "not found / not allowed" anonymously: a private page.
/// Fetch it again through Adom Bridge's `desktop_wiki_fetch`, which carries the signed-in
/// user's wiki session (wiki #132). The body comes back base64 or as text depending on its type.
fn wiki_session_fetch(url: &str, timeout: Duration) -> Result<Vec<u8>, String> {
let reply = crate::ab::call("desktop_wiki_fetch", json!({"url": url, "reason": "install a KiCad PCM package from a private wiki page"}), timeout)
.map_err(|e| format!("wiki session fetch through Adom Bridge failed: {e:?}"))?;
if let Some(status) = reply.get("httpStatus").and_then(Value::as_u64) {
if status >= 400 {
return Err(format!("HTTPError: http status: {status} (signed in through Adom Bridge)"));
}
}
for key in ["base64", "dataBase64", "bodyBase64", "contentBase64", "b64"] {
if let Some(s) = reply.get(key).and_then(Value::as_str) {
return crate::install::b64_decode(s);
}
}
for key in ["text", "body", "content"] {
if let Some(s) = reply.get(key).and_then(Value::as_str) {
return Ok(s.as_bytes().to_vec());
}
}
for key in ["savedTo", "path", "fullPath", "localPath"] {
if let Some(p) = reply.get(key).and_then(Value::as_str) {
if let Ok(b) = std::fs::read(p) {
return Ok(b);
}
}
}
let keys: Vec<String> = reply.raw.as_object().map(|o| o.keys().cloned().collect()).unwrap_or_default();
Err(format!("wiki session fetch answered without a body field the bridge knows (keys: {})", keys.join(", ")))
}
fn is_wiki_url(url: &str) -> bool {
url.starts_with("https://wiki.adom.inc/")
}
#[cfg(feature = "net")]
pub fn http_fetch(url: &str, timeout: Duration) -> Result<Vec<u8>, String> {
if let Some(p) = file_url_path(url) {
return std::fs::read(&p).map_err(|e| format!("OSError: {}: {e}", p.display()));
}
match anonymous_fetch(url, timeout) {
Err(e) if is_wiki_url(url) && (e.contains("404") || e.contains("401") || e.contains("403")) => {
wiki_session_fetch(url, timeout).map_err(|w| format!("{e}; then {w}"))
}
r => r,
}
}
#[cfg(feature = "net")]
fn anonymous_fetch(url: &str, timeout: Duration) -> Result<Vec<u8>, String> {
let mut resp = ureq::get(url)
.header("User-Agent", UA)
.config()
.timeout_global(Some(timeout))
.build()
.call()
.map_err(|e| format!("{}: {e}", err_kind(&e)))?;
resp.body_mut().with_config().limit(MAX_ZIP).read_to_vec().map_err(|e| format!("{}: {e}", err_kind(&e)))
}
#[cfg(feature = "net")]
fn err_kind(e: &ureq::Error) -> &'static str {
match e {
ureq::Error::StatusCode(_) => "HTTPError",
ureq::Error::Timeout(_) => "TimeoutError",
ureq::Error::Io(_) => "OSError",
_ => "URLError",
}
}
// ── SHA-256 (FIPS 180-4), std only ────────────────────────────────────────────
pub fn sha256_hex(data: &[u8]) -> String {
const K: [u32; 64] = [
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3,
0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13,
0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208,
0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
];
let mut h: [u32; 8] = [0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19];
let mut msg = data.to_vec();
let bit_len = (data.len() as u64).wrapping_mul(8);
msg.push(0x80);
while msg.len() % 64 != 56 {
msg.push(0);
}
msg.extend_from_slice(&bit_len.to_be_bytes());
let mut w = [0u32; 64];
for chunk in msg.chunks(64) {
for i in 0..16 {
w[i] = u32::from_be_bytes([chunk[i * 4], chunk[i * 4 + 1], chunk[i * 4 + 2], chunk[i * 4 + 3]]);
}
for i in 16..64 {
let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3);
let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10);
w[i] = w[i - 16].wrapping_add(s0).wrapping_add(w[i - 7]).wrapping_add(s1);
}
let [mut a, mut b, mut c, mut d, mut e, mut f, mut g, mut hh] = h;
for i in 0..64 {
let s1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25);
let ch = (e & f) ^ (!e & g);
let t1 = hh.wrapping_add(s1).wrapping_add(ch).wrapping_add(K[i]).wrapping_add(w[i]);
let s0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22);
let maj = (a & b) ^ (a & c) ^ (b & c);
let t2 = s0.wrapping_add(maj);
hh = g;
g = f;
f = e;
e = d.wrapping_add(t1);
d = c;
c = b;
b = a;
a = t1.wrapping_add(t2);
}
for (x, y) in h.iter_mut().zip([a, b, c, d, e, f, g, hh]) {
*x = x.wrapping_add(y);
}
}
h.iter().map(|x| format!("{x:08x}")).collect()
}
// ── Inflate (RFC 1951), std only ──────────────────────────────────────────────
mod inflate {
struct Bits<'a> {
data: &'a [u8],
pos: usize,
buf: u64,
cnt: u32,
}
impl<'a> Bits<'a> {
fn bits(&mut self, n: u32) -> Result<u32, String> {
while self.cnt < n {
let b = *self.data.get(self.pos).ok_or("deflate stream truncated")?;
self.pos += 1;
self.buf |= (b as u64) << self.cnt;
self.cnt += 8;
}
let v = (self.buf & ((1u64 << n) - 1)) as u32;
self.buf >>= n;
self.cnt -= n;
Ok(v)
}
fn align(&mut self) {
let drop = self.cnt % 8;
self.buf >>= drop;
self.cnt -= drop;
}
}
struct Huff {
count: [u16; 16],
symbol: Vec<u16>,
}
impl Huff {
fn new(lengths: &[u8]) -> Result<Huff, String> {
let mut count = [0u16; 16];
for &l in lengths {
count[l as usize] += 1;
}
count[0] = 0;
let mut left: i32 = 1;
for c in &count[1..] {
left = (left << 1) - *c as i32;
if left < 0 {
return Err("over-subscribed huffman code".into());
}
}
let mut offs = [0u16; 16];
for i in 1..15 {
offs[i + 1] = offs[i] + count[i];
}
let mut symbol = vec![0u16; lengths.len()];
for (s, &l) in lengths.iter().enumerate() {
if l != 0 {
symbol[offs[l as usize] as usize] = s as u16;
offs[l as usize] += 1;
}
}
Ok(Huff { count, symbol })
}
fn decode(&self, b: &mut Bits) -> Result<u16, String> {
let (mut code, mut first, mut index) = (0i32, 0i32, 0i32);
for len in 1..16 {
code |= b.bits(1)? as i32;
let count = self.count[len] as i32;
if code - count < first {
return self.symbol.get((index + (code - first)) as usize).copied().ok_or_else(|| "bad huffman symbol".to_string());
}
index += count;
first += count;
first <<= 1;
code <<= 1;
}
Err("invalid huffman code".into())
}
}
const LEN_BASE: [u16; 29] = [3, 4, 5, 6, 7, 8, 9, 10, 11, 13, 15, 17, 19, 23, 27, 31, 35, 43, 51, 59, 67, 83, 99, 115, 131, 163, 195, 227, 258];
const LEN_EXTRA: [u8; 29] = [0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5, 0];
const DIST_BASE: [u16; 30] = [1, 2, 3, 4, 5, 7, 9, 13, 17, 25, 33, 49, 65, 97, 129, 193, 257, 385, 513, 769, 1025, 1537, 2049, 3073, 4097, 6145, 8193, 12289, 16385, 24577];
const DIST_EXTRA: [u8; 30] = [0, 0, 0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 10, 10, 11, 11, 12, 12, 13, 13];
fn codes(b: &mut Bits, out: &mut Vec<u8>, lit: &Huff, dist: &Huff, cap: usize) -> Result<(), String> {
loop {
let sym = lit.decode(b)? as usize;
if sym < 256 {
out.push(sym as u8);
} else if sym == 256 {
return Ok(());
} else {
let i = sym - 257;
if i >= 29 {
return Err("bad length symbol".into());
}
let len = LEN_BASE[i] as usize + b.bits(LEN_EXTRA[i] as u32)? as usize;
let d = dist.decode(b)? as usize;
if d >= 30 {
return Err("bad distance symbol".into());
}
let distance = DIST_BASE[d] as usize + b.bits(DIST_EXTRA[d] as u32)? as usize;
if distance > out.len() {
return Err("distance too far back".into());
}
let start = out.len() - distance;
for k in 0..len {
let byte = out[start + k];
out.push(byte);
}
}
if out.len() > cap {
return Err("inflated size exceeds the declared size".into());
}
}
}
/// Inflate a raw deflate stream. `expected` bounds the output so a bogus entry cannot balloon.
pub fn inflate(data: &[u8], expected: usize) -> Result<Vec<u8>, String> {
let mut b = Bits { data, pos: 0, buf: 0, cnt: 0 };
let mut out = Vec::with_capacity(expected);
let cap = expected;
loop {
let last = b.bits(1)?;
match b.bits(2)? {
0 => {
b.align();
let len = b.bits(16)? as usize;
let nlen = b.bits(16)? as usize;
if len != (!nlen & 0xffff) {
return Err("stored block length check failed".into());
}
let end = b.pos.checked_add(len).ok_or("overflow")?;
let chunk = data.get(b.pos..end).ok_or("stored block truncated")?;
out.extend_from_slice(chunk);
b.pos = end;
if out.len() > cap {
return Err("inflated size exceeds the declared size".into());
}
}
1 => {
let mut l = [0u8; 288];
for (i, v) in l.iter_mut().enumerate() {
*v = match i {
0..=143 => 8,
144..=255 => 9,
256..=279 => 7,
_ => 8,
};
}
let lit = Huff::new(&l)?;
let dist = Huff::new(&[5u8; 30])?;
codes(&mut b, &mut out, &lit, &dist, cap)?;
}
2 => {
let nlen = b.bits(5)? as usize + 257;
let ndist = b.bits(5)? as usize + 1;
let ncode = b.bits(4)? as usize + 4;
if nlen > 286 || ndist > 30 {
return Err("bad dynamic block counts".into());
}
const ORDER: [usize; 19] = [16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15];
let mut cl = [0u8; 19];
for &o in ORDER.iter().take(ncode) {
cl[o] = b.bits(3)? as u8;
}
let clh = Huff::new(&cl)?;
let mut lengths = vec![0u8; nlen + ndist];
let mut i = 0;
while i < nlen + ndist {
let sym = clh.decode(&mut b)?;
match sym {
0..=15 => {
lengths[i] = sym as u8;
i += 1;
}
16 => {
if i == 0 {
return Err("repeat with no previous length".into());
}
let prev = lengths[i - 1];
let n = 3 + b.bits(2)? as usize;
if i + n > lengths.len() {
return Err("too many lengths".into());
}
for _ in 0..n {
lengths[i] = prev;
i += 1;
}
}
17 | 18 => {
let n = if sym == 17 { 3 + b.bits(3)? as usize } else { 11 + b.bits(7)? as usize };
if i + n > lengths.len() {
return Err("too many lengths".into());
}
i += n;
}
_ => return Err("bad code length symbol".into()),
}
}
if lengths[256] == 0 {
return Err("no end-of-block code".into());
}
let lit = Huff::new(&lengths[..nlen])?;
let dist = Huff::new(&lengths[nlen..])?;
codes(&mut b, &mut out, &lit, &dist, cap)?;
}
_ => return Err("invalid block type".into()),
}
if last == 1 {
return Ok(out);
}
}
}
}
// ── Minimal zip reader ────────────────────────────────────────────────────────
pub struct ZipEntry {
pub name: String,
pub method: u16,
pub compressed_size: u64,
pub size: u64,
local_offset: usize,
}
pub struct ZipFile<'a> {
data: &'a [u8],
pub entries: Vec<ZipEntry>,
}
fn u16_at(d: &[u8], i: usize) -> Result<u16, String> {
Ok(u16::from_le_bytes([*d.get(i).ok_or("zip truncated")?, *d.get(i + 1).ok_or("zip truncated")?]))
}
fn u32_at(d: &[u8], i: usize) -> Result<u32, String> {
Ok(u32::from_le_bytes([
*d.get(i).ok_or("zip truncated")?,
*d.get(i + 1).ok_or("zip truncated")?,
*d.get(i + 2).ok_or("zip truncated")?,
*d.get(i + 3).ok_or("zip truncated")?,
]))
}
impl<'a> ZipFile<'a> {
pub fn parse(data: &'a [u8]) -> Result<ZipFile<'a>, String> {
if data.len() < 22 {
return Err("not a zip file (too small)".into());
}
let min = data.len().saturating_sub(22 + 65535);
let mut eocd = None;
let mut i = data.len() - 22;
loop {
if &data[i..i + 4] == b"PK\x05\x06" {
eocd = Some(i);
break;
}
if i == min {
break;
}
i -= 1;
}
let eocd = eocd.ok_or("not a zip file (no end-of-central-directory record)")?;
let total = u16_at(data, eocd + 10)? as usize;
let cd_off = u32_at(data, eocd + 16)? as usize;
if total == 0xffff || cd_off == 0xffff_ffff {
return Err("zip64 archives are not supported".into());
}
let mut entries = Vec::with_capacity(total);
let mut p = cd_off;
for _ in 0..total {
if data.get(p..p + 4) != Some(b"PK\x01\x02") {
return Err("bad central directory entry".into());
}
let method = u16_at(data, p + 10)?;
let compressed_size = u32_at(data, p + 20)? as u64;
let size = u32_at(data, p + 24)? as u64;
let n = u16_at(data, p + 28)? as usize;
let e = u16_at(data, p + 30)? as usize;
let c = u16_at(data, p + 32)? as usize;
let local_offset = u32_at(data, p + 42)? as usize;
if compressed_size == 0xffff_ffff || size == 0xffff_ffff || local_offset == 0xffff_ffff {
return Err("zip64 entries are not supported".into());
}
let name = String::from_utf8_lossy(data.get(p + 46..p + 46 + n).ok_or("zip truncated")?).to_string();
entries.push(ZipEntry { name, method, compressed_size, size, local_offset });
p += 46 + n + e + c;
}
Ok(ZipFile { data, entries })
}
pub fn read(&self, entry: &ZipEntry) -> Result<Vec<u8>, String> {
let p = entry.local_offset;
if self.data.get(p..p + 4) != Some(b"PK\x03\x04") {
return Err(format!("bad local header for {}", entry.name));
}
let n = u16_at(self.data, p + 26)? as usize;
let e = u16_at(self.data, p + 28)? as usize;
let start = p + 30 + n + e;
let end = start.checked_add(entry.compressed_size as usize).ok_or("overflow")?;
let raw = self.data.get(start..end).ok_or_else(|| format!("entry data truncated: {}", entry.name))?;
match entry.method {
0 => Ok(raw.to_vec()),
8 => {
let out = inflate::inflate(raw, entry.size as usize).map_err(|e| format!("{}: {e}", entry.name))?;
if out.len() as u64 != entry.size {
return Err(format!("{}: inflated {} bytes, expected {}", entry.name, out.len(), entry.size));
}
Ok(out)
}
m => Err(format!("{}: unsupported zip compression method {m}", entry.name)),
}
}
}
// ── Versions, the way PCM orders them ─────────────────────────────────────────
/// Dotted version order: each component numerically ("1.10.0" > "1.9.0"), missing
/// components are 0 ("1.0" == "1.0.0"), a non-numeric component counts as 0 and the
/// raw strings break the tie so odd versions still order deterministically.
pub fn compare_versions(a: &str, b: &str) -> Ordering {
let parse = |s: &str| -> (Vec<u64>, bool) {
let mut lossy = false;
let parts = s.trim().split('.').map(|p| p.trim().parse::<u64>().unwrap_or_else(|_| { lossy = true; 0 })).collect();
(parts, lossy)
};
let ((pa, la), (pb, lb)) = (parse(a), parse(b));
let n = pa.len().max(pb.len());
for i in 0..n {
let (x, y) = (pa.get(i).copied().unwrap_or(0), pb.get(i).copied().unwrap_or(0));
match x.cmp(&y) {
Ordering::Equal => continue,
o => return o,
}
}
if la || lb {
a.trim().cmp(b.trim())
} else {
Ordering::Equal
}
}
/// Order two PackageVersion objects: `version_epoch` first (a higher epoch is always
/// later, as the schema says), then `compare_versions` on `version`.
pub fn version_cmp(a: &Value, b: &Value) -> Ordering {
let epoch = |v: &Value| v.get("version_epoch").and_then(Value::as_u64).unwrap_or(0);
let ver = |v: &Value| v.get("version").and_then(Value::as_str).unwrap_or("").to_string();
epoch(a).cmp(&epoch(b)).then_with(|| compare_versions(&ver(a), &ver(b)))
}
/// PCM's compatibility filter: `kicad_version` (minimum) <= this KiCad and, when present,
/// `kicad_version_max` >= this KiCad, compared on major.minor. An unknown KiCad version
/// (no detected install) accepts everything.
pub fn version_compatible(v: &Value, kicad_version: &str) -> bool {
let mm = |s: &str| s.split('.').take(2).collect::<Vec<_>>().join(".");
if kicad_version.trim().is_empty() {
return true;
}
let here = mm(kicad_version);
if let Some(min) = v.get("kicad_version").and_then(Value::as_str).filter(|s| !s.is_empty()) {
if compare_versions(&mm(min), &here) == Ordering::Greater {
return false;
}
}
if let Some(max) = v.get("kicad_version_max").and_then(Value::as_str).filter(|s| !s.is_empty()) {
if compare_versions(&mm(max), &here) == Ordering::Less {
return false;
}
}
true
}
/// The version PCM would offer: the highest compatible one that has a download_url.
pub fn latest_version<'a>(versions: &'a [Value], kicad_version: &str) -> Option<&'a Value> {
versions
.iter()
.filter(|v| v.get("download_url").and_then(Value::as_str).map(|u| !u.is_empty()).unwrap_or(false))
.filter(|v| version_compatible(v, kicad_version))
.max_by(|a, b| version_cmp(a, b))
}
// ── Repositories (kicad.json pcm.repositories) ────────────────────────────────
/// PCM's repository_id: sha256(url) truncated to 16 hex chars (pcm.cpp SetRepositoryList).
pub fn repository_id(url: &str) -> String {
sha256_hex(url.as_bytes())[..16].to_string()
}
fn kicad_json_path(ctx: &Ctx) -> Option<PathBuf> {
ctx.config_dir.as_ref().map(|c| c.join("kicad.json"))
}
fn read_kicad_json(path: &Path) -> Result<Value, String> {
match std::fs::read_to_string(path) {
Ok(text) => {
let v: Value = serde_json::from_str(&text).map_err(|e| format!("{}: {e}", norm(path)))?;
if v.is_object() {
Ok(v)
} else {
Err(format!("{}: top level is not a JSON object", norm(path)))
}
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(json!({"meta": {"filename": "kicad", "version": 0}})),
Err(e) => Err(format!("{}: {e}", norm(path))),
}
}
/// The `(name, url)` rows KiCad's PCM will use: `pcm.repositories` from kicad.json, or
/// KiCad's built-in default when the key is absent (which is how a fresh KiCad looks).
pub fn registered_repositories(ctx: &Ctx) -> Vec<(String, String)> {
let doc = kicad_json_path(ctx).and_then(|p| read_kicad_json(&p).ok()).unwrap_or(Value::Null);
repositories_of(&doc)
}
fn repositories_of(doc: &Value) -> Vec<(String, String)> {
match doc.get("pcm").and_then(|p| p.get("repositories")).and_then(Value::as_array) {
Some(rows) => rows
.iter()
.filter_map(|r| {
let url = r.get("url").and_then(Value::as_str)?.to_string();
let name = r.get("name").and_then(Value::as_str).unwrap_or("").to_string();
Some((name, url))
})
.collect(),
None => vec![(DEFAULT_REPOSITORY_NAME.to_string(), DEFAULT_REPOSITORY_URL.to_string())],
}
}
fn repositories_json(rows: &[(String, String)]) -> Value {
Value::Array(rows.iter().map(|(n, u)| json!({"name": n, "url": u})).collect())
}
fn repositories_report(rows: &[(String, String)]) -> Value {
Value::Array(rows.iter().map(|(n, u)| json!({"name": n, "url": u, "repositoryId": repository_id(u)})).collect())
}
/// `.adom-bak` beside the file (when it exists), then tmp + rename. Two-space indent,
/// which is how KiCad's own settings writer (nlohmann dump(2)) lays kicad.json out.
fn write_kicad_json(path: &Path, doc: &Value) -> Result<Option<PathBuf>, String> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", norm(parent)))?;
}
let mut backup = None;
if path.is_file() {
let b = path.with_file_name(format!("{}.adom-bak", path.file_name().unwrap_or_default().to_string_lossy()));
std::fs::copy(path, &b).map_err(|e| format!("cannot back up {}: {e}", norm(path)))?;
backup = Some(b);
}
let mut buf = Vec::new();
let mut ser = serde_json::Serializer::with_formatter(&mut buf, serde_json::ser::PrettyFormatter::with_indent(b" "));
serde::Serialize::serialize(doc, &mut ser).map_err(|e| e.to_string())?;
buf.push(b'\n');
let tmp = path.with_file_name(format!("{}.adom-tmp", path.file_name().unwrap_or_default().to_string_lossy()));
std::fs::write(&tmp, &buf).and_then(|_| std::fs::rename(&tmp, path)).map_err(|e| {
let _ = std::fs::remove_file(&tmp);
format!("cannot write {}: {e}", norm(path))
})?;
Ok(backup)
}
const REPO_RESTART_NOTE: &str = "KiCad reads pcm.repositories at start: a running KiCad keeps its old list until it is closed and launched again, and a KiCad that is running NOW will overwrite this edit when it saves its settings on exit, so close KiCad first when kicadRunning is true or null.";
fn valid_repo_url(u: &str) -> bool {
(u.starts_with("https://") || u.starts_with("http://") || u.starts_with("file://")) && !u[u.find("://").unwrap_or(0) + 3..].is_empty()
}
/// kicad_pcm_add_repository {url, name?}: register a PCM repository in kicad.json the way
/// the Manage dialog does. The repository.json is fetched to confirm it is one and to
/// take its name when none is given. Idempotent on the URL.
pub fn add_repository(ctx: &Ctx, args: &Value, fetch: Fetch) -> Value {
let s = |k: &str| args.get(k).and_then(Value::as_str).map(|v| v.trim().to_string()).filter(|v| !v.is_empty());
let Some(url) = s("url") else {
return json!({"success": false, "errorCode": "invalid_arg", "error": "url is required",
"_hint": "url is the repository.json URL, e.g. https://wiki.adom.inc/api/pages/adom/kicad-bridge/files/pcm/repository.json"});
};
if !valid_repo_url(&url) {
return json!({"success": false, "errorCode": "invalid_arg", "error": format!("not a repository URL: {url}"),
"_hint": "PCM accepts http(s):// and file:// URLs that point at a repository.json."});
}
let Some(path) = kicad_json_path(ctx) else {
return json!({"success": false, "errorCode": "settings_not_found", "error": "KiCad config directory unknown",
"_hint": "kicad_readiness first; the bridge needs a detected KiCad to know where kicad.json lives."});
};
// Confirm it is a PCM repository (and learn its name), like the dialog does.
let (fetched, fetched_name, fetch_error) = match fetch(&url, Duration::from_secs(30)) {
Ok(bytes) => match serde_json::from_slice::<Value>(&bytes) {
Ok(v) if v.get("packages").and_then(|p| p.get("url")).is_some() => (true, v.get("name").and_then(Value::as_str).map(String::from), None),
Ok(_) => (false, None, Some("the URL serves JSON, but not a PCM repository (no packages.url)".to_string())),
Err(e) => (false, None, Some(format!("the URL does not serve JSON: {e}"))),
},
Err(e) => (false, None, Some(e)),
};
let name = match s("name").or(fetched_name) {
Some(n) => n,
None => {
return json!({"success": false, "errorCode": "repository_unreachable", "url": url,
"error": format!("could not read the repository and no name was given: {}", fetch_error.clone().unwrap_or_default()),
"_hint": "Check the URL serves a repository.json anonymously (curl it), or pass name to register it unverified."});
}
};
let mut doc = match read_kicad_json(&path) {
Ok(d) => d,
Err(e) => return json!({"success": false, "errorCode": "settings_unreadable", "error": e}),
};
let mut rows = repositories_of(&doc);
let existing = rows.iter().find(|(_, u)| u == &url).cloned();
let mut out = json!({
"success": true, "url": url, "repositoryId": repository_id(&url),
"file": norm(&path), "verified": fetched,
"kicadRunning": Value::Null, "restartNeeded": true,
});
if let Some(e) = fetch_error {
out["warning"] = json!(format!("registered unverified: {e}"));
}
if let Some((n, _)) = existing {
out["name"] = json!(n);
out["added"] = json!(false);
out["alreadyRegistered"] = json!(true);
out["repositories"] = repositories_report(&rows);
out["_hint"] = json!(format!("Already in pcm.repositories as '{n}'; nothing written. {REPO_RESTART_NOTE}"));
return out;
}
rows.push((name.clone(), url.clone()));
let obj = doc.as_object_mut().unwrap();
if !obj.get("pcm").map(Value::is_object).unwrap_or(false) {
obj.insert("pcm".into(), Value::Object(Map::new()));
}
obj["pcm"]["repositories"] = repositories_json(&rows);
match write_kicad_json(&path, &doc) {
Ok(backup) => {
out["name"] = json!(name);
out["added"] = json!(true);
out["alreadyRegistered"] = json!(false);
out["backup"] = backup.map(|b| json!(norm(&b))).unwrap_or(Value::Null);
out["repositories"] = repositories_report(&rows);
out["_hint"] = json!(format!("Written to kicad.json pcm.repositories (KiCad's own default list was kept in front of it). {REPO_RESTART_NOTE} Then kicad_pcm_install {{\"repositoryUrl\": \"{url}\", \"identifier\": ...}} installs from it without the dialog."));
out
}
Err(e) => json!({"success": false, "errorCode": "settings_write_failed", "error": e,
"_hint": "The original kicad.json is intact (atomic replace). Check the directory is writable."}),
}
}
/// kicad_pcm_remove_repository {url}: drop a row from pcm.repositories. Packages
/// installed from it stay installed (PCM keeps them too; they just lose update checks).
pub fn remove_repository(ctx: &Ctx, args: &Value) -> Value {
let Some(url) = args.get("url").and_then(Value::as_str).map(str::trim).filter(|s| !s.is_empty()) else {
return json!({"success": false, "errorCode": "invalid_arg", "error": "url is required"});
};
let Some(path) = kicad_json_path(ctx) else {
return json!({"success": false, "errorCode": "settings_not_found", "error": "KiCad config directory unknown"});
};
let mut doc = match read_kicad_json(&path) {
Ok(d) => d,
Err(e) => return json!({"success": false, "errorCode": "settings_unreadable", "error": e}),
};
let mut rows = repositories_of(&doc);
let before = rows.len();
rows.retain(|(_, u)| u != url);
if rows.len() == before {
return json!({"success": true, "url": url, "removed": false, "file": norm(&path), "repositories": repositories_report(&rows),
"kicadRunning": Value::Null, "restartNeeded": false,
"_hint": "That URL is not in pcm.repositories; nothing written."});
}
let obj = doc.as_object_mut().unwrap();
if !obj.get("pcm").map(Value::is_object).unwrap_or(false) {
obj.insert("pcm".into(), Value::Object(Map::new()));
}
obj["pcm"]["repositories"] = repositories_json(&rows);
match write_kicad_json(&path, &doc) {
Ok(backup) => json!({"success": true, "url": url, "removed": true, "file": norm(&path),
"backup": backup.map(|b| json!(norm(&b))).unwrap_or(Value::Null),
"repositories": repositories_report(&rows), "kicadRunning": Value::Null, "restartNeeded": true,
"_hint": format!("Removed from kicad.json pcm.repositories. Packages installed from it stay installed. {REPO_RESTART_NOTE}")}),
Err(e) => json!({"success": false, "errorCode": "settings_write_failed", "error": e}),
}
}
// ── Fetching a repository ─────────────────────────────────────────────────────
pub enum RepoError {
/// packages.json bytes do not hash to repository.json's packages.sha256.
Hash,
Other(String),
}
/// repository.json -> packages.json, sha256-checked the way PCM checks it.
/// Returns (repository document, package array).
pub fn fetch_repository(fetch: Fetch, repo_url: &str, timeout: Duration) -> Result<(Value, Vec<Value>), RepoError> {
let repo: Value = serde_json::from_slice(&fetch(repo_url, timeout).map_err(RepoError::Other)?).map_err(|e| RepoError::Other(format!("JSONDecodeError: {e}")))?;
let pkgs_url = repo
.get("packages")
.and_then(|p| p.get("url"))
.and_then(Value::as_str)
.ok_or_else(|| RepoError::Other("KeyError: 'packages'".into()))?
.to_string();
let raw = fetch(&pkgs_url, timeout).map_err(RepoError::Other)?;
let want_sha = repo.get("packages").and_then(|p| p.get("sha256")).and_then(Value::as_str).unwrap_or("");
if sha256_hex(&raw) != want_sha {
return Err(RepoError::Hash);
}
let pk: Value = serde_json::from_slice(&raw).map_err(|e| RepoError::Other(format!("JSONDecodeError: {e}")))?;
let list = pk.get("packages").and_then(Value::as_array).cloned().ok_or_else(|| RepoError::Other("KeyError: 'packages'".into()))?;
Ok((repo, list))
}
// ── PCM on disk ───────────────────────────────────────────────────────────────
pub struct Paths {
pub third: PathBuf,
pub installed: PathBuf,
pub version: String,
pub resolved_by: &'static str,
}
/// (3rdparty dir, installed_packages.json, version) or None when KiCad's dirs are unknown.
pub fn paths(ctx: &Ctx) -> Option<Paths> {
let (third, resolved_by) = ctx.third_party_dir()?;
let cfg = ctx.config_dir.as_ref()?;
Some(Paths { third, installed: cfg.join("installed_packages.json"), version: ctx.version.clone(), resolved_by })
}
pub fn read_installed(p: &Path) -> Value {
if let Ok(text) = std::fs::read_to_string(p) {
if let Ok(v) = serde_json::from_str::<Value>(&text) {
if v.is_object() && v.get("packages").map(Value::is_array).unwrap_or(false) {
return v;
}
}
}
json!({"packages": []})
}
/// Atomic write, four-space indent like KiCad's own file.
pub fn write_installed(p: &Path, d: &Value) -> Result<(), String> {
if let Some(parent) = p.parent() {
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
let mut buf = Vec::new();
let mut ser = serde_json::Serializer::with_formatter(&mut buf, serde_json::ser::PrettyFormatter::with_indent(b" "));
serde::Serialize::serialize(d, &mut ser).map_err(|e| e.to_string())?;
buf.push(b'\n');
let tmp = p.with_extension("json.tmp");
std::fs::write(&tmp, &buf).map_err(|e| e.to_string())?;
std::fs::rename(&tmp, p).map_err(|e| e.to_string())
}
pub fn pkgdir(identifier: &str) -> String {
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>,
pub footprints: Vec<String>,
pub models: Vec<String>,
pub package_dir: String,
}
/// 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 {
return Err(format!("package unpacks to {total} bytes, over the {MAX_ZIP} cap"));
}
let pkgid = pkgdir(identifier);
let mut out = Extracted { files: 0, symbols: vec![], footprints: vec![], models: vec![], package_dir: pkgid.clone() };
for entry in &zf.entries {
let name = entry.name.replace('\\', "/");
if name.is_empty() || name.ends_with('/') {
continue;
}
let parts: Vec<&str> = name.split('/').filter(|p| !p.is_empty() && *p != ".").collect();
let drive = name.len() >= 2 && name.as_bytes()[0].is_ascii_alphabetic() && name.as_bytes()[1] == b':';
if parts.iter().any(|p| *p == "..") || drive || name.starts_with('/') {
return Err(format!("unsafe zip entry: {}", entry.name));
}
if parts.len() < 2 || !FOLDERS.contains(&parts[0]) {
continue;
}
let base = third.join(parts[0]).join(&pkgid);
let mut target = base.clone();
for p in &parts[1..] {
target.push(p);
}
if !target.starts_with(&base) {
return Err(format!("unsafe zip entry: {}", entry.name));
}
let bytes = zf.read(entry)?;
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", norm(parent)))?;
}
std::fs::write(&target, &bytes).map_err(|e| format!("{}: {e}", norm(&target)))?;
out.files += 1;
let last = parts[parts.len() - 1];
if parts[0] == "symbols" && name.ends_with(".kicad_sym") {
out.symbols.push(last[..last.len() - 10].to_string());
} else if parts[0] == "footprints" && parts.len() >= 3 && parts[1].ends_with(".pretty") {
let lib = parts[1][..parts[1].len() - 7].to_string();
if !out.footprints.contains(&lib) {
out.footprints.push(lib);
}
} else if parts[0] == "3dmodels" && parts.len() >= 3 && !out.models.iter().any(|m| m == parts[1]) {
out.models.push(parts[1].to_string());
}
}
Ok(out)
}
/// kicad.json pcm block: (lib_auto_add, lib_prefix). Defaults are KiCad's.
fn pcm_prefs(ctx: &Ctx) -> (bool, String) {
let v = ctx
.config_dir
.as_ref()
.and_then(|c| std::fs::read_to_string(c.join("kicad.json")).ok())
.and_then(|s| serde_json::from_str::<Value>(&s).ok())
.unwrap_or(Value::Null);
let pcm = v.get("pcm").cloned().unwrap_or(Value::Null);
let auto_add = pcm.get("lib_auto_add").and_then(Value::as_bool).unwrap_or(true);
let prefix = pcm.get("lib_prefix").and_then(Value::as_str).filter(|s| !s.is_empty()).unwrap_or("PCM_").to_string();
(auto_add, prefix)
}
fn now_secs() -> u64 {
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
}
fn ident_of(entry: &Value) -> Option<&str> {
entry.get("package").and_then(|p| p.get("identifier")).and_then(Value::as_str)
}
/// kicadRunning / restartNeeded for a PCM verb: the process scan answers it where the OS
/// layer has one, and an unseeable KiCad is null with restartNeeded true (wiki #63).
fn running_block() -> (Value, bool) {
let (running, restart, _, _) = crate::install_kicad::running_report();
(running, restart)
}
/// The repository URL an installed package came from: its registry row's repository_id
/// matched against the registered repositories (by id, then by name as a fallback).
fn repository_url_for(installed: &Value, ident: &str, repos: &[(String, String)]) -> Option<String> {
let entry = installed["packages"].as_array()?.iter().find(|e| ident_of(e) == Some(ident))?;
let id = entry.get("repository_id").and_then(Value::as_str).unwrap_or("");
let name = entry.get("repository_name").and_then(Value::as_str).unwrap_or("");
repos
.iter()
.find(|(_, u)| !id.is_empty() && repository_id(u) == id)
.or_else(|| repos.iter().find(|(n, _)| !name.is_empty() && n == name))
.map(|(_, u)| u.clone())
}
/// kicad_pcm_install {repositoryUrl?, identifier, version?, update?, libPrefix?, force?}.
///
/// `update:true` is PCM's Update button without the dialog: the repository is taken
/// from the installed package's own registry row when repositoryUrl is omitted, the
/// newest compatible version is chosen, an installed version that is already the newest
/// answers upToDate, and otherwise the old files are replaced and installed_packages.json
/// rewritten (previousVersion says what was there). A plain install of a version that
/// differs from the installed one is the same replacement; `force` reinstalls the same one.
/// `kicad_pcm_install {zipPath}` (wiki #132): install a local package zip the same way a
/// repository install does. The zip's metadata.json becomes a one-package repository served
/// from memory, so the extraction, the registry row and every check are the same code.
fn install_from_zip(ctx: &Ctx, args: &Value, zip_path: &str) -> Value {
let p = file_url_path(zip_path).unwrap_or_else(|| PathBuf::from(zip_path));
let blob = match std::fs::read(&p) {
Ok(b) => b,
Err(e) => return json!({"success": false, "errorCode": "zip_unreadable", "error": format!("cannot read {}: {e}", p.display())}),
};
let meta: Value = match ZipFile::parse(&blob).and_then(|z| {
let entry = z.entries.iter().find(|e| e.name == "metadata.json").ok_or_else(|| "the zip has no metadata.json at its root (not a KiCad PCM package)".to_string())?;
let raw = z.read(entry)?;
serde_json::from_slice::<Value>(&raw).map_err(|e| format!("metadata.json: {e}"))
}) {
Ok(m) => m,
Err(e) => return json!({"success": false, "errorCode": "not_a_pcm_package", "error": e,
"_hint": "A PCM package zip carries metadata.json beside its symbols/, footprints/ and 3dmodels/ folders."}),
};
let Some(ident) = meta.get("identifier").and_then(Value::as_str).map(String::from) else {
return json!({"success": false, "errorCode": "not_a_pcm_package", "error": "metadata.json has no identifier"});
};
let zip_url = format!("file:///{}", p.to_string_lossy().replace('\\', "/").trim_start_matches('/'));
let mut pkg = meta.clone();
let mut versions = meta.get("versions").and_then(Value::as_array).cloned().unwrap_or_default();
if versions.is_empty() {
versions.push(json!({"version": "0.0.0", "status": "stable", "kicad_version": "6.0"}));
}
// The zip is one version: the one it says first (PCM writes the packaged version there).
let mut v = versions[0].clone();
v["download_url"] = json!(zip_url);
v["download_sha256"] = json!(sha256_hex(&blob));
v["download_size"] = json!(blob.len());
pkg["versions"] = json!([v]);
let packages = serde_json::to_vec(&json!({"packages": [pkg]})).unwrap_or_default();
let repo_url = "local-zip://repository.json";
let pkgs_url = "local-zip://packages.json";
let repo = serde_json::to_vec(&json!({"name": "Local package zip", "packages": {"url": pkgs_url, "sha256": sha256_hex(&packages)}})).unwrap_or_default();
let serve = move |url: &str, t: Duration| -> Result<Vec<u8>, String> {
match url {
u if u == repo_url => Ok(repo.clone()),
u if u == pkgs_url => Ok(packages.clone()),
u => http_fetch(u, t),
}
};
let mut a = args.clone();
a["identifier"] = json!(ident);
a["repositoryUrl"] = json!(repo_url);
a.as_object_mut().map(|o| o.remove("zipPath"));
let mut out = install(ctx, &a, &serve);
out["zipPath"] = json!(p.to_string_lossy());
out["_note"] = json!("Installed from a local zip: KiCad's PCM shows it as installed but cannot check it for updates (no repository URL). Install from the repository instead when it is reachable.");
out
}
pub fn install(ctx: &Ctx, args: &Value, fetch: Fetch) -> Value {
if let Some(zip) = args.get("zipPath").and_then(Value::as_str).filter(|z| !z.trim().is_empty()) {
return install_from_zip(ctx, args, zip.trim());
}
let s = |k: &str| args.get(k).and_then(Value::as_str).map(|v| v.trim().to_string()).filter(|v| !v.is_empty());
let (auto_add, default_prefix) = pcm_prefs(ctx);
let prefix = s("libPrefix").unwrap_or(default_prefix);
let update = args.get("update").and_then(Value::as_bool).unwrap_or(false);
let Some(ident) = s("identifier") else {
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",
"_hint": "kicad_readiness first; the bridge needs a detected KiCad to know 3rdparty and settings paths."});
};
let mut installed = read_installed(&paths.installed);
let previous: Option<String> = installed["packages"]
.as_array()
.and_then(|a| a.iter().find(|e| ident_of(e) == Some(ident.as_str())))
.and_then(|e| e.get("current_version").and_then(Value::as_str))
.map(String::from);
let repo_url = match s("repositoryUrl") {
Some(u) => u,
None => {
if previous.is_none() {
return json!({"success": false, "errorCode": "not_installed", "error": format!("repositoryUrl is required: {ident} is not installed, so there is no repository to take it from"),
"_hint": "repositoryUrl is required for a first install; update:true on an installed package finds its repository itself."});
}
match repository_url_for(&installed, &ident, ®istered_repositories(ctx)) {
Some(u) => u,
None => {
return json!({"success": false, "errorCode": "repository_not_registered",
"error": format!("{ident} is installed but its repository is not in kicad.json pcm.repositories"),
"_hint": "Pass repositoryUrl, or kicad_pcm_add_repository {url} first so KiCad's PCM can see it too."});
}
}
}
};
let (repo, packages) = match fetch_repository(fetch, &repo_url, Duration::from_secs(120)) {
Ok(v) => v,
Err(RepoError::Hash) => {
return json!({"success": false, "error": "packages.json hash does not match repository.json",
"_hint": "The repository is mid-update or corrupted; retry in a minute."})
}
Err(RepoError::Other(e)) => return json!({"success": false, "error": format!("could not read repository: {e}")}),
};
let Some(pkg) = packages.iter().find(|p| p.get("identifier").and_then(Value::as_str) == Some(ident.as_str())) else {
let available: Vec<Value> = packages.iter().take(20).map(|p| p.get("identifier").cloned().unwrap_or(Value::Null)).collect();
return json!({"success": false, "error": format!("package not in repository: {ident}"), "available": available});
};
let versions = pkg.get("versions").and_then(Value::as_array).cloned().unwrap_or_default();
let vsel = match &want {
Some(w) => match versions.iter().find(|v| v.get("version").and_then(Value::as_str) == Some(w.as_str())) {
Some(v) => v.clone(),
None => {
let available: Vec<Value> = versions.iter().map(|v| v.get("version").cloned().unwrap_or(Value::Null)).collect();
return json!({"success": false, "error": format!("version {w} not offered"), "available": available});
}
},
None => match latest_version(&versions, &ctx.version) {
Some(v) => v.clone(),
None => return json!({"success": false, "error": "package has no downloadable version",
"_hint": format!("No version with a download_url is compatible with KiCad {}.", ctx.version)}),
},
};
let Some(download_url) = vsel.get("download_url").and_then(Value::as_str).filter(|u| !u.is_empty()).map(String::from) else {
return json!({"success": false, "error": "package has no downloadable version"});
};
let version = vsel.get("version").and_then(Value::as_str).unwrap_or("").to_string();
let force = args.get("force").and_then(Value::as_bool).unwrap_or(false);
let prior_same = previous.as_deref() == Some(version.as_str());
if update && want.is_none() {
if let Some(prev) = &previous {
if compare_versions(&version, prev) != Ordering::Greater && !force {
return json!({"success": true, "upToDate": true, "identifier": ident, "version": prev, "latestVersion": version,
"repositoryUrl": repo_url,
"_hint": "Installed version is the newest the repository offers; nothing changed. Pass version to install a specific one, or force:true to reinstall."});
}
}
}
if prior_same && !force {
return json!({"success": true, "alreadyInstalled": true, "identifier": ident, "version": version,
"_hint": "Already installed at this version; pass force:true to reinstall."});
}
let ext = match (|| -> Result<Result<Extracted, Value>, String> {
let blob = fetch(&download_url, Duration::from_secs(600))?;
let got = sha256_hex(&blob);
if let Some(want_sha) = vsel.get("download_sha256").and_then(Value::as_str).filter(|s| !s.is_empty()) {
if got != want_sha {
return Ok(Err(json!({"success": false, "error": "download sha256 mismatch", "expected": want_sha, "got": got,
"_hint": "Refusing to install a package whose bytes do not match the repository's digest."})));
}
}
// replace-on-update: remove the previous extraction first, as PCM does
for folder in FOLDERS {
let old = paths.third.join(folder).join(pkgdir(&ident));
if old.exists() {
let _ = std::fs::remove_dir_all(&old);
}
}
Ok(Ok(extract(&blob, &paths.third, &ident)?))
})() {
Ok(Ok(e)) => e,
Ok(Err(v)) => return v,
Err(e) => return json!({"success": false, "error": format!("install failed: {e}")}),
};
let mut package: Map<String, Value> = pkg.as_object().cloned().unwrap_or_default();
package.remove("versions");
package.insert("versions".into(), json!([vsel]));
let entry = json!({
"package": package,
"current_version": version,
"repository_id": repository_id(&repo_url),
"repository_name": repo.get("name").and_then(Value::as_str).unwrap_or(""),
"install_timestamp": now_secs(),
"pinned": false,
});
let mut list: Vec<Value> = installed["packages"].as_array().cloned().unwrap_or_default();
list.retain(|e| ident_of(e) != Some(ident.as_str()));
list.push(entry);
installed["packages"] = Value::Array(list);
if let Err(e) = write_installed(&paths.installed, &installed) {
return json!({"success": false, "error": format!("install failed: could not write installed_packages.json: {e}")});
}
let (running, restart) = running_block();
let nick = |names: &[String]| -> Vec<String> { names.iter().map(|n| format!("{prefix}{n}")).collect() };
let replaced = previous.as_ref().map(|p| p != &version).unwrap_or(false);
let mut hint = if replaced {
format!("Replaced {} with {version} exactly as PCM's Update does (old files removed, registry row rewritten; keep_on_update patterns are not honoured by the bridge). ", previous.clone().unwrap_or_default())
} else {
String::from("Installed exactly as PCM would; KiCad's PCM dialog will list it as Installed. ")
};
hint.push_str("Library nicknames above are created by KiCad's own PCM scan when the global library table next LOADS, so a KiCad that is already running must be restarted (never done for you) before the libraries appear.");
if !auto_add {
hint.push_str(" kicad.json pcm.lib_auto_add is OFF on this box, so KiCad will NOT add the rows itself: register the libraries with kicad_install_library or turn lib_auto_add back on.");
}
json!({
"success": true, "identifier": ident, "version": version,
"previousVersion": previous, "updated": replaced,
"repositoryUrl": repo_url,
"filesInstalled": ext.files,
"installedTo": norm(&paths.third.join("*").join(&ext.package_dir)),
"thirdPartyDir": norm(&paths.third), "thirdPartyResolvedBy": paths.resolved_by,
"symbolLibraries": nick(&ext.symbols),
"footprintLibraries": nick(&ext.footprints),
"modelSets": ext.models,
"libAutoAdd": auto_add, "libPrefix": prefix,
"kicadRunning": running, "restartNeeded": restart,
"_hint": hint,
})
}
/// kicad_pcm_list {checkUpdates?: true}: packages KiCad's PCM considers installed, each
/// with `updateAvailable` from its own repository (the registry row's repository_id
/// matched against kicad.json pcm.repositories, then repository.json -> packages.json,
/// sha256-checked, newest compatible version compared with current_version).
/// checkUpdates:false skips the network and leaves the update fields null.
pub fn list(ctx: &Ctx, args: &Value, fetch: Fetch) -> Value {
let Some(paths) = paths(ctx) else {
return json!({"success": false, "error": "KiCad config directory unknown"});
};
let check = args.get("checkUpdates").and_then(Value::as_bool).unwrap_or(true);
let repos = registered_repositories(ctx);
let d = read_installed(&paths.installed);
let mut cache: HashMap<String, Result<Vec<Value>, String>> = HashMap::new();
let mut updates = 0usize;
let out: Vec<Value> = d["packages"]
.as_array()
.into_iter()
.flatten()
.map(|e| {
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 = 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,
"updateAvailable": Value::Null, "latestVersion": Value::Null, "repositoryUrl": Value::Null,
"updateCheck": if check { "repository_not_registered" } else { "skipped" }});
if !check {
return row;
}
let Some(url) = repository_url_for(&d, id, &repos) else { return row };
row["repositoryUrl"] = json!(url);
let fetched = cache.entry(url.clone()).or_insert_with(|| match fetch_repository(fetch, &url, Duration::from_secs(20)) {
Ok((_, list)) => Ok(list),
Err(RepoError::Hash) => Err("packages.json hash does not match repository.json".into()),
Err(RepoError::Other(m)) => Err(m),
});
match fetched {
Err(m) => row["updateCheck"] = json!(format!("fetch_failed: {m}")),
Ok(list) => match list.iter().find(|q| q.get("identifier").and_then(Value::as_str) == Some(id)) {
None => row["updateCheck"] = json!("package_not_in_repository"),
Some(q) => {
let versions = q.get("versions").and_then(Value::as_array).cloned().unwrap_or_default();
match latest_version(&versions, &ctx.version) {
None => row["updateCheck"] = json!("no_compatible_version"),
Some(v) => {
let latest = v.get("version").and_then(Value::as_str).unwrap_or("");
let pinned = e.get("pinned").and_then(Value::as_bool).unwrap_or(false);
let newer = compare_versions(latest, current) == Ordering::Greater;
if newer && !pinned {
updates += 1;
}
row["latestVersion"] = json!(latest);
row["updateAvailable"] = json!(newer && !pinned);
row["updateCheck"] = json!(if newer && pinned { "pinned" } else { "ok" });
}
}
}
},
}
row
})
.collect();
let hint = if !check {
"checkUpdates:false: no repository was fetched.".to_string()
} else if updates > 0 {
format!("{updates} package(s) have a newer version in their repository: kicad_pcm_install {{\"identifier\": ..., \"update\": true}} applies it (KiCad's own PCM dialog shows the same Update button after a restart).")
} else {
"Every installed package is at the newest version its repository offers (updateCheck says why when a check could not run).".to_string()
};
json!({"success": true, "count": out.len(), "packages": out, "updatesAvailable": updates,
"repositories": repositories_report(&repos),
"thirdPartyDir": norm(&paths.third), "thirdPartyResolvedBy": paths.resolved_by, "kicadVersion": paths.version,
"_hint": hint})
}
/// kicad_pcm_uninstall {identifier}: remove the 3rdparty directories and the record.
pub fn uninstall(ctx: &Ctx, args: &Value) -> Value {
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"});
};
let mut removed = Vec::new();
for folder in FOLDERS {
let d = paths.third.join(folder).join(pkgdir(ident));
if d.exists() {
let _ = std::fs::remove_dir_all(&d);
removed.push(norm(&d));
}
}
let mut installed = read_installed(&paths.installed);
let mut list: Vec<Value> = installed["packages"].as_array().cloned().unwrap_or_default();
let before = list.len();
list.retain(|e| ident_of(e) != Some(ident));
let record_removed = before != list.len();
installed["packages"] = Value::Array(list);
if let Err(e) = write_installed(&paths.installed, &installed) {
return json!({"success": false, "error": format!("could not write installed_packages.json: {e}")});
}
let (running, restart) = running_block();
json!({"success": true, "identifier": ident, "removedDirs": removed, "recordRemoved": record_removed,
"kicadRunning": running, "restartNeeded": restart,
"_hint": "KiCad drops the PCM_ library rows itself on next load when pcm.lib_auto_remove is on."})
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
#[test]
fn file_urls_read_local_files() {
assert_eq!(file_url_path("file:///C:/Users/a/pcm/repository.json").unwrap(), PathBuf::from("C:/Users/a/pcm/repository.json"));
assert_eq!(file_url_path("file:///home/box/pcm/repository.json").unwrap(), PathBuf::from("/home/box/pcm/repository.json"));
assert_eq!(file_url_path("file://localhost/home/a%20b/r.json").unwrap(), PathBuf::from("/home/a b/r.json"));
assert!(file_url_path("https://wiki.adom.inc/x").is_none());
let dir = std::env::temp_dir().join(format!("pcm-file-url-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let f = dir.join("repository.json");
std::fs::write(&f, b"{}").unwrap();
let url = format!("file://{}{}", if cfg!(windows) { "/" } else { "" }, f.to_string_lossy().replace('\\', "/"));
assert_eq!(http_fetch(&url, Duration::from_secs(1)).unwrap(), b"{}");
let _ = std::fs::remove_dir_all(&dir);
}
// Built with Python zipfile: metadata.json, plugins/__init__.py, symbols/Adom.kicad_sym,
// footprints/Adom.pretty/R.kicad_mod, 3dmodels/Adom.3dshapes/R.step (deflate), a
// footprints/ directory entry, colors/theme.json (stored).
const ZIP: &[u8] = &[80,75,3,4,20,0,0,0,8,0,85,94,43,93,70,221,171,251,38,0,0,0,36,0,0,0,13,0,0,0,109,101,116,97,100,97,116,97,46,106,115,111,110,171,86,202,76,73,205,43,201,76,203,76,45,82,178,82,202,204,75,214,75,76,201,207,213,203,206,76,78,76,209,43,73,45,46,81,170,5,0,80,75,3,4,20,0,0,0,8,0,85,94,43,93,175,166,158,248,14,0,0,0,12,0,0,0,19,0,0,0,112,108,117,103,105,110,115,47,95,95,105,110,105,116,95,95,46,112,121,43,40,202,204,43,209,80,207,200,84,215,228,2,0,80,75,3,4,20,0,0,0,8,0,85,94,43,93,136,205,60,219,64,0,0,0,16,4,0,0,22,0,0,0,115,121,109,98,111,108,115,47,65,100,111,109,46,107,105,99,97,100,95,115,121,109,211,200,206,76,78,76,137,47,174,204,77,202,207,137,207,201,76,82,208,40,75,45,42,206,204,207,83,48,50,48,50,54,52,52,50,208,84,208,72,79,205,75,45,74,44,201,47,82,40,209,212,228,210,24,213,51,170,103,84,207,176,212,3,0,80,75,3,4,20,0,0,0,8,0,85,94,43,93,173,223,251,226,33,0,0,0,31,0,0,0,34,0,0,0,102,111,111,116,112,114,105,110,116,115,47,65,100,111,109,46,112,114,101,116,116,121,47,82,46,107,105,99,97,100,95,109,111,100,211,72,203,207,47,41,40,202,204,43,81,80,10,82,82,208,200,73,172,76,45,82,80,114,211,115,46,85,210,212,228,2,0,80,75,3,4,20,0,0,0,8,0,85,94,43,93,79,211,211,10,41,0,0,0,58,0,0,0,29,0,0,0,51,100,109,111,100,101,108,115,47,65,100,111,109,46,51,100,115,104,97,112,101,115,47,82,46,115,116,101,112,243,12,246,215,53,52,48,54,48,214,53,50,180,230,242,112,117,116,113,13,178,118,245,115,9,118,117,182,118,113,12,113,132,177,129,148,174,39,138,90,0,80,75,3,4,20,0,0,0,8,0,85,94,43,93,0,0,0,0,2,0,0,0,0,0,0,0,11,0,0,0,102,111,111,116,112,114,105,110,116,115,47,3,0,80,75,3,4,20,0,0,0,0,0,0,0,33,0,163,169,132,166,21,0,0,0,21,0,0,0,17,0,0,0,99,111,108,111,114,115,47,116,104,101,109,101,46,106,115,111,110,123,34,109,101,116,97,34,58,123,34,110,97,109,101,34,58,34,120,34,125,125,80,75,1,2,20,3,20,0,0,0,8,0,85,94,43,93,70,221,171,251,38,0,0,0,36,0,0,0,13,0,0,0,0,0,0,0,0,0,0,0,128,1,0,0,0,0,109,101,116,97,100,97,116,97,46,106,115,111,110,80,75,1,2,20,3,20,0,0,0,8,0,85,94,43,93,175,166,158,248,14,0,0,0,12,0,0,0,19,0,0,0,0,0,0,0,0,0,0,0,128,1,81,0,0,0,112,108,117,103,105,110,115,47,95,95,105,110,105,116,95,95,46,112,121,80,75,1,2,20,3,20,0,0,0,8,0,85,94,43,93,136,205,60,219,64,0,0,0,16,4,0,0,22,0,0,0,0,0,0,0,0,0,0,0,128,1,144,0,0,0,115,121,109,98,111,108,115,47,65,100,111,109,46,107,105,99,97,100,95,115,121,109,80,75,1,2,20,3,20,0,0,0,8,0,85,94,43,93,173,223,251,226,33,0,0,0,31,0,0,0,34,0,0,0,0,0,0,0,0,0,0,0,128,1,4,1,0,0,102,111,111,116,112,114,105,110,116,115,47,65,100,111,109,46,112,114,101,116,116,121,47,82,46,107,105,99,97,100,95,109,111,100,80,75,1,2,20,3,20,0,0,0,8,0,85,94,43,93,79,211,211,10,41,0,0,0,58,0,0,0,29,0,0,0,0,0,0,0,0,0,0,0,128,1,101,1,0,0,51,100,109,111,100,101,108,115,47,65,100,111,109,46,51,100,115,104,97,112,101,115,47,82,46,115,116,101,112,80,75,1,2,20,3,20,0,0,0,8,0,85,94,43,93,0,0,0,0,2,0,0,0,0,0,0,0,11,0,0,0,0,0,0,0,0,0,16,0,253,65,201,1,0,0,102,111,111,116,112,114,105,110,116,115,47,80,75,1,2,20,3,20,0,0,0,0,0,0,0,33,0,163,169,132,166,21,0,0,0,21,0,0,0,17,0,0,0,0,0,0,0,0,0,0,0,128,1,244,1,0,0,99,111,108,111,114,115,47,116,104,101,109,101,46,106,115,111,110,80,75,5,6,0,0,0,0,7,0,7,0,211,1,0,0,56,2,0,0,0,0];
const ZIP_SHA: &str = "6b091da4f29425644b6bb432a635430ae43d8ed4eff700a54f053cd3d3aea5bc";
// zlib.compressobj(9, DEFLATED, -15) of a 2280-byte repetitive text: a dynamic-huffman block.
const RAW_DYN: &[u8] = &[237,202,215,17,128,32,20,5,209,86,110,5,142,57,244,65,3,68,179,40,138,169,122,173,131,121,159,59,123,88,167,177,249,94,142,16,206,94,11,140,189,49,248,121,221,97,79,237,112,252,123,226,239,3,101,219,8,140,48,97,194,132,9,19,14,11,199,73,154,229,69,89,213,13,23,82,105,67,29,86,127];
const RAW_STORED: &[u8] = &[1,18,0,237,255,104,101,108,108,111,32,115,116,111,114,101,100,32,98,108,111,99,107];
#[test]
fn sha256_vectors() {
assert_eq!(sha256_hex(b"abc"), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");
assert_eq!(sha256_hex(b""), "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855");
assert_eq!(sha256_hex(&[b'a'; 1000]), "41edece42d63e8d9bf515a9ba6932e1c20cbc9f5a5d134645adb5db1b9737ea3");
assert_eq!(sha256_hex(ZIP), ZIP_SHA);
}
#[test]
fn inflate_dynamic_and_stored_blocks() {
let text = "The quick brown fox jumps over the lazy dog. ".repeat(40) + &"0123456789abcdef".repeat(30);
let out = inflate::inflate(RAW_DYN, text.len()).unwrap();
assert_eq!(out, text.as_bytes());
assert_eq!(inflate::inflate(RAW_STORED, 18).unwrap(), b"hello stored block");
assert!(inflate::inflate(RAW_DYN, 10).is_err(), "output bound enforced");
assert!(inflate::inflate(&RAW_DYN[..20], text.len()).is_err(), "truncated stream reported");
}
#[test]
fn zip_reader_lists_and_reads_entries() {
let zf = ZipFile::parse(ZIP).unwrap();
let names: Vec<&str> = zf.entries.iter().map(|e| e.name.as_str()).collect();
assert_eq!(names, vec!["metadata.json", "plugins/__init__.py", "symbols/Adom.kicad_sym", "footprints/Adom.pretty/R.kicad_mod", "3dmodels/Adom.3dshapes/R.step", "footprints/", "colors/theme.json"]);
let sym = zf.entries.iter().find(|e| e.name == "symbols/Adom.kicad_sym").unwrap();
assert_eq!(sym.method, 8);
assert_eq!(zf.read(sym).unwrap(), "(kicad_symbol_lib (version 20231120) (generator t))\n".repeat(20).as_bytes());
let stored = zf.entries.iter().find(|e| e.name == "colors/theme.json").unwrap();
assert_eq!(stored.method, 0);
assert_eq!(zf.read(stored).unwrap(), br#"{"meta":{"name":"x"}}"#);
assert!(ZipFile::parse(b"not a zip at all, definitely not").is_err());
}
struct Tmp(PathBuf);
impl Drop for Tmp {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn tmp(name: &str) -> Tmp {
let p = std::env::temp_dir().join(format!("kicad-core-pcm-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&p);
std::fs::create_dir_all(&p).unwrap();
Tmp(p)
}
fn ctx(root: &Path) -> Ctx {
Ctx { version: "10.0.6".into(), base_dir: None, config_dir: Some(root.join("config")), user_dir: Some(root.join("Documents/KiCad/10.0")), common: Value::Null }
}
fn read_json(p: &Path) -> Value {
serde_json::from_str(&std::fs::read_to_string(p).unwrap()).unwrap()
}
const REPO_URL: &str = "https://wiki.adom.inc/adom/kicad-pcm/repository.json";
fn repo_table(download_sha: Option<&str>) -> HashMap<String, Vec<u8>> {
let mut v = json!({"version": "1.0.0", "download_url": "https://example.test/pkg.zip"});
if let Some(s) = download_sha {
v["download_sha256"] = json!(s);
}
let packages = json!({"packages": [
{"identifier": "inc.adom.kicad.test", "name": "Adom Test", "type": "library", "versions": [{"version": "0.9.0", "download_url": "https://example.test/old.zip"}, v]},
{"identifier": "inc.adom.kicad.other", "name": "Other", "type": "plugin", "versions": []}
]}).to_string().into_bytes();
let repo = json!({"name": "Adom PCM", "packages": {"url": "https://example.test/packages.json", "sha256": sha256_hex(&packages)}}).to_string().into_bytes();
let mut t = HashMap::new();
t.insert(REPO_URL.to_string(), repo);
t.insert("https://example.test/packages.json".to_string(), packages);
t.insert("https://example.test/pkg.zip".to_string(), ZIP.to_vec());
t
}
fn fetcher(table: HashMap<String, Vec<u8>>) -> impl Fn(&str, Duration) -> Result<Vec<u8>, String> {
move |url: &str, _t: Duration| table.get(url).cloned().ok_or_else(|| format!("URLError: no such url {url}"))
}
#[test]
fn extract_lays_out_like_pcm() {
let t = tmp("extract");
let third = t.0.join("3rdparty");
let e = extract(ZIP, &third, "inc.adom.kicad.test").unwrap();
assert_eq!(e.package_dir, "inc_adom_kicad_test");
assert_eq!(e.files, 5, "metadata.json and the directory entry are skipped");
assert_eq!(e.symbols, vec!["Adom"]);
assert_eq!(e.footprints, vec!["Adom"]);
assert_eq!(e.models, vec!["Adom.3dshapes"]);
assert!(third.join("footprints/inc_adom_kicad_test/Adom.pretty/R.kicad_mod").is_file());
assert!(third.join("plugins/inc_adom_kicad_test/__init__.py").is_file());
assert!(third.join("colors/inc_adom_kicad_test/theme.json").is_file());
assert!(!third.join("metadata.json").exists());
}
#[test]
fn install_from_a_local_zip_like_a_repository() {
let t = tmp("zipinstall");
let c = ctx(&t.0);
let zip = t.0.join("pkg.zip");
std::fs::write(&zip, ZIP).unwrap();
let unused = |u: &str, _t: Duration| -> Result<Vec<u8>, String> { Err(format!("no network in this test: {u}")) };
let r = install(&c, &json!({"zipPath": zip.to_string_lossy()}), &unused);
assert_eq!(r["success"], json!(true), "{r}");
assert_eq!(r["identifier"], json!("inc.adom.kicad.test"));
assert!(r["_note"].as_str().unwrap().contains("local zip"));
let reg = read_installed(&t.0.join("config/installed_packages.json"));
assert!(reg["packages"].as_array().unwrap().iter().any(|e| ident_of(e) == Some("inc.adom.kicad.test")), "{reg}");
let bad = install(&c, &json!({"zipPath": t.0.join("missing.zip").to_string_lossy()}), &unused);
assert_eq!(bad["errorCode"], json!("zip_unreadable"));
}
#[test]
fn install_list_uninstall_round_trip() {
let t = tmp("install");
let c = ctx(&t.0);
let f = fetcher(repo_table(Some(ZIP_SHA)));
let r = install(&c, &json!({"repositoryUrl": REPO_URL, "identifier": "inc.adom.kicad.test"}), &f);
assert_eq!(r["success"], json!(true), "{r}");
assert_eq!(r["version"], json!("1.0.0"));
assert_eq!(r["filesInstalled"], json!(5));
assert_eq!(r["symbolLibraries"], json!(["PCM_Adom"]));
assert_eq!(r["footprintLibraries"], json!(["PCM_Adom"]));
assert_eq!(r["modelSets"], json!(["Adom.3dshapes"]));
assert_eq!(r["restartNeeded"], json!(r["kicadRunning"] != json!(false)), "{r}");
assert_eq!(r["thirdPartyDir"], json!(norm(&t.0.join("Documents/KiCad/10.0/3rdparty"))));
let reg = read_installed(&t.0.join("config/installed_packages.json"));
let e = ®["packages"][0];
assert_eq!(e["current_version"], json!("1.0.0"));
assert_eq!(e["repository_id"], json!(&sha256_hex(REPO_URL.as_bytes())[..16]));
assert_eq!(e["repository_name"], json!("Adom PCM"));
assert_eq!(e["pinned"], json!(false));
assert_eq!(e["package"]["versions"].as_array().unwrap().len(), 1, "only the installed version is recorded");
assert!(e["install_timestamp"].as_u64().unwrap() > 1_700_000_000);
// second call: already installed unless forced
let r = install(&c, &json!({"repositoryUrl": REPO_URL, "identifier": "inc.adom.kicad.test"}), &f);
assert_eq!(r["alreadyInstalled"], json!(true));
let r = install(&c, &json!({"repositoryUrl": REPO_URL, "identifier": "inc.adom.kicad.test", "force": true, "libPrefix": "X_"}), &f);
assert_eq!(r["symbolLibraries"], json!(["X_Adom"]));
// list
let l = list(&c, &json!({"checkUpdates": false}), &f);
assert_eq!(l["count"], json!(1));
assert_eq!(l["packages"][0]["updateCheck"], json!("skipped"));
assert!(l["packages"][0]["updateAvailable"].is_null());
assert_eq!(l["packages"][0]["identifier"], json!("inc.adom.kicad.test"));
assert_eq!(l["packages"][0]["onDisk"], json!(true));
assert_eq!(l["kicadVersion"], json!("10.0.6"));
// uninstall
let u = uninstall(&c, &json!({"identifier": "inc.adom.kicad.test"}));
assert_eq!(u["recordRemoved"], json!(true));
assert_eq!(u["removedDirs"].as_array().unwrap().len(), 5);
assert!(!t.0.join("Documents/KiCad/10.0/3rdparty/symbols/inc_adom_kicad_test").exists());
assert_eq!(list(&c, &json!({}), &f)["count"], json!(0));
let u = uninstall(&c, &json!({"identifier": "inc.adom.kicad.test"}));
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);
let f = fetcher(repo_table(Some("00")));
let r = install(&c, &json!({"repositoryUrl": REPO_URL, "identifier": "inc.adom.kicad.test"}), &f);
assert_eq!(r["error"], json!("download sha256 mismatch"));
assert_eq!(r["got"], json!(ZIP_SHA));
let r = install(&c, &json!({"repositoryUrl": REPO_URL, "identifier": "inc.adom.kicad.nope"}), &f);
assert!(r["error"].as_str().unwrap().starts_with("package not in repository"));
assert_eq!(r["available"], json!(["inc.adom.kicad.test", "inc.adom.kicad.other"]));
let r = install(&c, &json!({"repositoryUrl": REPO_URL, "identifier": "inc.adom.kicad.test", "version": "3.0"}), &f);
assert_eq!(r["error"], json!("version 3.0 not offered"));
assert_eq!(r["available"], json!(["0.9.0", "1.0.0"]));
let r = install(&c, &json!({"repositoryUrl": REPO_URL, "identifier": "inc.adom.kicad.other"}), &f);
assert_eq!(r["error"], json!("package has no downloadable version"));
let r = install(&c, &json!({"identifier": "x"}), &f);
assert!(r["error"].as_str().unwrap().contains("required"));
// packages.json digest mismatch
let mut table = repo_table(None);
table.insert("https://example.test/packages.json".into(), b"{\"packages\":[]}".to_vec());
let r = install(&c, &json!({"repositoryUrl": REPO_URL, "identifier": "inc.adom.kicad.test"}), &fetcher(table));
assert!(r["error"].as_str().unwrap().contains("hash does not match"));
// unreachable repository
let r = install(&c, &json!({"repositoryUrl": "https://example.test/none.json", "identifier": "x"}), &fetcher(HashMap::new()));
assert!(r["error"].as_str().unwrap().starts_with("could not read repository: URLError"));
// no KiCad dirs known
let r = install(&Ctx::default(), &json!({"repositoryUrl": REPO_URL, "identifier": "x"}), &f);
assert!(r["error"].as_str().unwrap().contains("directories unknown"));
assert_eq!(list(&Ctx::default(), &json!({}), &f)["success"], json!(false));
}
#[test]
fn kicad_json_prefs_and_common_override() {
let t = tmp("prefs");
let mut c = ctx(&t.0);
std::fs::create_dir_all(t.0.join("config")).unwrap();
std::fs::write(t.0.join("config/kicad.json"), r#"{"pcm": {"lib_auto_add": false, "lib_prefix": "ADOM_"}}"#).unwrap();
c.common = json!({"environment": {"vars": {"KICAD10_3RD_PARTY": norm(&t.0.join("custom3rd"))}}});
let f = fetcher(repo_table(None));
let r = install(&c, &json!({"repositoryUrl": REPO_URL, "identifier": "inc.adom.kicad.test"}), &f);
assert_eq!(r["success"], json!(true), "{r}");
assert_eq!(r["libPrefix"], json!("ADOM_"));
assert_eq!(r["libAutoAdd"], json!(false));
assert_eq!(r["symbolLibraries"], json!(["ADOM_Adom"]));
assert_eq!(r["thirdPartyResolvedBy"], json!("kicad_common.json override"));
assert!(t.0.join("custom3rd/symbols/inc_adom_kicad_test/Adom.kicad_sym").is_file());
assert!(r["_hint"].as_str().unwrap().contains("lib_auto_add is OFF"));
}
#[test]
fn installed_registry_is_tolerant_and_pretty() {
let t = tmp("registry");
let p = t.0.join("installed_packages.json");
assert_eq!(read_installed(&p), json!({"packages": []}));
std::fs::write(&p, "garbage").unwrap();
assert_eq!(read_installed(&p), json!({"packages": []}));
write_installed(&p, &json!({"packages": [{"current_version": "1"}]})).unwrap();
let text = std::fs::read_to_string(&p).unwrap();
assert!(text.starts_with("{\n \"packages\""), "{text}");
assert!(text.ends_with("}\n"));
assert_eq!(read_installed(&p)["packages"][0]["current_version"], json!("1"));
}
#[test]
fn version_order_matches_pcm() {
use std::cmp::Ordering::*;
assert_eq!(compare_versions("1.0.0", "1.0"), Equal);
assert_eq!(compare_versions("1.1.0", "1.0.0"), Greater);
assert_eq!(compare_versions("1.10.0", "1.9.0"), Greater, "numeric, not lexical");
assert_eq!(compare_versions("2", "1.99.99"), Greater);
assert_eq!(compare_versions("0.9", "1.0.0"), Less);
assert_eq!(compare_versions("1.0.0-rc1", "1.0.0"), Greater, "non-numeric tail ties on numbers, then the string decides");
// epoch wins over the dotted number
let a = json!({"version": "1.0.0", "version_epoch": 1});
let b = json!({"version": "9.9.9"});
assert_eq!(version_cmp(&a, &b), Greater);
assert_eq!(version_cmp(&json!({"version": "1.2"}), &json!({"version": "1.10"})), Less);
// compatibility on major.minor
let v = json!({"version": "1", "kicad_version": "10.0", "download_url": "u"});
assert!(version_compatible(&v, "10.0.5"));
assert!(!version_compatible(&v, "9.0.4"));
assert!(version_compatible(&v, ""), "unknown KiCad accepts everything");
let capped = json!({"version": "1", "kicad_version": "8.0", "kicad_version_max": "9.0", "download_url": "u"});
assert!(version_compatible(&capped, "9.0.2"));
assert!(!version_compatible(&capped, "10.0.5"));
// latest: highest compatible with a download_url
let versions = vec![
json!({"version": "1.0.0", "kicad_version": "10.0", "download_url": "a"}),
json!({"version": "1.2.0", "kicad_version": "11.0", "download_url": "c"}),
json!({"version": "1.1.0", "kicad_version": "10.0", "download_url": "b"}),
json!({"version": "1.3.0", "kicad_version": "10.0"}),
];
assert_eq!(latest_version(&versions, "10.0.5").unwrap()["version"], json!("1.1.0"));
assert_eq!(latest_version(&versions, "11.0.0").unwrap()["version"], json!("1.2.0"));
assert!(latest_version(&versions[3..], "10.0.5").is_none(), "no download_url, nothing to offer");
assert_eq!(repository_id("https://repository.kicad.org/repository.json").len(), 16);
}
#[test]
fn repositories_edit_round_trip() {
let t = tmp("repos");
let c = ctx(&t.0);
let cfg = t.0.join("config");
let f = fetcher(repo_table(Some(ZIP_SHA)));
// Nothing registered yet: KiCad's built-in default is what PCM would use.
assert_eq!(registered_repositories(&c), vec![(DEFAULT_REPOSITORY_NAME.to_string(), DEFAULT_REPOSITORY_URL.to_string())]);
// Validation.
assert_eq!(add_repository(&c, &json!({}), &f)["errorCode"], json!("invalid_arg"));
assert_eq!(add_repository(&c, &json!({"url": "ftp://x/y.json"}), &f)["errorCode"], json!("invalid_arg"));
let r = add_repository(&c, &json!({"url": "https://example.test/none.json"}), &f);
assert_eq!(r["errorCode"], json!("repository_unreachable"));
assert!(!cfg.join("kicad.json").exists(), "a refused add writes nothing");
// Unverified add with an explicit name still lands, with a warning.
let r = add_repository(&c, &json!({"url": "https://example.test/none.json", "name": "Blind"}), &f);
assert_eq!(r["success"], json!(true), "{r}");
assert_eq!(r["verified"], json!(false));
assert!(r["warning"].as_str().unwrap().contains("unverified"));
assert!(r["backup"].is_null(), "no kicad.json existed, so nothing to back up");
// First add on a fresh kicad.json: the official row is kept in front.
let r = add_repository(&c, &json!({"url": REPO_URL}), &f);
assert_eq!(r["success"], json!(true), "{r}");
assert_eq!(r["added"], json!(true));
assert_eq!(r["verified"], json!(true));
assert_eq!(r["name"], json!("Adom PCM"), "name taken from the fetched repository.json");
assert_eq!(r["repositoryId"], json!(repository_id(REPO_URL)));
assert_eq!(r["restartNeeded"], json!(true));
assert!(r["kicadRunning"].is_null());
let bak = cfg.join("kicad.json.adom-bak");
assert!(bak.exists());
assert_eq!(r["backup"], json!(norm(&bak)));
let doc = read_json(&cfg.join("kicad.json"));
assert_eq!(doc["pcm"]["repositories"], json!([
{"name": DEFAULT_REPOSITORY_NAME, "url": DEFAULT_REPOSITORY_URL},
{"name": "Blind", "url": "https://example.test/none.json"},
{"name": "Adom PCM", "url": REPO_URL},
]));
assert_eq!(doc["meta"]["filename"], json!("kicad"));
let text = std::fs::read_to_string(cfg.join("kicad.json")).unwrap();
assert!(text.starts_with("{\n \""), "two-space indent like KiCad: {text}");
assert!(!cfg.join("kicad.json.adom-tmp").exists());
// Idempotent: same URL again writes nothing (backup untouched).
let before = std::fs::read_to_string(&bak).unwrap();
let r = add_repository(&c, &json!({"url": REPO_URL, "name": "Renamed"}), &f);
assert_eq!(r["added"], json!(false));
assert_eq!(r["alreadyRegistered"], json!(true));
assert_eq!(r["name"], json!("Adom PCM"));
assert_eq!(std::fs::read_to_string(&bak).unwrap(), before);
assert_eq!(registered_repositories(&c).len(), 3);
// Other keys in kicad.json survive the edit.
let mut doc = read_json(&cfg.join("kicad.json"));
doc["system"] = json!({"check_for_kicad_updates": true});
doc["pcm"]["lib_prefix"] = json!("PCM_");
std::fs::write(cfg.join("kicad.json"), doc.to_string()).unwrap();
let r = remove_repository(&c, &json!({"url": "https://example.test/none.json"}));
assert_eq!(r["removed"], json!(true), "{r}");
let doc = read_json(&cfg.join("kicad.json"));
assert_eq!(doc["system"]["check_for_kicad_updates"], json!(true));
assert_eq!(doc["pcm"]["lib_prefix"], json!("PCM_"));
assert_eq!(doc["pcm"]["repositories"].as_array().unwrap().len(), 2);
let r = remove_repository(&c, &json!({"url": "https://example.test/none.json"}));
assert_eq!(r["removed"], json!(false));
assert_eq!(remove_repository(&c, &json!({}))["errorCode"], json!("invalid_arg"));
assert_eq!(add_repository(&Ctx::default(), &json!({"url": REPO_URL}), &f)["errorCode"], json!("settings_not_found"));
// A corrupt kicad.json is refused, not clobbered.
std::fs::write(cfg.join("kicad.json"), "{ nope").unwrap();
assert_eq!(add_repository(&c, &json!({"url": "https://example.test/other.json", "name": "x"}), &f)["errorCode"], json!("settings_unreadable"));
assert_eq!(std::fs::read_to_string(cfg.join("kicad.json")).unwrap(), "{ nope");
}
fn repo_table_v(versions: Vec<Value>) -> HashMap<String, Vec<u8>> {
let packages = json!({"packages": [
{"identifier": "inc.adom.kicad.test", "name": "Adom Test", "type": "library", "versions": versions}
]}).to_string().into_bytes();
let repo = json!({"name": "Adom PCM", "packages": {"url": "https://example.test/packages.json", "sha256": sha256_hex(&packages)}}).to_string().into_bytes();
let mut t = HashMap::new();
t.insert(REPO_URL.to_string(), repo);
t.insert("https://example.test/packages.json".to_string(), packages);
t.insert("https://example.test/pkg.zip".to_string(), ZIP.to_vec());
t
}
#[test]
fn list_reports_updates_and_update_installs() {
let t = tmp("update");
let c = ctx(&t.0);
let v100 = json!({"version": "1.0.0", "status": "stable", "kicad_version": "10.0", "download_url": "https://example.test/pkg.zip", "download_sha256": ZIP_SHA});
let mut v110 = v100.clone();
v110["version"] = json!("1.1.0");
let mut v200 = v100.clone();
v200["version"] = json!("2.0.0");
v200["kicad_version"] = json!("11.0");
// Repository offers 1.0.0 only; register it and install.
let f1 = fetcher(repo_table_v(vec![v100.clone()]));
assert_eq!(add_repository(&c, &json!({"url": REPO_URL}), &f1)["added"], json!(true));
let r = install(&c, &json!({"repositoryUrl": REPO_URL, "identifier": "inc.adom.kicad.test"}), &f1);
assert_eq!(r["success"], json!(true), "{r}");
assert_eq!(r["version"], json!("1.0.0"));
assert!(r["previousVersion"].is_null());
assert_eq!(r["updated"], json!(false));
let l = list(&c, &json!({}), &f1);
assert_eq!(l["updatesAvailable"], json!(0));
assert_eq!(l["packages"][0]["updateAvailable"], json!(false));
assert_eq!(l["packages"][0]["latestVersion"], json!("1.0.0"));
assert_eq!(l["packages"][0]["updateCheck"], json!("ok"));
assert_eq!(l["packages"][0]["repositoryUrl"], json!(REPO_URL));
assert_eq!(l["repositories"].as_array().unwrap().len(), 2);
// update:true with nothing newer: upToDate, nothing touched.
let r = install(&c, &json!({"identifier": "inc.adom.kicad.test", "update": true}), &f1);
assert_eq!(r["upToDate"], json!(true), "{r}");
assert_eq!(r["version"], json!("1.0.0"));
// The repository bumps to 1.1.0 (and lists a 2.0.0 this KiCad cannot take).
let f2 = fetcher(repo_table_v(vec![v100.clone(), v110.clone(), v200.clone()]));
let l = list(&c, &json!({}), &f2);
assert_eq!(l["updatesAvailable"], json!(1), "{l}");
assert_eq!(l["packages"][0]["updateAvailable"], json!(true));
assert_eq!(l["packages"][0]["latestVersion"], json!("1.1.0"), "2.0.0 needs KiCad 11, so it is not offered");
assert!(l["_hint"].as_str().unwrap().contains("update"));
// Headless Update button: repositoryUrl omitted, resolved from the registry row.
let r = install(&c, &json!({"identifier": "inc.adom.kicad.test", "update": true}), &f2);
assert_eq!(r["success"], json!(true), "{r}");
assert_eq!(r["version"], json!("1.1.0"));
assert_eq!(r["previousVersion"], json!("1.0.0"));
assert_eq!(r["updated"], json!(true));
assert_eq!(r["repositoryUrl"], json!(REPO_URL));
assert!(r["_hint"].as_str().unwrap().contains("Replaced 1.0.0 with 1.1.0"));
let reg = read_installed(&t.0.join("config/installed_packages.json"));
assert_eq!(reg["packages"].as_array().unwrap().len(), 1, "one row, replaced");
assert_eq!(reg["packages"][0]["current_version"], json!("1.1.0"));
assert_eq!(reg["packages"][0]["package"]["versions"][0]["version"], json!("1.1.0"));
let l = list(&c, &json!({}), &f2);
assert_eq!(l["updatesAvailable"], json!(0));
assert_eq!(l["packages"][0]["updateAvailable"], json!(false));
// A pinned package is never offered an update, as PCM does.
let mut reg = read_installed(&t.0.join("config/installed_packages.json"));
reg["packages"][0]["current_version"] = json!("1.0.0");
reg["packages"][0]["pinned"] = json!(true);
write_installed(&t.0.join("config/installed_packages.json"), ®).unwrap();
let l = list(&c, &json!({}), &f2);
assert_eq!(l["packages"][0]["updateAvailable"], json!(false));
assert_eq!(l["packages"][0]["updateCheck"], json!("pinned"));
assert_eq!(l["updatesAvailable"], json!(0));
// Unreachable repository: the row says so instead of failing the verb.
let l = list(&c, &json!({}), &fetcher(HashMap::new()));
assert_eq!(l["success"], json!(true));
assert!(l["packages"][0]["updateCheck"].as_str().unwrap().starts_with("fetch_failed"));
// Repository removed from kicad.json: update:true without repositoryUrl cannot resolve it.
remove_repository(&c, &json!({"url": REPO_URL}));
let l = list(&c, &json!({}), &f2);
assert_eq!(l["packages"][0]["updateCheck"], json!("repository_not_registered"));
let r = install(&c, &json!({"identifier": "inc.adom.kicad.test", "update": true}), &f2);
assert_eq!(r["errorCode"], json!("repository_not_registered"));
// ...but an explicit repositoryUrl still works, and a not-installed package needs one.
let r = install(&c, &json!({"identifier": "inc.adom.kicad.test", "update": true, "repositoryUrl": REPO_URL, "force": true}), &f2);
assert_eq!(r["version"], json!("1.1.0"), "{r}");
let r = install(&c, &json!({"identifier": "inc.adom.kicad.other", "update": true}), &f2);
assert_eq!(r["errorCode"], json!("not_installed"));
}
}