mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-08 01:16:45 +08:00
Use one slash-delimited path contract for skill authoring endpoints and daemon bundle validation. Cover canonical path handling and all public file-write routes with regression tests.
186 lines
4.2 KiB
Go
186 lines
4.2 KiB
Go
package daemon
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
|
|
skillpkg "github.com/multica-ai/multica/server/internal/skill"
|
|
"github.com/multica-ai/multica/server/pkg/skillbundle"
|
|
)
|
|
|
|
type SkillBundleCache struct {
|
|
root string
|
|
rename func(string, string) error
|
|
removeAll func(string) error
|
|
mu sync.Mutex
|
|
locks map[string]*sync.Mutex
|
|
}
|
|
|
|
func NewSkillBundleCache(root string) *SkillBundleCache {
|
|
return &SkillBundleCache{
|
|
root: root,
|
|
rename: os.Rename,
|
|
removeAll: os.RemoveAll,
|
|
locks: make(map[string]*sync.Mutex),
|
|
}
|
|
}
|
|
|
|
func (c *SkillBundleCache) Load(workspaceID string, ref SkillRefData) (SkillData, bool) {
|
|
if c == nil || c.root == "" {
|
|
return SkillData{}, false
|
|
}
|
|
keyPath := c.bundlePath(workspaceID, ref)
|
|
data, err := os.ReadFile(keyPath)
|
|
if err != nil {
|
|
return SkillData{}, false
|
|
}
|
|
var bundle SkillData
|
|
if err := json.Unmarshal(data, &bundle); err != nil || !validateSkillBundle(ref, bundle) {
|
|
_ = os.Remove(keyPath)
|
|
return SkillData{}, false
|
|
}
|
|
return bundle, true
|
|
}
|
|
|
|
func (c *SkillBundleCache) Store(workspaceID string, bundle SkillData) error {
|
|
if c == nil || c.root == "" {
|
|
return nil
|
|
}
|
|
ref := SkillRefData{ID: bundle.ID, Source: bundle.Source, Hash: bundle.Hash}
|
|
dir := filepath.Dir(c.bundlePath(workspaceID, ref))
|
|
tmp, err := os.MkdirTemp(filepath.Dir(dir), ".bundle-*")
|
|
if err != nil {
|
|
if mkErr := os.MkdirAll(filepath.Dir(dir), 0o755); mkErr != nil {
|
|
return mkErr
|
|
}
|
|
tmp, err = os.MkdirTemp(filepath.Dir(dir), ".bundle-*")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
defer os.RemoveAll(tmp)
|
|
|
|
data, err := json.Marshal(bundle)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := os.WriteFile(filepath.Join(tmp, "bundle.json"), data, 0o644); err != nil {
|
|
return err
|
|
}
|
|
if err := c.removeAll(dir); err != nil {
|
|
return err
|
|
}
|
|
if err := c.rename(tmp, dir); err != nil {
|
|
if !errors.Is(err, fs.ErrExist) {
|
|
return err
|
|
}
|
|
if err := c.removeAll(dir); err != nil {
|
|
return err
|
|
}
|
|
if err := c.rename(tmp, dir); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *SkillBundleCache) WithRefLock(workspaceID string, ref SkillRefData, fn func() error) error {
|
|
if c == nil {
|
|
return fn()
|
|
}
|
|
key := workspaceID + "\x00" + ref.Source + "\x00" + ref.ID + "\x00" + ref.Hash
|
|
lock := c.lockForKey(key)
|
|
lock.Lock()
|
|
defer lock.Unlock()
|
|
return fn()
|
|
}
|
|
|
|
func (c *SkillBundleCache) lockForKey(key string) *sync.Mutex {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
if lock := c.locks[key]; lock != nil {
|
|
return lock
|
|
}
|
|
lock := &sync.Mutex{}
|
|
c.locks[key] = lock
|
|
return lock
|
|
}
|
|
|
|
func (c *SkillBundleCache) bundlePath(workspaceID string, ref SkillRefData) string {
|
|
return filepath.Join(
|
|
c.root,
|
|
safeCacheSegment(workspaceID),
|
|
safeCacheSegment(ref.Source),
|
|
safeCacheSegment(ref.ID),
|
|
safeCacheSegment(ref.Hash),
|
|
"bundle.json",
|
|
)
|
|
}
|
|
|
|
func validateSkillBundle(ref SkillRefData, bundle SkillData) bool {
|
|
if bundle.ID != ref.ID || bundle.Source != ref.Source || bundle.Hash != ref.Hash {
|
|
return false
|
|
}
|
|
if len(bundle.Files) != ref.FileCount {
|
|
return false
|
|
}
|
|
files := make([]skillbundle.File, 0, len(bundle.Files))
|
|
for _, file := range bundle.Files {
|
|
if !safeSkillFilePath(file.Path) {
|
|
return false
|
|
}
|
|
files = append(files, skillbundle.File{Path: file.Path, Content: file.Content})
|
|
}
|
|
manifest := skillbundle.BuildManifest(skillbundle.Skill{
|
|
ID: bundle.ID,
|
|
Source: bundle.Source,
|
|
Name: bundle.Name,
|
|
Description: bundle.Description,
|
|
Content: bundle.Content,
|
|
Files: files,
|
|
})
|
|
if manifest.Hash != ref.Hash {
|
|
return false
|
|
}
|
|
if ref.SizeBytes > 0 && manifest.SizeBytes != ref.SizeBytes {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func safeSkillFilePath(p string) bool {
|
|
return skillpkg.IsSafeFilePath(p)
|
|
}
|
|
|
|
func safeCacheSegment(s string) string {
|
|
var b strings.Builder
|
|
if s == "" {
|
|
return "_"
|
|
}
|
|
for _, r := range s {
|
|
switch {
|
|
case r >= 'a' && r <= 'z':
|
|
b.WriteRune(r)
|
|
case r >= 'A' && r <= 'Z':
|
|
b.WriteRune(r)
|
|
case r >= '0' && r <= '9':
|
|
b.WriteRune(r)
|
|
case r == '-' || r == '_' || r == '.':
|
|
b.WriteRune(r)
|
|
default:
|
|
b.WriteByte('_')
|
|
}
|
|
}
|
|
out := b.String()
|
|
if out == "." || out == ".." {
|
|
return fmt.Sprintf("_%s", out)
|
|
}
|
|
return out
|
|
}
|