mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-10 11:19:50 +08:00
1048 lines
35 KiB
Rust
1048 lines
35 KiB
Rust
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<PathBuf>,
|
||
|
|
|
||
|
|
/// Structured JSON keyring file. May also be set with AETHER_BACKUP_KEYRING_FILE.
|
||
|
|
#[arg(long, env = "AETHER_BACKUP_KEYRING_FILE")]
|
||
|
|
keyring_file: Option<PathBuf>,
|
||
|
|
|
||
|
|
/// 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<KeyringSecret>,
|
||
|
|
#[serde(default)]
|
||
|
|
legacy_v1: Vec<KeyringSecret>,
|
||
|
|
}
|
||
|
|
|
||
|
|
#[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<usize, CliError> {
|
||
|
|
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<Vec<BackupDecryptionKey>, 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<String>,
|
||
|
|
) -> 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<Vec<(String, bool)>, CliError> {
|
||
|
|
let mut indexes = HashMap::<String, usize>::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<KeyringDocument, CliError> {
|
||
|
|
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<Vec<u8>, 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<File, CliError> {
|
||
|
|
#[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<File, CliError> {
|
||
|
|
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<File, CliError> {
|
||
|
|
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<std::ffi::CString, CliError> {
|
||
|
|
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<Option<libc::mode_t>, CliError> {
|
||
|
|
use std::mem::MaybeUninit;
|
||
|
|
use std::os::fd::AsRawFd;
|
||
|
|
|
||
|
|
let mut stat = MaybeUninit::<libc::stat>::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)
|
||
|
|
}
|