fix: zipping exposes wrong path for nested folders

Massimo Melina committed Jul 24, 2026 at 12:56 UTC f93586863cd12ad1b2816efa435fec40bbed74ad
2 files changed +12 -3
src/zip.ts
+3 -2
@@ -35,8 +35,9 @@ export async function zipStreamFromFolder(node: VfsNode, ctx: Koa.Context) {
35 continue
36 if (nodeIsFolder(subNode)) { // a directory needs to be walked
37 if (hasPermission(subNode, 'can_list', ctx) && hasPermission(subNode, 'can_archive', ctx)) {
38 - yield subNode // it could be empty
39 - yield* walkNode(subNode, { ctx, prefixPath: pathDecodeSegments(uri) + '/', requiredPerm: 'can_archive' })
38 + const archivePath = pathDecodeSegments(uri)
39 + yield { ...subNode, name: archivePath } // keep empty selected folders at the same path used for their contents
40 + yield* walkNode(subNode, { ctx, prefixPath: archivePath + '/', requiredPerm: 'can_archive' })
41 }
42 continue
43 }
tests/test.ts
+9 -1
@@ -7,8 +7,9 @@ import { basename, dirname, join, resolve } from 'path'
7 import { exec } from 'child_process'
8 import _ from 'lodash'
9 import yaml from 'yaml'
10 +import unzipper from 'unzipper'
11 import { findDefined, pathEncode, randomId, try_, tryJson, UPLOAD_TEMP_HASH, wait, waitFor } from '../src/cross'
11 -import { httpStream, parseHttpUrl, stream2string, XRequestOptions } from '../src/util-http'
12 +import { httpStream, httpWithBody, parseHttpUrl, stream2string, XRequestOptions } from '../src/util-http'
13 import { ThrottledStream, ThrottleGroup } from '../src/ThrottledStream'
14 import { mkdir, rm, rename, writeFile, access } from 'fs/promises'
15 import { Readable } from 'stream'
@@ -297,6 +298,13 @@ describe('basics', () => {
298 test('zip.list.compacted folders', req('/f1/?get=zip&list=page%2Fgpl.png%2F%2F%00index.html', /page\/gpl.png.+page\/index.html/))
299 test('zip.list.selected folder decodes prefix', req('/tests/?get=zip&list=C%253A', data =>
300 String(data).includes('C:/gpl.png') && !String(data).includes('C%3A/gpl.png')))
301 + test('zip.list.selected nested folder preserves path', async () => {
302 + const url = '/?get=zip&list=f1%2Fpage'
303 + const { body } = await httpWithBody(BASE_URL + url, { path: url })
304 + const paths = (await unzipper.Open.buffer(body!)).files.map(x => x.path)
305 + if (!paths.includes('f1/page/') || paths.includes('page/'))
306 + throw Error('unexpected archive paths: ' + paths)
307 + })
308 test('zip.list.bad encoding', req('/f1/?get=zip&list=%E0%A4%A//%00', { status: 200, length: 22 })) // basically empty
309 test('zip.list.null filename', req('/f1/?get=zip&list=%00', 400)) // tries to name the output with null-byte
310 test('zip.masked deep', req('/cantSearchForMasksDeep/?get=zip', {