Something went wrong. Try again.
Maps Linux distribution source packages to their upstream repositories, and through them to each other
Something went wrong. Try again.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031// Package repourl canonicalizes source repository URLs into stable keys.//// The key identifies an upstream repository across distributions, e.g. "github.com/curl/curl".// It is lowercase, has no scheme, no ".git" suffix, no trailing slash and no forge-specific// browsing suffix (/tree/main, /-/blob/..., /commit/...). Registry pages (pypi, crates.io, ...)// and known tarball hosts are recognised too so they can be recorded as mirrors and used as// hints, but they are flagged with Kind != KindRepo.package repourl
import ( "errors" "fmt" "net/url" "path" "regexp" "strings")
// Kind says what a parsed URL points at.type Kind string
const ( KindRepo Kind = "repo" // a version control repository KindRegistry Kind = "registry" // a package registry entry (pypi, crates.io, npm, ...) KindTarball Kind = "tarball" // a release archive; Hint may name the repo it likely came from KindOther Kind = "other" // a website we could not classify)
// Canon is a canonicalized repository reference.type Canon struct { // Key is the repository key, e.g. "github.com/curl/curl". Key string // URL is the canonical https clone URL (or registry/tarball URL for non-repos). URL string // Host is the canonical host of Key. Host string // Path is Key without the host, e.g. "curl/curl". Path string // Forge is github, gitlab, gitea, sourceforge, savannah, cgit, launchpad, bitbucket, // sourcehut, googlesource, pypi, crates, npm, rubygems, hackage, cpan, gnu, or "" when unknown. Forge string Kind Kind // Hint is a best-guess repo key for tarballs/registries (e.g. ftp.gnu.org/gnu/tar -> // git.savannah.gnu.org/tar). Empty when there is no reliable guess. Hint string // Ref is a branch/tag/commit selector found in the URL fragment (Arch: #tag=v1.0). Ref string}
// ErrNotRepo is returned by Parse when the URL is understood but is not a repository.var ErrNotRepo = errors.New("repourl: not a repository URL")
// ErrUnparseable is returned when the input does not look like a URL at all.var ErrUnparseable = errors.New("repourl: unparseable")
// cgitVerbs are the cgit browsing pages that follow a repository path.const cgitVerbs = `tree|log|commit|diff|tag|refs|snapshot|plain|about|stats|summary|shortlog|history|blob`
var ( schemeOnly = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9+.-]*:`) scpLike = regexp.MustCompile(`^(?:[\w.-]+@)?([\w.-]+\.[\w-]+):([^/].*)$`) vcsPrefix = regexp.MustCompile(`^(git|hg|svn|bzr|fossil)\+`) shaRe = regexp.MustCompile(`^[0-9a-f]{7,40}$`) sfDownloadRe = regexp.MustCompile(`^/(?:projects?|project)/([^/]+)`) pypiFileRe = regexp.MustCompile(`^([A-Za-z0-9][A-Za-z0-9._-]*?)-\d[\w.]*(?:\.tar\.gz|\.tar\.bz2|\.tar\.xz|\.zip|\.whl|\.tgz)$`) gnomeSrcRe = regexp.MustCompile(`^/sources/([^/]+)/`) githubRelRe = regexp.MustCompile(`^/([^/]+)/([^/]+)/(?:releases|archive|tarball|zipball|commit|commits|tree|blob|raw|pull|pulls|issues|compare|tags|tag|wiki|actions|blame|graphs|network|security|releases|downloads)(?:/|$)`) gitlabDashRe = regexp.MustCompile(`^(/.+?)/-/`) gitlabSuffixRe = regexp.MustCompile(`^(/.+?)/(?:tree|blob|raw|commits?|tags|merge_requests|issues|archive|compare|releases|wikis|pipelines|graphs|network)(?:/|$)`) giteaSuffixRe = regexp.MustCompile(`^(/[^/]+/[^/]+)/(?:src|commits?|releases|issues|pulls|compare|archive|raw|branch|tag|wiki|actions|activity|projects|settings)(?:/|$)`) cgitSuffixRe = regexp.MustCompile(`^(/.+?)/(?:` + cgitVerbs + `)(?:/|$)`) cgitVerbRe = regexp.MustCompile(`^/(?:` + cgitVerbs + `)(?:/|$)`) launchpadRe = regexp.MustCompile(`^/~?([^/]+)`) kernelOrgRe = regexp.MustCompile(`^/pub/scm/(.+)$`) crateDlRe = regexp.MustCompile(`^/api/v1/crates/([^/]+)/`) crateRe = regexp.MustCompile(`^/crates/([^/]+)`) pypiProjRe = regexp.MustCompile(`^/(?:project|simple|pypi)/([^/]+)`) npmRe = regexp.MustCompile(`^/(?:package/)?((?:@[^/]+/)?[^/]+)`) hackageRe = regexp.MustCompile(`^/package/([^/]+?)(?:-\d[\d.]*)?(?:/|$)`) rubygemsRe = regexp.MustCompile(`^/(?:gems|downloads)/([^/]+?)(?:-\d[\d.]*\.gem)?(?:/|$)`) cpanRe = regexp.MustCompile(`^/(?:authors/id/./../[^/]+|release|pod|dist)/([^/]+?)(?:-v?\d[\d._]*[^/]*)?(?:/|$)`) gnuFtpRe = regexp.MustCompile(`^/(?:gnu|non-gnu|pub/gnu)/([^/]+)`) savannahDlRe = regexp.MustCompile(`^/(?:download|releases|dl)/([^/]+)/`) savannahRe = regexp.MustCompile(`^/(?:git|cgit|r|projects|p)/([^/]+?)(?:\.git)?(?:/|$)`) xorgRe = regexp.MustCompile(`^/(?:releases/individual|archive/individual)/([^/]+)/([A-Za-z0-9._+-]+?)-\d`) kdeDlRe = regexp.MustCompile(`^/(?:stable|unstable)/[^/]+/(?:[^/]+/)*?(?:src/)?([A-Za-z0-9._+-]+?)-\d[\w.]*\.tar`) wwwPrefix = regexp.MustCompile(`^www\d*\.`) // genericVerbRe recognises, on a host repourl does not know, the browsing suffix every // common forge puts after <owner>/<repo>; its presence is evidence of a repository. genericVerbRe = regexp.MustCompile(`^(/[^/]+/[^/]+)/(?:pull|pulls|issues|issue|commit|commits|merge_requests|merge-requests|compare|tree|blob|src|raw|releases|tags|tag|branch|wiki|-)(?:/|$)`) // genericDashRe is GitLab's "/-/" separator at any depth (subgroups). genericDashRe = regexp.MustCompile(`^(/[^/]+/[^/]+(?:/[^/]+)*?)/-(?:/|$)`) // cgitMountRe is the leading mount point of a cgit/gitweb instance. cgitMountRe = regexp.MustCompile(`^/(cgit|browse|git|r|gitweb|index\.cgi)(?:/|$)`) // versionedNameRe splits "name-1.2.3..." into the name. versionedNameRe = regexp.MustCompile(`^(.+?)-v?\d`))
// gitlabHosts are known GitLab instances (subgroup-aware path handling).var gitlabHosts = map[string]bool{ "gitlab.com": true, "salsa.debian.org": true, "gitlab.gnome.org": true, "gitlab.freedesktop.org": true, "invent.kde.org": true, "gitlab.archlinux.org": true, "gitlab.alpinelinux.org": true, "gitlab.torproject.org": true, "gitlab.xfce.org": true, "gitlab.kitware.com": true, "gitlab.haskell.org": true, "gitlab.inria.fr": true, "gitlab.isc.org": true, "gitlab.labs.nic.cz": true, "gitlab.winehq.org": true, "gitlab.gnu.org": true, "code.videolan.org": true, "git.launchpad.net": false, "framagit.org": true, "0xacab.org": true, "gitlab.postmarketos.org": true, "gitlab.manjaro.org": true, "gitlab.mageia.org": true, "gitlab.suse.de": true, "forge.softwareheritage.org": false, "gitlab.opencode.de": true, "gitlab.matrix.org": true,}
// giteaHosts are known Gitea/Forgejo instances.var giteaHosts = map[string]bool{ "codeberg.org": true, "src.opensuse.org": true, "gitea.com": true, "forge.sourceware.org": true, "opendev.org": true, // OpenStack's forge; github.com/openstack mirrors it "git.sfconservancy.org": false, "gitea.osmocom.org": true, "git.savannah.gnu.org": false, "git.sr.ht": false, "notabug.org": true, "git.disroot.org": true, "code.forgejo.org": true, "git.jami.net": true, "git.ffmpeg.org": true, "git.enlightenment.org": true, // moved from cgit to Gitea in 2021}
// pagureHosts are known Pagure instances. code.opensuse.org's web pages and API sit behind a// proof-of-work check, but git over https is served normally.var pagureHosts = map[string]bool{ "code.opensuse.org": true, "pagure.io": true, "src.fedoraproject.org": true,}
// pagureVerbs are the path components after which a Pagure URL browses a repository.var pagureVerbs = map[string]bool{ "blob": true, "tree": true, "raw": true, "c": true, "commits": true, "commit": true, "pull-request": true, "pull-requests": true, "issue": true, "issues": true, "releases": true, "tags": true, "branches": true, "branch": true, "history": true, "blame": true, "stats": true, "roadmap": true, "forks": true, "diff": true, "boards": true, "settings": true, "archive": true,}
// pagureRoutes are top-level Pagure pages that are not repositories.var pagureRoutes = map[string]bool{ "api": true, "login": true, "logout": true, "user": true, "users": true, "group": true, "groups": true, "projects": true, "browse": true, "about": true, "new": true, "search": true, "static": true, "theme": true, "settings": true,}
// cgitHosts are known cgit/gitweb instances where the repo path is everything before a browsing// verb.var cgitHosts = map[string]bool{ "git.kernel.org": true, "git.savannah.gnu.org": true, "git.savannah.nongnu.org": true, "cgit.freedesktop.org": true, "git.zx2c4.com": true, "git.musl-libc.org": true, "git.busybox.net": true, "git.openembedded.org": true, "git.yoctoproject.org": true, "git.netfilter.org": true, "git.tuxfamily.org": true, "git.gnupg.org": true, "git.samba.org": true, "git.libssh.org": true, "git.lysator.liu.se": true, "git.dpkg.org": true, "git.ipfire.org": true, "git.buildroot.net": true, "git.qemu.org": true, "git.xiph.org": true, "git.videolan.org": true, "git.ghostscript.com": true, "git.sv.gnu.org": true, "git.suckless.org": true, "git.code.sf.net": false, "git.mirrors.kernel.org": true, "git.openssl.org": true, "cgit.kde.org": true, "git.gnome.org": true, "git.postgresql.org": true, "git.php.net": true, "git.pwmt.org": true, "git.madduck.net": true, "git.lighttpd.net": true, "git.lttng.org": true, "git.tukaani.org": true, "git.dovecot.net": false, "git.openldap.org": true, "git.taler.net": true, "git.gnunet.org": true, "git.zsh.org": true, "git.midnight-commander.org": true, "git.sourceware.org": true,}
// Parse canonicalizes raw. It never returns an empty Key for a non-error result.func Parse(raw string) (Canon, error) { // A URL cannot contain whitespace; packaging fields append branches and refs after it // ("https://git.sr.ht/~u/p refs/tags/v1", "URL -b main"). f := strings.Fields(raw) if len(f) == 0 { return Canon{}, ErrUnparseable } s := f[0] // Arch/pip style "name::url". if i := strings.Index(s, "::"); i >= 0 && !strings.Contains(s[:i], "/") && !strings.Contains(s[:i], ":") { s = s[i+2:] } // Strip "git+", "hg+" etc. prefixes (Arch PKGBUILD, pip VCS URLs). vcs := "" if m := vcsPrefix.FindStringSubmatch(s); m != nil { vcs = m[1] s = s[len(m[0]):] } // scp-like git@host:owner/repo.git if !strings.Contains(s, "://") { if m := scpLike.FindStringSubmatch(s); m != nil { s = "https://" + m[1] + "/" + m[2] } else if schemeOnly.MatchString(s) { return Canon{}, ErrUnparseable } else if strings.Contains(s, ".") { s = "https://" + s } else { return Canon{}, ErrUnparseable } } u, err := url.Parse(s) if err != nil || u.Host == "" { return Canon{}, ErrUnparseable } c := Canon{} switch u.Scheme { case "http", "https", "git", "ssh", "git+ssh", "ssh+git", "hg", "svn", "bzr", "ftp", "ftps", "hg+https", "svn+https": default: return Canon{}, ErrUnparseable } if u.Fragment != "" { c.Ref = refFromFragment(u.Fragment) } host := strings.ToLower(u.Hostname()) host = wwwPrefix.ReplaceAllString(host, "") if host == "" { return Canon{}, ErrUnparseable } p := path.Clean("/" + u.Path) if p == "/." { p = "/" } // Host aliases. switch host { case "codeload.github.com", "raw.githubusercontent.com", "objects.githubusercontent.com": host = "github.com" case "api.github.com": host = "github.com" p = strings.TrimPrefix(p, "/repos") case "git.sv.gnu.org", "git.savannah.nongnu.org", "savannah.gnu.org", "savannah.nongnu.org", "download.savannah.gnu.org", "download.savannah.nongnu.org": host = "git.savannah.gnu.org" case "web.git.kernel.org": host = "git.kernel.org" case "sf.net", "git.code.sf.net", "svn.code.sf.net", "hg.code.sf.net", "downloads.sourceforge.net", "download.sourceforge.net", "prdownloads.sourceforge.net", "sourceforge.io", "dl.sourceforge.net", "master.dl.sourceforge.net": host = "sourceforge.net" case "code.launchpad.net", "git.launchpad.net", "bazaar.launchpad.net": host = "launchpad.net" case "files.pythonhosted.org", "pypi.python.org", "pypi.io", "cheeseshop.python.org": host = "pypi.org" case "packages.python.org": // PyPI's old documentation hosting: packages.python.org/<project>/ is a PyPI project. if parts := splitPath(p); len(parts) > 0 { return finish(Canon{Host: "pypi.org", Path: normPyPI(parts[0]), Forge: "pypi", Kind: KindRegistry}) } case "static.crates.io": host = "crates.io" case "registry.npmjs.org", "npmjs.com", "npmjs.org": host = "npmjs.com" case "cpan.metacpan.org", "search.cpan.org", "cpan.org", "search.metacpan.org": host = "metacpan.org" case "ftpmirror.gnu.org", "ftp.gnu.org", "mirrors.kernel.org", "alpha.gnu.org", "gnu.org": // keep; handled below } if isCatalogue(host, p) { return finish(Canon{Host: host, Path: strings.TrimPrefix(p, "/"), Kind: KindOther}) } if strings.HasSuffix(host, ".sourceforge.net") || strings.HasSuffix(host, ".sf.net") || strings.HasSuffix(host, ".sourceforge.io") { // project.sourceforge.net -> sourceforge.net/project proj := strings.SplitN(host, ".", 2)[0] return finish(Canon{Host: "sourceforge.net", Path: proj, Forge: "sourceforge", Kind: KindRepo, Ref: c.Ref}) } c.Host = host _ = vcs switch { case host == "github.com": return parseGitHub(c, p) case host == "sourceforge.net": return parseSourceForge(c, p) case host == "git.savannah.gnu.org": if m := savannahRe.FindStringSubmatch(p); m != nil { return finish(Canon{Host: host, Path: m[1], Forge: "savannah", Kind: KindRepo, Ref: c.Ref}) } // savannah.gnu.org/download/<proj>/... and /releases/<proj>/... are tarball trees. if m := savannahDlRe.FindStringSubmatch(p); m != nil { return finish(Canon{Host: host, Path: strings.TrimPrefix(p, "/"), Forge: "savannah", Kind: KindTarball, Hint: "git.savannah.gnu.org/" + m[1]}) } // Tracker pages name their project in ?group= (savannah.gnu.org/bugs/?group=aldo). if parts := splitPath(p); len(parts) >= 1 && savannahTools[parts[0]] { if g := queryParam(u, "group"); g != "" { return finish(Canon{Host: host, Path: g, Forge: "savannah", Kind: KindRepo, Ref: c.Ref}) } return finish(Canon{Host: host, Path: strings.TrimPrefix(p, "/"), Forge: "savannah", Kind: KindOther}) } // A bare project name (how keys are spelled) or a .git path is the repository. if parts := splitPath(p); len(parts) == 1 || strings.HasSuffix(p, ".git") { return finish(Canon{Host: host, Path: trimRepoPath(p), Forge: "savannah", Kind: KindRepo, Ref: c.Ref}) } return finish(Canon{Host: host, Path: strings.TrimPrefix(p, "/"), Forge: "savannah", Kind: KindOther}) case host == "git.kernel.org" || host == "git.mirrors.kernel.org": // cgit also serves every repository under /cgit/ (git.kernel.org/cgit/linux/kernel/...). if rest, ok := strings.CutPrefix(p, "/cgit/"); ok { p = "/pub/scm/" + rest } if m := kernelOrgRe.FindStringSubmatch(p); m != nil { return finish(Canon{Host: "git.kernel.org", Path: "pub/scm/" + trimRepoPath(cgitRepoPath("/"+m[1])), Forge: "cgit", Kind: KindRepo, Ref: c.Ref}) } if strings.HasPrefix(p, "/pub/linux/") || strings.HasPrefix(p, "/pub/software/") { return finish(Canon{Host: "kernel.org", Path: strings.TrimPrefix(p, "/"), Forge: "", Kind: KindTarball}) } case host == "launchpad.net": parts := splitPath(p) if len(parts) >= 1 { name := parts[0] // ~owner/project/+git/repo : personal branch of a project -> key by project. if strings.HasPrefix(name, "~") && len(parts) >= 2 { name = parts[1] } if strings.HasPrefix(name, "+") && len(parts) >= 2 { // /+git/foo name = parts[1] } return finish(Canon{Host: host, Path: name, Forge: "launchpad", Kind: KindRepo, Ref: c.Ref}) } case host == "bitbucket.org": return finishOwnerRepo(c, p, "bitbucket", giteaSuffixRe) case host == "git.sr.ht" || host == "sr.ht" || host == "hg.sr.ht": parts := splitPath(p) if len(parts) >= 2 { return finish(Canon{Host: "git.sr.ht", Path: parts[0] + "/" + parts[1], Forge: "sourcehut", Kind: KindRepo, Ref: c.Ref}) } case strings.HasSuffix(host, ".googlesource.com"): rp := strings.TrimPrefix(p, "/") if i := strings.Index(rp, "/+/"); i >= 0 { rp = rp[:i] } return finish(Canon{Host: host, Path: trimRepoPath("/" + rp), Forge: "googlesource", Kind: KindRepo, Ref: c.Ref}) case host == "pypi.org": if m := pypiProjRe.FindStringSubmatch(p); m != nil { return finish(Canon{Host: host, Path: normPyPI(m[1]), Forge: "pypi", Kind: KindRegistry}) } base := path.Base(p) if m := pypiFileRe.FindStringSubmatch(base); m != nil { return finish(Canon{Host: host, Path: normPyPI(m[1]), Forge: "pypi", Kind: KindTarball, Hint: "pypi.org/" + normPyPI(m[1])}) } case host == "crates.io": if m := crateDlRe.FindStringSubmatch(p); m != nil { return finish(Canon{Host: host, Path: m[1], Forge: "crates", Kind: KindTarball, Hint: "crates.io/" + m[1]}) } if m := crateRe.FindStringSubmatch(p); m != nil { return finish(Canon{Host: host, Path: m[1], Forge: "crates", Kind: KindRegistry}) } case host == "npmjs.com": if m := npmRe.FindStringSubmatch(p); m != nil { return finish(Canon{Host: host, Path: m[1], Forge: "npm", Kind: KindRegistry}) } case host == "hackage.haskell.org": if m := hackageRe.FindStringSubmatch(p); m != nil { return finish(Canon{Host: host, Path: m[1], Forge: "hackage", Kind: KindRegistry}) } case host == "rubygems.org": if m := rubygemsRe.FindStringSubmatch(p); m != nil { return finish(Canon{Host: host, Path: m[1], Forge: "rubygems", Kind: KindRegistry}) } case host == "metacpan.org": if m := cpanRe.FindStringSubmatch(p); m != nil { return finish(Canon{Host: host, Path: m[1], Forge: "cpan", Kind: KindRegistry}) } case host == "ftp.gnu.org" || host == "ftpmirror.gnu.org" || host == "alpha.gnu.org" || host == "gnu.org": if m := gnuFtpRe.FindStringSubmatch(p); m != nil { proj := m[1] // A tarball at the top of /gnu/ (gnu/dejagnu-1.6.3.tar.gz) names its project before // the version. if isArchive(proj) { if v := versionedNameRe.FindStringSubmatch(proj); v != nil { proj = v[1] } } return finish(Canon{Host: host, Path: strings.TrimPrefix(p, "/"), Forge: "gnu", Kind: KindTarball, Hint: "git.savannah.gnu.org/" + proj}) } case host == "download.gnome.org" || host == "ftp.gnome.org": if m := gnomeSrcRe.FindStringSubmatch(strings.TrimPrefix(p, "/pub/GNOME")); m != nil { return finish(Canon{Host: host, Path: strings.TrimPrefix(p, "/"), Kind: KindTarball, Hint: "gitlab.gnome.org/gnome/" + strings.ToLower(m[1])}) } case host == "download.kde.org" || host == "ftp.kde.org": if m := kdeDlRe.FindStringSubmatch(p); m != nil { return finish(Canon{Host: host, Path: strings.TrimPrefix(p, "/"), Kind: KindTarball, Hint: "invent.kde.org/" + strings.ToLower(m[1])}) } case host == "apps.kde.org" || (host == "kde.org" || host == "www.kde.org") && strings.HasPrefix(p, "/applications/"): // apps.kde.org/[<lang>/]<app>/ and kde.org/applications/<category>/org.kde.<app> name the // application; its repository group comes from KDE's project list (bulk canonicalize). if segs := splitPath(p); len(segs) > 0 && segs[0] != "categories" { app := strings.TrimPrefix(strings.ToLower(segs[len(segs)-1]), "org.kde.") if app != "" && app != "applications" { return finish(Canon{Host: host, Path: strings.TrimPrefix(p, "/"), Kind: KindOther, Hint: "invent.kde.org/" + app}) } } case host == "xorg.freedesktop.org" || host == "x.org": if m := xorgRe.FindStringSubmatch(p); m != nil { return finish(Canon{Host: host, Path: strings.TrimPrefix(p, "/"), Kind: KindTarball, Hint: "gitlab.freedesktop.org/xorg/" + strings.ToLower(m[1]) + "/" + strings.ToLower(m[2])}) } case host == "sourceware.org": // https://sourceware.org/git/glibc.git , /git/?p=glibc.git , /pub/glibc/releases/... if strings.HasPrefix(p, "/git/") || p == "/git" { name := strings.TrimPrefix(strings.TrimPrefix(p, "/git"), "/") if name == "" { name = queryParam(u, "p") } name = strings.TrimSuffix(cgitRepoPath("/"+name), ".git") name = strings.Trim(name, "/") if name != "" { return finish(Canon{Host: host, Path: name, Forge: "cgit", Kind: KindRepo, Ref: c.Ref}) } } if strings.HasPrefix(p, "/pub/") { parts := splitPath(p) if len(parts) >= 2 { return finish(Canon{Host: host, Path: strings.TrimPrefix(p, "/"), Kind: KindTarball, Hint: "sourceware.org/" + parts[1]}) } } } if gitlabHosts[host] { return parseGitLab(c, p) } if giteaHosts[host] { return finishOwnerRepo(c, p, "gitea", giteaSuffixRe) } if pagureHosts[host] { return parsePagure(c, p) } if cgitHosts[host] { return parseCgit(c, p, u) } // gitweb style ?p=foo.git if q := queryParam(u, "p"); q != "" && strings.HasSuffix(q, ".git") { return finish(Canon{Host: host, Path: strings.TrimSuffix(q, ".git"), Forge: "gitweb", Kind: KindRepo, Ref: c.Ref}) } // Unknown host, cgit-like path: a leading mount (/cgit/, /git/, ...) or a ".git/" component // followed by a browsing verb marks a repository. if rp, forge, ok := genericCgitPath(p); ok { return finish(Canon{Host: host, Path: rp, Forge: forge, Kind: KindRepo, Ref: c.Ref}) } // Generic: anything ending in .git or served over git/ssh scheme is a repo. if strings.HasSuffix(p, ".git") || u.Scheme == "git" || u.Scheme == "ssh" || u.Scheme == "git+ssh" || vcs == "git" { return finish(Canon{Host: host, Path: trimRepoPath(p), Kind: KindRepo, Ref: c.Ref}) } if vcs == "hg" || vcs == "svn" || vcs == "bzr" || u.Scheme == "hg" || u.Scheme == "svn" || u.Scheme == "bzr" { return finish(Canon{Host: host, Path: trimRepoPath(p), Forge: vcs, Kind: KindRepo, Ref: c.Ref}) } // Generic tarball detection. if isArchive(p) { return finish(Canon{Host: host, Path: strings.TrimPrefix(p, "/"), Kind: KindTarball}) } // Unknown host, forge-like path: a browsing verb after <owner>/<repo> (/pulls/3, // /-/merge_requests/1, /commit/abc, ...) is evidence of a repository. A bare // <owner>/<repo> stays KindOther: it is just as often a web page. if rp, ok := stripForgeVerb(p); ok { return finish(Canon{Host: host, Path: rp, Kind: KindRepo, Ref: c.Ref}) } // A generic hostname with a path: unknown website. return finish(Canon{Host: host, Path: strings.TrimPrefix(p, "/"), Kind: KindOther})}
// stripForgeVerb cuts a forge browsing suffix off p when p has the shape// /<owner>/<repo>/<verb>/... (or GitLab's /<group>/.../<repo>/-/...). It reports whether a verb// was found.func stripForgeVerb(p string) (string, bool) { if m := genericDashRe.FindStringSubmatch(p); m != nil { return m[1], true } // Redmine and Trac project pages (/projects/<name>/issues) are trackers, not repositories. if m := genericVerbRe.FindStringSubmatch(p); m != nil && !strings.HasPrefix(m[1], "/projects/") { return m[1], true } return "", false}
// savannahTools are Savannah's per-project tracker and page prefixes.var savannahTools = map[string]bool{ "bugs": true, "support": true, "task": true, "patch": true, "news": true, "mail": true, "file": true, "search": true, "cookbook": true, "people": true, "account": true, "my": true, // Version-control pages with ?group= (savannah.gnu.org/cvs/?group=gnubg). "cvs": true, "git": true, "hg": true, "svn": true, "bzr": true, "arch": true,}
// stripMount removes a leading cgit/gitweb mount point (/cgit/, /browse/, /git/, /r/,// /gitweb/) from p and returns the remainder and the mount name ("" when there was none).func stripMount(p string) (rest, mount string) { m := cgitMountRe.FindStringSubmatch(p) if m == nil { return p, "" } rest = p[len(m[0]):] if rest == "" { return "/", m[1] } return "/" + rest, m[1]}
// genericCgitPath recognises cgit/gitweb repository paths on hosts repourl does not know:// a leading mount point, or a ".git/" component with more path after it// (/cgit/qt/qtbase.git/commit/?id=x -> qt/qtbase). Forge is "cgit" when a mount or a cgit// verb was seen and "" for a bare foo.git/... path.func genericCgitPath(p string) (repo, forge string, ok bool) { rest, mount := stripMount(p) if i := strings.Index(rest, ".git/"); i >= 0 { forge = "" if mount != "" || cgitVerbRe.MatchString(rest[i+4:]) { forge = "cgit" } return trimRepoPath(rest[:i]), forge, true } if mount == "" { return "", "", false } rest = cgitRepoPath(rest) if strings.Trim(rest, "/") == "" { return "", "", false } return trimRepoPath(rest), "cgit", true}
// knownHomepages maps project websites that distros name as "URL"/"Homepage" to the// project's repository key. Only unambiguous, long-lived projects belong here; the resolver// treats the resulting candidate as a hint (weight scaled down), not as direct evidence.var knownHomepages = map[string]string{ "curl.se": "github.com/curl/curl", "curl.haxx.se": "github.com/curl/curl", "zlib.net": "github.com/madler/zlib", "python.org": "github.com/python/cpython", "docs.python.org": "github.com/python/cpython", "musl.libc.org": "git.musl-libc.org/musl", "doxygen.nl": "github.com/doxygen/doxygen", "doxygen.org": "github.com/doxygen/doxygen", "openssl.org": "github.com/openssl/openssl", "openssh.com": "github.com/openssh/openssh-portable", "gnupg.org": "git.gnupg.org/gnupg", "gnutls.org": "gitlab.com/gnutls/gnutls", "sqlite.org": "github.com/sqlite/sqlite", "libpng.org": "github.com/pnggroup/libpng", "ijg.org": "github.com/libjpeg-turbo/libjpeg-turbo", "libjpeg-turbo.org": "github.com/libjpeg-turbo/libjpeg-turbo", "pcre.org": "github.com/pcre2project/pcre2", "tukaani.org": "github.com/tukaani-project/xz", "xz.tukaani.org": "github.com/tukaani-project/xz", "sourceware.org": "", "kernel.org": "git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux", "cmake.org": "gitlab.kitware.com/cmake/cmake", "mesonbuild.com": "github.com/mesonbuild/meson", "ninja-build.org": "github.com/ninja-build/ninja", "nginx.org": "github.com/nginx/nginx", "apache.org": "", "httpd.apache.org": "github.com/apache/httpd", "apr.apache.org": "github.com/apache/apr", "ffmpeg.org": "github.com/ffmpeg/ffmpeg", "libav.org": "github.com/libav/libav", "vim.org": "github.com/vim/vim", "neovim.io": "github.com/neovim/neovim", "gnu.org": "", "git-scm.com": "github.com/git/git", "rsync.samba.org": "github.com/rsyncproject/rsync", "samba.org": "git.samba.org/samba", "postgresql.org": "git.postgresql.org/postgresql", "mariadb.org": "github.com/mariadb/server", "redis.io": "github.com/redis/redis", "memcached.org": "github.com/memcached/memcached", "sphinx-doc.org": "github.com/sphinx-doc/sphinx", "matplotlib.org": "github.com/matplotlib/matplotlib", "numpy.org": "github.com/numpy/numpy", "scipy.org": "github.com/scipy/scipy", "pandas.pydata.org": "github.com/pandas-dev/pandas", "palletsprojects.com": "", "flask.palletsprojects.com": "github.com/pallets/flask", "jinja.palletsprojects.com": "github.com/pallets/jinja", "requests.readthedocs.io": "github.com/psf/requests", "rust-lang.org": "github.com/rust-lang/rust", "golang.org": "github.com/golang/go", "go.dev": "github.com/golang/go", "nodejs.org": "github.com/nodejs/node", "ruby-lang.org": "github.com/ruby/ruby", "perl.org": "github.com/perl/perl5", "php.net": "github.com/php/php-src", "lua.org": "github.com/lua/lua", "tcl.tk": "github.com/tcltk/tcl", "gcc.gnu.org": "gcc.gnu.org/git/gcc", "llvm.org": "github.com/llvm/llvm-project", "clang.llvm.org": "github.com/llvm/llvm-project", "boost.org": "github.com/boostorg/boost", "qt.io": "code.qt.io/qt/qtbase", "gtk.org": "gitlab.gnome.org/gnome/gtk", "gnome.org": "", "kde.org": "", "xfce.org": "", "freedesktop.org": "", "x.org": "", "mesa3d.org": "gitlab.freedesktop.org/mesa/mesa", "wayland.freedesktop.org": "gitlab.freedesktop.org/wayland/wayland", "pulseaudio.org": "gitlab.freedesktop.org/pulseaudio/pulseaudio", "pipewire.org": "gitlab.freedesktop.org/pipewire/pipewire", "systemd.io": "github.com/systemd/systemd", "busybox.net": "git.busybox.net/busybox", "openldap.org": "git.openldap.org/openldap/openldap", "bind9.net": "gitlab.isc.org/isc-projects/bind9", "isc.org": "", "strace.io": "github.com/strace/strace", "valgrind.org": "sourceware.org/valgrind", "gdb.org": "sourceware.org/binutils-gdb", "7-zip.org": "", "info-zip.org": "", "libarchive.org": "github.com/libarchive/libarchive", "zstd.net": "github.com/facebook/zstd", "lz4.org": "github.com/lz4/lz4", "bzip.org": "gitlab.com/bzip2/bzip2", "sourceforge.net": "", "savannah.gnu.org": "", "savannah.nongnu.org": "",}
// HomepageHint returns the repository key a well-known project homepage stands for, or "".func HomepageHint(raw string) string { c, err := Parse(raw) if err != nil { return "" } return c.Hint}
// Key is a convenience returning only the key or "" on error.func Key(raw string) string { c, err := Parse(raw) if err != nil { return "" } return c.Key}
// RepoKey returns the key only if raw is a repository URL.func RepoKey(raw string) (string, error) { c, err := Parse(raw) if err != nil { return "", err } if c.Kind != KindRepo { return "", ErrNotRepo } return c.Key, nil}
// SameRepo reports whether two URLs canonicalize to the same repository key.func SameRepo(a, b string) bool { ka, ea := RepoKey(a) kb, eb := RepoKey(b) return ea == nil && eb == nil && ka == kb}
// hostsManyProjects reports paths of a well-known site that belong to other projects:// - kernel.org serves the kernel (pub/linux/kernel/v6.x/...), but /pub/ also holds// nfs-utils, util-linux and developers' trees under pub/linux/kernel/people/, and project// sites live on subdomains (iwd.wiki.kernel.org, b4.docs.kernel.org);// - samba.org serves Samba, but also rsync, ppp, cifs-utils and the cwrap libraries under// /pub/ and /ftp/, and other projects on subdomains (dbench.samba.org, rzip.samba.org).func hostsManyProjects(c Canon) bool { p := strings.TrimPrefix(c.Path, "/") switch { case c.Host == "kernel.org" || strings.HasSuffix(c.Host, ".kernel.org"): switch strings.TrimSuffix(strings.TrimSuffix(c.Host, "kernel.org"), ".") { case "", "www", "cdn", "mirrors", "mirrors.edge": return strings.HasPrefix(p, "pub/") && !kernelReleaseRe.MatchString(p) } return true case isCatalogue(c.Host, "/"+p): return true case c.Host == "python.org" || strings.HasSuffix(c.Host, ".python.org"): // python.org is CPython's site, but its subdomains also serve PyPy's releases // (downloads.python.org/pypy), wikis and mailing lists of other projects. switch strings.TrimSuffix(strings.TrimSuffix(c.Host, "python.org"), ".") { case "", "docs", "bugs", "devguide": return false } return true case c.Host == "gnupg.org": // gnupg.org/software/<project>/ and gnupg.org/ftp/gcrypt/<project>/ name GnuPG's sibling // projects (gpgme, libgcrypt, gpa, ...), each with its own repository (a host rule maps // them); only GnuPG itself stands for git.gnupg.org/gnupg. parts := strings.Split(p, "/") switch { case len(parts) >= 2 && parts[0] == "software": return parts[1] != "gnupg" && parts[1] != "index.html" case len(parts) >= 3 && parts[0] == "ftp" && parts[1] == "gcrypt": return parts[2] != "gnupg" } return false case c.Host == "samba.org" || strings.HasSuffix(c.Host, ".samba.org"): switch sub := strings.TrimSuffix(strings.TrimSuffix(c.Host, "samba.org"), "."); sub { case "", "www", "download", "ftp": default: _, known := knownHomepages[c.Host] return !known && !sambaTree[sub] } parts := strings.Split(p, "/") if parts[0] == "pub" || parts[0] == "ftp" { return len(parts) < 2 || !sambaTree[parts[1]] } return parts[0] != "" && parts[0] != "samba" } return false}
// catalogues are sites that list many projects, one page each: a page there names no// repository, and taking the site's own repository for it would join unrelated packages.var catalogues = []struct{ host, prefix string }{ {"octave.sourceforge.net", "/"}, {"octave.sourceforge.io", "/"}, // Octave Forge // Octave Forge's SourceForge project, which holds every package's releases. // (its /p/octave/<package> repositories are each package's own). {"sourceforge.net", "/octave/"}, {"sourceforge.net", "/projects/octave/"}, {"sourceforge.net", "/project/octave/"}, {"sourceforge.net", "/sourceforge/octave/"}, {"gnu-octave.github.io", "/packages/"}, // the Octave package index {"pear.php.net", "/"}, // PEAR {"homalg-project.github.io", "/pkg/"}, // homalg's GAP packages}
func isCatalogue(host, p string) bool { for _, c := range catalogues { if host == c.host && strings.HasPrefix(p+"/", c.prefix) { return true } } return false}
// IsCatalogue reports whether raw is a page of a catalogue site (Octave Forge, PEAR, ...),// which names no repository of its own.func IsCatalogue(raw string) bool { u, err := url.Parse(strings.TrimSpace(raw)) if err != nil { return false } return isCatalogue(wwwPrefix.ReplaceAllString(strings.ToLower(u.Hostname()), ""), path.Clean("/"+u.Path))}
// sambaTree names the projects developed in Samba's own repository.var sambaTree = map[string]bool{"samba": true, "talloc": true, "tdb": true, "tevent": true, "ldb": true}
var ( kernelReleaseRe = regexp.MustCompile(`^pub/linux/kernel/v\d`) gitlabProjectIDRe = regexp.MustCompile(`^/(?:api/v4/projects|-/project)/(\d+)(?:/|$)`) gitlabAPIPathRe = regexp.MustCompile(`^/api/v4/projects/([^/]+(?:/[^/]+)+?)/(?:packages|repository|uploads|releases|jobs)/`))
func finish(c Canon) (Canon, error) { c.Path = strings.Trim(c.Path, "/") if c.Path == "" && c.Kind != KindOther { return Canon{}, ErrUnparseable } if c.Kind != KindRepo && c.Hint == "" && !hostsManyProjects(c) { // A well-known project website stands for its repository. host := c.Host for host != "" { if repo, ok := knownHomepages[host]; ok { if repo != "" { c.Hint = repo } break } i := strings.IndexByte(host, '.') if i < 0 { break } host = host[i+1:] } } if c.Kind == KindRepo { c.Path = strings.TrimSuffix(c.Path, ".git") // Pagure paths are case-sensitive (pagure.io/PAGURE does not exist), so their case is // part of the identity; every other forge we know folds case. if c.Forge != "pagure" { c.Path = strings.ToLower(c.Path) } } c.Key = c.Host if c.Path != "" { c.Key += "/" + c.Path } switch c.Kind { case KindRepo: c.URL = "https://" + c.Key if c.Forge == "sourceforge" { c.URL = "https://git.code.sf.net/p/" + c.Path + "/code" } if c.Forge == "savannah" { c.URL = "https://git.savannah.gnu.org/git/" + c.Path + ".git" } if c.Forge == "launchpad" { c.URL = "https://git.launchpad.net/" + c.Path } if c.Forge == "gitweb" { c.URL = "https://" + c.Host + "/?p=" + c.Path + ".git" } default: c.URL = "https://" + c.Key } return c, nil}
func finishOwnerRepo(c Canon, p, forge string, suffix *regexp.Regexp) (Canon, error) { if m := suffix.FindStringSubmatch(p); m != nil { p = m[1] } parts := splitPath(p) if len(parts) < 2 { if len(parts) == 1 { return finish(Canon{Host: c.Host, Path: parts[0], Forge: forge, Kind: KindOther}) } return Canon{}, ErrUnparseable } return finish(Canon{Host: c.Host, Path: parts[0] + "/" + parts[1], Forge: forge, Kind: KindRepo, Ref: c.Ref})}
func parseGitHub(c Canon, p string) (Canon, error) { parts := splitPath(p) if len(parts) == 0 { return Canon{}, ErrUnparseable } if len(parts) == 1 { return finish(Canon{Host: "github.com", Path: parts[0], Forge: "github", Kind: KindOther}) } owner, repo := parts[0], parts[1] if owner == "orgs" || owner == "users" || owner == "settings" || owner == "search" || owner == "topics" || owner == "sponsors" { return finish(Canon{Host: "github.com", Path: strings.TrimPrefix(p, "/"), Forge: "github", Kind: KindOther}) } kind := KindRepo // github.com/owner/repo/archive/v1.tar.gz and releases/download are tarballs from that repo. if m := githubRelRe.FindStringSubmatch(p); m != nil { verb := strings.Split(strings.TrimPrefix(p, "/"+owner+"/"+repo+"/"), "/")[0] switch verb { case "archive", "releases", "tarball", "zipball": if strings.Contains(p, "/releases/download/") || verb != "releases" { out, err := finish(Canon{Host: "github.com", Path: owner + "/" + repo, Forge: "github", Kind: KindTarball, Ref: c.Ref}) if err != nil { return out, err } out.Hint = out.Key out.URL = "https://github.com/" + strings.ToLower(owner+"/"+repo) return out, nil } } } // codeload: /owner/repo/tar.gz/v1.2 if len(parts) >= 3 && (parts[2] == "tar.gz" || parts[2] == "zip" || parts[2] == "legacy.tar.gz") { out, err := finish(Canon{Host: "github.com", Path: owner + "/" + repo, Forge: "github", Kind: KindTarball}) if err == nil { out.Hint = out.Key } return out, err } return finish(Canon{Host: "github.com", Path: owner + "/" + repo, Forge: "github", Kind: kind, Ref: c.Ref})}
func parseGitLab(c Canon, p string) (Canon, error) { // API and upload URLs name the project by number (/api/v4/projects/123/repository/archive, // /-/project/123/uploads/...). The key keeps the number; the GitLab API resolves it to the // project path (bulk canonicalize). if m := gitlabProjectIDRe.FindStringSubmatch(p); m != nil { return finish(Canon{Host: c.Host, Path: "-/project/" + m[1], Forge: "gitlab", Kind: KindRepo, Ref: c.Ref}) } // The same routes can name the project by its (URL-encoded) path instead: // /api/v4/projects/orcus%2Forcus/packages/generic/source/0.21.0/liborcus-0.21.0.tar.xz is a // release file of gitlab.com/orcus/orcus. if m := gitlabAPIPathRe.FindStringSubmatch(p); m != nil { out, err := finish(Canon{Host: c.Host, Path: m[1], Forge: "gitlab", Kind: KindTarball}) if err == nil { out.Hint = c.Host + "/" + strings.ToLower(strings.Trim(m[1], "/")) } return out, err } if m := gitlabDashRe.FindStringSubmatch(p); m != nil { p = m[1] } else if m := gitlabSuffixRe.FindStringSubmatch(p); m != nil { p = m[1] } parts := splitPath(p) if len(parts) < 2 { if len(parts) == 1 { return finish(Canon{Host: c.Host, Path: parts[0], Forge: "gitlab", Kind: KindOther}) } return Canon{}, ErrUnparseable } // Strip trailing "/-" remnants and .git return finish(Canon{Host: c.Host, Path: strings.Join(parts, "/"), Forge: "gitlab", Kind: KindRepo, Ref: c.Ref})}
// parsePagure handles Pagure paths: <repo>, <namespace>/<repo>, fork/<user>/[<namespace>/]<repo>,// each optionally followed by a browsing verb (/blob/<ref>/f/<file>, /c/<sha>,// /pull-request/<n>, ...).func parsePagure(c Canon, p string) (Canon, error) { parts := splitPath(strings.TrimSuffix(strings.TrimSuffix(p, "/"), ".git")) if len(parts) == 0 { return Canon{}, ErrUnparseable } if pagureRoutes[parts[0]] { return finish(Canon{Host: c.Host, Path: strings.Join(parts, "/"), Forge: "pagure", Kind: KindOther}) } start := 0 if parts[0] == "fork" { if len(parts) < 3 { return finish(Canon{Host: c.Host, Path: strings.Join(parts, "/"), Forge: "pagure", Kind: KindOther}) } start = 2 } end := len(parts) for i := start; i < len(parts); i++ { if pagureVerbs[parts[i]] && i > start { end = i break } } if end-start > 2 { end = start + 2 } return finish(Canon{Host: c.Host, Path: strings.Join(parts[:end], "/"), Forge: "pagure", Kind: KindRepo, Ref: c.Ref})}
// cgitRepoPath strips cgit browsing verbs: /cgit/foo.git/tree/bar -> /cgit/foo.git ;// /foo/log/ -> /foo.func cgitRepoPath(p string) string { if i := strings.Index(p, ".git/"); i >= 0 { return p[:i+4] } if m := cgitSuffixRe.FindStringSubmatch(p); m != nil { return m[1] } return p}
// queryParam reads a query parameter tolerating ';' separators (gitweb style ?p=x.git;a=summary).func queryParam(u *url.URL, key string) string { for _, part := range strings.FieldsFunc(u.RawQuery, func(r rune) bool { return r == '&' || r == ';' }) { k, v, _ := strings.Cut(part, "=") if k == key { if dv, err := url.QueryUnescape(v); err == nil { return dv } return v } } return ""}
func parseCgit(c Canon, p string, u *url.URL) (Canon, error) { rp, _ := stripMount(p) rp = cgitRepoPath(rp) if q := queryParam(u, "p"); q != "" && strings.Trim(rp, "/") == "" { rp = "/" + q } return finish(Canon{Host: c.Host, Path: trimRepoPath(rp), Forge: "cgit", Kind: KindRepo, Ref: c.Ref})}
func parseSourceForge(c Canon, p string) (Canon, error) { // /projects/NAME/..., /p/NAME/code, /project/NAME/... (downloads) parts := splitPath(p) // An umbrella project keeps each package in its own repository under /p/PROJECT/REPO // (Octave Forge under /p/octave/); elsewhere the project is the key, as distributions reach // it through its downloads as often as through a repository page. if len(parts) >= 3 && parts[0] == "p" && sfUmbrellas[strings.ToLower(parts[1])] && !sfToolPages[strings.ToLower(parts[2])] && !strings.EqualFold(parts[2], parts[1]) { return finish(Canon{Host: "sourceforge.net", Path: parts[1] + "/" + parts[2], Forge: "sourceforge", Kind: KindRepo, Ref: c.Ref}) } if len(parts) >= 2 && (parts[0] == "projects" || parts[0] == "project" || parts[0] == "p") { return finish(Canon{Host: "sourceforge.net", Path: parts[1], Forge: "sourceforge", Kind: KindRepo, Ref: c.Ref}) } if m := sfDownloadRe.FindStringSubmatch(p); m != nil { return finish(Canon{Host: "sourceforge.net", Path: m[1], Forge: "sourceforge", Kind: KindRepo, Ref: c.Ref}) } // downloads.sourceforge.net/NAME/file.tar.gz (old style), also as /sourceforge/NAME/... if len(parts) >= 2 && parts[0] == "sourceforge" { parts = parts[1:] } if len(parts) >= 1 && !strings.Contains(parts[0], ".") { return finish(Canon{Host: "sourceforge.net", Path: parts[0], Forge: "sourceforge", Kind: KindRepo, Ref: c.Ref}) } return Canon{}, ErrUnparseable}
// sfUmbrellas are SourceForge projects that host many packages, each in its own repository.var sfUmbrellas = map[string]bool{"octave": true}
// sfToolPages are the SourceForge project pages and default repository names under /p/PROJECT/.var sfToolPages = map[string]bool{ "code": true, "git": true, "svn": true, "hg": true, "mercurial": true, "cvs": true, "bzr": true, "home": true, "wiki": true, "tickets": true, "bugs": true, "feature-requests": true, "patches": true, "support-requests": true, "discussion": true, "news": true, "blog": true, "mailman": true, "activity": true, "files": true, "donate": true, "support": true, "reviews": true,}
func splitPath(p string) []string { var out []string for _, s := range strings.Split(strings.Trim(p, "/"), "/") { if s != "" && s != "." { out = append(out, s) } } return out}
// trimRepoPath strips a leading slash and a trailing .git.func trimRepoPath(p string) string { p = strings.Trim(p, "/") p = strings.TrimSuffix(p, "/") return strings.TrimSuffix(p, ".git")}
func isArchive(p string) bool { for _, ext := range []string{".tar.gz", ".tgz", ".tar.bz2", ".tbz2", ".tar.xz", ".txz", ".tar.zst", ".tar.lz", ".tar", ".zip", ".crate", ".gem", ".whl", ".jar", ".7z", ".tar.lzma"} { if strings.HasSuffix(strings.ToLower(p), ext) { return true } } return false}
func normPyPI(name string) string { // PEP 503 normalization. name = strings.ToLower(name) name = regexp.MustCompile(`[-_.]+`).ReplaceAllString(name, "-") return name}
// refFromFragment extracts a ref from Arch-style fragments: "tag=v1.2", "commit=abc", "branch=x".func refFromFragment(f string) string { for _, kv := range strings.Split(f, "&") { k, v, ok := strings.Cut(kv, "=") if !ok { continue } switch k { case "tag", "commit", "branch", "revision": // Arch allows "?signed" after the fragment: "#tag=v1?signed". if i := strings.IndexByte(v, '?'); i >= 0 { v = v[:i] } return v } } return ""}
// IsSHA reports whether s looks like an abbreviated or full git object id.func IsSHA(s string) bool { return shaRe.MatchString(strings.ToLower(s)) }
// String implements fmt.Stringer.func (c Canon) String() string { return fmt.Sprintf("%s (%s, %s)", c.Key, c.Kind, c.Forge) }