pub mod crypto;
pub mod entities;
pub mod migrate;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::time::Duration;
use sqlx::SqlitePool;
use sqlx::migrate::Migrator;
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions};
use thiserror::Error;
use crate::config::Config;
pub const MAX_CONEXIONES: u32 = 5;
pub const BUSY_TIMEOUT: Duration = Duration::from_secs(5);
pub const SUFIJO_WAL: &str = "-wal";
#[derive(Debug, Error)]
pub enum DbError {
#[error(
"el directorio de la base de datos no existe: {} (revísalo: Kesto no lo crea)",
.ruta.display()
)]
DirectorioAusente {
ruta: PathBuf,
},
#[error(
"la ruta del directorio de la base de datos no es un directorio: {} (hay un fichero donde \
se esperaba un directorio)",
.ruta.display()
)]
RutaNoEsDirectorio {
ruta: PathBuf,
},
#[error("el DSN de la base de datos no se puede leer: {dsn}")]
DsnIlegible {
dsn: String,
#[source]
causa: sqlx::Error,
},
#[error("no se pudo abrir la base de datos en {}: {causa}", .ruta.display())]
Apertura {
ruta: PathBuf,
#[source]
causa: sqlx::Error,
},
#[error("no se pudieron aplicar las migraciones: {causa}")]
Migracion {
#[source]
causa: sqlx::migrate::MigrateError,
},
#[error("no se pudo aplicar el esquema de la base: {causa}")]
EsquemaNoAplicado {
#[source]
causa: sqlx::Error,
},
#[error("las migraciones dejaron filas huérfanas en: {tablas}")]
HuerfanosTrasMigrar {
tablas: String,
},
#[error("la consulta falló: {consulta}: {causa}")]
Consulta {
consulta: &'static str,
#[source]
causa: sqlx::Error,
},
#[error("el secreto de `{campo}` está vacío: un secreto vacío no se guarda")]
SecretoVacio {
campo: &'static str,
},
#[error(transparent)]
Cifrado(#[from] crate::db::crypto::ErrorDeCifrado),
}
pub fn ruta_del_dsn(dsn: &str) -> Result<PathBuf, DbError> {
let opciones = opciones_del_dsn(dsn)?;
Ok(opciones.get_filename().to_path_buf())
}
fn opciones_del_dsn(dsn: &str) -> Result<SqliteConnectOptions, DbError> {
let opciones = SqliteConnectOptions::from_str(dsn).map_err(|causa| DbError::DsnIlegible {
dsn: dsn.to_owned(),
causa,
})?;
Ok(opciones
.create_if_missing(true)
.foreign_keys(true)
.journal_mode(SqliteJournalMode::Wal)
.busy_timeout(BUSY_TIMEOUT))
}
fn exigir_directorio(ruta: &Path) -> Result<(), DbError> {
let Some(directorio) = ruta.parent().filter(|padre| !padre.as_os_str().is_empty()) else {
return Ok(());
};
if directorio.is_dir() {
return Ok(());
}
if directorio.exists() {
return Err(DbError::RutaNoEsDirectorio {
ruta: directorio.to_path_buf(),
});
}
Err(DbError::DirectorioAusente {
ruta: directorio.to_path_buf(),
})
}
pub async fn abrir(dsn: &str) -> Result<SqlitePool, DbError> {
let opciones = opciones_del_dsn(dsn)?;
let ruta = opciones.get_filename().to_path_buf();
exigir_directorio(&ruta)?;
let pool = SqlitePoolOptions::new()
.max_connections(MAX_CONEXIONES)
.connect_with(opciones)
.await
.map_err(|causa| DbError::Apertura {
ruta: ruta.clone(),
causa,
})?;
sqlx::query("SELECT 1")
.execute(&pool)
.await
.map_err(|causa| DbError::Apertura { ruta, causa })?;
Ok(pool)
}
pub async fn preparar(config: &Config) -> Result<SqlitePool, DbError> {
preparar_con(config, &migrate::migrador()).await
}
pub async fn preparar_con(config: &Config, migrator: &Migrator) -> Result<SqlitePool, DbError> {
let pool = abrir(&config.database_url).await?;
migrate::aplicar(&pool, migrator).await?;
Ok(pool)
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use std::time::{Duration, Instant};
use sqlx::Row;
use sqlx::SqlitePool;
use super::{BUSY_TIMEOUT, DbError, SUFIJO_WAL, abrir, preparar, preparar_con, ruta_del_dsn};
use crate::db::entities::{
AltaDeRepositorio, AltaDeTarget, Repositorios, Targets, TipoDeTarget,
};
use crate::secret::SecretString;
use crate::test_support::{
DirectorioDePrueba, base_con_clave, config_con_dsn, directorio_de_prueba, migrador_roto,
};
async fn tablas(pool: &SqlitePool) -> Vec<String> {
sqlx::query_scalar::<_, String>(
"SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name",
)
.fetch_all(pool)
.await
.expect("la consulta de tablas no puede fallar")
}
async fn esquema(pool: &SqlitePool) -> Vec<String> {
sqlx::query("SELECT type, name, sql FROM sqlite_master ORDER BY type, name")
.fetch_all(pool)
.await
.expect("la consulta del esquema no puede fallar")
.iter()
.map(|fila| {
let tipo: String = fila.try_get("type").expect("type es texto");
let nombre: String = fila.try_get("name").expect("name es texto");
let sql: Option<String> = fila.try_get("sql").expect("sql es texto o nulo");
format!("{tipo}|{nombre}|{}", sql.unwrap_or_default())
})
.collect()
}
async fn migraciones_registradas(pool: &SqlitePool) -> i64 {
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM _sqlx_migrations")
.fetch_one(pool)
.await
.expect("la tabla de control de migraciones debe existir")
}
#[tokio::test]
async fn el_fichero_de_la_base_se_crea_al_abrir() {
let directorio = directorio_de_prueba();
let ruta = directorio.ruta_de_la_base();
assert!(
!ruta.exists(),
"el aparato de prueba no debe crear el fichero por su cuenta"
);
let pool = abrir(&directorio.dsn())
.await
.expect("la base debe abrirse en un directorio que existe");
assert!(
ruta.exists(),
"abrir una base que no existe debe crear el fichero en {}",
ruta.display()
);
pool.close().await;
}
#[tokio::test]
async fn un_directorio_inexistente_hace_fallar_la_apertura_nombrando_la_ruta() {
let directorio = directorio_de_prueba();
let ausente = directorio.ruta().join("no-existe");
let dsn = format!("sqlite://{}/kesto.db", ausente.display());
let error = abrir(&dsn)
.await
.expect_err("un directorio ausente no puede dar una base abierta");
assert!(
matches!(error, DbError::DirectorioAusente { .. }),
"el fallo debe ser el directorio ausente, no otro: {error}"
);
assert!(
error.to_string().contains(&ausente.display().to_string()),
"el mensaje debe nombrar la ruta del directorio que falta: {error}"
);
assert!(
!ausente.exists(),
"el directorio no debe crearse: {}",
ausente.display()
);
}
#[test]
fn el_dsn_de_tres_barras_da_la_ruta_absoluta_sin_barra_de_mas() {
let config = crate::config::Config::from_lookup(&crate::test_support::lookup_from(&[(
"KESTO_MASTER_KEY",
crate::test_support::MASTER_KEY,
)]))
.expect("la configuración por defecto es válida");
assert_eq!(
config.database_url, "sqlite:///app/data/kesto.db",
"el defecto del SRS no es el que esta prueba cree"
);
let ruta = ruta_del_dsn(&config.database_url).expect("el DSN del SRS debe leerse");
assert_eq!(ruta, PathBuf::from("/app/data/kesto.db"));
assert!(ruta.is_absolute(), "la ruta debe ser absoluta: {ruta:?}");
assert_eq!(
ruta.file_name().and_then(|nombre| nombre.to_str()),
Some("kesto.db"),
"el nombre del fichero no debe quedar con una barra de más: {ruta:?}"
);
assert_eq!(
ruta_del_dsn("sqlite:///tmp/x/y.db").expect("el DSN debe leerse"),
PathBuf::from("/tmp/x/y.db")
);
}
#[tokio::test]
async fn tras_migrar_existen_las_cuatro_tablas_y_las_de_control() {
let directorio = directorio_de_prueba();
let pool = preparar(&config_con_dsn(&directorio.dsn()))
.await
.expect("preparar debe abrir la base y dejar el esquema aplicado");
let declaradas = tablas(&pool).await;
for esperada in [
"repositories",
"backup_targets",
"backup_jobs",
"job_executions",
"_sqlx_migrations",
] {
assert!(
declaradas.iter().any(|tabla| tabla == esperada),
"falta la tabla {esperada}: {declaradas:?}"
);
}
pool.close().await;
}
#[tokio::test]
async fn arrancar_dos_veces_no_cambia_el_esquema() {
let directorio = directorio_de_prueba();
let config = config_con_dsn(&directorio.dsn());
let pool = preparar(&config)
.await
.expect("el primer arranque debe dejar la base lista");
let antes = esquema(&pool).await;
let migraciones_antes = migraciones_registradas(&pool).await;
pool.close().await;
let pool = preparar(&config)
.await
.expect("el segundo arranque debe dejar la base lista");
let despues = esquema(&pool).await;
let migraciones_despues = migraciones_registradas(&pool).await;
pool.close().await;
assert_eq!(
migraciones_antes, migraciones_despues,
"el segundo arranque volvió a aplicar una migración ya aplicada"
);
assert!(
migraciones_antes >= 2,
"las dos migraciones deben quedar registradas: {migraciones_antes}"
);
assert_eq!(
antes, despues,
"el esquema cambió entre dos arranques sobre la misma base"
);
}
#[tokio::test]
async fn una_migracion_que_falla_es_error_de_arranque() {
let directorio = directorio_de_prueba();
let config = config_con_dsn(&directorio.dsn());
let error = preparar_con(&config, &migrador_roto())
.await
.expect_err("una migración que falla no puede dar un arranque en pie");
assert!(
matches!(error, DbError::Migracion { .. }),
"el fallo debe llegar como error de migración: {error}"
);
let pool = abrir(&directorio.dsn())
.await
.expect("la base debe poder abrirse para comprobar el registro");
let registradas = migraciones_registradas(&pool).await;
pool.close().await;
assert_eq!(
registradas, 0,
"una migración que falla no debe quedar registrada como aplicada"
);
}
#[tokio::test]
async fn el_journaling_queda_en_wal_y_aparece_el_fichero_wal() {
let directorio = directorio_de_prueba();
let pool = preparar(&config_con_dsn(&directorio.dsn()))
.await
.expect("preparar debe dejar la base lista");
let modo = sqlx::query_scalar::<_, String>("PRAGMA journal_mode")
.fetch_one(&pool)
.await
.expect("el modo de journaling debe poder consultarse");
assert_eq!(
modo.to_lowercase(),
"wal",
"el journaling debe quedar en WAL, no en {modo}"
);
let wal = PathBuf::from(format!(
"{}{SUFIJO_WAL}",
directorio.ruta_de_la_base().display()
));
assert!(
wal.exists(),
"WAL deja su fichero junto a la base y no está: {}",
wal.display()
);
pool.close().await;
}
#[tokio::test]
async fn las_claves_foraneas_estan_encendidas_en_toda_conexion_del_pool() {
let directorio = directorio_de_prueba();
let pool = preparar(&config_con_dsn(&directorio.dsn()))
.await
.expect("preparar debe dejar la base lista");
let mut conexiones = Vec::new();
for _ in 0..3 {
conexiones.push(
pool.acquire()
.await
.expect("el pool debe entregar una conexión"),
);
}
assert!(
pool.size() >= 3,
"el pool debe tener las tres conexiones abiertas a la vez, y tiene {}",
pool.size()
);
for conexion in &mut conexiones {
let encendidas = sqlx::query_scalar::<_, bool>("PRAGMA foreign_keys")
.fetch_one(&mut **conexion)
.await
.expect("el estado de las claves foráneas debe poder consultarse");
assert!(
encendidas,
"una conexión del pool tiene las claves foráneas apagadas: \
el PRAGMA suelto solo afecta a la conexión que lo ejecuta"
);
}
drop(conexiones);
pool.close().await;
}
#[cfg(unix)]
#[tokio::test]
async fn un_directorio_sin_permiso_de_escritura_hace_fallar_la_apertura() {
use std::os::unix::fs::PermissionsExt;
let directorio = directorio_de_prueba();
let solo_lectura = directorio.ruta().join("solo-lectura");
std::fs::create_dir(&solo_lectura).expect("el directorio de la prueba debe crearse");
std::fs::set_permissions(&solo_lectura, std::fs::Permissions::from_mode(0o555))
.expect("los permisos del directorio deben poder cambiarse");
if std::fs::File::create(solo_lectura.join("tanteo")).is_ok() {
std::fs::set_permissions(&solo_lectura, std::fs::Permissions::from_mode(0o755))
.expect("los permisos del directorio deben poder devolverse");
eprintln!(
"se omite la aserción de permisos del test 1.9: el directorio en solo lectura \
sigue siendo escribible —la suite corre como root o equivalente—, así que los \
permisos no impiden la escritura y no hay nada que medir"
);
return;
}
let resultado = abrir(&format!("sqlite://{}/kesto.db", solo_lectura.display())).await;
std::fs::set_permissions(&solo_lectura, std::fs::Permissions::from_mode(0o755))
.expect("los permisos del directorio deben poder devolverse");
let error = resultado
.expect_err("WAL necesita crear ficheros junto a la base: sin escritura, no hay base");
assert!(
error
.to_string()
.contains(&solo_lectura.display().to_string()),
"el mensaje debe nombrar la ruta de la base: {error}"
);
}
#[tokio::test]
async fn un_fichero_donde_se_espera_un_directorio_falla_nombrando_la_ruta() {
let directorio = directorio_de_prueba();
let fichero = directorio.ruta().join("no-soy-un-directorio");
std::fs::write(&fichero, b"contenido").expect("el fichero de la prueba debe crearse");
let dsn = format!("sqlite://{}/kesto.db", fichero.display());
let error = abrir(&dsn)
.await
.expect_err("un fichero donde se espera un directorio no puede dar una base abierta");
assert!(
matches!(error, DbError::RutaNoEsDirectorio { .. }),
"el fallo debe ser «no es un directorio», no otro: {error}"
);
assert!(
error.to_string().contains(&fichero.display().to_string()),
"el mensaje debe nombrar la ruta que no es un directorio: {error}"
);
assert!(
fichero.is_file(),
"la ruta debe seguir siendo un fichero: {}",
fichero.display()
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn una_escritura_que_coincide_con_otra_espera_en_vez_de_fallar() {
const VENTANA_PENDIENTE: Duration = Duration::from_millis(200);
let directorio = directorio_de_prueba();
let pool = preparar(&config_con_dsn(&directorio.dsn()))
.await
.expect("preparar debe dejar la base lista");
sqlx::query("CREATE TABLE prueba_de_bloqueo (x INTEGER)")
.execute(&pool)
.await
.expect("la tabla de la prueba debe crearse fuera de la transacción");
let (aviso, espera) = tokio::sync::oneshot::channel::<()>();
let (soltar, espera_suelta) = tokio::sync::oneshot::channel::<()>();
let pool_bloqueante = pool.clone();
let bloqueo = tokio::spawn(async move {
let mut conexion = pool_bloqueante
.acquire()
.await
.expect("el pool debe entregar la conexión que bloquea");
sqlx::query("BEGIN IMMEDIATE")
.execute(&mut *conexion)
.await
.expect("el bloqueo de escritura debe poder tomarse");
sqlx::query("INSERT INTO prueba_de_bloqueo (x) VALUES (0)")
.execute(&mut *conexion)
.await
.expect("la primera escritura debe poder hacerse");
aviso.send(()).expect("el test debe seguir esperando");
espera_suelta.await.expect("el test debe soltar el bloqueo");
sqlx::query("COMMIT")
.execute(&mut *conexion)
.await
.expect("el bloqueo debe poder soltarse");
});
espera.await.expect("el bloqueo debe establecerse");
let pool_segunda = pool.clone();
let mut segunda = tokio::spawn(async move {
sqlx::query("INSERT INTO prueba_de_bloqueo (x) VALUES (1)")
.execute(&pool_segunda)
.await
});
let inicio = Instant::now();
let pendiente = tokio::time::timeout(VENTANA_PENDIENTE, &mut segunda).await;
assert!(
pendiente.is_err(),
"la segunda escritura terminó pese al bloqueo: no esperó nada"
);
soltar.send(()).expect("el bloqueo debe poder soltarse");
let resultado = segunda
.await
.expect("la tarea de la segunda escritura no debe entrar en pánico");
let transcurrido = inicio.elapsed();
bloqueo
.await
.expect("la tarea que bloquea no debe entrar en pánico");
resultado.expect(
"la segunda escritura debe esperar al bloqueo y completarse, \
no rendirse antes de agotar el `busy_timeout`",
);
assert!(
transcurrido >= VENTANA_PENDIENTE,
"la segunda escritura no esperó al bloqueo: {transcurrido:?}"
);
assert!(
transcurrido < BUSY_TIMEOUT,
"la segunda escritura esperó más que el propio `busy_timeout`: {transcurrido:?}"
);
pool.close().await;
}
#[tokio::test]
async fn los_tres_secretos_no_aparecen_en_claro_en_el_fichero() {
const NOMBRE_DEL_REPOSITORIO: &str = "repo-1-47-buscable-en-el-fichero";
const SECRETO_ACCESO: &str = "AKIAIOSFODNN7EXAMPLE-1-47";
const PASSPHRASE: &str = "passphrase-distinguible-000000000000000000000047";
const PASSWORD_DE_BASE: &str = "db-password-distinguible-000000000000000000000047";
fn contiene(pajar: &[u8], aguja: &str) -> bool {
!aguja.is_empty()
&& pajar
.windows(aguja.len())
.any(|ventana| ventana == aguja.as_bytes())
}
fn bytes_de_los_ficheros(directorio: &DirectorioDePrueba) -> Vec<u8> {
let mut bytes = Vec::new();
for sufijo in ["", SUFIJO_WAL, "-shm"] {
let ruta = PathBuf::from(format!(
"{}{sufijo}",
directorio.ruta_de_la_base().display()
));
if let Ok(contenido) = std::fs::read(&ruta) {
bytes.extend_from_slice(&contenido);
}
}
bytes
}
let (directorio, pool, clave) = base_con_clave().await;
Repositorios::nuevo(pool.clone(), clave.clone())
.alta(AltaDeRepositorio {
name: NOMBRE_DEL_REPOSITORIO.to_owned(),
endpoint: "https://s3.example.test/repo-1-47".to_owned(),
bucket: "cubo-repo-1-47".to_owned(),
region: "eu-west-1".to_owned(),
access_key_id: "AKIAIOSFODNN7EXAMPLE".to_owned(),
secret_access_key: SecretString::new(SECRETO_ACCESO.to_owned()),
passphrase: SecretString::new(PASSPHRASE.to_owned()),
})
.await
.expect("el alta del repositorio no puede fallar");
Targets::nuevo(pool.clone(), clave)
.alta(AltaDeTarget {
name: "target-1-47".to_owned(),
target_type: TipoDeTarget::Mariadb,
container_name: Some("contenedor-1-47".to_owned()),
db_user: Some("usuario-de-base".to_owned()),
db_password: Some(SecretString::new(PASSWORD_DE_BASE.to_owned())),
db_name: Some("base-1-47".to_owned()),
host_path: None,
db_path: None,
})
.await
.expect("el alta del target no puede fallar");
let bytes = bytes_de_los_ficheros(&directorio);
assert!(
contiene(&bytes, NOMBRE_DEL_REPOSITORIO),
"el lector no encontró ni el nombre del repositorio, así que la ausencia de \
los secretos no prueba nada"
);
for (nombre, valor) in [
("secret_access_key", SECRETO_ACCESO),
("passphrase", PASSPHRASE),
("db_password", PASSWORD_DE_BASE),
] {
assert!(
!contiene(&bytes, valor),
"el valor en claro de {nombre} está en el fichero de la base"
);
}
}
}