137 lines
4.1 KiB
Go
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
|
|
}
|