use rustic_core::jiff::Zoned;
use rustic_core::repofile::SnapshotId;
use rustic_core::{
ForgetGroups, Grouped, KeepOptions, Open, PruneOptions, Repository, SnapshotGroupCriterion,
};
use super::Error;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Retencion {
pub keep_daily: i64,
pub keep_weekly: i64,
pub keep_monthly: i64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InformeDeRetencion {
pub retirados: Vec<String>,
pub purgado: bool,
pub bytes_a_reclamar: u64,
}
pub(crate) fn aplicar<S: Open>(
repositorio: &Repository<S>,
retencion: &Retencion,
) -> Result<InformeDeRetencion, Error> {
let reglas = reglas(retencion)?;
let ahora = Zoned::now();
let instantaneas = repositorio.get_all_snapshots().map_err(|error| {
fallo(format!(
"no se han podido leer las instantáneas del repositorio: {}",
error.display_log()
))
})?;
let grupos = Grouped::from_items(instantaneas, SnapshotGroupCriterion::default());
let a_retirar = ForgetGroups::from_grouped_snapshots_with_retention(grupos, ®las, &ahora)
.map_err(|error| {
fallo(format!(
"no se han podido evaluar las reglas de retención: {}",
error.display_log()
))
})?
.into_forget_ids();
if !a_retirar.is_empty() {
repositorio.delete_snapshots(&a_retirar).map_err(|error| {
fallo(format!(
"no se han podido retirar las instantáneas: {}",
error.display_log()
))
})?;
}
let opciones = PruneOptions::default();
let plan = repositorio.prune_plan(&opciones).map_err(|error| {
fallo(format!(
"no se ha podido calcular el plan de purga: {}",
error.display_log()
))
})?;
let bytes_a_reclamar = plan.stats.size_sum().remove;
repositorio
.prune(&opciones, plan)
.map_err(|error| fallo(format!("no se ha podido purgar: {}", error.display_log())))?;
Ok(InformeDeRetencion {
retirados: identificadores(&a_retirar),
purgado: true,
bytes_a_reclamar,
})
}
fn fallo(motivo: String) -> Error {
Error::RetencionFallida { motivo }
}
fn identificadores(ids: &[SnapshotId]) -> Vec<String> {
ids.iter()
.map(|id| id.to_hex().as_str().to_owned())
.collect()
}
pub(crate) fn reglas(retencion: &Retencion) -> Result<KeepOptions, Error> {
if retencion.keep_daily == 0 && retencion.keep_weekly == 0 && retencion.keep_monthly == 0 {
return Err(Error::RetencionQueLoBorraTodo);
}
Ok(KeepOptions::default()
.keep_daily(regla("keep_daily", retencion.keep_daily)?)
.keep_weekly(regla("keep_weekly", retencion.keep_weekly)?)
.keep_monthly(regla("keep_monthly", retencion.keep_monthly)?))
}
fn regla(columna: &str, valor: i64) -> Result<i32, Error> {
let acotado = i32::try_from(valor).map_err(|_| {
fallo(format!(
"`{columna}` vale {valor}, que no cabe en una regla de retención: el §3 guarda un \
entero de 64 bits y la biblioteca usa uno de 32"
))
})?;
if acotado < -1 {
return Err(fallo(format!(
"`{columna}` vale {valor}: el -1 es la forma que tiene la biblioteca de decir \
«conservar todas», y cualquier otro negativo lo leería igual sin decirlo, así que \
una columna con ese número dentro es una errata y no una instrucción"
)));
}
Ok(acotado)
}
#[cfg(test)]
mod tests {
use std::path::{Path, PathBuf};
use rustic_core::PruneOptions;
use rustic_core::repofile::SnapshotFile;
use crate::test_support::{
PASSPHRASE_DE_PRUEBA, SEMILLA_CONTENIDO, abrir_local, directorio_de_prueba,
sembrar_snapshots, volcado_guardado,
};
use super::{Error, Retencion, aplicar, reglas};
const FECHAS: [&str; 3] = [
"2026-01-01T10:00:00Z",
"2026-01-02T10:00:00Z",
"2026-01-03T10:00:00Z",
];
fn retencion_con_diarias(diarias: i64) -> Retencion {
Retencion {
keep_daily: diarias,
keep_weekly: 0,
keep_monthly: 0,
}
}
fn la_que_queda(repositorio: &rustic_core::Repository<rustic_core::OpenStatus>) -> String {
let instantaneas: Vec<SnapshotFile> = repositorio
.get_all_snapshots()
.expect("el repositorio se puede listar");
assert_eq!(
instantaneas.len(),
1,
"la retención tenía que dejar una sola instantánea y dejó {}",
instantaneas.len()
);
instantaneas[0].id.to_hex().as_str().to_owned()
}
#[test]
fn la_retencion_retira_los_que_sobran_y_purga() {
let directorio = directorio_de_prueba();
let ruta = directorio.ruta().join("repositorio");
let siembra = sembrar_snapshots(&ruta, PASSPHRASE_DE_PRUEBA, &FECHAS);
let sembrados = &siembra.identificadores;
let repositorio = abrir_local(&ruta, PASSPHRASE_DE_PRUEBA);
let informe = aplicar(&repositorio, &retencion_con_diarias(1))
.expect("aplicar la retención no puede fallar");
let mut retirados = informe.retirados.clone();
retirados.sort();
let mut esperados = vec![sembrados[0].clone(), sembrados[1].clone()];
esperados.sort();
assert_eq!(
retirados, esperados,
"la retención tenía que retirar las dos instantáneas más antiguas y retiró {retirados:?}"
);
assert!(
informe.purgado,
"la retención retiró instantáneas y no purgó lo que quedó huérfano: la tercera \
operación no ocurrió"
);
assert_eq!(
la_que_queda(&repositorio),
sembrados[2],
"la instantánea que se conserva no es la más reciente"
);
assert_eq!(
volcado_guardado(&ruta, PASSPHRASE_DE_PRUEBA, &sembrados[2], &siembra.nodo),
Some(SEMILLA_CONTENIDO.to_vec()),
"la purga se llevó por delante el contenido de la instantánea que se conserva"
);
}
#[test]
fn los_tres_keep_llegan_a_las_reglas() {
let una = directorio_de_prueba();
let ruta_una = una.ruta().join("repositorio");
sembrar_snapshots(&ruta_una, PASSPHRASE_DE_PRUEBA, &FECHAS);
let con_una = abrir_local(&ruta_una, PASSPHRASE_DE_PRUEBA);
let informe = aplicar(&con_una, &retencion_con_diarias(1))
.expect("aplicar la retención no puede fallar");
assert_eq!(
informe.retirados.len(),
2,
"con `keep_daily = 1` sobre tres días distintos tenían que sobrar dos \
instantáneas, y sobraron {}",
informe.retirados.len()
);
la_que_queda(&con_una);
let tres = directorio_de_prueba();
let ruta_tres = tres.ruta().join("repositorio");
sembrar_snapshots(&ruta_tres, PASSPHRASE_DE_PRUEBA, &FECHAS);
let con_tres = abrir_local(&ruta_tres, PASSPHRASE_DE_PRUEBA);
let informe = aplicar(&con_tres, &retencion_con_diarias(3))
.expect("aplicar la retención no puede fallar");
assert!(
informe.retirados.is_empty(),
"con `keep_daily = 3` sobre tres días distintos no sobra ninguna instantánea, y se \
retiraron {:?}: el valor de la columna no está llegando a las reglas",
informe.retirados
);
assert_eq!(
con_tres
.get_all_snapshots()
.expect("el repositorio se puede listar")
.len(),
3,
"con `keep_daily = 3` tienen que quedar las tres instantáneas"
);
assert!(
informe.purgado,
"la purga se aplica aunque no se retire nada: es la operación que reclama lo que \
quedó huérfano de retenciones anteriores"
);
}
#[test]
fn las_reglas_rechazan_un_keep_que_la_biblioteca_no_acepta() {
let traducidas = reglas(&Retencion {
keep_daily: 7,
keep_weekly: 4,
keep_monthly: 12,
})
.expect("los valores del esquema son reglas válidas");
assert_eq!(traducidas.keep_daily, Some(7), "`keep_daily` no llegó");
assert_eq!(traducidas.keep_weekly, Some(4), "`keep_weekly` no llegó");
assert_eq!(traducidas.keep_monthly, Some(12), "`keep_monthly` no llegó");
for (familia, informada) in [
("keep_last", traducidas.keep_last.is_some()),
("keep_minutely", traducidas.keep_minutely.is_some()),
("keep_hourly", traducidas.keep_hourly.is_some()),
(
"keep_quarter_yearly",
traducidas.keep_quarter_yearly.is_some(),
),
("keep_half_yearly", traducidas.keep_half_yearly.is_some()),
("keep_yearly", traducidas.keep_yearly.is_some()),
("keep_within", traducidas.keep_within.is_some()),
("keep_within_daily", traducidas.keep_within_daily.is_some()),
] {
assert!(
!informada,
"`{familia}` viaja informada y el §3 no le da ninguna columna: su apagado \
tiene que ser explícito, y esto lo deja de ser"
);
}
let todas = reglas(&retencion_con_diarias(-1)).expect("el -1 de la biblioteca se acepta");
assert_eq!(
todas.keep_daily,
Some(-1),
"el -1 tiene que llegar tal cual: es la forma de la biblioteca de decir \
«conservar todas»"
);
let error = reglas(&retencion_con_diarias(-2)).expect_err(
"-2 no es una regla de retención: la biblioteca lo leería como «conservar todo» sin \
decirlo, y una columna con un número imposible es una errata",
);
let Error::RetencionFallida { motivo } = &error else {
panic!("un valor de retención imposible tiene que ser un fallo de retención: {error}");
};
assert!(
motivo.contains("keep_daily"),
"el motivo del rechazo no nombra la columna que lo provocó: {motivo}"
);
assert!(
motivo.contains("-2"),
"el motivo del rechazo no dice el valor que lo provocó: {motivo}"
);
}
#[test]
fn las_tres_reglas_a_cero_se_rechazan_y_no_retiran_nada() {
let directorio = directorio_de_prueba();
let ruta = directorio.ruta().join("repositorio");
sembrar_snapshots(&ruta, PASSPHRASE_DE_PRUEBA, &FECHAS);
let repositorio = abrir_local(&ruta, PASSPHRASE_DE_PRUEBA);
let error = reglas(&Retencion {
keep_daily: 0,
keep_weekly: 0,
keep_monthly: 0,
})
.expect_err(
"con las tres reglas a cero no hay nada que conservar: el historial entero del \
repositorio se retiraría, incluida la copia recién hecha",
);
assert!(
matches!(error, Error::RetencionQueLoBorraTodo),
"el rechazo de las tres reglas a cero tiene que ser la variante que lo dice, y \
salió {error}"
);
assert!(
error.to_string().contains("keep_daily")
&& error.to_string().contains("keep_weekly")
&& error.to_string().contains("keep_monthly"),
"el motivo del rechazo tiene que nombrar las tres columnas y no una: una retención \
que solo tuviera mal una no es este caso. Motivo: {error}"
);
let error = aplicar(
&repositorio,
&Retencion {
keep_daily: 0,
keep_weekly: 0,
keep_monthly: 0,
},
)
.expect_err("aplicar una retención que lo borra todo tiene que fallar antes de aplicar");
assert!(
matches!(error, Error::RetencionQueLoBorraTodo),
"la retención no falló por el motivo del guardarraíl: {error}"
);
let quedan: Vec<SnapshotFile> = repositorio
.get_all_snapshots()
.expect("el repositorio se puede listar");
assert_eq!(
quedan.len(),
3,
"el rechazo llegó **después** de retirar: quedan {} instantáneas de las tres que \
había, y ninguna tenía que tocarse",
quedan.len()
);
}
#[test]
fn un_cero_suelto_sigue_siendo_una_retencion_valida() {
let directorio = directorio_de_prueba();
let ruta = directorio.ruta().join("repositorio");
let siembra = sembrar_snapshots(&ruta, PASSPHRASE_DE_PRUEBA, &FECHAS);
let repositorio = abrir_local(&ruta, PASSPHRASE_DE_PRUEBA);
reglas(&Retencion {
keep_daily: 0,
keep_weekly: 1,
keep_monthly: 0,
})
.expect(
"`keep_daily = 0` con una regla semanal informada es una decisión y no un error: \
un cero suelto no puede caer por el guardarraíl de las tres a cero",
);
let informe = aplicar(
&repositorio,
&Retencion {
keep_daily: 0,
keep_weekly: 1,
keep_monthly: 0,
},
)
.expect("la retención con un solo cero se aplica");
assert_eq!(
informe.retirados.len(),
2,
"las tres fechas caen en la misma semana, así que una regla semanal de una tiene \
que retirar dos instantáneas y retiró {}",
informe.retirados.len()
);
assert_eq!(
la_que_queda(&repositorio),
siembra.identificadores[2],
"la instantánea que se conserva no es la más reciente de la semana"
);
assert!(informe.purgado, "la retención se aplicó y no purgó");
}
#[test]
fn el_plan_de_purga_se_calcula_sin_escribir_en_el_destino() {
let directorio = directorio_de_prueba();
let ruta = directorio.ruta().join("repositorio");
let siembra = sembrar_snapshots(&ruta, PASSPHRASE_DE_PRUEBA, &FECHAS);
let repositorio = abrir_local(&ruta, PASSPHRASE_DE_PRUEBA);
let solo_lectura = SoloLectura::nuevo(&ruta);
let escritura = repositorio
.delete_snapshots(&[snapshot_id(&siembra.identificadores[0])])
.expect_err(
"el destino está en solo lectura y la biblioteca retiró una instantánea: los \
permisos no están mordiendo, así que la comprobación del plan pasaría sin \
medir nada —¿se está corriendo esto como `root`?—",
);
assert!(
!escritura.display_log().is_empty(),
"el fallo de escritura no trae motivo"
);
repositorio.prune_plan(&PruneOptions::default()).expect(
"medido: calcular el plan de purga no escribe nada en el destino —solo lee los \
ficheros de índice y lista los paquetes—, así que tiene que salir con el \
destino en solo lectura. Que esto falle significa que el cálculo del plan \
escribe, y entonces «el plan se calcula antes de tocar nada» es falso",
);
drop(solo_lectura);
drop(repositorio);
}
fn snapshot_id(hex: &str) -> rustic_core::repofile::SnapshotId {
hex.parse()
.expect("el identificador de una instantánea sembrada es un hex válido")
}
struct SoloLectura {
originales: Vec<(PathBuf, std::fs::Permissions)>,
}
impl SoloLectura {
fn nuevo(raiz: &Path) -> Self {
use std::os::unix::fs::PermissionsExt;
let mut originales = Vec::new();
for entrada in caminos(raiz) {
let originales_de_la_entrada = std::fs::metadata(&entrada)
.expect("cada entrada del destino tiene metadatos")
.permissions();
let mut de_solo_lectura = originales_de_la_entrada.clone();
let modo = if entrada.is_dir() { 0o555 } else { 0o444 };
de_solo_lectura.set_mode(modo);
std::fs::set_permissions(&entrada, de_solo_lectura)
.expect("los permisos del destino se pueden cambiar");
originales.push((entrada, originales_de_la_entrada));
}
Self { originales }
}
}
impl Drop for SoloLectura {
fn drop(&mut self) {
for (entrada, permisos) in self.originales.drain(..).rev() {
let _ = std::fs::set_permissions(&entrada, permisos);
}
}
}
fn caminos(raiz: &Path) -> Vec<PathBuf> {
fn recorrer(actual: &Path, vistos: &mut Vec<PathBuf>) {
vistos.push(actual.to_path_buf());
if let Ok(entradas) = std::fs::read_dir(actual) {
for entrada in entradas.flatten() {
recorrer(&entrada.path(), vistos);
}
}
}
let mut vistos = Vec::new();
recorrer(raiz, &mut vistos);
vistos
}
}