Skip to main content

cellos_core/
spec_validation.rs

1//! Pure validation for parsed [`ExecutionCellDocument`](crate::ExecutionCellDocument).
2
3use std::collections::HashSet;
4
5use crate::error::CellosError;
6use crate::ExecutionCellDocument;
7use url::Url;
8
9/// Returns true when `value` matches `sha256:<64 lowercase hex digits>`.
10fn is_sha256_digest(value: &str) -> bool {
11    let Some(hex) = value.strip_prefix("sha256:") else {
12        return false;
13    };
14    hex.len() == 64
15        && hex
16            .chars()
17            .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
18}
19
20pub(crate) fn is_portable_identifier(value: &str) -> bool {
21    let mut chars = value.chars();
22    let Some(first) = chars.next() else {
23        return false;
24    };
25    if !first.is_ascii_alphanumeric() {
26        return false;
27    }
28    if value.len() > 128 || value.contains("..") {
29        return false;
30    }
31    chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
32}
33
34fn ensure_portable_identifier(value: &str, field: &str) -> Result<(), CellosError> {
35    if is_portable_identifier(value) {
36        Ok(())
37    } else {
38        Err(CellosError::InvalidSpec(format!(
39            "{field} must match [A-Za-z0-9][A-Za-z0-9._-]{{0,127}} and must not contain '..'"
40        )))
41    }
42}
43
44fn is_kubernetes_namespace(value: &str) -> bool {
45    if value.is_empty() || value.len() > 63 || value.contains("..") {
46        return false;
47    }
48
49    let mut chars = value.chars();
50    let Some(first) = chars.next() else {
51        return false;
52    };
53    if !first.is_ascii_lowercase() && !first.is_ascii_digit() {
54        return false;
55    }
56
57    let Some(last) = value.chars().last() else {
58        return false;
59    };
60    if !last.is_ascii_lowercase() && !last.is_ascii_digit() {
61        return false;
62    }
63
64    value
65        .chars()
66        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
67}
68
69fn ensure_kubernetes_namespace(value: &str, field: &str) -> Result<(), CellosError> {
70    if is_kubernetes_namespace(value) {
71        Ok(())
72    } else {
73        Err(CellosError::InvalidSpec(format!(
74            "{field} must match Kubernetes DNS label rules: lowercase alphanumeric plus '-', <=63 chars"
75        )))
76    }
77}
78
79/// Returns true when `value` is a single DNS label per RFC 1035 (LDH; 1-63 chars;
80/// must start and end with an ASCII alphanumeric; underscores rejected).
81fn is_dns_label(value: &str) -> bool {
82    if value.is_empty() || value.len() > 63 {
83        return false;
84    }
85    let bytes = value.as_bytes();
86    let first_ok = bytes
87        .first()
88        .copied()
89        .is_some_and(|b| b.is_ascii_alphanumeric());
90    let last_ok = bytes
91        .last()
92        .copied()
93        .is_some_and(|b| b.is_ascii_alphanumeric());
94    if !first_ok || !last_ok {
95        return false;
96    }
97    bytes
98        .iter()
99        .all(|b| b.is_ascii_alphanumeric() || *b == b'-')
100}
101
102/// Returns true when `value` is a sane FQDN — optionally with a single leading
103/// `*.` wildcard. Total length capped at 253 (RFC 1035). IPv4/IPv6 literals are
104/// rejected so callers can't sneak an IP through a hostname slot.
105///
106/// Used by [`validate_execution_cell_document`] for `dnsAuthority.hostnameAllowlist`,
107/// `cdnAuthority.providers[].hostnamePattern`, and any future hostname-shaped
108/// fields under T13.
109pub(crate) fn is_fqdn_or_wildcard(value: &str) -> bool {
110    if value.is_empty() || value.len() > 253 {
111        return false;
112    }
113    // Reject IPv4-like dotted-quads (e.g. "1.2.3.4") — they parse as 4 labels of
114    // pure digits, which we don't want under "hostname". An IPv6 literal contains
115    // ':' which is rejected by is_dns_label, so no separate guard is needed.
116    if value
117        .split('.')
118        .all(|segment| !segment.is_empty() && segment.chars().all(|c| c.is_ascii_digit()))
119        && value.contains('.')
120    {
121        return false;
122    }
123
124    let labels: Vec<&str> = value.split('.').collect();
125    if labels.len() < 2 {
126        // Require at least one dot — "example" alone is not a usable FQDN here.
127        return false;
128    }
129
130    // Optional single leading wildcard label.
131    let (first, rest) = labels.split_first().expect("non-empty above");
132    if *first == "*" {
133        if rest.is_empty() {
134            return false;
135        }
136        return rest.iter().all(|label| is_dns_label(label));
137    }
138
139    labels.iter().all(|label| is_dns_label(label))
140}
141
142fn parse_http_base_url(value: &str, field: &str) -> Result<Url, CellosError> {
143    let parsed = Url::parse(value).map_err(|_| {
144        CellosError::InvalidSpec(format!(
145            "{field} must be an absolute http(s) base URL without query or fragment"
146        ))
147    })?;
148    let scheme = parsed.scheme();
149    if scheme != "http" && scheme != "https" {
150        return Err(CellosError::InvalidSpec(format!(
151            "{field} must use http or https"
152        )));
153    }
154    if parsed.host_str().is_none() || parsed.query().is_some() || parsed.fragment().is_some() {
155        return Err(CellosError::InvalidSpec(format!(
156            "{field} must be an absolute http(s) base URL without query or fragment"
157        )));
158    }
159    Ok(parsed)
160}
161
162/// Validate a policy pack's declared `spec.version` against the runtime's
163/// compiled-in supported floor. P4-04.
164///
165/// Thin façade over [`crate::check_policy_pack_version_compatibility`]
166/// exposed from the `spec_validation` namespace. `allow_downgrade` lets the
167/// caller (the supervisor, via the `CELLOS_POLICY_ALLOW_DOWNGRADE` operator
168/// override) opt out of the strict floor; cellos-core itself does not read
169/// process env vars (D11).
170pub fn check_policy_pack_version(
171    declared: Option<&str>,
172    allow_downgrade: bool,
173) -> Result<(), CellosError> {
174    crate::policy::check_policy_pack_version_compatibility(declared, allow_downgrade)
175}
176
177/// Reject a `tenant_id` that contains any NATS subject-token reserved char.
178///
179/// NATS subjects are dot-delimited tokens; the wildcards `*` and `>` and any
180/// whitespace would either fan out the subject across tenants or produce an
181/// unroutable wire string. This guard runs at admission so a malformed
182/// `correlation.tenantId` cannot bleed into another tenant's subject when
183/// substituted into a `{tenantId}` template (see
184/// `cellos-supervisor::spec_input::resolve_event_subject`).
185///
186/// Pure: no env access, no I/O. Empty `tenant_id` is rejected — callers that
187/// want "absent tenant" semantics should pass `Option::None` in the field
188/// rather than the empty string.
189pub fn validate_tenant_id_for_subject_token(tenant_id: &str) -> Result<(), CellosError> {
190    if tenant_id.is_empty() {
191        return Err(CellosError::InvalidSpec(
192            "spec.correlation.tenantId must be non-empty when present".into(),
193        ));
194    }
195    for ch in tenant_id.chars() {
196        let bad = ch == '.' || ch == '*' || ch == '>' || ch.is_whitespace();
197        if bad {
198            return Err(CellosError::InvalidSpec(format!(
199                "spec.correlation.tenantId contains NATS-reserved char: {ch:?} (in {tenant_id:?})"
200            )));
201        }
202    }
203    Ok(())
204}
205
206/// Reject specs that violate MVP invariants (stricter than JSON Schema alone).
207pub fn validate_execution_cell_document(doc: &ExecutionCellDocument) -> Result<(), CellosError> {
208    if doc.api_version != "cellos.io/v1" {
209        return Err(CellosError::InvalidSpec(format!(
210            "unsupported apiVersion: {}",
211            doc.api_version
212        )));
213    }
214    if doc.kind != "ExecutionCell" {
215        return Err(CellosError::InvalidSpec(format!(
216            "unsupported kind: {}",
217            doc.kind
218        )));
219    }
220    ensure_portable_identifier(&doc.spec.id, "spec.id")?;
221    if let Some(correlation) = &doc.spec.correlation {
222        if let Some(tenant_id) = correlation.tenant_id.as_deref() {
223            validate_tenant_id_for_subject_token(tenant_id)?;
224        }
225    }
226    let authority_secret_refs = doc
227        .spec
228        .authority
229        .secret_refs
230        .as_ref()
231        .map(|refs| refs.iter().map(String::as_str).collect::<HashSet<_>>())
232        .unwrap_or_default();
233    for secret_ref in &authority_secret_refs {
234        ensure_portable_identifier(secret_ref, "authority.secretRefs[]")?;
235    }
236    if let Some(identity) = &doc.spec.identity {
237        ensure_portable_identifier(&identity.secret_ref, "spec.identity.secretRef")?;
238        if let Some(ttl_seconds) = identity.ttl_seconds {
239            if ttl_seconds > doc.spec.lifetime.ttl_seconds {
240                return Err(CellosError::InvalidSpec(
241                    "spec.identity.ttlSeconds must be <= spec.lifetime.ttlSeconds".into(),
242                ));
243            }
244        }
245        if !authority_secret_refs.contains(identity.secret_ref.as_str()) {
246            return Err(CellosError::InvalidSpec(format!(
247                "spec.identity.secretRef {:?} must also appear in authority.secretRefs",
248                identity.secret_ref
249            )));
250        }
251    }
252    if let Some(env) = &doc.spec.environment {
253        if env.image_reference.is_empty() {
254            return Err(CellosError::InvalidSpec(
255                "spec.environment.imageReference must be non-empty".into(),
256            ));
257        }
258        if let Some(digest) = &env.image_digest {
259            if !is_sha256_digest(digest) {
260                return Err(CellosError::InvalidSpec(
261                    "spec.environment.imageDigest must be a sha256:<hex64> digest when present"
262                        .into(),
263                ));
264            }
265        }
266        if let Some(template_id) = &env.template_id {
267            ensure_portable_identifier(template_id, "spec.environment.templateId")?;
268        }
269    }
270    if let Some(placement) = &doc.spec.placement {
271        if placement.pool_id.is_none()
272            && placement.kubernetes_namespace.is_none()
273            && placement.queue_name.is_none()
274        {
275            return Err(CellosError::InvalidSpec(
276                "spec.placement must set at least one placement hint".into(),
277            ));
278        }
279        if let Some(pool_id) = &placement.pool_id {
280            ensure_portable_identifier(pool_id, "spec.placement.poolId")?;
281        }
282        if let Some(namespace) = &placement.kubernetes_namespace {
283            ensure_kubernetes_namespace(namespace, "spec.placement.kubernetesNamespace")?;
284        }
285        if let Some(queue_name) = &placement.queue_name {
286            ensure_portable_identifier(queue_name, "spec.placement.queueName")?;
287        }
288    }
289    if let Some(ingress) = &doc.spec.ingress {
290        if let Some(git) = &ingress.git {
291            if let Some(secret_ref) = &git.secret_ref {
292                ensure_portable_identifier(secret_ref, "spec.ingress.git.secretRef")?;
293            }
294        }
295        if let Some(image) = &ingress.oci_image {
296            if let Some(secret_ref) = &image.secret_ref {
297                ensure_portable_identifier(secret_ref, "spec.ingress.ociImage.secretRef")?;
298            }
299        }
300    }
301    if let Some(run) = &doc.spec.run {
302        // FC-65: argv invariants enforced at admission, before any VM is spawned.
303        // Non-UTF-8 argv impossible: serde_json deserializes argv as Vec<String>,
304        // which is UTF-8 by construction (rejected at the JSON deserialize boundary).
305        if run.argv.is_empty() || run.argv.iter().any(|s| s.is_empty()) {
306            return Err(CellosError::InvalidSpec(
307                "spec.run.argv must be non-empty with no empty strings".into(),
308            ));
309        }
310        // Reject embedded NUL bytes: execve takes NUL-terminated C strings, so an
311        // embedded \0 would silently truncate the argument inside the guest.
312        if let Some(idx) = run.argv.iter().position(|s| s.as_bytes().contains(&0)) {
313            return Err(CellosError::InvalidSpec(format!(
314                "spec.run.argv[{}] contains NUL byte (would be silently truncated by execve)",
315                idx
316            )));
317        }
318        check_argv_size_within_kernel_cmdline_limit(&run.argv)?;
319        if let Some(timeout_ms) = run.timeout_ms {
320            let ttl_ms = doc.spec.lifetime.ttl_seconds.saturating_mul(1000);
321            if timeout_ms > ttl_ms {
322                return Err(CellosError::InvalidSpec(
323                    "spec.run.timeoutMs must be <= spec.lifetime.ttlSeconds * 1000".into(),
324                ));
325            }
326        }
327        if let Some(limits) = &run.limits {
328            if limits.memory_max_bytes == Some(0) {
329                return Err(CellosError::InvalidSpec(
330                    "spec.run.limits.memoryMaxBytes must be > 0".into(),
331                ));
332            }
333            if let Some(cpu_max) = &limits.cpu_max {
334                if cpu_max.quota_micros == 0 {
335                    return Err(CellosError::InvalidSpec(
336                        "spec.run.limits.cpuMax.quotaMicros must be > 0".into(),
337                    ));
338                }
339                if cpu_max.period_micros == Some(0) {
340                    return Err(CellosError::InvalidSpec(
341                        "spec.run.limits.cpuMax.periodMicros must be > 0".into(),
342                    ));
343                }
344            }
345        }
346    }
347    if let Some(rules) = &doc.spec.authority.egress_rules {
348        for r in rules {
349            if r.host.is_empty() {
350                return Err(CellosError::InvalidSpec(
351                    "authority.egressRules[].host must be non-empty".into(),
352                ));
353            }
354        }
355    }
356    if let Some(dns_authority) = &doc.spec.authority.dns_authority {
357        validate_dns_authority(dns_authority)?;
358    }
359    if let Some(cdn_authority) = &doc.spec.authority.cdn_authority {
360        validate_cdn_authority(cdn_authority)?;
361    }
362    // If a derivation token is present, validate structural constraints (subset only).
363    // Signature verification is performed by the supervisor with role keys loaded
364    // from `CELLOS_AUTHORITY_KEYS_PATH` — see `verify_authority_derivation`.
365    if let Some(ref token) = doc.spec.authority.authority_derivation {
366        verify_authority_derivation_structural(&doc.spec, token)?;
367    }
368    if let Some(export) = &doc.spec.export {
369        let targets = export.targets.as_deref().unwrap_or(&[]);
370        let mut target_names = HashSet::new();
371        for target in targets {
372            ensure_portable_identifier(target.name(), "spec.export.targets[].name")?;
373            if !target_names.insert(target.name().to_string()) {
374                return Err(CellosError::InvalidSpec(format!(
375                    "duplicate export target name {:?}",
376                    target.name()
377                )));
378            }
379            if let Some(secret_ref) = target.secret_ref() {
380                if !authority_secret_refs.contains(secret_ref) {
381                    return Err(CellosError::InvalidSpec(format!(
382                        "export target {:?} secretRef {:?} must appear in authority.secretRefs",
383                        target.name(),
384                        secret_ref
385                    )));
386                }
387            }
388            if let crate::ExportTarget::Http(target) = target {
389                let parsed =
390                    parse_http_base_url(&target.base_url, "spec.export.targets[].baseUrl")?;
391                if let Some(rules) = &doc.spec.authority.egress_rules {
392                    if !rules.is_empty() {
393                        let host = parsed.host_str().expect("checked above");
394                        let port = parsed
395                            .port_or_known_default()
396                            .expect("http/https always has a known default port");
397                        let allowed = rules
398                            .iter()
399                            .any(|rule| rule.port == port && rule.host.eq_ignore_ascii_case(host));
400                        if !allowed {
401                            return Err(CellosError::InvalidSpec(format!(
402                                "http export target {:?} host {}:{} must appear in authority.egressRules",
403                                target.name, host, port
404                            )));
405                        }
406                    }
407                }
408            }
409        }
410        if let Some(artifacts) = &export.artifacts {
411            for artifact in artifacts {
412                ensure_portable_identifier(&artifact.name, "spec.export.artifacts[].name")?;
413                match artifact.target.as_deref() {
414                    Some(target_name) => {
415                        ensure_portable_identifier(target_name, "spec.export.artifacts[].target")?;
416                        if !target_names.contains(target_name) {
417                            return Err(CellosError::InvalidSpec(format!(
418                                "export artifact {:?} references unknown target {:?}",
419                                artifact.name, target_name
420                            )));
421                        }
422                    }
423                    None if targets.len() > 1 => {
424                        return Err(CellosError::InvalidSpec(format!(
425                            "export artifact {:?} must set target when multiple export targets exist",
426                            artifact.name
427                        )));
428                    }
429                    None => {}
430                }
431            }
432        }
433    }
434    if let Some(telemetry) = &doc.spec.telemetry {
435        validate_telemetry_block(telemetry, doc.spec.authority.egress_rules.as_deref())?;
436    }
437    Ok(())
438}
439
440/// F4a — admission validation for [`crate::TelemetrySpec`].
441///
442/// Checks (in order):
443/// 1. `events` is non-empty.
444/// 2. `channel == VsockCbor` (the only supported channel today — guards
445///    against forward-compat envelopes accidentally bypassing host wiring).
446/// 3. `agentVersion` matches a permissive semver shape
447///    (`MAJOR.MINOR.PATCH` with optional `-prerelease` suffix).
448/// 4. For every entry in `events` that begins with `net.` (case-insensitive),
449///    the spec's `authority.egressRules` MUST be non-empty. A `net.*` event
450///    declared without any declared egress is an unbacked telemetry/authority
451///    claim — the SIEM would receive network observations from a cell that
452///    declared no network authority. Returns
453///    `CellosError::InvalidSpec("telemetry_without_egress: ...")`.
454///
455/// Pure structural; no env access. Mirrored in
456/// `contracts/schemas/execution-cell-v1.schema.json` under the `Telemetry`
457/// definition.
458fn validate_telemetry_block(
459    telemetry: &crate::TelemetrySpec,
460    egress_rules: Option<&[crate::EgressRule]>,
461) -> Result<(), CellosError> {
462    if telemetry.events.is_empty() {
463        return Err(CellosError::InvalidSpec(
464            "spec.telemetry.events must be non-empty".into(),
465        ));
466    }
467    match telemetry.channel {
468        crate::TelemetryChannel::VsockCbor => {}
469    }
470    if !is_semver_shape(&telemetry.agent_version) {
471        return Err(CellosError::InvalidSpec(
472            "spec.telemetry.agentVersion must match MAJOR.MINOR.PATCH semver shape (optional -prerelease suffix)".into()
473        ));
474    }
475    let has_egress = egress_rules.map(|r| !r.is_empty()).unwrap_or(false);
476    for event in &telemetry.events {
477        let trimmed = event.trim();
478        if trimmed.is_empty() {
479            return Err(CellosError::InvalidSpec(
480                "spec.telemetry.events[] entries must be non-empty".into(),
481            ));
482        }
483        if trimmed.to_ascii_lowercase().starts_with("net.") && !has_egress {
484            return Err(CellosError::InvalidSpec(format!(
485                "telemetry_without_egress: spec.telemetry.events[] entry {trimmed:?} requires \
486                 at least one authority.egressRules entry — net.* telemetry without declared \
487                 egress is an unbacked observation claim"
488            )));
489        }
490    }
491    Ok(())
492}
493
494/// Permissive semver-shape check for `spec.telemetry.agentVersion`.
495///
496/// Matches `MAJOR.MINOR.PATCH` (each numeric, no leading zeros except "0")
497/// with an optional `-<prerelease>` suffix. Build metadata (`+...`) is not
498/// accepted for now — keep the shape narrow until operators ask for it.
499fn is_semver_shape(value: &str) -> bool {
500    let core = match value.split_once('-') {
501        Some((core, pre)) => {
502            if pre.is_empty()
503                || !pre
504                    .chars()
505                    .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-'))
506            {
507                return false;
508            }
509            core
510        }
511        None => value,
512    };
513    let parts: Vec<&str> = core.split('.').collect();
514    if parts.len() != 3 {
515        return false;
516    }
517    parts.iter().all(|p| {
518        !p.is_empty()
519            && p.chars().all(|c| c.is_ascii_digit())
520            && !(p.len() > 1 && p.starts_with('0'))
521    })
522}
523
524/// Structural subset check for an `AuthorityDerivationToken` — does NOT verify
525/// signatures. Used by `validate_execution_cell_document`, which has no access
526/// to role keys. The supervisor performs full signature verification separately
527/// via `verify_authority_derivation` once keys are loaded from
528/// `CELLOS_AUTHORITY_KEYS_PATH`.
529fn verify_authority_derivation_structural(
530    spec: &crate::ExecutionCellSpec,
531    token: &crate::AuthorityDerivationToken,
532) -> Result<(), crate::error::CellosError> {
533    let spec_capability = crate::AuthorityCapability {
534        egress_rules: spec.authority.egress_rules.clone().unwrap_or_default(),
535        secret_refs: spec.authority.secret_refs.clone().unwrap_or_default(),
536    };
537    if !spec_capability.is_superset_of(&token.leaf_capability) {
538        return Err(crate::error::CellosError::InvalidSpec(
539            "spec.authorityDerivation.leafCapability exceeds spec.authority — child authority must be ⊆ declared authority".into()
540        ));
541    }
542    Ok(())
543}
544
545/// Validate `authority.dnsAuthority` (T13 / SEC-20).
546///
547/// Pure structural / domain checks — does NOT contact any resolver and does NOT
548/// require trust-keyset state. The supervisor performs full trust-keyset binding
549/// at runtime in SEC-21/SEC-22.
550fn validate_dns_authority(dns: &crate::DnsAuthority) -> Result<(), CellosError> {
551    let mut resolver_ids: HashSet<&str> = HashSet::new();
552    for resolver in &dns.resolvers {
553        ensure_portable_identifier(
554            &resolver.resolver_id,
555            "authority.dnsAuthority.resolvers[].resolverId",
556        )?;
557        if !resolver_ids.insert(resolver.resolver_id.as_str()) {
558            return Err(CellosError::InvalidSpec(format!(
559                "authority.dnsAuthority.resolvers[].resolverId duplicates value {:?}",
560                resolver.resolver_id
561            )));
562        }
563        if resolver.endpoint.is_empty() {
564            return Err(CellosError::InvalidSpec(
565                "authority.dnsAuthority.resolvers[].endpoint must be non-empty".into(),
566            ));
567        }
568        if let Some(kid) = &resolver.trust_kid {
569            ensure_portable_identifier(kid, "authority.dnsAuthority.resolvers[].trustKid")?;
570        }
571    }
572
573    for hostname in &dns.hostname_allowlist {
574        if !is_fqdn_or_wildcard(hostname) {
575            return Err(CellosError::InvalidSpec(format!(
576                "authority.dnsAuthority.hostnameAllowlist entry {hostname:?} is not a valid FQDN \
577                 (single leading '*.' wildcard allowed; IPs are rejected)"
578            )));
579        }
580    }
581
582    if let Some(refresh) = &dns.refresh_policy {
583        if let (Some(min_ttl), Some(max_stale)) =
584            (refresh.min_ttl_seconds, refresh.max_stale_seconds)
585        {
586            if min_ttl > max_stale {
587                return Err(CellosError::InvalidSpec(format!(
588                    "authority.dnsAuthority.refreshPolicy.minTtlSeconds ({min_ttl}) must be <= maxStaleSeconds ({max_stale})"
589                )));
590            }
591        }
592    }
593
594    Ok(())
595}
596
597/// Validate `authority.cdnAuthority` (T13 / SEC-20).
598fn validate_cdn_authority(cdn: &crate::CdnAuthority) -> Result<(), CellosError> {
599    let mut provider_ids: HashSet<&str> = HashSet::new();
600    for provider in &cdn.providers {
601        ensure_portable_identifier(
602            &provider.provider_id,
603            "authority.cdnAuthority.providers[].providerId",
604        )?;
605        if !provider_ids.insert(provider.provider_id.as_str()) {
606            return Err(CellosError::InvalidSpec(format!(
607                "authority.cdnAuthority.providers[].providerId duplicates value {:?}",
608                provider.provider_id
609            )));
610        }
611        if !is_fqdn_or_wildcard(&provider.hostname_pattern) {
612            return Err(CellosError::InvalidSpec(format!(
613                "authority.cdnAuthority.providers[].hostnamePattern {:?} is not a valid FQDN \
614                 (single leading '*.' wildcard allowed; IPs are rejected)",
615                provider.hostname_pattern
616            )));
617        }
618    }
619    Ok(())
620}
621
622/// FC-17 / FC-66 — admission cap on the size of `spec.run.argv` once the
623/// Firecracker host base64-encodes it onto the kernel boot cmdline.
624///
625/// The Linux kernel cmdline has a 4 KiB hard limit. The Firecracker host
626/// (`crates/cellos-host-firecracker/src/lib.rs::build_boot_args`) writes
627/// `cellos.argv=<base64(json_array)>` plus a small fixed prefix (`console=`,
628/// `root=`, `cellos.cell_id=`, `cellos.vsock_port=` …). We cap the encoded
629/// argv payload at 3 KiB so the rest of the cmdline keeps ~1 KiB of headroom
630/// against future cmdline additions.
631///
632/// `MAX_ARGV_ENCODED_BYTES` is exposed in the typed
633/// [`CellosError::ArgvTooLarge::limit_bytes`] field so operators do not have
634/// to dig into core to see the budget.
635const MAX_ARGV_ENCODED_BYTES: usize = 3072;
636
637/// FC-17 — admission check that `spec.run.argv` will fit inside the Linux
638/// kernel boot-cmdline 4 KiB hard limit once Firecracker assembles it.
639///
640/// FC-66 — surfaces the rejection as the typed
641/// [`CellosError::ArgvTooLarge`] variant (carrying the actual encoded byte
642/// count and the static limit) rather than as a string-payload `InvalidSpec`,
643/// so callers can pattern-match on it without parsing a message.
644///
645/// We reproduce the host's exact encoding (`base64(serde_json::to_string(argv))`)
646/// and compare its length against [`MAX_ARGV_ENCODED_BYTES`] (3072 bytes).
647pub(crate) fn check_argv_size_within_kernel_cmdline_limit(
648    argv: &[String],
649) -> Result<(), CellosError> {
650    use base64::engine::general_purpose::STANDARD;
651    use base64::Engine as _;
652
653    // serde_json::to_string on Vec<String> cannot fail (no non-string keys, no
654    // floats), but the API is fallible — fall back to a conservative upper
655    // bound (raw concatenated bytes + JSON framing) on the unreachable Err
656    // path so admission never panics on user input.
657    let encoded_len = match serde_json::to_string(argv) {
658        Ok(json) => STANDARD.encode(json.as_bytes()).len(),
659        Err(_) => {
660            // ceil(n/3)*4 of a generous JSON framing estimate:
661            // 2 brackets + per-arg 3 bytes (quotes + comma) + raw arg bytes,
662            // each byte possibly doubled by JSON string-escaping.
663            let json_upper = 2 + argv
664                .iter()
665                .map(|s| s.len().saturating_mul(2).saturating_add(3))
666                .sum::<usize>();
667            json_upper.div_ceil(3).saturating_mul(4)
668        }
669    };
670
671    if encoded_len > MAX_ARGV_ENCODED_BYTES {
672        return Err(CellosError::ArgvTooLarge {
673            encoded_bytes: encoded_len,
674            limit_bytes: MAX_ARGV_ENCODED_BYTES,
675        });
676    }
677    Ok(())
678}
679
680/// Build the canonical JSON payload that the grantor signs and the supervisor verifies.
681///
682/// Field order is significant — both signer and verifier MUST agree on the encoding.
683/// The `serde_json::json!` macro preserves insertion order for object literals,
684/// so the bytes produced here are deterministic given the same inputs.
685///
686/// Layout:
687/// ```json
688/// { "roleRoot": "<RoleId>", "leafCapability": <AuthorityCapability>, "parentRunId": <string|null> }
689/// ```
690pub fn authority_derivation_signing_payload(
691    token: &crate::AuthorityDerivationToken,
692) -> Result<Vec<u8>, crate::error::CellosError> {
693    let value = serde_json::json!({
694        "roleRoot": token.role_root.to_string(),
695        "leafCapability": &token.leaf_capability,
696        "parentRunId": token.parent_run_id,
697    });
698    serde_json::to_vec(&value).map_err(|e| {
699        crate::error::CellosError::InvalidSpec(format!(
700            "authority derivation signing payload encode failed: {e}"
701        ))
702    })
703}
704
705/// Verify an `AuthorityDerivationToken` against the declared spec authority.
706///
707/// Checks (in order):
708/// 1. `token.leaf_capability` is a subset of `spec.authority` (egress + secret dims).
709/// 2. `role_keys[token.role_root]` exists.
710/// 3. The base64-encoded verifying key parses as a valid 32-byte ED25519 key.
711/// 4. `token.grantor_signature.bytes` parses as a valid 64-byte ED25519 signature.
712/// 5. The signature verifies (`verify_strict`) over the canonical signing payload.
713///
714/// `role_keys`: map from RoleId string → base64-encoded ED25519 verifying key
715/// (raw 32-byte form, base64-STANDARD encoded).
716///
717/// Returns `Ok(())` on success, `Err(CellosError::InvalidSpec(...))` on any failure.
718pub fn verify_authority_derivation(
719    spec: &crate::ExecutionCellSpec,
720    token: &crate::AuthorityDerivationToken,
721    role_keys: &std::collections::HashMap<String, String>,
722) -> Result<(), crate::error::CellosError> {
723    use base64::engine::general_purpose::STANDARD;
724    use base64::Engine as _;
725    use ed25519_dalek::{Signature, VerifyingKey};
726
727    // Step 1: structural subset check.
728    verify_authority_derivation_structural(spec, token)?;
729
730    // Step 2: look up the verifying key for this role.
731    let role_id = token.role_root.to_string();
732    let verifying_key_b64 = role_keys.get(&role_id).ok_or_else(|| {
733        crate::error::CellosError::InvalidSpec(format!("unknown role: {role_id}"))
734    })?;
735
736    // Step 3: decode + parse the verifying key.
737    let verifying_key_bytes = STANDARD.decode(verifying_key_b64.as_bytes()).map_err(|e| {
738        crate::error::CellosError::InvalidSpec(format!(
739            "authority derivation verifying key for role {role_id} is not valid base64: {e}"
740        ))
741    })?;
742    let verifying_key_array: [u8; 32] =
743        verifying_key_bytes.as_slice().try_into().map_err(|_| {
744            crate::error::CellosError::InvalidSpec(format!(
745                "authority derivation verifying key for role {role_id} must be 32 bytes (got {})",
746                verifying_key_bytes.len()
747            ))
748        })?;
749    let verifying_key = VerifyingKey::from_bytes(&verifying_key_array).map_err(|e| {
750        crate::error::CellosError::InvalidSpec(format!(
751            "authority derivation verifying key for role {role_id} is not a valid ED25519 point: {e}"
752        ))
753    })?;
754
755    // Step 4: decode + parse the signature.
756    let sig_bytes = STANDARD
757        .decode(token.grantor_signature.bytes.as_bytes())
758        .map_err(|e| {
759            crate::error::CellosError::InvalidSpec(format!(
760                "authority derivation grantor signature is not valid base64: {e}"
761            ))
762        })?;
763    let sig_array: [u8; 64] = sig_bytes.as_slice().try_into().map_err(|_| {
764        crate::error::CellosError::InvalidSpec(format!(
765            "authority derivation grantor signature must be 64 bytes (got {})",
766            sig_bytes.len()
767        ))
768    })?;
769    let signature = Signature::from_bytes(&sig_array);
770
771    // Step 5: build canonical payload + verify.
772    let payload = authority_derivation_signing_payload(token)?;
773    verifying_key
774        .verify_strict(&payload, &signature)
775        .map_err(|_| {
776            crate::error::CellosError::InvalidSpec("authority derivation signature invalid".into())
777        })?;
778
779    tracing::debug!(
780        role_root = %token.role_root,
781        "authority derivation token verified (structural + signature)"
782    );
783
784    Ok(())
785}
786
787/// Enforce the derivation-token scope policy after signature verification (L5-16 / I6 / O6).
788///
789/// `parent_run_id` is grantor-asserted in the signed payload. A token signed with
790/// `parentRunId: null` verifies cryptographically against any compatible run —
791/// making it a replayable universal delegation token. This function closes that
792/// replay window by rejecting universal tokens **by default** (1.0 strict-by-default
793/// posture) and only accepting them when an operator EXPLICITLY opts out into
794/// permissive mode.
795///
796/// Behaviour:
797/// - When `allow_universal` is `false` — the **default** posture, including
798///   when `CELLOS_REQUIRE_SCOPED_DERIVATION_TOKENS` is unset, empty, or set to
799///   `1`/`true`/`yes`/`on` — a token with `parent_run_id == None` is rejected
800///   as `CellosError::InvalidSpec`.
801/// - When `allow_universal` is `true` — only when the operator EXPLICITLY
802///   opted out by setting `CELLOS_REQUIRE_SCOPED_DERIVATION_TOKENS` to one of
803///   `0`/`false`/`no`/`off` — a token with `parent_run_id == None` is
804///   accepted. The supervisor additionally emits a structured CloudEvent
805///   (`dev.cellos.events.cell.identity.v1.universal_token_accepted_in_permissive_mode`)
806///   recording the exposure so the audit trail captures it. A `WARN` is also
807///   logged on the `cellos.supervisor.authority` target.
808///
809/// This is additive: tokens whose `parent_run_id` is `Some(_)` always pass.
810pub fn enforce_derivation_scope_policy(
811    token: &crate::AuthorityDerivationToken,
812    allow_universal: bool,
813) -> Result<(), crate::error::CellosError> {
814    if token.parent_run_id.is_some() {
815        return Ok(());
816    }
817
818    if allow_universal {
819        tracing::warn!(
820            target: "cellos.supervisor.authority",
821            role_root = %token.role_root,
822            "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)"
823        );
824        return Ok(());
825    }
826
827    Err(crate::error::CellosError::InvalidSpec(
828        "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(),
829    ))
830}
831
832/// Verify a SEC-25 signed trust-keyset envelope and return its raw payload bytes.
833///
834/// `verifying_keys` is a map from `signerKid` to a parsed `ed25519_dalek::VerifyingKey`.
835/// The caller is responsible for sourcing this map (e.g. from
836/// `CELLOS_TRUST_VERIFY_KEYS_PATH`).
837///
838/// Verification steps (in order):
839/// 1. Decode `payload` as base64url. Compute `sha256:<hex>` over the raw bytes
840///    and compare to `payloadDigest`. Mismatch → `CellosError::InvalidSpec`.
841/// 2. For each entry in `signatures`:
842///    - Look up `signerKid` in `verifying_keys`. Missing kid → that signature is
843///      rejected (do not fail the whole envelope yet).
844///    - If `notBefore`/`notAfter` are set, parse them as RFC3339 and check
845///      `now ∈ [notBefore, notAfter]`. Out-of-window → that signature is rejected.
846///    - Decode `signature` as base64url; require exactly 64 bytes; call
847///      `verify_strict` over the raw payload bytes.
848///    - On success, record the signer's `signerKid` in a deduplicating set.
849/// 3. **Phase 3 N-of-M threshold:** compare the count of DISTINCT verified
850///    signer kids against `envelope.required_signer_count.unwrap_or(1)`
851///    (clamped to a minimum of 1). The Phase 1 default of 1 preserves
852///    backward-compat with the original "at least one signature must verify"
853///    policy — single-signer envelopes accepted, missing-`required_signer_count`
854///    envelopes accepted. Operator deployments raise the threshold (e.g. 2)
855///    so a single compromised signer key cannot alone authorize a keyset.
856/// 4. If `verified_distinct_signers >= required` → return the raw payload
857///    bytes. Otherwise → `CellosError::InvalidSpec` with a message that
858///    distinguishes the legacy single-signer failure (`"no signature
859///    verified"`) from a threshold shortfall (`"only N distinct signers
860///    verified, need M"`).
861///
862/// **Distinct-kid counting.** Two signature entries with the same `signerKid`
863/// — even with different signature bytes — count as one verifier. The
864/// schema's `signatures` list permits duplicates (a re-sign by the same
865/// operator is structurally valid) but the threshold policy treats them as
866/// one operator's vote. This is the intended N-of-M semantic.
867///
868/// This function does NOT deserialize the inner trust-keyset payload. The
869/// schema is the contract of record; callers may parse the bytes into any
870/// shape (`serde_json::Value` or a future `TrustKeysetV1` Rust mirror).
871pub fn verify_signed_trust_keyset_envelope(
872    envelope: &crate::types::SignedTrustKeysetEnvelope,
873    verifying_keys: &std::collections::HashMap<String, ed25519_dalek::VerifyingKey>,
874    now: std::time::SystemTime,
875) -> Result<Vec<u8>, crate::error::CellosError> {
876    use base64::engine::general_purpose::URL_SAFE_NO_PAD;
877    use base64::Engine as _;
878    use ed25519_dalek::Signature;
879    use std::collections::HashSet;
880
881    // scope: lock payloadType + algorithms to declared SEC-25 shape.
882    if envelope.payload_type != "application/vnd.cellos.trust-keyset-v1+json" {
883        return Err(crate::error::CellosError::InvalidSpec(format!(
884            "signed trust keyset envelope payloadType must be application/vnd.cellos.trust-keyset-v1+json, got '{}'",
885            envelope.payload_type
886        )));
887    }
888
889    // Step 1: decode payload + verify digest.
890    // Accept either base64url-with-padding or base64url-no-padding by stripping '=' first.
891    let payload_b64 = envelope.payload.trim_end_matches('=');
892    let payload_bytes = URL_SAFE_NO_PAD.decode(payload_b64).map_err(|e| {
893        crate::error::CellosError::InvalidSpec(format!(
894            "signed trust keyset envelope payload is not valid base64url: {e}"
895        ))
896    })?;
897    let computed_digest = sha256_hex_prefixed(&payload_bytes);
898    if computed_digest != envelope.payload_digest {
899        return Err(crate::error::CellosError::InvalidSpec(format!(
900            "signed trust keyset envelope payload digest mismatch: declared={}, computed={}",
901            envelope.payload_digest, computed_digest
902        )));
903    }
904
905    if envelope.signatures.is_empty() {
906        // Schema enforces minItems: 1, but defend against hand-built structs.
907        return Err(crate::error::CellosError::InvalidSpec(
908            "signed trust keyset envelope has no signatures".into(),
909        ));
910    }
911
912    // scope: N-of-M threshold. Default + zero clamp to 1 (single-signature
913    // semantics). Schema's `minimum: 1` blocks zero at the contract layer;
914    // the Rust clamp is a defense-in-depth against hand-built envelopes.
915    let required = envelope.required_signer_count.unwrap_or(1).max(1);
916
917    // Step 2: walk signatures; collect distinct signerKids whose signature
918    // verified. We collect ALL of them (no early-break) so the threshold
919    // check has the true distinct-verifier count.
920    let mut verified_signers: HashSet<&str> = HashSet::new();
921    for sig_entry in &envelope.signatures {
922        if sig_entry.algorithm != "ed25519" {
923            // Unknown algorithm: skip (a future-version envelope might have
924            // multi-algo signatures alongside our supported one).
925            continue;
926        }
927        // If this kid already verified via an earlier signature, skip the
928        // expensive crypto check — a duplicate kid only ever counts once.
929        if verified_signers.contains(sig_entry.signer_kid.as_str()) {
930            continue;
931        }
932        let Some(verifying_key) = verifying_keys.get(&sig_entry.signer_kid) else {
933            // Unknown kid for the verifier's keyring → skip this signature.
934            continue;
935        };
936
937        // notBefore / notAfter window check (each bound is optional).
938        if !signature_window_contains(
939            now,
940            sig_entry.not_before.as_deref(),
941            sig_entry.not_after.as_deref(),
942        )? {
943            continue;
944        }
945
946        // Decode signature: base64url (with or without padding) → exactly 64 bytes.
947        let sig_b64 = sig_entry.signature.trim_end_matches('=');
948        let Ok(sig_bytes) = URL_SAFE_NO_PAD.decode(sig_b64) else {
949            continue;
950        };
951        let Ok(sig_array) = <[u8; 64]>::try_from(sig_bytes.as_slice()) else {
952            continue;
953        };
954        let signature = Signature::from_bytes(&sig_array);
955
956        if verifying_key
957            .verify_strict(&payload_bytes, &signature)
958            .is_ok()
959        {
960            verified_signers.insert(sig_entry.signer_kid.as_str());
961        }
962    }
963
964    if verified_signers.len() >= required as usize {
965        return Ok(payload_bytes);
966    }
967
968    // Preserve the legacy "no signature verified" error message when the
969    // threshold is the Phase 1 default (1) and zero signatures verified —
970    // existing operator triage runbooks and the SEC-25 Phase 2 supervisor
971    // tests grep for that exact substring. The new threshold-shortfall
972    // message is reserved for Phase 3 N-of-M (>= 2) failures.
973    if required == 1 {
974        Err(crate::error::CellosError::InvalidSpec(
975            "signed trust keyset envelope: no signature verified".into(),
976        ))
977    } else {
978        Err(crate::error::CellosError::InvalidSpec(format!(
979            "signed trust keyset envelope: only {} distinct signers verified, need {}",
980            verified_signers.len(),
981            required
982        )))
983    }
984}
985
986/// Verify a chain of signed trust-keyset envelopes for replay-safety (SEC-25 Phase 3).
987///
988/// The chain is ordered **oldest-first**; the HEAD (current) envelope is the
989/// LAST entry. Each non-genesis envelope MUST carry a
990/// `replacesEnvelopeDigest` equal to `sha256:<hex>` of the immediately prior
991/// envelope's raw decoded payload bytes. The first envelope's
992/// `replacesEnvelopeDigest` MAY be absent (genesis) or present (chain root
993/// reference) — the chain verifier does not check the genesis link.
994///
995/// Per-envelope verification (signature, digest, threshold, validity window)
996/// is delegated to [`verify_signed_trust_keyset_envelope`]; chain integrity
997/// is layered on top:
998///
999/// 1. Empty chain → `CellosError::InvalidSpec`.
1000/// 2. For each envelope in order, call `verify_signed_trust_keyset_envelope`.
1001///    Per-envelope verification failures (including N-of-M threshold
1002///    shortfall) propagate immediately.
1003/// 3. For each adjacent pair `(prev, next)`, require
1004///    `next.replaces_envelope_digest == Some("sha256:<hex>(prev_payload_bytes)")`.
1005///    Mismatch → `CellosError::InvalidSpec` naming the chain index.
1006///
1007/// Returns the verified raw payload bytes of the HEAD envelope on success.
1008///
1009/// **Replay defense.** Verifiers that cache keyset state SHOULD reject any
1010/// new envelope whose `replacesEnvelopeDigest` does not match the cached
1011/// HEAD's payloadDigest — that is either a replay of an old envelope or a
1012/// chain fork. This function does not maintain that cache; it only verifies
1013/// the integrity of a chain handed to it.
1014pub fn verify_signed_trust_keyset_chain(
1015    chain: &[crate::types::SignedTrustKeysetEnvelope],
1016    verifying_keys: &std::collections::HashMap<String, ed25519_dalek::VerifyingKey>,
1017    now: std::time::SystemTime,
1018) -> Result<Vec<u8>, crate::error::CellosError> {
1019    if chain.is_empty() {
1020        return Err(crate::error::CellosError::InvalidSpec(
1021            "signed trust keyset chain: empty chain".into(),
1022        ));
1023    }
1024
1025    let mut prev_payload_bytes: Option<Vec<u8>> = None;
1026    for (idx, envelope) in chain.iter().enumerate() {
1027        let payload_bytes = verify_signed_trust_keyset_envelope(envelope, verifying_keys, now)
1028            .map_err(|e| {
1029                crate::error::CellosError::InvalidSpec(format!(
1030                    "signed trust keyset chain: envelope at index {idx} failed verification: {e}"
1031                ))
1032            })?;
1033
1034        if let Some(prev_bytes) = prev_payload_bytes.as_deref() {
1035            // Non-genesis: replacesEnvelopeDigest MUST equal sha256(prev.payload_bytes).
1036            let expected = sha256_hex_prefixed(prev_bytes);
1037            match envelope.replaces_envelope_digest.as_deref() {
1038                Some(actual) if actual == expected => {}
1039                Some(actual) => {
1040                    return Err(crate::error::CellosError::InvalidSpec(format!(
1041                        "signed trust keyset chain: envelope at index {idx} replacesEnvelopeDigest mismatch: declared={actual}, expected={expected}"
1042                    )));
1043                }
1044                None => {
1045                    return Err(crate::error::CellosError::InvalidSpec(format!(
1046                        "signed trust keyset chain: envelope at index {idx} missing replacesEnvelopeDigest (only the genesis envelope at index 0 may omit it)"
1047                    )));
1048                }
1049            }
1050        }
1051        // The genesis envelope (index 0) is allowed to set or omit
1052        // replacesEnvelopeDigest — we do not check the link before it.
1053
1054        prev_payload_bytes = Some(payload_bytes);
1055    }
1056
1057    // Unwrap is safe: chain non-empty above + prev_payload_bytes is set on
1058    // every loop iteration.
1059    Ok(prev_payload_bytes.expect("chain non-empty checked above"))
1060}
1061
1062/// `sha256:<hex>` over `bytes`, matching the prefix convention used across
1063/// CellOS trust-plane schemas (`payloadDigest`, `policyDigest`, etc.).
1064fn sha256_hex_prefixed(bytes: &[u8]) -> String {
1065    use sha2::{Digest, Sha256};
1066    use std::fmt::Write as _;
1067    let out = Sha256::new().chain_update(bytes).finalize();
1068    let mut hex = String::with_capacity(7 + 64);
1069    hex.push_str("sha256:");
1070    for b in out.iter() {
1071        let _ = write!(hex, "{b:02x}");
1072    }
1073    hex
1074}
1075
1076/// Return `Ok(true)` iff `now` is within `[not_before, not_after]` (inclusive).
1077/// Either bound may be `None` (meaning unbounded on that side). RFC3339 parse
1078/// failure → `Err(CellosError::InvalidSpec)`.
1079fn signature_window_contains(
1080    now: std::time::SystemTime,
1081    not_before: Option<&str>,
1082    not_after: Option<&str>,
1083) -> Result<bool, crate::error::CellosError> {
1084    use chrono::{DateTime, Utc};
1085    let now_chrono: DateTime<Utc> = now.into();
1086    if let Some(nb) = not_before {
1087        let parsed = DateTime::parse_from_rfc3339(nb).map_err(|e| {
1088            crate::error::CellosError::InvalidSpec(format!(
1089                "signed trust keyset envelope notBefore '{nb}' is not RFC3339: {e}"
1090            ))
1091        })?;
1092        if now_chrono < parsed.with_timezone(&Utc) {
1093            return Ok(false);
1094        }
1095    }
1096    if let Some(na) = not_after {
1097        let parsed = DateTime::parse_from_rfc3339(na).map_err(|e| {
1098            crate::error::CellosError::InvalidSpec(format!(
1099                "signed trust keyset envelope notAfter '{na}' is not RFC3339: {e}"
1100            ))
1101        })?;
1102        if now_chrono > parsed.with_timezone(&Utc) {
1103            return Ok(false);
1104        }
1105    }
1106    Ok(true)
1107}
1108
1109#[cfg(test)]
1110mod tests {
1111    use super::*;
1112    use crate::{
1113        AuthorityBundle, EnvironmentSpec, ExecutionCellSpec, ExportArtifact, ExportChannels,
1114        ExportTarget, HttpExportTarget, Lifetime, RunCpuMax, RunLimits, RunSpec, S3ExportTarget,
1115        SecretDeliveryMode, WorkloadIdentity, WorkloadIdentityKind,
1116    };
1117
1118    #[test]
1119    fn rejects_empty_argv_token() {
1120        let doc = ExecutionCellDocument {
1121            api_version: "cellos.io/v1".into(),
1122            kind: "ExecutionCell".into(),
1123            spec: ExecutionCellSpec {
1124                id: "x".into(),
1125                correlation: None,
1126                ingress: None,
1127                environment: None,
1128                placement: None,
1129                policy: None,
1130                identity: None,
1131                run: Some(RunSpec {
1132                    argv: vec!["sh".into(), "".into()],
1133                    working_directory: None,
1134                    timeout_ms: None,
1135                    limits: None,
1136                    secret_delivery: SecretDeliveryMode::Env,
1137                }),
1138                authority: AuthorityBundle::default(),
1139                lifetime: Lifetime { ttl_seconds: 1 },
1140                export: None,
1141                telemetry: None,
1142            },
1143        };
1144        assert!(validate_execution_cell_document(&doc).is_err());
1145    }
1146
1147    /// FC-17 — argv totalling ~1 KiB of payload bytes encodes well under the
1148    /// 3 KiB cap and must pass admission.
1149    #[test]
1150    fn admits_argv_under_kernel_cmdline_limit() {
1151        // 1 KiB of payload across two args; base64(json) ~= 1.4 KiB, < 3072.
1152        let payload = "a".repeat(1024);
1153        let doc = ExecutionCellDocument {
1154            api_version: "cellos.io/v1".into(),
1155            kind: "ExecutionCell".into(),
1156            spec: ExecutionCellSpec {
1157                id: "argv-fits".into(),
1158                correlation: None,
1159                ingress: None,
1160                environment: None,
1161                placement: None,
1162                policy: None,
1163                identity: None,
1164                run: Some(RunSpec {
1165                    argv: vec!["sh".into(), payload],
1166                    working_directory: None,
1167                    timeout_ms: None,
1168                    limits: None,
1169                    secret_delivery: SecretDeliveryMode::Env,
1170                }),
1171                authority: AuthorityBundle::default(),
1172                lifetime: Lifetime { ttl_seconds: 1 },
1173                export: None,
1174                telemetry: None,
1175            },
1176        };
1177        validate_execution_cell_document(&doc).expect("1 KiB argv must pass admission");
1178    }
1179
1180    /// FC-17 / FC-66 — argv whose base64(json) encoding exceeds 3 KiB must be
1181    /// rejected at admission with the typed [`CellosError::ArgvTooLarge`]
1182    /// variant (not a string-payload `InvalidSpec`) so callers can
1183    /// pattern-match without parsing a message.
1184    #[test]
1185    fn rejects_argv_exceeding_kernel_cmdline_limit() {
1186        // 5 KiB of raw payload → JSON ~5 KiB → base64 ~6.7 KiB; well over 3072.
1187        let payload = "a".repeat(5 * 1024);
1188        let doc = ExecutionCellDocument {
1189            api_version: "cellos.io/v1".into(),
1190            kind: "ExecutionCell".into(),
1191            spec: ExecutionCellSpec {
1192                id: "argv-too-big".into(),
1193                correlation: None,
1194                ingress: None,
1195                environment: None,
1196                placement: None,
1197                policy: None,
1198                identity: None,
1199                run: Some(RunSpec {
1200                    argv: vec!["sh".into(), payload],
1201                    working_directory: None,
1202                    timeout_ms: None,
1203                    limits: None,
1204                    secret_delivery: SecretDeliveryMode::Env,
1205                }),
1206                authority: AuthorityBundle::default(),
1207                lifetime: Lifetime { ttl_seconds: 1 },
1208                export: None,
1209                telemetry: None,
1210            },
1211        };
1212        let err = validate_execution_cell_document(&doc).expect_err("5 KiB argv must reject");
1213        match err {
1214            CellosError::ArgvTooLarge {
1215                encoded_bytes,
1216                limit_bytes,
1217            } => {
1218                assert!(
1219                    encoded_bytes > limit_bytes,
1220                    "encoded_bytes ({encoded_bytes}) must exceed limit_bytes ({limit_bytes})"
1221                );
1222                assert_eq!(limit_bytes, 3072, "FC-17 budget is 3 KiB");
1223            }
1224            other => panic!("expected CellosError::ArgvTooLarge, got: {other:?}"),
1225        }
1226    }
1227
1228    #[test]
1229    fn rejects_identity_ttl_longer_than_cell_ttl() {
1230        let doc = ExecutionCellDocument {
1231            api_version: "cellos.io/v1".into(),
1232            kind: "ExecutionCell".into(),
1233            spec: ExecutionCellSpec {
1234                id: "x".into(),
1235                correlation: None,
1236                ingress: None,
1237                environment: None,
1238                placement: None,
1239                policy: None,
1240                identity: Some(WorkloadIdentity {
1241                    kind: WorkloadIdentityKind::FederatedOidc,
1242                    provider: "github-actions".into(),
1243                    audience: "sts.amazonaws.com".into(),
1244                    subject: None,
1245                    ttl_seconds: Some(120),
1246                    secret_ref: "AWS_WEB_IDENTITY".into(),
1247                }),
1248                run: None,
1249                authority: AuthorityBundle {
1250                    filesystem: None,
1251                    network: None,
1252                    egress_rules: None,
1253                    secret_refs: Some(vec!["AWS_WEB_IDENTITY".into()]),
1254                    authority_derivation: None,
1255                    dns_authority: None,
1256                    cdn_authority: None,
1257                },
1258                lifetime: Lifetime { ttl_seconds: 60 },
1259                export: None,
1260                telemetry: None,
1261            },
1262        };
1263        assert!(validate_execution_cell_document(&doc).is_err());
1264    }
1265
1266    #[test]
1267    fn rejects_unknown_export_target_reference() {
1268        let doc = ExecutionCellDocument {
1269            api_version: "cellos.io/v1".into(),
1270            kind: "ExecutionCell".into(),
1271            spec: ExecutionCellSpec {
1272                id: "x".into(),
1273                correlation: None,
1274                ingress: None,
1275                environment: None,
1276                placement: None,
1277                policy: None,
1278                identity: None,
1279                run: None,
1280                authority: AuthorityBundle {
1281                    filesystem: None,
1282                    network: None,
1283                    egress_rules: None,
1284                    secret_refs: Some(vec!["AWS_WEB_IDENTITY".into()]),
1285                    authority_derivation: None,
1286                    dns_authority: None,
1287                    cdn_authority: None,
1288                },
1289                lifetime: Lifetime { ttl_seconds: 60 },
1290                export: Some(ExportChannels {
1291                    artifacts: Some(vec![ExportArtifact {
1292                        name: "junit".into(),
1293                        path: "/tmp/junit.xml".into(),
1294                        target: Some("missing".into()),
1295                        content_type: None,
1296                    }]),
1297                    targets: Some(vec![ExportTarget::S3(S3ExportTarget {
1298                        name: "artifacts".into(),
1299                        bucket: "cellos-artifacts".into(),
1300                        key_prefix: None,
1301                        region: None,
1302                        secret_ref: Some("AWS_WEB_IDENTITY".into()),
1303                    })]),
1304                }),
1305                telemetry: None,
1306            },
1307        };
1308        assert!(validate_execution_cell_document(&doc).is_err());
1309    }
1310
1311    #[test]
1312    fn rejects_spec_id_with_path_traversal() {
1313        let doc = ExecutionCellDocument {
1314            api_version: "cellos.io/v1".into(),
1315            kind: "ExecutionCell".into(),
1316            spec: ExecutionCellSpec {
1317                id: "../escape".into(),
1318                correlation: None,
1319                ingress: None,
1320                environment: None,
1321                placement: None,
1322                policy: None,
1323                identity: None,
1324                run: None,
1325                authority: AuthorityBundle::default(),
1326                lifetime: Lifetime { ttl_seconds: 60 },
1327                export: None,
1328                telemetry: None,
1329            },
1330        };
1331        assert!(validate_execution_cell_document(&doc).is_err());
1332    }
1333
1334    #[test]
1335    fn rejects_export_artifact_name_with_dotdot() {
1336        let doc = ExecutionCellDocument {
1337            api_version: "cellos.io/v1".into(),
1338            kind: "ExecutionCell".into(),
1339            spec: ExecutionCellSpec {
1340                id: "safe-cell".into(),
1341                correlation: None,
1342                ingress: None,
1343                environment: None,
1344                placement: None,
1345                policy: None,
1346                identity: None,
1347                run: None,
1348                authority: AuthorityBundle::default(),
1349                lifetime: Lifetime { ttl_seconds: 60 },
1350                export: Some(ExportChannels {
1351                    artifacts: Some(vec![ExportArtifact {
1352                        name: "bad..name".into(),
1353                        path: "/tmp/junit.xml".into(),
1354                        target: None,
1355                        content_type: None,
1356                    }]),
1357                    targets: None,
1358                }),
1359                telemetry: None,
1360            },
1361        };
1362        assert!(validate_execution_cell_document(&doc).is_err());
1363    }
1364
1365    #[test]
1366    fn rejects_secret_ref_with_separator() {
1367        let doc = ExecutionCellDocument {
1368            api_version: "cellos.io/v1".into(),
1369            kind: "ExecutionCell".into(),
1370            spec: ExecutionCellSpec {
1371                id: "safe-cell".into(),
1372                correlation: None,
1373                ingress: None,
1374                environment: None,
1375                placement: None,
1376                policy: None,
1377                identity: None,
1378                run: None,
1379                authority: AuthorityBundle {
1380                    filesystem: None,
1381                    network: None,
1382                    egress_rules: None,
1383                    secret_refs: Some(vec!["bad/ref".into()]),
1384                    authority_derivation: None,
1385                    dns_authority: None,
1386                    cdn_authority: None,
1387                },
1388                lifetime: Lifetime { ttl_seconds: 60 },
1389                export: None,
1390                telemetry: None,
1391            },
1392        };
1393        assert!(validate_execution_cell_document(&doc).is_err());
1394    }
1395
1396    #[test]
1397    fn rejects_http_export_target_with_non_http_base_url() {
1398        let doc = ExecutionCellDocument {
1399            api_version: "cellos.io/v1".into(),
1400            kind: "ExecutionCell".into(),
1401            spec: ExecutionCellSpec {
1402                id: "safe-cell".into(),
1403                correlation: None,
1404                ingress: None,
1405                environment: None,
1406                placement: None,
1407                policy: None,
1408                identity: None,
1409                run: None,
1410                authority: AuthorityBundle {
1411                    filesystem: None,
1412                    network: None,
1413                    egress_rules: None,
1414                    secret_refs: Some(vec!["ARTIFACT_API_TOKEN".into()]),
1415                    authority_derivation: None,
1416                    dns_authority: None,
1417                    cdn_authority: None,
1418                },
1419                lifetime: Lifetime { ttl_seconds: 60 },
1420                export: Some(ExportChannels {
1421                    artifacts: Some(vec![ExportArtifact {
1422                        name: "coverage-summary".into(),
1423                        path: "/tmp/coverage.txt".into(),
1424                        target: Some("artifact-api".into()),
1425                        content_type: Some("text/plain".into()),
1426                    }]),
1427                    targets: Some(vec![ExportTarget::Http(HttpExportTarget {
1428                        name: "artifact-api".into(),
1429                        base_url: "ftp://artifacts.example.invalid/upload".into(),
1430                        secret_ref: Some("ARTIFACT_API_TOKEN".into()),
1431                    })]),
1432                }),
1433                telemetry: None,
1434            },
1435        };
1436        assert!(validate_execution_cell_document(&doc).is_err());
1437    }
1438
1439    // ── Property-based tests (SEC-11) ─────────────────────────────────────
1440    // These cover the identifier validation rules systematically so that
1441    // hand-crafted inputs can't be the only signal of correctness.
1442
1443    use super::is_portable_identifier;
1444    use proptest::prelude::*;
1445
1446    fn minimal_doc_with_placement(placement: crate::PlacementSpec) -> ExecutionCellDocument {
1447        ExecutionCellDocument {
1448            api_version: "cellos.io/v1".into(),
1449            kind: "ExecutionCell".into(),
1450            spec: ExecutionCellSpec {
1451                id: "placement-test-cell".into(),
1452                correlation: None,
1453                ingress: None,
1454                environment: None,
1455                placement: Some(placement),
1456                policy: None,
1457                identity: None,
1458                run: None,
1459                authority: AuthorityBundle::default(),
1460                lifetime: Lifetime { ttl_seconds: 60 },
1461                export: None,
1462                telemetry: None,
1463            },
1464        }
1465    }
1466
1467    #[test]
1468    fn rejects_placement_pool_id_with_separator() {
1469        let doc = minimal_doc_with_placement(crate::PlacementSpec {
1470            pool_id: Some("pool/main".into()),
1471            kubernetes_namespace: None,
1472            queue_name: None,
1473        });
1474        let err = validate_execution_cell_document(&doc).unwrap_err();
1475        assert!(err.to_string().contains("spec.placement.poolId"));
1476    }
1477
1478    #[test]
1479    fn rejects_empty_placement_hints() {
1480        let doc = minimal_doc_with_placement(crate::PlacementSpec::default());
1481        let err = validate_execution_cell_document(&doc).unwrap_err();
1482        assert!(err
1483            .to_string()
1484            .contains("spec.placement must set at least one placement hint"));
1485    }
1486
1487    #[test]
1488    fn rejects_placement_queue_name_with_dotdot() {
1489        let doc = minimal_doc_with_placement(crate::PlacementSpec {
1490            pool_id: None,
1491            kubernetes_namespace: None,
1492            queue_name: Some("ci..high".into()),
1493        });
1494        let err = validate_execution_cell_document(&doc).unwrap_err();
1495        assert!(err.to_string().contains("spec.placement.queueName"));
1496    }
1497
1498    #[test]
1499    fn rejects_placement_namespace_with_uppercase() {
1500        let doc = minimal_doc_with_placement(crate::PlacementSpec {
1501            pool_id: None,
1502            kubernetes_namespace: Some("CellOS-Prod".into()),
1503            queue_name: None,
1504        });
1505        let err = validate_execution_cell_document(&doc).unwrap_err();
1506        assert!(err
1507            .to_string()
1508            .contains("spec.placement.kubernetesNamespace"));
1509    }
1510
1511    #[test]
1512    fn accepts_valid_placement_hints() {
1513        let doc = minimal_doc_with_placement(crate::PlacementSpec {
1514            pool_id: Some("runner-pool-amd64".into()),
1515            kubernetes_namespace: Some("cellos-prod".into()),
1516            queue_name: Some("ci-high".into()),
1517        });
1518        assert!(validate_execution_cell_document(&doc).is_ok());
1519    }
1520
1521    proptest! {
1522        /// Any identifier containing a forward slash is rejected.
1523        #[test]
1524        fn prop_rejects_slash_in_identifier(
1525            prefix in "[A-Za-z0-9._-]{0,20}",
1526            suffix in "[A-Za-z0-9._-]{0,20}",
1527        ) {
1528            let with_slash = format!("{prefix}/{suffix}");
1529            prop_assert!(!is_portable_identifier(&with_slash), "slash in {with_slash:?}");
1530        }
1531
1532        /// Any identifier containing a backslash is rejected.
1533        #[test]
1534        fn prop_rejects_backslash_in_identifier(
1535            prefix in "[A-Za-z0-9._-]{0,20}",
1536            suffix in "[A-Za-z0-9._-]{0,20}",
1537        ) {
1538            let with_bs = format!("{prefix}\\{suffix}");
1539            prop_assert!(!is_portable_identifier(&with_bs), "backslash in {with_bs:?}");
1540        }
1541
1542        /// Any identifier containing `..` is rejected regardless of surrounding chars.
1543        #[test]
1544        fn prop_rejects_dotdot_in_identifier(
1545            prefix in "[A-Za-z0-9._-]{0,20}",
1546            suffix in "[A-Za-z0-9._-]{0,20}",
1547        ) {
1548            let with_dotdot = format!("{prefix}..{suffix}");
1549            prop_assert!(!is_portable_identifier(&with_dotdot), ".. in {with_dotdot:?}");
1550        }
1551
1552        /// Any identifier starting with a non-alphanumeric ASCII char is rejected.
1553        #[test]
1554        fn prop_rejects_non_alphanum_first_char(
1555            first in "[._\\-]",
1556            rest in "[A-Za-z0-9._-]{0,20}",
1557        ) {
1558            let s = format!("{first}{rest}");
1559            prop_assert!(!is_portable_identifier(&s), "starts with non-alphanum: {s:?}");
1560        }
1561
1562        /// Valid identifiers (alphanumeric start, safe body, ≤128 chars, no ..) are accepted.
1563        #[test]
1564        fn prop_accepts_valid_identifiers(
1565            first in "[A-Za-z0-9]",
1566            rest in "[A-Za-z0-9._-]{0,60}",
1567        ) {
1568            let s = format!("{first}{rest}");
1569            // Exclude generated strings that happen to contain ".."
1570            prop_assume!(!s.contains(".."));
1571            prop_assert!(is_portable_identifier(&s), "should accept {s:?}");
1572        }
1573
1574        /// Validate that any spec.id failing is_portable_identifier causes validate() to error.
1575        #[test]
1576        fn prop_invalid_spec_id_rejected_by_validate(
1577            prefix in "[A-Za-z0-9._-]{0,10}",
1578            suffix in "[A-Za-z0-9._-]{0,10}",
1579        ) {
1580            // inject a forward slash so it definitely fails the portable identifier check
1581            let bad_id = format!("{prefix}/{suffix}");
1582            let doc = ExecutionCellDocument {
1583                api_version: "cellos.io/v1".into(),
1584                kind: "ExecutionCell".into(),
1585                spec: ExecutionCellSpec {
1586                    id: bad_id.clone(),
1587                    correlation: None,
1588                    ingress: None,
1589                environment: None,
1590                    placement: None,
1591                    policy: None,
1592                    identity: None,
1593                    run: None,
1594                    authority: AuthorityBundle::default(),
1595                    lifetime: Lifetime { ttl_seconds: 60 },
1596                    export: None,
1597                    telemetry: None,
1598                },
1599            };
1600            prop_assert!(
1601                validate_execution_cell_document(&doc).is_err(),
1602                "expected error for id {bad_id:?}"
1603            );
1604        }
1605
1606        /// Validate that spec.run.argv with any empty token causes validate() to error.
1607        #[test]
1608        fn prop_empty_argv_token_rejected(
1609            prefix_args in prop::collection::vec("[A-Za-z0-9/_-]{1,20}", 0..5),
1610            suffix_args in prop::collection::vec("[A-Za-z0-9/_-]{1,20}", 0..5),
1611        ) {
1612            // build argv with an empty string inserted in the middle
1613            let mut argv: Vec<String> = prefix_args;
1614            argv.push(String::new()); // the bad token
1615            argv.extend(suffix_args);
1616
1617            let doc = ExecutionCellDocument {
1618                api_version: "cellos.io/v1".into(),
1619                kind: "ExecutionCell".into(),
1620                spec: ExecutionCellSpec {
1621                    id: "valid-cell".into(),
1622                    correlation: None,
1623                    ingress: None,
1624                environment: None,
1625                    placement: None,
1626                    policy: None,
1627                    identity: None,
1628                    run: Some(RunSpec {
1629                        argv,
1630                        working_directory: None,
1631                        timeout_ms: None,
1632                        limits: None,
1633                        secret_delivery: SecretDeliveryMode::Env,
1634                    }),
1635                    authority: AuthorityBundle::default(),
1636                    lifetime: Lifetime { ttl_seconds: 60 },
1637                    export: None,
1638                    telemetry: None,
1639                },
1640            };
1641            prop_assert!(
1642                validate_execution_cell_document(&doc).is_err(),
1643                "empty argv token should be rejected"
1644            );
1645        }
1646    }
1647
1648    #[test]
1649    fn rejects_http_export_target_missing_egress_rule() {
1650        let doc = ExecutionCellDocument {
1651            api_version: "cellos.io/v1".into(),
1652            kind: "ExecutionCell".into(),
1653            spec: ExecutionCellSpec {
1654                id: "safe-cell".into(),
1655                correlation: None,
1656                ingress: None,
1657                environment: None,
1658                placement: None,
1659                policy: None,
1660                identity: None,
1661                run: None,
1662                authority: AuthorityBundle {
1663                    filesystem: None,
1664                    network: None,
1665                    egress_rules: Some(vec![crate::EgressRule {
1666                        host: "api.github.com".into(),
1667                        port: 443,
1668                        protocol: Some("tls".into()),
1669                        dns_egress_justification: None,
1670                    }]),
1671                    secret_refs: Some(vec!["ARTIFACT_API_TOKEN".into()]),
1672                    authority_derivation: None,
1673                    dns_authority: None,
1674                    cdn_authority: None,
1675                },
1676                lifetime: Lifetime { ttl_seconds: 60 },
1677                export: Some(ExportChannels {
1678                    artifacts: Some(vec![ExportArtifact {
1679                        name: "coverage-summary".into(),
1680                        path: "/tmp/coverage.txt".into(),
1681                        target: Some("artifact-api".into()),
1682                        content_type: Some("text/plain".into()),
1683                    }]),
1684                    targets: Some(vec![ExportTarget::Http(HttpExportTarget {
1685                        name: "artifact-api".into(),
1686                        base_url: "https://artifacts.acme.internal/upload".into(),
1687                        secret_ref: Some("ARTIFACT_API_TOKEN".into()),
1688                    })]),
1689                }),
1690                telemetry: None,
1691            },
1692        };
1693        assert!(validate_execution_cell_document(&doc).is_err());
1694    }
1695
1696    #[test]
1697    fn rejects_run_timeout_longer_than_lifetime() {
1698        let doc = ExecutionCellDocument {
1699            api_version: "cellos.io/v1".into(),
1700            kind: "ExecutionCell".into(),
1701            spec: ExecutionCellSpec {
1702                id: "safe-cell".into(),
1703                correlation: None,
1704                ingress: None,
1705                environment: None,
1706                placement: None,
1707                policy: None,
1708                identity: None,
1709                run: Some(RunSpec {
1710                    argv: vec!["/usr/bin/true".into()],
1711                    working_directory: None,
1712                    timeout_ms: Some(61_000),
1713                    limits: None,
1714                    secret_delivery: SecretDeliveryMode::Env,
1715                }),
1716                authority: AuthorityBundle::default(),
1717                lifetime: Lifetime { ttl_seconds: 60 },
1718                export: None,
1719                telemetry: None,
1720            },
1721        };
1722        assert!(validate_execution_cell_document(&doc).is_err());
1723    }
1724
1725    /// Boundary case for review-2026-04-08 gap #2: `timeout_ms == ttl * 1000`
1726    /// must be accepted (the validator uses `>`, not `>=`) so an operator
1727    /// can declare an honest "use the full TTL as the soft cap" spec.
1728    #[test]
1729    fn accepts_run_timeout_equal_to_lifetime() {
1730        let doc = ExecutionCellDocument {
1731            api_version: "cellos.io/v1".into(),
1732            kind: "ExecutionCell".into(),
1733            spec: ExecutionCellSpec {
1734                id: "safe-cell-boundary".into(),
1735                correlation: None,
1736                ingress: None,
1737                environment: None,
1738                placement: None,
1739                policy: None,
1740                identity: None,
1741                run: Some(RunSpec {
1742                    argv: vec!["/usr/bin/true".into()],
1743                    working_directory: None,
1744                    timeout_ms: Some(60_000),
1745                    limits: None,
1746                    secret_delivery: SecretDeliveryMode::Env,
1747                }),
1748                authority: AuthorityBundle::default(),
1749                lifetime: Lifetime { ttl_seconds: 60 },
1750                export: None,
1751                telemetry: None,
1752            },
1753        };
1754        assert!(
1755            validate_execution_cell_document(&doc).is_ok(),
1756            "timeout_ms == ttl_seconds * 1000 must be accepted (boundary)"
1757        );
1758    }
1759
1760    /// Just-over-the-boundary case for gap #2: `timeout_ms == ttl*1000 + 1`
1761    /// must be rejected. Pins the precision of the comparison so a
1762    /// future refactor that switches to seconds (lossy) cannot pass.
1763    #[test]
1764    fn rejects_run_timeout_one_ms_over_lifetime() {
1765        let doc = ExecutionCellDocument {
1766            api_version: "cellos.io/v1".into(),
1767            kind: "ExecutionCell".into(),
1768            spec: ExecutionCellSpec {
1769                id: "safe-cell-overshoot".into(),
1770                correlation: None,
1771                ingress: None,
1772                environment: None,
1773                placement: None,
1774                policy: None,
1775                identity: None,
1776                run: Some(RunSpec {
1777                    argv: vec!["/usr/bin/true".into()],
1778                    working_directory: None,
1779                    timeout_ms: Some(60_001),
1780                    limits: None,
1781                    secret_delivery: SecretDeliveryMode::Env,
1782                }),
1783                authority: AuthorityBundle::default(),
1784                lifetime: Lifetime { ttl_seconds: 60 },
1785                export: None,
1786                telemetry: None,
1787            },
1788        };
1789        let err = validate_execution_cell_document(&doc).expect_err("must reject");
1790        let msg = format!("{err}");
1791        assert!(
1792            msg.contains("timeoutMs"),
1793            "error must mention timeoutMs; got {msg:?}"
1794        );
1795        assert!(
1796            msg.contains("ttlSeconds"),
1797            "error must mention ttlSeconds; got {msg:?}"
1798        );
1799    }
1800
1801    #[test]
1802    fn accepts_run_limits_and_timeout_within_lifetime() {
1803        let doc = ExecutionCellDocument {
1804            api_version: "cellos.io/v1".into(),
1805            kind: "ExecutionCell".into(),
1806            spec: ExecutionCellSpec {
1807                id: "safe-cell".into(),
1808                correlation: None,
1809                ingress: None,
1810                environment: None,
1811                placement: None,
1812                policy: None,
1813                identity: None,
1814                run: Some(RunSpec {
1815                    argv: vec!["/usr/bin/true".into()],
1816                    working_directory: None,
1817                    timeout_ms: Some(5_000),
1818                    limits: Some(RunLimits {
1819                        memory_max_bytes: Some(268_435_456),
1820                        cpu_max: Some(RunCpuMax {
1821                            quota_micros: 50_000,
1822                            period_micros: Some(100_000),
1823                        }),
1824                        graceful_shutdown_seconds: None,
1825                    }),
1826                    secret_delivery: SecretDeliveryMode::Env,
1827                }),
1828                authority: AuthorityBundle::default(),
1829                lifetime: Lifetime { ttl_seconds: 60 },
1830                export: None,
1831                telemetry: None,
1832            },
1833        };
1834        assert!(validate_execution_cell_document(&doc).is_ok());
1835    }
1836
1837    // ── spec.environment validation ───────────────────────────────────────────
1838
1839    fn minimal_doc_with_env(env: EnvironmentSpec) -> ExecutionCellDocument {
1840        ExecutionCellDocument {
1841            api_version: "cellos.io/v1".into(),
1842            kind: "ExecutionCell".into(),
1843            spec: ExecutionCellSpec {
1844                id: "env-test-cell".into(),
1845                correlation: None,
1846                ingress: None,
1847                environment: Some(env),
1848                placement: None,
1849                policy: None,
1850                identity: None,
1851                run: None,
1852                authority: AuthorityBundle::default(),
1853                lifetime: Lifetime { ttl_seconds: 60 },
1854                export: None,
1855                telemetry: None,
1856            },
1857        }
1858    }
1859
1860    #[test]
1861    fn accepts_environment_with_reference_only() {
1862        let doc = minimal_doc_with_env(EnvironmentSpec {
1863            image_reference: "ubuntu:24.04".into(),
1864            image_digest: None,
1865            template_id: None,
1866        });
1867        assert!(validate_execution_cell_document(&doc).is_ok());
1868    }
1869
1870    #[test]
1871    fn accepts_environment_with_digest_and_template() {
1872        let doc = minimal_doc_with_env(EnvironmentSpec {
1873            image_reference: "ubuntu:24.04".into(),
1874            image_digest: Some(
1875                "sha256:a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2".into(),
1876            ),
1877            template_id: Some("ubuntu-24-04-build".into()),
1878        });
1879        assert!(validate_execution_cell_document(&doc).is_ok());
1880    }
1881
1882    #[test]
1883    fn rejects_environment_with_empty_image_reference() {
1884        let doc = minimal_doc_with_env(EnvironmentSpec {
1885            image_reference: "".into(),
1886            image_digest: None,
1887            template_id: None,
1888        });
1889        let err = validate_execution_cell_document(&doc).unwrap_err();
1890        assert!(
1891            err.to_string().contains("imageReference"),
1892            "expected imageReference in error, got: {err}"
1893        );
1894    }
1895
1896    #[test]
1897    fn rejects_environment_with_bad_digest_missing_prefix() {
1898        let doc = minimal_doc_with_env(EnvironmentSpec {
1899            image_reference: "ubuntu:24.04".into(),
1900            image_digest: Some(
1901                "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2".into(),
1902            ),
1903            template_id: None,
1904        });
1905        let err = validate_execution_cell_document(&doc).unwrap_err();
1906        assert!(err.to_string().contains("imageDigest"), "{err}");
1907    }
1908
1909    #[test]
1910    fn rejects_environment_with_digest_wrong_length() {
1911        let doc = minimal_doc_with_env(EnvironmentSpec {
1912            image_reference: "ubuntu:24.04".into(),
1913            image_digest: Some("sha256:deadbeef".into()),
1914            template_id: None,
1915        });
1916        let err = validate_execution_cell_document(&doc).unwrap_err();
1917        assert!(err.to_string().contains("imageDigest"), "{err}");
1918    }
1919
1920    #[test]
1921    fn rejects_environment_with_uppercase_digest() {
1922        let doc = minimal_doc_with_env(EnvironmentSpec {
1923            image_reference: "ubuntu:24.04".into(),
1924            image_digest: Some(
1925                "sha256:A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2".into(),
1926            ),
1927            template_id: None,
1928        });
1929        let err = validate_execution_cell_document(&doc).unwrap_err();
1930        assert!(err.to_string().contains("imageDigest"), "{err}");
1931    }
1932
1933    #[test]
1934    fn rejects_environment_with_invalid_template_id() {
1935        let doc = minimal_doc_with_env(EnvironmentSpec {
1936            image_reference: "ubuntu:24.04".into(),
1937            image_digest: None,
1938            template_id: Some("../escape".into()),
1939        });
1940        let err = validate_execution_cell_document(&doc).unwrap_err();
1941        assert!(err.to_string().contains("templateId"), "{err}");
1942    }
1943
1944    #[test]
1945    fn is_sha256_digest_accepts_valid() {
1946        assert!(is_sha256_digest(
1947            "sha256:a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
1948        ));
1949    }
1950
1951    #[test]
1952    fn is_sha256_digest_rejects_short() {
1953        assert!(!is_sha256_digest("sha256:deadbeef"));
1954    }
1955
1956    #[test]
1957    fn is_sha256_digest_rejects_no_prefix() {
1958        assert!(!is_sha256_digest(
1959            "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
1960        ));
1961    }
1962
1963    #[test]
1964    fn is_sha256_digest_rejects_uppercase() {
1965        assert!(!is_sha256_digest(
1966            "sha256:A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2"
1967        ));
1968    }
1969
1970    fn derivation_spec_with_authority(
1971        egress: Vec<crate::EgressRule>,
1972        secret_refs: Vec<String>,
1973    ) -> ExecutionCellSpec {
1974        ExecutionCellSpec {
1975            id: "deriv-test".into(),
1976            correlation: None,
1977            ingress: None,
1978            environment: None,
1979            placement: None,
1980            policy: None,
1981            identity: None,
1982            run: None,
1983            authority: AuthorityBundle {
1984                filesystem: None,
1985                network: None,
1986                egress_rules: Some(egress),
1987                secret_refs: Some(secret_refs),
1988                authority_derivation: None,
1989                dns_authority: None,
1990                cdn_authority: None,
1991            },
1992            lifetime: Lifetime { ttl_seconds: 60 },
1993            export: None,
1994            telemetry: None,
1995        }
1996    }
1997
1998    fn token_with_leaf(leaf: crate::AuthorityCapability) -> crate::AuthorityDerivationToken {
1999        crate::AuthorityDerivationToken {
2000            role_root: crate::RoleId("role-test".into()),
2001            parent_run_id: None,
2002            derivation_steps: vec![],
2003            leaf_capability: leaf,
2004            grantor_signature: crate::AuthoritySignature {
2005                algorithm: "ed25519".into(),
2006                bytes: "AA==".into(),
2007            },
2008        }
2009    }
2010
2011    /// Sign `token` with `signing_key`, returning a token whose `grantor_signature.bytes`
2012    /// is the base64-STANDARD encoding of the resulting ED25519 signature over the
2013    /// canonical signing payload.
2014    fn sign_token(
2015        signing_key: &ed25519_dalek::SigningKey,
2016        mut token: crate::AuthorityDerivationToken,
2017    ) -> crate::AuthorityDerivationToken {
2018        use base64::engine::general_purpose::STANDARD;
2019        use base64::Engine as _;
2020        use ed25519_dalek::Signer as _;
2021
2022        let payload = authority_derivation_signing_payload(&token).expect("payload encode");
2023        let signature = signing_key.sign(&payload);
2024        token.grantor_signature.bytes = STANDARD.encode(signature.to_bytes());
2025        token
2026    }
2027
2028    /// Build a deterministic ED25519 signing key from a fixed seed — avoids pulling
2029    /// `rand_core` and `OsRng` into dev-deps just for tests.
2030    fn test_signing_key(seed: u8) -> ed25519_dalek::SigningKey {
2031        ed25519_dalek::SigningKey::from_bytes(&[seed; 32])
2032    }
2033
2034    fn verifying_key_b64(signing_key: &ed25519_dalek::SigningKey) -> String {
2035        use base64::engine::general_purpose::STANDARD;
2036        use base64::Engine as _;
2037        STANDARD.encode(signing_key.verifying_key().to_bytes())
2038    }
2039
2040    #[test]
2041    fn verify_authority_derivation_child_within_parent_passes() {
2042        // Structural pass — signature still required for the new full verify.
2043        let spec = derivation_spec_with_authority(
2044            vec![crate::EgressRule {
2045                host: "api.example.com".into(),
2046                port: 443,
2047                protocol: Some("https".into()),
2048                dns_egress_justification: None,
2049            }],
2050            vec!["api-key".into()],
2051        );
2052        let signing_key = test_signing_key(0x42);
2053        let token = sign_token(
2054            &signing_key,
2055            token_with_leaf(crate::AuthorityCapability {
2056                egress_rules: vec![crate::EgressRule {
2057                    host: "api.example.com".into(),
2058                    port: 443,
2059                    protocol: Some("https".into()),
2060                    dns_egress_justification: None,
2061                }],
2062                secret_refs: vec!["api-key".into()],
2063            }),
2064        );
2065        let mut keys = std::collections::HashMap::new();
2066        keys.insert("role-test".to_string(), verifying_key_b64(&signing_key));
2067        assert!(verify_authority_derivation(&spec, &token, &keys).is_ok());
2068    }
2069
2070    #[test]
2071    fn verify_authority_derivation_child_exceeding_parent_fails_egress() {
2072        let spec = derivation_spec_with_authority(
2073            vec![crate::EgressRule {
2074                host: "api.example.com".into(),
2075                port: 443,
2076                protocol: Some("https".into()),
2077                dns_egress_justification: None,
2078            }],
2079            vec![],
2080        );
2081        let token = token_with_leaf(crate::AuthorityCapability {
2082            egress_rules: vec![crate::EgressRule {
2083                host: "evil.example.com".into(),
2084                port: 443,
2085                protocol: Some("https".into()),
2086                dns_egress_justification: None,
2087            }],
2088            secret_refs: vec![],
2089        });
2090        let keys = std::collections::HashMap::new();
2091        let err = verify_authority_derivation(&spec, &token, &keys).unwrap_err();
2092        assert!(err.to_string().contains("leafCapability"), "{err}");
2093    }
2094
2095    #[test]
2096    fn verify_authority_derivation_child_exceeding_parent_fails_secrets() {
2097        let spec = derivation_spec_with_authority(vec![], vec!["api-key".into()]);
2098        let token = token_with_leaf(crate::AuthorityCapability {
2099            egress_rules: vec![],
2100            secret_refs: vec!["root-token".into()],
2101        });
2102        let keys = std::collections::HashMap::new();
2103        let err = verify_authority_derivation(&spec, &token, &keys).unwrap_err();
2104        assert!(err.to_string().contains("leafCapability"), "{err}");
2105    }
2106
2107    #[test]
2108    fn validate_doc_rejects_spec_with_exceeding_derivation_token() {
2109        let mut spec = derivation_spec_with_authority(
2110            vec![crate::EgressRule {
2111                host: "api.example.com".into(),
2112                port: 443,
2113                protocol: Some("https".into()),
2114                dns_egress_justification: None,
2115            }],
2116            vec![],
2117        );
2118        spec.authority.authority_derivation = Some(token_with_leaf(crate::AuthorityCapability {
2119            egress_rules: vec![crate::EgressRule {
2120                host: "evil.example.com".into(),
2121                port: 443,
2122                protocol: Some("https".into()),
2123                dns_egress_justification: None,
2124            }],
2125            secret_refs: vec![],
2126        }));
2127        let doc = ExecutionCellDocument {
2128            api_version: "cellos.io/v1".into(),
2129            kind: "ExecutionCell".into(),
2130            spec,
2131        };
2132        let err = validate_execution_cell_document(&doc).unwrap_err();
2133        assert!(err.to_string().contains("leafCapability"), "{err}");
2134    }
2135
2136    #[test]
2137    fn validate_doc_accepts_spec_with_valid_derivation_token() {
2138        let mut spec = derivation_spec_with_authority(
2139            vec![crate::EgressRule {
2140                host: "api.example.com".into(),
2141                port: 443,
2142                protocol: Some("https".into()),
2143                dns_egress_justification: None,
2144            }],
2145            vec!["api-key".into()],
2146        );
2147        spec.authority.authority_derivation = Some(token_with_leaf(crate::AuthorityCapability {
2148            egress_rules: vec![crate::EgressRule {
2149                host: "api.example.com".into(),
2150                port: 443,
2151                protocol: Some("https".into()),
2152                dns_egress_justification: None,
2153            }],
2154            secret_refs: vec!["api-key".into()],
2155        }));
2156        let doc = ExecutionCellDocument {
2157            api_version: "cellos.io/v1".into(),
2158            kind: "ExecutionCell".into(),
2159            spec,
2160        };
2161        assert!(validate_execution_cell_document(&doc).is_ok());
2162    }
2163
2164    // ── ED25519 signature verification (L5-14) ─────────────────────────────
2165
2166    fn signed_spec_and_token() -> (
2167        crate::ExecutionCellSpec,
2168        crate::AuthorityDerivationToken,
2169        ed25519_dalek::SigningKey,
2170    ) {
2171        let spec = derivation_spec_with_authority(
2172            vec![crate::EgressRule {
2173                host: "api.example.com".into(),
2174                port: 443,
2175                protocol: Some("https".into()),
2176                dns_egress_justification: None,
2177            }],
2178            vec!["api-key".into()],
2179        );
2180        let signing_key = test_signing_key(0x11);
2181        let token = sign_token(
2182            &signing_key,
2183            token_with_leaf(crate::AuthorityCapability {
2184                egress_rules: vec![crate::EgressRule {
2185                    host: "api.example.com".into(),
2186                    port: 443,
2187                    protocol: Some("https".into()),
2188                    dns_egress_justification: None,
2189                }],
2190                secret_refs: vec!["api-key".into()],
2191            }),
2192        );
2193        (spec, token, signing_key)
2194    }
2195
2196    #[test]
2197    fn test_good_signature_passes() {
2198        let (spec, token, signing_key) = signed_spec_and_token();
2199        let mut keys = std::collections::HashMap::new();
2200        keys.insert("role-test".to_string(), verifying_key_b64(&signing_key));
2201        assert!(verify_authority_derivation(&spec, &token, &keys).is_ok());
2202    }
2203
2204    #[test]
2205    fn test_bad_signature_rejected() {
2206        let (spec, mut token, signing_key) = signed_spec_and_token();
2207        // Tamper: flip the last byte of the base64-decoded signature, then re-encode.
2208        use base64::engine::general_purpose::STANDARD;
2209        use base64::Engine as _;
2210        let mut sig_bytes = STANDARD
2211            .decode(token.grantor_signature.bytes.as_bytes())
2212            .expect("decode original signature");
2213        let last = sig_bytes.len() - 1;
2214        sig_bytes[last] ^= 0x01;
2215        token.grantor_signature.bytes = STANDARD.encode(&sig_bytes);
2216
2217        let mut keys = std::collections::HashMap::new();
2218        keys.insert("role-test".to_string(), verifying_key_b64(&signing_key));
2219        let err = verify_authority_derivation(&spec, &token, &keys).unwrap_err();
2220        match err {
2221            crate::error::CellosError::InvalidSpec(msg) => {
2222                assert!(
2223                    msg.contains("authority derivation signature invalid"),
2224                    "unexpected error message: {msg}"
2225                );
2226            }
2227            other => panic!("expected InvalidSpec, got {other:?}"),
2228        }
2229    }
2230
2231    #[test]
2232    fn test_unknown_role_rejected() {
2233        let (spec, token, _signing_key) = signed_spec_and_token();
2234        let other_key = test_signing_key(0x22);
2235        let mut keys = std::collections::HashMap::new();
2236        // Map only contains a different role — token's role-test is unknown.
2237        keys.insert("role-other".to_string(), verifying_key_b64(&other_key));
2238        let err = verify_authority_derivation(&spec, &token, &keys).unwrap_err();
2239        match err {
2240            crate::error::CellosError::InvalidSpec(msg) => {
2241                assert!(msg.contains("unknown role"), "unexpected message: {msg}");
2242                assert!(
2243                    msg.contains("role-test"),
2244                    "expected role id in message: {msg}"
2245                );
2246            }
2247            other => panic!("expected InvalidSpec, got {other:?}"),
2248        }
2249    }
2250
2251    #[test]
2252    fn test_no_token_passes() {
2253        // Build a doc whose spec has NO authorityDerivation token — it must
2254        // pass validation unchanged regardless of role_keys availability.
2255        let spec = derivation_spec_with_authority(
2256            vec![crate::EgressRule {
2257                host: "api.example.com".into(),
2258                port: 443,
2259                protocol: Some("https".into()),
2260                dns_egress_justification: None,
2261            }],
2262            vec!["api-key".into()],
2263        );
2264        assert!(spec.authority.authority_derivation.is_none());
2265        let doc = ExecutionCellDocument {
2266            api_version: "cellos.io/v1".into(),
2267            kind: "ExecutionCell".into(),
2268            spec,
2269        };
2270        assert!(validate_execution_cell_document(&doc).is_ok());
2271    }
2272
2273    #[test]
2274    fn test_empty_keys_with_token_fails() {
2275        let (spec, token, _signing_key) = signed_spec_and_token();
2276        let keys: std::collections::HashMap<String, String> = std::collections::HashMap::new();
2277        let err = verify_authority_derivation(&spec, &token, &keys).unwrap_err();
2278        match err {
2279            crate::error::CellosError::InvalidSpec(msg) => {
2280                assert!(msg.contains("unknown role"), "unexpected message: {msg}");
2281            }
2282            other => panic!("expected InvalidSpec, got {other:?}"),
2283        }
2284    }
2285
2286    // ── parentRunId scope policy (L5-16) ──────────────────────────────────
2287
2288    #[test]
2289    fn enforce_scope_policy_universal_allowed_when_permissive() {
2290        let (_spec, token, _signing_key) = signed_spec_and_token();
2291        assert!(token.parent_run_id.is_none());
2292        // Permissive mode: WARN-and-pass.
2293        assert!(enforce_derivation_scope_policy(&token, true).is_ok());
2294    }
2295
2296    #[test]
2297    fn enforce_scope_policy_universal_rejected_when_strict() {
2298        let (_spec, token, _signing_key) = signed_spec_and_token();
2299        assert!(token.parent_run_id.is_none());
2300        let err = enforce_derivation_scope_policy(&token, false).unwrap_err();
2301        match err {
2302            crate::error::CellosError::InvalidSpec(msg) => {
2303                assert!(
2304                    msg.contains("parentRunId: null")
2305                        && msg.contains("CELLOS_REQUIRE_SCOPED_DERIVATION_TOKENS"),
2306                    "unexpected message: {msg}"
2307                );
2308            }
2309            other => panic!("expected InvalidSpec, got {other:?}"),
2310        }
2311    }
2312
2313    #[test]
2314    fn enforce_scope_policy_scoped_token_passes_in_either_mode() {
2315        let (_spec, mut token, _signing_key) = signed_spec_and_token();
2316        token.parent_run_id = Some("run-2026-04-25-abc".into());
2317        assert!(enforce_derivation_scope_policy(&token, true).is_ok());
2318        assert!(enforce_derivation_scope_policy(&token, false).is_ok());
2319    }
2320
2321    // ── Fixture generator (documentation, not a CI gate) ──────────────────
2322    //
2323    // Regenerates the signature embedded in
2324    // `contracts/examples/execution-cell-ci-runner-signed.valid.json` and the
2325    // verifying key in `contracts/examples/authority-keys.example.json`.
2326    //
2327    // Run with:
2328    //   cargo test -p cellos-core --lib gen_signed_ci_runner_fixture \
2329    //     -- --ignored --nocapture
2330    //
2331    // Seed: [0x42; 32]  ·  Role: "role-ci-runner"  ·  parentRunId: "run-demo-ref-001"
2332    //
2333    // The leaf capability constructed here MUST stay byte-identical with the
2334    // `leafCapability` JSON object in the fixture file (same fields, same
2335    // values, same serde-camelCase rendering) — otherwise the canonical
2336    // signing payload diverges and the signature stops verifying.
2337    #[test]
2338    #[ignore]
2339    fn gen_signed_ci_runner_fixture() {
2340        let signing_key = test_signing_key(0x42);
2341        let leaf = crate::AuthorityCapability {
2342            egress_rules: vec![
2343                crate::EgressRule {
2344                    host: "api.github.com".into(),
2345                    port: 443,
2346                    protocol: Some("tls".into()),
2347                    dns_egress_justification: None,
2348                },
2349                crate::EgressRule {
2350                    host: "ghcr.io".into(),
2351                    port: 443,
2352                    protocol: Some("tls".into()),
2353                    dns_egress_justification: None,
2354                },
2355                crate::EgressRule {
2356                    host: "artifacts.internal".into(),
2357                    port: 443,
2358                    protocol: Some("tls".into()),
2359                    dns_egress_justification: None,
2360                },
2361                crate::EgressRule {
2362                    host: "dns.internal".into(),
2363                    port: 53,
2364                    protocol: Some("dns-acknowledged".into()),
2365                    dns_egress_justification: Some(
2366                        "Internal resolver at dns.internal required for artifacts.internal hostname resolution; nameserver is operator-controlled and air-gapped from public internet.".into(),
2367                    ),
2368                },
2369            ],
2370            secret_refs: vec!["NPM_TOKEN".into(), "GITHUB_TOKEN".into()],
2371        };
2372        let token = crate::AuthorityDerivationToken {
2373            role_root: crate::RoleId("role-ci-runner".into()),
2374            parent_run_id: Some("run-demo-ref-001".into()),
2375            derivation_steps: vec![],
2376            leaf_capability: leaf,
2377            grantor_signature: crate::AuthoritySignature {
2378                algorithm: "ed25519".into(),
2379                bytes: "AA==".into(),
2380            },
2381        };
2382        let signed = sign_token(&signing_key, token);
2383        eprintln!("FIXTURE seed=0x42 role=role-ci-runner parentRunId=run-demo-ref-001");
2384        eprintln!(
2385            "FIXTURE verifyingKey(b64): {}",
2386            verifying_key_b64(&signing_key)
2387        );
2388        eprintln!(
2389            "FIXTURE grantorSignature(b64): {}",
2390            signed.grantor_signature.bytes
2391        );
2392    }
2393
2394    // ── T13 dnsAuthority / cdnAuthority validation (SEC-20) ───────────────
2395
2396    fn doc_with_authority(authority: AuthorityBundle) -> ExecutionCellDocument {
2397        ExecutionCellDocument {
2398            api_version: "cellos.io/v1".into(),
2399            kind: "ExecutionCell".into(),
2400            spec: ExecutionCellSpec {
2401                id: "dns-cdn-test-cell".into(),
2402                correlation: None,
2403                ingress: None,
2404                environment: None,
2405                placement: None,
2406                policy: None,
2407                identity: None,
2408                run: None,
2409                authority,
2410                lifetime: Lifetime { ttl_seconds: 60 },
2411                export: None,
2412                telemetry: None,
2413            },
2414        }
2415    }
2416
2417    fn full_dns_authority() -> crate::DnsAuthority {
2418        crate::DnsAuthority {
2419            resolvers: vec![crate::DnsResolver {
2420                resolver_id: "internal-doh".into(),
2421                endpoint: "https://1.1.1.1/dns-query".into(),
2422                protocol: crate::DnsResolverProtocol::Doh,
2423                trust_kid: Some("resolver-kid-2026q2".into()),
2424                dnssec: None,
2425            }],
2426            allowed_query_types: vec![crate::DnsQueryType::A, crate::DnsQueryType::AAAA],
2427            hostname_allowlist: vec!["api.example.com".into(), "*.cdn.example.com".into()],
2428            refresh_policy: Some(crate::DnsRefreshPolicy {
2429                min_ttl_seconds: Some(30),
2430                max_stale_seconds: Some(300),
2431                strategy: Some(crate::DnsRefreshStrategy::TtlHonor),
2432            }),
2433            // SEC-21 Phase 3e — keep the test fixture's `dnsAuthority`
2434            // backward-compat shape (rebinding policy unset).
2435            rebinding_policy: None,
2436            block_direct_workload_dns: true,
2437            // SEC-22 Phase 3d — UDP-side kernel block opt-ins default off
2438            // for the spec-validation fixture so existing tests keep their
2439            // pre-3d shape; the nft generator tests below exercise the
2440            // true case.
2441            block_udp_doq: false,
2442            block_udp_http3: false,
2443        }
2444    }
2445
2446    #[test]
2447    fn t13_accepts_full_dns_and_cdn_authority() {
2448        let authority = AuthorityBundle {
2449            filesystem: None,
2450            network: None,
2451            egress_rules: None,
2452            secret_refs: None,
2453            authority_derivation: None,
2454            dns_authority: Some(full_dns_authority()),
2455            cdn_authority: Some(crate::CdnAuthority {
2456                providers: vec![crate::CdnProvider {
2457                    provider_id: "cloudfront".into(),
2458                    hostname_pattern: "*.cdn.example.com".into(),
2459                    accept_fronting: false,
2460                }],
2461            }),
2462        };
2463        let doc = doc_with_authority(authority);
2464        validate_execution_cell_document(&doc).expect("full T13 authority should validate");
2465    }
2466
2467    #[test]
2468    fn t13_rejects_dns_hostname_allowlist_with_ip_literal() {
2469        let mut dns = full_dns_authority();
2470        dns.hostname_allowlist = vec!["10.0.0.1".into()];
2471        let authority = AuthorityBundle {
2472            dns_authority: Some(dns),
2473            ..AuthorityBundle::default()
2474        };
2475        let err = validate_execution_cell_document(&doc_with_authority(authority)).unwrap_err();
2476        assert!(
2477            err.to_string()
2478                .contains("authority.dnsAuthority.hostnameAllowlist"),
2479            "{err}"
2480        );
2481    }
2482
2483    #[test]
2484    fn t13_rejects_dns_hostname_allowlist_with_internal_wildcard() {
2485        let mut dns = full_dns_authority();
2486        // Wildcard must be the leading label only — "api.*.example.com" is not allowed.
2487        dns.hostname_allowlist = vec!["api.*.example.com".into()];
2488        let authority = AuthorityBundle {
2489            dns_authority: Some(dns),
2490            ..AuthorityBundle::default()
2491        };
2492        let err = validate_execution_cell_document(&doc_with_authority(authority)).unwrap_err();
2493        assert!(
2494            err.to_string()
2495                .contains("authority.dnsAuthority.hostnameAllowlist"),
2496            "{err}"
2497        );
2498    }
2499
2500    #[test]
2501    fn t13_rejects_dns_resolver_with_invalid_resolver_id() {
2502        let mut dns = full_dns_authority();
2503        dns.resolvers[0].resolver_id = "../escape".into();
2504        let authority = AuthorityBundle {
2505            dns_authority: Some(dns),
2506            ..AuthorityBundle::default()
2507        };
2508        let err = validate_execution_cell_document(&doc_with_authority(authority)).unwrap_err();
2509        assert!(
2510            err.to_string()
2511                .contains("authority.dnsAuthority.resolvers[].resolverId"),
2512            "{err}"
2513        );
2514    }
2515
2516    #[test]
2517    fn t13_rejects_duplicate_dns_resolver_ids() {
2518        let mut dns = full_dns_authority();
2519        dns.resolvers.push(crate::DnsResolver {
2520            resolver_id: "internal-doh".into(),
2521            endpoint: "https://1.0.0.1/dns-query".into(),
2522            protocol: crate::DnsResolverProtocol::Doh,
2523            trust_kid: None,
2524            dnssec: None,
2525        });
2526        let authority = AuthorityBundle {
2527            dns_authority: Some(dns),
2528            ..AuthorityBundle::default()
2529        };
2530        let err = validate_execution_cell_document(&doc_with_authority(authority)).unwrap_err();
2531        assert!(
2532            err.to_string().contains("duplicates value"),
2533            "expected duplicate resolverId rejection, got: {err}"
2534        );
2535    }
2536
2537    #[test]
2538    fn t13_rejects_refresh_policy_min_ttl_greater_than_max_stale() {
2539        let mut dns = full_dns_authority();
2540        dns.refresh_policy = Some(crate::DnsRefreshPolicy {
2541            min_ttl_seconds: Some(600),
2542            max_stale_seconds: Some(60),
2543            strategy: Some(crate::DnsRefreshStrategy::TtlHonor),
2544        });
2545        let authority = AuthorityBundle {
2546            dns_authority: Some(dns),
2547            ..AuthorityBundle::default()
2548        };
2549        let err = validate_execution_cell_document(&doc_with_authority(authority)).unwrap_err();
2550        assert!(
2551            err.to_string().contains("minTtlSeconds")
2552                && err.to_string().contains("maxStaleSeconds"),
2553            "{err}"
2554        );
2555    }
2556
2557    #[test]
2558    fn t13_rejects_cdn_provider_with_ip_literal_pattern() {
2559        let authority = AuthorityBundle {
2560            cdn_authority: Some(crate::CdnAuthority {
2561                providers: vec![crate::CdnProvider {
2562                    provider_id: "fastly".into(),
2563                    hostname_pattern: "192.168.1.1".into(),
2564                    accept_fronting: false,
2565                }],
2566            }),
2567            ..AuthorityBundle::default()
2568        };
2569        let err = validate_execution_cell_document(&doc_with_authority(authority)).unwrap_err();
2570        assert!(
2571            err.to_string()
2572                .contains("authority.cdnAuthority.providers[].hostnamePattern"),
2573            "{err}"
2574        );
2575    }
2576
2577    #[test]
2578    fn t13_rejects_duplicate_cdn_provider_ids() {
2579        let authority = AuthorityBundle {
2580            cdn_authority: Some(crate::CdnAuthority {
2581                providers: vec![
2582                    crate::CdnProvider {
2583                        provider_id: "cloudfront".into(),
2584                        hostname_pattern: "a.cdn.example.com".into(),
2585                        accept_fronting: false,
2586                    },
2587                    crate::CdnProvider {
2588                        provider_id: "cloudfront".into(),
2589                        hostname_pattern: "b.cdn.example.com".into(),
2590                        accept_fronting: true,
2591                    },
2592                ],
2593            }),
2594            ..AuthorityBundle::default()
2595        };
2596        let err = validate_execution_cell_document(&doc_with_authority(authority)).unwrap_err();
2597        assert!(err.to_string().contains("duplicates value"), "{err}");
2598    }
2599
2600    #[test]
2601    fn t13_dns_resolver_protocol_serialises_kebab_case() {
2602        let resolver = crate::DnsResolver {
2603            resolver_id: "p1".into(),
2604            endpoint: "1.1.1.1:53".into(),
2605            protocol: crate::DnsResolverProtocol::Do53Udp,
2606            trust_kid: None,
2607            dnssec: None,
2608        };
2609        let v = serde_json::to_value(&resolver).unwrap();
2610        assert_eq!(v.get("protocol"), Some(&serde_json::json!("do53-udp")));
2611    }
2612
2613    #[test]
2614    fn t13_dns_authority_roundtrips_through_full_document() {
2615        let raw = r#"{
2616            "apiVersion": "cellos.io/v1",
2617            "kind": "ExecutionCell",
2618            "spec": {
2619                "id": "t13-roundtrip",
2620                "authority": {
2621                    "dnsAuthority": {
2622                        "resolvers": [{
2623                            "resolverId": "internal-doh",
2624                            "endpoint": "https://1.1.1.1/dns-query",
2625                            "protocol": "doh",
2626                            "trustKid": "resolver-kid-2026q2"
2627                        }],
2628                        "allowedQueryTypes": ["A", "AAAA", "HTTPS"],
2629                        "hostnameAllowlist": ["api.example.com", "*.cdn.example.com"],
2630                        "refreshPolicy": {
2631                            "minTtlSeconds": 30,
2632                            "maxStaleSeconds": 300,
2633                            "strategy": "ttl-honor"
2634                        },
2635                        "blockDirectWorkloadDns": true
2636                    },
2637                    "cdnAuthority": {
2638                        "providers": [{
2639                            "providerId": "cloudfront",
2640                            "hostnamePattern": "*.cdn.example.com",
2641                            "acceptFronting": false
2642                        }]
2643                    }
2644                },
2645                "lifetime": { "ttlSeconds": 60 }
2646            }
2647        }"#;
2648        let doc: ExecutionCellDocument =
2649            serde_json::from_str(raw).expect("parse T13 example document");
2650        validate_execution_cell_document(&doc).expect("validation must pass");
2651
2652        let dns = doc.spec.authority.dns_authority.as_ref().unwrap();
2653        assert_eq!(dns.resolvers.len(), 1);
2654        assert!(matches!(
2655            dns.resolvers[0].protocol,
2656            crate::DnsResolverProtocol::Doh
2657        ));
2658        assert!(dns.block_direct_workload_dns);
2659
2660        let cdn = doc.spec.authority.cdn_authority.as_ref().unwrap();
2661        assert_eq!(cdn.providers.len(), 1);
2662        assert!(!cdn.providers[0].accept_fronting);
2663
2664        let serialised = serde_json::to_string(&doc).expect("re-serialise");
2665        let doc2: ExecutionCellDocument =
2666            serde_json::from_str(&serialised).expect("re-parse roundtrip");
2667        assert_eq!(
2668            doc2.spec.authority.dns_authority,
2669            doc.spec.authority.dns_authority
2670        );
2671        assert_eq!(
2672            doc2.spec.authority.cdn_authority,
2673            doc.spec.authority.cdn_authority
2674        );
2675    }
2676
2677    #[test]
2678    fn t13_is_fqdn_or_wildcard_unit_cases() {
2679        assert!(super::is_fqdn_or_wildcard("api.example.com"));
2680        assert!(super::is_fqdn_or_wildcard("*.example.com"));
2681        assert!(super::is_fqdn_or_wildcard("a.b.c.d.example.com"));
2682        assert!(!super::is_fqdn_or_wildcard(""));
2683        assert!(!super::is_fqdn_or_wildcard("example")); // no dot
2684        assert!(!super::is_fqdn_or_wildcard("10.0.0.1")); // IPv4 literal
2685        assert!(!super::is_fqdn_or_wildcard("2001:db8::1")); // IPv6 literal
2686        assert!(!super::is_fqdn_or_wildcard("api.*.example.com")); // mid wildcard
2687        assert!(!super::is_fqdn_or_wildcard("-bad.example.com")); // label starts with '-'
2688        assert!(!super::is_fqdn_or_wildcard("under_score.example.com")); // underscore
2689    }
2690
2691    // ── F4a telemetry admission tests ──────────────────────────────────────
2692
2693    fn telemetry_doc(
2694        events: Vec<String>,
2695        agent_version: &str,
2696        egress: Vec<crate::EgressRule>,
2697    ) -> ExecutionCellDocument {
2698        let authority = AuthorityBundle {
2699            egress_rules: if egress.is_empty() {
2700                None
2701            } else {
2702                Some(egress)
2703            },
2704            ..AuthorityBundle::default()
2705        };
2706        ExecutionCellDocument {
2707            api_version: "cellos.io/v1".into(),
2708            kind: "ExecutionCell".into(),
2709            spec: ExecutionCellSpec {
2710                id: "telemetry-test-cell".into(),
2711                correlation: None,
2712                ingress: None,
2713                environment: None,
2714                placement: None,
2715                policy: None,
2716                identity: None,
2717                run: None,
2718                authority,
2719                lifetime: Lifetime { ttl_seconds: 60 },
2720                export: None,
2721                telemetry: Some(crate::TelemetrySpec {
2722                    channel: crate::TelemetryChannel::VsockCbor,
2723                    events,
2724                    rate_limits: None,
2725                    host_vs_guest_fields: None,
2726                    agent_version: agent_version.into(),
2727                }),
2728            },
2729        }
2730    }
2731
2732    #[test]
2733    fn telemetry_rejects_empty_events() {
2734        let doc = telemetry_doc(vec![], "1.0.0", vec![]);
2735        let err = validate_execution_cell_document(&doc).unwrap_err();
2736        assert!(
2737            err.to_string().contains("spec.telemetry.events"),
2738            "got: {err}"
2739        );
2740    }
2741
2742    #[test]
2743    fn telemetry_rejects_bad_semver() {
2744        let doc = telemetry_doc(vec!["process.spawn".into()], "v1", vec![]);
2745        let err = validate_execution_cell_document(&doc).unwrap_err();
2746        assert!(err.to_string().contains("agentVersion"), "got: {err}");
2747    }
2748
2749    #[test]
2750    fn telemetry_rejects_net_event_without_egress() {
2751        let doc = telemetry_doc(vec!["net.connect.attempt".into()], "1.0.0", vec![]);
2752        let err = validate_execution_cell_document(&doc).unwrap_err();
2753        let msg = err.to_string();
2754        assert!(msg.contains("telemetry_without_egress"), "got: {msg}");
2755    }
2756
2757    #[test]
2758    fn telemetry_accepts_net_event_when_egress_declared() {
2759        let doc = telemetry_doc(
2760            vec!["net.connect.attempt".into()],
2761            "1.0.0",
2762            vec![crate::EgressRule {
2763                host: "api.example.com".into(),
2764                port: 443,
2765                protocol: Some("https".into()),
2766                dns_egress_justification: None,
2767            }],
2768        );
2769        validate_execution_cell_document(&doc).expect("valid telemetry+egress spec");
2770    }
2771
2772    #[test]
2773    fn telemetry_accepts_non_net_event_without_egress() {
2774        let doc = telemetry_doc(vec!["process.spawn".into()], "1.0.0", vec![]);
2775        validate_execution_cell_document(&doc).expect("non-net.* event must not require egress");
2776    }
2777
2778    #[test]
2779    fn telemetry_accepts_prerelease_semver() {
2780        let doc = telemetry_doc(vec!["process.spawn".into()], "1.2.3-rc.1", vec![]);
2781        validate_execution_cell_document(&doc).expect("prerelease semver accepted");
2782    }
2783}
2784
2785#[cfg(test)]
2786mod sec25_envelope_tests {
2787    //! SEC-25 signed trust-keyset envelope verifier tests.
2788    //!
2789    //! All synthetic: deterministic Ed25519 keys, no network, no real keyset
2790    //! material. The inner payload is a minimal byte string — these tests
2791    //! exercise the envelope/digest/signature path, not the inner
2792    //! trust-keyset-v1 schema.
2793    use super::{
2794        sha256_hex_prefixed, verify_signed_trust_keyset_chain, verify_signed_trust_keyset_envelope,
2795    };
2796    use crate::types::{SignedTrustKeysetEnvelope, TrustKeysetSignature};
2797    use base64::engine::general_purpose::URL_SAFE_NO_PAD;
2798    use base64::Engine as _;
2799    use ed25519_dalek::{Signer as _, SigningKey, VerifyingKey};
2800    use std::collections::HashMap;
2801    use std::time::{Duration, SystemTime, UNIX_EPOCH};
2802
2803    fn signing_key(seed: u8) -> SigningKey {
2804        SigningKey::from_bytes(&[seed; 32])
2805    }
2806
2807    /// Build a well-formed envelope with one valid signature. Returns the
2808    /// envelope and the signer's verifying key map (a single kid).
2809    fn make_envelope(
2810        payload_bytes: &[u8],
2811        signer: &SigningKey,
2812        signer_kid: &str,
2813        not_before: Option<String>,
2814        not_after: Option<String>,
2815    ) -> (SignedTrustKeysetEnvelope, HashMap<String, VerifyingKey>) {
2816        let signature = signer.sign(payload_bytes);
2817        let envelope = SignedTrustKeysetEnvelope {
2818            schema_version: "1.0.0".into(),
2819            payload_type: "application/vnd.cellos.trust-keyset-v1+json".into(),
2820            payload: URL_SAFE_NO_PAD.encode(payload_bytes),
2821            signatures: vec![TrustKeysetSignature {
2822                signer_kid: signer_kid.into(),
2823                algorithm: "ed25519".into(),
2824                signature: URL_SAFE_NO_PAD.encode(signature.to_bytes()),
2825                not_before,
2826                not_after,
2827            }],
2828            payload_digest: sha256_hex_prefixed(payload_bytes),
2829            produced_at: "2026-05-01T00:00:00Z".into(),
2830            replaces_envelope_digest: None,
2831            required_signer_count: None,
2832        };
2833        let mut keys = HashMap::new();
2834        keys.insert(signer_kid.to_string(), signer.verifying_key());
2835        (envelope, keys)
2836    }
2837
2838    #[test]
2839    fn verifies_well_formed_envelope_with_synthetic_key() {
2840        let signer = signing_key(7);
2841        let payload = br#"{"schemaVersion":"1.0.0","keysetId":"ks-7","keys":[]}"#;
2842        let (env, keys) = make_envelope(payload, &signer, "kid-active-7", None, None);
2843        let returned = verify_signed_trust_keyset_envelope(&env, &keys, SystemTime::now())
2844            .expect("envelope should verify");
2845        assert_eq!(returned, payload, "verifier must return raw payload bytes");
2846    }
2847
2848    #[test]
2849    fn rejects_payload_digest_mismatch() {
2850        let signer = signing_key(11);
2851        let payload = b"hello-payload";
2852        let (mut env, keys) = make_envelope(payload, &signer, "kid-active-11", None, None);
2853        // Flip a single hex char in the digest — keep prefix valid.
2854        let last_idx = env.payload_digest.len() - 1;
2855        let last_char = env.payload_digest.chars().last().unwrap();
2856        let new_char = if last_char == '0' { '1' } else { '0' };
2857        env.payload_digest
2858            .replace_range(last_idx.., &new_char.to_string());
2859
2860        let err = verify_signed_trust_keyset_envelope(&env, &keys, SystemTime::now())
2861            .expect_err("digest mismatch must fail");
2862        assert!(
2863            format!("{err}").contains("payload digest mismatch"),
2864            "expected digest-mismatch error, got: {err}"
2865        );
2866    }
2867
2868    #[test]
2869    fn rejects_unknown_signer_kid() {
2870        let signer = signing_key(13);
2871        let payload = b"unknown-kid-payload";
2872        let (env, _keys) = make_envelope(payload, &signer, "kid-active-13", None, None);
2873        let empty_keys: HashMap<String, VerifyingKey> = HashMap::new();
2874        let err = verify_signed_trust_keyset_envelope(&env, &empty_keys, SystemTime::now())
2875            .expect_err("unknown kid must fail");
2876        assert!(
2877            format!("{err}").contains("no signature verified"),
2878            "expected no-signature-verified error, got: {err}"
2879        );
2880    }
2881
2882    #[test]
2883    fn rejects_signature_outside_not_after_window() {
2884        let signer = signing_key(17);
2885        let payload = b"window-payload";
2886        // notAfter set well in the past → window check fails.
2887        let (env, keys) = make_envelope(
2888            payload,
2889            &signer,
2890            "kid-active-17",
2891            None,
2892            Some("2000-01-01T00:00:00Z".into()),
2893        );
2894        let err = verify_signed_trust_keyset_envelope(&env, &keys, SystemTime::now())
2895            .expect_err("expired window must fail");
2896        assert!(
2897            format!("{err}").contains("no signature verified"),
2898            "expected no-signature-verified error, got: {err}"
2899        );
2900    }
2901
2902    #[test]
2903    fn rejects_tampered_payload() {
2904        let signer = signing_key(19);
2905        let original = b"original-trust-keyset-payload-bytes";
2906        let (mut env, keys) = make_envelope(original, &signer, "kid-active-19", None, None);
2907
2908        // Tamper: flip one byte in the decoded payload, re-encode, and
2909        // recompute the digest so we exercise the signature check (not the
2910        // digest check) failing.
2911        let mut tampered = original.to_vec();
2912        tampered[0] ^= 0x01;
2913        env.payload = URL_SAFE_NO_PAD.encode(&tampered);
2914        env.payload_digest = sha256_hex_prefixed(&tampered);
2915
2916        let err = verify_signed_trust_keyset_envelope(&env, &keys, SystemTime::now())
2917            .expect_err("tampered payload must fail signature check");
2918        assert!(
2919            format!("{err}").contains("no signature verified"),
2920            "expected signature failure, got: {err}"
2921        );
2922    }
2923
2924    #[test]
2925    fn accepts_when_at_least_one_signature_verifies() {
2926        let signer_good = signing_key(23);
2927        let signer_other = signing_key(29);
2928        let payload = b"multi-sig-payload";
2929
2930        // First signature: bogus kid (verifier's keyring won't know it).
2931        let bad_sig = signer_other.sign(payload);
2932        // Second signature: good kid + good signature.
2933        let good_sig = signer_good.sign(payload);
2934
2935        let env = SignedTrustKeysetEnvelope {
2936            schema_version: "1.0.0".into(),
2937            payload_type: "application/vnd.cellos.trust-keyset-v1+json".into(),
2938            payload: URL_SAFE_NO_PAD.encode(payload),
2939            signatures: vec![
2940                TrustKeysetSignature {
2941                    signer_kid: "kid-unknown-29".into(),
2942                    algorithm: "ed25519".into(),
2943                    signature: URL_SAFE_NO_PAD.encode(bad_sig.to_bytes()),
2944                    not_before: None,
2945                    not_after: None,
2946                },
2947                TrustKeysetSignature {
2948                    signer_kid: "kid-active-23".into(),
2949                    algorithm: "ed25519".into(),
2950                    signature: URL_SAFE_NO_PAD.encode(good_sig.to_bytes()),
2951                    not_before: None,
2952                    not_after: None,
2953                },
2954            ],
2955            payload_digest: sha256_hex_prefixed(payload),
2956            produced_at: "2026-05-01T00:00:00Z".into(),
2957            replaces_envelope_digest: None,
2958            required_signer_count: None,
2959        };
2960
2961        let mut keys = HashMap::new();
2962        // Only the good signer is in the verifier's keyring.
2963        keys.insert("kid-active-23".to_string(), signer_good.verifying_key());
2964
2965        let returned = verify_signed_trust_keyset_envelope(
2966            &env,
2967            &keys,
2968            UNIX_EPOCH + Duration::from_secs(1_800_000_000),
2969        )
2970        .expect("at least one signature should verify");
2971        assert_eq!(returned, payload);
2972    }
2973
2974    // ── Phase 3: multi-signer threshold + envelope chain ────────────────────
2975
2976    /// Build an envelope signed by N distinct signers, with an explicit
2977    /// `required_signer_count`. Returns the envelope and a keyring containing
2978    /// every signer.
2979    fn make_multisig_envelope(
2980        payload_bytes: &[u8],
2981        signers: &[(&str, &SigningKey)],
2982        required_signer_count: Option<u32>,
2983    ) -> (SignedTrustKeysetEnvelope, HashMap<String, VerifyingKey>) {
2984        let signatures = signers
2985            .iter()
2986            .map(|(kid, signer)| {
2987                let sig = signer.sign(payload_bytes);
2988                TrustKeysetSignature {
2989                    signer_kid: (*kid).to_string(),
2990                    algorithm: "ed25519".into(),
2991                    signature: URL_SAFE_NO_PAD.encode(sig.to_bytes()),
2992                    not_before: None,
2993                    not_after: None,
2994                }
2995            })
2996            .collect();
2997
2998        let envelope = SignedTrustKeysetEnvelope {
2999            schema_version: "1.0.0".into(),
3000            payload_type: "application/vnd.cellos.trust-keyset-v1+json".into(),
3001            payload: URL_SAFE_NO_PAD.encode(payload_bytes),
3002            signatures,
3003            payload_digest: sha256_hex_prefixed(payload_bytes),
3004            produced_at: "2026-05-01T00:00:00Z".into(),
3005            replaces_envelope_digest: None,
3006            required_signer_count,
3007        };
3008        let mut keys = HashMap::new();
3009        for (kid, signer) in signers {
3010            keys.insert((*kid).to_string(), signer.verifying_key());
3011        }
3012        (envelope, keys)
3013    }
3014
3015    #[test]
3016    fn verifies_with_two_distinct_signers_when_threshold_2() {
3017        let signer_a = signing_key(41);
3018        let signer_b = signing_key(43);
3019        let payload = b"phase3-multisig-payload-2of2";
3020
3021        let (env, keys) = make_multisig_envelope(
3022            payload,
3023            &[("kid-ops-a", &signer_a), ("kid-ops-b", &signer_b)],
3024            Some(2),
3025        );
3026
3027        let returned = verify_signed_trust_keyset_envelope(&env, &keys, SystemTime::now())
3028            .expect("two distinct signers + threshold 2 should verify");
3029        assert_eq!(returned, payload);
3030    }
3031
3032    #[test]
3033    fn rejects_when_threshold_2_but_only_one_verifies() {
3034        let signer_a = signing_key(47);
3035        let signer_b = signing_key(53);
3036        let payload = b"phase3-only-one-verifies";
3037
3038        // Build a 2-signer envelope but supply ONLY one signer's public key
3039        // in the verifier's keyring. The other signature has no key to
3040        // verify against, so the threshold of 2 is unreachable.
3041        let (env, _full_keys) = make_multisig_envelope(
3042            payload,
3043            &[("kid-ops-a", &signer_a), ("kid-ops-b", &signer_b)],
3044            Some(2),
3045        );
3046        let mut sparse_keys: HashMap<String, VerifyingKey> = HashMap::new();
3047        sparse_keys.insert("kid-ops-a".into(), signer_a.verifying_key());
3048
3049        let err = verify_signed_trust_keyset_envelope(&env, &sparse_keys, SystemTime::now())
3050            .expect_err("threshold 2 with one verifier present must fail");
3051        let msg = format!("{err}");
3052        assert!(
3053            msg.contains("only 1 distinct signers verified, need 2"),
3054            "expected threshold-shortfall error, got: {msg}"
3055        );
3056    }
3057
3058    #[test]
3059    fn counts_distinct_kids_only_duplicate_kid_does_not_inflate() {
3060        // Two signature entries from the same kid count as 1 verifier — they
3061        // do not satisfy a threshold of 2 even when both individually verify.
3062        let signer = signing_key(59);
3063        let payload = b"phase3-duplicate-kid-payload";
3064        let sig_a = signer.sign(payload);
3065        let sig_b = signer.sign(payload);
3066
3067        let env = SignedTrustKeysetEnvelope {
3068            schema_version: "1.0.0".into(),
3069            payload_type: "application/vnd.cellos.trust-keyset-v1+json".into(),
3070            payload: URL_SAFE_NO_PAD.encode(payload),
3071            signatures: vec![
3072                TrustKeysetSignature {
3073                    signer_kid: "kid-ops-only".into(),
3074                    algorithm: "ed25519".into(),
3075                    signature: URL_SAFE_NO_PAD.encode(sig_a.to_bytes()),
3076                    not_before: None,
3077                    not_after: None,
3078                },
3079                TrustKeysetSignature {
3080                    signer_kid: "kid-ops-only".into(),
3081                    algorithm: "ed25519".into(),
3082                    signature: URL_SAFE_NO_PAD.encode(sig_b.to_bytes()),
3083                    not_before: None,
3084                    not_after: None,
3085                },
3086            ],
3087            payload_digest: sha256_hex_prefixed(payload),
3088            produced_at: "2026-05-01T00:00:00Z".into(),
3089            replaces_envelope_digest: None,
3090            required_signer_count: Some(2),
3091        };
3092        let mut keys = HashMap::new();
3093        keys.insert("kid-ops-only".into(), signer.verifying_key());
3094
3095        let err = verify_signed_trust_keyset_envelope(&env, &keys, SystemTime::now())
3096            .expect_err("duplicate kid must not satisfy threshold 2");
3097        let msg = format!("{err}");
3098        assert!(
3099            msg.contains("only 1 distinct signers verified, need 2"),
3100            "expected duplicate-kid threshold failure, got: {msg}"
3101        );
3102    }
3103
3104    #[test]
3105    fn default_threshold_1_preserves_phase1_behavior() {
3106        // An envelope with required_signer_count = None (and one with Some(1))
3107        // both behave identically to Phase 1: at-least-one-signature-verifies.
3108        let signer = signing_key(61);
3109        let payload = b"phase1-default-threshold";
3110
3111        for explicit in [None, Some(1)] {
3112            let (env, keys) =
3113                make_multisig_envelope(payload, &[("kid-default-61", &signer)], explicit);
3114            let returned = verify_signed_trust_keyset_envelope(&env, &keys, SystemTime::now())
3115                .expect("default / explicit-1 threshold should verify");
3116            assert_eq!(
3117                returned, payload,
3118                "raw payload bytes returned (explicit={explicit:?})"
3119            );
3120        }
3121    }
3122
3123    #[test]
3124    fn rejects_when_required_zero_treated_as_one() {
3125        // The schema's `minimum: 1` blocks required_signer_count: 0, but a
3126        // hand-built envelope could still set Some(0). The Rust verifier
3127        // clamps to 1 — so an envelope with zero verifying signatures still
3128        // surfaces the legacy "no signature verified" error rather than
3129        // passing on a zero threshold.
3130        let signer = signing_key(67);
3131        let payload = b"phase3-zero-clamp-payload";
3132        let (env, _keys) = make_multisig_envelope(
3133            payload,
3134            &[("kid-active-67", &signer)],
3135            Some(0), // hand-built envelope tries to bypass threshold
3136        );
3137        // Empty keyring → zero signatures verify; clamped threshold = 1
3138        // surfaces the legacy single-signer error.
3139        let empty: HashMap<String, VerifyingKey> = HashMap::new();
3140        let err = verify_signed_trust_keyset_envelope(&env, &empty, SystemTime::now())
3141            .expect_err("zero threshold must clamp to 1");
3142        let msg = format!("{err}");
3143        assert!(
3144            msg.contains("no signature verified"),
3145            "expected legacy single-signer error after clamping to 1, got: {msg}"
3146        );
3147    }
3148
3149    /// Build a chain envelope with an explicit `replacesEnvelopeDigest` value.
3150    fn make_chain_envelope(
3151        payload_bytes: &[u8],
3152        signer: &SigningKey,
3153        signer_kid: &str,
3154        replaces: Option<String>,
3155    ) -> SignedTrustKeysetEnvelope {
3156        let signature = signer.sign(payload_bytes);
3157        SignedTrustKeysetEnvelope {
3158            schema_version: "1.0.0".into(),
3159            payload_type: "application/vnd.cellos.trust-keyset-v1+json".into(),
3160            payload: URL_SAFE_NO_PAD.encode(payload_bytes),
3161            signatures: vec![TrustKeysetSignature {
3162                signer_kid: signer_kid.into(),
3163                algorithm: "ed25519".into(),
3164                signature: URL_SAFE_NO_PAD.encode(signature.to_bytes()),
3165                not_before: None,
3166                not_after: None,
3167            }],
3168            payload_digest: sha256_hex_prefixed(payload_bytes),
3169            produced_at: "2026-05-01T00:00:00Z".into(),
3170            replaces_envelope_digest: replaces,
3171            required_signer_count: None,
3172        }
3173    }
3174
3175    #[test]
3176    fn chain_with_correct_replaces_envelope_digest_succeeds() {
3177        let signer = signing_key(71);
3178        let payload_old = b"phase3-chain-old-payload";
3179        let payload_new = b"phase3-chain-new-payload";
3180
3181        let prev = make_chain_envelope(payload_old, &signer, "kid-active-71", None);
3182        let next = make_chain_envelope(
3183            payload_new,
3184            &signer,
3185            "kid-active-71",
3186            Some(sha256_hex_prefixed(payload_old)),
3187        );
3188
3189        let mut keys = HashMap::new();
3190        keys.insert("kid-active-71".into(), signer.verifying_key());
3191
3192        let head_payload =
3193            verify_signed_trust_keyset_chain(&[prev, next], &keys, SystemTime::now())
3194                .expect("correctly-chained envelopes should verify");
3195        assert_eq!(head_payload, payload_new);
3196    }
3197
3198    #[test]
3199    fn chain_with_mismatched_replaces_envelope_digest_rejected() {
3200        let signer = signing_key(73);
3201        let payload_old = b"phase3-chain-mismatch-old";
3202        let payload_new = b"phase3-chain-mismatch-new";
3203
3204        let prev = make_chain_envelope(payload_old, &signer, "kid-active-73", None);
3205        // Tamper: point at a DIFFERENT envelope's digest. This is the replay
3206        // / fork attempt the chain verifier defends against.
3207        let bogus_digest = sha256_hex_prefixed(b"this-is-not-the-prior-payload");
3208        let next = make_chain_envelope(payload_new, &signer, "kid-active-73", Some(bogus_digest));
3209
3210        let mut keys = HashMap::new();
3211        keys.insert("kid-active-73".into(), signer.verifying_key());
3212
3213        let err = verify_signed_trust_keyset_chain(&[prev, next], &keys, SystemTime::now())
3214            .expect_err("mismatched replacesEnvelopeDigest must fail");
3215        let msg = format!("{err}");
3216        assert!(
3217            msg.contains("replacesEnvelopeDigest mismatch"),
3218            "expected chain link mismatch error, got: {msg}"
3219        );
3220        assert!(
3221            msg.contains("index 1"),
3222            "expected index 1 (the bad link) in error, got: {msg}"
3223        );
3224    }
3225
3226    #[test]
3227    fn chain_first_envelope_without_replaces_accepted_as_genesis() {
3228        // Genesis envelope (index 0) MAY omit replacesEnvelopeDigest. A
3229        // single-element chain is the trivial case — only the genesis is
3230        // present, so no link checks fire.
3231        let signer = signing_key(79);
3232        let payload = b"phase3-chain-genesis-only";
3233
3234        let genesis = make_chain_envelope(payload, &signer, "kid-active-79", None);
3235        let mut keys = HashMap::new();
3236        keys.insert("kid-active-79".into(), signer.verifying_key());
3237
3238        let head_payload = verify_signed_trust_keyset_chain(&[genesis], &keys, SystemTime::now())
3239            .expect("genesis-only chain should verify");
3240        assert_eq!(head_payload, payload);
3241    }
3242
3243    #[test]
3244    fn chain_returns_head_payload_bytes() {
3245        // A 3-envelope chain returns ONLY the HEAD's raw payload bytes — not
3246        // any intermediate's payload. This is the core contract.
3247        let signer = signing_key(83);
3248        let p0 = b"phase3-head-bytes-genesis";
3249        let p1 = b"phase3-head-bytes-middle";
3250        let p2 = b"phase3-head-bytes-HEAD-distinct";
3251
3252        let env0 = make_chain_envelope(p0, &signer, "kid-active-83", None);
3253        let env1 = make_chain_envelope(p1, &signer, "kid-active-83", Some(sha256_hex_prefixed(p0)));
3254        let env2 = make_chain_envelope(p2, &signer, "kid-active-83", Some(sha256_hex_prefixed(p1)));
3255
3256        let mut keys = HashMap::new();
3257        keys.insert("kid-active-83".into(), signer.verifying_key());
3258
3259        let head_payload =
3260            verify_signed_trust_keyset_chain(&[env0, env1, env2], &keys, SystemTime::now())
3261                .expect("3-envelope chain should verify");
3262        assert_eq!(
3263            head_payload, p2,
3264            "verifier must return HEAD payload, not earlier links"
3265        );
3266    }
3267
3268    #[test]
3269    fn chain_empty_rejected() {
3270        let keys: HashMap<String, VerifyingKey> = HashMap::new();
3271        let err = verify_signed_trust_keyset_chain(&[], &keys, SystemTime::now())
3272            .expect_err("empty chain must be rejected");
3273        let msg = format!("{err}");
3274        assert!(
3275            msg.contains("empty chain"),
3276            "expected empty-chain error, got: {msg}"
3277        );
3278    }
3279
3280    #[test]
3281    fn chain_propagates_threshold_failure_per_envelope() {
3282        // A chain of two envelopes where the SECOND envelope sets
3283        // requiredSignerCount=2 but only carries one valid signature should
3284        // fail at the per-envelope threshold step — the chain verifier MUST
3285        // surface that as an indexed envelope failure (not as a digest-link
3286        // failure).
3287        let signer_a = signing_key(89);
3288        let signer_b = signing_key(97);
3289        let p0 = b"phase3-threshold-prop-genesis";
3290        let p1 = b"phase3-threshold-prop-head";
3291
3292        let env0 = make_chain_envelope(p0, &signer_a, "kid-ops-a", None);
3293        // env1 declares required=2 but only signer_a signs — only one
3294        // distinct signer can verify.
3295        let mut env1 =
3296            make_chain_envelope(p1, &signer_a, "kid-ops-a", Some(sha256_hex_prefixed(p0)));
3297        env1.required_signer_count = Some(2);
3298
3299        let mut keys = HashMap::new();
3300        keys.insert("kid-ops-a".into(), signer_a.verifying_key());
3301        keys.insert("kid-ops-b".into(), signer_b.verifying_key());
3302
3303        let err = verify_signed_trust_keyset_chain(&[env0, env1], &keys, SystemTime::now())
3304            .expect_err("env1 threshold-2 with one signer must fail");
3305        let msg = format!("{err}");
3306        assert!(
3307            msg.contains("envelope at index 1 failed verification"),
3308            "expected indexed envelope failure, got: {msg}"
3309        );
3310        assert!(
3311            msg.contains("only 1 distinct signers verified, need 2"),
3312            "expected per-envelope threshold message in chain error, got: {msg}"
3313        );
3314    }
3315}