diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5ab92a8d..4e668f28 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,12 @@ on: branches: [main] pull_request: +# Least privilege for the GITHUB_TOKEN: every job only reads the repository. Caches +# (setup-node, rust-cache, cache-cargo-install) use the Actions cache service, not this token; +# nothing here comments, uploads releases or writes checks. +permissions: + contents: read + concurrency: group: ci-${{ github.ref }} cancel-in-progress: true diff --git a/.github/workflows/npm-smoke.yml b/.github/workflows/npm-smoke.yml index 0cdb7431..db1589f7 100644 --- a/.github/workflows/npm-smoke.yml +++ b/.github/workflows/npm-smoke.yml @@ -17,6 +17,10 @@ on: - "packages/create-cloudhal/**" - "starters/**" +# Packs, installs and builds locally; the token only reads the repository. +permissions: + contents: read + jobs: smoke: runs-on: ubuntu-latest diff --git a/packages/analytics/test/fake-wae-check.test.ts b/packages/analytics/test/fake-wae-check.test.ts new file mode 100644 index 00000000..526cfe10 --- /dev/null +++ b/packages/analytics/test/fake-wae-check.test.ts @@ -0,0 +1,42 @@ +/** Alert 8: the fake WAE's type-error check answers as its regex did, in linear time. */ +import { describe, expect, it } from "vitest"; +import { weightsBesideLiteralZero } from "./helpers/fake-wae.ts"; + +const old = (q: string) => /quantileWeighted\([^;]*?if\([^,]+,\s*double\d+\s*,/.test(q); + +describe("weightsBesideLiteralZero", () => { + it("answers as the regex did", () => { + for (const q of [ + "SELECT quantileWeighted(0.5)(double1, if(blob2 = 'x', double3, 0)) FROM t", + "SELECT quantileWeighted(0.5)(double1, if(blob2 = 'x', 1, 0)) FROM t", + "SELECT quantileWeighted(0.5)(double1, _sample_interval) FROM t; SELECT if(a, double1, 0)", + "quantileWeighted(if(,double1,", + "quantileWeighted(if(a, double12 ,", + "if(a, double1, 0) quantileWeighted(", + "", + ]) + expect(weightsBesideLiteralZero(q), q).toBe(old(q)); + let s = 8; + const next = () => { + s = (s * 1103515245 + 12345) & 0x7fffffff; + return s; + }; + const tokens = ["quantileWeighted(", "if(", ",", " ", "double1", ";", "a", "double"]; + for (let n = 0; n < 20_000; n++) { + let q = ""; + for (let i = next() % 10; i > 0; i--) q += tokens[next() % tokens.length]; + expect(weightsBesideLiteralZero(q), JSON.stringify(q)).toBe(old(q)); + } + }); + + it("is linear on many quantileWeighted( and if(", () => { + const start = performance.now(); + expect(weightsBesideLiteralZero("quantileWeighted(".repeat(10_000))).toBe(false); + expect(weightsBesideLiteralZero(`quantileWeighted(${"if(".repeat(50_000)}`)).toBe(false); + // every `if(` shares one comma followed by a long run the probe has to scan + const shared = `quantileWeighted(${"if(".repeat(50_000)}x,${" ".repeat(50_000)}y`; + expect(weightsBesideLiteralZero(shared)).toBe(false); + expect(weightsBesideLiteralZero(`${shared.slice(0, -1)}double1,`)).toBe(true); + expect(performance.now() - start).toBeLessThan(1500); + }); +}); diff --git a/packages/analytics/test/helpers/fake-wae.ts b/packages/analytics/test/helpers/fake-wae.ts index 891f7548..715f44e7 100644 --- a/packages/analytics/test/helpers/fake-wae.ts +++ b/packages/analytics/test/helpers/fake-wae.ts @@ -136,7 +136,7 @@ export class FakeWae implements WaeSqlBinding { const alias = q.match(/\b(?:blob|double)\d+ AS (\w+)/); if (alias) throw new SqlApiError(`Input was invalid: column \`${alias[1]}\` not found`); } - if (/quantileWeighted\([^;]*?if\([^,]+,\s*double\d+\s*,/.test(q)) + if (weightsBesideLiteralZero(q)) throw new SqlApiError( "Input was invalid: Your query could not be executed due to a type error", ); @@ -154,3 +154,47 @@ export class FakeWae implements WaeSqlBinding { }; } } + +const isSpace = (c: string | undefined) => c !== undefined && /\s/.test(c); +const isDigit = (c: string | undefined) => c !== undefined && c >= "0" && c <= "9"; + +/** Whether `\s*double\d+\s*,` matches at `at`, scanned by hand (no regex to rescan). */ +function weightArgAt(q: string, at: number): boolean { + let i = at; + while (isSpace(q[i])) i++; + if (!q.startsWith("double", i)) return false; + i += 6; + const digits = i; + while (isDigit(q[i])) i++; + if (i === digits) return false; + while (isSpace(q[i])) i++; + return q[i] === ","; +} + +/** + * Whether `q` has `quantileWeighted(…if(, doubleN, …` before the next `;`: what + * `/quantileWeighted\([^;]*?if\([^,]+,\s*double\d+\s*,/` tested, in one pass (that regex rescanned + * from every `quantileWeighted(` and `if(`; CodeQL alert 8). A `quantileWeighted(` with no match + * before its `;` means none of the later ones before that `;` match either, and each comma is + * probed once however many `if(` share it. + */ +export function weightsBesideLiteralZero(q: string): boolean { + let comma = -1; + // The last comma probed: a probe that matched returned at once, so a repeat can only fail. + let probed = -1; + for (let at = q.indexOf("quantileWeighted("); at !== -1; ) { + const semi = q.indexOf(";", at); + const end = semi === -1 ? q.length : semi; + for (let k = q.indexOf("if(", at + 17); k !== -1 && k < end; k = q.indexOf("if(", k + 1)) { + // `[^,]+`: the condition runs to the first comma after `if(`, at least one character. + if (comma < k + 3) comma = q.indexOf(",", k + 3); + if (comma === -1) return false; + if (comma === k + 3 || comma === probed) continue; + probed = comma; + if (weightArgAt(q, comma + 1)) return true; + } + if (semi === -1) return false; + at = q.indexOf("quantileWeighted(", semi + 1); + } + return false; +} diff --git a/packages/astro-renderer/test/corrections.test.ts b/packages/astro-renderer/test/corrections.test.ts index 01d45206..e2f64169 100644 --- a/packages/astro-renderer/test/corrections.test.ts +++ b/packages/astro-renderer/test/corrections.test.ts @@ -71,7 +71,7 @@ describe("corrections", () => { const html = renderInlineMarkdown( " [x](javascript:alert(1)) ![pixel](https://evil.example/p.gif)\n\n# Not a heading", ); - expect(html).not.toMatch(//); + expect(html).not.toMatch(//i); expect(html).toContain("<script>"); expect(html).toContain("pixel"); }); diff --git a/packages/cli/test/i18n-catalog-entries.test.ts b/packages/cli/test/i18n-catalog-entries.test.ts new file mode 100644 index 00000000..cf85bdc9 --- /dev/null +++ b/packages/cli/test/i18n-catalog-entries.test.ts @@ -0,0 +1,21 @@ +/** + * Alert 37: the i18n codemod (scripts/i18n-codemod, tested here with the other scripts, whose + * files this package's tsconfig covers) decodes string literals of either quote without breaking + * escapes. + */ +import { describe, expect, it } from "vitest"; +import { stringBodyAsJson } from "../../../scripts/i18n-codemod/catalog-entries.ts"; + +const decode = (body: string) => JSON.parse(`"${stringBodyAsJson(body)}"`) as string; + +describe("stringBodyAsJson", () => { + it("decodes what the literal means", () => { + expect(decode("plain")).toBe("plain"); + expect(decode("it\\'s")).toBe("it's"); + expect(decode('say "hi"')).toBe('say "hi"'); + expect(decode('a\\"b')).toBe('a"b'); + expect(decode("back\\\\slash")).toBe("back\\slash"); + expect(decode('back\\\\"')).toBe('back\\"'); + expect(decode("line\\nbreak \\u00e9")).toBe("line\nbreak \u00e9"); + }); +}); diff --git a/scripts/i18n-codemod/catalog-entries.ts b/scripts/i18n-codemod/catalog-entries.ts index 8b49ec4d..d40110ed 100644 --- a/scripts/i18n-codemod/catalog-entries.ts +++ b/scripts/i18n-codemod/catalog-entries.ts @@ -17,6 +17,25 @@ export interface CatalogEntry { comment?: string; } +/** + * The body of a string literal (either quote) as the body of a JSON string: `\'` becomes `'`, a bare `"` + * is escaped, every other escape (`\\`, `\"`, `\n`, `\u…`) stays as written. One pass, so an + * escaped backslash before a quote is never taken apart (the replace chain it replaced turned + * `'a\\"b'`'s `\"` into `\\"`, which ends the JSON string early). + */ +export function stringBodyAsJson(body: string): string { + let out = ""; + for (let k = 0; k < body.length; k++) { + const c = body[k] as string; + if (c === "\\") { + const next = body[k + 1] ?? ""; + out += next === "'" ? "'" : `\\${next}`; + k++; + } else out += c === '"' ? '\\"' : c; + } + return out; +} + class Scanner { i = 0; readonly src: string; @@ -53,9 +72,8 @@ class Scanner { while (this.src[j] !== quote) j += this.src[j] === "\\" ? 2 : 1; const body = this.src.slice(this.i + 1, j); this.i = j + 1; - return quote === '"' - ? (JSON.parse(`"${body}"`) as string) - : JSON.parse(`"${body.replace(/\\'/g, "'").replace(/"/g, '\\"')}"`); + // Either quote: `\'` is legal in a double-quoted literal too, and not in JSON. + return JSON.parse(`"${stringBodyAsJson(body)}"`) as string; } key(): string { diff --git a/scripts/mcp-tools-doc.ts b/scripts/mcp-tools-doc.ts index f0856942..c48b34d7 100644 --- a/scripts/mcp-tools-doc.ts +++ b/scripts/mcp-tools-doc.ts @@ -34,7 +34,9 @@ function summary(description: string): string { // A cut inside a code span would leave it open for the rest of the table. if ((short.match(/`/g) ?? []).length % 2 === 1) short = `${short.slice(0, -1)}\`…`; // `<` outside code spans would read as an HTML tag; inside them it shows as written. + // Backslashes first, so an escaped pipe in a description cannot end the cell. return short + .replace(/\\/g, "\\\\") .replace(/\|/g, "\\|") .split("`") .map((part, i) => (i % 2 === 0 ? part.replace(/ { - const res = await fetch(`https://registry.npmjs.org/${name.replace("/", "%2F")}/${version}`); + const res = await fetch(`https://registry.npmjs.org/${name.replaceAll("/", "%2F")}/${version}`); return res.ok; }