Skip to main content

heddle_object_model/object/thread_replication/
hosted_import.rs

1//! Hosted import is an executor attestation of provider provenance. The original
2//! creator-signed genesis and imported Git authorship are never replaced by a
3//! claim that the initiating human signed a future source capture.
4use std::collections::BTreeSet;
5
6use serde::{Deserialize, Serialize};
7use uuid::Uuid;
8
9use super::{Capture, ThreadGenesis, ThreadOperation, bounded, invalid};
10use crate::{
11    error::Result,
12    object::{ContentHash, State},
13};
14
15pub const HOSTED_IMPORT_FORMAT: &str = "heddle-hosted-import-v1";
16
17/// Stable synthetic pre-history for every new Spool. This is system
18/// initialization, never an assertion of human capture authorship.
19pub fn synthetic_initial_base() -> Result<State> {
20    use crate::object::{Attribution, ChangeId, Principal, Tree};
21    let mut state = State::new_refresh_of(
22        Tree::new().hash(),
23        Vec::new(),
24        Attribution::human(Principal::new("Heddle", "init@heddle")),
25        ChangeId::from_bytes(*b"heddle-seed-v2!!"),
26    );
27    state.created_at = chrono::DateTime::UNIX_EPOCH;
28    // Restore the derived cached ID after fixing the canonical creation time.
29    State::decode_current_msgpack(&state.encode_current_msgpack()?)
30}
31
32/// Only the exact deterministic empty seed can bootstrap without an original
33/// source operation. A random empty State, even with Heddle attribution, is
34/// authored content and requires ordinary source provenance.
35pub fn initial_base_state(genesis: &ThreadGenesis, bytes: &[u8]) -> Result<State> {
36    if bytes.is_empty() || bytes.len() > 4096 {
37        return Err(invalid("initial Thread base exceeds bootstrap bound"));
38    }
39    let state = State::decode_current_msgpack(bytes)?;
40    let expected = synthetic_initial_base()?;
41    if state.id() != genesis.base || expected.encode_current_msgpack()? != bytes {
42        return Err(invalid(
43            "initial Thread base differs from signed empty seed",
44        ));
45    }
46    Ok(state)
47}
48
49/// Git's algorithm is part of identity; a SHA-256 object never truncates to SHA-1.
50#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
51#[serde(rename_all = "snake_case")]
52pub enum ImportedCommit {
53    Sha1([u8; 20]),
54    Sha256([u8; 32]),
55}
56
57#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
58#[serde(rename_all = "snake_case")]
59pub enum ImportProvider {
60    /// Connected GitHub App installation; `repository_id` is the numeric GitHub repo id.
61    GitHub { repository_id: String },
62    /// Credential-free public Git; `clone_url` is the exact clone URL and sole locator.
63    Git { clone_url: String },
64}
65
66impl ImportProvider {
67    fn is_valid(&self) -> bool {
68        let locator = match self {
69            Self::GitHub { repository_id } => repository_id,
70            Self::Git { clone_url } => clone_url,
71        };
72        !locator.is_empty() && locator.len() <= 4096 && !locator.chars().any(char::is_control)
73    }
74}
75
76#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
77#[serde(deny_unknown_fields)]
78pub struct HostedImport {
79    pub version: u16,
80    pub spool: Uuid,
81    pub spool_genesis: ContentHash,
82    pub executor: [u8; 32],
83    pub target_thread: ContentHash,
84    pub expected_target_frontier: BTreeSet<ContentHash>,
85    /// Thread source ancestry is independent of the retained Git history. The
86    /// executor preserves Git attribution and records its source commit below.
87    pub result: Capture,
88    pub provider: ImportProvider,
89    pub source_commit: ImportedCommit,
90    pub initiating_request_proof: ContentHash,
91    pub executed_at_ms: i64,
92}
93impl HostedImport {
94    pub fn encode(&self) -> Result<Vec<u8>> {
95        if self.version != 1
96            || self.spool.is_nil()
97            || self.expected_target_frontier.len() > 128
98            || self.executed_at_ms < 0
99            || !self.provider.is_valid()
100        {
101            return Err(invalid("invalid or unbounded hosted import receipt"));
102        }
103        let state = self.resulting_state()?;
104        if state.encode_current_msgpack()? != self.result.state {
105            return Err(invalid("non-canonical hosted import capture"));
106        }
107        let bytes = rmp_serde::to_vec_named(self)?;
108        bounded(&bytes)?;
109        Ok(bytes)
110    }
111    pub fn decode(bytes: &[u8]) -> Result<Self> {
112        bounded(bytes)?;
113        let value: Self = rmp_serde::from_slice(bytes)?;
114        if value.encode()? != bytes {
115            return Err(invalid("non-canonical hosted import receipt"));
116        }
117        Ok(value)
118    }
119    pub fn id(&self) -> Result<ContentHash> {
120        Ok(ContentHash::compute_typed(
121            HOSTED_IMPORT_FORMAT,
122            &self.encode()?,
123        ))
124    }
125    pub fn resulting_state(&self) -> Result<State> {
126        self.result.validated_state()
127    }
128    pub(super) fn validate_operation(&self, operation: &ThreadOperation) -> Result<()> {
129        if self.target_thread != operation.thread
130            || self.executor != operation.publisher
131            || self.expected_target_frontier != operation.parents
132        {
133            return Err(invalid(
134                "hosted import differs from signed executor, Thread or frontier",
135            ));
136        }
137        Ok(())
138    }
139    pub(super) fn validate_parents(
140        &self,
141        genesis: &ThreadGenesis,
142        parents: &[ThreadOperation],
143    ) -> Result<()> {
144        if self.spool.to_string() != genesis.spool {
145            return Err(invalid("hosted import belongs to another Spool"));
146        }
147        let state = self.resulting_state()?;
148        let mut expected = BTreeSet::new();
149        if parents.is_empty() {
150            expected.insert(genesis.base);
151        }
152        for parent in parents {
153            expected.insert(
154                parent
155                    .source_state()?
156                    .ok_or_else(|| invalid("hosted import parent is not source"))?
157                    .id(),
158            );
159        }
160        if state.parents.iter().copied().collect::<BTreeSet<_>>() != expected
161            || state.parents.len() != expected.len()
162        {
163            return Err(invalid(
164                "hosted import capture drops or invents Thread ancestry",
165            ));
166        }
167        Ok(())
168    }
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174    use crate::object::{
175        Attribution, Principal, StateId, Tree, thread_replication::ThreadOperationBody,
176    };
177    fn fixture() -> (ThreadGenesis, HostedImport, ThreadOperation) {
178        let genesis = ThreadGenesis {
179            version: 1,
180            spool: Uuid::from_u128(7).to_string(),
181            parent: None,
182            base: StateId::from_bytes([1; 32]),
183            name: "import".into(),
184            intent: "import Git history".into(),
185            owner: crate::object::thread_replication::GenesisOwner::Account(Uuid::from_u128(12)),
186            creator: [2; 32],
187            nonce: vec![3; 16],
188        };
189        let state = State::new_snapshot(
190            Tree::new().hash(),
191            vec![genesis.base],
192            Attribution::human(Principal::new("Git author", "git@example.test")),
193        );
194        let receipt = HostedImport {
195            version: 1,
196            spool: Uuid::from_u128(7),
197            spool_genesis: ContentHash::from_bytes([4; 32]),
198            executor: [5; 32],
199            target_thread: genesis.id().expect("Thread"),
200            expected_target_frontier: BTreeSet::new(),
201            result: state.encode_current_msgpack().expect("source").into(),
202            provider: ImportProvider::GitHub {
203                repository_id: "123".into(),
204            },
205            source_commit: ImportedCommit::Sha1([6; 20]),
206            initiating_request_proof: ContentHash::from_bytes([7; 32]),
207            executed_at_ms: 100,
208        };
209        let operation = ThreadOperation {
210            version: 1,
211            thread: receipt.target_thread,
212            parents: BTreeSet::new(),
213            publisher: receipt.executor,
214            body: ThreadOperationBody::HostedImport(receipt.encode().expect("receipt")),
215        };
216        (genesis, receipt, operation)
217    }
218    #[test]
219    fn public_git_provider_round_trips_and_validates() {
220        let (_, mut receipt, _) = fixture();
221        receipt.provider = ImportProvider::Git {
222            clone_url: "https://example.test/owner/repository.git".into(),
223        };
224
225        let bytes = receipt.encode().expect("valid public Git provider");
226        assert_eq!(
227            HostedImport::decode(&bytes).expect("canonical public Git provider"),
228            receipt
229        );
230    }
231    #[test]
232    fn provider_locators_are_bounded_and_free_of_control_characters() {
233        let (_, mut receipt, _) = fixture();
234        for provider in [
235            ImportProvider::GitHub {
236                repository_id: String::new(),
237            },
238            ImportProvider::GitHub {
239                repository_id: "x".repeat(4097),
240            },
241            ImportProvider::Git {
242                clone_url: "https://example.test/repository.git\n".into(),
243            },
244        ] {
245            receipt.provider = provider;
246            assert!(receipt.encode().is_err());
247        }
248    }
249    #[test]
250    fn import_preserves_git_attribution_and_binds_executor_scope_and_ancestry() {
251        let (genesis, receipt, operation) = fixture();
252        operation.validate_parents(&genesis, &[]).expect("import");
253        let decoded =
254            ThreadOperation::decode(&operation.encode().expect("canonical")).expect("decode");
255        assert_eq!(decoded, operation);
256        assert_eq!(
257            decoded
258                .source_state()
259                .expect("state")
260                .expect("capture")
261                .attribution
262                .principal
263                .name,
264            b"Git author".to_vec()
265        );
266        assert_ne!(
267            operation.publisher, genesis.creator,
268            "executor does not impersonate creator"
269        );
270        let mut changed = operation.clone();
271        changed.publisher = genesis.creator;
272        assert!(changed.encode().is_err());
273        changed = operation.clone();
274        changed.parents.insert(ContentHash::from_bytes([99; 32]));
275        assert!(changed.encode().is_err());
276        let mut foreign = receipt.clone();
277        foreign.spool = Uuid::from_u128(8);
278        changed = operation.clone();
279        changed.body = ThreadOperationBody::HostedImport(foreign.encode().expect("structural"));
280        assert!(changed.validate_parents(&genesis, &[]).is_err());
281        let mut wrong = receipt;
282        let mut state = wrong.resulting_state().expect("capture");
283        state.parents.clear();
284        wrong.result.state = state.encode_current_msgpack().expect("wrong ancestry");
285        changed = operation;
286        changed.body = ThreadOperationBody::HostedImport(wrong.encode().expect("structural"));
287        assert!(changed.validate_parents(&genesis, &[]).is_err());
288    }
289    #[test]
290    fn later_capture_retains_imported_source_and_reference_frontier() {
291        let (genesis, mut receipt, mut imported) = fixture();
292        receipt.result.source_targets = Some(ContentHash::from_bytes([17; 32]));
293        imported.body = ThreadOperationBody::HostedImport(receipt.encode().expect("receipt"));
294        let state = State::new_snapshot(
295            Tree::new().hash(),
296            vec![receipt.resulting_state().expect("source").id()],
297            Attribution::human(Principal::new("Human", "human@example.test")),
298        );
299        let mut capture = ThreadOperation {
300            version: 1,
301            thread: imported.thread,
302            parents: BTreeSet::from([imported.id().expect("parent")]),
303            publisher: genesis.creator,
304            body: ThreadOperationBody::Capture(
305                crate::object::thread_replication::AuthoredCapture::local(Capture {
306                    state: state.encode_current_msgpack().expect("capture"),
307                    source_targets: receipt.result.source_targets,
308                    visibility: receipt.result.visibility.clone(),
309                }),
310            ),
311        };
312        capture
313            .validate_parents(&genesis, std::slice::from_ref(&imported))
314            .expect("later capture");
315        let ThreadOperationBody::Capture(result) = &mut capture.body else {
316            panic!("capture")
317        };
318        result.result.source_targets = None;
319        assert!(
320            capture.validate_parents(&genesis, &[imported]).is_err(),
321            "imported references cannot disappear"
322        );
323    }
324    #[test]
325    fn genesis_local_ownership_is_creator_bound_and_account_ownership_is_explicit() {
326        use crate::object::thread_replication::GenesisOwner;
327        let (mut genesis, _, _) = fixture();
328        let account_id = genesis.id().expect("account-owned identity");
329        genesis.owner = GenesisOwner::LocalKey(genesis.creator);
330        let local_id = genesis.id().expect("local key needs no account");
331        assert_ne!(account_id, local_id, "owner is part of immutable identity");
332        genesis.owner = GenesisOwner::LocalKey([99; 32]);
333        assert!(
334            genesis.encode().is_err(),
335            "local owner must sign its genesis"
336        );
337        genesis.owner = GenesisOwner::LocalKey([0; 32]);
338        genesis.creator = [0; 32];
339        assert!(genesis.encode().is_err(), "zero local key is not an owner");
340        genesis.owner = GenesisOwner::Account(Uuid::nil());
341        assert!(genesis.encode().is_err(), "nil account is not an owner");
342    }
343    #[test]
344    fn import_initial_base_is_exact_bounded_empty_seed() {
345        let (mut genesis, _, _) = fixture();
346        let seed = synthetic_initial_base().expect("known system seed");
347        let bytes = seed.encode_current_msgpack().expect("seed");
348        genesis.base = seed.id();
349        assert_eq!(
350            initial_base_state(&genesis, &bytes)
351                .expect("one-call bootstrap")
352                .id(),
353            seed.id()
354        );
355        let random_seed = State::new_snapshot(
356            Tree::new().hash(),
357            vec![],
358            Attribution::human(Principal::new("Heddle", "init@heddle")),
359        );
360        genesis.base = random_seed.id();
361        assert!(
362            initial_base_state(
363                &genesis,
364                &random_seed.encode_current_msgpack().expect("random seed")
365            )
366            .is_err(),
367            "the old random empty seed shape is not a bootstrap exception"
368        );
369        genesis.base = seed.id();
370        let mut changed = seed.clone();
371        changed.tree = ContentHash::from_bytes([88; 32]);
372        genesis.base = changed.id();
373        assert!(
374            initial_base_state(
375                &genesis,
376                &changed.encode_current_msgpack().expect("changed")
377            )
378            .is_err(),
379            "nonempty source needs authorized closure transfer"
380        );
381        changed = seed.clone();
382        changed.provenance = Some(ContentHash::from_bytes([89; 32]));
383        genesis.base = changed.id();
384        assert!(
385            initial_base_state(
386                &genesis,
387                &changed.encode_current_msgpack().expect("changed")
388            )
389            .is_err(),
390            "seed cannot introduce another reference"
391        );
392        assert!(
393            initial_base_state(&genesis, &bytes).is_err(),
394            "base must match signed identity"
395        );
396    }
397    #[test]
398    fn synthetic_initial_base_is_stable_across_rust_and_browser() {
399        let state = synthetic_initial_base().expect("synthetic seed");
400        let bytes = state.encode_current_msgpack().expect("canonical seed");
401        let expected = include_str!("../../../tests/fixtures/synthetic-initial-base-v2.txt");
402        assert_eq!(
403            format!(
404                "canonical={}\nid={}\n",
405                hex::encode(&bytes),
406                hex::encode(state.id().as_bytes())
407            ),
408            expected
409        );
410        assert_eq!(
411            bytes,
412            synthetic_initial_base()
413                .expect("repeat")
414                .encode_current_msgpack()
415                .expect("repeat bytes")
416        );
417        let (mut genesis, _, _) = fixture();
418        genesis.base = state.id();
419        initial_base_state(&genesis, &bytes).expect("accepted seed shape");
420    }
421}