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", () => {...@@ -91,6 +91,14 @@ describe("server keybindings", () => {
91 when: "terminalFocus && (",91 when: "terminalFocus && (",
92 }),92 }),
93 );93 );
94
95 assert.isNull(
96 compileResolvedKeybindingRule({
97 key: "mod+d",
98 command: "terminal.split",
99 when: `${"!".repeat(300)}terminalFocus`,
100 }),
101 );
94 });102 });
95103
96 it("upserts custom keybindings to ~/.t3/keybindings.json", () => {104 it("upserts custom keybindings to ~/.t3/keybindings.json", () => {
apps/server/src/keybindings.ts+31-16
...@@ -24,6 +24,8 @@ type WhenToken =...@@ -24,6 +24,8 @@ type WhenToken =
24 | { type: "lparen" }24 | { type: "lparen" }
25 | { type: "rparen" };25 | { type: "rparen" };
2626
27const MAX_WHEN_EXPRESSION_DEPTH = 256;
28
27export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [29export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [
28 { key: "mod+j", command: "terminal.toggle" },30 { key: "mod+j", command: "terminal.toggle" },
29 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },31 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },
...@@ -159,7 +161,10 @@ export function parseKeybindingWhenExpression(expression: string): KeybindingWhe...@@ -159,7 +161,10 @@ export function parseKeybindingWhenExpression(expression: string): KeybindingWhe
159 if (!tokens || tokens.length === 0) return null;161 if (!tokens || tokens.length === 0) return null;
160 let index = 0;162 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 }
163 const token = tokens[index];168 const token = tokens[index];
164 if (!token) return null;169 if (!token) return null;
165170
...@@ -170,7 +175,7 @@ export function parseKeybindingWhenExpression(expression: string): KeybindingWhe...@@ -170,7 +175,7 @@ export function parseKeybindingWhenExpression(expression: string): KeybindingWhe
170175
171 if (token.type === "lparen") {176 if (token.type === "lparen") {
172 index += 1;177 index += 1;
173 const expressionNode = parseOr();178 const expressionNode = parseOr(depth + 1);
174 const closeToken = tokens[index];179 const closeToken = tokens[index];
175 if (!expressionNode || !closeToken || closeToken.type !== "rparen") {180 if (!expressionNode || !closeToken || closeToken.type !== "rparen") {
176 return null;181 return null;
...@@ -182,24 +187,34 @@ export function parseKeybindingWhenExpression(expression: string): KeybindingWhe...@@ -182,24 +187,34 @@ export function parseKeybindingWhenExpression(expression: string): KeybindingWhe
182 return null;187 return null;
183 };188 };
184189
185 const parseUnary = (): KeybindingWhenNode | null => {190 const parseUnary = (depth: number): KeybindingWhenNode | null => {
186 const token = tokens[index];191 let notCount = 0;
187 if (token?.type === "not") {192 while (tokens[index]?.type === "not") {
188 index += 1;193 index += 1;
189 const node = parseUnary();194 notCount += 1;
190 if (!node) return null;195 if (notCount > MAX_WHEN_EXPRESSION_DEPTH) {
191 return { type: "not", node };196 return null;
197 }
192 }198 }
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;
194 };209 };
195210
196 const parseAnd = (): KeybindingWhenNode | null => {211 const parseAnd = (depth: number): KeybindingWhenNode | null => {
197 let left = parseUnary();212 let left = parseUnary(depth);
198 if (!left) return null;213 if (!left) return null;
199214
200 while (tokens[index]?.type === "and") {215 while (tokens[index]?.type === "and") {
201 index += 1;216 index += 1;
202 const right = parseUnary();217 const right = parseUnary(depth);
203 if (!right) return null;218 if (!right) return null;
204 left = { type: "and", left, right };219 left = { type: "and", left, right };
205 }220 }
...@@ -207,13 +222,13 @@ export function parseKeybindingWhenExpression(expression: string): KeybindingWhe...@@ -207,13 +222,13 @@ export function parseKeybindingWhenExpression(expression: string): KeybindingWhe
207 return left;222 return left;
208 };223 };
209224
210 const parseOr = (): KeybindingWhenNode | null => {225 const parseOr = (depth: number): KeybindingWhenNode | null => {
211 let left = parseAnd();226 let left = parseAnd(depth);
212 if (!left) return null;227 if (!left) return null;
213228
214 while (tokens[index]?.type === "or") {229 while (tokens[index]?.type === "or") {
215 index += 1;230 index += 1;
216 const right = parseAnd();231 const right = parseAnd(depth);
217 if (!right) return null;232 if (!right) return null;
218 left = { type: "or", left, right };233 left = { type: "or", left, right };
219 }234 }
...@@ -221,7 +236,7 @@ export function parseKeybindingWhenExpression(expression: string): KeybindingWhe...@@ -221,7 +236,7 @@ export function parseKeybindingWhenExpression(expression: string): KeybindingWhe
221 return left;236 return left;
222 };237 };
223238
224 const ast = parseOr();239 const ast = parseOr(0);
225 if (!ast || index !== tokens.length) return null;240 if (!ast || index !== tokens.length) return null;
226 return ast;241 return ast;
227}242}
apps/web/src/projectScripts.ts+1
...@@ -48,6 +48,7 @@ export function nextProjectScriptId(name: string, existingIds: Iterable<string>)...@@ -48,6 +48,7 @@ export function nextProjectScriptId(name: string, existingIds: Iterable<string>)
48 suffix += 1;48 suffix += 1;
49 }49 }
5050
51 // This last-resort fallback only triggers after exhausting thousands of suffixes.
51 return `${baseId}-${Date.now()}`.slice(0, MAX_SCRIPT_ID_LENGTH);52 return `${baseId}-${Date.now()}`.slice(0, MAX_SCRIPT_ID_LENGTH);
52}53}
5354