plugins 2.0 update
This commit is contained in:
@@ -21,7 +21,7 @@ type Manager struct {
|
||||
type pluginInstance struct {
|
||||
id string
|
||||
manifest PluginManifest
|
||||
native NativePlugin
|
||||
native PluginRuntime
|
||||
}
|
||||
|
||||
func NewManager(writer wire.Writer, host HostInfo) *Manager {
|
||||
@@ -49,7 +49,13 @@ func (m *Manager) Load(ctx context.Context, manifest PluginManifest, binary []by
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
np, err := loadNativePlugin(binary)
|
||||
var np PluginRuntime
|
||||
var err error
|
||||
if manifest.RuntimeKind == "wasm" || manifest.WASM != "" {
|
||||
np, err = loadWASMPlugin(ctx, manifest, binary)
|
||||
} else {
|
||||
np, err = loadNativePlugin(binary)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -107,7 +113,7 @@ func (m *Manager) Load(ctx context.Context, manifest PluginManifest, binary []by
|
||||
|
||||
rt := np.Runtime()
|
||||
freeable := rt != "go"
|
||||
log.Printf("[plugin] loaded %s (native, runtime=%s, freeable=%v)", pluginID, rt, freeable)
|
||||
log.Printf("[plugin] loaded %s (runtime=%s, freeable=%v)", pluginID, rt, freeable)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -11,14 +11,28 @@ type PluginAssets struct {
|
||||
type PluginManifest struct {
|
||||
ID string `msgpack:"id" json:"id"`
|
||||
Name string `msgpack:"name" json:"name"`
|
||||
APIVersion int `msgpack:"apiVersion,omitempty" json:"apiVersion,omitempty"`
|
||||
RuntimeKind string `msgpack:"runtime,omitempty" json:"runtime,omitempty"`
|
||||
Version string `msgpack:"version,omitempty" json:"version,omitempty"`
|
||||
Description string `msgpack:"description,omitempty" json:"description,omitempty"`
|
||||
Binary string `msgpack:"binary,omitempty" json:"binary,omitempty"`
|
||||
Binaries map[string]string `msgpack:"binaries,omitempty" json:"binaries,omitempty"`
|
||||
WASM string `msgpack:"wasm,omitempty" json:"wasm,omitempty"`
|
||||
Needs PluginNeeds `msgpack:"needs,omitempty" json:"needs,omitempty"`
|
||||
Entry string `msgpack:"entry,omitempty" json:"entry,omitempty"`
|
||||
Assets PluginAssets `msgpack:"assets,omitempty" json:"assets,omitempty"`
|
||||
}
|
||||
|
||||
type PluginNeeds struct {
|
||||
Files []PluginFileNeed `msgpack:"files,omitempty" json:"files,omitempty"`
|
||||
}
|
||||
|
||||
type PluginFileNeed struct {
|
||||
Bucket string `msgpack:"bucket" json:"bucket"`
|
||||
Access []string `msgpack:"access" json:"access"`
|
||||
Reason string `msgpack:"reason,omitempty" json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
type PluginMessage struct {
|
||||
Type string `msgpack:"type"`
|
||||
Event string `msgpack:"event,omitempty"`
|
||||
@@ -33,7 +47,7 @@ type HostInfo struct {
|
||||
Version string `msgpack:"version" json:"version"`
|
||||
}
|
||||
|
||||
type NativePlugin interface {
|
||||
type PluginRuntime interface {
|
||||
Load(send func(event string, payload []byte), hostInfo []byte) error
|
||||
|
||||
Event(event string, payload []byte) error
|
||||
@@ -45,13 +59,18 @@ type NativePlugin interface {
|
||||
Runtime() string
|
||||
}
|
||||
|
||||
type NativePlugin = PluginRuntime
|
||||
|
||||
func ManifestFromMap(m map[string]interface{}) (PluginManifest, error) {
|
||||
manifest := PluginManifest{}
|
||||
manifest.ID = stringVal(m["id"])
|
||||
manifest.Name = stringVal(m["name"])
|
||||
manifest.APIVersion = intVal(m["apiVersion"])
|
||||
manifest.RuntimeKind = stringVal(m["runtime"])
|
||||
manifest.Version = stringVal(m["version"])
|
||||
manifest.Description = stringVal(m["description"])
|
||||
manifest.Binary = stringVal(m["binary"])
|
||||
manifest.WASM = stringVal(m["wasm"])
|
||||
manifest.Entry = stringVal(m["entry"])
|
||||
|
||||
if binariesRaw, ok := m["binaries"].(map[string]interface{}); ok {
|
||||
@@ -70,6 +89,27 @@ func ManifestFromMap(m map[string]interface{}) (PluginManifest, error) {
|
||||
JS: stringVal(assetsRaw["js"]),
|
||||
}
|
||||
}
|
||||
if needsRaw, ok := m["needs"].(map[string]interface{}); ok {
|
||||
if filesRaw, ok := needsRaw["files"].([]interface{}); ok {
|
||||
for _, item := range filesRaw {
|
||||
fileRaw, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
need := PluginFileNeed{Bucket: stringVal(fileRaw["bucket"]), Reason: stringVal(fileRaw["reason"])}
|
||||
if accessRaw, ok := fileRaw["access"].([]interface{}); ok {
|
||||
for _, v := range accessRaw {
|
||||
if s, ok := v.(string); ok && s != "" {
|
||||
need.Access = append(need.Access, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
if need.Bucket != "" && len(need.Access) > 0 {
|
||||
manifest.Needs.Files = append(manifest.Needs.Files, need)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if manifest.ID == "" {
|
||||
return PluginManifest{}, errors.New("missing plugin id")
|
||||
@@ -84,3 +124,34 @@ func stringVal(v interface{}) string {
|
||||
s, _ := v.(string)
|
||||
return s
|
||||
}
|
||||
|
||||
func intVal(v interface{}) int {
|
||||
switch n := v.(type) {
|
||||
case int:
|
||||
return n
|
||||
case int8:
|
||||
return int(n)
|
||||
case int16:
|
||||
return int(n)
|
||||
case int32:
|
||||
return int(n)
|
||||
case int64:
|
||||
return int(n)
|
||||
case uint:
|
||||
return int(n)
|
||||
case uint8:
|
||||
return int(n)
|
||||
case uint16:
|
||||
return int(n)
|
||||
case uint32:
|
||||
return int(n)
|
||||
case uint64:
|
||||
return int(n)
|
||||
case float32:
|
||||
return int(n)
|
||||
case float64:
|
||||
return int(n)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,511 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/tetratelabs/wazero"
|
||||
"github.com/tetratelabs/wazero/api"
|
||||
"github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1"
|
||||
)
|
||||
|
||||
const maxWASMFileReadBytes = 32 * 1024 * 1024
|
||||
|
||||
type wasmPlugin struct {
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
runtime wazero.Runtime
|
||||
module api.Module
|
||||
manifest PluginManifest
|
||||
hostInfo []byte
|
||||
send func(string, []byte)
|
||||
mu sync.Mutex
|
||||
acl map[string]map[string]bool
|
||||
}
|
||||
|
||||
func loadWASMPlugin(parent context.Context, manifest PluginManifest, data []byte) (PluginRuntime, error) {
|
||||
if len(data) == 0 {
|
||||
return nil, errors.New("empty wasm plugin module")
|
||||
}
|
||||
ctx, cancel := context.WithCancel(parent)
|
||||
rt := wazero.NewRuntime(ctx)
|
||||
wp := &wasmPlugin{
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
runtime: rt,
|
||||
manifest: manifest,
|
||||
acl: buildFileACL(manifest.Needs),
|
||||
}
|
||||
|
||||
wasi_snapshot_preview1.MustInstantiate(ctx, rt)
|
||||
if err := wp.instantiateHostModule("env"); err != nil {
|
||||
cancel()
|
||||
_ = rt.Close(ctx)
|
||||
return nil, err
|
||||
}
|
||||
if err := wp.instantiateHostModule("overlord"); err != nil {
|
||||
cancel()
|
||||
_ = rt.Close(ctx)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
mod, err := rt.InstantiateWithConfig(ctx, data, wazero.NewModuleConfig().
|
||||
WithName(manifest.ID).
|
||||
WithStartFunctions("_initialize"))
|
||||
if err != nil {
|
||||
cancel()
|
||||
_ = rt.Close(ctx)
|
||||
return nil, fmt.Errorf("wasm instantiate: %w", err)
|
||||
}
|
||||
wp.module = mod
|
||||
return wp, nil
|
||||
}
|
||||
|
||||
func (p *wasmPlugin) instantiateHostModule(name string) error {
|
||||
builder := p.runtime.NewHostModuleBuilder(name)
|
||||
export := func(name string, params []api.ValueType, results []api.ValueType, fn api.GoModuleFunc) {
|
||||
builder.NewFunctionBuilder().WithGoModuleFunction(fn, params, results).Export(name)
|
||||
}
|
||||
|
||||
export("overlord_emit",
|
||||
[]api.ValueType{api.ValueTypeI32, api.ValueTypeI32, api.ValueTypeI32, api.ValueTypeI32},
|
||||
[]api.ValueType{api.ValueTypeI32},
|
||||
func(ctx context.Context, mod api.Module, stack []uint64) {
|
||||
event, ok := readWASMBytes(mod, uint32(stack[0]), uint32(stack[1]))
|
||||
if !ok {
|
||||
stack[0] = wasmStatus(statusInvalid)
|
||||
return
|
||||
}
|
||||
payload, ok := readWASMBytes(mod, uint32(stack[2]), uint32(stack[3]))
|
||||
if !ok {
|
||||
stack[0] = wasmStatus(statusInvalid)
|
||||
return
|
||||
}
|
||||
if p.send != nil {
|
||||
p.send(string(event), payload)
|
||||
}
|
||||
stack[0] = 0
|
||||
})
|
||||
export("overlord_host_info",
|
||||
[]api.ValueType{api.ValueTypeI32, api.ValueTypeI32},
|
||||
[]api.ValueType{api.ValueTypeI32},
|
||||
func(ctx context.Context, mod api.Module, stack []uint64) {
|
||||
stack[0] = wasmStatus(writeWASMOutput(mod, uint32(stack[0]), uint32(stack[1]), p.hostInfo))
|
||||
})
|
||||
|
||||
export("overlord_fs_stat", fsReadParams(), []api.ValueType{api.ValueTypeI32}, p.fsStat)
|
||||
export("overlord_fs_list", fsReadParams(), []api.ValueType{api.ValueTypeI32}, p.fsList)
|
||||
export("overlord_fs_read", fsReadParams(), []api.ValueType{api.ValueTypeI32}, p.fsRead)
|
||||
export("overlord_fs_write",
|
||||
[]api.ValueType{api.ValueTypeI32, api.ValueTypeI32, api.ValueTypeI32, api.ValueTypeI32, api.ValueTypeI32, api.ValueTypeI32},
|
||||
[]api.ValueType{api.ValueTypeI32},
|
||||
p.fsWrite)
|
||||
export("overlord_fs_delete",
|
||||
[]api.ValueType{api.ValueTypeI32, api.ValueTypeI32, api.ValueTypeI32, api.ValueTypeI32},
|
||||
[]api.ValueType{api.ValueTypeI32},
|
||||
p.fsDelete)
|
||||
export("overlord_fs_mkdir",
|
||||
[]api.ValueType{api.ValueTypeI32, api.ValueTypeI32, api.ValueTypeI32, api.ValueTypeI32},
|
||||
[]api.ValueType{api.ValueTypeI32},
|
||||
p.fsMkdir)
|
||||
|
||||
_, err := builder.Instantiate(p.ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
func fsReadParams() []api.ValueType {
|
||||
return []api.ValueType{
|
||||
api.ValueTypeI32, api.ValueTypeI32, // bucket
|
||||
api.ValueTypeI32, api.ValueTypeI32, // path
|
||||
api.ValueTypeI32, api.ValueTypeI32, // output buffer
|
||||
}
|
||||
}
|
||||
|
||||
func (p *wasmPlugin) Load(send func(string, []byte), hostInfo []byte) error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.send = send
|
||||
p.hostInfo = append(p.hostInfo[:0], hostInfo...)
|
||||
return p.callBytes("overlord_on_load", hostInfo)
|
||||
}
|
||||
|
||||
func (p *wasmPlugin) Event(event string, payload []byte) error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
eventPtr, err := p.writeGuestBytes([]byte(event))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payloadPtr, err := p.writeGuestBytes(payload)
|
||||
if err != nil {
|
||||
p.freeGuestBytes(eventPtr, uint32(len(event)))
|
||||
return err
|
||||
}
|
||||
fn := p.module.ExportedFunction("overlord_on_event")
|
||||
if fn == nil {
|
||||
p.freeGuestBytes(eventPtr, uint32(len(event)))
|
||||
p.freeGuestBytes(payloadPtr, uint32(len(payload)))
|
||||
return errors.New("missing export: overlord_on_event")
|
||||
}
|
||||
results, err := fn.Call(p.ctx, uint64(eventPtr), uint64(len(event)), uint64(payloadPtr), uint64(len(payload)))
|
||||
p.freeGuestBytes(eventPtr, uint32(len(event)))
|
||||
p.freeGuestBytes(payloadPtr, uint32(len(payload)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(results) > 0 && int32(uint32(results[0])) != 0 {
|
||||
return fmt.Errorf("overlord_on_event returned %d", int32(uint32(results[0])))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *wasmPlugin) Unload() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.module == nil {
|
||||
return
|
||||
}
|
||||
if fn := p.module.ExportedFunction("overlord_on_unload"); fn != nil {
|
||||
_, _ = fn.Call(p.ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *wasmPlugin) Close() error {
|
||||
p.Unload()
|
||||
p.cancel()
|
||||
if p.runtime != nil {
|
||||
return p.runtime.Close(context.Background())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *wasmPlugin) Runtime() string {
|
||||
return "wasm"
|
||||
}
|
||||
|
||||
func (p *wasmPlugin) callBytes(export string, data []byte) error {
|
||||
ptr, err := p.writeGuestBytes(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer p.freeGuestBytes(ptr, uint32(len(data)))
|
||||
fn := p.module.ExportedFunction(export)
|
||||
if fn == nil {
|
||||
return fmt.Errorf("missing export: %s", export)
|
||||
}
|
||||
results, err := fn.Call(p.ctx, uint64(ptr), uint64(len(data)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(results) > 0 && int32(uint32(results[0])) != 0 {
|
||||
return fmt.Errorf("%s returned %d", export, int32(uint32(results[0])))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *wasmPlugin) writeGuestBytes(data []byte) (uint32, error) {
|
||||
alloc := p.module.ExportedFunction("overlord_alloc")
|
||||
if alloc == nil {
|
||||
return 0, errors.New("missing export: overlord_alloc")
|
||||
}
|
||||
results, err := alloc.Call(p.ctx, uint64(len(data)))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(results) == 0 || uint32(results[0]) == 0 {
|
||||
return 0, errors.New("overlord_alloc returned null")
|
||||
}
|
||||
ptr := uint32(results[0])
|
||||
if len(data) > 0 && !p.module.Memory().Write(ptr, data) {
|
||||
p.freeGuestBytes(ptr, uint32(len(data)))
|
||||
return 0, errors.New("wasm memory write failed")
|
||||
}
|
||||
return ptr, nil
|
||||
}
|
||||
|
||||
func (p *wasmPlugin) freeGuestBytes(ptr uint32, size uint32) {
|
||||
if ptr == 0 {
|
||||
return
|
||||
}
|
||||
if free := p.module.ExportedFunction("overlord_free"); free != nil {
|
||||
_, _ = free.Call(p.ctx, uint64(ptr), uint64(size))
|
||||
}
|
||||
}
|
||||
|
||||
type wasmFileStat struct {
|
||||
Path string `json:"path"`
|
||||
IsDir bool `json:"isDir"`
|
||||
Size int64 `json:"size"`
|
||||
ModTime int64 `json:"modTime"`
|
||||
Mode string `json:"mode"`
|
||||
}
|
||||
|
||||
func (p *wasmPlugin) fsStat(ctx context.Context, mod api.Module, stack []uint64) {
|
||||
target, status := p.resolveFromStack(mod, stack, "read")
|
||||
if status != statusOK {
|
||||
stack[0] = wasmStatus(status)
|
||||
return
|
||||
}
|
||||
st, err := os.Stat(target)
|
||||
if err != nil {
|
||||
stack[0] = wasmStatus(statusNotFound)
|
||||
return
|
||||
}
|
||||
info := wasmFileStat{Path: target, IsDir: st.IsDir(), Size: st.Size(), ModTime: st.ModTime().UnixMilli(), Mode: st.Mode().String()}
|
||||
p.writeJSONResult(mod, stack, info)
|
||||
}
|
||||
|
||||
func (p *wasmPlugin) fsList(ctx context.Context, mod api.Module, stack []uint64) {
|
||||
target, status := p.resolveFromStack(mod, stack, "list")
|
||||
if status != statusOK {
|
||||
stack[0] = wasmStatus(status)
|
||||
return
|
||||
}
|
||||
entries, err := os.ReadDir(target)
|
||||
if err != nil {
|
||||
stack[0] = wasmStatus(statusIO)
|
||||
return
|
||||
}
|
||||
out := make([]wasmFileStat, 0, len(entries))
|
||||
for _, ent := range entries {
|
||||
st, err := ent.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, wasmFileStat{Path: ent.Name(), IsDir: ent.IsDir(), Size: st.Size(), ModTime: st.ModTime().UnixMilli(), Mode: st.Mode().String()})
|
||||
}
|
||||
p.writeJSONResult(mod, stack, out)
|
||||
}
|
||||
|
||||
func (p *wasmPlugin) fsRead(ctx context.Context, mod api.Module, stack []uint64) {
|
||||
target, status := p.resolveFromStack(mod, stack, "read")
|
||||
if status != statusOK {
|
||||
stack[0] = wasmStatus(status)
|
||||
return
|
||||
}
|
||||
st, err := os.Stat(target)
|
||||
if err != nil || st.IsDir() {
|
||||
stack[0] = wasmStatus(statusNotFound)
|
||||
return
|
||||
}
|
||||
if st.Size() > maxWASMFileReadBytes {
|
||||
stack[0] = wasmStatus(statusTooLarge)
|
||||
return
|
||||
}
|
||||
data, err := os.ReadFile(target)
|
||||
if err != nil {
|
||||
stack[0] = wasmStatus(statusIO)
|
||||
return
|
||||
}
|
||||
stack[0] = wasmStatus(writeWASMOutput(mod, uint32(stack[4]), uint32(stack[5]), data))
|
||||
}
|
||||
|
||||
func (p *wasmPlugin) fsWrite(ctx context.Context, mod api.Module, stack []uint64) {
|
||||
target, status := p.resolvePath(mod, uint32(stack[0]), uint32(stack[1]), uint32(stack[2]), uint32(stack[3]), "write")
|
||||
if status != statusOK {
|
||||
stack[0] = wasmStatus(status)
|
||||
return
|
||||
}
|
||||
data, ok := readWASMBytes(mod, uint32(stack[4]), uint32(stack[5]))
|
||||
if !ok {
|
||||
stack[0] = wasmStatus(statusInvalid)
|
||||
return
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o700); err != nil {
|
||||
stack[0] = wasmStatus(statusIO)
|
||||
return
|
||||
}
|
||||
if err := os.WriteFile(target, data, 0o600); err != nil {
|
||||
stack[0] = wasmStatus(statusIO)
|
||||
return
|
||||
}
|
||||
stack[0] = 0
|
||||
}
|
||||
|
||||
func (p *wasmPlugin) fsDelete(ctx context.Context, mod api.Module, stack []uint64) {
|
||||
target, status := p.resolvePath(mod, uint32(stack[0]), uint32(stack[1]), uint32(stack[2]), uint32(stack[3]), "delete")
|
||||
if status != statusOK {
|
||||
stack[0] = wasmStatus(status)
|
||||
return
|
||||
}
|
||||
if err := os.RemoveAll(target); err != nil {
|
||||
stack[0] = wasmStatus(statusIO)
|
||||
return
|
||||
}
|
||||
stack[0] = 0
|
||||
}
|
||||
|
||||
func (p *wasmPlugin) fsMkdir(ctx context.Context, mod api.Module, stack []uint64) {
|
||||
target, status := p.resolvePath(mod, uint32(stack[0]), uint32(stack[1]), uint32(stack[2]), uint32(stack[3]), "mkdir")
|
||||
if status != statusOK {
|
||||
stack[0] = wasmStatus(status)
|
||||
return
|
||||
}
|
||||
if err := os.MkdirAll(target, 0o700); err != nil {
|
||||
stack[0] = wasmStatus(statusIO)
|
||||
return
|
||||
}
|
||||
stack[0] = 0
|
||||
}
|
||||
|
||||
func (p *wasmPlugin) resolveFromStack(mod api.Module, stack []uint64, op string) (string, int32) {
|
||||
return p.resolvePath(mod, uint32(stack[0]), uint32(stack[1]), uint32(stack[2]), uint32(stack[3]), op)
|
||||
}
|
||||
|
||||
func (p *wasmPlugin) resolvePath(mod api.Module, bucketPtr, bucketLen, pathPtr, pathLen uint32, op string) (string, int32) {
|
||||
bucketBytes, ok := readWASMBytes(mod, bucketPtr, bucketLen)
|
||||
if !ok {
|
||||
return "", statusInvalid
|
||||
}
|
||||
pathBytes, ok := readWASMBytes(mod, pathPtr, pathLen)
|
||||
if !ok {
|
||||
return "", statusInvalid
|
||||
}
|
||||
bucket := string(bucketBytes)
|
||||
rel := string(pathBytes)
|
||||
if !p.acl[bucket][op] {
|
||||
return "", statusDenied
|
||||
}
|
||||
return resolvePluginBucketPath(p.manifest.ID, bucket, rel)
|
||||
}
|
||||
|
||||
func resolvePluginBucketPath(pluginID, bucket, rel string) (string, int32) {
|
||||
if strings.ContainsRune(rel, 0) {
|
||||
return "", statusInvalid
|
||||
}
|
||||
if bucket == "fullDisk" {
|
||||
if rel == "" {
|
||||
if filepath.Separator == '\\' {
|
||||
return filepath.VolumeName(os.TempDir()) + string(filepath.Separator), statusOK
|
||||
}
|
||||
return string(filepath.Separator), statusOK
|
||||
}
|
||||
if !filepath.IsAbs(rel) {
|
||||
if filepath.Separator == '\\' {
|
||||
rel = filepath.VolumeName(os.TempDir()) + string(filepath.Separator) + rel
|
||||
} else {
|
||||
rel = string(filepath.Separator) + rel
|
||||
}
|
||||
}
|
||||
return filepath.Clean(rel), statusOK
|
||||
}
|
||||
root, ok := bucketRoot(pluginID, bucket)
|
||||
if !ok {
|
||||
return "", statusDenied
|
||||
}
|
||||
if filepath.IsAbs(rel) || strings.HasPrefix(rel, string(filepath.Separator)) || strings.HasPrefix(rel, "/") || strings.HasPrefix(rel, `\`) {
|
||||
return "", statusDenied
|
||||
}
|
||||
cleanRel := strings.TrimPrefix(filepath.Clean(string(filepath.Separator)+rel), string(filepath.Separator))
|
||||
target := filepath.Join(root, cleanRel)
|
||||
resolvedRoot, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
return "", statusIO
|
||||
}
|
||||
resolvedTarget, err := filepath.Abs(target)
|
||||
if err != nil {
|
||||
return "", statusIO
|
||||
}
|
||||
r, err := filepath.Rel(resolvedRoot, resolvedTarget)
|
||||
if err != nil || r == ".." || strings.HasPrefix(r, ".."+string(filepath.Separator)) {
|
||||
return "", statusDenied
|
||||
}
|
||||
return resolvedTarget, statusOK
|
||||
}
|
||||
|
||||
func bucketRoot(pluginID, bucket string) (string, bool) {
|
||||
home, _ := os.UserHomeDir()
|
||||
switch bucket {
|
||||
case "home":
|
||||
return home, home != ""
|
||||
case "desktop":
|
||||
return filepath.Join(home, "Desktop"), home != ""
|
||||
case "documents":
|
||||
return filepath.Join(home, "Documents"), home != ""
|
||||
case "downloads":
|
||||
return filepath.Join(home, "Downloads"), home != ""
|
||||
case "temp":
|
||||
return os.TempDir(), true
|
||||
case "appData":
|
||||
dir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
return filepath.Join(dir, "Overlord"), true
|
||||
case "pluginData":
|
||||
dir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
root := filepath.Join(dir, "Overlord", "plugins", pluginID)
|
||||
_ = os.MkdirAll(root, 0o700)
|
||||
return root, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func buildFileACL(needs PluginNeeds) map[string]map[string]bool {
|
||||
acl := map[string]map[string]bool{}
|
||||
for _, need := range needs.Files {
|
||||
if acl[need.Bucket] == nil {
|
||||
acl[need.Bucket] = map[string]bool{}
|
||||
}
|
||||
for _, op := range need.Access {
|
||||
acl[need.Bucket][op] = true
|
||||
}
|
||||
}
|
||||
return acl
|
||||
}
|
||||
|
||||
func (p *wasmPlugin) writeJSONResult(mod api.Module, stack []uint64, v interface{}) {
|
||||
data, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
stack[0] = wasmStatus(statusIO)
|
||||
return
|
||||
}
|
||||
stack[0] = wasmStatus(writeWASMOutput(mod, uint32(stack[4]), uint32(stack[5]), data))
|
||||
}
|
||||
|
||||
func readWASMBytes(mod api.Module, ptr, size uint32) ([]byte, bool) {
|
||||
if size == 0 {
|
||||
return nil, true
|
||||
}
|
||||
data, ok := mod.Memory().Read(ptr, size)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
out := make([]byte, len(data))
|
||||
copy(out, data)
|
||||
return out, true
|
||||
}
|
||||
|
||||
func writeWASMOutput(mod api.Module, ptr, size uint32, data []byte) int32 {
|
||||
if uint32(len(data)) > size {
|
||||
return statusTooSmall
|
||||
}
|
||||
if len(data) > 0 && !mod.Memory().Write(ptr, data) {
|
||||
return statusInvalid
|
||||
}
|
||||
return int32(len(data))
|
||||
}
|
||||
|
||||
const (
|
||||
statusOK int32 = 0
|
||||
statusDenied int32 = -1
|
||||
statusInvalid int32 = -2
|
||||
statusNotFound int32 = -3
|
||||
statusTooSmall int32 = -4
|
||||
statusTooLarge int32 = -5
|
||||
statusIO int32 = -6
|
||||
)
|
||||
|
||||
func wasmStatus(status int32) uint64 {
|
||||
return uint64(uint32(status))
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuildFileACL(t *testing.T) {
|
||||
acl := buildFileACL(PluginNeeds{Files: []PluginFileNeed{
|
||||
{Bucket: "downloads", Access: []string{"read", "list"}},
|
||||
}})
|
||||
if !acl["downloads"]["read"] || !acl["downloads"]["list"] {
|
||||
t.Fatalf("expected read/list permissions: %#v", acl)
|
||||
}
|
||||
if acl["downloads"]["write"] {
|
||||
t.Fatalf("did not expect write permission")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteWASMOutputStatusCodes(t *testing.T) {
|
||||
status := statusTooSmall
|
||||
if wasmStatus(statusTooSmall) != uint64(uint32(status)) {
|
||||
t.Fatalf("negative status should be encoded as wasm i32")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePluginBucketPathRejectsTraversal(t *testing.T) {
|
||||
target, status := resolvePluginBucketPath("demo", "pluginData", ".."+string(filepath.Separator)+"escape.txt")
|
||||
if status != statusOK {
|
||||
t.Fatalf("expected cleaned pluginData path, got status %d", status)
|
||||
}
|
||||
if strings.Contains(target, "..") {
|
||||
t.Fatalf("expected traversal to be cleaned, got %q", target)
|
||||
}
|
||||
|
||||
_, status = resolvePluginBucketPath("demo", "downloads", string(filepath.Separator)+"absolute.txt")
|
||||
if status != statusDenied {
|
||||
t.Fatalf("expected absolute path to be denied for scoped bucket, got %d", status)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user