From 233fe5f00dba457847d9a08102ca812b8ef039f0 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Thu, 13 Aug 2026 23:18:34 +0900 Subject: [PATCH] fix(packaging): skip filesystem links during mode normalization --- scripts/prepare-package.ts | 34 +++++++---- tests/install-scripts.test.ts | 108 ++++++++++++++++++++++++++++++++++ 2 files changed, 131 insertions(+), 11 deletions(-) diff --git a/scripts/prepare-package.ts b/scripts/prepare-package.ts index 8e42c42f9..4ac334be6 100644 --- a/scripts/prepare-package.ts +++ b/scripts/prepare-package.ts @@ -1,31 +1,43 @@ -import { chmodSync, existsSync, readdirSync, statSync } from "node:fs"; +import { chmodSync, existsSync, lstatSync, readdirSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { generateCompatibilityVersionManifest } from "./generate-compatibility-version"; const root = dirname(fileURLToPath(new URL("../package.json", import.meta.url))); -function chmodIfExists(path: string, mode: number): void { - if (!existsSync(path)) return; +function applyMode(path: string, mode: number): void { try { chmodSync(path, mode); } catch { /* best-effort for read-only filesystems */ } } +function chmodIfRegularEntry(path: string, mode: number): void { + if (!existsSync(path)) return; + const st = lstatSync(path); + if (st.isSymbolicLink()) return; + applyMode(path, mode); +} + function chmodTree(path: string): void { if (!existsSync(path)) return; - const st = statSync(path); + const st = lstatSync(path); + if (st.isSymbolicLink()) return; if (st.isDirectory()) { - chmodIfExists(path, 0o755); + applyMode(path, 0o755); for (const entry of readdirSync(path)) chmodTree(join(path, entry)); return; } - chmodIfExists(path, 0o644); + applyMode(path, 0o644); +} + +export function normalizePackageModes(packageRoot: string): void { + chmodIfRegularEntry(join(packageRoot, "bin", "ocx.mjs"), 0o755); + chmodIfRegularEntry(join(packageRoot, "bin", "package-main.mjs"), 0o644); + chmodTree(join(packageRoot, "gui", "dist")); } // Generate the exact CL-00 implementation manifest immediately before package // assembly. The output stays untracked to avoid a self-referential digest, but // package.json already ships src/** so the generated artifact is embedded. -generateCompatibilityVersionManifest(root); - -chmodIfExists(join(root, "bin", "ocx.mjs"), 0o755); -chmodIfExists(join(root, "bin", "package-main.mjs"), 0o644); -chmodTree(join(root, "gui", "dist")); +if (import.meta.main) { + generateCompatibilityVersionManifest(root); + normalizePackageModes(root); +} diff --git a/tests/install-scripts.test.ts b/tests/install-scripts.test.ts index c3bf3da6a..f3cbde242 100644 --- a/tests/install-scripts.test.ts +++ b/tests/install-scripts.test.ts @@ -1,6 +1,19 @@ import { describe, expect, setDefaultTimeout, test } from "bun:test"; import { spawnSync } from "node:child_process"; +import { + chmodSync, + lstatSync, + mkdirSync, + mkdtempSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { fileURLToPath } from "node:url"; +import { normalizePackageModes } from "../scripts/prepare-package"; // Windows CI runners spawn Node/Bun child processes slowly ("Slow filesystem detected"); // the package-main import test measured 9.4s there vs bun's 5s default. Same remedy as @@ -10,6 +23,15 @@ setDefaultTimeout(30_000); const root = new URL("../", import.meta.url); const repoRoot = fileURLToPath(root); +function mode(path: string): number { + return statSync(path).mode & 0o777; +} + +function removeModeFixture(path: string): void { + try { chmodSync(path, 0o700); } catch { /* best-effort cleanup */ } + try { rmSync(path, { recursive: true, force: true }); } catch { /* best-effort cleanup */ } +} + async function readText(path: string): Promise { return await Bun.file(new URL(path, root)).text(); } @@ -115,4 +137,90 @@ describe("install scripts", () => { expect(script).toContain("createdAt,databaseId,headSha,status,url"); expect(script).toContain("await watchRun(releaseRun.databaseId)"); }); + + test.skipIf(process.platform === "win32")( + "package mode normalization skips direct and nested filesystem links", + () => { + const fixtureRoot = mkdtempSync(join(tmpdir(), "ocx-package-modes-")); + const packageRoot = join(fixtureRoot, "package"); + const externalRoot = join(fixtureRoot, "external"); + const externalLauncher = join(externalRoot, "package-main.mjs"); + const externalDirectory = join(externalRoot, "dashboard"); + const externalAsset = join(externalDirectory, "asset.js"); + + try { + mkdirSync(join(packageRoot, "bin"), { recursive: true }); + mkdirSync(join(packageRoot, "gui", "dist", "nested"), { recursive: true }); + mkdirSync(externalDirectory, { recursive: true }); + + writeFileSync(join(packageRoot, "bin", "ocx.mjs"), "launcher"); + writeFileSync(join(packageRoot, "gui", "dist", "asset.js"), "asset"); + writeFileSync(join(packageRoot, "gui", "dist", "nested", "chunk.js"), "chunk"); + writeFileSync(externalLauncher, "external launcher"); + writeFileSync(externalAsset, "external asset"); + + chmodSync(join(packageRoot, "bin", "ocx.mjs"), 0o600); + chmodSync(join(packageRoot, "gui", "dist"), 0o700); + chmodSync(join(packageRoot, "gui", "dist", "asset.js"), 0o600); + chmodSync(join(packageRoot, "gui", "dist", "nested"), 0o700); + chmodSync(join(packageRoot, "gui", "dist", "nested", "chunk.js"), 0o600); + chmodSync(externalLauncher, 0o600); + chmodSync(externalDirectory, 0o700); + chmodSync(externalAsset, 0o600); + + const launcherLink = join(packageRoot, "bin", "package-main.mjs"); + const nestedDirectoryLink = join(packageRoot, "gui", "dist", "nested", "external"); + symlinkSync(externalLauncher, launcherLink, "file"); + symlinkSync(externalDirectory, nestedDirectoryLink, "dir"); + + normalizePackageModes(packageRoot); + + expect(lstatSync(launcherLink).isSymbolicLink()).toBeTrue(); + expect(lstatSync(nestedDirectoryLink).isSymbolicLink()).toBeTrue(); + expect(mode(externalLauncher)).toBe(0o600); + expect(mode(externalDirectory)).toBe(0o700); + expect(mode(externalAsset)).toBe(0o600); + expect(mode(join(packageRoot, "bin", "ocx.mjs"))).toBe(0o755); + expect(mode(join(packageRoot, "gui", "dist"))).toBe(0o755); + expect(mode(join(packageRoot, "gui", "dist", "asset.js"))).toBe(0o644); + expect(mode(join(packageRoot, "gui", "dist", "nested"))).toBe(0o755); + expect(mode(join(packageRoot, "gui", "dist", "nested", "chunk.js"))).toBe(0o644); + } finally { + try { chmodSync(externalLauncher, 0o600); } catch { /* best-effort cleanup */ } + try { chmodSync(externalDirectory, 0o700); } catch { /* best-effort cleanup */ } + try { chmodSync(externalAsset, 0o600); } catch { /* best-effort cleanup */ } + removeModeFixture(fixtureRoot); + } + }, + ); + + test("package mode normalization does not traverse a linked output root", () => { + const fixtureRoot = mkdtempSync(join(tmpdir(), "ocx-package-root-link-")); + const packageRoot = join(fixtureRoot, "package"); + const externalOutput = join(fixtureRoot, "external-output"); + const externalAsset = join(externalOutput, "asset.js"); + + try { + mkdirSync(join(packageRoot, "gui"), { recursive: true }); + mkdirSync(externalOutput, { recursive: true }); + writeFileSync(externalAsset, "external asset"); + chmodSync(externalOutput, 0o555); + chmodSync(externalAsset, 0o444); + + const outputLink = join(packageRoot, "gui", "dist"); + symlinkSync(externalOutput, outputLink, process.platform === "win32" ? "junction" : "dir"); + const directoryModeBefore = mode(externalOutput); + const assetModeBefore = mode(externalAsset); + + normalizePackageModes(packageRoot); + + expect(lstatSync(outputLink).isSymbolicLink()).toBeTrue(); + expect(mode(externalOutput)).toBe(directoryModeBefore); + expect(mode(externalAsset)).toBe(assetModeBefore); + } finally { + try { chmodSync(externalAsset, 0o600); } catch { /* best-effort cleanup */ } + try { chmodSync(externalOutput, 0o700); } catch { /* best-effort cleanup */ } + removeModeFixture(fixtureRoot); + } + }); });