Files
wireguard-admin/tests/config_preservation.rs
T
2026-10-26 23:25:13 +02:00

435 lines
17 KiB
Rust

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<T: std::fmt::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::<Vec<_>>();
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"
);
}