feat(onboarding): PLC/blob file upload (multipart) + single-file ingest fix #163
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user