package agent import ( "archive/zip" "encoding/base64" "encoding/json" "errors" "fmt" "io" "os" "os/exec" "os/user" "path/filepath" "runtime" "sort" "strings" "unicode/utf8" "github.com/x-grk/longpolling-remote-access/internal/proto" ) // fsReadCap bounds how many bytes a view/read returns. const fsReadCap = proto.FSReadCap // fsDownloadCap bounds a browser download (kept under the 64 MiB result/body // limit once base64-encoded). const fsDownloadCap = 40 * 1024 * 1024 // dispatchFS runs one native file-manager operation. func dispatchFS(env proto.TaskEnvelope) (any, error) { switch env.Type { case proto.TaskFSList: var a proto.FSPathArgs _ = json.Unmarshal(env.Args, &a) return fsList(a.Path) case proto.TaskFSRead: var a proto.FSPathArgs _ = json.Unmarshal(env.Args, &a) return fsReadCapped(a.Path, fsReadCap) case proto.TaskFSDownload: var a proto.FSPathArgs _ = json.Unmarshal(env.Args, &a) return fsReadCapped(a.Path, fsDownloadCap) case proto.TaskFSWrite: var a proto.FSWriteArgs _ = json.Unmarshal(env.Args, &a) return fsWrite(a.Path, a.Data) case proto.TaskFSMkdir: var a proto.FSPathArgs _ = json.Unmarshal(env.Args, &a) return map[string]string{"path": a.Path}, os.MkdirAll(a.Path, 0o755) case proto.TaskFSDelete: var a proto.FSPathArgs _ = json.Unmarshal(env.Args, &a) return map[string]string{"path": a.Path}, os.Remove(a.Path) case proto.TaskFSDelTree: var a proto.FSPathArgs _ = json.Unmarshal(env.Args, &a) if a.Path == "" || a.Path == "/" { return nil, errors.New("отказ: удаление корня запрещено") } return map[string]string{"path": a.Path}, os.RemoveAll(a.Path) case proto.TaskFSMove: var a proto.FSMoveArgs _ = json.Unmarshal(env.Args, &a) return map[string]string{"src": a.Src, "dst": a.Dst}, os.Rename(a.Src, a.Dst) case proto.TaskFSUnzip: var a proto.FSUnzipArgs _ = json.Unmarshal(env.Args, &a) return fsUnzip(a.Path, a.Dest) case proto.TaskFSZip: var a proto.FSZipArgs _ = json.Unmarshal(env.Args, &a) return fsZip(a.Paths, a.Dest) case proto.TaskFSStat: var a proto.FSPathArgs _ = json.Unmarshal(env.Args, &a) return fsStat(a.Path) case proto.TaskFSChmod: var a proto.FSChmodArgs _ = json.Unmarshal(env.Args, &a) return fsChmod(a) case proto.TaskFSCopy: var a proto.FSCopyArgs _ = json.Unmarshal(env.Args, &a) return fsCopy(a) default: return nil, fmt.Errorf("неизвестная операция %q", env.Type) } } func fsList(path string) (proto.FSListResult, error) { if path == "" { path = "/" } // "~" / "~name" is where the file manager starts: the home directory, or // the root when there is none to open. home := strings.HasPrefix(path, "~") && !strings.Contains(path, "/") if home { path = homeDir(path[1:]) } abs, _ := filepath.Abs(path) entries, err := os.ReadDir(abs) if err != nil && home && abs != "/" { abs = "/" entries, err = os.ReadDir(abs) } if err != nil { return proto.FSListResult{}, err } out := make([]proto.FSEntry, 0, len(entries)) for _, e := range entries { info, err := e.Info() if err != nil { continue } ent := proto.FSEntry{ Name: e.Name(), IsDir: e.IsDir(), Size: info.Size(), Mode: info.Mode().String(), MTime: info.ModTime().Unix(), } // A link to a folder opens like one. if info.Mode()&os.ModeSymlink != 0 { ent.Link = true if t, err := os.Stat(filepath.Join(abs, e.Name())); err == nil && t.IsDir() { ent.IsDir = true } } out = append(out, ent) } // Directories first, then alphabetical. sort.Slice(out, func(i, j int) bool { if out[i].IsDir != out[j].IsDir { return out[i].IsDir } return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name) }) return proto.FSListResult{Path: abs, Entries: out}, nil } func fsReadCapped(path string, cap int) (proto.FSReadResult, error) { info, err := os.Stat(path) if err != nil { return proto.FSReadResult{}, err } if info.IsDir() { return proto.FSReadResult{}, errors.New("это каталог") } f, err := os.Open(path) if err != nil { return proto.FSReadResult{}, err } defer f.Close() buf := make([]byte, cap+1) n, _ := io.ReadFull(f, buf) truncated := n > cap if truncated { n = cap } data := buf[:n] binary := !utf8.Valid(data) || containsNUL(data) return proto.FSReadResult{ Path: path, Size: info.Size(), Content: base64.StdEncoding.EncodeToString(data), Truncated: truncated, Binary: binary, }, nil } func containsNUL(b []byte) bool { for _, c := range b { if c == 0 { return true } } return false } func fsWrite(path, dataB64 string) (any, error) { data, err := base64.StdEncoding.DecodeString(dataB64) if err != nil { return nil, err } if err := os.WriteFile(path, data, 0o644); err != nil { return nil, err } return map[string]any{"path": path, "size": len(data)}, nil } func fsUnzip(path, dest string) (any, error) { if dest == "" { dest = filepath.Dir(path) } bin, err := exec.LookPath("unzip") if err != nil { return nil, errors.New("unzip не установлен на клиенте") } out, err := exec.Command(bin, "-o", path, "-d", dest).CombinedOutput() if err != nil { return nil, fmt.Errorf("unzip: %v: %s", err, strings.TrimSpace(string(out))) } return map[string]string{"dest": dest}, nil } // fsZip creates dest as a zip archive of the given paths (files and dirs). func fsZip(paths []string, dest string) (any, error) { if dest == "" { return nil, errors.New("не указан путь архива") } if len(paths) == 0 { return nil, errors.New("не выбраны файлы") } f, err := os.Create(dest) if err != nil { return nil, err } defer f.Close() zw := zip.NewWriter(f) defer zw.Close() for _, p := range paths { parent := filepath.Dir(p) err := filepath.Walk(p, func(fp string, info os.FileInfo, err error) error { if err != nil { return err } rel, err := filepath.Rel(parent, fp) if err != nil { return err } rel = filepath.ToSlash(rel) if info.IsDir() { _, err := zw.Create(rel + "/") return err } w, err := zw.Create(rel) if err != nil { return err } src, err := os.Open(fp) if err != nil { return err } defer src.Close() _, err = io.Copy(w, src) return err }) if err != nil { return nil, err } } return map[string]string{"dest": dest}, nil } // homeDir returns the home directory of the named user ("" = the user the // agent runs as), or "/" when it cannot be told. func homeDir(name string) string { if name != "" { if validUserName(name) { if u, err := user.Lookup(name); err == nil && u.HomeDir != "" { return u.HomeDir } } return "/" } if u, err := user.Current(); err == nil && u.HomeDir != "" { return u.HomeDir } if h := os.Getenv("HOME"); h != "" { return h } // A root service often has neither a passwd entry we can read nor $HOME. if os.Geteuid() == 0 { if runtime.GOOS == "darwin" { return "/var/root" } return "/root" } return "/" }