From c8e66c704a70f5133922aa1bd1a3ba42959093ba Mon Sep 17 00:00:00 2001 From: Dery Ferdika <114888155+DeryFerd@users.noreply.github.com> Date: Wed, 7 Oct 2026 00:46:52 +0700 Subject: [PATCH] fix(artifacts): make extraction output tree work on Windows (#753) SafeOutputTree.create() called fchmod(0o700) on a directory handle and commit() fsynced a directory handle. Both are POSIX-only: Windows rejects them with EPERM ("operation not permitted, fchmod"/"fsync"), so every extract_artifact call failed with a generic io error and produced no output. Skip the redundant directory chmod and directory fsync on Windows. mkdir already applies the requested mode, and file contents are synced in write(). Add a Windows regression test and run it, plus the archive-safety test, in the curated Windows CI lane so the behavior is covered. --- .github/workflows/ci.yml | 2 + src/artifacts/SafeOutputTree.ts | 43 +++++++++++++------ .../filesystem/safeOutputTree.test.ts | 14 ++++++ 3 files changed, 47 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8a8679e1e2..1a9e2219a7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -365,6 +365,8 @@ jobs: run: >- npx vitest run --coverage.enabled=false tests/boundary/filesystem/binaryTarget.test.ts + tests/boundary/filesystem/safeOutputTree.test.ts + tests/boundary/providers/native/artifactProvider.archiveSafety.test.ts tests/boundary/filesystem/setupUnavailableProvider.test.ts tests/boundary/filesystem/asarInventoryFreshness.test.ts tests/composition/javascript-applications/javascriptArtifactReconstruction.test.ts diff --git a/src/artifacts/SafeOutputTree.ts b/src/artifacts/SafeOutputTree.ts index ee07136310..67731f4431 100644 --- a/src/artifacts/SafeOutputTree.ts +++ b/src/artifacts/SafeOutputTree.ts @@ -43,12 +43,24 @@ export class SafeOutputTree { status: "not-required", }; - private constructor(outputRoot: string) { + private constructor( + outputRoot: string, + private readonly platform: NodeJS.Platform, + ) { this.#outputRoot = outputRoot; } - /** Exclusively create the absent destination as this operation's owned tree. */ - static async create(outputRoot: string): Promise { + /** + * Exclusively create the absent destination as this operation's owned tree. + * + * POSIX mode bits and directory `fchmod`/`fsync` have no Windows equivalent: + * `mkdir` already applies the requested mode, so the redundant handle + * `chmod` is skipped there to avoid an `EPERM` on the directory descriptor. + */ + static async create( + outputRoot: string, + platform: NodeJS.Platform = process.platform, + ): Promise { if (!isAbsolute(outputRoot)) throw new ArtifactReaderFailure( "path", @@ -78,16 +90,18 @@ export class SafeOutputTree { throw cause; }); try { - const stagingHandle = await open( - canonicalOutput, - constants.O_RDONLY | constants.O_DIRECTORY, - ); - try { - await stagingHandle.chmod(0o700); - } finally { - await stagingHandle.close(); + if (platform !== "win32") { + const stagingHandle = await open( + canonicalOutput, + constants.O_RDONLY | constants.O_DIRECTORY, + ); + try { + await stagingHandle.chmod(0o700); + } finally { + await stagingHandle.close(); + } } - return new SafeOutputTree(canonicalOutput); + return new SafeOutputTree(canonicalOutput, platform); } catch (cause: unknown) { await rm(canonicalOutput, { recursive: true, force: true }); throw cause; @@ -162,6 +176,11 @@ export class SafeOutputTree { /** Sync the owned output tree and prevent further writes through this instance. */ async commit(): Promise { this.#assertWritable(); + // Windows has no directory fsync; file contents are already synced in write(). + if (this.platform === "win32") { + this.#published = true; + return; + } const parent = await open( dirname(this.#outputRoot), constants.O_RDONLY | constants.O_DIRECTORY, diff --git a/tests/boundary/filesystem/safeOutputTree.test.ts b/tests/boundary/filesystem/safeOutputTree.test.ts index 541b073e36..b22636dc00 100644 --- a/tests/boundary/filesystem/safeOutputTree.test.ts +++ b/tests/boundary/filesystem/safeOutputTree.test.ts @@ -81,4 +81,18 @@ describe("safe artifact output tree", () => { residualPaths: [], }); }); + + it("publishes without POSIX-only directory chmod or fsync on Windows", async () => { + const parent = await createTestTempDirectory("rea-safe-output-"); + const output = join(parent, "published"); + const tree = await SafeOutputTree.create(output, "win32"); + const bytes = Buffer.from("windows output"); + const digest = createHash("sha256").update(bytes).digest("hex"); + + await tree.write("nested/file.txt", Readable.from(bytes), digest); + await tree.commit(); + expect(await readFile(join(output, "nested", "file.txt"), "utf8")).toBe( + "windows output", + ); + }); });