This commit is contained in:
Mateusz Gruszczyński committed 2026-10-04 16:47:24 +02:00
1 parent 59ddabb72b
commit 174389e5b1
48 files changed
+688 -212

No files matched your search

+244
View File
@@ -0,0 +1,244 @@
fn excluded_file(name: &str) -> bool {
name == "process.lock"
|| name == "config.sqlite3"
|| name == "config.sqlite3-wal"
|| name == "config.sqlite3-shm"
|| name == "master.key"
|| name == ".lock"
|| name.ends_with(".sock")
|| name.ends_with(".pid")
|| name.ends_with(".log")
|| name.ends_with(".jsonl")
}
fn archive_data_path(relative: &Path) -> Result<String> {
let value = relative
.to_str()
.context("backup paths must be valid UTF-8")?
.replace('\\', "/");
ensure!(!value.is_empty(), "empty backup path");
Ok(format!("data/{value}"))
}
fn backup_input(path: &Path) -> Result<File> {
let mut options = OpenOptions::new();
options.read(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.custom_flags(libc::O_NOFOLLOW);
}
let file = options.open(path)?;
ensure!(file.metadata()?.is_file(), "backup input must be a regular file");
Ok(file)
}
fn snapshot_metadata_unchanged(before: &std::fs::Metadata, after: &std::fs::Metadata) -> bool {
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
return before.dev() == after.dev()
&& before.ino() == after.ino()
&& before.size() == after.size()
&& before.mtime() == after.mtime()
&& before.mtime_nsec() == after.mtime_nsec()
&& before.ctime() == after.ctime()
&& before.ctime_nsec() == after.ctime_nsec();
}
#[cfg(not(unix))]
{
before.len() == after.len() && before.modified().ok() == after.modified().ok()
}
}
fn copy_snapshot_file(source: &Path, target: &Path) -> Result<(u64, String)> {
let mut input = backup_input(source)?;
let before = input.metadata()?;
let mut output = private_file_options().create_new(true).open(target)?;
let mut hasher = Sha256::new();
let mut size = 0u64;
let mut buffer = [0u8; 1024 * 1024];
loop {
let read = input.read(&mut buffer)?;
if read == 0 {
break;
}
output.write_all(&buffer[..read])?;
hasher.update(&buffer[..read]);
size = size.checked_add(read as u64).context("backup input too large")?;
}
output.sync_all()?;
let after = input.metadata()?;
ensure!(
snapshot_metadata_unchanged(&before, &after),
"durable file changed while backup snapshot was being captured: {}",
source.display()
);
Ok((size, format!("{:x}", hasher.finalize())))
}
fn symlink_target_bytes(target: &Path) -> Result<Vec<u8>> {
#[cfg(unix)]
{
use std::os::unix::ffi::OsStrExt;
let bytes = target.as_os_str().as_bytes().to_vec();
ensure!(!bytes.is_empty() && bytes.len() <= 4096, "invalid symlink target");
return Ok(bytes);
}
#[cfg(not(unix))]
{
let bytes = target
.to_str()
.context("symlink target must be valid UTF-8")?
.as_bytes()
.to_vec();
ensure!(!bytes.is_empty() && bytes.len() <= 4096, "invalid symlink target");
Ok(bytes)
}
}
fn symlink_target_path(bytes: &[u8]) -> Result<PathBuf> {
ensure!(!bytes.is_empty() && bytes.len() <= 4096, "invalid symlink target");
#[cfg(unix)]
{
use std::os::unix::ffi::OsStringExt;
return Ok(PathBuf::from(std::ffi::OsString::from_vec(bytes.to_vec())));
}
#[cfg(not(unix))]
{
Ok(PathBuf::from(std::str::from_utf8(bytes).context("symlink target is not UTF-8")?))
}
}
fn validate_symlink_target(link_relative: &Path, target: &Path) -> Result<()> {
ensure!(!target.is_absolute(), "backup symlink target must be relative");
let mut depth = link_relative.parent().map_or(0usize, |parent| parent.components().count());
for component in target.components() {
match component {
Component::Normal(_) => depth = depth.checked_add(1).context("symlink target too deep")?,
Component::CurDir => {}
Component::ParentDir => {
ensure!(depth > 0, "backup symlink escapes the data directory");
depth -= 1;
}
Component::RootDir | Component::Prefix(_) => bail!("backup symlink target must be relative"),
}
}
Ok(())
}
fn create_symlink(target: &Path, link: &Path) -> Result<()> {
#[cfg(unix)]
{
std::os::unix::fs::symlink(target, link)?;
return Ok(());
}
#[cfg(not(unix))]
{
let _ = (target, link);
bail!("backup symlink restore is supported only on Unix")
}
}
fn collect_data_files(
root: &Path,
current: &Path,
snapshot_root: &Path,
entries: &mut Vec<ArchiveEntry>,
symlinks: &mut Vec<PathBuf>,
) -> Result<()> {
for item in fs::read_dir(current)? {
let item = item?;
let path = item.path();
let name = item.file_name();
let name_text = name.to_string_lossy();
if excluded_file(&name_text) || excluded_directory(&name_text) || path == root.join("secrets") {
continue;
}
let meta = fs::symlink_metadata(&path)?;
let relative = path.strip_prefix(root)?;
let archive_path = archive_data_path(relative)?;
let snapshot_path = snapshot_root.join(relative);
if meta.is_dir() {
private_dir(&snapshot_path)?;
entries.push(ArchiveEntry {
manifest: ManifestEntry {
path: archive_path,
size: 0,
sha256: hash_bytes(&[]),
kind: EntryKind::Directory,
},
source: EntrySource::Bytes(Vec::new()),
});
collect_data_files(root, &path, snapshot_root, entries, symlinks)?;
} else if meta.file_type().is_symlink() {
let target = fs::read_link(&path)?;
validate_symlink_target(relative, &target)?;
if let Some(parent) = snapshot_path.parent() {
private_dir(parent)?;
}
create_symlink(&target, &snapshot_path)?;
let bytes = symlink_target_bytes(&target)?;
entries.push(ArchiveEntry {
manifest: ManifestEntry {
path: archive_path,
size: bytes.len() as u64,
sha256: hash_bytes(&bytes),
kind: EntryKind::Symlink,
},
source: EntrySource::Bytes(bytes),
});
symlinks.push(snapshot_path);
} else if meta.is_file() {
if let Some(parent) = snapshot_path.parent() {
private_dir(parent)?;
}
let (size, sha256) = copy_snapshot_file(&path, &snapshot_path)?;
entries.push(ArchiveEntry {
manifest: ManifestEntry {
path: archive_path,
size,
sha256,
kind: EntryKind::File,
},
source: EntrySource::File(snapshot_path),
});
} else {
bail!("backup refuses non-file runtime object: {}", path.display());
}
}
Ok(())
}
fn validate_snapshot_symlinks(snapshot_root: &Path, symlinks: &[PathBuf]) -> Result<()> {
let root = fs::canonicalize(snapshot_root)?;
for link in symlinks {
let resolved = fs::canonicalize(link)
.with_context(|| format!("backup symlink target is missing: {}", link.display()))?;
ensure!(resolved.starts_with(&root), "backup symlink escapes the data directory: {}", link.display());
}
Ok(())
}
fn validate_snapshot_config(envelope: &crate::config::Envelope, snapshot_root: &Path) -> Result<()> {
let cert_dir = snapshot_root.join("certs");
for certificate in &envelope.config.tls.certificates {
for name in [&certificate.cert_file, &certificate.key_file] {
crate::tls::safe_name(name)?;
let path = cert_dir.join(name);
let meta = fs::symlink_metadata(&path)
.with_context(|| format!("configured certificate file changed during backup: {name}"))?;
ensure!(meta.is_file() && !meta.file_type().is_symlink(), "configured certificate file is not a regular file: {name}");
}
}
if envelope.config.administration.tls && envelope.config.administration.certificate_id.is_none() {
for name in ["default.pem", "default.key"] {
let path = cert_dir.join(name);
let meta = fs::symlink_metadata(&path)
.with_context(|| format!("default administration certificate changed during backup: {name}"))?;
ensure!(meta.is_file() && !meta.file_type().is_symlink(), "default administration certificate is not a regular file: {name}");
}
}
Ok(())
}