Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .github/workflows/fuzz-nightly.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
name: nightly-fuzz

on:
schedule:
- cron: "17 2 * * *"
workflow_dispatch:

permissions:
contents: read

jobs:
fuzz:
name: nightly-fuzz
runs-on: ubuntu-latest
timeout-minutes: 90
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version-file: "go.mod"
cache: true

- name: Run nightly fuzz suite
run: make fuzz-nightly NIGHTLY_FUZZTIME=45s

- name: Upload failing fuzz corpus
if: failure()
uses: actions/upload-artifact@v4
with:
name: nightly-fuzz-corpus
path: |
**/testdata/fuzz/**
if-no-files-found: ignore
27 changes: 26 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: help build run test test-race lint clean deps tidy docs site release snapshot install
.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

# Default target
.DEFAULT_GOAL := help
Expand All @@ -9,6 +9,8 @@ VERSION?=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
COMMIT?=$(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
DATE?=$(shell date -u +"%Y-%m-%dT%H:%M:%SZ")
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)"
FUZZTIME?=5s
NIGHTLY_FUZZTIME?=45s

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

fuzz: ## Run fuzz tests (set FUZZTIME=30s for deeper runs)
@echo "Running fuzz tests (FUZZTIME=$(FUZZTIME))..."
go test ./internal/base64 -run=^$$ -fuzz=FuzzBase64RoundTrip -fuzztime=$(FUZZTIME)
go test ./internal/base64 -run=^$$ -fuzz=FuzzDecodeMatchesStdlibBehavior -fuzztime=$(FUZZTIME)
go test ./internal/csv2json -run=^$$ -fuzz=FuzzConvertDoesNotPanic -fuzztime=$(FUZZTIME)
go test ./internal/converter -run=^$$ -fuzz=FuzzYAMLToJSON -fuzztime=$(FUZZTIME)
go test ./internal/converter -run=^$$ -fuzz=FuzzJSONToYAML -fuzztime=$(FUZZTIME)
go test ./internal/converter -run=^$$ -fuzz=FuzzTOMLToJSON -fuzztime=$(FUZZTIME)
go test ./internal/converter -run=^$$ -fuzz=FuzzJSONToTOML -fuzztime=$(FUZZTIME)
go test ./internal/converter -run=^$$ -fuzz=FuzzXMLToJSON -fuzztime=$(FUZZTIME)
go test ./internal/converter -run=^$$ -fuzz=FuzzJSONToXML -fuzztime=$(FUZZTIME)
go test ./internal/converter -run=^$$ -fuzz=FuzzYAMLToTOML -fuzztime=$(FUZZTIME)
go test ./internal/converter -run=^$$ -fuzz=FuzzTOMLToYAML -fuzztime=$(FUZZTIME)
go test ./internal/yamlfmt -run=^$$ -fuzz=FuzzFormatYAML -fuzztime=$(FUZZTIME)
go test ./internal/htmlfmt -run=^$$ -fuzz=FuzzFormatHTML -fuzztime=$(FUZZTIME)
go test ./tui/jsonrepair -run=^$$ -fuzz=FuzzRepairJSONProducesValidJSON -fuzztime=$(FUZZTIME)
go test ./tui/json2toon -run=^$$ -fuzz=FuzzConvertNoPanic -fuzztime=$(FUZZTIME)
go test ./tui/json2toon -run=^$$ -fuzz=FuzzConvertWithOptionsNoPanic -fuzztime=$(FUZZTIME)

fuzz-nightly: ## Run extended fuzz tests (default NIGHTLY_FUZZTIME=45s)
@echo "Running nightly fuzz tests (NIGHTLY_FUZZTIME=$(NIGHTLY_FUZZTIME))..."
$(MAKE) fuzz FUZZTIME=$(NIGHTLY_FUZZTIME)

test-coverage: ## Run tests with coverage report
@echo "Running tests with coverage..."
go test -coverprofile=coverage.txt ./...
Expand Down
94 changes: 94 additions & 0 deletions internal/base64/base64_fuzz_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package base64

import (
"bytes"
stdbase64 "encoding/base64"
"os"
"path/filepath"
"strings"
"testing"
)

const fuzzSeedDir = "../../testdata"

func addByteSeedsFromFiles(f *testing.F, fileNames ...string) {
for _, fileName := range fileNames {
content, err := os.ReadFile(filepath.Join(fuzzSeedDir, fileName))
if err != nil {
continue
}

f.Add(content)
}
}

func addStringSeedsFromFiles(f *testing.F, fileNames ...string) {
for _, fileName := range fileNames {
content, err := os.ReadFile(filepath.Join(fuzzSeedDir, fileName))
if err != nil {
continue
}

f.Add(string(content))
}
}

func FuzzBase64RoundTrip(f *testing.F) {
f.Add([]byte(""))
f.Add([]byte("hello world"))
f.Add([]byte("\x00\x01\x02\xff\xfe"))
f.Add([]byte("ñáéíóú 中文 🚀"))
addByteSeedsFromFiles(f, "sample.txt", "json.txt", "binary.txt", "example.json", "example.yaml")

f.Fuzz(func(t *testing.T, data []byte) {
encoded := Encode(data)

decoded, err := Decode(encoded)
if err != nil {
t.Fatalf("Decode(Encode(data)) returned error: %v", err)
}

if !bytes.Equal(decoded, data) {
t.Fatalf("round-trip mismatch: got %x, want %x", decoded, data)
}

decodedString, err := DecodeToString(encoded)
if err != nil {
t.Fatalf("DecodeToString(Encode(data)) returned error: %v", err)
}

if decodedString != string(data) {
t.Fatalf("string round-trip mismatch: got %q, want %q", decodedString, string(data))
}
})
}

func FuzzDecodeMatchesStdlibBehavior(f *testing.F) {
f.Add("")
f.Add("SGVsbG8=")
f.Add(" SGVsbG8= ")
f.Add("%%%")
f.Add("YWJj\n")
addStringSeedsFromFiles(f, "sample.base64", "json.base64", "binary.base64", "invalid.base64")

f.Fuzz(func(t *testing.T, input string) {
trimmed := strings.TrimSpace(input)
want, wantErr := stdbase64.StdEncoding.DecodeString(trimmed)

got, err := Decode(input)
if wantErr != nil {
if err == nil {
t.Fatalf("expected error for input %q", input)
}
return
}

if err != nil {
t.Fatalf("unexpected error for input %q: %v", input, err)
}

if !bytes.Equal(got, want) {
t.Fatalf("decoded bytes mismatch for input %q: got %x, want %x", input, got, want)
}
})
}
43 changes: 41 additions & 2 deletions internal/converter/converter.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ package converter
import (
"bytes"
"encoding/json"
"encoding/xml"
"errors"
"fmt"
"io"

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

return buf.String(), nil
tomlContent := buf.String()
var validation any
if err := toml.Unmarshal([]byte(tomlContent), &validation); err != nil {
return "", fmt.Errorf("TOML encoding error: %w", err)
}

return tomlContent, nil
}

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

if len(bytes.TrimSpace(xmlBytes)) == 0 {
return "", errors.New("XML encoding error: empty XML output")
}

decoder := xml.NewDecoder(bytes.NewReader(xmlBytes))
seenStart := false
for {
tok, err := decoder.Token()
if err != nil {
if err == io.EOF {
break
}
return "", fmt.Errorf("XML encoding error: %w", err)
}

if _, ok := tok.(xml.StartElement); ok {
seenStart = true
}
}

if !seenStart {
return "", errors.New("XML encoding error: empty XML output")
}

var buf bytes.Buffer
buf.Write(xmlBytes)
return buf.String(), nil
Expand All @@ -127,7 +160,13 @@ func YAMLToTOML(yamlContent string) (string, error) {
return "", fmt.Errorf("TOML encoding error: %w", err)
}

return buf.String(), nil
tomlContent := buf.String()
var validation any
if err := toml.Unmarshal([]byte(tomlContent), &validation); err != nil {
return "", fmt.Errorf("TOML encoding error: %w", err)
}

return tomlContent, nil
}

func TOMLToYAML(tomlContent string) (string, error) {
Expand Down
Loading