From 3486a731e7438695312688f7fc3a5dbfa32cd85a Mon Sep 17 00:00:00 2001 From: signal-forge-lab <61216086+signal-forge-lab@users.noreply.github.com> Date: Sat, 12 Sep 2026 02:48:05 +0900 Subject: [PATCH] feat: support native artifact downloads on Windows --- docs/artifact-exchange.md | 15 ++- docs/configuration.md | 4 +- docs/security.md | 9 +- package.json | 1 + pnpm-lock.yaml | 171 +++++++++++++++++++++++++++ pnpm-workspace.yaml | 1 + src/artifact-destination-windows.ts | 175 ++++++++++++++++++++++++++++ src/artifact-download.test.ts | 66 +++++++++-- src/artifact-path-windows.ts | 41 +++++++ src/artifact-tools.ts | 50 ++++++-- 10 files changed, 505 insertions(+), 28 deletions(-) create mode 100644 src/artifact-destination-windows.ts create mode 100644 src/artifact-path-windows.ts diff --git a/docs/artifact-exchange.md b/docs/artifact-exchange.md index bdbf70873..256d53884 100644 --- a/docs/artifact-exchange.md +++ b/docs/artifact-exchange.md @@ -37,8 +37,13 @@ file-object shape, trusted OpenAI download hosts, and redirects before streaming Malformed references, unknown fields, absolute paths, traversal, and symlinked parents are rejected. -Downloads are streamed under `artifacts.maxFileBytes` and published as -owner-only files without overwriting an existing destination. The tool is -currently available on Linux. It is not registered on macOS, Windows, or BSD -because Node.js does not expose the required descriptor-relative filesystem -operations there. +Downloads are streamed under `artifacts.maxFileBytes` and published without +overwriting an existing destination. The tool is available on Linux and +Windows. Linux uses descriptor-anchored directory operations. Windows pins +each destination directory with native handles that reject reparse points and +prevent rename/replacement while the transfer is in progress. macOS and BSD +remain unsupported. + +On POSIX filesystems the partial is created with mode `0600`. Windows file +permissions follow the destination directory's ACL inheritance rather than +POSIX mode bits. diff --git a/docs/configuration.md b/docs/configuration.md index 98f0d124e..c4b2c14ce 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -192,8 +192,8 @@ them. Set `artifacts.enabled` to `true` when a host needs to save a native attached or generated file into an open workspace. `artifacts.maxFileBytes` limits one -streamed file. The secure publication path is currently available only on -Linux; the tool is not registered on macOS, Windows, or BSD. +streamed file. The secure publication path is available on Linux and Windows; +the tool is not registered on macOS or BSD. ## Environment boundary diff --git a/docs/security.md b/docs/security.md index 13efd7d08..52148f5e6 100644 --- a/docs/security.md +++ b/docs/security.md @@ -106,8 +106,13 @@ credentials, malformed references, and unknown object fields are rejected. Absolute paths, traversal, symlinked parents, and existing destinations also fail closed. Downloads stream under the configured per-file limit and are -published without overwrite as owner-only files. DevSpace does not extract or -execute transferred content. +published without overwrite. On Linux, destination traversal stays anchored to +opened directory descriptors. On Windows, DevSpace holds native directory +handles without delete sharing, rejects reparse points, and keeps those handles +open while Node performs the path-based write and publication operations. On +POSIX systems the partial is created with mode `0600`; Windows permissions +follow inherited ACLs. DevSpace does not extract or execute transferred +content. ## Logs diff --git a/package.json b/package.json index 3357476a0..fa39fb46c 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,7 @@ "drizzle-orm": "^0.45.2", "express": "^5.2.1", "jsonc-parser": "^3.3.1", + "koffi": "^3.1.2", "lucide": "^1.24.0", "react": "^19.2.6", "react-dom": "^19.2.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5fbd601c3..92ca7f368 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -62,6 +62,9 @@ importers: jsonc-parser: specifier: ^3.3.1 version: 3.3.1 + koffi: + specifier: ^3.1.2 + version: 3.2.1 lucide: specifier: ^1.24.0 version: 1.24.0 @@ -506,6 +509,96 @@ packages: peerDependencies: hono: ^4 + '@koromix/koffi-android-arm64@3.2.1': + resolution: {integrity: sha512-1pJQ4jnZlUJduK9u9DC5CGy3aOgDUPvIXpNb6syV3+Dh5Q/ugezAIGCqvY+w+1mgXsve0pd0NVvJRjdZNHQ6MA==} + cpu: [arm64] + os: [android] + + '@koromix/koffi-android-x64@3.2.1': + resolution: {integrity: sha512-HH40xGh3gVQifjOBnhwT2tECC0lL1lYe+nxHvWNSzxDIyQNcVPXg38ta7vuONRFpD+uIrw7fqGYLzbZIagkVcg==} + cpu: [x64] + os: [android] + + '@koromix/koffi-darwin-arm64@3.2.1': + resolution: {integrity: sha512-Vj4h+xcjc5+Cn0DhPHjgRX4omKAv96Kehtcd+1YgYuY2W7FvQn9vS+3SmzVwhC5Qmg9bIwUZObYQ8T/4hBqQqA==} + cpu: [arm64] + os: [darwin] + + '@koromix/koffi-darwin-x64@3.2.1': + resolution: {integrity: sha512-gFCWxNBTZIvxo1p+PURWfsy2Ctj5FGnVVs1f03lTLhBvmxEto70pdIiFztdFLDFkAJ1pmtQmruRKapeK+E8YPA==} + cpu: [x64] + os: [darwin] + + '@koromix/koffi-freebsd-arm64@3.2.1': + resolution: {integrity: sha512-qj+f1s2e6vULaUG1cdlTcCXmunCq2t+rjxku1+esaMIqVnHpOwj0QzPuInG0AFdXjwBNQhyVR/HpDj8daEwwsQ==} + cpu: [arm64] + os: [freebsd] + + '@koromix/koffi-freebsd-ia32@3.2.1': + resolution: {integrity: sha512-6olHb1Qfgai0jjs6ddlDDD0ZfsCxy7SPi8rMRpuYQWH0qhgtyQu82hw5b1p7z+TJ0zZP3ZeQQ6l+U/MlM1ICHQ==} + cpu: [ia32] + os: [freebsd] + + '@koromix/koffi-freebsd-x64@3.2.1': + resolution: {integrity: sha512-Dikhw1ySYNVMkmeFvFVjnU5Wdk6mffNoOjJxm9bTG96vg7OlemylxqdEven47R1YJ3yzNVJn/MlQ207ORWfi2w==} + cpu: [x64] + os: [freebsd] + + '@koromix/koffi-linux-arm64@3.2.1': + resolution: {integrity: sha512-K+cGUL5iBcDqxmsocrjmlASqDf24gc7artbVW3PewG2c9AqwC63lezgwvB85Nx4lZAQjB6zIFHh9A7t1yGbwhw==} + cpu: [arm64] + os: [linux] + + '@koromix/koffi-linux-arm@3.2.1': + resolution: {integrity: sha512-OfwUwZylidq95wQKp6ClInULrfB2giu7dqM6Rhe0zAe6lES5I2SXNw15T9+GnRHk3/9hKT2XZ37OZLaKSyWNLA==} + cpu: [arm] + os: [linux] + + '@koromix/koffi-linux-ia32@3.2.1': + resolution: {integrity: sha512-rxj6UYjU1qd98gxNQOSCdLpc5cPRi5Giq9rNd3jnGuSNIyMkwa6Dxw4cUjmhIBCYESMJtmNt5NWnJp5u9wTfYQ==} + cpu: [ia32] + os: [linux] + + '@koromix/koffi-linux-loong64@3.2.1': + resolution: {integrity: sha512-aHhnHzkPRmT/IHDlGvESJ/Bs32m8N6UE6Ab6kMeJzgk74IN8af2m/81/wZJtybR1M2UxCV4NmlVNUYQQvSAO3Q==} + cpu: [loong64] + os: [linux] + + '@koromix/koffi-linux-riscv64@3.2.1': + resolution: {integrity: sha512-qtQBsjbm3LiirLJvajWmKkNb7ARk7fvJVXdftJ7NtAnF3Xw8EbDvrtvmvtNI1yLPlYcBmlzCCD71hwhWYk0SIA==} + cpu: [riscv64] + os: [linux] + + '@koromix/koffi-linux-x64@3.2.1': + resolution: {integrity: sha512-c7hw7Qs/r5gnFRTQLcbifBwRU7wiocj+2pVuDQ5Ahb3r36SZmupmgYbTWcLvTW+hul1jd7SKRV0d14ZJq/tvSw==} + cpu: [x64] + os: [linux] + + '@koromix/koffi-openbsd-ia32@3.2.1': + resolution: {integrity: sha512-mmY8fY8LQ/CB52+h3yrMYmVyoxzW3x08S0yI6VNfHWdfU6yJtZkKCbhjmQCYMrWbYKC4gMvwZwCywIGPAkLyeA==} + cpu: [ia32] + os: [openbsd] + + '@koromix/koffi-openbsd-x64@3.2.1': + resolution: {integrity: sha512-k4ig6aAPbFSRATOIIOfdf/KtlOGH4SVls6L9fy0QnTxRJYvY2oSltTsQtBDANgEQldlq8Kl5WnpRa1VSibP4Lw==} + cpu: [x64] + os: [openbsd] + + '@koromix/koffi-win32-arm64@3.2.1': + resolution: {integrity: sha512-cTWBJGK//pDMeKQJE/79Aq9MiOAF4H8QyLZHSQ9IWm8czOfwjG4J1AhsQ9DjI9KFOykH77hhnpmQTGVMIubGig==} + cpu: [arm64] + os: [win32] + + '@koromix/koffi-win32-ia32@3.2.1': + resolution: {integrity: sha512-Z50EM6TAZ7CFyMmyX6thv8eNpJchqe9eenhibSIy2Eq/FQYF76gU2VK/LEoaF46L8hfC7TpTp9b10MvReHEyFA==} + cpu: [ia32] + os: [win32] + + '@koromix/koffi-win32-x64@3.2.1': + resolution: {integrity: sha512-ZmZNiBO6bkOSh3QNzgfvb1cMY0yMobn6ZQrSMqbAce21qyYL8niIbyipz9N/PIRDciGhsV0wUxnZsxIO+yWsHQ==} + cpu: [x64] + os: [win32] + '@mariozechner/clipboard-darwin-arm64@0.3.9': resolution: {integrity: sha512-BfgV7vCEWZwJwZJw03r6bP5+tf0iI/ANuQYCxi9RNn7FrWB3yzGuMKCrNLRl6V761vXRdL8+OqZ0wd4TqlsNOQ==} engines: {node: '>= 10'} @@ -1490,6 +1583,9 @@ packages: jws@4.0.1: resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + koffi@3.2.1: + resolution: {integrity: sha512-0qE3lZ8jllRqPN4Ob6Ajl7c2bJSJDhQWuKLGP5hIEpHLllJWv1ydHFMhHmHc5p/W9GticKVDbYzZd7TBoQ4CZg==} + lightningcss-android-arm64@1.32.0: resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} engines: {node: '>= 12.0.0'} @@ -2577,6 +2673,60 @@ snapshots: dependencies: hono: 4.12.25 + '@koromix/koffi-android-arm64@3.2.1': + optional: true + + '@koromix/koffi-android-x64@3.2.1': + optional: true + + '@koromix/koffi-darwin-arm64@3.2.1': + optional: true + + '@koromix/koffi-darwin-x64@3.2.1': + optional: true + + '@koromix/koffi-freebsd-arm64@3.2.1': + optional: true + + '@koromix/koffi-freebsd-ia32@3.2.1': + optional: true + + '@koromix/koffi-freebsd-x64@3.2.1': + optional: true + + '@koromix/koffi-linux-arm64@3.2.1': + optional: true + + '@koromix/koffi-linux-arm@3.2.1': + optional: true + + '@koromix/koffi-linux-ia32@3.2.1': + optional: true + + '@koromix/koffi-linux-loong64@3.2.1': + optional: true + + '@koromix/koffi-linux-riscv64@3.2.1': + optional: true + + '@koromix/koffi-linux-x64@3.2.1': + optional: true + + '@koromix/koffi-openbsd-ia32@3.2.1': + optional: true + + '@koromix/koffi-openbsd-x64@3.2.1': + optional: true + + '@koromix/koffi-win32-arm64@3.2.1': + optional: true + + '@koromix/koffi-win32-ia32@3.2.1': + optional: true + + '@koromix/koffi-win32-x64@3.2.1': + optional: true + '@mariozechner/clipboard-darwin-arm64@0.3.9': optional: true @@ -3457,6 +3607,27 @@ snapshots: jwa: 2.0.1 safe-buffer: 5.2.1 + koffi@3.2.1: + optionalDependencies: + '@koromix/koffi-android-arm64': 3.2.1 + '@koromix/koffi-android-x64': 3.2.1 + '@koromix/koffi-darwin-arm64': 3.2.1 + '@koromix/koffi-darwin-x64': 3.2.1 + '@koromix/koffi-freebsd-arm64': 3.2.1 + '@koromix/koffi-freebsd-ia32': 3.2.1 + '@koromix/koffi-freebsd-x64': 3.2.1 + '@koromix/koffi-linux-arm': 3.2.1 + '@koromix/koffi-linux-arm64': 3.2.1 + '@koromix/koffi-linux-ia32': 3.2.1 + '@koromix/koffi-linux-loong64': 3.2.1 + '@koromix/koffi-linux-riscv64': 3.2.1 + '@koromix/koffi-linux-x64': 3.2.1 + '@koromix/koffi-openbsd-ia32': 3.2.1 + '@koromix/koffi-openbsd-x64': 3.2.1 + '@koromix/koffi-win32-arm64': 3.2.1 + '@koromix/koffi-win32-ia32': 3.2.1 + '@koromix/koffi-win32-x64': 3.2.1 + lightningcss-android-arm64@1.32.0: optional: true diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 54fb78321..b10c94d08 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,5 +2,6 @@ allowBuilds: '@google/genai': false better-sqlite3: true esbuild: true + koffi: true node-pty: true protobufjs: false diff --git a/src/artifact-destination-windows.ts b/src/artifact-destination-windows.ts new file mode 100644 index 000000000..1d5b3b997 --- /dev/null +++ b/src/artifact-destination-windows.ts @@ -0,0 +1,175 @@ +import { constants as fsConstants } from "node:fs"; +import { mkdir, open, type FileHandle } from "node:fs/promises"; +import { join, toNamespacedPath } from "node:path"; +import koffi from "koffi"; +import { ArtifactError } from "./artifact-error.js"; + +export interface WindowsArtifactDestinationDirectory { + handle: FileHandle; + anchorPath: string; + close(): Promise; +} + +export async function prepareWindowsArtifactDestinationDirectory( + workspaceRoot: string, + parentParts: readonly string[], +): Promise { + const pinnedHandles: unknown[] = []; + let directoryHandle: FileHandle | undefined; + let parentPath = workspaceRoot; + + try { + pinnedHandles.push(pinWindowsDirectory(workspaceRoot, "artifact_workspace_unsafe")); + for (const part of parentParts) { + parentPath = join(parentPath, part); + try { + await mkdir(parentPath, { mode: 0o755 }); + } catch (error) { + if (!isNodeError(error) || error.code !== "EEXIST") throw error; + } + pinnedHandles.push( + pinWindowsDirectory(parentPath, "artifact_destination_parent_unsafe"), + ); + } + + directoryHandle = await open(parentPath, fsConstants.O_RDONLY); + const entry = await directoryHandle.stat(); + if (!entry.isDirectory()) { + throw new ArtifactError( + "artifact_destination_parent_unsafe", + "Artifact destination parent is not a directory.", + ); + } + + return { + handle: directoryHandle, + anchorPath: parentPath, + async close() { + await directoryHandle?.close().catch(() => undefined); + closeWindowsHandles(pinnedHandles); + }, + }; + } catch (error) { + await directoryHandle?.close().catch(() => undefined); + closeWindowsHandles(pinnedHandles); + throw error; + } +} + +interface WindowsApi { + CreateFileW( + path: string, + access: number, + share: number, + security: null, + disposition: number, + flags: number, + templateFile: null, + ): unknown; + GetFileInformationByHandleEx( + handle: unknown, + infoClass: number, + info: WindowsAttributeInfo, + size: number, + ): number; + CloseHandle(handle: unknown): number; + HANDLE: ReturnType; + FILE_ATTRIBUTE_TAG_INFO: ReturnType; +} + +interface WindowsAttributeInfo { + FileAttributes?: number; + ReparseTag?: number; +} + +let cachedWindowsApi: WindowsApi | undefined; + +function windowsApi(): WindowsApi { + cachedWindowsApi ??= createWindowsApi(); + return cachedWindowsApi; +} + +function createWindowsApi(): WindowsApi { + const kernel32 = koffi.load("kernel32.dll"); + const HANDLE = koffi.pointer("DevSpaceArtifactWindowsHandle", koffi.opaque()); + const FILE_ATTRIBUTE_TAG_INFO = koffi.struct("DevSpaceArtifactFileAttributeTagInfo", { + FileAttributes: "uint32_t", + ReparseTag: "uint32_t", + }); + return { + CreateFileW: kernel32.func( + "DevSpaceArtifactWindowsHandle __stdcall CreateFileW(const char16_t *path, uint32_t access, uint32_t share, void *security, uint32_t disposition, uint32_t flags, void *templateFile)", + ) as unknown as WindowsApi["CreateFileW"], + GetFileInformationByHandleEx: kernel32.func( + "int __stdcall GetFileInformationByHandleEx(DevSpaceArtifactWindowsHandle handle, int infoClass, _Out_ DevSpaceArtifactFileAttributeTagInfo *info, uint32_t size)", + ) as unknown as WindowsApi["GetFileInformationByHandleEx"], + CloseHandle: kernel32.func( + "int __stdcall CloseHandle(DevSpaceArtifactWindowsHandle handle)", + ) as unknown as WindowsApi["CloseHandle"], + HANDLE, + FILE_ATTRIBUTE_TAG_INFO, + }; +} + +function pinWindowsDirectory(path: string, code: string): unknown { + const api = windowsApi(); + const FILE_LIST_DIRECTORY = 0x0001; + const FILE_TRAVERSE = 0x0020; + const FILE_READ_ATTRIBUTES = 0x0080; + const SYNCHRONIZE = 0x00100000; + const FILE_SHARE_READ = 0x00000001; + const FILE_SHARE_WRITE = 0x00000002; + const OPEN_EXISTING = 3; + const FILE_FLAG_OPEN_REPARSE_POINT = 0x00200000; + const FILE_FLAG_BACKUP_SEMANTICS = 0x02000000; + const FILE_ATTRIBUTE_DIRECTORY = 0x00000010; + const FILE_ATTRIBUTE_REPARSE_POINT = 0x00000400; + const FILE_ATTRIBUTE_TAG_INFO_CLASS = 9; + + // Omitting FILE_SHARE_DELETE keeps each path component pinned against + // rename/replacement while path-based Node operations run beneath it. + const handle = api.CreateFileW( + toNamespacedPath(path), + FILE_LIST_DIRECTORY | FILE_TRAVERSE | FILE_READ_ATTRIBUTES | SYNCHRONIZE, + FILE_SHARE_READ | FILE_SHARE_WRITE, + null, + OPEN_EXISTING, + FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS, + null, + ); + const pointerBits = koffi.sizeof(api.HANDLE) * 8; + if (handle === null || koffi.address(handle) === BigInt.asUintN(pointerBits, -1n)) { + throw new ArtifactError(code, "Artifact directory could not be pinned safely."); + } + + const info: WindowsAttributeInfo = {}; + const success = api.GetFileInformationByHandleEx( + handle, + FILE_ATTRIBUTE_TAG_INFO_CLASS, + info, + koffi.sizeof(api.FILE_ATTRIBUTE_TAG_INFO), + ); + const attributes = info.FileAttributes ?? 0; + if ( + !success + || (attributes & FILE_ATTRIBUTE_DIRECTORY) === 0 + || (attributes & FILE_ATTRIBUTE_REPARSE_POINT) !== 0 + ) { + api.CloseHandle(handle); + throw new ArtifactError( + code, + "Artifact directory must be a real directory, not a reparse point.", + ); + } + return handle; +} + +function closeWindowsHandles(handles: unknown[]): void { + if (handles.length === 0) return; + const api = windowsApi(); + for (const handle of handles.reverse()) api.CloseHandle(handle); +} + +function isNodeError(error: unknown): error is NodeJS.ErrnoException { + return error instanceof Error && "code" in error; +} diff --git a/src/artifact-download.test.ts b/src/artifact-download.test.ts index 0e1fe4433..862ef5000 100644 --- a/src/artifact-download.test.ts +++ b/src/artifact-download.test.ts @@ -23,6 +23,7 @@ import { registerArtifactTools, } from "./artifact-tools.js"; import { ArtifactError } from "./artifact-error.js"; +import { parseSafeWindowsArtifactRelativePath } from "./artifact-path-windows.js"; import { IncomingArtifactAdapterRegistry, type IncomingArtifactAdapter, @@ -33,6 +34,7 @@ const root = await mkdtemp(join(tmpdir(), "devspace-artifact-download-test-")); try { testOneToolContract(); testPlatformSupportContract(); + testWindowsPathValidationContract(); if (isArtifactDownloadSupportedPlatform()) { await testSafeDownloadAndConflict(join(root, "downloads")); await testDestinationValidation(join(root, "destinations")); @@ -103,7 +105,39 @@ function testPlatformSupportContract(): void { assert.equal(isArtifactDownloadSupportedPlatform("freebsd"), false); assert.equal(isArtifactDownloadSupportedPlatform("openbsd"), false); assert.equal(isArtifactDownloadSupportedPlatform("netbsd"), false); - assert.equal(isArtifactDownloadSupportedPlatform("win32"), false); + assert.equal(isArtifactDownloadSupportedPlatform("win32"), true); +} + +function testWindowsPathValidationContract(): void { + for (const path of [ + "../outside.txt", + "C:drive-relative.txt", + "C:\\absolute.txt", + "\\\\server\\share\\file.txt", + "\\\\?\\C:\\file.txt", + "file.txt:stream", + "CON", + "con.txt", + "CLOCK$", + "COM¹.txt", + "LPT³.log", + "folder/trailing-dot.", + "folder/trailing-space ", + "folder/invalid?.txt", + ]) { + assert.equal(parseSafeWindowsArtifactRelativePath(path), undefined, path); + } + + assert.deepEqual(parseSafeWindowsArtifactRelativePath("assets\\icons/lovely.png"), { + path: "assets/icons/lovely.png", + parts: ["assets", "icons", "lovely.png"], + name: "lovely.png", + }); + assert.deepEqual(parseSafeWindowsArtifactRelativePath("画像/アイコン.png"), { + path: "画像/アイコン.png", + parts: ["画像", "アイコン.png"], + name: "アイコン.png", + }); } async function testUnsupportedPlatform(testRoot: string): Promise { @@ -165,7 +199,19 @@ async function testDestinationValidation(testRoot: string): Promise { const workspaceRoot = join(testRoot, "workspace"); await mkdir(workspaceRoot, { recursive: true }); - for (const path of ["../outside.txt", "nested/../outside.txt", "/absolute.txt", "folder/"]) { + const invalidPaths = ["../outside.txt", "nested/../outside.txt", "/absolute.txt", "folder/"]; + if (process.platform === "win32") { + invalidPaths.push( + "C:drive-relative.txt", + "C:\\absolute.txt", + "\\\\server\\share\\file.txt", + "file.txt:stream", + "CON.txt", + "folder/trailing-dot.", + "folder/trailing-space ", + ); + } + for (const path of invalidPaths) { await expectArtifactError( downloadIncomingArtifact({ registry: registryFor({ name: "blocked.txt", stream: Readable.from(["blocked"]) }), @@ -258,13 +304,11 @@ async function testCrashLeftoverCleanup(testRoot: string): Promise { } async function testSymlinkRejection(testRoot: string): Promise { - if (process.platform === "win32") return; - const outside = join(testRoot, "outside"); await mkdir(outside, { recursive: true, mode: 0o700 }); const linkedWorkspaceRoot = join(testRoot, "linked-workspace"); - await symlink(outside, linkedWorkspaceRoot, "dir"); + await symlink(outside, linkedWorkspaceRoot, process.platform === "win32" ? "junction" : "dir"); await expectArtifactError( downloadIncomingArtifact({ registry: registryFor({ name: "blocked.txt", stream: Readable.from(["blocked"]) }), @@ -279,7 +323,11 @@ async function testSymlinkRejection(testRoot: string): Promise { const linkedDestinationRoot = join(testRoot, "linked-destination-workspace"); await mkdir(linkedDestinationRoot, { recursive: true }); - await symlink(outside, join(linkedDestinationRoot, "assets"), "dir"); + await symlink( + outside, + join(linkedDestinationRoot, "assets"), + process.platform === "win32" ? "junction" : "dir", + ); await expectArtifactError( downloadIncomingArtifact({ registry: registryFor({ name: "blocked.txt", stream: Readable.from(["blocked"]) }), @@ -342,7 +390,11 @@ async function testPublishedPermissions(testRoot: string): Promise { process.umask(previousUmask); } - assert.equal((await stat(join(workspaceRoot, "private.txt"))).mode & 0o777, 0o600); + const published = await stat(join(workspaceRoot, "private.txt")); + assert.equal(published.isFile(), true); + if (process.platform !== "win32") { + assert.equal(published.mode & 0o777, 0o600); + } } function testLogRedaction(): void { diff --git a/src/artifact-path-windows.ts b/src/artifact-path-windows.ts new file mode 100644 index 000000000..c71dc4876 --- /dev/null +++ b/src/artifact-path-windows.ts @@ -0,0 +1,41 @@ +import { win32 } from "node:path"; + +const WINDOWS_INVALID_NAME_CHARS = /[<>"|?*]/u; +const WINDOWS_DEVICE_NAME = /^(?:CON|PRN|AUX|NUL|CLOCK\$|CONIN\$|CONOUT\$|COM[1-9¹²³]|LPT[1-9¹²³])$/iu; + +export interface SafeWindowsArtifactPath { + path: string; + parts: string[]; + name: string; +} + +export function parseSafeWindowsArtifactRelativePath( + value: string, +): SafeWindowsArtifactPath | undefined { + if ( + !value + || /[\u0000-\u001f\u007f]/u.test(value) + || /^[\\/]/u.test(value) + || /^[A-Za-z]:/u.test(value) + || win32.isAbsolute(value) + || value.endsWith("\\") + || value.endsWith("/") + || value.includes(":") + ) return undefined; + + const rawParts = value.split(/[\\/]+/u); + if (rawParts.includes("..") || rawParts.at(-1) === ".") return undefined; + const parts = rawParts.filter((part) => part !== "" && part !== "."); + if (parts.length === 0 || parts.some(isUnsafeWindowsPathPart)) return undefined; + const name = parts.at(-1); + if (!name) return undefined; + return { path: parts.join("/"), parts, name }; +} + +function isUnsafeWindowsPathPart(part: string): boolean { + if (!part || part === "." || part === "..") return true; + if (part.endsWith(".") || part.endsWith(" ")) return true; + if (WINDOWS_INVALID_NAME_CHARS.test(part)) return true; + const deviceBase = (part.split(".", 1)[0] ?? part).trimEnd(); + return WINDOWS_DEVICE_NAME.test(deviceBase); +} diff --git a/src/artifact-tools.ts b/src/artifact-tools.ts index 1a9338d16..ba18bf6d4 100644 --- a/src/artifact-tools.ts +++ b/src/artifact-tools.ts @@ -14,6 +14,7 @@ import { registerAppTool } from "@modelcontextprotocol/ext-apps/server"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import * as z from "zod/v4"; import { ArtifactError } from "./artifact-error.js"; +import { parseSafeWindowsArtifactRelativePath } from "./artifact-path-windows.js"; import type { ServerConfig } from "./config.js"; import { describeIncomingArtifactValue, @@ -35,7 +36,7 @@ const PARTIAL_PREFIX = ".devspace-download-"; const PARTIAL_SUFFIX = ".partial"; const STALE_PARTIAL_AGE_MS = 24 * 60 * 60 * 1_000; const MAX_STALE_PARTIAL_CLEANUP = 32; -const ARTIFACT_DOWNLOAD_PLATFORMS = new Set(["linux"]); +const ARTIFACT_DOWNLOAD_PLATFORMS = new Set(["linux", "win32"]); const openAIFileReferenceInputSchema = z.strictObject({ download_url: z.string(), @@ -161,7 +162,7 @@ export async function downloadIncomingArtifact({ if (!isArtifactDownloadSupportedPlatform()) { throw new ArtifactError( "artifact_platform_unsupported", - "Native file download requires descriptor-anchored directory operations on this platform.", + "Native file download requires secure platform filesystem primitives.", ); } if (!Number.isSafeInteger(maxFileBytes) || maxFileBytes < 1) { @@ -192,15 +193,25 @@ export async function downloadIncomingArtifact({ ); } - workspaceHandle = await openDirectoryNoFollow( - workspaceRoot, - "artifact_workspace_unsafe", - "Selected workspace root is not a real directory.", - ); - destinationDirectory = await prepareDestinationDirectory( - workspaceHandle, - destination.parentParts, - ); + if (process.platform === "win32") { + const { prepareWindowsArtifactDestinationDirectory } = await import( + "./artifact-destination-windows.js" + ); + destinationDirectory = await prepareWindowsArtifactDestinationDirectory( + workspaceRoot, + destination.parentParts, + ); + } else { + workspaceHandle = await openDirectoryNoFollow( + workspaceRoot, + "artifact_workspace_unsafe", + "Selected workspace root is not a real directory.", + ); + destinationDirectory = await prepareDestinationDirectory( + workspaceHandle, + destination.parentParts, + ); + } await cleanupStalePartials(destinationDirectory); partialPath = join( @@ -369,7 +380,7 @@ async function assertDirectoryHandle(handle: FileHandle): Promise { } function descriptorDirectoryPath(handle: FileHandle): string { - if (isArtifactDownloadSupportedPlatform()) return `/proc/self/fd/${handle.fd}`; + if (process.platform === "linux") return `/proc/self/fd/${handle.fd}`; throw new ArtifactError( "artifact_platform_unsupported", "Native file download requires descriptor-anchored directory operations on this platform.", @@ -377,6 +388,21 @@ function descriptorDirectoryPath(handle: FileHandle): string { } function normalizeArtifactDestination(value: string): ArtifactDestination { + if (process.platform === "win32") { + const parsed = parseSafeWindowsArtifactRelativePath(value); + if (!parsed) { + throw new ArtifactError( + "artifact_destination_invalid", + "Artifact destination must be a safe relative Windows file path inside the workspace.", + ); + } + return { + path: parsed.path, + parentParts: parsed.parts.slice(0, -1), + name: parsed.name, + }; + } + const rawParts = value.split(sep); if ( !value