use std::fs;
use std::path::{Path, PathBuf};
const EXTENSIONES: [&str; 3] = ["yml", "yaml", "md"];
const DIRECTORIOS_IGNORADOS: [&str; 3] = [".git", "target", "openspec"];
const FICHERO_IGNORADO: &str = "PLAN.md";
const SUELO: [&str; 4] = [
"compose.yml",
"examples/traefik/compose.yml",
"examples/traefik/README.md",
"README.md",
];
const PREFIJO_LABEL: &str = "traefik.http.routers.";
const SUFIJO_REGLA: &str = ".rule:";
const NOMBRE_RESPALDO: &str = "catchall";
const VENTANA_REGLA: usize = 10;
const DEBE_LLEVAR: [&str; 3] = [
"HostRegexp(",
"!Path(`/metrics`)",
r"(!PathRegexp(`\.html?((%2[fF])|/|$)`) || Path(`/default.html`) || Path(`/default.htm`))",
];
const SINTAXIS_V2: &str = "HostRegexp(`{";
struct Respaldo {
fichero: String,
linea: usize,
regla: Option<String>,
}
fn descubrir_ficheros() -> Vec<String> {
let raiz = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let mut encontrados = Vec::new();
recorrer(&raiz, &raiz, &mut encontrados);
encontrados.sort();
encontrados
}
fn recorrer(dir: &Path, raiz: &Path, encontrados: &mut Vec<String>) {
let Ok(entradas) = fs::read_dir(dir) else {
return;
};
for entrada in entradas.flatten() {
let ruta = entrada.path();
let nombre = entrada.file_name();
let nombre = nombre.to_string_lossy();
if ruta.is_dir() {
if !DIRECTORIOS_IGNORADOS.contains(&nombre.as_ref()) {
recorrer(&ruta, raiz, encontrados);
}
continue;
}
if nombre.as_ref() == FICHERO_IGNORADO {
continue;
}
let extension = ruta
.extension()
.and_then(|e| e.to_str())
.unwrap_or_default();
if !EXTENSIONES.contains(&extension) {
continue;
}
if let Ok(relativa) = ruta.strip_prefix(raiz) {
encontrados.push(relativa.to_string_lossy().replace('\\', "/"));
}
}
}
fn valor_de_regla(cruda: &str) -> String {
let sin_comentario = cruda.split(" #").next().unwrap_or_default().trim();
if sin_comentario.len() >= 2 && sin_comentario.starts_with('"') && sin_comentario.ends_with('"')
{
sin_comentario[1..sin_comentario.len() - 1].replace("\\\\", "\\")
} else {
sin_comentario.to_string()
}
}
fn respaldos_de(fichero: &str, texto: &str) -> Vec<Respaldo> {
let lineas: Vec<&str> = texto.lines().collect();
let mut encontrados = Vec::new();
for (indice, linea) in lineas.iter().enumerate() {
let numero = indice + 1;
let como_label = linea.trim_start_matches(['#', ' ', '\t']);
let respaldo_label = como_label
.strip_prefix(PREFIJO_LABEL)
.and_then(|resto| resto.find(SUFIJO_REGLA).map(|posicion| (resto, posicion)))
.filter(|(resto, posicion)| {
let nombre = &resto[..*posicion];
!nombre.contains('.') && nombre.ends_with(NOMBRE_RESPALDO)
});
if let Some((resto, posicion)) = respaldo_label {
let regla = valor_de_regla(&resto[posicion + SUFIJO_REGLA.len()..]);
if !regla.contains(SINTAXIS_V2) {
encontrados.push(Respaldo {
fichero: fichero.to_string(),
linea: numero,
regla: Some(regla),
});
}
continue;
}
if linea.trim() == "catchall:" {
let hallada = (indice + 1..lineas.len())
.take(VENTANA_REGLA)
.find_map(|j| {
lineas[j]
.trim()
.strip_prefix("rule:")
.map(|regla| (j, regla))
});
match hallada {
Some((j, cruda)) => {
let regla = valor_de_regla(cruda);
if !regla.contains(SINTAXIS_V2) {
encontrados.push(Respaldo {
fichero: fichero.to_string(),
linea: j + 1,
regla: Some(regla),
});
}
}
None => encontrados.push(Respaldo {
fichero: fichero.to_string(),
linea: numero,
regla: None,
}),
}
}
}
encontrados
}
fn incumplimientos(respaldo: &Respaldo) -> Vec<String> {
let Some(regla) = &respaldo.regla else {
return vec![format!(
"{}:{} — la clave `catchall:` no declara una `rule:` en las {VENTANA_REGLA} líneas siguientes",
respaldo.fichero, respaldo.linea
)];
};
let mut fallos = Vec::new();
for exigida in DEBE_LLEVAR {
if !regla.contains(exigida) {
fallos.push(format!(
"{}:{} — la regla de respaldo {regla:?} no lleva `{exigida}`",
respaldo.fichero, respaldo.linea
));
}
}
fallos
}
fn fallos_de_cobertura(aportados: &[(String, usize)]) -> Vec<String> {
let mut fallos = Vec::new();
for esperado in SUELO {
let aporta = aportados
.iter()
.find(|(fichero, _)| fichero.as_str() == esperado)
.map(|(_, cuantos)| *cuantos)
.unwrap_or(0);
if aporta == 0 {
fallos.push(format!(
"{esperado} — no aportó ninguna definición de router de respaldo; \
si lo has quitado a propósito, quítalo también de `SUELO` en esta prueba"
));
}
}
fallos
}
fn exigir_sin_fallos(motivo: &str, fallos: &[String]) {
assert!(
fallos.is_empty(),
"{motivo} ({} incumplimiento(s)):\n- {}",
fallos.len(),
fallos.join("\n- ")
);
}
#[test]
fn cada_respaldo_de_traefik_catchall_excluye_metrics() {
let mut aportados: Vec<(String, usize)> = Vec::new();
let mut fallos = Vec::new();
for fichero in descubrir_ficheros() {
let ruta = Path::new(env!("CARGO_MANIFEST_DIR")).join(&fichero);
match fs::read_to_string(&ruta) {
Ok(texto) => {
let respaldos = respaldos_de(&fichero, &texto);
aportados.push((fichero.clone(), respaldos.len()));
for respaldo in &respaldos {
fallos.extend(incumplimientos(respaldo));
}
}
Err(error) => fallos.push(format!("no se pudo leer {fichero}: {error}")),
}
}
fallos.extend(fallos_de_cobertura(&aportados));
exigir_sin_fallos(
"cada router de respaldo del repositorio debe excluir `/metrics` de su regla",
&fallos,
);
}
#[test]
fn r1_una_exclusion_tras_comentario_de_cola_no_cuenta() {
let texto = "traefik.http.routers.x-catchall.rule: HostRegexp(`.+`) # && !Path(`/metrics`)\n";
let respaldos = respaldos_de("sintetico.yml", texto);
assert_eq!(
respaldos.len(),
1,
"la línea debe reconocerse como definición de respaldo"
);
let regla = respaldos[0]
.regla
.as_deref()
.expect("la regla se pudo leer");
assert_eq!(
regla, "HostRegexp(`.+`)",
"el comentario de cola no forma parte del valor de la regla"
);
assert!(
!incumplimientos(&respaldos[0]).is_empty(),
"una definición cuyo valor no lleva la exclusión debe considerarse incumplidora"
);
let texto = "catchall:\n rule: \"HostRegexp(`.+`)\" # && !Path(`/metrics`)\n";
let respaldos = respaldos_de("sintetico.yaml", texto);
assert_eq!(respaldos.len(), 1);
let regla = respaldos[0]
.regla
.as_deref()
.expect("la regla se pudo leer");
assert_eq!(regla, "HostRegexp(`.+`)");
assert!(
!incumplimientos(&respaldos[0]).is_empty(),
"la forma entrecomillada con comentario de cola tampoco lleva la exclusión"
);
}
#[test]
fn r2_una_regla_con_sintaxis_v2_no_es_un_respaldo() {
let texto = "traefik.http.routers.errserve-catchall.rule: HostRegexp(`{host:.+}`)\n";
assert!(
respaldos_de("sintetico.yml", texto).is_empty(),
"una regla v2 no casa con ninguna petición: no es un respaldo"
);
let texto =
"traefik.http.routers.errserve-catchall.rule: HostRegexp(`.+`) && !Path(`/metrics`)\n";
assert_eq!(
respaldos_de("sintetico.yml", texto).len(),
1,
"el mismo nombre con la sintaxis de v3 sí es un respaldo"
);
}
#[test]
fn r3_la_cobertura_es_un_suelo_no_un_techo() {
let completos = vec![
("compose.yml".to_string(), 1),
("examples/traefik/compose.yml".to_string(), 1),
("examples/traefik/README.md".to_string(), 1),
("README.md".to_string(), 1),
("examples/traefik/dynamic.yaml".to_string(), 2),
];
assert!(
fallos_de_cobertura(&completos).is_empty(),
"un fichero de más y conforme no debe ser un fallo"
);
let cojo = vec![
("compose.yml".to_string(), 0),
("examples/traefik/compose.yml".to_string(), 1),
("examples/traefik/README.md".to_string(), 1),
("README.md".to_string(), 1),
];
let fallos = fallos_de_cobertura(&cojo);
assert_eq!(
fallos.len(),
1,
"solo el fichero del suelo que dejó de aportar"
);
assert!(
fallos[0].starts_with("compose.yml —"),
"el fallo debe nombrar el fichero que dejó de aportar: {:?}",
fallos[0]
);
}
#[test]
fn r4_un_fichero_conforme_fuera_del_suelo_no_genera_incumplimiento() {
let texto = "http:\n routers:\n catchall:\n rule: \"HostRegexp(`.+`) && !Path(`/metrics`) && (!PathRegexp(`\\\\.html?((%2[fF])|/|$)`) || Path(`/default.html`) || Path(`/default.htm`))\"\n";
let respaldos = respaldos_de("examples/traefik/dynamic.yaml", texto);
assert_eq!(respaldos.len(), 1);
assert!(
incumplimientos(&respaldos[0]).is_empty(),
"una regla conforme no incumple por estar en un fichero fuera del suelo"
);
assert!(
!SUELO.contains(&"examples/traefik/dynamic.yaml"),
"el fichero del caso ha de estar, a propósito, fuera del suelo"
);
}
#[test]
fn r5_un_respaldo_sin_la_exclusion_del_leak_incumple() {
let texto = "traefik.http.routers.x-catchall.rule: HostRegexp(`.+`) && !Path(`/metrics`)\n";
let respaldos = respaldos_de("sintetico.yml", texto);
assert_eq!(
respaldos.len(),
1,
"la línea debe reconocerse como definición de respaldo"
);
let fallos = incumplimientos(&respaldos[0]);
assert!(
!fallos.is_empty(),
"una definición sin la exclusión del leak debe considerarse incumplidora"
);
let mensaje = fallos.join("\n");
assert!(
mensaje.contains("sintetico.yml:1"),
"el fallo debe nombrar el fichero y la línea: {mensaje:?}"
);
assert!(
mensaje.contains(DEBE_LLEVAR[2]),
"el fallo debe nombrar la conjunción que falta: {mensaje:?}"
);
let texto = "catchall:\n rule: \"HostRegexp(`.+`) && !Path(`/metrics`)\"\n";
let respaldos = respaldos_de("sintetico.yaml", texto);
assert_eq!(respaldos.len(), 1);
let fallos = incumplimientos(&respaldos[0]);
assert!(
!fallos.is_empty(),
"la forma de provider de fichero tampoco lleva la exclusión del leak"
);
let mensaje = fallos.join("\n");
assert!(
mensaje.contains("sintetico.yaml:2"),
"el fallo debe nombrar el fichero y la línea: {mensaje:?}"
);
assert!(
mensaje.contains(DEBE_LLEVAR[2]),
"el fallo debe nombrar la conjunción que falta: {mensaje:?}"
);
}
#[test]
fn r6_una_conjuncion_del_leak_tras_comentario_de_cola_no_cuenta() {
let texto = "traefik.http.routers.x-catchall.rule: HostRegexp(`.+`) && !Path(`/metrics`) # && (!PathRegexp(`\\.html?((%2[fF])|/|$)`) || Path(`/default.html`) || Path(`/default.htm`))\n";
let respaldos = respaldos_de("sintetico.yml", texto);
assert_eq!(
respaldos.len(),
1,
"la línea debe reconocerse como definición de respaldo"
);
let regla = respaldos[0]
.regla
.as_deref()
.expect("la regla se pudo leer");
assert!(
!regla.contains(DEBE_LLEVAR[2]),
"el comentario de cola no forma parte del valor: la conjunción del leak no debe estar en la regla leída, pero la regla es {regla:?}"
);
assert!(
!incumplimientos(&respaldos[0]).is_empty(),
"sin la conjunción en el valor, la definición incumple"
);
}
#[test]
fn r7_valor_de_regla_desdobla_la_barra_solo_si_va_entrecomillado() {
assert_eq!(
valor_de_regla(r#" "a\\.b" "#),
r"a\.b",
"un valor entrecomillado se lee con la barra desdoblada, como lo lee YAML"
);
assert_eq!(
valor_de_regla(r" a\\.b "),
r"a\\.b",
"en un valor plano la barra doblada se queda doblada: no es un escape de YAML"
);
let texto = "catchall:\n rule: \"HostRegexp(`.+`) && !Path(`/metrics`) && (!PathRegexp(`\\\\.html?((%2[fF])|/|$)`) || Path(`/default.html`) || Path(`/default.htm`))\"\n";
let respaldos = respaldos_de("sintetico.yaml", texto);
assert_eq!(respaldos.len(), 1);
assert!(
incumplimientos(&respaldos[0]).is_empty(),
"un valor entrecomillado con la barra doblada y la conjunción completa es conforme"
);
}
#[test]
fn r8_la_forma_anclada_al_fin_de_cadena_incumple() {
let texto = "traefik.http.routers.x-catchall.rule: HostRegexp(`.+`) && !Path(`/metrics`) && (!PathRegexp(`\\.html?$`) || Path(`/default.html`) || Path(`/default.htm`))\n";
let respaldos = respaldos_de("sintetico.yml", texto);
assert_eq!(
respaldos.len(),
1,
"la línea debe reconocerse como definición de respaldo"
);
let fallos = incumplimientos(&respaldos[0]);
assert!(
!fallos.is_empty(),
"la forma anclada al fin de cadena ha de incumplir"
);
let mensaje = fallos.join("\n");
assert!(
mensaje.contains("sintetico.yml:1"),
"el fallo debe nombrar el fichero y la línea: {mensaje:?}"
);
assert!(
mensaje.contains(DEBE_LLEVAR[2]),
"el fallo debe nombrar la subcadena que falta: {mensaje:?}"
);
}