From 661cc82c6e2cccbddbd80b986499d5d0d1e48112 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 6 Jul 2026 10:29:46 +0100 Subject: [PATCH] fix(tui): harden OSC8 URL boundaries (#100780) * test(tui): cover parenthetical link rendering * fix(tui): trim unmatched URL parentheses * fix(tui): reject incomplete URL ranges * fix(tui): harden wrapped URL boundaries --- src/tui/components/hyperlink-markdown.test.ts | 9 ++ src/tui/osc8-hyperlinks.test.ts | 66 ++++++++++++++ src/tui/osc8-hyperlinks.ts | 88 +++++++++++++------ 3 files changed, 136 insertions(+), 27 deletions(-) diff --git a/src/tui/components/hyperlink-markdown.test.ts b/src/tui/components/hyperlink-markdown.test.ts index ed155f2a6dc9..62c2c181bc33 100644 --- a/src/tui/components/hyperlink-markdown.test.ts +++ b/src/tui/components/hyperlink-markdown.test.ts @@ -34,4 +34,13 @@ describe("HyperlinkMarkdown", () => { expect(normalized.some((line) => line.includes("is_palindrome"))).toBe(true); expect(normalized.some((line) => line.includes("__init__"))).toBe(true); }); + + it("links the complete parenthetical URL rendered by pi-tui", () => { + const url = "https://en.wikipedia.org/wiki/URL_(disambiguation)"; + const markdown = new HyperlinkMarkdown(`[Wikipedia](${url})`, 0, 0, markdownTheme); + + const rendered = markdown.render(120).join("\n"); + + expect(rendered).toContain(`\x1b]8;;${url}\x07${url}\x1b]8;;\x07`); + }); }); diff --git a/src/tui/osc8-hyperlinks.test.ts b/src/tui/osc8-hyperlinks.test.ts index 673c41ebfed4..66e04d62afa0 100644 --- a/src/tui/osc8-hyperlinks.test.ts +++ b/src/tui/osc8-hyperlinks.test.ts @@ -48,16 +48,37 @@ describe("extractUrls", () => { expect(urls).toEqual(["https://example.com/path?q=1&r=2#section"]); }); + it("extracts a bare URL with a bracketed IPv6 authority", () => { + const url = "http://[::1]:8080/path"; + expect(extractUrls(url)).toEqual([url]); + }); + it("extracts markdown link hrefs with parentheses in the URL", () => { const url = "https://en.wikipedia.org/wiki/URL_(disambiguation)"; expect(extractUrls(`[Wikipedia](${url})`)).toEqual([url]); }); + it("does not extract an incomplete markdown link destination", () => { + expect(extractUrls("[broken](https://)")).toEqual([]); + }); + + it.each(["[broken](https://.)", "https://.", "https:///path"])( + "does not extract a URL without a real authority from %s", + (text) => { + expect(extractUrls(text)).toEqual([]); + }, + ); + it("handles bare URLs with trailing closing paren as punctuation", () => { const urls = extractUrls("(see https://example.com/path)"); expect(urls).toEqual(["https://example.com/path"]); }); + it("drops punctuation after an unmatched closing paren", () => { + const urls = extractUrls("(see https://example.com/path)."); + expect(urls).toEqual(["https://example.com/path"]); + }); + it("handles markdown link with angle brackets and parenthetical URL", () => { const url = "https://en.wikipedia.org/wiki/Special_(film)"; expect(extractUrls(`[link](<${url}>)`)).toEqual([url]); @@ -92,6 +113,44 @@ describe("addOsc8Hyperlinks", () => { expect(result[1]).toContain(`\x1b]8;;${fullUrl}\x07`); }); + it("wraps a URL with a bracketed IPv6 authority", () => { + const url = "http://[::1]:8080/path"; + expect(addOsc8Hyperlinks([url], [url])).toEqual([`\x1b]8;;${url}\x07${url}\x1b]8;;\x07`]); + }); + + it("wraps a URL broken immediately after its scheme", () => { + const fullUrl = "https://example.com/path"; + const result = addOsc8Hyperlinks(["https://", "example.com/path"], [fullUrl]); + + expect(result[0]).toBe(`\x1b]8;;${fullUrl}\x07https://\x1b]8;;\x07`); + expect(result[1]).toBe(`\x1b]8;;${fullUrl}\x07example.com/path\x1b]8;;\x07`); + }); + + it("does not cross-link a scheme-only fragment to a partial domain match", () => { + const result = addOsc8Hyperlinks(["https://", "example.org"], ["https://example.com"]); + + expect(result).toEqual(["https://", "example.org"]); + }); + + it("does not cross-link a scheme-only fragment to a longer URL token", () => { + const result = addOsc8Hyperlinks( + ["https://", "example.com/pathology"], + ["https://example.com/path"], + ); + + expect(result).toEqual(["https://", "example.com/pathology"]); + }); + + it("does not recover a punctuated incomplete URL as a wrapped URL", () => { + const result = addOsc8Hyperlinks(["broken (https://)", "example.com"], ["https://example.com"]); + + expect(result).toEqual(["broken (https://)", "example.com"]); + }); + + it("does not wrap a punctuation-only URL body", () => { + expect(addOsc8Hyperlinks(["https://."], ["https://."])).toEqual(["https://."]); + }); + it("handles URL with ANSI styling codes", () => { const url = "https://example.com"; // Simulate styled text: green URL @@ -148,6 +207,13 @@ describe("addOsc8Hyperlinks", () => { expect(result[0]).toBe(`Wikipedia (\x1b]8;;${url}\x07${url}\x1b]8;;\x07)`); }); + it("does not resolve an incomplete URL to another known URL", () => { + const url = "https://example.com"; + const result = addOsc8Hyperlinks(["broken (https://)", url], [url]); + expect(result[0]).toBe("broken (https://)"); + expect(result[1]).toContain(`\x1b]8;;${url}\x07${url}\x1b]8;;\x07`); + }); + it("handles URL split across three lines", () => { const fullUrl = "https://example.com/a/very/long/path/that/keeps/going/and/going"; const lines = ["https://example.com/a/very/lon", "g/path/that/keeps/going/and/g", "oing"]; diff --git a/src/tui/osc8-hyperlinks.ts b/src/tui/osc8-hyperlinks.ts index 509896d144da..9f67f2b717d3 100644 --- a/src/tui/osc8-hyperlinks.ts +++ b/src/tui/osc8-hyperlinks.ts @@ -9,32 +9,31 @@ const OSC8_START_RE = new RegExp(`^${OSC8_PATTERN}`); /** Allow one level of balanced parentheses inside a URL so markdown link * targets like `https://en.wikipedia.org/wiki/URL_(disambiguation)` are * fully captured instead of truncated at the first `)`. */ -const URL_PATH_WITH_PARENS = /https?:\/\/[^()\s<>]*(?:\([^()\s<>]*\)[^()\s<>]*)*/g; +const URL_PATH_WITH_PARENS = /https?:\/\/[^()\s<>]+(?:\([^()\s<>]*\)[^()\s<>]*)*/g; -/** Strip trailing `)` characters that don't have a matching `(` in the URL. +/** Strip the suffix starting at a `)` without a matching `(` in the URL. * Bare URLs in prose can pick up a trailing `)` that belongs to surrounding * punctuation, e.g. `(see https://example.com/path)` — the `)` after `path` - * is sentence punctuation, not part of the URL. */ + * and anything after it are sentence punctuation, not part of the URL. */ function trimUnbalancedTrailingParens(url: string): string { let open = 0; - for (const ch of url) { + for (let index = 0; index < url.length; index++) { + const ch = url[index]; if (ch === "(") { open++; } else if (ch === ")") { + if (open === 0) { + return url.slice(0, index); + } open--; } } - // If close parens outnumber open parens, the trailing ones are not - // part of the URL — strip them. - if (open >= 0) { - return url; - } - let trimmed = url; - while (trimmed.endsWith(")") && open < 0) { - trimmed = trimmed.slice(0, -1); - open++; - } - return trimmed; + return url; +} + +function hasUrlContent(url: string): boolean { + const authority = url.slice(url.indexOf("://") + 3).split(/[/?#]/, 1)[0]; + return /[\p{L}\p{N}]/u.test(authority) || /^\[[0-9a-f:.]+\](?::\d+)?$/i.test(authority); } /** @@ -51,14 +50,19 @@ export function extractUrls(markdown: string): string[] { ); let m: RegExpExecArray | null; while ((m = mdLinkRe.exec(markdown)) !== null) { - urls.add(m[1]); + if (hasUrlContent(m[1])) { + urls.add(m[1]); + } } // Bare URLs (remove markdown links first to avoid double-matching) const stripped = markdown.replace(mdLinkRe, ""); - const bareRe = /https?:\/\/[^\s\]>]+/g; + const bareRe = /https?:\/\/(?:\[[0-9a-f:.]+\](?::\d+)?[^\s\]>]*|[^\s[\]>]+)/gi; while ((m = bareRe.exec(stripped)) !== null) { - urls.add(trimUnbalancedTrailingParens(m[0])); + const url = trimUnbalancedTrailingParens(m[0]); + if (hasUrlContent(url)) { + urls.add(url); + } } return [...urls]; @@ -82,6 +86,7 @@ function findUrlRanges( visibleText: string, knownUrls: string[], pending: { url: string; consumed: number } | null, + nextVisibleText?: string, ): { ranges: UrlRange[]; pending: { url: string; consumed: number } | null } { const ranges: UrlRange[] = []; let newPending: { url: string; consumed: number } | null = null; @@ -117,7 +122,7 @@ function findUrlRanges( } // Find new URL starts in visible text - const urlRe = /https?:\/\/[^\s\]>]+/g; + const urlRe = /https?:\/\/(?:\[[0-9a-f:.]+\](?::\d+)?[^\s\]>]*|[^\s[\]>]*)/gi; urlRe.lastIndex = searchFrom; let match: RegExpExecArray | null; @@ -129,11 +134,40 @@ function findUrlRanges( let resolvedUrl = fragment; let found = false; - for (const known of knownUrls) { - if (known === fragment) { - resolvedUrl = known; - found = true; - break; + // A wrap may split immediately after the scheme. Only accept that fragment + // when the next line actually continues a known URL; otherwise a stray + // `https://` could inherit an unrelated target from the URL list. + if (!hasUrlContent(fragment)) { + const hasUnpunctuatedSchemeAtLineEnd = + fragment === match[0] && visibleText.slice(start + match[0].length).trim().length === 0; + if (!hasUnpunctuatedSchemeAtLineEnd) { + continue; + } + const nextToken = nextVisibleText?.trimStart().match(/^[^\s\]>]+/)?.[0] ?? ""; + const nextFragment = trimUnbalancedTrailingParens(nextToken); + for (const known of knownUrls) { + if (!known.startsWith(fragment)) { + continue; + } + const remaining = known.slice(fragment.length); + const continuesKnownUrl = nextFragment.length > 0 && remaining.startsWith(nextFragment); + if (continuesKnownUrl && known.length > resolvedUrl.length) { + resolvedUrl = known; + found = true; + } + } + if (!found) { + continue; + } + } + + if (!found) { + for (const known of knownUrls) { + if (known === fragment) { + resolvedUrl = known; + found = true; + break; + } } } if (!found) { @@ -247,10 +281,10 @@ export function addOsc8Hyperlinks(lines: string[], urls: string[]): string[] { } let pending: { url: string; consumed: number } | null = null; + const visibleLines = lines.map(stripAnsi); - return lines.map((line) => { - const visible = stripAnsi(line); - const result = findUrlRanges(visible, urls, pending); + return lines.map((line, index) => { + const result = findUrlRanges(visibleLines[index], urls, pending, visibleLines[index + 1]); pending = result.pending; return applyOsc8Ranges(line, result.ranges); });