internal/backends/derive.go
Rama main · réplica de lectura del repositorio original.
package backends
import (
"fmt"
"regexp"
"strings"
)
// Asset-pattern derivation for user-tracked repos.
//
// The built-in catalog carries hand-written asset regexes because we ship one
// table for every user. A user tracking their own repo gets the same machinery
// without ever seeing a regex: they pick the asset they want out of the newest
// release, and DeriveUnique turns that one example into a pattern that will
// still match the equivalent asset in next week's build.
//
// The rule is simply "version-ish tokens vary, everything else identifies the
// build":
//
// llama-b1234-bin-win-vulkan-x64.zip -> ^llama-.*-bin-win-vulkan-x64\.zip$
//
// Two properties matter more than cleverness here, because the result decides
// which binary gets downloaded and executed:
//
// - It must not match a *different* flavour. DeriveUnique verifies the
// derived pattern against the whole release and tightens it until it
// matches exactly the asset that was picked.
// - When it cannot be made safe, it degrades to the literal file name, which
// fails closed on the next release ("assets changed, re-pick") rather than
// silently installing the wrong build.
// repoRe bounds an "owner/name" GitHub repo reference.
//
// This is a security control, not a nicety: ghClient.Releases interpolates the
// repo straight into an api.github.com URL path, so a user-supplied "../..",
// "@evil.example", scheme or query would re-target the request at whatever the
// attacker likes and validAssetURL would then happily accept the assets it
// named. Everything outside this character set is rejected before it can reach
// a URL.
var repoRe = regexp.MustCompile(`^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$`)
// ValidateRepo checks an "owner/name" reference, rejecting anything that could
// escape the API URL path.
func ValidateRepo(repo string) error {
repo = strings.TrimSpace(repo)
if repo == "" {
return fmt.Errorf("repository is required")
}
if !repoRe.MatchString(repo) {
return fmt.Errorf("%q is not an owner/name GitHub repository", repo)
}
// "." and ".." are valid path segments to the regex above but not valid repo
// names, and they are exactly the ones that would traverse the API path.
for _, seg := range strings.Split(repo, "/") {
if seg == "." || seg == ".." {
return fmt.Errorf("%q is not an owner/name GitHub repository", repo)
}
}
return nil
}
// ParseRepo accepts either "owner/name" or a github.com URL and returns the
// validated "owner/name" form. The UI lets the user paste whatever they have in
// the address bar.
func ParseRepo(in string) (string, error) {
s := strings.TrimSpace(in)
s = strings.TrimSuffix(s, "/")
if i := strings.Index(s, "github.com/"); i >= 0 {
s = s[i+len("github.com/"):]
}
s = strings.TrimSuffix(s, ".git")
// A pasted URL can carry extra path segments (/releases, /tree/main, …).
if parts := strings.Split(s, "/"); len(parts) > 2 {
s = parts[0] + "/" + parts[1]
}
if err := ValidateRepo(s); err != nil {
return "", err
}
return s, nil
}
// versionRes are the token shapes that change from release to release and must
// therefore become wildcards. Anything else is treated as identifying the build
// (platform, GPU runtime, architecture, extension).
var versionRes = []*regexp.Regexp{
regexp.MustCompile(`^v?[0-9]+$`), // 1, v2, 1234
regexp.MustCompile(`^b[0-9]+$`), // llama.cpp build numbers: b1234
regexp.MustCompile(`^r[0-9]+$`), // r7
regexp.MustCompile(`^[0-9]{8}$`), // 20260815 (nightly dates)
regexp.MustCompile(`^[0-9a-f]{7,40}$`), // git shas
regexp.MustCompile(`^v?[0-9]+[a-z]?[0-9]*$`), // 1a, 2rc1-ish single tokens
regexp.MustCompile(`^(rc|alpha|beta|pre)[0-9]*$`),
}
func versionish(tok string) bool {
t := strings.ToLower(tok)
if t == "" {
return false
}
for _, re := range versionRes {
if re.MatchString(t) {
return true
}
}
return false
}
// piece is one lexical unit of an asset name: a separator run, or a token that
// is either literal or wildcarded.
type piece struct {
text string
sep bool
wild bool
}
// isSep reports the characters asset names use to join their parts. Splitting
// on these (rather than on any non-alphanumeric) keeps "gfx110X" and "x64" as
// single identifying tokens.
func isSep(r rune) bool { return r == '-' || r == '_' || r == '.' || r == '+' }
// lex splits an asset name into alternating token/separator pieces and marks
// which tokens vary between releases.
func lex(name string) []piece {
var out []piece
cur := strings.Builder{}
curSep := false
flush := func() {
if cur.Len() == 0 {
return
}
s := cur.String()
out = append(out, piece{text: s, sep: curSep, wild: !curSep && versionish(s)})
cur.Reset()
}
for i, r := range name {
s := isSep(r)
if i > 0 && s != curSep {
flush()
}
curSep = s
cur.WriteRune(r)
}
flush()
return out
}
// render turns lexed pieces into an anchored regex. Adjacent wildcards (and the
// separators between them) collapse into a single ".*" so a "13.3" version
// becomes one wildcard rather than ".*\..*".
func render(ps []piece) string {
var b strings.Builder
b.WriteByte('^')
for i := 0; i < len(ps); i++ {
if !ps[i].wild {
b.WriteString(regexp.QuoteMeta(ps[i].text))
continue
}
// Swallow the rest of this wildcard run: (wild sep)* wild.
for i+2 < len(ps) && ps[i+1].sep && ps[i+2].wild {
i += 2
}
b.WriteString(".*")
}
b.WriteByte('$')
return b.String()
}
// literal is the exact-name pattern — the fail-closed fallback.
func literal(name string) string { return "^" + regexp.QuoteMeta(name) + "$" }
// DerivePattern turns one asset name into an anchored pattern with its
// version-ish parts wildcarded. It does not consider the rest of the release;
// callers that have the full asset list should use DeriveUnique.
func DerivePattern(name string) string {
ps := lex(name)
pat := render(ps)
// A name that is nothing but version tokens would derive to "^.*$" and match
// every asset in the release. Pin it to the literal name instead.
if !hasLiteral(ps) {
return literal(name)
}
return pat
}
func hasLiteral(ps []piece) bool {
for _, p := range ps {
if !p.sep && !p.wild {
return true
}
}
return false
}
// DeriveUnique derives a pattern for `name` that matches it and nothing else in
// `all` (the asset names of the release it was picked from).
//
// Wildcarding every version-ish token is right for the common case but wrong
// when a release ships several builds that differ *only* in a version — the
// canonical example being llama.cpp's side-by-side CUDA 12.x and 13.x zips,
// where the naive pattern matches both and the install becomes a coin flip.
// When that happens the rightmost wildcard is restored to its literal text and
// the check repeats, so the toolkit version gets pinned while the build number
// stays free. This is the same guarantee Variant.PairKey gives the built-in
// catalog, arrived at from an example instead of a hand-written capture.
//
// If no amount of tightening disambiguates, the literal name is returned: a
// future release simply won't match, which surfaces as "re-pick this asset"
// rather than as the wrong binary on disk.
func DeriveUnique(name string, all []string) string {
ps := lex(name)
if !hasLiteral(ps) {
return literal(name)
}
for {
pat := render(ps)
re, err := regexp.Compile(pat)
if err != nil {
return literal(name)
}
hits := 0
for _, n := range all {
if re.MatchString(n) {
hits++
}
}
// 0 hits is impossible for a well-formed derivation (the source name is in
// `all`), but treating it as ambiguous keeps the fallback honest if `all`
// omits it.
if hits == 1 {
return pat
}
if !unwildRightmost(ps) {
return literal(name)
}
}
}
// unwildRightmost restores the last wildcarded token to a literal, reporting
// whether there was one left to restore.
func unwildRightmost(ps []piece) bool {
for i := len(ps) - 1; i >= 0; i-- {
if ps[i].wild {
ps[i].wild = false
return true
}
}
return false
}
// SuggestLabel names a variant from the parts of its asset name that identify
// the build, so a tracked source arrives with "Windows gfx110X" rather than
// "variant 1". Purely cosmetic — the user can rename it.
func SuggestLabel(name string) string {
// Drop the extension and any leading repo-ish prefix, keep identifying tokens.
base := name
for _, ext := range []string{".tar.gz", ".tgz", ".zip", ".exe", ".7z"} {
if strings.HasSuffix(strings.ToLower(base), ext) {
base = base[:len(base)-len(ext)]
break
}
}
var keep []string
for _, p := range lex(base) {
if p.sep || p.wild {
continue
}
l := strings.ToLower(p.text)
if l == "bin" || l == "x64" || l == "amd64" {
continue
}
keep = append(keep, p.text)
}
if len(keep) == 0 {
return "default"
}
if len(keep) > 4 {
keep = keep[len(keep)-4:]
}
return strings.Join(keep, " ")
}
// ClosestAsset finds the asset in `all` most similar to `exemplar`, scored 0-100
// by token overlap. It is a diagnostic, never an installer: when a source's
// pattern stops matching because upstream renamed its assets, this is what turns
// a bare "no asset found" into "closest is X — re-pick?".
func ClosestAsset(exemplar string, all []string) (string, int) {
want := tokenSet(exemplar)
if len(want) == 0 {
return "", 0
}
best, bestScore := "", 0
for _, n := range all {
got := tokenSet(n)
inter := 0
for t := range want {
if got[t] {
inter++
}
}
union := len(want) + len(got) - inter
if union == 0 {
continue
}
if s := inter * 100 / union; s > bestScore {
best, bestScore = n, s
}
}
return best, bestScore
}
func tokenSet(name string) map[string]bool {
out := map[string]bool{}
for _, p := range lex(name) {
if p.sep || p.wild {
continue
}
out[strings.ToLower(p.text)] = true
}
return out
}