mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-07 18:07:47 +08:00
212 lines
6.9 KiB
Rust
212 lines
6.9 KiB
Rust
use std::fmt;
|
|||
|
|
|
||
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
|
|
pub(crate) enum BackupScope {
|
||
|
|
Config,
|
||
|
|
Users,
|
||
|
|
Data,
|
||
|
|
}
|
||
|
|
|
||
|
|
impl BackupScope {
|
||
|
|
pub(crate) fn from_config_value(value: &str) -> Option<Self> {
|
||
|
|
match value.trim() {
|
||
|
|
"config" => Some(Self::Config),
|
||
|
|
"users" => Some(Self::Users),
|
||
|
|
"data" => Some(Self::Data),
|
||
|
|
_ => None,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
pub(crate) fn as_config_value(self) -> &'static str {
|
||
|
|
match self {
|
||
|
|
Self::Config => "config",
|
||
|
|
Self::Users => "users",
|
||
|
|
Self::Data => "data",
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
pub(crate) fn route_kind(self) -> &'static str {
|
||
|
|
match self {
|
||
|
|
Self::Config => "config_export",
|
||
|
|
Self::Users => "users_export",
|
||
|
|
Self::Data => "data_export",
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
pub(crate) fn file_stem(self) -> &'static str {
|
||
|
|
match self {
|
||
|
|
Self::Config => "aether-config-backup",
|
||
|
|
Self::Users => "aether-users-backup",
|
||
|
|
Self::Data => "aether-data-backup",
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
pub(crate) fn object_key(self, prefix: &str, timestamp: &str) -> String {
|
||
|
|
let file_name = self.file_name(timestamp);
|
||
|
|
let prefix = normalized_prefix(prefix);
|
||
|
|
|
||
|
|
if prefix.is_empty() {
|
||
|
|
file_name
|
||
|
|
} else {
|
||
|
|
format!("{prefix}/{file_name}")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
pub(crate) fn matching_backup_keys(
|
||
|
|
self,
|
||
|
|
prefix: &str,
|
||
|
|
keys: impl IntoIterator<Item = String>,
|
||
|
|
) -> Vec<String> {
|
||
|
|
let normalized_prefix = normalized_prefix(prefix);
|
||
|
|
let expected_prefix = if normalized_prefix.is_empty() {
|
||
|
|
String::new()
|
||
|
|
} else {
|
||
|
|
format!("{normalized_prefix}/")
|
||
|
|
};
|
||
|
|
let file_prefix = format!("{}-", self.file_stem());
|
||
|
|
let file_suffix = ".json.zst";
|
||
|
|
|
||
|
|
keys.into_iter()
|
||
|
|
.filter(|key| {
|
||
|
|
let Some(file_name) = key.strip_prefix(&expected_prefix) else {
|
||
|
|
return false;
|
||
|
|
};
|
||
|
|
if file_name.contains('/') {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
let Some(timestamp) = file_name
|
||
|
|
.strip_prefix(&file_prefix)
|
||
|
|
.and_then(|rest| rest.strip_suffix(file_suffix))
|
||
|
|
else {
|
||
|
|
return false;
|
||
|
|
};
|
||
|
|
|
||
|
|
is_aether_backup_timestamp(timestamp)
|
||
|
|
})
|
||
|
|
.collect()
|
||
|
|
}
|
||
|
|
|
||
|
|
fn file_name(self, timestamp: &str) -> String {
|
||
|
|
format!("{}-{timestamp}.json.zst", self.file_stem())
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
impl fmt::Display for BackupScope {
|
||
|
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||
|
|
formatter.write_str(self.as_config_value())
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
fn normalized_prefix(prefix: &str) -> &str {
|
||
|
|
prefix.trim_end_matches('/')
|
||
|
|
}
|
||
|
|
|
||
|
|
fn is_aether_backup_timestamp(timestamp: &str) -> bool {
|
||
|
|
let bytes = timestamp.as_bytes();
|
||
|
|
|
||
|
|
bytes.len() == 15
|
||
|
|
&& bytes[8] == b'-'
|
||
|
|
&& bytes[..8].iter().all(|byte| byte.is_ascii_digit())
|
||
|
|
&& bytes[9..].iter().all(|byte| byte.is_ascii_digit())
|
||
|
|
}
|
||
|
|
|
||
|
|
#[cfg(test)]
|
||
|
|
mod tests {
|
||
|
|
use super::BackupScope;
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn backup_scope_matches_export_routes_and_object_prefixes() {
|
||
|
|
assert_eq!(BackupScope::Config.as_config_value(), "config");
|
||
|
|
assert_eq!(BackupScope::Users.as_config_value(), "users");
|
||
|
|
assert_eq!(BackupScope::Data.as_config_value(), "data");
|
||
|
|
|
||
|
|
assert_eq!(BackupScope::Config.route_kind(), "config_export");
|
||
|
|
assert_eq!(BackupScope::Users.route_kind(), "users_export");
|
||
|
|
assert_eq!(BackupScope::Data.route_kind(), "data_export");
|
||
|
|
|
||
|
|
assert_eq!(BackupScope::Config.file_stem(), "aether-config-backup");
|
||
|
|
assert_eq!(BackupScope::Users.file_stem(), "aether-users-backup");
|
||
|
|
assert_eq!(BackupScope::Data.file_stem(), "aether-data-backup");
|
||
|
|
|
||
|
|
assert_eq!(
|
||
|
|
BackupScope::Config.object_key("prod/", "20260524-031500"),
|
||
|
|
"prod/aether-config-backup-20260524-031500.json.zst"
|
||
|
|
);
|
||
|
|
assert_eq!(
|
||
|
|
BackupScope::Users.object_key("prod/", "20260524-031500"),
|
||
|
|
"prod/aether-users-backup-20260524-031500.json.zst"
|
||
|
|
);
|
||
|
|
assert_eq!(
|
||
|
|
BackupScope::Data.object_key("prod/", "20260524-031500"),
|
||
|
|
"prod/aether-data-backup-20260524-031500.json.zst"
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn retention_filter_only_matches_same_scope() {
|
||
|
|
let keys = vec![
|
||
|
|
"prod/aether-config-backup-20260524-010000.json.zst".to_string(),
|
||
|
|
"prod/aether-users-backup-20260524-010000.json.zst".to_string(),
|
||
|
|
"prod/aether-data-backup-20260524-010000.json.zst".to_string(),
|
||
|
|
"prod/random.json.zst".to_string(),
|
||
|
|
];
|
||
|
|
|
||
|
|
let matched = BackupScope::Users.matching_backup_keys("prod/", keys);
|
||
|
|
|
||
|
|
assert_eq!(
|
||
|
|
matched,
|
||
|
|
vec!["prod/aether-users-backup-20260524-010000.json.zst"]
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn retention_filter_requires_aether_timestamp_format() {
|
||
|
|
let keys = vec![
|
||
|
|
"prod/aether-users-backup-20260524-010000.json.zst".to_string(),
|
||
|
|
"prod/aether-users-backup-foo.json.zst".to_string(),
|
||
|
|
"prod/aether-users-backup-2026052-010000.json.zst".to_string(),
|
||
|
|
"prod/aether-users-backup-202605240-010000.json.zst".to_string(),
|
||
|
|
"prod/aether-users-backup-20260524-01000.json.zst".to_string(),
|
||
|
|
"prod/aether-users-backup-20260524-0100000.json.zst".to_string(),
|
||
|
|
"prod/aether-users-backup-20260524010000.json.zst".to_string(),
|
||
|
|
"prod/aether-users-backup-2026052a-010000.json.zst".to_string(),
|
||
|
|
"prod/aether-users-backup-20260524-01000x.json.zst".to_string(),
|
||
|
|
];
|
||
|
|
|
||
|
|
let matched = BackupScope::Users.matching_backup_keys("prod/", keys);
|
||
|
|
|
||
|
|
assert_eq!(
|
||
|
|
matched,
|
||
|
|
vec!["prod/aether-users-backup-20260524-010000.json.zst"]
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn backup_key_prefix_boundaries_are_exact() {
|
||
|
|
assert_eq!(
|
||
|
|
BackupScope::Config.object_key("", "20260524-031500"),
|
||
|
|
"aether-config-backup-20260524-031500.json.zst"
|
||
|
|
);
|
||
|
|
assert_eq!(
|
||
|
|
BackupScope::Config.object_key("prod", "20260524-031500"),
|
||
|
|
"prod/aether-config-backup-20260524-031500.json.zst"
|
||
|
|
);
|
||
|
|
|
||
|
|
let keys = vec![
|
||
|
|
"prod/aether-config-backup-20260524-010000.json.zst".to_string(),
|
||
|
|
"prod//aether-config-backup-20260524-010000.json.zst".to_string(),
|
||
|
|
"prod-backups/aether-config-backup-20260524-010000.json.zst".to_string(),
|
||
|
|
"prod/aether-config-backup-20260524-010000.json".to_string(),
|
||
|
|
"prod/aether-config-backup-.json.zst".to_string(),
|
||
|
|
"aether-config-backup-20260524-010000.json.zst".to_string(),
|
||
|
|
];
|
||
|
|
|
||
|
|
let matched = BackupScope::Config.matching_backup_keys("prod", keys);
|
||
|
|
|
||
|
|
assert_eq!(
|
||
|
|
matched,
|
||
|
|
vec!["prod/aether-config-backup-20260524-010000.json.zst"]
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|