Files
device_agent/capability_fs.go
T
2026-05-30 17:28:38 +02:00

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
}