Standard library · System and tools

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 Join to clean up. It only adds or merges the separator, so Join("a/b", "../c") is a/b/../c; pass the result through Clean. An absolute second part does not replace the first either, unlike Python's os.path.join.
  • Reading Ext as the whole extension. It is only the last one: .gz for backup.tar.gz, and empty for a dot file such as .bashrc.
  • Comparing paths as text without Clean. docs/./cv.pdf and docs/cv.pdf are 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.

fs-path-guide-safe.salam
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
end
Outputserve /srv/uploads/avatar.png serve /srv/uploads/2026/report.pdf serve /srv/uploads/b.txt refused [../secret.txt] refused [a/../../etc/passwd] serve /srv/uploads/etc/passwd refused []

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

fs-path-guide-output.salam
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
end
Outputindex.md -> /site/public/index.html blog/hello.md -> /site/public/blog/hello.html blog/2026/salam.v2.md -> /site/public/blog/2026/salam.v2.html

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

fs-path-guide-backup.salam
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")
end
Outputnotes/todo.txt saved as notes/todo.bak.txt notes/2026/plan.md saved as notes/2026/plan.bak.md draft of plan.md

Which 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 wantCallFor /home/sara/docs/cv.pdf
The folderDirname or Parent/home/sara/docs
The name with its extensionFilenamecv.pdf
The name without its extensionStem or Basenamecv
The extensionExt.pdf
Folder and name at onceSplitdir and base fields
Remove ., .. and double slashesCleanunchanged
The path from one folder to anotherReldocs/cv.pdf from /home/sara
Check a path from a userIsSafetrue inside the base folder

Validate only rejects an empty string. It is not a security check; use IsSafe for that.

Types

PathParts

struct PathParts
The two parts of a path.
NameTypeDescription
dirstr = ""The directory part.
basestr = ""The file name.
fs-path-Join.salam
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("")
end
Output/home/sara/docs/cv.pdf /home/sara/docs cv.pdf /home/sara/docs /home/sara/docs cv.pdf cv .pdf cv /a/./b/../c//d c true false true docs/cv.pdf true false true false

Functions

Join

func Join(a: str, b: str): str
Joins two parts of a path with one / 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): PathParts
Splits a path into its directory (Dirname) 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
Turns Windows \ 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
Simplifies a path: it resolves . 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
True when the path starts with / 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): str
Makes the path absolute against the current directory and cleans it.

The example for this item is shown with the ones above: fs-path-Join.salam

Rel

func Rel(base: str, target: str): str
The path of target 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): str
The parent directory; the same as Dirname.

The example for this item is shown with the ones above: fs-path-Join.salam

Dirname

func Dirname(p: str): str
The directory part of a path: . 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): str
The file name with its extension, the last part of the path.

The example for this item is shown with the ones above: fs-path-Join.salam

Basename

func Basename(p: str): str
The file name without the directory and without its extension; the same as Stem. 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
The last extension, with its dot, such as .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): str
The file name without its last extension: backup.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): bool
True when p, 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): bool
True when the path is not empty; it checks nothing else.

The example for this item is shown with the ones above: fs-path-Join.salam