| 1 | // Where screenshots, zoom crops and recordings are written, and the one check |
| 2 | // every caller-chosen output path goes through. |
| 3 | import fs from "node:fs"; |
| 4 | import path from "node:path"; |
| 5 | import { ExecError } from "./exec.mjs"; |
| 6 | import { stateDir } from "./registry.mjs"; |
| 7 | |
| 8 | /** The one recordings directory: desktop and browser captures, zoom crops, |
| 9 | * recordings and trajectories all live under it. */ |
| 10 | export function recordingsDir() { |
| 11 | return path.resolve(process.env.CODEWHALE_CU_RECORDINGS_DIR || path.join(stateDir(), "recordings")); |
| 12 | } |
| 13 | |
| 14 | const badPath = (message) => Object.assign(new ExecError(message), { code: "bad_args" }); |
| 15 | |
| 16 | function inside(dir, file) { |
| 17 | const rel = path.relative(dir, file); |
| 18 | return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel); |
| 19 | } |
| 20 | |
| 21 | /** |
| 22 | * Validate a caller-supplied capture output path. `undefined`/`null` means |
| 23 | * "use the default name" and returns null. Anything else must be an absolute |
| 24 | * .png/.jpg/.jpeg filename inside the recordings directory, must not be an |
| 25 | * existing symlink, and its parent must not resolve outside that directory. |
| 26 | * Creates the recordings directory (and the parent) so the checks see the |
| 27 | * real filesystem. Returns the resolved absolute path. |
| 28 | */ |
| 29 | export function recordingsOutputPath(file) { |
| 30 | if (file === undefined || file === null) return null; |
| 31 | if (typeof file !== "string" || !path.isAbsolute(file) || file.includes("\0")) { |
| 32 | throw badPath("output path must be an absolute filename inside the recordings directory"); |
| 33 | } |
| 34 | if (!/\.(png|jpe?g)$/i.test(file)) throw badPath("output path must end in .png, .jpg or .jpeg"); |
| 35 | const dir = recordingsDir(); |
| 36 | const resolved = path.resolve(file); |
| 37 | if (!inside(dir, resolved)) { |
| 38 | throw badPath(`output path must be inside the recordings directory (${dir}); omit path to use a generated name there`); |
| 39 | } |
| 40 | fs.mkdirSync(dir, { recursive: true }); |
| 41 | const realDir = fs.realpathSync(dir); |
| 42 | // Resolve the deepest parent that exists before creating anything, so a |
| 43 | // symlinked subdirectory cannot redirect the mkdir or the capture. |
| 44 | let existing = path.dirname(resolved); |
| 45 | const present = (p) => { try { fs.lstatSync(p); return true; } catch { return false; } }; |
| 46 | while (!present(existing)) existing = path.dirname(existing); |
| 47 | let realParent; |
| 48 | try { realParent = fs.realpathSync(existing); } catch { throw badPath("output path's parent directory cannot be resolved"); } |
| 49 | if (realParent !== realDir && !inside(realDir, realParent)) { |
| 50 | throw badPath("output path must not leave the recordings directory through a symlink"); |
| 51 | } |
| 52 | fs.mkdirSync(path.dirname(resolved), { recursive: true }); |
| 53 | let stat = null; |
| 54 | try { stat = fs.lstatSync(resolved); } catch { /* does not exist yet */ } |
| 55 | if (stat?.isSymbolicLink()) throw badPath("output path must not be a symlink"); |
| 56 | return resolved; |
| 57 | } |
| 58 |