LCOV - code coverage report
Current view: top level - http/server/routing - route_pattern.cpp (source / functions) Coverage Total Hit
Test: coverage_filtered.info Lines: 93.3 % 341 318
Test Date: 2026-11-10 10:38:07 Functions: 100.0 % 24 24

            Line data    Source code
       1              : #include "route_pattern.hpp"
       2              : #include <algorithm>
       3              : #include <stdexcept>
       4              : 
       5              : namespace thinger::http::detail {
       6              : 
       7              : namespace {
       8              : 
       9        47714 :     bool is_name_start(char c) {
      10        47714 :         return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '_';
      11              :     }
      12              : 
      13        36763 :     bool is_name_char(char c) {
      14        36763 :         return is_name_start(c) || (c >= '0' && c <= '9');
      15              :     }
      16              : 
      17           60 :     bool is_alnum(char c) {
      18           60 :         return is_name_char(c) && c != '_';
      19              :     }
      20              : 
      21              :     // Position of the parenthesis closing the one at `open`, skipping escaped characters
      22              :     // and character classes; npos if it is not closed
      23         4947 :     size_t closing_parenthesis(std::string_view text, size_t open) {
      24         4947 :         int depth = 0;
      25         4947 :         bool in_class = false;
      26        27300 :         for (size_t i = open; i < text.size(); ++i) {
      27        27291 :             char c = text[i];
      28        27291 :             if (c == '\\') {
      29           84 :                 ++i;
      30        27207 :             } else if (in_class) {
      31         6303 :                 if (c == ']') in_class = false;
      32        20904 :             } else if (c == '[') {
      33         1425 :                 in_class = true;
      34        19479 :             } else if (c == '(') {
      35         4980 :                 ++depth;
      36        14499 :             } else if (c == ')' && --depth == 0) {
      37         4938 :                 return i;
      38              :             }
      39              :         }
      40            9 :         return std::string_view::npos;
      41              :     }
      42              : 
      43              :     // Capture groups of a regex: '(' not escaped, not in a class, not followed by '?'
      44           57 :     size_t capture_groups(std::string_view regex) {
      45           57 :         size_t groups = 0;
      46           57 :         bool in_class = false;
      47          399 :         for (size_t i = 0; i < regex.size(); ++i) {
      48          342 :             char c = regex[i];
      49          342 :             if (c == '\\') {
      50           36 :                 ++i;
      51          306 :             } else if (in_class) {
      52           66 :                 if (c == ']') in_class = false;
      53          240 :             } else if (c == '[') {
      54           12 :                 in_class = true;
      55          228 :             } else if (c == '(' && (i + 1 == regex.size() || regex[i + 1] != '?')) {
      56            9 :                 ++groups;
      57              :             }
      58              :         }
      59           57 :         return groups;
      60              :     }
      61              : 
      62              :     // Whether a regex may match '/'. Conservative: true unless it has none of the
      63              :     // constructs that may (any character, negated or POSIX classes, ranges including '/',
      64              :     // escapes of other characters...)
      65           27 :     bool regex_may_match_slash(std::string_view regex) {
      66           27 :         bool in_class = false;
      67          267 :         for (size_t i = 0; i < regex.size(); ++i) {
      68          240 :             char c = regex[i];
      69          240 :             if (c == '\\') {
      70           12 :                 if (++i == regex.size()) return true;
      71           12 :                 char escaped = regex[i];
      72           12 :                 if (escaped == '/' || (is_alnum(escaped) && std::string_view("dwsbB").find(escaped) == std::string_view::npos)) {
      73            0 :                     return true;
      74              :                 }
      75          228 :             } else if (in_class) {
      76           42 :                 if (c == ']') {
      77            6 :                     in_class = false;
      78           36 :                 } else if (c == '/' || c == '[') {
      79            0 :                     return true;
      80           36 :                 } else if (c == '-' && i + 1 < regex.size() && regex[i + 1] != ']') {
      81           12 :                     auto low = static_cast<unsigned char>(regex[i - 1]);
      82           12 :                     auto high = static_cast<unsigned char>(regex[i + 1]);
      83           12 :                     if (high == '\\' || (low <= '/' && high >= '/')) return true;
      84              :                 }
      85          186 :             } else if (c == '[') {
      86              :                 // negated classes, and [] (empty in ECMAScript, a literal ']' elsewhere)
      87            6 :                 if (i + 1 < regex.size() && (regex[i + 1] == '^' || regex[i + 1] == ']')) return true;
      88            6 :                 in_class = true;
      89          180 :             } else if (c == '.' || c == '/') {
      90            0 :                 return true;
      91              :             }
      92              :         }
      93           27 :         return false;
      94              :     }
      95              : 
      96              :     // Alternative words of a regex such as "properties|buckets|flows"; empty if it has
      97              :     // anything else than alternatives of literal characters
      98         3258 :     std::optional<std::vector<std::string>> regex_words(std::string_view regex) {
      99         3258 :         if (regex.find('|') == std::string_view::npos) return std::nullopt;
     100           36 :         std::vector<std::string> words(1);
     101          201 :         for (char c : regex) {
     102          189 :             if (c == '|') {
     103           12 :                 words.emplace_back();
     104          177 :             } else if (c == '\0' || std::string_view(".^$()[]{}*+?\\").find(c) != std::string_view::npos) {
     105           24 :                 return std::nullopt;
     106              :             } else {
     107          153 :                 words.back() += c;
     108              :             }
     109              :         }
     110           12 :         return words;
     111           36 :     }
     112              : 
     113          609 :     std::bitset<256> char_range(int low, int high) {
     114         1827 :         std::bitset<256> chars;
     115         8019 :         for (int c = low; c <= high; ++c) chars.set(c);
     116          609 :         return chars;
     117              :     }
     118              : 
     119              :     // Characters of \d, \w and \s (and their complements \D, \W, \S), as std::regex
     120              :     // matches them on char in the default locale
     121           45 :     bool class_escape(char escape, std::bitset<256>& chars) {
     122           45 :         switch (escape) {
     123           18 :             case 'd': case 'D':
     124           18 :                 chars = char_range('0', '9');
     125           18 :                 break;
     126            9 :             case 'w': case 'W':
     127            9 :                 chars = char_range('a', 'z') | char_range('A', 'Z') | char_range('0', '9');
     128            9 :                 chars.set('_');
     129            9 :                 break;
     130            0 :             case 's': case 'S':
     131            0 :                 chars = char_range('\t', '\r');
     132            0 :                 chars.set(' ');
     133            0 :                 break;
     134           18 :             default:
     135           18 :                 return false;
     136              :         }
     137           27 :         if (escape >= 'A' && escape <= 'Z') chars.flip();
     138           27 :         return true;
     139              :     }
     140              : 
     141              :     // Character class [...] starting at `i`; leaves `i` past it
     142          507 :     bool parse_class(std::string_view regex, size_t& i, std::bitset<256>& chars) {
     143          507 :         size_t j = i + 1;
     144          507 :         bool negated = j < regex.size() && regex[j] == '^';
     145          507 :         if (negated) ++j;
     146          507 :         if (j < regex.size() && regex[j] == ']') return false;  // [] and [^]
     147              : 
     148         1521 :         std::bitset<256> set;
     149              :         while (true) {
     150         1119 :             if (j >= regex.size()) return false;
     151         1119 :             char c = regex[j];
     152         1119 :             if (c == ']') {
     153          507 :                 ++j;
     154          507 :                 break;
     155              :             }
     156          612 :             if (c == '[') return false;  // POSIX classes
     157              : 
     158          612 :             int low = -1;
     159          612 :             if (c == '\\') {
     160            0 :                 if (j + 1 >= regex.size()) return false;
     161            0 :                 char escaped = regex[j + 1];
     162            0 :                 if (is_alnum(escaped)) {
     163            0 :                     std::bitset<256> escaped_chars;
     164            0 :                     if (!class_escape(escaped, escaped_chars)) return false;
     165            0 :                     set |= escaped_chars;
     166              :                 } else {
     167            0 :                     low = static_cast<unsigned char>(escaped);
     168              :                 }
     169            0 :                 j += 2;
     170              :             } else {
     171          612 :                 low = static_cast<unsigned char>(c);
     172          612 :                 ++j;
     173              :             }
     174          612 :             if (low < 0) continue;
     175              : 
     176              :             // Range low-high, unless '-' is the last character of the class
     177          612 :             if (j + 1 < regex.size() && regex[j] == '-' && regex[j + 1] != ']') {
     178              :                 int high;
     179          564 :                 char h = regex[j + 1];
     180          564 :                 if (h == '\\') {
     181            0 :                     if (j + 2 >= regex.size() || is_alnum(regex[j + 2])) return false;
     182            0 :                     high = static_cast<unsigned char>(regex[j + 2]);
     183            0 :                     j += 3;
     184          564 :                 } else if (h == '[') {
     185            0 :                     return false;
     186              :                 } else {
     187          564 :                     high = static_cast<unsigned char>(h);
     188          564 :                     j += 2;
     189              :                 }
     190          564 :                 if (high < low) return false;
     191          564 :                 set |= char_range(low, high);
     192              :             } else {
     193           48 :                 set.set(low);
     194              :             }
     195          612 :         }
     196          507 :         chars = negated ? ~set : set;
     197          507 :         i = j;
     198          507 :         return true;
     199              :     }
     200              : 
     201              :     // Quantifier {n}, {n,} or {n,m} starting at `i`; leaves `i` past it
     202           45 :     bool parse_braces(std::string_view regex, size_t& i, uint32_t& min, uint32_t& max, uint32_t unbounded) {
     203           75 :         auto number = [&](size_t& j, uint32_t& value) {
     204           75 :             size_t start = j;
     205           75 :             uint64_t result = 0;
     206          177 :             while (j < regex.size() && regex[j] >= '0' && regex[j] <= '9') {
     207          102 :                 result = result * 10 + (regex[j++] - '0');
     208          102 :                 if (result >= unbounded) return false;
     209              :             }
     210           75 :             value = static_cast<uint32_t>(result);
     211           75 :             return j > start;
     212           45 :         };
     213           45 :         size_t j = i + 1;
     214           45 :         if (!number(j, min)) return false;
     215           45 :         max = min;
     216           45 :         if (j < regex.size() && regex[j] == ',') {
     217           30 :             ++j;
     218           30 :             max = unbounded;
     219           30 :             if (j < regex.size() && regex[j] != '}' && !number(j, max)) return false;
     220              :         }
     221           45 :         if (j >= regex.size() || regex[j] != '}' || max < min) return false;
     222           45 :         i = j + 1;
     223           45 :         return true;
     224              :     }
     225              : 
     226              :     // A text matched against a regex the matcher could not compile, with the groups to capture.
     227              :     // A text longer than this does not match, and is not run through std::regex: libstdc++
     228              :     // matches recursively, about one frame per byte, so a request target of up to 16 KiB could
     229              :     // overflow the stack (as the Host name, capped before host patterns). 2 KiB leaves a wide
     230              :     // margin on default thread stacks and is the URL length long held as portable. Every other
     231              :     // matcher still takes the whole target, within the 8000 octets RFC 9110 section 4.1
     232              :     // recommends for a URI
     233          201 :     bool match_regex(const std::regex& regex, const std::vector<size_t>& groups, std::string_view text,
     234              :                      route_captures& captures) {
     235          201 :         constexpr size_t max_regex_text_size = 2048;
     236          201 :         if (text.size() > max_regex_text_size) return false;
     237          189 :         std::cmatch matches;
     238          189 :         if (!std::regex_match(text.data(), text.data() + text.size(), matches, regex)) return false;
     239           87 :         for (size_t group : groups) {
     240           45 :             const auto& capture = matches[group];
     241           45 :             captures.push_back(capture.matched ? std::string_view(capture.first, capture.length()) : std::string_view{});
     242              :         }
     243           42 :         return true;
     244          189 :     }
     245              : 
     246              : } // namespace
     247              : 
     248        69042 : std::vector<pattern_token> parse_route_pattern(std::string_view pattern) {
     249        69042 :     std::vector<pattern_token> tokens;
     250        69042 :     size_t i = 0;
     251       616289 :     while (i < pattern.size()) {
     252       547256 :         if (pattern[i] == ':' && i + 1 < pattern.size() && is_name_start(pattern[i + 1])) {
     253        10951 :             size_t end = i + 2;
     254        41974 :             while (end < pattern.size() && is_name_char(pattern[end])) ++end;
     255        21902 :             pattern_token token{true, std::string(pattern.substr(i + 1, end - i - 1)), {}};
     256        10951 :             if (end < pattern.size() && pattern[end] == '(') {
     257         4947 :                 size_t close = closing_parenthesis(pattern, end);
     258         4947 :                 if (close == std::string_view::npos) {
     259           27 :                     throw std::invalid_argument("Unbalanced parentheses in route pattern: " + std::string(pattern));
     260              :                 }
     261         4938 :                 token.regex = pattern.substr(end + 1, close - end - 1);
     262         4938 :                 end = close + 1;
     263              :             }
     264        10942 :             tokens.push_back(std::move(token));
     265        10942 :             i = end;
     266        10942 :             continue;
     267        10951 :         }
     268       607277 :         if (tokens.empty() || tokens.back().parameter) tokens.push_back({});
     269       536305 :         tokens.back().text += pattern[i++];
     270              :     }
     271        69033 :     return tokens;
     272        70981 : }
     273              : 
     274         4880 : bool regex_depends_on_context(std::string_view regex) {
     275         4880 :     bool in_class = false;
     276        11834 :     for (size_t i = 0; i < regex.size(); ++i) {
     277         6966 :         char c = regex[i];
     278         6966 :         if (c == '\\') {
     279           36 :             if (++i == regex.size()) return false;
     280           36 :             if (!in_class && (regex[i] == 'b' || regex[i] == 'B' || (regex[i] >= '1' && regex[i] <= '9'))) return true;
     281         6930 :         } else if (in_class) {
     282         2256 :             if (c == ']') in_class = false;
     283         4674 :         } else if (c == '[') {
     284          507 :             if (i + 1 < regex.size() && regex[i + 1] == '^') ++i;
     285              :             // [] and [^] end where they start in ECMAScript
     286          507 :             if (i + 1 < regex.size() && regex[i + 1] == ']') {
     287            0 :                 ++i;
     288              :             } else {
     289          507 :                 in_class = true;
     290              :             }
     291         4167 :         } else if (c == '^' || c == '$' || regex.substr(i, 3) == "(?=" || regex.substr(i, 3) == "(?!") {
     292            3 :             return true;
     293              :         }
     294              :     }
     295         4868 :     return false;
     296              : }
     297              : 
     298         3900 : std::string pattern_regex(std::span<const pattern_token> tokens) {
     299         3900 :     std::string regex;
     300         9522 :     for (const auto& token : tokens) {
     301         5622 :         if (token.parameter) {
     302         4248 :             regex += '(';
     303         4470 :             regex += token.regex.empty() ? "[^/]+" : token.regex;
     304         4248 :             regex += ')';
     305         4248 :             continue;
     306              :         }
     307         5235 :         for (char c : token.text) {
     308         3861 :             if (std::string_view(".^$|()[]{}*+?\\").find(c) != std::string_view::npos) regex += '\\';
     309         3861 :             regex += c;
     310              :         }
     311              :     }
     312         3900 :     return regex;
     313            0 : }
     314              : 
     315         3669 : path_matcher::path_matcher(std::span<const pattern_token> tokens)
     316         3669 :     : key_(pattern_regex(tokens))
     317              : {
     318         3669 :     if (tokens.size() == 1 && tokens.front().parameter) {
     319         3258 :         words_ = regex_words(tokens.front().regex);
     320         3258 :         if (words_) {
     321           12 :             may_match_slash_ = std::any_of(words_->begin(), words_->end(),
     322           24 :                                            [](const std::string& word) { return word.find('/') != std::string::npos; });
     323         3618 :             return;
     324              :         }
     325              :     }
     326              : 
     327         3657 :     bool compiled = true;
     328         8061 :     for (const auto& token : tokens) {
     329         4455 :         if (!token.parameter) {
     330         2436 :             for (char c : token.text) {
     331         4950 :                 atom literal;
     332         1650 :                 literal.chars.set(static_cast<unsigned char>(c));
     333         1650 :                 atoms_.push_back(literal);
     334              :             }
     335          786 :             continue;
     336          786 :         }
     337         3669 :         size_t first = atoms_.size();
     338         3669 :         if (token.regex.empty()) {
     339           36 :             atom any;
     340           12 :             any.chars.set();
     341           12 :             any.chars.reset('/');
     342           12 :             any.max = unbounded;
     343           12 :             atoms_.push_back(any);
     344         3657 :         } else if (!compile_regex(token.regex, atoms_)) {
     345           51 :             compiled = false;
     346           51 :             break;
     347              :         }
     348         3618 :         groups_.push_back({static_cast<uint16_t>(first), static_cast<uint16_t>(atoms_.size())});
     349              :     }
     350              : 
     351         3657 :     if (compiled && atoms_.size() <= max_atoms) {
     352         7314 :         may_match_slash_ = std::any_of(atoms_.begin(), atoms_.end(), [](const atom& a) { return a.chars['/']; });
     353         3606 :         catch_all_ = may_match_slash_ && atoms_.size() == 1 && groups_.size() == 1;
     354         3606 :         return;
     355              :     }
     356              : 
     357              :     // Fall back to std::regex
     358           51 :     atoms_.clear();
     359           51 :     groups_.clear();
     360           51 :     regex_.emplace(key_);
     361           51 :     size_t group = 1;
     362          144 :     for (const auto& token : tokens) {
     363           93 :         if (token.parameter) {
     364           57 :             regex_groups_.push_back(group);
     365           57 :             group += 1 + capture_groups(token.regex);
     366           57 :             may_match_slash_ = may_match_slash_ || regex_may_match_slash(token.regex);
     367              :         } else {
     368           36 :             may_match_slash_ = may_match_slash_ || token.text.find('/') != std::string::npos;
     369              :         }
     370              :     }
     371           51 :     catch_all_ = false;
     372            0 : }
     373              : 
     374         3657 : bool path_matcher::compile_regex(std::string_view regex, std::vector<atom>& atoms) {
     375         3657 :     size_t i = 0;
     376         7332 :     while (i < regex.size()) {
     377        11178 :         atom a;
     378         3726 :         char c = regex[i];
     379         3726 :         if (c == '[') {
     380          558 :             if (!parse_class(regex, i, a.chars)) return false;
     381         3219 :         } else if (c == '.') {
     382         3108 :             a.chars.set();
     383         3108 :             a.chars.reset('\n');
     384         3108 :             a.chars.reset('\r');
     385         3108 :             ++i;
     386          111 :         } else if (c == '\\') {
     387           48 :             if (i + 1 >= regex.size()) return false;
     388           48 :             char escaped = regex[i + 1];
     389           48 :             if (is_alnum(escaped)) {
     390           45 :                 if (!class_escape(escaped, a.chars)) return false;
     391              :             } else {
     392            3 :                 a.chars.set(static_cast<unsigned char>(escaped));
     393              :             }
     394           30 :             i += 2;
     395           63 :         } else if (c == '\0' || std::string_view("()|^$*+?{}]").find(c) != std::string_view::npos) {
     396           33 :             return false;
     397              :         } else {
     398           30 :             a.chars.set(static_cast<unsigned char>(c));
     399           30 :             ++i;
     400              :         }
     401              : 
     402              :         // Quantifier (greedy: lazy ones may change how captures split)
     403         3675 :         if (i < regex.size()) {
     404         3675 :             bool quantified = true;
     405         3675 :             switch (regex[i]) {
     406         1572 :                 case '*': a.min = 0; a.max = unbounded; ++i; break;
     407         2025 :                 case '+': a.min = 1; a.max = unbounded; ++i; break;
     408            0 :                 case '?': a.min = 0; a.max = 1; ++i; break;
     409           45 :                 case '{':
     410           45 :                     if (!parse_braces(regex, i, a.min, a.max, unbounded)) return false;
     411           45 :                     break;
     412           33 :                 default: quantified = false;
     413              :             }
     414         3675 :             if (quantified && i < regex.size() && regex[i] == '?') return false;
     415              :         }
     416              : 
     417         3675 :         if (atoms.size() >= max_atoms) return false;
     418         3675 :         atoms.push_back(a);
     419              :     }
     420         3606 :     return true;
     421              : }
     422              : 
     423        61437 : bool path_matcher::match_atoms(size_t index, size_t position, std::string_view text, size_t* starts) const {
     424        61437 :     if (index == atoms_.size()) return position == text.size();
     425        61437 :     starts[index] = position;
     426        61437 :     const auto& a = atoms_[index];
     427              : 
     428              :     // Longest run of the atom characters, then backtrack down to its minimum (greedy)
     429        61437 :     size_t limit = std::min<size_t>(a.max, text.size() - position);
     430        61437 :     size_t run = 0;
     431       172854 :     while (run < limit && a.chars[static_cast<unsigned char>(text[position + run])]) ++run;
     432        61437 :     if (run < a.min) return false;
     433        15543 :     if (index + 1 == atoms_.size()) return position + run == text.size();
     434        68028 :     for (size_t count = run + 1; count-- > a.min;) {
     435        57411 :         if (match_atoms(index + 1, position + count, text, starts)) return true;
     436              :     }
     437        10617 :     return false;
     438              : }
     439              : 
     440         7818 : bool path_matcher::match(std::string_view text, route_captures& captures) const {
     441         7818 :     if (words_) {
     442            6 :         if (std::find(words_->begin(), words_->end(), text) == words_->end()) return false;
     443            3 :         captures.push_back(text);
     444            3 :         return true;
     445              :     }
     446              : 
     447         7812 :     if (regex_) return match_regex(*regex_, regex_groups_, text, captures);
     448              : 
     449              :     // A single atom, as a parameter with a character class: check length and characters
     450         7611 :     if (atoms_.size() == 1) {
     451         3585 :         const auto& a = atoms_.front();
     452         3585 :         if (text.size() < a.min || text.size() > a.max) return false;
     453       116451 :         for (char c : text) {
     454       114393 :             if (!a.chars[static_cast<unsigned char>(c)]) return false;
     455              :         }
     456         2058 :         if (!groups_.empty()) captures.push_back(text);
     457         2058 :         return true;
     458              :     }
     459              : 
     460              :     size_t starts[max_atoms + 1];
     461         4026 :     if (!match_atoms(0, 0, text, starts)) return false;
     462          885 :     starts[atoms_.size()] = text.size();
     463         1776 :     for (const auto& g : groups_) {
     464          891 :         captures.push_back(text.substr(starts[g.first], starts[g.last] - starts[g.first]));
     465              :     }
     466          885 :     return true;
     467              : }
     468              : 
     469           78 : std::bitset<256> path_matcher::first_chars() const {
     470          234 :     std::bitset<256> chars;
     471           78 :     if (words_) {
     472           27 :         for (const auto& word : *words_) {
     473           18 :             if (word.empty()) return std::bitset<256>().set();
     474           18 :             chars.set(static_cast<unsigned char>(word.front()));
     475              :         }
     476            9 :         return chars;
     477              :     }
     478           69 :     if (!regex_) {
     479           48 :         for (const auto& a : atoms_) {
     480           45 :             chars |= a.chars;
     481           45 :             if (a.min > 0) return chars;
     482              :         }
     483              :     }
     484              :     // Unknown, or the match may be empty
     485           27 :     return std::bitset<256>().set();
     486              : }
     487              : 
     488           39 : bool path_matcher::may_overlap(const path_matcher& other) const {
     489           39 :     return (first_chars() & other.first_chars()).any();
     490              : }
     491              : 
     492              : } // namespace thinger::http::detail
        

Generated by: LCOV version 2.0-1