shiplog-redact 0.6.0

Deterministic profile-based redaction engine for shiplog events and workstreams.
Documentation
//! Profile-based structural redaction policy.

use crate::profile::RedactionProfile;
use crate::repo::{AliasResolver, redact_repo_public};
use shiplog_schema::event::{EventEnvelope, EventPayload};
use shiplog_schema::workstream::{Workstream, WorkstreamsFile};

/// Redact a single event for the selected profile.
pub(crate) fn redact_event_with_aliases<A: AliasResolver + ?Sized>(
    mut event: EventEnvelope,
    profile: RedactionProfile,
    aliases: &A,
) -> EventEnvelope {
    match profile {
        RedactionProfile::Internal => event,
        RedactionProfile::Manager => {
            match &mut event.payload {
                EventPayload::PullRequest(pr) => {
                    pr.touched_paths_hint.clear();
                }
                EventPayload::Review(_) => {}
                EventPayload::Manual(m) => {
                    m.description = None;
                    m.impact = None;
                }
            }

            event.links.clear();
            event
        }
        RedactionProfile::Public => {
            event.repo = redact_repo_public(&event.repo, aliases);

            match &mut event.payload {
                EventPayload::PullRequest(pr) => {
                    pr.title = "[redacted]".to_string();
                    pr.touched_paths_hint.clear();
                }
                EventPayload::Review(r) => {
                    r.pull_title = "[redacted]".to_string();
                }
                EventPayload::Manual(m) => {
                    m.title = "[redacted]".to_string();
                    m.description = None;
                    m.impact = None;
                }
            }

            event.links.clear();
            event.source.url = None;
            event.source.opaque_id = None;
            event
        }
    }
}

/// Redact all events for the selected profile.
pub(crate) fn redact_events_with_aliases<A: AliasResolver + ?Sized>(
    events: &[EventEnvelope],
    profile: RedactionProfile,
    aliases: &A,
) -> Vec<EventEnvelope> {
    events
        .iter()
        .cloned()
        .map(|event| redact_event_with_aliases(event, profile, aliases))
        .collect()
}

/// Redact a single workstream for the selected profile.
pub(crate) fn redact_workstream_with_aliases<A: AliasResolver + ?Sized>(
    mut workstream: Workstream,
    profile: RedactionProfile,
    aliases: &A,
) -> Workstream {
    match profile {
        RedactionProfile::Internal => workstream,
        RedactionProfile::Manager => {
            workstream.summary = None;
            workstream
        }
        RedactionProfile::Public => {
            workstream.title = aliases.alias("ws", &workstream.title);
            workstream.summary = None;
            workstream.tags.retain(|tag| tag != "repo");
            workstream
        }
    }
}

/// Redact all workstreams for the selected profile.
pub(crate) fn redact_workstreams_with_aliases<A: AliasResolver + ?Sized>(
    workstreams: &WorkstreamsFile,
    profile: RedactionProfile,
    aliases: &A,
) -> WorkstreamsFile {
    WorkstreamsFile {
        workstreams: workstreams
            .workstreams
            .iter()
            .cloned()
            .map(|workstream| redact_workstream_with_aliases(workstream, profile, aliases))
            .collect(),
        ..workstreams.clone()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use chrono::{NaiveDate, Utc};
    use shiplog_ids::{EventId, WorkstreamId};
    use shiplog_schema::event::*;
    use shiplog_schema::workstream::WorkstreamStats;

    fn alias(kind: &str, value: &str) -> String {
        let mut acc = 14695981039346656037u64;
        for byte in kind.bytes().chain(value.bytes()) {
            acc ^= u64::from(byte);
            acc = acc.wrapping_mul(1099511628211);
        }
        format!("{kind}-{acc:016x}")
    }

    fn sample_pr_event() -> EventEnvelope {
        EventEnvelope {
            id: EventId::from_parts(["x", "1"]),
            kind: EventKind::PullRequest,
            occurred_at: Utc::now(),
            actor: Actor {
                login: "a".into(),
                id: None,
            },
            repo: RepoRef {
                full_name: "org/repo".into(),
                html_url: Some("https://github.com/org/repo".into()),
                visibility: RepoVisibility::Private,
            },
            payload: EventPayload::PullRequest(PullRequestEvent {
                number: 1,
                title: "Highly Sensitive".into(),
                state: PullRequestState::Merged,
                created_at: Utc::now(),
                merged_at: Some(Utc::now()),
                additions: Some(10),
                deletions: Some(3),
                changed_files: Some(2),
                touched_paths_hint: vec!["secret/path.rs".into()],
                window: None,
            }),
            tags: vec![],
            links: vec![Link {
                label: "pr".into(),
                url: "https://github.com/org/repo/pull/1".into(),
            }],
            source: SourceRef {
                system: SourceSystem::Github,
                url: Some("https://api.github.com/repos/org/repo/pulls/1".into()),
                opaque_id: Some("github-pr-node-id".into()),
            },
        }
    }

    fn sample_workstreams() -> WorkstreamsFile {
        WorkstreamsFile {
            version: 1,
            generated_at: Utc::now(),
            workstreams: vec![Workstream {
                id: WorkstreamId::from_parts(["ws", "a"]),
                title: "Sensitive Platform Migration".into(),
                summary: Some("Internal architectural details".into()),
                tags: vec!["platform".into(), "repo".into()],
                stats: WorkstreamStats::zero(),
                events: vec![],
                receipts: vec![],
            }],
        }
    }

    #[test]
    fn profile_parser_defaults_unknown_to_public() {
        assert_eq!(
            RedactionProfile::from_profile_str("unexpected"),
            RedactionProfile::Public
        );
    }

    #[test]
    fn public_profile_redacts_pr_sensitive_fields() {
        let event = sample_pr_event();
        let out = redact_event_with_aliases(event, RedactionProfile::Public, &alias);

        assert!(out.links.is_empty());
        assert!(out.source.url.is_none());
        assert!(out.source.opaque_id.is_none());
        assert_ne!(out.repo.full_name, "org/repo");

        match out.payload {
            EventPayload::PullRequest(pr) => {
                assert_eq!(pr.title, "[redacted]");
                assert!(pr.touched_paths_hint.is_empty());
            }
            _ => panic!("expected pull request payload"),
        }
    }

    #[test]
    fn manager_profile_preserves_context_and_removes_detail_fields() {
        let mut event = sample_pr_event();
        event.payload = EventPayload::Manual(ManualEvent {
            event_type: ManualEventType::Incident,
            title: "Database incident".into(),
            description: Some("Sensitive details".into()),
            started_at: Some(NaiveDate::from_ymd_opt(2025, 1, 1).unwrap()),
            ended_at: Some(NaiveDate::from_ymd_opt(2025, 1, 1).unwrap()),
            impact: Some("Sensitive impact".into()),
        });

        let out = redact_event_with_aliases(event, RedactionProfile::Manager, &alias);
        match out.payload {
            EventPayload::Manual(m) => {
                assert_eq!(m.title, "Database incident");
                assert!(m.description.is_none());
                assert!(m.impact.is_none());
            }
            _ => panic!("expected manual payload"),
        }
    }

    #[test]
    fn workstream_public_profile_aliases_title_and_filters_repo_tag() {
        let ws_file = sample_workstreams();
        let out = redact_workstreams_with_aliases(&ws_file, RedactionProfile::Public, &alias);
        let ws = &out.workstreams[0];

        assert_ne!(ws.title, "Sensitive Platform Migration");
        assert!(ws.summary.is_none());
        assert!(!ws.tags.contains(&"repo".to_string()));
    }

    #[test]
    fn workstream_manager_profile_removes_summary_only() {
        let ws_file = sample_workstreams();
        let out = redact_workstreams_with_aliases(&ws_file, RedactionProfile::Manager, &alias);
        let ws = &out.workstreams[0];

        assert_eq!(ws.title, "Sensitive Platform Migration");
        assert!(ws.summary.is_none());
        assert!(ws.tags.contains(&"repo".to_string()));
    }

    #[test]
    fn closure_alias_resolver_is_supported() {
        let resolver = |kind: &str, value: &str| format!("{kind}:{value}");
        assert_eq!(resolver.alias("repo", "org/repo"), "repo:org/repo");
    }
}