mirror of
https://github.com/stablyai/orca.git
synced 2026-10-02 16:02:15 +00:00
The six oversized src/main/ipc modules each carried a file-level `eslint-disable max-lines` and ran 427-671 counted lines against a 300-line budget. AGENTS.md calls for splitting rather than suppressing, and config/max-lines-baseline.txt is a shrink-only ratchet, so this removes all six suppressions and prunes their entries (341 -> 335). Pure move, no behavior change. Each file is cut along the seams it already had: pet splits into format allowlist / storage paths / symlink-safe copy / bundle manifest + import; filesystem-auth into path-containment primitives, the config-derived allow-list, and the git-registered root cache; notifications into sound selection, native lifecycle, permission probe, and burst cooldown; crash-reporting into renderer error reports, breadcrumbs, and sender. The IPC surface is proved intact rather than assumed: comparing (method, channel) multisets between HEAD and the split gives 49 registrations across 49 distinct channels on both sides. filesystem-auth's security boundary keeps its acyclic layering -- containment primitives, then allow-list, then root cache, then path-resolution orchestration -- with no layer gaining a back-edge. Also keeps clipboard-ipc-handlers.test.ts under the 800-line test budget. The split had briefly added a redundant vi.mock for isENOENT (byte-identical to the real implementation) that pushed it to 801; the mock is dropped in favor of the real function, with realpath added to the existing node:fs/promises mock. Verified: oxlint clean, ratchet passes, typecheck clean, full unit suite green (the one remaining failure is a pre-existing load flake in an untouched file, green when re-run serially), no new runtime import cycles among 617 modules, and no lint suppression added anywhere.
56 lines
2.5 KiB
TypeScript
56 lines
2.5 KiB
TypeScript
import { stat } from 'node:fs/promises'
|
|
import { isAbsolute, resolve, sep } from 'node:path'
|
|
import { classifyFile } from './pet-image-formats'
|
|
import { MAX_BYTES } from './pet-import-size-limits'
|
|
import { isSymlink } from './pet-symlink-safe-copy'
|
|
import type { PetManifestLike, ResolvedPetManifest } from './pet-bundle'
|
|
|
|
/** Validated spritesheet source inside a bundle: an absolute path plus its allowlisted format. */
|
|
export async function resolveBundleSpritesheetSource<T extends PetManifestLike>(
|
|
manifest: ResolvedPetManifest<T>,
|
|
bundleDir: string
|
|
): Promise<{ sheetSrc: string; sheetClass: { mimeType: string; ext: string } }> {
|
|
// Why: spritesheetPath is bundle-relative and attacker-controlled — reject absolute/escaping paths (and symlinks) so a bundle can't reach outside.
|
|
const normalizedSpritePath = manifest.spritesheetPath.replace(/[\\/]+/g, sep)
|
|
if (
|
|
isAbsolute(manifest.spritesheetPath) ||
|
|
isAbsolute(normalizedSpritePath) ||
|
|
/^[a-zA-Z]:/.test(manifest.spritesheetPath)
|
|
) {
|
|
throw new Error('spritesheetPath must be relative to the bundle.')
|
|
}
|
|
// Why: bundles exported on Windows may be imported on macOS/Linux; normalize separators before resolving.
|
|
const sheetSrc = resolve(bundleDir, normalizedSpritePath)
|
|
const bundleResolved = resolve(bundleDir)
|
|
if (sheetSrc === bundleResolved) {
|
|
throw new Error('spritesheetPath must point to a file, not the bundle root.')
|
|
}
|
|
const bundleRoot = bundleResolved + sep
|
|
// Why: Windows volumes are case-insensitive; lowercase the prefix compare so case differences can't bypass the escape check.
|
|
const cmp = process.platform === 'win32' ? (s: string) => s.toLowerCase() : (s: string) => s
|
|
if (!cmp(sheetSrc + sep).startsWith(cmp(bundleRoot))) {
|
|
throw new Error('spritesheetPath escapes the bundle.')
|
|
}
|
|
if (await isSymlink(sheetSrc)) {
|
|
throw new Error('spritesheet must not be a symlink.')
|
|
}
|
|
const sheetClass = classifyFile(sheetSrc)
|
|
if (!sheetClass || sheetClass.ext === '.svg') {
|
|
// SVG can't be used as a sprite sheet (no pixel grid).
|
|
throw new Error('Spritesheet must be a PNG, APNG, JPG, GIF, or WebP.')
|
|
}
|
|
let sheetStat: Awaited<ReturnType<typeof stat>>
|
|
try {
|
|
sheetStat = await stat(sheetSrc)
|
|
} catch {
|
|
throw new Error('Spritesheet file not found.')
|
|
}
|
|
if (!sheetStat.isFile()) {
|
|
throw new Error('Spritesheet path is not a file.')
|
|
}
|
|
if (sheetStat.size > MAX_BYTES) {
|
|
throw new Error(`Spritesheet is too large (${(sheetStat.size / (1024 * 1024)).toFixed(1)} MB).`)
|
|
}
|
|
return { sheetSrc, sheetClass }
|
|
}
|