internal/backends/install.go
Rama main · réplica de lectura del repositorio original.
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
}