// Phase C Step 9 — On-disk plugin discovery. // // Each plugin lives in its own subdirectory under PluginsConfig.Directory: // // plugins/ // hello/ // plugin.json // hello.wasm // game-detection/ // plugin.json // detector.wasm // assets/... // // Loader walks the directory, parses every plugin.json, and returns a slice // of foundPlugin records. The Registry then persists each into the store. package plugin import ( "encoding/json" "fmt" "os" "path/filepath" ) type foundPlugin struct { Manifest *Manifest Dir string WASMPath string } // scanPluginDirectory walks dir non-recursively and parses plugin.json from // every immediate subdirectory. Returns on the first error encountered; // partial results are not returned alongside errors. func scanPluginDirectory(dir string) ([]foundPlugin, error) { if dir == "" { return nil, nil } entries, err := os.ReadDir(dir) if err != nil { if os.IsNotExist(err) { // Directory absent is fine — operators may not have created it yet. return nil, nil } return nil, err } var found []foundPlugin for _, e := range entries { if !e.IsDir() { continue } pluginDir := filepath.Join(dir, e.Name()) // Prefer plugin.toml (wazero build) over plugin.json. manifest, ok, tomlErr := tryLoadPluginTOML(pluginDir) if tomlErr != nil { return nil, fmt.Errorf("plugin %q: %w", e.Name(), tomlErr) } if !ok { // Fall back to plugin.json. manifestPath := filepath.Join(pluginDir, "plugin.json") raw, rdErr := os.ReadFile(manifestPath) if rdErr != nil { if os.IsNotExist(rdErr) { continue } return nil, fmt.Errorf("plugin %q: read plugin.json: %w", e.Name(), rdErr) } var parseErr error manifest, parseErr = ParseManifest(raw) if parseErr != nil { return nil, fmt.Errorf("plugin %q: %w", e.Name(), parseErr) } } // Reject any symlinks anywhere in the plugin directory tree. The asset // handler enforces that resolved paths stay rooted at pluginDir, but // http.ServeFile / os.Open follow symlinks transparently — a malicious // plugin .zip containing `assets/index.html -> /etc/passwd` would // otherwise serve host files. os.Lstat is used for the entrypoint // check below so a symlink is detected instead of followed, even // when its target is a valid .wasm file. if err := rejectSymlinksUnder(pluginDir); err != nil { return nil, fmt.Errorf("plugin %q: %w", e.Name(), err) } wasmPath := filepath.Join(pluginDir, manifest.Entrypoint) if info, statErr := os.Lstat(wasmPath); statErr != nil { return nil, fmt.Errorf("plugin %q: missing entrypoint %s: %w", e.Name(), manifest.Entrypoint, statErr) } else if info.Mode()&os.ModeSymlink != 0 { return nil, fmt.Errorf("plugin %q: entrypoint %s is a symlink", e.Name(), manifest.Entrypoint) } found = append(found, foundPlugin{ Manifest: manifest, Dir: pluginDir, WASMPath: wasmPath, }) } return found, nil } // rejectSymlinksUnder walks root and returns an error if any entry is a // symlink. Defends against malicious plugin packages that ship symlinks to // host filesystem paths. func rejectSymlinksUnder(root string) error { return filepath.Walk(root, func(path string, info os.FileInfo, err error) error { if err != nil { return err } if info.Mode()&os.ModeSymlink != 0 { return fmt.Errorf("symlink not allowed: %s", path) } return nil }) } // serialize returns a canonical JSON encoding of the manifest, used as the // manifest_json column value in the plugins table. func (m *Manifest) serialize() (string, error) { b, err := json.Marshal(m) if err != nil { return "", fmt.Errorf("manifest serialize: %w", err) } return string(b), nil }