Skip to main content

mkit_git_bridge/
author.rs

1//! Author/committer/tagger line synthesis (SPEC-GIT-BRIDGE §6.2).
2//!
3//! The line is display-only — reconstruction reads the `mkit-author` /
4//! `mkit-tagger` header — but it is part of the hashed git bytes, so
5//! the synthesis is normative and a pure function of the identity
6//! payload and timestamp.
7
8use crate::b64;
9use mkit_core::object::{Identity, IdentityKind};
10
11/// Fixed, never-routable email (RFC 2606 reserved TLD).
12pub const BRIDGE_EMAIL: &str = "bridge@mkit.invalid";
13
14/// Bytes that may not appear in a git author-line name slot.
15fn name_byte_ok(b: u8) -> bool {
16    !(b == b'<' || b == b'>' || b < 0x20 || b == 0x7F)
17}
18
19/// Deterministic display name for an identity (§6.2).
20#[must_use]
21pub fn display_name(identity: &Identity) -> String {
22    match identity.kind {
23        IdentityKind::Ed25519 => {
24            format!("mkit:ed25519:{}", crate::gitobj::bytes_hex(&identity.bytes))
25        }
26        IdentityKind::DidKey => {
27            if identity.bytes.iter().all(|&b| name_byte_ok(b))
28                && let Ok(s) = std::str::from_utf8(&identity.bytes)
29            {
30                format!("did:key:{s}")
31            } else {
32                opaque_name(&identity.bytes)
33            }
34        }
35        IdentityKind::Opaque => {
36            if identity.bytes.iter().all(|&b| name_byte_ok(b))
37                && let Ok(s) = std::str::from_utf8(&identity.bytes)
38            {
39                s.to_owned()
40            } else {
41                opaque_name(&identity.bytes)
42            }
43        }
44    }
45}
46
47fn opaque_name(payload: &[u8]) -> String {
48    format!("mkit:opaque:{}", b64::encode(payload))
49}
50
51/// Full synthesized line value: `<name> <email> <ts> +0000`.
52///
53/// The caller has already rejected timestamps above `i64::MAX`
54/// (`Refusal::TimestampOverflow`), so this renders unconditionally.
55#[must_use]
56pub fn line(identity: &Identity, timestamp: u64) -> Vec<u8> {
57    let mut out = Vec::new();
58    out.extend_from_slice(display_name(identity).as_bytes());
59    out.extend_from_slice(b" <");
60    out.extend_from_slice(BRIDGE_EMAIL.as_bytes());
61    out.extend_from_slice(b"> ");
62    out.extend_from_slice(timestamp.to_string().as_bytes());
63    out.extend_from_slice(b" +0000");
64    out
65}
66
67/// Extract the timestamp back out of a synthesized line
68/// (reconstruction path). Returns `None` when the line does not match
69/// the bridge shape.
70#[must_use]
71pub fn parse_timestamp(line: &[u8]) -> Option<u64> {
72    // ... <email>> <ts> +0000
73    let s = std::str::from_utf8(line).ok()?;
74    let rest = s.strip_suffix(" +0000")?;
75    let (_, ts) = rest.rsplit_once(' ')?;
76    ts.parse::<u64>().ok()
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82
83    #[test]
84    fn ed25519_name_is_hex() {
85        let id = Identity::ed25519([0xAA; 32]);
86        let n = display_name(&id);
87        assert!(n.starts_with("mkit:ed25519:aaaa"));
88        assert_eq!(n.len(), "mkit:ed25519:".len() + 64);
89    }
90
91    #[test]
92    fn printable_opaque_is_verbatim() {
93        let id = Identity::opaque(b"Alice Example".to_vec());
94        assert_eq!(display_name(&id), "Alice Example");
95    }
96
97    #[test]
98    fn angle_bracket_opaque_falls_back_to_base64() {
99        let id = Identity::opaque(b"Alice <alice@example.com>".to_vec());
100        let n = display_name(&id);
101        assert!(n.starts_with("mkit:opaque:"), "got {n}");
102        assert!(!n.contains('<'));
103    }
104
105    #[test]
106    fn non_utf8_opaque_falls_back_to_base64() {
107        let id = Identity::opaque(vec![0xFF, 0xFE, 0x00]);
108        assert!(display_name(&id).starts_with("mkit:opaque:"));
109    }
110
111    #[test]
112    fn line_round_trips_timestamp() {
113        let id = Identity::opaque(b"x".to_vec());
114        let l = line(&id, 1_700_000_000);
115        assert!(l.ends_with(b" +0000"));
116        assert_eq!(parse_timestamp(&l), Some(1_700_000_000));
117    }
118}