use std::fs; use std::path::Path; use wireguard_admin::config::{ResolvedSettings, SettingsFile}; use wireguard_admin::domain::PublicKey; use wireguard_admin::vault::import::{CandidateStatus, parse_client_config}; use wireguard_admin::vault::source::{ MAX_CANDIDATE_BYTES, SourceFailureKind, import_configured_directory, prepare_initial_setup, }; use wireguard_admin::vault::{VaultBundle, WireGuardPrivateKey}; use wireguard_admin::wireguard::ConfigDocument; fn settings(source: &Path) -> ResolvedSettings { let raw = format!( r#" interface_name = "wg0" server_config_path = "server.conf" client_source_directory = {:?} server_endpoint = "vpn.example:51820" ipv4_allocation_range = "10.8.0.0/24" ipv6_allocation_range = "fd00::/64" dns_addresses = ["10.8.0.1"] systemd_unit = "wg-quick@wg0.service" restart_timeout_seconds = 30 "#, source.display().to_string() ); SettingsFile::parse_toml(&raw) .unwrap() .validate() .unwrap() .resolve_paths(Path::new("/synthetic/app")) } fn server_and_client() -> (ConfigDocument, WireGuardPrivateKey, PublicKey) { let server_key = WireGuardPrivateKey::generate().unwrap(); let client_key = WireGuardPrivateKey::generate().unwrap(); let client_public = client_key.public_key(); let server = ConfigDocument::parse( format!( "[Interface]\nPrivateKey = {}\nAddress = 10.8.0.1/24\n\n[Peer]\nPublicKey = {}\nAllowedIPs = 10.8.0.2/32\n# admin note\n", server_key.to_base64(), client_public ) .as_bytes(), ) .unwrap(); (server, client_key, client_public) } fn client_file(client: &WireGuardPrivateKey, server: &ConfigDocument) -> Vec { let server_key = server .source_slice(server.sections()[0].span) .unwrap() .lines() .find_map(|line| line.strip_prefix("PrivateKey = ")) .map(|value| { WireGuardPrivateKey::parse_base64(value) .unwrap() .public_key() }) .unwrap(); format!( "[Interface]\nPrivateKey = {}\nAddress = 10.8.0.2/32\n\n[Peer]\nPublicKey = {}\nEndpoint = vpn.example:51820\nAllowedIPs = 0.0.0.0/0, ::/0\n", client.to_base64(), server_key ) .into_bytes() } #[test] fn initial_setup_encrypts_valid_files_and_leaves_sources_and_server_unchanged() { let directory = tempfile::tempdir().unwrap(); let (server, client, identity) = server_and_client(); let original_server = server.render(); let source_bytes = client_file(&client, &server); fs::write(directory.path().join("laptop.conf"), &source_bytes).unwrap(); fs::write( directory.path().join("broken.conf"), b"not a WireGuard file", ) .unwrap(); fs::write(directory.path().join("ignored.txt"), &source_bytes).unwrap(); fs::create_dir(directory.path().join("nested")).unwrap(); fs::write(directory.path().join("nested/server.conf"), &source_bytes).unwrap(); let prepared = prepare_initial_setup(&settings(directory.path()), &server, "setup password").unwrap(); assert!(prepared.report.scan_complete); assert_eq!(prepared.bundle.record_count(), 1); let statuses = prepared.report.reconciliation.candidate_summaries(); assert!(statuses.iter().any(|(_, status)| matches!( status, CandidateStatus::Matched { address_discrepancy: false, server_identity: wireguard_admin::vault::import::ServerIdentityComparison::Matches, missing_metadata: true, invalid_metadata: false, unsupported_projection: false, } ))); assert!( statuses .iter() .any(|(_, status)| matches!(status, CandidateStatus::Invalid(_))) ); let encoded = prepared.bundle.to_toml().unwrap(); assert!(!encoded.contains(&client.to_base64())); let loaded = VaultBundle::from_toml(&encoded).unwrap(); assert_eq!(loaded.record_count(), 1); assert!(loaded.contains_record(identity)); assert_eq!( loaded .retrieve_record(identity, "setup password") .unwrap() .public_key(), identity ); assert!(loaded.retrieve_record(identity, "wrong password").is_err()); assert!(fs::read(directory.path().join("laptop.conf")).unwrap() == source_bytes); assert_eq!( fs::read(directory.path().join("broken.conf")).unwrap(), b"not a WireGuard file" ); assert!(server.render() == original_server); } #[test] fn symlinks_and_oversized_candidates_are_reported_without_import() { let directory = tempfile::tempdir().unwrap(); let external = tempfile::NamedTempFile::new().unwrap(); fs::write(external.path(), b"outside source root").unwrap(); #[cfg(unix)] std::os::unix::fs::symlink(external.path(), directory.path().join("linked.conf")).unwrap(); fs::write( directory.path().join("large.conf"), vec![b'x'; MAX_CANDIDATE_BYTES + 1], ) .unwrap(); let (server, _, _) = server_and_client(); let mut bundle = VaultBundle::create("password").unwrap(); let report = import_configured_directory(&mut bundle, &settings(directory.path()), &server).unwrap(); assert_eq!(bundle.record_count(), 0); assert!(!report.scan_complete); assert!(report.issues.iter().any(|issue| { issue.kind == SourceFailureKind::FileTooLarge && issue.file_name.as_deref() == Some("large.conf") })); #[cfg(unix)] assert!(report.issues.iter().any(|issue| { issue.kind == SourceFailureKind::FileUnavailable && issue.file_name.as_deref() == Some("linked.conf") })); } #[test] fn enumeration_failure_is_visible_and_does_not_claim_a_complete_scan() { let absent = tempfile::tempdir().unwrap().path().join("missing"); let (server, _, _) = server_and_client(); let mut bundle = VaultBundle::create("password").unwrap(); let report = import_configured_directory(&mut bundle, &settings(&absent), &server).unwrap(); assert!(!report.scan_complete); assert_eq!( report.issues[0].kind, SourceFailureKind::DirectoryUnavailable ); assert_eq!(bundle.record_count(), 0); } #[test] fn candidate_count_limit_marks_the_scan_incomplete() { let directory = tempfile::tempdir().unwrap(); for index in 0..=wireguard_admin::vault::source::MAX_CANDIDATES { fs::write( directory.path().join(format!("candidate-{index}.conf")), b"bad", ) .unwrap(); } let (server, _, _) = server_and_client(); let mut bundle = VaultBundle::create("password").unwrap(); let report = import_configured_directory(&mut bundle, &settings(directory.path()), &server).unwrap(); assert!(!report.scan_complete); assert!( report .issues .iter() .any(|issue| issue.kind == SourceFailureKind::TooManyEntries) ); assert_eq!(bundle.record_count(), 0); } #[test] fn explicit_import_uses_existing_identity_and_rejects_duplicate_without_replacement() { let directory = tempfile::tempdir().unwrap(); let (server, client, identity) = server_and_client(); let initial_source = tempfile::tempdir().unwrap(); let initial = prepare_initial_setup(&settings(initial_source.path()), &server, "password").unwrap(); let original_recipient = initial.bundle.recipient().to_owned(); let mut bundle = initial.bundle; fs::write( directory.path().join("client.conf"), client_file(&client, &server), ) .unwrap(); let report = import_configured_directory(&mut bundle, &settings(directory.path()), &server).unwrap(); assert_eq!(bundle.recipient(), original_recipient); assert_eq!(bundle.record_count(), 1); assert!( report .reconciliation .candidate_summaries() .iter() .any(|(_, status)| matches!(status, CandidateStatus::Matched { .. })) ); let before_repeat = bundle.to_toml().unwrap(); let repeated = import_configured_directory(&mut bundle, &settings(directory.path()), &server).unwrap(); assert_eq!(bundle.to_toml().unwrap(), before_repeat); assert_eq!(bundle.record_count(), 1); assert!( repeated .issues .iter() .any(|issue| issue.kind == SourceFailureKind::DuplicateRecordEqualityUnverified) ); let result = bundle.retrieve_record(identity, "password").unwrap(); assert_eq!(result.public_key(), identity); assert!(parse_client_config(&client_file(&client, &server)).is_ok()); }