1use std::collections::{BTreeMap, BTreeSet};
2
3use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
4use ed25519_dalek::{Signature, Signer as _, SigningKey, Verifier as _, VerifyingKey};
5use http::Uri;
6use schemars::JsonSchema;
7use serde::{Deserialize, Serialize};
8use utoipa::ToSchema;
9
10use crate::extraction_input_digest;
11
12use super::{
13 DeliveryEffects, DeliveryIssue, DeliveryIssueCode, ReleaseSignerStatus, ReleaseTrustProvider,
14 ServiceRelease, issue, service_release_integrity_is_valid,
15};
16
17pub const EDGE_CONTRACT_PROTOCOL: &str = "lenso.edge-contract.v1";
18pub const GATEWAY_PLAN_PROTOCOL: &str = "lenso.gateway-plan.v1";
19pub const GATEWAY_OBSERVATION_PROTOCOL: &str = "lenso.gateway-observation.v1";
20
21pub trait GatewayObservationProvider: std::fmt::Debug + Send + Sync {
22 fn provider_id(&self) -> &str;
23
24 fn sign(&self, observation_id: &str) -> Option<String>;
25
26 fn verify(&self, observation_id: &str, proof: &str) -> bool;
27}
28
29#[derive(Debug, Clone)]
30pub struct DeterministicGatewayObservationProvider {
31 provider_id: String,
32 key: String,
33}
34
35impl DeterministicGatewayObservationProvider {
36 #[must_use]
37 pub fn new(provider_id: impl Into<String>, key: impl Into<String>) -> Self {
38 Self {
39 provider_id: provider_id.into(),
40 key: key.into(),
41 }
42 }
43
44 fn expected_proof(&self, observation_id: &str) -> String {
45 digest_json(&(
46 "lenso.gateway-observation-authority-proof.v1",
47 self.provider_id.as_str(),
48 observation_id,
49 self.key.as_str(),
50 ))
51 }
52}
53
54impl GatewayObservationProvider for DeterministicGatewayObservationProvider {
55 fn provider_id(&self) -> &str {
56 &self.provider_id
57 }
58
59 fn sign(&self, observation_id: &str) -> Option<String> {
60 Some(self.expected_proof(observation_id))
61 }
62
63 fn verify(&self, observation_id: &str, proof: &str) -> bool {
64 self.expected_proof(observation_id) == proof
65 }
66}
67
68#[derive(Debug, Clone)]
70pub struct Ed25519GatewayObservationProvider {
71 provider_id: String,
72 public_key: VerifyingKey,
73 signing_key: Option<SigningKey>,
74}
75
76impl Ed25519GatewayObservationProvider {
77 pub fn from_base64_public_key(
78 provider_id: impl Into<String>,
79 encoded: &str,
80 ) -> Result<Self, String> {
81 let bytes = BASE64
82 .decode(encoded)
83 .map_err(|error| format!("invalid Ed25519 public key encoding: {error}"))?;
84 let bytes: [u8; 32] = bytes
85 .try_into()
86 .map_err(|_| "Ed25519 public keys must contain exactly 32 bytes".to_owned())?;
87 let public_key = VerifyingKey::from_bytes(&bytes)
88 .map_err(|error| format!("invalid Ed25519 public key: {error}"))?;
89 Ok(Self {
90 provider_id: provider_id.into(),
91 public_key,
92 signing_key: None,
93 })
94 }
95
96 pub fn from_base64_private_key(
97 provider_id: impl Into<String>,
98 encoded: &str,
99 ) -> Result<Self, String> {
100 let bytes = BASE64
101 .decode(encoded)
102 .map_err(|error| format!("invalid Ed25519 private key encoding: {error}"))?;
103 let bytes: [u8; 32] = bytes
104 .try_into()
105 .map_err(|_| "Ed25519 private keys must contain exactly 32 bytes".to_owned())?;
106 let signing_key = SigningKey::from_bytes(&bytes);
107 Ok(Self {
108 provider_id: provider_id.into(),
109 public_key: signing_key.verifying_key(),
110 signing_key: Some(signing_key),
111 })
112 }
113}
114
115impl GatewayObservationProvider for Ed25519GatewayObservationProvider {
116 fn provider_id(&self) -> &str {
117 &self.provider_id
118 }
119
120 fn sign(&self, observation_id: &str) -> Option<String> {
121 self.signing_key
122 .as_ref()
123 .map(|key| BASE64.encode(key.sign(observation_id.as_bytes()).to_bytes()))
124 }
125
126 fn verify(&self, observation_id: &str, proof: &str) -> bool {
127 let Ok(bytes) = BASE64.decode(proof) else {
128 return false;
129 };
130 let Ok(signature) = Signature::from_slice(&bytes) else {
131 return false;
132 };
133 self.public_key
134 .verify(observation_id.as_bytes(), &signature)
135 .is_ok()
136 }
137}
138
139#[derive(
140 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, ToSchema,
141)]
142#[serde(rename_all = "snake_case")]
143pub enum EdgeOperationVisibility {
144 PublicEligible,
145 Internal,
146}
147
148#[derive(
149 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, ToSchema,
150)]
151#[serde(rename_all = "camelCase")]
152pub struct EdgeServiceOperation {
153 pub contract_id: String,
154 pub contract_version: String,
155 pub contract_digest: String,
156 pub operation_id: String,
157 pub visibility: EdgeOperationVisibility,
158 pub request_schema_reference: String,
159 pub response_schema_reference: String,
160}
161
162#[derive(
163 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, ToSchema,
164)]
165#[serde(rename_all = "snake_case")]
166pub enum EdgeAuthentication {
167 Public,
168 Workload,
169 User,
170 WorkloadOrUser,
171}
172
173#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
174#[serde(rename_all = "camelCase")]
175pub struct CorsIntent {
176 #[serde(default)]
177 pub allowed_origins: Vec<String>,
178 #[serde(default)]
179 pub allowed_methods: Vec<String>,
180}
181
182#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
183#[serde(rename_all = "camelCase")]
184pub struct RateIntent {
185 pub requests: u32,
186 pub window_seconds: u32,
187}
188
189#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
190#[serde(rename_all = "camelCase")]
191pub struct EdgeRoute {
192 pub contract_id: String,
193 pub contract_version: String,
194 pub operation_id: String,
195 pub public_path: String,
196 pub authentication: EdgeAuthentication,
197 pub cors: CorsIntent,
198 pub rate: RateIntent,
199 pub deprecated: bool,
200}
201
202#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
203#[serde(rename_all = "camelCase")]
204pub struct ResolvedEdgeRoute {
205 pub contract_id: String,
206 pub contract_version: String,
207 pub operation_id: String,
208 pub public_path: String,
209 pub authentication: EdgeAuthentication,
210 pub cors: CorsIntent,
211 pub rate: RateIntent,
212 pub deprecated: bool,
213 pub request_schema_reference: String,
214 pub response_schema_reference: String,
215}
216
217#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
218#[serde(rename_all = "camelCase")]
219pub struct EdgeContract {
220 pub protocol: String,
221 pub edge_contract_id: String,
222 pub edge_contract_digest: String,
223 pub service_id: String,
224 pub release_id: String,
225 pub release_digest: String,
226 pub operation_catalog_digest: String,
227 pub provider_id: String,
228 pub provider_proof: String,
229 pub routes: Vec<ResolvedEdgeRoute>,
230}
231
232#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
233#[serde(rename_all = "camelCase")]
234pub struct GatewayEnvironmentBinding {
235 pub environment: String,
236 pub gateway_adapter: String,
237 pub public_origin: String,
238 pub expected_gateway_revision: u64,
239}
240
241#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
242#[serde(rename_all = "camelCase")]
243pub struct GatewayObservation {
244 pub protocol: String,
245 pub observation_id: String,
246 pub plan_id: String,
247 pub plan_digest: String,
248 pub environment: String,
249 pub release_id: String,
250 pub release_digest: String,
251 pub resource_uid: String,
252 pub resource_version: String,
253 pub authority_context: String,
254 pub configuration_identity: String,
255 pub revision: u64,
256 pub observed_after: String,
257 pub fresh: bool,
258 pub provider_id: String,
259 pub provider_proof: String,
260}
261
262#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
263#[serde(rename_all = "camelCase")]
264pub struct GatewayPlanDiffEntry {
265 pub subject: String,
266 #[serde(default, skip_serializing_if = "Option::is_none")]
267 pub before: Option<String>,
268 #[serde(default, skip_serializing_if = "Option::is_none")]
269 pub after: Option<String>,
270}
271
272#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
273#[serde(rename_all = "camelCase")]
274pub struct GatewayConfigurationPlan {
275 pub protocol: String,
276 pub plan_id: String,
277 pub plan_digest: String,
278 pub edge_contract_id: String,
279 pub edge_contract_digest: String,
280 pub edge_release_id: String,
281 pub edge_release_digest: String,
282 pub operation_catalog_digest: String,
283 pub edge_provider_id: String,
284 pub edge_provider_proof: String,
285 pub environment: String,
286 pub gateway_adapter: String,
287 pub public_origin: String,
288 pub expected_gateway_revision: u64,
289 pub configuration_identity: String,
290 pub routes: Vec<ResolvedEdgeRoute>,
291 pub diff: Vec<GatewayPlanDiffEntry>,
292 pub drifted: bool,
293 pub issues: Vec<DeliveryIssue>,
294 pub next_actions: Vec<String>,
295 pub effects: DeliveryEffects,
296}
297
298pub fn build_edge_contract(
299 release: &ServiceRelease,
300 available_operations: &[EdgeServiceOperation],
301 provider_id: &str,
302 provider: &dyn ReleaseTrustProvider,
303 mut routes: Vec<EdgeRoute>,
304) -> Result<EdgeContract, Vec<DeliveryIssue>> {
305 let service_id = release.service_id.clone();
306 let mut canonical_operations = available_operations.to_vec();
307 canonical_operations.sort();
308 let operation_catalog_digest = digest_json(&(
309 "lenso.service-operation-catalog.v1",
310 release.release_id.as_str(),
311 release.release_digest.as_str(),
312 canonical_operations.as_slice(),
313 ));
314 routes.sort_by(|left, right| {
315 (
316 &left.public_path,
317 &left.operation_id,
318 &left.contract_version,
319 )
320 .cmp(&(
321 &right.public_path,
322 &right.operation_id,
323 &right.contract_version,
324 ))
325 });
326 let operations = available_operations
327 .iter()
328 .map(|operation| {
329 (
330 (
331 operation.contract_id.as_str(),
332 operation.contract_version.as_str(),
333 operation.operation_id.as_str(),
334 ),
335 operation,
336 )
337 })
338 .collect::<BTreeMap<_, _>>();
339 let mut issues = Vec::new();
340 let operation_keys = available_operations
341 .iter()
342 .map(|operation| {
343 (
344 operation.contract_id.as_str(),
345 operation.contract_version.as_str(),
346 operation.operation_id.as_str(),
347 )
348 })
349 .collect::<BTreeSet<_>>();
350 if !service_release_integrity_is_valid(release)
351 || provider_id.trim().is_empty()
352 || operation_keys.len() != available_operations.len()
353 || available_operations.is_empty()
354 || available_operations.iter().any(|operation| {
355 !release.contract_versions.iter().any(|contract| {
356 contract.contract_id == operation.contract_id
357 && contract.version == operation.contract_version
358 && contract.artifact.digest == operation.contract_digest
359 })
360 })
361 {
362 issues.push(issue(
363 DeliveryIssueCode::EdgeOperationUnknown,
364 "The Service operation catalog is not a trusted, release-bound projection of its exact Contract artifacts.",
365 "Project all operations from the immutable Service Contract set through a trusted provider.",
366 "Refresh the operation catalog for the exact Service Release.",
367 ));
368 }
369 let mut paths = BTreeSet::new();
370 let mut resolved = Vec::new();
371 for route in routes {
372 if !public_path_template_is_safe(&route.public_path)
373 || route.rate.requests == 0
374 || route.rate.window_seconds == 0
375 || !cors_intent_is_safe(&route.cors)
376 {
377 issues.push(issue(
378 DeliveryIssueCode::EdgeExposureUnsafe,
379 format!(
380 "Edge route `{}` has an invalid public path, CORS intent, or rate intent.",
381 route.operation_id
382 ),
383 "Declare an absolute path, HTTP(S) origins, standard uppercase methods, and a positive bounded rate intent.",
384 "Correct the Edge route and generate it again.",
385 ));
386 continue;
387 }
388 if !paths.insert(route.public_path.clone()) {
389 issues.push(issue(
390 DeliveryIssueCode::EdgePathConflict,
391 format!(
392 "Public path `{}` is declared more than once.",
393 route.public_path
394 ),
395 "Assign one explicit Service Contract operation to each public path.",
396 "Resolve the path conflict and generate the Edge Contract again.",
397 ));
398 continue;
399 }
400 let key = (
401 route.contract_id.as_str(),
402 route.contract_version.as_str(),
403 route.operation_id.as_str(),
404 );
405 let Some(operation) = operations.get(&key) else {
406 issues.push(issue(
407 DeliveryIssueCode::EdgeOperationUnknown,
408 format!(
409 "Edge route `{}` does not reference an exact Service Contract operation.",
410 route.operation_id
411 ),
412 "Reference an operation and version from the authoritative Service Contract.",
413 "Correct the operation reference and generate the Edge Contract again.",
414 ));
415 continue;
416 };
417 if operation.visibility != EdgeOperationVisibility::PublicEligible {
418 issues.push(issue(
419 DeliveryIssueCode::EdgeExposureUnsafe,
420 format!("Operation `{}` is internal and cannot be exposed.", route.operation_id),
421 "Keep administration, Story feeds, health internals, and Workload management private.",
422 "Select an explicitly public-eligible Service Contract operation.",
423 ));
424 continue;
425 }
426 let request_schema_reference = operation.request_schema_reference.clone();
427 let response_schema_reference = operation.response_schema_reference.clone();
428 resolved.push(ResolvedEdgeRoute {
429 contract_id: route.contract_id,
430 contract_version: route.contract_version,
431 operation_id: route.operation_id,
432 public_path: route.public_path,
433 authentication: route.authentication,
434 cors: route.cors,
435 rate: route.rate,
436 deprecated: route.deprecated,
437 request_schema_reference,
438 response_schema_reference,
439 });
440 }
441 if service_id.trim().is_empty() || resolved.is_empty() {
442 issues.push(issue(
443 DeliveryIssueCode::EdgeExposureUnsafe,
444 "An Edge Contract requires a Service identity and at least one valid public operation.",
445 "Declare only intended public operations from an authoritative Service Contract.",
446 "Correct the Edge Contract input and generate it again.",
447 ));
448 }
449 if !issues.is_empty() {
450 return Err(issues);
451 }
452 let authority_subject = edge_authority_subject(
453 release.release_id.as_str(),
454 release.release_digest.as_str(),
455 operation_catalog_digest.as_str(),
456 resolved.as_slice(),
457 );
458 let Some(provider_proof) = provider.sign(provider_id, &authority_subject) else {
459 return Err(vec![issue(
460 DeliveryIssueCode::EdgeOperationUnknown,
461 "The selected Edge authority provider is not trusted for the resolved public routes.",
462 "Use a configured provider that attests the exact release, operation catalog, and resolved routes.",
463 "Configure the provider and generate the Edge Contract again.",
464 )]);
465 };
466 let edge_contract_digest = digest_json(&(
467 EDGE_CONTRACT_PROTOCOL,
468 service_id.as_str(),
469 release.release_id.as_str(),
470 release.release_digest.as_str(),
471 operation_catalog_digest.as_str(),
472 provider_id,
473 provider_proof.as_str(),
474 resolved.as_slice(),
475 ));
476 Ok(EdgeContract {
477 protocol: EDGE_CONTRACT_PROTOCOL.to_owned(),
478 edge_contract_id: format!("edge-contract:{edge_contract_digest}"),
479 edge_contract_digest,
480 service_id,
481 release_id: release.release_id.clone(),
482 release_digest: release.release_digest.clone(),
483 operation_catalog_digest,
484 provider_id: provider_id.to_owned(),
485 provider_proof,
486 routes: resolved,
487 })
488}
489
490pub fn plan_gateway_configuration(
491 edge: &EdgeContract,
492 provider: &dyn ReleaseTrustProvider,
493 binding: &GatewayEnvironmentBinding,
494 observed: Option<&GatewayObservation>,
495 observation_provider: &dyn GatewayObservationProvider,
496) -> Result<GatewayConfigurationPlan, Vec<DeliveryIssue>> {
497 if !edge_contract_authority_is_valid(edge, provider)
498 || binding.environment.trim().is_empty()
499 || binding.gateway_adapter.trim().is_empty()
500 || !cors_origin_is_safe(&binding.public_origin)
501 || observed.is_some_and(|observation| {
502 !gateway_observation_integrity_is_valid(observation, observation_provider)
503 })
504 {
505 return Err(vec![issue(
506 DeliveryIssueCode::EdgeExposureUnsafe,
507 "Gateway planning requires an integrity-valid Edge Contract and explicit environment binding.",
508 "Correct the Edge Contract, adapter identity, and public origin.",
509 "Regenerate the Gateway plan before mutation.",
510 )]);
511 }
512 let configuration_identity = digest_json(&(
513 edge.edge_contract_digest.as_str(),
514 edge.release_id.as_str(),
515 edge.release_digest.as_str(),
516 edge.operation_catalog_digest.as_str(),
517 edge.provider_id.as_str(),
518 edge.provider_proof.as_str(),
519 binding.environment.as_str(),
520 binding.gateway_adapter.as_str(),
521 binding.public_origin.as_str(),
522 edge.routes.as_slice(),
523 ));
524 let drifted = observed.is_some_and(|observation| {
525 observation.configuration_identity != configuration_identity
526 || observation.revision != binding.expected_gateway_revision
527 || !observation.fresh
528 });
529 let diff = observed
530 .filter(|observation| observation.configuration_identity != configuration_identity)
531 .map(|observation| {
532 vec![GatewayPlanDiffEntry {
533 subject: "gateway.configurationIdentity".to_owned(),
534 before: Some(observation.configuration_identity.clone()),
535 after: Some(configuration_identity.clone()),
536 }]
537 })
538 .unwrap_or_default();
539 let issues = Vec::new();
540 let next_actions =
541 vec!["Review and apply this plan through the selected Gateway Adapter.".to_owned()];
542 let effects = DeliveryEffects::default();
543 let plan_digest = digest_json(&(
544 GATEWAY_PLAN_PROTOCOL,
545 edge.edge_contract_id.as_str(),
546 edge.edge_contract_digest.as_str(),
547 edge.release_id.as_str(),
548 edge.release_digest.as_str(),
549 edge.operation_catalog_digest.as_str(),
550 edge.provider_id.as_str(),
551 edge.provider_proof.as_str(),
552 binding,
553 configuration_identity.as_str(),
554 edge.routes.as_slice(),
555 diff.as_slice(),
556 drifted,
557 issues.as_slice(),
558 next_actions.as_slice(),
559 &effects,
560 ));
561 Ok(GatewayConfigurationPlan {
562 protocol: GATEWAY_PLAN_PROTOCOL.to_owned(),
563 plan_id: format!("gateway-plan:{plan_digest}"),
564 plan_digest,
565 edge_contract_id: edge.edge_contract_id.clone(),
566 edge_contract_digest: edge.edge_contract_digest.clone(),
567 edge_release_id: edge.release_id.clone(),
568 edge_release_digest: edge.release_digest.clone(),
569 operation_catalog_digest: edge.operation_catalog_digest.clone(),
570 edge_provider_id: edge.provider_id.clone(),
571 edge_provider_proof: edge.provider_proof.clone(),
572 environment: binding.environment.clone(),
573 gateway_adapter: binding.gateway_adapter.clone(),
574 public_origin: binding.public_origin.clone(),
575 expected_gateway_revision: binding.expected_gateway_revision,
576 configuration_identity,
577 routes: edge.routes.clone(),
578 diff,
579 drifted,
580 issues,
581 next_actions,
582 effects,
583 })
584}
585
586#[must_use]
587pub fn edge_contract_integrity_is_valid(edge: &EdgeContract) -> bool {
588 edge.protocol == EDGE_CONTRACT_PROTOCOL
589 && edge.routes.iter().all(resolved_edge_route_is_safe)
590 && edge.edge_contract_id == format!("edge-contract:{}", edge.edge_contract_digest)
591 && digest_json(&(
592 edge.protocol.as_str(),
593 edge.service_id.as_str(),
594 edge.release_id.as_str(),
595 edge.release_digest.as_str(),
596 edge.operation_catalog_digest.as_str(),
597 edge.provider_id.as_str(),
598 edge.provider_proof.as_str(),
599 edge.routes.as_slice(),
600 )) == edge.edge_contract_digest
601}
602
603#[must_use]
604pub fn edge_contract_authority_is_valid(
605 edge: &EdgeContract,
606 provider: &dyn ReleaseTrustProvider,
607) -> bool {
608 edge_contract_integrity_is_valid(edge)
609 && provider.verify(
610 edge.provider_id.as_str(),
611 edge_authority_subject(
612 edge.release_id.as_str(),
613 edge.release_digest.as_str(),
614 edge.operation_catalog_digest.as_str(),
615 edge.routes.as_slice(),
616 )
617 .as_str(),
618 edge.provider_proof.as_str(),
619 ) == ReleaseSignerStatus::Trusted
620}
621
622#[must_use]
623pub fn gateway_plan_integrity_is_valid(plan: &GatewayConfigurationPlan) -> bool {
624 let binding = GatewayEnvironmentBinding {
625 environment: plan.environment.clone(),
626 gateway_adapter: plan.gateway_adapter.clone(),
627 public_origin: plan.public_origin.clone(),
628 expected_gateway_revision: plan.expected_gateway_revision,
629 };
630 plan.protocol == GATEWAY_PLAN_PROTOCOL
631 && cors_origin_is_safe(&plan.public_origin)
632 && plan.routes.iter().all(resolved_edge_route_is_safe)
633 && plan.plan_id == format!("gateway-plan:{}", plan.plan_digest)
634 && digest_json(&(
635 plan.protocol.as_str(),
636 plan.edge_contract_id.as_str(),
637 plan.edge_contract_digest.as_str(),
638 plan.edge_release_id.as_str(),
639 plan.edge_release_digest.as_str(),
640 plan.operation_catalog_digest.as_str(),
641 plan.edge_provider_id.as_str(),
642 plan.edge_provider_proof.as_str(),
643 &binding,
644 plan.configuration_identity.as_str(),
645 plan.routes.as_slice(),
646 plan.diff.as_slice(),
647 plan.drifted,
648 plan.issues.as_slice(),
649 plan.next_actions.as_slice(),
650 &plan.effects,
651 )) == plan.plan_digest
652}
653
654fn resolved_edge_route_is_safe(route: &ResolvedEdgeRoute) -> bool {
655 public_path_template_is_safe(&route.public_path)
656 && route.rate.requests > 0
657 && route.rate.window_seconds > 0
658 && cors_intent_is_safe(&route.cors)
659}
660
661fn public_path_template_is_safe(path: &str) -> bool {
662 if path.len() > 2_048 || !path.starts_with('/') || path == "/" || path.ends_with('/') {
663 return false;
664 }
665 let mut parameters = BTreeSet::new();
666 path[1..].split('/').all(|segment| {
667 if segment.is_empty() {
668 return false;
669 }
670 if let Some(parameter) = segment
671 .strip_prefix('{')
672 .and_then(|value| value.strip_suffix('}'))
673 {
674 !parameter.is_empty()
675 && parameter.len() <= 64
676 && parameter
677 .chars()
678 .all(|character| character.is_ascii_alphanumeric() || character == '_')
679 && parameter
680 .chars()
681 .next()
682 .is_some_and(|character| character.is_ascii_alphabetic())
683 && parameters.insert(parameter)
684 } else {
685 segment.len() <= 128
686 && segment.chars().all(|character| {
687 character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.')
688 })
689 }
690 })
691}
692
693fn cors_intent_is_safe(cors: &CorsIntent) -> bool {
694 let origins = cors.allowed_origins.iter().collect::<BTreeSet<_>>();
695 let methods = cors.allowed_methods.iter().collect::<BTreeSet<_>>();
696 origins.len() == cors.allowed_origins.len()
697 && methods.len() == cors.allowed_methods.len()
698 && cors.allowed_origins.len() <= 64
699 && cors.allowed_methods.len() <= 7
700 && (cors.allowed_origins.is_empty() == cors.allowed_methods.is_empty())
701 && cors
702 .allowed_origins
703 .iter()
704 .all(|origin| cors_origin_is_safe(origin))
705 && cors
706 .allowed_methods
707 .iter()
708 .all(|method| cors_method_is_safe(method))
709}
710
711fn cors_origin_is_safe(origin: &str) -> bool {
712 let Ok(uri) = origin.parse::<Uri>() else {
713 return false;
714 };
715 origin.len() <= 2_048
716 && !origin.contains(['@', '"', '\\', '?', '#'])
717 && !origin.chars().any(char::is_whitespace)
718 && matches!(uri.scheme_str(), Some("http" | "https"))
719 && uri.authority().is_some()
720 && matches!(uri.path(), "" | "/")
721 && uri.query().is_none()
722}
723
724fn cors_method_is_safe(method: &str) -> bool {
725 matches!(
726 method,
727 "GET" | "HEAD" | "POST" | "PUT" | "PATCH" | "DELETE" | "OPTIONS"
728 )
729}
730
731#[must_use]
732pub fn gateway_plan_authority_is_valid(
733 plan: &GatewayConfigurationPlan,
734 provider: &dyn ReleaseTrustProvider,
735) -> bool {
736 gateway_plan_integrity_is_valid(plan)
737 && provider.verify(
738 plan.edge_provider_id.as_str(),
739 edge_authority_subject(
740 plan.edge_release_id.as_str(),
741 plan.edge_release_digest.as_str(),
742 plan.operation_catalog_digest.as_str(),
743 plan.routes.as_slice(),
744 )
745 .as_str(),
746 plan.edge_provider_proof.as_str(),
747 ) == ReleaseSignerStatus::Trusted
748}
749
750#[must_use]
751pub fn edge_authority_subject(
752 release_id: &str,
753 release_digest: &str,
754 operation_catalog_digest: &str,
755 routes: &[ResolvedEdgeRoute],
756) -> String {
757 digest_json(&(
758 "lenso.edge-authority-subject.v1",
759 release_id,
760 release_digest,
761 operation_catalog_digest,
762 routes,
763 ))
764}
765
766#[must_use]
767pub fn observe_gateway(
768 plan: &GatewayConfigurationPlan,
769 revision: u64,
770 observed_after: impl Into<String>,
771 fresh: bool,
772 provider: &dyn GatewayObservationProvider,
773) -> Result<GatewayObservation, DeliveryIssue> {
774 let observed_after = observed_after.into();
775 let fresh = fresh && revision == plan.expected_gateway_revision;
776 let resource_uid = format!("synthetic:{}", plan.plan_id);
777 let resource_version = "1";
778 let authority_context = plan.plan_id.as_str();
779 let digest = digest_json(&(
780 GATEWAY_OBSERVATION_PROTOCOL,
781 plan.plan_id.as_str(),
782 plan.plan_digest.as_str(),
783 plan.environment.as_str(),
784 plan.edge_release_id.as_str(),
785 plan.edge_release_digest.as_str(),
786 resource_uid.as_str(),
787 resource_version,
788 authority_context,
789 plan.configuration_identity.as_str(),
790 revision,
791 observed_after.as_str(),
792 fresh,
793 provider.provider_id(),
794 ));
795 let observation_id = format!("gateway-observation:{digest}");
796 let provider_proof = provider.sign(&observation_id).ok_or_else(|| {
797 issue(
798 DeliveryIssueCode::ObservationStale,
799 "The Gateway adapter authority refused to attest the observation.",
800 "Use the configured Gateway observation provider at the adapter read boundary.",
801 "Collect a new Gateway observation before continuing.",
802 )
803 })?;
804 Ok(GatewayObservation {
805 protocol: GATEWAY_OBSERVATION_PROTOCOL.to_owned(),
806 observation_id,
807 plan_id: plan.plan_id.clone(),
808 plan_digest: plan.plan_digest.clone(),
809 environment: plan.environment.clone(),
810 release_id: plan.edge_release_id.clone(),
811 release_digest: plan.edge_release_digest.clone(),
812 resource_uid,
813 resource_version: resource_version.to_owned(),
814 authority_context: authority_context.to_owned(),
815 configuration_identity: plan.configuration_identity.clone(),
816 revision,
817 observed_after,
818 fresh,
819 provider_id: provider.provider_id().to_owned(),
820 provider_proof,
821 })
822}
823
824#[must_use]
825pub fn gateway_observation_content_integrity_is_valid(observation: &GatewayObservation) -> bool {
826 observation.protocol == GATEWAY_OBSERVATION_PROTOCOL
827 && !observation.provider_id.trim().is_empty()
828 && !observation.provider_proof.trim().is_empty()
829 && observation.observation_id
830 == format!(
831 "gateway-observation:{}",
832 digest_json(&(
833 observation.protocol.as_str(),
834 observation.plan_id.as_str(),
835 observation.plan_digest.as_str(),
836 observation.environment.as_str(),
837 observation.release_id.as_str(),
838 observation.release_digest.as_str(),
839 observation.resource_uid.as_str(),
840 observation.resource_version.as_str(),
841 observation.authority_context.as_str(),
842 observation.configuration_identity.as_str(),
843 observation.revision,
844 observation.observed_after.as_str(),
845 observation.fresh,
846 observation.provider_id.as_str(),
847 ))
848 )
849}
850
851#[must_use]
852pub fn gateway_observation_integrity_is_valid(
853 observation: &GatewayObservation,
854 provider: &dyn GatewayObservationProvider,
855) -> bool {
856 gateway_observation_content_integrity_is_valid(observation)
857 && observation.provider_id == provider.provider_id()
858 && provider.verify(&observation.observation_id, &observation.provider_proof)
859}
860
861fn digest_json(value: &impl Serialize) -> String {
862 extraction_input_digest(serde_json::to_vec(value).expect("edge values must serialize"))
863}