use crate::config::Config;
use crate::embedded;
use crate::resolve::{self, Format};
use crate::theme::Theme;
use axum::http::StatusCode;
use serde::Serialize;
use std::fmt;
use std::io::ErrorKind;
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,
default_code: u16,
format: Format,
content_dir: &Path,
theme: &Theme,
) -> Result<Vec<u8>, RenderError> {
match format {
Format::Html => html_body(code, default_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,
config.default_error_page,
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,
default_code: u16,
content_dir: &Path,
theme: &Theme,
) -> Result<Vec<u8>, RenderError> {
let body = raw_html(code, default_code, content_dir)?;
let body = plantilla(&body, code);
Ok(autocontenido(&body, content_dir, theme))
}
pub(crate) fn plantilla(body: &[u8], code: u16) -> Vec<u8> {
rellena(
body,
code,
resolve::canonical_reason(code),
resolve::description(code),
)
}
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 raw_html(code: u16, default_code: u16, content_dir: &Path) -> Result<Vec<u8>, RenderError> {
if let Some(body) = read_html(content_dir, code).map_err(RenderError::Io)? {
return Ok(body);
}
if code != default_code
&& let Some(body) = read_html(content_dir, default_code).map_err(RenderError::Io)?
{
return Ok(body);
}
Ok(embedded::PAGE.as_bytes().to_vec())
}
fn autocontenido(body: &[u8], content_dir: &Path, theme: &Theme) -> Vec<u8> {
if marcador(body).is_none() {
return body.to_vec();
}
let hoja = theme.sheet(content_dir);
let mut salida = Vec::with_capacity(body.len() + hoja.len() + "<style>\n\n</style>".len());
let mut resto = body;
while let Some((inicio, fin)) = marcador(resto) {
salida.extend_from_slice(&resto[..inicio]);
salida.extend_from_slice(b"<style>\n");
salida.extend_from_slice(&hoja);
salida.extend_from_slice(b"\n</style>");
resto = &resto[fin..];
}
salida.extend_from_slice(resto);
salida
}
const MARCADOR_ETIQUETA: &[u8] = b"link";
const MARCADOR_HREF: &str = "/css/error.css";
fn marcador(html: &[u8]) -> Option<(usize, usize)> {
let mut cursor = 0;
while let Some(relativo) = html[cursor..].iter().position(|&byte| byte == b'<') {
let inicio = cursor + relativo;
let nombre = inicio + 1;
let es_link = html
.get(nombre..nombre + MARCADOR_ETIQUETA.len())
.is_some_and(|candidato| candidato.eq_ignore_ascii_case(MARCADOR_ETIQUETA))
&& html
.get(nombre + MARCADOR_ETIQUETA.len())
.copied()
.is_some_and(es_frontera_de_etiqueta);
if es_link
&& let Some((fin, atributos)) =
atributos_de_etiqueta(html, nombre + MARCADOR_ETIQUETA.len())
&& es_el_marcador(&atributos)
{
return Some((inicio, fin));
}
cursor = inicio + 1;
}
None
}
fn es_frontera_de_etiqueta(byte: u8) -> bool {
matches!(byte, b' ' | b'\t' | b'\n' | b'\r' | b'/' | b'>')
}
fn es_espacio(byte: u8) -> bool {
matches!(byte, b' ' | b'\t' | b'\n' | b'\r')
}
type Atributos<'a> = Vec<(&'a [u8], &'a [u8])>;
fn atributos_de_etiqueta(html: &[u8], mut cursor: usize) -> Option<(usize, Atributos<'_>)> {
let mut atributos = Atributos::new();
loop {
while html.get(cursor).copied().is_some_and(es_espacio) {
cursor += 1;
}
match html.get(cursor).copied()? {
b'>' => return Some((cursor + 1, atributos)),
b'/' => cursor += 1,
_ => {
let inicio_nombre = cursor;
while let Some(byte) = html.get(cursor).copied() {
if es_espacio(byte) || matches!(byte, b'=' | b'>' | b'/') {
break;
}
cursor += 1;
}
let nombre = &html[inicio_nombre..cursor];
while html.get(cursor).copied().is_some_and(es_espacio) {
cursor += 1;
}
let mut valor: &[u8] = b"";
if html.get(cursor) == Some(&b'=') {
cursor += 1;
while html.get(cursor).copied().is_some_and(es_espacio) {
cursor += 1;
}
match html.get(cursor).copied()? {
comilla @ (b'"' | b'\'') => {
cursor += 1;
let inicio_valor = cursor;
while html
.get(cursor)
.copied()
.is_some_and(|byte| byte != comilla)
{
cursor += 1;
}
valor = &html[inicio_valor..cursor];
if html.get(cursor).copied() == Some(comilla) {
cursor += 1;
}
}
_ => {
let inicio_valor = cursor;
while let Some(byte) = html.get(cursor).copied() {
if es_espacio(byte) || byte == b'>' {
break;
}
cursor += 1;
}
valor = &html[inicio_valor..cursor];
}
}
}
atributos.push((nombre, valor));
}
}
}
}
fn es_el_marcador(atributos: &[(&[u8], &[u8])]) -> bool {
let rel = atributos
.iter()
.find(|(nombre, _)| nombre.eq_ignore_ascii_case(b"rel"));
let href = atributos
.iter()
.find(|(nombre, _)| nombre.eq_ignore_ascii_case(b"href"));
match (rel, href) {
(Some((_, rel)), Some((_, href))) => {
rel.eq_ignore_ascii_case(b"stylesheet") && *href == MARCADOR_HREF.as_bytes()
}
_ => false,
}
}
fn read_html(content_dir: &Path, code: u16) -> Result<Option<Vec<u8>>, std::io::Error> {
match std::fs::read(content_dir.join(format!("{code}.html"))) {
Ok(body) => Ok(Some(body)),
Err(err) if err.kind() == ErrorKind::NotFound => Ok(None),
Err(err) => Err(err),
}
}
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_404_HTML: &str = include_str!("../tests/fixtures/content/404.html");
const EMBEDDED_404_HTML: &str = include_str!("../content/404.html");
const EMBEDDED_CSS: &str = include_str!("../content/css/error.css");
const MARCADOR: &str = r#"<link rel="stylesheet" href="/css/error.css" />"#;
fn servida_autocontenida(pagina: &str, hoja: &str) -> Vec<u8> {
pagina
.replace(MARCADOR, &format!("<style>\n{hoja}\n</style>"))
.into_bytes()
}
fn contiene(pajar: &[u8], aguja: &[u8]) -> bool {
pajar.windows(aguja.len()).any(|ventana| ventana == aguja)
}
fn servida_del_fixture(code: u16) -> String {
let rellena = plantilla(FIXTURE_404_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 {
crate::theme::default_theme()
}
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,
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,
404,
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,
404,
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_del_fichero_del_codigo() {
let body = render_body(
404,
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 el_cuerpo_html_cae_al_fichero_del_codigo_por_defecto() {
let body = render_body(
418,
404,
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 plantilla 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, 404, 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_plantilla_embebida() {
let body = render_body(503, 404, Format::Html, &sin_contenido_dir(), tema_default())
.expect("la plantilla embebida está siempre");
let rellenada = plantilla(EMBEDDED_404_HTML.as_bytes(), 503);
let rellenada = String::from_utf8(rellenada).expect("la plantilla rellenada es UTF-8");
assert_eq!(
body,
servida_autocontenida(&rellenada, EMBEDDED_CSS),
"sin directorio, el cuerpo tiene que ser la plantilla embebida rellenada con `503`, \
autocontenida con la hoja `default`"
);
assert!(
String::from_utf8(body).unwrap().contains("503"),
"la plantilla rellenada tiene que nombrar su código"
);
}
#[test]
fn sin_directorio_un_codigo_sin_pagina_propia_recibe_la_plantilla_con_su_codigo() {
let body = render_body(418, 404, Format::Html, &sin_contenido_dir(), tema_default())
.expect("la plantilla embebida está siempre");
let rellenada = plantilla(EMBEDDED_404_HTML.as_bytes(), 418);
let rellenada = String::from_utf8(rellenada).expect("la plantilla rellenada es UTF-8");
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,
servida_autocontenida(&rellenada, EMBEDDED_CSS),
"un código sin fichero propio recibe la plantilla rellenada con **su** código (`418`)"
);
let page = render_page(&sin_contenido_config(), 418, Format::Html, tema_default())
.expect("la plantilla embebida está siempre");
assert_eq!(
page.status,
StatusCode::IM_A_TEAPOT,
"el estado sigue siendo el código resuelto (`418`)"
);
assert_eq!(
page.body,
servida_autocontenida(&rellenada, EMBEDDED_CSS),
"el cuerpo es la plantilla rellenada con `418`, autocontenida"
);
}
#[test]
fn la_cascada_ya_no_se_queda_sin_pagina_y_el_unico_fallo_es_la_ilegibilidad() {
let body = render_body(599, 418, Format::Html, &sin_contenido_dir(), tema_default())
.expect("la plantilla 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 plantilla rellenada con `599`: {texto}"
);
let ilegible = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/pagina-ilegible");
match render_body(404, 404, Format::Html, &ilegible, tema_default()) {
Err(RenderError::Io(_)) => {}
other => panic!("una página ilegible tiene que ser `Io`: {other:?}"),
}
}
fn sirve(html: &str) -> String {
let salida = autocontenido(html.as_bytes(), &sin_contenido_dir(), tema_default());
String::from_utf8(salida).expect("la página servida de una entrada UTF-8 es UTF-8")
}
#[test]
fn el_marcador_se_sustituye_por_un_style_con_la_hoja() {
let salida = sirve(&format!(
"<html><head>{MARCADOR}</head><body>c404</body></html>"
));
assert!(
salida.contains("<style>"),
"el marcador tiene que salir como un bloque `<style>`, fue: {salida}"
);
assert!(
salida.contains(EMBEDDED_CSS),
"el bloque `<style>` tiene que llevar la hoja del tema, fue: {salida}"
);
assert!(
!salida.contains("rel="),
"el marcador no puede quedar en el cuerpo servido, fue: {salida}"
);
assert!(
salida.contains("<body>c404</body>"),
"solo se sustituye el marcador: el resto de la página queda igual, fue: {salida}"
);
}
#[test]
fn la_sustitucion_acepta_cualquier_orden_de_atributos_y_las_dos_comillas() {
let casos = [
r#"<link href="/css/error.css" rel="stylesheet" />"#,
r#"<link REL="StyleSheet" HREF="/css/error.css">"#,
r#"<link rel='stylesheet' href='/css/error.css'>"#,
r#"<link rel = "stylesheet" href = "/css/error.css" />"#,
];
for caso in casos {
let salida = sirve(&format!("<head>{caso}</head>"));
assert!(
salida.contains("<style>") && salida.contains(EMBEDDED_CSS),
"`{caso}` tiene que sustituirse por el `<style>` con la hoja, fue: {salida}"
);
assert!(
!salida.contains("href=\"/css/error.css\"") && !salida.contains("HREF="),
"`{caso}` no puede quedar en el cuerpo servido, fue: {salida}"
);
}
}
#[test]
fn sin_marcador_la_pagina_sale_byte_a_byte() {
let pagina = "<!doctype html>\n<html><head><title>x</title></head><body><h1>404</h1></body></html>\n";
assert_eq!(
sirve(pagina).as_bytes(),
pagina.as_bytes(),
"sin marcador el HTML se devuelve byte a byte"
);
}
#[test]
fn un_enlace_que_no_es_el_marcador_se_deja_en_paz() {
let ajeno = r#"<link rel="stylesheet" href="/css/otra.css">"#;
let sin_rel = r#"<link href="/css/error.css">"#;
let salida = sirve(&format!("<head>{ajeno}{sin_rel}{MARCADOR}</head>"));
assert!(
salida.contains(ajeno),
"un `<link>` que no es el marcador tiene que seguir en el cuerpo, fue: {salida}"
);
assert!(
salida.contains(sin_rel),
"un `<link>` sin `rel=\"stylesheet\"` tampoco es el marcador, fue: {salida}"
);
assert!(
salida.contains("<style>") && !salida.contains(MARCADOR),
"y el marcador, en medio de los dos, se sustituye, fue: {salida}"
);
}
#[test]
fn una_pagina_no_utf8_con_el_marcador_se_sirve_autocontenida() {
let pagina: &[u8] =
b"<!doctype html>\n<!-- \xE9\xF1 -->\n<html><head><link rel=\"stylesheet\" href=\"/css/error.css\" /></head></html>";
let salida = autocontenido(pagina, &sin_contenido_dir(), tema_default());
assert!(
contiene(&salida, b"<style>"),
"el marcador tiene que salir como un bloque `<style>`"
);
assert!(
contiene(&salida, EMBEDDED_CSS.as_bytes()),
"el bloque `<style>` tiene que llevar la hoja del tema"
);
assert!(
!contiene(&salida, MARCADOR.as_bytes()),
"el marcador no puede quedar en el cuerpo servido"
);
assert!(
contiene(&salida, b"\xE9\xF1"),
"los bytes no-UTF-8 (`\\xE9\\xF1`) tienen que seguir intactos en la salida"
);
}
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 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 plantilla 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`: es el defecto que este change corrige: {cuerpo}"
);
}
}