//! Trusted saved-file access and recoverable multi-file installation. use std::fmt; use std::fs::{self, File}; use std::io::{self, Read, Write}; use std::os::fd::OwnedFd; use std::os::unix::fs::{MetadataExt, PermissionsExt}; use std::path::{Component, Path, PathBuf}; use rustix::fs::{self as rfs, AtFlags, Mode, OFlags}; use serde::{Deserialize, Serialize}; use thiserror::Error; use time::OffsetDateTime; use time::format_description::well_known::Rfc3339; use crate::backup::SavedBytes; use crate::config::{ResolvedSettings, SettingsFile}; use crate::vault::VaultBundle; use crate::wireguard::ConfigDocument; const MAX_SETTINGS: usize = 1024 * 1024; const MAX_SERVER: usize = 4 * 1024 * 1024; const MAX_STATE: usize = 2 * 1024 * 1024; const MAX_VAULT: usize = 16 * 1024 * 1024; const STATE_VERSION: u32 = 1; const JOURNAL: &str = ".wg-admin-transaction"; const JOURNAL_META: &str = "transaction.toml"; /// Capability passed by the application after its mandatory backup completed. /// Its constructor is crate-private so filesystem installation cannot be /// called from an external library consumer without crossing the app boundary. pub struct BackupComplete(pub(crate) ()); #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum InstallCheckpoint { JournalPayloadWrite(usize), JournalPayloadSync(usize), StageWrite(usize), StageSync(usize), ParentSync(usize), PreparedMarker, PreparedMarkerSync, CommittedMarker, CommittedMarkerSync, RollbackReplace(usize), JournalCleanup, Staged, Prepared, Replaced(usize), CommitMarked, } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(deny_unknown_fields)] pub struct PersistedState { version: u32, pub reserved_addresses: Vec, pub last_verified_at_utc: Option, } impl PersistedState { pub fn new( reserved_addresses: impl IntoIterator, last_verified_at_utc: Option, ) -> Result { let mut addresses = reserved_addresses.into_iter().collect::>(); addresses.sort(); if addresses.windows(2).any(|pair| pair[0] == pair[1]) || addresses.iter().any(|address| !canonical_ip(address)) { return Err(PersistenceError::InvalidState); } if let Some(timestamp) = &last_verified_at_utc { validate_timestamp(timestamp)?; } Ok(Self { version: STATE_VERSION, reserved_addresses: addresses, last_verified_at_utc, }) } pub fn to_toml(&self) -> Result, PersistenceError> { validate_state(self)?; toml::to_string(self) .map(String::into_bytes) .map_err(|_| PersistenceError::InvalidState) } pub fn from_toml(bytes: &[u8]) -> Result { let source = std::str::from_utf8(bytes).map_err(|_| PersistenceError::InvalidState)?; let state: Self = toml::from_str(source).map_err(|_| PersistenceError::InvalidState)?; validate_state(&state)?; Ok(state) } pub fn apply_to( &self, states: &mut crate::domain::ClientStates, ) -> Result<(), PersistenceError> { validate_state(self)?; let addresses = self .reserved_addresses .iter() .map(|address| { address .parse::() .map_err(|_| PersistenceError::InvalidState) }) .collect::, _>>()?; states .restore_persisted_reservations(addresses) .map_err(|_| PersistenceError::InvalidState) } } fn validate_state(state: &PersistedState) -> Result<(), PersistenceError> { if state.version != STATE_VERSION || state .reserved_addresses .windows(2) .any(|pair| pair[0] >= pair[1]) || state .reserved_addresses .iter() .any(|address| !canonical_ip(address)) { return Err(PersistenceError::InvalidState); } if let Some(timestamp) = &state.last_verified_at_utc { validate_timestamp(timestamp)?; } Ok(()) } fn canonical_ip(value: &str) -> bool { value .parse::() .is_ok_and(|address| address.to_string() == value) } fn validate_timestamp(value: &str) -> Result<(), PersistenceError> { let parsed = OffsetDateTime::parse(value, &Rfc3339).map_err(|_| PersistenceError::InvalidState)?; if parsed.offset() != time::UtcOffset::UTC || parsed.nanosecond() != 0 || parsed .format(&Rfc3339) .map_err(|_| PersistenceError::InvalidState)? != value { return Err(PersistenceError::InvalidState); } Ok(()) } #[derive(Debug, Error)] pub enum PersistenceError { #[error("saved state is invalid or incompatible with the configured installation")] InvalidState, #[error("saved path is unsafe, aliased, or is not a regular file")] UnsafePath, #[error("saved server configuration or vault has permissions broader than 0600")] InsecurePermissions, #[error("saved file exceeds its size limit")] TooLarge, #[error("saved-file transaction needs recovery before this operation")] RecoveryRequired, #[error("saved-file operation failed")] Io(#[source] io::Error), } impl From for PersistenceError { fn from(error: io::Error) -> Self { Self::Io(error) } } impl From for PersistenceError { fn from(error: rustix::io::Errno) -> Self { Self::Io(error.into()) } } /// Paths are derived only from executable-relative resolved settings. #[derive(Clone)] pub struct SavedStore { executable_directory: PathBuf, settings: ResolvedSettings, } impl SavedStore { pub fn new( executable_directory: impl Into, settings: ResolvedSettings, ) -> Result { let executable_directory = executable_directory.into(); let store = Self { executable_directory, settings, }; store.check_mapping()?; Ok(store) } /// Trusted archive destination paired with this store's resolved settings. pub(crate) fn archives_directory(&self) -> &Path { &self.settings.archives_directory } pub fn read_saved(&self) -> Result { self.recover()?; self.check_mapping()?; let settings = read_regular(&self.settings_path(), MAX_SETTINGS)?; let server_config = read_private_regular(&self.settings.server_config_path, MAX_SERVER)?; let internal_state = read_optional(&self.state_path(), MAX_STATE)?; let vault = read_optional_private(&self.vault_path(), MAX_VAULT)?; let saved = SavedBytes { settings, server_config, internal_state, vault, }; self.validate_saved(&saved)?; Ok(saved) } pub fn validate_saved(&self, saved: &SavedBytes) -> Result<(), PersistenceError> { self.resolved_settings_for(saved)?; ConfigDocument::parse(&saved.server_config).map_err(|_| PersistenceError::InvalidState)?; if let Some(bytes) = &saved.internal_state { PersistedState::from_toml(bytes)?; } if let Some(bytes) = &saved.vault { let source = std::str::from_utf8(bytes).map_err(|_| PersistenceError::InvalidState)?; VaultBundle::from_toml(source).map_err(|_| PersistenceError::InvalidState)?; } Ok(()) } pub(crate) fn resolved_settings_for( &self, saved: &SavedBytes, ) -> Result { let settings_text = std::str::from_utf8(&saved.settings).map_err(|_| PersistenceError::InvalidState)?; let resolved = SettingsFile::parse_toml(settings_text) .map_err(|_| PersistenceError::InvalidState)? .validate() .map_err(|_| PersistenceError::InvalidState)? .resolve_paths(&self.executable_directory); if !same_installation_paths(&resolved, &self.settings) { return Err(PersistenceError::InvalidState); } Ok(resolved) } pub(crate) fn set_resolved_settings(&mut self, settings: ResolvedSettings) { self.settings = settings; } pub fn recover(&self) -> Result<(), PersistenceError> { self.recover_with_hook(|_| Ok(())) } pub(crate) fn recover_with_hook( &self, mut checkpoint: impl FnMut(InstallCheckpoint) -> io::Result<()>, ) -> Result<(), PersistenceError> { self.check_mapping()?; let journal = self.settings.data_directory.join(JOURNAL); match fs::symlink_metadata(&journal) { Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), Err(error) => Err(error.into()), Ok(meta) if !meta.file_type().is_dir() => Err(PersistenceError::RecoveryRequired), Ok(_) => self.recover_journal(&journal, &mut checkpoint), } } pub fn install( &self, saved: &SavedBytes, _backup: BackupComplete, ) -> Result<(), PersistenceError> { self.install_with_hook(saved, |_| Ok(())) } pub(crate) fn install_with_hook( &self, saved: &SavedBytes, mut checkpoint: impl FnMut(InstallCheckpoint) -> io::Result<()>, ) -> Result<(), PersistenceError> { self.validate_saved(saved)?; self.check_mapping()?; self.recover()?; let old = self.read_unvalidated_current()?; self.validate_saved(&old)?; self.ensure_private_dir(&self.settings.data_directory)?; let journal = self.settings.data_directory.join(JOURNAL); create_private_dir(&journal).map_err(|error| { if error.kind() == io::ErrorKind::AlreadyExists { PersistenceError::RecoveryRequired } else { error.into() } })?; let prepare = (|| -> Result<(), PersistenceError> { for (index, bytes) in component_bytes(&old).into_iter().enumerate() { write_journal_payload(&journal, &format!("old-{index}"), bytes, |sync| { checkpoint(if sync { InstallCheckpoint::JournalPayloadSync(index) } else { InstallCheckpoint::JournalPayloadWrite(index) }) })?; } for (index, bytes) in component_bytes(saved).into_iter().enumerate() { write_journal_payload(&journal, &format!("new-{index}"), bytes, |sync| { checkpoint(if sync { InstallCheckpoint::JournalPayloadSync(index + 4) } else { InstallCheckpoint::JournalPayloadWrite(index + 4) }) })?; } checkpoint(InstallCheckpoint::Staged)?; let metadata = JournalMetadata { version: 1, old_present: presence(&old), }; checkpoint(InstallCheckpoint::PreparedMarker)?; write_private_atomic( &journal.join(JOURNAL_META), &toml::to_string(&metadata) .map_err(|_| PersistenceError::InvalidState)? .into_bytes(), )?; checkpoint(InstallCheckpoint::PreparedMarkerSync)?; File::open(&journal)?.sync_all()?; File::open(&self.settings.data_directory)?.sync_all()?; write_private_atomic(&journal.join("prepared"), b"prepared\n")?; File::open(&journal)?.sync_all()?; File::open(&self.settings.data_directory)?.sync_all()?; checkpoint(InstallCheckpoint::Prepared)?; Ok(()) })(); if let Err(error) = prepare { return match fs::remove_dir_all(&journal) { Ok(()) => Err(error), Err(_) => Err(PersistenceError::RecoveryRequired), }; } let targets = self.targets(); let proposed_components = component_bytes(saved); for index in 0..4 { let bytes = proposed_components[index]; replace_file_with_hook(&targets[index], bytes, index, &mut checkpoint) .map_err(|_| PersistenceError::RecoveryRequired)?; checkpoint(InstallCheckpoint::Replaced(index + 1)) .map_err(|_| PersistenceError::RecoveryRequired)?; } sync_target_parents_with_hook(&targets, &mut checkpoint) .map_err(|_| PersistenceError::RecoveryRequired)?; checkpoint(InstallCheckpoint::CommittedMarker) .map_err(|_| PersistenceError::RecoveryRequired)?; write_private_atomic(&journal.join("committed"), b"committed\n") .map_err(|_| PersistenceError::RecoveryRequired)?; checkpoint(InstallCheckpoint::CommitMarked) .map_err(|_| PersistenceError::RecoveryRequired)?; checkpoint(InstallCheckpoint::CommittedMarkerSync) .map_err(|_| PersistenceError::RecoveryRequired)?; File::open(&journal) .and_then(|dir| dir.sync_all()) .map_err(|_| PersistenceError::RecoveryRequired)?; checkpoint(InstallCheckpoint::JournalCleanup) .map_err(|_| PersistenceError::RecoveryRequired)?; fs::remove_dir_all(&journal).map_err(|_| PersistenceError::RecoveryRequired)?; File::open(&self.settings.data_directory) .and_then(|dir| dir.sync_all()) .map_err(|_| PersistenceError::RecoveryRequired)?; Ok(()) } fn recover_journal( &self, journal: &Path, checkpoint: &mut impl FnMut(InstallCheckpoint) -> io::Result<()>, ) -> Result<(), PersistenceError> { match read_regular(&journal.join("prepared"), 64) { Ok(bytes) if bytes == b"prepared\n" => {} Err(PersistenceError::Io(error)) if error.kind() == io::ErrorKind::NotFound => { // No destination is touched until the prepared marker and all // prior-state payloads have been synced. checkpoint(InstallCheckpoint::JournalCleanup) .map_err(|_| PersistenceError::RecoveryRequired)?; fs::remove_dir_all(journal).map_err(|_| PersistenceError::RecoveryRequired)?; File::open(&self.settings.data_directory)?.sync_all()?; return Ok(()); } _ => return Err(PersistenceError::RecoveryRequired), } let meta = match read_regular(&journal.join(JOURNAL_META), 64 * 1024) { Ok(meta) => meta, Err(PersistenceError::Io(error)) if error.kind() == io::ErrorKind::NotFound => { // The journal is durable before the first destination write. A // missing metadata file therefore means preparation never // crossed the mutation boundary. checkpoint(InstallCheckpoint::JournalCleanup) .map_err(|_| PersistenceError::RecoveryRequired)?; fs::remove_dir_all(journal).map_err(|_| PersistenceError::RecoveryRequired)?; File::open(&self.settings.data_directory)?.sync_all()?; return Ok(()); } Err(_) => return Err(PersistenceError::RecoveryRequired), }; let metadata: JournalMetadata = toml::from_str( std::str::from_utf8(&meta).map_err(|_| PersistenceError::RecoveryRequired)?, ) .map_err(|_| PersistenceError::RecoveryRequired)?; if metadata.version != 1 { return Err(PersistenceError::RecoveryRequired); } match read_regular(&journal.join("committed"), 64) { Ok(bytes) if bytes == b"committed\n" => { checkpoint(InstallCheckpoint::JournalCleanup) .map_err(|_| PersistenceError::RecoveryRequired)?; fs::remove_dir_all(journal).map_err(|_| PersistenceError::RecoveryRequired)?; File::open(&self.settings.data_directory)?.sync_all()?; return Ok(()); } Err(PersistenceError::Io(error)) if error.kind() == io::ErrorKind::NotFound => {} _ => return Err(PersistenceError::RecoveryRequired), } let targets = self.targets(); for index in (0..4).rev() { checkpoint(InstallCheckpoint::RollbackReplace(index)) .map_err(|_| PersistenceError::RecoveryRequired)?; let old = if metadata.old_present[index] { Some( read_regular( &journal.join(format!("old-{index}")), component_limit(index), ) .map_err(|_| PersistenceError::RecoveryRequired)?, ) } else { None }; replace_file(&targets[index], old.as_deref()) .map_err(|_| PersistenceError::RecoveryRequired)?; } sync_target_parents_with_hook(&targets, checkpoint) .map_err(|_| PersistenceError::RecoveryRequired)?; checkpoint(InstallCheckpoint::JournalCleanup) .map_err(|_| PersistenceError::RecoveryRequired)?; fs::remove_dir_all(journal).map_err(|_| PersistenceError::RecoveryRequired)?; File::open(&self.settings.data_directory)?.sync_all()?; Ok(()) } fn read_unvalidated_current(&self) -> Result { Ok(SavedBytes { settings: read_regular(&self.settings_path(), MAX_SETTINGS)?, server_config: read_private_regular(&self.settings.server_config_path, MAX_SERVER)?, internal_state: read_optional(&self.state_path(), MAX_STATE)?, vault: read_optional_private(&self.vault_path(), MAX_VAULT)?, }) } fn check_mapping(&self) -> Result<(), PersistenceError> { let targets = self.targets(); for path in &targets { check_path_components(path, true)?; } for a in 0..targets.len() { for b in a + 1..targets.len() { if lexical_normalize(&targets[a]) == lexical_normalize(&targets[b]) { return Err(PersistenceError::UnsafePath); } if let (Ok(ma), Ok(mb)) = ( fs::symlink_metadata(&targets[a]), fs::symlink_metadata(&targets[b]), ) { use std::os::unix::fs::MetadataExt; if ma.dev() == mb.dev() && ma.ino() == mb.ino() { return Err(PersistenceError::UnsafePath); } } } } Ok(()) } fn ensure_private_dir(&self, path: &Path) -> Result<(), PersistenceError> { create_path_dirs(path)?; fs::set_permissions(path, fs::Permissions::from_mode(0o700))?; Ok(()) } fn settings_path(&self) -> PathBuf { self.executable_directory .join(crate::config::SETTINGS_FILE_NAME) } fn state_path(&self) -> PathBuf { self.settings.data_directory.join("internal-state.toml") } fn vault_path(&self) -> PathBuf { self.settings.data_directory.join("vault.toml") } fn targets(&self) -> [PathBuf; 4] { [ self.settings_path(), self.settings.server_config_path.clone(), self.state_path(), self.vault_path(), ] } /// Paths which client plaintext delivery must not replace. This is a /// read-only view of the resolved installation mapping; source candidates /// are protected individually so a safe, newly selected sibling output /// remains available. pub(crate) fn delivery_output_protection( &self, ) -> Result { let mut protected_files = self.targets().into_iter().collect::>(); let source_directory = &self.settings.client_source_directory; match fs::symlink_metadata(source_directory) { Ok(metadata) if metadata.file_type().is_dir() => {} Ok(_) => return Err(crate::delivery::files::FileOutputError::UnsafePath), Err(error) if error.kind() == io::ErrorKind::NotFound => { return Ok(crate::delivery::files::OutputProtection::new( protected_files, vec![ self.settings.data_directory.clone(), self.settings.archives_directory.clone(), ], )); } Err(_) => return Err(crate::delivery::files::FileOutputError::UnsafePath), } match fs::read_dir(source_directory) { Ok(entries) => { let mut count = 0usize; for entry in entries { let entry = entry.map_err(|_| crate::delivery::files::FileOutputError::Io)?; count += 1; if count > crate::vault::source::MAX_DIRECTORY_ENTRIES { return Err(crate::delivery::files::FileOutputError::UnsafePath); } protected_files.push(entry.path()); } } Err(error) if error.kind() == io::ErrorKind::NotFound => {} Err(_) => return Err(crate::delivery::files::FileOutputError::UnsafePath), } Ok(crate::delivery::files::OutputProtection::new( protected_files, vec![ self.settings.data_directory.clone(), self.settings.archives_directory.clone(), ], )) } } #[derive(Deserialize, Serialize)] #[serde(deny_unknown_fields)] struct JournalMetadata { version: u32, old_present: [bool; 4], } fn same_installation_paths(a: &ResolvedSettings, b: &ResolvedSettings) -> bool { a.interface_name == b.interface_name && a.server_config_path == b.server_config_path && a.client_source_directory == b.client_source_directory && a.data_root == b.data_root && a.data_directory == b.data_directory && a.archives_directory == b.archives_directory } fn presence(saved: &SavedBytes) -> [bool; 4] { [ true, true, saved.internal_state.is_some(), saved.vault.is_some(), ] } fn component_bytes(saved: &SavedBytes) -> [Option<&[u8]>; 4] { [ Some(&saved.settings), Some(&saved.server_config), saved.internal_state.as_deref(), saved.vault.as_deref(), ] } fn component_limit(index: usize) -> usize { [MAX_SETTINGS, MAX_SERVER, MAX_STATE, MAX_VAULT][index] } fn write_journal_payload( dir: &Path, name: &str, bytes: Option<&[u8]>, mut checkpoint: impl FnMut(bool) -> io::Result<()>, ) -> Result<(), PersistenceError> { if let Some(bytes) = bytes { write_private(&dir.join(name), bytes, &mut checkpoint)?; } Ok(()) } fn write_private( path: &Path, bytes: &[u8], checkpoint: &mut impl FnMut(bool) -> io::Result<()>, ) -> Result<(), PersistenceError> { let parent = path.parent().ok_or(PersistenceError::UnsafePath)?; let name = path.file_name().ok_or(PersistenceError::UnsafePath)?; let dir = open_dir(parent)?; let fd = rfs::openat( &dir, name, OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::NOFOLLOW | OFlags::CLOEXEC, Mode::from_bits_truncate(0o600), )?; let mut file = File::from(fd); checkpoint(false)?; file.write_all(bytes)?; checkpoint(true)?; file.sync_all()?; Ok(()) } fn write_private_atomic(path: &Path, bytes: &[u8]) -> Result<(), PersistenceError> { let parent = path.parent().ok_or(PersistenceError::UnsafePath)?; let dir = open_dir(parent)?; let name = path.file_name().ok_or(PersistenceError::UnsafePath)?; let temporary = format!( ".wg-admin-journal-{}-{}", std::process::id(), getrandom::u32().map_err(|_| PersistenceError::InvalidState)? ); let fd = rfs::openat( &dir, temporary.as_str(), OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::NOFOLLOW | OFlags::CLOEXEC, Mode::from_bits_truncate(0o600), )?; let mut file = File::from(fd); file.write_all(bytes)?; file.sync_all()?; rfs::renameat(&dir, temporary.as_str(), &dir, name)?; File::from(dir).sync_all()?; Ok(()) } fn create_private_dir(path: &Path) -> io::Result<()> { let parent = path .parent() .ok_or_else(|| io::Error::from(io::ErrorKind::InvalidInput))?; let name = path .file_name() .ok_or_else(|| io::Error::from(io::ErrorKind::InvalidInput))?; let dir = open_dir(parent).map_err(|error| match error { PersistenceError::Io(error) => error, _ => io::Error::from(io::ErrorKind::InvalidInput), })?; rfs::mkdirat(&dir, name, Mode::from_bits_truncate(0o700)).map_err(io::Error::from) } fn read_regular(path: &Path, limit: usize) -> Result, PersistenceError> { read_regular_impl(path, limit, false) } fn read_private_regular(path: &Path, limit: usize) -> Result, PersistenceError> { read_regular_impl(path, limit, true) } fn read_regular_impl( path: &Path, limit: usize, require_private: bool, ) -> Result, PersistenceError> { check_path_components(path, false)?; let parent = path.parent().ok_or(PersistenceError::UnsafePath)?; let name = path.file_name().ok_or(PersistenceError::UnsafePath)?; let dir = open_dir(parent)?; let fd = rfs::openat( &dir, name, OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC, Mode::empty(), )?; let file = File::from(fd); let metadata = file.metadata()?; if !metadata.is_file() { return Err(PersistenceError::UnsafePath); } if require_private && metadata.mode() & 0o077 != 0 { return Err(PersistenceError::InsecurePermissions); } if metadata.len() > limit as u64 { return Err(PersistenceError::TooLarge); } let mut bytes = Vec::with_capacity(metadata.len() as usize); file.take((limit + 1) as u64).read_to_end(&mut bytes)?; if bytes.len() > limit { return Err(PersistenceError::TooLarge); } Ok(bytes) } fn read_optional(path: &Path, limit: usize) -> Result>, PersistenceError> { match read_regular(path, limit) { Ok(bytes) => Ok(Some(bytes)), Err(PersistenceError::Io(error)) if error.kind() == io::ErrorKind::NotFound => Ok(None), Err(error) => Err(error), } } fn read_optional_private(path: &Path, limit: usize) -> Result>, PersistenceError> { match read_private_regular(path, limit) { Ok(bytes) => Ok(Some(bytes)), Err(PersistenceError::Io(error)) if error.kind() == io::ErrorKind::NotFound => Ok(None), Err(error) => Err(error), } } fn replace_file(target: &Path, bytes: Option<&[u8]>) -> Result<(), PersistenceError> { let mut checkpoint = |_| Ok(()); replace_file_with_hook(target, bytes, 0, &mut checkpoint) } fn replace_file_with_hook( target: &Path, bytes: Option<&[u8]>, index: usize, checkpoint: &mut impl FnMut(InstallCheckpoint) -> io::Result<()>, ) -> Result<(), PersistenceError> { let parent = target.parent().ok_or(PersistenceError::UnsafePath)?; check_path_components(parent, true)?; check_path_components(target, true)?; let dir = open_dir(parent)?; let name = target.file_name().ok_or(PersistenceError::UnsafePath)?; if let Some(bytes) = bytes { let temp_name = format!( ".wg-admin-stage-{}-{}", std::process::id(), getrandom::u32().map_err(|_| PersistenceError::InvalidState)? ); let fd = rfs::openat( &dir, temp_name.as_str(), OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::NOFOLLOW | OFlags::CLOEXEC, Mode::from_bits_truncate(0o600), )?; let mut temp = File::from(fd); checkpoint(InstallCheckpoint::StageWrite(index))?; temp.write_all(bytes)?; checkpoint(InstallCheckpoint::StageSync(index))?; temp.sync_all()?; rfs::renameat(&dir, temp_name.as_str(), &dir, name)?; } else { match rfs::unlinkat(&dir, name, AtFlags::empty()) { Ok(()) => {} Err(error) if error.kind() == io::ErrorKind::NotFound => {} Err(error) => return Err(error.into()), } } File::from(dir).sync_all()?; Ok(()) } fn sync_target_parents_with_hook( targets: &[PathBuf; 4], checkpoint: &mut impl FnMut(InstallCheckpoint) -> io::Result<()>, ) -> Result<(), PersistenceError> { for (index, target) in targets.iter().enumerate() { checkpoint(InstallCheckpoint::ParentSync(index))?; let parent = target.parent().ok_or(PersistenceError::UnsafePath)?; File::from(open_dir(parent)?).sync_all()?; } Ok(()) } fn open_dir(path: &Path) -> Result { if !path.is_absolute() { return Err(PersistenceError::UnsafePath); } let mut dir = rfs::open( "/", OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC, Mode::empty(), )?; for component in path.components().skip(1) { let Component::Normal(name) = component else { return Err(PersistenceError::UnsafePath); }; dir = rfs::openat( &dir, name, OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC, Mode::empty(), )?; } Ok(dir) } fn check_path_components(path: &Path, allow_missing_final: bool) -> Result<(), PersistenceError> { if !path.is_absolute() { return Err(PersistenceError::UnsafePath); } let mut cursor = PathBuf::from("/"); let components = path .components() .filter(|c| !matches!(c, Component::RootDir)); let all = components.collect::>(); for (index, component) in all.iter().enumerate() { let Component::Normal(name) = component else { return Err(PersistenceError::UnsafePath); }; cursor.push(name); match fs::symlink_metadata(&cursor) { Ok(meta) if meta.file_type().is_symlink() => return Err(PersistenceError::UnsafePath), Ok(meta) if index + 1 < all.len() && !meta.is_dir() => { return Err(PersistenceError::UnsafePath); } Ok(_) => {} Err(error) if error.kind() == io::ErrorKind::NotFound && (allow_missing_final || index + 1 < all.len()) => {} Err(error) => return Err(error.into()), } } Ok(()) } fn create_path_dirs(path: &Path) -> Result<(), PersistenceError> { if !path.is_absolute() { return Err(PersistenceError::UnsafePath); } let mut directory = rfs::open( "/", OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC, Mode::empty(), )?; for component in path.components().skip(1) { let Component::Normal(name) = component else { return Err(PersistenceError::UnsafePath); }; match rfs::mkdirat(&directory, name, Mode::from_bits_truncate(0o700)) { Ok(()) => {} Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {} Err(error) => return Err(error.into()), } directory = rfs::openat( &directory, name, OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC, Mode::empty(), )?; } Ok(()) } fn lexical_normalize(path: &Path) -> PathBuf { let mut result = PathBuf::new(); for component in path.components() { match component { Component::CurDir => {} Component::ParentDir => { result.pop(); } other => result.push(other.as_os_str()), } } result } impl fmt::Debug for SavedStore { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("SavedStore") .field("settings_path", &self.settings_path()) .field("server_config_path", &self.settings.server_config_path) .field("data_directory", &self.settings.data_directory) .finish() } } #[cfg(test)] mod tests { use super::*; use tempfile::TempDir; const SETTINGS: &str = r#"interface_name = "wg0" server_config_path = "wg0.conf" client_source_directory = "clients" 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 "#; fn fixture() -> (TempDir, SavedStore, SavedBytes) { let temp = tempfile::tempdir().expect("temporary fixture directory"); let app = temp.path().join("app"); fs::create_dir(&app).expect("app directory"); let server_path = app.join("wg0.conf"); let settings = SettingsFile::parse_toml(SETTINGS) .unwrap() .validate() .unwrap() .resolve_paths(&app); fs::write(app.join(crate::config::SETTINGS_FILE_NAME), SETTINGS).unwrap(); fs::write( &server_path, b"[Interface]\nPrivateKey = fixture\nAddress = 10.8.0.1/24\n", ) .unwrap(); fs::set_permissions(&server_path, fs::Permissions::from_mode(0o600)).unwrap(); let store = SavedStore::new(&app, settings).unwrap(); let saved = SavedBytes { settings: SETTINGS.as_bytes().to_vec(), server_config: b"[Interface]\nPrivateKey = next\nAddress = 10.8.0.1/24\n".to_vec(), internal_state: None, vault: None, }; (temp, store, saved) } #[test] fn state_codec_is_canonical_and_rejects_bad_or_unsupported_state() { let state = PersistedState::new(["fd00::8".to_owned(), "10.0.0.8".to_owned()], None).unwrap(); assert_eq!(state.reserved_addresses, ["10.0.0.8", "fd00::8"]); let decoded = PersistedState::from_toml(&state.to_toml().unwrap()).unwrap(); assert_eq!(decoded, state); assert!(PersistedState::from_toml(b"version = 2\nreserved_addresses = []\n").is_err()); assert!( PersistedState::from_toml( b"version = 1\nreserved_addresses = [\"10.0.0.1\", \"10.0.0.1\"]\n" ) .is_err() ); assert!( PersistedState::from_toml(b"version = 1\nreserved_addresses = []\nextra = true\n") .is_err() ); } #[test] fn persisted_history_never_marks_the_runtime_verified() { let client = crate::domain::Client::new( crate::domain::PublicKey::from_bytes([7; 32]), "alias", crate::domain::ClientAddresses { ipv4: "10.0.0.2".parse().unwrap(), ipv6: None, }, ) .unwrap(); let saved = crate::domain::Snapshot::new([client]).unwrap(); let mut states = crate::domain::ClientStates::new( saved, None, crate::domain::RuntimeObservation::Unknown, ) .unwrap(); let state = PersistedState::new( ["10.0.0.8".to_owned()], Some("2026-10-19T12:30:00Z".to_owned()), ) .unwrap(); state.apply_to(&mut states).unwrap(); assert_eq!( states.applied(), &crate::domain::RuntimeObservation::Unknown ); assert!( states .reserved_addresses() .contains(&"10.0.0.8".parse().unwrap()) ); } #[test] fn read_returns_exact_saved_bytes_and_optional_absence() { let (_temp, store, saved) = fixture(); let actual = store.read_saved().unwrap(); assert_eq!(actual.settings, saved.settings); assert_eq!( actual.server_config, b"[Interface]\nPrivateKey = fixture\nAddress = 10.8.0.1/24\n" ); assert_eq!(actual.internal_state, None); assert_eq!(actual.vault, None); assert!(!format!("{actual:?}").contains("PrivateKey = fixture")); } #[test] fn existing_secret_bearing_saved_files_with_broad_modes_fail_closed() { for (file, mode) in [("wg0.conf", 0o640), ("vault.toml", 0o644)] { let (_temp, store, _saved) = fixture(); if file == "vault.toml" { let vault = VaultBundle::create("fixture password") .unwrap() .to_toml() .unwrap(); let path = store.vault_path(); fs::create_dir_all(path.parent().unwrap()).unwrap(); fs::write(&path, vault).unwrap(); fs::set_permissions(&path, fs::Permissions::from_mode(mode)).unwrap(); } else { fs::set_permissions( &store.settings.server_config_path, fs::Permissions::from_mode(mode), ) .unwrap(); } assert!(matches!( store.read_saved(), Err(PersistenceError::InsecurePermissions) )); } } #[test] fn symlink_destinations_are_rejected_without_reading_the_target() { let (temp, store, _saved) = fixture(); let external = temp.path().join("outside"); fs::write(&external, b"[Interface]\n").unwrap(); let server = store.settings.server_config_path.clone(); fs::remove_file(&server).unwrap(); std::os::unix::fs::symlink(&external, &server).unwrap(); assert!(matches!( store.read_saved(), Err(PersistenceError::UnsafePath) )); } #[test] fn destination_aliases_and_hard_links_are_rejected() { let (temp, store, _saved) = fixture(); let app = temp.path().join("app"); fs::remove_file(&store.settings.server_config_path).unwrap(); fs::hard_link(store.settings_path(), &store.settings.server_config_path).unwrap(); assert!(matches!( store.read_saved(), Err(PersistenceError::UnsafePath) )); let aliased_settings = SETTINGS.replace( "server_config_path = \"wg0.conf\"", "server_config_path = \"wireguard-admin.toml\"", ); let resolved = SettingsFile::parse_toml(&aliased_settings) .unwrap() .validate() .unwrap() .resolve_paths(&app); assert!(matches!( SavedStore::new(&app, resolved), Err(PersistenceError::UnsafePath) )); } #[test] fn reopening_after_each_partial_replacement_rolls_back_before_reading() { for fail_after in 1..=4 { let (_temp, store, proposed) = fixture(); let mut replacement_count = 0; let result = store.install_with_hook(&proposed, |checkpoint| { if let InstallCheckpoint::Replaced(count) = checkpoint { replacement_count = count; if replacement_count == fail_after { return Err(io::Error::other("injected interruption")); } } Ok(()) }); assert!(matches!(result, Err(PersistenceError::RecoveryRequired))); assert!(matches!(store.recover(), Ok(()))); let restored = store.read_saved().unwrap(); assert_eq!(restored.settings, SETTINGS.as_bytes()); assert!( restored .server_config .windows(b"PrivateKey = fixture".len()) .any(|v| v == b"PrivateKey = fixture") ); assert_eq!(restored.internal_state, None); assert_eq!(restored.vault, None); } } #[test] fn staging_failure_leaves_the_old_set_and_cleans_unprepared_evidence() { let (_temp, store, proposed) = fixture(); let result = store.install_with_hook(&proposed, |checkpoint| { if checkpoint == InstallCheckpoint::Staged { Err(io::Error::other("injected staging interruption")) } else { Ok(()) } }); assert!(matches!(result, Err(PersistenceError::Io(_)))); assert!(!store.settings.data_directory.join(JOURNAL).exists()); assert!( store .read_saved() .unwrap() .server_config .windows(b"PrivateKey = fixture".len()) .any(|v| v == b"PrivateKey = fixture") ); } #[test] fn injected_journal_payload_write_sync_and_marker_failures_leave_old_bytes() { let points = [ InstallCheckpoint::JournalPayloadWrite(0), InstallCheckpoint::JournalPayloadSync(0), InstallCheckpoint::JournalPayloadWrite(1), InstallCheckpoint::JournalPayloadSync(1), InstallCheckpoint::JournalPayloadWrite(4), InstallCheckpoint::JournalPayloadSync(4), InstallCheckpoint::JournalPayloadWrite(5), InstallCheckpoint::JournalPayloadSync(5), InstallCheckpoint::PreparedMarker, InstallCheckpoint::PreparedMarkerSync, ]; for point in points { let (_temp, store, proposed) = fixture(); let old = store.read_saved().unwrap(); assert!( store .install_with_hook(&proposed, |current| { if current == point { Err(io::Error::other("fault injection")) } else { Ok(()) } }) .is_err() ); assert!(!store.settings.data_directory.join(JOURNAL).exists()); assert_eq!(store.read_saved().unwrap().server_config, old.server_config); assert_eq!(store.read_saved().unwrap().settings, old.settings); } } #[test] fn each_destination_write_and_sync_failure_recovers_exact_old_set() { for index in 0..4 { for point in [ InstallCheckpoint::StageWrite(index), InstallCheckpoint::StageSync(index), InstallCheckpoint::ParentSync(index), ] { let (_temp, store, mut proposed) = fixture(); proposed.internal_state = Some( PersistedState::new(Vec::::new(), None) .unwrap() .to_toml() .unwrap(), ); proposed.vault = Some( VaultBundle::create("fault-test password") .unwrap() .to_toml() .unwrap() .into_bytes(), ); let old = store.read_saved().unwrap(); assert!(matches!( store.install_with_hook(&proposed, |current| { if current == point { Err(io::Error::other("fault injection")) } else { Ok(()) } }), Err(PersistenceError::RecoveryRequired) )); assert_eq!(store.read_saved().unwrap().settings, old.settings); assert_eq!(store.read_saved().unwrap().server_config, old.server_config); assert_eq!( store.read_saved().unwrap().internal_state, old.internal_state ); assert_eq!(store.read_saved().unwrap().vault, old.vault); } } } #[test] fn rollback_and_cleanup_faults_keep_recovery_required_until_reopen_retry() { let (_temp, store, proposed) = fixture(); let old = store.read_saved().unwrap(); assert!(matches!( store.install_with_hook(&proposed, |point| { if point == InstallCheckpoint::Replaced(1) { Err(io::Error::other("interrupt after replacement")) } else { Ok(()) } }), Err(PersistenceError::RecoveryRequired) )); assert!(matches!( store.recover_with_hook(|point| { if point == InstallCheckpoint::RollbackReplace(3) { Err(io::Error::other("rollback fault")) } else { Ok(()) } }), Err(PersistenceError::RecoveryRequired) )); assert_eq!(store.read_saved().unwrap().settings, old.settings); assert_eq!(store.read_saved().unwrap().server_config, old.server_config); let replacement = SavedBytes { settings: old.settings.clone(), server_config: b"[Interface]\nPrivateKey = replacement\n".to_vec(), internal_state: old.internal_state.clone(), vault: old.vault.clone(), }; assert!(matches!( store.install_with_hook(&replacement, |point| { if point == InstallCheckpoint::Replaced(1) { Err(io::Error::other("interrupt after replacement")) } else { Ok(()) } }), Err(PersistenceError::RecoveryRequired) )); assert!(matches!( store.recover_with_hook(|point| { if point == InstallCheckpoint::JournalCleanup { Err(io::Error::other("cleanup fault")) } else { Ok(()) } }), Err(PersistenceError::RecoveryRequired) )); assert_eq!(store.read_saved().unwrap().server_config, old.server_config); } #[test] fn committed_marker_and_cleanup_failures_are_not_false_success() { for point in [ InstallCheckpoint::CommittedMarker, InstallCheckpoint::CommittedMarkerSync, InstallCheckpoint::JournalCleanup, ] { let (_temp, store, proposed) = fixture(); let old = store.read_saved().unwrap(); assert!(matches!( store.install_with_hook(&proposed, |current| { if current == point { Err(io::Error::other("fault injection")) } else { Ok(()) } }), Err(PersistenceError::RecoveryRequired) )); let reopened = store.read_saved().unwrap(); if point == InstallCheckpoint::CommittedMarker { assert_eq!(reopened.server_config, old.server_config); } else { assert_eq!(reopened.server_config, proposed.server_config); } } } #[test] fn reopening_after_durable_commit_marker_only_cleans_journal() { let (_temp, store, proposed) = fixture(); let result = store.install_with_hook(&proposed, |checkpoint| { if checkpoint == InstallCheckpoint::CommitMarked { Err(io::Error::other("injected cleanup interruption")) } else { Ok(()) } }); assert!(matches!(result, Err(PersistenceError::RecoveryRequired))); store.recover().unwrap(); let installed = store.read_saved().unwrap(); assert_eq!(installed.server_config, proposed.server_config); assert!(!store.settings.data_directory.join(JOURNAL).exists()); } #[test] fn complete_install_publishes_components_with_private_modes() { let (_temp, store, mut proposed) = fixture(); proposed.internal_state = Some( PersistedState::new(["10.8.0.9".to_owned()], None) .unwrap() .to_toml() .unwrap(), ); store.install(&proposed, BackupComplete(())).unwrap(); assert_eq!( store.read_saved().unwrap().server_config, proposed.server_config ); for path in store.targets() { if path.exists() { use std::os::unix::fs::PermissionsExt; assert_eq!( fs::metadata(path).unwrap().permissions().mode() & 0o777, 0o600 ); } } assert_eq!( fs::metadata(&store.settings.data_directory) .unwrap() .permissions() .mode() & 0o777, 0o700 ); assert!(!store.settings.data_directory.join(JOURNAL).exists()); } #[test] fn invalid_recovery_evidence_blocks_saved_reads() { let (_temp, store, _saved) = fixture(); fs::create_dir_all(store.settings.data_directory.join(JOURNAL)).unwrap(); fs::write( store .settings .data_directory .join(JOURNAL) .join(JOURNAL_META), b"invalid\n", ) .unwrap(); fs::write( store.settings.data_directory.join(JOURNAL).join("prepared"), b"prepared\n", ) .unwrap(); assert!(matches!( store.read_saved(), Err(PersistenceError::RecoveryRequired) )); } }