Ctrl K
ComunidadGuías…

internal/backends/derive.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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
}