• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

realm / realm-core / 2469

03 Jul 2024 10:12PM UTC coverage: 90.958% (-0.03%) from 90.984%
2469

push

Evergreen

web-flow
RCORE-2185 Sync client should steal file ident of fresh realm when performing client reset (#7850)

* Initial changes to use the file ident from the fresh realm during client reset

* Fixed failing realm_sync_test tests

* Don't send UPLOAD Messages while downloading fresh realm

* Allow sending QUERY bootstrap for fresh download sessions

* Added SHARED_GROUP_FRESH_PATH to generate path for fresh realm

* Removed SHARED_GROUP_FRESH_PATH and used session_reason setting instead

* Some cleanup after tests passing

* Added test to verify no UPLOAD messages are sent during fresh realm download

* Use is_fresh_path to determine if hook event called by client reset fresh realm download session

* Fixed tsan failure around REQUIRE() within hook event callback in flx_migration test

* Updates from review and streamlined changes based on recommendations

* Reverted some test changes that are no longer needed

* Updated logic for when to perform a client reset diff

* Updated fresh realm download to update upload progress but not send upload messages

* Removed has_client_reset_config flag in favor of get_cliet_reset_config()

* Updats from the review - renamed m_allow_uploads to m_delay_uploads

* Updated assert

* Updated test to start with file ident, added comment about client reset and no file ident

* Updated comment for m_delay_uploads

102284 of 180462 branches covered (56.68%)

140 of 147 new or added lines in 10 files covered. (95.24%)

90 existing lines in 15 files now uncovered.

215145 of 236531 relevant lines covered (90.96%)

6144068.37 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

73.54
/src/realm/sync/noinst/protocol_codec.hpp
1
#ifndef REALM_NOINST_PROTOCOL_CODEC_HPP
2
#define REALM_NOINST_PROTOCOL_CODEC_HPP
3

4
#include <cstdint>
5
#include <algorithm>
6
#include <memory>
7
#include <vector>
8
#include <string>
9

10
#include <realm/util/buffer_stream.hpp>
11
#include <realm/util/compression.hpp>
12
#include <realm/util/from_chars.hpp>
13
#include <realm/util/logger.hpp>
14
#include <realm/util/memory_stream.hpp>
15
#include <realm/util/optional.hpp>
16
#include <realm/binary_data.hpp>
17
#include <realm/chunked_binary.hpp>
18
#include <realm/sync/changeset_parser.hpp>
19
#include <realm/sync/history.hpp>
20
#include <realm/sync/impl/clamped_hex_dump.hpp>
21
#include <realm/sync/noinst/integer_codec.hpp>
22
#include <realm/sync/protocol.hpp>
23
#include <realm/sync/transform.hpp>
24

25
#include <external/json/json.hpp>
26

27
namespace realm::_impl {
28
struct ProtocolCodecException : public std::runtime_error {
29
    using std::runtime_error::runtime_error;
30
};
31
class HeaderLineParser {
32
public:
33
    explicit HeaderLineParser(std::string_view line)
34
        : m_sv(line)
75,724✔
35
    {
158,580✔
36
    }
158,580✔
37

38
    template <typename T>
39
    T read_next(char expected_terminator = ' ')
40
    {
1,626,148✔
41
        const auto [tok, rest] = peek_token_impl<T>();
1,626,148✔
42
        if (rest.empty()) {
1,626,148✔
43
            throw ProtocolCodecException("header line ended prematurely without terminator");
×
44
        }
×
45
        if (rest.front() != expected_terminator) {
1,626,148✔
46
            throw ProtocolCodecException(util::format(
×
47
                "expected to find delimeter '%1' in header line, but found '%2'", expected_terminator, rest.front()));
×
48
        }
×
49
        m_sv = rest.substr(1);
1,626,148✔
50
        return tok;
1,626,148✔
51
    }
1,626,148✔
52

53
    template <typename T>
54
    T read_sized_data(size_t size)
55
    {
106,626✔
56
        auto ret = m_sv;
106,626✔
57
        advance(size);
106,626✔
58
        return T(ret.data(), size);
106,626✔
59
    }
106,626✔
60

61
    size_t bytes_remaining() const noexcept
62
    {
84,410✔
63
        return m_sv.size();
84,410✔
64
    }
84,410✔
65

66
    std::string_view remaining() const noexcept
67
    {
98,748✔
68
        return m_sv;
98,748✔
69
    }
98,748✔
70

71
    bool at_end() const noexcept
72
    {
1,882,694✔
73
        return m_sv.empty();
1,882,694✔
74
    }
1,882,694✔
75

76
    void advance(size_t size)
77
    {
106,638✔
78
        if (size > m_sv.size()) {
106,638✔
79
            throw ProtocolCodecException(
×
80
                util::format("cannot advance header by %1 characters, only %2 characters left", size, m_sv.size()));
×
81
        }
×
82
        m_sv.remove_prefix(size);
106,638✔
83
    }
106,638✔
84

85
private:
86
    template <typename T>
87
    std::pair<T, std::string_view> peek_token_impl() const
88
    {
1,626,134✔
89
        // We currently only support numeric, string, and boolean values in header lines.
90
        static_assert(std::is_integral_v<T> || std::is_floating_point_v<T> ||
1,626,134✔
91
                      is_any_v<T, std::string_view, std::string>);
1,626,134✔
92
        if (at_end()) {
1,626,134✔
93
            throw ProtocolCodecException("reached end of header line prematurely");
×
94
        }
×
95
        if constexpr (is_any_v<T, std::string_view, std::string>) {
1,626,134✔
96
            // Currently all string fields in wire protocol header lines appear at the beginning of the line and
97
            // should be delimited by a space.
98
            auto delim_at = m_sv.find(' ');
838,506✔
99
            if (delim_at == std::string_view::npos) {
838,506✔
100
                throw ProtocolCodecException("reached end of header line prematurely");
×
101
            }
×
102

103
            return {m_sv.substr(0, delim_at), m_sv.substr(delim_at)};
149,740✔
104
        }
78,318✔
105
        else if constexpr (std::is_integral_v<T> && !std::is_same_v<T, bool>) {
1,422,764✔
106
            T cur_arg = {};
714,322✔
107
            auto parse_res = util::from_chars(m_sv.data(), m_sv.data() + m_sv.size(), cur_arg, 10);
714,322✔
108
            if (parse_res.ec != std::errc{}) {
1,364,078✔
109
                throw ProtocolCodecException(util::format("error parsing integer in header line: %1",
×
110
                                                          std::make_error_code(parse_res.ec).message()));
×
111
            }
×
112

113
            return {cur_arg, m_sv.substr(parse_res.ptr - m_sv.data())};
1,364,078✔
114
        }
708,398✔
115
        else if constexpr (std::is_same_v<T, bool>) {
110,562✔
116
            int cur_arg;
53,664✔
117
            auto parse_res = util::from_chars(m_sv.data(), m_sv.data() + m_sv.size(), cur_arg, 10);
53,664✔
118
            if (parse_res.ec != std::errc{}) {
108,820✔
119
                throw ProtocolCodecException(util::format("error parsing boolean in header line: %1",
×
120
                                                          std::make_error_code(parse_res.ec).message()));
×
121
            }
×
122

123
            return {(cur_arg != 0), m_sv.substr(parse_res.ptr - m_sv.data())};
108,820✔
124
        }
56,900✔
125
        else if constexpr (std::is_floating_point_v<T>) {
3,458✔
126
            // Currently all double are in the middle of the string delimited by a space.
127
            auto delim_at = m_sv.find(' ');
3,458✔
128
            if (delim_at == std::string_view::npos)
3,458✔
129
                throw ProtocolCodecException("reached end of header line prematurely for double value parsing");
×
130

131
            // FIXME use std::from_chars one day when it's availiable in every std lib
132
            T val = {};
3,458✔
133
            try {
3,458✔
134
                std::string str(m_sv.substr(0, delim_at));
3,458✔
135
                if constexpr (std::is_same_v<T, float>)
1,744✔
136
                    val = std::stof(str);
137
                else if constexpr (std::is_same_v<T, double>)
1,744✔
138
                    val = std::stod(str);
3,458✔
139
                else if constexpr (std::is_same_v<T, long double>)
1,744✔
140
                    val = std::stold(str);
1,744✔
141
            }
3,458✔
142
            catch (const std::exception& err) {
3,458✔
143
                throw ProtocolCodecException(
×
144
                    util::format("error parsing floating-point number in header line: %1", err.what()));
×
145
            }
×
146

147
            return {val, m_sv.substr(delim_at)};
3,458✔
148
        }
3,458✔
149
    }
1,626,134✔
150

151
    std::string_view m_sv;
152
};
153

154
class ClientProtocol {
155
public:
156
    // clang-format off
157
    using file_ident_type    = sync::file_ident_type;
158
    using version_type       = sync::version_type;
159
    using salt_type          = sync::salt_type;
160
    using timestamp_type     = sync::timestamp_type;
161
    using session_ident_type = sync::session_ident_type;
162
    using request_ident_type = sync::request_ident_type;
163
    using milliseconds_type  = sync::milliseconds_type;
164
    using SaltedFileIdent    = sync::SaltedFileIdent;
165
    using SaltedVersion      = sync::SaltedVersion;
166
    using DownloadCursor     = sync::DownloadCursor;
167
    using UploadCursor       = sync::UploadCursor;
168
    using SyncProgress       = sync::SyncProgress;
169
    // clang-format on
170

171
    using OutputBuffer = util::ResettableExpandableBufferOutputStream;
172
    using RemoteChangeset = sync::RemoteChangeset;
173
    using ReceivedChangesets = std::vector<RemoteChangeset>;
174

175
    /// Messages sent by the client.
176

177
    void make_pbs_bind_message(int protocol_version, OutputBuffer&, session_ident_type session_ident,
178
                               const std::string& server_path, const std::string& signed_user_token,
179
                               bool need_client_file_ident, bool is_subserver);
180

181
    void make_flx_bind_message(int protocol_version, OutputBuffer& out, session_ident_type session_ident,
182
                               const nlohmann::json& json_data, const std::string& signed_user_token,
183
                               bool need_client_file_ident, bool is_subserver);
184

185
    void make_pbs_ident_message(OutputBuffer&, session_ident_type session_ident, SaltedFileIdent client_file_ident,
186
                                const SyncProgress& progress);
187

188
    void make_flx_ident_message(OutputBuffer&, session_ident_type session_ident, SaltedFileIdent client_file_ident,
189
                                const SyncProgress& progress, int64_t query_version, std::string_view query_body);
190

191
    void make_query_change_message(OutputBuffer&, session_ident_type, int64_t version, std::string_view query_body);
192

193
    void make_json_error_message(OutputBuffer&, session_ident_type, int error_code, std::string_view error_body);
194

195
    void make_test_command_message(OutputBuffer&, session_ident_type session, request_ident_type request_ident,
196
                                   std::string_view body);
197

198
    class UploadMessageBuilder {
199
    public:
200
        UploadMessageBuilder(OutputBuffer& body_buffer, std::vector<char>& compression_buffer,
201
                             util::compression::CompressMemoryArena& compress_memory_arena);
202

203
        void add_changeset(version_type client_version, version_type server_version, timestamp_type origin_timestamp,
204
                           file_ident_type origin_file_ident, ChunkedBinaryData changeset);
205

206
        void make_upload_message(int protocol_version, OutputBuffer&, session_ident_type session_ident,
207
                                 version_type progress_client_version, version_type progress_server_version,
208
                                 version_type locked_server_version);
209

210
    private:
211
        std::size_t m_num_changesets = 0;
212
        OutputBuffer& m_body_buffer;
213
        std::vector<char>& m_compression_buffer;
214
        util::compression::CompressMemoryArena& m_compress_memory_arena;
215
    };
216

217
    UploadMessageBuilder make_upload_message_builder();
218

219
    void make_unbind_message(OutputBuffer&, session_ident_type session_ident);
220

221
    void make_mark_message(OutputBuffer&, session_ident_type session_ident, request_ident_type request_ident);
222

223
    void make_ping(OutputBuffer&, milliseconds_type timestamp, milliseconds_type rtt);
224

225
    std::string compressed_hex_dump(BinaryData blob);
226

227
    // Messages received by the client.
228

229
    // parse_message_received takes a (WebSocket) message and parses it.
230
    // The result of the parsing is handled by an object of type Connection.
231
    // Typically, Connection would be the Connection class from client.cpp
232
    template <class Connection>
233
    void parse_message_received(Connection& connection, std::string_view msg_data)
234
    {
79,658✔
235
        util::Logger& logger = connection.logger;
79,658✔
236
        auto report_error = [&](const auto fmt, auto&&... args) {
79,658✔
237
            auto msg = util::format(fmt, std::forward<decltype(args)>(args)...);
×
238
            connection.handle_protocol_error(Status{ErrorCodes::SyncProtocolInvariantFailed, std::move(msg)});
×
239
        };
×
240

241
        HeaderLineParser msg(msg_data);
79,658✔
242
        std::string_view message_type;
79,658✔
243
        try {
79,658✔
244
            message_type = msg.read_next<std::string_view>();
79,658✔
245
        }
79,658✔
246
        catch (const ProtocolCodecException& e) {
79,658✔
247
            return report_error("Could not find message type in message: %1", e.what());
×
248
        }
×
249

250
        try {
79,660✔
251
            if (message_type == "download") {
79,660✔
252
                parse_download_message(connection, msg);
48,796✔
253
            }
48,796✔
254
            else if (message_type == "pong") {
30,864✔
255
                auto timestamp = msg.read_next<milliseconds_type>('\n');
152✔
256
                connection.receive_pong(timestamp);
152✔
257
            }
152✔
258
            else if (message_type == "unbound") {
30,712✔
259
                auto session_ident = msg.read_next<session_ident_type>('\n');
4,132✔
260
                connection.receive_unbound_message(session_ident); // Throws
4,132✔
261
            }
4,132✔
262
            else if (message_type == "error") {
26,580✔
263
                auto error_code = msg.read_next<int>();
80✔
264
                auto message_size = msg.read_next<size_t>();
80✔
265
                auto is_fatal = sync::IsFatal{!msg.read_next<bool>()};
80✔
266
                auto session_ident = msg.read_next<session_ident_type>('\n');
80✔
267
                auto message = msg.read_sized_data<StringData>(message_size);
80✔
268

269
                connection.receive_error_message(sync::ProtocolErrorInfo{error_code, message, is_fatal},
80✔
270
                                                 session_ident); // Throws
80✔
271
            }
80✔
272
            else if (message_type == "log_message") { // introduced in protocol version 10
26,500✔
273
                parse_log_message(connection, msg);
6,032✔
274
            }
6,032✔
275
            else if (message_type == "json_error") { // introduced in protocol 4
20,468✔
276
                sync::ProtocolErrorInfo info{};
684✔
277
                info.raw_error_code = msg.read_next<int>();
684✔
278
                auto message_size = msg.read_next<size_t>();
684✔
279
                auto session_ident = msg.read_next<session_ident_type>('\n');
684✔
280
                auto json_raw = msg.read_sized_data<std::string_view>(message_size);
684✔
281
                try {
684✔
282
                    auto json = nlohmann::json::parse(json_raw);
684✔
283
                    logger.trace(util::LogCategory::session, "Error message encoded as json: %1", json_raw);
684✔
284
                    info.client_reset_recovery_is_disabled = json["isRecoveryModeDisabled"];
684✔
285
                    info.is_fatal = sync::IsFatal{!json["tryAgain"]};
684✔
286
                    info.message = json["message"];
684✔
287
                    info.log_url = std::make_optional<std::string>(json["logURL"]);
684✔
288
                    info.should_client_reset = std::make_optional<bool>(json["shouldClientReset"]);
684✔
289
                    info.server_requests_action = string_to_action(json["action"]); // Throws
684✔
290

291
                    if (auto backoff_interval = json.find("backoffIntervalSec"); backoff_interval != json.end()) {
684✔
292
                        info.resumption_delay_interval.emplace();
548✔
293
                        info.resumption_delay_interval->resumption_delay_interval =
548✔
294
                            std::chrono::seconds{backoff_interval->get<int>()};
548✔
295
                        info.resumption_delay_interval->max_resumption_delay_interval =
548✔
296
                            std::chrono::seconds{json.at("backoffMaxDelaySec").get<int>()};
548✔
297
                        info.resumption_delay_interval->resumption_delay_backoff_multiplier =
548✔
298
                            json.at("backoffMultiplier").get<int>();
548✔
299
                    }
548✔
300

301
                    if (info.raw_error_code == static_cast<int>(sync::ProtocolError::migrate_to_flx)) {
684✔
302
                        auto query_string = json.find("partitionQuery");
36✔
303
                        if (query_string == json.end() || !query_string->is_string() ||
36✔
304
                            query_string->get<std::string_view>().empty()) {
36✔
305
                            return report_error(
×
306
                                "Missing/invalid partition query string in migrate to flexible sync error response");
×
307
                        }
×
308

309
                        info.migration_query_string.emplace(query_string->get<std::string_view>());
36✔
310
                    }
36✔
311

312
                    if (info.raw_error_code == static_cast<int>(sync::ProtocolError::schema_version_changed)) {
684✔
313
                        auto schema_version = json.find("previousSchemaVersion");
68✔
314
                        if (schema_version == json.end() || !schema_version->is_number_unsigned()) {
68✔
315
                            return report_error(
×
316
                                "Missing/invalid previous schema version in schema migration error response");
×
317
                        }
×
318

319
                        info.previous_schema_version.emplace(schema_version->get<uint64_t>());
68✔
320
                    }
68✔
321

322
                    if (auto rejected_updates = json.find("rejectedUpdates"); rejected_updates != json.end()) {
684✔
323
                        if (!rejected_updates->is_array()) {
52✔
324
                            return report_error(
×
325
                                "Compensating writes error list is not stored in an array as expected");
×
326
                        }
×
327

328
                        for (const auto& rejected_update : *rejected_updates) {
60✔
329
                            if (!rejected_update.is_object()) {
60✔
330
                                return report_error(
×
331
                                    "Compensating write error information is not stored in an object as expected");
×
332
                            }
×
333

334
                            sync::CompensatingWriteErrorInfo cwei;
60✔
335
                            cwei.reason = rejected_update["reason"];
60✔
336
                            cwei.object_name = rejected_update["table"];
60✔
337
                            std::string_view pk = rejected_update["pk"].get<std::string_view>();
60✔
338
                            cwei.primary_key = sync::parse_base64_encoded_primary_key(pk);
60✔
339
                            info.compensating_writes.push_back(std::move(cwei));
60✔
340
                        }
60✔
341

342
                        // Not provided when 'write_not_allowed' (230) error is received from the server.
343
                        if (auto server_version = json.find("compensatingWriteServerVersion");
52✔
344
                            server_version != json.end()) {
52✔
345
                            info.compensating_write_server_version =
48✔
346
                                std::make_optional<version_type>(server_version->get<int64_t>());
48✔
347
                        }
48✔
348
                        info.compensating_write_rejected_client_version =
52✔
349
                            json.at("rejectedClientVersion").get<int64_t>();
52✔
350
                    }
52✔
351
                }
684✔
352
                catch (const nlohmann::json::exception& e) {
684✔
353
                    // If any of the above json fields are not present, this is a fatal error
354
                    // however, additional optional fields may be added in the future.
355
                    return report_error("Failed to parse 'json_error' with error_code %1: '%2'", info.raw_error_code,
×
356
                                        e.what());
×
357
                }
×
358
                connection.receive_error_message(info, session_ident); // Throws
684✔
359
            }
684✔
360
            else if (message_type == "query_error") {
19,784✔
361
                auto error_code = msg.read_next<int>();
20✔
362
                auto message_size = msg.read_next<size_t>();
20✔
363
                auto session_ident = msg.read_next<session_ident_type>();
20✔
364
                auto query_version = msg.read_next<int64_t>('\n');
20✔
365

366
                auto message = msg.read_sized_data<std::string_view>(message_size);
20✔
367

368
                connection.receive_query_error_message(error_code, message, query_version, session_ident); // throws
20✔
369
            }
20✔
370
            else if (message_type == "mark") {
19,764✔
371
                auto session_ident = msg.read_next<session_ident_type>();
16,314✔
372
                auto request_ident = msg.read_next<request_ident_type>('\n');
16,314✔
373

374
                connection.receive_mark_message(session_ident, request_ident); // Throws
16,314✔
375
            }
16,314✔
376
            else if (message_type == "ident") {
3,450✔
377
                session_ident_type session_ident = msg.read_next<session_ident_type>();
3,398✔
378
                SaltedFileIdent client_file_ident;
3,398✔
379
                client_file_ident.ident = msg.read_next<file_ident_type>();
3,398✔
380
                client_file_ident.salt = msg.read_next<salt_type>('\n');
3,398✔
381

382
                connection.receive_ident_message(session_ident, client_file_ident); // Throws
3,398✔
383
            }
3,398✔
384
            else if (message_type == "test_command") {
52✔
385
                session_ident_type session_ident = msg.read_next<session_ident_type>();
52✔
386
                request_ident_type request_ident = msg.read_next<request_ident_type>();
52✔
387
                auto body_size = msg.read_next<size_t>('\n');
52✔
388
                auto body = msg.read_sized_data<std::string_view>(body_size);
52✔
389

390
                connection.receive_test_command_response(session_ident, request_ident, body);
52✔
391
            }
52✔
UNCOV
392
            else {
×
UNCOV
393
                return report_error("Unknown input message type '%1'", msg_data);
×
UNCOV
394
            }
×
395
        }
79,660✔
396
        catch (const ProtocolCodecException& e) {
79,660✔
397
            return report_error("Bad syntax in %1 message: %2", message_type, e.what());
×
398
        }
×
399
        if (!msg.at_end()) {
79,660✔
400
            return report_error("wire protocol message had leftover data after being parsed");
×
401
        }
×
402
    }
79,660✔
403

404
    struct DownloadMessage {
405
        SyncProgress progress;
406
        std::optional<int64_t> query_version;
407
        std::optional<bool> last_in_batch;
408
        sync::DownloadableProgress downloadable;
409
        ReceivedChangesets changesets;
410
    };
411

412
private:
413
    template <typename Connection>
414
    void parse_download_message(Connection& connection, HeaderLineParser& msg)
415
    {
48,796✔
416
        bool is_flx = connection.is_flx_sync_connection();
48,796✔
417

418
        util::Logger& logger = connection.logger;
48,796✔
419
        auto report_error = [&](ErrorCodes::Error code, const auto fmt, auto&&... args) {
48,796✔
420
            auto msg = util::format(fmt, std::forward<decltype(args)>(args)...);
×
421
            connection.handle_protocol_error(Status{code, std::move(msg)});
×
422
        };
×
423

424
        auto msg_with_header = msg.remaining();
48,796✔
425
        auto session_ident = msg.read_next<session_ident_type>();
48,796✔
426

427
        DownloadMessage message;
48,796✔
428
        auto&& progress = message.progress;
48,796✔
429
        progress.download.server_version = msg.read_next<version_type>();
48,796✔
430
        progress.download.last_integrated_client_version = msg.read_next<version_type>();
48,796✔
431
        progress.latest_server_version.version = msg.read_next<version_type>();
48,796✔
432
        progress.latest_server_version.salt = msg.read_next<salt_type>();
48,796✔
433
        progress.upload.client_version = msg.read_next<version_type>();
48,796✔
434
        progress.upload.last_integrated_server_version = msg.read_next<version_type>();
48,796✔
435

436
        if (is_flx) {
48,796✔
437
            message.query_version = msg.read_next<int64_t>();
3,458✔
438
            if (message.query_version < 0)
3,458✔
439
                return report_error(ErrorCodes::SyncProtocolInvariantFailed, "Bad query version",
×
440
                                    message.query_version);
×
441

442
            message.last_in_batch = msg.read_next<bool>();
3,458✔
443

444
            double progress_estimate = msg.read_next<double>();
3,458✔
445
            if (progress_estimate < 0 || progress_estimate > 1)
3,458✔
446
                return report_error(ErrorCodes::SyncProtocolInvariantFailed, "Bad progress value: %1",
×
447
                                    progress_estimate);
×
448
            message.downloadable = progress_estimate;
3,458✔
449
        }
3,458✔
450
        else
45,338✔
451
            message.downloadable = uint64_t(msg.read_next<int64_t>());
45,338✔
452

453
        auto is_body_compressed = msg.read_next<bool>();
48,796✔
454
        auto uncompressed_body_size = msg.read_next<size_t>();
48,796✔
455
        auto compressed_body_size = msg.read_next<size_t>('\n');
48,796✔
456

457
        if (uncompressed_body_size > s_max_body_size) {
48,796✔
458
            auto header = msg_with_header.substr(0, msg_with_header.size() - msg.remaining().size());
×
459
            return report_error(ErrorCodes::LimitExceeded, "Limits exceeded in input message '%1'", header);
×
460
        }
×
461

462
        std::unique_ptr<char[]> uncompressed_body_buffer;
48,796✔
463
        // if is_body_compressed == true, we must decompress the received body.
464
        if (is_body_compressed) {
48,796✔
465
            uncompressed_body_buffer = std::make_unique<char[]>(uncompressed_body_size);
4,396✔
466
            std::error_code ec =
4,396✔
467
                util::compression::decompress({msg.remaining().data(), compressed_body_size},
4,396✔
468
                                              {uncompressed_body_buffer.get(), uncompressed_body_size});
4,396✔
469

470
            if (ec) {
4,396✔
471
                return report_error(ErrorCodes::RuntimeError, "compression::inflate: %1", ec.message());
×
472
            }
×
473

474
            msg = HeaderLineParser(std::string_view(uncompressed_body_buffer.get(), uncompressed_body_size));
4,396✔
475
        }
4,396✔
476

477
        logger.debug(util::LogCategory::changeset,
48,796✔
478
                     "Download message compression: session_ident=%1, is_body_compressed=%2, "
48,796✔
479
                     "compressed_body_size=%3, uncompressed_body_size=%4",
48,796✔
480
                     session_ident, is_body_compressed, compressed_body_size, uncompressed_body_size);
48,796✔
481

482
        // Loop through the body and find the changesets.
483
        while (!msg.at_end()) {
96,108✔
484
            RemoteChangeset cur_changeset;
47,312✔
485
            cur_changeset.remote_version = msg.read_next<version_type>();
47,312✔
486
            cur_changeset.last_integrated_local_version = msg.read_next<version_type>();
47,312✔
487
            cur_changeset.origin_timestamp = msg.read_next<timestamp_type>();
47,312✔
488
            cur_changeset.origin_file_ident = msg.read_next<file_ident_type>();
47,312✔
489
            cur_changeset.original_changeset_size = msg.read_next<size_t>();
47,312✔
490
            auto changeset_size = msg.read_next<size_t>();
47,312✔
491

492
            if (changeset_size > msg.bytes_remaining()) {
47,312✔
493
                return report_error(ErrorCodes::SyncProtocolInvariantFailed, "Bad changeset size %1 > %2",
×
494
                                    changeset_size, msg.bytes_remaining());
×
495
            }
×
496
            if (cur_changeset.remote_version == 0) {
47,312✔
497
                return report_error(ErrorCodes::SyncProtocolInvariantFailed,
×
498
                                    "Server version in downloaded changeset cannot be zero");
×
499
            }
×
500
            auto changeset_data = msg.read_sized_data<BinaryData>(changeset_size);
47,312✔
501
            logger.debug(util::LogCategory::changeset,
47,312✔
502
                         "Received: DOWNLOAD CHANGESET(session_ident=%1, server_version=%2, "
47,312✔
503
                         "client_version=%3, origin_timestamp=%4, origin_file_ident=%5, "
47,312✔
504
                         "original_changeset_size=%6, changeset_size=%7)",
47,312✔
505
                         session_ident, cur_changeset.remote_version, cur_changeset.last_integrated_local_version,
47,312✔
506
                         cur_changeset.origin_timestamp, cur_changeset.origin_file_ident,
47,312✔
507
                         cur_changeset.original_changeset_size, changeset_size); // Throws
47,312✔
508
            if (logger.would_log(util::LogCategory::changeset, util::Logger::Level::trace)) {
47,312✔
509
                if (changeset_data.size() < 1056) {
×
510
                    logger.trace(util::LogCategory::changeset, "Changeset: %1",
×
511
                                 clamped_hex_dump(changeset_data)); // Throws
×
512
                }
×
513
                else {
×
514
                    logger.trace(util::LogCategory::changeset, "Changeset(comp): %1 %2", changeset_data.size(),
×
515
                                 compressed_hex_dump(changeset_data)); // Throws
×
516
                }
×
517
#if REALM_DEBUG
×
518
                ChunkedBinaryInputStream in{changeset_data};
×
519
                sync::Changeset log;
×
520
                sync::parse_changeset(in, log);
×
521
                std::stringstream ss;
×
522
                log.print(ss);
×
523
                logger.trace(util::LogCategory::changeset, "Changeset (parsed):\n%1", ss.str());
×
524
#endif
×
525
            }
×
526

527
            cur_changeset.data = changeset_data;
47,312✔
528
            message.changesets.push_back(std::move(cur_changeset)); // Throws
47,312✔
529
        }
47,312✔
530

531
        connection.receive_download_message(session_ident, message); // Throws
48,796✔
532
    }
48,796✔
533

534
    static sync::ProtocolErrorInfo::Action string_to_action(const std::string& action_string)
535
    {
684✔
536
        using action = sync::ProtocolErrorInfo::Action;
684✔
537
        static const std::unordered_map<std::string, action> mapping{
684✔
538
            {"ProtocolViolation", action::ProtocolViolation},
684✔
539
            {"ApplicationBug", action::ApplicationBug},
684✔
540
            {"Warning", action::Warning},
684✔
541
            {"Transient", action::Transient},
684✔
542
            {"DeleteRealm", action::DeleteRealm},
684✔
543
            {"ClientReset", action::ClientReset},
684✔
544
            {"ClientResetNoRecovery", action::ClientResetNoRecovery},
684✔
545
            {"MigrateToFLX", action::MigrateToFLX},
684✔
546
            {"RevertToPBS", action::RevertToPBS},
684✔
547
            {"RefreshUser", action::RefreshUser},
684✔
548
            {"RefreshLocation", action::RefreshLocation},
684✔
549
            {"LogOutUser", action::LogOutUser},
684✔
550
            {"MigrateSchema", action::MigrateSchema},
684✔
551
        };
684✔
552

553
        if (auto action_it = mapping.find(action_string); action_it != mapping.end()) {
684✔
554
            return action_it->second;
676✔
555
        }
676✔
556
        return action::ApplicationBug;
8✔
557
    }
684✔
558

559
    template <typename Connection>
560
    void parse_log_message(Connection& connection, HeaderLineParser& msg)
561
    {
6,034✔
562
        auto report_error = [&](const auto fmt, auto&&... args) {
6,034✔
563
            auto msg = util::format(fmt, std::forward<decltype(args)>(args)...);
×
564
            connection.handle_protocol_error(Status{ErrorCodes::SyncProtocolInvariantFailed, std::move(msg)});
×
565
        };
×
566

567
        auto session_ident = msg.read_next<session_ident_type>();
6,034✔
568
        auto message_length = msg.read_next<size_t>('\n');
6,034✔
569
        auto message_body_str = msg.read_sized_data<std::string_view>(message_length);
6,034✔
570
        nlohmann::json message_body;
6,034✔
571
        try {
6,034✔
572
            message_body = nlohmann::json::parse(message_body_str);
6,034✔
573
        }
6,034✔
574
        catch (const nlohmann::json::exception& e) {
6,034✔
575
            return report_error("Malformed json in log_message message: \"%1\": %2", message_body_str, e.what());
×
576
        }
×
577
        static const std::unordered_map<std::string_view, util::Logger::Level> name_to_level = {
6,034✔
578
            {"fatal", util::Logger::Level::fatal},   {"error", util::Logger::Level::error},
6,034✔
579
            {"warn", util::Logger::Level::warn},     {"info", util::Logger::Level::info},
6,034✔
580
            {"detail", util::Logger::Level::detail}, {"debug", util::Logger::Level::debug},
6,034✔
581
            {"trace", util::Logger::Level::trace},
6,034✔
582
        };
6,034✔
583

584
        // See if the log_message contains the appservices_request_id
585
        if (auto it = message_body.find("co_id"); it != message_body.end() && it->is_string()) {
6,034✔
586
            connection.receive_appservices_request_id(it->get<std::string_view>());
2,018✔
587
        }
2,018✔
588

589
        std::string_view log_level;
6,034✔
590
        bool has_level = false;
6,034✔
591
        if (auto it = message_body.find("level"); it != message_body.end() && it->is_string()) {
6,034✔
592
            log_level = it->get<std::string_view>();
6,034✔
593
            has_level = !log_level.empty();
6,034✔
594
        }
6,034✔
595

596
        std::string_view msg_text;
6,034✔
597
        if (auto it = message_body.find("msg"); it != message_body.end() && it->is_string()) {
6,034✔
598
            msg_text = it->get<std::string_view>();
6,034✔
599
        }
6,034✔
600

601
        // If there is no message text, then we're done
602
        if (msg_text.empty()) {
6,034✔
603
            return;
×
604
        }
×
605

606
        // If a log level wasn't provided, default to debug
607
        util::Logger::Level parsed_level = util::Logger::Level::debug;
6,034✔
608
        if (has_level) {
6,034✔
609
            if (auto it = name_to_level.find(log_level); it != name_to_level.end()) {
6,034✔
610
                parsed_level = it->second;
6,034✔
611
            }
6,034✔
612
            else {
×
613
                return report_error("Unknown log level found in log_message: \"%1\"", log_level);
×
614
            }
×
615
        }
6,034✔
616
        connection.receive_server_log_message(session_ident, parsed_level, msg_text);
6,034✔
617
    }
6,034✔
618

619
    static constexpr std::size_t s_max_body_size = std::numeric_limits<std::size_t>::max();
620

621
    // Permanent buffer to use for building messages.
622
    OutputBuffer m_output_buffer;
623

624
    // Permanent buffers to use for internal purposes such as compression.
625
    std::vector<char> m_buffer;
626

627
    util::compression::CompressMemoryArena m_compress_memory_arena;
628
};
629

630

631
class ServerProtocol {
632
public:
633
    // clang-format off
634
    using file_ident_type    = sync::file_ident_type;
635
    using version_type       = sync::version_type;
636
    using salt_type          = sync::salt_type;
637
    using timestamp_type     = sync::timestamp_type;
638
    using session_ident_type = sync::session_ident_type;
639
    using request_ident_type = sync::request_ident_type;
640
    using SaltedFileIdent    = sync::SaltedFileIdent;
641
    using SaltedVersion      = sync::SaltedVersion;
642
    using milliseconds_type  = sync::milliseconds_type;
643
    using UploadCursor       = sync::UploadCursor;
644
    // clang-format on
645

646
    using OutputBuffer = util::ResettableExpandableBufferOutputStream;
647

648
    // Messages sent by the server to the client
649

650
    void make_ident_message(int protocol_version, OutputBuffer&, session_ident_type session_ident,
651
                            file_ident_type client_file_ident, salt_type client_file_ident_salt);
652

653
    void make_alloc_message(OutputBuffer&, session_ident_type session_ident, file_ident_type file_ident);
654

655
    void make_unbound_message(OutputBuffer&, session_ident_type session_ident);
656

657

658
    struct ChangesetInfo {
659
        version_type server_version;
660
        version_type client_version;
661
        sync::HistoryEntry entry;
662
        std::size_t original_size;
663
    };
664

665
    void make_download_message(int protocol_version, OutputBuffer&, session_ident_type session_ident,
666
                               version_type download_server_version, version_type download_client_version,
667
                               version_type latest_server_version, salt_type latest_server_version_salt,
668
                               version_type upload_client_version, version_type upload_server_version,
669
                               std::uint_fast64_t downloadable_bytes, std::size_t num_changesets, const char* body,
670
                               std::size_t uncompressed_body_size, std::size_t compressed_body_size,
671
                               bool body_is_compressed, util::Logger&);
672

673
    void make_mark_message(OutputBuffer&, session_ident_type session_ident, request_ident_type request_ident);
674

675
    void make_error_message(int protocol_version, OutputBuffer&, sync::ProtocolError error_code, const char* message,
676
                            std::size_t message_size, bool try_again, session_ident_type session_ident);
677

678
    void make_pong(OutputBuffer&, milliseconds_type timestamp);
679

680
    void make_log_message(OutputBuffer& out, util::Logger::Level level, std::string message,
681
                          session_ident_type sess_id = 0, std::optional<std::string> co_id = std::nullopt);
682

683
    // Messages received by the server.
684

685
    // parse_ping_received takes a (WebSocket) ping and parses it.
686
    // The result of the parsing is handled by an object of type Connection.
687
    // Typically, Connection would be the Connection class from server.cpp
688
    template <typename Connection>
689
    void parse_ping_received(Connection& connection, std::string_view msg_data)
690
    {
×
691
        try {
×
692
            HeaderLineParser msg(msg_data);
×
693
            auto timestamp = msg.read_next<milliseconds_type>();
×
694
            auto rtt = msg.read_next<milliseconds_type>('\n');
×
695

696
            connection.receive_ping(timestamp, rtt);
×
697
        }
×
698
        catch (const ProtocolCodecException& e) {
×
699
            connection.handle_protocol_error(Status{ErrorCodes::SyncProtocolInvariantFailed,
×
700
                                                    util::format("Bad syntax in PING message: %1", e.what())});
×
701
        }
×
702
    }
×
703

704
    // UploadChangeset is used to store received changesets in
705
    // the UPLOAD message.
706
    struct UploadChangeset {
707
        UploadCursor upload_cursor;
708
        timestamp_type origin_timestamp;
709
        file_ident_type origin_file_ident; // Zero when originating from connected client file
710
        BinaryData changeset;
711
    };
712

713
    // parse_message_received takes a (WebSocket) message and parses it.
714
    // The result of the parsing is handled by an object of type Connection.
715
    // Typically, Connection would be the Connection class from server.cpp
716
    template <class Connection>
717
    void parse_message_received(Connection& connection, std::string_view msg_data)
718
    {
70,126✔
719
        auto& logger = connection.logger;
70,126✔
720

721
        auto report_error = [&](ErrorCodes::Error err, const auto fmt, auto&&... args) {
70,126✔
722
            auto msg = util::format(fmt, std::forward<decltype(args)>(args)...);
×
723
            connection.handle_protocol_error(Status{err, std::move(msg)});
×
724
        };
×
725

726
        HeaderLineParser msg(msg_data);
70,126✔
727
        std::string_view message_type;
70,126✔
728
        try {
70,126✔
729
            message_type = msg.read_next<std::string_view>();
70,126✔
730
        }
70,126✔
731
        catch (const ProtocolCodecException& e) {
70,126✔
732
            return report_error(ErrorCodes::SyncProtocolInvariantFailed, "Could not find message type in message: %1",
×
733
                                e.what());
×
734
        }
×
735

736
        try {
70,128✔
737
            if (message_type == "upload") {
70,128✔
738
                auto msg_with_header = msg.remaining();
45,582✔
739
                auto session_ident = msg.read_next<session_ident_type>();
45,582✔
740
                auto is_body_compressed = msg.read_next<bool>();
45,582✔
741
                auto uncompressed_body_size = msg.read_next<size_t>();
45,582✔
742
                auto compressed_body_size = msg.read_next<size_t>();
45,582✔
743
                auto progress_client_version = msg.read_next<version_type>();
45,582✔
744
                auto progress_server_version = msg.read_next<version_type>();
45,582✔
745
                auto locked_server_version = msg.read_next<version_type>('\n');
45,582✔
746

747
                std::size_t body_size = (is_body_compressed ? compressed_body_size : uncompressed_body_size);
45,582✔
748
                if (body_size > s_max_body_size) {
45,582✔
749
                    auto header = msg_with_header.substr(0, msg_with_header.size() - msg.bytes_remaining());
×
750

751
                    return report_error(ErrorCodes::LimitExceeded,
×
752
                                        "Body size of upload message is too large. Raw header: %1", header);
×
753
                }
×
754

755

756
                std::unique_ptr<char[]> uncompressed_body_buffer;
45,582✔
757
                // if is_body_compressed == true, we must decompress the received body.
758
                if (is_body_compressed) {
45,582✔
759
                    uncompressed_body_buffer = std::make_unique<char[]>(uncompressed_body_size);
4,436✔
760
                    auto compressed_body = msg.read_sized_data<BinaryData>(compressed_body_size);
4,436✔
761

762
                    std::error_code ec = util::compression::decompress(
4,436✔
763
                        compressed_body, {uncompressed_body_buffer.get(), uncompressed_body_size});
4,436✔
764

765
                    if (ec) {
4,436✔
766
                        return report_error(ErrorCodes::RuntimeError, "compression::inflate: %1", ec.message());
×
767
                    }
×
768

769
                    msg = HeaderLineParser(std::string_view(uncompressed_body_buffer.get(), uncompressed_body_size));
4,436✔
770
                }
4,436✔
771

772
                logger.debug(util::LogCategory::changeset,
45,582✔
773
                             "Upload message compression: is_body_compressed = %1, "
45,582✔
774
                             "compressed_body_size=%2, uncompressed_body_size=%3, "
45,582✔
775
                             "progress_client_version=%4, progress_server_version=%5, "
45,582✔
776
                             "locked_server_version=%6",
45,582✔
777
                             is_body_compressed, compressed_body_size, uncompressed_body_size,
45,582✔
778
                             progress_client_version, progress_server_version, locked_server_version); // Throws
45,582✔
779

780

781
                std::vector<UploadChangeset> upload_changesets;
45,582✔
782

783
                // Loop through the body and find the changesets.
784
                while (!msg.at_end()) {
82,706✔
785
                    UploadChangeset upload_changeset;
37,128✔
786
                    size_t changeset_size;
37,128✔
787
                    try {
37,128✔
788
                        upload_changeset.upload_cursor.client_version = msg.read_next<version_type>();
37,128✔
789
                        upload_changeset.upload_cursor.last_integrated_server_version = msg.read_next<version_type>();
37,128✔
790
                        upload_changeset.origin_timestamp = msg.read_next<timestamp_type>();
37,128✔
791
                        upload_changeset.origin_file_ident = msg.read_next<file_ident_type>();
37,128✔
792
                        changeset_size = msg.read_next<size_t>();
37,128✔
793
                    }
37,128✔
794
                    catch (const ProtocolCodecException& e) {
37,128✔
795
                        return report_error(ErrorCodes::SyncProtocolInvariantFailed,
×
796
                                            "Bad changeset header syntax: %1", e.what());
×
797
                    }
×
798

799
                    if (changeset_size > msg.bytes_remaining()) {
37,124✔
800
                        return report_error(ErrorCodes::SyncProtocolInvariantFailed, "Bad changeset size");
×
801
                    }
×
802

803
                    upload_changeset.changeset = msg.read_sized_data<BinaryData>(changeset_size);
37,124✔
804

805
                    if (logger.would_log(util::Logger::Level::trace)) {
37,124✔
806
                        logger.trace(util::LogCategory::changeset,
×
807
                                     "Received: UPLOAD CHANGESET(client_version=%1, server_version=%2, "
×
808
                                     "origin_timestamp=%3, origin_file_ident=%4, changeset_size=%5)",
×
809
                                     upload_changeset.upload_cursor.client_version,
×
810
                                     upload_changeset.upload_cursor.last_integrated_server_version,
×
811
                                     upload_changeset.origin_timestamp, upload_changeset.origin_file_ident,
×
812
                                     changeset_size); // Throws
×
813
                        logger.trace(util::LogCategory::changeset, "Changeset: %1",
×
814
                                     clamped_hex_dump(upload_changeset.changeset)); // Throws
×
815
                    }
×
816
                    upload_changesets.push_back(std::move(upload_changeset)); // Throws
37,124✔
817
                }
37,124✔
818

819
                connection.receive_upload_message(session_ident, progress_client_version, progress_server_version,
45,578✔
820
                                                  locked_server_version,
45,578✔
821
                                                  upload_changesets); // Throws
45,578✔
822
            }
45,578✔
823
            else if (message_type == "mark") {
24,546✔
824
                auto session_ident = msg.read_next<session_ident_type>();
12,060✔
825
                auto request_ident = msg.read_next<request_ident_type>('\n');
12,060✔
826

827
                connection.receive_mark_message(session_ident, request_ident); // Throws
12,060✔
828
            }
12,060✔
829
            else if (message_type == "ping") {
12,486✔
830
                auto timestamp = msg.read_next<milliseconds_type>();
104✔
831
                auto rtt = msg.read_next<milliseconds_type>('\n');
104✔
832

833
                connection.receive_ping(timestamp, rtt);
104✔
834
            }
104✔
835
            else if (message_type == "bind") {
12,382✔
836
                auto session_ident = msg.read_next<session_ident_type>();
5,456✔
837
                auto path_size = msg.read_next<size_t>();
5,456✔
838
                auto signed_user_token_size = msg.read_next<size_t>();
5,456✔
839
                auto need_client_file_ident = msg.read_next<bool>();
5,456✔
840
                auto is_subserver = msg.read_next<bool>('\n');
5,456✔
841

842
                if (path_size == 0) {
5,456✔
843
                    return report_error(ErrorCodes::SyncProtocolInvariantFailed, "Path size in BIND message is zero");
×
844
                }
×
845
                if (path_size > s_max_path_size) {
5,456✔
846
                    return report_error(ErrorCodes::SyncProtocolInvariantFailed,
×
847
                                        "Path size in BIND message is too large");
×
848
                }
×
849
                if (signed_user_token_size > s_max_signed_user_token_size) {
5,456✔
850
                    return report_error(ErrorCodes::SyncProtocolInvariantFailed,
×
851
                                        "Signed user token size in BIND message is too large");
×
852
                }
×
853

854
                auto path = msg.read_sized_data<std::string>(path_size);
5,456✔
855
                auto signed_user_token = msg.read_sized_data<std::string>(signed_user_token_size);
5,456✔
856

857
                connection.receive_bind_message(session_ident, std::move(path), std::move(signed_user_token),
5,456✔
858
                                                need_client_file_ident, is_subserver); // Throws
5,456✔
859
            }
5,456✔
860
            else if (message_type == "ident") {
6,926✔
861
                auto session_ident = msg.read_next<session_ident_type>();
4,364✔
862
                auto client_file_ident = msg.read_next<file_ident_type>();
4,364✔
863
                auto client_file_ident_salt = msg.read_next<salt_type>();
4,364✔
864
                auto scan_server_version = msg.read_next<version_type>();
4,364✔
865
                auto scan_client_version = msg.read_next<version_type>();
4,364✔
866
                auto latest_server_version = msg.read_next<version_type>();
4,364✔
867
                auto latest_server_version_salt = msg.read_next<salt_type>('\n');
4,364✔
868

869
                connection.receive_ident_message(session_ident, client_file_ident, client_file_ident_salt,
4,364✔
870
                                                 scan_server_version, scan_client_version, latest_server_version,
4,364✔
871
                                                 latest_server_version_salt); // Throws
4,364✔
872
            }
4,364✔
873
            else if (message_type == "unbind") {
2,564✔
874
                auto session_ident = msg.read_next<session_ident_type>('\n');
2,562✔
875

876
                connection.receive_unbind_message(session_ident); // Throws
2,562✔
877
            }
2,562✔
878
            else if (message_type == "json_error") {
2,147,483,649✔
879
                auto error_code = msg.read_next<int>();
×
880
                auto message_size = msg.read_next<size_t>();
×
881
                auto session_ident = msg.read_next<session_ident_type>('\n');
×
882
                auto json_raw = msg.read_sized_data<std::string_view>(message_size);
×
883

884
                connection.receive_error_message(session_ident, error_code, json_raw);
×
885
            }
×
886
            else {
2,147,483,649✔
887
                return report_error(ErrorCodes::SyncProtocolInvariantFailed, "unknown message type %1", message_type);
2,147,483,649✔
888
            }
2,147,483,649✔
889
        }
70,128✔
890
        catch (const ProtocolCodecException& e) {
70,128✔
891
            return report_error(ErrorCodes::SyncProtocolInvariantFailed, "bad syntax in %1 message: %2", message_type,
×
892
                                e.what());
×
893
        }
×
894
    }
70,128✔
895

896
    void insert_single_changeset_download_message(OutputBuffer&, const ChangesetInfo&, util::Logger&);
897

898
private:
899
    // clang-format off
900
    static constexpr std::size_t s_max_head_size              =  256;
901
    static constexpr std::size_t s_max_signed_user_token_size = 2048;
902
    static constexpr std::size_t s_max_client_info_size       = 1024;
903
    static constexpr std::size_t s_max_path_size              = 1024;
904
    static constexpr std::size_t s_max_changeset_size         = std::numeric_limits<std::size_t>::max(); // FIXME: What is a reasonable value here?
905
    static constexpr std::size_t s_max_body_size              = std::numeric_limits<std::size_t>::max();
906
    // clang-format on
907
};
908

909
// make_authorization_header() makes the value of the Authorization header used in the
910
// sync Websocket handshake.
911
std::string make_authorization_header(const std::string& signed_user_token);
912

913
// parse_authorization_header() parses the value of the Authorization header and returns
914
// the signed_user_token. None is returned in case of syntax error.
915
util::Optional<StringData> parse_authorization_header(const std::string& authorization_header);
916

917
} // namespace realm::_impl
918

919
#endif // REALM_NOINST_PROTOCOL_CODEC_HPP
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc