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