use std::collections::HashMap; use std::ffi::OsString; use std::fs::{self, File, OpenOptions}; use std::io::{Read, Write}; use std::path::{Path, PathBuf}; use aether_gateway::{ restore_backup_json, BackupDecryptionKey, BackupRestoreLimits, DEFAULT_BACKUP_MAX_ENCRYPTED_BYTES, DEFAULT_BACKUP_MAX_JSON_BYTES, }; use clap::Parser; use serde::Deserialize; use sha2::{Digest, Sha256}; use uuid::Uuid; const HISTORICAL_KEYS_ENV: &str = "AETHER_BACKUP_HISTORICAL_KEYS_JSON"; const MAX_SECRET_FILE_BYTES: usize = 1024 * 1024; const MAX_KEY_FILES: usize = 16; const MAX_V2_KEY_CANDIDATES: usize = 256; const MAX_LEGACY_V1_KEY_CANDIDATES: usize = 16; const AUTOMATIC_KEY_ENV_VARS: [(&str, bool); 3] = [ ("AETHER_BACKUP_ENCRYPTION_KEY", false), ("AETHER_GATEWAY_DATA_ENCRYPTION_KEY", true), ("ENCRYPTION_KEY", true), ]; #[derive(Debug, Parser)] #[command( name = "aether-backup-restore", about = "Decrypt and verify an Aether S3 backup into a local JSON file" )] struct Args { /// Local encrypted .json.zst.aes256gcm file. #[arg(long)] input: PathBuf, /// Complete canonical S3 object key used when the backup was encrypted. #[arg(long)] object_key: String, /// Destination for verified JSON. Existing files are rejected by default. #[arg(long)] output: PathBuf, /// Plaintext secret file; may be repeated. The secret itself is never accepted as an argument. #[arg(long = "key-file")] key_files: Vec, /// Structured JSON keyring file. May also be set with AETHER_BACKUP_KEYRING_FILE. #[arg(long, env = "AETHER_BACKUP_KEYRING_FILE")] keyring_file: Option, /// Replace an existing output file atomically. #[arg(long)] overwrite: bool, /// Maximum encrypted input size in MiB. #[arg(long, default_value_t = mib(DEFAULT_BACKUP_MAX_ENCRYPTED_BYTES), value_parser = clap::value_parser!(u64).range(1..=4096))] max_encrypted_mib: u64, /// Maximum decompressed JSON size in MiB. #[arg(long, default_value_t = mib(DEFAULT_BACKUP_MAX_JSON_BYTES), value_parser = clap::value_parser!(u64).range(1..=8192))] max_json_mib: u64, } #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct KeyringDocument { version: u8, #[serde(default)] keys: Vec, #[serde(default)] legacy_v1: Vec, } #[derive(Deserialize)] #[serde(untagged)] enum KeyringSecret { Direct(String), Named { #[serde(alias = "key")] secret: String, }, } impl KeyringSecret { fn into_secret(self) -> String { match self { Self::Direct(secret) | Self::Named { secret } => secret, } } } #[derive(Debug, thiserror::Error)] enum CliError { #[error("{0}")] Message(String), #[error("file operation failed: {0}")] Io(#[from] std::io::Error), #[error("backup restore failed: {0}")] Restore(#[from] aether_gateway::BackupRestoreError), } fn main() { if let Err(error) = run(Args::parse()) { eprintln!("{error}"); std::process::exit(1); } } fn run(args: Args) -> Result<(), CliError> { reject_output_aliases(&args)?; let limits = BackupRestoreLimits { max_encrypted_bytes: checked_mib(args.max_encrypted_mib)?, max_json_bytes: checked_mib(args.max_json_mib)?, }; let encrypted = read_limited_file(&args.input, limits.max_encrypted_bytes, true, false)?; let candidates = load_key_candidates(&args)?; if candidates.is_empty() { return Err(CliError::Message(format!( "no backup keys configured; set AETHER_BACKUP_ENCRYPTION_KEY, AETHER_GATEWAY_DATA_ENCRYPTION_KEY, or ENCRYPTION_KEY, or use a protected key/keyring file ({HISTORICAL_KEYS_ENV} is also supported)" ))); } let cipher_sha256 = format!("{:x}", Sha256::digest(&encrypted)); let restored = restore_backup_json(&args.object_key, &encrypted, &candidates, limits)?; write_atomic_private(&args.output, restored.json_bytes(), args.overwrite)?; let restored_scope = restored.scope().as_str(); let summary = serde_json::json!({ "status": "verified_json_written", "object_key": args.object_key, "output": args.output.display().to_string(), "cipher_sha256": cipher_sha256, "envelope_version": restored.envelope_version, "key_id": restored.key_id, "export_version": restored.export_version, "exported_at": restored.exported_at, "scope": restored_scope, "database_applied": false, }); println!( "{}", serde_json::to_string(&summary).map_err(|error| { CliError::Message(format!("could not serialize restore summary: {error}")) })? ); Ok(()) } fn reject_output_aliases(args: &Args) -> Result<(), CliError> { let Ok(output) = fs::canonicalize(&args.output) else { return Ok(()); }; let mut protected_inputs = Vec::with_capacity(args.key_files.len() + 2); protected_inputs.push(&args.input); protected_inputs.extend(args.key_files.iter()); if let Some(keyring_file) = &args.keyring_file { protected_inputs.push(keyring_file); } for input in protected_inputs { if fs::canonicalize(input).is_ok_and(|canonical| canonical == output) { return Err(CliError::Message(format!( "output {} must not replace the encrypted input or a key file", args.output.display() ))); } } Ok(()) } const fn mib(bytes: usize) -> u64 { (bytes / (1024 * 1024)) as u64 } fn checked_mib(value: u64) -> Result { value .checked_mul(1024 * 1024) .and_then(|bytes| usize::try_from(bytes).ok()) .ok_or_else(|| CliError::Message("configured size limit is too large".to_string())) } fn load_key_candidates(args: &Args) -> Result, CliError> { if args.key_files.len() > MAX_KEY_FILES { return Err(CliError::Message(format!( "at most {MAX_KEY_FILES} --key-file values are allowed" ))); } let mut values = Vec::<(String, bool)>::new(); append_automatic_environment_keys(&mut values, |env_name| std::env::var(env_name).ok())?; if let Ok(value) = std::env::var(HISTORICAL_KEYS_ENV) { append_keyring_document( &mut values, parse_keyring(value.as_bytes(), HISTORICAL_KEYS_ENV)?, )?; } if let Some(path) = &args.keyring_file { let bytes = read_limited_file(path, MAX_SECRET_FILE_BYTES, true, true)?; append_keyring_document( &mut values, parse_keyring(&bytes, &path.display().to_string())?, )?; } for path in &args.key_files { let bytes = read_limited_file(path, MAX_SECRET_FILE_BYTES, true, true)?; let value = String::from_utf8(bytes).map_err(|_| { CliError::Message(format!("key file {} is not valid UTF-8", path.display())) })?; push_secret(&mut values, value, true)?; } let unique = deduplicate_and_validate_key_values(values)?; unique .into_iter() .map(|(secret, allow_v1)| { if allow_v1 { BackupDecryptionKey::historical(secret) } else { BackupDecryptionKey::v2_only(secret) } .map_err(CliError::from) }) .collect() } fn append_automatic_environment_keys( values: &mut Vec<(String, bool)>, mut get_env: impl FnMut(&str) -> Option, ) -> Result<(), CliError> { for (env_name, allow_v1) in AUTOMATIC_KEY_ENV_VARS { if let Some(value) = get_env(env_name) { push_secret(values, value, allow_v1)?; } } Ok(()) } fn deduplicate_and_validate_key_values( values: Vec<(String, bool)>, ) -> Result, CliError> { let mut indexes = HashMap::::new(); let mut unique = Vec::<(String, bool)>::new(); let mut legacy_v1_count = 0_usize; for (secret, allow_v1) in values { if let Some(index) = indexes.get(&secret).copied() { if allow_v1 && !unique[index].1 { unique[index].1 = true; legacy_v1_count += 1; } } else { let index = unique.len(); indexes.insert(secret.clone(), index); unique.push((secret, allow_v1)); legacy_v1_count += usize::from(allow_v1); if unique.len() > MAX_V2_KEY_CANDIDATES { return Err(CliError::Message(format!( "backup restore allows at most {MAX_V2_KEY_CANDIDATES} v2 candidate keys" ))); } } if legacy_v1_count > MAX_LEGACY_V1_KEY_CANDIDATES { return Err(CliError::Message(format!( "legacy v1 backup restore allows at most {MAX_LEGACY_V1_KEY_CANDIDATES} candidate keys" ))); } } Ok(unique) } fn parse_keyring(bytes: &[u8], source: &str) -> Result { serde_json::from_slice(bytes).map_err(|error| { CliError::Message(format!( "keyring {source} is not valid structured JSON: {error}" )) }) } fn append_keyring_document( values: &mut Vec<(String, bool)>, keyring: KeyringDocument, ) -> Result<(), CliError> { if keyring.version != 1 { return Err(CliError::Message(format!( "unsupported backup keyring version {}", keyring.version ))); } for secret in keyring.keys { push_secret(values, secret.into_secret(), false)?; } for secret in keyring.legacy_v1 { push_secret(values, secret.into_secret(), true)?; } Ok(()) } fn push_secret( values: &mut Vec<(String, bool)>, value: String, allow_v1: bool, ) -> Result<(), CliError> { let value = value.trim().to_string(); if value.is_empty() || value.contains('\0') { return Err(CliError::Message( "backup key material must not be empty or contain NUL".to_string(), )); } values.push((value, allow_v1)); Ok(()) } fn read_limited_file( path: &Path, limit: usize, reject_symlink: bool, require_private_permissions: bool, ) -> Result, CliError> { let mut file = open_file_without_following_symlinks(path, reject_symlink)?; let metadata = file.metadata()?; if !metadata.is_file() { return Err(CliError::Message(format!( "{} is not a regular file", path.display() ))); } if require_private_permissions { validate_secret_file_permissions(path, &metadata)?; } #[cfg(not(unix))] if reject_symlink && fs::symlink_metadata(path)?.file_type().is_symlink() { return Err(CliError::Message(format!( "input file {} changed to a symbolic link while being opened", path.display() ))); } if metadata.len() > u64::try_from(limit).unwrap_or(u64::MAX) { return Err(CliError::Message(format!( "{} exceeds the configured {} byte limit", path.display(), limit ))); } let read_limit = u64::try_from(limit).unwrap_or(u64::MAX).saturating_add(1); let mut bytes = Vec::with_capacity((metadata.len() as usize).min(limit)); Read::by_ref(&mut file) .take(read_limit) .read_to_end(&mut bytes)?; if bytes.len() > limit { return Err(CliError::Message(format!( "{} exceeds the configured {} byte limit", path.display(), limit ))); } Ok(bytes) } fn open_file_without_following_symlinks( path: &Path, reject_symlink: bool, ) -> Result { #[cfg(unix)] if reject_symlink { return open_file_beneath_real_directories(path); } #[cfg(not(unix))] if reject_symlink && fs::symlink_metadata(path)?.file_type().is_symlink() { return Err(CliError::Message(format!( "input file {} must not be a symbolic link", path.display() ))); } let mut options = OpenOptions::new(); options.read(true); #[cfg(windows)] if reject_symlink { use std::os::windows::fs::OpenOptionsExt; const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; options.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT); } options.open(path).map_err(CliError::from) } #[cfg(unix)] fn open_file_beneath_real_directories(path: &Path) -> Result { use std::os::fd::{AsRawFd, FromRawFd}; let (parent, file_name) = open_real_parent_directory(path)?; let file_name = unix_path_component(&file_name, "input file name")?; // SAFETY: `parent` is a valid open directory descriptor, `file_name` is NUL-terminated, // and ownership of a successful descriptor is transferred immediately to `File`. let descriptor = unsafe { libc::openat( parent.as_raw_fd(), file_name.as_ptr(), libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW, ) }; if descriptor < 0 { let error = std::io::Error::last_os_error(); if matches!( error.raw_os_error(), Some(libc::ELOOP) | Some(libc::ENOTDIR) ) && unix_file_mode_at(&parent, &file_name)? .is_some_and(|mode| mode & libc::S_IFMT == libc::S_IFLNK) { return Err(CliError::Message(format!( "input file {} must not be a symbolic link", path.display() ))); } return Err(CliError::Io(error)); } // SAFETY: `openat` returned a new owned descriptor and no other owner exists. Ok(unsafe { File::from_raw_fd(descriptor) }) } #[cfg(unix)] fn open_real_parent_directory(path: &Path) -> Result<(File, OsString), CliError> { let file_name = path.file_name().map(OsString::from).ok_or_else(|| { CliError::Message(format!( "path {} must include a regular file name", path.display() )) })?; let parent = path .parent() .filter(|path| !path.as_os_str().is_empty()) .unwrap_or(Path::new(".")); Ok((open_real_directory(parent)?, file_name)) } #[cfg(unix)] fn open_real_directory(path: &Path) -> Result { use std::os::fd::{AsRawFd, FromRawFd}; use std::path::Component; let mut directory = File::open(if path.is_absolute() { "/" } else { "." })?; for component in path.components() { let name = match component { Component::RootDir | Component::CurDir => continue, Component::Normal(name) => name, Component::ParentDir => { return Err(CliError::Message(format!( "path {} must not contain '..' components", path.display() ))) } Component::Prefix(_) => { return Err(CliError::Message(format!( "path {} uses an unsupported prefix", path.display() ))) } }; let name = unix_path_component(name, "directory component")?; // SAFETY: `directory` is a valid open directory descriptor and `name` is a valid // NUL-terminated component. A successful descriptor is immediately owned by `File`. let descriptor = unsafe { libc::openat( directory.as_raw_fd(), name.as_ptr(), libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_DIRECTORY, ) }; if descriptor < 0 { let error = std::io::Error::last_os_error(); if matches!( error.raw_os_error(), Some(libc::ELOOP) | Some(libc::ENOTDIR) ) && unix_file_mode_at(&directory, &name)? .is_some_and(|mode| mode & libc::S_IFMT == libc::S_IFLNK) { return Err(CliError::Message(format!( "path {} must not contain symbolic-link directory components", path.display() ))); } return Err(CliError::Io(error)); } // SAFETY: `openat` returned a new owned descriptor and no other owner exists. directory = unsafe { File::from_raw_fd(descriptor) }; } Ok(directory) } #[cfg(unix)] fn unix_path_component( component: &std::ffi::OsStr, description: &str, ) -> Result { use std::os::unix::ffi::OsStrExt; std::ffi::CString::new(component.as_bytes()).map_err(|_| { CliError::Message(format!( "{description} must not contain an embedded NUL byte" )) }) } #[cfg(unix)] fn validate_secret_file_permissions(path: &Path, metadata: &fs::Metadata) -> Result<(), CliError> { use std::os::unix::fs::{MetadataExt, PermissionsExt}; if metadata.permissions().mode() & 0o077 != 0 { return Err(CliError::Message(format!( "secret file {} is accessible by group or other users; require mode 0600 or stricter", path.display() ))); } // SAFETY: `geteuid` has no preconditions and does not retain pointers or borrowed state. let effective_uid = unsafe { libc::geteuid() }; if metadata.uid() != effective_uid { return Err(CliError::Message(format!( "secret file {} must be owned by the current effective user (uid {effective_uid})", path.display() ))); } Ok(()) } #[cfg(not(unix))] fn validate_secret_file_permissions( _path: &Path, _metadata: &fs::Metadata, ) -> Result<(), CliError> { Ok(()) } #[cfg(unix)] fn write_atomic_private(path: &Path, bytes: &[u8], overwrite: bool) -> Result<(), CliError> { use std::os::fd::{AsRawFd, FromRawFd}; use std::os::unix::fs::PermissionsExt; let (parent, output_file_name) = open_real_parent_directory(path)?; let output_name = unix_path_component(&output_file_name, "output file name")?; if let Some(mode) = unix_file_mode_at(&parent, &output_name)? { if mode & libc::S_IFMT != libc::S_IFREG { return Err(CliError::Message(format!( "output {} must be a regular file path, not a symbolic link or special file", path.display() ))); } if !overwrite { return Err(CliError::Message(format!( "output {} already exists; pass --overwrite to replace it", path.display() ))); } } let safe_file_name = safe_temp_file_component(&output_file_name); let temp_file_name = OsString::from(format!( ".{}.aether-restore-{}-{}.tmp", safe_file_name.to_string_lossy(), std::process::id(), Uuid::new_v4() )); let temp_name = unix_path_component(&temp_file_name, "temporary output file name")?; // SAFETY: `parent` is a valid directory descriptor, `temp_name` is NUL-terminated, and a // successful descriptor is transferred immediately to `File`. O_EXCL prevents name reuse. let descriptor = unsafe { libc::openat( parent.as_raw_fd(), temp_name.as_ptr(), libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW, 0o600, ) }; if descriptor < 0 { return Err(CliError::Io(std::io::Error::last_os_error())); } // SAFETY: `openat` returned a new owned descriptor and no other owner exists. let mut temp = unsafe { File::from_raw_fd(descriptor) }; let result = (|| -> Result<(), CliError> { temp.set_permissions(fs::Permissions::from_mode(0o600))?; temp.write_all(bytes)?; temp.sync_all()?; drop(temp); if overwrite { unix_rename_at(&parent, &temp_name, &output_name)?; } else { unix_link_at(&parent, &temp_name, &output_name).map_err(|error| { if error.kind() == std::io::ErrorKind::AlreadyExists { CliError::Message(format!( "output {} already exists; pass --overwrite to replace it", path.display() )) } else { CliError::Io(error) } })?; unix_unlink_at(&parent, &temp_name)?; } parent.sync_all()?; Ok(()) })(); if result.is_err() { let _ = unix_unlink_at(&parent, &temp_name); } result } #[cfg(unix)] fn unix_file_mode_at( parent: &File, file_name: &std::ffi::CStr, ) -> Result, CliError> { use std::mem::MaybeUninit; use std::os::fd::AsRawFd; let mut stat = MaybeUninit::::uninit(); // SAFETY: `parent` and `file_name` remain valid for the call, and `stat` points to writable // storage. The value is initialized only when fstatat reports success. let result = unsafe { libc::fstatat( parent.as_raw_fd(), file_name.as_ptr(), stat.as_mut_ptr(), libc::AT_SYMLINK_NOFOLLOW, ) }; if result == 0 { // SAFETY: successful fstatat initialized the complete stat structure. return Ok(Some(unsafe { stat.assume_init() }.st_mode)); } let error = std::io::Error::last_os_error(); if error.raw_os_error() == Some(libc::ENOENT) { Ok(None) } else { Err(CliError::Io(error)) } } #[cfg(unix)] fn unix_link_at( parent: &File, source: &std::ffi::CStr, destination: &std::ffi::CStr, ) -> Result<(), std::io::Error> { use std::os::fd::AsRawFd; // SAFETY: both names are valid NUL-terminated components and both directory descriptors are // the same live `parent` descriptor. No pointers are retained after the call. let result = unsafe { libc::linkat( parent.as_raw_fd(), source.as_ptr(), parent.as_raw_fd(), destination.as_ptr(), 0, ) }; if result == 0 { Ok(()) } else { Err(std::io::Error::last_os_error()) } } #[cfg(unix)] fn unix_rename_at( parent: &File, source: &std::ffi::CStr, destination: &std::ffi::CStr, ) -> Result<(), CliError> { use std::os::fd::AsRawFd; // SAFETY: both names are valid NUL-terminated components and `parent` remains open for the // duration of the call. renameat does not retain either pointer. let result = unsafe { libc::renameat( parent.as_raw_fd(), source.as_ptr(), parent.as_raw_fd(), destination.as_ptr(), ) }; if result == 0 { Ok(()) } else { Err(CliError::Io(std::io::Error::last_os_error())) } } #[cfg(unix)] fn unix_unlink_at(parent: &File, file_name: &std::ffi::CStr) -> Result<(), CliError> { use std::os::fd::AsRawFd; // SAFETY: `parent` is a valid directory descriptor and `file_name` is a NUL-terminated // component. unlinkat does not retain either argument. let result = unsafe { libc::unlinkat(parent.as_raw_fd(), file_name.as_ptr(), 0) }; if result == 0 { Ok(()) } else { Err(CliError::Io(std::io::Error::last_os_error())) } } #[cfg(not(unix))] fn write_atomic_private(path: &Path, bytes: &[u8], overwrite: bool) -> Result<(), CliError> { let parent = path .parent() .filter(|path| !path.as_os_str().is_empty()) .unwrap_or(Path::new(".")); let file_name = safe_temp_file_component(path.file_name().ok_or_else(|| { CliError::Message("output path must include a file name".to_string()) })?); if !parent.is_dir() { return Err(CliError::Message(format!( "output directory {} does not exist", parent.display() ))); } if fs::symlink_metadata(parent)?.file_type().is_symlink() { return Err(CliError::Message(format!( "output directory {} must not be a symbolic link", parent.display() ))); } if let Ok(metadata) = fs::symlink_metadata(path) { if metadata.file_type().is_symlink() || metadata.is_dir() || !metadata.is_file() { return Err(CliError::Message(format!( "output {} must be a regular file path, not a symbolic link or special file", path.display() ))); } } if !overwrite && fs::symlink_metadata(path).is_ok() { return Err(CliError::Message(format!( "output {} already exists; pass --overwrite to replace it", path.display() ))); } let temp_name = format!( ".{}.aether-restore-{}-{}.tmp", file_name.to_string_lossy(), std::process::id(), Uuid::new_v4() ); let temp_path = parent.join(temp_name); let mut options = OpenOptions::new(); options.write(true).create_new(true); let mut temp = options.open(&temp_path)?; let result = (|| -> Result<(), CliError> { temp.write_all(bytes)?; temp.sync_all()?; drop(temp); if overwrite { replace_output_file(&temp_path, path)?; } else { fs::hard_link(&temp_path, path).map_err(|error| { if error.kind() == std::io::ErrorKind::AlreadyExists { CliError::Message(format!( "output {} already exists; pass --overwrite to replace it", path.display() )) } else { CliError::Io(error) } })?; fs::remove_file(&temp_path)?; } Ok(()) })(); if result.is_err() { let _ = fs::remove_file(&temp_path); } result } fn safe_temp_file_component(file_name: &std::ffi::OsStr) -> OsString { let sanitized: String = file_name .to_string_lossy() .chars() .map(|character| match character { '/' | '\\' | ':' | '\0' => '_', character => character, }) .collect(); OsString::from(sanitized) } #[cfg(test)] mod tests { use super::{ append_automatic_environment_keys, deduplicate_and_validate_key_values, read_limited_file, write_atomic_private, MAX_LEGACY_V1_KEY_CANDIDATES, MAX_V2_KEY_CANDIDATES, }; #[cfg(unix)] fn unix_test_directory(prefix: &str) -> std::path::PathBuf { std::fs::canonicalize(std::env::temp_dir()) .expect("system temporary directory should canonicalize") .join(format!("{prefix}-{}", uuid::Uuid::new_v4())) } #[test] fn automatic_key_environment_priority_matches_backup_generation() { let mut requested = Vec::new(); let mut values = Vec::new(); append_automatic_environment_keys(&mut values, |env_name| { requested.push(env_name.to_string()); Some(format!("secret-for-{env_name}")) }) .expect("automatic keys should load"); assert_eq!( requested, vec![ "AETHER_BACKUP_ENCRYPTION_KEY", "AETHER_GATEWAY_DATA_ENCRYPTION_KEY", "ENCRYPTION_KEY", ] ); assert_eq!( values, vec![ ("secret-for-AETHER_BACKUP_ENCRYPTION_KEY".to_string(), false,), ( "secret-for-AETHER_GATEWAY_DATA_ENCRYPTION_KEY".to_string(), true, ), ("secret-for-ENCRYPTION_KEY".to_string(), true), ] ); } #[test] fn candidate_deduplication_preserves_priority_and_promotes_legacy_access() { let candidates = deduplicate_and_validate_key_values(vec![ ("backup-current".to_string(), false), ("gateway-fallback".to_string(), true), ("default-fallback".to_string(), true), ("backup-current".to_string(), true), ]) .expect("candidate set should be valid"); assert_eq!( candidates, vec![ ("backup-current".to_string(), true), ("gateway-fallback".to_string(), true), ("default-fallback".to_string(), true), ] ); } #[test] fn rejects_too_many_v2_candidates_before_key_construction() { let values = (0..=MAX_V2_KEY_CANDIDATES) .map(|index| (format!("v2-key-{index}"), false)) .collect(); let error = deduplicate_and_validate_key_values(values) .expect_err("candidate count above the restore limit must fail"); assert_eq!( error.to_string(), format!("backup restore allows at most {MAX_V2_KEY_CANDIDATES} v2 candidate keys") ); } #[test] fn accepts_candidate_counts_at_both_restore_limits() { let values = (0..MAX_LEGACY_V1_KEY_CANDIDATES) .map(|index| (format!("legacy-key-{index}"), true)) .chain( (MAX_LEGACY_V1_KEY_CANDIDATES..MAX_V2_KEY_CANDIDATES) .map(|index| (format!("v2-key-{index}"), false)), ) .collect(); let candidates = deduplicate_and_validate_key_values(values) .expect("candidate counts at the restore limits must be accepted"); assert_eq!(candidates.len(), MAX_V2_KEY_CANDIDATES); assert_eq!( candidates.iter().filter(|(_, allow_v1)| *allow_v1).count(), MAX_LEGACY_V1_KEY_CANDIDATES ); } #[test] fn rejects_too_many_legacy_candidates_before_key_construction() { let values = (0..=MAX_LEGACY_V1_KEY_CANDIDATES) .map(|index| (format!("legacy-key-{index}"), true)) .collect(); let error = deduplicate_and_validate_key_values(values) .expect_err("legacy candidate count above the restore limit must fail"); assert_eq!( error.to_string(), format!( "legacy v1 backup restore allows at most {MAX_LEGACY_V1_KEY_CANDIDATES} candidate keys" ) ); } #[cfg(unix)] #[test] fn encrypted_input_symbolic_links_are_rejected() { use std::os::unix::fs::symlink; let directory = unix_test_directory("aether-backup-restore-symlink-test"); std::fs::create_dir(&directory).expect("test directory should be created"); let target = directory.join("backup.bin"); let link = directory.join("backup-link.bin"); std::fs::write(&target, b"encrypted-backup").expect("test target should be written"); symlink(&target, &link).expect("test symlink should be created"); let error = read_limited_file(&link, 1024, true, false) .expect_err("encrypted backup symlink must be rejected"); assert!(error.to_string().contains("must not be a symbolic link")); std::fs::remove_dir_all(directory).expect("test directory should be removed"); } #[cfg(unix)] #[test] fn encrypted_input_symbolic_link_ancestors_are_rejected() { use std::os::unix::fs::symlink; let directory = unix_test_directory("aether-backup-restore-ancestor-test"); let real_parent = directory.join("real-parent"); let linked_parent = directory.join("linked-parent"); std::fs::create_dir_all(&real_parent).expect("real parent should be created"); std::fs::write(real_parent.join("backup.bin"), b"encrypted-backup") .expect("test input should be written"); symlink(&real_parent, &linked_parent).expect("parent symlink should be created"); read_limited_file(&linked_parent.join("backup.bin"), 1024, true, false) .expect_err("a symbolic-link ancestor must be rejected"); std::fs::remove_dir_all(directory).expect("test directory should be removed"); } #[cfg(unix)] #[test] fn atomic_private_output_rejects_symbolic_link_ancestors() { use std::os::unix::fs::symlink; let directory = unix_test_directory("aether-backup-output-ancestor-test"); let real_parent = directory.join("real-parent"); let linked_parent = directory.join("linked-parent"); std::fs::create_dir_all(&real_parent).expect("real parent should be created"); symlink(&real_parent, &linked_parent).expect("parent symlink should be created"); write_atomic_private(&linked_parent.join("restored.json"), b"{}", false) .expect_err("output through a symbolic-link ancestor must be rejected"); assert!(!real_parent.join("restored.json").exists()); std::fs::remove_dir_all(directory).expect("test directory should be removed"); } #[cfg(unix)] #[test] fn atomic_private_output_is_mode_0600_and_preserves_no_overwrite() { use std::os::unix::fs::PermissionsExt; let directory = unix_test_directory("aether-backup-output-mode-test"); std::fs::create_dir(&directory).expect("test directory should be created"); let output = directory.join("restored.json"); write_atomic_private(&output, b"first", false).expect("first output should be written"); assert_eq!( std::fs::metadata(&output) .expect("output metadata should load") .permissions() .mode() & 0o777, 0o600 ); write_atomic_private(&output, b"second", false) .expect_err("no-overwrite mode must preserve an existing output"); assert_eq!( std::fs::read(&output).expect("output should remain readable"), b"first" ); write_atomic_private(&output, b"second", true) .expect("overwrite mode should atomically replace a regular output"); assert_eq!( std::fs::read(&output).expect("replaced output should be readable"), b"second" ); std::fs::remove_dir_all(directory).expect("test directory should be removed"); } #[cfg(unix)] #[test] fn secret_files_require_current_owner_and_private_permissions() { use std::os::unix::fs::PermissionsExt; let directory = unix_test_directory("aether-backup-secret-permissions-test"); std::fs::create_dir(&directory).expect("test directory should be created"); let secret_file = directory.join("backup.key"); std::fs::write(&secret_file, b"private-backup-key").expect("test secret should be written"); std::fs::set_permissions(&secret_file, std::fs::Permissions::from_mode(0o600)) .expect("test secret permissions should be private"); assert_eq!( read_limited_file(&secret_file, 1024, true, true) .expect("current-user-owned private secret should load"), b"private-backup-key" ); std::fs::set_permissions(&secret_file, std::fs::Permissions::from_mode(0o640)) .expect("test secret permissions should become group-readable"); assert!(read_limited_file(&secret_file, 1024, true, true) .expect_err("group-readable secret must be rejected") .to_string() .contains("accessible by group or other users")); std::fs::remove_dir_all(directory).expect("test directory should be removed"); } } #[cfg(windows)] fn replace_output_file(temp_path: &Path, path: &Path) -> Result<(), CliError> { if fs::symlink_metadata(path).is_ok() { return Err(CliError::Message( "--overwrite cannot atomically replace an existing file on Windows; choose a new output path" .to_string(), )); } fs::rename(temp_path, path).map_err(CliError::from) } #[cfg(not(any(unix, windows)))] fn replace_output_file(temp_path: &Path, path: &Path) -> Result<(), CliError> { if fs::symlink_metadata(path).is_ok() { return Err(CliError::Message( "--overwrite is unsupported on this platform; choose a new output path".to_string(), )); } fs::rename(temp_path, path).map_err(CliError::from) }