diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 631f092..40f8337 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -23,8 +23,10 @@ jobs: with: go-version-file: go.mod - name: Install depends - run: go get . + run: go get ./... - name: Build run: go build -v ./... - name: Vet run: go vet ./... + - name: Test + run: go test ./... diff --git a/README.md b/README.md index 92257ba..2a8a566 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,10 @@ -# xdg-cli +# cli +## xdg-cli Library to create `urfave/cli/v3`’s `ValueSource` with XDG Base Directory files. +## os-release +Library to parse os-release. + ## Author Louis Royer and the NextMN Contributors diff --git a/go.mod b/go.mod index 3507ad4..e7c96a7 100644 --- a/go.mod +++ b/go.mod @@ -1,10 +1,10 @@ -module github.com/nextmn/cli-xdg +module github.com/nextmn/cli -go 1.25.5 +go 1.26.0 require ( github.com/adrg/xdg v0.5.3 github.com/urfave/cli/v3 v3.8.0 ) -require golang.org/x/sys v0.26.0 // indirect +require golang.org/x/sys v0.41.0 // indirect diff --git a/go.sum b/go.sum index 6223cc0..f225e7e 100644 --- a/go.sum +++ b/go.sum @@ -8,7 +8,7 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/urfave/cli/v3 v3.8.0 h1:XqKPrm0q4P0q5JpoclYoCAv0/MIvH/jZ2umzuf8pNTI= github.com/urfave/cli/v3 v3.8.0/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso= -golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= -golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/os-release/err.go b/os-release/err.go new file mode 100644 index 0000000..c61a2f4 --- /dev/null +++ b/os-release/err.go @@ -0,0 +1,15 @@ +// Copyright Louis Royer and the NextMN contributors. All rights reserved. +// Use of this source code is governed by a MIT-style license that can be +// found in the LICENSE file. +// SPDX-License-Identifier: MIT + +package osrelease + +import ( + "errors" +) + +var ( + ErrNoOsReleaseFile = errors.New("cannot open os-release file") + ErrCannotParseFile = errors.New("cannot parse os-release file") +) diff --git a/os-release/os-release.go b/os-release/os-release.go new file mode 100644 index 0000000..cbe1624 --- /dev/null +++ b/os-release/os-release.go @@ -0,0 +1,141 @@ +// Copyright Louis Royer and the NextMN contributors. All rights reserved. +// Use of this source code is governed by a MIT-style license that can be +// found in the LICENSE file. +// SPDX-License-Identifier: MIT + +package osrelease + +import ( + "bufio" + "errors" + "io" + "os" + "strings" +) + +// OsRelease contains operating system identification data, +// programmatic fields only (see `OS-RELEASE(5)`) +type OsRelease struct { + // Operating system identifier, excluding any version information. + // If not set, a default `ID=linux` may be used + Id string + // List of operating system identifiers that are closely related + // to the local operating system in regards + // to packaging and programming interface + IdLike []string + + // Specific variant or edition of the operating system + VariantId string + + // Operating system version, + // excluding any OS name information or release code name + VersionId string + // Operating system release code name, + // excluding any OS name information or release version + VersionCodename string + + // System image originally used as the installation base + // Optional field + BuildId string + + // Specific image of the operating system + // Optional field + ImageId string + // OS image version + ImageVersion string +} + +// Escape field value +func escape(field string) (string, error) { + if len(field) == 0 { + return "", ErrCannotParseFile + } + switch field[0] { + case '\'': + field = strings.Trim(field, "'") + case '"': + field = strings.Trim(field, "\"") + } + + builder := strings.Builder{} + esc := false + for _, b := range field { + if b == '\\' && !esc { + esc = true + } else if strings.ContainsRune("$'\"\\`", b) == esc { + // Shell special characters ("$", quotes, backslash, backtick) must be escaped + builder.WriteRune(b) + esc = false + } else { + return "", ErrCannotParseFile + } + } + return builder.String(), nil +} + +// Create OsRelease from a file +func FromFile(file io.Reader) (*OsRelease, error) { + osRelease := OsRelease{} + scanner := bufio.NewScanner(file) + var err error + for scanner.Scan() { + line := scanner.Text() + if strings.HasPrefix(line, "#") { + continue + } + line = strings.TrimSpace(line) + if line == "" { + continue + } + key, value, found := strings.Cut(line, "=") + if !found { + return nil, ErrCannotParseFile + } + switch key { + case "ID": + osRelease.Id, err = escape(value) + case "ID_LIKE": + if tmp, err2 := escape(value); err2 == nil { + osRelease.IdLike = strings.Fields(tmp) + } else { + err = err2 + } + case "VARIANT_ID": + osRelease.VariantId, err = escape(value) + case "VERSION_ID": + osRelease.VersionId, err = escape(value) + case "VERSION_CODENAME": + osRelease.VersionCodename, err = escape(value) + case "BUILD_ID": + osRelease.BuildId, err = escape(value) + case "IMAGE_ID": + osRelease.ImageId, err = escape(value) + case "IMAGE_VERSION": + osRelease.ImageVersion, err = escape(value) + default: + err = nil + } + if err != nil { + return &OsRelease{}, err + } + } + if osRelease.Id == "" { + osRelease.Id = "linux" + } + return &osRelease, nil +} + +// Create OsRelease for the current operating system +func New() (*OsRelease, error) { + // main file is /etc/os-release (symlinks followed) + f, err := os.Open("/etc/os-release") + if err != nil { + // fallback on /usr/lib/os-release + f, err = os.Open("/usr/lib/os-release") + if err != nil { + return nil, errors.Join(ErrNoOsReleaseFile, err) + } + } + defer f.Close() + return FromFile(f) +} diff --git a/os-release/os-release_test.go b/os-release/os-release_test.go new file mode 100644 index 0000000..3c7a6e6 --- /dev/null +++ b/os-release/os-release_test.go @@ -0,0 +1,57 @@ +// Copyright Louis Royer and the NextMN contributors. All rights reserved. +// Use of this source code is governed by a MIT-style license that can be +// found in the LICENSE file. +// SPDX-License-Identifier: MIT + +package osrelease + +import ( + "strings" + "testing" +) + +func TestFromFile(t *testing.T) { + debian := ` +PRETTY_NAME="Debian GNU/Linux 12 (bookworm)" +NAME="Debian GNU/Linux" +VERSION_ID="12" +VERSION="12 (bookworm)" +VERSION_CODENAME=bookworm +ID=debian +HOME_URL="https://www.debian.org/" +SUPPORT_URL="https://www.debian.org/support" +BUG_REPORT_URL="https://bugs.debian.org/" +` + rel, err := FromFile(strings.NewReader(debian)) + if err != nil { + t.Fatal(err) + } + if rel.Id != "debian" { + t.Errorf("osRelease.Id = %s; want debian", rel.Id) + } + if rel.VersionId != "12" { + t.Errorf("osRelease.VersionId = %s; want 12", rel.VersionId) + } + if rel.VersionCodename != "bookworm" { + t.Errorf("osRelease.VersionCodename = %s; want bookworm", rel.VersionCodename) + } + + alpine := ` +NAME="Alpine Linux" +ID=alpine +VERSION_ID=3.22.1 +PRETTY_NAME="Alpine Linux v3.22" +HOME_URL="https://alpinelinux.org/" +BUG_REPORT_URL="https://gitlab.alpinelinux.org/alpine/aports/-/issues" +` + rel, err = FromFile(strings.NewReader(alpine)) + if err != nil { + t.Fatal(err) + } + if rel.Id != "alpine" { + t.Errorf("osRelease.Id = %s; want alpine", rel.Id) + } + if rel.VersionId != "3.22.1" { + t.Errorf("osRelease.VersionId = %s; want 3.22.1", rel.VersionId) + } +} diff --git a/cli-xdg.go b/xdg/cli-xdg.go similarity index 100% rename from cli-xdg.go rename to xdg/cli-xdg.go