265 lines
7.0 KiB
Go
265 lines
7.0 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
const (
|
|
fsDefaultMaxBytes = 64 * 1024
|
|
fsHardMaxBytes = 1024 * 1024
|
|
)
|
|
|
|
// isPathAllowed valida path contra una lista de globs declarados en cap.PathsAllowed.
|
|
// Aplica EvalSymlinks (best-effort) y filepath.Clean para neutralizar ../ + symlinks.
|
|
// Soporta el sufijo "/**" para match recursivo bajo un prefijo.
|
|
func isPathAllowed(path string, allowed []string) bool {
|
|
if len(allowed) == 0 {
|
|
return false
|
|
}
|
|
abs, err := filepath.Abs(path)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
abs = filepath.Clean(abs)
|
|
|
|
// Resolve symlinks si el target existe; si no existe (write a archivo nuevo)
|
|
// resolvemos el directorio padre, no romper.
|
|
resolved := abs
|
|
if real, err := filepath.EvalSymlinks(abs); err == nil {
|
|
resolved = real
|
|
} else {
|
|
// Para paths no existentes (write): intenta resolver el dir padre.
|
|
dir := filepath.Dir(abs)
|
|
base := filepath.Base(abs)
|
|
if realDir, err := filepath.EvalSymlinks(dir); err == nil {
|
|
resolved = filepath.Join(realDir, base)
|
|
}
|
|
}
|
|
resolved = filepath.Clean(resolved)
|
|
|
|
for _, pat := range allowed {
|
|
patClean := filepath.Clean(pat)
|
|
// Soporte sufijo /** recursivo
|
|
if strings.HasSuffix(patClean, string(filepath.Separator)+"**") || strings.HasSuffix(patClean, "/**") {
|
|
prefix := strings.TrimSuffix(patClean, "/**")
|
|
prefix = strings.TrimSuffix(prefix, string(filepath.Separator)+"**")
|
|
prefix = filepath.Clean(prefix)
|
|
if resolved == prefix || strings.HasPrefix(resolved, prefix+string(filepath.Separator)) {
|
|
return true
|
|
}
|
|
continue
|
|
}
|
|
// Si patron es un dir y resolved esta debajo, allow (caso /etc/)
|
|
if strings.HasSuffix(pat, "/") {
|
|
prefix := filepath.Clean(pat)
|
|
if strings.HasPrefix(resolved, prefix+string(filepath.Separator)) || resolved == prefix {
|
|
return true
|
|
}
|
|
}
|
|
// Match exacto o glob simple
|
|
if match, _ := filepath.Match(patClean, resolved); match {
|
|
return true
|
|
}
|
|
if resolved == patClean {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// mapIntField extrae un int de map[string]any tolerando float64 (JSON default).
|
|
func mapIntField(m map[string]any, key string, def int) int {
|
|
if v, ok := m[key]; ok && v != nil {
|
|
switch t := v.(type) {
|
|
case float64:
|
|
return int(t)
|
|
case int:
|
|
return t
|
|
case int64:
|
|
return int(t)
|
|
case json.Number:
|
|
n, err := t.Int64()
|
|
if err == nil {
|
|
return int(n)
|
|
}
|
|
}
|
|
}
|
|
return def
|
|
}
|
|
|
|
func mapStringField(m map[string]any, key string) string {
|
|
if v, ok := m[key]; ok && v != nil {
|
|
if s, ok := v.(string); ok {
|
|
return s
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// runFsRead lee un archivo y devuelve content_b64 + meta. Trunca a max_bytes.
|
|
func runFsRead(cap *Capability, args map[string]any) (any, int, error) {
|
|
path := mapStringField(args, "path")
|
|
if path == "" {
|
|
return nil, -1, fmt.Errorf("path required")
|
|
}
|
|
maxBytes := mapIntField(args, "max_bytes", fsDefaultMaxBytes)
|
|
if maxBytes <= 0 {
|
|
maxBytes = fsDefaultMaxBytes
|
|
}
|
|
if maxBytes > fsHardMaxBytes {
|
|
maxBytes = fsHardMaxBytes
|
|
}
|
|
if !isPathAllowed(path, cap.PathsAllowed) {
|
|
return nil, -1, fmt.Errorf("path not allowed by manifest: %s", path)
|
|
}
|
|
st, err := os.Stat(path)
|
|
if err != nil {
|
|
return nil, -1, fmt.Errorf("stat: %w", err)
|
|
}
|
|
if st.IsDir() {
|
|
return nil, -1, fmt.Errorf("path is a directory")
|
|
}
|
|
f, err := os.Open(path) // #nosec G304 — whitelisted above
|
|
if err != nil {
|
|
return nil, -1, fmt.Errorf("open: %w", err)
|
|
}
|
|
defer f.Close()
|
|
buf := make([]byte, maxBytes)
|
|
n, err := f.Read(buf)
|
|
if err != nil && err.Error() != "EOF" && n == 0 {
|
|
return nil, -1, fmt.Errorf("read: %w", err)
|
|
}
|
|
truncated := int64(n) < st.Size()
|
|
return map[string]any{
|
|
"content_b64": base64.StdEncoding.EncodeToString(buf[:n]),
|
|
"size": st.Size(),
|
|
"bytes_read": n,
|
|
"mtime": st.ModTime().Unix(),
|
|
"truncated": truncated,
|
|
"path": path,
|
|
}, 0, nil
|
|
}
|
|
|
|
// runFsWrite escribe content_b64 a path. Mkdir parent, default mode 0644.
|
|
func runFsWrite(cap *Capability, args map[string]any) (any, int, error) {
|
|
path := mapStringField(args, "path")
|
|
if path == "" {
|
|
return nil, -1, fmt.Errorf("path required")
|
|
}
|
|
contentB64 := mapStringField(args, "content_b64")
|
|
if contentB64 == "" {
|
|
// Soporte fallback "content" plano
|
|
if c := mapStringField(args, "content"); c != "" {
|
|
contentB64 = base64.StdEncoding.EncodeToString([]byte(c))
|
|
}
|
|
}
|
|
mode := mapIntField(args, "mode", 0644)
|
|
if !isPathAllowed(path, cap.PathsAllowed) {
|
|
return nil, -1, fmt.Errorf("path not allowed by manifest: %s", path)
|
|
}
|
|
data, err := base64.StdEncoding.DecodeString(contentB64)
|
|
if err != nil {
|
|
return nil, -1, fmt.Errorf("invalid content_b64: %w", err)
|
|
}
|
|
if int64(len(data)) > fsHardMaxBytes {
|
|
return nil, -1, fmt.Errorf("content too large (>1MB)")
|
|
}
|
|
parent := filepath.Dir(path)
|
|
if err := os.MkdirAll(parent, 0755); err != nil {
|
|
return nil, -1, fmt.Errorf("mkdir parent: %w", err)
|
|
}
|
|
if err := os.WriteFile(path, data, os.FileMode(mode)); err != nil { // #nosec G306
|
|
return nil, -1, fmt.Errorf("write: %w", err)
|
|
}
|
|
return map[string]any{
|
|
"path": path,
|
|
"bytes_written": len(data),
|
|
}, 0, nil
|
|
}
|
|
|
|
// runFsList lista un directorio (no recursivo). glob opcional filtra entries.
|
|
func runFsList(cap *Capability, args map[string]any) (any, int, error) {
|
|
dir := mapStringField(args, "dir")
|
|
if dir == "" {
|
|
return nil, -1, fmt.Errorf("dir required")
|
|
}
|
|
glob := mapStringField(args, "glob")
|
|
if !isPathAllowed(dir, cap.PathsAllowed) {
|
|
return nil, -1, fmt.Errorf("dir not allowed by manifest: %s", dir)
|
|
}
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
return nil, -1, fmt.Errorf("readdir: %w", err)
|
|
}
|
|
out := []map[string]any{}
|
|
for _, e := range entries {
|
|
if glob != "" {
|
|
if m, _ := filepath.Match(glob, e.Name()); !m {
|
|
continue
|
|
}
|
|
}
|
|
info, ierr := e.Info()
|
|
kind := "file"
|
|
var size int64
|
|
var mtime int64
|
|
if ierr == nil {
|
|
if info.Mode()&os.ModeSymlink != 0 {
|
|
kind = "symlink"
|
|
} else if info.IsDir() {
|
|
kind = "dir"
|
|
}
|
|
size = info.Size()
|
|
mtime = info.ModTime().Unix()
|
|
}
|
|
out = append(out, map[string]any{
|
|
"name": e.Name(),
|
|
"kind": kind,
|
|
"size": size,
|
|
"mtime": mtime,
|
|
})
|
|
}
|
|
sort.Slice(out, func(i, j int) bool {
|
|
return out[i]["name"].(string) < out[j]["name"].(string)
|
|
})
|
|
return map[string]any{
|
|
"dir": dir,
|
|
"entries": out,
|
|
"count": len(out),
|
|
}, 0, nil
|
|
}
|
|
|
|
// runFsStat devuelve metadata de un archivo o directorio.
|
|
func runFsStat(cap *Capability, args map[string]any) (any, int, error) {
|
|
path := mapStringField(args, "path")
|
|
if path == "" {
|
|
return nil, -1, fmt.Errorf("path required")
|
|
}
|
|
if !isPathAllowed(path, cap.PathsAllowed) {
|
|
return nil, -1, fmt.Errorf("path not allowed by manifest: %s", path)
|
|
}
|
|
st, err := os.Lstat(path)
|
|
if err != nil {
|
|
return nil, -1, fmt.Errorf("stat: %w", err)
|
|
}
|
|
kind := "file"
|
|
if st.IsDir() {
|
|
kind = "dir"
|
|
} else if st.Mode()&os.ModeSymlink != 0 {
|
|
kind = "symlink"
|
|
}
|
|
res := map[string]any{
|
|
"path": path,
|
|
"kind": kind,
|
|
"size": st.Size(),
|
|
"mode": fmt.Sprintf("%#o", st.Mode().Perm()),
|
|
"mtime": st.ModTime().Unix(),
|
|
}
|
|
return res, 0, nil
|
|
}
|