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_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}