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
3 changes: 3 additions & 0 deletions .e2e-workspace/assets/name with spaces.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions .e2e-workspace/assets/normal-image.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
19 changes: 19 additions & 0 deletions .e2e-workspace/image-paths.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Local Image Paths

## Normal filename

![Normal image](./assets/normal-image.svg)

## URL-encoded spaces

![Encoded spaces](./assets/name%20with%20spaces.svg)

## Angle-bracket destination

![Angle-bracket spaces](<./assets/name with spaces.svg>)

## Reference-style destination

![Reference spaces][image-with-spaces]

[image-with-spaces]: ./assets/name%20with%20spaces.svg
38 changes: 33 additions & 5 deletions src/extension/preview/markdown/linkResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,22 @@ function restoreSvgFragment(
return uri.with({ fragment });
}

function decodeLocalImagePath(rawPath: string): string | undefined {
// Encoded separators must not become new path boundaries during URI decoding.
if (/%(?:2f|5c)/i.test(rawPath)) {
return undefined;
}

try {
return rawPath
.split('/')
.map((segment) => decodeURIComponent(segment))
.join('/');
} catch {
return undefined;
}
}

export function resolveImageUri(source: vscode.Uri, src: string): vscode.Uri | undefined {
const { normalizedSrc, fragment } = stripLocalImageUrlDecoration(src);
if (
Expand All @@ -113,14 +129,26 @@ export function resolveImageUri(source: vscode.Uri, src: string): vscode.Uri | u
}
const sourceFolder = vscode.workspace.getWorkspaceFolder(source);
if (/^file:/i.test(normalizedSrc)) {
const parsed = vscode.Uri.parse(normalizedSrc, true);
const resolved = restoreSvgFragment(parsed, fragment);
if (!sourceFolder) return resolved;
return isWithinWorkspace(parsed, sourceFolder.uri) ? resolved : undefined;
if (/%(?:2f|5c)/i.test(normalizedSrc)) {
return undefined;
}
try {
const parsed = vscode.Uri.parse(normalizedSrc, true);
const resolved = restoreSvgFragment(parsed, fragment);
if (!sourceFolder) return resolved;
return isWithinWorkspace(parsed, sourceFolder.uri) ? resolved : undefined;
} catch {
return undefined;
}
}

const decodedPath = decodeLocalImagePath(normalizedSrc);
if (decodedPath === undefined) {
return undefined;
}
const resolvedBase = vscode.Uri.joinPath(
source.with({ path: path.posix.dirname(source.path) }),
normalizedSrc
decodedPath
);
const resolved = restoreSvgFragment(resolvedBase, fragment);
if (!sourceFolder) return resolved;
Expand Down
7 changes: 7 additions & 0 deletions test/e2e/preview.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,12 @@ test.describe('preview features (VS Code)', () => {
const linkedSubdoc = await readWorkspaceMarkdown(workspace, 'sub/linked-subdoc.md');
const mermaidEdgeCases = await readWorkspaceMarkdown(workspace, 'mermaid-edge-cases.md');
const remoteImages = await readWorkspaceMarkdown(workspace, 'remote-images.md');
const imagePaths = await readWorkspaceMarkdown(workspace, 'image-paths.md');

await access(join(workspace, 'assets/banner.svg'));
await access(join(workspace, 'assets/grid.svg'));
await access(join(workspace, 'assets/normal-image.svg'));
await access(join(workspace, 'assets/name with spaces.svg'));

expect(sample).toContain('# Sample');
expect(sample).toContain('## Mermaid');
Expand All @@ -48,6 +51,10 @@ test.describe('preview features (VS Code)', () => {
expect(remoteImages).toContain('# Remote Images Fixture');
expect(remoteImages).toContain('offlineMarkdownViewer.preview.allowRemoteImages = false');
expect(remoteImages).toContain('Download Image');
expect(imagePaths).toContain('](./assets/normal-image.svg)');
expect(imagePaths).toContain('](./assets/name%20with%20spaces.svg)');
expect(imagePaths).toContain('](<./assets/name with spaces.svg>)');
expect(imagePaths).toContain('[image-with-spaces]: ./assets/name%20with%20spaces.svg');

const remoteImageLinks = extractMarkdownImageLinks(remoteImages).filter((link) => isHttpUrl(link));
expect(remoteImageLinks).toEqual(
Expand Down
19 changes: 14 additions & 5 deletions test/unit/helpers/vscodeMock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,15 @@ function toFsPath(uriPath: string): string {
return /^\/[A-Za-z]:\//.test(uriPath) ? uriPath.slice(1) : uriPath;
}

function encodeUriPath(uriPath: string): string {
return uriPath
.split('/')
.map((segment) =>
encodeURIComponent(segment).replace(/%3A/gi, ':').replace(/%40/gi, '@')
)
.join('/');
}

export class Uri {
constructor(
public readonly scheme: string,
Expand All @@ -35,20 +44,20 @@ export class Uri {
static file(fsPath: string): Uri {
const normalizedFsPath = normalizeSlashes(fsPath);
const uriPath = toUriPath(normalizedFsPath);
return new Uri('file', normalizedFsPath, uriPath, `file://${uriPath}`);
return new Uri('file', normalizedFsPath, uriPath, `file://${encodeUriPath(uriPath)}`);
}

static parse(input: string): Uri {
if (input.startsWith('file://')) {
const match = /^file:\/\/([^?#]*)(?:\?([^#]*))?(?:#(.*))?$/i.exec(input);
const uriPath = normalizeSlashes(match?.[1] ?? '');
const uriPath = decodeURIComponent(normalizeSlashes(match?.[1] ?? ''));
const fsPath = toFsPath(uriPath);
const query = match?.[2] ?? '';
const fragment = match?.[3] ?? '';
return new Uri('file', fsPath, uriPath, input, query, fragment);
}
if (/^https?:\/\//i.test(input)) {
return new Uri(input.split(':')[0], '', '', input);
return new Uri(input.slice(0, input.indexOf(':')), '', '', input);
}
return Uri.file(input);
}
Expand All @@ -59,15 +68,15 @@ export class Uri {
'file',
toFsPath(nextUriPath),
nextUriPath,
`file://${nextUriPath}`
`file://${encodeUriPath(nextUriPath)}`
);
}

with(update: { path?: string; query?: string; fragment?: string }): Uri {
const nextPath = update.path ?? this.path;
const base =
this.scheme === 'file'
? `file://${nextPath}`
? `file://${encodeUriPath(nextPath)}`
: `${this.scheme}:${nextPath}`;
const nextQuery = update.query ?? this.query;
const nextFragment = update.fragment ?? this.fragment;
Expand Down
60 changes: 60 additions & 0 deletions test/unit/linkResolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,60 @@ describe('linkResolver', () => {
it('blocks preview image resolution outside workspace', () => {
const source = Uri.file('/workspace/docs/a.md');
expect(api.resolveImageUri(source as any, '../../secret.png')).toBeUndefined();
expect(api.resolveImageUri(source as any, '%2e%2e/%2e%2e/secret.png')).toBeUndefined();
});

it.each([
['name%20with%20spaces.png', '/workspace/docs/name with spaces.png'],
['name%20with%20multiple%20spaces.png', '/workspace/docs/name with multiple spaces.png'],
['status%20%28final%29.png', '/workspace/docs/status (final).png'],
['section%23one.png', '/workspace/docs/section#one.png'],
['question%3Fmark.png', '/workspace/docs/question?mark.png'],
['plain.png', '/workspace/docs/plain.png'],
['nested/images/name%20with%20spaces.png', '/workspace/docs/nested/images/name with spaces.png'],
['progress%25100.png', '/workspace/docs/progress%100.png']
])('decodes local image URI path segments once: %s', (src, expectedPath) => {
const source = Uri.file('/workspace/docs/a.md');
const resolved = api.resolveImageUri(source as any, src);

expect(resolved?.fsPath).toBe(expectedPath);
});

it('does not decode local image paths twice', () => {
const source = Uri.file('/workspace/docs/a.md');
const resolved = api.resolveImageUri(source as any, 'literal%2520name.png');

expect(resolved?.fsPath).toBe('/workspace/docs/literal%20name.png');
});

it('resolves encoded file URIs through VS Code URI parsing', () => {
const source = Uri.file('/workspace/docs/a.md');
const resolved = api.resolveImageUri(
source as any,
'file:///workspace/docs/name%20with%20spaces.png'
);

expect(resolved?.fsPath).toBe('/workspace/docs/name with spaces.png');
});

it.each([
'nested%2Fsecret.png',
'nested%5Csecret.png',
'file:///workspace/docs/nested%2Fsecret.png',
'file:///workspace/docs/nested%5Csecret.png',
'malformed%E0%A4%A.png'
])('rejects unsafe or malformed local image encoding without throwing: %s', (src) => {
const source = Uri.file('/workspace/docs/a.md');

expect(() => api.resolveImageUri(source as any, src)).not.toThrow();
expect(api.resolveImageUri(source as any, src)).toBeUndefined();
});

it('keeps remote HTTP image URLs out of local resolution', () => {
const source = Uri.file('/workspace/docs/a.md');

expect(api.resolveImageUri(source as any, 'http://example.com/name%20one.png')).toBeUndefined();
expect(api.resolveImageUri(source as any, 'https://example.com/name%20two.png')).toBeUndefined();
});

it('strips query strings and fragments before resolving local image paths', () => {
Expand All @@ -41,6 +95,12 @@ describe('linkResolver', () => {
'file:///workspace/docs/demo@2x.gif?cache=1#retina'
)?.toString()
).toBe('file:///workspace/docs/demo@2x.gif');
expect(
api.resolveImageUri(
source as any,
'./icons%23set.svg?cache=1#logo'
)?.toString()
).toBe('file:///workspace/docs/icons%23set.svg#logo');
});

it('preserves SVG fragments in resolved local image URIs', () => {
Expand Down
60 changes: 60 additions & 0 deletions test/unit/markdownPipeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,66 @@ describe('markdownPipeline', () => {
expect(result.html).toContain('footnote');
});

it('resolves encoded, angle-bracket, and reference-style local image destinations', () => {
const sourceUri = Uri.file('/workspace/docs/readme.md');
const webview = {
asWebviewUri(uri: { toString(): string }) {
return { toString: () => `vscode-webview://${uri.toString()}` };
}
};
const input = [
'![encoded](name%20with%20spaces.png)',
'',
'![angle](<name with spaces.png>)',
'',
'![reference][spaced-image]',
'',
'[spaced-image]: nested/name%20with%20spaces.png'
].join('\n');

const result = renderMarkdown(input, {
sourceUri,
webview: webview as any,
allowHtml: true,
allowRemoteImages: false,
maxImageMB: 8
});

expect(
String(result.html).match(
/data-omv-local-src="file:\/\/\/workspace\/docs\/name%20with%20spaces\.png"/g
)
).toHaveLength(2);
expect(result.html).toContain(
'data-omv-local-src="file:///workspace/docs/nested/name%20with%20spaces.png"'
);
});

it('leaves allowed HTTP and HTTPS image URLs unchanged', () => {
const sourceUri = Uri.file('/workspace/docs/readme.md');
const webview = {
asWebviewUri(uri: { toString(): string }) {
return { toString: () => `vscode-webview://${uri.toString()}` };
}
};
const input = [
'![http](http://example.com/name%20one.png)',
'![https](https://example.com/name%20two.png)'
].join('\n\n');

const result = renderMarkdown(input, {
sourceUri,
webview: webview as any,
allowHtml: true,
allowRemoteImages: true,
maxImageMB: 8
});

expect(result.html).toContain('src="http://example.com/name%20one.png"');
expect(result.html).toContain('src="https://example.com/name%20two.png"');
expect(result.html).not.toContain('data-omv-local-src=');
});

it('parses inline and multiline display math placeholders', () => {
const sourceUri = Uri.file('/workspace/docs/math.md');
const webview = {
Expand Down
Loading