1use std::collections::BTreeMap;
36
37use chrono::{DateTime, Utc};
38use serde::{Deserialize, Serialize};
39
40use tatara_process::export::{
41 ArtifactVariant, ExportSpec, NatsSubjectChannel, ReportFormat, ReportPayloadShape,
42 RunMarkerSource,
43};
44use tatara_process::receipt::ReceiptEnvelope;
45
46pub fn resolve_run_id(spec: &ExportSpec, namespace: &str, name: &str) -> String {
58 if let Some(o) = &spec.experiment_id_override {
59 if !o.is_empty() {
60 return o.clone();
61 }
62 }
63 format!("{namespace}/{name}")
64}
65
66pub fn resolve_subject(channel: &NatsSubjectChannel, run_id: &str) -> String {
72 channel.subject.replace("{{run_id}}", run_id)
73}
74
75#[derive(Clone, Debug, Serialize, Deserialize)]
80pub struct ExportEvent {
81 pub signal_type: String,
86
87 pub run_id: String,
90
91 pub timestamp: DateTime<Utc>,
93
94 pub labels: BTreeMap<String, String>,
98
99 pub payload: serde_json::Value,
104
105 #[serde(skip_serializing_if = "Option::is_none")]
109 pub format: Option<ReportFormat>,
110}
111
112pub fn prepare_event_payload(
128 source: ArtifactVariant<'_>,
129 artifact_bytes: &[u8],
130 run_id: &str,
131 signal_type: &str,
132 now: DateTime<Utc>,
133) -> ExportEvent {
134 let mut labels = BTreeMap::new();
135 labels.insert("run_id".into(), run_id.to_string());
136
137 let (payload, format) = match source {
138 ArtifactVariant::Receipts(_) => {
139 let parsed: serde_json::Value =
141 serde_json::from_slice(artifact_bytes).unwrap_or(serde_json::Value::Array(vec![]));
142 (serde_json::json!({ "receipts": parsed }), None)
143 }
144 ArtifactVariant::TestReport(tr) => {
145 labels.insert("configmap".into(), tr.configmap.clone());
146 labels.insert("key".into(), tr.key.clone());
147 let p = match tr.format.payload_shape() {
154 ReportPayloadShape::NdJsonLines => {
155 let lines: Vec<serde_json::Value> = artifact_bytes
156 .split(|b| *b == b'\n')
157 .filter(|l| !l.is_empty())
158 .filter_map(|l| serde_json::from_slice(l).ok())
159 .collect();
160 serde_json::json!({ "ndjson": lines })
161 }
162 ReportPayloadShape::OpaqueBytes => {
163 use base64_inline as base64;
164 serde_json::json!({ "raw_b64": base64::encode(artifact_bytes) })
165 }
166 };
167 (p, Some(tr.format))
168 }
169 ArtifactVariant::ProcessSnapshot(_) => {
170 let parsed: serde_json::Value =
171 serde_json::from_slice(artifact_bytes).unwrap_or(serde_json::Value::Null);
172 (serde_json::json!({ "snapshot": parsed }), None)
173 }
174 ArtifactVariant::RunMarker(rm) => {
175 merge_labels(&mut labels, &rm.labels);
176 (serde_json::Value::Null, None)
177 }
178 };
179
180 ExportEvent {
181 signal_type: signal_type.to_string(),
182 run_id: run_id.to_string(),
183 timestamp: now,
184 labels,
185 payload,
186 format,
187 }
188}
189
190fn merge_labels(into: &mut BTreeMap<String, String>, from: &BTreeMap<String, String>) {
191 for (k, v) in from {
192 into.insert(k.clone(), v.clone());
193 }
194}
195
196pub fn run_marker_event(
200 rm: &RunMarkerSource,
201 run_id: &str,
202 signal_type: &str,
203 now: DateTime<Utc>,
204) -> ExportEvent {
205 prepare_event_payload(
206 ArtifactVariant::RunMarker(rm),
207 &[],
208 run_id,
209 signal_type,
210 now,
211 )
212}
213
214#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
219#[serde(rename_all = "camelCase")]
220pub enum ExportOutcome {
221 Shipped,
224 Rejected(String),
228 Failed(String),
231}
232
233impl ExportOutcome {
234 pub fn kind(&self) -> &'static str {
236 match self {
237 Self::Shipped => "Shipped",
238 Self::Rejected(_) => "Rejected",
239 Self::Failed(_) => "Failed",
240 }
241 }
242
243 pub fn is_shipped(&self) -> bool {
245 matches!(self, Self::Shipped)
246 }
247}
248
249pub fn compose_export_receipt(
267 spec: &ExportSpec,
268 shipped_event_bytes: &[u8],
269 outcome: &ExportOutcome,
270 previous_root: Option<&str>,
271 run_id: &str,
272 process_ref: Option<&str>,
273) -> anyhow::Result<ReceiptEnvelope> {
274 let intent_hash = hex_blake3(&canonical_json(spec)?);
275 let artifact_hash = hex_blake3(shipped_event_bytes);
276 let control_hash = hex_blake3(&canonical_json(outcome)?);
277
278 let mut env = ReceiptEnvelope::build(
279 "tatara.export",
280 intent_hash,
281 artifact_hash,
282 control_hash,
283 previous_root,
284 );
285 env.process_ref = process_ref.map(String::from);
286
287 let mut evidence = serde_json::Map::new();
288 evidence.insert(
289 "run_id".into(),
290 serde_json::Value::String(run_id.to_string()),
291 );
292 evidence.insert(
293 "outcome".into(),
294 serde_json::Value::String(outcome.kind().to_string()),
295 );
296 if let ExportOutcome::Rejected(m) | ExportOutcome::Failed(m) = outcome {
297 evidence.insert("error".into(), serde_json::Value::String(m.clone()));
298 }
299 evidence.insert(
300 "shipped_bytes_len".into(),
301 serde_json::Value::Number(shipped_event_bytes.len().into()),
302 );
303 env.evidence = serde_json::Value::Object(evidence);
304
305 Ok(env)
306}
307
308fn canonical_json<T: Serialize>(value: &T) -> anyhow::Result<Vec<u8>> {
309 let v = serde_json::to_value(value)?;
314 Ok(serde_json::to_vec(&v)?)
315}
316
317fn hex_blake3(bytes: &[u8]) -> String {
318 blake3::hash(bytes).to_hex().to_string()
319}
320
321mod base64_inline {
324 const ALPHA: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
325 pub fn encode(input: &[u8]) -> String {
326 let mut out = String::with_capacity((input.len() + 2) / 3 * 4);
327 let mut chunks = input.chunks_exact(3);
328 for chunk in chunks.by_ref() {
329 let n = ((chunk[0] as u32) << 16) | ((chunk[1] as u32) << 8) | (chunk[2] as u32);
330 out.push(ALPHA[((n >> 18) & 0x3F) as usize] as char);
331 out.push(ALPHA[((n >> 12) & 0x3F) as usize] as char);
332 out.push(ALPHA[((n >> 6) & 0x3F) as usize] as char);
333 out.push(ALPHA[(n & 0x3F) as usize] as char);
334 }
335 let rem = chunks.remainder();
336 match rem.len() {
337 1 => {
338 let n = (rem[0] as u32) << 16;
339 out.push(ALPHA[((n >> 18) & 0x3F) as usize] as char);
340 out.push(ALPHA[((n >> 12) & 0x3F) as usize] as char);
341 out.push('=');
342 out.push('=');
343 }
344 2 => {
345 let n = ((rem[0] as u32) << 16) | ((rem[1] as u32) << 8);
346 out.push(ALPHA[((n >> 18) & 0x3F) as usize] as char);
347 out.push(ALPHA[((n >> 12) & 0x3F) as usize] as char);
348 out.push(ALPHA[((n >> 6) & 0x3F) as usize] as char);
349 out.push('=');
350 }
351 _ => {}
352 }
353 out
354 }
355}
356
357#[cfg(test)]
360mod tests {
361 use super::*;
362 use tatara_process::export::{
363 ArtifactSource, HttpEventChannel, ProcessSnapshotSource, ReceiptsSource, RunMarkerSource,
364 TestReportSource, VectorChannel,
365 };
366
367 fn http_spec(signal_type: &str) -> ExportSpec {
368 ExportSpec {
369 source: ArtifactSource {
370 run_marker: Some(RunMarkerSource::default()),
371 ..ArtifactSource::default()
372 },
373 channel: VectorChannel {
374 http_event: Some(HttpEventChannel {
375 endpoint: None,
376 signal_type: signal_type.to_string(),
377 }),
378 ..VectorChannel::default()
379 },
380 when: Default::default(),
381 experiment_id_override: None,
382 }
383 }
384
385 #[test]
386 fn run_id_falls_back_to_ns_slash_name() {
387 let s = http_spec("x");
388 assert_eq!(resolve_run_id(&s, "akeyless-test", "r1"), "akeyless-test/r1");
389 }
390
391 #[test]
392 fn run_id_uses_override_when_set() {
393 let mut s = http_spec("x");
394 s.experiment_id_override = Some("akeyless-run-2026-05-20".into());
395 assert_eq!(resolve_run_id(&s, "ns", "n"), "akeyless-run-2026-05-20");
396 }
397
398 #[test]
399 fn run_id_ignores_empty_override() {
400 let mut s = http_spec("x");
401 s.experiment_id_override = Some(String::new());
402 assert_eq!(resolve_run_id(&s, "ns", "n"), "ns/n");
403 }
404
405 #[test]
406 fn subject_substitutes_run_id_template() {
407 let ch = NatsSubjectChannel {
408 subject: "pleme.pleme-dev.ephemeral.{{run_id}}.receipt".into(),
409 stream: "EPHEMERAL_RECEIPTS".into(),
410 url: None,
411 };
412 assert_eq!(
413 resolve_subject(&ch, "ns/n"),
414 "pleme.pleme-dev.ephemeral.ns/n.receipt"
415 );
416 }
417
418 #[test]
419 fn subject_passthrough_when_no_template() {
420 let ch = NatsSubjectChannel {
421 subject: "pleme.fixed.subject".into(),
422 stream: "S".into(),
423 url: None,
424 };
425 assert_eq!(resolve_subject(&ch, "ignored"), "pleme.fixed.subject");
426 }
427
428 #[test]
429 fn run_marker_event_has_labels_and_run_id() {
430 let mut labels = BTreeMap::new();
431 labels.insert("phase".into(), "end".into());
432 let rm = RunMarkerSource { labels };
433 let now = chrono::Utc::now();
434 let ev = run_marker_event(&rm, "ns/n", "ephemeral-marker", now);
435 assert_eq!(ev.signal_type, "ephemeral-marker");
436 assert_eq!(ev.run_id, "ns/n");
437 assert_eq!(ev.labels["phase"], "end");
438 assert_eq!(ev.labels["run_id"], "ns/n");
439 assert_eq!(ev.payload, serde_json::Value::Null);
440 }
441
442 #[test]
443 fn test_report_ndjson_parses_into_array() {
444 let tr = TestReportSource {
445 configmap: "cm".into(),
446 key: "out.ndjson".into(),
447 format: ReportFormat::NdJson,
448 namespace: None,
449 };
450 let bytes = b"{\"a\":1}\n{\"b\":2}\n\n{\"c\":3}\n";
451 let now = chrono::Utc::now();
452 let ev = prepare_event_payload(
453 ArtifactVariant::TestReport(&tr),
454 bytes,
455 "ns/n",
456 "test-report",
457 now,
458 );
459 let arr = ev.payload["ndjson"].as_array().unwrap();
460 assert_eq!(arr.len(), 3);
461 assert_eq!(arr[0]["a"], 1);
462 assert_eq!(arr[2]["c"], 3);
463 assert_eq!(ev.labels["configmap"], "cm");
464 assert_eq!(ev.format, Some(ReportFormat::NdJson));
465 }
466
467 #[test]
468 fn test_report_raw_format_base64_encodes() {
469 let tr = TestReportSource {
470 configmap: "cm".into(),
471 key: "report.bin".into(),
472 format: ReportFormat::Raw,
473 namespace: None,
474 };
475 let bytes = b"<<binary>>";
476 let now = chrono::Utc::now();
477 let ev = prepare_event_payload(
478 ArtifactVariant::TestReport(&tr),
479 bytes,
480 "ns/n",
481 "test-report",
482 now,
483 );
484 let b64 = ev.payload["raw_b64"].as_str().unwrap();
485 assert_eq!(b64.len(), ((bytes.len() + 2) / 3) * 4);
487 assert_eq!(ev.format, Some(ReportFormat::Raw));
488 }
489
490 #[test]
491 fn receipts_source_embeds_parsed_json() {
492 let r = ReceiptsSource::default();
493 let raw = serde_json::to_vec(&serde_json::json!([
494 { "kind": "tatara.processed.run", "composed_root": "abc" },
495 { "kind": "tatara.processed.run", "composed_root": "def" },
496 ]))
497 .unwrap();
498 let now = chrono::Utc::now();
499 let ev = prepare_event_payload(
500 ArtifactVariant::Receipts(&r),
501 &raw,
502 "ns/n",
503 "receipt",
504 now,
505 );
506 let arr = ev.payload["receipts"].as_array().unwrap();
507 assert_eq!(arr.len(), 2);
508 assert_eq!(arr[1]["composed_root"], "def");
509 }
510
511 #[test]
512 fn process_snapshot_embeds_parsed_json() {
513 let p = ProcessSnapshotSource::default();
514 let raw = serde_json::to_vec(&serde_json::json!({ "phase": "Attested" })).unwrap();
515 let now = chrono::Utc::now();
516 let ev = prepare_event_payload(
517 ArtifactVariant::ProcessSnapshot(&p),
518 &raw,
519 "ns/n",
520 "process-snapshot",
521 now,
522 );
523 assert_eq!(ev.payload["snapshot"]["phase"], "Attested");
524 }
525
526 #[test]
529 fn outcome_kind_is_stable() {
530 assert_eq!(ExportOutcome::Shipped.kind(), "Shipped");
531 assert_eq!(ExportOutcome::Rejected("x".into()).kind(), "Rejected");
532 assert_eq!(ExportOutcome::Failed("y".into()).kind(), "Failed");
533 }
534
535 #[test]
536 fn export_receipt_chains_three_pillars() {
537 use tatara_process::receipt::RECEIPT_VERSION;
538 let s = http_spec("test-report");
539 let event_bytes = b"{\"signalType\":\"test-report\"}";
540 let r = compose_export_receipt(
541 &s,
542 event_bytes,
543 &ExportOutcome::Shipped,
544 None,
545 "ns/n",
546 Some("akeyless-test/r1"),
547 )
548 .expect("receipt");
549 assert_eq!(r.version, RECEIPT_VERSION);
550 assert_eq!(r.kind, "tatara.export");
551 assert_eq!(r.intent_hash.len(), 64);
553 assert_eq!(r.artifact_hash.len(), 64);
554 assert_eq!(r.control_hash.len(), 64);
555 assert_eq!(r.composed_root.len(), 64);
556 assert_eq!(r.process_ref.as_deref(), Some("akeyless-test/r1"));
558 assert_eq!(r.evidence["run_id"], "ns/n");
559 assert_eq!(r.evidence["outcome"], "Shipped");
560 assert!(r.verify_root(None));
563 }
564
565 #[test]
566 fn export_receipt_chains_prev_root() {
567 let s = http_spec("test-report");
568 let ev = b"x";
569 let r1 =
570 compose_export_receipt(&s, ev, &ExportOutcome::Shipped, None, "r", None).unwrap();
571 let r2 = compose_export_receipt(
572 &s,
573 ev,
574 &ExportOutcome::Shipped,
575 Some(&r1.composed_root),
576 "r",
577 None,
578 )
579 .unwrap();
580 assert_ne!(r1.composed_root, r2.composed_root);
582 assert!(r2.verify_root(Some(&r1.composed_root)));
584 }
585
586 #[test]
587 fn export_receipt_failure_carries_error_text() {
588 let s = http_spec("x");
589 let r = compose_export_receipt(
590 &s,
591 b"",
592 &ExportOutcome::Failed("connection refused".into()),
593 None,
594 "ns/n",
595 None,
596 )
597 .unwrap();
598 assert_eq!(r.evidence["error"], "connection refused");
599 assert_eq!(r.evidence["outcome"], "Failed");
600 }
601
602 #[test]
603 fn export_receipt_intent_hash_changes_with_spec() {
604 let s1 = http_spec("a");
605 let s2 = http_spec("b"); let r1 =
607 compose_export_receipt(&s1, b"", &ExportOutcome::Shipped, None, "ns/n", None).unwrap();
608 let r2 =
609 compose_export_receipt(&s2, b"", &ExportOutcome::Shipped, None, "ns/n", None).unwrap();
610 assert_ne!(r1.intent_hash, r2.intent_hash);
611 }
612
613 #[test]
614 fn export_receipt_artifact_hash_changes_with_payload() {
615 let s = http_spec("x");
616 let r1 =
617 compose_export_receipt(&s, b"payload-1", &ExportOutcome::Shipped, None, "ns/n", None)
618 .unwrap();
619 let r2 =
620 compose_export_receipt(&s, b"payload-2", &ExportOutcome::Shipped, None, "ns/n", None)
621 .unwrap();
622 assert_ne!(r1.artifact_hash, r2.artifact_hash);
623 }
624}