diff --git a/runtime/engines/reveal.ts b/runtime/engines/reveal.ts index 96dcfd2..f218ee1 100644 --- a/runtime/engines/reveal.ts +++ b/runtime/engines/reveal.ts @@ -1,11 +1,117 @@ -import { mkdir, readFile, writeFile } from 'node:fs/promises'; -import { dirname, join } from 'node:path'; +import { mkdir, readFile, writeFile, readdir, cp, lstat, rm } from 'node:fs/promises'; +import type { Dirent } from 'node:fs'; +import { dirname, join, basename, resolve, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; import { marked } from 'marked'; -const REVEAL_PREFIX = '/vendor/reveal'; +// Relative (no leading slash) so the emitted HTML works both as served +// from the local review server AND as a standalone file:// open after +// publish_deck multi-file mode. The dev server's /vendor/reveal/* route +// still matches because the browser resolves the relative path against +// the page URL, which is the server root '/'. +const REVEAL_PREFIX = 'vendor/reveal'; const __dirname = dirname(fileURLToPath(import.meta.url)); +/** + * Names at the deck root that are deckmark/agent internals, NOT user assets. + * Everything else (images/, fonts/, etc.) gets copied into build/ so the + * rendered deck can reference assets via relative paths like + * `` and the local server can serve them. + * + * `vendor` is excluded so a user-provided `vendor/` folder cannot collide + * with — or overwrite — the official reveal.js dist that publish_deck + * (multi-file mode) places at `/vendor/reveal/`. + */ +const EXCLUDED_FROM_ASSET_SYNC = new Set([ + // The content markdown is excluded dynamically by basename(opts.contentPath) + // — it may be named anything (slides.md, talk.md, etc.), and the MCP layer + // lets callers override it. 'content.md' stays here as a belt-and-suspenders + // default for the common case. + 'content.md', + 'deckmark.config.json', + 'annotations', + 'build', + 'published', + 'vendor', + 'node_modules', + '.git', + '.github', + '.gitignore', + '.claude-plugin', + '.mcp.json', + 'AGENTS.md', + 'CLAUDE.md', + 'GEMINI.md', + '.codex', + '.cursor' +]); + +/** + * cp() filter that rejects symlinks at any depth. Required because + * `cp(..., { recursive: true })` without this would copy nested symlinks + * verbatim (e.g. `images/secret -> /etc/passwd`); the static server then + * follows them via stat()/readFile(), exposing arbitrary local files. + * + * `lstat` (not `stat`) is mandatory here — `stat` follows symlinks and + * would report the target's type, defeating the check. + */ +async function rejectSymlink(src: string): Promise { + try { + const st = await lstat(src); + return !st.isSymbolicLink(); + } catch { + return false; + } +} + +/** + * Mirror non-internal files from the deck folder into build/ so the rendered + * HTML can reference user assets (images, fonts, etc.) via stable relative + * paths. Re-running overwrites matching files in build/. In the normal build + * flow, removed assets do not linger because `buildDeck` clears the output + * directory before syncing user assets. + * + * Symlinks are skipped at every depth (top-level entries + the `filter` + * passed to cp()) to keep links like `images/secret -> /etc/passwd` out of + * the published output. The static server has its own containment check, + * but this is defense in depth. + */ +async function syncUserAssetsToBuild( + deckDir: string, + buildDir: string, + contentBase: string +): Promise { + let entries: Dirent[]; + try { + entries = await readdir(deckDir, { withFileTypes: true }); + } catch { + return; + } + // Compare resolved absolute paths — not basenames — so a deck that happens + // to contain a folder whose name matches the buildDir basename (e.g. an + // `output/` assets folder when outDir=/tmp/out/output/) doesn't get + // accidentally skipped. Only the literal buildDir is excluded. + const buildResolved = resolve(buildDir); + for (const e of entries) { + // Hidden / dotfiles never sync. + if (e.name.startsWith('.')) continue; + if (EXCLUDED_FROM_ASSET_SYNC.has(e.name)) continue; + // Skip the content markdown (any filename — caller passes its basename). + if (e.name === contentBase) continue; + // Don't sync the build dir itself, even if it sits inside deckDir. + if (resolve(join(deckDir, e.name)) === buildResolved) continue; + // Top-level .html and .tgz files are likely publish artifacts; skip. + if (!e.isDirectory() && (e.name.endsWith('.html') || e.name.endsWith('.tgz'))) continue; + // Fast path: skip top-level symlinks before recursing. + if (e.isSymbolicLink()) continue; + const src = join(deckDir, e.name); + const dst = join(buildDir, e.name); + // `filter` is applied to every src path cp() visits during recursion, + // so nested symlinks are rejected too — not just top-level ones. + await cp(src, dst, { recursive: true, force: true, filter: rejectSymlink }); + } +} + export type DeckStyle = 'professional' | 'academic' | 'fashion' | 'technical' | 'fun'; export type DeckMode = 'light' | 'dark'; export type DeckMotion = 'slide-transitions' | 'fragment-reveals' | 'auto-animate'; @@ -133,7 +239,65 @@ ${sections.join('\n')} `; + // Clean the build dir before each build so stale entries (especially any + // pre-existing symlinks) can't survive a rebuild. Because rm() is + // destructive, we ratchet through a layered guard: + // + // (a) filesystem root → throw (would wipe a drive) + // (b) outDir contains the deck source → throw (would wipe content.md) + // (c) outDir exists, is non-empty, has no .deckmark-build marker → throw + // (it's a user-owned dir; refusing to clean prevents catastrophic + // data loss if outDir is mis-specified, e.g. pointed at ~/Documents) + // + // First-build flow: outDir doesn't exist OR is empty → skip rm, mkdir, + // drop marker. Subsequent builds find the marker (we wrote it last time) + // and can rm safely. + const resolvedOutDir = resolve(opts.outDir); + const resolvedContent = resolve(opts.contentPath); + if (dirname(resolvedOutDir) === resolvedOutDir) { + throw new Error( + `buildDeck: refusing to clean filesystem root as outDir: ${resolvedOutDir}` + ); + } + const outDirWithSep = resolvedOutDir.endsWith(sep) ? resolvedOutDir : resolvedOutDir + sep; + if (resolvedContent === resolvedOutDir || resolvedContent.startsWith(outDirWithSep)) { + throw new Error( + `buildDeck: refusing to clean outDir ${resolvedOutDir} — it contains the deck source ${resolvedContent}` + ); + } + const markerPath = join(opts.outDir, '.deckmark-build'); + let existingEntries: string[] | null = null; + try { + existingEntries = await readdir(opts.outDir); + } catch (err: unknown) { + const e = err as NodeJS.ErrnoException; + if (e.code !== 'ENOENT') throw err; + // ENOENT → outDir doesn't exist yet; we'll create it below. + } + if (existingEntries !== null && existingEntries.length > 0) { + const hasMarker = existingEntries.includes('.deckmark-build'); + if (!hasMarker) { + throw new Error( + `buildDeck: refusing to clean ${resolvedOutDir} — directory is non-empty ` + + `and has no .deckmark-build marker, so it doesn't look like a deckmark ` + + `build output. Pass an empty dir or one previously built by deckmark.` + ); + } + await rm(opts.outDir, { recursive: true, force: true }); + } await mkdir(opts.outDir, { recursive: true }); + // Drop the marker first so an interrupted build still leaves the dir + // recognizable on next run. + await writeFile(markerPath, ''); + // Mirror user assets (images/, fonts/, etc.) from the deck folder into + // build/ before writing index.html, so the rendered deck can reference + // them via relative paths and so publish_deck (which reads from build/) + // can find them for inlining or for multi-file deploy. + await syncUserAssetsToBuild( + dirname(opts.contentPath), + opts.outDir, + basename(opts.contentPath) + ); await writeFile(join(opts.outDir, 'index.html'), htmlDocument, 'utf8'); return { outDir: opts.outDir, slideCount: sections.length, style, mode, motion, slideNumbers: slideNumberValue }; } diff --git a/runtime/publish/inline-html.ts b/runtime/publish/inline-html.ts index 7d1f72f..45ff9a4 100644 --- a/runtime/publish/inline-html.ts +++ b/runtime/publish/inline-html.ts @@ -1,6 +1,6 @@ // runtime/publish/inline-html.ts import { readFile, writeFile, mkdir } from 'node:fs/promises'; -import { resolve, dirname, extname } from 'node:path'; +import { resolve, dirname, extname, sep } from 'node:path'; import { createRequire } from 'node:module'; import { existsSync } from 'node:fs'; @@ -14,6 +14,19 @@ function dbg(msg: string): void { // node_modules when deckmark is installed via npx. See static-overlay.ts. const REVEAL_DIST = dirname(require.resolve('reveal.js/dist/reveal.js')); +/** + * True only when `candidate` resolves to a path equal to, or inside, `root`. + * Guards every inliner sink from path-traversal references that, after a + * `resolve(root, file)`, would land outside `root` and let the inliner + * read/embed arbitrary local files into the single-file HTML. + * + * Both reveal asset inlining (root = REVEAL_DIST) and image inlining + * (root = buildDir) use this — different sinks, same attack class. + */ +function isUnder(root: string, candidate: string): boolean { + return candidate === root || candidate.startsWith(root + sep); +} + function mimeFor(ext: string): string { switch (ext.toLowerCase()) { case '.png': return 'image/png'; @@ -57,9 +70,13 @@ async function replaceLinkStylesheets(html: string): Promise { dbg(` link matches: ${matches.length}`); for (const m of matches) { const href = m[1]; - if (!href.startsWith('/vendor/reveal/')) continue; - const file = href.replace('/vendor/reveal/', ''); + // Accept both relative ('vendor/reveal/...') and absolute ('/vendor/reveal/...') + // forms — engine now emits relative but old-published HTML may have absolute. + const VENDOR_RE = /^\/?vendor\/reveal\//; + if (!VENDOR_RE.test(href)) continue; + const file = href.replace(VENDOR_RE, ''); const src = resolve(REVEAL_DIST, file); + if (!isUnder(REVEAL_DIST, src)) { dbg(` skip traversal: ${src}`); continue; } if (!existsSync(src)) { dbg(` skip missing: ${src}`); continue; } const css = await readFile(src, 'utf8'); html = html.replace(m[0], ``); @@ -74,9 +91,11 @@ async function replaceScripts(html: string): Promise { dbg(` script matches: ${matches.length}`); for (const m of matches) { const src = m[1]; - if (!src.startsWith('/vendor/reveal/')) continue; - const file = src.replace('/vendor/reveal/', ''); + const VENDOR_RE = /^\/?vendor\/reveal\//; + if (!VENDOR_RE.test(src)) continue; + const file = src.replace(VENDOR_RE, ''); const filePath = resolve(REVEAL_DIST, file); + if (!isUnder(REVEAL_DIST, filePath)) { dbg(` skip traversal: ${filePath}`); continue; } if (!existsSync(filePath)) { dbg(` skip missing: ${filePath}`); continue; } const js = await readFile(filePath, 'utf8'); html = html.replace(m[0], ``); @@ -97,6 +116,11 @@ async function replaceImages(html: string, buildDir: string): Promise { } const file = src.replace(/^[/.]+/, ''); const filePath = resolve(buildDir, file); + // Containment guard: stripping leading '/' and '.' chars does NOT remove + // internal `..` segments, so `images/../../../etc/passwd` would still + // escape buildDir after resolve(). Reject anything that lands outside. + const buildResolved = resolve(buildDir); + if (!isUnder(buildResolved, filePath)) { dbg(` skip traversal: ${filePath}`); continue; } if (!existsSync(filePath)) { dbg(` skip missing local img: ${filePath}`); continue; } const ext = extname(filePath); const buf = await readFile(filePath); diff --git a/runtime/publish/multi-file.ts b/runtime/publish/multi-file.ts index b6b442d..56f211a 100644 --- a/runtime/publish/multi-file.ts +++ b/runtime/publish/multi-file.ts @@ -1,6 +1,6 @@ // runtime/publish/multi-file.ts -import { mkdir, cp } from 'node:fs/promises'; -import { dirname, join } from 'node:path'; +import { mkdir, cp, lstat } from 'node:fs/promises'; +import { dirname, join, basename } from 'node:path'; import { createRequire } from 'node:module'; const require = createRequire(import.meta.url); @@ -9,6 +9,53 @@ const require = createRequire(import.meta.url); // node_modules when deckmark is installed via npx. See static-overlay.ts. const REVEAL_DIST = dirname(require.resolve('reveal.js/dist/reveal.js')); +/** + * cp() filter that rejects symlinks at every depth. The engine's asset sync + * already rejects symlinks before they reach buildDir, but multi-file may + * also be invoked with a buildDir produced by other tooling. A + * symlink-following web server (Apache/nginx default) would expose whatever + * the symlink points at, so we belt-and-suspenders it here too. + */ +async function rejectSymlink(src: string): Promise { + try { + const st = await lstat(src); + return !st.isSymbolicLink(); + } catch { + return false; + } +} + +/** + * cp() filter for buildDir → outDir: rejects symlinks AND the internal + * .deckmark-build marker so the published folder doesn't ship deckmark's + * "this dir is ours to clean" signal. + */ +async function rejectSymlinkAndMarker(src: string): Promise { + if (basename(src) === '.deckmark-build') return false; + return rejectSymlink(src); +} + +/** + * Throw a clear error if `path` exists and is not a directory. Used before + * `mkdir({ recursive: true })` calls that would otherwise fail with ENOTDIR + * deep inside Node's internals — leaving the caller to figure out which + * path was the problem. + */ +async function assertDirOrAbsent(path: string): Promise { + try { + const st = await lstat(path); + if (!st.isDirectory()) { + throw new Error( + `multiFile: ${path} exists but is not a directory; refusing to overwrite (remove it manually if intentional)` + ); + } + } catch (err: unknown) { + const e = err as NodeJS.ErrnoException; + if (e.code === 'ENOENT') return; + throw err; + } +} + export interface MultiFileOpts { buildDir: string; outDir: string; @@ -16,12 +63,24 @@ export interface MultiFileOpts { export async function multiFile(opts: MultiFileOpts): Promise<{ outDir: string; files: string[] }> { await mkdir(opts.outDir, { recursive: true }); - // Copy reveal.js dist contents to outDir/vendor/reveal/ - await mkdir(join(opts.outDir, 'vendor', 'reveal'), { recursive: true }); - await cp(REVEAL_DIST, join(opts.outDir, 'vendor', 'reveal'), { recursive: true }); - // Copy build/ contents (index.html and any embedded images) to outDir/ - await cp(opts.buildDir, opts.outDir, { recursive: true, force: true }); + // Order matters: copy buildDir FIRST (user assets, including any + // user-named vendor/ that may have made it in), then overlay the + // official reveal.js dist LAST. Whatever path collisions exist, + // /vendor/reveal/* always reflects the real reveal.js — never user + // content masquerading under the same path. + await cp(opts.buildDir, opts.outDir, { recursive: true, force: true, filter: rejectSymlinkAndMarker }); + + // After the buildDir copy, the reveal dist is overlaid at /vendor/reveal/. + // If buildDir happened to contain a *file* (not a directory) at either + // `vendor` or `vendor/reveal`, the subsequent mkdir({ recursive: true }) + // would throw ENOTDIR with no context. The engine's asset sync already + // excludes `vendor`, but multiFile is callable with any buildDir, so + // fail with a clear message instead of letting users debug a stat error. + await assertDirOrAbsent(join(opts.outDir, 'vendor')); + await assertDirOrAbsent(join(opts.outDir, 'vendor', 'reveal')); + await mkdir(join(opts.outDir, 'vendor', 'reveal'), { recursive: true }); + await cp(REVEAL_DIST, join(opts.outDir, 'vendor', 'reveal'), { recursive: true, force: true, filter: rejectSymlink }); // Walk the output to list what landed const { readdir } = await import('node:fs/promises'); diff --git a/test/unit/engines-reveal.test.ts b/test/unit/engines-reveal.test.ts index 1c35c58..00a89d8 100644 --- a/test/unit/engines-reveal.test.ts +++ b/test/unit/engines-reveal.test.ts @@ -2,7 +2,7 @@ import { test } from 'node:test'; import { strict as assert } from 'node:assert'; import { mkdtemp, writeFile, readFile, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { join, basename } from 'node:path'; import { buildDeck } from '../../runtime/engines/reveal.ts'; async function tmpDir() { @@ -32,7 +32,9 @@ test('buildDeck produces index.html with both slides', async () => { assert.match(html, /]*>[\s\S]*Slide One/); assert.match(html, /]*>[\s\S]*Slide Two/); assert.match(html, /reveal\.js/); - assert.match(html, /\/vendor\/reveal\/reveal\.js/); + // Relative (no leading slash) so file:// open also works after publish_deck. + assert.match(html, /["']vendor\/reveal\/reveal\.js["']/); + assert.doesNotMatch(html, /["']\/vendor\/reveal/); await rm(dir, { recursive: true }); }); @@ -57,6 +59,69 @@ test('buildDeck de-duplicates slugs when titles collide', async () => { await rm(dir, { recursive: true }); }); +test('buildDeck refuses to clean an outDir that contains the deck source', async () => { + const dir = await tmpDir(); + await writeFile(join(dir, 'content.md'), SAMPLE_CONTENT); + // outDir = the deck dir itself. Without the guard, rm({ recursive: true }) + // would wipe content.md before reading it. The guard must catch it before + // any destructive call. + await assert.rejects( + () => buildDeck({ contentPath: join(dir, 'content.md'), outDir: dir }), + /refusing to clean outDir/i + ); + // Source survived the rejected call. + const stillThere = await readFile(join(dir, 'content.md'), 'utf8'); + assert.match(stillThere, /Slide One/); + await rm(dir, { recursive: true }); +}); + +test('buildDeck refuses to clean a non-empty outDir without the .deckmark-build marker', async () => { + const { mkdir, writeFile: w } = await import('node:fs/promises'); + const { existsSync } = await import('node:fs'); + const dir = await tmpDir(); + await writeFile(join(dir, 'content.md'), SAMPLE_CONTENT); + // Pretend outDir is a user-owned folder with existing data — no marker. + const outDir = join(dir, 'random-user-folder'); + await mkdir(outDir, { recursive: true }); + await w(join(outDir, 'family-photo.jpg'), 'JPEG_BYTES'); + await w(join(outDir, 'taxes.pdf'), 'PDF_BYTES'); + await assert.rejects( + () => buildDeck({ contentPath: join(dir, 'content.md'), outDir }), + /no \.deckmark-build marker/i + ); + // Pre-existing user data survived. + assert.ok(existsSync(join(outDir, 'family-photo.jpg'))); + assert.ok(existsSync(join(outDir, 'taxes.pdf'))); + await rm(dir, { recursive: true }); +}); + +test('buildDeck cleans an outDir on repeated builds (marker round-trip)', async () => { + const { existsSync } = await import('node:fs'); + const dir = await tmpDir(); + await writeFile(join(dir, 'content.md'), SAMPLE_CONTENT); + // First build: outDir doesn't exist → succeeds, drops marker. + await buildDeck({ contentPath: join(dir, 'content.md'), outDir: join(dir, 'build') }); + assert.ok(existsSync(join(dir, 'build', '.deckmark-build'))); + // Second build: outDir exists with marker → rm + rebuild → succeeds. + await buildDeck({ contentPath: join(dir, 'content.md'), outDir: join(dir, 'build') }); + assert.ok(existsSync(join(dir, 'build', '.deckmark-build'))); + assert.ok(existsSync(join(dir, 'build', 'index.html'))); + await rm(dir, { recursive: true }); +}); + +test('buildDeck refuses to clean filesystem root', async () => { + const dir = await tmpDir(); + await writeFile(join(dir, 'content.md'), SAMPLE_CONTENT); + // Pick a platform-appropriate root. The check is path-based so no actual + // rm is attempted — we only need to confirm the guard fires. + const root = process.platform === 'win32' ? 'C:\\' : '/'; + await assert.rejects( + () => buildDeck({ contentPath: join(dir, 'content.md'), outDir: root }), + /refusing to clean filesystem root/i + ); + await rm(dir, { recursive: true }); +}); + test('buildDeck throws when content has no slides', async () => { const dir = await tmpDir(); await writeFile(join(dir, 'content.md'), '\n\n---\n\n'); @@ -75,8 +140,8 @@ test('buildDeck sets data-mode on and embeds the style sheet', async () = assert.match(html, /]+data-mode="dark"/); assert.match(html, /data-deckmark-style="academic"/); assert.match(html, /data-deckmark-mode="dark"/); - // dark mode loads the dark reveal base theme - assert.match(html, /\/vendor\/reveal\/theme\/black\.css/); + // dark mode loads the dark reveal base theme (relative path) + assert.match(html, /["']vendor\/reveal\/theme\/black\.css["']/); await rm(dir, { recursive: true }); }); @@ -101,3 +166,154 @@ test('buildDeck with motion=[] disables transitions', async () => { assert.match(html, /transition:\s*"none"/); await rm(dir, { recursive: true }); }); + +test('buildDeck mirrors user assets (images/) from deck folder into build/', async () => { + const { mkdir, writeFile: w } = await import('node:fs/promises'); + const { existsSync } = await import('node:fs'); + const dir = await tmpDir(); + // Set up a deck with an images/ subfolder containing a couple of files + await mkdir(join(dir, 'images', 'nested'), { recursive: true }); + await w(join(dir, 'images', '1-0.jpg'), 'fake-jpeg-bytes'); + await w(join(dir, 'images', 'nested', 'deep.png'), ''); + await w(join(dir, 'content.md'), SAMPLE_CONTENT); + await buildDeck({ contentPath: join(dir, 'content.md'), outDir: join(dir, 'build') }); + // After build, images/ should be mirrored inside build/ + assert.ok(existsSync(join(dir, 'build', 'images', '1-0.jpg')), 'images/1-0.jpg should be copied to build/'); + assert.ok(existsSync(join(dir, 'build', 'images', 'nested', 'deep.png')), 'nested image should be copied'); + await rm(dir, { recursive: true }); +}); + +test('buildDeck skips nested symlinks during asset sync (security: no /etc/passwd via images/secret)', async () => { + // Symlink creation on Windows usually requires either admin rights or + // Developer Mode. If symlink() fails with EPERM we skip — the test is a + // security regression check, not a portability test. + const { mkdir, writeFile: w, symlink } = await import('node:fs/promises'); + const { existsSync, lstatSync } = await import('node:fs'); + const dir = await tmpDir(); + await mkdir(join(dir, 'images'), { recursive: true }); + await w(join(dir, 'images', 'real.jpg'), 'jpeg-bytes'); + // Drop a target file outside the deck and try to symlink to it from inside. + // Name the target uniquely per run so concurrent tests don't collide on + // a shared path under the OS temp dir. + const outsideTarget = join(dir, '..', `sensitive-${basename(dir)}.txt`); + await w(outsideTarget, 'SECRET'); + try { + await symlink(outsideTarget, join(dir, 'images', 'leak')); + } catch (err: unknown) { + const e = err as NodeJS.ErrnoException; + if (e.code === 'EPERM' || e.code === 'ENOSYS') { + await rm(dir, { recursive: true }); + await rm(outsideTarget, { force: true }); + return; + } + throw err; + } + await w(join(dir, 'content.md'), SAMPLE_CONTENT); + await buildDeck({ contentPath: join(dir, 'content.md'), outDir: join(dir, 'build') }); + assert.ok(existsSync(join(dir, 'build', 'images', 'real.jpg')), 'real file should still be copied'); + assert.equal( + existsSync(join(dir, 'build', 'images', 'leak')), + false, + 'nested symlink must NOT be copied into build/' + ); + // Sanity: the source symlink itself is in fact a symlink (so the test + // would meaningfully fail if rejectSymlink stopped working). + assert.ok(lstatSync(join(dir, 'images', 'leak')).isSymbolicLink()); + await rm(dir, { recursive: true }); + await rm(outsideTarget, { force: true }); +}); + +test('buildDeck does NOT copy a custom-named content file (e.g. slides.md) into build/', async () => { + const { writeFile: w } = await import('node:fs/promises'); + const { existsSync } = await import('node:fs'); + const dir = await tmpDir(); + // Caller passes an arbitrary contentPath via the MCP layer — make sure the + // sync excludes whatever basename was used, not just the literal 'content.md'. + await w(join(dir, 'slides.md'), SAMPLE_CONTENT); + await buildDeck({ contentPath: join(dir, 'slides.md'), outDir: join(dir, 'build') }); + assert.equal( + existsSync(join(dir, 'build', 'slides.md')), + false, + 'custom-named content markdown should NOT be copied into build/' + ); + // sanity: index.html still produced from it + assert.ok(existsSync(join(dir, 'build', 'index.html'))); + await rm(dir, { recursive: true }); +}); + +test('buildDeck does not accidentally skip a deck folder whose name matches the outDir basename', async () => { + const { mkdir, writeFile: w } = await import('node:fs/promises'); + const { existsSync } = await import('node:fs'); + const deckDir = await tmpDir(); + // The deck legitimately has a folder called "output/" — it must be copied. + await mkdir(join(deckDir, 'output'), { recursive: true }); + await w(join(deckDir, 'output', 'chart.png'), 'PNG'); + await w(join(deckDir, 'content.md'), SAMPLE_CONTENT); + // Choose an outDir whose basename collides with the deck's 'output/' folder + // but lives in a different parent — the old basename-based skip would have + // dropped the deck's output/ on the floor. + const outParent = await tmpDir(); + const outDir = join(outParent, 'output'); + await buildDeck({ contentPath: join(deckDir, 'content.md'), outDir }); + assert.ok( + existsSync(join(outDir, 'output', 'chart.png')), + "deck's output/ folder should be copied even when outDir basename is also 'output'" + ); + await rm(deckDir, { recursive: true }); + await rm(outParent, { recursive: true }); +}); + +test('buildDeck removes stale symlinks left in build/ from a prior build', async () => { + const { mkdir, writeFile: w, symlink } = await import('node:fs/promises'); + const { existsSync } = await import('node:fs'); + const dir = await tmpDir(); + await mkdir(join(dir, 'build', 'images'), { recursive: true }); + // Marker tells buildDeck "this dir is yours to clean" — simulates the + // state left behind by a previous successful build. + await w(join(dir, 'build', '.deckmark-build'), ''); + const outsideTarget = join(dir, '..', `stale-${basename(dir)}.txt`); + await w(outsideTarget, 'STALE_SECRET'); + try { + await symlink(outsideTarget, join(dir, 'build', 'images', 'leak')); + } catch (err: unknown) { + const e = err as NodeJS.ErrnoException; + if (e.code === 'EPERM' || e.code === 'ENOSYS') { + await rm(dir, { recursive: true }); + await rm(outsideTarget, { force: true }); + return; + } + throw err; + } + await w(join(dir, 'content.md'), SAMPLE_CONTENT); + await buildDeck({ contentPath: join(dir, 'content.md'), outDir: join(dir, 'build') }); + assert.equal( + existsSync(join(dir, 'build', 'images', 'leak')), + false, + 'stale symlink from a previous build must not survive a rebuild' + ); + await rm(dir, { recursive: true }); + await rm(outsideTarget, { force: true }); +}); + +test('buildDeck does NOT sync deckmark internals (AGENTS.md, annotations/, .gitignore, etc.) into build/', async () => { + const { mkdir, writeFile: w } = await import('node:fs/promises'); + const { existsSync } = await import('node:fs'); + const dir = await tmpDir(); + // Internals that should NOT be copied + await w(join(dir, 'AGENTS.md'), '# agent instructions'); + await w(join(dir, 'deckmark.config.json'), '{}'); + await w(join(dir, '.gitignore'), 'build/\n'); + await mkdir(join(dir, 'annotations'), { recursive: true }); + await w(join(dir, 'annotations', 'session-stub.json'), '{}'); + // A user asset that SHOULD be copied + await mkdir(join(dir, 'assets'), { recursive: true }); + await w(join(dir, 'assets', 'logo.svg'), ''); + await w(join(dir, 'content.md'), SAMPLE_CONTENT); + await buildDeck({ contentPath: join(dir, 'content.md'), outDir: join(dir, 'build') }); + assert.equal(existsSync(join(dir, 'build', 'AGENTS.md')), false); + assert.equal(existsSync(join(dir, 'build', 'deckmark.config.json')), false); + assert.equal(existsSync(join(dir, 'build', '.gitignore')), false); + assert.equal(existsSync(join(dir, 'build', 'annotations')), false); + assert.ok(existsSync(join(dir, 'build', 'assets', 'logo.svg')), 'user asset should be copied'); + await rm(dir, { recursive: true }); +}); diff --git a/test/unit/publish.test.ts b/test/unit/publish.test.ts index 024c426..c54ae24 100644 --- a/test/unit/publish.test.ts +++ b/test/unit/publish.test.ts @@ -36,3 +36,64 @@ test('multiFile writes deploy folder with index.html and vendor/reveal/', async assert.ok(r.files.some(f => f.startsWith('vendor/reveal/'))); await rm(dir, { recursive: true }); }); + +test('multiFile fails clearly when buildDir has a FILE at vendor (not a directory)', async () => { + const dir = await setupDeck(); + // Tamper: drop a plain file at build/vendor so the reveal dist overlay + // can't mkdir /vendor/reveal on top of it. The engine's own sync + // excludes 'vendor', but multiFile is callable with any buildDir. + await writeFile(join(dir, 'build', 'vendor'), 'not-a-directory'); + const outDir = join(dir, 'publish-bad'); + await assert.rejects( + () => multiFile({ buildDir: join(dir, 'build'), outDir }), + /vendor.*not a directory/i + ); + await rm(dir, { recursive: true }); +}); + +test('inlineHtml refuses traversal img src like images/../../../../etc/passwd', async () => { + const dir = await setupDeck(); + // Inject an with a traversal src. Stripping leading '/' and '.' + // characters doesn't kill internal `..` segments, so resolve(buildDir, ...) + // walks out of buildDir without the new containment guard. + const indexPath = join(dir, 'build', 'index.html'); + const orig = await readFile(indexPath, 'utf8'); + const tampered = orig.replace( + '
', + '
\n' + ); + await writeFile(indexPath, tampered, 'utf8'); + const outFile = join(dir, 'tampered-img.html'); + await inlineHtml({ buildDir: join(dir, 'build'), outFile }); + const html = await readFile(outFile, 'utf8'); + // The img tag should still reference the traversal path (untouched), NOT + // a base64 data URI of /etc/passwd contents. + assert.match(html, /]+src="images\/\.\.\//); + assert.doesNotMatch(html, /data:application\/octet-stream;base64,/); + assert.doesNotMatch(html, /root:x:/); + await rm(dir, { recursive: true }); +}); + +test('inlineHtml refuses path-traversal references like vendor/reveal/../../etc', async () => { + const dir = await setupDeck(); + // Tamper with the built HTML to inject a traversal reference. The + // resolve() against REVEAL_DIST would land outside the reveal dist, and + // without the containment guard the inliner would happily readFile() + // whatever local file the user pointed at. + const indexPath = join(dir, 'build', 'index.html'); + const orig = await readFile(indexPath, 'utf8'); + const tampered = orig + .replace('href="vendor/reveal/reveal.css"', 'href="vendor/reveal/../../../../../../../etc/passwd"') + .replace('src="vendor/reveal/reveal.js"', 'src="vendor/reveal/../../../../../../../etc/hosts"'); + await writeFile(indexPath, tampered, 'utf8'); + const outFile = join(dir, 'tampered.html'); + await inlineHtml({ buildDir: join(dir, 'build'), outFile }); + const html = await readFile(outFile, 'utf8'); + // The tampered hrefs should remain as plain /