diff --git a/docs/artifact-exchange.md b/docs/artifact-exchange.md index 6a6f5c75..62bc4262 100644 --- a/docs/artifact-exchange.md +++ b/docs/artifact-exchange.md @@ -34,10 +34,13 @@ The `file` input must be the native value supplied by the MCP host. DevSpace doe not accept pasted download URLs or local source paths. It validates the complete file-object shape, trusted OpenAI download hosts, and redirects before streaming. Malformed references, unknown fields, absolute paths, traversal, and symlinked -parents are rejected. +parents are rejected. Windows junctions, other reparse points, alternate data +streams, device names, and ambiguous reserved path segments are also rejected. Downloads are streamed under `DEVSPACE_ARTIFACT_MAX_FILE_BYTES` 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. +private files without overwriting an existing destination. POSIX platforms use +mode `0600`; Windows preserves the destination directory's ACL inheritance. The +tool is available on Linux, macOS, and Windows. Linux and Windows clean bounded, +stale partial downloads opportunistically; macOS never path-walks to clean an +abruptly abandoned partial because doing so would weaken descriptor-relative +publication guarantees. diff --git a/docs/configuration.md b/docs/configuration.md index 3502a98b..774331cd 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -48,9 +48,11 @@ an attached or generated file into an already-open workspace: DEVSPACE_ARTIFACTS=1 npx @waishnav/devspace serve ``` -This feature currently supports Linux. It is not registered on macOS, Windows, -or BSD because the secure publication path depends on traversable, -descriptor-anchored directory paths provided by Linux procfs. +This feature supports Linux, macOS, and Windows. Linux uses procfs-backed +directory descriptors, macOS uses descriptor-relative filesystem operations, +and Windows pins each destination directory with a non-delete-sharing handle +while rejecting junctions and other reparse points. The tool is not registered +on the remaining BSD platforms. | Variable | Default | Purpose | | --- | --- | --- | @@ -64,6 +66,8 @@ The same settings may be persisted in `~/.devspace/config.json` as a `workspaceId` returned by `open_workspace`, and a relative workspace `path`. DevSpace safely creates missing parent directories, refuses to overwrite an existing destination, and returns only the normalized workspace-relative path. +Windows alternate data streams, device names, ambiguous trailing dots or spaces, +and other reserved path segments are rejected. It does not accept conflict modes, expected hashes, arbitrary URL strings, local paths, embedded credentials, or extra object fields. diff --git a/docs/security.md b/docs/security.md index d7ec0e1d..d40797b3 100644 --- a/docs/security.md +++ b/docs/security.md @@ -105,9 +105,11 @@ download hosts and redirects. Arbitrary URL strings, local source paths, 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. +fail closed. Windows additionally rejects junctions and other reparse points, +alternate data streams, device names, and ambiguous reserved path segments. +Downloads stream under the configured per-file limit and are published without +overwrite. POSIX files use mode `0600`; Windows files inherit the destination +directory's ACL. DevSpace does not extract or execute transferred content. ## Logs diff --git a/package-lock.json b/package-lock.json index 0d7decc4..e106e817 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "diff": "^8.0.3", "drizzle-orm": "^0.45.2", "express": "^5.2.1", + "koffi": "^3.1.2", "lucide": "^1.24.0", "react": "^19.2.6", "react-dom": "^19.2.6", @@ -4794,6 +4795,225 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/@koromix/koffi-darwin-arm64": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-darwin-arm64/-/koffi-darwin-arm64-3.1.2.tgz", + "integrity": "sha512-32pU4pNZABIz+l9DNJl51Y+jur4vv+SF4Ip2CSF4OUg1xUyefoLpX0NttDmzGITIrneUEVSEN+dT22524ESKBw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@koromix/koffi-darwin-x64": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-darwin-x64/-/koffi-darwin-x64-3.1.2.tgz", + "integrity": "sha512-S+H6LQgUoMj77BqDegwlRaxwLXDfwvSJGuceOqtH0I5V8rzKLmu/hC7NBlxOoAlvKlcV63FtdNiE2E9YSltffg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@koromix/koffi-freebsd-arm64": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-arm64/-/koffi-freebsd-arm64-3.1.2.tgz", + "integrity": "sha512-fD0ow2PBE60nw7K6xcbala6qwXxfcYeU62tduNeIPvx0KoWhU2rMKZiDNe+iI5TQb3rxYYjjP+aF2Sdm9y6EXQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@koromix/koffi-freebsd-ia32": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-ia32/-/koffi-freebsd-ia32-3.1.2.tgz", + "integrity": "sha512-t8OmL+hoJGDLZDnuLjgLemSYrXX99M7Md+zJX8bMHOtiNbFtkGXn/mV21Pb1ik9JhBXjwK1r4hvBPNlqTMGrHg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@koromix/koffi-freebsd-x64": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-x64/-/koffi-freebsd-x64-3.1.2.tgz", + "integrity": "sha512-axbLgiM4Y2vyDOTqlXCI8vkg9wqjwSRsmoWXSKreA5YFJwnYA6Sc4aHMz+qZgUSfFei52Qrv1RGhDyo4kHvqhA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@koromix/koffi-linux-arm64": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-arm64/-/koffi-linux-arm64-3.1.2.tgz", + "integrity": "sha512-f0hqAIlFcL9wlRGJ/uCfyfspqnGaASk2gLx1UAP3RBgMQl68D1e+fiHNdXa7g9d76ttmpA8/PGNAqc1X4Byy1Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@koromix/koffi-linux-ia32": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-ia32/-/koffi-linux-ia32-3.1.2.tgz", + "integrity": "sha512-UGLPuqeOV/UArsK6oeB5yI/XjSWkFqFlBTC9rUbezBuHJhSibk1EMv7QC0cvtDMu18bo+ucqXWPzh42oT5yYlw==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@koromix/koffi-linux-loong64": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-loong64/-/koffi-linux-loong64-3.1.2.tgz", + "integrity": "sha512-jI0+gM2oDsJ7reOt3XPyO7lyQtZ1CT6NR2uqGQcQVM43cyXBAVYYCUxEH3LHCbgumFaZ+LueIUgbMSwb9pHBxQ==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@koromix/koffi-linux-riscv64": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-riscv64/-/koffi-linux-riscv64-3.1.2.tgz", + "integrity": "sha512-yB99adXBRd5T+xXG+f6nnUkC3jCI0iXvPU6RqD9Kx7aZP4Y4NNUWJ5Q4FaP9jb1XmZLY4pGBUiHt8u03Yl7NyA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@koromix/koffi-linux-x64": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-x64/-/koffi-linux-x64-3.1.2.tgz", + "integrity": "sha512-Oxvo6F3Edzy/Jm2EtbHWkJ2xRB0mXDAe63k5+USL5uiGE5xZjwEUDOBKIhv2BpCZSOAJrfoojFFogj6+ICKQhw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@koromix/koffi-openbsd-ia32": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-openbsd-ia32/-/koffi-openbsd-ia32-3.1.2.tgz", + "integrity": "sha512-SSWzUhL8Ex84JTsO67+MdWZrdwgOzoOrQ0+ZbB+UsivHoAxmWLHKWZaSafNqyBZtxGY1EgtR8AIPouWE9U+Zfw==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@koromix/koffi-openbsd-x64": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-openbsd-x64/-/koffi-openbsd-x64-3.1.2.tgz", + "integrity": "sha512-0ZuI4St7chq3M0d3VivvKIqacZ7RhgohdR476V3HpJkaNdfIywsJIw+GBvqkQahu+4A2Rpu6yQJpWSrfk/Z+Jw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@koromix/koffi-win32-arm64": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-win32-arm64/-/koffi-win32-arm64-3.1.2.tgz", + "integrity": "sha512-8Wn6phw7y53uI52+aBPAqEfZ5pj/HCjg/YtdthqSWYHy+d0MhyASKlcmuP0B5raxQnnA1Bm9LC8UO3M3RojeBw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@koromix/koffi-win32-ia32": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-win32-ia32/-/koffi-win32-ia32-3.1.2.tgz", + "integrity": "sha512-FkKaPBMawgHMNnp1FwLldXMNvEa139GXkxPi9JD9xU71Kh/ZmuEYHGSD6JwZDmDr4jekVrBrr+eGZ+j6C2mkXg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@koromix/koffi-win32-x64": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-win32-x64/-/koffi-win32-x64-3.1.2.tgz", + "integrity": "sha512-FeFC59UU1XX4J3ZaqKrsrEzczzB5qksMJo7/R45vIg8mGNVSLMVE85JRiZpjcp9i5Lbav5Vw47QvwFzBgIfvlw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/koffi": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/koffi/-/koffi-3.1.2.tgz", + "integrity": "sha512-wVwuE21TBl8/si6E0hPorKR2PJ2q33mEWVETANrtSp3kFM8fi2FcD/J5wmxu0T4TBcqmMQ4xKuF1X1ayFmphzw==", + "hasInstallScript": true, + "license": "MIT", + "optionalDependencies": { + "@koromix/koffi-darwin-arm64": "3.1.2", + "@koromix/koffi-darwin-x64": "3.1.2", + "@koromix/koffi-freebsd-arm64": "3.1.2", + "@koromix/koffi-freebsd-ia32": "3.1.2", + "@koromix/koffi-freebsd-x64": "3.1.2", + "@koromix/koffi-linux-arm64": "3.1.2", + "@koromix/koffi-linux-ia32": "3.1.2", + "@koromix/koffi-linux-loong64": "3.1.2", + "@koromix/koffi-linux-riscv64": "3.1.2", + "@koromix/koffi-linux-x64": "3.1.2", + "@koromix/koffi-openbsd-ia32": "3.1.2", + "@koromix/koffi-openbsd-x64": "3.1.2", + "@koromix/koffi-win32-arm64": "3.1.2", + "@koromix/koffi-win32-ia32": "3.1.2", + "@koromix/koffi-win32-x64": "3.1.2" + } + }, "node_modules/lru_map": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/lru_map/-/lru_map-0.4.1.tgz", diff --git a/package.json b/package.json index 2a564f40..7dc66315 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "diff": "^8.0.3", "drizzle-orm": "^0.45.2", "express": "^5.2.1", + "koffi": "^3.1.2", "lucide": "^1.24.0", "react": "^19.2.6", "react-dom": "^19.2.6", diff --git a/src/artifact-download.test.ts b/src/artifact-download.test.ts index c49aad84..564969e7 100644 --- a/src/artifact-download.test.ts +++ b/src/artifact-download.test.ts @@ -99,11 +99,11 @@ function testOneToolContract(): void { function testPlatformSupportContract(): void { assert.equal(isArtifactDownloadSupportedPlatform("linux"), true); - assert.equal(isArtifactDownloadSupportedPlatform("darwin"), false); + assert.equal(isArtifactDownloadSupportedPlatform("darwin"), true); 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); } async function testUnsupportedPlatform(testRoot: string): Promise { @@ -165,7 +165,18 @@ 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( + "file.txt:stream", + "CON.txt", + "folder./file.txt", + "bad.txt", + "C:drive-relative.txt", + ); + } + + for (const path of invalidPaths) { await expectArtifactError( downloadIncomingArtifact({ registry: registryFor({ name: "blocked.txt", stream: Readable.from(["blocked"]) }), @@ -219,6 +230,8 @@ async function testSizeLimitAndCleanup(testRoot: string): Promise { } async function testCrashLeftoverCleanup(testRoot: string): Promise { + if (process.platform === "darwin") return; + const workspaceRoot = join(testRoot, "workspace"); await mkdir(workspaceRoot, { recursive: true }); await downloadIncomingArtifact({ @@ -258,13 +271,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 +290,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"]) }), @@ -294,6 +309,8 @@ async function testSymlinkRejection(testRoot: string): Promise { } async function testPublicationFailurePreservesReplacement(testRoot: string): Promise { + if (process.platform === "darwin") return; + const workspaceRoot = join(testRoot, "workspace"); await mkdir(workspaceRoot, { recursive: true }); const destinationPath = join(workspaceRoot, "generated.txt"); @@ -323,6 +340,8 @@ async function testPublicationFailurePreservesReplacement(testRoot: string): Pro } async function testPublishedPermissions(testRoot: string): Promise { + if (process.platform === "win32") return; + const workspaceRoot = join(testRoot, "workspace"); await mkdir(workspaceRoot, { recursive: true }); const previousUmask = process.umask(0o077); diff --git a/src/artifact-secure-filesystem-common.ts b/src/artifact-secure-filesystem-common.ts new file mode 100644 index 00000000..9d310b2c --- /dev/null +++ b/src/artifact-secure-filesystem-common.ts @@ -0,0 +1,155 @@ +import { close, fstat, fsync, write } from "node:fs"; +import { lstat, readdir, unlink, type FileHandle } from "node:fs/promises"; +import { join } from "node:path"; +import { ArtifactError } from "./artifact-error.js"; + +export const PARTIAL_PREFIX = ".devspace-download-"; +export const PARTIAL_SUFFIX = ".partial"; +const STALE_PARTIAL_AGE_MS = 24 * 60 * 60 * 1_000; +const MAX_STALE_PARTIAL_CLEANUP = 32; + +export type FileEntry = Awaited>; + +export async function writeAllFileHandle( + handle: FileHandle, + buffer: Buffer, + position: number, +): Promise { + let offset = 0; + while (offset < buffer.length) { + const { bytesWritten } = await handle.write( + buffer, + offset, + buffer.length - offset, + position + offset, + ); + if (bytesWritten <= 0) throw shortWriteError(); + offset += bytesWritten; + } +} + +export async function writeAllFd( + fd: number, + buffer: Buffer, + position: number, +): Promise { + let offset = 0; + while (offset < buffer.length) { + const bytesWritten = await writeFd( + fd, + buffer, + offset, + buffer.length - offset, + position + offset, + ); + if (bytesWritten <= 0) throw shortWriteError(); + offset += bytesWritten; + } +} + +export function fsyncFd(fd: number): Promise { + return new Promise((resolve, reject) => { + fsync(fd, (error) => error ? reject(error) : resolve()); + }); +} + +export function fstatFd(fd: number): Promise { + return new Promise((resolve, reject) => { + fstat(fd, (error, stats) => error ? reject(error) : resolve(stats)); + }); +} + +export function closeFd(fd: number): Promise { + return new Promise((resolve, reject) => { + close(fd, (error) => error ? reject(error) : resolve()); + }); +} + +export function assertWrittenEntry(entry: FileEntry, expectedSize: number): void { + if (!entry.isFile() || entry.size !== expectedSize) { + throw new ArtifactError( + "artifact_write_integrity_failed", + "Native file could not be verified before publication.", + ); + } +} + +export function assertSameEntry( + entry: FileEntry, + expected: FileEntry, + code: "artifact_partial_unsafe" | "artifact_destination_publish_failed", +): void { + if ( + entry.isSymbolicLink() + || !entry.isFile() + || entry.dev !== expected.dev + || entry.ino !== expected.ino + || entry.size !== expected.size + ) { + throw new ArtifactError( + code, + code === "artifact_partial_unsafe" + ? "Native file partial changed before publication." + : "Published artifact did not match the verified download.", + ); + } +} + +export async function cleanupStalePartials(directoryPath: string): Promise { + const entries = await readdir(directoryPath, { withFileTypes: true }); + let inspected = 0; + const cutoff = Date.now() - STALE_PARTIAL_AGE_MS; + for (const entry of entries) { + if (inspected >= MAX_STALE_PARTIAL_CLEANUP) break; + if (!entry.name.startsWith(PARTIAL_PREFIX) || !entry.name.endsWith(PARTIAL_SUFFIX)) { + continue; + } + inspected += 1; + const path = join(directoryPath, entry.name); + const metadata = await lstatOrUndefined(path); + if ( + !metadata + || metadata.isSymbolicLink() + || !metadata.isFile() + || metadata.mtimeMs >= cutoff + || (process.getuid?.() !== undefined && metadata.uid !== process.getuid?.()) + ) continue; + await unlink(path).catch(() => undefined); + } +} + +export function destinationExistsError(): ArtifactError { + return new ArtifactError("artifact_destination_exists", "Artifact destination already exists."); +} + +export function isNodeError(error: unknown): error is NodeJS.ErrnoException { + return error instanceof Error && "code" in error; +} + +function writeFd( + fd: number, + buffer: Buffer, + offset: number, + length: number, + position: number, +): Promise { + return new Promise((resolve, reject) => { + write(fd, buffer, offset, length, position, (error, bytesWritten) => { + if (error) reject(error); + else resolve(bytesWritten); + }); + }); +} + +async function lstatOrUndefined(path: string) { + try { + return await lstat(path); + } catch (error) { + if (isNodeError(error) && error.code === "ENOENT") return undefined; + throw error; + } +} + +function shortWriteError(): ArtifactError { + return new ArtifactError("artifact_short_write", "Native file was not fully written."); +} diff --git a/src/artifact-secure-filesystem-darwin.ts b/src/artifact-secure-filesystem-darwin.ts new file mode 100644 index 00000000..51e30dc9 --- /dev/null +++ b/src/artifact-secure-filesystem-darwin.ts @@ -0,0 +1,192 @@ +import { randomUUID } from "node:crypto"; +import { constants as fsConstants } from "node:fs"; +import koffi from "koffi"; +import { ArtifactError } from "./artifact-error.js"; +import { + assertSameEntry, + assertWrittenEntry, + closeFd, + destinationExistsError, + fstatFd, + fsyncFd, + PARTIAL_PREFIX, + PARTIAL_SUFFIX, + writeAllFd, + type FileEntry, +} from "./artifact-secure-filesystem-common.js"; +import type { + SecureArtifactTarget, + SecureArtifactTargetOptions, +} from "./artifact-secure-filesystem.js"; + +const NO_FOLLOW = fsConstants.O_NOFOLLOW ?? 0; +const O_CLOEXEC = 0x01000000; +const DIRECTORY_FLAGS = fsConstants.O_RDONLY | (fsConstants.O_DIRECTORY ?? 0) | NO_FOLLOW; + +export async function openDarwinArtifactTarget({ + workspaceRoot, + parentParts, + name, +}: SecureArtifactTargetOptions): Promise { + const libc = darwinLibc(); + const directoryFds: number[] = []; + let rootFd = -1; + let fileFd = -1; + let partialName: string | undefined; + let writtenEntry: FileEntry | undefined; + let parentFd = -1; + + try { + rootFd = libc.open( + workspaceRoot, + DIRECTORY_FLAGS | O_CLOEXEC, + ); + if (rootFd < 0) { + throw new ArtifactError( + "artifact_workspace_unsafe", + "Selected workspace root is not a real directory.", + ); + } + + parentFd = rootFd; + for (const part of parentParts) { + if (libc.mkdirat(parentFd, part, 0o755) < 0 && koffi.errno() !== koffi.os.errno.EEXIST) { + throw new ArtifactError( + "artifact_destination_parent_unsafe", + "Artifact destination parent could not be created safely.", + ); + } + const childFd = libc.openat( + parentFd, + part, + DIRECTORY_FLAGS | O_CLOEXEC, + ); + if (childFd < 0) { + throw new ArtifactError( + "artifact_destination_parent_unsafe", + "Artifact destination parent must be a real directory inside the workspace.", + ); + } + directoryFds.push(childFd); + parentFd = childFd; + } + + partialName = `${PARTIAL_PREFIX}${randomUUID()}${PARTIAL_SUFFIX}`; + fileFd = libc.openat( + parentFd, + partialName, + fsConstants.O_WRONLY + | fsConstants.O_CREAT + | fsConstants.O_EXCL + | NO_FOLLOW + | O_CLOEXEC, + 0o600, + ); + if (fileFd < 0) { + throw new ArtifactError( + "artifact_partial_unsafe", + "Native file partial could not be created safely.", + ); + } + + return { + writeAll: (buffer, position) => writeAllFd(fileFd, buffer, position), + async syncAndVerify(expectedSize) { + await fsyncFd(fileFd); + writtenEntry = await fstatFd(fileFd); + assertWrittenEntry(writtenEntry, expectedSize); + const verificationFd = libc.openat( + parentFd, + partialName!, + fsConstants.O_RDONLY | NO_FOLLOW | O_CLOEXEC, + ); + if (verificationFd < 0) throw partialUnsafeError(koffi.errno()); + try { + assertSameEntry(await fstatFd(verificationFd), writtenEntry, "artifact_partial_unsafe"); + } finally { + await closeFd(verificationFd).catch(() => undefined); + } + }, + async publish() { + if (!writtenEntry) throw new Error("Artifact must be verified before publication."); + if (libc.linkat(parentFd, partialName!, parentFd, name, 0) < 0) { + if (koffi.errno() === koffi.os.errno.EEXIST) throw destinationExistsError(); + throw publicationFailedError(); + } + + const publishedFd = libc.openat( + parentFd, + name, + fsConstants.O_RDONLY | NO_FOLLOW | O_CLOEXEC, + ); + if (publishedFd < 0) throw publicationFailedError(); + try { + assertSameEntry( + await fstatFd(publishedFd), + writtenEntry, + "artifact_destination_publish_failed", + ); + } finally { + await closeFd(publishedFd).catch(() => undefined); + } + + if (libc.unlinkat(parentFd, partialName!, 0) === 0) partialName = undefined; + }, + async close() { + if (fileFd >= 0) await closeFd(fileFd).catch(() => undefined); + if (partialName) libc.unlinkat(parentFd, partialName, 0); + for (const fd of directoryFds.reverse()) await closeFd(fd).catch(() => undefined); + if (rootFd >= 0) await closeFd(rootFd).catch(() => undefined); + }, + }; + } catch (error) { + if (fileFd >= 0) await closeFd(fileFd).catch(() => undefined); + if (partialName && parentFd >= 0) libc.unlinkat(parentFd, partialName, 0); + for (const fd of directoryFds.reverse()) await closeFd(fd).catch(() => undefined); + if (rootFd >= 0) await closeFd(rootFd).catch(() => undefined); + throw error; + } +} + +let cachedDarwinLibc: ReturnType | undefined; + +function darwinLibc() { + cachedDarwinLibc ??= createDarwinLibc(); + return cachedDarwinLibc; +} + +function createDarwinLibc() { + const libc = koffi.load("/usr/lib/libSystem.B.dylib"); + const open = libc.func("int open(const char *path, int flags, ...)"); + const openat = libc.func("int openat(int fd, const char *path, int flags, ...)"); + return { + open(path: string, flags: number, mode?: number): number { + return mode === undefined ? open(path, flags) : open(path, flags, "int", mode); + }, + openat(fd: number, path: string, flags: number, mode?: number): number { + return mode === undefined + ? openat(fd, path, flags) + : openat(fd, path, flags, "int", mode); + }, + mkdirat: libc.func("int mkdirat(int fd, const char *path, uint32_t mode)"), + linkat: libc.func( + "int linkat(int oldfd, const char *oldpath, int newfd, const char *newpath, int flags)", + ), + unlinkat: libc.func("int unlinkat(int fd, const char *path, int flags)"), + }; +} + +function partialUnsafeError(errno?: number): ArtifactError { + const suffix = errno === undefined ? "" : ` (errno ${errno})`; + return new ArtifactError( + "artifact_partial_unsafe", + `Native file partial changed before publication${suffix}.`, + ); +} + +function publicationFailedError(): ArtifactError { + return new ArtifactError( + "artifact_destination_publish_failed", + "Native file could not be published at the requested destination.", + ); +} diff --git a/src/artifact-secure-filesystem-linux.ts b/src/artifact-secure-filesystem-linux.ts new file mode 100644 index 00000000..dec7565e --- /dev/null +++ b/src/artifact-secure-filesystem-linux.ts @@ -0,0 +1,145 @@ +import { randomUUID } from "node:crypto"; +import { constants as fsConstants } from "node:fs"; +import { lstat, mkdir, open, unlink, type FileHandle } from "node:fs/promises"; +import { join } from "node:path"; +import { ArtifactError } from "./artifact-error.js"; +import { + assertSameEntry, + assertWrittenEntry, + cleanupStalePartials, + destinationExistsError, + isNodeError, + PARTIAL_PREFIX, + PARTIAL_SUFFIX, + writeAllFileHandle, + type FileEntry, +} from "./artifact-secure-filesystem-common.js"; +import type { + SecureArtifactTarget, + SecureArtifactTargetOptions, +} from "./artifact-secure-filesystem.js"; + +const NO_FOLLOW = fsConstants.O_NOFOLLOW ?? 0; +const DIRECTORY_FLAGS = fsConstants.O_RDONLY | (fsConstants.O_DIRECTORY ?? 0) | NO_FOLLOW; + +export async function openLinuxArtifactTarget({ + workspaceRoot, + parentParts, + name, + publishLink, +}: SecureArtifactTargetOptions): Promise { + const directoryHandles: FileHandle[] = []; + let rootHandle: FileHandle | undefined; + let fileHandle: FileHandle | undefined; + let partialPath: string | undefined; + let candidatePath: string | undefined; + let writtenEntry: FileEntry | undefined; + + try { + rootHandle = await openDirectoryNoFollow( + workspaceRoot, + "artifact_workspace_unsafe", + "Selected workspace root is not a real directory.", + ); + let parentHandle = rootHandle; + let parentAnchor = `/proc/self/fd/${rootHandle.fd}`; + for (const part of parentParts) { + await assertDirectoryHandle(parentHandle); + const childPath = join(parentAnchor, part); + try { + await mkdir(childPath, { mode: 0o755 }); + } catch (error) { + if (!isNodeError(error) || error.code !== "EEXIST") throw error; + } + const child = await openDirectoryNoFollow( + childPath, + "artifact_destination_parent_unsafe", + "Artifact destination parent must be a real directory inside the workspace.", + ); + directoryHandles.push(child); + parentHandle = child; + parentAnchor = `/proc/self/fd/${child.fd}`; + } + + await cleanupStalePartials(parentAnchor); + partialPath = join( + parentAnchor, + `${PARTIAL_PREFIX}${randomUUID()}${PARTIAL_SUFFIX}`, + ); + candidatePath = join(parentAnchor, name); + fileHandle = await open( + partialPath, + fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | NO_FOLLOW, + 0o600, + ); + + return { + writeAll: (buffer, position) => writeAllFileHandle(fileHandle!, buffer, position), + async syncAndVerify(expectedSize) { + await fileHandle!.sync(); + writtenEntry = await fileHandle!.stat(); + assertWrittenEntry(writtenEntry, expectedSize); + assertSameEntry(await lstat(partialPath!), writtenEntry, "artifact_partial_unsafe"); + }, + async publish() { + if (!writtenEntry) throw new Error("Artifact must be verified before publication."); + try { + await publishLink(partialPath!, candidatePath!); + assertSameEntry( + await lstat(candidatePath!), + writtenEntry, + "artifact_destination_publish_failed", + ); + await unlink(partialPath!).catch(() => undefined); + partialPath = undefined; + } catch (error) { + if (isNodeError(error) && error.code === "EEXIST") throw destinationExistsError(); + throw error; + } + }, + async close() { + await fileHandle?.close().catch(() => undefined); + if (partialPath) await unlink(partialPath).catch(() => undefined); + for (const handle of directoryHandles.reverse()) { + await handle.close().catch(() => undefined); + } + await rootHandle?.close().catch(() => undefined); + }, + }; + } catch (error) { + await fileHandle?.close().catch(() => undefined); + if (partialPath) await unlink(partialPath).catch(() => undefined); + for (const handle of directoryHandles.reverse()) { + await handle.close().catch(() => undefined); + } + await rootHandle?.close().catch(() => undefined); + throw error; + } +} + +async function openDirectoryNoFollow( + path: string, + code: string, + message: string, +): Promise { + let handle: FileHandle | undefined; + try { + handle = await open(path, DIRECTORY_FLAGS); + await assertDirectoryHandle(handle); + return handle; + } catch (error) { + await handle?.close().catch(() => undefined); + if (error instanceof ArtifactError) throw error; + throw new ArtifactError(code, message); + } +} + +async function assertDirectoryHandle(handle: FileHandle): Promise { + const entry = await handle.stat(); + if (!entry.isDirectory()) { + throw new ArtifactError( + "artifact_directory_unsafe", + "Artifact destination parent is not a directory.", + ); + } +} diff --git a/src/artifact-secure-filesystem-windows.ts b/src/artifact-secure-filesystem-windows.ts new file mode 100644 index 00000000..50591a76 --- /dev/null +++ b/src/artifact-secure-filesystem-windows.ts @@ -0,0 +1,211 @@ +import { randomUUID } from "node:crypto"; +import { constants as fsConstants } from "node:fs"; +import { lstat, mkdir, open, unlink, type FileHandle } from "node:fs/promises"; +import { join, toNamespacedPath } from "node:path"; +import koffi from "koffi"; +import { ArtifactError } from "./artifact-error.js"; +import { + assertSameEntry, + assertWrittenEntry, + cleanupStalePartials, + destinationExistsError, + isNodeError, + PARTIAL_PREFIX, + PARTIAL_SUFFIX, + writeAllFileHandle, + type FileEntry, +} from "./artifact-secure-filesystem-common.js"; +import type { + SecureArtifactTarget, + SecureArtifactTargetOptions, +} from "./artifact-secure-filesystem.js"; + +export async function openWindowsArtifactTarget({ + workspaceRoot, + parentParts, + name, + publishLink, +}: SecureArtifactTargetOptions): Promise { + const pinnedHandles: unknown[] = []; + let fileHandle: FileHandle | undefined; + let partialPath: string | undefined; + let writtenEntry: FileEntry | 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"), + ); + } + + await cleanupStalePartials(parentPath); + partialPath = join( + parentPath, + `${PARTIAL_PREFIX}${randomUUID()}${PARTIAL_SUFFIX}`, + ); + const candidatePath = join(parentPath, name); + fileHandle = await open( + partialPath, + fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL, + 0o600, + ); + + return { + writeAll: (buffer, position) => writeAllFileHandle(fileHandle!, buffer, position), + async syncAndVerify(expectedSize) { + await fileHandle!.sync(); + writtenEntry = await fileHandle!.stat(); + assertWrittenEntry(writtenEntry, expectedSize); + assertSameEntry(await lstat(partialPath!), writtenEntry, "artifact_partial_unsafe"); + }, + async publish() { + if (!writtenEntry) throw new Error("Artifact must be verified before publication."); + try { + await publishLink(partialPath!, candidatePath); + assertSameEntry( + await lstat(candidatePath), + writtenEntry, + "artifact_destination_publish_failed", + ); + await unlink(partialPath!).catch(() => undefined); + partialPath = undefined; + } catch (error) { + if (isNodeError(error) && error.code === "EEXIST") throw destinationExistsError(); + throw error; + } + }, + async close() { + await fileHandle?.close().catch(() => undefined); + if (partialPath) await unlink(partialPath).catch(() => undefined); + closeWindowsHandles(pinnedHandles); + }, + }; + } catch (error) { + await fileHandle?.close().catch(() => undefined); + if (partialPath) await unlink(partialPath).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; + + 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 { + const api = windowsApi(); + for (const handle of handles.reverse()) api.CloseHandle(handle); +} diff --git a/src/artifact-secure-filesystem.ts b/src/artifact-secure-filesystem.ts new file mode 100644 index 00000000..23cf6235 --- /dev/null +++ b/src/artifact-secure-filesystem.ts @@ -0,0 +1,47 @@ +import { link } from "node:fs/promises"; +import { ArtifactError } from "./artifact-error.js"; + +export type ArtifactPublishLink = typeof link; + +export interface SecureArtifactTarget { + writeAll(buffer: Buffer, position: number): Promise; + syncAndVerify(expectedSize: number): Promise; + publish(): Promise; + close(): Promise; +} + +export interface SecureArtifactTargetOptions { + workspaceRoot: string; + parentParts: readonly string[]; + name: string; + publishLink: ArtifactPublishLink; +} + +const SUPPORTED_PLATFORMS = new Set(["linux", "darwin", "win32"]); + +export function isSecureArtifactPlatformSupported( + platform: NodeJS.Platform = process.platform, +): boolean { + return SUPPORTED_PLATFORMS.has(platform); +} + +export async function openSecureArtifactTarget( + options: SecureArtifactTargetOptions, +): Promise { + if (process.platform === "linux") { + const { openLinuxArtifactTarget } = await import("./artifact-secure-filesystem-linux.js"); + return openLinuxArtifactTarget(options); + } + if (process.platform === "darwin") { + const { openDarwinArtifactTarget } = await import("./artifact-secure-filesystem-darwin.js"); + return openDarwinArtifactTarget(options); + } + if (process.platform === "win32") { + const { openWindowsArtifactTarget } = await import("./artifact-secure-filesystem-windows.js"); + return openWindowsArtifactTarget(options); + } + throw new ArtifactError( + "artifact_platform_unsupported", + "Native file download requires secure platform filesystem primitives.", + ); +} diff --git a/src/artifact-tools.ts b/src/artifact-tools.ts index 4734f095..639e1c97 100644 --- a/src/artifact-tools.ts +++ b/src/artifact-tools.ts @@ -1,19 +1,15 @@ -import { createHash, randomUUID } from "node:crypto"; -import { constants as fsConstants } from "node:fs"; -import { - link, - lstat, - mkdir, - open, - readdir, - unlink, - type FileHandle, -} from "node:fs/promises"; -import { isAbsolute, join, normalize, sep } from "node:path"; +import { createHash } from "node:crypto"; +import { link } from "node:fs/promises"; +import { isAbsolute, normalize, sep } from "node:path"; 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 { + isSecureArtifactPlatformSupported, + openSecureArtifactTarget, + type SecureArtifactTarget, +} from "./artifact-secure-filesystem.js"; import type { ServerConfig } from "./config.js"; import { describeIncomingArtifactValue, @@ -29,13 +25,6 @@ const ARTIFACT_WRITE_ANNOTATIONS = { idempotentHint: false, openWorldHint: true, }; -const NO_FOLLOW = fsConstants.O_NOFOLLOW ?? 0; -const DIRECTORY_FLAGS = fsConstants.O_RDONLY | (fsConstants.O_DIRECTORY ?? 0) | NO_FOLLOW; -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 openAIFileReferenceInputSchema = z.strictObject({ download_url: z.string(), @@ -67,13 +56,7 @@ export interface DownloadIncomingArtifactResult { export function isArtifactDownloadSupportedPlatform( platform: NodeJS.Platform = process.platform, ): boolean { - return ARTIFACT_DOWNLOAD_PLATFORMS.has(platform); -} - -interface SecureDestinationDirectory { - handle: FileHandle; - anchorPath: string; - close(): Promise; + return isSecureArtifactPlatformSupported(platform); } interface ArtifactDestination { @@ -161,7 +144,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) { @@ -179,10 +162,7 @@ export async function downloadIncomingArtifact({ const destination = normalizeArtifactDestination(path); const opened = await registry.open(file); - let workspaceHandle: FileHandle | undefined; - let destinationDirectory: SecureDestinationDirectory | undefined; - let partialPath: string | undefined; - let handle: FileHandle | undefined; + let target: SecureArtifactTarget | undefined; try { if (opened.size !== undefined && opened.size > maxFileBytes) { @@ -192,26 +172,12 @@ export async function downloadIncomingArtifact({ ); } - workspaceHandle = await openDirectoryNoFollow( + target = await openSecureArtifactTarget({ workspaceRoot, - "artifact_workspace_unsafe", - "Selected workspace root is not a real directory.", - ); - destinationDirectory = await prepareDestinationDirectory( - workspaceHandle, - destination.parentParts, - ); - await cleanupStalePartials(destinationDirectory); - - partialPath = join( - destinationDirectory.anchorPath, - `${PARTIAL_PREFIX}${randomUUID()}${PARTIAL_SUFFIX}`, - ); - handle = await open( - partialPath, - fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | NO_FOLLOW, - 0o600, - ); + parentParts: destination.parentParts, + name: destination.name, + publishLink, + }); const hash = createHash("sha256"); let size = 0; @@ -223,7 +189,7 @@ export async function downloadIncomingArtifact({ "Native file exceeds the configured per-file limit.", ); } - await writeAll(handle, chunk, size); + await target.writeAll(chunk, size); hash.update(chunk); size += chunk.length; } @@ -235,39 +201,8 @@ export async function downloadIncomingArtifact({ ); } - await handle.sync(); - const writtenEntry = await handle.stat(); - if (!writtenEntry.isFile() || writtenEntry.size !== size) { - throw new ArtifactError( - "artifact_write_integrity_failed", - "Native file could not be verified before publication.", - ); - } - - const partialEntry = await lstat(partialPath); - if ( - partialEntry.isSymbolicLink() - || !partialEntry.isFile() - || partialEntry.dev !== writtenEntry.dev - || partialEntry.ino !== writtenEntry.ino - || partialEntry.size !== writtenEntry.size - ) { - throw new ArtifactError( - "artifact_partial_unsafe", - "Native file partial changed before publication.", - ); - } - - await publishDestination( - destinationDirectory, - partialPath, - destination.name, - writtenEntry, - handle, - publishLink, - ); - await unlink(partialPath).catch(() => undefined); - partialPath = undefined; + await target.syncAndVerify(size); + await target.publish(); return { path: destination.path, @@ -278,10 +213,7 @@ export async function downloadIncomingArtifact({ opened.stream.destroy(); throw error; } finally { - await handle?.close().catch(() => undefined); - if (partialPath) await unlink(partialPath).catch(() => undefined); - await destinationDirectory?.close().catch(() => undefined); - await workspaceHandle?.close().catch(() => undefined); + await target?.close().catch(() => undefined); } } @@ -341,43 +273,8 @@ function artifactToolResponse(result: { path: string }) { }; } -async function openDirectoryNoFollow( - path: string, - code: string, - message: string, -): Promise { - let handle: FileHandle | undefined; - try { - handle = await open(path, DIRECTORY_FLAGS); - await assertDirectoryHandle(handle); - return handle; - } catch (error) { - await handle?.close().catch(() => undefined); - if (error instanceof ArtifactError) throw error; - throw new ArtifactError(code, message); - } -} - -async function assertDirectoryHandle(handle: FileHandle): Promise { - const entry = await handle.stat(); - if (!entry.isDirectory()) { - throw new ArtifactError( - "artifact_directory_unsafe", - "Artifact destination parent is not a directory.", - ); - } -} - -function descriptorDirectoryPath(handle: FileHandle): string { - if (isArtifactDownloadSupportedPlatform()) return `/proc/self/fd/${handle.fd}`; - throw new ArtifactError( - "artifact_platform_unsupported", - "Native file download requires descriptor-anchored directory operations on this platform.", - ); -} - function normalizeArtifactDestination(value: string): ArtifactDestination { - const rawParts = value.split(sep); + const rawParts = process.platform === "win32" ? value.split(/[\\/]/) : value.split(sep); if ( !value || value.includes("\u0000") @@ -404,6 +301,7 @@ function normalizeArtifactDestination(value: string): ArtifactDestination { } const parts = normalized.split(sep); + if (process.platform === "win32") validateWindowsDestinationParts(parts); const name = parts.at(-1); if (!name || name === "." || name === "..") { throw new ArtifactError( @@ -413,162 +311,28 @@ function normalizeArtifactDestination(value: string): ArtifactDestination { } return { - path: normalized, + path: parts.join("/"), parentParts: parts.slice(0, -1), name, }; } -async function prepareDestinationDirectory( - rootHandle: FileHandle, - parentParts: readonly string[], -): Promise { - const openedHandles: FileHandle[] = []; - let parentHandle = rootHandle; - let parentAnchor = descriptorDirectoryPath(rootHandle); - - try { - for (const part of parentParts) { - const child = await ensureWorkspaceChildDirectory( - parentHandle, - parentAnchor, - part, - ); - openedHandles.push(child); - parentHandle = child; - parentAnchor = descriptorDirectoryPath(child); - } - - return { - handle: parentHandle, - anchorPath: parentAnchor, - async close() { - for (const handle of openedHandles.reverse()) { - await handle.close().catch(() => undefined); - } - }, - }; - } catch (error) { - for (const handle of openedHandles.reverse()) { - await handle.close().catch(() => undefined); - } - throw error; - } -} - -async function ensureWorkspaceChildDirectory( - parentHandle: FileHandle, - parentAnchor: string, - name: string, -): Promise { - await assertDirectoryHandle(parentHandle); - const path = join(parentAnchor, name); - try { - await mkdir(path, { mode: 0o755 }); - } catch (error) { - if (!isNodeError(error) || error.code !== "EEXIST") throw error; - } - - return openDirectoryNoFollow( - path, - "artifact_destination_parent_unsafe", - "Artifact destination parent must be a real directory inside the workspace.", - ); -} - -async function publishDestination( - directory: SecureDestinationDirectory, - partialPath: string, - filename: string, - writtenEntry: Awaited>, - handle: FileHandle, - publishLink: typeof link, -): Promise { - await assertDirectoryHandle(directory.handle); - const candidate = join(directory.anchorPath, filename); - try { - await publishLink(partialPath, candidate); - assertPublishedArtifactEntry(await lstat(candidate), writtenEntry); - } catch (error) { - if (isNodeError(error) && error.code === "EEXIST") { - throw new ArtifactError( - "artifact_destination_exists", - "Artifact destination already exists.", - ); - } - // Once the destination path exists, never unlink it during failure cleanup. - // Another process may have replaced that path after publication, and a - // path-based verification followed by unlink would introduce another race. - throw error; - } -} - -function assertPublishedArtifactEntry( - entry: Awaited>, - writtenEntry: Awaited>, -): void { - if ( - entry.isSymbolicLink() - || !entry.isFile() - || entry.dev !== writtenEntry.dev - || entry.ino !== writtenEntry.ino - || entry.size !== writtenEntry.size - ) { - throw new ArtifactError( - "artifact_destination_publish_failed", - "Published artifact did not match the verified download.", - ); - } -} - -async function cleanupStalePartials( - directory: SecureDestinationDirectory, -): Promise { - await assertDirectoryHandle(directory.handle); - const entries = await readdir(directory.anchorPath, { withFileTypes: true }); - let inspected = 0; - const cutoff = Date.now() - STALE_PARTIAL_AGE_MS; - for (const entry of entries) { - if (inspected >= MAX_STALE_PARTIAL_CLEANUP) break; - if ( - !entry.name.startsWith(PARTIAL_PREFIX) - || !entry.name.endsWith(PARTIAL_SUFFIX) - ) continue; - inspected += 1; - - const path = join(directory.anchorPath, entry.name); - const metadata = await lstatOrUndefined(path); +function validateWindowsDestinationParts(parts: readonly string[]): void { + const reservedDevice = /^(?:CON|PRN|AUX|NUL|CLOCK\$|CONIN\$|CONOUT\$|COM[1-9¹²³]|LPT[1-9¹²³])(?:\.|$)/i; + const forbiddenCharacter = /[<>:"|?*\u0000-\u001f]/; + for (const part of parts) { if ( - !metadata - || metadata.isSymbolicLink() - || !metadata.isFile() - || metadata.mtimeMs >= cutoff - || (process.getuid?.() !== undefined && metadata.uid !== process.getuid?.()) - ) continue; - await unlink(path).catch(() => undefined); - } -} - -async function writeAll( - handle: FileHandle, - buffer: Buffer, - position: number, -): Promise { - let offset = 0; - while (offset < buffer.length) { - const { bytesWritten } = await handle.write( - buffer, - offset, - buffer.length - offset, - position + offset, - ); - if (bytesWritten <= 0) { + !part + || part.endsWith(".") + || part.endsWith(" ") + || forbiddenCharacter.test(part) + || reservedDevice.test(part) + ) { throw new ArtifactError( - "artifact_short_write", - "Native file was not fully written.", + "artifact_destination_invalid", + "Artifact destination contains a Windows-reserved path segment.", ); } - offset += bytesWritten; } } @@ -597,16 +361,3 @@ function incomingStreamChunk(value: unknown): Buffer { "Incoming artifact stream yielded a value that is not bytes or text.", ); } - -async function lstatOrUndefined(path: string) { - try { - return await lstat(path); - } catch (error) { - if (isNodeError(error) && error.code === "ENOENT") return undefined; - throw error; - } -} - -function isNodeError(error: unknown): error is NodeJS.ErrnoException { - return error instanceof Error && "code" in error; -}