Skip to content

Commit 8827edc

Browse files
committed
fix(daemon): align archives with managed dotfile visibility
1 parent 851827b commit 8827edc

3 files changed

Lines changed: 82 additions & 24 deletions

File tree

apps/daemon/src/projects.ts

Lines changed: 26 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
335336
export 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;

apps/daemon/tests/project-archive.test.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,14 @@ describe('buildProjectArchive', () => {
3636
.filter((entry) => !entry.dir)
3737
.map((entry) => entry.name)
3838
.sort();
39-
expect(fileEntries).toEqual(['DESIGN-HANDOFF.md', 'DESIGN-MANIFEST.json', 'frames/phone.html', 'index.html', 'src/app.css']);
39+
expect(fileEntries).toEqual([
40+
'.hidden',
41+
'DESIGN-HANDOFF.md',
42+
'DESIGN-MANIFEST.json',
43+
'frames/phone.html',
44+
'index.html',
45+
'src/app.css',
46+
]);
4047
});
4148

4249
it('zips the whole project when no root is given', async () => {
@@ -49,10 +56,11 @@ describe('buildProjectArchive', () => {
4956
expect(fileEntries).toContain('DESIGN-HANDOFF.md');
5057
expect(fileEntries).toContain('DESIGN-MANIFEST.json');
5158
expect(fileEntries).toContain('README.md');
59+
expect(fileEntries).toContain('ui-design/.hidden');
5260
expect(fileEntries).toContain('ui-design/index.html');
5361
expect(fileEntries).toContain('ui-design/src/app.css');
54-
// dotfiles and .artifact.json sidecars are filtered, matching listFiles
55-
expect(fileEntries.find((n) => n.includes('.hidden'))).toBeUndefined();
62+
// Invariant: managed-project archives match listFiles and keep user
63+
// dotfiles, while generated .artifact.json sidecars remain excluded.
5664
expect(fileEntries.find((n) => n.endsWith('.artifact.json'))).toBeUndefined();
5765
});
5866

apps/daemon/tests/project-hidden-files.test.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,15 @@ import { mkdtempSync, rmSync } from 'node:fs';
22
import { mkdir, writeFile } from 'node:fs/promises';
33
import { tmpdir } from 'node:os';
44
import path from 'node:path';
5+
import JSZip from 'jszip';
56
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
67

7-
import { buildBatchArchive, listFiles, listProjectFolders } from '../src/projects.js';
8+
import {
9+
buildBatchArchive,
10+
buildProjectArchive,
11+
listFiles,
12+
listProjectFolders,
13+
} from '../src/projects.js';
814

915
// Regression tests for #6175: the file/folder listing helpers used a blanket
1016
// `startsWith('.')` filter, so legitimate dot-prefixed user content
@@ -81,6 +87,35 @@ describe('dot-prefixed user content in managed projects (#6175)', () => {
8187
expect(names).not.toContain('.file-versions');
8288
});
8389

90+
it('includes visible dot-prefixed user content in full project archives', async () => {
91+
const { buffer } = await buildProjectArchive(projectsRoot, projectId, '');
92+
const zip = await JSZip.loadAsync(buffer);
93+
const paths = Object.keys(zip.files);
94+
95+
expect(paths).toContain('.github/workflows/ci.yml');
96+
expect(paths).toContain('.storybook/main.ts');
97+
expect(paths).toContain('.notes.md');
98+
expect(paths.some((p) => p.startsWith('.git/'))).toBe(false);
99+
expect(paths.some((p) => p.startsWith('.od/'))).toBe(false);
100+
expect(paths.some((p) => p.startsWith('node_modules/'))).toBe(false);
101+
expect(paths.some((p) => p.startsWith('.live-artifacts/'))).toBe(false);
102+
expect(paths.some((p) => p.startsWith('.file-versions/'))).toBe(false);
103+
});
104+
105+
it('includes visible dot-prefixed user content in batch archives', async () => {
106+
await writeFile(path.join(projectsRoot, projectId, 'build'), 'plain user file');
107+
const { buffer } = await buildBatchArchive(projectsRoot, projectId, [
108+
'.github/workflows/ci.yml',
109+
'.notes.md',
110+
'build',
111+
]);
112+
const zip = await JSZip.loadAsync(buffer);
113+
114+
expect(Object.keys(zip.files)).toEqual(
115+
expect.arrayContaining(['.github/workflows/ci.yml', '.notes.md', 'build']),
116+
);
117+
});
118+
84119
it('batch archive keeps rejecting reserved daemon state', async () => {
85120
await expect(
86121
buildBatchArchive(projectsRoot, projectId, ['.live-artifacts/artifact-1/index.html']),
@@ -122,6 +157,15 @@ describe('dot-prefixed entries in imported folders stay hidden (#6175)', () => {
122157
expect(names.some((p) => p.startsWith('.'))).toBe(false);
123158
});
124159

160+
it('keeps rejecting hidden archive roots for external baseDir projects', async () => {
161+
await expect(
162+
buildProjectArchive('/unused/projects', 'unused-id', '.github', {
163+
kind: 'prototype',
164+
baseDir,
165+
}),
166+
).rejects.toThrow(/hidden path segments/);
167+
});
168+
125169
it('keeps rejecting hidden segments in batch archives for external baseDir projects', async () => {
126170
await expect(
127171
buildBatchArchive('/unused/projects', 'unused-id', ['.github/workflows/ci.yml'], {

0 commit comments

Comments
 (0)