refactor: split the repo into self-contained validator, directory, quickstart, spec and site folders

validator/ holds mewslint (lint, css, fetch, DTD/CSS data); directory/ holds
the mews.page service (app, db, check, pages, cli) and depends on validator/;
quickstart/ holds mews-quickstart and md2mews.py for third parties who want
to create Mews content without cloning the repo; spec/ consolidates doc/ and
dtd/ with the canonical mews-0.1.css; site/ becomes a Pelican project with
the forward-facing pages as Markdown, the check form kept as a raw HTML
snippet. Root pyproject.toml is now a virtual uv workspace over validator,
directory and quickstart, with cross-cutting tests that span more than one
of them staying at the repo root. Makefile and flake.nix updated to match.
This commit is contained in:
randogoth 2026-10-13 12:29:23 +03:00
parent 5fa8708bd1
commit 060d4e00fd
90 changed files with 1290 additions and 287 deletions

131
validator/tests/conftest.py Normal file
View file

@ -0,0 +1,131 @@
"""Shared fixtures: fixture paths, and a local web server."""
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
import threading
import pytest
FIXTURES = Path(__file__).resolve().parent / "fixtures"
@pytest.fixture
def fail_page():
"""Read one of the non-conforming fixture pages."""
def read(name: str) -> bytes:
return (FIXTURES / "fail" / f"{name}.html").read_bytes()
return read
class Site:
"""A web server on loopback whose responses the test writes."""
def __init__(self) -> None:
self.routes: dict[str, tuple[int, dict[str, str], bytes, bool]] = {}
self.requests: list[tuple[str, dict[str, str]]] = []
self.delay = 0.0
self._server = ThreadingHTTPServer(("127.0.0.1", 0), _handler(self))
self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
self._thread.start()
@property
def base(self) -> str:
"""The address the server answers on."""
host, port = self._server.server_address[:2]
return f"http://{host}:{port}"
def add(
self,
path: str,
body: bytes = b"",
status: int = 200,
headers: dict[str, str] | None = None,
declare_length: bool = True,
) -> str:
"""Serve body at path, and return its full address.
With declare_length off the response carries no Content-Length and ends
at the connection close, which is how a streaming body reaches the
fetcher's byte cap.
"""
self.routes[path] = (status, headers or {}, body, declare_length)
return self.base + path
def close(self) -> None:
"""Stop the server."""
self._server.shutdown()
self._server.server_close()
def _handler(site: "Site"):
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def do_GET(self):
import time
site.requests.append((self.path, dict(self.headers)))
if site.delay:
time.sleep(site.delay)
status, headers, body, declare_length = site.routes.get(
self.path, (404, {}, b"not here", True)
)
if not declare_length:
self.protocol_version = "HTTP/1.0"
self.close_connection = True
self.send_response(status)
for name, value in headers.items():
self.send_header(name, value)
if "Content-Type" not in headers and status == 200:
self.send_header("Content-Type", "text/html; charset=utf-8")
if declare_length:
self.send_header("Content-Length", str(len(body)))
self.end_headers()
if body:
self.wfile.write(body)
def log_message(self, *args):
pass
def handle_one_request(self):
try:
super().handle_one_request()
except (BrokenPipeError, ConnectionResetError):
self.close_connection = True
return Handler
@pytest.fixture
def site():
"""A local web server for the checks that need a live page."""
server = Site()
yield server
server.close()
@pytest.fixture
def conforming():
"""A page that follows the spec, with a stylesheet link the test can point."""
def build(stylesheet: str = "mews-0.1.css", body: str = "<p>Hello.</p>") -> bytes:
return f"""<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html PUBLIC "-//WAPFORUM//DTD XHTML Mobile 1.2//EN"
"http://www.openmobilealliance.org/tech/DTD/xhtml-mobile12.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<title>Test site</title>
<meta name="mews-profile" content="0.1" />
<meta name="viewport" content="width=device-width" />
<meta name="description" content="A test page." />
<link rel="stylesheet" type="text/css" href="{stylesheet}" />
</head>
<body>
{body}
</body>
</html>
""".encode()
return build

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html PUBLIC "-//WAPFORUM//DTD XHTML Mobile 1.2//EN"
"http://www.openmobilealliance.org/tech/DTD/xhtml-mobile12.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<title>Test</title>
<meta name="mews-profile" content="0.1" />
<meta name="viewport" content="width=device-width" />
<link rel="stylesheet" type="text/css" href="mews-0.1.css" />
</head>
<body class="mews-pink">
<p>Hello.</p>
</body>
</html>

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html PUBLIC "-//WAPFORUM//DTD XHTML Mobile 1.2//EN"
"http://www.openmobilealliance.org/tech/DTD/xhtml-mobile12.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<title>Test</title>
<meta name="mews-profile" content="0.1" />
<meta name="viewport" content="width=device-width" />
<link rel="alternate" type="application/rss+xml" href="feed.xml" />
<link rel="stylesheet" type="text/css" href="mews-0.1.css" />
</head>
<body>
<p>Hello.</p>
</body>
</html>

View file

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html PUBLIC "-//WAPFORUM//DTD XHTML Mobile 1.2//EN"
"http://www.openmobilealliance.org/tech/DTD/xhtml-mobile12.dtd" [
<!ENTITY lol "lol">
<!ENTITY lol1 "&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;">
<!ENTITY lol2 "&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;">
]>
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<title>Test</title>
<meta name="mews-profile" content="0.1" />
<meta name="viewport" content="width=device-width" />
<link rel="stylesheet" type="text/css" href="mews-0.1.css" />
</head>
<body>
<p>&lol2;</p>
</body>
</html>

14
validator/tests/fixtures/fail/bom.html vendored Normal file
View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html PUBLIC "-//WAPFORUM//DTD XHTML Mobile 1.2//EN"
"http://www.openmobilealliance.org/tech/DTD/xhtml-mobile12.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<title>Test</title>
<meta name="mews-profile" content="0.1" />
<meta name="viewport" content="width=device-width" />
<link rel="stylesheet" type="text/css" href="mews-0.1.css" />
</head>
<body>
<p>Hello.</p>
</body>
</html>

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html PUBLIC "-//WAPFORUM//DTD XHTML Mobile 1.2//EN"
"http://www.openmobilealliance.org/tech/DTD/xhtml-mobile12.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<title>Test</title>
<meta name="mews-profile" content="0.1" />
<meta name="viewport" content="width=device-width" />
<link rel="stylesheet" type="text/css" href="mews-0.1.css" />
</head>
<body>
<p class="mews-warm">Hello.</p>
</body>
</html>

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html PUBLIC "-//WAPFORUM//DTD XHTML Mobile 1.2//EN"
"http://www.openmobilealliance.org/tech/DTD/xhtml-mobile12.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<title>Test</title>
<meta name="mews-profile" content="0.1" />
<meta name="viewport" content="width=device-width" />
<link rel="stylesheet" type="text/css" href="mews-0.1.css" />
</head>
<body>
<p><img src="data:image/gif;base64,R0lGODl=" alt="x" /></p>
</body>
</html>

14
validator/tests/fixtures/fail/div.html vendored Normal file
View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html PUBLIC "-//WAPFORUM//DTD XHTML Mobile 1.2//EN"
"http://www.openmobilealliance.org/tech/DTD/xhtml-mobile12.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<title>Test</title>
<meta name="mews-profile" content="0.1" />
<meta name="viewport" content="width=device-width" />
<link rel="stylesheet" type="text/css" href="mews-0.1.css" />
</head>
<body>
<div><p>Hello.</p></div>
</body>
</html>

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html PUBLIC "-//WAPFORUM//DTD XHTML Mobile 1.2//EN"
"http://www.openmobilealliance.org/tech/DTD/xhtml-mobile12.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<title>Test</title>
<meta name="mews-profile" content="0.1" />
<meta name="viewport" content="width=device-width" />
<link rel="stylesheet" type="text/css" href="mews-0.1.css" />
</head>
<body>
</body>
</html>

View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html PUBLIC "-//WAPFORUM//DTD XHTML Mobile 1.2//EN"
"http://www.openmobilealliance.org/tech/DTD/xhtml-mobile12.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<title>Test</title>
<link rel="stylesheet" type="text/css" href="mews-0.1.css" />
<meta name="mews-profile" content="0.1" />
</head>
<body>
<p>Hello.</p>
</body>
</html>

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html PUBLIC "-//WAPFORUM//DTD XHTML Mobile 1.2//EN"
"http://www.openmobilealliance.org/tech/DTD/xhtml-mobile12.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<title>Test</title>
<meta name="mews-profile" content="0.1" />
<meta name="viewport" content="width=device-width" />
<link rel="stylesheet" type="text/css" href="mews-0.1.css" />
</head>
<body>
<p><img src="a.png" /></p>
</body>
</html>

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html PUBLIC "-//WAPFORUM//DTD XHTML Mobile 1.2//EN"
"http://www.openmobilealliance.org/tech/DTD/xhtml-mobile12.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<title>Test</title>
<meta name="mews-profile" content="0.1" />
<meta name="viewport" content="width=device-width" />
<link rel="stylesheet" type="text/css" href="mews-0.1.css" />
</head>
<body>
<p><img src="a.png" alt="A square" width="10" /></p>
</body>
</html>

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html PUBLIC "-//WAPFORUM//DTD XHTML Mobile 1.2//EN"
"http://www.openmobilealliance.org/tech/DTD/xhtml-mobile12.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<title>Test</title>
<meta name="mews-profile" content="0.1" />
<meta name="viewport" content="width=device-width" />
<link rel="stylesheet" type="text/css" href="mews-0.1.css" />
</head>
<body>
<form action="/x" method="post"><fieldset><p><input type="email" name="e" /></p></fieldset></form>
</body>
</html>

View file

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html PUBLIC "-//WAPFORUM//DTD XHTML Mobile 1.2//EN"
"http://www.openmobilealliance.org/tech/DTD/xhtml-mobile12.dtd" [
<!ENTITY boom "kaboom">
]>
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<title>Test</title>
<meta name="mews-profile" content="0.1" />
<meta name="viewport" content="width=device-width" />
<link rel="stylesheet" type="text/css" href="mews-0.1.css" />
</head>
<body>
<p>&boom;</p>
</body>
</html>

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html PUBLIC "-//WAPFORUM//DTD XHTML Mobile 1.2//EN"
"http://www.openmobilealliance.org/tech/DTD/xhtml-mobile12.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<title>Test</title>
<meta name="mews-profile" content="0.1" />
<meta name="viewport" content="width=device-width" />
<link rel="canonical" href="/x" />
<link rel="stylesheet" type="text/css" href="mews-0.1.css" />
</head>
<body>
<p>Hello.</p>
</body>
</html>

View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html PUBLIC "-//WAPFORUM//DTD XHTML Mobile 1.2//EN"
"http://www.openmobilealliance.org/tech/DTD/xhtml-mobile12.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<title>Test</title>
<meta name="mews-profile" content="9.9" />
<link rel="stylesheet" type="text/css" href="mews-0.1.css" />
</head>
<body>
<p>Hello.</p>
</body>
</html>

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html PUBLIC "-//WAPFORUM//DTD XHTML Mobile 1.2//EN"
"http://www.openmobilealliance.org/tech/DTD/xhtml-mobile12.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<title>Test</title>
<meta name="mews-profile" content="0.1" />
<meta name="generator" content="hand" />
<link rel="stylesheet" type="text/css" href="mews-0.1.css" />
</head>
<body>
<p>Hello.</p>
</body>
</html>

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html PUBLIC "-//WAPFORUM//DTD XHTML Mobile 1.2//EN"
"http://www.openmobilealliance.org/tech/DTD/xhtml-mobile12.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<title>Test</title>
<meta name="mews-profile" content="0.1" />
<meta name="viewport" content="width=device-width" />
<link rel="stylesheet" type="text/css" href="mews-0.1.css" />
</head>
<body>
<table><tr><td><table><tr><td>x</td></tr></table></td></tr></table>
</body>
</html>

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html PUBLIC "-//WAPFORUM//DTD XHTML Mobile 1.2//EN"
"http://www.openmobilealliance.org/tech/DTD/xhtml-mobile12.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<title>Test</title>
<meta name="mews-profile" content="0.1" />
<meta name="viewport" content="width=device-width" />
<link rel="stylesheet" type="text/css" href="mews-0.1.css" />
</head>
<body><p>Hello.</p></body>
</html>

View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html PUBLIC "-//WAPFORUM//DTD XHTML Mobile 1.2//EN"
"http://www.openmobilealliance.org/tech/DTD/xhtml-mobile12.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<title>Test</title>
<meta name="viewport" content="width=device-width" />
<link rel="stylesheet" type="text/css" href="mews-0.1.css" />
</head>
<body>
<p>Hello.</p>
</body>
</html>

View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html PUBLIC "-//WAPFORUM//DTD XHTML Mobile 1.2//EN"
"http://www.openmobilealliance.org/tech/DTD/xhtml-mobile12.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<title>Test</title>
<meta name="mews-profile" content="0.1" />
<link rel="stylesheet" type="text/css" href="mews-0.1.css" />
</head>
<body>
<p>Hello.</p>
</body>
</html>

View file

@ -0,0 +1,13 @@
<!DOCTYPE html PUBLIC "-//WAPFORUM//DTD XHTML Mobile 1.2//EN"
"http://www.openmobilealliance.org/tech/DTD/xhtml-mobile12.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<title>Test</title>
<meta name="mews-profile" content="0.1" />
<meta name="viewport" content="width=device-width" />
<link rel="stylesheet" type="text/css" href="mews-0.1.css" />
</head>
<body>
<p>Hello.</p>
</body>
</html>

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html PUBLIC "-//WAPFORUM//DTD XHTML Mobile 1.2//EN"
"http://www.openmobilealliance.org/tech/DTD/xhtml-mobile12.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<title>Test</title>
<meta name="mews-profile" content="0.1" />
<meta name="viewport" content="width=device-width" />
<link rel="stylesheet" type="text/css" href="mews-0.1.css" />
</head>
<body>
<p>Hello.
</body>
</html>

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html PUBLIC "-//WAPFORUM//DTD XHTML Mobile 1.2//EN"
"http://www.openmobilealliance.org/tech/DTD/xhtml-mobile12.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<title>Test</title>
<meta name="mews-profile" content="0.1" />
<meta name="viewport" content="width=device-width" />
<link rel="stylesheet" type="text/css" href="mews-0.1.css" />
</head>
<body>
<p><img src="https://cdn.example.net/a.png" alt="x" /></p>
</body>
</html>

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html PUBLIC "-//WAPFORUM//DTD XHTML Mobile 1.2//EN"
"http://www.openmobilealliance.org/tech/DTD/xhtml-mobile12.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<title>Test</title>
<meta name="mews-profile" content="0.1" />
<meta name="viewport" content="width=device-width" />
<link rel="stylesheet" type="text/css" href="mews-0.1.css" />
</head>
<body>
<p>Hello.</p>
<script type="text/javascript">alert(1)</script>
</body>
</html>

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html PUBLIC "-//WAPFORUM//DTD XHTML Mobile 1.2//EN"
"http://www.openmobilealliance.org/tech/DTD/xhtml-mobile12.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<title>Test</title>
<meta name="mews-profile" content="0.1" />
<meta name="viewport" content="width=device-width" />
<link rel="stylesheet" type="text/css" href="mews-0.1.css" />
</head>
<body>
<p style="color: red">Hello.</p>
</body>
</html>

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html PUBLIC "-//WAPFORUM//DTD XHTML Mobile 1.2//EN"
"http://www.openmobilealliance.org/tech/DTD/xhtml-mobile12.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<title>Test</title>
<meta name="mews-profile" content="0.1" />
<meta name="viewport" content="width=device-width" />
<link rel="stylesheet" type="text/css" href="mews-0.1.css" />
</head>
<body>
<p>Hello.</p>
<style>p { color: red }</style>
</body>
</html>

14
validator/tests/fixtures/fail/sup.html vendored Normal file
View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html PUBLIC "-//WAPFORUM//DTD XHTML Mobile 1.2//EN"
"http://www.openmobilealliance.org/tech/DTD/xhtml-mobile12.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<title>Test</title>
<meta name="mews-profile" content="0.1" />
<meta name="viewport" content="width=device-width" />
<link rel="stylesheet" type="text/css" href="mews-0.1.css" />
</head>
<body>
<p>E = mc<sup>2</sup></p>
</body>
</html>

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html PUBLIC "-//WAPFORUM//DTD XHTML Mobile 1.2//EN"
"http://www.openmobilealliance.org/tech/DTD/xhtml-mobile12.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<title>Test</title>
<meta name="mews-profile" content="0.1" />
<meta name="viewport" content="width=device-width" />
<link rel="stylesheet" type="text/css" href="mews-0.1.css" />
</head>
<body>
<ul><li><table><tr><td>x</td></tr></table></li></ul>
</body>
</html>

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html PUBLIC "-//WAPFORUM//DTD XHTML Mobile 1.2//EN"
"http://www.openmobilealliance.org/tech/DTD/xhtml-mobile12.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<title>Test</title>
<meta name="mews-profile" content="0.1" />
<meta name="mews-profile" content="0.1" />
<link rel="stylesheet" type="text/css" href="mews-0.1.css" />
</head>
<body>
<p>Hello.</p>
</body>
</html>

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html PUBLIC "-//WAPFORUM//DTD XHTML Mobile 1.2//EN"
"http://www.openmobilealliance.org/tech/DTD/xhtml-mobile12.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<title>Test</title>
<meta name="mews-profile" content="0.1" />
<meta name="viewport" content="width=device-width" />
<link rel="stylesheet" type="text/css" href="mews-0.1.css" />
<link rel="stylesheet" type="text/css" href="extra.css" />
</head>
<body>
<p>Hello.</p>
</body>
</html>

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<title>Test</title>
<meta name="mews-profile" content="0.1" />
<meta name="viewport" content="width=device-width" />
<link rel="stylesheet" type="text/css" href="mews-0.1.css" />
</head>
<body>
<p>Hello.</p>
</body>
</html>

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html PUBLIC "-//WAPFORUM//DTD XHTML Mobile 1.2//EN"
"http://www.openmobilealliance.org/tech/DTD/xhtml-mobile12.dtd">
<html xml:lang="en" lang="en">
<head>
<title>Test</title>
<meta name="mews-profile" content="0.1" />
<meta name="viewport" content="width=device-width" />
<link rel="stylesheet" type="text/css" href="mews-0.1.css" />
</head>
<body><p>Hello.</p></body>
</html>

View file

@ -0,0 +1,60 @@
"""The section 5.2 comparison: an unmodified copy, plus added @font-face rules."""
from importlib import resources
from mews import css
CANONICAL = resources.files("mews.data").joinpath("mews-0.1.css").read_text("utf-8")
FONT_FACE = """@font-face {
font-family: "Atkinson Hyperlegible";
src: url(/fonts/atkinson.woff2) format("woff2");
}
"""
def test_an_unmodified_copy_passes():
assert css.compare(CANONICAL).equal
def test_line_endings_and_trailing_spaces_do_not_matter():
mangled = CANONICAL.replace("\n", " \r\n") + "\n\n\n"
assert css.compare(mangled).equal
def test_an_added_font_face_passes_and_is_read():
result = css.compare(FONT_FACE + CANONICAL)
assert result.equal
assert result.font_urls == ["/fonts/atkinson.woff2"]
def test_a_font_face_at_the_end_also_passes():
assert css.compare(CANONICAL + "\n" + FONT_FACE).equal
def test_a_changed_rule_fails_with_a_line_number():
result = css.compare(CANONICAL.replace("#faf8f3", "#ffffff"))
assert not result.equal
assert result.diff_line == 5
assert "background-color" in result.diff
def test_a_reordered_rule_fails():
lines = CANONICAL.split("\n")
swapped = "\n".join([*lines[:4], lines[5], lines[4], *lines[6:]])
assert not css.compare(swapped).equal
def test_a_removed_rule_fails():
assert not css.compare(CANONICAL.replace("hr {", "hr-off {")).equal
def test_an_import_is_noticed():
assert css.compare('@import url("x.css");\n' + CANONICAL).has_import
def test_a_font_face_inside_a_media_query_is_not_stripped():
inner = "@media screen {\n" + FONT_FACE + "}\n"
result = css.compare(CANONICAL + inner)
assert not result.equal

View file

@ -0,0 +1,31 @@
"""Registrable-domain comparison, which is what the same-site rules rest on."""
import pytest
from mews.fetch import registered_domain, same_site
CASES = [
("example.com", "example.com"),
("img.example.com", "example.com"),
("a.b.example.co.uk", "example.co.uk"),
("EXAMPLE.COM", "example.com"),
("example.com.", "example.com"),
("a.github.io", "a.github.io"),
("not-a-tld", None),
("", None),
]
@pytest.mark.parametrize(("host", "expected"), CASES)
def test_registrable_domain(host, expected):
assert registered_domain(host) == expected
def test_subdomains_of_one_domain_are_one_site():
assert same_site("example.com", "img.example.com")
def test_two_sites_on_a_shared_suffix_are_not_one_site():
"""The reason this uses a public suffix list rather than counting labels."""
assert not same_site("a.github.io", "b.github.io")
assert not same_site("example.co.uk", "evil.co.uk")

View file

@ -0,0 +1,175 @@
"""The fetcher's refusals.
The submit endpoint fetches addresses strangers type, so this is the test that
keeps the service from being used as a probe against its own machine.
"""
import pytest
from mews import fetch
from mews.fetch import Fetcher, FetchError, UrlError, normalise_url, vet_address
PRIVATE = [
"127.0.0.1",
"127.1.2.3",
"10.0.0.1",
"172.16.0.1",
"192.168.1.1",
"169.254.169.254",
"0.0.0.0",
"100.64.0.1",
"192.0.0.1",
"198.18.0.1",
"192.0.2.1",
"203.0.113.1",
"240.0.0.1",
"255.255.255.255",
"::1",
"::",
"fd00::1",
"fe80::1",
"::ffff:127.0.0.1",
"2002:7f00:1::",
"64:ff9b::7f00:1",
"2001:db8::1",
]
PUBLIC = ["93.184.216.34", "1.1.1.1", "194.68.44.29", "2606:2800:220:1::"]
REFUSED = [
"file:///etc/passwd",
"javascript:alert(1)",
"gopher://example.com/",
"http://user:pw@example.com/",
"http://example.com:22/",
"http://127.0.0.1/",
"http://2130706433/",
"http://0x7f.1/",
"http://[::1]/",
"http://localhost/",
"http://box.internal/",
"http://printer.local/",
"http://not-a-tld/",
"https://exa mple.com/",
"https://a_b.example.com/",
"",
]
@pytest.mark.parametrize("address", PRIVATE)
def test_addresses_off_the_public_internet_are_refused(address):
with pytest.raises(UrlError):
vet_address(address)
@pytest.mark.parametrize("address", PUBLIC)
def test_public_addresses_pass(address):
vet_address(address)
@pytest.mark.parametrize("url", REFUSED)
def test_addresses_the_checker_will_not_read(url):
with pytest.raises(UrlError):
normalise_url(url)
def test_a_bare_domain_becomes_https():
assert normalise_url("example.com") == "https://example.com/"
def test_the_fragment_is_dropped():
assert normalise_url("https://example.com/a#b") == "https://example.com/a"
def test_the_host_is_lowercased_and_punycoded():
assert normalise_url("https://EXAMPLE.com/") == "https://example.com/"
def test_the_extra_blocked_ranges_come_from_the_environment(monkeypatch):
"""The deployment blocks its own public address this way."""
monkeypatch.setattr(
fetch, "_EXTRA_NETS", [__import__("ipaddress").ip_network("194.68.44.28/32")]
)
with pytest.raises(UrlError):
vet_address("194.68.44.28")
vet_address("194.68.44.29")
def test_loopback_is_allowed_only_when_asked():
with pytest.raises(UrlError):
vet_address("127.0.0.1")
vet_address("127.0.0.1", allow_loopback=True)
# --- against a real server on loopback ---------------------------------
def test_a_page_is_read(site):
url = site.add("/a.html", b"<p>hi</p>")
with Fetcher(allow_loopback=True) as fetcher:
response = fetcher.get(url)
assert response.status == 200
assert response.body == b"<p>hi</p>"
def test_the_host_header_carries_the_name_not_the_address(site):
url = site.add("/a.html", b"x")
with Fetcher(allow_loopback=True) as fetcher:
fetcher.get(url)
_, headers = site.requests[-1]
assert headers["Host"] == url.split("//")[1].split("/")[0]
def test_a_redirect_is_followed(site):
target = site.add("/b.html", b"there")
site.add("/a.html", status=302, headers={"Location": target})
with Fetcher(allow_loopback=True) as fetcher:
response = fetcher.get(site.base + "/a.html")
assert response.body == b"there"
assert response.url == target
def test_a_redirect_to_a_private_address_is_refused(site):
site.add("/a.html", status=302, headers={"Location": "http://10.0.0.1/"})
with Fetcher(allow_loopback=True) as fetcher, pytest.raises(UrlError):
fetcher.get(site.base + "/a.html")
def test_a_redirect_loop_stops(site):
site.add("/a.html", status=302, headers={"Location": site.base + "/a.html"})
with Fetcher(allow_loopback=True) as fetcher, pytest.raises(FetchError):
fetcher.get(site.base + "/a.html")
def test_a_body_over_the_cap_is_cut_short(site):
url = site.add("/big.html", b"x" * 400_000, declare_length=False)
with Fetcher(allow_loopback=True) as fetcher:
response = fetcher.get(url)
assert response.truncated
assert len(response.body) == fetch.PAGE_CAP
def test_a_declared_length_over_the_cap_is_refused_unread(site):
url = site.add("/big.html", b"x" * 100, headers={"Content-Length": "9999999"})
with Fetcher(allow_loopback=True) as fetcher, pytest.raises(FetchError):
fetcher.get(url)
def test_a_slow_page_times_out(site):
url = site.add("/slow.html", b"x")
site.delay = 0.3
with (
Fetcher(allow_loopback=True, budget=0.05) as fetcher,
pytest.raises(FetchError),
):
fetcher.get(url)
def test_a_missing_page_is_reported_by_status(site):
with Fetcher(allow_loopback=True) as fetcher:
assert fetcher.get(site.base + "/nope.html").status == 404
def test_a_name_that_does_not_resolve_is_reported():
with Fetcher() as fetcher, pytest.raises(FetchError):
fetcher.get("https://this-name-does-not-exist.example/")

View file

@ -0,0 +1,78 @@
"""The DTD check, and the mapping from libxml2's messages to spec sections.
One test per message shape, so the mapping table in mews.lint stays pinned to
what libxml2 actually says.
"""
import pytest
from mews.lint import validate_bytes
# fixture name -> (section, level, code)
CASES = {
"script": ("4.1", "must", "dtd-element"),
"div": ("4.1", "must", "dtd-element"),
"sup": ("4.1", "must", "dtd-element"),
"style-element": ("5", "must", "dtd-styling"),
"style-attr": ("5", "must", "dtd-styling"),
"class-on-p": ("4.1", "must", "dtd-attribute"),
"img-width": ("4.1", "must", "dtd-attribute"),
"img-no-alt": ("4.1", "must", "dtd-required-attribute"),
"input-type-email": ("4.1", "must", "dtd-value"),
"nested-table": ("4.2", "must", "dtd-nested-table"),
"table-in-li": ("4.2", "must", "dtd-nested-table"),
"bad-body-class": ("5.3", "must", "dtd-variant"),
"empty-body": ("4.1", "must", "dtd-content"),
"head-link-before-meta": ("3.3", "must", "dtd-head"),
"link-bad-rel": ("3.3", "must", "link-rel"),
}
@pytest.mark.parametrize(("name", "expected"), CASES.items())
def test_dtd_failure_maps_to_its_section(fail_page, name, expected):
report = validate_bytes(fail_page(name))
section, level, code = expected
match = [f for f in report.findings if f.code == code]
assert match, f"{name}: no {code} finding in {[f.code for f in report.findings]}"
assert match[0].section == section
assert match[0].level == level
assert not report.conforms
def test_one_mistake_is_one_finding(fail_page):
"""A <script> makes libxml2 say three things; the author sees one."""
report = validate_bytes(fail_page("script"))
assert [f.code for f in report.findings] == ["dtd-element"]
def test_findings_carry_a_line_number(fail_page):
report = validate_bytes(fail_page("div"))
assert report.findings[0].location.startswith("line ")
def test_messages_name_the_element(fail_page):
report = validate_bytes(fail_page("div"))
assert "<div>" in report.findings[0].message
def test_enumerated_values_come_from_the_dtd(fail_page):
report = validate_bytes(fail_page("input-type-email"))
message = report.findings[0].message
assert "text, password, checkbox, radio, submit, reset, hidden" in message
def test_unmapped_message_still_gets_a_section():
from mews.lint import _map_error
code, message = _map_error("Something libxml2 made up", set())
assert code == "dtd-other"
assert "Mews DTD" in message
def test_entities_the_doctype_defines_are_resolved(conforming):
report = validate_bytes(conforming(body="<p>a&nbsp;b&mdash;c</p>"))
assert report.conforms
def test_a_conforming_page_passes(conforming):
assert validate_bytes(conforming()).conforms

View file

@ -0,0 +1,159 @@
"""The checks a DTD cannot express: prologue, head, sizes, images, transport."""
import pytest
from mews.fetch import Fetched
from mews.lint import SIZE_MUST, validate_bytes
CASES = {
"no-marker": ("3.2", "must", "marker"),
"two-markers": ("3.2", "must", "marker"),
"marker-wrong-version": ("3.2", "must", "marker-version"),
"meta-bad-name": ("3.3", "must", "meta-name"),
"two-stylesheets": ("3.3", "must", "link-stylesheet-count"),
"bad-feed-type": ("3.3", "must", "link-alternate-type"),
"no-xml-decl": ("3.1", "must", "xml-declaration"),
"bom": ("3.1", "must", "xml-declaration"),
"wrong-doctype": ("3.1", "must", "doctype"),
"internal-subset": ("3.1", "must", "internal-subset"),
"billion-laughs": ("3.1", "must", "internal-subset"),
"not-wellformed": ("3", "must", "well-formed"),
"wrong-namespace": ("3.1", "must", "root-element"),
"data-uri-img": ("4.2", "must", "image-data-uri"),
"no-lang": ("3.1", "should", "lang"),
"no-viewport": ("3.3", "should", "viewport"),
}
@pytest.mark.parametrize(("name", "expected"), CASES.items())
def test_rule_failure_maps_to_its_section(fail_page, name, expected):
report = validate_bytes(fail_page(name))
section, level, code = expected
match = [f for f in report.findings if f.code == code]
assert match, f"{name}: no {code} in {[f.code for f in report.findings]}"
assert (match[0].section, match[0].level) == (section, level)
def test_either_quote_character_opens_the_declaration(conforming):
"""Serialisers differ: lxml writes single quotes, and XML allows them."""
page = conforming().replace(
b'<?xml version="1.0" encoding="UTF-8"?>',
b"<?xml version='1.0' encoding='UTF-8'?>",
)
report = validate_bytes(page)
assert "xml-declaration" not in [f.code for f in report.findings]
assert report.conforms
def test_entity_declarations_are_refused_before_parsing(fail_page):
"""A page declaring its own entities is rejected without expanding them."""
report = validate_bytes(fail_page("billion-laughs"))
assert [f.code for f in report.findings] == ["internal-subset"]
def test_should_level_findings_still_conform(fail_page):
assert validate_bytes(fail_page("no-viewport")).conforms
assert validate_bytes(fail_page("no-lang")).conforms
def test_offsite_image_needs_the_page_address(fail_page):
"""Without a URL there is no site to compare against, so nothing is claimed."""
page = fail_page("offsite-img")
assert not [f for f in validate_bytes(page).findings if f.code == "image-offsite"]
report = validate_bytes(page, url="https://example.com/x.html")
assert [f.code for f in report.findings if f.code == "image-offsite"]
def test_subdomains_of_one_site_are_the_same_site(conforming):
page = conforming(body='<p><img src="https://img.example.com/a.png" alt="a" /></p>')
report = validate_bytes(page, url="https://example.com/x.html")
assert not [f for f in report.findings if f.code == "image-offsite"]
def test_oversized_page_is_reported_not_truncated(conforming):
filler = "<p>" + ("x" * 200) + "</p>\n "
page = conforming(body=filler * (SIZE_MUST // 200 + 20))
report = validate_bytes(page)
assert "size-must" in [f.code for f in report.findings]
assert not report.conforms
def test_page_between_the_two_size_limits_only_warns(conforming):
filler = "<p>" + ("x" * 200) + "</p>\n "
page = conforming(body=filler * 400)
report = validate_bytes(page)
assert "size-should" in [f.code for f in report.findings]
assert report.conforms
def _response(url="https://example.com/", headers=None, status=200):
import httpx
return Fetched(url, status, httpx.Headers(headers or {}), b"", False)
def test_a_cookie_on_the_page_is_a_failure(conforming):
report = validate_bytes(
conforming(),
url="https://example.com/",
response=_response(
headers={"set-cookie": "a=1", "etag": '"x"', "content-type": "text/html"}
),
)
cookie = [f for f in report.findings if f.code == "cookie"]
assert cookie and cookie[0].section == "7.4"
assert not report.conforms
def test_missing_cache_validators_warn(conforming):
report = validate_bytes(
conforming(),
url="https://example.com/",
response=_response(headers={"content-type": "text/html"}),
)
assert "validators" in [f.code for f in report.findings]
assert report.conforms
def test_plain_http_warns(conforming):
report = validate_bytes(
conforming(),
url="http://example.com/",
response=_response(
url="http://example.com/",
headers={"content-type": "text/html", "etag": '"x"'},
),
)
assert "https" in [f.code for f in report.findings]
def test_wrong_content_type_warns(conforming):
report = validate_bytes(
conforming(),
url="https://example.com/",
response=_response(headers={"content-type": "text/plain", "etag": '"x"'}),
)
assert "content-type" in [f.code for f in report.findings]
def test_title_and_description_are_picked_up(conforming):
report = validate_bytes(conforming())
assert report.title == "Test site"
assert report.description == "A test page."
assert report.language == "en"
def test_control_characters_are_stripped_from_the_title():
page = """<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html PUBLIC "-//WAPFORUM//DTD XHTML Mobile 1.2//EN"
"http://www.openmobilealliance.org/tech/DTD/xhtml-mobile12.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<title>Good‮evil site</title>
<meta name="mews-profile" content="0.1" />
</head>
<body><p>x</p></body>
</html>
""".encode()
report = validate_bytes(page)
assert report.title == "Goodevil site"

View file

@ -0,0 +1,166 @@
"""The checks that read a page's stylesheet and images from a live server."""
from importlib import resources
import zlib
import pytest
from mews.fetch import Fetcher
from mews.lint import validate_bytes
CANONICAL = resources.files("mews.data").joinpath("mews-0.1.css").read_bytes()
FONT_FACE = b"""@font-face {
font-family: "Atkinson Hyperlegible";
src: url(/fonts/atkinson.woff2) format("woff2");
}
"""
PNG = (
b"\x89PNG\r\n\x1a\n"
b"\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00"
b"\x1f\x15\xc4\x89"
b"\x00\x00\x00\x00IEND\xaeB`\x82"
)
@pytest.fixture
def check(site):
"""Validate a page served by the local server, reading its sub-resources."""
def run(page: bytes, path: str = "/index.html"):
url = site.add(path, page, headers={"ETag": '"v1"'})
with Fetcher(allow_loopback=True) as fetcher:
return validate_bytes(page, url=url, fetcher=fetcher)
return run
def codes(report):
return [finding.code for finding in report.findings]
def test_an_unmodified_stylesheet_copy_passes(site, check, conforming):
site.add("/mews-0.1.css", CANONICAL)
report = check(conforming())
assert "stylesheet-modified" not in codes(report)
def test_an_added_font_face_passes(site, check, conforming):
site.add("/mews-0.1.css", FONT_FACE + CANONICAL)
site.add("/fonts/atkinson.woff2", b"font")
report = check(conforming())
assert "stylesheet-modified" not in codes(report)
assert "font-offsite" not in codes(report)
def test_a_changed_stylesheet_fails(site, check, conforming):
site.add("/mews-0.1.css", CANONICAL.replace(b"#faf8f3", b"#ffffff"))
report = check(conforming())
failure = [f for f in report.findings if f.code == "stylesheet-modified"]
assert failure and failure[0].section == "5.2"
assert not report.conforms
def test_a_font_from_another_site_fails(site, check, conforming):
off = FONT_FACE.replace(b"/fonts/atkinson.woff2", b"https://fonts.example.net/a")
site.add("/mews-0.1.css", off + CANONICAL)
report = check(conforming())
assert "font-offsite" in codes(report)
assert not report.conforms
def test_an_import_in_the_stylesheet_fails(site, check, conforming):
site.add("/mews-0.1.css", b'@import url("x.css");\n' + CANONICAL)
report = check(conforming())
assert "stylesheet-import" in codes(report)
def test_a_missing_stylesheet_only_warns(site, check, conforming):
report = check(conforming())
warning = [f for f in report.findings if f.code == "stylesheet-unreachable"]
assert warning and warning[0].level == "should"
def test_a_stylesheet_on_another_site_fails(site, check, conforming):
site.add("/mews-0.1.css", CANONICAL)
report = check(conforming(stylesheet="https://cdn.example.net/mews-0.1.css"))
assert "stylesheet-offsite" in codes(report)
assert not report.conforms
def test_the_central_stylesheet_only_warns(site, check, conforming):
"""Linking mews.page's own copy is discouraged, not a failure."""
report = check(conforming(stylesheet="https://mews.page/mews-0.1.css"))
assert "stylesheet-offsite" not in codes(report)
canonical = [f for f in report.findings if f.code == "stylesheet-canonical"]
assert canonical and canonical[0].level == "should"
def test_a_cookie_on_the_stylesheet_fails(site, check, conforming):
site.add("/mews-0.1.css", CANONICAL, headers={"Set-Cookie": "a=1"})
report = check(conforming())
assert "cookie" in codes(report)
assert not report.conforms
# --- images ------------------------------------------------------------
def test_a_small_png_passes(site, check, conforming):
site.add("/mews-0.1.css", CANONICAL)
site.add("/a.png", PNG, headers={"Content-Type": "image/png"})
report = check(conforming(body='<p><img src="a.png" alt="A dot" /></p>'))
assert report.conforms
assert not report.warnings
def test_an_image_that_is_not_an_image_warns(site, check, conforming):
site.add("/mews-0.1.css", CANONICAL)
site.add("/a.png", b"<svg/>")
report = check(conforming(body='<p><img src="a.png" alt="x" /></p>'))
warning = [f for f in report.findings if f.code == "image-format"]
assert warning and warning[0].level == "should"
assert report.conforms
def test_a_large_image_warns(site, check, conforming):
site.add("/mews-0.1.css", CANONICAL)
site.add("/a.png", PNG + b"\x00" * 60_000)
report = check(conforming(body='<p><img src="a.png" alt="x" /></p>'))
assert "image-size" in codes(report)
def test_camera_metadata_warns(site, check, conforming):
site.add("/mews-0.1.css", CANONICAL)
site.add("/a.jpg", b"\xff\xd8\xff\xe1\x00\x16Exif\x00\x00" + b"\x00" * 100)
report = check(conforming(body='<p><img src="a.jpg" alt="x" /></p>'))
assert "image-metadata" in codes(report)
def test_a_missing_image_warns(site, check, conforming):
site.add("/mews-0.1.css", CANONICAL)
report = check(conforming(body='<p><img src="gone.png" alt="x" /></p>'))
assert "image-unreachable" in codes(report)
def test_only_the_first_twenty_images_are_read(site, check, conforming):
site.add("/mews-0.1.css", CANONICAL)
site.add("/a.png", PNG)
body = "".join(f'<p><img src="a.png?{i}" alt="x" /></p>' for i in range(25))
report = check(conforming(body=body))
assert "images-not-checked" in codes(report)
assert len([r for r in site.requests if r[0].startswith("/a.png")]) == 20
def test_a_compression_bomb_is_refused(site, conforming):
"""A short download that expands enormously is not a page."""
body = zlib.compress(b"x" * 10_000_000)
url = site.add(
"/big.html",
body,
headers={"Content-Encoding": "deflate", "Content-Type": "text/html"},
declare_length=False,
)
with Fetcher(allow_loopback=True) as fetcher:
response = fetcher.get(url)
assert response.truncated