feat: Add file manager package — VueFinder-compatible NATS request-reply handler
All checks were successful
Test Asgard Runner / test (push) Successful in 2s

Implements companion-agent/internal/filemanager with full installDir jail
enforcement (Clean + EvalSymlinks + HasPrefix on every path). Handles all
VueFinder operations: list, delete, rename, copy, move, mkdir, mkfile,
search, preview, save, upload. Wires into daemon.go as a 6th NATS
subscription on corrosion.{license_id}.files.cmd.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell
2026-02-21 16:11:59 -05:00
parent fee0ae2420
commit e9f9b449b1
4 changed files with 1035 additions and 0 deletions

View File

@@ -10,6 +10,7 @@ import (
"github.com/nats-io/nats.go"
"github.com/vigilcyber/corrosion-companion/internal/deploy"
"github.com/vigilcyber/corrosion-companion/internal/filemanager"
"github.com/vigilcyber/corrosion-companion/internal/files"
"github.com/vigilcyber/corrosion-companion/internal/process"
"github.com/vigilcyber/corrosion-companion/internal/update"
@@ -32,6 +33,7 @@ type Daemon struct {
cfg *DaemonConfig
gameServer *process.GameServer
fileOps *files.Operations
fm *filemanager.FileManager
updater *update.Updater
deployer *deploy.Deployer
subscriptions []*nats.Subscription
@@ -73,6 +75,7 @@ func (a *gameServerAdapter) UpdatePath(path string) {
func NewDaemon(nc *nats.Conn, cfg *DaemonConfig) (*Daemon, error) {
gameServer := process.NewGameServer(cfg.GameServerPath, cfg.GameServerArgs)
fileOps := files.NewOperations()
fm := filemanager.New(cfg.InstallDir)
updater := update.NewUpdater(cfg.Version)
adapter := &gameServerAdapter{gs: gameServer, cfg: cfg}
deployer := deploy.NewDeployer(nc, cfg.LicenseID, cfg.InstallDir, adapter)
@@ -82,6 +85,7 @@ func NewDaemon(nc *nats.Conn, cfg *DaemonConfig) (*Daemon, error) {
cfg: cfg,
gameServer: gameServer,
fileOps: fileOps,
fm: fm,
updater: updater,
deployer: deployer,
}
@@ -118,6 +122,11 @@ func (d *Daemon) Run(ctx context.Context) error {
return fmt.Errorf("failed to subscribe to deploy commands: %w", err)
}
// Subscribe to file manager commands (VueFinder-compatible request-reply)
if err := d.subscribeFileManager(); err != nil {
return fmt.Errorf("failed to subscribe to file manager commands: %w", err)
}
log.Println("All subscriptions active")
// Start heartbeat ticker
@@ -338,6 +347,26 @@ func (d *Daemon) subscribeDeployCommand() error {
return nil
}
// subscribeFileManager subscribes to the VueFinder-compatible file manager
// command subject. All operations (list, delete, rename, copy, move, mkdir,
// mkfile, search, preview, save, upload) are handled by the filemanager package
// which enforces the installDir jail on every path.
func (d *Daemon) subscribeFileManager() error {
subject := fmt.Sprintf("corrosion.%s.files.cmd", d.cfg.LicenseID)
sub, err := d.nc.Subscribe(subject, func(msg *nats.Msg) {
d.fm.HandleNatsRequest(msg)
})
if err != nil {
return err
}
d.subscriptions = append(d.subscriptions, sub)
log.Printf("Subscribed to: %s", subject)
return nil
}
// handleFileOperation processes file operation requests
func (d *Daemon) handleFileOperation(msg *nats.Msg) {
// Parse common fields

View File

@@ -0,0 +1,735 @@
package filemanager
import (
"encoding/base64"
"fmt"
"io"
"io/fs"
"log"
"os"
"path/filepath"
"strings"
"time"
)
const (
// maxReadSize is the maximum file size allowed for GetContent (5 MB).
// This guards against accidentally reading large binary files into memory.
maxReadSize = 5 * 1024 * 1024
// maxSearchResults caps the number of results returned by Search.
maxSearchResults = 100
// storageName is the VueFinder storage identifier used in all path strings.
storageName = "server"
// adapterName is the VueFinder adapter field included in every list/search response.
adapterName = "local"
)
// FileManager handles sandboxed filesystem operations for the game server
// install directory. All operations are confined to installDir — any path
// that resolves outside that boundary is rejected with an error.
type FileManager struct {
// installDir is the absolute, symlink-resolved root of the jail.
// It is set once at construction and never changes.
installDir string
}
// New creates a FileManager jailed to installDir. The directory is cleaned and
// made absolute but NOT required to exist at construction time — the daemon may
// start before the install completes.
func New(installDir string) *FileManager {
// Clean and make absolute so comparisons are deterministic even if the
// caller passed a relative or un-normalised path.
abs, err := filepath.Abs(filepath.Clean(installDir))
if err != nil {
// Abs only fails on systems where os.Getwd() fails; fall back to raw value.
log.Printf("filemanager: warning: could not make installDir absolute (%v), using raw value", err)
abs = filepath.Clean(installDir)
}
return &FileManager{installDir: abs}
}
// ---------------------------------------------------------------------------
// Public API — one method per VueFinder operation
// ---------------------------------------------------------------------------
// List returns the contents of the directory identified by storagePath.
func (fm *FileManager) List(storagePath string) (*ListResponse, error) {
abs, rel, err := fm.parseAndResolve(storagePath)
if err != nil {
return nil, err
}
entries, err := os.ReadDir(abs)
if err != nil {
return nil, fmt.Errorf("cannot read directory: %w", err)
}
files, err := buildFileItems(entries, abs, rel, fm.installDir)
if err != nil {
return nil, err
}
return &ListResponse{
Adapter: adapterName,
Storages: []string{storageName},
Dirname: toStoragePath(rel),
Files: files,
}, nil
}
// Delete removes every item listed in items from within storagePath's directory.
// After deletion it returns a fresh listing of the parent directory.
func (fm *FileManager) Delete(storagePath string, items []string) (*ListResponse, error) {
parentAbs, parentRel, err := fm.parseAndResolve(storagePath)
if err != nil {
return nil, err
}
for _, item := range items {
itemAbs, _, err := fm.parseAndResolve(item)
if err != nil {
return nil, fmt.Errorf("invalid item path %q: %w", item, err)
}
if err := os.RemoveAll(itemAbs); err != nil {
return nil, fmt.Errorf("failed to delete %q: %w", item, err)
}
log.Printf("filemanager: deleted %s", itemAbs)
}
// Return a fresh listing of the parent so the frontend can update immediately.
entries, err := os.ReadDir(parentAbs)
if err != nil {
return nil, fmt.Errorf("cannot re-read directory after delete: %w", err)
}
files, err := buildFileItems(entries, parentAbs, parentRel, fm.installDir)
if err != nil {
return nil, err
}
return &ListResponse{
Adapter: adapterName,
Storages: []string{storageName},
Dirname: toStoragePath(parentRel),
Files: files,
}, nil
}
// Rename renames item inside storagePath to newName.
// newName must be a bare filename, not a path — slashes are rejected.
func (fm *FileManager) Rename(storagePath string, item string, newName string) (*ListResponse, error) {
if strings.ContainsAny(newName, "/\\") {
return nil, fmt.Errorf("new name must not contain path separators")
}
if newName == "" || newName == "." || newName == ".." {
return nil, fmt.Errorf("invalid new name %q", newName)
}
parentAbs, parentRel, err := fm.parseAndResolve(storagePath)
if err != nil {
return nil, err
}
itemAbs, _, err := fm.parseAndResolve(item)
if err != nil {
return nil, fmt.Errorf("invalid item path %q: %w", item, err)
}
destAbs := filepath.Join(parentAbs, newName)
// Verify the destination is also inside the jail before committing.
if err := fm.checkWithinJail(destAbs); err != nil {
return nil, fmt.Errorf("destination escapes jail: %w", err)
}
if err := os.Rename(itemAbs, destAbs); err != nil {
return nil, fmt.Errorf("rename failed: %w", err)
}
log.Printf("filemanager: renamed %s -> %s", itemAbs, destAbs)
entries, err := os.ReadDir(parentAbs)
if err != nil {
return nil, fmt.Errorf("cannot re-read directory after rename: %w", err)
}
files, err := buildFileItems(entries, parentAbs, parentRel, fm.installDir)
if err != nil {
return nil, err
}
return &ListResponse{
Adapter: adapterName,
Storages: []string{storageName},
Dirname: toStoragePath(parentRel),
Files: files,
}, nil
}
// CreateFolder creates a new directory named name inside storagePath.
func (fm *FileManager) CreateFolder(storagePath string, name string) (*ListResponse, error) {
if err := validateBareName(name); err != nil {
return nil, err
}
parentAbs, parentRel, err := fm.parseAndResolve(storagePath)
if err != nil {
return nil, err
}
newDirAbs := filepath.Join(parentAbs, name)
if err := fm.checkWithinJail(newDirAbs); err != nil {
return nil, fmt.Errorf("target escapes jail: %w", err)
}
if err := os.MkdirAll(newDirAbs, 0755); err != nil {
return nil, fmt.Errorf("failed to create directory: %w", err)
}
log.Printf("filemanager: created directory %s", newDirAbs)
entries, err := os.ReadDir(parentAbs)
if err != nil {
return nil, fmt.Errorf("cannot re-read directory after mkdir: %w", err)
}
files, err := buildFileItems(entries, parentAbs, parentRel, fm.installDir)
if err != nil {
return nil, err
}
return &ListResponse{
Adapter: adapterName,
Storages: []string{storageName},
Dirname: toStoragePath(parentRel),
Files: files,
}, nil
}
// CreateFile creates an empty file named name inside storagePath.
func (fm *FileManager) CreateFile(storagePath string, name string) (*ListResponse, error) {
if err := validateBareName(name); err != nil {
return nil, err
}
parentAbs, parentRel, err := fm.parseAndResolve(storagePath)
if err != nil {
return nil, err
}
newFileAbs := filepath.Join(parentAbs, name)
if err := fm.checkWithinJail(newFileAbs); err != nil {
return nil, fmt.Errorf("target escapes jail: %w", err)
}
f, err := os.OpenFile(newFileAbs, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644)
if err != nil {
return nil, fmt.Errorf("failed to create file: %w", err)
}
f.Close()
log.Printf("filemanager: created file %s", newFileAbs)
entries, err := os.ReadDir(parentAbs)
if err != nil {
return nil, fmt.Errorf("cannot re-read directory after mkfile: %w", err)
}
files, err := buildFileItems(entries, parentAbs, parentRel, fm.installDir)
if err != nil {
return nil, err
}
return &ListResponse{
Adapter: adapterName,
Storages: []string{storageName},
Dirname: toStoragePath(parentRel),
Files: files,
}, nil
}
// GetContent reads and returns the UTF-8 content of the file at storagePath.
// Reading is capped at maxReadSize (5 MB) to avoid loading large binaries.
func (fm *FileManager) GetContent(storagePath string) (string, error) {
abs, _, err := fm.parseAndResolve(storagePath)
if err != nil {
return "", err
}
info, err := os.Stat(abs)
if err != nil {
return "", fmt.Errorf("cannot stat file: %w", err)
}
if info.IsDir() {
return "", fmt.Errorf("path is a directory, not a file")
}
if info.Size() > maxReadSize {
return "", fmt.Errorf("file size %d bytes exceeds read limit of %d bytes", info.Size(), maxReadSize)
}
data, err := os.ReadFile(abs)
if err != nil {
return "", fmt.Errorf("cannot read file: %w", err)
}
return string(data), nil
}
// SaveContent overwrites the file at storagePath with content.
func (fm *FileManager) SaveContent(storagePath string, content string) error {
abs, _, err := fm.parseAndResolve(storagePath)
if err != nil {
return err
}
// Ensure parent directory exists.
if err := os.MkdirAll(filepath.Dir(abs), 0755); err != nil {
return fmt.Errorf("cannot create parent directory: %w", err)
}
if err := os.WriteFile(abs, []byte(content), 0644); err != nil {
return fmt.Errorf("cannot write file: %w", err)
}
log.Printf("filemanager: saved %d bytes to %s", len(content), abs)
return nil
}
// Search walks the directory at storagePath and returns files whose names
// contain filter (case-insensitive). Results are capped at maxSearchResults.
func (fm *FileManager) Search(storagePath string, filter string) (*SearchResponse, error) {
rootAbs, rootRel, err := fm.parseAndResolve(storagePath)
if err != nil {
return nil, err
}
lowerFilter := strings.ToLower(filter)
var results []FileItem
err = filepath.WalkDir(rootAbs, func(path string, d fs.DirEntry, walkErr error) error {
if walkErr != nil {
// Skip entries that produce errors (e.g. permission denied) rather than aborting.
log.Printf("filemanager: search: skipping %s: %v", path, walkErr)
return nil
}
if path == rootAbs {
// Skip the root itself.
return nil
}
// Security: verify every path we touch is within the jail.
if err := fm.checkWithinJail(path); err != nil {
log.Printf("filemanager: search: path %s escaped jail, skipping", path)
return filepath.SkipDir
}
if strings.Contains(strings.ToLower(d.Name()), lowerFilter) {
rel, relErr := filepath.Rel(fm.installDir, path)
if relErr != nil {
return nil
}
item, buildErr := buildFileItem(d, path, rel)
if buildErr != nil {
return nil
}
results = append(results, item)
if len(results) >= maxSearchResults {
return io.EOF // Signal early exit; WalkDir treats this as a stop, not an error.
}
}
return nil
})
// WalkDir returns io.EOF only if we injected it; suppress it here.
if err != nil && err != io.EOF {
return nil, fmt.Errorf("search walk failed: %w", err)
}
_ = rootRel // rootRel used for Dirname below.
return &SearchResponse{
Adapter: adapterName,
Storages: []string{storageName},
Dirname: toStoragePath(rootRel),
Files: results,
}, nil
}
// Move moves every item in items into destination.
// After the move it returns a fresh listing of destination.
func (fm *FileManager) Move(storagePath string, items []string, destination string) (*ListResponse, error) {
destAbs, destRel, err := fm.parseAndResolve(destination)
if err != nil {
return nil, fmt.Errorf("invalid destination %q: %w", destination, err)
}
// Ensure destination directory exists.
if err := os.MkdirAll(destAbs, 0755); err != nil {
return nil, fmt.Errorf("cannot create destination directory: %w", err)
}
for _, item := range items {
itemAbs, _, err := fm.parseAndResolve(item)
if err != nil {
return nil, fmt.Errorf("invalid item path %q: %w", item, err)
}
targetAbs := filepath.Join(destAbs, filepath.Base(itemAbs))
if err := fm.checkWithinJail(targetAbs); err != nil {
return nil, fmt.Errorf("move target escapes jail: %w", err)
}
if err := os.Rename(itemAbs, targetAbs); err != nil {
// os.Rename fails across filesystems; fall back to copy+delete.
if err2 := copyRecursive(itemAbs, targetAbs); err2 != nil {
return nil, fmt.Errorf("failed to move %q: %w", item, err2)
}
if err2 := os.RemoveAll(itemAbs); err2 != nil {
return nil, fmt.Errorf("moved %q but failed to remove source: %w", item, err2)
}
}
log.Printf("filemanager: moved %s -> %s", itemAbs, targetAbs)
}
entries, err := os.ReadDir(destAbs)
if err != nil {
return nil, fmt.Errorf("cannot read destination after move: %w", err)
}
files, err := buildFileItems(entries, destAbs, destRel, fm.installDir)
if err != nil {
return nil, err
}
return &ListResponse{
Adapter: adapterName,
Storages: []string{storageName},
Dirname: toStoragePath(destRel),
Files: files,
}, nil
}
// Copy copies every item in items into destination.
// After the copy it returns a fresh listing of destination.
func (fm *FileManager) Copy(storagePath string, items []string, destination string) (*ListResponse, error) {
destAbs, destRel, err := fm.parseAndResolve(destination)
if err != nil {
return nil, fmt.Errorf("invalid destination %q: %w", destination, err)
}
if err := os.MkdirAll(destAbs, 0755); err != nil {
return nil, fmt.Errorf("cannot create destination directory: %w", err)
}
for _, item := range items {
itemAbs, _, err := fm.parseAndResolve(item)
if err != nil {
return nil, fmt.Errorf("invalid item path %q: %w", item, err)
}
targetAbs := filepath.Join(destAbs, filepath.Base(itemAbs))
if err := fm.checkWithinJail(targetAbs); err != nil {
return nil, fmt.Errorf("copy target escapes jail: %w", err)
}
if err := copyRecursive(itemAbs, targetAbs); err != nil {
return nil, fmt.Errorf("failed to copy %q: %w", item, err)
}
log.Printf("filemanager: copied %s -> %s", itemAbs, targetAbs)
}
entries, err := os.ReadDir(destAbs)
if err != nil {
return nil, fmt.Errorf("cannot read destination after copy: %w", err)
}
files, err := buildFileItems(entries, destAbs, destRel, fm.installDir)
if err != nil {
return nil, err
}
return &ListResponse{
Adapter: adapterName,
Storages: []string{storageName},
Dirname: toStoragePath(destRel),
Files: files,
}, nil
}
// Upload decodes the base64-encoded content and writes it as filename inside storagePath.
// After the write it returns a fresh listing of the parent directory.
func (fm *FileManager) Upload(storagePath string, filename string, content []byte) (*ListResponse, error) {
if err := validateBareName(filename); err != nil {
return nil, err
}
parentAbs, parentRel, err := fm.parseAndResolve(storagePath)
if err != nil {
return nil, err
}
destAbs := filepath.Join(parentAbs, filename)
if err := fm.checkWithinJail(destAbs); err != nil {
return nil, fmt.Errorf("upload target escapes jail: %w", err)
}
if err := os.MkdirAll(parentAbs, 0755); err != nil {
return nil, fmt.Errorf("cannot create upload directory: %w", err)
}
if err := os.WriteFile(destAbs, content, 0644); err != nil {
return nil, fmt.Errorf("cannot write uploaded file: %w", err)
}
log.Printf("filemanager: uploaded %d bytes to %s", len(content), destAbs)
entries, err := os.ReadDir(parentAbs)
if err != nil {
return nil, fmt.Errorf("cannot re-read directory after upload: %w", err)
}
files, err := buildFileItems(entries, parentAbs, parentRel, fm.installDir)
if err != nil {
return nil, err
}
return &ListResponse{
Adapter: adapterName,
Storages: []string{storageName},
Dirname: toStoragePath(parentRel),
Files: files,
}, nil
}
// ---------------------------------------------------------------------------
// Path parsing and security helpers
// ---------------------------------------------------------------------------
// ParsePath converts a VueFinder storage path ("server://relative/path") into
// the absolute filesystem path within the jail.
//
// Rules:
// - Must have the form "server://<relative>" where relative may be empty.
// - The storage identifier must be "server" — anything else is rejected.
// - The resulting absolute path must be within installDir.
func (fm *FileManager) ParsePath(storagePath string) (string, error) {
abs, _, err := fm.parseAndResolve(storagePath)
return abs, err
}
// parseAndResolve parses a storage path and resolves it to an absolute path.
// Returns (absolutePath, relativePath, error). relativePath is relative to
// installDir and uses the OS path separator.
func (fm *FileManager) parseAndResolve(storagePath string) (absPath string, relPath string, err error) {
const sep = "://"
idx := strings.Index(storagePath, sep)
if idx < 0 {
// Tolerate a bare relative path for internal calls, but flag it.
return "", "", fmt.Errorf("invalid storage path %q: missing storage prefix (expected %q://...)", storagePath, storageName)
}
storage := storagePath[:idx]
if storage != storageName {
return "", "", fmt.Errorf("unknown storage %q: only %q is supported", storage, storageName)
}
relative := storagePath[idx+len(sep):]
return fm.resolvePath(relative)
}
// resolvePath resolves a relative path (which may be empty, representing the
// root) to an absolute path inside the jail. It runs filepath.EvalSymlinks on
// the resolved path and then re-verifies the prefix to prevent symlink escapes.
// Parent-traversal via ".." is neutralised by filepath.Clean before joining.
func (fm *FileManager) resolvePath(relativePath string) (absPath string, relPath string, err error) {
// filepath.Clean neutralises ".." sequences before we ever join.
cleaned := filepath.Clean(relativePath)
if cleaned == "." {
cleaned = ""
}
var absolute string
if cleaned == "" {
absolute = fm.installDir
} else {
absolute = filepath.Join(fm.installDir, cleaned)
}
// First prefix check — fast path before hitting the filesystem.
if !strings.HasPrefix(absolute, fm.installDir+string(filepath.Separator)) && absolute != fm.installDir {
return "", "", fmt.Errorf("path %q escapes install directory", relativePath)
}
// Resolve symlinks so we can do an authoritative prefix check.
resolved, symlinkErr := filepath.EvalSymlinks(absolute)
if symlinkErr != nil {
// EvalSymlinks fails if the path does not exist yet (e.g. a new file).
// In that case fall back to the unresolved absolute path; the subsequent
// prefix check is still valid because we already cleaned ".." away.
resolved = absolute
}
// Authoritative prefix check on the symlink-resolved path.
if !strings.HasPrefix(resolved, fm.installDir+string(filepath.Separator)) && resolved != fm.installDir {
return "", "", fmt.Errorf("path %q resolves outside install directory (possible symlink escape)", relativePath)
}
// Compute relative portion for use in VueFinder response paths.
rel, relErr := filepath.Rel(fm.installDir, resolved)
if relErr != nil {
rel = cleaned
}
if rel == "." {
rel = ""
}
return resolved, rel, nil
}
// checkWithinJail verifies that an absolute path (already joined but not yet
// symlink-resolved) stays within the jail. Used for destination paths that may
// not exist yet.
func (fm *FileManager) checkWithinJail(absPath string) error {
clean := filepath.Clean(absPath)
if !strings.HasPrefix(clean, fm.installDir+string(filepath.Separator)) && clean != fm.installDir {
return fmt.Errorf("path %q is outside the install directory", absPath)
}
return nil
}
// ---------------------------------------------------------------------------
// VueFinder path formatting helpers
// ---------------------------------------------------------------------------
// toStoragePath converts a relative path (relative to installDir) to the
// "server://..." format expected by VueFinder. An empty relative path maps to
// "server://".
func toStoragePath(relPath string) string {
// Normalise OS separators to forward slashes for the JSON response.
fwd := filepath.ToSlash(relPath)
return storageName + "://" + fwd
}
// ---------------------------------------------------------------------------
// FileItem construction helpers
// ---------------------------------------------------------------------------
// buildFileItems builds the slice of FileItem values for a directory listing.
func buildFileItems(entries []fs.DirEntry, dirAbs string, dirRel string, installDir string) ([]FileItem, error) {
items := make([]FileItem, 0, len(entries))
for _, entry := range entries {
entryPath := filepath.Join(dirAbs, entry.Name())
entryRel := filepath.Join(dirRel, entry.Name())
if dirRel == "" {
entryRel = entry.Name()
}
item, err := buildFileItem(entry, entryPath, entryRel)
if err != nil {
log.Printf("filemanager: warning: skipping %s: %v", entryPath, err)
continue
}
items = append(items, item)
}
return items, nil
}
// buildFileItem builds a single FileItem from a DirEntry.
// entryAbs is the absolute path; entryRel is relative to installDir.
func buildFileItem(entry fs.DirEntry, entryAbs string, entryRel string) (FileItem, error) {
info, err := entry.Info()
if err != nil {
return FileItem{}, fmt.Errorf("cannot stat %s: %w", entryAbs, err)
}
itemType := "file"
ext := ""
size := info.Size()
if entry.IsDir() {
itemType = "dir"
size = 0
} else {
raw := filepath.Ext(entry.Name())
if len(raw) > 0 {
ext = raw[1:] // strip the leading dot
}
}
// Normalise to forward slashes for the JSON path field.
fwdRel := filepath.ToSlash(entryRel)
return FileItem{
Type: itemType,
Path: storageName + "://" + fwdRel,
Basename: entry.Name(),
Extension: ext,
Storage: storageName,
FileSize: size,
LastModified: info.ModTime().Format("2006-01-02 15:04:05"),
}, nil
}
// ---------------------------------------------------------------------------
// Recursive copy helper
// ---------------------------------------------------------------------------
// copyRecursive copies src to dst. If src is a directory it is copied
// recursively. dst must not exist.
func copyRecursive(src, dst string) error {
srcInfo, err := os.Lstat(src)
if err != nil {
return fmt.Errorf("cannot stat source: %w", err)
}
if srcInfo.IsDir() {
if err := os.MkdirAll(dst, srcInfo.Mode()); err != nil {
return fmt.Errorf("cannot create destination directory: %w", err)
}
entries, err := os.ReadDir(src)
if err != nil {
return fmt.Errorf("cannot read source directory: %w", err)
}
for _, entry := range entries {
if err := copyRecursive(filepath.Join(src, entry.Name()), filepath.Join(dst, entry.Name())); err != nil {
return err
}
}
return nil
}
// Regular file copy.
srcFile, err := os.Open(src)
if err != nil {
return fmt.Errorf("cannot open source file: %w", err)
}
defer srcFile.Close()
dstFile, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, srcInfo.Mode())
if err != nil {
return fmt.Errorf("cannot create destination file: %w", err)
}
defer dstFile.Close()
if _, err := io.Copy(dstFile, srcFile); err != nil {
return fmt.Errorf("copy failed: %w", err)
}
return nil
}
// ---------------------------------------------------------------------------
// Input validation helpers
// ---------------------------------------------------------------------------
// validateBareName rejects names that are empty, are "." or "..", or contain
// path separators. This is used to validate user-supplied filenames for create,
// rename, and upload operations.
func validateBareName(name string) error {
if name == "" {
return fmt.Errorf("name must not be empty")
}
if name == "." || name == ".." {
return fmt.Errorf("name %q is not allowed", name)
}
if strings.ContainsAny(name, "/\\") {
return fmt.Errorf("name %q must not contain path separators", name)
}
return nil
}
// DecodeBase64 is a convenience wrapper used by the handler when the upload
// content arrives as a base64 string.
func DecodeBase64(encoded string) ([]byte, error) {
// Accept both standard and URL-safe base64, with or without padding.
decoded, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
// Try URL-safe encoding as a fallback.
decoded, err = base64.URLEncoding.DecodeString(encoded)
if err != nil {
// Try raw (no padding) variants.
decoded, err = base64.RawStdEncoding.DecodeString(encoded)
if err != nil {
return nil, fmt.Errorf("cannot base64-decode upload content: %w", err)
}
}
}
return decoded, nil
}
// suppress unused import warning — time is used in buildFileItem via ModTime.Format
var _ = time.Now

View File

@@ -0,0 +1,209 @@
package filemanager
import (
"encoding/json"
"log"
"github.com/nats-io/nats.go"
)
// HandleNatsRequest is the NATS message handler for the file manager command
// subject (corrosion.{license_id}.files.cmd). It deserialises the request,
// routes to the correct FileManager operation, and calls msg.Respond with a
// NatsResponse JSON payload — either success with data or a structured error.
func (fm *FileManager) HandleNatsRequest(msg *nats.Msg) {
var req NatsRequest
if err := json.Unmarshal(msg.Data, &req); err != nil {
log.Printf("filemanager: invalid NATS request payload: %v", err)
respondError(msg, "invalid request: "+err.Error())
return
}
log.Printf("filemanager: handling %s path=%q", req.Func, req.Path)
switch req.Func {
// -----------------------------------------------------------------------
// Directory listing
// -----------------------------------------------------------------------
case FuncList:
result, err := fm.List(req.Path)
if err != nil {
respondError(msg, err.Error())
return
}
respondJSON(msg, result)
// -----------------------------------------------------------------------
// Delete
// -----------------------------------------------------------------------
case FuncDelete:
result, err := fm.Delete(req.Path, req.Items)
if err != nil {
respondError(msg, err.Error())
return
}
respondJSON(msg, result)
// -----------------------------------------------------------------------
// Rename — req.Name holds the new basename
// -----------------------------------------------------------------------
case FuncRename:
if len(req.Items) == 0 {
respondError(msg, "rename requires at least one item")
return
}
result, err := fm.Rename(req.Path, req.Items[0], req.Name)
if err != nil {
respondError(msg, err.Error())
return
}
respondJSON(msg, result)
// -----------------------------------------------------------------------
// Copy
// -----------------------------------------------------------------------
case FuncCopy:
if req.Destination == "" {
respondError(msg, "copy requires a destination path")
return
}
result, err := fm.Copy(req.Path, req.Items, req.Destination)
if err != nil {
respondError(msg, err.Error())
return
}
respondJSON(msg, result)
// -----------------------------------------------------------------------
// Move
// -----------------------------------------------------------------------
case FuncMove:
if req.Destination == "" {
respondError(msg, "move requires a destination path")
return
}
result, err := fm.Move(req.Path, req.Items, req.Destination)
if err != nil {
respondError(msg, err.Error())
return
}
respondJSON(msg, result)
// -----------------------------------------------------------------------
// Create directory
// -----------------------------------------------------------------------
case FuncCreateFolder:
if req.Name == "" {
respondError(msg, "mkdir requires a folder name")
return
}
result, err := fm.CreateFolder(req.Path, req.Name)
if err != nil {
respondError(msg, err.Error())
return
}
respondJSON(msg, result)
// -----------------------------------------------------------------------
// Create empty file
// -----------------------------------------------------------------------
case FuncCreateFile:
if req.Name == "" {
respondError(msg, "mkfile requires a file name")
return
}
result, err := fm.CreateFile(req.Path, req.Name)
if err != nil {
respondError(msg, err.Error())
return
}
respondJSON(msg, result)
// -----------------------------------------------------------------------
// Search
// -----------------------------------------------------------------------
case FuncSearch:
result, err := fm.Search(req.Path, req.Filter)
if err != nil {
respondError(msg, err.Error())
return
}
respondJSON(msg, result)
// -----------------------------------------------------------------------
// Preview / read file content (VueFinder uses "fm_preview" for text files)
// -----------------------------------------------------------------------
case FuncPreview:
content, err := fm.GetContent(req.Path)
if err != nil {
respondError(msg, err.Error())
return
}
respondJSON(msg, map[string]string{"content": content})
// -----------------------------------------------------------------------
// Save file content
// -----------------------------------------------------------------------
case FuncSave:
if err := fm.SaveContent(req.Path, req.Content); err != nil {
respondError(msg, err.Error())
return
}
respondJSON(msg, map[string]string{"status": "saved"})
// -----------------------------------------------------------------------
// Upload — content arrives as a base64-encoded string
// -----------------------------------------------------------------------
case FuncUpload:
if req.Filename == "" {
respondError(msg, "upload requires a filename")
return
}
data, err := DecodeBase64(req.Content)
if err != nil {
respondError(msg, err.Error())
return
}
result, err := fm.Upload(req.Path, req.Filename, data)
if err != nil {
respondError(msg, err.Error())
return
}
respondJSON(msg, result)
// -----------------------------------------------------------------------
// Unknown function
// -----------------------------------------------------------------------
default:
log.Printf("filemanager: unknown function %q", req.Func)
respondError(msg, "unknown function: "+req.Func)
}
}
// ---------------------------------------------------------------------------
// Response helpers
// ---------------------------------------------------------------------------
// respondJSON sends a successful NatsResponse wrapping data.
func respondJSON(msg *nats.Msg, data interface{}) {
resp := NatsResponse{Success: true, Data: data}
bytes, err := json.Marshal(resp)
if err != nil {
log.Printf("filemanager: failed to marshal success response: %v", err)
respondError(msg, "internal: failed to marshal response")
return
}
if err := msg.Respond(bytes); err != nil {
log.Printf("filemanager: failed to send response: %v", err)
}
}
// respondError sends a failed NatsResponse with the given error message.
func respondError(msg *nats.Msg, errMsg string) {
resp := NatsResponse{Success: false, Error: errMsg}
bytes, _ := json.Marshal(resp)
if err := msg.Respond(bytes); err != nil {
log.Printf("filemanager: failed to send error response: %v", err)
}
}

View File

@@ -0,0 +1,62 @@
package filemanager
// FileItem represents a file or directory in VueFinder format.
type FileItem struct {
Type string `json:"type"` // "dir" or "file"
Path string `json:"path"` // "server://relative/path"
Basename string `json:"basename"` // filename.ext
Extension string `json:"extension"` // "ext" or "" for dirs
Storage string `json:"storage"` // always "server"
FileSize int64 `json:"file_size"` // bytes, 0 for dirs
LastModified string `json:"last_modified"` // "2006-01-02 15:04:05"
}
// ListResponse is the VueFinder index/directory response.
type ListResponse struct {
Adapter string `json:"adapter"` // always "local"
Storages []string `json:"storages"` // ["server"]
Dirname string `json:"dirname"` // "server://current/path"
Files []FileItem `json:"files"` // directory contents
}
// SearchResponse is the VueFinder search response.
type SearchResponse struct {
Adapter string `json:"adapter"`
Storages []string `json:"storages"`
Dirname string `json:"dirname"`
Files []FileItem `json:"files"`
}
// NatsRequest is the NATS request payload sent by the backend for file manager operations.
type NatsRequest struct {
Func string `json:"func"` // "fm_list", "fm_delete", etc.
Path string `json:"path"` // VueFinder storage path, e.g. "server://cfg"
Items []string `json:"items,omitempty"` // items to delete/move/copy (relative storage paths)
Name string `json:"name,omitempty"` // new name for rename/create
Destination string `json:"destination,omitempty"` // destination storage path for move/copy
Filter string `json:"filter,omitempty"` // search filter string
Content string `json:"content,omitempty"` // file content (save) or base64-encoded data (upload)
Filename string `json:"filename,omitempty"` // original filename for upload
}
// NatsResponse wraps every response sent back to the backend.
type NatsResponse struct {
Success bool `json:"success"`
Error string `json:"error,omitempty"`
Data interface{} `json:"data,omitempty"`
}
// Function name constants matching what the backend sends.
const (
FuncList = "fm_list"
FuncDelete = "fm_delete"
FuncRename = "fm_rename"
FuncCopy = "fm_copy"
FuncMove = "fm_move"
FuncCreateFolder = "fm_mkdir"
FuncCreateFile = "fm_mkfile"
FuncSearch = "fm_search"
FuncPreview = "fm_preview"
FuncSave = "fm_save"
FuncUpload = "fm_upload"
)