//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 }