Hwaro::Utils::PathUtils
Constants
A URL scheme prefix (https:, mailto:, …) on an alias.
Instance methods
Path#absolute?, plus a rooted path (/x, \x) on Windows. The
stdlib calls those drive-relative, but they never name something
under the current directory: File.join(root, "/x") would quietly
nest what the user meant as C:\x, and a "no absolute paths" check
would let /etc/x through.
Why a build would not publish value as an alias, or nil when it
would: external URLs are dropped at parse time, and a path segment
that would traverse (.., .) is refused when the redirect stub is
written (url_output_path in the render phase).
Does the filesystem that reference lives on fold letter case in file
names? Probed rather than inferred from the platform: a macOS
developer can format a case-SENSITIVE APFS volume and a Linux box can
mount a case-insensitive one, so flag?(:darwin) would both suppress
pages that would have published and miss pages that won't.
The probe writes nothing into the user's tree: it flips the case of the ASCII letters of an existing path and asks whether the result names the SAME file. Inode identity, not mere existence — a directory that genuinely holds both spellings must not read as case-folding. Anything inconclusive (no ASCII letters to flip, an unreadable path) answers "case sensitive", which is the behavior that predates this check.
path without trailing separators, unless it is a filesystem root:
/, and on Windows C:/, which stripped to C: would name that
drive's current directory instead of its root.
True when the normalized relative path climbs out of its base: its
first segment is exactly ... A string-prefix test also matched
legal names such as ..public.
An alias names an incoming path ON THIS SITE, so one carrying a URL
scheme or an authority (//host/path) is not something a build can
publish. The single predicate the build's alias filter
(Phases::ParseContent) and the tools that mirror it (tool export,
tool platform) share.
The project root for a content directory argument: content or
<root>/content → its parent, a directory that itself holds a
content/ (or sits beside one) → itself, anything else → itself.
Lets tool check-links / tool unused-assets run with -c content,
-c . or from inside a subdirectory and still find config.toml.
path with glob metacharacters escaped, so a content-derived
directory can prefix a glob pattern
(Dir.glob(File.join(glob_escape(dir), "**", "*"))) and match
literally: unescaped, posts/[WIP] Hello or a{b,c} matched nothing.
POSIX: backslash escapes. Windows has no escape character (\ is a
separator there), so *, ? and [ become one-character classes
([[]); braces cannot be escaped at all (brace expansion only honours
\), so a {a,b} directory still matches nothing there.
File.match? that treats a malformed glob as non-matching instead of raising File::BadPatternError, so a single config typo can't crash a build or deploy.
The language code an extension-less content filename carries
(about.ko → "ko", _index.zh-tw → "zh-tw"): the non-empty text
after the last dot, provided a non-empty base name precedes it
(".ko" is not a translation) and the block accepts the code. Callers
pass the site's DECLARED codes (plus the default) — never a shape
check, which read setup.mac.md as a translation. Shared by the
build's ReadContent and the tools that must route files the same way.
NFC form of value (the identity for ASCII, which is nearly every
path). Config patterns are typed precomposed while macOS, zip and
Dropbox hand out decomposed (NFD) file names, so every
pattern-versus-path comparison folds both sides first. Comparison
only: never use it to spell an output path or URL.
Collision key for an alias target: one spelling per published path.
/foo, /foo/, /foo/index.html and /foo/index.htm all name the
same file on disk, so they must collapse to ONE key or alias collision
detection cannot see the conflict. aliases = ["/index.html"] kept
its own key, never collided with the homepage's /, and its redirect
stub was written straight over public/index.html — silently, and
order-dependently under the parallel render. Shared by the render
phase and platform redirect generation so the two never drift.
The output FILE a site-relative URL will be written to, normalized so
that two different URL strings landing on one file share a key.
nil when the URL cannot be published at all (a refused segment) —
the writer reports those through its own warn-and-skip path.
The Render phase's collision detector used to key on the raw URL
string, so /posts/a/b/ and /posts/a%2fb/ looked like two separate
outputs while being one posts/a/b/index.html: the build claimed
"render: 2 pages", wrote one file, and never mentioned the page it
destroyed.
Case- and Unicode-folded form of an output_file_key. APFS/HFS+ and
NTFS treat Foo/index.html and foo/index.html as the same file, and
APFS additionally folds the NFD and NFC spellings of one accented name
together — so two pages whose URLs differ only that way overwrite each
other there while both publish fine on a case-sensitive host.
Return a slash-separated path relative to root, or nil when it is
outside. This uses the same Windows extended-path normalization as
within?, so callers do not slice using the original prefix length.
path with every symlink resolved, including when it does not exist
yet: resolve the deepest existing ancestor and re-append the missing
tail. A not-yet-created destination is the normal case (-o export on
a fresh checkout), so bailing out on a missing leaf would leave exactly
the paths a destination guard exists for unresolved.
Guards that compare a requested destination against project
directories MUST resolve both sides through here: File.expand_path
is purely lexical, so ln -s . selfdir made -o selfdir look like a
dedicated sibling directory while it named the project root.
Falls back to the given path when resolution fails (broken link, symlink loop, unreadable ancestor) — lexical matching is what the callers had before, so a failure never loosens a guard below its old behavior.
True when path, with all symbolic links resolved, lies inside
root (also fully resolved). Used to stop symlinked source files
from publishing content that lives outside the project — e.g. a
static/leak -> ~/.ssh/id_rsa symlink would otherwise be copied
into the public output. In-repo symlinks resolve back within the
root and are kept. Returns false on a dangling/unreadable path
rather than raising.
path with exactly one leading slash, for joining onto a base URL
that has none (base_url). The idiom used to be spelled
inline at every URL-building site.
Sanitize path to prevent directory traversal and normalize separators
This method performs the following operations:
- URL-decodes the path (repeated until stable to catch double-encoding)
- Removes null bytes
- Splits into segments and rejects traversal components
- Rejoins with "/" separator
Example: sanitize_path("/foo/../bar//baz/") # => "foo/bar/baz" sanitize_path("%2Ffoo%2Fbar") # => "foo/bar" sanitize_path("....//etc/passwd") # => "etc/passwd" sanitize_path("notes/v1..v2") # => "notes/v1..v2"
ONE traversal predicate, TWO policies.
Returns the safe segments of path, plus whether any segment had to be
refused because it could name something other than a child of the
directory it appears in. Callers pick a policy:
- neutralize-and-continue (
sanitize_path) — for untrusted input that must still yield a usable path: importer section names, remote-scaffold archive entries. - refuse-outright (the build's
url_output_path) — for writers, where dropping a segment does not sanitize anything, it RELOCATES the page onto whatever already occupies the shortened path. That is howcontent/a..b/came to overwrite the site'sindex.html.
Both policies share this body deliberately. They were hand-copied
before and drifted, which is precisely how one build writer
(generate_redirect_page) kept the wrong policy and clobbered the
homepage while its sibling refused the same URL.
A segment is refused when it is nothing but dots and ASCII spaces.
Windows' RtlDosPathNameToNtPathName trims trailing . and ASCII
space and nothing else, so .., .. , . and ... all name .
or .. there. Segments that merely CONTAIN dots (v1..v2, ..foo)
are ordinary names and are kept.
This is not identical to the pre-2026-08 rule, which refused any
segment containing ... Newly KEPT are segments that contain .. but
cannot traverse on any supported platform — a..b, and the
whitespace-suffixed forms "..\t", "..\u00a0" (only ASCII space
and . are trimmed by Win32, so these stay distinct filenames).
Newly REFUSED are all-space segments such as " ", which the old
rule let through.
A segment is ALSO refused when decoding it reveals a separator, and the
split therefore happens BEFORE the decode. Decoding the whole path
first turned %2f into a real separator: the content file
posts/a%2fb.md claims the URL /posts/a%2fb/ but resolved to the
output path posts/a/b/index.html and overwrote the page that
legitimately owns it — the build reported "render: 2 pages", left one
file on disk, and said nothing. A literal \ is refused for the same
reason from the other side: it separates on Windows, and browsers
normalize it to / inside a URL path, so a segment holding one can
never be both published and linked to correctly everywhere. Refusing
is the only honest answer for a name whose depth is ambiguous;
resolving it either way silently destroys somebody's page.
Why the parse phase drops value from a page's aliases, or nil.
Besides external URLs: a ?query or #fragment is not part of the
path a static host maps to a file, so /old/a?x=1 became the
directory public/old/a?x=1/ — unreachable on every host, and an
illegal filename on Windows.