Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
148 changes: 148 additions & 0 deletions scripts/localFileLinks.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
27 changes: 26 additions & 1 deletion src/lib/MarkdownViewer.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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');
}
}
}
}
Expand Down
44 changes: 44 additions & 0 deletions src/lib/utils/localFileLinks.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading