Hwaro::Services::DevPath
Request-path resolution for the dev server's own handlers.
Utils::PathUtils.sanitize_path is deliberately lenient — it decodes
until stable and treats \ as a separator — because it also guards
config-supplied paths. Using it to route HTTP requests made the dev
server answer 200 for URLs that every static host 404s
(/guide%5Cindex.html, /%2Fguide%2Findex.html, and their
double-encoded forms), so a broken link worked locally and broke after
deploy.
This resolver is strict instead: one decode pass, / is the only
separator, and encoded separators are refused outright. Legitimately
percent-encoded paths (/%ED%95%9C%EA%B8%80/ for /한글/) still
resolve, in both raw-UTF-8 and encoded form.
Constants
Percent-encoded NUL. Deleting it (what the shared sanitize_path does)
made /index%00.html serve the real homepage with a 200, while the
StaticFileHandler sitting in the same chain answers a decoded NUL with
400 — an inconsistency inside one server. Fail closed instead.
%2f / %5c in any casing. Double-encoded forms (%252f) survive the
single decode as a literal %2f inside one segment, which then simply
doesn't exist on disk — no separate rule needed.
Instance methods
The canonical form of path when it is not already canonical, or nil
when nothing should be redirected.
Mirrors HTTP::StaticFileHandler's own test exactly — decode once,
Path.posix(...).expand("/"), compare as Path — so a pre-empt built
on this fires when and only when stdlib would have redirected, and
lands on the same target (Path#expand keeps a trailing slash, so a
directory URL stays a directory URL). The result is re-encoded because
the comparison runs on decoded bytes.
Handlers pre-empt stdlib rather than letting it answer because a
redirect it emits reflects whatever the chain did to request.path
along the way — IndexRewriteHandler's / → /index.html rewrite
leaked into it — and because Response#redirect closes the response,
so no handler above can correct the Location afterwards.
A segment that is nothing but dots and spaces. .. is the parent link;
..., ".. " and ". " are included because Windows strips BOTH
trailing dots and trailing spaces from a path component, which can
collapse them onto one that traverses. Same predicate the shared
PathUtils.sanitize_path uses, so the two cannot drift.
Deliberately NOT "contains ..": after a single decode only such a
segment can traverse, while lib.v1..2.js or a slug like a..b are
ordinary filenames that a static host serves. Rejecting those would
reintroduce — in the opposite direction — the dev/prod divergence this
resolver exists to remove.
Percent-encode a resolved relative path back into a URI reference.
safe_relative hands back decoded bytes, so a redirect built straight
from it would put a raw space (or raw UTF-8) into Location:.
Resolve a request path to a path relative to the output root, or nil
when the request must not be served from disk at all. An empty string
means "the output root itself" (e.g. /), which callers handle
separately from nil.
True for request shapes no static host serves: a backslash separator,
an encoded / or \, or a NUL in any form. Declining them in our own
handlers is not enough — HTTP::StaticFileHandler decodes once too and
would happily resolve /%2Fguide%2Findex.html to the real page — so
UnservablePathHandler turns this predicate into an actual 404 before
the request can reach it.