pathologize

package module
v1.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 9, 2026 License: Apache-2.0 Imports: 4 Imported by: 1

README

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, COM1–COM9, LPT1–LPT9 (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.

Documentation

Overview

Package pathologize produces file and path names that are safe to use on every modern operating system and file system — not just the one the code happens to be running on.

The standard library (path/filepath, os) validates and manipulates paths against the rules of the host OS. That is exactly right when a file never leaves the machine that created it. But files rarely stay put anymore: they travel through network shares (SMB, NFS), cloud storage (S3, GCS, Azure Blob), and sync tools (Dropbox, Syncthing, Google Drive, iCloud, OneDrive). A name that is perfectly legal on Linux — "aux", "report:final", "notes." — can break, get silently renamed, or fail to sync the moment it reaches a Windows client or a case-insensitive file system.

Pathologize takes the union of the restrictions across modern systems and applies all of them at once, so a name it approves is portable everywhere. It is intentionally restrictive: it rejects names that are too long, contain characters invalid on any modern OS, or collide with reserved device names (CON, NUL, COM1, ...) even when the host OS would accept them. It does not impose obsolete constraints (such as DOS 8.3 limits) that no modern system still enforces.

Use Clean on a single file or directory name, CleanPath on a full path, IsClean to test whether a name is already portable, and Join to combine a trusted root directory with untrusted path parts without letting them escape it.

See the Wikipedia article on reserved filenames for background: https://en.wikipedia.org/wiki/Filename#Reserved_characters_and_words

Index

Examples

Constants

This section is empty.

Variables

This section is empty.

Functions

func Clean

func Clean(filename string) string

Clean returns a version of filename that is safe to use on every modern operating and file system. It strips characters that are invalid on any modern OS, replaces invalid UTF-8 sequences with U+FFFD, trims leading/trailing whitespace and trailing dots, truncates to the maximum length, and defuses reserved device names (CON, NUL, COM1, ...) by appending an underscore. If nothing usable remains, it returns a default name so the result is never empty.

Clean is idempotent: its result is always itself clean, so IsClean(Clean(x)) is true for any x. The result is always valid UTF-8, never empty, and never longer than 255 bytes.

Clean operates on a single path component. To clean a full path while preserving its directory structure, use CleanPath.

Example
package main

import (
	"fmt"

	"github.com/spf13/pathologize"
)

func main() {
	fmt.Println(pathologize.Clean("report:final.txt"))
	fmt.Println(pathologize.Clean("CON.txt"))
	fmt.Println(pathologize.Clean("  notes.  "))
}
Output:
reportfinal.txt
CON_.txt
notes

func CleanPath

func CleanPath(path string) string

CleanPath mirrors the lexical semantics of path.Clean — collapsing "//", resolving "." and internal "..", dropping a trailing separator — while additionally sanitizing each component with Clean. Both '/' and '\' are treated as separators (output always uses '/'). A leading volume is preserved: a Windows drive prefix such as "C:" passes through verbatim, and a UNC host+share prefix such as \\server\share is preserved with its separators normalized ("//server/share"), a form Windows accepts; the host and share names are sanitized with Clean like every other component.

CleanPath makes a path VALID, not SAFE. Like path.Clean, it preserves a leading ".." in a relative path and preserves an absolute path as absolute — it does not neutralize path traversal or contain the result under any root. CleanPath is idempotent on an already-valid path (CleanPath(p) == p) and is not a defense against untrusted input; for that, sanitize untrusted values with Clean, or use Join to safely combine a trusted root with untrusted parts.

CleanPath limits each component to 255 bytes but places no limit on the total path length. Some systems limit total length — notably Windows APIs default to 260 characters unless long paths are enabled — and callers whose paths may approach such limits must enforce them themselves, since only the caller knows the full path a component will end up in.

Example
package main

import (
	"fmt"

	"github.com/spf13/pathologize"
)

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

func IsClean

func IsClean(filename string) bool

IsClean reports whether filename is already safe to use across all modern operating and file systems — that is, whether Clean would leave it unchanged. Use it to detect names that need attention (for example, to warn a user) without rewriting them.

Example
package main

import (
	"fmt"

	"github.com/spf13/pathologize"
)

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

func Join added in v1.1.0

func Join(root string, parts ...string) string

Join safely combines a trusted root with one or more untrusted path parts. root is passed through verbatim — including any separators, drive prefix, or trailing slash — and is never sanitized; it must come from a trusted source such as a configured destination directory. Each part is fully sanitized: it is split on '/' and '\', and structural segments (empty, ".", "..") are dropped; all other segments are cleaned with Clean so invalid characters and reserved names are neutralized. If every segment of a part is dropped, the part contributes the default name so it still yields exactly one segment. This guarantees the result stays lexically under root — a part cannot escape it or inject an absolute path — which is what CleanPath, by design, does not guarantee.

If no parts are given, root is returned unchanged.

Example
package main

import (
	"fmt"

	"github.com/spf13/pathologize"
)

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

Types

This section is empty.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL