mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
fix(web): open Windows absolute file paths in session viewer (#1113)
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user