1use std::sync::{Arc, OnceLock};
23
24use crate::runtime::audit_log::{
25 AuditAuthSource, AuditEvent, AuditField, AuditFieldEscaper, AuditLogger, Outcome,
26};
27
28static GLOBAL_SINK: OnceLock<Arc<AuditLogger>> = OnceLock::new();
48
49pub fn install_global_audit_sink(logger: Arc<AuditLogger>) {
55 let _ = GLOBAL_SINK.set(logger);
56}
57
58#[derive(Debug)]
68pub enum OperatorEvent {
69 ReplicationBroken { peer: String, reason: String },
71 Divergence {
74 peer: String,
75 leader_lsn: u64,
76 follower_lsn: u64,
77 },
78 WalFsyncFailed { path: String, error: String },
80 DiskSpaceCritical {
82 path: String,
83 available_bytes: u64,
84 threshold_bytes: u64,
85 },
86 AuthBypass {
89 principal: String,
90 resource: String,
91 detail: String,
92 },
93 AdminCapabilityGranted {
95 granted_to: String,
96 capability: String,
97 granted_by: String,
98 },
99 SecretRotationFailed { secret_ref: String, error: String },
101 ConfigChanged {
103 key: String,
104 old_value: String,
105 new_value: String,
106 changed_by: String,
107 },
108 StartupFailed { phase: String, error: String },
110 ShutdownForced { reason: String },
113 SchemaCorruption { collection: String, detail: String },
115 CheckpointFailed { lsn: u64, error: String },
117 DanglingAdminIntent {
121 id: crate::crypto::uuid::Uuid,
122 op: crate::telemetry::admin_intent_log::IntentOp,
123 started_at_ms: u64,
125 last_phase: crate::telemetry::admin_intent_log::IntentPhase,
126 },
127 ConfigChangeRequiresRestart { fields_changed: String },
131 SubscriptionSchemaChange {
135 collection: String,
136 subscription_names: String,
138 fields_added: String,
140 fields_removed: String,
142 lsn: u64,
143 },
144 OutboxDlqActivated {
147 queue: String,
148 dlq: String,
149 reason: String,
150 },
151 QueueDlqPromoted {
155 queue: String,
156 group: String,
157 dlq: String,
158 message_id: u64,
159 attempts: u32,
160 reason: String,
161 },
162 DeposedPrimaryRollback {
173 common_point_lsn: u64,
176 tail_to_lsn: u64,
178 tail_lsns: u64,
180 commit_watermark: u64,
184 rollback_file: String,
186 new_primary_addr: String,
188 new_term: u64,
190 },
191}
192
193impl OperatorEvent {
194 pub fn all_variant_names() -> &'static [&'static str] {
196 &[
197 "ReplicationBroken",
198 "Divergence",
199 "WalFsyncFailed",
200 "DiskSpaceCritical",
201 "AuthBypass",
202 "AdminCapabilityGranted",
203 "SecretRotationFailed",
204 "ConfigChanged",
205 "StartupFailed",
206 "ShutdownForced",
207 "SchemaCorruption",
208 "CheckpointFailed",
209 "DanglingAdminIntent",
210 "ConfigChangeRequiresRestart",
211 "SubscriptionSchemaChange",
212 "OutboxDlqActivated",
213 "QueueDlqPromoted",
214 "DeposedPrimaryRollback",
215 ]
216 }
217
218 pub(super) fn variant_name(&self) -> &'static str {
220 match self {
221 Self::ReplicationBroken { .. } => "ReplicationBroken",
222 Self::Divergence { .. } => "Divergence",
223 Self::WalFsyncFailed { .. } => "WalFsyncFailed",
224 Self::DiskSpaceCritical { .. } => "DiskSpaceCritical",
225 Self::AuthBypass { .. } => "AuthBypass",
226 Self::AdminCapabilityGranted { .. } => "AdminCapabilityGranted",
227 Self::SecretRotationFailed { .. } => "SecretRotationFailed",
228 Self::ConfigChanged { .. } => "ConfigChanged",
229 Self::StartupFailed { .. } => "StartupFailed",
230 Self::ShutdownForced { .. } => "ShutdownForced",
231 Self::SchemaCorruption { .. } => "SchemaCorruption",
232 Self::CheckpointFailed { .. } => "CheckpointFailed",
233 Self::DanglingAdminIntent { .. } => "DanglingAdminIntent",
234 Self::ConfigChangeRequiresRestart { .. } => "ConfigChangeRequiresRestart",
235 Self::SubscriptionSchemaChange { .. } => "SubscriptionSchemaChange",
236 Self::OutboxDlqActivated { .. } => "OutboxDlqActivated",
237 Self::QueueDlqPromoted { .. } => "QueueDlqPromoted",
238 Self::DeposedPrimaryRollback { .. } => "DeposedPrimaryRollback",
239 }
240 }
241
242 pub fn emit_global(self) {
256 match GLOBAL_SINK.get() {
257 Some(logger) => self.emit(logger.as_ref()),
258 None => {
259 let (_, _, summary) = self.decompose();
260 tracing::warn!(target: "reddb::operator", "{summary}");
261 eprintln!("[reddb::operator] (no audit sink) {summary}");
262 }
263 }
264 }
265
266 pub fn emit(self, audit: &AuditLogger) {
267 let (action, fields, summary) = self.decompose();
268
269 let ev = AuditEvent::builder(action)
270 .source(AuditAuthSource::System)
271 .outcome(Outcome::Error)
272 .fields(fields)
273 .build();
274
275 let audit_ok = {
277 audit.record_event(ev);
281 true
282 };
283
284 tracing::warn!(target: "reddb::operator", "{summary}");
286
287 if !audit_ok {
290 eprintln!("[reddb::operator] {summary}");
291 }
292 }
293
294 pub(super) fn decompose(self) -> (&'static str, Vec<AuditField>, String) {
296 match self {
297 Self::ReplicationBroken { peer, reason } => {
298 let summary = format!("replication broken: peer={peer} reason={reason}");
299 let fields = vec![
300 AuditFieldEscaper::field("peer", peer),
301 AuditFieldEscaper::field("reason", reason),
302 ];
303 ("operator/replication_broken", fields, summary)
304 }
305 Self::Divergence {
306 peer,
307 leader_lsn,
308 follower_lsn,
309 } => {
310 let summary = format!(
311 "replication divergence: peer={peer} leader_lsn={leader_lsn} follower_lsn={follower_lsn}"
312 );
313 let fields = vec![
314 AuditFieldEscaper::field("peer", peer),
315 AuditFieldEscaper::field("leader_lsn", leader_lsn),
316 AuditFieldEscaper::field("follower_lsn", follower_lsn),
317 ];
318 ("operator/divergence", fields, summary)
319 }
320 Self::WalFsyncFailed { path, error } => {
321 let summary = format!("WAL fsync failed: path={path} error={error}");
322 let fields = vec![
323 AuditFieldEscaper::field("path", path),
324 AuditFieldEscaper::field("error", error),
325 ];
326 ("operator/wal_fsync_failed", fields, summary)
327 }
328 Self::DiskSpaceCritical {
329 path,
330 available_bytes,
331 threshold_bytes,
332 } => {
333 let summary = format!(
334 "disk space critical: path={path} available={available_bytes} threshold={threshold_bytes}"
335 );
336 let fields = vec![
337 AuditFieldEscaper::field("path", path),
338 AuditFieldEscaper::field("available_bytes", available_bytes),
339 AuditFieldEscaper::field("threshold_bytes", threshold_bytes),
340 ];
341 ("operator/disk_space_critical", fields, summary)
342 }
343 Self::AuthBypass {
344 principal,
345 resource,
346 detail,
347 } => {
348 let summary =
349 format!("auth bypass detected: principal={principal} resource={resource}");
350 let fields = vec![
351 AuditFieldEscaper::field("principal", principal),
352 AuditFieldEscaper::field("resource", resource),
353 AuditFieldEscaper::field("detail", detail),
354 ];
355 ("operator/auth_bypass", fields, summary)
356 }
357 Self::AdminCapabilityGranted {
358 granted_to,
359 capability,
360 granted_by,
361 } => {
362 let summary = format!(
363 "admin capability granted: to={granted_to} capability={capability} by={granted_by}"
364 );
365 let fields = vec![
366 AuditFieldEscaper::field("granted_to", granted_to),
367 AuditFieldEscaper::field("capability", capability),
368 AuditFieldEscaper::field("granted_by", granted_by),
369 ];
370 ("operator/admin_capability_granted", fields, summary)
371 }
372 Self::SecretRotationFailed { secret_ref, error } => {
373 let summary = format!("secret rotation failed: ref={secret_ref} error={error}");
374 let fields = vec![
375 AuditFieldEscaper::field("secret_ref", secret_ref),
376 AuditFieldEscaper::field("error", error),
377 ];
378 ("operator/secret_rotation_failed", fields, summary)
379 }
380 Self::ConfigChanged {
381 key,
382 old_value,
383 new_value,
384 changed_by,
385 } => {
386 let summary = format!(
387 "config changed: key={key} old={old_value} new={new_value} by={changed_by}"
388 );
389 let fields = vec![
390 AuditFieldEscaper::field("key", key),
391 AuditFieldEscaper::field("old_value", old_value),
392 AuditFieldEscaper::field("new_value", new_value),
393 AuditFieldEscaper::field("changed_by", changed_by),
394 ];
395 ("operator/config_changed", fields, summary)
396 }
397 Self::StartupFailed { phase, error } => {
398 let summary = format!("startup failed: phase={phase} error={error}");
399 let fields = vec![
400 AuditFieldEscaper::field("phase", phase),
401 AuditFieldEscaper::field("error", error),
402 ];
403 ("operator/startup_failed", fields, summary)
404 }
405 Self::ShutdownForced { reason } => {
406 let summary = format!("shutdown forced: reason={reason}");
407 let fields = vec![AuditFieldEscaper::field("reason", reason)];
408 ("operator/shutdown_forced", fields, summary)
409 }
410 Self::SchemaCorruption { collection, detail } => {
411 let summary = format!("schema corruption: collection={collection} detail={detail}");
412 let fields = vec![
413 AuditFieldEscaper::field("collection", collection),
414 AuditFieldEscaper::field("detail", detail),
415 ];
416 ("operator/schema_corruption", fields, summary)
417 }
418 Self::CheckpointFailed { lsn, error } => {
419 let summary = format!("checkpoint failed: lsn={lsn} error={error}");
420 let fields = vec![
421 AuditFieldEscaper::field("lsn", lsn),
422 AuditFieldEscaper::field("error", error),
423 ];
424 ("operator/checkpoint_failed", fields, summary)
425 }
426 Self::DanglingAdminIntent {
427 id,
428 op,
429 started_at_ms,
430 last_phase,
431 } => {
432 let summary = format!(
433 "dangling admin intent: id={id} op={op} started_at_ms={started_at_ms} last_phase={last_phase}"
434 );
435 let fields = vec![
436 AuditFieldEscaper::field("id", id.to_string()),
437 AuditFieldEscaper::field("op", op.to_string()),
438 AuditFieldEscaper::field("started_at_ms", started_at_ms),
439 AuditFieldEscaper::field("last_phase", last_phase.to_string()),
440 ];
441 ("operator/dangling_admin_intent", fields, summary)
442 }
443 Self::ConfigChangeRequiresRestart { fields_changed } => {
444 let summary = format!("config change requires restart: fields={fields_changed}");
445 let fields = vec![AuditFieldEscaper::field("fields_changed", fields_changed)];
446 ("operator/config_change_requires_restart", fields, summary)
447 }
448 Self::SubscriptionSchemaChange {
449 collection,
450 subscription_names,
451 fields_added,
452 fields_removed,
453 lsn,
454 } => {
455 let summary = format!(
456 "subscription schema change: collection={collection} subscriptions=[{subscription_names}] added=[{fields_added}] removed=[{fields_removed}] lsn={lsn}"
457 );
458 let fields = vec![
459 AuditFieldEscaper::field("collection", collection),
460 AuditFieldEscaper::field("subscription_names", subscription_names),
461 AuditFieldEscaper::field("fields_added", fields_added),
462 AuditFieldEscaper::field("fields_removed", fields_removed),
463 AuditFieldEscaper::field("lsn", lsn),
464 ];
465 ("operator/subscription_schema_change", fields, summary)
466 }
467 Self::OutboxDlqActivated { queue, dlq, reason } => {
468 let summary =
469 format!("outbox DLQ activated: queue={queue} dlq={dlq} reason={reason}");
470 let fields = vec![
471 AuditFieldEscaper::field("queue", queue),
472 AuditFieldEscaper::field("dlq", dlq),
473 AuditFieldEscaper::field("reason", reason),
474 ];
475 ("operator/outbox_dlq_activated", fields, summary)
476 }
477 Self::QueueDlqPromoted {
478 queue,
479 group,
480 dlq,
481 message_id,
482 attempts,
483 reason,
484 } => {
485 let summary = format!(
486 "queue DLQ promoted: queue={queue} group={group} dlq={dlq} message_id={message_id} attempts={attempts} reason={reason}"
487 );
488 let fields = vec![
489 AuditFieldEscaper::field("queue", queue),
490 AuditFieldEscaper::field("group", group),
491 AuditFieldEscaper::field("dlq", dlq),
492 AuditFieldEscaper::field("message_id", message_id),
493 AuditFieldEscaper::field("attempts", attempts as u64),
494 AuditFieldEscaper::field("reason", reason),
495 ];
496 ("operator/queue_dlq_promoted", fields, summary)
497 }
498 Self::DeposedPrimaryRollback {
499 common_point_lsn,
500 tail_to_lsn,
501 tail_lsns,
502 commit_watermark,
503 rollback_file,
504 new_primary_addr,
505 new_term,
506 } => {
507 let summary = format!(
508 "deposed primary auto-rollback: recovered to common_point_lsn={common_point_lsn} \
509 (commit_watermark={commit_watermark}); discarded divergent tail of {tail_lsns} LSN(s) \
510 up to {tail_to_lsn} saved to {rollback_file}; rejoining as replica of \
511 {new_primary_addr} under term {new_term}"
512 );
513 let fields = vec![
514 AuditFieldEscaper::field("common_point_lsn", common_point_lsn),
515 AuditFieldEscaper::field("tail_to_lsn", tail_to_lsn),
516 AuditFieldEscaper::field("tail_lsns", tail_lsns),
517 AuditFieldEscaper::field("commit_watermark", commit_watermark),
518 AuditFieldEscaper::field("rollback_file", rollback_file),
519 AuditFieldEscaper::field("new_primary_addr", new_primary_addr),
520 AuditFieldEscaper::field("new_term", new_term),
521 ];
522 ("operator/deposed_primary_rollback", fields, summary)
523 }
524 }
525 }
526}
527
528#[cfg(test)]
533mod tests {
534 use std::time::Duration;
535
536 use super::*;
537 use crate::runtime::audit_log::AuditLogger;
538
539 struct TempAuditPath(std::path::PathBuf);
540
541 impl std::ops::Deref for TempAuditPath {
542 type Target = std::path::PathBuf;
543
544 fn deref(&self) -> &std::path::PathBuf {
545 &self.0
546 }
547 }
548
549 impl AsRef<std::path::Path> for TempAuditPath {
550 fn as_ref(&self) -> &std::path::Path {
551 &self.0
552 }
553 }
554
555 impl Drop for TempAuditPath {
556 fn drop(&mut self) {
557 if let Some(parent) = self.0.parent() {
558 let _ = std::fs::remove_dir_all(parent);
559 }
560 }
561 }
562
563 fn make_logger() -> (AuditLogger, TempAuditPath) {
564 let dir = std::env::temp_dir().join(format!(
565 "reddb-op-event-{}-{}",
566 std::process::id(),
567 crate::utils::now_unix_nanos()
568 ));
569 std::fs::create_dir_all(&dir).unwrap();
570 let path = dir.join(".audit.log");
571 let logger = AuditLogger::with_path(path.clone());
572 (logger, TempAuditPath(path))
573 }
574
575 fn drain(logger: &AuditLogger) {
576 assert!(
577 logger.wait_idle(Duration::from_secs(2)),
578 "audit logger drain timed out"
579 );
580 }
581
582 fn read_last_line(path: &std::path::Path) -> crate::json::Value {
583 let body = std::fs::read_to_string(path).unwrap();
584 let line = body.lines().last().expect("at least one audit line");
585 crate::json::from_str(line).expect("valid JSON")
586 }
587
588 #[test]
593 fn replication_broken_emits() {
594 let (logger, path) = make_logger();
595 OperatorEvent::ReplicationBroken {
596 peer: "replica-1".into(),
597 reason: "TCP reset".into(),
598 }
599 .emit(&logger);
600 drain(&logger);
601 let v = read_last_line(&path);
602 assert_eq!(
603 v.get("action").and_then(|x| x.as_str()),
604 Some("operator/replication_broken")
605 );
606 let peer = v
607 .get("detail")
608 .and_then(|d| d.get("peer"))
609 .and_then(|x| x.as_str());
610 assert_eq!(peer, Some("replica-1"));
611 }
612
613 #[test]
614 fn divergence_emits() {
615 let (logger, path) = make_logger();
616 OperatorEvent::Divergence {
617 peer: "replica-2".into(),
618 leader_lsn: 1000,
619 follower_lsn: 999,
620 }
621 .emit(&logger);
622 drain(&logger);
623 let v = read_last_line(&path);
624 assert_eq!(
625 v.get("action").and_then(|x| x.as_str()),
626 Some("operator/divergence")
627 );
628 let lsn = v
629 .get("detail")
630 .and_then(|d| d.get("leader_lsn"))
631 .and_then(|x| x.as_i64());
632 assert_eq!(lsn, Some(1000));
633 }
634
635 #[test]
636 fn wal_fsync_failed_emits() {
637 let (logger, path) = make_logger();
638 OperatorEvent::WalFsyncFailed {
639 path: "/data/wal".into(),
640 error: "EIO".into(),
641 }
642 .emit(&logger);
643 drain(&logger);
644 let v = read_last_line(&path);
645 assert_eq!(
646 v.get("action").and_then(|x| x.as_str()),
647 Some("operator/wal_fsync_failed")
648 );
649 let err = v
650 .get("detail")
651 .and_then(|d| d.get("error"))
652 .and_then(|x| x.as_str());
653 assert_eq!(err, Some("EIO"));
654 }
655
656 #[test]
657 fn disk_space_critical_emits() {
658 let (logger, path) = make_logger();
659 OperatorEvent::DiskSpaceCritical {
660 path: "/data".into(),
661 available_bytes: 1024,
662 threshold_bytes: 10240,
663 }
664 .emit(&logger);
665 drain(&logger);
666 let v = read_last_line(&path);
667 assert_eq!(
668 v.get("action").and_then(|x| x.as_str()),
669 Some("operator/disk_space_critical")
670 );
671 let avail = v
672 .get("detail")
673 .and_then(|d| d.get("available_bytes"))
674 .and_then(|x| x.as_i64());
675 assert_eq!(avail, Some(1024));
676 }
677
678 #[test]
679 fn auth_bypass_emits() {
680 let (logger, path) = make_logger();
681 OperatorEvent::AuthBypass {
682 principal: "alice".into(),
683 resource: "/admin/drop".into(),
684 detail: "scope check skipped".into(),
685 }
686 .emit(&logger);
687 drain(&logger);
688 let v = read_last_line(&path);
689 assert_eq!(
690 v.get("action").and_then(|x| x.as_str()),
691 Some("operator/auth_bypass")
692 );
693 let res = v
694 .get("detail")
695 .and_then(|d| d.get("resource"))
696 .and_then(|x| x.as_str());
697 assert_eq!(res, Some("/admin/drop"));
698 }
699
700 #[test]
701 fn admin_capability_granted_emits() {
702 let (logger, path) = make_logger();
703 OperatorEvent::AdminCapabilityGranted {
704 granted_to: "bob".into(),
705 capability: "ADMIN_WRITE".into(),
706 granted_by: "root".into(),
707 }
708 .emit(&logger);
709 drain(&logger);
710 let v = read_last_line(&path);
711 assert_eq!(
712 v.get("action").and_then(|x| x.as_str()),
713 Some("operator/admin_capability_granted")
714 );
715 let cap = v
716 .get("detail")
717 .and_then(|d| d.get("capability"))
718 .and_then(|x| x.as_str());
719 assert_eq!(cap, Some("ADMIN_WRITE"));
720 }
721
722 #[test]
723 fn secret_rotation_failed_emits() {
724 let (logger, path) = make_logger();
725 OperatorEvent::SecretRotationFailed {
726 secret_ref: "jwt-signing-key".into(),
727 error: "HSM unreachable".into(),
728 }
729 .emit(&logger);
730 drain(&logger);
731 let v = read_last_line(&path);
732 assert_eq!(
733 v.get("action").and_then(|x| x.as_str()),
734 Some("operator/secret_rotation_failed")
735 );
736 let r = v
737 .get("detail")
738 .and_then(|d| d.get("secret_ref"))
739 .and_then(|x| x.as_str());
740 assert_eq!(r, Some("jwt-signing-key"));
741 }
742
743 #[test]
744 fn config_changed_emits() {
745 let (logger, path) = make_logger();
746 OperatorEvent::ConfigChanged {
747 key: "max_connections".into(),
748 old_value: "100".into(),
749 new_value: "200".into(),
750 changed_by: "ops-bot".into(),
751 }
752 .emit(&logger);
753 drain(&logger);
754 let v = read_last_line(&path);
755 assert_eq!(
756 v.get("action").and_then(|x| x.as_str()),
757 Some("operator/config_changed")
758 );
759 let nv = v
760 .get("detail")
761 .and_then(|d| d.get("new_value"))
762 .and_then(|x| x.as_str());
763 assert_eq!(nv, Some("200"));
764 }
765
766 #[test]
767 fn startup_failed_emits() {
768 let (logger, path) = make_logger();
769 OperatorEvent::StartupFailed {
770 phase: "wal_recovery".into(),
771 error: "corrupt frame".into(),
772 }
773 .emit(&logger);
774 drain(&logger);
775 let v = read_last_line(&path);
776 assert_eq!(
777 v.get("action").and_then(|x| x.as_str()),
778 Some("operator/startup_failed")
779 );
780 let phase = v
781 .get("detail")
782 .and_then(|d| d.get("phase"))
783 .and_then(|x| x.as_str());
784 assert_eq!(phase, Some("wal_recovery"));
785 }
786
787 #[test]
788 fn shutdown_forced_emits() {
789 let (logger, path) = make_logger();
790 OperatorEvent::ShutdownForced {
791 reason: "OOM".into(),
792 }
793 .emit(&logger);
794 drain(&logger);
795 let v = read_last_line(&path);
796 assert_eq!(
797 v.get("action").and_then(|x| x.as_str()),
798 Some("operator/shutdown_forced")
799 );
800 let r = v
801 .get("detail")
802 .and_then(|d| d.get("reason"))
803 .and_then(|x| x.as_str());
804 assert_eq!(r, Some("OOM"));
805 }
806
807 #[test]
808 fn schema_corruption_emits() {
809 let (logger, path) = make_logger();
810 OperatorEvent::SchemaCorruption {
811 collection: "users".into(),
812 detail: "unknown type tag 0xFF".into(),
813 }
814 .emit(&logger);
815 drain(&logger);
816 let v = read_last_line(&path);
817 assert_eq!(
818 v.get("action").and_then(|x| x.as_str()),
819 Some("operator/schema_corruption")
820 );
821 let coll = v
822 .get("detail")
823 .and_then(|d| d.get("collection"))
824 .and_then(|x| x.as_str());
825 assert_eq!(coll, Some("users"));
826 }
827
828 #[test]
829 fn checkpoint_failed_emits() {
830 let (logger, path) = make_logger();
831 OperatorEvent::CheckpointFailed {
832 lsn: 42_000,
833 error: "write stall".into(),
834 }
835 .emit(&logger);
836 drain(&logger);
837 let v = read_last_line(&path);
838 assert_eq!(
839 v.get("action").and_then(|x| x.as_str()),
840 Some("operator/checkpoint_failed")
841 );
842 let lsn = v
843 .get("detail")
844 .and_then(|d| d.get("lsn"))
845 .and_then(|x| x.as_i64());
846 assert_eq!(lsn, Some(42_000));
847 }
848
849 #[test]
850 fn deposed_primary_rollback_emits() {
851 let (logger, path) = make_logger();
852 OperatorEvent::DeposedPrimaryRollback {
853 common_point_lsn: 200,
854 tail_to_lsn: 230,
855 tail_lsns: 30,
856 commit_watermark: 200,
857 rollback_file: "/data/rollback/term7-lsn200-230.rbk".into(),
858 new_primary_addr: "http://node-b:55055".into(),
859 new_term: 8,
860 }
861 .emit(&logger);
862 drain(&logger);
863 let v = read_last_line(&path);
864 assert_eq!(
865 v.get("action").and_then(|x| x.as_str()),
866 Some("operator/deposed_primary_rollback")
867 );
868 let cp = v
869 .get("detail")
870 .and_then(|d| d.get("common_point_lsn"))
871 .and_then(|x| x.as_i64());
872 assert_eq!(cp, Some(200));
873 let file = v
874 .get("detail")
875 .and_then(|d| d.get("rollback_file"))
876 .and_then(|x| x.as_str());
877 assert_eq!(file, Some("/data/rollback/term7-lsn200-230.rbk"));
878 }
879
880 #[test]
885 fn adversarial_fields_are_escape_safe() {
886 let payloads: &[(&str, &str)] = &[
887 ("crlf", "line1\r\nline2"),
888 ("nul", "before\0after"),
889 ("quote", r#"she said "hi""#),
890 ("json_inject", r#"{"injected":true}"#),
891 ("low_ctrl", "\x01\x02\x07\x1f"),
892 ("backslash", "C:\\path\\file"),
893 ("mixed", "name=\"x\"\n\\path\t\x01end"),
894 ];
895
896 for (label, payload) in payloads {
897 let (logger, path) = make_logger();
898 OperatorEvent::SchemaCorruption {
899 collection: payload.to_string(),
900 detail: payload.to_string(),
901 }
902 .emit(&logger);
903 drain(&logger);
904
905 let body = std::fs::read_to_string(&path).unwrap();
906 let line = body.lines().last().unwrap_or("");
907
908 assert!(
910 !line.contains('\n'),
911 "{label}: embedded newline in JSONL row"
912 );
913
914 let v: crate::json::Value = crate::json::from_str(line)
915 .unwrap_or_else(|e| panic!("{label}: audit line not valid JSON: {e}\n{line:?}"));
916 let recovered = v
917 .get("detail")
918 .and_then(|d| d.get("collection"))
919 .and_then(|x| x.as_str())
920 .unwrap_or("");
921 assert_eq!(recovered, *payload, "{label}: round-trip mismatch");
922 }
923 }
924
925 #[test]
930 fn emit_sets_outcome_error_and_source_system() {
931 let (logger, path) = make_logger();
932 OperatorEvent::ShutdownForced {
933 reason: "test".into(),
934 }
935 .emit(&logger);
936 drain(&logger);
937 let v = read_last_line(&path);
938 assert_eq!(v.get("outcome").and_then(|x| x.as_str()), Some("error"));
939 assert_eq!(v.get("source").and_then(|x| x.as_str()), Some("system"));
940 }
941}