1use 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
17pub 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 State::decode_current_msgpack(&state.encode_current_msgpack()?)
30}
31
32pub 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#[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 GitHub { repository_id: String },
62 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 pub result: Capture,
88 pub provider: ImportProvider,
89 pub source_ref: String,
90 pub source_commit: ImportedCommit,
91 pub initiating_request_proof: ContentHash,
92 pub executed_at_ms: i64,
93}
94impl HostedImport {
95 pub fn encode(&self) -> Result<Vec<u8>> {
96 if self.version != 1
97 || self.spool.is_nil()
98 || self.expected_target_frontier.len() > 128
99 || self.executed_at_ms < 0
100 || !self.provider.is_valid()
101 || self.source_ref.len() > 4096
102 || !self.source_ref.starts_with("refs/heads/")
103 || self.source_ref.chars().any(char::is_control)
104 {
105 return Err(invalid("invalid or unbounded hosted import receipt"));
106 }
107 let state = self.resulting_state()?;
108 if state.encode_current_msgpack()? != self.result.state {
109 return Err(invalid("non-canonical hosted import capture"));
110 }
111 let bytes = rmp_serde::to_vec_named(self)?;
112 bounded(&bytes)?;
113 Ok(bytes)
114 }
115 pub fn decode(bytes: &[u8]) -> Result<Self> {
116 bounded(bytes)?;
117 let value: Self = rmp_serde::from_slice(bytes)?;
118 if value.encode()? != bytes {
119 return Err(invalid("non-canonical hosted import receipt"));
120 }
121 Ok(value)
122 }
123 pub fn id(&self) -> Result<ContentHash> {
124 Ok(ContentHash::compute_typed(
125 HOSTED_IMPORT_FORMAT,
126 &self.encode()?,
127 ))
128 }
129 pub fn resulting_state(&self) -> Result<State> {
130 self.result.validated_state()
131 }
132 pub(super) fn validate_operation(&self, operation: &ThreadOperation) -> Result<()> {
133 if self.target_thread != operation.thread
134 || self.executor != operation.publisher
135 || self.expected_target_frontier != operation.parents
136 {
137 return Err(invalid(
138 "hosted import differs from signed executor, Thread or frontier",
139 ));
140 }
141 Ok(())
142 }
143 pub(super) fn validate_parents(
147 &self,
148 genesis: &ThreadGenesis,
149 parents: &[ThreadOperation],
150 ) -> Result<()> {
151 if self.spool.to_string() != genesis.spool {
152 return Err(invalid("hosted import belongs to another Spool"));
153 }
154 if self.source_ref != format!("refs/heads/{}", genesis.name) {
155 return Err(invalid(
156 "hosted import source ref differs from signed Thread name",
157 ));
158 }
159 let state = self.resulting_state()?;
160 let mut expected = BTreeSet::new();
161 if parents.is_empty() {
162 if genesis.base != synthetic_initial_base()?.id() || genesis.parent.is_some() {
163 return Err(invalid(
164 "initial hosted import requires the canonical empty seed",
165 ));
166 }
167 if state.parents.contains(&genesis.base) {
168 return Err(invalid(
169 "Git tip cannot have the synthetic seed as a parent",
170 ));
171 }
172 return Ok(());
173 }
174 for parent in parents {
175 expected.insert(
176 parent
177 .source_state()?
178 .ok_or_else(|| invalid("hosted import parent is not source"))?
179 .id(),
180 );
181 }
182 if state.parents.iter().copied().collect::<BTreeSet<_>>() != expected
183 || state.parents.len() != expected.len()
184 {
185 return Err(invalid(
186 "hosted import capture drops or invents Thread ancestry",
187 ));
188 }
189 Ok(())
190 }
191}
192
193#[cfg(test)]
194mod tests {
195 use super::*;
196 use crate::object::{
197 Attribution, Principal, StateId, Tree, thread_replication::ThreadOperationBody,
198 };
199 fn fixture() -> (ThreadGenesis, HostedImport, ThreadOperation) {
200 let genesis = ThreadGenesis {
201 version: 1,
202 spool: Uuid::from_u128(7).to_string(),
203 parent: None,
204 base: synthetic_initial_base().expect("seed").id(),
205 name: "import".into(),
206 intent: "import Git history".into(),
207 owner: crate::object::thread_replication::GenesisOwner::Account(Uuid::from_u128(12)),
208 creator: [2; 32],
209 nonce: vec![3; 16],
210 };
211 let state = State::new_snapshot(
212 Tree::new().hash(),
213 vec![StateId::from_bytes([31; 32])],
214 Attribution::human(Principal::new("Git author", "git@example.test")),
215 );
216 let receipt = HostedImport {
217 version: 1,
218 spool: Uuid::from_u128(7),
219 spool_genesis: ContentHash::from_bytes([4; 32]),
220 executor: [5; 32],
221 target_thread: genesis.id().expect("Thread"),
222 expected_target_frontier: BTreeSet::new(),
223 result: state.encode_current_msgpack().expect("source").into(),
224 provider: ImportProvider::GitHub {
225 repository_id: "123".into(),
226 },
227 source_ref: "refs/heads/import".into(),
228 source_commit: ImportedCommit::Sha1([6; 20]),
229 initiating_request_proof: ContentHash::from_bytes([7; 32]),
230 executed_at_ms: 100,
231 };
232 let operation = ThreadOperation {
233 version: 1,
234 thread: receipt.target_thread,
235 parents: BTreeSet::new(),
236 publisher: receipt.executor,
237 body: ThreadOperationBody::HostedImport(receipt.encode().expect("receipt")),
238 };
239 (genesis, receipt, operation)
240 }
241 #[test]
242 fn initial_import_accepts_real_git_parents_and_root() {
243 let (genesis, mut receipt, mut operation) = fixture();
244 for parents in [
245 vec![StateId::from_bytes([31; 32]), StateId::from_bytes([32; 32])],
246 vec![],
247 ] {
248 let mut state = receipt.resulting_state().expect("capture");
249 state.parents = parents;
250 receipt.result.state = state.encode_current_msgpack().expect("Git tip");
251 operation.body = ThreadOperationBody::HostedImport(receipt.encode().expect("receipt"));
252 operation
253 .validate_parents(&genesis, &[])
254 .expect("real Git ancestry, including root");
255 }
256 }
257 #[test]
258 fn initial_import_rejects_another_branch() {
259 let (genesis, mut receipt, mut operation) = fixture();
260 receipt.source_ref = "refs/heads/another".into();
261 operation.body = ThreadOperationBody::HostedImport(receipt.encode().expect("receipt"));
262 assert!(operation.validate_parents(&genesis, &[]).is_err());
263 }
264
265 #[test]
266 fn initial_import_rejects_non_head_refs() {
267 let (_, mut receipt, _) = fixture();
268 for source_ref in ["refs/tags/import", "import"] {
269 receipt.source_ref = source_ref.into();
270 assert!(receipt.encode().is_err(), "{source_ref}");
271 }
272 }
273
274 #[test]
275 fn initial_import_rejects_seed_among_git_parents() {
276 let (genesis, mut receipt, mut operation) = fixture();
277 let mut state = receipt.resulting_state().expect("state");
278 state.parents.push(genesis.base);
279 receipt.result.state = state.encode_current_msgpack().expect("seed parent");
280 operation.body = ThreadOperationBody::HostedImport(receipt.encode().expect("receipt"));
281 assert!(operation.validate_parents(&genesis, &[]).is_err());
282 }
283
284 #[test]
285 fn later_hosted_import_rejects_another_branch() {
286 let (genesis, mut receipt, imported) = fixture();
287 receipt.expected_target_frontier = BTreeSet::from([imported.id().expect("ID")]);
288 let mut state = receipt.resulting_state().expect("state");
289 state.parents = vec![state.id()];
290 receipt.result.state = state.encode_current_msgpack().expect("later capture");
291 let mut operation = ThreadOperation {
292 parents: receipt.expected_target_frontier.clone(),
293 body: ThreadOperationBody::HostedImport(receipt.encode().expect("receipt")),
294 ..imported.clone()
295 };
296 operation
297 .validate_parents(&genesis, std::slice::from_ref(&imported))
298 .expect("matching branch and causal parent");
299 receipt.source_ref = "refs/heads/another".into();
300 operation.body = ThreadOperationBody::HostedImport(receipt.encode().expect("receipt"));
301 assert!(operation.validate_parents(&genesis, &[imported]).is_err());
302 }
303
304 #[test]
305 fn later_hosted_import_keeps_the_causal_parent_rule() {
306 let (genesis, mut receipt, imported) = fixture();
307 receipt.expected_target_frontier = BTreeSet::from([imported.id().expect("ID")]);
308 let parent = receipt.resulting_state().expect("parent").id();
309 let mut state = receipt.resulting_state().expect("state");
310 state.parents = vec![parent];
311 receipt.result.state = state.encode_current_msgpack().expect("later capture");
312 receipt
313 .validate_parents(&genesis, std::slice::from_ref(&imported))
314 .expect("causal parents");
315 state.parents.clear();
316 receipt.result.state = state
317 .encode_current_msgpack()
318 .expect("missing causal parent");
319 assert!(receipt.validate_parents(&genesis, &[imported]).is_err());
320 }
321 #[test]
322 fn public_git_provider_round_trips_and_validates() {
323 let (_, mut receipt, _) = fixture();
324 receipt.provider = ImportProvider::Git {
325 clone_url: "https://example.test/owner/repository.git".into(),
326 };
327
328 let bytes = receipt.encode().expect("valid public Git provider");
329 assert_eq!(
330 HostedImport::decode(&bytes).expect("canonical public Git provider"),
331 receipt
332 );
333 }
334 #[test]
335 fn provider_locators_are_bounded_and_free_of_control_characters() {
336 let (_, mut receipt, _) = fixture();
337 for provider in [
338 ImportProvider::GitHub {
339 repository_id: String::new(),
340 },
341 ImportProvider::GitHub {
342 repository_id: "x".repeat(4097),
343 },
344 ImportProvider::Git {
345 clone_url: "https://example.test/repository.git\n".into(),
346 },
347 ] {
348 receipt.provider = provider;
349 assert!(receipt.encode().is_err());
350 }
351 }
352 #[test]
353 fn import_preserves_git_attribution_and_binds_executor_scope_and_ancestry() {
354 let (genesis, receipt, operation) = fixture();
355 operation.validate_parents(&genesis, &[]).expect("import");
356 let decoded =
357 ThreadOperation::decode(&operation.encode().expect("canonical")).expect("decode");
358 assert_eq!(decoded, operation);
359 assert_eq!(
360 decoded
361 .source_state()
362 .expect("state")
363 .expect("capture")
364 .attribution
365 .principal
366 .name,
367 b"Git author".to_vec()
368 );
369 assert_ne!(
370 operation.publisher, genesis.creator,
371 "executor does not impersonate creator"
372 );
373 let mut changed = operation.clone();
374 changed.publisher = genesis.creator;
375 assert!(changed.encode().is_err());
376 changed = operation.clone();
377 changed.parents.insert(ContentHash::from_bytes([99; 32]));
378 assert!(changed.encode().is_err());
379 let mut foreign = receipt.clone();
380 foreign.spool = Uuid::from_u128(8);
381 changed = operation.clone();
382 changed.body = ThreadOperationBody::HostedImport(foreign.encode().expect("structural"));
383 assert!(changed.validate_parents(&genesis, &[]).is_err());
384 let mut wrong = receipt;
385 let mut state = wrong.resulting_state().expect("capture");
386 state.parents = vec![genesis.base];
387 wrong.result.state = state.encode_current_msgpack().expect("wrong ancestry");
388 changed = operation;
389 changed.body = ThreadOperationBody::HostedImport(wrong.encode().expect("structural"));
390 assert!(changed.validate_parents(&genesis, &[]).is_err());
391 }
392 #[test]
393 fn later_capture_retains_imported_source_and_reference_frontier() {
394 let (genesis, mut receipt, mut imported) = fixture();
395 receipt.result.source_targets = Some(ContentHash::from_bytes([17; 32]));
396 imported.body = ThreadOperationBody::HostedImport(receipt.encode().expect("receipt"));
397 let state = State::new_snapshot(
398 Tree::new().hash(),
399 vec![receipt.resulting_state().expect("source").id()],
400 Attribution::human(Principal::new("Human", "human@example.test")),
401 );
402 let mut capture = ThreadOperation {
403 version: 1,
404 thread: imported.thread,
405 parents: BTreeSet::from([imported.id().expect("parent")]),
406 publisher: genesis.creator,
407 body: ThreadOperationBody::Capture(
408 crate::object::thread_replication::AuthoredCapture::local(Capture {
409 state: state.encode_current_msgpack().expect("capture"),
410 source_targets: receipt.result.source_targets,
411 visibility: receipt.result.visibility.clone(),
412 }),
413 ),
414 };
415 capture
416 .validate_parents(&genesis, std::slice::from_ref(&imported))
417 .expect("later capture");
418 let ThreadOperationBody::Capture(result) = &mut capture.body else {
419 panic!("capture")
420 };
421 result.result.source_targets = None;
422 assert!(
423 capture.validate_parents(&genesis, &[imported]).is_err(),
424 "imported references cannot disappear"
425 );
426 }
427 #[test]
428 fn genesis_local_ownership_is_creator_bound_and_account_ownership_is_explicit() {
429 use crate::object::thread_replication::GenesisOwner;
430 let (mut genesis, _, _) = fixture();
431 let account_id = genesis.id().expect("account-owned identity");
432 genesis.owner = GenesisOwner::LocalKey(genesis.creator);
433 let local_id = genesis.id().expect("local key needs no account");
434 assert_ne!(account_id, local_id, "owner is part of immutable identity");
435 genesis.owner = GenesisOwner::LocalKey([99; 32]);
436 assert!(
437 genesis.encode().is_err(),
438 "local owner must sign its genesis"
439 );
440 genesis.owner = GenesisOwner::LocalKey([0; 32]);
441 genesis.creator = [0; 32];
442 assert!(genesis.encode().is_err(), "zero local key is not an owner");
443 genesis.owner = GenesisOwner::Account(Uuid::nil());
444 assert!(genesis.encode().is_err(), "nil account is not an owner");
445 }
446 #[test]
447 fn import_initial_base_is_exact_bounded_empty_seed() {
448 let (mut genesis, _, _) = fixture();
449 let seed = synthetic_initial_base().expect("known system seed");
450 let bytes = seed.encode_current_msgpack().expect("seed");
451 genesis.base = seed.id();
452 assert_eq!(
453 initial_base_state(&genesis, &bytes)
454 .expect("one-call bootstrap")
455 .id(),
456 seed.id()
457 );
458 let random_seed = State::new_snapshot(
459 Tree::new().hash(),
460 vec![],
461 Attribution::human(Principal::new("Heddle", "init@heddle")),
462 );
463 genesis.base = random_seed.id();
464 assert!(
465 initial_base_state(
466 &genesis,
467 &random_seed.encode_current_msgpack().expect("random seed")
468 )
469 .is_err(),
470 "the old random empty seed shape is not a bootstrap exception"
471 );
472 genesis.base = seed.id();
473 let mut changed = seed.clone();
474 changed.tree = ContentHash::from_bytes([88; 32]);
475 genesis.base = changed.id();
476 assert!(
477 initial_base_state(
478 &genesis,
479 &changed.encode_current_msgpack().expect("changed")
480 )
481 .is_err(),
482 "nonempty source needs authorized closure transfer"
483 );
484 changed = seed.clone();
485 changed.provenance = Some(ContentHash::from_bytes([89; 32]));
486 genesis.base = changed.id();
487 assert!(
488 initial_base_state(
489 &genesis,
490 &changed.encode_current_msgpack().expect("changed")
491 )
492 .is_err(),
493 "seed cannot introduce another reference"
494 );
495 assert!(
496 initial_base_state(&genesis, &bytes).is_err(),
497 "base must match signed identity"
498 );
499 }
500 #[test]
501 fn synthetic_initial_base_is_stable_across_rust_and_browser() {
502 let state = synthetic_initial_base().expect("synthetic seed");
503 let bytes = state.encode_current_msgpack().expect("canonical seed");
504 let expected = include_str!("../../../tests/fixtures/synthetic-initial-base-v2.txt");
505 assert_eq!(
506 format!(
507 "canonical={}\nid={}\n",
508 hex::encode(&bytes),
509 hex::encode(state.id().as_bytes())
510 ),
511 expected
512 );
513 assert_eq!(
514 bytes,
515 synthetic_initial_base()
516 .expect("repeat")
517 .encode_current_msgpack()
518 .expect("repeat bytes")
519 );
520 let (mut genesis, _, _) = fixture();
521 genesis.base = state.id();
522 initial_base_state(&genesis, &bytes).expect("accepted seed shape");
523 }
524}