module

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

ENCODED_NULL = /%00/i

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.

ENCODED_SEPARATOR = /%(?:2f|5c)/i

%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

canonical_target(path : String) : String | Nil

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.

Source
dots_only?(segment : String) : Bool

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.

Source
encode_relative(relative : String) : String

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:.

Source
safe_relative(path : String) : String | Nil
Source
unservable?(path : String) : Bool

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.

Source