1// Package url provides URL manipulation utilities for the browser.
2package url
3
4import (
5 "crypto/sha256"
6 "encoding/hex"
7 "fmt"
8 "net"
9 "net/url"
10 "strings"
11)
12
13// looksLikeFilePath returns true if the input looks like a filesystem path.
14// This includes absolute paths (/path), relative paths (./path, ../path),
15// and home-relative paths (~/path).
16func looksLikeFilePath(input string) bool {
17 switch {
18 case strings.HasPrefix(input, "/"):
19 return true
20 case strings.HasPrefix(input, "./"):
21 return true
22 case strings.HasPrefix(input, "../"):
23 return true
24 case strings.HasPrefix(input, "~/"):
25 return true
26 }
27 return false
28}
29
30// Normalize adds https:// prefix if missing for URL-like inputs.
31// Returns the input unchanged if it already has a scheme or doesn't look like a URL.
32// Path-like inputs are returned unchanged; filesystem probing belongs in application adapters.
33func Normalize(input string) string {
34 if input == "" {
35 return ""
36 }
37
38 // Already has scheme
39 switch {
40 case strings.HasPrefix(input, "http://"):
41 return input
42 case strings.HasPrefix(input, "https://"):
43 return input
44 case strings.HasPrefix(input, "dumb://"):
45 return input
46 case strings.HasPrefix(input, "file://"):
47 return input
48 case strings.HasPrefix(input, "about:"):
49 return input
50 case IsExternalScheme(input):
51 return input
52 case hasExplicitSchemeURL(input):
53 return input
54 }
55
56 // Path-like input is not treated as a URL to avoid malformed URLs like https:///path.
57 if looksLikeFilePath(input) {
58 return input
59 }
60
61 // Looks like a URL (contains . and no spaces)
62 if input == "localhost" || strings.HasPrefix(input, "localhost:") || strings.HasPrefix(input, "localhost/") {
63 return "http://" + input
64 }
65 if looksLikeIPAddressWithOptionalPortOrPath(input) {
66 return "http://" + input
67 }
68 if strings.Contains(input, ".") && !strings.Contains(input, " ") {
69 return "https://" + input
70 }
71
72 return input
73}
74
75func looksLikeIPAddressWithOptionalPortOrPath(input string) bool {
76 if input == "" || strings.Contains(input, " ") {
77 return false
78 }
79
80 parsed, err := url.Parse("http://" + input)
81 if err != nil {
82 return false
83 }
84
85 hostname := parsed.Hostname()
86 if hostname == "" {
87 return false
88 }
89
90 return net.ParseIP(hostname) != nil
91}
92
93// LooksLikeURL checks if the input appears to be a URL (not a search query).
94// Returns true for strings like "github.com", "google.com/search", etc.
95// Also returns true for URLs with explicit schemes like "dumb://".
96func LooksLikeURL(input string) bool {
97 if input == "" {
98 return false
99 }
100
101 // Explicit schemes should always be treated as URLs.
102 switch {
103 case strings.HasPrefix(input, "http://"):
104 return true
105 case strings.HasPrefix(input, "https://"):
106 return true
107 case strings.HasPrefix(input, "dumb://"):
108 return true
109 case strings.HasPrefix(input, "file://"):
110 return true
111 case strings.HasPrefix(input, "about:"):
112 return true
113 case IsExternalScheme(input):
114 return true
115 case hasExplicitSchemeURL(input):
116 return true
117 }
118
119 // Contains a dot and no spaces = likely a URL
120 if input == "localhost" || strings.HasPrefix(input, "localhost:") || strings.HasPrefix(input, "localhost/") {
121 return true
122 }
123 if looksLikeIPAddressWithOptionalPortOrPath(input) {
124 return true
125 }
126 return strings.Contains(input, ".") && !strings.Contains(input, " ")
127}
128
129func hasExplicitSchemeURL(input string) bool {
130 if !strings.Contains(input, "://") {
131 return false
132 }
133
134 parsed, err := url.Parse(input)
135 if err != nil {
136 return false
137 }
138
139 return parsed.Scheme != ""
140}
141
142// ExtractDomain extracts the normalized domain (host) from a URL string.
143// Normalizes by stripping "www." prefix so youtube.com and www.youtube.com
144// resolve to the same value.
145func ExtractDomain(rawURL string) string {
146 if rawURL == "" {
147 return ""
148 }
149 parsed, err := url.Parse(rawURL)
150 if err != nil || parsed.Host == "" {
151 return ""
152 }
153 return strings.TrimPrefix(strings.ToLower(parsed.Host), "www.")
154}
155
156// CanonicalDomain extracts a normalized domain from either a URL string or a
157// host-like domain string. It keeps non-default ports so callers that need to
158// round-trip repository domain keys do not lose identity.
159func CanonicalDomain(raw string) string {
160 raw = strings.TrimSpace(strings.ToLower(raw))
161 if raw == "" {
162 return ""
163 }
164 if domain := ExtractDomain(raw); domain != "" {
165 return domain
166 }
167 if idx := strings.IndexAny(raw, "/?#"); idx >= 0 {
168 raw = raw[:idx]
169 }
170 return strings.TrimPrefix(raw, "www.")
171}
172
173// DisplayDomain returns a user-facing domain label with any port removed.
174func DisplayDomain(raw string) string {
175 domain := CanonicalDomain(raw)
176 if host, _, err := net.SplitHostPort(domain); err == nil {
177 return host
178 }
179 return domain
180}
181
182// maxSanitizedFilenameLen leaves headroom under common 255-byte filename
183// limits for favicon sidecars such as ".content-type".
184const maxSanitizedFilenameLen = 240
185
186// SanitizeDomainForFilename converts a domain/key to a safe bounded filename with .ico extension.
187func SanitizeDomainForFilename(domain string) string {
188 return sanitizeDomainWithSuffix(domain, ".ico")
189}
190
191// SanitizeDomainForPNG converts a domain/key to a safe bounded filename with .png extension.
192// Used for favicon export for tools like rofi/fuzzel that require PNG format.
193func SanitizeDomainForPNG(domain string) string {
194 return sanitizeDomainWithSuffix(domain, ".png")
195}
196
197// sanitizeDomain percent-escapes filesystem-unsafe characters while preserving
198// ordinary domain filenames. Percent escapes avoid collisions between path keys
199// such as example.com/a_b and example.com/a/b.
200func sanitizeDomain(domain string) string {
201 replacer := strings.NewReplacer(
202 "%", "%25",
203 ":", "%3A",
204 "/", "%2F",
205 "\\", "%5C",
206 "*", "%2A",
207 "?", "%3F",
208 "\"", "%22",
209 "<", "%3C",
210 ">", "%3E",
211 "|", "%7C",
212 )
213 return replacer.Replace(domain)
214}
215
216func sanitizeDomainWithSuffix(domain, suffix string) string {
217 sanitized := sanitizeDomain(domain)
218 if len(sanitized)+len(suffix) <= maxSanitizedFilenameLen {
219 return sanitized + suffix
220 }
221 sum := sha256.Sum256([]byte(domain))
222 return "sha256-" + hex.EncodeToString(sum[:]) + suffix
223}
224
225// SanitizeDomainForPNGSized converts a domain to a safe filename with size suffix.
226// Example: "google.com" with size 32 -> "google.com.32.png"
227// Used for normalized favicon export for tools like rofi/fuzzel.
228func SanitizeDomainForPNGSized(domain string, size int) string {
229 return sanitizeDomainWithSuffix(domain, fmt.Sprintf(".%d.png", size))
230}
231
232// TrimLeadingSpacesIfURL removes leading whitespace from input if the trimmed
233// result looks like a URL. Returns the original input unchanged if it doesn't
234// contain leading spaces or if the trimmed result is not a URL.
235// This handles cases like pasting " https://example.com" from clipboard.
236func TrimLeadingSpacesIfURL(input string) string {
237 trimmed := strings.TrimLeft(input, " \t")
238 if trimmed != input && LooksLikeURL(trimmed) {
239 return trimmed
240 }
241 return input
242}
243
244// IsExternalScheme returns true if the URL uses a scheme that should be
245// launched externally (e.g., vscode://, vscode-insiders://, spotify://, steam://)
246// rather than handled by the browser.
247// URL schemes are case-insensitive per RFC 3986.
248func IsExternalScheme(uri string) bool {
249 if uri == "" {
250 return false
251 }
252
253 parsed, err := url.Parse(uri)
254 if err != nil || parsed.Scheme == "" {
255 return false
256 }
257 if !strings.Contains(uri, "://") {
258 rest := strings.TrimPrefix(uri, parsed.Scheme+":")
259 if strings.EqualFold(parsed.Scheme, "localhost") || strings.Contains(parsed.Scheme, ".") || isDecimalPort(rest) {
260 return false
261 }
262 }
263
264 switch strings.ToLower(parsed.Scheme) {
265 case "http", "https", "file", "dumb", "about", "data", "blob", "javascript":
266 return false
267 default:
268 return true
269 }
270}
271
272func isDecimalPort(value string) bool {
273 if value == "" {
274 return false
275 }
276 for _, r := range value {
277 if r < '0' || r > '9' {
278 return false
279 }
280 }
281 return true
282}
283
284// ExtractOrigin extracts the origin (scheme://host) from a URI.
285// This normalizes URIs to origins for permission storage and comparison.
286// Canonicalizes by lowercasing scheme and host, and omitting default ports.
287// Example: "https://example.com/path?query" -> "https://example.com"
288// Example: "HTTPS://EXAMPLE.COM:443/" -> "https://example.com"
289func ExtractOrigin(uri string) (string, error) {
290 parsed, err := url.Parse(uri)
291 if err != nil {
292 return "", fmt.Errorf("invalid URI: %w", err)
293 }
294
295 if parsed.Scheme == "" || parsed.Host == "" {
296 return "", fmt.Errorf("URI missing scheme or host: %s", uri)
297 }
298
299 // Canonicalize: lowercase scheme and hostname
300 scheme := strings.ToLower(parsed.Scheme)
301 hostname := strings.ToLower(parsed.Hostname())
302 if strings.Contains(hostname, ":") && !strings.HasPrefix(hostname, "[") {
303 hostname = "[" + hostname + "]"
304 }
305
306 // Determine if we should include the port
307 port := parsed.Port()
308 if port != "" {
309 // Omit default ports
310 if (scheme == "http" && port == "80") || (scheme == "https" && port == "443") {
311 port = ""
312 }
313 }
314
315 // Build canonical origin
316 origin := scheme + "://" + hostname
317 if port != "" {
318 origin = origin + ":" + port
319 }
320
321 return origin, nil
322}