diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..30bcfa4 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.build/ diff --git a/AGENTS.md b/AGENTS.md index 1aaa2e8..6d49371 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,6 +31,7 @@ The Definition of Done defines the minimum quality bar for every completed chang - Every code change is covered by tests where applicable. - New functionality includes new tests. - Bug fixes include at least one regression test. +- Minimum code coverage is 80% (statements, measured with `go test -cover`). 1. Functional documentation - Implemented functionality is documented. @@ -57,6 +58,7 @@ The Definition of Done defines the minimum quality bar for every completed chang ### Review Checklist (Quick) - [ ] Change is implemented and meets acceptance criteria. - [ ] Tests were added/updated and pass. +- [ ] Code coverage is at least 80%. - [ ] Functionality is documented. - [ ] Documentation is in English. - [ ] Documentation is located under `docs/` (except `README.md` and `AGENTS.md`). diff --git a/README.md b/README.md index 2142931..7077afc 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,57 @@ [![Build Status](https://drone.yoorie.de/api/badges/go-lib/util/status.svg)](https://drone.yoorie.de/go-lib/util) -## Documentation +## Project Description -Is missed so far and will be created soon. +This repository provides a small, cross-platform utility package for Go projects. +It focuses on common helpers that are often reimplemented in multiple services, +such as file checks, safe path joining for URL-like strings, and OS-specific +configuration directory handling. + +The package is intentionally lightweight and easy to reuse in CLI tools, +daemons, and backend services. + +## Included Utilities + +- `FileExists(fileName string) bool` + - Returns whether a file exists on disk. +- `JoiningSlash(elem ...string) string` + - Joins path segments with exactly one slash between elements. +- `GetGlobalConfigurationDirectory(appname string) string` + - Returns an operating-system-specific global configuration directory. + - Linux and macOS: `/etc/` + - Windows: `%APPDATA%\\` +- `GetGlobalConfigurationFile(appname string, file string) string` + - Builds a full path to a config file inside the global config directory. +- `IsSuperUser() bool` + - Detects whether the current process runs with elevated privileges. + +## Installation + +```bash +go get scm.yoorie.de/go-lib/util +``` + +## Example + +```go +package main + +import ( + "fmt" + + "scm.yoorie.de/go-lib/util" +) + +func main() { + if util.FileExists("config.yaml") { + fmt.Println("config file found") + } + + fmt.Println(util.JoiningSlash("/api", "v1", "users")) + fmt.Println(util.GetGlobalConfigurationFile("myapp", "config.yaml")) +} +``` --- Copyright © 2023 yoorie.de diff --git a/coverage b/coverage new file mode 100644 index 0000000..5f02b11 --- /dev/null +++ b/coverage @@ -0,0 +1 @@ +mode: set diff --git a/docs/DEFINITION_OF_DONE.md b/docs/DEFINITION_OF_DONE.md index c254e82..9d85002 100644 --- a/docs/DEFINITION_OF_DONE.md +++ b/docs/DEFINITION_OF_DONE.md @@ -10,6 +10,7 @@ This Definition of Done defines the minimum quality bar for every completed chan - Every code change is covered by tests where applicable. - New functionality includes new tests. - Bug fixes include at least one regression test. +- Minimum code coverage is 80% (statements, measured with `go test -cover`). 1. Functional documentation - Implemented functionality is documented. @@ -38,6 +39,7 @@ This Definition of Done defines the minimum quality bar for every completed chan - [ ] Change is implemented and meets acceptance criteria. - [ ] Tests were added/updated and pass. +- [ ] Code coverage is at least 80%. - [ ] Functionality is documented. - [ ] Documentation is in English. - [ ] Documentation is located under `docs/` (except `README.md` and `AGENTS.md`). diff --git a/go.mod b/go.mod index 1b98bc3..0285bb6 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,6 @@ module scm.yoorie.de/go-lib/util go 1.16 require ( - github.com/stretchr/testify v1.8.2 + github.com/smartystreets/goconvey v1.6.4 golang.org/x/sys v0.6.0 ) diff --git a/go.sum b/go.sum index 3b079bb..7d950f9 100644 --- a/go.sum +++ b/go.sum @@ -1,19 +1,15 @@ -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= -github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s= +github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= diff --git a/os_windows.go b/os_windows.go index ea7e3ce..677ecdf 100644 --- a/os_windows.go +++ b/os_windows.go @@ -11,6 +11,13 @@ import ( "golang.org/x/sys/windows" ) +var ( + allocateAndInitializeSid = windows.AllocateAndInitializeSid + freeSid = windows.FreeSid + tokenIsMember = func(token windows.Token, sid *windows.SID) (bool, error) { return token.IsMember(sid) } + fatalf = log.Fatalf +) + // IsSuperUser returns true, if the current user is a super user // A.K.A root, Administrator etc func IsSuperUser() bool { @@ -20,7 +27,7 @@ func IsSuperUser() bool { // official windows documentation. The Go API for this is a // direct wrap around the official C++ API. // See https://docs.microsoft.com/en-us/windows/desktop/api/securitybaseapi/nf-securitybaseapi-checktokenmembership - err := windows.AllocateAndInitializeSid( + err := allocateAndInitializeSid( &windows.SECURITY_NT_AUTHORITY, 2, windows.SECURITY_BUILTIN_DOMAIN_RID, @@ -28,19 +35,19 @@ func IsSuperUser() bool { 0, 0, 0, 0, 0, 0, &sid) if err != nil { - log.Fatalf("SID Error: %s", err) + fatalf("SID Error: %s", err) return false } - defer windows.FreeSid(sid) + defer freeSid(sid) // This appears to cast a null pointer so I'm not sure why this // works, but this guy says it does and it Works for Me™: // https://github.com/golang/go/issues/28804#issuecomment-438838144 token := windows.Token(0) - member, err := token.IsMember(sid) + member, err := tokenIsMember(token, sid) if err != nil { - log.Fatalf("Token Membership Error: %s", err) + fatalf("Token Membership Error: %s", err) return false } diff --git a/os_windows_test.go b/os_windows_test.go new file mode 100644 index 0000000..f33a65a --- /dev/null +++ b/os_windows_test.go @@ -0,0 +1,47 @@ +//go:build windows +// +build windows + +package util + +import ( + "errors" + "testing" + + . "github.com/smartystreets/goconvey/convey" + "golang.org/x/sys/windows" +) + +func TestIsSuperUser_SidAllocationError(t *testing.T) { + Convey("IsSuperUser should return false when SID allocation fails", t, func() { + origAllocate := allocateAndInitializeSid + origFatalf := fatalf + defer func() { + allocateAndInitializeSid = origAllocate + fatalf = origFatalf + }() + + allocateAndInitializeSid = func( + authority *windows.SidIdentifierAuthority, + subAuthorityCount byte, + subAuthority0 uint32, + subAuthority1 uint32, + subAuthority2 uint32, + subAuthority3 uint32, + subAuthority4 uint32, + subAuthority5 uint32, + subAuthority6 uint32, + subAuthority7 uint32, + sid **windows.SID, + ) error { + return errors.New("forced sid allocation error") + } + + fatalCalled := false + fatalf = func(format string, v ...interface{}) { + fatalCalled = true + } + + So(IsSuperUser(), ShouldBeFalse) + So(fatalCalled, ShouldBeTrue) + }) +} diff --git a/utils_test.go b/utils_test.go index 05960f3..1ba39da 100644 --- a/utils_test.go +++ b/utils_test.go @@ -2,97 +2,60 @@ package util import ( "os" - "os/user" "path/filepath" - "runtime" - "strings" "testing" - "github.com/stretchr/testify/assert" + . "github.com/smartystreets/goconvey/convey" ) -func TestFileExist(t *testing.T) { - assert.True(t, FileExists("utils.go")) +func TestFileExists(t *testing.T) { + Convey("FileExists should report existing and missing files", t, func() { + tmpDir := t.TempDir() + tmpFile := filepath.Join(tmpDir, "exists.txt") + + err := os.WriteFile(tmpFile, []byte("ok"), 0o600) + So(err, ShouldBeNil) + + So(FileExists(tmpFile), ShouldBeTrue) + So(FileExists(filepath.Join(tmpDir, "missing.txt")), ShouldBeFalse) + }) } -func TestFileExistNot(t *testing.T) { - assert.True(t, !FileExists("Utils2.go")) +func TestJoiningSlash(t *testing.T) { + Convey("JoiningSlash should combine URL-like segments safely", t, func() { + So(JoiningSlash("http://my.tld/docs/", "bla/", "blub/"), ShouldEqual, "http://my.tld/docs/bla/blub/") + So(JoiningSlash("http://my.tld", "bla", "blub"), ShouldEqual, "http://my.tld/bla/blub") + So(JoiningSlash("http://my.tld/", "bla", "blub"), ShouldEqual, "http://my.tld/bla/blub") + So(JoiningSlash("http://my.tld", "bla/", "blub"), ShouldEqual, "http://my.tld/bla/blub") + So(JoiningSlash("http://my.tld/docs", "bla/", "blub"), ShouldEqual, "http://my.tld/docs/bla/blub") + So(JoiningSlash("http://my.tld/docs/", "bla/", "blub"), ShouldEqual, "http://my.tld/docs/bla/blub") + So(JoiningSlash("", "api", "v1"), ShouldEqual, "api/v1") + So(JoiningSlash("", "", ""), ShouldEqual, "") + }) } -func TestJoiningSlash1(t *testing.T) { - actual := JoiningSlash("http://my.tld/docs/", "bla/", "blub/") - expected := "http://my.tld/docs/bla/blub/" - assert.Equal(t, expected, actual) +func TestSingleJoiningSlash(t *testing.T) { + Convey("singleJoiningSlash should handle slash edge cases", t, func() { + So(singleJoiningSlash("a/", "/b"), ShouldEqual, "a/b") + So(singleJoiningSlash("a", "b"), ShouldEqual, "a/b") + So(singleJoiningSlash("a/", "b"), ShouldEqual, "a/b") + So(singleJoiningSlash("a", "/b"), ShouldEqual, "a/b") + }) } -func TestJoiningSlash2(t *testing.T) { - actual := JoiningSlash("http://my.tld", "bla", "blub") - assert.Equal(t, "http://my.tld/bla/blub", actual) +func TestGetGlobalConfiguration(t *testing.T) { + Convey("GetGlobalConfigurationFile should create the expected path", t, func() { + appName := "myapp" + fileName := "config.yaml" + + dir := GetGlobalConfigurationDirectory(appName) + So(GetGlobalConfigurationFile(appName, fileName), ShouldEqual, filepath.Join(dir, fileName)) + }) } -func TestJoiningSlash3(t *testing.T) { - actual := JoiningSlash("http://my.tld/", "bla", "blub") - assert.Equal(t, "http://my.tld/bla/blub", actual) -} -func TestJoiningSlash4(t *testing.T) { - actual := JoiningSlash("http://my.tld", "bla/", "blub") - assert.Equal(t, "http://my.tld/bla/blub", actual) -} -func TestJoiningSlash5(t *testing.T) { - actual := JoiningSlash("http://my.tld/docs", "bla/", "blub") - expected := "http://my.tld/docs/bla/blub" - assert.Equal(t, expected, actual) -} - -func TestJoiningSlash6(t *testing.T) { - actual := JoiningSlash("http://my.tld/docs/", "bla/", "blub") - expected := "http://my.tld/docs/bla/blub" - assert.Equal(t, expected, actual) -} - -/* -Can run only as admin within windows or linux -e.g. sudo TESTASSUDO=yes /usr/local/go/bin/go test -timeout 30s -run ^TestIsSuperUser$ -*/ func TestIsSuperUser(t *testing.T) { - if !strings.EqualFold(os.Getenv("TESTASSUDO"), "yes") { - t.Skip("Skipping in normal tests") - } - cuser, err := user.Current() - assert.Nil(t, err) - assert.NotNil(t, cuser) - assert.True(t, IsSuperUser()) -} - -func TestGlobalConfigurationDirectoryWindows(t *testing.T) { - if runtime.GOOS != "windows" { - t.Skipf("Skipping on OS %s", runtime.GOOS) - } - appFolder := GetGlobalConfigurationDirectory("myapp") - assert.Equal(t, filepath.Join(os.Getenv("APPDATA"), "myapp"), appFolder) -} - -func TestGlobalConfigurationDirectoryLinux(t *testing.T) { - if runtime.GOOS != "linux" { - t.Skipf("Skipping on OS %s", runtime.GOOS) - } - appFolder := GetGlobalConfigurationDirectory("myapp") - assert.Equal(t, "/etc/myapp", appFolder) -} - -func TestGlobalConfigurationDirectoryMacOS(t *testing.T) { - if runtime.GOOS != "darwin" { - t.Skipf("Skipping on OS %s", runtime.GOOS) - } - appFolder := GetGlobalConfigurationDirectory("myapp") - assert.Equal(t, "/etc/myapp", appFolder) -} - -func TestMkDir(t *testing.T) { - path := "c:/tmp/bla/blub" - if !FileExists(path) { - err := os.MkdirAll(path, os.ModeDir) - assert.Nil(t, err) - assert.True(t, FileExists(path)) - } + Convey("IsSuperUser should return a boolean without requiring elevated rights", t, func() { + result := IsSuperUser() + So(result, ShouldBeIn, []bool{true, false}) + }) }