Line data Source code
1 : /*
2 : * Copyright (C) 2004-2026 Savoir-faire Linux Inc.
3 : *
4 : * This program is free software: you can redistribute it and/or modify
5 : * it under the terms of the GNU General Public License as published by
6 : * the Free Software Foundation, either version 3 of the License, or
7 : * (at your option) any later version.
8 : *
9 : * This program is distributed in the hope that it will be useful,
10 : * but WITHOUT ANY WARRANTY; without even the implied warranty of
11 : * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 : * GNU General Public License for more details.
13 : *
14 : * You should have received a copy of the GNU General Public License
15 : * along with this program. If not, see <https://www.gnu.org/licenses/>.
16 : */
17 :
18 : #include "conversationrepository.h"
19 :
20 : #include "account_const.h"
21 : #include "base64.h"
22 : #include "jamiaccount.h"
23 : #include "fileutils.h"
24 : #include "gittransport.h"
25 : #include "string_utils.h"
26 : #include "client/jami_signal.h"
27 : #include "vcard.h"
28 : #include "json_utils.h"
29 : #include "fileutils.h"
30 : #include "logger.h"
31 : #include "jami/conversation_interface.h"
32 :
33 : #include <opendht/crypto.h>
34 :
35 : #include <git2/blob.h>
36 : #include <git2/buffer.h>
37 : #include <git2/commit.h>
38 : #include <git2/deprecated.h>
39 : #include <git2/refs.h>
40 : #include <git2/object.h>
41 : #include <git2/indexer.h>
42 : #include <git2/remote.h>
43 : #include <git2/merge.h>
44 : #include <git2/diff.h>
45 :
46 : #include <algorithm>
47 : #include <iterator>
48 : #include <ctime>
49 : #include <fstream>
50 : #include <future>
51 : #include <json/json.h>
52 : #include <regex>
53 : #include <exception>
54 : #include <optional>
55 : #include <memory>
56 : #include <cstdint>
57 : #include <utility>
58 :
59 : using namespace std::string_view_literals;
60 : constexpr auto DIFF_REGEX = " +\\| +[0-9]+.*"sv;
61 : constexpr size_t MAX_FETCH_SIZE {256 * 1024 * 1024}; // 256Mb
62 :
63 : namespace jami {
64 :
65 : #ifdef LIBJAMI_TEST
66 : bool ConversationRepository::DISABLE_RESET = false;
67 : bool ConversationRepository::FETCH_FROM_LOCAL_REPOS = false;
68 : #endif
69 :
70 : static const std::regex regex_display_name("<|>");
71 :
72 : inline std::string_view
73 7606 : as_view(const git_blob* blob)
74 : {
75 7606 : return std::string_view(static_cast<const char*>(git_blob_rawcontent(blob)), git_blob_rawsize(blob));
76 : }
77 : /**
78 : * Check that a certificate stored under devices/<deviceId>.crt is the certificate of
79 : * that device: the file name must be the id of the embedded public key.
80 : */
81 : inline bool
82 4875 : isCertificateOfDevice(const dht::crypto::Certificate& cert, std::string_view deviceId)
83 : {
84 4875 : return cert.getLongId().to_view() == deviceId;
85 : }
86 :
87 : /**
88 : * Check that deviceCert was issued by the holder of memberCert's key, i.e. that its
89 : * signature verifies with memberCert's public key, and that memberCert is the
90 : * certificate of memberUri.
91 : * @note Issuer chains embedded in the certificates are ignored (they are not
92 : * authenticated) and validity periods are checked against the commit time by the caller.
93 : */
94 : bool
95 4919 : isDeviceOfMember(const dht::crypto::Certificate& deviceCert,
96 : const dht::crypto::Certificate& memberCert,
97 : std::string_view memberUri)
98 : {
99 4919 : if (not deviceCert.cert or not memberCert.cert)
100 0 : return false;
101 4919 : if (memberCert.getId().toString() != memberUri)
102 0 : return false;
103 4920 : unsigned result = 0;
104 4920 : auto err = gnutls_x509_crt_verify(deviceCert.cert, &memberCert.cert, 1, GNUTLS_VERIFY_DISABLE_TIME_CHECKS, &result);
105 4920 : return err == GNUTLS_E_SUCCESS and not(result & GNUTLS_CERT_INVALID);
106 : }
107 :
108 : inline std::string_view
109 7605 : as_view(const GitObject& blob)
110 : {
111 7605 : return as_view(reinterpret_cast<git_blob*>(blob.get()));
112 : }
113 :
114 : class ConversationRepository::Impl
115 : {
116 : public:
117 555 : Impl(const std::shared_ptr<JamiAccount>& account, const std::string& id)
118 555 : : account_(account)
119 555 : , id_(id)
120 555 : , accountId_(account->getAccountID())
121 555 : , userId_(account->getUsername())
122 1665 : , deviceId_(account->currentDeviceId())
123 : {
124 555 : if (!isValidConversationId(id_))
125 0 : throw std::logic_error(fmt::format("Invalid conversation id: {}", id_));
126 555 : conversationDataPath_ = fileutils::get_data_dir() / accountId_ / "conversation_data" / id_;
127 555 : membersCache_ = conversationDataPath_ / "members";
128 555 : checkLocks();
129 555 : loadMembers();
130 555 : if (members_.empty()) {
131 540 : initMembers();
132 : }
133 564 : }
134 :
135 555 : void checkLocks()
136 : {
137 555 : auto repo = repository();
138 555 : if (!repo)
139 0 : throw std::logic_error("Invalid git repository");
140 :
141 555 : std::filesystem::path repoPath = git_repository_path(repo.get());
142 555 : std::error_code ec;
143 :
144 555 : auto indexPath = std::filesystem::path(repoPath / "index.lock");
145 555 : if (std::filesystem::exists(indexPath, ec)) {
146 0 : JAMI_WARNING("[Account {}] [Conversation {}] Conversation is locked, removing lock {}",
147 : accountId_,
148 : id_,
149 : indexPath);
150 0 : std::filesystem::remove(indexPath, ec);
151 0 : if (ec)
152 0 : JAMI_ERROR("[Account {}] [Conversation {}] Unable to remove lock {}: {}",
153 : accountId_,
154 : id_,
155 : indexPath,
156 : ec.message());
157 : }
158 :
159 555 : auto refPath = std::filesystem::path(repoPath / "refs" / "heads" / "main.lock");
160 555 : if (std::filesystem::exists(refPath)) {
161 0 : JAMI_WARNING("[Account {}] [Conversation {}] Conversation is locked, removing lock {}",
162 : accountId_,
163 : id_,
164 : refPath);
165 0 : std::filesystem::remove(refPath, ec);
166 0 : if (ec)
167 0 : JAMI_ERROR("[Account {}] [Conversation {}] Unable to remove lock {}: {}",
168 : accountId_,
169 : id_,
170 : refPath,
171 : ec.message());
172 : }
173 :
174 555 : auto remotePath = std::filesystem::path(repoPath / "refs" / "remotes");
175 1071 : for (const auto& fileIt : std::filesystem::directory_iterator(remotePath, ec)) {
176 258 : auto refPath = fileIt.path() / "main.lock";
177 258 : if (std::filesystem::exists(refPath, ec)) {
178 0 : JAMI_WARNING("[Account {}] [Conversation {}] Conversation is locked for remote {}, removing lock",
179 : accountId_,
180 : id_,
181 : fileIt.path().filename());
182 0 : std::filesystem::remove(refPath, ec);
183 0 : if (ec)
184 0 : JAMI_ERROR("[Account {}] [Conversation {}] Unable to remove lock {}: {}",
185 : accountId_,
186 : id_,
187 : refPath,
188 : ec.message());
189 : }
190 813 : }
191 :
192 555 : auto err = git_repository_state_cleanup(repo.get());
193 555 : if (err < 0) {
194 0 : JAMI_ERROR("[Account {}] [Conversation {}] Unable to clean up the repository: {}",
195 : accountId_,
196 : id_,
197 : git_error_last()->message);
198 : }
199 555 : }
200 :
201 555 : void loadMembers()
202 : {
203 : try {
204 : // read file
205 1095 : auto file = fileutils::loadFile(membersCache_);
206 : // load values
207 15 : msgpack::object_handle oh = msgpack::unpack((const char*) file.data(), file.size());
208 15 : std::lock_guard lk {membersMtx_};
209 15 : oh.get().convert(members_);
210 555 : } catch (const std::exception& e) {
211 540 : }
212 555 : }
213 : // Note: membersMtx_ needs to be locked when calling saveMembers
214 1776 : void saveMembers()
215 : {
216 1776 : std::ofstream file(membersCache_, std::ios::trunc | std::ios::binary);
217 1776 : msgpack::pack(file, members_);
218 :
219 1776 : if (onMembersChanged_) {
220 1236 : std::set<std::string> memberUris;
221 14454 : for (const auto& member : members_) {
222 13216 : memberUris.emplace(member.uri);
223 : }
224 1233 : onMembersChanged_(memberUris);
225 1236 : }
226 1776 : }
227 :
228 : OnMembersChanged onMembersChanged_ {};
229 :
230 : // NOTE! We use temporary GitRepository to avoid keeping the file opened
231 : // TODO: check why git_remote_fetch() leaves pack-data opened
232 47667 : GitRepository repository() const
233 : {
234 95340 : auto path = fmt::format("{}/{}/conversations/{}", fileutils::get_data_dir().string(), accountId_, id_);
235 47625 : git_repository* repo = nullptr;
236 47625 : auto err = git_repository_open(&repo, path.c_str());
237 47685 : if (err < 0) {
238 1 : JAMI_ERROR("Unable to open Git repository: {} ({})", path, git_error_last()->message);
239 1 : return nullptr;
240 : }
241 47684 : return GitRepository(std::move(repo));
242 47673 : }
243 :
244 682 : std::string getDisplayName() const
245 : {
246 682 : auto shared = account_.lock();
247 682 : if (!shared)
248 0 : return {};
249 682 : auto name = shared->getDisplayName();
250 682 : if (name.empty())
251 0 : name = deviceId_;
252 682 : return std::regex_replace(name, regex_display_name, "");
253 682 : }
254 :
255 : GitSignature signature();
256 : bool mergeFastforward(const git_oid* target_oid, int is_unborn);
257 : std::string createMergeCommit(git_index* index, const std::string& wanted_ref);
258 :
259 : bool validCommits(const std::vector<ConversationCommit>& commits) const;
260 : bool checkValidUserDiff(const std::string& userDevice,
261 : const std::string& commitId,
262 : const std::string& parentId) const;
263 : bool checkValidCheckpoint(const std::string& userDevice,
264 : const std::string& commitId,
265 : const std::string& parentId) const;
266 : bool checkVote(const std::string& userDevice, const std::string& commitId, const std::string& parentId) const;
267 : bool checkEdit(const std::string& userDevice, const ConversationCommit& commit) const;
268 : bool isValidUserAtCommit(const std::string& userDevice,
269 : const std::string& commitId,
270 : const git_buf& sig,
271 : const git_buf& sig_data) const;
272 : bool checkInitialCommit(const std::string& userDevice,
273 : const std::string& commitId,
274 : const CommitMessage& commitMsg) const;
275 : bool checkValidAdd(const std::string& userDevice,
276 : const std::string& uriMember,
277 : const std::string& commitid,
278 : const std::string& parentId) const;
279 : bool checkValidJoins(const std::string& userDevice,
280 : const std::string& uriMember,
281 : const std::string& commitid,
282 : const std::string& parentId) const;
283 : bool checkValidRemove(const std::string& userDevice,
284 : const std::string& uriMember,
285 : const std::string& commitid,
286 : const std::string& parentId) const;
287 : bool checkValidVoteResolution(const std::string& userDevice,
288 : const std::string& uriMember,
289 : const std::string& commitId,
290 : const std::string& parentId,
291 : const std::string& voteType) const;
292 : bool checkValidProfileUpdate(const std::string& userDevice,
293 : const std::string& commitid,
294 : const std::string& parentId) const;
295 : bool checkValidMergeCommit(const std::string& mergeId, const std::vector<std::string>& parents) const;
296 : std::optional<std::set<std::string_view>> getDeltaPathsFromDiff(const GitDiff& diff) const;
297 :
298 : bool add(const std::string& path);
299 : void addUserDevice();
300 : void resetHard();
301 : // Verify that the device in the repository is still valid
302 : bool validateDevice();
303 : std::string commit(const std::string& msg, bool verifyDevice = true);
304 : std::string commitMessage(const std::string& msg, bool verifyDevice = true);
305 : ConversationMode mode() const;
306 :
307 : // NOTE! GitDiff needs to be deleted before repo
308 : GitDiff diff(git_repository* repo, const std::string& idNew, const std::string& idOld) const;
309 : std::string diffStats(const std::string& newId, const std::string& oldId) const;
310 : std::string diffStats(const GitDiff& diff) const;
311 :
312 : std::vector<ConversationCommit> behind(const std::string& from) const;
313 : void forEachCommit(PreConditionCb&& preCondition,
314 : std::function<void(ConversationCommit&&)>&& emplaceCb,
315 : PostConditionCb&& postCondition,
316 : const std::string& from = "",
317 : bool logIfNotFound = true) const;
318 : std::vector<ConversationCommit> log(const LogOptions& options) const;
319 :
320 : GitObject fileAtTree(const std::string& path, const GitTree& tree) const;
321 : GitObject memberCertificate(std::string_view memberUri, const GitTree& tree) const;
322 : // NOTE! GitDiff needs to be deleted before repo
323 : GitTree treeAtCommit(git_repository* repo, const std::string& commitId) const;
324 :
325 : std::vector<std::string> getInitialMembers() const;
326 :
327 : bool resolveBan(const std::string_view type, const std::string& uri);
328 : bool resolveUnban(const std::string_view type, const std::string& uri);
329 :
330 : std::weak_ptr<JamiAccount> account_;
331 : const std::string id_;
332 : const std::string accountId_;
333 : const std::string userId_;
334 : const std::string deviceId_;
335 : mutable std::optional<ConversationMode> mode_ {};
336 :
337 : // Members utils
338 : mutable std::mutex membersMtx_ {};
339 : std::vector<ConversationMember> members_ {};
340 :
341 1701 : std::vector<ConversationMember> members() const
342 : {
343 1701 : std::lock_guard lk(membersMtx_);
344 3402 : return members_;
345 1701 : }
346 :
347 : std::filesystem::path conversationDataPath_ {};
348 : std::filesystem::path membersCache_ {};
349 :
350 25 : std::map<std::string, std::vector<DeviceId>> devices(bool ignoreExpired = true) const
351 : {
352 25 : auto acc = account_.lock();
353 25 : auto repo = repository();
354 25 : if (!repo or !acc)
355 0 : return {};
356 25 : std::map<std::string, std::vector<DeviceId>> memberDevices;
357 25 : std::filesystem::path repoPath = git_repository_workdir(repo.get());
358 25 : std::error_code ec;
359 72 : for (const auto& fileIt : std::filesystem::directory_iterator(repoPath / "devices", ec)) {
360 : try {
361 47 : auto cert = std::make_shared<dht::crypto::Certificate>(fileutils::loadFile(fileIt.path()));
362 47 : if (!cert)
363 0 : continue;
364 47 : if (ignoreExpired && cert->getExpiration() < std::chrono::system_clock::now())
365 0 : continue;
366 47 : auto issuerUid = cert->getIssuerUID();
367 47 : if (issuerUid.empty())
368 0 : continue;
369 : // Only trust the issuer UID once the device is proven to be certified by that member
370 94 : auto memberFile = repoPath / "members" / fmt::format("{}.crt", issuerUid);
371 94 : auto adminFile = repoPath / "admins" / fmt::format("{}.crt", issuerUid);
372 : auto parentCert = std::make_shared<dht::crypto::Certificate>(
373 47 : fileutils::loadFile(std::filesystem::is_regular_file(memberFile, ec) ? memberFile : adminFile));
374 47 : if (!isDeviceOfMember(*cert, *parentCert, issuerUid)) {
375 0 : JAMI_WARNING("[Account {}] [Conversation {}] Device {} is not certified by {}, ignoring",
376 : accountId_,
377 : id_,
378 : cert->getLongId(),
379 : issuerUid);
380 0 : continue;
381 : }
382 47 : if (!acc->certStore().getCertificate(issuerUid)) {
383 0 : if (ignoreExpired || parentCert->getExpiration() < std::chrono::system_clock::now())
384 0 : acc->certStore().pinCertificate(parentCert,
385 : true); // Pin certificate to local store if not already done
386 : }
387 47 : if (!acc->certStore().getCertificate(cert->getPublicKey().getLongId().toString())) {
388 0 : cert->issuer = parentCert;
389 0 : acc->certStore().pinCertificate(cert,
390 : true); // Pin certificate to local store if not already done
391 : }
392 47 : memberDevices[issuerUid].emplace_back(cert->getPublicKey().getLongId());
393 :
394 47 : } catch (const std::exception&) {
395 0 : }
396 25 : }
397 25 : return memberDevices;
398 25 : }
399 :
400 12861 : bool hasCommit(const std::string& commitId) const
401 : {
402 12861 : auto repo = repository();
403 12860 : if (!repo)
404 0 : return false;
405 :
406 : git_oid oid;
407 12859 : if (git_oid_fromstr(&oid, commitId.c_str()) < 0)
408 1 : return false;
409 12864 : git_commit* commitPtr = nullptr;
410 12864 : if (git_commit_lookup(&commitPtr, repo.get(), &oid) < 0)
411 2735 : return false;
412 10128 : git_commit_free(commitPtr);
413 10129 : return true;
414 12865 : }
415 :
416 : ConversationCommit parseCommit(git_repository* repo, const git_commit* commit) const;
417 :
418 1331 : std::optional<ConversationCommit> getCommit(const std::string& commitId) const
419 : {
420 1331 : auto repo = repository();
421 1331 : if (!repo)
422 0 : return std::nullopt;
423 :
424 : git_oid oid;
425 1331 : if (git_oid_fromstr(&oid, commitId.c_str()) < 0)
426 1 : return std::nullopt;
427 :
428 1330 : git_commit* commitPtr = nullptr;
429 1330 : if (git_commit_lookup(&commitPtr, repo.get(), &oid) < 0)
430 1 : return std::nullopt;
431 1329 : GitCommit commit {commitPtr};
432 :
433 1329 : return parseCommit(repo.get(), commit.get());
434 1331 : }
435 :
436 : bool resolveConflicts(git_index* index, const std::string& other_id);
437 :
438 3746 : std::set<std::string> memberUris(std::string_view filter, const std::set<MemberRole>& filteredRoles) const
439 : {
440 3746 : std::lock_guard lk(membersMtx_);
441 3747 : std::set<std::string> ret;
442 34075 : for (const auto& member : members_) {
443 30333 : if ((filteredRoles.find(member.role) != filteredRoles.end())
444 30357 : or (not filter.empty() and filter == member.uri))
445 2115 : continue;
446 28217 : ret.emplace(member.uri);
447 : }
448 7494 : return ret;
449 3747 : }
450 :
451 : void initMembers();
452 :
453 : std::optional<std::map<std::string, std::string>> convCommitToMap(const ConversationCommit& commit) const;
454 :
455 : // Permissions
456 : MemberRole updateProfilePermLvl_ {MemberRole::ADMIN};
457 :
458 : /**
459 : * Retrieve the user related to a device using the account's certificate store.
460 : * @note deviceToUri_ is used to cache result and avoid always loading the certificate
461 : * @note Only verified bindings are returned: either the certificate store knows the
462 : * device certificate with its (verified) issuer, or the device certificate found in the
463 : * repository is signed by the member certificate it claims as issuer.
464 : */
465 38875 : std::string uriFromDevice(const std::string& deviceId, const std::string& commitId = "") const
466 : {
467 : // Check if we have the device in cache.
468 38875 : std::lock_guard lk(deviceToUriMtx_);
469 38939 : auto it = deviceToUri_.find(deviceId);
470 38905 : if (it != deviceToUri_.end())
471 37271 : return it->second;
472 :
473 1658 : auto acc = account_.lock();
474 1658 : if (!acc)
475 0 : return {};
476 :
477 1658 : std::string uri;
478 1658 : auto cert = acc->certStore().getCertificate(deviceId);
479 1658 : if (cert && cert->issuer && isCertificateOfDevice(*cert, deviceId)) {
480 : // Do not trust the pinned chain blindly, it may come from an unverified bundle.
481 1600 : auto issuerId = cert->issuer->getId().toString();
482 1600 : if (isDeviceOfMember(*cert, *cert->issuer, issuerId))
483 1600 : uri = std::move(issuerId);
484 1600 : }
485 1658 : if (uri.empty()) {
486 58 : if (!commitId.empty()) {
487 58 : uri = uriFromDeviceAtCommit(deviceId, commitId);
488 : } else {
489 : // Not pinned, so load certificate from the repository's head
490 0 : auto repo = repository();
491 0 : if (!repo)
492 0 : return {};
493 : git_oid head;
494 0 : if (git_reference_name_to_id(&head, repo.get(), "HEAD") < 0)
495 0 : return {};
496 0 : uri = uriFromDeviceAtCommit(deviceId, git_oid_tostr_s(&head));
497 0 : }
498 : }
499 1658 : if (uri.empty())
500 7 : return {};
501 :
502 1651 : deviceToUri_.insert({deviceId, uri});
503 1651 : return uri;
504 38879 : }
505 : mutable std::mutex deviceToUriMtx_;
506 : mutable std::map<std::string, std::string> deviceToUri_;
507 :
508 : /**
509 : * Retrieve the user related to a device using certificate directly from the repository at a
510 : * specific commit. The device certificate must be signed by the member (or admin)
511 : * certificate present in the same tree, otherwise the device is not resolved.
512 : * @note Prefer uriFromDevice() if possible as it uses the cache.
513 : */
514 60 : std::string uriFromDeviceAtCommit(const std::string& deviceId, const std::string& commitId) const
515 : {
516 60 : auto repo = repository();
517 60 : if (!repo)
518 0 : return {};
519 60 : auto tree = treeAtCommit(repo.get(), commitId);
520 60 : if (!tree)
521 0 : return {};
522 60 : auto deviceFile = fmt::format("devices/{}.crt", deviceId);
523 60 : auto blob_device = fileAtTree(deviceFile, tree);
524 60 : if (!blob_device) {
525 2 : JAMI_ERROR("{} announced but not found", deviceId);
526 2 : return {};
527 : }
528 : try {
529 58 : auto deviceCert = dht::crypto::Certificate(as_view(blob_device));
530 58 : auto uri = verifiedUriFromDeviceCert(deviceCert, deviceId, tree);
531 58 : if (uri.empty())
532 5 : JAMI_ERROR("Device certificate {} is not issued by a member of the conversation", deviceId);
533 58 : return uri;
534 58 : } catch (const std::exception& e) {
535 0 : JAMI_ERROR("Unable to load certificate for device {}: {}", deviceId, e.what());
536 0 : return {};
537 0 : }
538 60 : }
539 :
540 : /**
541 : * Resolve the member owning a device certificate found in a tree, verifying the
542 : * signature of the device certificate with the member certificate stored in the tree.
543 : * @return the member URI, or an empty string if the chain is unable to be verified
544 : */
545 58 : std::string verifiedUriFromDeviceCert(const dht::crypto::Certificate& deviceCert,
546 : std::string_view deviceId,
547 : const GitTree& tree) const
548 : {
549 58 : if (!isCertificateOfDevice(deviceCert, deviceId))
550 0 : return {};
551 58 : auto uri = deviceCert.getIssuerUID();
552 58 : if (uri.empty()) {
553 0 : if (deviceCert.issuer)
554 0 : uri = deviceCert.issuer->getId().toString();
555 0 : if (uri.empty())
556 0 : return {};
557 : }
558 58 : auto blob_member = memberCertificate(uri, tree);
559 58 : if (!blob_member)
560 0 : return {};
561 : try {
562 58 : auto memberCert = dht::crypto::Certificate(as_view(blob_member));
563 58 : if (!isDeviceOfMember(deviceCert, memberCert, uri))
564 5 : return {};
565 58 : } catch (const std::exception&) {
566 0 : return {};
567 0 : }
568 53 : return uri;
569 58 : }
570 :
571 : /**
572 : * Verify that a certificate modification is correct
573 : * @param certContent Content of the new certificate
574 : * @param userUri Account we want for this certificate
575 : * @param tree Tree of the commit the certificate is taken from (used to
576 : * retrieve the member certificate signing a device certificate)
577 : * @param deviceId If not empty, the certificate is the device certificate of this
578 : * device and MUST be signed by userUri's member certificate.
579 : * Otherwise, it's the member certificate of userUri.
580 : * @param oldCert Previous certificate. getId() should return the same id as the new
581 : * certificate.
582 : */
583 488 : bool verifyCertificate(std::string_view certContent,
584 : const std::string& userUri,
585 : const GitTree& tree,
586 : std::string_view deviceId,
587 : std::string_view oldCert = ""sv) const
588 : {
589 : try {
590 488 : auto cert = dht::crypto::Certificate(certContent);
591 488 : if (not deviceId.empty()) {
592 246 : if (!isCertificateOfDevice(cert, deviceId)) {
593 2 : JAMI_ERROR("Certificate stored for device {} belongs to another key", deviceId);
594 2 : return false;
595 : }
596 : // The device certificate MUST be issued by the member it's attributed to.
597 : // Its issuer DN is not authenticated, so only the signature is trusted.
598 244 : auto blob_member = memberCertificate(userUri, tree);
599 244 : if (!blob_member) {
600 0 : JAMI_ERROR("No member certificate found for {}", userUri);
601 0 : return false;
602 : }
603 244 : auto memberCert = dht::crypto::Certificate(as_view(blob_member));
604 244 : if (!isDeviceOfMember(cert, memberCert, userUri)) {
605 0 : JAMI_ERROR("Device certificate {} is not signed by {}", deviceId, userUri);
606 0 : return false;
607 : }
608 486 : } else if (cert.getId().toString() != userUri) {
609 0 : JAMI_ERROR("Certificate with a bad ID {}", cert.getId().toString());
610 0 : return false;
611 : }
612 486 : if (!oldCert.empty()) {
613 2 : auto previousCert = dht::crypto::Certificate(oldCert);
614 2 : if (cert.getId() != previousCert.getId()) {
615 0 : JAMI_ERROR("Certificate with a bad ID {}", cert.getId().toString());
616 0 : return false;
617 : }
618 2 : }
619 486 : return true;
620 488 : } catch (const std::exception& e) {
621 0 : JAMI_ERROR("Unable to parse certificate for {}: {}", deviceId.empty() ? userUri : deviceId, e.what());
622 0 : return false;
623 0 : }
624 : }
625 :
626 : std::mutex opMtx_; // Mutex for operations
627 : };
628 :
629 : /////////////////////////////////////////////////////////////////////////////////
630 :
631 : /**
632 : * Creates an empty repository
633 : * @param path Path of the new repository
634 : * @return The libgit2's managed repository
635 : */
636 : GitRepository
637 259 : create_empty_repository(const std::string& path)
638 : {
639 259 : git_repository* repo = nullptr;
640 : git_repository_init_options opts;
641 259 : git_repository_init_options_init(&opts, GIT_REPOSITORY_INIT_OPTIONS_VERSION);
642 259 : opts.flags |= GIT_REPOSITORY_INIT_MKPATH;
643 259 : opts.initial_head = "main";
644 259 : if (git_repository_init_ext(&repo, path.c_str(), &opts) < 0) {
645 0 : JAMI_ERROR("Unable to create a git repository in {}", path);
646 : }
647 518 : return GitRepository(std::move(repo));
648 : }
649 :
650 : /**
651 : * Add all files to index
652 : * @param repo
653 : * @return if operation is successful
654 : */
655 : bool
656 496 : git_add_all(git_repository* repo)
657 : {
658 : // git add -A
659 496 : git_index* index_ptr = nullptr;
660 496 : if (git_repository_index(&index_ptr, repo) < 0) {
661 0 : JAMI_ERROR("Unable to open repository index");
662 0 : return false;
663 : }
664 496 : GitIndex index {index_ptr};
665 496 : git_strarray array {nullptr, 0};
666 496 : git_index_add_all(index.get(), &array, 0, nullptr, nullptr);
667 496 : git_index_write(index.get());
668 496 : git_strarray_dispose(&array);
669 496 : return true;
670 496 : }
671 :
672 : /**
673 : * Adds initial files. This adds the certificate of the account in the /admins directory
674 : * the device's key in /devices and the CRLs in /CRLs.
675 : * @param repo The repository
676 : * @return if files were added successfully
677 : */
678 : bool
679 259 : add_initial_files(GitRepository& repo,
680 : const std::shared_ptr<JamiAccount>& account,
681 : ConversationMode mode,
682 : const std::string& otherMember = "")
683 : {
684 259 : auto deviceId = account->currentDeviceId();
685 259 : std::filesystem::path repoPath = git_repository_workdir(repo.get());
686 259 : auto adminsPath = repoPath / MemberPath::ADMINS;
687 259 : auto devicesPath = repoPath / MemberPath::DEVICES;
688 259 : auto invitedPath = repoPath / MemberPath::INVITED;
689 259 : auto crlsPath = repoPath / "CRLs" / deviceId;
690 :
691 259 : if (!dhtnet::fileutils::recursive_mkdir(adminsPath, 0700)) {
692 0 : JAMI_ERROR("Error when creating {}. Abort create conversations", adminsPath);
693 0 : return false;
694 : }
695 :
696 259 : auto cert = account->identity().second;
697 259 : auto deviceCert = cert->toString(false);
698 259 : auto parentCert = cert->issuer;
699 259 : if (!parentCert) {
700 0 : JAMI_ERROR("Parent cert is null");
701 0 : return false;
702 : }
703 :
704 : // /admins
705 518 : auto adminPath = adminsPath / fmt::format("{}.crt", parentCert->getId().toString());
706 259 : std::ofstream file(adminPath, std::ios::trunc | std::ios::binary);
707 259 : if (!file.is_open()) {
708 0 : JAMI_ERROR("Unable to write data to {}", adminPath);
709 0 : return false;
710 : }
711 259 : file << parentCert->toString(true);
712 259 : file.close();
713 :
714 259 : if (!dhtnet::fileutils::recursive_mkdir(devicesPath, 0700)) {
715 0 : JAMI_ERROR("Error when creating {}. Abort create conversations", devicesPath);
716 0 : return false;
717 : }
718 :
719 : // /devices
720 518 : auto devicePath = devicesPath / fmt::format("{}.crt", deviceId);
721 259 : file = std::ofstream(devicePath, std::ios::trunc | std::ios::binary);
722 259 : if (!file.is_open()) {
723 0 : JAMI_ERROR("Unable to write data to {}", devicePath);
724 0 : return false;
725 : }
726 259 : file << deviceCert;
727 259 : file.close();
728 :
729 259 : if (!dhtnet::fileutils::recursive_mkdir(crlsPath, 0700)) {
730 0 : JAMI_ERROR("Error when creating {}. Abort create conversations", crlsPath);
731 0 : return false;
732 : }
733 :
734 : // /CRLs
735 259 : for (const auto& crl : account->identity().second->getRevocationLists()) {
736 0 : if (!crl)
737 0 : continue;
738 0 : auto crlPath = crlsPath / deviceId / (dht::toHex(crl->getNumber()) + ".crl");
739 0 : std::ofstream file(crlPath, std::ios::trunc | std::ios::binary);
740 0 : if (!file.is_open()) {
741 0 : JAMI_ERROR("Unable to write data to {}", crlPath);
742 0 : return false;
743 : }
744 0 : file << crl->toString();
745 0 : file.close();
746 259 : }
747 :
748 : // /invited for one to one
749 259 : if (mode == ConversationMode::ONE_TO_ONE) {
750 78 : if (!dhtnet::fileutils::recursive_mkdir(invitedPath, 0700)) {
751 0 : JAMI_ERROR("Error when creating {}.", invitedPath);
752 0 : return false;
753 : }
754 78 : auto invitedMemberPath = invitedPath / otherMember;
755 78 : if (std::filesystem::is_regular_file(invitedMemberPath)) {
756 0 : JAMI_WARNING("Member {} already present", otherMember);
757 0 : return false;
758 : }
759 :
760 78 : std::ofstream file(invitedMemberPath, std::ios::trunc | std::ios::binary);
761 78 : if (!file.is_open()) {
762 0 : JAMI_ERROR("Unable to write data to {}", invitedMemberPath);
763 0 : return false;
764 : }
765 78 : }
766 :
767 259 : if (!git_add_all(repo.get())) {
768 0 : return false;
769 : }
770 :
771 259 : JAMI_LOG("Initial files added in {}", repoPath);
772 259 : return true;
773 259 : }
774 :
775 : /**
776 : * Sign and create the initial commit
777 : * @param repo The Git repository
778 : * @param account The account who signs
779 : * @param message The initial commit message
780 : * @return The first commit hash or empty if failed
781 : */
782 : std::string
783 259 : initial_commit(GitRepository& repo, const std::shared_ptr<JamiAccount>& account, const CommitMessage& message)
784 : {
785 259 : auto deviceId = std::string(account->currentDeviceId());
786 259 : auto name = account->getDisplayName();
787 259 : if (name.empty())
788 0 : name = deviceId;
789 259 : name = std::regex_replace(name, regex_display_name, "");
790 :
791 259 : git_signature* sig_ptr = nullptr;
792 259 : git_index* index_ptr = nullptr;
793 : git_oid tree_id, commit_id;
794 259 : git_tree* tree_ptr = nullptr;
795 :
796 : // Sign commit's buffer
797 259 : if (git_signature_new(&sig_ptr, name.c_str(), deviceId.c_str(), std::time(nullptr), 0) < 0) {
798 1 : if (git_signature_new(&sig_ptr, deviceId.c_str(), deviceId.c_str(), std::time(nullptr), 0) < 0) {
799 0 : JAMI_ERROR("Unable to create a commit signature.");
800 0 : return {};
801 : }
802 : }
803 259 : GitSignature sig {sig_ptr};
804 :
805 259 : if (git_repository_index(&index_ptr, repo.get()) < 0) {
806 0 : JAMI_ERROR("Unable to open the repository index");
807 0 : return {};
808 : }
809 259 : GitIndex index {index_ptr};
810 :
811 259 : if (git_index_write_tree(&tree_id, index.get()) < 0) {
812 0 : JAMI_ERROR("Unable to write initial tree from index");
813 0 : return {};
814 : }
815 :
816 259 : if (git_tree_lookup(&tree_ptr, repo.get(), &tree_id) < 0) {
817 0 : JAMI_ERROR("Unable to look up the initial tree");
818 0 : return {};
819 : }
820 259 : GitTree tree {tree_ptr};
821 :
822 259 : git_buf to_sign = {};
823 518 : if (git_commit_create_buffer(
824 777 : &to_sign, repo.get(), sig.get(), sig.get(), nullptr, message.toString().c_str(), tree.get(), 0, nullptr)
825 259 : < 0) {
826 0 : JAMI_ERROR("Unable to create initial buffer");
827 0 : return {};
828 : }
829 :
830 259 : std::string signed_str = base64::encode(account->identity().first->sign((const uint8_t*) to_sign.ptr, to_sign.size));
831 :
832 : // git commit -S
833 259 : if (git_commit_create_with_signature(&commit_id, repo.get(), to_sign.ptr, signed_str.c_str(), "signature") < 0) {
834 0 : git_buf_dispose(&to_sign);
835 0 : JAMI_ERROR("Unable to sign the initial commit");
836 0 : return {};
837 : }
838 259 : git_buf_dispose(&to_sign);
839 :
840 : // Move commit to main branch
841 259 : git_commit* commit = nullptr;
842 259 : if (git_commit_lookup(&commit, repo.get(), &commit_id) == 0) {
843 259 : git_reference* ref = nullptr;
844 259 : git_branch_create(&ref, repo.get(), "main", commit, true);
845 259 : git_commit_free(commit);
846 259 : git_reference_free(ref);
847 : }
848 :
849 259 : auto commit_str = git_oid_tostr_s(&commit_id);
850 259 : if (commit_str)
851 518 : return commit_str;
852 0 : return {};
853 259 : }
854 :
855 : //////////////////////////////////
856 :
857 : GitSignature
858 682 : ConversationRepository::Impl::signature()
859 : {
860 682 : auto name = getDisplayName();
861 682 : if (name.empty()) {
862 0 : JAMI_ERROR("[Account {}] [Conversation {}] Unable to create a commit signature: no name set", accountId_, id_);
863 0 : return nullptr;
864 : }
865 :
866 682 : git_signature* sig_ptr = nullptr;
867 : // Sign commit's buffer
868 682 : if (git_signature_new(&sig_ptr, name.c_str(), deviceId_.c_str(), std::time(nullptr), 0) < 0) {
869 : // Maybe the display name is invalid (like " ") - try without
870 1 : int err = git_signature_new(&sig_ptr, deviceId_.c_str(), deviceId_.c_str(), std::time(nullptr), 0);
871 1 : if (err < 0) {
872 0 : JAMI_ERROR("[Account {}] [Conversation {}] Unable to create a commit signature: {}", accountId_, id_, err);
873 0 : return nullptr;
874 : }
875 : }
876 682 : return GitSignature(sig_ptr);
877 682 : }
878 :
879 : std::string
880 27 : ConversationRepository::Impl::createMergeCommit(git_index* index, const std::string& wanted_ref)
881 : {
882 27 : if (!validateDevice()) {
883 0 : JAMI_ERROR("[Account {}] [Conversation {}] Invalid device. Not migrated?", accountId_, id_);
884 0 : return {};
885 : }
886 : // The merge will occur between current HEAD and wanted_ref
887 27 : git_reference* head_ref_ptr = nullptr;
888 27 : auto repo = repository();
889 27 : if (!repo || git_repository_head(&head_ref_ptr, repo.get()) < 0) {
890 0 : JAMI_ERROR("[Account {}] [Conversation {}] Unable to get HEAD reference", accountId_, id_);
891 0 : return {};
892 : }
893 27 : GitReference head_ref {head_ref_ptr};
894 :
895 : // Maybe that's a ref, so DWIM it
896 27 : git_reference* merge_ref_ptr = nullptr;
897 27 : git_reference_dwim(&merge_ref_ptr, repo.get(), wanted_ref.c_str());
898 27 : GitReference merge_ref {merge_ref_ptr};
899 :
900 27 : GitSignature sig {signature()};
901 :
902 : // Prepare a standard merge commit message
903 27 : const char* msg_target = nullptr;
904 27 : if (merge_ref) {
905 0 : git_branch_name(&msg_target, merge_ref.get());
906 : } else {
907 27 : msg_target = wanted_ref.c_str();
908 : }
909 :
910 27 : auto commitMsg = fmt::format("Merge {} '{}'", merge_ref ? "branch" : "commit", msg_target);
911 :
912 : // Set up our parent commits
913 81 : GitCommit parents[2];
914 27 : git_commit* parent = nullptr;
915 27 : if (git_reference_peel((git_object**) &parent, head_ref.get(), GIT_OBJ_COMMIT) < 0) {
916 0 : JAMI_ERROR("[Account {}] [Conversation {}] Unable to peel HEAD reference", accountId_, id_);
917 0 : return {};
918 : }
919 27 : parents[0] = GitCommit(parent);
920 : git_oid commit_id;
921 27 : if (git_oid_fromstr(&commit_id, wanted_ref.c_str()) < 0) {
922 0 : return {};
923 : }
924 27 : git_annotated_commit* annotated_ptr = nullptr;
925 27 : if (git_annotated_commit_lookup(&annotated_ptr, repo.get(), &commit_id) < 0) {
926 0 : JAMI_ERROR("[Account {}] [Conversation {}] Unable to look up commit {}", accountId_, id_, wanted_ref);
927 0 : return {};
928 : }
929 27 : GitAnnotatedCommit annotated {annotated_ptr};
930 27 : if (git_commit_lookup(&parent, repo.get(), git_annotated_commit_id(annotated.get())) < 0) {
931 0 : JAMI_ERROR("[Account {}] [Conversation {}] Unable to look up commit {}", accountId_, id_, wanted_ref);
932 0 : return {};
933 : }
934 27 : parents[1] = GitCommit(parent);
935 :
936 : // Prepare our commit tree
937 : git_oid tree_oid;
938 27 : git_tree* tree_ptr = nullptr;
939 27 : if (git_index_write_tree_to(&tree_oid, index, repo.get()) < 0) {
940 0 : const git_error* err = giterr_last();
941 0 : if (err)
942 0 : JAMI_ERROR("[Account {}] [Conversation {}] Unable to write index: {}", accountId_, id_, err->message);
943 0 : return {};
944 : }
945 27 : if (git_tree_lookup(&tree_ptr, repo.get(), &tree_oid) < 0) {
946 0 : JAMI_ERROR("[Account {}] [Conversation {}] Unable to look up tree", accountId_, id_);
947 0 : return {};
948 : }
949 27 : GitTree tree {tree_ptr};
950 :
951 : // Commit
952 27 : git_buf to_sign = {};
953 : // The last argument of git_commit_create_buffer is of type
954 : // 'const git_commit **' in all versions of libgit2 except 1.8.0,
955 : // 1.8.1 and 1.8.3, in which it is of type 'git_commit *const *'.
956 : #if LIBGIT2_VER_MAJOR == 1 && LIBGIT2_VER_MINOR == 8 \
957 : && (LIBGIT2_VER_REVISION == 0 || LIBGIT2_VER_REVISION == 1 || LIBGIT2_VER_REVISION == 3)
958 27 : git_commit* const parents_ptr[2] {parents[0].get(), parents[1].get()};
959 : #else
960 : const git_commit* parents_ptr[2] {parents[0].get(), parents[1].get()};
961 : #endif
962 54 : if (git_commit_create_buffer(
963 54 : &to_sign, repo.get(), sig.get(), sig.get(), nullptr, commitMsg.c_str(), tree.get(), 2, &parents_ptr[0])
964 27 : < 0) {
965 0 : const git_error* err = giterr_last();
966 0 : if (err)
967 0 : JAMI_ERROR("[Account {}] [Conversation {}] Unable to create commit buffer: {}",
968 : accountId_,
969 : id_,
970 : err->message);
971 0 : return {};
972 : }
973 :
974 27 : auto account = account_.lock();
975 27 : if (!account)
976 0 : return {};
977 : // git commit -S
978 27 : auto to_sign_vec = std::vector<uint8_t>(to_sign.ptr, to_sign.ptr + to_sign.size);
979 27 : auto signed_buf = account->identity().first->sign(to_sign_vec);
980 27 : std::string signed_str = base64::encode(signed_buf);
981 : git_oid commit_oid;
982 27 : if (git_commit_create_with_signature(&commit_oid, repo.get(), to_sign.ptr, signed_str.c_str(), "signature") < 0) {
983 0 : git_buf_dispose(&to_sign);
984 0 : JAMI_ERROR("[Account {}] [Conversation {}] Unable to sign commit", accountId_, id_);
985 0 : return {};
986 : }
987 27 : git_buf_dispose(&to_sign);
988 :
989 27 : auto commit_str = git_oid_tostr_s(&commit_oid);
990 27 : if (commit_str) {
991 27 : JAMI_LOG("[Account {}] [Conversation {}] New merge commit added with id: {}", accountId_, id_, commit_str);
992 : // Move commit to main branch
993 27 : git_reference* ref_ptr = nullptr;
994 27 : if (git_reference_create(&ref_ptr, repo.get(), "refs/heads/main", &commit_oid, true, nullptr) < 0) {
995 0 : const git_error* err = giterr_last();
996 0 : if (err) {
997 0 : JAMI_ERROR("[Account {}] [Conversation {}] Unable to move commit to main: {}",
998 : accountId_,
999 : id_,
1000 : err->message);
1001 0 : emitSignal<libjami::ConversationSignal::OnConversationError>(accountId_, id_, ECOMMIT, err->message);
1002 : }
1003 0 : return {};
1004 : }
1005 27 : git_reference_free(ref_ptr);
1006 : }
1007 :
1008 : // We're done merging. Clean up the repository state and index
1009 27 : git_repository_state_cleanup(repo.get());
1010 :
1011 27 : git_object* target_ptr = nullptr;
1012 27 : if (git_object_lookup(&target_ptr, repo.get(), &commit_oid, GIT_OBJ_COMMIT) != 0) {
1013 0 : const git_error* err = giterr_last();
1014 0 : if (err)
1015 0 : JAMI_ERROR("[Account {}] [Conversation {}] failed to look up OID {}: {}",
1016 : accountId_,
1017 : id_,
1018 : git_oid_tostr_s(&commit_oid),
1019 : err->message);
1020 0 : return {};
1021 : }
1022 27 : GitObject target {target_ptr};
1023 :
1024 27 : git_reset(repo.get(), target.get(), GIT_RESET_HARD, nullptr);
1025 :
1026 27 : return commit_str ? commit_str : "";
1027 135 : }
1028 :
1029 : bool
1030 959 : ConversationRepository::Impl::mergeFastforward(const git_oid* target_oid, int is_unborn)
1031 : {
1032 : // Initialize target
1033 959 : git_reference* target_ref_ptr = nullptr;
1034 959 : auto repo = repository();
1035 959 : if (!repo) {
1036 0 : JAMI_ERROR("[Account {}] [Conversation {}] No repository found", accountId_, id_);
1037 0 : return false;
1038 : }
1039 959 : if (is_unborn) {
1040 0 : git_reference* head_ref_ptr = nullptr;
1041 : // HEAD reference is unborn, lookup manually so we don't try to resolve it
1042 0 : if (git_reference_lookup(&head_ref_ptr, repo.get(), "HEAD") < 0) {
1043 0 : JAMI_ERROR("[Account {}] [Conversation {}] failed to look up HEAD ref", accountId_, id_);
1044 0 : return false;
1045 : }
1046 0 : GitReference head_ref {head_ref_ptr};
1047 :
1048 : // Grab the reference HEAD should be pointing to
1049 0 : const auto* symbolic_ref = git_reference_symbolic_target(head_ref.get());
1050 :
1051 : // Create our main reference on the target OID
1052 0 : if (git_reference_create(&target_ref_ptr, repo.get(), symbolic_ref, target_oid, 0, nullptr) < 0) {
1053 0 : const git_error* err = giterr_last();
1054 0 : if (err)
1055 0 : JAMI_ERROR("[Account {}] [Conversation {}] failed to create main reference: {}",
1056 : accountId_,
1057 : id_,
1058 : err->message);
1059 0 : return false;
1060 : }
1061 :
1062 959 : } else if (git_repository_head(&target_ref_ptr, repo.get()) < 0) {
1063 : // HEAD exists, just look up and resolve
1064 0 : JAMI_ERROR("[Account {}] [Conversation {}] failed to get HEAD reference", accountId_, id_);
1065 0 : return false;
1066 : }
1067 959 : GitReference target_ref {target_ref_ptr};
1068 :
1069 : // Look up the target object
1070 959 : git_object* target_ptr = nullptr;
1071 959 : if (git_object_lookup(&target_ptr, repo.get(), target_oid, GIT_OBJ_COMMIT) != 0) {
1072 0 : JAMI_ERROR("[Account {}] [Conversation {}] failed to look up OID {}",
1073 : accountId_,
1074 : id_,
1075 : git_oid_tostr_s(target_oid));
1076 0 : return false;
1077 : }
1078 959 : GitObject target {target_ptr};
1079 :
1080 : // Checkout the result so the workdir is in the expected state
1081 : git_checkout_options ff_checkout_options;
1082 959 : git_checkout_init_options(&ff_checkout_options, GIT_CHECKOUT_OPTIONS_VERSION);
1083 959 : ff_checkout_options.checkout_strategy = GIT_CHECKOUT_SAFE;
1084 959 : if (git_checkout_tree(repo.get(), target.get(), &ff_checkout_options) != 0) {
1085 0 : if (auto err = git_error_last())
1086 0 : JAMI_ERROR("[Account {}] [Conversation {}] failed to checkout HEAD reference: {}",
1087 : accountId_,
1088 : id_,
1089 : err->message);
1090 : else
1091 0 : JAMI_ERROR("[Account {}] [Conversation {}] failed to checkout HEAD reference: unknown error",
1092 : accountId_,
1093 : id_);
1094 0 : return false;
1095 : }
1096 :
1097 : // Move the target reference to the target OID
1098 : git_reference* new_target_ref;
1099 959 : if (git_reference_set_target(&new_target_ref, target_ref.get(), target_oid, nullptr) < 0) {
1100 0 : JAMI_ERROR("[Account {}] [Conversation {}] failed to move HEAD reference", accountId_, id_);
1101 0 : return false;
1102 : }
1103 957 : git_reference_free(new_target_ref);
1104 :
1105 958 : return true;
1106 958 : }
1107 :
1108 : bool
1109 438 : ConversationRepository::Impl::add(const std::string& path)
1110 : {
1111 438 : auto repo = repository();
1112 438 : if (!repo)
1113 0 : return false;
1114 438 : git_index* index_ptr = nullptr;
1115 438 : if (git_repository_index(&index_ptr, repo.get()) < 0) {
1116 0 : JAMI_ERROR("Unable to open repository index");
1117 0 : return false;
1118 : }
1119 438 : GitIndex index {index_ptr};
1120 438 : if (git_index_add_bypath(index.get(), path.c_str()) != 0) {
1121 0 : const git_error* err = giterr_last();
1122 0 : if (err)
1123 0 : JAMI_ERROR("Error when adding file: {}", err->message);
1124 0 : return false;
1125 : }
1126 438 : return git_index_write(index.get()) == 0;
1127 438 : }
1128 :
1129 : bool
1130 181 : ConversationRepository::Impl::checkValidUserDiff(const std::string& userDevice,
1131 : const std::string& commitId,
1132 : const std::string& parentId) const
1133 : {
1134 : // Retrieve tree for recent commit
1135 181 : auto repo = repository();
1136 181 : if (!repo)
1137 0 : return false;
1138 : // Here, we check that a file device is modified or not.
1139 181 : auto changedFiles = ConversationRepository::changedFiles(diffStats(commitId, parentId));
1140 181 : if (changedFiles.size() == 0)
1141 172 : return true;
1142 :
1143 : // If a certificate is modified (in the changedFiles), it MUST be a certificate from the user
1144 : // Retrieve userUri
1145 9 : auto treeNew = treeAtCommit(repo.get(), commitId);
1146 9 : auto userUri = uriFromDevice(userDevice, commitId);
1147 9 : if (userUri.empty())
1148 1 : return false;
1149 :
1150 8 : std::string userDeviceFile = fmt::format("devices/{}.crt", userDevice);
1151 8 : std::string adminsFile = fmt::format("admins/{}.crt", userUri);
1152 8 : std::string membersFile = fmt::format("members/{}.crt", userUri);
1153 8 : auto treeOld = treeAtCommit(repo.get(), parentId);
1154 8 : if (not treeNew or not treeOld)
1155 0 : return false;
1156 12 : for (const auto& changedFile : changedFiles) {
1157 9 : if (changedFile == adminsFile || changedFile == membersFile) {
1158 : // In this case, we should verify it's not added (normal commit, not a member change)
1159 : // but only updated
1160 1 : auto oldFile = fileAtTree(changedFile, treeOld);
1161 1 : if (!oldFile) {
1162 0 : JAMI_ERROR("Invalid file modified: {}", changedFile);
1163 0 : return false;
1164 : }
1165 1 : auto newFile = fileAtTree(changedFile, treeNew);
1166 1 : if (!newFile || !verifyCertificate(as_view(newFile), userUri, treeNew, "", as_view(oldFile))) {
1167 0 : JAMI_ERROR("Invalid certificate {}", changedFile);
1168 0 : return false;
1169 : }
1170 9 : } else if (changedFile == userDeviceFile) {
1171 : // In this case, device is added or modified (certificate expiration)
1172 4 : auto oldFile = fileAtTree(changedFile, treeOld);
1173 4 : std::string_view oldCert;
1174 4 : if (oldFile)
1175 2 : oldCert = as_view(oldFile);
1176 4 : auto newFile = fileAtTree(changedFile, treeNew);
1177 4 : if (!newFile || !verifyCertificate(as_view(newFile), userUri, treeNew, userDevice, oldCert)) {
1178 1 : JAMI_ERROR("Invalid certificate {}", changedFile);
1179 1 : return false;
1180 : }
1181 5 : } else {
1182 : // Invalid file detected
1183 4 : JAMI_ERROR("Invalid add file detected: {} {}", changedFile, (int) mode());
1184 4 : return false;
1185 : }
1186 : }
1187 :
1188 3 : return true;
1189 181 : }
1190 :
1191 : bool
1192 14 : ConversationRepository::Impl::checkValidCheckpoint(const std::string& userDevice,
1193 : const std::string& commitId,
1194 : const std::string& parentId) const
1195 : {
1196 : // Checkpoints carry CRDT updates in the commit message and exist only in
1197 : // document repositories. The author's membership is verified afterwards by
1198 : // isValidUserAtCommit(), like for any other commit; what is checked here is
1199 : // that the tree is either untouched or only adds content-addressed
1200 : // attachments, so a checkpoint can never alter certificates or metadata.
1201 : // The one exception is the author's own device certificate, which is added
1202 : // alongside a device's first commit exactly as for any other commit type.
1203 14 : if (mode() != ConversationMode::DOCUMENT) {
1204 1 : JAMI_ERROR("Checkpoint commit {} in a non-document repository", commitId);
1205 1 : return false;
1206 : }
1207 13 : auto repo = repository();
1208 13 : if (!repo)
1209 0 : return false;
1210 13 : auto changedFiles = ConversationRepository::changedFiles(diffStats(commitId, parentId));
1211 13 : if (changedFiles.empty())
1212 9 : return true;
1213 4 : auto userUri = uriFromDevice(userDevice, commitId);
1214 4 : if (userUri.empty())
1215 0 : return false;
1216 4 : std::string userDeviceFile = fmt::format("devices/{}.crt", userDevice);
1217 4 : auto treeNew = treeAtCommit(repo.get(), commitId);
1218 4 : auto treeOld = treeAtCommit(repo.get(), parentId);
1219 4 : if (not treeNew or not treeOld)
1220 0 : return false;
1221 5 : for (const auto& changedFile : changedFiles) {
1222 4 : if (changedFile.starts_with("attachments/")) {
1223 : // The entry's name must be the git oid of its own content: two
1224 : // admissible attachments sharing a name then necessarily hold the
1225 : // same bytes, so concurrent additions can never conflict.
1226 2 : auto blob = fileAtTree(changedFile, treeNew);
1227 2 : if (!blob) {
1228 0 : JAMI_ERROR("Attachment removed in checkpoint commit {}: {}", commitId, changedFile);
1229 0 : return false;
1230 : }
1231 2 : auto name = changedFile.substr(std::string_view("attachments/").size());
1232 2 : if (name != git_oid_tostr_s(git_object_id(blob.get()))) {
1233 1 : JAMI_ERROR("Attachment not content-addressed in commit {}: {}", commitId, changedFile);
1234 1 : return false;
1235 : }
1236 1 : continue;
1237 4 : }
1238 2 : if (changedFile == userDeviceFile) {
1239 1 : auto newFile = fileAtTree(changedFile, treeNew);
1240 1 : if (!newFile) {
1241 1 : JAMI_ERROR("Device certificate removed in checkpoint commit {}: {}", commitId, changedFile);
1242 1 : return false;
1243 : }
1244 0 : auto oldFile = fileAtTree(changedFile, treeOld);
1245 0 : std::string_view oldCert;
1246 0 : if (oldFile)
1247 0 : oldCert = as_view(oldFile);
1248 0 : if (!verifyCertificate(as_view(newFile), userUri, treeNew, userDevice, oldCert)) {
1249 0 : JAMI_ERROR("Invalid certificate {}", changedFile);
1250 0 : return false;
1251 : }
1252 0 : continue;
1253 1 : }
1254 1 : JAMI_ERROR("Invalid file in checkpoint commit {}: {}", commitId, changedFile);
1255 1 : return false;
1256 : }
1257 1 : return true;
1258 13 : }
1259 :
1260 : bool
1261 3 : ConversationRepository::Impl::checkEdit(const std::string& userDevice, const ConversationCommit& commit) const
1262 : {
1263 3 : auto repo = repository();
1264 3 : if (!repo)
1265 0 : return false;
1266 3 : auto userUri = uriFromDevice(userDevice, commit.id);
1267 3 : if (userUri.empty())
1268 0 : return false;
1269 : // Check that edited commit is found, for the same author, and editable (plain/text)
1270 3 : auto editedId = commit.commitMsg.editedId;
1271 3 : auto editedCommit = getCommit(editedId);
1272 3 : if (editedCommit == std::nullopt) {
1273 0 : JAMI_ERROR("Commit {:s} not found", editedId);
1274 0 : return false;
1275 : }
1276 3 : if (editedCommit->authorId != commit.authorId or commit.authorId != userUri) {
1277 0 : JAMI_ERROR("Edited commit {:s} got a different author ({:s})", editedId, commit.id);
1278 0 : return false;
1279 : }
1280 3 : if (editedCommit->commitMsg.type == CommitType::TEXT) {
1281 1 : return true;
1282 : }
1283 2 : if (editedCommit->commitMsg.type == CommitType::DATA_TRANSFER) {
1284 0 : if (!editedCommit->commitMsg.tid.empty())
1285 0 : return true;
1286 : }
1287 : // Removing a collaborative document is an edition of the commit that
1288 : // announced it, so the author check above is what says that only the member
1289 : // who created a document may remove it for everyone.
1290 2 : if (editedCommit->commitMsg.type == CommitType::COLLAB_DOC) {
1291 1 : return true;
1292 : }
1293 1 : JAMI_ERROR("Edited commit {:s} is not valid!", editedId);
1294 1 : return false;
1295 3 : }
1296 :
1297 : bool
1298 13 : ConversationRepository::Impl::checkVote(const std::string& userDevice,
1299 : const std::string& commitId,
1300 : const std::string& parentId) const
1301 : {
1302 : // Check that maximum deviceFile and a vote is added
1303 13 : auto changedFiles = ConversationRepository::changedFiles(diffStats(commitId, parentId));
1304 13 : if (changedFiles.size() == 0) {
1305 2 : return true;
1306 11 : } else if (changedFiles.size() > 2) {
1307 0 : return false;
1308 : }
1309 : // If modified, it's the first commit of a device, we check
1310 : // that the file wasn't there previously. And the vote MUST be added
1311 22 : std::string deviceFile = "";
1312 11 : std::string votedFile = "";
1313 22 : for (const auto& changedFile : changedFiles) {
1314 : // NOTE: libgit2 return a diff with /, not DIR_SEPARATOR_DIR
1315 26 : if (changedFile == fmt::format("devices/{}.crt", userDevice)) {
1316 2 : deviceFile = changedFile;
1317 11 : } else if (changedFile.find("votes") == 0) {
1318 9 : votedFile = changedFile;
1319 : } else {
1320 : // Invalid file detected
1321 2 : JAMI_ERROR("Invalid vote file detected: {}", changedFile);
1322 2 : return false;
1323 : }
1324 : }
1325 :
1326 9 : if (votedFile.empty()) {
1327 0 : JAMI_WARNING("No vote detected for commit {}", commitId);
1328 0 : return false;
1329 : }
1330 :
1331 9 : auto repo = repository();
1332 9 : if (!repo)
1333 0 : return false;
1334 9 : auto treeNew = treeAtCommit(repo.get(), commitId);
1335 9 : auto treeOld = treeAtCommit(repo.get(), parentId);
1336 9 : if (not treeNew or not treeOld)
1337 0 : return false;
1338 :
1339 9 : auto userUri = uriFromDevice(userDevice, commitId);
1340 9 : if (userUri.empty())
1341 0 : return false;
1342 : // Check that voter is admin
1343 9 : auto adminFile = fmt::format("admins/{}.crt", userUri);
1344 :
1345 9 : if (!fileAtTree(adminFile, treeOld)) {
1346 0 : JAMI_ERROR("Vote from non admin: {}", userUri);
1347 0 : return false;
1348 : }
1349 :
1350 : // Check votedFile path
1351 9 : static const std::regex regex_votes("votes.(\\w+).(members|devices|admins|invited).(\\w+).(\\w+)");
1352 9 : std::svmatch base_match;
1353 9 : if (!std::regex_match(votedFile, base_match, regex_votes) or base_match.size() != 5) {
1354 0 : JAMI_WARNING("Invalid votes path: {}", votedFile);
1355 0 : return false;
1356 : }
1357 :
1358 9 : std::string_view matchedUri = svsub_match_view(base_match[4]);
1359 9 : if (matchedUri != userUri) {
1360 0 : JAMI_ERROR("Admin voted for other user: {:s} vs {:s}", userUri, matchedUri);
1361 0 : return false;
1362 : }
1363 9 : std::string_view votedUri = svsub_match_view(base_match[3]);
1364 9 : std::string_view type = svsub_match_view(base_match[2]);
1365 9 : std::string_view voteType = svsub_match_view(base_match[1]);
1366 9 : if (voteType != "ban" && voteType != "unban") {
1367 0 : JAMI_ERROR("Unrecognized vote {:s}", voteType);
1368 0 : return false;
1369 : }
1370 :
1371 : // Check that vote file is empty and wasn't modified
1372 9 : if (fileAtTree(votedFile, treeOld)) {
1373 0 : JAMI_ERROR("Invalid voted file modified: {:s}", votedFile);
1374 0 : return false;
1375 : }
1376 9 : auto vote = fileAtTree(votedFile, treeNew);
1377 9 : if (!vote) {
1378 0 : JAMI_ERROR("No vote file found for: {:s}", userUri);
1379 0 : return false;
1380 : }
1381 9 : auto voteContent = as_view(vote);
1382 9 : if (!voteContent.empty()) {
1383 0 : JAMI_ERROR("Vote file not empty: {:s}", votedFile);
1384 0 : return false;
1385 : }
1386 :
1387 : // Check that peer voted is only other device or other member
1388 9 : if (type != "devices") {
1389 9 : if (votedUri == userUri) {
1390 0 : JAMI_ERROR("Detected vote for self: {:s}", votedUri);
1391 0 : return false;
1392 : }
1393 9 : if (voteType == "ban") {
1394 : // file in members or admin or invited
1395 7 : auto invitedFile = fmt::format("invited/{}", votedUri);
1396 7 : if (!memberCertificate(votedUri, treeOld) && !fileAtTree(invitedFile, treeOld)) {
1397 0 : JAMI_ERROR("No member file found for vote: {:s}", votedUri);
1398 0 : return false;
1399 : }
1400 7 : }
1401 : } else {
1402 : // Check not current device
1403 0 : if (votedUri == userDevice) {
1404 0 : JAMI_ERROR("Detected vote for self: {:s}", votedUri);
1405 0 : return false;
1406 : }
1407 : // File in devices
1408 0 : deviceFile = fmt::format("devices/{}.crt", votedUri);
1409 0 : if (!fileAtTree(deviceFile, treeOld)) {
1410 0 : JAMI_ERROR("No device file found for vote: {:s}", votedUri);
1411 0 : return false;
1412 : }
1413 : }
1414 :
1415 9 : return true;
1416 13 : }
1417 :
1418 : bool
1419 804 : ConversationRepository::Impl::checkValidAdd(const std::string& userDevice,
1420 : const std::string& uriMember,
1421 : const std::string& commitId,
1422 : const std::string& parentId) const
1423 : {
1424 804 : auto repo = repository();
1425 804 : if (not repo)
1426 0 : return false;
1427 :
1428 : // std::string repoPath = git_repository_workdir(repo.get());
1429 804 : if (mode() == ConversationMode::ONE_TO_ONE) {
1430 1 : auto initialMembers = getInitialMembers();
1431 1 : auto it = std::find(initialMembers.begin(), initialMembers.end(), uriMember);
1432 1 : if (it == initialMembers.end()) {
1433 1 : JAMI_ERROR("Invalid add in one to one conversation: {}", uriMember);
1434 1 : return false;
1435 : }
1436 1 : }
1437 :
1438 803 : auto userUri = uriFromDevice(userDevice, commitId);
1439 803 : if (userUri.empty())
1440 0 : return false;
1441 :
1442 : // Check that only /invited/uri.crt is added & deviceFile & CRLs
1443 803 : auto changedFiles = ConversationRepository::changedFiles(diffStats(commitId, parentId));
1444 803 : if (changedFiles.size() == 0) {
1445 0 : return false;
1446 803 : } else if (changedFiles.size() > 3) {
1447 0 : return false;
1448 : }
1449 :
1450 : // Check that user added is not sender
1451 803 : if (userUri == uriMember) {
1452 0 : JAMI_ERROR("Member tried to add self: {}", userUri);
1453 0 : return false;
1454 : }
1455 :
1456 : // If modified, it's the first commit of a device, we check
1457 : // that the file wasn't there previously. And the member MUST be added
1458 : // NOTE: libgit2 return a diff with /, not DIR_SEPARATOR_DIR
1459 1606 : std::string deviceFile = "";
1460 1606 : std::string invitedFile = "";
1461 803 : std::string crlFile = std::string("CRLs/") + userUri;
1462 1606 : for (const auto& changedFile : changedFiles) {
1463 1608 : if (changedFile == std::string("devices/") + userDevice + ".crt") {
1464 1 : deviceFile = changedFile;
1465 1606 : } else if (changedFile == std::string("invited/") + uriMember) {
1466 802 : invitedFile = changedFile;
1467 1 : } else if (changedFile == crlFile) {
1468 : // Nothing to do
1469 : } else {
1470 : // Invalid file detected
1471 1 : JAMI_ERROR("Invalid add file detected: {}", changedFile);
1472 1 : return false;
1473 : }
1474 : }
1475 :
1476 802 : auto treeOld = treeAtCommit(repo.get(), parentId);
1477 802 : if (not treeOld)
1478 0 : return false;
1479 :
1480 802 : auto treeNew = treeAtCommit(repo.get(), commitId);
1481 802 : auto blob_invite = fileAtTree(invitedFile, treeNew);
1482 802 : if (!blob_invite) {
1483 0 : JAMI_ERROR("Invitation not found for commit {}", commitId);
1484 0 : return false;
1485 : }
1486 :
1487 802 : auto invitation = as_view(blob_invite);
1488 802 : if (!invitation.empty()) {
1489 0 : JAMI_ERROR("Invitation not empty for commit {}", commitId);
1490 0 : return false;
1491 : }
1492 :
1493 : // Check that user not in /banned
1494 : std::string bannedFile = fmt::format("{}/{}/{}.crt",
1495 802 : MemberPath::BANNED.string(),
1496 1604 : MemberPath::MEMBERS.string(),
1497 1604 : uriMember);
1498 802 : if (fileAtTree(bannedFile, treeOld)) {
1499 0 : JAMI_ERROR("Tried to add banned member: {}", bannedFile);
1500 0 : return false;
1501 : }
1502 :
1503 802 : return true;
1504 804 : }
1505 :
1506 : bool
1507 843 : ConversationRepository::Impl::checkValidJoins(const std::string& userDevice,
1508 : const std::string& uriMember,
1509 : const std::string& commitId,
1510 : const std::string& parentId) const
1511 : {
1512 : // Check no other files changed
1513 843 : auto changedFiles = ConversationRepository::changedFiles(diffStats(commitId, parentId));
1514 843 : auto invitedFile = fmt::format("invited/{}", uriMember);
1515 843 : auto membersFile = fmt::format("members/{}.crt", uriMember);
1516 843 : auto deviceFile = fmt::format("devices/{}.crt", userDevice);
1517 :
1518 3367 : for (auto& file : changedFiles) {
1519 2524 : if (file != invitedFile && file != membersFile && file != deviceFile) {
1520 0 : JAMI_ERROR("Unwanted file {} found", file);
1521 0 : return false;
1522 : }
1523 : }
1524 :
1525 : // Retrieve tree for commits
1526 843 : auto repo = repository();
1527 842 : assert(repo);
1528 842 : auto treeNew = treeAtCommit(repo.get(), commitId);
1529 843 : auto treeOld = treeAtCommit(repo.get(), parentId);
1530 843 : if (not treeNew or not treeOld)
1531 0 : return false;
1532 :
1533 : // Check /invited
1534 843 : if (fileAtTree(invitedFile, treeNew)) {
1535 1 : JAMI_ERROR("{} invited not removed", uriMember);
1536 1 : return false;
1537 : }
1538 842 : if (!fileAtTree(invitedFile, treeOld)) {
1539 1 : JAMI_ERROR("{} invited not found", uriMember);
1540 1 : return false;
1541 : }
1542 :
1543 : // Check /members added
1544 841 : if (!fileAtTree(membersFile, treeNew)) {
1545 0 : JAMI_ERROR("{} members not found", uriMember);
1546 0 : return false;
1547 : }
1548 841 : if (fileAtTree(membersFile, treeOld)) {
1549 0 : JAMI_ERROR("{} members found too soon", uriMember);
1550 0 : return false;
1551 : }
1552 :
1553 : // Check /devices added
1554 841 : if (!fileAtTree(deviceFile, treeNew)) {
1555 0 : JAMI_ERROR("{} devices not found", uriMember);
1556 0 : return false;
1557 : }
1558 :
1559 : // Check certificate
1560 841 : auto blob_device = fileAtTree(deviceFile, treeNew);
1561 841 : if (!blob_device) {
1562 0 : JAMI_ERROR("{} announced but not found", deviceFile);
1563 0 : return false;
1564 : }
1565 841 : auto blob_member = fileAtTree(membersFile, treeNew);
1566 840 : if (!blob_member) {
1567 0 : JAMI_ERROR("{} announced but not found", userDevice);
1568 0 : return false;
1569 : }
1570 : try {
1571 841 : auto deviceCert = dht::crypto::Certificate(as_view(blob_device));
1572 841 : auto memberCert = dht::crypto::Certificate(as_view(blob_member));
1573 841 : if (!isCertificateOfDevice(deviceCert, userDevice) || !isDeviceOfMember(deviceCert, memberCert, uriMember)) {
1574 1 : JAMI_ERROR("Incorrect device certificate {} for user {}", userDevice, uriMember);
1575 1 : return false;
1576 : }
1577 842 : } catch (const std::exception& e) {
1578 0 : JAMI_ERROR("Unable to parse certificates for {} joining with {}: {}", uriMember, userDevice, e.what());
1579 0 : return false;
1580 0 : }
1581 :
1582 840 : return true;
1583 843 : }
1584 :
1585 : bool
1586 9 : ConversationRepository::Impl::checkValidRemove(const std::string& userDevice,
1587 : const std::string& uriMember,
1588 : const std::string& commitId,
1589 : const std::string& parentId) const
1590 : {
1591 : // Retrieve tree for recent commit
1592 9 : auto repo = repository();
1593 9 : if (!repo)
1594 0 : return false;
1595 9 : auto treeOld = treeAtCommit(repo.get(), parentId);
1596 9 : if (not treeOld)
1597 0 : return false;
1598 :
1599 9 : auto changedFiles = ConversationRepository::changedFiles(diffStats(commitId, parentId));
1600 : // NOTE: libgit2 return a diff with /, not DIR_SEPARATOR_DIR
1601 9 : std::string deviceFile = fmt::format("devices/{}.crt", userDevice);
1602 9 : std::string adminFile = fmt::format("admins/{}.crt", uriMember);
1603 9 : std::string memberFile = fmt::format("members/{}.crt", uriMember);
1604 9 : std::string crlFile = fmt::format("CRLs/{}", uriMember);
1605 9 : std::string invitedFile = fmt::format("invited/{}", uriMember);
1606 9 : std::vector<std::string> devicesRemoved;
1607 :
1608 : // Check that no weird file is added nor removed
1609 9 : static const std::regex regex_devices("devices.(\\w+)\\.crt");
1610 9 : std::smatch base_match;
1611 27 : for (const auto& f : changedFiles) {
1612 18 : if (f == deviceFile || f == adminFile || f == memberFile || f == crlFile || f == invitedFile) {
1613 : // Ignore
1614 16 : continue;
1615 2 : } else if (std::regex_match(f, base_match, regex_devices)) {
1616 2 : if (base_match.size() == 2)
1617 2 : devicesRemoved.emplace_back(base_match[1]);
1618 : } else {
1619 0 : JAMI_ERROR("Unwanted changed file detected: {}", f);
1620 0 : return false;
1621 : }
1622 : }
1623 :
1624 : // Check that removed devices are for removed member (or directly uriMember)
1625 11 : for (const auto& deviceUri : devicesRemoved) {
1626 4 : deviceFile = fmt::format("devices/{}.crt", deviceUri);
1627 2 : auto blob_device = fileAtTree(deviceFile, treeOld);
1628 2 : if (!blob_device) {
1629 0 : JAMI_ERROR("Device not found added ({})", deviceFile);
1630 0 : return false;
1631 : }
1632 2 : auto userUri = uriFromDeviceAtCommit(deviceUri, parentId);
1633 :
1634 2 : if (uriMember != userUri and uriMember != deviceUri /* If device is removed */) {
1635 0 : JAMI_ERROR("Device removed but not for removed user ({})", deviceFile);
1636 0 : return false;
1637 : }
1638 2 : }
1639 :
1640 9 : return true;
1641 9 : }
1642 :
1643 : bool
1644 14 : ConversationRepository::Impl::checkValidVoteResolution(const std::string& userDevice,
1645 : const std::string& uriMember,
1646 : const std::string& commitId,
1647 : const std::string& parentId,
1648 : const std::string& voteType) const
1649 : {
1650 : // Retrieve tree for recent commit
1651 14 : auto repo = repository();
1652 14 : if (!repo)
1653 0 : return false;
1654 14 : auto treeOld = treeAtCommit(repo.get(), parentId);
1655 14 : if (not treeOld)
1656 0 : return false;
1657 :
1658 14 : auto changedFiles = ConversationRepository::changedFiles(diffStats(commitId, parentId));
1659 : // NOTE: libgit2 return a diff with /, not DIR_SEPARATOR_DIR
1660 14 : std::string deviceFile = fmt::format("devices/{}.crt", userDevice);
1661 14 : std::string adminFile = fmt::format("admins/{}.crt", uriMember);
1662 14 : std::string memberFile = fmt::format("members/{}.crt", uriMember);
1663 14 : std::string crlFile = fmt::format("CRLs/{}", uriMember);
1664 14 : std::string invitedFile = fmt::format("invited/{}", uriMember);
1665 14 : std::vector<std::string> voters;
1666 14 : std::vector<std::string> devicesRemoved;
1667 14 : std::vector<std::string> bannedFiles;
1668 : // Check that no weird file is added nor removed
1669 :
1670 14 : const std::regex regex_votes("votes." + voteType + ".(members|devices|admins|invited).(\\w+).(\\w+)");
1671 14 : static const std::regex regex_devices("devices.(\\w+)\\.crt");
1672 14 : static const std::regex regex_banned("banned.(members|devices|admins).(\\w+)\\.crt");
1673 14 : static const std::regex regex_banned_invited("banned.(invited).(\\w+)");
1674 14 : std::smatch base_match;
1675 41 : for (const auto& f : changedFiles) {
1676 29 : if (f == deviceFile || f == adminFile || f == memberFile || f == crlFile || f == invitedFile) {
1677 : // Ignore
1678 9 : continue;
1679 20 : } else if (std::regex_match(f, base_match, regex_votes)) {
1680 9 : if (base_match.size() != 4 or base_match[2] != uriMember) {
1681 0 : JAMI_ERROR("Invalid vote file detected: {}", f);
1682 0 : return false;
1683 : }
1684 9 : voters.emplace_back(base_match[3]);
1685 : // Check that votes were not added here
1686 9 : if (!fileAtTree(f, treeOld)) {
1687 0 : JAMI_ERROR("invalid vote added ({})", f);
1688 0 : return false;
1689 : }
1690 11 : } else if (std::regex_match(f, base_match, regex_devices)) {
1691 0 : if (base_match.size() == 2)
1692 0 : devicesRemoved.emplace_back(base_match[1]);
1693 11 : } else if (std::regex_match(f, base_match, regex_banned)
1694 11 : || std::regex_match(f, base_match, regex_banned_invited)) {
1695 9 : bannedFiles.emplace_back(f);
1696 9 : if (base_match.size() != 3 or base_match[2] != uriMember) {
1697 0 : JAMI_ERROR("Invalid banned file detected : {}", f);
1698 0 : return false;
1699 : }
1700 : } else {
1701 2 : JAMI_ERROR("Unwanted changed file detected: {}", f);
1702 2 : return false;
1703 : }
1704 : }
1705 :
1706 : // Check that removed devices are for removed member (or directly uriMember)
1707 12 : for (const auto& deviceUri : devicesRemoved) {
1708 0 : deviceFile = fmt::format("devices/{}.crt", deviceUri);
1709 0 : if (voteType == "ban") {
1710 : // If we ban a device, it should be there before
1711 0 : if (!fileAtTree(deviceFile, treeOld)) {
1712 0 : JAMI_ERROR("Device not found added ({})", deviceFile);
1713 0 : return false;
1714 : }
1715 0 : } else if (voteType == "unban") {
1716 : // If we unban a device, it should not be there before
1717 0 : if (fileAtTree(deviceFile, treeOld)) {
1718 0 : JAMI_ERROR("Device not found added ({})", deviceFile);
1719 0 : return false;
1720 : }
1721 : }
1722 : // The device certificate is in the old tree when banned, in the new one when unbanned
1723 0 : auto deviceOwner = uriFromDevice(deviceUri, voteType == "ban" ? parentId : commitId);
1724 0 : if (uriMember != deviceOwner and uriMember != deviceUri /* If device is removed */) {
1725 0 : JAMI_ERROR("Device removed but not for removed user ({})", deviceFile);
1726 0 : return false;
1727 : }
1728 0 : }
1729 :
1730 12 : auto userUri = uriFromDevice(userDevice, commitId);
1731 12 : if (userUri.empty())
1732 0 : return false;
1733 :
1734 : // Check that voters are admins
1735 24 : adminFile = fmt::format("admins/{}.crt", userUri);
1736 12 : if (!fileAtTree(adminFile, treeOld)) {
1737 1 : JAMI_ERROR("admin file ({}) not found", adminFile);
1738 1 : return false;
1739 : }
1740 :
1741 : // If not for self check that vote is valid and not added
1742 11 : auto nbAdmins = 0;
1743 11 : auto nbVotes = 0;
1744 11 : std::string repoPath = git_repository_workdir(repo.get());
1745 22 : for (const auto& certificate : dhtnet::fileutils::readDirectory(repoPath + "admins")) {
1746 11 : if (certificate.find(".crt") == std::string::npos) {
1747 0 : JAMI_WARNING("Incorrect file found: {}", certificate);
1748 0 : continue;
1749 : }
1750 11 : nbAdmins += 1;
1751 22 : auto adminUri = certificate.substr(0, certificate.size() - std::string(".crt").size());
1752 11 : if (std::find(voters.begin(), voters.end(), adminUri) != voters.end()) {
1753 9 : nbVotes += 1;
1754 : }
1755 22 : }
1756 :
1757 11 : if (nbAdmins == 0 or (static_cast<double>(nbVotes) / static_cast<double>(nbAdmins)) < .5) {
1758 2 : JAMI_ERROR("Incomplete vote detected (commit: {})", commitId);
1759 2 : return false;
1760 : }
1761 :
1762 : // If not for self check that member or device certificate is moved to banned/
1763 9 : return !bannedFiles.empty();
1764 14 : }
1765 :
1766 : bool
1767 25 : ConversationRepository::Impl::checkValidProfileUpdate(const std::string& userDevice,
1768 : const std::string& commitId,
1769 : const std::string& parentId) const
1770 : {
1771 : // Retrieve tree for recent commit
1772 25 : auto repo = repository();
1773 25 : if (!repo)
1774 0 : return false;
1775 25 : auto treeNew = treeAtCommit(repo.get(), commitId);
1776 25 : auto treeOld = treeAtCommit(repo.get(), parentId);
1777 25 : if (not treeNew or not treeOld)
1778 0 : return false;
1779 :
1780 25 : auto userUri = uriFromDevice(userDevice, commitId);
1781 25 : if (userUri.empty())
1782 0 : return false;
1783 :
1784 : // Check if profile is changed by an user with correct privilege
1785 25 : auto valid = false;
1786 25 : if (updateProfilePermLvl_ == MemberRole::ADMIN) {
1787 25 : std::string adminFile = fmt::format("admins/{}.crt", userUri);
1788 25 : auto adminCert = fileAtTree(adminFile, treeNew);
1789 25 : valid |= adminCert != nullptr;
1790 25 : }
1791 25 : if (updateProfilePermLvl_ >= MemberRole::MEMBER) {
1792 0 : std::string memberFile = fmt::format("members/{}.crt", userUri);
1793 0 : auto memberCert = fileAtTree(memberFile, treeNew);
1794 0 : valid |= memberCert != nullptr;
1795 0 : }
1796 :
1797 25 : if (!valid) {
1798 1 : JAMI_ERROR("Profile changed from unauthorized user: {} ({})", userDevice, userUri);
1799 1 : return false;
1800 : }
1801 :
1802 24 : auto changedFiles = ConversationRepository::changedFiles(diffStats(commitId, parentId));
1803 : // Check that no weird file is added nor removed
1804 24 : std::string userDeviceFile = fmt::format("devices/{}.crt", userDevice);
1805 48 : for (const auto& f : changedFiles) {
1806 25 : if (f == "profile.vcf") {
1807 : // Ignore
1808 2 : } else if (f == userDeviceFile) {
1809 : // In this case, device is added or modified (certificate expiration)
1810 1 : auto oldFile = fileAtTree(f, treeOld);
1811 1 : std::string_view oldCert;
1812 1 : if (oldFile)
1813 0 : oldCert = as_view(oldFile);
1814 1 : auto newFile = fileAtTree(f, treeNew);
1815 1 : if (!newFile || !verifyCertificate(as_view(newFile), userUri, treeNew, userDevice, oldCert)) {
1816 0 : JAMI_ERROR("Invalid certificate {}", f);
1817 0 : return false;
1818 : }
1819 1 : } else {
1820 1 : JAMI_ERROR("Unwanted changed file detected: {}", f);
1821 1 : return false;
1822 : }
1823 : }
1824 23 : return true;
1825 25 : }
1826 :
1827 : /**
1828 : * @brief Get the deltas from a git diff
1829 : * @param diff The diff object to extract deltas from
1830 : * @return The set of git_diff deltas extracted from the diff
1831 : */
1832 : std::optional<std::set<std::string_view>>
1833 20 : ConversationRepository::Impl::getDeltaPathsFromDiff(const GitDiff& diff) const
1834 : {
1835 20 : std::set<std::string_view> deltas_set = {};
1836 64 : for (size_t delta_idx = 0, delta_count = git_diff_num_deltas(diff.get()); delta_idx < delta_count; delta_idx++) {
1837 44 : const git_diff_delta* delta = git_diff_get_delta(diff.get(), delta_idx);
1838 44 : if (!delta) {
1839 0 : JAMI_LOG("[Account {}] [Conversation {}] Index of delta out of range!", accountId_, id_);
1840 0 : return std::nullopt;
1841 : }
1842 :
1843 44 : deltas_set.emplace(std::string_view(delta->old_file.path));
1844 44 : deltas_set.emplace(std::string_view(delta->new_file.path));
1845 : }
1846 20 : return deltas_set;
1847 20 : }
1848 :
1849 : /**
1850 : * @brief Validate the merge commit by ensuring the absence of invalid files
1851 : * @param mergeId The id of the merge commit
1852 : * @param parents The two commit IDs of parent's of the merge commit
1853 : * @return bool Whether or not invalid files were found in the merge commit
1854 : */
1855 : bool
1856 10 : ConversationRepository::Impl::checkValidMergeCommit(const std::string& mergeId,
1857 : const std::vector<std::string>& parents) const
1858 : {
1859 : // Get the repository associated with this implementation
1860 10 : auto repo = repository();
1861 10 : if (!repo)
1862 0 : return false;
1863 :
1864 : // Check for exactly two parents
1865 10 : if (static_cast<int>(parents.size()) != 2)
1866 0 : return false;
1867 :
1868 : // Get the tree of the merge commit
1869 10 : GitTree merge_commit_tree = treeAtCommit(repo.get(), mergeId);
1870 :
1871 : // Get the diff of the merge commit and the first parent
1872 10 : GitTree first_tree = treeAtCommit(repo.get(), parents[0]);
1873 10 : git_diff* diff_merge_tree_to_first_tree = nullptr;
1874 10 : if (git_diff_tree_to_tree(&diff_merge_tree_to_first_tree,
1875 : repo.get(),
1876 : first_tree.get(),
1877 : merge_commit_tree.get(),
1878 : nullptr)
1879 10 : < 0) {
1880 0 : const git_error* err = giterr_last();
1881 0 : if (err)
1882 0 : JAMI_ERROR("[Account {}] [Conversation {}] Failed to git diff of merge and first parent "
1883 : "failed: {}",
1884 : accountId_,
1885 : id_,
1886 : err->message);
1887 0 : return false;
1888 : }
1889 10 : GitDiff first_diff {diff_merge_tree_to_first_tree};
1890 :
1891 : // Get the diff of the merge commit and the second parent
1892 10 : GitTree second_tree = treeAtCommit(repo.get(), parents[1]);
1893 10 : git_diff* diff_merge_tree_to_second_tree = nullptr;
1894 10 : if (git_diff_tree_to_tree(&diff_merge_tree_to_second_tree,
1895 : repo.get(),
1896 : second_tree.get(),
1897 : merge_commit_tree.get(),
1898 : nullptr)
1899 10 : < 0) {
1900 0 : const git_error* err = giterr_last();
1901 0 : if (err)
1902 0 : JAMI_ERROR("[Account {}] [Conversation {}] Failed to git diff of merge and second parent "
1903 : "failed: {}",
1904 : accountId_,
1905 : id_,
1906 : err->message);
1907 0 : return false;
1908 : }
1909 10 : GitDiff second_diff {diff_merge_tree_to_second_tree};
1910 :
1911 : // Get the deltas of the first parent's commit
1912 10 : auto first_parent_deltas_set = getDeltaPathsFromDiff(first_diff);
1913 : // Get the deltas of the second parent's commit
1914 10 : auto second_parent_deltas_set = getDeltaPathsFromDiff(second_diff);
1915 10 : if (first_parent_deltas_set == std::nullopt || second_parent_deltas_set == std::nullopt) {
1916 0 : JAMI_ERROR("[Account {}] [Conversation {}] Unable to get deltas from diffs for merge commit {}",
1917 : accountId_,
1918 : id_,
1919 : mergeId);
1920 0 : return false;
1921 : }
1922 : // Get the intersection of the deltas of both parents
1923 10 : std::set<std::string_view> parent_deltas_intersection_set = {};
1924 10 : std::set_intersection(first_parent_deltas_set->begin(),
1925 : first_parent_deltas_set->end(),
1926 : second_parent_deltas_set->begin(),
1927 : second_parent_deltas_set->end(),
1928 : std::inserter(parent_deltas_intersection_set, parent_deltas_intersection_set.begin()));
1929 :
1930 : // The intersection of the set of diffs of both the parents of the merge commit should be be the
1931 : // empty set (i.e. no deltas in the intersection vector). This ensures that no malicious files
1932 : // have been added into the merge commit itself.
1933 10 : if (not parent_deltas_intersection_set.empty()) {
1934 1 : return false;
1935 : }
1936 9 : return true;
1937 10 : }
1938 :
1939 : bool
1940 2133 : ConversationRepository::Impl::isValidUserAtCommit(const std::string& userDevice,
1941 : const std::string& commitId,
1942 : const git_buf& sig,
1943 : const git_buf& sig_data) const
1944 : {
1945 2133 : auto acc = account_.lock();
1946 2133 : if (!acc)
1947 0 : return false;
1948 2133 : auto cert = acc->certStore().getCertificate(userDevice);
1949 2133 : auto hasPinnedCert = cert and cert->issuer;
1950 2132 : auto repo = repository();
1951 2133 : if (not repo)
1952 0 : return false;
1953 :
1954 : // Retrieve tree for commit
1955 2133 : auto tree = treeAtCommit(repo.get(), commitId);
1956 2133 : if (not tree)
1957 0 : return false;
1958 :
1959 : // Check that /devices/userDevice.crt exists
1960 2133 : std::string deviceFile = fmt::format("devices/{}.crt", userDevice);
1961 2133 : auto blob_device = fileAtTree(deviceFile, tree);
1962 2133 : if (!blob_device) {
1963 3 : JAMI_ERROR("{} announced but not found", deviceFile);
1964 3 : return false;
1965 : }
1966 2129 : std::shared_ptr<dht::crypto::Certificate> deviceCert, parentCert;
1967 : try {
1968 2129 : deviceCert = std::make_shared<dht::crypto::Certificate>(as_view(blob_device));
1969 0 : } catch (const std::exception& e) {
1970 0 : JAMI_ERROR("Unable to parse {}: {}", deviceFile, e.what());
1971 0 : return false;
1972 0 : }
1973 2129 : if (!isCertificateOfDevice(*deviceCert, userDevice)) {
1974 0 : JAMI_ERROR("{} belongs to another key", deviceFile);
1975 0 : return false;
1976 : }
1977 2130 : auto userUri = deviceCert->getIssuerUID();
1978 2130 : if (userUri.empty()) {
1979 0 : JAMI_ERROR("{} got no issuer UID", deviceFile);
1980 0 : if (not hasPinnedCert) {
1981 0 : return false;
1982 : } else {
1983 : // Uses pinned certificate if one.
1984 0 : userUri = cert->issuer->getId().toString();
1985 : }
1986 : }
1987 :
1988 : // Check that /(members|admins)/userUri.crt exists
1989 2130 : auto blob_parent = memberCertificate(userUri, tree);
1990 2129 : if (not blob_parent) {
1991 0 : JAMI_ERROR("Certificate not found for {}", userUri);
1992 0 : return false;
1993 : }
1994 :
1995 : try {
1996 2129 : parentCert = std::make_shared<dht::crypto::Certificate>(as_view(blob_parent));
1997 0 : } catch (const std::exception& e) {
1998 0 : JAMI_ERROR("Unable to parse certificate of {}: {}", userUri, e.what());
1999 0 : return false;
2000 0 : }
2001 :
2002 : // The issuer UID is a plain string chosen by whoever built the device certificate:
2003 : // the device MUST be proven to be issued by the member's key.
2004 2130 : if (!isDeviceOfMember(*deviceCert, *parentCert, userUri)) {
2005 0 : JAMI_ERROR("Device {} is not certified by {}", userDevice, userUri);
2006 0 : return false;
2007 : }
2008 :
2009 : // Check that certificates were still valid
2010 : git_oid oid;
2011 2130 : git_commit* commit_ptr = nullptr;
2012 2130 : if (git_oid_fromstr(&oid, commitId.c_str()) < 0 || git_commit_lookup(&commit_ptr, repo.get(), &oid) < 0) {
2013 0 : JAMI_WARNING("Failed to look up commit {}", commitId);
2014 0 : return false;
2015 : }
2016 2130 : GitCommit commit {commit_ptr};
2017 :
2018 2129 : auto commitTime = std::chrono::system_clock::from_time_t(git_commit_time(commit.get()));
2019 2130 : if (deviceCert->getExpiration() < commitTime) {
2020 0 : JAMI_ERROR("Certificate {} expired", deviceCert->getId().toString());
2021 0 : return false;
2022 : }
2023 2129 : if (parentCert->getExpiration() < commitTime) {
2024 0 : JAMI_ERROR("Certificate {} expired", parentCert->getId().toString());
2025 0 : return false;
2026 : }
2027 :
2028 : // Verify the signature (git verify-commit)
2029 2130 : auto pk = base64::decode(std::string_view(sig.ptr, sig.size));
2030 2129 : bool valid_signature = deviceCert->getPublicKey().checkSignature(reinterpret_cast<const uint8_t*>(sig_data.ptr),
2031 2130 : sig_data.size,
2032 2130 : pk.data(),
2033 : pk.size());
2034 :
2035 2130 : if (!valid_signature) {
2036 1 : JAMI_WARNING("Commit {} not signed by device {}.", git_oid_tostr_s(&oid), userDevice);
2037 1 : return false;
2038 : }
2039 :
2040 2129 : if (not hasPinnedCert) {
2041 : // Pin the verified chain, dropping any unauthenticated chain embedded in the file
2042 33 : deviceCert->issuer = parentCert;
2043 33 : acc->certStore().pinCertificate(deviceCert);
2044 : }
2045 2129 : return true;
2046 2133 : }
2047 :
2048 : bool
2049 242 : ConversationRepository::Impl::checkInitialCommit(const std::string& userDevice,
2050 : const std::string& commitId,
2051 : const CommitMessage& commitMsg) const
2052 : {
2053 242 : auto account = account_.lock();
2054 242 : auto repo = repository();
2055 242 : if (not account or not repo) {
2056 0 : JAMI_WARNING("Invalid repository detected");
2057 0 : return false;
2058 : }
2059 :
2060 242 : auto treeNew = treeAtCommit(repo.get(), commitId);
2061 242 : auto userUri = uriFromDevice(userDevice, commitId);
2062 242 : if (userUri.empty())
2063 0 : return false;
2064 :
2065 242 : auto changedFiles = ConversationRepository::changedFiles(diffStats(commitId, ""));
2066 : // NOTE: libgit2 return a diff with /, not DIR_SEPARATOR_DIR
2067 :
2068 : try {
2069 242 : mode();
2070 0 : } catch (...) {
2071 0 : JAMI_ERROR("Invalid mode detected for commit: {}", commitId);
2072 0 : return false;
2073 0 : }
2074 :
2075 242 : std::string invited = {};
2076 242 : if (mode_ == ConversationMode::ONE_TO_ONE) {
2077 64 : invited = commitMsg.invited;
2078 : }
2079 :
2080 242 : auto hasDevice = false, hasAdmin = false;
2081 242 : std::string adminsFile = fmt::format("admins/{}.crt", userUri);
2082 242 : std::string deviceFile = fmt::format("devices/{}.crt", userDevice);
2083 242 : std::string crlFile = fmt::format("CRLs/{}", userUri);
2084 242 : std::string invitedFile = fmt::format("invited/{}", invited);
2085 :
2086 : // Check that admin cert is added
2087 : // Check that device cert is added
2088 : // Check CRLs added
2089 : // Check that no other file is added
2090 : // Check if invited file present for one to one.
2091 787 : for (const auto& changedFile : changedFiles) {
2092 547 : if (changedFile == adminsFile) {
2093 241 : hasAdmin = true;
2094 241 : auto newFile = fileAtTree(changedFile, treeNew);
2095 241 : if (!newFile || !verifyCertificate(as_view(newFile), userUri, treeNew, "")) {
2096 0 : JAMI_ERROR("Invalid certificate found {}", changedFile);
2097 0 : return false;
2098 : }
2099 547 : } else if (changedFile == deviceFile) {
2100 241 : hasDevice = true;
2101 241 : auto newFile = fileAtTree(changedFile, treeNew);
2102 241 : if (!newFile || !verifyCertificate(as_view(newFile), userUri, treeNew, userDevice)) {
2103 1 : JAMI_ERROR("Invalid certificate found {}", changedFile);
2104 1 : return false;
2105 : }
2106 306 : } else if (changedFile == crlFile || changedFile == invitedFile) {
2107 : // Nothing to do
2108 64 : continue;
2109 : } else {
2110 : // Invalid file detected
2111 1 : JAMI_ERROR("Invalid add file detected: {} {}", changedFile, (int) *mode_);
2112 1 : return false;
2113 : }
2114 : }
2115 :
2116 240 : return hasDevice && hasAdmin;
2117 242 : }
2118 :
2119 : bool
2120 694 : ConversationRepository::Impl::validateDevice()
2121 : {
2122 694 : auto repo = repository();
2123 694 : auto account = account_.lock();
2124 694 : if (!account || !repo) {
2125 0 : JAMI_WARNING("[Account {}] [Conversation {}] Invalid repository detected", accountId_, id_);
2126 0 : return false;
2127 : }
2128 694 : auto path = fmt::format("devices/{}.crt", deviceId_);
2129 694 : std::filesystem::path devicePath = git_repository_workdir(repo.get());
2130 694 : devicePath /= path;
2131 694 : if (!std::filesystem::is_regular_file(devicePath)) {
2132 0 : JAMI_WARNING("[Account {}] [Conversation {}] Unable to find file {}", accountId_, id_, devicePath);
2133 0 : return false;
2134 : }
2135 :
2136 694 : auto wrongDeviceFile = false;
2137 : try {
2138 698 : auto deviceCert = dht::crypto::Certificate(fileutils::loadFile(devicePath));
2139 692 : wrongDeviceFile = !account->isValidAccountDevice(deviceCert);
2140 694 : } catch (const std::exception&) {
2141 2 : wrongDeviceFile = true;
2142 2 : }
2143 694 : if (wrongDeviceFile) {
2144 5 : JAMI_WARNING(
2145 : "[Account {}] [Conversation {}] Device certificate is no longer valid. Attempting to update certificate.",
2146 : accountId_,
2147 : id_);
2148 : // Replace certificate with current cert
2149 5 : auto cert = account->identity().second;
2150 5 : if (!cert || !account->isValidAccountDevice(*cert)) {
2151 0 : JAMI_ERROR("[Account {}] [Conversation {}] Current device's certificate is invalid. A migration is needed",
2152 : accountId_,
2153 : id_);
2154 0 : return false;
2155 : }
2156 5 : std::ofstream file(devicePath, std::ios::trunc | std::ios::binary);
2157 5 : if (!file.is_open()) {
2158 0 : JAMI_ERROR("[Account {}] [Conversation {}] Unable to write data to {}", accountId_, id_, devicePath);
2159 0 : return false;
2160 : }
2161 5 : file << cert->toString(false);
2162 5 : file.close();
2163 5 : if (!add(path)) {
2164 0 : JAMI_ERROR("[Account {}] [Conversation {}] Unable to add file {}", accountId_, id_, devicePath);
2165 0 : return false;
2166 : }
2167 5 : }
2168 :
2169 : // Check account cert (a new device can be added but account certifcate can be the old one!)
2170 694 : auto adminPath = fmt::format("admins/{}.crt", userId_);
2171 694 : auto memberPath = fmt::format("members/{}.crt", userId_);
2172 694 : std::filesystem::path parentPath = git_repository_workdir(repo.get());
2173 694 : std::filesystem::path relativeParentPath;
2174 694 : if (std::filesystem::is_regular_file(parentPath / adminPath))
2175 409 : relativeParentPath = adminPath;
2176 285 : else if (std::filesystem::is_regular_file(parentPath / memberPath))
2177 284 : relativeParentPath = memberPath;
2178 694 : parentPath /= relativeParentPath;
2179 694 : if (relativeParentPath.empty()) {
2180 1 : JAMI_ERROR("[Account {}] [Conversation {}] Invalid parent path (not in members or admins)", accountId_, id_);
2181 1 : return false;
2182 : }
2183 693 : wrongDeviceFile = false;
2184 : try {
2185 693 : auto parentCert = dht::crypto::Certificate(fileutils::loadFile(parentPath));
2186 693 : wrongDeviceFile = !account->isValidAccountDevice(parentCert);
2187 693 : } catch (const std::exception&) {
2188 0 : wrongDeviceFile = true;
2189 0 : }
2190 693 : if (wrongDeviceFile) {
2191 1 : JAMI_WARNING(
2192 : "[Account {}] [Conversation {}] Account certificate is no longer valid. Attempting to update certificate.",
2193 : accountId_,
2194 : id_);
2195 1 : auto cert = account->identity().second;
2196 1 : auto newCert = cert->issuer;
2197 1 : if (newCert && std::filesystem::is_regular_file(parentPath)) {
2198 1 : std::ofstream file(parentPath, std::ios::trunc | std::ios::binary);
2199 1 : if (!file.is_open()) {
2200 0 : JAMI_ERROR("Unable to write data to {}", path);
2201 0 : return false;
2202 : }
2203 1 : file << newCert->toString(true);
2204 1 : file.close();
2205 1 : if (!add(relativeParentPath.string())) {
2206 0 : JAMI_WARNING("Unable to add file {}", path);
2207 0 : return false;
2208 : }
2209 1 : }
2210 1 : }
2211 :
2212 693 : return true;
2213 694 : }
2214 :
2215 : std::string
2216 655 : ConversationRepository::Impl::commit(const std::string& msg, bool verifyDevice)
2217 : {
2218 655 : if (verifyDevice && !validateDevice()) {
2219 1 : JAMI_ERROR("[Account {}] [Conversation {}] commit failed: Invalid device", accountId_, id_);
2220 1 : return {};
2221 : }
2222 654 : GitSignature sig = signature();
2223 654 : if (!sig) {
2224 0 : JAMI_ERROR("[Account {}] [Conversation {}] commit failed: Unable to generate signature", accountId_, id_);
2225 0 : return {};
2226 : }
2227 654 : auto account = account_.lock();
2228 :
2229 : // Retrieve current index
2230 654 : git_index* index_ptr = nullptr;
2231 654 : auto repo = repository();
2232 654 : if (!repo)
2233 0 : return {};
2234 654 : if (git_repository_index(&index_ptr, repo.get()) < 0) {
2235 0 : JAMI_ERROR("[Account {}] [Conversation {}] commit failed: Unable to open repository index", accountId_, id_);
2236 0 : return {};
2237 : }
2238 654 : GitIndex index {index_ptr};
2239 :
2240 : git_oid tree_id;
2241 654 : if (git_index_write_tree(&tree_id, index.get()) < 0) {
2242 0 : JAMI_ERROR("[Account {}] [Conversation {}] commit failed: Unable to write initial tree from index",
2243 : accountId_,
2244 : id_);
2245 0 : return {};
2246 : }
2247 :
2248 654 : git_tree* tree_ptr = nullptr;
2249 654 : if (git_tree_lookup(&tree_ptr, repo.get(), &tree_id) < 0) {
2250 0 : JAMI_ERROR("[Account {}] [Conversation {}] Unable to look up initial tree", accountId_, id_);
2251 0 : return {};
2252 : }
2253 654 : GitTree tree {tree_ptr};
2254 :
2255 : git_oid commit_id;
2256 654 : if (git_reference_name_to_id(&commit_id, repo.get(), "HEAD") < 0) {
2257 0 : JAMI_ERROR("[Account {}] [Conversation {}] Unable to get reference for HEAD", accountId_, id_);
2258 0 : return {};
2259 : }
2260 :
2261 654 : git_commit* head_ptr = nullptr;
2262 654 : if (git_commit_lookup(&head_ptr, repo.get(), &commit_id) < 0) {
2263 0 : JAMI_ERROR("[Account {}] [Conversation {}] Unable to look up HEAD commit", accountId_, id_);
2264 0 : return {};
2265 : }
2266 654 : GitCommit head_commit {head_ptr};
2267 :
2268 654 : git_buf to_sign = {};
2269 : // The last argument of git_commit_create_buffer is of type
2270 : // 'const git_commit **' in all versions of libgit2 except 1.8.0,
2271 : // 1.8.1 and 1.8.3, in which it is of type 'git_commit *const *'.
2272 : #if LIBGIT2_VER_MAJOR == 1 && LIBGIT2_VER_MINOR == 8 \
2273 : && (LIBGIT2_VER_REVISION == 0 || LIBGIT2_VER_REVISION == 1 || LIBGIT2_VER_REVISION == 3)
2274 654 : git_commit* const head_ref[1] = {head_commit.get()};
2275 : #else
2276 : const git_commit* head_ref[1] = {head_commit.get()};
2277 : #endif
2278 1308 : if (git_commit_create_buffer(
2279 1308 : &to_sign, repo.get(), sig.get(), sig.get(), nullptr, msg.c_str(), tree.get(), 1, &head_ref[0])
2280 654 : < 0) {
2281 0 : JAMI_ERROR("[Account {}] [Conversation {}] Unable to create commit buffer", accountId_, id_);
2282 0 : return {};
2283 : }
2284 :
2285 : // git commit -S
2286 654 : auto to_sign_vec = std::vector<uint8_t>(to_sign.ptr, to_sign.ptr + to_sign.size);
2287 654 : auto signed_buf = account->identity().first->sign(to_sign_vec);
2288 654 : std::string signed_str = base64::encode(signed_buf);
2289 654 : if (git_commit_create_with_signature(&commit_id, repo.get(), to_sign.ptr, signed_str.c_str(), "signature") < 0) {
2290 0 : JAMI_ERROR("[Account {}] [Conversation {}] Unable to sign commit", accountId_, id_);
2291 0 : git_buf_dispose(&to_sign);
2292 0 : return {};
2293 : }
2294 654 : git_buf_dispose(&to_sign);
2295 :
2296 : // Move commit to main branch
2297 654 : git_reference* ref_ptr = nullptr;
2298 654 : if (git_reference_create(&ref_ptr, repo.get(), "refs/heads/main", &commit_id, true, nullptr) < 0) {
2299 0 : const git_error* err = giterr_last();
2300 0 : if (err) {
2301 0 : JAMI_ERROR("[Account {}] [Conversation {}] Unable to move commit to main: {}",
2302 : accountId_,
2303 : id_,
2304 : err->message);
2305 0 : emitSignal<libjami::ConversationSignal::OnConversationError>(accountId_, id_, ECOMMIT, err->message);
2306 : }
2307 0 : return {};
2308 : }
2309 654 : git_reference_free(ref_ptr);
2310 :
2311 654 : auto commit_str = git_oid_tostr_s(&commit_id);
2312 654 : if (commit_str) {
2313 654 : JAMI_LOG("[Account {}] [Conversation {}] New message added with id: {}", accountId_, id_, commit_str);
2314 : }
2315 1308 : return commit_str ? commit_str : "";
2316 654 : }
2317 :
2318 : ConversationMode
2319 7577 : ConversationRepository::Impl::mode() const
2320 : {
2321 : // If already retrieved, return it, else get it from first commit
2322 7577 : if (mode_ != std::nullopt)
2323 7034 : return *mode_;
2324 :
2325 542 : auto initialCommit = getCommit(id_);
2326 542 : if (!initialCommit) {
2327 1 : emitSignal<libjami::ConversationSignal::OnConversationError>(accountId_, id_, EINVALIDMODE, "No initial commit");
2328 1 : throw std::logic_error("Unable to retrieve first commit");
2329 : }
2330 :
2331 541 : int mode = initialCommit->commitMsg.mode;
2332 541 : switch (mode) {
2333 148 : case 0:
2334 148 : mode_ = ConversationMode::ONE_TO_ONE;
2335 148 : break;
2336 6 : case 1:
2337 6 : mode_ = ConversationMode::ADMIN_INVITES_ONLY;
2338 6 : break;
2339 332 : case 2:
2340 332 : mode_ = ConversationMode::INVITES_ONLY;
2341 332 : break;
2342 0 : case 3:
2343 0 : mode_ = ConversationMode::PUBLIC;
2344 0 : break;
2345 55 : case 4:
2346 55 : mode_ = ConversationMode::DOCUMENT;
2347 55 : break;
2348 0 : default:
2349 0 : emitSignal<libjami::ConversationSignal::OnConversationError>(accountId_,
2350 0 : id_,
2351 : EINVALIDMODE,
2352 : "Incorrect mode detected");
2353 0 : throw std::logic_error("Incorrect mode detected");
2354 : }
2355 541 : return *mode_;
2356 542 : }
2357 :
2358 : std::string
2359 3127 : ConversationRepository::Impl::diffStats(const std::string& newId, const std::string& oldId) const
2360 : {
2361 3127 : if (auto repo = repository()) {
2362 3127 : if (auto d = diff(repo.get(), newId, oldId))
2363 3127 : return diffStats(d);
2364 3127 : }
2365 0 : return {};
2366 : }
2367 :
2368 : GitDiff
2369 3127 : ConversationRepository::Impl::diff(git_repository* repo, const std::string& idNew, const std::string& idOld) const
2370 : {
2371 3127 : if (!repo) {
2372 0 : JAMI_ERROR("Unable to get reference for HEAD");
2373 0 : return nullptr;
2374 : }
2375 :
2376 : // Retrieve tree for commit new
2377 : git_oid oid;
2378 3127 : git_commit* commitNew = nullptr;
2379 3127 : if (idNew == "HEAD") {
2380 983 : if (git_reference_name_to_id(&oid, repo, "HEAD") < 0) {
2381 0 : JAMI_ERROR("Unable to get reference for HEAD");
2382 0 : return nullptr;
2383 : }
2384 :
2385 983 : if (git_commit_lookup(&commitNew, repo, &oid) < 0) {
2386 0 : JAMI_ERROR("Unable to look up HEAD commit");
2387 0 : return nullptr;
2388 : }
2389 : } else {
2390 2144 : if (git_oid_fromstr(&oid, idNew.c_str()) < 0 || git_commit_lookup(&commitNew, repo, &oid) < 0) {
2391 0 : GitCommit new_commit {commitNew};
2392 0 : JAMI_WARNING("Failed to look up commit {}", idNew);
2393 0 : return nullptr;
2394 0 : }
2395 : }
2396 3126 : GitCommit new_commit {commitNew};
2397 :
2398 3127 : git_tree* tNew = nullptr;
2399 3127 : if (git_commit_tree(&tNew, new_commit.get()) < 0) {
2400 0 : JAMI_ERROR("Unable to look up initial tree");
2401 0 : return nullptr;
2402 : }
2403 3127 : GitTree treeNew {tNew};
2404 :
2405 3126 : git_diff* diff_ptr = nullptr;
2406 3126 : if (idOld.empty()) {
2407 243 : if (git_diff_tree_to_tree(&diff_ptr, repo, nullptr, treeNew.get(), {}) < 0) {
2408 0 : JAMI_ERROR("Unable to get diff to empty repository");
2409 0 : return nullptr;
2410 : }
2411 243 : return GitDiff(diff_ptr);
2412 : }
2413 :
2414 : // Retrieve tree for commit old
2415 2883 : git_commit* commitOld = nullptr;
2416 2883 : if (git_oid_fromstr(&oid, idOld.c_str()) < 0 || git_commit_lookup(&commitOld, repo, &oid) < 0) {
2417 0 : JAMI_WARNING("Failed to look up commit {}", idOld);
2418 0 : return nullptr;
2419 : }
2420 2884 : GitCommit old_commit {commitOld};
2421 :
2422 2884 : git_tree* tOld = nullptr;
2423 2884 : if (git_commit_tree(&tOld, old_commit.get()) < 0) {
2424 0 : JAMI_ERROR("Unable to look up initial tree");
2425 0 : return nullptr;
2426 : }
2427 2884 : GitTree treeOld {tOld};
2428 :
2429 : // Calc diff
2430 2884 : if (git_diff_tree_to_tree(&diff_ptr, repo, treeOld.get(), treeNew.get(), {}) < 0) {
2431 0 : JAMI_ERROR("Unable to get diff between {} and {}", idOld, idNew);
2432 0 : return nullptr;
2433 : }
2434 2884 : return GitDiff(diff_ptr);
2435 3126 : }
2436 :
2437 : std::vector<ConversationCommit>
2438 1760 : ConversationRepository::Impl::behind(const std::string& from) const
2439 : {
2440 : git_oid oid_local, oid_head, oid_remote;
2441 1760 : auto repo = repository();
2442 1761 : if (!repo)
2443 0 : return {};
2444 1761 : if (git_reference_name_to_id(&oid_local, repo.get(), "HEAD") < 0) {
2445 0 : JAMI_ERROR("Unable to get reference for HEAD");
2446 0 : return {};
2447 : }
2448 1761 : oid_head = oid_local;
2449 1761 : std::string head = git_oid_tostr_s(&oid_head);
2450 1761 : if (git_oid_fromstr(&oid_remote, from.c_str()) < 0) {
2451 0 : JAMI_ERROR("Unable to get reference for commit {}", from);
2452 0 : return {};
2453 : }
2454 :
2455 : git_oidarray bases;
2456 1761 : if (git_merge_bases(&bases, repo.get(), &oid_local, &oid_remote) != 0) {
2457 0 : JAMI_ERROR("Unable to get any merge base for commit {} and {}", from, head);
2458 0 : return {};
2459 : }
2460 3434 : for (std::size_t i = 0; i < bases.count; ++i) {
2461 1761 : std::string oid = git_oid_tostr_s(&bases.ids[i]);
2462 1761 : if (oid != head) {
2463 87 : oid_local = bases.ids[i];
2464 87 : break;
2465 : }
2466 1760 : }
2467 1760 : git_oidarray_free(&bases);
2468 1761 : std::string to = git_oid_tostr_s(&oid_local);
2469 1761 : if (to == from)
2470 757 : return {};
2471 1004 : return log(LogOptions {from, to});
2472 1761 : }
2473 :
2474 : void
2475 3881 : ConversationRepository::Impl::forEachCommit(PreConditionCb&& preCondition,
2476 : std::function<void(ConversationCommit&&)>&& emplaceCb,
2477 : PostConditionCb&& postCondition,
2478 : const std::string& from,
2479 : bool logIfNotFound) const
2480 : {
2481 : git_oid oid, oidFrom, oidMerge;
2482 :
2483 : // NOTE! Start from head to get all merge possibilities and correct linearized parent.
2484 3881 : auto repo = repository();
2485 3880 : if (!repo or git_reference_name_to_id(&oid, repo.get(), "HEAD") < 0) {
2486 0 : JAMI_ERROR("[Account {}] [Conversation {}] Unable to get reference for HEAD", accountId_, id_);
2487 0 : return;
2488 : }
2489 :
2490 3881 : if (from != "" && git_oid_fromstr(&oidFrom, from.c_str()) == 0) {
2491 2794 : auto isMergeBase = git_merge_base(&oidMerge, repo.get(), &oid, &oidFrom) == 0
2492 2794 : && git_oid_equal(&oidMerge, &oidFrom);
2493 2794 : if (!isMergeBase) {
2494 : // We're logging a non merged branch, so, take this one instead of HEAD
2495 1019 : oid = oidFrom;
2496 : }
2497 : }
2498 :
2499 3881 : git_revwalk* walker_ptr = nullptr;
2500 3881 : if (git_revwalk_new(&walker_ptr, repo.get()) < 0 || git_revwalk_push(walker_ptr, &oid) < 0) {
2501 15 : GitRevWalker walker {walker_ptr};
2502 : // This fail can be permitted in the case we check if a commit exists before pulling (so can fail
2503 : // there). Only log if the fail is unwanted.
2504 15 : if (logIfNotFound)
2505 0 : JAMI_DEBUG("[Account {}] [Conversation {}] Unable to init revwalker from {}", accountId_, id_, from);
2506 15 : return;
2507 15 : }
2508 :
2509 3866 : GitRevWalker walker {walker_ptr};
2510 3866 : git_revwalk_sorting(walker.get(), GIT_SORT_TOPOLOGICAL | GIT_SORT_TIME);
2511 :
2512 23700 : while (!git_revwalk_next(&oid, walker.get())) {
2513 22468 : git_commit* commit_ptr = nullptr;
2514 22468 : std::string id = git_oid_tostr_s(&oid);
2515 22448 : if (git_commit_lookup(&commit_ptr, repo.get(), &oid) < 0) {
2516 0 : JAMI_WARNING("[Account {}] [Conversation {}] Failed to look up commit {}", accountId_, id_, id);
2517 0 : break;
2518 : }
2519 22470 : GitCommit commit {commit_ptr};
2520 :
2521 22468 : ConversationCommit cc = parseCommit(repo.get(), commit.get());
2522 :
2523 22450 : auto result = preCondition(id, cc.author, commit);
2524 22450 : if (result == CallbackResult::Skip)
2525 38 : continue;
2526 22412 : else if (result == CallbackResult::Break)
2527 2039 : break;
2528 :
2529 20373 : auto post = postCondition(id, cc.author, cc);
2530 20365 : emplaceCb(std::move(cc));
2531 :
2532 20387 : if (post)
2533 578 : break;
2534 27773 : }
2535 3881 : }
2536 :
2537 : std::vector<ConversationCommit>
2538 1419 : ConversationRepository::Impl::log(const LogOptions& options) const
2539 : {
2540 1419 : std::vector<ConversationCommit> commits {};
2541 1419 : auto startLogging = options.from == "";
2542 1418 : auto breakLogging = false;
2543 1419 : forEachCommit(
2544 2838 : [&](const auto& id, const auto& author, const auto& commit) {
2545 3791 : if (!commits.empty()) {
2546 : // Set linearized parent
2547 2365 : commits.rbegin()->linearized_parent = id;
2548 : }
2549 3791 : if (options.skipMerge && git_commit_parentcount(commit.get()) > 1) {
2550 0 : return CallbackResult::Skip;
2551 : }
2552 3791 : if ((options.nbOfCommits != 0 && commits.size() == options.nbOfCommits))
2553 1 : return CallbackResult::Break; // Stop logging
2554 3790 : if (breakLogging)
2555 0 : return CallbackResult::Break; // Stop logging
2556 3790 : if (id == options.to) {
2557 1004 : if (options.includeTo)
2558 0 : breakLogging = true; // For the next commit
2559 : else
2560 1004 : return CallbackResult::Break; // Stop logging
2561 : }
2562 :
2563 2786 : if (!startLogging && options.from != "" && options.from == id)
2564 1006 : startLogging = true;
2565 2785 : if (!startLogging)
2566 7 : return CallbackResult::Skip; // Start logging after this one
2567 :
2568 2778 : if (options.fastLog) {
2569 0 : if (options.authorUri != "") {
2570 0 : if (options.authorUri == uriFromDevice(author.email)) {
2571 0 : return CallbackResult::Break; // Found author, stop
2572 : }
2573 : }
2574 : // Used to only count commit
2575 0 : commits.emplace(commits.end(), ConversationCommit {});
2576 0 : return CallbackResult::Skip;
2577 : }
2578 :
2579 2778 : return CallbackResult::Ok; // Continue
2580 0 : },
2581 5616 : [&](auto&& cc) { commits.emplace(commits.end(), std::forward<decltype(cc)>(cc)); },
2582 2779 : [](auto, auto, auto) { return false; },
2583 1418 : options.from,
2584 1418 : options.logIfNotFound);
2585 2838 : return commits;
2586 0 : }
2587 :
2588 : GitObject
2589 14212 : ConversationRepository::Impl::fileAtTree(const std::string& path, const GitTree& tree) const
2590 : {
2591 14212 : git_object* blob_ptr = nullptr;
2592 14212 : if (git_object_lookup_bypath(&blob_ptr, reinterpret_cast<git_object*>(tree.get()), path.c_str(), GIT_OBJECT_BLOB)
2593 14211 : != 0) {
2594 4017 : return GitObject(nullptr);
2595 : }
2596 10194 : return GitObject(blob_ptr);
2597 : }
2598 :
2599 : GitObject
2600 2439 : ConversationRepository::Impl::memberCertificate(std::string_view memberUri, const GitTree& tree) const
2601 : {
2602 4878 : auto blob = fileAtTree(fmt::format("members/{}.crt", memberUri), tree);
2603 2437 : if (not blob)
2604 3010 : blob = fileAtTree(fmt::format("admins/{}.crt", memberUri), tree);
2605 2438 : return blob;
2606 0 : }
2607 :
2608 : GitTree
2609 5913 : ConversationRepository::Impl::treeAtCommit(git_repository* repo, const std::string& commitId) const
2610 : {
2611 : git_oid oid;
2612 5913 : git_commit* commit = nullptr;
2613 5913 : if (git_oid_fromstr(&oid, commitId.c_str()) < 0 || git_commit_lookup(&commit, repo, &oid) < 0) {
2614 0 : JAMI_WARNING("[Account {}] [Conversation {}] Failed to look up commit {}", accountId_, id_, commitId);
2615 0 : return GitTree(nullptr);
2616 : }
2617 5913 : GitCommit gc {commit};
2618 5910 : git_tree* tree = nullptr;
2619 5910 : if (git_commit_tree(&tree, gc.get()) < 0) {
2620 0 : JAMI_ERROR("[Account {}] [Conversation {}] Unable to look up initial tree", accountId_, id_);
2621 0 : return GitTree(nullptr);
2622 : }
2623 5913 : return GitTree {tree};
2624 5912 : }
2625 :
2626 : std::vector<std::string>
2627 222 : ConversationRepository::Impl::getInitialMembers() const
2628 : {
2629 222 : auto acc = account_.lock();
2630 222 : if (!acc)
2631 0 : return {};
2632 222 : auto firstCommitOpt = getCommit(id_);
2633 222 : if (firstCommitOpt == std::nullopt) {
2634 0 : return {};
2635 : }
2636 222 : auto& commit = *firstCommitOpt;
2637 :
2638 222 : auto authorDevice = commit.author.email;
2639 222 : auto authorId = uriFromDevice(authorDevice, id_);
2640 222 : if (authorId.empty())
2641 0 : return {};
2642 222 : if (mode() == ConversationMode::ONE_TO_ONE) {
2643 222 : auto invitedId = commit.commitMsg.invited;
2644 222 : if (!invitedId.empty() && invitedId != authorId)
2645 884 : return {authorId, invitedId};
2646 222 : }
2647 3 : return {authorId};
2648 444 : }
2649 :
2650 : bool
2651 1 : ConversationRepository::Impl::resolveConflicts(git_index* index, const std::string& other_id)
2652 : {
2653 1 : git_index_conflict_iterator* conflict_iterator = nullptr;
2654 1 : const git_index_entry* ancestor_out = nullptr;
2655 1 : const git_index_entry* our_out = nullptr;
2656 1 : const git_index_entry* their_out = nullptr;
2657 :
2658 1 : git_index_conflict_iterator_new(&conflict_iterator, index);
2659 1 : GitIndexConflictIterator ci {conflict_iterator};
2660 :
2661 : git_oid head_commit_id;
2662 1 : auto repo = repository();
2663 1 : if (!repo || git_reference_name_to_id(&head_commit_id, repo.get(), "HEAD") < 0) {
2664 0 : JAMI_ERROR("[Account {}] [Conversation {}] Unable to get reference for HEAD", accountId_, id_);
2665 0 : return false;
2666 : }
2667 1 : auto commit_str = git_oid_tostr_s(&head_commit_id);
2668 1 : if (!commit_str)
2669 0 : return false;
2670 1 : auto useRemote = (other_id > commit_str); // Choose by commit version
2671 :
2672 : // NOTE: for now, only authorize conflicts on "profile.vcf"
2673 1 : std::vector<git_index_entry> new_entries;
2674 2 : while (git_index_conflict_next(&ancestor_out, &our_out, &their_out, ci.get()) != GIT_ITEROVER) {
2675 1 : if (ancestor_out && ancestor_out->path && our_out && our_out->path && their_out && their_out->path) {
2676 1 : if (std::string_view(ancestor_out->path) == "profile.vcf"sv) {
2677 : // Checkout the wanted version. Copy the index_entry.
2678 1 : git_index_entry resolution = useRemote ? *their_out : *our_out;
2679 1 : resolution.flags &= GIT_INDEX_STAGE_NORMAL;
2680 1 : if (!(resolution.flags & GIT_IDXENTRY_VALID))
2681 1 : resolution.flags |= GIT_IDXENTRY_VALID;
2682 : // NOTE: do no git_index_add yet, wait for after full conflict checks
2683 1 : new_entries.push_back(resolution);
2684 1 : continue;
2685 1 : }
2686 0 : JAMI_ERROR("Conflict detected on a file that is not authorized: {}", ancestor_out->path);
2687 0 : return false;
2688 : }
2689 0 : return false;
2690 : }
2691 :
2692 2 : for (auto& entry : new_entries)
2693 1 : git_index_add(index, &entry);
2694 1 : git_index_conflict_cleanup(index);
2695 :
2696 : // Checkout and clean up
2697 : git_checkout_options opt;
2698 1 : git_checkout_options_init(&opt, GIT_CHECKOUT_OPTIONS_VERSION);
2699 1 : opt.checkout_strategy |= GIT_CHECKOUT_FORCE;
2700 1 : opt.checkout_strategy |= GIT_CHECKOUT_ALLOW_CONFLICTS;
2701 1 : if (other_id > commit_str)
2702 1 : opt.checkout_strategy |= GIT_CHECKOUT_USE_THEIRS;
2703 : else
2704 0 : opt.checkout_strategy |= GIT_CHECKOUT_USE_OURS;
2705 :
2706 1 : if (git_checkout_index(repo.get(), index, &opt) < 0) {
2707 0 : const git_error* err = giterr_last();
2708 0 : if (err)
2709 0 : JAMI_ERROR("Unable to checkout index: {}", err->message);
2710 0 : return false;
2711 : }
2712 :
2713 1 : return true;
2714 1 : }
2715 :
2716 : void
2717 1365 : ConversationRepository::Impl::initMembers()
2718 : {
2719 : using std::filesystem::path;
2720 1365 : auto repo = repository();
2721 1365 : if (!repo)
2722 0 : throw std::logic_error("Invalid Git repository");
2723 :
2724 1365 : std::vector<std::string> uris;
2725 1365 : std::lock_guard lk(membersMtx_);
2726 1365 : members_.clear();
2727 1365 : path repoPath = git_repository_workdir(repo.get());
2728 :
2729 0 : static const std::vector<std::pair<MemberRole, path>> paths = {{MemberRole::ADMIN, MemberPath::ADMINS},
2730 0 : {MemberRole::MEMBER, MemberPath::MEMBERS},
2731 0 : {MemberRole::INVITED, MemberPath::INVITED},
2732 0 : {MemberRole::BANNED,
2733 0 : MemberPath::BANNED / MemberPath::MEMBERS},
2734 0 : {MemberRole::BANNED,
2735 1498 : MemberPath::BANNED / MemberPath::INVITED}};
2736 :
2737 1365 : std::error_code ec;
2738 8190 : for (const auto& [role, p] : paths) {
2739 19754 : for (const auto& f : std::filesystem::directory_iterator(repoPath / p, ec)) {
2740 12952 : auto uri = f.path().stem().string();
2741 12919 : if (std::find(uris.begin(), uris.end(), uri) == uris.end()) {
2742 12947 : members_.emplace_back(ConversationMember {uri, role});
2743 12903 : uris.emplace_back(uri);
2744 : }
2745 19780 : }
2746 : }
2747 :
2748 1365 : if (mode() == ConversationMode::ONE_TO_ONE) {
2749 566 : for (const auto& member : getInitialMembers()) {
2750 377 : if (std::find(uris.begin(), uris.end(), member) == uris.end()) {
2751 : // If member is in the initial commit, but not in invited, this means that user left.
2752 0 : members_.emplace_back(ConversationMember {member, MemberRole::LEFT});
2753 : }
2754 189 : }
2755 : }
2756 1363 : saveMembers();
2757 1387 : }
2758 :
2759 : std::optional<std::map<std::string, std::string>>
2760 19802 : ConversationRepository::Impl::convCommitToMap(const ConversationCommit& commit) const
2761 : {
2762 19802 : if (commit.authorId.empty()) {
2763 3 : JAMI_ERROR("[Account {}] [Conversation {}] Invalid author ID for commit {}", accountId_, id_, commit.id);
2764 3 : return std::nullopt;
2765 : }
2766 19791 : std::string parents;
2767 19767 : auto parentsSize = commit.parents.size();
2768 38465 : for (std::size_t i = 0; i < parentsSize; ++i) {
2769 18685 : parents += commit.parents[i];
2770 18695 : if (i != parentsSize - 1)
2771 44 : parents += ",";
2772 : }
2773 19780 : std::string type {};
2774 19780 : if (parentsSize > 1)
2775 44 : type = CommitType::MERGE;
2776 19780 : std::string body {};
2777 19779 : std::map<std::string, std::string> message;
2778 19800 : if (type.empty()) {
2779 19755 : Json::Value cm = commit.commitMsg.toJson();
2780 77874 : for (auto const& id : cm.getMemberNames()) {
2781 58152 : if (id == CommitKey::TYPE) {
2782 19747 : type = cm[id].asString();
2783 19735 : continue;
2784 : }
2785 38388 : message.insert({id, cm[id].asString()});
2786 19691 : }
2787 19754 : }
2788 19809 : if (type.empty()) {
2789 0 : return std::nullopt;
2790 19804 : } else if (type == CommitType::DATA_TRANSFER) {
2791 : // Avoid the client to do the concatenation
2792 47 : auto tid = message[CommitKey::TID];
2793 47 : if (not tid.empty()) {
2794 180 : message["fileId"] = getFileId(commit.id, tid, message[CommitKey::DISPLAY_NAME]);
2795 : } else {
2796 4 : message["fileId"] = "";
2797 : }
2798 47 : }
2799 59380 : message["id"] = commit.id;
2800 19784 : message["parents"] = parents;
2801 39566 : message["linearizedParent"] = commit.linearized_parent;
2802 59373 : message["author"] = commit.authorId;
2803 19793 : message["type"] = type;
2804 59377 : message["timestamp"] = std::to_string(commit.timestamp);
2805 :
2806 19781 : return message;
2807 19794 : }
2808 :
2809 : std::string
2810 3127 : ConversationRepository::Impl::diffStats(const GitDiff& diff) const
2811 : {
2812 3127 : git_diff_stats* stats_ptr = nullptr;
2813 3127 : if (git_diff_get_stats(&stats_ptr, diff.get()) < 0) {
2814 0 : JAMI_ERROR("[Account {}] [Conversation {}] Unable to get diff stats", accountId_, id_);
2815 0 : return {};
2816 : }
2817 3127 : GitDiffStats stats {stats_ptr};
2818 :
2819 3127 : git_diff_stats_format_t format = GIT_DIFF_STATS_FULL;
2820 3127 : git_buf statsBuf = {};
2821 3127 : if (git_diff_stats_to_buf(&statsBuf, stats.get(), format, 80) < 0) {
2822 0 : JAMI_ERROR("[Account {}] [Conversation {}] Unable to format diff stats", accountId_, id_);
2823 0 : return {};
2824 : }
2825 :
2826 3127 : auto res = std::string(statsBuf.ptr, statsBuf.ptr + statsBuf.size);
2827 3127 : git_buf_dispose(&statsBuf);
2828 3127 : return res;
2829 3126 : }
2830 :
2831 : ConversationCommit
2832 23797 : ConversationRepository::Impl::parseCommit(git_repository* repo, const git_commit* commit) const
2833 : {
2834 : git_oid oid;
2835 23797 : git_oid_cpy(&oid, git_commit_id(commit));
2836 :
2837 23797 : ConversationCommit convCommit;
2838 23788 : convCommit.id = git_oid_tostr_s(&oid);
2839 23787 : const char* commitMsgStr = git_commit_message(commit);
2840 23786 : auto commitMsg = CommitMessage::fromString(commitMsgStr);
2841 23786 : if (commitMsg) {
2842 23785 : convCommit.commitMsg = *commitMsg;
2843 : } else {
2844 0 : JAMI_WARNING("[Account {}] [Conversation {}] Unable to parse commit message for commit {}: '{}'",
2845 : accountId_,
2846 : id_,
2847 : convCommit.id,
2848 : commitMsgStr);
2849 : }
2850 23783 : convCommit.timestamp = git_commit_time(commit);
2851 :
2852 23783 : const git_signature* sig = git_commit_author(commit);
2853 23783 : GitAuthor author;
2854 23787 : author.name = sig->name;
2855 23783 : author.email = sig->email;
2856 23778 : convCommit.author = std::move(author);
2857 23774 : convCommit.authorId = uriFromDevice(convCommit.author.email, convCommit.id);
2858 :
2859 23792 : std::vector<std::string> parents;
2860 23792 : auto parentsCount = git_commit_parentcount(commit);
2861 45449 : for (unsigned int p = 0; p < parentsCount; ++p) {
2862 21651 : if (const git_oid* pid = git_commit_parent_id(commit, p)) {
2863 21651 : parents.emplace_back(git_oid_tostr_s(pid));
2864 : }
2865 : }
2866 23798 : convCommit.parents = std::move(parents);
2867 :
2868 23787 : git_buf signature = {}, signed_data = {};
2869 23787 : if (git_commit_extract_signature(&signature, &signed_data, repo, &oid, "signature") < 0) {
2870 1 : JAMI_WARNING("[Account {}] [Conversation {}] Unable to extract signature for commit {}",
2871 : accountId_,
2872 : id_,
2873 : convCommit.id);
2874 : } else {
2875 23796 : convCommit.signature = base64::decode(std::string_view(signature.ptr, signature.size));
2876 47574 : convCommit.signed_content = std::vector<uint8_t>(signed_data.ptr, signed_data.ptr + signed_data.size);
2877 : }
2878 23788 : git_buf_dispose(&signature);
2879 23795 : git_buf_dispose(&signed_data);
2880 :
2881 47571 : return convCommit;
2882 23799 : }
2883 :
2884 : //////////////////////////////////
2885 :
2886 : // Transient conversation artifacts (clone staging areas and the backups taken
2887 : // before a clone is swapped into place) live here rather than in `conversations`,
2888 : // so that the conversation loader never sees a directory whose name is not a
2889 : // conversation id. It is a sibling of `conversations` so moves between the two
2890 : // stay on one filesystem, and therefore atomic.
2891 : static std::filesystem::path
2892 514 : conversationsStagingPath(const std::string& accountId)
2893 : {
2894 1028 : return fileutils::get_data_dir() / accountId / "conversations.staging";
2895 : }
2896 :
2897 : bool
2898 13666 : ConversationRepository::isValidConversationId(std::string_view id) noexcept
2899 : {
2900 : // git SHA-1 object id, as printed by git_oid_tostr_s()
2901 13666 : constexpr size_t SHA1_HEX_SIZE = 40;
2902 13666 : if (id.size() != SHA1_HEX_SIZE)
2903 21 : return false;
2904 13645 : return std::all_of(id.begin(), id.end(), [](unsigned char c) {
2905 545602 : return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f');
2906 13648 : });
2907 : }
2908 :
2909 : std::unique_ptr<ConversationRepository>
2910 237 : ConversationRepository::createConversation(const std::shared_ptr<JamiAccount>& account,
2911 : ConversationMode mode,
2912 : const std::string& otherMember)
2913 : {
2914 237 : return createRepository(account, mode, otherMember, CommitMessage::initial(mode, otherMember));
2915 : }
2916 :
2917 : std::unique_ptr<ConversationRepository>
2918 22 : ConversationRepository::createDocument(const std::shared_ptr<JamiAccount>& account,
2919 : const std::string& parentConversationId,
2920 : const std::string& mimeType)
2921 : {
2922 22 : return createRepository(account,
2923 : ConversationMode::DOCUMENT,
2924 : "",
2925 66 : CommitMessage::initialDocument(parentConversationId, mimeType));
2926 : }
2927 :
2928 : std::unique_ptr<ConversationRepository>
2929 259 : ConversationRepository::createRepository(const std::shared_ptr<JamiAccount>& account,
2930 : ConversationMode mode,
2931 : const std::string& otherMember,
2932 : const CommitMessage& initialMessage)
2933 : {
2934 : // Create temporary directory because we are unable to know the first hash for now.
2935 : // It is staged outside of `conversations` so that the conversation loader never
2936 : // sees a directory whose name is not a conversation id.
2937 259 : std::uniform_int_distribution<uint64_t> dist;
2938 259 : auto conversationsPath = fileutils::get_data_dir() / account->getAccountID() / "conversations";
2939 259 : dhtnet::fileutils::check_dir(conversationsPath);
2940 259 : auto stagingPath = conversationsStagingPath(account->getAccountID());
2941 259 : dhtnet::fileutils::check_dir(stagingPath);
2942 259 : auto tmpPath = stagingPath / std::to_string(dist(account->rand));
2943 259 : if (std::filesystem::is_directory(tmpPath)) {
2944 0 : JAMI_ERROR("{} already exists. Abort create conversations", tmpPath);
2945 0 : return {};
2946 : }
2947 259 : if (!dhtnet::fileutils::recursive_mkdir(tmpPath, 0700)) {
2948 0 : JAMI_ERROR("An error occurred when creating {}. Abort create conversations.", tmpPath);
2949 0 : return {};
2950 : }
2951 259 : auto repo = create_empty_repository(tmpPath.string());
2952 259 : if (!repo) {
2953 0 : return {};
2954 : }
2955 :
2956 : // Add initial files
2957 259 : if (!add_initial_files(repo, account, mode, otherMember)) {
2958 0 : JAMI_ERROR("An error occurred while adding the initial files.");
2959 0 : dhtnet::fileutils::removeAll(tmpPath, true);
2960 0 : return {};
2961 : }
2962 :
2963 : // Commit changes
2964 259 : auto id = initial_commit(repo, account, initialMessage);
2965 259 : if (id.empty()) {
2966 0 : JAMI_ERROR("Unable to create initial commit in {}", tmpPath);
2967 0 : dhtnet::fileutils::removeAll(tmpPath, true);
2968 0 : return {};
2969 : }
2970 :
2971 : // Move to wanted directory
2972 259 : auto newPath = conversationsPath / id;
2973 259 : std::error_code ec;
2974 259 : std::filesystem::rename(tmpPath, newPath, ec);
2975 259 : if (ec) {
2976 0 : JAMI_ERROR("Unable to move {} in {}: {}", tmpPath, newPath, ec.message());
2977 0 : dhtnet::fileutils::removeAll(tmpPath, true);
2978 0 : return {};
2979 : }
2980 :
2981 259 : JAMI_LOG("New conversation initialized in {}", newPath);
2982 :
2983 259 : return std::make_unique<ConversationRepository>(account, id);
2984 259 : }
2985 :
2986 : std::pair<std::unique_ptr<ConversationRepository>, std::vector<ConversationCommit>>
2987 255 : ConversationRepository::cloneConversation(const std::shared_ptr<JamiAccount>& account,
2988 : const std::string& deviceId,
2989 : const std::string& conversationId)
2990 : {
2991 : // The id becomes a directory name that is swapped, backed up and erased below:
2992 : // never let anything but a commit hash reach the filesystem.
2993 255 : if (!isValidConversationId(conversationId)) {
2994 0 : JAMI_ERROR("[Account {}] Clone conversation with invalid conversationId '{}'",
2995 : account->getAccountID(),
2996 : conversationId);
2997 0 : return {};
2998 : }
2999 :
3000 255 : auto conversationsPath = fileutils::get_data_dir() / account->getAccountID() / "conversations";
3001 255 : dhtnet::fileutils::check_dir(conversationsPath);
3002 255 : auto path = conversationsPath / conversationId;
3003 : // Clone into the staging directory and only atomically swap it into place
3004 : // once the clone has succeeded and been validated. This guarantees that a
3005 : // failing clone (network error, oversized pack, bad remote, failed commit
3006 : // validation, ...) cannot destroy a pre-existing local conversation at
3007 : // `path`. Staging is a sibling of `conversations` so the swap stays on one
3008 : // filesystem while these transient directories remain invisible to the
3009 : // conversation loader.
3010 255 : auto stagingPath = conversationsStagingPath(account->getAccountID());
3011 255 : dhtnet::fileutils::check_dir(stagingPath);
3012 255 : const auto tmpClonePath = stagingPath / (conversationId + ".clone");
3013 255 : const auto backupPath = stagingPath / (conversationId + ".bak");
3014 255 : auto url = fmt::format("git://{}/{}", deviceId, conversationId);
3015 : #ifdef LIBJAMI_TEST
3016 255 : if (FETCH_FROM_LOCAL_REPOS) {
3017 2 : url = fmt::format("file://{}",
3018 3 : (fileutils::get_data_dir() / deviceId / "conversations" / conversationId).string());
3019 : }
3020 : #endif
3021 :
3022 : // Scrub any leftover temp artifacts from a previous crashed attempt.
3023 : // These paths are distinct from the real `path`, so this cannot touch
3024 : // an in-use conversation.
3025 255 : std::error_code ec;
3026 255 : if (std::filesystem::exists(tmpClonePath, ec))
3027 0 : dhtnet::fileutils::removeAll(tmpClonePath, true);
3028 255 : if (std::filesystem::exists(backupPath, ec))
3029 0 : dhtnet::fileutils::removeAll(backupPath, true);
3030 :
3031 255 : git_clone_options opts = GIT_CLONE_OPTIONS_INIT;
3032 255 : opts.fetch_opts.follow_redirects = GIT_REMOTE_REDIRECT_NONE;
3033 255 : opts.fetch_opts.callbacks.transfer_progress = [](const git_indexer_progress* stats, void*) {
3034 : // If a pack is more than MAX_FETCH_SIZE, it's abnormal.
3035 6698 : if (stats->received_bytes > MAX_FETCH_SIZE) {
3036 0 : JAMI_ERROR("Abort fetching repository, the fetch is too big: {} bytes ({}/{})",
3037 : stats->received_bytes,
3038 : stats->received_objects,
3039 : stats->total_objects);
3040 0 : return -1;
3041 : }
3042 6698 : return 0;
3043 : };
3044 :
3045 255 : JAMI_DEBUG("[Account {}] [Conversation {}] Start clone of {:s} to {} (staging {})",
3046 : account->getAccountID(),
3047 : conversationId,
3048 : url,
3049 : path,
3050 : tmpClonePath);
3051 255 : git_repository* rep = nullptr;
3052 255 : if (auto err = git_clone(&rep, url.c_str(), tmpClonePath.string().c_str(), &opts)) {
3053 14 : if (const git_error* gerr = giterr_last())
3054 14 : JAMI_ERROR("[Account {}] [Conversation {}] Error when retrieving remote conversation: {:s} {}",
3055 : account->getAccountID(),
3056 : conversationId,
3057 : gerr->message,
3058 : path);
3059 : else
3060 0 : JAMI_ERROR("[Account {}] [Conversation {}] Unknown error {:d} when retrieving remote conversation",
3061 : account->getAccountID(),
3062 : conversationId,
3063 : err);
3064 : // Failed clone: scrub any partial staging dir and leave the
3065 : // pre-existing conversation at `path` (if any) untouched.
3066 14 : if (std::filesystem::exists(tmpClonePath, ec))
3067 0 : dhtnet::fileutils::removeAll(tmpClonePath, true);
3068 14 : return {};
3069 : }
3070 241 : git_repository_free(rep);
3071 :
3072 : // Clone succeeded in the staging location. Move any pre-existing
3073 : // directory aside (as a backup we can roll back to) before swapping
3074 : // the new contents into place.
3075 241 : bool hadBackup = false;
3076 241 : if (std::filesystem::exists(path, ec)) {
3077 0 : JAMI_WARNING("[Account {}] [Conversation {}] Replacing pre-existing directory {}",
3078 : account->getAccountID(),
3079 : conversationId,
3080 : path);
3081 0 : std::filesystem::rename(path, backupPath, ec);
3082 0 : if (ec) {
3083 0 : JAMI_ERROR("[Account {}] [Conversation {}] Unable to move existing directory aside: {}. "
3084 : "Aborting clone to preserve existing data.",
3085 : account->getAccountID(),
3086 : conversationId,
3087 : ec.message());
3088 0 : dhtnet::fileutils::removeAll(tmpClonePath, true);
3089 0 : return {};
3090 : }
3091 0 : hadBackup = true;
3092 : }
3093 241 : std::filesystem::rename(tmpClonePath, path, ec);
3094 241 : if (ec) {
3095 0 : JAMI_ERROR("[Account {}] [Conversation {}] Unable to move cloned directory into place: {}",
3096 : account->getAccountID(),
3097 : conversationId,
3098 : ec.message());
3099 0 : dhtnet::fileutils::removeAll(tmpClonePath, true);
3100 0 : if (hadBackup) {
3101 0 : std::error_code restoreEc;
3102 0 : std::filesystem::rename(backupPath, path, restoreEc);
3103 0 : if (restoreEc)
3104 0 : JAMI_ERROR("[Account {}] [Conversation {}] Unable to restore backup: {}",
3105 : account->getAccountID(),
3106 : conversationId,
3107 : restoreEc.message());
3108 : }
3109 0 : return {};
3110 : }
3111 :
3112 241 : auto repo = std::make_unique<ConversationRepository>(account, conversationId);
3113 240 : repo->pinCertificates(true); // need to load certificates to validate unknown members
3114 240 : auto [commitsToValidate, valid] = repo->validClone();
3115 240 : if (!valid) {
3116 : // Invalid clone: erase it and, if we had a previous valid
3117 : // conversation, restore it so the caller sees a rollback rather
3118 : // than data loss.
3119 3 : repo->erase();
3120 3 : repo.reset();
3121 3 : if (hadBackup) {
3122 0 : std::error_code restoreEc;
3123 0 : std::filesystem::rename(backupPath, path, restoreEc);
3124 0 : if (restoreEc)
3125 0 : JAMI_ERROR("[Account {}] [Conversation {}] Unable to restore previous data: {}",
3126 : account->getAccountID(),
3127 : conversationId,
3128 : restoreEc.message());
3129 : }
3130 3 : JAMI_ERROR("[Account {}] [Conversation {}] An error occurred while validating remote conversation.",
3131 : account->getAccountID(),
3132 : conversationId);
3133 : // Distinguish this permanent failure from transient (network) errors,
3134 : // which return an empty result: the remote history is immutable, so
3135 : // retrying the clone would re-download the same malformed repository.
3136 9 : throw InvalidRepositoryError("Remote conversation failed validation");
3137 : }
3138 :
3139 : // Success: discard the backup.
3140 237 : if (hadBackup && std::filesystem::exists(backupPath, ec))
3141 0 : dhtnet::fileutils::removeAll(backupPath, true);
3142 :
3143 237 : JAMI_LOG("[Account {}] [Conversation {}] New conversation cloned in {}",
3144 : account->getAccountID(),
3145 : conversationId,
3146 : path);
3147 237 : return {std::move(repo), std::move(commitsToValidate)};
3148 281 : }
3149 :
3150 : bool
3151 2014 : ConversationRepository::Impl::validCommits(const std::vector<ConversationCommit>& commitsToValidate) const
3152 : {
3153 2014 : auto repo = repository();
3154 :
3155 4141 : for (const auto& commit : commitsToValidate) {
3156 2161 : auto userDevice = commit.author.email;
3157 2161 : auto validUserAtCommit = commit.id;
3158 :
3159 : git_oid oid;
3160 2161 : git_commit* commit_ptr = nullptr;
3161 2161 : if (git_oid_fromstr(&oid, validUserAtCommit.c_str()) < 0
3162 2161 : || git_commit_lookup(&commit_ptr, repo.get(), &oid) < 0) {
3163 0 : JAMI_WARNING("Failed to look up commit {}", validUserAtCommit.c_str());
3164 : }
3165 2161 : GitBuf sig(new git_buf {});
3166 2160 : GitBuf sig_data(new git_buf {});
3167 :
3168 : // Extract the signature block and signature content from the commit
3169 2160 : int sig_extract_res = git_commit_extract_signature(sig.get(), sig_data.get(), repo.get(), &oid, "signature");
3170 2161 : if (sig_extract_res != 0) {
3171 0 : switch (sig_extract_res) {
3172 0 : case GIT_ERROR_INVALID:
3173 0 : JAMI_ERROR("Error, the commit ID ({}) does not correspond to a commit.", validUserAtCommit);
3174 0 : break;
3175 0 : case GIT_ERROR_OBJECT:
3176 0 : JAMI_ERROR("Error, the commit ID ({}) does not have a signature.", validUserAtCommit);
3177 0 : break;
3178 0 : default:
3179 0 : JAMI_ERROR("An unknown error occurred while extracting signature for commit ID {}.", validUserAtCommit);
3180 0 : break;
3181 : }
3182 0 : emitSignal<libjami::ConversationSignal::OnConversationError>(accountId_,
3183 0 : id_,
3184 : EVALIDFETCH,
3185 : "Malformed commit");
3186 0 : return false;
3187 : }
3188 :
3189 2161 : if (commit.parents.size() == 0) {
3190 242 : if (!checkInitialCommit(userDevice, commit.id, commit.commitMsg)) {
3191 2 : JAMI_WARNING("[Account {}] [Conversation {}] Malformed initial commit {}. Please "
3192 : "ensure that you are using the latest "
3193 : "version of Jami, or that one of your contacts is not performing any "
3194 : "unwanted actions.",
3195 : accountId_,
3196 : id_,
3197 : commit.id);
3198 2 : emitSignal<libjami::ConversationSignal::OnConversationError>(accountId_,
3199 2 : id_,
3200 : EVALIDFETCH,
3201 : "Malformed initial commit");
3202 2 : return false;
3203 : }
3204 : // The initial commit MUST be signed by the device that claims to create the conversation
3205 240 : if (!isValidUserAtCommit(userDevice, validUserAtCommit, *sig, *sig_data)) {
3206 0 : JAMI_WARNING("[Account {}] [Conversation {}] Initial commit {} not signed by its author. Please "
3207 : "ensure that you are using the latest version of Jami, or that one of your "
3208 : "contacts is not performing any unwanted actions.",
3209 : accountId_,
3210 : id_,
3211 : commit.id);
3212 0 : emitSignal<libjami::ConversationSignal::OnConversationError>(accountId_,
3213 0 : id_,
3214 : EVALIDFETCH,
3215 : "Invalid user");
3216 0 : return false;
3217 : }
3218 1919 : } else if (commit.parents.size() == 1) {
3219 1909 : std::string type = commit.commitMsg.type;
3220 1909 : std::string editedId = commit.commitMsg.editedId;
3221 1909 : if (type.empty()) {
3222 0 : emitSignal<libjami::ConversationSignal::OnConversationError>(accountId_,
3223 0 : id_,
3224 : EVALIDFETCH,
3225 : "Malformed commit (empty type)");
3226 0 : return false;
3227 : }
3228 :
3229 1909 : if (type == CommitType::VOTE) {
3230 : // Check that vote is valid
3231 13 : if (!checkVote(userDevice, commit.id, commit.parents[0])) {
3232 2 : JAMI_WARNING("[Account {}] [Conversation {}] Malformed vote commit {}. Please "
3233 : "ensure that you are using the latest "
3234 : "version of Jami, or that one of your contacts is not performing "
3235 : "any unwanted actions.",
3236 : accountId_,
3237 : id_,
3238 : commit.id);
3239 :
3240 2 : emitSignal<libjami::ConversationSignal::OnConversationError>(accountId_,
3241 2 : id_,
3242 : EVALIDFETCH,
3243 : "Malformed vote");
3244 2 : return false;
3245 : }
3246 1896 : } else if (type == CommitType::MEMBER) {
3247 1672 : std::string action = commit.commitMsg.action;
3248 1672 : std::string uriMember = commit.commitMsg.uri;
3249 :
3250 1672 : dht::InfoHash h(uriMember);
3251 1672 : if (not h) {
3252 2 : JAMI_WARNING("[Account {}] [Conversation {}] Commit {} with invalid member URI {}. Please ensure "
3253 : "that you are using the latest version of Jami, or that one of your contacts is not "
3254 : "performing any unwanted actions.",
3255 : accountId_,
3256 : id_,
3257 : commit.id,
3258 : uriMember);
3259 :
3260 2 : emitSignal<libjami::ConversationSignal::OnConversationError>(accountId_,
3261 2 : id_,
3262 : EVALIDFETCH,
3263 : "Invalid member URI");
3264 2 : return false;
3265 : }
3266 1670 : if (action == CommitAction::ADD) {
3267 804 : if (!checkValidAdd(userDevice, uriMember, commit.id, commit.parents[0])) {
3268 2 : JAMI_WARNING("[Account {}] [Conversation {}] Malformed add commit {}. Please ensure that you "
3269 : "are using the latest version of Jami, or that one of your contacts is not "
3270 : "performing any unwanted actions.",
3271 : accountId_,
3272 : id_,
3273 : commit.id);
3274 :
3275 2 : emitSignal<libjami::ConversationSignal::OnConversationError>(accountId_,
3276 2 : id_,
3277 : EVALIDFETCH,
3278 : "Malformed add member commit");
3279 2 : return false;
3280 : }
3281 866 : } else if (action == CommitAction::JOIN) {
3282 843 : if (!checkValidJoins(userDevice, uriMember, commit.id, commit.parents[0])) {
3283 3 : JAMI_WARNING("[Account {}] [Conversation {}] Malformed joins commit {}. "
3284 : "Please ensure that you are using the latest "
3285 : "version of Jami, or that one of your contacts is not "
3286 : "performing any unwanted actions.",
3287 : accountId_,
3288 : id_,
3289 : commit.id);
3290 :
3291 3 : emitSignal<libjami::ConversationSignal::OnConversationError>(accountId_,
3292 3 : id_,
3293 : EVALIDFETCH,
3294 : "Malformed join member commit");
3295 3 : return false;
3296 : }
3297 23 : } else if (action == CommitAction::REMOVE) {
3298 : // In this case, we remove the user. So if self, the user will not be
3299 : // valid for this commit. Check previous commit
3300 9 : validUserAtCommit = commit.parents[0];
3301 9 : if (!checkValidRemove(userDevice, uriMember, commit.id, commit.parents[0])) {
3302 0 : JAMI_WARNING("[Account {}] [Conversation {}] Malformed removes commit {}. "
3303 : "Please ensure that you are using the latest "
3304 : "version of Jami, or that one of your contacts is not "
3305 : "performing any unwanted actions.",
3306 : accountId_,
3307 : id_,
3308 : commit.id);
3309 :
3310 0 : emitSignal<libjami::ConversationSignal::OnConversationError>(accountId_,
3311 0 : id_,
3312 : EVALIDFETCH,
3313 : "Malformed remove member commit");
3314 0 : return false;
3315 : }
3316 14 : } else if (action == CommitAction::BAN || action == CommitAction::UNBAN) {
3317 : // Note device.size() == "member".size()
3318 14 : if (!checkValidVoteResolution(userDevice, uriMember, commit.id, commit.parents[0], action)) {
3319 5 : JAMI_WARNING("[Account {}] [Conversation {}] Malformed removes commit {}. "
3320 : "Please ensure that you are using the latest "
3321 : "version of Jami, or that one of your contacts is not "
3322 : "performing any unwanted actions.",
3323 : accountId_,
3324 : id_,
3325 : commit.id);
3326 :
3327 5 : emitSignal<libjami::ConversationSignal::OnConversationError>(accountId_,
3328 5 : id_,
3329 : EVALIDFETCH,
3330 : "Malformed ban member commit");
3331 5 : return false;
3332 : }
3333 : } else {
3334 0 : JAMI_WARNING("[Account {}] [Conversation {}] Malformed member commit {} with "
3335 : "action {}. Please ensure that you are using the latest "
3336 : "version of Jami, or that one of your contacts is not performing "
3337 : "any unwanted actions.",
3338 : accountId_,
3339 : id_,
3340 : commit.id,
3341 : action);
3342 :
3343 0 : emitSignal<libjami::ConversationSignal::OnConversationError>(accountId_,
3344 0 : id_,
3345 : EVALIDFETCH,
3346 : "Malformed member commit");
3347 0 : return false;
3348 : }
3349 1908 : } else if (type == CommitType::UPDATE_PROFILE) {
3350 25 : if (!checkValidProfileUpdate(userDevice, commit.id, commit.parents[0])) {
3351 2 : JAMI_WARNING("[Account {}] [Conversation {}] Malformed profile updates commit "
3352 : "{}. Please ensure that you are using the latest "
3353 : "version of Jami, or that one of your contacts is not performing "
3354 : "any unwanted actions.",
3355 : accountId_,
3356 : id_,
3357 : commit.id);
3358 :
3359 2 : emitSignal<libjami::ConversationSignal::OnConversationError>(accountId_,
3360 2 : id_,
3361 : EVALIDFETCH,
3362 : "Malformed profile updates commit");
3363 2 : return false;
3364 : }
3365 199 : } else if (type == CommitType::CHECKPOINT) {
3366 14 : if (!checkValidCheckpoint(userDevice, commit.id, commit.parents[0])) {
3367 4 : JAMI_WARNING("[Account {}] [Conversation {}] Malformed checkpoint commit {}. "
3368 : "Please ensure that you are using the latest "
3369 : "version of Jami, or that one of your contacts is not performing "
3370 : "any unwanted actions.",
3371 : accountId_,
3372 : id_,
3373 : commit.id);
3374 :
3375 4 : emitSignal<libjami::ConversationSignal::OnConversationError>(accountId_,
3376 4 : id_,
3377 : EVALIDFETCH,
3378 : "Malformed checkpoint commit");
3379 4 : return false;
3380 : }
3381 185 : } else if (type == CommitType::EDITED_MESSAGE || !editedId.empty()) {
3382 3 : if (!checkEdit(userDevice, commit)) {
3383 1 : JAMI_ERROR("Commit {:s} malformed", commit.id);
3384 :
3385 1 : emitSignal<libjami::ConversationSignal::OnConversationError>(accountId_,
3386 1 : id_,
3387 : EVALIDFETCH,
3388 : "Malformed edit commit");
3389 1 : return false;
3390 : }
3391 : } else {
3392 : // Free-form message commits (texts, call history, data
3393 : // transfers…) only belong to conversations: every commit a
3394 : // document repository can legitimately contain is handled by
3395 : // one of the branches above.
3396 182 : if (mode() == ConversationMode::DOCUMENT) {
3397 1 : JAMI_WARNING("[Account {}] [Conversation {}] Rejecting {} commit {} in a "
3398 : "document repository. Please ensure that you are using the "
3399 : "latest version of Jami, or that one of your contacts is not "
3400 : "performing any unwanted actions.",
3401 : accountId_,
3402 : id_,
3403 : type,
3404 : commit.id);
3405 :
3406 1 : emitSignal<libjami::ConversationSignal::OnConversationError>(
3407 1 : accountId_, id_, EVALIDFETCH, "Message commit in document repository");
3408 1 : return false;
3409 : }
3410 : // Note: accept all mimetype here, as we can have new mimetypes
3411 : // Just avoid to add weird files
3412 : // Check that no weird file is added outside device cert nor removed
3413 181 : if (!checkValidUserDiff(userDevice, commit.id, commit.parents[0])) {
3414 6 : JAMI_WARNING("[Account {}] [Conversation {}] Malformed {} commit {}. Please "
3415 : "ensure that you are using the latest "
3416 : "version of Jami, or that one of your contacts is not performing "
3417 : "any unwanted actions.",
3418 : accountId_,
3419 : id_,
3420 : type,
3421 : commit.id);
3422 :
3423 6 : emitSignal<libjami::ConversationSignal::OnConversationError>(accountId_,
3424 6 : id_,
3425 : EVALIDFETCH,
3426 : "Malformed commit");
3427 6 : return false;
3428 : }
3429 : }
3430 : // For all commits, check that the user is valid.
3431 : // So, the user certificate MUST be in /members or /admins
3432 : // and device cert MUST be in /devices
3433 1881 : if (!isValidUserAtCommit(userDevice, validUserAtCommit, *sig, *sig_data)) {
3434 3 : JAMI_WARNING("[Account {}] [Conversation {}] Malformed commit {}. Please ensure "
3435 : "that you are using the latest "
3436 : "version of Jami, or that one of your contacts is not performing any "
3437 : "unwanted actions. {}",
3438 : accountId_,
3439 : id_,
3440 : validUserAtCommit,
3441 : commit.commitMsg.toString());
3442 3 : emitSignal<libjami::ConversationSignal::OnConversationError>(accountId_,
3443 3 : id_,
3444 : EVALIDFETCH,
3445 : "Invalid user");
3446 3 : return false;
3447 : }
3448 1940 : } else {
3449 : // For all commits, check that the user is valid.
3450 : // So, the user certificate MUST be in /members or /admins
3451 : // and device cert MUST be in /devices
3452 10 : if (!isValidUserAtCommit(userDevice, validUserAtCommit, *sig, *sig_data)) {
3453 0 : JAMI_WARNING("[Account {}] [Conversation {}] Malformed commit {}.Please ensure "
3454 : "that you are using the latest "
3455 : "version of Jami, or that one of your contacts is not performing any "
3456 : "unwanted actions. {}",
3457 : accountId_,
3458 : id_,
3459 : validUserAtCommit,
3460 : commit.commitMsg.toString());
3461 0 : emitSignal<libjami::ConversationSignal::OnConversationError>(accountId_,
3462 0 : id_,
3463 : EVALIDFETCH,
3464 : "Malformed commit");
3465 0 : return false;
3466 : }
3467 :
3468 10 : if (!checkValidMergeCommit(commit.id, commit.parents)) {
3469 1 : JAMI_WARNING("[Account {}] [Conversation {}] Malformed merge commit {}. Please "
3470 : "ensure that you are using the latest "
3471 : "version of Jami, or that one of your contacts is not performing "
3472 : "any unwanted actions.",
3473 : accountId_,
3474 : id_,
3475 : commit.id);
3476 :
3477 1 : emitSignal<libjami::ConversationSignal::OnConversationError>(accountId_,
3478 1 : id_,
3479 : EVALIDFETCH,
3480 : "Malformed merge commit");
3481 1 : return false;
3482 : }
3483 : }
3484 2127 : JAMI_DEBUG("[Account {}] [Conversation {}] Validate commit {}", accountId_, id_, commit.id);
3485 2263 : }
3486 1980 : return true;
3487 2014 : }
3488 :
3489 : /////////////////////////////////////////////////////////////////////////////////
3490 :
3491 555 : ConversationRepository::ConversationRepository(const std::shared_ptr<JamiAccount>& account, const std::string& id)
3492 555 : : pimpl_ {new Impl {account, id}}
3493 554 : {}
3494 :
3495 554 : ConversationRepository::~ConversationRepository() = default;
3496 :
3497 : const std::string&
3498 20897 : ConversationRepository::id() const
3499 : {
3500 20897 : return pimpl_->id_;
3501 : }
3502 :
3503 : std::string
3504 177 : ConversationRepository::addMember(const std::string& uri)
3505 : {
3506 177 : std::lock_guard lkOp(pimpl_->opMtx_);
3507 177 : pimpl_->resetHard();
3508 177 : auto repo = pimpl_->repository();
3509 177 : if (not repo)
3510 0 : return {};
3511 :
3512 : // First, we need to add the member file to the repository if not present
3513 177 : std::filesystem::path repoPath = git_repository_workdir(repo.get());
3514 :
3515 177 : std::filesystem::path invitedPath = repoPath / MemberPath::INVITED;
3516 177 : if (!dhtnet::fileutils::recursive_mkdir(invitedPath, 0700)) {
3517 0 : JAMI_ERROR("Error when creating {}.", invitedPath);
3518 0 : return {};
3519 : }
3520 177 : std::filesystem::path devicePath = invitedPath / uri;
3521 177 : if (std::filesystem::is_regular_file(devicePath)) {
3522 0 : JAMI_WARNING("Member {} already present!", uri);
3523 0 : return {};
3524 : }
3525 :
3526 177 : std::ofstream file(devicePath, std::ios::trunc | std::ios::binary);
3527 177 : if (!file.is_open()) {
3528 0 : JAMI_ERROR("Unable to write data to {}", devicePath);
3529 0 : return {};
3530 : }
3531 177 : std::string path = "invited/" + uri;
3532 177 : if (!pimpl_->add(path))
3533 0 : return {};
3534 :
3535 177 : auto message = CommitMessage::member(CommitAction::ADD, uri);
3536 177 : auto commitId = pimpl_->commit(message.toString());
3537 177 : if (commitId.empty()) {
3538 0 : JAMI_ERROR("Unable to commit addition of member {}", uri);
3539 0 : return {};
3540 : }
3541 :
3542 177 : std::lock_guard lk(pimpl_->membersMtx_);
3543 177 : pimpl_->members_.emplace_back(ConversationMember {uri, MemberRole::INVITED});
3544 177 : pimpl_->saveMembers();
3545 177 : return commitId;
3546 177 : }
3547 :
3548 : void
3549 492 : ConversationRepository::onMembersChanged(OnMembersChanged&& cb)
3550 : {
3551 492 : pimpl_->onMembersChanged_ = std::move(cb);
3552 492 : }
3553 :
3554 : std::string
3555 1 : ConversationRepository::amend(const std::string& id, const std::string& msg)
3556 : {
3557 1 : GitSignature sig = pimpl_->signature();
3558 1 : if (!sig)
3559 0 : return {};
3560 :
3561 : git_oid tree_id, commit_id;
3562 1 : git_commit* commit_ptr = nullptr;
3563 1 : auto repo = pimpl_->repository();
3564 1 : if (!repo || git_oid_fromstr(&tree_id, id.c_str()) < 0 || git_commit_lookup(&commit_ptr, repo.get(), &tree_id) < 0) {
3565 0 : GitCommit commit {commit_ptr};
3566 0 : JAMI_WARNING("Failed to look up commit {}", id);
3567 0 : return {};
3568 0 : }
3569 1 : GitCommit commit {commit_ptr};
3570 :
3571 1 : if (git_commit_amend(&commit_id, commit.get(), nullptr, sig.get(), sig.get(), nullptr, msg.c_str(), nullptr) < 0) {
3572 0 : if (const git_error* err = giterr_last())
3573 0 : JAMI_ERROR("Unable to amend commit: {}", err->message);
3574 0 : return {};
3575 : }
3576 :
3577 : // Move commit to main branch
3578 1 : git_reference* ref_ptr = nullptr;
3579 1 : if (git_reference_create(&ref_ptr, repo.get(), "refs/heads/main", &commit_id, true, nullptr) < 0) {
3580 0 : if (const git_error* err = giterr_last()) {
3581 0 : JAMI_ERROR("Unable to move commit to main: {}", err->message);
3582 0 : emitSignal<libjami::ConversationSignal::OnConversationError>(pimpl_->accountId_,
3583 0 : pimpl_->id_,
3584 : ECOMMIT,
3585 0 : err->message);
3586 : }
3587 0 : return {};
3588 : }
3589 1 : git_reference_free(ref_ptr);
3590 :
3591 1 : auto commit_str = git_oid_tostr_s(&commit_id);
3592 1 : if (commit_str) {
3593 1 : JAMI_DEBUG("Commit {} amended (new ID: {})", id, commit_str);
3594 2 : return commit_str;
3595 : }
3596 0 : return {};
3597 1 : }
3598 :
3599 : bool
3600 1803 : ConversationRepository::fetch(const std::string& remoteDeviceId)
3601 : {
3602 : git_fetch_options fetch_opts;
3603 1803 : git_fetch_options_init(&fetch_opts, GIT_FETCH_OPTIONS_VERSION);
3604 1803 : fetch_opts.follow_redirects = GIT_REMOTE_REDIRECT_NONE;
3605 : // We read the fetched branch through refs/remotes/<device> and never through
3606 : // FETCH_HEAD. Writing it would only add a repository-wide lock file that two
3607 : // fetches on the same conversation would fight over.
3608 1803 : fetch_opts.update_fetchhead = 0;
3609 :
3610 : // Assert that repository exists
3611 1803 : auto repo = pimpl_->repository();
3612 1803 : if (!repo)
3613 0 : return false;
3614 :
3615 : // Everything up to here touches state the other operations also touch:
3616 : // resetHard() must not run while a commit is staging files, and creating the
3617 : // remote rewrites .git/config, which is repository-wide. None of it waits on
3618 : // the network, so the lock is held only for as long as local work takes.
3619 1803 : git_remote* remote_ptr = nullptr;
3620 : {
3621 1803 : std::lock_guard lkOp(pimpl_->opMtx_);
3622 1803 : pimpl_->resetHard();
3623 1803 : auto res = git_remote_lookup(&remote_ptr, repo.get(), remoteDeviceId.c_str());
3624 1803 : if (res != 0) {
3625 882 : if (res != GIT_ENOTFOUND) {
3626 0 : JAMI_ERROR("[Account {}] [Conversation {}] Unable to look up for remote {}",
3627 : pimpl_->accountId_,
3628 : pimpl_->id_,
3629 : remoteDeviceId);
3630 0 : return false;
3631 : }
3632 882 : std::string channelName = fmt::format("git://{}/{}", remoteDeviceId, pimpl_->id_);
3633 : #ifdef LIBJAMI_TEST
3634 882 : if (FETCH_FROM_LOCAL_REPOS) {
3635 0 : channelName = fmt::format("file://{}",
3636 0 : (fileutils::get_data_dir() / remoteDeviceId / "conversations" / pimpl_->id_)
3637 0 : .string());
3638 : }
3639 : #endif
3640 882 : if (git_remote_create(&remote_ptr, repo.get(), remoteDeviceId.c_str(), channelName.c_str()) < 0) {
3641 0 : JAMI_ERROR("[Account {}] [Conversation {}] Unable to create remote for repository",
3642 : pimpl_->accountId_,
3643 : pimpl_->id_);
3644 0 : return false;
3645 : }
3646 882 : }
3647 1803 : }
3648 1802 : GitRemote remote {remote_ptr};
3649 :
3650 : // From here on the fetch waits on the peer, and it does so without opMtx_.
3651 : // What it writes - the object database and refs/remotes/<device> - is either
3652 : // append-only or private to this device, so a message being committed
3653 : // meanwhile no longer has to wait for a peer that has gone quiet.
3654 : //
3655 : // Two fetches for the same device would still contend on that ref, so this
3656 : // relies on there being at most one at a time. Conversation::pull() is the
3657 : // only caller and guarantees it: fetchingRemotes_ is keyed by device and is
3658 : // itself the in-flight marker, a worker is spawned only when the entry did
3659 : // not already exist, and the entry is erased only by that worker as it
3660 : // exits, all under pullcbsMtx_. Further requests for a device already being
3661 : // fetched are queued behind it rather than starting a second fetch.
3662 1803 : fetch_opts.callbacks.transfer_progress = [](const git_indexer_progress* stats, void*) {
3663 : // Uncomment to get advancment
3664 : // if (stats->received_objects % 500 == 0 || stats->received_objects == stats->total_objects)
3665 : // JAMI_DEBUG("{}/{} {}kb", stats->received_objects, stats->total_objects,
3666 : // stats->received_bytes/1024);
3667 : // If a pack is more than 256Mb, it's anormal.
3668 45093 : if (stats->received_bytes > MAX_FETCH_SIZE) {
3669 0 : JAMI_ERROR("Abort fetching repository, the fetch is too big: {} bytes ({}/{})",
3670 : stats->received_bytes,
3671 : stats->received_objects,
3672 : stats->total_objects);
3673 0 : return -1;
3674 : }
3675 45093 : return 0;
3676 : };
3677 1803 : if (git_remote_fetch(remote.get(), nullptr, &fetch_opts, "fetch") < 0) {
3678 41 : const git_error* err = giterr_last();
3679 41 : if (err) {
3680 41 : JAMI_WARNING("[Account {}] [Conversation {}] Unable to fetch remote repository: {:s}",
3681 : pimpl_->accountId_,
3682 : pimpl_->id_,
3683 : err->message);
3684 : }
3685 41 : return false;
3686 : }
3687 :
3688 1760 : return true;
3689 1801 : }
3690 :
3691 : std::vector<std::map<std::string, std::string>>
3692 1762 : ConversationRepository::mergeHistory(const std::string& uri,
3693 : std::function<void(const std::string&)>&& disconnectFromPeerCb)
3694 : {
3695 1762 : auto remoteHeadRes = remoteHead(uri);
3696 1762 : if (remoteHeadRes.empty()) {
3697 1 : JAMI_WARNING("[Account {}] [Conversation {}] Unable to get HEAD of {}", pimpl_->accountId_, pimpl_->id_, uri);
3698 1 : return {};
3699 : }
3700 :
3701 : // Validate commit
3702 1761 : auto [newCommits, err] = validFetch(uri);
3703 1760 : if (newCommits.empty()) {
3704 778 : if (err)
3705 21 : JAMI_ERROR("[Account {}] [Conversation {}] Unable to validate history with {}",
3706 : pimpl_->accountId_,
3707 : pimpl_->id_,
3708 : uri);
3709 778 : removeBranchWith(uri);
3710 778 : return {};
3711 : }
3712 :
3713 : // If validated, merge
3714 983 : auto [ok, cid] = merge(remoteHeadRes);
3715 983 : if (!ok) {
3716 0 : JAMI_ERROR("[Account {}] [Conversation {}] Unable to merge history with {}",
3717 : pimpl_->accountId_,
3718 : pimpl_->id_,
3719 : uri);
3720 0 : removeBranchWith(uri);
3721 0 : return {};
3722 : }
3723 983 : if (!cid.empty()) {
3724 : // A merge commit was generated, should be added in new commits
3725 25 : auto commit = getCommit(cid);
3726 25 : if (commit != std::nullopt)
3727 25 : newCommits.emplace_back(*commit);
3728 25 : }
3729 :
3730 983 : JAMI_LOG("[Account {}] [Conversation {}] Successfully merged history with {}", pimpl_->accountId_, pimpl_->id_, uri);
3731 983 : auto result = convCommitsToMap(newCommits);
3732 2007 : for (auto& commit : result) {
3733 1023 : auto it = commit.find(CommitKey::TYPE);
3734 1022 : if (it != commit.end() && it->second == CommitType::MEMBER) {
3735 824 : refreshMembers();
3736 :
3737 1650 : if (commit[CommitKey::ACTION] == CommitAction::BAN)
3738 10 : disconnectFromPeerCb(commit[CommitKey::URI]);
3739 : }
3740 : }
3741 982 : return result;
3742 1760 : }
3743 :
3744 : std::string
3745 3522 : ConversationRepository::remoteHead(const std::string& remoteDeviceId, const std::string& branch) const
3746 : {
3747 3522 : git_remote* remote_ptr = nullptr;
3748 3522 : auto repo = pimpl_->repository();
3749 3521 : if (!repo || git_remote_lookup(&remote_ptr, repo.get(), remoteDeviceId.c_str()) < 0) {
3750 1 : JAMI_WARNING("No remote found with ID: {}", remoteDeviceId);
3751 1 : return {};
3752 : }
3753 3522 : GitRemote remote {remote_ptr};
3754 :
3755 3522 : git_reference* head_ref_ptr = nullptr;
3756 3522 : std::string remoteHead = "refs/remotes/" + remoteDeviceId + "/" + branch;
3757 : git_oid commit_id;
3758 3522 : if (git_reference_name_to_id(&commit_id, repo.get(), remoteHead.c_str()) < 0) {
3759 0 : const git_error* err = giterr_last();
3760 0 : if (err)
3761 0 : JAMI_ERROR("failed to look up {} ref: {}", remoteHead, err->message);
3762 0 : return {};
3763 : }
3764 3522 : GitReference head_ref {head_ref_ptr};
3765 :
3766 3520 : auto commit_str = git_oid_tostr_s(&commit_id);
3767 3522 : if (!commit_str)
3768 0 : return {};
3769 7042 : return commit_str;
3770 3521 : }
3771 :
3772 : void
3773 465 : ConversationRepository::Impl::addUserDevice()
3774 : {
3775 465 : auto account = account_.lock();
3776 465 : if (!account)
3777 0 : return;
3778 :
3779 : // First, we need to add device file to the repository if not present
3780 465 : auto repo = repository();
3781 465 : if (!repo)
3782 0 : return;
3783 : // NOTE: libgit2 uses / for files
3784 465 : std::string path = fmt::format("devices/{}.crt", deviceId_);
3785 465 : std::filesystem::path devicePath = git_repository_workdir(repo.get()) + path;
3786 465 : if (!std::filesystem::is_regular_file(devicePath)) {
3787 211 : std::ofstream file(devicePath, std::ios::trunc | std::ios::binary);
3788 211 : if (!file.is_open()) {
3789 0 : JAMI_ERROR("Unable to write data to {}", devicePath);
3790 0 : return;
3791 : }
3792 211 : auto cert = account->identity().second;
3793 211 : auto deviceCert = cert->toString(false);
3794 211 : file << deviceCert;
3795 211 : file.close();
3796 :
3797 211 : if (!add(path))
3798 0 : JAMI_WARNING("Unable to add file {}", devicePath);
3799 211 : }
3800 465 : }
3801 :
3802 : void
3803 3480 : ConversationRepository::Impl::resetHard()
3804 : {
3805 : #ifdef LIBJAMI_TEST
3806 3480 : if (DISABLE_RESET)
3807 474 : return;
3808 : #endif
3809 3006 : auto repo = repository();
3810 3006 : if (!repo)
3811 0 : return;
3812 3006 : git_object* head_commit_obj = nullptr;
3813 3006 : auto error = git_revparse_single(&head_commit_obj, repo.get(), "HEAD");
3814 3006 : if (error < 0) {
3815 0 : JAMI_ERROR("[Account {}] [Conversation {}] Unable to get HEAD commit: {}", accountId_, id_, error);
3816 0 : return;
3817 : }
3818 3006 : GitObject target {head_commit_obj};
3819 3006 : git_reset(repo.get(), head_commit_obj, GIT_RESET_HARD, nullptr);
3820 3006 : }
3821 :
3822 : std::string
3823 198 : ConversationRepository::commitMessage(const std::string& msg, bool verifyDevice)
3824 : {
3825 198 : std::lock_guard lkOp(pimpl_->opMtx_);
3826 198 : pimpl_->resetHard();
3827 396 : return pimpl_->commitMessage(msg, verifyDevice);
3828 198 : }
3829 :
3830 : std::string
3831 465 : ConversationRepository::Impl::commitMessage(const std::string& msg, bool verifyDevice)
3832 : {
3833 465 : addUserDevice();
3834 465 : return commit(msg, verifyDevice);
3835 : }
3836 :
3837 : std::vector<std::string>
3838 0 : ConversationRepository::commitMessages(const std::vector<std::string>& msgs)
3839 : {
3840 0 : pimpl_->addUserDevice();
3841 0 : std::vector<std::string> ret;
3842 0 : ret.reserve(msgs.size());
3843 0 : for (const auto& msg : msgs)
3844 0 : ret.emplace_back(pimpl_->commit(msg));
3845 0 : return ret;
3846 0 : }
3847 :
3848 : std::vector<ConversationCommit>
3849 415 : ConversationRepository::log(const LogOptions& options) const
3850 : {
3851 415 : return pimpl_->log(options);
3852 : }
3853 :
3854 : void
3855 2462 : ConversationRepository::log(PreConditionCb&& preCondition,
3856 : std::function<void(ConversationCommit&&)>&& emplaceCb,
3857 : PostConditionCb&& postCondition,
3858 : const std::string& from,
3859 : bool logIfNotFound) const
3860 : {
3861 2462 : pimpl_->forEachCommit(std::move(preCondition), std::move(emplaceCb), std::move(postCondition), from, logIfNotFound);
3862 2462 : }
3863 :
3864 : bool
3865 12861 : ConversationRepository::hasCommit(const std::string& commitId) const
3866 : {
3867 12861 : return pimpl_->hasCommit(commitId);
3868 : }
3869 :
3870 : std::optional<ConversationCommit>
3871 489 : ConversationRepository::getCommit(const std::string& commitId) const
3872 : {
3873 489 : return pimpl_->getCommit(commitId);
3874 : }
3875 :
3876 : std::pair<bool, std::string>
3877 986 : ConversationRepository::merge(const std::string& merge_id, bool force)
3878 : {
3879 986 : std::lock_guard lkOp(pimpl_->opMtx_);
3880 986 : pimpl_->resetHard();
3881 : // First, the repository must be in a clean state
3882 986 : auto repo = pimpl_->repository();
3883 986 : if (!repo) {
3884 0 : JAMI_ERROR("[Account {}] [Conversation {}] Unable to merge without repo", pimpl_->accountId_, pimpl_->id_);
3885 0 : return {false, ""};
3886 : }
3887 986 : int state = git_repository_state(repo.get());
3888 986 : if (state != GIT_REPOSITORY_STATE_NONE) {
3889 0 : pimpl_->resetHard();
3890 0 : int state = git_repository_state(repo.get());
3891 0 : if (state != GIT_REPOSITORY_STATE_NONE) {
3892 0 : JAMI_ERROR("[Account {}] [Conversation {}] Merge operation aborted: repository is in unexpected state {}",
3893 : pimpl_->accountId_,
3894 : pimpl_->id_,
3895 : state);
3896 0 : return {false, ""};
3897 : }
3898 : }
3899 : // Checkout main (to do a `git_merge branch`)
3900 986 : if (git_repository_set_head(repo.get(), "refs/heads/main") < 0) {
3901 0 : JAMI_ERROR("[Account {}] [Conversation {}] Merge operation aborted: unable to checkout main branch",
3902 : pimpl_->accountId_,
3903 : pimpl_->id_);
3904 0 : return {false, ""};
3905 : }
3906 :
3907 : // Then check that merge_id exists
3908 : git_oid commit_id;
3909 986 : if (git_oid_fromstr(&commit_id, merge_id.c_str()) < 0) {
3910 0 : JAMI_ERROR("[Account {}] [Conversation {}] Merge operation aborted: unable to look up commit {}",
3911 : pimpl_->accountId_,
3912 : pimpl_->id_,
3913 : merge_id);
3914 0 : return {false, ""};
3915 : }
3916 986 : git_annotated_commit* annotated_ptr = nullptr;
3917 986 : if (git_annotated_commit_lookup(&annotated_ptr, repo.get(), &commit_id) < 0) {
3918 0 : JAMI_ERROR("[Account {}] [Conversation {}] Merge operation aborted: unable to look up commit {}",
3919 : pimpl_->accountId_,
3920 : pimpl_->id_,
3921 : merge_id);
3922 0 : return {false, ""};
3923 : }
3924 986 : GitAnnotatedCommit annotated {annotated_ptr};
3925 :
3926 : // Now, we can analyze the type of merge required
3927 : git_merge_analysis_t analysis;
3928 : git_merge_preference_t preference;
3929 986 : const git_annotated_commit* const_annotated = annotated.get();
3930 986 : if (git_merge_analysis(&analysis, &preference, repo.get(), &const_annotated, 1) < 0) {
3931 0 : JAMI_ERROR("[Account {}] [Conversation {}] Merge operation aborted: repository analysis failed",
3932 : pimpl_->accountId_,
3933 : pimpl_->id_);
3934 0 : return {false, ""};
3935 : }
3936 :
3937 : // Handle easy merges
3938 986 : if (analysis & GIT_MERGE_ANALYSIS_UP_TO_DATE) {
3939 0 : JAMI_LOG("Already up-to-date");
3940 0 : return {true, ""};
3941 986 : } else if (analysis & GIT_MERGE_ANALYSIS_UNBORN
3942 986 : || (analysis & GIT_MERGE_ANALYSIS_FASTFORWARD && !(preference & GIT_MERGE_PREFERENCE_NO_FASTFORWARD))) {
3943 959 : if (analysis & GIT_MERGE_ANALYSIS_UNBORN)
3944 0 : JAMI_LOG("[Account {}] [Conversation {}] Merge analysis result: Unborn", pimpl_->accountId_, pimpl_->id_);
3945 : else
3946 959 : JAMI_LOG("[Account {}] [Conversation {}] Merge analysis result: Fast-forward",
3947 : pimpl_->accountId_,
3948 : pimpl_->id_);
3949 959 : const auto* target_oid = git_annotated_commit_id(annotated.get());
3950 :
3951 959 : if (!pimpl_->mergeFastforward(target_oid, (analysis & GIT_MERGE_ANALYSIS_UNBORN))) {
3952 0 : const git_error* err = giterr_last();
3953 0 : if (err)
3954 0 : JAMI_ERROR("[Account {}] [Conversation {}] Fast forward merge failed: {}",
3955 : pimpl_->accountId_,
3956 : pimpl_->id_,
3957 : err->message);
3958 0 : return {false, ""};
3959 : }
3960 959 : return {true, ""}; // fast forward so no commit generated;
3961 : }
3962 :
3963 27 : if (!pimpl_->validateDevice() && !force) {
3964 0 : JAMI_ERROR("[Account {}] [Conversation {}] Invalid device. Not migrated?", pimpl_->accountId_, pimpl_->id_);
3965 0 : return {false, ""};
3966 : }
3967 :
3968 : // Else we want to check for conflicts
3969 : git_oid head_commit_id;
3970 27 : if (git_reference_name_to_id(&head_commit_id, repo.get(), "HEAD") < 0) {
3971 0 : JAMI_ERROR("[Account {}] [Conversation {}] Unable to get reference for HEAD", pimpl_->accountId_, pimpl_->id_);
3972 0 : return {false, ""};
3973 : }
3974 :
3975 27 : git_commit* head_ptr = nullptr;
3976 27 : if (git_commit_lookup(&head_ptr, repo.get(), &head_commit_id) < 0) {
3977 0 : JAMI_ERROR("[Account {}] [Conversation {}] Unable to look up HEAD commit", pimpl_->accountId_, pimpl_->id_);
3978 0 : return {false, ""};
3979 : }
3980 27 : GitCommit head_commit {head_ptr};
3981 :
3982 27 : git_commit* other__ptr = nullptr;
3983 27 : if (git_commit_lookup(&other__ptr, repo.get(), &commit_id) < 0) {
3984 0 : JAMI_ERROR("[Account {}] [Conversation {}] Unable to look up HEAD commit", pimpl_->accountId_, pimpl_->id_);
3985 0 : return {false, ""};
3986 : }
3987 27 : GitCommit other_commit {other__ptr};
3988 :
3989 : git_merge_options merge_opts;
3990 27 : git_merge_options_init(&merge_opts, GIT_MERGE_OPTIONS_VERSION);
3991 27 : merge_opts.recursion_limit = 2;
3992 27 : git_index* index_ptr = nullptr;
3993 27 : if (git_merge_commits(&index_ptr, repo.get(), head_commit.get(), other_commit.get(), &merge_opts) < 0) {
3994 0 : const git_error* err = giterr_last();
3995 0 : if (err)
3996 0 : JAMI_ERROR("[Account {}] [Conversation {}] Git merge failed: {}",
3997 : pimpl_->accountId_,
3998 : pimpl_->id_,
3999 : err->message);
4000 0 : return {false, ""};
4001 : }
4002 27 : GitIndex index {index_ptr};
4003 27 : if (git_index_has_conflicts(index.get())) {
4004 1 : JAMI_LOG("Some conflicts were detected during the merge operations. Resolution phase.");
4005 1 : if (!pimpl_->resolveConflicts(index.get(), merge_id) or !git_add_all(repo.get())) {
4006 0 : JAMI_ERROR("Merge operation aborted; Unable to automatically resolve conflicts");
4007 0 : return {false, ""};
4008 : }
4009 : }
4010 27 : auto result = pimpl_->createMergeCommit(index.get(), merge_id);
4011 27 : JAMI_LOG("Merge done between {} and main", merge_id);
4012 :
4013 27 : return {!result.empty(), result};
4014 984 : }
4015 :
4016 : std::string
4017 985 : ConversationRepository::diffStats(const std::string& newId, const std::string& oldId) const
4018 : {
4019 985 : return pimpl_->diffStats(newId, oldId);
4020 : }
4021 :
4022 : std::vector<std::string>
4023 3127 : ConversationRepository::changedFiles(std::string_view diffStats)
4024 : {
4025 3127 : static const std::regex re(" +\\| +[0-9]+.*");
4026 3127 : std::vector<std::string> changedFiles;
4027 3127 : std::string_view line;
4028 12094 : while (jami::getline(diffStats, line)) {
4029 8963 : std::svmatch match;
4030 8968 : if (!std::regex_search(line, match, re) && match.size() == 0)
4031 3129 : continue;
4032 5839 : changedFiles.emplace_back(std::regex_replace(std::string {line}, re, "").substr(1));
4033 8967 : }
4034 6250 : return changedFiles;
4035 0 : }
4036 :
4037 : std::string
4038 238 : ConversationRepository::join()
4039 : {
4040 238 : std::lock_guard lkOp(pimpl_->opMtx_);
4041 238 : pimpl_->resetHard();
4042 : // Check that not already member
4043 238 : auto repo = pimpl_->repository();
4044 238 : if (!repo)
4045 0 : return {};
4046 238 : std::filesystem::path repoPath = git_repository_workdir(repo.get());
4047 238 : auto account = pimpl_->account_.lock();
4048 238 : if (!account)
4049 0 : return {};
4050 238 : auto cert = account->identity().second;
4051 238 : auto parentCert = cert->issuer;
4052 238 : if (!parentCert) {
4053 0 : JAMI_ERROR("Parent cert is null!");
4054 0 : return {};
4055 : }
4056 238 : auto uri = parentCert->getId().toString();
4057 238 : auto membersPath = repoPath / MemberPath::MEMBERS;
4058 238 : auto memberFile = membersPath / (uri + ".crt");
4059 238 : auto adminsPath = repoPath / MemberPath::ADMINS / (uri + ".crt");
4060 238 : if (std::filesystem::is_regular_file(memberFile) or std::filesystem::is_regular_file(adminsPath)) {
4061 : // Already member, nothing to commit
4062 32 : return {};
4063 : }
4064 : // Remove invited/uri.crt
4065 206 : auto invitedPath = repoPath / MemberPath::INVITED;
4066 206 : dhtnet::fileutils::remove(fileutils::getFullPath(invitedPath, uri));
4067 : // Add members/uri.crt
4068 206 : if (!dhtnet::fileutils::recursive_mkdir(membersPath, 0700)) {
4069 0 : JAMI_ERROR("Error when creating {}. Abort create conversations", membersPath);
4070 0 : return {};
4071 : }
4072 206 : std::ofstream file(memberFile, std::ios::trunc | std::ios::binary);
4073 206 : if (!file.is_open()) {
4074 0 : JAMI_ERROR("Unable to write data to {}", memberFile);
4075 0 : return {};
4076 : }
4077 206 : file << parentCert->toString(true);
4078 206 : file.close();
4079 : // git add -A
4080 206 : if (!git_add_all(repo.get())) {
4081 0 : return {};
4082 : }
4083 :
4084 : {
4085 206 : std::lock_guard lk(pimpl_->membersMtx_);
4086 206 : auto updated = false;
4087 :
4088 733 : for (auto& member : pimpl_->members_) {
4089 733 : if (member.uri == uri) {
4090 206 : updated = true;
4091 206 : member.role = MemberRole::MEMBER;
4092 206 : break;
4093 : }
4094 : }
4095 206 : if (!updated)
4096 0 : pimpl_->members_.emplace_back(ConversationMember {uri, MemberRole::MEMBER});
4097 206 : pimpl_->saveMembers();
4098 206 : }
4099 :
4100 206 : auto message = CommitMessage::member(CommitAction::JOIN, uri);
4101 412 : return pimpl_->commitMessage(message.toString());
4102 238 : }
4103 :
4104 : std::string
4105 13 : ConversationRepository::leave()
4106 : {
4107 13 : std::lock_guard lkOp(pimpl_->opMtx_);
4108 13 : pimpl_->resetHard();
4109 : // TODO: simplify
4110 13 : auto account = pimpl_->account_.lock();
4111 13 : auto repo = pimpl_->repository();
4112 13 : if (!account || !repo)
4113 0 : return {};
4114 :
4115 : // Remove related files
4116 13 : std::filesystem::path repoPath = git_repository_workdir(repo.get());
4117 13 : auto crt = fmt::format("{}.crt", pimpl_->userId_);
4118 13 : auto adminFile = repoPath / MemberPath::ADMINS / crt;
4119 13 : auto memberFile = repoPath / MemberPath::MEMBERS / crt;
4120 13 : auto crlsPath = repoPath / "CRLs";
4121 13 : std::error_code ec;
4122 :
4123 13 : if (std::filesystem::is_regular_file(adminFile, ec)) {
4124 8 : std::filesystem::remove(adminFile, ec);
4125 : }
4126 :
4127 13 : if (std::filesystem::is_regular_file(memberFile, ec)) {
4128 5 : std::filesystem::remove(memberFile, ec);
4129 : }
4130 :
4131 : // /CRLs
4132 13 : for (const auto& crl : account->identity().second->getRevocationLists()) {
4133 0 : if (!crl)
4134 0 : continue;
4135 0 : auto crlPath = crlsPath / pimpl_->deviceId_ / fmt::format("{}.crl", dht::toHex(crl->getNumber()));
4136 0 : if (std::filesystem::is_regular_file(crlPath, ec)) {
4137 0 : std::filesystem::remove(crlPath, ec);
4138 : }
4139 0 : }
4140 :
4141 : // Devices
4142 33 : for (const auto& certificate : std::filesystem::directory_iterator(repoPath / "devices", ec)) {
4143 20 : if (certificate.is_regular_file(ec)) {
4144 : try {
4145 20 : crypto::Certificate cert(fileutils::loadFile(certificate.path()));
4146 20 : if (cert.getIssuerUID() == pimpl_->userId_)
4147 13 : std::filesystem::remove(certificate.path(), ec);
4148 20 : } catch (...) {
4149 0 : continue;
4150 0 : }
4151 : }
4152 13 : }
4153 :
4154 13 : if (!git_add_all(repo.get())) {
4155 0 : return {};
4156 : }
4157 :
4158 : {
4159 13 : std::lock_guard lk(pimpl_->membersMtx_);
4160 39 : pimpl_->members_.erase(std::remove_if(pimpl_->members_.begin(),
4161 13 : pimpl_->members_.end(),
4162 21 : [&](auto& member) { return member.uri == pimpl_->userId_; }),
4163 13 : pimpl_->members_.end());
4164 13 : pimpl_->saveMembers();
4165 13 : }
4166 :
4167 26 : auto message = CommitMessage::member(CommitAction::REMOVE, pimpl_->userId_);
4168 26 : return pimpl_->commit(message.toString(), false);
4169 13 : }
4170 :
4171 : void
4172 38 : ConversationRepository::erase()
4173 : {
4174 : // First, we need to add the member file to the repository if not present
4175 38 : if (auto repo = pimpl_->repository()) {
4176 38 : std::string repoPath = git_repository_workdir(repo.get());
4177 38 : JAMI_LOG("Erasing {}", repoPath);
4178 38 : dhtnet::fileutils::removeAll(repoPath, true);
4179 76 : }
4180 38 : }
4181 :
4182 : ConversationMode
4183 4744 : ConversationRepository::mode() const
4184 : {
4185 4744 : return pimpl_->mode();
4186 : }
4187 :
4188 : std::string
4189 74 : ConversationRepository::parentConversationId() const
4190 : {
4191 74 : if (auto commit = pimpl_->getCommit(pimpl_->id_))
4192 74 : return commit->commitMsg.parent;
4193 0 : return {};
4194 : }
4195 :
4196 : std::string
4197 1 : ConversationRepository::documentMimeType() const
4198 : {
4199 1 : if (auto commit = pimpl_->getCommit(pimpl_->id_))
4200 1 : return commit->commitMsg.mimeType;
4201 0 : return {};
4202 : }
4203 :
4204 : std::string
4205 4 : ConversationRepository::addAttachment(const std::vector<uint8_t>& data)
4206 : {
4207 4 : if (data.empty())
4208 0 : return {};
4209 4 : if (mode() != ConversationMode::DOCUMENT) {
4210 1 : JAMI_ERROR("[Account {}] [Conversation {}] Refusing to attach to a non-document repository",
4211 : pimpl_->accountId_,
4212 : pimpl_->id_);
4213 1 : return {};
4214 : }
4215 3 : std::lock_guard lkOp(pimpl_->opMtx_);
4216 3 : pimpl_->resetHard();
4217 3 : auto repo = pimpl_->repository();
4218 3 : if (!repo)
4219 0 : return {};
4220 :
4221 : // Store the blob first to learn its oid: the file is named after its own
4222 : // content hash, so the same bytes added twice converge to a single entry
4223 : // and concurrent additions never conflict.
4224 : git_oid blobId;
4225 3 : if (git_blob_create_from_buffer(&blobId, repo.get(), data.data(), data.size()) < 0) {
4226 0 : JAMI_ERROR("[Account {}] [Conversation {}] Unable to store attachment blob", pimpl_->accountId_, pimpl_->id_);
4227 0 : return {};
4228 : }
4229 3 : std::string id = git_oid_tostr_s(&blobId);
4230 :
4231 3 : std::filesystem::path repoPath = git_repository_workdir(repo.get());
4232 3 : auto attachmentPath = repoPath / "attachments" / id;
4233 3 : if (std::filesystem::is_regular_file(attachmentPath))
4234 1 : return id; // Same content already attached
4235 2 : if (!dhtnet::fileutils::recursive_mkdir(attachmentPath.parent_path(), 0700)) {
4236 0 : JAMI_ERROR("Error when creating {}", attachmentPath.parent_path());
4237 0 : return {};
4238 : }
4239 2 : std::ofstream file(attachmentPath, std::ios::trunc | std::ios::binary);
4240 2 : if (!file.is_open()) {
4241 0 : JAMI_ERROR("Unable to write data to {}", attachmentPath);
4242 0 : return {};
4243 : }
4244 2 : file.write(reinterpret_cast<const char*>(data.data()), data.size());
4245 2 : file.close();
4246 :
4247 2 : if (!pimpl_->add("attachments/" + id))
4248 0 : return {};
4249 : // An attachment travels as a checkpoint that carries no update: the tree
4250 : // change is the whole payload.
4251 2 : if (pimpl_->commitMessage(CommitMessage::checkpoint({}).toString()).empty())
4252 0 : return {};
4253 2 : return id;
4254 3 : }
4255 :
4256 : std::vector<uint8_t>
4257 4 : ConversationRepository::attachment(const std::string& attachmentId) const
4258 : {
4259 4 : auto repo = pimpl_->repository();
4260 4 : if (!repo)
4261 0 : return {};
4262 4 : auto tree = pimpl_->treeAtCommit(repo.get(), getHead());
4263 4 : if (!tree)
4264 0 : return {};
4265 4 : auto blob = pimpl_->fileAtTree("attachments/" + attachmentId, tree);
4266 4 : if (!blob)
4267 1 : return {};
4268 3 : auto content = as_view(blob);
4269 6 : return std::vector<uint8_t>(content.begin(), content.end());
4270 4 : }
4271 :
4272 : std::vector<std::string>
4273 38 : ConversationRepository::attachmentIds() const
4274 : {
4275 38 : std::vector<std::string> ids;
4276 38 : auto repo = pimpl_->repository();
4277 38 : if (!repo)
4278 0 : return ids;
4279 38 : auto tree = pimpl_->treeAtCommit(repo.get(), getHead());
4280 38 : if (!tree)
4281 0 : return ids;
4282 38 : auto* entry = git_tree_entry_byname(tree.get(), "attachments");
4283 38 : if (!entry || git_tree_entry_type(entry) != GIT_OBJECT_TREE)
4284 35 : return ids;
4285 3 : git_tree* sub_ptr = nullptr;
4286 3 : if (git_tree_lookup(&sub_ptr, repo.get(), git_tree_entry_id(entry)) < 0)
4287 0 : return ids;
4288 3 : GitTree sub {sub_ptr};
4289 3 : auto count = git_tree_entrycount(sub.get());
4290 3 : ids.reserve(count);
4291 6 : for (size_t i = 0; i < count; ++i) {
4292 3 : if (auto* e = git_tree_entry_byindex(sub.get(), i))
4293 3 : if (git_tree_entry_type(e) == GIT_OBJECT_BLOB)
4294 3 : ids.emplace_back(git_tree_entry_name(e));
4295 : }
4296 3 : return ids;
4297 38 : }
4298 :
4299 : std::string
4300 16 : ConversationRepository::voteKick(const std::string& uri, const std::string& type)
4301 : {
4302 16 : std::lock_guard lkOp(pimpl_->opMtx_);
4303 16 : pimpl_->resetHard();
4304 16 : auto repo = pimpl_->repository();
4305 16 : auto account = pimpl_->account_.lock();
4306 16 : if (!account || !repo)
4307 0 : return {};
4308 16 : std::filesystem::path repoPath = git_repository_workdir(repo.get());
4309 16 : auto cert = account->identity().second;
4310 16 : if (!cert || !cert->issuer)
4311 0 : return {};
4312 16 : auto adminUri = cert->issuer->getId().toString();
4313 16 : if (adminUri == uri) {
4314 1 : JAMI_WARNING("Admin tried to ban theirself");
4315 1 : return {};
4316 : }
4317 :
4318 15 : auto oldFile = repoPath / type / (uri + (type != "invited" ? ".crt" : ""));
4319 15 : if (!std::filesystem::is_regular_file(oldFile)) {
4320 0 : JAMI_WARNING("Didn't found file for {} with type {}", uri, type);
4321 0 : return {};
4322 : }
4323 :
4324 15 : auto relativeVotePath = fmt::format("votes/ban/{}/{}", type, uri);
4325 15 : auto voteDirectory = repoPath / relativeVotePath;
4326 15 : if (!dhtnet::fileutils::recursive_mkdir(voteDirectory, 0700)) {
4327 0 : JAMI_ERROR("Error when creating {}. Abort vote", voteDirectory);
4328 0 : return {};
4329 : }
4330 15 : auto votePath = fileutils::getFullPath(voteDirectory, adminUri);
4331 15 : std::ofstream voteFile(votePath, std::ios::trunc | std::ios::binary);
4332 15 : if (!voteFile.is_open()) {
4333 0 : JAMI_ERROR("Unable to write data to {}", votePath);
4334 0 : return {};
4335 : }
4336 15 : voteFile.close();
4337 :
4338 15 : auto toAdd = fmt::format("{}/{}", relativeVotePath, adminUri);
4339 15 : if (!pimpl_->add(toAdd))
4340 0 : return {};
4341 :
4342 15 : auto message = CommitMessage::vote(uri);
4343 30 : return pimpl_->commitMessage(message.toString());
4344 16 : }
4345 :
4346 : std::string
4347 2 : ConversationRepository::voteUnban(const std::string& uri, const std::string_view type)
4348 : {
4349 2 : std::lock_guard lkOp(pimpl_->opMtx_);
4350 2 : pimpl_->resetHard();
4351 2 : auto repo = pimpl_->repository();
4352 2 : auto account = pimpl_->account_.lock();
4353 2 : if (!account || !repo)
4354 0 : return {};
4355 2 : std::filesystem::path repoPath = git_repository_workdir(repo.get());
4356 2 : auto cert = account->identity().second;
4357 2 : if (!cert || !cert->issuer)
4358 0 : return {};
4359 2 : auto adminUri = cert->issuer->getId().toString();
4360 :
4361 2 : auto relativeVotePath = fmt::format("votes/unban/{}/{}", type, uri);
4362 2 : auto voteDirectory = repoPath / relativeVotePath;
4363 2 : if (!dhtnet::fileutils::recursive_mkdir(voteDirectory, 0700)) {
4364 0 : JAMI_ERROR("Error when creating {}. Abort vote", voteDirectory);
4365 0 : return {};
4366 : }
4367 2 : auto votePath = voteDirectory / adminUri;
4368 2 : std::ofstream voteFile(votePath, std::ios::trunc | std::ios::binary);
4369 2 : if (!voteFile.is_open()) {
4370 0 : JAMI_ERROR("Unable to write data to {}", votePath);
4371 0 : return {};
4372 : }
4373 2 : voteFile.close();
4374 :
4375 2 : auto toAdd = fileutils::getFullPath(relativeVotePath, adminUri).string();
4376 6 : if (!pimpl_->add(toAdd.c_str()))
4377 0 : return {};
4378 :
4379 2 : auto message = CommitMessage::vote(uri);
4380 4 : return pimpl_->commitMessage(message.toString());
4381 2 : }
4382 :
4383 : bool
4384 15 : ConversationRepository::Impl::resolveBan(const std::string_view type, const std::string& uri)
4385 : {
4386 15 : auto repo = repository();
4387 15 : std::filesystem::path repoPath = git_repository_workdir(repo.get());
4388 15 : auto bannedPath = repoPath / "banned";
4389 15 : auto devicesPath = repoPath / "devices";
4390 : // Move from device or members file into banned
4391 15 : auto crtStr = uri + (type != "invited" ? ".crt" : "");
4392 15 : auto originFilePath = repoPath / type / crtStr;
4393 :
4394 15 : auto destPath = bannedPath / type;
4395 15 : auto destFilePath = destPath / crtStr;
4396 15 : if (!dhtnet::fileutils::recursive_mkdir(destPath, 0700)) {
4397 0 : JAMI_ERROR("An error occurred while creating the {} directory. Abort resolving vote.", destPath);
4398 0 : return false;
4399 : }
4400 :
4401 15 : std::error_code ec;
4402 15 : std::filesystem::rename(originFilePath, destFilePath, ec);
4403 15 : if (ec) {
4404 0 : JAMI_ERROR("An error occurred while moving the {} origin file path to the {} destination "
4405 : "file path. Abort resolving vote.",
4406 : originFilePath,
4407 : destFilePath);
4408 0 : return false;
4409 : }
4410 :
4411 : // If members, remove related devices and mark as banned
4412 15 : if (type != "devices") {
4413 14 : std::error_code ec;
4414 45 : for (const auto& certificate : std::filesystem::directory_iterator(devicesPath, ec)) {
4415 31 : auto certPath = certificate.path();
4416 : try {
4417 31 : crypto::Certificate cert(fileutils::loadFile(certPath));
4418 31 : if (auto issuer = cert.issuer)
4419 0 : if (issuer->getPublicKey().getId().to_view() == uri)
4420 31 : dhtnet::fileutils::remove(certPath, true);
4421 31 : } catch (...) {
4422 0 : continue;
4423 0 : }
4424 45 : }
4425 14 : std::lock_guard lk(membersMtx_);
4426 14 : auto updated = false;
4427 :
4428 29 : for (auto& member : members_) {
4429 29 : if (member.uri == uri) {
4430 14 : updated = true;
4431 14 : member.role = MemberRole::BANNED;
4432 14 : break;
4433 : }
4434 : }
4435 14 : if (!updated)
4436 0 : members_.emplace_back(ConversationMember {uri, MemberRole::BANNED});
4437 14 : saveMembers();
4438 14 : }
4439 15 : return true;
4440 15 : }
4441 :
4442 : bool
4443 2 : ConversationRepository::Impl::resolveUnban(const std::string_view type, const std::string& uri)
4444 : {
4445 2 : auto repo = repository();
4446 2 : std::filesystem::path repoPath = git_repository_workdir(repo.get());
4447 2 : auto bannedPath = repoPath / "banned";
4448 2 : auto crtStr = uri + (type != "invited" ? ".crt" : "");
4449 2 : auto originFilePath = bannedPath / type / crtStr;
4450 2 : auto destPath = repoPath / type;
4451 2 : auto destFilePath = destPath / crtStr;
4452 2 : if (!dhtnet::fileutils::recursive_mkdir(destPath, 0700)) {
4453 0 : JAMI_ERROR("An error occurred while creating the {} destination path. Abort resolving vote.", destPath);
4454 0 : return false;
4455 : }
4456 2 : std::error_code ec;
4457 2 : std::filesystem::rename(originFilePath, destFilePath, ec);
4458 2 : if (ec) {
4459 0 : JAMI_ERROR("Error when moving {} to {}. Abort resolving vote.", originFilePath, destFilePath);
4460 0 : return false;
4461 : }
4462 :
4463 2 : std::lock_guard lk(membersMtx_);
4464 2 : auto updated = false;
4465 :
4466 2 : auto role = MemberRole::MEMBER;
4467 2 : if (type == "invited")
4468 1 : role = MemberRole::INVITED;
4469 1 : else if (type == "admins")
4470 0 : role = MemberRole::ADMIN;
4471 :
4472 4 : for (auto& member : members_) {
4473 4 : if (member.uri == uri) {
4474 2 : updated = true;
4475 2 : member.role = role;
4476 2 : break;
4477 : }
4478 : }
4479 2 : if (!updated)
4480 0 : members_.emplace_back(ConversationMember {uri, role});
4481 2 : saveMembers();
4482 2 : return true;
4483 2 : }
4484 :
4485 : std::string
4486 17 : ConversationRepository::resolveVote(const std::string& uri, const std::string_view type, const std::string& voteType)
4487 : {
4488 17 : std::lock_guard lkOp(pimpl_->opMtx_);
4489 17 : pimpl_->resetHard();
4490 : // Count ratio admin/votes
4491 17 : auto nbAdmins = 0, nbVotes = 0;
4492 : // For each admin, check if voted
4493 17 : auto repo = pimpl_->repository();
4494 17 : if (!repo)
4495 0 : return {};
4496 17 : std::filesystem::path repoPath = git_repository_workdir(repo.get());
4497 17 : auto adminsPath = repoPath / MemberPath::ADMINS;
4498 17 : auto voteDirectory = repoPath / "votes" / voteType / type / uri;
4499 34 : for (const auto& certificate : dhtnet::fileutils::readDirectory(adminsPath)) {
4500 17 : if (certificate.find(".crt") == std::string::npos) {
4501 0 : JAMI_WARNING("Incorrect file found: {}/{}", adminsPath, certificate);
4502 0 : continue;
4503 : }
4504 34 : auto adminUri = certificate.substr(0, certificate.size() - std::string(".crt").size());
4505 17 : nbAdmins += 1;
4506 17 : if (std::filesystem::is_regular_file(fileutils::getFullPath(voteDirectory, adminUri)))
4507 17 : nbVotes += 1;
4508 34 : }
4509 :
4510 17 : if (nbAdmins > 0 && (static_cast<double>(nbVotes) / static_cast<double>(nbAdmins)) > .5) {
4511 17 : JAMI_WARNING("More than half of the admins voted to ban {}, applying the ban.", uri);
4512 :
4513 : // Remove vote directory
4514 17 : dhtnet::fileutils::removeAll(voteDirectory, true);
4515 :
4516 17 : if (voteType == CommitAction::BAN) {
4517 15 : if (!pimpl_->resolveBan(type, uri))
4518 0 : return {};
4519 2 : } else if (voteType == CommitAction::UNBAN) {
4520 2 : if (!pimpl_->resolveUnban(type, uri))
4521 0 : return {};
4522 : }
4523 :
4524 : // Commit
4525 17 : if (!git_add_all(repo.get()))
4526 0 : return {};
4527 :
4528 17 : auto message = CommitMessage::member(voteType, uri);
4529 34 : return pimpl_->commitMessage(message.toString());
4530 17 : }
4531 :
4532 : // If vote nok
4533 0 : return {};
4534 17 : }
4535 :
4536 : std::pair<std::vector<ConversationCommit>, bool>
4537 1760 : ConversationRepository::validFetch(const std::string& remoteDevice) const
4538 : {
4539 1760 : auto newCommit = remoteHead(remoteDevice);
4540 1760 : if (not pimpl_ or newCommit.empty())
4541 0 : return {{}, false};
4542 1761 : auto commitsToValidate = pimpl_->behind(newCommit);
4543 1761 : std::reverse(std::begin(commitsToValidate), std::end(commitsToValidate));
4544 1761 : auto isValid = pimpl_->validCommits(commitsToValidate);
4545 1761 : if (isValid)
4546 1740 : return {commitsToValidate, false};
4547 21 : return {{}, true};
4548 1761 : }
4549 :
4550 : std::pair<std::vector<ConversationCommit>, bool>
4551 240 : ConversationRepository::validClone() const
4552 : {
4553 240 : auto commits = log({});
4554 240 : if (!pimpl_->validCommits(commits))
4555 3 : return {{}, false};
4556 237 : return {std::move(commits), true};
4557 480 : }
4558 :
4559 : bool
4560 2 : ConversationRepository::isValidUserAtCommit(const std::string& userDevice,
4561 : const std::string& commitId,
4562 : const git_buf& sig,
4563 : const git_buf& sig_data) const
4564 : {
4565 2 : return pimpl_->isValidUserAtCommit(userDevice, commitId, sig, sig_data);
4566 : }
4567 :
4568 : bool
4569 13 : ConversationRepository::validCommits(const std::vector<ConversationCommit>& commitsToValidate) const
4570 : {
4571 13 : return pimpl_->validCommits(commitsToValidate);
4572 : }
4573 :
4574 : void
4575 777 : ConversationRepository::removeBranchWith(const std::string& remoteDevice)
4576 : {
4577 777 : git_remote* remote_ptr = nullptr;
4578 777 : auto repo = pimpl_->repository();
4579 778 : if (!repo || git_remote_lookup(&remote_ptr, repo.get(), remoteDevice.c_str()) < 0) {
4580 0 : JAMI_WARNING("No remote found with id: {}", remoteDevice);
4581 0 : return;
4582 : }
4583 778 : GitRemote remote {remote_ptr};
4584 :
4585 778 : git_remote_prune(remote.get(), nullptr);
4586 778 : }
4587 :
4588 : std::vector<std::string>
4589 32 : ConversationRepository::getInitialMembers() const
4590 : {
4591 32 : return pimpl_->getInitialMembers();
4592 : }
4593 :
4594 : std::vector<ConversationMember>
4595 1701 : ConversationRepository::members() const
4596 : {
4597 1701 : return pimpl_->members();
4598 : }
4599 :
4600 : std::set<std::string>
4601 3747 : ConversationRepository::memberUris(std::string_view filter, const std::set<MemberRole>& filteredRoles) const
4602 : {
4603 3747 : return pimpl_->memberUris(filter, filteredRoles);
4604 : }
4605 :
4606 : std::map<std::string, std::vector<DeviceId>>
4607 25 : ConversationRepository::devices(bool ignoreExpired) const
4608 : {
4609 25 : return pimpl_->devices(ignoreExpired);
4610 : }
4611 :
4612 : void
4613 824 : ConversationRepository::refreshMembers() const
4614 : {
4615 : try {
4616 824 : pimpl_->initMembers();
4617 0 : } catch (...) {
4618 0 : }
4619 825 : }
4620 :
4621 : void
4622 240 : ConversationRepository::pinCertificates(bool blocking)
4623 : {
4624 240 : auto acc = pimpl_->account_.lock();
4625 240 : auto repo = pimpl_->repository();
4626 240 : if (!repo or !acc)
4627 0 : return;
4628 :
4629 240 : std::string repoPath = git_repository_workdir(repo.get());
4630 0 : std::vector<std::string> paths = {repoPath + MemberPath::ADMINS.string(),
4631 240 : repoPath + MemberPath::MEMBERS.string(),
4632 1200 : repoPath + MemberPath::DEVICES.string()};
4633 :
4634 960 : for (const auto& path : paths) {
4635 720 : if (blocking) {
4636 720 : std::promise<bool> p;
4637 720 : std::future<bool> f = p.get_future();
4638 1440 : acc->certStore().pinCertificatePath(path, [&](auto /* certs */) { p.set_value(true); });
4639 720 : f.wait();
4640 720 : } else {
4641 0 : acc->certStore().pinCertificatePath(path, {});
4642 : }
4643 : }
4644 480 : }
4645 :
4646 : std::string
4647 13820 : ConversationRepository::uriFromDevice(const std::string& deviceId) const
4648 : {
4649 41406 : return pimpl_->uriFromDevice(deviceId);
4650 : }
4651 :
4652 : std::string
4653 27 : ConversationRepository::updateInfos(const std::map<std::string, std::string>& profile)
4654 : {
4655 27 : std::lock_guard lkOp(pimpl_->opMtx_);
4656 27 : pimpl_->resetHard();
4657 27 : auto valid = false;
4658 : {
4659 27 : std::lock_guard lk(pimpl_->membersMtx_);
4660 29 : for (const auto& member : pimpl_->members_) {
4661 29 : if (member.uri == pimpl_->userId_) {
4662 27 : valid = member.role <= pimpl_->updateProfilePermLvl_;
4663 27 : break;
4664 : }
4665 : }
4666 27 : }
4667 27 : if (!valid) {
4668 2 : JAMI_ERROR("Insufficient permission to update information.");
4669 2 : emitSignal<libjami::ConversationSignal::OnConversationError>(pimpl_->accountId_,
4670 2 : pimpl_->id_,
4671 : EUNAUTHORIZED,
4672 : "Insufficient permission to update information.");
4673 2 : return {};
4674 : }
4675 :
4676 25 : auto infosMap = infos();
4677 53 : for (const auto& [k, v] : profile) {
4678 28 : infosMap[k] = v;
4679 : }
4680 25 : auto repo = pimpl_->repository();
4681 25 : if (!repo)
4682 0 : return {};
4683 25 : std::filesystem::path repoPath = git_repository_workdir(repo.get());
4684 25 : auto profilePath = repoPath / "profile.vcf";
4685 25 : std::ofstream file(profilePath, std::ios::trunc | std::ios::binary);
4686 25 : if (!file.is_open()) {
4687 0 : JAMI_ERROR("Unable to write data to {}", profilePath);
4688 0 : return {};
4689 : }
4690 :
4691 100 : auto addKey = [&](auto property, auto key) {
4692 200 : auto it = infosMap.find(std::string(key));
4693 100 : if (it != infosMap.end()) {
4694 28 : file << property;
4695 28 : file << ":";
4696 28 : file << it->second;
4697 28 : file << vCard::Delimiter::END_LINE_TOKEN;
4698 : }
4699 100 : };
4700 :
4701 25 : file << vCard::Delimiter::BEGIN_TOKEN;
4702 25 : file << vCard::Delimiter::END_LINE_TOKEN;
4703 25 : file << vCard::Property::VCARD_VERSION;
4704 25 : file << ":2.1";
4705 25 : file << vCard::Delimiter::END_LINE_TOKEN;
4706 25 : addKey(vCard::Property::FORMATTED_NAME, vCard::Value::TITLE);
4707 25 : addKey(vCard::Property::DESCRIPTION, vCard::Value::DESCRIPTION);
4708 25 : file << vCard::Property::PHOTO;
4709 25 : file << vCard::Delimiter::SEPARATOR_TOKEN;
4710 25 : file << vCard::Property::BASE64;
4711 25 : auto avatarIt = infosMap.find(std::string(vCard::Value::AVATAR));
4712 25 : if (avatarIt != infosMap.end()) {
4713 : // TODO: type=png? store another way?
4714 0 : file << ":";
4715 0 : file << avatarIt->second;
4716 : }
4717 25 : file << vCard::Delimiter::END_LINE_TOKEN;
4718 25 : addKey(vCard::Property::RDV_ACCOUNT, vCard::Value::RDV_ACCOUNT);
4719 25 : file << vCard::Delimiter::END_LINE_TOKEN;
4720 25 : addKey(vCard::Property::RDV_DEVICE, vCard::Value::RDV_DEVICE);
4721 25 : file << vCard::Delimiter::END_LINE_TOKEN;
4722 25 : file << vCard::Delimiter::END_TOKEN;
4723 25 : file.close();
4724 :
4725 75 : if (!pimpl_->add("profile.vcf"))
4726 0 : return {};
4727 25 : auto message = CommitMessage::updateProfile();
4728 50 : return pimpl_->commitMessage(message.toString());
4729 27 : }
4730 :
4731 : std::map<std::string, std::string>
4732 441 : ConversationRepository::infos() const
4733 : {
4734 441 : if (auto repo = pimpl_->repository()) {
4735 : try {
4736 441 : std::filesystem::path repoPath = git_repository_workdir(repo.get());
4737 441 : auto profilePath = repoPath / "profile.vcf";
4738 441 : std::map<std::string, std::string> result;
4739 441 : std::error_code ec;
4740 441 : if (std::filesystem::is_regular_file(profilePath, ec)) {
4741 66 : auto content = fileutils::loadFile(profilePath);
4742 132 : result = ConversationRepository::infosFromVCard(
4743 198 : vCard::utils::toMap(std::string_view {(const char*) content.data(), content.size()}));
4744 66 : }
4745 1323 : result["mode"] = std::to_string(static_cast<int>(mode()));
4746 441 : return result;
4747 441 : } catch (...) {
4748 0 : }
4749 441 : }
4750 0 : return {};
4751 : }
4752 :
4753 : std::map<std::string, std::string>
4754 170 : ConversationRepository::infosFromVCard(vCard::utils::VCardData&& details)
4755 : {
4756 170 : std::map<std::string, std::string> result;
4757 455 : for (auto&& [k, v] : details) {
4758 285 : if (k == vCard::Property::FORMATTED_NAME) {
4759 171 : result["title"] = std::move(v);
4760 228 : } else if (k == vCard::Property::DESCRIPTION) {
4761 6 : result["description"] = std::move(v);
4762 226 : } else if (k.find(vCard::Property::PHOTO) == 0) {
4763 0 : result["avatar"] = std::move(v);
4764 226 : } else if (k.find(vCard::Property::RDV_ACCOUNT) == 0) {
4765 33 : result["rdvAccount"] = std::move(v);
4766 215 : } else if (k.find(vCard::Property::RDV_DEVICE) == 0) {
4767 33 : result["rdvDevice"] = std::move(v);
4768 : }
4769 : }
4770 170 : return result;
4771 0 : }
4772 :
4773 : std::string
4774 1810 : ConversationRepository::getHead() const
4775 : {
4776 1810 : if (auto repo = pimpl_->repository()) {
4777 : git_oid commit_id;
4778 1809 : if (git_reference_name_to_id(&commit_id, repo.get(), "HEAD") < 0) {
4779 0 : JAMI_ERROR("Unable to get reference for HEAD");
4780 0 : return {};
4781 : }
4782 1810 : if (auto commit_str = git_oid_tostr_s(&commit_id))
4783 3620 : return commit_str;
4784 1810 : }
4785 0 : return {};
4786 : }
4787 :
4788 : std::optional<std::map<std::string, std::string>>
4789 17022 : ConversationRepository::convCommitToMap(const ConversationCommit& commit) const
4790 : {
4791 17022 : return pimpl_->convCommitToMap(commit);
4792 : }
4793 :
4794 : std::vector<std::map<std::string, std::string>>
4795 1673 : ConversationRepository::convCommitsToMap(const std::vector<ConversationCommit>& commits) const
4796 : {
4797 1673 : std::vector<std::map<std::string, std::string>> result = {};
4798 1673 : result.reserve(commits.size());
4799 4449 : for (const auto& commit : commits) {
4800 2776 : if (auto message = pimpl_->convCommitToMap(commit))
4801 2775 : result.emplace_back(*message);
4802 : }
4803 1673 : return result;
4804 0 : }
4805 :
4806 : } // namespace jami
|