fix: masks with slash were not correctly applied in case of search
Massimo Melina committed
Mar 11, 2026 at 12:32 UTC
e4ca138666af742c20455df8351c5b624db2c9b5
3 files changed
+79
-12
src/vfs.ts
+27
-12
@@ -344,7 +344,7 @@ export async function* walkNode(parent: VfsNode, {
344
const { source } = parent
345
const taken = new Set()
346
const maskApplier = parentMaskApplier(parent)
347
- const visitLater: any = []
347
+ const visitLater: [VfsNode, string][] = []
348
const childrenWorking = parent.children?.length && Promise.all(parent.children.map(async child => {
349
const nodeName = getNodeName(child)
350
const name = prefixPath + nodeName
@@ -370,6 +370,7 @@ export async function* walkNode(parent: VfsNode, {
370
&& !masksCouldGivePermission(parent.masks, requiredPerm))
371
return
372
373
+ const pathMaskApplier = parentMaskApplier(parent, true)
374
try {
375
await walkDir(source, { depth, ctx, hidden: showHiddenFiles.get(), parallelizeRecursion }, async entry => {
376
if (ctx?.isAborted()) {
@@ -391,6 +392,8 @@ export async function* walkNode(parent: VfsNode, {
392
return false // false just in case it's a folder
393
394
const item: VfsNode = { name, isFolder, source: join(source, path), parent }
395
+ // masks containing '/' must be matched against the relative path while keeping walkDir recursion enabled
396
+ await pathMaskApplier(item, renamed || path)
397
if (await cantSee(item)) // can't see: don't produce and don't recur
398
return false
399
if (onlyFiles ? !isFolder : (!onlyFolders || isFolder))
@@ -435,36 +438,48 @@ export function masksCouldGivePermission(masks: Masks | undefined, perm: keyof V
438
props[perm] || masksCouldGivePermission(props.masks, perm))
439
}
440
438
-export function parentMaskApplier(parent: VfsNode) {
441
+export function parentMaskApplier(parent: VfsNode, pathBased=false) {
442
// rules are met in the parent.masks object from nearest to farthest, but since we finally apply with _.defaults, the nearest has precedence in the final result
440
- const matchers = onlyTruthy(_.map(parent.masks, (mods, k) => {
443
+ const matchers = onlyTruthy(_.map(parent.masks, (mods, mask) => {
444
if (!mods) return
445
const mustBeFolder = (() => { // undefined if no restriction is requested
443
- if (k.at(-1) !== '|') return // parse special flag syntax as suffix |FLAG| inside the key. This allows specifying different flags with the same mask using separate keys. To avoid syntax conflicts with the rest of the file-mask, we look for an ending pipe, as it has no practical use. Ending-pipe was preferred over starting-pipe to leave the rest of the logic (inheritMasks) untouched.
444
- const i = k.lastIndexOf('|', k.length - 2)
446
+ if (mask.at(-1) !== '|') return // parse special flag syntax as suffix |FLAG| inside the key. This allows specifying different flags with the same mask using separate keys. To avoid syntax conflicts with the rest of the file-mask, we look for an ending pipe, as it has no practical use. Ending-pipe was preferred over starting-pipe to leave the rest of the logic (inheritMasks) untouched.
447
+ const i = mask.lastIndexOf('|', mask.length - 2)
448
if (i < 0) return
446
- const type = k.slice(i + 1, -1)
447
- k = k.slice(0, i) // remove
449
+ const type = mask.slice(i + 1, -1)
450
+ mask = mask.slice(0, i) // remove
451
return type === 'folders'
452
})()
450
- const m = /^(!?)\*\*\//.exec(k) // ** globstar matches also zero subfolders, so this mask must be applied here too
451
- k = m ? m[1] + k.slice(m[0].length) : !k.includes('/') ? k : ''
452
- return k && { mods, matcher: makeMatcher(k), mustBeFolder }
453
+ if (pathBased) {
454
+ if (!mask.includes('/')) return
455
+ // avoid evaluating twice masks like **/*.png because parentMaskApplier already handles them by basename
456
+ const m = /^(!?)\*\*\//.exec(mask)
457
+ // this keeps the fast basename path as source-of-truth for patterns that collapse to a filename after **/
458
+ if (m && !mask.slice(m[0].length).includes('/')) return
459
+ }
460
+ else {
461
+ const m = /^(!?)\*\*\//.exec(mask) // ** globstar matches also zero subfolders, so this mask must be applied here too
462
+ mask = m ? m[1] + mask.slice(m[0].length) : !mask.includes('/') ? mask : ''
463
+ if (!mask) return
464
+ }
465
+ return mask && { matcher: makeMatcher(mask), mods, mustBeFolder }
466
}))
454
- return async (item: VfsNode, virtualBasename=basename(getNodeName(item))) => { // we basename for depth>0
467
+ return async (item: VfsNode, virtualName=(pathBased ? _.identity : basename)(getNodeName(item))!) => {
468
+ // depth traversal passes full relative paths, while node traversal still matches only basenames
469
let isFolder: boolean | undefined = undefined
470
for (const { matcher, mods, mustBeFolder } of matchers) {
471
if (mustBeFolder !== undefined) {
472
isFolder ??= nodeIsFolder(item)
473
if (mustBeFolder !== isFolder) continue
474
}
461
- if (!matcher(virtualBasename)) continue
475
+ if (!matcher(virtualName)) continue
476
item.masks &&= _.merge(_.cloneDeep(mods.masks), item.masks) // item.masks must take precedence
477
_.defaults(item, mods)
478
}
479
}
480
}
481
482
+// propagates masks, don't apply
483
function inheritMasks(item: VfsNode, parent: VfsNode, virtualBasename=getNodeName(item)) {
484
const { masks } = parent
485
if (!masks) return
tests/config.yaml
+38
@@ -104,6 +104,44 @@ vfs:
104
- name: mid
105
children:
106
- source: ../alfa.txt
107
+ - name: cantSearchForMasksDeep
108
+ source: ..
109
+ can_see:
110
+ this: false
111
+ rename:
112
+ alfa.txt: gpl-visible.png
113
+ masks:
114
+ page/gpl.png:
115
+ can_see: false
116
+ - name: maskOverlap
117
+ source: ..
118
+ can_see:
119
+ this: false
120
+ rename:
121
+ alfa.txt: gpl-visible.png
122
+ masks:
123
+ "*.png":
124
+ can_see: true
125
+ can_read: false
126
+ page/gpl.png:
127
+ can_see: false
128
+ - name: maskOnRenamedPath
129
+ source: ..
130
+ can_see:
131
+ this: false
132
+ rename:
133
+ page/gpl.png: renamed-gpl.png
134
+ masks:
135
+ page/renamed*:
136
+ can_see: false
137
+ children:
138
+ - name: nested
139
+ source: ..
140
+ rename:
141
+ page/gpl.png: renamed-gpl-nested.png
142
+ masks:
143
+ page/renamed*:
144
+ can_see: false
145
- name: cantReadBut
146
source: ..
147
can_read: false
tests/test.ts
+14
@@ -130,6 +130,16 @@ describe('basics', () => {
130
test('cantListBut.child masked', reqList('/cantListBut/page', 200))
131
test('cantSearchForMasks', reqList('/', { outList: ['cantSearchForMasks/page/gpl.png'] }, { search: 'gpl' }))
132
test('onlyFiles.deep', reqList('/onlyFilesDeep', { outList: ['top/mid/'] }, { onlyFiles: true, search: 'mid' }))
133
+ test('cantSearchForMasks.deep', reqList('/cantSearchForMasksDeep', { inList: ['gpl-visible.png'], outList: ['page/gpl.png'] }, { search: 'gpl' }))
134
+ test('masks.overlap.basename+path', reqList('/maskOverlap', {
135
+ inList: ['gpl-visible.png'],
136
+ outList: ['page/gpl.png'],
137
+ cb: data => /[rR]/.test(_.find(data?.list, { n: 'gpl-visible.png' })?.p || ''),
138
+ }, { search: 'gpl' }))
139
+ test('mask.onRenamedPath', async () => {
140
+ await reqList('/maskOnRenamedPath', { outList: ['page/renamed-gpl.png'] }, { search: 'renamed-gpl' })()
141
+ await reqList('/maskOnRenamedPath', { outList: ['nested/page/renamed-gpl-nested.png'] }, { search: 'renamed-gpl-nested' })()
142
+ })
143
test('cantReadBut', reqList('/cantReadBut/', 403))
144
test('cantReadBut.can', req('/cantReadBut/alfa.txt', 200))
145
test('cantReadBut.parent', reqList('/', { permInList: { 'cantReadBut/': '!r' } }))
@@ -175,6 +185,10 @@ describe('basics', () => {
185
test('zip.partial.end', req('/f1/f2/?get=zip', { re:/^6/, length:10 }, { headers: { Range: 'bytes=-10' } }) )
186
test('zip.list.bad encoding', req('/f1/?get=zip&list=%E0%A4%A//%00', { status: 200, length: 22 })) // basically empty
187
test('zip.list.null filename', req('/f1/?get=zip&list=%00', 400)) // tries to name the output with null-byte
188
+ test('zip.masked deep', req('/cantSearchForMasksDeep/?get=zip', {
189
+ status: 200,
190
+ cb: data => !data.includes('page/gpl.png') && data.includes('gpl-visible.png'),
191
+ }))
192
test('zip.alfa is forbidden', req('/protectFromAbove/child/?get=zip&list=alfa.txt//renamed', { empty: true, length:134 }, { method:'HEAD' }))
193
test('zip.cantReadPage', req('/cantReadPage/?get=zip', { length: 4832 }, { method:'HEAD' }))
194