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
|