Skip to main content

heddle_object_model/object/thread_replication/
source_author.rs

1//! Original source authorship is signed with the capture, independently of its
2//! courier. These codecs bind claims; admission verifies the actual authority.
3use serde::{Deserialize, Serialize};
4use uuid::Uuid;
5
6use super::{Capture, metadata::AUTHORITY_FORMAT};
7use crate::{
8    error::{HeddleError, Result},
9    object::{CollaborationActor, ContentHash},
10};
11
12pub const MAX_SOURCE_AUTHORITY_BYTES: usize = 64 * 1024;
13/// Original source-write authority, independent of the courier's transport RPC.
14pub const SOURCE_AUTHORIZATION_METHOD: &str = "/heddle.api.v1alpha2.SyncService/PublishContent";
15
16#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
18pub enum SourceAuthor {
19    /// The operation publisher is the original local key. A receiver must
20    /// independently establish its ownership/delegation for this exact Thread.
21    LocalKey,
22    Account {
23        spool: Uuid,
24        actor: CollaborationActor,
25        authority_digest: ContentHash,
26        #[serde(with = "serde_bytes")]
27        authority: Vec<u8>,
28    },
29}
30impl SourceAuthor {
31    pub fn account(spool: Uuid, actor: CollaborationActor, authority: Vec<u8>) -> Result<Self> {
32        let value = Self::Account {
33            spool,
34            actor,
35            authority_digest: ContentHash::compute_typed(AUTHORITY_FORMAT, &authority),
36            authority,
37        };
38        value.validate()?;
39        Ok(value)
40    }
41
42    pub fn validate(&self) -> Result<()> {
43        if let Self::Account {
44            spool,
45            actor,
46            authority_digest,
47            authority,
48        } = self
49        {
50            if spool.is_nil()
51                || actor.principal_id.is_nil()
52                || actor.agent_id.as_ref().is_some_and(|id| {
53                    id.is_empty() || id.len() > 256 || id.chars().any(char::is_control)
54                })
55            {
56                return Err(invalid("invalid original source author identity"));
57            }
58            if authority.is_empty()
59                || authority.len() > MAX_SOURCE_AUTHORITY_BYTES
60                || ContentHash::compute_typed(AUTHORITY_FORMAT, authority) != *authority_digest
61            {
62                return Err(invalid("invalid original source authority binding"));
63            }
64        }
65        Ok(())
66    }
67}
68
69/// Authorship belongs to a human/device capture, not to derived integration
70/// results. Hosted integration/import retain their independent executor proof.
71#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
72#[serde(deny_unknown_fields)]
73pub struct AuthoredCapture {
74    pub result: Capture,
75    pub author: SourceAuthor,
76}
77impl AuthoredCapture {
78    pub fn local(result: Capture) -> Self {
79        Self {
80            result,
81            author: SourceAuthor::LocalKey,
82        }
83    }
84    pub fn account(
85        result: Capture,
86        spool: Uuid,
87        actor: CollaborationActor,
88        authority: Vec<u8>,
89    ) -> Result<Self> {
90        Ok(Self {
91            result,
92            author: SourceAuthor::account(spool, actor, authority)?,
93        })
94    }
95}
96fn invalid(message: &str) -> HeddleError {
97    HeddleError::InvalidObject(message.into())
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103
104    fn actor() -> CollaborationActor {
105        CollaborationActor {
106            principal_id: Uuid::from_u128(1),
107            agent_id: Some("capture-agent".into()),
108        }
109    }
110    #[test]
111    fn original_source_authority_binds_exact_envelope_and_bounded_identity() {
112        let author = SourceAuthor::account(Uuid::from_u128(2), actor(), vec![3; 32])
113            .expect("bounded authority");
114        let SourceAuthor::Account {
115            authority_digest, ..
116        } = &author
117        else {
118            panic!("account author")
119        };
120        assert_eq!(
121            *authority_digest,
122            ContentHash::compute_typed(AUTHORITY_FORMAT, &[3; 32])
123        );
124        let mut changed = author;
125        let SourceAuthor::Account { authority, .. } = &mut changed else {
126            panic!("account author")
127        };
128        authority[0] ^= 1;
129        assert!(
130            changed.validate().is_err(),
131            "authority bytes cannot change under original digest"
132        );
133        assert!(SourceAuthor::account(Uuid::nil(), actor(), vec![1]).is_err());
134        assert!(SourceAuthor::account(Uuid::from_u128(2), actor(), vec![]).is_err());
135        assert!(
136            SourceAuthor::account(
137                Uuid::from_u128(2),
138                actor(),
139                vec![1; MAX_SOURCE_AUTHORITY_BYTES + 1]
140            )
141            .is_err()
142        );
143        let mut invalid_agent = actor();
144        invalid_agent.agent_id = Some("\n".into());
145        assert!(SourceAuthor::account(Uuid::from_u128(2), invalid_agent, vec![1]).is_err());
146        SourceAuthor::LocalKey
147            .validate()
148            .expect("local key needs no account enrollment");
149    }
150}