Skip to main content

shiplog_redact/
lib.rs

1//! Deterministic structural redaction for shiplog packets.
2//!
3//! Supports `internal`, `manager`, and `public` projections with stable alias
4//! generation backed by keyed hashing and optional alias cache persistence.
5
6use 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
21/// Default filename for the alias cache (`redaction.aliases.json`).
22///
23/// # Examples
24///
25/// ```
26/// use shiplog_redact::CACHE_FILENAME;
27///
28/// assert_eq!(CACHE_FILENAME, "redaction.aliases.json");
29/// ```
30pub use alias::CACHE_FILENAME;
31
32/// Redaction profile enum (`Internal`, `Manager`, `Public`).
33///
34/// # Examples
35///
36/// ```
37/// use shiplog_redact::RedactionProfile;
38///
39/// let p = RedactionProfile::from_profile_str("manager");
40/// assert_eq!(p.as_str(), "manager");
41///
42/// // Unknown strings default to Public:
43/// let unknown = RedactionProfile::from_profile_str("bogus");
44/// assert_eq!(unknown, RedactionProfile::Public);
45/// ```
46pub use profile::RedactionProfile;
47
48/// Deterministic redactor.
49///
50/// This intentionally does not try to be clever.
51/// - It doesn't do NLP.
52/// - It doesn't detect secrets.
53/// - It does *structural* redaction so you can safely share packets.
54///
55/// # Examples
56///
57/// ```
58/// use shiplog_redact::DeterministicRedactor;
59///
60/// let redactor = DeterministicRedactor::new(b"my-secret-key");
61/// // The redactor is now ready to redact events and workstreams
62/// // via the `Redactor` trait from `shiplog_ports`.
63/// ```
64pub struct DeterministicRedactor {
65    aliases: DeterministicAliasStore,
66}
67
68impl DeterministicRedactor {
69    /// Create a new redactor with the given redaction key.
70    ///
71    /// The same key always produces the same aliases, making redaction
72    /// deterministic across runs.
73    ///
74    /// # Examples
75    ///
76    /// ```
77    /// use shiplog_redact::DeterministicRedactor;
78    ///
79    /// let r1 = DeterministicRedactor::new(b"key-a");
80    /// let r2 = DeterministicRedactor::new(b"key-a");
81    /// // Both produce identical aliases for the same inputs.
82    /// ```
83    pub fn new(key: impl AsRef<[u8]>) -> Self {
84        Self {
85            aliases: DeterministicAliasStore::new(key),
86        }
87    }
88
89    /// Path to the alias cache file in a given output directory.
90    ///
91    /// # Examples
92    ///
93    /// ```
94    /// use shiplog_redact::DeterministicRedactor;
95    /// use std::path::Path;
96    ///
97    /// let p = DeterministicRedactor::cache_path(Path::new("/out/run_1"));
98    /// assert!(p.to_string_lossy().contains("redaction.aliases.json"));
99    /// ```
100    pub fn cache_path(out_dir: &Path) -> PathBuf {
101        DeterministicAliasStore::cache_path(out_dir)
102    }
103
104    /// Load cached aliases from disk. No-op if file is missing.
105    ///
106    /// # Examples
107    ///
108    /// ```rust,no_run
109    /// use shiplog_redact::DeterministicRedactor;
110    /// use std::path::Path;
111    ///
112    /// let r = DeterministicRedactor::new(b"key");
113    /// // Loads previously-saved aliases; silently succeeds if file absent.
114    /// r.load_cache(Path::new("/out/run_1/redaction.aliases.json")).unwrap();
115    /// ```
116    pub fn load_cache(&self, path: &Path) -> Result<()> {
117        self.aliases.load_cache(path)
118    }
119
120    /// Save current aliases to disk.
121    ///
122    /// # Examples
123    ///
124    /// ```rust,no_run
125    /// use shiplog_redact::DeterministicRedactor;
126    /// use std::path::Path;
127    ///
128    /// let r = DeterministicRedactor::new(b"key");
129    /// // After redacting events, persist the alias map for future runs.
130    /// r.save_cache(Path::new("/out/run_1/redaction.aliases.json")).unwrap();
131    /// ```
132    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    /// Property test: PR titles must not appear in public redacted output
234    #[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    /// Property test: Repo names must not appear in public redacted output
287    #[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    /// Property test: Manual event content must not leak in public mode
344    #[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    /// Property test: All URL patterns must be stripped from public output
406    #[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            // URLs should be completely gone
459            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    /// Property test: Workstream titles and summaries must not leak in public mode
475    #[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        // Original title should not appear (aliased instead)
504        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        // Summary should be None (not present in output)
514        assert!(
515            !json.contains("Developing"),
516            "Workstream description leaked"
517        );
518
519        // "repo" tag should be filtered out, but other tags remain
520        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    /// Property test: Internal profile should NOT redact (sanity check)
536    #[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        // All sensitive data should be preserved
583        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    /// Property test: Manager profile keeps titles but removes sensitive details
598    #[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        // Title should be preserved
643        match &out[0].payload {
644            EventPayload::PullRequest(pr) => {
645                assert_eq!(pr.title, pr_title);
646                // But touched_paths should be cleared
647                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        // Links should be stripped
656        assert!(
657            out[0].links.is_empty(),
658            "links should be stripped in manager view"
659        );
660
661        // Repo and source URL should be preserved
662        assert_eq!(out[0].repo.full_name, "myorg/auth-service");
663        assert!(out[0].source.url.is_some());
664    }
665
666    /// Property test: Manager profile handles manual events correctly
667    #[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                // Title should be preserved
711                assert_eq!(m.title, "Database outage resolution");
712                // Description and impact should be removed
713                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        // Links should be stripped
726        assert!(out[0].links.is_empty());
727    }
728
729    /// Property test: Manager profile handles workstreams
730    #[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        // Title should be preserved (not aliased)
758        assert_eq!(ws_out.title, "Authentication Service Improvements");
759
760        // Summary should be removed
761        assert!(
762            ws_out.summary.is_none(),
763            "summary should be removed in manager view"
764        );
765
766        // All tags should be preserved (including "repo")
767        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        // Generate aliases with key A
872        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        // Load into redactor with key B
877        let r2 = DeterministicRedactor::new(b"key-B");
878        r2.load_cache(&cache_path).unwrap();
879
880        // The loaded alias should be used (not regenerated with key B)
881        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        // But a new value not in cache should use key B
888        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    // Property test using proptest: arbitrary strings should not leak through redaction
898    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            // Title should be replaced, not preserved
933            prop_assert!(!json.contains(&title), "Title '{}' leaked in output", title);
934
935            // Repo should be aliased, not preserved
936            if !repo.is_empty() {
937                prop_assert!(!json.contains(&repo), "Repo '{}' leaked in output", repo);
938            }
939
940            // Title should be the literal redaction marker
941            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}