NEO.K / MSSP 開源專案考古002-http-server
專案CPython http.server
授權PSF-2.0
檢視版本3.14.5
日期2026-08-02
來源upstream ↗

002 — CPython http.server 3.14.5

專案: CPython Lib/http/server.py 授權: PSF-2.0 檢視版本: 3.14.5 判定: seam-confirmed-by-ecosystem

python src/main.py          # 重切版對幾個原始請求跑一遍,全程沒有 socket
python src/island-test.py   # 單獨載入一個處理器,並量上游取得一個動詞的代價

為什麼選它

因為它是幾乎每個 Python 開發者都讀過、而且大多數人都繼承過的那個檔案。python -m http.server 是內建的,SimpleHTTPRequestHandler 是新手寫的第一個伺服器,而擴充它的方法在文件裡寫得很清楚:繼承,然後定義 do_GET

那個「很清楚」正是這次要看的東西。它不是一個被忽略的角落,它是被推薦的做法。

上一則考古(001 commander)看的是「決策」與「效果」焊在一起。這一則看的是更前面一步的問題:當擴充點是繼承,就不存在可以單獨載入的子集——連要不要拆的討論都還沒開始,就已經沒有東西可拆了。

原專案的結構地圖

以下數字由 src/island-test.py 第 4 節在執行時從真正的模組讀出,不是抄來的:

量到的
http/server.py 1,441 行
BaseHTTPRequestHandler 方法數 28
SimpleHTTPRequestHandler 方法數 11
SimpleHTTPRequestHandler MRO 深度 5(→ BaseHTTPRequestHandler → StreamRequestHandler → BaseRequestHandler → object
handle_one_request 36 行
parse_request 116 行
log_message 25 行
分派機制 getattr(self, mname)

三個地方值得逐一看。

handle_one_request,36 行,五件事。 它從 self.rfile 讀、呼叫解析、用 getattr 找方法、往 self.wfile 寫、並呼叫 self.log_*。讀取、解析、分派、回應、記錄——同一個方法。要測其中任何一件,就要有前面全部。

parse_request,116 行,會送回應。 輸入不合法時它呼叫 self.send_error(...)。所以「解析一個請求」與「回答一個請求」是同一個操作,而兩者都需要一條連線。解析是知識,送出是效果——這條縫跟 001 是同一族,但表現方式不同:commander 是終止行程,這裡是解析器自己握著 socket。

log_message,25 行,寫死 sys.stderr 不是寫到「伺服器被給定的某個串流」,是函式裡直接寫著 sys.stderr。要換地方,就得繼承請求處理器——跟新增一個動詞是同一種變更。「我想把 log 放別的地方」和「我想支援新的 HTTP 方法」在這個結構裡是同一件事。

而最上層的那件事:getattr(self, 'do_' + command) 處理器的集合,就是實例上的屬性集合。這有兩個後果:

  1. 沒有辦法把「一組不同的處理器」交給分派器——因為沒有地方可以交。
  2. 一個類別只能有一個 GET 處理器,因為動詞就是屬性名。健康檢查與檔案服務都要回答 GET,那它們不能各自是一個處理器;其中一個必須變成另一個方法裡的分支,或者靠繼承順序決定誰贏。

MSSP 重切

src/ 是同一件事的重切版,刻意做到沒有任何 socket

SMS message.pyparse(bytes) -> Request | Response。輸入不合法時回傳那個 400,而不是送出它。拒絕成為一個值,於是一個沒有連線的呼叫者(例如測試)得到跟有連線的呼叫者一樣的答案。

SMS dispatch.py — 處理器是登錄表裡的值,分派器被告知有哪些存在。比對用的是方法加上目標,這是與上游的真正差別:getattr(self, 'do_GET') 只可能找到一個東西。

這裡我自己踩了一次。dispatch 的第一版只比對方法,於是健康檢查回答了每一個 GET,檔案處理器一次都沒跑到——包括那個專門用來測試它會拒絕離開根目錄的請求。我用意外的方式重現了上游的限制,而 main.py 那條「沒有 200」的斷言照樣通過,因為它從來沒被執行到。斷言改成必須看到 403,而不只是沒看到 200。

SCL policy.json / policy.py — 哪個處理器可以回答哪個方法、哪個可以讀檔案系統。confine() 對應上游的 translate_path:上游那個是子類別可以覆寫的方法,所以限制成立是因為子類別選擇不覆寫;這裡它是處理器必須呼叫、而且換不掉的函式。

TMS handlers/health.py(20 行)與 handlers/static_file.py — 各自是一個單元。健康檢查沒有持有任何連線的參照,island-test.py 用它一個就跑起來。

DMS access_log.py — sink 是參數。孤島測試傳 list.append 進去,不必攔截任何串流。

執行結果:

$ python src/main.py
  -> GET /health HTTP/1.1        HTTP/1.1 200 OK
  -> GET /index.html HTTP/1.1    HTTP/1.1 200 OK
  -> GET /../secrets.txt HTTP/1.1  HTTP/1.1 403 Forbidden
  -> POST /health HTTP/1.1       HTTP/1.1 405 Method Not Allowed
  -> nonsense                    HTTP/1.1 400 Bad Request

  confinement demonstrated: the file handler refused a path outside its root

孤島測試第 1 節:handlers/health沒有其他處理器、沒有 socket、沒有 server 物件、沒有 client address 的情況下回答 200。上游做同一件事的最小形態,是那 1,441 行加上一個方法。

什麼不適合拆

繼承在這裡不是疏忽,是 1999 年的正確答案。 socketserver 的框架是模板方法模式,而模板方法在當年是把「協定處理」與「應用邏輯」分開的標準工具。要換成登錄表,就要有辦法把處理器集合傳進去——那需要標準庫在 API 上做一次破壞性變更,去換一個大多數使用者感受不到的好處。不划算,而且上游知道。

BaseHTTPRequestHandler 的 28 個方法裡,大部分該留在一起。 send_response / send_header / end_headers / flush_headers 是同一個狀態機的四個階段——它們共享「header 送出前後」這個不可見的順序約束。把它們拆成四個模組,只會讓那個約束從「同一個類別裡看得到」變成「四個檔案之間的口頭協定」。這正是拆分不等於解耦的樣子。

log_message 寫死 sys.stderr 的代價,比看起來小。 它是為了讓 python -m http.server 一行就能用而付的。標準庫的預設值要對「完全不設定」的使用者正確,而不是對「會注入 sink」的使用者正確。這是必要複雜度的一種:便利本身就是需求。

生態系已經自己回答過這個問題了。 WSGI(PEP 333, 2003)與後來的 ASGI 就是把「請求」變成一個值、把應用變成一個可呼叫物件——正是這裡重切的方向。所以判定是 seam-confirmed-by-ecosystem:這條縫不需要我來論證它存在,Python 世界二十多年來所有正經的 web 伺服器都不繼承 BaseHTTPRequestHandlerhttp.server 的文件自己也寫著它不建議用於正式環境。

結論不是「標準庫做錯了」。 是:一個為了零設定而生的模組,其擴充機制必然把整體綁給每一個擴充者,而那個代價在它自己的用途裡是合理的。搬到別的用途才不合理——而那正是生態系另外長出 WSGI 的原因。

這次沒有解決什麼

重切原始碼

FMS

FMS/manifest.json
{
  "name": "http-server-recut",
  "what_it_is": "A minimal HTTP request handler, re-cut so that a single verb handler can be exercised with no connection.",
  "examined": {
    "project": "CPython http.server",
    "version": "3.14.5",
    "license": "PSF-2.0",
    "measured": {
      "module_lines": 1441,
      "BaseHTTPRequestHandler_methods": 28,
      "SimpleHTTPRequestHandler_methods": 11,
      "handle_one_request_lines": 36,
      "parse_request_lines": 116,
      "log_message_lines": 25,
      "dispatch_mechanism": "getattr(self, 'do_' + command)"
    }
  },

  "capabilities": {
    "SMS": {
      "message": "parse(bytes) -> Request | Response, and serialise(Response) -> bytes. Owns no connection.",
      "dispatch": "Chooses a handler from a registry it is given, checks SCL, calls it."
    },
    "SCL": {
      "policy": "Which handler may answer which method, and which may read the filesystem. Confinement to the served root lives here, not in the handler."
    },
    "TMS": {
      "handlers/health": "Answers a liveness probe. Needs nothing.",
      "handlers/static-file": "Serves a file from a confined root."
    },
    "DMS": {
      "access_log": "Events, written to a sink the caller supplies."
    }
  },

  "the_finding": "Upstream's extension point is inheritance. A new verb is a method on a subclass of the whole handler, so the smallest thing that can hold that verb is the whole handler. There is no subset to load, and therefore no island test to run.",

  "non_goals": [
    "Being an HTTP server. There is no socket here at all — requests arrive as bytes, which is the point.",
    "Being faster, safer or more complete than the standard library. It is smaller because it does less."
  ]
}

SCL

SCL/policy.json
{
  "policy_version": "1.0",

  "_comment": [
    "Upstream, a handler's permissions are whatever its code happens to do,",
    "because a handler is a method on the same object that owns the socket and",
    "the filesystem helpers. translate_path exists to keep a request from",
    "escaping the served directory, and it is a method the subclass may",
    "override — so the boundary is enforced by the subclass agreeing to it.",
    "",
    "Here the permission is data next to the handler name, and dispatch checks",
    "it before calling anything."
  ],

  "handlers": {
    "handlers/static-file": {
      "methods": ["GET"],
      "read_filesystem": true,
      "root": "public"
    },
    "handlers/health": {
      "methods": ["GET"],
      "read_filesystem": false
    }
  }
}
SCL/policy.py
"""What each handler may do.

`may_handle` is called by SMS/dispatch before a handler runs, so a handler
cannot answer a method its policy entry does not list — regardless of what it
would have done.

`confine` is the one worth comparing with upstream. `SimpleHTTPRequestHandler`
keeps requests inside the served directory in `translate_path`, a method the
subclass may override; the confinement holds because subclasses do not override
it. Here it is a function the handler must call and cannot replace.
"""
from __future__ import annotations

import json
from pathlib import Path

_POLICY = json.loads(Path(__file__).with_name("policy.json").read_text(encoding="utf-8"))
HANDLERS: dict[str, dict] = _POLICY["handlers"]


class Refused(Exception):
    """Raised when a handler asks for something its entry does not grant."""


def may_handle(handler: str, method: str) -> bool:
    return method in HANDLERS.get(handler, {}).get("methods", [])


def confine(handler: str, target: str, root: Path) -> Path:
    """Resolve a request target inside `root`, or refuse."""
    if not HANDLERS.get(handler, {}).get("read_filesystem"):
        raise Refused(f"{handler} has read_filesystem=false")

    candidate = (root / target.lstrip("/")).resolve()
    if root.resolve() not in candidate.parents and candidate != root.resolve():
        raise Refused(f"{handler} tried to leave {root}: {target}")
    return candidate

SMS

SMS/dispatch.py
"""Choosing a handler for a request.

Upstream this is one line inside `handle_one_request`:

    method = getattr(self, mname)

which is why adding a verb means subclassing. The set of handlers is the set of
attributes on the instance, so there is no way to hand the dispatcher a
different set — and no way to have one without having all of them.

Here handlers are values in a registry. The dispatcher is told what exists,
which is what lets a caller pass exactly one.
"""
from __future__ import annotations

import sys
from pathlib import Path
from typing import Callable, Protocol

sys.path.insert(0, str(Path(__file__).resolve().parent.parent))

from SCL import policy                          # noqa: E402
from SMS.message import Request, Response       # noqa: E402


class Handler(Protocol):
    name: str
    method: str

    def matches(self, request: Request) -> bool: ...

    def handle(self, request: Request) -> Response: ...


def dispatch(
    request: Request,
    handlers: list[Handler],
    *,
    on_event: Callable[..., None] = lambda **_: None,
) -> Response:
    # Method *and* target, which is the difference that matters. Upstream
    # dispatches on the verb alone, because the verb is the attribute name:
    # `getattr(self, 'do_GET')` can only ever find one thing. Two capabilities
    # that both answer GET — a health probe and a file server — cannot both
    # exist as handlers there; one of them has to become a branch inside the
    # other's method, or arrive by inheritance order.
    #
    # The first version of this file matched on method alone and reproduced the
    # same limitation by accident: the health handler answered every GET and the
    # file handler never ran once, including for the request written to test
    # that it refuses to leave its root.
    handler = next((h for h in handlers if h.method == request.method and h.matches(request)), None)

    if handler is None:
        on_event(kind="unhandled", method=request.method, target=request.target)
        allowed = ", ".join(sorted({h.method for h in handlers})) or "none"
        return Response(405, "Method Not Allowed", headers={"Allow": allowed})

    if not policy.may_handle(handler.name, request.method):
        on_event(kind="refused", handler=handler.name, method=request.method)
        return Response(403, "Forbidden", b"handler is not permitted this method")

    response = handler.handle(request)
    on_event(kind="handled", handler=handler.name, status=response.status,
             method=request.method, target=request.target)
    return response
SMS/message.py
"""Requests and responses as values. Nothing here owns a socket.

This is the first thing the re-cut moves. Upstream, `parse_request` is 116 lines
that read from `self.rfile` and call `self.send_error(...)` when the input is
malformed — so parsing a request and answering one are the same operation, and
neither can happen without a connection.

Here `parse` takes bytes and returns either a Request or a Response. A rejection
is a *value* the caller may choose to send. Nothing is written to anyone.
"""
from __future__ import annotations

from dataclasses import dataclass, field


@dataclass(frozen=True)
class Request:
    method: str
    target: str
    version: str
    headers: dict[str, str] = field(default_factory=dict)


@dataclass(frozen=True)
class Response:
    status: int
    reason: str
    body: bytes = b""
    headers: dict[str, str] = field(default_factory=dict)


def parse(raw: bytes) -> Request | Response:
    """bytes -> Request, or the Response explaining why not.

    Returning the rejection instead of sending it is the whole difference. A
    caller with no connection — a test, say — gets the same answer as one with.
    """
    try:
        head, _, _ = raw.partition(b"\r\n\r\n")
        lines = head.decode("iso-8859-1").split("\r\n")
        parts = lines[0].split()
    except Exception:
        return Response(400, "Bad Request", b"unreadable request line")

    if len(parts) != 3:
        return Response(400, "Bad Request", b"malformed request line")

    method, target, version = parts
    if not version.startswith("HTTP/"):
        return Response(400, "Bad Request", b"unrecognised protocol version")

    headers: dict[str, str] = {}
    for line in lines[1:]:
        name, sep, value = line.partition(":")
        if sep:
            headers[name.strip().lower()] = value.strip()

    return Request(method=method, target=target, version=version, headers=headers)


def serialise(response: Response) -> bytes:
    head = f"HTTP/1.1 {response.status} {response.reason}\r\n"
    headers = {"Content-Length": str(len(response.body)), **response.headers}
    head += "".join(f"{k}: {v}\r\n" for k, v in headers.items())
    return head.encode("latin-1") + b"\r\n" + response.body

TMS

TMS/handlers/health.py
"""Answers a liveness probe. Touches nothing.

The whole point of this file is how little it needs. It is 20 lines, it holds no
reference to a connection, and `island-test.py` runs it with nothing else
loaded. Upstream, the same capability is a `do_GET` on a subclass of
`BaseHTTPRequestHandler`, and instantiating one requires a socket, a client
address and a server object — so the smallest possible version of this is the
entire 1,441-line module plus a method.
"""
from __future__ import annotations

import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parents[2]))

from SMS.message import Request, Response  # noqa: E402


class HealthHandler:
    name = "handlers/health"
    method = "GET"

    def matches(self, request: Request) -> bool:
        return request.target == "/health"

    def handle(self, request: Request) -> Response:
        return Response(200, "OK", b"ok", {"Content-Type": "text/plain"})
TMS/handlers/static_file.py
"""Serves a file from a directory.

The confinement it needs — "do not leave the served root" — is asked of SCL
rather than implemented here. Upstream the equivalent lives in
`SimpleHTTPRequestHandler.translate_path`, on the class a subclass extends,
which means a subclass that overrides it takes the confinement with it. The
protection is real but it is held in the same place as the thing it protects
against.
"""
from __future__ import annotations

import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parents[2]))

from SCL.policy import Refused, confine       # noqa: E402
from SMS.message import Request, Response     # noqa: E402

_TYPES = {".html": "text/html", ".css": "text/css", ".json": "application/json", ".txt": "text/plain"}


class StaticFileHandler:
    name = "handlers/static-file"
    method = "GET"

    def __init__(self, root: Path) -> None:
        self._root = root

    def matches(self, request: Request) -> bool:
        # Everything the probe did not claim. Order in the registry decides, and
        # the registry is a list the caller controls.
        return True

    def handle(self, request: Request) -> Response:
        try:
            path = confine(self.name, request.target, self._root)
        except Refused as exc:
            # A refusal is reported as a value, like every other outcome here.
            return Response(403, "Forbidden", str(exc).encode())

        if not path.is_file():
            return Response(404, "Not Found", b"no such file")

        body = path.read_bytes()
        ctype = _TYPES.get(path.suffix, "application/octet-stream")
        return Response(200, "OK", body, {"Content-Type": ctype})

DMS

DMS/access_log.py
"""The access log as a sink you pass in.

Upstream, `log_message` is 25 lines that write to `sys.stderr`. Not to a stream
the server was given — to `sys.stderr`, named in the function. Redirecting it
means subclassing the request handler, which is the same extension mechanism as
adding a verb, so "I want the log somewhere else" and "I want to serve a new
method" are the same kind of change.

Here the sink is an argument. Passing `list.append` is how the island test reads
what happened without capturing a stream.
"""
from __future__ import annotations

from typing import Callable


class AccessLog:
    def __init__(self, sink: Callable[[str], None]) -> None:
        self._sink = sink
        self.events: list[dict] = []

    def record(self, **event) -> None:
        self.events.append(event)
        kind = event.pop("kind", "event")
        self._sink(f"{kind:<10} " + " ".join(f"{k}={v}" for k, v in event.items()))

root

island-test.py
"""The island test, and the measurement that motivated the re-cut.

    python src/island-test.py

Section 1 runs one handler with nothing else loaded and no connection.
Section 2 measures what the same thing costs upstream, by importing the real
module and asking what it takes to obtain one verb.
"""
from __future__ import annotations

import inspect
import re
import sys
from pathlib import Path

HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))

from DMS.access_log import AccessLog                     # noqa: E402
from SCL import policy                                   # noqa: E402
from SMS.dispatch import dispatch                        # noqa: E402
from SMS.message import Request, Response, parse         # noqa: E402

failures: list[str] = []


def report(label: str, ok: bool, detail: str = "") -> None:
    print(f"  {'PASS' if ok else 'FAIL'}  {label}" + (f" - {detail}" if detail else ""))
    if not ok:
        failures.append(label)


print("\n== 1. one handler, alone, with no connection")

from TMS.handlers.health import HealthHandler            # noqa: E402

lines: list[str] = []
log = AccessLog(sink=lines.append)
parsed = parse(b"GET /health HTTP/1.1\r\n\r\n")
response = dispatch(parsed, [HealthHandler()], on_event=log.record)

report("handlers/health answered with only itself loaded", response.status == 200,
       f"HTTP {response.status} {response.reason}")
report("no socket, no server, no client address were needed",
       isinstance(parsed, Request) and response.body == b"ok")
report("the log went to the caller's sink", len(lines) == 1, f"{len(lines)} line(s)")

# the static handler is absent, so its method must not be answerable
absent = dispatch(parse(b"GET /index.html HTTP/1.1\r\n\r\n"), [HealthHandler()], on_event=log.record)
report("an absent handler is absent", absent.status == 405,
       f"HTTP {absent.status} - nothing loaded claims /index.html, and that is reported rather than guessed")

print("\n== 2. a rejection is a value, not a transmission")
bad = parse(b"nonsense\r\n\r\n")
report("the parser returns the rejection", isinstance(bad, Response) and bad.status == 400,
       "upstream parse_request calls send_error, so parsing needs a connection")

print("\n== 3. SCL refuses before the handler runs")
denied = dispatch(Request("DELETE", "/health", "HTTP/1.1"), [HealthHandler()], on_event=log.record)
report("a method no handler declares is refused", denied.status == 405,
       f"HTTP {denied.status}, Allow: {denied.headers.get('Allow')}")
report("policy is data, not prose", policy.may_handle("handlers/health", "GET")
       and not policy.may_handle("handlers/health", "DELETE"))

print("\n== 4. what one verb costs upstream")
import http.server as upstream                            # noqa: E402

module_lines = len(inspect.getsource(upstream).splitlines())
base_methods = [m for m in upstream.BaseHTTPRequestHandler.__dict__ if not m.startswith("__")]
mro = [c.__name__ for c in upstream.SimpleHTTPRequestHandler.__mro__]
dispatch_src = inspect.getsource(upstream.BaseHTTPRequestHandler.handle_one_request)
uses_getattr = bool(re.search(r"getattr\(self,\s*mname\)", dispatch_src))
log_src = inspect.getsource(upstream.BaseHTTPRequestHandler.log_message)

print(f"    module                       {module_lines} lines")
print(f"    BaseHTTPRequestHandler       {len(base_methods)} methods")
print(f"    SimpleHTTPRequestHandler MRO {' -> '.join(mro)}")
print(f"    dispatch by getattr          {uses_getattr}")
print(f"    log_message names sys.stderr {'sys.stderr' in log_src}")

report("upstream dispatches by attribute lookup, so handlers cannot be passed in", uses_getattr,
       "if this stops being true the re-cut's premise needs revisiting")
report("upstream's log destination is named in the function", "sys.stderr" in log_src)
report("a verb handler is reached only through the whole handler",
       "BaseHTTPRequestHandler" in mro and len(mro) >= 4,
       f"{len(mro)}-deep MRO, {len(base_methods)} inherited methods before the verb")

print()
if failures:
    print(f"  {len(failures)} check(s) failed: {', '.join(failures)}")
    raise SystemExit(1)
print("  island test passed")
main.py
"""Run the re-cut against a few raw requests.

    python src/main.py

No socket is opened. Requests are bytes, which is what makes the handlers
testable and what upstream cannot offer without a connection.
"""
from __future__ import annotations

import sys
import tempfile
from pathlib import Path

HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))

from DMS.access_log import AccessLog                     # noqa: E402
from SMS.dispatch import dispatch                        # noqa: E402
from SMS.message import Request, Response, parse, serialise  # noqa: E402
from TMS.handlers.health import HealthHandler            # noqa: E402
from TMS.handlers.static_file import StaticFileHandler   # noqa: E402

REQUESTS = [
    b"GET /health HTTP/1.1\r\nHost: localhost\r\n\r\n",
    b"GET /index.html HTTP/1.1\r\nHost: localhost\r\n\r\n",
    b"GET /../secrets.txt HTTP/1.1\r\nHost: localhost\r\n\r\n",
    b"POST /health HTTP/1.1\r\nHost: localhost\r\n\r\n",
    b"nonsense\r\n\r\n",
]


def main() -> int:
    root = Path(tempfile.mkdtemp()) / "public"
    root.mkdir()
    (root / "index.html").write_text("<h1>served</h1>\n", encoding="utf-8")
    (root.parent / "secrets.txt").write_text("not for you\n", encoding="utf-8")

    log = AccessLog(sink=lambda line: print(f"    {line}"))
    handlers = [HealthHandler(), StaticFileHandler(root=root)]

    print("\n== http-server re-cut (no socket)")
    for raw in REQUESTS:
        first = raw.split(b"\r\n", 1)[0].decode("latin-1")
        print(f"\n  -> {first}")
        parsed = parse(raw)
        # A rejection from the parser is a Response, not something it sent.
        response = parsed if isinstance(parsed, Response) else dispatch(
            parsed, handlers, on_event=log.record
        )
        head = serialise(response).split(b"\r\n", 1)[0].decode("latin-1")
        print(f"    {head}   {len(response.body)} byte(s)")

    print(f"\n  {len(log.events)} event(s) recorded to the sink the caller supplied")

    # The confinement is the one outcome worth asserting rather than eyeballing,
    # and the assertion has to require the refusal rather than merely permit it.
    # Written as "no 200 for a path containing secrets", it passed while the file
    # handler was never reached at all.
    escape = next((e for e in log.events if "secrets" in str(e.get("target", ""))), None)
    problems = []
    if escape is None:
        problems.append("the escape attempt reached no handler, so nothing was tested")
    elif escape.get("handler") != "handlers/static-file":
        problems.append(f"the escape attempt went to {escape.get('handler')}, not the file handler")
    elif escape.get("status") != 403:
        problems.append(f"the escape attempt returned {escape.get('status')}, not 403")

    if problems:
        print("  CONFINEMENT NOT DEMONSTRATED: " + "; ".join(problems))
        return 1
    print("  confinement demonstrated: the file handler refused a path outside its root")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())