diff --git a/QUICKSTART.md b/QUICKSTART.md index 7a314f1..8bcffe7 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -455,6 +455,24 @@ UNTRUSTED input: caps on source length, nesting depth, layout box count, and out stripping so the output stays within the `svg/g/path/rect` alphabet — a cap trip surfaces as `OUTPUT_CAP_EXCEEDED`, never an escaped error. +SVG layout and MathML also preflight materialized work before allocating glyphs or +markup. Each render allows 100,000 UTF-16 units of text/operator-name payload, +counted per occurrence after macro expansion (including repeated array separators). +Spaces and surrogate pairs count conservatively. A bounded AST walk also charges +ancestor copying; MathML includes XML escaping and table/tag overhead, with a +2,000,000-unit markup estimate and a separate copying allowance. These are +conservative work limits, so deeply wrapped or markup-heavy input may be refused +even when its final string would be smaller. Ordinary accepted output is unchanged; +`toMathML` reports a typed resource-cap exception, and diagnostic SVG rendering +reports `OUTPUT_CAP_EXCEEDED`. Each call has a fresh budget. + +SVG layout also charges actual primitive construction, including translated and +colored copies: at most 400,000 glyph placements and 100,000 rules per render. +This covers generated dashed rules and separators reused across table rows, +whose geometry can grow without a large text payload. Excessive or non-finite +rule geometry is refused before its producer loop. These are construction-count +limits, not a guarantee that every admitted render fits an arbitrary heap size. + ```java try { String svg = com.lattex.api.LatteX.render(userInput); diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 617b43b..813beba 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -6,6 +6,18 @@ LatteX turns LaTeX math into clean, self-contained **SVG** — pure Java, zero d ## Unreleased +### Render work is bounded before glyph and MathML allocation + +Repeated text macros and array separators now consume a fresh per-render materialized-work +budget before glyph lists or MathML strings are built. Excessive work produces a typed resource +cap instead of waiting for the final SVG-size check. The conservative budget also bounds traversal, +XML escaping and ancestor copying; ordinary accepted output is unchanged. + +Layout additionally charges every generated glyph/rule and its copies against +render-local construction limits. Dashed-rule geometry is checked before its +producer loop, and repeated table-separator stamps share one cumulative allowance. +This closes geometry amplification that text and AST counts alone cannot measure. + ### `\begin{equation}` renders, and says it dropped the number LatteX already rendered everything *inside* a display wrapper; only the outermost container was diff --git a/src/main/java/com/lattex/api/LatteX.java b/src/main/java/com/lattex/api/LatteX.java index 50df5a4..78ddbf3 100644 --- a/src/main/java/com/lattex/api/LatteX.java +++ b/src/main/java/com/lattex/api/LatteX.java @@ -1,5 +1,7 @@ package com.lattex.api; +import com.lattex.layout.RenderWorkBudget; + import java.util.List; import java.util.ArrayList; @@ -1189,6 +1191,7 @@ public static String toMathML(String latex) { * helper, so the two can never disagree on the root element. */ private static String mathmlDocument(MathNode node) { + RenderWorkBudget.checkMathML(node); return "" + toMathML(node) + ""; } diff --git a/src/main/java/com/lattex/layout/Box.java b/src/main/java/com/lattex/layout/Box.java index 1ab6e35..b45a1f1 100644 --- a/src/main/java/com/lattex/layout/Box.java +++ b/src/main/java/com/lattex/layout/Box.java @@ -46,6 +46,10 @@ static Box glue(double width) { * coordinate space (dy positive = further down). */ void drawInto(List outGlyphs, List outRules, double dx, double dy) { + // Reject the entire stamp before translating/cloning any polygon arrays. + // Canonical constructors charge the actual copies, so these do not reserve. + RenderWorkBudget.requireGlyphs(glyphs.size()); + RenderWorkBudget.requireRules(rules.size()); for (PositionedGlyph g : glyphs) { // unmappedCodePoint rides through the translate: every author glyph reaches the final // Layout via this copy, so dropping it here would leave the diagnostic reporting only diff --git a/src/main/java/com/lattex/layout/LayoutEngine.java b/src/main/java/com/lattex/layout/LayoutEngine.java index 2a5a565..8f8d697 100644 --- a/src/main/java/com/lattex/layout/LayoutEngine.java +++ b/src/main/java/com/lattex/layout/LayoutEngine.java @@ -114,6 +114,11 @@ private LayoutEngine() { /** Lays out a math tree at the origin; returns positioned glyphs/rules + bbox. */ public static Layout layout(MathNode root, LayoutContext ctx) { + RenderWorkBudget.checkLayout(root); + return RenderWorkBudget.withLayoutBudget(() -> layoutWithBudget(root, ctx)); + } + + private static Layout layoutWithBudget(MathNode root, LayoutContext ctx) { LAYOUT_BOXES.get()[0] = 0; // fresh L10 box budget per top-level layout Box box = layoutBox(root, ctx); List glyphs = new ArrayList<>(); @@ -2567,8 +2572,11 @@ private static void addVerticalRules(List rules, double regionStart, doubl if (count <= 0) { return; } + RenderWorkBudget.requireRules(count); double block = count * thick + (count - 1) * thick; // bars + inter-bar gaps double x0 = regionStart + (gap - block) / 2.0; + requireFiniteRuleGeometry(regionStart, gap, thick, topY, spanH, block, x0, + x0 + (count - 1) * 2.0 * thick); for (int k = 0; k < count; k++) { rules.add(new Rule(x0 + k * 2.0 * thick, topY, thick, spanH)); } @@ -2581,18 +2589,46 @@ private static void addVerticalRules(List rules, double regionStart, doubl */ private static void addHorizontalRule(List rules, double x, double y, double width, double thick, boolean dashed, double em) { + requireFiniteRuleGeometry(x, y, width, thick); if (!dashed) { rules.add(new Rule(x, y, width, thick)); return; } double dash = 0.25 * em; double gap = 0.18 * em; + double step = dash + gap; double cursor = x; double end = x + width; + requireFiniteRuleGeometry(x, y, width, thick, em, dash, step, end); + if (width <= 0) { + return; + } + // A finite bound on the requested dashes prevents an enormous partial list. + // One extra slot covers endpoint/subtraction rounding; the loop below keeps + // the original geometry for accepted input. Constructors still charge once. + double requested = Math.ceil(width / step) + 1; + if (!(dash > 0) || !(step > 0) || !(x + step > x) + || !(end + step > end) || !Double.isFinite(requested) + || requested > RenderWorkBudget.MAX_RULE_ALLOCATIONS) { + throw MathSyntaxException.capExceeded("output work budget exceeded: dashed rule geometry"); + } + RenderWorkBudget.requireRules((long) requested); while (cursor < end) { + double next = cursor + step; + if (!(next > cursor) || !Double.isFinite(next)) { + throw MathSyntaxException.capExceeded("output work budget exceeded: dashed rule progress"); + } double w = Math.min(dash, end - cursor); rules.add(new Rule(cursor, y, w, thick)); - cursor += dash + gap; + cursor = next; + } + } + + private static void requireFiniteRuleGeometry(double... values) { + for (double value : values) { + if (!Double.isFinite(value)) { + throw MathSyntaxException.capExceeded("output work budget exceeded: nonfinite rule geometry"); + } } } diff --git a/src/main/java/com/lattex/layout/PositionedGlyph.java b/src/main/java/com/lattex/layout/PositionedGlyph.java index 1f55ed1..a17a745 100644 --- a/src/main/java/com/lattex/layout/PositionedGlyph.java +++ b/src/main/java/com/lattex/layout/PositionedGlyph.java @@ -44,6 +44,10 @@ public record PositionedGlyph(int glyphId, double originX, double baselineY, dou Color color, int sourceCodePoint, int fenceDepth, int unmappedCodePoint) { + public PositionedGlyph { + RenderWorkBudget.chargeGlyph(); + } + /** A glyph with no source token — a construction glyph (not an author atom). */ public static final int NO_SOURCE = -1; diff --git a/src/main/java/com/lattex/layout/RenderWorkBudget.java b/src/main/java/com/lattex/layout/RenderWorkBudget.java new file mode 100644 index 0000000..e8e3e32 --- /dev/null +++ b/src/main/java/com/lattex/layout/RenderWorkBudget.java @@ -0,0 +1,220 @@ +package com.lattex.layout; + +import com.lattex.parse.MathNode; +import com.lattex.parse.MathSyntaxException; +import java.util.function.Supplier; + +/** + * Internal render preflight shared by SVG layout and MathML serialization. Public only + * so the API implementation can call into this unexported package; no mutable state + * survives a call. Layout additionally binds a cumulative allocation allowance for + * every primitive producer and copy, including geometry without text payload. + * + *

Tokens and boxes do not measure text: one TextRun token can request thousands of + * glyphs, and a shared array separator can be stamped on every row. Charge UTF-16 + * units PER OCCURRENCE, including spaces and supplementary-character surrogate pairs + * (deliberately conservative), without expanding text or allocating output. The + * 100k text allowance is independent of the unchanged parser/token/box limits. + */ +public final class RenderWorkBudget { + private static final long MAX_TEXT_UNITS = 100_000; + private static final long MAX_VISITS = 100_000; + private static final int MAX_DEPTH = 512; + private static final long MAX_LAYOUT_COPY_UNITS = 2_000_000; + private static final long MAX_MATHML_UNITS = 2_000_000; + private static final long MAX_MATHML_COPY_UNITS = 16_000_000; + + // Cumulative construction events, not the final retained primitive count. A flat + // 96k-atom layout creates 288k glyph placements (producer, row, final flattening); + // even one color-copy layer fits under 400k. Rules have their own 100k allowance + // because a tiny AST can generate or repeatedly stamp very large rule lists. + // These bound work and object counts, not bytes or a promise about arbitrary heaps. + static final long MAX_GLYPH_ALLOCATIONS = 400_000; + static final long MAX_RULE_ALLOCATIONS = 100_000; + private static final ScopedValue ALLOCATIONS = ScopedValue.newInstance(); + + private static final class Allocations { + long glyphs; + long rules; + } + + /** Synchronous render-local binding; nested calls and failures restore the caller. */ + static T withLayoutBudget(Supplier action) { + return ScopedValue.where(ALLOCATIONS, new Allocations()).call(action::get); + } + + static void chargeGlyph() { + if (ALLOCATIONS.isBound()) { + Allocations current = ALLOCATIONS.get(); + current.glyphs = charge(current.glyphs, 1, MAX_GLYPH_ALLOCATIONS, + "glyph allocations"); + } + } + + static void chargeRule() { + if (ALLOCATIONS.isBound()) { + Allocations current = ALLOCATIONS.get(); + current.rules = charge(current.rules, 1, MAX_RULE_ALLOCATIONS, + "rule allocations"); + } + } + + // Capacity prechecks reserve nothing: canonical constructors charge each event + // exactly once. Used before producer loops and polygon clone/translation work. + static void requireGlyphs(long count) { + if (ALLOCATIONS.isBound()) { + charge(ALLOCATIONS.get().glyphs, count, MAX_GLYPH_ALLOCATIONS, + "glyph allocations"); + } + } + + static void requireRules(long count) { + if (ALLOCATIONS.isBound()) { + charge(ALLOCATIONS.get().rules, count, MAX_RULE_ALLOCATIONS, + "rule allocations"); + } + } + + private final boolean mathml; + private long textUnits; + private long visits; + private long outputUnits; + private long copyUnits; + + private RenderWorkBudget(boolean mathml) { + this.mathml = mathml; + } + + /** Refuses excessive materialized text/ancestor copying before any layout boxes. */ + public static void checkLayout(MathNode root) { + new RenderWorkBudget(false).visit(root, 1); + } + + /** Refuses excessive materialized markup/ancestor copying before serialization. */ + public static void checkMathML(MathNode root) { + new RenderWorkBudget(true).visit(root, 1); + } + + private static long charge(long used, long amount, long limit, String kind) { + // Subtraction avoids overflow even for a future caller with a much larger bound. + if (amount < 0 || amount > limit - used) { + throw MathSyntaxException.capExceeded("output work budget exceeded: " + kind); + } + return used + amount; + } + + private void units(long payload, int depth) { + textUnits = charge(textUnits, payload, MAX_TEXT_UNITS, "materialized text"); + if (mathml) { + // xmlEscape expands a UTF-16 unit by at most 6 ("). 256 units per + // node/cell/row cover current fixed tags, attributes, numeric widths and + // fences, plus the document root. Table columnalign is charged separately. + long markup = 256 + 6 * payload; // payload <= String.length(): no overflow + outputUnits = charge(outputUnits, markup, MAX_MATHML_UNITS, "MathML markup"); + // Each ancestor copies its descendants into a concatenation or builder. + // Four copies per level also allow builder growth/toString and temporary + // helper wrappers (stack braces, negations, table separators). This is a + // conservative work policy, not an exact byte-size prediction. + copyUnits = charge(copyUnits, markup * 4 * (depth + 1), + MAX_MATHML_COPY_UNITS, "MathML copying"); + } else { + // Box composition copies glyph placements through ancestors. Include one + // unit for each non-text node and four placements/copies per level; text + // counts even in phantoms, whose boxes are allocated before ink is hidden. + copyUnits = charge(copyUnits, (payload + 1) * 4 * (depth + 1), + MAX_LAYOUT_COPY_UNITS, "layout copying"); + } + } + + private void visit(MathNode node, int depth) { + if (node == null) { + return; + } + if (depth > MAX_DEPTH) { + throw MathSyntaxException.capExceeded("output work budget exceeded: nesting too deep"); + } + if (++visits > MAX_VISITS) { + // Retain the existing direct-layout box-cap diagnostic for wide trees. + throw MathSyntaxException.capExceeded("layout box budget exceeded during output preflight"); + } + long payload = switch (node) { + case MathNode.TextRun t -> t.text().length(); + case MathNode.OperatorName o -> o.name().length(); + default -> 0; + }; + units(payload, depth); + int child = depth + 1; + // Exhaustiveness is intentional: a new text-bearing node must choose its + // materialized traversal here, rather than silently bypassing the budget. + switch (node) { + case MathNode.Atom _, MathNode.Spacing _, MathNode.MiddleDelim _, + MathNode.SizedDelim _, MathNode.TextRun _, MathNode.OperatorName _ -> { } + case MathNode.MathList n -> n.items().forEach(item -> visit(item, child)); + case MathNode.SupSub n -> { + visit(n.base(), child); visit(n.sup(), child); visit(n.sub(), child); + } + case MathNode.Fraction n -> { + visit(n.numerator(), child); visit(n.denominator(), child); + } + case MathNode.Radical n -> { + visit(n.radicand(), child); visit(n.index(), child); + } + case MathNode.BigOperator n -> { + visit(n.op(), child); visit(n.lower(), child); visit(n.upper(), child); + } + case MathNode.Fenced n -> visit(n.body(), child); + case MathNode.Colored n -> visit(n.body(), child); + case MathNode.ClassOverride n -> visit(n.body(), child); + case MathNode.Boxed n -> visit(n.body(), child); + case MathNode.Negated n -> visit(n.body(), child); + case MathNode.Cancel n -> { visit(n.body(), child); visit(n.to(), child); } + case MathNode.Tagged n -> { visit(n.body(), child); visit(n.label(), child); } + case MathNode.Accent n -> visit(n.base(), child); + case MathNode.Phantom n -> visit(n.content(), child); + case MathNode.Lap n -> visit(n.body(), child); + case MathNode.StyledMath n -> visit(n.body(), child); + case MathNode.StyleSwitch n -> visit(n.body(), child); + case MathNode.Stack n -> { + visit(n.base(), child); visit(n.above(), child); visit(n.below(), child); + } + case MathNode.XArrow n -> { visit(n.above(), child); visit(n.below(), child); } + case MathNode.CdArrow n -> { visit(n.labelA(), child); visit(n.labelB(), child); } + case MathNode.Matrix n -> matrix(n, child); + case MathNode.BorderMatrix n -> { + visit(n.corner(), child); + n.columnLabels().forEach(label -> visit(label, child)); + for (int r = 0; r < n.rowCount(); r++) { + units(0, child); // row markup + visit(n.rowLabels().get(r), child); + for (MathNode cell : n.body().get(r)) { + units(0, child); // cell markup/fences + visit(cell, child); + } + } + } + } + } + + private void matrix(MathNode.Matrix matrix, int depth) { + for (int c = 0; c < matrix.columnCount(); c++) { + units(0, depth); // columnalign attribute, including empty tables + } + for (int r = 0; r < matrix.rows().size(); r++) { + units(0, depth); // row and optional label-cell markup + visit(matrix.rowTags().get(r), depth); + for (MathNode cell : matrix.rows().get(r)) { + units(0, depth); // cell/span/alignment attributes + visit(cell, depth); + } + // Do not deduplicate shared separator ASTs. Layout creates the boundary + // box even when a span hides it; MathML emits visible material per row. + // Counting every boundary per row safely covers both, conservatively + // charging hidden material too, without invoking spanAt's linear scan. + for (MathNode.ColumnSeparator separator : matrix.columnSeparators()) { + if (separator != null) { + visit(separator.material(), depth); + } + } + } + } +} diff --git a/src/main/java/com/lattex/layout/Rule.java b/src/main/java/com/lattex/layout/Rule.java index 8d8a5f0..cf9bb37 100644 --- a/src/main/java/com/lattex/layout/Rule.java +++ b/src/main/java/com/lattex/layout/Rule.java @@ -33,6 +33,7 @@ public record Rule(double x, double y, double width, double height, Color color, double[] polygon) { public Rule { + RenderWorkBudget.chargeRule(); // Defensive copy so a caller's array can't mutate the rule out from under us. polygon = polygon == null ? null : polygon.clone(); } diff --git a/src/test/java/com/lattex/api/MathMLTest.java b/src/test/java/com/lattex/api/MathMLTest.java index 698e27e..913dca9 100644 --- a/src/test/java/com/lattex/api/MathMLTest.java +++ b/src/test/java/com/lattex/api/MathMLTest.java @@ -24,6 +24,43 @@ */ class MathMLTest { + @Test + void repeatedTextPayloadIsAResourceCapAndNextCallRemainsHealthy() { + MathSyntaxException cap = assertThrows(MathSyntaxException.class, + () -> LatteX.toMathML(OutputWorkBudgetProbe.witness("text"))); + assertTrue(cap.isCapExceeded()); + assertTrue(cap.getMessage().contains("output work budget"), cap.getMessage()); + assertWellFormed(LatteX.toMathML("\\text{healthy} + x^2")); + } + + @Test + void escapedTextAndRecursiveConcatenationAreBudgetedBeforeMaterialization() { + // Under the text/source limits, and less than 2M final output characters: + // escaping plus repeatedly copying a long descendant is its own work axis. + String source = "\\boxed{".repeat(20) + "\\text{" + "\"".repeat(90_000) + "}" + + "}".repeat(20); + MathSyntaxException cap = assertThrows(MathSyntaxException.class, + () -> LatteX.toMathML(source)); + assertTrue(cap.isCapExceeded()); + assertTrue(cap.getMessage().contains("MathML copying"), cap.getMessage()); + String healthy = LatteX.toMathML("\\boxed{\\text{\"<&>}} "); + assertTrue(healthy.contains(""<&>")); + assertWellFormed(healthy); + } + + @Test + void arraySeparatorPayloadIsChargedOnEveryRow() { + String source = "\\begin{array}{c@{\\text{" + "a".repeat(5_000) + "}}}" + + "x\\\\".repeat(24) + "x\\end{array}"; + MathSyntaxException cap = assertThrows(MathSyntaxException.class, + () -> LatteX.toMathML(source)); + assertTrue(cap.isCapExceeded()); + assertTrue(cap.getMessage().contains("output work budget"), cap.getMessage()); + assertTrue(LatteX.renderWithDiagnostics(source).diagnostics().outcome() + == Outcome.OUTPUT_CAP_EXCEEDED); + assertWellFormed(LatteX.toMathML("\\begin{array}{c@{\\text{ok}}}x\\\\x\\end{array}")); + } + /// Overload carrying the corpus coordinate, so a sweep failure names the ROW that produced it /// rather than only the emitted XML (plan bc50471c). private static void assertWellFormed(String xml, String where) { diff --git a/src/test/java/com/lattex/api/OutputWorkBudgetProbe.java b/src/test/java/com/lattex/api/OutputWorkBudgetProbe.java new file mode 100644 index 0000000..826d396 --- /dev/null +++ b/src/test/java/com/lattex/api/OutputWorkBudgetProbe.java @@ -0,0 +1,73 @@ +package com.lattex.api; + +import com.lattex.parse.MathSyntaxException; + +/** Standalone small-heap public-API probe; an escaping Error must make the fork fail. */ +public final class OutputWorkBudgetProbe { + private OutputWorkBudgetProbe() { } + + static String witness(String command) { + if (command.equals("dashed")) { + return "\\begin{array}{c}\\hdashline\\hspace{1000000000em}\\end{array}"; + } + if (command.equals("separator")) { + String inner = "\\begin{array}{" + "|".repeat(10_000) + "c}x\\end{array}"; + return "\\begin{array}{c@{" + inner + "}}" + + "x\\\\".repeat(999) + "x\\end{array}"; + } + // ~9.5k source chars, 900 macro calls, ~900 text tokens/boxes, but 4.5M + // materialized characters. Parser splice volume is intentionally well below + // its limit: a TextRun's payload, rather than token count, is the multiplier. + return "\\newcommand{\\zzq}{\\" + command + "{" + "a".repeat(5_000) + "}}" + + "\\zzq ".repeat(900); + } + + public static void main(String[] args) { + String mode = args[0]; + String hostile = witness(args[1]); + if (hostile.length() >= 100_000) { + throw new AssertionError("witness must remain under the source cap"); + } + // Repeat the refusal on the SAME thread, interleaved with a healthy render: + // no leaked per-render state may poison the next invocation. + for (int i = 0; i < 2; i++) { + try { + if (mode.equals("svg")) { + LatteX.render(hostile); + } else if (mode.equals("mathml")) { + LatteX.toMathML(hostile); + } else { + throw new AssertionError("unknown probe mode: " + mode); + } + throw new AssertionError("hostile output was accepted"); + } catch (MathSyntaxException cap) { + if (!cap.isCapExceeded() || !cap.getMessage().contains("output work budget")) { + throw new AssertionError("expected allocation-time output work refusal", cap); + } + if ((args[1].equals("text") || args[1].equals("textbf")) + && !cap.getMessage().contains("materialized text")) { + throw new AssertionError("text amplification must fail in preflight", cap); + } + if (args[1].equals("dashed") && !cap.getMessage().contains("dashed rule geometry")) { + throw new AssertionError("dashes must refuse before their producer loop", cap); + } + if (args[1].equals("separator") && !cap.getMessage().contains("rule allocations")) { + throw new AssertionError("separator copies must hit cumulative rule accounting", cap); + } + } + if (!LatteX.render("\\frac{x^2}{2}+\\text{healthy}").startsWith("")) { + throw new AssertionError("healthy control failed after refusal"); + } + } + if (mode.equals("svg")) { + RenderResult result = LatteX.renderWithDiagnostics(hostile); + if (result.diagnostics().outcome() != Outcome.OUTPUT_CAP_EXCEEDED + || !result.diagnostics().stage().equals("layout") + || LatteX.tryRenderMath(hostile).isPresent()) { + throw new AssertionError("diagnostic/fail-soft seams lost the output cap"); + } + } + System.out.println("OUTPUT_WORK_CAP_OK " + mode + " " + args[1]); + } +} diff --git a/src/test/java/com/lattex/api/OutputWorkBudgetTest.java b/src/test/java/com/lattex/api/OutputWorkBudgetTest.java new file mode 100644 index 0000000..44d7312 --- /dev/null +++ b/src/test/java/com/lattex/api/OutputWorkBudgetTest.java @@ -0,0 +1,166 @@ +package com.lattex.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.lattex.layout.RenderWorkBudget; +import com.lattex.parse.MathNode; +import com.lattex.parse.MathParser; +import com.lattex.parse.MathSyntaxException; +import java.io.File; +import java.net.JarURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class OutputWorkBudgetTest { + @TempDir Path temporary; + + @Test + void svgAndMathMLRefuseAmplificationInSmallHeapForks() throws Exception { + for (String mode : List.of("svg", "mathml")) { + for (String command : List.of("text", "textbf")) { + assertSmallHeapProbe(mode, command); + } + } + } + + @Test + void geometryWithoutTextRefusesBeforeAllocationInSmallHeapForks() throws Exception { + // The disabled-accounting mutant must fail only in these bounded forks, + // never by constructing a billion-rule witness in the test worker JVM. + assertSmallHeapProbe("svg", "dashed"); + assertSmallHeapProbe("svg", "separator"); + } + + private void assertSmallHeapProbe(String mode, String command) throws Exception { + Path log = temporary.resolve(mode + "-" + command + ".log"); + String javaName = System.getProperty("os.name").startsWith("Windows") + ? "java.exe" : "java"; + String java = Path.of(System.getProperty("java.home"), "bin", javaName).toString(); + ProcessBuilder builder = new ProcessBuilder(java, "-Xmx48m", "-cp", probeClasspath(), + OutputWorkBudgetProbe.class.getName(), mode, command) + .redirectErrorStream(true).redirectOutput(log.toFile()); + // The heap bound must not be replaced or supplemented by ambient flags. + builder.environment().remove("JAVA_TOOL_OPTIONS"); + builder.environment().remove("JDK_JAVA_OPTIONS"); + builder.environment().remove("_JAVA_OPTIONS"); + Process fork = builder.start(); + try { + assertTrue(fork.waitFor(30, TimeUnit.SECONDS), "small-heap probe timed out"); + String output = Files.readString(log, StandardCharsets.UTF_8); + assertEquals(0, fork.exitValue(), output); + assertEquals("OUTPUT_WORK_CAP_OK " + mode + " " + command, + output.strip(), "typed cap, never OOM or an unrelated parser cap"); + } finally { + if (fork.isAlive()) { + fork.destroyForcibly(); + fork.waitFor(5, TimeUnit.SECONDS); + } + } + } + + private static String probeClasspath() throws Exception { + // Gradle's java.class.path may contain only its worker bootstrap jar. Include + // the actual main/test code sources and the bundled font's resource root too. + Set entries = new LinkedHashSet<>(); + entries.add(System.getProperty("java.class.path")); + for (Class type : List.of(OutputWorkBudgetProbe.class, LatteX.class)) { + entries.add(Path.of(type.getProtectionDomain().getCodeSource().getLocation().toURI()) + .toString()); + } + URL font = LatteX.class.getResource("/com/lattex/font/STIXTwoMath-Regular.ttf"); + if (font == null) { + throw new AssertionError("bundled font missing from test classpath"); + } + if (font.getProtocol().equals("jar")) { + entries.add(Path.of(((JarURLConnection) font.openConnection()).getJarFileURL().toURI()) + .toString()); + } else { + Path root = Path.of(font.toURI()); + for (int i = 0; i < 4; i++) { + root = root.getParent(); + } + entries.add(root.toString()); + } + return String.join(File.pathSeparator, entries); + } + + @Test + void representativeMacroAndSpliceVolumeStillAdmit() { + String expanded = "\\text{healthy}".repeat(100); + String macro = "\\newcommand{\\word}{\\text{healthy}}" + "\\word ".repeat(100); + assertEquals(LatteX.render(expanded), LatteX.render(macro)); + assertEquals(LatteX.toMathML(expanded), LatteX.toMathML(macro)); + + // Existing parser acceptance controls: preserve 90k body-spliced atoms and + // 96k placeholder-spliced atoms. Rendering those may independently hit SVG's + // established 2M-character cap; do not pretend that parser success promises SVG. + String body = "x ".repeat(1_000).strip(); + MathNode ninetyThousand = MathParser.parse("\\newcommand{\\bigrun}{" + body + "}" + + "\\bigrun ".repeat(90)); + assertEquals(MathParser.parse("x".repeat(90_000)), ninetyThousand); + RenderWorkBudget.checkLayout(ninetyThousand); + String def = "\\newcommand{\\rep}[1]{#1#1#1#1#1#1}"; + MathNode ninetySixThousand = MathParser.parse(def + ("\\rep{" + body + "} ").repeat(16)); + assertEquals(MathParser.parse("x".repeat(96_000)), ninetySixThousand); + RenderWorkBudget.checkLayout(ninetySixThousand); + } + + @Test + void sharedTextAndOperatorPayloadsAreChargedPerOccurrence() { + for (MathNode payload : List.of( + new MathNode.TextRun("a".repeat(50_001), MathNode.TextStyle.ROMAN), + new MathNode.OperatorName("a".repeat(50_001), false))) { + MathNode repeated = new MathNode.MathList(List.of(payload, payload)); + assertTrue(assertThrows(MathSyntaxException.class, + () -> RenderWorkBudget.checkLayout(repeated)).isCapExceeded()); + assertTrue(assertThrows(MathSyntaxException.class, + () -> RenderWorkBudget.checkMathML(repeated)).isCapExceeded()); + } + } + + @Test + void textLimitCountsSpacesAndUtf16PairsWithAnAdmittedBoundaryControl() { + for (String payload : List.of(" ".repeat(50_000), "😀".repeat(25_000))) { + MathNode text = new MathNode.TextRun(payload, MathNode.TextStyle.ROMAN); + MathNode atBoundary = new MathNode.MathList(List.of(text, text)); + // Two shared 50k-unit payloads must admit; node-identity deduplication + // would incorrectly admit a third, including inkless spaces. + RenderWorkBudget.checkLayout(atBoundary); + RenderWorkBudget.checkMathML(atBoundary); + MathNode over = new MathNode.MathList(List.of(text, text, text)); + assertTrue(assertThrows(MathSyntaxException.class, + () -> RenderWorkBudget.checkLayout(over)).isCapExceeded()); + assertTrue(assertThrows(MathSyntaxException.class, + () -> RenderWorkBudget.checkMathML(over)).isCapExceeded()); + } + } + + @Test + void programmaticFanOutAndDepthAreBoundedWithoutRecursiveOutput() { + MathNode node = MathNode.Atom.ord('x'); + for (int i = 0; i < 600; i++) { + node = new MathNode.Boxed(node); + } + MathNode deep = node; + assertTrue(assertThrows(MathSyntaxException.class, + () -> RenderWorkBudget.checkLayout(deep)).isCapExceeded()); + assertTrue(assertThrows(MathSyntaxException.class, + () -> RenderWorkBudget.checkMathML(deep)).isCapExceeded()); + MathNode wide = new MathNode.MathList(java.util.Collections.nCopies(100_001, + MathNode.Atom.ord('x'))); + assertTrue(assertThrows(MathSyntaxException.class, + () -> RenderWorkBudget.checkLayout(wide)).isCapExceeded()); + assertTrue(assertThrows(MathSyntaxException.class, + () -> RenderWorkBudget.checkMathML(wide)).isCapExceeded()); + } +} diff --git a/src/test/java/com/lattex/layout/GeometryWorkBudgetTest.java b/src/test/java/com/lattex/layout/GeometryWorkBudgetTest.java new file mode 100644 index 0000000..7fff046 --- /dev/null +++ b/src/test/java/com/lattex/layout/GeometryWorkBudgetTest.java @@ -0,0 +1,225 @@ +package com.lattex.layout; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.lattex.api.Color; +import com.lattex.api.LatteX; +import com.lattex.font.SfntFont; +import com.lattex.parse.MathNode; +import com.lattex.parse.MathParser; +import com.lattex.parse.MathSyntaxException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Test; + +/** Actual construction/copy work, including geometry with no textual AST payload. */ +class GeometryWorkBudgetTest { + private static final SfntFont FONT = SfntFont.loadBundled(); + + private static LayoutContext context() { + return new LayoutContext(FONT, FONT.mathConstants(), 40.0); + } + + private static Layout layout(String source) { + return LayoutEngine.layout(MathParser.parse(source), context()); + } + + private static MathNode.Matrix grid(MathNode cell, int vertical, MathNode.RowRule horizontal) { + return new MathNode.Matrix(List.of(List.of(cell)), List.of(MathNode.ColumnAlign.CENTER), + List.of(vertical, 0), List.of(horizontal, MathNode.RowRule.NONE), + MathNode.Fenced.NULL_DELIMITER, MathNode.Fenced.NULL_DELIMITER, MathNode.MatrixKind.ARRAY); + } + + private static void assertWorkCap(Runnable action) { + MathSyntaxException failure = assertThrows(MathSyntaxException.class, action::run); + assertTrue(failure.isCapExceeded(), failure.getMessage()); + assertTrue(failure.getMessage().contains("output work budget"), failure.getMessage()); + } + + @Test + void ordinaryRulesAndRepeatedSeparatorsKeepTheirInk() { + // Separator material and a vertical rule cannot share one column boundary. + // Put the repeated separator between two real columns, with outer bars apart. + String source = "\\begin{array}{|c@{\\begin{array}{||c|}x\\end{array}}c|}" + + "a&b\\\\c&d\\end{array}"; + Layout result = layout(source); + assertEquals(6, result.glyphs().size(), "four cells plus the separator on BOTH rows"); + assertEquals(8, result.rules().size(), "two outer bars plus three separator bars per row"); + assertTrue(layout("\\begin{array}{c}\\hdashline x\\end{array}").rules().size() > 0); + String colored = "\\textcolor{red}{" + source + "}"; + assertEquals(LatteX.render(colored), LatteX.render(colored), + "ordinary repeated renders remain byte-identical"); + } + + @Test + void excessiveVerticalCountRefusesBeforeItsProducerLoop() { + assertWorkCap(() -> LayoutEngine.layout( + grid(MathNode.Atom.ord('x'), Integer.MAX_VALUE, MathNode.RowRule.NONE), context())); + assertEquals(1, layout("x").glyphs().size()); + } + + @Test + void nonfiniteDashedGeometryIsTypedWithoutLooping() { + for (double width : List.of(Double.NaN, Double.POSITIVE_INFINITY)) { + assertWorkCap(() -> LayoutEngine.layout( + grid(new MathNode.Spacing(width), 0, MathNode.RowRule.DASHED), context())); + } + assertEquals(1, layout("x").glyphs().size()); + } + + @Test + void repeatedBoxStampsChargeCumulativelyAcrossDestinations() { + // This bounded ~100k-copy discriminator is safe even with accounting removed; + // the 10M-copy public witness belongs only in OutputWorkBudgetTest's 48m fork. + Rule polygon = Rule.polygon(new double[] {0, 0, 1, 0, 0, 1}, null); + Box shared = new Box(List.of(), Collections.nCopies(1_000, polygon), 1, 1, 0); + RenderWorkBudget.withLayoutBudget(() -> { + for (int i = 0; i < 100; i++) { + List destination = new ArrayList<>(); + shared.drawInto(new ArrayList<>(), destination, i, 0); + assertEquals(1_000, destination.size()); + } + List refusedDestination = new ArrayList<>(); + assertWorkCap(() -> shared.drawInto(new ArrayList<>(), refusedDestination, 0, 0)); + assertTrue(refusedDestination.isEmpty(), "precheck precedes polygon cloning/translation"); + assertWorkCap(() -> polygon.paintedWith(Color.BLACK)); + return null; + }); + } + + @Test + void ruleCapacityPrecheckRefusesBeforeAnyGlyphOrRuleAppend() { + // Construct the shared originals unbound, leaving exactly one rule slot below. + PositionedGlyph glyph = new PositionedGlyph(1, 0, 0, 1); + Rule polygon = Rule.polygon(new double[] {0, 0, 1, 0, 0, 1}, null); + Box shared = new Box(List.of(glyph), List.of(polygon, polygon), 1, 1, 0); + RenderWorkBudget.withLayoutBudget(() -> { + for (int i = 0; i < 99_999; i++) { + new Rule(0, 0, 1, 1); + } + List glyphs = new ArrayList<>(); + List rules = new ArrayList<>(); + assertWorkCap(() -> shared.drawInto(glyphs, rules, 0, 0)); + // Without the rule precheck, the glyph and first rule would be appended + // before the second rule's canonical constructor refuses the stamp. + assertTrue(glyphs.isEmpty(), "rule precheck must precede every glyph append"); + assertTrue(rules.isEmpty(), "rule precheck must precede every rule append"); + return null; + }); + } + + @Test + void glyphCapacityPrecheckRefusesBeforeAnyGlyphOrRuleAppend() { + // Two requested copies must refuse with one slot left, not after a partial stamp. + PositionedGlyph glyph = new PositionedGlyph(1, 0, 0, 1); + Rule rule = new Rule(0, 0, 1, 1); + Box shared = new Box(List.of(glyph, glyph), List.of(rule), 1, 1, 0); + RenderWorkBudget.withLayoutBudget(() -> { + for (int i = 0; i < 399_999; i++) { + new PositionedGlyph(1, 0, 0, 1); + } + List glyphs = new ArrayList<>(); + List rules = new ArrayList<>(); + assertWorkCap(() -> shared.drawInto(glyphs, rules, 0, 0)); + // Removing only the glyph precheck permits one append before refusal. + assertTrue(glyphs.isEmpty(), "glyph precheck must precede every glyph append"); + assertTrue(rules.isEmpty(), "glyph precheck must precede every rule append"); + return null; + }); + } + + @Test + void glyphCopiesAndColorRebuildsUseTheSameCumulativeAllowance() { + PositionedGlyph original = new PositionedGlyph(1, 0, 0, 1); + Box shared = new Box(Collections.nCopies(1_000, original), List.of(), 1, 1, 0); + RenderWorkBudget.withLayoutBudget(() -> { + for (int i = 0; i < 400; i++) { + List destination = new ArrayList<>(); + shared.drawInto(destination, new ArrayList<>(), i, 0); + assertEquals(1_000, destination.size()); + } + assertWorkCap(() -> original.paintedWith(Color.BLACK)); + assertWorkCap(() -> shared.drawInto(new ArrayList<>(), new ArrayList<>(), 0, 0)); + return null; + }); + } + + @Test + void nestedAndExceptionalBindingsRestoreOuterAndUnboundLifetimes() { + RenderWorkBudget.withLayoutBudget(() -> { + for (int i = 0; i < 100_000; i++) { + new Rule(0, 0, 1, 1); + } + assertWorkCap(() -> new Rule(0, 0, 1, 1)); + assertThrows(IllegalStateException.class, () -> RenderWorkBudget.withLayoutBudget(() -> { + new Rule(0, 0, 1, 1); // Fresh inner budget, even though the outer is exhausted. + assertEquals(1, layout("x").glyphs().size()); // A real nested layout binding. + throw new IllegalStateException("inner failure"); + })); + assertWorkCap(() -> new Rule(0, 0, 1, 1)); // Outer exhaustion was restored. + return null; + }); + assertThrows(IllegalArgumentException.class, () -> RenderWorkBudget.withLayoutBudget(() -> { + throw new IllegalArgumentException("outer failure"); + })); + // Ordinary direct record construction outside a render remains unrestricted. + new Rule(0, 0, 1, 1); + new PositionedGlyph(1, 0, 0, 1); + assertEquals(1, layout("x").glyphs().size()); + } + + @Test + void concurrentRendersHaveIndependentCumulativeAllowances() throws Exception { + CountDownLatch together = new CountDownLatch(2); + CountDownLatch firstCharged = new CountDownLatch(1); + CountDownLatch secondCharged = new CountDownLatch(1); + try (var executor = Executors.newFixedThreadPool(2)) { + var first = executor.submit(() -> + independentWork(together, null, firstCharged, secondCharged)); + var second = executor.submit(() -> + independentWork(together, firstCharged, secondCharged, null)); + assertEquals(70_000, first.get(10, TimeUnit.SECONDS).intValue()); + assertEquals(70_000, second.get(10, TimeUnit.SECONDS).intValue()); + } + } + + private static int independentWork(CountDownLatch together, CountDownLatch predecessor, + CountDownLatch charged, CountDownLatch successor) { + return RenderWorkBudget.withLayoutBudget(() -> { + // Both bindings stay active through both batches. Serialize the charges so a + // shared-counter mutant cannot pass through scheduling or lost-update races. + together.countDown(); + await(together); + if (predecessor != null) { + await(predecessor); + } + try { + for (int i = 0; i < 70_000; i++) { + new Rule(0, 0, 1, 1); + } + } finally { + charged.countDown(); + } + if (successor != null) { + await(successor); + } + assertEquals(1, layout("x").glyphs().size()); + return 70_000; + }); + } + + private static void await(CountDownLatch latch) { + try { + assertTrue(latch.await(5, TimeUnit.SECONDS), "budget binding rendezvous timed out"); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new AssertionError("budget binding rendezvous interrupted", interrupted); + } + } +} diff --git a/src/test/java/com/lattex/layout/ResourceCapDirectTest.java b/src/test/java/com/lattex/layout/ResourceCapDirectTest.java index ada46c7..e6fdeed 100644 --- a/src/test/java/com/lattex/layout/ResourceCapDirectTest.java +++ b/src/test/java/com/lattex/layout/ResourceCapDirectTest.java @@ -1,5 +1,6 @@ package com.lattex.layout; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -34,6 +35,22 @@ private static MathNode flatList(int atoms) { return new MathNode.MathList(items); } + @Test + void repeatedTextAndOperatorNamesRefuseBeforeGlyphAllocation() { + LayoutContext ctx = new LayoutContext(FONT, FONT.mathConstants(), 40.0); + for (MathNode payload : List.of( + new MathNode.TextRun("a".repeat(50_001), MathNode.TextStyle.ROMAN), + new MathNode.OperatorName("a".repeat(50_001), false))) { + MathNode repeated = new MathNode.MathList(List.of(payload, payload)); + MathSyntaxException ex = assertThrows(MathSyntaxException.class, + () -> LayoutEngine.layout(repeated, ctx)); + assertTrue(ex.isCapExceeded()); + assertTrue(ex.getMessage().contains("output work budget"), ex.getMessage()); + assertTrue(LayoutEngine.layout(MathNode.Atom.ord('x'), ctx).glyphs().size() == 1, + "a refused render must not poison the next layout"); + } + } + @Test void layoutBoxBudgetTripsOnAWideShallowTree() { // ~150k atoms: shallow (depth 2, under the depth guard) but past the 100k box @@ -45,6 +62,12 @@ void layoutBoxBudgetTripsOnAWideShallowTree() { assertTrue(ex.getMessage().contains("box budget"), ex.getMessage()); } + @Test + void directNinetySixThousandFlatAtomsRetainLayoutAcceptance() { + LayoutContext ctx = new LayoutContext(FONT, FONT.mathConstants(), 40.0); + assertEquals(96_000, LayoutEngine.layout(flatList(96_000), ctx).glyphs().size()); + } + @Test void emitterOutputCapTripsBeforeBuildingAHugeSvg() { // ~90k atoms: layout SUCCEEDS (under the box budget), but emitting ~90k