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