( formData: FormData, targetDir: string, projectDir: string, )
| 1494 | // ── Upload file processing ────────────────────────────────────────────────── |
| 1495 | |
| 1496 | async function processUploadedFiles( |
| 1497 | formData: FormData, |
| 1498 | targetDir: string, |
| 1499 | projectDir: string, |
| 1500 | ): Promise<{ |
| 1501 | uploaded: string[]; |
| 1502 | skipped: string[]; |
| 1503 | invalid: Array<{ name: string; reason: string }>; |
| 1504 | }> { |
| 1505 | const MAX_UPLOAD_BYTES = 500 * 1024 * 1024; // 500 MB per file |
| 1506 | const uploaded: string[] = []; |
| 1507 | const skipped: string[] = []; |
| 1508 | const invalid: Array<{ name: string; reason: string }> = []; |
| 1509 | |
| 1510 | // @types/node v25 narrows the ambient `FormData.entries()` to |
| 1511 | // `[string, string]` in workspaces where another dep declares an |
| 1512 | // `onmessage` global (it trips the worker branch of v25's conditional |
| 1513 | // File type). At runtime the value is still `File | string` — cast the |
| 1514 | // iterator so the rest of this block keeps type-checking on every |
| 1515 | // bun-install layout (hoisted on Windows surfaces this; isolated on |
| 1516 | // Linux happens to keep v24 in scope). |
| 1517 | type FileLike = { |
| 1518 | readonly name: string; |
| 1519 | readonly size: number; |
| 1520 | arrayBuffer(): Promise<ArrayBuffer>; |
| 1521 | }; |
| 1522 | const entries = formData.entries() as unknown as Iterable<[string, FileLike | string]>; |
| 1523 | |
| 1524 | // Derive the subdirectory prefix from targetDir relative to projectDir |
| 1525 | const subDir = targetDir === projectDir ? "" : targetDir.slice(projectDir.length + 1); |
| 1526 | |
| 1527 | for (const [, value] of entries) { |
| 1528 | if (typeof value === "string") continue; |
| 1529 | |
| 1530 | // Strip path separators — browsers may include directory components |
| 1531 | const name = value.name.split("/").pop()?.split("\\").pop() ?? ""; |
| 1532 | if (!name || name.includes("\0") || name.includes("..")) continue; |
| 1533 | |
| 1534 | // Reject individual files that exceed the size limit |
| 1535 | if (value.size > MAX_UPLOAD_BYTES) { |
| 1536 | skipped.push(name); |
| 1537 | continue; |
| 1538 | } |
| 1539 | |
| 1540 | const destPath = resolve(targetDir, name); |
| 1541 | if (!isSafePath(projectDir, destPath)) continue; |
| 1542 | |
| 1543 | // Don't overwrite — append (2), (3), etc. |
| 1544 | let finalPath = destPath; |
| 1545 | let finalName = name; |
| 1546 | if (existsSync(finalPath)) { |
| 1547 | // Handle dotfiles correctly: .gitignore → ext="", base=".gitignore" |
| 1548 | const dotIdx = name.indexOf(".", name.startsWith(".") ? 1 : 0); |
| 1549 | const ext = dotIdx > 0 ? name.slice(dotIdx) : ""; |
| 1550 | const base = dotIdx > 0 ? name.slice(0, dotIdx) : name; |
| 1551 | let n = 2; |
| 1552 | const MAX_COPY_INDEX = 10000; |
| 1553 | while (n < MAX_COPY_INDEX && existsSync(resolve(targetDir, `${base} (${n})${ext}`))) n++; |
no test coverage detected