Files
relspecgo/pkg/safefile/root.go
T

137 lines
4.1 KiB
Go

// Package safefile provides portable path validation and root-scoped file access.
package safefile
import (
"fmt"
"io"
"os"
"path/filepath"
"strings"
"unicode"
)
// Relative validates artifact paths consistently on Unix and Windows. It rejects
// traversal rather than normalizing it, so aliases cannot hide filename collisions.
func Relative(name string) (string, error) {
if name == "" || !filepath.IsLocal(name) || strings.ContainsAny(name, `\:<>"|?*`) {
return "", fmt.Errorf("unsafe relative path %q", name)
}
for _, r := range name {
if unicode.IsControl(r) {
return "", fmt.Errorf("control character in path %q", name)
}
}
for _, part := range strings.Split(filepath.ToSlash(name), "/") {
if part == "" || part == "." || part == ".." || strings.HasSuffix(part, ".") || strings.HasSuffix(part, " ") {
return "", fmt.Errorf("unsafe path component in %q", name)
}
base := strings.ToUpper(strings.SplitN(part, ".", 2)[0])
device := base == "CON" || base == "PRN" || base == "AUX" || base == "NUL" || base == "CONIN$" || base == "CONOUT$"
for _, prefix := range []string{"COM", "LPT"} {
if strings.HasPrefix(base, prefix) {
suffix := []rune(strings.TrimPrefix(base, prefix))
device = device || (len(suffix) == 1 && strings.ContainsRune("123456789¹²³", suffix[0]))
}
}
if device {
return "", fmt.Errorf("reserved device path %q", name)
}
}
return filepath.FromSlash(name), nil
}
// Check rejects existing symlinks and special files; os.Root separately enforces
// containment at open/rename time, including if a symlink changes after this check.
func Check(root *os.Root, name string) error {
if _, err := Relative(filepath.ToSlash(name)); err != nil {
return err
}
parts := strings.Split(name, string(filepath.Separator))
current := ""
for i, part := range parts {
current = filepath.Join(current, part)
info, err := root.Lstat(current)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return err
}
if info.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("symlink is not allowed in path %q", name)
}
if i < len(parts)-1 && !info.IsDir() {
return fmt.Errorf("parent is not a directory in path %q", name)
}
if i == len(parts)-1 && !info.Mode().IsRegular() {
return fmt.Errorf("path is not a regular file: %q", name)
}
}
return nil
}
// Read opens through a pinned root and bounds both the declared and actual size.
func Read(root *os.Root, name string, maxBytes int64) ([]byte, error) {
if maxBytes <= 0 || maxBytes == (1<<63)-1 {
return nil, fmt.Errorf("file size limit must be positive and bounded")
}
if err := Check(root, name); err != nil {
return nil, err
}
file, err := root.Open(name)
if err != nil {
return nil, err
}
defer file.Close()
info, err := file.Stat()
if err != nil {
return nil, err
}
if !info.Mode().IsRegular() {
return nil, fmt.Errorf("asset is not a regular file: %q", name)
}
if info.Size() > maxBytes {
return nil, fmt.Errorf("asset %q exceeds %d byte limit", name, maxBytes)
}
data, err := io.ReadAll(io.LimitReader(file, maxBytes+1))
if err != nil {
return nil, err
}
if int64(len(data)) > maxBytes {
return nil, fmt.Errorf("asset %q exceeds %d byte limit", name, maxBytes)
}
return data, nil
}
// CheckRoot rejects symlinks in the configured root or its existing ancestors.
// The configured root is a trusted caller-selected boundary; OpenRoot pins it for
// subsequent operations. Missing components are allowed for output root creation.
func CheckRoot(path string) error {
absolute, err := filepath.Abs(path)
if err != nil {
return err
}
volume := filepath.VolumeName(absolute)
current := volume + string(filepath.Separator)
for _, part := range strings.Split(strings.TrimPrefix(absolute, current), string(filepath.Separator)) {
if 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("symlink is not allowed in root path %q", path)
}
if !info.IsDir() {
return fmt.Errorf("root parent is not a directory: %q", current)
}
}
return nil
}