import { safePromise } from "ext:core/promises"; import type { CopyInfo } from "ext:core/ops"; import { ObjectCreate, RangeError, Set, SetPrototypeAdd as add, SetPrototypeDelete as deleteKey, SetPrototypeHas as has, StringPrototypeCharCodeAt as charCodeAt, StringPrototypeSlice as slice, TypedArrayPrototypeGetBuffer as bufferOf, TypedArrayPrototypeGetByteLength as length, TypedArrayPrototypeGetByteOffset as offsetOf, TypedArrayPrototypeGetTag as tag, TypeError, Uint8Array as Uint8ArrayCtor, Uint8ArrayPrototypeSet as setBytes, uncurryThis, } from "ext:core/primordials"; import { op_file_copy_dir_mode as dirMode, op_file_copy_link as copyLink, op_file_ensure_dir as ensureDir, op_file_path_join as join, op_file_path_parent as parent, op_file_prepare_copy as prepare, op_file_read_tree_dir as readTreeDir, op_file_tmp_dir as tmpDir, op_path_platform as platform, } from "ext:core/ops"; import { args, copyFile, FileHandle, integer, mkdir, open, path, remove, rename, stat, writeOptions, } from "./main.js"; import { errorCode, failure, result } from "./errors.js"; import { dictionary, required } from "ext:web/idl.js"; export interface WriteFileOptions { append?: boolean; create?: boolean; createNew?: boolean; mode?: number; } export interface MakeTempOptions { dir?: string | URL; prefix?: string; suffix?: string; } export interface CopyOptions { dereference?: boolean; } const read = uncurryThis(FileHandle.prototype.read), write = uncurryThis(FileHandle.prototype.write), dispose = uncurryThis(FileHandle.prototype[Symbol.dispose]); const Encoder = globalThis.TextEncoder, Decoder = globalThis.TextDecoder; const encode = uncurryThis(Encoder.prototype.encode), decode = uncurryThis(Decoder.prototype.decode); const encoder = new Encoder(), decoder = new Decoder(); const systemCrypto = globalThis.crypto, getRandomValues = uncurryThis(systemCrypto.getRandomValues); const windowsHost = platform() === "win32"; const digits = "0123456789abcdef"; function checkTempComponent(value: string, suffix: boolean): void { for (let i = 0; i < value.length; i++) { const code = charCodeAt(value, i); if (code < 32 || (code >= 127 && code <= 159)) { throw failure( "InvalidInput", "Invalid control character in temp prefix or suffix", ); } if ( code === 34 || code === 42 || code === 47 || code === 58 || code === 60 || code === 62 || code === 63 || code === 92 || code === 124 ) { throw failure( "InvalidInput", "Invalid character in temp prefix or suffix", ); } } // Windows strips trailing dots and spaces, so the created name would not // match the returned one. if (suffix && windowsHost && value.length > 0) { const last = charCodeAt(value, value.length - 1); if (last === 46 || last === 32) { throw failure( "InvalidInput", "Invalid trailing character in temp suffix", ); } } } function randomHex(): string { const bytes = new Uint8ArrayCtor(8); getRandomValues(systemCrypto, bytes); let text = ""; for (let i = 0; i < 8; i++) { text += digits[bytes[i] >> 4] + digits[bytes[i] & 15]; } // Like Rust's `{value:08x}`: at least 8 digits. let start = 0; while (start < 8 && text[start] === "0") start++; return start === 0 ? text : slice(text, start); } function tempOptions(options: unknown): [string, string, string] { const settings = dictionary(options); const dir = settings.dir, prefix = settings.prefix, suffix = settings.suffix; const base = dir === undefined ? result(tmpDir()) : path(dir); if (prefix !== undefined && typeof prefix !== "string") { throw new TypeError("prefix must be string"); } if (suffix !== undefined && typeof suffix !== "string") { throw new TypeError("suffix must be string"); } const head = prefix === undefined ? "" : prefix as string; const tail = suffix === undefined ? "" : suffix as string; checkTempComponent(head, false); checkTempComponent(tail, true); return [base, head, tail]; } export async function readFile( pathname: string | URL, ): Promise { required(arguments.length, 1); const file = await safePromise(open(pathname)), chunks = ObjectCreate(null) as Record>; let count = 0, total = 0; try { for (;;) { const buffer = new Uint8ArrayCtor(65536), size = await safePromise(read(file, buffer)); if (size === null) break; chunks[count++] = new Uint8ArrayCtor(bufferOf(buffer), 0, size); total += size; } } finally { dispose(file); } const output = new Uint8ArrayCtor(total); let offset = 0; for (let i = 0; i < count; i++) { setBytes(output, chunks[i], offset); offset += length(chunks[i]); } return output; } export async function readTextFile(pathname: string | URL): Promise { required(arguments.length, 1); return decode(decoder, await safePromise(readFile(pathname))) as string; } export async function writeFile( pathname: string | URL, data: Uint8Array, options: WriteFileOptions | undefined = undefined, ): Promise { required(arguments.length, 2); const filename = path(pathname); if (tag(data) !== "Uint8Array") { throw new TypeError("writeFile requires a Uint8Array"); } const snapshot = new Uint8ArrayCtor(data as Uint8Array), flags = writeOptions(options); const file = await safePromise(open(filename, flags)); try { let offset = 0; while (offset < length(snapshot)) { const buffer = new Uint8ArrayCtor( bufferOf(snapshot), offsetOf(snapshot) + offset, length(snapshot) - offset, ); const size = await safePromise(write(file, buffer)); if (size <= 0) throw failure("IoError", "File write made no progress"); offset += size; } } finally { dispose(file); } } export async function writeTextFile( pathname: string | URL, text: string, options: WriteFileOptions | undefined = undefined, ): Promise { required(arguments.length, 2); if (typeof text !== "string") { throw new TypeError("writeTextFile requires a string"); } await safePromise(writeFile(pathname, encode(encoder, text), options)); } /** False only for missing paths; permission and other errors propagate. */ export async function exists(pathname: string | URL): Promise { required(arguments.length, 1); try { await safePromise(stat(pathname)); return true; } catch (error) { if (errorCode(error) === "NotFound") return false; throw error; } } export async function mkdirs( pathname: string | URL, options: { mode?: number } | undefined = undefined, ): Promise { required(arguments.length, 1); const filename = path(pathname), mode = dictionary(options).mode; if (mode !== undefined && integer(mode) > 0o7777) { throw new RangeError("Invalid directory mode"); } const nativeMode = mode === undefined ? null : mode as number; async function ensure(filename: string): Promise { try { result(await safePromise(ensureDir(args(filename, nativeMode)))); } catch (error) { if (errorCode(error) !== "NotFound") throw error; await safePromise(ensure(result(parent(filename)))); result(await safePromise(ensureDir(args(filename, nativeMode)))); } } await safePromise(ensure(filename)); } /** Creates a uniquely-named empty directory with mode 0o700; the caller removes it. Collisions retry; missing parents are an error. */ export async function makeTempDir( options: MakeTempOptions | undefined = undefined, ): Promise { const [base, prefix, suffix] = tempOptions(options); for (let attempt = 0; attempt < 10; attempt++) { const candidate = result(join(args(base, prefix + randomHex() + suffix))); try { await safePromise(mkdir(candidate, { mode: 0o700 })); return candidate; } catch (error) { if (errorCode(error) !== "AlreadyExists") throw error; } } throw failure("AlreadyExists", "too many temp dirs exist"); } /** Creates a uniquely-named empty file with mode 0o600; the caller removes it. Collisions retry; missing parents are an error. */ export async function makeTempFile( options: MakeTempOptions | undefined = undefined, ): Promise { const [base, prefix, suffix] = tempOptions(options); for (let attempt = 0; attempt < 10; attempt++) { const candidate = result(join(args(base, prefix + randomHex() + suffix))); try { const file = await safePromise( open(candidate, { write: true, createNew: true, mode: 0o600 }), ); dispose(file); return candidate; } catch (error) { if (errorCode(error) !== "AlreadyExists") throw error; } } throw failure("AlreadyExists", "too many temp files exist"); } /** Preserves symlinks by default and ordinary mode bits, including existing destination directories. Following links rejects ancestor cycles but may duplicate shared subtrees. */ export async function copy( source: string | URL, destination: string | URL, options: CopyOptions | undefined = undefined, ): Promise { required(arguments.length, 2); const from = path(source), to = path(destination), dereference = dictionary(options).dereference; if (dereference !== undefined && typeof dereference !== "boolean") { throw new TypeError("dereference must be boolean"); } const ancestors = new Set(); async function visit(source: string, destination: string): Promise { const info = result( await safePromise( prepare(args(source, destination, false, dereference === true)), ), ); const actualSource = info[3]; if (info[0] === 1) { if (has(ancestors, info[2])) { throw failure( "InvalidInput", "Directory cycle while copying symbolic links", ); } add(ancestors, info[2]); try { await safePromise(mkdirs(destination, { mode: 0o700 })); result(await safePromise(dirMode(args(destination, 0o700)))); const entries = result( await safePromise(readTreeDir(actualSource)), ); for (let i = 0; i < entries.length; i++) { await safePromise(visit( result(join(args(actualSource, entries[i][0]))), result(join(args(destination, entries[i][0]))), )); } result(await safePromise(dirMode(args(destination, info[1])))); } finally { deleteKey(ancestors, info[2]); } } else { async function transfer(): Promise { if (info[0] === 2) { result(await safePromise(copyLink(args(actualSource, destination)))); } else await safePromise(copyFile(actualSource, destination)); } try { await safePromise(transfer()); } catch (error) { if (errorCode(error) !== "NotFound") throw error; await safePromise(mkdirs(result(parent(destination)))); await safePromise(transfer()); } } } await safePromise(visit(from, to)); } /** Rename first; cross-device fallback copies then removes and is not atomic. */ export async function move( source: string | URL, destination: string | URL, ): Promise { required(arguments.length, 2); const from = path(source), to = path(destination); try { await safePromise(rename(from, to)); return; } catch (error) { if (errorCode(error) !== "CrossDevice") throw error; } const directory = result( await safePromise(prepare(args(from, to, true, false))), )[0] === 1; await safePromise(copy(from, to)); result(await safePromise(prepare(args(from, to, true, false)))); await safePromise(remove(from, { recursive: directory })); }