diff --git a/companion-agent/internal/app/daemon.go b/companion-agent/internal/app/daemon.go index 8c31ab6..7a14c51 100644 --- a/companion-agent/internal/app/daemon.go +++ b/companion-agent/internal/app/daemon.go @@ -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 diff --git a/companion-agent/internal/filemanager/filemanager.go b/companion-agent/internal/filemanager/filemanager.go new file mode 100644 index 0000000..9ea7d29 --- /dev/null +++ b/companion-agent/internal/filemanager/filemanager.go @@ -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://" 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 diff --git a/companion-agent/internal/filemanager/handler.go b/companion-agent/internal/filemanager/handler.go new file mode 100644 index 0000000..96f0c67 --- /dev/null +++ b/companion-agent/internal/filemanager/handler.go @@ -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) + } +} diff --git a/companion-agent/internal/filemanager/types.go b/companion-agent/internal/filemanager/types.go new file mode 100644 index 0000000..b192e4c --- /dev/null +++ b/companion-agent/internal/filemanager/types.go @@ -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" +)