fix(ingest): materialize single-file PLC uploads so the scanner finds them
CI / Check (pull_request) Successful in 6m42s
CI / Detect Changes (pull_request) Has been skipped
CI / Deploy Agent (pull_request) Has been skipped
CI / Deploy Dashboard (pull_request) Has been skipped
CI / Deploy Docs (pull_request) Has been skipped
CI / Deploy MCP (pull_request) Has been skipped

A PlcProject uploaded as a single .st/.xml file (not a zip) was stored as a
content-addressed blob with no extension, so the PLC scanner's extension-based
file discovery skipped it — an uploaded project produced zero findings.

When an "extractable" blob turns out not to be an archive, materialize it into
the artifact work dir under its original file name (from source_ref, which the
upload handler sets to the uploaded filename). analyze_tree then discovers it by
extension and reports a readable path. Falls back to the raw blob if the copy
fails. Covers the demo's upload -> scan path end to end.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Sharang Parnerkar
2026-07-16 11:33:56 +02:00
co-authored by Claude Opus 4.8
parent bd80ccef49
commit c8ab5177bb
+85 -3
View File
@@ -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<PathBuf, AgentError> {
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"
);
}
}