Ctrl K
ComunidadGuías…

internal/backends/install.go

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

Archivo original ↗Preguntar a KiBot
package backends

import (
	"archive/tar"
	"archive/zip"
	"compress/gzip"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"io/fs"
	"net/http"
	"os"
	"path/filepath"
	"regexp"
	"runtime"
	"sort"
	"strings"
	"time"
)

// manifestName marks an install directory. Its presence (not a central index)
// is what makes a version "installed", so a half-extracted or hand-deleted
// folder can never show up as usable.
const manifestName = ".qm-install.json"

// downloadTimeout bounds one asset fetch. llama.cpp CUDA zips run to ~500 MB.
const downloadTimeout = 30 * time.Minute

// Installed is one version of a component on disk.
type Installed struct {
	Component   string    `json:"component"`
	Version     string    `json:"version"`
	Variant     string    `json:"variant"`
	Exe         string    `json:"exe"` // absolute path to the executable
	Dir         string    `json:"dir"`
	Asset       string    `json:"asset"`
	InstalledAt time.Time `json:"installedAt"`
	SizeBytes   int64     `json:"sizeBytes"`
}

// manifest is the on-disk form (Exe relative to Dir so the bundle stays movable).
type manifest struct {
	Component   string    `json:"component"`
	Version     string    `json:"version"`
	Variant     string    `json:"variant"`
	Exe         string    `json:"exe"`
	Asset       string    `json:"asset"`
	InstalledAt time.Time `json:"installedAt"`
	SizeBytes   int64     `json:"sizeBytes"`
}

var unsafePath = regexp.MustCompile(`[^A-Za-z0-9._-]`)

// sanitizeSeg makes a release tag safe as a single path segment.
func sanitizeSeg(s string) string {
	s = unsafePath.ReplaceAllString(strings.TrimSpace(s), "_")
	if s == "" {
		s = "unknown"
	}
	if len(s) > 64 {
		s = s[:64]
	}
	return s
}

// ComponentDir is where every installed version of a component lives.
func (m *Manager) ComponentDir(comp string) string {
	return filepath.Join(m.root, "bin", sanitizeSeg(comp))
}

// InstallDir is the versioned, side-by-side directory for one build. Versions
// are never overwritten, so an update can't brick a working setup and an older
// build stays one click away.
func (m *Manager) InstallDir(comp, version, variant string) string {
	return filepath.Join(m.ComponentDir(comp), sanitizeSeg(version)+"-"+sanitizeSeg(variant))
}

// Installed lists every installed version of a component, newest install first.
func (m *Manager) Installed(comp string) []Installed {
	ents, err := os.ReadDir(m.ComponentDir(comp))
	if err != nil {
		return nil
	}
	var out []Installed
	for _, e := range ents {
		if !e.IsDir() {
			continue
		}
		dir := filepath.Join(m.ComponentDir(comp), e.Name())
		inst, err := readManifest(dir)
		if err != nil {
			continue // not ours, or a failed extract
		}
		out = append(out, inst)
	}
	sort.Slice(out, func(i, j int) bool { return out[i].InstalledAt.After(out[j].InstalledAt) })
	return out
}

// AllInstalled lists installed builds across every catalog component.
func (m *Manager) AllInstalled() []Installed {
	var out []Installed
	for _, c := range m.Catalog() {
		out = append(out, m.Installed(c.ID)...)
	}
	return out
}

func readManifest(dir string) (Installed, error) {
	data, err := os.ReadFile(filepath.Join(dir, manifestName))
	if err != nil {
		return Installed{}, err
	}
	var mf manifest
	if err := json.Unmarshal(data, &mf); err != nil {
		return Installed{}, err
	}
	exe := filepath.Join(dir, filepath.FromSlash(mf.Exe))
	if st, err := os.Stat(exe); err != nil || st.IsDir() {
		return Installed{}, fmt.Errorf("executable missing from %s", dir)
	}
	// Measure the directory rather than trusting the manifest: what the user
	// wants to see is the disk this build occupies, and manifests written before
	// this recorded only the executable (llama-server.exe is a stub in front of
	// the ggml DLLs, so it read as 0 MB).
	size := mf.SizeBytes
	if n, err := dirSize(dir); err == nil {
		size = n
	}
	return Installed{
		Component: mf.Component, Version: mf.Version, Variant: mf.Variant,
		Exe: exe, Dir: dir, Asset: mf.Asset,
		InstalledAt: mf.InstalledAt, SizeBytes: size,
	}, nil
}

// dirSize totals the regular files under dir. Unreadable entries are skipped
// rather than failing the scan: a wrong-by-one-file size is better than a build
// disappearing from the installed list.
func dirSize(dir string) (int64, error) {
	var total int64
	err := filepath.WalkDir(dir, func(_ string, d fs.DirEntry, err error) error {
		if err != nil || d.IsDir() {
			return nil //nolint:nilerr // skip what we can't stat, keep walking
		}
		if info, err := d.Info(); err == nil {
			total += info.Size()
		}
		return nil
	})
	return total, err
}

func writeManifest(dir string, mf manifest) error {
	data, err := json.MarshalIndent(mf, "", "  ")
	if err != nil {
		return err
	}
	return os.WriteFile(filepath.Join(dir, manifestName), data, 0o644)
}

// Uninstall removes one installed build. Refuses when it is the active exe of a
// registry entry — the caller points the entry elsewhere first.
func (m *Manager) Uninstall(comp, version, variant string) error {
	dir := m.InstallDir(comp, version, variant)
	if _, err := readManifest(dir); err != nil {
		return fmt.Errorf("not installed: %s %s (%s)", comp, version, variant)
	}
	return os.RemoveAll(dir)
}

// download streams an asset to dst, reporting progress. Total is the size the
// API reported (0 when unknown) so the UI can show a bar before the first byte.
func (m *Manager) download(ctx context.Context, src, dst string, onProgress func(done, total int64)) error {
	if err := validAssetURL(src); err != nil {
		return err
	}
	ctx, cancel := context.WithTimeout(ctx, downloadTimeout)
	defer cancel()
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, src, nil)
	if err != nil {
		return err
	}
	req.Header.Set("User-Agent", ghUserAgent)
	resp, err := m.dl.Do(req)
	if err != nil {
		return err
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		return fmt.Errorf("download: %s", resp.Status)
	}
	f, err := os.Create(dst)
	if err != nil {
		return err
	}
	defer f.Close()
	pr := &progressReader{r: resp.Body, total: resp.ContentLength, on: onProgress}
	if _, err := io.Copy(f, pr); err != nil {
		return err
	}
	return f.Sync()
}

// progressReader reports bytes read at most every progressEvery bytes so a
// 500 MB download doesn't generate a million callbacks.
type progressReader struct {
	r       io.Reader
	total   int64
	done    int64
	lastRep int64
	on      func(done, total int64)
}

const progressEvery = 512 << 10

func (p *progressReader) Read(b []byte) (int, error) {
	n, err := p.r.Read(b)
	p.done += int64(n)
	if p.on != nil && (p.done-p.lastRep >= progressEvery || err != nil) {
		p.lastRep = p.done
		p.on(p.done, p.total)
	}
	return n, err
}

// extract unpacks an archive (zip or tar.gz) into dir. Entry paths are checked
// against dir so a crafted archive can't write outside it (zip slip).
func extract(archivePath, dir string) error {
	lower := strings.ToLower(archivePath)
	switch {
	case strings.HasSuffix(lower, ".zip"):
		return extractZip(archivePath, dir)
	case strings.HasSuffix(lower, ".tar.gz"), strings.HasSuffix(lower, ".tgz"):
		return extractTarGz(archivePath, dir)
	}
	return fmt.Errorf("unsupported archive type: %s", filepath.Base(archivePath))
}

// safeJoin resolves name inside dir, rejecting absolute paths and ../ escapes.
func safeJoin(dir, name string) (string, error) {
	clean := filepath.Clean(filepath.FromSlash(name))
	if filepath.IsAbs(clean) || strings.HasPrefix(clean, ".."+string(filepath.Separator)) || clean == ".." {
		return "", fmt.Errorf("unsafe archive entry: %s", name)
	}
	out := filepath.Join(dir, clean)
	rel, err := filepath.Rel(dir, out)
	if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
		return "", fmt.Errorf("unsafe archive entry: %s", name)
	}
	return out, nil
}

func extractZip(src, dir string) error {
	zr, err := zip.OpenReader(src)
	if err != nil {
		return err
	}
	defer zr.Close()
	var links []linkEntry
	for _, f := range zr.File {
		dst, err := safeJoin(dir, f.Name)
		if err != nil {
			return err
		}
		if f.FileInfo().IsDir() {
			if err := os.MkdirAll(dst, 0o755); err != nil {
				return err
			}
			continue
		}
		if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
			return err
		}
		rc, err := f.Open()
		if err != nil {
			return err
		}
		// A zip symlink is a tiny entry whose CONTENT is the target path; written
		// out verbatim it becomes a text file with a library's name, which the
		// loader rejects as a bad ELF instead of following. Defer it like a tar
		// link so the SONAME chain survives (see applyLinks).
		if f.Mode()&os.ModeSymlink != 0 {
			target, err := io.ReadAll(io.LimitReader(rc, 4096))
			rc.Close()
			if err != nil {
				return err
			}
			links = append(links, linkEntry{dst: dst, target: string(target)})
			continue
		}
		err = writeFile(dst, rc, f.Mode())
		rc.Close()
		if err != nil {
			return err
		}
	}
	return applyLinks(dir, links)
}

func extractTarGz(src, dir string) error {
	f, err := os.Open(src)
	if err != nil {
		return err
	}
	defer f.Close()
	gz, err := gzip.NewReader(f)
	if err != nil {
		return err
	}
	defer gz.Close()
	tr := tar.NewReader(gz)
	var links []linkEntry
	for {
		h, err := tr.Next()
		if errors.Is(err, io.EOF) {
			return applyLinks(dir, links)
		}
		if err != nil {
			return err
		}
		dst, err := safeJoin(dir, h.Name)
		if err != nil {
			return err
		}
		switch h.Typeflag {
		case tar.TypeDir:
			if err := os.MkdirAll(dst, 0o755); err != nil {
				return err
			}
		case tar.TypeReg:
			if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
				return err
			}
			if err := writeFile(dst, tr, os.FileMode(h.Mode)); err != nil {
				return err
			}
		case tar.TypeSymlink:
			links = append(links, linkEntry{dst: dst, target: h.Linkname})
		case tar.TypeLink:
			links = append(links, linkEntry{dst: dst, target: h.Linkname, hard: true})
		default:
			// Devices, fifos and the rest are skipped: no backend archive needs
			// them, and they are the other half of a path-escape trick.
		}
	}
}

func writeFile(dst string, r io.Reader, mode os.FileMode) error {
	if mode == 0 {
		mode = 0o644
	}
	out, err := os.OpenFile(dst, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode.Perm())
	if err != nil {
		return err
	}
	defer out.Close()
	_, err = io.Copy(out, r)
	return err
}

// linkEntry is one archived symlink (or hard link), held back until every
// regular file has been written: an archive is free to list a link before the
// file it points at.
type linkEntry struct {
	dst    string // absolute path of the link itself
	target string // link text, exactly as the archive stores it
	hard   bool
}

// applyLinks materialises the deferred links.
//
// Why this matters: llama.cpp's Linux bundles ship each library as a SONAME
// chain of symlinks (libllama-common.so -> .so.0 -> .so.0.3.0), and the ELF
// headers reference the MIDDLE name. Dropping the links left only the versioned
// files on disk, so every spawn died with "libllama-common.so.0: cannot open
// shared object file" no matter what was on LD_LIBRARY_PATH.
//
// Rounds, not one pass: on Windows a symlink needs a privilege most accounts
// lack, so the fallback copies the target - which has to exist first, and a
// chain's links can appear in any order. A round that resolves nothing means
// the rest are dangling (target absent from the archive); that is left alone
// rather than failing the install, since nothing may ever load them.
func applyLinks(dir string, links []linkEntry) error {
	for len(links) > 0 {
		var stuck []linkEntry
		for _, l := range links {
			done, err := applyLink(dir, l)
			if err != nil {
				return err
			}
			if !done {
				stuck = append(stuck, l)
			}
		}
		if len(stuck) == len(links) {
			return nil
		}
		links = stuck
	}
	return nil
}

// applyLink writes one link, or reports false when it needs another round.
func applyLink(dir string, l linkEntry) (bool, error) {
	// A link is the other half of a path-escape trick, so resolve where it
	// actually points and refuse anything outside the install directory. A
	// symlink's text is relative to the link, a tar hard link's to the archive
	// root.
	base := dir
	if !l.hard {
		base = filepath.Dir(l.dst)
	}
	// Archives are unix-shaped, so "/etc/passwd" is an absolute target even on
	// Windows, where filepath.IsAbs would call it relative and join it INTO the
	// install directory.
	target := filepath.FromSlash(l.target)
	if target == "" || filepath.IsAbs(target) || strings.HasPrefix(l.target, "/") || filepath.VolumeName(target) != "" {
		return false, fmt.Errorf("unsafe archive link: %s -> %s", filepath.Base(l.dst), l.target)
	}
	abs := filepath.Join(base, target)
	if rel, err := filepath.Rel(dir, abs); err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
		return false, fmt.Errorf("unsafe archive link: %s -> %s", filepath.Base(l.dst), l.target)
	}
	_ = os.Remove(l.dst) // a re-extract over a previous install
	if !l.hard {
		// Dangling is fine here: a real symlink resolves whenever the target
		// lands, so this needs no ordering.
		if err := os.Symlink(target, l.dst); err == nil {
			return true, nil
		}
	}
	st, err := os.Stat(abs)
	if err != nil {
		return false, nil // target not written yet - retry next round
	}
	if !st.Mode().IsRegular() {
		return false, nil
	}
	if l.hard {
		if err := os.Link(abs, l.dst); err == nil {
			return true, nil
		}
	}
	return true, copyFile(abs, l.dst) // 0o755: every link in a backend bundle is a library or an exe
}

// findExe locates the component's executable anywhere under dir (release zips
// nest it in a subfolder, and the nesting differs per project and per build).
func findExe(dir, name string) (string, error) {
	var hit string
	err := filepath.WalkDir(dir, func(p string, d os.DirEntry, err error) error {
		if err != nil || hit != "" || d.IsDir() {
			return nil
		}
		if strings.EqualFold(d.Name(), name) {
			hit = p
		}
		return nil
	})
	if err != nil {
		return "", err
	}
	if hit == "" {
		return "", fmt.Errorf("%q not found in the downloaded archive", name)
	}
	if runtime.GOOS != "windows" {
		_ = os.Chmod(hit, 0o755) // zip entries from CI often lack the exec bit
	}
	return hit, nil
}