| 1 | // Canonical parsing and validation for local file URLs used by Markdown links. |
| 2 | |
| 3 | export function hasDisallowedWindowsPathSyntax(path: string): boolean { |
| 4 | const slashPath = path.replace(/\\/g, "/"); |
| 5 | if (slashPath.includes("\0")) return true; |
| 6 | if (/^\/\/[.?](?:\/|$)/.test(slashPath)) return true; |
| 7 | const isDrivePath = /^[A-Za-z]:\//.test(slashPath); |
| 8 | const isUncPath = slashPath.startsWith("//"); |
| 9 | if (!isDrivePath && !isUncPath) return false; |
| 10 | const remainder = slashPath.slice(2); |
| 11 | if (remainder.includes(":")) return true; |
| 12 | return remainder.split("/").some((component) => { |
| 13 | const base = component.trimEnd().replace(/\.+$/, "").split(".", 1)[0]?.toUpperCase() ?? ""; |
| 14 | return /^(?:CON|PRN|AUX|NUL|CLOCK\$|CONIN\$|CONOUT\$|COM[1-9¹²³]|LPT[1-9¹²³])$/.test(base); |
| 15 | }); |
| 16 | } |
| 17 | |
| 18 | function hasDisallowedRawFileUrlSyntax(href: string): boolean { |
| 19 | let rawPath: string; |
| 20 | try { |
| 21 | rawPath = decodeURIComponent(href.slice("file:".length)).replace(/\\/g, "/"); |
| 22 | } catch { |
| 23 | return true; |
| 24 | } |
| 25 | // URL normalisation removes dot segments. Reject Windows device authorities |
| 26 | // before constructing URL so file:////./PhysicalDrive0 cannot become the |
| 27 | // apparently ordinary //PhysicalDrive0 UNC path. |
| 28 | return /^\/\/[.?](?:\/|$)/.test(rawPath) || /^\/{4,}[.?](?:\/|$)/.test(rawPath); |
| 29 | } |
| 30 | |
| 31 | /** |
| 32 | * Returns the decoded filesystem path represented by a local file URL. |
| 33 | * The scheme check is intentionally case-sensitive so `FILE:` cannot bypass |
| 34 | * the Markdown URL allowlist and reach a browser or native opener. |
| 35 | */ |
| 36 | export function localPathFromHref(href?: string): string | null { |
| 37 | if (!href || !href.startsWith("file://")) return null; |
| 38 | if (hasDisallowedRawFileUrlSyntax(href)) return null; |
| 39 | |
| 40 | try { |
| 41 | const url = new URL(href); |
| 42 | if (url.protocol !== "file:") return null; |
| 43 | if (url.username || url.password || url.port) return null; |
| 44 | if (url.search || url.hash) return null; |
| 45 | if (url.hostname === "." || url.hostname === "?") return null; |
| 46 | |
| 47 | let path = decodeURIComponent(url.pathname); |
| 48 | if (url.hostname) path = `//${url.hostname}${path}`; |
| 49 | |
| 50 | // file:///D:/... has a URL root slash that is not part of the Windows |
| 51 | // drive path. Multiple leading slashes are the slash-form UNC variant. |
| 52 | if (/^\/[A-Za-z]:\//.test(path)) path = path.slice(1); |
| 53 | if (path.startsWith("//")) path = `//${path.replace(/^\/+/, "")}`; |
| 54 | if (hasDisallowedWindowsPathSyntax(path)) return null; |
| 55 | if (/^[A-Za-z]:\//.test(path)) return path; |
| 56 | if (!path.startsWith("/")) return null; |
| 57 | return path; |
| 58 | } catch { |
| 59 | return null; |
| 60 | } |
| 61 | } |
| 62 | |
| 63 | export function isLocalFileHref(href?: string): boolean { |
| 64 | return localPathFromHref(href) !== null; |
| 65 | } |
| 66 |