fix(hashline): secured parser against unsafe integers and large ranges
- Validate line numbers as safe integers and reject values exceeding `Number.MAX_SAFE_INTEGER`. - Impose a maximum expansion limit of 100,000 lines on patch ranges to prevent memory and CPU exhaustion. - Inline delete range iteration directly into execution to avoid allocating intermediate anchor arrays.
This commit is contained in:
@@ -2,6 +2,10 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Rejected line anchors above `Number.MAX_SAFE_INTEGER` and ranges spanning more than 100,000 lines before expansion, preventing streaming previews from exhausting CPU and memory.
|
||||
|
||||
## [17.1.5] - 2026-07-27
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -20,6 +20,9 @@ import {
|
||||
import { stripOneLeadingHashlinePrefix } from "./prefixes";
|
||||
import { type BlockTarget, cloneCursor, type ParsedRange, type Token, Tokenizer } from "./tokenizer";
|
||||
import type { Anchor, BlockSpan, Cursor, Edit, FileOp } from "./types";
|
||||
|
||||
/** Bounds parser amplification before the target file's line count is available. */
|
||||
const MAX_EXPANDED_RANGE_LINES = 100_000;
|
||||
/** Parser error carrying enough range metadata for source-aware diagnostic enrichment. */
|
||||
export class InvalidAbsoluteRangeError extends Error {
|
||||
/** Patch-language line containing the invalid range header. */
|
||||
@@ -46,16 +49,26 @@ export class InvalidAbsoluteRangeError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
function validateRangeOrder(range: ParsedRange, lineNum: number, op: AbsoluteRangeOp): void {
|
||||
function validateRange(range: ParsedRange, lineNum: number, op: AbsoluteRangeOp): void {
|
||||
if (
|
||||
!Number.isSafeInteger(range.start.line) ||
|
||||
range.start.line < 1 ||
|
||||
!Number.isSafeInteger(range.end.line) ||
|
||||
range.end.line < 1
|
||||
) {
|
||||
throw new Error(
|
||||
`line ${lineNum}: ${op} range endpoints must be positive safe integers; got ${range.start.line} and ${range.end.line}.`,
|
||||
);
|
||||
}
|
||||
if (range.end.line < range.start.line) {
|
||||
throw new InvalidAbsoluteRangeError(lineNum, range.start.line, range.end.line, op);
|
||||
}
|
||||
}
|
||||
|
||||
function expandRange(range: ParsedRange): Anchor[] {
|
||||
const anchors: Anchor[] = [];
|
||||
for (let line = range.start.line; line <= range.end.line; line++) anchors.push({ line });
|
||||
return anchors;
|
||||
const span = range.end.line - range.start.line + 1;
|
||||
if (span > MAX_EXPANDED_RANGE_LINES) {
|
||||
throw new Error(
|
||||
`line ${lineNum}: ${op} range spans ${span} lines; the maximum is ${MAX_EXPANDED_RANGE_LINES}. Split it into smaller hunks.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function isSkippableCommentLine(line: string): boolean {
|
||||
@@ -195,7 +208,7 @@ export class Executor {
|
||||
case "op-block":
|
||||
this.#discardPendingSkippableComments();
|
||||
if (token.target.kind === "replace" || token.target.kind === "delete") {
|
||||
validateRangeOrder(token.target.range, token.lineNum, token.target.kind);
|
||||
validateRange(token.target.range, token.lineNum, token.target.kind);
|
||||
}
|
||||
if (token.target.kind === "rem") {
|
||||
this.#flushPending();
|
||||
@@ -438,6 +451,10 @@ export class Executor {
|
||||
this.#edits.push({ kind: "delete", anchor: { ...anchor }, lineNum, index: this.#editIndex++ });
|
||||
}
|
||||
|
||||
#pushDeleteRange(range: ParsedRange, lineNum: number): void {
|
||||
for (let line = range.start.line; line <= range.end.line; line++) this.#pushDelete({ line }, lineNum);
|
||||
}
|
||||
|
||||
#pushBlock(anchor: Anchor, payloads: readonly PayloadRow[], lineNum: number, mode?: "insert_after"): void {
|
||||
this.#edits.push({
|
||||
kind: "block",
|
||||
@@ -461,7 +478,7 @@ export class Executor {
|
||||
this.#stripBarePrefixesIfUniform(payloads);
|
||||
this.#pending = undefined;
|
||||
if (target.kind === "delete") {
|
||||
for (const anchor of expandRange(target.range)) this.#pushDelete(anchor, lineNum);
|
||||
this.#pushDeleteRange(target.range, lineNum);
|
||||
return;
|
||||
}
|
||||
if (target.kind === "delete_block") {
|
||||
@@ -481,7 +498,7 @@ export class Executor {
|
||||
}
|
||||
if (payloads.length === 0) {
|
||||
if (target.kind === "replace") {
|
||||
for (const anchor of expandRange(target.range)) this.#pushDelete(anchor, lineNum);
|
||||
this.#pushDeleteRange(target.range, lineNum);
|
||||
return;
|
||||
}
|
||||
throw new Error(`line ${lineNum}: ${EMPTY_INSERT}`);
|
||||
@@ -489,7 +506,7 @@ export class Executor {
|
||||
if (target.kind === "replace") {
|
||||
const cursor: Cursor = { kind: "before_anchor", anchor: { ...target.range.start } };
|
||||
this.#emitPayloadRows(cursor, payloads, lineNum, "replacement");
|
||||
for (const anchor of expandRange(target.range)) this.#pushDelete(anchor, lineNum);
|
||||
this.#pushDeleteRange(target.range, lineNum);
|
||||
return;
|
||||
}
|
||||
if (target.kind === "insert_before") {
|
||||
|
||||
@@ -132,6 +132,7 @@ function scanLineNumber(line: string, index: number, end: number): NumberScan |
|
||||
const code = line.charCodeAt(nextIndex);
|
||||
if (!isDigitCode(code)) break;
|
||||
lineNumber = lineNumber * 10 + (code - CHAR_ZERO);
|
||||
if (!Number.isSafeInteger(lineNumber)) return null;
|
||||
nextIndex++;
|
||||
}
|
||||
return { line: lineNumber, nextIndex };
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { applyEdits, parsePatch, parsePatchStreaming } from "@oh-my-pi/hashline";
|
||||
import { applyEdits, parseLid, parsePatch, parsePatchStreaming, Tokenizer } from "@oh-my-pi/hashline";
|
||||
|
||||
function applyPatch(text: string, diff: string): string {
|
||||
return applyEdits(text, parsePatch(diff).edits).text;
|
||||
@@ -72,6 +72,19 @@ describe("hashline format v4", () => {
|
||||
expect(() => applyEdits("a\nb", edits)).toThrow(/Line 4 does not exist/);
|
||||
});
|
||||
|
||||
it("rejects unsafe line numbers before expanding ranges", () => {
|
||||
const unsafe = String(Number.MAX_SAFE_INTEGER + 1);
|
||||
expect(parseLid(String(Number.MAX_SAFE_INTEGER), 1)).toEqual({ line: Number.MAX_SAFE_INTEGER });
|
||||
expect(() => parseLid(unsafe, 1)).toThrow();
|
||||
expect(new Tokenizer().isOp(`SWAP ${unsafe}.=${unsafe}:`)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects safe-integer ranges above the expansion limit", () => {
|
||||
expect(() => parsePatch("SWAP 1.=100001:\n+x")).toThrow(
|
||||
/replace range spans 100001 lines; the maximum is 100000/,
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores deleting the trailing blank sentinel of a newline-terminated file", () => {
|
||||
// "a\nb\n" splits into ["a", "b", ""]; line 3 is the phantom sentinel.
|
||||
const edits = parsePatch("DEL 3").edits;
|
||||
|
||||
Reference in New Issue
Block a user