1348 lines
49 KiB
Rust
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)
|
|
));
|
|
}
|
|
}
|