test: more checks on % filenames

Massimo Melina committed Jan 24, 2026 at 14:49 UTC 996c2df362b3c5369cc13ebe0726154e841f14fb
4 files changed +69 -13
frontend/src/show.ts
+2 -2
@@ -2,7 +2,7 @@ import { DirEntry, DirList, ext2type, state, useSnapState } from './state'
2 import { createElement as h, forwardRef, Fragment, useEffect, useMemo, useRef, useState } from 'react'
3 import {
4 basename, dirname, domOn, hfsEvent, hIcon, isMac, newDialog, pathEncode, restartAnimation, useStateMounted,
5 - isNumeric,
5 + isNumeric, safeDecodeURIComponent,
6 } from './misc'
7 import { useEventListener, useWindowSize } from 'usehooks-ts'
8 import { EntryDetails, useMidnight } from './BrowseFiles'
@@ -204,7 +204,7 @@ export function fileShow(entry: DirEntry, { startPlaying=false, startShuffle=fal
204 setCover(pathEncode(_.maxBy(covers, 's')?.n || ''))
205 const meta = {
206 title: cur.name,
207 - album: decodeURIComponent(basename(dirname(cur.uri))),
207 + album: safeDecodeURIComponent(basename(dirname(cur.uri)), ''),
208 artwork: covers.map(x => ({ src: x.n }))
209 }
210 const m = window.MediaMetadata && (navigator.mediaSession.metadata = new MediaMetadata(meta))
src/vfs.ts
+2 -2
@@ -5,7 +5,7 @@ import { basename, dirname, join, resolve } from 'path'
5 import {
6 makeMatcher, setHidden, onlyTruthy, isValidFileName, throw_, VfsPerms, Who, debounceAsync,
7 isWhoObject, WHO_ANY_ACCOUNT, defaultPerms, PERM_KEYS, removeStarting, HTTP_SERVER_ERROR, try_, matches,
8 - statWithTimeout,
8 + statWithTimeout, safeDecodeURIComponent,
9 } from './misc'
10 import Koa from 'koa'
11 import _ from 'lodash'
@@ -117,7 +117,7 @@ export async function urlToNode(
117 const slice = url.slice(initialSlashes, nextSlash < 0 ? undefined : nextSlash)
118 if (!slice)
119 return parent
120 - const name = try_(() => decodeURIComponent(slice))
120 + const name = safeDecodeURIComponent(slice, '')
121 if (!name) // failed decoding
122 return
123 const hasTrailingSlash = url.endsWith('/')
tests/config.yaml
+1 -1
@@ -51,7 +51,7 @@ vfs:
51 source: ../tmp/no-upload
52 can_upload: false
53 - name: cant-overwrite
54 - source: ../tmp/cant-overwrite
54 + source: ../work/cant-overwrite
55 can_upload:
56 - admins
57 can_delete: false
tests/test.ts
+64 -8
@@ -205,7 +205,7 @@ describe('basics', () => {
205 await Promise.all([rmAny(absPath), rmAny(storedPath)])
206 }
207 })
208 - test('create_folder', reqApi('create_folder', { uri: UPLOAD_ROOT, name: 'temp' }, 401))
208 + test('create_folder', reqApi('create_folder', { uri: UPLOAD_ROOT, name: UPLOAD_DIR }, 401))
209 test('create_folder.bad type', reqApi('create_folder', { uri: UPLOAD_ROOT, name: 123 }, { status: 400, re: /name/ }))
210 test('delete.no perm', req('/for-admins/', 405, { method: 'delete' }))
211 test('delete.need account', req(UPLOAD_ROOT + 'alfa.txt', 401, { method: 'delete'}))
@@ -254,7 +254,7 @@ describe('accounts', () => {
254 describe('after-login', () => {
255 before(() => login(username))
256 const trickyChars = '%strange#'
257 - test('create_folder', reqApi('create_folder', { uri: UPLOAD_ROOT, name: 'temp' }, 200))
257 + test('create_folder', reqApi('create_folder', { uri: UPLOAD_ROOT, name: UPLOAD_DIR }, 200))
258 test('create_folder.empty name', reqApi('create_folder', { uri: UPLOAD_ROOT, name: '' }, 409))
259 test('create_folder.tricky chars', async () => {
260 await reqApi('create_folder', { uri: UPLOAD_ROOT, name: trickyChars }, 200)()
@@ -295,6 +295,62 @@ describe('after-login', () => {
295 throwIf(!u?.ip ? 'ip' : u?.username !== username ? 'username' : '')
296 }))
297 test('file_details.non-admin', reqApi('get_file_details', { uris: [UPLOAD_DEST] }, res => res?.details?.[0] === false, { jar: {} }))
298 + test('percent name apis.details', async () => {
299 + const percentName = `x%25-${randomId(4)}`
300 + const percentUri = `${UPLOAD_ROOT}${pathEncode(percentName)}`
301 + const comment = `note-${randomId(6)}`
302 + await reqUpload(percentUri, 200)()
303 + try {
304 + await reqApi('get_file_details', { uris: [percentUri] }, res => !!res?.details?.[0]?.upload)()
305 + await reqApi('comment', { uri: percentUri, comment }, 200)()
306 + await reqApi('get_file_list', { uri: UPLOAD_ROOT }, res => _.find(res?.list, { n: percentName })?.comment === comment)()
307 + await reqApi('get_folder_size', { uri: percentUri }, 405)()
308 + }
309 + finally {
310 + await req(percentUri, 200, { method: 'delete' })().catch(() => {})
311 + await rmAny(resolve(__dirname, percentName))
312 + }
313 + })
314 +
315 + test('percent name apis.rename', async () => {
316 + const percentName = `x%25-${randomId(4)}`
317 + const percentUri = `${UPLOAD_ROOT}${pathEncode(percentName)}`
318 + const renameName = `${percentName}-renamed`
319 + const renamedUri = `${UPLOAD_ROOT}${pathEncode(renameName)}`
320 + await reqUpload(percentUri, 200)()
321 + try {
322 + await reqApi('rename', { uri: percentUri, dest: renameName }, 200)()
323 + await req(percentUri, 404)()
324 + await req(renamedUri, 200)()
325 + }
326 + finally {
327 + await req(renamedUri, 200, { method: 'delete' })().catch(() => {})
328 + await rmAny(resolve(__dirname, renameName))
329 + }
330 + })
331 +
332 + test('percent name apis.move-copy', async () => {
333 + const percentName = `x%25-${randomId(4)}`
334 + const percentUri = `${UPLOAD_ROOT}${pathEncode(percentName)}`
335 + const folderName = `pct-${randomId(6)}`
336 + const folderUri = `${UPLOAD_ROOT}${folderName}/`
337 + const movedUri = `${UPLOAD_ROOT}${folderName}/${pathEncode(percentName)}`
338 + await reqUpload(percentUri, 200)()
339 + try {
340 + await reqApi('create_folder', { uri: UPLOAD_ROOT, name: folderName }, 200)()
341 + await reqApi('move_files', { uri_from: [percentUri], uri_to: folderUri }, res => !res?.errors?.[0])()
342 + await req(movedUri, 200)()
343 + await reqApi('copy_files', { uri_from: [movedUri], uri_to: UPLOAD_ROOT }, res => !res?.errors?.[0])()
344 + await req(percentUri, 200)()
345 + }
346 + finally {
347 + await req(percentUri, 200, { method: 'delete' })().catch(() => {})
348 + await req(movedUri, 200, { method: 'delete' })().catch(() => {})
349 + await rmAny(resolve(__dirname, percentName))
350 + await rmAny(resolve(__dirname, folderName, percentName))
351 + await rmAny(resolve(__dirname, folderName))
352 + }
353 + })
354 test('zip.no-list but archive', req('/zipNoList/?get=zip', 403, { jar: {} }))
355 test('upload but not delete', async () => {
356 const name = `cant-delete`
@@ -349,7 +405,7 @@ describe('after-login', () => {
405 if (after !== before)
406 throw "size changed"
407 })
352 - test('upload.crossing', reqUpload(UPLOAD_DEST.replace('temp', '../..'), 404))
408 + test('upload.crossing', reqUpload(UPLOAD_DEST.replace(UPLOAD_DIR, '../..'), 404))
409 test('upload.overlap', async () => {
410 const ms = 300
411 const first = reqUpload(UPLOAD_DEST, 200, makeReadableThatTakes(ms))()
@@ -434,15 +490,15 @@ describe('after-login', () => {
490 const res = statfsSync(ROOT)
491 const free = res.bavail * res.bsize
492 const fakeSize = Math.round(free * 0.51)
437 - const r1 = reqUpload(UPLOAD_ROOT + 'temp/free1', 400, makeReadableThatTakes(1000), fakeSize)()
493 + const r1 = reqUpload(`${UPLOAD_ROOT}${UPLOAD_DIR}/free1`, 400, makeReadableThatTakes(1000), fakeSize)()
494 setTimeout(r1.abort, 1500)
495 await Promise.all([
496 r1.catch(() => {}),
441 - wait(100).then(() => reqUpload(UPLOAD_ROOT + 'temp/free2', 507, makeReadableThatTakes(500), fakeSize)())
497 + wait(100).then(() => reqUpload(`${UPLOAD_ROOT}${UPLOAD_DIR}/free2`, 507, makeReadableThatTakes(500), fakeSize)())
498 ])
499 })
500 test('max_dl.account', async () => {
445 - const uri = UPLOAD_ROOT + 'temp/big'
501 + const uri = `${UPLOAD_ROOT}${UPLOAD_DIR}/big`
502 await reqUpload(uri, 200, BIG_CONTENT)()
503 await testMaxDl(uri, 2, 1)
504 })
@@ -451,7 +507,7 @@ describe('after-login', () => {
507 await reqApi('logout', {}, 401)()
508 await reqApi('get_accounts', {}, 401)() // no more
509 })
454 - after(() => rmAny(resolve(__dirname, 'temp')))
510 + after(() => rmAny(resolve(__dirname, UPLOAD_DIR)))
511 })
512
513 describe('admin', () => {
@@ -700,7 +756,7 @@ function throwIf(msg: any) {
756 }
757
758 async function ensureCantOverwriteDir() {
703 - const baseDir = resolve(__dirname, 'tmp', CANT_OVERWRITE_NAME)
759 + const baseDir = resolve(__dirname, 'work', CANT_OVERWRITE_NAME)
760 await mkdir(baseDir, { recursive: true })
761 return baseDir
762 }