Files
narratio/internal/pathsafe/root_scoped.go

59 lines
1.7 KiB
Go

package pathsafe
import (
"fmt"
"path/filepath"
"strings"
)
// JoinSlashRelativeUnderRoot validates a slash-style relative path and resolves
// it under root. The returned path uses the host filepath separator.
func JoinSlashRelativeUnderRoot(root, relative string) (string, error) {
rootClean := filepath.Clean(strings.TrimSpace(root))
if rootClean == "." || rootClean == "" {
return "", fmt.Errorf("root path is required")
}
normalized, err := NormalizeRelativeDestination(relative)
if err != nil {
return "", err
}
joined := filepath.Clean(filepath.Join(rootClean, filepath.FromSlash(normalized)))
rel, err := filepath.Rel(rootClean, joined)
if err != nil {
return "", fmt.Errorf("resolve relative path under root: %w", err)
}
if rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return "", ErrRelativePathEscape
}
return joined, nil
}
// SlashRelativeFromRoot derives a slash-style relative path for target under
// root. Target may be absolute or relative to root.
func SlashRelativeFromRoot(root, target string) (string, error) {
rootClean := filepath.Clean(strings.TrimSpace(root))
if rootClean == "." || rootClean == "" {
return "", fmt.Errorf("root path is required")
}
targetClean := filepath.Clean(strings.TrimSpace(target))
if targetClean == "." || targetClean == "" {
return "", ErrRelativePathRequired
}
if !filepath.IsAbs(targetClean) {
targetClean = filepath.Clean(filepath.Join(rootClean, targetClean))
}
rel, err := filepath.Rel(rootClean, targetClean)
if err != nil {
return "", fmt.Errorf("derive path relative to root: %w", err)
}
normalized, err := NormalizeRelativeDestination(filepath.ToSlash(rel))
if err != nil {
return "", err
}
return normalized, nil
}