(uri: string)
| 82 | * @throws {Error} if the URI is malformed or contains dangerous characters |
| 83 | */ |
| 84 | export function parseDeepLink(uri: string): DeepLinkAction { |
| 85 | // Normalize: accept with or without the trailing colon in protocol |
| 86 | const normalized = uri.startsWith(`${DEEP_LINK_PROTOCOL}://`) |
| 87 | ? uri |
| 88 | : uri.startsWith(`${DEEP_LINK_PROTOCOL}:`) |
| 89 | ? uri.replace(`${DEEP_LINK_PROTOCOL}:`, `${DEEP_LINK_PROTOCOL}://`) |
| 90 | : null |
| 91 | |
| 92 | if (!normalized) { |
| 93 | throw new Error( |
| 94 | `Invalid deep link: expected ${DEEP_LINK_PROTOCOL}:// scheme, got "${uri}"`, |
| 95 | ) |
| 96 | } |
| 97 | |
| 98 | let url: URL |
| 99 | try { |
| 100 | url = new URL(normalized) |
| 101 | } catch { |
| 102 | throw new Error(`Invalid deep link URL: "${uri}"`) |
| 103 | } |
| 104 | |
| 105 | if (url.hostname !== 'open') { |
| 106 | throw new Error(`Unknown deep link action: "${url.hostname}"`) |
| 107 | } |
| 108 | |
| 109 | const cwd = url.searchParams.get('cwd') ?? undefined |
| 110 | const repo = url.searchParams.get('repo') ?? undefined |
| 111 | const rawQuery = url.searchParams.get('q') |
| 112 | |
| 113 | // Validate cwd if present — must be an absolute path |
| 114 | if (cwd && !cwd.startsWith('/') && !/^[a-zA-Z]:[/\\]/.test(cwd)) { |
| 115 | throw new Error( |
| 116 | `Invalid cwd in deep link: must be an absolute path, got "${cwd}"`, |
| 117 | ) |
| 118 | } |
| 119 | |
| 120 | // Reject control characters in cwd (newlines, etc.) but allow path chars like backslash. |
| 121 | if (cwd && containsControlChars(cwd)) { |
| 122 | throw new Error('Deep link cwd contains disallowed control characters') |
| 123 | } |
| 124 | if (cwd && cwd.length > MAX_CWD_LENGTH) { |
| 125 | throw new Error( |
| 126 | `Deep link cwd exceeds ${MAX_CWD_LENGTH} characters (got ${cwd.length})`, |
| 127 | ) |
| 128 | } |
| 129 | |
| 130 | // Validate repo slug format. Resolution happens later (protocolHandler.ts) — |
| 131 | // this parser stays pure with no config/filesystem access. |
| 132 | if (repo && !REPO_SLUG_PATTERN.test(repo)) { |
| 133 | throw new Error( |
| 134 | `Invalid repo in deep link: expected "owner/repo", got "${repo}"`, |
| 135 | ) |
| 136 | } |
| 137 | |
| 138 | let query: string | undefined |
| 139 | if (rawQuery && rawQuery.trim().length > 0) { |
| 140 | // Strip hidden Unicode characters (ASCII smuggling / hidden prompt injection) |
| 141 | query = partiallySanitizeUnicode(rawQuery.trim()) |
no test coverage detected