Files
wireguard-admin/src/persistence.rs
T

1348 lines
49 KiB
Rust

//! 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<String>,
pub last_verified_at_utc: Option<String>,
}
impl PersistedState {
pub fn new(
reserved_addresses: impl IntoIterator<Item = String>,
last_verified_at_utc: Option<String>,
) -> Result<Self, PersistenceError> {
let mut addresses = reserved_addresses.into_iter().collect::<Vec<_>>();
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<Vec<u8>, PersistenceError> {
validate_state(self)?;
toml::to_string(self)
.map(String::into_bytes)
.map_err(|_| PersistenceError::InvalidState)
}
pub fn from_toml(bytes: &[u8]) -> Result<Self, PersistenceError> {
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::<std::net::IpAddr>()
.map_err(|_| PersistenceError::InvalidState)
})
.collect::<Result<Vec<_>, _>>()?;
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::<std::net::IpAddr>()
.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<io::Error> for PersistenceError {
fn from(error: io::Error) -> Self {
Self::Io(error)
}
}
impl From<rustix::io::Errno> 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<PathBuf>,
settings: ResolvedSettings,
) -> Result<Self, PersistenceError> {
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<SavedBytes, PersistenceError> {
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<ResolvedSettings, PersistenceError> {
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<SavedBytes, PersistenceError> {
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<crate::delivery::files::OutputProtection, crate::delivery::files::FileOutputError>
{
let mut protected_files = self.targets().into_iter().collect::<Vec<_>>();
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<Vec<u8>, PersistenceError> {
read_regular_impl(path, limit, false)
}
fn read_private_regular(path: &Path, limit: usize) -> Result<Vec<u8>, PersistenceError> {
read_regular_impl(path, limit, true)
}
fn read_regular_impl(
path: &Path,
limit: usize,
require_private: bool,
) -> Result<Vec<u8>, 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<Option<Vec<u8>>, 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<Option<Vec<u8>>, 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<OwnedFd, PersistenceError> {
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::<Vec<_>>();
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::<String>::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)
));
}
}