Skip to content

Commit 4fbe7ae

Browse files
authored
Merge pull request #356 from quarto-dev/feature/bd-esnxtcoy-zip-absolute-paths
Fix downloaded project ZIP using absolute paths (#147)
2 parents 298611a + 8644683 commit 4fbe7ae

10 files changed

Lines changed: 344 additions & 9 deletions

File tree

13.8 KB
Binary file not shown.

hub-client/changelog.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ be in reverse chronological order (latest first).
1515
1616
-->
1717

18+
### 2026-07-01
19+
20+
- [`21be266a`](https://github.com/quarto-dev/q2/commits/21be266a): Downloaded project ZIPs now use relative paths nested under a single project-name folder (e.g. `Demo-Playground/index.qmd`) instead of absolute paths — `unzip` no longer warns about "stripped absolute path spec" and the archive extracts into one tidy directory.
21+
1822
### 2026-06-25
1923

2024
- [`d6066dc9`](https://github.com/quarto-dev/q2/commits/d6066dc9): The editing toolbar (and breadcrumb navigator) no longer gets cut off when you edit the very first block of a document with no title — it now flips below the block when there isn't room above.
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
/**
2+
* Tests for the "Export ZIP" wiring in ProjectTab.
3+
*
4+
* Scope: the component derives ONE project-folder slug and uses it for both
5+
* the in-archive top-level folder (passed to `onExportZip`) and the download
6+
* filename stem, so the two can never drift (GH #147). The slug is sanitized
7+
* via `projectFolderName`, so a hostile character in the project name must not
8+
* leak into either.
9+
*
10+
* The path normalization itself is exhaustively covered by node-env unit tests
11+
* (quarto-sync-client/export-zip.test.ts and project-folder-name.test.ts); here
12+
* we only assert the UI hands the same, sanitized slug to both consumers.
13+
*
14+
* @vitest-environment jsdom
15+
*/
16+
17+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
18+
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
19+
import ProjectTab from './ProjectTab';
20+
import type { ProjectEntry } from '@quarto/preview-renderer/types/project';
21+
22+
function makeProject(description: string): ProjectEntry {
23+
return {
24+
id: 'local-1',
25+
indexDocId: 'automerge:abc123',
26+
syncServer: 'wss://example.test/sync',
27+
description,
28+
createdAt: '2026-07-01T00:00:00.000Z',
29+
lastAccessed: '2026-07-01T00:00:00.000Z',
30+
};
31+
}
32+
33+
describe('ProjectTab — Export ZIP wiring', () => {
34+
let clickedDownloadNames: string[];
35+
let createElementSpy: ReturnType<typeof vi.spyOn>;
36+
37+
beforeEach(() => {
38+
clickedDownloadNames = [];
39+
// jsdom does not implement object URLs; stub them.
40+
vi.stubGlobal('URL', {
41+
...URL,
42+
createObjectURL: vi.fn(() => 'blob:mock'),
43+
revokeObjectURL: vi.fn(),
44+
});
45+
// Capture the download filename of any anchor the handler clicks.
46+
const realCreateElement = document.createElement.bind(document);
47+
createElementSpy = vi
48+
.spyOn(document, 'createElement')
49+
.mockImplementation((tag: string, opts?: ElementCreationOptions) => {
50+
const el = realCreateElement(tag, opts);
51+
if (tag === 'a') {
52+
vi.spyOn(el as HTMLAnchorElement, 'click').mockImplementation(() => {
53+
clickedDownloadNames.push((el as HTMLAnchorElement).download);
54+
});
55+
}
56+
return el;
57+
});
58+
});
59+
60+
afterEach(() => {
61+
createElementSpy.mockRestore();
62+
vi.unstubAllGlobals();
63+
cleanup();
64+
});
65+
66+
it('passes the sanitized slug as rootDir and reuses it for the filename', () => {
67+
const onExportZip = vi.fn(() => new Uint8Array([1, 2, 3]));
68+
render(
69+
<ProjectTab
70+
project={makeProject('Demo Playground')}
71+
onChooseNewProject={() => {}}
72+
onExportZip={onExportZip}
73+
/>,
74+
);
75+
76+
fireEvent.click(screen.getByText('Export ZIP'));
77+
78+
expect(onExportZip).toHaveBeenCalledWith('Demo-Playground');
79+
expect(clickedDownloadNames).toEqual(['Demo-Playground.zip']);
80+
});
81+
82+
it('sanitizes hostile characters in the project name for both outputs', () => {
83+
const onExportZip = vi.fn(() => new Uint8Array([1]));
84+
render(
85+
<ProjectTab
86+
project={makeProject('Demo: Playground?')}
87+
onChooseNewProject={() => {}}
88+
onExportZip={onExportZip}
89+
/>,
90+
);
91+
92+
fireEvent.click(screen.getByText('Export ZIP'));
93+
94+
// ':' and '?' collapse to hyphens; folder and filename stay in lock-step.
95+
expect(onExportZip).toHaveBeenCalledWith('Demo-Playground');
96+
expect(clickedDownloadNames).toEqual(['Demo-Playground.zip']);
97+
});
98+
99+
it('falls back to "project" when the name is empty', () => {
100+
const onExportZip = vi.fn(() => new Uint8Array([1]));
101+
render(
102+
<ProjectTab
103+
project={makeProject('')}
104+
onChooseNewProject={() => {}}
105+
onExportZip={onExportZip}
106+
/>,
107+
);
108+
109+
fireEvent.click(screen.getByText('Export ZIP'));
110+
111+
expect(onExportZip).toHaveBeenCalledWith('project');
112+
expect(clickedDownloadNames).toEqual(['project.zip']);
113+
});
114+
});

hub-client/src/components/tabs/ProjectTab.tsx

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,19 @@
99
*/
1010

1111
import { useState, useCallback } from 'react';
12+
import { projectFolderName } from '@quarto/quarto-sync-client';
1213
import type { ProjectEntry } from '@quarto/preview-renderer/types/project';
1314
import './ProjectTab.css';
1415

1516
interface ProjectTabProps {
1617
project: ProjectEntry;
1718
onChooseNewProject: () => void;
18-
onExportZip: () => Uint8Array;
19+
/**
20+
* Produce the project ZIP, nesting every entry under `rootDir` (the
21+
* project-name folder). Callers should pass the same value used for the
22+
* download filename stem so the folder and filename stay in lock-step.
23+
*/
24+
onExportZip: (rootDir: string) => Uint8Array;
1925
}
2026

2127
export default function ProjectTab({ project, onChooseNewProject, onExportZip }: ProjectTabProps) {
@@ -37,12 +43,15 @@ export default function ProjectTab({ project, onChooseNewProject, onExportZip }:
3743
setExporting(true);
3844
setExportError(null);
3945
try {
40-
const zipBytes = onExportZip();
46+
// One slug drives both the in-archive top-level folder and the download
47+
// filename stem, so they can never drift (GH #147).
48+
const folderName = projectFolderName(project.description);
49+
const zipBytes = onExportZip(folderName);
4150
const blob = new Blob([zipBytes.buffer as ArrayBuffer], { type: 'application/zip' });
4251
const url = URL.createObjectURL(blob);
4352
const a = document.createElement('a');
4453
a.href = url;
45-
a.download = `${(project.description || 'project').replace(/ /g, '-')}.zip`;
54+
a.download = `${folderName}.zip`;
4655
a.click();
4756
URL.revokeObjectURL(url);
4857
} catch (err) {

ts-packages/preview-runtime/src/automergeSync.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -304,10 +304,14 @@ export function getFilePaths(): string[] {
304304

305305
/**
306306
* Export all project files as a ZIP archive.
307-
* Returns a Uint8Array containing the ZIP file bytes.
307+
*
308+
* @param rootDir - Optional top-level folder to nest every entry under
309+
* (typically the project's name). See {@link exportZip} for the path
310+
* normalization applied (leading slashes stripped, folder sanitized).
311+
* @returns a Uint8Array containing the ZIP file bytes.
308312
*/
309-
export function exportProjectAsZip(): Uint8Array {
310-
return exportZip(ensureClient());
313+
export function exportProjectAsZip(rootDir?: string): Uint8Array {
314+
return exportZip(ensureClient(), rootDir);
311315
}
312316

313317
/**

ts-packages/quarto-sync-client/src/export-zip.test.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, it, expect } from 'vitest';
22
import { unzipSync, strFromU8 } from 'fflate';
33
import { exportProjectAsZip } from './export-zip.js';
4+
import { parseProjectZip } from './import-zip.js';
45
import type { SyncClient } from './client.js';
56

67
/** Create a mock SyncClient with the given text and binary files. */
@@ -142,4 +143,85 @@ describe('exportProjectAsZip', () => {
142143
expect(Object.keys(entries)).toHaveLength(1);
143144
expect(strFromU8(entries['exists.qmd'])).toBe('content');
144145
});
146+
147+
// --- Absolute-path bug (GH #147) --------------------------------------
148+
149+
it('strips a leading slash from stored paths (no rootDir)', () => {
150+
const client = mockClient({
151+
textFiles: {
152+
'/cscheid/columns.qmd': 'col',
153+
'/cscheid/crossrefs.qmd': 'xref',
154+
},
155+
});
156+
157+
const zip = exportProjectAsZip(client);
158+
const entries = unzipSync(zip);
159+
160+
expect(entries['cscheid/columns.qmd']).toBeDefined();
161+
expect(entries['cscheid/crossrefs.qmd']).toBeDefined();
162+
// No entry may be absolute — unzip strips those with a warning.
163+
expect(Object.keys(entries).every(k => !k.startsWith('/'))).toBe(true);
164+
});
165+
166+
it('nests every entry under the rootDir folder and strips leading slashes', () => {
167+
const client = mockClient({
168+
textFiles: {
169+
'/cscheid/columns.qmd': 'col',
170+
'/index.qmd': 'root',
171+
},
172+
binaryFiles: {
173+
'/images/logo.gif': {
174+
content: new Uint8Array([0x47, 0x49, 0x46, 0x38]),
175+
mimeType: 'image/gif',
176+
},
177+
},
178+
});
179+
180+
const zip = exportProjectAsZip(client, 'Demo-Playground');
181+
const entries = unzipSync(zip);
182+
183+
expect(entries['Demo-Playground/cscheid/columns.qmd']).toBeDefined();
184+
expect(entries['Demo-Playground/index.qmd']).toBeDefined();
185+
expect(entries['Demo-Playground/images/logo.gif']).toBeDefined();
186+
// Every entry is under the single top-level folder; none is absolute.
187+
const keys = Object.keys(entries);
188+
expect(keys.every(k => k.startsWith('Demo-Playground/'))).toBe(true);
189+
expect(keys.every(k => !k.startsWith('/'))).toBe(true);
190+
});
191+
192+
it('normalizes a rootDir with stray slashes into one clean segment', () => {
193+
const client = mockClient({
194+
textFiles: { '/index.qmd': 'root' },
195+
});
196+
197+
// Leading/trailing slashes on rootDir must not leak into entry keys.
198+
const zip = exportProjectAsZip(client, '/My Project/');
199+
const entries = unzipSync(zip);
200+
201+
const keys = Object.keys(entries);
202+
expect(keys).toEqual(['My-Project/index.qmd']);
203+
expect(keys.every(k => !k.startsWith('/') && !k.includes('//'))).toBe(true);
204+
});
205+
206+
it('round-trips through parseProjectZip back to project-relative paths', () => {
207+
const client = mockClient({
208+
textFiles: {
209+
'/cscheid/columns.qmd': 'col',
210+
'/cscheid/crossrefs.qmd': 'xref',
211+
'/index.qmd': 'root',
212+
},
213+
});
214+
215+
const zip = exportProjectAsZip(client, 'Demo-Playground');
216+
const parsed = parseProjectZip(zip);
217+
const paths = parsed.map(f => f.path).sort();
218+
219+
// The importer strips the common top-level folder, recovering the
220+
// original project-relative paths (leading slash gone on both sides).
221+
expect(paths).toEqual([
222+
'cscheid/columns.qmd',
223+
'cscheid/crossrefs.qmd',
224+
'index.qmd',
225+
]);
226+
});
145227
});

ts-packages/quarto-sync-client/src/export-zip.ts

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
*/
77

88
import { zipSync, strToU8 } from 'fflate';
9+
import { projectFolderName } from './project-folder-name.js';
910
import type { SyncClient } from './client.js';
1011

1112
/**
@@ -14,28 +15,52 @@ import type { SyncClient } from './client.js';
1415
* Reads every file from the connected SyncClient (text and binary)
1516
* and packs them into a ZIP. Text files are encoded as UTF-8.
1617
*
18+
* Project paths are stored absolute (leading slash). ZIP entries must be
19+
* *relative* — an absolute entry makes `unzip` emit a "stripped absolute
20+
* path spec" warning and drop the leading slash (GH #147). This function
21+
* therefore always strips leading slashes, and when a `rootDir` is given it
22+
* nests every entry under that single top-level folder (matching the download
23+
* filename), so the archive extracts into one tidy directory. The importer
24+
* (`parseProjectZip`) strips that common folder back off on the way in.
25+
*
1726
* @param client - A connected SyncClient instance
27+
* @param rootDir - Optional top-level folder name (typically the project's
28+
* description). Sanitized to a safe single path segment. When omitted or
29+
* blank, entries are packed at the archive root (still relative).
1830
* @returns Uint8Array containing the ZIP file bytes
1931
* @throws If the client is not connected
2032
*/
21-
export function exportProjectAsZip(client: SyncClient): Uint8Array {
33+
export function exportProjectAsZip(
34+
client: SyncClient,
35+
rootDir?: string,
36+
): Uint8Array {
2237
if (!client.isConnected()) {
2338
throw new Error('SyncClient is not connected');
2439
}
2540

41+
// Sanitize the wrapper folder to a safe single segment. `projectFolderName`
42+
// trims stray leading/trailing separators, so we never emit `//` or an
43+
// absolute prefix. Blank/undefined => no wrapper folder.
44+
const prefix = rootDir && rootDir.trim() ? `${projectFolderName(rootDir)}/` : '';
45+
2646
const paths = client.getFilePaths();
2747
const files: Record<string, Uint8Array> = {};
2848

2949
for (const path of paths) {
50+
// Strip leading slashes so the entry is relative, then nest under prefix.
51+
const relative = path.replace(/^\/+/, '');
52+
if (relative === '') continue; // guard against a bare "/" path
53+
const key = `${prefix}${relative}`;
54+
3055
if (client.isFileBinary(path)) {
3156
const binary = client.getBinaryFileContent(path);
3257
if (binary) {
33-
files[path] = binary.content;
58+
files[key] = binary.content;
3459
}
3560
} else {
3661
const text = client.getFileContent(path);
3762
if (text !== null) {
38-
files[path] = strToU8(text);
63+
files[key] = strToU8(text);
3964
}
4065
}
4166
}

ts-packages/quarto-sync-client/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ export { MemoryStorageAdapter } from './storage-adapter.js';
9393
export { computeSHA256 } from './hash.js';
9494
export { exportProjectAsZip } from './export-zip.js';
9595
export { parseProjectZip } from './import-zip.js';
96+
export { projectFolderName } from './project-folder-name.js';
9697

9798
// Export replay API
9899
export { createReplaySession } from './replay.js';
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { projectFolderName } from './project-folder-name.js';
3+
4+
describe('projectFolderName', () => {
5+
it('turns spaces into hyphens (existing download-filename behavior)', () => {
6+
expect(projectFolderName('Demo Playground')).toBe('Demo-Playground');
7+
});
8+
9+
it('falls back to "project" for undefined or empty names', () => {
10+
expect(projectFolderName(undefined)).toBe('project');
11+
expect(projectFolderName('')).toBe('project');
12+
expect(projectFolderName(' ')).toBe('project');
13+
});
14+
15+
it('collapses path separators into a single safe segment', () => {
16+
expect(projectFolderName('a/b')).toBe('a-b');
17+
expect(projectFolderName('a\\b')).toBe('a-b');
18+
});
19+
20+
it('replaces Windows-hostile characters rather than preserving them', () => {
21+
const result = projectFolderName('A: B? "C" <D> |E| *F*');
22+
expect(result).not.toMatch(/[<>:"/\\|?*]/);
23+
// spaces and reserved chars collapse to single hyphens
24+
expect(result).toBe('A-B-C-D-E-F');
25+
});
26+
27+
it('replaces control characters', () => {
28+
// Tab (U+0009) and other C0 control chars must not survive into a path.
29+
const tab = String.fromCharCode(9);
30+
const result = projectFolderName('a' + tab + 'b' + tab + 'c');
31+
expect(result).toBe('a-b-c');
32+
});
33+
34+
it('strips trailing dots and spaces (illegal on Windows)', () => {
35+
expect(projectFolderName('My Project.')).toBe('My-Project');
36+
expect(projectFolderName('My Project...')).toBe('My-Project');
37+
expect(projectFolderName('trailing ')).toBe('trailing');
38+
});
39+
40+
it('trims leading/trailing hyphens produced by surrounding slashes', () => {
41+
expect(projectFolderName('/My Project/')).toBe('My-Project');
42+
expect(projectFolderName('/leading')).toBe('leading');
43+
});
44+
45+
it('returns a non-empty result even for all-hostile input', () => {
46+
expect(projectFolderName('///')).toBe('project');
47+
expect(projectFolderName('***')).toBe('project');
48+
expect(projectFolderName('...')).toBe('project');
49+
});
50+
});

0 commit comments

Comments
 (0)