305 lines
6.2 KiB
Go
305 lines
6.2 KiB
Go
package files
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const DefaultMaxReadBytes = 1 << 20 // 1MB
|
|
|
|
var (
|
|
ErrPathTraversal = errors.New("path escapes data root")
|
|
ErrSymlink = errors.New("symlinks are not allowed")
|
|
ErrNotFound = errors.New("file not found")
|
|
ErrNotDirectory = errors.New("path is not a directory")
|
|
ErrIsDirectory = errors.New("path is a directory")
|
|
)
|
|
|
|
// DirEntry describes a file or directory entry returned by ListDir.
|
|
type DirEntry struct {
|
|
Name string
|
|
Path string
|
|
IsDir bool
|
|
Size int64
|
|
ModifiedAt time.Time
|
|
}
|
|
|
|
// ResolvePath resolves relativePath under dataRoot and rejects path traversal.
|
|
func ResolvePath(dataRoot, relativePath string) (string, error) {
|
|
root, err := filepath.Abs(filepath.Clean(dataRoot))
|
|
if err != nil {
|
|
return "", fmt.Errorf("resolve data root: %w", err)
|
|
}
|
|
|
|
rel := strings.TrimPrefix(relativePath, "/")
|
|
rel = strings.TrimPrefix(rel, `\`)
|
|
rel = filepath.Clean(rel)
|
|
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
|
return "", ErrPathTraversal
|
|
}
|
|
|
|
resolved := filepath.Join(root, rel)
|
|
resolved, err = filepath.Abs(resolved)
|
|
if err != nil {
|
|
return "", fmt.Errorf("resolve path: %w", err)
|
|
}
|
|
|
|
if resolved != root && !strings.HasPrefix(resolved, root+string(filepath.Separator)) {
|
|
return "", ErrPathTraversal
|
|
}
|
|
|
|
return resolved, nil
|
|
}
|
|
|
|
// ListDir lists entries in a directory relative to dataRoot.
|
|
func ListDir(dataRoot, relativePath string) ([]DirEntry, error) {
|
|
dirPath, err := ResolvePath(dataRoot, relativePath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := rejectSymlinks(dataRoot, dirPath); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
info, err := os.Lstat(dirPath)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil, ErrNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
if !info.IsDir() {
|
|
return nil, ErrNotDirectory
|
|
}
|
|
|
|
entries, err := os.ReadDir(dirPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
root, err := filepath.Abs(filepath.Clean(dataRoot))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
result := make([]DirEntry, 0, len(entries))
|
|
for _, entry := range entries {
|
|
entryPath := filepath.Join(dirPath, entry.Name())
|
|
if err := rejectSymlinks(root, entryPath); err != nil {
|
|
continue
|
|
}
|
|
|
|
entryInfo, err := entry.Info()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
rel, err := filepath.Rel(root, entryPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rel = filepath.ToSlash(rel)
|
|
|
|
result = append(result, DirEntry{
|
|
Name: entry.Name(),
|
|
Path: rel,
|
|
IsDir: entry.IsDir(),
|
|
Size: entryInfo.Size(),
|
|
ModifiedAt: entryInfo.ModTime().UTC(),
|
|
})
|
|
}
|
|
|
|
sort.Slice(result, func(i, j int) bool {
|
|
if result[i].IsDir != result[j].IsDir {
|
|
return result[i].IsDir
|
|
}
|
|
return strings.ToLower(result[i].Name) < strings.ToLower(result[j].Name)
|
|
})
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// ReadFile reads file content relative to dataRoot up to maxBytes.
|
|
func ReadFile(dataRoot, relativePath string, maxBytes int) (content string, truncated bool, err error) {
|
|
if maxBytes <= 0 {
|
|
maxBytes = DefaultMaxReadBytes
|
|
}
|
|
|
|
filePath, err := ResolvePath(dataRoot, relativePath)
|
|
if err != nil {
|
|
return "", false, err
|
|
}
|
|
if err := rejectSymlinks(dataRoot, filePath); err != nil {
|
|
return "", false, err
|
|
}
|
|
|
|
info, err := os.Lstat(filePath)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return "", false, ErrNotFound
|
|
}
|
|
return "", false, err
|
|
}
|
|
if info.IsDir() {
|
|
return "", false, ErrIsDirectory
|
|
}
|
|
if info.Mode()&os.ModeSymlink != 0 {
|
|
return "", false, ErrSymlink
|
|
}
|
|
|
|
f, err := os.Open(filePath)
|
|
if err != nil {
|
|
return "", false, err
|
|
}
|
|
defer f.Close()
|
|
|
|
limited := io.LimitReader(f, int64(maxBytes)+1)
|
|
data, err := io.ReadAll(limited)
|
|
if err != nil {
|
|
return "", false, err
|
|
}
|
|
if len(data) > maxBytes {
|
|
truncated = true
|
|
data = data[:maxBytes]
|
|
}
|
|
|
|
return string(data), truncated, nil
|
|
}
|
|
|
|
// WriteFile atomically writes content to a path relative to dataRoot.
|
|
func WriteFile(dataRoot, relativePath, content string, create bool) error {
|
|
filePath, err := ResolvePath(dataRoot, relativePath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if _, err := os.Lstat(filePath); err != nil {
|
|
if os.IsNotExist(err) {
|
|
if !create {
|
|
return ErrNotFound
|
|
}
|
|
} else {
|
|
return err
|
|
}
|
|
} else if !create {
|
|
if err := rejectSymlinks(dataRoot, filePath); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
dir := filepath.Dir(filePath)
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
return err
|
|
}
|
|
|
|
tmp, err := os.CreateTemp(dir, ".hexahost-write-*")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tmpPath := tmp.Name()
|
|
|
|
cleanup := func() {
|
|
_ = tmp.Close()
|
|
_ = os.Remove(tmpPath)
|
|
}
|
|
|
|
if _, err := tmp.WriteString(content); err != nil {
|
|
cleanup()
|
|
return err
|
|
}
|
|
if err := tmp.Sync(); err != nil {
|
|
cleanup()
|
|
return err
|
|
}
|
|
if err := tmp.Close(); err != nil {
|
|
cleanup()
|
|
return err
|
|
}
|
|
if err := os.Rename(tmpPath, filePath); err != nil {
|
|
cleanup()
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// DeleteFile removes a file relative to dataRoot.
|
|
func DeleteFile(dataRoot, relativePath string) error {
|
|
filePath, err := ResolvePath(dataRoot, relativePath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := rejectSymlinks(dataRoot, filePath); err != nil {
|
|
return err
|
|
}
|
|
|
|
info, err := os.Lstat(filePath)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return ErrNotFound
|
|
}
|
|
return err
|
|
}
|
|
if info.IsDir() {
|
|
return ErrIsDirectory
|
|
}
|
|
|
|
return os.Remove(filePath)
|
|
}
|
|
|
|
func rejectSymlinks(dataRoot, target string) error {
|
|
root, err := filepath.Abs(filepath.Clean(dataRoot))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
target, err = filepath.Abs(target)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if target == root {
|
|
info, err := os.Lstat(target)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
if info.Mode()&os.ModeSymlink != 0 {
|
|
return fmt.Errorf("%w: %s", ErrSymlink, target)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
rel, err := filepath.Rel(root, target)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
current := root
|
|
for _, part := range strings.Split(rel, string(filepath.Separator)) {
|
|
if part == "" || part == "." {
|
|
continue
|
|
}
|
|
current = filepath.Join(current, part)
|
|
info, err := os.Lstat(current)
|
|
if os.IsNotExist(err) {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if info.Mode()&os.ModeSymlink != 0 {
|
|
return fmt.Errorf("%w: %s", ErrSymlink, current)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|