feat: Add file manager package — VueFinder-compatible NATS request-reply handler
All checks were successful
Test Asgard Runner / test (push) Successful in 2s
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:
@@ -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
|
||||
|
||||
735
companion-agent/internal/filemanager/filemanager.go
Normal file
735
companion-agent/internal/filemanager/filemanager.go
Normal 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
|
||||
209
companion-agent/internal/filemanager/handler.go
Normal file
209
companion-agent/internal/filemanager/handler.go
Normal 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)
|
||||
}
|
||||
}
|
||||
62
companion-agent/internal/filemanager/types.go
Normal file
62
companion-agent/internal/filemanager/types.go
Normal 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"
|
||||
)
|
||||
Reference in New Issue
Block a user