//! Control-application dependency SBOM from a CODESYS `.projectarchive`. //! //! A `.projectarchive` is a ZIP that bundles the project plus its referenced //! libraries and the target runtime. Each referenced library is an entry whose //! path segment follows the CODESYS convention //! `Name, Major.Minor.Patch.Build (Company)` (e.g. `Standard, 3.5.18.0 (System)`, //! `CSV Utility SL, 1.9.0.0 (CODESYS)`); the runtime appears as a device-descriptor //! entry `CODESYS Control … …`. We enumerate those entries — no binary //! parsing — and emit SBOM components tagged `pkg:codesys/…`, so the CVE pipeline //! can match them (the runtime `Cmp*` / `3SLicense` components carry real CODESYS //! CVEs). use std::collections::BTreeSet; use std::path::{Path, PathBuf}; use compliance_core::models::SbomEntry; /// Collect the control-application SBOM from every `.projectarchive` reachable for /// a target: the ingested artifact file itself (an uploaded archive), plus any /// `*.projectarchive` committed inside the working tree — e.g. a git repo or an /// extracted source archive that ships the archive alongside its PLCopen XML / ST /// exports. Deduplicated by (name, version). pub fn collect_sbom(artifact_file: &Path, working_path: &Path, repo_id: &str) -> Vec { let mut archives: Vec = Vec::new(); if artifact_file.is_file() { archives.push(artifact_file.to_path_buf()); } for entry in walkdir::WalkDir::new(working_path) .max_depth(8) .into_iter() .filter_map(|e| e.ok()) { let p = entry.path(); if entry.file_type().is_file() && p.extension() .and_then(|x| x.to_str()) .is_some_and(|x| x.eq_ignore_ascii_case("projectarchive")) { archives.push(p.to_path_buf()); } } let mut seen: BTreeSet<(String, String)> = BTreeSet::new(); let mut out = Vec::new(); for a in archives { for e in projectarchive_sbom(&a, repo_id) { if seen.insert((e.name.clone(), e.version.clone())) { out.push(e); } } } out } /// Extract CODESYS library + runtime components from a `.projectarchive` (a zip). /// Best-effort: returns empty if the file is not a readable zip (e.g. a bare /// `.st`/`.xml` project, which carries no library manifest). pub fn projectarchive_sbom(archive: &Path, repo_id: &str) -> Vec { let Ok(file) = std::fs::File::open(archive) else { return Vec::new(); }; let Ok(mut zip) = zip::ZipArchive::new(file) else { return Vec::new(); }; let mut seen: BTreeSet<(String, String)> = BTreeSet::new(); let mut entries = Vec::new(); for i in 0..zip.len() { let Ok(entry) = zip.by_index(i) else { continue; }; // Entry paths use `\` (Windows-authored) and/or `/` separators; the // component id is one path segment. for seg in entry.name().split(['/', '\\']) { if let Some((name, version)) = parse_library(seg).or_else(|| parse_runtime(seg)) { if seen.insert((name.clone(), version.clone())) { let mut e = SbomEntry::new( repo_id.to_string(), name.clone(), version.clone(), "codesys".to_string(), ); e.purl = Some(format!( "pkg:codesys/{}@{version}", name.replace(' ', "%20") )); entries.push(e); } } } } entries } /// `Name, X.Y.Z.W (Company)` → (name, version). fn parse_library(seg: &str) -> Option<(String, String)> { let seg = seg.trim(); // Company is the trailing "(…)". let open = seg.rfind(" (")?; let rest = &seg[open + 2..]; let close = rest.find(')')?; if rest[..close].trim().is_empty() { return None; } let head = seg[..open].trim(); // "Name, X.Y.Z.W" let comma = head.rfind(", ")?; let name = head[..comma].trim().to_string(); let version = head[comma + 2..].trim().to_string(); if name.is_empty() || !is_dotted_version(&version) { return None; } Some((name, version)) } /// Device-descriptor entry `CODESYS Control … X.Y.Z.W …` → (runtime name, version). fn parse_runtime(seg: &str) -> Option<(String, String)> { let seg = seg.trim(); if !seg.starts_with("CODESYS Control") { return None; } let version = seg .split_whitespace() .find(|t| is_dotted_version(t))? .to_string(); // The runtime name is the first field, before the run of padding spaces that // precede the descriptor's numeric columns. let name = seg.split(" ").next().unwrap_or(seg).trim().to_string(); if name.is_empty() { return None; } Some((name, version)) } /// A dotted numeric version with at least 3 components (`3.5.18.0`, `4.17.0.0`). fn is_dotted_version(s: &str) -> bool { let parts: Vec<&str> = s.split('.').collect(); parts.len() >= 3 && parts .iter() .all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit())) } #[cfg(test)] mod tests { use super::*; use std::collections::HashMap; use std::io::Write; /// Build a synthetic `.projectarchive` (zip) mirroring the real CODESYS entry /// naming (verified against Proemion/codesys-examples): a native `.project`, /// referenced libraries as `Name, Version (Company)` segments, and a runtime /// device descriptor. fn synthetic_archive(dir: &Path) -> std::path::PathBuf { let path = dir.join("App.projectarchive"); write_synthetic_archive(&path); path } fn write_synthetic_archive(path: &Path) { let file = std::fs::File::create(path).expect("create"); let mut zip = zip::ZipWriter::new(file); let opts: zip::write::SimpleFileOptions = Default::default(); let names = [ "App.project", r"{b0b5}\App.Device.Plc.compileinfo", r"{e179}\Standard, 3.5.18.0 (System) standard.compiled-library-v3", r"{e179}\Util, 3.5.21.0 (System) util.compiled-library-v3", r"{e179}\CSV Utility SL, 1.9.0.0 (CODESYS) csv utility sl.compiled-library-v3", r"{e179}\3SLicense, 3.5.20.0 (CODESYS) 3slicense.compiled-library-v3", r"{0c63}\CODESYS Control for Linux ARM SL 0000 0006 4.17.0.0 4096 .zip", ]; for n in names { zip.start_file(n, opts).expect("start"); zip.write_all(b"x").expect("write"); } zip.finish().expect("finish"); } #[test] fn extracts_libraries_and_runtime_from_projectarchive() { let tmp = std::env::temp_dir().join(format!("cs-plc-sbom-{}", uuid::Uuid::new_v4())); std::fs::create_dir_all(&tmp).expect("mkdir"); let archive = synthetic_archive(&tmp); let entries = projectarchive_sbom(&archive, "plc-target"); let by_name: HashMap<&str, &SbomEntry> = entries.iter().map(|e| (e.name.as_str(), e)).collect(); // Libraries with their versions. assert_eq!( by_name.get("Standard").map(|e| e.version.as_str()), Some("3.5.18.0") ); assert_eq!( by_name.get("Util").map(|e| e.version.as_str()), Some("3.5.21.0") ); assert_eq!( by_name.get("CSV Utility SL").map(|e| e.version.as_str()), Some("1.9.0.0"), "multi-word library names must parse" ); assert!(by_name.contains_key("3SLicense")); // The runtime, from the device descriptor. assert_eq!( by_name .get("CODESYS Control for Linux ARM SL") .map(|e| e.version.as_str()), Some("4.17.0.0") ); // Every component is CODESYS-tagged with a purl the CVE pipeline can match, // and the native `.project` / compileinfo are not mistaken for components. for e in &entries { assert_eq!(e.package_manager, "codesys"); assert!(e.purl.as_deref().unwrap_or("").starts_with("pkg:codesys/")); } assert!(!by_name.contains_key("App")); let _ = std::fs::remove_dir_all(&tmp); } #[test] fn collect_sbom_finds_a_projectarchive_committed_in_a_git_tree() { let tmp = std::env::temp_dir().join(format!("cs-plc-collect-{}", uuid::Uuid::new_v4())); let src = tmp.join("clone/src"); std::fs::create_dir_all(&src).expect("mkdir"); // Simulate a git clone that commits the archive alongside its exports. write_synthetic_archive(&src.join("PumpStation.projectarchive")); // The artifact "file" is a git URL (not a real file), so the SBOM must // come from walking the cloned tree. let entries = collect_sbom(Path::new("https://git.example/plc.git"), &tmp, "t"); let names: std::collections::HashSet<&str> = entries.iter().map(|e| e.name.as_str()).collect(); assert!( names.contains("Standard"), "found libs in the committed archive" ); assert!(names.contains("CODESYS Control for Linux ARM SL")); let _ = std::fs::remove_dir_all(&tmp); } #[test] fn non_zip_file_yields_no_sbom() { let tmp = std::env::temp_dir().join(format!("cs-plc-sbom-st-{}", uuid::Uuid::new_v4())); std::fs::create_dir_all(&tmp).expect("mkdir"); let st = tmp.join("prog.st"); std::fs::write(&st, "PROGRAM P\nVAR x : INT; END_VAR\nEND_PROGRAM\n").expect("write"); assert!(projectarchive_sbom(&st, "t").is_empty()); let _ = std::fs::remove_dir_all(&tmp); } }