1use std::collections::HashSet;
12
13use crate::error::CellosError;
14use crate::ExecutionCellDocument;
15use url::Url;
16
17fn is_sha256_digest(value: &str) -> bool {
19 let Some(hex) = value.strip_prefix("sha256:") else {
20 return false;
21 };
22 hex.len() == 64
23 && hex
24 .chars()
25 .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
26}
27
28pub(crate) fn is_portable_identifier(value: &str) -> bool {
29 let mut chars = value.chars();
30 let Some(first) = chars.next() else {
31 return false;
32 };
33 if !first.is_ascii_alphanumeric() {
34 return false;
35 }
36 if value.len() > 128 || value.contains("..") {
37 return false;
38 }
39 chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
40}
41
42fn ensure_portable_identifier(value: &str, field: &str) -> Result<(), CellosError> {
43 if is_portable_identifier(value) {
44 Ok(())
45 } else {
46 Err(CellosError::InvalidSpec(format!(
47 "{field} must match [A-Za-z0-9][A-Za-z0-9._-]{{0,127}} and must not contain '..'"
48 )))
49 }
50}
51
52fn is_kubernetes_namespace(value: &str) -> bool {
53 if value.is_empty() || value.len() > 63 || value.contains("..") {
54 return false;
55 }
56
57 let mut chars = value.chars();
58 let Some(first) = chars.next() else {
59 return false;
60 };
61 if !first.is_ascii_lowercase() && !first.is_ascii_digit() {
62 return false;
63 }
64
65 let Some(last) = value.chars().last() else {
66 return false;
67 };
68 if !last.is_ascii_lowercase() && !last.is_ascii_digit() {
69 return false;
70 }
71
72 value
73 .chars()
74 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
75}
76
77fn ensure_kubernetes_namespace(value: &str, field: &str) -> Result<(), CellosError> {
78 if is_kubernetes_namespace(value) {
79 Ok(())
80 } else {
81 Err(CellosError::InvalidSpec(format!(
82 "{field} must match Kubernetes DNS label rules: lowercase alphanumeric plus '-', <=63 chars"
83 )))
84 }
85}
86
87fn is_dns_label(value: &str) -> bool {
90 if value.is_empty() || value.len() > 63 {
91 return false;
92 }
93 let bytes = value.as_bytes();
94 let first_ok = bytes
95 .first()
96 .copied()
97 .is_some_and(|b| b.is_ascii_alphanumeric());
98 let last_ok = bytes
99 .last()
100 .copied()
101 .is_some_and(|b| b.is_ascii_alphanumeric());
102 if !first_ok || !last_ok {
103 return false;
104 }
105 bytes
106 .iter()
107 .all(|b| b.is_ascii_alphanumeric() || *b == b'-')
108}
109
110pub(crate) fn is_fqdn_or_wildcard(value: &str) -> bool {
118 if value.is_empty() || value.len() > 253 {
119 return false;
120 }
121 if value
125 .split('.')
126 .all(|segment| !segment.is_empty() && segment.chars().all(|c| c.is_ascii_digit()))
127 && value.contains('.')
128 {
129 return false;
130 }
131
132 let labels: Vec<&str> = value.split('.').collect();
133 if labels.len() < 2 {
134 return false;
136 }
137
138 let (first, rest) = labels.split_first().expect("non-empty above");
140 if *first == "*" {
141 if rest.is_empty() {
142 return false;
143 }
144 return rest.iter().all(|label| is_dns_label(label));
145 }
146
147 labels.iter().all(|label| is_dns_label(label))
148}
149
150fn parse_http_base_url(value: &str, field: &str) -> Result<Url, CellosError> {
151 let parsed = Url::parse(value).map_err(|_| {
152 CellosError::InvalidSpec(format!(
153 "{field} must be an absolute http(s) base URL without query or fragment"
154 ))
155 })?;
156 let scheme = parsed.scheme();
157 if scheme != "http" && scheme != "https" {
158 return Err(CellosError::InvalidSpec(format!(
159 "{field} must use http or https"
160 )));
161 }
162 if parsed.host_str().is_none() || parsed.query().is_some() || parsed.fragment().is_some() {
163 return Err(CellosError::InvalidSpec(format!(
164 "{field} must be an absolute http(s) base URL without query or fragment"
165 )));
166 }
167 Ok(parsed)
168}
169
170pub fn check_policy_pack_version(
179 declared: Option<&str>,
180 allow_downgrade: bool,
181) -> Result<(), CellosError> {
182 crate::policy::check_policy_pack_version_compatibility(declared, allow_downgrade)
183}
184
185pub fn validate_tenant_id_for_subject_token(tenant_id: &str) -> Result<(), CellosError> {
198 if tenant_id.is_empty() {
199 return Err(CellosError::InvalidSpec(
200 "spec.correlation.tenantId must be non-empty when present".into(),
201 ));
202 }
203 for ch in tenant_id.chars() {
204 let bad = ch == '.' || ch == '*' || ch == '>' || ch.is_whitespace();
205 if bad {
206 return Err(CellosError::InvalidSpec(format!(
207 "spec.correlation.tenantId contains NATS-reserved char: {ch:?} (in {tenant_id:?})"
208 )));
209 }
210 }
211 Ok(())
212}
213
214fn validate_identity_fields_for_kind(
228 identity: &crate::WorkloadIdentity,
229) -> Result<(), CellosError> {
230 use crate::WorkloadIdentityKind;
231
232 let cloud_kind = match &identity.kind {
233 WorkloadIdentityKind::FederatedOidc => return Ok(()),
234 WorkloadIdentityKind::AwsAssumeRole { .. } => "awsAssumeRole",
235 WorkloadIdentityKind::AzureManagedIdentity { .. } => "azureManagedIdentity",
236 };
237
238 let irrelevant = [
242 ("provider", !identity.provider.is_empty()),
243 ("audience", !identity.audience.is_empty()),
244 (
245 "subject",
246 identity.subject.as_deref().is_some_and(|s| !s.is_empty()),
247 ),
248 ];
249 for (field, populated) in irrelevant {
250 if populated {
251 return Err(CellosError::InvalidSpec(format!(
252 "identity_field_irrelevant_for_kind: spec.identity.{field} must be empty for kind {cloud_kind}"
253 )));
254 }
255 }
256 Ok(())
257}
258
259pub fn validate_execution_cell_document(doc: &ExecutionCellDocument) -> Result<(), CellosError> {
261 if doc.api_version != "cellos.io/v1" {
262 return Err(CellosError::InvalidSpec(format!(
263 "unsupported apiVersion: {}",
264 doc.api_version
265 )));
266 }
267 if doc.kind != "ExecutionCell" {
268 return Err(CellosError::InvalidSpec(format!(
269 "unsupported kind: {}",
270 doc.kind
271 )));
272 }
273 ensure_portable_identifier(&doc.spec.id, "spec.id")?;
274 if let Some(correlation) = &doc.spec.correlation {
275 if let Some(tenant_id) = correlation.tenant_id.as_deref() {
276 validate_tenant_id_for_subject_token(tenant_id)?;
277 }
278 }
279 let authority_secret_refs = doc
280 .spec
281 .authority
282 .secret_refs
283 .as_ref()
284 .map(|refs| refs.iter().map(String::as_str).collect::<HashSet<_>>())
285 .unwrap_or_default();
286 for secret_ref in &authority_secret_refs {
287 ensure_portable_identifier(secret_ref, "authority.secretRefs[]")?;
288 }
289 if let Some(identity) = &doc.spec.identity {
290 ensure_portable_identifier(&identity.secret_ref, "spec.identity.secretRef")?;
291 validate_identity_fields_for_kind(identity)?;
292 if let Some(ttl_seconds) = identity.ttl_seconds {
293 if ttl_seconds > doc.spec.lifetime.ttl_seconds {
294 return Err(CellosError::InvalidSpec(
295 "spec.identity.ttlSeconds must be <= spec.lifetime.ttlSeconds".into(),
296 ));
297 }
298 }
299 if !authority_secret_refs.contains(identity.secret_ref.as_str()) {
300 return Err(CellosError::InvalidSpec(format!(
301 "spec.identity.secretRef {:?} must also appear in authority.secretRefs",
302 identity.secret_ref
303 )));
304 }
305 }
306 if let Some(env) = &doc.spec.environment {
307 if env.image_reference.is_empty() {
308 return Err(CellosError::InvalidSpec(
309 "spec.environment.imageReference must be non-empty".into(),
310 ));
311 }
312 if let Some(digest) = &env.image_digest {
313 if !is_sha256_digest(digest) {
314 return Err(CellosError::InvalidSpec(
315 "spec.environment.imageDigest must be a sha256:<hex64> digest when present"
316 .into(),
317 ));
318 }
319 }
320 if let Some(template_id) = &env.template_id {
321 ensure_portable_identifier(template_id, "spec.environment.templateId")?;
322 }
323 }
324 if let Some(placement) = &doc.spec.placement {
325 if placement.pool_id.is_none()
326 && placement.kubernetes_namespace.is_none()
327 && placement.queue_name.is_none()
328 {
329 return Err(CellosError::InvalidSpec(
330 "spec.placement must set at least one placement hint".into(),
331 ));
332 }
333 if let Some(pool_id) = &placement.pool_id {
334 ensure_portable_identifier(pool_id, "spec.placement.poolId")?;
335 }
336 if let Some(namespace) = &placement.kubernetes_namespace {
337 ensure_kubernetes_namespace(namespace, "spec.placement.kubernetesNamespace")?;
338 }
339 if let Some(queue_name) = &placement.queue_name {
340 ensure_portable_identifier(queue_name, "spec.placement.queueName")?;
341 }
342 }
343 if let Some(ingress) = &doc.spec.ingress {
344 if let Some(git) = &ingress.git {
345 if let Some(secret_ref) = &git.secret_ref {
346 ensure_portable_identifier(secret_ref, "spec.ingress.git.secretRef")?;
347 }
348 }
349 if let Some(image) = &ingress.oci_image {
350 if let Some(secret_ref) = &image.secret_ref {
351 ensure_portable_identifier(secret_ref, "spec.ingress.ociImage.secretRef")?;
352 }
353 }
354 }
355 if let Some(run) = &doc.spec.run {
356 if run.argv.is_empty() || run.argv.iter().any(|s| s.is_empty()) {
360 return Err(CellosError::InvalidSpec(
361 "spec.run.argv must be non-empty with no empty strings".into(),
362 ));
363 }
364 if let Some(idx) = run.argv.iter().position(|s| s.as_bytes().contains(&0)) {
367 return Err(CellosError::InvalidSpec(format!(
368 "spec.run.argv[{idx}] contains NUL byte (would be silently truncated by execve)"
369 )));
370 }
371 check_argv_size_within_kernel_cmdline_limit(&run.argv)?;
372 if let Some(timeout_ms) = run.timeout_ms {
373 let ttl_ms = doc.spec.lifetime.ttl_seconds.saturating_mul(1000);
374 if timeout_ms > ttl_ms {
375 return Err(CellosError::InvalidSpec(
376 "spec.run.timeoutMs must be <= spec.lifetime.ttlSeconds * 1000".into(),
377 ));
378 }
379 }
380 if let Some(limits) = &run.limits {
381 if limits.memory_max_bytes == Some(0) {
382 return Err(CellosError::InvalidSpec(
383 "spec.run.limits.memoryMaxBytes must be > 0".into(),
384 ));
385 }
386 if let Some(cpu_max) = &limits.cpu_max {
387 if cpu_max.quota_micros == 0 {
388 return Err(CellosError::InvalidSpec(
389 "spec.run.limits.cpuMax.quotaMicros must be > 0".into(),
390 ));
391 }
392 if cpu_max.period_micros == Some(0) {
393 return Err(CellosError::InvalidSpec(
394 "spec.run.limits.cpuMax.periodMicros must be > 0".into(),
395 ));
396 }
397 }
398 }
399 }
400 if let Some(rules) = &doc.spec.authority.egress_rules {
401 for r in rules {
402 if r.host.is_empty() {
403 return Err(CellosError::InvalidSpec(
404 "authority.egressRules[].host must be non-empty".into(),
405 ));
406 }
407 if r.host.chars().any(|c| {
423 c.is_whitespace() || matches!(c, ';' | '{' | '}' | '"' | '\'' | '#' | '\\')
424 }) {
425 return Err(CellosError::InvalidSpec(format!(
426 "authority.egressRules[].host contains characters not allowed in a network host: {:?}",
427 r.host
428 )));
429 }
430 if let Some(proto) = r.protocol.as_deref() {
431 if proto.chars().any(|c| {
437 c.is_whitespace() || matches!(c, ';' | '{' | '}' | '"' | '\'' | '#' | '\\')
438 }) {
439 return Err(CellosError::InvalidSpec(format!(
440 "authority.egressRules[].protocol contains characters not allowed in a protocol name: {proto:?}"
441 )));
442 }
443 }
444 }
445 }
446 if let Some(dns_authority) = &doc.spec.authority.dns_authority {
447 validate_dns_authority(dns_authority)?;
448 }
449 if let Some(cdn_authority) = &doc.spec.authority.cdn_authority {
450 validate_cdn_authority(cdn_authority)?;
451 }
452 if let Some(ref token) = doc.spec.authority.authority_derivation {
456 verify_authority_derivation_structural(&doc.spec, token)?;
457 }
458 if let Some(export) = &doc.spec.export {
459 let targets = export.targets.as_deref().unwrap_or(&[]);
460 let mut target_names = HashSet::new();
461 for target in targets {
462 ensure_portable_identifier(target.name(), "spec.export.targets[].name")?;
463 if !target_names.insert(target.name().to_string()) {
464 return Err(CellosError::InvalidSpec(format!(
465 "duplicate export target name {:?}",
466 target.name()
467 )));
468 }
469 if let Some(secret_ref) = target.secret_ref() {
470 if !authority_secret_refs.contains(secret_ref) {
471 return Err(CellosError::InvalidSpec(format!(
472 "export target {:?} secretRef {:?} must appear in authority.secretRefs",
473 target.name(),
474 secret_ref
475 )));
476 }
477 }
478 if let crate::ExportTarget::Http(target) = target {
479 let parsed =
480 parse_http_base_url(&target.base_url, "spec.export.targets[].baseUrl")?;
481 if let Some(rules) = &doc.spec.authority.egress_rules {
482 if !rules.is_empty() {
483 let host = parsed.host_str().expect("checked above");
484 let port = parsed
485 .port_or_known_default()
486 .expect("http/https always has a known default port");
487 let allowed = rules
488 .iter()
489 .any(|rule| rule.port == port && rule.host.eq_ignore_ascii_case(host));
490 if !allowed {
491 return Err(CellosError::InvalidSpec(format!(
492 "http export target {:?} host {}:{} must appear in authority.egressRules",
493 target.name, host, port
494 )));
495 }
496 }
497 }
498 }
499 }
500 if let Some(artifacts) = &export.artifacts {
501 for artifact in artifacts {
502 ensure_portable_identifier(&artifact.name, "spec.export.artifacts[].name")?;
503 match artifact.target.as_deref() {
504 Some(target_name) => {
505 ensure_portable_identifier(target_name, "spec.export.artifacts[].target")?;
506 if !target_names.contains(target_name) {
507 return Err(CellosError::InvalidSpec(format!(
508 "export artifact {:?} references unknown target {:?}",
509 artifact.name, target_name
510 )));
511 }
512 }
513 None if targets.len() > 1 => {
514 return Err(CellosError::InvalidSpec(format!(
515 "export artifact {:?} must set target when multiple export targets exist",
516 artifact.name
517 )));
518 }
519 None => {}
520 }
521 }
522 }
523 }
524 if let Some(telemetry) = &doc.spec.telemetry {
525 validate_telemetry_block(telemetry, doc.spec.authority.egress_rules.as_deref())?;
526 }
527 Ok(())
528}
529
530fn validate_telemetry_block(
549 telemetry: &crate::TelemetrySpec,
550 egress_rules: Option<&[crate::EgressRule]>,
551) -> Result<(), CellosError> {
552 if telemetry.events.is_empty() {
553 return Err(CellosError::InvalidSpec(
554 "spec.telemetry.events must be non-empty".into(),
555 ));
556 }
557 match telemetry.channel {
558 crate::TelemetryChannel::VsockCbor => {}
559 }
560 if !is_semver_shape(&telemetry.agent_version) {
561 return Err(CellosError::InvalidSpec(
562 "spec.telemetry.agentVersion must match MAJOR.MINOR.PATCH semver shape (optional -prerelease suffix)".into()
563 ));
564 }
565 let has_egress = egress_rules.map(|r| !r.is_empty()).unwrap_or(false);
566 for event in &telemetry.events {
567 let trimmed = event.trim();
568 if trimmed.is_empty() {
569 return Err(CellosError::InvalidSpec(
570 "spec.telemetry.events[] entries must be non-empty".into(),
571 ));
572 }
573 if trimmed.to_ascii_lowercase().starts_with("net.") && !has_egress {
574 return Err(CellosError::InvalidSpec(format!(
575 "telemetry_without_egress: spec.telemetry.events[] entry {trimmed:?} requires \
576 at least one authority.egressRules entry — net.* telemetry without declared \
577 egress is an unbacked observation claim"
578 )));
579 }
580 }
581 Ok(())
582}
583
584fn is_semver_shape(value: &str) -> bool {
590 let core = match value.split_once('-') {
591 Some((core, pre)) => {
592 if pre.is_empty()
593 || !pre
594 .chars()
595 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-'))
596 {
597 return false;
598 }
599 core
600 }
601 None => value,
602 };
603 let parts: Vec<&str> = core.split('.').collect();
604 if parts.len() != 3 {
605 return false;
606 }
607 parts.iter().all(|p| {
608 !p.is_empty()
609 && p.chars().all(|c| c.is_ascii_digit())
610 && !(p.len() > 1 && p.starts_with('0'))
611 })
612}
613
614fn verify_authority_derivation_structural(
620 spec: &crate::ExecutionCellSpec,
621 token: &crate::AuthorityDerivationToken,
622) -> Result<(), crate::error::CellosError> {
623 let spec_capability = crate::AuthorityCapability {
624 egress_rules: spec.authority.egress_rules.clone().unwrap_or_default(),
625 secret_refs: spec.authority.secret_refs.clone().unwrap_or_default(),
626 };
627 if !spec_capability.is_superset_of(&token.leaf_capability) {
628 return Err(crate::error::CellosError::InvalidSpec(
629 "spec.authorityDerivation.leafCapability exceeds spec.authority — child authority must be ⊆ declared authority".into()
630 ));
631 }
632 Ok(())
633}
634
635fn validate_dns_authority(dns: &crate::DnsAuthority) -> Result<(), CellosError> {
641 let mut resolver_ids: HashSet<&str> = HashSet::new();
642 for resolver in &dns.resolvers {
643 ensure_portable_identifier(
644 &resolver.resolver_id,
645 "authority.dnsAuthority.resolvers[].resolverId",
646 )?;
647 if !resolver_ids.insert(resolver.resolver_id.as_str()) {
648 return Err(CellosError::InvalidSpec(format!(
649 "authority.dnsAuthority.resolvers[].resolverId duplicates value {:?}",
650 resolver.resolver_id
651 )));
652 }
653 if resolver.endpoint.is_empty() {
654 return Err(CellosError::InvalidSpec(
655 "authority.dnsAuthority.resolvers[].endpoint must be non-empty".into(),
656 ));
657 }
658 if let Some(kid) = &resolver.trust_kid {
659 ensure_portable_identifier(kid, "authority.dnsAuthority.resolvers[].trustKid")?;
660 }
661 }
662
663 for hostname in &dns.hostname_allowlist {
664 if !is_fqdn_or_wildcard(hostname) {
665 return Err(CellosError::InvalidSpec(format!(
666 "authority.dnsAuthority.hostnameAllowlist entry {hostname:?} is not a valid FQDN \
667 (single leading '*.' wildcard allowed; IPs are rejected)"
668 )));
669 }
670 }
671
672 if let Some(refresh) = &dns.refresh_policy {
673 if let (Some(min_ttl), Some(max_stale)) =
674 (refresh.min_ttl_seconds, refresh.max_stale_seconds)
675 {
676 if min_ttl > max_stale {
677 return Err(CellosError::InvalidSpec(format!(
678 "authority.dnsAuthority.refreshPolicy.minTtlSeconds ({min_ttl}) must be <= maxStaleSeconds ({max_stale})"
679 )));
680 }
681 }
682 }
683
684 Ok(())
685}
686
687fn validate_cdn_authority(cdn: &crate::CdnAuthority) -> Result<(), CellosError> {
689 let mut provider_ids: HashSet<&str> = HashSet::new();
690 for provider in &cdn.providers {
691 ensure_portable_identifier(
692 &provider.provider_id,
693 "authority.cdnAuthority.providers[].providerId",
694 )?;
695 if !provider_ids.insert(provider.provider_id.as_str()) {
696 return Err(CellosError::InvalidSpec(format!(
697 "authority.cdnAuthority.providers[].providerId duplicates value {:?}",
698 provider.provider_id
699 )));
700 }
701 if !is_fqdn_or_wildcard(&provider.hostname_pattern) {
702 return Err(CellosError::InvalidSpec(format!(
703 "authority.cdnAuthority.providers[].hostnamePattern {:?} is not a valid FQDN \
704 (single leading '*.' wildcard allowed; IPs are rejected)",
705 provider.hostname_pattern
706 )));
707 }
708 }
709 Ok(())
710}
711
712const MAX_ARGV_ENCODED_BYTES: usize = 3072;
726
727pub(crate) fn check_argv_size_within_kernel_cmdline_limit(
738 argv: &[String],
739) -> Result<(), CellosError> {
740 use base64::engine::general_purpose::STANDARD;
741 use base64::Engine as _;
742
743 let encoded_len = match serde_json::to_string(argv) {
748 Ok(json) => STANDARD.encode(json.as_bytes()).len(),
749 Err(_) => {
750 let json_upper = 2 + argv
754 .iter()
755 .map(|s| s.len().saturating_mul(2).saturating_add(3))
756 .sum::<usize>();
757 json_upper.div_ceil(3).saturating_mul(4)
758 }
759 };
760
761 if encoded_len > MAX_ARGV_ENCODED_BYTES {
762 return Err(CellosError::ArgvTooLarge {
763 encoded_bytes: encoded_len,
764 limit_bytes: MAX_ARGV_ENCODED_BYTES,
765 });
766 }
767 Ok(())
768}
769
770pub fn authority_derivation_signing_payload(
781 token: &crate::AuthorityDerivationToken,
782) -> Result<Vec<u8>, crate::error::CellosError> {
783 let value = serde_json::json!({
784 "roleRoot": token.role_root.to_string(),
785 "leafCapability": &token.leaf_capability,
786 "parentRunId": token.parent_run_id,
787 });
788 serde_json::to_vec(&value).map_err(|e| {
789 crate::error::CellosError::InvalidSpec(format!(
790 "authority derivation signing payload encode failed: {e}"
791 ))
792 })
793}
794
795pub fn verify_authority_derivation(
809 spec: &crate::ExecutionCellSpec,
810 token: &crate::AuthorityDerivationToken,
811 role_keys: &std::collections::HashMap<String, String>,
812) -> Result<(), crate::error::CellosError> {
813 use base64::engine::general_purpose::STANDARD;
814 use base64::Engine as _;
815
816 verify_authority_derivation_structural(spec, token)?;
818
819 let role_id = token.role_root.to_string();
821 let verifying_key_b64 = role_keys.get(&role_id).ok_or_else(|| {
822 crate::error::CellosError::InvalidSpec(format!("unknown role: {role_id}"))
823 })?;
824
825 let verifying_key_bytes = STANDARD.decode(verifying_key_b64.as_bytes()).map_err(|e| {
827 crate::error::CellosError::InvalidSpec(format!(
828 "authority derivation verifying key for role {role_id} is not valid base64: {e}"
829 ))
830 })?;
831 let verifying_key_array: [u8; 32] =
832 verifying_key_bytes.as_slice().try_into().map_err(|_| {
833 crate::error::CellosError::InvalidSpec(format!(
834 "authority derivation verifying key for role {role_id} must be 32 bytes (got {})",
835 verifying_key_bytes.len()
836 ))
837 })?;
838
839 let sig_bytes = STANDARD
841 .decode(token.grantor_signature.bytes.as_bytes())
842 .map_err(|e| {
843 crate::error::CellosError::InvalidSpec(format!(
844 "authority derivation grantor signature is not valid base64: {e}"
845 ))
846 })?;
847 let sig_array: [u8; 64] = sig_bytes.as_slice().try_into().map_err(|_| {
848 crate::error::CellosError::InvalidSpec(format!(
849 "authority derivation grantor signature must be 64 bytes (got {})",
850 sig_bytes.len()
851 ))
852 })?;
853
854 let payload = authority_derivation_signing_payload(token)?;
858 crate::crypto::provider()
859 .verify_ed25519(&verifying_key_array, &payload, &sig_array)
860 .map_err(|_| {
861 crate::error::CellosError::InvalidSpec("authority derivation signature invalid".into())
862 })?;
863
864 tracing::debug!(
865 role_root = %token.role_root,
866 "authority derivation token verified (structural + signature)"
867 );
868
869 Ok(())
870}
871
872pub fn enforce_derivation_scope_policy(
896 token: &crate::AuthorityDerivationToken,
897 allow_universal: bool,
898) -> Result<(), crate::error::CellosError> {
899 if token.parent_run_id.is_some() {
900 return Ok(());
901 }
902
903 if allow_universal {
904 tracing::warn!(
905 target: "cellos.supervisor.authority",
906 role_root = %token.role_root,
907 "authority derivation token has parentRunId: null — universal token accepted in permissive mode (unset CELLOS_REQUIRE_SCOPED_DERIVATION_TOKENS or set it to 1/true/yes/on to restore the strict-by-default posture)"
908 );
909 return Ok(());
910 }
911
912 Err(crate::error::CellosError::InvalidSpec(
913 "authority derivation token has parentRunId: null — universal tokens are rejected by the strict-by-default policy (set CELLOS_REQUIRE_SCOPED_DERIVATION_TOKENS to 0/false/no/off to opt into permissive mode, which emits an audit warning event instead)".into(),
914 ))
915}
916
917pub fn verify_signed_trust_keyset_envelope(
957 envelope: &crate::types::SignedTrustKeysetEnvelope,
958 verifying_keys: &std::collections::HashMap<String, crate::TrustAnchorPublicKey>,
959 now: std::time::SystemTime,
960) -> Result<Vec<u8>, crate::error::CellosError> {
961 use base64::engine::general_purpose::URL_SAFE_NO_PAD;
962 use base64::Engine as _;
963 use std::collections::HashSet;
964
965 if envelope.payload_type != crate::types::TRUST_KEYSET_V1_PAYLOAD_TYPE
974 && envelope.payload_type != crate::types::AUTHORITY_CEILING_V1_PAYLOAD_TYPE
975 && envelope.payload_type != crate::types::TRUST_REVOCATION_V1_PAYLOAD_TYPE
976 {
977 return Err(crate::error::CellosError::InvalidSpec(format!(
978 "signed trust keyset envelope payloadType must be one of \
979 {}, {}, or {}, got '{}'",
980 crate::types::TRUST_KEYSET_V1_PAYLOAD_TYPE,
981 crate::types::AUTHORITY_CEILING_V1_PAYLOAD_TYPE,
982 crate::types::TRUST_REVOCATION_V1_PAYLOAD_TYPE,
983 envelope.payload_type
984 )));
985 }
986
987 let payload_b64 = envelope.payload.trim_end_matches('=');
990 let payload_bytes = URL_SAFE_NO_PAD.decode(payload_b64).map_err(|e| {
991 crate::error::CellosError::InvalidSpec(format!(
992 "signed trust keyset envelope payload is not valid base64url: {e}"
993 ))
994 })?;
995 let computed_digest = sha256_hex_prefixed(&payload_bytes);
996 if computed_digest != envelope.payload_digest {
997 return Err(crate::error::CellosError::InvalidSpec(format!(
998 "signed trust keyset envelope payload digest mismatch: declared={}, computed={}",
999 envelope.payload_digest, computed_digest
1000 )));
1001 }
1002
1003 if envelope.signatures.is_empty() {
1004 return Err(crate::error::CellosError::InvalidSpec(
1006 "signed trust keyset envelope has no signatures".into(),
1007 ));
1008 }
1009
1010 let required = envelope.required_signer_count.unwrap_or(1).max(1);
1014
1015 let mut verified_signers: HashSet<&str> = HashSet::new();
1019 for sig_entry in &envelope.signatures {
1020 if sig_entry.algorithm != "ed25519" {
1021 continue;
1024 }
1025 if verified_signers.contains(sig_entry.signer_kid.as_str()) {
1028 continue;
1029 }
1030 let Some(verifying_key) = verifying_keys.get(&sig_entry.signer_kid) else {
1031 continue;
1033 };
1034
1035 if !signature_window_contains(
1037 now,
1038 sig_entry.not_before.as_deref(),
1039 sig_entry.not_after.as_deref(),
1040 )? {
1041 continue;
1042 }
1043
1044 let sig_b64 = sig_entry.signature.trim_end_matches('=');
1046 let Ok(sig_bytes) = URL_SAFE_NO_PAD.decode(sig_b64) else {
1047 continue;
1048 };
1049 if sig_bytes.len() != 64 {
1050 continue;
1051 }
1052
1053 if crate::crypto::provider()
1055 .verify_ed25519(verifying_key.as_bytes(), &payload_bytes, &sig_bytes)
1056 .is_ok()
1057 {
1058 verified_signers.insert(sig_entry.signer_kid.as_str());
1059 }
1060 }
1061
1062 if verified_signers.len() >= required as usize {
1063 return Ok(payload_bytes);
1064 }
1065
1066 if required == 1 {
1072 Err(crate::error::CellosError::InvalidSpec(
1073 "signed trust keyset envelope: no signature verified".into(),
1074 ))
1075 } else {
1076 Err(crate::error::CellosError::InvalidSpec(format!(
1077 "signed trust keyset envelope: only {} distinct signers verified, need {}",
1078 verified_signers.len(),
1079 required
1080 )))
1081 }
1082}
1083
1084pub fn verify_signed_trust_keyset_chain(
1113 chain: &[crate::types::SignedTrustKeysetEnvelope],
1114 verifying_keys: &std::collections::HashMap<String, crate::TrustAnchorPublicKey>,
1115 now: std::time::SystemTime,
1116) -> Result<Vec<u8>, crate::error::CellosError> {
1117 if chain.is_empty() {
1118 return Err(crate::error::CellosError::InvalidSpec(
1119 "signed trust keyset chain: empty chain".into(),
1120 ));
1121 }
1122
1123 let mut prev_payload_bytes: Option<Vec<u8>> = None;
1124 for (idx, envelope) in chain.iter().enumerate() {
1125 let payload_bytes = verify_signed_trust_keyset_envelope(envelope, verifying_keys, now)
1126 .map_err(|e| {
1127 crate::error::CellosError::InvalidSpec(format!(
1128 "signed trust keyset chain: envelope at index {idx} failed verification: {e}"
1129 ))
1130 })?;
1131
1132 if let Some(prev_bytes) = prev_payload_bytes.as_deref() {
1133 let expected = sha256_hex_prefixed(prev_bytes);
1135 match envelope.replaces_envelope_digest.as_deref() {
1136 Some(actual) if actual == expected => {}
1137 Some(actual) => {
1138 return Err(crate::error::CellosError::InvalidSpec(format!(
1139 "signed trust keyset chain: envelope at index {idx} replacesEnvelopeDigest mismatch: declared={actual}, expected={expected}"
1140 )));
1141 }
1142 None => {
1143 return Err(crate::error::CellosError::InvalidSpec(format!(
1144 "signed trust keyset chain: envelope at index {idx} missing replacesEnvelopeDigest (only the genesis envelope at index 0 may omit it)"
1145 )));
1146 }
1147 }
1148 }
1149 prev_payload_bytes = Some(payload_bytes);
1153 }
1154
1155 Ok(prev_payload_bytes.expect("chain non-empty checked above"))
1158}
1159
1160fn sha256_hex_prefixed(bytes: &[u8]) -> String {
1163 use sha2::{Digest, Sha256};
1164 use std::fmt::Write as _;
1165 let out = Sha256::new().chain_update(bytes).finalize();
1166 let mut hex = String::with_capacity(7 + 64);
1167 hex.push_str("sha256:");
1168 for b in out.iter() {
1169 let _ = write!(hex, "{b:02x}");
1170 }
1171 hex
1172}
1173
1174fn signature_window_contains(
1178 now: std::time::SystemTime,
1179 not_before: Option<&str>,
1180 not_after: Option<&str>,
1181) -> Result<bool, crate::error::CellosError> {
1182 use chrono::{DateTime, Utc};
1183 let now_chrono: DateTime<Utc> = now.into();
1184 if let Some(nb) = not_before {
1185 let parsed = DateTime::parse_from_rfc3339(nb).map_err(|e| {
1186 crate::error::CellosError::InvalidSpec(format!(
1187 "signed trust keyset envelope notBefore '{nb}' is not RFC3339: {e}"
1188 ))
1189 })?;
1190 if now_chrono < parsed.with_timezone(&Utc) {
1191 return Ok(false);
1192 }
1193 }
1194 if let Some(na) = not_after {
1195 let parsed = DateTime::parse_from_rfc3339(na).map_err(|e| {
1196 crate::error::CellosError::InvalidSpec(format!(
1197 "signed trust keyset envelope notAfter '{na}' is not RFC3339: {e}"
1198 ))
1199 })?;
1200 if now_chrono > parsed.with_timezone(&Utc) {
1201 return Ok(false);
1202 }
1203 }
1204 Ok(true)
1205}
1206
1207#[cfg(test)]
1208mod tests {
1209 use super::*;
1210 use crate::{
1211 AuthorityBundle, EnvironmentSpec, ExecutionCellSpec, ExportArtifact, ExportChannels,
1212 ExportTarget, HttpExportTarget, Lifetime, RunCpuMax, RunLimits, RunSpec, S3ExportTarget,
1213 SecretDeliveryMode, WorkloadIdentity, WorkloadIdentityKind,
1214 };
1215
1216 #[test]
1217 fn rejects_empty_argv_token() {
1218 let doc = ExecutionCellDocument {
1219 api_version: "cellos.io/v1".into(),
1220 kind: "ExecutionCell".into(),
1221 spec: ExecutionCellSpec {
1222 id: "x".into(),
1223 correlation: None,
1224 ingress: None,
1225 environment: None,
1226 placement: None,
1227 policy: None,
1228 identity: None,
1229 run: Some(RunSpec {
1230 argv: vec!["sh".into(), "".into()],
1231 working_directory: None,
1232 timeout_ms: None,
1233 limits: None,
1234 secret_delivery: SecretDeliveryMode::Env,
1235 }),
1236 authority: AuthorityBundle::default(),
1237 lifetime: Lifetime { ttl_seconds: 1 },
1238 export: None,
1239 telemetry: None,
1240 },
1241 };
1242 assert!(validate_execution_cell_document(&doc).is_err());
1243 }
1244
1245 #[test]
1246 fn rejects_egress_rule_host_with_nft_metacharacters() {
1247 let doc = ExecutionCellDocument {
1251 api_version: "cellos.io/v1".into(),
1252 kind: "ExecutionCell".into(),
1253 spec: ExecutionCellSpec {
1254 id: "x".into(),
1255 correlation: None,
1256 ingress: None,
1257 environment: None,
1258 placement: None,
1259 policy: None,
1260 identity: None,
1261 run: None,
1262 authority: AuthorityBundle {
1263 egress_rules: Some(vec![crate::EgressRule {
1264 host: "10.0.0.1 accept; drop".into(),
1265 port: 443,
1266 protocol: Some("tcp".into()),
1267 dns_egress_justification: None,
1268 }]),
1269 ..AuthorityBundle::default()
1270 },
1271 lifetime: Lifetime { ttl_seconds: 1 },
1272 export: None,
1273 telemetry: None,
1274 },
1275 };
1276 let err = validate_execution_cell_document(&doc)
1277 .expect_err("egress host with metacharacters must be rejected");
1278 assert!(
1279 matches!(err, CellosError::InvalidSpec(_)),
1280 "expected InvalidSpec, got {err:?}"
1281 );
1282 }
1283
1284 #[test]
1285 fn rejects_egress_rule_protocol_with_nft_metacharacters() {
1286 let doc = ExecutionCellDocument {
1287 api_version: "cellos.io/v1".into(),
1288 kind: "ExecutionCell".into(),
1289 spec: ExecutionCellSpec {
1290 id: "x".into(),
1291 correlation: None,
1292 ingress: None,
1293 environment: None,
1294 placement: None,
1295 policy: None,
1296 identity: None,
1297 run: None,
1298 authority: AuthorityBundle {
1299 egress_rules: Some(vec![crate::EgressRule {
1300 host: "10.0.0.1".into(),
1301 port: 443,
1302 protocol: Some("tcp accept; drop".into()),
1303 dns_egress_justification: None,
1304 }]),
1305 ..AuthorityBundle::default()
1306 },
1307 lifetime: Lifetime { ttl_seconds: 1 },
1308 export: None,
1309 telemetry: None,
1310 },
1311 };
1312 let err = validate_execution_cell_document(&doc)
1313 .expect_err("egress protocol with metacharacters must be rejected");
1314 assert!(
1315 matches!(err, CellosError::InvalidSpec(_)),
1316 "expected InvalidSpec, got {err:?}"
1317 );
1318 }
1319
1320 #[test]
1321 fn admits_egress_rule_with_clean_hostname_and_known_protocol() {
1322 let doc = ExecutionCellDocument {
1326 api_version: "cellos.io/v1".into(),
1327 kind: "ExecutionCell".into(),
1328 spec: ExecutionCellSpec {
1329 id: "x".into(),
1330 correlation: None,
1331 ingress: None,
1332 environment: None,
1333 placement: None,
1334 policy: None,
1335 identity: None,
1336 run: None,
1337 authority: AuthorityBundle {
1338 egress_rules: Some(vec![crate::EgressRule {
1339 host: "api.example.com".into(),
1340 port: 443,
1341 protocol: Some("https".into()),
1342 dns_egress_justification: None,
1343 }]),
1344 ..AuthorityBundle::default()
1345 },
1346 lifetime: Lifetime { ttl_seconds: 1 },
1347 export: None,
1348 telemetry: None,
1349 },
1350 };
1351 assert!(
1352 validate_execution_cell_document(&doc).is_ok(),
1353 "clean hostname egress rule must pass admission"
1354 );
1355 }
1356
1357 #[test]
1360 fn admits_argv_under_kernel_cmdline_limit() {
1361 let payload = "a".repeat(1024);
1363 let doc = ExecutionCellDocument {
1364 api_version: "cellos.io/v1".into(),
1365 kind: "ExecutionCell".into(),
1366 spec: ExecutionCellSpec {
1367 id: "argv-fits".into(),
1368 correlation: None,
1369 ingress: None,
1370 environment: None,
1371 placement: None,
1372 policy: None,
1373 identity: None,
1374 run: Some(RunSpec {
1375 argv: vec!["sh".into(), payload],
1376 working_directory: None,
1377 timeout_ms: None,
1378 limits: None,
1379 secret_delivery: SecretDeliveryMode::Env,
1380 }),
1381 authority: AuthorityBundle::default(),
1382 lifetime: Lifetime { ttl_seconds: 1 },
1383 export: None,
1384 telemetry: None,
1385 },
1386 };
1387 validate_execution_cell_document(&doc).expect("1 KiB argv must pass admission");
1388 }
1389
1390 #[test]
1395 fn rejects_argv_exceeding_kernel_cmdline_limit() {
1396 let payload = "a".repeat(5 * 1024);
1398 let doc = ExecutionCellDocument {
1399 api_version: "cellos.io/v1".into(),
1400 kind: "ExecutionCell".into(),
1401 spec: ExecutionCellSpec {
1402 id: "argv-too-big".into(),
1403 correlation: None,
1404 ingress: None,
1405 environment: None,
1406 placement: None,
1407 policy: None,
1408 identity: None,
1409 run: Some(RunSpec {
1410 argv: vec!["sh".into(), payload],
1411 working_directory: None,
1412 timeout_ms: None,
1413 limits: None,
1414 secret_delivery: SecretDeliveryMode::Env,
1415 }),
1416 authority: AuthorityBundle::default(),
1417 lifetime: Lifetime { ttl_seconds: 1 },
1418 export: None,
1419 telemetry: None,
1420 },
1421 };
1422 let err = validate_execution_cell_document(&doc).expect_err("5 KiB argv must reject");
1423 match err {
1424 CellosError::ArgvTooLarge {
1425 encoded_bytes,
1426 limit_bytes,
1427 } => {
1428 assert!(
1429 encoded_bytes > limit_bytes,
1430 "encoded_bytes ({encoded_bytes}) must exceed limit_bytes ({limit_bytes})"
1431 );
1432 assert_eq!(limit_bytes, 3072, "FC-17 budget is 3 KiB");
1433 }
1434 other => panic!("expected CellosError::ArgvTooLarge, got: {other:?}"),
1435 }
1436 }
1437
1438 #[test]
1439 fn rejects_identity_ttl_longer_than_cell_ttl() {
1440 let doc = ExecutionCellDocument {
1441 api_version: "cellos.io/v1".into(),
1442 kind: "ExecutionCell".into(),
1443 spec: ExecutionCellSpec {
1444 id: "x".into(),
1445 correlation: None,
1446 ingress: None,
1447 environment: None,
1448 placement: None,
1449 policy: None,
1450 identity: Some(WorkloadIdentity {
1451 kind: WorkloadIdentityKind::FederatedOidc,
1452 provider: "github-actions".into(),
1453 audience: "sts.amazonaws.com".into(),
1454 subject: None,
1455 ttl_seconds: Some(120),
1456 secret_ref: "AWS_WEB_IDENTITY".into(),
1457 }),
1458 run: None,
1459 authority: AuthorityBundle {
1460 filesystem: None,
1461 network: None,
1462 egress_rules: None,
1463 secret_refs: Some(vec!["AWS_WEB_IDENTITY".into()]),
1464 authority_derivation: None,
1465 dns_authority: None,
1466 cdn_authority: None,
1467 },
1468 lifetime: Lifetime { ttl_seconds: 60 },
1469 export: None,
1470 telemetry: None,
1471 },
1472 };
1473 assert!(validate_execution_cell_document(&doc).is_err());
1474 }
1475
1476 fn doc_with_identity(identity: WorkloadIdentity) -> ExecutionCellDocument {
1480 let secret_ref = identity.secret_ref.clone();
1481 ExecutionCellDocument {
1482 api_version: "cellos.io/v1".into(),
1483 kind: "ExecutionCell".into(),
1484 spec: ExecutionCellSpec {
1485 id: "x".into(),
1486 correlation: None,
1487 ingress: None,
1488 environment: None,
1489 placement: None,
1490 policy: None,
1491 identity: Some(identity),
1492 run: None,
1493 authority: AuthorityBundle {
1494 secret_refs: Some(vec![secret_ref]),
1495 ..AuthorityBundle::default()
1496 },
1497 lifetime: Lifetime { ttl_seconds: 60 },
1498 export: None,
1499 telemetry: None,
1500 },
1501 }
1502 }
1503
1504 #[test]
1505 fn rejects_aws_assume_role_with_non_empty_audience() {
1506 let doc = doc_with_identity(WorkloadIdentity {
1507 kind: WorkloadIdentityKind::AwsAssumeRole {
1508 role_arn: "arn:aws:iam::123456789012:role/cell".into(),
1509 sts_session_policy: None,
1510 },
1511 provider: String::new(),
1512 audience: "sts.amazonaws.com".into(),
1513 subject: None,
1514 ttl_seconds: None,
1515 secret_ref: "AWS_WEB_IDENTITY".into(),
1516 });
1517 let err = validate_execution_cell_document(&doc)
1518 .expect_err("awsAssumeRole carrying an OIDC audience must be rejected");
1519 match err {
1520 CellosError::InvalidSpec(msg) => assert!(
1521 msg.contains("identity_field_irrelevant_for_kind"),
1522 "expected identity_field_irrelevant_for_kind, got {msg:?}"
1523 ),
1524 other => panic!("expected InvalidSpec, got {other:?}"),
1525 }
1526 }
1527
1528 #[test]
1529 fn rejects_aws_assume_role_secret_ref_not_in_authority() {
1530 let doc = ExecutionCellDocument {
1532 api_version: "cellos.io/v1".into(),
1533 kind: "ExecutionCell".into(),
1534 spec: ExecutionCellSpec {
1535 id: "x".into(),
1536 correlation: None,
1537 ingress: None,
1538 environment: None,
1539 placement: None,
1540 policy: None,
1541 identity: Some(WorkloadIdentity {
1542 kind: WorkloadIdentityKind::AwsAssumeRole {
1543 role_arn: "arn:aws:iam::123456789012:role/cell".into(),
1544 sts_session_policy: None,
1545 },
1546 provider: String::new(),
1547 audience: String::new(),
1548 subject: None,
1549 ttl_seconds: None,
1550 secret_ref: "AWS_WEB_IDENTITY".into(),
1551 }),
1552 run: None,
1553 authority: AuthorityBundle::default(),
1554 lifetime: Lifetime { ttl_seconds: 60 },
1555 export: None,
1556 telemetry: None,
1557 },
1558 };
1559 let err = validate_execution_cell_document(&doc).expect_err(
1560 "awsAssumeRole secret_ref absent from authority.secretRefs must be rejected",
1561 );
1562 assert!(
1563 matches!(err, CellosError::InvalidSpec(_)),
1564 "expected InvalidSpec, got {err:?}"
1565 );
1566 }
1567
1568 #[test]
1569 fn accepts_valid_aws_assume_role() {
1570 let doc = doc_with_identity(WorkloadIdentity {
1571 kind: WorkloadIdentityKind::AwsAssumeRole {
1572 role_arn: "arn:aws:iam::123456789012:role/cell".into(),
1573 sts_session_policy: None,
1574 },
1575 provider: String::new(),
1576 audience: String::new(),
1577 subject: None,
1578 ttl_seconds: Some(30),
1579 secret_ref: "AWS_WEB_IDENTITY".into(),
1580 });
1581 validate_execution_cell_document(&doc)
1582 .expect("a well-formed awsAssumeRole spec must be accepted");
1583 }
1584
1585 #[test]
1586 fn accepts_federated_oidc_with_populated_oidc_fields() {
1587 let doc = doc_with_identity(WorkloadIdentity {
1589 kind: WorkloadIdentityKind::FederatedOidc,
1590 provider: "github-actions".into(),
1591 audience: "sts.amazonaws.com".into(),
1592 subject: Some("repo:org/repo:ref:refs/heads/main".into()),
1593 ttl_seconds: Some(30),
1594 secret_ref: "AWS_WEB_IDENTITY".into(),
1595 });
1596 validate_execution_cell_document(&doc)
1597 .expect("federatedOidc with populated OIDC fields must be accepted");
1598 }
1599
1600 #[test]
1601 fn rejects_azure_managed_identity_with_non_empty_provider() {
1602 let doc = doc_with_identity(WorkloadIdentity {
1603 kind: WorkloadIdentityKind::AzureManagedIdentity {
1604 resource_id: "/subscriptions/s/resourceGroups/g/providers/x".into(),
1605 },
1606 provider: "azure-ad".into(),
1607 audience: String::new(),
1608 subject: None,
1609 ttl_seconds: None,
1610 secret_ref: "AZURE_MI".into(),
1611 });
1612 let err = validate_execution_cell_document(&doc)
1613 .expect_err("azureManagedIdentity carrying an OIDC provider must be rejected");
1614 match err {
1615 CellosError::InvalidSpec(msg) => assert!(
1616 msg.contains("identity_field_irrelevant_for_kind"),
1617 "expected identity_field_irrelevant_for_kind, got {msg:?}"
1618 ),
1619 other => panic!("expected InvalidSpec, got {other:?}"),
1620 }
1621 }
1622
1623 #[test]
1624 fn rejects_unknown_export_target_reference() {
1625 let doc = ExecutionCellDocument {
1626 api_version: "cellos.io/v1".into(),
1627 kind: "ExecutionCell".into(),
1628 spec: ExecutionCellSpec {
1629 id: "x".into(),
1630 correlation: None,
1631 ingress: None,
1632 environment: None,
1633 placement: None,
1634 policy: None,
1635 identity: None,
1636 run: None,
1637 authority: AuthorityBundle {
1638 filesystem: None,
1639 network: None,
1640 egress_rules: None,
1641 secret_refs: Some(vec!["AWS_WEB_IDENTITY".into()]),
1642 authority_derivation: None,
1643 dns_authority: None,
1644 cdn_authority: None,
1645 },
1646 lifetime: Lifetime { ttl_seconds: 60 },
1647 export: Some(ExportChannels {
1648 artifacts: Some(vec![ExportArtifact {
1649 name: "junit".into(),
1650 path: "/tmp/junit.xml".into(),
1651 target: Some("missing".into()),
1652 content_type: None,
1653 }]),
1654 targets: Some(vec![ExportTarget::S3(S3ExportTarget {
1655 name: "artifacts".into(),
1656 bucket: "cellos-artifacts".into(),
1657 key_prefix: None,
1658 region: None,
1659 secret_ref: Some("AWS_WEB_IDENTITY".into()),
1660 })]),
1661 }),
1662 telemetry: None,
1663 },
1664 };
1665 assert!(validate_execution_cell_document(&doc).is_err());
1666 }
1667
1668 #[test]
1669 fn rejects_spec_id_with_path_traversal() {
1670 let doc = ExecutionCellDocument {
1671 api_version: "cellos.io/v1".into(),
1672 kind: "ExecutionCell".into(),
1673 spec: ExecutionCellSpec {
1674 id: "../escape".into(),
1675 correlation: None,
1676 ingress: None,
1677 environment: None,
1678 placement: None,
1679 policy: None,
1680 identity: None,
1681 run: None,
1682 authority: AuthorityBundle::default(),
1683 lifetime: Lifetime { ttl_seconds: 60 },
1684 export: None,
1685 telemetry: None,
1686 },
1687 };
1688 assert!(validate_execution_cell_document(&doc).is_err());
1689 }
1690
1691 #[test]
1692 fn rejects_export_artifact_name_with_dotdot() {
1693 let doc = ExecutionCellDocument {
1694 api_version: "cellos.io/v1".into(),
1695 kind: "ExecutionCell".into(),
1696 spec: ExecutionCellSpec {
1697 id: "safe-cell".into(),
1698 correlation: None,
1699 ingress: None,
1700 environment: None,
1701 placement: None,
1702 policy: None,
1703 identity: None,
1704 run: None,
1705 authority: AuthorityBundle::default(),
1706 lifetime: Lifetime { ttl_seconds: 60 },
1707 export: Some(ExportChannels {
1708 artifacts: Some(vec![ExportArtifact {
1709 name: "bad..name".into(),
1710 path: "/tmp/junit.xml".into(),
1711 target: None,
1712 content_type: None,
1713 }]),
1714 targets: None,
1715 }),
1716 telemetry: None,
1717 },
1718 };
1719 assert!(validate_execution_cell_document(&doc).is_err());
1720 }
1721
1722 #[test]
1723 fn rejects_secret_ref_with_separator() {
1724 let doc = ExecutionCellDocument {
1725 api_version: "cellos.io/v1".into(),
1726 kind: "ExecutionCell".into(),
1727 spec: ExecutionCellSpec {
1728 id: "safe-cell".into(),
1729 correlation: None,
1730 ingress: None,
1731 environment: None,
1732 placement: None,
1733 policy: None,
1734 identity: None,
1735 run: None,
1736 authority: AuthorityBundle {
1737 filesystem: None,
1738 network: None,
1739 egress_rules: None,
1740 secret_refs: Some(vec!["bad/ref".into()]),
1741 authority_derivation: None,
1742 dns_authority: None,
1743 cdn_authority: None,
1744 },
1745 lifetime: Lifetime { ttl_seconds: 60 },
1746 export: None,
1747 telemetry: None,
1748 },
1749 };
1750 assert!(validate_execution_cell_document(&doc).is_err());
1751 }
1752
1753 #[test]
1754 fn rejects_http_export_target_with_non_http_base_url() {
1755 let doc = ExecutionCellDocument {
1756 api_version: "cellos.io/v1".into(),
1757 kind: "ExecutionCell".into(),
1758 spec: ExecutionCellSpec {
1759 id: "safe-cell".into(),
1760 correlation: None,
1761 ingress: None,
1762 environment: None,
1763 placement: None,
1764 policy: None,
1765 identity: None,
1766 run: None,
1767 authority: AuthorityBundle {
1768 filesystem: None,
1769 network: None,
1770 egress_rules: None,
1771 secret_refs: Some(vec!["ARTIFACT_API_TOKEN".into()]),
1772 authority_derivation: None,
1773 dns_authority: None,
1774 cdn_authority: None,
1775 },
1776 lifetime: Lifetime { ttl_seconds: 60 },
1777 export: Some(ExportChannels {
1778 artifacts: Some(vec![ExportArtifact {
1779 name: "coverage-summary".into(),
1780 path: "/tmp/coverage.txt".into(),
1781 target: Some("artifact-api".into()),
1782 content_type: Some("text/plain".into()),
1783 }]),
1784 targets: Some(vec![ExportTarget::Http(HttpExportTarget {
1785 name: "artifact-api".into(),
1786 base_url: "ftp://artifacts.example.invalid/upload".into(),
1787 secret_ref: Some("ARTIFACT_API_TOKEN".into()),
1788 })]),
1789 }),
1790 telemetry: None,
1791 },
1792 };
1793 assert!(validate_execution_cell_document(&doc).is_err());
1794 }
1795
1796 use super::is_portable_identifier;
1801 use proptest::prelude::*;
1802
1803 fn minimal_doc_with_placement(placement: crate::PlacementSpec) -> ExecutionCellDocument {
1804 ExecutionCellDocument {
1805 api_version: "cellos.io/v1".into(),
1806 kind: "ExecutionCell".into(),
1807 spec: ExecutionCellSpec {
1808 id: "placement-test-cell".into(),
1809 correlation: None,
1810 ingress: None,
1811 environment: None,
1812 placement: Some(placement),
1813 policy: None,
1814 identity: None,
1815 run: None,
1816 authority: AuthorityBundle::default(),
1817 lifetime: Lifetime { ttl_seconds: 60 },
1818 export: None,
1819 telemetry: None,
1820 },
1821 }
1822 }
1823
1824 #[test]
1825 fn rejects_placement_pool_id_with_separator() {
1826 let doc = minimal_doc_with_placement(crate::PlacementSpec {
1827 pool_id: Some("pool/main".into()),
1828 kubernetes_namespace: None,
1829 queue_name: None,
1830 });
1831 let err = validate_execution_cell_document(&doc).unwrap_err();
1832 assert!(err.to_string().contains("spec.placement.poolId"));
1833 }
1834
1835 #[test]
1836 fn rejects_empty_placement_hints() {
1837 let doc = minimal_doc_with_placement(crate::PlacementSpec::default());
1838 let err = validate_execution_cell_document(&doc).unwrap_err();
1839 assert!(err
1840 .to_string()
1841 .contains("spec.placement must set at least one placement hint"));
1842 }
1843
1844 #[test]
1845 fn rejects_placement_queue_name_with_dotdot() {
1846 let doc = minimal_doc_with_placement(crate::PlacementSpec {
1847 pool_id: None,
1848 kubernetes_namespace: None,
1849 queue_name: Some("ci..high".into()),
1850 });
1851 let err = validate_execution_cell_document(&doc).unwrap_err();
1852 assert!(err.to_string().contains("spec.placement.queueName"));
1853 }
1854
1855 #[test]
1856 fn rejects_placement_namespace_with_uppercase() {
1857 let doc = minimal_doc_with_placement(crate::PlacementSpec {
1858 pool_id: None,
1859 kubernetes_namespace: Some("CellOS-Prod".into()),
1860 queue_name: None,
1861 });
1862 let err = validate_execution_cell_document(&doc).unwrap_err();
1863 assert!(err
1864 .to_string()
1865 .contains("spec.placement.kubernetesNamespace"));
1866 }
1867
1868 #[test]
1869 fn accepts_valid_placement_hints() {
1870 let doc = minimal_doc_with_placement(crate::PlacementSpec {
1871 pool_id: Some("runner-pool-amd64".into()),
1872 kubernetes_namespace: Some("cellos-prod".into()),
1873 queue_name: Some("ci-high".into()),
1874 });
1875 assert!(validate_execution_cell_document(&doc).is_ok());
1876 }
1877
1878 proptest! {
1879 #[test]
1881 fn prop_rejects_slash_in_identifier(
1882 prefix in "[A-Za-z0-9._-]{0,20}",
1883 suffix in "[A-Za-z0-9._-]{0,20}",
1884 ) {
1885 let with_slash = format!("{prefix}/{suffix}");
1886 prop_assert!(!is_portable_identifier(&with_slash), "slash in {with_slash:?}");
1887 }
1888
1889 #[test]
1891 fn prop_rejects_backslash_in_identifier(
1892 prefix in "[A-Za-z0-9._-]{0,20}",
1893 suffix in "[A-Za-z0-9._-]{0,20}",
1894 ) {
1895 let with_bs = format!("{prefix}\\{suffix}");
1896 prop_assert!(!is_portable_identifier(&with_bs), "backslash in {with_bs:?}");
1897 }
1898
1899 #[test]
1901 fn prop_rejects_dotdot_in_identifier(
1902 prefix in "[A-Za-z0-9._-]{0,20}",
1903 suffix in "[A-Za-z0-9._-]{0,20}",
1904 ) {
1905 let with_dotdot = format!("{prefix}..{suffix}");
1906 prop_assert!(!is_portable_identifier(&with_dotdot), ".. in {with_dotdot:?}");
1907 }
1908
1909 #[test]
1911 fn prop_rejects_non_alphanum_first_char(
1912 first in "[._\\-]",
1913 rest in "[A-Za-z0-9._-]{0,20}",
1914 ) {
1915 let s = format!("{first}{rest}");
1916 prop_assert!(!is_portable_identifier(&s), "starts with non-alphanum: {s:?}");
1917 }
1918
1919 #[test]
1921 fn prop_accepts_valid_identifiers(
1922 first in "[A-Za-z0-9]",
1923 rest in "[A-Za-z0-9._-]{0,60}",
1924 ) {
1925 let s = format!("{first}{rest}");
1926 prop_assume!(!s.contains(".."));
1928 prop_assert!(is_portable_identifier(&s), "should accept {s:?}");
1929 }
1930
1931 #[test]
1933 fn prop_invalid_spec_id_rejected_by_validate(
1934 prefix in "[A-Za-z0-9._-]{0,10}",
1935 suffix in "[A-Za-z0-9._-]{0,10}",
1936 ) {
1937 let bad_id = format!("{prefix}/{suffix}");
1939 let doc = ExecutionCellDocument {
1940 api_version: "cellos.io/v1".into(),
1941 kind: "ExecutionCell".into(),
1942 spec: ExecutionCellSpec {
1943 id: bad_id.clone(),
1944 correlation: None,
1945 ingress: None,
1946 environment: None,
1947 placement: None,
1948 policy: None,
1949 identity: None,
1950 run: None,
1951 authority: AuthorityBundle::default(),
1952 lifetime: Lifetime { ttl_seconds: 60 },
1953 export: None,
1954 telemetry: None,
1955 },
1956 };
1957 prop_assert!(
1958 validate_execution_cell_document(&doc).is_err(),
1959 "expected error for id {bad_id:?}"
1960 );
1961 }
1962
1963 #[test]
1965 fn prop_empty_argv_token_rejected(
1966 prefix_args in prop::collection::vec("[A-Za-z0-9/_-]{1,20}", 0..5),
1967 suffix_args in prop::collection::vec("[A-Za-z0-9/_-]{1,20}", 0..5),
1968 ) {
1969 let mut argv: Vec<String> = prefix_args;
1971 argv.push(String::new()); argv.extend(suffix_args);
1973
1974 let doc = ExecutionCellDocument {
1975 api_version: "cellos.io/v1".into(),
1976 kind: "ExecutionCell".into(),
1977 spec: ExecutionCellSpec {
1978 id: "valid-cell".into(),
1979 correlation: None,
1980 ingress: None,
1981 environment: None,
1982 placement: None,
1983 policy: None,
1984 identity: None,
1985 run: Some(RunSpec {
1986 argv,
1987 working_directory: None,
1988 timeout_ms: None,
1989 limits: None,
1990 secret_delivery: SecretDeliveryMode::Env,
1991 }),
1992 authority: AuthorityBundle::default(),
1993 lifetime: Lifetime { ttl_seconds: 60 },
1994 export: None,
1995 telemetry: None,
1996 },
1997 };
1998 prop_assert!(
1999 validate_execution_cell_document(&doc).is_err(),
2000 "empty argv token should be rejected"
2001 );
2002 }
2003 }
2004
2005 #[test]
2006 fn rejects_http_export_target_missing_egress_rule() {
2007 let doc = ExecutionCellDocument {
2008 api_version: "cellos.io/v1".into(),
2009 kind: "ExecutionCell".into(),
2010 spec: ExecutionCellSpec {
2011 id: "safe-cell".into(),
2012 correlation: None,
2013 ingress: None,
2014 environment: None,
2015 placement: None,
2016 policy: None,
2017 identity: None,
2018 run: None,
2019 authority: AuthorityBundle {
2020 filesystem: None,
2021 network: None,
2022 egress_rules: Some(vec![crate::EgressRule {
2023 host: "api.github.com".into(),
2024 port: 443,
2025 protocol: Some("tls".into()),
2026 dns_egress_justification: None,
2027 }]),
2028 secret_refs: Some(vec!["ARTIFACT_API_TOKEN".into()]),
2029 authority_derivation: None,
2030 dns_authority: None,
2031 cdn_authority: None,
2032 },
2033 lifetime: Lifetime { ttl_seconds: 60 },
2034 export: Some(ExportChannels {
2035 artifacts: Some(vec![ExportArtifact {
2036 name: "coverage-summary".into(),
2037 path: "/tmp/coverage.txt".into(),
2038 target: Some("artifact-api".into()),
2039 content_type: Some("text/plain".into()),
2040 }]),
2041 targets: Some(vec![ExportTarget::Http(HttpExportTarget {
2042 name: "artifact-api".into(),
2043 base_url: "https://artifacts.acme.internal/upload".into(),
2044 secret_ref: Some("ARTIFACT_API_TOKEN".into()),
2045 })]),
2046 }),
2047 telemetry: None,
2048 },
2049 };
2050 assert!(validate_execution_cell_document(&doc).is_err());
2051 }
2052
2053 #[test]
2054 fn rejects_run_timeout_longer_than_lifetime() {
2055 let doc = ExecutionCellDocument {
2056 api_version: "cellos.io/v1".into(),
2057 kind: "ExecutionCell".into(),
2058 spec: ExecutionCellSpec {
2059 id: "safe-cell".into(),
2060 correlation: None,
2061 ingress: None,
2062 environment: None,
2063 placement: None,
2064 policy: None,
2065 identity: None,
2066 run: Some(RunSpec {
2067 argv: vec!["/usr/bin/true".into()],
2068 working_directory: None,
2069 timeout_ms: Some(61_000),
2070 limits: None,
2071 secret_delivery: SecretDeliveryMode::Env,
2072 }),
2073 authority: AuthorityBundle::default(),
2074 lifetime: Lifetime { ttl_seconds: 60 },
2075 export: None,
2076 telemetry: None,
2077 },
2078 };
2079 assert!(validate_execution_cell_document(&doc).is_err());
2080 }
2081
2082 #[test]
2086 fn accepts_run_timeout_equal_to_lifetime() {
2087 let doc = ExecutionCellDocument {
2088 api_version: "cellos.io/v1".into(),
2089 kind: "ExecutionCell".into(),
2090 spec: ExecutionCellSpec {
2091 id: "safe-cell-boundary".into(),
2092 correlation: None,
2093 ingress: None,
2094 environment: None,
2095 placement: None,
2096 policy: None,
2097 identity: None,
2098 run: Some(RunSpec {
2099 argv: vec!["/usr/bin/true".into()],
2100 working_directory: None,
2101 timeout_ms: Some(60_000),
2102 limits: None,
2103 secret_delivery: SecretDeliveryMode::Env,
2104 }),
2105 authority: AuthorityBundle::default(),
2106 lifetime: Lifetime { ttl_seconds: 60 },
2107 export: None,
2108 telemetry: None,
2109 },
2110 };
2111 assert!(
2112 validate_execution_cell_document(&doc).is_ok(),
2113 "timeout_ms == ttl_seconds * 1000 must be accepted (boundary)"
2114 );
2115 }
2116
2117 #[test]
2121 fn rejects_run_timeout_one_ms_over_lifetime() {
2122 let doc = ExecutionCellDocument {
2123 api_version: "cellos.io/v1".into(),
2124 kind: "ExecutionCell".into(),
2125 spec: ExecutionCellSpec {
2126 id: "safe-cell-overshoot".into(),
2127 correlation: None,
2128 ingress: None,
2129 environment: None,
2130 placement: None,
2131 policy: None,
2132 identity: None,
2133 run: Some(RunSpec {
2134 argv: vec!["/usr/bin/true".into()],
2135 working_directory: None,
2136 timeout_ms: Some(60_001),
2137 limits: None,
2138 secret_delivery: SecretDeliveryMode::Env,
2139 }),
2140 authority: AuthorityBundle::default(),
2141 lifetime: Lifetime { ttl_seconds: 60 },
2142 export: None,
2143 telemetry: None,
2144 },
2145 };
2146 let err = validate_execution_cell_document(&doc).expect_err("must reject");
2147 let msg = format!("{err}");
2148 assert!(
2149 msg.contains("timeoutMs"),
2150 "error must mention timeoutMs; got {msg:?}"
2151 );
2152 assert!(
2153 msg.contains("ttlSeconds"),
2154 "error must mention ttlSeconds; got {msg:?}"
2155 );
2156 }
2157
2158 #[test]
2159 fn accepts_run_limits_and_timeout_within_lifetime() {
2160 let doc = ExecutionCellDocument {
2161 api_version: "cellos.io/v1".into(),
2162 kind: "ExecutionCell".into(),
2163 spec: ExecutionCellSpec {
2164 id: "safe-cell".into(),
2165 correlation: None,
2166 ingress: None,
2167 environment: None,
2168 placement: None,
2169 policy: None,
2170 identity: None,
2171 run: Some(RunSpec {
2172 argv: vec!["/usr/bin/true".into()],
2173 working_directory: None,
2174 timeout_ms: Some(5_000),
2175 limits: Some(RunLimits {
2176 memory_max_bytes: Some(268_435_456),
2177 cpu_max: Some(RunCpuMax {
2178 quota_micros: 50_000,
2179 period_micros: Some(100_000),
2180 }),
2181 graceful_shutdown_seconds: None,
2182 }),
2183 secret_delivery: SecretDeliveryMode::Env,
2184 }),
2185 authority: AuthorityBundle::default(),
2186 lifetime: Lifetime { ttl_seconds: 60 },
2187 export: None,
2188 telemetry: None,
2189 },
2190 };
2191 assert!(validate_execution_cell_document(&doc).is_ok());
2192 }
2193
2194 fn minimal_doc_with_env(env: EnvironmentSpec) -> ExecutionCellDocument {
2197 ExecutionCellDocument {
2198 api_version: "cellos.io/v1".into(),
2199 kind: "ExecutionCell".into(),
2200 spec: ExecutionCellSpec {
2201 id: "env-test-cell".into(),
2202 correlation: None,
2203 ingress: None,
2204 environment: Some(env),
2205 placement: None,
2206 policy: None,
2207 identity: None,
2208 run: None,
2209 authority: AuthorityBundle::default(),
2210 lifetime: Lifetime { ttl_seconds: 60 },
2211 export: None,
2212 telemetry: None,
2213 },
2214 }
2215 }
2216
2217 #[test]
2218 fn accepts_environment_with_reference_only() {
2219 let doc = minimal_doc_with_env(EnvironmentSpec {
2220 image_reference: "ubuntu:24.04".into(),
2221 image_digest: None,
2222 template_id: None,
2223 });
2224 assert!(validate_execution_cell_document(&doc).is_ok());
2225 }
2226
2227 #[test]
2228 fn accepts_environment_with_digest_and_template() {
2229 let doc = minimal_doc_with_env(EnvironmentSpec {
2230 image_reference: "ubuntu:24.04".into(),
2231 image_digest: Some(
2232 "sha256:a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2".into(),
2233 ),
2234 template_id: Some("ubuntu-24-04-build".into()),
2235 });
2236 assert!(validate_execution_cell_document(&doc).is_ok());
2237 }
2238
2239 #[test]
2240 fn rejects_environment_with_empty_image_reference() {
2241 let doc = minimal_doc_with_env(EnvironmentSpec {
2242 image_reference: "".into(),
2243 image_digest: None,
2244 template_id: None,
2245 });
2246 let err = validate_execution_cell_document(&doc).unwrap_err();
2247 assert!(
2248 err.to_string().contains("imageReference"),
2249 "expected imageReference in error, got: {err}"
2250 );
2251 }
2252
2253 #[test]
2254 fn rejects_environment_with_bad_digest_missing_prefix() {
2255 let doc = minimal_doc_with_env(EnvironmentSpec {
2256 image_reference: "ubuntu:24.04".into(),
2257 image_digest: Some(
2258 "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2".into(),
2259 ),
2260 template_id: None,
2261 });
2262 let err = validate_execution_cell_document(&doc).unwrap_err();
2263 assert!(err.to_string().contains("imageDigest"), "{err}");
2264 }
2265
2266 #[test]
2267 fn rejects_environment_with_digest_wrong_length() {
2268 let doc = minimal_doc_with_env(EnvironmentSpec {
2269 image_reference: "ubuntu:24.04".into(),
2270 image_digest: Some("sha256:deadbeef".into()),
2271 template_id: None,
2272 });
2273 let err = validate_execution_cell_document(&doc).unwrap_err();
2274 assert!(err.to_string().contains("imageDigest"), "{err}");
2275 }
2276
2277 #[test]
2278 fn rejects_environment_with_uppercase_digest() {
2279 let doc = minimal_doc_with_env(EnvironmentSpec {
2280 image_reference: "ubuntu:24.04".into(),
2281 image_digest: Some(
2282 "sha256:A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2".into(),
2283 ),
2284 template_id: None,
2285 });
2286 let err = validate_execution_cell_document(&doc).unwrap_err();
2287 assert!(err.to_string().contains("imageDigest"), "{err}");
2288 }
2289
2290 #[test]
2291 fn rejects_environment_with_invalid_template_id() {
2292 let doc = minimal_doc_with_env(EnvironmentSpec {
2293 image_reference: "ubuntu:24.04".into(),
2294 image_digest: None,
2295 template_id: Some("../escape".into()),
2296 });
2297 let err = validate_execution_cell_document(&doc).unwrap_err();
2298 assert!(err.to_string().contains("templateId"), "{err}");
2299 }
2300
2301 #[test]
2302 fn is_sha256_digest_accepts_valid() {
2303 assert!(is_sha256_digest(
2304 "sha256:a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
2305 ));
2306 }
2307
2308 #[test]
2309 fn is_sha256_digest_rejects_short() {
2310 assert!(!is_sha256_digest("sha256:deadbeef"));
2311 }
2312
2313 #[test]
2314 fn is_sha256_digest_rejects_no_prefix() {
2315 assert!(!is_sha256_digest(
2316 "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
2317 ));
2318 }
2319
2320 #[test]
2321 fn is_sha256_digest_rejects_uppercase() {
2322 assert!(!is_sha256_digest(
2323 "sha256:A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2"
2324 ));
2325 }
2326
2327 fn derivation_spec_with_authority(
2328 egress: Vec<crate::EgressRule>,
2329 secret_refs: Vec<String>,
2330 ) -> ExecutionCellSpec {
2331 ExecutionCellSpec {
2332 id: "deriv-test".into(),
2333 correlation: None,
2334 ingress: None,
2335 environment: None,
2336 placement: None,
2337 policy: None,
2338 identity: None,
2339 run: None,
2340 authority: AuthorityBundle {
2341 filesystem: None,
2342 network: None,
2343 egress_rules: Some(egress),
2344 secret_refs: Some(secret_refs),
2345 authority_derivation: None,
2346 dns_authority: None,
2347 cdn_authority: None,
2348 },
2349 lifetime: Lifetime { ttl_seconds: 60 },
2350 export: None,
2351 telemetry: None,
2352 }
2353 }
2354
2355 fn token_with_leaf(leaf: crate::AuthorityCapability) -> crate::AuthorityDerivationToken {
2356 crate::AuthorityDerivationToken {
2357 role_root: crate::RoleId("role-test".into()),
2358 parent_run_id: None,
2359 derivation_steps: vec![],
2360 leaf_capability: leaf,
2361 grantor_signature: crate::AuthoritySignature {
2362 algorithm: "ed25519".into(),
2363 bytes: "AA==".into(),
2364 },
2365 }
2366 }
2367
2368 fn sign_token(
2372 seed: &[u8; 32],
2373 mut token: crate::AuthorityDerivationToken,
2374 ) -> crate::AuthorityDerivationToken {
2375 use base64::engine::general_purpose::STANDARD;
2376 use base64::Engine as _;
2377
2378 let payload = authority_derivation_signing_payload(&token).expect("payload encode");
2379 let signature = crate::crypto::provider()
2381 .sign_ed25519(seed, &payload)
2382 .expect("provider sign");
2383 token.grantor_signature.bytes = STANDARD.encode(signature);
2384 token
2385 }
2386
2387 fn test_signing_key(seed: u8) -> [u8; 32] {
2391 [seed; 32]
2392 }
2393
2394 fn verifying_key_b64(seed: &[u8; 32]) -> String {
2395 use base64::engine::general_purpose::STANDARD;
2396 use base64::Engine as _;
2397 let public = crate::crypto::provider()
2398 .public_key_from_seed(seed)
2399 .expect("provider derive");
2400 STANDARD.encode(public)
2401 }
2402
2403 #[test]
2404 fn verify_authority_derivation_child_within_parent_passes() {
2405 let spec = derivation_spec_with_authority(
2407 vec![crate::EgressRule {
2408 host: "api.example.com".into(),
2409 port: 443,
2410 protocol: Some("https".into()),
2411 dns_egress_justification: None,
2412 }],
2413 vec!["api-key".into()],
2414 );
2415 let signing_key = test_signing_key(0x42);
2416 let token = sign_token(
2417 &signing_key,
2418 token_with_leaf(crate::AuthorityCapability {
2419 egress_rules: vec![crate::EgressRule {
2420 host: "api.example.com".into(),
2421 port: 443,
2422 protocol: Some("https".into()),
2423 dns_egress_justification: None,
2424 }],
2425 secret_refs: vec!["api-key".into()],
2426 }),
2427 );
2428 let mut keys = std::collections::HashMap::new();
2429 keys.insert("role-test".to_string(), verifying_key_b64(&signing_key));
2430 assert!(verify_authority_derivation(&spec, &token, &keys).is_ok());
2431 }
2432
2433 #[test]
2434 fn verify_authority_derivation_child_exceeding_parent_fails_egress() {
2435 let spec = derivation_spec_with_authority(
2436 vec![crate::EgressRule {
2437 host: "api.example.com".into(),
2438 port: 443,
2439 protocol: Some("https".into()),
2440 dns_egress_justification: None,
2441 }],
2442 vec![],
2443 );
2444 let token = token_with_leaf(crate::AuthorityCapability {
2445 egress_rules: vec![crate::EgressRule {
2446 host: "evil.example.com".into(),
2447 port: 443,
2448 protocol: Some("https".into()),
2449 dns_egress_justification: None,
2450 }],
2451 secret_refs: vec![],
2452 });
2453 let keys = std::collections::HashMap::new();
2454 let err = verify_authority_derivation(&spec, &token, &keys).unwrap_err();
2455 assert!(err.to_string().contains("leafCapability"), "{err}");
2456 }
2457
2458 #[test]
2459 fn verify_authority_derivation_child_exceeding_parent_fails_secrets() {
2460 let spec = derivation_spec_with_authority(vec![], vec!["api-key".into()]);
2461 let token = token_with_leaf(crate::AuthorityCapability {
2462 egress_rules: vec![],
2463 secret_refs: vec!["root-token".into()],
2464 });
2465 let keys = std::collections::HashMap::new();
2466 let err = verify_authority_derivation(&spec, &token, &keys).unwrap_err();
2467 assert!(err.to_string().contains("leafCapability"), "{err}");
2468 }
2469
2470 #[test]
2471 fn validate_doc_rejects_spec_with_exceeding_derivation_token() {
2472 let mut spec = derivation_spec_with_authority(
2473 vec![crate::EgressRule {
2474 host: "api.example.com".into(),
2475 port: 443,
2476 protocol: Some("https".into()),
2477 dns_egress_justification: None,
2478 }],
2479 vec![],
2480 );
2481 spec.authority.authority_derivation = Some(token_with_leaf(crate::AuthorityCapability {
2482 egress_rules: vec![crate::EgressRule {
2483 host: "evil.example.com".into(),
2484 port: 443,
2485 protocol: Some("https".into()),
2486 dns_egress_justification: None,
2487 }],
2488 secret_refs: vec![],
2489 }));
2490 let doc = ExecutionCellDocument {
2491 api_version: "cellos.io/v1".into(),
2492 kind: "ExecutionCell".into(),
2493 spec,
2494 };
2495 let err = validate_execution_cell_document(&doc).unwrap_err();
2496 assert!(err.to_string().contains("leafCapability"), "{err}");
2497 }
2498
2499 #[test]
2500 fn validate_doc_accepts_spec_with_valid_derivation_token() {
2501 let mut spec = derivation_spec_with_authority(
2502 vec![crate::EgressRule {
2503 host: "api.example.com".into(),
2504 port: 443,
2505 protocol: Some("https".into()),
2506 dns_egress_justification: None,
2507 }],
2508 vec!["api-key".into()],
2509 );
2510 spec.authority.authority_derivation = Some(token_with_leaf(crate::AuthorityCapability {
2511 egress_rules: vec![crate::EgressRule {
2512 host: "api.example.com".into(),
2513 port: 443,
2514 protocol: Some("https".into()),
2515 dns_egress_justification: None,
2516 }],
2517 secret_refs: vec!["api-key".into()],
2518 }));
2519 let doc = ExecutionCellDocument {
2520 api_version: "cellos.io/v1".into(),
2521 kind: "ExecutionCell".into(),
2522 spec,
2523 };
2524 assert!(validate_execution_cell_document(&doc).is_ok());
2525 }
2526
2527 fn signed_spec_and_token() -> (
2530 crate::ExecutionCellSpec,
2531 crate::AuthorityDerivationToken,
2532 [u8; 32],
2533 ) {
2534 let spec = derivation_spec_with_authority(
2535 vec![crate::EgressRule {
2536 host: "api.example.com".into(),
2537 port: 443,
2538 protocol: Some("https".into()),
2539 dns_egress_justification: None,
2540 }],
2541 vec!["api-key".into()],
2542 );
2543 let signing_key = test_signing_key(0x11);
2544 let token = sign_token(
2545 &signing_key,
2546 token_with_leaf(crate::AuthorityCapability {
2547 egress_rules: vec![crate::EgressRule {
2548 host: "api.example.com".into(),
2549 port: 443,
2550 protocol: Some("https".into()),
2551 dns_egress_justification: None,
2552 }],
2553 secret_refs: vec!["api-key".into()],
2554 }),
2555 );
2556 (spec, token, signing_key)
2557 }
2558
2559 #[test]
2560 fn test_good_signature_passes() {
2561 let (spec, token, signing_key) = signed_spec_and_token();
2562 let mut keys = std::collections::HashMap::new();
2563 keys.insert("role-test".to_string(), verifying_key_b64(&signing_key));
2564 assert!(verify_authority_derivation(&spec, &token, &keys).is_ok());
2565 }
2566
2567 #[test]
2568 fn test_bad_signature_rejected() {
2569 let (spec, mut token, signing_key) = signed_spec_and_token();
2570 use base64::engine::general_purpose::STANDARD;
2572 use base64::Engine as _;
2573 let mut sig_bytes = STANDARD
2574 .decode(token.grantor_signature.bytes.as_bytes())
2575 .expect("decode original signature");
2576 let last = sig_bytes.len() - 1;
2577 sig_bytes[last] ^= 0x01;
2578 token.grantor_signature.bytes = STANDARD.encode(&sig_bytes);
2579
2580 let mut keys = std::collections::HashMap::new();
2581 keys.insert("role-test".to_string(), verifying_key_b64(&signing_key));
2582 let err = verify_authority_derivation(&spec, &token, &keys).unwrap_err();
2583 match err {
2584 crate::error::CellosError::InvalidSpec(msg) => {
2585 assert!(
2586 msg.contains("authority derivation signature invalid"),
2587 "unexpected error message: {msg}"
2588 );
2589 }
2590 other => panic!("expected InvalidSpec, got {other:?}"),
2591 }
2592 }
2593
2594 #[test]
2595 fn test_unknown_role_rejected() {
2596 let (spec, token, _signing_key) = signed_spec_and_token();
2597 let other_key = test_signing_key(0x22);
2598 let mut keys = std::collections::HashMap::new();
2599 keys.insert("role-other".to_string(), verifying_key_b64(&other_key));
2601 let err = verify_authority_derivation(&spec, &token, &keys).unwrap_err();
2602 match err {
2603 crate::error::CellosError::InvalidSpec(msg) => {
2604 assert!(msg.contains("unknown role"), "unexpected message: {msg}");
2605 assert!(
2606 msg.contains("role-test"),
2607 "expected role id in message: {msg}"
2608 );
2609 }
2610 other => panic!("expected InvalidSpec, got {other:?}"),
2611 }
2612 }
2613
2614 #[test]
2615 fn test_no_token_passes() {
2616 let spec = derivation_spec_with_authority(
2619 vec![crate::EgressRule {
2620 host: "api.example.com".into(),
2621 port: 443,
2622 protocol: Some("https".into()),
2623 dns_egress_justification: None,
2624 }],
2625 vec!["api-key".into()],
2626 );
2627 assert!(spec.authority.authority_derivation.is_none());
2628 let doc = ExecutionCellDocument {
2629 api_version: "cellos.io/v1".into(),
2630 kind: "ExecutionCell".into(),
2631 spec,
2632 };
2633 assert!(validate_execution_cell_document(&doc).is_ok());
2634 }
2635
2636 #[test]
2637 fn test_empty_keys_with_token_fails() {
2638 let (spec, token, _signing_key) = signed_spec_and_token();
2639 let keys: std::collections::HashMap<String, String> = std::collections::HashMap::new();
2640 let err = verify_authority_derivation(&spec, &token, &keys).unwrap_err();
2641 match err {
2642 crate::error::CellosError::InvalidSpec(msg) => {
2643 assert!(msg.contains("unknown role"), "unexpected message: {msg}");
2644 }
2645 other => panic!("expected InvalidSpec, got {other:?}"),
2646 }
2647 }
2648
2649 #[test]
2652 fn enforce_scope_policy_universal_allowed_when_permissive() {
2653 let (_spec, token, _signing_key) = signed_spec_and_token();
2654 assert!(token.parent_run_id.is_none());
2655 assert!(enforce_derivation_scope_policy(&token, true).is_ok());
2657 }
2658
2659 #[test]
2660 fn enforce_scope_policy_universal_rejected_when_strict() {
2661 let (_spec, token, _signing_key) = signed_spec_and_token();
2662 assert!(token.parent_run_id.is_none());
2663 let err = enforce_derivation_scope_policy(&token, false).unwrap_err();
2664 match err {
2665 crate::error::CellosError::InvalidSpec(msg) => {
2666 assert!(
2667 msg.contains("parentRunId: null")
2668 && msg.contains("CELLOS_REQUIRE_SCOPED_DERIVATION_TOKENS"),
2669 "unexpected message: {msg}"
2670 );
2671 }
2672 other => panic!("expected InvalidSpec, got {other:?}"),
2673 }
2674 }
2675
2676 #[test]
2677 fn enforce_scope_policy_scoped_token_passes_in_either_mode() {
2678 let (_spec, mut token, _signing_key) = signed_spec_and_token();
2679 token.parent_run_id = Some("run-2026-04-25-abc".into());
2680 assert!(enforce_derivation_scope_policy(&token, true).is_ok());
2681 assert!(enforce_derivation_scope_policy(&token, false).is_ok());
2682 }
2683
2684 #[test]
2701 #[ignore]
2702 fn gen_signed_ci_runner_fixture() {
2703 let signing_key = test_signing_key(0x42);
2704 let leaf = crate::AuthorityCapability {
2705 egress_rules: vec![
2706 crate::EgressRule {
2707 host: "api.github.com".into(),
2708 port: 443,
2709 protocol: Some("tls".into()),
2710 dns_egress_justification: None,
2711 },
2712 crate::EgressRule {
2713 host: "ghcr.io".into(),
2714 port: 443,
2715 protocol: Some("tls".into()),
2716 dns_egress_justification: None,
2717 },
2718 crate::EgressRule {
2719 host: "artifacts.internal".into(),
2720 port: 443,
2721 protocol: Some("tls".into()),
2722 dns_egress_justification: None,
2723 },
2724 crate::EgressRule {
2725 host: "dns.internal".into(),
2726 port: 53,
2727 protocol: Some("dns-acknowledged".into()),
2728 dns_egress_justification: Some(
2729 "Internal resolver at dns.internal required for artifacts.internal hostname resolution; nameserver is operator-controlled and air-gapped from public internet.".into(),
2730 ),
2731 },
2732 ],
2733 secret_refs: vec!["NPM_TOKEN".into(), "GITHUB_TOKEN".into()],
2734 };
2735 let token = crate::AuthorityDerivationToken {
2736 role_root: crate::RoleId("role-ci-runner".into()),
2737 parent_run_id: Some("run-demo-ref-001".into()),
2738 derivation_steps: vec![],
2739 leaf_capability: leaf,
2740 grantor_signature: crate::AuthoritySignature {
2741 algorithm: "ed25519".into(),
2742 bytes: "AA==".into(),
2743 },
2744 };
2745 let signed = sign_token(&signing_key, token);
2746 eprintln!("FIXTURE seed=0x42 role=role-ci-runner parentRunId=run-demo-ref-001");
2747 eprintln!(
2748 "FIXTURE verifyingKey(b64): {}",
2749 verifying_key_b64(&signing_key)
2750 );
2751 eprintln!(
2752 "FIXTURE grantorSignature(b64): {}",
2753 signed.grantor_signature.bytes
2754 );
2755 }
2756
2757 fn doc_with_authority(authority: AuthorityBundle) -> ExecutionCellDocument {
2760 ExecutionCellDocument {
2761 api_version: "cellos.io/v1".into(),
2762 kind: "ExecutionCell".into(),
2763 spec: ExecutionCellSpec {
2764 id: "dns-cdn-test-cell".into(),
2765 correlation: None,
2766 ingress: None,
2767 environment: None,
2768 placement: None,
2769 policy: None,
2770 identity: None,
2771 run: None,
2772 authority,
2773 lifetime: Lifetime { ttl_seconds: 60 },
2774 export: None,
2775 telemetry: None,
2776 },
2777 }
2778 }
2779
2780 fn full_dns_authority() -> crate::DnsAuthority {
2781 crate::DnsAuthority {
2782 resolvers: vec![crate::DnsResolver {
2783 resolver_id: "internal-doh".into(),
2784 endpoint: "https://1.1.1.1/dns-query".into(),
2785 protocol: crate::DnsResolverProtocol::Doh,
2786 trust_kid: Some("resolver-kid-2026q2".into()),
2787 dnssec: None,
2788 }],
2789 allowed_query_types: vec![crate::DnsQueryType::A, crate::DnsQueryType::AAAA],
2790 hostname_allowlist: vec!["api.example.com".into(), "*.cdn.example.com".into()],
2791 refresh_policy: Some(crate::DnsRefreshPolicy {
2792 min_ttl_seconds: Some(30),
2793 max_stale_seconds: Some(300),
2794 strategy: Some(crate::DnsRefreshStrategy::TtlHonor),
2795 }),
2796 rebinding_policy: None,
2799 block_direct_workload_dns: true,
2800 block_udp_doq: false,
2805 block_udp_http3: false,
2806 }
2807 }
2808
2809 #[test]
2810 fn t13_accepts_full_dns_and_cdn_authority() {
2811 let authority = AuthorityBundle {
2812 filesystem: None,
2813 network: None,
2814 egress_rules: None,
2815 secret_refs: None,
2816 authority_derivation: None,
2817 dns_authority: Some(full_dns_authority()),
2818 cdn_authority: Some(crate::CdnAuthority {
2819 providers: vec![crate::CdnProvider {
2820 provider_id: "cloudfront".into(),
2821 hostname_pattern: "*.cdn.example.com".into(),
2822 accept_fronting: false,
2823 }],
2824 }),
2825 };
2826 let doc = doc_with_authority(authority);
2827 validate_execution_cell_document(&doc).expect("full T13 authority should validate");
2828 }
2829
2830 #[test]
2831 fn t13_rejects_dns_hostname_allowlist_with_ip_literal() {
2832 let mut dns = full_dns_authority();
2833 dns.hostname_allowlist = vec!["10.0.0.1".into()];
2834 let authority = AuthorityBundle {
2835 dns_authority: Some(dns),
2836 ..AuthorityBundle::default()
2837 };
2838 let err = validate_execution_cell_document(&doc_with_authority(authority)).unwrap_err();
2839 assert!(
2840 err.to_string()
2841 .contains("authority.dnsAuthority.hostnameAllowlist"),
2842 "{err}"
2843 );
2844 }
2845
2846 #[test]
2847 fn t13_rejects_dns_hostname_allowlist_with_internal_wildcard() {
2848 let mut dns = full_dns_authority();
2849 dns.hostname_allowlist = vec!["api.*.example.com".into()];
2851 let authority = AuthorityBundle {
2852 dns_authority: Some(dns),
2853 ..AuthorityBundle::default()
2854 };
2855 let err = validate_execution_cell_document(&doc_with_authority(authority)).unwrap_err();
2856 assert!(
2857 err.to_string()
2858 .contains("authority.dnsAuthority.hostnameAllowlist"),
2859 "{err}"
2860 );
2861 }
2862
2863 #[test]
2864 fn t13_rejects_dns_resolver_with_invalid_resolver_id() {
2865 let mut dns = full_dns_authority();
2866 dns.resolvers[0].resolver_id = "../escape".into();
2867 let authority = AuthorityBundle {
2868 dns_authority: Some(dns),
2869 ..AuthorityBundle::default()
2870 };
2871 let err = validate_execution_cell_document(&doc_with_authority(authority)).unwrap_err();
2872 assert!(
2873 err.to_string()
2874 .contains("authority.dnsAuthority.resolvers[].resolverId"),
2875 "{err}"
2876 );
2877 }
2878
2879 #[test]
2880 fn t13_rejects_duplicate_dns_resolver_ids() {
2881 let mut dns = full_dns_authority();
2882 dns.resolvers.push(crate::DnsResolver {
2883 resolver_id: "internal-doh".into(),
2884 endpoint: "https://1.0.0.1/dns-query".into(),
2885 protocol: crate::DnsResolverProtocol::Doh,
2886 trust_kid: None,
2887 dnssec: None,
2888 });
2889 let authority = AuthorityBundle {
2890 dns_authority: Some(dns),
2891 ..AuthorityBundle::default()
2892 };
2893 let err = validate_execution_cell_document(&doc_with_authority(authority)).unwrap_err();
2894 assert!(
2895 err.to_string().contains("duplicates value"),
2896 "expected duplicate resolverId rejection, got: {err}"
2897 );
2898 }
2899
2900 #[test]
2901 fn t13_rejects_refresh_policy_min_ttl_greater_than_max_stale() {
2902 let mut dns = full_dns_authority();
2903 dns.refresh_policy = Some(crate::DnsRefreshPolicy {
2904 min_ttl_seconds: Some(600),
2905 max_stale_seconds: Some(60),
2906 strategy: Some(crate::DnsRefreshStrategy::TtlHonor),
2907 });
2908 let authority = AuthorityBundle {
2909 dns_authority: Some(dns),
2910 ..AuthorityBundle::default()
2911 };
2912 let err = validate_execution_cell_document(&doc_with_authority(authority)).unwrap_err();
2913 assert!(
2914 err.to_string().contains("minTtlSeconds")
2915 && err.to_string().contains("maxStaleSeconds"),
2916 "{err}"
2917 );
2918 }
2919
2920 #[test]
2921 fn t13_rejects_cdn_provider_with_ip_literal_pattern() {
2922 let authority = AuthorityBundle {
2923 cdn_authority: Some(crate::CdnAuthority {
2924 providers: vec![crate::CdnProvider {
2925 provider_id: "fastly".into(),
2926 hostname_pattern: "192.168.1.1".into(),
2927 accept_fronting: false,
2928 }],
2929 }),
2930 ..AuthorityBundle::default()
2931 };
2932 let err = validate_execution_cell_document(&doc_with_authority(authority)).unwrap_err();
2933 assert!(
2934 err.to_string()
2935 .contains("authority.cdnAuthority.providers[].hostnamePattern"),
2936 "{err}"
2937 );
2938 }
2939
2940 #[test]
2941 fn t13_rejects_duplicate_cdn_provider_ids() {
2942 let authority = AuthorityBundle {
2943 cdn_authority: Some(crate::CdnAuthority {
2944 providers: vec![
2945 crate::CdnProvider {
2946 provider_id: "cloudfront".into(),
2947 hostname_pattern: "a.cdn.example.com".into(),
2948 accept_fronting: false,
2949 },
2950 crate::CdnProvider {
2951 provider_id: "cloudfront".into(),
2952 hostname_pattern: "b.cdn.example.com".into(),
2953 accept_fronting: true,
2954 },
2955 ],
2956 }),
2957 ..AuthorityBundle::default()
2958 };
2959 let err = validate_execution_cell_document(&doc_with_authority(authority)).unwrap_err();
2960 assert!(err.to_string().contains("duplicates value"), "{err}");
2961 }
2962
2963 #[test]
2964 fn t13_dns_resolver_protocol_serialises_kebab_case() {
2965 let resolver = crate::DnsResolver {
2966 resolver_id: "p1".into(),
2967 endpoint: "1.1.1.1:53".into(),
2968 protocol: crate::DnsResolverProtocol::Do53Udp,
2969 trust_kid: None,
2970 dnssec: None,
2971 };
2972 let v = serde_json::to_value(&resolver).unwrap();
2973 assert_eq!(v.get("protocol"), Some(&serde_json::json!("do53-udp")));
2974 }
2975
2976 #[test]
2977 fn t13_dns_authority_roundtrips_through_full_document() {
2978 let raw = r#"{
2979 "apiVersion": "cellos.io/v1",
2980 "kind": "ExecutionCell",
2981 "spec": {
2982 "id": "t13-roundtrip",
2983 "authority": {
2984 "dnsAuthority": {
2985 "resolvers": [{
2986 "resolverId": "internal-doh",
2987 "endpoint": "https://1.1.1.1/dns-query",
2988 "protocol": "doh",
2989 "trustKid": "resolver-kid-2026q2"
2990 }],
2991 "allowedQueryTypes": ["A", "AAAA", "HTTPS"],
2992 "hostnameAllowlist": ["api.example.com", "*.cdn.example.com"],
2993 "refreshPolicy": {
2994 "minTtlSeconds": 30,
2995 "maxStaleSeconds": 300,
2996 "strategy": "ttl-honor"
2997 },
2998 "blockDirectWorkloadDns": true
2999 },
3000 "cdnAuthority": {
3001 "providers": [{
3002 "providerId": "cloudfront",
3003 "hostnamePattern": "*.cdn.example.com",
3004 "acceptFronting": false
3005 }]
3006 }
3007 },
3008 "lifetime": { "ttlSeconds": 60 }
3009 }
3010 }"#;
3011 let doc: ExecutionCellDocument =
3012 serde_json::from_str(raw).expect("parse T13 example document");
3013 validate_execution_cell_document(&doc).expect("validation must pass");
3014
3015 let dns = doc.spec.authority.dns_authority.as_ref().unwrap();
3016 assert_eq!(dns.resolvers.len(), 1);
3017 assert!(matches!(
3018 dns.resolvers[0].protocol,
3019 crate::DnsResolverProtocol::Doh
3020 ));
3021 assert!(dns.block_direct_workload_dns);
3022
3023 let cdn = doc.spec.authority.cdn_authority.as_ref().unwrap();
3024 assert_eq!(cdn.providers.len(), 1);
3025 assert!(!cdn.providers[0].accept_fronting);
3026
3027 let serialised = serde_json::to_string(&doc).expect("re-serialise");
3028 let doc2: ExecutionCellDocument =
3029 serde_json::from_str(&serialised).expect("re-parse roundtrip");
3030 assert_eq!(
3031 doc2.spec.authority.dns_authority,
3032 doc.spec.authority.dns_authority
3033 );
3034 assert_eq!(
3035 doc2.spec.authority.cdn_authority,
3036 doc.spec.authority.cdn_authority
3037 );
3038 }
3039
3040 #[test]
3041 fn t13_is_fqdn_or_wildcard_unit_cases() {
3042 assert!(super::is_fqdn_or_wildcard("api.example.com"));
3043 assert!(super::is_fqdn_or_wildcard("*.example.com"));
3044 assert!(super::is_fqdn_or_wildcard("a.b.c.d.example.com"));
3045 assert!(!super::is_fqdn_or_wildcard(""));
3046 assert!(!super::is_fqdn_or_wildcard("example")); assert!(!super::is_fqdn_or_wildcard("10.0.0.1")); assert!(!super::is_fqdn_or_wildcard("2001:db8::1")); assert!(!super::is_fqdn_or_wildcard("api.*.example.com")); assert!(!super::is_fqdn_or_wildcard("-bad.example.com")); assert!(!super::is_fqdn_or_wildcard("under_score.example.com")); }
3053
3054 fn telemetry_doc(
3057 events: Vec<String>,
3058 agent_version: &str,
3059 egress: Vec<crate::EgressRule>,
3060 ) -> ExecutionCellDocument {
3061 let authority = AuthorityBundle {
3062 egress_rules: if egress.is_empty() {
3063 None
3064 } else {
3065 Some(egress)
3066 },
3067 ..AuthorityBundle::default()
3068 };
3069 ExecutionCellDocument {
3070 api_version: "cellos.io/v1".into(),
3071 kind: "ExecutionCell".into(),
3072 spec: ExecutionCellSpec {
3073 id: "telemetry-test-cell".into(),
3074 correlation: None,
3075 ingress: None,
3076 environment: None,
3077 placement: None,
3078 policy: None,
3079 identity: None,
3080 run: None,
3081 authority,
3082 lifetime: Lifetime { ttl_seconds: 60 },
3083 export: None,
3084 telemetry: Some(crate::TelemetrySpec {
3085 channel: crate::TelemetryChannel::VsockCbor,
3086 events,
3087 rate_limits: None,
3088 host_vs_guest_fields: None,
3089 agent_version: agent_version.into(),
3090 }),
3091 },
3092 }
3093 }
3094
3095 #[test]
3096 fn telemetry_rejects_empty_events() {
3097 let doc = telemetry_doc(vec![], "1.0.0", vec![]);
3098 let err = validate_execution_cell_document(&doc).unwrap_err();
3099 assert!(
3100 err.to_string().contains("spec.telemetry.events"),
3101 "got: {err}"
3102 );
3103 }
3104
3105 #[test]
3106 fn telemetry_rejects_bad_semver() {
3107 let doc = telemetry_doc(vec!["process.spawn".into()], "v1", vec![]);
3108 let err = validate_execution_cell_document(&doc).unwrap_err();
3109 assert!(err.to_string().contains("agentVersion"), "got: {err}");
3110 }
3111
3112 #[test]
3113 fn telemetry_rejects_net_event_without_egress() {
3114 let doc = telemetry_doc(vec!["net.connect.attempt".into()], "1.0.0", vec![]);
3115 let err = validate_execution_cell_document(&doc).unwrap_err();
3116 let msg = err.to_string();
3117 assert!(msg.contains("telemetry_without_egress"), "got: {msg}");
3118 }
3119
3120 #[test]
3121 fn telemetry_accepts_net_event_when_egress_declared() {
3122 let doc = telemetry_doc(
3123 vec!["net.connect.attempt".into()],
3124 "1.0.0",
3125 vec![crate::EgressRule {
3126 host: "api.example.com".into(),
3127 port: 443,
3128 protocol: Some("https".into()),
3129 dns_egress_justification: None,
3130 }],
3131 );
3132 validate_execution_cell_document(&doc).expect("valid telemetry+egress spec");
3133 }
3134
3135 #[test]
3136 fn telemetry_accepts_non_net_event_without_egress() {
3137 let doc = telemetry_doc(vec!["process.spawn".into()], "1.0.0", vec![]);
3138 validate_execution_cell_document(&doc).expect("non-net.* event must not require egress");
3139 }
3140
3141 #[test]
3142 fn telemetry_accepts_prerelease_semver() {
3143 let doc = telemetry_doc(vec!["process.spawn".into()], "1.2.3-rc.1", vec![]);
3144 validate_execution_cell_document(&doc).expect("prerelease semver accepted");
3145 }
3146}
3147
3148#[cfg(all(test, feature = "dalek"))]
3153mod sec25_envelope_tests {
3154 use super::{
3161 sha256_hex_prefixed, verify_signed_trust_keyset_chain, verify_signed_trust_keyset_envelope,
3162 };
3163 use crate::types::{SignedTrustKeysetEnvelope, TrustKeysetSignature};
3164 use crate::TrustAnchorPublicKey;
3165 use base64::engine::general_purpose::URL_SAFE_NO_PAD;
3166 use base64::Engine as _;
3167 use ed25519_dalek::{Signer as _, SigningKey};
3168 use std::collections::HashMap;
3169 use std::time::{Duration, SystemTime, UNIX_EPOCH};
3170
3171 fn signing_key(seed: u8) -> SigningKey {
3172 SigningKey::from_bytes(&[seed; 32])
3173 }
3174
3175 fn anchor_of(signer: &SigningKey) -> TrustAnchorPublicKey {
3177 TrustAnchorPublicKey::from_bytes_unchecked(signer.verifying_key().to_bytes())
3178 }
3179
3180 fn make_envelope(
3183 payload_bytes: &[u8],
3184 signer: &SigningKey,
3185 signer_kid: &str,
3186 not_before: Option<String>,
3187 not_after: Option<String>,
3188 ) -> (
3189 SignedTrustKeysetEnvelope,
3190 HashMap<String, TrustAnchorPublicKey>,
3191 ) {
3192 let signature = signer.sign(payload_bytes);
3193 let envelope = SignedTrustKeysetEnvelope {
3194 schema_version: "1.0.0".into(),
3195 payload_type: "application/vnd.cellos.trust-keyset-v1+json".into(),
3196 payload: URL_SAFE_NO_PAD.encode(payload_bytes),
3197 signatures: vec![TrustKeysetSignature {
3198 signer_kid: signer_kid.into(),
3199 algorithm: "ed25519".into(),
3200 signature: URL_SAFE_NO_PAD.encode(signature.to_bytes()),
3201 not_before,
3202 not_after,
3203 }],
3204 payload_digest: sha256_hex_prefixed(payload_bytes),
3205 produced_at: "2026-05-01T00:00:00Z".into(),
3206 replaces_envelope_digest: None,
3207 required_signer_count: None,
3208 };
3209 let mut keys = HashMap::new();
3210 keys.insert(signer_kid.to_string(), anchor_of(&signer));
3211 (envelope, keys)
3212 }
3213
3214 #[test]
3215 fn verifies_well_formed_envelope_with_synthetic_key() {
3216 let signer = signing_key(7);
3217 let payload = br#"{"schemaVersion":"1.0.0","keysetId":"ks-7","keys":[]}"#;
3218 let (env, keys) = make_envelope(payload, &signer, "kid-active-7", None, None);
3219 let returned = verify_signed_trust_keyset_envelope(&env, &keys, SystemTime::now())
3220 .expect("envelope should verify");
3221 assert_eq!(returned, payload, "verifier must return raw payload bytes");
3222 }
3223
3224 #[test]
3225 fn accepts_three_sibling_payload_types_rejects_fourth() {
3226 let signer = signing_key(23);
3231 let payload = br#"{"schemaVersion":"1.0.0","keysetId":"ks-23","keys":[]}"#;
3232 let (base_env, keys) = make_envelope(payload, &signer, "kid-active-23", None, None);
3233
3234 let mut keyset_env = base_env.clone();
3236 keyset_env.payload_type = crate::types::TRUST_KEYSET_V1_PAYLOAD_TYPE.into();
3237 verify_signed_trust_keyset_envelope(&keyset_env, &keys, SystemTime::now())
3238 .expect("trust-keyset-v1 media type must be accepted");
3239
3240 let mut ceiling_env = base_env.clone();
3242 ceiling_env.payload_type = crate::types::AUTHORITY_CEILING_V1_PAYLOAD_TYPE.into();
3243 verify_signed_trust_keyset_envelope(&ceiling_env, &keys, SystemTime::now())
3244 .expect("authority-ceiling-v1 media type must be accepted");
3245
3246 let mut revocation_env = base_env.clone();
3248 revocation_env.payload_type = crate::types::TRUST_REVOCATION_V1_PAYLOAD_TYPE.into();
3249 verify_signed_trust_keyset_envelope(&revocation_env, &keys, SystemTime::now())
3250 .expect("trust-revocation-v1 media type must be accepted");
3251
3252 let mut other_env = base_env;
3254 other_env.payload_type = "application/vnd.cellos.something-else-v1+json".into();
3255 let err = verify_signed_trust_keyset_envelope(&other_env, &keys, SystemTime::now())
3256 .expect_err("non-sibling media type must be rejected");
3257 assert!(
3258 format!("{err}").contains("payloadType must be one of"),
3259 "expected media-type guard rejection, got: {err}"
3260 );
3261 }
3262
3263 #[test]
3264 fn rejects_payload_digest_mismatch() {
3265 let signer = signing_key(11);
3266 let payload = b"hello-payload";
3267 let (mut env, keys) = make_envelope(payload, &signer, "kid-active-11", None, None);
3268 let last_idx = env.payload_digest.len() - 1;
3270 let last_char = env.payload_digest.chars().last().unwrap();
3271 let new_char = if last_char == '0' { '1' } else { '0' };
3272 env.payload_digest
3273 .replace_range(last_idx.., &new_char.to_string());
3274
3275 let err = verify_signed_trust_keyset_envelope(&env, &keys, SystemTime::now())
3276 .expect_err("digest mismatch must fail");
3277 assert!(
3278 format!("{err}").contains("payload digest mismatch"),
3279 "expected digest-mismatch error, got: {err}"
3280 );
3281 }
3282
3283 #[test]
3284 fn rejects_unknown_signer_kid() {
3285 let signer = signing_key(13);
3286 let payload = b"unknown-kid-payload";
3287 let (env, _keys) = make_envelope(payload, &signer, "kid-active-13", None, None);
3288 let empty_keys: HashMap<String, TrustAnchorPublicKey> = HashMap::new();
3289 let err = verify_signed_trust_keyset_envelope(&env, &empty_keys, SystemTime::now())
3290 .expect_err("unknown kid must fail");
3291 assert!(
3292 format!("{err}").contains("no signature verified"),
3293 "expected no-signature-verified error, got: {err}"
3294 );
3295 }
3296
3297 #[test]
3298 fn rejects_signature_outside_not_after_window() {
3299 let signer = signing_key(17);
3300 let payload = b"window-payload";
3301 let (env, keys) = make_envelope(
3303 payload,
3304 &signer,
3305 "kid-active-17",
3306 None,
3307 Some("2000-01-01T00:00:00Z".into()),
3308 );
3309 let err = verify_signed_trust_keyset_envelope(&env, &keys, SystemTime::now())
3310 .expect_err("expired window must fail");
3311 assert!(
3312 format!("{err}").contains("no signature verified"),
3313 "expected no-signature-verified error, got: {err}"
3314 );
3315 }
3316
3317 #[test]
3318 fn rejects_tampered_payload() {
3319 let signer = signing_key(19);
3320 let original = b"original-trust-keyset-payload-bytes";
3321 let (mut env, keys) = make_envelope(original, &signer, "kid-active-19", None, None);
3322
3323 let mut tampered = original.to_vec();
3327 tampered[0] ^= 0x01;
3328 env.payload = URL_SAFE_NO_PAD.encode(&tampered);
3329 env.payload_digest = sha256_hex_prefixed(&tampered);
3330
3331 let err = verify_signed_trust_keyset_envelope(&env, &keys, SystemTime::now())
3332 .expect_err("tampered payload must fail signature check");
3333 assert!(
3334 format!("{err}").contains("no signature verified"),
3335 "expected signature failure, got: {err}"
3336 );
3337 }
3338
3339 #[test]
3340 fn accepts_when_at_least_one_signature_verifies() {
3341 let signer_good = signing_key(23);
3342 let signer_other = signing_key(29);
3343 let payload = b"multi-sig-payload";
3344
3345 let bad_sig = signer_other.sign(payload);
3347 let good_sig = signer_good.sign(payload);
3349
3350 let env = SignedTrustKeysetEnvelope {
3351 schema_version: "1.0.0".into(),
3352 payload_type: "application/vnd.cellos.trust-keyset-v1+json".into(),
3353 payload: URL_SAFE_NO_PAD.encode(payload),
3354 signatures: vec![
3355 TrustKeysetSignature {
3356 signer_kid: "kid-unknown-29".into(),
3357 algorithm: "ed25519".into(),
3358 signature: URL_SAFE_NO_PAD.encode(bad_sig.to_bytes()),
3359 not_before: None,
3360 not_after: None,
3361 },
3362 TrustKeysetSignature {
3363 signer_kid: "kid-active-23".into(),
3364 algorithm: "ed25519".into(),
3365 signature: URL_SAFE_NO_PAD.encode(good_sig.to_bytes()),
3366 not_before: None,
3367 not_after: None,
3368 },
3369 ],
3370 payload_digest: sha256_hex_prefixed(payload),
3371 produced_at: "2026-05-01T00:00:00Z".into(),
3372 replaces_envelope_digest: None,
3373 required_signer_count: None,
3374 };
3375
3376 let mut keys = HashMap::new();
3377 keys.insert("kid-active-23".to_string(), anchor_of(&signer_good));
3379
3380 let returned = verify_signed_trust_keyset_envelope(
3381 &env,
3382 &keys,
3383 UNIX_EPOCH + Duration::from_secs(1_800_000_000),
3384 )
3385 .expect("at least one signature should verify");
3386 assert_eq!(returned, payload);
3387 }
3388
3389 fn make_multisig_envelope(
3395 payload_bytes: &[u8],
3396 signers: &[(&str, &SigningKey)],
3397 required_signer_count: Option<u32>,
3398 ) -> (
3399 SignedTrustKeysetEnvelope,
3400 HashMap<String, TrustAnchorPublicKey>,
3401 ) {
3402 let signatures = signers
3403 .iter()
3404 .map(|(kid, signer)| {
3405 let sig = signer.sign(payload_bytes);
3406 TrustKeysetSignature {
3407 signer_kid: (*kid).to_string(),
3408 algorithm: "ed25519".into(),
3409 signature: URL_SAFE_NO_PAD.encode(sig.to_bytes()),
3410 not_before: None,
3411 not_after: None,
3412 }
3413 })
3414 .collect();
3415
3416 let envelope = SignedTrustKeysetEnvelope {
3417 schema_version: "1.0.0".into(),
3418 payload_type: "application/vnd.cellos.trust-keyset-v1+json".into(),
3419 payload: URL_SAFE_NO_PAD.encode(payload_bytes),
3420 signatures,
3421 payload_digest: sha256_hex_prefixed(payload_bytes),
3422 produced_at: "2026-05-01T00:00:00Z".into(),
3423 replaces_envelope_digest: None,
3424 required_signer_count,
3425 };
3426 let mut keys = HashMap::new();
3427 for (kid, signer) in signers {
3428 keys.insert((*kid).to_string(), anchor_of(&signer));
3429 }
3430 (envelope, keys)
3431 }
3432
3433 #[test]
3434 fn verifies_with_two_distinct_signers_when_threshold_2() {
3435 let signer_a = signing_key(41);
3436 let signer_b = signing_key(43);
3437 let payload = b"phase3-multisig-payload-2of2";
3438
3439 let (env, keys) = make_multisig_envelope(
3440 payload,
3441 &[("kid-ops-a", &signer_a), ("kid-ops-b", &signer_b)],
3442 Some(2),
3443 );
3444
3445 let returned = verify_signed_trust_keyset_envelope(&env, &keys, SystemTime::now())
3446 .expect("two distinct signers + threshold 2 should verify");
3447 assert_eq!(returned, payload);
3448 }
3449
3450 #[test]
3451 fn rejects_when_threshold_2_but_only_one_verifies() {
3452 let signer_a = signing_key(47);
3453 let signer_b = signing_key(53);
3454 let payload = b"phase3-only-one-verifies";
3455
3456 let (env, _full_keys) = make_multisig_envelope(
3460 payload,
3461 &[("kid-ops-a", &signer_a), ("kid-ops-b", &signer_b)],
3462 Some(2),
3463 );
3464 let mut sparse_keys: HashMap<String, TrustAnchorPublicKey> = HashMap::new();
3465 sparse_keys.insert("kid-ops-a".into(), anchor_of(&signer_a));
3466
3467 let err = verify_signed_trust_keyset_envelope(&env, &sparse_keys, SystemTime::now())
3468 .expect_err("threshold 2 with one verifier present must fail");
3469 let msg = format!("{err}");
3470 assert!(
3471 msg.contains("only 1 distinct signers verified, need 2"),
3472 "expected threshold-shortfall error, got: {msg}"
3473 );
3474 }
3475
3476 #[test]
3477 fn counts_distinct_kids_only_duplicate_kid_does_not_inflate() {
3478 let signer = signing_key(59);
3481 let payload = b"phase3-duplicate-kid-payload";
3482 let sig_a = signer.sign(payload);
3483 let sig_b = signer.sign(payload);
3484
3485 let env = SignedTrustKeysetEnvelope {
3486 schema_version: "1.0.0".into(),
3487 payload_type: "application/vnd.cellos.trust-keyset-v1+json".into(),
3488 payload: URL_SAFE_NO_PAD.encode(payload),
3489 signatures: vec![
3490 TrustKeysetSignature {
3491 signer_kid: "kid-ops-only".into(),
3492 algorithm: "ed25519".into(),
3493 signature: URL_SAFE_NO_PAD.encode(sig_a.to_bytes()),
3494 not_before: None,
3495 not_after: None,
3496 },
3497 TrustKeysetSignature {
3498 signer_kid: "kid-ops-only".into(),
3499 algorithm: "ed25519".into(),
3500 signature: URL_SAFE_NO_PAD.encode(sig_b.to_bytes()),
3501 not_before: None,
3502 not_after: None,
3503 },
3504 ],
3505 payload_digest: sha256_hex_prefixed(payload),
3506 produced_at: "2026-05-01T00:00:00Z".into(),
3507 replaces_envelope_digest: None,
3508 required_signer_count: Some(2),
3509 };
3510 let mut keys = HashMap::new();
3511 keys.insert("kid-ops-only".into(), anchor_of(&signer));
3512
3513 let err = verify_signed_trust_keyset_envelope(&env, &keys, SystemTime::now())
3514 .expect_err("duplicate kid must not satisfy threshold 2");
3515 let msg = format!("{err}");
3516 assert!(
3517 msg.contains("only 1 distinct signers verified, need 2"),
3518 "expected duplicate-kid threshold failure, got: {msg}"
3519 );
3520 }
3521
3522 #[test]
3523 fn default_threshold_1_preserves_phase1_behavior() {
3524 let signer = signing_key(61);
3527 let payload = b"phase1-default-threshold";
3528
3529 for explicit in [None, Some(1)] {
3530 let (env, keys) =
3531 make_multisig_envelope(payload, &[("kid-default-61", &signer)], explicit);
3532 let returned = verify_signed_trust_keyset_envelope(&env, &keys, SystemTime::now())
3533 .expect("default / explicit-1 threshold should verify");
3534 assert_eq!(
3535 returned, payload,
3536 "raw payload bytes returned (explicit={explicit:?})"
3537 );
3538 }
3539 }
3540
3541 #[test]
3542 fn rejects_when_required_zero_treated_as_one() {
3543 let signer = signing_key(67);
3549 let payload = b"phase3-zero-clamp-payload";
3550 let (env, _keys) = make_multisig_envelope(
3551 payload,
3552 &[("kid-active-67", &signer)],
3553 Some(0), );
3555 let empty: HashMap<String, TrustAnchorPublicKey> = HashMap::new();
3558 let err = verify_signed_trust_keyset_envelope(&env, &empty, SystemTime::now())
3559 .expect_err("zero threshold must clamp to 1");
3560 let msg = format!("{err}");
3561 assert!(
3562 msg.contains("no signature verified"),
3563 "expected legacy single-signer error after clamping to 1, got: {msg}"
3564 );
3565 }
3566
3567 fn make_chain_envelope(
3569 payload_bytes: &[u8],
3570 signer: &SigningKey,
3571 signer_kid: &str,
3572 replaces: Option<String>,
3573 ) -> SignedTrustKeysetEnvelope {
3574 let signature = signer.sign(payload_bytes);
3575 SignedTrustKeysetEnvelope {
3576 schema_version: "1.0.0".into(),
3577 payload_type: "application/vnd.cellos.trust-keyset-v1+json".into(),
3578 payload: URL_SAFE_NO_PAD.encode(payload_bytes),
3579 signatures: vec![TrustKeysetSignature {
3580 signer_kid: signer_kid.into(),
3581 algorithm: "ed25519".into(),
3582 signature: URL_SAFE_NO_PAD.encode(signature.to_bytes()),
3583 not_before: None,
3584 not_after: None,
3585 }],
3586 payload_digest: sha256_hex_prefixed(payload_bytes),
3587 produced_at: "2026-05-01T00:00:00Z".into(),
3588 replaces_envelope_digest: replaces,
3589 required_signer_count: None,
3590 }
3591 }
3592
3593 #[test]
3594 fn chain_with_correct_replaces_envelope_digest_succeeds() {
3595 let signer = signing_key(71);
3596 let payload_old = b"phase3-chain-old-payload";
3597 let payload_new = b"phase3-chain-new-payload";
3598
3599 let prev = make_chain_envelope(payload_old, &signer, "kid-active-71", None);
3600 let next = make_chain_envelope(
3601 payload_new,
3602 &signer,
3603 "kid-active-71",
3604 Some(sha256_hex_prefixed(payload_old)),
3605 );
3606
3607 let mut keys = HashMap::new();
3608 keys.insert("kid-active-71".into(), anchor_of(&signer));
3609
3610 let head_payload =
3611 verify_signed_trust_keyset_chain(&[prev, next], &keys, SystemTime::now())
3612 .expect("correctly-chained envelopes should verify");
3613 assert_eq!(head_payload, payload_new);
3614 }
3615
3616 #[test]
3617 fn chain_with_mismatched_replaces_envelope_digest_rejected() {
3618 let signer = signing_key(73);
3619 let payload_old = b"phase3-chain-mismatch-old";
3620 let payload_new = b"phase3-chain-mismatch-new";
3621
3622 let prev = make_chain_envelope(payload_old, &signer, "kid-active-73", None);
3623 let bogus_digest = sha256_hex_prefixed(b"this-is-not-the-prior-payload");
3626 let next = make_chain_envelope(payload_new, &signer, "kid-active-73", Some(bogus_digest));
3627
3628 let mut keys = HashMap::new();
3629 keys.insert("kid-active-73".into(), anchor_of(&signer));
3630
3631 let err = verify_signed_trust_keyset_chain(&[prev, next], &keys, SystemTime::now())
3632 .expect_err("mismatched replacesEnvelopeDigest must fail");
3633 let msg = format!("{err}");
3634 assert!(
3635 msg.contains("replacesEnvelopeDigest mismatch"),
3636 "expected chain link mismatch error, got: {msg}"
3637 );
3638 assert!(
3639 msg.contains("index 1"),
3640 "expected index 1 (the bad link) in error, got: {msg}"
3641 );
3642 }
3643
3644 #[test]
3645 fn chain_first_envelope_without_replaces_accepted_as_genesis() {
3646 let signer = signing_key(79);
3650 let payload = b"phase3-chain-genesis-only";
3651
3652 let genesis = make_chain_envelope(payload, &signer, "kid-active-79", None);
3653 let mut keys = HashMap::new();
3654 keys.insert("kid-active-79".into(), anchor_of(&signer));
3655
3656 let head_payload = verify_signed_trust_keyset_chain(&[genesis], &keys, SystemTime::now())
3657 .expect("genesis-only chain should verify");
3658 assert_eq!(head_payload, payload);
3659 }
3660
3661 #[test]
3662 fn chain_returns_head_payload_bytes() {
3663 let signer = signing_key(83);
3666 let p0 = b"phase3-head-bytes-genesis";
3667 let p1 = b"phase3-head-bytes-middle";
3668 let p2 = b"phase3-head-bytes-HEAD-distinct";
3669
3670 let env0 = make_chain_envelope(p0, &signer, "kid-active-83", None);
3671 let env1 = make_chain_envelope(p1, &signer, "kid-active-83", Some(sha256_hex_prefixed(p0)));
3672 let env2 = make_chain_envelope(p2, &signer, "kid-active-83", Some(sha256_hex_prefixed(p1)));
3673
3674 let mut keys = HashMap::new();
3675 keys.insert("kid-active-83".into(), anchor_of(&signer));
3676
3677 let head_payload =
3678 verify_signed_trust_keyset_chain(&[env0, env1, env2], &keys, SystemTime::now())
3679 .expect("3-envelope chain should verify");
3680 assert_eq!(
3681 head_payload, p2,
3682 "verifier must return HEAD payload, not earlier links"
3683 );
3684 }
3685
3686 #[test]
3687 fn chain_empty_rejected() {
3688 let keys: HashMap<String, TrustAnchorPublicKey> = HashMap::new();
3689 let err = verify_signed_trust_keyset_chain(&[], &keys, SystemTime::now())
3690 .expect_err("empty chain must be rejected");
3691 let msg = format!("{err}");
3692 assert!(
3693 msg.contains("empty chain"),
3694 "expected empty-chain error, got: {msg}"
3695 );
3696 }
3697
3698 #[test]
3699 fn chain_propagates_threshold_failure_per_envelope() {
3700 let signer_a = signing_key(89);
3706 let signer_b = signing_key(97);
3707 let p0 = b"phase3-threshold-prop-genesis";
3708 let p1 = b"phase3-threshold-prop-head";
3709
3710 let env0 = make_chain_envelope(p0, &signer_a, "kid-ops-a", None);
3711 let mut env1 =
3714 make_chain_envelope(p1, &signer_a, "kid-ops-a", Some(sha256_hex_prefixed(p0)));
3715 env1.required_signer_count = Some(2);
3716
3717 let mut keys = HashMap::new();
3718 keys.insert("kid-ops-a".into(), anchor_of(&signer_a));
3719 keys.insert("kid-ops-b".into(), anchor_of(&signer_b));
3720
3721 let err = verify_signed_trust_keyset_chain(&[env0, env1], &keys, SystemTime::now())
3722 .expect_err("env1 threshold-2 with one signer must fail");
3723 let msg = format!("{err}");
3724 assert!(
3725 msg.contains("envelope at index 1 failed verification"),
3726 "expected indexed envelope failure, got: {msg}"
3727 );
3728 assert!(
3729 msg.contains("only 1 distinct signers verified, need 2"),
3730 "expected per-envelope threshold message in chain error, got: {msg}"
3731 );
3732 }
3733}