Skip to main content

mkit_git_bridge/
headers.rs

1//! `mkit-*` header names and value encodings (SPEC-GIT-BRIDGE §6, §7).
2
3use crate::b64;
4use crate::gitobj::{bytes_from_hex, bytes_hex};
5use mkit_core::object::{IDENTITY_MAX_LEN, Identity, IdentityKind};
6
7/// Header names, in the exact emission order the spec pins for
8/// commits (§6.1). `MKIT_PARENT` repeats; the two annotation headers
9/// are emitted only when non-zero.
10pub const MKIT_SCHEMA: &str = "mkit-schema";
11pub const MKIT_AUTHOR: &str = "mkit-author";
12pub const MKIT_TAGGER: &str = "mkit-tagger";
13pub const MKIT_SIGNER: &str = "mkit-signer";
14pub const MKIT_SIGNATURE: &str = "mkit-signature";
15pub const MKIT_TREE: &str = "mkit-tree";
16pub const MKIT_PARENT: &str = "mkit-parent";
17pub const MKIT_MESSAGE_HASH: &str = "mkit-message-hash";
18pub const MKIT_CONTENT_DIGEST: &str = "mkit-content-digest";
19pub const MKIT_TARGET: &str = "mkit-target";
20pub const MKIT_TARGET_TYPE: &str = "mkit-target-type";
21
22/// Reserved by §8 for the future remix mapping — never emitted, and
23/// reconstruction rejects them so a v1 verifier cannot silently
24/// accept a future-format object.
25pub const RESERVED: &[&str] = &["mkit-remix-source", "mkit-object-type"];
26
27/// The schema version this mapping covers (§1.2).
28pub const SCHEMA_VALUE: &str = "1";
29
30/// Encode an identity header value: `<kind-hex2>:<unpadded base64>`.
31#[must_use]
32pub fn identity_value(identity: &Identity) -> String {
33    format!(
34        "{:02x}:{}",
35        identity.kind as u8,
36        b64::encode(&identity.bytes)
37    )
38}
39
40/// Strict inverse of [`identity_value`].
41#[must_use]
42pub fn parse_identity(value: &str) -> Option<Identity> {
43    let (kind_hex, payload_b64) = value.split_once(':')?;
44    let kind_bytes = bytes_from_hex(kind_hex, 1)?;
45    let kind = match kind_bytes[0] {
46        0x01 => IdentityKind::Ed25519,
47        0x02 => IdentityKind::DidKey,
48        0x03 => IdentityKind::Opaque,
49        _ => return None,
50    };
51    let bytes = b64::decode(payload_b64)?;
52    if bytes.is_empty() || bytes.len() > IDENTITY_MAX_LEN as usize {
53        return None;
54    }
55    let id = Identity { kind, bytes };
56    id.is_valid().then_some(id)
57}
58
59/// Encode a 32-byte hash header value.
60#[must_use]
61pub fn hash_value(h: &[u8; 32]) -> String {
62    bytes_hex(h)
63}
64
65/// Strict 32-byte lowercase-hex decode.
66#[must_use]
67pub fn parse_hash(value: &str) -> Option<[u8; 32]> {
68    let v = bytes_from_hex(value, 32)?;
69    let mut out = [0u8; 32];
70    out.copy_from_slice(&v);
71    Some(out)
72}
73
74/// Strict 64-byte lowercase-hex decode (signatures).
75#[must_use]
76pub fn parse_signature(value: &str) -> Option<[u8; 64]> {
77    let v = bytes_from_hex(value, 64)?;
78    let mut out = [0u8; 64];
79    out.copy_from_slice(&v);
80    Some(out)
81}
82
83#[cfg(test)]
84mod tests {
85    use super::*;
86
87    #[test]
88    fn identity_round_trips_all_kinds() {
89        for id in [
90            Identity::ed25519([7; 32]),
91            Identity {
92                kind: IdentityKind::DidKey,
93                bytes: b"z6Mk".to_vec(),
94            },
95            Identity::opaque(vec![0xFF, 0x00, 0x10]),
96        ] {
97            let v = identity_value(&id);
98            assert_eq!(parse_identity(&v).unwrap(), id, "value {v}");
99        }
100    }
101
102    #[test]
103    fn identity_rejects_bad_kind_and_empty() {
104        assert!(parse_identity("04:QQ").is_none());
105        assert!(parse_identity("03:").is_none());
106        assert!(
107            parse_identity("3:QQ").is_none(),
108            "kind must be two hex digits"
109        );
110    }
111
112    #[test]
113    fn hash_value_round_trips() {
114        let h = [0x5A; 32];
115        assert_eq!(parse_hash(&hash_value(&h)).unwrap(), h);
116        assert!(parse_hash("zz").is_none());
117    }
118}