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