stem_rs/descriptor/
consensus.rs

1//! Network status consensus document parsing.
2//!
3//! This module provides types for parsing Tor network status consensus documents
4//! which describe the current state of the Tor network including all known relays.
5//!
6//! # Overview
7//!
8//! Network status documents are the authoritative source of information about
9//! the Tor network. They come in two forms:
10//!
11//! - **Votes**: Individual directory authority opinions about the network
12//! - **Consensus**: The agreed-upon view signed by multiple authorities
13//!
14//! Clients download the consensus to learn about available relays, their
15//! capabilities, and which relays are recommended for different purposes.
16//!
17//! # Document Types
18//!
19//! | Type | Description |
20//! |------|-------------|
21//! | Consensus | Agreed network status signed by authorities |
22//! | Vote | Individual authority's view before consensus |
23//! | Microdesc Consensus | Consensus using microdescriptor hashes |
24//!
25//! # Validity Times
26//!
27//! Consensus documents have three important timestamps:
28//!
29//! - **valid-after**: When the consensus becomes valid
30//! - **fresh-until**: When clients should fetch a new consensus
31//! - **valid-until**: When the consensus expires completely
32//!
33//! Clients should fetch a new consensus between `fresh-until` and `valid-until`.
34//!
35//! # Document Format
36//!
37//! ```text
38//! network-status-version 3 [microdesc]
39//! vote-status consensus|vote
40//! consensus-method <N>
41//! valid-after <YYYY-MM-DD HH:MM:SS>
42//! fresh-until <YYYY-MM-DD HH:MM:SS>
43//! valid-until <YYYY-MM-DD HH:MM:SS>
44//! voting-delay <vote-seconds> <dist-seconds>
45//! known-flags <flag> <flag> ...
46//! recommended-client-protocols <proto>=<versions> ...
47//! required-client-protocols <proto>=<versions> ...
48//! params <key>=<value> ...
49//! dir-source <nickname> <identity> <hostname> <address> <dirport> <orport>
50//! ...
51//! directory-footer
52//! bandwidth-weights <key>=<value> ...
53//! directory-signature <identity> <signing-key-digest>
54//! -----BEGIN SIGNATURE-----
55//! <base64 signature>
56//! -----END SIGNATURE-----
57//! ```
58//!
59//! # Example
60//!
61//! ```rust,no_run
62//! use stem_rs::descriptor::{NetworkStatusDocument, Descriptor};
63//!
64//! let content = std::fs::read_to_string("cached-consensus").unwrap();
65//! let consensus = NetworkStatusDocument::parse(&content).unwrap();
66//!
67//! println!("Consensus method: {:?}", consensus.consensus_method);
68//! println!("Valid after: {}", consensus.valid_after);
69//! println!("Valid until: {}", consensus.valid_until);
70//! println!("Known flags: {:?}", consensus.known_flags);
71//! println!("Authorities: {}", consensus.authorities.len());
72//! println!("Signatures: {}", consensus.signatures.len());
73//!
74//! // Check protocol requirements
75//! if let Some(versions) = consensus.required_client_protocols.get("Link") {
76//!     println!("Required Link protocol versions: {:?}", versions);
77//! }
78//! ```
79//!
80//! # Shared Randomness
81//!
82//! Modern consensus documents include shared randomness values used for
83//! hidden service directory assignment. These are computed collaboratively
84//! by the directory authorities.
85//!
86//! # See Also
87//!
88//! - [`RouterStatusEntry`](super::RouterStatusEntry) - Individual relay entries in consensus
89//! - [`DirectoryAuthority`](super::DirectoryAuthority) - Authority information
90//! - [Python Stem NetworkStatusDocument](https://stem.torproject.org/api/descriptor/networkstatus.html)
91
92use std::collections::HashMap;
93use std::fmt;
94use std::net::IpAddr;
95use std::str::FromStr;
96
97use chrono::{DateTime, NaiveDateTime, Utc};
98
99use crate::version::Version;
100use crate::Error;
101
102use super::authority::DirectoryAuthority;
103use super::{compute_digest, Descriptor, DigestEncoding, DigestHash};
104
105/// Shared randomness value from directory authority collaboration.
106///
107/// Directory authorities collaboratively generate random values that are
108/// used for hidden service directory assignment. Each consensus includes
109/// the current and previous shared randomness values.
110///
111/// # Fields
112///
113/// - `num_reveals`: Number of authorities that revealed their commitment
114/// - `value`: The base64-encoded random value
115#[derive(Debug, Clone, PartialEq)]
116pub struct SharedRandomness {
117    /// Number of authorities that participated in the reveal phase.
118    pub num_reveals: u32,
119    /// The shared random value (base64-encoded).
120    pub value: String,
121}
122
123/// A signature on a network status document.
124///
125/// Each directory authority signs the consensus with their signing key.
126/// The signature covers the document from the beginning through the
127/// `directory-signature` line.
128///
129/// # Fields
130///
131/// - `identity`: The authority's identity key fingerprint
132/// - `signing_key_digest`: Digest of the signing key used
133/// - `signature`: The PEM-encoded signature
134/// - `algorithm`: Optional algorithm identifier (e.g., "sha256")
135#[derive(Debug, Clone, PartialEq)]
136pub struct DocumentSignature {
137    /// The signing authority's identity key fingerprint (40 hex chars).
138    pub identity: String,
139    /// Digest of the signing key used for this signature.
140    pub signing_key_digest: String,
141    /// The PEM-encoded signature block.
142    pub signature: String,
143    /// Algorithm used (e.g., "sha256"), if specified.
144    pub algorithm: Option<String>,
145}
146
147/// A network status consensus or vote document.
148///
149/// This is the primary document that describes the state of the Tor network.
150/// Clients download the consensus to learn about available relays and their
151/// capabilities.
152///
153/// # Document Types
154///
155/// - **Consensus** (`is_consensus = true`): The agreed-upon network view
156/// - **Vote** (`is_vote = true`): An individual authority's opinion
157/// - **Microdesc** (`is_microdescriptor = true`): Uses microdescriptor hashes
158///
159/// # Validity Times
160///
161/// The document has three important timestamps that control its lifecycle:
162///
163/// ```text
164/// valid-after -----> fresh-until -----> valid-until
165///     |                  |                  |
166///     |   Document is    |   Should fetch   |   Document
167///     |   fresh/current  |   new consensus  |   expired
168/// ```
169///
170/// # Example
171///
172/// ```rust,no_run
173/// use stem_rs::descriptor::{NetworkStatusDocument, Descriptor};
174///
175/// let content = std::fs::read_to_string("cached-consensus").unwrap();
176/// let doc = NetworkStatusDocument::parse(&content).unwrap();
177///
178/// // Check document type
179/// if doc.is_consensus {
180///     println!("This is a consensus document");
181/// }
182///
183/// // Check validity
184/// let now = chrono::Utc::now();
185/// if now > doc.valid_until {
186///     println!("Consensus has expired!");
187/// } else if now > doc.fresh_until {
188///     println!("Should fetch a new consensus");
189/// }
190///
191/// // Check required protocols
192/// for (proto, versions) in &doc.required_client_protocols {
193///     println!("Required {}: {:?}", proto, versions);
194/// }
195/// ```
196///
197/// # Thread Safety
198///
199/// `NetworkStatusDocument` is `Send` and `Sync` as it contains only owned data.
200#[derive(Debug, Clone, PartialEq)]
201pub struct NetworkStatusDocument {
202    /// Network status version (typically 3).
203    pub version: u32,
204    /// Version flavor (empty string or "microdesc").
205    pub version_flavor: String,
206    /// Whether this is a consensus document.
207    pub is_consensus: bool,
208    /// Whether this is a vote document.
209    pub is_vote: bool,
210    /// Whether this uses microdescriptor format.
211    pub is_microdescriptor: bool,
212    /// Consensus method used (consensus only).
213    pub consensus_method: Option<u32>,
214    /// Supported consensus methods (vote only).
215    pub consensus_methods: Option<Vec<u32>>,
216    /// When this vote was published (vote only).
217    pub published: Option<DateTime<Utc>>,
218    /// When this document becomes valid.
219    pub valid_after: DateTime<Utc>,
220    /// When clients should fetch a new document.
221    pub fresh_until: DateTime<Utc>,
222    /// When this document expires.
223    pub valid_until: DateTime<Utc>,
224    /// Seconds authorities wait for votes.
225    pub vote_delay: Option<u32>,
226    /// Seconds authorities wait for signatures.
227    pub dist_delay: Option<u32>,
228    /// Recommended Tor versions for clients.
229    pub client_versions: Vec<Version>,
230    /// Recommended Tor versions for relays.
231    pub server_versions: Vec<Version>,
232    /// Flags that may appear on relay entries.
233    pub known_flags: Vec<String>,
234    /// Recommended protocol versions for clients.
235    pub recommended_client_protocols: HashMap<String, Vec<u32>>,
236    /// Recommended protocol versions for relays.
237    pub recommended_relay_protocols: HashMap<String, Vec<u32>>,
238    /// Required protocol versions for clients.
239    pub required_client_protocols: HashMap<String, Vec<u32>>,
240    /// Required protocol versions for relays.
241    pub required_relay_protocols: HashMap<String, Vec<u32>>,
242    /// Consensus parameters (key=value pairs).
243    pub params: HashMap<String, i32>,
244    /// Previous shared randomness value.
245    pub shared_randomness_previous: Option<SharedRandomness>,
246    /// Current shared randomness value.
247    pub shared_randomness_current: Option<SharedRandomness>,
248    /// Bandwidth weights for path selection.
249    pub bandwidth_weights: HashMap<String, i32>,
250    /// Directory authorities that contributed to this document.
251    pub authorities: Vec<DirectoryAuthority>,
252    /// Signatures from directory authorities.
253    pub signatures: Vec<DocumentSignature>,
254    /// Raw bytes of the original document.
255    raw_content: Vec<u8>,
256    /// Lines that were not recognized during parsing.
257    unrecognized_lines: Vec<String>,
258}
259
260impl NetworkStatusDocument {
261    fn parse_timestamp(value: &str) -> Result<DateTime<Utc>, Error> {
262        let datetime =
263            NaiveDateTime::parse_from_str(value.trim(), "%Y-%m-%d %H:%M:%S").map_err(|e| {
264                Error::Parse {
265                    location: "timestamp".to_string(),
266                    reason: format!("invalid datetime: {} - {}", value, e),
267                }
268            })?;
269        Ok(datetime.and_utc())
270    }
271
272    fn parse_protocols(value: &str) -> HashMap<String, Vec<u32>> {
273        let mut protocols = HashMap::new();
274        for entry in value.split_whitespace() {
275            if let Some(eq_pos) = entry.find('=') {
276                let proto_name = &entry[..eq_pos];
277                let versions_str = &entry[eq_pos + 1..];
278                let versions: Vec<u32> = versions_str
279                    .split(',')
280                    .filter_map(|v| {
281                        if let Some(dash) = v.find('-') {
282                            let start: u32 = v[..dash].parse().ok()?;
283                            let end: u32 = v[dash + 1..].parse().ok()?;
284                            Some((start..=end).collect::<Vec<_>>())
285                        } else {
286                            v.parse().ok().map(|n| vec![n])
287                        }
288                    })
289                    .flatten()
290                    .collect();
291                protocols.insert(proto_name.to_string(), versions);
292            }
293        }
294        protocols
295    }
296
297    fn parse_params(value: &str) -> HashMap<String, i32> {
298        let mut params = HashMap::new();
299        for entry in value.split_whitespace() {
300            if let Some(eq_pos) = entry.find('=') {
301                let key = &entry[..eq_pos];
302                let val_str = &entry[eq_pos + 1..];
303                if let Ok(val) = val_str.parse::<i32>() {
304                    params.insert(key.to_string(), val);
305                }
306            }
307        }
308        params
309    }
310
311    fn parse_shared_randomness(value: &str) -> Option<SharedRandomness> {
312        let parts: Vec<&str> = value.split_whitespace().collect();
313        if parts.len() >= 2 {
314            let num_reveals = parts[0].parse().ok()?;
315            let value = parts[1].to_string();
316            Some(SharedRandomness { num_reveals, value })
317        } else {
318            None
319        }
320    }
321
322    fn parse_versions(value: &str) -> Vec<Version> {
323        value
324            .split(',')
325            .filter_map(|v| {
326                let v = v.trim();
327                if v.is_empty() {
328                    None
329                } else {
330                    Version::parse(v).ok()
331                }
332            })
333            .collect()
334    }
335
336    fn extract_pem_block(lines: &[&str], start_idx: usize) -> (String, usize) {
337        let mut block = String::new();
338        let mut idx = start_idx;
339        while idx < lines.len() {
340            let line = lines[idx];
341            block.push_str(line);
342            block.push('\n');
343            if line.starts_with("-----END ") {
344                break;
345            }
346            idx += 1;
347        }
348        (block.trim_end().to_string(), idx)
349    }
350
351    fn parse_dir_source(value: &str) -> Result<DirectoryAuthority, Error> {
352        let parts: Vec<&str> = value.split_whitespace().collect();
353        if parts.len() < 6 {
354            return Err(Error::Parse {
355                location: "dir-source".to_string(),
356                reason: "dir-source requires 6 fields".to_string(),
357            });
358        }
359        let nickname = parts[0].to_string();
360        let v3ident = parts[1].to_string();
361        let hostname = parts[2].to_string();
362        let address: IpAddr = parts[3].parse().map_err(|_| Error::Parse {
363            location: "dir-source".to_string(),
364            reason: format!("invalid address: {}", parts[3]),
365        })?;
366        let dir_port: Option<u16> = {
367            let port: u16 = parts[4].parse().map_err(|_| Error::Parse {
368                location: "dir-source".to_string(),
369                reason: format!("invalid dir_port: {}", parts[4]),
370            })?;
371            if port == 0 {
372                None
373            } else {
374                Some(port)
375            }
376        };
377        let or_port: u16 = parts[5].parse().map_err(|_| Error::Parse {
378            location: "dir-source".to_string(),
379            reason: format!("invalid or_port: {}", parts[5]),
380        })?;
381        let is_legacy = nickname.ends_with("-legacy");
382        Ok(DirectoryAuthority {
383            nickname,
384            v3ident,
385            hostname,
386            address,
387            dir_port,
388            or_port,
389            is_legacy,
390            contact: None,
391            vote_digest: None,
392            legacy_dir_key: None,
393            key_certificate: None,
394            is_shared_randomness_participate: false,
395            shared_randomness_commitments: Vec::new(),
396            shared_randomness_previous_reveal_count: None,
397            shared_randomness_previous_value: None,
398            shared_randomness_current_reveal_count: None,
399            shared_randomness_current_value: None,
400            raw_content: Vec::new(),
401            unrecognized_lines: Vec::new(),
402        })
403    }
404}
405
406impl Descriptor for NetworkStatusDocument {
407    fn parse(content: &str) -> Result<Self, Error> {
408        let raw_content = content.as_bytes().to_vec();
409        let lines: Vec<&str> = content.lines().collect();
410
411        let mut version: u32 = 3;
412        let mut version_flavor = "ns".to_string();
413        let mut is_consensus = true;
414        let mut is_vote = false;
415        let mut is_microdescriptor = false;
416        let mut consensus_method: Option<u32> = None;
417        let mut consensus_methods: Option<Vec<u32>> = None;
418        let mut published: Option<DateTime<Utc>> = None;
419        let mut valid_after: Option<DateTime<Utc>> = None;
420        let mut fresh_until: Option<DateTime<Utc>> = None;
421        let mut valid_until: Option<DateTime<Utc>> = None;
422        let mut vote_delay: Option<u32> = None;
423        let mut dist_delay: Option<u32> = None;
424        let mut client_versions: Vec<Version> = Vec::new();
425        let mut server_versions: Vec<Version> = Vec::new();
426        let mut known_flags: Vec<String> = Vec::new();
427        let mut recommended_client_protocols: HashMap<String, Vec<u32>> = HashMap::new();
428        let mut recommended_relay_protocols: HashMap<String, Vec<u32>> = HashMap::new();
429        let mut required_client_protocols: HashMap<String, Vec<u32>> = HashMap::new();
430        let mut required_relay_protocols: HashMap<String, Vec<u32>> = HashMap::new();
431        let mut params: HashMap<String, i32> = HashMap::new();
432        let mut shared_randomness_previous: Option<SharedRandomness> = None;
433        let mut shared_randomness_current: Option<SharedRandomness> = None;
434        let mut bandwidth_weights: HashMap<String, i32> = HashMap::new();
435        let mut authorities: Vec<DirectoryAuthority> = Vec::new();
436        let mut signatures: Vec<DocumentSignature> = Vec::new();
437        let mut unrecognized_lines: Vec<String> = Vec::new();
438        let mut current_authority: Option<DirectoryAuthority> = None;
439
440        let mut idx = 0;
441        while idx < lines.len() {
442            let line = lines[idx];
443
444            if line.starts_with("@type ") {
445                idx += 1;
446                continue;
447            }
448
449            let (keyword, value) = if let Some(space_pos) = line.find(' ') {
450                (&line[..space_pos], line[space_pos + 1..].trim())
451            } else {
452                (line, "")
453            };
454
455            match keyword {
456                "network-status-version" => {
457                    let parts: Vec<&str> = value.split_whitespace().collect();
458                    if let Some(v) = parts.first() {
459                        version = v.parse().unwrap_or(3);
460                    }
461                    if let Some(flavor) = parts.get(1) {
462                        version_flavor = flavor.to_string();
463                        is_microdescriptor = *flavor == "microdesc";
464                    }
465                }
466                "vote-status" => {
467                    is_consensus = value == "consensus";
468                    is_vote = value == "vote";
469                }
470                "consensus-method" => {
471                    consensus_method = value.parse().ok();
472                }
473                "consensus-methods" => {
474                    consensus_methods = Some(
475                        value
476                            .split_whitespace()
477                            .filter_map(|v| v.parse().ok())
478                            .collect(),
479                    );
480                }
481                "published" => {
482                    published = Some(Self::parse_timestamp(value)?);
483                }
484                "valid-after" => {
485                    valid_after = Some(Self::parse_timestamp(value)?);
486                }
487                "fresh-until" => {
488                    fresh_until = Some(Self::parse_timestamp(value)?);
489                }
490                "valid-until" => {
491                    valid_until = Some(Self::parse_timestamp(value)?);
492                }
493                "voting-delay" => {
494                    let parts: Vec<&str> = value.split_whitespace().collect();
495                    if parts.len() >= 2 {
496                        vote_delay = parts[0].parse().ok();
497                        dist_delay = parts[1].parse().ok();
498                    }
499                }
500                "client-versions" => {
501                    client_versions = Self::parse_versions(value);
502                }
503                "server-versions" => {
504                    server_versions = Self::parse_versions(value);
505                }
506                "known-flags" => {
507                    known_flags = value.split_whitespace().map(|s| s.to_string()).collect();
508                }
509                "recommended-client-protocols" => {
510                    recommended_client_protocols = Self::parse_protocols(value);
511                }
512                "recommended-relay-protocols" => {
513                    recommended_relay_protocols = Self::parse_protocols(value);
514                }
515                "required-client-protocols" => {
516                    required_client_protocols = Self::parse_protocols(value);
517                }
518                "required-relay-protocols" => {
519                    required_relay_protocols = Self::parse_protocols(value);
520                }
521                "params" => {
522                    params = Self::parse_params(value);
523                }
524                "shared-rand-previous-value" => {
525                    shared_randomness_previous = Self::parse_shared_randomness(value);
526                }
527                "shared-rand-current-value" => {
528                    shared_randomness_current = Self::parse_shared_randomness(value);
529                }
530                "bandwidth-weights" => {
531                    bandwidth_weights = Self::parse_params(value);
532                }
533
534                "dir-source" => {
535                    if let Some(auth) = current_authority.take() {
536                        authorities.push(auth);
537                    }
538                    current_authority = Some(Self::parse_dir_source(value)?);
539                }
540                "contact" => {
541                    if let Some(ref mut auth) = current_authority {
542                        auth.contact = Some(value.to_string());
543                    }
544                }
545                "vote-digest" => {
546                    if let Some(ref mut auth) = current_authority {
547                        auth.vote_digest = Some(value.to_string());
548                    }
549                }
550                "legacy-dir-key" => {
551                    if let Some(ref mut auth) = current_authority {
552                        auth.legacy_dir_key = Some(value.to_string());
553                    }
554                }
555                "directory-signature" => {
556                    if let Some(auth) = current_authority.take() {
557                        authorities.push(auth);
558                    }
559                    let parts: Vec<&str> = value.split_whitespace().collect();
560                    let (algorithm, identity, signing_key_digest) = if parts.len() >= 3 {
561                        (
562                            Some(parts[0].to_string()),
563                            parts[1].to_string(),
564                            parts[2].to_string(),
565                        )
566                    } else if parts.len() >= 2 {
567                        (None, parts[0].to_string(), parts[1].to_string())
568                    } else {
569                        (None, String::new(), String::new())
570                    };
571                    let (signature, end_idx) = Self::extract_pem_block(&lines, idx + 1);
572                    signatures.push(DocumentSignature {
573                        identity,
574                        signing_key_digest,
575                        signature,
576                        algorithm,
577                    });
578                    idx = end_idx;
579                }
580                "r" | "s" | "v" | "pr" | "w" | "p" | "m" | "a" => {
581                    if let Some(auth) = current_authority.take() {
582                        authorities.push(auth);
583                    }
584                }
585                "directory-footer" => {}
586                _ => {
587                    if !line.is_empty() && !line.starts_with("-----") {
588                        unrecognized_lines.push(line.to_string());
589                    }
590                }
591            }
592            idx += 1;
593        }
594
595        if let Some(auth) = current_authority.take() {
596            authorities.push(auth);
597        }
598
599        let valid_after = valid_after.ok_or_else(|| Error::Parse {
600            location: "valid-after".to_string(),
601            reason: "missing valid-after".to_string(),
602        })?;
603        let fresh_until = fresh_until.ok_or_else(|| Error::Parse {
604            location: "fresh-until".to_string(),
605            reason: "missing fresh-until".to_string(),
606        })?;
607        let valid_until = valid_until.ok_or_else(|| Error::Parse {
608            location: "valid-until".to_string(),
609            reason: "missing valid-until".to_string(),
610        })?;
611
612        Ok(Self {
613            version,
614            version_flavor,
615            is_consensus,
616            is_vote,
617            is_microdescriptor,
618            consensus_method,
619            consensus_methods,
620            published,
621            valid_after,
622            fresh_until,
623            valid_until,
624            vote_delay,
625            dist_delay,
626            client_versions,
627            server_versions,
628            known_flags,
629            recommended_client_protocols,
630            recommended_relay_protocols,
631            required_client_protocols,
632            required_relay_protocols,
633            params,
634            shared_randomness_previous,
635            shared_randomness_current,
636            bandwidth_weights,
637            authorities,
638            signatures,
639            raw_content,
640            unrecognized_lines,
641        })
642    }
643
644    fn to_descriptor_string(&self) -> String {
645        let mut result = String::new();
646
647        if self.is_microdescriptor {
648            result.push_str(&format!(
649                "network-status-version {} microdesc\n",
650                self.version
651            ));
652        } else {
653            result.push_str(&format!("network-status-version {}\n", self.version));
654        }
655
656        if self.is_consensus {
657            result.push_str("vote-status consensus\n");
658        } else {
659            result.push_str("vote-status vote\n");
660        }
661
662        if let Some(method) = self.consensus_method {
663            result.push_str(&format!("consensus-method {}\n", method));
664        }
665
666        if let Some(ref methods) = self.consensus_methods {
667            let methods_str: Vec<String> = methods.iter().map(|m| m.to_string()).collect();
668            result.push_str(&format!("consensus-methods {}\n", methods_str.join(" ")));
669        }
670
671        if let Some(published) = self.published {
672            result.push_str(&format!(
673                "published {}\n",
674                published.format("%Y-%m-%d %H:%M:%S")
675            ));
676        }
677
678        result.push_str(&format!(
679            "valid-after {}\n",
680            self.valid_after.format("%Y-%m-%d %H:%M:%S")
681        ));
682        result.push_str(&format!(
683            "fresh-until {}\n",
684            self.fresh_until.format("%Y-%m-%d %H:%M:%S")
685        ));
686        result.push_str(&format!(
687            "valid-until {}\n",
688            self.valid_until.format("%Y-%m-%d %H:%M:%S")
689        ));
690
691        if let (Some(vote), Some(dist)) = (self.vote_delay, self.dist_delay) {
692            result.push_str(&format!("voting-delay {} {}\n", vote, dist));
693        }
694
695        if !self.client_versions.is_empty() {
696            let versions: Vec<String> =
697                self.client_versions.iter().map(|v| v.to_string()).collect();
698            result.push_str(&format!("client-versions {}\n", versions.join(",")));
699        }
700
701        if !self.server_versions.is_empty() {
702            let versions: Vec<String> =
703                self.server_versions.iter().map(|v| v.to_string()).collect();
704            result.push_str(&format!("server-versions {}\n", versions.join(",")));
705        }
706
707        if !self.known_flags.is_empty() {
708            result.push_str(&format!("known-flags {}\n", self.known_flags.join(" ")));
709        }
710
711        result
712    }
713
714    fn digest(&self, hash: DigestHash, encoding: DigestEncoding) -> Result<String, Error> {
715        Ok(compute_digest(&self.raw_content, hash, encoding))
716    }
717
718    fn raw_content(&self) -> &[u8] {
719        &self.raw_content
720    }
721
722    fn unrecognized_lines(&self) -> &[String] {
723        &self.unrecognized_lines
724    }
725}
726
727impl FromStr for NetworkStatusDocument {
728    type Err = Error;
729
730    fn from_str(s: &str) -> Result<Self, Self::Err> {
731        Self::parse(s)
732    }
733}
734
735impl fmt::Display for NetworkStatusDocument {
736    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
737        write!(f, "{}", self.to_descriptor_string())
738    }
739}
740
741#[cfg(test)]
742mod tests {
743    use super::*;
744
745    const EXAMPLE_CONSENSUS: &str = r#"network-status-version 3
746vote-status consensus
747consensus-method 26
748valid-after 2017-05-25 04:46:30
749fresh-until 2017-05-25 04:46:40
750valid-until 2017-05-25 04:46:50
751voting-delay 2 2
752client-versions 
753server-versions 
754known-flags Authority Exit Fast Guard HSDir NoEdConsensus Running Stable V2Dir Valid
755recommended-client-protocols Cons=1-2 Desc=1-2 DirCache=1 HSDir=1 HSIntro=3 HSRend=1 Link=4 LinkAuth=1 Microdesc=1-2 Relay=2
756recommended-relay-protocols Cons=1-2 Desc=1-2 DirCache=1 HSDir=1 HSIntro=3 HSRend=1 Link=4 LinkAuth=1 Microdesc=1-2 Relay=2
757required-client-protocols Cons=1-2 Desc=1-2 DirCache=1 HSDir=1 HSIntro=3 HSRend=1 Link=4 LinkAuth=1 Microdesc=1-2 Relay=2
758required-relay-protocols Cons=1 Desc=1 DirCache=1 HSDir=1 HSIntro=3 HSRend=1 Link=3-4 LinkAuth=1 Microdesc=1 Relay=1-2
759dir-source test001a 596CD48D61FDA4E868F4AA10FF559917BE3B1A35 127.0.0.1 127.0.0.1 7001 5001
760contact auth1@test.test
761vote-digest 2E7177224BBA39B505F7608FF376C07884CF926F
762dir-source test000a BCB380A633592C218757BEE11E630511A485658A 127.0.0.1 127.0.0.1 7000 5000
763contact auth0@test.test
764vote-digest 5DD41617166FFB82882A117EEFDA0353A2794DC5
765directory-footer
766bandwidth-weights Wbd=3333 Wbe=0 Wbg=0 Wbm=10000
767directory-signature 596CD48D61FDA4E868F4AA10FF559917BE3B1A35 9FBF54D6A62364320308A615BF4CF6B27B254FAD
768-----BEGIN SIGNATURE-----
769Ho0rLojfLHs9cSPFxe6znuGuFU8BvRr6gnH1gULTjUZO0NSQvo5N628KFeAsq+pT
770-----END SIGNATURE-----
771"#;
772
773    #[test]
774    fn test_parse_consensus() {
775        let doc = NetworkStatusDocument::parse(EXAMPLE_CONSENSUS).unwrap();
776        assert_eq!(doc.version, 3);
777        assert!(doc.is_consensus);
778        assert!(!doc.is_vote);
779        assert!(!doc.is_microdescriptor);
780        assert_eq!(doc.consensus_method, Some(26));
781        assert_eq!(doc.vote_delay, Some(2));
782        assert_eq!(doc.dist_delay, Some(2));
783    }
784
785    #[test]
786    fn test_parse_known_flags() {
787        let doc = NetworkStatusDocument::parse(EXAMPLE_CONSENSUS).unwrap();
788        assert!(doc.known_flags.contains(&"Authority".to_string()));
789        assert!(doc.known_flags.contains(&"Exit".to_string()));
790        assert!(doc.known_flags.contains(&"Fast".to_string()));
791        assert!(doc.known_flags.contains(&"Guard".to_string()));
792        assert!(doc.known_flags.contains(&"HSDir".to_string()));
793        assert!(doc.known_flags.contains(&"Running".to_string()));
794        assert!(doc.known_flags.contains(&"Stable".to_string()));
795        assert!(doc.known_flags.contains(&"Valid".to_string()));
796    }
797
798    #[test]
799    fn test_parse_protocols() {
800        let doc = NetworkStatusDocument::parse(EXAMPLE_CONSENSUS).unwrap();
801        assert_eq!(
802            doc.recommended_client_protocols.get("Cons"),
803            Some(&vec![1, 2])
804        );
805        assert_eq!(doc.recommended_client_protocols.get("Link"), Some(&vec![4]));
806        assert_eq!(doc.required_relay_protocols.get("Link"), Some(&vec![3, 4]));
807    }
808
809    #[test]
810    fn test_parse_authorities() {
811        let doc = NetworkStatusDocument::parse(EXAMPLE_CONSENSUS).unwrap();
812        assert_eq!(doc.authorities.len(), 2);
813        let auth1 = &doc.authorities[0];
814        assert_eq!(auth1.nickname, "test001a");
815        assert_eq!(auth1.v3ident, "596CD48D61FDA4E868F4AA10FF559917BE3B1A35");
816        assert_eq!(auth1.contact, Some("auth1@test.test".to_string()));
817        assert_eq!(
818            auth1.vote_digest,
819            Some("2E7177224BBA39B505F7608FF376C07884CF926F".to_string())
820        );
821    }
822
823    #[test]
824    fn test_parse_bandwidth_weights() {
825        let doc = NetworkStatusDocument::parse(EXAMPLE_CONSENSUS).unwrap();
826        assert_eq!(doc.bandwidth_weights.get("Wbd"), Some(&3333));
827        assert_eq!(doc.bandwidth_weights.get("Wbe"), Some(&0));
828        assert_eq!(doc.bandwidth_weights.get("Wbm"), Some(&10000));
829    }
830
831    #[test]
832    fn test_parse_signatures() {
833        let doc = NetworkStatusDocument::parse(EXAMPLE_CONSENSUS).unwrap();
834        assert_eq!(doc.signatures.len(), 1);
835        let sig = &doc.signatures[0];
836        assert_eq!(sig.identity, "596CD48D61FDA4E868F4AA10FF559917BE3B1A35");
837        assert_eq!(
838            sig.signing_key_digest,
839            "9FBF54D6A62364320308A615BF4CF6B27B254FAD"
840        );
841        assert!(sig.signature.contains("BEGIN SIGNATURE"));
842    }
843
844    #[test]
845    fn test_parse_timestamps() {
846        let doc = NetworkStatusDocument::parse(EXAMPLE_CONSENSUS).unwrap();
847        assert_eq!(
848            doc.valid_after.format("%Y-%m-%d %H:%M:%S").to_string(),
849            "2017-05-25 04:46:30"
850        );
851        assert_eq!(
852            doc.fresh_until.format("%Y-%m-%d %H:%M:%S").to_string(),
853            "2017-05-25 04:46:40"
854        );
855        assert_eq!(
856            doc.valid_until.format("%Y-%m-%d %H:%M:%S").to_string(),
857            "2017-05-25 04:46:50"
858        );
859    }
860
861    #[test]
862    fn test_microdescriptor_consensus() {
863        let content = "network-status-version 3 microdesc
864vote-status consensus
865valid-after 2017-05-25 04:46:30
866fresh-until 2017-05-25 04:46:40
867valid-until 2017-05-25 04:46:50
868";
869        let doc = NetworkStatusDocument::parse(content).unwrap();
870        assert!(doc.is_microdescriptor);
871        assert_eq!(doc.version_flavor, "microdesc");
872    }
873
874    use proptest::prelude::*;
875
876    fn valid_timestamp() -> impl Strategy<Value = DateTime<Utc>> {
877        (
878            2015u32..2025,
879            1u32..13,
880            1u32..29,
881            0u32..24,
882            0u32..60,
883            0u32..60,
884        )
885            .prop_map(|(year, month, day, hour, min, sec)| {
886                let naive = chrono::NaiveDate::from_ymd_opt(year as i32, month, day)
887                    .unwrap()
888                    .and_hms_opt(hour, min, sec)
889                    .unwrap();
890                naive.and_utc()
891            })
892    }
893
894    fn valid_flag() -> impl Strategy<Value = String> {
895        prop_oneof![
896            Just("Authority".to_string()),
897            Just("Exit".to_string()),
898            Just("Fast".to_string()),
899            Just("Guard".to_string()),
900            Just("HSDir".to_string()),
901            Just("Running".to_string()),
902            Just("Stable".to_string()),
903            Just("Valid".to_string()),
904        ]
905    }
906
907    fn simple_consensus() -> impl Strategy<Value = NetworkStatusDocument> {
908        (
909            valid_timestamp(),
910            valid_timestamp(),
911            valid_timestamp(),
912            proptest::collection::vec(valid_flag(), 1..5),
913            1u32..30,
914        )
915            .prop_map(|(valid_after, fresh_until, valid_until, flags, method)| {
916                let mut doc = NetworkStatusDocument {
917                    version: 3,
918                    version_flavor: String::new(),
919                    is_consensus: true,
920                    is_vote: false,
921                    is_microdescriptor: false,
922                    consensus_method: Some(method),
923                    consensus_methods: None,
924                    published: None,
925                    valid_after,
926                    fresh_until,
927                    valid_until,
928                    vote_delay: Some(2),
929                    dist_delay: Some(2),
930                    client_versions: Vec::new(),
931                    server_versions: Vec::new(),
932                    known_flags: flags,
933                    recommended_client_protocols: HashMap::new(),
934                    recommended_relay_protocols: HashMap::new(),
935                    required_client_protocols: HashMap::new(),
936                    required_relay_protocols: HashMap::new(),
937                    params: HashMap::new(),
938                    shared_randomness_previous: None,
939                    shared_randomness_current: None,
940                    bandwidth_weights: HashMap::new(),
941                    authorities: Vec::new(),
942                    signatures: Vec::new(),
943                    raw_content: Vec::new(),
944                    unrecognized_lines: Vec::new(),
945                };
946                doc.known_flags.sort();
947                doc.known_flags.dedup();
948                doc
949            })
950    }
951
952    proptest! {
953        #![proptest_config(ProptestConfig::with_cases(100))]
954
955        #[test]
956        fn prop_consensus_roundtrip(doc in simple_consensus()) {
957            let serialized = doc.to_descriptor_string();
958            let parsed = NetworkStatusDocument::parse(&serialized);
959
960            prop_assert!(parsed.is_ok(), "Failed to parse serialized consensus: {:?}", parsed.err());
961
962            let parsed = parsed.unwrap();
963
964            prop_assert_eq!(doc.version, parsed.version, "version mismatch");
965            prop_assert_eq!(doc.is_consensus, parsed.is_consensus, "is_consensus mismatch");
966            prop_assert_eq!(doc.is_vote, parsed.is_vote, "is_vote mismatch");
967            prop_assert_eq!(doc.consensus_method, parsed.consensus_method, "consensus_method mismatch");
968            prop_assert_eq!(doc.vote_delay, parsed.vote_delay, "vote_delay mismatch");
969            prop_assert_eq!(doc.dist_delay, parsed.dist_delay, "dist_delay mismatch");
970
971            prop_assert_eq!(
972                doc.valid_after.format("%Y-%m-%d %H:%M:%S").to_string(),
973                parsed.valid_after.format("%Y-%m-%d %H:%M:%S").to_string(),
974                "valid_after mismatch"
975            );
976            prop_assert_eq!(
977                doc.fresh_until.format("%Y-%m-%d %H:%M:%S").to_string(),
978                parsed.fresh_until.format("%Y-%m-%d %H:%M:%S").to_string(),
979                "fresh_until mismatch"
980            );
981            prop_assert_eq!(
982                doc.valid_until.format("%Y-%m-%d %H:%M:%S").to_string(),
983                parsed.valid_until.format("%Y-%m-%d %H:%M:%S").to_string(),
984                "valid_until mismatch"
985            );
986
987            let mut doc_flags = doc.known_flags.clone();
988            let mut parsed_flags = parsed.known_flags.clone();
989            doc_flags.sort();
990            parsed_flags.sort();
991            prop_assert_eq!(doc_flags, parsed_flags, "known_flags mismatch");
992        }
993    }
994}