From 21be266a8d75d3f0890aa59087c2403a2348893a Mon Sep 17 00:00:00 2001 From: Carlos Scheidegger Date: Wed, 1 Jul 2026 13:01:12 -0500 Subject: [PATCH 1/2] Fix downloaded project ZIP using absolute paths (GH #147) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hub-client "Export ZIP" feature produced archives whose entries were absolute (e.g. /cscheid/columns.qmd), because exportProjectAsZip used the stored index paths verbatim and those carry a leading slash. unzip refuses absolute paths and prints "stripped absolute path spec" for every entry. Fix at the ZIP serialization boundary (not the storage convention, which is load-bearing across the sync client and index): - export-zip.ts: exportProjectAsZip(client, rootDir?) now strips leading slashes so entries are relative, and nests every entry under a single sanitized top-level folder when rootDir is given. The importer already strips a common leading directory, so export/import round-trip cleanly. - New project-folder-name.ts helper (exported from quarto-sync-client): derives a safe single path segment from the project name — Windows-hostile chars, path separators, and control chars collapse to hyphens; trailing dots/spaces are trimmed; empty falls back to "project". - automergeSync.ts wrapper forwards an optional rootDir. - ProjectTab.tsx computes one slug via projectFolderName(project.description) and uses it for both the in-archive folder and the .zip download filename, so the two can never drift. Tests: new project-folder-name.test.ts (9 cases), export-zip round-trip and absolute-path cases, and a jsdom ProjectTab.test.tsx for the slug wiring. Verified end-to-end with the real function + system `unzip -l`: entries now read Demo-Playground/cscheid/columns.qmd with no warnings. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-07-01-zip-export-absolute-paths.md | Bin 0 -> 14086 bytes .../src/components/tabs/ProjectTab.test.tsx | 114 ++++++++++++++++++ hub-client/src/components/tabs/ProjectTab.tsx | 15 ++- .../preview-runtime/src/automergeSync.ts | 10 +- .../quarto-sync-client/src/export-zip.test.ts | 82 +++++++++++++ .../quarto-sync-client/src/export-zip.ts | 31 ++++- ts-packages/quarto-sync-client/src/index.ts | 1 + .../src/project-folder-name.test.ts | 50 ++++++++ .../src/project-folder-name.ts | 46 +++++++ 9 files changed, 340 insertions(+), 9 deletions(-) create mode 100644 claude-notes/plans/2026-07-01-zip-export-absolute-paths.md create mode 100644 hub-client/src/components/tabs/ProjectTab.test.tsx create mode 100644 ts-packages/quarto-sync-client/src/project-folder-name.test.ts create mode 100644 ts-packages/quarto-sync-client/src/project-folder-name.ts diff --git a/claude-notes/plans/2026-07-01-zip-export-absolute-paths.md b/claude-notes/plans/2026-07-01-zip-export-absolute-paths.md new file mode 100644 index 0000000000000000000000000000000000000000..9b1770843ca4ba3937e95c6d4d63d032816dce57 GIT binary patch literal 14086 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 literal 0 HcmV?d00001 diff --git a/hub-client/src/components/tabs/ProjectTab.test.tsx b/hub-client/src/components/tabs/ProjectTab.test.tsx new file mode 100644 index 000000000..0006cafb3 --- /dev/null +++ b/hub-client/src/components/tabs/ProjectTab.test.tsx @@ -0,0 +1,114 @@ +/** + * Tests for the "Export ZIP" wiring in ProjectTab. + * + * Scope: the component derives ONE project-folder slug and uses it for both + * the in-archive top-level folder (passed to `onExportZip`) and the download + * filename stem, so the two can never drift (GH #147). The slug is sanitized + * via `projectFolderName`, so a hostile character in the project name must not + * leak into either. + * + * The path normalization itself is exhaustively covered by node-env unit tests + * (quarto-sync-client/export-zip.test.ts and project-folder-name.test.ts); here + * we only assert the UI hands the same, sanitized slug to both consumers. + * + * @vitest-environment jsdom + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent, cleanup } from '@testing-library/react'; +import ProjectTab from './ProjectTab'; +import type { ProjectEntry } from '@quarto/preview-renderer/types/project'; + +function makeProject(description: string): ProjectEntry { + return { + id: 'local-1', + indexDocId: 'automerge:abc123', + syncServer: 'wss://example.test/sync', + description, + createdAt: '2026-07-01T00:00:00.000Z', + lastAccessed: '2026-07-01T00:00:00.000Z', + }; +} + +describe('ProjectTab — Export ZIP wiring', () => { + let clickedDownloadNames: string[]; + let createElementSpy: ReturnType; + + beforeEach(() => { + clickedDownloadNames = []; + // jsdom does not implement object URLs; stub them. + vi.stubGlobal('URL', { + ...URL, + createObjectURL: vi.fn(() => 'blob:mock'), + revokeObjectURL: vi.fn(), + }); + // Capture the download filename of any anchor the handler clicks. + const realCreateElement = document.createElement.bind(document); + createElementSpy = vi + .spyOn(document, 'createElement') + .mockImplementation((tag: string, opts?: ElementCreationOptions) => { + const el = realCreateElement(tag, opts); + if (tag === 'a') { + vi.spyOn(el as HTMLAnchorElement, 'click').mockImplementation(() => { + clickedDownloadNames.push((el as HTMLAnchorElement).download); + }); + } + return el; + }); + }); + + afterEach(() => { + createElementSpy.mockRestore(); + vi.unstubAllGlobals(); + cleanup(); + }); + + it('passes the sanitized slug as rootDir and reuses it for the filename', () => { + const onExportZip = vi.fn(() => new Uint8Array([1, 2, 3])); + render( + {}} + onExportZip={onExportZip} + />, + ); + + fireEvent.click(screen.getByText('Export ZIP')); + + expect(onExportZip).toHaveBeenCalledWith('Demo-Playground'); + expect(clickedDownloadNames).toEqual(['Demo-Playground.zip']); + }); + + it('sanitizes hostile characters in the project name for both outputs', () => { + const onExportZip = vi.fn(() => new Uint8Array([1])); + render( + {}} + onExportZip={onExportZip} + />, + ); + + fireEvent.click(screen.getByText('Export ZIP')); + + // ':' and '?' collapse to hyphens; folder and filename stay in lock-step. + expect(onExportZip).toHaveBeenCalledWith('Demo-Playground'); + expect(clickedDownloadNames).toEqual(['Demo-Playground.zip']); + }); + + it('falls back to "project" when the name is empty', () => { + const onExportZip = vi.fn(() => new Uint8Array([1])); + render( + {}} + onExportZip={onExportZip} + />, + ); + + fireEvent.click(screen.getByText('Export ZIP')); + + expect(onExportZip).toHaveBeenCalledWith('project'); + expect(clickedDownloadNames).toEqual(['project.zip']); + }); +}); diff --git a/hub-client/src/components/tabs/ProjectTab.tsx b/hub-client/src/components/tabs/ProjectTab.tsx index 24e686781..97cc66f18 100644 --- a/hub-client/src/components/tabs/ProjectTab.tsx +++ b/hub-client/src/components/tabs/ProjectTab.tsx @@ -9,13 +9,19 @@ */ import { useState, useCallback } from 'react'; +import { projectFolderName } from '@quarto/quarto-sync-client'; import type { ProjectEntry } from '@quarto/preview-renderer/types/project'; import './ProjectTab.css'; interface ProjectTabProps { project: ProjectEntry; onChooseNewProject: () => void; - onExportZip: () => Uint8Array; + /** + * Produce the project ZIP, nesting every entry under `rootDir` (the + * project-name folder). Callers should pass the same value used for the + * download filename stem so the folder and filename stay in lock-step. + */ + onExportZip: (rootDir: string) => Uint8Array; } export default function ProjectTab({ project, onChooseNewProject, onExportZip }: ProjectTabProps) { @@ -37,12 +43,15 @@ export default function ProjectTab({ project, onChooseNewProject, onExportZip }: setExporting(true); setExportError(null); try { - const zipBytes = onExportZip(); + // One slug drives both the in-archive top-level folder and the download + // filename stem, so they can never drift (GH #147). + const folderName = projectFolderName(project.description); + const zipBytes = onExportZip(folderName); const blob = new Blob([zipBytes.buffer as ArrayBuffer], { type: 'application/zip' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; - a.download = `${(project.description || 'project').replace(/ /g, '-')}.zip`; + a.download = `${folderName}.zip`; a.click(); URL.revokeObjectURL(url); } catch (err) { diff --git a/ts-packages/preview-runtime/src/automergeSync.ts b/ts-packages/preview-runtime/src/automergeSync.ts index 7295e83f9..1db0d3333 100644 --- a/ts-packages/preview-runtime/src/automergeSync.ts +++ b/ts-packages/preview-runtime/src/automergeSync.ts @@ -304,10 +304,14 @@ export function getFilePaths(): string[] { /** * Export all project files as a ZIP archive. - * Returns a Uint8Array containing the ZIP file bytes. + * + * @param rootDir - Optional top-level folder to nest every entry under + * (typically the project's name). See {@link exportZip} for the path + * normalization applied (leading slashes stripped, folder sanitized). + * @returns a Uint8Array containing the ZIP file bytes. */ -export function exportProjectAsZip(): Uint8Array { - return exportZip(ensureClient()); +export function exportProjectAsZip(rootDir?: string): Uint8Array { + return exportZip(ensureClient(), rootDir); } /** diff --git a/ts-packages/quarto-sync-client/src/export-zip.test.ts b/ts-packages/quarto-sync-client/src/export-zip.test.ts index 663409711..a597d67f3 100644 --- a/ts-packages/quarto-sync-client/src/export-zip.test.ts +++ b/ts-packages/quarto-sync-client/src/export-zip.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from 'vitest'; import { unzipSync, strFromU8 } from 'fflate'; import { exportProjectAsZip } from './export-zip.js'; +import { parseProjectZip } from './import-zip.js'; import type { SyncClient } from './client.js'; /** Create a mock SyncClient with the given text and binary files. */ @@ -142,4 +143,85 @@ describe('exportProjectAsZip', () => { expect(Object.keys(entries)).toHaveLength(1); expect(strFromU8(entries['exists.qmd'])).toBe('content'); }); + + // --- Absolute-path bug (GH #147) -------------------------------------- + + it('strips a leading slash from stored paths (no rootDir)', () => { + const client = mockClient({ + textFiles: { + '/cscheid/columns.qmd': 'col', + '/cscheid/crossrefs.qmd': 'xref', + }, + }); + + const zip = exportProjectAsZip(client); + const entries = unzipSync(zip); + + expect(entries['cscheid/columns.qmd']).toBeDefined(); + expect(entries['cscheid/crossrefs.qmd']).toBeDefined(); + // No entry may be absolute — unzip strips those with a warning. + expect(Object.keys(entries).every(k => !k.startsWith('/'))).toBe(true); + }); + + it('nests every entry under the rootDir folder and strips leading slashes', () => { + const client = mockClient({ + textFiles: { + '/cscheid/columns.qmd': 'col', + '/index.qmd': 'root', + }, + binaryFiles: { + '/images/logo.gif': { + content: new Uint8Array([0x47, 0x49, 0x46, 0x38]), + mimeType: 'image/gif', + }, + }, + }); + + const zip = exportProjectAsZip(client, 'Demo-Playground'); + const entries = unzipSync(zip); + + expect(entries['Demo-Playground/cscheid/columns.qmd']).toBeDefined(); + expect(entries['Demo-Playground/index.qmd']).toBeDefined(); + expect(entries['Demo-Playground/images/logo.gif']).toBeDefined(); + // Every entry is under the single top-level folder; none is absolute. + const keys = Object.keys(entries); + expect(keys.every(k => k.startsWith('Demo-Playground/'))).toBe(true); + expect(keys.every(k => !k.startsWith('/'))).toBe(true); + }); + + it('normalizes a rootDir with stray slashes into one clean segment', () => { + const client = mockClient({ + textFiles: { '/index.qmd': 'root' }, + }); + + // Leading/trailing slashes on rootDir must not leak into entry keys. + const zip = exportProjectAsZip(client, '/My Project/'); + const entries = unzipSync(zip); + + const keys = Object.keys(entries); + expect(keys).toEqual(['My-Project/index.qmd']); + expect(keys.every(k => !k.startsWith('/') && !k.includes('//'))).toBe(true); + }); + + it('round-trips through parseProjectZip back to project-relative paths', () => { + const client = mockClient({ + textFiles: { + '/cscheid/columns.qmd': 'col', + '/cscheid/crossrefs.qmd': 'xref', + '/index.qmd': 'root', + }, + }); + + const zip = exportProjectAsZip(client, 'Demo-Playground'); + const parsed = parseProjectZip(zip); + const paths = parsed.map(f => f.path).sort(); + + // The importer strips the common top-level folder, recovering the + // original project-relative paths (leading slash gone on both sides). + expect(paths).toEqual([ + 'cscheid/columns.qmd', + 'cscheid/crossrefs.qmd', + 'index.qmd', + ]); + }); }); diff --git a/ts-packages/quarto-sync-client/src/export-zip.ts b/ts-packages/quarto-sync-client/src/export-zip.ts index ddb8b01a8..d28f7d97a 100644 --- a/ts-packages/quarto-sync-client/src/export-zip.ts +++ b/ts-packages/quarto-sync-client/src/export-zip.ts @@ -6,6 +6,7 @@ */ import { zipSync, strToU8 } from 'fflate'; +import { projectFolderName } from './project-folder-name.js'; import type { SyncClient } from './client.js'; /** @@ -14,28 +15,52 @@ import type { SyncClient } from './client.js'; * Reads every file from the connected SyncClient (text and binary) * and packs them into a ZIP. Text files are encoded as UTF-8. * + * Project paths are stored absolute (leading slash). ZIP entries must be + * *relative* — an absolute entry makes `unzip` emit a "stripped absolute + * path spec" warning and drop the leading slash (GH #147). This function + * therefore always strips leading slashes, and when a `rootDir` is given it + * nests every entry under that single top-level folder (matching the download + * filename), so the archive extracts into one tidy directory. The importer + * (`parseProjectZip`) strips that common folder back off on the way in. + * * @param client - A connected SyncClient instance + * @param rootDir - Optional top-level folder name (typically the project's + * description). Sanitized to a safe single path segment. When omitted or + * blank, entries are packed at the archive root (still relative). * @returns Uint8Array containing the ZIP file bytes * @throws If the client is not connected */ -export function exportProjectAsZip(client: SyncClient): Uint8Array { +export function exportProjectAsZip( + client: SyncClient, + rootDir?: string, +): Uint8Array { if (!client.isConnected()) { throw new Error('SyncClient is not connected'); } + // Sanitize the wrapper folder to a safe single segment. `projectFolderName` + // trims stray leading/trailing separators, so we never emit `//` or an + // absolute prefix. Blank/undefined => no wrapper folder. + const prefix = rootDir && rootDir.trim() ? `${projectFolderName(rootDir)}/` : ''; + const paths = client.getFilePaths(); const files: Record = {}; for (const path of paths) { + // Strip leading slashes so the entry is relative, then nest under prefix. + const relative = path.replace(/^\/+/, ''); + if (relative === '') continue; // guard against a bare "/" path + const key = `${prefix}${relative}`; + if (client.isFileBinary(path)) { const binary = client.getBinaryFileContent(path); if (binary) { - files[path] = binary.content; + files[key] = binary.content; } } else { const text = client.getFileContent(path); if (text !== null) { - files[path] = strToU8(text); + files[key] = strToU8(text); } } } diff --git a/ts-packages/quarto-sync-client/src/index.ts b/ts-packages/quarto-sync-client/src/index.ts index 367d1d12b..d5601e69f 100644 --- a/ts-packages/quarto-sync-client/src/index.ts +++ b/ts-packages/quarto-sync-client/src/index.ts @@ -93,6 +93,7 @@ export { MemoryStorageAdapter } from './storage-adapter.js'; export { computeSHA256 } from './hash.js'; export { exportProjectAsZip } from './export-zip.js'; export { parseProjectZip } from './import-zip.js'; +export { projectFolderName } from './project-folder-name.js'; // Export replay API export { createReplaySession } from './replay.js'; diff --git a/ts-packages/quarto-sync-client/src/project-folder-name.test.ts b/ts-packages/quarto-sync-client/src/project-folder-name.test.ts new file mode 100644 index 000000000..b30d09519 --- /dev/null +++ b/ts-packages/quarto-sync-client/src/project-folder-name.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from 'vitest'; +import { projectFolderName } from './project-folder-name.js'; + +describe('projectFolderName', () => { + it('turns spaces into hyphens (existing download-filename behavior)', () => { + expect(projectFolderName('Demo Playground')).toBe('Demo-Playground'); + }); + + it('falls back to "project" for undefined or empty names', () => { + expect(projectFolderName(undefined)).toBe('project'); + expect(projectFolderName('')).toBe('project'); + expect(projectFolderName(' ')).toBe('project'); + }); + + it('collapses path separators into a single safe segment', () => { + expect(projectFolderName('a/b')).toBe('a-b'); + expect(projectFolderName('a\\b')).toBe('a-b'); + }); + + it('replaces Windows-hostile characters rather than preserving them', () => { + const result = projectFolderName('A: B? "C" |E| *F*'); + expect(result).not.toMatch(/[<>:"/\\|?*]/); + // spaces and reserved chars collapse to single hyphens + expect(result).toBe('A-B-C-D-E-F'); + }); + + it('replaces control characters', () => { + // Tab (U+0009) and other C0 control chars must not survive into a path. + const tab = String.fromCharCode(9); + const result = projectFolderName('a' + tab + 'b' + tab + 'c'); + expect(result).toBe('a-b-c'); + }); + + it('strips trailing dots and spaces (illegal on Windows)', () => { + expect(projectFolderName('My Project.')).toBe('My-Project'); + expect(projectFolderName('My Project...')).toBe('My-Project'); + expect(projectFolderName('trailing ')).toBe('trailing'); + }); + + it('trims leading/trailing hyphens produced by surrounding slashes', () => { + expect(projectFolderName('/My Project/')).toBe('My-Project'); + expect(projectFolderName('/leading')).toBe('leading'); + }); + + it('returns a non-empty result even for all-hostile input', () => { + expect(projectFolderName('///')).toBe('project'); + expect(projectFolderName('***')).toBe('project'); + expect(projectFolderName('...')).toBe('project'); + }); +}); diff --git a/ts-packages/quarto-sync-client/src/project-folder-name.ts b/ts-packages/quarto-sync-client/src/project-folder-name.ts new file mode 100644 index 000000000..50f18f3f0 --- /dev/null +++ b/ts-packages/quarto-sync-client/src/project-folder-name.ts @@ -0,0 +1,46 @@ +/** + * Derive a safe single path segment / filename stem from a project's + * human-readable name (its `description`). + * + * Used for two things that must stay in lock-step (see GH #147): + * - the download filename stem of an exported project ZIP, and + * - the top-level folder every entry inside that ZIP is nested under. + * + * The result is safe to use as one path segment on all platforms: spaces, + * path separators, Windows-reserved characters (`< > : " / \ | ? *`) and + * C0 control characters are collapsed to hyphens, and trailing dots/spaces + * (illegal on Windows) are removed. An empty result falls back to + * `"project"`, matching the historical download-filename fallback. + */ + +// Character codes that are unsafe as a single path segment on some platform: +// the Windows-reserved set plus both path separators. (Control chars and the +// space are handled by the `code <= 0x20` check in `isHostile`.) +const RESERVED_CODES = new Set([ + 0x3c, // < + 0x3e, // > + 0x3a, // : + 0x22, // " + 0x2f, // / (forward slash) + 0x5c, // \ (backslash) + 0x7c, // | + 0x3f, // ? + 0x2a, // * +]); + +function isHostile(code: number): boolean { + // code <= 0x20 covers all C0 control chars (0x00–0x1f) and the space (0x20). + return code <= 0x20 || RESERVED_CODES.has(code); +} + +export function projectFolderName(description: string | undefined): string { + let out = ''; + for (const ch of description || '') { + out += isHostile(ch.charCodeAt(0)) ? '-' : ch; + } + const cleaned = out + .replace(/-+/g, '-') // collapse runs of hyphens + .replace(/^-+/, '') // trim leading hyphens (e.g. from a leading slash) + .replace(/[-. ]+$/, ''); // trim trailing hyphen/dot/space + return cleaned || 'project'; +} From 8644683c2744d763a11dbdf2e07bab177c9eab2e Mon Sep 17 00:00:00 2001 From: Carlos Scheidegger Date: Wed, 1 Jul 2026 13:01:35 -0500 Subject: [PATCH 2/2] Add changelog entry for ZIP absolute-paths fix Co-Authored-By: Claude Opus 4.8 (1M context) --- hub-client/changelog.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/hub-client/changelog.md b/hub-client/changelog.md index 7e56c43af..ba38b713d 100644 --- a/hub-client/changelog.md +++ b/hub-client/changelog.md @@ -15,6 +15,10 @@ be in reverse chronological order (latest first). --> +### 2026-07-01 + +- [`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. + ### 2026-06-25 - [`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.