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 {
MissingHtml { code: u16, default_code: u16 },
Io(std::io::Error),
}
impl fmt::Display for RenderError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
RenderError::MissingHtml { code, default_code } => write!(
f,
"no hay página HTML para el error: ni `{code}.html` ni `{default_code}.html` \
están ni en `CONTENT_DIR` ni entre las páginas embebidas"
),
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),
RenderError::MissingHtml { .. } => None,
}
}
}
#[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)?;
Ok(autocontenido(&body, content_dir, theme))
}
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);
}
if let Some(body) = embedded::page(code) {
return Ok(body.as_bytes().to_vec());
}
if code != default_code
&& let Some(body) = embedded::page(default_code)
{
return Ok(body.as_bytes().to_vec());
}
Err(RenderError::MissingHtml { code, default_code })
}
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_503_HTML: &str = include_str!("../content/503.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 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(), FIXTURE_404_HTML.trim());
}
#[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(), FIXTURE_404_HTML.trim());
}
#[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_copia_embebida_del_codigo() {
let body = render_body(503, 404, Format::Html, &sin_contenido_dir(), tema_default())
.expect("la página embebida del `503` debe existir aunque no haya directorio");
assert_eq!(
body,
servida_autocontenida(EMBEDDED_503_HTML, EMBEDDED_CSS),
"sin directorio, el cuerpo tiene que ser la copia embebida de `content/503.html`, \
autocontenida con la hoja `default`"
);
}
#[test]
fn sin_directorio_el_cuerpo_cae_a_la_copia_embebida_del_defecto_conservando_el_codigo() {
let body = render_body(418, 404, Format::Html, &sin_contenido_dir(), tema_default())
.expect("la página embebida del defecto debe existir aunque no haya directorio");
assert_eq!(
body,
servida_autocontenida(EMBEDDED_404_HTML, EMBEDDED_CSS),
"no hay `418` embebida: el cuerpo tiene que ser la del defecto (`404`), autocontenida"
);
let page = render_page(&sin_contenido_config(), 418, Format::Html, tema_default())
.expect("la página embebida del defecto debe existir");
assert_eq!(
page.status,
StatusCode::IM_A_TEAPOT,
"el estado sigue siendo el código resuelto (`418`), nunca el del cuerpo"
);
assert_eq!(
page.body,
servida_autocontenida(EMBEDDED_404_HTML, EMBEDDED_CSS),
"el cuerpo es la copia embebida del defecto (`404`), autocontenida"
);
}
#[test]
fn sin_pagina_en_ninguna_capa_sigue_dando_missing_html() {
match render_body(599, 418, Format::Html, &sin_contenido_dir(), tema_default()) {
Err(RenderError::MissingHtml { .. }) => {}
other => {
panic!("sin página ni en el disco ni embebida debe ser `MissingHtml`: {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"
);
}
}