master
John Lauer Merge PR #99: Add complete-project DRC with input hashes and explicit rule probes 2b18ad1 1mo ago
//! 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"
        );
    }
}