LCOV - code coverage report
Current view: top level - src/esys/repo/sharing - identity_sharing.cpp (source / functions) Hit Total Coverage
Test: esysrepo_coverage.info Lines: 167 190 87.9 %
Date: 2026-11-07 22:18:26 Functions: 20 20 100.0 %

          Line data    Source code
       1             : /*!
       2             :  * \file esys/repo/sharing/identity_sharing.cpp
       3             :  * \brief Normalized repository URL identity (ADR-0042)
       4             :  *
       5             :  * \cond
       6             :  * __legal_b__
       7             :  *
       8             :  * Copyright (c) 2026 Michel Gillet
       9             :  * Distributed under the MIT License.
      10             :  * (See accompanying file LICENSE.txt or
      11             :  * copy at https://opensource.org/licenses/MIT)
      12             :  *
      13             :  * __legal_e__
      14             :  * \endcond
      15             :  *
      16             :  */
      17             : 
      18             : #include "esys/repo/esysrepo_prec.h"
      19             : #include "esys/repo/sharing/identity.h"
      20             : 
      21             : #include <algorithm>
      22             : #include <cctype>
      23             : 
      24             : namespace esys::repo::sharing
      25             : {
      26             : namespace
      27             : {
      28             : 
      29         162 : std::string trim_copy(const std::string &s)
      30             : {
      31         162 :     auto begin = s.find_first_not_of(" \t\r\n");
      32         162 :     if (begin == std::string::npos) return "";
      33         162 :     auto end = s.find_last_not_of(" \t\r\n");
      34         162 :     return s.substr(begin, end - begin + 1);
      35             : }
      36             : 
      37          57 : std::string to_lower(std::string s)
      38             : {
      39         453 :     std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) { return static_cast<char>(std::tolower(c)); });
      40          57 :     return s;
      41             : }
      42             : 
      43          91 : bool starts_with_ci(const std::string &s, const std::string &prefix)
      44             : {
      45          91 :     if (s.size() < prefix.size()) return false;
      46         296 :     for (std::size_t i = 0; i < prefix.size(); ++i)
      47             :     {
      48         269 :         if (std::tolower(static_cast<unsigned char>(s[i])) != std::tolower(static_cast<unsigned char>(prefix[i])))
      49             :             return false;
      50             :     }
      51             :     return true;
      52             : }
      53             : 
      54          27 : void replace_all(std::string &s, char from, char to)
      55             : {
      56         559 :     for (char &c : s)
      57         532 :         if (c == from) c = to;
      58             : }
      59             : 
      60          27 : void collapse_slashes(std::string &s)
      61             : {
      62          27 :     std::string out;
      63          27 :     out.reserve(s.size());
      64          27 :     bool prev_slash = false;
      65         559 :     for (char c : s)
      66             :     {
      67         532 :         if (c == '/')
      68             :         {
      69          37 :             if (!prev_slash) out.push_back(c);
      70             :             prev_slash = true;
      71             :         }
      72             :         else
      73             :         {
      74         495 :             out.push_back(c);
      75             :             prev_slash = false;
      76             :         }
      77             :     }
      78          27 :     s.swap(out);
      79          27 : }
      80             : 
      81          60 : void strip_trailing_slash(std::string &s)
      82             : {
      83          60 :     while (!s.empty() && s.back() == '/') s.pop_back();
      84          60 : }
      85             : 
      86          30 : void strip_dot_git(std::string &s)
      87             : {
      88          30 :     if (s.size() >= 4)
      89             :     {
      90          30 :         auto tail = s.substr(s.size() - 4);
      91          30 :         if (to_lower(tail) == ".git") s.resize(s.size() - 4);
      92          30 :     }
      93          30 : }
      94             : 
      95         119 : bool has_scheme(const std::string &url)
      96             : {
      97         119 :     auto pos = url.find("://");
      98         119 :     if (pos == std::string::npos) return false;
      99             :     // scheme must be alphabetic before ://
     100          96 :     if (pos == 0) return false;
     101         552 :     for (std::size_t i = 0; i < pos; ++i)
     102             :     {
     103         456 :         unsigned char c = static_cast<unsigned char>(url[i]);
     104         456 :         if (!std::isalpha(c) && c != '+' && c != '-' && c != '.') return false;
     105             :     }
     106             :     return true;
     107             : }
     108             : 
     109          41 : bool is_scp_like(const std::string &url)
     110             : {
     111          41 :     if (has_scheme(url)) return false;
     112             :     // user@host:path  (colon after @, not Windows drive)
     113          12 :     auto at = url.find('@');
     114          12 :     if (at == std::string::npos) return false;
     115           3 :     auto colon = url.find(':', at + 1);
     116           3 :     if (colon == std::string::npos) return false;
     117             :     return true;
     118             : }
     119             : 
     120          42 : bool is_windows_drive(const std::string &url)
     121             : {
     122          42 :     return url.size() >= 3 && std::isalpha(static_cast<unsigned char>(url[0])) && url[1] == ':' &&
     123           0 :            (url[2] == '/' || url[2] == '\\');
     124             : }
     125             : 
     126             : //! Convert scp / ssh / https / git forms into host + path (path may be empty)
     127          27 : bool extract_host_path(std::string url, std::string &host, std::string &path, std::string &err)
     128             : {
     129          27 :     url = trim_copy(url);
     130          27 :     if (url.empty())
     131             :     {
     132           0 :         err = "empty URL";
     133           0 :         return false;
     134             :     }
     135             : 
     136          27 :     if (is_scp_like(url))
     137             :     {
     138           1 :         auto at = url.find('@');
     139           1 :         auto colon = url.find(':', at + 1);
     140           1 :         host = url.substr(at + 1, colon - at - 1);
     141           1 :         path = url.substr(colon + 1);
     142           1 :         return true;
     143             :     }
     144             : 
     145             :     // Strip known schemes
     146          26 :     std::string rest = url;
     147          26 :     if (starts_with_ci(rest, "ssh://"))
     148           4 :         rest = rest.substr(6);
     149          22 :     else if (starts_with_ci(rest, "https://"))
     150          22 :         rest = rest.substr(8);
     151           0 :     else if (starts_with_ci(rest, "http://"))
     152           0 :         rest = rest.substr(7);
     153           0 :     else if (starts_with_ci(rest, "git://"))
     154           0 :         rest = rest.substr(6);
     155           0 :     else if (has_scheme(rest))
     156             :     {
     157           0 :         err = "unsupported URL scheme for sharing identity";
     158             :         return false;
     159             :     }
     160             : 
     161             :     // Optional userinfo user@host/...
     162          26 :     auto slash = rest.find('/');
     163          26 :     std::string authority = (slash == std::string::npos) ? rest : rest.substr(0, slash);
     164          26 :     path = (slash == std::string::npos) ? "" : rest.substr(slash + 1);
     165             : 
     166          26 :     auto at = authority.rfind('@');
     167          26 :     if (at != std::string::npos) authority = authority.substr(at + 1);
     168             : 
     169             :     // Drop port if present (host:port)
     170          26 :     auto colon = authority.find(':');
     171          26 :     if (colon != std::string::npos) authority = authority.substr(0, colon);
     172             : 
     173          26 :     host = authority;
     174          26 :     if (host.empty())
     175             :     {
     176          26 :         err = "URL has no host";
     177             :         return false;
     178             :     }
     179             :     return true;
     180          53 : }
     181             : 
     182          27 : std::string join_identity(std::string host, std::string path)
     183             : {
     184          27 :     host = to_lower(host);
     185          27 :     replace_all(path, '\\', '/');
     186          27 :     collapse_slashes(path);
     187          54 :     while (!path.empty() && path.front() == '/') path.erase(path.begin());
     188          27 :     strip_trailing_slash(path);
     189          27 :     strip_dot_git(path);
     190          27 :     strip_trailing_slash(path);
     191          54 :     if (path.empty()) return host;
     192          54 :     return host + "/" + path;
     193             : }
     194             : 
     195             : //! Avoid ESYSREPO_RESULT_T when R is std::string (ambiguous with Result text ctor).
     196          30 : Result_t<std::string> id_ok(std::string value)
     197             : {
     198          90 :     return Result_t<std::string>(std::move(value));
     199             : }
     200             : 
     201           1 : Result_t<std::string> id_err(ResultCode code, const std::string &msg)
     202             : {
     203           2 :     return Result_t<std::string>(ESYSREPO_RESULT(code, msg));
     204             : }
     205             : 
     206             : //! Drop last path segment of an absolute-ish URL path (for relative join)
     207           3 : std::string parent_dir_url(const std::string &parent_remote)
     208             : {
     209           3 :     std::string p = trim_copy(parent_remote);
     210           3 :     strip_trailing_slash(p);
     211           3 :     strip_dot_git(p);
     212             : 
     213             :     // Keep scheme/user prefix; operate on last path segment
     214           3 :     auto scheme_end = p.find("://");
     215           3 :     std::size_t path_start = 0;
     216           3 :     if (scheme_end != std::string::npos)
     217           3 :         path_start = scheme_end + 3;
     218           0 :     else if (is_scp_like(p))
     219             :     {
     220           0 :         auto at = p.find('@');
     221           0 :         auto colon = p.find(':', at + 1);
     222             :         // scp: drop last segment after colon
     223           0 :         auto slash = p.rfind('/');
     224           0 :         if (slash != std::string::npos && slash > colon) return p.substr(0, slash);
     225           0 :         return p.substr(0, colon + 1); // host: with empty path
     226             :     }
     227             : 
     228           3 :     auto slash = p.rfind('/');
     229           3 :     if (slash != std::string::npos && slash >= path_start) return p.substr(0, slash);
     230           3 :     return p;
     231           3 : }
     232             : 
     233             : } // namespace
     234             : 
     235          40 : bool Identity::is_relative(const std::string &url)
     236             : {
     237          40 :     auto u = trim_copy(url);
     238          40 :     if (u.empty()) return false;
     239          40 :     if (has_scheme(u) || is_scp_like(u) || is_windows_drive(u)) return false;
     240           7 :     if (starts_with_ci(u, "file:")) return false;
     241           7 :     if (!u.empty() && u[0] == '/') return false; // absolute fs path
     242             :     return true;                                 // ../foo, ./foo, bare path
     243          40 : }
     244             : 
     245          36 : bool Identity::is_file_remote(const std::string &url)
     246             : {
     247          36 :     auto u = trim_copy(url);
     248          36 :     if (u.empty()) return false;
     249          36 :     if (starts_with_ci(u, "file:")) return true;
     250          35 :     if (is_windows_drive(u)) return true;
     251          35 :     if (u.size() >= 2 && u[0] == '\\' && u[1] == '\\') return true; // UNC
     252          35 :     if (!has_scheme(u) && !is_scp_like(u) && !u.empty() && u[0] == '/') return true;
     253             :     return false;
     254          36 : }
     255             : 
     256           3 : Result_t<std::string> Identity::resolve_relative(const std::string &url, const std::string &parent_remote)
     257             : {
     258           3 :     auto u = trim_copy(url);
     259           3 :     auto parent = trim_copy(parent_remote);
     260           3 :     if (u.empty()) return id_err(ResultCode::SHARING_IDENTITY_INVALID, "empty URL");
     261           3 :     if (parent.empty()) return id_err(ResultCode::SHARING_IDENTITY_INVALID, "relative URL requires parent remote");
     262           3 :     if (!is_relative(u)) return id_ok(u);
     263             : 
     264           3 :     std::string base = parent;
     265           3 :     std::string rel = u;
     266             :     // Count leading ../
     267           6 :     while (true)
     268             :     {
     269           6 :         if (rel.size() >= 3 && rel.compare(0, 3, "../") == 0)
     270             :         {
     271           3 :             base = parent_dir_url(base);
     272           3 :             rel = rel.substr(3);
     273           3 :             continue;
     274             :         }
     275           3 :         if (rel == "..")
     276             :         {
     277           0 :             base = parent_dir_url(base);
     278           0 :             rel.clear();
     279             :             break;
     280             :         }
     281           3 :         if (rel.size() >= 2 && rel.compare(0, 2, "./") == 0)
     282             :         {
     283           0 :             rel = rel.substr(2);
     284           0 :             continue;
     285             :         }
     286           3 :         if (rel == ".")
     287             :         {
     288           0 :             rel.clear();
     289             :             break;
     290             :         }
     291             :         break;
     292             :     }
     293             : 
     294           3 :     strip_trailing_slash(base);
     295           3 :     if (rel.empty()) return id_ok(base);
     296             : 
     297             :     // scp parent: host:path — append with /
     298           3 :     if (is_scp_like(base) || (!has_scheme(base) && base.find('@') != std::string::npos))
     299             :     {
     300           0 :         if (!base.empty() && base.back() != ':' && base.back() != '/') base.push_back('/');
     301           0 :         return id_ok(base + rel);
     302             :     }
     303             : 
     304           3 :     if (!base.empty() && base.back() != '/') base.push_back('/');
     305           6 :     return id_ok(base + rel);
     306           6 : }
     307             : 
     308           7 : std::string Identity::compose_location_url(const std::string &addr, const std::string &repo_name)
     309             : {
     310           7 :     std::string a = trim_copy(addr);
     311           7 :     std::string n = trim_copy(repo_name);
     312           7 :     if (a.empty()) return n;
     313          14 :     if (n.empty()) return a;
     314           7 :     if (!n.empty() && n[0] == '/') return a + n;
     315           7 :     if (!a.empty() && a.back() == '/') return a + n;
     316          14 :     return a + "/" + n;
     317           7 : }
     318             : 
     319          28 : Result_t<std::string> Identity::normalize(const std::string &url)
     320             : {
     321          28 :     auto u = trim_copy(url);
     322          28 :     if (u.empty()) return id_err(ResultCode::SHARING_IDENTITY_INVALID, "empty URL");
     323          28 :     if (is_file_remote(u))
     324           1 :         return id_err(ResultCode::SHARING_IDENTITY_FILE_REMOTE, "file:// and filesystem remotes are out of v1");
     325          27 :     if (is_relative(u))
     326           0 :         return id_err(ResultCode::SHARING_IDENTITY_INVALID, "relative URL must be resolved before normalize");
     327             : 
     328          27 :     std::string host;
     329          27 :     std::string path;
     330          27 :     std::string err;
     331          27 :     if (!extract_host_path(u, host, path, err)) return id_err(ResultCode::SHARING_IDENTITY_INVALID, err);
     332             : 
     333          54 :     return id_ok(join_identity(host, path));
     334          55 : }
     335             : 
     336           8 : Result_t<std::string> Identity::normalize(const std::string &url, const std::string &parent_remote)
     337             : {
     338           8 :     auto u = trim_copy(url);
     339           8 :     if (is_relative(u))
     340             :     {
     341           3 :         auto resolved = resolve_relative(u, parent_remote);
     342           3 :         if (resolved.error()) return resolved;
     343           6 :         return normalize(resolved.get());
     344           3 :     }
     345           5 :     return normalize(u);
     346           8 : }
     347             : 
     348             : } // namespace esys::repo::sharing

Generated by: LCOV version 1.14