LCOV - code coverage report
Current view: top level - src/jamidht - conversationrepository.h (source / functions) Coverage Total Hit
Test: jami-coverage-filtered.info Lines: 91.7 % 24 22
Test Date: 2026-09-13 09:08:58 Functions: 91.7 % 12 11

            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              : #pragma once
      18              : #include "def.h"
      19              : #include "vcard.h"
      20              : #include "git_def.h"
      21              : #include "jamidht/commit_message.h"
      22              : 
      23              : #include <opendht/default_types.h>
      24              : 
      25              : #include <algorithm>
      26              : #include <optional>
      27              : #include <memory>
      28              : #include <stdexcept>
      29              : #include <string>
      30              : #include <string_view>
      31              : #include <vector>
      32              : 
      33              : namespace jami {
      34              : 
      35              : using DeviceId = dht::PkId;
      36              : 
      37              : constexpr auto EFETCH = 1;
      38              : constexpr auto EINVALIDMODE = 2;
      39              : constexpr auto EVALIDFETCH = 3;
      40              : constexpr auto EUNAUTHORIZED = 4;
      41              : constexpr auto ECOMMIT = 5;
      42              : constexpr auto EUNRECOVERABLE = 6;
      43              : 
      44              : constexpr size_t CONVERSATION_ID_LENGTH = 40;
      45              : 
      46              : constexpr bool
      47          313 : isConversationId(std::string_view id) noexcept
      48              : {
      49          313 :     return id.size() == CONVERSATION_ID_LENGTH && std::all_of(id.begin(), id.end(), [](char c) {
      50        12520 :                return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f');
      51          313 :            });
      52              : }
      53              : 
      54              : class JamiAccount;
      55              : 
      56              : /**
      57              :  * Exception thrown when a cloned conversation repository fails commit validation.
      58              :  * Unlike network errors, this failure is permanent: the remote history is immutable,
      59              :  * so cloning the same conversation again will fail the same way.
      60              :  */
      61              : class InvalidRepositoryError : public std::runtime_error
      62              : {
      63              : public:
      64            3 :     explicit InvalidRepositoryError(const std::string& what)
      65            3 :         : std::runtime_error(what)
      66            3 :     {}
      67              : };
      68              : 
      69              : struct LogOptions
      70              : {
      71              :     std::string from {};
      72              :     std::string to {};
      73              :     uint64_t nbOfCommits {0};  // maximum number of commits wanted
      74              :     bool skipMerge {false};    // Do not include merge commits in the log. Used by the module to get
      75              :                                // last interaction without potential merges
      76              :     bool includeTo {false};    // If we want or not the "to" commit [from-to] or [from-to)
      77              :     bool fastLog {false};      // Do not parse content, used mostly to count
      78              :     bool logIfNotFound {true}; // Add a warning in the log if commit is not found
      79              : 
      80              :     std::string authorUri {}; // filter commits from author
      81              : };
      82              : 
      83              : struct Filter
      84              : {
      85              :     std::string author;
      86              :     std::string lastId;
      87              :     std::string regexSearch;
      88              :     std::string type;
      89              :     int64_t after {0};
      90              :     int64_t before {0};
      91              :     uint32_t maxResult {0};
      92              :     bool caseSensitive {false};
      93              : };
      94              : 
      95              : struct GitAuthor
      96              : {
      97              :     std::string name {};
      98              :     std::string email {};
      99              : };
     100              : 
     101              : struct ConversationCommit
     102              : {
     103              :     std::string id {};
     104              :     std::vector<std::string> parents {};
     105              :     GitAuthor author {};
     106              :     std::vector<uint8_t> signed_content {};
     107              :     std::vector<uint8_t> signature {};
     108              :     CommitMessage commitMsg {};
     109              :     std::string linearized_parent {};
     110              :     std::string authorId {};
     111              :     int64_t timestamp {0};
     112              : };
     113              : 
     114              : enum class MemberRole { ADMIN = 0, MEMBER, INVITED, BANNED, LEFT };
     115              : 
     116              : namespace MemberPath {
     117              : 
     118              : static const std::filesystem::path ADMINS {"admins"};
     119              : static const std::filesystem::path MEMBERS {"members"};
     120              : static const std::filesystem::path INVITED {"invited"};
     121              : static const std::filesystem::path BANNED {"banned"};
     122              : static const std::filesystem::path DEVICES {"devices"};
     123              : 
     124              : } // namespace MemberPath
     125              : 
     126              : struct ConversationMember
     127              : {
     128              :     std::string uri;
     129              :     MemberRole role;
     130              : 
     131          268 :     std::map<std::string, std::string> map() const
     132              :     {
     133          268 :         std::string rolestr;
     134          267 :         if (role == MemberRole::ADMIN) {
     135          133 :             rolestr = "admin";
     136          134 :         } else if (role == MemberRole::MEMBER) {
     137           77 :             rolestr = "member";
     138           57 :         } else if (role == MemberRole::INVITED) {
     139           51 :             rolestr = "invited";
     140            6 :         } else if (role == MemberRole::BANNED) {
     141            6 :             rolestr = "banned";
     142            0 :         } else if (role == MemberRole::LEFT) {
     143            0 :             rolestr = "left"; // For one to one
     144              :         }
     145              : 
     146         1337 :         return {{"uri", uri}, {"role", rolestr}};
     147          535 :     }
     148        14469 :     MSGPACK_DEFINE(uri, role)
     149              : };
     150              : 
     151              : enum class CallbackResult { Skip, Break, Ok };
     152              : 
     153              : using PreConditionCb = std::function<CallbackResult(const std::string&, const GitAuthor&, const GitCommit&)>;
     154              : using PostConditionCb = std::function<bool(const std::string&, const GitAuthor&, ConversationCommit&)>;
     155              : using OnMembersChanged = std::function<void(const std::set<std::string>&)>;
     156              : 
     157              : /**
     158              :  * This class gives access to the git repository that represents the conversation
     159              :  */
     160              : class LIBJAMI_TEST_EXPORT ConversationRepository
     161              : {
     162              : public:
     163              : #ifdef LIBJAMI_TEST
     164              :     static bool DISABLE_RESET; // Some tests inject bad files so resetHard() will break the test
     165              : 
     166              :     // If true, clone and fetch operations will be performed directly using the target repo's path,
     167              :     // avoiding the need for setting up a GitServer and a DHTNet connection.
     168              :     static bool FETCH_FROM_LOCAL_REPOS;
     169              : #endif
     170              :     /**
     171              :      * Check that an identifier can be the id of a conversation (or document) repository:
     172              :      * the hex SHA-1 of its initial commit, exactly 40 lowercase hex digits.
     173              :      * Ids received from peers or read from disk are used as directory names and
     174              :      * MUST pass this check before touching the filesystem.
     175              :      */
     176              :     static LIBJAMI_TEST_EXPORT bool isValidConversationId(std::string_view id) noexcept;
     177              : 
     178              :     /**
     179              :      * Creates a new repository, with initial files, where the first commit hash is the conversation id
     180              :      * @param account       The related account
     181              :      * @param mode          The wanted mode
     182              :      * @param otherMember   The other uri
     183              :      * @return  the conversation repository object
     184              :      */
     185              :     static LIBJAMI_TEST_EXPORT std::unique_ptr<ConversationRepository> createConversation(
     186              :         const std::shared_ptr<JamiAccount>& account,
     187              :         ConversationMode mode = ConversationMode::INVITES_ONLY,
     188              :         const std::string& otherMember = "");
     189              : 
     190              :     /**
     191              :      * Creates a new collaborative document repository (a swarm exactly like a conversation,
     192              :      * with mode ConversationMode::DOCUMENT), where the first commit hash is the document id.
     193              :      * @param account                  The related account
     194              :      * @param parentConversationId     The conversation the document is announced in
     195              :      * @param mimeType                 The media type of what the document holds
     196              :      * @return the repository object
     197              :      */
     198              :     static LIBJAMI_TEST_EXPORT std::unique_ptr<ConversationRepository> createDocument(
     199              :         const std::shared_ptr<JamiAccount>& account,
     200              :         const std::string& parentConversationId,
     201              :         const std::string& mimeType);
     202              : 
     203              :     /**
     204              :      * Clones a conversation on a remote device
     205              :      * @note This will use the socket registered for the conversation with
     206              :      * Conversation::addGitSocket()
     207              :      * @param account           The account getting the conversation
     208              :      * @param deviceId          Remote device
     209              :      * @param conversationId    Conversation to clone
     210              :      * @throws InvalidRepositoryError if the cloned repository fails commit validation. This is a
     211              :      *         permanent failure (the remote history is immutable); transient network errors do not
     212              :      *         throw but return an empty repository instead. The sole caller,
     213              :      *         ConversationModule::Impl::handlePendingConversation(), catches this to stop retrying;
     214              :      *         any new caller must handle it as well.
     215              :      */
     216              :     static LIBJAMI_TEST_EXPORT std::pair<std::unique_ptr<ConversationRepository>, std::vector<ConversationCommit>>
     217              :     cloneConversation(const std::shared_ptr<JamiAccount>& account,
     218              :                       const std::string& deviceId,
     219              :                       const std::string& conversationId);
     220              : 
     221              :     /**
     222              :      * Open a conversation repository for an account and an id
     223              :      * @param account       The related account
     224              :      * @param id            The conversation id
     225              :      */
     226              :     ConversationRepository(const std::shared_ptr<JamiAccount>& account, const std::string& id);
     227              :     ~ConversationRepository();
     228              : 
     229              :     /**
     230              :      * Write the certificate in /members and commit the change
     231              :      * @param uri    Member to add
     232              :      * @return the commit id if successful
     233              :      */
     234              :     std::string addMember(const std::string& uri);
     235              : 
     236              :     /**
     237              :      * Fetch a remote repository via the given socket
     238              :      * @note This will use the socket registered for the conversation with
     239              :      * Conversation::addGitSocket()
     240              :      * @note will create a remote identified by the deviceId
     241              :      * @param remoteDeviceId    Remote device id to fetch
     242              :      * @return if the operation was successful
     243              :      */
     244              :     bool fetch(const std::string& remoteDeviceId);
     245              : 
     246              :     /**
     247              :      * Merge the history of the conversation with another peer
     248              :      * @param uri                    The peer uri
     249              :      * @param disconnectFromPeerCb   Callback to disconnect from peer when banning
     250              :      * @return                       A vector of media maps representing the merged history
     251              :      */
     252              :     std::vector<std::map<std::string, std::string>> mergeHistory(
     253              :         const std::string& uri, std::function<void(const std::string&)>&& disconnectFromPeerCb = {});
     254              : 
     255              :     /**
     256              :      * Retrieve remote head. Can be useful after a fetch operation
     257              :      * @param remoteDeviceId        The remote name
     258              :      * @param branch                Remote branch to check (default: main)
     259              :      * @return the commit id pointed
     260              :      */
     261              :     std::string remoteHead(const std::string& remoteDeviceId, const std::string& branch = "main") const;
     262              : 
     263              :     /**
     264              :      * Return the conversation id
     265              :      */
     266              :     const std::string& id() const;
     267              : 
     268              :     /**
     269              :      * Add a new commit to the conversation
     270              :      * @param msg           The commit message of the commit
     271              :      * @param verifyDevice  If we need to validate that certificates are correct (used for testing)
     272              :      * @return <empty> on failure, else the message id
     273              :      */
     274              :     std::string commitMessage(const std::string& msg, bool verifyDevice = true);
     275              : 
     276              :     std::vector<std::string> commitMessages(const std::vector<std::string>& msgs);
     277              : 
     278              :     /**
     279              :      * Amend a commit message
     280              :      * @param id      The commit to amend
     281              :      * @param msg     The commit message of the commit
     282              :      * @return <empty> on failure, else the message id
     283              :      */
     284              :     std::string amend(const std::string& id, const std::string& msg);
     285              : 
     286              :     /**
     287              :      * Get commits depending on the options we pass
     288              :      * @return a list of commits
     289              :      */
     290              :     std::vector<ConversationCommit> log(const LogOptions& options = {}) const;
     291              :     void log(PreConditionCb&& preCondition,
     292              :              std::function<void(ConversationCommit&&)>&& emplaceCb,
     293              :              PostConditionCb&& postCondition,
     294              :              const std::string& from = "",
     295              :              bool logIfNotFound = true) const;
     296              : 
     297              :     /**
     298              :      * Check if a commit exists in the repository
     299              :      * @param commitId The commit id to check
     300              :      * @return true if the commit was found, false if not or if an error occurred
     301              :      */
     302              :     bool hasCommit(const std::string& commitId) const;
     303              :     std::optional<ConversationCommit> getCommit(const std::string& commitId) const;
     304              : 
     305              :     /**
     306              :      * Get parent via topological + date sort in branch main of a commit
     307              :      * @param commitId      id to choice
     308              :      */
     309              :     std::optional<std::string> linearizedParent(const std::string& commitId) const;
     310              : 
     311              :     /**
     312              :      * Merge another branch into the main branch
     313              :      * @param merge_id      The reference to merge
     314              :      * @param force         Should be false, skip validateDevice() ; used for test purpose
     315              :      * @return a pair containing if the merge was successful and the merge commit id
     316              :      * generated if one (can be a fast forward merge without commit)
     317              :      */
     318              :     std::pair<bool, std::string> merge(const std::string& merge_id, bool force = false);
     319              : 
     320              :     /**
     321              :      * Get current diff stats between two commits
     322              :      * @param oldId     Old commit
     323              :      * @param newId     Recent commit (empty value will compare to the empty repository)
     324              :      * @note "HEAD" is also accepted as parameter for newId
     325              :      * @return diff stats
     326              :      */
     327              :     std::string diffStats(const std::string& newId, const std::string& oldId = "") const;
     328              : 
     329              :     /**
     330              :      * Get changed files from a git diff
     331              :      * @param diffStats     The stats to analyze
     332              :      * @return get the changed files from a git diff
     333              :      */
     334              :     static std::vector<std::string> changedFiles(std::string_view diffStats);
     335              : 
     336              :     /**
     337              :      * Join a repository
     338              :      * @return commit Id
     339              :      */
     340              :     std::string join();
     341              : 
     342              :     /**
     343              :      * Erase self from repository
     344              :      * @return commit Id
     345              :      */
     346              :     std::string leave();
     347              : 
     348              :     /**
     349              :      * Erase repository
     350              :      */
     351              :     void erase();
     352              : 
     353              :     /**
     354              :      * Get conversation's mode
     355              :      * @return the mode
     356              :      */
     357              :     ConversationMode mode() const;
     358              : 
     359              :     /**
     360              :      * Document repositories only (mode() == ConversationMode::DOCUMENT).
     361              :      * Read back the fields of the initial commit: the id of the conversation
     362              :      * the document was announced in and the media type of what it holds.
     363              :      */
     364              :     std::string parentConversationId() const;
     365              :     std::string documentMimeType() const;
     366              : 
     367              :     /**
     368              :      * Document repositories only. Attachments are content-addressed blobs
     369              :      * stored under attachments/<oid>: the file name is the git oid of the
     370              :      * content, so the same bytes added twice converge to the same entry.
     371              :      */
     372              :     /**
     373              :      * Store an attachment and commit it
     374              :      * @param data  The attachment content
     375              :      * @return the attachment id (blob oid) or empty on failure
     376              :      */
     377              :     std::string addAttachment(const std::vector<uint8_t>& data);
     378              :     /**
     379              :      * Read an attachment's content at HEAD
     380              :      */
     381              :     std::vector<uint8_t> attachment(const std::string& attachmentId) const;
     382              :     /**
     383              :      * List attachment ids present at HEAD
     384              :      */
     385              :     std::vector<std::string> attachmentIds() const;
     386              : 
     387              :     /**
     388              :      * The voting system is divided in two parts. The voting phase where
     389              :      * admins can decide an action (such as kicking someone)
     390              :      * and the resolving phase, when > 50% of the admins voted, we can
     391              :      * considered the vote as finished
     392              :      */
     393              :     /**
     394              :      * Add a vote to kick a device or a user
     395              :      * @param uri       identified of the user/device
     396              :      * @param type      device, members, admins or invited
     397              :      * @return the commit id or empty if failed
     398              :      */
     399              :     std::string voteKick(const std::string& uri, const std::string& type);
     400              :     /**
     401              :      * Add a vote to re-add a user
     402              :      * @param uri       identified of the user
     403              :      * @param type      device, members, admins or invited
     404              :      * @return the commit id or empty if failed
     405              :      */
     406              :     std::string voteUnban(const std::string& uri, const std::string_view type);
     407              :     /**
     408              :      * Validate if a vote is finished
     409              :      * @param uri       identified of the user/device
     410              :      * @param type      device, members, admins or invited
     411              :      * @param voteType  "ban" or "unban"
     412              :      * @return the commit id or empty if failed
     413              :      */
     414              :     std::string resolveVote(const std::string& uri, const std::string_view type, const std::string& voteType);
     415              : 
     416              :     /**
     417              :      * Validate a fetch with remote device
     418              :      * @param remotedevice
     419              :      * @return the validated commits and if an error occurs
     420              :      */
     421              :     std::pair<std::vector<ConversationCommit>, bool> validFetch(const std::string& remoteDevice) const;
     422              : 
     423              :     /**
     424              :      * Validate a clone
     425              :      * @return the validated commits and false if an error occurs
     426              :      */
     427              :     std::pair<std::vector<ConversationCommit>, bool> validClone() const;
     428              : 
     429              :     /**
     430              :      * Verify the signature against the given commit
     431              :      * @param userDevice    the email of the sender (i.e. their device's public key)
     432              :      * @param commitId      the id of the commit
     433              :      */
     434              :     bool isValidUserAtCommit(const std::string& userDevice,
     435              :                              const std::string& commitId,
     436              :                              const git_buf& sig,
     437              :                              const git_buf& sig_data) const;
     438              : 
     439              :     /**
     440              :      * Validate that commits are not malformed
     441              :      * @param commitsToValidate     the list of commits
     442              :      */
     443              :     bool validCommits(const std::vector<ConversationCommit>& commitsToValidate) const;
     444              : 
     445              :     /**
     446              :      * Delete branch with remote
     447              :      * @param remoteDevice
     448              :      */
     449              :     void removeBranchWith(const std::string& remoteDevice);
     450              : 
     451              :     /**
     452              :      * One to one util, get initial members
     453              :      * @return initial members
     454              :      */
     455              :     std::vector<std::string> getInitialMembers() const;
     456              : 
     457              :     /**
     458              :      * Get conversation's members
     459              :      * @return members
     460              :      */
     461              :     std::vector<ConversationMember> members() const;
     462              : 
     463              :     /**
     464              :      * Get conversation's devices
     465              :      * @param ignoreExpired     If we want to ignore expired devices
     466              :      * @return members
     467              :      */
     468              :     std::map<std::string, std::vector<DeviceId>> devices(bool ignoreExpired = true) const;
     469              : 
     470              :     /**
     471              :      * @param filter           If we want to remove one member
     472              :      * @param filteredRoles    If we want to ignore some roles
     473              :      * @return members' uris
     474              :      */
     475              :     std::set<std::string> memberUris(std::string_view filter, const std::set<MemberRole>& filteredRoles) const;
     476              : 
     477              :     /**
     478              :      * To use after a merge with member's events, refresh members knowledge
     479              :      */
     480              :     void refreshMembers() const;
     481              : 
     482              :     void onMembersChanged(OnMembersChanged&& cb);
     483              : 
     484              :     /**
     485              :      * Because conversations can contains non contacts certificates, this methods
     486              :      * loads certificates in conversations into the cert store
     487              :      * @param blocking      if we need to wait that certificates are pinned
     488              :      */
     489              :     void pinCertificates(bool blocking = false);
     490              :     /**
     491              :      * Retrieve the uri from a deviceId
     492              :      * @note used by swarm manager (peersToSyncWith)
     493              :      * @param deviceId
     494              :      * @return corresponding issuer
     495              :      */
     496              :     std::string uriFromDevice(const std::string& deviceId) const;
     497              : 
     498              :     /**
     499              :      * Change repository's infos
     500              :      * @param map       New infos (supported keys: title, description, avatar)
     501              :      * @return the commit id
     502              :      */
     503              :     std::string updateInfos(const std::map<std::string, std::string>& map);
     504              : 
     505              :     /**
     506              :      * Retrieve current infos (title, description, avatar, mode)
     507              :      * @return infos
     508              :      */
     509              :     std::map<std::string, std::string> infos() const;
     510              :     static std::map<std::string, std::string> infosFromVCard(vCard::utils::VCardData&& details);
     511              : 
     512              :     /**
     513              :      * Convert ConversationCommit to MapStringString for the client
     514              :      */
     515              :     std::vector<std::map<std::string, std::string>> convCommitsToMap(
     516              :         const std::vector<ConversationCommit>& commits) const;
     517              :     std::optional<std::map<std::string, std::string>> convCommitToMap(const ConversationCommit& commit) const;
     518              : 
     519              :     /**
     520              :      * Get current HEAD hash
     521              :      */
     522              :     std::string getHead() const;
     523              : 
     524              : private:
     525              :     ConversationRepository() = delete;
     526              :     static std::unique_ptr<ConversationRepository> createRepository(const std::shared_ptr<JamiAccount>& account,
     527              :                                                                     ConversationMode mode,
     528              :                                                                     const std::string& otherMember,
     529              :                                                                     const CommitMessage& initialMessage);
     530              :     class Impl;
     531              :     std::unique_ptr<Impl> pimpl_;
     532              : };
     533              : 
     534              : } // namespace jami
     535        14500 : MSGPACK_ADD_ENUM(jami::MemberRole);
     536         4413 : MSGPACK_ADD_ENUM(jami::ConversationMode);
        

Generated by: LCOV version 2.0-1