{"version":3,"file":"safeFs.cjs","names":["fs","path","forceRemove"],"sources":["../../src/helpers/safeFs.ts"],"sourcesContent":["import fs from 'node:fs';\nimport path from 'node:path';\n\nimport { forceRemove } from './sandboxUser.js';\n\n/**\n * Recursively copy `source` into `destination` without ever following a symlink that already\n * exists at a destination path. A sandboxed submission (running as a different OS user) can plant\n * such a symlink to redirect this trusted-user write outside its working directory, so every\n * pre-existing destination entry is lstat'd and unlinked (never followed) before being written.\n * Symlinks in the SOURCE are recreated as symlinks; their targets are never read.\n *\n * Merges into an existing destination directory (like `fs.cp(..., { recursive: true })`), but a\n * plain `fs.cp` follows destination symlinks nested inside directories, which this avoids.\n */\nexport async function copyWithoutFollowingSymlinks(source: string, destination: string): Promise<void> {\n  const sourceStats = await fs.promises.lstat(source);\n\n  if (sourceStats.isSymbolicLink()) {\n    await removeExistingEntry(destination);\n    await fs.promises.symlink(await fs.promises.readlink(source), destination);\n    return;\n  }\n\n  if (sourceStats.isDirectory()) {\n    // A pre-existing symlink (or file) at the destination is removed so the merge target is a real\n    // directory the harness owns, not something the submission redirected.\n    const existingDestinationStats = await lstatOrUndefined(destination);\n    if (!existingDestinationStats?.isDirectory()) await removeExistingEntry(destination);\n    await fs.promises.mkdir(destination, { recursive: true });\n    for (const entry of await fs.promises.readdir(source)) {\n      await copyWithoutFollowingSymlinks(path.join(source, entry), path.join(destination, entry));\n    }\n    return;\n  }\n\n  await removeExistingEntry(destination);\n  await fs.promises.copyFile(source, destination);\n}\n\n/**\n * Create `directory` and the missing levels between it and `root`, replacing anything that is not\n * already a real directory. `fs.mkdir(..., { recursive: true })` follows a symlink it finds on the\n * way, which a submission can plant to place a trusted-user write outside the tree; this walks the\n * levels one at a time instead. Only levels BELOW `root` are inspected, so an unrelated symlink\n * above the caller's tree (`/tmp` on macOS, for instance) is never touched. `root` must already be\n * a directory the harness trusts, and `directory` must be inside it.\n */\nexport async function createDirectoryWithoutFollowingSymlinks(root: string, directory: string): Promise<void> {\n  const relativePath = path.relative(root, directory);\n  if (!isContainedPath(root, directory)) {\n    throw new Error(`directory must be inside root: ${directory} is not inside ${root}`);\n  }\n\n  let currentPath = root;\n  for (const level of relativePath.split(path.sep).filter(Boolean)) {\n    currentPath = path.join(currentPath, level);\n    const stats = await lstatOrUndefined(currentPath);\n    if (stats?.isDirectory()) continue;\n    if (stats) await removeExistingEntry(currentPath);\n    await fs.promises.mkdir(currentPath);\n  }\n}\n\n// `fs.rm` unlinks a symlink itself rather than following it, and removes files/directories\n// otherwise; `forceRemove` adds the retry for entries a sandboxed submission left unreadable.\nasync function removeExistingEntry(target: string): Promise<void> {\n  await forceRemove(target);\n}\n\n/**\n * Write `data` to `filePath` without following a symlink already there. `fs.writeFile` follows one,\n * so a sandboxed submission could point a file the harness is about to write (a generated project\n * file, a fixture) at something only the harness can reach and have it overwritten. Trusted\n * callbacks that create files inside a submission directory must use this instead.\n */\nexport async function writeFileWithoutFollowingSymlinks(filePath: string, data: string | Uint8Array): Promise<void> {\n  await removeExistingEntry(filePath);\n  // `wx` fails rather than following a symlink planted between the removal and the write.\n  await fs.promises.writeFile(filePath, data, { flag: 'wx' });\n}\n\n/** Whether `realTargetPath` is `realDirectoryPath` itself or below it. Both must be realpaths. */\nexport function isContainedPath(realDirectoryPath: string, realTargetPath: string): boolean {\n  const relativePath = path.relative(realDirectoryPath, realTargetPath);\n  // A bare `..`-prefix test would also reject an in-directory name like `..result`.\n  return relativePath !== '..' && !relativePath.startsWith(`..${path.sep}`) && !path.isAbsolute(relativePath);\n}\n\nasync function lstatOrUndefined(target: string): Promise<fs.Stats | undefined> {\n  try {\n    return await fs.promises.lstat(target);\n  } catch {\n    return undefined;\n  }\n}\n"],"mappings":"uLAeA,eAAsB,EAA6B,EAAgB,EAAoC,CACrG,IAAM,EAAc,MAAMA,EAAAA,QAAG,SAAS,MAAM,CAAM,EAElD,GAAI,EAAY,eAAe,EAAG,CAChC,MAAM,EAAoB,CAAW,EACrC,MAAMA,EAAAA,QAAG,SAAS,QAAQ,MAAMA,EAAAA,QAAG,SAAS,SAAS,CAAM,EAAG,CAAW,EACzE,MACF,CAEA,GAAI,EAAY,YAAY,EAAG,EAIxB,MADkC,EAAiB,CAAW,EAAA,EACpC,YAAY,GAAG,MAAM,EAAoB,CAAW,EACnF,MAAMA,EAAAA,QAAG,SAAS,MAAM,EAAa,CAAE,UAAW,EAAK,CAAC,EACxD,IAAK,IAAM,KAAS,MAAMA,EAAAA,QAAG,SAAS,QAAQ,CAAM,EAClD,MAAM,EAA6BC,EAAAA,QAAK,KAAK,EAAQ,CAAK,EAAGA,EAAAA,QAAK,KAAK,EAAa,CAAK,CAAC,EAE5F,MACF,CAEA,MAAM,EAAoB,CAAW,EACrC,MAAMD,EAAAA,QAAG,SAAS,SAAS,EAAQ,CAAW,CAChD,CAUA,eAAsB,EAAwC,EAAc,EAAkC,CAC5G,IAAM,EAAeC,EAAAA,QAAK,SAAS,EAAM,CAAS,EAClD,GAAI,CAAC,EAAgB,EAAM,CAAS,EAClC,MAAU,MAAM,kCAAkC,EAAU,iBAAiB,GAAM,EAGrF,IAAI,EAAc,EAClB,IAAK,IAAM,KAAS,EAAa,MAAMA,EAAAA,QAAK,GAAG,CAAC,CAAC,OAAO,OAAO,EAAG,CAChE,EAAcA,EAAAA,QAAK,KAAK,EAAa,CAAK,EAC1C,IAAM,EAAQ,MAAM,EAAiB,CAAW,EAC5C,GAAO,YAAY,IACnB,GAAO,MAAM,EAAoB,CAAW,EAChD,MAAMD,EAAAA,QAAG,SAAS,MAAM,CAAW,EACrC,CACF,CAIA,eAAe,EAAoB,EAA+B,CAChE,MAAME,EAAAA,YAAY,CAAM,CAC1B,CAQA,eAAsB,EAAkC,EAAkB,EAA0C,CAClH,MAAM,EAAoB,CAAQ,EAElC,MAAMF,EAAAA,QAAG,SAAS,UAAU,EAAU,EAAM,CAAE,KAAM,IAAK,CAAC,CAC5D,CAGA,SAAgB,EAAgB,EAA2B,EAAiC,CAC1F,IAAM,EAAeC,EAAAA,QAAK,SAAS,EAAmB,CAAc,EAEpE,OAAO,IAAiB,MAAQ,CAAC,EAAa,WAAW,KAAKA,EAAAA,QAAK,KAAK,GAAK,CAACA,EAAAA,QAAK,WAAW,CAAY,CAC5G,CAEA,eAAe,EAAiB,EAA+C,CAC7E,GAAI,CACF,OAAO,MAAMD,EAAAA,QAAG,SAAS,MAAM,CAAM,CACvC,MAAQ,CACN,MACF,CACF"}