authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-17 21:32:30-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-18 00:59:06-08:00
log4e791759cfecaa74319959fe800e0a34c680c442
tree9e998a033ad6e0146e8015a79a8b00b8b031df77
parent9799b42dcd46fec621ab3abf48332f22950537af

Address second-pass PR feedback on parser hardening

Co-authored-by: codex <codex@users.noreply.github.com>

3 files changed, 40 insertions(+), 16 deletions(-)

apps/server/src/keybindings.test.ts+8
......@@ -91,6 +91,14 @@ describe("server keybindings", () => {
9191 when: "terminalFocus && (",
9292 }),
9393 );
94
95 assert.isNull(
96 compileResolvedKeybindingRule({
97 key: "mod+d",
98 command: "terminal.split",
99 when: `${"!".repeat(300)}terminalFocus`,
100 }),
101 );
94102 });
95103
96104 it("upserts custom keybindings to ~/.t3/keybindings.json", () => {
apps/server/src/keybindings.ts+31-16
......@@ -24,6 +24,8 @@ type WhenToken =
2424 | { type: "lparen" }
2525 | { type: "rparen" };
2626
27const MAX_WHEN_EXPRESSION_DEPTH = 256;
28
2729export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [
2830 { key: "mod+j", command: "terminal.toggle" },
2931 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },
......@@ -159,7 +161,10 @@ export function parseKeybindingWhenExpression(expression: string): KeybindingWhe
159161 if (!tokens || tokens.length === 0) return null;
160162 let index = 0;
161163
162 const parsePrimary = (): KeybindingWhenNode | null => {
164 const parsePrimary = (depth: number): KeybindingWhenNode | null => {
165 if (depth > MAX_WHEN_EXPRESSION_DEPTH) {
166 return null;
167 }
163168 const token = tokens[index];
164169 if (!token) return null;
165170
......@@ -170,7 +175,7 @@ export function parseKeybindingWhenExpression(expression: string): KeybindingWhe
170175
171176 if (token.type === "lparen") {
172177 index += 1;
173 const expressionNode = parseOr();
178 const expressionNode = parseOr(depth + 1);
174179 const closeToken = tokens[index];
175180 if (!expressionNode || !closeToken || closeToken.type !== "rparen") {
176181 return null;
......@@ -182,24 +187,34 @@ export function parseKeybindingWhenExpression(expression: string): KeybindingWhe
182187 return null;
183188 };
184189
185 const parseUnary = (): KeybindingWhenNode | null => {
186 const token = tokens[index];
187 if (token?.type === "not") {
190 const parseUnary = (depth: number): KeybindingWhenNode | null => {
191 let notCount = 0;
192 while (tokens[index]?.type === "not") {
188193 index += 1;
189 const node = parseUnary();
190 if (!node) return null;
191 return { type: "not", node };
194 notCount += 1;
195 if (notCount > MAX_WHEN_EXPRESSION_DEPTH) {
196 return null;
197 }
192198 }
193 return parsePrimary();
199
200 let node = parsePrimary(depth);
201 if (!node) return null;
202
203 while (notCount > 0) {
204 node = { type: "not", node };
205 notCount -= 1;
206 }
207
208 return node;
194209 };
195210
196 const parseAnd = (): KeybindingWhenNode | null => {
197 let left = parseUnary();
211 const parseAnd = (depth: number): KeybindingWhenNode | null => {
212 let left = parseUnary(depth);
198213 if (!left) return null;
199214
200215 while (tokens[index]?.type === "and") {
201216 index += 1;
202 const right = parseUnary();
217 const right = parseUnary(depth);
203218 if (!right) return null;
204219 left = { type: "and", left, right };
205220 }
......@@ -207,13 +222,13 @@ export function parseKeybindingWhenExpression(expression: string): KeybindingWhe
207222 return left;
208223 };
209224
210 const parseOr = (): KeybindingWhenNode | null => {
211 let left = parseAnd();
225 const parseOr = (depth: number): KeybindingWhenNode | null => {
226 let left = parseAnd(depth);
212227 if (!left) return null;
213228
214229 while (tokens[index]?.type === "or") {
215230 index += 1;
216 const right = parseAnd();
231 const right = parseAnd(depth);
217232 if (!right) return null;
218233 left = { type: "or", left, right };
219234 }
......@@ -221,7 +236,7 @@ export function parseKeybindingWhenExpression(expression: string): KeybindingWhe
221236 return left;
222237 };
223238
224 const ast = parseOr();
239 const ast = parseOr(0);
225240 if (!ast || index !== tokens.length) return null;
226241 return ast;
227242}
apps/web/src/projectScripts.ts+1
......@@ -48,6 +48,7 @@ export function nextProjectScriptId(name: string, existingIds: Iterable<string>)
4848 suffix += 1;
4949 }
5050
51 // This last-resort fallback only triggers after exhausting thousands of suffixes.
5152 return `${baseId}-${Date.now()}`.slice(0, MAX_SCRIPT_ID_LENGTH);
5253}
5354