Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions api/services/generator_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,14 @@ def _discover_extensions() -> Dict[str, Tuple[type, dict]]:
for ext_dir in sorted(EXTENSIONS_DIR.iterdir()):
if not ext_dir.is_dir():
continue
# Dot-dirs are install machinery (staging/backup), never extensions
if ext_dir.name.startswith("."):
continue
# Marker left by the installer while setup runs (or after a crash):
# the folder is not ready to be loaded
if (ext_dir / ".modly-incomplete").exists():
print(f"[Registry] Skipping '{ext_dir.name}': install has not completed")
continue

manifest_path = ext_dir / "manifest.json"
generator_path = ext_dir / "generator.py"
Expand Down
23 changes: 23 additions & 0 deletions electron/main/extension-path-guard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,32 @@ test('resolvePathWithinRoot rejects canonical escapes', async () => {
assert.throws(() => resolvePathWithinRoot(root, '../escape'), /escapes root/i)
})

test('resolvePathWithinRoot rejects leaves that resolve to the root itself', async () => {
const { resolvePathWithinRoot } = await loadGuard()
const root = path.join('/tmp', 'extensions-root')
assert.throws(() => resolvePathWithinRoot(root, '.'), /escapes root/i)
assert.throws(() => resolvePathWithinRoot(root, ''), /escapes root/i)
})

test('parseExtensionBackupName extracts ids with dashes and rejects foreign names', async () => {
const { parseExtensionBackupName } = await loadGuard()
assert.deepEqual(parseExtensionBackupName('.modly-backup-hunyuan3d-mini-1752580000000'), { extensionId: 'hunyuan3d-mini' })
assert.deepEqual(parseExtensionBackupName('.modly-backup-x-1'), { extensionId: 'x' })
assert.equal(parseExtensionBackupName('.modly-backup-noTimestamp'), null)
assert.equal(parseExtensionBackupName('.modly-staging-x-1'), null)
assert.equal(parseExtensionBackupName('regular-extension'), null)
})

test('buildExtensionBackupPath stays within root and rejects unsafe ids', async () => {
const { buildExtensionBackupPath } = await loadGuard()
const root = path.join('/tmp', 'extensions-root')
assert.equal(buildExtensionBackupPath(root, 'mesh-process', '123'), path.resolve(root, '.modly-backup-mesh-process-123'))
assert.throws(() => buildExtensionBackupPath(root, '../escape', '123'), /path separators/i)
})

test('buildExtensionStagingPath stays within root and rejects unsafe ids', async () => {
const { buildExtensionStagingPath } = await loadGuard()
const root = path.join('/tmp', 'extensions-root')
assert.equal(buildExtensionStagingPath(root, 'mesh-process', '42'), path.resolve(root, '.modly-staging-mesh-process-42'))
assert.throws(() => buildExtensionStagingPath(root, '../escape', '42'), /path separators/i)
})
44 changes: 42 additions & 2 deletions electron/main/extension-path-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@ export function resolvePathWithinRoot(rootDir: string, unsafeLeaf: string): stri
const resolvedCandidate = resolvePath(resolvedRoot, unsafeLeaf)
const normalizedRelative = relative(resolvedRoot, resolvedCandidate).replace(/\\/g, '/')

if (normalizedRelative === '..' || normalizedRelative.startsWith('../') || isAbsolute(normalizedRelative)) {
// An empty relative path means the leaf resolved to the root itself ('', '.')
// — never a valid child, and catastrophic for deletion call sites.
if (normalizedRelative === '' || normalizedRelative === '..' || normalizedRelative.startsWith('../') || isAbsolute(normalizedRelative)) {
throw new Error(`Resolved path escapes root: ${unsafeLeaf}`)
}

Expand All @@ -47,7 +49,45 @@ export function resolveExtensionPathWithinRoot(rootDir: string, extensionId: unk
return resolvePathWithinRoot(rootDir, assertSafeExtensionId(extensionId))
}

// ─── Internal (non-extension) dir names inside extensionsDir ─────────────────
// Extension ids can never start with a dot, so dot-prefixed names are reserved
// for install machinery: staging copies, backups of the previous version.
// Both the Electron and Python discovery sides must skip them.

export const EXT_BACKUP_PREFIX = '.modly-backup-'
export const EXT_STAGING_PREFIX = '.modly-staging-'
// Marker file inside an extension folder while its setup is still running —
// presence after a crash means the install never completed.
export const EXT_INCOMPLETE_MARKER = '.modly-incomplete'

export function isInternalExtensionDirName(name: string): boolean {
return name.startsWith('.')
}

export function buildExtensionBackupPath(rootDir: string, extensionId: unknown, suffix: string): string {
const safeId = assertSafeExtensionId(extensionId)
return resolvePathWithinRoot(rootDir, `.modly-backup-${safeId}-${suffix}`)
return resolvePathWithinRoot(rootDir, `${EXT_BACKUP_PREFIX}${safeId}-${suffix}`)
}

// A backup dir is the previous version of an extension, parked during an
// install swap. Its name embeds the extension id: .modly-backup-<id>-<ts>.
// Ids may contain '-', so strip the numeric timestamp suffix, not a naive split.
export function parseExtensionBackupName(name: string): { extensionId: string } | null {
if (!name.startsWith(EXT_BACKUP_PREFIX)) return null
const rest = name.slice(EXT_BACKUP_PREFIX.length)
const match = rest.match(/^(.+)-\d+$/)
if (!match) return null
try {
return { extensionId: assertSafeExtensionId(match[1]) }
} catch {
return null
}
}

// Staging dir lives next to the final location so activation is a same-volume
// atomic rename. Unique per attempt (suffix) so a new install can never merge
// into the leftovers of a previous one, and never races the startup purge.
export function buildExtensionStagingPath(rootDir: string, extensionId: unknown, suffix: string): string {
const safeId = assertSafeExtensionId(extensionId)
return resolvePathWithinRoot(rootDir, `${EXT_STAGING_PREFIX}${safeId}-${suffix}`)
}
Loading