use super::archive::{calculate_file_hash, create_rcman_container, create_zip_archive};
use super::types::{
BackupAnalysis, BackupContents, BackupManifest, ExternalConfigProvider,
SubSettingsManifestEntry,
};
use crate::backup::{BackupInfo, BackupIntegrity};
use crate::config::SettingsSchema;
use crate::error::{Error, Result};
use crate::manager::SettingsManager;
use crate::storage::StorageBackend;
use crate::utils::sync::RwLockExt;
use crate::{BackupOptions, ExportType};
use log::{debug, info, warn};
use std::fs;
use std::path::{Path, PathBuf};
use time::{OffsetDateTime, macros::format_description};
#[cfg(feature = "profiles")]
use crate::profiles::PROFILES_DIR;
type ExternalGatherResult = (
u64,
u32,
Vec<String>,
std::collections::HashMap<String, String>,
);
struct SecretContext<'a> {
prefix: &'a str,
metadata: &'a std::collections::HashMap<String, crate::SettingMetadata>,
should_include: bool,
credential_profile: Option<&'a str>,
}
#[cfg_attr(not(feature = "profiles"), allow(unused_variables))]
fn collect_settings_files<S: StorageBackend, Schema: SettingsSchema>(
config: &crate::config::SettingsConfig<S, Schema>,
options: &BackupOptions,
) -> Vec<(PathBuf, PathBuf)> {
let mut files = Vec::new();
#[cfg(feature = "profiles")]
if config.profiles_enabled {
let ext = config.storage.extension();
let manifest_filename = format!(".profiles.{ext}");
let manifest = config.config_dir.join(&manifest_filename);
if manifest.exists() {
files.push((manifest, PathBuf::from(&manifest_filename)));
}
let profiles_dir = config.config_dir.join(PROFILES_DIR);
if let Ok(entries) = fs::read_dir(&profiles_dir) {
for entry in entries.flatten() {
let profile_name = entry.file_name().to_string_lossy().to_string();
#[cfg(feature = "profiles")]
if !options.include_profiles.is_empty()
&& !options.include_profiles.contains(&profile_name)
{
continue;
}
let settings_file = entry.path().join(&config.settings_file);
if settings_file.exists() {
let dest = PathBuf::from(PROFILES_DIR)
.join(&profile_name)
.join(&config.settings_file);
files.push((settings_file, dest));
}
}
}
return files;
}
let settings_path = config.settings_path();
if settings_path.exists() {
files.push((settings_path, PathBuf::from(&config.settings_file)));
}
files
}
pub struct BackupManager<'a, S: StorageBackend + 'static, Schema: SettingsSchema = ()> {
pub(crate) manager: &'a SettingsManager<S, Schema>,
}
impl<'a, S: StorageBackend + 'static, Schema: SettingsSchema> BackupManager<'a, S, Schema> {
pub fn new(manager: &'a SettingsManager<S, Schema>) -> Self {
Self { manager }
}
pub fn register_external_provider(&self, provider: Box<dyn ExternalConfigProvider>) {
self.manager.register_external_provider(provider);
}
pub fn create(&self, options: &BackupOptions) -> Result<PathBuf> {
info!("Creating backup with options: {:?}", options.export_type);
let password = validate_password(options.password.clone())?;
let temp_dir = tempfile::tempdir().map_err(|e| Error::BackupFailed(e.to_string()))?;
let export_dir = temp_dir.path().join("export");
crate::utils::security::ensure_secure_dir(&export_dir)?;
let (contents, total_size) = self.gather_files(&export_dir, options)?;
let data_filename = "data.zip";
let inner_archive_path = temp_dir.path().join(data_filename);
create_zip_archive(
&export_dir,
&inner_archive_path,
options.on_progress.clone(),
total_size,
password.as_deref(),
)?;
let (checksum, _) = calculate_file_hash(&inner_archive_path)?;
let manifest = BackupManifest {
version: 1,
backup: BackupInfo {
app_name: self.manager.config().app_name.clone(),
app_version: self.manager.config().app_version.clone(),
created_at: OffsetDateTime::now_utc(),
export_type: options.export_type.clone(),
encrypted: password.is_some(),
user_note: options.user_note.clone(),
secret_policy: Some(options.secret_policy.clone()),
},
contents,
integrity: BackupIntegrity {
sha256: Some(checksum),
size_bytes: total_size,
compressed_size_bytes: fs::metadata(&inner_archive_path).ok().map(|m| m.len()),
},
};
let manifest_json = serde_json::to_string_pretty(&manifest)
.map_err(|e| Error::BackupFailed(e.to_string()))?;
let now = OffsetDateTime::now_utc();
let timestamp_format = format_description!("[year][month][day]_[hour][minute][second]");
let timestamp = now
.format(×tamp_format)
.unwrap_or_else(|_| "unknown".to_string());
let filename = if let Some(suffix) = &options.filename_suffix {
format!(
"{}_{}_{}.rcman",
self.manager.config().app_name,
timestamp,
sanitize_filename(suffix)
)
} else {
match &options.export_type {
ExportType::Full => format!(
"{}_{}_full.rcman",
self.manager.config().app_name,
timestamp
),
ExportType::SettingsOnly => {
let suffix =
if !options.include_settings && options.include_sub_settings.len() == 1 {
&options.include_sub_settings[0]
} else {
"settings"
};
format!(
"{}_{}_{}.rcman",
self.manager.config().app_name,
timestamp,
suffix
)
}
ExportType::Single { name, .. } => {
format!(
"{}_{}_{}.rcman",
self.manager.config().app_name,
sanitize_filename(name),
timestamp
)
}
}
};
let output_path = options.output_dir.join(&filename);
fs::create_dir_all(&options.output_dir).map_err(|e| Error::DirectoryCreate {
path: options.output_dir.clone(),
source: e,
})?;
create_rcman_container(
&output_path,
&manifest_json,
"manifest.json",
&inner_archive_path,
data_filename,
)?;
info!("Backup created: {:?}", output_path.display());
Ok(output_path)
}
fn gather_files(
&self,
export_dir: &Path,
options: &BackupOptions,
) -> Result<(BackupContents, u64)> {
let mut contents = BackupContents::default();
let mut total_size = 0u64;
let include_settings = (options.include_settings
|| matches!(options.export_type, ExportType::Full))
&& !matches!(options.export_type, ExportType::Single { .. });
if include_settings {
let (size, count) = self.gather_main_settings(export_dir, options)?;
total_size += size;
contents.file_count += count;
contents.settings = true;
}
let sub_settings_to_backup = match &options.export_type {
ExportType::Full => {
if options.include_sub_settings.is_empty() {
self.manager.sub_settings_types()
} else {
options.include_sub_settings.clone()
}
}
ExportType::SettingsOnly => options.include_sub_settings.clone(),
ExportType::Single {
settings_type,
name,
} => {
if let Ok(sub) = self.manager.sub_settings(settings_type) {
let sub_export_dir = export_dir.join(settings_type);
crate::error::create_dir(&sub_export_dir)?;
let value: serde_json::Value = sub.get_value(name)?;
let ext = self.manager.storage().extension();
let dest = sub_export_dir.join(format!("{name}.{ext}"));
let content = self.manager.storage().serialize(&value)?;
crate::error::write_file(&dest, &content)?;
total_size += content.len() as u64;
contents.file_count += 1;
contents.sub_settings.insert(
settings_type.clone(),
SubSettingsManifestEntry::MultiFile(vec![name.clone()]),
);
debug!("Added single entry: {settings_type}/{name}");
}
Vec::new()
}
};
for sub_type in sub_settings_to_backup {
if let Ok(sub) = self.manager.sub_settings(&sub_type) {
let (size, count, manifest_entry) = self.gather_sub_settings(
export_dir,
&sub_type,
&sub,
self.manager.storage(),
options,
)?;
total_size += size;
contents.file_count += count;
if let Some(entry) = manifest_entry {
contents.sub_settings.insert(sub_type, entry);
}
}
}
let include_external_configs = matches!(options.export_type, ExportType::Full)
|| !options.include_external_configs.is_empty();
if include_external_configs {
let (size, count, configs, config_files) =
self.gather_external_configs(export_dir, options)?;
total_size += size;
contents.file_count += count;
contents.external_configs = configs;
contents.external_config_files = config_files;
}
Ok((contents, total_size))
}
fn gather_main_settings(
&self,
export_dir: &Path,
options: &BackupOptions,
) -> Result<(u64, u32)> {
use std::collections::HashSet;
let mut total_size = 0u64;
let mut file_count = 0u32;
let settings_files = collect_settings_files(self.manager.config(), options);
let metadata = Schema::get_metadata();
let mut backed_up_profile_settings = HashSet::new();
for (src, dest) in settings_files {
if let Some(profile) = Self::profile_from_backup_dest(&dest) {
backed_up_profile_settings.insert(profile.to_string());
}
let full_dest = export_dir.join(&dest);
if let Some(parent) = full_dest.parent() {
crate::error::create_dir(parent)?;
}
let credential_profile = Self::profile_from_backup_dest(&dest);
let should_include_secrets = match options.secret_policy {
crate::SecretBackupPolicy::Exclude => false,
crate::SecretBackupPolicy::Include => true,
crate::SecretBackupPolicy::EncryptedOnly => options.password.is_some(),
};
let ctx = SecretContext {
prefix: "",
metadata: &metadata,
should_include: should_include_secrets,
credential_profile,
};
let size = self.process_and_save_settings(&src, &full_dest, &ctx)?;
total_size += size;
file_count += 1;
debug!("Added settings file: {}", dest.display());
}
#[cfg(feature = "profiles")]
if self.manager.config().profiles_enabled {
let profiles_dir = self.manager.config().config_dir.join(PROFILES_DIR);
if let Ok(entries) = crate::error::read_dir(&profiles_dir) {
for entry in entries.flatten() {
let profile_name = entry.file_name().to_string_lossy().to_string();
if !options.include_profiles.is_empty()
&& !options.include_profiles.contains(&profile_name)
{
continue;
}
if backed_up_profile_settings.contains(&profile_name) {
continue;
}
let relative_dest = PathBuf::from(PROFILES_DIR)
.join(&profile_name)
.join(&self.manager.config().settings_file);
if let Some(size) = self.write_synthesized_settings_file(
export_dir,
&relative_dest,
options,
Some(profile_name.as_str()),
)? {
total_size += size;
file_count += 1;
debug!(
"Added synthesized profile settings file for profile: {profile_name}"
);
}
}
}
return Ok((total_size, file_count));
}
if total_size == 0 && file_count == 0 {
let relative_dest = PathBuf::from(&self.manager.config().settings_file);
if let Some(size) =
self.write_synthesized_settings_file(export_dir, &relative_dest, options, None)?
{
total_size += size;
file_count += 1;
debug!(
"Added synthesized settings file: {}",
self.manager.config().settings_file
);
}
}
Ok((total_size, file_count))
}
fn write_synthesized_settings_file(
&self,
export_dir: &Path,
relative_dest: &Path,
options: &BackupOptions,
credential_profile: Option<&str>,
) -> Result<Option<u64>> {
let should_include_secrets = match options.secret_policy {
crate::SecretBackupPolicy::Exclude => false,
crate::SecretBackupPolicy::Include => true,
crate::SecretBackupPolicy::EncryptedOnly => options.password.is_some(),
};
let mut value = self.manager.get_all_data()?;
self.inject_or_remove_secrets(
&mut value,
"",
&Schema::get_metadata(),
should_include_secrets,
credential_profile,
);
let content = self.manager.storage().serialize(&value)?;
let full_dest = export_dir.join(relative_dest);
if let Some(parent) = full_dest.parent() {
crate::error::create_dir(parent)?;
}
crate::error::write_file(&full_dest, &content)?;
Ok(Some(content.len() as u64))
}
fn process_and_save_settings(
&self,
src: &Path,
dest: &Path,
ctx: &SecretContext<'_>,
) -> Result<u64> {
let content = std::fs::read(src).map_err(|e| Error::FileRead {
path: src.to_path_buf(),
source: e,
})?;
let content_str = String::from_utf8(content).map_err(|e| Error::FileRead {
path: src.to_path_buf(),
source: std::io::Error::new(std::io::ErrorKind::InvalidData, e),
})?;
let storage = &self.manager.config().storage;
let mut value: serde_json::Value = storage.deserialize(&content_str)?;
self.inject_or_remove_secrets(
&mut value,
ctx.prefix,
ctx.metadata,
ctx.should_include,
ctx.credential_profile,
);
let serialized = storage.serialize(&value)?;
crate::error::write_file(dest, &serialized)?;
Ok(serialized.len() as u64)
}
fn inject_or_remove_secrets(
&self,
value: &mut serde_json::Value,
prefix: &str,
metadata: &std::collections::HashMap<String, crate::SettingMetadata>,
should_include: bool,
#[allow(unused_variables)] credential_profile: Option<&str>,
) {
#[cfg(any(feature = "keychain", feature = "encrypted-file"))]
let creds_opt = self.manager.credentials();
for (full_key, meta) in metadata {
if !meta.is_secret() {
continue;
}
let relative_key = if prefix.is_empty() {
full_key.as_str()
} else {
full_key.as_str()
};
if should_include {
#[cfg(any(feature = "keychain", feature = "encrypted-file"))]
if let Some(creds) = creds_opt {
let credential_key = if prefix.is_empty() {
full_key.clone()
} else {
format!("{prefix}.{full_key}")
};
match creds.get_with_profile(&credential_key, credential_profile) {
Ok(Some(secret)) => {
crate::utils::value::set_path(
value,
relative_key,
serde_json::Value::String(secret),
);
}
Ok(None) => {}
Err(err) => {
debug!(
"Failed to fetch secret '{relative_key}' (credential_key: '{credential_key}'): {err}"
);
}
}
}
} else {
crate::utils::value::remove_path(value, relative_key);
}
}
}
fn profile_from_backup_dest(dest: &Path) -> Option<&str> {
#[cfg(feature = "profiles")]
{
let mut components = dest.components();
let first = components.next()?.as_os_str().to_str()?;
if first != PROFILES_DIR {
return None;
}
components.next()?.as_os_str().to_str()
}
#[cfg(not(feature = "profiles"))]
{
let _ = dest;
None
}
}
fn gather_sub_settings(
&self,
export_dir: &Path,
sub_type: &str,
sub: &crate::sub_settings::SubSettings<S>,
storage: &S,
options: &BackupOptions,
) -> Result<(u64, u32, Option<SubSettingsManifestEntry>)> {
#[cfg(feature = "profiles")]
let profiles_enabled = sub.profiles_enabled();
#[cfg(not(feature = "profiles"))]
let profiles_enabled = false;
if profiles_enabled {
#[cfg(feature = "profiles")]
return Self::gather_profiled_sub_settings(export_dir, sub_type, sub, storage, options);
#[cfg(not(feature = "profiles"))]
unreachable!()
}
let sub_export_dir = export_dir.join(sub_type);
let sub_metadata = sub
.schema_metadata()
.unwrap_or_else(|| std::sync::Arc::new(std::collections::HashMap::new()));
if sub.is_single_file() {
if let Some(path) = sub.file_path()
&& path.exists()
{
let ext = sub.extension();
let dest = export_dir.join(format!("{sub_type}.{ext}"));
if let Some(parent) = dest.parent() {
crate::error::create_dir(parent)?;
}
let raw = std::fs::read(&path).map_err(|e| Error::FileRead {
path: path.clone(),
source: e,
})?;
let raw_str = String::from_utf8(raw).map_err(|e| Error::FileRead {
path: path.clone(),
source: std::io::Error::new(std::io::ErrorKind::InvalidData, e),
})?;
let storage_impl = &self.manager.config().storage;
let mut root_value: serde_json::Value = storage_impl.deserialize(&raw_str)?;
let should_include_secrets = match options.secret_policy {
crate::SecretBackupPolicy::Exclude => false,
crate::SecretBackupPolicy::Include => true,
crate::SecretBackupPolicy::EncryptedOnly => options.password.is_some(),
};
if let Some(obj) = root_value.as_object_mut() {
for (entry_name, entry_value) in obj.iter_mut() {
let credential_key_prefix = format!("sub.{sub_type}.{entry_name}");
self.inject_or_remove_secrets(
entry_value,
&credential_key_prefix,
&sub_metadata,
should_include_secrets,
None,
);
}
}
let content = storage_impl.serialize(&root_value)?;
crate::error::write_file(&dest, &content)?;
let size = content.len() as u64;
debug!("📄 Added single-file sub-settings: {sub_type}");
return Ok((
size,
1,
Some(SubSettingsManifestEntry::SingleFile(format!(
"{sub_type}.{ext}",
))),
));
}
Ok((0, 0, None))
} else {
crate::error::create_dir(&sub_export_dir)?;
let mut total_size = 0u64;
let mut items = Vec::new();
for name in sub.list()? {
if let Ok(mut value) = sub.get_value(&name) {
let ext = sub.extension();
let dest = sub_export_dir.join(format!("{name}.{ext}"));
let should_include_secrets = match options.secret_policy {
crate::SecretBackupPolicy::Exclude => false,
crate::SecretBackupPolicy::Include => true,
crate::SecretBackupPolicy::EncryptedOnly => options.password.is_some(),
};
let credential_key_prefix = format!("sub.{sub_type}.{name}");
self.inject_or_remove_secrets(
&mut value,
&credential_key_prefix,
&sub_metadata,
should_include_secrets,
None,
);
let content = storage.serialize(&value)?;
crate::error::write_file(&dest, &content)?;
total_size += content.len() as u64;
items.push(name);
}
}
let count = u32::try_from(items.len()).unwrap_or(u32::MAX);
debug!("Added sub-settings directory: {sub_type}");
Ok((
total_size,
count,
Some(SubSettingsManifestEntry::MultiFile(items)),
))
}
}
#[cfg(feature = "profiles")]
fn gather_profiled_sub_settings(
export_dir: &Path,
sub_type: &str,
sub: &crate::sub_settings::SubSettings<S>,
storage: &S,
options: &BackupOptions,
) -> Result<(u64, u32, Option<SubSettingsManifestEntry>)> {
let sub_export_dir = export_dir.join(sub_type);
let mut total_size = 0u64;
let mut file_count = 0u32;
let root_path = sub.root_path();
let ext = storage.extension();
let manifest_filename = format!(".profiles.{ext}");
let profiles_manifest = root_path.join(&manifest_filename);
if profiles_manifest.exists() {
crate::error::create_dir(&sub_export_dir)?;
let dest = sub_export_dir.join(&manifest_filename);
crate::error::copy_file(&profiles_manifest, &dest)?;
total_size += crate::error::file_size(&dest);
file_count += 1;
}
let profiles_dir = root_path.join(PROFILES_DIR);
if !profiles_dir.exists() {
return Ok((total_size, file_count, None));
}
let dest_profiles_dir = sub_export_dir.join(PROFILES_DIR);
crate::error::create_dir(&dest_profiles_dir)?;
let mut profile_items: std::collections::HashMap<String, Vec<String>> =
std::collections::HashMap::new();
for entry in crate::error::read_dir(&profiles_dir)? {
let entry = entry.map_err(|e| Error::DirectoryRead {
path: profiles_dir.clone(),
source: e,
})?;
let profile_name = entry.file_name().to_string_lossy().to_string();
if !options.include_profiles.is_empty()
&& !options.include_profiles.contains(&profile_name)
{
continue;
}
let profile_path = entry.path();
let profile_export_dir = dest_profiles_dir.join(&profile_name);
crate::error::create_dir(&profile_export_dir)?;
let mut items_in_profile = Vec::new();
if let Ok(profile_entries) = fs::read_dir(&profile_path) {
for item_entry in profile_entries.flatten() {
let path = item_entry.path();
if path.extension().and_then(|s| s.to_str()) == Some(storage.extension()) {
let dest = profile_export_dir.join(item_entry.file_name());
crate::error::copy_file(&path, &dest)?;
total_size += crate::error::file_size(&dest);
file_count += 1;
if let Some(item_name) = path.file_stem().and_then(|s| s.to_str()) {
items_in_profile.push(item_name.to_string());
}
}
}
}
if !items_in_profile.is_empty() {
profile_items.insert(profile_name, items_in_profile);
}
}
let manifest_entry = if profile_items.is_empty() {
None
} else {
let profiles_map = profile_items
.into_iter()
.map(|(profile_name, items)| {
let entry = if items.len() == 1 {
let mut single_item = items;
let item = single_item.pop().ok_or_else(|| {
Error::BackupFailed(
"Expected single profile item but none found".to_string(),
)
})?;
super::types::ProfileEntry::Single(item)
} else {
super::types::ProfileEntry::Multiple(items)
};
Ok((profile_name, entry))
})
.collect::<Result<_>>()?;
Some(SubSettingsManifestEntry::Profiled {
profiles: profiles_map,
})
};
Ok((total_size, file_count, manifest_entry))
}
fn gather_external_configs(
&self,
export_dir: &Path,
options: &BackupOptions,
) -> Result<ExternalGatherResult> {
let providers = self.manager.external_providers.read_recovered()?;
let mut all_configs = Vec::new();
all_configs.extend(self.manager.config().external_configs.clone());
for provider in providers.iter() {
all_configs.extend(provider.get_configs());
}
let external_dir = export_dir.join("external");
let mut total_size = 0u64;
let mut file_count = 0u32;
let mut config_ids = Vec::new();
let mut config_files = std::collections::HashMap::new();
let mut seen_config_ids = std::collections::HashSet::new();
for config in all_configs {
let include_all_for_full = matches!(options.export_type, ExportType::Full)
&& options.include_external_configs.is_empty();
if !include_all_for_full && !options.include_external_configs.contains(&config.id) {
continue;
}
if !seen_config_ids.insert(config.id.clone()) {
warn!(
"Skipping duplicate external config id: {} (keeping first occurrence)",
config.id
);
continue;
}
if !config.exists() {
debug!("Skipping non-existent external config: {}", config.id);
continue;
}
crate::error::create_dir(&external_dir)?;
let dest = external_dir.join(&config.archive_filename);
match &config.export_source {
super::types::ExportSource::File(path) => {
crate::error::copy_file(path, &dest)?;
}
super::types::ExportSource::Command { program, args } => {
let output = std::process::Command::new(program)
.args(args)
.output()
.map_err(|e| {
Error::BackupFailed(format!("Failed to run command '{program}': {e}"))
})?;
if !output.status.success() {
return Err(Error::BackupFailed(format!(
"Command '{program}' failed with exit code {:?}",
output.status.code()
)));
}
crate::error::write_file(&dest, &output.stdout)?;
}
super::types::ExportSource::Content(bytes) => {
crate::error::write_file(&dest, bytes)?;
}
}
total_size += crate::error::file_size(&dest);
file_count += 1;
config_ids.push(config.id.clone());
config_files.insert(config.id.clone(), config.archive_filename.clone());
debug!("Added external config: {}", config.id);
}
Ok((total_size, file_count, config_ids, config_files))
}
pub fn analyze(&self, path: &Path) -> Result<BackupAnalysis> {
if !path.exists() {
return Err(Error::PathNotFound(path.display().to_string()));
}
let manifest_bytes = super::archive::read_file_from_zip(path, "manifest.json")?;
let manifest_str = String::from_utf8(manifest_bytes).map_err(|e| {
Error::InvalidBackup(format!(
"{}: Invalid manifest encoding: {}",
path.display(),
e
))
})?;
let manifest: BackupManifest = serde_json::from_str(&manifest_str).map_err(|e| {
Error::InvalidBackup(format!("{}: Invalid manifest JSON: {}", path.display(), e))
})?;
let mut warnings = Vec::new();
let mut is_valid = true;
if !super::types::is_manifest_version_supported(manifest.version) {
warnings.push(format!(
"Backup manifest version {} is not supported (supported: {}-{})",
manifest.version,
super::types::MANIFEST_VERSION_MIN_SUPPORTED,
super::types::MANIFEST_VERSION_MAX_SUPPORTED
));
is_valid = false;
}
if manifest.backup.app_version != self.manager.config().app_version {
warnings.push(format!(
"Backup was created with app version {}, current version is {}",
manifest.backup.app_version,
self.manager.config().app_version
));
}
let data_bytes = super::archive::read_file_from_zip(path, "data.zip")?;
let temp_dir = tempfile::tempdir().map_err(|e| Error::BackupFailed(e.to_string()))?;
let data_archive_path = temp_dir.path().join("data.zip");
std::fs::write(&data_archive_path, data_bytes).map_err(|e| Error::FileWrite {
path: data_archive_path.clone(),
source: e,
})?;
let data_encrypted = super::archive::is_zip_encrypted(&data_archive_path)?;
if manifest.backup.encrypted != data_encrypted {
warnings.push(format!(
"Manifest claims encrypted={}, but data.zip encrypted={}",
manifest.backup.encrypted, data_encrypted
));
}
Ok(BackupAnalysis {
is_valid,
requires_password: data_encrypted,
warnings,
created_at: manifest
.backup
.created_at
.format(&time::format_description::well_known::Rfc3339)
.unwrap_or_default(),
backup_type: format_export_type(&manifest.backup.export_type),
is_encrypted: manifest.backup.encrypted,
format_version: manifest.version.to_string(),
user_note: manifest.backup.user_note.clone(),
manifest,
})
}
}
fn validate_password(password: Option<String>) -> Result<Option<String>> {
match password {
Some(p) if p.trim().is_empty() => Err(Error::BackupFailed(
"Password cannot be empty or whitespace-only".into(),
)),
Some(p) if p.len() < 4 => Err(Error::BackupFailed(
"Password must be at least 4 characters".into(),
)),
other => Ok(other),
}
}
fn sanitize_filename(name: &str) -> String {
name.chars()
.map(|c| match c {
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
c => c,
})
.collect()
}
fn format_export_type(export_type: &ExportType) -> String {
match export_type {
ExportType::Full => "Full Backup".into(),
ExportType::SettingsOnly => "Settings Only".into(),
ExportType::Single {
settings_type,
name,
} => format!("Single {settings_type}: {name}"),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::SettingsConfig;
use crate::sub_settings::SubSettingsConfig;
use serde_json::json;
use tempfile::tempdir;
#[test]
fn test_create_full_backup() {
let temp = tempdir().unwrap();
let config = SettingsConfig::builder("test-app", "1.0.0")
.with_config_dir(temp.path())
.build();
let manager = SettingsManager::new(config).unwrap();
fs::write(
temp.path().join("settings.json"),
r#"{"general": {"theme": "dark"}}"#,
)
.unwrap();
manager
.register_sub_settings(SubSettingsConfig::new("profiles"))
.unwrap();
let profiles = manager.sub_settings("profiles").unwrap();
profiles
.set("default", &json!({"name": "Default"}))
.unwrap();
let backup_dir = temp.path().join("backups");
let backup = manager.backup();
let backup_path = backup
.create(&BackupOptions {
output_dir: backup_dir.clone(),
export_type: ExportType::Full,
include_sub_settings: vec!["profiles".into()],
..Default::default()
})
.unwrap();
assert!(backup_path.exists());
assert!(backup_path.extension().unwrap() == "rcman");
}
#[test]
fn test_analyze_backup() {
let temp = tempdir().unwrap();
let config = SettingsConfig::builder("test-app", "1.0.0")
.with_config_dir(temp.path())
.build();
let manager = SettingsManager::new(config).unwrap();
fs::write(temp.path().join("settings.json"), "{}").unwrap();
let backup = manager.backup();
let backup_path = backup
.create(&BackupOptions {
output_dir: temp.path().join("backups"),
export_type: ExportType::SettingsOnly,
..Default::default()
})
.unwrap();
let analysis = backup.analyze(&backup_path).unwrap();
assert!(analysis.is_valid);
assert!(!analysis.requires_password);
assert_eq!(analysis.manifest.backup.app_name, "test-app");
}
#[test]
fn test_validate_password() {
assert!(validate_password(None).unwrap().is_none());
assert!(validate_password(Some("valid".into())).unwrap().is_some());
assert!(validate_password(Some(" ".into())).is_err());
assert!(validate_password(Some("abc".into())).is_err()); }
#[test]
fn test_partial_backup_logic() {
let temp = tempdir().unwrap();
let config = SettingsConfig::builder("test-app", "1.0.0")
.with_config_dir(temp.path())
.build();
let manager = SettingsManager::new(config).unwrap();
fs::write(
temp.path().join("settings.json"),
r#"{"general": {"theme": "dark"}}"#,
)
.unwrap();
manager
.register_sub_settings(SubSettingsConfig::new("profiles"))
.unwrap();
let profiles = manager.sub_settings("profiles").unwrap();
profiles
.set("default", &json!({"name": "Default"}))
.unwrap();
let backup_dir = temp.path().join("backups");
let backup = manager.backup();
let backup_path = backup
.create(&BackupOptions {
output_dir: backup_dir.clone(),
export_type: ExportType::SettingsOnly,
include_settings: false, include_sub_settings: vec!["profiles".into()], ..Default::default()
})
.unwrap();
let analysis = backup.analyze(&backup_path).unwrap();
assert!(analysis.is_valid);
assert!(
!analysis.manifest.contents.settings,
"Should NOT include settings.json"
);
assert!(
analysis
.manifest
.contents
.sub_settings
.contains_key("profiles"),
"Should include profiles"
);
}
#[test]
fn test_sanitize_filename() {
assert_eq!(sanitize_filename("normal"), "normal");
assert_eq!(sanitize_filename("with/slash"), "with_slash");
assert_eq!(sanitize_filename("file:name"), "file_name");
}
}