Skip to main content

cellos_core/
spec_validation.rs

1//! Pure validation for parsed [`ExecutionCellDocument`].
2//!
3//! Admission-side checkers used by `cellos-server` and `cellos-supervisor`
4//! before any I/O. No tokio, no syscalls — values in, `Result` out.
5//!
6//! See [`crate::types::ExecutionCellDocument`] for the input shape and
7//! [`crate::CellosError`] for the failure taxonomy.
8//!
9//! [`ExecutionCellDocument`]: crate::types::ExecutionCellDocument
10
11use std::collections::HashSet;
12
13use crate::error::CellosError;
14use crate::ExecutionCellDocument;
15use url::Url;
16
17/// Returns true when `value` matches `sha256:<64 lowercase hex digits>`.
18fn 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
87/// Returns true when `value` is a single DNS label per RFC 1035 (LDH; 1-63 chars;
88/// must start and end with an ASCII alphanumeric; underscores rejected).
89fn 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
110/// Returns true when `value` is a sane FQDN — optionally with a single leading
111/// `*.` wildcard. Total length capped at 253 (RFC 1035). IPv4/IPv6 literals are
112/// rejected so callers can't sneak an IP through a hostname slot.
113///
114/// Used by [`validate_execution_cell_document`] for `dnsAuthority.hostnameAllowlist`,
115/// `cdnAuthority.providers[].hostnamePattern`, and any future hostname-shaped
116/// fields under T13.
117pub(crate) fn is_fqdn_or_wildcard(value: &str) -> bool {
118    if value.is_empty() || value.len() > 253 {
119        return false;
120    }
121    // Reject IPv4-like dotted-quads (e.g. "1.2.3.4") — they parse as 4 labels of
122    // pure digits, which we don't want under "hostname". An IPv6 literal contains
123    // ':' which is rejected by is_dns_label, so no separate guard is needed.
124    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        // Require at least one dot — "example" alone is not a usable FQDN here.
135        return false;
136    }
137
138    // Optional single leading wildcard label.
139    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
170/// Validate a policy pack's declared `spec.version` against the runtime's
171/// compiled-in supported floor. P4-04.
172///
173/// Thin façade over [`crate::check_policy_pack_version_compatibility`]
174/// exposed from the `spec_validation` namespace. `allow_downgrade` lets the
175/// caller (the supervisor, via the `CELLOS_POLICY_ALLOW_DOWNGRADE` operator
176/// override) opt out of the strict floor; cellos-core itself does not read
177/// process env vars (D11).
178pub 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
185/// Reject a `tenant_id` that contains any NATS subject-token reserved char.
186///
187/// NATS subjects are dot-delimited tokens; the wildcards `*` and `>` and any
188/// whitespace would either fan out the subject across tenants or produce an
189/// unroutable wire string. This guard runs at admission so a malformed
190/// `correlation.tenantId` cannot bleed into another tenant's subject when
191/// substituted into a `{tenantId}` template (see
192/// `cellos-supervisor::spec_input::resolve_event_subject`).
193///
194/// Pure: no env access, no I/O. Empty `tenant_id` is rejected — callers that
195/// want "absent tenant" semantics should pass `Option::None` in the field
196/// rather than the empty string.
197pub 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
214/// Reject a cloud `WorkloadIdentityKind` that carries a non-empty OIDC-only
215/// field it does not use (ADR-0026 §Decision 3).
216///
217/// `WorkloadIdentity` is a flat struct whose `provider`/`audience`/`subject`
218/// type OIDC credential acquisition, while the cloud variants carry their scope
219/// in the enum payload (`role_arn`, `resource_id`). For a cloud kind those OIDC
220/// fields are dead state, so a spec that populates them is a split-brain
221/// declaration: it asks for an assume-role identity while also describing an
222/// OIDC subject it will never use. We reject it rather than silently ignore the
223/// stray fields, so the spec means exactly one thing.
224///
225/// Pure: no env access, no I/O. `FederatedOidc` is unaffected — its sibling
226/// OIDC fields remain meaningful and are not constrained here.
227fn 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    // OIDC-only fields that carry no meaning for a cloud kind. `provider` and
239    // `audience` are mandatory strings on the struct, so "absent" means empty;
240    // `subject` is optional, so "absent" means `None` or empty.
241    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
259/// Reject specs that violate MVP invariants (stricter than JSON Schema alone).
260pub 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        // FC-65: argv invariants enforced at admission, before any VM is spawned.
357        // Non-UTF-8 argv impossible: serde_json deserializes argv as Vec<String>,
358        // which is UTF-8 by construction (rejected at the JSON deserialize boundary).
359        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        // Reject embedded NUL bytes: execve takes NUL-terminated C strings, so an
365        // embedded \0 would silently truncate the argument inside the guest.
366        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            // SEC: the Linux supervisor backend (`generate_nft_ruleset`)
408            // string-interpolates `host` and `protocol` directly into nft
409            // accept lines. A host or protocol carrying nft metacharacters
410            // (whitespace, `;`, `{`, `}`, quotes, newlines, comment `#`,
411            // backslash) could inject or corrupt the ruleset; a corrupt
412            // ruleset makes nft reject the *entire* table, so the cell runs
413            // with NO cellos egress filtering (fail-open). Validate at
414            // admission so a malformed rule is rejected before any run
415            // starts. This closes the divergence with the Firecracker
416            // backend, which already parses `host`
417            // (cellos-host-firecracker/src/lib.rs).
418            //
419            // We do NOT require a literal IP here: hostname-form egress hosts
420            // are supported elsewhere in the spec model and are handled
421            // (skipped fail-closed) by the generator's own re-validation.
422            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                // Reject protocol strings carrying nft metacharacters (the
432                // injection vector). Unknown-but-clean protocol names (e.g.
433                // "tls") are tolerated at admission for backward-compat and
434                // are normalized / skipped fail-closed by the backend nft
435                // generators rather than interpolated verbatim.
436                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 a derivation token is present, validate structural constraints (subset only).
453    // Signature verification is performed by the supervisor with role keys loaded
454    // from `CELLOS_AUTHORITY_KEYS_PATH` — see `verify_authority_derivation`.
455    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
530/// F4a — admission validation for [`crate::TelemetrySpec`].
531///
532/// Checks (in order):
533/// 1. `events` is non-empty.
534/// 2. `channel == VsockCbor` (the only supported channel today — guards
535///    against forward-compat envelopes accidentally bypassing host wiring).
536/// 3. `agentVersion` matches a permissive semver shape
537///    (`MAJOR.MINOR.PATCH` with optional `-prerelease` suffix).
538/// 4. For every entry in `events` that begins with `net.` (case-insensitive),
539///    the spec's `authority.egressRules` MUST be non-empty. A `net.*` event
540///    declared without any declared egress is an unbacked telemetry/authority
541///    claim — the SIEM would receive network observations from a cell that
542///    declared no network authority. Returns
543///    `CellosError::InvalidSpec("telemetry_without_egress: ...")`.
544///
545/// Pure structural; no env access. Mirrored in
546/// `contracts/schemas/execution-cell-v1.schema.json` under the `Telemetry`
547/// definition.
548fn 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
584/// Permissive semver-shape check for `spec.telemetry.agentVersion`.
585///
586/// Matches `MAJOR.MINOR.PATCH` (each numeric, no leading zeros except "0")
587/// with an optional `-<prerelease>` suffix. Build metadata (`+...`) is not
588/// accepted for now — keep the shape narrow until operators ask for it.
589fn 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
614/// Structural subset check for an `AuthorityDerivationToken` — does NOT verify
615/// signatures. Used by `validate_execution_cell_document`, which has no access
616/// to role keys. The supervisor performs full signature verification separately
617/// via `verify_authority_derivation` once keys are loaded from
618/// `CELLOS_AUTHORITY_KEYS_PATH`.
619fn 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
635/// Validate `authority.dnsAuthority` (T13 / SEC-20).
636///
637/// Pure structural / domain checks — does NOT contact any resolver and does NOT
638/// require trust-keyset state. The supervisor performs full trust-keyset binding
639/// at runtime in SEC-21/SEC-22.
640fn 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
687/// Validate `authority.cdnAuthority` (T13 / SEC-20).
688fn 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
712/// FC-17 / FC-66 — admission cap on the size of `spec.run.argv` once the
713/// Firecracker host base64-encodes it onto the kernel boot cmdline.
714///
715/// The Linux kernel cmdline has a 4 KiB hard limit. The Firecracker host
716/// (`crates/cellos-host-firecracker/src/lib.rs::build_boot_args`) writes
717/// `cellos.argv=<base64(json_array)>` plus a small fixed prefix (`console=`,
718/// `root=`, `cellos.cell_id=`, `cellos.vsock_port=` …). We cap the encoded
719/// argv payload at 3 KiB so the rest of the cmdline keeps ~1 KiB of headroom
720/// against future cmdline additions.
721///
722/// `MAX_ARGV_ENCODED_BYTES` is exposed in the typed
723/// [`CellosError::ArgvTooLarge::limit_bytes`] field so operators do not have
724/// to dig into core to see the budget.
725const MAX_ARGV_ENCODED_BYTES: usize = 3072;
726
727/// FC-17 — admission check that `spec.run.argv` will fit inside the Linux
728/// kernel boot-cmdline 4 KiB hard limit once Firecracker assembles it.
729///
730/// FC-66 — surfaces the rejection as the typed
731/// [`CellosError::ArgvTooLarge`] variant (carrying the actual encoded byte
732/// count and the static limit) rather than as a string-payload `InvalidSpec`,
733/// so callers can pattern-match on it without parsing a message.
734///
735/// We reproduce the host's exact encoding (`base64(serde_json::to_string(argv))`)
736/// and compare its length against [`MAX_ARGV_ENCODED_BYTES`] (3072 bytes).
737pub(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    // serde_json::to_string on Vec<String> cannot fail (no non-string keys, no
744    // floats), but the API is fallible — fall back to a conservative upper
745    // bound (raw concatenated bytes + JSON framing) on the unreachable Err
746    // path so admission never panics on user input.
747    let encoded_len = match serde_json::to_string(argv) {
748        Ok(json) => STANDARD.encode(json.as_bytes()).len(),
749        Err(_) => {
750            // ceil(n/3)*4 of a generous JSON framing estimate:
751            // 2 brackets + per-arg 3 bytes (quotes + comma) + raw arg bytes,
752            // each byte possibly doubled by JSON string-escaping.
753            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
770/// Build the canonical JSON payload that the grantor signs and the supervisor verifies.
771///
772/// Field order is significant — both signer and verifier MUST agree on the encoding.
773/// The `serde_json::json!` macro preserves insertion order for object literals,
774/// so the bytes produced here are deterministic given the same inputs.
775///
776/// Layout:
777/// ```json
778/// { "roleRoot": "<RoleId>", "leafCapability": <AuthorityCapability>, "parentRunId": <string|null> }
779/// ```
780pub 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
795/// Verify an `AuthorityDerivationToken` against the declared spec authority.
796///
797/// Checks (in order):
798/// 1. `token.leaf_capability` is a subset of `spec.authority` (egress + secret dims).
799/// 2. `role_keys[token.role_root]` exists.
800/// 3. The base64-encoded verifying key parses as a valid 32-byte ED25519 key.
801/// 4. `token.grantor_signature.bytes` parses as a valid 64-byte ED25519 signature.
802/// 5. The signature verifies (`verify_strict`) over the canonical signing payload.
803///
804/// `role_keys`: map from RoleId string → base64-encoded ED25519 verifying key
805/// (raw 32-byte form, base64-STANDARD encoded).
806///
807/// Returns `Ok(())` on success, `Err(CellosError::InvalidSpec(...))` on any failure.
808pub 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    // Step 1: structural subset check.
817    verify_authority_derivation_structural(spec, token)?;
818
819    // Step 2: look up the verifying key for this role.
820    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    // Step 3: decode + parse the verifying key.
826    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    // Step 4: decode + parse the signature.
840    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    // Step 5: build canonical payload + verify through the crypto provider port
855    // (ADR-0027, S04) — strict Ed25519, including the canonical-point check the
856    // dalek adapter performs on the public key.
857    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
872/// Enforce the derivation-token scope policy after signature verification (L5-16 / I6 / O6).
873///
874/// `parent_run_id` is grantor-asserted in the signed payload. A token signed with
875/// `parentRunId: null` verifies cryptographically against any compatible run —
876/// making it a replayable universal delegation token. This function closes that
877/// replay window by rejecting universal tokens **by default** (1.0 strict-by-default
878/// posture) and only accepting them when an operator EXPLICITLY opts out into
879/// permissive mode.
880///
881/// Behaviour:
882/// - When `allow_universal` is `false` — the **default** posture, including
883///   when `CELLOS_REQUIRE_SCOPED_DERIVATION_TOKENS` is unset, empty, or set to
884///   `1`/`true`/`yes`/`on` — a token with `parent_run_id == None` is rejected
885///   as `CellosError::InvalidSpec`.
886/// - When `allow_universal` is `true` — only when the operator EXPLICITLY
887///   opted out by setting `CELLOS_REQUIRE_SCOPED_DERIVATION_TOKENS` to one of
888///   `0`/`false`/`no`/`off` — a token with `parent_run_id == None` is
889///   accepted. The supervisor additionally emits a structured CloudEvent
890///   (`dev.cellos.events.cell.identity.v1.universal_token_accepted_in_permissive_mode`)
891///   recording the exposure so the audit trail captures it. A `WARN` is also
892///   logged on the `cellos.supervisor.authority` target.
893///
894/// This is additive: tokens whose `parent_run_id` is `Some(_)` always pass.
895pub 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
917/// Verify a SEC-25 signed trust-keyset envelope and return its raw payload bytes.
918///
919/// `verifying_keys` is a map from `signerKid` to a [`crate::TrustAnchorPublicKey`].
920/// The caller is responsible for sourcing this map (e.g. from
921/// `CELLOS_TRUST_VERIFY_KEYS_PATH`).
922///
923/// Verification steps (in order):
924/// 1. Decode `payload` as base64url. Compute `sha256:<hex>` over the raw bytes
925///    and compare to `payloadDigest`. Mismatch → `CellosError::InvalidSpec`.
926/// 2. For each entry in `signatures`:
927///    - Look up `signerKid` in `verifying_keys`. Missing kid → that signature is
928///      rejected (do not fail the whole envelope yet).
929///    - If `notBefore`/`notAfter` are set, parse them as RFC3339 and check
930///      `now ∈ [notBefore, notAfter]`. Out-of-window → that signature is rejected.
931///    - Decode `signature` as base64url; require exactly 64 bytes; call
932///      `verify_strict` over the raw payload bytes.
933///    - On success, record the signer's `signerKid` in a deduplicating set.
934/// 3. **Phase 3 N-of-M threshold:** compare the count of DISTINCT verified
935///    signer kids against `envelope.required_signer_count.unwrap_or(1)`
936///    (clamped to a minimum of 1). The Phase 1 default of 1 preserves
937///    backward-compat with the original "at least one signature must verify"
938///    policy — single-signer envelopes accepted, missing-`required_signer_count`
939///    envelopes accepted. Operator deployments raise the threshold (e.g. 2)
940///    so a single compromised signer key cannot alone authorize a keyset.
941/// 4. If `verified_distinct_signers >= required` → return the raw payload
942///    bytes. Otherwise → `CellosError::InvalidSpec` with a message that
943///    distinguishes the legacy single-signer failure (`"no signature
944///    verified"`) from a threshold shortfall (`"only N distinct signers
945///    verified, need M"`).
946///
947/// **Distinct-kid counting.** Two signature entries with the same `signerKid`
948/// — even with different signature bytes — count as one verifier. The
949/// schema's `signatures` list permits duplicates (a re-sign by the same
950/// operator is structurally valid) but the threshold policy treats them as
951/// one operator's vote. This is the intended N-of-M semantic.
952///
953/// This function does NOT deserialize the inner trust-keyset payload. The
954/// schema is the contract of record; callers may parse the bytes into any
955/// shape (`serde_json::Value` or a future `TrustKeysetV1` Rust mirror).
956pub 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    // scope: lock payloadType + algorithms to declared SEC-25 shape. The
966    // accepted-media-type set is the closed two-member sibling set
967    // {trust-keyset-v1, authority-ceiling-v1} (ADR-0024 Decision 1): both share
968    // this payload-agnostic envelope, and the caller re-asserts the exact
969    // expected media type after verification. The cryptographic core
970    // (digest, Ed25519, time window, N-of-M threshold) is unchanged; only the
971    // accepted media-type set widens by one named constant. Any other
972    // payloadType is still rejected.
973    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    // Step 1: decode payload + verify digest.
988    // Accept either base64url-with-padding or base64url-no-padding by stripping '=' first.
989    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        // Schema enforces minItems: 1, but defend against hand-built structs.
1005        return Err(crate::error::CellosError::InvalidSpec(
1006            "signed trust keyset envelope has no signatures".into(),
1007        ));
1008    }
1009
1010    // scope: N-of-M threshold. Default + zero clamp to 1 (single-signature
1011    // semantics). Schema's `minimum: 1` blocks zero at the contract layer;
1012    // the Rust clamp is a defense-in-depth against hand-built envelopes.
1013    let required = envelope.required_signer_count.unwrap_or(1).max(1);
1014
1015    // Step 2: walk signatures; collect distinct signerKids whose signature
1016    // verified. We collect ALL of them (no early-break) so the threshold
1017    // check has the true distinct-verifier count.
1018    let mut verified_signers: HashSet<&str> = HashSet::new();
1019    for sig_entry in &envelope.signatures {
1020        if sig_entry.algorithm != "ed25519" {
1021            // Unknown algorithm: skip (a future-version envelope might have
1022            // multi-algo signatures alongside our supported one).
1023            continue;
1024        }
1025        // If this kid already verified via an earlier signature, skip the
1026        // expensive crypto check — a duplicate kid only ever counts once.
1027        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            // Unknown kid for the verifier's keyring → skip this signature.
1032            continue;
1033        };
1034
1035        // notBefore / notAfter window check (each bound is optional).
1036        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        // Decode signature: base64url (with or without padding) → exactly 64 bytes.
1045        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        // Strict Ed25519 verify through the crypto provider port (ADR-0027).
1054        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    // Preserve the legacy "no signature verified" error message when the
1067    // threshold is the Phase 1 default (1) and zero signatures verified —
1068    // existing operator triage runbooks and the SEC-25 Phase 2 supervisor
1069    // tests grep for that exact substring. The new threshold-shortfall
1070    // message is reserved for Phase 3 N-of-M (>= 2) failures.
1071    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
1084/// Verify a chain of signed trust-keyset envelopes for replay-safety (SEC-25 Phase 3).
1085///
1086/// The chain is ordered **oldest-first**; the HEAD (current) envelope is the
1087/// LAST entry. Each non-genesis envelope MUST carry a
1088/// `replacesEnvelopeDigest` equal to `sha256:<hex>` of the immediately prior
1089/// envelope's raw decoded payload bytes. The first envelope's
1090/// `replacesEnvelopeDigest` MAY be absent (genesis) or present (chain root
1091/// reference) — the chain verifier does not check the genesis link.
1092///
1093/// Per-envelope verification (signature, digest, threshold, validity window)
1094/// is delegated to [`verify_signed_trust_keyset_envelope`]; chain integrity
1095/// is layered on top:
1096///
1097/// 1. Empty chain → `CellosError::InvalidSpec`.
1098/// 2. For each envelope in order, call `verify_signed_trust_keyset_envelope`.
1099///    Per-envelope verification failures (including N-of-M threshold
1100///    shortfall) propagate immediately.
1101/// 3. For each adjacent pair `(prev, next)`, require
1102///    `next.replaces_envelope_digest == Some("sha256:<hex>(prev_payload_bytes)")`.
1103///    Mismatch → `CellosError::InvalidSpec` naming the chain index.
1104///
1105/// Returns the verified raw payload bytes of the HEAD envelope on success.
1106///
1107/// **Replay defense.** Verifiers that cache keyset state SHOULD reject any
1108/// new envelope whose `replacesEnvelopeDigest` does not match the cached
1109/// HEAD's payloadDigest — that is either a replay of an old envelope or a
1110/// chain fork. This function does not maintain that cache; it only verifies
1111/// the integrity of a chain handed to it.
1112pub 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            // Non-genesis: replacesEnvelopeDigest MUST equal sha256(prev.payload_bytes).
1134            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        // The genesis envelope (index 0) is allowed to set or omit
1150        // replacesEnvelopeDigest — we do not check the link before it.
1151
1152        prev_payload_bytes = Some(payload_bytes);
1153    }
1154
1155    // Unwrap is safe: chain non-empty above + prev_payload_bytes is set on
1156    // every loop iteration.
1157    Ok(prev_payload_bytes.expect("chain non-empty checked above"))
1158}
1159
1160/// `sha256:<hex>` over `bytes`, matching the prefix convention used across
1161/// CellOS trust-plane schemas (`payloadDigest`, `policyDigest`, etc.).
1162fn 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
1174/// Return `Ok(true)` iff `now` is within `[not_before, not_after]` (inclusive).
1175/// Either bound may be `None` (meaning unbounded on that side). RFC3339 parse
1176/// failure → `Err(CellosError::InvalidSpec)`.
1177fn 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        // SEC: an egress host carrying nft metacharacters could inject or
1248        // corrupt the generated nft ruleset (and a corrupt ruleset makes the
1249        // cell run fail-open). Admission must reject it.
1250        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        // Hostname-form hosts remain valid at admission (the backend nft
1323        // generator handles resolution/skip) — only metacharacters are
1324        // rejected. Guards against the validation being over-strict.
1325        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    /// FC-17 — argv totalling ~1 KiB of payload bytes encodes well under the
1358    /// 3 KiB cap and must pass admission.
1359    #[test]
1360    fn admits_argv_under_kernel_cmdline_limit() {
1361        // 1 KiB of payload across two args; base64(json) ~= 1.4 KiB, < 3072.
1362        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    /// FC-17 / FC-66 — argv whose base64(json) encoding exceeds 3 KiB must be
1391    /// rejected at admission with the typed [`CellosError::ArgvTooLarge`]
1392    /// variant (not a string-payload `InvalidSpec`) so callers can
1393    /// pattern-match without parsing a message.
1394    #[test]
1395    fn rejects_argv_exceeding_kernel_cmdline_limit() {
1396        // 5 KiB of raw payload → JSON ~5 KiB → base64 ~6.7 KiB; well over 3072.
1397        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    /// Build a minimal document around a single `WorkloadIdentity` whose
1477    /// `secret_ref` is allowlisted in `authority.secretRefs`. Used by the
1478    /// per-kind identity-field validation tests (ADR-0026).
1479    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        // secret_ref deliberately omitted from authority.secretRefs.
1531        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        // The per-kind guard must not constrain FederatedOidc's sibling fields.
1588        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    // ── Property-based tests (SEC-11) ─────────────────────────────────────
1797    // These cover the identifier validation rules systematically so that
1798    // hand-crafted inputs can't be the only signal of correctness.
1799
1800    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        /// Any identifier containing a forward slash is rejected.
1880        #[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        /// Any identifier containing a backslash is rejected.
1890        #[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        /// Any identifier containing `..` is rejected regardless of surrounding chars.
1900        #[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        /// Any identifier starting with a non-alphanumeric ASCII char is rejected.
1910        #[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        /// Valid identifiers (alphanumeric start, safe body, ≤128 chars, no ..) are accepted.
1920        #[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            // Exclude generated strings that happen to contain ".."
1927            prop_assume!(!s.contains(".."));
1928            prop_assert!(is_portable_identifier(&s), "should accept {s:?}");
1929        }
1930
1931        /// Validate that any spec.id failing is_portable_identifier causes validate() to error.
1932        #[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            // inject a forward slash so it definitely fails the portable identifier check
1938            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        /// Validate that spec.run.argv with any empty token causes validate() to error.
1964        #[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            // build argv with an empty string inserted in the middle
1970            let mut argv: Vec<String> = prefix_args;
1971            argv.push(String::new()); // the bad token
1972            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    /// Boundary case for review-2026-04-08 gap #2: `timeout_ms == ttl * 1000`
2083    /// must be accepted (the validator uses `>`, not `>=`) so an operator
2084    /// can declare an honest "use the full TTL as the soft cap" spec.
2085    #[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    /// Just-over-the-boundary case for gap #2: `timeout_ms == ttl*1000 + 1`
2118    /// must be rejected. Pins the precision of the comparison so a
2119    /// future refactor that switches to seconds (lossy) cannot pass.
2120    #[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    // ── spec.environment validation ───────────────────────────────────────────
2195
2196    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    /// Sign `token` with `signing_key`, returning a token whose `grantor_signature.bytes`
2369    /// is the base64-STANDARD encoding of the resulting ED25519 signature over the
2370    /// canonical signing payload.
2371    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        // C08: sign through the active provider (no dalek import in tests).
2380        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    /// A deterministic ED25519 signing-key SEED from a fixed byte — avoids
2388    /// pulling `rand_core` / `OsRng` into dev-deps. The seed is the provider's
2389    /// key representation; sign via [`sign_token`] / the provider directly.
2390    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        // Structural pass — signature still required for the new full verify.
2406        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    // ── ED25519 signature verification (L5-14) ─────────────────────────────
2528
2529    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        // Tamper: flip the last byte of the base64-decoded signature, then re-encode.
2571        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        // Map only contains a different role — token's role-test is unknown.
2600        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        // Build a doc whose spec has NO authorityDerivation token — it must
2617        // pass validation unchanged regardless of role_keys availability.
2618        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    // ── parentRunId scope policy (L5-16) ──────────────────────────────────
2650
2651    #[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        // Permissive mode: WARN-and-pass.
2656        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    // ── Fixture generator (documentation, not a CI gate) ──────────────────
2685    //
2686    // Regenerates the signature embedded in
2687    // `contracts/examples/execution-cell-ci-runner-signed.valid.json` and the
2688    // verifying key in `contracts/examples/authority-keys.example.json`.
2689    //
2690    // Run with:
2691    //   cargo test -p cellos-core --lib gen_signed_ci_runner_fixture \
2692    //     -- --ignored --nocapture
2693    //
2694    // Seed: [0x42; 32]  ·  Role: "role-ci-runner"  ·  parentRunId: "run-demo-ref-001"
2695    //
2696    // The leaf capability constructed here MUST stay byte-identical with the
2697    // `leafCapability` JSON object in the fixture file (same fields, same
2698    // values, same serde-camelCase rendering) — otherwise the canonical
2699    // signing payload diverges and the signature stops verifying.
2700    #[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    // ── T13 dnsAuthority / cdnAuthority validation (SEC-20) ───────────────
2758
2759    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            // SEC-21 Phase 3e — keep the test fixture's `dnsAuthority`
2797            // backward-compat shape (rebinding policy unset).
2798            rebinding_policy: None,
2799            block_direct_workload_dns: true,
2800            // SEC-22 Phase 3d — UDP-side kernel block opt-ins default off
2801            // for the spec-validation fixture so existing tests keep their
2802            // pre-3d shape; the nft generator tests below exercise the
2803            // true case.
2804            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        // Wildcard must be the leading label only — "api.*.example.com" is not allowed.
2850        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")); // no dot
3047        assert!(!super::is_fqdn_or_wildcard("10.0.0.1")); // IPv4 literal
3048        assert!(!super::is_fqdn_or_wildcard("2001:db8::1")); // IPv6 literal
3049        assert!(!super::is_fqdn_or_wildcard("api.*.example.com")); // mid wildcard
3050        assert!(!super::is_fqdn_or_wildcard("-bad.example.com")); // label starts with '-'
3051        assert!(!super::is_fqdn_or_wildcard("under_score.example.com")); // underscore
3052    }
3053
3054    // ── F4a telemetry admission tests ──────────────────────────────────────
3055
3056    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// C08: these envelope tests sign with raw ed25519-dalek test keys; gate them
3149// behind the `dalek` feature so the pure-fips test build (no dalek) still
3150// compiles. They run under the default build (dalek on) — full coverage — and
3151// the dalek<->fips cross-provider tests prove the two providers agree.
3152#[cfg(all(test, feature = "dalek"))]
3153mod sec25_envelope_tests {
3154    //! SEC-25 signed trust-keyset envelope verifier tests.
3155    //!
3156    //! All synthetic: deterministic Ed25519 keys, no network, no real keyset
3157    //! material. The inner payload is a minimal byte string — these tests
3158    //! exercise the envelope/digest/signature path, not the inner
3159    //! trust-keyset-v1 schema.
3160    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    /// Build the new keyring value type from a test signing key's public half.
3176    fn anchor_of(signer: &SigningKey) -> TrustAnchorPublicKey {
3177        TrustAnchorPublicKey::from_bytes_unchecked(signer.verifying_key().to_bytes())
3178    }
3179
3180    /// Build a well-formed envelope with one valid signature. Returns the
3181    /// envelope and the signer's verifying key map (a single kid).
3182    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        // ADR-0024 Decision 1 + S35: the media-type guard is a closed three-member
3227        // set {trust-keyset-v1, authority-ceiling-v1, trust-revocation-v1}. The
3228        // signature is over the raw payload bytes, not the payloadType, so
3229        // overriding the media type alone is the precise exercise of the guard.
3230        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        // Sibling A: trust-keyset-v1 (the make_envelope default) accepted.
3235        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        // Sibling B: authority-ceiling-v1 accepted by the same guard.
3241        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        // Sibling C: trust-revocation-v1 accepted by the same guard (S35).
3247        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        // Any fourth media type is still rejected (fail-closed).
3253        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        // Flip a single hex char in the digest — keep prefix valid.
3269        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        // notAfter set well in the past → window check fails.
3302        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        // Tamper: flip one byte in the decoded payload, re-encode, and
3324        // recompute the digest so we exercise the signature check (not the
3325        // digest check) failing.
3326        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        // First signature: bogus kid (verifier's keyring won't know it).
3346        let bad_sig = signer_other.sign(payload);
3347        // Second signature: good kid + good signature.
3348        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        // Only the good signer is in the verifier's keyring.
3378        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    // ── Phase 3: multi-signer threshold + envelope chain ────────────────────
3390
3391    /// Build an envelope signed by N distinct signers, with an explicit
3392    /// `required_signer_count`. Returns the envelope and a keyring containing
3393    /// every signer.
3394    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        // Build a 2-signer envelope but supply ONLY one signer's public key
3457        // in the verifier's keyring. The other signature has no key to
3458        // verify against, so the threshold of 2 is unreachable.
3459        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        // Two signature entries from the same kid count as 1 verifier — they
3479        // do not satisfy a threshold of 2 even when both individually verify.
3480        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        // An envelope with required_signer_count = None (and one with Some(1))
3525        // both behave identically to Phase 1: at-least-one-signature-verifies.
3526        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        // The schema's `minimum: 1` blocks required_signer_count: 0, but a
3544        // hand-built envelope could still set Some(0). The Rust verifier
3545        // clamps to 1 — so an envelope with zero verifying signatures still
3546        // surfaces the legacy "no signature verified" error rather than
3547        // passing on a zero threshold.
3548        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), // hand-built envelope tries to bypass threshold
3554        );
3555        // Empty keyring → zero signatures verify; clamped threshold = 1
3556        // surfaces the legacy single-signer error.
3557        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    /// Build a chain envelope with an explicit `replacesEnvelopeDigest` value.
3568    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        // Tamper: point at a DIFFERENT envelope's digest. This is the replay
3624        // / fork attempt the chain verifier defends against.
3625        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        // Genesis envelope (index 0) MAY omit replacesEnvelopeDigest. A
3647        // single-element chain is the trivial case — only the genesis is
3648        // present, so no link checks fire.
3649        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        // A 3-envelope chain returns ONLY the HEAD's raw payload bytes — not
3664        // any intermediate's payload. This is the core contract.
3665        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        // A chain of two envelopes where the SECOND envelope sets
3701        // requiredSignerCount=2 but only carries one valid signature should
3702        // fail at the per-envelope threshold step — the chain verifier MUST
3703        // surface that as an indexed envelope failure (not as a digest-link
3704        // failure).
3705        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        // env1 declares required=2 but only signer_a signs — only one
3712        // distinct signer can verify.
3713        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}