@spintax/core 0.5.1 → 0.5.3
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 +64 -0
- package/dist/index.cjs +11 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +11 -2
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,70 @@
|
|
|
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.3 — 2026-08-18
|
|
7
|
+
|
|
8
|
+
The expansion budget added in 0.5.2 was created per rendered AST, and an `#include` renders one
|
|
9
|
+
— so every include handed the bomb a fresh allowance. Fifty `#include` lines over a single
|
|
10
|
+
62-character body turned **690 bytes into 57 MB**, growing linearly with the include count: the
|
|
11
|
+
bound held for each subtree and bounded nothing overall.
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
The budget now belongs to the `render()` call and travels on the render context, includes and
|
|
16
|
+
all. Measured after: 200 includes over the same body produce 1.14 MB, flat, in about 210 ms —
|
|
17
|
+
the same figure one include produces.
|
|
18
|
+
|
|
19
|
+
A host may cap the number of include resolutions (the reference Worker does), but the engine
|
|
20
|
+
must not need it to.
|
|
21
|
+
|
|
22
|
+
### Notes
|
|
23
|
+
|
|
24
|
+
Worth stating plainly, since it decides who is exposed: **this needs an untrusted template
|
|
25
|
+
author, not untrusted data.** The bomb is built from `#set` definitions, and definitions live in
|
|
26
|
+
the template — T1, author-trusted in the §6 model. A context value is T2, and `neutralize()`
|
|
27
|
+
strips `%` from it (`%b% %b%` becomes `b b`), so shielded data cannot carry a reference at all.
|
|
28
|
+
A host that renders its own templates against user data is not reachable; a host that renders
|
|
29
|
+
templates its users write is.
|
|
30
|
+
|
|
31
|
+
## 0.5.2 — 2026-08-18
|
|
32
|
+
|
|
33
|
+
**`render()` no longer dies on a 62-character template** (issue #69). Not a regression — the
|
|
34
|
+
published 0.3.4 does the same, and so did every engine in the family. Live, this was HTTP 503
|
|
35
|
+
from the reference Worker.
|
|
36
|
+
|
|
37
|
+
### Fixed
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
#set %a% = %b% %b%
|
|
41
|
+
#set %b% = %a% %a%
|
|
42
|
+
%a%
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Each expansion replaces one reference with two, so the 50-level depth cap allows 2^50 and the
|
|
46
|
+
process ends: an out-of-memory abort here and in the Python port, a memory fatal in both PHP
|
|
47
|
+
engines. Acyclic doubling definitions do it too, so the cycle guard never fires. **Any**
|
|
48
|
+
reference to such a value reaches it — plain text, a plural slot, an enumeration, a
|
|
49
|
+
permutation. Defining the pair is harmless, and a conditional testing one is harmless, because
|
|
50
|
+
neither expands the value.
|
|
51
|
+
|
|
52
|
+
A render may now expand at most 1 MB of `%variable%` text. Past that a reference is left
|
|
53
|
+
literal — exactly what an undefined name already does, so no new output shape appears, render
|
|
54
|
+
stays lenient (§9.2), and a plural whose count did not resolve erases as it always has. The
|
|
55
|
+
ceiling is far above any real document; the point is to end an explosion, not to ration output.
|
|
56
|
+
|
|
57
|
+
### Notes
|
|
58
|
+
|
|
59
|
+
**What a truncated explosion looks like is deliberately NOT parity-gated,** and the conformance
|
|
60
|
+
README now says so. The engines expand by different mechanisms — a per-reference tree walk here
|
|
61
|
+
and in Python, a whole-text fixpoint in both PHP engines — so they stop in different places and
|
|
62
|
+
produce different byte counts for the same bomb (1 198 223 against 599 191, measured). What is
|
|
63
|
+
contract, and what each engine pins in its own suite, is that render terminates, does not
|
|
64
|
+
throw, bounds its output, and leaves what it could not afford as literal `%name%`. Making the
|
|
65
|
+
byte counts agree would mean rewriting one engine's expansion to match another's traversal, for
|
|
66
|
+
input no author writes.
|
|
67
|
+
|
|
68
|
+
This is the render-side twin of the counting bomb fixed in 0.5.1; that one bounds `validate()`.
|
|
69
|
+
|
|
6
70
|
## 0.5.1 — 2026-08-18
|
|
7
71
|
|
|
8
72
|
Two crashes in the 0.5.0 form-counting path, both reachable from template text, both found by
|
package/dist/index.cjs
CHANGED
|
@@ -911,6 +911,7 @@ var NotImplementedError = class extends SpintaxError {
|
|
|
911
911
|
|
|
912
912
|
// src/internal/render.ts
|
|
913
913
|
var MAX_VARIABLE_DEPTH = 50;
|
|
914
|
+
var MAX_EXPANSION_CHARS = 1024 * 1024;
|
|
914
915
|
var INCLUDE_LINE_RE = /^[ \t]*#include[ \t\n\r\f\x0B]+"([^"]+)"[ \t\n\r\f\x0B]*$/gmu;
|
|
915
916
|
function renderAst(ast, ctx) {
|
|
916
917
|
const base = buildVars(ast.setDefs, ctx.runtimeContext);
|
|
@@ -918,7 +919,8 @@ function renderAst(ast, ctx) {
|
|
|
918
919
|
rng: ctx.rng,
|
|
919
920
|
locale: ctx.locale,
|
|
920
921
|
depth: 0,
|
|
921
|
-
onPluralError: ctx.onPluralError
|
|
922
|
+
onPluralError: ctx.onPluralError,
|
|
923
|
+
budget: ctx.budget
|
|
922
924
|
};
|
|
923
925
|
const vars = Object.keys(ast.defDefs).length > 0 ? { ...base, ...rollDefinitions(ast.defDefs, base, ctx.runtimeContext, walkOpts) } : base;
|
|
924
926
|
const text = renderNodes(ast.nodes, { ...walkOpts, vars });
|
|
@@ -1031,6 +1033,8 @@ function resolveVariable(name, opts) {
|
|
|
1031
1033
|
const value = opts.vars[name.toLowerCase()];
|
|
1032
1034
|
if (value === void 0) return `%${name}%`;
|
|
1033
1035
|
if (opts.depth >= MAX_VARIABLE_DEPTH || !/[{[%]/u.test(value)) return value;
|
|
1036
|
+
if (opts.budget.left <= 0) return `%${name}%`;
|
|
1037
|
+
opts.budget.left -= value.length;
|
|
1034
1038
|
return renderNodes(parseSequence(value), { ...opts, depth: opts.depth + 1 });
|
|
1035
1039
|
}
|
|
1036
1040
|
function expandVarsOnly(text, opts) {
|
|
@@ -1040,6 +1044,8 @@ function expandVarsOnly(text, opts) {
|
|
|
1040
1044
|
out = out.replace(/%(\w+)%/gu, (m, name) => {
|
|
1041
1045
|
const value = opts.vars[name.toLowerCase()];
|
|
1042
1046
|
if (value === void 0) return m;
|
|
1047
|
+
if (opts.budget.left <= 0) return m;
|
|
1048
|
+
opts.budget.left -= value.length;
|
|
1043
1049
|
changed = true;
|
|
1044
1050
|
return value;
|
|
1045
1051
|
});
|
|
@@ -1354,7 +1360,10 @@ function renderWith(input, rng, opts = {}) {
|
|
|
1354
1360
|
resolver: opts.includeResolver,
|
|
1355
1361
|
maxDepth: opts.maxDepth ?? DEFAULT_MAX_DEPTH,
|
|
1356
1362
|
includeStack: [],
|
|
1357
|
-
onPluralError: opts.onPluralError
|
|
1363
|
+
onPluralError: opts.onPluralError,
|
|
1364
|
+
// One allowance for the whole call. Created here rather than in renderAst because
|
|
1365
|
+
// includes re-enter renderAst, and a budget made there is a budget per subtree.
|
|
1366
|
+
budget: { left: MAX_EXPANSION_CHARS }
|
|
1358
1367
|
};
|
|
1359
1368
|
const ast = resolveAst(input);
|
|
1360
1369
|
let out = renderAst(ast, ctx);
|