import path from "node:path"; import { access, constants as fsConstants, mkdir, readdir, realpath, rename, rm, symlink, unlink, } from "node:fs/promises"; import type { Paths } from "../lib/paths"; import { getFreeBytes } from "../lib/diskSpace"; import { formatBytes } from "../lib/format"; import { getSettings, writeSettings, type SiteSettings, } from "../lib/settings"; import type { RelocationMarker, RelocationPhase } from "../lib/channelMedia"; import { NOT_ANSWERING, isDriveNotAnswering, onDrive, sinceText, stalledLocation, } from "../lib/storageHealth"; import { relocatedSavedVideosDir, savedVideosMarkerPath, } from "../lib/savedVideoStore"; import { clearDirMarker, copyMirrorVerify, isDirectory, linkOrDirState, makeProgressSink, measureTree, readDirMarker, verifyCopy, writeDirMarker, type RelocationProgress, } from "./relocateDir"; import { assertRelocationRootPresent } from "./relocateChannelMedia"; // MOVING THE SAVED-VIDEO STORE TO ANOTHER DRIVE. // // The store (`transcripts/saved-videos`) is where a persisted source container // goes when the per-download retention rule decides to keep one — the single // largest thing in the corpus that is not a channel's `data/`, and the one // directory a channel move could never reach. `plans/storage-locations.md` // recorded it as "follow-up, not here"; this is the follow-up. // // SAME MECHANISM, SAME INVARIANT. `` becomes an absolute SYMLINK to // `/saved-videos` and the copy is verified before the source is touched, // exactly as a channel's `data/` does it — `relocateDir.ts` is literally the // same code. Nothing that reads the store has to know: `savedVideoRoot()` // (lib/savedVideo.ts) returns `paths.savedVideosDir` as it always did and the // kernel follows the link. // // WHAT IS DIFFERENT FROM A CHANNEL, and it is one thing: the record of where it // went is `settings.storage.savedVideosLocationId`, not a per-channel config // field. So the swap's last step is a settings write rather than a // writeChannelConfig, and a rollback has one file to put back instead of n. // // THE PER-CHANNEL OVERRIDE IS NOT MOVED. `ChannelConfig.savedVideosDir` lets a // channel point its store somewhere else entirely; a channel that has one is // not in this store and this move neither reads nor rewrites it. That is // deliberate — it is an absolute path the operator set, and silently // re-anchoring it would be this function deciding something it was not asked. // THE MARKER'S NAME AND ITS READER LIVE IN lib/, not here — see // lib/savedVideoStore.ts. `savedVideo-server.ts`, which persists a container // and is the thing that must be refused while a move is in flight, is lib and // may not import controller. Re-exported so every existing importer of this // module keeps working; this file WRITES the marker, it does not own it. export { SAVED_VIDEOS_DIRNAME, SAVED_VIDEOS_MARKER_FILENAME, relocatedSavedVideosDir, savedVideosMarkerPath, } from "../lib/savedVideoStore"; // THE PARKED NAME IS TIMESTAMPED AND SWEPT BY PREFIX, exactly as the channel // mover's `data.relocated-` is, and for its reason: a rerun that renamed // onto a parked directory left by an earlier attempt gets ENOTEMPTY, and a // sweep that took only the name THIS process minted would orphan the earlier // one for ever — a full second copy of the store, on the volume the move exists // to free. const PARKED_PREFIX = "saved-videos.relocated-"; const INCOMING_NAME = "saved-videos.incoming"; export type SavedVideosStoreStatus = // The store is a real directory under the corpus. | "in-place" // Relocated, link and settings agree, and the target is a reachable dir. | "ok" // Relocated, but the target is not there — almost always an unmounted drive. | "unreachable" // A move is in flight, or one was interrupted: the marker is present. | "in-transition" // Disk and settings disagree, in either direction. Never guessed past. | "inconsistent"; export type SavedVideosStore = { // Always `paths.savedVideosDir` — the path every reader uses, moved or not. dir: string; // `settings.storage.savedVideosLocationId`, or "" for in place. locationId: string; // Where the bytes actually are (the link target), when it is relocated. target?: string; status: SavedVideosStoreStatus; detail?: string; marker?: RelocationMarker; }; // WHAT THE STORE IS RIGHT NOW, from the disk first and the settings second. // // The same shape and the same discipline as `inspectChannelMedia`: lstat (never // stat, so a dangling link is not read as absent), and any disagreement between // the link and the record is `inconsistent` rather than a guess. export async function inspectSavedVideosStore( paths: Paths, settings?: SiteSettings, ): Promise { const s = settings ?? getSettings(); const dir = paths.savedVideosDir; const locationId = s.storage.savedVideosLocationId ?? ""; const marker = await readDirMarker(savedVideosMarkerPath(paths)); const state = await linkOrDirState(dir); const loc = s.storage.locations.find((l) => l.id === locationId); const expected = loc ? relocatedSavedVideosDir(loc.root) : ""; if (marker) { return { dir, locationId, ...(state.kind === "link" ? { target: state.linkTarget } : {}), status: "in-transition", detail: `a move (${marker.direction}) to ${marker.target} is in flight or was interrupted at phase "${marker.phase}"`, marker, }; } if (state.kind === "link") { const target = state.linkTarget; if (expected && path.resolve(target) !== path.resolve(expected)) { return { dir, locationId, target, status: "inconsistent", detail: `the store links to ${target}, but settings record location "${locationId}" at ${expected}`, }; } if (!expected) { return { dir, locationId, target, status: "inconsistent", detail: `the store links to ${target}, but no storage location is recorded for it`, }; } // The store's drive is not answering: reported without the stat below, // which would wait on it (the /storage page asks on every render). The // page skips the store's size walk for an unreachable store, too. const stall = loc ? stalledLocation(loc) : null; if (stall) { return { dir, locationId, target, status: "unreachable", detail: `${NOT_ANSWERING} (location "${stall.label}", ${sinceText(stall.since)})`, }; } let reachable: boolean; try { reachable = await onDrive(loc ?? target, () => isDirectory(target)); } catch (err) { if (!isDriveNotAnswering(err)) throw err; return { dir, locationId, target, status: "unreachable", detail: err.message, }; } if (!reachable) { return { dir, locationId, target, status: "unreachable", detail: `${target} is not reachable (is the drive mounted?)`, }; } return { dir, locationId, target, status: "ok" }; } if (expected) { return { dir, locationId, status: "inconsistent", detail: `settings record the store on location "${locationId}", but ${dir} is not a symlink`, }; } if (state.kind === "other") { return { dir, locationId: "", status: "inconsistent", detail: `${dir} is neither a directory nor a symlink`, }; } return { dir, locationId: "", status: "in-place" }; } export type SavedVideosRelocateResult = { // "" when the store was moved back in place. locationId: string; target: string; bytes: number; files: number; resumed: boolean; retried: boolean; }; type Opts = { paths: Paths; // "" moves the store back in place; any other value is a location id. locationId: string; onLog?: (line: string) => void; onProgress?: (p: RelocationProgress) => void; signal?: AbortSignal; // Injectable for unit tests, exactly as the storage controller does it. io?: { read: () => SiteSettings; write: (next: SiteSettings) => Promise }; // WHAT IS WRITING INTO THE STORE, asked as the job's first step (release 16 // slice RM, the channel mover's rule): a sentence naming it, or null. The // editor passes its store check (storage/lib/storeBusy.ts) — a store has no // slug, so the question is the whole machine's and lives with the editor // that knows its kinds. A bin script passes nothing; the marker remains the // guard either way (`assertSavedVideosStoreWritable`). busy?: () => string | null; }; const BYTES_PER_GB = 1024 ** 3; export async function relocateSavedVideos( opts: Opts, ): Promise { const io = opts.io ?? { read: getSettings, write: writeSettings }; const log = opts.onLog ?? ((m: string) => console.log(m)); const paths = opts.paths; // The job's first step: a move never starts over a writer, and never waits // silently for one either. const busy = opts.busy?.() ?? null; if (busy) throw new Error(`${busy} Nothing has been touched.`); const markerFile = savedVideosMarkerPath(paths); const existing = await readDirMarker(markerFile); const wantBack = opts.locationId.trim() === ""; const direction = wantBack ? "back" : "out"; // A MARKER FOR THE OTHER DIRECTION IS NEVER RESUMED INTO THIS ONE. Same // target, opposite intent — the channel mover refuses this for the reason // that finishing a move-out as a move-back swaps the wrong way round. if (existing && existing.direction !== direction) { throw new Error( `A saved-video store move (${existing.direction}) to ${existing.target} is ` + `in flight or was interrupted — finish that before moving ${direction}.`, ); } return wantBack ? moveStoreBack({ ...opts, io, log, markerFile, resume: existing }) : moveStoreOut({ ...opts, io, log, markerFile, resume: existing }); } type Inner = Opts & { io: NonNullable; log: (m: string) => void; markerFile: string; resume: RelocationMarker | null; }; async function stampMarker( file: string, target: string, direction: "out" | "back", phase: RelocationPhase, ): Promise { await writeDirMarker(file, { target, direction, startedAt: new Date().toISOString(), phase, }); } async function moveStoreOut(a: Inner): Promise { const { paths, log, markerFile, io } = a; const settings = io.read(); const loc = settings.storage.locations.find( (l) => l.id === a.locationId.trim(), ); if (!loc) { throw new Error( `There is no storage location "${a.locationId}". Add it on /storage.`, ); } const store = paths.savedVideosDir; const target = relocatedSavedVideosDir(loc.root); if (a.resume && a.resume.target !== target) { throw new Error( `A move to ${a.resume.target} is already in progress — finish or clear it ` + `before moving to ${target}.`, ); } // PREFLIGHT. Everything that can refuse does so before a byte is written and // before the marker exists. if (!(await isDirectory(loc.root))) { throw new Error( `The destination root ${loc.root} does not exist or is not a directory ` + `(is the drive mounted?)`, ); } try { await access(loc.root, fsConstants.W_OK); } catch { throw new Error(`The destination root ${loc.root} is not writable`); } // RESOLVED THROUGH EVERY SYMLINK, on BOTH sides. A lexical comparison is // exactly the check the channel mover learned not to make: `/mnt/x` can be a // link back into the corpus, and the corpus dir itself is routinely a link // (the worktrees' `test-transcripts`, a bind mount in a container). Miss it // and the store is copied onto itself, verified against itself, and then the // "source" is reclaimed — which is the only copy. const [realRoot, realCorpus] = await Promise.all([ realOrResolved(loc.root), realOrResolved(paths.transcriptsDir), ]); if (isWithin(realCorpus, realRoot)) { throw new Error( `The destination root ${loc.root} is inside the corpus at ` + `${paths.transcriptsDir} — the store would be copied onto itself and ` + `then reclaimed. Pick a directory on the other drive.`, ); } // Belt and braces for a root outside the corpus whose `saved-videos` level is // a link back into it: the target resolves separately, because realpath of // the root cannot see through a link one level down. const realTarget = await realOrResolved(relocatedSavedVideosDir(realRoot)); const realStore = await realOrResolved(paths.savedVideosDir); if (isWithin(realCorpus, realTarget) || isWithin(realStore, realTarget)) { throw new Error( `The destination ${target} resolves inside the corpus at ` + `${paths.transcriptsDir} — the store would be copied onto itself and ` + `then reclaimed. Pick a directory on the other drive.`, ); } const state = await linkOrDirState(store); if (!a.resume && state.kind !== "real-dir" && state.kind !== "missing") { throw new Error( `${store} is not a real directory (it is a ${state.kind}) — the store is ` + `already moved, or something else is there.`, ); } // A STORE THAT HAS NEVER EXISTED IS NOT AN RSYNC ERROR. Nothing has ever been // persisted on a corpus whose keep-latest window is unset, so there is no // `saved-videos` directory at all — and rsync exits 23 on a missing source, // which the old code reported as "rsync failed (exit 23)" AFTER writing the // marker, leaving the store stuck in transition over a directory that was // never there. Create it empty and let the move proceed: the operator asked // for the store to live on the platter, and an empty store on the platter is // exactly that answer, ready for the first persist. if (state.kind === "missing") { await mkdir(store, { recursive: true }); log(`${store} did not exist yet — created it empty before the move.`); } let phase: RelocationPhase = a.resume?.phase ?? "copy"; const measured = await measureTree(store); let retried = false; log( `Moving the saved-video store: ${measured.files} file(s), ` + `${formatBytes(measured.bytes)} -> ${target}`, ); if (phase === "copy") { const marginGB = settings.minFreeDiskGB > 0 ? settings.resumeMarginGB : 0; const needed = measured.bytes + marginGB * BYTES_PER_GB; const free = await getFreeBytes(loc.root); if (free < needed) { throw new Error( `Not enough space on ${loc.root}: ${formatBytes(free)} free, ` + `${formatBytes(measured.bytes)} to move` + (marginGB > 0 ? ` plus a ${marginGB} GB resume margin = ${formatBytes(needed)} required` : " required"), ); } // THE MOVE MUST NOT MATERIALISE THE MOUNTPOINT — see // `assertRelocationRootPresent`. `mkdir(target, {recursive: true})` below // would otherwise build `/saved-videos` on the root filesystem with // the platter unplugged, and every pinned container would land there. await assertRelocationRootPresent(loc.root, settings.storage, paths); await mkdir(target, { recursive: true }); // What a previous attempt already landed — see the channel mover. const already = (await measureTree(target)).bytes; await stampMarker(markerFile, target, "out", "copy"); // Copy, mirror toward the target, verify — the channel mover's pipeline // (relocateDir.ts's copyMirrorVerify), so a persist that landed in the // store during the copy is carried, and a container unpersisted during it // is removed from the copy rather than failing the counts. retried ||= ( await copyMirrorVerify({ rsyncBin: paths.rsyncBin, src: store, dest: target, live: store, log, progress: makeProgressSink({ totalBytes: measured.bytes, alreadyBytes: already, log, onProgress: a.onProgress, }), signal: a.signal, cancelled: () => new Error( `Cancelled. ${store} is untouched and the partial copy at ${target} is ` + `resumable — rerun to continue.`, ), }) ).retried; phase = "swap"; } if (phase === "swap") { await stampMarker(markerFile, target, "out", "swap"); // OBSERVE, DO NOT ASSUME — a crash lands between any two syscalls, and the // marker can only say which phase it was in, never how far through it got. const now = await linkOrDirState(store); const parked = path.join( path.dirname(store), `${PARKED_PREFIX}${Date.now()}`, ); if (now.kind === "real-dir") { // Re-verify: a resumed run did not do the copy in this process and must // not take the interrupted one's word for it. log("Verifying the copy…"); retried ||= ( await verifyCopy({ rsyncBin: paths.rsyncBin, src: store, dest: target, log, signal: a.signal, // The store is still the live directory here, so a difference gets // the mirror pass a copy phase would give it. mirror: { live: store }, }) ).retried; await rename(store, parked); } else if (now.kind === "other") { throw new Error( `${store} is neither a directory nor a symlink — refusing to replace it`, ); } // THE LINK IS MADE DEFENSIVELY, AND THE RECORD IS WRITTEN ONLY IF IT EXISTS. // // `after.kind === "missing"` was the whole condition, and it is not enough: // `moveFileCrossDevice` (savedVideo-server.ts) does an unconditional // `mkdir -p` of the destination's parent, so a persist firing in the // instant between the rename above and this line RECREATES `saved-videos` // as a real, empty directory. The old code then saw `real-dir`, made no // link, and went on to record the store as living on a location it could // not be reached at — a settings field pointing at bytes nothing follows. // (The guard in lib/savedVideoStore.ts is what closes that window; this is // what makes the outcome safe if it is ever open anyway.) let after = await linkOrDirState(store); if ( after.kind === "link" && path.resolve(after.linkTarget) !== path.resolve(target) ) { throw new Error( `${store} already points at ${after.linkTarget}, not ${target}`, ); } if (after.kind === "real-dir") { // An empty directory is the mkdir -p above and nothing else — remove it // and link. A NON-empty one holds bytes this move did not copy, and // deleting it is not this function's call to make. const stray = await readdir(store).catch(() => ["keep"] as string[]); if (stray.length > 0) { throw new Error( `${store} is a real directory again and is not empty (${stray.length} ` + `entr(ies)) — something wrote into the store during the move. The ` + `copy at ${target} is complete and untouched; move those entries ` + `aside and rerun.`, ); } log( `${store} was recreated as an empty directory during the swap ` + `(a persist raced the move) — removing it and linking.`, ); await rm(store, { recursive: false, force: true }).catch(async () => { await rm(store, { recursive: true, force: true }); }); after = await linkOrDirState(store); } if (after.kind === "missing") { await symlink(target, store); after = await linkOrDirState(store); } if (after.kind !== "link") { throw new Error( `${store} is not a symlink after the swap (it is ${after.kind}) — ` + `refusing to record the store as relocated. The copy at ${target} is ` + `complete; nothing has been deleted.`, ); } // THE RECORD IS WRITTEN ONLY NOW: on success, after the link exists and has // been read back. It is a statement about where bytes are, and a statement // nothing can follow is worse than no statement. const latest = io.read(); await io.write({ ...latest, storage: { ...latest.storage, savedVideosLocationId: loc.id }, }); log(`Swapped: ${store} -> ${target}`); await stampMarker(markerFile, target, "out", "reclaim"); } await reclaimParked(paths, log); await clearDirMarker(markerFile); log( `Done. ${formatBytes(measured.bytes)} now on "${loc.label || loc.id}"; ` + `${formatBytes(await getFreeBytes(paths.transcriptsDir))} free on the corpus volume.`, ); return { locationId: loc.id, target, bytes: measured.bytes, files: measured.files, resumed: Boolean(a.resume), retried, }; } async function moveStoreBack(a: Inner): Promise { const { paths, log, markerFile, io } = a; const settings = io.read(); const store = paths.savedVideosDir; const state = await linkOrDirState(store); // The link is the first source of truth; the marker is the second, because a // crash after the swap leaves settings cleared and only the marker naming the // target still holding the bytes. const target = state.kind === "link" ? state.linkTarget : (a.resume?.target ?? (() => { const loc = settings.storage.locations.find( (l) => l.id === (settings.storage.savedVideosLocationId ?? ""), ); return loc ? relocatedSavedVideosDir(loc.root) : ""; })()); if (!target) { throw new Error( "The saved-video store is not relocated — it is already in place.", ); } const incoming = path.join(path.dirname(store), INCOMING_NAME); let phase: RelocationPhase = a.resume?.phase ?? "copy"; if (phase === "copy" && !(await isDirectory(target))) { throw new Error( `The relocated store at ${target} is not reachable (is the drive mounted?)`, ); } let retried = false; const measured = (await isDirectory(target)) ? await measureTree(target) : (await isDirectory(incoming)) ? await measureTree(incoming) : { bytes: 0, files: 0 }; log( `Moving the saved-video store back in place: ${measured.files} file(s), ` + `${formatBytes(measured.bytes)} <- ${target}`, ); if (phase === "copy") { const marginGB = settings.minFreeDiskGB > 0 ? settings.resumeMarginGB : 0; const already = (await isDirectory(incoming)) ? (await measureTree(incoming)).bytes : 0; const needed = Math.max(0, measured.bytes - already) + marginGB * BYTES_PER_GB; const free = await getFreeBytes(paths.transcriptsDir); if (free < needed) { throw new Error( `Not enough space on the corpus volume: ${formatBytes(free)} free, ` + `${formatBytes(Math.max(0, measured.bytes - already))} still to move back` + (marginGB > 0 ? ` plus a ${marginGB} GB resume margin = ${formatBytes(needed)} required` : " required"), ); } // ON THE SOURCE SIDE'S LOCATION ROOT, not on `incoming` — `incoming` is a // corpus directory a move-back is entitled to create. `isDirectory(target)` // above covers existence; this covers IDENTITY, because an empty // mountpoint directory with the drive unplugged IS a directory, and // copying it back would report a clean move of zero bytes and then delete // the target. await assertRelocationRootPresent( path.dirname(target), settings.storage, paths, ); await mkdir(incoming, { recursive: true }); await stampMarker(markerFile, target, "back", "copy"); // Copy, mirror toward `incoming` (the copy under construction — the target // on the drive is the source here, never the target of --delete), verify. retried ||= ( await copyMirrorVerify({ rsyncBin: paths.rsyncBin, src: target, dest: incoming, // The store's path, a link to the target by now: --delete may never // reach what it resolves to. live: store, log, progress: makeProgressSink({ totalBytes: measured.bytes, // The figure the space check above is priced in. alreadyBytes: already, log, onProgress: a.onProgress, }), signal: a.signal, cancelled: () => new Error( `Cancelled. ${target} is untouched and ${incoming} is resumable — rerun to continue.`, ), }) ).retried; phase = "swap"; } // MAY THE TARGET BE DELETED AT THE END? Only when this run can vouch for the // copy that is standing in for it. // // The old code reclaimed the target whenever `store` was a real directory, // and "a real directory" is not evidence of anything: it is ALSO what // `rsync --copy-links` of the corpus produces (WORKTREES.md documents that as // the way to carry a store into a shard), and it is what the `inconsistent` // state looks like — settings naming a location while the disk holds a real // dir. In both cases `rm -rf target` deletes the relocated copy on the // strength of a local directory nobody compared it with. // // Three things count as vouching, and nothing else does: this run did the // swap itself from a verified `incoming`; the marker says a previous run got // past the swap (`phase: "reclaim"`, which is written only after it); or the // local tree measures at least as large as the target's, which is the // cheapest honest answer when neither of the first two applies. let vouched = a.resume?.phase === "reclaim"; if (phase === "swap") { await stampMarker(markerFile, target, "back", "swap"); const now = await linkOrDirState(store); if (now.kind === "real-dir") { log(`${store} is already a real directory — the swap had completed`); } else if (now.kind === "other") { throw new Error( `${store} is neither a directory nor a symlink — refusing to replace it`, ); } else { if (!(await isDirectory(incoming))) { throw new Error( `Cannot finish the move back: ${store} is not a directory and there ` + `is no verified copy at ${incoming}`, ); } // unlink, not rm -r: `store` is the LINK here, and removing it // recursively would be the one way this design eats the media. if (now.kind !== "missing") await unlink(store); await rename(incoming, store); // THIS run moved a verified copy into place. Nothing is more vouched // than that. vouched = true; log(`Swapped: ${store} is a real directory again`); } const latest = io.read(); if ((latest.storage.savedVideosLocationId ?? "") !== "") { const { savedVideosLocationId: _dropped, ...storage } = latest.storage; await io.write({ ...latest, storage }); } await stampMarker(markerFile, target, "back", "reclaim"); } // THE LAST MEASUREMENT BEFORE THE ONLY DESTRUCTIVE STEP. Cheap — two walks of // a store that holds one container per pinned video — and it is the answer to // "is what I am about to delete still the only copy". if (!vouched && (await isDirectory(target))) { const [here, there] = await Promise.all([ measureTree(store), measureTree(target), ]); vouched = here.bytes >= there.bytes && here.files >= there.files; if (!vouched) { // NOT AN ERROR, AND DELIBERATELY NOT: the move back has succeeded as far // as anything reversible goes — the store is a real directory and the // record is cleared. What is refused is the DELETE, and leaving a second // copy on the platter is the safe half of that decision. The marker is // cleared so the store is not stuck in transition over it. await reclaimParked(paths, log); await clearDirMarker(markerFile); log( `The store is in place, but ${target} was NOT deleted: it holds ` + `${there.files} file(s)/${formatBytes(there.bytes)} against ` + `${here.files}/${formatBytes(here.bytes)} here, so this run cannot ` + `vouch that the local copy is complete. Compare them and remove it ` + `by hand.`, ); return { locationId: "", target, bytes: here.bytes, files: here.files, resumed: Boolean(a.resume), retried, }; } } await rm(target, { recursive: true, force: true }); await reclaimParked(paths, log); await clearDirMarker(markerFile); log(`Done. ${formatBytes(measured.bytes)} back in place.`); return { locationId: "", target, bytes: measured.bytes, files: measured.files, resumed: Boolean(a.resume), retried, }; } // Every leftover a crashed run can have parked beside the store, swept on every // path — the channel mover's `sweepParked`, for the reason it exists there: a // crash between the settings write and the reclaim marker otherwise orphans a // full second copy of the store on the volume the move exists to free. async function reclaimParked( paths: Paths, log: (m: string) => void, ): Promise { const parent = path.dirname(paths.savedVideosDir); const names = await readdir(parent).catch(() => [] as string[]); for (const n of names) { if (!n.startsWith(PARKED_PREFIX) && n !== INCOMING_NAME) continue; const p = path.join(parent, n); log(`Reclaiming ${p}`); await rm(p, { recursive: true, force: true }); } } // Resolved through every symlink when the path exists, lexically when it does // not — `relocateChannelMedia.ts`'s helper, same name, same reason. async function realOrResolved(p: string): Promise { return await realpath(p).catch(() => path.resolve(p)); } function isWithin(parent: string, child: string): boolean { const rel = path.relative(parent, child); return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel)); }