1use std::collections::BTreeMap;
23
24use chrono::{DateTime, Utc};
25use khive_storage::{Edge, Entity, LinkId, Note};
26use khive_types::EdgeRelation;
27use serde_json::{json, Map, Value};
28use uuid::Uuid;
29
30use crate::error::CodeIngestError;
31use crate::vocab::{is_valid_confidence, is_valid_severity};
32
33pub const CODE_INGEST_NAMESPACE: Uuid = Uuid::from_u128(0x288fe3dc_ac69_5aef_ab1e_9b170fb07376);
36
37#[derive(Clone, Debug)]
39pub struct CodeIngestOptions<'a> {
40 pub namespace: &'a str,
42 pub observed_at: DateTime<Utc>,
46 pub source_run: Option<&'a str>,
48}
49
50#[derive(Clone, Debug)]
53pub struct CodeIngestBatch {
54 pub entities: Vec<Entity>,
55 pub notes: Vec<Note>,
56 pub edges: Vec<Edge>,
57}
58
59fn uuid5_tuple<T: serde::Serialize>(parts: &T) -> Result<Uuid, CodeIngestError> {
60 let bytes = serde_json::to_vec(parts)?;
61 Ok(Uuid::new_v5(&CODE_INGEST_NAMESPACE, &bytes))
62}
63
64fn sort_json_keys(value: &Value) -> Value {
68 match value {
69 Value::Object(map) => {
70 let sorted: BTreeMap<&str, Value> = map
71 .iter()
72 .map(|(k, v)| (k.as_str(), sort_json_keys(v)))
73 .collect();
74 let mut out = Map::with_capacity(sorted.len());
75 for (k, v) in sorted {
76 out.insert(k.to_string(), v);
77 }
78 Value::Object(out)
79 }
80 Value::Array(arr) => Value::Array(arr.iter().map(sort_json_keys).collect()),
81 other => other.clone(),
82 }
83}
84
85fn value_to_display(value: Option<&Value>) -> String {
89 match value {
90 None | Some(Value::Null) => String::new(),
91 Some(Value::String(s)) => s.clone(),
92 Some(other) => other.to_string(),
93 }
94}
95
96fn tolerated_field(obj: &Map<String, Value>, key: &str) -> Option<Value> {
99 match obj.get(key) {
100 None | Some(Value::Null) => None,
101 Some(v) => Some(v.clone()),
102 }
103}
104
105fn map_producer_status_to_kind_status(audit_status: Option<&Value>) -> &'static str {
110 match audit_status.and_then(Value::as_str) {
111 Some("fixed") => "resolved",
112 Some("false_positive") => "invalid",
113 _ => "open",
114 }
115}
116
117fn require_str<'a>(
118 obj: &'a Map<String, Value>,
119 key: &'static str,
120 path: &'static str,
121) -> Result<&'a str, CodeIngestError> {
122 match obj.get(key) {
123 None => Err(CodeIngestError::MissingField { path }),
124 Some(Value::String(s)) => Ok(s.as_str()),
125 Some(_) => Err(CodeIngestError::InvalidType {
126 path: path.to_string(),
127 expected: "string",
128 }),
129 }
130}
131
132fn optional_str(
133 obj: &Map<String, Value>,
134 key: &'static str,
135 path: &'static str,
136) -> Result<Option<String>, CodeIngestError> {
137 match obj.get(key) {
138 None | Some(Value::Null) => Ok(None),
139 Some(Value::String(s)) => Ok(Some(s.clone())),
140 Some(_) => Err(CodeIngestError::InvalidType {
141 path: path.to_string(),
142 expected: "string",
143 }),
144 }
145}
146
147fn normalize_title(title: &str) -> Result<String, CodeIngestError> {
148 let normalized = title
149 .split_whitespace()
150 .collect::<Vec<_>>()
151 .join(" ")
152 .to_ascii_lowercase();
153 if normalized.is_empty() {
154 return Err(CodeIngestError::InvalidType {
155 path: "findings[].title".to_string(),
156 expected: "non-empty string",
157 });
158 }
159 Ok(normalized)
160}
161
162fn canonicalize_evidence(
166 value: Option<&Value>,
167 finding_index: usize,
168) -> Result<Vec<Value>, CodeIngestError> {
169 let items: Vec<Value> = match value {
170 None | Some(Value::Null) => Vec::new(),
171 Some(Value::String(s)) => vec![Value::String(s.clone())],
172 Some(Value::Object(m)) => vec![Value::Object(m.clone())],
173 Some(Value::Array(arr)) => arr.clone(),
174 Some(_) => {
175 return Err(CodeIngestError::InvalidEvidence {
176 finding_index,
177 evidence_index: 0,
178 });
179 }
180 };
181
182 let mut out = Vec::with_capacity(items.len());
183 for (evidence_index, item) in items.into_iter().enumerate() {
184 let canonical = match item {
185 Value::String(s) => {
186 if s.trim().is_empty() {
187 return Err(CodeIngestError::InvalidEvidence {
188 finding_index,
189 evidence_index,
190 });
191 }
192 json!({ "description": s })
193 }
194 Value::Object(m) => Value::Object(m),
195 _ => {
196 return Err(CodeIngestError::InvalidEvidence {
197 finding_index,
198 evidence_index,
199 });
200 }
201 };
202 out.push(canonical);
203 }
204 Ok(out)
205}
206
207struct ValidatedAudit {
208 date: String,
209 scope: String,
210 repo: String,
211 branch: String,
212 commit: String,
213 standards_file: String,
214 extra: Map<String, Value>,
215}
216
217const KNOWN_AUDIT_KEYS: &[&str] = &[
218 "date",
219 "scope",
220 "repo",
221 "branch",
222 "commit",
223 "standards_file",
224];
225
226impl ValidatedAudit {
227 fn parse(obj: &Map<String, Value>) -> Result<Self, CodeIngestError> {
228 let mut extra = Map::new();
229 for (k, v) in obj {
230 if !KNOWN_AUDIT_KEYS.contains(&k.as_str()) {
231 extra.insert(k.clone(), v.clone());
232 }
233 }
234 Ok(Self {
235 date: require_str(obj, "date", "audit.date")?.to_string(),
236 scope: require_str(obj, "scope", "audit.scope")?.to_string(),
237 repo: require_str(obj, "repo", "audit.repo")?.to_string(),
238 branch: require_str(obj, "branch", "audit.branch")?.to_string(),
239 commit: require_str(obj, "commit", "audit.commit")?.to_string(),
240 standards_file: require_str(obj, "standards_file", "audit.standards_file")?.to_string(),
241 extra,
242 })
243 }
244}
245
246struct ValidatedFinding {
247 id: String,
248 title: String,
249 normalized_title: String,
250 severity: String,
251 confidence: String,
252 categories: Option<Value>,
253 standard: Option<Value>,
254 evidence: Vec<Value>,
255 impact: Option<Value>,
256 recommendation: Option<Value>,
257 verification: Option<Value>,
258 failure_scenario: Option<String>,
259 priority: Option<Value>,
260 audit_status: Option<Value>,
261 refs: Option<Value>,
262 raw: Map<String, Value>,
263}
264
265const KNOWN_FINDING_KEYS: &[&str] = &[
266 "id",
267 "title",
268 "severity",
269 "confidence",
270 "categories",
271 "status",
272 "standard",
273 "evidence",
274 "impact",
275 "recommendation",
276 "verification",
277 "failure_scenario",
278 "priority",
279 "refs",
280];
281
282impl ValidatedFinding {
283 fn parse(obj: &Map<String, Value>, finding_index: usize) -> Result<Self, CodeIngestError> {
284 let id = require_str(obj, "id", "findings[].id")?.to_string();
285 let title = require_str(obj, "title", "findings[].title")?.to_string();
286 if title.trim().is_empty() {
287 return Err(CodeIngestError::InvalidType {
288 path: "findings[].title".to_string(),
289 expected: "non-empty string",
290 });
291 }
292 let normalized_title = normalize_title(&title)?;
293
294 let severity = require_str(obj, "severity", "findings[].severity")?.to_string();
295 if !is_valid_severity(&severity) {
296 return Err(CodeIngestError::InvalidValue {
297 field: "severity",
298 value: severity,
299 valid: "critical | high | medium | low | info",
300 });
301 }
302
303 let confidence = require_str(obj, "confidence", "findings[].confidence")?.to_string();
304 if !is_valid_confidence(&confidence) {
305 return Err(CodeIngestError::InvalidValue {
306 field: "confidence",
307 value: confidence,
308 valid: "high | medium | low",
309 });
310 }
311
312 let categories = tolerated_field(obj, "categories");
319 let standard = tolerated_field(obj, "standard");
320 let evidence = canonicalize_evidence(obj.get("evidence"), finding_index)?;
321 let impact = tolerated_field(obj, "impact");
322 let recommendation = tolerated_field(obj, "recommendation");
323 let verification = tolerated_field(obj, "verification");
324 let failure_scenario =
325 optional_str(obj, "failure_scenario", "findings[].failure_scenario")?;
326 let audit_status = tolerated_field(obj, "status");
327 let priority = tolerated_field(obj, "priority");
328 let refs = tolerated_field(obj, "refs");
329
330 if matches!(severity.as_str(), "medium" | "high" | "critical") {
331 let has_scenario = failure_scenario
332 .as_deref()
333 .is_some_and(|s| !s.trim().is_empty());
334 if !has_scenario {
335 return Err(CodeIngestError::MissingFailureScenario {
336 id: id.clone(),
337 severity: severity.clone(),
338 });
339 }
340 }
341
342 let mut raw = Map::new();
343 for (k, v) in obj {
344 if !KNOWN_FINDING_KEYS.contains(&k.as_str()) {
345 raw.insert(k.clone(), v.clone());
346 }
347 }
348
349 Ok(Self {
350 id,
351 title,
352 normalized_title,
353 severity,
354 confidence,
355 categories,
356 standard,
357 evidence,
358 impact,
359 recommendation,
360 verification,
361 failure_scenario,
362 priority,
363 audit_status,
364 refs,
365 raw,
366 })
367 }
368}
369
370pub fn ingest_findings_json(
377 input: &[u8],
378 options: CodeIngestOptions<'_>,
379) -> Result<CodeIngestBatch, CodeIngestError> {
380 let root: Value = serde_json::from_slice(input)?;
381 let root_obj = root.as_object().ok_or(CodeIngestError::InvalidRoot)?;
382
383 let audit_obj = root_obj
384 .get("audit")
385 .and_then(Value::as_object)
386 .ok_or(CodeIngestError::InvalidRoot)?;
387 let findings_arr = root_obj
388 .get("findings")
389 .and_then(Value::as_array)
390 .ok_or(CodeIngestError::InvalidRoot)?;
391
392 let audit = ValidatedAudit::parse(audit_obj)?;
393
394 let source_run = match options.source_run.filter(|s| !s.trim().is_empty()) {
395 Some(explicit) => explicit.to_string(),
396 None => {
397 if audit.date.trim().is_empty() || audit.commit.trim().is_empty() {
398 return Err(CodeIngestError::MissingSourceRun);
399 }
400 format!("{}:{}", audit.date, audit.commit)
401 }
402 };
403
404 let mut validated_findings = Vec::with_capacity(findings_arr.len());
405 for (finding_index, raw) in findings_arr.iter().enumerate() {
406 let obj = raw
407 .as_object()
408 .ok_or_else(|| CodeIngestError::InvalidType {
409 path: format!("findings[{finding_index}]"),
410 expected: "object",
411 })?;
412 validated_findings.push(ValidatedFinding::parse(obj, finding_index)?);
413 }
414
415 let project_id = uuid5_tuple(&(
418 "code-project",
419 1u8,
420 options.namespace,
421 audit.repo.as_str(),
422 audit.scope.as_str(),
423 ))?;
424 let project_external_id = serde_json::to_string(&(
425 "code-project",
426 1u8,
427 options.namespace,
428 audit.repo.as_str(),
429 audit.scope.as_str(),
430 ))?;
431
432 let mut project_properties = Map::new();
433 project_properties.insert("repo".into(), json!(audit.repo));
434 project_properties.insert("branch".into(), json!(audit.branch));
435 project_properties.insert("commit".into(), json!(audit.commit));
436 project_properties.insert("date".into(), json!(audit.date));
437 project_properties.insert("standards_file".into(), json!(audit.standards_file));
438 project_properties.insert("source_run".into(), json!(source_run));
439 project_properties.insert("external_id".into(), json!(project_external_id));
440 if !audit.extra.is_empty() {
441 project_properties.insert("audit_extra".into(), Value::Object(audit.extra));
442 }
443
444 let observed_micros = options.observed_at.timestamp_micros();
445
446 let mut project_entity = Entity::new(options.namespace, "project", audit.scope.as_str());
447 project_entity.id = project_id;
448 project_entity.properties = Some(Value::Object(project_properties));
449 project_entity.created_at = observed_micros;
450 project_entity.updated_at = observed_micros;
451
452 let mut notes = Vec::with_capacity(validated_findings.len());
453 let mut edges = Vec::with_capacity(validated_findings.len());
454
455 for finding in &validated_findings {
456 let identity_value = sort_json_keys(&json!({
462 "kind": "code-finding",
463 "schema_version": 1,
464 "namespace": options.namespace,
465 "source_run": source_run,
466 "scope": audit.scope,
467 "id": finding.id,
468 "normalized_title": finding.normalized_title,
469 "severity": finding.severity,
470 "confidence": finding.confidence,
471 "categories": finding.categories,
472 "standard": finding.standard,
473 "evidence": finding.evidence,
474 "impact": finding.impact,
475 "recommendation": finding.recommendation,
476 "verification": finding.verification,
477 "failure_scenario": finding.failure_scenario,
478 "priority": finding.priority,
479 "audit_status": finding.audit_status,
480 "refs": finding.refs,
481 "raw": finding.raw,
482 }));
483 let finding_id = uuid5_tuple(&identity_value)?;
484 let finding_external_id = serde_json::to_string(&identity_value)?;
485
486 let mut props = Map::new();
487 props.insert("external_id".into(), json!(finding_external_id));
488 props.insert("finding_id".into(), json!(finding.id));
489 props.insert("severity".into(), json!(finding.severity));
490 props.insert("confidence".into(), json!(finding.confidence));
491 props.insert("source_run".into(), json!(source_run));
492 props.insert("evidence".into(), Value::Array(finding.evidence.clone()));
493 if let Some(categories) = &finding.categories {
494 props.insert("categories".into(), categories.clone());
495 }
496 if let Some(standard) = &finding.standard {
497 props.insert("standard".into(), standard.clone());
498 }
499 if let Some(refs) = &finding.refs {
500 props.insert("refs".into(), refs.clone());
501 }
502 if let Some(priority) = &finding.priority {
503 props.insert("priority".into(), priority.clone());
504 }
505 if let Some(status) = &finding.audit_status {
506 props.insert("audit_status".into(), status.clone());
507 }
508 if let Some(scenario) = &finding.failure_scenario {
509 props.insert("failure_scenario".into(), json!(scenario));
510 }
511 if let Some(impact) = &finding.impact {
512 props.insert("impact".into(), impact.clone());
513 }
514 if let Some(recommendation) = &finding.recommendation {
515 props.insert("recommendation".into(), recommendation.clone());
516 }
517 if let Some(verification) = &finding.verification {
518 props.insert("verification".into(), verification.clone());
519 }
520 props.insert(
521 "kind_status".into(),
522 json!(map_producer_status_to_kind_status(
523 finding.audit_status.as_ref()
524 )),
525 );
526 if !finding.raw.is_empty() {
527 props.insert("raw".into(), Value::Object(finding.raw.clone()));
528 }
529
530 let content = format!(
531 "{}: {}",
532 finding.title,
533 value_to_display(finding.impact.as_ref())
534 );
535 let mut note = Note::new(options.namespace, "finding", content);
536 note.id = finding_id;
537 note.name = Some(finding.title.clone());
538 note.properties = Some(Value::Object(props));
539 note.created_at = observed_micros;
540 note.updated_at = observed_micros;
541 notes.push(note);
542
543 let edge_id = uuid5_tuple(&(
544 "code-edge",
545 1u8,
546 options.namespace,
547 finding_id,
548 project_id,
549 EdgeRelation::Annotates.as_str(),
550 ))?;
551 edges.push(Edge {
552 id: LinkId::from(edge_id),
553 namespace: options.namespace.to_string(),
554 source_id: finding_id,
555 target_id: project_id,
556 relation: EdgeRelation::Annotates,
557 weight: 1.0,
558 created_at: options.observed_at,
559 updated_at: options.observed_at,
560 deleted_at: None,
561 metadata: None,
562 target_backend: None,
563 });
564 }
565
566 Ok(CodeIngestBatch {
567 entities: vec![project_entity],
568 notes,
569 edges,
570 })
571}