/** * no-insecure-uuid lint script * * `window.crypto.randomUUID` is only available in browser secure contexts * (HTTPS or localhost). Profilarr is commonly served over plain HTTP on a * LAN, where calling it throws `TypeError: crypto.randomUUID is not a * function` and crashes the page. * * A safe wrapper exists at `src/lib/shared/utils/uuid.ts` (`uuid()`) that * falls back to a `Math.random()`-based UUID outside secure contexts. This * rule blocks new `crypto.randomUUID()` calls in code that may run in the * browser, directing authors to that wrapper instead. * * Severity: * - error in `src/lib/client/**` and client-side `src/routes/**` (always * browser code). * - warn in `src/lib/shared/**` because shared code can run on either * side. Suppressible with a directive comment on the previous line: * * // lint-disable-next-line no-insecure-uuid -- server-only path * crypto.randomUUID(); * * Reason after `--` is required and must be non-empty. A malformed * directive (missing reason, wrong rule name, missing `--`) becomes its * own warning instead of silently exempting the call. * - server files are out of scope (Deno, secure context not applicable). * Routes excluded: `+server.ts`, `+page.server.ts`, `+layout.server.ts`, * `hooks.server.ts`, `*.server.ts/.js`. * - `src/lib/shared/utils/uuid.ts` itself is exempt (it IS the wrapper). * * Usage: * deno task lint:crypto * * Issue: https://github.com/Dictionarry-Hub/profilarr/issues/458 */ import { parse } from 'svelte/compiler'; import { buildLineOffsets, classifyDirective, collectFiles, type Colorizer, type CommentStatus, offsetToLineCol, pickColorizer } from './_lib.ts'; // ============================================================================ // CONFIGURATION // ============================================================================ const RULE_NAME = 'no-insecure-uuid'; const SUGGESTION = "import `uuid` from '$shared/utils/uuid'"; const SCOPE_ROOTS = ['src/routes', 'src/lib/client', 'src/lib/shared']; const EXEMPT_FILES = new Set(['src/lib/shared/utils/uuid.ts']); // Fast pre-filter: skip the file entirely if `crypto.randomUUID(` doesn't // appear in the raw source. const SCAN_RE = /\bcrypto\.randomUUID\s*\(/; // Real matcher run on stripped source: also catches `window.crypto.…` and // `globalThis.crypto.…`. const MATCH_RE = /\b(?:(?:window|globalThis)\s*\.\s*)?crypto\s*\.\s*randomUUID\s*\(/g; const FILE_EXT_RE = /\.(?:svelte|ts|js)$/; // SvelteKit server-only file conventions function isServerFile(file: string): boolean { if (/\+server\.(?:ts|js)$/.test(file)) return true; if (/\.server\.(?:ts|js)$/.test(file)) return true; if (/(?:^|\/)hooks\.server\.(?:ts|js)$/.test(file)) return true; return false; } type Severity = 'error' | 'warn'; function severityFor(file: string): Severity | null { if (EXEMPT_FILES.has(file)) return null; if (file.startsWith('src/lib/shared/')) return 'warn'; if (file.startsWith('src/lib/client/')) return 'error'; if (file.startsWith('src/routes/')) { if (isServerFile(file)) return null; return 'error'; } return null; } // ============================================================================ // TYPES // ============================================================================ interface BaseViolation { file: string; line: number; column: number; severity: Severity; } interface InsecureUuidViolation extends BaseViolation { kind: 'insecure-uuid'; } interface MalformedDirectiveViolation extends BaseViolation { kind: 'malformed-directive'; reason: string; } interface ParseErrorViolation extends BaseViolation { kind: 'parse-error'; message: string; } interface ReadErrorViolation extends BaseViolation { kind: 'read-error'; message: string; } type Violation = | InsecureUuidViolation | MalformedDirectiveViolation | ParseErrorViolation | ReadErrorViolation; // Minimal Svelte AST shape. We only touch `instance` and `module` script // blocks; their `content` carries byte offsets into the original source. interface ScriptBlock { start: number; end: number; content: { start: number; end: number }; } interface SvelteRoot { type: 'Root'; instance: ScriptBlock | null; module: ScriptBlock | null; } // ============================================================================ // FILE DISCOVERY // ============================================================================ function collectTargetFiles(): Promise { return collectFiles({ roots: SCOPE_ROOTS, acceptFile: (rel) => FILE_EXT_RE.test(rel) && severityFor(rel) !== null }); } // ============================================================================ // COMMENT / STRING STRIPPER // // Replaces comments and string-literal contents with spaces (preserving // newlines so line numbers stay accurate). Template literals are handled // with brace tracking so `crypto.randomUUID()` calls inside `${…}` // expressions are NOT stripped. // ============================================================================ function stripCommentsAndStrings(src: string): string { const n = src.length; const out = new Array(n); // Stack of active template literals. Each entry tracks the brace depth // inside the current `${…}` expression. depth 0 means we're in literal // text (between backticks); depth > 0 means we're in code inside `${…}`. const tpls: { exprDepth: number }[] = []; const inTplText = () => tpls.length > 0 && tpls[tpls.length - 1].exprDepth === 0; let i = 0; while (i < n) { const ch = src[i]; const nx = src[i + 1] ?? ''; if (inTplText()) { if (ch === '\\' && i + 1 < n) { out[i] = ' '; out[i + 1] = src[i + 1] === '\n' ? '\n' : ' '; i += 2; continue; } if (ch === '`') { out[i] = ' '; i++; tpls.pop(); continue; } if (ch === '$' && nx === '{') { out[i] = ' '; out[i + 1] = ' '; i += 2; tpls[tpls.length - 1].exprDepth = 1; continue; } out[i] = ch === '\n' ? '\n' : ' '; i++; continue; } // Code mode (top-level OR inside a `${…}` expression). if (ch === '/' && nx === '/') { while (i < n && src[i] !== '\n') { out[i] = ' '; i++; } continue; } if (ch === '/' && nx === '*') { out[i] = ' '; out[i + 1] = ' '; i += 2; while (i < n) { if (src[i] === '*' && src[i + 1] === '/') { out[i] = ' '; out[i + 1] = ' '; i += 2; break; } out[i] = src[i] === '\n' ? '\n' : ' '; i++; } continue; } if (ch === '"' || ch === "'") { const quote = ch; out[i] = ' '; i++; while (i < n) { if (src[i] === '\\' && i + 1 < n) { out[i] = ' '; out[i + 1] = src[i + 1] === '\n' ? '\n' : ' '; i += 2; continue; } if (src[i] === quote) { out[i] = ' '; i++; break; } if (src[i] === '\n') { // Unterminated string literal — bail out cleanly. break; } out[i] = ' '; i++; } continue; } if (ch === '`') { out[i] = ' '; i++; tpls.push({ exprDepth: 0 }); continue; } // Brace tracking inside template-literal expression if (tpls.length > 0 && tpls[tpls.length - 1].exprDepth > 0) { if (ch === '{') { tpls[tpls.length - 1].exprDepth++; out[i] = ch; i++; continue; } if (ch === '}') { tpls[tpls.length - 1].exprDepth--; if (tpls[tpls.length - 1].exprDepth === 0) { out[i] = ' '; i++; continue; } out[i] = ch; i++; continue; } } out[i] = ch; i++; } return out.join(''); } // ============================================================================ // DIRECTIVE COMMENT // ============================================================================ // Walk back to the previous non-blank line and look for a `//` directive. function findPrecedingDirective( source: string, lineOffsets: number[], offset: number ): CommentStatus { const { line } = offsetToLineCol(lineOffsets, offset); let prev = line - 1; while (prev >= 1) { const start = lineOffsets[prev - 1]; const end = prev < lineOffsets.length ? lineOffsets[prev] - 1 : source.length; const text = source.slice(start, end); const trimmed = text.trim(); if (trimmed === '') { prev--; continue; } if (trimmed.startsWith('//')) { return classifyDirective(trimmed.slice(2).trim(), RULE_NAME); } return { kind: 'none' }; } return { kind: 'none' }; } // ============================================================================ // PER-FILE LINTER // ============================================================================ function lintScriptRange( file: string, source: string, scriptStart: number, scriptEnd: number, lineOffsets: number[], severity: Severity, out: Violation[] ): void { const slice = source.slice(scriptStart, scriptEnd); const stripped = stripCommentsAndStrings(slice); MATCH_RE.lastIndex = 0; let m: RegExpExecArray | null; while ((m = MATCH_RE.exec(stripped)) !== null) { const matchOffset = scriptStart + m.index; const { line, column } = offsetToLineCol(lineOffsets, matchOffset); // Directives only apply in warn-scope. In error-scope the violation // fires regardless — the loud red error is sufficient signal. if (severity === 'warn') { const status = findPrecedingDirective(source, lineOffsets, matchOffset); if (status.kind === 'valid') continue; if (status.kind === 'malformed') { out.push({ kind: 'malformed-directive', file, line, column, severity, reason: status.reason }); continue; } } out.push({ kind: 'insecure-uuid', file, line, column, severity }); } } function lintTsFile(file: string, source: string, severity: Severity): Violation[] { const out: Violation[] = []; const lineOffsets = buildLineOffsets(source); lintScriptRange(file, source, 0, source.length, lineOffsets, severity, out); return out; } function lintSvelteFile(file: string, source: string, severity: Severity): Violation[] { const out: Violation[] = []; let root: SvelteRoot; try { // deno-lint-ignore no-explicit-any root = parse(source, { modern: true, filename: file } as any) as SvelteRoot; } catch (err) { const message = err instanceof Error ? err.message : String(err); out.push({ kind: 'parse-error', file, line: 1, column: 1, severity, message }); return out; } const lineOffsets = buildLineOffsets(source); for (const block of [root.instance, root.module]) { if (!block?.content) continue; lintScriptRange( file, source, block.content.start, block.content.end, lineOffsets, severity, out ); } return out; } function lintFile(file: string, source: string, severity: Severity): Violation[] { if (file.endsWith('.svelte')) return lintSvelteFile(file, source, severity); return lintTsFile(file, source, severity); } // ============================================================================ // REPORT FORMATTER // ============================================================================ function violationLabel(v: Violation): string { switch (v.kind) { case 'insecure-uuid': return 'crypto.randomUUID()'; case 'malformed-directive': return 'malformed directive'; case 'parse-error': return 'parse error'; case 'read-error': return 'read error'; } } function violationDetail(v: Violation, c: Colorizer): string { switch (v.kind) { case 'insecure-uuid': { const arrow = c.dim('\u2192'); return `${arrow} ${c.cyan(SUGGESTION)}`; } case 'malformed-directive': return c.dim(v.reason); case 'parse-error': case 'read-error': return c.dim(v.message); } } function colorForLabel(v: Violation, c: Colorizer): (s: string) => string { if (v.severity === 'error' && v.kind === 'insecure-uuid') return c.red; if (v.kind === 'parse-error' || v.kind === 'read-error') return c.red; return c.yellow; } function formatHeader(violations: Violation[], c: Colorizer): string { let errors = 0; let warnings = 0; for (const v of violations) { if (v.severity === 'error') errors++; else warnings++; } const fileCount = new Set(violations.map((v) => v.file)).size; const fileWord = fileCount === 1 ? 'file' : 'files'; const parts: string[] = []; if (errors > 0) parts.push(c.bold(c.red(`${errors} ${errors === 1 ? 'error' : 'errors'}`))); if (warnings > 0) parts.push(c.bold(c.yellow(`${warnings} ${warnings === 1 ? 'warning' : 'warnings'}`))); const counts = parts.join(c.dim(', ')); return `${counts} across ${c.bold(`${fileCount} ${fileWord}`)} ${c.dim(`(${RULE_NAME})`)}`; } function formatReport(violations: Violation[], c: Colorizer): string { const lines: string[] = []; const byFile = new Map(); for (const v of violations) { const arr = byFile.get(v.file); if (arr) arr.push(v); else byFile.set(v.file, [v]); } lines.push(formatHeader(violations, c)); lines.push(''); lines.push(c.dim('\u2500'.repeat(60))); lines.push(''); const fileEntries = [...byFile.entries()].sort((a, b) => { if (b[1].length !== a[1].length) return b[1].length - a[1].length; return a[0].localeCompare(b[0]); }); for (let fi = 0; fi < fileEntries.length; fi++) { const [file, fileViolations] = fileEntries[fi]; const sorted = [...fileViolations].sort((a, b) => { if (a.line !== b.line) return a.line - b.line; return a.column - b.column; }); lines.push(`${c.bold(file)} ${c.dim(`(${fileViolations.length})`)}`); const maxLocWidth = Math.max(...sorted.map((v) => `${v.line}:${v.column}`.length)); const maxLabelWidth = Math.max(...sorted.map((v) => violationLabel(v).length)); for (const v of sorted) { const locStr = `${v.line}:${v.column}`.padStart(maxLocWidth); const labelRaw = violationLabel(v).padEnd(maxLabelWidth); const colored = colorForLabel(v, c)(labelRaw); const detail = violationDetail(v, c); lines.push(` ${c.dim(locStr)} ${colored} ${detail}`); } if (fi < fileEntries.length - 1) lines.push(''); } return lines.join('\n'); } // ============================================================================ // MAIN // ============================================================================ async function main(): Promise { const files = await collectTargetFiles(); const all: Violation[] = []; for (const file of files) { let source: string; try { source = await Deno.readTextFile(file); } catch (err) { const message = err instanceof Error ? err.message : String(err); all.push({ kind: 'read-error', file, line: 1, column: 1, severity: 'error', message }); continue; } if (!SCAN_RE.test(source)) continue; const severity = severityFor(file); if (severity === null) continue; all.push(...lintFile(file, source, severity)); } const c = pickColorizer(); if (all.length === 0) { console.log(`${c.green('\u2713')} no insecure UUID calls ${c.dim(`(${RULE_NAME})`)}`); Deno.exit(0); } console.log(formatReport(all, c)); const errorCount = all.filter((v) => v.severity === 'error').length; Deno.exit(errorCount > 0 ? 1 : 0); } if (import.meta.main) { await main(); }