// Package urlx is the URL ⇄ filesystem contract at the heart of kage. // // Every reference kage meets — a page link, a stylesheet, an image, a font — // is funnelled through Normalize so that two different-looking URLs that point // at the same resource collapse to one canonical key. LocalPath then maps that // canonical URL to a deterministic path on disk, and Rel turns two such paths // into the relative link that goes back into the rewritten HTML or CSS. // // The package is pure: no network, no filesystem, no clock. That is what makes // the rest of kage easy to reason about — a page worker can rewrite a link to // an asset long before the asset has been downloaded, because both sides agree // on where the bytes will live. package urlx import ( "crypto/sha256" "encoding/hex" "fmt" "net/url" "path" "strings" "golang.org/x/net/publicsuffix" ) // Kind distinguishes a crawlable page from a downloadable asset; the two map to // different places on disk (pages mirror the URL path, assets live under the // reserved prefix). type Kind int const ( // Page is an HTML document kage renders and rewrites. Page Kind = iota // Asset is a stylesheet, image, font, or media file kage downloads verbatim. Asset ) // DefaultReserved is the directory under the mirror root where every asset and // kage's own state live. It is deliberately unlikely to collide with a real URL // path segment. const DefaultReserved = "_kage" // binaryExts are extensions that, when seen on an , mean the link points // at a file to download rather than a page to render. var binaryExts = map[string]bool{ ".pdf": true, ".zip": true, ".gz": true, ".tar": true, ".tgz": true, ".rar": true, ".7z": true, ".dmg": true, ".exe": true, ".msi": true, ".png": true, ".jpg": true, ".jpeg": true, ".gif": true, ".webp": true, ".svg": true, ".ico": true, ".bmp": true, ".avif": true, ".mp3": true, ".mp4": true, ".webm": true, ".mov": true, ".avi": true, ".wav": true, ".ogg": true, ".flac": true, ".woff": true, ".woff2": true, ".ttf": true, ".otf": true, ".eot": true, ".css": true, ".js": true, ".json": true, ".xml": true, ".rss": true, ".doc": true, ".docx": true, ".xls": true, ".xlsx": true, ".ppt": true, ".pptx": true, ".csv": true, ".txt": true, } // ParseSeed turns a command-line argument like "example.com", // "https://example.com/docs" or "http://ex.com" into a canonical absolute URL. // A bare host (no scheme) is assumed to be https. func ParseSeed(arg string) (*url.URL, error) { s := strings.TrimSpace(arg) if s == "" { return nil, fmt.Errorf("empty seed") } if !strings.Contains(s, "://") { s = "https://" + s } u, err := url.Parse(s) if err != nil { return nil, fmt.Errorf("parse seed %q: %w", arg, err) } if u.Host == "" { return nil, fmt.Errorf("seed %q has no host", arg) } return canonical(u, true), nil } // Normalize resolves ref against base and canonicalises the result. It returns // an error for references kage cannot crawl or download: empty, fragment-only, // or a non-http(s) scheme (mailto:, tel:, data:, javascript:, blob:, …). func Normalize(base *url.URL, ref string) (*url.URL, error) { ref = strings.TrimSpace(ref) if ref == "" { return nil, fmt.Errorf("empty reference") } if strings.HasPrefix(ref, "#") { return nil, fmt.Errorf("fragment-only reference") } low := strings.ToLower(ref) for _, bad := range []string{"javascript:", "mailto:", "tel:", "data:", "blob:", "about:", "ftp:", "sms:"} { if strings.HasPrefix(low, bad) { return nil, fmt.Errorf("non-crawlable scheme: %s", bad) } } r, err := url.Parse(ref) if err != nil { return nil, fmt.Errorf("parse ref %q: %w", ref, err) } var abs *url.URL if base != nil { abs = base.ResolveReference(r) } else { abs = r } if abs.Scheme != "http" && abs.Scheme != "https" { return nil, fmt.Errorf("non-http scheme: %q", abs.Scheme) } if abs.Host == "" { return nil, fmt.Errorf("reference has no host") } return canonical(abs, false), nil } // canonical lower-cases scheme/host, drops the fragment and any default port, // and cleans the path while preserving a meaningful trailing slash. When root // is true an empty path becomes "/". func canonical(u *url.URL, root bool) *url.URL { c := *u c.Scheme = strings.ToLower(c.Scheme) c.Host = strings.ToLower(c.Host) c.Fragment = "" // Strip a default port. if h := c.Hostname(); h != "" { switch { case c.Scheme == "http" && c.Port() == "80": c.Host = h case c.Scheme == "https" && c.Port() == "443": c.Host = h } } if c.Path == "" { if root { c.Path = "/" } } else { trailing := strings.HasSuffix(c.Path, "/") cleaned := path.Clean(c.Path) if cleaned == "." { cleaned = "/" } if trailing && cleaned != "/" { cleaned += "/" } c.Path = cleaned } c.RawQuery = safeRawQuery(c.RawQuery) return &c } // safeRawQuery percent-encodes the characters a query string is not allowed to // carry on the wire (a space, a control byte, a non-ASCII byte) while leaving // everything already legal untouched, including existing %XX escapes and the // query sub-delimiters a cache-buster relies on (& = , ; etc.). It exists // because real sites emit hrefs with raw spaces in the query, e.g. a CSS link // busted with a date string like "?Thursday, 26-Feb-2026 16:26:41 UTC"; a // browser encodes the spaces before requesting, but url.Parse keeps RawQuery // verbatim, so without this the request line is malformed and the server // answers 400. Re-encoding here, on the canonical URL, fixes both the fetch and // the on-disk key in one place. func safeRawQuery(raw string) string { if raw == "" { return "" } var b strings.Builder for i := 0; i < len(raw); i++ { ch := raw[i] switch { case ch == '%' && i+2 < len(raw) && isHex(raw[i+1]) && isHex(raw[i+2]): // Already a percent-escape; copy it through unchanged. b.WriteString(raw[i : i+3]) i += 2 case queryByteAllowed(ch): b.WriteByte(ch) default: b.WriteByte('%') const hexDigits = "0123456789ABCDEF" b.WriteByte(hexDigits[ch>>4]) b.WriteByte(hexDigits[ch&0x0f]) } } return b.String() } // queryByteAllowed reports whether ch may appear literally in a URL query. // It is the RFC 3986 query grammar (pchar / "/" / "?"), which keeps the // sub-delims that structure a query and rejects spaces, quotes, and controls. func queryByteAllowed(ch byte) bool { switch { case ch >= 'a' && ch <= 'z', ch >= 'A' && ch <= 'Z', ch >= '0' && ch <= '9': return true } return strings.IndexByte("-._~!$&'()*+,;=:@/?", ch) >= 0 } func isHex(ch byte) bool { return (ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f') || (ch >= 'A' && ch <= 'F') } // Key is the canonical string form used to dedup pages and assets. func Key(u *url.URL) string { return u.String() } // ScopeConfig controls which page URLs are in scope for crawling. type ScopeConfig struct { IncludeSubdomains bool ScopePrefix string // only crawl paths under this prefix, e.g. "/docs/" ExcludePaths []string // skip any path containing one of these substrings } // SameSite reports whether u belongs to the seed's site: the same host, or a // subdomain of it when allowSub is set. func SameSite(seed, u *url.URL, allowSub bool) bool { sh, uh := seed.Hostname(), u.Hostname() if sh == uh { return true } if allowSub && strings.HasSuffix(uh, "."+sh) { return true } return false } // SameRegistrableDomain reports whether u shares the seed's registrable domain // (its eTLD+1). It is looser than SameSite: developer.apple.com, www.apple.com, // and images.apple.com all fold to apple.com and count as same-domain, while a // separate brand like cdn-apple.com or an unrelated third party like // ec.europa.eu does not. It is how kage decides whether an asset host is "the // site's own" without listing every subdomain a CDN might use. When either host // has no registrable domain (an IP, an oddball TLD), it falls back to an exact // host match so the decision stays conservative. func SameRegistrableDomain(seed, u *url.URL) bool { sh, uh := seed.Hostname(), u.Hostname() if sh == uh { return true } sd, err1 := publicsuffix.EffectiveTLDPlusOne(sh) ud, err2 := publicsuffix.EffectiveTLDPlusOne(uh) if err1 != nil || err2 != nil { return false } return sd == ud } // Ext returns the lowercased file extension of a URL's last path segment, // including the leading dot (".pdf", ".mp4"), or "" when there is none. It // ignores the query string, so "/a/clip.mp4?v=2" reports ".mp4". func Ext(u *url.URL) string { return strings.ToLower(path.Ext(lastSegment(u.Path))) } // InScope reports whether u should be crawled as a page given the seed and cfg. func InScope(seed, u *url.URL, cfg ScopeConfig) bool { if !SameSite(seed, u, cfg.IncludeSubdomains) { return false } if cfg.ScopePrefix != "" && !strings.HasPrefix(u.Path, cfg.ScopePrefix) { return false } for _, ex := range cfg.ExcludePaths { if ex != "" && strings.Contains(u.Path, ex) { return false } } return true } // LikelyPage reports whether an target should be rendered as a page // rather than downloaded as a file. Links ending in a known binary/document // extension are treated as assets. func LikelyPage(u *url.URL) bool { ext := strings.ToLower(path.Ext(lastSegment(u.Path))) return !binaryExts[ext] } // LocalPath maps a canonical URL to a slash-separated path relative to the // mirror root (out//). Pages mirror the URL path as a directory index; // assets live under reserved//. See spec §4.3. func LocalPath(seedHost string, u *url.URL, kind Kind, reserved string) string { if reserved == "" { reserved = DefaultReserved } host := u.Hostname() switch kind { case Asset: dir, base := splitAsset(u) base = applyQuery(base, u) return joinClean(reserved, host, dir, base) default: // Page dir := collapseIndex(strings.Trim(u.Path, "/")) leaf := applyQuery("index.html", u) if strings.EqualFold(host, seedHost) { return joinClean(dir, leaf) } // Subdomain pages get a host segment so they never collide with the // seed host's tree. return joinClean(host, dir, leaf) } } // collapseIndex treats a directory-index document as the directory itself, so // "/" and "/index.html" map to one page, and "/docs/index.html" collapses to // "/docs". The argument is a path already trimmed of surrounding slashes. func collapseIndex(p string) string { for _, idx := range []string{"index.html", "index.htm"} { if p == idx { return "" } if rest, ok := strings.CutSuffix(p, "/"+idx); ok { return rest } } return p } // splitAsset breaks an asset URL path into a directory and a filename, inventing // a filename for directory-like or empty paths. func splitAsset(u *url.URL) (dir, base string) { clean := strings.Trim(u.Path, "/") switch { case clean == "": return "", "index" case strings.HasSuffix(u.Path, "/"): return clean, "index" default: if i := strings.LastIndex(clean, "/"); i >= 0 { return clean[:i], clean[i+1:] } return "", clean } } // applyQuery folds a URL's query string into a filename as "__q-", // inserting it before the extension so the file keeps a sensible suffix. func applyQuery(name string, u *url.URL) string { if u.RawQuery == "" { return name } sum := sha256.Sum256([]byte(u.RawQuery)) suffix := "__q-" + hex.EncodeToString(sum[:])[:6] if dot := strings.LastIndex(name, "."); dot > 0 { return name[:dot] + suffix + name[dot:] } return name + suffix } // Rel returns the relative link from the directory of fromFile to toFile, both // slash paths relative to the mirror root. The result always uses '/'. func Rel(fromDir, toFile string) string { fromDir = path.Clean("/" + strings.TrimPrefix(fromDir, "/")) toFile = path.Clean("/" + strings.TrimPrefix(toFile, "/")) rel := relPath(fromDir, toFile) if rel == "" { return "." } return rel } // relPath computes a relative path between two cleaned absolute slash paths, // treating 'from' as a directory. func relPath(fromDir, toFile string) string { from := splitNonEmpty(fromDir) to := splitNonEmpty(toFile) // Common prefix. i := 0 for i < len(from) && i < len(to) && from[i] == to[i] { i++ } var out []string for range from[i:] { out = append(out, "..") } out = append(out, to[i:]...) return strings.Join(out, "/") } func splitNonEmpty(p string) []string { var out []string for s := range strings.SplitSeq(p, "/") { if s != "" { out = append(out, s) } } return out } // Dir returns the directory portion of a slash file path. func Dir(file string) string { if i := strings.LastIndex(file, "/"); i >= 0 { return file[:i] } return "" } // joinClean joins path elements with '/', dropping empties and cleaning the // result, and never returns a leading slash. func joinClean(elems ...string) string { var parts []string for _, e := range elems { e = strings.Trim(e, "/") if e != "" { parts = append(parts, e) } } return strings.Join(parts, "/") } func lastSegment(p string) string { p = strings.TrimRight(p, "/") if i := strings.LastIndex(p, "/"); i >= 0 { return p[i+1:] } return p }