3 Commits

Author SHA1 Message Date
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
brent saner
8260e4fa93 v1.14.0
ADD:
* `paths.Len()`
* `paths.LenSys()`
* `paths.Segment()`
* `paths.SegmentSys()`
* `paths.Strip()`
* `paths.StripSys()`

FIX:
* Naming conflics between `<stdlib>/path` and the `path` parameters
2025-07-31 03:48:08 -04:00
brent saner
e5f7296d2e v1.13.3
ADDED:
* envs.GetEnvErr(), envs.GetEnvErrNoBlank(), envs.EnvErrNoVal
  This allows error-returned env vars for nonexistent/empty values.
2025-07-12 15:10:38 -04:00
18 changed files with 1147 additions and 133 deletions

View File

@@ -1,20 +1,20 @@
package envs
import (
`bytes`
`errors`
`fmt`
`io/ioutil`
`os`
`reflect`
`strings`
`sync`
"bytes"
"errors"
"fmt"
"io/ioutil"
"os"
"reflect"
"strings"
"sync"
`r00t2.io/goutils/multierr`
`r00t2.io/goutils/structutils`
`r00t2.io/sysutils/errs`
`r00t2.io/sysutils/internal`
`r00t2.io/sysutils/paths`
"r00t2.io/goutils/multierr"
"r00t2.io/goutils/structutils"
"r00t2.io/sysutils/errs"
"r00t2.io/sysutils/internal"
"r00t2.io/sysutils/paths"
)
/*
@@ -45,6 +45,54 @@ func DefEnvBlank(key, fallback string) (value string) {
return
}
// GetEnvErr returns the value of key if it exists. If it does not exist, err will be an EnvErrNoVal.
func GetEnvErr(key string) (value string, err error) {
var exists bool
if value, exists = os.LookupEnv(key); !exists {
err = &EnvErrNoVal{
VarName: key,
}
return
}
return
}
/*
GetEnvErrNoBlank behaves exactly like GetEnvErr with the
additional stipulation that the value must not be empty.
An error for a value that is non-empty but whitespace only (e.g. VARNM="\t")
can be returned if ignoreWhitespace == true.
(If it is, an EnvErrNoVal will also be returned.)
*/
func GetEnvErrNoBlank(key string, ignoreWhitespace bool) (value string, err error) {
var exists bool
var e *EnvErrNoVal = &EnvErrNoVal{
VarName: key,
WasRequiredNonEmpty: true,
IgnoreWhitespace: ignoreWhitespace,
}
if value, exists = os.LookupEnv(key); !exists {
err = e
return
} else {
e.WasFound = true
e.WasWhitespace = (strings.TrimSpace(value) == "") && (value != "")
if ignoreWhitespace && e.WasWhitespace {
err = e
return
}
}
return
}
// GetEnvMap returns a map of all environment variables. All values are strings.
func GetEnvMap() (envVars map[string]string) {

27
envs/funcs_enverrnoval.go Normal file
View File

@@ -0,0 +1,27 @@
package envs
import (
"strings"
)
// Error conforms to a stdlib error interface.
func (e *EnvErrNoVal) Error() (errStr string) {
var sb *strings.Builder = new(strings.Builder)
sb.WriteString("the variable '")
sb.WriteString(e.VarName)
sb.WriteString("' was ")
if e.WasFound {
sb.WriteString("found")
} else {
sb.WriteString("not found")
}
if e.WasRequiredNonEmpty && e.WasFound {
sb.WriteString(" but is empty and was required to be non-empty")
}
errStr = sb.String()
return
}

20
envs/types.go Normal file
View File

@@ -0,0 +1,20 @@
package envs
type (
/*
EnvErrNoVal is an error containing the variable that does not exist
(and information surrounding the errored state).
*/
EnvErrNoVal struct {
// VarName is the variable name/key name originally specified in the function call.
VarName string `json:"var" toml:"VariableName" yaml:"Variable Name/Key" xml:"key,attr"`
// WasFound is only used for GetEnvErrNoBlank(). It is true if the variable was found/populated.
WasFound bool `json:"found" toml:"Found" yaml:"Found" xml:"found,attr"`
// WasRequiredNonEmpty indicates that this error was returned in a context where a variable was required to be non-empty (e.g. via GetEnvErrNoBlank()) but was empty.
WasRequiredNonEmpty bool `json:"reqd_non_empty" toml:"RequiredNonEmpty" yaml:"Required Non-Empty" xml:"reqNonEmpty,attr"`
// IgnoreWhitespace is true if the value was found but its evaluation was done against a whitestripped version.
IgnoreWhitespace bool `json:"ignore_ws" toml:"IgnoreWhitespace" yaml:"Ignore Whitespace" xml:"ignoreWhitespace,attr"`
// WasWhitespace is true if the value was whitespace-only.
WasWhitespace bool `json:"was_ws" toml:"WasWhitespace" yaml:"Was Whitespace Only" xml:"wasWhitespace,attr"`
}
)

View File

@@ -1,10 +1,10 @@
package envs
import (
`strconv`
`strings`
"strconv"
"strings"
`r00t2.io/sysutils/internal`
"r00t2.io/sysutils/internal"
)
// envListToMap splits a []string of env var keypairs to a map.
@@ -35,7 +35,7 @@ func nativizeEnvMap(stringMap map[string]string) (envMap map[string]interface{})
var pathVar string = internal.GetPathEnvName()
var err error
envMap = make(map[string]interface{}, 0)
envMap = make(map[string]interface{})
for k, v := range stringMap {

10
go.mod
View File

@@ -1,16 +1,16 @@
module r00t2.io/sysutils
go 1.23.2
go 1.24.5
require (
github.com/davecgh/go-spew v1.1.1
github.com/djherbis/times v1.6.0
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
github.com/shirou/gopsutil/v4 v4.25.5
golang.org/x/sync v0.15.0
golang.org/x/sys v0.33.0
github.com/shirou/gopsutil/v4 v4.25.6
golang.org/x/sync v0.16.0
golang.org/x/sys v0.34.0
honnef.co/go/augeas v0.0.0-20161110001225-ca62e35ed6b8
r00t2.io/goutils v1.8.1
r00t2.io/goutils v1.9.2
)
require (

20
go.sum
View File

@@ -1,4 +1,3 @@
github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c=
@@ -12,15 +11,14 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 h1:PpXWgLPs+Fqr325bN2FD2ISlRRztXibcX6e8f5FR5Dc=
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/shirou/gopsutil/v4 v4.25.5 h1:rtd9piuSMGeU8g1RMXjZs9y9luK5BwtnG7dZaQUJAsc=
github.com/shirou/gopsutil/v4 v4.25.5/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c=
github.com/shirou/gopsutil/v4 v4.25.6 h1:kLysI2JsKorfaFPcYmcJqbzROzsBWEOAtw6A7dIfqXs=
github.com/shirou/gopsutil/v4 v4.25.6/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=
@@ -29,19 +27,17 @@ github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfj
github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/augeas v0.0.0-20161110001225-ca62e35ed6b8 h1:FW42yWB1sGClqswyHIB68wo0+oPrav1IuQ+Tdy8Qp8E=
honnef.co/go/augeas v0.0.0-20161110001225-ca62e35ed6b8/go.mod h1:44w9OfBSQ9l3o59rc2w3AnABtE44bmtNnRMNC7z+oKE=
r00t2.io/goutils v1.8.1 h1:TQcUycPKsYn0QI4uCqb56utmvu/vVSxlblBg98iXStg=
r00t2.io/goutils v1.8.1/go.mod h1:9ObJI9S71wDLTOahwoOPs19DhZVYrOh4LEHmQ8SW4Lk=
r00t2.io/sysutils v1.1.1/go.mod h1:Wlfi1rrJpoKBOjWiYM9rw2FaiZqraD6VpXyiHgoDo/o=
r00t2.io/goutils v1.9.2 h1:1rcDgJ3MorWVBmZSvLpbAUNC+J+ctRfJQq5Wliucjww=
r00t2.io/goutils v1.9.2/go.mod h1:76AxpXUeL10uFklxRB11kQsrtj2AKiNm8AwG1bNoBCA=

View File

@@ -4,6 +4,10 @@ import (
"io/fs"
)
const (
GenericSeparator rune = '/'
)
// Mostly just for reference.
const (
// ModeDir | ModeSymlink | ModeNamedPipe | ModeSocket | ModeDevice | ModeCharDevice | ModeIrregular

17
paths/consts_unix.go Normal file
View File

@@ -0,0 +1,17 @@
//go:build !windows
package paths
const (
/*
MaxSymlinkLevel is hardcoded into the kernel for macOS, BSDs and Linux. It's unlikely to change.
Thankfully, it's the same on all of them.
On all, it's defined as MAXSYMLINKS in the following headers:
macOS (no, macOS is not a BSD; no, it is not FreeBSD; yes, I *will* fight you on it and win): sys/param.h
BSDs: sys/sys/param.h
Linux: include/linux/namei.h
*/
MaxSymlinkLevel uint = 40
)

15
paths/consts_windows.go Normal file
View File

@@ -0,0 +1,15 @@
//go:build windows
package paths
const (
/*
MaxSymLinkLevel on Windows is weird; Microsoft calls them "reparse points".
And it changes on the Windows version you're on, but it's been 63 past Windows Server 2003/Windows XP.
They're *very* EOL, so I'm completely ignoring them.
https://learn.microsoft.com/en-us/windows/win32/fileio/symbolic-link-programming-consideration
*/
MaxSymlinkLevel uint = 63
)

View File

@@ -1,10 +1,12 @@
package paths
import (
`errors`
"errors"
"fmt"
)
var (
ErrMaxSymlinkLevel = fmt.Errorf("max symlink level met/exceeded")
ErrNilErrChan error = errors.New("an initialized error channel is required")
ErrNilMatchChan error = errors.New("an initialized matches channel is required")
ErrNilMismatchChan error = errors.New("an initialized mismatches channel is required")

View File

@@ -23,6 +23,7 @@ import (
"errors"
"fmt"
"io/fs"
"math"
"os"
"os/user"
"path"
@@ -42,7 +43,7 @@ import (
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(path *string) (err error) {
func ExpandHome(p *string) (err error) {
var unameSplit []string
var uname string
@@ -51,10 +52,10 @@ func ExpandHome(path *string) (err error) {
// Props to this guy.
// https://stackoverflow.com/a/43578461/733214
if len(*path) == 0 {
if len(*p) == 0 {
err = errors.New("empty path")
return
} else if (*path)[0] != '~' {
} else if (*p)[0] != '~' {
return
}
@@ -70,7 +71,7 @@ func ExpandHome(path *string) (err error) {
}
*/
// K but do it smarter.
unameSplit = strings.SplitN(*path, string(os.PathSeparator), 2)
unameSplit = strings.SplitN(*p, string(os.PathSeparator), 2)
if len(unameSplit) != 2 {
unameSplit = append(unameSplit, "")
}
@@ -86,7 +87,7 @@ func ExpandHome(path *string) (err error) {
}
}
*path = filepath.Join(u.HomeDir, unameSplit[1])
*p = filepath.Join(u.HomeDir, unameSplit[1])
return
}
@@ -108,9 +109,9 @@ potentially returning an inaccurate result.
This is a thin wrapper around GetFirstWithRef.
*/
func GetFirst(paths []string) (content []byte, isDir, ok bool) {
func GetFirst(p []string) (content []byte, isDir, ok bool) {
content, isDir, ok, _ = GetFirstWithRef(paths)
content, isDir, ok, _ = GetFirstWithRef(p)
return
}
@@ -119,25 +120,25 @@ func GetFirst(paths []string) (content []byte, isDir, ok bool) {
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 paths in which a path was found.
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(paths []string) (content []byte, isDir, ok bool, idx int) {
func GetFirstWithRef(p []string) (content []byte, isDir, ok bool, idx int) {
var locPaths []string
var exists bool
var stat os.FileInfo
var stat fs.FileInfo
var err error
idx = -1
// We have to be a little less cavalier about this.
if paths == nil {
if p == nil {
return
}
locPaths = make([]string, len(paths))
locPaths = paths[:] // Create an explicit copy so we don't modify paths.
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
@@ -160,6 +161,30 @@ func GetFirstWithRef(paths []string) (content []byte, isDir, ok bool, idx int) {
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.
@@ -167,11 +192,11 @@ 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(path string) (err error) {
func MakeDirIfNotExist(p string) (err error) {
var stat os.FileInfo
var stat fs.FileInfo
var exists bool
var locPath string = path
var locPath string = p
if exists, stat, err = RealPathExistsStat(&locPath); err != nil {
if !exists {
@@ -208,14 +233,18 @@ It is recommended to check err (if not nil) for an invalid path error. If this i
path syntax/string itself is not supported on the runtime OS. This can be done via:
if errors.Is(err, fs.ErrInvalid) {...}
*/
func RealPath(path *string) (err error) {
if err = ExpandHome(path); err != nil {
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 *path, err = filepath.Abs(*path); err != nil {
if *p, err = filepath.Abs(*p); err != nil {
return
}
@@ -225,25 +254,25 @@ func RealPath(path *string) (err error) {
/*
RealPathJoin combines RealPath with (path).Join.
If dst is nil, then rootPath will be updated with the new value.
If dst is nil, then p will be updated with the new value.
You probably don't want that.
*/
func RealPathJoin(rootPath, dst *string, subPaths ...string) (err error) {
func RealPathJoin(p, dst *string, subPaths ...string) (err error) {
var newPath string
var realDst *string
if err = RealPath(rootPath); err != nil {
if err = RealPath(p); err != nil {
return
}
if dst == nil {
realDst = rootPath
realDst = p
} else {
realDst = dst
}
newPath = path.Join(append([]string{*rootPath}, subPaths...)...)
newPath = path.Join(append([]string{*p}, subPaths...)...)
if err = RealPath(&newPath); err != nil {
return
}
@@ -259,22 +288,22 @@ 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(path, dst *string, subPaths ...string) (err error) {
func RealPathJoinSys(p, dst *string, subPaths ...string) (err error) {
var newPath string
var realDst *string
if err = RealPath(path); err != nil {
if err = RealPath(p); err != nil {
return
}
if dst == nil {
realDst = path
realDst = p
} else {
realDst = dst
}
newPath = filepath.Join(append([]string{*path}, subPaths...)...)
newPath = filepath.Join(append([]string{*p}, subPaths...)...)
if err = RealPath(&newPath); err != nil {
return
}
@@ -300,13 +329,13 @@ See also the documentation for RealPath.
In those cases, it may be preferable to use RealPathExistsStat and checking stat for nil.
*/
func RealPathExists(path *string) (exists bool, err error) {
func RealPathExists(p *string) (exists bool, err error) {
if err = RealPath(path); err != nil {
if err = RealPath(p); err != nil {
return
}
if _, err = os.Stat(*path); err != nil {
if _, err = os.Stat(*p); err != nil {
if errors.Is(err, fs.ErrNotExist) {
err = nil
}
@@ -319,22 +348,68 @@ func RealPathExists(path *string) (exists bool, err error) {
}
/*
RealPathExistsStat is like RealPathExists except it will also return the os.FileInfo
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(path *string) (exists bool, stat os.FileInfo, err error) {
func RealPathExistsStat(p *string) (exists bool, stat fs.FileInfo, err error) {
if exists, err = RealPathExists(path); err != nil {
if exists, err = RealPathExists(p); err != nil {
return
}
if stat, err = os.Stat(*path); err != nil {
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
}
@@ -498,6 +573,198 @@ func SearchFsPathsAsync(matcher FsSearchCriteriaAsync) {
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

18
pdsh/consts.go Normal file
View File

@@ -0,0 +1,18 @@
package pdsh
import (
"regexp"
"r00t2.io/goutils/remap"
)
const (
dshGrpPathEnv string = "DSHGROUP_PATH"
)
// DSH Groups
var (
dshGrpDefGrpDir string = "/etc/dsh/group"
dshGrpInclPtrn *remap.ReMap = &remap.ReMap{Regexp: regexp.MustCompile(`^\s*#include\s+(?P<incl>.+)$`)}
dshGrpSubTokenPtrn *remap.ReMap = &remap.ReMap{Regexp: regexp.MustCompile(`^(?P<start_pad>0*)(?P<start>[1-9]+[0-9]*)?(?:-(?P<end_pad>0*)(?P<end>[1-9]+[0-9]*))?$`)}
)

16
pdsh/docs.go Normal file
View File

@@ -0,0 +1,16 @@
/*
Package pdsh (!! WIP !!) provides PDSH-compatible functionality for parsing group files.
Note that this library will *only* source and parse PDSH-compatible host/group files,
it will not actually connect to anything.
It simply provides ways of returning lists of hosts using generation rules/patterns.
Currently, the only supported PDSH module is `misc/dshgroup` but additional/all other
host list modules are planned.
For details, see:
- https://github.com/chaos/pdsh/
- https://github.com/chaos/pdsh/blob/master/doc/pdsh.1.in
*/
package pdsh

10
pdsh/errs.go Normal file
View File

@@ -0,0 +1,10 @@
package pdsh
import (
"errors"
)
var (
ErrInvalidDshGrpSyntax error = errors.New("invalid dsh group file syntax")
ErrInvalidDshGrpPtrn error = errors.New("invalid dsh group pattern syntax")
)

View File

@@ -0,0 +1,174 @@
package pdsh
import (
"io/fs"
"os"
"path/filepath"
"strings"
"r00t2.io/sysutils/envs"
"r00t2.io/sysutils/paths"
)
/*
Evaluate returns a list of directories and files that would be searched/read with
the given call and DshGroupLister configuration, in order of parsing.
The behavior is the same as DshGroupLister.GroupedHosts, including searchPaths.
If DshGroupLister.ForceLegacy is false, include files will also be parsed in.
(This may incur slightly additional processing time.)
Only existing dirs/files are returned. Symlinks are evaluated to their target.
If dedupe is true, deduplication is performed. This adds some cycles, but may be desired if you make heavy use of symlinks.
*/
func (d *DshGroupLister) Evaluate(dedupe bool, searchPaths ...string) (dirs, files []string, err error) {
var exists bool
// var u *user.User
var spl []string
var dPath string
var fPath string
var incls []string
var de fs.DirEntry
var stat fs.FileInfo
var entries []fs.DirEntry
var tmpF []string
var fpathMap map[string]bool = make(map[string]bool)
// TODO: Does/how does pdsh resolve relative symlinks?
// Dirs first
if searchPaths != nil {
for _, dPath = range searchPaths {
if _, exists, _, _, stat, err = paths.RealPathExistsStatTarget(&dPath, "."); err != nil {
return
} else if !exists {
continue
}
if !stat.IsDir() {
continue
}
dirs = append(dirs, dPath)
}
}
if !d.NoHome && envs.HasEnv("HOME") {
// So pdsh actually checks $HOME, it doesn't pull the homedir for the user.
/*
if u, err = user.Current(); err != nil {
return
}
dPath = filepath.Join(u.HomeDir, ".dsh", "group")
*/
dPath = filepath.Join(os.Getenv("HOME"), ".dsh", "group")
if _, exists, _, _, stat, err = paths.RealPathExistsStatTarget(&dPath, "."); err != nil {
return
} else if exists {
if stat.IsDir() {
dirs = append(dirs, dPath)
}
}
}
if !d.NoEnv && envs.HasEnv(dshGrpPathEnv) {
spl = strings.Split(os.Getenv(dshGrpPathEnv), string(os.PathListSeparator))
for _, dPath = range spl {
if strings.TrimSpace(dPath) == "" {
continue
}
if _, exists, _, _, stat, err = paths.RealPathExistsStatTarget(&dPath, "."); err != nil {
return
} else if !exists {
continue
}
if !stat.IsDir() {
continue
}
dirs = append(dirs, dPath)
}
}
if !d.NoDefault && !envs.HasEnv(dshGrpPathEnv) {
dPath = dshGrpDefGrpDir
if _, exists, _, _, stat, err = paths.RealPathExistsStatTarget(&dPath, "."); err != nil {
return
} else if exists {
if stat.IsDir() {
dirs = append(dirs, dPath)
}
}
}
// Then files. Do *not* walk the dirs; only first-level is parsed by pdsh so this does the same.
for _, dPath = range dirs {
if entries, err = os.ReadDir(dPath); err != nil {
return
}
for _, de = range entries {
fPath = filepath.Join(dPath, de.Name())
// NORMALLY, os.Stat calls stat(2), which follows symlinks. (os.Lstat()/lstat(2) does not.)
// But the stat for an fs.DirEntry? Uses lstat.
// Whatever, we want to resolve symlinks anyways.
if _, exists, _, _, stat, err = paths.RealPathExistsStatTarget(&fPath, "."); err != nil {
return
} else if exists {
if !stat.Mode().IsRegular() {
continue
}
if dedupe {
if _, exists = fpathMap[fPath]; !exists {
fpathMap[fPath] = true
files = append(files, fPath)
}
} else {
files = append(files, fPath)
}
if !d.ForceLegacy {
if incls, err = getDshGrpIncludes(fPath); err != nil {
return
}
if dedupe {
for _, i := range incls {
if _, exists = fpathMap[i]; !exists {
fpathMap[i] = true
files = append(files, i)
}
}
} else {
files = append(files, incls...)
}
}
}
}
}
files = tmpF
return
}
/*
GroupedHosts returns a map of `map[<GROUP>][]string{<HOST>[, <HOST>, ...]}.
Additional search paths may be specified via searchpaths.
If there are any conflicting group names, the first found group name is used.
For example, assuming the group name `<GROUP>`, the following files will be checked in this order:
0. IF searchPaths is not nil:
a. searchpaths[0]/<GROUP>
b. searchpaths[1]/<GROUP>
c. searchpaths[2]/<GROUP>
d. ( ... )
1. IF DshGroupLister.NoHome is false:
a. `~/.dsh/group/<GROUP>`
2. IF $DSHGROUP_PATH is defined AND DshGroupLister.NoEnv is false:
a. `strings.Split(os.Getenv("DSHGROUP_PATH", string(os.PathListSeparator)))[0]/<GROUP>`
b. `strings.Split(os.Getenv("DSHGROUP_PATH", string(os.PathListSeparator)))[1]/<GROUP>`
c. `strings.Split(os.Getenv("DSHGROUP_PATH", string(os.PathListSeparator)))[2]/<GROUP>`
d. ( ... )
3. IF $DSHGROUP_PATH is NOT defined AND DshGroupLister.NoDefault is false:
a. `/etc/dsh/group/<GROUP>`
*/
func (d *DshGroupLister) GroupedHosts(dedupe bool, searchPaths ...string) (groupedHosts map[string][]string, err error) {
return
}

298
pdsh/funcs_dshgrp.go Normal file
View File

@@ -0,0 +1,298 @@
package pdsh
import (
"bufio"
"bytes"
"os"
"strconv"
"strings"
"r00t2.io/sysutils/paths"
)
/*
ParseDshPtrn parses ptrn using the DSH group pattern ptrn as according to `HOSTLIST EXPRESSSIONS` in pdsh(1).
`#include` directives are explicitly skipped; this only parses actual generation pattern strings.
*/
func ParseDshPtrn(ptrn string) (hostList []string, err error) {
var r rune
var pos int
var s string
var inToken bool
var tokStr string
var tok dshGrpToken
var strBuf *bytes.Buffer = new(bytes.Buffer)
var tokBuf *bytes.Buffer = new(bytes.Buffer)
var parser *dshGrpGenerator = &dshGrpGenerator{
tokens: make([]dshGrpToken, 0),
tokenized: make([]string, 0),
text: ptrn,
}
s = strings.TrimSpace(ptrn)
if s == "" {
return
}
if strings.HasPrefix(s, "#") {
return
}
// A quick sanity check. The end-state from the state machine below will catch any weird bracket issues beyond this.
if strings.Count(s, "[") != strings.Count(s, "]") {
err = ErrInvalidDshGrpSyntax
return
}
// Now the hacky bits. We read until we get to a start-token ('['), end-token (']'), or a pattern separator (',') that is *outside* a range token.
for pos, r = range s {
switch r {
case '[':
if inToken {
// Nested [...[
err = &PtrnParseErr{
pos: uint(pos),
ptrn: ptrn,
r: r,
err: ErrInvalidDshGrpSyntax,
}
return
}
parser.tokenized = append(parser.tokenized, strBuf.String())
strBuf.Reset()
inToken = true
case ']':
if !inToken {
// Nested ]...]
err = &PtrnParseErr{
pos: uint(pos),
ptrn: ptrn,
r: r,
err: ErrInvalidDshGrpSyntax,
}
return
}
tokStr = tokBuf.String()
if tok, err = parseDshGrpToken(tokStr); err != nil {
err = &PtrnParseErr{
pos: uint(pos),
ptrn: ptrn,
r: r,
err: err,
}
return
}
parser.tokens = append(parser.tokens, tok)
tokBuf.Reset()
inToken = false
default:
if inToken {
// If it isn't between '0' and '9', isn't '-', and isn't ','...
if !(0x30 <= r && r <= 0x39) && (r != 0x2d) && (r != 0x2c) {
// It's not a valid token. (The actual syntax is validated in parseDshGrpToken and parseDshGrpSubtoken)
err = &PtrnParseErr{
pos: uint(pos),
ptrn: ptrn,
r: r,
err: ErrInvalidDshGrpSyntax,
}
return
}
tokBuf.WriteRune(r)
} else {
if strings.TrimSpace(string(r)) == "" || r == '#' {
// Whitespace is "invalid" (treat it as the end of the pattern).
// Same for end-of-line octothorpes.
if tokBuf.Len() > 0 {
// This should never happen.
err = &PtrnParseErr{
pos: uint(pos),
ptrn: ptrn,
r: r,
err: ErrInvalidDshGrpSyntax,
}
return
}
if strBuf.Len() > 0 {
parser.tokenized = append(parser.tokenized, strBuf.String())
}
break
}
// Otherwise we just check for valid DNS chars.
if !(0x30 <= r && r <= 0x39) && // '0'-'9'
(r != 0x2d) && // '-'
(r != 0x2e) && // '.'
!(0x41 <= r && r <= 0x5a) && // 'A' through 'Z' (inclusive)
!(0x6a <= r && r <= 0x7a) { // 'a' through 'z' (inclusive)
err = &PtrnParseErr{
pos: uint(pos),
ptrn: ptrn,
r: r,
err: ErrInvalidDshGrpPtrn,
}
return
}
// (Probably) valid(-ish), so add it.
strBuf.WriteRune(r)
}
}
}
// If the token never closed, it's also invalid.
if inToken {
err = ErrInvalidDshGrpSyntax
return
}
return
}
// parseDshGrpToken parses a token string into a dshGrpToken.
func parseDshGrpToken(tokenStr string) (token dshGrpToken, err error) {
var s string
var st []string
var sub dshGrpSubtoken
s = strings.TrimSpace(tokenStr)
st = strings.Split(s, ",")
token = dshGrpToken{
token: tokenStr,
subtokens: make([]dshGrpSubtoken, 0, len(st)),
}
for _, s = range st {
if strings.TrimSpace(s) == "" {
continue
}
if sub, err = parseDshGrpSubtoken(s); err != nil {
return
}
token.subtokens = append(token.subtokens, sub)
}
return
}
// parseDshGrpSubtoken parses a subtoken string into a dshGrpSubtoken.
func parseDshGrpSubtoken(subTokenStr string) (subtoken dshGrpSubtoken, err error) {
var u64 uint64
var vals []string
var endPad string
var startPad string
var st dshGrpSubtoken
var matches map[string][]string
if matches = dshGrpSubTokenPtrn.MapString(subTokenStr, false, false, true); matches == nil || len(matches) == 0 {
err = ErrInvalidDshGrpPtrn
return
}
if vals = matches["start_pad"]; vals != nil && len(vals) == 1 {
startPad = vals[0]
}
/*
Due to a... particular quirk in the regex that I'm too tired to fix,
the start_pad may be e.g. "0" (or "00", etc.) and start may be "" if the range starts *at* 0
(or 00, 000, etc.).
*/
if vals = matches["start"]; vals != nil && len(vals) == 1 {
if u64, err = strconv.ParseUint(vals[0], 10, 64); err != nil {
return
}
st.start = uint(u64)
} else if startPad != "" {
// Yeah, regex bug. So we remove one 0 from startPad, and set st.start to 0.
st.start = 0 // This is implicit, though.
startPad = startPad[:len(startPad)-1]
}
if vals = matches["end_pad"]; vals != nil && len(vals) == 1 {
endPad = vals[0]
}
if vals = matches["end"]; vals != nil && len(vals) == 1 {
if u64, err = strconv.ParseUint(vals[0], 10, 64); err != nil {
return
}
st.end = uint(u64)
}
if startPad != "" && endPad != "" {
// We set the pad to the largest.
if len(startPad) > len(endPad) {
st.pad = startPad
} else {
st.pad = endPad
}
} else if startPad != "" {
st.pad = startPad
} else if endPad != "" {
st.pad = endPad
}
subtoken = st
return
}
/*
getDshGrpIncludes parses fpath for `#include ...` directives. It skips any entries in which
`len(paths.SegmentSys(p) == []string{p}`, as these are inherently included by the dir read.
It is assumed that fpath is a cleaned, absolute filepath.
*/
func getDshGrpIncludes(fpath string) (includes []string, err error) {
var f *os.File
var line string
var exists bool
var inclpath string
var subIncl []string
var segs []string
var scanner *bufio.Scanner
var matches map[string][]string
if f, err = os.Open(fpath); err != nil {
return
}
defer f.Close()
scanner = bufio.NewScanner(f)
for scanner.Scan() {
line = strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
if !dshGrpInclPtrn.MatchString(line) {
continue
}
matches = dshGrpInclPtrn.MapString(line, false, false, true)
if matches == nil {
err = ErrInvalidDshGrpSyntax
return
}
if matches["incl"] == nil || len(matches["incl"]) == 0 {
err = ErrInvalidDshGrpSyntax
return
}
inclpath = matches["incl"][0]
segs = paths.SegmentSys(inclpath, false, false)
if segs == nil || len(segs) == 0 || (len(segs) == 1 && segs[0] == inclpath) {
continue
}
if exists, err = paths.RealPathExists(&inclpath); err != nil {
return
}
if !exists {
continue
}
includes = append(includes, inclpath)
if subIncl, err = getDshGrpIncludes(inclpath); err != nil {
return
}
if subIncl != nil && len(subIncl) > 0 {
includes = append(includes, subIncl...)
}
}
return
}

View File

@@ -0,0 +1,16 @@
package pdsh
import (
"fmt"
)
// Error conforms a PtrnParseErr to error interface.
func (p *PtrnParseErr) Error() (errStr string) {
errStr = fmt.Sprintf(
"Parse error in pattern '%s', position %d rune '%s': %v",
p.ptrn, p.pos, string(p.r), p.err,
)
return
}

86
pdsh/types.go Normal file
View File

@@ -0,0 +1,86 @@
package pdsh
// TODO: This... doesn't really have much usefulness, does it?
/*
type (
HostLister interface {
// Hosts returns ALL hsots (where applicable) that are considered/generated for a Lister.
Hosts() (hosts []string, err error)
}
)
*/
type (
/*
DshGroupLister behaves like the host list generator
for pdsh(1)'s "dshgroup module options" (the `misc/dshgroup`
module for pdsh).
*/
DshGroupLister struct {
/*
NoEnv, if true, will *not* use DSHGROUP_PATH (force-defaulting to /etc/dsh/group/,
but see NoDefault).
*/
NoEnv bool
/*
NoDefault, if true, will *not* add the default path `/etc/dsh/group/`
to the search paths.
If NoDefault is false, this path is only added if DSHGROUP_PATH is not defined
(or, if it IS defined, if NoEnv is true).
*/
NoDefault bool
// NoHome, if true, will *not* add the `~/.dsh/group/` path to the search paths.
NoHome bool
/*
ForceLegacy, if true, will disable the PDSH `#include <PATH|GROUP>` modification --
treating the source as a traditional DSH group file instead (e.g. `#include ...`
is treated as just a comment).
*/
ForceLegacy bool
}
)
type (
dshGrpGenerator struct {
/*
tokens are interleaved with tokenized and indexed *after*;
in other words, str = <substr0><token0><substr1><token1>...
*/
tokens []dshGrpToken
// tokenized holds the split original text with tokens removed and split where the tokens occur.
tokenized []string
// text holds the original pattern.
text string
}
dshGrpToken struct {
/*
token contains the original range specifier.
Tokens may be e.g.:
* 3: str3
* 3-5: str3, str4, str5
* 3,5: str3, str5
*/
token string
// subtokens hold a split of the individual range specifiers.
subtokens []dshGrpSubtoken
}
dshGrpSubtoken struct {
// start indicates either the single value or the start of the range.
start uint
// end, if 0 or less than start, indicates a single-value range.
end uint
// pad, if non-empty, is a string to add to the beginning of each of the generated substrings for this subtoken.
pad string
}
)
type (
PtrnParseErr struct {
pos uint
ptrn string
r rune
err error
}
)