Skip to content

Commit 6ebf9e5

Browse files
author
Stanislav (Stas) Katkov
committed
Introduce fuzzing to classes
1 parent c5bc0ef commit 6ebf9e5

22 files changed

Lines changed: 686 additions & 16 deletions

File tree

.github/workflows/fuzz-nightly.yml

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
name: nightly-fuzz
2+
3+
on:
4+
schedule:
5+
- cron: "17 2 * * *"
6+
workflow_dispatch:
7+
8+
permissions:
9+
contents: read
10+
11+
jobs:
12+
fuzz:
13+
name: nightly-fuzz
14+
runs-on: ubuntu-latest
15+
timeout-minutes: 90
16+
steps:
17+
- uses: actions/checkout@v6
18+
- uses: actions/setup-go@v6
19+
with:
20+
go-version-file: "go.mod"
21+
cache: true
22+
23+
- name: Run nightly fuzz suite
24+
run: make fuzz-nightly NIGHTLY_FUZZTIME=45s
25+
26+
- name: Upload failing fuzz corpus
27+
if: failure()
28+
uses: actions/upload-artifact@v4
29+
with:
30+
name: nightly-fuzz-corpus
31+
path: |
32+
**/testdata/fuzz/**
33+
if-no-files-found: ignore

Makefile

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
.PHONY: help build run test test-race lint clean deps tidy docs site release snapshot install
1+
.PHONY: help build run test test-race test-coverage lint fmt vet check fuzz fuzz-nightly clean deps tidy update-deps generate docs site release snapshot install verify all
22

33
# Default target
44
.DEFAULT_GOAL := help
@@ -9,6 +9,8 @@ VERSION?=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
99
COMMIT?=$(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
1010
DATE?=$(shell date -u +"%Y-%m-%dT%H:%M:%SZ")
1111
LDFLAGS=-ldflags "-X github.com/skatkov/devtui/cmd.version=$(VERSION) -X github.com/skatkov/devtui/cmd.commit=$(COMMIT) -X github.com/skatkov/devtui/cmd.date=$(DATE)"
12+
FUZZTIME?=5s
13+
NIGHTLY_FUZZTIME?=45s
1214

1315
help: ## Display this help message
1416
@echo "DevTUI - Development targets:"
@@ -33,6 +35,29 @@ test-race: ## Run tests with race detection (what CI uses)
3335
@echo "Running tests with race detection..."
3436
go test -v -failfast -race ./...
3537

38+
fuzz: ## Run fuzz tests (set FUZZTIME=30s for deeper runs)
39+
@echo "Running fuzz tests (FUZZTIME=$(FUZZTIME))..."
40+
go test ./internal/base64 -run=^$$ -fuzz=FuzzBase64RoundTrip -fuzztime=$(FUZZTIME)
41+
go test ./internal/base64 -run=^$$ -fuzz=FuzzDecodeMatchesStdlibBehavior -fuzztime=$(FUZZTIME)
42+
go test ./internal/csv2json -run=^$$ -fuzz=FuzzConvertDoesNotPanic -fuzztime=$(FUZZTIME)
43+
go test ./internal/converter -run=^$$ -fuzz=FuzzYAMLToJSON -fuzztime=$(FUZZTIME)
44+
go test ./internal/converter -run=^$$ -fuzz=FuzzJSONToYAML -fuzztime=$(FUZZTIME)
45+
go test ./internal/converter -run=^$$ -fuzz=FuzzTOMLToJSON -fuzztime=$(FUZZTIME)
46+
go test ./internal/converter -run=^$$ -fuzz=FuzzJSONToTOML -fuzztime=$(FUZZTIME)
47+
go test ./internal/converter -run=^$$ -fuzz=FuzzXMLToJSON -fuzztime=$(FUZZTIME)
48+
go test ./internal/converter -run=^$$ -fuzz=FuzzJSONToXML -fuzztime=$(FUZZTIME)
49+
go test ./internal/converter -run=^$$ -fuzz=FuzzYAMLToTOML -fuzztime=$(FUZZTIME)
50+
go test ./internal/converter -run=^$$ -fuzz=FuzzTOMLToYAML -fuzztime=$(FUZZTIME)
51+
go test ./internal/yamlfmt -run=^$$ -fuzz=FuzzFormatYAML -fuzztime=$(FUZZTIME)
52+
go test ./internal/htmlfmt -run=^$$ -fuzz=FuzzFormatHTML -fuzztime=$(FUZZTIME)
53+
go test ./tui/jsonrepair -run=^$$ -fuzz=FuzzRepairJSONProducesValidJSON -fuzztime=$(FUZZTIME)
54+
go test ./tui/json2toon -run=^$$ -fuzz=FuzzConvertNoPanic -fuzztime=$(FUZZTIME)
55+
go test ./tui/json2toon -run=^$$ -fuzz=FuzzConvertWithOptionsNoPanic -fuzztime=$(FUZZTIME)
56+
57+
fuzz-nightly: ## Run extended fuzz tests (default NIGHTLY_FUZZTIME=45s)
58+
@echo "Running nightly fuzz tests (NIGHTLY_FUZZTIME=$(NIGHTLY_FUZZTIME))..."
59+
$(MAKE) fuzz FUZZTIME=$(NIGHTLY_FUZZTIME)
60+
3661
test-coverage: ## Run tests with coverage report
3762
@echo "Running tests with coverage..."
3863
go test -coverprofile=coverage.txt ./...
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package base64
2+
3+
import (
4+
"bytes"
5+
stdbase64 "encoding/base64"
6+
"strings"
7+
"testing"
8+
)
9+
10+
func FuzzBase64RoundTrip(f *testing.F) {
11+
f.Add([]byte(""))
12+
f.Add([]byte("hello world"))
13+
f.Add([]byte("\x00\x01\x02\xff\xfe"))
14+
f.Add([]byte("ñáéíóú 中文 🚀"))
15+
16+
f.Fuzz(func(t *testing.T, data []byte) {
17+
encoded := Encode(data)
18+
19+
decoded, err := Decode(encoded)
20+
if err != nil {
21+
t.Fatalf("Decode(Encode(data)) returned error: %v", err)
22+
}
23+
24+
if !bytes.Equal(decoded, data) {
25+
t.Fatalf("round-trip mismatch: got %x, want %x", decoded, data)
26+
}
27+
28+
decodedString, err := DecodeToString(encoded)
29+
if err != nil {
30+
t.Fatalf("DecodeToString(Encode(data)) returned error: %v", err)
31+
}
32+
33+
if decodedString != string(data) {
34+
t.Fatalf("string round-trip mismatch: got %q, want %q", decodedString, string(data))
35+
}
36+
})
37+
}
38+
39+
func FuzzDecodeMatchesStdlibBehavior(f *testing.F) {
40+
f.Add("")
41+
f.Add("SGVsbG8=")
42+
f.Add(" SGVsbG8= ")
43+
f.Add("%%%")
44+
f.Add("YWJj\n")
45+
46+
f.Fuzz(func(t *testing.T, input string) {
47+
trimmed := strings.TrimSpace(input)
48+
want, wantErr := stdbase64.StdEncoding.DecodeString(trimmed)
49+
50+
got, err := Decode(input)
51+
if wantErr != nil {
52+
if err == nil {
53+
t.Fatalf("expected error for input %q", input)
54+
}
55+
return
56+
}
57+
58+
if err != nil {
59+
t.Fatalf("unexpected error for input %q: %v", input, err)
60+
}
61+
62+
if !bytes.Equal(got, want) {
63+
t.Fatalf("decoded bytes mismatch for input %q: got %x, want %x", input, got, want)
64+
}
65+
})
66+
}

internal/converter/converter.go

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@ package converter
33
import (
44
"bytes"
55
"encoding/json"
6+
"encoding/xml"
7+
"errors"
68
"fmt"
9+
"io"
710

811
"github.com/clbanning/mxj/v2"
912
"github.com/pelletier/go-toml/v2"
@@ -77,7 +80,13 @@ func JSONToTOML(jsonContent string) (string, error) {
7780
return "", fmt.Errorf("TOML encoding error: %w", err)
7881
}
7982

80-
return buf.String(), nil
83+
tomlContent := buf.String()
84+
var validation any
85+
if err := toml.Unmarshal([]byte(tomlContent), &validation); err != nil {
86+
return "", fmt.Errorf("TOML encoding error: %w", err)
87+
}
88+
89+
return tomlContent, nil
8190
}
8291

8392
func XMLToJSON(xmlContent string) (string, error) {
@@ -105,6 +114,30 @@ func JSONToXML(jsonContent string) (string, error) {
105114
return "", fmt.Errorf("XML encoding error: %w", err)
106115
}
107116

117+
if len(bytes.TrimSpace(xmlBytes)) == 0 {
118+
return "", errors.New("XML encoding error: empty XML output")
119+
}
120+
121+
decoder := xml.NewDecoder(bytes.NewReader(xmlBytes))
122+
seenStart := false
123+
for {
124+
tok, err := decoder.Token()
125+
if err != nil {
126+
if err == io.EOF {
127+
break
128+
}
129+
return "", fmt.Errorf("XML encoding error: %w", err)
130+
}
131+
132+
if _, ok := tok.(xml.StartElement); ok {
133+
seenStart = true
134+
}
135+
}
136+
137+
if !seenStart {
138+
return "", errors.New("XML encoding error: empty XML output")
139+
}
140+
108141
var buf bytes.Buffer
109142
buf.Write(xmlBytes)
110143
return buf.String(), nil
@@ -127,7 +160,13 @@ func YAMLToTOML(yamlContent string) (string, error) {
127160
return "", fmt.Errorf("TOML encoding error: %w", err)
128161
}
129162

130-
return buf.String(), nil
163+
tomlContent := buf.String()
164+
var validation any
165+
if err := toml.Unmarshal([]byte(tomlContent), &validation); err != nil {
166+
return "", fmt.Errorf("TOML encoding error: %w", err)
167+
}
168+
169+
return tomlContent, nil
131170
}
132171

133172
func TOMLToYAML(tomlContent string) (string, error) {

0 commit comments

Comments
 (0)