diff --git a/src/main/actions/ExportTimelineAction.ts b/src/main/actions/ExportTimelineAction.ts index 56fbda3b..9dd54488 100644 --- a/src/main/actions/ExportTimelineAction.ts +++ b/src/main/actions/ExportTimelineAction.ts @@ -38,6 +38,7 @@ import { } from '../services/exportPlan.js'; import type { ExportFormat, ProjectTimelineTransition } from '@shared/index.js'; import { EXPORTS_FOLDER } from '@shared/constants/videoFiles.js'; +import { trimChar } from '../utils/trimChar.js'; interface TimelineClipData { @@ -482,9 +483,11 @@ export class ExportTimelineAction extends BaseAction:"/\\|?*\x00-\x1f]/g, '') - .replace(/\.+$/, '') + const cleaned = trimChar( + (name ?? '').replace(/[<>:"/\\|?*\x00-\x1f]/g, ''), + '.', + 'end', + ) .trim() .slice(0, 120); diff --git a/src/main/actions/MoveClipToGameAction.ts b/src/main/actions/MoveClipToGameAction.ts index 0efc7a0d..e4661f44 100644 --- a/src/main/actions/MoveClipToGameAction.ts +++ b/src/main/actions/MoveClipToGameAction.ts @@ -4,6 +4,7 @@ import { BaseAction } from './BaseAction.js'; import { AppDataSource, VIDEOS_ROOT } from '../data-source.js'; import { Clip } from '../entity/Clip.js'; import { cleanupEmptyFolders } from '../utils/cleanupEmptyFolders.js'; +import { gameFolderPath } from '../services/gameFolder.js'; export interface MoveClipToGameInput { clipId: number; @@ -30,11 +31,14 @@ export class MoveClipToGameAction extends BaseAction { return res.status(400).json({ error: 'targetGame is required' }); } + const problem = gameFolderProblem(targetGame.trim()); + if (problem) return res.status(400).json({ error: problem }); + const action = new MoveClipToGameAction(); const { clip } = await action.execute({ clipId: id, targetGame: targetGame.trim() }); diff --git a/src/main/routes/tagPatterns.ts b/src/main/routes/tagPatterns.ts index 62069b0c..1efe0018 100644 --- a/src/main/routes/tagPatterns.ts +++ b/src/main/routes/tagPatterns.ts @@ -2,22 +2,21 @@ import express from 'express'; import { AppDataSource } from '../data-source.js'; import { TagPattern } from '../entity/TagPattern.js'; import { asyncHandler } from '../utils/asyncHandler.js'; -import { TagPatternDTO } from '@shared/index.js'; +import { TagPatternDTO, tagPatternProblem } from '@shared/index.js'; export const tagPatternsRouter = express.Router(); -/** Guards against a rule that cannot compile being saved and then thrown on every match. */ +/** + * Guards against a rule that cannot compile, or one that could backtrack for + * ever, being saved and then run against every clip. See `tagPatternProblem`. + */ function invalidExpression(patterns: unknown): string | null { if (!Array.isArray(patterns) || patterns.length === 0) { return 'A pattern needs at least one expression'; } for (const source of patterns) { - if (typeof source !== 'string' || !source.trim()) return 'An expression cannot be empty'; - try { - new RegExp(source, 'i'); - } catch { - return `"${source}" is not a valid expression`; - } + const problem = tagPatternProblem(source); + if (problem) return problem; } return null; } diff --git a/src/main/services/clipSearch.ts b/src/main/services/clipSearch.ts index ddb82e8c..6992241b 100644 --- a/src/main/services/clipSearch.ts +++ b/src/main/services/clipSearch.ts @@ -28,6 +28,8 @@ * module chose, and nothing the user types is ever an operator. */ +import { trimChar } from '../utils/trimChar.js'; + /** * A search that will not be run as a query. * @@ -201,7 +203,7 @@ function termsOf(raw: string | undefined | null): string[] { return raw .split(/[^\p{L}\p{N}']+/u) - .map((term) => term.replace(/^'+|'+$/g, '')) + .map((term) => trimChar(term, "'")) .filter((term) => term.length >= MIN_TERM_LENGTH) .slice(0, MAX_TERMS); } diff --git a/src/main/services/gameFolder.ts b/src/main/services/gameFolder.ts new file mode 100644 index 00000000..c8b2d545 --- /dev/null +++ b/src/main/services/gameFolder.ts @@ -0,0 +1,54 @@ +import path from 'node:path'; + +/** + * Characters Windows will not take in a folder name, plus the control range. + * The two separators are the ones that matter: with either of them a game name + * is a path, and `..\..\Windows` joined onto the videos root is somewhere else. + */ +const PROHIBITED = /[/\\:"<>*?|\x00-\x1f]/; + +/** Device names Windows reserves in every folder, with or without an extension. */ +const RESERVED = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i; + +/** + * Why a name cannot be a game folder, or null when it can. + * + * Refused rather than cleaned. `safeName` in `capture/gameNames.ts` cleans, + * which is right for a name read off a store; this is a name somebody typed, + * and moving their clip into a folder spelled differently from what they typed + * would be a surprise found later. + */ +export function gameFolderProblem(name: string): string | null { + if (!name) return 'A game needs a name'; + if (name === '.' || name === '..') return 'A game cannot be called that'; + if (PROHIBITED.test(name)) return 'A game name cannot contain / \\ : " < > * ? or |'; + // The watcher, the scan and the sweepers all skip dot folders on purpose, so a + // clip moved into one would vanish from the library while still on disk. + if (name.startsWith('.')) return 'A game name cannot start with a dot'; + // Windows drops these on its own, so the folder would not have the name asked for. + const last = name[name.length - 1]; + if (last === '.' || last === ' ') return 'A game name cannot end with a dot or a space'; + if (RESERVED.test(name)) return 'Windows reserves that name'; + return null; +} + +/** + * The folder for a game, proved to sit directly inside the videos root. + * + * `gameFolderProblem` already rules out anything that could climb out, and + * this checks the result anyway: one guard is a single point of failure, and + * this is the line that decides where somebody's recording is moved to. + */ +export function gameFolderPath(videosRoot: string, name: string): string { + const problem = gameFolderProblem(name); + if (problem) throw new GameFolderError(problem); + + const root = path.resolve(videosRoot); + const target = path.resolve(root, name); + if (!target.startsWith(root + path.sep) || path.dirname(target) !== root) { + throw new GameFolderError('A game folder has to sit inside the videos folder'); + } + return target; +} + +export class GameFolderError extends Error {} diff --git a/src/main/utils/trimChar.ts b/src/main/utils/trimChar.ts new file mode 100644 index 00000000..90740f08 --- /dev/null +++ b/src/main/utils/trimChar.ts @@ -0,0 +1,16 @@ +/** + * Take a run of one character off either end of a string. + * + * A loop rather than `/^x+|x+$/`: an end-anchored `x+$` is retried from every + * position of a long run that turns out not to end the string, which is + * quadratic in the run's length, and both call sites trim text somebody typed. + */ +export function trimChar(value: string, char: string, ends: 'both' | 'end' = 'both'): string { + let start = 0; + let end = value.length; + if (ends === 'both') { + while (start < end && value[start] === char) start++; + } + while (end > start && value[end - 1] === char) end--; + return value.slice(start, end); +} diff --git a/src/shared/constants/tagPatternRules.ts b/src/shared/constants/tagPatternRules.ts new file mode 100644 index 00000000..e1076e5a --- /dev/null +++ b/src/shared/constants/tagPatternRules.ts @@ -0,0 +1,85 @@ +/** + * What a tag rule is allowed to be. + * + * A tag rule is a regular expression the user writes, so compiling user input + * is the feature rather than a mistake, and escaping it would turn every rule + * into a literal. What can go wrong is an expression that backtracks without + * end: `(a+)+` against a long filename freezes the renderer on every clip it is + * matched to, for as long as it stays saved. So a rule is refused when it is + * long, and when it repeats something that already repeats, which is the shape + * behind nearly every catastrophic expression. + * + * Shared because the route, the DTO and the editor all ask the same question, + * and two answers to it is how a rule the editor accepted gets a 400. + */ + +/** Far longer than any rule anybody has written, and short enough to reason about. */ +export const MAX_TAG_PATTERN_LENGTH = 200; + +/** Why an expression cannot be a tag rule, or null when it can. */ +export function tagPatternProblem(source: unknown): string | null { + if (typeof source !== 'string' || !source.trim()) return 'An expression cannot be empty'; + if (source.length > MAX_TAG_PATTERN_LENGTH) { + return `An expression can be at most ${MAX_TAG_PATTERN_LENGTH} characters`; + } + try { + new RegExp(source, 'i'); + } catch { + return `"${source}" is not a valid expression`; + } + if (repeatsARepetition(source)) { + return `"${source}" repeats something that already repeats, which can freeze the library`; + } + return null; +} + +/** + * Does a quantified group contain a quantifier of its own? + * + * A scan rather than a regular expression, since a pattern that recognises + * nested quantifiers is itself the kind of pattern that backtracks. `?` is not + * counted: `(a+)?` matches the group once or not at all, which cannot explode. + */ +function repeatsARepetition(source: string): boolean { + const groups: boolean[] = []; + let closedGroupRepeats = false; + + for (let i = 0; i < source.length; i++) { + const ch = source[i]; + + if (ch === '\\') { + i++; + closedGroupRepeats = false; + continue; + } + if (ch === '[') { + // A class is one character, whatever is inside it. + i++; + while (i < source.length && source[i] !== ']') { + if (source[i] === '\\') i++; + i++; + } + closedGroupRepeats = false; + continue; + } + if (ch === '(') { + groups.push(false); + closedGroupRepeats = false; + continue; + } + if (ch === ')') { + const repeats = groups.pop() ?? false; + if (repeats && groups.length) groups[groups.length - 1] = true; + closedGroupRepeats = repeats; + continue; + } + + const quantifier = ch === '*' || ch === '+' || (ch === '{' && /\d/.test(source[i + 1] ?? '')); + if (quantifier) { + if (closedGroupRepeats) return true; + if (groups.length) groups[groups.length - 1] = true; + } + closedGroupRepeats = false; + } + return false; +} diff --git a/src/shared/dtos/tag/TagPatternDTO.ts b/src/shared/dtos/tag/TagPatternDTO.ts index 37a65195..1767eb8e 100644 --- a/src/shared/dtos/tag/TagPatternDTO.ts +++ b/src/shared/dtos/tag/TagPatternDTO.ts @@ -1,4 +1,5 @@ import { BaseDTO } from '../BaseDTO.js'; +import { tagPatternProblem } from '../../constants/tagPatternRules.js'; export type TagCategory = 'Gameplay' | 'Weapons' | 'Maps' | 'Modes' | 'Quality' | 'General'; @@ -53,13 +54,10 @@ export class TagPatternDTO extends BaseDTO { } // An expression that cannot compile would throw at match time, on every - // clip, for as long as it stays saved. + // clip, for as long as it stays saved; one that backtracks would hang there. for (const source of this.patterns ?? []) { - try { - new RegExp(source, 'i'); - } catch { - errors.push(`"${source}" is not a valid expression`); - } + const problem = tagPatternProblem(source); + if (problem) errors.push(problem); } return { isValid: errors.length === 0, errors: errors.length ? errors : undefined }; diff --git a/src/shared/index.ts b/src/shared/index.ts index a252e521..803b82a3 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -32,6 +32,7 @@ export * from './constants/ui.js'; // Which files are clips, so the scan and the watcher cannot disagree again export * from './constants/videoFiles.js'; +export * from './constants/tagPatternRules.js'; // Where a window opens and closes around a moment, so the analysis and a // GoodBit made from one cannot disagree about the same reading diff --git a/tests/unit/main/codeScanning.spec.ts b/tests/unit/main/codeScanning.spec.ts new file mode 100644 index 00000000..260987a3 --- /dev/null +++ b/tests/unit/main/codeScanning.spec.ts @@ -0,0 +1,102 @@ +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { gameFolderPath, gameFolderProblem } from '../../../src/main/services/gameFolder'; +import { trimChar } from '../../../src/main/utils/trimChar'; +import { planSearch } from '../../../src/main/services/clipSearch'; +import { tagPatternProblem, MAX_TAG_PATTERN_LENGTH } from '../../../src/shared/constants/tagPatternRules'; + +/* + * The fixes for the code scanning alerts, each held to what it claims: a game + * name cannot move a recording out of the library, a trim is linear, and a tag + * rule that could hang the library is refused while every ordinary one is not. + */ + +describe('gameFolderProblem', () => { + it.each(['Battlefield 6', 'Tom Clancy\'s Rainbow Six Siege', 'cs2', 'Headliners-Win64-Shipping', 'DOOM The Dark Ages'])( + 'accepts a real game folder: %s', + (name) => expect(gameFolderProblem(name)).toBeNull(), + ); + + it.each([ + ['..', 'climbs out'], + ['.', 'is the root itself'], + ['..\\..\\Windows', 'is a Windows path'], + ['../../etc', 'is a POSIX path'], + ['C:evil', 'names a drive'], + ['.goodbit-incoming', 'would be hidden from the scan'], + ['Game.', 'loses its dot on Windows'], + ['Game ', 'loses its space on Windows'], + ['CON', 'is a device'], + ['nul.txt', 'is a device with an extension'], + ['a\u0000b', 'holds a control character'], + ['', 'is empty'], + ])('refuses %j, which %s', (name) => { + expect(gameFolderProblem(name)).not.toBeNull(); + }); +}); + +describe('gameFolderPath', () => { + const root = path.resolve('/library'); + + it('lands directly inside the videos root', () => { + expect(gameFolderPath(root, 'Battlefield 6')).toBe(path.join(root, 'Battlefield 6')); + }); + + it('throws rather than returning a path outside it', () => { + expect(() => gameFolderPath(root, '..')).toThrow(); + expect(() => gameFolderPath(root, '../outside')).toThrow(); + }); +}); + +describe('trimChar', () => { + it('trims both ends by default', () => { + expect(trimChar("''don't''", "'")).toBe("don't"); + }); + + it('trims only the end when asked', () => { + expect(trimChar('..name...', '.', 'end')).toBe('..name'); + }); + + it('empties a string that is all the character', () => { + expect(trimChar("''''", "'")).toBe(''); + }); + + it('is linear on the input that made the regex quadratic', () => { + // A long run of dots that does not end the string: `/\.+$/` retries from + // every position of it. + const hostile = '.'.repeat(200_000) + 'x'; + const started = performance.now(); + expect(trimChar(hostile, '.', 'end')).toBe(hostile); + expect(performance.now() - started).toBeLessThan(50); + }); + + it('leaves search terms as they were', () => { + expect(planSearch("'clutch' don't").terms).toEqual(['clutch', "don't"]); + }); +}); + +describe('tagPatternProblem', () => { + it.each(['clutch', 'ace|4k', '\\bheadshot\\b', '(double|triple) kill', 'kill{2,3}', '(?:a+)?b', '[+*]+', '\\(a+\\)+'])( + 'accepts an ordinary rule: %s', + (source) => expect(tagPatternProblem(source)).toBeNull(), + ); + + it.each(['(a+)+', '(a*)*', '(\\w+\\s?)+$', '((ab)+c)+', '(a+){2,}', '(?:x+y)*'])( + 'refuses a rule that repeats a repetition: %s', + (source) => expect(tagPatternProblem(source)).toMatch(/repeats something/), + ); + + it('refuses a rule that does not compile', () => { + expect(tagPatternProblem('(')).toMatch(/not a valid expression/); + }); + + it('refuses an empty rule and a non-string', () => { + expect(tagPatternProblem(' ')).not.toBeNull(); + expect(tagPatternProblem(42)).not.toBeNull(); + }); + + it('refuses a rule longer than the cap', () => { + expect(tagPatternProblem('a'.repeat(MAX_TAG_PATTERN_LENGTH))).toBeNull(); + expect(tagPatternProblem('a'.repeat(MAX_TAG_PATTERN_LENGTH + 1))).toMatch(/at most/); + }); +});