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

realm / realm-core / thomas.goyne_478

02 Aug 2024 05:19PM UTC coverage: 91.089% (-0.01%) from 91.1%
thomas.goyne_478

Pull #7944

Evergreen

tgoyne
Only track pending client resets done by the same core version

If the previous attempt at performing a client reset was done with a different
core version then we should retry the client reset as the new version may have
fixed a bug that made the previous attempt fail (or may be a downgrade to a
version before when the bug was introduced). This also simplifies the tracking
as it means that we don't need to be able to read trackers created by different
versions.

This also means that we can freely change the schema of the table, which this
takes advantage of to drop the unused primary key and make the error required,
as we never actually stored null and the code reading it would have crashed if
it encountered a null error.
Pull Request #7944: Only track pending client resets done by the same core version

102704 of 181534 branches covered (56.58%)

138 of 153 new or added lines in 10 files covered. (90.2%)

85 existing lines in 16 files now uncovered.

216717 of 237917 relevant lines covered (91.09%)

5947762.1 hits per line

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

74.0
/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)
76,344✔
35
    {
159,648✔
36
    }
159,648✔
37

38
    template <typename T>
39
    T read_next(char expected_terminator = ' ')
40
    {
1,636,738✔
41
        const auto [tok, rest] = peek_token_impl<T>();
1,636,738✔
42
        if (rest.empty()) {
1,636,738✔
43
            throw ProtocolCodecException("header line ended prematurely without terminator");
×
44
        }
×
45
        if (rest.front() != expected_terminator) {
1,636,738✔
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,636,738✔
50
        return tok;
1,636,738✔
51
    }
1,636,738✔
52

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

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

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

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

76
    void advance(size_t size)
77
    {
106,856✔
78
        if (size > m_sv.size()) {
106,856✔
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,856✔
83
    }
106,856✔
84

85
private:
86
    template <typename T>
87
    std::pair<T, std::string_view> peek_token_impl() const
88
    {
1,636,656✔
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,636,656✔
91
                      is_any_v<T, std::string_view, std::string>);
1,636,656✔
92
        if (at_end()) {
1,636,656✔
93
            throw ProtocolCodecException("reached end of header line prematurely");
×
94
        }
×
95
        if constexpr (is_any_v<T, std::string_view, std::string>) {
1,636,656✔
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(' ');
842,062✔
99
            if (delim_at == std::string_view::npos) {
842,062✔
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,318✔
104
        }
78,034✔
105
        else if constexpr (std::is_integral_v<T> && !std::is_same_v<T, bool>) {
1,435,034✔
106
            T cur_arg = {};
721,586✔
107
            auto parse_res = util::from_chars(m_sv.data(), m_sv.data() + m_sv.size(), cur_arg, 10);
721,586✔
108
            if (parse_res.ec != std::errc{}) {
1,377,688✔
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,377,688✔
114
        }
713,432✔
115
        else if constexpr (std::is_same_v<T, bool>) {
106,886✔
116
            int cur_arg;
52,312✔
117
            auto parse_res = util::from_chars(m_sv.data(), m_sv.data() + m_sv.size(), cur_arg, 10);
52,312✔
118
            if (parse_res.ec != std::errc{}) {
104,128✔
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())};
104,128✔
124
        }
54,572✔
125
        else if constexpr (std::is_floating_point_v<T>) {
5,526✔
126
            // Currently all double are in the middle of the string delimited by a space.
127
            auto delim_at = m_sv.find(' ');
5,526✔
128
            if (delim_at == std::string_view::npos)
5,526✔
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 = {};
5,526✔
133
            try {
5,526✔
134
                std::string str(m_sv.substr(0, delim_at));
5,526✔
135
                if constexpr (std::is_same_v<T, float>)
2,756✔
136
                    val = std::stof(str);
137
                else if constexpr (std::is_same_v<T, double>)
2,756✔
138
                    val = std::stod(str);
5,526✔
139
                else if constexpr (std::is_same_v<T, long double>)
2,756✔
140
                    val = std::stold(str);
2,756✔
141
            }
5,526✔
142
            catch (const std::exception& err) {
5,526✔
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)};
5,526✔
148
        }
5,526✔
149
    }
1,636,656✔
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
    {
81,254✔
235
        util::Logger& logger = connection.logger;
81,254✔
236
        auto report_error = [&](const auto fmt, auto&&... args) {
81,254✔
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);
81,254✔
242
        std::string_view message_type;
81,254✔
243
        try {
81,254✔
244
            message_type = msg.read_next<std::string_view>();
81,254✔
245
        }
81,254✔
246
        catch (const ProtocolCodecException& e) {
81,254✔
247
            return report_error("Could not find message type in message: %1", e.what());
×
248
        }
×
249

250
        try {
81,252✔
251
            if (message_type == "download") {
81,252✔
252
                parse_download_message(connection, msg);
49,492✔
253
            }
49,492✔
254
            else if (message_type == "pong") {
31,760✔
255
                auto timestamp = msg.read_next<milliseconds_type>('\n');
164✔
256
                connection.receive_pong(timestamp);
164✔
257
            }
164✔
258
            else if (message_type == "unbound") {
31,596✔
259
                auto session_ident = msg.read_next<session_ident_type>('\n');
3,976✔
260
                connection.receive_unbound_message(session_ident); // Throws
3,976✔
261
            }
3,976✔
262
            else if (message_type == "error") {
27,620✔
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
27,540✔
273
                parse_log_message(connection, msg);
5,920✔
274
            }
5,920✔
275
            else if (message_type == "json_error") { // introduced in protocol 4
21,620✔
276
                sync::ProtocolErrorInfo info{};
890✔
277
                info.raw_error_code = msg.read_next<int>();
890✔
278
                auto message_size = msg.read_next<size_t>();
890✔
279
                auto session_ident = msg.read_next<session_ident_type>('\n');
890✔
280
                auto json_raw = msg.read_sized_data<std::string_view>(message_size);
890✔
281
                try {
890✔
282
                    auto json = nlohmann::json::parse(json_raw);
890✔
283
                    logger.trace(util::LogCategory::session, "Error message encoded as json: %1", json_raw);
890✔
284
                    info.client_reset_recovery_is_disabled = json["isRecoveryModeDisabled"];
890✔
285
                    info.is_fatal = sync::IsFatal{!json["tryAgain"]};
890✔
286
                    info.message = json["message"];
890✔
287
                    info.log_url = std::make_optional<std::string>(json["logURL"]);
890✔
288
                    info.should_client_reset = std::make_optional<bool>(json["shouldClientReset"]);
890✔
289
                    info.server_requests_action = string_to_action(json["action"]); // Throws
890✔
290

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

301
                    if (info.raw_error_code == static_cast<int>(sync::ProtocolError::migrate_to_flx)) {
890✔
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)) {
890✔
313
                        auto schema_version = json.find("previousSchemaVersion");
70✔
314
                        if (schema_version == json.end() || !schema_version->is_number_unsigned()) {
70✔
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>());
70✔
320
                    }
70✔
321

322
                    if (auto rejected_updates = json.find("rejectedUpdates"); rejected_updates != json.end()) {
890✔
323
                        if (!rejected_updates->is_array()) {
72✔
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) {
136✔
329
                            if (!rejected_update.is_object()) {
136✔
330
                                return report_error(
×
331
                                    "Compensating write error information is not stored in an object as expected");
×
332
                            }
×
333

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

342
                        // Not provided when 'write_not_allowed' (230) error is received from the server.
343
                        if (auto server_version = json.find("compensatingWriteServerVersion");
72✔
344
                            server_version != json.end()) {
72✔
345
                            info.compensating_write_server_version =
68✔
346
                                std::make_optional<version_type>(server_version->get<int64_t>());
68✔
347
                        }
68✔
348
                        info.compensating_write_rejected_client_version =
72✔
349
                            json.at("rejectedClientVersion").get<int64_t>();
72✔
350
                    }
72✔
351
                }
890✔
352
                catch (const nlohmann::json::exception& e) {
890✔
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
890✔
359
            }
890✔
360
            else if (message_type == "query_error") {
20,730✔
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") {
20,710✔
371
                auto session_ident = msg.read_next<session_ident_type>();
17,026✔
372
                auto request_ident = msg.read_next<request_ident_type>('\n');
17,026✔
373

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

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

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

404
    struct DownloadMessage {
405
        SyncProgress progress;
406
        std::optional<int64_t> query_version; // FLX sync only
407
        sync::DownloadBatchState batch_state = sync::DownloadBatchState::SteadyState;
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
    {
49,490✔
416
        bool is_flx = connection.is_flx_sync_connection();
49,490✔
417

418
        util::Logger& logger = connection.logger;
49,490✔
419
        auto report_error = [&](ErrorCodes::Error code, const auto fmt, auto&&... args) {
49,490✔
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();
49,490✔
425
        auto session_ident = msg.read_next<session_ident_type>();
49,490✔
426

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

436
        if (is_flx) {
49,490✔
437
            message.query_version = msg.read_next<int64_t>();
5,526✔
438
            if (message.query_version < 0)
5,526✔
439
                return report_error(ErrorCodes::SyncProtocolInvariantFailed, "Bad query version: %1",
×
440
                                    message.query_version);
×
441
            int batch_state = msg.read_next<int>();
5,526✔
442
            if (batch_state != static_cast<int>(sync::DownloadBatchState::MoreToCome) &&
5,526✔
443
                batch_state != static_cast<int>(sync::DownloadBatchState::LastInBatch) &&
5,526✔
444
                batch_state != static_cast<int>(sync::DownloadBatchState::SteadyState)) {
5,526✔
445
                return report_error(ErrorCodes::SyncProtocolInvariantFailed, "Bad batch state: %1", batch_state);
×
446
            }
×
447
            message.batch_state = static_cast<sync::DownloadBatchState>(batch_state);
5,526✔
448

449
            double progress_estimate = msg.read_next<double>();
5,526✔
450
            if (progress_estimate < 0 || progress_estimate > 1)
5,526✔
451
                return report_error(ErrorCodes::SyncProtocolInvariantFailed, "Bad progress value: %1",
×
452
                                    progress_estimate);
×
453
            message.downloadable = progress_estimate;
5,526✔
454
        }
5,526✔
455
        else
43,964✔
456
            message.downloadable = uint64_t(msg.read_next<int64_t>());
43,964✔
457

458
        auto is_body_compressed = msg.read_next<bool>();
49,490✔
459
        auto uncompressed_body_size = msg.read_next<size_t>();
49,490✔
460
        auto compressed_body_size = msg.read_next<size_t>('\n');
49,490✔
461

462
        if (uncompressed_body_size > s_max_body_size) {
49,490✔
463
            auto header = msg_with_header.substr(0, msg_with_header.size() - msg.remaining().size());
×
464
            return report_error(ErrorCodes::LimitExceeded, "Limits exceeded in input message '%1'", header);
×
465
        }
×
466

467
        std::unique_ptr<char[]> uncompressed_body_buffer;
49,490✔
468
        // if is_body_compressed == true, we must decompress the received body.
469
        if (is_body_compressed) {
49,490✔
470
            uncompressed_body_buffer = std::make_unique<char[]>(uncompressed_body_size);
5,862✔
471
            std::error_code ec =
5,862✔
472
                util::compression::decompress({msg.remaining().data(), compressed_body_size},
5,862✔
473
                                              {uncompressed_body_buffer.get(), uncompressed_body_size});
5,862✔
474

475
            if (ec) {
5,862✔
476
                return report_error(ErrorCodes::RuntimeError, "compression::inflate: %1", ec.message());
×
477
            }
×
478

479
            msg = HeaderLineParser(std::string_view(uncompressed_body_buffer.get(), uncompressed_body_size));
5,862✔
480
        }
5,862✔
481

482
        logger.debug(util::LogCategory::changeset,
49,490✔
483
                     "Download message compression: session_ident=%1, is_body_compressed=%2, "
49,490✔
484
                     "compressed_body_size=%3, uncompressed_body_size=%4",
49,490✔
485
                     session_ident, is_body_compressed, compressed_body_size, uncompressed_body_size);
49,490✔
486

487
        // Loop through the body and find the changesets.
488
        while (!msg.at_end()) {
98,198✔
489
            RemoteChangeset cur_changeset;
48,708✔
490
            cur_changeset.remote_version = msg.read_next<version_type>();
48,708✔
491
            cur_changeset.last_integrated_local_version = msg.read_next<version_type>();
48,708✔
492
            cur_changeset.origin_timestamp = msg.read_next<timestamp_type>();
48,708✔
493
            cur_changeset.origin_file_ident = msg.read_next<file_ident_type>();
48,708✔
494
            cur_changeset.original_changeset_size = msg.read_next<size_t>();
48,708✔
495
            auto changeset_size = msg.read_next<size_t>();
48,708✔
496

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

532
            cur_changeset.data = changeset_data;
48,708✔
533
            message.changesets.push_back(std::move(cur_changeset)); // Throws
48,708✔
534
        }
48,708✔
535

536
        connection.receive_download_message(session_ident, message); // Throws
49,490✔
537
    }
49,490✔
538

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

558
        if (auto action_it = mapping.find(action_string); action_it != mapping.end()) {
890✔
559
            return action_it->second;
882✔
560
        }
882✔
561
        return action::ApplicationBug;
8✔
562
    }
890✔
563

564
    template <typename Connection>
565
    void parse_log_message(Connection& connection, HeaderLineParser& msg)
566
    {
5,920✔
567
        auto report_error = [&](const auto fmt, auto&&... args) {
5,920✔
568
            auto msg = util::format(fmt, std::forward<decltype(args)>(args)...);
×
569
            connection.handle_protocol_error(Status{ErrorCodes::SyncProtocolInvariantFailed, std::move(msg)});
×
570
        };
×
571

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

589
        // See if the log_message contains the appservices_request_id
590
        if (auto it = message_body.find("co_id"); it != message_body.end() && it->is_string()) {
5,916✔
591
            connection.receive_appservices_request_id(it->get<std::string_view>());
1,984✔
592
        }
1,984✔
593

594
        std::string_view log_level;
5,916✔
595
        bool has_level = false;
5,916✔
596
        if (auto it = message_body.find("level"); it != message_body.end() && it->is_string()) {
5,920✔
597
            log_level = it->get<std::string_view>();
5,920✔
598
            has_level = !log_level.empty();
5,920✔
599
        }
5,920✔
600

601
        std::string_view msg_text;
5,916✔
602
        if (auto it = message_body.find("msg"); it != message_body.end() && it->is_string()) {
5,920✔
603
            msg_text = it->get<std::string_view>();
5,920✔
604
        }
5,920✔
605

606
        // If there is no message text, then we're done
607
        if (msg_text.empty()) {
5,916✔
608
            return;
×
609
        }
×
610

611
        // If a log level wasn't provided, default to debug
612
        util::Logger::Level parsed_level = util::Logger::Level::debug;
5,916✔
613
        if (has_level) {
5,920✔
614
            if (auto it = name_to_level.find(log_level); it != name_to_level.end()) {
5,920✔
615
                parsed_level = it->second;
5,920✔
616
            }
5,920✔
UNCOV
617
            else {
×
UNCOV
618
                return report_error("Unknown log level found in log_message: \"%1\"", log_level);
×
UNCOV
619
            }
×
620
        }
5,920✔
621
        connection.receive_server_log_message(session_ident, parsed_level, msg_text);
5,916✔
622
    }
5,916✔
623

624
    static constexpr std::size_t s_max_body_size = std::numeric_limits<std::size_t>::max();
625

626
    // Permanent buffer to use for building messages.
627
    OutputBuffer m_output_buffer;
628

629
    // Permanent buffers to use for internal purposes such as compression.
630
    std::vector<char> m_buffer;
631

632
    util::compression::CompressMemoryArena m_compress_memory_arena;
633
};
634

635

636
class ServerProtocol {
637
public:
638
    // clang-format off
639
    using file_ident_type    = sync::file_ident_type;
640
    using version_type       = sync::version_type;
641
    using salt_type          = sync::salt_type;
642
    using timestamp_type     = sync::timestamp_type;
643
    using session_ident_type = sync::session_ident_type;
644
    using request_ident_type = sync::request_ident_type;
645
    using SaltedFileIdent    = sync::SaltedFileIdent;
646
    using SaltedVersion      = sync::SaltedVersion;
647
    using milliseconds_type  = sync::milliseconds_type;
648
    using UploadCursor       = sync::UploadCursor;
649
    // clang-format on
650

651
    using OutputBuffer = util::ResettableExpandableBufferOutputStream;
652

653
    // Messages sent by the server to the client
654

655
    void make_ident_message(int protocol_version, OutputBuffer&, session_ident_type session_ident,
656
                            file_ident_type client_file_ident, salt_type client_file_ident_salt);
657

658
    void make_alloc_message(OutputBuffer&, session_ident_type session_ident, file_ident_type file_ident);
659

660
    void make_unbound_message(OutputBuffer&, session_ident_type session_ident);
661

662

663
    struct ChangesetInfo {
664
        version_type server_version;
665
        version_type client_version;
666
        sync::HistoryEntry entry;
667
        std::size_t original_size;
668
    };
669

670
    void make_download_message(int protocol_version, OutputBuffer&, session_ident_type session_ident,
671
                               version_type download_server_version, version_type download_client_version,
672
                               version_type latest_server_version, salt_type latest_server_version_salt,
673
                               version_type upload_client_version, version_type upload_server_version,
674
                               std::uint_fast64_t downloadable_bytes, std::size_t num_changesets, const char* body,
675
                               std::size_t uncompressed_body_size, std::size_t compressed_body_size,
676
                               bool body_is_compressed, util::Logger&);
677

678
    void make_mark_message(OutputBuffer&, session_ident_type session_ident, request_ident_type request_ident);
679

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

683
    void make_pong(OutputBuffer&, milliseconds_type timestamp);
684

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

688
    // Messages received by the server.
689

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

701
            connection.receive_ping(timestamp, rtt);
×
702
        }
×
703
        catch (const ProtocolCodecException& e) {
×
704
            connection.handle_protocol_error(Status{ErrorCodes::SyncProtocolInvariantFailed,
×
705
                                                    util::format("Bad syntax in PING message: %1", e.what())});
×
706
        }
×
707
    }
×
708

709
    // UploadChangeset is used to store received changesets in
710
    // the UPLOAD message.
711
    struct UploadChangeset {
712
        UploadCursor upload_cursor;
713
        timestamp_type origin_timestamp;
714
        file_ident_type origin_file_ident; // Zero when originating from connected client file
715
        BinaryData changeset;
716
    };
717

718
    // parse_message_received takes a (WebSocket) message and parses it.
719
    // The result of the parsing is handled by an object of type Connection.
720
    // Typically, Connection would be the Connection class from server.cpp
721
    template <class Connection>
722
    void parse_message_received(Connection& connection, std::string_view msg_data)
723
    {
68,154✔
724
        auto& logger = connection.logger;
68,154✔
725

726
        auto report_error = [&](ErrorCodes::Error err, const auto fmt, auto&&... args) {
68,154✔
727
            auto msg = util::format(fmt, std::forward<decltype(args)>(args)...);
×
728
            connection.handle_protocol_error(Status{err, std::move(msg)});
×
729
        };
×
730

731
        HeaderLineParser msg(msg_data);
68,154✔
732
        std::string_view message_type;
68,154✔
733
        try {
68,154✔
734
            message_type = msg.read_next<std::string_view>();
68,154✔
735
        }
68,154✔
736
        catch (const ProtocolCodecException& e) {
68,154✔
737
            return report_error(ErrorCodes::SyncProtocolInvariantFailed, "Could not find message type in message: %1",
×
738
                                e.what());
×
739
        }
×
740

741
        try {
68,164✔
742
            if (message_type == "upload") {
68,164✔
743
                auto msg_with_header = msg.remaining();
44,646✔
744
                auto session_ident = msg.read_next<session_ident_type>();
44,646✔
745
                auto is_body_compressed = msg.read_next<bool>();
44,646✔
746
                auto uncompressed_body_size = msg.read_next<size_t>();
44,646✔
747
                auto compressed_body_size = msg.read_next<size_t>();
44,646✔
748
                auto progress_client_version = msg.read_next<version_type>();
44,646✔
749
                auto progress_server_version = msg.read_next<version_type>();
44,646✔
750
                auto locked_server_version = msg.read_next<version_type>('\n');
44,646✔
751

752
                std::size_t body_size = (is_body_compressed ? compressed_body_size : uncompressed_body_size);
44,646✔
753
                if (body_size > s_max_body_size) {
44,646✔
754
                    auto header = msg_with_header.substr(0, msg_with_header.size() - msg.bytes_remaining());
×
755

756
                    return report_error(ErrorCodes::LimitExceeded,
×
757
                                        "Body size of upload message is too large. Raw header: %1", header);
×
758
                }
×
759

760

761
                std::unique_ptr<char[]> uncompressed_body_buffer;
44,646✔
762
                // if is_body_compressed == true, we must decompress the received body.
763
                if (is_body_compressed) {
44,646✔
764
                    uncompressed_body_buffer = std::make_unique<char[]>(uncompressed_body_size);
4,436✔
765
                    auto compressed_body = msg.read_sized_data<BinaryData>(compressed_body_size);
4,436✔
766

767
                    std::error_code ec = util::compression::decompress(
4,436✔
768
                        compressed_body, {uncompressed_body_buffer.get(), uncompressed_body_size});
4,436✔
769

770
                    if (ec) {
4,436✔
771
                        return report_error(ErrorCodes::RuntimeError, "compression::inflate: %1", ec.message());
×
772
                    }
×
773

774
                    msg = HeaderLineParser(std::string_view(uncompressed_body_buffer.get(), uncompressed_body_size));
4,436✔
775
                }
4,436✔
776

777
                logger.debug(util::LogCategory::changeset,
44,646✔
778
                             "Upload message compression: is_body_compressed = %1, "
44,646✔
779
                             "compressed_body_size=%2, uncompressed_body_size=%3, "
44,646✔
780
                             "progress_client_version=%4, progress_server_version=%5, "
44,646✔
781
                             "locked_server_version=%6",
44,646✔
782
                             is_body_compressed, compressed_body_size, uncompressed_body_size,
44,646✔
783
                             progress_client_version, progress_server_version, locked_server_version); // Throws
44,646✔
784

785

786
                std::vector<UploadChangeset> upload_changesets;
44,646✔
787

788
                // Loop through the body and find the changesets.
789
                while (!msg.at_end()) {
81,496✔
790
                    UploadChangeset upload_changeset;
36,848✔
791
                    size_t changeset_size;
36,848✔
792
                    try {
36,848✔
793
                        upload_changeset.upload_cursor.client_version = msg.read_next<version_type>();
36,848✔
794
                        upload_changeset.upload_cursor.last_integrated_server_version = msg.read_next<version_type>();
36,848✔
795
                        upload_changeset.origin_timestamp = msg.read_next<timestamp_type>();
36,848✔
796
                        upload_changeset.origin_file_ident = msg.read_next<file_ident_type>();
36,848✔
797
                        changeset_size = msg.read_next<size_t>();
36,848✔
798
                    }
36,848✔
799
                    catch (const ProtocolCodecException& e) {
36,848✔
800
                        return report_error(ErrorCodes::SyncProtocolInvariantFailed,
×
801
                                            "Bad changeset header syntax: %1", e.what());
×
802
                    }
×
803

804
                    if (changeset_size > msg.bytes_remaining()) {
36,850✔
805
                        return report_error(ErrorCodes::SyncProtocolInvariantFailed, "Bad changeset size");
×
806
                    }
×
807

808
                    upload_changeset.changeset = msg.read_sized_data<BinaryData>(changeset_size);
36,850✔
809

810
                    if (logger.would_log(util::Logger::Level::trace)) {
36,850✔
811
                        logger.trace(util::LogCategory::changeset,
×
812
                                     "Received: UPLOAD CHANGESET(client_version=%1, server_version=%2, "
×
813
                                     "origin_timestamp=%3, origin_file_ident=%4, changeset_size=%5)",
×
814
                                     upload_changeset.upload_cursor.client_version,
×
815
                                     upload_changeset.upload_cursor.last_integrated_server_version,
×
816
                                     upload_changeset.origin_timestamp, upload_changeset.origin_file_ident,
×
817
                                     changeset_size); // Throws
×
818
                        logger.trace(util::LogCategory::changeset, "Changeset: %1",
×
819
                                     clamped_hex_dump(upload_changeset.changeset)); // Throws
×
820
                    }
×
821
                    upload_changesets.push_back(std::move(upload_changeset)); // Throws
36,850✔
822
                }
36,850✔
823

824
                connection.receive_upload_message(session_ident, progress_client_version, progress_server_version,
44,648✔
825
                                                  locked_server_version,
44,648✔
826
                                                  upload_changesets); // Throws
44,648✔
827
            }
44,648✔
828
            else if (message_type == "mark") {
23,518✔
829
                auto session_ident = msg.read_next<session_ident_type>();
12,058✔
830
                auto request_ident = msg.read_next<request_ident_type>('\n');
12,058✔
831

832
                connection.receive_mark_message(session_ident, request_ident); // Throws
12,058✔
833
            }
12,058✔
834
            else if (message_type == "ping") {
11,460✔
835
                auto timestamp = msg.read_next<milliseconds_type>();
114✔
836
                auto rtt = msg.read_next<milliseconds_type>('\n');
114✔
837

838
                connection.receive_ping(timestamp, rtt);
114✔
839
            }
114✔
840
            else if (message_type == "bind") {
11,346✔
841
                auto session_ident = msg.read_next<session_ident_type>();
4,960✔
842
                auto path_size = msg.read_next<size_t>();
4,960✔
843
                auto signed_user_token_size = msg.read_next<size_t>();
4,960✔
844
                auto need_client_file_ident = msg.read_next<bool>();
4,960✔
845
                auto is_subserver = msg.read_next<bool>('\n');
4,960✔
846

847
                if (path_size == 0) {
4,960✔
848
                    return report_error(ErrorCodes::SyncProtocolInvariantFailed, "Path size in BIND message is zero");
×
849
                }
×
850
                if (path_size > s_max_path_size) {
4,960✔
851
                    return report_error(ErrorCodes::SyncProtocolInvariantFailed,
×
852
                                        "Path size in BIND message is too large");
×
853
                }
×
854
                if (signed_user_token_size > s_max_signed_user_token_size) {
4,960✔
855
                    return report_error(ErrorCodes::SyncProtocolInvariantFailed,
×
856
                                        "Signed user token size in BIND message is too large");
×
857
                }
×
858

859
                auto path = msg.read_sized_data<std::string>(path_size);
4,960✔
860
                auto signed_user_token = msg.read_sized_data<std::string>(signed_user_token_size);
4,960✔
861

862
                connection.receive_bind_message(session_ident, std::move(path), std::move(signed_user_token),
4,960✔
863
                                                need_client_file_ident, is_subserver); // Throws
4,960✔
864
            }
4,960✔
865
            else if (message_type == "ident") {
6,386✔
866
                auto session_ident = msg.read_next<session_ident_type>();
4,264✔
867
                auto client_file_ident = msg.read_next<file_ident_type>();
4,264✔
868
                auto client_file_ident_salt = msg.read_next<salt_type>();
4,264✔
869
                auto scan_server_version = msg.read_next<version_type>();
4,264✔
870
                auto scan_client_version = msg.read_next<version_type>();
4,264✔
871
                auto latest_server_version = msg.read_next<version_type>();
4,264✔
872
                auto latest_server_version_salt = msg.read_next<salt_type>('\n');
4,264✔
873

874
                connection.receive_ident_message(session_ident, client_file_ident, client_file_ident_salt,
4,264✔
875
                                                 scan_server_version, scan_client_version, latest_server_version,
4,264✔
876
                                                 latest_server_version_salt); // Throws
4,264✔
877
            }
4,264✔
878
            else if (message_type == "unbind") {
2,122✔
879
                auto session_ident = msg.read_next<session_ident_type>('\n');
2,120✔
880

881
                connection.receive_unbind_message(session_ident); // Throws
2,120✔
882
            }
2,120✔
883
            else if (message_type == "json_error") {
2✔
884
                auto error_code = msg.read_next<int>();
×
885
                auto message_size = msg.read_next<size_t>();
×
886
                auto session_ident = msg.read_next<session_ident_type>('\n');
×
887
                auto json_raw = msg.read_sized_data<std::string_view>(message_size);
×
888

889
                connection.receive_error_message(session_ident, error_code, json_raw);
×
890
            }
×
891
            else {
2✔
892
                return report_error(ErrorCodes::SyncProtocolInvariantFailed, "unknown message type %1", message_type);
2✔
893
            }
2✔
894
        }
68,164✔
895
        catch (const ProtocolCodecException& e) {
68,164✔
896
            return report_error(ErrorCodes::SyncProtocolInvariantFailed, "bad syntax in %1 message: %2", message_type,
×
897
                                e.what());
×
898
        }
×
899
    }
68,164✔
900

901
    void insert_single_changeset_download_message(OutputBuffer&, const ChangesetInfo&, util::Logger&);
902

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

914
// make_authorization_header() makes the value of the Authorization header used in the
915
// sync Websocket handshake.
916
std::string make_authorization_header(const std::string& signed_user_token);
917

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

922
} // namespace realm::_impl
923

924
#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

© 2026 Coveralls, Inc