#[must_use]
#[cfg(unix)]
#[allow(unsafe_code)]
pub fn is_root() -> bool {
unsafe { libc::geteuid() == 0 }
}
#[must_use]
#[cfg(not(unix))]
pub fn is_root() -> bool {
false
}
pub fn create_user_dir(path: &std::path::Path) -> std::io::Result<()> {
std::fs::create_dir_all(path)?;
crate::config::fix_ownership(path);
Ok(())
}
pub fn write_user_file(path: &std::path::Path, contents: impl AsRef<[u8]>) -> std::io::Result<()> {
std::fs::write(path, contents)?;
crate::config::fix_ownership(path);
Ok(())
}
#[must_use]
pub fn format_bytes_speed(bytes: u64) -> String {
#[allow(clippy::cast_precision_loss)]
if bytes >= 1_000_000 {
format!("{:.1} MB/s", bytes as f64 / 1_000_000.0)
} else if bytes >= 1_000 {
format!("{:.1} KB/s", bytes as f64 / 1_000.0)
} else {
format!("{bytes} B/s")
}
}
#[must_use]
pub fn is_private_ip(ip: &str) -> bool {
let parts: Vec<&str> = ip.split('.').collect();
if parts.len() != 4 {
return false;
}
let octets: Result<Vec<u8>, _> = parts.iter().map(|p| p.parse::<u8>()).collect();
let Ok(octets) = octets else {
return false;
};
match octets[0] {
10 => true, 172 if (16..=31).contains(&octets[1]) => true, 192 if octets[1] == 168 => true, _ => false,
}
}
pub fn get_app_config_dir() -> std::io::Result<std::path::PathBuf> {
crate::config::get_config_dir()
}
pub fn get_profiles_dir() -> std::io::Result<std::path::PathBuf> {
let root = get_app_config_dir()?;
let path = root.join(crate::constants::PROFILES_DIR_NAME);
if !path.exists() {
create_user_dir(&path)?;
}
Ok(path)
}
#[cfg(unix)]
pub fn get_tmp_config_dir(session_id: &str) -> std::io::Result<std::path::PathBuf> {
use std::os::unix::fs::DirBuilderExt;
let root = get_app_config_dir()?;
let tmp_root = root.join(crate::constants::TMP_CONFIG_DIR);
if !tmp_root.exists() {
std::fs::DirBuilder::new()
.mode(0o700)
.recursive(true)
.create(&tmp_root)?;
crate::config::fix_ownership(&tmp_root);
}
let session_dir = tmp_root.join(session_id);
if !session_dir.exists() {
std::fs::DirBuilder::new()
.mode(0o700)
.recursive(true)
.create(&session_dir)?;
crate::config::fix_ownership(&session_dir);
}
Ok(session_dir)
}
#[cfg(not(unix))]
pub fn get_tmp_config_dir(session_id: &str) -> std::io::Result<std::path::PathBuf> {
let root = get_app_config_dir()?;
let session_dir = root.join(crate::constants::TMP_CONFIG_DIR).join(session_id);
if !session_dir.exists() {
create_user_dir(&session_dir)?;
}
Ok(session_dir)
}
#[must_use]
pub fn sanitize_profile_name(name: &str) -> String {
name.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
c
} else {
'_'
}
})
.collect()
}
pub fn get_openvpn_run_paths(
profile_name: &str,
) -> std::io::Result<(std::path::PathBuf, std::path::PathBuf)> {
let root = get_app_config_dir()?;
let run_dir = root.join(crate::constants::OPENVPN_RUN_DIR);
if !run_dir.exists() {
create_user_dir(&run_dir)?;
}
let safe_name = sanitize_profile_name(profile_name);
let pid_path = run_dir.join(format!("{safe_name}.pid"));
let log_path = run_dir.join(format!("{safe_name}.log"));
Ok((pid_path, log_path))
}
pub fn cleanup_openvpn_run_files(profile_name: &str) {
if let Ok((pid_path, log_path)) = get_openvpn_run_paths(profile_name) {
let _ = std::fs::remove_file(&pid_path);
let _ = std::fs::remove_file(&log_path);
}
}
#[must_use]
pub fn read_openvpn_pid(profile_name: &str) -> Option<u32> {
let (pid_path, _) = get_openvpn_run_paths(profile_name).ok()?;
let content = std::fs::read_to_string(&pid_path).ok()?;
content.trim().parse::<u32>().ok()
}
pub fn get_openvpn_auth_path(profile_name: &str) -> std::io::Result<std::path::PathBuf> {
let root = get_app_config_dir()?;
let auth_dir = root.join(crate::constants::OPENVPN_AUTH_DIR);
if !auth_dir.exists() {
create_user_dir(&auth_dir)?;
}
let safe_name = sanitize_profile_name(profile_name);
Ok(auth_dir.join(format!("{safe_name}.auth")))
}
fn format_openvpn_auth_body(username: &str, password: &str) -> String {
format!("{username}\n{password}\n")
}
pub fn get_openvpn_scrv1_auth_path(profile_name: &str) -> std::io::Result<std::path::PathBuf> {
let root = get_app_config_dir()?;
let auth_dir = root.join(crate::constants::OPENVPN_AUTH_DIR);
if !auth_dir.exists() {
create_user_dir(&auth_dir)?;
}
let safe_name = sanitize_profile_name(profile_name);
Ok(auth_dir.join(format!("{safe_name}.scrv1.auth")))
}
#[cfg(unix)]
pub fn write_openvpn_scrv1_auth_file(
profile_name: &str,
username: &str,
password: &str,
otp: &str,
) -> std::io::Result<std::path::PathBuf> {
use crate::vortix_core::secret_file::{write_secret_file, SecretFileError};
let auth_path = get_openvpn_scrv1_auth_path(profile_name)?;
match std::fs::remove_file(&auth_path) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(e),
}
let body = format!("{username}\n{password}\n{otp}\n");
write_secret_file(&auth_path, body.as_bytes()).map_err(|e| match e {
SecretFileError::Io(io) => io,
other => std::io::Error::other(other.to_string()),
})?;
Ok(auth_path)
}
#[cfg(not(unix))]
pub fn write_openvpn_scrv1_auth_file(
profile_name: &str,
username: &str,
password: &str,
otp: &str,
) -> std::io::Result<std::path::PathBuf> {
let auth_path = get_openvpn_scrv1_auth_path(profile_name)?;
let body = format!("{username}\n{password}\n{otp}\n");
write_user_file(&auth_path, body)?;
Ok(auth_path)
}
pub fn delete_openvpn_scrv1_auth_file(profile_name: &str) {
if let Ok(auth_path) = get_openvpn_scrv1_auth_path(profile_name) {
let _ = std::fs::remove_file(&auth_path);
}
}
#[cfg(unix)]
pub fn write_openvpn_auth_file(
profile_name: &str,
username: &str,
password: &str,
) -> std::io::Result<std::path::PathBuf> {
use crate::vortix_core::secret_file::{write_secret_file, SecretFileError};
let auth_path = get_openvpn_auth_path(profile_name)?;
match std::fs::remove_file(&auth_path) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(e),
}
let body = format_openvpn_auth_body(username, password);
write_secret_file(&auth_path, body.as_bytes()).map_err(|e| match e {
SecretFileError::Io(io) => io,
other => std::io::Error::other(other.to_string()),
})?;
Ok(auth_path)
}
#[cfg(not(unix))]
pub fn write_openvpn_auth_file(
profile_name: &str,
username: &str,
password: &str,
) -> std::io::Result<std::path::PathBuf> {
let auth_path = get_openvpn_auth_path(profile_name)?;
let body = format_openvpn_auth_body(username, password);
write_user_file(&auth_path, body)?;
Ok(auth_path)
}
#[must_use]
pub fn read_openvpn_saved_auth(profile_name: &str) -> Option<(String, String)> {
let auth_path = get_openvpn_auth_path(profile_name).ok()?;
let content = std::fs::read_to_string(&auth_path).ok()?;
let mut lines = content.lines();
let username = lines.next()?.to_string();
let password = lines.next()?.to_string();
if username.is_empty() || password.is_empty() {
return None;
}
Some((username, password))
}
pub fn delete_openvpn_auth_file(profile_name: &str) {
if let Ok(auth_path) = get_openvpn_auth_path(profile_name) {
let _ = std::fs::remove_file(&auth_path);
}
}
#[must_use]
pub fn read_openvpn_static_challenge_prompt(config_path: &std::path::Path) -> Option<String> {
let text = std::fs::read_to_string(config_path).ok()?;
let parsed = crate::vortix_protocol_openvpn::parser::parse_ovpn_conf(&text).ok()?;
parsed.static_challenge.map(|sc| sc.prompt)
}
pub fn scrub_stale_scrv1_auth_files() {
let Ok(root) = get_app_config_dir() else {
return;
};
let auth_dir = root.join(crate::constants::OPENVPN_AUTH_DIR);
let Ok(entries) = std::fs::read_dir(&auth_dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
let Some(name) = path.file_name().and_then(|s| s.to_str()) else {
continue;
};
if name.to_ascii_lowercase().ends_with(".scrv1.auth") {
tracing::warn!(
file = %name,
"AUTH: stale credentials bundle — clearing"
);
let _ = std::fs::remove_file(&path);
}
}
}
#[must_use]
pub fn openvpn_config_needs_auth(config_path: &std::path::Path) -> bool {
let Ok(content) = std::fs::read_to_string(config_path) else {
return false;
};
for line in content.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with(';') {
continue;
}
if trimmed == crate::constants::OVPN_AUTH_USER_PASS {
return true;
}
if let Some(rest) = trimmed.strip_prefix(crate::constants::OVPN_AUTH_USER_PASS) {
if rest.trim().is_empty() {
return true;
}
return false;
}
}
false
}
#[must_use]
pub fn truncate(s: &str, max_chars: usize) -> String {
if s.chars().count() > max_chars {
let mut t: String = s.chars().take(max_chars.saturating_sub(3)).collect();
t.push_str("...");
t
} else {
s.to_string()
}
}
#[must_use]
pub fn format_local_time() -> String {
format_system_time_local(std::time::SystemTime::now())
}
#[must_use]
pub fn format_system_time_local(time: std::time::SystemTime) -> String {
format_system_time_inner(time).unwrap_or_else(|| "00:00:00".to_string())
}
#[cfg(unix)]
#[allow(unsafe_code)]
fn format_system_time_inner(time: std::time::SystemTime) -> Option<String> {
let secs = time
.duration_since(std::time::SystemTime::UNIX_EPOCH)
.ok()?
.as_secs();
let mut tm: libc::tm = unsafe { std::mem::zeroed() };
#[allow(clippy::cast_possible_wrap)]
let time_t = secs as libc::time_t;
let result =
unsafe { libc::localtime_r(std::ptr::from_ref(&time_t), std::ptr::from_mut(&mut tm)) };
if result.is_null() {
return None;
}
Some(format!(
"{:02}:{:02}:{:02}",
tm.tm_hour, tm.tm_min, tm.tm_sec
))
}
#[cfg(not(unix))]
fn format_system_time_inner(time: std::time::SystemTime) -> Option<String> {
use time::format_description::well_known::iso8601;
let odt = time::OffsetDateTime::from(time);
let format = time::format_description::parse("[hour]:[minute]:[second]").ok()?;
let _ = iso8601;
odt.format(&format).ok()
}
#[must_use]
pub fn format_relative_time(time: std::time::SystemTime) -> String {
let now = std::time::SystemTime::now();
match now.duration_since(time) {
Ok(duration) => {
let secs = duration.as_secs();
if secs < 60 {
format!("{secs}s")
} else if secs < 3600 {
format!("{}m", secs / 60)
} else if secs < 86400 {
format!("{}h", secs / 3600)
} else if secs < 2_592_000 {
format!("{}d ago", secs / 86400)
} else if secs < 31_536_000 {
format!("{}M ago", secs / 2_592_000)
} else {
format!("{}Y ago", secs / 31_536_000)
}
}
Err(_) => "now".to_string(),
}
}
pub fn home_dir() -> Option<std::path::PathBuf> {
std::env::var("HOME")
.ok()
.map(std::path::PathBuf::from)
.or_else(home_dir_from_passwd)
}
#[cfg(unix)]
#[allow(unsafe_code)]
fn home_dir_from_passwd() -> Option<std::path::PathBuf> {
unsafe {
let uid = libc::getuid();
let pw = libc::getpwuid(uid);
if pw.is_null() {
return None;
}
let home = std::ffi::CStr::from_ptr((*pw).pw_dir);
home.to_str().ok().map(std::path::PathBuf::from)
}
}
#[cfg(not(unix))]
fn home_dir_from_passwd() -> Option<std::path::PathBuf> {
None
}
#[derive(serde::Serialize, serde::Deserialize)]
pub struct ProfileMetadata {
#[serde(
with = "systemtime_serde",
skip_serializing_if = "Option::is_none",
default
)]
pub last_used: Option<std::time::SystemTime>,
}
mod systemtime_serde {
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::time::{SystemTime, UNIX_EPOCH};
#[allow(clippy::ref_option)]
pub fn serialize<S>(time: &Option<SystemTime>, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match time {
Some(t) => {
let duration = t
.duration_since(UNIX_EPOCH)
.map_err(serde::ser::Error::custom)?;
duration.as_secs().serialize(serializer)
}
None => serializer.serialize_none(),
}
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<SystemTime>, D::Error>
where
D: Deserializer<'de>,
{
let secs: Option<u64> = Option::deserialize(deserializer)?;
Ok(secs.map(|s| UNIX_EPOCH + std::time::Duration::from_secs(s)))
}
}
pub fn load_profile_metadata() -> Result<std::collections::HashMap<String, ProfileMetadata>, String>
{
let metadata_path = get_app_config_dir()
.map_err(|e| format!("Failed to get config dir: {e}"))?
.join(crate::constants::METADATA_FILE_NAME);
if !metadata_path.exists() {
return Ok(std::collections::HashMap::new());
}
let content = std::fs::read_to_string(&metadata_path)
.map_err(|e| format!("Failed to read metadata: {e}"))?;
serde_json::from_str(&content).or_else(|e| {
crate::logger::log(
crate::logger::LogLevel::Warning,
"CONFIG",
format!(
"Failed to parse {}: {}. Using defaults.",
crate::constants::METADATA_FILE_NAME,
e
),
);
Ok(std::collections::HashMap::new())
})
}
pub fn save_profile_metadata(
data: &std::collections::HashMap<String, ProfileMetadata>,
) -> Result<(), String> {
let metadata_path = get_app_config_dir()
.map_err(|e| format!("Failed to get config dir: {e}"))?
.join(crate::constants::METADATA_FILE_NAME);
let json = serde_json::to_string_pretty(data)
.map_err(|e| format!("Failed to serialize metadata: {e}"))?;
write_user_file(&metadata_path, json).map_err(|e| format!("Failed to write metadata: {e}"))?;
Ok(())
}
#[must_use]
pub fn get_unique_path(dir: &std::path::Path, filename: &str) -> std::path::PathBuf {
let mut path = dir.join(filename);
let mut counter = 1;
let path_obj = std::path::Path::new(filename);
let stem = path_obj
.file_stem()
.map_or(filename, |s| s.to_str().unwrap_or(filename));
let ext = path_obj.extension().map(|e| e.to_str().unwrap_or(""));
while path.exists() {
let new_name = if let Some(e) = ext {
if e.is_empty() {
format!("{stem}_{counter}")
} else {
format!("{stem}_{counter}.{e}")
}
} else {
format!("{stem}_{counter}")
};
path = dir.join(new_name);
counter += 1;
}
path
}
pub(crate) fn binary_exists(name: &str) -> bool {
use std::env;
let Ok(path) = env::var("PATH") else {
return false;
};
for dir in env::split_paths(&path) {
let candidate = dir.join(name);
if !candidate.is_file() {
continue;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(meta) = candidate.metadata() {
if meta.permissions().mode() & 0o111 != 0 {
return true;
}
}
}
#[cfg(not(unix))]
{
return true;
}
}
false
}
pub(crate) fn find_binary_path(name: &str) -> Option<std::path::PathBuf> {
use std::env;
let path = env::var("PATH").ok()?;
for dir in env::split_paths(&path) {
let candidate = dir.join(name);
if !candidate.is_file() {
continue;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(meta) = candidate.metadata() {
if meta.permissions().mode() & 0o111 != 0 {
return Some(candidate);
}
}
}
#[cfg(not(unix))]
{
return Some(candidate);
}
}
None
}
#[cfg(target_os = "linux")] pub(crate) fn resolvconf_works() -> bool {
use crate::vortix_process::CommandSpec;
use std::time::Duration;
if !binary_exists("resolvconf") {
return false;
}
crate::vortix_process::run_to_output(
CommandSpec::oneshot("resolvconf", vec!["--version".into()])
.timeout(Duration::from_secs(10)),
)
.is_ok_and(|o| o.status.success())
}
#[cfg(target_os = "linux")] pub(crate) fn resolvectl_works() -> bool {
use crate::vortix_process::CommandSpec;
use std::time::Duration;
if !binary_exists("resolvectl") {
return false;
}
crate::vortix_process::run_to_output(
CommandSpec::oneshot("resolvectl", vec!["--version".into()])
.timeout(Duration::from_secs(10)),
)
.is_ok_and(|o| o.status.success())
}
#[cfg(target_os = "linux")] pub(crate) fn use_resolvectl_path() -> bool {
is_systemd_resolved() && resolvectl_works()
}
#[cfg(target_os = "linux")] pub(crate) fn is_systemd_resolved() -> bool {
match std::fs::read_link("/etc/resolv.conf") {
Ok(target) => {
let s = target.to_string_lossy();
s.contains("systemd") || s.contains("resolvconf/run")
}
Err(_) => false,
}
}
#[cfg(any(target_os = "linux", test))]
pub(crate) fn wireguard_config_has_dns(config_path: &std::path::Path) -> bool {
let Ok(content) = std::fs::read_to_string(config_path) else {
return false;
};
for line in content.lines() {
let trimmed = line.trim().to_lowercase();
if trimmed.starts_with("dns") {
if let Some(rest) = trimmed.strip_prefix("dns") {
let rest = rest.trim_start();
if rest.starts_with('=') {
return true;
}
}
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{Duration, SystemTime};
#[test]
fn binary_exists_finds_a_known_present_unix_binary() {
#[cfg(unix)]
assert!(
binary_exists("sh"),
"binary_exists should locate `sh` on Unix-like PATH"
);
#[cfg(not(unix))]
let _ = binary_exists("sh");
}
#[test]
fn binary_exists_returns_false_for_known_absent_binary() {
assert!(!binary_exists("vortix-nonexistent-xyz123"));
}
#[test]
fn find_binary_path_returns_existing_path_for_known_unix_binary() {
#[cfg(unix)]
{
let path =
find_binary_path("sh").expect("`sh` should be locatable on every Unix CI runner");
assert!(path.is_file(), "returned path must exist on disk: {path:?}");
assert!(
path.ends_with("sh"),
"returned path's filename should be `sh`: {path:?}"
);
}
}
#[test]
fn find_binary_path_returns_none_for_known_absent_binary() {
assert!(find_binary_path("vortix-nonexistent-xyz123").is_none());
}
#[test]
fn find_binary_path_and_binary_exists_agree() {
for name in ["sh", "vortix-nonexistent-xyz123", "cat", "another-fake"] {
assert_eq!(
binary_exists(name),
find_binary_path(name).is_some(),
"binary_exists and find_binary_path disagree on `{name}`"
);
}
}
#[test]
fn test_format_bytes_speed_bytes() {
assert_eq!(format_bytes_speed(0), "0 B/s");
assert_eq!(format_bytes_speed(500), "500 B/s");
assert_eq!(format_bytes_speed(999), "999 B/s");
}
#[test]
fn test_format_bytes_speed_kilobytes() {
assert_eq!(format_bytes_speed(1_000), "1.0 KB/s");
assert_eq!(format_bytes_speed(1_500), "1.5 KB/s");
assert_eq!(format_bytes_speed(999_999), "1000.0 KB/s");
}
#[test]
fn test_format_bytes_speed_megabytes() {
assert_eq!(format_bytes_speed(1_000_000), "1.0 MB/s");
assert_eq!(format_bytes_speed(1_500_000), "1.5 MB/s");
assert_eq!(format_bytes_speed(100_000_000), "100.0 MB/s");
}
#[test]
fn test_truncate_short_string() {
assert_eq!(truncate("hello", 10), "hello");
assert_eq!(truncate("test", 4), "test");
}
#[test]
fn test_truncate_exact_length() {
assert_eq!(truncate("hello", 5), "hello");
}
#[test]
fn test_truncate_long_string() {
assert_eq!(truncate("hello world", 8), "hello...");
assert_eq!(truncate("this is a long string", 10), "this is...");
}
#[test]
fn test_truncate_with_unicode() {
assert_eq!(truncate("héllo", 5), "héllo");
assert_eq!(truncate("héllo world", 8), "héllo...");
}
#[test]
fn test_home_dir_exists() {
let home = home_dir();
assert!(home.is_some());
assert!(home.unwrap().exists());
}
#[test]
fn test_format_relative_time() {
let now = SystemTime::now();
let just_now = now - Duration::from_secs(5);
assert_eq!(format_relative_time(just_now), "5s");
let five_mins = now - Duration::from_secs(300);
assert_eq!(format_relative_time(five_mins), "5m");
let two_hours = now - Duration::from_secs(7200);
assert_eq!(format_relative_time(two_hours), "2h");
let three_days = now - Duration::from_secs(86400 * 3);
assert_eq!(format_relative_time(three_days), "3d ago");
let two_months = now - Duration::from_secs(2_592_000 * 2);
assert_eq!(format_relative_time(two_months), "2M ago");
let three_years = now - Duration::from_secs(31_536_000 * 3);
assert_eq!(format_relative_time(three_years), "3Y ago");
let future = now + Duration::from_secs(10);
assert_eq!(format_relative_time(future), "now");
}
#[test]
fn test_is_private_ip_class_a() {
assert!(is_private_ip("10.0.0.1"));
assert!(is_private_ip("10.255.255.255"));
assert!(is_private_ip("10.1.2.3"));
}
#[test]
fn test_is_private_ip_class_b() {
assert!(is_private_ip("172.16.0.1"));
assert!(is_private_ip("172.31.255.255"));
assert!(is_private_ip("172.20.10.5"));
}
#[test]
fn test_is_private_ip_class_c() {
assert!(is_private_ip("192.168.0.1"));
assert!(is_private_ip("192.168.255.255"));
assert!(is_private_ip("192.168.1.100"));
}
#[test]
fn test_is_private_ip_public() {
assert!(!is_private_ip("8.8.8.8"));
assert!(!is_private_ip("1.2.3.4"));
assert!(!is_private_ip("172.15.0.1")); assert!(!is_private_ip("172.32.0.1")); assert!(!is_private_ip("192.169.0.1")); }
#[test]
fn test_is_private_ip_invalid() {
assert!(!is_private_ip("999.999.999.999"));
assert!(!is_private_ip("not.an.ip.address"));
assert!(!is_private_ip("10.0.0"));
assert!(!is_private_ip(""));
}
#[test]
fn test_get_unique_path_no_collision() {
let dir = tempfile::Builder::new()
.prefix("vortix_test_")
.tempdir()
.unwrap();
let path = get_unique_path(dir.path(), "test.conf");
assert_eq!(path.file_name().unwrap(), "test.conf");
}
#[test]
fn test_get_unique_path_with_collision() {
let dir = tempfile::Builder::new()
.prefix("vortix_test_")
.tempdir()
.unwrap();
std::fs::write(dir.path().join("test.conf"), "existing").unwrap();
let path = get_unique_path(dir.path(), "test.conf");
assert_eq!(path.file_name().unwrap(), "test_1.conf");
std::fs::write(dir.path().join("test_1.conf"), "also existing").unwrap();
let path2 = get_unique_path(dir.path(), "test.conf");
assert_eq!(path2.file_name().unwrap(), "test_2.conf");
}
#[test]
fn test_openvpn_config_needs_auth_bare_directive() {
let dir = tempfile::Builder::new()
.prefix("vortix_test_")
.tempdir()
.unwrap();
let path = dir.path().join("test.ovpn");
std::fs::write(
&path,
"client\nremote example.com 1194\nauth-user-pass\ndev tun\n",
)
.unwrap();
assert!(openvpn_config_needs_auth(&path));
}
#[test]
fn test_openvpn_config_needs_auth_bare_with_trailing_space() {
let dir = tempfile::Builder::new()
.prefix("vortix_test_")
.tempdir()
.unwrap();
let path = dir.path().join("test.ovpn");
std::fs::write(
&path,
"client\nremote example.com 1194\nauth-user-pass \ndev tun\n",
)
.unwrap();
assert!(openvpn_config_needs_auth(&path));
}
#[test]
fn test_openvpn_config_needs_auth_with_file_arg() {
let dir = tempfile::Builder::new()
.prefix("vortix_test_")
.tempdir()
.unwrap();
let path = dir.path().join("test.ovpn");
std::fs::write(
&path,
"client\nremote example.com 1194\nauth-user-pass /etc/openvpn/creds.txt\ndev tun\n",
)
.unwrap();
assert!(!openvpn_config_needs_auth(&path));
}
#[test]
fn test_openvpn_config_needs_auth_absent() {
let dir = tempfile::Builder::new()
.prefix("vortix_test_")
.tempdir()
.unwrap();
let path = dir.path().join("test.ovpn");
std::fs::write(
&path,
"client\nremote example.com 1194\ndev tun\nproto udp\n",
)
.unwrap();
assert!(!openvpn_config_needs_auth(&path));
}
#[test]
fn test_openvpn_config_needs_auth_commented_out() {
let dir = tempfile::Builder::new()
.prefix("vortix_test_")
.tempdir()
.unwrap();
let path = dir.path().join("test.ovpn");
std::fs::write(
&path,
"client\nremote example.com 1194\n# auth-user-pass\n; auth-user-pass\ndev tun\n",
)
.unwrap();
assert!(!openvpn_config_needs_auth(&path));
}
#[test]
fn test_openvpn_config_needs_auth_nonexistent_file() {
let path = std::path::PathBuf::from("/tmp/nonexistent_vortix_config_12345.ovpn");
assert!(!openvpn_config_needs_auth(&path));
}
static CONFIG_DIR_GUARD: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn set_temp_config_dir() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>) {
let guard = CONFIG_DIR_GUARD
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let dir = tempfile::Builder::new()
.prefix("vortix_utils_test_")
.tempdir()
.unwrap();
crate::config::set_config_dir(dir.path().to_path_buf());
(dir, guard)
}
#[test]
fn test_write_read_openvpn_auth_file() {
let _tmp = set_temp_config_dir();
let name = "test_auth_roundtrip";
let result = write_openvpn_auth_file(name, "myuser", "mypass");
assert!(result.is_ok());
let path = result.unwrap();
assert!(path.exists());
let creds = read_openvpn_saved_auth(name);
assert!(creds.is_some());
let (user, pass) = creds.unwrap();
assert_eq!(user, "myuser");
assert_eq!(pass, "mypass");
delete_openvpn_auth_file(name);
assert!(!path.exists());
}
#[cfg(unix)]
#[test]
fn test_auth_file_permissions() {
use std::os::unix::fs::PermissionsExt;
let _tmp = set_temp_config_dir();
let name = "test_auth_perms";
let result = write_openvpn_auth_file(name, "user", "pass");
assert!(result.is_ok());
let path = result.unwrap();
let perms = std::fs::metadata(&path).unwrap().permissions();
assert_eq!(perms.mode() & 0o777, 0o600);
delete_openvpn_auth_file(name);
}
#[test]
fn auth_file_format_is_username_then_password() {
let body = format_openvpn_auth_body("u", "p");
assert_eq!(body, "u\np\n");
}
#[test]
fn scrub_deletes_scrv1_bundle_and_leaves_canonical_auth_alone() {
let _tmp = set_temp_config_dir();
let plain = write_openvpn_auth_file("scrub-plain", "u", "p").unwrap();
let bundle = write_openvpn_scrv1_auth_file("scrub-bundle", "u", "p", "123456").unwrap();
assert!(plain.exists());
assert!(bundle.exists());
scrub_stale_scrv1_auth_files();
assert!(plain.exists(), "canonical .auth file must survive scrub");
assert!(
!bundle.exists(),
".scrv1.auth bundle must be deleted by scrub"
);
delete_openvpn_auth_file("scrub-plain");
}
#[test]
fn scrub_no_op_when_auth_dir_missing() {
let _tmp = set_temp_config_dir();
scrub_stale_scrv1_auth_files();
}
#[test]
fn test_sanitize_profile_name_ascii() {
assert_eq!(sanitize_profile_name("my-vpn_1"), "my-vpn_1");
}
#[test]
fn test_sanitize_profile_name_spaces() {
assert_eq!(sanitize_profile_name("my vpn server"), "my_vpn_server");
}
#[test]
fn test_sanitize_profile_name_special_chars() {
assert_eq!(sanitize_profile_name("vpn@home!#$"), "vpn_home___");
}
#[test]
fn test_sanitize_profile_name_unicode_rejected() {
assert_eq!(sanitize_profile_name("café-vpn"), "caf_-vpn");
assert_eq!(sanitize_profile_name("München"), "M_nchen");
}
#[test]
fn test_sanitize_profile_name_cjk() {
assert_eq!(sanitize_profile_name("日本VPN"), "__VPN");
}
#[test]
fn test_sanitize_profile_name_empty() {
assert_eq!(sanitize_profile_name(""), "");
}
#[test]
fn test_truncate_very_small_budget() {
assert_eq!(truncate("hello world", 3), "...");
assert_eq!(truncate("hello world", 2), "...");
assert_eq!(truncate("hello world", 0), "...");
}
#[test]
fn test_read_openvpn_saved_auth_missing_file() {
let creds = read_openvpn_saved_auth("nonexistent_profile_xyz_12345");
assert!(creds.is_none());
}
#[test]
fn test_read_openvpn_saved_auth_empty_creds() {
let _tmp = set_temp_config_dir();
let name = "test_auth_empty_creds";
let path = get_openvpn_auth_path(name).unwrap();
std::fs::write(&path, "\npassword\n").unwrap();
assert!(read_openvpn_saved_auth(name).is_none());
std::fs::write(&path, "username\n\n").unwrap();
assert!(read_openvpn_saved_auth(name).is_none());
delete_openvpn_auth_file(name);
}
#[test]
fn test_wg_config_has_dns_present() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("wg0.conf");
std::fs::write(
&path,
"[Interface]\nPrivateKey = abc\nAddress = 10.0.0.2/24\nDNS = 1.1.1.1\n\n[Peer]\nPublicKey = xyz\nEndpoint = 1.2.3.4:51820\n",
)
.unwrap();
assert!(wireguard_config_has_dns(&path));
}
#[test]
fn test_wg_config_has_dns_absent() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("wg0.conf");
std::fs::write(
&path,
"[Interface]\nPrivateKey = abc\nAddress = 10.0.0.2/24\n\n[Peer]\nPublicKey = xyz\nEndpoint = 1.2.3.4:51820\n",
)
.unwrap();
assert!(!wireguard_config_has_dns(&path));
}
#[test]
fn test_wg_config_has_dns_case_insensitive() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("wg0.conf");
std::fs::write(
&path,
"[Interface]\nPrivateKey = abc\nAddress = 10.0.0.2/24\ndns = 8.8.8.8\n\n[Peer]\nPublicKey = xyz\nEndpoint = 1.2.3.4:51820\n",
)
.unwrap();
assert!(wireguard_config_has_dns(&path));
}
#[test]
fn test_wg_config_has_dns_with_spaces() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("wg0.conf");
std::fs::write(
&path,
"[Interface]\nPrivateKey = abc\nAddress = 10.0.0.2/24\n DNS = 1.1.1.1, 8.8.8.8\n\n[Peer]\nPublicKey = xyz\nEndpoint = 1.2.3.4:51820\n",
)
.unwrap();
assert!(wireguard_config_has_dns(&path));
}
#[test]
fn test_wg_config_has_dns_nonexistent_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("missing.conf");
assert!(!wireguard_config_has_dns(&path));
}
#[test]
fn test_wg_config_dns_in_comment_not_matched() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("wg0.conf");
std::fs::write(
&path,
"[Interface]\nPrivateKey = abc\nAddress = 10.0.0.2/24\n# DNS = 1.1.1.1\n\n[Peer]\nPublicKey = xyz\nEndpoint = 1.2.3.4:51820\n",
)
.unwrap();
assert!(!wireguard_config_has_dns(&path));
}
#[cfg(unix)]
#[test]
fn test_get_tmp_config_dir_creates_session_subdir_at_0700() {
use std::os::unix::fs::PermissionsExt;
let _tmp = set_temp_config_dir();
let sid = format!("session-{}-{}", std::process::id(), line!());
let session_dir = get_tmp_config_dir(&sid).unwrap();
assert!(session_dir.ends_with(format!("tmp/{sid}")));
let leaf_perms = std::fs::metadata(&session_dir).unwrap().permissions();
assert_eq!(leaf_perms.mode() & 0o777, 0o700);
let tmp_root = session_dir.parent().unwrap();
let root_perms = std::fs::metadata(tmp_root).unwrap().permissions();
assert_eq!(root_perms.mode() & 0o777, 0o700);
}
#[cfg(unix)]
#[test]
fn test_get_tmp_config_dir_is_idempotent() {
let _tmp = set_temp_config_dir();
let sid = format!("idempotent-{}-{}", std::process::id(), line!());
let a = get_tmp_config_dir(&sid).unwrap();
let b = get_tmp_config_dir(&sid).unwrap();
assert_eq!(a, b);
assert!(a.exists());
}
}