Skip to content

Commit 4027467

Browse files
committed
fix: do not exit code 1 with invalid glibc
1 parent 65dfdb1 commit 4027467

File tree

4 files changed

+301
-0
lines changed

4 files changed

+301
-0
lines changed
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
package utils
2+
3+
import (
4+
"fmt"
5+
"os/exec"
6+
"regexp"
7+
"runtime"
8+
"strings"
9+
"sync"
10+
11+
"github.com/snyk/error-catalog-golang-public/snyk_errors"
12+
)
13+
14+
const (
15+
MIN_GLIBC_VERSION_LINUX_AMD64 = "2.28"
16+
MIN_GLIBC_VERSION_LINUX_ARM64 = "2.31"
17+
)
18+
19+
var (
20+
cachedVersion string
21+
cachedVersionErr error
22+
versionDetectOnce sync.Once
23+
versionRegex = regexp.MustCompile(`(\d+\.\d+)`)
24+
)
25+
26+
type GlibcVersion func() (string, error)
27+
28+
// ValidateGlibcVersion checks if the glibc version is supported and returns an Error Catalog error if it is not.
29+
// This check only applies to glibc-based Linux systems (amd64, arm64).
30+
// Optionally accepts a custom GlibcVersion, mainly for testing.
31+
func ValidateGlibcVersion(opt ...GlibcVersion) error {
32+
var versionFn GlibcVersion
33+
if len(opt) > 0 {
34+
versionFn = opt[0]
35+
} else {
36+
versionFn = defaultGlibcVersion()
37+
}
38+
39+
version, err := versionFn()
40+
if err != nil {
41+
return err
42+
}
43+
44+
// Skip validation on non-Linux or if glibc not detected
45+
if version == "" || runtime.GOOS != "linux" {
46+
return nil
47+
}
48+
49+
var minVersion string
50+
switch runtime.GOARCH {
51+
case "arm64":
52+
minVersion = MIN_GLIBC_VERSION_LINUX_ARM64
53+
case "amd64":
54+
minVersion = MIN_GLIBC_VERSION_LINUX_AMD64
55+
default:
56+
return nil
57+
}
58+
59+
res, err := SemverCompare(version, minVersion)
60+
if err != nil {
61+
return err
62+
}
63+
64+
if res < 0 {
65+
return snyk_errors.Error{
66+
Title: "Unsupported glibc version",
67+
Description: fmt.Sprintf("The installed glibc version, %s is not supported. Upgrade to a version of glibc >= %s", version, minVersion),
68+
ErrorCode: "SNYK-CLI-0000",
69+
Links: []string{"https://docs.snyk.io/developer-tools/snyk-cli/releases-and-channels-for-the-snyk-cli#runtime-requirements"},
70+
}
71+
}
72+
73+
// We currently do not fail on Linux when glibc is not detected, which could lead to an ungraceful failure.
74+
// Failing here would require detectGlibcVersion to always return a valid version, which is not the case.
75+
return nil
76+
}
77+
78+
// defaultGlibcVersion attempts to detect the glibc version on Linux systems
79+
// The detection is performed only once and cached for subsequent calls
80+
func defaultGlibcVersion() GlibcVersion {
81+
return func() (string, error) {
82+
versionDetectOnce.Do(func() {
83+
cachedVersion, cachedVersionErr = detectGlibcVersion()
84+
})
85+
return cachedVersion, cachedVersionErr
86+
}
87+
}
88+
89+
// detectGlibcVersion attempts to detect the glibc version on Linux systems
90+
func detectGlibcVersion() (string, error) {
91+
if runtime.GOOS != "linux" {
92+
return "", nil
93+
}
94+
95+
// Method 1: Try ldd --version
96+
cmd := exec.Command("ldd", "--version")
97+
output, err := cmd.Output()
98+
if err == nil {
99+
lines := strings.Split(string(output), "\n")
100+
if len(lines) > 0 {
101+
// Parse version from first line, e.g., "ldd (GNU libc) 2.31"
102+
if matches := versionRegex.FindStringSubmatch(lines[0]); len(matches) > 1 {
103+
return matches[1], nil
104+
}
105+
}
106+
}
107+
108+
// Method 2: Try getconf GNU_LIBC_VERSION
109+
cmd = exec.Command("getconf", "GNU_LIBC_VERSION")
110+
output, err = cmd.Output()
111+
if err == nil {
112+
// Output format: "glibc 2.31"
113+
if matches := versionRegex.FindStringSubmatch(string(output)); len(matches) > 1 {
114+
return matches[1], nil
115+
}
116+
}
117+
118+
return "", nil
119+
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
package utils
2+
3+
import (
4+
"errors"
5+
"os/exec"
6+
"runtime"
7+
"strings"
8+
"testing"
9+
10+
"github.com/snyk/error-catalog-golang-public/snyk_errors"
11+
"github.com/stretchr/testify/assert"
12+
"github.com/stretchr/testify/require"
13+
)
14+
15+
func Test_ValidateGlibcVersion_doesNotApplyOnNonLinux(t *testing.T) {
16+
// skip for Linux
17+
if runtime.GOOS == "linux" {
18+
t.Skip("Test only applicable on non-Linux")
19+
}
20+
21+
err := ValidateGlibcVersion()
22+
if err != nil {
23+
t.Errorf("Expected no error but got: %v", err)
24+
}
25+
}
26+
27+
func Test_ValidateGlibcVersion_validates(t *testing.T) {
28+
if runtime.GOOS != "linux" {
29+
t.Skip("Test only applicable on Linux")
30+
}
31+
32+
// Skip Alpine Linux becuase it does not use glibc
33+
out, err := exec.Command("ldd", "--version").CombinedOutput()
34+
if err != nil || strings.Contains(string(out), "musl") {
35+
t.Skip("Test only applicable on glibc-based Linux")
36+
}
37+
38+
detectionErr := errors.New("detection failed")
39+
40+
tests := []struct {
41+
name string
42+
version string
43+
versionError error
44+
expectedSnykErrCode string
45+
}{
46+
{
47+
name: "Version too old on amd64",
48+
version: "2.27",
49+
expectedSnykErrCode: "SNYK-CLI-0000",
50+
},
51+
{
52+
name: "Version exactly minimum on amd64",
53+
version: MIN_GLIBC_VERSION_LINUX_AMD64,
54+
},
55+
{
56+
name: "Version newer than minimum on amd64",
57+
version: "2.35",
58+
},
59+
{
60+
name: "Version too old on arm64",
61+
version: "2.30",
62+
expectedSnykErrCode: "SNYK-CLI-0000",
63+
},
64+
{
65+
name: "Version exactly minimum on arm64",
66+
version: MIN_GLIBC_VERSION_LINUX_ARM64,
67+
},
68+
{
69+
name: "Version newer than minimum on arm64",
70+
version: "2.35",
71+
},
72+
{
73+
name: "Empty version (musl/Alpine)",
74+
version: "",
75+
},
76+
{
77+
name: "Version detection returns error",
78+
versionError: detectionErr,
79+
},
80+
{
81+
name: "Invalid version format",
82+
version: "invalid.version",
83+
},
84+
}
85+
86+
for _, tt := range tests {
87+
t.Run(tt.name, func(t *testing.T) {
88+
mockVersionFn := func() (string, error) {
89+
return tt.version, tt.versionError
90+
}
91+
92+
actualErr := ValidateGlibcVersion(mockVersionFn)
93+
94+
if tt.versionError != nil {
95+
assert.ErrorIs(t, actualErr, tt.versionError)
96+
return
97+
}
98+
99+
if tt.expectedSnykErrCode != "" {
100+
require.NotNil(t, actualErr, "Expected error but got nil")
101+
var snykErr snyk_errors.Error
102+
require.True(t, errors.As(actualErr, &snykErr), "Expected snyk_errors.Error but got: %v", actualErr)
103+
assert.Equal(t, tt.expectedSnykErrCode, snykErr.ErrorCode)
104+
} else {
105+
assert.NoError(t, actualErr)
106+
}
107+
})
108+
}
109+
}

cliv2/internal/utils/helpers.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
package utils
22

3+
import (
4+
"strconv"
5+
"strings"
6+
)
7+
38
// Dedupe removes duplicate entries from a given slice.
49
// Returns a new, deduplicated slice.
510
//
@@ -36,3 +41,65 @@ func Contains(list []string, element string) bool {
3641
}
3742
return false
3843
}
44+
45+
// SemverCompare compares two semantic version strings component-wise
46+
// Returns: -1 if v1 < v2, 0 if equal, 1 if v1 > v2
47+
// Example:
48+
//
49+
// SemverCompare("2.27", "2.31") // returns -1
50+
// SemverCompare("2.31", "2.31") // returns 0
51+
// SemverCompare("2.35", "2.31") // returns 1
52+
func SemverCompare(v1 string, v2 string) (int, error) {
53+
p1, err := parseSemver(v1)
54+
if err != nil {
55+
return 0, err
56+
}
57+
p2, err := parseSemver(v2)
58+
if err != nil {
59+
return 0, err
60+
}
61+
62+
maxLen := len(p1)
63+
if len(p2) > maxLen {
64+
maxLen = len(p2)
65+
}
66+
67+
for i := 0; i < maxLen; i++ {
68+
c1, c2 := 0, 0
69+
if i < len(p1) {
70+
c1 = p1[i]
71+
}
72+
if i < len(p2) {
73+
c2 = p2[i]
74+
}
75+
if c1 < c2 {
76+
return -1, nil
77+
}
78+
if c1 > c2 {
79+
return 1, nil
80+
}
81+
}
82+
return 0, nil
83+
}
84+
85+
// parseSemver parses a semantic version string into a slice of integers
86+
// Example:
87+
//
88+
// parseSemver("2.27") // returns []int{2, 27}
89+
// parseSemver("2.31") // returns []int{2, 31}
90+
// parseSemver("2.35") // returns []int{2, 35}
91+
func parseSemver(v string) ([]int, error) {
92+
if v == "" {
93+
return []int{}, nil
94+
}
95+
parts := strings.Split(v, ".")
96+
nums := make([]int, len(parts))
97+
for i, p := range parts {
98+
n, err := strconv.Atoi(strings.TrimSpace(p))
99+
if err != nil {
100+
return nil, err
101+
}
102+
nums[i] = n
103+
}
104+
return nums, nil
105+
}

cliv2/pkg/basic_workflows/legacycli.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"strconv"
1010

1111
"github.com/snyk/cli/cliv2/internal/proxy/interceptor"
12+
"github.com/snyk/cli/cliv2/internal/utils"
1213

1314
"github.com/pkg/errors"
1415
"github.com/rs/zerolog"
@@ -70,6 +71,11 @@ func legacycliWorkflow(
7071
var outBuffer bytes.Buffer
7172
var outWriter *bufio.Writer
7273

74+
err = utils.ValidateGlibcVersion()
75+
if err != nil {
76+
return output, err
77+
}
78+
7379
config := invocation.GetConfiguration()
7480
debugLogger := invocation.GetEnhancedLogger() // uses zerolog
7581
debugLoggerDefault := invocation.GetLogger() // uses log

0 commit comments

Comments
 (0)