path
Path strings: build them, take them apart, and check that one is inside
another. Only is_within touches the filesystem — everything else is pure
string work.
introduce path| Function | Signature | Returns |
|---|---|---|
join | join(parts) | str |
ext | ext(p) | str ("" if none) |
filename | filename(p) | str |
parent | parent(p) | str |
stem | stem(p) | str |
is_absolute | is_absolute(p) | bool |
is_relative | is_relative(p) | bool |
normalize | normalize(p) | str |
is_within | is_within(base, candidate) | bool |
#Building and splitting
#join(parts)
Join path components with the platform separator. Takes one array, not varargs.
println(path.join(["/home", "user", "file.txt"])) // /home/user/file.txt
println(path.join(["a", "b", "c.txt"])) // a/b/c.txt
joinis not a security boundary — see below.
#ext(p)
File extension without the dot, or "" when there is none.
println(path.ext("photo.png")) // png
println(path.ext("archive.tar.gz"))// gz
println("[" + path.ext("README") + "]") // []#filename(p)
The last component, extension included.
println(path.filename("/a/b/c.txt")) // c.txt#parent(p)
Everything before the last component.
println(path.parent("/a/b/c.txt")) // /a/b#stem(p)
The filename without its extension.
println(path.stem("data.csv")) // dataRenaming while keeping a directory:
introduce path
fun to_json(p <str>) {
path.join([path.parent(p), path.stem(p) + ".json"])
}
println(to_json("/tmp/report.csv")) // /tmp/report.json#is_absolute(p) / is_relative(p)
Exact opposites.
println(path.is_absolute("/a")) // True
println(path.is_relative("a/b")) // True#normalize(p)
Collapse . and .. lexically — it never touches the filesystem, so it
works on paths that do not exist yet.
println(path.normalize("/srv/uploads/../../etc/passwd")) // /etc/passwd
println(path.normalize("a/./b/../c")) // a/c
println(path.normalize("../../a")) // ../../a
println(path.normalize("/../x")) // /x
println(path.normalize("")) // .Note the last three. In a relative path a leading .. has nothing to cancel
and genuinely names a sibling, so it survives. Above the root there is no
parent to climb to, so .. is dropped. An empty path normalizes to ".".
Because it is lexical, normalize does not follow symlinks: if a is a
symlink to /etc, a/../x normalizes to x but opening it reaches /x. Use
it to tidy a path for display or comparison, not to decide whether a path is
safe.
#join is not a security boundary
join follows the same rule as every other language's path join, and that rule
is surprising the first time it bites you: an absolute component throws away
everything joined before it, and .. is left in the string untouched.
println(path.join(["/srv/uploads", "/etc/passwd"]))
// /etc/passwd <- the base is simply gone
println(path.join(["/srv/uploads", "../../etc/passwd"]))
// /srv/uploads/../../etc/passwd <- still points at /etc/passwd once openedSo path.join([upload_dir, filename]) proves nothing about where the result
lands if filename came from a user. The function that makes that decision is
is_within.
#is_within(base, candidate) — the containment check
Does candidate resolve to base itself or to something inside it?
The pattern is: join, then check, then open — never join then open.
introduce path
fun safe_target(base <str>, user_name <str>) {
target := path.join([base, user_name])
option {
path.is_within(base, target) -> { target }
{ None }
}
}
println(safe_target("/srv/uploads", "report.pdf")) // /srv/uploads/report.pdf
println(safe_target("/srv/uploads", "../../etc/passwd")) // None
println(safe_target("/srv/uploads", "/etc/passwd")) // NoneBehaviour in detail:
println(path.is_within("/srv/uploads", "/srv/uploads/report.pdf")) // True
println(path.is_within("/srv/uploads", "/srv/uploads")) // True (base itself counts)
println(path.is_within("/srv/uploads", "/etc/passwd")) // False
println(path.is_within("/srv/up", "/srv/uploaded/x")) // False (component-wise, not string prefix)
println(path.is_within("/srv/uploads",
path.join(["/srv/uploads", "../../etc/passwd"]))) // FalseThe sibling case matters: a naive string-prefix check would call
/srv/uploaded/x "inside" /srv/up. is_within compares whole path
components, so it does not.
Both arguments are made absolute (relative paths resolve against the current
working directory) and then resolved as far as the filesystem allows: the part
of each path that already exists is canonicalised, which does follow
symlinks, and any trailing components that do not exist yet are resolved
lexically. So an existing symlink inside base that points outside it is
correctly rejected:
// /srv/uploads/link.txt exists and is a symlink to ../secret.txt
println(path.is_within("/srv/uploads", "/srv/uploads/link.txt")) // False#Honest limits — read these before relying on it
The symlink guarantee is partial, and the check is only as good as the filesystem at the instant it runs:
- Components that do not exist yet are only checked lexically.
is_within(base, base + "/newdir/f.txt")isTruewhilenewdirdoes not exist. If something then createsnewdiras a symlink pointing elsewhere, your later open leavesbaseandis_withinnever saw it. - TOCTOU. Between
is_withinreturningTrueand your program opening the file, anything with write access tobasecan swap a component for a symlink. There is no atomic "check and open" here. - It is a lexical/canonical answer, not a kernel-enforced one. It does not consider mount points, hard links, bind mounts, or permissions.
Treat is_within as the thing that rejects traversal input — ../../etc/passwd
and friends — which is what most programs actually need. If you are guarding
real secrets against a hostile local process, you still want an OS-level
sandbox (a container, a chroot, openat-style resolved handles), not this
function.
#Worked example
introduce path
introduce io
introduce os
// Serve a file out of an uploads directory, safely.
fun read_upload(base <str>, requested <str>) {
target := path.join([base, requested])
option {
path.is_within(base, target) == False -> { <Error<forbidden>>("path escapes base") }
os.is_file(target) == False -> { <Error<not_found>>("no such upload") }
{ io.read_file(target) }
}
}See also: os for directory listing and creation, io for reading and writing.