diff --git a/__tests__/scripts/ghost-assets/decryptGhostAssets.test.ts b/__tests__/scripts/ghost-assets/decryptGhostAssets.test.ts new file mode 100644 index 0000000..5f7a45d --- /dev/null +++ b/__tests__/scripts/ghost-assets/decryptGhostAssets.test.ts @@ -0,0 +1,354 @@ +import fs from "node:fs" +import os from "node:os" +import path from "node:path" +import { + TextReader, + Uint8ArrayWriter, + ZipWriter, +} from "@zip.js/zip.js/index-native.js" +import { afterEach, describe, expect, it, vi } from "vitest" +import { + GHOST_ARCHIVES, + runGhostAssetDecryption, +} from "@/scripts/ghost-assets/decryptGhostAssets" +import { extractEncryptedZipArchive } from "@/scripts/ghost-assets/extractEncryptedZipArchive" + +const temporaryDirectories: string[] = [] + +type EncryptedArchiveEntry = { + content?: string + directory?: boolean + filename: string +} + +const DEFAULT_ENCRYPTED_ARCHIVE_ENTRIES: readonly EncryptedArchiveEntry[] = [ + { + directory: true, + filename: "fullPage_js_extensions_bundle/", + }, + { + directory: true, + filename: "fullPage_js_extensions_bundle/cinematic/", + }, + { + content: "cinematic", + filename: "fullPage_js_extensions_bundle/cinematic/effect.js", + }, + { + directory: true, + filename: "fullPage_js_extensions_bundle/cards/", + }, + { + content: "cards", + filename: "fullPage_js_extensions_bundle/cards/card.js", + }, +] + +const createTemporaryDirectory = () => { + const temporaryDirectory = fs.mkdtempSync( + path.join(os.tmpdir(), "doctor-derek-ghost-assets-"), + ) + temporaryDirectories.push(temporaryDirectory) + return temporaryDirectory +} + +const createEncryptedArchive = async ( + archivePath: string, + password: string, + entries: readonly EncryptedArchiveEntry[] = DEFAULT_ENCRYPTED_ARCHIVE_ENTRIES, +) => { + const archiveWriter = new ZipWriter(new Uint8ArrayWriter(), { + password, + useWebWorkers: false, + zipCrypto: true, + }) + + for (const entry of entries) + await archiveWriter.add( + entry.filename, + entry.content === undefined ? undefined : new TextReader(entry.content), + { directory: entry.directory }, + ) + + fs.writeFileSync(archivePath, await archiveWriter.close()) +} + +const createLogger = () => ({ + error: vi.fn(), + log: vi.fn(), + warn: vi.fn(), +}) + +describe("ghost asset decryption", () => { + afterEach(() => { + for (const temporaryDirectory of temporaryDirectories.splice(0)) + fs.rmSync(temporaryDirectory, { force: true, recursive: true }) + }) + + it("preserves the canonical archive routing configuration", () => { + expect(GHOST_ARCHIVES).toEqual([ + { + name: "FullPage Extensions", + zipPath: path.join( + process.cwd(), + "ghost_assets/fullPage_js_extensions_bundle.zip", + ), + targetDir: path.join(process.cwd(), "vendor"), + junkPaths: false, + }, + { + name: "Restora Fonts", + zipPath: path.join(process.cwd(), "ghost_assets/fonts.zip"), + targetDir: path.join(process.cwd(), "vendor/fonts"), + junkPaths: true, + }, + ]) + }) + + it("bypasses all archive work when the environment secret is absent", async () => { + const archiveExists = vi.fn() + const createDirectory = vi.fn() + const directoryExists = vi.fn() + const extractArchive = vi.fn() + + const exitCode = await runGhostAssetDecryption({ + assetKey: "", + dependencies: { + archiveExists, + createDirectory, + directoryExists, + extractArchive, + logger: createLogger(), + }, + }) + + expect(exitCode).toBe(0) + expect(archiveExists).not.toHaveBeenCalled() + expect(directoryExists).not.toHaveBeenCalled() + expect(createDirectory).not.toHaveBeenCalled() + expect(extractArchive).not.toHaveBeenCalled() + }) + + it("routes both archives to extraction in deterministic order", async () => { + const createDirectory = vi.fn() + const extractArchive = vi.fn() + + const exitCode = await runGhostAssetDecryption({ + assetKey: "test-password", + dependencies: { + archiveExists: () => true, + createDirectory, + directoryExists: () => false, + extractArchive, + logger: createLogger(), + }, + }) + + expect(exitCode).toBe(0) + expect(createDirectory.mock.calls).toEqual( + GHOST_ARCHIVES.map(({ targetDir }) => [targetDir]), + ) + expect(extractArchive.mock.calls).toEqual( + GHOST_ARCHIVES.map((archive) => [archive, "test-password"]), + ) + }) + + it("warns for a missing archive while extracting available archives", async () => { + const logger = createLogger() + const extractArchive = vi.fn() + const missingArchive = GHOST_ARCHIVES[0] + const availableArchive = GHOST_ARCHIVES[1] + + const exitCode = await runGhostAssetDecryption({ + archives: [missingArchive, availableArchive], + assetKey: "test-password", + dependencies: { + archiveExists: (archivePath) => + archivePath === availableArchive.zipPath, + directoryExists: () => true, + extractArchive, + logger, + }, + }) + + expect(exitCode).toBe(0) + expect(logger.warn).toHaveBeenCalledExactlyOnceWith( + `⚠️ Warning: Archive not found at ${missingArchive.zipPath}`, + ) + expect(extractArchive).toHaveBeenCalledExactlyOnceWith( + availableArchive, + "test-password", + ) + }) + + it("preserves or flattens paths according to each archive contract", async () => { + const temporaryDirectory = createTemporaryDirectory() + const archivePath = path.join(temporaryDirectory, "assets.zip") + const preservedTarget = path.join(temporaryDirectory, "preserved") + const flattenedTarget = path.join(temporaryDirectory, "flattened") + + await createEncryptedArchive(archivePath, "test-password") + await extractEncryptedZipArchive( + { + name: "Preserved paths", + zipPath: archivePath, + targetDir: preservedTarget, + junkPaths: false, + }, + "test-password", + ) + await extractEncryptedZipArchive( + { + name: "Flattened paths", + zipPath: archivePath, + targetDir: flattenedTarget, + junkPaths: true, + }, + "test-password", + ) + + expect( + fs.readFileSync( + path.join( + preservedTarget, + "fullPage_js_extensions_bundle/cinematic/effect.js", + ), + "utf8", + ), + ).toBe("cinematic") + expect( + fs.readFileSync( + path.join( + preservedTarget, + "fullPage_js_extensions_bundle/cards/card.js", + ), + "utf8", + ), + ).toBe("cards") + expect( + fs.readFileSync(path.join(flattenedTarget, "effect.js"), "utf8"), + ).toBe("cinematic") + expect(fs.readFileSync(path.join(flattenedTarget, "card.js"), "utf8")).toBe( + "cards", + ) + }) + + it("handles shell-sensitive passwords as literal archive data", async () => { + const temporaryDirectory = createTemporaryDirectory() + const archivePath = path.join(temporaryDirectory, "literal-password.zip") + const targetDirectory = path.join(temporaryDirectory, "extracted") + const shellSensitivePassword = + "literal \"quotes\" 'apostrophes' $HOME $(touch owned) `ticks` ; & | < > %PATH% !" + + await createEncryptedArchive(archivePath, shellSensitivePassword) + await extractEncryptedZipArchive( + { + name: "Literal password", + zipPath: archivePath, + targetDir: targetDirectory, + junkPaths: true, + }, + shellSensitivePassword, + ) + + expect( + fs.readFileSync(path.join(targetDirectory, "effect.js"), "utf8"), + ).toBe("cinematic") + }) + + it("rejects archive entries that resolve outside the destination", async () => { + const temporaryDirectory = createTemporaryDirectory() + const archivePath = path.join(temporaryDirectory, "unsafe-path.zip") + const targetDirectory = path.join(temporaryDirectory, "extracted") + const escapedPath = path.join(temporaryDirectory, "escaped.txt") + + await createEncryptedArchive(archivePath, "test-password", [ + { content: "unsafe", filename: "..\\escaped.txt" }, + ]) + + await expect( + extractEncryptedZipArchive( + { + name: "Unsafe path", + zipPath: archivePath, + targetDir: targetDirectory, + junkPaths: false, + }, + "test-password", + ), + ).rejects.toThrow("Archive entry resolves outside its destination.") + expect(fs.existsSync(escapedPath)).toBe(false) + }) + + it("redacts extraction errors and returns a deterministic failure", async () => { + const secret = 'never expose $(this) ; & | "secret"' + const logger = createLogger() + const archive = { + name: "Failing archive", + zipPath: "failing.zip", + targetDir: "destination", + junkPaths: false, + } + + const exitCode = await runGhostAssetDecryption({ + archives: [archive], + assetKey: secret, + dependencies: { + archiveExists: () => true, + directoryExists: () => true, + extractArchive: () => { + throw new Error(`Library failure included ${secret}`) + }, + logger, + }, + }) + const emittedMessages = [ + ...logger.error.mock.calls, + ...logger.log.mock.calls, + ...logger.warn.mock.calls, + ] + .map(([message]) => String(message)) + .join("\n") + + expect(exitCode).toBe(1) + expect(logger.error.mock.calls).toEqual([ + ["❌ FATAL ERROR: Decryption failed."], + [ + "Possible causes: Wrong GHOST_ASSET_KEY_DOCTORDEREK_COM or invalid encrypted archive.", + ], + ]) + expect(emittedMessages).not.toContain(secret) + expect(emittedMessages).not.toContain("Library failure included") + }) + + it("converts a wrong archive password into the same fixed failure", async () => { + const temporaryDirectory = createTemporaryDirectory() + const archivePath = path.join(temporaryDirectory, "wrong-password.zip") + const targetDirectory = path.join(temporaryDirectory, "extracted") + const logger = createLogger() + + await createEncryptedArchive(archivePath, "correct-password") + + const exitCode = await runGhostAssetDecryption({ + archives: [ + { + name: "Wrong password", + zipPath: archivePath, + targetDir: targetDirectory, + junkPaths: true, + }, + ], + assetKey: "wrong-password", + dependencies: { logger }, + }) + + expect(exitCode).toBe(1) + expect(logger.error.mock.calls).toEqual([ + ["❌ FATAL ERROR: Decryption failed."], + [ + "Possible causes: Wrong GHOST_ASSET_KEY_DOCTORDEREK_COM or invalid encrypted archive.", + ], + ]) + expect(fs.existsSync(path.join(targetDirectory, "effect.js"))).toBe(false) + }) +}) diff --git a/package.json b/package.json index 77cc60a..6c56a32 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "@heroicons/react": "^2", "@rive-app/react-canvas-lite": "^4", "@xstate/react": "^6", + "@zip.js/zip.js": "^2", "motion": "^12", "next": "^16", "next-themes": "^0.4.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a48abaa..807380a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: '@xstate/react': specifier: ^6 version: 6.1.0(@types/react@19.2.18)(react@19.2.8)(xstate@5.32.5) + '@zip.js/zip.js': + specifier: ^2 + version: 2.8.60 motion: specifier: ^12 version: 12.43.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -1963,6 +1966,10 @@ packages: '@xtuc/long@4.2.2': resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} + '@zip.js/zip.js@2.8.60': + resolution: {integrity: sha512-pULv0waMlnKAUUxrsuAOa0ADONGRuhdHORtuXVgClNlScDB5YjImCV8DZrE1SicaROyE6MwGkH8CLJ+o4Nx07g==} + engines: {bun: '>=0.7.0', deno: '>=1.0.0', node: '>=18.0.0'} + acorn-import-phases@1.0.4: resolution: {integrity: sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==} engines: {node: '>=10.13.0'} @@ -6358,6 +6365,8 @@ snapshots: '@xtuc/long@4.2.2': {} + '@zip.js/zip.js@2.8.60': {} + acorn-import-phases@1.0.4(acorn@8.17.0): dependencies: acorn: 8.17.0 diff --git a/scripts/decrypt-assets.ts b/scripts/decrypt-assets.ts index 6b55fea..0b2facd 100644 --- a/scripts/decrypt-assets.ts +++ b/scripts/decrypt-assets.ts @@ -1,86 +1,3 @@ -import { execSync } from "child_process" -import { existsSync, mkdirSync } from "fs" -import { join } from "path" -import { getErrorMessage } from "../utils/errors" +import { runGhostAssetDecryption } from "./ghost-assets/decryptGhostAssets" -type GhostArchive = { - name: string - zipPath: string - targetDir: string - junkPaths: boolean -} - -const ASSET_KEY = process.env.GHOST_ASSET_KEY_DOCTORDEREK_COM - -const ARCHIVES: GhostArchive[] = [ - { - name: "FullPage Extensions", - zipPath: join( - __dirname, - "../ghost_assets/fullPage_js_extensions_bundle.zip", - ), - targetDir: join(__dirname, "../vendor"), - /** - * APPROVED EXCEPTION TO NO CODE COMMENT RULE: - * Keeps internal folder structure (e.g., /cinematic/) - */ - junkPaths: false, - }, - { - name: "Restora Fonts", - zipPath: join(__dirname, "../ghost_assets/fonts.zip"), - targetDir: join(__dirname, "../vendor/fonts"), - /** - * APPROVED EXCEPTION TO NO CODE COMMENT RULE: - * Flattens directory structure so webfont files - * land directly in vendor/fonts/ - */ - junkPaths: true, - }, -] - -console.log("=========================================") -console.log("🦝 MAPACHITO GHOST PIPELINE INITIATED 🦝") -console.log("=========================================") - -if (!ASSET_KEY) { - console.log("⚠️ GHOST_ASSET_KEY_DOCTORDEREK_COM not found in environment.") - console.log("⏩ Bypassing decryption (Open-source fallback mode active).") - console.log("=========================================") - process.exit(0) -} - -console.log("🔑 Asset Key detected. Commencing decryption...") - -try { - for (const archive of ARCHIVES) { - if (existsSync(archive.zipPath)) { - if (!existsSync(archive.targetDir)) { - console.log(`📁 Creating directory: ${archive.targetDir}`) - mkdirSync(archive.targetDir, { recursive: true }) - } - - console.log(`📦 Unzipping payload: ${archive.name}`) - const junkFlag = archive.junkPaths ? "-j " : "" - execSync( - `unzip -o -q ${junkFlag}-P "${ASSET_KEY}" "${archive.zipPath}" -d "${archive.targetDir}"`, - { stdio: "inherit" }, - ) - } else { - console.warn(`⚠️ Warning: Archive not found at ${archive.zipPath}`) - } - } - - console.log("✅ GHOST PIPELINE SUCCESS: Commercial assets injected.") - console.log("[$̲̅(̲̅ιοο̲̅)̲̅$̲̅] Proceeding with Vercel build...") - console.log("=========================================") -} catch (error) { - const message = getErrorMessage(error) - console.error("❌ FATAL ERROR: Decryption failed.") - console.error(message) - console.error( - "Possible causes: Wrong GHOST_ASSET_KEY_DOCTORDEREK_COM or missing unzip utility.", - ) - console.log("=========================================") - process.exit(1) -} +process.exitCode = await runGhostAssetDecryption() diff --git a/scripts/ghost-assets/decryptGhostAssets.ts b/scripts/ghost-assets/decryptGhostAssets.ts new file mode 100644 index 0000000..f90d553 --- /dev/null +++ b/scripts/ghost-assets/decryptGhostAssets.ts @@ -0,0 +1,123 @@ +import { existsSync, mkdirSync } from "node:fs" +import { join } from "node:path" +import { extractEncryptedZipArchive } from "./extractEncryptedZipArchive" + +export type GhostArchive = { + name: string + zipPath: string + targetDir: string + junkPaths: boolean +} + +type GhostAssetLogger = Pick + +type GhostAssetPipelineDependencies = { + archiveExists: (path: string) => boolean + createDirectory: (path: string) => void + directoryExists: (path: string) => boolean + extractArchive: ( + archive: GhostArchive, + assetKey: string, + ) => Promise | void + logger: GhostAssetLogger +} + +type RunGhostAssetDecryptionOptions = { + archives?: readonly GhostArchive[] + assetKey?: string + dependencies?: Partial +} + +const REPOSITORY_ROOT = join(__dirname, "../..") + +export const GHOST_ARCHIVES: readonly GhostArchive[] = [ + { + name: "FullPage Extensions", + zipPath: join( + REPOSITORY_ROOT, + "ghost_assets/fullPage_js_extensions_bundle.zip", + ), + targetDir: join(REPOSITORY_ROOT, "vendor"), + /** + * APPROVED EXCEPTION TO NO CODE COMMENT RULE: + * Keeps internal folder structure (e.g., /cinematic/) + */ + junkPaths: false, + }, + { + name: "Restora Fonts", + zipPath: join(REPOSITORY_ROOT, "ghost_assets/fonts.zip"), + targetDir: join(REPOSITORY_ROOT, "vendor/fonts"), + /** + * APPROVED EXCEPTION TO NO CODE COMMENT RULE: + * Flattens directory structure so webfont files + * land directly in vendor/fonts/ + */ + junkPaths: true, + }, +] + +const defaultDependencies: GhostAssetPipelineDependencies = { + archiveExists: existsSync, + createDirectory: (path) => mkdirSync(path, { recursive: true }), + directoryExists: existsSync, + extractArchive: extractEncryptedZipArchive, + logger: console, +} + +export const runGhostAssetDecryption = async ({ + archives = GHOST_ARCHIVES, + assetKey = process.env.GHOST_ASSET_KEY_DOCTORDEREK_COM, + dependencies: dependencyOverrides = {}, +}: RunGhostAssetDecryptionOptions = {}) => { + const dependencies = { ...defaultDependencies, ...dependencyOverrides } + + dependencies.logger.log("=========================================") + dependencies.logger.log("🦝 MAPACHITO GHOST PIPELINE INITIATED 🦝") + dependencies.logger.log("=========================================") + + if (!assetKey) { + dependencies.logger.log( + "⚠️ GHOST_ASSET_KEY_DOCTORDEREK_COM not found in environment.", + ) + dependencies.logger.log( + "⏩ Bypassing decryption (Open-source fallback mode active).", + ) + dependencies.logger.log("=========================================") + return 0 + } + + dependencies.logger.log("🔑 Asset Key detected. Commencing decryption...") + + try { + for (const archive of archives) { + if (dependencies.archiveExists(archive.zipPath)) { + if (!dependencies.directoryExists(archive.targetDir)) { + dependencies.logger.log(`📁 Creating directory: ${archive.targetDir}`) + dependencies.createDirectory(archive.targetDir) + } + + dependencies.logger.log(`📦 Unzipping payload: ${archive.name}`) + await dependencies.extractArchive(archive, assetKey) + } else { + dependencies.logger.warn( + `⚠️ Warning: Archive not found at ${archive.zipPath}`, + ) + } + } + + dependencies.logger.log( + "✅ GHOST PIPELINE SUCCESS: Commercial assets injected.", + ) + dependencies.logger.log("[$̲̅(̲̅ιοο̲̅)̲̅$̲̅] Proceeding with Vercel build...") + dependencies.logger.log("=========================================") + return 0 + } catch { + dependencies.logger.error("❌ FATAL ERROR: Decryption failed.") + dependencies.logger.error( + "Possible causes: Wrong GHOST_ASSET_KEY_DOCTORDEREK_COM or invalid encrypted archive.", + ) + dependencies.logger.log("=========================================") + return 1 + } +} diff --git a/scripts/ghost-assets/extractEncryptedZipArchive.ts b/scripts/ghost-assets/extractEncryptedZipArchive.ts new file mode 100644 index 0000000..2383cc4 --- /dev/null +++ b/scripts/ghost-assets/extractEncryptedZipArchive.ts @@ -0,0 +1,85 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises" +import { + basename, + dirname, + isAbsolute, + relative, + resolve, + sep, +} from "node:path" +import { + Uint8ArrayReader, + Uint8ArrayWriter, + ZipReader, +} from "@zip.js/zip.js/index-native.js" +import type { GhostArchive } from "./decryptGhostAssets" + +const resolveArchiveEntryPath = ( + targetDirectory: string, + entryName: string, + junkPaths: boolean, +) => { + const normalizedEntryName = entryName.replaceAll("\\", "/") + const outputName = junkPaths + ? basename(normalizedEntryName) + : normalizedEntryName + const resolvedTargetDirectory = resolve(targetDirectory) + const resolvedOutputPath = resolve( + resolvedTargetDirectory, + ...outputName.split("/"), + ) + const relativeOutputPath = relative( + resolvedTargetDirectory, + resolvedOutputPath, + ) + + if ( + isAbsolute(relativeOutputPath) || + relativeOutputPath === ".." || + relativeOutputPath.startsWith(`..${sep}`) + ) + throw new Error("Archive entry resolves outside its destination.") + + return resolvedOutputPath +} + +export const extractEncryptedZipArchive = async ( + archive: GhostArchive, + assetKey: string, +) => { + const archiveData = await readFile(archive.zipPath) + const zipReader = new ZipReader(new Uint8ArrayReader(archiveData), { + checkAmbiguity: true, + useWebWorkers: false, + }) + + try { + const entries = await zipReader.getEntries() + + for (const entry of entries) { + if (entry.directory && archive.junkPaths) continue + + const outputPath = resolveArchiveEntryPath( + archive.targetDir, + entry.filename, + archive.junkPaths, + ) + + if (entry.directory) { + await mkdir(outputPath, { recursive: true }) + continue + } + + const entryData = await entry.getData(new Uint8ArrayWriter(), { + checkAmbiguity: true, + password: assetKey, + useWebWorkers: false, + }) + + await mkdir(dirname(outputPath), { recursive: true }) + await writeFile(outputPath, entryData) + } + } finally { + await zipReader.close() + } +}