package files import ( "fmt" "io" "os" "path/filepath" "strings" "syscall" "github.com/darroyo/nasctl/internal/system" ) type Entry struct { Name string `json:"name"` Path string `json:"path"` IsDir bool `json:"is_dir"` Size int64 `json:"size"` Mode string `json:"mode"` ModeNum uint32 `json:"mode_num"` ModTime int64 `json:"mod_time"` } type DirList struct { Entries []Entry `json:"entries"` Path string `json:"path"` Total int `json:"total"` Page int `json:"page"` Limit int `json:"limit"` HasMore bool `json:"has_more"` } type FileInfo struct { Path string `json:"path"` Name string `json:"name"` IsDir bool `json:"is_dir"` Size int64 `json:"size"` Mode string `json:"mode"` ModeNum uint32 `json:"mode_num"` ModTime int64 `json:"mod_time"` Uid uint32 `json:"uid"` Gid uint32 `json:"gid"` TotalBytes uint64 `json:"total_bytes,omitempty"` FreeBytes uint64 `json:"free_bytes,omitempty"` } type SearchHit struct { Name string `json:"name"` Path string `json:"path"` IsDir bool `json:"is_dir"` Size int64 `json:"size"` } type FileCapabilities struct { Chmod bool `json:"chmod"` Chown bool `json:"chown"` } func List(path string, allowedRoots []string, page, limit int) (*DirList, error) { if err := system.ValidatePathAllowed(path, allowedRoots); err != nil { return nil, err } info, err := os.Stat(path) if err != nil { return nil, fmt.Errorf("stat %q: %w", path, err) } if !info.IsDir() { return nil, fmt.Errorf("path is not a directory: %q", path) } entries, err := os.ReadDir(path) if err != nil { return nil, fmt.Errorf("read dir %q: %w", path, err) } allEntries := make([]Entry, 0, len(entries)) for _, e := range entries { fullPath := filepath.Join(path, e.Name()) fi, err := e.Info() if err != nil { continue } stat := fi.Sys().(*syscall.Stat_t) allEntries = append(allEntries, Entry{ Name: e.Name(), Path: fullPath, IsDir: e.IsDir(), Size: fi.Size(), Mode: fi.Mode().String(), ModeNum: uint32(stat.Mode), ModTime: fi.ModTime().Unix(), }) } sortEntries(allEntries) total := len(allEntries) start := (page - 1) * limit end := start + limit if start > total { start = total } if end > total { end = total } return &DirList{ Entries: allEntries[start:end], Path: path, Total: total, Page: page, Limit: limit, HasMore: end < total, }, nil } func Info(path string, allowedRoots []string) (*FileInfo, error) { if err := system.ValidatePathAllowed(path, allowedRoots); err != nil { return nil, err } fi, err := os.Lstat(path) if err != nil { return nil, fmt.Errorf("lstat %q: %w", path, err) } stat := fi.Sys().(*syscall.Stat_t) info := &FileInfo{ Name: fi.Name(), Path: path, IsDir: fi.IsDir(), Size: fi.Size(), Mode: fi.Mode().String(), ModeNum: uint32(stat.Mode), ModTime: fi.ModTime().Unix(), Uid: stat.Uid, Gid: stat.Gid, } if fi.IsDir() { var st syscall.Statfs_t if err := syscall.Statfs(path, &st); err == nil { info.TotalBytes = st.Blocks * uint64(st.Bsize) info.FreeBytes = st.Bavail * uint64(st.Bsize) } } return info, nil } func Mkdir(path string, allowedRoots []string) error { if err := system.ValidatePathAllowed(path, allowedRoots); err != nil { return err } return os.MkdirAll(path, 0o755) } func Rename(path, newName string, allowedRoots []string) error { if err := system.ValidatePathAllowed(path, allowedRoots); err != nil { return err } if err := validateNewName(newName); err != nil { return err } dir := filepath.Dir(path) newPath := filepath.Join(dir, newName) if err := system.ValidatePathAllowed(newPath, allowedRoots); err != nil { return err } return os.Rename(path, newPath) } func Delete(path string, allowedRoots []string) error { if err := system.ValidatePathAllowed(path, allowedRoots); err != nil { return err } clean := filepath.Clean(path) if clean == "/" { return fmt.Errorf("refusing to delete root") } return os.RemoveAll(path) } func Chmod(path string, mode os.FileMode, allowedRoots []string) error { if err := system.ValidatePathAllowed(path, allowedRoots); err != nil { return err } return os.Chmod(path, mode) } func Chown(path string, uid, gid int, allowedRoots []string) error { if err := system.ValidatePathAllowed(path, allowedRoots); err != nil { return err } return os.Chown(path, uid, gid) } func Statfs(path string) (total, free uint64, err error) { var st syscall.Statfs_t if err := syscall.Statfs(path, &st); err != nil { return 0, 0, err } return st.Blocks * uint64(st.Bsize), st.Bavail * uint64(st.Bsize), nil } func ReadFile(path string, max int64, allowedRoots []string) ([]byte, error) { if err := system.ValidatePathAllowed(path, allowedRoots); err != nil { return nil, err } f, err := os.Open(path) if err != nil { return nil, err } defer f.Close() return io.ReadAll(io.LimitReader(f, max)) } func WriteFile(path string, r io.Reader, max int64, allowedRoots []string) error { if err := system.ValidatePathAllowed(path, allowedRoots); err != nil { return err } f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644) if err != nil { return err } defer f.Close() _, err = io.Copy(f, io.LimitReader(r, max)) return err } func Search(root, query string, limit int, allowedRoots []string) ([]SearchHit, error) { if err := system.ValidatePathAllowed(root, allowedRoots); err != nil { return nil, err } if query == "" { return nil, nil } var hits []SearchHit q := strings.ToLower(query) walker := func(path string, info os.FileInfo, err error) error { if err != nil { return nil } if strings.Contains(strings.ToLower(info.Name()), q) { if err := system.ValidatePathAllowed(path, allowedRoots); err != nil { return nil } hits = append(hits, SearchHit{ Name: info.Name(), Path: path, IsDir: info.IsDir(), Size: info.Size(), }) if len(hits) >= limit { return filepath.SkipAll } } return nil } _ = filepath.Walk(root, walker) return hits, nil } func Capabilities() FileCapabilities { return FileCapabilities{ Chmod: os.Geteuid() == 0, Chown: os.Geteuid() == 0, } } func validateNewName(name string) error { if name == "" || len(name) > 255 { return fmt.Errorf("invalid name length") } if name == ".." || name == "." { return fmt.Errorf("invalid name: %q", name) } if strings.ContainsRune(name, '/') { return fmt.Errorf("name must not contain /") } if strings.HasPrefix(name, "-") { return fmt.Errorf("name must not start with -") } if strings.Contains(name, "\x00") { return fmt.Errorf("name must not contain null byte") } return nil } func sortEntries(entries []Entry) { for i := range entries { for j := i + 1; j < len(entries); j++ { di, dj := entries[i].IsDir, entries[j].IsDir if di != dj { if di { continue } entries[i], entries[j] = entries[j], entries[i] continue } if entries[i].Name > entries[j].Name { entries[i], entries[j] = entries[j], entries[i] } } } }