Skip to main content

quincy_server/
users.rs

1//! Users file parser for Quincy VPN.
2//!
3//! Parses a TOML-formatted users file that maps usernames to their authorized
4//! keys and certificates for handshake-layer authentication. Pre-builds
5//! lookup indices for O(1) key and fingerprint resolution.
6
7use std::collections::{HashMap, HashSet};
8use std::net::IpAddr;
9use std::path::Path;
10
11use figment::{
12    providers::{Format, Toml},
13    Figment,
14};
15use reishi_quinn::{PqPublicKey, PublicKey};
16use serde::Deserialize;
17use tracing::warn;
18
19use quincy::config::{decode_base64_key, AddressRange, Bandwidth};
20use quincy::error::{AuthError, Result};
21
22/// A parsed users file mapping usernames to their authentication credentials.
23///
24/// Pre-builds internal lookup indices during construction so that key and
25/// fingerprint lookups are O(1) rather than linear scans.
26///
27/// The TOML file has the following format:
28/// ```toml
29/// [users.alice]
30/// authorized_keys = ["base64-encoded-x25519-pubkey"]
31/// authorized_certs = ["sha256:hex-fingerprint"]
32///
33/// [users.bob]
34/// authorized_keys = ["base64-encoded-pq-pubkey"]
35/// ```
36#[derive(Clone, Debug)]
37pub struct UsersFile {
38    /// Map of username to their authentication entry.
39    pub users: HashMap<String, UserEntry>,
40    /// Index: X25519 public key -> username.
41    noise_key_to_user: HashMap<PublicKey, String>,
42    /// Index: PQ public key -> username.
43    noise_pq_key_to_user: HashMap<PqPublicKey, String>,
44    /// Index: certificate fingerprint -> username.
45    cert_fingerprint_to_user: HashMap<String, String>,
46}
47
48/// Raw deserialization target for the users file.
49#[derive(Deserialize)]
50struct RawUsersFile {
51    #[serde(default)]
52    users: HashMap<String, UserEntry>,
53}
54
55/// Authentication credentials for a single user.
56#[derive(Clone, Debug, Deserialize)]
57pub struct UserEntry {
58    /// Base64-encoded public keys authorized for this user (Noise protocol).
59    #[serde(default)]
60    pub authorized_keys: Vec<String>,
61    /// Certificate fingerprints authorized for this user (TLS mTLS).
62    /// Format: `sha256:<hex>`
63    #[serde(default)]
64    pub authorized_certs: Vec<String>,
65    /// Optional bandwidth limit for this user.
66    /// Overrides the server's `default_bandwidth_limit`.
67    /// Format: human-readable string, e.g. `"10 mbps"`.
68    #[serde(default)]
69    pub bandwidth_limit: Option<Bandwidth>,
70    /// Optional per-user address pool. When set, this user can only receive
71    /// tunnel IPs from these ranges, and the addresses are reserved (not
72    /// available to other users).
73    ///
74    /// Keep ranges small (a `/24` or narrower is typical) — overlap validation
75    /// iterates every address eagerly at startup.
76    #[serde(default)]
77    pub address_pool: Vec<AddressRange>,
78}
79
80impl UsersFile {
81    /// Loads and parses a users file from the given path.
82    ///
83    /// ### Arguments
84    /// - `path` - path to the TOML users file
85    ///
86    /// ### Errors
87    /// Returns `AuthError::StoreUnavailable` if the file cannot be read or parsed,
88    /// or `AuthError::InvalidUserStore` if the file contains duplicate keys/fingerprints
89    /// or invalid fingerprint formats.
90    pub fn load(path: &Path) -> Result<Self> {
91        if !path.exists() {
92            return Err(AuthError::StoreUnavailable.into());
93        }
94
95        let figment = Figment::new().merge(Toml::file(path));
96        let raw: RawUsersFile = figment.extract().map_err(|_| AuthError::StoreUnavailable)?;
97
98        Self::from_raw(raw)
99    }
100
101    /// Parses a users file from a TOML string.
102    ///
103    /// ### Arguments
104    /// - `content` - TOML content as a string
105    ///
106    /// ### Errors
107    /// Returns `AuthError::StoreUnavailable` if the content cannot be parsed,
108    /// or `AuthError::InvalidUserStore` if the content contains duplicate keys/fingerprints
109    /// or invalid fingerprint formats.
110    pub fn parse(content: &str) -> Result<Self> {
111        let figment = Figment::new().merge(Toml::string(content));
112        let raw: RawUsersFile = figment.extract().map_err(|_| AuthError::StoreUnavailable)?;
113
114        Self::from_raw(raw)
115    }
116
117    /// Validates that a certificate fingerprint has the expected `sha256:<64 hex chars>` format.
118    ///
119    /// ### Arguments
120    /// - `fingerprint` - the fingerprint string to validate
121    /// - `username` - the username that owns this fingerprint (for error messages)
122    ///
123    /// ### Errors
124    /// Returns `AuthError::InvalidUserStore` if the fingerprint format is invalid.
125    fn validate_fingerprint(fingerprint: &str, username: &str) -> Result<()> {
126        let Some(hex_part) = fingerprint.strip_prefix("sha256:") else {
127            return Err(AuthError::InvalidUserStore {
128                reason: format!(
129                    "user '{username}': invalid fingerprint format '{fingerprint}' \
130                     (must start with 'sha256:')"
131                ),
132            }
133            .into());
134        };
135
136        if hex_part.len() != 64 || !hex_part.chars().all(|c| c.is_ascii_hexdigit()) {
137            return Err(AuthError::InvalidUserStore {
138                reason: format!(
139                    "user '{username}': invalid fingerprint format '{fingerprint}' \
140                     (expected 'sha256:' followed by exactly 64 hex characters)"
141                ),
142            }
143            .into());
144        }
145
146        Ok(())
147    }
148
149    /// Builds a `UsersFile` with pre-computed lookup indices from raw deserialized data.
150    ///
151    /// Validates fingerprint formats, normalizes fingerprints to lowercase, and
152    /// checks for duplicate keys/fingerprints across users.
153    ///
154    /// ### Errors
155    /// Returns `AuthError::InvalidUserStore` if duplicate keys or fingerprints are
156    /// detected, or if a fingerprint has an invalid format.
157    fn from_raw(raw: RawUsersFile) -> Result<Self> {
158        let mut noise_key_to_user = HashMap::new();
159        let mut noise_pq_key_to_user = HashMap::new();
160        let mut cert_fingerprint_to_user = HashMap::new();
161
162        for (username, entry) in &raw.users {
163            for key_b64 in &entry.authorized_keys {
164                let mut decoded = false;
165
166                // Try as X25519 key (exactly 32 bytes)
167                if let Ok(bytes) = decode_base64_key::<{ PublicKey::LEN }>(key_b64) {
168                    let pubkey = PublicKey::from_bytes(*bytes);
169                    if let Some(existing) = noise_key_to_user.get(&pubkey) {
170                        return Err(AuthError::InvalidUserStore {
171                            reason: format!(
172                                "duplicate Noise X25519 key for users '{existing}' and '{username}'"
173                            ),
174                        }
175                        .into());
176                    }
177                    noise_key_to_user.insert(pubkey, username.clone());
178                    decoded = true;
179                }
180
181                // Try as PQ key (validated by from_bytes after length-checked decode)
182                if let Ok(bytes) = decode_base64_key::<{ PqPublicKey::LEN }>(key_b64) {
183                    let pq_pubkey = PqPublicKey::from_bytes(*bytes);
184                    if let Some(existing) = noise_pq_key_to_user.get(&pq_pubkey) {
185                        return Err(AuthError::InvalidUserStore {
186                            reason: format!(
187                                "duplicate Noise PQ key for users '{existing}' and '{username}'"
188                            ),
189                        }
190                        .into());
191                    }
192                    noise_pq_key_to_user.insert(pq_pubkey, username.clone());
193                    decoded = true;
194                }
195
196                if !decoded {
197                    warn!(
198                        "Ignoring unrecognized key for user '{username}': \
199                         not a valid X25519 ({} bytes) or PQ ({} bytes) public key",
200                        PublicKey::LEN,
201                        PqPublicKey::LEN,
202                    );
203                }
204            }
205
206            for fp in &entry.authorized_certs {
207                Self::validate_fingerprint(fp, username)?;
208
209                let normalized = fp.to_lowercase();
210                if let Some(existing) = cert_fingerprint_to_user.get(&normalized) {
211                    return Err(AuthError::InvalidUserStore {
212                        reason: format!(
213                            "duplicate certificate fingerprint '{normalized}' \
214                             for users '{existing}' and '{username}'"
215                        ),
216                    }
217                    .into());
218                }
219                cert_fingerprint_to_user.insert(normalized, username.clone());
220            }
221        }
222
223        // Validate per-user address pools: reject overlapping addresses between users
224        let mut all_pool_addresses: HashMap<IpAddr, String> = HashMap::new();
225        for (username, entry) in &raw.users {
226            let mut user_addresses: HashSet<IpAddr> = HashSet::new();
227            for range in &entry.address_pool {
228                for addr in range.into_inner() {
229                    if !user_addresses.insert(addr) {
230                        return Err(AuthError::InvalidUserStore {
231                            reason: format!(
232                                "user '{username}': duplicate address {addr} in address_pool"
233                            ),
234                        }
235                        .into());
236                    }
237                    if let Some(existing) = all_pool_addresses.get(&addr) {
238                        return Err(AuthError::InvalidUserStore {
239                            reason: format!(
240                                "address {addr} claimed by both users '{existing}' and '{username}'"
241                            ),
242                        }
243                        .into());
244                    }
245                    all_pool_addresses.insert(addr, username.clone());
246                }
247            }
248        }
249
250        Ok(Self {
251            users: raw.users,
252            noise_key_to_user,
253            noise_pq_key_to_user,
254            cert_fingerprint_to_user,
255        })
256    }
257
258    /// Looks up a username by their Noise X25519 public key.
259    ///
260    /// ### Arguments
261    /// - `pubkey` - the X25519 public key to search for
262    ///
263    /// ### Returns
264    /// The username if found, or `None` if no user has this key authorized.
265    pub fn find_user_by_noise_pubkey(&self, pubkey: &PublicKey) -> Option<&str> {
266        self.noise_key_to_user.get(pubkey).map(|s| s.as_str())
267    }
268
269    /// Looks up a username by their Noise hybrid PQ public key.
270    ///
271    /// ### Arguments
272    /// - `pq_pubkey` - the PQ public key to search for
273    ///
274    /// ### Returns
275    /// The username if found, or `None` if no user has this key authorized.
276    pub fn find_user_by_noise_pq_pubkey(&self, pq_pubkey: &PqPublicKey) -> Option<&str> {
277        self.noise_pq_key_to_user.get(pq_pubkey).map(|s| s.as_str())
278    }
279
280    /// Looks up a username by a TLS certificate fingerprint.
281    ///
282    /// ### Arguments
283    /// - `fingerprint` - the certificate fingerprint in `sha256:<hex>` format
284    ///
285    /// ### Returns
286    /// The username if found, or `None` if no user has this fingerprint authorized.
287    pub fn find_user_by_cert_fingerprint(&self, fingerprint: &str) -> Option<&str> {
288        self.cert_fingerprint_to_user
289            .get(&fingerprint.to_lowercase())
290            .map(|s| s.as_str())
291    }
292
293    /// Collects all authorized X25519 public keys from all users.
294    ///
295    /// Keys that fail to decode (wrong length, invalid base64) are silently skipped.
296    ///
297    /// ### Returns
298    /// A set of all valid X25519 public keys across all users.
299    pub fn collect_noise_public_keys(&self) -> HashSet<PublicKey> {
300        self.noise_key_to_user.keys().cloned().collect()
301    }
302
303    /// Collects all authorized hybrid PQ public keys from all users.
304    ///
305    /// ### Returns
306    /// A set of all valid PQ public keys across all users.
307    pub fn collect_noise_pq_public_keys(&self) -> HashSet<PqPublicKey> {
308        self.noise_pq_key_to_user.keys().cloned().collect()
309    }
310
311    /// Collects all authorized certificate fingerprints from all users.
312    ///
313    /// ### Returns
314    /// A set of all certificate fingerprints (in `sha256:<hex>` format) across all users.
315    pub fn collect_cert_fingerprints(&self) -> HashSet<String> {
316        self.cert_fingerprint_to_user.keys().cloned().collect()
317    }
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323
324    const SAMPLE_USERS_TOML: &str = r#"
325        [users.alice]
326        authorized_keys = ["AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="]
327        authorized_certs = ["sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"]
328        bandwidth_limit = "10 mbps"
329
330        [users.bob]
331        authorized_keys = ["AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="]
332        authorized_certs = []
333    "#;
334
335    #[test]
336    fn parse_users_file() {
337        let users = UsersFile::parse(SAMPLE_USERS_TOML).expect("valid TOML");
338        assert_eq!(users.users.len(), 2);
339        assert!(users.users.contains_key("alice"));
340        assert!(users.users.contains_key("bob"));
341    }
342
343    #[test]
344    fn parse_empty_users_file() {
345        let users = UsersFile::parse("[users]").expect("valid TOML");
346        assert!(users.users.is_empty());
347    }
348
349    #[test]
350    fn parse_users_file_no_users_section() {
351        let users = UsersFile::parse("").expect("valid TOML with defaults");
352        assert!(users.users.is_empty());
353    }
354
355    #[test]
356    fn find_user_by_noise_pubkey_found() {
357        let users = UsersFile::parse(SAMPLE_USERS_TOML).expect("valid TOML");
358        // "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" decodes to [0u8; 32]
359        let key = PublicKey::from_bytes([0u8; 32]);
360        assert_eq!(users.find_user_by_noise_pubkey(&key), Some("alice"));
361    }
362
363    #[test]
364    fn find_user_by_noise_pubkey_not_found() {
365        let users = UsersFile::parse(SAMPLE_USERS_TOML).expect("valid TOML");
366        let key = PublicKey::from_bytes([0xFFu8; 32]);
367        assert_eq!(users.find_user_by_noise_pubkey(&key), None);
368    }
369
370    #[test]
371    fn find_user_by_cert_fingerprint_found() {
372        let users = UsersFile::parse(SAMPLE_USERS_TOML).expect("valid TOML");
373        assert_eq!(
374            users.find_user_by_cert_fingerprint(
375                "sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"
376            ),
377            Some("alice")
378        );
379    }
380
381    #[test]
382    fn find_user_by_cert_fingerprint_not_found() {
383        let users = UsersFile::parse(SAMPLE_USERS_TOML).expect("valid TOML");
384        assert_eq!(
385            users.find_user_by_cert_fingerprint("sha256:nonexistent"),
386            None
387        );
388    }
389
390    #[test]
391    fn collect_noise_public_keys() {
392        let users = UsersFile::parse(SAMPLE_USERS_TOML).expect("valid TOML");
393        let keys = users.collect_noise_public_keys();
394        // Both alice and bob have 32-byte keys
395        assert_eq!(keys.len(), 2);
396    }
397
398    #[test]
399    fn collect_cert_fingerprints() {
400        let users = UsersFile::parse(SAMPLE_USERS_TOML).expect("valid TOML");
401        let fps = users.collect_cert_fingerprints();
402        assert_eq!(fps.len(), 1);
403        assert!(
404            fps.contains("sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890")
405        );
406    }
407
408    #[test]
409    fn user_entry_defaults() {
410        let toml = r#"
411            [users.charlie]
412        "#;
413        let users = UsersFile::parse(toml).expect("valid TOML");
414        let charlie = users.users.get("charlie").expect("charlie exists");
415        assert!(charlie.authorized_keys.is_empty());
416        assert!(charlie.authorized_certs.is_empty());
417    }
418
419    #[test]
420    fn load_nonexistent_file() {
421        let result = UsersFile::load(Path::new("/nonexistent/users.toml"));
422        assert!(result.is_err());
423    }
424
425    #[test]
426    fn indices_built_for_empty_users() {
427        let users = UsersFile::parse("").expect("valid TOML");
428        assert!(users.noise_key_to_user.is_empty());
429        assert!(users.noise_pq_key_to_user.is_empty());
430        assert!(users.cert_fingerprint_to_user.is_empty());
431    }
432
433    #[test]
434    fn indices_built_correctly() {
435        let users = UsersFile::parse(SAMPLE_USERS_TOML).expect("valid TOML");
436
437        // Alice's key is [0u8; 32], Bob's key is [1, 0, 0, ..., 0]
438        assert_eq!(users.noise_key_to_user.len(), 2);
439        assert_eq!(users.cert_fingerprint_to_user.len(), 1);
440
441        let alice_key = PublicKey::from_bytes([0u8; 32]);
442        assert_eq!(users.noise_key_to_user.get(&alice_key).unwrap(), "alice");
443        assert_eq!(
444            users
445                .cert_fingerprint_to_user
446                .get("sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890")
447                .unwrap(),
448            "alice"
449        );
450    }
451
452    #[test]
453    fn duplicate_noise_key_rejected() {
454        // Both alice and bob share the same X25519 key ([0u8; 32])
455        let toml = r#"
456            [users.alice]
457            authorized_keys = ["AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="]
458
459            [users.bob]
460            authorized_keys = ["AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="]
461        "#;
462        let result = UsersFile::parse(toml);
463        assert!(result.is_err());
464        let err = result.unwrap_err().to_string();
465        assert!(err.contains("duplicate Noise X25519 key"), "error: {err}");
466    }
467
468    #[test]
469    fn duplicate_cert_fingerprint_rejected() {
470        let fp = "sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890";
471        let toml = format!(
472            r#"
473            [users.alice]
474            authorized_certs = ["{fp}"]
475
476            [users.bob]
477            authorized_certs = ["{fp}"]
478        "#
479        );
480        let result = UsersFile::parse(&toml);
481        assert!(result.is_err());
482        let err = result.unwrap_err().to_string();
483        assert!(
484            err.contains("duplicate certificate fingerprint"),
485            "error: {err}"
486        );
487    }
488
489    #[test]
490    fn duplicate_cert_fingerprint_case_insensitive() {
491        // Same fingerprint but with different casing should be detected as duplicate
492        let toml = r#"
493            [users.alice]
494            authorized_certs = ["sha256:ABCDEF1234567890abcdef1234567890abcdef1234567890abcdef1234567890"]
495
496            [users.bob]
497            authorized_certs = ["sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"]
498        "#;
499        let result = UsersFile::parse(toml);
500        assert!(result.is_err());
501        let err = result.unwrap_err().to_string();
502        assert!(
503            err.contains("duplicate certificate fingerprint"),
504            "error: {err}"
505        );
506    }
507
508    #[test]
509    fn fingerprint_normalized_to_lowercase() {
510        let toml = r#"
511            [users.alice]
512            authorized_certs = ["sha256:ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890"]
513        "#;
514        let users = UsersFile::parse(toml).expect("valid TOML");
515        // Lookup with lowercase should succeed after normalization
516        assert_eq!(
517            users.find_user_by_cert_fingerprint(
518                "sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"
519            ),
520            Some("alice")
521        );
522    }
523
524    #[test]
525    fn find_user_by_cert_fingerprint_mixed_case() {
526        let toml = r#"
527            [users.alice]
528            authorized_certs = ["sha256:ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890"]
529        "#;
530        let users = UsersFile::parse(toml).expect("valid TOML");
531        // Lookup with lowercase should work
532        assert_eq!(
533            users.find_user_by_cert_fingerprint(
534                "sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"
535            ),
536            Some("alice")
537        );
538        // Lookup with mixed case should also work (defense in depth)
539        assert_eq!(
540            users.find_user_by_cert_fingerprint(
541                "sha256:AbCdEf1234567890AbCdEf1234567890AbCdEf1234567890AbCdEf1234567890"
542            ),
543            Some("alice")
544        );
545    }
546
547    #[test]
548    fn fingerprint_missing_sha256_prefix_rejected() {
549        let toml = r#"
550            [users.alice]
551            authorized_certs = ["abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"]
552        "#;
553        let result = UsersFile::parse(toml);
554        assert!(result.is_err());
555        let err = result.unwrap_err().to_string();
556        assert!(err.contains("must start with 'sha256:'"), "error: {err}");
557    }
558
559    #[test]
560    fn fingerprint_wrong_hex_length_rejected() {
561        let toml = r#"
562            [users.alice]
563            authorized_certs = ["sha256:abcdef"]
564        "#;
565        let result = UsersFile::parse(toml);
566        assert!(result.is_err());
567        let err = result.unwrap_err().to_string();
568        assert!(err.contains("exactly 64 hex characters"), "error: {err}");
569    }
570
571    #[test]
572    fn fingerprint_non_hex_chars_rejected() {
573        let toml = r#"
574            [users.alice]
575            authorized_certs = ["sha256:zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"]
576        "#;
577        let result = UsersFile::parse(toml);
578        assert!(result.is_err());
579        let err = result.unwrap_err().to_string();
580        assert!(err.contains("exactly 64 hex characters"), "error: {err}");
581    }
582
583    #[test]
584    fn fingerprint_valid_formats_accepted() {
585        // All lowercase
586        let toml = r#"
587            [users.alice]
588            authorized_certs = ["sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"]
589        "#;
590        assert!(UsersFile::parse(toml).is_ok());
591
592        // Mixed case (should be normalized)
593        let toml = r#"
594            [users.alice]
595            authorized_certs = ["sha256:0123456789ABCDEF0123456789abcdef0123456789ABCDEF0123456789abcdef"]
596        "#;
597        assert!(UsersFile::parse(toml).is_ok());
598    }
599
600    #[test]
601    fn parse_user_entry_with_bandwidth_limit() {
602        let users = UsersFile::parse(SAMPLE_USERS_TOML).expect("valid TOML");
603        let alice = users.users.get("alice").expect("alice exists");
604        assert_eq!(
605            alice.bandwidth_limit,
606            Some(Bandwidth::from_bytes_per_second(1_250_000))
607        );
608        let bob = users.users.get("bob").expect("bob exists");
609        assert_eq!(bob.bandwidth_limit, None);
610    }
611
612    #[test]
613    fn same_key_for_same_user_rejected() {
614        // A user listing the same key twice should also be rejected as a duplicate
615        let toml = r#"
616            [users.alice]
617            authorized_keys = [
618                "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
619                "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
620            ]
621        "#;
622        let result = UsersFile::parse(toml);
623        assert!(result.is_err());
624        let err = result.unwrap_err().to_string();
625        assert!(err.contains("duplicate Noise X25519 key"), "error: {err}");
626    }
627
628    #[test]
629    fn parse_user_entry_with_address_pool() {
630        let toml = r#"
631            [users.alice]
632            authorized_keys = ["AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="]
633            address_pool = ["10.0.0.100/32", "10.0.0.101 - 10.0.0.103"]
634        "#;
635        let users = UsersFile::parse(toml).expect("valid TOML");
636        let alice = users.users.get("alice").expect("alice exists");
637        assert_eq!(alice.address_pool.len(), 2);
638    }
639
640    #[test]
641    fn parse_user_entry_without_address_pool() {
642        let toml = r#"
643            [users.alice]
644            authorized_keys = ["AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="]
645        "#;
646        let users = UsersFile::parse(toml).expect("valid TOML");
647        let alice = users.users.get("alice").expect("alice exists");
648        assert!(alice.address_pool.is_empty());
649    }
650
651    #[test]
652    fn overlapping_address_pools_between_users_rejected() {
653        let toml = r#"
654            [users.alice]
655            authorized_keys = ["AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="]
656            address_pool = ["10.0.0.100/31"]
657
658            [users.bob]
659            authorized_keys = ["AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="]
660            address_pool = ["10.0.0.100 - 10.0.0.101"]
661        "#;
662        let result = UsersFile::parse(toml);
663        assert!(result.is_err());
664        let err = result.unwrap_err().to_string();
665        assert!(err.contains("claimed by both users"), "error: {err}");
666    }
667
668    #[test]
669    fn duplicate_addresses_within_user_pool_rejected() {
670        let toml = r#"
671            [users.alice]
672            authorized_keys = ["AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="]
673            address_pool = ["10.0.0.100/32", "10.0.0.100/32"]
674        "#;
675        let result = UsersFile::parse(toml);
676        assert!(result.is_err());
677        let err = result.unwrap_err().to_string();
678        assert!(err.contains("duplicate address"), "error: {err}");
679    }
680}