@@ -327,16 +327,19 @@ async function collectFiles(
327327}
328328
329329// Build a ZIP of every file under the project directory (or under `root`,
330- // if it points at a subdirectory). Mirrors listFiles' filtering — dotfiles
331- // and `.artifact.json` sidecars are excluded — so the archive matches what
332- // the user sees in the file panel. Used by the "Download as .zip" share
333- // menu item, which exports the user's actual project tree (e.g. the
334- // uploaded `ui-design/` folder), not just the rendered HTML.
330+ // if it points at a subdirectory). Mirrors listFiles' filtering: managed
331+ // projects keep user-owned dotfiles, imported folders keep hiding all hidden
332+ // segments, and ignored/reserved trees plus `.artifact.json` sidecars stay
333+ // excluded everywhere. Used by the "Download as .zip" share menu item, which
334+ // exports the user's actual project tree (e.g. the uploaded `ui-design/`
335+ // folder), not just the rendered HTML.
335336export async function buildProjectArchive ( projectsRoot , projectId , root , metadata ?) {
336337 const projectRoot = resolveProjectDir ( projectsRoot , projectId , metadata ) ;
338+ const skipHidden = hasExternalProjectRoot ( metadata ) ;
337339 let archiveRoot = projectRoot ;
338340 let archiveBaseName = '' ;
339341 if ( typeof root === 'string' && root . trim ( ) . length > 0 ) {
342+ assertVisibleForImportedProject ( root , metadata ) ;
340343 // Use the symlink-aware resolver so that an imported folder containing
341344 // e.g. `docs -> /Users/me/.ssh` cannot exfiltrate via
342345 // GET /api/projects/:id/archive?root=docs. resolveSafe()'s string
@@ -369,7 +372,7 @@ export async function buildProjectArchive(projectsRoot, projectId, root, metadat
369372 }
370373
371374 const entries = [ ] ;
372- await collectArchiveEntries ( archiveRoot , '' , entries ) ;
375+ await collectArchiveEntries ( archiveRoot , '' , entries , skipHidden ) ;
373376 if ( entries . length === 0 ) {
374377 const err = new Error ( 'archive root is empty' ) ;
375378 err . code = 'ENOENT' ;
@@ -414,20 +417,23 @@ export async function buildBatchArchive(projectsRoot, projectId, fileNames, meta
414417 }
415418
416419 // Mirror the visible-file allowlist from collectFiles/collectArchiveEntries:
417- // reject any hidden segment, .artifact.json sidecars, and symlinks at any
418- // level of the path (not just the final basename).
420+ // imported folders reject every hidden segment; managed projects allow
421+ // user dotfiles but still reject ignored/reserved trees. Sidecars and
422+ // symlinks remain ineligible everywhere.
419423 const relSegments = path . relative ( projectRoot , filePath ) . split ( path . sep ) ;
420- let hidden = false ;
421- for ( const seg of relSegments ) {
422- if ( seg . startsWith ( '.' ) ) {
423- hidden = true ;
424- break ;
425- }
426- }
427- if ( hidden ) {
424+ const importedHidden =
425+ hasExternalProjectRoot ( metadata ) && relSegments . some ( ( seg ) => seg . startsWith ( '.' ) ) ;
426+ if ( importedHidden ) {
428427 rejected . push ( { name, reason : 'hidden segments are not eligible for archive' } ) ;
429428 continue ;
430429 }
430+ // Only directory segments participate in the shared directory ignore
431+ // policy. A regular user file may legitimately be named `build` or
432+ // `vendor`; validateProjectPath() already rejects reserved state segments.
433+ if ( relSegments . slice ( 0 , - 1 ) . some ( ( seg ) => isIgnoredProjectDirName ( seg ) ) ) {
434+ rejected . push ( { name, reason : 'ignored directory segments are not eligible for archive' } ) ;
435+ continue ;
436+ }
431437 if ( path . basename ( filePath ) . endsWith ( '.artifact.json' ) ) {
432438 rejected . push ( { name, reason : 'artifact sidecars are not eligible for archive' } ) ;
433439 continue ;
@@ -515,7 +521,7 @@ export async function buildBatchArchive(projectsRoot, projectId, fileNames, meta
515521 return { buffer, baseName : '' } ;
516522}
517523
518- async function collectArchiveEntries ( dir , relDir , out ) {
524+ async function collectArchiveEntries ( dir , relDir , out , skipHidden = false ) {
519525 let entries = [ ] ;
520526 try {
521527 entries = await readdir ( dir , { withFileTypes : true } ) ;
@@ -524,13 +530,13 @@ async function collectArchiveEntries(dir, relDir, out) {
524530 throw err ;
525531 }
526532 for ( const e of entries ) {
527- if ( e . name . startsWith ( '.' ) ) continue ;
533+ if ( skipHidden && e . name . startsWith ( '.' ) ) continue ;
528534 if ( ! e . isDirectory ( ) && ! e . isFile ( ) ) continue ;
529535 const rel = relDir ? `${ relDir } /${ e . name } ` : e . name ;
530536 const full = path . join ( dir , e . name ) ;
531537 if ( e . isDirectory ( ) ) {
532- if ( isIgnoredProjectDirName ( e . name ) ) continue ;
533- await collectArchiveEntries ( full , rel , out ) ;
538+ if ( isListingSkippedDirName ( e . name ) ) continue ;
539+ await collectArchiveEntries ( full , rel , out , skipHidden ) ;
534540 continue ;
535541 }
536542 if ( e . name . endsWith ( '.artifact.json' ) ) continue ;
0 commit comments