Files
notarius/internal/core/fileio/fileio.go

247 lines
7.0 KiB
Go

// Package fileio provides confined, atomic artifact writes.
package fileio
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"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)
}
// WriteJSONWithProcessPermissions writes indented JSON beneath root using the
// process and filesystem creation policy for new directories and files.
func WriteJSONWithProcessPermissions(root, name string, payload any) error {
data, err := json.MarshalIndent(payload, "", " ")
if err != nil {
return fmt.Errorf("marshal artifact %q: %w", name, err)
}
return WriteBytesWithProcessPermissions(root, name, append(data, '\n'))
}
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
}
// WriteBytesWithProcessPermissions atomically writes data beneath root while
// allowing the process umask and filesystem default ACLs to select effective
// permissions for newly created entries.
func WriteBytesWithProcessPermissions(root, name string, data []byte) error {
target, err := SafePath(root, name)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(target), 0o777); err != nil {
return fmt.Errorf("write artifact %q: %w", name, err)
}
if err := rejectSymlinkComponents(root, name); err != nil {
return err
}
if err := writeAtomicWithProcessPermissions(target, data); 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
}
func writeAtomicWithProcessPermissions(target string, data []byte) error {
directory := filepath.Dir(target)
base := filepath.Base(target)
seen := make(map[string]struct{}, 32)
for attempt := 0; attempt < 32; attempt++ {
suffix := make([]byte, 16)
if _, err := rand.Read(suffix); err != nil {
return fmt.Errorf("generate temporary file name: %w", err)
}
temporaryPath := filepath.Join(directory, "."+base+".tmp-"+hex.EncodeToString(suffix))
if _, exists := seen[temporaryPath]; exists {
continue
}
seen[temporaryPath] = struct{}{}
temporary, err := os.OpenFile(temporaryPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o666)
if errors.Is(err, os.ErrExist) {
continue
}
if err != nil {
return err
}
return writeAndPublish(temporary, temporaryPath, target, data)
}
return fmt.Errorf("create temporary file for %q: exhausted unique names", target)
}
func writeAndPublish(temporary *os.File, temporaryPath, target string, data []byte) error {
keep := true
defer func() {
if keep {
_ = os.Remove(temporaryPath)
}
}()
if _, err := temporary.Write(data); err != nil {
_ = temporary.Close()
return err
}
if err := temporary.Close(); err != nil {
return err
}
if err := os.Rename(temporaryPath, target); err != nil {
return err
}
keep = false
return nil
}