Skip to main content

zerodds_security_runtime/
gateway_bridge.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3
4//! Gateway-bridge helper.
5//!
6//! Architecture reference: `docs/architecture/09_delegation.md` §3 (use
7//! cases) + §5.3 (bridge sub-gateway chaining).
8//!
9//! A [`GatewayBridge`] typically sits on a hull or
10//! turret compute unit responsible for several edge peers (sensors, ECUs)
11//! without their own cert. The bridge:
12//!
13//! 1. **Issues** delegation links per edge peer, signed with its
14//!    own gateway key.
15//! 2. **Manages** the active delegations in a `BTreeMap`
16//!    (`edge_guid → DelegationLink`).
17//! 3. **Provides** on request the full [`DelegationChain`] to the
18//!    discovery layer (SPDP property), optionally as 1-hop or
19//!    n-hop when the bridge is itself the delegatee of a higher level
20//!    (double-star hull+turret).
21//! 4. **Revokes** delegations explicitly (revocation list, sent along in the
22//!    next SPDP beacon).
23//!
24//! The bridge **does not run a forwarding path itself** — it is the
25//! policy/data-model helper; the actual re-sealing and
26//! forwarding of the RTPS frames happens in the DCPS runtime (plan
27//! §stage j-g, comes later).
28
29extern crate alloc;
30
31use alloc::collections::BTreeMap;
32use alloc::string::String;
33use alloc::vec::Vec;
34
35use zerodds_security_permissions::EdgeIdentityConfig;
36use zerodds_security_pki::{DelegationChain, DelegationError, DelegationLink, SignatureAlgorithm};
37
38/// Configuration of a gateway bridge.
39///
40/// `gateway_guid` is the 16-byte subject GUID that the issued
41/// delegations carry as their `delegator_guid`.
42/// `signing_key` is the PKCS#8-DER-formatted private key material for
43/// signing new links — the bridge holds it in RAM, the loading mechanism
44/// is up to the caller (filesystem, secret manager, HSM).
45/// `algorithm` must match the trust anchor of the profile against which the
46/// chain is later validated.
47#[derive(Debug, Clone)]
48pub struct GatewayBridgeConfig {
49    /// 16-byte gateway participant GUID.
50    pub gateway_guid: [u8; 16],
51    /// PKCS#8-DER-formatted private key.
52    pub signing_key: Vec<u8>,
53    /// Signature algorithm.
54    pub algorithm: SignatureAlgorithm,
55}
56
57/// Error from gateway-bridge operations.
58#[derive(Debug, Clone, PartialEq, Eq)]
59#[non_exhaustive]
60pub enum GatewayBridgeError {
61    /// The edge is not registered.
62    UnknownEdge {
63        /// 16-byte edge GUID.
64        edge_guid: [u8; 16],
65    },
66    /// The sign operation failed (delegated from the PKI crate).
67    DelegationFailed(DelegationError),
68}
69
70impl core::fmt::Display for GatewayBridgeError {
71    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
72        match self {
73            Self::UnknownEdge { edge_guid } => {
74                write!(f, "no active delegation for edge {edge_guid:?}")
75            }
76            Self::DelegationFailed(e) => write!(f, "delegation failed: {e}"),
77        }
78    }
79}
80
81#[cfg(feature = "std")]
82impl std::error::Error for GatewayBridgeError {}
83
84impl From<DelegationError> for GatewayBridgeError {
85    fn from(e: DelegationError) -> Self {
86        Self::DelegationFailed(e)
87    }
88}
89
90/// Result alias.
91pub type GatewayBridgeResult<T> = Result<T, GatewayBridgeError>;
92
93/// Gateway-bridge helper.
94///
95/// Lifecycle:
96/// 1. [`GatewayBridge::new`] with `GatewayBridgeConfig`.
97/// 2. Optional [`GatewayBridge::with_upstream`] to hook the own bridge
98///    into an existing chain (e.g. from the hull GW to the own turret GW).
99/// 3. Per edge: [`GatewayBridge::delegate_for`] to issue a new
100///    delegation.
101/// 4. [`GatewayBridge::chain_for`] provides the chain as output for
102///    SPDP/SEDP properties.
103/// 5. [`GatewayBridge::revoke_delegation`] removes an edge.
104#[derive(Debug, Clone)]
105pub struct GatewayBridge {
106    config: GatewayBridgeConfig,
107    /// Optional: chain that legitimizes this gateway as the delegatee of a
108    /// higher level. In [`Self::chain_for`] it is prepended to the issued
109    /// edge link.
110    upstream: Option<DelegationChain>,
111    /// Active edge delegations (`edge_guid → Link`).
112    active: BTreeMap<[u8; 16], DelegationLink>,
113    /// Revocation list: edge GUIDs whose delegation should be announced as
114    /// revoked in the next SPDP wave. The caller clears
115    /// the list after a successful announce via
116    /// [`Self::take_revocations`].
117    revocations: Vec<[u8; 16]>,
118}
119
120impl GatewayBridge {
121    /// Constructor.
122    #[must_use]
123    pub fn new(config: GatewayBridgeConfig) -> Self {
124        Self {
125            config,
126            upstream: None,
127            active: BTreeMap::new(),
128            revocations: Vec::new(),
129        }
130    }
131
132    /// Sets an upstream chain. In [`Self::chain_for`] it is
133    /// prepended to the edge link — sub-gateway chaining for
134    /// a double-star (turret GW under hull GW).
135    ///
136    /// Validation of the upstream chain is NOT part of the bridge —
137    /// the caller must call `validate_chain` itself beforehand, to
138    /// prevent mismatch-profile errors.
139    pub fn with_upstream(&mut self, upstream_chain: DelegationChain) {
140        self.upstream = Some(upstream_chain);
141    }
142
143    /// 16-byte gateway GUID (read-only).
144    #[must_use]
145    pub fn gateway_guid(&self) -> [u8; 16] {
146        self.config.gateway_guid
147    }
148
149    /// Issues a new delegation for an edge peer. If the
150    /// edge was already delegated, the old link is overwritten
151    /// (typical with ephemeral-edge rotation, plan §stage j-f).
152    ///
153    /// `not_before` and `not_after` are absolute Unix seconds;
154    /// `topic_patterns`/`partition_patterns` are the glob whitelist
155    /// that the edge may have in the narrowest scope.
156    ///
157    /// # Errors
158    /// [`GatewayBridgeError::DelegationFailed`] if the PKI sign
159    /// step fails (cap violation, key parse error).
160    pub fn delegate_for(
161        &mut self,
162        edge_guid: [u8; 16],
163        topic_patterns: Vec<String>,
164        partition_patterns: Vec<String>,
165        not_before: i64,
166        not_after: i64,
167    ) -> GatewayBridgeResult<&DelegationLink> {
168        let mut link = DelegationLink::new(
169            self.config.gateway_guid,
170            edge_guid,
171            topic_patterns,
172            partition_patterns,
173            not_before,
174            not_after,
175            self.config.algorithm,
176        )?;
177        link.sign(&self.config.signing_key)?;
178        self.active.insert(edge_guid, link);
179        // Edge active again → remove from revocations if needed, in case of
180        // a re-issue (e.g. after renewal).
181        self.revocations.retain(|g| g != &edge_guid);
182        // The lookup after a successful insert cannot fail,
183        // but clippy::expect_used forbids expect — we provide
184        // unwrap_or via a fresh lookup.
185        self.active
186            .get(&edge_guid)
187            .ok_or(GatewayBridgeError::UnknownEdge { edge_guid })
188    }
189
190    /// Revokes the active delegation for an edge. The edge is
191    /// added to the revocation list and can be communicated to the
192    /// discovery layer via [`Self::take_revocations`].
193    ///
194    /// # Errors
195    /// [`GatewayBridgeError::UnknownEdge`] if the edge is not active.
196    pub fn revoke_delegation(&mut self, edge_guid: [u8; 16]) -> GatewayBridgeResult<()> {
197        if self.active.remove(&edge_guid).is_some() {
198            if !self.revocations.contains(&edge_guid) {
199                self.revocations.push(edge_guid);
200            }
201            Ok(())
202        } else {
203            Err(GatewayBridgeError::UnknownEdge { edge_guid })
204        }
205    }
206
207    /// Returns the outgoing chain for an edge.
208    ///
209    /// 1-hop bridge (no upstream): chain = `[edge link]`,
210    ///   `origin_guid = gateway_guid`.
211    /// n-hop bridge (with upstream): chain = `upstream.links ++
212    ///   [edge link]`, `origin_guid = upstream.origin_guid`.
213    ///
214    /// Returns `None` if the edge is not active.
215    #[must_use]
216    pub fn chain_for(&self, edge_guid: &[u8; 16]) -> Option<DelegationChain> {
217        let edge_link = self.active.get(edge_guid)?.clone();
218        match &self.upstream {
219            None => DelegationChain::new(self.config.gateway_guid, alloc::vec![edge_link]).ok(),
220            Some(up) => {
221                let mut links = up.links.clone();
222                links.push(edge_link);
223                DelegationChain::new(up.origin_guid, links).ok()
224            }
225        }
226    }
227
228    /// Number of active edge delegations.
229    #[must_use]
230    pub fn active_count(&self) -> usize {
231        self.active.len()
232    }
233
234    /// True if an edge is actively delegated.
235    #[must_use]
236    pub fn has_edge(&self, edge_guid: &[u8; 16]) -> bool {
237        self.active.contains_key(edge_guid)
238    }
239
240    /// Iterates over all active edge delegations.
241    pub fn iter_active(&self) -> impl Iterator<Item = (&[u8; 16], &DelegationLink)> {
242        self.active.iter()
243    }
244
245    /// Reads and clears the revocation list (the discovery layer calls this
246    /// per SPDP beacon tick).
247    pub fn take_revocations(&mut self) -> Vec<[u8; 16]> {
248        core::mem::take(&mut self.revocations)
249    }
250
251    /// Read access to the upstream chain (useful for logging /
252    /// metrics).
253    #[must_use]
254    pub fn upstream(&self) -> Option<&DelegationChain> {
255        self.upstream.as_ref()
256    }
257
258    /// Rotates ephemeral edge identities whose lifetime has expired.
259    ///
260    /// Workflow per ephemeral edge:
261    /// 1. If the edge is not active → skip (init comes via
262    ///    `delegate_for` by the caller).
263    /// 2. If `now < link.not_after - lifetime/N` (N=renewal window)
264    ///    → still too fresh, skip.
265    /// 3. Otherwise: pull a new GuidPrefix (`prefix_generator(name)`),
266    ///    revoke the old edge, issue a new `delegate_for` with a `now`-based
267    ///    time window.
268    ///
269    /// `prefix_generator` is a pluggable hook (e.g. a ChaCha20 RNG
270    /// or a system RNG); the bridge is deterministically testable because
271    /// the randomness source comes from the caller.
272    ///
273    /// Returns the list of rotated edge names.
274    ///
275    /// # Errors
276    /// Propagates [`GatewayBridgeError::DelegationFailed`] if a
277    /// re-sign fails — but does NOT abort the loop;
278    /// faulty edges are collected in the `Err` tail vec and the
279    /// caller can decide.
280    pub fn rotate_ephemerals<F>(
281        &mut self,
282        identities: &[EdgeIdentityConfig],
283        now: i64,
284        topic_patterns: Vec<String>,
285        partition_patterns: Vec<String>,
286        mut prefix_generator: F,
287    ) -> (Vec<String>, Vec<(String, GatewayBridgeError)>)
288    where
289        F: FnMut(&str) -> [u8; 12],
290    {
291        let mut rotated = Vec::new();
292        let mut failed = Vec::new();
293        for cfg in identities.iter().filter(|c| c.is_ephemeral()) {
294            // Edge GUID = 12-byte prefix + 4-byte EntityId 0x00.0x00.0x01.0xC1 (DDS conv).
295            // Here we take only the prefix as the identifier key —
296            // the GuidPrefix itself decides the edge identity.
297            let new_prefix = prefix_generator(&cfg.name);
298            // Reconstruct full 16-byte edge guid (prefix + EntityId of the
299            // default participant EntityId).
300            let mut edge_guid = [0u8; 16];
301            edge_guid[..12].copy_from_slice(&new_prefix);
302            edge_guid[12..].copy_from_slice(&[0x00, 0x00, 0x01, 0xC1]);
303
304            // Pick up all existing edges with the same name
305            // (matching here is prefix-based; in a full impl we would
306            // have a name-keyed map). Since we don't have the name in the
307            // active map, we proceed raw here:
308            // delegate_for overwrites on conflict.
309            let lifetime = i64::from(cfg.effective_lifetime());
310            let new_not_after = now.saturating_add(lifetime);
311            match self.delegate_for(
312                edge_guid,
313                topic_patterns.clone(),
314                partition_patterns.clone(),
315                now,
316                new_not_after,
317            ) {
318                Ok(_) => rotated.push(cfg.name.clone()),
319                Err(e) => failed.push((cfg.name.clone(), e)),
320            }
321        }
322        (rotated, failed)
323    }
324}
325
326#[cfg(test)]
327#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
328mod tests {
329    use super::*;
330    use alloc::string::ToString;
331    use ring::rand::SystemRandom;
332    use ring::signature::{ECDSA_P256_SHA256_FIXED_SIGNING, EcdsaKeyPair, KeyPair};
333    use zerodds_security_permissions::EdgeIdentityMode;
334
335    fn ecdsa_p256_keypair() -> (Vec<u8>, Vec<u8>) {
336        let rng = SystemRandom::new();
337        let pkcs8 =
338            EcdsaKeyPair::generate_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, &rng).expect("gen");
339        let sk = pkcs8.as_ref().to_vec();
340        let kp = EcdsaKeyPair::from_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, &sk, &rng).expect("p");
341        (sk, kp.public_key().as_ref().to_vec())
342    }
343
344    fn make_bridge(gw_guid: [u8; 16]) -> (GatewayBridge, Vec<u8>) {
345        let (sk, pk) = ecdsa_p256_keypair();
346        let cfg = GatewayBridgeConfig {
347            gateway_guid: gw_guid,
348            signing_key: sk,
349            algorithm: SignatureAlgorithm::EcdsaP256,
350        };
351        (GatewayBridge::new(cfg), pk)
352    }
353
354    #[test]
355    fn delegate_for_creates_signed_link() {
356        let gw = [0xAA; 16];
357        let edge = [0xBB; 16];
358        let (mut bridge, pk) = make_bridge(gw);
359        let link = bridge
360            .delegate_for(
361                edge,
362                alloc::vec!["sensor/*".to_string()],
363                alloc::vec![],
364                1_000,
365                9_000,
366            )
367            .expect("delegate")
368            .clone();
369        assert_eq!(link.delegator_guid, gw);
370        assert_eq!(link.delegatee_guid, edge);
371        assert_eq!(link.signature.len(), 64); // ECDSA-P256 fixed
372        link.verify(&pk).expect("verify");
373        assert_eq!(bridge.active_count(), 1);
374        assert!(bridge.has_edge(&edge));
375    }
376
377    #[test]
378    fn one_hop_chain_for_edge() {
379        let gw = [0xAA; 16];
380        let edge = [0xBB; 16];
381        let (mut bridge, _pk) = make_bridge(gw);
382        bridge
383            .delegate_for(
384                edge,
385                alloc::vec!["sensor/*".to_string()],
386                alloc::vec![],
387                0,
388                9_000,
389            )
390            .expect("delegate");
391        let chain = bridge.chain_for(&edge).expect("chain");
392        assert_eq!(chain.depth(), 1);
393        assert_eq!(chain.origin_guid, gw);
394        assert_eq!(chain.edge_guid(), Some(edge));
395    }
396
397    #[test]
398    fn chain_for_missing_edge_is_none() {
399        let gw = [0xAA; 16];
400        let (bridge, _) = make_bridge(gw);
401        assert!(bridge.chain_for(&[0xCC; 16]).is_none());
402    }
403
404    #[test]
405    fn revoke_delegation_removes_active_and_records_revocation() {
406        let gw = [0xAA; 16];
407        let edge = [0xBB; 16];
408        let (mut bridge, _pk) = make_bridge(gw);
409        bridge
410            .delegate_for(edge, alloc::vec![], alloc::vec![], 0, 9_000)
411            .expect("delegate");
412        bridge.revoke_delegation(edge).expect("revoke");
413        assert_eq!(bridge.active_count(), 0);
414        assert!(!bridge.has_edge(&edge));
415        let revocations = bridge.take_revocations();
416        assert_eq!(revocations, alloc::vec![edge]);
417        // The list is empty after take.
418        assert!(bridge.take_revocations().is_empty());
419    }
420
421    #[test]
422    fn revoke_unknown_edge_is_error() {
423        let gw = [0xAA; 16];
424        let (mut bridge, _) = make_bridge(gw);
425        let err = bridge.revoke_delegation([0xFF; 16]).expect_err("must fail");
426        assert!(matches!(err, GatewayBridgeError::UnknownEdge { .. }));
427    }
428
429    #[test]
430    fn re_delegate_clears_pending_revocation() {
431        let gw = [0xAA; 16];
432        let edge = [0xBB; 16];
433        let (mut bridge, _) = make_bridge(gw);
434        bridge
435            .delegate_for(edge, alloc::vec![], alloc::vec![], 0, 9_000)
436            .expect("delegate");
437        bridge.revoke_delegation(edge).expect("revoke");
438        // Re-delegate (e.g. after a cert renewal) must remove the revocation.
439        bridge
440            .delegate_for(edge, alloc::vec![], alloc::vec![], 100, 10_000)
441            .expect("redelegate");
442        assert!(bridge.take_revocations().is_empty());
443        assert!(bridge.has_edge(&edge));
444    }
445
446    #[test]
447    fn sub_gateway_chaining_two_hops() {
448        // Hull GW (gw1) delegates to turret GW (gw2). The turret GW bridges
449        // edge `turm-imu`. The resulting chain has 2 links and origin = gw1.
450        let gw1 = [0x11; 16];
451        let gw2 = [0x22; 16];
452        let edge = [0x33; 16];
453
454        // gw1 creates an upstream link gw1 -> gw2.
455        let (sk1, _pk1) = ecdsa_p256_keypair();
456        let mut upstream_link = DelegationLink::new(
457            gw1,
458            gw2,
459            alloc::vec!["*".to_string()],
460            alloc::vec![],
461            0,
462            9_000,
463            SignatureAlgorithm::EcdsaP256,
464        )
465        .expect("upstream link");
466        upstream_link.sign(&sk1).expect("sign upstream");
467        let upstream_chain =
468            DelegationChain::new(gw1, alloc::vec![upstream_link]).expect("upstream chain");
469
470        // Turret bridge.
471        let (mut turm_bridge, _pk2) = make_bridge(gw2);
472        turm_bridge.with_upstream(upstream_chain);
473
474        turm_bridge
475            .delegate_for(
476                edge,
477                alloc::vec!["sensor/imu".to_string()],
478                alloc::vec![],
479                100,
480                8_000,
481            )
482            .expect("turm delegate");
483        let chain = turm_bridge.chain_for(&edge).expect("chain");
484        assert_eq!(chain.depth(), 2);
485        assert_eq!(chain.origin_guid, gw1);
486        assert_eq!(chain.edge_guid(), Some(edge));
487        // The last link is gw2 -> edge.
488        assert_eq!(chain.links.last().unwrap().delegator_guid, gw2);
489        assert_eq!(chain.links.last().unwrap().delegatee_guid, edge);
490    }
491
492    #[test]
493    fn iter_active_lists_all_delegations() {
494        let gw = [0xAA; 16];
495        let (mut bridge, _) = make_bridge(gw);
496        for i in 0..5u8 {
497            let mut edge = [0u8; 16];
498            edge[0] = i;
499            bridge
500                .delegate_for(edge, alloc::vec![], alloc::vec![], 0, 9_000)
501                .expect("delegate");
502        }
503        assert_eq!(bridge.active_count(), 5);
504        let collected: Vec<[u8; 16]> = bridge.iter_active().map(|(g, _)| *g).collect();
505        assert_eq!(collected.len(), 5);
506    }
507
508    #[test]
509    fn upstream_accessor_reflects_state() {
510        let gw = [0xAA; 16];
511        let (mut bridge, _) = make_bridge(gw);
512        assert!(bridge.upstream().is_none());
513        let (sk, _pk) = ecdsa_p256_keypair();
514        let mut up_link = DelegationLink::new(
515            [0x11; 16],
516            gw,
517            alloc::vec!["*".to_string()],
518            alloc::vec![],
519            0,
520            9_000,
521            SignatureAlgorithm::EcdsaP256,
522        )
523        .unwrap();
524        up_link.sign(&sk).unwrap();
525        let chain = DelegationChain::new([0x11; 16], alloc::vec![up_link]).unwrap();
526        bridge.with_upstream(chain.clone());
527        assert_eq!(bridge.upstream(), Some(&chain));
528    }
529
530    #[test]
531    fn bridge_two_hop_chain_validates_via_chain_check() {
532        use alloc::collections::BTreeSet;
533        use zerodds_security_permissions::{
534            DelegationProfile, TrustAnchor, TrustPolicy, validate_chain,
535        };
536
537        // Setup: gw1 (hull), gw2 (turret), edge.
538        let gw1 = [0x11; 16];
539        let gw2 = [0x22; 16];
540        let edge = [0x33; 16];
541
542        // Hull-GW key pair.
543        let (sk1, pk1) = ecdsa_p256_keypair();
544        // Turret-GW key pair (different pair!).
545        let (sk2, pk2) = ecdsa_p256_keypair();
546
547        // The hull creates the upstream link gw1 -> gw2 (signed with sk1).
548        let mut upstream_link = DelegationLink::new(
549            gw1,
550            gw2,
551            alloc::vec!["*".to_string()],
552            alloc::vec![],
553            0,
554            9_000,
555            SignatureAlgorithm::EcdsaP256,
556        )
557        .unwrap();
558        upstream_link.sign(&sk1).unwrap();
559        let upstream = DelegationChain::new(gw1, alloc::vec![upstream_link]).unwrap();
560
561        // Turret bridge (signed with sk2).
562        let cfg = GatewayBridgeConfig {
563            gateway_guid: gw2,
564            signing_key: sk2,
565            algorithm: SignatureAlgorithm::EcdsaP256,
566        };
567        let mut turm_bridge = GatewayBridge::new(cfg);
568        turm_bridge.with_upstream(upstream);
569        turm_bridge
570            .delegate_for(
571                edge,
572                alloc::vec!["sensor/imu".to_string()],
573                alloc::vec![],
574                100,
575                8_000,
576            )
577            .unwrap();
578
579        let chain = turm_bridge.chain_for(&edge).expect("chain");
580
581        // Profile with trust anchor = gw1 (pk1).
582        let mut algos = BTreeSet::new();
583        algos.insert(SignatureAlgorithm::EcdsaP256.wire_id());
584        let profile = DelegationProfile {
585            name: "vehicle".to_string(),
586            trust_policy: TrustPolicy::DirectOrDelegated,
587            trust_anchors: alloc::vec![TrustAnchor {
588                subject_guid: gw1,
589                verify_public_key: pk1,
590                algorithm: SignatureAlgorithm::EcdsaP256,
591            }],
592            max_chain_depth: 3,
593            allowed_algorithms: algos,
594            require_ocsp: false,
595        };
596
597        // The resolver returns pk2 for gw2.
598        let resolver = move |g: &[u8; 16]| -> Option<(Vec<u8>, SignatureAlgorithm)> {
599            if g == &gw2 {
600                Some((pk2.clone(), SignatureAlgorithm::EcdsaP256))
601            } else {
602                None
603            }
604        };
605
606        let validated = validate_chain(&chain, &profile, 5_000, resolver).expect("validate");
607        assert_eq!(validated.chain_depth, 2);
608        assert_eq!(validated.edge_guid, edge);
609        // Scope intersection: "*" and "sensor/imu" → "sensor/imu".
610        assert!(
611            validated
612                .effective_topic_patterns
613                .contains(&"sensor/imu".to_string())
614        );
615    }
616
617    // ---- RC1: rotate_ephemerals ----
618
619    #[test]
620    fn rotate_ephemerals_creates_delegations_for_ephemeral_only() {
621        let gw = [0xAA; 16];
622        let (mut bridge, _pk) = make_bridge(gw);
623
624        let identities = alloc::vec![
625            EdgeIdentityConfig {
626                name: "static-edge".into(),
627                mode: EdgeIdentityMode::Static,
628                guid_prefix: Some([0x01; 12]),
629                lifetime_seconds: None,
630            },
631            EdgeIdentityConfig {
632                name: "ephemeral-edge".into(),
633                mode: EdgeIdentityMode::Ephemeral,
634                guid_prefix: None,
635                lifetime_seconds: Some(60),
636            },
637        ];
638
639        let mut counter = 0u8;
640        let prefix_gen = |_name: &str| -> [u8; 12] {
641            counter += 1;
642            [counter; 12]
643        };
644        let (rotated, failed) = bridge.rotate_ephemerals(
645            &identities,
646            1_000,
647            alloc::vec!["sensor/*".to_string()],
648            alloc::vec![],
649            prefix_gen,
650        );
651        assert_eq!(rotated, alloc::vec!["ephemeral-edge".to_string()]);
652        assert!(failed.is_empty());
653        // The static edge was NOT delegated (the caller manages static
654        // itself).
655        assert_eq!(bridge.active_count(), 1);
656    }
657
658    #[test]
659    fn rotate_ephemerals_uses_provided_prefix_generator() {
660        let gw = [0xAA; 16];
661        let (mut bridge, _) = make_bridge(gw);
662
663        let identities = alloc::vec![EdgeIdentityConfig {
664            name: "rot-edge".into(),
665            mode: EdgeIdentityMode::Ephemeral,
666            guid_prefix: None,
667            lifetime_seconds: Some(120),
668        }];
669
670        let captured_name: alloc::sync::Arc<core::sync::atomic::AtomicBool> =
671            alloc::sync::Arc::new(core::sync::atomic::AtomicBool::new(false));
672        let captured_clone = captured_name.clone();
673        let prefix_gen = move |name: &str| -> [u8; 12] {
674            if name == "rot-edge" {
675                captured_clone.store(true, core::sync::atomic::Ordering::SeqCst);
676            }
677            [0xDE; 12]
678        };
679
680        let (rotated, _) =
681            bridge.rotate_ephemerals(&identities, 5_000, alloc::vec![], alloc::vec![], prefix_gen);
682        assert_eq!(rotated.len(), 1);
683        assert!(captured_name.load(core::sync::atomic::Ordering::SeqCst));
684
685        // The edge is active with the generated prefix.
686        let mut expected_guid = [0u8; 16];
687        expected_guid[..12].copy_from_slice(&[0xDE; 12]);
688        expected_guid[12..].copy_from_slice(&[0x00, 0x00, 0x01, 0xC1]);
689        assert!(bridge.has_edge(&expected_guid));
690    }
691
692    #[test]
693    fn rotate_ephemerals_uses_lifetime_for_not_after() {
694        let gw = [0xAA; 16];
695        let (mut bridge, _) = make_bridge(gw);
696        let identities = alloc::vec![EdgeIdentityConfig {
697            name: "imu".into(),
698            mode: EdgeIdentityMode::Ephemeral,
699            guid_prefix: None,
700            lifetime_seconds: Some(300),
701        }];
702        let (_rotated, _) =
703            bridge.rotate_ephemerals(&identities, 1_000, alloc::vec![], alloc::vec![], |_| {
704                [0xAB; 12]
705            });
706        let mut edge_guid = [0u8; 16];
707        edge_guid[..12].copy_from_slice(&[0xAB; 12]);
708        edge_guid[12..].copy_from_slice(&[0x00, 0x00, 0x01, 0xC1]);
709        let link = bridge.iter_active().find(|(g, _)| *g == &edge_guid);
710        assert!(link.is_some());
711        let (_g, l) = link.unwrap();
712        assert_eq!(l.not_before, 1_000);
713        assert_eq!(l.not_after, 1_300);
714    }
715
716    #[test]
717    fn rotate_ephemerals_repeated_calls_replace_old_delegation() {
718        let gw = [0xAA; 16];
719        let (mut bridge, _) = make_bridge(gw);
720        let identities = alloc::vec![EdgeIdentityConfig {
721            name: "ecu".into(),
722            mode: EdgeIdentityMode::Ephemeral,
723            guid_prefix: None,
724            lifetime_seconds: Some(60),
725        }];
726
727        // First rotation with prefix [0x11; 12].
728        let (_, _) =
729            bridge.rotate_ephemerals(&identities, 1_000, alloc::vec![], alloc::vec![], |_| {
730                [0x11; 12]
731            });
732        // Second rotation with prefix [0x22; 12] — the old edge stays
733        // in the active map (delegate_for only overwrites the exact same
734        // GUID). Both are active. In production the
735        // discovery layer would evict the old one via take_revocations.
736        let (_, _) =
737            bridge.rotate_ephemerals(&identities, 2_000, alloc::vec![], alloc::vec![], |_| {
738                [0x22; 12]
739            });
740        assert_eq!(bridge.active_count(), 2);
741    }
742
743    #[test]
744    fn delegation_link_too_many_topics_propagates_as_bridge_error() {
745        let gw = [0xAA; 16];
746        let edge = [0xBB; 16];
747        let (mut bridge, _) = make_bridge(gw);
748        let topics: Vec<String> = (0..200).map(|i| alloc::format!("t{i}")).collect();
749        let err = bridge
750            .delegate_for(edge, topics, alloc::vec![], 0, 9_000)
751            .expect_err("must fail");
752        assert!(matches!(err, GatewayBridgeError::DelegationFailed(_)));
753    }
754}