module

Hwaro::Utils::PathUtils

Constants

ALIAS_SCHEME_RE = /\A[a-z][a-z0-9+.\-]*:/i

A URL scheme prefix (https:, mailto:, …) on an alias.

Instance methods

absolute?(path : String) : Bool

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.

Source
alias_refusal(value : String) : String | Nil

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

Source
case_folding_fs?(reference : String) : Bool

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.

Source
chomp_separator(path : String) : String

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.

Source
escapes_parent?(path : String) : Bool

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.

Source
external_alias?(value : String) : Bool

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.

Source
find_project_root(content_dir : String) : String

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.

Source
glob_escape(path : String) : String

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.

Source
glob_match?(pattern : String, path : String) : Bool

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.

Source
language_suffix(stem : String, & : String -> Bool) : String | Nil

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.

Source
nfc(value : String) : String

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.

Source
normalize_alias_url(alias_path : String) : String

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.

Source
output_file_key(url : String) : String | Nil

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.

Source
output_fold_key(key : String) : String

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.

Source
relative_path(path : String, root : String) : String | Nil

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.

Source
resolved_real_path(path : String) : String

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.

Source
resolves_within?(path : String, root : String) : Bool

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.

Source
root_relative(path : String) : String

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.

Source
sanitize_path(path : String) : String

Sanitize path to prevent directory traversal and normalize separators

This method performs the following operations:

  1. URL-decodes the path (repeated until stable to catch double-encoding)
  2. Removes null bytes
  3. Splits into segments and rejects traversal components
  4. 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"

Source
split_safe_segments(path : String) : Tuple(Array(String), Bool)

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 how content/a..b/ came to overwrite the site's index.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.

Source
unpublishable_alias(value : String) : String | Nil

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.

Source
within?(path : String, root : String) : Bool

True when path is root or lies under it. Both must already be in comparable (expanded or resolved) form. A root that already ends in a separator — the filesystem root "/" — is not given a second one.

Source