Files
narratio/internal/fileops/read_confined.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)
}