app
service-kicad
Public Made by Adomby adom
Shared headless KiCad 10: DRC, ERC, SVG/Gerber/STEP export, Altium library conversion, symbol/footprint/3D-model lookup. Every Adom tool shells to this CLI instead of calling the HTTP API directly.
master
John Lauer
Merge PR #99: Add complete-project DRC with input hashes and explicit rule probes
2b18ad1
1mo ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402
//! Exact project transport. Does not claim native rules were enforced.
use anyhow::{bail, Context, Result};
use serde::Serialize;
use sha2::{Digest, Sha256};
use std::{
collections::{BTreeMap, BTreeSet},
fs,
io::{Cursor, Read},
path::{Component, Path, PathBuf},
};
pub const MAX_BYTES: u64 = 100 * 1024 * 1024;
pub const MAX_FILES: usize = 4096;
#[derive(Debug, Clone, Serialize)]
pub struct InputFile {
pub path: String,
pub bytes: u64,
pub sha256: String,
}
pub struct Project {
pub directory: tempfile::TempDir,
pub board: PathBuf,
pub project: PathBuf,
pub rules: Option<PathBuf>,
pub files: Vec<InputFile>,
}
fn relative_name(path: &Path) -> Result<String> {
let s = path.to_str().context("project path must be UTF-8")?;
if s.is_empty()
|| s.contains(['\\', ':'])
|| s.chars().any(char::is_control)
|| s.starts_with('/')
|| s.split('/').any(|p| p.is_empty() || p == "." || p == "..")
{
bail!("unsafe_project_path: {s:?}");
}
if path
.components()
.any(|c| !matches!(c, Component::Normal(_)))
{
bail!("unsafe_project_path: {s:?}");
}
Ok(s.to_owned())
}
fn allowed(path: &Path) -> bool {
matches!(
path.file_name().and_then(|x| x.to_str()),
Some("fp-lib-table" | "sym-lib-table")
) || matches!(
path.extension().and_then(|x| x.to_str()),
Some("kicad_pcb" | "kicad_pro" | "kicad_dru" | "kicad_mod" | "kicad_sym" | "kicad_wks")
)
}
fn file_record(path: String, bytes: &[u8]) -> InputFile {
InputFile {
path,
bytes: bytes.len() as u64,
sha256: format!("{:x}", Sha256::digest(bytes)),
}
}
/// Extract only regular KiCad input files, never links or special entries.
/// The fresh temp directory is retained by Project through the native command.
pub fn unpack(bytes: &[u8], selected: &str, require_rules: bool) -> Result<Project> {
if bytes.len() as u64 > MAX_BYTES {
bail!("project_too_large");
}
let board = PathBuf::from(relative_name(Path::new(selected))?);
if board.extension().and_then(|s| s.to_str()) != Some("kicad_pcb") {
bail!("selected_board_must_be_kicad_pcb");
}
let directory = tempfile::tempdir()?;
let mut archive = tar::Archive::new(Cursor::new(bytes));
let mut names = BTreeSet::new();
let mut files = Vec::new();
let mut total = 0u64;
for (count, entry) in archive.entries()?.enumerate() {
if count >= MAX_FILES {
bail!("too_many_project_entries");
}
let mut entry = entry?;
let kind = entry.header().entry_type();
if !kind.is_file() {
bail!("project_entry_not_regular_file");
}
let path = entry.path()?.into_owned();
let name = relative_name(&path)?;
if !allowed(&path) {
bail!("unsupported_project_file: {name}");
}
if !names.insert(name.to_lowercase()) {
bail!("duplicate_project_path: {name}");
}
let size = entry.size();
total = total.checked_add(size).context("project_size_overflow")?;
if total > MAX_BYTES {
bail!("project_too_large");
}
let mut data = Vec::new();
entry.by_ref().take(size + 1).read_to_end(&mut data)?;
if data.len() as u64 != size {
bail!("truncated_project_entry: {name}");
}
let dest = directory.path().join(&path);
fs::create_dir_all(dest.parent().context("missing parent")?)?;
fs::write(dest, &data)?;
files.push(file_record(name, &data));
}
if !directory.path().join(&board).is_file() {
bail!("selected_board_missing: {selected}");
}
let project = board.with_extension("kicad_pro");
if !directory.path().join(&project).is_file() {
bail!("matching_project_missing: {}", project.display());
}
// Reject malformed project JSON before invoking native KiCad.
let config: serde_json::Value =
serde_json::from_slice(&fs::read(directory.path().join(&project))?)
.context("invalid_kicad_project_json")?;
if !config.is_object() {
bail!("kicad_project_must_be_object");
}
let rule_path = board.with_extension("kicad_dru");
let rules = directory
.path()
.join(&rule_path)
.is_file()
.then_some(rule_path);
if require_rules && rules.is_none() {
bail!(
"matching_custom_rules_missing: use an explicit no-custom-rules mode only if intended"
);
}
files.sort_by(|a, b| a.path.cmp(&b.path));
Ok(Project {
directory,
board,
project,
rules,
files,
})
}
/// Only requested inputs are uploaded. No implicit recursive scan of the workspace.
pub fn pack(
root: &Path,
selected: &str,
includes: &[PathBuf],
require_rules: bool,
) -> Result<Vec<u8>> {
let root = root.canonicalize().context("project directory")?;
let board = PathBuf::from(relative_name(Path::new(selected))?);
let mut paths = BTreeSet::new();
let mut count = 0usize;
fn collect(
root: &Path,
rel: &Path,
paths: &mut BTreeSet<PathBuf>,
count: &mut usize,
) -> Result<()> {
relative_name(rel)?;
// Check every path ancestor; canonical containment alone could accept links.
let mut at = root.to_owned();
for part in rel.components() {
at.push(part);
if fs::symlink_metadata(&at)?.file_type().is_symlink() {
bail!("project_symlink_rejected: {}", rel.display());
}
}
let full = root.join(rel);
let metadata = fs::symlink_metadata(&full)?;
*count += 1;
if *count > MAX_FILES {
bail!("too_many_project_entries");
}
if metadata.is_dir() {
for child in fs::read_dir(full)? {
collect(root, &rel.join(child?.file_name()), paths, count)?;
}
} else if metadata.is_file() && allowed(rel) {
paths.insert(rel.to_owned());
} else {
bail!("unsupported_project_file: {}", rel.display());
}
Ok(())
}
collect(&root, &board, &mut paths, &mut count)?;
collect(
&root,
&board.with_extension("kicad_pro"),
&mut paths,
&mut count,
)?;
let rules = board.with_extension("kicad_dru");
if require_rules || root.join(&rules).exists() {
collect(&root, &rules, &mut paths, &mut count)?;
}
for name in ["fp-lib-table", "sym-lib-table"] {
let path = board.parent().unwrap_or(Path::new("")).join(name);
if root.join(&path).exists() {
collect(&root, &path, &mut paths, &mut count)?;
}
}
for path in includes {
collect(&root, path, &mut paths, &mut count)?;
}
let mut tar = tar::Builder::new(Vec::new());
let mut total = 0u64;
let mut names = BTreeMap::new();
for path in paths {
let name = relative_name(&path)?;
if names.insert(name.to_lowercase(), name.clone()).is_some() {
bail!("duplicate_project_path: {name}");
}
if fs::metadata(root.join(&path))?.len() > MAX_BYTES - total {
bail!("project_too_large");
}
let data = fs::read(root.join(&path))?;
total += data.len() as u64;
if total > MAX_BYTES {
bail!("project_too_large");
}
let mut header = tar::Header::new_gnu();
header.set_mode(0o600);
header.set_size(data.len() as u64);
header.set_cksum();
tar.append_data(&mut header, path, Cursor::new(data))?;
}
let bytes = tar.into_inner()?;
// Same validation and size limits as the API, before making a network call.
unpack(&bytes, selected, require_rules)?;
Ok(bytes)
}
#[cfg(test)]
mod tests {
use super::*;
fn archive(entries: &[(&str, &[u8], tar::EntryType)]) -> Vec<u8> {
let mut builder = tar::Builder::new(Vec::new());
for (name, content, kind) in entries {
let mut header = tar::Header::new_gnu();
// Raw header allows deliberately invalid path fixtures that append_data rejects.
header.as_mut_bytes()[..100].fill(0);
header.as_mut_bytes()[..name.len()].copy_from_slice(name.as_bytes());
header.set_size(content.len() as u64);
header.set_mode(0o600);
header.set_entry_type(*kind);
header.set_cksum();
builder.append(&header, *content).unwrap();
}
builder.into_inner().unwrap()
}
fn valid() -> Vec<u8> {
archive(&[
(
"nested/board.kicad_pcb",
b"(board)",
tar::EntryType::Regular,
),
("nested/board.kicad_pro", b"{}", tar::EntryType::Regular),
(
"nested/board.kicad_dru",
b"(version 1)",
tar::EntryType::Regular,
),
])
}
#[test]
fn exact_stem_nested_inputs_and_hashes_survive() {
let p = unpack(&valid(), "nested/board.kicad_pcb", true).unwrap();
assert_eq!(
p.rules.as_deref(),
Some(Path::new("nested/board.kicad_dru"))
);
assert_eq!(
fs::read(p.directory.path().join(&p.project)).unwrap(),
b"{}"
);
assert_eq!(
p.files
.iter()
.find(|f| f.path.ends_with(".kicad_pro"))
.unwrap()
.sha256,
"44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a"
);
}
#[test]
fn traversal_absolute_and_windows_paths_are_rejected() {
for path in [
"../board.kicad_pcb",
"/board.kicad_pcb",
"C:/board.kicad_pcb",
"nested/../board.kicad_pcb",
"nested\\board.kicad_pcb",
] {
let bytes = archive(&[(path, b"x", tar::EntryType::Regular)]);
assert!(unpack(&bytes, "board.kicad_pcb", true).is_err(), "{path}");
}
}
#[test]
fn links_and_special_files_are_rejected() {
for kind in [
tar::EntryType::Symlink,
tar::EntryType::Link,
tar::EntryType::Fifo,
tar::EntryType::Directory,
] {
assert!(unpack(
&archive(&[("board.kicad_pcb", b"", kind)]),
"board.kicad_pcb",
true
)
.is_err());
}
}
#[test]
fn duplicate_paths_including_case_aliases_are_rejected() {
let bytes = archive(&[
("board.kicad_pcb", b"a", tar::EntryType::Regular),
("BOARD.kicad_pcb", b"b", tar::EntryType::Regular),
]);
assert!(unpack(&bytes, "board.kicad_pcb", true)
.err()
.unwrap()
.to_string()
.contains("duplicate"));
}
#[test]
fn missing_selected_board_project_and_rules_fail_without_fallback() {
assert!(unpack(&valid(), "other.kicad_pcb", true).is_err());
assert!(unpack(
&archive(&[("b.kicad_pcb", b"x", tar::EntryType::Regular)]),
"b.kicad_pcb",
true
)
.is_err());
let bytes = archive(&[
("b.kicad_pcb", b"x", tar::EntryType::Regular),
("b.kicad_pro", b"{}", tar::EntryType::Regular),
]);
assert!(unpack(&bytes, "b.kicad_pcb", true).is_err());
assert!(unpack(&bytes, "b.kicad_pcb", false)
.unwrap()
.rules
.is_none());
}
#[test]
fn project_json_must_be_an_object() {
let bytes = archive(&[
("b.kicad_pcb", b"x", tar::EntryType::Regular),
("b.kicad_pro", b"[]", tar::EntryType::Regular),
]);
assert!(unpack(&bytes, "b.kicad_pcb", false).is_err());
}
#[test]
fn pack_preserves_only_selected_files_and_explicit_libraries() {
let temp = tempfile::tempdir().unwrap();
for (name, content) in [
("a.kicad_pcb", "a"),
("a.kicad_pro", "{}"),
("a.kicad_dru", "(version 1)"),
("other.kicad_pcb", "private unrelated board"),
("credentials.txt", "not an upload"),
] {
fs::write(temp.path().join(name), content).unwrap();
}
fs::create_dir(temp.path().join("Parts.pretty")).unwrap();
fs::write(
temp.path().join("Parts.pretty/A.kicad_mod"),
"(footprint A)",
)
.unwrap();
let bytes = pack(temp.path(), "a.kicad_pcb", &["Parts.pretty".into()], true).unwrap();
let p = unpack(&bytes, "a.kicad_pcb", true).unwrap();
assert_eq!(p.files.len(), 4);
assert!(!p
.files
.iter()
.any(|f| f.path.contains("other") || f.path.contains("credentials")));
}
#[test]
#[cfg(unix)]
fn cli_rejects_linked_files_and_linked_ancestors() {
let temp = tempfile::tempdir().unwrap();
let real = tempfile::tempdir().unwrap();
fs::write(real.path().join("a.kicad_pcb"), "a").unwrap();
std::os::unix::fs::symlink(real.path(), temp.path().join("linked")).unwrap();
assert!(pack(temp.path(), "linked/a.kicad_pcb", &[], false).is_err());
}
#[test]
fn explicit_board_selection_does_not_pick_first_file() {
let bytes = archive(&[
("a.kicad_pcb", b"wrong", tar::EntryType::Regular),
("z.kicad_pcb", b"right", tar::EntryType::Regular),
("z.kicad_pro", b"{}", tar::EntryType::Regular),
]);
let p = unpack(&bytes, "z.kicad_pcb", false).unwrap();
assert_eq!(
fs::read(p.directory.path().join(p.board)).unwrap(),
b"right"
);
}
}