use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::Arc;
use rustic_backend::opendal::OpenDALBackend;
use rustic_core::repofile::{SnapshotFile, SnapshotId};
use rustic_core::{
BackupOptions, ConfigOptions, Credentials, Excludes, FileType, IndexedIdsStatus, KeyOptions,
LocalDestination, LocalSource, LocalSourceFilterOptions, LocalSourceSaveOptions, LsOptions,
ParentOptions, ReadBackend, Repository, RepositoryBackends, RepositoryOptions, RestoreOptions,
SnapshotOptions, WriteBackend,
};
use time::OffsetDateTime;
use super::{BarrasDelMotor, Error, InformeDeRetencion, OrigenDelVolcado, Retencion};
use crate::secret::SecretString;
#[derive(Debug)]
pub struct Acceso {
pub endpoint: String,
pub bucket: String,
pub region: String,
pub access_key_id: String,
pub secret_access_key: SecretString,
pub passphrase: SecretString,
}
impl Acceso {
fn nombre_del_destino(&self) -> String {
format!(
"{}/{}",
self.endpoint.trim_end_matches('/'),
self.bucket.trim_matches('/')
)
}
fn validar(&self) -> Result<(), Error> {
let obligatorios = [
("endpoint", self.endpoint.as_str()),
("bucket", self.bucket.as_str()),
("access_key_id", self.access_key_id.as_str()),
("secret_access_key", self.secret_access_key.expose()),
("passphrase", self.passphrase.expose()),
];
for (campo, valor) in obligatorios {
if valor.trim().is_empty() {
return Err(Error::AccesoInvalido {
campo: campo.to_owned(),
});
}
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PuestaEnMarcha {
Inicializado,
Conectado,
}
#[derive(Debug, Clone)]
pub struct Copia {
pub rutas: Vec<PathBuf>,
pub nombre: String,
pub etiqueta: String,
pub host: String,
pub comando: String,
}
impl Copia {
fn instantanea(&self) -> Result<SnapshotFile, Error> {
SnapshotOptions::default()
.label(self.etiqueta.clone())
.host(self.host.clone())
.command(self.comando.clone())
.to_snapshot()
.map_err(|error| Error::CopiaFallida {
etiqueta: self.etiqueta.clone(),
motivo: format!(
"no se ha podido construir la instantánea del volcado `{}`: {error}",
self.nombre
),
})
}
fn rutas(&self) -> Result<&[PathBuf], Error> {
if self.rutas.is_empty() {
return Err(Error::CopiaSinRutas {
etiqueta: self.etiqueta.clone(),
});
}
Ok(&self.rutas)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CopiaHecha {
pub id: String,
pub ficheros: u64,
pub bytes: u64,
}
impl CopiaHecha {
fn desde(snapshot: &SnapshotFile) -> Self {
let resumen = snapshot.summary.as_ref();
Self {
id: snapshot.id.to_hex().as_str().to_owned(),
ficheros: resumen.map_or(0, |resumen| resumen.total_files_processed),
bytes: resumen.map_or(0, |resumen| resumen.total_bytes_processed),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResumenDeSnapshot {
pub id: String,
pub fecha: OffsetDateTime,
pub rutas: Vec<String>,
pub etiqueta: Option<String>,
pub ficheros: u64,
pub bytes: u64,
}
impl ResumenDeSnapshot {
fn desde(snapshot: &SnapshotFile, destino: &str) -> Result<Self, Error> {
let instante = snapshot.time.timestamp().as_nanosecond();
let fecha = OffsetDateTime::from_unix_timestamp_nanos(instante).map_err(|error| {
Error::ListadoFallido {
destino: destino.to_owned(),
motivo: format!(
"la fecha de la instantánea `{}` no es representable: {error}",
snapshot.id.to_hex().as_str()
),
}
})?;
Ok(Self {
id: snapshot.id.to_hex().as_str().to_owned(),
fecha,
rutas: snapshot.paths.iter().cloned().collect(),
etiqueta: (!snapshot.label.is_empty()).then(|| snapshot.label.clone()),
ficheros: snapshot
.summary
.as_ref()
.map_or(0, |resumen| resumen.total_files_processed),
bytes: snapshot
.summary
.as_ref()
.map_or(0, |resumen| resumen.total_bytes_processed),
})
}
}
pub struct Repositorio {
repo: Repository<IndexedIdsStatus>,
destino: String,
}
impl std::fmt::Debug for Repositorio {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Repositorio")
.field("destino", &self.destino)
.finish_non_exhaustive()
}
}
impl Repositorio {
pub fn poner_en_marcha(
acceso: &Acceso,
barras: BarrasDelMotor,
) -> Result<(Self, PuestaEnMarcha), Error> {
let backends = backends_de_s3(acceso)?;
poner_en_marcha_con(acceso, backends, barras)
}
pub fn archivar_volcado(
&self,
copia: &Copia,
origen: &OrigenDelVolcado,
) -> Result<CopiaHecha, Error> {
let rutas = copia.rutas()?;
if copia.nombre != origen.nombre() {
return Err(Error::NombreDeVolcadoIncoherente {
en_la_copia: copia.nombre.clone(),
en_el_origen: origen.nombre().to_owned(),
});
}
let instantanea = copia.instantanea()?;
let opciones = BackupOptions::default().parent_opts(ParentOptions::default().force(true));
let guardada = self
.repo
.archive(&opciones, origen, instantanea, rutas)
.map_err(|error| Error::CopiaFallida {
etiqueta: copia.etiqueta.clone(),
motivo: error.display_log(),
})?;
Ok(CopiaHecha::desde(&guardada))
}
pub fn archivar_directorio(&self, copia: &Copia) -> Result<CopiaHecha, Error> {
let rutas = copia.rutas()?;
let instantanea = copia.instantanea()?;
let fallo = |motivo: String| Error::CopiaFallida {
etiqueta: copia.etiqueta.clone(),
motivo,
};
let origen = LocalSource::new(
LocalSourceSaveOptions::default(),
&Excludes::default(),
&LocalSourceFilterOptions::default(),
rutas,
)
.map_err(|error| fallo(format!("no se ha podido leer el directorio: {error}")))?;
let guardada = self
.repo
.archive(&BackupOptions::default(), &origen, instantanea, rutas)
.map_err(|error| fallo(error.display_log()))?;
Ok(CopiaHecha::desde(&guardada))
}
pub fn aplicar_retencion(&self, retencion: &Retencion) -> Result<InformeDeRetencion, Error> {
super::retencion::aplicar(&self.repo, retencion)
}
pub fn listar_snapshots(&self, ventana: usize) -> Result<Vec<ResumenDeSnapshot>, Error> {
let fallo = |motivo: String| Error::ListadoFallido {
destino: self.destino.clone(),
motivo,
};
let identificadores: Vec<String> = self
.repo
.list::<SnapshotId>()
.map_err(|error| fallo(error.display_log()))?
.take(ventana)
.map(|id| id.to_hex().as_str().to_owned())
.collect();
if identificadores.is_empty() {
return Ok(Vec::new());
}
let mut instantaneas = self
.repo
.get_snapshots(&identificadores)
.map_err(|error| fallo(error.display_log()))?;
instantaneas.sort_unstable_by(|una, otra| otra.time.cmp(&una.time));
instantaneas
.iter()
.map(|instantanea| ResumenDeSnapshot::desde(instantanea, &self.destino))
.collect()
}
pub fn tiene_la_instantanea(&self, id: &str) -> Result<bool, Error> {
let fallo = |motivo: String| Error::ListadoFallido {
destino: self.destino.clone(),
motivo,
};
let Ok(buscada) = id.parse::<SnapshotId>() else {
return Ok(false);
};
self.repo
.list::<SnapshotId>()
.map_err(|error| fallo(error.display_log()))
.map(|mut presentes| presentes.any(|candidata| candidata == buscada))
}
pub fn olvidar_instantanea(&self, id: &str) -> Result<(), Error> {
let fallo = |motivo: String| Error::OlvidoFallido {
destino: self.destino.clone(),
motivo,
};
let instantanea: SnapshotId = id
.parse()
.map_err(|error: Box<rustic_core::RusticError>| fallo(error.display_log()))?;
self.repo
.delete_snapshots(&[instantanea])
.map_err(|error| fallo(error.display_log()))
}
pub fn restaurar_snapshot(self, instantanea: &str, destino_local: &str) -> Result<(), Error> {
let Repositorio { repo, destino } = self;
let fallo = |motivo: String| Error::RestauracionFallida {
destino: destino.clone(),
instantanea: instantanea.to_owned(),
motivo,
};
let identificador: SnapshotId = instantanea
.parse()
.map_err(|error: Box<rustic_core::RusticError>| fallo(error.display_log()))?;
let repo = repo
.to_indexed()
.map_err(|error| fallo(error.display_log()))?;
let hex = identificador.to_hex().as_str().to_owned();
let mut copias = repo
.get_snapshots(&[hex.as_str()])
.map_err(|error| fallo(error.display_log()))?;
let Some(copia) = copias.pop() else {
return Err(fallo(format!(
"el repositorio no tiene ninguna instantánea con el identificador `{hex}`"
)));
};
let raiz = repo
.node_from_snapshot_and_path(&copia, "")
.map_err(|error| fallo(error.display_log()))?;
let nodos = repo
.ls(&raiz, &LsOptions::default())
.map_err(|error| fallo(error.display_log()))?;
let destino_local = LocalDestination::new(destino_local, true, false)
.map_err(|error| fallo(error.display_log()))?;
let opciones = RestoreOptions::default().verify_existing(true);
let plan = repo
.prepare_restore(&opciones, nodos.clone(), &destino_local, false)
.map_err(|error| fallo(error.display_log()))?;
repo.restore(plan, &opciones, nodos, &destino_local)
.map_err(|error| fallo(error.display_log()))
}
}
fn backends_de_s3(acceso: &Acceso) -> Result<RepositoryBackends, Error> {
let opciones = opciones_de_s3(acceso)?;
let backend: Arc<dyn WriteBackend> = Arc::new(
OpenDALBackend::new(ESQUEMA_DE_S3, opciones).map_err(|error| {
Error::DestinoNoAlcanzable {
destino: acceso.nombre_del_destino(),
motivo: error.display_log(),
}
})?,
);
Ok(RepositoryBackends::new(backend, None))
}
fn opciones_de_s3(acceso: &Acceso) -> Result<BTreeMap<String, String>, Error> {
acceso.validar()?;
Ok([
("endpoint", acceso.endpoint.clone()),
("region", acceso.region.clone()),
("bucket", acceso.bucket.clone()),
("access_key_id", acceso.access_key_id.clone()),
(
"secret_access_key",
acceso.secret_access_key.expose().to_owned(),
),
]
.into_iter()
.map(|(clave, valor)| (clave.to_owned(), valor))
.collect())
}
const ESQUEMA_DE_S3: &str = "s3";
pub(crate) fn poner_en_marcha_con(
acceso: &Acceso,
backends: RepositoryBackends,
barras: BarrasDelMotor,
) -> Result<(Repositorio, PuestaEnMarcha), Error> {
acceso.validar()?;
let destino = acceso.nombre_del_destino();
let fallo_del_destino = |error: Box<rustic_core::RusticError>| Error::DestinoNoAlcanzable {
destino: destino.clone(),
motivo: error.display_log(),
};
let configuracion = backends.repository();
let repositorio =
Repository::new_with_progress(&RepositoryOptions::default(), &backends, barras)
.map_err(fallo_del_destino)?;
let credenciales = Credentials::Password(acceso.passphrase.expose().to_owned());
let sin_configuracion = configuracion
.list(FileType::Config)
.map_err(fallo_del_destino)?
.is_empty();
let (abierto, puesta) = if sin_configuracion {
(
repositorio
.init(
&credenciales,
&KeyOptions::default(),
&ConfigOptions::default(),
)
.map_err(fallo_del_destino)?,
PuestaEnMarcha::Inicializado,
)
} else {
(
repositorio.open(&credenciales).map_err(|error| {
if error.is_incorrect_password() {
Error::PassphraseNoAbre {
destino: destino.clone(),
motivo: error.display_log(),
}
} else {
Error::DestinoNoAlcanzable {
destino: destino.clone(),
motivo: error.display_log(),
}
}
})?,
PuestaEnMarcha::Conectado,
)
};
let repo = abierto.to_indexed_ids().map_err(fallo_del_destino)?;
Ok((Repositorio { repo, destino }, puesta))
}
#[cfg(test)]
mod tests {
use std::path::{Path, PathBuf};
use std::time::Duration;
use rustic_backend::opendal::OpenDALBackend;
use rustic_core::repofile::SnapshotFile;
use rustic_core::{FileType, ReadBackend, SnapshotOptions};
use crate::rustic::tunel_del_volcado;
use crate::secret::SecretString;
use crate::test_support::{
AJENA_CONTENIDO, CLAVE_SECRETA_DE_PRUEBA, DirectorioDePrueba, ETIQUETA_AJENA,
ETIQUETA_SEMBRADA, OTRA_PASSPHRASE_DE_PRUEBA, PASSPHRASE_DE_PRUEBA, SEMILLA_CONTENIDO,
acceso_local, backends_locales, barras_mudas, directorio_de_prueba, nodo_del_snapshot,
sembrar_snapshot_ajeno, sembrar_snapshots, snapshot_guardado, volcado_guardado,
};
use super::{
Acceso, Copia, CopiaHecha, ESQUEMA_DE_S3, Error, PuestaEnMarcha, Repositorio,
backends_de_s3, opciones_de_s3, poner_en_marcha_con,
};
const PLAZO_DE_PRUEBA: Duration = Duration::from_secs(60);
const ETIQUETA: &str = "tarea-de-prueba";
const COMANDO: &str =
"mariadb-dump --single-transaction --quick --lock-tables=false -u tienda --all-databases";
struct Destino {
_directorio: DirectorioDePrueba,
ruta: PathBuf,
}
impl Destino {
fn nuevo() -> Self {
let directorio = directorio_de_prueba();
let ruta = directorio.ruta().join("repositorio");
Self {
_directorio: directorio,
ruta,
}
}
fn acceso(&self, passphrase: &str) -> Acceso {
acceso_local(&self.ruta, passphrase)
}
fn backends(&self) -> rustic_core::RepositoryBackends {
backends_locales(&self.ruta)
}
fn en_marcha(&self, passphrase: &str) -> Result<(Repositorio, PuestaEnMarcha), Error> {
poner_en_marcha_con(&self.acceso(passphrase), self.backends(), barras_mudas())
}
fn tiene_repositorio(&self) -> bool {
!self
.backends()
.repository()
.list(FileType::Config)
.expect("listar un destino local no puede fallar")
.is_empty()
}
fn instantanea(&self) -> Vec<(String, u64)> {
fn recorrer(raiz: &Path, actual: &Path, vistos: &mut Vec<(String, u64)>) {
for entrada in std::fs::read_dir(actual).expect("el destino se puede recorrer") {
let entrada = entrada.expect("cada entrada del destino se lee");
let ruta = entrada.path();
if ruta.is_dir() {
recorrer(raiz, &ruta, vistos);
} else {
let relativa = ruta
.strip_prefix(raiz)
.expect("todo lo recorrido cuelga de la raíz")
.to_string_lossy()
.into_owned();
let tamano = entrada
.metadata()
.expect("cada entrada del destino tiene metadatos")
.len();
vistos.push((relativa, tamano));
}
}
}
let mut vistos = Vec::new();
if self.ruta.exists() {
recorrer(&self.ruta, &self.ruta, &mut vistos);
}
vistos.sort();
vistos
}
}
fn copia_de(nombre: &str, etiqueta: &str) -> Copia {
Copia {
rutas: vec![PathBuf::from(nombre)],
nombre: nombre.to_owned(),
etiqueta: etiqueta.to_owned(),
host: "host-de-prueba".to_owned(),
comando: COMANDO.to_owned(),
}
}
fn archivar(
repositorio: &Repositorio,
copia: &Copia,
contenido: &[u8],
) -> Result<CopiaHecha, Error> {
let tunel = tunel_del_volcado(copia.nombre.clone(), 4, PLAZO_DE_PRUEBA)
.expect("el nombre del volcado de la prueba es válido");
let trozos = vec![contenido.to_vec()];
let productor = tunel.productor;
let emisor = std::thread::spawn(move || {
for trozo in trozos {
productor
.enviar(trozo)
.expect("el archivado sigue leyendo, así que el envío no puede fallar");
}
productor.terminar();
});
let hecho = repositorio.archivar_volcado(copia, &tunel.origen);
emisor.join().expect("el hilo productor no puede caerse");
assert!(
tunel.entrega.completa(),
"el volcado se entregó entero y la señal de la entrega sigue en falso: sin ella \
la copia no se podría dar por buena"
);
hecho
}
fn archivar_truncado(
repositorio: &Repositorio,
copia: &Copia,
trozo: &[u8],
) -> Result<CopiaHecha, Error> {
let tunel = tunel_del_volcado(copia.nombre.clone(), 4, PLAZO_DE_PRUEBA)
.expect("el nombre del volcado de la prueba es válido");
let productor = tunel.productor;
productor
.enviar(trozo.to_vec())
.expect("el canal tiene sitio de sobra");
drop(productor);
let hecho = repositorio.archivar_volcado(copia, &tunel.origen);
assert!(
!tunel.entrega.completa(),
"un productor que murió sin declarar el final dejó la señal de la entrega puesta"
);
hecho
}
#[test]
fn un_destino_vacio_se_inicializa_y_queda_con_su_configuracion() {
let destino = Destino::nuevo();
assert!(
!destino.tiene_repositorio(),
"el destino de la prueba no empieza vacío"
);
let (repositorio, puesta) = destino
.en_marcha(PASSPHRASE_DE_PRUEBA)
.expect("un destino vacío y alcanzable se inicializa");
assert_eq!(
puesta,
PuestaEnMarcha::Inicializado,
"un destino sin repositorio se inicializa, y dijo otra cosa"
);
assert!(
destino.tiene_repositorio(),
"el destino se inicializó y no quedó ningún fichero de configuración en {}",
destino.ruta.display()
);
drop(repositorio);
}
#[test]
fn un_destino_con_repositorio_se_conecta_sin_fallar_por_ya_existe() {
let destino = Destino::nuevo();
let (primero, puesta) = destino
.en_marcha(PASSPHRASE_DE_PRUEBA)
.expect("el primer destino se inicializa");
assert_eq!(
puesta,
PuestaEnMarcha::Inicializado,
"la primera puesta en marcha tenía que inicializar"
);
drop(primero);
let (segundo, puesta) = destino.en_marcha(PASSPHRASE_DE_PRUEBA).expect(
"un destino que ya tiene repositorio tiene que abrirse, y no fallar por existir ya",
);
assert_eq!(
puesta,
PuestaEnMarcha::Conectado,
"la segunda puesta en marcha tenía que conectar con el repositorio que ya estaba"
);
drop(segundo);
}
#[test]
fn una_passphrase_equivocada_no_se_confunde_con_un_repositorio_inexistente() {
let destino = Destino::nuevo();
let (repositorio, _) = destino
.en_marcha(PASSPHRASE_DE_PRUEBA)
.expect("el destino se inicializa");
drop(repositorio);
let antes = destino.instantanea();
assert!(
!antes.is_empty(),
"el destino se inicializó y su foto está vacía: no habría nada que comparar"
);
let error = destino
.en_marcha(OTRA_PASSPHRASE_DE_PRUEBA)
.expect_err("con el repositorio ya creado, otra passphrase no puede abrirlo");
let Error::PassphraseNoAbre {
destino: nombrado,
motivo,
} = &error
else {
panic!(
"una passphrase que no abre tiene que reportarse como tal, y se reportó como \
otra cosa: {error}"
);
};
assert!(
nombrado.contains(&destino.ruta.display().to_string()),
"el motivo del fallo no nombra el destino ({}): {nombrado}",
destino.ruta.display()
);
assert!(
!motivo.is_empty(),
"el motivo del fallo de la passphrase está vacío"
);
assert!(
!error.to_string().contains(OTRA_PASSPHRASE_DE_PRUEBA)
&& !error.to_string().contains(PASSPHRASE_DE_PRUEBA),
"el error publicó una passphrase: {error}"
);
assert_eq!(
destino.instantanea(),
antes,
"un intento de apertura con la passphrase equivocada tocó el destino"
);
}
#[test]
fn un_destino_inalcanzable_da_un_error_distinto_y_nombra_el_destino() {
let directorio = directorio_de_prueba();
let obstaculo = directorio.ruta().join("esto-no-es-un-directorio");
std::fs::write(&obstaculo, b"soy un fichero, no un directorio").expect("el obstáculo");
let ruta = obstaculo.join("repositorio");
let error = poner_en_marcha_con(
&acceso_local(&ruta, PASSPHRASE_DE_PRUEBA),
backends_locales(&ruta),
barras_mudas(),
)
.expect_err("un destino que no se puede preparar no puede ponerse en marcha");
let Error::DestinoNoAlcanzable {
destino: nombrado,
motivo,
} = &error
else {
panic!(
"un destino que no se alcanza tiene que reportarse como un fallo de destino, y \
se reportó como otra cosa: {error}"
);
};
assert!(
nombrado.contains(&ruta.display().to_string()),
"el motivo del fallo no nombra el destino ({}): {nombrado}",
ruta.display()
);
assert!(
!motivo.is_empty(),
"el motivo del destino inalcanzable está vacío"
);
assert!(
!matches!(
error,
Error::PassphraseNoAbre { .. } | Error::NombreDeVolcadoInvalido { .. }
),
"el destino inalcanzable se confundió con otro fallo: {error}"
);
}
#[test]
fn el_modulo_se_pone_en_marcha_sin_recibir_la_clave_maestra() {
let destino = Destino::nuevo();
let acceso = destino.acceso(PASSPHRASE_DE_PRUEBA);
let (repositorio, puesta) =
poner_en_marcha_con(&acceso, destino.backends(), barras_mudas())
.expect("con los dos secretos ya descifrados, el repositorio se pone en marcha");
assert_eq!(
puesta,
PuestaEnMarcha::Inicializado,
"la puesta en marcha de un destino vacío se inicializa"
);
let publicado = format!("{acceso:?}");
assert!(
!publicado.contains(PASSPHRASE_DE_PRUEBA),
"el Debug del acceso publicó la passphrase: {publicado}"
);
assert!(
!publicado.contains(CLAVE_SECRETA_DE_PRUEBA),
"el Debug del acceso publicó la clave secreta: {publicado}"
);
assert!(
publicado.contains("<redactado>"),
"el Debug del acceso no redacta nada, así que las dos comprobaciones de arriba \
estarían pasando sin medir nada: {publicado}"
);
drop(repositorio);
}
#[test]
fn archivar_volcado_guarda_el_volcado_con_sus_ficheros_y_sus_bytes() {
let destino = Destino::nuevo();
let (repositorio, _) = destino
.en_marcha(PASSPHRASE_DE_PRUEBA)
.expect("el destino se inicializa");
let copia = copia_de("volcado-de-tienda.sql", ETIQUETA);
let contenido =
b"-- tienda\nCREATE TABLE tienda (id INT);\nINSERT INTO tienda VALUES (1);\n";
let hecho = archivar(&repositorio, &copia, contenido).expect("archivar no puede fallar");
assert_eq!(
hecho.id.len(),
64,
"el identificador del snapshot no es el hex completo: {:?}. La forma corta del \
`Display` de `SnapshotId` son ocho caracteres y es un prefijo ambiguo",
hecho.id
);
assert_eq!(
hecho.ficheros, 1,
"el archivado procesó un fichero y dijo {}",
hecho.ficheros
);
assert_eq!(
hecho.bytes,
contenido.len() as u64,
"el archivado procesó {} bytes y dijo {}",
contenido.len(),
hecho.bytes
);
assert_eq!(
volcado_guardado(
&destino.ruta,
PASSPHRASE_DE_PRUEBA,
&hecho.id,
&copia.nombre
)
.expect("el snapshot tiene que tener el volcado dentro"),
contenido,
"el volcado que se restaura del repositorio no es el que se entregó"
);
drop(repositorio);
}
#[test]
fn dos_copias_consecutivas_del_mismo_destino_llevan_cada_una_su_contenido() {
let destino = Destino::nuevo();
let copia = copia_de("volcado-de-tienda.sql", ETIQUETA);
let de_ayer = b"-- de ayer\nCREATE TABLE tienda (id INT);\n";
let de_hoy = b"-- de hoy\nCREATE TABLE tienda (id INT);\nINSERT INTO tienda VALUES (7);\n";
let (primero, _) = destino
.en_marcha(PASSPHRASE_DE_PRUEBA)
.expect("el destino se inicializa");
let ayer = archivar(&primero, &copia, de_ayer).expect("la copia de ayer se archiva");
drop(primero);
let (segundo, puesta) = destino
.en_marcha(PASSPHRASE_DE_PRUEBA)
.expect("la segunda copia abre el repositorio que ya está");
assert_eq!(
puesta,
PuestaEnMarcha::Conectado,
"la segunda copia tenía que conectar con el repositorio"
);
let hoy = archivar(&segundo, &copia, de_hoy).expect("la copia de hoy se archiva");
drop(segundo);
assert_eq!(
volcado_guardado(&destino.ruta, PASSPHRASE_DE_PRUEBA, &ayer.id, &copia.nombre)
.expect("la copia de ayer tiene que tener su volcado"),
de_ayer,
"la copia de ayer no devuelve los bytes de ayer"
);
assert_eq!(
volcado_guardado(&destino.ruta, PASSPHRASE_DE_PRUEBA, &hoy.id, &copia.nombre)
.expect("la copia de hoy tiene que tener su volcado"),
de_hoy,
"la copia de hoy devolvió los bytes de ayer: el emparejamiento con la instantánea \
anterior no está desactivado y la segunda copia apunta al contenido de la primera"
);
assert_eq!(
hoy.bytes,
de_hoy.len() as u64,
"la copia de hoy dice haber procesado {} bytes y su contenido tiene {}: una copia \
que reutiliza el contenido anterior no lee nada",
hoy.bytes,
de_hoy.len()
);
}
#[test]
fn un_volcado_que_no_se_entrega_entero_no_deja_el_volcado_dentro() {
let destino = Destino::nuevo();
let (repositorio, _) = destino
.en_marcha(PASSPHRASE_DE_PRUEBA)
.expect("el destino se inicializa");
let copia = copia_de("volcado-truncado.sql", ETIQUETA);
let devuelto = archivar_truncado(&repositorio, &copia, b"-- tienda\nCREATE TABLE tie");
let hecho = devuelto
.expect(
"medido: el archivado de la biblioteca **no** devuelve error con el volcado \
truncado, lo descarta con un aviso y guarda la instantánea igualmente. Que esto \
falle significa que la biblioteca cambió de comportamiento y hay que volver a \
medir qué se puede afirmar del resultado",
)
.clone();
assert_eq!(
hecho.ficheros, 0,
"el archivado dice haber procesado {} ficheros con el volcado truncado: el resumen \
no puede contar lo que no llegó a leer",
hecho.ficheros
);
assert_eq!(
volcado_guardado(
&destino.ruta,
PASSPHRASE_DE_PRUEBA,
&hecho.id,
&copia.nombre
),
None,
"el volcado truncado quedó dentro de la instantánea: es una copia que existe, ocupa \
sitio y no se puede restaurar"
);
drop(repositorio);
}
#[test]
fn el_snapshot_graba_el_comando_del_volcado_y_no_el_argv_de_kesto() {
let destino = Destino::nuevo();
let (repositorio, _) = destino
.en_marcha(PASSPHRASE_DE_PRUEBA)
.expect("el destino se inicializa");
let copia = copia_de("volcado-de-tienda.sql", ETIQUETA);
let hecho = archivar(&repositorio, &copia, b"-- tienda\nSELECT 1;\n")
.expect("archivar no puede fallar");
let argv: Vec<String> = std::env::args_os()
.map(|argumento| argumento.to_string_lossy().into_owned())
.collect();
assert!(
!argv.is_empty(),
"un proceso tiene al menos un argumento: sin `argv` esta prueba no mide nada"
);
let por_defecto = SnapshotFile::from_options(&SnapshotOptions::default())
.expect("la instantánea por defecto se construye");
let comando_por_defecto = por_defecto
.summary
.expect("la instantánea por defecto trae resumen")
.command;
assert!(
comando_por_defecto.contains(&argv[0]),
"la biblioteca dejó de rellenar el comando con el `argv` del proceso \
({comando_por_defecto:?}): la comprobación de abajo ya no mediría lo que dice \
medir, y habría que volver a medir por dónde sale la línea de comandos"
);
let guardado = snapshot_guardado(&destino.ruta, PASSPHRASE_DE_PRUEBA, &hecho.id);
let comando = guardado
.summary
.expect("la instantánea guardada trae su resumen")
.command;
assert_eq!(
comando, copia.comando,
"el comando que la instantánea grabó no es el del volcado"
);
for argumento in &argv {
assert!(
!comando.contains(argumento.as_str()),
"el comando de la instantánea lleva un argumento del proceso de Kesto \
({argumento:?}): es un campo de auditoría que, dejado a su defecto, audita lo \
que no debe. Comando grabado: {comando:?}"
);
}
drop(repositorio);
}
#[test]
fn el_snapshot_lleva_sus_rutas_su_etiqueta_y_su_host() {
let destino = Destino::nuevo();
let (repositorio, _) = destino
.en_marcha(PASSPHRASE_DE_PRUEBA)
.expect("el destino se inicializa");
let copia = copia_de("volcado-de-tienda.sql", ETIQUETA);
let hecho = archivar(&repositorio, &copia, b"-- tienda\nSELECT 1;\n")
.expect("archivar no puede fallar");
let guardado = snapshot_guardado(&destino.ruta, PASSPHRASE_DE_PRUEBA, &hecho.id);
assert_eq!(
guardado.paths.iter().cloned().collect::<Vec<String>>(),
copia
.rutas
.iter()
.map(|ruta| ruta.to_string_lossy().into_owned())
.collect::<Vec<String>>(),
"las rutas que la instantánea declara no son las de la copia"
);
assert_eq!(
guardado.label, copia.etiqueta,
"la etiqueta de la instantánea no es la que identifica a la tarea"
);
assert_eq!(
guardado.hostname, copia.host,
"el host de la instantánea no es el de la copia"
);
drop(repositorio);
}
#[test]
fn archivar_directorio_copia_un_directorio_sin_pasar_por_docker() {
let destino = Destino::nuevo();
let (repositorio, _) = destino
.en_marcha(PASSPHRASE_DE_PRUEBA)
.expect("el destino se inicializa");
let fuente = directorio_de_prueba();
std::fs::create_dir_all(fuente.ruta().join("subdirectorio")).expect("el subdirectorio");
std::fs::write(
fuente.ruta().join("subdirectorio").join("datos.txt"),
b"datos del directorio\n",
)
.expect("el fichero del directorio");
let copia = Copia {
rutas: vec![fuente.ruta().to_path_buf()],
nombre: "directorio".to_owned(),
etiqueta: ETIQUETA.to_owned(),
host: "host-de-prueba".to_owned(),
comando: "copia de directorio".to_owned(),
};
let hecho = repositorio
.archivar_directorio(&copia)
.expect("copiar un directorio no puede fallar");
assert!(
hecho.bytes >= b"datos del directorio\n".len() as u64,
"la copia del directorio dice haber procesado {} bytes, menos que el único fichero \
que contiene",
hecho.bytes
);
let ruta = format!("{}/subdirectorio/datos.txt", fuente.ruta().display());
let relativa = ruta.trim_start_matches('/');
assert_eq!(
volcado_guardado(&destino.ruta, PASSPHRASE_DE_PRUEBA, &hecho.id, relativa),
Some(b"datos del directorio\n".to_vec()),
"el fichero del directorio no se puede leer del snapshot (nodo {relativa:?})"
);
drop(repositorio);
}
#[test]
fn la_retencion_del_repositorio_retira_los_que_sobran_y_purga() {
let destino = Destino::nuevo();
let siembra = sembrar_snapshots(
&destino.ruta,
PASSPHRASE_DE_PRUEBA,
&[
"2026-01-01T10:00:00Z",
"2026-01-02T10:00:00Z",
"2026-01-03T10:00:00Z",
],
);
let sembrados = &siembra.identificadores;
let (repositorio, puesta) = destino
.en_marcha(PASSPHRASE_DE_PRUEBA)
.expect("el destino sembrado se abre");
assert_eq!(
puesta,
PuestaEnMarcha::Conectado,
"el destino ya tenía repositorio"
);
let informe = repositorio
.aplicar_retencion(&crate::rustic::Retencion {
keep_daily: 1,
keep_weekly: 0,
keep_monthly: 0,
})
.expect("aplicar la retención no puede fallar");
let mut retirados = informe.retirados.clone();
retirados.sort();
let mut sobrantes = vec![sembrados[0].clone(), sembrados[1].clone()];
sobrantes.sort();
assert_eq!(
retirados, sobrantes,
"la retención tenía que retirar los dos más antiguos"
);
assert!(
informe.purgado,
"la retención retiró instantáneas y no purgó lo que quedó huérfano"
);
let quedan = repositorio
.listar_snapshots(100)
.expect("el repositorio se puede listar");
assert_eq!(
quedan.len(),
1,
"tenía que quedar una instantánea y quedan {}",
quedan.len()
);
assert_eq!(
quedan[0].id, sembrados[2],
"la instantánea que quedó no es la más reciente"
);
assert_eq!(
volcado_guardado(
&destino.ruta,
PASSPHRASE_DE_PRUEBA,
&quedan[0].id,
&siembra.nodo
),
Some(SEMILLA_CONTENIDO.to_vec()),
"la purga se llevó por delante el contenido de la instantánea que se conserva"
);
drop(repositorio);
}
#[test]
fn el_listado_sale_del_repositorio_y_va_acotado() {
let destino = Destino::nuevo();
let siembra = sembrar_snapshots(
&destino.ruta,
PASSPHRASE_DE_PRUEBA,
&[
"2026-01-01T10:00:00Z",
"2026-01-02T10:00:00Z",
"2026-01-03T10:00:00Z",
],
);
let sembrados = &siembra.identificadores;
let (repositorio, _) = destino
.en_marcha(PASSPHRASE_DE_PRUEBA)
.expect("el destino sembrado se abre");
let acotado = repositorio
.listar_snapshots(2)
.expect("el repositorio se puede listar");
assert_eq!(
acotado.len(),
2,
"la ventana es de dos y el listado devolvió {}: no está acotado",
acotado.len()
);
for resumen in &acotado {
assert_eq!(
resumen.id.len(),
64,
"el identificador del resumen no es el hex completo: {:?}",
resumen.id
);
assert!(
sembrados.contains(&resumen.id),
"el listado devolvió una instantánea que no está en el repositorio: {}",
resumen.id
);
let guardada = snapshot_guardado(&destino.ruta, PASSPHRASE_DE_PRUEBA, &resumen.id);
assert_eq!(
resumen.rutas,
guardada.paths.iter().cloned().collect::<Vec<String>>(),
"el resumen no trae las rutas que la instantánea declara"
);
assert_eq!(
resumen.etiqueta.as_deref(),
Some(ETIQUETA_SEMBRADA),
"el resumen no trae la etiqueta de la tarea"
);
assert!(
resumen.ficheros >= 1,
"el resumen dice que la instantánea procesó {} ficheros y el árbol tiene uno",
resumen.ficheros
);
assert!(
resumen.bytes > 0,
"el resumen dice que la instantánea procesó cero bytes"
);
assert!(
resumen.fecha > time::OffsetDateTime::UNIX_EPOCH,
"el resumen trae una fecha que no es la de una copia: {}",
resumen.fecha
);
}
assert!(
acotado[0].fecha > acotado[1].fecha,
"el listado va de la más antigua a la más reciente: {} y {}",
acotado[0].fecha,
acotado[1].fecha
);
let todos = repositorio
.listar_snapshots(100)
.expect("el repositorio se puede listar");
assert_eq!(
todos.len(),
3,
"con una ventana mayor que el repositorio tienen que salir las tres instantáneas"
);
assert_eq!(
todos[0].id, sembrados[2],
"con la ventana ancha, la primera del listado tiene que ser la más reciente"
);
assert!(
todos[0].fecha > todos[1].fecha && todos[1].fecha > todos[2].fecha,
"el listado no va de la más reciente a la más antigua: {:?}",
todos
.iter()
.map(|resumen| resumen.fecha)
.collect::<Vec<_>>()
);
assert_eq!(
todos[0].fecha,
time::macros::datetime!(2026-01-03 10:00:00 UTC),
"la fecha del resumen no es la de la instantánea: se convirtió por su \
representación local en vez de por su instante"
);
drop(repositorio);
}
#[test]
fn archivar_volcado_exige_que_el_nombre_de_la_copia_y_el_del_origen_coincidan() {
let destino = Destino::nuevo();
let (repositorio, _) = destino
.en_marcha(PASSPHRASE_DE_PRUEBA)
.expect("el destino se inicializa");
let copia = copia_de("volcado-de-tienda.sql", ETIQUETA);
let hecho = archivar(&repositorio, &copia, b"-- tienda\nSELECT 1;\n")
.expect("con los dos nombres iguales el volcado se archiva");
assert_eq!(
volcado_guardado(
&destino.ruta,
PASSPHRASE_DE_PRUEBA,
&hecho.id,
&copia.nombre
),
Some(b"-- tienda\nSELECT 1;\n".to_vec()),
"el volcado archivado no se puede leer con el nombre que la copia declara"
);
let antes = destino.instantanea();
let copia = copia_de("volcado-de-tienda.sql", ETIQUETA);
let tunel = tunel_del_volcado("volcado-de-otra-cosa.sql".to_owned(), 4, PLAZO_DE_PRUEBA)
.expect("el nombre del otro volcado es válido");
let productor = tunel.productor;
let emisor = std::thread::spawn(move || productor.terminar());
let error = repositorio
.archivar_volcado(&copia, &tunel.origen)
.expect_err("con los dos nombres distintos no se puede archivar");
emisor.join().expect("el hilo productor no puede caerse");
let Error::NombreDeVolcadoIncoherente {
en_la_copia,
en_el_origen,
} = &error
else {
panic!(
"una copia cuyo nombre no es el del nodo tiene que rechazarse nombrando la \
discrepancia, y se rechazó de otra forma: {error}"
);
};
assert_eq!(
en_la_copia, &copia.nombre,
"el error no dice el nombre que la copia declara"
);
assert_eq!(
en_el_origen,
tunel.origen.nombre(),
"el error no dice el nombre con el que el origen nombra su nodo"
);
assert_ne!(
en_la_copia, en_el_origen,
"los dos nombres del error son iguales: el error no describe una discrepancia"
);
assert_eq!(
destino.instantanea(),
antes,
"la copia con el nombre incoherente se rechazó **después** de escribir en el \
destino: la instantánea que declara una cosa y contiene otra quedó guardada"
);
drop(repositorio);
}
#[test]
fn el_nodo_del_volcado_declara_tamano_cero() {
let destino = Destino::nuevo();
let (repositorio, _) = destino
.en_marcha(PASSPHRASE_DE_PRUEBA)
.expect("el destino se inicializa");
let copia = copia_de("volcado-de-tienda.sql", ETIQUETA);
let contenido = b"-- tienda\nCREATE TABLE tienda (id INT);\n";
let hecho = archivar(&repositorio, &copia, contenido).expect("archivar no puede fallar");
let nodo = nodo_del_snapshot(
&destino.ruta,
PASSPHRASE_DE_PRUEBA,
&hecho.id,
&copia.nombre,
);
assert_eq!(
nodo.meta.size, 0,
"el nodo del volcado declara tamaño {} y el contrato dice que declara cero: el \
tamaño de un volcado por `stdout` no se conoce hasta que termina, y el único \
sitio donde existe es el resumen de la instantánea. Si el nodo lo llevara, el \
emparejamiento con la instantánea anterior lo compararía y la segunda copia \
del mismo destino se descartaría sin leerla",
nodo.meta.size
);
assert!(
hecho.bytes > 0,
"el volcado archivado no tiene bytes: la comprobación de arriba pasa por vacío"
);
assert_eq!(
volcado_guardado(
&destino.ruta,
PASSPHRASE_DE_PRUEBA,
&hecho.id,
&copia.nombre
),
Some(contenido.to_vec()),
"el nodo declara tamaño cero pero el contenido tiene que estar entero dentro"
);
assert_eq!(
hecho.bytes,
contenido.len() as u64,
"el tamaño real del volcado tiene que estar en el resumen, y el resumen dice {} \
para un contenido de {}",
hecho.bytes,
contenido.len()
);
drop(repositorio);
}
#[test]
fn un_volcado_de_longitud_cero_deja_un_nodo_vacio_y_cuenta_un_fichero() {
let destino = Destino::nuevo();
let (repositorio, _) = destino
.en_marcha(PASSPHRASE_DE_PRUEBA)
.expect("el destino se inicializa");
let copia = copia_de("volcado-vacio.sql", ETIQUETA);
let hecho = archivar(&repositorio, &copia, b"").expect("archivar no puede fallar");
assert_eq!(
hecho.ficheros, 1,
"un volcado de longitud cero tiene que dejar su nodo en el árbol —es la medición \
que decide que `ficheros` sirva como apoderado del éxito—, y el archivado contó \
{} ficheros. Si esto cambia, el apoderado cambia con él y lo que hay que usar \
para decidir el éxito es otra cosa",
hecho.ficheros
);
assert_eq!(
hecho.bytes, 0,
"un volcado vacío no puede tener bytes, y el archivado contó {}",
hecho.bytes
);
assert_eq!(
volcado_guardado(
&destino.ruta,
PASSPHRASE_DE_PRUEBA,
&hecho.id,
&copia.nombre
),
Some(Vec::new()),
"el nodo del volcado vacío no está en el árbol: `ficheros == 1` y el nodo ausente \
no pueden ser verdad a la vez"
);
let nodo = nodo_del_snapshot(
&destino.ruta,
PASSPHRASE_DE_PRUEBA,
&hecho.id,
&copia.nombre,
);
assert_eq!(
nodo.meta.size, 0,
"el nodo del volcado vacío declara tamaño {} y no puede declarar otro",
nodo.meta.size
);
drop(repositorio);
}
#[test]
fn el_listado_trae_tambien_las_instantaneas_que_no_creo_kesto() {
let destino = Destino::nuevo();
let siembra = sembrar_snapshots(
&destino.ruta,
PASSPHRASE_DE_PRUEBA,
&[
"2026-01-01T10:00:00Z",
"2026-01-02T10:00:00Z",
"2026-01-03T10:00:00Z",
],
);
let (ajena, nodo_ajeno) = sembrar_snapshot_ajeno(&destino.ruta, PASSPHRASE_DE_PRUEBA);
let (repositorio, _) = destino
.en_marcha(PASSPHRASE_DE_PRUEBA)
.expect("el destino sembrado se abre");
let todos = repositorio
.listar_snapshots(100)
.expect("el repositorio se puede listar");
assert_eq!(
todos.len(),
4,
"el repositorio tiene tres instantáneas sembradas y una ajena, y el listado \
devolvió {}: el listado no está saliendo del repositorio",
todos.len()
);
let resumen = todos
.iter()
.find(|resumen| resumen.id == ajena)
.unwrap_or_else(|| {
panic!(
"la instantánea ajena no está en el listado ({ajena}): el listado solo \
devuelve lo que el histórico de Kesto conoce, y su contrato dice que \
sale del repositorio"
)
});
assert_eq!(
resumen.etiqueta.as_deref(),
Some(ETIQUETA_AJENA),
"el resumen de la instantánea ajena no trae su etiqueta"
);
assert_eq!(
resumen.rutas.len(),
1,
"el resumen de la instantánea ajena no trae sus rutas: {:?}",
resumen.rutas
);
assert_ne!(
resumen.rutas,
snapshot_guardado(
&destino.ruta,
PASSPHRASE_DE_PRUEBA,
&siembra.identificadores[0]
)
.paths
.iter()
.cloned()
.collect::<Vec<String>>(),
"la instantánea ajena declara las mismas rutas que una sembrada: no es ajena, y \
esta prueba no mediría lo que dice medir"
);
assert!(
resumen.bytes > 0,
"el resumen de la instantánea ajena dice que procesó cero bytes"
);
assert_eq!(
volcado_guardado(&destino.ruta, PASSPHRASE_DE_PRUEBA, &ajena, &nodo_ajeno),
Some(AJENA_CONTENIDO.to_vec()),
"el contenido de la instantánea ajena no se puede restaurar y el resumen dice que \
está: el listado la leyó a medias"
);
assert_eq!(
todos[0].id, ajena,
"la instantánea ajena es la más reciente y no encabeza el listado"
);
drop(repositorio);
}
#[test]
fn un_repositorio_sin_claves_utilizables_se_reporta_como_una_passphrase_que_no_abre() {
let destino = Destino::nuevo();
let (repositorio, _) = destino
.en_marcha(PASSPHRASE_DE_PRUEBA)
.expect("el destino se inicializa");
drop(repositorio);
let mut quedan = 0;
for (relativa, _) in destino.instantanea() {
if !relativa.starts_with("config") {
std::fs::remove_file(destino.ruta.join(&relativa))
.expect("los ficheros del destino se pueden retirar");
} else {
quedan += 1;
}
}
assert_eq!(
quedan, 1,
"el destino tiene que quedarse con un solo fichero de configuración y quedaron \
{quedan}: sin él el desenlace sería «el repositorio no existe», que es otro caso"
);
let error = destino.en_marcha(PASSPHRASE_DE_PRUEBA).expect_err(
"un repositorio sin claves utilizables no puede abrirse, ni siquiera con la \
passphrase con la que se creó",
);
let Error::PassphraseNoAbre {
destino: nombrado,
motivo,
} = &error
else {
panic!(
"medido: sin claves utilizables la biblioteca devuelve el mismo código que con \
una contraseña equivocada —C002—, así que la variante tiene que ser la de la \
passphrase. Salió otra cosa: {error}"
);
};
assert!(
nombrado.contains(&destino.ruta.display().to_string()),
"el motivo del fallo no nombra el destino ({}): {nombrado}",
destino.ruta.display()
);
assert!(
motivo.contains("C002"),
"el motivo tiene que llevar el código con el que la biblioteca señala «no hay \
ninguna clave que acepte esta contraseña» —C002—, porque es el código el que \
decide el desenlace y no el texto: {motivo}"
);
}
#[test]
fn un_fallo_de_apertura_que_no_es_el_de_la_passphrase_es_un_fallo_del_destino() {
use std::os::unix::fs::PermissionsExt;
let destino = Destino::nuevo();
let (repositorio, _) = destino
.en_marcha(PASSPHRASE_DE_PRUEBA)
.expect("el destino se inicializa");
drop(repositorio);
let ficheros = destino.instantanea();
assert!(
!ficheros.is_empty(),
"el destino se inicializó y su foto está vacía"
);
let mut permisos_originales = Vec::new();
for (relativa, _) in &ficheros {
let camino = destino.ruta.join(relativa);
let originales = std::fs::metadata(&camino)
.expect("cada fichero del destino tiene metadatos")
.permissions();
let mut sin_permisos = originales.clone();
sin_permisos.set_mode(0o000);
std::fs::set_permissions(&camino, sin_permisos)
.expect("los permisos del destino se pueden cambiar");
permisos_originales.push((camino, originales));
}
let resultado = destino.en_marcha(PASSPHRASE_DE_PRUEBA);
for (camino, originales) in permisos_originales {
std::fs::set_permissions(&camino, originales)
.expect("los permisos del destino se pueden devolver");
}
let error = resultado.expect_err("con los ficheros ilegibles, el destino no abre");
let Error::DestinoNoAlcanzable {
destino: nombrado,
motivo,
} = &error
else {
panic!(
"un fallo de apertura que no es el de la passphrase —un error de permisos del \
backend— tiene que reportarse como fallo del destino y no como «la passphrase \
no abre»: una implementación que asignara esa variante a cualquier fallo de \
`open()` pasaría todas las demás pruebas. Salió: {error}"
);
};
assert!(
!matches!(error, Error::PassphraseNoAbre { .. }),
"el fallo de permisos del backend se confundió con una passphrase que no abre: \
{error}"
);
assert!(
nombrado.contains(&destino.ruta.display().to_string()),
"el fallo del destino no nombra el destino ({}): {nombrado}",
destino.ruta.display()
);
assert!(
motivo.contains("Permission denied") || motivo.contains("os error 13"),
"el motivo no es el del fallo de permisos que provoca la prueba, así que esta \
prueba puede estar midiendo otro fallo del destino: {motivo}"
);
}
#[test]
fn el_destino_de_s3_se_construye_con_el_esquema_bueno_y_no_con_el_malo() {
let destino = Destino::nuevo();
let acceso = destino.acceso(PASSPHRASE_DE_PRUEBA);
let opciones = opciones_de_s3(&acceso).expect("la fila de prueba es un destino válido");
let bueno = OpenDALBackend::new(ESQUEMA_DE_S3, opciones.clone()).unwrap_or_else(|error| {
panic!(
"el esquema `{ESQUEMA_DE_S3}` tiene que construir el backend sin red y falló: \
{}. Es el esquema que la biblioteca registra para S3; si esto falla, la \
constante que usa el módulo no es la que `opendal` espera",
error.display_log()
)
});
assert_eq!(
ESQUEMA_DE_S3, "s3",
"el módulo tiene que pasarle a `opendal` el **esquema** de S3, que es `s3`"
);
assert!(
bueno.location().ends_with(&acceso.bucket),
"la localización del backend ({}) no termina en el cubo de la fila ({})",
bueno.location(),
acceso.bucket
);
let backends = backends_de_s3(&acceso)
.expect("el camino de producción construye los backends de S3 sin red");
assert!(
backends.repository().location().ends_with(&acceso.bucket),
"el backend que construye el camino de producción no apunta al cubo de la fila"
);
let error = OpenDALBackend::new("opendal:s3", opciones).expect_err(
"`opendal:s3` no es un esquema: la ruta se parte por el primer `:` y lo que \
queda delante es el servicio que `opendal` busca en su registro",
);
let motivo = error.display_log();
assert!(
motivo.contains("opendal"),
"el error del esquema malo no nombra el esquema que no está registrado: {motivo}"
);
assert!(
motivo.contains("scheme is not registered"),
"el error del esquema malo no dice que el esquema no está registrado: {motivo}"
);
}
#[test]
fn los_valores_de_la_fila_llegan_al_destino_de_s3() {
let acceso = Acceso {
endpoint: "https://s3.example.test:9000".to_owned(),
bucket: "cesto-de-copias".to_owned(),
region: "eu-south-2".to_owned(),
access_key_id: "clave-de-acceso-distinguible".to_owned(),
secret_access_key: SecretString::new(CLAVE_SECRETA_DE_PRUEBA.to_owned()),
passphrase: SecretString::new(PASSPHRASE_DE_PRUEBA.to_owned()),
};
let opciones = opciones_de_s3(&acceso).expect("la fila describe un destino utilizable");
for (clave, valor) in [
("endpoint", acceso.endpoint.as_str()),
("region", acceso.region.as_str()),
("bucket", acceso.bucket.as_str()),
("access_key_id", acceso.access_key_id.as_str()),
] {
assert_eq!(
opciones.get(clave).map(String::as_str),
Some(valor),
"el valor de `{clave}` no llega al destino de S3: el mapa que se le entrega a \
la dependencia trae {:?}",
opciones.get(clave)
);
}
let backend = OpenDALBackend::new(ESQUEMA_DE_S3, opciones)
.expect("el esquema de S3 construye el backend sin red");
assert!(
backend.location().ends_with(&acceso.bucket),
"el backend construido no apunta al cubo de la fila: {}",
backend.location()
);
}
#[test]
fn un_fallo_al_construir_el_destino_de_s3_no_publica_la_clave_secreta() {
let acceso = Acceso {
endpoint: "https://s3.example.test:9000".to_owned(),
bucket: "cesto-de-copias".to_owned(),
region: "eu-south-2".to_owned(),
access_key_id: "clave-de-acceso-distinguible".to_owned(),
secret_access_key: SecretString::new(CLAVE_SECRETA_DE_PRUEBA.to_owned()),
passphrase: SecretString::new(PASSPHRASE_DE_PRUEBA.to_owned()),
};
let opciones = opciones_de_s3(&acceso).expect("la fila describe un destino utilizable");
assert_eq!(
opciones.get("secret_access_key").map(String::as_str),
Some(CLAVE_SECRETA_DE_PRUEBA),
"el mapa tiene que llevar la clave secreta en claro —la dependencia la necesita \
así—, o esta prueba no mediría lo que dice medir"
);
let error = OpenDALBackend::new("opendal:s3", opciones)
.expect_err("el esquema malo no construye nada");
let publicado = format!("{} {error:?} {}", error.display_log(), error);
assert!(
!publicado.is_empty() && publicado.trim().len() > 1,
"el error de la construcción está vacío, así que la comprobación de abajo pasaría \
sin medir nada"
);
assert!(
!publicado.contains(CLAVE_SECRETA_DE_PRUEBA),
"un error al construir el destino de S3 publicó la clave secreta: {publicado}"
);
assert!(
!publicado.contains(PASSPHRASE_DE_PRUEBA),
"un error al construir el destino de S3 publicó la passphrase: {publicado}"
);
}
#[test]
fn un_destino_de_s3_inalcanzable_da_un_error_limpio_y_sin_secretos() {
let acceso = Acceso {
endpoint: "http://127.0.0.1:9".to_owned(),
bucket: "cubo-al-que-nadie-responde".to_owned(),
region: "eu-south-2".to_owned(),
access_key_id: "clave-de-acceso-distinguible".to_owned(),
secret_access_key: SecretString::new(CLAVE_SECRETA_DE_PRUEBA.to_owned()),
passphrase: SecretString::new(OTRA_PASSPHRASE_DE_PRUEBA.to_owned()),
};
let mut opciones = opciones_de_s3(&acceso).expect("la fila describe un destino utilizable");
assert_eq!(
opciones.get("secret_access_key").map(String::as_str),
Some(CLAVE_SECRETA_DE_PRUEBA),
"el mapa tiene que llevar la clave secreta en claro —la dependencia la necesita así—, \
o la aserción de abajo no mediría lo que dice medir"
);
opciones.insert("retry".to_owned(), "0".to_owned());
let backend: std::sync::Arc<dyn rustic_core::WriteBackend> = std::sync::Arc::new(
OpenDALBackend::new(ESQUEMA_DE_S3, opciones)
.expect("el esquema de S3 construye el backend sin red"),
);
let backends = rustic_core::RepositoryBackends::new(backend, None);
let error = poner_en_marcha_con(&acceso, backends, barras_mudas())
.expect_err("un destino que no responde no puede poner nada en marcha");
let (destino, motivo) = match error {
Error::DestinoNoAlcanzable { destino, motivo } => (destino, motivo),
otro => panic!(
"un destino que no se alcanza tiene que llegar como `DestinoNoAlcanzable` y llegó \
como {otro:?}: confundirlo con una passphrase que no abre manda al operador a \
revisar lo que no es"
),
};
assert!(
destino.contains(&acceso.endpoint) && destino.contains(&acceso.bucket),
"el motivo no nombra el sitio al que no se llegó: {destino}"
);
assert!(
motivo.contains(&acceso.bucket),
"el motivo no nombra el cubo: {motivo}"
);
assert!(
!motivo.contains(CLAVE_SECRETA_DE_PRUEBA),
"el motivo de un fallo de S3 publica la clave secreta: {motivo}"
);
assert!(
!motivo.contains(OTRA_PASSPHRASE_DE_PRUEBA),
"el motivo de un fallo de S3 publica la passphrase: {motivo}"
);
}
#[test]
fn el_informe_de_retencion_lleva_los_bytes_que_el_plan_va_a_reclamar() {
let destino = Destino::nuevo();
let (repositorio, _) = destino
.en_marcha(PASSPHRASE_DE_PRUEBA)
.expect("el destino se inicializa");
let copia = copia_de("volcado-de-tienda.sql", ETIQUETA);
for contenido in [
&b"-- de ayer\nCREATE TABLE tienda (id INT);\n"[..],
&b"-- de hoy\nCREATE TABLE tienda (id INT);\nINSERT INTO tienda VALUES (7);\n"[..],
&b"-- de manana\nCREATE TABLE tienda (id INT);\nINSERT INTO tienda VALUES (8);\n\
INSERT INTO tienda VALUES (9);\n"[..],
] {
archivar(&repositorio, &copia, contenido).expect("archivar no puede fallar");
}
let informe = repositorio
.aplicar_retencion(&crate::rustic::Retencion {
keep_daily: 1,
keep_weekly: 0,
keep_monthly: 0,
})
.expect("aplicar la retención no puede fallar");
assert_eq!(
informe.retirados.len(),
2,
"con `keep_daily = 1` sobre tres instantáneas del mismo día tienen que sobrar dos, \
y sobraron {}",
informe.retirados.len()
);
assert!(
informe.purgado,
"la retención retiró instantáneas y no purgó"
);
assert!(
informe.bytes_a_reclamar > 0,
"los dos volcados retirados tenían contenido propio y el plan dice que no hay nada \
que reclamar: el informe no está leyendo el plan, o lo está leyendo después de \
que la purga hiciera su trabajo"
);
let destino = Destino::nuevo();
sembrar_snapshots(
&destino.ruta,
PASSPHRASE_DE_PRUEBA,
&[
"2026-01-01T10:00:00Z",
"2026-01-02T10:00:00Z",
"2026-01-03T10:00:00Z",
],
);
let (repositorio, _) = destino
.en_marcha(PASSPHRASE_DE_PRUEBA)
.expect("el destino sembrado se abre");
let informe = repositorio
.aplicar_retencion(&crate::rustic::Retencion {
keep_daily: 1,
keep_weekly: 0,
keep_monthly: 0,
})
.expect("aplicar la retención no puede fallar");
assert_eq!(
informe.retirados.len(),
2,
"la siembra tiene tres fechas en tres días distintos y `keep_daily = 1` tiene que \
retirar dos"
);
assert_eq!(
informe.bytes_a_reclamar, 0,
"las tres instantáneas sembradas comparten el mismo árbol, así que al retirar dos \
no queda nada sin referenciar y el plan no tiene nada que reclamar: el informe \
dice {}",
informe.bytes_a_reclamar
);
drop(repositorio);
}
#[test]
fn olvidar_instantanea_retira_la_instantanea_del_repositorio() {
let destino = Destino::nuevo();
let (repositorio, _) = destino
.en_marcha(PASSPHRASE_DE_PRUEBA)
.expect("el destino se inicializa");
let copia = copia_de("volcado-de-tienda.sql", ETIQUETA);
let hecho = archivar(
&repositorio,
&copia,
b"-- tienda\nCREATE TABLE tienda (id INT);\n",
)
.expect("archivar no puede fallar");
let antes = repositorio
.listar_snapshots(100)
.expect("el repositorio se puede listar");
assert_eq!(
antes.len(),
1,
"el destino recién inicializado tenía que tener una sola instantánea y dice {}",
antes.len()
);
repositorio
.olvidar_instantanea(&hecho.id)
.expect("retirar la instantánea recién escrita no puede fallar");
let despues = repositorio
.listar_snapshots(100)
.expect("el repositorio se puede listar");
assert!(
despues.iter().all(|resumen| resumen.id != hecho.id),
"la instantánea `{}` sigue en el repositorio después de olvidarla",
hecho.id
);
assert_eq!(
despues.len(),
0,
"el repositorio tenía una sola instantánea y tras olvidarla dice {}",
despues.len()
);
drop(repositorio);
}
#[test]
fn olvidar_instantanea_con_un_identificador_invalido_devuelve_error_sin_panico() {
let destino = Destino::nuevo();
let (repositorio, _) = destino
.en_marcha(PASSPHRASE_DE_PRUEBA)
.expect("el destino se inicializa");
for id in ["", "no-es-un-hexadecimal"] {
let fallo = repositorio
.olvidar_instantanea(id)
.expect_err("un identificador que no es un hex completo no puede olvidarse");
assert!(
matches!(fallo, Error::OlvidoFallido { .. }),
"el fallo de olvidar un identificador inválido no es `OlvidoFallido`: {fallo:?}"
);
}
drop(repositorio);
}
#[test]
fn tiene_la_instantanea_distingue_la_que_esta_de_la_que_no() {
let destino = Destino::nuevo();
let siembra = sembrar_snapshots(
&destino.ruta,
PASSPHRASE_DE_PRUEBA,
&["2026-01-01T10:00:00Z"],
);
let sembrada = siembra.identificadores[0].clone();
let (repositorio, _) = destino
.en_marcha(PASSPHRASE_DE_PRUEBA)
.expect("el destino sembrado se abre");
let ausente = "f".repeat(64);
assert_eq!(
sembrada.len(),
ausente.len(),
"los dos identificadores tienen que tener la misma forma, y el sembrado mide {}",
sembrada.len()
);
assert!(
repositorio
.tiene_la_instantanea(&sembrada)
.expect("preguntar por una instantánea con el repositorio abierto no puede fallar"),
"la instantánea sembrada está en el repositorio y la comprobación dijo que no: `{sembrada}`"
);
assert!(
!repositorio
.tiene_la_instantanea(&ausente)
.expect("preguntar por una instantánea que no está no es un fallo"),
"un identificador bien formado que no es el resumen de ninguna instantánea dice estar: \
`{ausente}`"
);
assert!(
!repositorio
.tiene_la_instantanea("no-es-un-hexadecimal")
.expect("una cadena que no puede ser un identificador no es un fallo: no está"),
"una cadena que no llega a ser un hex completo no puede ser la de ninguna instantánea"
);
drop(repositorio);
}
}