diff --git a/web/src/lib/remark-file-path-links.test.ts b/web/src/lib/remark-file-path-links.test.ts index 3aef4abf..b4203d18 100644 --- a/web/src/lib/remark-file-path-links.test.ts +++ b/web/src/lib/remark-file-path-links.test.ts @@ -48,9 +48,18 @@ describe('remarkFilePathLinks', () => { expect(links.map(linkedPath)).toEqual(['screenshot.png', 'README.md']) }) + it('links Windows absolute paths so the session host can validate and read them', () => { + const nodes = transform('Open C:\\Users\\dev\\project\\handoff.md and D:/work/app/src/main.ts:12') + const links = nodes.filter((node) => node.type === 'link') - it('does not link paths that are outside the session workspace', () => { - const nodes = transform('Skip /Users/dev/project/a.png, ~/a.png, ../a.png and C:\\tmp\\a.png') + expect(links.map(linkedPath)).toEqual([ + 'C:\\Users\\dev\\project\\handoff.md', + 'D:/work/app/src/main.ts' + ]) + }) + + it('does not link other absolute or parent paths', () => { + const nodes = transform('Skip /Users/dev/project/a.png, ~/a.png and ../a.png') expect(nodes.some((node) => node.type === 'link')).toBe(false) }) @@ -107,6 +116,18 @@ describe('remarkFilePathLinks — inlineCode', () => { expect(linkedPath(nodes.find((n) => n.type === 'link')!)).toBe('docs/flow.mmd') }) + it.each([ + 'C:\\Users\\dev\\project\\handoff.md', + 'D:/work/app/src/main.ts:12' + ])('links Windows absolute inlineCode path %s', (value) => { + const nodes = transformNodes([{ type: 'inlineCode', value }]) + const link = nodes.find((node) => node.type === 'link')! + + expect(linkedPath(link)).toBe(value.replace(/:\d+(?::\d+)?$/, '')) + expect(link.children?.[0]?.type).toBe('inlineCode') + expect(link.children?.[0]?.value).toBe(value) + }) + it.each([ 'npm run build', 'str.split()', @@ -122,7 +143,7 @@ describe('remarkFilePathLinks — inlineCode', () => { }) it('does not link unsafe paths inside inlineCode', () => { - for (const value of ['/etc/passwd.sh', '~/secrets.env', '../escape.ts', 'C:\\win.ini']) { + for (const value of ['/etc/passwd.sh', '~/secrets.env', '../escape.ts']) { const nodes = transformNodes([{ type: 'inlineCode', value }]) expect(nodes.some((node) => node.type === 'link')).toBe(false) } @@ -154,6 +175,16 @@ describe('remarkFilePathLinks — explicit markdown links', () => { expect(linkedPath(nodes.find((n) => n.type === 'link')!)).toBe('./diagram.mmd') }) + it.each([ + 'C:\\Users\\dev\\project\\handoff.md', + 'D:/work/app/src/main.ts:12' + ])('rewrites a Windows absolute file link %s', (url) => { + const nodes = transformNodes([linkNode(url)]) + const link = nodes.find((node) => node.type === 'link')! + + expect(linkedPath(link)).toBe(url.replace(/:\d+(?::\d+)?$/, '')) + }) + it.each([ 'https://example.com/a.md', 'mailto:dev@example.com', @@ -161,7 +192,6 @@ describe('remarkFilePathLinks — explicit markdown links', () => { '/abs/path.md', '~/home.md', '../escape.md', - 'C:\\win\\a.md', 'foo:bar.md', '/settings', './relative-route', diff --git a/web/src/lib/remark-file-path-links.ts b/web/src/lib/remark-file-path-links.ts index 5c5cce32..66121cba 100644 --- a/web/src/lib/remark-file-path-links.ts +++ b/web/src/lib/remark-file-path-links.ts @@ -1,6 +1,6 @@ const FILE_PATH_HREF_PREFIX = 'hapi-file:' -const PATH_PATTERN = /(?:\.\/|[A-Za-z0-9_.-]+\/)[^\s`"\'<>]*?\.(?:[A-Za-z0-9]{1,12}|lock)(?::\d+(?::\d+)?)?|(?:[A-Za-z0-9_.-]+\.(?:[A-Za-z0-9]{1,12}|lock))(?::\d+(?::\d+)?)?/g +const PATH_PATTERN = /(?:[A-Za-z]:[\\/]|\.\/|[A-Za-z0-9_.-]+\/)[^\s`"\'<>]*?\.(?:[A-Za-z0-9]{1,12}|lock)(?::\d+(?::\d+)?)?|(?:[A-Za-z0-9_.-]+\.(?:[A-Za-z0-9]{1,12}|lock))(?::\d+(?::\d+)?)?/g const TRAILING_PUNCTUATION = new Set(['.', ',', ';', ':', '!', '?']) // Extensions that autolink to the session file viewer. Kept intentionally @@ -77,13 +77,17 @@ function hasKnownFileExtension(value: string): boolean { return COMMON_FILE_EXTENSIONS.has(ext) } +function isWindowsAbsolutePath(value: string): boolean { + return /^[A-Za-z]:[\\/]/.test(value) +} + function shouldLinkPath(value: string): boolean { if (value.includes('://')) return false const path = stripLineSuffix(value) if (path.length < 3) return false if (path.startsWith('/') || path.startsWith('~/')) return false if (path.startsWith('../') || path.includes('/../')) return false - if (/^[A-Za-z]:[\\/]/.test(path)) return false + if (isWindowsAbsolutePath(path)) return hasKnownFileExtension(path) if (path.includes('/')) return hasKnownFileExtension(path) return hasKnownFileExtension(path) } @@ -163,10 +167,11 @@ function linkInlineCodeNode(node: MarkdownNode): MarkdownNode | null { // a repo-relative allowlisted file path into a `hapi-file:` href so it opens the // session file viewer instead of dead-ending in the SPA router. // -// Security: reuses shouldLinkPath (rejects abs / `~/` / `../` / Windows drive / -// `scheme://`) and additionally rejects any residual colon after the line-suffix -// strip, so scheme-bearing urls (mailto:, obsidian://, foo:bar.md) are left for -// the deny-scheme layer. The visible label is preserved untouched. +// Security: reuses shouldLinkPath (rejects POSIX abs / `~/` / `../` / `scheme://`) +// and rejects residual colons for non-Windows targets after the line-suffix strip, +// so scheme-bearing urls (mailto:, obsidian://, foo:bar.md) are left for the +// deny-scheme layer. Windows absolute paths are routed through the session file +// viewer; the CLI still enforces that they stay inside the session workspace. function rewriteFileLinkNode(node: MarkdownNode): void { if (node.type !== 'link') return const url = node.url @@ -174,7 +179,7 @@ function rewriteFileLinkNode(node: MarkdownNode): void { if (url.startsWith(FILE_PATH_HREF_PREFIX)) return const target = stripLineSuffix(url) - if (target.includes(':')) return + if (!isWindowsAbsolutePath(target) && target.includes(':')) return if (!shouldLinkPath(target)) return node.url = createFileHref(target)