package tools import ( "fmt" "os" "path/filepath" "strings" ) // Resolve joins rel to root and rejects paths that escape the workspace, // including symlink targets outside root. Missing files are allowed when their // existing ancestors stay inside root. func Resolve(root, rel string) (string, error) { rootAbs, err := filepath.Abs(root) if err != nil { return "", err } rootAbs = filepath.Clean(rootAbs) resolvedRoot, err := filepath.EvalSymlinks(rootAbs) if err != nil { return "", err } rel = strings.TrimSpace(rel) if rel == "" { rel = "." } if filepath.IsAbs(rel) { inside, err := filepath.Rel(rootAbs, filepath.Clean(rel)) if err != nil || escapes(inside) { return "", fmt.Errorf("path escapes workspace") } rel = inside } rel = filepath.Clean(rel) if escapes(rel) { return "", fmt.Errorf("path escapes workspace") } if rel == "." { return resolvedRoot, nil } current := resolvedRoot parts := strings.Split(rel, string(os.PathSeparator)) for i, part := range parts { if part == "" || part == "." { continue } if part == ".." { return "", fmt.Errorf("path escapes workspace") } next := filepath.Join(current, part) info, err := os.Lstat(next) if os.IsNotExist(err) { tail := filepath.Join(parts[i:]...) return filepath.Join(current, tail), nil } if err != nil { return "", err } if info.Mode()&os.ModeSymlink != 0 { target, err := filepath.EvalSymlinks(next) if err != nil { return "", fmt.Errorf("path escapes workspace") } relTarget, err := filepath.Rel(resolvedRoot, target) if err != nil || escapes(relTarget) { return "", fmt.Errorf("path escapes workspace") } current = target continue } current = next } relFinal, err := filepath.Rel(resolvedRoot, current) if err != nil || escapes(relFinal) { return "", fmt.Errorf("path escapes workspace") } return current, nil } func escapes(rel string) bool { if rel == ".." { return true } return strings.HasPrefix(rel, ".."+string(os.PathSeparator)) }