Skip to content

Repository files navigation

pathologize

Pathologize

Pathologize is a Go library that fixes what ails the paths in your application. It rewrites file and path names so they are safe to use on every modern operating system and file system — not just the one your code happens to be running on.

It is intentionally restrictive: it will not allow names that are too long, contain characters invalid on any modern OS, or collide with reserved device names on any modern operating system, even when the host OS would happily accept them.

Why not just use the standard library?

Go's standard library (path/filepath, os) is excellent at working with paths on the host system. It validates, cleans, and joins paths according to the rules of the OS your program is currently running on. When a file is created, read, and deleted on the same machine, that is exactly what you want.

But files rarely stay put anymore. Today we routinely share files and paths across many file systems at once:

  • Network storage — SMB, NFS, and file servers mounted from mixed clients.
  • Cloud storage — S3, Google Cloud Storage, Azure Blob, and object stores that impose their own key rules.
  • Sync tools — Dropbox, Syncthing, Google Drive, iCloud, and OneDrive, which replicate the same file onto Windows, macOS, and Linux.

A name that is perfectly legal on the machine that created it can break the moment it lands somewhere else. For example:

Name Linux/macOS Windows Problem when shared
report:final.txt valid invalid : is illegal on Windows/NTFS
aux valid reserved AUX is a reserved device name
notes. valid stripped/renamed Windows drops trailing dots
draft valid stripped/renamed leading/trailing spaces are unsafe
Q3\report valid filename path separator \ splits the path on Windows

Relying on the host OS to validate names means these problems surface after the file has already synced or uploaded — as silent renames, sync conflicts, or hard failures on someone else's machine.

Pathologize takes the union of the restrictions across modern systems and applies all of them at once. A name it approves is portable everywhere. It does not drag in obsolete constraints (such as DOS 8.3 filenames) that no modern system still enforces.

Specifically, Pathologize handles:

  • Invalid characters — control characters and characters (\ / : * ? " < > |) that are illegal on one or more modern systems.
  • Invalid UTF-8 — malformed byte sequences are replaced with U+FFFD, since Windows and APFS require valid Unicode names.
  • Reserved names — device names such as CON, PRN, AUX, NUL, COM1COM9, LPT1LPT9 (including the superscript variants COM¹COM³, LPT¹LPT³), and NTFS metadata names, including when they appear with an extension (e.g. CON.txt).
  • Length limits — truncation to the maximum safe length, without splitting a multi-byte UTF-8 character.
  • Trailing/leading noise — leading and trailing whitespace and trailing dots that are silently dropped or rejected on some systems.

See the Wikipedia article on reserved filenames for background.

Installation

go get github.com/spf13/pathologize

Usage

Clean — sanitize a single name

Clean takes a single file or directory name and returns a version that is safe everywhere.

package main

import (
	"fmt"

	"github.com/spf13/pathologize"
)

func main() {
	// A name that is legal on Linux but breaks on Windows.
	fmt.Println(pathologize.Clean("report:final.txt")) // reportfinal.txt

	// A reserved device name is defused, even with an extension.
	fmt.Println(pathologize.Clean("CON.txt")) // CON_.txt

	// Trailing dots and surrounding spaces are removed.
	fmt.Println(pathologize.Clean("  notes.  ")) // notes
}

A common use is sanitizing a name that arrives from user input, an upload, or an external system before writing it to storage that will later be synced or shared:

name := pathologize.Clean(userSuppliedName)
f, err := os.Create(filepath.Join(destDir, name))

CleanPath — clean a full path

CleanPath cleans a full path by running Clean on each directory segment and the file name. It accepts both / and \ as separators, always emits / (accepted on every modern OS, so results are stable across platforms), and preserves a leading volume: a Windows drive prefix such as C: passes through, and a UNC host+share prefix such as \\server\share is preserved with its separators normalized (//server/share, a form Windows accepts).

package main

import (
	"fmt"

	"github.com/spf13/pathologize"
)

func main() {
	path := `C:/Users/dir:e*c?t<o>r|y/CON..`
	fmt.Println(pathologize.CleanPath(path)) // C:/Users/directory/CON_
}

CleanPath mirrors the lexical semantics of the standard library's path.Clean — collapsing //, resolving . and internal .., and dropping a trailing separator — while additionally sanitizing each component. Like path.Clean, it makes a path valid, not safe: it preserves a leading .. in a relative path and preserves an absolute path as absolute. It will not stop ../../etc/passwd from resolving outside its starting directory, so do not use it on untrusted input. For that, use Join.

Note that CleanPath limits each component to 255 bytes but places no limit on total path length. Windows APIs default to a 260-character total limit unless long paths are enabled; only the caller knows the full path a component will end up in, so callers near that limit must enforce it themselves.

Join — safely combine a trusted root with untrusted parts

When you need to join a trusted root directory with an untrusted path segment (a filename from user input, a downloaded archive, a scraped URL), use Join. The root is passed through untouched; every part is fully sanitized, empty components are dropped, and .. and absolute-path segments are neutralized — so the result can never escape the root.

package main

import (
	"fmt"

	"github.com/spf13/pathologize"
)

func main() {
	dest := pathologize.Join("/srv/uploads", "../../etc/passwd")
	fmt.Println(dest) // /srv/uploads/etc/passwd
}

IsClean — check without changing

IsClean reports whether a name is already safe everywhere — useful for validating input and warning the user instead of silently rewriting it.

package main

import (
	"fmt"

	"github.com/spf13/pathologize"
)

func main() {
	fmt.Println(pathologize.IsClean("notes.txt")) // true
	fmt.Println(pathologize.IsClean("CON.."))     // false
}

License

This project is licensed under the Apache 2.0 License. See the LICENSE file for details.

About

Clean paths to ensure safe to use on all modern FS/OSs

Topics

Resources

Stars

76 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages