use crate::config::Config;
use crate::resolve::{self, Format};
use crate::theme::Theme;
use axum::http::StatusCode;
use serde::Serialize;
use std::fmt;
use std::path::Path;
const X_ROBOTS_TAG_NAME: &str = "X-Robots-Tag";
pub const X_ROBOTS_TAG_VALUE: &str = "noindex, nofollow, nosnippet, noarchive";
const RETRY_AFTER_NAME: &str = "Retry-After";
#[derive(Debug)]
pub enum RenderError {
Io(std::io::Error),
}
impl fmt::Display for RenderError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
RenderError::Io(err) => {
write!(
f,
"no se pudo construir el cuerpo de la página de error: {err}"
)
}
}
}
}
impl std::error::Error for RenderError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
RenderError::Io(err) => Some(err),
}
}
}
#[derive(Debug)]
pub struct RenderedPage {
pub status: StatusCode,
pub content_type: &'static str,
pub headers: Vec<(&'static str, String)>,
pub body: Vec<u8>,
}
pub fn render_body(
code: u16,
format: Format,
content_dir: &Path,
theme: &Theme,
) -> Result<Vec<u8>, RenderError> {
match format {
Format::Html => html_body(code, content_dir, theme),
Format::Json => json_body(code),
Format::Xml => Ok(xml_body(code).into_bytes()),
Format::Text => Ok(text_body(code).into_bytes()),
}
}
pub fn render_page(
config: &Config,
code: u16,
format: Format,
theme: &Theme,
) -> Result<RenderedPage, RenderError> {
let body = render_body(code, format, &config.content_dir, theme)?;
Ok(RenderedPage {
status: status_of(code),
content_type: content_type_for(format),
headers: companion_headers(code, config.retry_after),
body,
})
}
pub fn render_text_fallback(code: u16, retry_after: u32) -> RenderedPage {
RenderedPage {
status: status_of(code),
content_type: content_type_for(Format::Text),
headers: companion_headers(code, retry_after),
body: text_body(code).into_bytes(),
}
}
fn status_of(code: u16) -> StatusCode {
StatusCode::from_u16(code).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR)
}
fn companion_headers(code: u16, retry_after: u32) -> Vec<(&'static str, String)> {
let mut headers = vec![(X_ROBOTS_TAG_NAME, X_ROBOTS_TAG_VALUE.to_string())];
if let Some(secs) = resolve::retry_after_secs(code, retry_after) {
headers.push((RETRY_AFTER_NAME, secs.to_string()));
}
headers
}
fn content_type_for(format: Format) -> &'static str {
match format {
Format::Html => "text/html; charset=utf-8",
Format::Json => "application/json",
Format::Xml => "application/xml",
Format::Text => "text/plain; charset=utf-8",
}
}
fn html_body(code: u16, content_dir: &Path, theme: &Theme) -> Result<Vec<u8>, RenderError> {
let pagina = theme.page(content_dir).map_err(RenderError::Io)?;
Ok(plantilla(&pagina, code))
}
pub(crate) fn plantilla(body: &[u8], code: u16) -> Vec<u8> {
rellena(
body,
code,
resolve::canonical_reason(code),
resolve::description(code),
)
}
pub(crate) fn rellena(body: &[u8], code: u16, razon: &str, descripcion: &str) -> Vec<u8> {
if !lleva_marcadores(body) {
return body.to_vec();
}
let codigo = code.to_string();
let razon = escape_html(razon);
let descripcion = escape_html(descripcion);
let cuerpo = reemplaza_todos(body, b"{{code}}", codigo.as_bytes());
let cuerpo = reemplaza_todos(&cuerpo, b"{{reason}}", razon.as_bytes());
reemplaza_todos(&cuerpo, b"{{description}}", descripcion.as_bytes())
}
fn lleva_marcadores(body: &[u8]) -> bool {
[
b"{{code}}".as_slice(),
b"{{reason}}".as_slice(),
b"{{description}}".as_slice(),
]
.iter()
.any(|marcador| busca(body, marcador).is_some())
}
fn reemplaza_todos(pajar: &[u8], aguja: &[u8], valor: &[u8]) -> Vec<u8> {
debug_assert!(
!aguja.is_empty(),
"la aguja no puede estar vacía: el bucle no terminaría"
);
let mut salida = Vec::with_capacity(pajar.len());
let mut resto = pajar;
while let Some(inicio) = busca(resto, aguja) {
salida.extend_from_slice(&resto[..inicio]);
salida.extend_from_slice(valor);
resto = &resto[inicio + aguja.len()..];
}
salida.extend_from_slice(resto);
salida
}
pub(crate) fn busca(pajar: &[u8], aguja: &[u8]) -> Option<usize> {
if aguja.is_empty() || aguja.len() > pajar.len() {
return None;
}
pajar
.windows(aguja.len())
.position(|ventana| ventana == aguja)
}
fn escape_html(texto: &str) -> String {
let mut escapado = String::with_capacity(texto.len());
for caracter in texto.chars() {
match caracter {
'&' => escapado.push_str("&"),
'<' => escapado.push_str("<"),
'>' => escapado.push_str(">"),
_ => escapado.push(caracter),
}
}
escapado
}
fn json_body(code: u16) -> Result<Vec<u8>, RenderError> {
let body = JsonBody {
error: true,
code,
message: resolve::canonical_reason(code),
description: resolve::description(code),
};
serde_json::to_vec(&body).map_err(|err| RenderError::Io(std::io::Error::other(err)))
}
#[derive(Debug, Serialize)]
struct JsonBody<'a> {
error: bool,
code: u16,
message: &'a str,
description: &'a str,
}
fn xml_body(code: u16) -> String {
let message = escape_xml(resolve::canonical_reason(code));
let description = escape_xml(resolve::description(code));
format!(
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\
<error><code>{code}</code><message>{message}</message><description>{description}</description></error>"
)
}
fn text_body(code: u16) -> String {
format!(
"{code} {}\n{}\n",
resolve::canonical_reason(code),
resolve::description(code)
)
}
fn escape_xml(value: &str) -> String {
value
.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
use crate::resolve::Format;
use axum::http::StatusCode;
use serde_json::Value;
use std::path::{Path, PathBuf};
const FIXTURE_DEFAULT_HTML: &str = include_str!("../tests/fixtures/content/default.html");
fn pagina_embebida(nombre: &str) -> &'static str {
crate::embedded::EMBEBIDOS
.iter()
.find(|(n, _)| *n == nombre)
.map(|(_, p)| *p)
.unwrap_or_else(|| panic!("el tema embebido `{nombre}` no existe"))
}
fn servida_del_fixture(code: u16) -> String {
let rellena = plantilla(FIXTURE_DEFAULT_HTML.as_bytes(), code);
let rellena =
String::from_utf8(rellena).expect("la plantilla del fixture, rellenada, es UTF-8");
assert!(
rellena.contains(&code.to_string()),
"red de la sustitución: el cuerpo servido del fixture tiene que \
**nombrar** su código (`{code}`), fue: {rellena}"
);
assert!(
!rellena.contains("{{"),
"red de la sustitución: no puede quedar ningún marcador sin rellenar, fue: {rellena}"
);
rellena
}
fn tema_default() -> &'static Theme {
use std::sync::OnceLock;
static DEFAULT: OnceLock<Theme> = OnceLock::new();
DEFAULT.get_or_init(|| {
crate::theme::Themes::incorporados()
.by_name(crate::theme::DEFAULT_THEME_NAME)
.cloned()
.expect("el tema por defecto tiene que estar en el registro")
})
}
fn fixtures_content_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/content")
}
fn fixtures_config() -> Config {
Config::from_env_iter([(
"CONTENT_DIR".to_string(),
fixtures_content_dir().to_string_lossy().into_owned(),
)])
.unwrap()
}
fn sin_contenido_dir() -> PathBuf {
let dir = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/inexistente-y-ademas-inexistente");
assert!(
!dir.exists(),
"el fixture `sin contenido` no puede existir: {}",
dir.display()
);
dir
}
fn sin_contenido_config() -> Config {
Config::from_env_iter([(
"CONTENT_DIR".to_string(),
sin_contenido_dir().to_string_lossy().into_owned(),
)])
.expect("la configuración de fixtures debe construirse")
}
fn header<'a>(page: &'a RenderedPage, name: &str) -> Option<&'a str> {
page.headers
.iter()
.find(|(key, _)| *key == name)
.map(|(_, value)| value.as_str())
}
#[test]
fn el_cuerpo_json_lleva_las_cuatro_claves() {
let body = render_body(404, Format::Json, &fixtures_content_dir(), tema_default()).unwrap();
let json: Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["error"], Value::Bool(true));
assert_eq!(json["code"], Value::from(404));
assert_eq!(json["message"], Value::from("Not Found"));
assert!(json["description"].is_string());
assert!(!json["description"].as_str().unwrap().is_empty());
}
#[test]
fn el_cuerpo_xml_lleva_el_elemento_code() {
let body = render_body(503, Format::Xml, &fixtures_content_dir(), tema_default()).unwrap();
let xml = String::from_utf8(body).unwrap();
assert!(
xml.contains("<code>503</code>"),
"el XML debe contener <code>503</code>, fue: {xml}"
);
assert!(xml.contains("<message>"), "el XML debe llevar <message>");
assert!(
xml.contains("<description>"),
"el XML debe llevar <description>"
);
}
#[test]
fn el_cuerpo_de_texto_lleva_codigo_y_razon() {
let body = render_body(503, Format::Text, &fixtures_content_dir(), tema_default()).unwrap();
let text = String::from_utf8(body).unwrap();
assert!(
text.contains("503"),
"el texto debe llevar el código: {text}"
);
assert!(
text.contains("Service Unavailable"),
"el texto debe llevar la razón canónica: {text}"
);
}
#[test]
fn el_cuerpo_html_sale_de_la_pagina_del_tema_del_operador() {
let body = render_body(404, Format::Html, &fixtures_content_dir(), tema_default()).unwrap();
let html = String::from_utf8(body).unwrap();
assert_eq!(html.trim(), servida_del_fixture(404).trim());
assert!(
html.contains("404"),
"el cuerpo tiene que nombrar su propio código (`404`): {html}"
);
assert!(
!html.contains("{{"),
"no puede quedar ningún marcador sin rellenar en el cuerpo: {html}"
);
}
#[test]
fn un_codigo_distinto_del_defecto_usa_la_pagina_del_tema() {
let body = render_body(418, Format::Html, &fixtures_content_dir(), tema_default()).unwrap();
let html = String::from_utf8(body).unwrap();
assert_eq!(html.trim(), servida_del_fixture(418).trim());
assert!(
html.contains("418"),
"la página del operador se rellena con el código pedido (`418`): {html}"
);
}
#[test]
fn el_cuerpo_no_depende_del_formato_para_el_codigo() {
for format in [Format::Html, Format::Json, Format::Xml, Format::Text] {
let body = render_body(503, format, &fixtures_content_dir(), tema_default()).unwrap();
assert!(!body.is_empty(), "cuerpo vacío para {format:?}");
}
}
#[test]
fn toda_pagina_de_error_lleva_x_robots_tag() {
for code in [404, 500, 503] {
let page = render_page(&fixtures_config(), code, Format::Json, tema_default()).unwrap();
assert_eq!(
header(&page, "X-Robots-Tag"),
Some("noindex, nofollow, nosnippet, noarchive"),
"X-Robots-Tag ausente o incorrecta para {code}"
);
}
}
#[test]
fn el_503_lleva_retry_after_120() {
let page = render_page(&fixtures_config(), 503, Format::Text, tema_default()).unwrap();
assert_eq!(header(&page, "Retry-After"), Some("120"));
}
#[test]
fn el_429_lleva_retry_after_120() {
let page = render_page(&fixtures_config(), 429, Format::Text, tema_default()).unwrap();
assert_eq!(header(&page, "Retry-After"), Some("120"));
}
#[test]
fn el_404_no_lleva_retry_after() {
let page = render_page(&fixtures_config(), 404, Format::Text, tema_default()).unwrap();
assert_eq!(header(&page, "Retry-After"), None);
}
#[test]
fn el_estado_es_el_codigo_resuelto_y_nunca_200() {
let page = render_page(&fixtures_config(), 404, Format::Html, tema_default()).unwrap();
assert_eq!(page.status, StatusCode::NOT_FOUND);
assert_ne!(page.status, StatusCode::OK);
let page = render_page(&fixtures_config(), 503, Format::Html, tema_default()).unwrap();
assert_eq!(page.status, StatusCode::SERVICE_UNAVAILABLE);
}
#[test]
fn el_content_type_sigue_al_formato() {
let content_type = |format| {
render_page(&fixtures_config(), 404, format, tema_default())
.unwrap()
.content_type
};
assert!(content_type(Format::Html).starts_with("text/html"));
assert!(content_type(Format::Json).starts_with("application/json"));
assert!(content_type(Format::Xml).starts_with("application/xml"));
assert!(content_type(Format::Text).starts_with("text/plain"));
}
#[test]
fn el_respaldo_de_texto_conserva_el_codigo_y_sus_cabeceras() {
let page = render_text_fallback(418, 120);
assert_eq!(page.status, StatusCode::IM_A_TEAPOT);
assert_ne!(page.status, StatusCode::INTERNAL_SERVER_ERROR);
assert_ne!(page.status, StatusCode::OK);
assert!(page.content_type.starts_with("text/plain"));
assert_eq!(
header(&page, "X-Robots-Tag"),
Some("noindex, nofollow, nosnippet, noarchive")
);
let body = String::from_utf8(page.body).unwrap();
assert!(
body.contains("418"),
"el cuerpo debe llevar el código: {body}"
);
}
#[test]
fn el_respaldo_de_texto_mantiene_retry_after() {
assert_eq!(
header(&render_text_fallback(503, 120), "Retry-After"),
Some("120")
);
assert_eq!(header(&render_text_fallback(404, 120), "Retry-After"), None);
}
fn fixtures_config_con_retry_after(value: &str) -> Config {
Config::from_env_iter([
(
"CONTENT_DIR".to_string(),
fixtures_content_dir().to_string_lossy().into_owned(),
),
("RETRY_AFTER".to_string(), value.to_string()),
])
.expect("la configuración de fixtures debe construirse")
}
#[test]
fn el_503_lleva_el_retry_after_configurado() {
let config = fixtures_config_con_retry_after("30");
let page = render_page(&config, 503, Format::Text, tema_default()).unwrap();
assert_eq!(
header(&page, "Retry-After"),
Some("30"),
"el `503` debe llevar el valor configurado, no el 120 del defecto"
);
}
#[test]
fn el_429_lleva_el_retry_after_configurado() {
let config = fixtures_config_con_retry_after("45");
let page = render_page(&config, 429, Format::Text, tema_default()).unwrap();
assert_eq!(
header(&page, "Retry-After"),
Some("45"),
"el `429` debe llevar el valor configurado, no el 120 del defecto"
);
}
#[test]
fn el_retry_after_cero_sale_en_la_cabecera() {
let config = fixtures_config_con_retry_after("0");
let page = render_page(&config, 503, Format::Text, tema_default()).unwrap();
assert_eq!(
header(&page, "Retry-After"),
Some("0"),
"un `RETRY_AFTER=0` debe emitir `Retry-After: 0`, no suprimir la cabecera"
);
}
#[test]
fn el_404_no_lleva_retry_after_ni_configurado() {
let config = fixtures_config_con_retry_after("30");
let page = render_page(&config, 404, Format::Text, tema_default()).unwrap();
assert_eq!(
header(&page, "Retry-After"),
None,
"un `404` no lleva `Retry-After` por mucho que haya `RETRY_AFTER` configurado"
);
}
#[test]
fn el_respaldo_de_texto_lleva_el_retry_after_configurado() {
let page = render_text_fallback(503, 30);
assert_eq!(
header(&page, "Retry-After"),
Some("30"),
"el respaldo del `503` debe llevar el valor configurado"
);
assert_eq!(page.status, StatusCode::SERVICE_UNAVAILABLE);
}
#[test]
fn sin_directorio_el_cuerpo_html_sale_de_la_pagina_embebida_del_tema() {
let body = render_body(503, Format::Html, &sin_contenido_dir(), tema_default())
.expect("la página embebida está siempre");
let rellenada = plantilla(pagina_embebida("default").as_bytes(), 503);
assert_eq!(
body, rellenada,
"sin directorio, el cuerpo tiene que ser la página embebida del tema rellenada con `503`"
);
assert!(
String::from_utf8(body).unwrap().contains("503"),
"la página rellenada tiene que nombrar su código"
);
}
#[test]
fn sin_directorio_un_codigo_sin_pagina_propia_recibe_la_pagina_con_su_codigo() {
let body = render_body(418, Format::Html, &sin_contenido_dir(), tema_default())
.expect("la página embebida está siempre");
let rellenada = plantilla(pagina_embebida("default").as_bytes(), 418);
let texto = String::from_utf8(body.clone()).expect("el cuerpo servido es UTF-8");
assert!(
texto.contains("418"),
"el cuerpo tiene que nombrar su propio código (`418`), no el defecto: {texto}"
);
assert!(
!texto.contains("{{"),
"no puede quedar ningún marcador sin rellenar en el cuerpo servido: {texto}"
);
assert_eq!(
body, rellenada,
"un código sin fichero propio recibe la página rellenada con **su** código (`418`)"
);
let page = render_page(&sin_contenido_config(), 418, Format::Html, tema_default())
.expect("la página embebida está siempre");
assert_eq!(
page.status,
StatusCode::IM_A_TEAPOT,
"el estado sigue siendo el código resuelto (`418`)"
);
assert_eq!(page.body, rellenada);
}
#[test]
fn la_cascada_es_un_solo_paso_y_el_unico_fallo_es_la_ilegibilidad() {
let body = render_body(599, Format::Html, &sin_contenido_dir(), tema_default())
.expect("la página embebida cubre cualquier código: nunca falta la página");
let texto = String::from_utf8(body).expect("la página servida es UTF-8");
assert!(
texto.contains("599"),
"un `599` sin fichero propio recibe la página rellenada con `599`: {texto}"
);
let ilegible = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/pagina-ilegible");
match render_body(404, Format::Html, &ilegible, tema_default()) {
Err(RenderError::Io(_)) => {}
other => panic!("una página ilegible tiene que ser `Io`: {other:?}"),
}
}
const PLANTILLA: &str =
"<title>{{code}}</title><h1>{{code}}</h1><h2>{{reason}}</h2><p>{{description}}</p>";
#[test]
fn la_plantilla_rellena_el_codigo_la_razon_y_la_descripcion() {
let rellena = plantilla(PLANTILLA.as_bytes(), 404);
let rellena = String::from_utf8(rellena).expect("la plantilla de prueba es UTF-8");
assert!(
!rellena.contains("{{"),
"no puede quedar ningún marcador sin rellenar: {rellena}"
);
assert!(
rellena.contains(resolve::canonical_reason(404)),
"la razón canónica tiene que estar en el cuerpo: {rellena}"
);
assert!(
rellena.contains(resolve::description(404)),
"la descripción tiene que estar en el cuerpo: {rellena}"
);
}
#[test]
fn la_plantilla_rellena_cada_aparicion() {
let rellena = plantilla(PLANTILLA.as_bytes(), 503);
let rellena = String::from_utf8(rellena).expect("la plantilla de prueba es UTF-8");
assert_eq!(
rellena.matches("503").count(),
2,
"las **dos** apariciones de `{{code}}` tienen que rellenarse, no solo la primera: \
{rellena}"
);
}
#[test]
fn una_pagina_sin_marcadores_se_sirve_byte_a_byte() {
let original = b"<p>esta pagina es mia y no lleva marcadores</p>";
assert_eq!(
plantilla(original, 404),
original.to_vec(),
"una pagina sin marcadores no se toca"
);
}
#[test]
fn la_sustitucion_es_por_bytes_y_respeta_lo_no_utf8() {
let original: &[u8] = b"<p>caf\xE9 \xF1 {{code}}</p>";
let rellena = plantilla(original, 404);
assert!(
rellena.contains(&0xE9),
"el byte `\\xE9` de la página tiene que sobrevivir intacto: {rellena:?}"
);
assert!(
rellena.contains(&0xF1),
"el byte `\\xF1` de la página tiene que sobrevivir intacto: {rellena:?}"
);
assert!(
!rellena.windows(3).any(|ventana| ventana == b"\xEF\xBF\xBD"),
"no puede aparecer el carácter de reemplazo U+FFFD: la sustitución es por bytes, no \
por `str`: {rellena:?}"
);
assert!(
rellena.windows(3).any(|ventana| ventana == b"404"),
"la sustitución tiene que ocurrir igual aunque la página no sea UTF-8: {rellena:?}"
);
}
#[test]
fn escape_html_escapa_lo_que_se_inserta() {
assert_eq!(
escape_html("a & b < c > d"),
"a & b < c > d",
"el `&`, el `<` y el `>` tienen que salir escapados: los inserta el servidor"
);
}
#[test]
fn la_sustitucion_escapa_lo_que_inserta() {
let rellena = rellena(
b"<h2>{{reason}}</h2><p>{{description}}</p>",
404,
"a & b",
"c < d > e",
);
let rellena = String::from_utf8(rellena).expect("la plantilla de prueba es UTF-8");
assert!(
rellena.contains("a & b"),
"la razón tiene que salir escapada: {rellena}"
);
assert!(
rellena.contains("c < d > e"),
"la descripción tiene que salir escapada: {rellena}"
);
assert!(
!rellena.contains('&') || !rellena.contains("< d"),
"no puede quedar ningún metacarácter sin escapar: {rellena}"
);
}
#[test]
fn un_valor_con_otro_marcador_sale_literal() {
let rellena = rellena(b"<p>{{reason}}</p>", 404, "x {{code}} y", "z");
let rellena = String::from_utf8(rellena).expect("la plantilla de prueba es UTF-8");
assert!(
rellena.contains("x {{code}} y"),
"el valor insertado no se reescanea: `{{code}}` tiene que quedar literal, \
fue: {rellena}"
);
}
#[test]
fn un_codigo_sin_fichero_propio_no_recibe_el_cuerpo_de_otro() {
let pagina = render_page(&fixtures_config(), 451, Format::Html, tema_default())
.expect("la página del tema siempre está");
let cuerpo = String::from_utf8(pagina.body).expect("el HTML es UTF-8");
assert!(
cuerpo.contains("451"),
"el cuerpo tiene que nombrar su propio código: {cuerpo}"
);
assert!(
!cuerpo.contains("404"),
"el cuerpo no puede nombrar el `404`: {cuerpo}"
);
}
}