166 lines
4.5 KiB
Go
166 lines
4.5 KiB
Go
// Package fileio provides confined, atomic artifact writes.
|
|
package fileio
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
// EncodePathComponent returns a filesystem-safe, injective representation of
|
|
// one logical path component.
|
|
func EncodePathComponent(value string) string {
|
|
if value == "" {
|
|
return "%"
|
|
}
|
|
|
|
const hexadecimal = "0123456789ABCDEF"
|
|
var out strings.Builder
|
|
for index := 0; index < len(value); index++ {
|
|
byteValue := value[index]
|
|
switch {
|
|
case byteValue >= 'a' && byteValue <= 'z', byteValue >= 'A' && byteValue <= 'Z', byteValue >= '0' && byteValue <= '9', byteValue == '-', byteValue == '_':
|
|
out.WriteByte(byteValue)
|
|
case byteValue == '.' && safePathDot(value, index):
|
|
out.WriteByte(byteValue)
|
|
default:
|
|
out.WriteByte('%')
|
|
out.WriteByte(hexadecimal[byteValue>>4])
|
|
out.WriteByte(hexadecimal[byteValue&0x0f])
|
|
}
|
|
}
|
|
return out.String()
|
|
}
|
|
|
|
func safePathDot(value string, index int) bool {
|
|
if value == "." || value == ".." {
|
|
return false
|
|
}
|
|
return (index == 0 || value[index-1] != '.') && (index+1 == len(value) || value[index+1] != '.')
|
|
}
|
|
|
|
func SafePath(root, name string) (string, error) {
|
|
root = strings.TrimSpace(root)
|
|
if root == "" {
|
|
return "", fmt.Errorf("file root must not be empty")
|
|
}
|
|
name = strings.TrimSpace(name)
|
|
if name == "" {
|
|
return "", fmt.Errorf("artifact name must not be empty")
|
|
}
|
|
if strings.ContainsRune(name, '\\') {
|
|
return "", fmt.Errorf("artifact name %q must use slash-separated relative paths", name)
|
|
}
|
|
if path.IsAbs(name) || filepath.IsAbs(name) {
|
|
return "", fmt.Errorf("artifact name %q must be relative", name)
|
|
}
|
|
if name == "." || strings.Contains(name, "..") {
|
|
return "", fmt.Errorf("artifact name %q must not contain ..", name)
|
|
}
|
|
if path.Clean(name) != name {
|
|
return "", fmt.Errorf("artifact name %q must be clean", name)
|
|
}
|
|
absRoot, err := filepath.Abs(root)
|
|
if err != nil {
|
|
return "", fmt.Errorf("resolve file root %q: %w", root, err)
|
|
}
|
|
target, err := filepath.Abs(filepath.Join(absRoot, filepath.FromSlash(name)))
|
|
if err != nil {
|
|
return "", fmt.Errorf("resolve artifact %q: %w", name, err)
|
|
}
|
|
rel, err := filepath.Rel(absRoot, target)
|
|
if err != nil {
|
|
return "", fmt.Errorf("resolve artifact %q: %w", name, err)
|
|
}
|
|
if rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
|
return "", fmt.Errorf("artifact name %q resolves outside file root", name)
|
|
}
|
|
if err := rejectSymlinkComponents(absRoot, name); err != nil {
|
|
return "", err
|
|
}
|
|
return target, nil
|
|
}
|
|
|
|
func WriteJSON(root, name string, payload any, dirMode, fileMode os.FileMode) error {
|
|
data, err := json.MarshalIndent(payload, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("marshal artifact %q: %w", name, err)
|
|
}
|
|
return WriteBytes(root, name, append(data, '\n'), dirMode, fileMode)
|
|
}
|
|
|
|
func WriteBytes(root, name string, data []byte, dirMode, fileMode os.FileMode) error {
|
|
target, err := SafePath(root, name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(target), dirMode); err != nil {
|
|
return fmt.Errorf("write artifact %q: %w", name, err)
|
|
}
|
|
if err := rejectSymlinkComponents(root, name); err != nil {
|
|
return err
|
|
}
|
|
if err := writeAtomic(target, data, fileMode); err != nil {
|
|
return fmt.Errorf("write artifact %q: %w", name, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func rejectSymlinkComponents(root, name string) error {
|
|
absRoot, err := filepath.Abs(root)
|
|
if err != nil {
|
|
return fmt.Errorf("resolve file root %q: %w", root, err)
|
|
}
|
|
current := absRoot
|
|
for _, component := range strings.Split(filepath.FromSlash(name), string(filepath.Separator)) {
|
|
if component == "" || component == "." {
|
|
continue
|
|
}
|
|
current = filepath.Join(current, component)
|
|
info, err := os.Lstat(current)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("inspect artifact path %q: %w", name, err)
|
|
}
|
|
if info.Mode()&os.ModeSymlink != 0 {
|
|
return fmt.Errorf("artifact path %q must not traverse symbolic links", name)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func writeAtomic(target string, data []byte, fileMode os.FileMode) error {
|
|
temp, err := os.CreateTemp(filepath.Dir(target), "."+filepath.Base(target)+".tmp-*")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tempPath := temp.Name()
|
|
keep := true
|
|
defer func() {
|
|
if keep {
|
|
_ = os.Remove(tempPath)
|
|
}
|
|
}()
|
|
if _, err := temp.Write(data); err != nil {
|
|
_ = temp.Close()
|
|
return err
|
|
}
|
|
if err := temp.Chmod(fileMode); err != nil {
|
|
_ = temp.Close()
|
|
return err
|
|
}
|
|
if err := temp.Close(); err != nil {
|
|
return err
|
|
}
|
|
if err := os.Rename(tempPath, target); err != nil {
|
|
return err
|
|
}
|
|
keep = false
|
|
return nil
|
|
}
|