fs/path
Working with file paths: joining them, separating the name and the extension, absolute and relative paths, and a safety check.
import fs.path
The fs.path package takes paths apart and puts them together: join, split, clean, make absolute or relative, and check that a path from a user stays inside a folder. Every function is a thin name over the same function in filepath (with Filename for Base, Dirname and Parent for Dir), so pick one of the two packages and use it throughout a program. One name differs in meaning: filepath.Base keeps the extension, while path.Basename drops it; fs.path reads naturally next to fs.file and fs.dir.
Do not build paths by concatenating strings; Join puts the separator in for you. The separator is always /, on Windows too: every function first turns \ into /, and Windows accepts / in paths. When a path comes from a user, check it with IsSafe before opening it, so that nobody climbs out of the permitted directory with ../...
Every function here works on the text of the path alone. None of them looks at the disk, except Abs, Rel and IsSafe with a relative path, which read the current directory. To create, read or list files, combine this package with fs.dir, fs.file or io, as the guide below does.
What people get wrong:
- Expecting
Jointo clean up. It only adds or merges the separator, soJoin("a/b", "../c")isa/b/../c; pass the result throughClean. An absolute second part does not replace the first either, unlike Python'sos.path.join. - Reading
Extas the whole extension. It is only the last one:.gzforbackup.tar.gz, and empty for a dot file such as.bashrc. - Comparing paths as text without
Clean.docs/./cv.pdfanddocs/cv.pdfare the same file. Comparisons are also case sensitive, which is wrong on Windows and on default macOS disks.
Serving a file a user asked for
A download handler gets a name such as 2026/report.pdf from the request and has to turn it into a file inside one folder. IsSafe simplifies the joined path first and then checks that it still starts with the folder, so a/../b.txt is fine while ../secret.txt and a/../../etc/passwd are refused. An empty name is refused too.
import fs.path
func resolve(root: str, requested: str): str:
if not path.IsSafe(root, requested): ret "" end
ret path.Clean(path.Join(root, requested))
end
func main:
root := "/srv/uploads"
each name in ["avatar.png", "2026/report.pdf", "a/../b.txt", "../secret.txt", "a/../../etc/passwd", "/etc/passwd", ""]:
full := resolve(root, name)
if full.len() == 0:
println "refused", "[" + name + "]"
else:
println "serve ", full
end
end
endLook at the /etc/passwd line: Join does not let an absolute second part replace the first, so the result lands inside the folder and is safe. The check works on text only; it does not follow symbolic links, so do not put links that point outside the folder in a directory you serve.
Mapping one folder tree onto another
A static site generator reads content/blog/hello.md and writes public/blog/hello.html. Rel gives the part below the source folder, Dirname and Stem take it apart, and Join and Clean build the new path. Stem removes only the last extension, so salam.v2.md keeps its .v2.
import fs.path
func output_for(src_root: str, out_root: str, file: str): str:
rel := path.Rel(src_root, file)
folder := path.Join(out_root, path.Dirname(rel))
page := path.Stem(rel).concat(".html")
ret path.Clean(path.Join(folder, page))
end
func main:
src := "/site/content"
out := "/site/public"
each file in ["/site/content/index.md", "/site/content/blog/hello.md", "/site/content/blog/2026/salam.v2.md"]:
println path.Rel(src, file), "->", output_for(src, out, file)
end
endFor a file directly in the source folder Dirname returns .; the final Clean removes the /. that Join leaves behind. The example uses absolute paths because Rel makes relative ones absolute against the current directory first.
Making a new name next to a file
To put a backup, a thumbnail or a converted copy next to the original, keep the folder and change only the name. Here Stem and Ext turn plan.md into plan.bak.md, and fs.dir and io do the actual work on disk.
import fs.path
import fs.dir
import io
func backup_name(p: str): str:
name := path.Stem(p).concat(".bak").concat(path.Ext(p))
ret path.Join(path.Dirname(p), name)
end
func main:
dir.MkdirAll("notes/2026")
each p in ["notes/todo.txt", "notes/2026/plan.md"]:
io.WriteFile(p, "draft of ".concat(path.Filename(p)))
copy := backup_name(p)
io.WriteFile(copy, io.ReadFile(p))
println p, "saved as", copy
end
println io.ReadFile("notes/2026/plan.bak.md")
endWhich function for which job
Several functions here have close names. Filename keeps the extension; Basename and Stem both drop it. Normalize only changes \ to /; Clean is the one that resolves . and ...
| You want | Call | For /home/sara/docs/cv.pdf |
|---|---|---|
| The folder | Dirname or Parent | /home/sara/docs |
| The name with its extension | Filename | cv.pdf |
| The name without its extension | Stem or Basename | cv |
| The extension | Ext | .pdf |
| Folder and name at once | Split | dir and base fields |
Remove ., .. and double slashes | Clean | unchanged |
| The path from one folder to another | Rel | docs/cv.pdf from /home/sara |
| Check a path from a user | IsSafe | true inside the base folder |
Validate only rejects an empty string. It is not a security check; use IsSafe for that.
Types
PathParts
struct PathParts| Name | Type | Description |
|---|---|---|
dir | str = "" | The directory part. |
base | str = "" | The file name. |
import fs.path
func main:
p := path.Join("/home/sara", "docs/cv.pdf")
println p
parts := path.Split(p)
println parts.dir, parts.base
println path.Dirname(p), path.Parent(p), path.Filename(p), path.Basename(p)
println path.Ext(p), path.Stem(p)
println path.Normalize("/a/./b/../c//d"), path.Clean("a/b/../../c")
println path.IsAbs(p), path.IsAbs("docs"), path.IsAbs(path.Abs("docs"))
println path.Rel("/home/sara", "/home/sara/docs/cv.pdf")
println path.IsSafe("/srv/files", "a/b.txt"), path.IsSafe("/srv/files", "../../etc/passwd")
println path.Validate("docs/cv.pdf"), path.Validate("")
endFunctions
Join
func Join(a: str, b: str): str/ between them. An empty part gives the other part; . and .. are kept, so call Clean on the result when it matters.The example for this item is shown with the ones above: fs-path-Join.salam
Split
func Split(p: str): PathPartsDirname) and its file name (Filename).The example for this item is shown with the ones above: fs-path-Join.salam
Normalize
func Normalize(p: str): str\ separators into / and does nothing else. To resolve .. and ., see Clean.The example for this item is shown with the ones above: fs-path-Join.salam
Clean
func Clean(p: str): str. and .., removes repeated and trailing separators, and gives . for an empty path. A .. above the root of an absolute path is dropped.The example for this item is shown with the ones above: fs-path-Join.salam
IsAbs
func IsAbs(p: str): bool/ or with a drive letter such as C:.The example for this item is shown with the ones above: fs-path-Join.salam
Abs
func Abs(p: str): strThe example for this item is shown with the ones above: fs-path-Join.salam
Rel
func Rel(base: str, target: str): strtarget relative to base, with .. steps when it lies outside; . when they are the same. Both are made absolute first.The example for this item is shown with the ones above: fs-path-Join.salam
Parent
func Parent(p: str): strDirname.The example for this item is shown with the ones above: fs-path-Join.salam
Dirname
func Dirname(p: str): str. when there is none, / for a file at the root. A trailing / is ignored.The example for this item is shown with the ones above: fs-path-Join.salam
Filename
func Filename(p: str): strThe example for this item is shown with the ones above: fs-path-Join.salam
Basename
func Basename(p: str): strStem. For the name with its extension, use Filename.The example for this item is shown with the ones above: fs-path-Join.salam
Ext
func Ext(p: str): str.gz; empty when there is none or the name starts with its only dot (.bashrc).The example for this item is shown with the ones above: fs-path-Join.salam
Stem
func Stem(p: str): strbackup.tar for backup.tar.gz.The example for this item is shown with the ones above: fs-path-Join.salam
IsSafe
func IsSafe(base: str, p: str): boolp, joined to base and simplified, is base itself or lies inside it. An empty p is refused. It works on text and does not follow symbolic links.The example for this item is shown with the ones above: fs-path-Join.salam
Validate
func Validate(p: str): boolThe example for this item is shown with the ones above: fs-path-Join.salam