Files
Soda 2a1f393ad2 fix(skills): enforce portable supporting-file paths (#7743)
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.
2026-10-26 13:49:05 +08:00

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
}