diff --git a/src/lib/badge-editor-upload.ts b/src/lib/badge-editor-upload.ts index 929715c..e8d5510 100644 --- a/src/lib/badge-editor-upload.ts +++ b/src/lib/badge-editor-upload.ts @@ -13,6 +13,17 @@ export type DiskDirectory = { removeEntry: (name: string, options?: { recursive?: boolean }) => Promise } +export async function validateBadgeRoot(root: DiskDirectory) { + try { + await root.getDirectoryHandle('apps') + } catch (error) { + if (error instanceof DOMException && (error.name === 'NotFoundError' || error.name === 'TypeMismatchError')) { + throw new Error('Select the BADGER USB volume itself, rather than an app folder. The selected folder must contain the apps directory.') + } + throw error + } +} + export class ExistingAppError extends Error { constructor() { super('This app already exists. Open it from the badge to edit, or allow updating this app and click Upload again.') diff --git a/src/lib/badge-editor.ts b/src/lib/badge-editor.ts index 11550b7..a094423 100644 --- a/src/lib/badge-editor.ts +++ b/src/lib/badge-editor.ts @@ -1,4 +1,4 @@ -import { ExistingAppError, uploadEditorProject, validateAppName, type DiskDirectory } from './badge-editor-upload' +import { ExistingAppError, uploadEditorProject, validateAppName, validateBadgeRoot, type DiskDirectory } from './badge-editor-upload' import { initEditorSplit } from './badge-editor-split' import { initProjectPicker } from './badge-editor-picker' import { initEditorFullscreen } from './badge-editor-fullscreen' @@ -494,8 +494,7 @@ export async function initBadgeEditor() { setBusy(true) try { const root = await picker.call(window, { mode: 'readwrite' }) - if (root.name.toUpperCase() !== 'BADGER') throw new Error('Select the BADGER USB volume itself, rather than an app folder.') - await root.getDirectoryHandle('apps') + await validateBadgeRoot(root) const oldDisk = disk disk = root try { await refreshDeviceApps() } catch (error) { disk = oldDisk; throw error } diff --git a/tests/badge-editor-upload.test.mjs b/tests/badge-editor-upload.test.mjs index 6dd4673..0f551cc 100644 --- a/tests/badge-editor-upload.test.mjs +++ b/tests/badge-editor-upload.test.mjs @@ -5,7 +5,43 @@ import { build } from 'esbuild' const compiled = await build({ entryPoints: [fileURLToPath(new URL('../src/lib/badge-editor-upload.ts', import.meta.url))], bundle: true, format: 'esm', platform: 'node', write: false }) const moduleUrl = `data:text/javascript;base64,${Buffer.from(compiled.outputFiles[0].text).toString('base64')}` -const { uploadEditorApp, uploadEditorProject, ExistingAppError } = await import(moduleUrl) +const { uploadEditorApp, uploadEditorProject, validateBadgeRoot, ExistingAppError } = await import(moduleUrl) + +test('badge root validation accepts Windows drive names and renamed volumes without writing', async () => { + for (const name of ['BADGER', 'E:', 'E:\\', 'BADGER (E:)', 'MY-BADGE']) { + const checked = [] + await validateBadgeRoot({ + name, + async getDirectoryHandle(path, options) { + checked.push(path) + assert.equal(options, undefined) + return {} + }, + }) + assert.deepEqual(checked, ['apps']) + } +}) + +test('badge root validation rejects app folders and volumes without an apps directory', async () => { + for (const name of ['my-app', 'apps', 'BADGER', 'RP2350', 'E:']) { + for (const errorName of ['NotFoundError', 'TypeMismatchError']) { + await assert.rejects(validateBadgeRoot({ + name, + async getDirectoryHandle() { throw new DOMException('Invalid apps directory', errorName) }, + }), /Select the BADGER USB volume itself/) + } + } +}) + +test('badge root validation preserves permission and device errors', async () => { + for (const errorName of ['NotAllowedError', 'NotReadableError']) { + const error = new DOMException('Cannot read device', errorName) + await assert.rejects(validateBadgeRoot({ + name: 'E:', + async getDirectoryHandle() { throw error }, + }), thrown => thrown === error) + } +}) function filesystem(existing = false, failure = '') { const files = new Map(existing ? [['__init__.py', 'original'], ['icon.png', 'icon']] : [])