use std::net::Ipv4Addr; use std::str::FromStr; use wireguard_admin::domain::{Client, ClientAddresses, PublicKey}; use wireguard_admin::wireguard::{ ConfigDocument, DocumentIssue, EditError, MetadataAttachment, MetadataIssue, PeerIssue, }; const FIXTURE: &str = include_str!("fixtures/wireguard/representative.conf"); const KEY_A: &str = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="; const KEY_B: &str = "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE="; const KEY_C: &str = "AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgI="; const KEY_D: &str = "AwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwM="; const SECRET_MARKERS: [&str; 3] = [ "SYNTHETIC-PLACEHOLDER-INTERFACE-PRIVATE-KEY", "SYNTHETIC-PLACEHOLDER-PEER-PSK-A", "SYNTHETIC-PLACEHOLDER-PEER-PSK-B", ]; type MetadataCase = (&'static str, &'static str, fn(&MetadataIssue) -> bool); fn key(text: &str) -> PublicKey { PublicKey::from_str(text).expect("synthetic public key is valid") } fn client(public_key: &str, alias: &str, last_octet: u8) -> Client { Client::new( key(public_key), alias, ClientAddresses { ipv4: Ipv4Addr::new(10, 44, 0, last_octet), ipv6: None, }, ) .expect("test alias is valid") } fn text(document: &ConfigDocument) -> &str { std::str::from_utf8(document.render()).expect("fixture is UTF-8") } fn assert_secret_free_debug(value: &T) { let debug = format!("{value:?}"); for marker in SECRET_MARKERS { assert!( !debug.contains(marker), "Debug leaked a synthetic secret marker" ); } } #[test] fn round_trip_preserves_lf_crlf_mixed_and_missing_final_newline_inputs() { let lf = FIXTURE.as_bytes().to_vec(); let crlf = FIXTURE.replace('\n', "\r\n").into_bytes(); let mixed = FIXTURE .lines() .enumerate() .flat_map(|(line, value)| { let ending = if line % 3 == 1 { "\r\n" } else { "\n" }; format!("{value}{ending}").into_bytes() }) .collect::>(); let mut no_final_newline = lf.clone(); while no_final_newline.last() == Some(&b'\n') || no_final_newline.last() == Some(&b'\r') { no_final_newline.pop(); } for (label, source) in [ ("LF", lf), ("CRLF", crlf), ("mixed", mixed), ("no final newline", no_final_newline), ] { let parsed = ConfigDocument::parse(&source).expect("synthetic UTF-8 parses"); assert!( parsed.render() == source.as_slice(), "{label} round trip changed bytes" ); assert_eq!(parsed.peers().len(), 2, "{label} lost a peer"); } } #[test] fn edits_keep_interface_and_unowned_bytes_exact_and_can_be_reparsed() { let original = ConfigDocument::parse(FIXTURE).unwrap(); let original_text = text(&original).to_owned(); let interface_end = original_text.find("\n\n# Admin note").unwrap(); let expected_interface = &original_text[..interface_end + 1]; let with_metadata = original .insert_legacy_metadata(&key(KEY_A), "old", Some("2024-02-29T12:30:00Z")) .unwrap(); let renamed = with_metadata .rename_client(&key(KEY_A), "new # = \"café\"") .unwrap(); let result = text(&renamed); assert!( result.starts_with(expected_interface), "interface bytes changed" ); assert!(result.contains("# Another administrator note.\n")); assert!(result.contains("PresharedKey = SYNTHETIC-PLACEHOLDER-PEER-PSK-A\n")); assert!(result.contains("CustomPeerOption = keep-me\n")); assert!(result.contains("# wg-admin:version=1\n")); assert!(result.contains("# wg-admin:created-utc=2024-02-29T12:30:00Z\n")); assert_eq!( renamed.project_client(&key(KEY_A)).unwrap().alias, "new # = \"café\"" ); assert_eq!( ConfigDocument::parse(renamed.render()).unwrap().render(), renamed.render() ); let deleted = renamed.delete_client(&key(KEY_A)).unwrap(); let deleted_text = text(&deleted); assert!( deleted_text.starts_with(expected_interface), "delete changed interface bytes" ); assert!(!deleted_text.contains(KEY_A)); assert!(deleted_text.contains("# Admin note: retained between interface and peer.\n")); assert!(deleted_text.contains("# Another administrator note.\n")); assert!(deleted_text.contains("PresharedKey = SYNTHETIC-PLACEHOLDER-PEER-PSK-B\n")); assert_eq!(deleted.peers().len(), 1); assert_eq!(deleted.peers()[0].identity, Some(key(KEY_B))); } #[test] fn rename_in_mixed_ending_metadata_changes_only_alias_representation() { let source = format!( "# admin preface\r\n# wg-admin:begin\n# wg-admin:version=1\r\n# wg-admin:alias=b2xk\n# wg-admin:created-utc=2024-01-02T03:04:05Z\r\n# wg-admin:end\n[Peer]\r\nPublicKey = {KEY_A}\nAllowedIPs = 10.44.0.2/32\r\nPresharedKey = SYNTHETIC-PLACEHOLDER-PEER-PSK-A" ); let original = ConfigDocument::parse(&source).unwrap(); let renamed = original.rename_client(&key(KEY_A), "new").unwrap(); let expected = source.replace("# wg-admin:alias=b2xk", "# wg-admin:alias=bmV3"); assert!( text(&renamed) == expected, "rename must replace only alias bytes" ); assert_eq!(renamed.project_client(&key(KEY_A)).unwrap().alias, "new"); } #[test] fn add_delete_and_repeated_edits_recompute_spans_without_touching_neighbors() { let source = "[Interface]\nListenPort = 51820\n\n[Peer]\nPublicKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\nAllowedIPs = 10.44.0.2/32\n\n# inter-peer note\n[Peer]\nPublicKey = AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=\nAllowedIPs = 10.44.0.3/32\n\n[Peer]\nPublicKey = AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgI=\nAllowedIPs = 10.44.0.4/32"; let original = ConfigDocument::parse(source).unwrap(); let added = original .add_client(&client(KEY_D, "duplicate alias", 9), None) .unwrap(); assert!( text(&added).starts_with(source), "add changed existing source bytes" ); let with_duplicate_aliases = added .insert_legacy_metadata(&key(KEY_A), "shared alias", None) .unwrap() .insert_legacy_metadata(&key(KEY_B), "shared alias", None) .unwrap(); assert_eq!( with_duplicate_aliases .project_client(&key(KEY_A)) .unwrap() .alias, with_duplicate_aliases .project_client(&key(KEY_B)) .unwrap() .alias ); let delete_first = original.delete_client(&key(KEY_A)).unwrap(); assert!(text(&delete_first).contains("# inter-peer note\n")); assert!(text(&delete_first).contains(KEY_B)); assert!(text(&delete_first).contains(KEY_C)); let delete_last = delete_first.delete_client(&key(KEY_C)).unwrap(); assert!(text(&delete_last).contains(KEY_B)); assert!(!text(&delete_last).contains(KEY_C)); let delete_only_peer = delete_last.delete_client(&key(KEY_B)).unwrap(); assert!(delete_only_peer.peers().is_empty()); let twice_renamed = added .rename_client(&key(KEY_D), "#marker = \"café\"") .unwrap() .rename_client(&key(KEY_D), "duplicate alias") .unwrap(); assert_eq!( twice_renamed.project_client(&key(KEY_D)).unwrap().alias, "duplicate alias" ); assert_eq!(text(&twice_renamed).matches("[Peer]").count(), 4); assert!(text(&twice_renamed).contains("# inter-peer note\n")); } #[test] fn metadata_issues_are_preserved_and_unsafe_edits_fail_without_mutation() { let cases: [MetadataCase; 3] = [ ( "unknown version", "# wg-admin:begin\n# wg-admin:version=2\n# wg-admin:alias=b2xk\n# wg-admin:end\n", |issue| matches!(issue, MetadataIssue::UnsupportedVersion { .. }), ), ( "unknown field", "# wg-admin:begin\n# wg-admin:version=1\n# wg-admin:alias=b2xk\n# wg-admin:future=x\n# wg-admin:end\n", |issue| matches!(issue, MetadataIssue::UnknownField { .. }), ), ( "duplicate field", "# wg-admin:begin\n# wg-admin:version=1\n# wg-admin:alias=b2xk\n# wg-admin:alias=b3RoZXI\n# wg-admin:end\n", |issue| matches!(issue, MetadataIssue::UnknownField { .. }), ), ]; for (label, block, matches_issue) in cases { let source = format!("{block}[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.44.0.2/32\n"); let parsed = ConfigDocument::parse(&source).unwrap(); assert!( matches!(&parsed.peers()[0].metadata, MetadataAttachment::Invalid(issue) if matches_issue(issue)), "{label} not reported" ); assert!(matches!( parsed.rename_client(&key(KEY_A), "safe"), Err(EditError::UnsafeDocument | EditError::UnsafePeer) )); assert!( parsed.render() == source.as_bytes(), "{label} rejection mutated source" ); } let malformed = format!( "# wg-admin:begin \n# wg-admin:version=1\n# wg-admin:alias=b2xk\n# wg-admin:end\n[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.44.0.2/32\n" ); let parsed = ConfigDocument::parse(&malformed).unwrap(); assert!(!parsed.issues().is_empty()); assert!(parsed.rename_client(&key(KEY_A), "safe").is_err()); assert!( parsed.render() == malformed.as_bytes(), "malformed input bytes changed" ); let misattached = format!( "# wg-admin:begin\n# wg-admin:version=1\n# wg-admin:alias=b2xk\n# wg-admin:end\n# admin intervenes\n[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.44.0.2/32\n" ); let parsed = ConfigDocument::parse(&misattached).unwrap(); assert!(parsed.issues().iter().any(|issue| matches!( issue, DocumentIssue::ManagedMetadata(MetadataIssue::Misattached { .. }) ))); assert!(parsed.rename_client(&key(KEY_A), "safe").is_err()); assert!( parsed.render() == misattached.as_bytes(), "misattached input bytes changed" ); } #[test] fn duplicate_adjacent_metadata_blocks_and_ambiguous_peers_remain_visible() { let block = "# wg-admin:begin\n# wg-admin:version=1\n# wg-admin:alias=b25l\n# wg-admin:end\n"; let duplicate_blocks = format!("{block}{block}[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.44.0.2/32\n"); let parsed = ConfigDocument::parse(&duplicate_blocks).unwrap(); assert!(matches!( &parsed.peers()[0].metadata, MetadataAttachment::Invalid(MetadataIssue::DuplicateBlocks { count: 2, .. }) )); assert!(parsed.rename_client(&key(KEY_A), "blocked").is_err()); assert!( parsed.render() == duplicate_blocks.as_bytes(), "duplicate blocks changed" ); let ambiguous = format!( "[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.44.0.2/32\nPublicKey = {KEY_B}\n\n[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.44.0.3/32\n" ); let parsed = ConfigDocument::parse(&ambiguous).unwrap(); assert!( parsed.peers()[0] .issues .iter() .any(|issue| matches!(issue, PeerIssue::DuplicatePublicKeyField { .. })) ); assert!( parsed.peers()[1] .issues .iter() .any(|issue| matches!(issue, PeerIssue::DuplicatePeerIdentity { .. })) ); assert!(parsed.rename_client(&key(KEY_A), "blocked").is_err()); assert!( parsed.render() == ambiguous.as_bytes(), "ambiguous source bytes changed" ); } #[test] fn aliases_encode_unicode_and_syntax_and_reject_control_injection() { for alias in ["# café = \"laptop\"", "# wg-admin:begin", "x=y # z"] { let document = ConfigDocument::parse(format!( "[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.44.0.2/32\n" )) .unwrap() .insert_legacy_metadata(&key(KEY_A), alias, None) .unwrap(); assert_eq!(document.project_client(&key(KEY_A)).unwrap().alias, alias); assert!(text(&document).contains("# wg-admin:alias=")); let rendered_alias = text(&document) .lines() .find(|line| line.starts_with("# wg-admin:alias=")) .expect("metadata has an alias line"); assert!(!rendered_alias.contains(alias)); } let original = ConfigDocument::parse(format!( "[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.44.0.2/32\n" )) .unwrap(); for alias in ["line\nbreak", "carriage\rreturn", "terminal\u{001b}escape"] { assert!(matches!( original.insert_legacy_metadata(&key(KEY_A), alias, None), Err(EditError::InvalidAlias) )); assert_eq!(original.render().last(), Some(&b'\n')); } } #[test] fn unsupported_routes_multiple_addresses_and_ipv6_only_round_trip_but_projection_fails() { let cases = [ ("multiple host routes", "10.44.0.2/32, 10.44.0.22/32"), ("network route", "10.44.0.0/24"), ("default route", "0.0.0.0/0"), ("IPv6 only", "fd42::2/128"), ]; for (label, routes) in cases { let source = format!( "# wg-admin:begin\n# wg-admin:version=1\n# wg-admin:alias=Y2xpZW50\n# wg-admin:end\n[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = {routes}\n" ); let parsed = ConfigDocument::parse(&source).unwrap(); let peer = &parsed.peers()[0]; assert!( parsed.render() == source.as_bytes(), "{label} round trip changed bytes" ); match label { "multiple host routes" => assert!(peer.issues.iter().any(|issue| matches!( issue, PeerIssue::UnsupportedAllowedIpShape { field: 0, item: 1 } ))), "network route" | "default route" => assert!(peer.issues.iter().any(|issue| matches!( issue, PeerIssue::UnsupportedAllowedIpShape { field: 0, item: 0 } ))), "IPv6 only" => assert!(peer.issues.contains(&PeerIssue::MissingIpv4HostRoute)), _ => unreachable!("test case has an expected route shape"), } let issue_debug = format!("{:?}", peer.issues); assert!( !issue_debug.contains(routes), "{label} issue exposed route text" ); assert_eq!( parsed.project_client(&key(KEY_A)), Err(EditError::UnsupportedAddresses), "{label} projection" ); let renamed = parsed.rename_client(&key(KEY_A), "updated").unwrap(); let expected = source.replace("# wg-admin:alias=Y2xpZW50", "# wg-admin:alias=dXBkYXRlZA"); assert_eq!( text(&renamed), expected, "{label} rename changed other bytes" ); } } #[test] fn valid_subnet_routes_are_classified_without_changing_source_or_mutation_safety() { let source = format!( "# wg-admin:begin\n# wg-admin:version=1\n# wg-admin:alias=Y2xpZW50\n# wg-admin:end\n[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.9.0.10/32, 192.168.88.0/24, 192.168.8.0/24\n" ); let document = ConfigDocument::parse(&source).unwrap(); let peer = &document.peers()[0]; assert!(peer.has_subnet_routes); assert_eq!(peer.allowed_ips.len(), 3); assert!(peer.issues.iter().any(|issue| matches!( issue, PeerIssue::UnsupportedAllowedIpShape { field: 0, item: 1 } ))); assert_eq!(document.render(), source.as_bytes()); assert_eq!( document.project_client(&key(KEY_A)), Err(EditError::UnsupportedAddresses) ); let renamed = document .rename_client(&key(KEY_A), "alias-only edit remains byte-preserving") .expect("safe alias-only edit remains supported"); assert!( String::from_utf8_lossy(renamed.render()) .contains("AllowedIPs = 10.9.0.10/32, 192.168.88.0/24, 192.168.8.0/24") ); let malformed = format!( "[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.9.0.10/32, 192.168.88.0/24, not-an-address\n" ); let mixed = ConfigDocument::parse(&malformed).unwrap(); assert!(mixed.peers()[0].has_subnet_routes); assert!( mixed.peers()[0] .issues .iter() .any(|issue| matches!(issue, PeerIssue::MalformedAllowedIp { .. })) ); } #[test] fn parse_errors_debug_and_failed_edits_never_expose_document_secrets() { let document = ConfigDocument::parse(FIXTURE).unwrap(); assert_secret_free_debug(&document); assert_secret_free_debug(&document.peers()); let duplicate_identity = document .add_client(&client(KEY_A, "duplicate", 9), None) .unwrap_err(); assert_secret_free_debug(&duplicate_identity); let invalid = ConfigDocument::parse(b"[Interface]\nPrivateKey = SYNTHETIC\n\xff").unwrap_err(); assert_secret_free_debug(&invalid); assert!(!invalid.to_string().contains("SYNTHETIC")); let original_bytes = document.render().to_vec(); assert!(document.rename_client(&key(KEY_C), "not present").is_err()); assert!(document.delete_client(&key(KEY_C)).is_err()); assert!( document.render() == original_bytes.as_slice(), "failed edits changed source bytes" ); }