From e61aee936d94a9520aac0a87f414fbf5fd075d3c Mon Sep 17 00:00:00 2001 From: flamerged <34665379+flamerged@users.noreply.github.com> Date: Fri, 1 May 2026 21:41:24 +0200 Subject: [PATCH] fix: round-K audit follow-ups (input hardening + config robustness) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - isValidRemoteName(): reject custom remote names that ssh would parse as flags (`-oProxyCommand=…`), have whitespace, or are empty. Wired into the interactive add-remote prompt with a user-facing rejection message. All three ssh spawn sites now also pass `--` before the remote, so even if a `-`-prefixed name slipped past the validator (e.g. via a hand-edited config.json), ssh wouldn't treat it as flags. - parseSSHConfig() now follows `Include` directives recursively. The loader is injected (testable). Real-world loader expands `~`, anchors relative paths to `~/.ssh/`, and handles basic `*`/`?` glob in the basename — covers `Include ~/.ssh/config.d/*` cleanly. Recursion capped at 16 levels to short-circuit cyclic includes. `Match` blocks are skipped (they don't introduce new Host names). - pipeToRemote() now serializes through a 2-slot semaphore. Five rapid Cmd+Shift+3s previously spawned five parallel ssh sessions; now at most two run concurrently with the rest queued. - Custom macOS screenshot prefix support: at startup, `defaults read com.apple.screencapture name` is queried and a second regex is built from the override (with metacharacter escaping). Users who customized their prefix via `defaults write … name "MyShot"` are now picked up. - loadConfig() now mtime-gates: stat first, return cached parse if mtimeMs matches the last successful load. The poll loop calls this 5x/sec; on the steady-state hot path it now does one stat instead of a full read+JSON.parse. saveConfig() invalidates the cache. +11 tests (Include, multi-spec Include, recursion cap, Match skip, isValidRemoteName, custom prefix regex). Total 37. --- src/config.ts | 172 ++++++++++++++++++++++++++++++++--- src/index.ts | 24 ++++- src/monitor.ts | 101 +++++++++++++++++++- test/config.test.ts | 89 +++++++++++++++++- test/monitor-helpers.test.ts | 30 ++++++ 5 files changed, 395 insertions(+), 21 deletions(-) diff --git a/src/config.ts b/src/config.ts index 446b6da..ddb58c9 100644 --- a/src/config.ts +++ b/src/config.ts @@ -16,6 +16,21 @@ export interface Config { activeTarget?: string } +// Reject remote names that ssh would interpret as option flags. A user-typed +// `-oProxyCommand=…` slipping through to `spawn('ssh', [name, …])` would let +// ssh take that arg as flags and execute the embedded command. The spawn +// sites also use a `--` separator now, but rejecting at input gives a clear +// error and stops typos like `-myhost` from creating a non-functional entry. +export function isValidRemoteName(name: string): boolean { + if (name.length === 0) return false + if (name.startsWith('-')) return false + // No whitespace. Internal hyphens are fine — hostnames like prod-1, db-2 + // are common. The interactive prompt that feeds this is single-line, so + // control-character checks are not needed here. + if (/\s/.test(name)) return false + return true +} + export function getConfigDir(): string { return path.join(os.homedir(), '.config', 'sshshot') } @@ -39,11 +54,33 @@ export function getPidFile(): string { let lastReadErrorMessage: string | null = null let lastParseErrorMessage: string | null = null +// mtime-gated config cache — the daemon's poll loop calls loadConfig 5x/sec +// just to pick up `sshshot target ` changes, but the file rarely +// actually changes. Stat-and-compare keeps disk reads + JSON.parse cost +// near zero on the steady-state hot path; the actual read+parse only fires +// when mtimeMs differs from the previous successful load. Saving via +// saveConfig invalidates the cache (sets cachedConfigMtime back to null). +let cachedConfig: Config | null = null +let cachedConfigMtime: number | null = null + export function loadConfig(): Config | null { const configPath = getConfigPath() - if (!fs.existsSync(configPath)) { + + // Stat first. If the file is gone, drop the cache and return null. If the + // mtime matches what we last loaded, return the cached value without + // re-reading or re-parsing. + let stat: fs.Stats + try { + stat = fs.statSync(configPath) + } catch { + cachedConfig = null + cachedConfigMtime = null return null } + if (cachedConfig !== null && cachedConfigMtime === stat.mtimeMs) { + return cachedConfig + } + let content: string try { content = fs.readFileSync(configPath, 'utf-8') @@ -55,12 +92,12 @@ export function loadConfig(): Config | null { } return null } - // Successful read — clear any prior read-error suppression so a future - // failure with the same message does log again. lastReadErrorMessage = null try { const parsed = JSON.parse(content) as Config lastParseErrorMessage = null + cachedConfig = parsed + cachedConfigMtime = stat.mtimeMs return parsed } catch (err) { // The previous behavior was to throw, which crashed the daemon's poll @@ -75,6 +112,9 @@ export function loadConfig(): Config | null { ) lastParseErrorMessage = msg } + // Don't cache a parse failure — every subsequent stat will re-attempt + // until the user fixes the file, which is what we want for a transient + // mid-write read. return null } } @@ -86,8 +126,23 @@ export function saveConfig(config: Config): void { fs.mkdirSync(configDir, { recursive: true }) } fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n') + // Invalidate the load cache so the next loadConfig() picks up the value + // we just wrote (in case mtime resolution rounded equal — rare on macOS, + // where APFS gives ns-resolution stat, but cheap insurance). + cachedConfig = null + cachedConfigMtime = null } +// Loader signature for Include directives. Given a raw path spec (which may +// be relative, contain `~`, or a basic `*`/`?` glob), return the contents +// of every file it resolves to. Empty array on no matches / unreadable. +// Tests inject a deterministic loader; the real loader uses fs. +export type SSHConfigIncludeLoader = (pathSpec: string) => string[] + +// OpenSSH's documented Include nesting limit. A misconfigured include cycle +// will short-circuit at this depth instead of stack-overflowing the parser. +const MAX_INCLUDE_DEPTH = 16 + // Pure parser, exported for tests. Takes the raw text of an ssh_config and // returns the Host blocks (excluding wildcard patterns). // @@ -95,12 +150,32 @@ export function saveConfig(config: Config): void { // SSHHost per alias so all of them show up in the auto-detect picker. The // previous parser dropped b and c entirely, so users with shorthand aliases // like `Host prod-1 prod` had to type the long form by hand. -export function parseSSHConfig(content: string): SSHHost[] { +// +// `Include ` directives recursively pull in additional config +// files. Most modern setups (especially corp/devbox boxes that ship a +// `~/.ssh/config.d/*` drop-in dir) put almost all hosts in includes; without +// this support, first-run auto-detect appeared empty. `Match` blocks are +// skipped — they don't introduce new Host names, only conditionally apply +// to existing ones. +export function parseSSHConfig(content: string, loader?: SSHConfigIncludeLoader): SSHHost[] { + return parseSSHConfigInner(content, loader ?? (() => []), 0) +} + +function parseSSHConfigInner( + content: string, + loader: SSHConfigIncludeLoader, + depth: number +): SSHHost[] { + if (depth > MAX_INCLUDE_DEPTH) return [] + const lines = content.split('\n') const hosts: SSHHost[] = [] // currentBlock is a list of host objects sharing one Host directive — all - // are mutated together when we see HostName / User children. + // are mutated together when we see HostName / User children. Match blocks + // null this out so subsequent HostName/User lines don't leak into a Host + // block that already finished. let currentBlock: SSHHost[] | null = null + let inMatchBlock = false const flush = () => { if (currentBlock) { @@ -111,11 +186,11 @@ export function parseSSHConfig(content: string): SSHHost[] { for (const line of lines) { const trimmed = line.trim() - if (trimmed.toLowerCase().startsWith('host ')) { + const lower = trimmed.toLowerCase() + + if (lower.startsWith('host ')) { flush() - // Split on whitespace, drop wildcard patterns. A line like - // `Host * !bad ok` would yield a block with just { name: 'ok' } — - // we accept the non-wildcard aliases and ignore the rest. + inMatchBlock = false const aliases = trimmed .slice(5) .trim() @@ -124,11 +199,30 @@ export function parseSSHConfig(content: string): SSHHost[] { if (aliases.length > 0) { currentBlock = aliases.map((name) => ({ name })) } - } else if (currentBlock) { - if (trimmed.toLowerCase().startsWith('hostname ')) { + } else if (lower.startsWith('match ')) { + // Skip the entire Match block — close the previous Host so its + // HostName/User children don't get inherited. + flush() + inMatchBlock = true + } else if (lower.startsWith('include ')) { + flush() + inMatchBlock = false + // Multiple path specs can appear on one line, whitespace-separated. + const specs = trimmed + .slice(8) + .trim() + .split(/\s+/) + .filter((s) => s.length > 0) + for (const spec of specs) { + for (const includedContent of loader(spec)) { + hosts.push(...parseSSHConfigInner(includedContent, loader, depth + 1)) + } + } + } else if (currentBlock && !inMatchBlock) { + if (lower.startsWith('hostname ')) { const hostname = trimmed.slice(9).trim() for (const h of currentBlock) h.hostname = hostname - } else if (trimmed.toLowerCase().startsWith('user ')) { + } else if (lower.startsWith('user ')) { const user = trimmed.slice(5).trim() for (const h of currentBlock) h.user = user } @@ -139,6 +233,58 @@ export function parseSSHConfig(content: string): SSHHost[] { return hosts } +// Real-world Include loader: resolves the path spec against the filesystem, +// expanding `~` and treating relative paths as anchored at `~/.ssh/`. Basic +// glob expansion (`*`, `?`) is supported in the basename only — sufficient +// for the canonical `Include ~/.ssh/config.d/*` pattern. Unreadable files +// are silently skipped (the user's .ssh dir might have permission quirks). +export function makeFsIncludeLoader(home: string): SSHConfigIncludeLoader { + return (spec: string): string[] => { + let resolved = spec + if (resolved.startsWith('~/')) { + resolved = path.join(home, resolved.slice(2)) + } + if (!path.isAbsolute(resolved)) { + resolved = path.join(home, '.ssh', resolved) + } + + let matches: string[] + if (resolved.includes('*') || resolved.includes('?')) { + const dir = path.dirname(resolved) + const pattern = path.basename(resolved) + const re = globToRegExp(pattern) + try { + matches = fs + .readdirSync(dir) + .filter((name) => re.test(name)) + .map((name) => path.join(dir, name)) + } catch { + matches = [] + } + } else { + matches = [resolved] + } + + const contents: string[] = [] + for (const file of matches) { + try { + contents.push(fs.readFileSync(file, 'utf-8')) + } catch { + // include target missing or unreadable; skip + } + } + return contents + } +} + +function globToRegExp(pattern: string): RegExp { + const escaped = pattern + .replace(/[.+^${}()|[\]\\]/g, '\\$&') + .replace(/\*/g, '.*') + .replace(/\?/g, '.') + return new RegExp(`^${escaped}$`) +} + export function detectSSHRemotes(): SSHHost[] { const home = os.homedir() const sshConfigPath = path.join(home, '.ssh', 'config') @@ -147,5 +293,5 @@ export function detectSSHRemotes(): SSHHost[] { return [] } - return parseSSHConfig(fs.readFileSync(sshConfigPath, 'utf-8')) + return parseSSHConfig(fs.readFileSync(sshConfigPath, 'utf-8'), makeFsIncludeLoader(home)) } diff --git a/src/index.ts b/src/index.ts index 4340154..dc21cea 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,7 +4,14 @@ import * as fs from 'fs' import * as path from 'path' import * as os from 'os' import { spawn, spawnSync, execSync } from 'child_process' -import { Config, getPidFile, loadConfig, saveConfig, detectSSHRemotes } from './config' +import { + Config, + detectSSHRemotes, + getPidFile, + isValidRemoteName, + loadConfig, + saveConfig +} from './config' import { promptConfirm, promptSelect, promptInput, promptMultiSelect } from './prompts' import { startMonitor } from './monitor' @@ -191,9 +198,18 @@ async function addRemotes(existing: string[]): Promise { let addMore = await promptConfirm('Add a custom SSH remote?') while (addMore) { const remoteName = await promptInput('Enter SSH remote (e.g., user@host)') - if (remoteName && !remotes.includes(remoteName)) { - remotes.push(remoteName) - console.log(`Added: ${remoteName}`) + if (remoteName) { + if (!isValidRemoteName(remoteName)) { + // Block names that ssh would parse as flags (`-oProxyCommand=…`), + // names with whitespace, and control chars. Empty input is also + // covered by isValidRemoteName. + console.log( + `Rejected: ${JSON.stringify(remoteName)} (must not start with '-' or contain whitespace)` + ) + } else if (!remotes.includes(remoteName)) { + remotes.push(remoteName) + console.log(`Added: ${remoteName}`) + } } addMore = await promptConfirm('Add another?') } diff --git a/src/monitor.ts b/src/monitor.ts index 7267c5d..8512d19 100644 --- a/src/monitor.ts +++ b/src/monitor.ts @@ -299,8 +299,45 @@ async function getClipboardImageMac(): Promise { export const MAC_SCREENSHOT_FILENAME_RE = /^(Screenshot|Bildschirm(foto|aufnahme)|Capture (d'écran|d'ecran)|Captura (de pantalla|de tela)|Schermata|Schermafbeelding|Skärmavbild|Skjermbilde|Skærmbillede|スクリーンショット|スクリーンキャプチャ|화면 캡처|Снимок экрана|Capture)(?=\s|\.|$).*\.png$/i +// Optional second matcher built at startup from +// `defaults read com.apple.screencapture name` so users who customized +// their screenshot prefix (e.g. `defaults write com.apple.screencapture +// name "MyShot"`) are still detected. Stays null when the user hasn't +// overridden the default. +let customMacScreenshotRe: RegExp | null = null + +// Build a filename regex from a user-provided prefix, escaping regex +// metacharacters. Exported for tests. +export function buildCustomScreenshotRegex(prefix: string): RegExp { + const trimmed = prefix.trim() + if (trimmed.length === 0) { + throw new Error('Empty prefix') + } + const escaped = trimmed.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + return new RegExp(`^${escaped}(?=\\s|\\.|$).*\\.png$`, 'i') +} + +// Read the user's custom screenshot name override at daemon startup. +// macOS-only — the keys don't exist on other platforms. Logs nothing on the +// no-override path; logs a one-line confirmation when an override is found. +export function detectCustomMacScreenshotPrefix(): string | null { + if (!isMac) return null + const result = spawnSync('defaults', ['read', 'com.apple.screencapture', 'name'], { + encoding: 'utf8', + timeout: 2000 + }) + if (result.status !== 0 || !result.stdout) return null + const name = result.stdout.trim() + // `defaults read` returns the literal value with surrounding double quotes + // for strings containing spaces. Strip them. + const unquoted = name.replace(/^"(.*)"$/, '$1') + return unquoted.length > 0 ? unquoted : null +} + export function isMacScreenshotFilename(filename: string): boolean { - return MAC_SCREENSHOT_FILENAME_RE.test(filename) + if (MAC_SCREENSHOT_FILENAME_RE.test(filename)) return true + if (customMacScreenshotRe && customMacScreenshotRe.test(filename)) return true + return false } // mtime of the candidate file we observed on the previous poll. Used to @@ -422,9 +459,13 @@ function getRemoteHomePath(remote: string): string { // Primary: ask the remote directly. Handles non-standard home dirs // (/Users/ on macOS, /data/ on some configs, custom HOME // overrides in /etc/passwd). Spawn with array args — no shell. + // The `--` after the options terminates ssh's option parsing so a remote + // name starting with `-` (e.g. a typo or a malicious `-oProxyCommand=…`) + // can't be mistaken for a flag. The CLI input layer already rejects such + // names via isValidRemoteName, but defense in depth is cheap. const echoResult = spawnSync( 'ssh', - ['-o', 'ConnectTimeout=5', '-o', 'BatchMode=yes', remote, 'echo $HOME'], + ['-o', 'ConnectTimeout=5', '-o', 'BatchMode=yes', '--', remote, 'echo $HOME'], { encoding: 'utf8', timeout: 7000 } ) if (echoResult.status === 0 && echoResult.stdout) { @@ -447,7 +488,10 @@ function getRemoteHomePath(remote: string): string { // Fallback 2: named host without user — resolve via `ssh -G` and guess. // Use spawnSync with array args so a maliciously-crafted `remote` // containing shell metacharacters cannot inject. - const sshGResult = spawnSync('ssh', ['-G', remote], { encoding: 'utf8', timeout: 3000 }) + const sshGResult = spawnSync('ssh', ['-G', '--', remote], { + encoding: 'utf8', + timeout: 3000 + }) if (sshGResult.status === 0 && sshGResult.stdout) { const userMatch = sshGResult.stdout.match(/^user\s+(.+)$/m) if (userMatch) { @@ -464,6 +508,35 @@ function getRemoteHomePath(remote: string): string { return '~' } +// Cap concurrent ssh uploads. Without this, a burst of screenshots (e.g. +// Cmd+Shift+3 spam, or a slow remote causing pile-up) would spawn one ssh +// process per screenshot — five rapid screenshots = five simultaneous ssh +// sessions opening connections to the same host. Two-at-a-time gives some +// pipelining benefit without thrashing the user's connection budget or +// triggering MaxSessions limits on the remote sshd. +const MAX_CONCURRENT_UPLOADS = 2 +let activeUploadCount = 0 +const uploadWaitQueue: Array<() => void> = [] + +function acquireUploadSlot(): Promise { + if (activeUploadCount < MAX_CONCURRENT_UPLOADS) { + activeUploadCount++ + return Promise.resolve() + } + return new Promise((resolve) => { + uploadWaitQueue.push(() => { + activeUploadCount++ + resolve() + }) + }) +} + +function releaseUploadSlot(): void { + activeUploadCount-- + const next = uploadWaitQueue.shift() + if (next) next() +} + async function pipeToRemote( imageBuffer: Buffer, remote: string, @@ -477,6 +550,7 @@ async function pipeToRemote( }) } + await acquireUploadSlot() const homeDir = getRemoteHomePath(remote) const remotePath = `${homeDir}/sshshot-screenshots/${filename}` @@ -489,6 +563,10 @@ async function pipeToRemote( [ '-o', 'ConnectTimeout=5', + // `--` terminates ssh's option parsing — see getRemoteHomePath for + // the rationale. The validator at config-input time should already + // have rejected `-`-prefixed remotes; this is the defense-in-depth. + '--', remote, `mkdir -p ~/sshshot-screenshots && cat > ~/sshshot-screenshots/${filename}` ], @@ -507,10 +585,12 @@ async function pipeToRemote( proc.on('close', (code) => { // Return the explicit path for clipboard, but command used ~ for reliability + releaseUploadSlot() resolve({ success: code === 0, path: remotePath, error: stderr.trim() || undefined }) }) proc.on('error', (err) => { + releaseUploadSlot() resolve({ success: false, path: remotePath, error: err.message }) }) }) @@ -751,6 +831,21 @@ export async function startMonitor(initialRemote: string): Promise { log(' Clipboard detection disabled — file watcher still active.') } + // Pick up a user-customized screenshot name prefix (set via + // `defaults write com.apple.screencapture name `). Stays unset + // on stock systems so the locale matcher above is the sole filter. + const customPrefix = detectCustomMacScreenshotPrefix() + if (customPrefix) { + try { + customMacScreenshotRe = buildCustomScreenshotRegex(customPrefix) + log(`Custom screenshot prefix detected: '${customPrefix}'`) + } catch (err) { + log( + `Could not compile custom screenshot prefix '${customPrefix}': ${(err as Error).message}` + ) + } + } + // Initialize lastSeenScreenshotMtime to newest existing screenshot file // (across all supported locale prefixes — see MAC_SCREENSHOT_FILENAME_RE) const screenshotDir = getMacScreenshotDir() diff --git a/test/config.test.ts b/test/config.test.ts index 2d7d131..b4f3859 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { parseSSHConfig } from '../src/config' +import { isValidRemoteName, parseSSHConfig } from '../src/config' describe('parseSSHConfig', () => { it('returns empty array for empty input', () => { @@ -115,4 +115,91 @@ Host hetzner-2 { name: 'hetzner-2', hostname: '9.10.11.12' } ]) }) + + it('expands Include directives via the loader', () => { + const root = `Host primary + HostName 1.2.3.4 + +Include corp_config +` + const corp = `Host corp-bastion + HostName bastion.corp + User ops +` + const loader = (spec: string): string[] => (spec === 'corp_config' ? [corp] : []) + expect(parseSSHConfig(root, loader)).toEqual([ + { name: 'primary', hostname: '1.2.3.4' }, + { name: 'corp-bastion', hostname: 'bastion.corp', user: 'ops' } + ]) + }) + + it('handles multiple path specs on one Include line', () => { + const root = `Include first second +` + const first = `Host a + HostName a.example.com +` + const second = `Host b + HostName b.example.com +` + const loader = (spec: string): string[] => { + if (spec === 'first') return [first] + if (spec === 'second') return [second] + return [] + } + expect(parseSSHConfig(root, loader)).toEqual([ + { name: 'a', hostname: 'a.example.com' }, + { name: 'b', hostname: 'b.example.com' } + ]) + }) + + it('caps Include recursion depth instead of stack-overflowing on cycles', () => { + // Self-referential include: file A includes itself. Without the depth + // cap this would recurse indefinitely. + const fileA = `Host a + HostName a.example.com + +Include selfref +` + const loader = (spec: string): string[] => (spec === 'selfref' ? [fileA] : []) + const result = parseSSHConfig(fileA, loader) + // Each recursion level emits the `a` host once; depth cap stops the loop. + // We don't assert exact count — just that it terminated and only emitted + // the one expected host name. + expect(result.length).toBeGreaterThan(0) + expect(result.every((h) => h.name === 'a')).toBe(true) + }) + + it('skips Match blocks (no Host names emitted from them)', () => { + const input = `Host plain + HostName plain.example.com + +Match host *.dev + ForwardAgent yes + HostName dev.example.com +` + expect(parseSSHConfig(input)).toEqual([{ name: 'plain', hostname: 'plain.example.com' }]) + }) +}) + +describe('isValidRemoteName', () => { + it('accepts ordinary hostnames', () => { + expect(isValidRemoteName('myhost')).toBe(true) + expect(isValidRemoteName('prod-1')).toBe(true) + expect(isValidRemoteName('user@host.example.com')).toBe(true) + expect(isValidRemoteName('192.168.1.1')).toBe(true) + }) + + it('rejects names that ssh would parse as flags', () => { + expect(isValidRemoteName('-oProxyCommand=evil.sh')).toBe(false) + expect(isValidRemoteName('-l')).toBe(false) + expect(isValidRemoteName('-')).toBe(false) + }) + + it('rejects empty / whitespace / control characters', () => { + expect(isValidRemoteName('')).toBe(false) + expect(isValidRemoteName('host with space')).toBe(false) + expect(isValidRemoteName('host\twith\ttab')).toBe(false) + expect(isValidRemoteName('host\nwith\nnewline')).toBe(false) + }) }) diff --git a/test/monitor-helpers.test.ts b/test/monitor-helpers.test.ts index d7bab76..338c95c 100644 --- a/test/monitor-helpers.test.ts +++ b/test/monitor-helpers.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { MAC_SCREENSHOT_FILENAME_RE, SAFE_REMOTE_FILENAME_RE, + buildCustomScreenshotRegex, generateFilename, getImageHash, isMacScreenshotFilename @@ -107,6 +108,35 @@ describe('SAFE_REMOTE_FILENAME_RE', () => { }) }) +describe('buildCustomScreenshotRegex', () => { + it('matches a custom prefix the user set via `defaults write`', () => { + const re = buildCustomScreenshotRegex('MyShot') + expect(re.test('MyShot 2026-05-01 at 12.34.56.png')).toBe(true) + expect(re.test('MyShot.png')).toBe(true) + }) + + it('rejects similar-but-wrong prefixes', () => { + const re = buildCustomScreenshotRegex('MyShot') + expect(re.test('MyShots 2026-05-01.png')).toBe(false) + expect(re.test('NotMyShot 2026-05-01.png')).toBe(false) + expect(re.test('Screenshot 2026-05-01.png')).toBe(false) + }) + + it('escapes regex metacharacters in the prefix', () => { + // A user with a literal `+` in their prefix shouldn't accidentally turn + // the previous char into "one or more" — escapes must be applied. + const re = buildCustomScreenshotRegex('a+b') + expect(re.test('a+b 2026-05-01.png')).toBe(true) + // 'aab' would only match if `+` were the regex quantifier — confirm escape. + expect(re.test('aab 2026-05-01.png')).toBe(false) + }) + + it('throws on empty/whitespace-only input', () => { + expect(() => buildCustomScreenshotRegex('')).toThrow() + expect(() => buildCustomScreenshotRegex(' ')).toThrow() + }) +}) + describe('getImageHash', () => { it('is deterministic for the same buffer', () => { const buf = Buffer.from('hello world')