diff --git a/.github/workflows/cifuzz.yml b/.github/workflows/cifuzz.yml new file mode 100644 index 0000000..346bf29 --- /dev/null +++ b/.github/workflows/cifuzz.yml @@ -0,0 +1,43 @@ +name: CIFuzz +on: + pull_request: + paths: + - '**.go' + - '.github/workflows/cifuzz.yml' + push: + branches: [main, master] + +permissions: + contents: read + security-events: write + +jobs: + fuzzing: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + sanitizer: [address] + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Build Fuzzers (${{ matrix.sanitizer }}) + id: build + uses: google/oss-fuzz/infra/cifuzz/actions/build_fuzzers@ba0e2e0399a10b7b42afb16e7a6c4ccd3ff52431 + with: + oss-fuzz-project-name: 'semver' + language: go + sanitizer: ${{ matrix.sanitizer }} + - name: Run Fuzzers (${{ matrix.sanitizer }}) + uses: google/oss-fuzz/infra/cifuzz/actions/run_fuzzers@ba0e2e0399a10b7b42afb16e7a6c4ccd3ff52431 + with: + oss-fuzz-project-name: 'semver' + language: go + fuzz-seconds: 300 + sanitizer: ${{ matrix.sanitizer }} + output-sarif: true + - name: Upload Sarif + if: always() && steps.build.outcome == 'success' + uses: github/codeql-action/upload-sarif@601d5b1bcb3e5ef5eea97a6d0dcdbbb8c2b80116 + with: + sarif_file: cifuzz-sarif/results.sarif + category: fuzz-${{ matrix.sanitizer }} diff --git a/fuzz_test.go b/fuzz_test.go new file mode 100644 index 0000000..5a58e4a --- /dev/null +++ b/fuzz_test.go @@ -0,0 +1,253 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package semver_test + +import ( + "math" + "testing" + + semver "github.com/Masterminds/semver/v3" +) + +// ============================================================================= +// Fuzz Target 1: Version Comparison — Compare, LessThan, GreaterThan, Equal +// ============================================================================= + +// FuzzVersionCompare compares two parsed versions and checks comparison invariants. +func FuzzVersionCompare(f *testing.F) { + seeds := [][2]string{ + {"1.0.0", "2.0.0"}, + {"1.0.0", "1.0.0"}, + {"2.0.0", "1.0.0"}, + {"1.0.0-alpha", "1.0.0"}, + {"1.0.0-alpha", "1.0.0-alpha"}, + {"1.0.0-alpha.1", "1.0.0-alpha.2"}, + {"1.0.0+build.1", "1.0.0+build.2"}, + {"0.0.0", "18446744073709551615.18446744073709551615.18446744073709551615"}, + } + for _, s := range seeds { + f.Add(s[0], s[1]) + } + + f.Fuzz(func(t *testing.T, a, b string) { + if len(a) > 256 || len(b) > 256 { + return + } + + va, errA := semver.NewVersion(a) + vb, errB := semver.NewVersion(b) + if errA != nil || errB != nil { + return + } + + cmp := va.Compare(vb) + cmpRev := vb.Compare(va) + + // Antisymmetry + if cmp == 0 && cmpRev != 0 { + t.Errorf("Compare asymmetry: %s vs %s → %d / %d", a, b, cmp, cmpRev) + } + if cmp > 0 && cmpRev >= 0 { + t.Errorf("Compare antisymmetry violation: %s vs %s → %d / %d", a, b, cmp, cmpRev) + } + if cmp < 0 && cmpRev <= 0 { + t.Errorf("Compare antisymmetry violation: %s vs %s → %d / %d", a, b, cmp, cmpRev) + } + + // Equal ↔ Compare == 0 + if va.Equal(vb) != (cmp == 0) { + t.Errorf("Equal/Compare mismatch: %s vs %s → Compare=%d Equal=%v", a, b, cmp, va.Equal(vb)) + } + + // LessThan / GreaterThan consistency + lt := va.LessThan(vb) + gt := va.GreaterThan(vb) + if lt == gt && cmp != 0 { + t.Errorf("LessThan/GreaterThan both %v for Compare=%d", lt, cmp) + } + if lt != (cmp < 0) { + t.Errorf("LessThan mismatch: %s vs %s → Compare=%d LessThan=%v", a, b, cmp, lt) + } + + // Nil check safety + func() { + defer func() { _ = recover() }() + _ = va.Compare(nil) + }() + }) +} + +// ============================================================================= +// Fuzz Target 2: Version Round-Trip — Parse → String → Parse → Equal +// ============================================================================= + +// FuzzVersionRoundTrip verifies that version → string → version preserves equality. +func FuzzVersionRoundTrip(f *testing.F) { + seeds := []string{ + "1.2.3", + "0.0.0", + "v1.0.0", + "1.2.3-alpha.1+build.123", + "1.0.0-beta+exp.sha.5114f85", + "18446744073709551615.0.0", + } + for _, s := range seeds { + f.Add(s) + } + + f.Fuzz(func(t *testing.T, v string) { + if len(v) > 256 { + return + } + + ver, err := semver.NewVersion(v) + if err != nil { + return + } + + str := ver.String() + ver2, err2 := semver.NewVersion(str) + if err2 != nil { + t.Errorf("Round-trip parse failed: original=%q string=%q err=%v", v, str, err2) + return + } + + if !ver.Equal(ver2) { + t.Errorf("Round-trip inequality: original=%q → string=%q → parsed=%q", + v, str, ver2.String()) + } + }) +} + +// ============================================================================= +// Fuzz Target 3: Version Increment — IncPatch/IncMinor/IncMajor (overflow) +// ============================================================================= + +// FuzzIncOverflow tests increment operations on edge-case versions. +func FuzzIncOverflow(f *testing.F) { + seeds := []string{ + "0.0.0", + "1.2.3", + "18446744073709551615.0.0", + "0.18446744073709551615.0", + "0.0.18446744073709551615", + "18446744073709551615.18446744073709551615.18446744073709551615", + } + for _, s := range seeds { + f.Add(s) + } + + f.Fuzz(func(t *testing.T, v string) { + if len(v) > 256 { + return + } + + ver, err := semver.NewVersion(v) + if err != nil { + return + } + + // Each increment must not panic + func() { + defer func() { _ = recover() }() + _ = ver.IncPatch().String() + }() + + func() { + defer func() { _ = recover() }() + _ = ver.IncMinor().String() + }() + + func() { + defer func() { _ = recover() }() + _ = ver.IncMajor().String() + }() + + // Invariants for non-overflow versions + if ver.Patch() < math.MaxUint64 { + if inc := ver.IncPatch(); inc.Patch() != ver.Patch()+1 { + t.Errorf("IncPatch: %d + 1 != %d", ver.Patch(), inc.Patch()) + } + } + if ver.Minor() < math.MaxUint64 { + if inc := ver.IncMinor(); inc.Minor() != ver.Minor()+1 { + t.Errorf("IncMinor: %d + 1 != %d", ver.Minor(), inc.Minor()) + } + if inc := ver.IncMinor(); inc.Patch() != 0 { + t.Errorf("IncMinor: patch not reset to 0, got %d", inc.Patch()) + } + } + }) +} + +// ============================================================================= +// Fuzz Target 4: Constraint × Version Integration — Check + Validate safety +// ============================================================================= + +// FuzzConstraintVersionCheck feeds constraint+version pairs and verifies no panics. +func FuzzConstraintVersionCheck(f *testing.F) { + seeds := []struct{ constraint, version string }{ + {">=1.0.0", "1.0.0"}, + {"<2.0.0", "1.0.0"}, + {">=1.0.0 <2.0.0", "1.5.0"}, + {"^1.2.3", "1.2.4"}, + {"^1.2.3", "2.0.0"}, + {"~1.2.3", "1.2.4"}, + {"1.x", "1.9.9"}, + {"*", "99.99.99"}, + } + for _, s := range seeds { + f.Add(s.constraint, s.version) + } + + f.Fuzz(func(t *testing.T, constraint, version string) { + if len(constraint) > 600 || len(version) > 256 { + return + } + + cs, err := semver.NewConstraint(constraint) + if err != nil { + // Test nil version on failed constraint (should not panic) + func() { _ = cs.Check(nil) }() + func() { _, _ = cs.Validate(nil) }() + return + } + + ver, err := semver.NewVersion(version) + if err != nil { + // Test nil version safety + func() { + defer func() { _ = recover() }() + _ = cs.Check(nil) + }() + return + } + + // Check must not panic + func() { + defer func() { _ = recover() }() + _ = cs.Check(ver) + }() + + // Validate must not panic + func() { + defer func() { _ = recover() }() + _, _ = cs.Validate(ver) + }() + + // Pre-release interaction + _ = ver.Prerelease() + }) +}