diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml new file mode 100644 index 0000000..8b73b3d --- /dev/null +++ b/.github/workflows/go.yml @@ -0,0 +1,17 @@ +name: go +on: + push: + branches: + - main + pull_request: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: stable + - name: Test + run: go test -v ./... diff --git a/LICENSE b/LICENSE index f8e295e..b4b0bf5 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ BSD 3-Clause License -Copyright (c) 2026, secDre4mer +Copyright (c) 2026, Nextron Systems Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: diff --git a/README.md b/README.md new file mode 100644 index 0000000..9ca65a9 --- /dev/null +++ b/README.md @@ -0,0 +1,17 @@ +Universal Path Handling +======================= + +This library provides functions to handle either Windows or Unix style paths, +regardless of the operating system the code is running on. + +The code for this is copied from the excellent Golang Standard Library's `path/filepath` package (which, unfortunately, +is restricted via build tags to the native OS path style). We do not claim any ownership of this code. The `patches` +directory contains the modifications made to the original code to make it work here. + +There are two subpackages: + +- `windows` handles Windows style paths (e.g. `C:\Program Files\app\file.txt`) +- `unix` handles Unix style paths (e.g. `/usr/local/bin/app/file.txt`) + +The main package provides a `Style` type that can be set to either `Windows` or `Unix` and uses the appropriate subpackage +to perform path operations. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4e7d882 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/NextronSystems/universalpath + +go 1.20 diff --git a/patches/unix.diff b/patches/unix.diff new file mode 100644 index 0000000..b173f8b --- /dev/null +++ b/patches/unix.diff @@ -0,0 +1,152 @@ +diff --git a/unix/path_lite.go b/unix/path_lite.go +index 4a37298..c15d1ed 100644 +--- a/unix/path_lite.go ++++ b/unix/path_lite.go +@@ -2,17 +2,13 @@ + // Use of this source code is governed by a BSD-style + // license that can be found in the LICENSE file. + +-// Package filepathlite implements a subset of path/filepath, +-// only using packages which may be imported by "os". +-// +-// Tests for these functions are in path/filepath. +-package filepathlite ++package unix + + import ( + "errors" +- "internal/stringslite" + "io/fs" + "slices" ++ "strings" + ) + + var errInvalidPath = errors.New("invalid path") +@@ -149,7 +145,7 @@ func unixIsLocal(path string) bool { + hasDots := false + for p := path; p != ""; { + var part string +- part, p, _ = stringslite.Cut(p, "/") ++ part, p, _ = strings.Cut(p, "/") + if part == "." || part == ".." { + hasDots = true + break +@@ -158,7 +154,7 @@ func unixIsLocal(path string) bool { + if hasDots { + path = Clean(path) + } +- if path == ".." || stringslite.HasPrefix(path, "../") { ++ if path == ".." || strings.HasPrefix(path, "../") { + return false + } + return true +@@ -189,7 +185,7 @@ func FromSlash(path string) string { + } + + func replaceStringByte(s string, old, new byte) string { +- if stringslite.IndexByte(s, old) == -1 { ++ if strings.IndexByte(s, old) == -1 { + return s + } + n := []byte(s) +diff --git a/unix/path_lite_nonwindows.go b/unix/path_lite_nonwindows.go +index c9c4c02..497acf3 100644 +--- a/unix/path_lite_nonwindows.go ++++ b/unix/path_lite_nonwindows.go +@@ -2,8 +2,6 @@ + // Use of this source code is governed by a BSD-style + // license that can be found in the LICENSE file. + +-//go:build !windows +- +-package filepathlite ++package unix + + func postClean(out *lazybuf) {} +diff --git a/unix/path_lite_unix.go b/unix/path_lite_unix.go +index e31f1ae..21244f3 100644 +--- a/unix/path_lite_unix.go ++++ b/unix/path_lite_unix.go +@@ -2,13 +2,10 @@ + // Use of this source code is governed by a BSD-style + // license that can be found in the LICENSE file. + +-//go:build unix || (js && wasm) || wasip1 +- +-package filepathlite ++package unix + + import ( +- "internal/bytealg" +- "internal/stringslite" ++ "strings" + ) + + const ( +@@ -25,7 +22,7 @@ func isLocal(path string) bool { + } + + func localize(path string) (string, error) { +- if bytealg.IndexByteString(path, 0) >= 0 { ++ if strings.IndexByte(path, 0) >= 0 { + return "", errInvalidPath + } + return path, nil +@@ -33,7 +30,7 @@ func localize(path string) (string, error) { + + // IsAbs reports whether the path is absolute. + func IsAbs(path string) bool { +- return stringslite.HasPrefix(path, "/") ++ return strings.HasPrefix(path, "/") + } + + // volumeNameLen returns length of the leading volume name on Windows. +diff --git a/unix/path_unix.go b/unix/path_unix.go +index 6bc974d..032945b 100644 +--- a/unix/path_unix.go ++++ b/unix/path_unix.go +@@ -2,34 +2,13 @@ + // Use of this source code is governed by a BSD-style + // license that can be found in the LICENSE file. + +-//go:build unix || (js && wasm) || wasip1 +- +-package filepath ++package unix + + import ( + "strings" + ) + +-// HasPrefix exists for historical compatibility and should not be used. +-// +-// Deprecated: HasPrefix does not respect path boundaries and +-// does not ignore case when required. +-func HasPrefix(p, prefix string) bool { +- return strings.HasPrefix(p, prefix) +-} +- +-func splitList(path string) []string { +- if path == "" { +- return []string{} +- } +- return strings.Split(path, string(ListSeparator)) +-} +- +-func abs(path string) (string, error) { +- return unixAbs(path) +-} +- +-func join(elem []string) string { ++func Join(elem ...string) string { + // If there's a bug here, fix the logic in ./path_plan9.go too. + for i, e := range elem { + if e != "" { +@@ -38,7 +17,3 @@ func join(elem []string) string { + } + return "" + } +- +-func sameWord(a, b string) bool { +- return a == b +-} diff --git a/patches/windows.diff b/patches/windows.diff new file mode 100644 index 0000000..36335a7 --- /dev/null +++ b/patches/windows.diff @@ -0,0 +1,341 @@ +diff --git a/windows/path_lite.go b/windows/path_lite.go +index 4a37298..2ebb0d1 100644 +--- a/windows/path_lite.go ++++ b/windows/path_lite.go +@@ -2,17 +2,12 @@ + // Use of this source code is governed by a BSD-style + // license that can be found in the LICENSE file. + +-// Package filepathlite implements a subset of path/filepath, +-// only using packages which may be imported by "os". +-// +-// Tests for these functions are in path/filepath. +-package filepathlite ++package windows + + import ( + "errors" +- "internal/stringslite" +- "io/fs" + "slices" ++ "strings" + ) + + var errInvalidPath = errors.New("invalid path") +@@ -137,41 +132,6 @@ func Clean(path string) string { + return FromSlash(out.string()) + } + +-// IsLocal is filepath.IsLocal. +-func IsLocal(path string) bool { +- return isLocal(path) +-} +- +-func unixIsLocal(path string) bool { +- if IsAbs(path) || path == "" { +- return false +- } +- hasDots := false +- for p := path; p != ""; { +- var part string +- part, p, _ = stringslite.Cut(p, "/") +- if part == "." || part == ".." { +- hasDots = true +- break +- } +- } +- if hasDots { +- path = Clean(path) +- } +- if path == ".." || stringslite.HasPrefix(path, "../") { +- return false +- } +- return true +-} +- +-// Localize is filepath.Localize. +-func Localize(path string) (string, error) { +- if !fs.ValidPath(path) { +- return "", errInvalidPath +- } +- return localize(path) +-} +- + // ToSlash is filepath.ToSlash. + func ToSlash(path string) string { + if Separator == '/' { +@@ -189,7 +149,7 @@ func FromSlash(path string) string { + } + + func replaceStringByte(s string, old, new byte) string { +- if stringslite.IndexByte(s, old) == -1 { ++ if strings.IndexByte(s, old) == -1 { + return s + } + n := []byte(s) +diff --git a/windows/path_lite_windowsspecific.go b/windows/path_lite_windowsspecific.go +index 011baa9..23ba2b6 100644 +--- a/windows/path_lite_windowsspecific.go ++++ b/windows/path_lite_windowsspecific.go +@@ -2,14 +2,7 @@ + // Use of this source code is governed by a BSD-style + // license that can be found in the LICENSE file. + +-package filepathlite +- +-import ( +- "internal/bytealg" +- "internal/stringslite" +- "internal/syscall/windows" +- "syscall" +-) ++package windows + + const ( + Separator = '\\' // OS-specific path separator +@@ -20,159 +13,6 @@ func IsPathSeparator(c uint8) bool { + return c == '\\' || c == '/' + } + +-func isLocal(path string) bool { +- if path == "" { +- return false +- } +- if IsPathSeparator(path[0]) { +- // Path rooted in the current drive. +- return false +- } +- if stringslite.IndexByte(path, ':') >= 0 { +- // Colons are only valid when marking a drive letter ("C:foo"). +- // Rejecting any path with a colon is conservative but safe. +- return false +- } +- hasDots := false // contains . or .. path elements +- for p := path; p != ""; { +- var part string +- part, p, _ = cutPath(p) +- if part == "." || part == ".." { +- hasDots = true +- } +- if isReservedName(part) { +- return false +- } +- } +- if hasDots { +- path = Clean(path) +- } +- if path == ".." || stringslite.HasPrefix(path, `..\`) { +- return false +- } +- return true +-} +- +-func localize(path string) (string, error) { +- for i := 0; i < len(path); i++ { +- switch path[i] { +- case ':', '\\', 0: +- return "", errInvalidPath +- } +- } +- containsSlash := false +- for p := path; p != ""; { +- // Find the next path element. +- var element string +- i := bytealg.IndexByteString(p, '/') +- if i < 0 { +- element = p +- p = "" +- } else { +- containsSlash = true +- element = p[:i] +- p = p[i+1:] +- } +- if isReservedName(element) { +- return "", errInvalidPath +- } +- } +- if containsSlash { +- // We can't depend on strings, so substitute \ for / manually. +- buf := []byte(path) +- for i, b := range buf { +- if b == '/' { +- buf[i] = '\\' +- } +- } +- path = string(buf) +- } +- return path, nil +-} +- +-// isReservedName reports if name is a Windows reserved device name. +-// It does not detect names with an extension, which are also reserved on some Windows versions. +-// +-// For details, search for PRN in +-// https://docs.microsoft.com/en-us/windows/desktop/fileio/naming-a-file. +-func isReservedName(name string) bool { +- // Device names can have arbitrary trailing characters following a dot or colon. +- base := name +- for i := 0; i < len(base); i++ { +- switch base[i] { +- case ':', '.': +- base = base[:i] +- } +- } +- // Trailing spaces in the last path element are ignored. +- for len(base) > 0 && base[len(base)-1] == ' ' { +- base = base[:len(base)-1] +- } +- if !isReservedBaseName(base) { +- return false +- } +- if len(base) == len(name) { +- return true +- } +- // The path element is a reserved name with an extension. +- // Since Windows 11, reserved names with extensions are no +- // longer reserved. For example, "CON.txt" is a valid file +- // name. Use RtlIsDosDeviceName_U to see if the name is reserved. +- p, err := syscall.UTF16PtrFromString(name) +- if err != nil { +- return false +- } +- return windows.RtlIsDosDeviceName_U(p) > 0 +-} +- +-func isReservedBaseName(name string) bool { +- if len(name) == 3 { +- switch string([]byte{toUpper(name[0]), toUpper(name[1]), toUpper(name[2])}) { +- case "CON", "PRN", "AUX", "NUL": +- return true +- } +- } +- if len(name) >= 4 { +- switch string([]byte{toUpper(name[0]), toUpper(name[1]), toUpper(name[2])}) { +- case "COM", "LPT": +- if len(name) == 4 && '1' <= name[3] && name[3] <= '9' { +- return true +- } +- // Superscript ¹, ², and ³ are considered numbers as well. +- switch name[3:] { +- case "\u00b2", "\u00b3", "\u00b9": +- return true +- } +- return false +- } +- } +- +- // Passing CONIN$ or CONOUT$ to CreateFile opens a console handle. +- // https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-createfilea#consoles +- // +- // While CONIN$ and CONOUT$ aren't documented as being files, +- // they behave the same as CON. For example, ./CONIN$ also opens the console input. +- if len(name) == 6 && name[5] == '$' && equalFold(name, "CONIN$") { +- return true +- } +- if len(name) == 7 && name[6] == '$' && equalFold(name, "CONOUT$") { +- return true +- } +- return false +-} +- +-func equalFold(a, b string) bool { +- if len(a) != len(b) { +- return false +- } +- for i := 0; i < len(a); i++ { +- if toUpper(a[i]) != toUpper(b[i]) { +- return false +- } +- } +- return true +-} +- + func toUpper(c byte) byte { + if 'a' <= c && c <= 'z' { + return c - ('a' - 'A') +diff --git a/windows/path_windowsspecific.go b/windows/path_windowsspecific.go +index d0eb42c..40b9315 100644 +--- a/windows/path_windowsspecific.go ++++ b/windows/path_windowsspecific.go +@@ -2,71 +2,14 @@ + // Use of this source code is governed by a BSD-style + // license that can be found in the LICENSE file. + +-package filepath ++package windows + + import ( + "os" + "strings" +- "syscall" + ) + +-// HasPrefix exists for historical compatibility and should not be used. +-// +-// Deprecated: HasPrefix does not respect path boundaries and +-// does not ignore case when required. +-func HasPrefix(p, prefix string) bool { +- if strings.HasPrefix(p, prefix) { +- return true +- } +- return strings.HasPrefix(strings.ToLower(p), strings.ToLower(prefix)) +-} +- +-func splitList(path string) []string { +- // The same implementation is used in LookPath in os/exec; +- // consider changing os/exec when changing this. +- +- if path == "" { +- return []string{} +- } +- +- // Split path, respecting but preserving quotes. +- list := []string{} +- start := 0 +- quo := false +- for i := 0; i < len(path); i++ { +- switch c := path[i]; { +- case c == '"': +- quo = !quo +- case c == ListSeparator && !quo: +- list = append(list, path[start:i]) +- start = i + 1 +- } +- } +- list = append(list, path[start:]) +- +- // Remove quotes. +- for i, s := range list { +- list[i] = strings.ReplaceAll(s, `"`, ``) +- } +- +- return list +-} +- +-func abs(path string) (string, error) { +- if path == "" { +- // syscall.FullPath returns an error on empty path, because it's not a valid path. +- // To implement Abs behavior of returning working directory on empty string input, +- // special-case empty path by changing it to "." path. See golang.org/issue/24441. +- path = "." +- } +- fullPath, err := syscall.FullPath(path) +- if err != nil { +- return "", err +- } +- return Clean(fullPath), nil +-} +- +-func join(elem []string) string { ++func Join(elem ...string) string { + var b strings.Builder + var lastChar byte + for _, e := range elem { +@@ -112,7 +55,3 @@ func join(elem []string) string { + } + return Clean(b.String()) + } +- +-func sameWord(a, b string) bool { +- return strings.EqualFold(a, b) +-} diff --git a/style.go b/style.go new file mode 100644 index 0000000..4ce2a27 --- /dev/null +++ b/style.go @@ -0,0 +1,85 @@ +package universalpath + +import ( + "github.com/NextronSystems/universalpath/unix" + "github.com/NextronSystems/universalpath/windows" +) + +type Style int + +const ( + Unix Style = iota + Windows +) + +func (p Style) Clean(s string) string { + if p == Unix { + return unix.Clean(s) + } else { + return windows.Clean(s) + } +} + +func (p Style) Split(s string) (string, string) { + if p == Unix { + return unix.Split(s) + } else { + return windows.Split(s) + } +} + +func (p Style) Base(s string) string { + if p == Unix { + return unix.Base(s) + } else { + return windows.Base(s) + } +} + +func (p Style) Ext(s string) string { + if p == Unix { + return unix.Ext(s) + } else { + return windows.Ext(s) + } +} + +func (p Style) Dir(s string) string { + if p == Unix { + return unix.Dir(s) + } else { + return windows.Dir(s) + } +} + +func (p Style) Join(s ...string) string { + if p == Unix { + return unix.Join(s...) + } else { + return windows.Join(s...) + } +} + +func (p Style) Separator() string { + if p == Unix { + return "/" + } else { + return `\` + } +} + +func (p Style) SeparatorByte() byte { + if p == Unix { + return '/' + } else { + return '\\' + } +} + +func (p Style) IsAbs(s string) bool { + if p == Unix { + return unix.IsAbs(s) + } else { + return windows.IsAbs(s) + } +} diff --git a/style_nonwindows.go b/style_nonwindows.go new file mode 100644 index 0000000..fa44def --- /dev/null +++ b/style_nonwindows.go @@ -0,0 +1,5 @@ +//go:build !windows + +package universalpath + +const Native = Unix diff --git a/style_windows.go b/style_windows.go new file mode 100644 index 0000000..bca0b3d --- /dev/null +++ b/style_windows.go @@ -0,0 +1,5 @@ +//go:build windows + +package universalpath + +const Native = Windows diff --git a/unix/origins.go b/unix/origins.go new file mode 100644 index 0000000..f380f47 --- /dev/null +++ b/unix/origins.go @@ -0,0 +1,12 @@ +package unix + +// The following files in this directory are copied from the Go standard library (version 1.25.5) +// and are licensed under a BSD-3-style license. + +// They have been modified to fit into this project structure. +// The changes have been documented in the patch files located in the patches/ directory. + +// path_lite.go: https://github.com/golang/go/tree/go1.25.5/src/internal/filepathlite/path.go +// path_lite_unix.go: https://github.com/golang/go/tree/go1.25.5/src/internal/filepathlite/path_unix.go +// path_lite_nonwindows.go: https://github.com/golang/go/tree/go1.25.5/src/internal/filepathlite/path_nonwindows.go +// path_unix.go: https://github.com/golang/go/tree/go1.25.5/src/path/filepath/path_unix.go diff --git a/unix/path_lite.go b/unix/path_lite.go new file mode 100644 index 0000000..c15d1ed --- /dev/null +++ b/unix/path_lite.go @@ -0,0 +1,270 @@ +// Copyright 2024 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package unix + +import ( + "errors" + "io/fs" + "slices" + "strings" +) + +var errInvalidPath = errors.New("invalid path") + +// A lazybuf is a lazily constructed path buffer. +// It supports append, reading previously appended bytes, +// and retrieving the final string. It does not allocate a buffer +// to hold the output until that output diverges from s. +type lazybuf struct { + path string + buf []byte + w int + volAndPath string + volLen int +} + +func (b *lazybuf) index(i int) byte { + if b.buf != nil { + return b.buf[i] + } + return b.path[i] +} + +func (b *lazybuf) append(c byte) { + if b.buf == nil { + if b.w < len(b.path) && b.path[b.w] == c { + b.w++ + return + } + b.buf = make([]byte, len(b.path)) + copy(b.buf, b.path[:b.w]) + } + b.buf[b.w] = c + b.w++ +} + +func (b *lazybuf) prepend(prefix ...byte) { + b.buf = slices.Insert(b.buf, 0, prefix...) + b.w += len(prefix) +} + +func (b *lazybuf) string() string { + if b.buf == nil { + return b.volAndPath[:b.volLen+b.w] + } + return b.volAndPath[:b.volLen] + string(b.buf[:b.w]) +} + +// Clean is filepath.Clean. +func Clean(path string) string { + originalPath := path + volLen := volumeNameLen(path) + path = path[volLen:] + if path == "" { + if volLen > 1 && IsPathSeparator(originalPath[0]) && IsPathSeparator(originalPath[1]) { + // should be UNC + return FromSlash(originalPath) + } + return originalPath + "." + } + rooted := IsPathSeparator(path[0]) + + // Invariants: + // reading from path; r is index of next byte to process. + // writing to buf; w is index of next byte to write. + // dotdot is index in buf where .. must stop, either because + // it is the leading slash or it is a leading ../../.. prefix. + n := len(path) + out := lazybuf{path: path, volAndPath: originalPath, volLen: volLen} + r, dotdot := 0, 0 + if rooted { + out.append(Separator) + r, dotdot = 1, 1 + } + + for r < n { + switch { + case IsPathSeparator(path[r]): + // empty path element + r++ + case path[r] == '.' && (r+1 == n || IsPathSeparator(path[r+1])): + // . element + r++ + case path[r] == '.' && path[r+1] == '.' && (r+2 == n || IsPathSeparator(path[r+2])): + // .. element: remove to last separator + r += 2 + switch { + case out.w > dotdot: + // can backtrack + out.w-- + for out.w > dotdot && !IsPathSeparator(out.index(out.w)) { + out.w-- + } + case !rooted: + // cannot backtrack, but not rooted, so append .. element. + if out.w > 0 { + out.append(Separator) + } + out.append('.') + out.append('.') + dotdot = out.w + } + default: + // real path element. + // add slash if needed + if rooted && out.w != 1 || !rooted && out.w != 0 { + out.append(Separator) + } + // copy element + for ; r < n && !IsPathSeparator(path[r]); r++ { + out.append(path[r]) + } + } + } + + // Turn empty string into "." + if out.w == 0 { + out.append('.') + } + + postClean(&out) // avoid creating absolute paths on Windows + return FromSlash(out.string()) +} + +// IsLocal is filepath.IsLocal. +func IsLocal(path string) bool { + return isLocal(path) +} + +func unixIsLocal(path string) bool { + if IsAbs(path) || path == "" { + return false + } + hasDots := false + for p := path; p != ""; { + var part string + part, p, _ = strings.Cut(p, "/") + if part == "." || part == ".." { + hasDots = true + break + } + } + if hasDots { + path = Clean(path) + } + if path == ".." || strings.HasPrefix(path, "../") { + return false + } + return true +} + +// Localize is filepath.Localize. +func Localize(path string) (string, error) { + if !fs.ValidPath(path) { + return "", errInvalidPath + } + return localize(path) +} + +// ToSlash is filepath.ToSlash. +func ToSlash(path string) string { + if Separator == '/' { + return path + } + return replaceStringByte(path, Separator, '/') +} + +// FromSlash is filepath.FromSlash. +func FromSlash(path string) string { + if Separator == '/' { + return path + } + return replaceStringByte(path, '/', Separator) +} + +func replaceStringByte(s string, old, new byte) string { + if strings.IndexByte(s, old) == -1 { + return s + } + n := []byte(s) + for i := range n { + if n[i] == old { + n[i] = new + } + } + return string(n) +} + +// Split is filepath.Split. +func Split(path string) (dir, file string) { + vol := VolumeName(path) + i := len(path) - 1 + for i >= len(vol) && !IsPathSeparator(path[i]) { + i-- + } + return path[:i+1], path[i+1:] +} + +// Ext is filepath.Ext. +func Ext(path string) string { + for i := len(path) - 1; i >= 0 && !IsPathSeparator(path[i]); i-- { + if path[i] == '.' { + return path[i:] + } + } + return "" +} + +// Base is filepath.Base. +func Base(path string) string { + if path == "" { + return "." + } + // Strip trailing slashes. + for len(path) > 0 && IsPathSeparator(path[len(path)-1]) { + path = path[0 : len(path)-1] + } + // Throw away volume name + path = path[len(VolumeName(path)):] + // Find the last element + i := len(path) - 1 + for i >= 0 && !IsPathSeparator(path[i]) { + i-- + } + if i >= 0 { + path = path[i+1:] + } + // If empty now, it had only slashes. + if path == "" { + return string(Separator) + } + return path +} + +// Dir is filepath.Dir. +func Dir(path string) string { + vol := VolumeName(path) + i := len(path) - 1 + for i >= len(vol) && !IsPathSeparator(path[i]) { + i-- + } + dir := Clean(path[len(vol) : i+1]) + if dir == "." && len(vol) > 2 { + // must be UNC + return vol + } + return vol + dir +} + +// VolumeName is filepath.VolumeName. +func VolumeName(path string) string { + return FromSlash(path[:volumeNameLen(path)]) +} + +// VolumeNameLen returns the length of the leading volume name on Windows. +// It returns 0 elsewhere. +func VolumeNameLen(path string) int { + return volumeNameLen(path) +} diff --git a/unix/path_lite_nonwindows.go b/unix/path_lite_nonwindows.go new file mode 100644 index 0000000..497acf3 --- /dev/null +++ b/unix/path_lite_nonwindows.go @@ -0,0 +1,7 @@ +// Copyright 2023 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package unix + +func postClean(out *lazybuf) {} diff --git a/unix/path_lite_unix.go b/unix/path_lite_unix.go new file mode 100644 index 0000000..21244f3 --- /dev/null +++ b/unix/path_lite_unix.go @@ -0,0 +1,40 @@ +// Copyright 2010 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package unix + +import ( + "strings" +) + +const ( + Separator = '/' // OS-specific path separator + ListSeparator = ':' // OS-specific path list separator +) + +func IsPathSeparator(c uint8) bool { + return Separator == c +} + +func isLocal(path string) bool { + return unixIsLocal(path) +} + +func localize(path string) (string, error) { + if strings.IndexByte(path, 0) >= 0 { + return "", errInvalidPath + } + return path, nil +} + +// IsAbs reports whether the path is absolute. +func IsAbs(path string) bool { + return strings.HasPrefix(path, "/") +} + +// volumeNameLen returns length of the leading volume name on Windows. +// It returns 0 elsewhere. +func volumeNameLen(path string) int { + return 0 +} diff --git a/unix/path_test.go b/unix/path_test.go new file mode 100644 index 0000000..9263a9c --- /dev/null +++ b/unix/path_test.go @@ -0,0 +1,116 @@ +package unix_test + +import ( + "testing" + + "github.com/NextronSystems/universalpath/unix" +) + +type stringTest struct { + input string + output string +} + +func TestBase(t *testing.T) { + for _, tc := range []stringTest{ + {input: "/foo/bar/baz.txt", output: "baz.txt"}, + {input: "foo/bar/baz.txt", output: "baz.txt"}, + {input: "baz.txt", output: "baz.txt"}, + {input: "/baz.txt", output: "baz.txt"}, + {input: ".", output: "."}, + {input: "..", output: ".."}, + {input: "/", output: "/"}, + {input: "", output: "."}, + } { + result := unix.Base(tc.input) + if result != tc.output { + t.Errorf("Unix.Base(%q) = %q; want %q", tc.input, result, tc.output) + } + } +} + +func TestDir(t *testing.T) { + for _, tc := range []stringTest{ + {input: "/foo/bar/baz.txt", output: "/foo/bar"}, + {input: "foo/bar/baz.txt", output: "foo/bar"}, + {input: "baz.txt", output: "."}, + {input: "/baz.txt", output: "/"}, + {input: ".", output: "."}, + {input: "..", output: "."}, + {input: "/", output: "/"}, + {input: "", output: "."}, + } { + result := unix.Dir(tc.input) + if result != tc.output { + t.Errorf("Unix.Dir(%q) = %q; want %q", tc.input, result, tc.output) + } + } +} + +func TestJoin(t *testing.T) { + for _, tc := range []struct { + parts []string + output string + }{ + {parts: []string{"/foo", "bar", "baz.txt"}, output: "/foo/bar/baz.txt"}, + {parts: []string{"foo", "bar", "baz.txt"}, output: "foo/bar/baz.txt"}, + {parts: []string{"baz.txt"}, output: "baz.txt"}, + {parts: []string{"/", "..", "baz.txt"}, output: "/baz.txt"}, + {parts: []string{"/", "foo", "..", "baz.txt"}, output: "/baz.txt"}, + {parts: []string{"/", "foo", "bar", "..", "baz.txt"}, output: "/foo/baz.txt"}, + {parts: []string{"..", "baz.txt"}, output: "../baz.txt"}, + } { + result := unix.Join(tc.parts...) + if result != tc.output { + t.Errorf("Unix.Join(%q) = %q; want %q", tc.parts, result, tc.output) + } + } +} + +func TestExt(t *testing.T) { + for _, tc := range []stringTest{ + {input: "/foo/bar/baz.txt", output: ".txt"}, + {input: "foo/bar/baz.tar.gz", output: ".gz"}, + {input: "baz", output: ""}, + {input: "/baz.", output: "."}, + } { + result := unix.Ext(tc.input) + if result != tc.output { + t.Errorf("Unix.Ext(%q) = %q; want %q", tc.input, result, tc.output) + } + } +} + +func TestSplit(t *testing.T) { + for _, tc := range []struct { + input string + dir string + base string + }{ + {input: "/foo/bar/baz.txt", dir: "/foo/bar/", base: "baz.txt"}, + {input: "foo/bar/baz.txt", dir: "foo/bar/", base: "baz.txt"}, + {input: "baz.txt", dir: "", base: "baz.txt"}, + {input: "/baz.txt", dir: "/", base: "baz.txt"}, + } { + dir, base := unix.Split(tc.input) + if dir != tc.dir || base != tc.base { + t.Errorf("Unix.Split(%q) = (%q, %q); want (%q, %q)", tc.input, dir, base, tc.dir, tc.base) + } + } +} + +func TestClean(t *testing.T) { + for _, tc := range []stringTest{ + {input: "/foo/./bar//baz.txt", output: "/foo/bar/baz.txt"}, + {input: "foo/bar/../baz.txt", output: "foo/baz.txt"}, + {input: "./baz.txt", output: "baz.txt"}, + {input: "/foo/bar/../../baz.txt", output: "/baz.txt"}, + {input: "/../baz.txt", output: "/baz.txt"}, + {input: "foo//bar///baz.txt", output: "foo/bar/baz.txt"}, + } { + result := unix.Clean(tc.input) + if result != tc.output { + t.Errorf("Unix.Clean(%q) = %q; want %q", tc.input, result, tc.output) + } + } +} diff --git a/unix/path_unix.go b/unix/path_unix.go new file mode 100644 index 0000000..032945b --- /dev/null +++ b/unix/path_unix.go @@ -0,0 +1,19 @@ +// Copyright 2010 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package unix + +import ( + "strings" +) + +func Join(elem ...string) string { + // If there's a bug here, fix the logic in ./path_plan9.go too. + for i, e := range elem { + if e != "" { + return Clean(strings.Join(elem[i:], string(Separator))) + } + } + return "" +} diff --git a/windows/origins.go b/windows/origins.go new file mode 100644 index 0000000..974c2ac --- /dev/null +++ b/windows/origins.go @@ -0,0 +1,11 @@ +package windows + +// The following files in this directory are copied from the Go standard library (version 1.25.5) +// and are licensed under a BSD-3-style license. + +// They have been modified to fit into this project structure. +// The changes have been documented in the patch files located in the patches/ directory. + +// path_lite.go: https://github.com/golang/go/tree/go1.25.5/src/internal/filepathlite/path.go +// path_lite_windowsspecific.go: https://github.com/golang/go/tree/go1.25.5/src/internal/filepathlite/path_windows.go +// path_windowsspecific.go: https://github.com/golang/go/tree/go1.25.5/src/path/filepath/path_windows.go diff --git a/windows/path_lite.go b/windows/path_lite.go new file mode 100644 index 0000000..2ebb0d1 --- /dev/null +++ b/windows/path_lite.go @@ -0,0 +1,234 @@ +// Copyright 2024 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package windows + +import ( + "errors" + "slices" + "strings" +) + +var errInvalidPath = errors.New("invalid path") + +// A lazybuf is a lazily constructed path buffer. +// It supports append, reading previously appended bytes, +// and retrieving the final string. It does not allocate a buffer +// to hold the output until that output diverges from s. +type lazybuf struct { + path string + buf []byte + w int + volAndPath string + volLen int +} + +func (b *lazybuf) index(i int) byte { + if b.buf != nil { + return b.buf[i] + } + return b.path[i] +} + +func (b *lazybuf) append(c byte) { + if b.buf == nil { + if b.w < len(b.path) && b.path[b.w] == c { + b.w++ + return + } + b.buf = make([]byte, len(b.path)) + copy(b.buf, b.path[:b.w]) + } + b.buf[b.w] = c + b.w++ +} + +func (b *lazybuf) prepend(prefix ...byte) { + b.buf = slices.Insert(b.buf, 0, prefix...) + b.w += len(prefix) +} + +func (b *lazybuf) string() string { + if b.buf == nil { + return b.volAndPath[:b.volLen+b.w] + } + return b.volAndPath[:b.volLen] + string(b.buf[:b.w]) +} + +// Clean is filepath.Clean. +func Clean(path string) string { + originalPath := path + volLen := volumeNameLen(path) + path = path[volLen:] + if path == "" { + if volLen > 1 && IsPathSeparator(originalPath[0]) && IsPathSeparator(originalPath[1]) { + // should be UNC + return FromSlash(originalPath) + } + return originalPath + "." + } + rooted := IsPathSeparator(path[0]) + + // Invariants: + // reading from path; r is index of next byte to process. + // writing to buf; w is index of next byte to write. + // dotdot is index in buf where .. must stop, either because + // it is the leading slash or it is a leading ../../.. prefix. + n := len(path) + out := lazybuf{path: path, volAndPath: originalPath, volLen: volLen} + r, dotdot := 0, 0 + if rooted { + out.append(Separator) + r, dotdot = 1, 1 + } + + for r < n { + switch { + case IsPathSeparator(path[r]): + // empty path element + r++ + case path[r] == '.' && (r+1 == n || IsPathSeparator(path[r+1])): + // . element + r++ + case path[r] == '.' && path[r+1] == '.' && (r+2 == n || IsPathSeparator(path[r+2])): + // .. element: remove to last separator + r += 2 + switch { + case out.w > dotdot: + // can backtrack + out.w-- + for out.w > dotdot && !IsPathSeparator(out.index(out.w)) { + out.w-- + } + case !rooted: + // cannot backtrack, but not rooted, so append .. element. + if out.w > 0 { + out.append(Separator) + } + out.append('.') + out.append('.') + dotdot = out.w + } + default: + // real path element. + // add slash if needed + if rooted && out.w != 1 || !rooted && out.w != 0 { + out.append(Separator) + } + // copy element + for ; r < n && !IsPathSeparator(path[r]); r++ { + out.append(path[r]) + } + } + } + + // Turn empty string into "." + if out.w == 0 { + out.append('.') + } + + postClean(&out) // avoid creating absolute paths on Windows + return FromSlash(out.string()) +} + +// ToSlash is filepath.ToSlash. +func ToSlash(path string) string { + if Separator == '/' { + return path + } + return replaceStringByte(path, Separator, '/') +} + +// FromSlash is filepath.FromSlash. +func FromSlash(path string) string { + if Separator == '/' { + return path + } + return replaceStringByte(path, '/', Separator) +} + +func replaceStringByte(s string, old, new byte) string { + if strings.IndexByte(s, old) == -1 { + return s + } + n := []byte(s) + for i := range n { + if n[i] == old { + n[i] = new + } + } + return string(n) +} + +// Split is filepath.Split. +func Split(path string) (dir, file string) { + vol := VolumeName(path) + i := len(path) - 1 + for i >= len(vol) && !IsPathSeparator(path[i]) { + i-- + } + return path[:i+1], path[i+1:] +} + +// Ext is filepath.Ext. +func Ext(path string) string { + for i := len(path) - 1; i >= 0 && !IsPathSeparator(path[i]); i-- { + if path[i] == '.' { + return path[i:] + } + } + return "" +} + +// Base is filepath.Base. +func Base(path string) string { + if path == "" { + return "." + } + // Strip trailing slashes. + for len(path) > 0 && IsPathSeparator(path[len(path)-1]) { + path = path[0 : len(path)-1] + } + // Throw away volume name + path = path[len(VolumeName(path)):] + // Find the last element + i := len(path) - 1 + for i >= 0 && !IsPathSeparator(path[i]) { + i-- + } + if i >= 0 { + path = path[i+1:] + } + // If empty now, it had only slashes. + if path == "" { + return string(Separator) + } + return path +} + +// Dir is filepath.Dir. +func Dir(path string) string { + vol := VolumeName(path) + i := len(path) - 1 + for i >= len(vol) && !IsPathSeparator(path[i]) { + i-- + } + dir := Clean(path[len(vol) : i+1]) + if dir == "." && len(vol) > 2 { + // must be UNC + return vol + } + return vol + dir +} + +// VolumeName is filepath.VolumeName. +func VolumeName(path string) string { + return FromSlash(path[:volumeNameLen(path)]) +} + +// VolumeNameLen returns the length of the leading volume name on Windows. +// It returns 0 elsewhere. +func VolumeNameLen(path string) int { + return volumeNameLen(path) +} diff --git a/windows/path_lite_windowsspecific.go b/windows/path_lite_windowsspecific.go new file mode 100644 index 0000000..23ba2b6 --- /dev/null +++ b/windows/path_lite_windowsspecific.go @@ -0,0 +1,166 @@ +// Copyright 2010 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package windows + +const ( + Separator = '\\' // OS-specific path separator + ListSeparator = ';' // OS-specific path list separator +) + +func IsPathSeparator(c uint8) bool { + return c == '\\' || c == '/' +} + +func toUpper(c byte) byte { + if 'a' <= c && c <= 'z' { + return c - ('a' - 'A') + } + return c +} + +// IsAbs reports whether the path is absolute. +func IsAbs(path string) (b bool) { + l := volumeNameLen(path) + if l == 0 { + return false + } + // If the volume name starts with a double slash, this is an absolute path. + if IsPathSeparator(path[0]) && IsPathSeparator(path[1]) { + return true + } + path = path[l:] + if path == "" { + return false + } + return IsPathSeparator(path[0]) +} + +// volumeNameLen returns length of the leading volume name on Windows. +// It returns 0 elsewhere. +// +// See: +// https://learn.microsoft.com/en-us/dotnet/standard/io/file-path-formats +// https://googleprojectzero.blogspot.com/2016/02/the-definitive-guide-on-win32-to-nt.html +func volumeNameLen(path string) int { + switch { + case len(path) >= 2 && path[1] == ':': + // Path starts with a drive letter. + // + // Not all Windows functions necessarily enforce the requirement that + // drive letters be in the set A-Z, and we don't try to here. + // + // We don't handle the case of a path starting with a non-ASCII character, + // in which case the "drive letter" might be multiple bytes long. + return 2 + + case len(path) == 0 || !IsPathSeparator(path[0]): + // Path does not have a volume component. + return 0 + + case pathHasPrefixFold(path, `\\.\UNC`): + // We're going to treat the UNC host and share as part of the volume + // prefix for historical reasons, but this isn't really principled; + // Windows's own GetFullPathName will happily remove the first + // component of the path in this space, converting + // \\.\unc\a\b\..\c into \\.\unc\a\c. + return uncLen(path, len(`\\.\UNC\`)) + + case pathHasPrefixFold(path, `\\.`) || + pathHasPrefixFold(path, `\\?`) || pathHasPrefixFold(path, `\??`): + // Path starts with \\.\, and is a Local Device path; or + // path starts with \\?\ or \??\ and is a Root Local Device path. + // + // We treat the next component after the \\.\ prefix as + // part of the volume name, which means Clean(`\\?\c:\`) + // won't remove the trailing \. (See #64028.) + if len(path) == 3 { + return 3 // exactly \\. + } + _, rest, ok := cutPath(path[4:]) + if !ok { + return len(path) + } + return len(path) - len(rest) - 1 + + case len(path) >= 2 && IsPathSeparator(path[1]): + // Path starts with \\, and is a UNC path. + return uncLen(path, 2) + } + return 0 +} + +// pathHasPrefixFold tests whether the path s begins with prefix, +// ignoring case and treating all path separators as equivalent. +// If s is longer than prefix, then s[len(prefix)] must be a path separator. +func pathHasPrefixFold(s, prefix string) bool { + if len(s) < len(prefix) { + return false + } + for i := 0; i < len(prefix); i++ { + if IsPathSeparator(prefix[i]) { + if !IsPathSeparator(s[i]) { + return false + } + } else if toUpper(prefix[i]) != toUpper(s[i]) { + return false + } + } + if len(s) > len(prefix) && !IsPathSeparator(s[len(prefix)]) { + return false + } + return true +} + +// uncLen returns the length of the volume prefix of a UNC path. +// prefixLen is the prefix prior to the start of the UNC host; +// for example, for "//host/share", the prefixLen is len("//")==2. +func uncLen(path string, prefixLen int) int { + count := 0 + for i := prefixLen; i < len(path); i++ { + if IsPathSeparator(path[i]) { + count++ + if count == 2 { + return i + } + } + } + return len(path) +} + +// cutPath slices path around the first path separator. +func cutPath(path string) (before, after string, found bool) { + for i := range path { + if IsPathSeparator(path[i]) { + return path[:i], path[i+1:], true + } + } + return path, "", false +} + +// postClean adjusts the results of Clean to avoid turning a relative path +// into an absolute or rooted one. +func postClean(out *lazybuf) { + if out.volLen != 0 || out.buf == nil { + return + } + // If a ':' appears in the path element at the start of a path, + // insert a .\ at the beginning to avoid converting relative paths + // like a/../c: into c:. + for _, c := range out.buf { + if IsPathSeparator(c) { + break + } + if c == ':' { + out.prepend('.', Separator) + return + } + } + // If a path begins with \??\, insert a \. at the beginning + // to avoid converting paths like \a\..\??\c:\x into \??\c:\x + // (equivalent to c:\x). + if len(out.buf) >= 3 && IsPathSeparator(out.buf[0]) && out.buf[1] == '?' && out.buf[2] == '?' { + out.prepend(Separator, '.') + } +} diff --git a/windows/path_test.go b/windows/path_test.go new file mode 100644 index 0000000..ec42e1d --- /dev/null +++ b/windows/path_test.go @@ -0,0 +1,117 @@ +package windows_test + +import ( + "testing" + + "github.com/NextronSystems/universalpath/windows" +) + +type stringTest struct { + input string + output string +} + +func TestBase(t *testing.T) { + for _, tc := range []stringTest{ + {input: `C:\foo\bar\baz.txt`, output: "baz.txt"}, + {input: `foo\bar\baz.txt`, output: "baz.txt"}, + {input: `baz.txt`, output: "baz.txt"}, + {input: `\\.\pipe\baz.txt`, output: "baz.txt"}, + {input: ".", output: "."}, + {input: "..", output: ".."}, + {input: "/", output: "\\"}, + {input: "", output: "."}, + } { + result := windows.Base(tc.input) + if result != tc.output { + t.Errorf("Windows.Base(%q) = %q; want %q", tc.input, result, tc.output) + } + } +} + +func TestDir(t *testing.T) { + for _, tc := range []stringTest{ + {input: `C:\foo\bar\baz.txt`, output: `C:\foo\bar`}, + {input: `foo\bar\baz.txt`, output: `foo\bar`}, + {input: `baz.txt`, output: `.`}, + {input: ".", output: "."}, + {input: "..", output: "."}, + {input: "C:\\", output: "C:\\"}, + {input: "", output: "."}, + } { + result := windows.Dir(tc.input) + if result != tc.output { + t.Errorf("Windows.Dir(%q) = %q; want %q", tc.input, result, tc.output) + } + } +} + +func TestJoin(t *testing.T) { + for _, tc := range []struct { + parts []string + output string + }{ + {parts: []string{`C:\foo`, "bar", "baz.txt"}, output: `C:\foo\bar\baz.txt`}, + {parts: []string{`foo`, "bar", "baz.txt"}, output: `foo\bar\baz.txt`}, + {parts: []string{`baz.txt`}, output: `baz.txt`}, + {parts: []string{`C:\`, "foo", "..", "baz.txt"}, output: `C:\baz.txt`}, + {parts: []string{`C:\`, "..", "baz.txt"}, output: `C:\baz.txt`}, + {parts: []string{"..", "baz.txt"}, output: "..\\baz.txt"}, + } { + result := windows.Join(tc.parts...) + if result != tc.output { + t.Errorf("Windows.Join(%q) = %q; want %q", tc.parts, result, tc.output) + } + } +} + +func TestExt(t *testing.T) { + for _, tc := range []stringTest{ + {input: `C:\foo\bar\baz.txt`, output: ".txt"}, + {input: `foo\bar\baz.tar.gz`, output: ".gz"}, + {input: `baz`, output: ""}, + {input: `\baz.`, output: "."}, + } { + result := windows.Ext(tc.input) + if result != tc.output { + t.Errorf("Windows.Ext(%q) = %q; want %q", tc.input, result, tc.output) + } + } +} + +func TestSplit(t *testing.T) { + for _, tc := range []struct { + input string + dir string + base string + }{ + {input: `C:\foo\bar\baz.txt`, dir: `C:\foo\bar\`, base: "baz.txt"}, + {input: `foo\bar\baz.txt`, dir: `foo\bar\`, base: "baz.txt"}, + {input: `baz.txt`, dir: ``, base: "baz.txt"}, + {input: `\\.\pipe\baz.txt`, dir: `\\.\pipe\`, base: "baz.txt"}, + {input: `\\network\path\baz.txt`, dir: `\\network\path\`, base: "baz.txt"}, + } { + dir, base := windows.Split(tc.input) + if dir != tc.dir || base != tc.base { + t.Errorf("Windows.Split(%q) = (%q, %q); want (%q, %q)", tc.input, dir, base, tc.dir, tc.base) + } + } +} + +func TestClean(t *testing.T) { + for _, tc := range []stringTest{ + {input: `C:\foo\..\bar\baz.txt`, output: `C:\bar\baz.txt`}, + {input: `foo\..\bar\baz.txt`, output: `bar\baz.txt`}, + {input: `.\baz.txt`, output: `baz.txt`}, + {input: `C:\foo\.\bar\baz.txt`, output: `C:\foo\bar\baz.txt`}, + {input: `C:\foo\\bar\\baz.txt`, output: `C:\foo\bar\baz.txt`}, + {input: `C:\foo\bar\..\..\baz.txt`, output: `C:\baz.txt`}, + {input: `..\baz.txt`, output: `..\baz.txt`}, + {input: `\\network\path\..\baz.txt`, output: `\\network\path\baz.txt`}, + } { + result := windows.Clean(tc.input) + if result != tc.output { + t.Errorf("Windows.Clean(%q) = %q; want %q", tc.input, result, tc.output) + } + } +} diff --git a/windows/path_windowsspecific.go b/windows/path_windowsspecific.go new file mode 100644 index 0000000..40b9315 --- /dev/null +++ b/windows/path_windowsspecific.go @@ -0,0 +1,57 @@ +// Copyright 2024 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package windows + +import ( + "os" + "strings" +) + +func Join(elem ...string) string { + var b strings.Builder + var lastChar byte + for _, e := range elem { + switch { + case b.Len() == 0: + // Add the first non-empty path element unchanged. + case os.IsPathSeparator(lastChar): + // If the path ends in a slash, strip any leading slashes from the next + // path element to avoid creating a UNC path (any path starting with "\\") + // from non-UNC elements. + // + // The correct behavior for Join when the first element is an incomplete UNC + // path (for example, "\\") is underspecified. We currently join subsequent + // elements so Join("\\", "host", "share") produces "\\host\share". + for len(e) > 0 && os.IsPathSeparator(e[0]) { + e = e[1:] + } + // If the path is \ and the next path element is ??, + // add an extra .\ to create \.\?? rather than \??\ + // (a Root Local Device path). + if b.Len() == 1 && strings.HasPrefix(e, "??") && (len(e) == len("??") || os.IsPathSeparator(e[2])) { + b.WriteString(`.\`) + } + case lastChar == ':': + // If the path ends in a colon, keep the path relative to the current directory + // on a drive and don't add a separator. Preserve leading slashes in the next + // path element, which may make the path absolute. + // + // Join(`C:`, `f`) = `C:f` + // Join(`C:`, `\f`) = `C:\f` + default: + // In all other cases, add a separator between elements. + b.WriteByte('\\') + lastChar = '\\' + } + if len(e) > 0 { + b.WriteString(e) + lastChar = e[len(e)-1] + } + } + if b.Len() == 0 { + return "" + } + return Clean(b.String()) +}