@spintax/core 0.5.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,50 @@
3
3
  All notable changes to `@spintax/core` are documented here. This project adheres to
4
4
  [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
5
5
 
6
+ ## 0.5.1 — 2026-08-18
7
+
8
+ Two crashes in the 0.5.0 form-counting path, both reachable from template text, both found by
9
+ review rather than by a failing test. **Upgrade from 0.5.0.**
10
+
11
+ ### Fixed
12
+
13
+ - **A 62-character template could take `validate()` out with an out-of-memory crash.**
14
+
15
+ ```
16
+ #set %a% = %b% %b%
17
+ #set %b% = %a% %a%
18
+ {plural 2: one|%a%}
19
+ ```
20
+
21
+ Counting plural forms expands definitions, and the expansion was bounded by 51 *passes* with
22
+ nothing bounding the *size*. That template doubles the text on every pass, so 51 passes is 2^51.
23
+ Every engine in the family died on it — this one and the Python port with an out-of-memory
24
+ crash, both PHP engines with a memory fatal. A non-cyclic doubling chain does the same, so the
25
+ circular-reference guard never got a chance.
26
+
27
+ The expansion now stops at 64 KB and reports the count as unknowable, which is the same silence
28
+ this validator already uses for every input it cannot pin down. A form list is a handful of
29
+ plural forms; nothing legitimate approaches the ceiling.
30
+
31
+ `validate` is exposed by public HTTP surfaces — this was a remote denial of service, not a
32
+ local footgun.
33
+
34
+ - **`validate()` threw `RangeError` on a long `#set` chain.** The macro walk added in 0.5.0 was
35
+ recursive: roughly 9000 links (a 205 KB template) exhausted the call stack, while the Python
36
+ port gave up at ~1000 links (20 KB) and both PHP engines answered normally — one input, three
37
+ behaviours. The walk is iterative now.
38
+
39
+ It is a pre-order depth-first walk in source order, and it has to stay one: the first non-clean
40
+ answer wins, so a graph holding both an opaque macro and a brackety one gives different
41
+ diagnostics depending on which is met first. Checked by leaving the PHP engine recursive and
42
+ diffing 300 generated macro graphs against it — no mismatches.
43
+
44
+ ### Notes
45
+
46
+ No API change and no verdict change for any template that was not crashing. Both fixes are
47
+ mirrored into `spintax/core`, `spintax-core` and the WordPress plugin, and pinned by two corpus
48
+ fixtures whose real assertion is that the engine answers at all — a runner that hangs fails them.
49
+
6
50
  ## 0.5.0 — 2026-08-18
7
51
 
8
52
  Two plural fixes, both cross-engine, both found by measuring one engine against another rather
package/dist/index.cjs CHANGED
@@ -633,6 +633,7 @@ function checkPlurals(text, idx, locale, knownVariables, out) {
633
633
  var UNRESOLVED_AT_PLURAL_TIME = /\[|\{(?!\?)/u;
634
634
  var ANY_BRACKET = /[[\]{}]/u;
635
635
  var FORM_EXPANSION_PASSES = 51;
636
+ var FORM_EXPANSION_MAX_GROWTH = 64 * 1024;
636
637
  function expandFormsForCounting(formsRaw, defs, macros, hostNames) {
637
638
  const unknown = (directMacroSpintax = false) => ({
638
639
  forms: 0,
@@ -640,17 +641,23 @@ function expandFormsForCounting(formsRaw, defs, macros, hostNames) {
640
641
  directMacroSpintax
641
642
  });
642
643
  const seenMacro = /* @__PURE__ */ new Set();
644
+ const refsOf = (text2) => [...text2.matchAll(/%(\w+)%/gu)].map((m) => (m[1] ?? "").toLowerCase());
643
645
  const walkMacros = (source) => {
644
- for (const m of source.matchAll(/%(\w+)%/gu)) {
645
- const name = (m[1] ?? "").toLowerCase();
646
+ const stack = [{ refs: refsOf(source), i: 0 }];
647
+ while (stack.length > 0) {
648
+ const frame = stack[stack.length - 1];
649
+ if (frame.i >= frame.refs.length) {
650
+ stack.pop();
651
+ continue;
652
+ }
653
+ const name = frame.refs[frame.i++];
646
654
  if (defs[name] !== void 0 || hostNames.has(name)) continue;
647
655
  const macro = macros[name];
648
656
  if (macro === void 0 || seenMacro.has(name)) continue;
649
657
  seenMacro.add(name);
650
658
  if (/\{\?/u.test(macro)) return "opaque";
651
659
  if (ANY_BRACKET.test(macro)) return "brackets";
652
- const deeper = walkMacros(macro);
653
- if (deeper !== "clean") return deeper;
660
+ stack.push({ refs: refsOf(macro), i: 0 });
654
661
  }
655
662
  return "clean";
656
663
  };
@@ -658,28 +665,33 @@ function expandFormsForCounting(formsRaw, defs, macros, hostNames) {
658
665
  if (verbatim === "brackets") return unknown(true);
659
666
  if (verbatim === "opaque") return unknown();
660
667
  let text = formsRaw;
668
+ const budget = formsRaw.length + FORM_EXPANSION_MAX_GROWTH;
661
669
  for (let pass = 0; pass < FORM_EXPANSION_PASSES; pass++) {
662
670
  let sawReference = false;
663
671
  let bailed = false;
664
- text = text.replace(/%(\w+)%/gu, (whole, raw) => {
672
+ const parts = [];
673
+ let total = 0;
674
+ let cursor = 0;
675
+ const re = /%(\w+)%/gu;
676
+ let m;
677
+ while ((m = re.exec(text)) !== null) {
665
678
  sawReference = true;
666
- const name = raw.toLowerCase();
667
- if (hostNames.has(name)) {
668
- bailed = true;
669
- return whole;
670
- }
671
- const value = defs[name] ?? macros[name];
672
- if (value === void 0) {
673
- bailed = true;
674
- return whole;
675
- }
676
- if (ANY_BRACKET.test(value)) {
679
+ const name = (m[1] ?? "").toLowerCase();
680
+ const value = hostNames.has(name) ? void 0 : defs[name] ?? macros[name];
681
+ if (value === void 0 || ANY_BRACKET.test(value)) {
677
682
  bailed = true;
678
- return whole;
683
+ break;
679
684
  }
680
- return value;
681
- });
685
+ parts.push(text.slice(cursor, m.index), value);
686
+ total += m.index - cursor + value.length;
687
+ cursor = m.index + m[0].length;
688
+ if (total > budget) return unknown();
689
+ }
682
690
  if (bailed) return unknown();
691
+ parts.push(text.slice(cursor));
692
+ total += text.length - cursor;
693
+ if (total > budget) return unknown();
694
+ text = parts.join("");
683
695
  if (!sawReference) {
684
696
  return { forms: text.split("|").length, unresolved: false, directMacroSpintax: false };
685
697
  }