diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml new file mode 100644 index 000000000..7a87b5468 --- /dev/null +++ b/.github/workflows/fuzz.yml @@ -0,0 +1,40 @@ +name: Fuzz + +on: + pull_request: + paths-ignore: + - 'docs/**' + - 'charts/**' + schedule: + # Nightly at 04:00 UTC (06:00 CEST) with longer fuzz duration + - cron: '0 4 * * *' + +jobs: + fuzz: + runs-on: ubuntu-latest + strategy: + matrix: + fuzz-target: + - { package: ./restic/kubernetes/, func: FuzzFilterAndConvert } + - { package: ./restic/logging/, func: FuzzBackupOutputParser } + - { package: ./operator/utils/, func: FuzzJsonArgsArrayUnmarshalJSON } + steps: + - uses: actions/checkout@v6 + + - name: Determine Go version from go.mod + run: echo "GO_VERSION=$(go mod edit -json | jq -r .Go)" >> $GITHUB_ENV + + - uses: actions/setup-go@v6 + with: + go-version: ${{ env.GO_VERSION }} + + - name: Set fuzz duration + run: | + if [ "${{ github.event_name }}" = "schedule" ]; then + echo "FUZZ_TIME=5m" >> $GITHUB_ENV + else + echo "FUZZ_TIME=60s" >> $GITHUB_ENV + fi + + - name: Run fuzz test + run: go test ${{ matrix.fuzz-target.package }} -fuzz=${{ matrix.fuzz-target.func }} -fuzztime=$FUZZ_TIME diff --git a/operator/utils/utils_fuzz_test.go b/operator/utils/utils_fuzz_test.go new file mode 100644 index 000000000..a2f85849e --- /dev/null +++ b/operator/utils/utils_fuzz_test.go @@ -0,0 +1,25 @@ +package utils + +import ( + "testing" +) + +func FuzzJsonArgsArrayUnmarshalJSON(f *testing.F) { + // Seed with realistic inputs + f.Add([]byte(`"--verbose"`)) + f.Add([]byte(`["--verbose", "--dry-run"]`)) + f.Add([]byte(`[]`)) + f.Add([]byte(`""`)) + f.Add([]byte(`null`)) + f.Add([]byte(`123`)) + f.Add([]byte(`{"key": "value"}`)) + f.Add([]byte(`[1, 2, 3]`)) + f.Add([]byte(`[null]`)) + f.Add([]byte(``)) + + f.Fuzz(func(t *testing.T, data []byte) { + var arr JsonArgsArray + // Must not panic — errors are acceptable + _ = arr.UnmarshalJSON(data) + }) +} diff --git a/restic/kubernetes/snapshots.go b/restic/kubernetes/snapshots.go index 37429add6..70fd2345d 100644 --- a/restic/kubernetes/snapshots.go +++ b/restic/kubernetes/snapshots.go @@ -98,9 +98,14 @@ func filterAndConvert(list []dto.Snapshot, namespace, repository string) *k8upv1 continue } + name := snapshot.ID + if len(name) > 8 { + name = name[:8] + } + finalList.Items = append(finalList.Items, k8upv1.Snapshot{ ObjectMeta: metav1.ObjectMeta{ - Name: snapshot.ID[:8], + Name: name, Namespace: namespace, }, Spec: k8upv1.SnapshotSpec{ diff --git a/restic/kubernetes/snapshots_fuzz_test.go b/restic/kubernetes/snapshots_fuzz_test.go new file mode 100644 index 000000000..2df71013f --- /dev/null +++ b/restic/kubernetes/snapshots_fuzz_test.go @@ -0,0 +1,38 @@ +package kubernetes + +import ( + "testing" + "time" + + "github.com/k8up-io/k8up/v2/restic/dto" +) + +func FuzzFilterAndConvert(f *testing.F) { + // Seed with realistic inputs + f.Add("abc12345678", "default", "s3:http://minio:9000/backup") + f.Add("short", "default", "s3:http://minio:9000/backup") + f.Add("", "default", "s3:http://minio:9000/backup") + f.Add("abcdefgh", "test-ns", "rest:https://user:pass@host/path") + f.Add("1234567", "default", "") // 7 chars - just under the [:8] slice + + f.Fuzz(func(t *testing.T, id, namespace, repository string) { + snapshots := []dto.Snapshot{ + { + ID: id, + Time: time.Now(), + Hostname: namespace, // must match namespace to pass filter + Paths: []string{"/data"}, + }, + } + + // This must not panic + result := filterAndConvert(snapshots, namespace, repository) + + // If snapshot passed the filter, verify basic properties + if len(result.Items) > 0 { + if result.Items[0].Namespace != namespace { + t.Errorf("expected namespace %q, got %q", namespace, result.Items[0].Namespace) + } + } + }) +} diff --git a/restic/logging/logging_fuzz_test.go b/restic/logging/logging_fuzz_test.go new file mode 100644 index 000000000..dfd471547 --- /dev/null +++ b/restic/logging/logging_fuzz_test.go @@ -0,0 +1,38 @@ +package logging + +import ( + "testing" + + "github.com/go-logr/logr" +) + +func FuzzBackupOutputParser(f *testing.F) { + // Seed with realistic restic JSON output + f.Add(`{"message_type":"status","percent_done":0.5,"total_files":100,"files_done":50}`) + f.Add(`{"message_type":"summary","files_new":10,"files_changed":2,"total_duration":1.5,"snapshot_id":"abc12345"}`) + f.Add(`{"message_type":"error","error":{"Op":"read","Path":"/data/file","Err":13},"during":"scan","item":"/data/file"}`) + f.Add(`not json at all`) + f.Add(`{}`) + f.Add(`{"message_type":"unknown"}`) + f.Add(``) + f.Add(`{"message_type":"status","percent_done":-1}`) + f.Add(`{"message_type":"summary","total_duration":999999999999}`) + + f.Fuzz(func(t *testing.T, input string) { + summaryCalled := false + summaryFunc := func(summary BackupSummary, errorCount int, folder string, startTimestamp, endTimestamp int64) { + summaryCalled = true + _ = summaryCalled + } + + parser := &BackupOutputParser{ + log: logr.Discard(), + folder: "/data", + summaryFunc: summaryFunc, + percentageFunc: IgnorePercentage, + } + + // Must not panic + parser.out(input) + }) +}