zingolib 7.0.0

Zingo backend library.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
//! The Mixnet Mode session driver's types (ADR 0024, decision 2): the
//! typed status snapshot the one shared subscription delivers, the
//! per-session start policy, and the provisioning strategy a session hands
//! the driver at its go-online moment.
//!
//! The driver itself is [`crate::lightclient::LightClient::start_mixnet_session`];
//! this module holds its vocabulary. Status reaches subscribers through one
//! session-level `tokio::sync::watch` channel: the supervisor's tasks
//! publish transport transitions directly, the wallet's slot methods publish
//! the slot states, and nothing polls. The channel's keep-only-latest
//! semantics are the publication-sequencing guard — a stale snapshot can
//! never overtake a newer one.
#![forbid(unsafe_code)]

use std::sync::Arc;

use crate::mixnet::{DeathReport, Indicator};

/// One observation of Mixnet Mode for subscribers: the mode plus the
/// evidence scoped to it. Every field beyond the mode is `None` outside
/// the one mode it belongs to, exactly as the pull accessors are gated.
///
/// This is the wire snapshot the neon boundary carries to zingo-pc, the
/// boundary-carrier consumer ADR 0024 sequences last. The serde form omits
/// every absent field, so the `mode` token alone discriminates and each state
/// carries only its own evidence. The wire is pinned by the `wire_contract`
/// golden test below, the same way [`Indicator`]'s five tokens are pinned in
/// `mode.rs`.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(try_from = "RawMixnetStatus")]
pub struct MixnetStatus {
    /// The Mixnet Mode at this observation.
    pub mode: Indicator,
    /// The local SOCKS5 address, present exactly while ready.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub socks5_addr: Option<std::net::SocketAddr>,
    /// The Exit Node identities the ready transport bound, present only
    /// while ready.
    #[serde(skip_serializing_if = "Vec::is_empty", default)]
    pub exits: Vec<crate::mixnet::ExitNodeId>,
    /// The transport's latest bootstrap progress line, present only while
    /// bootstrapping, so a subscriber can narrate the connect race.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub bootstrap_detail: Option<String>,
    /// The latched death read whole, present only while died.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub death: Option<DeathReport>,
}

/// The lifted form before mode-scoping, through whose mirror serde reads
/// the typed fields — whose own deserializers refuse a malformed address
/// or exit entry as suspicious, because producer and consumer are pinned
/// to one code revision — so that [`TryFrom`] can refuse any evidence
/// outside its one mode.
#[derive(serde::Deserialize)]
struct RawMixnetStatus {
    mode: Indicator,
    #[serde(default)]
    socks5_addr: Option<std::net::SocketAddr>,
    #[serde(default)]
    exits: Vec<crate::mixnet::ExitNodeId>,
    #[serde(default)]
    bootstrap_detail: Option<String>,
    #[serde(default)]
    death: Option<DeathReport>,
}

impl TryFrom<RawMixnetStatus> for MixnetStatus {
    type Error = String;

    fn try_from(raw: RawMixnetStatus) -> Result<Self, Self::Error> {
        fn stray(field: &str, mode: Indicator) -> String {
            format!("{field} is not evidence for mode {}", mode.as_str())
        }
        // Route evidence rides every routable mode: earned Ready and
        // stale-proven PreviouslyProvenThisEpoch, which routes the same.
        if raw.socks5_addr.is_some() && !raw.mode.is_ready() {
            return Err(stray("socks5_addr", raw.mode));
        }
        if !raw.exits.is_empty() && !raw.mode.is_ready() {
            return Err(stray("exits", raw.mode));
        }
        if raw.bootstrap_detail.is_some() && raw.mode != Indicator::Bootstrapping {
            return Err(stray("bootstrap_detail", raw.mode));
        }
        if raw.death.is_some() && raw.mode != Indicator::Died {
            return Err(stray("death", raw.mode));
        }
        Ok(MixnetStatus {
            mode: raw.mode,
            socks5_addr: raw.socks5_addr,
            exits: raw.exits,
            bootstrap_detail: raw.bootstrap_detail,
            death: raw.death,
        })
    }
}

impl MixnetStatus {
    /// The snapshot of a slot state that carries no transport — unattached
    /// or switched off — where every transport-scoped field is absent.
    pub(crate) fn slot_only(mode: Indicator) -> Self {
        MixnetStatus {
            mode,
            socks5_addr: None,
            exits: Vec::new(),
            bootstrap_detail: None,
            death: None,
        }
    }

    /// A status carrying only the evidence its mode offers — route fields
    /// ride the routable modes and the death rides `Died` alone — so every
    /// published shape satisfies the wire guard by construction.
    pub(crate) fn evidenced(
        mode: Indicator,
        socks5_addr: Option<std::net::SocketAddr>,
        exits: Vec<crate::mixnet::ExitNodeId>,
        death: Option<DeathReport>,
    ) -> Self {
        let routable = mode.is_ready();
        MixnetStatus {
            mode,
            socks5_addr: if routable { socks5_addr } else { None },
            exits: if routable { exits } else { Vec::new() },
            bootstrap_detail: None,
            death: if mode == Indicator::Died { death } else { None },
        }
    }
}

/// The shared sending half of the session's one status channel. The wallet
/// owns it for the session's life and clones it into each transport's
/// supervisor tasks, so successive transports (an enable after a disable)
/// publish into the same channel a subscriber holds.
pub(crate) type StatusPublisher = Arc<tokio::sync::watch::Sender<MixnetStatus>>;

/// A fresh session-level status channel, opened in the ground state:
/// unattached, which carries no online intent. Receivers come from
/// `subscribe()` on the sender, so only the publisher is kept.
pub(crate) fn status_publisher() -> StatusPublisher {
    let (sender, _initial_receiver) =
        tokio::sync::watch::channel(MixnetStatus::slot_only(Indicator::Unattached));
    Arc::new(sender)
}

/// How a session's go-online moment treats the mixnet (ADR 0024, consent
/// at start). A two-variant enum rather than a boolean so the parameter
/// says what the opt-out is: a per-session consent act, never a persisted
/// preference.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MixnetStartPolicy {
    /// The default for every connected session: force Mixnet Mode on by
    /// provisioning the transport now, so the bootstrap overlaps sync and
    /// the mixnet-only surfaces are protected from the session's start.
    ForcedOn,
    /// The user's startup opt-out — the explicit act that reaches
    /// switched off and sets the session's transmit policy to nakednet, so
    /// sends travel over the indexer while the mixnet-only surfaces refuse.
    /// Never persisted.
    OptedOutThisSession,
}

/// How the transport comes to exist at the go-online moment — the one
/// dimension along which consumers legitimately differ (ADR 0011, ADR
/// 0024): a desktop session spawns the bundled binary, a mobile platform
/// that hosts the proxy itself hands the wallet an address to attach to.
#[derive(Debug)]
pub enum ProvisionStrategy<'a> {
    /// Spawn the bundled `nym-proxy` binary, resolving its path from the
    /// consumer's platform hints through the one precedence rule in
    /// [`crate::mixnet::provision`].
    Spawn(crate::mixnet::provision::SpawnHints<'a>),
    /// Attach to an already-running, mobile-platform-hosted SOCKS5 endpoint.
    Attach {
        /// The endpoint's socket address.
        socks5_addr: &'a str,
        /// The Exit Node identities the mobile platform host reports as bound.
        exits: &'a [crate::mixnet::ExitNodeId],
    },
}

/// The golden wire pins for the status snapshot (ADR 0024, the boundary-carrier
/// mint). Each state is pinned literally in both directions: serializing the
/// canonical value produces exactly the bytes, and those bytes lift back to the
/// value. A drift here is a breaking change to zingo-pc's neon boundary, the
/// same contract `mode.rs`'s `wire_contract` keeps for the five mode tokens.
#[cfg(test)]
mod wire_contract {
    use std::time::{Duration, UNIX_EPOCH};

    use zingo_net_diag::{NetOpFailure, NetOpStage};

    use super::MixnetStatus;
    use crate::mixnet::{DeathReport, Indicator};

    // A fixed latch moment (2025-07-30T18:26:40Z) so the death pins are
    // deterministic; the wire carries it as milliseconds since the epoch.
    fn fixed_at() -> std::time::SystemTime {
        UNIX_EPOCH + Duration::from_millis(1_753_900_000_000)
    }

    fn pin(status: &MixnetStatus, json: &str) {
        assert_eq!(serde_json::to_string(status).unwrap(), json);
        assert_eq!(&serde_json::from_str::<MixnetStatus>(json).unwrap(), status);
    }

    /// HYPOTHESIS: producer and consumer are pinned to one code revision,
    /// so a malformed wire value is suspicious — a Ready snapshot whose
    /// address is not a socket address, or whose exits carry a blank
    /// entry, refuses to deserialize whole. Falsified if either form
    /// lifts.
    #[test]
    fn a_malformed_snapshot_is_suspicious_and_refuses() {
        for json in [
            r#"{"mode":"ready","socks5_addr":"localhost:1080"}"#,
            r#"{"mode":"ready","exits":[""]}"#,
        ] {
            serde_json::from_str::<MixnetStatus>(json)
                .expect_err("a malformed wire value must refuse as suspicious");
        }
    }

    #[test]
    fn unattached_carries_only_its_token() {
        pin(
            &MixnetStatus::slot_only(Indicator::Unattached),
            r#"{"mode":"unattached"}"#,
        );
    }

    #[test]
    fn switched_off_carries_only_its_token() {
        pin(
            &MixnetStatus::slot_only(Indicator::SwitchedOff),
            r#"{"mode":"switched_off"}"#,
        );
    }

    #[test]
    fn bootstrapping_carries_its_narration() {
        pin(
            &MixnetStatus {
                mode: Indicator::Bootstrapping,
                socks5_addr: None,
                exits: Vec::new(),
                bootstrap_detail: Some("connecting to gateway".into()),
                death: None,
            },
            r#"{"mode":"bootstrapping","bootstrap_detail":"connecting to gateway"}"#,
        );
    }

    #[test]
    fn ready_carries_its_socks5_addr() {
        pin(
            &MixnetStatus {
                mode: Indicator::Ready,
                socks5_addr: Some("127.0.0.1:1080".parse().expect("the test address parses")),
                exits: Vec::new(),
                bootstrap_detail: None,
                death: None,
            },
            r#"{"mode":"ready","socks5_addr":"127.0.0.1:1080"}"#,
        );
    }

    #[test]
    fn ready_carries_its_bound_exits() {
        pin(
            &MixnetStatus {
                mode: Indicator::Ready,
                socks5_addr: Some("127.0.0.1:1080".parse().expect("the test address parses")),
                exits: vec!["exit-alpha".into(), "exit-beta".into()],
                bootstrap_detail: None,
                death: None,
            },
            r#"{"mode":"ready","socks5_addr":"127.0.0.1:1080","exits":["exit-alpha","exit-beta"]}"#,
        );
    }

    #[test]
    fn died_carries_a_typed_cause() {
        pin(
            &MixnetStatus {
                mode: Indicator::Died,
                socks5_addr: None,
                exits: Vec::new(),
                bootstrap_detail: None,
                death: Some(DeathReport {
                    at: fixed_at(),
                    detail: Some(NetOpFailure {
                        stage: NetOpStage::TimedOut { after_ms: 25000 },
                        target: "https://indexer.example:443".into(),
                        cause_chain: vec!["deadline elapsed".into()],
                    }),
                }),
            },
            r#"{"mode":"died","death":{"at":1753900000000,"detail":{"stage":{"timed-out":{"after_ms":25000}},"target":"https://indexer.example:443","cause_chain":["deadline elapsed"]}}}"#,
        );
    }

    #[test]
    fn died_without_a_held_cause_omits_the_detail() {
        pin(
            &MixnetStatus {
                mode: Indicator::Died,
                socks5_addr: None,
                exits: Vec::new(),
                bootstrap_detail: None,
                death: Some(DeathReport {
                    at: fixed_at(),
                    detail: None,
                }),
            },
            r#"{"mode":"died","death":{"at":1753900000000}}"#,
        );
    }

    #[test]
    fn a_hostile_at_lifts_to_a_result_not_a_panic() {
        // u64::MAX milliseconds since the epoch overflows Windows'
        // SystemTime representation while Linux absorbs it, so the codec's
        // checked add may legitimately return either verdict by platform.
        // The contract under test is only that hostile wire always reaches
        // a Result, never a panic — the failure an unchecked
        // `UNIX_EPOCH + Duration` exhibited on Windows.
        let hostile = format!(r#"{{"mode":"died","death":{{"at":{}}}}}"#, u64::MAX);
        let _ = serde_json::from_str::<MixnetStatus>(&hostile);
    }

    #[test]
    fn stray_evidence_is_refused() {
        // Each field lifts only inside its one mode; anywhere else the
        // wire is refused rather than silently carried, keeping the
        // struct's mode-scoping invariant true on both directions.
        for hostile in [
            r#"{"mode":"unattached","socks5_addr":"127.0.0.1:1080"}"#,
            r#"{"mode":"ready","bootstrap_detail":"connecting to gateway"}"#,
            r#"{"mode":"bootstrapping","death":{"at":0}}"#,
            r#"{"mode":"bootstrapping","exits":["exit-alpha"]}"#,
        ] {
            assert!(
                serde_json::from_str::<MixnetStatus>(hostile).is_err(),
                "lifted stray evidence: {hostile}"
            );
        }
    }

    #[test]
    fn every_stage_token_is_its_display_mint() {
        // Display's kebab rendering is the mint; the serde discriminant
        // must never drift from it. The match makes the list exhaustive:
        // a new variant fails to compile here until its token is pinned.
        let unit_stages = [
            NetOpStage::ProxyLaunch,
            NetOpStage::RouteResolution,
            NetOpStage::RemoteConnect,
            NetOpStage::LocalProxyConnect,
            NetOpStage::SocksHandshake,
            NetOpStage::TunnelTransport,
            NetOpStage::RemoteTls,
            NetOpStage::RemoteHttp,
            NetOpStage::PayloadDecode,
        ];
        for stage in &unit_stages {
            match stage {
                NetOpStage::ProxyLaunch
                | NetOpStage::RouteResolution
                | NetOpStage::RemoteConnect
                | NetOpStage::LocalProxyConnect
                | NetOpStage::SocksHandshake
                | NetOpStage::TunnelTransport
                | NetOpStage::RemoteTls
                | NetOpStage::RemoteHttp
                | NetOpStage::PayloadDecode
                | NetOpStage::TimedOut { .. } => {}
            }
            assert_eq!(
                serde_json::to_string(stage).unwrap(),
                format!("\"{stage}\"")
            );
        }
        // The one fielded variant: the discriminant is still Display's
        // token; the bound rides as a field, not parenthesized text.
        assert_eq!(
            serde_json::to_string(&NetOpStage::TimedOut { after_ms: 7 }).unwrap(),
            r#"{"timed-out":{"after_ms":7}}"#,
        );
    }

    #[test]
    fn a_unit_stage_is_its_kebab_token_not_a_substring() {
        // The discriminant is the same kebab token Display keeps, so a consumer
        // matches the variant; the cause chain stays layered, never joined.
        let failure = NetOpFailure {
            stage: NetOpStage::RemoteTls,
            target: "https://indexer.example:443".into(),
            cause_chain: vec!["handshake failed".into(), "certificate expired".into()],
        };
        assert_eq!(
            serde_json::to_string(&failure).unwrap(),
            r#"{"stage":"remote-tls","target":"https://indexer.example:443","cause_chain":["handshake failed","certificate expired"]}"#,
        );
    }
}