feat: add secure pac module packages

This commit is contained in:
reaper
2026-08-14 17:43:16 -05:00
parent afce94a0a8
commit 9a8476507b
8 changed files with 182 additions and 22 deletions
+9 -6
View File
@@ -11,7 +11,10 @@
- Application packages are discovered and installed into Aurora's Electron user-data `modules` directory. - 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. - 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. - 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. - 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. - 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. - 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 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 - Empty registry
- Available local packages - Available local packages
- Valid installation and registry refresh - Valid installation and registry refresh
- Invalid and incompatible manifest rejection - Invalid and incompatible manifest rejection
- Unsafe package path and symbolic-link rejection - Unsafe package path and symbolic-link rejection
- `.pac` discovery, installation, traversal rejection, and archive symlink rejection
- Package update - Package update
- Registry refresh after uninstall - Registry refresh after uninstall
- Preservation of source packages and existing project data - Preservation of source packages and existing project data
@@ -67,18 +71,17 @@ Live project smoke result for project `24`:
## Artifacts ## Artifacts
- Core AppImage: `/home/reaper/Documents/Codex/aurora/dist/final-alpha24/aurora-dockside-2.0.0-alpha.24.AppImage` - 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` - Directly installable unpacked WordPress package: `/home/reaper/Documents/Codex/aurora/packages/aurora-module-wordpress`
SHA-256: SHA-256:
```text ```text
fe700b5528ad7ddcdae43ced5651801370b2b09f8ed711fb447179281b6a9257 aurora-dockside-2.0.0-alpha.24.AppImage 1964de899632f7dca86b5ee6485d5c2bc8dc0cffcc7a9de4479ea7eef25e9e76 aurora-dockside-2.0.0-alpha.24.AppImage
942068c4eeb9b638f8aa6041dc8e5c15cec95fa6901fa24ed197221469ce37e9 aurora-module-wordpress-1.2.0.tar.gz d2165af4b75ab888c6d35a750a449205123866231ba5e98e1cbe9bcaa8738f46 aurora-module-wordpress-1.2.0.pac
``` ```
## Known limitations ## 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`. - 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`. - AppImage systems without working FUSE can use `--appimage-extract` and launch `squashfs-root/AppRun --no-sandbox`.
+4 -1
View File
@@ -14,8 +14,10 @@
"@tanstack/react-query": "^5.101.4", "@tanstack/react-query": "^5.101.4",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"electron-updater": "^6.8.9", "electron-updater": "^6.8.9",
"extract-zip": "^2.0.1",
"lucide-react": "^1.28.0", "lucide-react": "^1.28.0",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",
"yauzl": "^2.10.0",
"zustand": "^5.0.14" "zustand": "^5.0.14"
}, },
"devDependencies": { "devDependencies": {
@@ -29,6 +31,7 @@
"@types/node": "^22.19.1", "@types/node": "^22.19.1",
"@types/react": "^19.2.7", "@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@types/yauzl": "^2.10.3",
"@vitejs/plugin-react": "^5.1.1", "@vitejs/plugin-react": "^5.1.1",
"@vitest/coverage-v8": "^4.1.10", "@vitest/coverage-v8": "^4.1.10",
"@vitest/ui": "^4.1.10", "@vitest/ui": "^4.1.10",
@@ -3164,8 +3167,8 @@
"version": "2.10.3", "version": "2.10.3",
"resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz",
"integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==",
"devOptional": true,
"license": "MIT", "license": "MIT",
"optional": true,
"dependencies": { "dependencies": {
"@types/node": "*" "@types/node": "*"
} }
+3
View File
@@ -34,8 +34,10 @@
"@tanstack/react-query": "^5.101.4", "@tanstack/react-query": "^5.101.4",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"electron-updater": "^6.8.9", "electron-updater": "^6.8.9",
"extract-zip": "^2.0.1",
"lucide-react": "^1.28.0", "lucide-react": "^1.28.0",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",
"yauzl": "^2.10.0",
"zustand": "^5.0.14" "zustand": "^5.0.14"
}, },
"devDependencies": { "devDependencies": {
@@ -49,6 +51,7 @@
"@types/node": "^22.19.1", "@types/node": "^22.19.1",
"@types/react": "^19.2.7", "@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@types/yauzl": "^2.10.3",
"@vitejs/plugin-react": "^5.1.1", "@vitejs/plugin-react": "^5.1.1",
"@vitest/coverage-v8": "^4.1.10", "@vitest/coverage-v8": "^4.1.10",
"@vitest/ui": "^4.1.10", "@vitest/ui": "^4.1.10",
+1 -1
View File
@@ -1,5 +1,5 @@
# Aurora WordPress module # 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. Uninstalling this package removes only the copy in Aurora's user-data `modules/wordpress` directory. It never removes project files or databases.
+4 -1
View File
@@ -7,7 +7,10 @@ export function registerModulesIpc(): void {
ipcMain.handle('modules:listRegistry', () => listModules()) ipcMain.handle('modules:listRegistry', () => listModules())
ipcMain.handle('modules:listAvailable', () => getAvailableModulePackages()) ipcMain.handle('modules:listAvailable', () => getAvailableModulePackages())
ipcMain.handle('modules:pickAndInstallPackage', async () => { 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 if (picked.canceled || !picked.filePaths[0]) return null
return installModulePackage(picked.filePaths[0]) return installModulePackage(picked.filePaths[0])
}) })
+55
View File
@@ -1,4 +1,6 @@
import { execFile } from 'child_process'
import { mkdtemp, mkdir, readFile, symlink, writeFile } from 'fs/promises' import { mkdtemp, mkdir, readFile, symlink, writeFile } from 'fs/promises'
import { promisify } from 'util'
import { join, resolve } from 'path' import { join, resolve } from 'path'
import { tmpdir } from 'os' import { tmpdir } from 'os'
import { afterEach, describe, expect, it, vi } from 'vitest' 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' import { getAvailableModulePackages, getModuleRegistry, installModulePackage, uninstallModulePackage, validateModuleManifest } from './moduleRegistry'
const roots: string[] = [] const roots: string[] = []
const execFileAsync = promisify(execFile)
async function temp(prefix: string): Promise<string> { const root = await mkdtemp(join(tmpdir(), prefix)); roots.push(root); return root } async function temp(prefix: string): Promise<string> { 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: [] } 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<string> { const root = await temp('aurora-package-'); await writeFile(join(root, 'manifest.json'), JSON.stringify(value)); return root } async function packageDir(value = manifest): Promise<string> { const root = await temp('aurora-package-'); await writeFile(join(root, 'manifest.json'), JSON.stringify(value)); return root }
async function pacFile(value = manifest): Promise<string> {
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 }))) }) 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') expect((await getAvailableModulePackages()).map((item) => item.manifest.id)).toContain('sample-app')
delete process.env.AURORA_MODULE_PATH 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 () => { it('installs a valid local package and refreshes after install', async () => {
const userData = await temp('aurora-user-'); const source = await packageDir() const userData = await temp('aurora-user-'); const source = await packageDir()
await installModulePackage(source, userData) 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')) 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/) 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 () => { 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) const userData = await temp('aurora-user-'); const source = await packageDir(); await installModulePackage(source, userData); await uninstallModulePackage('sample-app', userData)
expect(await getModuleRegistry(userData)).toEqual([]) expect(await getModuleRegistry(userData)).toEqual([])
@@ -64,5 +114,10 @@ describe('external module registry', () => {
const packageRoot = resolve(process.cwd(), 'packages/aurora-module-wordpress') const packageRoot = resolve(process.cwd(), 'packages/aurora-module-wordpress')
const actual = JSON.parse(await readFile(join(packageRoot, 'manifest.json'), 'utf8')) const actual = JSON.parse(await readFile(join(packageRoot, 'manifest.json'), 'utf8'))
expect(validateModuleManifest(actual).id).toBe('wordpress') 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'])
}) })
}) })
+105 -12
View File
@@ -1,11 +1,16 @@
import { app } from 'electron' import { app } from 'electron'
import { cp, lstat, mkdir, readFile, readdir, realpath, rename, rm, stat } from 'fs/promises' import extract from 'extract-zip'
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'path' 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' import type { AuroraAvailableModule, AuroraModuleInstallResult, AuroraModuleManifest, AuroraModuleSetting } from '../shared/types'
export const CORE_VERSION = '2.0.0-alpha.24' export const CORE_VERSION = '2.0.0-alpha.24'
export const MODULE_API_VERSION = '1.0.0' export const MODULE_API_VERSION = '1.0.0'
let cache: AuroraModuleManifest[] | null = null 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') } 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) } 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<void> {
await visit(root) 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<AuroraModuleManifest> {
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<AuroraModuleManifest[]> { export async function getModuleRegistry(userData?: string): Promise<AuroraModuleManifest[]> {
if (!userData && cache) return cache if (!userData && cache) return cache
const dir = moduleDirectory(userData) const dir = moduleDirectory(userData)
@@ -101,7 +162,9 @@ export function invalidateModuleRegistry(): void { cache = null }
function catalogDirectories(): string[] { function catalogDirectories(): string[] {
const configured = (process.env.AURORA_MODULE_PATH ?? '').split(process.platform === 'win32' ? ';' : ':').filter(Boolean) 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')] return [...configured, ...besideImage, join(process.cwd(), 'modules'), join(process.cwd(), 'packages'), join(app.getAppPath(), 'packages')]
} }
@@ -110,10 +173,12 @@ export async function getAvailableModulePackages(): Promise<AuroraAvailableModul
for (const directory of catalogDirectories()) { for (const directory of catalogDirectories()) {
try { try {
for (const entry of await readdir(directory, { withFileTypes: true })) { for (const entry of await readdir(directory, { withFileTypes: true })) {
if (!entry.isDirectory()) continue if (!entry.isDirectory() && !(entry.isFile() && extname(entry.name).toLowerCase() === '.pac')) continue
const sourcePath = join(directory, entry.name) const sourcePath = join(directory, entry.name)
try { try {
const manifest = validateModuleManifest(JSON.parse(await readFile(join(sourcePath, 'manifest.json'), 'utf8'))) const manifest = entry.isDirectory()
? validateModuleManifest(JSON.parse(await readFile(join(sourcePath, 'manifest.json'), 'utf8')))
: await inspectPac(sourcePath)
if (!found.has(manifest.id)) found.set(manifest.id, { manifest, sourcePath }) if (!found.has(manifest.id)) found.set(manifest.id, { manifest, sourcePath })
} catch { /* unrelated or incompatible package */ } } catch { /* unrelated or incompatible package */ }
} }
@@ -124,17 +189,45 @@ export async function getAvailableModulePackages(): Promise<AuroraAvailableModul
export async function installModulePackage(source: string, userData?: string): Promise<AuroraModuleInstallResult> { export async function installModulePackage(source: string, userData?: string): Promise<AuroraModuleInstallResult> {
if (!isAbsolute(source)) throw new Error('Module package path must be absolute') 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') const sourceInfo = await stat(source)
await assertSafePackageTree(source) const isDirectory = sourceInfo.isDirectory()
const manifest = validateModuleManifest(JSON.parse(await readFile(join(source, 'manifest.json'), 'utf8'))) 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) const modules = moduleDirectory(userData)
await mkdir(modules, { recursive: true }) 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) const destination = resolve(modules, manifest.id)
if (dirname(destination) !== resolve(modules) || basename(destination) !== manifest.id) throw new Error('Unsafe module destination') 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()}`) const backup = join(modules, `.backup-${manifest.id}-${process.pid}-${Date.now()}`)
await cp(source, staging, { recursive: true, errorOnExist: true }) let hadExisting = false
await rm(destination, { recursive: true, force: true }) try {
await rename(staging, destination) 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() invalidateModuleRegistry()
return { manifest, installedPath: destination } return { manifest, installedPath: destination }
} }
@@ -42,7 +42,7 @@ export function GlobalModuleManager({ onClose }: { onClose: () => void }): React
})} })}
</div>} </div>}
</div> </div>
<footer className="flex justify-between border-t border-neutral-200 bg-neutral-50 px-5 py-3 dark:border-white/10 dark:bg-white/[0.03]"><button type="button" onClick={() => installFromFolder.mutate()} className="text-sm font-medium text-neutral-500 hover:text-cyan-700 dark:hover:text-cyan-300">Install from folder</button><button type="button" onClick={onClose} className="rounded-lg border border-neutral-300 px-3 py-1.5 text-sm font-medium dark:border-white/10">Close</button></footer> <footer className="flex justify-between border-t border-neutral-200 bg-neutral-50 px-5 py-3 dark:border-white/10 dark:bg-white/[0.03]"><button type="button" onClick={() => installFromFolder.mutate()} className="text-sm font-medium text-neutral-500 hover:text-cyan-700 dark:hover:text-cyan-300">Install .pac or folder</button><button type="button" onClick={onClose} className="rounded-lg border border-neutral-300 px-3 py-1.5 text-sm font-medium dark:border-white/10">Close</button></footer>
</div> </div>
</div> </div>
} }