122 lines
3.6 KiB
Go
122 lines
3.6 KiB
Go
package fileops
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
)
|
|
|
|
// ValidateConfinedDirectory opens path without following symbolic links and
|
|
// applies validate to the opened directory's metadata.
|
|
func ValidateConfinedDirectory(path string, validate func(os.FileInfo) error) error {
|
|
root, err := openConfinedDirectory(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() { _ = root.Close() }()
|
|
info, err := root.Stat(".")
|
|
if err != nil {
|
|
return fmt.Errorf("inspect root directory: %w", err)
|
|
}
|
|
if !info.IsDir() {
|
|
return fmt.Errorf("root path is not a directory")
|
|
}
|
|
if validate != nil {
|
|
return validate(info)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ReadRegularFileUnderRoot reads a bounded regular file after opening root and
|
|
// every relative-path ancestor without following symbolic links. validateRoot
|
|
// and validateFile may enforce caller-owned access policy while their handles
|
|
// are still verified.
|
|
func ReadRegularFileUnderRoot(
|
|
rootPath, relativePath string,
|
|
maxBytes int64,
|
|
validateRoot, validateFile func(os.FileInfo) error,
|
|
) ([]byte, error) {
|
|
if maxBytes < 0 {
|
|
return nil, fmt.Errorf("maximum byte count must not be negative")
|
|
}
|
|
if filepath.IsAbs(relativePath) {
|
|
return nil, fmt.Errorf("relative file path must not be absolute")
|
|
}
|
|
parts, err := relativePathParts(relativePath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid relative file path: %w", err)
|
|
}
|
|
root, err := openConfinedDirectory(rootPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer func() { _ = root.Close() }()
|
|
rootInfo, err := root.Stat(".")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("inspect root directory: %w", err)
|
|
}
|
|
if validateRoot != nil {
|
|
if err := validateRoot(rootInfo); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
for _, part := range parts[:len(parts)-1] {
|
|
child, err := openConfinedChild(root, part, false, 0)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
_ = root.Close()
|
|
root = child
|
|
}
|
|
|
|
name := parts[len(parts)-1]
|
|
inspected, err := root.Lstat(name)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("inspect file %q: %w", name, err)
|
|
}
|
|
if inspected.Mode()&os.ModeSymlink != 0 || !inspected.Mode().IsRegular() {
|
|
return nil, fmt.Errorf("file %q is not a regular file", name)
|
|
}
|
|
file, err := root.Open(name)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open file %q: %w", name, err)
|
|
}
|
|
defer func() { _ = file.Close() }()
|
|
opened, err := file.Stat()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("inspect opened file %q: %w", name, err)
|
|
}
|
|
current, err := root.Lstat(name)
|
|
if err != nil || current.Mode()&os.ModeSymlink != 0 || !current.Mode().IsRegular() || !os.SameFile(opened, current) {
|
|
if err != nil {
|
|
return nil, fmt.Errorf("reinspect file %q: %w", name, err)
|
|
}
|
|
return nil, fmt.Errorf("file %q changed while being opened", name)
|
|
}
|
|
if validateFile != nil {
|
|
if err := validateFile(opened); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
content, err := io.ReadAll(io.LimitReader(file, maxBytes+1))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read file %q: %w", name, err)
|
|
}
|
|
if int64(len(content)) > maxBytes {
|
|
return nil, fmt.Errorf("file %q exceeds %d-byte limit", name, maxBytes)
|
|
}
|
|
return content, nil
|
|
}
|
|
|
|
// ReadRegularFile reads a bounded regular file without following symbolic links
|
|
// in its parent hierarchy. It is intended for externally produced results;
|
|
// callers own the limit and semantic validation contract.
|
|
func ReadRegularFile(path string, maxBytes int64) ([]byte, error) {
|
|
clean := filepath.Clean(path)
|
|
if clean == "." || filepath.Base(clean) == "." {
|
|
return nil, fmt.Errorf("file path is required")
|
|
}
|
|
return ReadRegularFileUnderRoot(filepath.Dir(clean), filepath.Base(clean), maxBytes, nil, nil)
|
|
}
|