diff --git a/scripts/localFileLinks.test.ts b/scripts/localFileLinks.test.ts new file mode 100644 index 00000000..652dead7 --- /dev/null +++ b/scripts/localFileLinks.test.ts @@ -0,0 +1,148 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +import { resolveLocalFileLinkPath } from '../src/lib/utils/localFileLinks.js'; + +/* + * `[data](./data.csv)` did nothing on macOS and Linux, and opened a dead page + * in the browser on Windows. + * + * The link is a path, but the click handler passed `anchor.href` — which the + * DOM had already resolved against the webview's own origin. That origin is + * `tauri://localhost` on macOS and Linux and `http://tauri.localhost` on + * Windows, and the opener plugin's scope allows `mailto:`, `tel:`, `http://*` + * and `https://*` (tauri-plugin-opener `allow-default-urls`). So the first + * form was rejected as ForbiddenUrl — and, because the call was not awaited + * inside a try/catch, the rejection went unhandled and the click was silent — + * while the second matched `http://*` and was genuinely handed to the browser. + * + * The resolver below is pure, so these run the real code. The wiring in the + * component is asserted against its source; those tests establish the order of + * the branches and that both OS calls are guarded, not what the OS then does. + */ + +const CURRENT = '/notes/doc.md'; + +test('a relative link resolves against the open file', () => { + assert.equal(resolveLocalFileLinkPath('./data.csv', CURRENT), '/notes/data.csv'); + assert.equal(resolveLocalFileLinkPath('data.csv', CURRENT), '/notes/data.csv'); + assert.equal(resolveLocalFileLinkPath('../assets/report.pdf', CURRENT), '/assets/report.pdf'); + assert.equal(resolveLocalFileLinkPath('sub/dir/data.csv', CURRENT), '/notes/sub/dir/data.csv'); +}); + +test('the path is decoded and stripped of URL decoration', () => { + // The href in the document is percent-encoded markdown, not a filename. + assert.equal(resolveLocalFileLinkPath('./my%20file.csv', CURRENT), '/notes/my file.csv'); + // A query string or fragment belongs to URLs; on disk they are part of no + // filename, and leaving them on would look up a file that does not exist. + assert.equal(resolveLocalFileLinkPath('./data.csv?v=2', CURRENT), '/notes/data.csv'); + assert.equal(resolveLocalFileLinkPath('./data.csv#row3', CURRENT), '/notes/data.csv'); +}); + +test('absolute and Windows paths are taken as written', () => { + assert.equal(resolveLocalFileLinkPath('/srv/shared/data.csv', CURRENT), '/srv/shared/data.csv'); + // A drive letter looks like a scheme and must not be treated as one. + assert.equal(resolveLocalFileLinkPath('C:\\docs\\data.csv', CURRENT), 'C:/docs/data.csv'); + assert.equal(resolveLocalFileLinkPath('file:///tmp/data.csv', CURRENT), '/tmp/data.csv'); +}); + +test('web addresses are left to the browser', () => { + for (const href of [ + 'https://example.com/data.csv', + 'http://example.com/data.csv', + 'mailto:someone@example.com', + 'tel:+15551234', + 'obsidian://open?vault=x', + 'data:text/csv;base64,YQ==', + // Protocol-relative. The app already reads this as a web address for + // markdown links, and reading it as a UNC path here would disagree. + '//example.com/data.csv', + ]) { + assert.equal(resolveLocalFileLinkPath(href, CURRENT), null, href); + } +}); + +test('an in-page anchor is not a file', () => { + assert.equal(resolveLocalFileLinkPath('#section', CURRENT), null); + assert.equal(resolveLocalFileLinkPath('', CURRENT), null); + assert.equal(resolveLocalFileLinkPath(' ', CURRENT), null); +}); + +test('an unsaved buffer has nothing to resolve a relative link against', () => { + // `resolvePath('', './data.csv')` yields `data.csv`, which the OS would + // open relative to the process's working directory — some arbitrary file, + // or none. Refusing is the only honest answer. + assert.equal(resolveLocalFileLinkPath('./data.csv', ''), null); + assert.equal(resolveLocalFileLinkPath('data.csv', ''), null); + // An absolute link still names exactly one file. + assert.equal(resolveLocalFileLinkPath('/srv/data.csv', ''), '/srv/data.csv'); + assert.equal(resolveLocalFileLinkPath('C:\\data.csv', ''), 'C:/data.csv'); +}); + +test('a markdown link still resolves to a path, so branch order is what keeps it in-app', () => { + // Opening `./other.md` in a tab is a different, older feature. This + // resolver cannot tell the two apart and must not try to; the click handler + // asks about markdown targets first. The next test pins that order. + assert.equal(resolveLocalFileLinkPath('./other.md', CURRENT), '/notes/other.md'); +}); + +// --- wiring ------------------------------------------------------------------ + +const viewer = readFileSync('src/lib/MarkdownViewer.svelte', 'utf8'); +const handler = (() => { + const from = viewer.indexOf('async function handleDocumentClick'); + assert.notEqual(from, -1); + const to = viewer.indexOf('let zoomLevel', from); + assert.notEqual(to, -1); + return viewer.slice(from, to); +})(); + +test('markdown targets are still claimed before the local-file branch', () => { + const markdown = handler.indexOf('getRelativeMarkdownTarget(rawHref)'); + const local = handler.indexOf('resolveLocalFileLinkPath(rawHref, currentFile)'); + assert.notEqual(markdown, -1); + assert.notEqual(local, -1); + assert.ok(markdown < local, '`./other.md` must open in a tab, not in an external editor'); +}); + +test('a local file is handed to the OS as a path, not as a URL', () => { + assert.match(handler, /await openPath\(localFilePath\)/); + // The raw attribute, not `anchor.href`: the latter is the origin-resolved + // URL that caused the bug. + assert.match(handler, /resolveLocalFileLinkPath\(rawHref, currentFile\)/); + const local = handler.indexOf('resolveLocalFileLinkPath'); + const url = handler.indexOf('await openUrl(anchor.href)'); + assert.notEqual(url, -1, 'genuine web links must still go to the browser'); + assert.ok(local < url, 'a local file must be caught before the URL fallback'); +}); + +test('the capability still grants the command this depends on', () => { + // `open_path` needs both the command grant and a path scope. Granting the + // command alone leaves the plugin resolving + // `fs_scope.is_allowed(path) && allowed.any(matches_path_program)` against + // URL-only scope entries, which answer false to the second — that is how + // `openPath` came to be refused with ForbiddenPath everywhere, including in + // `askToOpenExportedFile` (#399, fixed in #403). Asserted here so the grant + // cannot quietly disappear; the scope's shape is deliberately not asserted, + // so narrowing it later is not a test failure. + const capability = readFileSync('src-tauri/capabilities/default.json', 'utf8'); + assert.match(capability, /opener:allow-open-path/); +}); + +test('neither OS call can leave an unhandled rejection behind', () => { + // `openUrl` rejects for anything outside the opener scope. Unawaited-in- + // try/catch, that rejection was the whole visible symptom on macOS: nothing + // happened, and nothing said why. + for (const call of ['await openPath(localFilePath)', 'await openUrl(anchor.href)']) { + const at = handler.indexOf(call); + assert.notEqual(at, -1, call); + const before = handler.slice(0, at); + const tryAt = before.lastIndexOf('try {'); + const catchAfter = handler.indexOf('} catch (error) {', at); + assert.notEqual(tryAt, -1, `${call} must be inside a try block`); + assert.notEqual(catchAfter, -1, `${call} must have a catch`); + assert.ok(before.slice(tryAt).split('} catch').length === 1, `${call} must be inside the nearest try`); + } + assert.equal(handler.match(/addToast\(`Failed to open/g)?.length, 2, 'both failures are reported'); +}); diff --git a/src/lib/MarkdownViewer.svelte b/src/lib/MarkdownViewer.svelte index a02d6fa8..574c04ae 100644 --- a/src/lib/MarkdownViewer.svelte +++ b/src/lib/MarkdownViewer.svelte @@ -50,6 +50,7 @@ import { resolveMarkdownTargetPath, type MarkdownLinkTarget as RelativeMarkdownTarget, } from './utils/markdownLinks.js'; +import { resolveLocalFileLinkPath } from './utils/localFileLinks.js'; import { normalizeAssetPath } from './utils/exportHtml.js'; import { dropRecentFile, @@ -2364,9 +2365,33 @@ import { createDocumentSession, type LoadMarkdownOptions } from './sessions/docu return; } + // A link to a local non-markdown file (`[data](./data.csv)`) is a + // path, and `anchor.href` is not: the DOM resolved it against the + // webview origin. Hand the OS the resolved disk path instead. + const localFilePath = resolveLocalFileLinkPath(rawHref, currentFile); + if (localFilePath) { + event.preventDefault(); + try { + await openPath(localFilePath); + } catch (error) { + console.error('Failed to open local file link', localFilePath, error); + addToast(`Failed to open ${localFilePath}`, 'error'); + } + return; + } + if (anchor.href) { event.preventDefault(); - await openUrl(anchor.href); + // `openUrl` rejects anything outside the opener plugin's scope + // (`mailto:`, `tel:`, `http://*`, `https://*`). Without this the + // rejection was unhandled: the click did nothing, said nothing, + // and left an uncaught promise rejection behind. + try { + await openUrl(anchor.href); + } catch (error) { + console.error('Failed to open link', anchor.href, error); + addToast(`Failed to open ${rawHref}`, 'error'); + } } } } diff --git a/src/lib/utils/localFileLinks.ts b/src/lib/utils/localFileLinks.ts new file mode 100644 index 00000000..db0b7c1b --- /dev/null +++ b/src/lib/utils/localFileLinks.ts @@ -0,0 +1,44 @@ +import { resolveExportImagePath } from './exportHtml.js'; + +const absoluteFilePathPattern = /^(?:[a-zA-Z]:[\\/]|\/|\\\\)/; + +/** + * The disk path a document link points at, or `null` when the link is not a + * reference to a local file. + * + * A markdown link such as `[data](./data.csv)` is a *path*, but by the time it + * reaches a click handler the DOM has already turned `anchor.href` into a URL + * resolved against the webview's own origin — `tauri://localhost/data.csv` on + * macOS and Linux, `http://tauri.localhost/data.csv` on Windows. Neither names + * anything on disk, and the two platforms fail differently: the opener + * plugin's scope allows `mailto:`, `tel:`, `http://*` and `https://*`, so the + * first form is rejected outright while the second matches `http://*` and is + * genuinely handed to the browser, which then shows a dead page. Resolving the + * *raw* href against the open file is what both platforms actually need. + * + * The decision table — which schemes are remote, how a Windows drive letter or + * a UNC path differs from a scheme, where a query string ends and a path + * begins — is `resolveExportImagePath`'s (#363), which the HTML export already + * relies on and which has its own tests, including the `asset.localhost` host + * spoofing cases. Duplicating it here would be a second place to get + * `C:\` versus `mailto:` wrong. + * + * Two rules are this caller's own: + * + * - `//host/path` is a protocol-relative web address, and the app already + * reads it that way for markdown links (`getMarkdownLinkTarget`). The image + * resolver would take it for a UNC path. + * - A relative link in a buffer that has never been saved has no base to + * resolve against. Handing the fragment to the OS as if it were a path would + * open something arbitrary relative to the process's working directory. + */ +export function resolveLocalFileLinkPath(rawHref: string, currentFile: string): string | null { + const trimmed = rawHref.trim(); + if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('//')) return null; + + const resolved = resolveExportImagePath(trimmed, currentFile); + if (!resolved) return null; + if (!currentFile && !absoluteFilePathPattern.test(resolved)) return null; + + return resolved; +}