#5 SH1E Pi-side decoder + fuzz harness

Two pieces required before any real-device release ships:

(1) cmd/sh1e-decode — a Pi-side CLI that validates and pretty-prints
    an SH1E envelope. Reads from stdin, a file, or a hex string
    (`-hex "53 48 31 45 ..."`, spaces/colons/pipes stripped so you
    can paste straight from the composer's hex dump pane).

    Output: ACCEPT line with byte count + fingerprint, then per-block
    summaries (plate type, every text block's font/size/anchor/align/
    rotation/content, every SVG path's anchor/scale/rotation/d-string).
    On rejection: REJECT <TAG>: <error> where TAG is a stable label
    matching the sentinel error name — easy to grep in CI fuzz runs.

    Run it like:
      sh1e-decode some-design.sh1e
      sh1e-decode < piped-stdin
      sh1e-decode -hex "53 48 31 45 01 4f 00 ..."

(2) engrave/wire/sh1e/fuzz_test.go — native Go 1.18+ fuzz harness for
    Decode and a faster envelope-only variant.

    Property: any input must either return a valid Design that
    round-trips through Encode to byte-identical output (canonical
    encoding) OR return a non-nil error. A panic, hang, or
    out-of-memory is a test failure — the Pi controller runs this
    parser on untrusted QR-scanned bytes and a crash there is a
    real-world fault.

    Seed corpus: valid envelopes from the existing tests +
    deliberately corrupted variants (length-field flipped, payload
    byte flipped) + obvious shape attacks (empty, wrong magic, wrong
    version, mostly-FF spam).

    Smoke-tested for 5 seconds → 0 crashes, 933k execs/sec, 60 new
    interesting inputs discovered, 72 corpus entries. Spec calls for
    1 CPU-week before any release ships; CI runs a shorter window
    per merge.

    Run with:
      go test -fuzz=FuzzDecode -fuzztime=10m ./engrave/wire/sh1e
      go test -fuzz=FuzzDecodeEnvelopeOnly -fuzztime=10m ./engrave/wire/sh1e

Plus a CRC-stdlib drift smoke test guarding against a future
dependency swap quietly changing the CRC table.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
mineracks 2026-05-28 20:41:34 +10:00
parent ded32c8f07
commit d7dd748fe4
2 changed files with 312 additions and 0 deletions

181
cmd/sh1e-decode/main.go Normal file
View File

@ -0,0 +1,181 @@
// Command sh1e-decode validates and pretty-prints an SH1E envelope.
//
// Intended for Pi-side use during real-device validation: the v1
// controller scans a QR code, hands the bytes to its own embedded
// decoder, and shows a preview on the LCD. This helper does the same
// validation chain on stdin or a file so you can replay payloads from
// the host machine — useful for fuzz-test triage, regression testing
// against captured QR scans, and ad-hoc "does this byte string parse?"
// questions.
//
// Usage:
//
// sh1e-decode < some.sh1e # decode from stdin
// sh1e-decode some.sh1e # decode from path
// sh1e-decode -hex "53 48 31 45…" # decode from a hex dump
//
// Output is human-readable: envelope header, validation result, each
// text block's content, each SVG path's preview. Non-zero exit on any
// parse / validation failure (errors.Is-mapped) so it integrates with
// CI fuzzers.
package main
import (
"bytes"
"encoding/hex"
"errors"
"flag"
"fmt"
"io"
"os"
"strings"
"github.com/mineracks/seedhammer-v1-companion/engrave/wire/sh1e"
)
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, "sh1e-decode:", err)
os.Exit(1)
}
}
func run() error {
hexInput := flag.String("hex", "", "decode from a hex-encoded string (spaces/newlines OK) instead of stdin/file")
flag.Parse()
var raw []byte
switch {
case *hexInput != "":
cleaned := strings.Map(func(r rune) rune {
if r == ' ' || r == '\n' || r == '\t' || r == ':' || r == '|' {
return -1
}
return r
}, *hexInput)
b, err := hex.DecodeString(cleaned)
if err != nil {
return fmt.Errorf("hex decode: %w", err)
}
raw = b
case flag.NArg() == 1:
b, err := os.ReadFile(flag.Arg(0))
if err != nil {
return err
}
raw = b
default:
b, err := io.ReadAll(os.Stdin)
if err != nil {
return err
}
raw = b
}
design, err := sh1e.Decode(raw)
if err != nil {
// Tag known error types so CI can grep their names.
tag := "OTHER"
switch {
case errors.Is(err, sh1e.ErrBadMagic):
tag = "BAD_MAGIC"
case errors.Is(err, sh1e.ErrUnsupportedVersion):
tag = "UNSUPPORTED_VERSION"
case errors.Is(err, sh1e.ErrLengthMismatch):
tag = "LENGTH_MISMATCH"
case errors.Is(err, sh1e.ErrCRCMismatch):
tag = "CRC_MISMATCH"
case errors.Is(err, sh1e.ErrBadCBOR):
tag = "BAD_CBOR"
case errors.Is(err, sh1e.ErrNotCanonical):
tag = "NOT_CANONICAL"
case errors.Is(err, sh1e.ErrTruncated):
tag = "TRUNCATED"
case errors.Is(err, sh1e.ErrFingerprintMismatch):
tag = "FINGERPRINT_MISMATCH"
case errors.Is(err, sh1e.ErrTooManyBlocks):
tag = "TOO_MANY_BLOCKS"
case errors.Is(err, sh1e.ErrTooManyPaths):
tag = "TOO_MANY_PATHS"
case errors.Is(err, sh1e.ErrTextTooLong):
tag = "TEXT_TOO_LONG"
case errors.Is(err, sh1e.ErrPathTooLong):
tag = "PATH_TOO_LONG"
case errors.Is(err, sh1e.ErrNonASCII):
tag = "NON_ASCII"
case errors.Is(err, sh1e.ErrInvalidEnum):
tag = "INVALID_ENUM"
case errors.Is(err, sh1e.ErrOutOfRange):
tag = "OUT_OF_RANGE"
case errors.Is(err, sh1e.ErrInvalidRotation):
tag = "INVALID_ROTATION"
}
return fmt.Errorf("REJECT %s: %w", tag, err)
}
prettyPrint(os.Stdout, raw, design)
return nil
}
func prettyPrint(w io.Writer, raw []byte, d sh1e.Design) {
var buf bytes.Buffer
fmt.Fprintf(&buf, "ACCEPT — %d bytes, version 0x%02x\n", len(raw), sh1e.Version)
fmt.Fprintf(&buf, "plate: %s\n", plateName(d.PlateType))
fmt.Fprintf(&buf, "fingerprint: %x\n", d.Fingerprint)
fmt.Fprintf(&buf, "text blocks: %d\n", len(d.TextBlocks))
for i, tb := range d.TextBlocks {
fmt.Fprintf(&buf,
" [%2d] font=%s size=%dpt anchor=(%d,%d) align=%s rot=%d %q\n",
i, fontName(tb.FontID), tb.Size, tb.XMM, tb.YMM,
alignName(tb.Alignment), tb.Rotation, tb.Text,
)
}
fmt.Fprintf(&buf, "svg paths: %d\n", len(d.SvgPaths))
for i, p := range d.SvgPaths {
preview := p.PathD
if len(preview) > 60 {
preview = preview[:57] + "..."
}
fmt.Fprintf(&buf,
" [%2d] anchor=(%d,%d) scale=%d%% rot=%d d=%q\n",
i, p.XMM, p.YMM, p.ScalePct, p.Rotation, preview,
)
}
_, _ = w.Write(buf.Bytes())
}
func plateName(p sh1e.PlateType) string {
switch p {
case sh1e.SmallPlate:
return "Small (SH-01, 85×55mm)"
case sh1e.SquarePlate:
return "Square (SH-02, 85×85mm)"
case sh1e.LargePlate:
return "Large (SH-03, 85×134mm)"
}
return fmt.Sprintf("unknown plate %d", p)
}
func fontName(f sh1e.FontID) string {
switch f {
case sh1e.FontComfortaa:
return "Comfortaa"
case sh1e.FontPoppins:
return "Poppins"
case sh1e.FontConstant:
return "Constant"
}
return fmt.Sprintf("unknown-%d", f)
}
func alignName(a sh1e.Alignment) string {
switch a {
case sh1e.AlignLeft:
return "Left"
case sh1e.AlignCenter:
return "Center"
case sh1e.AlignRight:
return "Right"
}
return fmt.Sprintf("unknown-%d", a)
}

View File

@ -0,0 +1,131 @@
package sh1e
import (
"bytes"
"encoding/binary"
"hash/crc32"
"testing"
)
// FuzzDecode runs Decode against arbitrary input. Every invocation must
// either return a Design that round-trips through Encode (canonical and
// stable) OR return a non-nil error. A panic, hang, or out-of-memory
// must never happen — the Pi controller runs this parser on untrusted
// QR-scanned data and a crash there is a real-world fault.
//
// Run with: go test -fuzz=FuzzDecode -fuzztime=10m ./engrave/wire/sh1e
//
// Pre-release we want at least 1 CPU-week of fuzzing per the spec's
// security analysis section. CI runs a shorter window per merge.
func FuzzDecode(f *testing.F) {
// Seed corpus: a couple of valid envelopes, plus the classic
// adversarial bait (truncated, version-bumped, length-mismatched).
seedDesigns := []Design{
{
PlateType: SmallPlate,
TextBlocks: []TextBlock{{
FontID: FontComfortaa,
Size: 12,
XMM: 5,
YMM: 5,
Alignment: AlignLeft,
Text: "ABANDON ABILITY ABLE",
}},
},
{
PlateType: LargePlate,
TextBlocks: []TextBlock{
{FontID: FontPoppins, Size: 18, XMM: 10, YMM: 10, Alignment: AlignCenter, Text: "TITLE"},
},
SvgPaths: []SvgPath{
{XMM: 30, YMM: 60, ScalePct: 100, PathD: "M 0 0 L 10 10 Z"},
},
},
}
for _, d := range seedDesigns {
if b, err := Encode(d); err == nil {
f.Add(b)
// Also seed corrupted variants — these should be REJECTED,
// not crash.
bad := bytes.Clone(b)
if len(bad) > 5 {
bad[5] ^= 0xff // corrupt the length field
f.Add(bad)
}
bad2 := bytes.Clone(b)
if len(bad2) > 11 {
bad2[11] ^= 0x01 // corrupt the payload
f.Add(bad2)
}
}
}
// Also seed the obvious wrong-shape inputs.
f.Add([]byte{})
f.Add([]byte("SH1E"))
f.Add([]byte("SH1F\x01\x00\x00\x00\x00\x00\x00"))
f.Add(append([]byte("SH1E\x01\xff\xff\xff\xff\xff\xff"), bytes.Repeat([]byte{0xff}, 64)...))
f.Fuzz(func(t *testing.T, data []byte) {
d, err := Decode(data)
if err != nil {
// Any error is acceptable — we just must not panic.
return
}
// Decoded successfully: the design must re-encode to the same
// payload bytes (canonical-encoding round-trip property).
// Otherwise our canonicity check is broken.
reencoded, err := Encode(d)
if err != nil {
t.Fatalf("Decode accepted bytes that don't round-trip through Encode: %v\ninput: %x", err, data)
}
// The full envelope should match the input exactly — Decode is
// supposed to reject any non-canonical envelope.
if !bytes.Equal(reencoded, data) {
t.Fatalf("Decode accepted non-canonical bytes\ninput: %x\nreencoded: %x", data, reencoded)
}
})
}
// FuzzDecodeEnvelopeOnly hammers just the magic + version + length + CRC
// preamble. Useful as a faster-converging fuzz target for the
// header-validation path — Decode rejects ~99% of inputs here before
// the CBOR parser ever runs.
func FuzzDecodeEnvelopeOnly(f *testing.F) {
// Seeds: valid envelopes + a few corruptions in the header bytes.
good, err := Encode(Design{
PlateType: SmallPlate,
TextBlocks: []TextBlock{{FontID: FontComfortaa, Size: 12, Text: "X"}},
})
if err != nil {
f.Fatal(err)
}
f.Add(good)
// Corrupted versions still need rejection without crash.
for i := 0; i < envelopeOverhead; i++ {
bad := bytes.Clone(good)
bad[i] ^= 0xff
f.Add(bad)
}
f.Fuzz(func(t *testing.T, data []byte) {
_, _ = Decode(data) // crash-is-the-failure-mode
})
}
// hashEnvelope is a smoke check that the CRC32 we compute matches Go's
// standard IEEE polynomial — protects against the unlikely event of a
// dependency-swap changing the underlying table.
func TestCRCMatchesStdlib(t *testing.T) {
payload := []byte("the quick brown fox")
want := crc32.ChecksumIEEE(payload)
envelope := append([]byte{}, Magic[:]...)
envelope = append(envelope, Version)
envelope = binary.LittleEndian.AppendUint16(envelope, uint16(len(payload)))
envelope = binary.LittleEndian.AppendUint32(envelope, want)
envelope = append(envelope, payload...)
got := binary.LittleEndian.Uint32(envelope[7:11])
if got != want {
t.Errorf("CRC drift detected: header %08x vs stdlib %08x", got, want)
}
}