1use anyhow::Result;
7use shiplog_ports::Redactor;
8use shiplog_schema::event::EventEnvelope;
9use shiplog_schema::workstream::WorkstreamsFile;
10use std::path::{Path, PathBuf};
11
12mod alias;
13mod policy;
14mod profile;
15mod projector;
16mod repo;
17
18use alias::DeterministicAliasStore;
19use projector::{project_events_with_aliases, project_workstreams_with_aliases};
20
21pub use alias::CACHE_FILENAME;
31
32pub use profile::RedactionProfile;
47
48pub struct DeterministicRedactor {
65 aliases: DeterministicAliasStore,
66}
67
68impl DeterministicRedactor {
69 pub fn new(key: impl AsRef<[u8]>) -> Self {
84 Self {
85 aliases: DeterministicAliasStore::new(key),
86 }
87 }
88
89 pub fn cache_path(out_dir: &Path) -> PathBuf {
101 DeterministicAliasStore::cache_path(out_dir)
102 }
103
104 pub fn load_cache(&self, path: &Path) -> Result<()> {
117 self.aliases.load_cache(path)
118 }
119
120 pub fn save_cache(&self, path: &Path) -> Result<()> {
133 self.aliases.save_cache(path)
134 }
135
136 fn alias(&self, kind: &str, value: &str) -> String {
137 self.aliases.alias(kind, value)
138 }
139}
140
141impl Redactor for DeterministicRedactor {
142 fn redact_events(&self, events: &[EventEnvelope], profile: &str) -> Result<Vec<EventEnvelope>> {
143 let aliases = |kind: &str, value: &str| self.alias(kind, value);
144 Ok(project_events_with_aliases(events, profile, &aliases))
145 }
146
147 fn redact_workstreams(
148 &self,
149 workstreams: &WorkstreamsFile,
150 profile: &str,
151 ) -> Result<WorkstreamsFile> {
152 let aliases = |kind: &str, value: &str| self.alias(kind, value);
153 Ok(project_workstreams_with_aliases(
154 workstreams,
155 profile,
156 &aliases,
157 ))
158 }
159}
160
161#[cfg(test)]
162mod tests {
163 use super::*;
164 use chrono::Utc;
165 use proptest::prelude::*;
166 use shiplog_ids::EventId;
167 use shiplog_schema::event::*;
168 use shiplog_schema::workstream::Workstream;
169
170 proptest! {
171 #[test]
172 fn aliases_are_stable_for_same_key(kind in "repo|ws", value in ".*") {
173 let r = DeterministicRedactor::new(b"test-key");
174 let a1 = r.alias(&kind, &value);
175 let a2 = r.alias(&kind, &value);
176 prop_assert_eq!(a1, a2);
177 }
178 }
179
180 #[test]
181 fn public_profile_strips_titles_and_links() {
182 let r = DeterministicRedactor::new(b"k");
183 let ev = EventEnvelope {
184 id: EventId::from_parts(["x", "1"]),
185 kind: EventKind::PullRequest,
186 occurred_at: Utc::now(),
187 actor: Actor {
188 login: "a".into(),
189 id: None,
190 },
191 repo: RepoRef {
192 full_name: "o/r".into(),
193 html_url: Some("https://github.com/o/r".into()),
194 visibility: RepoVisibility::Private,
195 },
196 payload: EventPayload::PullRequest(PullRequestEvent {
197 number: 1,
198 title: "secret pr title".into(),
199 state: PullRequestState::Merged,
200 created_at: Utc::now(),
201 merged_at: Some(Utc::now()),
202 additions: Some(1),
203 deletions: Some(1),
204 changed_files: Some(1),
205 touched_paths_hint: vec!["secret/path".into()],
206 window: None,
207 }),
208 tags: vec![],
209 links: vec![Link {
210 label: "pr".into(),
211 url: "https://github.com/o/r/pull/1".into(),
212 }],
213 source: SourceRef {
214 system: SourceSystem::Github,
215 url: Some("https://api.github.com/...".into()),
216 opaque_id: None,
217 },
218 };
219
220 let out = r.redact_events(&[ev], "public").unwrap();
221 match &out[0].payload {
222 EventPayload::PullRequest(pr) => {
223 assert_eq!(pr.title, "[redacted]");
224 assert!(pr.touched_paths_hint.is_empty());
225 }
226 _ => panic!("expected pr"),
227 }
228 assert!(out[0].links.is_empty());
229 assert!(out[0].source.url.is_none());
230 assert_ne!(out[0].repo.full_name, "o/r");
231 }
232
233 #[test]
235 fn public_redaction_no_leak_pr_title() {
236 let r = DeterministicRedactor::new(b"test-key");
237 let sensitive_title = "Secret Feature: Internal Auth Bypass";
238
239 let ev = EventEnvelope {
240 id: EventId::from_parts(["x", "1"]),
241 kind: EventKind::PullRequest,
242 occurred_at: Utc::now(),
243 actor: Actor {
244 login: "a".into(),
245 id: None,
246 },
247 repo: RepoRef {
248 full_name: "o/r".into(),
249 html_url: None,
250 visibility: RepoVisibility::Private,
251 },
252 payload: EventPayload::PullRequest(PullRequestEvent {
253 number: 1,
254 title: sensitive_title.into(),
255 state: PullRequestState::Merged,
256 created_at: Utc::now(),
257 merged_at: Some(Utc::now()),
258 additions: Some(10),
259 deletions: Some(5),
260 changed_files: Some(2),
261 touched_paths_hint: vec![],
262 window: None,
263 }),
264 tags: vec![],
265 links: vec![],
266 source: SourceRef {
267 system: SourceSystem::Github,
268 url: None,
269 opaque_id: None,
270 },
271 };
272
273 let out = r.redact_events(&[ev], "public").unwrap();
274 let json = serde_json::to_string(&out).unwrap();
275
276 assert!(
277 !json.contains(sensitive_title),
278 "Sensitive PR title leaked in JSON output"
279 );
280 assert!(
281 !json.contains("Auth Bypass"),
282 "Partial sensitive content leaked"
283 );
284 }
285
286 #[test]
288 fn public_redaction_no_leak_repo_name() {
289 let r = DeterministicRedactor::new(b"test-key");
290 let sensitive_repo = "acme-corp/top-secret-project";
291
292 let ev = EventEnvelope {
293 id: EventId::from_parts(["x", "1"]),
294 kind: EventKind::PullRequest,
295 occurred_at: Utc::now(),
296 actor: Actor {
297 login: "a".into(),
298 id: None,
299 },
300 repo: RepoRef {
301 full_name: sensitive_repo.into(),
302 html_url: None,
303 visibility: RepoVisibility::Private,
304 },
305 payload: EventPayload::PullRequest(PullRequestEvent {
306 number: 1,
307 title: "test".into(),
308 state: PullRequestState::Merged,
309 created_at: Utc::now(),
310 merged_at: Some(Utc::now()),
311 additions: Some(1),
312 deletions: Some(1),
313 changed_files: Some(1),
314 touched_paths_hint: vec![],
315 window: None,
316 }),
317 tags: vec![],
318 links: vec![],
319 source: SourceRef {
320 system: SourceSystem::Github,
321 url: None,
322 opaque_id: None,
323 },
324 };
325
326 let out = r.redact_events(&[ev], "public").unwrap();
327 let json = serde_json::to_string(&out).unwrap();
328
329 assert!(
330 !json.contains(sensitive_repo),
331 "Sensitive repo name leaked in JSON output"
332 );
333 assert!(
334 !json.contains("acme-corp"),
335 "Org name leaked in JSON output"
336 );
337 assert!(
338 !json.contains("top-secret"),
339 "Project name leaked in JSON output"
340 );
341 }
342
343 #[test]
345 fn public_redaction_no_leak_manual_content() {
346 use chrono::NaiveDate;
347
348 let r = DeterministicRedactor::new(b"test-key");
349 let sensitive_title = "Security Incident: Data Breach Response";
350 let sensitive_desc = "Customer PII was exposed in production logs";
351 let sensitive_impact = "Affected 10,000 user records";
352
353 let ev = EventEnvelope {
354 id: EventId::from_parts(["x", "1"]),
355 kind: EventKind::Manual,
356 occurred_at: Utc::now(),
357 actor: Actor {
358 login: "a".into(),
359 id: None,
360 },
361 repo: RepoRef {
362 full_name: "o/r".into(),
363 html_url: None,
364 visibility: RepoVisibility::Private,
365 },
366 payload: EventPayload::Manual(ManualEvent {
367 event_type: ManualEventType::Incident,
368 title: sensitive_title.into(),
369 description: Some(sensitive_desc.into()),
370 impact: Some(sensitive_impact.into()),
371 started_at: Some(NaiveDate::from_ymd_opt(2024, 1, 15).unwrap()),
372 ended_at: Some(NaiveDate::from_ymd_opt(2024, 1, 15).unwrap()),
373 }),
374 tags: vec![],
375 links: vec![],
376 source: SourceRef {
377 system: SourceSystem::Manual,
378 url: None,
379 opaque_id: None,
380 },
381 };
382
383 let out = r.redact_events(&[ev], "public").unwrap();
384 let json = serde_json::to_string(&out).unwrap();
385
386 assert!(
387 !json.contains(sensitive_title),
388 "Sensitive manual event title leaked"
389 );
390 assert!(
391 !json.contains(sensitive_desc),
392 "Sensitive manual event description leaked"
393 );
394 assert!(
395 !json.contains(sensitive_impact),
396 "Sensitive manual event impact leaked"
397 );
398 assert!(
399 !json.contains("Data Breach"),
400 "Partial sensitive content leaked"
401 );
402 assert!(!json.contains("PII"), "Sensitive abbreviation leaked");
403 }
404
405 #[test]
407 fn public_redaction_strips_all_urls() {
408 let r = DeterministicRedactor::new(b"test-key");
409
410 let urls = vec![
411 "https://github.com/acme-corp/secret/pull/42",
412 "https://api.github.com/repos/acme-corp/secret/issues/1",
413 "https://jira.internal.company.com/SECRET-123",
414 "https://docs.google.com/document/d/secret-doc-id",
415 ];
416
417 for url in urls {
418 let ev = EventEnvelope {
419 id: EventId::from_parts(["x", "1"]),
420 kind: EventKind::PullRequest,
421 occurred_at: Utc::now(),
422 actor: Actor {
423 login: "a".into(),
424 id: None,
425 },
426 repo: RepoRef {
427 full_name: "o/r".into(),
428 html_url: Some(url.into()),
429 visibility: RepoVisibility::Private,
430 },
431 payload: EventPayload::PullRequest(PullRequestEvent {
432 number: 1,
433 title: "test".into(),
434 state: PullRequestState::Merged,
435 created_at: Utc::now(),
436 merged_at: Some(Utc::now()),
437 additions: Some(1),
438 deletions: Some(1),
439 changed_files: Some(1),
440 touched_paths_hint: vec![],
441 window: None,
442 }),
443 tags: vec![],
444 links: vec![Link {
445 label: "link".into(),
446 url: url.into(),
447 }],
448 source: SourceRef {
449 system: SourceSystem::Github,
450 url: Some(url.into()),
451 opaque_id: None,
452 },
453 };
454
455 let out = r.redact_events(&[ev], "public").unwrap();
456 let json = serde_json::to_string(&out).unwrap();
457
458 assert!(
460 !json.contains("github.com/acme-corp"),
461 "GitHub URL leaked: {}",
462 url
463 );
464 assert!(!json.contains("jira.internal"), "Jira URL leaked: {}", url);
465 assert!(
466 !json.contains("docs.google.com"),
467 "Google Docs URL leaked: {}",
468 url
469 );
470 assert!(!json.contains("http"), "HTTP prefix leaked in: {}", url);
471 }
472 }
473
474 #[test]
476 fn workstream_redaction_no_leak() {
477 use shiplog_ids::WorkstreamId;
478 use shiplog_schema::workstream::WorkstreamStats;
479
480 let r = DeterministicRedactor::new(b"test-key");
481
482 let ws = Workstream {
483 id: WorkstreamId::from_parts(["ws", "test"]),
484 title: "Secret Project: Quantum Encryption".into(),
485 summary: Some(
486 "Developing military-grade encryption for classified communications".into(),
487 ),
488 tags: vec!["security".into(), "classified".into(), "repo".into()],
489 stats: WorkstreamStats::zero(),
490 events: vec![],
491 receipts: vec![],
492 };
493
494 let ws_file = WorkstreamsFile {
495 workstreams: vec![ws],
496 version: 1,
497 generated_at: Utc::now(),
498 };
499
500 let out = r.redact_workstreams(&ws_file, "public").unwrap();
501 let json = serde_json::to_string(&out).unwrap();
502
503 assert!(
505 !json.contains("Quantum Encryption"),
506 "Workstream title leaked"
507 );
508 assert!(
509 !json.contains("military-grade"),
510 "Workstream summary leaked"
511 );
512
513 assert!(
515 !json.contains("Developing"),
516 "Workstream description leaked"
517 );
518
519 let ws_out = &out.workstreams[0];
521 assert!(
522 !ws_out.tags.contains(&"repo".into()),
523 "repo tag should be filtered"
524 );
525 assert!(
526 ws_out.tags.contains(&"security".into()),
527 "security tag should remain"
528 );
529 assert!(
530 ws_out.tags.contains(&"classified".into()),
531 "classified tag should remain (only 'repo' is filtered)"
532 );
533 }
534
535 #[test]
537 fn internal_profile_preserves_all_data() {
538 let r = DeterministicRedactor::new(b"test-key");
539 let sensitive_title = "Secret Feature Title";
540 let sensitive_repo = "secret-org/secret-repo";
541
542 let ev = EventEnvelope {
543 id: EventId::from_parts(["x", "1"]),
544 kind: EventKind::PullRequest,
545 occurred_at: Utc::now(),
546 actor: Actor {
547 login: "a".into(),
548 id: None,
549 },
550 repo: RepoRef {
551 full_name: sensitive_repo.into(),
552 html_url: Some("https://github.com/secret".into()),
553 visibility: RepoVisibility::Private,
554 },
555 payload: EventPayload::PullRequest(PullRequestEvent {
556 number: 1,
557 title: sensitive_title.into(),
558 state: PullRequestState::Merged,
559 created_at: Utc::now(),
560 merged_at: Some(Utc::now()),
561 additions: Some(1),
562 deletions: Some(1),
563 changed_files: Some(1),
564 touched_paths_hint: vec!["secret/path".into()],
565 window: None,
566 }),
567 tags: vec![],
568 links: vec![Link {
569 label: "pr".into(),
570 url: "https://github.com/secret".into(),
571 }],
572 source: SourceRef {
573 system: SourceSystem::Github,
574 url: Some("https://api.github.com/secret".into()),
575 opaque_id: None,
576 },
577 };
578
579 let out = r.redact_events(&[ev], "internal").unwrap();
580 let json = serde_json::to_string(&out).unwrap();
581
582 assert!(
584 json.contains(sensitive_title),
585 "Internal profile should preserve title"
586 );
587 assert!(
588 json.contains(sensitive_repo),
589 "Internal profile should preserve repo"
590 );
591 assert!(
592 json.contains("https://github.com/secret"),
593 "Internal profile should preserve URLs"
594 );
595 }
596
597 #[test]
599 fn manager_profile_keeps_context_but_strips_details() {
600 let r = DeterministicRedactor::new(b"test-key");
601 let pr_title = "Feature: Add user authentication".to_string();
602
603 let ev = EventEnvelope {
604 id: EventId::from_parts(["x", "1"]),
605 kind: EventKind::PullRequest,
606 occurred_at: Utc::now(),
607 actor: Actor {
608 login: "a".into(),
609 id: None,
610 },
611 repo: RepoRef {
612 full_name: "myorg/auth-service".into(),
613 html_url: Some("https://github.com/myorg/auth-service".into()),
614 visibility: RepoVisibility::Private,
615 },
616 payload: EventPayload::PullRequest(PullRequestEvent {
617 number: 42,
618 title: pr_title.clone(),
619 state: PullRequestState::Merged,
620 created_at: Utc::now(),
621 merged_at: Some(Utc::now()),
622 additions: Some(100),
623 deletions: Some(50),
624 changed_files: Some(5),
625 touched_paths_hint: vec!["src/auth/internal.rs".into(), "src/secrets.rs".into()],
626 window: None,
627 }),
628 tags: vec![],
629 links: vec![Link {
630 label: "pr".into(),
631 url: "https://github.com/myorg/auth-service/pull/42".into(),
632 }],
633 source: SourceRef {
634 system: SourceSystem::Github,
635 url: Some("https://api.github.com/...".into()),
636 opaque_id: None,
637 },
638 };
639
640 let out = r.redact_events(&[ev], "manager").unwrap();
641
642 match &out[0].payload {
644 EventPayload::PullRequest(pr) => {
645 assert_eq!(pr.title, pr_title);
646 assert!(
648 pr.touched_paths_hint.is_empty(),
649 "touched_paths_hint should be cleared in manager view"
650 );
651 }
652 _ => panic!("expected pr"),
653 }
654
655 assert!(
657 out[0].links.is_empty(),
658 "links should be stripped in manager view"
659 );
660
661 assert_eq!(out[0].repo.full_name, "myorg/auth-service");
663 assert!(out[0].source.url.is_some());
664 }
665
666 #[test]
668 fn manager_profile_handles_manual_events() {
669 use chrono::NaiveDate;
670
671 let r = DeterministicRedactor::new(b"test-key");
672
673 let ev = EventEnvelope {
674 id: EventId::from_parts(["x", "1"]),
675 kind: EventKind::Manual,
676 occurred_at: Utc::now(),
677 actor: Actor {
678 login: "a".into(),
679 id: None,
680 },
681 repo: RepoRef {
682 full_name: "o/r".into(),
683 html_url: None,
684 visibility: RepoVisibility::Private,
685 },
686 payload: EventPayload::Manual(ManualEvent {
687 event_type: ManualEventType::Incident,
688 title: "Database outage resolution".into(),
689 description: Some("Detailed technical description of the fix".into()),
690 impact: Some("Affected 1000 users for 5 minutes".into()),
691 started_at: Some(NaiveDate::from_ymd_opt(2024, 1, 15).unwrap()),
692 ended_at: Some(NaiveDate::from_ymd_opt(2024, 1, 15).unwrap()),
693 }),
694 tags: vec![],
695 links: vec![Link {
696 label: "runbook".into(),
697 url: "https://wiki.internal/runbook".into(),
698 }],
699 source: SourceRef {
700 system: SourceSystem::Manual,
701 url: None,
702 opaque_id: None,
703 },
704 };
705
706 let out = r.redact_events(&[ev], "manager").unwrap();
707
708 match &out[0].payload {
709 EventPayload::Manual(m) => {
710 assert_eq!(m.title, "Database outage resolution");
712 assert!(
714 m.description.is_none(),
715 "description should be removed in manager view"
716 );
717 assert!(
718 m.impact.is_none(),
719 "impact should be removed in manager view"
720 );
721 }
722 _ => panic!("expected manual event"),
723 }
724
725 assert!(out[0].links.is_empty());
727 }
728
729 #[test]
731 fn manager_profile_handles_workstreams() {
732 use shiplog_ids::WorkstreamId;
733 use shiplog_schema::workstream::WorkstreamStats;
734
735 let r = DeterministicRedactor::new(b"test-key");
736
737 let ws = Workstream {
738 id: WorkstreamId::from_parts(["ws", "test"]),
739 title: "Authentication Service Improvements".into(),
740 summary: Some("Internal details about security architecture".into()),
741 tags: vec!["security".into(), "backend".into(), "repo".into()],
742 stats: WorkstreamStats::zero(),
743 events: vec![],
744 receipts: vec![],
745 };
746
747 let ws_file = WorkstreamsFile {
748 workstreams: vec![ws],
749 version: 1,
750 generated_at: Utc::now(),
751 };
752
753 let out = r.redact_workstreams(&ws_file, "manager").unwrap();
754
755 let ws_out = &out.workstreams[0];
756
757 assert_eq!(ws_out.title, "Authentication Service Improvements");
759
760 assert!(
762 ws_out.summary.is_none(),
763 "summary should be removed in manager view"
764 );
765
766 assert!(ws_out.tags.contains(&"security".into()));
768 assert!(ws_out.tags.contains(&"backend".into()));
769 assert!(ws_out.tags.contains(&"repo".into()));
770 }
771
772 #[test]
773 fn cache_round_trip() {
774 let dir = tempfile::tempdir().unwrap();
775 let cache_path = dir.path().join("redaction.aliases.json");
776
777 let r1 = DeterministicRedactor::new(b"key-a");
778 let a1 = r1.alias("repo", "acme/foo");
779 let a2 = r1.alias("ws", "my-workstream");
780 r1.save_cache(&cache_path).unwrap();
781
782 let r2 = DeterministicRedactor::new(b"key-a");
783 r2.load_cache(&cache_path).unwrap();
784 assert_eq!(r2.alias("repo", "acme/foo"), a1);
785 assert_eq!(r2.alias("ws", "my-workstream"), a2);
786 }
787
788 #[test]
789 fn missing_file_is_noop() {
790 let r = DeterministicRedactor::new(b"key");
791 let result = r.load_cache(std::path::Path::new("/nonexistent/path/cache.json"));
792 assert!(result.is_ok());
793 }
794
795 #[test]
796 fn corrupt_file_errors() {
797 let dir = tempfile::tempdir().unwrap();
798 let cache_path = dir.path().join("redaction.aliases.json");
799 std::fs::write(&cache_path, "this is not json!!!").unwrap();
800
801 let r = DeterministicRedactor::new(b"key");
802 let result = r.load_cache(&cache_path);
803 assert!(result.is_err());
804 }
805
806 #[test]
807 fn version_mismatch_errors() {
808 let dir = tempfile::tempdir().unwrap();
809 let cache_path = dir.path().join("redaction.aliases.json");
810 let bad = serde_json::json!({ "version": 99, "entries": {} });
811 std::fs::write(&cache_path, serde_json::to_string(&bad).unwrap()).unwrap();
812
813 let r = DeterministicRedactor::new(b"key");
814 let result = r.load_cache(&cache_path);
815 assert!(result.is_err());
816 let msg = format!("{}", result.unwrap_err());
817 assert!(msg.contains("unsupported alias cache version"));
818 }
819
820 #[test]
821 fn redaction_profile_as_str_returns_expected_values() {
822 assert_eq!(RedactionProfile::Internal.as_str(), "internal");
823 assert_eq!(RedactionProfile::Manager.as_str(), "manager");
824 assert_eq!(RedactionProfile::Public.as_str(), "public");
825 }
826
827 #[test]
828 fn cache_path_joins_out_dir_with_filename() {
829 let path = DeterministicRedactor::cache_path(Path::new("/some/out"));
830 assert!(
831 path.ends_with("redaction.aliases.json"),
832 "expected path to end with cache filename, got: {path:?}"
833 );
834 }
835
836 #[test]
837 fn internal_profile_preserves_workstreams() {
838 use shiplog_ids::WorkstreamId;
839 use shiplog_schema::workstream::WorkstreamStats;
840
841 let r = DeterministicRedactor::new(b"test-key");
842
843 let ws = Workstream {
844 id: WorkstreamId::from_parts(["ws", "keep"]),
845 title: "My Workstream Title".into(),
846 summary: Some("Detailed summary here".into()),
847 tags: vec!["tag-a".into(), "repo".into()],
848 stats: WorkstreamStats::zero(),
849 events: vec![],
850 receipts: vec![],
851 };
852
853 let ws_file = WorkstreamsFile {
854 workstreams: vec![ws],
855 version: 1,
856 generated_at: Utc::now(),
857 };
858
859 let out = r.redact_workstreams(&ws_file, "internal").unwrap();
860 let ws_out = &out.workstreams[0];
861 assert_eq!(ws_out.title, "My Workstream Title");
862 assert_eq!(ws_out.summary.as_deref(), Some("Detailed summary here"));
863 assert_eq!(ws_out.tags, vec!["tag-a".to_string(), "repo".to_string()]);
864 }
865
866 #[test]
867 fn cache_preserves_across_key_change() {
868 let dir = tempfile::tempdir().unwrap();
869 let cache_path = dir.path().join("redaction.aliases.json");
870
871 let r1 = DeterministicRedactor::new(b"key-A");
873 let alias_a = r1.alias("repo", "acme/foo");
874 r1.save_cache(&cache_path).unwrap();
875
876 let r2 = DeterministicRedactor::new(b"key-B");
878 r2.load_cache(&cache_path).unwrap();
879
880 let alias_b = r2.alias("repo", "acme/foo");
882 assert_eq!(
883 alias_a, alias_b,
884 "cached alias should be preserved, not regenerated with new key"
885 );
886
887 let fresh_b = r2.alias("repo", "acme/bar");
889 let r3 = DeterministicRedactor::new(b"key-A");
890 let fresh_a = r3.alias("repo", "acme/bar");
891 assert_ne!(
892 fresh_b, fresh_a,
893 "uncached alias should use current key, not old key"
894 );
895 }
896
897 proptest! {
899 #[test]
900 fn prop_sensitive_strings_redacted(
901 title in "[a-zA-Z0-9_-]{10,50}",
902 repo in r"[a-z0-9_-]+/[a-z0-9_-]+"
903 ) {
904 let r = DeterministicRedactor::new(b"test-key");
905
906 let ev = EventEnvelope {
907 id: EventId::from_parts(["x","1"]),
908 kind: EventKind::PullRequest,
909 occurred_at: Utc::now(),
910 actor: Actor { login: "a".into(), id: None },
911 repo: RepoRef { full_name: repo.clone(), html_url: None, visibility: RepoVisibility::Private },
912 payload: EventPayload::PullRequest(PullRequestEvent {
913 number: 1,
914 title: title.clone(),
915 state: PullRequestState::Merged,
916 created_at: Utc::now(),
917 merged_at: Some(Utc::now()),
918 additions: Some(1),
919 deletions: Some(1),
920 changed_files: Some(1),
921 touched_paths_hint: vec![],
922 window: None,
923 }),
924 tags: vec![],
925 links: vec![],
926 source: SourceRef { system: SourceSystem::Github, url: None, opaque_id: None },
927 };
928
929 let out = r.redact_events(&[ev], "public").unwrap();
930 let json = serde_json::to_string(&out)?;
931
932 prop_assert!(!json.contains(&title), "Title '{}' leaked in output", title);
934
935 if !repo.is_empty() {
937 prop_assert!(!json.contains(&repo), "Repo '{}' leaked in output", repo);
938 }
939
940 match &out[0].payload {
942 EventPayload::PullRequest(pr) => {
943 prop_assert_eq!(&pr.title, "[redacted]");
944 }
945 _ => prop_assert!(false, "Expected PR payload"),
946 }
947 }
948 }
949}