go_sysutils/paths/funcs.go
brent saner e797a14911
v1.14.1
FIXED:
* `envs/funcs.go:78:3: unknown field IgnoreWhiteSpace in struct literal of type EnvErrNoVal, but does have IgnoreWhitespace`
* `envs/funcs_enverrnoval.go:15:8: sb.WasFound undefined (type *strings.Builder has no field or method WasFound)`
2025-08-13 14:54:49 -04:00

840 lines
19 KiB
Go

/*
SysUtils - a library to assist with various system-related functions
Copyright (C) 2020 Brent Saner
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package paths
import (
"context"
"errors"
"fmt"
"io/fs"
"math"
"os"
"os/user"
"path"
"path/filepath"
"sort"
"strings"
"sync"
"time"
// "syscall"
"github.com/djherbis/times"
"r00t2.io/goutils/bitmask"
)
/*
ExpandHome will take a tilde(~)-prefixed path and resolve it to the actual path in-place.
"Nested" user paths (~someuser/somechroot/~someotheruser) are not supported as home directories are expected to be absolute paths.
*/
func ExpandHome(p *string) (err error) {
var unameSplit []string
var uname string
var u *user.User
// Props to this guy.
// https://stackoverflow.com/a/43578461/733214
if len(*p) == 0 {
err = errors.New("empty path")
return
} else if (*p)[0] != '~' {
return
}
// E(ffective)UID (e.g. chown'd user for SUID)
/*
uid := strconv.Itoa(syscall.Geteuid())
u, err := user.LookupId(euid)
*/
// (Real)UID (invoking user)
/*
if u, err = user.Current(); err != nil {
return
}
*/
// K but do it smarter.
unameSplit = strings.SplitN(*p, string(os.PathSeparator), 2)
if len(unameSplit) != 2 {
unameSplit = append(unameSplit, "")
}
uname = strings.TrimPrefix(unameSplit[0], "~")
if uname == "" {
if u, err = user.Current(); err != nil {
return
}
} else {
if u, err = user.Lookup(uname); err != nil {
return
}
}
*p = filepath.Join(u.HomeDir, unameSplit[1])
return
}
/*
GetFirst is the file equivalent of envs.GetFirst.
It iterates through paths, normalizing them along the way
(so abstracted paths such as ~/foo/bar.txt and relative paths
such as bar/baz.txt will still work), and returns the content
of the first found existing file. If the first found path
is a directory, content will be nil but isDir will be true
(as will ok).
If no path exists, ok will be false.
As always, results are not guaranteed due to permissions, etc.
potentially returning an inaccurate result.
This is a thin wrapper around GetFirstWithRef.
*/
func GetFirst(p []string) (content []byte, isDir, ok bool) {
content, isDir, ok, _ = GetFirstWithRef(p)
return
}
/*
GetFirstWithRef is the file equivalent of envs.GetFirstWithRef.
It behaves exactly like GetFirst, but with an additional returned value, idx,
which specifies the index in p in which a path was found.
As always, results are not guaranteed due to permissions, etc.
potentially returning an inaccurate result.
*/
func GetFirstWithRef(p []string) (content []byte, isDir, ok bool, idx int) {
var locPaths []string
var exists bool
var stat fs.FileInfo
var err error
idx = -1
// We have to be a little less cavalier about this.
if p == nil {
return
}
locPaths = make([]string, len(p))
locPaths = p[:] // Create an explicit copy so we don't modify p.
for i, p := range locPaths {
if exists, stat, err = RealPathExistsStat(&p); err != nil {
err = nil
continue
}
if !exists {
continue
}
isDir = stat.IsDir()
if !isDir {
if content, err = os.ReadFile(p); err != nil {
continue
}
}
ok = true
idx = i
return
}
return
}
/*
Len returns the number of path segments in p, as split with the same param signature to Segment.
See Segment for details on abs and strict.
*/
func Len(p string, abs, strict bool) (segments int) {
segments = len(Segment(p, abs, strict))
return
}
/*
LenSys returns the number of path segments in p, as split with the same param signature to SegmentSys.
See Segment for details on abs and strict.
*/
func LenSys(p string, abs, strict bool) (segments int) {
segments = len(SegmentSys(p, abs, strict))
return
}
/*
MakeDirIfNotExist will create a directory at a given path if it doesn't exist.
See also the documentation for RealPath.
This is a bit more sane option than os.MkdirAll as it will normalize paths a little better.
*/
func MakeDirIfNotExist(p string) (err error) {
var stat fs.FileInfo
var exists bool
var locPath string = p
if exists, stat, err = RealPathExistsStat(&locPath); err != nil {
if !exists {
// This, at least as of golang 1.15, uses the user's umask.
// It does not actually create a dir with 0777.
// It's up to the caller to do an os.Chmod() on the path after, if desired.
if err = os.MkdirAll(locPath, 0777); err != nil {
return
}
err = nil
return
} else {
return
}
}
// So it exists, but it probably isn't a dir.
if !stat.Mode().IsDir() {
err = errors.New(fmt.Sprintf("path %v exists but is not a directory", locPath))
return
} else {
return
}
// This should probably never happen. Probably.
err = errors.New("undefined")
return
}
/*
RealPath will transform a given path into the very best guess for an absolute path in-place.
It is recommended to check err (if not nil) for an invalid path error. If this is true, the
path syntax/string itself is not supported on the runtime OS. This can be done via:
if errors.Is(err, fs.ErrInvalid) {...}
RealPath is simply a wrapper around ExpandHome(path) and filepath.Abs(*path).
Note that RealPath does *not* resolve symlinks. Only RealPathExistsStatTarget does that.
*/
func RealPath(p *string) (err error) {
if err = ExpandHome(p); err != nil {
return
}
if *p, err = filepath.Abs(*p); err != nil {
return
}
return
}
/*
RealPathJoin combines RealPath with (path).Join.
If dst is nil, then p will be updated with the new value.
You probably don't want that.
*/
func RealPathJoin(p, dst *string, subPaths ...string) (err error) {
var newPath string
var realDst *string
if err = RealPath(p); err != nil {
return
}
if dst == nil {
realDst = p
} else {
realDst = dst
}
newPath = path.Join(append([]string{*p}, subPaths...)...)
if err = RealPath(&newPath); err != nil {
return
}
*realDst = newPath
return
}
/*
RealPathJoinSys combines RealPath with (path/filepath).Join.
If dst is nil, then path will be updated with the new value.
You probably don't want that.
*/
func RealPathJoinSys(p, dst *string, subPaths ...string) (err error) {
var newPath string
var realDst *string
if err = RealPath(p); err != nil {
return
}
if dst == nil {
realDst = p
} else {
realDst = dst
}
newPath = filepath.Join(append([]string{*p}, subPaths...)...)
if err = RealPath(&newPath); err != nil {
return
}
*realDst = newPath
return
}
/*
RealPathExists is like RealPath, but will also return a boolean as to whether the path
actually exists or not.
Note that err *may* be os.ErrPermission/fs.ErrPermission, in which case the exists value
cannot be trusted as a permission error occurred when trying to stat the path - if the
calling user/process does not have read permission on e.g. a parent directory, then
exists may be false but the path may actually exist. This condition can be checked via
via:
if errors.Is(err, fs.ErrPermission) {...}
See also the documentation for RealPath.
In those cases, it may be preferable to use RealPathExistsStat and checking stat for nil.
*/
func RealPathExists(p *string) (exists bool, err error) {
if err = RealPath(p); err != nil {
return
}
if _, err = os.Stat(*p); err != nil {
if errors.Is(err, fs.ErrNotExist) {
err = nil
}
return
}
exists = true
return
}
/*
RealPathExistsStat is like RealPathExists except it will also return the fs.FileInfo
for the path (assuming it exists).
If stat is nil, it is highly recommended to check err via the methods suggested
in the documentation for RealPath and RealPathExists.
*/
func RealPathExistsStat(p *string) (exists bool, stat fs.FileInfo, err error) {
if exists, err = RealPathExists(p); err != nil {
return
}
if !exists {
return
}
if stat, err = os.Stat(*p); err != nil {
return
}
return
}
/*
RealPathExistsStatTarget is the only "RealPather" that will resolve p to the (final) *target* of p if p is a symlink.
If p is not a symlink but does exist, the tgt* will reflect the same as p*.
See WalkLink for details on relRoot and other assorted rules/logic (RealPathExistsStatTarget wraps WalkLink).
*/
func RealPathExistsStatTarget(p *string, relRoot string) (pExists, tgtExists, wasLink bool, pStat fs.FileInfo, tgtStat fs.FileInfo, err error) {
var tgts []string
if pExists, err = RealPathExists(p); err != nil {
return
}
tgtExists = pExists
if !pExists {
return
}
// Can't use RealPathExistsStat because it calls os.Stat, not os.Lstat... thus defeating the purpose.
if pStat, err = os.Lstat(*p); err != nil {
return
}
tgtStat = pStat
wasLink = pStat.Mode().Type()&fs.ModeSymlink == fs.ModeSymlink
if wasLink {
if tgts, err = WalkLink(*p, relRoot); err != nil || tgts == nil || len(tgts) == 0 {
tgtExists = false
tgtStat = nil
return
}
if tgtExists, tgtStat, err = RealPathExistsStat(&tgts[len(tgts)-1]); err != nil {
return
}
*p = tgts[len(tgts)-1]
}
return
}
// SearchFsPaths gets a file/directory/etc. path list based on the provided criteria.
func SearchFsPaths(matcher FsSearchCriteria) (found, miss []*FsSearchResult, err error) {
var matched *FsSearchResult
var missed *FsSearchResult
if err = RealPath(&matcher.Root); err != nil {
return
}
if err = filepath.WalkDir(
matcher.Root,
func(path string, d fs.DirEntry, inErr error) (outErr error) {
if inErr != nil {
outErr = inErr
return
}
if matched, missed, outErr = matcher.Match(path, d, nil); outErr != nil {
return
}
if matched != nil && !matcher.NoMatch {
found = append(found, matched)
}
if missed != nil && !matcher.NoMismatch {
miss = append(miss, missed)
}
return
},
); err != nil {
return
}
if found == nil || len(found) == 0 {
return
}
// And sort them.
sort.Slice(
found,
func(i, j int) (isLess bool) {
isLess = found[i].Path < found[j].Path
return
},
)
return
}
/*
SearchFsPathsAsync is exactly like SearchFsPaths, but dispatches off concurrent
workers for the filtering logic instead of performing iteratively/recursively.
It may, in some cases, be *slightly more* performant and *slightly less* in others.
Note that unlike SearchFsPaths, the results written to the
FsSearchCriteriaAsync.ResChan are not guaranteed to be in any predictable order.
All channels are expected to have already been initialized by the caller.
They will not be closed by this function.
*/
func SearchFsPathsAsync(matcher FsSearchCriteriaAsync) {
var err error
var wgLocal sync.WaitGroup
var doneChan chan bool = make(chan bool, 1)
if matcher.ErrChan == nil {
panic(ErrNilErrChan)
return
}
if matcher.WG == nil {
matcher.ErrChan <- ErrNilWg
return
}
defer matcher.WG.Done()
if matcher.ResChan == nil && !matcher.NoMatch {
matcher.ErrChan <- ErrNilMatchChan
return
}
if matcher.MismatchChan == nil && !matcher.NoMismatch {
matcher.ErrChan <- ErrNilMismatchChan
return
}
if err = RealPath(&matcher.Root); err != nil {
matcher.ErrChan <- err
return
}
if matcher.Semaphore != nil && matcher.SemaphoreCtx == nil {
matcher.SemaphoreCtx = context.Background()
}
if err = filepath.WalkDir(
matcher.Root,
func(path string, de fs.DirEntry, inErr error) (outErr error) {
if inErr != nil {
inErr = filterNoFileDir(inErr)
if inErr != nil {
outErr = inErr
return
}
}
wgLocal.Add(1)
if matcher.Semaphore != nil {
if err = matcher.Semaphore.Acquire(matcher.SemaphoreCtx, 1); err != nil {
return
}
}
go func(p string, d fs.DirEntry) {
var pErr error
var pResMatch *FsSearchResult
var pResMiss *FsSearchResult
defer wgLocal.Done()
if matcher.Semaphore != nil {
defer matcher.Semaphore.Release(1)
}
if pResMatch, pResMiss, pErr = matcher.Match(p, d, nil); pErr != nil {
matcher.ErrChan <- pErr
return
}
if pResMatch != nil && !matcher.NoMatch {
matcher.ResChan <- pResMatch
}
if pResMiss != nil && !matcher.NoMismatch {
matcher.MismatchChan <- pResMiss
}
}(path, de)
return
},
); err != nil {
err = filterNoFileDir(err)
if err != nil {
matcher.ErrChan <- err
return
}
}
go func() {
wgLocal.Wait()
doneChan <- true
}()
<-doneChan
return
}
/*
Segment returns path p's segments as a slice of strings, using GenericSeparator as a separator.
If abs is true, the placeholder leading prefix(es) (if any) of GenericSeparator will be kept in-place;
otherwise it/they will be trimmed out.
e.g.:
abs == true: //foo/bar/baz => []string{"", "", "foo", "bar", "baz"}
abs == false: /foo/bar/baz => []string{"foo", "bar", "baz"}
If strict is true, any trailing GenericSeparator will be kept in-place;
otherwise they will be trimmed out.
e.g. (assuming abs == false):
strict == true: /foo/bar/baz// => []string{"foo", "bar", "baz", "", ""}
strict == false: /foo/bar/baz/ => []string{"foo", "bar", "baz"}
It is recommended to call RealPath for path's ptr first for normalization.
*/
func Segment(p string, abs, strict bool) (segments []string) {
if !abs {
p = strings.TrimLeft(p, string(GenericSeparator))
}
if !strict {
p = strings.TrimRight(p, string(GenericSeparator))
}
segments = strings.Split(p, string(GenericSeparator))
return
}
// SegmentSys is exactly like Segment, except using os.PathSeparator instead of GenericSeparator.
func SegmentSys(p string, abs, strict bool) (segments []string) {
if !abs {
p = strings.TrimLeft(p, string(os.PathSeparator))
}
if !strict {
p = strings.TrimRight(p, string(os.PathSeparator))
}
segments = strings.Split(p, string(os.PathSeparator))
return
}
/*
Strip is like Segment but trims out the leading n number of segments and reassembles the path using path.Join.
n may be negative, in which case the *trailing* n number of segments will be trimmed out.
(i.e. n == -1, p == `foo/bar/baz/quux` would be `foo/bar/baz`, not `bar/baz/quux`)
If you require more traditional slicing (e.g. with interval),
you may want to use path.Join with a sliced result of Segment instead.
e.g.: *only* the *last* n segments: path.Join(Segment(p, ...)[Len(p, ...)-n:]...)
If n == 0 or int(math.Abs(float64(n))) >= len(Segment(p, ...)), no transformation will be done.
e.g.
n == 2: foo/bar/baz/foobar/quux => baz/foobar/quux
n == -2: foo/bar/baz/foobar/quux => foo/bar/baz
*/
func Strip(p string, abs, strict bool, n int) (slicedPath string) {
var pLen int
var absN int
var segments []string
segments = Segment(p, abs, strict)
pLen = len(segments)
absN = int(math.Abs(float64(n)))
if n == 0 || absN >= pLen {
slicedPath = p
return
}
if n > 0 {
segments = segments[n:]
} else {
segments = segments[:pLen-absN]
}
slicedPath = path.Join(segments...)
return
}
// StripSys is exactly like Strip but using (path/filepath).Join and SegmentSys.
func StripSys(p string, abs, strict bool, n int) (slicedPath string) {
var pLen int
var absN int
var segments []string
segments = SegmentSys(p, abs, strict)
pLen = len(segments)
absN = int(math.Abs(float64(n)))
if n == 0 || absN >= pLen {
slicedPath = p
return
}
if n > 0 {
segments = segments[n:]
} else {
segments = segments[:pLen-absN]
}
slicedPath = filepath.Join(segments...)
return
}
/*
WalkLink walks the recursive target(s) of lnk (unless/until MaxSymlinkLevel is hit, which will trigger ErrMaxSymlinkLevel)
until it reaches a real (non-symlink) target.
lnk will have RealPath called on it first.
If lnk is not a symlink, then tgts == []string{lnk} and err = nil.
A broken link will return fs.ErrNotExist, with tgts containing the targets up to and including the path that triggered the error.
If lnk itself does not exist, tgts will be nil and err will be that of fs.ErrNotExist.
relRoot is a root directory to resolve relative links to. If empty, relative link target `t` from link `l` will be treated
as relative to `(path/filepath).Dir(l)` (that is to say, `t = filepath.Join(filepath.Dir(l), os.Readlink(l))`).
*/
func WalkLink(lnk, relRoot string) (tgts []string, err error) {
var exists bool
var curDepth uint
var stat fs.FileInfo
var curTgt string
var prevTgt string
if exists, err = RealPathExists(&lnk); err != nil {
return
} else if !exists {
err = fs.ErrNotExist
return
}
if relRoot != "" {
if err = RealPath(&relRoot); err != nil {
return
}
}
tgts = []string{}
curTgt = lnk
for curDepth = 0; curDepth < MaxSymlinkLevel; curDepth++ {
if exists, err = RealPathExists(&curTgt); err != nil {
return
}
prevTgt = curTgt
tgts = append(tgts, curTgt)
if !exists {
err = fs.ErrNotExist
return
}
if stat, err = os.Lstat(curTgt); err != nil {
return
}
if stat.Mode().Type()&os.ModeSymlink != os.ModeSymlink {
break
}
if curTgt, err = os.Readlink(curTgt); err != nil {
return
}
if !filepath.IsAbs(curTgt) {
if relRoot != "" {
curTgt = filepath.Join(relRoot, curTgt)
} else {
curTgt = filepath.Join(filepath.Dir(prevTgt), curTgt)
}
}
}
if curDepth >= MaxSymlinkLevel {
err = ErrMaxSymlinkLevel
return
}
return
}
/*
filterTimes checks a times.Timespec of a file using:
- an age specified by the caller
- an ageType bitmask for types of times to compare
- an olderThan bool (if false, the file must be younger than)
- an optional "now" timestamp for the age derivation.
*/
func filterTimes(tspec times.Timespec, age *time.Duration, ageType *pathTimeType, olderThan bool, now *time.Time) (include bool) {
var curAge time.Duration
var mask *bitmask.MaskBit
var tfunc func(t *time.Duration) (match bool) = func(t *time.Duration) (match bool) {
if olderThan {
match = *t > *age
} else {
match = *t < *age
}
return
}
if tspec == nil || age == nil || ageType == nil {
return
}
mask = ageType.Mask()
if now == nil {
now = new(time.Time)
*now = time.Now()
}
// BTIME (if supported)
if tspec.HasBirthTime() && (mask.HasFlag(bitmask.MaskBit(TimeAny)) || mask.HasFlag(bitmask.MaskBit(TimeCreated))) {
curAge = now.Sub(tspec.BirthTime())
if include = tfunc(&curAge); include {
return
}
}
// MTIME
if mask.HasFlag(bitmask.MaskBit(TimeAny)) || mask.HasFlag(bitmask.MaskBit(TimeModified)) {
curAge = now.Sub(tspec.ModTime())
if include = tfunc(&curAge); include {
return
}
}
// CTIME (if supported)
if tspec.HasChangeTime() && (mask.HasFlag(bitmask.MaskBit(TimeAny)) || mask.HasFlag(bitmask.MaskBit(TimeChanged))) {
curAge = now.Sub(tspec.ChangeTime())
if include = tfunc(&curAge); include {
return
}
}
// ATIME
if mask.HasFlag(bitmask.MaskBit(TimeAny)) || mask.HasFlag(bitmask.MaskBit(TimeAccessed)) {
curAge = now.Sub(tspec.AccessTime())
if include = tfunc(&curAge); include {
return
}
}
return
}
func filterNoFileDir(err error) (filtered error) {
filtered = err
if errors.Is(err, fs.ErrNotExist) {
filtered = nil
}
return
}