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", + ); + }); });