Skip to main content
  1. Posts/

New Day 10 - Two Fuzzes and More

NewDays glitch-aura-djinn Go fuzzing

The Two-Headed Monster with a 1 and a 0.
The Two-Headed Monster with a 1 and a 0.

In which we make two fuzz tests, and then do some more.


Fuzz Testing
#

I’ve long been a fan of fuzz testing, at least in a network context. A fuzzing engine may not write better tests than I can, but it can churn through variations much more quickly. So let’s try out the fuzz testing built into Go (at least as of version 1.18).

One Way
#

We’ll try fuzzing the map header -> text string -> map header round-trip first. If we get the same header back for any reasonable input, we’ll be confident about our conversions. In order to let the fuzzing engine just do variations on a string, we’ll do a cruder conversion of our struct:

main_test.go
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
// mapHeaderToString formats a MapHeader struct into a string.
//
// mh: The MapHeader struct to be formatted.
// Returns: The formatted string.
func mapHeaderToString(mh MapHeader) string {
  return fmt.Sprintf("%d\n%d\n%d\n%q\n%t\n%t\n%t\n%t\n%q", mh.ZCoord, mh.XOffset, mh.YOffset, mh.ZName,
    mh.AlwaysDark, mh.Outdoor, mh.TownLimits, mh.NoRecall, mh.ForestryLevel)
}

// stringToMapHeader converts a string to a MapHeader struct.
//
// It takes a string as a parameter and returns a MapHeader object and an error.
func stringToMapHeader(s string) (MapHeader, error) {
  mh := MapHeader{XMLName: xml.Name{Space: "", Local: "mapheader"}}
  _, err := fmt.Sscanf(s, "%d\n%d\n%d\n%q\n%t\n%t\n%t\n%t\n%q", &mh.ZCoord, &mh.XOffset, &mh.YOffset, &mh.ZName,
    &mh.AlwaysDark, &mh.Outdoor, &mh.TownLimits, &mh.NoRecall, &mh.ForestryLevel)
  return mh, err
}

Of the strings the fuzzing engine will feed us, only some will yield reasonable values via that Sscanf() call, and even fewer are suitable for the xml library. So we’ll do a lot of skipping in our fuzz target:

main_test.go
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
  skippedTests := 0

  f.Fuzz(func(t *testing.T, orig string) {
    mh, err := stringToMapHeader(orig)
    if err != nil || mh.ZName == "" {
      // Couldn't make a valid MapHeader, or ZName is blank
      skippedTests++
      t.Skip()
    }

    var safeZName bytes.Buffer
    err = xml.EscapeText(&safeZName, []byte(mh.ZName))
    if err != nil || safeZName.String() != mh.ZName {
      // ZName isn't XML-safe
      skippedTests++
      t.Skip()
    }

    var safeForestryLevel bytes.Buffer
    err = xml.EscapeText(&safeForestryLevel, []byte(mh.ForestryLevel))
    // ForestryLevel isn't XML-safe
    if err != nil || safeForestryLevel.String() != mh.ForestryLevel {
      skippedTests++
      t.Skip()
    }

    mapline := mapStyleMapHeader(mh)
    got, err := processXMLHeader("<mapheader> "+mapline+" </mapheader>", MapHeader{})
    if err != nil {
      t.Errorf("processXMLHeader() err = %v", err)
    }
    if !reflect.DeepEqual(got, mh) {
      t.Errorf("processXMLHeader(mapStyleMapHeader()) = %v, want %v", got, mh)
    }
  })

  fmt.Printf("Tests skipped: %d\n", skippedTests)
}

The chances of the engine giving us reasonable values is greatly increased by giving it a good seed corpus to mutate, so that’s the last thing to do:

main_test.go
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
func Fuzz_MapHeaderRoundTrip2(f *testing.F) {
  testcases := []MapHeader{
    {
      XMLName: xml.Name{Space: "", Local: "mapheader"}, ZCoord: 15, XOffset: 10, YOffset: 5, ZName: "zname",
    },
    {
      XMLName: xml.Name{Space: "", Local: "mapheader"}, ZCoord: 15, XOffset: 10, YOffset: 5, ZName: "zname",
      ForestryLevel: "heavy",
    },
    {
      XMLName: xml.Name{Space: "", Local: "mapheader"}, ZCoord: 15, XOffset: 10, YOffset: 5, ZName: "zname",
      AlwaysDark: true, Outdoor: true,
    },
    {
      XMLName: xml.Name{Space: "", Local: "mapheader"}, ZCoord: 15, XOffset: 10, YOffset: 5, ZName: "zname",
      TownLimits: true, NoRecall: true,
    },
    {
      XMLName: xml.Name{Space: "", Local: "mapheader"}, ZCoord: -300, XOffset: 99, YOffset: -500, ZName: "1a2b3c4d",
      AlwaysDark: true, Outdoor: true, TownLimits: true, NoRecall: true,
    },
  }

  for _, tc := range testcases {
    s := mapHeaderToString(tc)
    f.Add(s)
  }

Now the question is: do we get anything useful?

$ go test -fuzz=Fuzz_MapHeaderRoundTrip2 -parallel 5 -test.fuzzcachedir ./testdata/fuzz -v -fuzztime === RUN
...
Fuzz_MapHeaderRoundTrip2
fuzz: elapsed: 0s, gathering baseline coverage: 0/5 completed
fuzz: elapsed: 0s, gathering baseline coverage: 5/5 completed, now fuzzing with 5 workers
fuzz: elapsed: 3s, execs: 125869 (41921/sec), new interesting: 108 (total: 113)
fuzz: elapsed: 6s, execs: 125869 (0/sec), new interesting: 108 (total: 113)
...
fuzz: elapsed: 4m57s, execs: 21898244 (108216/sec), new interesting: 365 (total: 370)
fuzz: elapsed: 5m0s, execs: 22186800 (96181/sec), new interesting: 366 (total: 371)
fuzz: elapsed: 5m0s, execs: 22186800 (0/sec), new interesting: 366 (total: 371)
Tests skipped: 0
--- PASS: Fuzz_MapHeaderRoundTrip2 (300.08s)
=== NAME
PASS
ok      glitch-aura-djinn       300.102s

Hmm, 22 million permutations in five minutes, 366 of which exercised different code paths in one way or another. The output says 0 skipped; it seems to be a quirk of the fuzzing process that my counting of skipped tests didn’t work as I expected. Now that the corpus has been expanded, what does a normal testing cycle show?

$ go test -v | grep "skip"
Tests skipped: 296

296 skipped, about 80% of even those that were deemed interesting. But at least it didn’t take long. I didn’t expect to find bugs in my simple code this way, yet.

The Other Way
#

Now let’s try the test string -> map header -> test string round-trip test.

main_test.go
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
  skippedTests := 0
  goodXML := regexp.MustCompile(`^<z>(0|-?[1-9][0-9]*)</z> <x>(0|-?[1-9][0-9]*)</x> <y>(0|-?[1-9][0-9]*)</y>` +
    `( <f>[^<]+</f>)? <n>[^<]+</n>` +
    `( alwaysdark=true)?` + `( norecall=true)?` + `( outdoor=true)?` + `( townlimits=true)?` +
    `$`)

  f.Fuzz(func(t *testing.T, orig string) {

    if !goodXML.MatchString(orig) {
      skippedTests++
      t.Skip()
    }
    if strings.Contains(orig, "\r") {
      skippedTests++
      t.Skip()
    }
    if strings.Contains(orig, "&") {
      skippedTests++
      t.Skip()
    }

    mapHeader, err := processXMLHeader("<mapheader> "+orig+" </mapheader>", MapHeader{})
    if err != nil {
      skippedTests++
      t.Skip()
    }
    got := mapStyleMapHeader(mapHeader)
    if got != orig+"\n" {
      t.Errorf("mapStyleMapHeader(processXMLHeader()) = %v, want %v", got, orig)
    }
  })

  fmt.Printf("Tests skipped: %d\n", skippedTests)
}

A little experimentation showed that I needed to do some work to verify that the string was in the format required, hence the regex above. Let’s skip if there are carriage returns or ampersands, too. What does that leave us with?

$ go test -fuzz=Fuzz_MapHeaderRoundTrip1 -parallel 5 -test.fuzzcachedir ./testdata/fuzz -v -fuzztime 5m
...
=== RUN   Fuzz_MapHeaderRoundTrip1
fuzz: elapsed: 0s, gathering baseline coverage: 0/4 completed
fuzz: elapsed: 0s, gathering baseline coverage: 4/4 completed, now fuzzing with 5 workers
fuzz: elapsed: 3s, execs: 122494 (40747/sec), new interesting: 34 (total: 38)
fuzz: elapsed: 6s, execs: 122494 (0/sec), new interesting: 34 (total: 38)
fuzz: elapsed: 9s, execs: 122494 (0/sec), new interesting: 34 (total: 38)
...
fuzz: elapsed: 4m57s, execs: 553267 (0/sec), new interesting: 71 (total: 75)
fuzz: elapsed: 5m0s, execs: 553267 (0/sec), new interesting: 71 (total: 75)
fuzz: elapsed: 5m1s, execs: 553267 (0/sec), new interesting: 71 (total: 75)
Tests skipped: 0
--- PASS: Fuzz_MapHeaderRoundTrip1 (301.04s)
=== NAME
PASS
ok      glitch-aura-djinn       301.093s

A lot slower than the first one, probably because of my regex. Let’s give it some more time.

...
fuzz: elapsed: 15m1s, execs: 46494098 (0/sec), new interesting: 61 (total: 136)
$ go test -v | grep "skip"
Tests skipped: 126

Even more skippage, which I guess is understandable.

What if I remove the skipped tests from the corpus? Does that help the fuzzing engine concentrate on more fruitful paths the next time? Time for a quick bash script.

fuzz_and_prune.sh
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
#!/bin/bash

if [ $# -lt 2 ]; then
    echo "Usage: $0 <fuzz test case name> <fuzzing time per iteration>"
    echo "  e.g. $0 Fuzz_MapHeaderRoundTrip1 5m"
    exit 1
fi

while true; do
  go test -v | grep "SKIP: Fuzz_" | awk '{print "testdata/fuzz/" $3}' | xargs -n 1 rm
  go test -fuzz=$1 -test.fuzzcachedir ./testdata/fuzz -v -fuzztime $2
done
$ ./fuzz_and_prune.sh Fuzz_MapHeaderRoundTrip1 5m > fuzz_and_prune.out
...
$ grep "now fuzzing with" fuzz_and_prune.out
fuzz: elapsed: 0s, gathering baseline coverage: 4/4 completed, now fuzzing with 12 workers
fuzz: elapsed: 0s, gathering baseline coverage: 4/4 completed, now fuzzing with 12 workers
fuzz: elapsed: 0s, gathering baseline coverage: 5/5 completed, now fuzzing with 12 workers
fuzz: elapsed: 0s, gathering baseline coverage: 6/6 completed, now fuzzing with 12 workers
fuzz: elapsed: 0s, gathering baseline coverage: 7/7 completed, now fuzzing with 12 workers
fuzz: elapsed: 0s, gathering baseline coverage: 11/11 completed, now fuzzing with 12 workers
fuzz: elapsed: 0s, gathering baseline coverage: 17/17 completed, now fuzzing with 12 workers
fuzz: elapsed: 0s, gathering baseline coverage: 24/24 completed, now fuzzing with 12 workers
fuzz: elapsed: 0s, gathering baseline coverage: 29/29 completed, now fuzzing with 12 workers
fuzz: elapsed: 0s, gathering baseline coverage: 32/32 completed, now fuzzing with 12 workers
fuzz: elapsed: 0s, gathering baseline coverage: 38/38 completed, now fuzzing with 12 workers
fuzz: elapsed: 0s, gathering baseline coverage: 39/39 completed, now fuzzing with 12 workers
fuzz: elapsed: 0s, gathering baseline coverage: 42/42 completed, now fuzzing with 12 workers
fuzz: elapsed: 0s, gathering baseline coverage: 45/45 completed, now fuzzing with 12 workers
fuzz: elapsed: 0s, gathering baseline coverage: 49/49 completed, now fuzzing with 12 workers
fuzz: elapsed: 0s, gathering baseline coverage: 51/51 completed, now fuzzing with 12 workers
fuzz: elapsed: 0s, gathering baseline coverage: 53/53 completed, now fuzzing with 12 workers
fuzz: elapsed: 0s, gathering baseline coverage: 55/55 completed, now fuzzing with 12 workers

Slow progress; not sure it’s any better, and might fence in the fuzzing too much to hit some interesting cases. Might be worth some experiments in future.


Oh, So That’s How It’s Done
#

Fozzy Bear does a fuzzy facepalm.
Fozzy Bear does a fuzzy facepalm.

Here’s where I admit that I had been working off of a few examples and my memory and autocomplete, and had missed something about the fuzz function. Trying to understand the treatment of skipped tests, I took a look at the actual Go testing docs and watched GopherCon 2022: Katie Hockman - Fuzz Testing Made Easy.

From the docs: “ff must be a function with no return value whose first argument is *T and whose remaining arguments are the types to be fuzzed” (emphasis mine). So now I know I can specify multiple types, and not rely on collapsing everything into a single string.

main_test.go
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
  f.Fuzz(func(t *testing.T, zCoord, xOffset, yOffset int, zName, forestryLevel string, alwaysDark, outdoor, townLimits, noRecall bool) {
    mh := MapHeader{XMLName: xml.Name{Space: "", Local: "mapheader"}, ZCoord: zCoord, XOffset: xOffset, YOffset: yOffset,
      ZName: zName, ForestryLevel: forestryLevel, AlwaysDark: alwaysDark, Outdoor: outdoor, TownLimits: townLimits,
      NoRecall: noRecall}

    if mh.ZName == "" {
      // Invalid header, zname is blank.
      skippedTests++
      t.Skip()
    }

    var safeZName bytes.Buffer
    err := xml.EscapeText(&safeZName, []byte(mh.ZName))
    if err != nil || safeZName.String() != mh.ZName {
      // ZName isn't XML-safe
      skippedTests++
      t.Skip()
    }

    var safeForestryLevel bytes.Buffer
    err = xml.EscapeText(&safeForestryLevel, []byte(mh.ForestryLevel))
    // ForestryLevel isn't XML-safe
    if err != nil || safeForestryLevel.String() != mh.ForestryLevel {
      skippedTests++
      t.Skip()
    }

    mapline := mapStyleMapHeader(mh)
    got, err := processXMLHeader("<mapheader> "+mapline+" </mapheader>", MapHeader{})
    if err != nil {
      t.Errorf("processXMLHeader() err = %v", err)
    }
    if !reflect.DeepEqual(got, mh) {
      t.Errorf("processXMLHeader(mapStyleMapHeader()) = %v, want %v", got, mh)
    }
  })

Now we can do a round-trip header -> string -> header, with only an invalid ZName or an invalid ForestryLevel to cause us to skip. Does this work much better?

$ go test -fuzz=Fuzz_MapHeaderRoundTrip2b -parallel 5 -test.fuzzcachedir ./testdata/fuzz -v -f
uzztime 15m
...
fuzz: elapsed: 14m57s, execs: 74661339 (91206/sec), new interesting: 163 (total: 168)
fuzz: elapsed: 15m0s, execs: 74936024 (91553/sec), new interesting: 163 (total: 168)
fuzz: elapsed: 15m0s, execs: 74936024 (0/sec), new interesting: 163 (total: 168)
Tests skipped: 0
--- PASS: Fuzz_MapHeaderRoundTrip2b (900.08s)

$ go test -v | grep "skip"
Tests skipped: 103

Seems a bit better. Certainly makes me feel there’s less time being wasted on string conversions.


And Now the Whole Map
#

How to Draw an Owl: 1. Draw two circles. 2. Draw the rest of the owl.
How to Draw an Owl: 1. Draw two circles. 2. Draw the rest of the owl.

It’s relatively simple to add the rest of the functionality we need to read and recreate the ascii map files. We add a splitMapLevels() to split up the file into the individual levels, then a parseMapLevel() to parse a level into its header and cells, then a mapToString() to form those back into a map level string, and finally a readAndWriteMap() to round-trip a map file string through the whole process. This enables us to add the (cleaned-up) ascii map files to the testdata and write a really fun fuzz test:

main_test.go
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
func Fuzz_readAndWriteMap(f *testing.F) {
  testcases := []string{
    strings.Join([]string{
      `//comment1`, `<z>15</z> <x>10</x> <y>5</y> <n>zname1</n>`, `. `,
      `//comment2`, `<z>20</z> <x>10</x> <y>5</y> <n>zname2</n>`, `[]`,
    }, "\n") + "\n",
    strings.Join([]string{
      `//comment1`, `<z>15</z> <x>10</x> <y>5</y> <n>zname1</n>`,
      `[][][]`,
      `[]. []`,
      `[][][]`,
      `//comment2`, `<z>20</z> <x>10</x> <y>5</y> <n>zname2</n>`,
      `/\/\/\`,
      `/\{}/\`,
      `/\/\/\`,
    }, "\n") + "\n",
    strings.Join([]string{
      `//comment1`, `<z>15</z> <x>10</x> <y>5</y> <n>zname1</n>`,
      `[][][]`,
      `[]. []`,
      `[][][]`,
      `//comment2`, `<z>20</z> <x>10</x> <y>5</y> <n>zname2</n>`,
      `/\/\/\`,
      `/\{}/\`,
      `/\/\/\`,
      `//comment3`, `<z>-100</z> <x>5</x> <y>30</y> <n>zname 3</n>`,
      `/\[]/\`,
      `/\. /\`,
      `/\{}/\`,
    }, "\n") + "\n",
    "Annwn.txt",
    "Axe Glacier.txt",
    "Eridu.txt",
    "Hell.txt",
    "Island of Kesmai.txt",
    "Leng.txt",
    "Oakvael.txt",
    "Praetoseba.txt",
    "Rift Glacier.txt",
    "Shukumei.txt",
    "Torii.txt",
    "Underkingdom.txt",
    "test01.txt",
    "testunder.txt",
  }
  for _, tc := range testcases {
    if strings.HasSuffix(tc, ".txt") {
      buffer, err := os.ReadFile("testdata/maps/" + tc)
      if err != nil {
        tc = err.Error()
      } else {
        tc = strings.ReplaceAll(string(buffer), "\r", "\n")
        tc = strings.ReplaceAll(tc, "\n\n", "\n")
      }
    }
    f.Add(tc) // Use f.Add to provide a seed corpus
  }
  skippedTests := 0
  f.Fuzz(func(t *testing.T, orig string) {
    if shouldSkip(orig) {
      // fmt.Printf("Skipping %s\n", orig)
      skippedTests++
      t.Skip()
    }
    // fmt.Printf("Not skipped: %q\n", orig)
    written := readAndWriteMap(orig)
    if orig != written {
      t.Errorf("Before: %q, after: %q", orig, written)
    }
  })
  fmt.Printf("Tests skipped: %d\n", skippedTests)
}

The first few test cases look normal: simple variations on map file strings that are expected to come back the same after the round trip. Then we see all of the map filenames listed; while looping through the seed test cases, we read those files and swap in the contents for the strings. Instead of writing a bunch more test cases to try to cover everything, we can be pretty comfortable with the code that returns all of those map files correctly. And it should offer a much more useful corpus for the fuzzing engine to start with.

Of course, there are a bunch of reasons that a fuzzed string coming into the test case is invalid and can be skipped; those have been pushed into a separate shouldSkip() function. Every time the fuzzing created a failure that was an invalid map file string, I added to that function, so it’s quite large now.

Further fuzzing didn’t reveal any interesting bugs, but now we can feel comfortable adding more functionality. But first:

High-Level Tests in Bash
#

With the help of a main() function that simply reads a map file specified on the command line, pushes it through readAndWriteMap() (after replacing the line endings), and send it to stdout:

main.go
41
42
43
44
45
46
47
48
49
50
51
52
53
54
func main() {
  inputFilePath := os.Args[1]

  buffer, err := readFileToString(inputFilePath)
  if err != nil {
    panic(err)
  }

  // Convert CRs to newlines
  buffer = strings.ReplaceAll(buffer, "\r", "\n")

  result := readAndWriteMap(buffer)
  fmt.Print(result)
}

…we can write a quick bash script to churn through all of the map files and verify that they come out the same as they went in:

check_maps.sh
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
#!/bin/bash

for file in ./testdata/maps/*.txt; do
    # Run the Go program on each map file
    go run main.go "$file" > "$file.out"

    # Compare the result to the original file
    diff --strip-trailing-cr "$file" "$file.out"

    # Remove the .out file
    rm "$file.out"
done

…and add that to the GitLab CI definition:

.gitlab-ci.yml
15
16
17
18
19
20
21
22
23
24
25
26
27
28
test:
  stage: test
  image: golang:1.20
  artifacts:
    when: always
    reports:
      junit: report.xml
  script:
    - go get github.com/jstemmer/go-junit-report/v2
    - go install github.com/jstemmer/go-junit-report/v2
    - go test -cover -v ./... -coverprofile=unit.coverprofile -tags=unit \
      | $GOPATH/bin/go-junit-report -iocopy -set-exit-code -out report.xml
    - ./check_maps.sh
  coverage: '/coverage: \d+.\d+% of statements/'

Whew. So now we have multiple levels of testing the lean on as we evolve this code. Tomorrow.


One More Thing
#

Asking Codeium to clean up my code.
Asking Codeium to clean up my code.

While working on this project, I’ve started utilizing the Codeium LLM for autocomplete and doc/cleanup suggestions via their VSCode plugin. So far it’s been a bit more useful than some of the other LLMs I’ve tried; it comes up with a lot fewer, shall we say, fiction-based recommendations. Where I need a lot of boilerplate with minor variations, it does speed things up a bit; and when it’s time to refactor, it has enough context to be helpful there too.

Worth a try, I’d say. Although, as always, be careful with any closed-source code you feed to someone else’s services. And some people have concerns about the opaque language server that it runs locally.


More to come
More to come

gitch-aura-djinn New Day 10 Code