Ctrl K
ComunidadGuías…

internal/pluginhost/shadow.go

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

Archivo original ↗Preguntar a KiBot
//go:build windows || (cgo && (linux || darwin || freebsd))

package pluginhost

import (
	"crypto/sha256"
	"encoding/hex"
	"errors"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"runtime"
	"strconv"
	"strings"
	"sync"

	log "github.com/sirupsen/logrus"
)

// Plugin dynamic libraries are loaded from content-addressed shadow copies.
// A loaded Go plugin image must stay mapped for the lifetime of the host
// process (its runtime cannot be unloaded), so loading from a shadow copy
// keeps the original artifact replaceable and gives every distinct plugin
// build its own path, which dynamic loaders map as a fresh image.

const (
	shadowPluginPrefix           = "cliproxy-plugin-"
	shadowPluginTempPrefix       = ".cliproxy-plugin-"
	shadowPluginProcessDirPrefix = "pid-"
	shadowPluginCacheDirName     = "cache"
	shadowPluginLocalDirName     = ".shadow"
	shadowPluginDigestLength     = 32
)

type cachedPluginDigest struct {
	size        int64
	modTimeNano int64
	digest      string
}

var (
	shadowPluginCleanupOnce   sync.Once
	cleanedShadowTempDirs     sync.Map
	pluginDigestCache         sync.Map
	pruneShadowBeforeLockHook func(shadowFileName string)
)

func shadowCopyPlugin(file pluginFile) (string, error) {
	path, release, errShadow := shadowCopyPluginInternal(file, false)
	if release != nil {
		release()
	}
	return path, errShadow
}

func acquireShadowPluginLoad(file pluginFile) (string, func(), error) {
	return shadowCopyPluginInternal(file, true)
}

func acquireShadowPluginFallbackLoad(file pluginFile, failedLoadPath string) (string, func(), error) {
	source := filepath.Clean(file.Path)
	dir, errDir := shadowPluginFallbackDir(source)
	if errDir != nil {
		return "", nil, errDir
	}
	if failedLoadPath != "" && filepath.Clean(filepath.Dir(failedLoadPath)) == filepath.Clean(dir) {
		return "", nil, fmt.Errorf("shadow plugin already in fallback directory")
	}
	size, digest, errHash := cachedOrHashPluginFile(source)
	if errHash != nil {
		return "", nil, errHash
	}
	cleanupShadowTempFilesOnce(dir)
	return shadowCopyPluginToDirWithGuard(file, source, dir, size, digest, true)
}

func shadowCopyPluginInternal(file pluginFile, holdGuard bool) (string, func(), error) {
	shadowPluginCleanupOnce.Do(func() {
		removeStaleShadowPluginDirs(shadowPluginBaseDir(), os.Getpid())
	})
	source := filepath.Clean(file.Path)
	size, digest, errHash := hashPluginFile(source)
	if errHash != nil {
		return "", nil, errHash
	}
	if localDir, errLocalDir := shadowPluginLocalDir(source); errLocalDir == nil {
		cleanupShadowTempFilesOnce(localDir)
		if target, release, errCopy := shadowCopyPluginToDirWithGuard(file, source, localDir, size, digest, holdGuard); errCopy == nil {
			return target, release, nil
		}
	}
	dir, errDir := shadowPluginFallbackDir(source)
	if errDir != nil {
		return "", nil, errDir
	}
	cleanupShadowTempFilesOnce(dir)
	return shadowCopyPluginToDirWithGuard(file, source, dir, size, digest, holdGuard)
}

func cleanupShadowTempFilesOnce(dir string) {
	cleaned := filepath.Clean(dir)
	if _, loaded := cleanedShadowTempDirs.LoadOrStore(cleaned, struct{}{}); loaded {
		return
	}
	removeStaleShadowTempFiles(cleaned)
}

// removeStaleShadowPluginDirs removes shadow directories left behind by
// host processes that already exited. Directories whose owner process is
// still alive are kept, because that process may reuse the resident plugin
// images inside them.
func removeStaleShadowPluginDirs(baseDir string, currentPID int) {
	entries, errRead := os.ReadDir(baseDir)
	if errRead != nil {
		return
	}
	for _, entry := range entries {
		if entry == nil || !entry.IsDir() {
			continue
		}
		name := entry.Name()
		if !strings.HasPrefix(name, shadowPluginProcessDirPrefix) {
			continue
		}
		pid, errParse := strconv.Atoi(strings.TrimPrefix(name, shadowPluginProcessDirPrefix))
		if errParse != nil || pid <= 0 || pid == currentPID {
			continue
		}
		if shadowPluginProcessAlive(pid) {
			continue
		}
		removeShadowPluginDir(filepath.Join(baseDir, name))
	}
}

func shadowCopyPluginToDir(file pluginFile, dir string) (string, error) {
	source := filepath.Clean(file.Path)
	size, digest, errHash := hashPluginFile(source)
	if errHash != nil {
		return "", errHash
	}
	return shadowCopyPluginToDirWithDigest(file, dir, size, digest)
}

func shadowCopyPluginToDirWithGuard(file pluginFile, sourcePath string, dir string, size int64, digest string, holdGuard bool) (string, func(), error) {
	releaseGuard := func() {}
	if holdGuard {
		sourceDir := filepath.Dir(filepath.Clean(sourcePath))
		unlockCoord, errLock := acquireShadowCoordLock(sourceDir, dir, false, false)
		if errLock != nil {
			return "", nil, errLock
		}
		releaseGuard = unlockCoord
	}
	target, errCopy := shadowCopyPluginToDirWithDigest(file, dir, size, digest)
	if errCopy != nil {
		releaseGuard()
		return "", nil, errCopy
	}
	return target, releaseGuard, nil
}

func acquireShadowCoordLock(sourceDir string, fallbackDir string, exclusive bool, nonBlocking bool) (func(), error) {
	baseDir := fallbackDir
	if strings.TrimSpace(sourceDir) != "" {
		baseDir = filepath.Join(shadowPluginBaseDir(), shadowPluginCacheDirName, shadowDirectoryDigest(sourceDir))
	}
	if errMkdir := os.MkdirAll(baseDir, 0o700); errMkdir != nil {
		return nil, errMkdir
	}
	lockPath := filepath.Join(baseDir, ".cliproxy-coord.lock")
	lockFile, errOpen := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0o600)
	if errOpen != nil {
		return nil, errOpen
	}
	if errLock := lockShadowCoordFile(lockFile, exclusive, nonBlocking); errLock != nil {
		if errClose := lockFile.Close(); errClose != nil {
			log.WithError(errClose).Debug("pluginhost: coord lock file close failed")
		}
		return nil, errLock
	}
	var once sync.Once
	return func() {
		once.Do(func() {
			unlockShadowCoordFile(lockFile)
			if errClose := lockFile.Close(); errClose != nil {
				log.WithError(errClose).Debug("pluginhost: coord lock file close failed")
			}
		})
	}, nil
}

func shadowCopyPluginToDirWithDigest(file pluginFile, dir string, size int64, digest string) (string, error) {
	source := filepath.Clean(file.Path)
	ext := filepath.Ext(source)
	target := shadowPluginPath(dir, file.ID, digest, ext)
	if shadowPluginMatches(target, size, digest) {
		return target, nil
	}

	tmp, errTemp := os.CreateTemp(dir, shadowPluginTempPattern(os.Getpid(), file.ID, ext))
	if errTemp != nil {
		return "", errTemp
	}
	tmpName := tmp.Name()
	removeTemp := true
	defer func() {
		if removeTemp {
			removeShadowPlugin(tmpName)
		}
	}()

	in, errOpen := os.Open(source)
	if errOpen != nil {
		if errClose := tmp.Close(); errClose != nil {
			log.WithError(errClose).Debug("pluginhost: failed to close temp shadow plugin file")
		}
		return "", errOpen
	}
	defer func() {
		if errClose := in.Close(); errClose != nil {
			log.WithError(errClose).Debug("pluginhost: failed to close plugin source file")
		}
	}()
	hasher := sha256.New()
	copiedSize, errCopy := io.Copy(io.MultiWriter(tmp, hasher), in)
	if errCopy != nil {
		if errClose := tmp.Close(); errClose != nil {
			log.WithError(errClose).Debug("pluginhost: failed to close temp shadow plugin file")
		}
		return "", errCopy
	}
	if errClose := tmp.Close(); errClose != nil {
		return "", errClose
	}
	copiedDigest := hex.EncodeToString(hasher.Sum(nil))
	target = shadowPluginPath(dir, file.ID, copiedDigest, ext)
	if errPublish := publishShadowTempFile(tmpName, target, copiedSize, copiedDigest); errPublish != nil {
		return "", errPublish
	}
	removeTemp = false
	return target, nil
}

func publishShadowTempFile(tmpName string, target string, size int64, digest string) error {
	if errPublish := publishShadowPlugin(tmpName, target); errPublish == nil {
		return nil
	}
	if shadowPluginMatches(target, size, digest) {
		removeShadowPlugin(tmpName)
		return nil
	}
	if errRename := os.Rename(tmpName, target); errRename == nil {
		return nil
	}
	if shadowPluginMatches(target, size, digest) {
		removeShadowPlugin(tmpName)
		return nil
	}
	if errRemove := os.Remove(target); errRemove != nil && !errors.Is(errRemove, os.ErrNotExist) {
		if shadowPluginMatches(target, size, digest) {
			removeShadowPlugin(tmpName)
			return nil
		}
		removeShadowPlugin(target)
		return fmt.Errorf("remove stale shadow plugin: %w", errRemove)
	}
	if errRename := os.Rename(tmpName, target); errRename != nil {
		if shadowPluginMatches(target, size, digest) {
			removeShadowPlugin(tmpName)
			return nil
		}
		return fmt.Errorf("move shadow plugin: %w", errRename)
	}
	return nil
}

func hashPluginFile(path string) (int64, string, error) {
	cleaned := filepath.Clean(path)
	file, errOpen := os.Open(cleaned)
	if errOpen != nil {
		return 0, "", errOpen
	}
	defer func() {
		if errClose := file.Close(); errClose != nil {
			log.WithError(errClose).Debug("pluginhost: failed to close plugin file after hashing")
		}
	}()
	hasher := sha256.New()
	size, errCopy := io.Copy(hasher, file)
	if errCopy != nil {
		return 0, "", errCopy
	}
	digest := hex.EncodeToString(hasher.Sum(nil))
	if info, errStat := file.Stat(); errStat == nil && info.Mode().IsRegular() {
		pluginDigestCache.Store(cleaned, cachedPluginDigest{
			size:        size,
			modTimeNano: info.ModTime().UnixNano(),
			digest:      digest,
		})
	}
	return size, digest, nil
}

func cachedOrHashPluginFile(path string) (int64, string, error) {
	cleaned := filepath.Clean(path)
	if info, errStat := os.Stat(cleaned); errStat == nil && info.Mode().IsRegular() {
		if raw, okCache := pluginDigestCache.Load(cleaned); okCache {
			if cached, okType := raw.(cachedPluginDigest); okType &&
				cached.size == info.Size() &&
				cached.modTimeNano == info.ModTime().UnixNano() &&
				cached.digest != "" {
				return cached.size, cached.digest, nil
			}
		}
	}
	return hashPluginFile(cleaned)
}

func shadowPluginBaseDir() string {
	return filepath.Join(os.TempDir(), "cliproxy-pluginhost")
}

func shadowPluginLocalDir(sourcePath string) (string, error) {
	dir := filepath.Join(filepath.Dir(filepath.Clean(sourcePath)), shadowPluginLocalDirName)
	if errMkdir := os.MkdirAll(dir, 0o700); errMkdir != nil {
		return "", errMkdir
	}
	return dir, nil
}

func shadowPluginDir() (string, error) {
	dir := filepath.Join(shadowPluginBaseDir(), shadowPluginCacheDirName)
	if errMkdir := os.MkdirAll(dir, 0o700); errMkdir != nil {
		return "", errMkdir
	}
	return dir, nil
}

func shadowPluginFallbackDir(sourcePath string) (string, error) {
	baseDir, errDir := shadowPluginDir()
	if errDir != nil {
		return "", errDir
	}
	sourceDir := filepath.Dir(filepath.Clean(sourcePath))
	dir := filepath.Join(baseDir, shadowDirectoryDigest(sourceDir))
	if errMkdir := os.MkdirAll(dir, 0o700); errMkdir != nil {
		return "", errMkdir
	}
	return dir, nil
}

func shadowDirectoryDigest(dir string) string {
	cleaned := filepath.Clean(dir)
	if absDir, errAbs := filepath.Abs(cleaned); errAbs == nil {
		cleaned = filepath.Clean(absDir)
	}
	sum := sha256.Sum256([]byte(cleaned))
	return hex.EncodeToString(sum[:])[:shadowPluginDigestLength]
}

func shadowPluginProcessDirName(pid int) string {
	return fmt.Sprintf("%s%d", shadowPluginProcessDirPrefix, pid)
}

func shadowPluginTempPattern(pid int, id string, extension string) string {
	return fmt.Sprintf("%s%s%d-%s-*%s", shadowPluginTempPrefix, shadowPluginProcessDirPrefix, pid, id, extension)
}

func shadowTempFileOwnedByLiveProcess(name string) bool {
	if !strings.HasPrefix(name, shadowPluginTempPrefix) {
		return false
	}
	rest := strings.TrimPrefix(name, shadowPluginTempPrefix)
	if !strings.HasPrefix(rest, shadowPluginProcessDirPrefix) {
		return false
	}
	rest = strings.TrimPrefix(rest, shadowPluginProcessDirPrefix)
	sep := strings.IndexByte(rest, '-')
	if sep <= 0 {
		return false
	}
	pid, errParse := strconv.Atoi(rest[:sep])
	if errParse != nil || pid <= 0 {
		return false
	}
	if pid == os.Getpid() {
		return true
	}
	return shadowPluginProcessAlive(pid)
}

func removeStaleShadowTempFiles(dir string) {
	entries, errRead := os.ReadDir(dir)
	if errRead != nil {
		return
	}
	for _, entry := range entries {
		if entry == nil || entry.IsDir() {
			continue
		}
		name := entry.Name()
		if strings.HasPrefix(name, shadowPluginTempPrefix) && !shadowTempFileOwnedByLiveProcess(name) {
			removeShadowPlugin(filepath.Join(dir, name))
		}
	}
}

func shadowDirHasLiveProcessActivity(entries []os.DirEntry) bool {
	for _, entry := range entries {
		if entry == nil || entry.IsDir() {
			continue
		}
		name := entry.Name()
		if strings.HasPrefix(name, shadowPluginTempPrefix) && shadowTempFileOwnedByLiveProcess(name) {
			return true
		}
	}
	return false
}

func removeStaleShadowPlugins(dir string, keepNames ...string) {
	pruneShadowDirForSourceDir(dir, "", keepNames)
}

func shadowSourceDirHasLiveProcessActivity(dir string, sourceDir string) bool {
	if entries, errRead := os.ReadDir(dir); errRead == nil && shadowDirHasLiveProcessActivity(entries) {
		return true
	}
	if sourceDir == "" {
		return false
	}
	fallbackDir := filepath.Join(shadowPluginBaseDir(), shadowPluginCacheDirName, shadowDirectoryDigest(sourceDir))
	if filepath.Clean(fallbackDir) == filepath.Clean(dir) {
		return false
	}
	fallbackEntries, errRead := os.ReadDir(fallbackDir)
	if errRead != nil {
		return false
	}
	return shadowDirHasLiveProcessActivity(fallbackEntries)
}

func pruneShadowDirForSourceDir(dir string, sourceDir string, keepNames []string) {
	entries, errRead := os.ReadDir(dir)
	if errRead != nil {
		return
	}
	keep := make(map[string]struct{}, len(keepNames))
	for _, name := range keepNames {
		if trimmed := strings.TrimSpace(name); trimmed != "" {
			keep[filepath.Base(trimmed)] = struct{}{}
		}
	}
	for _, entry := range entries {
		if entry == nil || entry.IsDir() {
			continue
		}
		name := entry.Name()
		if strings.HasPrefix(name, shadowPluginTempPrefix) {
			if !shadowTempFileOwnedByLiveProcess(name) {
				removeShadowPlugin(filepath.Join(dir, name))
			}
			continue
		}
		if strings.HasPrefix(name, shadowPluginPrefix) {
			if _, okKeep := keep[name]; okKeep {
				continue
			}
			if hook := pruneShadowBeforeLockHook; hook != nil {
				hook(name)
			}
			removeStaleShadowPluginCandidate(dir, sourceDir, name)
		}
	}
}

func removeStaleShadowPluginCandidate(dir string, sourceDir string, name string) {
	unlockCoord, errLock := acquireShadowCoordLock(sourceDir, dir, true, true)
	if errLock != nil {
		return
	}
	defer unlockCoord()
	if shadowSourceDirHasLiveProcessActivity(dir, sourceDir) {
		return
	}
	if sourceDir != "" && shadowMatchesCurrentSourceDir(sourceDir, name) {
		return
	}
	removeShadowPlugin(filepath.Join(dir, name))
}

func shadowMatchesCurrentSourceDir(sourceDir string, shadowFileName string) bool {
	entries, errRead := os.ReadDir(sourceDir)
	if errRead != nil {
		return false
	}
	ext := filepath.Ext(shadowFileName)
	for _, entry := range entries {
		if entry == nil || !entry.Type().IsRegular() {
			continue
		}
		filePath := filepath.Join(sourceDir, entry.Name())
		file, okFile := pluginFileFromPath(filePath, ext)
		if !okFile {
			continue
		}
		if !strings.HasPrefix(shadowFileName, shadowPluginPrefix+file.ID+"-") {
			continue
		}
		_, digest, errHash := cachedOrHashPluginFile(filePath)
		if errHash != nil {
			continue
		}
		if filepath.Base(shadowPluginPath("", file.ID, digest, ext)) == shadowFileName {
			return true
		}
	}
	return false
}

type shadowDirTarget struct {
	shadowDir string
	sourceDir string
}

func cleanupStaleShadowPlugins(root string, activeFiles []pluginFile) {
	keepNames := make([]string, 0, len(activeFiles))
	seenDirs := make(map[string]struct{})
	targets := make([]shadowDirTarget, 0, len(activeFiles)*2+4)
	addShadowDirsForSourceDir := func(sourceDir string) {
		cleanedSourceDir := filepath.Clean(sourceDir)
		for _, dir := range []string{
			filepath.Join(cleanedSourceDir, shadowPluginLocalDirName),
			filepath.Join(shadowPluginBaseDir(), shadowPluginCacheDirName, shadowDirectoryDigest(cleanedSourceDir)),
		} {
			cleaned := filepath.Clean(dir)
			if _, exists := seenDirs[cleaned]; exists {
				continue
			}
			seenDirs[cleaned] = struct{}{}
			targets = append(targets, shadowDirTarget{
				shadowDir: cleaned,
				sourceDir: cleanedSourceDir,
			})
		}
	}
	for _, candidateDir := range candidateDirs(root, runtime.GOOS, runtime.GOARCH) {
		addShadowDirsForSourceDir(candidateDir)
	}
	seenSources := make(map[string]struct{}, len(activeFiles))
	for _, file := range activeFiles {
		id := strings.TrimSpace(file.ID)
		path := strings.TrimSpace(file.Path)
		if id == "" || path == "" {
			continue
		}
		source := filepath.Clean(path)
		if _, seen := seenSources[source]; seen {
			continue
		}
		seenSources[source] = struct{}{}
		addShadowDirsForSourceDir(filepath.Dir(source))
		_, digest, errHash := cachedOrHashPluginFile(source)
		if errHash != nil {
			continue
		}
		keepNames = append(keepNames, filepath.Base(shadowPluginPath("", id, digest, filepath.Ext(source))))
	}
	for _, target := range targets {
		pruneShadowDirForSourceDir(target.shadowDir, target.sourceDir, keepNames)
	}
}

func shadowPluginPath(dir string, id string, digest string, extension string) string {
	if len(digest) > shadowPluginDigestLength {
		digest = digest[:shadowPluginDigestLength]
	}
	return filepath.Join(dir, shadowPluginPrefix+id+"-"+digest+extension)
}

func shadowPluginMatches(path string, size int64, digest string) bool {
	info, errStat := os.Stat(path)
	if errStat != nil {
		return false
	}
	if !info.Mode().IsRegular() || info.Size() != size {
		return false
	}
	file, errOpen := os.Open(path)
	if errOpen != nil {
		return false
	}
	defer func() {
		if errClose := file.Close(); errClose != nil {
			log.WithError(errClose).Debug("pluginhost: failed to close shadow plugin file")
		}
	}()
	hasher := sha256.New()
	if _, errCopy := io.Copy(hasher, file); errCopy != nil {
		return false
	}
	return hex.EncodeToString(hasher.Sum(nil)) == digest
}