fn excluded_file(name: &str) -> bool { name == "process.lock" || name == ".geoip-update.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 { 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 { 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> { #[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 { 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, symlinks: &mut Vec, ) -> 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 { if crate::tls::certificate_files_are_external( &certificate.cert_file, &certificate.key_file, )? { // External certificate material is intentionally outside the RustProxy // data tree and is therefore referenced by configuration, not copied // into a RustProxy backup. The operator must restore/mount it separately. continue; } for name in [&certificate.cert_file, &certificate.key_file] { 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(()) }