refactor(go): split package validation and VM lifecycle out of extension_manager.go

This commit is contained in:
zarzet
2026-07-26 23:05:30 +07:00
parent b81bd5e8aa
commit ea172ba17b
3 changed files with 602 additions and 582 deletions
-582
View File
@@ -4,218 +4,15 @@ import (
"archive/zip"
"encoding/json"
"fmt"
"io"
"os"
"path"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"github.com/dop251/goja"
)
func compareVersions(v1, v2 string) int {
parts1 := strings.Split(strings.TrimPrefix(v1, "v"), ".")
parts2 := strings.Split(strings.TrimPrefix(v2, "v"), ".")
maxLen := len(parts1)
if len(parts2) > maxLen {
maxLen = len(parts2)
}
for i := 0; i < maxLen; i++ {
var n1, n2 int
if i < len(parts1) {
n1, _ = strconv.Atoi(parts1[i])
}
if i < len(parts2) {
n2, _ = strconv.Atoi(parts2[i])
}
if n1 < n2 {
return -1
}
if n1 > n2 {
return 1
}
}
return 0
}
func isExtensionPackagePath(filePath string) bool {
lowerPath := strings.ToLower(filePath)
return strings.HasSuffix(lowerPath, ".spotiflac-ext") || strings.HasSuffix(lowerPath, ".sflx")
}
func managedExtensionPath(root, extensionID string) (string, error) {
if root == "" {
return "", fmt.Errorf("extension directory is not configured")
}
if !extensionIDPattern.MatchString(extensionID) {
return "", fmt.Errorf("invalid extension ID %q", extensionID)
}
fullPath := filepath.Join(root, extensionID)
if !isPathWithinBase(root, fullPath) {
return "", fmt.Errorf("extension path escapes its managed directory")
}
return fullPath, nil
}
func safeExtensionAssetPath(root, assetPath string) (string, bool) {
if root == "" || assetPath == "" || filepath.IsAbs(assetPath) || strings.Contains(assetPath, `\`) {
return "", false
}
cleaned := path.Clean(assetPath)
if cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, "../") {
return "", false
}
fullPath := filepath.Join(root, filepath.FromSlash(cleaned))
return fullPath, isPathWithinBase(root, fullPath)
}
const (
maxExtensionArchiveEntries = 2048
maxExtensionArchiveUncompressedBytes = 256 * 1024 * 1024
maxExtensionManifestBytes = 1024 * 1024
)
func validateExtensionArchive(files []*zip.File) error {
if len(files) > maxExtensionArchiveEntries {
return fmt.Errorf(
"extension archive contains too many entries (maximum %d)",
maxExtensionArchiveEntries,
)
}
seenPaths := make(map[string]struct{}, len(files))
var totalUncompressed uint64
for _, file := range files {
if file.FileInfo().Mode()&os.ModeSymlink != 0 || strings.Contains(file.Name, `\`) {
return fmt.Errorf("unsafe path in extension archive: %s", file.Name)
}
relPath := path.Clean(file.Name)
if relPath == "." || relPath == ".." || strings.HasPrefix(relPath, "../") || path.IsAbs(relPath) {
return fmt.Errorf("unsafe path in extension archive: %s", file.Name)
}
pathKey := strings.ToLower(relPath)
if _, exists := seenPaths[pathKey]; exists {
return fmt.Errorf("duplicate path in extension archive: %s", file.Name)
}
seenPaths[pathKey] = struct{}{}
if file.FileInfo().IsDir() {
continue
}
if file.UncompressedSize64 > maxExtensionArchiveUncompressedBytes-totalUncompressed {
return fmt.Errorf(
"extension archive exceeds the %d MiB extracted size limit",
maxExtensionArchiveUncompressedBytes/(1024*1024),
)
}
totalUncompressed += file.UncompressedSize64
}
return nil
}
func inspectExtensionPackage(files []*zip.File) (*ExtensionManifest, error) {
if err := validateExtensionArchive(files); err != nil {
return nil, err
}
var manifestFile *zip.File
hasIndexJS := false
for _, file := range files {
switch path.Clean(file.Name) {
case "manifest.json":
manifestFile = file
case "index.js":
hasIndexJS = !file.FileInfo().IsDir()
}
}
if manifestFile == nil || manifestFile.FileInfo().IsDir() {
return nil, fmt.Errorf("invalid extension package: root manifest.json not found")
}
if !hasIndexJS {
return nil, fmt.Errorf("invalid extension package: root index.js not found")
}
if manifestFile.UncompressedSize64 > maxExtensionManifestBytes {
return nil, fmt.Errorf("invalid extension package: manifest.json is too large")
}
rc, err := manifestFile.Open()
if err != nil {
return nil, fmt.Errorf("failed to open manifest.json: %w", err)
}
manifestData, readErr := io.ReadAll(io.LimitReader(rc, maxExtensionManifestBytes+1))
closeErr := rc.Close()
if readErr != nil {
return nil, fmt.Errorf("failed to read manifest.json: %w", readErr)
}
if closeErr != nil {
return nil, fmt.Errorf("failed to close manifest.json: %w", closeErr)
}
if len(manifestData) > maxExtensionManifestBytes {
return nil, fmt.Errorf("invalid extension package: manifest.json is too large")
}
manifest, err := ParseManifest(manifestData)
if err != nil {
return nil, fmt.Errorf("invalid extension manifest: %w", err)
}
return manifest, nil
}
func extractExtensionArchive(zipReader *zip.ReadCloser, destination string) error {
if err := validateExtensionArchive(zipReader.File); err != nil {
return err
}
for _, file := range zipReader.File {
if file.FileInfo().IsDir() {
continue
}
if file.FileInfo().Mode()&os.ModeSymlink != 0 || strings.Contains(file.Name, `\`) {
return fmt.Errorf("unsafe path in extension archive: %s", file.Name)
}
relPath := path.Clean(file.Name)
if relPath == "." || relPath == ".." || strings.HasPrefix(relPath, "../") || path.IsAbs(relPath) {
return fmt.Errorf("unsafe path in extension archive: %s", file.Name)
}
destPath := filepath.Join(destination, filepath.FromSlash(relPath))
if !isPathWithinBase(destination, destPath) {
return fmt.Errorf("unsafe path in extension archive: %s", file.Name)
}
if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil {
return fmt.Errorf("failed to create extension directory: %w", err)
}
destFile, err := os.OpenFile(destPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
if err != nil {
return fmt.Errorf("failed to create extension file: %w", err)
}
srcFile, err := file.Open()
if err != nil {
destFile.Close()
return fmt.Errorf("failed to open file in archive: %w", err)
}
_, copyErr := io.Copy(destFile, srcFile)
closeSrcErr := srcFile.Close()
closeDestErr := destFile.Close()
if copyErr != nil {
return fmt.Errorf("failed to extract extension file: %w", copyErr)
}
if closeSrcErr != nil || closeDestErr != nil {
return fmt.Errorf("failed to close extracted extension file")
}
}
return nil
}
type loadedExtension struct {
ID string `json:"id"`
Manifest *ExtensionManifest `json:"manifest"`
@@ -453,385 +250,6 @@ func (m *extensionManager) loadExtensionFromFileLocked(filePath string) (*loaded
return ext, nil
}
func initializeVMLocked(ext *loadedExtension) error {
ext.VM = nil
ext.runtime = nil
ext.indexProgram = nil
ext.initialized = false
vm := goja.New()
ext.VM = vm
indexPath := filepath.Join(ext.SourceDir, "index.js")
jsCode, err := os.ReadFile(indexPath)
if err != nil {
return fmt.Errorf("failed to read index.js: %w", err)
}
indexProgram, err := goja.Compile(indexPath, string(jsCode), false)
if err != nil {
return fmt.Errorf("failed to compile extension code: %w", err)
}
ext.indexProgram = indexProgram
runtime := newExtensionRuntime(ext)
ext.runtime = runtime
runtime.RegisterAPIs(vm)
runtime.RegisterGoBackendAPIs(vm)
console := vm.NewObject()
console.Set("log", func(call goja.FunctionCall) goja.Value {
args := make([]any, len(call.Arguments))
for i, arg := range call.Arguments {
args[i] = arg.Export()
}
GoLog("[Extension:%s] %v\n", ext.ID, args)
return goja.Undefined()
})
vm.Set("console", console)
var registeredExtension goja.Value
vm.Set("registerExtension", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) > 0 {
registeredExtension = call.Arguments[0]
vm.Set("extension", call.Arguments[0])
}
return goja.Undefined()
})
_, err = vm.RunProgram(indexProgram)
if err != nil {
return fmt.Errorf("failed to execute extension code: %w", err)
}
if registeredExtension == nil || goja.IsUndefined(registeredExtension) {
return fmt.Errorf("extension did not call registerExtension()")
}
return nil
}
func newIsolatedExtensionRuntime(ext *loadedExtension) (*goja.Runtime, *extensionRuntime, error) {
vm := goja.New()
indexProgram := ext.indexProgram
if indexProgram == nil {
indexPath := filepath.Join(ext.SourceDir, "index.js")
jsCode, err := os.ReadFile(indexPath)
if err != nil {
return nil, nil, fmt.Errorf("failed to read index.js: %w", err)
}
indexProgram, err = goja.Compile(indexPath, string(jsCode), false)
if err != nil {
return nil, nil, fmt.Errorf("failed to compile extension code: %w", err)
}
}
runtime := &extensionRuntime{
extensionID: ext.ID,
manifest: ext.Manifest,
settings: make(map[string]any),
cookieJar: nil,
dataDir: ext.DataDir,
vm: vm,
}
if ext.runtime != nil && ext.runtime.cookieJar != nil {
runtime.cookieJar = ext.runtime.cookieJar
} else {
jar, _ := newSimpleCookieJar()
runtime.cookieJar = jar
}
runtime.httpClient = newExtensionHTTPClient(ext, runtime.cookieJar, extensionHTTPTimeout(ext, 30*time.Second), true)
runtime.downloadClient = newExtensionHTTPClient(ext, runtime.cookieJar, DownloadTimeout, false)
runtime.RegisterAPIs(vm)
runtime.RegisterGoBackendAPIs(vm)
console := vm.NewObject()
console.Set("log", func(call goja.FunctionCall) goja.Value {
args := make([]any, len(call.Arguments))
for i, arg := range call.Arguments {
args[i] = arg.Export()
}
GoLog("[Extension:%s] %v\n", ext.ID, args)
return goja.Undefined()
})
vm.Set("console", console)
var registeredExtension goja.Value
vm.Set("registerExtension", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) > 0 {
registeredExtension = call.Arguments[0]
vm.Set("extension", call.Arguments[0])
}
return goja.Undefined()
})
if _, err := vm.RunProgram(indexProgram); err != nil {
runtime.closeStorageFlusher()
return nil, nil, fmt.Errorf("failed to execute extension code: %w", err)
}
if registeredExtension == nil || goja.IsUndefined(registeredExtension) {
runtime.closeStorageFlusher()
return nil, nil, fmt.Errorf("extension did not call registerExtension()")
}
settings := getExtensionInitSettings(ext.ID)
if len(settings) > 0 {
if err := initializeExtensionRuntimeWithSettings(vm, ext.ID, settings); err != nil {
runtime.closeStorageFlusher()
return nil, nil, err
}
}
return vm, runtime, nil
}
// A goja runtime plus an executed extension program is several MB of live
// heap; rebuilding one per download multiplies that by the number of tracks.
// Extensions already serve many calls on the persistent shared VM, so reusing
// an initialized isolated runtime for consecutive downloads is the same
// lifecycle contract.
const maxIdleIsolatedRuntimes = 1
// acquireIsolatedExtensionRuntime pops an idle pooled runtime or builds one.
func acquireIsolatedExtensionRuntime(ext *loadedExtension) (*goja.Runtime, *extensionRuntime, error) {
ext.isolatedPoolMu.Lock()
if n := len(ext.isolatedPool); n > 0 {
handle := ext.isolatedPool[n-1]
ext.isolatedPool = ext.isolatedPool[:n-1]
ext.isolatedPoolMu.Unlock()
return handle.vm, handle.runtime, nil
}
ext.isolatedPoolMu.Unlock()
ext.VMMu.Lock()
defer ext.VMMu.Unlock()
return newIsolatedExtensionRuntime(ext)
}
// releaseIsolatedExtensionRuntime pools a healthy runtime for reuse or tears
// it down. Pass healthy=false after an interrupt/timeout/script error, whose
// VM state can't be trusted for reuse.
func releaseIsolatedExtensionRuntime(ext *loadedExtension, vm *goja.Runtime, runtime *extensionRuntime, healthy, cleanupSafe bool) {
if runtime != nil {
if err := runtime.flushStorageNow(); err != nil {
GoLog("[Extension:%s] isolated download storage flush failed: %v\n", ext.ID, err)
}
}
if healthy && vm != nil && runtime != nil && ext.Enabled {
ext.isolatedPoolMu.Lock()
if len(ext.isolatedPool) < maxIdleIsolatedRuntimes {
ext.isolatedPool = append(ext.isolatedPool, &isolatedRuntimeHandle{vm: vm, runtime: runtime})
ext.isolatedPoolMu.Unlock()
return
}
ext.isolatedPoolMu.Unlock()
}
if cleanupSafe {
if cleanupErr := runCleanupOnVM(vm); cleanupErr != nil {
GoLog("[Extension:%s] isolated download cleanup failed: %v\n", ext.ID, cleanupErr)
}
}
if runtime != nil {
runtime.closeStorageFlusher()
}
}
// quarantineRuntimeLocked detaches a VM that remained busy after interrupt.
// The caller holds VMMu. Touching or cleaning up that VM would race its stuck
// goroutine; a later call will build a fresh runtime from indexProgram.
func quarantineRuntimeLocked(ext *loadedExtension, vm *goja.Runtime) {
if ext == nil || ext.VM != vm {
return
}
ext.VM = nil
ext.runtime = nil
ext.initialized = false
ext.Error = "extension runtime was quarantined after an unresponsive script"
}
// drainIsolatedRuntimePool tears down idle isolated runtimes. Called on
// extension teardown and on app-wide memory release.
func drainIsolatedRuntimePool(ext *loadedExtension) {
ext.isolatedPoolMu.Lock()
pool := ext.isolatedPool
ext.isolatedPool = nil
ext.isolatedPoolMu.Unlock()
for _, handle := range pool {
if cleanupErr := runCleanupOnVM(handle.vm); cleanupErr != nil {
GoLog("[Extension:%s] isolated pool cleanup failed: %v\n", ext.ID, cleanupErr)
}
if handle.runtime != nil {
if err := handle.runtime.flushStorageNow(); err != nil {
GoLog("[Extension:%s] isolated pool storage flush failed: %v\n", ext.ID, err)
}
handle.runtime.closeStorageFlusher()
}
}
}
// drainAllIsolatedRuntimePools releases every extension's idle isolated
// runtimes (memory-pressure hook).
func drainAllIsolatedRuntimePools() {
m := getExtensionManager()
m.mu.RLock()
exts := make([]*loadedExtension, 0, len(m.extensions))
for _, ext := range m.extensions {
exts = append(exts, ext)
}
m.mu.RUnlock()
for _, ext := range exts {
drainIsolatedRuntimePool(ext)
}
}
func (m *extensionManager) initializeVM(ext *loadedExtension) error {
ext.VMMu.Lock()
defer ext.VMMu.Unlock()
return initializeVMLocked(ext)
}
func initializeExtensionRuntimeWithSettings(
vm *goja.Runtime,
extensionID string,
settings map[string]any,
) error {
settingsJSON, err := json.Marshal(settings)
if err != nil {
return fmt.Errorf("failed to save settings")
}
script := fmt.Sprintf(`
(function() {
var settings = %s;
if (typeof extension !== 'undefined' && typeof extension.initialize === 'function') {
try {
extension.initialize(settings);
return { success: true };
} catch (e) {
return { success: false, error: e.toString() };
}
}
return { success: true, message: 'no initialize function' };
})()
`, string(settingsJSON))
result, err := vm.RunString(script)
if err != nil {
GoLog("[Extension] Initialize error for %s: %v\n", extensionID, err)
return err
}
if result != nil && !goja.IsUndefined(result) {
exported := result.Export()
if resultMap, ok := exported.(map[string]any); ok {
if success, ok := resultMap["success"].(bool); ok && !success {
errMsg := "unknown error"
if e, ok := resultMap["error"].(string); ok {
errMsg = e
}
GoLog("[Extension] Initialize failed for %s: %s\n", extensionID, errMsg)
return fmt.Errorf("initialize failed: %s", errMsg)
}
}
}
return nil
}
func initializeExtensionWithSettingsLocked(
ext *loadedExtension,
settings map[string]any,
) error {
if ext.VM == nil {
return fmt.Errorf("extension failed to load: please reinstall the extension")
}
if err := initializeExtensionRuntimeWithSettings(ext.VM, ext.ID, settings); err != nil {
ext.Error = err.Error()
ext.Enabled = false
return err
}
ext.initialized = true
GoLog("[Extension] Initialized %s\n", ext.ID)
return nil
}
func runCleanupLocked(ext *loadedExtension) error {
if ext.VM != nil {
if err := runCleanupOnVM(ext.VM); err != nil {
return err
}
if ext.VM.Get("extension") != nil {
GoLog("[Extension] Cleanup called for %s\n", ext.ID)
}
}
return nil
}
func runCleanupOnVM(vm *goja.Runtime) error {
if vm == nil {
return nil
}
script := `
(function() {
if (typeof extension !== 'undefined' && typeof extension.cleanup === 'function') {
try {
extension.cleanup();
return { success: true };
} catch (e) {
return { success: false, error: e.toString() };
}
}
return { success: true, message: 'no cleanup function' };
})()
`
result, err := vm.RunString(script)
if err != nil {
return err
}
if result != nil && !goja.IsUndefined(result) {
exported := result.Export()
if resultMap, ok := exported.(map[string]any); ok {
if success, ok := resultMap["success"].(bool); ok && !success {
errMsg := "unknown error"
if e, ok := resultMap["error"].(string); ok {
errMsg = e
}
return fmt.Errorf("cleanup failed: %s", errMsg)
}
}
}
return nil
}
func teardownVMLocked(ext *loadedExtension) {
drainIsolatedRuntimePool(ext)
if err := runCleanupLocked(ext); err != nil {
GoLog("[Extension] Error calling cleanup for %s: %v\n", ext.ID, err)
}
if ext.runtime != nil {
if err := ext.runtime.flushStorageNow(); err != nil {
GoLog("[Extension] Failed to flush storage for %s: %v\n", ext.ID, err)
}
ext.runtime.closeStorageFlusher()
}
ext.runtime = nil
ext.VM = nil
ext.initialized = false
}
// supportedRuntimeFeatures maps every feature name the goja runtime provides
// to its current contract version (documented in SIGNED_SESSION_GUIDE.md).
// Bump a version here when a feature's contract changes.
var supportedRuntimeFeatures = map[string]int{
"signedSession": 1,
"sessionRefresh": 1,
+212
View File
@@ -0,0 +1,212 @@
package gobackend
import (
"archive/zip"
"fmt"
"io"
"os"
"path"
"path/filepath"
"strconv"
"strings"
)
func compareVersions(v1, v2 string) int {
parts1 := strings.Split(strings.TrimPrefix(v1, "v"), ".")
parts2 := strings.Split(strings.TrimPrefix(v2, "v"), ".")
maxLen := len(parts1)
if len(parts2) > maxLen {
maxLen = len(parts2)
}
for i := 0; i < maxLen; i++ {
var n1, n2 int
if i < len(parts1) {
n1, _ = strconv.Atoi(parts1[i])
}
if i < len(parts2) {
n2, _ = strconv.Atoi(parts2[i])
}
if n1 < n2 {
return -1
}
if n1 > n2 {
return 1
}
}
return 0
}
func isExtensionPackagePath(filePath string) bool {
lowerPath := strings.ToLower(filePath)
return strings.HasSuffix(lowerPath, ".spotiflac-ext") || strings.HasSuffix(lowerPath, ".sflx")
}
func managedExtensionPath(root, extensionID string) (string, error) {
if root == "" {
return "", fmt.Errorf("extension directory is not configured")
}
if !extensionIDPattern.MatchString(extensionID) {
return "", fmt.Errorf("invalid extension ID %q", extensionID)
}
fullPath := filepath.Join(root, extensionID)
if !isPathWithinBase(root, fullPath) {
return "", fmt.Errorf("extension path escapes its managed directory")
}
return fullPath, nil
}
func safeExtensionAssetPath(root, assetPath string) (string, bool) {
if root == "" || assetPath == "" || filepath.IsAbs(assetPath) || strings.Contains(assetPath, `\`) {
return "", false
}
cleaned := path.Clean(assetPath)
if cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, "../") {
return "", false
}
fullPath := filepath.Join(root, filepath.FromSlash(cleaned))
return fullPath, isPathWithinBase(root, fullPath)
}
const (
maxExtensionArchiveEntries = 2048
maxExtensionArchiveUncompressedBytes = 256 * 1024 * 1024
maxExtensionManifestBytes = 1024 * 1024
)
func validateExtensionArchive(files []*zip.File) error {
if len(files) > maxExtensionArchiveEntries {
return fmt.Errorf(
"extension archive contains too many entries (maximum %d)",
maxExtensionArchiveEntries,
)
}
seenPaths := make(map[string]struct{}, len(files))
var totalUncompressed uint64
for _, file := range files {
if file.FileInfo().Mode()&os.ModeSymlink != 0 || strings.Contains(file.Name, `\`) {
return fmt.Errorf("unsafe path in extension archive: %s", file.Name)
}
relPath := path.Clean(file.Name)
if relPath == "." || relPath == ".." || strings.HasPrefix(relPath, "../") || path.IsAbs(relPath) {
return fmt.Errorf("unsafe path in extension archive: %s", file.Name)
}
pathKey := strings.ToLower(relPath)
if _, exists := seenPaths[pathKey]; exists {
return fmt.Errorf("duplicate path in extension archive: %s", file.Name)
}
seenPaths[pathKey] = struct{}{}
if file.FileInfo().IsDir() {
continue
}
if file.UncompressedSize64 > maxExtensionArchiveUncompressedBytes-totalUncompressed {
return fmt.Errorf(
"extension archive exceeds the %d MiB extracted size limit",
maxExtensionArchiveUncompressedBytes/(1024*1024),
)
}
totalUncompressed += file.UncompressedSize64
}
return nil
}
func inspectExtensionPackage(files []*zip.File) (*ExtensionManifest, error) {
if err := validateExtensionArchive(files); err != nil {
return nil, err
}
var manifestFile *zip.File
hasIndexJS := false
for _, file := range files {
switch path.Clean(file.Name) {
case "manifest.json":
manifestFile = file
case "index.js":
hasIndexJS = !file.FileInfo().IsDir()
}
}
if manifestFile == nil || manifestFile.FileInfo().IsDir() {
return nil, fmt.Errorf("invalid extension package: root manifest.json not found")
}
if !hasIndexJS {
return nil, fmt.Errorf("invalid extension package: root index.js not found")
}
if manifestFile.UncompressedSize64 > maxExtensionManifestBytes {
return nil, fmt.Errorf("invalid extension package: manifest.json is too large")
}
rc, err := manifestFile.Open()
if err != nil {
return nil, fmt.Errorf("failed to open manifest.json: %w", err)
}
manifestData, readErr := io.ReadAll(io.LimitReader(rc, maxExtensionManifestBytes+1))
closeErr := rc.Close()
if readErr != nil {
return nil, fmt.Errorf("failed to read manifest.json: %w", readErr)
}
if closeErr != nil {
return nil, fmt.Errorf("failed to close manifest.json: %w", closeErr)
}
if len(manifestData) > maxExtensionManifestBytes {
return nil, fmt.Errorf("invalid extension package: manifest.json is too large")
}
manifest, err := ParseManifest(manifestData)
if err != nil {
return nil, fmt.Errorf("invalid extension manifest: %w", err)
}
return manifest, nil
}
func extractExtensionArchive(zipReader *zip.ReadCloser, destination string) error {
if err := validateExtensionArchive(zipReader.File); err != nil {
return err
}
for _, file := range zipReader.File {
if file.FileInfo().IsDir() {
continue
}
if file.FileInfo().Mode()&os.ModeSymlink != 0 || strings.Contains(file.Name, `\`) {
return fmt.Errorf("unsafe path in extension archive: %s", file.Name)
}
relPath := path.Clean(file.Name)
if relPath == "." || relPath == ".." || strings.HasPrefix(relPath, "../") || path.IsAbs(relPath) {
return fmt.Errorf("unsafe path in extension archive: %s", file.Name)
}
destPath := filepath.Join(destination, filepath.FromSlash(relPath))
if !isPathWithinBase(destination, destPath) {
return fmt.Errorf("unsafe path in extension archive: %s", file.Name)
}
if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil {
return fmt.Errorf("failed to create extension directory: %w", err)
}
destFile, err := os.OpenFile(destPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
if err != nil {
return fmt.Errorf("failed to create extension file: %w", err)
}
srcFile, err := file.Open()
if err != nil {
destFile.Close()
return fmt.Errorf("failed to open file in archive: %w", err)
}
_, copyErr := io.Copy(destFile, srcFile)
closeSrcErr := srcFile.Close()
closeDestErr := destFile.Close()
if copyErr != nil {
return fmt.Errorf("failed to extract extension file: %w", copyErr)
}
if closeSrcErr != nil || closeDestErr != nil {
return fmt.Errorf("failed to close extracted extension file")
}
}
return nil
}
+390
View File
@@ -0,0 +1,390 @@
package gobackend
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"
"github.com/dop251/goja"
)
func initializeVMLocked(ext *loadedExtension) error {
ext.VM = nil
ext.runtime = nil
ext.indexProgram = nil
ext.initialized = false
vm := goja.New()
ext.VM = vm
indexPath := filepath.Join(ext.SourceDir, "index.js")
jsCode, err := os.ReadFile(indexPath)
if err != nil {
return fmt.Errorf("failed to read index.js: %w", err)
}
indexProgram, err := goja.Compile(indexPath, string(jsCode), false)
if err != nil {
return fmt.Errorf("failed to compile extension code: %w", err)
}
ext.indexProgram = indexProgram
runtime := newExtensionRuntime(ext)
ext.runtime = runtime
runtime.RegisterAPIs(vm)
runtime.RegisterGoBackendAPIs(vm)
console := vm.NewObject()
console.Set("log", func(call goja.FunctionCall) goja.Value {
args := make([]any, len(call.Arguments))
for i, arg := range call.Arguments {
args[i] = arg.Export()
}
GoLog("[Extension:%s] %v\n", ext.ID, args)
return goja.Undefined()
})
vm.Set("console", console)
var registeredExtension goja.Value
vm.Set("registerExtension", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) > 0 {
registeredExtension = call.Arguments[0]
vm.Set("extension", call.Arguments[0])
}
return goja.Undefined()
})
_, err = vm.RunProgram(indexProgram)
if err != nil {
return fmt.Errorf("failed to execute extension code: %w", err)
}
if registeredExtension == nil || goja.IsUndefined(registeredExtension) {
return fmt.Errorf("extension did not call registerExtension()")
}
return nil
}
func newIsolatedExtensionRuntime(ext *loadedExtension) (*goja.Runtime, *extensionRuntime, error) {
vm := goja.New()
indexProgram := ext.indexProgram
if indexProgram == nil {
indexPath := filepath.Join(ext.SourceDir, "index.js")
jsCode, err := os.ReadFile(indexPath)
if err != nil {
return nil, nil, fmt.Errorf("failed to read index.js: %w", err)
}
indexProgram, err = goja.Compile(indexPath, string(jsCode), false)
if err != nil {
return nil, nil, fmt.Errorf("failed to compile extension code: %w", err)
}
}
runtime := &extensionRuntime{
extensionID: ext.ID,
manifest: ext.Manifest,
settings: make(map[string]any),
cookieJar: nil,
dataDir: ext.DataDir,
vm: vm,
}
if ext.runtime != nil && ext.runtime.cookieJar != nil {
runtime.cookieJar = ext.runtime.cookieJar
} else {
jar, _ := newSimpleCookieJar()
runtime.cookieJar = jar
}
runtime.httpClient = newExtensionHTTPClient(ext, runtime.cookieJar, extensionHTTPTimeout(ext, 30*time.Second), true)
runtime.downloadClient = newExtensionHTTPClient(ext, runtime.cookieJar, DownloadTimeout, false)
runtime.RegisterAPIs(vm)
runtime.RegisterGoBackendAPIs(vm)
console := vm.NewObject()
console.Set("log", func(call goja.FunctionCall) goja.Value {
args := make([]any, len(call.Arguments))
for i, arg := range call.Arguments {
args[i] = arg.Export()
}
GoLog("[Extension:%s] %v\n", ext.ID, args)
return goja.Undefined()
})
vm.Set("console", console)
var registeredExtension goja.Value
vm.Set("registerExtension", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) > 0 {
registeredExtension = call.Arguments[0]
vm.Set("extension", call.Arguments[0])
}
return goja.Undefined()
})
if _, err := vm.RunProgram(indexProgram); err != nil {
runtime.closeStorageFlusher()
return nil, nil, fmt.Errorf("failed to execute extension code: %w", err)
}
if registeredExtension == nil || goja.IsUndefined(registeredExtension) {
runtime.closeStorageFlusher()
return nil, nil, fmt.Errorf("extension did not call registerExtension()")
}
settings := getExtensionInitSettings(ext.ID)
if len(settings) > 0 {
if err := initializeExtensionRuntimeWithSettings(vm, ext.ID, settings); err != nil {
runtime.closeStorageFlusher()
return nil, nil, err
}
}
return vm, runtime, nil
}
// A goja runtime plus an executed extension program is several MB of live
// heap; rebuilding one per download multiplies that by the number of tracks.
// Extensions already serve many calls on the persistent shared VM, so reusing
// an initialized isolated runtime for consecutive downloads is the same
// lifecycle contract.
const maxIdleIsolatedRuntimes = 1
// acquireIsolatedExtensionRuntime pops an idle pooled runtime or builds one.
func acquireIsolatedExtensionRuntime(ext *loadedExtension) (*goja.Runtime, *extensionRuntime, error) {
ext.isolatedPoolMu.Lock()
if n := len(ext.isolatedPool); n > 0 {
handle := ext.isolatedPool[n-1]
ext.isolatedPool = ext.isolatedPool[:n-1]
ext.isolatedPoolMu.Unlock()
return handle.vm, handle.runtime, nil
}
ext.isolatedPoolMu.Unlock()
ext.VMMu.Lock()
defer ext.VMMu.Unlock()
return newIsolatedExtensionRuntime(ext)
}
// releaseIsolatedExtensionRuntime pools a healthy runtime for reuse or tears
// it down. Pass healthy=false after an interrupt/timeout/script error, whose
// VM state can't be trusted for reuse.
func releaseIsolatedExtensionRuntime(ext *loadedExtension, vm *goja.Runtime, runtime *extensionRuntime, healthy, cleanupSafe bool) {
if runtime != nil {
if err := runtime.flushStorageNow(); err != nil {
GoLog("[Extension:%s] isolated download storage flush failed: %v\n", ext.ID, err)
}
}
if healthy && vm != nil && runtime != nil && ext.Enabled {
ext.isolatedPoolMu.Lock()
if len(ext.isolatedPool) < maxIdleIsolatedRuntimes {
ext.isolatedPool = append(ext.isolatedPool, &isolatedRuntimeHandle{vm: vm, runtime: runtime})
ext.isolatedPoolMu.Unlock()
return
}
ext.isolatedPoolMu.Unlock()
}
if cleanupSafe {
if cleanupErr := runCleanupOnVM(vm); cleanupErr != nil {
GoLog("[Extension:%s] isolated download cleanup failed: %v\n", ext.ID, cleanupErr)
}
}
if runtime != nil {
runtime.closeStorageFlusher()
}
}
// quarantineRuntimeLocked detaches a VM that remained busy after interrupt.
// The caller holds VMMu. Touching or cleaning up that VM would race its stuck
// goroutine; a later call will build a fresh runtime from indexProgram.
func quarantineRuntimeLocked(ext *loadedExtension, vm *goja.Runtime) {
if ext == nil || ext.VM != vm {
return
}
ext.VM = nil
ext.runtime = nil
ext.initialized = false
ext.Error = "extension runtime was quarantined after an unresponsive script"
}
// drainIsolatedRuntimePool tears down idle isolated runtimes. Called on
// extension teardown and on app-wide memory release.
func drainIsolatedRuntimePool(ext *loadedExtension) {
ext.isolatedPoolMu.Lock()
pool := ext.isolatedPool
ext.isolatedPool = nil
ext.isolatedPoolMu.Unlock()
for _, handle := range pool {
if cleanupErr := runCleanupOnVM(handle.vm); cleanupErr != nil {
GoLog("[Extension:%s] isolated pool cleanup failed: %v\n", ext.ID, cleanupErr)
}
if handle.runtime != nil {
if err := handle.runtime.flushStorageNow(); err != nil {
GoLog("[Extension:%s] isolated pool storage flush failed: %v\n", ext.ID, err)
}
handle.runtime.closeStorageFlusher()
}
}
}
// drainAllIsolatedRuntimePools releases every extension's idle isolated
// runtimes (memory-pressure hook).
func drainAllIsolatedRuntimePools() {
m := getExtensionManager()
m.mu.RLock()
exts := make([]*loadedExtension, 0, len(m.extensions))
for _, ext := range m.extensions {
exts = append(exts, ext)
}
m.mu.RUnlock()
for _, ext := range exts {
drainIsolatedRuntimePool(ext)
}
}
func (m *extensionManager) initializeVM(ext *loadedExtension) error {
ext.VMMu.Lock()
defer ext.VMMu.Unlock()
return initializeVMLocked(ext)
}
func initializeExtensionRuntimeWithSettings(
vm *goja.Runtime,
extensionID string,
settings map[string]any,
) error {
settingsJSON, err := json.Marshal(settings)
if err != nil {
return fmt.Errorf("failed to save settings")
}
script := fmt.Sprintf(`
(function() {
var settings = %s;
if (typeof extension !== 'undefined' && typeof extension.initialize === 'function') {
try {
extension.initialize(settings);
return { success: true };
} catch (e) {
return { success: false, error: e.toString() };
}
}
return { success: true, message: 'no initialize function' };
})()
`, string(settingsJSON))
result, err := vm.RunString(script)
if err != nil {
GoLog("[Extension] Initialize error for %s: %v\n", extensionID, err)
return err
}
if result != nil && !goja.IsUndefined(result) {
exported := result.Export()
if resultMap, ok := exported.(map[string]any); ok {
if success, ok := resultMap["success"].(bool); ok && !success {
errMsg := "unknown error"
if e, ok := resultMap["error"].(string); ok {
errMsg = e
}
GoLog("[Extension] Initialize failed for %s: %s\n", extensionID, errMsg)
return fmt.Errorf("initialize failed: %s", errMsg)
}
}
}
return nil
}
func initializeExtensionWithSettingsLocked(
ext *loadedExtension,
settings map[string]any,
) error {
if ext.VM == nil {
return fmt.Errorf("extension failed to load: please reinstall the extension")
}
if err := initializeExtensionRuntimeWithSettings(ext.VM, ext.ID, settings); err != nil {
ext.Error = err.Error()
ext.Enabled = false
return err
}
ext.initialized = true
GoLog("[Extension] Initialized %s\n", ext.ID)
return nil
}
func runCleanupLocked(ext *loadedExtension) error {
if ext.VM != nil {
if err := runCleanupOnVM(ext.VM); err != nil {
return err
}
if ext.VM.Get("extension") != nil {
GoLog("[Extension] Cleanup called for %s\n", ext.ID)
}
}
return nil
}
func runCleanupOnVM(vm *goja.Runtime) error {
if vm == nil {
return nil
}
script := `
(function() {
if (typeof extension !== 'undefined' && typeof extension.cleanup === 'function') {
try {
extension.cleanup();
return { success: true };
} catch (e) {
return { success: false, error: e.toString() };
}
}
return { success: true, message: 'no cleanup function' };
})()
`
result, err := vm.RunString(script)
if err != nil {
return err
}
if result != nil && !goja.IsUndefined(result) {
exported := result.Export()
if resultMap, ok := exported.(map[string]any); ok {
if success, ok := resultMap["success"].(bool); ok && !success {
errMsg := "unknown error"
if e, ok := resultMap["error"].(string); ok {
errMsg = e
}
return fmt.Errorf("cleanup failed: %s", errMsg)
}
}
}
return nil
}
func teardownVMLocked(ext *loadedExtension) {
drainIsolatedRuntimePool(ext)
if err := runCleanupLocked(ext); err != nil {
GoLog("[Extension] Error calling cleanup for %s: %v\n", ext.ID, err)
}
if ext.runtime != nil {
if err := ext.runtime.flushStorageNow(); err != nil {
GoLog("[Extension] Failed to flush storage for %s: %v\n", ext.ID, err)
}
ext.runtime.closeStorageFlusher()
}
ext.runtime = nil
ext.VM = nil
ext.initialized = false
}
// supportedRuntimeFeatures maps every feature name the goja runtime provides
// to its current contract version (documented in SIGNED_SESSION_GUIDE.md).