Skip to main content

hns_browser_runtime/
lib.rs

1//! Session-bound lifecycle for HNS browser authority.
2//!
3//! The runtime owns the monotonically increasing generation and event clock
4//! shared by mobile and Chromium adapters. Admission stamps include the
5//! runtime session so work cannot be replayed into another engine instance,
6//! even when both instances happen to have the same generation.
7
8#![forbid(unsafe_code)]
9
10use std::error::Error;
11use std::fmt;
12
13/// Shared browser-runtime status schema.
14pub const RUNTIME_SCHEMA_VERSION: u16 = 2;
15
16/// Checked, nonzero identity for one browser-runtime start.
17///
18/// Callers must generate a fresh, unpredictable value for every process
19/// start. This type rejects the all-zero sentinel, but uniqueness and
20/// unpredictability remain caller responsibilities.
21#[repr(transparent)]
22#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
23pub struct RuntimeSessionId([u8; 16]);
24
25impl RuntimeSessionId {
26    /// Construct a checked runtime session identity.
27    ///
28    /// # Errors
29    ///
30    /// Returns [`RuntimeError::ZeroSession`] for the all-zero sentinel.
31    pub const fn new(bytes: [u8; 16]) -> Result<Self, RuntimeError> {
32        if u128::from_be_bytes(bytes) == 0 {
33            Err(RuntimeError::ZeroSession)
34        } else {
35            Ok(Self(bytes))
36        }
37    }
38
39    /// Return the exact opaque session bytes.
40    #[must_use]
41    pub const fn into_bytes(self) -> [u8; 16] {
42        self.0
43    }
44
45    /// Borrow the exact opaque session bytes.
46    #[must_use]
47    pub const fn as_bytes(&self) -> &[u8; 16] {
48        &self.0
49    }
50}
51
52impl TryFrom<[u8; 16]> for RuntimeSessionId {
53    type Error = RuntimeError;
54
55    fn try_from(value: [u8; 16]) -> Result<Self, Self::Error> {
56        Self::new(value)
57    }
58}
59
60/// Browser authority state required by the browser resolution model.
61#[repr(u8)]
62#[derive(Clone, Copy, Debug, Eq, PartialEq)]
63pub enum AuthorityState {
64    /// No local state has been opened.
65    Uninitialized = 0,
66    /// Local stores are available.
67    LocalStateOpened = 1,
68    /// Validated headers are synchronizing.
69    HeaderSyncing = 2,
70    /// Validated headers satisfy currency policy.
71    HeaderCurrent = 3,
72    /// Verified Urkel proof service is ready.
73    ProofReady = 4,
74    /// At least one policy-permitted DNS transport is ready.
75    ResolutionTransportReady = 5,
76    /// Current origin DNSSEC evidence is verified.
77    DnssecVerified = 6,
78    /// Current origin DANE evidence is verified.
79    DaneOriginVerified = 7,
80    /// Platform bridge is ready.
81    BrowserBridgeReady = 8,
82    /// Browser engine is active.
83    Active = 9,
84    /// A recoverable prerequisite is unavailable.
85    Degraded = 10,
86    /// Security state or policy was revoked.
87    Revoked = 11,
88    /// Runtime is stopped.
89    Stopped = 12,
90}
91
92/// Immutable runtime status.
93#[derive(Clone, Copy, Debug, Eq, PartialEq)]
94pub struct RuntimeSnapshot {
95    /// Status schema version.
96    schema_version: u16,
97    /// Caller-supplied per-start runtime session, which must be unique.
98    session: RuntimeSessionId,
99    /// Monotonic generation invalidated by policy changes.
100    generation: u64,
101    /// Monotonic event sequence within this session.
102    event_sequence: u64,
103    /// Current authority state.
104    authority_state: AuthorityState,
105}
106
107impl RuntimeSnapshot {
108    /// Runtime status schema version.
109    #[must_use]
110    pub const fn schema_version(&self) -> u16 {
111        self.schema_version
112    }
113
114    /// Checked runtime session identity.
115    #[must_use]
116    pub const fn session_id(&self) -> RuntimeSessionId {
117        self.session
118    }
119
120    /// Exact opaque runtime session bytes.
121    #[must_use]
122    pub const fn session_bytes(&self) -> [u8; 16] {
123        self.session.into_bytes()
124    }
125
126    /// Current runtime generation.
127    #[must_use]
128    pub const fn generation(&self) -> u64 {
129        self.generation
130    }
131
132    /// Current monotonic event sequence.
133    #[must_use]
134    pub const fn event_sequence(&self) -> u64 {
135        self.event_sequence
136    }
137
138    /// Current browser authority state.
139    #[must_use]
140    pub const fn authority_state(&self) -> AuthorityState {
141        self.authority_state
142    }
143}
144
145/// Opaque admission identity for work begun in one runtime session.
146#[derive(Clone, Copy, Debug, Eq, PartialEq)]
147pub struct RuntimeStamp {
148    session: RuntimeSessionId,
149    generation: u64,
150    event_sequence: u64,
151}
152
153impl RuntimeStamp {
154    /// Runtime session that admitted this work.
155    #[must_use]
156    pub const fn session(self) -> [u8; 16] {
157        self.session.into_bytes()
158    }
159
160    /// Runtime generation that admitted this work.
161    #[must_use]
162    pub const fn generation(self) -> u64 {
163        self.generation
164    }
165
166    /// Admission event sequence.
167    #[must_use]
168    pub const fn event_sequence(self) -> u64 {
169        self.event_sequence
170    }
171}
172
173/// Deterministic authority lifecycle and session clock.
174///
175/// This type is deliberately neither [`Clone`] nor [`Copy`]: duplicating it
176/// would fork the monotonic event clock for one session.
177#[derive(Debug, Eq, PartialEq)]
178pub struct BrowserRuntime {
179    session: RuntimeSessionId,
180    generation: u64,
181    event_sequence: u64,
182    invalidation_sequence: u64,
183    authority_state: AuthorityState,
184}
185
186impl BrowserRuntime {
187    /// Start a fresh runtime session at generation one.
188    #[must_use]
189    pub const fn new(session: RuntimeSessionId) -> Self {
190        Self {
191            session,
192            generation: 1,
193            event_sequence: 0,
194            invalidation_sequence: 0,
195            authority_state: AuthorityState::Uninitialized,
196        }
197    }
198
199    /// Read the complete immutable status.
200    #[must_use]
201    pub const fn snapshot(&self) -> RuntimeSnapshot {
202        RuntimeSnapshot {
203            schema_version: RUNTIME_SCHEMA_VERSION,
204            session: self.session,
205            generation: self.generation,
206            event_sequence: self.event_sequence,
207            authority_state: self.authority_state,
208        }
209    }
210
211    /// Current authority state.
212    #[must_use]
213    pub const fn authority_state(&self) -> AuthorityState {
214        self.authority_state
215    }
216
217    /// Whether policy revocation can advance both monotonic counters.
218    ///
219    /// # Errors
220    ///
221    /// Returns [`RuntimeError::CounterExhausted`] if either counter is at its
222    /// maximum value.
223    pub const fn ensure_policy_change_capacity(&self) -> Result<(), RuntimeError> {
224        if self.generation == u64::MAX || self.event_sequence == u64::MAX {
225            return Err(RuntimeError::CounterExhausted);
226        }
227        Ok(())
228    }
229
230    /// Revoke prior work after a committed policy change.
231    ///
232    /// # Errors
233    ///
234    /// Returns [`RuntimeError::CounterExhausted`] if the generation or event
235    /// sequence cannot advance.
236    pub fn policy_changed(&mut self) -> Result<RuntimeSnapshot, RuntimeError> {
237        self.ensure_policy_change_capacity()?;
238        self.generation += 1;
239        self.event_sequence += 1;
240        self.invalidation_sequence = self.event_sequence;
241        if self.authority_state != AuthorityState::Stopped {
242            self.authority_state = AuthorityState::Revoked;
243        }
244        Ok(self.snapshot())
245    }
246
247    /// Advance the exact browser authority state machine.
248    ///
249    /// # Errors
250    ///
251    /// Returns [`RuntimeError::InvalidAuthorityTransition`] for an edge outside
252    /// the required state graph, or [`RuntimeError::CounterExhausted`] if the
253    /// event sequence cannot advance.
254    pub fn transition(&mut self, next: AuthorityState) -> Result<RuntimeSnapshot, RuntimeError> {
255        if !valid_authority_transition(self.authority_state, next) {
256            return Err(RuntimeError::InvalidAuthorityTransition);
257        }
258        let event_sequence = self
259            .event_sequence
260            .checked_add(1)
261            .ok_or(RuntimeError::CounterExhausted)?;
262        self.event_sequence = event_sequence;
263        if matches!(
264            next,
265            AuthorityState::Degraded | AuthorityState::Revoked | AuthorityState::Stopped
266        ) {
267            self.invalidation_sequence = event_sequence;
268        }
269        self.authority_state = next;
270        Ok(self.snapshot())
271    }
272
273    /// Record an admitted operation and bind it to this session/generation.
274    ///
275    /// # Errors
276    ///
277    /// Returns [`RuntimeError::AuthorityNotReady`] before transport readiness,
278    /// [`RuntimeError::Stopped`] after terminal shutdown, or
279    /// [`RuntimeError::CounterExhausted`] if the event sequence cannot advance.
280    pub fn admit_event(&mut self) -> Result<RuntimeStamp, RuntimeError> {
281        if self.authority_state == AuthorityState::Stopped {
282            return Err(RuntimeError::Stopped);
283        }
284        if !matches!(
285            self.authority_state,
286            AuthorityState::ResolutionTransportReady
287                | AuthorityState::DnssecVerified
288                | AuthorityState::DaneOriginVerified
289                | AuthorityState::BrowserBridgeReady
290                | AuthorityState::Active
291        ) {
292            return Err(RuntimeError::AuthorityNotReady);
293        }
294        self.event_sequence = self
295            .event_sequence
296            .checked_add(1)
297            .ok_or(RuntimeError::CounterExhausted)?;
298        Ok(RuntimeStamp {
299            session: self.session,
300            generation: self.generation,
301            event_sequence: self.event_sequence,
302        })
303    }
304
305    /// Check that a stamp belongs to current, already-admitted work.
306    #[must_use]
307    pub fn admits(&self, stamp: RuntimeStamp) -> bool {
308        authority_admits_work(self.authority_state)
309            && stamp.session == self.session
310            && stamp.generation == self.generation
311            && stamp.event_sequence > self.invalidation_sequence
312            && stamp.event_sequence <= self.event_sequence
313    }
314}
315
316const fn authority_admits_work(state: AuthorityState) -> bool {
317    matches!(
318        state,
319        AuthorityState::ResolutionTransportReady
320            | AuthorityState::DnssecVerified
321            | AuthorityState::DaneOriginVerified
322            | AuthorityState::BrowserBridgeReady
323            | AuthorityState::Active
324    )
325}
326
327const fn valid_authority_transition(current: AuthorityState, next: AuthorityState) -> bool {
328    use AuthorityState::{
329        Active, BrowserBridgeReady, DaneOriginVerified, Degraded, DnssecVerified, HeaderCurrent,
330        HeaderSyncing, LocalStateOpened, ProofReady, ResolutionTransportReady, Revoked, Stopped,
331        Uninitialized,
332    };
333    matches!(
334        (current, next),
335        (Uninitialized, LocalStateOpened)
336            | (LocalStateOpened | Degraded | Revoked, HeaderSyncing)
337            | (HeaderSyncing, HeaderCurrent)
338            | (HeaderCurrent, ProofReady)
339            | (ProofReady, ResolutionTransportReady)
340            | (
341                ResolutionTransportReady,
342                DnssecVerified | BrowserBridgeReady
343            )
344            | (DnssecVerified, DaneOriginVerified | BrowserBridgeReady)
345            | (DaneOriginVerified, BrowserBridgeReady)
346            | (BrowserBridgeReady, Active)
347            | (
348                Uninitialized
349                    | LocalStateOpened
350                    | HeaderSyncing
351                    | HeaderCurrent
352                    | ProofReady
353                    | ResolutionTransportReady
354                    | DnssecVerified
355                    | DaneOriginVerified
356                    | BrowserBridgeReady
357                    | Active,
358                Degraded | Revoked | Stopped
359            )
360            | (Degraded | Revoked, Stopped)
361    )
362}
363
364/// Browser-runtime lifecycle or counter failure.
365#[derive(Clone, Copy, Debug, Eq, PartialEq)]
366pub enum RuntimeError {
367    /// The all-zero runtime session sentinel is forbidden.
368    ZeroSession,
369    /// Requested authority transition is not in the required state graph.
370    InvalidAuthorityTransition,
371    /// A monotonic generation or event counter cannot advance.
372    CounterExhausted,
373    /// Operations cannot be admitted after the terminal stopped state.
374    Stopped,
375    /// Resolution work was requested before the authority transport was ready.
376    AuthorityNotReady,
377}
378
379impl fmt::Display for RuntimeError {
380    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
381        match self {
382            Self::ZeroSession => formatter.write_str("runtime session must be nonzero"),
383            Self::InvalidAuthorityTransition => {
384                formatter.write_str("invalid browser authority state transition")
385            }
386            Self::CounterExhausted => formatter.write_str("browser runtime counter exhausted"),
387            Self::Stopped => formatter.write_str("browser runtime is stopped"),
388            Self::AuthorityNotReady => {
389                formatter.write_str("browser runtime authority is not ready")
390            }
391        }
392    }
393}
394
395impl Error for RuntimeError {}
396
397#[cfg(test)]
398#[allow(
399    clippy::unwrap_used,
400    reason = "tests fail immediately on invalid lifecycle fixtures"
401)]
402mod tests {
403    use super::*;
404
405    const ALL_STATES: [AuthorityState; 13] = [
406        AuthorityState::Uninitialized,
407        AuthorityState::LocalStateOpened,
408        AuthorityState::HeaderSyncing,
409        AuthorityState::HeaderCurrent,
410        AuthorityState::ProofReady,
411        AuthorityState::ResolutionTransportReady,
412        AuthorityState::DnssecVerified,
413        AuthorityState::DaneOriginVerified,
414        AuthorityState::BrowserBridgeReady,
415        AuthorityState::Active,
416        AuthorityState::Degraded,
417        AuthorityState::Revoked,
418        AuthorityState::Stopped,
419    ];
420
421    const ALLOWED_TRANSITIONS: &[(AuthorityState, AuthorityState)] = &[
422        (
423            AuthorityState::Uninitialized,
424            AuthorityState::LocalStateOpened,
425        ),
426        (
427            AuthorityState::LocalStateOpened,
428            AuthorityState::HeaderSyncing,
429        ),
430        (AuthorityState::HeaderSyncing, AuthorityState::HeaderCurrent),
431        (AuthorityState::HeaderCurrent, AuthorityState::ProofReady),
432        (
433            AuthorityState::ProofReady,
434            AuthorityState::ResolutionTransportReady,
435        ),
436        (
437            AuthorityState::ResolutionTransportReady,
438            AuthorityState::DnssecVerified,
439        ),
440        (
441            AuthorityState::ResolutionTransportReady,
442            AuthorityState::BrowserBridgeReady,
443        ),
444        (
445            AuthorityState::DnssecVerified,
446            AuthorityState::DaneOriginVerified,
447        ),
448        (
449            AuthorityState::DnssecVerified,
450            AuthorityState::BrowserBridgeReady,
451        ),
452        (
453            AuthorityState::DaneOriginVerified,
454            AuthorityState::BrowserBridgeReady,
455        ),
456        (AuthorityState::BrowserBridgeReady, AuthorityState::Active),
457        (AuthorityState::Degraded, AuthorityState::HeaderSyncing),
458        (AuthorityState::Revoked, AuthorityState::HeaderSyncing),
459        (AuthorityState::Degraded, AuthorityState::Stopped),
460        (AuthorityState::Revoked, AuthorityState::Stopped),
461    ];
462
463    fn session(byte: u8) -> RuntimeSessionId {
464        RuntimeSessionId::new([byte; 16]).unwrap()
465    }
466
467    fn make_resolution_ready(runtime: &mut BrowserRuntime) {
468        for state in [
469            AuthorityState::LocalStateOpened,
470            AuthorityState::HeaderSyncing,
471            AuthorityState::HeaderCurrent,
472            AuthorityState::ProofReady,
473            AuthorityState::ResolutionTransportReady,
474        ] {
475            runtime.transition(state).unwrap();
476        }
477    }
478
479    #[test]
480    fn advances_only_through_the_required_authority_graph() {
481        let mut runtime = BrowserRuntime::new(session(1));
482        for state in [
483            AuthorityState::LocalStateOpened,
484            AuthorityState::HeaderSyncing,
485            AuthorityState::HeaderCurrent,
486            AuthorityState::ProofReady,
487            AuthorityState::ResolutionTransportReady,
488            AuthorityState::DnssecVerified,
489            AuthorityState::DaneOriginVerified,
490            AuthorityState::BrowserBridgeReady,
491            AuthorityState::Active,
492        ] {
493            runtime.transition(state).unwrap();
494        }
495        assert_eq!(runtime.authority_state(), AuthorityState::Active);
496        assert_eq!(runtime.snapshot().event_sequence, 9);
497        assert_eq!(
498            runtime.transition(AuthorityState::HeaderCurrent),
499            Err(RuntimeError::InvalidAuthorityTransition)
500        );
501        assert_eq!(runtime.authority_state(), AuthorityState::Active);
502    }
503
504    #[test]
505    fn failure_paths_are_explicit_and_stopped_is_terminal() {
506        let mut runtime = BrowserRuntime::new(session(2));
507        assert_eq!(runtime.admit_event(), Err(RuntimeError::AuthorityNotReady));
508        runtime.transition(AuthorityState::Degraded).unwrap();
509        runtime.transition(AuthorityState::HeaderSyncing).unwrap();
510        runtime.transition(AuthorityState::Revoked).unwrap();
511        runtime.transition(AuthorityState::Stopped).unwrap();
512        assert_eq!(
513            runtime.transition(AuthorityState::HeaderSyncing),
514            Err(RuntimeError::InvalidAuthorityTransition)
515        );
516        assert_eq!(runtime.admit_event(), Err(RuntimeError::Stopped));
517    }
518
519    #[test]
520    fn policy_change_revokes_and_advances_both_clocks() {
521        let mut runtime = BrowserRuntime::new(session(3));
522        runtime
523            .transition(AuthorityState::LocalStateOpened)
524            .unwrap();
525        let before = runtime.snapshot();
526        let after = runtime.policy_changed().unwrap();
527        assert_eq!(after.generation, before.generation + 1);
528        assert_eq!(after.event_sequence, before.event_sequence + 1);
529        assert_eq!(after.authority_state, AuthorityState::Revoked);
530    }
531
532    #[test]
533    fn admission_stamps_reject_other_sessions_generations_and_future_events() {
534        let mut first = BrowserRuntime::new(session(4));
535        let mut second = BrowserRuntime::new(session(5));
536        make_resolution_ready(&mut first);
537        make_resolution_ready(&mut second);
538        let first_stamp = first.admit_event().unwrap();
539        let second_stamp = second.admit_event().unwrap();
540        assert!(first.admits(first_stamp));
541        assert!(!first.admits(second_stamp));
542
543        first.policy_changed().unwrap();
544        assert!(!first.admits(first_stamp));
545
546        let future = RuntimeStamp {
547            session: first.snapshot().session,
548            generation: first.snapshot().generation,
549            event_sequence: first.snapshot().event_sequence + 1,
550        };
551        assert!(!first.admits(future));
552    }
553
554    #[test]
555    fn rejects_zero_runtime_session() {
556        assert_eq!(
557            RuntimeSessionId::new([0; 16]),
558            Err(RuntimeError::ZeroSession)
559        );
560        assert_eq!(
561            RuntimeSessionId::try_from([0; 16]),
562            Err(RuntimeError::ZeroSession)
563        );
564        assert_eq!(session(9).into_bytes(), [9; 16]);
565    }
566
567    #[test]
568    fn authority_discriminants_remain_stable() {
569        for (index, state) in ALL_STATES.into_iter().enumerate() {
570            assert_eq!(usize::from(state as u8), index);
571        }
572    }
573
574    #[test]
575    fn transition_matrix_is_exhaustive() {
576        for current in ALL_STATES {
577            for next in ALL_STATES {
578                let expected = ALLOWED_TRANSITIONS.contains(&(current, next))
579                    || (matches!(
580                        current,
581                        AuthorityState::Uninitialized
582                            | AuthorityState::LocalStateOpened
583                            | AuthorityState::HeaderSyncing
584                            | AuthorityState::HeaderCurrent
585                            | AuthorityState::ProofReady
586                            | AuthorityState::ResolutionTransportReady
587                            | AuthorityState::DnssecVerified
588                            | AuthorityState::DaneOriginVerified
589                            | AuthorityState::BrowserBridgeReady
590                            | AuthorityState::Active
591                    ) && matches!(
592                        next,
593                        AuthorityState::Degraded
594                            | AuthorityState::Revoked
595                            | AuthorityState::Stopped
596                    ));
597                assert_eq!(
598                    valid_authority_transition(current, next),
599                    expected,
600                    "unexpected transition result for {current:?} -> {next:?}"
601                );
602            }
603        }
604    }
605
606    #[test]
607    fn bridge_can_start_before_navigation_and_after_icann_authenticated_absence() {
608        let mut startup = BrowserRuntime::new(session(6));
609        make_resolution_ready(&mut startup);
610        startup
611            .transition(AuthorityState::BrowserBridgeReady)
612            .unwrap();
613        startup.transition(AuthorityState::Active).unwrap();
614        assert_eq!(startup.authority_state(), AuthorityState::Active);
615
616        let mut webpki = BrowserRuntime::new(session(7));
617        make_resolution_ready(&mut webpki);
618        webpki.transition(AuthorityState::DnssecVerified).unwrap();
619        webpki
620            .transition(AuthorityState::BrowserBridgeReady)
621            .unwrap();
622        webpki.transition(AuthorityState::Active).unwrap();
623        assert_eq!(webpki.authority_state(), AuthorityState::Active);
624    }
625
626    #[test]
627    fn admitted_stamp_is_rejected_while_degraded_revoked_or_stopped() {
628        for terminal in [
629            AuthorityState::Degraded,
630            AuthorityState::Revoked,
631            AuthorityState::Stopped,
632        ] {
633            let mut runtime = BrowserRuntime::new(session(8));
634            make_resolution_ready(&mut runtime);
635            let stamp = runtime.admit_event().unwrap();
636            assert!(runtime.admits(stamp));
637            runtime.transition(terminal).unwrap();
638            assert!(!runtime.admits(stamp), "stamp survived {terminal:?}");
639        }
640    }
641
642    #[test]
643    fn admitted_stamp_cannot_resurrect_after_failure_recovery() {
644        for failure in [AuthorityState::Degraded, AuthorityState::Revoked] {
645            let mut runtime = BrowserRuntime::new(session(10));
646            make_resolution_ready(&mut runtime);
647            let stale = runtime.admit_event().unwrap();
648            runtime.transition(failure).unwrap();
649            for state in [
650                AuthorityState::HeaderSyncing,
651                AuthorityState::HeaderCurrent,
652                AuthorityState::ProofReady,
653                AuthorityState::ResolutionTransportReady,
654            ] {
655                runtime.transition(state).unwrap();
656            }
657            assert!(
658                !runtime.admits(stale),
659                "stamp resurrected after {failure:?} recovery"
660            );
661            let current = runtime.admit_event().unwrap();
662            assert!(runtime.admits(current));
663        }
664    }
665
666    #[test]
667    fn maximum_event_sequence_is_a_valid_final_snapshot() {
668        let mut runtime = BrowserRuntime::new(session(11));
669        runtime.event_sequence = u64::MAX - 1;
670        let snapshot = runtime
671            .transition(AuthorityState::LocalStateOpened)
672            .unwrap();
673        assert_eq!(snapshot.event_sequence(), u64::MAX);
674        assert_eq!(
675            runtime.transition(AuthorityState::HeaderSyncing),
676            Err(RuntimeError::CounterExhausted)
677        );
678    }
679}