diff --git a/docs/ALPHA24_COMPLETION_REPORT.md b/docs/ALPHA24_COMPLETION_REPORT.md index 4c83a9f..8d34cee 100644 --- a/docs/ALPHA24_COMPLETION_REPORT.md +++ b/docs/ALPHA24_COMPLETION_REPORT.md @@ -11,7 +11,10 @@ - Application packages are discovered and installed into Aurora's Electron user-data `modules` directory. - The module registry is invalidated after install, update, and uninstall operations. - Package manifests are validated for identity, version, category, dependencies, conflicts, settings, Core/API compatibility, project contributions, and safe relative entry paths. -- Installation rejects symbolic links and paths that could escape the package or module destination. +- Native `.pac` files are ZIP-compressed Aurora packages with `manifest.json` at archive root. +- A `.pac` placed beside the AppImage or in its `modules` folder is discovered automatically and appears in the Modules screen. +- `.pac` inspection rejects encrypted entries, symbolic links, path traversal, absolute/drive paths, excessive entry counts, and expanded archives larger than 256 MiB before extraction. +- Installation uses staging plus rollback-safe replacement, so a failed update preserves the currently installed module. - Application choices in New Project come only from the installed-module registry. - Project actions and metadata summaries are declarative module contributions. Core no longer contains WordPress-specific admin or multisite presentation. - The `moduleMetadata` capability is the versioned bridge used by trusted module lifecycle hooks. @@ -41,13 +44,14 @@ npm run build npx electron-builder --linux AppImage ``` -Automated result: 6 test files and 23 tests passed. Coverage includes: +Automated result: 6 test files and 26 tests passed. Coverage includes: - Empty registry - Available local packages - Valid installation and registry refresh - Invalid and incompatible manifest rejection - Unsafe package path and symbolic-link rejection +- `.pac` discovery, installation, traversal rejection, and archive symlink rejection - Package update - Registry refresh after uninstall - Preservation of source packages and existing project data @@ -67,18 +71,17 @@ Live project smoke result for project `24`: ## Artifacts - Core AppImage: `/home/reaper/Documents/Codex/aurora/dist/final-alpha24/aurora-dockside-2.0.0-alpha.24.AppImage` -- WordPress package archive: `/home/reaper/Documents/Codex/aurora/dist/final-alpha24/aurora-module-wordpress-1.2.0.tar.gz` +- Directly installable WordPress package: `/home/reaper/Documents/Codex/aurora/dist/final-alpha24/aurora-module-wordpress-1.2.0.pac` - Directly installable unpacked WordPress package: `/home/reaper/Documents/Codex/aurora/packages/aurora-module-wordpress` SHA-256: ```text -fe700b5528ad7ddcdae43ced5651801370b2b09f8ed711fb447179281b6a9257 aurora-dockside-2.0.0-alpha.24.AppImage -942068c4eeb9b638f8aa6041dc8e5c15cec95fa6901fa24ed197221469ce37e9 aurora-module-wordpress-1.2.0.tar.gz +1964de899632f7dca86b5ee6485d5c2bc8dc0cffcc7a9de4479ea7eef25e9e76 aurora-dockside-2.0.0-alpha.24.AppImage +d2165af4b75ab888c6d35a750a449205123866231ba5e98e1cbe9bcaa8738f46 aurora-module-wordpress-1.2.0.pac ``` ## Known limitations -- The module picker installs unpacked package directories. Extract the `.tar.gz` before selecting it in **Install from folder**. - Clean-registry install/remove behavior was exercised through the real registry implementation in automated temporary-directory tests. The live GUI smoke used the already installed local WordPress package and project `24`. - AppImage systems without working FUSE can use `--appimage-extract` and launch `squashfs-root/AppRun --no-sandbox`. diff --git a/package-lock.json b/package-lock.json index ba01d8c..deb449d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,8 +14,10 @@ "@tanstack/react-query": "^5.101.4", "clsx": "^2.1.1", "electron-updater": "^6.8.9", + "extract-zip": "^2.0.1", "lucide-react": "^1.28.0", "tailwind-merge": "^3.6.0", + "yauzl": "^2.10.0", "zustand": "^5.0.14" }, "devDependencies": { @@ -29,6 +31,7 @@ "@types/node": "^22.19.1", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", + "@types/yauzl": "^2.10.3", "@vitejs/plugin-react": "^5.1.1", "@vitest/coverage-v8": "^4.1.10", "@vitest/ui": "^4.1.10", @@ -3164,8 +3167,8 @@ "version": "2.10.3", "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "devOptional": true, "license": "MIT", - "optional": true, "dependencies": { "@types/node": "*" } diff --git a/package.json b/package.json index 9de70e6..b932cb2 100644 --- a/package.json +++ b/package.json @@ -34,8 +34,10 @@ "@tanstack/react-query": "^5.101.4", "clsx": "^2.1.1", "electron-updater": "^6.8.9", + "extract-zip": "^2.0.1", "lucide-react": "^1.28.0", "tailwind-merge": "^3.6.0", + "yauzl": "^2.10.0", "zustand": "^5.0.14" }, "devDependencies": { @@ -49,6 +51,7 @@ "@types/node": "^22.19.1", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", + "@types/yauzl": "^2.10.3", "@vitejs/plugin-react": "^5.1.1", "@vitest/coverage-v8": "^4.1.10", "@vitest/ui": "^4.1.10", diff --git a/packages/aurora-module-wordpress/README.md b/packages/aurora-module-wordpress/README.md index cfebdb1..0d11010 100644 --- a/packages/aurora-module-wordpress/README.md +++ b/packages/aurora-module-wordpress/README.md @@ -1,5 +1,5 @@ # Aurora WordPress module -Local trusted extension package for Aurora Dockside 2.0.0-alpha.24. Install the unpacked package directory from **New Project → Install local module**. Its manifest contributes creation fields; its main-process lifecycle uses only the versioned Core context supplied by module API 1.0.0. +Local trusted extension package for Aurora Dockside 2.0.0-alpha.24. Install the distributed `.pac` file from **Modules → Install .pac or folder**. The unpacked package directory remains installable for development. Its manifest contributes creation fields; its main-process lifecycle uses only the versioned Core context supplied by module API 1.0.0. Uninstalling this package removes only the copy in Aurora's user-data `modules/wordpress` directory. It never removes project files or databases. diff --git a/src/main/ipc/modules.ts b/src/main/ipc/modules.ts index 727c69d..fd1a997 100644 --- a/src/main/ipc/modules.ts +++ b/src/main/ipc/modules.ts @@ -7,7 +7,10 @@ export function registerModulesIpc(): void { ipcMain.handle('modules:listRegistry', () => listModules()) ipcMain.handle('modules:listAvailable', () => getAvailableModulePackages()) ipcMain.handle('modules:pickAndInstallPackage', async () => { - const picked = await dialog.showOpenDialog({ properties: ['openDirectory'] }) + const picked = await dialog.showOpenDialog({ + properties: ['openFile', 'openDirectory'], + filters: [{ name: 'Aurora module packages', extensions: ['pac'] }] + }) if (picked.canceled || !picked.filePaths[0]) return null return installModulePackage(picked.filePaths[0]) }) diff --git a/src/main/moduleRegistry.test.ts b/src/main/moduleRegistry.test.ts index 39062ca..8f56711 100644 --- a/src/main/moduleRegistry.test.ts +++ b/src/main/moduleRegistry.test.ts @@ -1,4 +1,6 @@ +import { execFile } from 'child_process' import { mkdtemp, mkdir, readFile, symlink, writeFile } from 'fs/promises' +import { promisify } from 'util' import { join, resolve } from 'path' import { tmpdir } from 'os' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -7,9 +9,16 @@ vi.mock('electron', () => ({ app: { getPath: () => '/unused', getAppPath: () => import { getAvailableModulePackages, getModuleRegistry, installModulePackage, uninstallModulePackage, validateModuleManifest } from './moduleRegistry' const roots: string[] = [] +const execFileAsync = promisify(execFile) async function temp(prefix: string): Promise { const root = await mkdtemp(join(tmpdir(), prefix)); roots.push(root); return root } const manifest = { id: 'sample-app', name: 'Sample', version: '1.0.0', category: 'application', description: 'test', aurora: { core: '2.0.0-alpha.24', moduleApi: '1.0.0' }, dependencies: [], conflicts: [], settings: [] } async function packageDir(value = manifest): Promise { const root = await temp('aurora-package-'); await writeFile(join(root, 'manifest.json'), JSON.stringify(value)); return root } +async function pacFile(value = manifest): Promise { + const source = await packageDir(value) + const output = join(await temp('aurora-pac-'), `${value.id}.pac`) + await execFileAsync('zip', ['-qr', output, '.'], { cwd: source }) + return output +} afterEach(async () => { const { rm } = await import('fs/promises'); await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) }) @@ -21,6 +30,27 @@ describe('external module registry', () => { expect((await getAvailableModulePackages()).map((item) => item.manifest.id)).toContain('sample-app') delete process.env.AURORA_MODULE_PATH }) + it('discovers and installs a compressed .pac package', async () => { + const catalog = await temp('aurora-catalog-') + const pac = await pacFile() + const catalogPac = join(catalog, 'sample-app.pac') + await import('fs/promises').then(({ copyFile }) => copyFile(pac, catalogPac)) + process.env.AURORA_MODULE_PATH = catalog + expect((await getAvailableModulePackages()).map((item) => item.manifest.id)).toContain('sample-app') + delete process.env.AURORA_MODULE_PATH + const userData = await temp('aurora-user-') + const installed = await installModulePackage(catalogPac, userData) + expect(installed.manifest.id).toBe('sample-app') + expect((await getModuleRegistry(userData)).map((item) => item.id)).toEqual(['sample-app']) + }) + it('discovers a .pac distributed beside the AppImage', async () => { + const release = await temp('aurora-release-') + const pac = await pacFile() + await import('fs/promises').then(({ copyFile }) => copyFile(pac, join(release, 'sample-app.pac'))) + process.env.APPIMAGE = join(release, 'aurora-dockside.AppImage') + expect((await getAvailableModulePackages()).map((item) => item.manifest.id)).toContain('sample-app') + delete process.env.APPIMAGE + }) it('installs a valid local package and refreshes after install', async () => { const userData = await temp('aurora-user-'); const source = await packageDir() await installModulePackage(source, userData) @@ -37,6 +67,26 @@ describe('external module registry', () => { const userData = await temp('aurora-user-'); const source = await packageDir(); await mkdir(join(source, 'main')); await symlink('/tmp', join(source, 'main', 'escape')) await expect(installModulePackage(source, userData)).rejects.toThrow(/symbolic links/) }) + it('rejects symbolic links and traversal paths inside .pac archives', async () => { + const userData = await temp('aurora-user-') + const linkedSource = await packageDir() + await symlink('/tmp', join(linkedSource, 'escape')) + const linkedPac = join(await temp('aurora-pac-'), 'linked.pac') + await execFileAsync('zip', ['-qry', linkedPac, '.'], { cwd: linkedSource }) + await expect(installModulePackage(linkedPac, userData)).rejects.toThrow(/symbolic links/) + + const traversalSource = await packageDir() + await mkdir(join(traversalSource, 'xx')) + await writeFile(join(traversalSource, 'xx', 'evil'), 'unsafe') + const traversalPac = join(await temp('aurora-pac-'), 'traversal.pac') + await execFileAsync('zip', ['-qr', traversalPac, '.'], { cwd: traversalSource }) + const archive = await readFile(traversalPac) + const original = Buffer.from('xx/evil') + const unsafe = Buffer.from('../evil') + for (let offset = archive.indexOf(original); offset !== -1; offset = archive.indexOf(original, offset + unsafe.length)) unsafe.copy(archive, offset) + await writeFile(traversalPac, archive) + await expect(installModulePackage(traversalPac, userData)).rejects.toThrow(/(?:Unsafe \.pac entry path|invalid relative path)/) + }) it('refreshes after uninstall without touching source', async () => { const userData = await temp('aurora-user-'); const source = await packageDir(); await installModulePackage(source, userData); await uninstallModulePackage('sample-app', userData) expect(await getModuleRegistry(userData)).toEqual([]) @@ -64,5 +114,10 @@ describe('external module registry', () => { const packageRoot = resolve(process.cwd(), 'packages/aurora-module-wordpress') const actual = JSON.parse(await readFile(join(packageRoot, 'manifest.json'), 'utf8')) expect(validateModuleManifest(actual).id).toBe('wordpress') + const archive = join(await temp('aurora-pac-'), 'wordpress.pac') + await execFileAsync('zip', ['-qr', archive, '.'], { cwd: packageRoot }) + const userData = await temp('aurora-user-') + expect((await installModulePackage(archive, userData)).manifest.id).toBe('wordpress') + expect((await getModuleRegistry(userData)).map((item) => item.id)).toEqual(['wordpress']) }) }) diff --git a/src/main/moduleRegistry.ts b/src/main/moduleRegistry.ts index b75566f..9db7c8b 100644 --- a/src/main/moduleRegistry.ts +++ b/src/main/moduleRegistry.ts @@ -1,11 +1,16 @@ import { app } from 'electron' -import { cp, lstat, mkdir, readFile, readdir, realpath, rename, rm, stat } from 'fs/promises' -import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'path' +import extract from 'extract-zip' +import yauzl, { type Entry } from 'yauzl' +import { cp, lstat, mkdir, mkdtemp, readFile, readdir, realpath, rename, rm, stat } from 'fs/promises' +import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from 'path' import type { AuroraAvailableModule, AuroraModuleInstallResult, AuroraModuleManifest, AuroraModuleSetting } from '../shared/types' export const CORE_VERSION = '2.0.0-alpha.24' export const MODULE_API_VERSION = '1.0.0' let cache: AuroraModuleManifest[] | null = null +const MAX_PAC_ENTRIES = 4096 +const MAX_PAC_EXPANDED_BYTES = 256 * 1024 * 1024 +const MAX_MANIFEST_BYTES = 1024 * 1024 export function moduleDirectory(userData = app.getPath('userData')): string { return join(userData, 'modules') } function validVersion(value: unknown): value is string { return typeof value === 'string' && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(value) } @@ -79,6 +84,62 @@ async function assertSafePackageTree(root: string): Promise { await visit(root) } +function validatePacEntry(entry: Entry): void { + const name = entry.fileName + if (!name || name.includes('\\') || name.startsWith('/') || /^[A-Za-z]:/.test(name)) throw new Error(`Unsafe .pac entry path: ${name || '(empty)'}`) + if (name.split('/').some((part) => part === '..')) throw new Error(`Unsafe .pac entry path: ${name}`) + if ((entry.generalPurposeBitFlag & 0x1) !== 0) throw new Error(`Encrypted .pac entries are not supported: ${name}`) + const unixMode = (entry.externalFileAttributes >>> 16) & 0xffff + if ((unixMode & 0xf000) === 0xa000) throw new Error(`.pac packages may not contain symbolic links: ${name}`) +} + +async function inspectPac(source: string): Promise { + return new Promise((resolvePromise, rejectPromise) => { + yauzl.open(source, { lazyEntries: true, decodeStrings: true }, (openError, zip) => { + if (openError || !zip) { rejectPromise(openError ?? new Error('Unable to open .pac package')); return } + let entries = 0 + let expandedBytes = 0 + let manifest: AuroraModuleManifest | null = null + let settled = false + const fail = (error: unknown): void => { + if (settled) return + settled = true + zip.close() + rejectPromise(error instanceof Error ? error : new Error(String(error))) + } + zip.on('error', fail) + zip.on('entry', (entry) => { + try { + validatePacEntry(entry) + entries += 1 + expandedBytes += entry.uncompressedSize + if (entries > MAX_PAC_ENTRIES) throw new Error(`.pac contains more than ${MAX_PAC_ENTRIES} entries`) + if (expandedBytes > MAX_PAC_EXPANDED_BYTES) throw new Error('.pac expanded size exceeds 256 MiB') + if (entry.fileName !== 'manifest.json') { zip.readEntry(); return } + if (entry.uncompressedSize > MAX_MANIFEST_BYTES) throw new Error('.pac manifest exceeds 1 MiB') + zip.openReadStream(entry, (streamError, stream) => { + if (streamError || !stream) { fail(streamError ?? new Error('Unable to read .pac manifest')); return } + const chunks: Buffer[] = [] + stream.on('data', (chunk: Buffer) => chunks.push(chunk)) + stream.on('error', fail) + stream.on('end', () => { + try { manifest = validateModuleManifest(JSON.parse(Buffer.concat(chunks).toString('utf8'))); zip.readEntry() } catch (error) { fail(error) } + }) + }) + } catch (error) { fail(error) } + }) + zip.on('end', () => { + if (settled) return + settled = true + zip.close() + if (!manifest) rejectPromise(new Error('.pac must contain manifest.json at the archive root')) + else resolvePromise(manifest) + }) + zip.readEntry() + }) + }) +} + export async function getModuleRegistry(userData?: string): Promise { if (!userData && cache) return cache const dir = moduleDirectory(userData) @@ -101,7 +162,9 @@ export function invalidateModuleRegistry(): void { cache = null } function catalogDirectories(): string[] { const configured = (process.env.AURORA_MODULE_PATH ?? '').split(process.platform === 'win32' ? ';' : ':').filter(Boolean) - const besideImage = process.env.APPIMAGE ? [join(dirname(process.env.APPIMAGE), 'modules')] : [] + const besideImage = process.env.APPIMAGE + ? [dirname(process.env.APPIMAGE), join(dirname(process.env.APPIMAGE), 'modules')] + : [] return [...configured, ...besideImage, join(process.cwd(), 'modules'), join(process.cwd(), 'packages'), join(app.getAppPath(), 'packages')] } @@ -110,10 +173,12 @@ export async function getAvailableModulePackages(): Promise { if (!isAbsolute(source)) throw new Error('Module package path must be absolute') - if (!(await stat(source)).isDirectory()) throw new Error('Select an unpacked local module directory') - await assertSafePackageTree(source) - const manifest = validateModuleManifest(JSON.parse(await readFile(join(source, 'manifest.json'), 'utf8'))) + const sourceInfo = await stat(source) + const isDirectory = sourceInfo.isDirectory() + const isPac = sourceInfo.isFile() && extname(source).toLowerCase() === '.pac' + if (!isDirectory && !isPac) throw new Error('Select an Aurora .pac file or unpacked module directory') const modules = moduleDirectory(userData) await mkdir(modules, { recursive: true }) + const staging = await mkdtemp(join(modules, '.install-')) + let manifest: AuroraModuleManifest + try { + if (isDirectory) { + await assertSafePackageTree(source) + await cp(source, staging, { recursive: true, errorOnExist: false }) + } else { + manifest = await inspectPac(source) + await extract(source, { dir: staging }) + } + await assertSafePackageTree(staging) + manifest = validateModuleManifest(JSON.parse(await readFile(join(staging, 'manifest.json'), 'utf8'))) + } catch (error) { + await rm(staging, { recursive: true, force: true }) + throw error + } const destination = resolve(modules, manifest.id) if (dirname(destination) !== resolve(modules) || basename(destination) !== manifest.id) throw new Error('Unsafe module destination') - const staging = join(modules, `.${manifest.id}-${process.pid}-${Date.now()}`) - await cp(source, staging, { recursive: true, errorOnExist: true }) - await rm(destination, { recursive: true, force: true }) - await rename(staging, destination) + const backup = join(modules, `.backup-${manifest.id}-${process.pid}-${Date.now()}`) + let hadExisting = false + try { + try { await stat(destination); hadExisting = true } catch { hadExisting = false } + if (hadExisting) await rename(destination, backup) + await rename(staging, destination) + await rm(backup, { recursive: true, force: true }) + } catch (error) { + await rm(staging, { recursive: true, force: true }) + if (hadExisting) { + await rm(destination, { recursive: true, force: true }) + await rename(backup, destination) + } + throw error + } invalidateModuleRegistry() return { manifest, installedPath: destination } } diff --git a/src/renderer/src/components/modules/GlobalModuleManager.tsx b/src/renderer/src/components/modules/GlobalModuleManager.tsx index 64617d5..fb9f14a 100644 --- a/src/renderer/src/components/modules/GlobalModuleManager.tsx +++ b/src/renderer/src/components/modules/GlobalModuleManager.tsx @@ -42,7 +42,7 @@ export function GlobalModuleManager({ onClose }: { onClose: () => void }): React })} } -
+ }