{"version":3,"file":"files.d.ts","sourceRoot":"","sources":["../../src/server/files.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAMH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAC5C,OAAO,KAAK,EAAE,aAAa,EAAgB,MAAM,uBAAuB,CAAC;AAEzE,MAAM,MAAM,YAAY,GAAG,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,KAAK,IAAI,CAAC;AAQtF;;;;;GAKG;AACH,wBAAsB,gBAAgB,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE;IAAE,SAAS,EAAE,OAAO,CAAA;CAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CAwCjG;AAED,qFAAqF;AACrF,wBAAsB,wBAAwB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAoB/E;AAED,qBAAa,OAAO;IACP,OAAO,CAAC,QAAQ,CAAC,GAAG;IAAhC,YAA6B,GAAG,EAAE,YAAY,EAAI;IAElD,kFAAkF;IAC5E,gBAAgB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAEvD;IAEK,IAAI,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,aAAa,EAAE,cAAc,CAAC,CAAC,CA2BxE;IAED,8EAA8E;IACxE,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC,CAQ9E;IAED;;;OAGG;IACG,aAAa,CAClB,MAAM,EAAE,MAAM,EACd,QAAQ,EAAE,MAAM,EAChB,SAAS,EAAE,OAAO,GAChB,OAAO,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,QAAQ,CAAC;QAAC,MAAM,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;QAAC,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;KAAE,CAAC,CAkDxG;IAEK,KAAK,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAOzD;CACD;AAED,kDAAkD;AAClD,wBAAgB,aAAa,CAAC,OAAO,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,EAAE,GAAG,KAAK,CAAC;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC,CAS7G","sourcesContent":["/**\n * Host-wide file API — browse, download, upload, mkdir.\n *\n * The dashboard is a trusted-operator surface: a paired device\n * already equals terminal access, so there is no project jail. Path handling\n * still canonicalizes and rejects confusion tricks so the *API* cannot be\n * abused: percent-decode checks, null-byte rejection, symlink resolution to a\n * real absolute path, and server-side logging of every operation.\n */\n\nimport { randomBytes } from \"node:crypto\";\nimport { constants } from \"node:fs\";\nimport { access, link, mkdir, open, readdir, realpath, rename, stat, unlink } from \"node:fs/promises\";\nimport { dirname, isAbsolute, join, normalize, resolve, sep } from \"node:path\";\nimport type { Writable } from \"node:stream\";\nimport type { DirListingDto, FileEntryDto } from \"../shared/protocol.js\";\n\nexport type FileOpLogger = (operation: string, path: string, detail?: string) => void;\n\nfunction assertValidChildName(name: string, label: string): void {\n\tif (!name || name.includes(\"/\") || name.includes(\"\\\\\") || name.includes(\"\\0\") || name === \".\" || name === \"..\") {\n\t\tthrow Object.assign(new Error(`Invalid ${label}: ${name}`), { status: 400 });\n\t}\n}\n\n/**\n * Canonicalize a client-supplied path. Throws (status 400) on anything\n * suspicious rather than guessing. Returns the resolved absolute path with\n * symlinks in the *parent* chain resolved (the leaf may not exist yet for\n * writes, so its parent is what gets realpath'd).\n */\nexport async function canonicalizePath(raw: string, opts: { mustExist: boolean }): Promise<string> {\n\tif (typeof raw !== \"string\" || raw.length === 0) {\n\t\tthrow Object.assign(new Error(\"Path is required\"), { status: 400 });\n\t}\n\tif (raw.includes(\"\\0\")) {\n\t\tthrow Object.assign(new Error(\"Path contains a null byte\"), { status: 400 });\n\t}\n\t// Reject lingering percent-encodings that survived normal URL decoding —\n\t// double-encoding is a classic canonicalization-confusion vector.\n\tif (/%2e|%2f|%5c|%00/i.test(raw)) {\n\t\tthrow Object.assign(new Error(\"Path contains percent-encoded traversal sequences\"), { status: 400 });\n\t}\n\tif (!isAbsolute(raw)) {\n\t\tthrow Object.assign(new Error(\"Path must be absolute\"), { status: 400 });\n\t}\n\tconst normalized = normalize(raw);\n\n\tif (opts.mustExist) {\n\t\ttry {\n\t\t\treturn await realpath(normalized);\n\t\t} catch (err) {\n\t\t\tthrow Object.assign(new Error(`Path does not exist or is unreadable: ${normalized}`), {\n\t\t\t\tstatus: 404,\n\t\t\t\tcause: err,\n\t\t\t});\n\t\t}\n\t}\n\t// For creation targets: resolve the parent, keep the leaf name literal.\n\tconst parent = dirname(normalized);\n\tlet realParent: string;\n\ttry {\n\t\trealParent = await realpath(parent);\n\t} catch (err) {\n\t\tthrow Object.assign(new Error(`Parent directory does not exist: ${parent}`), { status: 404, cause: err });\n\t}\n\tconst leaf = normalized.slice(parent === sep ? 1 : parent.length + 1);\n\tif (!leaf || leaf.includes(sep) || leaf === \".\" || leaf === \"..\") {\n\t\tthrow Object.assign(new Error(`Invalid file name: ${leaf || \"(empty)\"}`), { status: 400 });\n\t}\n\treturn join(realParent, leaf);\n}\n\n/** Resolve and canonicalize an existing directory supplied by a Dashboard client. */\nexport async function resolveExistingDirectory(rawPath: string): Promise<string> {\n\tconst path = await canonicalizePath(rawPath, { mustExist: true });\n\tlet info: Awaited<ReturnType<typeof stat>>;\n\ttry {\n\t\tinfo = await stat(path);\n\t} catch (err) {\n\t\tthrow Object.assign(new Error(`Path does not exist or is unreadable: ${path}`), { status: 404, cause: err });\n\t}\n\tif (!info.isDirectory()) {\n\t\tthrow Object.assign(new Error(`Not a directory: ${path}`), { status: 400 });\n\t}\n\ttry {\n\t\tawait access(path, constants.R_OK | constants.X_OK);\n\t} catch (err) {\n\t\tthrow Object.assign(new Error(`Directory is not readable and searchable: ${path}`), {\n\t\t\tstatus: 403,\n\t\t\tcause: err,\n\t\t});\n\t}\n\treturn path;\n}\n\nexport class FileApi {\n\tconstructor(private readonly log: FileOpLogger) {}\n\n\t/** Resolve an existing directory for listing and context-trust RPC operations. */\n\tasync resolveDirectory(rawPath: string): Promise<string> {\n\t\treturn resolveExistingDirectory(rawPath);\n\t}\n\n\tasync list(rawPath: string): Promise<Omit<DirListingDto, \"contextTrust\">> {\n\t\tconst path = await this.resolveDirectory(rawPath);\n\t\tconst names = await readdir(path, { withFileTypes: true });\n\t\tconst entries: FileEntryDto[] = [];\n\t\tfor (const dirent of names) {\n\t\t\tlet type: FileEntryDto[\"type\"] = \"other\";\n\t\t\tif (dirent.isDirectory()) type = \"dir\";\n\t\t\telse if (dirent.isFile()) type = \"file\";\n\t\t\telse if (dirent.isSymbolicLink()) type = \"symlink\";\n\t\t\tlet size = 0;\n\t\t\tlet modified = \"\";\n\t\t\ttry {\n\t\t\t\tconst s = await stat(join(path, dirent.name));\n\t\t\t\tsize = s.size;\n\t\t\t\tmodified = s.mtime.toISOString();\n\t\t\t\tif (type === \"symlink\") type = s.isDirectory() ? \"dir\" : \"file\";\n\t\t\t} catch {\n\t\t\t\t// Broken symlink or permission issue — keep the entry, mark it \"other\".\n\t\t\t\ttype = \"other\";\n\t\t\t}\n\t\t\tentries.push({ name: dirent.name, type, size, modified });\n\t\t}\n\t\tentries.sort((a, b) =>\n\t\t\t(a.type === \"dir\") === (b.type === \"dir\") ? a.name.localeCompare(b.name) : a.type === \"dir\" ? -1 : 1,\n\t\t);\n\t\tthis.log(\"list\", path);\n\t\treturn { path, entries };\n\t}\n\n\t/** Resolve a download target; returns the canonical path (must be a file). */\n\tasync resolveDownload(rawPath: string): Promise<{ path: string; size: number }> {\n\t\tconst path = await canonicalizePath(rawPath, { mustExist: true });\n\t\tconst info = await stat(path);\n\t\tif (!info.isFile()) {\n\t\t\tthrow Object.assign(new Error(`Not a file: ${path}`), { status: 400 });\n\t\t}\n\t\tthis.log(\"download\", path, `${info.size} bytes`);\n\t\treturn { path, size: info.size };\n\t}\n\n\t/**\n\t * Upload into a directory. Refuses to overwrite unless `overwrite` is set —\n\t * the collision surfaces as 409 so the client can prompt.\n\t */\n\tasync prepareUpload(\n\t\trawDir: string,\n\t\tfileName: string,\n\t\toverwrite: boolean,\n\t): Promise<{ path: string; stream: Writable; commit: () => Promise<void>; cleanup: () => Promise<void> }> {\n\t\tconst dir = await canonicalizePath(rawDir, { mustExist: true });\n\t\tconst dirInfo = await stat(dir);\n\t\tif (!dirInfo.isDirectory()) {\n\t\t\tthrow Object.assign(new Error(`Upload target is not a directory: ${dir}`), { status: 400 });\n\t\t}\n\t\tassertValidChildName(fileName, \"upload file name\");\n\t\tconst path = join(dir, fileName);\n\t\tlet exists = false;\n\t\ttry {\n\t\t\tawait stat(path);\n\t\t\texists = true;\n\t\t} catch {\n\t\t\texists = false;\n\t\t}\n\t\tif (exists && !overwrite) {\n\t\t\tthrow Object.assign(new Error(`File exists: ${path}`), { status: 409 });\n\t\t}\n\n\t\tconst tempPath = join(dir, `.dreb-upload-${process.pid}-${Date.now()}-${randomBytes(6).toString(\"hex\")}.tmp`);\n\t\tconst file = await open(tempPath, \"wx\");\n\t\tconst stream = file.createWriteStream({ autoClose: true });\n\t\tlet committed = false;\n\t\tconst cleanup = async () => {\n\t\t\tif (committed) return;\n\t\t\tawait unlink(tempPath).catch(() => {});\n\t\t};\n\t\tconst commit = async () => {\n\t\t\ttry {\n\t\t\t\tif (overwrite) {\n\t\t\t\t\tawait rename(tempPath, path);\n\t\t\t\t} else {\n\t\t\t\t\t// Atomic no-overwrite publish: hard-link into the final name and fail\n\t\t\t\t\t// with EEXIST if another writer won the race after the early stat().\n\t\t\t\t\tawait link(tempPath, path).catch((err: NodeJS.ErrnoException) => {\n\t\t\t\t\t\tif (err.code === \"EEXIST\") {\n\t\t\t\t\t\t\tthrow Object.assign(new Error(`File exists: ${path}`), { status: 409, cause: err });\n\t\t\t\t\t\t}\n\t\t\t\t\t\tthrow err;\n\t\t\t\t\t});\n\t\t\t\t\tawait unlink(tempPath);\n\t\t\t\t}\n\t\t\t\tcommitted = true;\n\t\t\t\tthis.log(\"upload\", path, exists ? \"overwrite\" : \"create\");\n\t\t\t} catch (err) {\n\t\t\t\tawait cleanup();\n\t\t\t\tthrow err;\n\t\t\t}\n\t\t};\n\t\treturn { path, stream, commit, cleanup };\n\t}\n\n\tasync mkdir(rawDir: string, name: string): Promise<string> {\n\t\tassertValidChildName(name, \"folder name\");\n\t\tconst parent = await canonicalizePath(rawDir, { mustExist: true });\n\t\tconst path = join(parent, name);\n\t\tawait mkdir(path);\n\t\tthis.log(\"mkdir\", path);\n\t\treturn path;\n\t}\n}\n\n/** Places shown as shortcuts in the files tab. */\nexport function defaultPlaces(homeDir: string, projectRoots: string[]): Array<{ label: string; path: string }> {\n\tconst places = [\n\t\t{ label: \"home\", path: homeDir },\n\t\t{ label: \"/tmp\", path: resolve(\"/tmp\") },\n\t];\n\tfor (const root of projectRoots) {\n\t\tplaces.push({ label: root.split(sep).filter(Boolean).pop() ?? root, path: root });\n\t}\n\treturn places;\n}\n"]}