@samitouri / QOSami-HFS / commits / e4ca1386

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