package agent import ( "bufio" "errors" "fmt" "io/fs" "os" "os/user" "path/filepath" "sort" "strconv" "strings" "syscall" "github.com/x-grk/longpolling-remote-access/internal/proto" ) // fsStat reports a file's properties, following a symlink to what it points // at, and the user and group names to choose an owner from. func fsStat(path string) (proto.FSStatResult, error) { res := proto.FSStatResult{Path: path} if li, err := os.Lstat(path); err == nil && li.Mode()&os.ModeSymlink != 0 { res.Link, _ = os.Readlink(path) } info, err := os.Stat(path) if err != nil { return res, err } res.IsDir, res.Size, res.MTime = info.IsDir(), info.Size(), info.ModTime().Unix() if st, ok := info.Sys().(*syscall.Stat_t); ok { res.Mode = uint32(st.Mode) & 07777 res.UID, res.GID = int(st.Uid), int(st.Gid) } else { res.Mode = uint32(info.Mode().Perm()) } res.Owner, res.Group = strconv.Itoa(res.UID), strconv.Itoa(res.GID) if u, err := user.LookupId(res.Owner); err == nil { res.Owner = u.Username } if g, err := user.LookupGroupId(res.Group); err == nil { res.Group = g.Name } res.Users, res.Groups = namesIn("/etc/passwd"), namesIn("/etc/group") return res, nil } // namesIn lists the first field of a passwd- or group-style file. func namesIn(file string) []string { f, err := os.Open(file) if err != nil { return nil } defer f.Close() var out []string sc := bufio.NewScanner(f) for sc.Scan() && len(out) < 1000 { line := sc.Text() if line == "" || line[0] == '#' { continue } if name, _, ok := strings.Cut(line, ":"); ok && name != "" { out = append(out, name) } } sort.Strings(out) return out } // lookupID resolves a user or group given by name or number; -1 for none. func lookupID(name string, group bool) (int, error) { if name == "" { return -1, nil } if n, err := strconv.Atoi(name); err == nil && n >= 0 { return n, nil } if group { g, err := user.LookupGroup(name) if err != nil { return -1, fmt.Errorf("группа %s не найдена", name) } return strconv.Atoi(g.Gid) } u, err := user.Lookup(name) if err != nil { return -1, fmt.Errorf("пользователь %s не найден", name) } return strconv.Atoi(u.Uid) } // fsChmod sets mode, owner and group on a path, and with Recursive on all // that is inside a folder. Symlinks inside are not followed. func fsChmod(a proto.FSChmodArgs) (any, error) { if a.Path == "" { return nil, errors.New("не указан путь") } if a.Recursive && filepath.Clean(a.Path) == "/" { return nil, errors.New("отказ: рекурсивно для корня нельзя") } if a.Mode != nil && *a.Mode > 07777 { return nil, errors.New("неверные права") } uid, err := lookupID(a.Owner, false) if err != nil { return nil, err } gid, err := lookupID(a.Group, true) if err != nil { return nil, err } changed, failed := 0, 0 var errs []string // The path itself always gets the change; Only picks among what is in it. apply := func(p string, isDir, isLink, self bool) { if !self && ((a.Only == "files" && isDir) || (a.Only == "dirs" && !isDir)) { return } var err error if uid >= 0 || gid >= 0 { err = os.Lchown(p, uid, gid) } // A symlink has no mode of its own: chmod would change its target. if err == nil && a.Mode != nil && !isLink { err = syscall.Chmod(p, *a.Mode) } if err != nil { failed++ if len(errs) < 5 { errs = append(errs, err.Error()) } return } changed++ } info, err := os.Stat(a.Path) if err != nil { return nil, err } if !a.Recursive || !info.IsDir() { apply(a.Path, info.IsDir(), false, true) } else { err = filepath.WalkDir(a.Path, func(p string, d fs.DirEntry, err error) error { if err != nil { failed++ if len(errs) < 5 { errs = append(errs, err.Error()) } return nil } apply(p, d.IsDir(), d.Type()&fs.ModeSymlink != 0, p == a.Path) return nil }) if err != nil { return nil, err } } res := map[string]any{"changed": changed, "failed": failed, "errors": errs} if failed > 0 && changed == 0 { return res, errors.New(errs[0]) } return res, nil }