// 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 }