diff --git a/compliance-agent/src/ingest/mod.rs b/compliance-agent/src/ingest/mod.rs index 0a0a63e..c1b2009 100644 --- a/compliance-agent/src/ingest/mod.rs +++ b/compliance-agent/src/ingest/mod.rs @@ -162,14 +162,27 @@ fn ingest_blob( match blob::extract_zip(&stored, &dest) { Ok(()) => dest, Err(e) => { - // Not a zip (e.g. a tar.gz source archive) — keep the blob and - // note it so later stages can decide what to do. + // Not a zip container — this is a single uploaded file (e.g. a + // `.st`/`.xml` PLC project or a `.tar.gz`). The content-addressed + // blob has no extension, so materialize it into a working dir + // under its original name; extension-based scanners (PLC) can then + // discover it and report a readable path. facts.push(DetectedFact::new( "archive_unextracted", e.to_string(), "ingest", )); - stored.clone() + match materialize_single(&stored, &dest, &blob_file_name(artifact)) { + Ok(dir) => dir, + Err(copy_err) => { + facts.push(DetectedFact::new( + "materialize_failed", + copy_err.to_string(), + "ingest", + )); + stored.clone() + } + } } } } else { @@ -186,6 +199,27 @@ fn ingest_blob( }) } +/// Copy a stored blob into `dest`/`name`, returning `dest`. Used when an +/// "extractable" artifact turns out to be a single file rather than an archive. +fn materialize_single(stored: &Path, dest: &Path, name: &str) -> Result { + std::fs::create_dir_all(dest)?; + std::fs::copy(stored, dest.join(name))?; + Ok(dest.to_path_buf()) +} + +/// A safe, single-segment file name for an artifact, preserving the original +/// extension so scanners can identify it. Derives from `source_ref` (the +/// uploaded/original file name); `file_name` strips any directory components, +/// so this is traversal-safe. Falls back to the artifact id. +fn blob_file_name(artifact: &Artifact) -> String { + Path::new(&artifact.source_ref) + .file_name() + .and_then(|n| n.to_str()) + .map(str::to_string) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| format!("artifact-{}", artifact.id)) +} + /// An artifact with no on-disk form: record a single fact, no hash/path. fn metadata_only(artifact: &Artifact, fact: DetectedFact) -> IngestedArtifact { IngestedArtifact { @@ -331,4 +365,52 @@ mod tests { assert_eq!(creds.ssh_key_path.as_deref(), Some("/default/ssh/key")); assert!(creds.auth_token.is_none()); } + + /// A single uploaded PLC file (not an archive) must land in a working dir + /// under its original name so the PLC scanner can discover it by extension + /// and report a readable path — the demo's upload → scan path. + #[test] + fn single_uploaded_plc_file_is_materialized_and_scannable() { + use compliance_core::models::PlcFormat; + + let scratch = Scratch::new(); + let store = scratch.0.join("store"); + // Simulate the upload handler: bytes written to an `uploads/` path, + // `source_ref` carrying the original (clean) file name. + let uploads = scratch.0.join("uploads"); + std::fs::create_dir_all(&uploads).expect("mkdir uploads"); + let uploaded = uploads.join("a1b2c3_pump_station.st"); + std::fs::write( + &uploaded, + "PROGRAM P\nVAR\n ApiKey : STRING := 'sk-live-1234';\nEND_VAR\nEND_PROGRAM\n", + ) + .expect("write st"); + + let mut artifact = Artifact::plc_project("pump_station.st", PlcFormat::StructuredText); + artifact.stored_path = Some(uploaded.to_string_lossy().to_string()); + + let ctx = ctx_for(&store, "t-plc"); + let out = ingest_artifact(&artifact, &ctx).expect("ingest"); + + // Working path is a directory (not the extensionless blob) holding the + // file under its original name. + let wp = out.working_path.expect("working path"); + assert!(wp.is_dir(), "expected a working dir, got {wp:?}"); + assert!(wp.join("pump_station.st").is_file()); + + // The PLC scanner finds the hardcoded credential and reports a clean path. + let findings = crate::pipeline::plc::analyze_tree(&wp, "t-plc"); + assert!( + !findings.is_empty(), + "scanner should flag the uploaded file" + ); + assert!(findings + .iter() + .any(|f| f.rule_id.as_deref() == Some("plc-hardcoded-credential"))); + assert_eq!( + findings[0].file_path.as_deref(), + Some("pump_station.st"), + "finding should reference the original file name" + ); + } }