Implement backups, commit and restoration

This commit is contained in:
2026-10-20 00:13:39 +03:00
parent c879a8101b
commit 5418841b92
17 changed files with 5049 additions and 38 deletions
+1482
View File
File diff suppressed because it is too large Load Diff
+973
View File
@@ -0,0 +1,973 @@
//! Bounded, versioned backups of installed saved bytes.
//!
//! This module never extracts archive paths and never performs runtime VPN operations.
use std::fmt;
use std::fs::{self, File};
use std::io::{self, Read, Write};
use std::os::unix::fs::OpenOptionsExt;
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;
const VERSION: u32 = 1;
const BLOCK: usize = 512;
const MAX_ARCHIVE: usize = 26 * 1024 * 1024;
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 MAX_LISTING_ENTRIES: usize = 4096;
const MANIFEST: &str = "manifest.toml";
const SETTINGS: &str = "settings.toml";
const SERVER: &str = "server.conf";
const STATE: &str = "internal-state.toml";
const VAULT: &str = "vault.toml";
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum ArchiveCheckpoint {
Write,
Sync,
Publish,
TemporaryCleanup,
DirectorySync,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum BackupReason {
Manual,
PreCommit,
PreRestore,
PreRestart,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(tag = "status", rename_all = "kebab-case", deny_unknown_fields)]
pub enum HistoricalApplyStatus {
Unknown,
Verified { verified_at_utc: String },
}
/// Exact installed bytes captured by a trusted caller. `None` means the owned
/// vault/state component was genuinely absent at capture time.
pub struct SavedBytes {
pub settings: Vec<u8>,
pub server_config: Vec<u8>,
pub internal_state: Option<Vec<u8>>,
pub vault: Option<Vec<u8>>,
}
impl fmt::Debug for SavedBytes {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("SavedBytes")
.field("settings_len", &self.settings.len())
.field("server_config_len", &self.server_config.len())
.field("internal_state_present", &self.internal_state.is_some())
.field("vault_present", &self.vault.is_some())
.field("contents", &"[REDACTED]")
.finish()
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
struct Manifest {
version: u32,
created_at_utc: String,
reason: BackupReason,
historical_apply: HistoricalApplyStatus,
members: Vec<MemberRecord>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
struct MemberRecord {
name: String,
present: bool,
length: Option<u64>,
}
/// Validated read-only archive contents. Byte payloads are deliberately omitted by Debug.
pub struct ArchivePreview {
pub created_at_utc: String,
pub reason: BackupReason,
pub historical_apply: HistoricalApplyStatus,
pub saved_bytes: SavedBytes,
}
impl fmt::Debug for ArchivePreview {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ArchivePreview")
.field("created_at_utc", &self.created_at_utc)
.field("reason", &self.reason)
.field("historical_apply", &self.historical_apply)
.field("saved_bytes", &self.saved_bytes)
.finish()
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ArchiveSummary {
pub file_name: String,
pub created_at_utc: String,
pub reason: BackupReason,
pub historical_apply: HistoricalApplyStatus,
}
#[derive(Debug, Error)]
pub enum BackupError {
#[error("backup input exceeds a size limit")]
TooLarge,
#[error("backup archive is invalid or incomplete")]
InvalidArchive,
#[error("backup manifest version is not supported")]
UnsupportedVersion,
#[error("backup timestamp is invalid")]
InvalidTimestamp,
#[error("backup filesystem operation failed")]
Io(#[source] io::Error),
#[error("backup archive serialization failed")]
Serialization,
}
impl From<io::Error> for BackupError {
fn from(error: io::Error) -> Self {
Self::Io(error)
}
}
impl From<rustix::io::Errno> for BackupError {
fn from(error: rustix::io::Errno) -> Self {
Self::Io(error.into())
}
}
/// Write a complete archive to a private temporary file and publish it without replacement.
pub(crate) fn create_archive(
archive_directory: &Path,
reason: BackupReason,
historical_apply: HistoricalApplyStatus,
saved: &SavedBytes,
) -> Result<PathBuf, BackupError> {
create_archive_with_hook(archive_directory, reason, historical_apply, saved, |_| {
Ok(())
})
}
pub(crate) fn create_archive_with_hook(
archive_directory: &Path,
reason: BackupReason,
historical_apply: HistoricalApplyStatus,
saved: &SavedBytes,
mut checkpoint: impl FnMut(ArchiveCheckpoint) -> io::Result<()>,
) -> Result<PathBuf, BackupError> {
validate_saved(saved)?;
validate_status(&historical_apply)?;
let created_at_utc = OffsetDateTime::now_utc()
.replace_nanosecond(0)
.map_err(|_| BackupError::InvalidTimestamp)?
.format(&Rfc3339)
.map_err(|_| BackupError::InvalidTimestamp)?;
let bytes = encode_archive(&created_at_utc, reason, historical_apply, saved)?;
let directory = ensure_archive_directory(archive_directory)?;
let stamp = created_at_utc.replace([':', '-'], "");
for suffix in 0_u32..1000 {
let name = if suffix == 0 {
format!("wireguard-admin-{stamp}.tar")
} else {
format!("wireguard-admin-{stamp}-{suffix}.tar")
};
let target = archive_directory.join(name);
let temporary = format!(
".wireguard-admin-backup-{}-{}",
std::process::id(),
getrandom::u32().map_err(|_| BackupError::Serialization)?
);
let fd = rfs::openat(
&directory,
temporary.as_str(),
OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::NOFOLLOW | OFlags::CLOEXEC,
Mode::from_bits_truncate(0o600),
)?;
let mut file = File::from(fd);
let write_result = (|| -> Result<(), BackupError> {
checkpoint(ArchiveCheckpoint::Write)?;
file.write_all(&bytes)?;
checkpoint(ArchiveCheckpoint::Sync)?;
file.sync_all()?;
Ok(())
})();
if let Err(error) = write_result {
let _ = rfs::unlinkat(&directory, temporary.as_str(), AtFlags::empty());
return Err(error);
}
let target_name = target.file_name().ok_or(BackupError::InvalidArchive)?;
if let Err(error) = checkpoint(ArchiveCheckpoint::Publish) {
let _ = rfs::unlinkat(&directory, temporary.as_str(), AtFlags::empty());
return Err(BackupError::Io(error));
}
match rfs::linkat(
&directory,
temporary.as_str(),
&directory,
target_name,
AtFlags::empty(),
) {
Ok(()) => {
checkpoint(ArchiveCheckpoint::TemporaryCleanup)?;
rfs::unlinkat(&directory, temporary.as_str(), AtFlags::empty())?;
checkpoint(ArchiveCheckpoint::DirectorySync)?;
File::from(directory.try_clone()?).sync_all()?;
return Ok(target);
}
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
rfs::unlinkat(&directory, temporary.as_str(), AtFlags::empty())?;
continue;
}
Err(error) => {
let _ = rfs::unlinkat(&directory, temporary.as_str(), AtFlags::empty());
return Err(BackupError::Io(error.into()));
}
}
}
Err(BackupError::Io(io::Error::new(
io::ErrorKind::AlreadyExists,
"archive name collisions",
)))
}
/// Parse and validate a complete bounded archive without extracting any member.
pub fn preview_archive_bytes(input: &[u8]) -> Result<ArchivePreview, BackupError> {
if input.len() > MAX_ARCHIVE || input.len() < BLOCK * 4 || !input.len().is_multiple_of(BLOCK) {
return Err(BackupError::InvalidArchive);
}
let mut offset = 0;
let mut records = Vec::new();
let mut previous_name: Option<String> = None;
loop {
let header = input
.get(offset..offset + BLOCK)
.ok_or(BackupError::InvalidArchive)?;
if header.iter().all(|byte| *byte == 0) {
let second = input
.get(offset + BLOCK..offset + BLOCK * 2)
.ok_or(BackupError::InvalidArchive)?;
if second.iter().any(|byte| *byte != 0)
|| input[offset + BLOCK * 2..].iter().any(|byte| *byte != 0)
{
return Err(BackupError::InvalidArchive);
}
break;
}
if records.len() >= 5 {
return Err(BackupError::InvalidArchive);
}
let (name, size) = parse_header(header)?;
if previous_name
.as_ref()
.is_some_and(|previous| previous == &name)
{
return Err(BackupError::InvalidArchive);
}
previous_name = Some(name.clone());
let data_start = offset + BLOCK;
let size_usize = usize::try_from(size).map_err(|_| BackupError::InvalidArchive)?;
let padded = size_usize
.checked_add(BLOCK - 1)
.ok_or(BackupError::InvalidArchive)?
/ BLOCK
* BLOCK;
let data_end = data_start
.checked_add(size_usize)
.ok_or(BackupError::InvalidArchive)?;
let next = data_start
.checked_add(padded)
.ok_or(BackupError::InvalidArchive)?;
let body = input
.get(data_start..data_end)
.ok_or(BackupError::InvalidArchive)?;
if input
.get(data_end..next)
.ok_or(BackupError::InvalidArchive)?
.iter()
.any(|byte| *byte != 0)
{
return Err(BackupError::InvalidArchive);
}
records.push((name, body.to_vec()));
offset = next;
}
let first = records.first().ok_or(BackupError::InvalidArchive)?;
if first.0 != MANIFEST {
return Err(BackupError::InvalidArchive);
}
let manifest: Manifest =
toml::from_str(std::str::from_utf8(&first.1).map_err(|_| BackupError::InvalidArchive)?)
.map_err(|_| BackupError::InvalidArchive)?;
if manifest.version != VERSION {
return Err(BackupError::UnsupportedVersion);
}
validate_timestamp(&manifest.created_at_utc)?;
validate_status(&manifest.historical_apply)?;
let expected_names = [SETTINGS, SERVER, STATE, VAULT];
if manifest.members.len() != expected_names.len() {
return Err(BackupError::InvalidArchive);
}
let mut payloads: Vec<Option<Vec<u8>>> = vec![None; 4];
for (idx, expected) in expected_names.iter().enumerate() {
let spec = &manifest.members[idx];
if spec.name != *expected || spec.present != spec.length.is_some() {
return Err(BackupError::InvalidArchive);
}
let max = limit_for(expected);
if spec.length.is_some_and(|len| len > max as u64) {
return Err(BackupError::TooLarge);
}
}
if records.len() != 1 + manifest.members.iter().filter(|m| m.present).count() {
return Err(BackupError::InvalidArchive);
}
let mut last_payload_index = None;
for (name, body) in records.iter().skip(1) {
let idx = expected_names
.iter()
.position(|expected| *expected == name)
.ok_or(BackupError::InvalidArchive)?;
if last_payload_index.is_some_and(|last| idx <= last) {
return Err(BackupError::InvalidArchive);
}
last_payload_index = Some(idx);
let spec = &manifest.members[idx];
if !spec.present || spec.length != Some(body.len() as u64) || body.len() > limit_for(name) {
return Err(BackupError::InvalidArchive);
}
if payloads[idx].replace(body.clone()).is_some() {
return Err(BackupError::InvalidArchive);
}
}
for (idx, spec) in manifest.members.iter().enumerate() {
if spec.present != payloads[idx].is_some() {
return Err(BackupError::InvalidArchive);
}
}
Ok(ArchivePreview {
created_at_utc: manifest.created_at_utc,
reason: manifest.reason,
historical_apply: manifest.historical_apply,
saved_bytes: SavedBytes {
settings: payloads[0].take().ok_or(BackupError::InvalidArchive)?,
server_config: payloads[1].take().ok_or(BackupError::InvalidArchive)?,
internal_state: payloads[2].take(),
vault: payloads[3].take(),
},
})
}
pub fn preview_archive(path: &Path) -> Result<ArchivePreview, BackupError> {
validate_path_components(path)?;
let metadata = fs::symlink_metadata(path)?;
if !metadata.file_type().is_file() || metadata.len() > MAX_ARCHIVE as u64 {
return Err(BackupError::InvalidArchive);
}
let file = fs::OpenOptions::new()
.read(true)
.custom_flags(0o400000)
.open(path)?;
if !file.metadata()?.file_type().is_file() {
return Err(BackupError::InvalidArchive);
}
let mut bytes = Vec::with_capacity(metadata.len() as usize);
file.take((MAX_ARCHIVE + 1) as u64)
.read_to_end(&mut bytes)?;
preview_archive_bytes(&bytes)
}
/// List only validated archives. Invalid files are omitted rather than trusted for display.
pub fn list_archives(directory: &Path) -> Result<Vec<ArchiveSummary>, BackupError> {
validate_path_components(directory)?;
if !fs::symlink_metadata(directory)?.is_dir() {
return Err(BackupError::InvalidArchive);
}
let mut summaries = Vec::new();
let mut inspected = 0;
for entry in fs::read_dir(directory)? {
inspected += 1;
if inspected > MAX_LISTING_ENTRIES {
return Err(BackupError::TooLarge);
}
let entry = entry?;
let file_type = entry.file_type()?;
let name = entry.file_name();
let Some(name_text) = name.to_str() else {
continue;
};
if !file_type.is_file()
|| !name_text.starts_with("wireguard-admin-")
|| !name_text.ends_with(".tar")
|| !name_text
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.'))
{
continue;
}
if let Ok(preview) = preview_archive(&entry.path()) {
summaries.push(ArchiveSummary {
file_name: name_text.to_owned(),
created_at_utc: preview.created_at_utc,
reason: preview.reason,
historical_apply: preview.historical_apply,
});
}
}
summaries.sort_by(|a, b| {
b.created_at_utc
.cmp(&a.created_at_utc)
.then_with(|| a.file_name.cmp(&b.file_name))
});
Ok(summaries)
}
fn ensure_archive_directory(path: &Path) -> Result<std::os::fd::OwnedFd, BackupError> {
if !path.is_absolute() {
return Err(BackupError::InvalidArchive);
}
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(BackupError::InvalidArchive);
};
match rfs::mkdirat(&directory, name, Mode::from_bits_truncate(0o700)) {
Ok(()) => {}
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {}
Err(error) => return Err(BackupError::Io(error.into())),
}
directory = rfs::openat(
&directory,
name,
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
Mode::empty(),
)?;
}
rfs::fchmod(&directory, Mode::from_bits_truncate(0o700))?;
File::from(directory.try_clone()?).sync_all()?;
Ok(directory)
}
fn validate_path_components(path: &Path) -> Result<(), BackupError> {
if !path.is_absolute() {
return Err(BackupError::InvalidArchive);
}
let mut directory = rfs::open(
"/",
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC,
Mode::empty(),
)?;
let components = path.components().skip(1).collect::<Vec<_>>();
for (index, component) in components.iter().enumerate() {
let Component::Normal(name) = component else {
return Err(BackupError::InvalidArchive);
};
if index + 1 == components.len() {
// Final item may be a regular archive file, so reject symlinks via
// no-follow open at the call site instead of requiring a directory.
match rfs::openat(
&directory,
*name,
OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
Mode::empty(),
) {
Ok(fd) => {
let metadata = File::from(fd).metadata()?;
if !metadata.is_file() && !metadata.is_dir() {
return Err(BackupError::InvalidArchive);
}
}
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => return Err(BackupError::Io(error.into())),
}
} else {
directory = rfs::openat(
&directory,
*name,
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
Mode::empty(),
)?;
}
}
Ok(())
}
fn validate_saved(saved: &SavedBytes) -> Result<(), BackupError> {
for (bytes, max) in [
(&saved.settings, MAX_SETTINGS),
(&saved.server_config, MAX_SERVER),
] {
if bytes.len() > max {
return Err(BackupError::TooLarge);
}
}
if saved
.internal_state
.as_ref()
.is_some_and(|bytes| bytes.len() > MAX_STATE)
|| saved
.vault
.as_ref()
.is_some_and(|bytes| bytes.len() > MAX_VAULT)
{
return Err(BackupError::TooLarge);
}
Ok(())
}
fn validate_status(status: &HistoricalApplyStatus) -> Result<(), BackupError> {
if let HistoricalApplyStatus::Verified { verified_at_utc } = status {
validate_timestamp(verified_at_utc)?;
}
Ok(())
}
fn validate_timestamp(value: &str) -> Result<(), BackupError> {
let parsed =
OffsetDateTime::parse(value, &Rfc3339).map_err(|_| BackupError::InvalidTimestamp)?;
if parsed.offset().whole_seconds() != 0
|| parsed.nanosecond() != 0
|| parsed
.format(&Rfc3339)
.map_err(|_| BackupError::InvalidTimestamp)?
!= value
{
return Err(BackupError::InvalidTimestamp);
}
Ok(())
}
fn encode_archive(
created_at: &str,
reason: BackupReason,
status: HistoricalApplyStatus,
saved: &SavedBytes,
) -> Result<Vec<u8>, BackupError> {
encode_archive_version(VERSION, created_at, reason, status, saved)
}
fn encode_archive_version(
version: u32,
created_at: &str,
reason: BackupReason,
status: HistoricalApplyStatus,
saved: &SavedBytes,
) -> Result<Vec<u8>, BackupError> {
let members = vec![
MemberRecord {
name: SETTINGS.into(),
present: true,
length: Some(saved.settings.len() as u64),
},
MemberRecord {
name: SERVER.into(),
present: true,
length: Some(saved.server_config.len() as u64),
},
MemberRecord {
name: STATE.into(),
present: saved.internal_state.is_some(),
length: saved.internal_state.as_ref().map(|v| v.len() as u64),
},
MemberRecord {
name: VAULT.into(),
present: saved.vault.is_some(),
length: saved.vault.as_ref().map(|v| v.len() as u64),
},
];
let manifest = toml::to_string(&Manifest {
version,
created_at_utc: created_at.into(),
reason,
historical_apply: status,
members,
})
.map_err(|_| BackupError::Serialization)?;
let mut builder = tar::Builder::new(Vec::new());
builder.mode(tar::HeaderMode::Deterministic);
append(&mut builder, MANIFEST, manifest.as_bytes())?;
append(&mut builder, SETTINGS, &saved.settings)?;
append(&mut builder, SERVER, &saved.server_config)?;
if let Some(state) = &saved.internal_state {
append(&mut builder, STATE, state)?;
}
if let Some(vault) = &saved.vault {
append(&mut builder, VAULT, vault)?;
}
builder.finish().map_err(|_| BackupError::Serialization)?;
let bytes = builder
.into_inner()
.map_err(|_| BackupError::Serialization)?;
if bytes.len() > MAX_ARCHIVE {
return Err(BackupError::TooLarge);
}
Ok(bytes)
}
fn append(
builder: &mut tar::Builder<Vec<u8>>,
name: &str,
bytes: &[u8],
) -> Result<(), BackupError> {
let mut header = tar::Header::new_ustar();
header.set_entry_type(tar::EntryType::Regular);
header
.set_path(name)
.map_err(|_| BackupError::Serialization)?;
header.set_size(bytes.len() as u64);
header.set_mode(0o600);
header.set_uid(0);
header.set_gid(0);
header.set_mtime(0);
header.set_cksum();
builder
.append(&header, bytes)
.map_err(|_| BackupError::Serialization)
}
fn parse_header(header: &[u8]) -> Result<(String, u64), BackupError> {
if header.len() != BLOCK
|| &header[257..263] != b"ustar\0"
|| &header[263..265] != b"00"
|| header[156] != 0 && header[156] != b'0'
|| header[157..257].iter().any(|byte| *byte != 0)
|| header[265..345].iter().any(|byte| *byte != 0)
|| header[345..500].iter().any(|byte| *byte != 0)
|| header[500..512].iter().any(|byte| *byte != 0)
{
return Err(BackupError::InvalidArchive);
}
let stored_checksum = parse_octal(&header[148..156])?;
let checksum = header[..148]
.iter()
.chain(&header[156..])
.map(|byte| *byte as u64)
.sum::<u64>()
+ 8 * b' ' as u64;
if stored_checksum != checksum {
return Err(BackupError::InvalidArchive);
}
if parse_octal(&header[100..108])? != 0o600
|| parse_octal(&header[108..116])? != 0
|| parse_octal(&header[116..124])? != 0
|| parse_octal(&header[136..148])? != 0
{
return Err(BackupError::InvalidArchive);
}
let raw_name = &header[..100];
let end = raw_name
.iter()
.position(|byte| *byte == 0)
.unwrap_or(raw_name.len());
if raw_name[end..].iter().any(|byte| *byte != 0) {
return Err(BackupError::InvalidArchive);
}
let name = std::str::from_utf8(&raw_name[..end])
.map_err(|_| BackupError::InvalidArchive)?
.to_owned();
if ![MANIFEST, SETTINGS, SERVER, STATE, VAULT].contains(&name.as_str()) {
return Err(BackupError::InvalidArchive);
}
let size = parse_octal(&header[124..136])?;
if size > limit_for(&name) as u64 && name != MANIFEST {
return Err(BackupError::TooLarge);
}
if name == MANIFEST && size > 64 * 1024 {
return Err(BackupError::TooLarge);
}
Ok((name, size))
}
fn parse_octal(field: &[u8]) -> Result<u64, BackupError> {
if field.is_empty() || field[0] & 0x80 != 0 {
return Err(BackupError::InvalidArchive);
}
let text = std::str::from_utf8(field).map_err(|_| BackupError::InvalidArchive)?;
let text = text.trim_matches(['\0', ' ']);
if text.is_empty() || !text.bytes().all(|b| (b'0'..=b'7').contains(&b)) {
return Err(BackupError::InvalidArchive);
}
u64::from_str_radix(text, 8).map_err(|_| BackupError::InvalidArchive)
}
fn limit_for(name: &str) -> usize {
match name {
SETTINGS => MAX_SETTINGS,
SERVER => MAX_SERVER,
STATE => MAX_STATE,
VAULT => MAX_VAULT,
_ => 64 * 1024,
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::os::unix::fs::symlink;
fn fixture() -> SavedBytes {
SavedBytes {
settings: b"settings fixture".to_vec(),
server_config: b"[Interface]\nPrivateKey = synthetic\n".to_vec(),
internal_state: None,
vault: Some(b"encrypted fixture".to_vec()),
}
}
#[test]
fn archive_round_trip_preserves_bytes_and_absence() {
let saved = fixture();
let bytes = encode_archive(
"2026-10-19T12:30:00Z",
BackupReason::PreCommit,
HistoricalApplyStatus::Unknown,
&saved,
)
.expect("archive fixture");
let preview = preview_archive_bytes(&bytes).expect("preview");
assert_eq!(preview.saved_bytes.settings, saved.settings);
assert_eq!(preview.saved_bytes.server_config, saved.server_config);
assert_eq!(preview.saved_bytes.internal_state, None);
assert_eq!(preview.saved_bytes.vault, saved.vault);
assert_eq!(preview.reason, BackupReason::PreCommit);
}
#[test]
fn archive_publication_is_private_and_listing_validates() {
let dir = tempfile::tempdir().expect("temporary directory");
let path = create_archive(
dir.path(),
BackupReason::Manual,
HistoricalApplyStatus::Unknown,
&fixture(),
)
.expect("create");
use std::os::unix::fs::PermissionsExt;
assert_eq!(
fs::metadata(&path).expect("metadata").permissions().mode() & 0o777,
0o600
);
assert_eq!(list_archives(dir.path()).expect("list").len(), 1);
}
#[test]
fn injected_archive_write_and_finalization_failures_never_report_success() {
let saved = fixture();
for point in [
ArchiveCheckpoint::Write,
ArchiveCheckpoint::Sync,
ArchiveCheckpoint::Publish,
ArchiveCheckpoint::TemporaryCleanup,
ArchiveCheckpoint::DirectorySync,
] {
let dir = tempfile::tempdir().expect("temporary directory");
let result = create_archive_with_hook(
dir.path(),
BackupReason::PreCommit,
HistoricalApplyStatus::Unknown,
&saved,
|current| {
if current == point {
Err(io::Error::other("archive fault injection"))
} else {
Ok(())
}
},
);
assert!(result.is_err(), "{point:?} must not report archive success");
let entries = fs::read_dir(dir.path())
.expect("archive directory remains readable")
.map(|entry| entry.unwrap().path())
.collect::<Vec<_>>();
assert!(entries.iter().all(|path| {
path.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| {
name.starts_with(".") || name.starts_with("wireguard-admin-")
})
}));
for path in entries.iter().filter(|path| {
path.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.starts_with("wireguard-admin-"))
}) {
assert!(
preview_archive(path).is_ok(),
"published archive is complete"
);
}
if matches!(
point,
ArchiveCheckpoint::Write | ArchiveCheckpoint::Sync | ArchiveCheckpoint::Publish
) {
assert!(
entries.is_empty(),
"pre-publication failure leaves no artifact"
);
}
}
}
#[test]
fn rejects_truncation_and_nonzero_trailing_bytes() {
let saved = fixture();
let mut bytes = encode_archive(
"2026-10-19T12:30:00Z",
BackupReason::Manual,
HistoricalApplyStatus::Unknown,
&saved,
)
.expect("archive");
assert!(matches!(
preview_archive_bytes(&bytes[..bytes.len() - BLOCK]),
Err(BackupError::InvalidArchive)
));
let last = bytes.len() - 1;
bytes[last] = 1;
assert!(preview_archive_bytes(&bytes).is_err());
}
#[test]
fn rejects_unsupported_version_and_extensions() {
let saved = fixture();
let unsupported = encode_archive_version(
99,
"2026-10-19T12:30:00Z",
BackupReason::Manual,
HistoricalApplyStatus::Unknown,
&saved,
)
.expect("archive");
assert!(matches!(
preview_archive_bytes(&unsupported),
Err(BackupError::UnsupportedVersion)
));
let mut bytes = encode_archive(
"2026-10-19T12:30:00Z",
BackupReason::Manual,
HistoricalApplyStatus::Unknown,
&saved,
)
.expect("archive");
bytes[156] = b'x';
let checksum = bytes[..148]
.iter()
.chain(&bytes[156..BLOCK])
.map(|byte| *byte as u64)
.sum::<u64>()
+ 8 * b' ' as u64;
let checksum_text = format!("{checksum:06o}\0 ");
bytes[148..156].copy_from_slice(checksum_text.as_bytes());
assert!(preview_archive_bytes(&bytes).is_err());
}
#[test]
fn rejects_traversal_and_link_headers_even_when_checksums_match() {
let saved = fixture();
let original = encode_archive(
"2026-10-19T12:30:00Z",
BackupReason::Manual,
HistoricalApplyStatus::Unknown,
&saved,
)
.expect("archive");
for (offset, field) in [(0, 0), (0, 1)] {
let mut bytes = original.clone();
match field {
0 => {
bytes[offset..offset + 100].fill(0);
bytes[offset..offset + 14].copy_from_slice(b"../server.conf");
}
_ => bytes[offset + 156] = b'2',
}
let checksum = bytes[offset..offset + 148]
.iter()
.chain(&bytes[offset + 156..offset + BLOCK])
.map(|byte| *byte as u64)
.sum::<u64>()
+ 8 * b' ' as u64;
let checksum_text = format!("{checksum:06o}\0 ");
bytes[offset + 148..offset + 156].copy_from_slice(checksum_text.as_bytes());
assert!(preview_archive_bytes(&bytes).is_err());
}
}
#[test]
fn repeated_creation_does_not_overwrite_and_absence_is_explicit() {
let dir = tempfile::tempdir().expect("temporary directory");
let saved = SavedBytes {
settings: b"settings".to_vec(),
server_config: b"server".to_vec(),
internal_state: None,
vault: None,
};
let first = create_archive(
dir.path(),
BackupReason::PreRestart,
HistoricalApplyStatus::Unknown,
&saved,
)
.expect("first archive");
let second = create_archive(
dir.path(),
BackupReason::PreRestart,
HistoricalApplyStatus::Unknown,
&saved,
)
.expect("second archive");
assert_ne!(first, second);
let preview = preview_archive(&second).expect("preview");
assert_eq!(preview.saved_bytes.internal_state, None);
assert_eq!(preview.saved_bytes.vault, None);
assert_eq!(list_archives(dir.path()).expect("list").len(), 2);
}
#[test]
fn archive_directory_and_selected_file_symlinks_are_rejected() {
let root = tempfile::tempdir().expect("temporary directory");
let real_directory = root.path().join("real-archives");
fs::create_dir(&real_directory).expect("archive target directory");
let linked_directory = root.path().join("archives-link");
symlink(&real_directory, &linked_directory).expect("directory symlink");
assert!(
create_archive(
&linked_directory,
BackupReason::Manual,
HistoricalApplyStatus::Unknown,
&fixture(),
)
.is_err()
);
assert!(
fs::read_dir(&real_directory)
.expect("real directory is readable")
.next()
.is_none()
);
let archive = create_archive(
&real_directory,
BackupReason::Manual,
HistoricalApplyStatus::Unknown,
&fixture(),
)
.expect("regular archive");
let linked_archive = root.path().join("archive-link.tar");
symlink(archive, &linked_archive).expect("archive symlink");
assert!(preview_archive(&linked_archive).is_err());
}
}
+21
View File
@@ -211,6 +211,25 @@ impl ClientStates {
&self.reserved_addresses
}
/// Restore conservative reservations from saved metadata without asserting
/// that the running interface matches the saved snapshot.
pub fn restore_persisted_reservations(
&mut self,
reservations: impl IntoIterator<Item = IpAddr>,
) -> Result<(), DomainError> {
let reservations = reservations.into_iter().collect::<BTreeSet<_>>();
if reservations
.iter()
.any(|address| self.saved.addresses().any(|saved| saved == *address))
{
return Err(DomainError::ReservationConflictsWithSavedClient);
}
self.reserved_addresses = reservations;
self.applied_snapshot = None;
self.applied = RuntimeObservation::Unknown;
Ok(())
}
pub fn draft_mut(&mut self) -> &mut Snapshot {
&mut self.draft
}
@@ -297,6 +316,8 @@ pub enum DomainError {
AddressUnavailable,
#[error("verified applied clients do not match the saved snapshot")]
AppliedSnapshotMismatch,
#[error("persisted reservation conflicts with a saved client address")]
ReservationConflictsWithSavedClient,
}
/// Return the lowest available host in `range` without walking the subnet.
+3
View File
@@ -1,5 +1,8 @@
pub mod app;
pub mod backup;
pub mod config;
pub mod domain;
pub mod persistence;
pub mod startup;
pub mod vault;
pub mod wireguard;
+1299
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -258,6 +258,13 @@ impl VaultBundle {
self.records.contains_key(&label.to_string())
}
/// Remove only the record identified by its canonical WireGuard public key.
/// The encrypted identity and every other record remain unchanged.
pub fn remove_record(&mut self, label: PublicKey) -> Result<bool, VaultError> {
self.validate_shape()?;
Ok(self.records.remove(&label.to_string()).is_some())
}
/// Retrieve a single record, freshly unlocking and verifying the identity on every call.
pub fn retrieve_record(
&self,
+14 -3
View File
@@ -366,6 +366,11 @@ mod tests {
use super::*;
use crate::vault::WireGuardPrivateKey;
use std::fs;
use std::os::unix::fs::PermissionsExt;
fn make_private(path: &Path) {
fs::set_permissions(path, fs::Permissions::from_mode(0o600)).unwrap();
}
#[test]
fn entry_enumeration_error_is_visible_and_other_files_still_import() {
@@ -386,7 +391,9 @@ mod tests {
client_key.to_base64(),
server_key.public_key()
);
fs::write(directory.path().join("valid.conf"), &client_bytes).unwrap();
let source_file = directory.path().join("valid.conf");
fs::write(&source_file, &client_bytes).unwrap();
make_private(&source_file);
let dirfd = open_source_directory(directory.path()).unwrap();
let entries = [
Ok(OsString::from("valid.conf")),
@@ -424,13 +431,16 @@ mod tests {
client_key.to_base64(),
server_key.public_key()
);
fs::write(source.join("client.conf"), &bytes).unwrap();
let client_file = source.join("client.conf");
fs::write(&client_file, &bytes).unwrap();
make_private(&client_file);
let directory = open_source_directory(&source).unwrap();
fs::rename(&source, &pinned_path).unwrap();
fs::create_dir(&source).unwrap();
let replacement = source.join("replacement.conf");
fs::write(
source.join("replacement.conf"),
&replacement,
format!(
"[Interface]\nPrivateKey = {}\nAddress = 10.8.0.3/32\n\n[Peer]\nPublicKey = {}\n",
decoy_key.to_base64(),
@@ -438,6 +448,7 @@ mod tests {
),
)
.unwrap();
make_private(&replacement);
let entries = rfs::Dir::read_from(&directory).unwrap();
let mut bundle = VaultBundle::create("password").unwrap();