diff --git a/README.md b/README.md index a2a38cc..95377cb 100644 --- a/README.md +++ b/README.md @@ -67,3 +67,23 @@ Flatty does not do compression it self but I recommend using the excellent https ## Networking The `flatty` + `supersnappy` + `netty` were originally made to be used together. [Netty](https://github.com/treeform/netty) is a great for UDP networking for games. + +## Untrusted input and hardening + +Flatty is often used to decode data that arrives over the network, so `fromFlatty` is built to never crash the process on a malformed or hostile blob. A bad blob fails with a **catchable** error instead of a segfault, an out-of-memory abort, or a stack overflow: + +* Every read is bounds-checked against the buffer, so truncated blobs can't over-read (this stays on even under `-d:danger`). +* Length and element-count prefixes are validated against the bytes remaining before anything is allocated, so a bogus length can't drive a huge allocation. +* Enum discriminators are range-checked before an object variant is built, so an out-of-range tag can't corrupt a variant or segfault. +* `Table`/`HashSet` preallocation from an untrusted count is capped, so a small blob can't force a huge hash-table allocation. +* A stack-pointer watermark stops deeply nested input (a long `ref`/`seq` chain) before it overflows the thread stack. + +Decode failures raise `FlattyError` (bad length, count, or enum) or `IndexDefect` (truncation). Wrap the decode and handle both: + +```nim +try: + let msg = data.fromFlatty(Message) + ... +except CatchableError, Defect: + discard # drop the blob / disconnect the peer +``` diff --git a/src/flatty.nim b/src/flatty.nim index beddd97..6a04f67 100644 --- a/src/flatty.nim +++ b/src/flatty.nim @@ -1,6 +1,6 @@ ## Convert any Nim objects, numbers, strings, refs to and from binary format. import - std/[importutils, tables, typetraits, sets], + std/[importutils, macros, tables, typetraits, sets], flatty/objvar when defined(js): @@ -12,6 +12,111 @@ else: type SomeTable*[K, V] = Table[K, V] | OrderedTable[K, V] type SomeSet[A] = set[A] | HashSet[A] | OrderedSet[A] +type FlattyError* = object of CatchableError + ## Raised when decoding malformed input: a negative or oversized length / + ## element count, or an undefined enum discriminator (out of range or a + ## hole in a discontinuous enum). Truncated reads raise IndexDefect from + ## the binny layer; catch both to fully contain a hostile payload. + +const flattyPreallocCap = 4096 + ## Upper bound on how many slots a Table/Set decode will preallocate from an + ## untrusted element count. This makes the preallocation a bounded constant + ## (~a few hundred KB, freed on error) regardless of the claimed count, so a + ## small payload can't force a huge hash-table allocation, while still + ## preallocating exactly for the common case of tables up to this size. + ## Larger containers grow organically as their real entries decode. + +template checkCount(s: string, i, count, elemSize: int) = + ## Reject a length/count prefix that cannot possibly be backed by the + ## bytes remaining in the buffer. Every value flatty encodes occupies at + ## least one byte, so a container can never hold more elements than there + ## are bytes left. Phrased to avoid overflow on a hostile `count`. + ## + ## NOTE: types that serialize to zero bytes (e.g. an empty object with no + ## fields) violate the >=1-byte assumption; a very large seq of those will + ## be rejected. That is a deliberate trade for a bound that needs no + ## configuration. + if count < 0 or (elemSize > 0 and count > (s.len - i) div elemSize): + raise newException( + FlattyError, + "flatty: element count " & $count & " exceeds " & $(s.len - i) & + " bytes remaining" + ) + +# Deeply nested input (a long ref/seq chain) would otherwise recurse until the +# thread stack overflows -- an uncatchable crash that no length check stops. +# Rather than thread a depth counter through every call (and unwind it on the +# way back up), we watch the actual stack pointer: the hardware stack already +# counts depth for us, returns need no bookkeeping, and sibling recursion +# self-corrects. We bail a fixed margin before the thread's real stack end, +# queried once from the OS so the guard adapts to frame size, build mode, and +# per-thread stack size. +when not defined(js): + const flattyStackMargin = 128 * 1024 + ## Stop recursing this many bytes before the true end of the stack, so the + ## unwinding `raise` itself has room to run. + + var flattyStackLimit {.threadvar.}: uint + ## Lowest safe stack address for this thread; recursion bails once the + ## stack pointer drops below it. 0 means "not yet computed" -> guard off. + + proc flattyComputeStackLimit(): uint = + ## Lowest safe address = (far end of this thread's stack) + margin. + when defined(windows): + proc getCurrentThreadStackLimits(lowLimit, highLimit: ptr uint) {. + importc: "GetCurrentThreadStackLimits", stdcall, dynlib: "kernel32".} + var lo, hi: uint + getCurrentThreadStackLimits(addr lo, addr hi) + lo + flattyStackMargin + elif defined(macosx): + proc pthread_self(): pointer {.importc, header: "".} + proc pthread_get_stackaddr_np(t: pointer): pointer {. + importc, header: "".} + proc pthread_get_stacksize_np(t: pointer): culong {. + importc, header: "".} + let t = pthread_self() + # stackaddr_np is the base (highest address); the stack grows down. + let base = cast[uint](pthread_get_stackaddr_np(t)) + let size = pthread_get_stacksize_np(t).uint + base - size + flattyStackMargin + elif defined(posix): + type PthreadAttr {.importc: "pthread_attr_t", header: "", + bycopy.} = object + abi: array[64, uint8] # opaque; sized generously + proc pthread_self(): culong {.importc, header: "".} + proc pthread_getattr_np(t: culong, a: ptr PthreadAttr): cint {. + importc, header: "".} + proc pthread_attr_getstack(a: ptr PthreadAttr, stackaddr: ptr pointer, + stacksize: ptr culong): cint {.importc, header: "".} + proc pthread_attr_destroy(a: ptr PthreadAttr): cint {. + importc, header: "".} + var a: PthreadAttr + if pthread_getattr_np(pthread_self(), addr a) != 0: + return 0 # can't tell; leave the guard off rather than false-trip + var lo: pointer + var size: culong + discard pthread_attr_getstack(addr a, addr lo, addr size) + discard pthread_attr_destroy(addr a) + # getstack returns the lowest address directly. + cast[uint](lo) + flattyStackMargin + else: + 0 # unknown platform: guard disabled + + template flattyInitStackGuard() = + flattyStackLimit = flattyComputeStackLimit() + + template flattyStackGuard() = + ## One-line guard at each recursive descent. A cycle in a Nim type must + ## pass through a heap indirection (ref/seq/Table/HashSet) every loop, so + ## guarding only those procs catches all unbounded recursion while leaving + ## the common object/tuple/array paths untouched. + var probe {.volatile.}: int + if flattyStackLimit != 0'u and cast[uint](addr probe) < flattyStackLimit: + raise newException(FlattyError, "flatty: input nesting too deep") +else: + template flattyInitStackGuard() = discard + template flattyStackGuard() = discard + when defined(flatty32) and defined(flatty64): {.error: "flatty32 and flatty64 cannot both be defined".} @@ -298,9 +403,44 @@ proc fromFlatty*[T: range](s: string, i: var int, x: var T) = proc toFlatty*[T: enum and not range](s: var string, x: T) = s.addInt64(x.int) +macro flattyEnumOrds(T: typedesc[enum]): untyped = + ## Defined ordinals of `T` as a compile-time `array` of `int64`. + ## Only needed for holey enums, where `low..high` is not a valid + ## membership test (and `items` / `succ` are unavailable). + result = nnkBracket.newTree() + let impl = getTypeImpl(T.getType[1]) + for c in impl: + if c.kind == nnkSym: + result.add newCall(bindSym"int64", newCall(bindSym"ord", c)) + proc fromFlatty*[T: enum and not range](s: string, i: var int, x: var T) = - x = cast[T](s.readInt64(i)) + let value = s.readInt64(i) i += 8 + # An undefined discriminator is the dangerous case: for an object variant + # it reaches `new(x, discriminator)` with a bad ordinal and can segfault + # or silently take the wrong branch under -d:danger. + when T is Ordinal: + # Contiguous enum: every value in low..high is defined, so a range + # check is exact and cheaper than scanning the ordinal list. + if value < low(T).int64 or value > high(T).int64: + raise newException( + FlattyError, + "flatty: enum value " & $value & " out of range for " & $T + ) + else: + # Holey enum: low..high includes gaps; require a defined ordinal. + const ords = flattyEnumOrds(T) + var ok = false + for o in ords: + if o == value: + ok = true + break + if not ok: + raise newException( + FlattyError, + "flatty: enum value " & $value & " out of range for " & $T + ) + x = cast[T](value) # Strings proc toFlatty*(s: var string, x: string) = @@ -309,6 +449,7 @@ proc toFlatty*(s: var string, x: string) = proc fromFlatty*(s: string, i: var int, x: var string) = let len = s.readFlattyInt(i) + s.checkCount(i, len, 1) when defined(js): x = s[i ..< i + len] else: @@ -335,6 +476,7 @@ proc toFlatty*[T](s: var string, x: seq[T]) = proc fromFlatty*[T](s: string, i: var int, x: var seq[T]) = let len = s.readFlattyInt(i) when not defined(js) and T.copyable: + s.checkCount(i, len, sizeof(T)) when declared(setLenUninit): x.setLenUninit(len) else: @@ -343,6 +485,10 @@ proc fromFlatty*[T](s: string, i: var int, x: var seq[T]) = copyMem(x[0].addr, s[i].unsafeAddr, len * sizeof(T)) i += sizeof(T) * len else: + # Element-wise types occupy at least one byte each; bound the count by + # the bytes remaining so a bogus length can't drive a huge setLen. + flattyStackGuard() + s.checkCount(i, len, 1) x.setLen(len) for j in x.mitems: s.fromFlatty(i, j) @@ -394,13 +540,21 @@ proc toTableLike[T](s: var string, K: type, V: type, x: T) {.inline.} = proc fromTableLike[T]( s: string, i: var int, K: type, V: type, x: var T ) {.inline.} = + flattyStackGuard() let len = s.readFlattyInt(i) + # `len` is bounded by the bytes remaining, but a hash slot is far larger + # than one byte, so preallocating `len` slots from an untrusted count is a + # memory-amplification DoS (a ~0.5MB payload can force tens of MB). Clamp + # the preallocation hint; a genuinely large table just grows as its real, + # byte-backed entries are decoded below. + s.checkCount(i, len, 1) + let prealloc = min(len, flattyPreallocCap) when T is Table[K, V]: - x = initTable[K, V](len) + x = initTable[K, V](prealloc) elif T is OrderedTable[K, V]: - x = initOrderedTable[K, V](len) + x = initOrderedTable[K, V](prealloc) elif T is CountTable[K]: - x = initCountTable[K](len) + x = initCountTable[K](prealloc) for _ in 0 ..< len: var k: K @@ -437,6 +591,13 @@ proc toFlatty*[N, T](s: var string, x: array[N, T]) = proc fromFlatty*[N, T](s: string, i: var int, x: var array[N, T]) = when not defined(js) and T.copyable: if x.len > 0: + # Array length is fixed, but the buffer may be short of sizeof(x). + if i < 0 or sizeof(x) > s.len - i: + raise newException( + FlattyError, + "flatty: array needs " & $sizeof(x) & " bytes, " & + $(s.len - i) & " remaining" + ) copyMem(x[low(x)].addr, s[i].unsafeAddr, sizeof(x)) i += sizeof(x) else: @@ -460,6 +621,7 @@ proc toFlatty*[T](s: var string, x: ref T) = s.toFlatty(x[]) proc fromFlatty*[T](s: string, i: var int, x: var ref T) = + flattyStackGuard() let isNil = s.readUint8(i).bool i += 1 if not isNil: @@ -473,11 +635,15 @@ proc toFlatty*[T](s: var string, x: SomeSet[T]) = s.toFlatty(e) proc fromFlatty*[T](s: string, i: var int, x: var SomeSet[T]) = + flattyStackGuard() let len = s.readFlattyInt(i) + # Clamp the preallocation hint; see fromTableLike for the amplification. + s.checkCount(i, len, 1) + let prealloc = min(len, flattyPreallocCap) when x is HashSet[T]: - x = initHashSet[T](len) + x = initHashSet[T](prealloc) elif x is OrderedSet[T]: - x = initOrderedSet[T](len) + x = initOrderedSet[T](prealloc) for j in 0 ..< len: var e: T s.fromFlatty(i, e) @@ -490,5 +656,6 @@ proc toFlatty*[T](x: T): string = proc fromFlatty*[T](s: string, x: typedesc[T]): T = ## Takes binary string and turn into structures. + flattyInitStackGuard() var i = 0 s.fromFlatty(i, result) diff --git a/src/flatty/binny.nim b/src/flatty/binny.nim index fe24e46..2fe4bc2 100644 --- a/src/flatty/binny.nim +++ b/src/flatty/binny.nim @@ -5,7 +5,21 @@ when cpuEndian != littleEndian: type Buffer = string | seq[uint8] +template boundsCheck(s: Buffer, i, n: int) = + ## Guard a read of `n` bytes starting at `i` against the buffer length. + ## Untrusted input reaches these reads, so this stays on even under + ## -d:danger (where the compiler's own [] bounds checks are elided). + ## Written as `i > s.len - n` so a hostile `n` near high(int) can't + ## overflow `i + n`. + if i < 0 or n < 0 or i > s.len - n: + raise newException( + IndexDefect, + "flatty: read of " & $n & " bytes at " & $i & + " exceeds buffer length " & $s.len + ) + func readUint8*(s: Buffer, i: int): uint8 {.inline.} = + boundsCheck(s, i, 1) cast[uint8](s[i]) func writeUint8*(s: var Buffer, i: int, v: uint8) {.inline.} = @@ -15,6 +29,7 @@ func addUint8*(s: var Buffer, v: uint8) {.inline.} = s.add v.char func readUint16*(s: Buffer, i: int): uint16 {.inline.} = + boundsCheck(s, i, 2) copyMem(result.addr, s[i].unsafeAddr, 2) func writeUint16*(s: var Buffer, i: int, v: uint16) {.inline.} = @@ -25,6 +40,7 @@ func addUint16*(s: var Buffer, v: uint16) {.inline.} = copyMem(s[s.len - sizeof(v)].addr, v.unsafeAddr, sizeof(v)) func readUint32*(s: Buffer, i: int): uint32 {.inline.} = + boundsCheck(s, i, 4) copyMem(result.addr, s[i].unsafeAddr, 4) func writeUint32*(s: var Buffer, i: int, v: uint32) {.inline.} = @@ -35,6 +51,7 @@ func addUint32*(s: var Buffer, v: uint32) {.inline.} = copyMem(s[s.len - sizeof(v)].addr, v.unsafeAddr, sizeof(v)) func readUint64*(s: Buffer, i: int): uint64 {.inline.} = + boundsCheck(s, i, 8) copyMem(result.addr, s[i].unsafeAddr, 8) func writeUint64*(s: var Buffer, i: int, v: uint64) {.inline.} = diff --git a/tests/fuzz.nim b/tests/fuzz.nim new file mode 100644 index 0000000..7f9e3ce --- /dev/null +++ b/tests/fuzz.nim @@ -0,0 +1,381 @@ +## Fuzz tests for the flatty decode path (`fromFlatty`). +## +## Goal: decoding untrusted / corrupt bytes must never take the process down. +## An acceptable outcome for a bad input is a *catchable* error +## (CatchableError or Defect). An UNacceptable outcome is a hard abort the +## caller cannot recover from: an out-of-memory `quit` (typically from a +## bogus length prefix driving a huge allocation) or a SIGSEGV (typically +## from building an object variant with an out-of-range discriminator). +## +## Because those bad outcomes kill the process, we can't just loop in-process: +## the first hard abort would end the run with no idea which input caused it. +## So this harness runs the actual decoding in a child process that writes the +## current input to a checkpoint file before each attempt. When the child +## aborts, the parent reads the checkpoint to recover the exact reproducer, +## records it, and resumes the child just past that input. Everything is +## driven by a seeded PRNG, so every reproducer replays deterministically. +## +## Run from the repo root: nim r tests/fuzz.nim +## Replay one input by hand: nim r tests/fuzz.nim --replay +## (the hex for each finding is printed next to its CRASH line) + +import + std/[os, osproc, random, strutils, tables], + flatty, flatty/binny + +# Types under fuzz. These mirror the shapes real protocols build on flatty: +# scalars, length-prefixed strings/seqs, tables, an object variant (the +# ClientPacket shape), and a recursive ref graph. + +type + FuzzKind = enum fkA, fkB, fkC, fkD + FuzzVariant = ref object + case kind: FuzzKind + of fkA: + n: int + s: string + of fkB: + xs: seq[int] + of fkC: + discard + of fkD: + t: Table[string, int] + + # Holey enum: defined ordinals 0, 2, 5. Values 1, 3, 4 sit in holes -- + # low..high range checks accept them, then `new(x, disc)` segfaults. + HoleyKind = enum hkA = 0, hkB = 2, hkC = 5 + HoleyVariant = ref object + case kind: HoleyKind + of hkA: + n: int + of hkB: + s: string + of hkC: + xs: seq[int] + + Nested = ref object + id: int + name: string + kids: seq[Nested] + +# The set of types we fuzz, addressed by name on the command line. +const FuzzTypes = [ + "int", "string", "seqint", "seqstr", "table", "variant", "holey", "nested" +] + +# Encoding helpers for hand-built adversarial inputs. + +proc i64(v: int64): string = result.addInt64(v) + +proc toHex(s: string): string = + for c in s: result.add toHex(c.ord, 2) + +proc fromHex(h: string): string = + var i = 0 + while i + 1 < h.len: + result.add chr(parseHexInt(h[i .. i+1])) + i += 2 + +# Deterministic "corner" inputs per type: the specific byte patterns most +# likely to trip an unchecked decoder. These run first, before random fuzz. +proc corners(typ: string): seq[string] = + case typ + of "int": + @["", "\x00", i64(1)[0 ..< 3]] # empty / short reads + of "string", "seqstr", "seqint", "table": + @[ + "", + i64(-1), # negative length prefix + i64(-100), + i64(0x7fffffffffffffff), # enormous length prefix + i64(0x0fffffffffffffff), + i64(1_000_000_000), # 1e9 elements/bytes + i64(5) & "ab", # length says 5, 2 present + ] + of "variant": + @[ + "", + i64(-1), # negative discriminator + i64(9999), # out-of-range discriminator + i64(int(high(FuzzKind)) + 1), # just past the enum + i64(int(fkA)), # valid tag, missing fields + ] + of "holey": + # HoleyVariant is a ref, so byte 0 is the nil flag (0 = present). Without + # that leading 0 the decoder returns nil and never touches the disc. + @[ + "", + "\x00", # non-nil, missing disc + "\x00" & i64(-1), # negative discriminator + "\x00" & i64(9999), # past high(HoleyKind) + "\x00" & i64(1), # hole between hkA and hkB + "\x00" & i64(3), # hole between hkB and hkC + "\x00" & i64(4), # hole between hkB and hkC + "\x00" & i64(int(hkA)), # valid tag, missing fields + "\x00" & i64(int(hkB)), + "\x00" & i64(int(hkC)), + ] + of "nested": + @[ + "", + "\x00", # isNil byte only + "\x01" & i64(5), # non-nil, then truncated + "\x01" & i64(0) & i64(0) & i64(-1), # bad kids length + ] + else: + @[] + +# Random input generation. A mix of pure-random bytes and mutations of a +# valid encoding (truncate / bitflip / extend) -- mutations reach deeper into +# the decoder than pure noise, which usually dies on the first length read. + +proc randBytes(r: var Rand, n: int): string = + for _ in 0 ..< n: result.add chr(r.rand(255)) + +proc genValid(r: var Rand, typ: string): string = + ## A well-formed encoding of a random value of `typ`. + case typ + of "int": + r.rand(int.high).toFlatty + of "string": + r.randBytes(r.rand(20)).toFlatty + of "seqint": + var xs: seq[int] + for _ in 0 ..< r.rand(8): xs.add r.rand(1000) + xs.toFlatty + of "seqstr": + var xs: seq[string] + for _ in 0 ..< r.rand(6): xs.add r.randBytes(r.rand(8)) + xs.toFlatty + of "table": + var t: Table[string, int] + for _ in 0 ..< r.rand(6): t[r.randBytes(1 + r.rand(5))] = r.rand(1000) + t.toFlatty + of "variant": + let k = FuzzKind(r.rand(int(high(FuzzKind)))) + var v: FuzzVariant + case k + of fkA: v = FuzzVariant(kind: fkA, n: r.rand(1000), s: r.randBytes(r.rand(8))) + of fkB: + var xs: seq[int] + for _ in 0 ..< r.rand(6): xs.add r.rand(1000) + v = FuzzVariant(kind: fkB, xs: xs) + of fkC: v = FuzzVariant(kind: fkC) + of fkD: + var t: Table[string, int] + for _ in 0 ..< r.rand(4): t[r.randBytes(1 + r.rand(4))] = r.rand(100) + v = FuzzVariant(kind: fkD, t: t) + v.toFlatty + of "holey": + # Pick only defined ordinals so genValid stays well-formed. + let defined = [hkA, hkB, hkC] + let k = defined[r.rand(defined.len - 1)] + var v: HoleyVariant + case k + of hkA: v = HoleyVariant(kind: hkA, n: r.rand(1000)) + of hkB: v = HoleyVariant(kind: hkB, s: r.randBytes(r.rand(8))) + of hkC: + var xs: seq[int] + for _ in 0 ..< r.rand(6): xs.add r.rand(1000) + v = HoleyVariant(kind: hkC, xs: xs) + v.toFlatty + of "nested": + proc gen(r: var Rand, depth: int): Nested = + result = Nested(id: r.rand(1000), name: r.randBytes(r.rand(6))) + if depth > 0: + for _ in 0 ..< r.rand(3): result.kids.add gen(r, depth - 1) + gen(r, 2).toFlatty + else: + "" + +proc mutate(r: var Rand, valid: string): string = + ## Corrupt a valid encoding in a random way. + case r.rand(3) + of 0: # truncate + result = if valid.len == 0: "" else: valid[0 ..< r.rand(valid.len)] + of 1: # bit-flips + result = valid + if result.len > 0: + for _ in 0 ..< (1 + r.rand(3)): + let idx = r.rand(result.len - 1) + result[idx] = chr(result[idx].ord xor (1 shl r.rand(7))) + else: # extend with noise + result = valid & r.randBytes(1 + r.rand(16)) + +proc genInput(r: var Rand, typ: string, idx, cornerCount: int): string = + ## Deterministic input for iteration `idx`. Always advances `r` for the + ## random range so that resume-after-crash stays aligned. + if idx < cornerCount: + corners(typ)[idx] # no RNG draw + elif r.rand(3) == 0: + r.randBytes(r.rand(40)) # pure noise + else: + r.mutate(genValid(r, typ)) # mutated valid + +# The decode step. No try/except around the actual crash-prone call inside +# the child's "attempt" -- we want the real OS-level outcome. Catchable errors +# are caught and counted; hard aborts kill the child and are caught by the +# parent via the exit code. + +proc decodeAs(typ, data: string) = + ## Decode `data` as `typ`. Mirrors `fromFlatty` exactly; may raise (caught + ## by caller) or hard-abort (kills the process). + case typ + of "int": discard data.fromFlatty(int) + of "string": discard data.fromFlatty(string) + of "seqint": discard data.fromFlatty(seq[int]) + of "seqstr": discard data.fromFlatty(seq[string]) + of "table": discard data.fromFlatty(Table[string, int]) + of "variant": + let v = data.fromFlatty(FuzzVariant) + if v != nil: discard ord(v.kind) # touch the discriminator + of "holey": + let v = data.fromFlatty(HoleyVariant) + if v != nil: + # A hole ordinal that survives fromFlatty is a hardening failure: under + # -d:danger, `case v.kind` silently takes the wrong branch; under + # -d:release, field access can SIGSEGV. low..high range checks miss + # holes, so treat an undefined kind as a hard abort for the harness. + if v.kind != hkA and v.kind != hkB and v.kind != hkC: + quit(139) + of "nested": + let n = data.fromFlatty(Nested) + if n != nil: discard n.kids.len + else: discard + +# Child mode: fuzz one type, checkpointing each input before the attempt. + +proc runChild(typ: string, seed: int64, total, skipTo: int, ckpt: string) = + var r = initRand(seed) + let cornerCount = corners(typ).len + var caught, clean = 0 + for idx in 0 ..< total: + let input = genInput(r, typ, idx, cornerCount) + if idx < skipTo: + continue # already covered; keep RNG aligned + # Checkpoint BEFORE the risky decode so a hard abort leaves a reproducer. + writeFile(ckpt, $idx & "\n" & input.toHex) + try: + decodeAs(typ, input) + inc clean + except CatchableError, Defect: + inc caught + writeFile(ckpt & ".summary", + "caught=" & $caught & " clean=" & $clean & " total=" & $total) + quit(0) + +# Parent mode: drive children, recover reproducers across hard aborts. + +type + Crash = object + typ: string + idx: int + hex: string + code: int + reason: string + +const ChildTimeoutMs = 1500 # a decode taking this long + # is thrashing on a bogus + # length -> treat as abort + +proc runChildProc(exe, typ: string, seed: int64, total, skipTo: int, + ckpt: string): tuple[code: int, timedOut: bool] = + ## Start one child and enforce a wall-clock timeout, killing a hung child. + let p = startProcess(exe, args = @[ + "--child", typ, $seed, $total, $skipTo, ckpt], + options = {poUsePath, poStdErrToStdOut}) + defer: p.close() + var waited = 0 + while p.running and waited < ChildTimeoutMs: + sleep(20) + waited += 20 + if p.running: + p.terminate(); sleep(50) + if p.running: p.kill() + discard p.waitForExit() + return (137, true) + return (p.waitForExit(), false) + +proc fuzzType(exe, typ: string, seed: int64, total: int, + crashes: var seq[Crash]) = + let ckpt = getTempDir() / ("flatty_fuzz_" & typ & ".ckpt") + removeFile(ckpt & ".summary") + var skipTo = 0 + while true: + let (code, timedOut) = runChildProc(exe, typ, seed, total, skipTo, ckpt) + if code == 0 and not timedOut: + if fileExists(ckpt & ".summary"): + echo " SUMMARY ", typ, " ", readFile(ckpt & ".summary") + break + # Hard abort (crash or thrash-timeout): recover the reproducer. + var idx = skipTo + var hex = "" + if fileExists(ckpt): + let parts = readFile(ckpt).splitLines + if parts.len >= 2: + idx = parseInt(parts[0]) + hex = parts[1] + let reason = + if timedOut: "timeout/oom-thrash" + elif code == 139: "SIGSEGV" + else: "abort/quit" + crashes.add Crash(typ: typ, idx: idx, hex: hex, code: code, reason: reason) + echo " CRASH ", typ, " @", idx, " (", reason, " exit=", code, + ") input=", (if hex.len <= 48: hex else: hex[0 ..< 48] & "..") + if idx + 1 >= total: break + skipTo = idx + 1 # resume just past the crash + +when isMainModule: + # Child dispatch. + if paramCount() >= 1 and paramStr(1) == "--child": + runChild(paramStr(2), parseInt(paramStr(3)).int64, + parseInt(paramStr(4)), parseInt(paramStr(5)), paramStr(6)) + + # Replay a single reproducer: test_fuzz --replay + # Decodes with no error handling so you can watch the abort under a debugger. + if paramCount() >= 2 and paramStr(1) == "--replay": + decodeAs(paramStr(2), fromHex(paramStr(3))) + echo "decoded without aborting" + quit(0) + + # --- Correctness precondition: valid values must round-trip. --- + block: + var r = initRand(1) + for typ in FuzzTypes: + for _ in 0 ..< 50: + let v = genValid(r, typ) + try: + decodeAs(typ, v) + except CatchableError, Defect: + doAssert false, "valid " & typ & " failed to round-trip: " & v.toHex + echo "round-trip of valid values: ok" + + # --- Fuzz. --- + let exe = getAppFilename() + const total = 200 + var crashes: seq[Crash] + echo "=== fuzzing ", FuzzTypes.len, " types x ", total, " inputs each ===" + for i, typ in FuzzTypes: + fuzzType(exe, typ, 0xF1A77'i64 + i.int64, total, crashes) + + # --- Report. --- + echo "" + echo "=== fuzz summary ===" + if crashes.len == 0: + echo "no hard aborts: fromFlatty survived every input (catchable errors ok)" + else: + var byType: Table[string, int] + for c in crashes: byType.mgetOrPut(c.typ, 0).inc + echo crashes.len, " hard aborts (uncatchable process kills):" + for typ, n in byType: + echo " ", typ, ": ", n + echo "" + echo "reproducers (decode the hex as the named type to replay):" + for c in crashes: + echo " ", c.typ, " (", c.reason, ") hex=", c.hex + + # This assertion FAILS until flatty's decode path is hardened. Every crash + # above is a byte string that a remote peer could send to kill the process. + doAssert crashes.len == 0, + $crashes.len & " inputs hard-abort fromFlatty (see reproducers above); " & + "decode must fail with a catchable error, never quit/segfault"