stem_rs/descriptor/
authority.rs

1//! Directory authority parsing for Tor network status documents.
2//!
3//! This module provides parsing for directory authority entries found in
4//! v3 network status documents (votes and consensus). Directory authorities
5//! are special relays that are hardcoded into Tor and are responsible for
6//! voting on the state of the network and producing the consensus document.
7//!
8//! # Overview
9//!
10//! Directory authorities perform several critical functions in the Tor network:
11//!
12//! - **Voting**: Each authority periodically publishes a vote describing which
13//!   relays it believes are in the network and their properties (flags, bandwidth, etc.)
14//! - **Consensus Generation**: Authorities exchange votes and produce a consensus
15//!   document that represents the agreed-upon state of the network
16//! - **Shared Randomness**: Authorities participate in a distributed random number
17//!   generation protocol used for hidden service directory assignment
18//!
19//! # Document Types
20//!
21//! Authority entries appear differently in votes versus consensus documents:
22//!
23//! - **In Votes**: Include the authority's key certificate for signature verification
24//! - **In Consensus**: Include a vote-digest referencing the authority's vote
25//!
26//! # Example
27//!
28//! ```rust
29//! use stem_rs::descriptor::authority::DirectoryAuthority;
30//!
31//! let content = r#"dir-source moria1 D586D18309DED4CD6D57C18FDB97EFA96D330566 128.31.0.39 128.31.0.39 9131 9101
32//! contact 1024D/28988BF5 arma mit edu
33//! vote-digest 49015F787433103580E3B66A1707A00E60F2D15B
34//! "#;
35//!
36//! let authority = DirectoryAuthority::parse(content, false).unwrap();
37//! assert_eq!(authority.nickname, "moria1");
38//! assert_eq!(authority.or_port, 9101);
39//! ```
40//!
41//! # See Also
42//!
43//! - [`consensus`](super::consensus): Network status documents containing authority entries
44//! - [`key_cert`](super::key_cert): Key certificates used by authorities in votes
45//! - [Tor Directory Protocol Specification](https://spec.torproject.org/dir-spec)
46//! - Python Stem's [`stem.directory`](https://stem.torproject.org/api/directory.html)
47
48use std::fmt;
49use std::net::IpAddr;
50use std::str::FromStr;
51
52use crate::Error;
53
54use super::key_cert::KeyCertificate;
55
56/// A commitment to a shared random value from a directory authority.
57///
58/// As part of the shared randomness protocol, each participating authority
59/// commits to a random value before revealing it. This prevents authorities
60/// from choosing their random contribution based on others' values.
61///
62/// # Protocol
63///
64/// 1. Each authority generates a random value and publishes a commitment (hash)
65/// 2. After all commitments are collected, authorities reveal their values
66/// 3. The revealed values are combined to produce the shared random value
67///
68/// # Fields
69///
70/// - `version`: Protocol version (currently 1)
71/// - `algorithm`: Hash algorithm used (e.g., "sha3-256")
72/// - `identity`: The authority's identity fingerprint
73/// - `commit`: The commitment value (hash of the random value)
74/// - `reveal`: The revealed random value (only present after reveal phase)
75#[derive(Debug, Clone, PartialEq)]
76pub struct SharedRandomnessCommitment {
77    /// Protocol version number for the shared randomness protocol.
78    pub version: u32,
79    /// Hash algorithm used for the commitment (e.g., "sha3-256").
80    pub algorithm: String,
81    /// Identity fingerprint of the committing authority.
82    pub identity: String,
83    /// The commitment value (hash of the random value being committed to).
84    pub commit: String,
85    /// The revealed random value, present only after the reveal phase.
86    pub reveal: Option<String>,
87}
88
89/// A directory authority entry from a network status document.
90///
91/// Directory authorities are trusted relays that vote on the state of the
92/// Tor network. This struct represents an authority's entry as it appears
93/// in vote or consensus documents.
94///
95/// # Conceptual Role
96///
97/// Directory authorities are the backbone of Tor's distributed trust model.
98/// They:
99/// - Collect and validate relay server descriptors
100/// - Vote on relay flags (Guard, Exit, Stable, etc.)
101/// - Produce the network consensus that clients use
102/// - Participate in shared randomness generation
103///
104/// # Vote vs Consensus Entries
105///
106/// Authority entries differ based on document type:
107///
108/// | Field | Vote | Consensus |
109/// |-------|------|-----------|
110/// | `key_certificate` | Required | Not present |
111/// | `vote_digest` | Not present | Required |
112/// | `legacy_dir_key` | May be present | Not present |
113///
114/// # Legacy Authorities
115///
116/// Some authority entries have a `-legacy` suffix on their nickname,
117/// indicating they are legacy entries for backward compatibility.
118/// Legacy entries have relaxed validation requirements.
119///
120/// # Example
121///
122/// ```rust
123/// use stem_rs::descriptor::authority::DirectoryAuthority;
124///
125/// // Parse a consensus authority entry
126/// let content = r#"dir-source gabelmoo F2044413DAC2E02E3D6BCF4735A19BCA1DE97281 131.188.40.189 131.188.40.189 80 443
127/// contact 4096R/261C5FBE77285F88FB0C343266C8C2D7C5AA446D Sebastian Hahn <tor@sebastianhahn.net>
128/// vote-digest 49015F787433103580E3B66A1707A00E60F2D15B
129/// "#;
130///
131/// let authority = DirectoryAuthority::parse(content, false)?;
132/// assert_eq!(authority.nickname, "gabelmoo");
133/// assert!(!authority.is_legacy);
134/// # Ok::<(), stem_rs::Error>(())
135/// ```
136///
137/// # See Also
138///
139/// - [`KeyCertificate`](super::key_cert::KeyCertificate): Authority signing keys
140/// - [`NetworkStatusDocument`](super::consensus::NetworkStatusDocument): Contains authority entries
141#[derive(Debug, Clone, PartialEq)]
142pub struct DirectoryAuthority {
143    /// The authority's nickname (1-19 alphanumeric characters).
144    /// May have a `-legacy` suffix for legacy entries.
145    pub nickname: String,
146    /// The authority's v3 identity key fingerprint (40 hex characters).
147    /// Used to identify the authority and verify signatures.
148    pub v3ident: String,
149    /// The authority's hostname.
150    pub hostname: String,
151    /// The authority's IP address (IPv4 or IPv6).
152    pub address: IpAddr,
153    /// The directory port for HTTP directory requests, or `None` if not available.
154    pub dir_port: Option<u16>,
155    /// The OR (onion router) port for relay traffic.
156    pub or_port: u16,
157    /// Whether this is a legacy authority entry (nickname ends with `-legacy`).
158    pub is_legacy: bool,
159    /// Contact information for the authority operator.
160    pub contact: Option<String>,
161    /// Digest of the authority's vote (only in consensus documents).
162    pub vote_digest: Option<String>,
163    /// Legacy directory key fingerprint (only in votes, for backward compatibility).
164    pub legacy_dir_key: Option<String>,
165    /// The authority's key certificate (only in vote documents).
166    pub key_certificate: Option<KeyCertificate>,
167    /// Whether this authority participates in the shared randomness protocol.
168    pub is_shared_randomness_participate: bool,
169    /// Commitments to shared random values from this authority.
170    pub shared_randomness_commitments: Vec<SharedRandomnessCommitment>,
171    /// Number of authorities that revealed for the previous shared random value.
172    pub shared_randomness_previous_reveal_count: Option<u32>,
173    /// The previous shared random value (base64 encoded).
174    pub shared_randomness_previous_value: Option<String>,
175    /// Number of authorities that revealed for the current shared random value.
176    pub shared_randomness_current_reveal_count: Option<u32>,
177    /// The current shared random value (base64 encoded).
178    pub shared_randomness_current_value: Option<String>,
179    /// Raw bytes of the authority entry as it appeared in the document.
180    pub(crate) raw_content: Vec<u8>,
181    /// Lines that were not recognized during parsing.
182    pub(crate) unrecognized_lines: Vec<String>,
183}
184
185impl Default for DirectoryAuthority {
186    fn default() -> Self {
187        Self {
188            nickname: String::new(),
189            v3ident: String::new(),
190            hostname: String::new(),
191            address: IpAddr::V4(std::net::Ipv4Addr::new(0, 0, 0, 0)),
192            dir_port: None,
193            or_port: 0,
194            is_legacy: false,
195            contact: None,
196            vote_digest: None,
197            legacy_dir_key: None,
198            key_certificate: None,
199            is_shared_randomness_participate: false,
200            shared_randomness_commitments: Vec::new(),
201            shared_randomness_previous_reveal_count: None,
202            shared_randomness_previous_value: None,
203            shared_randomness_current_reveal_count: None,
204            shared_randomness_current_value: None,
205            raw_content: Vec::new(),
206            unrecognized_lines: Vec::new(),
207        }
208    }
209}
210
211impl DirectoryAuthority {
212    /// Parses a directory authority entry from its string representation.
213    ///
214    /// This method parses authority entries with full validation enabled.
215    /// Use [`parse_with_validation`](Self::parse_with_validation) for control
216    /// over validation behavior.
217    ///
218    /// # Arguments
219    ///
220    /// * `content` - The raw authority entry text
221    /// * `is_vote` - `true` if parsing from a vote document, `false` for consensus
222    ///
223    /// # Errors
224    ///
225    /// Returns [`Error::Parse`] if:
226    /// - The entry doesn't start with a `dir-source` line
227    /// - Required fields are missing or malformed
228    /// - Fingerprints are not valid 40-character hex strings
229    /// - IP addresses or ports are invalid
230    /// - Vote entries lack a key certificate
231    /// - Consensus entries have fields that should only appear in votes
232    ///
233    /// # Example
234    ///
235    /// ```rust
236    /// use stem_rs::descriptor::authority::DirectoryAuthority;
237    ///
238    /// // Parse a consensus entry
239    /// let consensus_entry = r#"dir-source moria1 D586D18309DED4CD6D57C18FDB97EFA96D330566 128.31.0.39 128.31.0.39 9131 9101
240    /// contact arma
241    /// vote-digest 49015F787433103580E3B66A1707A00E60F2D15B
242    /// "#;
243    /// let authority = DirectoryAuthority::parse(consensus_entry, false)?;
244    /// # Ok::<(), stem_rs::Error>(())
245    /// ```
246    pub fn parse(content: &str, is_vote: bool) -> Result<Self, Error> {
247        Self::parse_with_validation(content, true, is_vote)
248    }
249
250    /// Parses a directory authority entry with configurable validation.
251    ///
252    /// When validation is disabled, the parser is more lenient and will
253    /// attempt to extract as much information as possible even from
254    /// malformed entries.
255    ///
256    /// # Arguments
257    ///
258    /// * `content` - The raw authority entry text
259    /// * `validate` - Whether to perform strict validation
260    /// * `is_vote` - `true` if parsing from a vote document, `false` for consensus
261    ///
262    /// # Errors
263    ///
264    /// When `validate` is `true`, returns [`Error::Parse`] for validation failures.
265    /// When `validate` is `false`, parsing errors are silently ignored where possible.
266    pub fn parse_with_validation(
267        content: &str,
268        validate: bool,
269        is_vote: bool,
270    ) -> Result<Self, Error> {
271        let raw_content = content.as_bytes().to_vec();
272
273        let (authority_content, key_cert) =
274            if let Some(key_div) = content.find("\ndir-key-certificate-version") {
275                let cert_content = &content[key_div + 1..];
276                let cert = if validate {
277                    Some(KeyCertificate::parse(cert_content)?)
278                } else {
279                    KeyCertificate::parse_with_validation(cert_content, false).ok()
280                };
281                (&content[..key_div + 1], cert)
282            } else {
283                (content, None)
284            };
285
286        let lines: Vec<&str> = authority_content.lines().collect();
287
288        let mut nickname: Option<String> = None;
289        let mut v3ident: Option<String> = None;
290        let mut hostname: Option<String> = None;
291        let mut address: Option<IpAddr> = None;
292        let mut dir_port: Option<u16> = None;
293        let mut or_port: Option<u16> = None;
294        let mut is_legacy = false;
295        let mut contact: Option<String> = None;
296        let mut vote_digest: Option<String> = None;
297        let mut legacy_dir_key: Option<String> = None;
298        let mut is_shared_randomness_participate = false;
299        let mut shared_randomness_commitments: Vec<SharedRandomnessCommitment> = Vec::new();
300        let mut shared_randomness_previous_reveal_count: Option<u32> = None;
301        let mut shared_randomness_previous_value: Option<String> = None;
302        let mut shared_randomness_current_reveal_count: Option<u32> = None;
303        let mut shared_randomness_current_value: Option<String> = None;
304        let mut unrecognized_lines: Vec<String> = Vec::new();
305        let mut first_keyword: Option<&str> = None;
306
307        for line in &lines {
308            let line = line.trim();
309            if line.is_empty() {
310                continue;
311            }
312
313            let (keyword, value) = if let Some(space_pos) = line.find(' ') {
314                (&line[..space_pos], line[space_pos + 1..].trim())
315            } else {
316                (line, "")
317            };
318
319            if first_keyword.is_none() {
320                first_keyword = Some(keyword);
321            }
322
323            match keyword {
324                "dir-source" => {
325                    let parts: Vec<&str> = value.split_whitespace().collect();
326                    if parts.len() >= 6 {
327                        let nick = parts[0].to_string();
328                        is_legacy = nick.ends_with("-legacy");
329
330                        let nick_to_validate = nick.trim_end_matches("-legacy");
331                        if validate && !is_valid_nickname(nick_to_validate) {
332                            return Err(Error::Parse {
333                                location: "dir-source".to_string(),
334                                reason: format!("Authority's nickname is invalid: {}", nick),
335                            });
336                        }
337
338                        if validate && !is_valid_fingerprint(parts[1]) {
339                            return Err(Error::Parse {
340                                location: "dir-source".to_string(),
341                                reason: format!("Authority's v3ident is invalid: {}", parts[1]),
342                            });
343                        }
344
345                        if validate && parts[2].is_empty() {
346                            return Err(Error::Parse {
347                                location: "dir-source".to_string(),
348                                reason: "Authority's hostname can't be blank".to_string(),
349                            });
350                        }
351
352                        let addr: Result<IpAddr, _> = parts[3].parse();
353                        if validate && addr.is_err() {
354                            return Err(Error::Parse {
355                                location: "dir-source".to_string(),
356                                reason: format!(
357                                    "Authority's address isn't a valid IPv4 address: {}",
358                                    parts[3]
359                                ),
360                            });
361                        }
362
363                        let dport: Result<u16, _> = parts[4].parse();
364                        if validate && dport.is_err() {
365                            return Err(Error::Parse {
366                                location: "dir-source".to_string(),
367                                reason: format!("Authority's DirPort is invalid: {}", parts[4]),
368                            });
369                        }
370
371                        let oport: Result<u16, _> = parts[5].parse();
372                        if validate && oport.is_err() {
373                            return Err(Error::Parse {
374                                location: "dir-source".to_string(),
375                                reason: format!("Authority's ORPort is invalid: {}", parts[5]),
376                            });
377                        }
378
379                        nickname = Some(nick);
380                        v3ident = Some(parts[1].to_string());
381                        hostname = Some(parts[2].to_string());
382                        address = addr.ok();
383                        dir_port = dport.ok().and_then(|p| if p == 0 { None } else { Some(p) });
384                        or_port = oport.ok();
385                    } else if validate {
386                        return Err(Error::Parse {
387                            location: "dir-source".to_string(),
388                            reason: format!(
389                                "Authority entry's 'dir-source' line must have six values: dir-source {}",
390                                value
391                            ),
392                        });
393                    }
394                }
395                "contact" => {
396                    contact = Some(value.to_string());
397                }
398                "vote-digest" => {
399                    if validate && !is_valid_fingerprint(value) {
400                        return Err(Error::Parse {
401                            location: "vote-digest".to_string(),
402                            reason: format!("Invalid vote-digest: {}", value),
403                        });
404                    }
405                    vote_digest = if is_valid_fingerprint(value) {
406                        Some(value.to_string())
407                    } else {
408                        None
409                    };
410                }
411                "legacy-dir-key" => {
412                    if validate && !is_valid_fingerprint(value) {
413                        return Err(Error::Parse {
414                            location: "legacy-dir-key".to_string(),
415                            reason: format!("Invalid legacy-dir-key: {}", value),
416                        });
417                    }
418                    legacy_dir_key = if is_valid_fingerprint(value) {
419                        Some(value.to_string())
420                    } else {
421                        None
422                    };
423                }
424                "shared-rand-participate" => {
425                    is_shared_randomness_participate = true;
426                }
427                "shared-rand-commit" => {
428                    if let Some(commitment) = parse_shared_rand_commit(value) {
429                        shared_randomness_commitments.push(commitment);
430                    }
431                }
432                "shared-rand-previous-value" => {
433                    let parts: Vec<&str> = value.split_whitespace().collect();
434                    if parts.len() >= 2 {
435                        shared_randomness_previous_reveal_count = parts[0].parse().ok();
436                        shared_randomness_previous_value = Some(parts[1].to_string());
437                    }
438                }
439                "shared-rand-current-value" => {
440                    let parts: Vec<&str> = value.split_whitespace().collect();
441                    if parts.len() >= 2 {
442                        shared_randomness_current_reveal_count = parts[0].parse().ok();
443                        shared_randomness_current_value = Some(parts[1].to_string());
444                    }
445                }
446                _ => {
447                    if !line.is_empty() && !line.starts_with("-----") {
448                        unrecognized_lines.push(line.to_string());
449                    }
450                }
451            }
452        }
453
454        if validate {
455            if first_keyword != Some("dir-source") {
456                return Err(Error::Parse {
457                    location: "DirectoryAuthority".to_string(),
458                    reason: "Authority entries are expected to start with a 'dir-source' line"
459                        .to_string(),
460                });
461            }
462
463            if nickname.is_none() {
464                return Err(Error::Parse {
465                    location: "DirectoryAuthority".to_string(),
466                    reason: "Authority entries must have a 'dir-source' line".to_string(),
467                });
468            }
469
470            if !is_legacy && contact.is_none() {
471                return Err(Error::Parse {
472                    location: "DirectoryAuthority".to_string(),
473                    reason: "Authority entries must have a 'contact' line".to_string(),
474                });
475            }
476
477            if is_vote {
478                if key_cert.is_none() {
479                    return Err(Error::Parse {
480                        location: "DirectoryAuthority".to_string(),
481                        reason: "Authority votes must have a key certificate".to_string(),
482                    });
483                }
484                if vote_digest.is_some() {
485                    return Err(Error::Parse {
486                        location: "DirectoryAuthority".to_string(),
487                        reason: "Authority votes shouldn't have a 'vote-digest' line".to_string(),
488                    });
489                }
490            } else {
491                if key_cert.is_some() {
492                    return Err(Error::Parse {
493                        location: "DirectoryAuthority".to_string(),
494                        reason: "Authority consensus entries shouldn't have a key certificate"
495                            .to_string(),
496                    });
497                }
498                if !is_legacy && vote_digest.is_none() {
499                    return Err(Error::Parse {
500                        location: "DirectoryAuthority".to_string(),
501                        reason: "Authority entries must have a 'vote-digest' line".to_string(),
502                    });
503                }
504                if legacy_dir_key.is_some() {
505                    return Err(Error::Parse {
506                        location: "DirectoryAuthority".to_string(),
507                        reason:
508                            "Authority consensus entries shouldn't have a 'legacy-dir-key' line"
509                                .to_string(),
510                    });
511                }
512            }
513        }
514
515        Ok(DirectoryAuthority {
516            nickname: nickname.unwrap_or_default(),
517            v3ident: v3ident.unwrap_or_default(),
518            hostname: hostname.unwrap_or_default(),
519            address: address.unwrap_or_else(|| "0.0.0.0".parse().unwrap()),
520            dir_port,
521            or_port: or_port.unwrap_or(0),
522            is_legacy,
523            contact,
524            vote_digest,
525            legacy_dir_key,
526            key_certificate: key_cert,
527            is_shared_randomness_participate,
528            shared_randomness_commitments,
529            shared_randomness_previous_reveal_count,
530            shared_randomness_previous_value,
531            shared_randomness_current_reveal_count,
532            shared_randomness_current_value,
533            raw_content,
534            unrecognized_lines,
535        })
536    }
537
538    /// Returns the raw bytes of the authority entry as it appeared in the document.
539    ///
540    /// This preserves the original formatting and can be used for
541    /// signature verification or re-serialization.
542    pub fn raw_content(&self) -> &[u8] {
543        &self.raw_content
544    }
545
546    /// Returns lines that were not recognized during parsing.
547    ///
548    /// Unrecognized lines may indicate:
549    /// - New fields added in newer Tor versions
550    /// - Malformed or corrupted data
551    /// - Custom extensions
552    ///
553    /// # Example
554    ///
555    /// ```rust
556    /// use stem_rs::descriptor::authority::DirectoryAuthority;
557    ///
558    /// let content = r#"dir-source test AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA example.com 1.2.3.4 80 443
559    /// contact test
560    /// unknown-field some-value
561    /// vote-digest AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
562    /// "#;
563    /// let authority = DirectoryAuthority::parse(content, false)?;
564    /// assert!(authority.unrecognized_lines().iter().any(|l| l.contains("unknown-field")));
565    /// # Ok::<(), stem_rs::Error>(())
566    /// ```
567    pub fn unrecognized_lines(&self) -> &[String] {
568        &self.unrecognized_lines
569    }
570
571    /// Converts the authority entry back to its descriptor string format.
572    ///
573    /// The output follows the Tor directory protocol format and can be
574    /// used for serialization or debugging.
575    ///
576    /// # Note
577    ///
578    /// The output may not be byte-for-byte identical to the original
579    /// input due to normalization of whitespace and field ordering.
580    pub fn to_descriptor_string(&self) -> String {
581        let mut result = String::new();
582
583        let dir_port_str = self
584            .dir_port
585            .map(|p| p.to_string())
586            .unwrap_or_else(|| "0".to_string());
587        result.push_str(&format!(
588            "dir-source {} {} {} {} {} {}\n",
589            self.nickname, self.v3ident, self.hostname, self.address, dir_port_str, self.or_port
590        ));
591
592        if let Some(ref contact) = self.contact {
593            result.push_str(&format!("contact {}\n", contact));
594        }
595
596        if let Some(ref legacy_key) = self.legacy_dir_key {
597            result.push_str(&format!("legacy-dir-key {}\n", legacy_key));
598        }
599
600        if self.is_shared_randomness_participate {
601            result.push_str("shared-rand-participate\n");
602        }
603
604        for commitment in &self.shared_randomness_commitments {
605            if let Some(ref reveal) = commitment.reveal {
606                result.push_str(&format!(
607                    "shared-rand-commit {} {} {} {} {}\n",
608                    commitment.version,
609                    commitment.algorithm,
610                    commitment.identity,
611                    commitment.commit,
612                    reveal
613                ));
614            } else {
615                result.push_str(&format!(
616                    "shared-rand-commit {} {} {} {}\n",
617                    commitment.version,
618                    commitment.algorithm,
619                    commitment.identity,
620                    commitment.commit
621                ));
622            }
623        }
624
625        if let (Some(count), Some(ref value)) = (
626            self.shared_randomness_previous_reveal_count,
627            &self.shared_randomness_previous_value,
628        ) {
629            result.push_str(&format!("shared-rand-previous-value {} {}\n", count, value));
630        }
631
632        if let (Some(count), Some(ref value)) = (
633            self.shared_randomness_current_reveal_count,
634            &self.shared_randomness_current_value,
635        ) {
636            result.push_str(&format!("shared-rand-current-value {} {}\n", count, value));
637        }
638
639        if let Some(ref digest) = self.vote_digest {
640            result.push_str(&format!("vote-digest {}\n", digest));
641        }
642
643        if let Some(ref cert) = self.key_certificate {
644            result.push_str(&cert.to_descriptor_string());
645        }
646
647        result
648    }
649}
650
651impl FromStr for DirectoryAuthority {
652    type Err = Error;
653
654    fn from_str(s: &str) -> Result<Self, Self::Err> {
655        Self::parse(s, false)
656    }
657}
658
659impl fmt::Display for DirectoryAuthority {
660    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
661        write!(f, "{}", self.to_descriptor_string())
662    }
663}
664
665/// Validates that a string is a valid relay fingerprint.
666///
667/// A valid fingerprint consists of exactly 40 hexadecimal characters
668/// (case-insensitive), representing a 160-bit SHA-1 hash.
669fn is_valid_fingerprint(s: &str) -> bool {
670    s.len() == 40 && s.chars().all(|c| c.is_ascii_hexdigit())
671}
672
673/// Validates that a string is a valid relay nickname.
674///
675/// A valid nickname is 1-19 characters consisting of alphanumeric
676/// characters, underscores, or hyphens.
677fn is_valid_nickname(s: &str) -> bool {
678    !s.is_empty()
679        && s.len() <= 19
680        && s.chars()
681            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
682}
683
684/// Parses a shared randomness commitment from its string representation.
685///
686/// Format: `version algorithm identity commit [reveal]`
687fn parse_shared_rand_commit(value: &str) -> Option<SharedRandomnessCommitment> {
688    let parts: Vec<&str> = value.split_whitespace().collect();
689    if parts.len() >= 4 {
690        Some(SharedRandomnessCommitment {
691            version: parts[0].parse().ok()?,
692            algorithm: parts[1].to_string(),
693            identity: parts[2].to_string(),
694            commit: parts[3].to_string(),
695            reveal: parts.get(4).map(|s| s.to_string()),
696        })
697    } else {
698        None
699    }
700}
701
702#[cfg(test)]
703mod tests {
704    use super::*;
705
706    const DIR_SOURCE_LINE: &str =
707        "turtles 27B6B5996C426270A5C95488AA5BCEB6BCC86956 no.place.com 76.73.17.194 9030 9090";
708
709    const MINIMAL_CONSENSUS_AUTHORITY: &str = r#"dir-source Unnamed 27B6B5996C426270A5C95488AA5BCEB6BCC86956 no.place.com 76.73.17.194 9030 9090
710contact Mike Perry <email>
711vote-digest 27B6B5996C426270A5C95488AA5BCEB6BCC86956
712"#;
713
714    const LEGACY_AUTHORITY: &str =
715        "dir-source gabelmoo-legacy 81349FC1F2DBA2C2C11B45CB9706637D480AB913 131.188.40.189 131.188.40.189 80 443";
716
717    const MINIMAL_KEY_CERT: &str = r#"dir-key-certificate-version 3
718fingerprint BCB380A633592C218757BEE11E630511A485658A
719dir-key-published 2017-05-25 04:45:52
720dir-key-expires 2018-05-25 04:45:52
721dir-identity-key
722-----BEGIN RSA PUBLIC KEY-----
723MIIBCgKCAQEA
724-----END RSA PUBLIC KEY-----
725dir-signing-key
726-----BEGIN RSA PUBLIC KEY-----
727MIIBCgKCAQEA
728-----END RSA PUBLIC KEY-----
729dir-key-certification
730-----BEGIN SIGNATURE-----
731AAAA
732-----END SIGNATURE-----
733"#;
734
735    #[test]
736    fn test_minimal_consensus_authority() {
737        let authority = DirectoryAuthority::parse(MINIMAL_CONSENSUS_AUTHORITY, false).unwrap();
738
739        assert_eq!("Unnamed", authority.nickname);
740        assert_eq!(40, authority.v3ident.len());
741        assert_eq!("no.place.com", authority.hostname);
742        assert_eq!(Some(9030), authority.dir_port);
743        assert_eq!(9090, authority.or_port);
744        assert!(!authority.is_legacy);
745        assert_eq!(Some("Mike Perry <email>".to_string()), authority.contact);
746        assert_eq!(40, authority.vote_digest.as_ref().unwrap().len());
747        assert_eq!(None, authority.legacy_dir_key);
748        assert_eq!(None, authority.key_certificate);
749        assert!(authority.unrecognized_lines().is_empty());
750    }
751
752    #[test]
753    fn test_minimal_vote_authority() {
754        let content = format!(
755            "dir-source Unnamed 27B6B5996C426270A5C95488AA5BCEB6BCC86956 no.place.com 76.73.17.194 9030 9090\ncontact Mike Perry <email>\n{}",
756            MINIMAL_KEY_CERT
757        );
758        let authority = DirectoryAuthority::parse(&content, true).unwrap();
759
760        assert_eq!("Unnamed", authority.nickname);
761        assert_eq!(40, authority.v3ident.len());
762        assert_eq!("no.place.com", authority.hostname);
763        assert_eq!(Some(9030), authority.dir_port);
764        assert_eq!(9090, authority.or_port);
765        assert!(!authority.is_legacy);
766        assert_eq!(Some("Mike Perry <email>".to_string()), authority.contact);
767        assert_eq!(None, authority.vote_digest);
768        assert_eq!(None, authority.legacy_dir_key);
769        assert!(authority.key_certificate.is_some());
770        assert!(authority.unrecognized_lines().is_empty());
771    }
772
773    #[test]
774    fn test_unrecognized_line() {
775        let content = "dir-source Unnamed 27B6B5996C426270A5C95488AA5BCEB6BCC86956 no.place.com 76.73.17.194 9030 9090\ncontact Mike Perry <email>\npepperjack is oh so tasty!\nvote-digest 27B6B5996C426270A5C95488AA5BCEB6BCC86956\n".to_string();
776        let authority = DirectoryAuthority::parse(&content, false).unwrap();
777        assert_eq!(
778            vec!["pepperjack is oh so tasty!"],
779            authority.unrecognized_lines()
780        );
781    }
782
783    #[test]
784    fn test_legacy_authority() {
785        let authority = DirectoryAuthority::parse(LEGACY_AUTHORITY, false).unwrap();
786
787        assert_eq!("gabelmoo-legacy", authority.nickname);
788        assert_eq!(
789            "81349FC1F2DBA2C2C11B45CB9706637D480AB913",
790            authority.v3ident
791        );
792        assert_eq!("131.188.40.189", authority.hostname);
793        assert_eq!(
794            "131.188.40.189".parse::<IpAddr>().unwrap(),
795            authority.address
796        );
797        assert_eq!(Some(80), authority.dir_port);
798        assert_eq!(443, authority.or_port);
799        assert!(authority.is_legacy);
800        assert_eq!(None, authority.contact);
801        assert_eq!(None, authority.vote_digest);
802        assert_eq!(None, authority.legacy_dir_key);
803        assert_eq!(None, authority.key_certificate);
804        assert!(authority.unrecognized_lines().is_empty());
805    }
806
807    #[test]
808    fn test_first_line_validation() {
809        let content = format!("ho-hum 567\n{}", MINIMAL_CONSENSUS_AUTHORITY);
810        let result = DirectoryAuthority::parse(&content, false);
811        assert!(result.is_err());
812
813        let authority = DirectoryAuthority::parse_with_validation(&content, false, false).unwrap();
814        assert_eq!(vec!["ho-hum 567"], authority.unrecognized_lines());
815    }
816
817    #[test]
818    fn test_missing_dir_source() {
819        let content =
820            "contact Mike Perry <email>\nvote-digest 27B6B5996C426270A5C95488AA5BCEB6BCC86956\n";
821        let result = DirectoryAuthority::parse(content, false);
822        assert!(result.is_err());
823    }
824
825    #[test]
826    fn test_missing_contact() {
827        let content = "dir-source Unnamed 27B6B5996C426270A5C95488AA5BCEB6BCC86956 no.place.com 76.73.17.194 9030 9090\nvote-digest 27B6B5996C426270A5C95488AA5BCEB6BCC86956\n";
828        let result = DirectoryAuthority::parse(content, false);
829        assert!(result.is_err());
830    }
831
832    #[test]
833    fn test_blank_lines() {
834        let content = format!(
835            "dir-source {} \n\n\ncontact Mike Perry <email>\nvote-digest 27B6B5996C426270A5C95488AA5BCEB6BCC86956\n",
836            DIR_SOURCE_LINE.replace("dir-source ", "")
837        );
838        let authority = DirectoryAuthority::parse(&content, false).unwrap();
839        assert_eq!(Some("Mike Perry <email>".to_string()), authority.contact);
840    }
841
842    #[test]
843    fn test_missing_dir_source_field() {
844        let content = "dir-source 27B6B5996C426270A5C95488AA5BCEB6BCC86956 no.place.com 76.73.17.194 9030 9090\ncontact test\nvote-digest 27B6B5996C426270A5C95488AA5BCEB6BCC86956\n";
845        let result = DirectoryAuthority::parse(content, false);
846        assert!(result.is_err());
847    }
848
849    #[test]
850    fn test_malformed_fingerprint() {
851        let test_values = ["", "zzzzz", "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"];
852
853        for value in &test_values {
854            let content = format!(
855                "dir-source turtles {} no.place.com 76.73.17.194 9030 9090\ncontact test\nvote-digest 27B6B5996C426270A5C95488AA5BCEB6BCC86956\n",
856                value
857            );
858            let result = DirectoryAuthority::parse(&content, false);
859            assert!(result.is_err(), "Expected error for fingerprint: {}", value);
860
861            let authority =
862                DirectoryAuthority::parse_with_validation(&content, false, false).unwrap();
863            assert!(authority.v3ident.is_empty() || authority.v3ident == *value);
864        }
865    }
866
867    #[test]
868    fn test_malformed_address() {
869        let test_values = [
870            "",
871            "71.35.150.",
872            "71.35..29",
873            "71.35.150",
874            "71.35.150.256",
875            "[fd9f:2e19:3bcf::02:9970]",
876        ];
877
878        for value in &test_values {
879            let content = format!(
880                "dir-source turtles 27B6B5996C426270A5C95488AA5BCEB6BCC86956 no.place.com {} 9030 9090\ncontact test\nvote-digest 27B6B5996C426270A5C95488AA5BCEB6BCC86956\n",
881                value
882            );
883            let result = DirectoryAuthority::parse(&content, false);
884            assert!(result.is_err(), "Expected error for address: {}", value);
885        }
886    }
887
888    #[test]
889    fn test_malformed_port() {
890        let test_values = ["", "-1", "399482", "blarg"];
891
892        for value in &test_values {
893            let content = format!(
894                "dir-source turtles 27B6B5996C426270A5C95488AA5BCEB6BCC86956 no.place.com 76.73.17.194 9030 {}\ncontact test\nvote-digest 27B6B5996C426270A5C95488AA5BCEB6BCC86956\n",
895                value
896            );
897            let result = DirectoryAuthority::parse(&content, false);
898            assert!(result.is_err(), "Expected error for or_port: {}", value);
899
900            let content = format!(
901                "dir-source turtles 27B6B5996C426270A5C95488AA5BCEB6BCC86956 no.place.com 76.73.17.194 {} 9090\ncontact test\nvote-digest 27B6B5996C426270A5C95488AA5BCEB6BCC86956\n",
902                value
903            );
904            let result = DirectoryAuthority::parse(&content, false);
905            assert!(result.is_err(), "Expected error for dir_port: {}", value);
906        }
907    }
908
909    #[test]
910    fn test_legacy_dir_key() {
911        let test_value = "65968CCB6BECB5AA88459C5A072624C6995B6B72";
912        let content = format!(
913            "dir-source Unnamed 27B6B5996C426270A5C95488AA5BCEB6BCC86956 no.place.com 76.73.17.194 9030 9090\ncontact Mike Perry <email>\nlegacy-dir-key {}\n{}",
914            test_value, MINIMAL_KEY_CERT
915        );
916        let authority = DirectoryAuthority::parse(&content, true).unwrap();
917        assert_eq!(Some(test_value.to_string()), authority.legacy_dir_key);
918
919        let content = format!(
920            "dir-source Unnamed 27B6B5996C426270A5C95488AA5BCEB6BCC86956 no.place.com 76.73.17.194 9030 9090\ncontact Mike Perry <email>\nlegacy-dir-key {}\nvote-digest 27B6B5996C426270A5C95488AA5BCEB6BCC86956\n",
921            test_value
922        );
923        let result = DirectoryAuthority::parse(&content, false);
924        assert!(result.is_err());
925    }
926
927    #[test]
928    fn test_invalid_legacy_dir_key() {
929        let test_values = ["", "zzzzz", "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"];
930
931        for value in &test_values {
932            let content = format!(
933                "dir-source Unnamed 27B6B5996C426270A5C95488AA5BCEB6BCC86956 no.place.com 76.73.17.194 9030 9090\ncontact Mike Perry <email>\nlegacy-dir-key {}\n{}",
934                value, MINIMAL_KEY_CERT
935            );
936            let result = DirectoryAuthority::parse(&content, true);
937            assert!(
938                result.is_err(),
939                "Expected error for legacy-dir-key: {}",
940                value
941            );
942        }
943    }
944
945    #[test]
946    fn test_key_certificate_in_consensus() {
947        let content = format!(
948            "dir-source Unnamed 27B6B5996C426270A5C95488AA5BCEB6BCC86956 no.place.com 76.73.17.194 9030 9090\ncontact Mike Perry <email>\nvote-digest 27B6B5996C426270A5C95488AA5BCEB6BCC86956\n{}",
949            MINIMAL_KEY_CERT
950        );
951        let result = DirectoryAuthority::parse(&content, false);
952        assert!(result.is_err());
953    }
954
955    #[test]
956    fn test_missing_key_certificate_in_vote() {
957        let content = "dir-source Unnamed 27B6B5996C426270A5C95488AA5BCEB6BCC86956 no.place.com 76.73.17.194 9030 9090\ncontact Mike Perry <email>\n";
958        let result = DirectoryAuthority::parse(content, true);
959        assert!(result.is_err());
960    }
961
962    #[test]
963    fn test_vote_digest_in_vote() {
964        let content = format!(
965            "dir-source Unnamed 27B6B5996C426270A5C95488AA5BCEB6BCC86956 no.place.com 76.73.17.194 9030 9090\ncontact Mike Perry <email>\nvote-digest 27B6B5996C426270A5C95488AA5BCEB6BCC86956\n{}",
966            MINIMAL_KEY_CERT
967        );
968        let result = DirectoryAuthority::parse(&content, true);
969        assert!(result.is_err());
970    }
971
972    #[test]
973    fn test_dir_port_zero() {
974        let content = "dir-source Unnamed 27B6B5996C426270A5C95488AA5BCEB6BCC86956 no.place.com 76.73.17.194 0 9090\ncontact Mike Perry <email>\nvote-digest 27B6B5996C426270A5C95488AA5BCEB6BCC86956\n";
975        let authority = DirectoryAuthority::parse(content, false).unwrap();
976        assert_eq!(None, authority.dir_port);
977    }
978
979    #[test]
980    fn test_shared_randomness() {
981        let content = r#"dir-source Unnamed 27B6B5996C426270A5C95488AA5BCEB6BCC86956 no.place.com 76.73.17.194 9030 9090
982contact Mike Perry <email>
983shared-rand-participate
984shared-rand-commit 1 sha3-256 27B6B5996C426270A5C95488AA5BCEB6BCC86956 AAAA
985shared-rand-previous-value 5 BBBB
986shared-rand-current-value 3 CCCC
987vote-digest 27B6B5996C426270A5C95488AA5BCEB6BCC86956
988"#;
989        let authority = DirectoryAuthority::parse(content, false).unwrap();
990
991        assert!(authority.is_shared_randomness_participate);
992        assert_eq!(1, authority.shared_randomness_commitments.len());
993        let commitment = &authority.shared_randomness_commitments[0];
994        assert_eq!(1, commitment.version);
995        assert_eq!("sha3-256", commitment.algorithm);
996        assert_eq!(
997            "27B6B5996C426270A5C95488AA5BCEB6BCC86956",
998            commitment.identity
999        );
1000        assert_eq!("AAAA", commitment.commit);
1001        assert_eq!(None, commitment.reveal);
1002
1003        assert_eq!(Some(5), authority.shared_randomness_previous_reveal_count);
1004        assert_eq!(
1005            Some("BBBB".to_string()),
1006            authority.shared_randomness_previous_value
1007        );
1008        assert_eq!(Some(3), authority.shared_randomness_current_reveal_count);
1009        assert_eq!(
1010            Some("CCCC".to_string()),
1011            authority.shared_randomness_current_value
1012        );
1013    }
1014
1015    #[test]
1016    fn test_shared_rand_commit_with_reveal() {
1017        let content = r#"dir-source Unnamed 27B6B5996C426270A5C95488AA5BCEB6BCC86956 no.place.com 76.73.17.194 9030 9090
1018contact Mike Perry <email>
1019shared-rand-commit 1 sha3-256 27B6B5996C426270A5C95488AA5BCEB6BCC86956 AAAA REVEAL
1020vote-digest 27B6B5996C426270A5C95488AA5BCEB6BCC86956
1021"#;
1022        let authority = DirectoryAuthority::parse(content, false).unwrap();
1023
1024        assert_eq!(1, authority.shared_randomness_commitments.len());
1025        let commitment = &authority.shared_randomness_commitments[0];
1026        assert_eq!(Some("REVEAL".to_string()), commitment.reveal);
1027    }
1028
1029    #[test]
1030    fn test_to_descriptor_string() {
1031        let authority = DirectoryAuthority::parse(MINIMAL_CONSENSUS_AUTHORITY, false).unwrap();
1032        let output = authority.to_descriptor_string();
1033
1034        assert!(output.contains("dir-source Unnamed"));
1035        assert!(output.contains("27B6B5996C426270A5C95488AA5BCEB6BCC86956"));
1036        assert!(output.contains("no.place.com"));
1037        assert!(output.contains("76.73.17.194"));
1038        assert!(output.contains("9030"));
1039        assert!(output.contains("9090"));
1040        assert!(output.contains("contact Mike Perry <email>"));
1041        assert!(output.contains("vote-digest"));
1042    }
1043
1044    #[test]
1045    fn test_from_str() {
1046        let authority: DirectoryAuthority = MINIMAL_CONSENSUS_AUTHORITY.parse().unwrap();
1047        assert_eq!("Unnamed", authority.nickname);
1048    }
1049
1050    #[test]
1051    fn test_display() {
1052        let authority = DirectoryAuthority::parse(MINIMAL_CONSENSUS_AUTHORITY, false).unwrap();
1053        let display = format!("{}", authority);
1054        assert!(display.contains("dir-source Unnamed"));
1055    }
1056}