1use crate::heddle::api::v1alpha2::*;
6pub use crate::hybrid_codec::{Reject, strict_decode};
7use crate::hybrid_codec::{canonical, field, hash, key_id, record, signing_digest, verify, width};
8
9pub const PERMISSION_DOMAIN: &str = "heddle-import-member-permission-v1";
10pub const GENESIS_DOMAIN: &str = "heddle-import-genesis-authority-v1";
11pub const DELEGATION_DOMAIN: &str = "heddle-import-job-delegation-v1";
12pub const OPERATION_DOMAIN: &str = "heddle-delegated-import-operation-v1";
13pub const MANIFEST_DOMAIN: &str = "heddle-import-result-manifest-v1";
14pub const PUBLICATION_DOMAIN: &str = "heddle-import-publication-payload-v1";
15pub const MAX_BRANCHES: usize = 256;
16pub const MAX_REF_BYTES: usize = 1024;
17pub const MAX_RECORD_BYTES: usize = 64 * 1024;
18pub const MAX_BUNDLE_BYTES: usize = 1024 * 1024;
19pub const MAX_DELEGATION_WINDOW_SECONDS: u64 = 7 * 24 * 60 * 60;
20pub const MAX_COMMIT_REQUEST_BYTES: usize = 2 * MAX_BUNDLE_BYTES;
21pub const MAX_IMPORT_SOURCE_REF_PAGE: usize = 512;
23pub const MAX_IMPORT_SOURCE_REFS: usize = 1_048_576;
26pub const DISCOVERY_DOMAIN: &str = "heddle-import-discovery-v1";
27pub const CANCELLATION_NAMESPACE: &str = "heddle-import-cancel-v1";
28
29record!(AuthorizationSignature, signer_key_id:b, signature:b);
30record!(RecordSignature, public_key:b, signature:b);
31record!(SignedRecord, format:s, canonical_record:b, signatures:l);
32record!(ImportFrontierV1, format_version:u, thread_id:b, operation_ids:h);
33record!(ImportContentV1, format_version:u, canonical_capture:b);
34record!(ImportBoundaryAcceptanceV1, binding:m, signed_acceptance:m, originals_manifest:b, publication_intent:b, original_receipts:l);
35record!(ImportGenesisWitnessV1, format_version:u, binding:m, original_genesis:m, creator_authority_envelope:b, boundary_acceptance:o);
36record!(ImportAuthorityWitnessV1, format_version:u, kind:e, original:m, dependencies:l, authority_envelope:b, boundary_acceptances:l);
37record!(HostedLandingRequestProofV1, format_version:u, signing_identity:s, method_path:s, timestamp_millis:u, nonce:b, request_body:b, signature:m);
38record!(HostedLandingWitnessV1, format_version:u, execution:m, request:m, source_operation:m, review_evidence:l, authority_envelope:b);
39record!(ImportIdentityV1, spool_uuid:b, spool_genesis_digest:b, owner_id:b,
40 owner_account_uuid:b, owner_state_hash:b, ownership_transfer_sequence:u);
41record!(ImportOwnerChainV1, spool_genesis_digest:b, owner_state_hashes:q, transfer_audit_hashes:q);
42record!(ImportBranchLimitV1, ref_name:s, hash_algorithm:e, ref_mode:e, pinned_commit_oid:b,
43 genesis_digest:b, target_thread_id:b, expected_frontier_digest:b, slot_id:u, ref_disclosure:e);
44record!(ImportPermissionScopeV1, provider:s, source_url:s, branches:l, destination_version:b,
45 options_digest:b, converter_version:s, max_operations:u, max_result_bytes:u);
46record!(ImportMemberPermissionV1, format_version:u, identity:m, logical_job_id:b,
47 retry_lineage_id:b, subject_public_key:b, purpose:e, scope:m, not_before_unix_seconds:u,
48 expires_at_unix_seconds:u, cancellation_id:b, owner_chain_digest:b, nonce:b);
49record!(SignedImportMemberPermissionV1, body:m, owner_signature:m);
50record!(ImportGenesisAuthorityV1, format_version:u, identity:m, genesis_digest:b,
51 original_creator_signature:b, creator_public_key:b, creator_authority_envelope_digest:b,
52 parent_permission_digest:b, owner_chain_digest:b);
53record!(SignedImportGenesisAuthorityV1, body:m, creator_signature:m);
54record!(ImportBranchManifestV1, limit:m, genesis_authority_digest:b);
55record!(ImportJobDelegationV1, format_version:u, identity:m, delegation_id:b, logical_job_id:b,
56 retry_lineage_id:b, job_public_key:b, job_key_id:b, delegating_public_key:b,
57 parent_permission_digest:b, owner_chain_digest:b, purpose:e, scope:m, branch_manifest:l,
58 not_before_unix_seconds:u, expires_at_unix_seconds:u, cancellation_id:b);
59record!(ImportJobPreparationV1, format_version:u, identity:m, delegation_id:b, logical_job_id:b,
60 retry_lineage_id:b, job_public_key:b, job_key_id:b, owner_chain_digest:b, purpose:e,
61 scope:m, cancellation_id:b);
62record!(SignedImportJobDelegationV1, body:m, delegating_signature:m);
63record!(ImportCommittedSlotV1, ref_name:s, slot_id:u, signed_operation_digest:b,
64 resulting_frontier_digest:b, result_bytes:u);
65record!(ImportResultManifestV1, format_version:u, logical_job_id:b, retry_lineage_id:b, slots:l);
66record!(DelegatedImportOperationV1, format_version:u, spool_uuid:b, spool_genesis_digest:b,
67 logical_job_id:b, retry_lineage_id:b, physical_operation_id:b, delegation_digest:b,
68 ref_name:s, slot_id:u, hash_algorithm:e, observed_commit_oid:b, genesis_digest:b,
69 target_thread_id:b, expected_frontier_digest:b, resulting_frontier_digest:b,
70 resulting_content_digest:b, result_bytes:u, options_digest:b, converter_version:s);
71record!(SignedDelegatedImportOperationV1, body:m, job_signature:m);
72record!(ImportPublicationWitnessV1, format_version:u, signed_operation_digest:b, delegation_digest:b,
73 logical_job_id:b, retry_lineage_id:b, physical_operation_id:b, ref_name:s, slot_id:u,
74 hash_algorithm:e, observed_commit_oid:b, expected_frontier_digest:b, resulting_frontier_digest:b,
75 terminal_manifest_digest:b);
76
77pub fn signed_permission_digest(v: &SignedImportMemberPermissionV1) -> Result<Vec<u8>, Reject> {
78 signing_digest("heddle-signed-import-member-permission-v1", v)
79}
80pub fn owner_chain_digest(v: &ImportOwnerChainV1) -> Result<Vec<u8>, Reject> {
81 width(&v.spool_genesis_digest, 32)?;
82 if v.owner_state_hashes.is_empty()
83 || v.owner_state_hashes.len() > 64
84 || v.transfer_audit_hashes.len() > 64
85 {
86 return Err(Reject::Bounds);
87 }
88 for h in v.owner_state_hashes.iter().chain(&v.transfer_audit_hashes) {
89 width(h, 32)?;
90 }
91 if v.owner_state_hashes.windows(2).any(|w| w[0] >= w[1]) {
92 return Err(Reject::Canonical);
93 }
94 signing_digest("heddle-import-owner-chain-v1", v)
95}
96pub fn signed_genesis_digest(v: &SignedImportGenesisAuthorityV1) -> Result<Vec<u8>, Reject> {
97 signing_digest("heddle-signed-import-genesis-authority-v1", v)
98}
99pub fn signed_delegation_digest(v: &SignedImportJobDelegationV1) -> Result<Vec<u8>, Reject> {
100 signing_digest("heddle-signed-import-job-delegation-v1", v)
101}
102pub fn signed_operation_digest(v: &SignedDelegatedImportOperationV1) -> Result<Vec<u8>, Reject> {
103 signing_digest("heddle-signed-delegated-import-operation-v1", v)
104}
105pub fn manifest_digest(v: &ImportResultManifestV1) -> Result<Vec<u8>, Reject> {
106 signing_digest(MANIFEST_DOMAIN, v)
107}
108pub fn verify_authorization_signature(
109 key: &[u8],
110 domain: &str,
111 body: &impl crate::hybrid_codec::Canonical,
112 signature: &AuthorizationSignature,
113) -> Result<(), Reject> {
114 if signature.signer_key_id != key_id(key) {
115 return Err(Reject::Signature);
116 }
117 let bytes = canonical(body)?;
118 if bytes.len() > MAX_RECORD_BYTES {
119 return Err(Reject::Bounds);
120 }
121 verify(
122 key,
123 &hash(&[domain.as_bytes(), &bytes]),
124 &signature.signature,
125 )
126}
127
128pub fn canonical_https(value: &str, origin: bool) -> Result<(), Reject> {
132 if value.len() > 2048 {
133 return Err(Reject::Bounds);
134 }
135 let rest = value.strip_prefix("https://").ok_or(Reject::Canonical)?;
136 let (host, path) = rest.split_once('/').unwrap_or((rest, ""));
137 if host.is_empty()
138 || host.len() > 253
139 || host.split('.').any(|part| {
140 part.is_empty()
141 || part.len() > 63
142 || part.starts_with('-')
143 || part.ends_with('-')
144 || !part
145 .bytes()
146 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
147 })
148 || (origin && rest != host)
149 || (!origin && path.is_empty())
150 || path.split('/').any(|p| {
151 p.is_empty() && !origin
152 || p == "."
153 || p == ".."
154 || !p
155 .bytes()
156 .all(|b| b.is_ascii_alphanumeric() || b"-._~".contains(&b))
157 })
158 {
159 return Err(Reject::Canonical);
160 }
161 Ok(())
162}
163fn identity(value: &ImportIdentityV1) -> Result<(), Reject> {
164 for v in [&value.spool_uuid, &value.owner_account_uuid] {
165 width(v, 16)?;
166 if v.iter().all(|b| *b == 0) {
167 return Err(Reject::Canonical);
168 }
169 }
170 for v in [
171 &value.spool_genesis_digest,
172 &value.owner_id,
173 &value.owner_state_hash,
174 ] {
175 width(v, 32)?;
176 }
177 Ok(())
178}
179fn interval(start: i64, end: i64, now: i64) -> Result<(), Reject> {
180 if start < 0 || end <= start {
181 return Err(Reject::Semantic);
182 }
183 if now < start || now >= end {
184 return Err(Reject::Expired);
185 }
186 Ok(())
187}
188fn validity(start: i64, end: i64, now: i64, current: bool) -> Result<(), Reject> {
190 if start < 0 || end <= start {
191 return Err(Reject::Semantic);
192 }
193 if current {
194 interval(start, end, now)?;
195 }
196 Ok(())
197}
198fn branch_ref(name: &str) -> Result<(), Reject> {
203 let short = name.strip_prefix("refs/heads/").ok_or(Reject::Canonical)?;
204 if short.is_empty() || short.starts_with('-') || short == "HEAD" {
205 return Err(Reject::Canonical);
206 }
207 git_ref_name(name)
208}
209
210fn git_ref_name(name: &str) -> Result<(), Reject> {
211 if name.ends_with('/')
212 || name.ends_with('.')
213 || name.contains("..")
214 || name.contains("//")
215 || name.contains("@{")
216 || name
217 .split('/')
218 .any(|p| p.starts_with('.') || p.ends_with(".lock"))
219 || name.bytes().any(|b| {
220 b <= b' ' || b == 0x7f || matches!(b, b'~' | b'^' | b':' | b'?' | b'*' | b'[' | b'\\')
221 })
222 {
223 return Err(Reject::Canonical);
224 }
225 if name.len() > MAX_REF_BYTES {
226 return Err(Reject::Bounds);
227 }
228 Ok(())
229}
230
231fn branch(value: &ImportBranchLimitV1) -> Result<(), Reject> {
232 branch_ref(&value.ref_name)?;
233 let size = match value.hash_algorithm {
234 1 => 20,
235 2 => 32,
236 _ => return Err(Reject::Version),
237 };
238 match value.ref_mode {
239 1 => {
240 width(&value.pinned_commit_oid, size)?;
241 if value.ref_disclosure != 0 {
242 return Err(Reject::RefDisclosure);
243 }
244 }
245 2 if value.pinned_commit_oid.is_empty() => {
246 if value.ref_disclosure != 1 {
247 return Err(Reject::RefDisclosure);
248 }
249 }
250 _ => return Err(Reject::Semantic),
251 }
252 for v in [
253 &value.genesis_digest,
254 &value.target_thread_id,
255 &value.expected_frontier_digest,
256 ] {
257 width(v, 32)?;
258 }
259 Ok(())
260}
261
262pub fn validate_ref_selection(
265 value: &ImportBranchLimitV1,
266 known_commit_oid: Option<&[u8]>,
267) -> Result<(), Reject> {
268 branch(value)?;
269 if let Some(oid) = known_commit_oid {
270 width(oid, if value.hash_algorithm == 1 { 20 } else { 32 })?;
271 if value.ref_mode != 1 || value.pinned_commit_oid != oid {
272 return Err(Reject::RefPinning);
273 }
274 }
275 Ok(())
276}
277
278pub fn conversion_options_digest(version: &str, options: &[u8]) -> Result<Vec<u8>, Reject> {
280 if version.is_empty() || version.len() > 128 || !version.is_ascii() {
281 return Err(Reject::Canonical);
282 }
283 if options.len() > 4096 {
284 return Err(Reject::Bounds);
285 }
286 let mut bytes = Vec::new();
287 crate::hybrid_codec::counted(&mut bytes, version.as_bytes())?;
288 crate::hybrid_codec::counted(&mut bytes, options)?;
289 Ok(hash(&[b"heddle-import-conversion-options-v1", &bytes]))
290}
291
292pub fn resolve_import_provider(
295 source: &ProviderRepository,
296 connection_provider: Option<&str>,
297) -> Result<&'static str, Reject> {
298 canonical_https(&source.clone_url, false)?;
299 if source.provider_repository_id.len() > 4096 || source.name.len() > 4096 {
300 return Err(Reject::Bounds);
301 }
302 if let Some(connection) = &source.connection {
303 let positive = |v: &str| {
304 !v.is_empty()
305 && v.bytes().all(|b| b.is_ascii_digit())
306 && v.parse::<u64>().is_ok_and(|id| id > 0)
307 };
308 if connection_provider != Some("github")
309 || connection.spool.is_some()
310 || connection.id.is_empty()
311 || !positive(&source.provider_repository_id)
312 || !positive(&source.installation_id)
313 {
314 return Err(Reject::SourceSelection);
315 }
316 let path = source
317 .clone_url
318 .strip_prefix("https://github.com/")
319 .ok_or(Reject::SourceSelection)?;
320 let parts: Vec<_> = path.split('/').collect();
321 if parts.len() != 2
322 || parts[0].is_empty()
323 || parts[1].strip_suffix(".git").is_none_or(str::is_empty)
324 {
325 return Err(Reject::SourceSelection);
326 }
327 Ok("github")
328 } else {
329 if connection_provider.is_some()
330 || source.private
331 || !source.installation_id.is_empty()
332 || (!source.provider_repository_id.is_empty()
333 && source.provider_repository_id != source.clone_url)
334 {
335 return Err(Reject::SourceSelection);
336 }
337 Ok("public-git")
338 }
339}
340
341pub fn validate_repository_size_estimate(source: &ProviderRepository) -> Result<(), Reject> {
343 match source.size_estimate_state {
344 0 if source.git_size_kib == 0 => Ok(()),
345 1 if source.connection.is_some() => Ok(()),
346 _ => Err(Reject::Canonical),
347 }
348}
349
350pub fn validate_resolve_import_source_response(
352 request: &ResolveImportSourceRequest,
353 response: &ResolveImportSourceResponse,
354 connection_provider: Option<&str>,
355) -> Result<(), Reject> {
356 use prost::Message;
357 if response.encoded_len() > MAX_BUNDLE_BYTES {
358 return Err(Reject::Bounds);
359 }
360 let selected = request.source.as_ref().ok_or(Reject::SourceSelection)?;
361 let resolved = response.source.as_ref().ok_or(Reject::SourceSelection)?;
362 let provider = resolve_import_provider(selected, connection_provider)?;
363 if resolve_import_provider(resolved, connection_provider)? != provider
364 || selected.clone_url != resolved.clone_url
365 || selected.connection != resolved.connection
366 || selected.installation_id != resolved.installation_id
367 || selected.private != resolved.private
368 || (selected.provider_repository_id != resolved.provider_repository_id
369 && (selected.connection.is_some() || !selected.provider_repository_id.is_empty()))
370 || (resolved.connection.is_none() && resolved.provider_repository_id != selected.clone_url)
371 {
372 return Err(Reject::SourceSelection);
373 }
374 validate_repository_size_estimate(resolved)?;
375 validate_repository_hash_algorithm_with_options(
376 resolved,
377 false,
378 DiscoveryOptions {
379 include_pull_request_refs: request.include_pull_request_refs,
380 },
381 )
382}
383
384fn validate_retained_import_source(
386 selector: &ImportSourceSelectionV1,
387 scope: &ImportPermissionScopeV1,
388) -> Result<(), Reject> {
389 let source = ProviderRepository {
390 connection: selector.connection.clone(),
391 provider_repository_id: selector.provider_repository_id.clone(),
392 installation_id: selector.installation_id.clone(),
393 private: selector.private,
394 clone_url: scope.source_url.clone(),
395 ..Default::default()
396 };
397 let provider =
398 resolve_import_provider(&source, selector.connection.as_ref().map(|_| "github"))?;
399 if scope.provider != provider
400 || (selector.connection.is_none() && selector.provider_repository_id != scope.source_url)
401 {
402 return Err(Reject::SourceSelection);
403 }
404 Ok(())
405}
406
407#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
409pub struct DiscoveryOptions {
410 pub include_pull_request_refs: bool,
411}
412
413#[derive(Debug, Clone, Copy, PartialEq, Eq)]
415pub enum DiscoverySelectionMode {
416 Prepare,
417 Commit,
418}
419
420#[derive(Debug)]
423pub struct DiscoveryDigest {
424 digest: [u8; 32],
425 ref_count: usize,
426 source_binding: Vec<u8>,
427 selection_binding: Vec<u8>,
428 mode: DiscoverySelectionMode,
429}
430impl DiscoveryDigest {
431 pub fn digest(&self) -> &[u8; 32] {
436 &self.digest
437 }
438 pub fn ref_count(&self) -> usize {
439 self.ref_count
440 }
441 fn check(
442 &self,
443 source: &ProviderRepository,
444 selected: &[ImportBranchLimitV1],
445 mode: DiscoverySelectionMode,
446 ) -> Result<(), Reject> {
447 if self.mode != mode
448 || self.source_binding != discovery_source_binding(source)?
449 || self.selection_binding != discovery_selection_binding(selected)?
450 {
451 return Err(Reject::SourceSelection);
452 }
453 Ok(())
454 }
455}
456
457fn discovery_ref_kind(r: &ProviderRef, options: DiscoveryOptions) -> i32 {
460 if r.name.starts_with("refs/heads/") {
461 1
462 } else if r.name.starts_with("refs/tags/") {
463 2
464 } else if options.include_pull_request_refs
465 && r.name
466 .strip_prefix("refs/pull/")
467 .and_then(|n| n.strip_suffix("/head"))
468 .is_some_and(|n| {
469 !n.is_empty() && !n.starts_with('0') && n.bytes().all(|b| b.is_ascii_digit())
470 })
471 {
472 3
473 } else {
474 0
475 }
476}
477
478fn discovery_source_binding(source: &ProviderRepository) -> Result<Vec<u8>, Reject> {
479 use prost::Message;
480 for text in [
482 &source.clone_url,
483 &source.provider_repository_id,
484 &source.installation_id,
485 ] {
486 if text.len() > 4096 {
487 return Err(Reject::Bounds);
488 }
489 }
490 if source
491 .connection
492 .as_ref()
493 .is_some_and(|c| c.encoded_len() > 4096)
494 {
495 return Err(Reject::Bounds);
496 }
497 let metadata = ProviderRepository {
498 connection: source.connection.clone(),
499 provider_repository_id: source.provider_repository_id.clone(),
500 clone_url: source.clone_url.clone(),
501 installation_id: source.installation_id.clone(),
502 private: source.private,
503 hash_algorithm: source.hash_algorithm,
504 ..Default::default()
505 };
506 Ok(hash(&[&metadata.encode_to_vec()]))
507}
508
509fn discovery_selection_binding(selected: &[ImportBranchLimitV1]) -> Result<Vec<u8>, Reject> {
510 use sha2::{Digest, Sha256};
511 if selected.len() > MAX_BRANCHES {
512 return Err(Reject::Bounds);
513 }
514 let mut digest = Sha256::new();
515 for (i, b) in selected.iter().enumerate() {
516 branch(b)?;
517 if i > 0 && selected[i - 1].ref_name >= b.ref_name {
518 return Err(Reject::Canonical);
519 }
520 digest.update(canonical(b)?);
521 }
522 Ok(digest.finalize().to_vec())
523}
524
525pub struct DiscoveryValidator {
532 digest: sha2::Sha256,
533 hash_algorithm: i32,
534 oid_size: Option<usize>,
535 options: DiscoveryOptions,
536 selected: Vec<ImportBranchLimitV1>,
537 previous: Option<String>,
538 ref_count: usize,
539 failed: bool,
540 source_binding: Vec<u8>,
541 selection_binding: Vec<u8>,
542 mode: DiscoverySelectionMode,
543}
544impl DiscoveryValidator {
545 pub fn new(
546 source: &ProviderRepository,
547 known: bool,
548 selected: &[ImportBranchLimitV1],
549 options: DiscoveryOptions,
550 mode: DiscoverySelectionMode,
551 ) -> Result<Self, Reject> {
552 use sha2::{Digest, Sha256};
553 let oid_size = match source.hash_algorithm {
554 1 => Some(40),
555 2 => Some(64),
556 0 if !known => None,
557 _ => return Err(Reject::Version),
558 };
559 let selection_binding = discovery_selection_binding(selected)?;
560 for b in selected {
561 if b.hash_algorithm != source.hash_algorithm {
562 return Err(Reject::SourceSelection);
563 }
564 }
565 let source_binding = discovery_source_binding(source)?;
566 let mut digest = Sha256::new();
567 digest.update(DISCOVERY_DOMAIN.as_bytes());
568 digest.update(&source_binding);
569 digest.update(u32::from(options.include_pull_request_refs).to_be_bytes());
570 digest.update((source.hash_algorithm as u32).to_be_bytes());
571 Ok(Self {
572 digest,
573 hash_algorithm: source.hash_algorithm,
574 oid_size,
575 options,
576 selected: selected.to_vec(),
577 previous: None,
578 ref_count: 0,
579 failed: false,
580 source_binding,
581 selection_binding,
582 mode,
583 })
584 }
585 pub fn push_page(&mut self, refs: &[ProviderRef]) -> Result<(), Reject> {
586 if self.failed {
587 return Err(Reject::Canonical);
588 }
589 let result = self.push_page_inner(refs);
590 if result.is_err() {
591 self.failed = true;
592 }
593 result
594 }
595 fn push_page_inner(&mut self, refs: &[ProviderRef]) -> Result<(), Reject> {
596 use sha2::Digest;
597 if refs.len() > MAX_IMPORT_SOURCE_REF_PAGE
598 || refs.len() > MAX_IMPORT_SOURCE_REFS - self.ref_count
599 {
600 return Err(Reject::Bounds);
601 }
602 for r in refs {
603 if r.name.len() > MAX_REF_BYTES {
604 return Err(Reject::Bounds);
605 }
606 if r.hash_algorithm != self.hash_algorithm {
607 return Err(Reject::SourceSelection);
608 }
609 if self.previous.as_ref().is_some_and(|p| p >= &r.name) {
610 return Err(Reject::Canonical);
611 }
612 if !r.head_oid.is_empty() {
613 let size = self.oid_size.ok_or(Reject::Version)?;
614 if r.head_oid.len() != size {
615 return Err(Reject::SourceSelection);
616 }
617 if !r
618 .head_oid
619 .bytes()
620 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
621 {
622 return Err(Reject::Canonical);
623 }
624 }
625 let kind = discovery_ref_kind(r, self.options);
626 if let Ok(i) = self.selected.binary_search_by(|b| b.ref_name.cmp(&r.name)) {
627 let b = &self.selected[i];
628 match self.mode {
629 DiscoverySelectionMode::Prepare => {
630 let oid = if r.head_oid.is_empty() {
631 None
632 } else {
633 Some(hex::decode(&r.head_oid).map_err(|_| Reject::Canonical)?)
634 };
635 validate_ref_selection(b, oid.as_deref())?;
636 }
637 DiscoverySelectionMode::Commit => {
638 if b.ref_mode == 2 && !r.head_oid.is_empty() {
639 return Err(Reject::RefPinning);
640 }
641 }
642 }
643 }
644 for text in [&r.name, &r.head_oid] {
647 self.digest.update((text.len() as u32).to_be_bytes());
648 self.digest.update(text.as_bytes());
649 }
650 self.digest.update((kind as u32).to_be_bytes());
651 self.digest.update((r.hash_algorithm as u32).to_be_bytes());
652 self.previous = Some(r.name.clone());
653 self.ref_count += 1;
654 }
655 Ok(())
656 }
657 pub fn finish(mut self) -> Result<DiscoveryDigest, Reject> {
658 use sha2::Digest;
659 if self.failed {
660 return Err(Reject::Canonical);
661 }
662 self.digest.update((self.ref_count as u64).to_be_bytes());
663 Ok(DiscoveryDigest {
664 digest: self.digest.finalize().into(),
665 ref_count: self.ref_count,
666 source_binding: self.source_binding,
667 selection_binding: self.selection_binding,
668 mode: self.mode,
669 })
670 }
671}
672
673pub fn discover_repository(
676 source: &ProviderRepository,
677 selected: &[ImportBranchLimitV1],
678 options: DiscoveryOptions,
679 mode: DiscoverySelectionMode,
680) -> Result<DiscoveryDigest, Reject> {
681 let mut validator = DiscoveryValidator::new(source, true, selected, options, mode)?;
682 for page in source.refs.chunks(MAX_IMPORT_SOURCE_REF_PAGE) {
683 validator.push_page(page)?;
684 }
685 validator.finish()
686}
687
688pub fn validate_repository_hash_algorithm(
690 source: &ProviderRepository,
691 known: bool,
692) -> Result<(), Reject> {
693 validate_repository_hash_algorithm_with_options(source, known, DiscoveryOptions::default())
694}
695pub fn validate_repository_hash_algorithm_with_options(
696 source: &ProviderRepository,
697 known: bool,
698 options: DiscoveryOptions,
699) -> Result<(), Reject> {
700 let mut validator =
701 DiscoveryValidator::new(source, known, &[], options, DiscoverySelectionMode::Prepare)?;
702 validator.push_page(&source.refs)?;
703 validator.finish().map(|_| ())
704}
705pub fn validate_discovered_repository(source: &ProviderRepository) -> Result<(), Reject> {
707 discover_repository(
708 source,
709 &[],
710 DiscoveryOptions::default(),
711 DiscoverySelectionMode::Prepare,
712 )
713 .map(|_| ())
714}
715pub fn validate_discovered_import_scope(
717 scope: &ImportPermissionScopeV1,
718 source: &ProviderRepository,
719) -> Result<(), Reject> {
720 if scope.source_url != source.clone_url {
721 return Err(Reject::SourceSelection);
722 }
723 discover_repository(
724 source,
725 &scope.branches,
726 DiscoveryOptions::default(),
727 DiscoverySelectionMode::Prepare,
728 )
729 .map(|_| ())
730}
731
732pub fn prepare_import_source_scope(
737 request: &PrepareImportJobRequest,
738 current_source: &ProviderRepository,
739 connection_provider: Option<&str>,
740 configuration: &GetImportConfigurationResponse,
741 current_destination_version: &[u8],
742) -> Result<ImportPermissionScopeV1, Reject> {
743 prepare_import_source_scope_inner(
744 request,
745 current_source,
746 connection_provider,
747 configuration,
748 current_destination_version,
749 None,
750 )
751}
752pub fn prepare_import_source_scope_with_discovery(
754 request: &PrepareImportJobRequest,
755 current_source: &ProviderRepository,
756 connection_provider: Option<&str>,
757 configuration: &GetImportConfigurationResponse,
758 current_destination_version: &[u8],
759 discovery: &DiscoveryDigest,
760) -> Result<ImportPermissionScopeV1, Reject> {
761 prepare_import_source_scope_inner(
762 request,
763 current_source,
764 connection_provider,
765 configuration,
766 current_destination_version,
767 Some(discovery),
768 )
769}
770fn prepare_import_source_scope_inner(
771 request: &PrepareImportJobRequest,
772 current_source: &ProviderRepository,
773 connection_provider: Option<&str>,
774 configuration: &GetImportConfigurationResponse,
775 current_destination_version: &[u8],
776 discovery: Option<&DiscoveryDigest>,
777) -> Result<ImportPermissionScopeV1, Reject> {
778 let selector = request.source.as_ref().ok_or(Reject::SourceSelection)?;
779 if selector.connection != current_source.connection
780 || (selector.provider_repository_id != current_source.provider_repository_id
781 && (selector.connection.is_some() || !selector.provider_repository_id.is_empty()))
782 || selector.installation_id != current_source.installation_id
783 || selector.private != current_source.private
784 {
785 return Err(Reject::SourceSelection);
786 }
787 let scope = request.proposed_scope.as_ref().ok_or(Reject::Canonical)?;
788 let provider = resolve_import_provider(current_source, connection_provider)?;
789 if scope.provider != provider {
790 return Err(Reject::SourceSelection);
791 }
792 if scope.source_url != current_source.clone_url {
793 return Err(Reject::SourceSelection);
794 }
795 if let Some(discovery) = discovery {
796 discovery.check(
797 current_source,
798 &scope.branches,
799 DiscoverySelectionMode::Prepare,
800 )?;
801 } else {
802 validate_discovered_import_scope(scope, current_source)?;
803 }
804 validate_import_configuration(configuration)?;
805 prepare_scope(scope, configuration, current_destination_version)
806}
807
808fn validate_provider_support(
809 provider: &str,
810 configuration: &GetImportConfigurationResponse,
811) -> Result<(), Reject> {
812 if !configuration
813 .providers
814 .iter()
815 .any(|p| p.provider == provider)
816 {
817 return Err(Reject::SourceSelection);
818 }
819 Ok(())
820}
821
822pub fn validate_import_configuration(v: &GetImportConfigurationResponse) -> Result<(), Reject> {
823 use prost::Message;
824 if v.encoded_len() > MAX_BUNDLE_BYTES || v.converters.is_empty() || v.converters.len() > 32 {
825 return Err(Reject::Bounds);
826 }
827 for (i, c) in v.converters.iter().enumerate() {
828 for text in [&c.converter_version, &c.options_encoding] {
829 if text.is_empty() || text.len() > 128 || !text.is_ascii() {
830 return Err(Reject::Canonical);
831 }
832 }
833 if i > 0 && v.converters[i - 1].converter_version >= c.converter_version {
834 return Err(Reject::Canonical);
835 }
836 if c.canonical_options.is_empty()
837 || c.canonical_options.len() > 64
838 || c.canonical_options.iter().any(|o| o.len() > 4096)
839 {
840 return Err(Reject::Bounds);
841 }
842 if c.canonical_options.windows(2).any(|w| w[0] >= w[1])
843 || !c.canonical_options.contains(&c.default_options)
844 {
845 return Err(Reject::Canonical);
846 }
847 }
848 if v.providers.is_empty() || v.providers.len() > 2 {
849 return Err(Reject::Bounds);
850 }
851 for (i, p) in v.providers.iter().enumerate() {
852 if i > 0 && v.providers[i - 1].provider >= p.provider {
853 return Err(Reject::Canonical);
854 }
855 let mode = match p.provider.as_str() {
856 "github" => 1,
857 "public-git" => 2,
858 _ => return Err(Reject::SourceSelection),
859 };
860 if p.source_modes != [mode] {
861 return Err(Reject::SourceSelection);
862 }
863 }
864 if let Some(default) = &v.default_converter_version
865 && !v.converters.iter().any(|c| &c.converter_version == default)
866 {
867 return Err(Reject::Canonical);
868 }
869 let l = v.limits.as_ref().ok_or(Reject::Canonical)?;
870 if l.max_branches == 0
871 || l.max_branches as usize > MAX_BRANCHES
872 || l.max_operations == 0
873 || l.max_operations as usize > MAX_BRANCHES
874 || l.max_result_bytes == 0
875 {
876 return Err(Reject::Bounds);
877 }
878 Ok(())
879}
880
881pub fn prepare_scope(
884 proposed: &ImportPermissionScopeV1,
885 configuration: &GetImportConfigurationResponse,
886 current_destination_version: &[u8],
887) -> Result<ImportPermissionScopeV1, Reject> {
888 use ImportPreparationRefusalReason as Reason;
889 validate_import_configuration(configuration)?;
890 width(current_destination_version, 32)?;
891 if !proposed.destination_version.is_empty() && proposed.destination_version.len() != 32 {
892 return Err(Reject::PreparationRefused(Reason::InvalidScope));
893 }
894 if !proposed.destination_version.is_empty()
895 && proposed.destination_version != current_destination_version
896 {
897 return Err(Reject::PreparationRefused(Reason::DestinationConflict));
898 }
899 let mut selected = proposed.clone();
900 selected.destination_version = current_destination_version.to_vec();
901 validate_scope(&selected).map_err(|_| Reject::PreparationRefused(Reason::InvalidScope))?;
902 validate_provider_support(&selected.provider, configuration)
903 .map_err(|_| Reject::PreparationRefused(Reason::InvalidScope))?;
904 let converter = configuration
905 .converters
906 .iter()
907 .find(|c| c.converter_version == selected.converter_version)
908 .ok_or(Reject::PreparationRefused(Reason::UnsupportedConverter))?;
909 let supported = converter
910 .canonical_options
911 .iter()
912 .try_fold(false, |found, o| {
913 Ok::<_, Reject>(
914 found
915 || conversion_options_digest(&converter.converter_version, o)?
916 == selected.options_digest,
917 )
918 })?;
919 if !supported {
920 return Err(Reject::PreparationRefused(Reason::UnsupportedOptions));
921 }
922 let limits = configuration.limits.as_ref().ok_or(Reject::Canonical)?;
923 if selected.branches.len() > limits.max_branches as usize
924 || selected.max_operations > limits.max_operations
925 || selected.max_result_bytes > limits.max_result_bytes
926 {
927 return Err(Reject::PreparationRefused(Reason::BudgetExceeded));
928 }
929 Ok(selected)
930}
931
932pub struct ImportSpoolReservation<'a> {
937 pub spool_uuid: &'a [u8],
938 pub logical_job_id: &'a [u8],
939 pub branches: &'a [ImportBranchLimitV1],
940}
941
942pub fn check_import_spool_reservations(
946 spool_uuid: &[u8],
947 logical_job_id: &[u8],
948 scope: &ImportPermissionScopeV1,
949 reservations: &[ImportSpoolReservation<'_>],
950) -> Result<(), Reject> {
951 initial_operation_id(spool_uuid, false)?;
952 initial_operation_id(logical_job_id, false)?;
953 validate_scope(scope)?;
954 for reservation in reservations {
955 initial_operation_id(reservation.spool_uuid, false)?;
956 initial_operation_id(reservation.logical_job_id, false)?;
957 if reservation.spool_uuid != spool_uuid {
958 continue;
959 }
960 let same_job = reservation.logical_job_id == logical_job_id;
961 for selected in &scope.branches {
962 let conflict = if same_job {
963 !reservation.branches.contains(selected)
964 } else {
965 reservation.branches.iter().any(|held| {
966 held.ref_name == selected.ref_name
967 || held.target_thread_id == selected.target_thread_id
968 || held.genesis_digest == selected.genesis_digest
969 })
970 };
971 if conflict {
972 return Err(Reject::PreparationRefused(
973 ImportPreparationRefusalReason::DestinationConflict,
974 ));
975 }
976 }
977 }
978 Ok(())
979}
980
981pub fn import_commit_conflict_failure(
984 rejection: &Reject,
985) -> Option<crate::heddle::api::common::CallFailure> {
986 use crate::heddle::api::common::{CallFailure, CallFailureCode, ErrorDetail, ErrorReason};
987 let (code, reason) = match rejection {
988 Reject::PreparationRefused(ImportPreparationRefusalReason::DestinationConflict) => (
989 CallFailureCode::AlreadyExists,
990 ErrorReason::ImportDestinationConflict,
991 ),
992 Reject::StaleContext => (CallFailureCode::Aborted, ErrorReason::VersionConflict),
993 _ => return None,
994 };
995 Some(CallFailure {
996 code: code as i32,
997 message: String::new(),
998 error: Some(ErrorDetail {
999 reason: reason as i32,
1000 ..Default::default()
1001 }),
1002 })
1003}
1004
1005pub fn check_import_destination_version(
1008 scope: &ImportPermissionScopeV1,
1009 current_destination_version: &[u8],
1010) -> Result<(), Reject> {
1011 width(&scope.destination_version, 32)?;
1012 width(current_destination_version, 32)?;
1013 if scope.destination_version != current_destination_version {
1014 return Err(Reject::StaleContext);
1015 }
1016 Ok(())
1017}
1018
1019pub fn validate_preparation_response(
1021 request: &PrepareImportJobRequest,
1022 response: &PrepareImportJobResponse,
1023) -> Result<(), Reject> {
1024 if let Some(refusal) = &response.refusal {
1025 let reason = ImportPreparationRefusalReason::try_from(refusal.reason)
1026 .map_err(|_| Reject::Version)?;
1027 if reason == ImportPreparationRefusalReason::Unspecified
1028 || refusal.field.len() > 256
1029 || !refusal.field.is_ascii()
1030 || response.proposal.is_some()
1031 || response.reservation_expires_at_unix_seconds != 0
1032 || response.prepared_at_unix_seconds != 0
1033 || response.max_validity_duration_seconds != 0
1034 || response.clock_skew_allowance_seconds != 0
1035 {
1036 return Err(Reject::Canonical);
1037 }
1038 return Err(Reject::PreparationRefused(reason));
1039 }
1040 let p = response.proposal.as_ref().ok_or(Reject::Canonical)?;
1041 let returned = p.scope.as_ref().ok_or(Reject::Canonical)?;
1042 let mut requested = request.proposed_scope.clone().ok_or(Reject::Canonical)?;
1043 if requested.destination_version.is_empty() {
1044 requested.destination_version = returned.destination_version.clone();
1045 }
1046 if canonical(&requested)? != canonical(returned)?
1047 || p.identity != request.identity
1048 || p.retry_lineage_id != request.retry_lineage_id
1049 {
1050 return Err(Reject::PreparedFields);
1051 }
1052 validate_scope(returned)?;
1053 Ok(())
1054}
1055
1056pub fn validate_commit_request_bounds(request: &CommitImportJobRequest) -> Result<(), Reject> {
1059 use prost::Message;
1060 if request.encoded_len() > MAX_COMMIT_REQUEST_BYTES
1061 || request
1062 .proof
1063 .as_ref()
1064 .ok_or(Reject::Canonical)?
1065 .encoded_len()
1066 > MAX_BUNDLE_BYTES
1067 {
1068 return Err(Reject::Bounds);
1069 }
1070 Ok(())
1071}
1072
1073pub fn validate_commit_request(
1077 request: &CommitImportJobRequest,
1078 resolved_provider: &str,
1079 current_source: &ProviderRepository,
1080 configuration: &GetImportConfigurationResponse,
1081) -> Result<(), Reject> {
1082 validate_commit_request_inner(
1083 request,
1084 resolved_provider,
1085 current_source,
1086 configuration,
1087 None,
1088 )
1089}
1090pub fn validate_commit_request_with_discovery(
1092 request: &CommitImportJobRequest,
1093 resolved_provider: &str,
1094 current_source: &ProviderRepository,
1095 configuration: &GetImportConfigurationResponse,
1096 discovery: &DiscoveryDigest,
1097) -> Result<(), Reject> {
1098 validate_commit_request_inner(
1099 request,
1100 resolved_provider,
1101 current_source,
1102 configuration,
1103 Some(discovery),
1104 )
1105}
1106fn validate_commit_request_inner(
1107 request: &CommitImportJobRequest,
1108 resolved_provider: &str,
1109 current_source: &ProviderRepository,
1110 configuration: &GetImportConfigurationResponse,
1111 discovery: Option<&DiscoveryDigest>,
1112) -> Result<(), Reject> {
1113 use prost::Message;
1114 validate_commit_request_bounds(request)?;
1115 let source = request.source.as_ref().ok_or(Reject::SourceSelection)?;
1116 let proof = request.proof.as_ref().ok_or(Reject::Canonical)?;
1117 if request.client_operation_id.is_empty()
1118 || request.client_operation_id.len() > 128
1119 || request.destination.as_ref().is_none_or(|d| d.id.is_empty())
1120 {
1121 return Err(Reject::Canonical);
1122 }
1123 if request.initial_base_state.len() > 4096 || proof.encoded_len() > MAX_BUNDLE_BYTES {
1124 return Err(Reject::Bounds);
1125 }
1126 if proof.format_version != 1
1127 || proof.delegations.len() != 1
1128 || !proof.operations.is_empty()
1129 || proof.terminal_manifest.is_some()
1130 || !proof.manifests.is_empty()
1131 {
1132 return Err(Reject::Canonical);
1133 }
1134 let d = proof.delegations[0]
1135 .body
1136 .as_ref()
1137 .ok_or(Reject::Canonical)?;
1138 let scope = d.scope.as_ref().ok_or(Reject::Canonical)?;
1139 validate_scope(scope)?;
1140 let id = d.identity.as_ref().ok_or(Reject::Canonical)?;
1141 identity(id)?;
1142 let uuid = hex::encode(&id.spool_uuid);
1143 let destination_id = format!(
1144 "{}-{}-{}-{}-{}",
1145 &uuid[..8],
1146 &uuid[8..12],
1147 &uuid[12..16],
1148 &uuid[16..20],
1149 &uuid[20..]
1150 );
1151 if request
1152 .destination
1153 .as_ref()
1154 .is_none_or(|s| s.id != destination_id)
1155 {
1156 return Err(Reject::Scope);
1157 }
1158 if source.clone_url != scope.source_url
1159 || resolved_provider != scope.provider
1160 || source.provider_repository_id.len() > 4096
1161 || source.name.len() > 4096
1162 {
1163 return Err(Reject::SourceSelection);
1164 }
1165 if source.connection != current_source.connection
1166 || (source.provider_repository_id != current_source.provider_repository_id
1167 && (source.connection.is_some() || !source.provider_repository_id.is_empty()))
1168 || source.clone_url != current_source.clone_url
1169 || source.installation_id != current_source.installation_id
1170 || source.private != current_source.private
1171 {
1172 return Err(Reject::SourceSelection);
1173 }
1174 let provider = resolve_import_provider(
1175 current_source,
1176 current_source
1177 .connection
1178 .as_ref()
1179 .map(|_| resolved_provider),
1180 )?;
1181 if provider != resolved_provider {
1182 return Err(Reject::SourceSelection);
1183 }
1184 validate_import_configuration(configuration)?;
1185 validate_provider_support(provider, configuration)?;
1186 if let Some(discovery) = discovery {
1187 discovery.check(
1188 current_source,
1189 &scope.branches,
1190 DiscoverySelectionMode::Commit,
1191 )?;
1192 } else {
1193 discover_repository(
1194 current_source,
1195 &scope.branches,
1196 DiscoveryOptions::default(),
1197 DiscoverySelectionMode::Commit,
1198 )?;
1199 }
1200 if source.hash_algorithm != current_source.hash_algorithm {
1201 return Err(Reject::SourceSelection);
1202 }
1203 prepare_scope(scope, configuration, &scope.destination_version)?;
1205 if proof.original_geneses.len() != scope.branches.len()
1206 || proof.creator_authority_envelopes.len() != scope.branches.len()
1207 || proof.genesis_authorities.len() != scope.branches.len()
1208 || d.branch_manifest.len() != scope.branches.len()
1209 {
1210 return Err(Reject::GenesisBinding);
1211 }
1212 for (i, b) in scope.branches.iter().enumerate() {
1215 let original = &proof.original_geneses[i];
1216 let binding = &proof.genesis_authorities[i];
1217 let g = binding.body.as_ref().ok_or(Reject::GenesisBinding)?;
1218 let m = &d.branch_manifest[i];
1219 if native_id(original) != b.genesis_digest
1220 || g.genesis_digest != b.genesis_digest
1221 || m.limit.as_ref() != Some(b)
1222 || m.genesis_authority_digest != signed_genesis_digest(binding)?
1223 || g.creator_authority_envelope_digest != hash(&[&proof.creator_authority_envelopes[i]])
1224 || proof.creator_authority_envelopes[i].is_empty()
1225 || proof.creator_authority_envelopes[i].len() > MAX_RECORD_BYTES
1226 || !original.signatures.iter().any(|s| {
1227 s.public_key == g.creator_public_key && s.signature == g.original_creator_signature
1228 })
1229 {
1230 return Err(Reject::GenesisBinding);
1231 }
1232 verify_native(original, "heddle-thread-genesis-v1")?;
1233 }
1234 Ok(())
1235}
1236
1237pub fn validate_import_source(_: &ImportSourceRequest) -> Result<(), Reject> {
1239 Err(Reject::ImportSourceRequiresCommit)
1240}
1241
1242pub fn verify_commit_submission(
1245 request: &CommitImportJobRequest,
1246 prepared: &PrepareImportJobResponse,
1247 resolved_provider: &str,
1248 current_source: &ProviderRepository,
1249 configuration: &GetImportConfigurationResponse,
1250 expected: &ImportOwnerExpectation<'_>,
1251) -> Result<VerifiedImportDelegation, Reject> {
1252 verify_commit_submission_inner(
1253 request,
1254 prepared,
1255 resolved_provider,
1256 current_source,
1257 configuration,
1258 expected,
1259 None,
1260 )
1261}
1262
1263pub fn verify_commit_submission_with_discovery(
1265 request: &CommitImportJobRequest,
1266 prepared: &PrepareImportJobResponse,
1267 resolved_provider: &str,
1268 current_source: &ProviderRepository,
1269 configuration: &GetImportConfigurationResponse,
1270 expected: &ImportOwnerExpectation<'_>,
1271 discovery: &DiscoveryDigest,
1272) -> Result<VerifiedImportDelegation, Reject> {
1273 verify_commit_submission_inner(
1274 request,
1275 prepared,
1276 resolved_provider,
1277 current_source,
1278 configuration,
1279 expected,
1280 Some(discovery),
1281 )
1282}
1283
1284fn verify_commit_submission_inner(
1285 request: &CommitImportJobRequest,
1286 prepared: &PrepareImportJobResponse,
1287 resolved_provider: &str,
1288 current_source: &ProviderRepository,
1289 configuration: &GetImportConfigurationResponse,
1290 expected: &ImportOwnerExpectation<'_>,
1291 discovery: Option<&DiscoveryDigest>,
1292) -> Result<VerifiedImportDelegation, Reject> {
1293 validate_commit_request_inner(
1294 request,
1295 resolved_provider,
1296 current_source,
1297 configuration,
1298 discovery,
1299 )?;
1300 let proof = request.proof.as_ref().ok_or(Reject::Canonical)?;
1301 let member = proof.member_permission.as_ref();
1302 verify_prepared_delegation(
1303 prepared,
1304 &proof.delegations[0],
1305 member,
1306 &proof.genesis_authorities,
1307 expected,
1308 )
1309}
1310
1311pub fn validate_commit_response(
1312 request: &CommitImportJobRequest,
1313 response: &MutationResponse,
1314) -> Result<(), Reject> {
1315 let receipt = response.receipt.as_ref().ok_or(Reject::PendingOperation)?;
1316 let Some(mutation_receipt::Outcome::PendingOperation(operation)) = &receipt.outcome else {
1317 return Err(Reject::PendingOperation);
1318 };
1319 if request.client_operation_id.is_empty()
1320 || receipt.client_operation_id != request.client_operation_id
1321 || request.destination.is_none()
1322 || operation.spool != request.destination
1323 || operation.id
1324 != initial_operation_id(
1325 &request
1326 .proof
1327 .as_ref()
1328 .ok_or(Reject::PendingOperation)?
1329 .delegations
1330 .first()
1331 .and_then(|d| d.body.as_ref())
1332 .ok_or(Reject::PendingOperation)?
1333 .retry_lineage_id,
1334 false,
1335 )?
1336 {
1337 return Err(Reject::PendingOperation);
1338 }
1339 Ok(())
1340}
1341
1342pub fn check_commit_replay(
1346 request: &CommitImportJobRequest,
1347 stored: &CommitImportJobRequest,
1348 response: &MutationResponse,
1349) -> Result<(), Reject> {
1350 if request != stored {
1351 return Err(Reject::OperationIdReused);
1352 }
1353 validate_commit_response(request, response)
1354}
1355pub fn validate_scope(value: &ImportPermissionScopeV1) -> Result<(), Reject> {
1356 canonical_https(&value.source_url, false)?;
1357 if value.provider.is_empty()
1358 || value.provider.len() > 64
1359 || !value
1360 .provider
1361 .bytes()
1362 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
1363 || value.converter_version.is_empty()
1364 || value.converter_version.len() > 128
1365 || !value.converter_version.is_ascii()
1366 {
1367 return Err(Reject::Canonical);
1368 }
1369 width(&value.destination_version, 32)?;
1370 width(&value.options_digest, 32)?;
1371 if value.branches.is_empty()
1372 || value.branches.len() > MAX_BRANCHES
1373 || value.max_operations == 0
1374 || value.max_operations as usize > MAX_BRANCHES
1375 || value.max_result_bytes == 0
1376 {
1377 return Err(Reject::Bounds);
1378 }
1379 if (value.max_operations as usize) < value.branches.len() {
1380 return Err(Reject::Scope);
1381 }
1382 for (i, b) in value.branches.iter().enumerate() {
1383 branch(b)?;
1384 if value.branches[..i].iter().any(|other| {
1385 other.target_thread_id == b.target_thread_id || other.genesis_digest == b.genesis_digest
1386 }) {
1387 return Err(Reject::Scope);
1388 }
1389 if i > 0 && value.branches[i - 1].ref_name.as_bytes() >= b.ref_name.as_bytes() {
1390 return Err(Reject::Canonical);
1391 }
1392 }
1393 Ok(())
1394}
1395fn scope_subset(child: &ImportPermissionScopeV1, parent: &ImportPermissionScopeV1) -> bool {
1396 child.provider == parent.provider
1397 && child.source_url == parent.source_url
1398 && child.destination_version == parent.destination_version
1399 && child.options_digest == parent.options_digest
1400 && child.converter_version == parent.converter_version
1401 && child.max_operations <= parent.max_operations
1402 && child.max_result_bytes <= parent.max_result_bytes
1403 && child.branches.iter().all(|c| parent.branches.contains(c))
1404}
1405
1406pub struct ImportOwnerExpectation<'a> {
1410 pub identity: &'a ImportIdentityV1,
1411 pub owner_public_key: &'a [u8],
1412 pub owner_chain_digest: &'a [u8],
1413 pub authority_expires_at_seconds: i64,
1416 pub now_unix_seconds: i64,
1417 pub forbidden_job_keys: &'a [Vec<u8>], pub known_job_associations: &'a [(Vec<u8>, Vec<u8>)], }
1420#[derive(Clone, Copy)]
1423pub struct ImportBundleOwnerExpectation<'a> {
1424 pub identity: &'a ImportIdentityV1,
1425 pub owner_public_key: &'a [u8],
1426 pub owner_chain_digest: &'a [u8],
1427 pub authority_expires_at_seconds: i64,
1428 pub effective_from_unix_seconds: i64,
1430 pub effective_until_unix_seconds: Option<i64>,
1431 pub forbidden_job_keys: &'a [Vec<u8>],
1432 pub forbidden_landing_keys: &'a [Vec<u8>],
1433 pub known_job_associations: &'a [(Vec<u8>, Vec<u8>)],
1434}
1435impl<'a> ImportBundleOwnerExpectation<'a> {
1436 fn at(self, time: i64, associations: &'a [(Vec<u8>, Vec<u8>)]) -> ImportOwnerExpectation<'a> {
1437 ImportOwnerExpectation {
1438 identity: self.identity,
1439 owner_public_key: self.owner_public_key,
1440 owner_chain_digest: self.owner_chain_digest,
1441 authority_expires_at_seconds: self.authority_expires_at_seconds,
1442 now_unix_seconds: time,
1443 forbidden_job_keys: self.forbidden_job_keys,
1444 known_job_associations: associations,
1445 }
1446 }
1447}
1448pub fn effective_owner_authority_expiry(
1451 deferred_human: bool,
1452 claimable_until: i64,
1453) -> Result<i64, Reject> {
1454 if deferred_human {
1455 if claimable_until <= 0 {
1456 return Err(Reject::Scope);
1457 }
1458 Ok(claimable_until)
1459 } else {
1460 Ok(i64::MAX)
1461 }
1462}
1463pub fn verify_member_permission(
1464 signed: &SignedImportMemberPermissionV1,
1465 expected: &ImportOwnerExpectation<'_>,
1466) -> Result<(), Reject> {
1467 verify_member_permission_inner(signed, expected, true)
1468}
1469fn verify_member_permission_inner(
1470 signed: &SignedImportMemberPermissionV1,
1471 expected: &ImportOwnerExpectation<'_>,
1472 current: bool,
1473) -> Result<(), Reject> {
1474 let p = signed.body.as_ref().ok_or(Reject::ImportPermission)?;
1475 if p.format_version != 1 || p.purpose != 1 {
1476 return Err(Reject::ImportPermission);
1477 }
1478 identity(p.identity.as_ref().ok_or(Reject::Canonical)?)?;
1479 if p.identity.as_ref() != Some(expected.identity)
1480 || p.owner_chain_digest != expected.owner_chain_digest
1481 {
1482 return Err(Reject::Root);
1483 }
1484 width(&p.logical_job_id, 16)?;
1485 width(&p.retry_lineage_id, 16)?;
1486 width(&p.subject_public_key, 32)?;
1487 width(&p.cancellation_id, 32)?;
1488 width(&p.nonce, 32)?;
1489 width(&p.owner_chain_digest, 32)?;
1490 validate_scope(p.scope.as_ref().ok_or(Reject::Canonical)?)?;
1491 validity(
1492 p.not_before_unix_seconds,
1493 p.expires_at_unix_seconds,
1494 expected.now_unix_seconds,
1495 current,
1496 )?;
1497 if p.expires_at_unix_seconds > expected.authority_expires_at_seconds {
1498 return Err(Reject::Scope);
1499 }
1500 verify_authorization_signature(
1501 expected.owner_public_key,
1502 PERMISSION_DOMAIN,
1503 p,
1504 signed.owner_signature.as_ref().ok_or(Reject::Signature)?,
1505 )
1506}
1507
1508#[derive(Debug, Clone, PartialEq)]
1509pub struct VerifiedImportDelegation {
1510 body: ImportJobDelegationV1,
1511 digest: Vec<u8>,
1512 member: Option<SignedImportMemberPermissionV1>,
1513}
1514impl VerifiedImportDelegation {
1515 pub fn body(&self) -> &ImportJobDelegationV1 {
1516 &self.body
1517 }
1518 pub fn digest(&self) -> &[u8] {
1519 &self.digest
1520 }
1521}
1522pub fn verify_delegation(
1523 signed: &SignedImportJobDelegationV1,
1524 member: Option<&SignedImportMemberPermissionV1>,
1525 expected: &ImportOwnerExpectation<'_>,
1526) -> Result<VerifiedImportDelegation, Reject> {
1527 verify_delegation_inner(signed, member, expected, true)
1528}
1529fn verify_delegation_inner(
1530 signed: &SignedImportJobDelegationV1,
1531 member: Option<&SignedImportMemberPermissionV1>,
1532 expected: &ImportOwnerExpectation<'_>,
1533 current: bool,
1534) -> Result<VerifiedImportDelegation, Reject> {
1535 let d = signed.body.as_ref().ok_or(Reject::Canonical)?;
1536 if d.format_version != 1 || d.purpose != 1 {
1537 return Err(Reject::Version);
1538 }
1539 identity(d.identity.as_ref().ok_or(Reject::Canonical)?)?;
1540 if d.identity.as_ref() != Some(expected.identity)
1541 || d.owner_chain_digest != expected.owner_chain_digest
1542 {
1543 return Err(Reject::Root);
1544 }
1545 for v in [&d.delegation_id, &d.logical_job_id, &d.retry_lineage_id] {
1546 width(v, 16)?;
1547 if v.iter().all(|b| *b == 0) {
1548 return Err(Reject::Canonical);
1549 }
1550 }
1551 for v in [
1552 &d.job_public_key,
1553 &d.job_key_id,
1554 &d.delegating_public_key,
1555 &d.parent_permission_digest,
1556 &d.owner_chain_digest,
1557 &d.cancellation_id,
1558 ] {
1559 width(v, 32)?;
1560 }
1561 if d.job_key_id != key_id(&d.job_public_key) {
1562 return Err(Reject::Canonical);
1563 }
1564 if d.job_public_key == d.delegating_public_key
1565 || d.job_public_key == expected.owner_public_key
1566 || expected.forbidden_job_keys.contains(&d.job_public_key)
1567 {
1568 return Err(Reject::KeyRole);
1569 }
1570 if expected
1571 .known_job_associations
1572 .iter()
1573 .any(|(k, j)| k == &d.job_public_key && j != &d.logical_job_id)
1574 {
1575 return Err(Reject::Scope);
1576 }
1577 let scope = d.scope.as_ref().ok_or(Reject::Canonical)?;
1578 validate_scope(scope)?;
1579 if d.branch_manifest.len() != scope.branches.len() {
1580 return Err(Reject::Scope);
1581 }
1582 for (m, b) in d.branch_manifest.iter().zip(&scope.branches) {
1583 width(&m.genesis_authority_digest, 32)?;
1584 if m.limit.as_ref() != Some(b) {
1585 return Err(Reject::Scope);
1586 }
1587 }
1588 if i128::from(d.expires_at_unix_seconds) - i128::from(d.not_before_unix_seconds)
1589 > i128::from(MAX_DELEGATION_WINDOW_SECONDS)
1590 {
1591 return Err(Reject::ValidityBounds);
1592 }
1593 validity(
1594 d.not_before_unix_seconds,
1595 d.expires_at_unix_seconds,
1596 expected.now_unix_seconds,
1597 current,
1598 )?;
1599 if d.expires_at_unix_seconds > expected.authority_expires_at_seconds {
1600 return Err(Reject::Scope);
1601 }
1602 if d.delegating_public_key == expected.owner_public_key {
1603 if member.is_some() || d.parent_permission_digest != vec![0; 32] {
1604 return Err(Reject::ImportPermission);
1605 }
1606 } else {
1607 let member = member.ok_or(Reject::ImportPermission)?;
1608 verify_member_permission_inner(member, expected, current)?;
1609 let p = member.body.as_ref().ok_or(Reject::ImportPermission)?;
1610 if d.parent_permission_digest != signed_permission_digest(member)?
1611 || d.delegating_public_key != p.subject_public_key
1612 || d.logical_job_id != p.logical_job_id
1613 || d.retry_lineage_id != p.retry_lineage_id
1614 || d.not_before_unix_seconds < p.not_before_unix_seconds
1615 || d.expires_at_unix_seconds > p.expires_at_unix_seconds
1616 || !scope_subset(scope, p.scope.as_ref().ok_or(Reject::Canonical)?)
1617 {
1618 return Err(Reject::Scope);
1619 }
1620 }
1621 verify_authorization_signature(
1622 &d.delegating_public_key,
1623 DELEGATION_DOMAIN,
1624 d,
1625 signed
1626 .delegating_signature
1627 .as_ref()
1628 .ok_or(Reject::Signature)?,
1629 )?;
1630 Ok(VerifiedImportDelegation {
1631 body: d.clone(),
1632 digest: signed_delegation_digest(signed)?,
1633 member: member.cloned(),
1634 })
1635}
1636pub fn delegation_preparation(d: &ImportJobDelegationV1) -> ImportJobPreparationV1 {
1639 ImportJobPreparationV1 {
1640 format_version: d.format_version,
1641 identity: d.identity.clone(),
1642 delegation_id: d.delegation_id.clone(),
1643 logical_job_id: d.logical_job_id.clone(),
1644 retry_lineage_id: d.retry_lineage_id.clone(),
1645 job_public_key: d.job_public_key.clone(),
1646 job_key_id: d.job_key_id.clone(),
1647 owner_chain_digest: d.owner_chain_digest.clone(),
1648 purpose: d.purpose,
1649 scope: d.scope.clone(),
1650 cancellation_id: d.cancellation_id.clone(),
1651 }
1652}
1653
1654pub fn verify_prepared_delegation(
1658 prepared: &PrepareImportJobResponse,
1659 signed: &SignedImportJobDelegationV1,
1660 member: Option<&SignedImportMemberPermissionV1>,
1661 geneses: &[SignedImportGenesisAuthorityV1],
1662 expected: &ImportOwnerExpectation<'_>,
1663) -> Result<VerifiedImportDelegation, Reject> {
1664 verify_prepared_inner(prepared, signed, member, geneses, expected, false)
1665}
1666pub fn preflight_prepared_delegation(
1668 prepared: &PrepareImportJobResponse,
1669 signed: &SignedImportJobDelegationV1,
1670 member: Option<&SignedImportMemberPermissionV1>,
1671 geneses: &[SignedImportGenesisAuthorityV1],
1672 expected: &ImportOwnerExpectation<'_>,
1673) -> Result<(), Reject> {
1674 verify_prepared_inner(prepared, signed, member, geneses, expected, true).map(|_| ())
1675}
1676fn verify_prepared_inner(
1677 prepared: &PrepareImportJobResponse,
1678 signed: &SignedImportJobDelegationV1,
1679 member: Option<&SignedImportMemberPermissionV1>,
1680 geneses: &[SignedImportGenesisAuthorityV1],
1681 expected: &ImportOwnerExpectation<'_>,
1682 browser: bool,
1683) -> Result<VerifiedImportDelegation, Reject> {
1684 let proposal = prepared.proposal.as_ref().ok_or(Reject::Canonical)?;
1685 let d = signed.body.as_ref().ok_or(Reject::Canonical)?;
1686 if canonical(proposal)? != canonical(&delegation_preparation(d))? {
1687 return Err(Reject::PreparedFields);
1688 }
1689 let scope = proposal.scope.as_ref().ok_or(Reject::Canonical)?;
1690 if d.branch_manifest.len() != scope.branches.len() {
1691 return Err(Reject::PreparedFields);
1692 }
1693 for (m, b) in d.branch_manifest.iter().zip(&scope.branches) {
1694 let limit = m.limit.as_ref().ok_or(Reject::PreparedFields)?;
1695 if canonical(limit)? != canonical(b)? {
1696 return Err(Reject::PreparedFields);
1697 }
1698 }
1699 let now = i128::from(expected.now_unix_seconds);
1702 let start = i128::from(d.not_before_unix_seconds);
1703 let end = i128::from(d.expires_at_unix_seconds);
1704 let at = i128::from(prepared.prepared_at_unix_seconds);
1705 let skew = i128::from(prepared.clock_skew_allowance_seconds);
1706 if at < 0
1707 || now < 0
1708 || if browser { now + skew < at } else { now < at }
1709 || i128::from(prepared.reservation_expires_at_unix_seconds) != at + 3600
1710 || now >= i128::from(prepared.reservation_expires_at_unix_seconds)
1711 {
1712 return Err(Reject::Expired);
1713 }
1714 if prepared.max_validity_duration_seconds == 0
1715 || prepared.max_validity_duration_seconds > MAX_DELEGATION_WINDOW_SECONDS
1716 || start < 0
1717 || start < at - skew
1718 || start > now + skew
1719 || end <= start
1720 || end <= now
1721 || end - start > i128::from(prepared.max_validity_duration_seconds)
1722 {
1723 return Err(Reject::ValidityBounds);
1724 }
1725 if let Some(parent) = member {
1726 if browser {
1727 let p = parent.body.as_ref().ok_or(Reject::ImportPermission)?;
1728 verify_member_permission_inner(parent, expected, false)?;
1729 if i128::from(p.not_before_unix_seconds) > now + skew
1730 || i128::from(p.expires_at_unix_seconds) <= now
1731 {
1732 return Err(Reject::Expired);
1733 }
1734 } else {
1735 verify_member_permission(parent, expected)?;
1736 }
1737 }
1738 let at_start = ImportOwnerExpectation {
1742 now_unix_seconds: expected.now_unix_seconds.max(d.not_before_unix_seconds),
1743 ..*expected
1744 };
1745 let verified = verify_delegation_inner(
1746 signed,
1747 member,
1748 if browser { expected } else { &at_start },
1749 !browser,
1750 )?;
1751 if geneses.len() != d.branch_manifest.len() {
1752 return Err(Reject::GenesisBinding);
1753 }
1754 for m in &d.branch_manifest {
1755 let branch = m.limit.as_ref().ok_or(Reject::Canonical)?;
1756 let g = geneses
1757 .iter()
1758 .find(|g| signed_genesis_digest(g).is_ok_and(|h| h == m.genesis_authority_digest))
1759 .ok_or(Reject::GenesisBinding)?;
1760 let body = g.body.as_ref().ok_or(Reject::GenesisBinding)?;
1761 if body.genesis_digest != branch.genesis_digest {
1762 return Err(Reject::GenesisBinding);
1763 }
1764 {
1765 verify_genesis_authority(
1766 g,
1767 &verified,
1768 &branch.genesis_digest,
1769 &body.original_creator_signature,
1770 &body.creator_authority_envelope_digest,
1771 )?;
1772 }
1773 }
1774 Ok(verified)
1775}
1776
1777pub fn verify_genesis_authority(
1778 signed: &SignedImportGenesisAuthorityV1,
1779 delegation: &VerifiedImportDelegation,
1780 original_genesis_digest: &[u8],
1781 original_signature: &[u8],
1782 envelope_digest: &[u8],
1783) -> Result<(), Reject> {
1784 let g = signed.body.as_ref().ok_or(Reject::Canonical)?;
1785 let d = &delegation.body;
1786 if g.format_version != 1 {
1787 return Err(Reject::Version);
1788 }
1789 width(&g.original_creator_signature, 64)?;
1790 for v in [
1791 &g.genesis_digest,
1792 &g.creator_public_key,
1793 &g.creator_authority_envelope_digest,
1794 &g.parent_permission_digest,
1795 &g.owner_chain_digest,
1796 ] {
1797 width(v, 32)?;
1798 }
1799 if g.identity != d.identity
1800 || g.creator_public_key != d.delegating_public_key
1801 || g.parent_permission_digest != d.parent_permission_digest
1802 || g.owner_chain_digest != d.owner_chain_digest
1803 || g.genesis_digest != original_genesis_digest
1804 || g.original_creator_signature != original_signature
1805 || g.creator_authority_envelope_digest != envelope_digest
1806 || !d.branch_manifest.iter().any(|m| {
1807 m.limit
1808 .as_ref()
1809 .is_some_and(|b| b.genesis_digest == g.genesis_digest)
1810 && signed_genesis_digest(signed).is_ok_and(|h| h == m.genesis_authority_digest)
1811 })
1812 {
1813 return Err(Reject::Scope);
1814 }
1815 verify_authorization_signature(
1816 &g.creator_public_key,
1817 GENESIS_DOMAIN,
1818 g,
1819 signed.creator_signature.as_ref().ok_or(Reject::Signature)?,
1820 )
1821}
1822pub fn verify_operation(
1825 signed: &SignedDelegatedImportOperationV1,
1826 delegation: &VerifiedImportDelegation,
1827) -> Result<(), Reject> {
1828 let o = signed.body.as_ref().ok_or(Reject::Canonical)?;
1829 let d = &delegation.body;
1830 if o.format_version != 1 {
1831 return Err(Reject::Version);
1832 }
1833 let id = d.identity.as_ref().ok_or(Reject::Canonical)?;
1834 width(&o.physical_operation_id, 16)?;
1835 for v in [
1836 &o.spool_genesis_digest,
1837 &o.delegation_digest,
1838 &o.genesis_digest,
1839 &o.target_thread_id,
1840 &o.expected_frontier_digest,
1841 &o.resulting_frontier_digest,
1842 &o.resulting_content_digest,
1843 &o.options_digest,
1844 ] {
1845 width(v, 32)?;
1846 }
1847 width(&o.spool_uuid, 16)?;
1848 width(&o.logical_job_id, 16)?;
1849 width(&o.retry_lineage_id, 16)?;
1850 let scope = d.scope.as_ref().ok_or(Reject::Canonical)?;
1851 let b = scope
1852 .branches
1853 .iter()
1854 .find(|b| b.ref_name == o.ref_name && b.slot_id == o.slot_id)
1855 .ok_or(Reject::Scope)?;
1856 let oid_len = match o.hash_algorithm {
1857 1 => 20,
1858 2 => 32,
1859 _ => return Err(Reject::Version),
1860 };
1861 width(&o.observed_commit_oid, oid_len)?;
1862 if o.spool_uuid != id.spool_uuid
1863 || o.spool_genesis_digest != id.spool_genesis_digest
1864 || o.logical_job_id != d.logical_job_id
1865 || o.retry_lineage_id != d.retry_lineage_id
1866 || o.delegation_digest != delegation.digest
1867 || o.hash_algorithm != b.hash_algorithm
1868 || (b.ref_mode == 1 && o.observed_commit_oid != b.pinned_commit_oid)
1869 || o.genesis_digest != b.genesis_digest
1870 || o.target_thread_id != b.target_thread_id
1871 || o.expected_frontier_digest != b.expected_frontier_digest
1872 || o.result_bytes > scope.max_result_bytes
1873 || o.result_bytes == 0
1874 || o.options_digest != scope.options_digest
1875 || o.converter_version != scope.converter_version
1876 {
1877 return Err(Reject::Scope);
1878 }
1879 verify_authorization_signature(
1880 &d.job_public_key,
1881 OPERATION_DOMAIN,
1882 o,
1883 signed.job_signature.as_ref().ok_or(Reject::Signature)?,
1884 )
1885}
1886pub fn verify_new_operation(
1887 signed: &SignedDelegatedImportOperationV1,
1888 delegation: &VerifiedImportDelegation,
1889 now_seconds: i64,
1890 committed_before: &ImportResultManifestV1,
1891) -> Result<(), Reject> {
1892 interval(
1893 delegation.body.not_before_unix_seconds,
1894 delegation.body.expires_at_unix_seconds,
1895 now_seconds,
1896 )?;
1897 check_import_publication_budget(signed, delegation, committed_before)
1898}
1899pub fn validate_manifest(m: &ImportResultManifestV1) -> Result<(), Reject> {
1900 if m.format_version != 1 {
1901 return Err(Reject::Version);
1902 }
1903 width(&m.logical_job_id, 16)?;
1904 width(&m.retry_lineage_id, 16)?;
1905 if m.slots.len() > MAX_BRANCHES {
1906 return Err(Reject::Bounds);
1907 }
1908 for (i, s) in m.slots.iter().enumerate() {
1909 width(&s.signed_operation_digest, 32)?;
1910 width(&s.resulting_frontier_digest, 32)?;
1911 branch_ref(&s.ref_name)?;
1912 if s.result_bytes == 0 {
1913 return Err(Reject::Bounds);
1914 }
1915 if i > 0 && (&m.slots[i - 1].ref_name, m.slots[i - 1].slot_id) >= (&s.ref_name, s.slot_id) {
1916 return Err(Reject::Canonical);
1917 }
1918 }
1919 Ok(())
1920}
1921
1922pub fn check_slot_replay(
1925 committed: &ImportResultManifestV1,
1926 signed: &SignedDelegatedImportOperationV1,
1927) -> Result<bool, Reject> {
1928 validate_manifest(committed)?;
1929 let o = signed.body.as_ref().ok_or(Reject::Canonical)?;
1930 if committed.logical_job_id != o.logical_job_id
1931 || committed.retry_lineage_id != o.retry_lineage_id
1932 {
1933 return Err(Reject::Scope);
1934 }
1935 match committed
1936 .slots
1937 .iter()
1938 .find(|s| s.ref_name == o.ref_name && s.slot_id == o.slot_id)
1939 {
1940 Some(s)
1941 if s.signed_operation_digest == signed_operation_digest(signed)?
1942 && s.resulting_frontier_digest == o.resulting_frontier_digest
1943 && s.result_bytes == o.result_bytes =>
1944 {
1945 Ok(true)
1946 }
1947 Some(_) => Err(Reject::SlotConflict),
1948 None => Ok(false),
1949 }
1950}
1951pub fn verify_publication(
1955 operation: &SignedDelegatedImportOperationV1,
1956 delegation: &VerifiedImportDelegation,
1957 manifest: &ImportResultManifestV1,
1958 statement: &crate::heddle::api::common::SignedHostedWitnessStatementV1,
1959 set: &crate::witness_trust::VerifiedWitnessSet,
1960 proof: Option<&crate::heddle::api::common::HostedWitnessHistoryProofV1>,
1961 now_ms: i64,
1962) -> Result<crate::witness_trust::ResolvedWitnessStatement, Reject> {
1963 validate_statement_boundary(statement.body.as_ref().ok_or(Reject::Canonical)?)?;
1964 verify_operation(operation, delegation)?;
1965 if !check_slot_replay(manifest, operation)? {
1966 return Err(Reject::Scope);
1967 }
1968 let mut before = manifest.clone();
1969 let o = operation.body.as_ref().ok_or(Reject::Canonical)?;
1970 before
1971 .slots
1972 .retain(|slot| slot.ref_name != o.ref_name || slot.slot_id != o.slot_id);
1973 check_import_publication_budget(operation, delegation, &before)?;
1974 let d = &delegation.body;
1975 let id = d.identity.as_ref().ok_or(Reject::Canonical)?;
1976 let s = statement.body.as_ref().ok_or(Reject::Canonical)?;
1977 let payload = ImportPublicationWitnessV1 {
1978 format_version: 1,
1979 signed_operation_digest: signed_operation_digest(operation)?,
1980 delegation_digest: delegation.digest.clone(),
1981 logical_job_id: o.logical_job_id.clone(),
1982 retry_lineage_id: o.retry_lineage_id.clone(),
1983 physical_operation_id: o.physical_operation_id.clone(),
1984 ref_name: o.ref_name.clone(),
1985 slot_id: o.slot_id,
1986 hash_algorithm: o.hash_algorithm,
1987 observed_commit_oid: o.observed_commit_oid.clone(),
1988 expected_frontier_digest: o.expected_frontier_digest.clone(),
1989 resulting_frontier_digest: o.resulting_frontier_digest.clone(),
1990 terminal_manifest_digest: manifest_digest(manifest)?,
1991 };
1992 if s.purpose != 3
1993 || s.spool_uuid != id.spool_uuid
1994 || s.spool_genesis_digest != id.spool_genesis_digest
1995 || s.owner_id != id.owner_id
1996 || s.owner_state_hash != id.owner_state_hash
1997 || s.ownership_transfer_sequence != id.ownership_transfer_sequence
1998 || s.authority_digest != delegation.digest
1999 || s.original_signatures_digest
2000 != hash(&[&operation
2001 .job_signature
2002 .as_ref()
2003 .ok_or(Reject::Signature)?
2004 .signature])
2005 || s.canonical_payload != canonical(&payload)?
2006 || s.basis != 1
2007 {
2008 return Err(Reject::Scope);
2009 }
2010 interval(
2011 d.not_before_unix_seconds,
2012 d.expires_at_unix_seconds,
2013 s.observed_at_unix_millis / 1000,
2014 )?;
2015 crate::witness_trust::resolve_statement(set, statement, proof, false, now_ms)
2016}
2017pub fn require_hybrid_peer(
2018 protocol: Option<&crate::heddle::api::common::ProtocolCompatibility>,
2019) -> Result<(), Reject> {
2020 let protocol = protocol.ok_or(Reject::Protocol)?;
2021 if protocol.protocol_version != 2
2022 || !protocol.mandatory_features.contains(&1)
2023 || protocol
2024 .mandatory_features
2025 .windows(2)
2026 .any(|pair| pair[0] >= pair[1])
2027 || protocol.mandatory_features.iter().any(|&feature| {
2028 crate::heddle::api::common::MandatoryProtocolFeature::try_from(feature)
2029 .map_or(true, |f| {
2030 f == crate::heddle::api::common::MandatoryProtocolFeature::Unspecified
2031 })
2032 })
2033 {
2034 return Err(Reject::Protocol);
2035 }
2036 Ok(())
2037}
2038
2039pub fn check_job_fence(
2042 logical_job_id: &[u8],
2043 active_delegation_digest: &[u8],
2044 expected_epoch: u64,
2045 active: &VerifiedImportDelegation,
2046 durable_epoch: u64,
2047) -> Result<(), Reject> {
2048 if logical_job_id != active.body.logical_job_id {
2049 return Err(Reject::Scope);
2050 }
2051 if expected_epoch != durable_epoch || active_delegation_digest != active.digest {
2052 return Err(Reject::StaleContext);
2053 }
2054 Ok(())
2055}
2056pub fn validate_public_bundle(bundle: &ImportPublicProofBundleV1) -> Result<(), Reject> {
2057 validate_bundle_bounds(bundle)?;
2058 validate_bundle_history(bundle, true)
2059}
2060fn validate_bundle_bounds(bundle: &ImportPublicProofBundleV1) -> Result<(), Reject> {
2061 crate::writer_authority::validate_owner_histories(&bundle.owner_histories)?;
2062 use prost::Message;
2063 if bundle.format_version != 1 {
2064 return Err(Reject::Version);
2065 }
2066 if bundle.encoded_len() > MAX_BUNDLE_BYTES
2067 || bundle.owner_histories.len() > 64
2068 || bundle.ownership_transfers.len() > 64
2069 || bundle.genesis_authorities.len() > MAX_BRANCHES
2070 || bundle.delegations.len() != 1
2071 || bundle.operations.len() > MAX_BRANCHES
2072 || bundle.statements.len() > 1024
2073 || bundle.history_proofs.len() > 1024
2074 || bundle.policies.len() > 256
2075 || bundle.original_geneses.len() > MAX_BRANCHES
2076 || bundle.creator_authority_envelopes.len() > MAX_BRANCHES
2077 || bundle.manifests.len() > MAX_BRANCHES
2078 || bundle.genesis_witnesses.len() > 256
2079 || bundle.authority_witnesses.len() > 256
2080 || bundle.landing_witnesses.len() > 256
2081 {
2082 return Err(Reject::Bounds);
2083 }
2084 Ok(())
2085}
2086
2087pub enum ImportPermissionEvidence<'a> {
2090 Import(&'a SignedImportMemberPermissionV1),
2091 OwnerCapability(&'a SignedOwnerCapability),
2092 OnlineRole(&'a str),
2093}
2094pub fn select_import_permission(
2095 evidence: ImportPermissionEvidence<'_>,
2096) -> Result<&SignedImportMemberPermissionV1, Reject> {
2097 match evidence {
2098 ImportPermissionEvidence::Import(p) => Ok(p),
2099 ImportPermissionEvidence::OwnerCapability(_) | ImportPermissionEvidence::OnlineRole(_) => {
2100 Err(Reject::ImportPermission)
2101 }
2102 }
2103}
2104pub fn require_import_operation_format(format: &str) -> Result<(), Reject> {
2106 if format != OPERATION_DOMAIN {
2107 return Err(Reject::Protocol);
2108 }
2109 Ok(())
2110}
2111pub fn frontier_digest(frontier: &ImportFrontierV1) -> Result<Vec<u8>, Reject> {
2112 if frontier.format_version != 1 {
2113 return Err(Reject::Version);
2114 }
2115 width(&frontier.thread_id, 32)?;
2116 if frontier.operation_ids.len() > 128 {
2117 return Err(Reject::Bounds);
2118 }
2119 for id in &frontier.operation_ids {
2120 width(id, 32)?;
2121 }
2122 if frontier.operation_ids.windows(2).any(|w| w[0] >= w[1]) {
2123 return Err(Reject::Canonical);
2124 }
2125 signing_digest("heddle-import-frontier-v1", frontier)
2126}
2127pub fn content_digest(content: &ImportContentV1) -> Result<Vec<u8>, Reject> {
2128 if content.format_version != 1 {
2129 return Err(Reject::Version);
2130 }
2131 if content.canonical_capture.is_empty() {
2132 return Err(Reject::Bounds);
2133 }
2134 signing_digest("heddle-import-content-v1", content)
2135}
2136pub fn signed_native_digest(record: &SignedRecord) -> Result<Vec<u8>, Reject> {
2137 signing_digest("heddle-signed-native-record-v1", record)
2138}
2139pub(crate) fn verify_native(record: &SignedRecord, format: &str) -> Result<(), Reject> {
2140 if record.format != format {
2141 return Err(Reject::Version);
2142 }
2143 if record.canonical_record.is_empty()
2144 || record.canonical_record.len() > MAX_RECORD_BYTES
2145 || record.signatures.is_empty()
2146 || record.signatures.len() > 16
2147 {
2148 return Err(Reject::Bounds);
2149 }
2150 let mut previous: Option<&[u8]> = None;
2151 let input = [format.as_bytes(), b"\0", &record.canonical_record].concat();
2152 for s in &record.signatures {
2153 if previous.is_some_and(|p| p >= s.public_key.as_slice()) {
2154 return Err(Reject::Canonical);
2155 }
2156 verify(&s.public_key, &input, &s.signature)?;
2157 previous = Some(&s.public_key);
2158 }
2159 Ok(())
2160}
2161pub fn verify_boundary_acceptance(e: &ImportBoundaryAcceptanceV1) -> Result<(), Reject> {
2165 let binding = e.binding.as_ref().ok_or(Reject::BoundaryAcceptance)?;
2166 validate_boundary_binding(binding)?;
2167 let acceptance = e
2168 .signed_acceptance
2169 .as_ref()
2170 .ok_or(Reject::BoundaryAcceptance)?;
2171 verify_native(acceptance, "heddle-original-boundary-acceptance-v1")?;
2172 if acceptance.signatures.len() != 1 {
2173 return Err(Reject::Signature);
2174 }
2175 if e.originals_manifest.is_empty()
2176 || e.publication_intent.is_empty()
2177 || e.originals_manifest.len() > MAX_RECORD_BYTES
2178 || e.publication_intent.len() > MAX_RECORD_BYTES
2179 || e.original_receipts.is_empty()
2180 || e.original_receipts.len() > 128
2181 {
2182 return Err(Reject::Bounds);
2183 }
2184 if binding.acceptance_id != native_id(acceptance)
2185 || binding.signed_acceptance_digest != signed_native_digest(acceptance)?
2186 || binding.originals_manifest_digest
2187 != boundary_octets_digest(
2188 "heddle-boundary-originals-manifest-v1",
2189 &e.originals_manifest,
2190 )
2191 || binding.publication_intent_digest
2192 != boundary_octets_digest(
2193 "heddle-boundary-publication-intent-v1",
2194 &e.publication_intent,
2195 )
2196 {
2197 return Err(Reject::BoundaryAcceptance);
2198 }
2199 let native: NativeBoundarySelection =
2200 rmp_serde::from_slice(&acceptance.canonical_record).map_err(|_| Reject::Canonical)?;
2201 if native.originals_manifest.as_slice()
2202 != native_octets_id(
2203 "heddle-original-publication-manifest-v1",
2204 &e.originals_manifest,
2205 )
2206 || native.publication_intent.as_slice()
2207 != native_octets_id(
2208 "heddle-original-publication-intent-v1",
2209 &e.publication_intent,
2210 )
2211 {
2212 return Err(Reject::BoundaryAcceptance);
2213 }
2214 let mut digests = Vec::new();
2215 for receipt in &e.original_receipts {
2216 if ![
2217 "heddle-thread-genesis-admission-v2",
2218 "heddle-thread-authority-admission-v3",
2219 ]
2220 .contains(&receipt.format.as_str())
2221 {
2222 return Err(Reject::Version);
2223 }
2224 verify_native(receipt, &receipt.format)?;
2225 if receipt.signatures.len() != 1 {
2226 return Err(Reject::Signature);
2227 }
2228 let native: NativeBoundaryReceipt =
2229 rmp_serde::from_slice(&receipt.canonical_record).map_err(|_| Reject::Canonical)?;
2230 if native.basis
2231 != (NativeBoundaryBasis::BoundaryAcceptance {
2232 acceptance: binding
2233 .acceptance_id
2234 .as_slice()
2235 .try_into()
2236 .map_err(|_| Reject::Canonical)?,
2237 })
2238 {
2239 return Err(Reject::BoundaryAcceptance);
2240 }
2241 digests.push(signed_native_digest(receipt)?);
2242 }
2243 if digests != binding.original_receipt_digests {
2244 return Err(Reject::BoundaryAcceptance);
2245 }
2246 Ok(())
2247}
2248#[derive(serde::Deserialize)]
2251struct NativeBoundarySelection {
2252 originals_manifest: [u8; 32],
2253 publication_intent: [u8; 32],
2254}
2255#[derive(serde::Deserialize, PartialEq)]
2256enum NativeBoundaryBasis {
2257 OriginalAuthority,
2258 BoundaryAcceptance { acceptance: [u8; 32] },
2259}
2260#[derive(serde::Deserialize)]
2261struct NativeBoundaryReceipt {
2262 basis: NativeBoundaryBasis,
2263 thread: [u8; 32],
2264 subject: Option<NativeBoundarySubject>,
2265}
2266#[derive(serde::Deserialize)]
2267enum NativeBoundarySubject {
2268 Operation([u8; 32]),
2269 OwnershipClaim([u8; 32]),
2270 OwnershipResolution([u8; 32]),
2271}
2272pub(crate) fn native_octets_id(format: &str, bytes: &[u8]) -> Vec<u8> {
2273 let mut h = blake3::Hasher::new();
2274 h.update(format.as_bytes());
2275 h.update(&(bytes.len() as u64).to_le_bytes());
2276 h.update(b"\0");
2277 h.update(bytes);
2278 h.finalize().as_bytes().to_vec()
2279}
2280pub(crate) fn boundary_original(
2281 e: &ImportBoundaryAcceptanceV1,
2282 original: &SignedRecord,
2283) -> Result<(), Reject> {
2284 let id = native_id(original);
2285 for receipt in &e.original_receipts {
2286 let value: NativeBoundaryReceipt =
2287 rmp_serde::from_slice(&receipt.canonical_record).map_err(|_| Reject::Canonical)?;
2288 let (format, subject) = match value.subject {
2289 None if receipt.format == "heddle-thread-genesis-admission-v2" => {
2290 ("heddle-thread-genesis-v1", value.thread)
2291 }
2292 Some(NativeBoundarySubject::Operation(id)) => ("heddle-thread-operation-v1", id),
2293 Some(NativeBoundarySubject::OwnershipClaim(id)) => {
2294 ("heddle-thread-ownership-claim-v1", id)
2295 }
2296 Some(NativeBoundarySubject::OwnershipResolution(id)) => {
2297 ("heddle-thread-ownership-resolution-v1", id)
2298 }
2299 _ => return Err(Reject::BoundaryAcceptance),
2300 };
2301 if original.format == format && id == subject {
2302 return Ok(());
2303 }
2304 }
2305 Err(Reject::BoundaryAcceptance)
2306}
2307pub fn boundary_octets_digest(domain: &str, bytes: &[u8]) -> Vec<u8> {
2308 hash(&[
2309 domain.as_bytes(),
2310 &(bytes.len() as u32).to_be_bytes(),
2311 bytes,
2312 ])
2313}
2314pub fn validate_boundary_binding(
2315 b: &crate::heddle::api::common::HostedWitnessBoundaryAcceptanceV1,
2316) -> Result<(), Reject> {
2317 if b.format_version != 1 {
2318 return Err(Reject::Version);
2319 }
2320 for digest in [
2321 &b.acceptance_id,
2322 &b.signed_acceptance_digest,
2323 &b.originals_manifest_digest,
2324 &b.publication_intent_digest,
2325 ] {
2326 width(digest, 32)?;
2327 }
2328 if b.original_receipt_digests.is_empty() || b.original_receipt_digests.len() > 128 {
2329 return Err(Reject::Bounds);
2330 }
2331 for digest in &b.original_receipt_digests {
2332 width(digest, 32)?;
2333 }
2334 if b.original_receipt_digests.windows(2).any(|w| w[0] >= w[1]) {
2335 return Err(Reject::Canonical);
2336 }
2337 Ok(())
2338}
2339pub fn validate_statement_boundary(
2340 s: &crate::heddle::api::common::HostedWitnessStatementV1,
2341) -> Result<(), Reject> {
2342 match (s.basis, s.boundary_acceptance.as_ref()) {
2343 (1, None) => Ok(()),
2344 (2, Some(b)) if s.purpose == 1 || s.purpose == 2 => validate_boundary_binding(b),
2345 _ => Err(Reject::BoundaryAcceptance),
2346 }
2347}
2348pub(crate) fn match_boundary(
2349 s: &crate::heddle::api::common::HostedWitnessStatementV1,
2350 evidence: &[ImportBoundaryAcceptanceV1],
2351) -> Result<(), Reject> {
2352 validate_statement_boundary(s)?;
2353 let mut previous = None;
2354 for e in evidence {
2355 verify_boundary_acceptance(e)?;
2356 let b = e.binding.as_ref().ok_or(Reject::BoundaryAcceptance)?;
2357 if previous.is_some_and(|p: &[u8]| p >= b.acceptance_id.as_slice()) {
2358 return Err(Reject::Canonical);
2359 }
2360 previous = Some(b.acceptance_id.as_slice());
2361 }
2362 if let Some(binding) = &s.boundary_acceptance
2363 && !evidence.iter().any(|e| e.binding.as_ref() == Some(binding))
2364 {
2365 return Err(Reject::BoundaryAcceptance);
2366 }
2367 Ok(())
2368}
2369fn native_dependencies(
2370 records: &[SignedRecord],
2371 evidence: &[ImportBoundaryAcceptanceV1],
2372 reviews_only: bool,
2373) -> Result<(), Reject> {
2374 if records.len() > 128 {
2375 return Err(Reject::Bounds);
2376 }
2377 let mut previous = None;
2378 for record in records {
2379 if reviews_only {
2380 require_review_operation(record)?;
2381 }
2382 match record.format.as_str() {
2383 "heddle-thread-genesis-v1"
2384 | "heddle-thread-operation-v1"
2385 | "heddle-thread-ownership-claim-v1"
2386 | "heddle-thread-ownership-resolution-v1" => (),
2387 "heddle-original-boundary-acceptance-v1"
2388 | "heddle-thread-genesis-admission-v2"
2389 | "heddle-thread-authority-admission-v3" => {
2390 if !evidence.iter().any(|e| {
2391 e.signed_acceptance.as_ref() == Some(record)
2392 || e.original_receipts.contains(record)
2393 }) {
2394 return Err(Reject::BoundaryAcceptance);
2395 }
2396 }
2397 _ => return Err(Reject::Version),
2398 }
2399 verify_native(record, &record.format)?;
2400 let digest = signed_native_digest(record)?;
2401 if previous.as_ref().is_some_and(|p| p >= &digest) {
2402 return Err(Reject::Canonical);
2403 }
2404 previous = Some(digest);
2405 }
2406 Ok(())
2407}
2408#[derive(serde::Deserialize)]
2409struct ReviewOperation {
2410 body: ReviewBody,
2411}
2412#[derive(serde::Deserialize)]
2413struct ReviewBody {
2414 kind: String,
2415 canonical: Vec<u8>,
2416}
2417#[derive(serde::Deserialize)]
2418struct ReviewControl {
2419 control: ReviewKind,
2420}
2421#[derive(serde::Deserialize)]
2422struct ReviewKind {
2423 kind: String,
2424}
2425fn require_review_operation(record: &SignedRecord) -> Result<(), Reject> {
2426 if record.format != "heddle-thread-operation-v1" {
2427 return Err(Reject::Semantic);
2428 }
2429 let operation: ReviewOperation =
2430 rmp_serde::from_slice(&record.canonical_record).map_err(|_| Reject::Semantic)?;
2431 if operation.body.kind != "metadata" {
2432 return Err(Reject::Semantic);
2433 }
2434 let control: ReviewControl =
2435 rmp_serde::from_slice(&operation.body.canonical).map_err(|_| Reject::Semantic)?;
2436 if control.control.kind != "review" {
2437 return Err(Reject::Semantic);
2438 }
2439 Ok(())
2440}
2441pub(crate) fn original_signatures(
2442 records: &[&SignedRecord],
2443 extra: &[RecordSignature],
2444) -> Result<Vec<u8>, Reject> {
2445 let signatures = records
2446 .iter()
2447 .flat_map(|r| r.signatures.iter())
2448 .chain(extra.iter())
2449 .collect::<Vec<_>>();
2450 let mut out = (signatures.len() as u32).to_be_bytes().to_vec();
2451 for s in signatures {
2452 s.write(&mut out)?;
2453 }
2454 Ok(hash(&[b"heddle-hosted-original-signatures-v1", &out]))
2455}
2456
2457#[cfg(test)]
2458#[test]
2459fn sha2_golden_original_signatures() {
2460 let first = SignedRecord {
2461 signatures: vec![RecordSignature {
2462 public_key: vec![1; 32],
2463 signature: vec![2; 64],
2464 }],
2465 ..Default::default()
2466 };
2467 let second = SignedRecord {
2468 signatures: vec![RecordSignature {
2469 public_key: vec![3; 32],
2470 signature: vec![4; 64],
2471 }],
2472 ..Default::default()
2473 };
2474 let extra = RecordSignature {
2475 public_key: vec![5; 32],
2476 signature: vec![6; 64],
2477 };
2478 assert_eq!(
2479 hex::encode(original_signatures(&[&first, &second], &[extra]).expect("fixed signatures")),
2480 "04bcf713a6a5fdec2bbfae89ee71894db72db54ca75597831fea7192ef9bf3e1"
2481 );
2482}
2483use crate::hybrid_codec::Canonical;
2484pub fn verify_landing_key_roles(
2486 payload: &HostedLandingWitnessV1,
2487 known_job_keys: &[Vec<u8>],
2488 forbidden_keys: &[Vec<u8>],
2489) -> Result<(), Reject> {
2490 let key = &payload
2491 .request
2492 .as_ref()
2493 .and_then(|r| r.signature.as_ref())
2494 .ok_or(Reject::Signature)?
2495 .public_key;
2496 if known_job_keys.contains(key) || forbidden_keys.contains(key) {
2497 return Err(Reject::KeyRole);
2498 }
2499 Ok(())
2500}
2501
2502#[derive(Clone, Copy)]
2504pub enum WitnessPayload<'a> {
2505 Genesis(&'a ImportGenesisWitnessV1),
2506 Authority(&'a ImportAuthorityWitnessV1),
2507 Landing(&'a HostedLandingWitnessV1),
2508}
2509pub fn verify_witness_payload(
2510 statement: &crate::heddle::api::common::HostedWitnessStatementV1,
2511 payload: WitnessPayload<'_>,
2512) -> Result<(), Reject> {
2513 let (purpose, bytes, authority, signatures, publisher) = match payload {
2514 WitnessPayload::Genesis(p) => {
2515 if p.format_version != 1 {
2516 return Err(Reject::Version);
2517 }
2518 let original = p.original_genesis.as_ref().ok_or(Reject::Canonical)?;
2519 let binding = p.binding.as_ref().ok_or(Reject::Canonical)?;
2520 let b = binding.body.as_ref().ok_or(Reject::Canonical)?;
2521 match_boundary(
2522 statement,
2523 &p.boundary_acceptance.iter().cloned().collect::<Vec<_>>(),
2524 )?;
2525 if let Some(e) = &p.boundary_acceptance {
2526 boundary_original(e, original)?;
2527 }
2528 if (statement.basis == 2) != p.boundary_acceptance.is_some() {
2529 return Err(Reject::BoundaryAcceptance);
2530 }
2531 verify_native(original, "heddle-thread-genesis-v1")?;
2532 if native_id(original) != b.genesis_digest {
2533 return Err(Reject::Scope);
2534 }
2535 if let Some(id) = &b.identity {
2536 if statement.spool_uuid != id.spool_uuid
2537 || statement.spool_genesis_digest != id.spool_genesis_digest
2538 || statement.owner_id != id.owner_id
2539 || statement.owner_state_hash != id.owner_state_hash
2540 || statement.ownership_transfer_sequence != id.ownership_transfer_sequence
2541 {
2542 return Err(Reject::Scope);
2543 }
2544 } else {
2545 return Err(Reject::Canonical);
2546 }
2547 let creator = original
2548 .signatures
2549 .iter()
2550 .find(|s| s.public_key == b.creator_public_key)
2551 .ok_or(Reject::Signature)?;
2552 if b.original_creator_signature != creator.signature
2553 || b.creator_authority_envelope_digest != hash(&[&p.creator_authority_envelope])
2554 {
2555 return Err(Reject::Scope);
2556 }
2557 verify_authorization_signature(
2558 &b.creator_public_key,
2559 GENESIS_DOMAIN,
2560 b,
2561 binding
2562 .creator_signature
2563 .as_ref()
2564 .ok_or(Reject::Signature)?,
2565 )?;
2566 (
2567 1,
2568 canonical(p)?,
2569 signed_genesis_digest(binding)?,
2570 original_signatures(&[original], &[])?,
2571 key_id(&b.creator_public_key),
2572 )
2573 }
2574 WitnessPayload::Authority(p) => {
2575 if p.format_version != 1 {
2576 return Err(Reject::Version);
2577 }
2578 let original = p.original.as_ref().ok_or(Reject::Canonical)?;
2579 let format = match p.kind {
2580 1 => "heddle-thread-operation-v1",
2581 2 => "heddle-thread-ownership-claim-v1",
2582 3 => "heddle-thread-ownership-resolution-v1",
2583 _ => return Err(Reject::Version),
2584 };
2585 verify_native(original, format)?;
2586 if (p.kind == 2 || p.kind == 3) && original.signatures.len() != 2 {
2587 return Err(Reject::Signature);
2588 }
2589 if !original
2590 .signatures
2591 .iter()
2592 .any(|s| key_id(&s.public_key) == statement.publisher_key_id)
2593 {
2594 return Err(Reject::Signature);
2595 }
2596 if p.authority_envelope.is_empty() || p.authority_envelope.len() > MAX_RECORD_BYTES {
2597 return Err(Reject::Bounds);
2598 }
2599 match_boundary(statement, &p.boundary_acceptances)?;
2600 if let Some(b) = &statement.boundary_acceptance {
2601 let e = p
2602 .boundary_acceptances
2603 .iter()
2604 .find(|e| e.binding.as_ref() == Some(b))
2605 .ok_or(Reject::BoundaryAcceptance)?;
2606 boundary_original(e, original)?;
2607 }
2608 native_dependencies(&p.dependencies, &p.boundary_acceptances, false)?;
2609 let records = std::iter::once(original)
2610 .chain(p.dependencies.iter())
2611 .collect::<Vec<_>>();
2612 (
2613 2,
2614 canonical(p)?,
2615 hash(&[
2616 b"heddle-hosted-authority-envelope-v1",
2617 &(p.authority_envelope.len() as u32).to_be_bytes(),
2618 &p.authority_envelope,
2619 ]),
2620 original_signatures(&records, &[])?,
2621 statement.publisher_key_id.clone(),
2622 )
2623 }
2624 WitnessPayload::Landing(p) => {
2625 if p.format_version != 1 {
2626 return Err(Reject::Version);
2627 }
2628 let execution = p.execution.as_ref().ok_or(Reject::Canonical)?;
2629 let source = p.source_operation.as_ref().ok_or(Reject::Canonical)?;
2630 let request = p.request.as_ref().ok_or(Reject::Canonical)?;
2631 if request.format_version != 1
2632 || request.method_path != "/heddle.api.v1alpha2.ThreadService/LandThread"
2633 {
2634 return Err(Reject::Version);
2635 }
2636 verify_native(execution, "heddle-thread-operation-v1")?;
2637 verify_native(source, "heddle-thread-operation-v1")?;
2638 match_boundary(statement, &[])?;
2639 native_dependencies(&p.review_evidence, &[], true)?;
2640 let signature = request.signature.as_ref().ok_or(Reject::Signature)?;
2641 if request.signing_identity
2642 != format!(
2643 "principal:device-key:{}",
2644 hex::encode(&signature.public_key)
2645 )
2646 {
2647 return Err(Reject::Signature);
2648 }
2649 width(&request.nonce, 16)?;
2650 if request.timestamp_millis <= 0
2651 || request.request_body.is_empty()
2652 || request.request_body.len() > MAX_RECORD_BYTES
2653 || p.authority_envelope.is_empty()
2654 || p.authority_envelope.len() > MAX_RECORD_BYTES
2655 {
2656 return Err(Reject::Bounds);
2657 }
2658 let input = crate::signing::unary_bytes(
2659 &request.signing_identity,
2660 &request.method_path,
2661 request.timestamp_millis,
2662 &request.nonce,
2663 &request.request_body,
2664 );
2665 verify(&signature.public_key, &input, &signature.signature)?;
2666 let records = [execution, source]
2667 .into_iter()
2668 .chain(p.review_evidence.iter())
2669 .collect::<Vec<_>>();
2670 (
2671 4,
2672 canonical(p)?,
2673 hash(&[
2674 b"heddle-hosted-authority-envelope-v1",
2675 &(p.authority_envelope.len() as u32).to_be_bytes(),
2676 &p.authority_envelope,
2677 ]),
2678 original_signatures(&records, std::slice::from_ref(signature))?,
2679 key_id(&signature.public_key),
2680 )
2681 }
2682 };
2683 if bytes.len() > MAX_RECORD_BYTES {
2684 return Err(Reject::Bounds);
2685 }
2686 if statement.purpose != purpose
2687 || statement.canonical_payload != bytes
2688 || statement.authority_digest != authority
2689 || statement.original_signatures_digest != signatures
2690 || statement.publisher_key_id != publisher
2691 {
2692 return Err(Reject::Scope);
2693 }
2694 Ok(())
2695}
2696
2697pub fn resolve_bundle_permission<'a>(
2698 bundle: &'a ImportPublicProofBundleV1,
2699 digest: &[u8],
2700) -> Result<Option<&'a SignedImportMemberPermissionV1>, Reject> {
2701 width(digest, 32)?;
2702 if digest == [0; 32] {
2703 return Ok(None);
2704 }
2705 bundle
2706 .member_permission
2707 .iter()
2708 .find(|p| signed_permission_digest(p).is_ok_and(|d| d == digest))
2709 .map(Some)
2710 .ok_or(Reject::ImportPermission)
2711}
2712pub fn resolve_bundle_manifest<'a>(
2713 bundle: &'a ImportPublicProofBundleV1,
2714 digest: &[u8],
2715) -> Result<&'a ImportResultManifestV1, Reject> {
2716 width(digest, 32)?;
2717 bundle
2718 .manifests
2719 .iter()
2720 .find(|m| manifest_digest(m).is_ok_and(|d| d == digest))
2721 .ok_or(Reject::StaleManifest)
2722}
2723pub fn publication_payload(
2724 operation: &SignedDelegatedImportOperationV1,
2725 manifest: &ImportResultManifestV1,
2726) -> Result<ImportPublicationWitnessV1, Reject> {
2727 let o = operation.body.as_ref().ok_or(Reject::Canonical)?;
2728 Ok(ImportPublicationWitnessV1 {
2729 format_version: 1,
2730 signed_operation_digest: signed_operation_digest(operation)?,
2731 delegation_digest: o.delegation_digest.clone(),
2732 logical_job_id: o.logical_job_id.clone(),
2733 retry_lineage_id: o.retry_lineage_id.clone(),
2734 physical_operation_id: o.physical_operation_id.clone(),
2735 ref_name: o.ref_name.clone(),
2736 slot_id: o.slot_id,
2737 hash_algorithm: o.hash_algorithm,
2738 observed_commit_oid: o.observed_commit_oid.clone(),
2739 expected_frontier_digest: o.expected_frontier_digest.clone(),
2740 resulting_frontier_digest: o.resulting_frontier_digest.clone(),
2741 terminal_manifest_digest: manifest_digest(manifest)?,
2742 })
2743}
2744fn validate_bundle_history(
2747 bundle: &ImportPublicProofBundleV1,
2748 require_admissions: bool,
2749) -> Result<(), Reject> {
2750 fn sorted<T>(
2751 values: &[T],
2752 digest: impl Fn(&T) -> Result<Vec<u8>, Reject>,
2753 ) -> Result<(), Reject> {
2754 let mut previous = None;
2755 for value in values {
2756 let d = digest(value)?;
2757 if previous.as_ref().is_some_and(|p| p >= &d) {
2758 return Err(Reject::Canonical);
2759 }
2760 previous = Some(d);
2761 }
2762 Ok(())
2763 }
2764 let mut foreign = crate::foreign_dependencies::References::new(
2765 &bundle.foreign_dependencies,
2766 ForeignDependencyOrigin::Import,
2767 )?;
2768 for p in &bundle.authority_witnesses {
2769 let subject_thread =
2770 crate::foreign_dependencies::thread(p.original.as_ref().ok_or(Reject::Canonical)?)?;
2771 if !bundle.genesis_witnesses.iter().any(|g| {
2772 g.original_genesis
2773 .as_ref()
2774 .is_some_and(|g| native_id(g) == subject_thread)
2775 }) {
2776 return Err(Reject::Scope);
2777 }
2778 }
2779 let job_keys = bundle
2780 .delegations
2781 .iter()
2782 .filter_map(|d| d.body.as_ref().map(|d| d.job_public_key.clone()))
2783 .collect::<Vec<_>>();
2784 for p in &bundle.landing_witnesses {
2785 verify_landing_key_roles(p, &job_keys, &[])?;
2786 let execution = p.execution.as_ref().ok_or(Reject::Canonical)?;
2787 let thread = crate::foreign_dependencies::thread(execution)?;
2788 if !bundle.genesis_witnesses.iter().any(|p| {
2789 p.original_genesis
2790 .as_ref()
2791 .is_some_and(|g| native_id(g) == thread)
2792 }) {
2793 return Err(Reject::Scope);
2794 }
2795 }
2796 for original in bundle
2798 .authority_witnesses
2799 .iter()
2800 .flat_map(|p| p.original.iter().chain(p.dependencies.iter()))
2801 .chain(bundle.landing_witnesses.iter().flat_map(|p| {
2802 p.execution
2803 .iter()
2804 .chain(p.source_operation.iter())
2805 .chain(p.review_evidence.iter())
2806 }))
2807 {
2808 if [
2809 "heddle-original-boundary-acceptance-v1",
2810 "heddle-thread-genesis-admission-v2",
2811 "heddle-thread-authority-admission-v3",
2812 ]
2813 .contains(&original.format.as_str())
2814 {
2815 continue;
2816 }
2817 let thread = crate::foreign_dependencies::thread(original)?;
2818 if !bundle.genesis_witnesses.iter().any(|p| {
2819 p.original_genesis
2820 .as_ref()
2821 .is_some_and(|g| native_id(g) == thread)
2822 }) {
2823 foreign.require(original)?;
2824 } else if original.format == "heddle-thread-genesis-v1"
2825 && !bundle
2826 .genesis_witnesses
2827 .iter()
2828 .any(|p| p.original_genesis.as_ref() == Some(original))
2829 {
2830 return Err(Reject::Scope);
2831 }
2832 }
2833 foreign.finish()?;
2834 for operation in &bundle.operations {
2835 if operation.body.is_none() {
2836 return Err(Reject::Canonical);
2837 }
2838 }
2839 for statement in &bundle.statements {
2840 let s = statement.body.as_ref().ok_or(Reject::Canonical)?;
2841 validate_statement_boundary(s)?;
2842 require_policy_history(
2843 &bundle.policies,
2844 &s.spool_uuid,
2845 s.policy_sequence,
2846 &s.policy_state_hash,
2847 )?;
2848 let envelope = match s.purpose {
2849 1 if s.basis == 2 => Some(
2850 bundle
2851 .genesis_witnesses
2852 .iter()
2853 .find(|p| canonical(*p).is_ok_and(|v| v == s.canonical_payload))
2854 .ok_or(Reject::Scope)?
2855 .creator_authority_envelope
2856 .as_slice(),
2857 ),
2858 2 => Some(
2859 bundle
2860 .authority_witnesses
2861 .iter()
2862 .find(|p| canonical(*p).is_ok_and(|bytes| bytes == s.canonical_payload))
2863 .ok_or(Reject::Scope)?
2864 .authority_envelope
2865 .as_slice(),
2866 ),
2867 4 => Some(
2868 bundle
2869 .landing_witnesses
2870 .iter()
2871 .find(|p| canonical(*p).is_ok_and(|bytes| bytes == s.canonical_payload))
2872 .ok_or(Reject::Scope)?
2873 .authority_envelope
2874 .as_slice(),
2875 ),
2876 _ => None,
2877 };
2878 if let Some(envelope) = envelope {
2879 crate::writer_authority::check_witness_writer(
2880 s,
2881 envelope,
2882 &bundle.owner_histories,
2883 &bundle.policies,
2884 crate::writer_authority::spool_account_for_statement(
2885 s,
2886 bundle
2887 .delegations
2888 .iter()
2889 .filter_map(|d| d.body.as_ref()?.identity.as_ref())
2890 .chain(
2891 bundle
2892 .genesis_authorities
2893 .iter()
2894 .filter_map(|g| g.body.as_ref()?.identity.as_ref()),
2895 ),
2896 &bundle.ownership_transfers,
2897 )?,
2898 bundle
2899 .authority_witnesses
2900 .iter()
2901 .find(|p| {
2902 (p.kind == 2 || p.kind == 3)
2903 && canonical(*p).is_ok_and(|v| v == s.canonical_payload)
2904 })
2905 .and_then(|p| p.original.as_ref())
2906 .map(|r| r.signatures.as_slice())
2907 .unwrap_or(&[]),
2908 if s.purpose == 1 {
2909 bundle
2910 .genesis_witnesses
2911 .iter()
2912 .find(|p| canonical(*p).is_ok_and(|v| v == s.canonical_payload))
2913 .and_then(|p| p.boundary_acceptance.as_ref())
2914 } else {
2915 bundle
2916 .authority_witnesses
2917 .iter()
2918 .find(|p| canonical(*p).is_ok_and(|v| v == s.canonical_payload))
2919 .and_then(|p| {
2920 p.boundary_acceptances
2921 .iter()
2922 .find(|e| e.binding == s.boundary_acceptance)
2923 })
2924 },
2925 )?;
2926 }
2927 }
2928 sorted(&bundle.manifests, manifest_digest)?;
2929 if let Some(p) = &bundle.member_permission
2930 && resolve_bundle_permission(bundle, &signed_permission_digest(p)?)? != Some(p)
2931 {
2932 return Err(Reject::ImportPermission);
2933 }
2934 let terminal = bundle.terminal_manifest.as_ref().ok_or(Reject::Canonical)?;
2935 if resolve_bundle_manifest(bundle, &manifest_digest(terminal)?)? != terminal {
2936 return Err(Reject::Canonical);
2937 }
2938 if bundle.delegations.len() != 1 {
2939 return Err(Reject::Canonical);
2940 }
2941 for d in &bundle.delegations {
2942 let body = d.body.as_ref().ok_or(Reject::Canonical)?;
2943 resolve_bundle_permission(bundle, &body.parent_permission_digest)?;
2944 for branch in &body.branch_manifest {
2945 let g = bundle
2946 .genesis_authorities
2947 .iter()
2948 .find(|g| {
2949 signed_genesis_digest(g).is_ok_and(|h| h == branch.genesis_authority_digest)
2950 })
2951 .ok_or(Reject::Scope)?;
2952 let b = g.body.as_ref().ok_or(Reject::Canonical)?;
2953 resolve_bundle_permission(bundle, &b.parent_permission_digest)?;
2954 if !bundle
2955 .original_geneses
2956 .iter()
2957 .any(|o| native_id(o) == b.genesis_digest)
2958 || !bundle
2959 .creator_authority_envelopes
2960 .iter()
2961 .any(|e| hash(&[e]) == b.creator_authority_envelope_digest)
2962 {
2963 return Err(Reject::Scope);
2964 }
2965 if require_admissions
2968 && bundle.operations.iter().any(|o| {
2969 o.body
2970 .as_ref()
2971 .is_some_and(|o| o.genesis_digest == b.genesis_digest)
2972 })
2973 && !bundle.genesis_witnesses.iter().any(|payload| {
2974 payload.binding.as_ref() == Some(g)
2975 && payload.original_genesis.as_ref().is_some_and(|o| {
2976 native_id(o) == b.genesis_digest && bundle.original_geneses.contains(o)
2977 })
2978 && hash(&[&payload.creator_authority_envelope])
2979 == b.creator_authority_envelope_digest
2980 && canonical(payload).is_ok_and(|bytes| {
2981 bundle.statements.iter().any(|s| {
2982 s.body
2983 .as_ref()
2984 .is_some_and(|s| s.purpose == 1 && s.canonical_payload == bytes)
2985 })
2986 })
2987 })
2988 {
2989 return Err(Reject::Scope);
2990 }
2991 }
2992 }
2993 for manifest in &bundle.manifests {
2994 validate_manifest(manifest)?;
2995 if manifest.logical_job_id != terminal.logical_job_id
2996 || manifest.retry_lineage_id != terminal.retry_lineage_id
2997 {
2998 return Err(Reject::Scope);
2999 }
3000 for slot in &manifest.slots {
3001 let operation = bundle
3002 .operations
3003 .iter()
3004 .find(|o| {
3005 signed_operation_digest(o).is_ok_and(|d| d == slot.signed_operation_digest)
3006 })
3007 .ok_or(Reject::Scope)?;
3008 if !check_slot_replay(manifest, operation)? || !check_slot_replay(terminal, operation)?
3009 {
3010 return Err(Reject::Scope);
3011 }
3012 }
3013 }
3014 for operation in &bundle.operations {
3015 let o = operation.body.as_ref().ok_or(Reject::Canonical)?;
3016 if !bundle
3017 .delegations
3018 .iter()
3019 .any(|d| signed_delegation_digest(d).is_ok_and(|h| h == o.delegation_digest))
3020 || !check_slot_replay(terminal, operation)?
3021 {
3022 return Err(Reject::Scope);
3023 }
3024 if !bundle.manifests.iter().any(|m| {
3025 check_slot_replay(m, operation) == Ok(true)
3026 && publication_payload(operation, m)
3027 .and_then(|p| canonical(&p))
3028 .is_ok_and(|p| {
3029 bundle.statements.iter().any(|s| {
3030 s.body
3031 .as_ref()
3032 .is_some_and(|s| s.purpose == 3 && s.canonical_payload == p)
3033 })
3034 })
3035 }) {
3036 return Err(Reject::Scope);
3037 }
3038 }
3039 for statement in &bundle.statements {
3040 let s = statement.body.as_ref().ok_or(Reject::Canonical)?;
3041 let found = match s.purpose {
3042 1 => bundle
3043 .genesis_witnesses
3044 .iter()
3045 .any(|p| canonical(p).is_ok_and(|p| p == s.canonical_payload)),
3046 2 => bundle
3047 .authority_witnesses
3048 .iter()
3049 .any(|p| canonical(p).is_ok_and(|p| p == s.canonical_payload)),
3050 3 => bundle.operations.iter().any(|o| {
3051 bundle.manifests.iter().any(|m| {
3052 publication_payload(o, m)
3053 .and_then(|p| canonical(&p))
3054 .is_ok_and(|p| p == s.canonical_payload)
3055 })
3056 }),
3057 4 => bundle
3058 .landing_witnesses
3059 .iter()
3060 .any(|p| canonical(p).is_ok_and(|p| p == s.canonical_payload)),
3061 _ => return Err(Reject::Version),
3062 };
3063 if !found {
3064 return Err(Reject::Scope);
3065 }
3066 }
3067 Ok(())
3068}
3069pub(crate) fn require_policy_history(
3075 policies: &[SignedSpoolPolicyRecord],
3076 spool: &[u8],
3077 mut sequence: u64,
3078 state_hash: &[u8],
3079) -> Result<(), Reject> {
3080 let mut previous: Option<(&[u8], u64)> = None;
3081 for signed in policies {
3082 let policy = signed.body.as_ref().ok_or(Reject::Canonical)?;
3083 let digest = policy_state_digest(policy)?;
3084 if digest != policy.policy_state_hash {
3085 return Err(Reject::Canonical);
3086 }
3087 let current = (policy.spool_uuid.as_slice(), policy.sequence);
3088 if previous.is_some_and(|prev| prev >= current) {
3089 return Err(Reject::Canonical);
3090 }
3091 previous = Some(current);
3092 }
3093 let mut state_hash = state_hash.to_vec();
3094 let mut successor_revoked: Option<&[Vec<u8>]> = None;
3095 for _ in 0..=policies.len() {
3096 width(&state_hash, 32)?;
3097 if sequence == 0 {
3098 return if state_hash == [0; 32] {
3099 Ok(())
3100 } else {
3101 Err(Reject::Scope)
3102 };
3103 }
3104 let mut matches = policies.iter().filter_map(|p| p.body.as_ref()).filter(|p| {
3105 p.spool_uuid == spool && p.sequence == sequence && p.policy_state_hash == state_hash
3106 });
3107 let policy = matches.next().ok_or(Reject::Scope)?;
3108 let revoked = policy
3110 .policy
3111 .as_ref()
3112 .map(|p| p.revoked_key_ids.as_slice())
3113 .unwrap_or(&[]);
3114 if successor_revoked.is_some_and(|next| revoked.iter().any(|id| !next.contains(id))) {
3115 return Err(Reject::Scope);
3116 }
3117 successor_revoked = Some(revoked);
3118 let head = policy.expected_head.as_ref().ok_or(Reject::Canonical)?;
3119 if head.sequence.checked_add(1) != Some(sequence) {
3120 return Err(Reject::Scope);
3121 }
3122 sequence = head.sequence;
3123 state_hash = head.state_hash.clone();
3124 }
3125 Err(Reject::Scope)
3126}
3127pub fn policy_state_digest(p: &SignedPolicyBody) -> Result<Vec<u8>, Reject> {
3129 use crate::hybrid_codec::counted;
3130 if p.format_version != 1 || !p.merge_parent_state_hashes.is_empty() {
3131 return Err(Reject::Version);
3132 }
3133 width(&p.spool_uuid, 16)?;
3134 width(&p.owner_id, 32)?;
3135 width(&p.owner_state_hash, 32)?;
3136 let head = p.expected_head.as_ref().ok_or(Reject::Canonical)?;
3137 width(&head.state_hash, 32)?;
3138 let policy = p.policy.as_ref().ok_or(Reject::Canonical)?;
3139 if p.merge_policies.len() != 2
3140 || p.merge_policies[0].setting_key != "max_audience"
3141 || p.merge_policies[0].semantics != 1
3142 || p.merge_policies[1].setting_key != "revoked_key_ids"
3143 || p.merge_policies[1].semantics != 2
3144 {
3145 return Err(Reject::Canonical);
3146 }
3147 let mut out = p.format_version.to_be_bytes().to_vec();
3148 counted(&mut out, &p.spool_uuid)?;
3149 counted(&mut out, &head.state_hash)?;
3150 out.extend_from_slice(&head.sequence.to_be_bytes());
3151 out.extend_from_slice(&p.sequence.to_be_bytes());
3152 out.extend_from_slice(&0u32.to_be_bytes());
3153 out.extend_from_slice(&(policy.revoked_key_ids.len() as u32).to_be_bytes());
3154 for (i, id) in policy.revoked_key_ids.iter().enumerate() {
3155 width(id, 32)?;
3156 if i > 0 && policy.revoked_key_ids[i - 1] >= *id {
3157 return Err(Reject::Canonical);
3158 }
3159 counted(&mut out, id)?;
3160 }
3161 out.push(u8::from(policy.max_audience.is_some()));
3162 if let Some(audience) = policy.max_audience {
3163 if !(1..=3).contains(&audience) {
3164 return Err(Reject::Canonical);
3165 }
3166 out.extend_from_slice(&(audience as u32).to_be_bytes());
3167 }
3168 out.extend_from_slice(&2u32.to_be_bytes());
3169 for rule in &p.merge_policies {
3170 counted(&mut out, rule.setting_key.as_bytes())?;
3171 out.extend_from_slice(&(rule.semantics as u32).to_be_bytes());
3172 }
3173 counted(&mut out, &p.owner_id)?;
3174 counted(&mut out, &p.owner_state_hash)?;
3175 out.extend_from_slice(&p.ownership_transfer_sequence.to_be_bytes());
3176 Ok(hash(&[b"heddle-spool-signed-policy-v2", &out]))
3177}
3178pub(crate) fn native_id(record: &SignedRecord) -> Vec<u8> {
3179 let mut h = blake3::Hasher::new();
3180 h.update(record.format.as_bytes());
3181 h.update(&(record.canonical_record.len() as u64).to_le_bytes());
3182 h.update(b"\0");
3183 h.update(&record.canonical_record);
3184 h.finalize().as_bytes().to_vec()
3185}
3186
3187pub fn initial_operation_id(lineage: &[u8], occupied: bool) -> Result<String, Reject> {
3190 width(lineage, 16)?;
3191 if lineage.iter().all(|b| *b == 0) {
3192 return Err(Reject::Canonical);
3193 }
3194 if occupied {
3195 return Err(Reject::OperationIdReused);
3196 }
3197 let h = hex::encode(lineage);
3198 Ok(format!(
3199 "{}-{}-{}-{}-{}",
3200 &h[..8],
3201 &h[8..12],
3202 &h[12..16],
3203 &h[16..20],
3204 &h[20..]
3205 ))
3206}
3207
3208pub fn check_cancel_request(
3211 request: &CancelImportJobRequest,
3212 active: &SignedImportJobDelegationV1,
3213 durable_epoch: u64,
3214 cancelled: bool,
3215) -> Result<(), Reject> {
3216 let d = active.body.as_ref().ok_or(Reject::Canonical)?;
3217 width(&request.logical_job_id, 16)?;
3218 width(&request.cancellation_id, 32)?;
3219 let id = d.identity.as_ref().ok_or(Reject::Canonical)?;
3220 if request.client_operation_id.is_empty()
3221 || request.destination.as_ref().is_none_or(|s| {
3222 initial_operation_id(&id.spool_uuid, false).map_or(true, |uuid| s.id != uuid)
3223 })
3224 || request.logical_job_id != d.logical_job_id
3225 {
3226 return Err(Reject::Scope);
3227 }
3228 if durable_epoch == 0 || request.expected_authority_epoch != durable_epoch {
3229 return Err(Reject::StaleContext);
3230 }
3231 if request.cancellation_id != d.cancellation_id {
3232 return Err(Reject::Scope);
3233 }
3234 if cancelled {
3235 return Err(Reject::Revoked);
3236 }
3237 Ok(())
3238}
3239pub fn check_cancel_replay(
3241 request: &CancelImportJobRequest,
3242 stored: &CancelImportJobRequest,
3243) -> Result<(), Reject> {
3244 if request != stored {
3245 return Err(Reject::OperationIdReused);
3246 }
3247 Ok(())
3248}
3249pub fn check_import_revocations(
3251 delegation: &SignedImportJobDelegationV1,
3252 member: Option<&SignedImportMemberPermissionV1>,
3253 revoked: &[Vec<u8>],
3254) -> Result<(), Reject> {
3255 let d = delegation.body.as_ref().ok_or(Reject::Canonical)?;
3256 width(&d.cancellation_id, 32)?;
3257 if revoked.contains(&d.cancellation_id) {
3258 return Err(Reject::Revoked);
3259 }
3260 if let Some(parent) = member {
3261 let p = parent.body.as_ref().ok_or(Reject::ImportPermission)?;
3262 width(&p.cancellation_id, 32)?;
3263 if revoked.contains(&p.cancellation_id) {
3264 return Err(Reject::Revoked);
3265 }
3266 }
3267 Ok(())
3268}
3269
3270pub fn remaining_import_scope(
3273 scope: &ImportPermissionScopeV1,
3274 committed: &ImportResultManifestV1,
3275) -> Result<ImportPermissionScopeV1, Reject> {
3276 validate_scope(scope)?;
3277 validate_manifest(committed)?;
3278 let mut remaining = scope.clone();
3279 for slot in &committed.slots {
3280 if !scope
3281 .branches
3282 .iter()
3283 .any(|b| b.ref_name == slot.ref_name && b.slot_id == slot.slot_id)
3284 {
3285 return Err(Reject::Scope);
3286 }
3287 {
3288 remaining.max_result_bytes = remaining
3289 .max_result_bytes
3290 .checked_sub(slot.result_bytes)
3291 .ok_or(Reject::Scope)?;
3292 remaining.max_operations = remaining
3293 .max_operations
3294 .checked_sub(1)
3295 .ok_or(Reject::Scope)?;
3296 }
3297 }
3298 remaining.branches.retain(|b| {
3299 !committed
3300 .slots
3301 .iter()
3302 .any(|s| s.ref_name == b.ref_name && s.slot_id == b.slot_id)
3303 });
3304 Ok(remaining)
3305}
3306
3307pub fn validate_hybrid_import_job_selector(
3309 selector: &HybridImportJobSelector,
3310) -> Result<(), Reject> {
3311 width(&selector.logical_job_id, 16)?;
3312 if selector.logical_job_id.iter().all(|byte| *byte == 0) {
3313 return Err(Reject::Canonical);
3314 }
3315 Ok(())
3316}
3317
3318pub fn import_job_state_request_from_operation(
3323 operation: &OperationRecord,
3324) -> Result<Option<GetImportJobStateRequest>, Reject> {
3325 let Some(operation_subject::Subject::Import(subject)) = operation
3326 .subject
3327 .as_ref()
3328 .and_then(|subject| subject.subject.as_ref())
3329 else {
3330 return Ok(None);
3331 };
3332 let Some(selector) = subject.hybrid_job.as_ref() else {
3333 return Ok(None);
3334 };
3335 validate_hybrid_import_job_selector(selector)?;
3336 let request = GetImportJobStateRequest {
3337 destination: operation
3338 .r#ref
3339 .as_ref()
3340 .and_then(|record| record.spool.clone()),
3341 logical_job_id: selector.logical_job_id.clone(),
3342 };
3343 validate_job_state_request(&request)?;
3344 Ok(Some(request))
3345}
3346
3347pub fn validate_job_state_request(request: &GetImportJobStateRequest) -> Result<(), Reject> {
3350 use prost::Message;
3351 if request.encoded_len() > 4096 {
3352 return Err(Reject::Bounds);
3353 }
3354 initial_operation_id(&request.logical_job_id, false)?;
3355 let destination = request.destination.as_ref().ok_or(Reject::Scope)?;
3356 let compact = destination.id.replace('-', "");
3357 let raw = hex::decode(&compact).map_err(|_| Reject::Scope)?;
3358 if initial_operation_id(&raw, false).map_or(true, |id| id != destination.id) {
3359 return Err(Reject::Scope);
3360 }
3361 Ok(())
3362}
3363
3364pub fn validate_job_state_response(
3366 request: &GetImportJobStateRequest,
3367 response: &GetImportJobStateResponse,
3368) -> Result<(), Reject> {
3369 use prost::Message;
3370 validate_job_state_request(request)?;
3371 if response.encoded_len() > 4096 {
3372 return Err(Reject::Bounds);
3373 }
3374 if !(1..=5).contains(&response.status) || response.authority_epoch == 0 {
3375 return Err(Reject::Canonical);
3376 }
3377 width(&response.active_cancellation_id, 32)?;
3378 width(&response.active_delegation_digest, 32)?;
3379 validate_retry_availability(response, request.destination.as_ref().ok_or(Reject::Scope)?)
3380}
3381
3382#[derive(Debug, Clone, PartialEq)]
3384pub struct ImportWitnessRootPin {
3385 pub authority: String,
3386 pub root_id: String,
3387 pub public_key: Vec<u8>,
3388 pub epoch: u64,
3389}
3390#[derive(Debug, Clone, PartialEq)]
3393pub struct ImportWitnessSnapshot {
3394 pub root: ImportWitnessRootPin,
3395 pub witness_set: crate::heddle::api::common::SignedHostedWitnessSetV1,
3396 pub clock_floor_unix_millis: i64,
3397 pub job_associations: Vec<(Vec<u8>, Vec<u8>)>,
3398 pub accepted_history: Vec<ImportPublicProofBundleV1>,
3399}
3400#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3403pub enum ImportBundleEvidence {
3404 Recovery,
3405 Witnessed,
3406}
3407#[derive(Debug, Clone, PartialEq)]
3408pub struct VerifiedImportBundleWitnesses {
3409 pub evidence: ImportBundleEvidence,
3410 pub accepted_history: ImportResultManifestV1,
3411 pub snapshot: Option<ImportWitnessSnapshot>,
3412 pub snapshot_advanced: bool,
3413 pub owner_check_time_unix_seconds: Option<i64>,
3414}
3415pub fn check_import_genesis_publication_pair(
3418 delegation: &VerifiedImportDelegation,
3419 admission: &crate::heddle::api::common::HostedWitnessStatementV1,
3420 publication: &crate::heddle::api::common::HostedWitnessStatementV1,
3421) -> Result<(), Reject> {
3422 validity(
3423 delegation.body.not_before_unix_seconds,
3424 delegation.body.expires_at_unix_seconds,
3425 admission.observed_at_unix_millis / 1000,
3426 true,
3427 )?;
3428 if admission.purpose != 1
3429 || publication.purpose != 3
3430 || admission.observed_at_unix_millis != publication.observed_at_unix_millis
3431 || admission.host_transaction_id != publication.host_transaction_id
3432 || admission.executor_id != publication.executor_id
3433 || admission.admission_order >= publication.admission_order
3434 {
3435 return Err(Reject::Transition);
3436 }
3437 Ok(())
3438}
3439
3440pub fn verify_import_bundle_witnesses<'a>(
3454 bundle: &ImportPublicProofBundleV1,
3455 pin: &ImportWitnessRootPin,
3456 snapshot: Option<&ImportWitnessSnapshot>,
3457 now_ms: i64,
3458 mut owner_at: impl FnMut(Option<i64>) -> Result<ImportBundleOwnerExpectation<'a>, Reject>,
3459 mut verify_policy: impl FnMut(
3460 &ImportPublicProofBundleV1,
3461 Option<&crate::heddle::api::common::HostedWitnessStatementV1>,
3462 ) -> Result<(), Reject>,
3463) -> Result<VerifiedImportBundleWitnesses, Reject> {
3464 use crate::witness_trust as trust;
3465 width(&pin.public_key, 32)?;
3466 if pin.epoch == 0 {
3467 return Err(Reject::StaleContext);
3468 }
3469 validate_bundle_bounds(bundle)?;
3470 let terminal = bundle.terminal_manifest.as_ref().ok_or(Reject::Canonical)?;
3471 validate_bundle_history(bundle, !terminal.slots.is_empty())?;
3472 let mut associations = snapshot.map_or_else(Vec::new, |s| s.job_associations.clone());
3473 for d in &bundle.delegations {
3474 let b = d.body.as_ref().ok_or(Reject::Canonical)?;
3475 if associations
3476 .iter()
3477 .any(|(key, job)| *key == b.job_public_key && *job != b.logical_job_id)
3478 {
3479 return Err(Reject::KeyRole);
3480 }
3481 if !associations.iter().any(|(key, _)| *key == b.job_public_key) {
3482 associations.push((b.job_public_key.clone(), b.logical_job_id.clone()));
3483 }
3484 }
3485 let job_keys = associations
3486 .iter()
3487 .map(|(key, _)| key.clone())
3488 .collect::<Vec<_>>();
3489 fn expectation<'a>(
3490 root: &'a ImportWitnessRootPin,
3491 floor: i64,
3492 now: i64,
3493 keys: &'a [Vec<u8>],
3494 ) -> trust::SetExpectation<'a> {
3495 trust::SetExpectation {
3496 authority: &root.authority,
3497 root_id: &root.root_id,
3498 root_public_key: &root.public_key,
3499 root_epoch: root.epoch,
3500 now_unix_millis: now,
3501 clock_floor_unix_millis: floor,
3502 known_job_keys: keys,
3503 }
3504 }
3505 let previous = if let Some(s) = snapshot {
3506 if s.root.authority != pin.authority {
3507 return Err(Reject::Root);
3508 }
3509 let restored = trust::restore_history_snapshot(
3510 &s.witness_set,
3511 &expectation(&s.root, 0, now_ms, &job_keys),
3512 )?;
3513 Some(restored)
3514 } else {
3515 None
3516 };
3517 let carried = bundle.witness_set.as_ref();
3518 if carried.is_none() && !bundle.statements.is_empty() {
3519 return Err(Reject::Canonical);
3520 }
3521 let new_set =
3522 carried.is_some_and(|c| snapshot.is_none_or(|s| s.root != *pin || s.witness_set != *c));
3523 let selected = expectation(
3524 pin,
3525 snapshot.map_or(0, |s| s.clock_floor_unix_millis),
3526 now_ms,
3527 &job_keys,
3528 );
3529 let set = carried
3530 .map(|carried| {
3531 if snapshot.is_some_and(|s| s.root != *pin) {
3532 trust::verify_set_after_root_replacement(
3533 carried,
3534 &selected,
3535 previous.as_ref().ok_or(Reject::StaleContext)?,
3536 )
3537 } else {
3538 trust::verify_set(carried, &selected, previous.as_ref())
3539 }
3540 })
3541 .transpose()?;
3542 let mut resolved = Vec::new();
3544 for signed in &bundle.statements {
3545 let s = signed.body.as_ref().ok_or(Reject::Canonical)?;
3546 let set = set.as_ref().ok_or(Reject::Canonical)?;
3547 let entry = set
3548 .body()
3549 .entries
3550 .iter()
3551 .find(|e| e.executor_id == s.executor_id)
3552 .ok_or(Reject::Root)?;
3553 let proof = if entry.state == 2 {
3554 let leaf = trust::leaf_digest(s.purpose, &canonical(s)?, &signed.signature)?;
3555 bundle.history_proofs.iter().find(|p| {
3556 p.purpose == s.purpose && trust::verify_inclusion(&leaf, p, entry).is_ok()
3557 })
3558 } else {
3559 None
3560 };
3561 let context = trust::resolve_statement(set, signed, proof, false, now_ms)?;
3562 verify_policy(bundle, Some(s))?;
3563 resolved.push((context, proof));
3564 }
3565 if bundle.statements.is_empty() {
3566 verify_policy(bundle, None)?;
3568 }
3569 let mut prefix = ImportResultManifestV1 {
3574 slots: vec![],
3575 ..terminal.clone()
3576 };
3577 let mut selected_publications = Vec::new();
3578 for operation in &bundle.operations {
3579 let digest = signed_operation_digest(operation)?;
3580 let slot = terminal
3581 .slots
3582 .iter()
3583 .find(|slot| slot.signed_operation_digest == digest)
3584 .ok_or(Reject::Scope)?;
3585 prefix.slots.push(slot.clone());
3586 prefix
3587 .slots
3588 .sort_by(|a, b| (&a.ref_name, a.slot_id).cmp(&(&b.ref_name, b.slot_id)));
3589 let payload = canonical(&publication_payload(operation, &prefix)?)?;
3590 let matches = bundle
3591 .statements
3592 .iter()
3593 .filter(|signed| {
3594 signed
3595 .body
3596 .as_ref()
3597 .is_some_and(|s| s.purpose == 3 && s.canonical_payload == payload)
3598 })
3599 .collect::<Vec<_>>();
3600 if matches.len() != 1 {
3601 return Err(Reject::Transition);
3602 }
3603 selected_publications.push(matches[0]);
3604 }
3605 if bundle.statements.iter().any(|signed| {
3606 signed
3607 .body
3608 .as_ref()
3609 .is_some_and(|s| s.purpose == 3 && !selected_publications.contains(&signed))
3610 }) {
3611 return Err(Reject::Transition);
3612 }
3613 let mut admitted_geneses = std::collections::BTreeSet::<Vec<u8>>::new();
3614 let mut times = vec![None; bundle.delegations.len()];
3615 let mut publications = Vec::new();
3616 for signed in &bundle.statements {
3617 let statement = signed.body.as_ref().ok_or(Reject::Canonical)?;
3618 if statement.purpose != 3 {
3619 continue;
3620 }
3621 let (operation, manifest) = bundle
3622 .operations
3623 .iter()
3624 .flat_map(|o| bundle.manifests.iter().map(move |m| (o, m)))
3625 .find(|(o, m)| {
3626 publication_payload(o, m)
3627 .and_then(|p| canonical(&p))
3628 .is_ok_and(|b| b == statement.canonical_payload)
3629 })
3630 .ok_or(Reject::Scope)?;
3631 let digest = &operation
3632 .body
3633 .as_ref()
3634 .ok_or(Reject::Canonical)?
3635 .delegation_digest;
3636 let index = bundle
3637 .delegations
3638 .iter()
3639 .position(|d| signed_delegation_digest(d).is_ok_and(|h| h == *digest))
3640 .ok_or(Reject::Scope)?;
3641 let time = statement.observed_at_unix_millis / 1000;
3642 if times[index].is_none_or(|(order, _)| statement.admission_order < order) {
3644 times[index] = Some((statement.admission_order, time));
3645 }
3646 publications.push((signed, operation, manifest, index));
3647 }
3648 let mut resolve_owner = |time: Option<i64>| {
3651 let facts = owner_at(time)?;
3652 if facts.effective_from_unix_seconds < 0
3653 || facts
3654 .effective_until_unix_seconds
3655 .is_some_and(|end| end <= facts.effective_from_unix_seconds)
3656 || time.is_some_and(|t| {
3657 t < facts.effective_from_unix_seconds
3658 || facts
3659 .effective_until_unix_seconds
3660 .is_some_and(|end| t >= end)
3661 })
3662 {
3663 return Err(Reject::Scope);
3664 }
3665 for (key, job) in facts.known_job_associations {
3666 if associations.iter().any(|(k, j)| k == key && j != job) {
3667 return Err(Reject::KeyRole);
3668 }
3669 if !associations.iter().any(|(k, _)| k == key) {
3670 associations.push((key.clone(), job.clone()));
3671 }
3672 }
3673 if facts
3674 .forbidden_job_keys
3675 .iter()
3676 .any(|key| associations.iter().any(|(known, _)| known == key))
3677 {
3678 return Err(Reject::KeyRole);
3679 }
3680 if set.as_ref().is_some_and(|set| {
3682 facts.known_job_associations.iter().any(|(key, _)| {
3683 *key == pin.public_key || set.body().entries.iter().any(|e| e.public_key == *key)
3684 })
3685 }) {
3686 return Err(Reject::KeyRole);
3687 }
3688 Ok((facts, associations.clone()))
3689 };
3690 let mut verified = Vec::new();
3691 for (i, d) in bundle.delegations.iter().enumerate() {
3692 let body = d.body.as_ref().ok_or(Reject::Canonical)?;
3693 let parent = resolve_bundle_permission(bundle, &body.parent_permission_digest)?;
3694 let time = times[i].map(|(_, time)| time);
3695 let (facts, selected_associations) = resolve_owner(time)?;
3696 let owner = facts.at(time.unwrap_or(0), &selected_associations);
3697 let token = verify_delegation_inner(d, parent, &owner, times[i].is_some())?;
3698 verified.push(token);
3699 }
3700 let initial = verified.first().ok_or(Reject::Canonical)?;
3701 for branch in &initial.body.branch_manifest {
3702 let limit = branch.limit.as_ref().ok_or(Reject::Canonical)?;
3703 let binding = bundle
3704 .genesis_authorities
3705 .iter()
3706 .find(|g| signed_genesis_digest(g).is_ok_and(|h| h == branch.genesis_authority_digest))
3707 .ok_or(Reject::Scope)?;
3708 let original = bundle
3709 .original_geneses
3710 .iter()
3711 .find(|o| native_id(o) == limit.genesis_digest)
3712 .ok_or(Reject::Scope)?;
3713 let g = binding.body.as_ref().ok_or(Reject::Canonical)?;
3714 let envelope = bundle
3715 .creator_authority_envelopes
3716 .iter()
3717 .find(|e| hash(&[e]) == g.creator_authority_envelope_digest)
3718 .ok_or(Reject::Scope)?;
3719 verify_native(original, "heddle-thread-genesis-v1")?;
3720 let signature = original
3721 .signatures
3722 .iter()
3723 .find(|s| s.public_key == g.creator_public_key)
3724 .ok_or(Reject::Scope)?;
3725 verify_genesis_authority(
3726 binding,
3727 initial,
3728 &limit.genesis_digest,
3729 &signature.signature,
3730 &hash(&[envelope]),
3731 )?;
3732 }
3733 for ((context, proof), signed) in resolved.iter().zip(&bundle.statements) {
3734 let s = signed.body.as_ref().ok_or(Reject::Canonical)?;
3735 if !verified.iter().any(|d| {
3736 d.body.identity.as_ref().is_some_and(|id| {
3737 s.spool_uuid == id.spool_uuid
3738 && s.spool_genesis_digest == id.spool_genesis_digest
3739 && s.owner_id == id.owner_id
3740 && s.owner_state_hash == id.owner_state_hash
3741 && s.ownership_transfer_sequence == id.ownership_transfer_sequence
3742 })
3743 }) {
3744 return Err(Reject::Scope);
3745 }
3746 match s.purpose {
3747 1 => {
3748 let time = s.observed_at_unix_millis / 1000;
3750 let (facts, selected_associations) = resolve_owner(Some(time))?;
3751 let owner = facts.at(time, &selected_associations);
3752 verify_delegation(
3753 &bundle.delegations[0],
3754 resolve_bundle_permission(bundle, &initial.body.parent_permission_digest)?,
3755 &owner,
3756 )?;
3757 let payload = bundle
3758 .genesis_witnesses
3759 .iter()
3760 .find(|p| canonical(*p).is_ok_and(|b| b == s.canonical_payload))
3761 .ok_or(Reject::Scope)?;
3762 let genesis = &payload
3763 .binding
3764 .as_ref()
3765 .and_then(|g| g.body.as_ref())
3766 .ok_or(Reject::Canonical)?
3767 .genesis_digest;
3768 if !admitted_geneses.insert(genesis.clone()) {
3769 return Err(Reject::Transition);
3770 }
3771 let publication = bundle
3772 .operations
3773 .iter()
3774 .zip(&selected_publications)
3775 .find(|(o, _)| {
3776 o.body
3777 .as_ref()
3778 .is_some_and(|o| &o.genesis_digest == genesis)
3779 })
3780 .and_then(|(_, s)| s.body.as_ref())
3781 .ok_or(Reject::Transition)?;
3782 check_import_genesis_publication_pair(initial, s, publication)?;
3783 verify_witness_payload(
3784 s,
3785 WitnessPayload::Genesis(
3786 bundle
3787 .genesis_witnesses
3788 .iter()
3789 .find(|p| canonical(*p).is_ok_and(|b| b == s.canonical_payload))
3790 .ok_or(Reject::Scope)?,
3791 ),
3792 )?;
3793 }
3794 2 => verify_witness_payload(
3795 s,
3796 WitnessPayload::Authority(
3797 bundle
3798 .authority_witnesses
3799 .iter()
3800 .find(|p| canonical(*p).is_ok_and(|b| b == s.canonical_payload))
3801 .ok_or(Reject::Scope)?,
3802 ),
3803 )?,
3804 4 => {
3805 let payload = bundle
3806 .landing_witnesses
3807 .iter()
3808 .find(|p| canonical(*p).is_ok_and(|b| b == s.canonical_payload))
3809 .ok_or(Reject::Scope)?;
3810 let (facts, selected_associations) =
3811 resolve_owner(Some(s.observed_at_unix_millis / 1000))?;
3812 let known = selected_associations
3813 .iter()
3814 .map(|(key, _)| key.clone())
3815 .collect::<Vec<_>>();
3816 verify_landing_key_roles(payload, &known, facts.forbidden_landing_keys)?;
3817 verify_witness_payload(s, WitnessPayload::Landing(payload))?;
3818 }
3819 3 => {
3820 let (_, operation, manifest, index) = publications
3821 .iter()
3822 .find(|(statement, _, _, _)| *statement == signed)
3823 .ok_or(Reject::Scope)?;
3824 let d = &bundle.delegations[*index];
3825 let body = d.body.as_ref().ok_or(Reject::Canonical)?;
3826 let time = s.observed_at_unix_millis / 1000;
3827 let (facts, selected_associations) = resolve_owner(Some(time))?;
3828 let owner = facts.at(time, &selected_associations);
3829 let delegation = verify_delegation(
3830 d,
3831 resolve_bundle_permission(bundle, &body.parent_permission_digest)?,
3832 &owner,
3833 )?;
3834 verify_publication(
3835 operation,
3836 &delegation,
3837 manifest,
3838 signed,
3839 set.as_ref().ok_or(Reject::Canonical)?,
3840 *proof,
3841 now_ms,
3842 )?;
3843 }
3844 _ => return Err(Reject::Version),
3845 }
3846 trust::recheck_context(
3847 context,
3848 set.as_ref().ok_or(Reject::Canonical)?,
3849 signed,
3850 now_ms,
3851 )?;
3852 }
3853 let mut progressive = ImportResultManifestV1 {
3855 slots: vec![],
3856 ..terminal.clone()
3857 };
3858 let mut order = 0;
3859 let mut observed = 0;
3860 let mut consumed = std::collections::BTreeMap::<(Vec<u8>, Vec<u8>), (u32, u64)>::new();
3861 let mut total_bytes = 0_u64;
3862 for o in &bundle.operations {
3863 let digest = signed_operation_digest(o)?;
3864 let slot = terminal
3865 .slots
3866 .iter()
3867 .find(|s| s.signed_operation_digest == digest)
3868 .ok_or(Reject::Scope)?;
3869 progressive.slots.push(slot.clone());
3870 progressive
3871 .slots
3872 .sort_by(|a, b| (&a.ref_name, a.slot_id).cmp(&(&b.ref_name, b.slot_id)));
3873 let payload = canonical(&publication_payload(o, &progressive)?)?;
3874 let s = bundle
3875 .statements
3876 .iter()
3877 .filter_map(|s| s.body.as_ref())
3878 .find(|s| s.purpose == 3 && s.canonical_payload == payload)
3879 .ok_or(Reject::Transition)?;
3880 if s.admission_order <= order || s.observed_at_unix_millis < observed {
3881 return Err(Reject::Transition);
3882 }
3883 order = s.admission_order;
3884 observed = s.observed_at_unix_millis;
3885 let digest = &o.body.as_ref().ok_or(Reject::Canonical)?.delegation_digest;
3886 let index = verified
3887 .iter()
3888 .position(|d| d.digest == *digest)
3889 .ok_or(Reject::Scope)?;
3890 let result_bytes = o.body.as_ref().ok_or(Reject::Canonical)?.result_bytes;
3891 total_bytes = total_bytes
3892 .checked_add(result_bytes)
3893 .ok_or(Reject::Bounds)?;
3894 let original_scope = initial.body.scope.as_ref().ok_or(Reject::Canonical)?;
3895 if total_bytes > original_scope.max_result_bytes
3896 || progressive.slots.len() > original_scope.max_operations as usize
3897 {
3898 return Err(Reject::Scope);
3899 }
3900 let delegation = &verified[index];
3901 let mut committed_before = progressive.clone();
3902 let operation_digest = signed_operation_digest(o)?;
3903 committed_before
3904 .slots
3905 .retain(|s| s.signed_operation_digest != operation_digest);
3906 check_import_publication_budget(o, delegation, &committed_before)?;
3907 let scope = delegation.body.scope.as_ref().ok_or(Reject::Canonical)?;
3908 let mut budgets = vec![(&delegation.digest, scope)];
3909 if let Some(parent) = &delegation.member {
3910 budgets.push((
3911 &delegation.body.parent_permission_digest,
3912 parent
3913 .body
3914 .as_ref()
3915 .and_then(|p| p.scope.as_ref())
3916 .ok_or(Reject::Canonical)?,
3917 ));
3918 }
3919 for (digest, scope) in budgets {
3920 let (operations, bytes) = consumed
3921 .entry((digest.clone(), delegation.body.logical_job_id.clone()))
3922 .or_default();
3923 *operations = operations.checked_add(1).ok_or(Reject::Bounds)?;
3924 *bytes = bytes.checked_add(result_bytes).ok_or(Reject::Bounds)?;
3925 if *operations > scope.max_operations || *bytes > scope.max_result_bytes {
3926 return Err(Reject::Scope);
3927 }
3928 }
3929 }
3930 if progressive != *terminal {
3931 return Err(Reject::Scope);
3932 }
3933 let mut history = snapshot.map_or_else(Vec::new, |s| s.accepted_history.clone());
3934 if let Some(old) = history.iter_mut().find(|b| {
3935 b.terminal_manifest
3936 .as_ref()
3937 .is_some_and(|m| m.logical_job_id == terminal.logical_job_id)
3938 && b.delegations
3939 .first()
3940 .and_then(|d| d.body.as_ref())
3941 .and_then(|d| d.identity.as_ref())
3942 .map(|id| &id.spool_uuid)
3943 == bundle
3944 .delegations
3945 .first()
3946 .and_then(|d| d.body.as_ref())
3947 .and_then(|d| d.identity.as_ref())
3948 .map(|id| &id.spool_uuid)
3949 }) {
3950 if !bundle.delegations.starts_with(&old.delegations)
3951 || !bundle.operations.starts_with(&old.operations)
3952 || bundle.owner_genesis != old.owner_genesis
3953 || !bundle
3954 .ownership_transfers
3955 .starts_with(&old.ownership_transfers)
3956 || !old
3957 .owner_histories
3958 .iter()
3959 .all(|v| bundle.owner_histories.contains(v))
3960 || bundle.member_permission != old.member_permission
3961 || !old
3962 .genesis_authorities
3963 .iter()
3964 .all(|v| bundle.genesis_authorities.contains(v))
3965 || !old
3966 .original_geneses
3967 .iter()
3968 .all(|v| bundle.original_geneses.contains(v))
3969 || !old
3970 .creator_authority_envelopes
3971 .iter()
3972 .all(|v| bundle.creator_authority_envelopes.contains(v))
3973 || !old.manifests.iter().all(|v| bundle.manifests.contains(v))
3974 || !old.statements.iter().all(|v| bundle.statements.contains(v))
3975 || !old.policies.iter().all(|v| bundle.policies.contains(v))
3976 || !old
3977 .genesis_witnesses
3978 .iter()
3979 .all(|v| bundle.genesis_witnesses.contains(v))
3980 || !old
3981 .authority_witnesses
3982 .iter()
3983 .all(|v| bundle.authority_witnesses.contains(v))
3984 || !old
3985 .landing_witnesses
3986 .iter()
3987 .all(|v| bundle.landing_witnesses.contains(v))
3988 || !old
3989 .foreign_dependencies
3990 .iter()
3991 .all(|v| bundle.foreign_dependencies.contains(v))
3992 {
3993 return Err(Reject::HighWater);
3994 }
3995 *old = bundle.clone();
3996 } else {
3997 history.push(bundle.clone());
3998 }
3999 let witnessed = times[0].is_some();
4000 let witnessed_prefix = usize::from(witnessed);
4001 let mut persisted = snapshot.cloned();
4002 if witnessed_prefix > 0 || new_set {
4003 let mut persisted_associations =
4004 snapshot.map_or_else(Vec::new, |s| s.job_associations.clone());
4005 if witnessed_prefix > 0 {
4006 for d in &bundle.delegations[..witnessed_prefix] {
4007 let b = d.body.as_ref().ok_or(Reject::Canonical)?;
4008 if !persisted_associations
4009 .iter()
4010 .any(|(key, _)| *key == b.job_public_key)
4011 {
4012 persisted_associations
4013 .push((b.job_public_key.clone(), b.logical_job_id.clone()));
4014 }
4015 }
4016 }
4017 persisted = Some(ImportWitnessSnapshot {
4018 root: pin.clone(),
4019 witness_set: carried.ok_or(Reject::Canonical)?.clone(),
4020 clock_floor_unix_millis: now_ms,
4021 job_associations: persisted_associations,
4022 accepted_history: if witnessed_prefix > 0 {
4023 history
4024 } else {
4025 snapshot.map_or_else(Vec::new, |s| s.accepted_history.clone())
4026 },
4027 });
4028 }
4029 Ok(VerifiedImportBundleWitnesses {
4030 evidence: if witnessed {
4031 ImportBundleEvidence::Witnessed
4032 } else {
4033 ImportBundleEvidence::Recovery
4034 },
4035 snapshot_advanced: persisted.as_ref() != snapshot,
4036 owner_check_time_unix_seconds: times[0].map(|(_, time)| time),
4037 accepted_history: terminal.clone(),
4038 snapshot: persisted,
4039 })
4040}
4041
4042pub struct ImportControlCaller<'a> {
4044 pub authenticated_pop: bool,
4045 pub destination_writer: bool,
4046 pub caller_account: &'a str,
4047 pub connection_owner_account: Option<&'a str>,
4048 pub authorized_source: Option<&'a ImportSourceSelectionV1>,
4049 pub exact_grants_current: bool,
4050 pub selected_commits_available: bool,
4051}
4052#[derive(Clone, Copy)]
4053pub enum ImportControlAction {
4054 Cancel,
4055 Retry,
4056}
4057
4058pub fn check_import_control_caller(
4061 action: ImportControlAction,
4062 retained: &ImportSourceSelectionV1,
4063 scope: &ImportPermissionScopeV1,
4064 caller: &ImportControlCaller<'_>,
4065) -> Result<(), Reject> {
4066 if !caller.authenticated_pop || !caller.destination_writer || caller.caller_account.is_empty() {
4067 return Err(Reject::Scope);
4068 }
4069 if matches!(action, ImportControlAction::Cancel) {
4070 return Ok(());
4071 }
4072 validate_retained_import_source(retained, scope)?;
4073 if retained.connection.is_some()
4074 && (caller.connection_owner_account != Some(caller.caller_account)
4075 || caller.authorized_source != Some(retained)
4076 || !caller.exact_grants_current)
4077 {
4078 return Err(Reject::SourceSelection);
4079 }
4080 if !caller.selected_commits_available {
4081 return Err(Reject::SourceSelection);
4082 }
4083 Ok(())
4084}
4085
4086fn validate_retry_availability(
4087 response: &GetImportJobStateResponse,
4088 destination: &SpoolRef,
4089) -> Result<(), Reject> {
4090 use get_import_job_state_response::RetryAvailability;
4091 match response
4092 .retry_availability
4093 .as_ref()
4094 .ok_or(Reject::Canonical)?
4095 {
4096 RetryAvailability::EligibleRetryTarget(target) => {
4097 if response.status != 1 {
4098 return Err(Reject::Canonical);
4099 }
4100 let operation = target.operation_ref.as_ref().ok_or(Reject::Canonical)?;
4101 let raw = hex::decode(operation.id.replace('-', "")).map_err(|_| Reject::Canonical)?;
4102 if initial_operation_id(&raw, false)? != operation.id {
4103 return Err(Reject::Canonical);
4104 }
4105 if operation.spool.as_ref() != Some(destination) {
4106 return Err(Reject::Scope);
4107 }
4108 if target.operation_version.is_empty() || target.operation_version.len() > 256 {
4109 return Err(Reject::Bounds);
4110 }
4111 }
4112 RetryAvailability::RetryUnavailable(reason) => {
4113 if !matches!(
4114 (response.status, *reason),
4115 (1, 1 | 2 | 6) | (2, 3) | (3, 4) | (4, 5) | (5, 7)
4116 ) {
4117 return Err(Reject::Canonical);
4118 }
4119 }
4120 }
4121 Ok(())
4122}
4123
4124pub fn validate_retry_state_response(
4126 request: &GetImportJobStateRequest,
4127 response: &GetImportJobStateResponse,
4128) -> Result<(), Reject> {
4129 validate_job_state_response(request, response)?;
4130 validate_retry_availability(response, request.destination.as_ref().ok_or(Reject::Scope)?)
4131}
4132
4133pub struct ImportRetryAdmission<'a> {
4136 pub read: &'a GetImportJobStateResponse,
4137 pub original: &'a OperationRecord,
4138 pub retry_lineage_id: &'a [u8],
4139 pub logical_job_terminal: bool,
4140 pub retained_source: &'a ImportSourceSelectionV1,
4141 pub committed_manifest: &'a ImportResultManifestV1,
4142 pub now_unix_seconds: i64,
4143}
4144
4145pub fn check_retry_admission(
4148 request: &RetryImportSourceRequest,
4149 context: &ImportRetryAdmission<'_>,
4150 active: &VerifiedImportDelegation,
4151 caller: &ImportControlCaller<'_>,
4152) -> Result<(), Reject> {
4153 use get_import_job_state_response::RetryAvailability;
4154 let destination = request
4155 .original_operation
4156 .as_ref()
4157 .and_then(|r| r.spool.clone());
4158 let read_request = GetImportJobStateRequest {
4159 destination,
4160 logical_job_id: request.logical_job_id.clone(),
4161 };
4162 validate_retry_state_response(&read_request, context.read)?;
4163 if request.client_operation_id.is_empty() || request.client_operation_id.len() > 128 {
4164 return Err(Reject::Canonical);
4165 }
4166 if context.logical_job_terminal || context.read.status != 1 {
4167 return Err(Reject::Revoked);
4168 }
4169 let Some(RetryAvailability::EligibleRetryTarget(target)) = &context.read.retry_availability
4170 else {
4171 return Err(Reject::StaleContext);
4172 };
4173 let target_ref = target.operation_ref.as_ref().ok_or(Reject::Canonical)?;
4174 let original_ref = context.original.r#ref.as_ref().ok_or(Reject::Scope)?;
4175 if request.original_operation.as_ref() != Some(original_ref)
4176 || original_ref.spool != target_ref.spool
4177 || original_ref.id != target_ref.id
4178 || context.retry_lineage_id != active.body.retry_lineage_id
4179 || import_job_state_request_from_operation(context.original)?.as_ref()
4180 != Some(&read_request)
4181 {
4182 return Err(Reject::Scope);
4183 }
4184 if request.expected_operation_version != target.operation_version
4185 || context.original.version != target.operation_version
4186 || context.original.superseded_by.is_some()
4187 || !matches!(context.original.state, 4 | 5)
4188 {
4189 return Err(Reject::StaleContext);
4190 }
4191 let state = context.read;
4192 if state.active_delegation_digest != active.digest
4193 || state.active_cancellation_id != active.body.cancellation_id
4194 {
4195 return Err(Reject::StaleContext);
4196 }
4197 check_job_fence(
4198 &request.logical_job_id,
4199 &request.active_delegation_digest,
4200 request.expected_authority_epoch,
4201 active,
4202 state.authority_epoch,
4203 )?;
4204 interval(
4205 active.body.not_before_unix_seconds,
4206 active.body.expires_at_unix_seconds,
4207 context.now_unix_seconds,
4208 )?;
4209 let manifest = context.committed_manifest;
4210 validate_manifest(manifest)?;
4211 if manifest.logical_job_id != active.body.logical_job_id
4212 || manifest.retry_lineage_id != active.body.retry_lineage_id
4213 {
4214 return Err(Reject::Scope);
4215 }
4216 let scope = active.body.scope.as_ref().ok_or(Reject::Canonical)?;
4217 if !scope.branches.iter().any(|b| {
4218 !manifest
4219 .slots
4220 .iter()
4221 .any(|s| s.ref_name == b.ref_name && s.slot_id == b.slot_id)
4222 }) {
4223 return Err(Reject::StaleContext);
4224 }
4225 check_import_control_caller(
4226 ImportControlAction::Retry,
4227 context.retained_source,
4228 scope,
4229 caller,
4230 )
4231}
4232
4233pub fn validate_retry_response(
4237 request: &RetryImportSourceRequest,
4238 response: &MutationResponse,
4239 prior_attempt_ids: &[String],
4240) -> Result<(), Reject> {
4241 let receipt = response.receipt.as_ref().ok_or(Reject::PendingOperation)?;
4242 let Some(mutation_receipt::Outcome::PendingOperation(operation)) = &receipt.outcome else {
4243 return Err(Reject::PendingOperation);
4244 };
4245 let original = request
4246 .original_operation
4247 .as_ref()
4248 .ok_or(Reject::PendingOperation)?;
4249 let raw = hex::decode(operation.id.replace('-', "")).map_err(|_| Reject::PendingOperation)?;
4250 if initial_operation_id(&raw, false).map_or(true, |id| id != operation.id)
4251 || request.client_operation_id.is_empty()
4252 || receipt.client_operation_id != request.client_operation_id
4253 || original.spool.is_none()
4254 || operation.spool != original.spool
4255 || operation.id == original.id
4256 || operation.id == request.client_operation_id
4257 || prior_attempt_ids.contains(&operation.id)
4258 {
4259 return Err(Reject::PendingOperation);
4260 }
4261 Ok(())
4262}
4263
4264pub fn check_retry_replay(request_bytes: &[u8], stored_bytes: &[u8]) -> Result<(), Reject> {
4266 if request_bytes.len() > 2 * MAX_BUNDLE_BYTES {
4267 return Err(Reject::Bounds);
4268 }
4269 if request_bytes != stored_bytes {
4270 return Err(Reject::OperationIdReused);
4271 }
4272 Ok(())
4273}
4274
4275pub fn check_import_publication_budget(
4278 signed: &SignedDelegatedImportOperationV1,
4279 active: &VerifiedImportDelegation,
4280 committed_before: &ImportResultManifestV1,
4281) -> Result<(), Reject> {
4282 verify_operation(signed, active)?;
4283 let scope = active.body.scope.as_ref().ok_or(Reject::Canonical)?;
4284 if committed_before.logical_job_id != active.body.logical_job_id
4285 || committed_before.retry_lineage_id != active.body.retry_lineage_id
4286 {
4287 return Err(Reject::Scope);
4288 }
4289 let remaining = remaining_import_scope(scope, committed_before)?;
4290 if check_slot_replay(committed_before, signed)? {
4291 return Ok(());
4292 }
4293 let o = signed.body.as_ref().ok_or(Reject::Canonical)?;
4294 if remaining.max_operations == 0 || o.result_bytes > remaining.max_result_bytes {
4295 return Err(Reject::Scope);
4296 }
4297 Ok(())
4298}