{"version":3,"file":"secure-file.d.ts","sourceRoot":"","sources":["../src/secure-file.ts"],"names":[],"mappings":"AAAA,OAAO,EAAa,KAAK,MAAM,EAAc,MAAM,SAAS,CAAC;AA0J7D,6FAA6F;AAC7F,wBAAsB,uBAAuB,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAoB1F;AA6FD;;;;GAIG;AACH,wBAAsB,8BAA8B,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,WAAW,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAOrH;AAED,gGAAgG;AAChG,wBAAsB,4BAA4B,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAIjG;AAED,sFAAsF;AACtF,wBAAsB,uBAAuB,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,YAAY,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAmBjH;AAED,+FAA+F;AAC/F,wBAAsB,uBAAuB,CAAC,MAAM,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAmB/G;AAED,yGAAyG;AACzG,wBAAsB,8BAA8B,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CA4BhH;AAED,qGAAqG;AACrG,wBAAsB,gCAAgC,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAkBpG;AAED,4FAA4F;AAC5F,wBAAsB,yBAAyB,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CA6B3G","sourcesContent":["import { constants, type Dirent, type Stats } from \"node:fs\";\nimport { lstat, mkdir, open, readdir, realpath } from \"node:fs/promises\";\nimport { basename, dirname, isAbsolute, join, relative, resolve, sep } from \"node:path\";\nimport { atomicWriteFile, durableUnlink } from \"./storage.ts\";\n\nconst COPY_BUFFER_BYTES = 64 * 1024;\n\nfunction errorCode(error: unknown): string | undefined {\n\treturn typeof error === \"object\" && error !== null && \"code\" in error && typeof error.code === \"string\"\n\t\t? error.code\n\t\t: undefined;\n}\n\nfunction sameIdentity(left: Stats, right: Stats): boolean {\n\treturn left.dev === right.dev && left.ino === right.ino;\n}\n\nfunction assertUnchanged(before: Stats, after: Stats, label: string): void {\n\tif (\n\t\t!after.isFile() ||\n\t\t!sameIdentity(before, after) ||\n\t\tbefore.size !== after.size ||\n\t\tbefore.mtimeMs !== after.mtimeMs ||\n\t\tbefore.ctimeMs !== after.ctimeMs\n\t) {\n\t\tthrow new Error(`${label} changed while it was read`);\n\t}\n}\n\nfunction noFollowFlags(label: string): number {\n\tif (!Number.isInteger(constants.O_NOFOLLOW) || constants.O_NOFOLLOW === 0) {\n\t\tthrow new Error(`${label} cannot be opened because atomic no-follow reads are unsupported`);\n\t}\n\treturn constants.O_RDONLY | constants.O_NOFOLLOW;\n}\n\nfunction noFollowDirectoryFlags(label: string): number {\n\tif (\n\t\t!Number.isInteger(constants.O_NOFOLLOW) ||\n\t\tconstants.O_NOFOLLOW === 0 ||\n\t\t!Number.isInteger(constants.O_DIRECTORY) ||\n\t\tconstants.O_DIRECTORY === 0\n\t) {\n\t\tthrow new Error(`${label} cannot be opened because descriptor-relative directory access is unsupported`);\n\t}\n\treturn constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW;\n}\n\nfunction descriptorDirectoryPath(handle: Awaited<ReturnType<typeof open>>, label: string): string {\n\tif (process.platform !== \"linux\") {\n\t\tthrow new Error(`${label} cannot be accessed safely because descriptor-relative paths are unsupported`);\n\t}\n\treturn `/proc/self/fd/${handle.fd}`;\n}\n\nasync function withRegularDirectoryNoFollow<T>(\n\tpath: string,\n\tlabel: string,\n\toperation: (descriptorPath: string, status: Stats) => Promise<T>,\n): Promise<T> {\n\tconst absolutePath = resolve(path);\n\tconst pathStatus = await lstat(absolutePath);\n\tif (!pathStatus.isDirectory() || pathStatus.isSymbolicLink()) {\n\t\tthrow new Error(`${label} must be a regular directory without symbolic links`);\n\t}\n\tif ((await realpath(absolutePath)) !== absolutePath) throw new Error(`${label} traverses a symbolic link`);\n\tlet handle: Awaited<ReturnType<typeof open>>;\n\ttry {\n\t\thandle = await open(absolutePath, noFollowDirectoryFlags(label));\n\t} catch (error) {\n\t\tif ([\"EINVAL\", \"ENOSYS\", \"ENOTSUP\", \"EOPNOTSUPP\"].includes(errorCode(error) ?? \"\")) {\n\t\t\tthrow new Error(`${label} cannot be opened with descriptor-relative no-follow protection`, { cause: error });\n\t\t}\n\t\tif (errorCode(error) === \"ELOOP\") {\n\t\t\tthrow new Error(`${label} must be a regular directory without symbolic links`, { cause: error });\n\t\t}\n\t\tthrow error;\n\t}\n\ttry {\n\t\tconst before = await handle.stat();\n\t\tif (!before.isDirectory() || !sameIdentity(pathStatus, before)) {\n\t\t\tthrow new Error(`${label} changed during validation`);\n\t\t}\n\t\tconst descriptorPath = descriptorDirectoryPath(handle, label);\n\t\tif ((await realpath(descriptorPath)) !== absolutePath)\n\t\t\tthrow new Error(`${label} descriptor changed during validation`);\n\t\tconst result = await operation(descriptorPath, before);\n\t\tawait handle.sync();\n\t\tconst after = await handle.stat();\n\t\tconst finalPathStatus = await lstat(absolutePath);\n\t\tif (\n\t\t\t!after.isDirectory() ||\n\t\t\t!finalPathStatus.isDirectory() ||\n\t\t\tfinalPathStatus.isSymbolicLink() ||\n\t\t\t!sameIdentity(before, after) ||\n\t\t\t!sameIdentity(before, finalPathStatus) ||\n\t\t\t(await realpath(absolutePath)) !== absolutePath\n\t\t) {\n\t\t\tthrow new Error(`${label} changed while it was accessed`);\n\t\t}\n\t\treturn result;\n\t} finally {\n\t\tawait handle.close();\n\t}\n}\n\nasync function withRegularFileNoFollow<T>(\n\tpath: string,\n\tlabel: string,\n\toperation: (handle: Awaited<ReturnType<typeof open>>, status: Stats) => Promise<T>,\n): Promise<T> {\n\tconst absolutePath = resolve(path);\n\tconst pathStatus = await lstat(absolutePath);\n\tif (!pathStatus.isFile() || pathStatus.isSymbolicLink()) {\n\t\tthrow new Error(`${label} must be a regular file without symbolic links`);\n\t}\n\tif ((await realpath(absolutePath)) !== absolutePath) {\n\t\tthrow new Error(`${label} traverses a symbolic link`);\n\t}\n\n\tlet handle: Awaited<ReturnType<typeof open>>;\n\ttry {\n\t\thandle = await open(absolutePath, noFollowFlags(label));\n\t} catch (error) {\n\t\tif ([\"EINVAL\", \"ENOSYS\", \"ENOTSUP\", \"EOPNOTSUPP\"].includes(errorCode(error) ?? \"\")) {\n\t\t\tthrow new Error(`${label} cannot be opened with atomic no-follow protection`, { cause: error });\n\t\t}\n\t\tif (errorCode(error) === \"ELOOP\") {\n\t\t\tthrow new Error(`${label} must be a regular file without symbolic links`, { cause: error });\n\t\t}\n\t\tthrow error;\n\t}\n\ttry {\n\t\tconst before = await handle.stat();\n\t\tif (!before.isFile() || !sameIdentity(pathStatus, before)) {\n\t\t\tthrow new Error(`${label} changed during validation`);\n\t\t}\n\t\tconst result = await operation(handle, before);\n\t\tassertUnchanged(before, await handle.stat(), label);\n\t\tconst finalPathStatus = await lstat(absolutePath);\n\t\tif (\n\t\t\t!finalPathStatus.isFile() ||\n\t\t\tfinalPathStatus.isSymbolicLink() ||\n\t\t\t!sameIdentity(before, finalPathStatus) ||\n\t\t\t(await realpath(absolutePath)) !== absolutePath\n\t\t) {\n\t\t\tthrow new Error(`${label} changed while it was read`);\n\t\t}\n\t\treturn result;\n\t} finally {\n\t\tawait handle.close();\n\t}\n}\n\n/** Resolve one directory once while rejecting a final-component symlink or identity race. */\nexport async function resolveRegularDirectory(path: string, label: string): Promise<string> {\n\tconst absolutePath = resolve(path);\n\tconst initial = await lstat(absolutePath);\n\tif (!initial.isDirectory() || initial.isSymbolicLink()) {\n\t\tthrow new Error(`${label} must be a regular directory`);\n\t}\n\tconst canonicalPath = await realpath(absolutePath);\n\tconst confirmed = await lstat(absolutePath);\n\tconst canonical = await lstat(canonicalPath);\n\tif (\n\t\t!confirmed.isDirectory() ||\n\t\tconfirmed.isSymbolicLink() ||\n\t\t!canonical.isDirectory() ||\n\t\tcanonical.isSymbolicLink() ||\n\t\t!sameIdentity(initial, confirmed) ||\n\t\t!sameIdentity(confirmed, canonical)\n\t) {\n\t\tthrow new Error(`${label} changed during validation`);\n\t}\n\treturn canonicalPath;\n}\n\nasync function nearestExistingDirectory(path: string, label: string): Promise<string> {\n\tlet candidate = dirname(resolve(path));\n\twhile (true) {\n\t\ttry {\n\t\t\tconst status = await lstat(candidate);\n\t\t\tif (!status.isDirectory() || status.isSymbolicLink()) {\n\t\t\t\tthrow new Error(`${label} ancestor must be a regular directory without symbolic links`);\n\t\t\t}\n\t\t\treturn candidate;\n\t\t} catch (error) {\n\t\t\tif (errorCode(error) !== \"ENOENT\") throw error;\n\t\t}\n\t\tconst parent = dirname(candidate);\n\t\tif (parent === candidate) throw new Error(`${label} has no existing directory anchor`);\n\t\tcandidate = parent;\n\t}\n}\n\nasync function ensureDirectoryDescendantsNoFollow(\n\tanchorPath: string,\n\ttargetPath: string,\n\tlabel: string,\n): Promise<void> {\n\tconst anchor = resolve(anchorPath);\n\tconst target = resolve(targetPath);\n\tconst relativeTarget = relative(anchor, target);\n\tif (relativeTarget === \"..\" || relativeTarget.startsWith(`..${sep}`) || isAbsolute(relativeTarget)) {\n\t\tthrow new Error(`${label} must remain below its trusted directory anchor`);\n\t}\n\tconst segments = relativeTarget ? relativeTarget.split(sep) : [];\n\tawait withRegularDirectoryNoFollow(anchor, `${label} anchor`, async (anchorDescriptorPath) => {\n\t\tconst handles: Array<Awaited<ReturnType<typeof open>>> = [];\n\t\tlet descriptorPath = anchorDescriptorPath;\n\t\tlet absolutePath = anchor;\n\t\ttry {\n\t\t\tfor (const segment of segments) {\n\t\t\t\tconst descriptorChild = join(descriptorPath, segment);\n\t\t\t\tconst absoluteChild = join(absolutePath, segment);\n\t\t\t\ttry {\n\t\t\t\t\tawait mkdir(descriptorChild, { mode: 0o700 });\n\t\t\t\t} catch (error) {\n\t\t\t\t\tif (errorCode(error) !== \"EEXIST\") throw error;\n\t\t\t\t}\n\t\t\t\tawait handles.at(-1)?.sync();\n\n\t\t\t\tlet handle: Awaited<ReturnType<typeof open>>;\n\t\t\t\ttry {\n\t\t\t\t\thandle = await open(descriptorChild, noFollowDirectoryFlags(label));\n\t\t\t\t} catch (error) {\n\t\t\t\t\tif (errorCode(error) === \"ELOOP\" || errorCode(error) === \"ENOTDIR\") {\n\t\t\t\t\t\tthrow new Error(`${label} must be a regular directory tree without symbolic links`, {\n\t\t\t\t\t\t\tcause: error,\n\t\t\t\t\t\t});\n\t\t\t\t\t}\n\t\t\t\t\tthrow error;\n\t\t\t\t}\n\t\t\t\thandles.push(handle);\n\t\t\t\tconst descriptorStatus = await handle.stat();\n\t\t\t\tconst pathStatus = await lstat(absoluteChild);\n\t\t\t\tif (\n\t\t\t\t\t!descriptorStatus.isDirectory() ||\n\t\t\t\t\t!pathStatus.isDirectory() ||\n\t\t\t\t\tpathStatus.isSymbolicLink() ||\n\t\t\t\t\t!sameIdentity(descriptorStatus, pathStatus) ||\n\t\t\t\t\t(await realpath(absoluteChild)) !== absoluteChild\n\t\t\t\t) {\n\t\t\t\t\tthrow new Error(`${label} changed during no-follow directory creation`);\n\t\t\t\t}\n\t\t\t\tawait handle.chmod(0o700);\n\t\t\t\tawait handle.sync();\n\t\t\t\tconst finalDescriptorStatus = await handle.stat();\n\t\t\t\tconst finalPathStatus = await lstat(absoluteChild);\n\t\t\t\tif (\n\t\t\t\t\t!finalDescriptorStatus.isDirectory() ||\n\t\t\t\t\t!finalPathStatus.isDirectory() ||\n\t\t\t\t\tfinalPathStatus.isSymbolicLink() ||\n\t\t\t\t\t!sameIdentity(descriptorStatus, finalDescriptorStatus) ||\n\t\t\t\t\t!sameIdentity(descriptorStatus, finalPathStatus) ||\n\t\t\t\t\t(await realpath(absoluteChild)) !== absoluteChild\n\t\t\t\t) {\n\t\t\t\t\tthrow new Error(`${label} changed while directory permissions were fixed`);\n\t\t\t\t}\n\t\t\t\tdescriptorPath = descriptorDirectoryPath(handle, label);\n\t\t\t\tabsolutePath = absoluteChild;\n\t\t\t}\n\t\t} finally {\n\t\t\tfor (const handle of handles.reverse()) await handle.close();\n\t\t}\n\t});\n}\n\n/**\n * Create and permission a private directory tree below an opened, verified anchor.\n * Without an explicit anchor, the target is secured below its nearest existing,\n * verified ancestor.\n */\nexport async function ensurePrivateDirectoryNoFollow(path: string, label: string, trustedRoot?: string): Promise<void> {\n\tconst target = resolve(path);\n\tif (trustedRoot !== undefined) {\n\t\tawait ensureDirectoryDescendantsNoFollow(trustedRoot, target, label);\n\t\treturn;\n\t}\n\tawait ensureDirectoryDescendantsNoFollow(await nearestExistingDirectory(target, label), target, label);\n}\n\n/** Read a canonical directory without following a symlink introduced below its trusted root. */\nexport async function readRegularDirectoryNoFollow(path: string, label: string): Promise<Dirent[]> {\n\treturn withRegularDirectoryNoFollow(path, label, (descriptorPath) =>\n\t\treaddir(descriptorPath, { withFileTypes: true }),\n\t);\n}\n\n/** Atomically open and read one unchanged regular file without following symlinks. */\nexport async function readRegularFileNoFollow(path: string, label: string, maximumBytes?: number): Promise<Buffer> {\n\treturn withRegularFileNoFollow(path, label, async (handle, status) => {\n\t\tif (maximumBytes === undefined) return handle.readFile();\n\t\tif (!Number.isSafeInteger(maximumBytes) || maximumBytes < 0) {\n\t\t\tthrow new Error(`${label} byte limit must be a non-negative safe integer`);\n\t\t}\n\t\tif (status.size > maximumBytes) throw new Error(`${label} exceeds ${maximumBytes} bytes`);\n\t\tconst chunks: Buffer[] = [];\n\t\tlet totalBytes = 0;\n\t\tconst buffer = Buffer.allocUnsafe(Math.min(COPY_BUFFER_BYTES, maximumBytes + 1));\n\t\twhile (true) {\n\t\t\tconst { bytesRead } = await handle.read(buffer, 0, buffer.byteLength, null);\n\t\t\tif (bytesRead === 0) break;\n\t\t\ttotalBytes += bytesRead;\n\t\t\tif (totalBytes > maximumBytes) throw new Error(`${label} exceeds ${maximumBytes} bytes`);\n\t\t\tchunks.push(Buffer.from(buffer.subarray(0, bytesRead)));\n\t\t}\n\t\treturn Buffer.concat(chunks, totalBytes);\n\t});\n}\n\n/** Copy one unchanged regular source through no-follow file handles into a new destination. */\nexport async function copyRegularFileNoFollow(source: string, destination: string, label: string): Promise<void> {\n\tawait withRegularFileNoFollow(source, label, async (sourceHandle) => {\n\t\tconst destinationHandle = await open(destination, \"wx\", 0o600);\n\t\ttry {\n\t\t\tconst buffer = Buffer.allocUnsafe(COPY_BUFFER_BYTES);\n\t\t\twhile (true) {\n\t\t\t\tconst { bytesRead } = await sourceHandle.read(buffer, 0, buffer.byteLength, null);\n\t\t\t\tif (bytesRead === 0) break;\n\t\t\t\tlet offset = 0;\n\t\t\t\twhile (offset < bytesRead) {\n\t\t\t\t\tconst { bytesWritten } = await destinationHandle.write(buffer, offset, bytesRead - offset, null);\n\t\t\t\t\tif (bytesWritten === 0) throw new Error(`Could not copy ${label}`);\n\t\t\t\t\toffset += bytesWritten;\n\t\t\t\t}\n\t\t\t}\n\t\t} finally {\n\t\t\tawait destinationHandle.close();\n\t\t}\n\t});\n}\n\n/** Atomically replace one regular file through an opened parent directory without following symlinks. */\nexport async function atomicWriteRegularFileNoFollow(path: string, content: string, label: string): Promise<void> {\n\tconst absolutePath = resolve(path);\n\tconst fileName = basename(absolutePath);\n\tif (!fileName || fileName === \".\" || fileName === \"..\") throw new Error(`${label} must name a file`);\n\tawait withRegularDirectoryNoFollow(dirname(absolutePath), `${label} parent`, async (descriptorPath) => {\n\t\tconst descriptorTarget = join(descriptorPath, fileName);\n\t\ttry {\n\t\t\tconst existing = await lstat(descriptorTarget);\n\t\t\tif (!existing.isFile() || existing.isSymbolicLink()) {\n\t\t\t\tthrow new Error(`${label} must be a regular file without symbolic links`);\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tif (errorCode(error) !== \"ENOENT\") throw error;\n\t\t}\n\t\tawait atomicWriteFile(descriptorTarget, content);\n\t\tconst descriptorStatus = await lstat(descriptorTarget);\n\t\tconst pathStatus = await lstat(absolutePath);\n\t\tif (\n\t\t\t!descriptorStatus.isFile() ||\n\t\t\tdescriptorStatus.isSymbolicLink() ||\n\t\t\t!pathStatus.isFile() ||\n\t\t\tpathStatus.isSymbolicLink() ||\n\t\t\t!sameIdentity(descriptorStatus, pathStatus) ||\n\t\t\t(await realpath(absolutePath)) !== absolutePath\n\t\t) {\n\t\t\tthrow new Error(`${label} changed while it was written`);\n\t\t}\n\t});\n}\n\n/** Durably remove one regular file through an opened parent directory without following symlinks. */\nexport async function durableUnlinkRegularFileNoFollow(path: string, label: string): Promise<boolean> {\n\tconst absolutePath = resolve(path);\n\tconst fileName = basename(absolutePath);\n\tif (!fileName || fileName === \".\" || fileName === \"..\") throw new Error(`${label} must name a file`);\n\treturn withRegularDirectoryNoFollow(dirname(absolutePath), `${label} parent`, async (descriptorPath) => {\n\t\tconst descriptorTarget = join(descriptorPath, fileName);\n\t\tlet existing: Stats;\n\t\ttry {\n\t\t\texisting = await lstat(descriptorTarget);\n\t\t} catch (error) {\n\t\t\tif (errorCode(error) === \"ENOENT\") return false;\n\t\t\tthrow error;\n\t\t}\n\t\tif (!existing.isFile() || existing.isSymbolicLink()) {\n\t\t\tthrow new Error(`${label} must be a regular file without symbolic links`);\n\t\t}\n\t\treturn durableUnlink(descriptorTarget);\n\t});\n}\n\n/** Durably append text through an opened parent directory and an O_NOFOLLOW file handle. */\nexport async function appendRegularFileNoFollow(path: string, content: string, label: string): Promise<void> {\n\tconst absolutePath = resolve(path);\n\tconst fileName = basename(absolutePath);\n\tif (!fileName || fileName === \".\" || fileName === \"..\") throw new Error(`${label} must name a file`);\n\tawait withRegularDirectoryNoFollow(dirname(absolutePath), `${label} parent`, async (descriptorPath) => {\n\t\tconst descriptorTarget = join(descriptorPath, fileName);\n\t\tconst flags = constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | noFollowFlags(label);\n\t\tlet handle: Awaited<ReturnType<typeof open>>;\n\t\ttry {\n\t\t\thandle = await open(descriptorTarget, flags, 0o600);\n\t\t} catch (error) {\n\t\t\tif (errorCode(error) === \"ELOOP\") {\n\t\t\t\tthrow new Error(`${label} must be a regular file without symbolic links`, { cause: error });\n\t\t\t}\n\t\t\tthrow error;\n\t\t}\n\t\ttry {\n\t\t\tconst status = await handle.stat();\n\t\t\tif (!status.isFile()) throw new Error(`${label} must be a regular file`);\n\t\t\tawait handle.writeFile(content, \"utf8\");\n\t\t\tawait handle.sync();\n\t\t\tconst finalStatus = await lstat(descriptorTarget);\n\t\t\tif (!finalStatus.isFile() || finalStatus.isSymbolicLink() || !sameIdentity(status, finalStatus)) {\n\t\t\t\tthrow new Error(`${label} changed while it was appended`);\n\t\t\t}\n\t\t} finally {\n\t\t\tawait handle.close();\n\t\t}\n\t});\n}\n"]}