From 4f9285ab5cdca9382d3295a50c22481c827a95b7 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Wed, 2 Sep 2026 19:08:32 +0200 Subject: [PATCH] Reject non-finite float bounds, unplain integer args, constant samples, a constant zero divisor and the default separator; a binding key is no path segment; ascii keeps DEL; an unheld path panics --- README.md | 19 ++++++++------- builtins.go | 48 +++++++++++++++++++++++++++---------- calc.go | 68 +++++++++++++++++++++++++++++++++++++++++++++++++++-- hold.go | 3 +++ node.go | 12 ++++++++++ path.go | 2 +- 6 files changed, 129 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 0ac460c..dc7e1fa 100644 --- a/README.md +++ b/README.md @@ -180,9 +180,9 @@ many times — each an independent draw — joined by `separator` (default `""`) { "format": "{word}", "repeat": 3, "separator": " ", "word": ["foo", "bar", "baz"] } ``` -Renders e.g. `bar foo baz`. A `separator` without a `repeat` is rejected, and so -is a `repeat` that multiplies to more than 1 048 576 renders along any path of -nested repeats. +Renders e.g. `bar foo baz`. Rejected at load: a `separator` without a `repeat`, +a `separator` of `""` (the default), and a `repeat` that multiplies to more than +1 048 576 renders along any path of nested repeats. ### Options and fields @@ -194,9 +194,11 @@ choice naming its item. ### Functions A `{name(args)}` token calls a builtin. Arguments are checked at `New`: a bad -count, range, country or expression fails fast, and a length, count or decimal -place beyond a sane maximum is rejected, so a fat-fingered `hex(2000000000)` -never tries to allocate gigabytes. Every builtin draws only from the seed — a +count, range, country or expression fails fast; an integer is written plain +(`5`, not `+5` or `05`); bounds are finite; a sample that could only ever emit one +value (`int(5,5)`, `float(1,1,2)`) is rejected naming the text to write instead; +and a length, count or decimal place beyond a sane maximum is rejected, so a +fat-fingered `hex(2000000000)` never tries to allocate gigabytes. Every builtin draws only from the seed — a time-based id takes its timestamp from the rng, not the clock — so seeded output stays reproducible. @@ -243,8 +245,9 @@ hyphenated field can't be an operand. ``` Renders e.g. `19.99 x 3 = 59.97`. An operand that can never be a number (`"abc"`, -or a choice of such) is rejected at load; one that sometimes is not yields `NaN`, -and a division by zero `Inf` — both print rather than fail. +or a choice of such) is rejected at load, as is a division by a constant zero +(`1/0`, or a fixed `"0"` field); an operand that sometimes is not a number yields +`NaN`, and a division by a sometimes-zero one `Inf` — both print rather than fail. ### Transforms diff --git a/builtins.go b/builtins.go index c121eb7..c592d6e 100644 --- a/builtins.go +++ b/builtins.go @@ -176,7 +176,7 @@ var asciiFolds = map[rune]string{ func asciiFold(s string) string { var b strings.Builder for _, r := range s { - if r < unicode.MaxASCII { + if r <= unicode.MaxASCII { b.WriteRune(r) } else { b.WriteString(asciiFolds[r]) @@ -221,10 +221,25 @@ func randChars(r rng, n int, alphabet string) string { return string(b) } +// plainInt parses an integer arg written the one way: no sign, no leading zero. +func plainInt(s string) (int, error) { + n, err := strconv.Atoi(s) + if err != nil { + return 0, fmt.Errorf("%q is not an integer", s) + } + if strconv.Itoa(n) != s { + return 0, fmt.Errorf("%q is not a plain integer; write %d", s, n) + } + return n, nil +} + func posIntArg(_ map[string]node, a []string) error { - n, err := strconv.Atoi(a[0]) - if err != nil || n < 1 { - return fmt.Errorf("count %q must be a positive integer", a[0]) + n, err := plainInt(a[0]) + if err != nil { + return fmt.Errorf("count %w", err) + } + if n < 1 { + return fmt.Errorf("count %q must be positive", a[0]) } if n > maxLen { return fmt.Errorf("count %d exceeds the maximum %d", n, maxLen) @@ -233,14 +248,17 @@ func posIntArg(_ map[string]node, a []string) error { } func intRangeArgs(_ map[string]node, a []string) error { - lo, e1 := strconv.Atoi(a[0]) - hi, e2 := strconv.Atoi(a[1]) + lo, e1 := plainInt(a[0]) + hi, e2 := plainInt(a[1]) if e1 != nil || e2 != nil { - return fmt.Errorf("int(min,max) needs integer args, got %q,%q", a[0], a[1]) + return fmt.Errorf("int(min,max) needs plain integers, got %q,%q", a[0], a[1]) } if lo > hi { return fmt.Errorf("int(min,max): min %d > max %d", lo, hi) } + if lo == hi { + return fmt.Errorf("int(%d,%d) is the constant %d; write it as text", lo, hi, lo) + } if uint64(hi)-uint64(lo) >= uint64(math.MaxInt64) { // span hi-lo+1 would overflow int -> IntN panic return fmt.Errorf("int(min,max): range %d..%d is too wide", lo, hi) } @@ -250,19 +268,25 @@ func intRangeArgs(_ map[string]node, a []string) error { func floatArgs(_ map[string]node, a []string) error { lo, e1 := strconv.ParseFloat(a[0], 64) hi, e2 := strconv.ParseFloat(a[1], 64) - dp, e3 := strconv.Atoi(a[2]) + dp, e3 := plainInt(a[2]) if e1 != nil || e2 != nil || e3 != nil { - return fmt.Errorf("float(min,max,dp) needs numeric args, got %q,%q,%q", a[0], a[1], a[2]) + return fmt.Errorf("float(min,max,dp) needs numeric bounds and a plain decimals count, got %q,%q,%q", a[0], a[1], a[2]) + } + if math.IsNaN(lo) || math.IsNaN(hi) || math.IsInf(lo, 0) || math.IsInf(hi, 0) { + return fmt.Errorf("float(min,max,dp) needs finite bounds, got %q,%q", a[0], a[1]) } if lo > hi { return fmt.Errorf("float(min,max,dp): min %v > max %v", lo, hi) } - if math.IsInf(hi-lo, 0) { // an overflowing span would render as "+Inf" - return fmt.Errorf("float(min,max,dp): range %v..%v is too wide", lo, hi) - } if dp < 0 || dp > maxDecimals { return fmt.Errorf("float(min,max,dp): decimals %d out of range 0..%d", dp, maxDecimals) } + if lo == hi { + return fmt.Errorf("float(%s,%s,%d) is the constant %q; write it as text", a[0], a[1], dp, strconv.FormatFloat(lo, 'f', dp, 64)) + } + if math.IsInf(hi-lo, 0) { // an overflowing span would render as "+Inf" + return fmt.Errorf("float(min,max,dp): range %v..%v is too wide", lo, hi) + } return nil } diff --git a/calc.go b/calc.go index 140b84d..212466f 100644 --- a/calc.go +++ b/calc.go @@ -78,14 +78,78 @@ func checkCalc(fields map[string]node, args []string) error { return fmt.Errorf("calc(%q): operand %q is never a number: it renders %q", args[0], name, text) } } + if divisor, zero := constantZeroDivisor(expr, fields); zero { + return fmt.Errorf("calc(%q) divides by %s, which is always zero", args[0], divisor) + } if len(args) == 2 { - if dp, err := strconv.Atoi(args[1]); err != nil || dp < 0 || dp > maxDecimals { - return fmt.Errorf("calc decimals %q must be an integer in 0..%d", args[1], maxDecimals) + if dp, err := plainInt(args[1]); err != nil || dp < 0 || dp > maxDecimals { + return fmt.Errorf("calc decimals %q must be a plain integer in 0..%d", args[1], maxDecimals) } } return nil } +// constantZeroDivisor finds a division whose right side is a constant zero: number +// literals and fixed operands folded, anything that varies left unknown. +func constantZeroDivisor(n calcNode, fields map[string]node) (string, bool) { + switch n := n.(type) { + case calcNeg: + return constantZeroDivisor(n.x, fields) + case calcBin: + if n.op == '/' { + if v, known := constantValue(n.r, fields); known && v == 0 { + return calcText(n.r), true + } + } + if d, zero := constantZeroDivisor(n.l, fields); zero { + return d, true + } + return constantZeroDivisor(n.r, fields) + } + return "", false +} + +// constantValue evaluates an expression whose every operand is fixed. +func constantValue(n calcNode, fields map[string]node) (float64, bool) { + switch n := n.(type) { + case calcNum: + return float64(n), true + case calcVar: + t, ok := fields[string(n)].(*template) + if !ok || !t.fixed || t.repeat > 1 { + return 0, false + } + v, err := strconv.ParseFloat(strings.TrimSpace(t.lit), 64) + return v, err == nil + case calcNeg: + v, ok := constantValue(n.x, fields) + return -v, ok + case calcBin: + l, lok := constantValue(n.l, fields) + r, rok := constantValue(n.r, fields) + if !lok || !rok { + return 0, false + } + return calcBin{n.op, calcNum(l), calcNum(r)}.eval(nil), true + } + return 0, false +} + +// calcText spells an expression node the way an author would read it. +func calcText(n calcNode) string { + switch n := n.(type) { + case calcNum: + return strconv.FormatFloat(float64(n), 'f', -1, 64) + case calcVar: + return string(n) + case calcNeg: + return "-" + calcText(n.x) + case calcBin: + return "(" + calcText(n.l) + " " + string(n.op) + " " + calcText(n.r) + ")" + } + return "?" +} + // neverNumeric reports a node no render of which is a number: fixed text that does // not parse, or a choice of only such items. text is one such render. func neverNumeric(n node) (text string, never bool) { diff --git a/hold.go b/hold.go index a900a7c..bb5c3fe 100644 --- a/hold.go +++ b/hold.go @@ -273,6 +273,9 @@ type draws struct { // linkRefs prove every step, so the walk cannot fail. func readField(s *session, t *template, held *draws, a arm) string { if !t.held[a.key] { + if len(a.tail) > 0 { + panic(fmt.Sprintf("fejkdata: %q reads a path into %q, which the expansion does not hold", a.name, a.key)) + } return render(s, t.fields[a.key]) } if v, read := held.value[a.path]; read { diff --git a/node.go b/node.go index 54b44a3..bbe80dd 100644 --- a/node.go +++ b/node.go @@ -57,6 +57,15 @@ type template struct { func (*template) isNode() {} +// field is the node a path segment names; a binding is a render edge, not a field. +func (t *template) field(seg string) (node, bool) { + if isRef(seg) { + return nil, false + } + n, ok := t.fields[seg] + return n, ok +} + // compile converts parsed JSON into a node tree, validating structure up front. // Only a choice's items carry a weight, so one here would be inert whatever its type. func compile(v any) (node, error) { @@ -229,6 +238,9 @@ func readOptions(m map[string]any) (templateOptions, error) { if repeat == 1 { return o, fmt.Errorf("separator joins repeated renders, so it has no effect without a repeat above 1") } + if o.separator == "" { + return o, fmt.Errorf("separator \"\" is the default, so it has no effect; drop it") + } } _, o.weighted = m["weight"] return o, nil diff --git a/path.go b/path.go index cc3edff..ee9b1c5 100644 --- a/path.go +++ b/path.go @@ -40,7 +40,7 @@ func walkPath(n node, tail []string, w pathWalk) error { return err } } - child, ok := n.fields[tail[0]] + child, ok := n.field(tail[0]) if !ok { return fmt.Errorf("no field %q", tail[0]) }