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

realm / realm-core / 1873

27 Nov 2023 09:40PM UTC coverage: 91.661% (-0.03%) from 91.695%
1873

push

Evergreen

web-flow
Fix a bunch of throw statements to use Realm exceptions (#7141)

* Fix a bunch of throw statements to use Realm exceptions
* check correct exception in test
* clang-format and replaced a couple of std::exceptions in SyncManager
* Updated changelog

---------

Co-authored-by: Jonathan Reams <jbreams@mongodb.com>
Co-authored-by: Jørgen Edelbo <jorgen.edelbo@mongodb.com>

92402 of 169340 branches covered (0.0%)

2 of 77 new or added lines in 7 files covered. (2.6%)

113 existing lines in 15 files now uncovered.

231821 of 252910 relevant lines covered (91.66%)

6407488.93 hits per line

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

88.3
/src/realm/object-store/sync/impl/sync_file.cpp
1
////////////////////////////////////////////////////////////////////////////
2
//
3
// Copyright 2016 Realm Inc.
4
//
5
// Licensed under the Apache License, Version 2.0 (the "License");
6
// you may not use this file except in compliance with the License.
7
// You may obtain a copy of the License at
8
//
9
// http://www.apache.org/licenses/LICENSE-2.0
10
//
11
// Unless required by applicable law or agreed to in writing, software
12
// distributed under the License is distributed on an "AS IS" BASIS,
13
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
// See the License for the specific language governing permissions and
15
// limitations under the License.
16
//
17
////////////////////////////////////////////////////////////////////////////
18

19
#include <realm/object-store/sync/impl/sync_file.hpp>
20

21
#include <realm/db.hpp>
22
#include <realm/util/file.hpp>
23
#include <realm/util/hex_dump.hpp>
24
#include <realm/util/sha_crypto.hpp>
25
#include <realm/util/time.hpp>
26
#include <realm/util/scope_exit.hpp>
27

28
#include <iomanip>
29
#include <sstream>
30
#include <system_error>
31
#include <fstream>
32

33
#ifdef _WIN32
34
#include <io.h>
35
#include <fcntl.h>
36

37
inline static int mkstemp(char* _template)
38
{
39
    return _open(_mktemp(_template), _O_CREAT | _O_TEMPORARY, _S_IREAD | _S_IWRITE);
40
}
41
#else
42
#include <unistd.h>
43
#endif
44

45

46
using File = realm::util::File;
47

48
namespace realm {
49

50
namespace {
51

52
uint8_t value_of_hex_digit(char hex_digit)
53
{
76✔
54
    if (hex_digit >= '0' && hex_digit <= '9') {
76✔
55
        return hex_digit - '0';
40✔
56
    }
40✔
57
    else if (hex_digit >= 'A' && hex_digit <= 'F') {
36✔
58
        return 10 + hex_digit - 'A';
36✔
59
    }
36✔
60
    else if (hex_digit >= 'a' && hex_digit <= 'f') {
×
61
        return 10 + hex_digit - 'a';
×
62
    }
×
63
    else {
×
NEW
64
        throw LogicError(ErrorCodes::InvalidArgument, "Cannot get the value of a character that isn't a hex digit.");
×
65
    }
×
66
}
76✔
67

68
bool filename_is_reserved(const std::string& filename)
69
{
5,547✔
70
    return (filename == "." || filename == "..");
5,547✔
71
}
5,547✔
72

73
bool character_is_unreserved(char character)
74
{
54,745✔
75
    bool is_capital_letter = (character >= 'A' && character <= 'Z');
54,745✔
76
    bool is_lowercase_letter = (character >= 'a' && character <= 'z');
54,745✔
77
    bool is_number = (character >= '0' && character <= '9');
54,745✔
78
    bool is_allowed_symbol = (character == '-' || character == '_' || character == '.');
54,745✔
79
    return is_capital_letter || is_lowercase_letter || is_number || is_allowed_symbol;
54,745✔
80
}
54,745✔
81

82
char decoded_char_for(const std::string& percent_encoding, size_t index)
83
{
38✔
84
    if (index + 2 >= percent_encoding.length()) {
38✔
NEW
85
        throw LogicError(ErrorCodes::InvalidArgument,
×
NEW
86
                         "Malformed string: not enough characters after '%' before end of string.");
×
UNCOV
87
    }
×
88
    REALM_ASSERT(percent_encoding[index] == '%');
38✔
89
    return (16 * value_of_hex_digit(percent_encoding[index + 1])) + value_of_hex_digit(percent_encoding[index + 2]);
38✔
90
}
38✔
91

92
} // namespace
93

94
namespace util {
95

96
std::string make_percent_encoded_string(const std::string& raw_string)
97
{
5,629✔
98
    std::string buffer;
5,629✔
99
    buffer.reserve(raw_string.size());
5,629✔
100
    for (size_t i = 0; i < raw_string.size(); i++) {
60,306✔
101
        unsigned char character = raw_string[i];
54,677✔
102
        if (character_is_unreserved(character)) {
54,677✔
103
            buffer.push_back(character);
53,175✔
104
        }
53,175✔
105
        else {
1,502✔
106
            buffer.resize(buffer.size() + 3);
1,502✔
107
            // Format string must resolve to exactly 3 characters.
751✔
108
            snprintf(&buffer.back() - 2, 4, "%%%2X", character);
1,502✔
109
        }
1,502✔
110
    }
54,677✔
111
    return buffer;
5,629✔
112
}
5,629✔
113

114
std::string make_raw_string(const std::string& percent_encoded_string)
115
{
4✔
116
    std::string buffer;
4✔
117
    size_t input_len = percent_encoded_string.length();
4✔
118
    buffer.reserve(input_len);
4✔
119
    size_t idx = 0;
4✔
120
    while (idx < input_len) {
110✔
121
        char current = percent_encoded_string[idx];
106✔
122
        if (current == '%') {
106✔
123
            // Decode. +3.
19✔
124
            buffer.push_back(decoded_char_for(percent_encoded_string, idx));
38✔
125
            idx += 3;
38✔
126
        }
38✔
127
        else {
68✔
128
            // No need to decode. +1.
34✔
129
            if (!character_is_unreserved(current)) {
68✔
NEW
130
                throw LogicError(ErrorCodes::InvalidArgument,
×
NEW
131
                                 "Input string is invalid: contains reserved characters.");
×
UNCOV
132
            }
×
133
            buffer.push_back(current);
68✔
134
            idx++;
68✔
135
        }
68✔
136
    }
106✔
137
    return buffer;
4✔
138
}
4✔
139

140
std::string file_path_by_appending_component(const std::string& path, const std::string& component,
141
                                             FilePathType path_type)
142
{
23,857✔
143
#ifdef _WIN32
144
    const char separator = '\\';
145
#else
146
    const char separator = '/';
23,857✔
147
#endif
23,857✔
148
    std::string buffer;
23,857✔
149
    buffer.reserve(2 + path.length() + component.length());
23,857✔
150
    buffer.append(path);
23,857✔
151
    std::string terminal = "";
23,857✔
152
    if (path_type == FilePathType::Directory && component[component.length() - 1] != separator) {
23,857✔
153
        terminal = separator;
18,560✔
154
    }
18,560✔
155
    char path_last = path[path.length() - 1];
23,857✔
156
    char component_first = component[0];
23,857✔
157
    if (path_last == separator && component_first == separator) {
23,857✔
158
        buffer.append(component.substr(1));
×
159
        buffer.append(terminal);
×
160
    }
×
161
    else if (path_last == separator || component_first == separator) {
23,857✔
162
        buffer.append(component);
19,366✔
163
        buffer.append(terminal);
19,366✔
164
    }
19,366✔
165
    else {
4,491✔
166
        buffer.append(std::string(1, separator));
4,491✔
167
        buffer.append(component);
4,491✔
168
        buffer.append(terminal);
4,491✔
169
    }
4,491✔
170
    return buffer;
23,857✔
171
}
23,857✔
172

173
std::string file_path_by_appending_extension(const std::string& path, const std::string& extension)
174
{
6✔
175
    std::string buffer;
6✔
176
    buffer.reserve(1 + path.length() + extension.length());
6✔
177
    buffer.append(path);
6✔
178
    char path_last = path[path.length() - 1];
6✔
179
    char extension_first = extension[0];
6✔
180
    if (path_last == '.' && extension_first == '.') {
6✔
181
        buffer.append(extension.substr(1));
2✔
182
    }
2✔
183
    else if (path_last == '.' || extension_first == '.') {
4✔
184
        buffer.append(extension);
4✔
185
    }
4✔
186
    else {
×
187
        buffer.append(".");
×
188
        buffer.append(extension);
×
189
    }
×
190
    return buffer;
6✔
191
}
6✔
192

193
std::string create_timestamped_template(const std::string& prefix, int wildcard_count)
194
{
72✔
195
    constexpr int WILDCARD_MAX = 20;
72✔
196
    constexpr int WILDCARD_MIN = 6;
72✔
197
    wildcard_count = std::min(WILDCARD_MAX, std::max(WILDCARD_MIN, wildcard_count));
72✔
198
    std::time_t time = std::time(nullptr);
72✔
199
    std::stringstream stream;
72✔
200
    stream << prefix << "-" << util::format_local_time(time, "%Y%m%d-%H%M%S") << "-"
72✔
201
           << std::string(wildcard_count, 'X');
72✔
202
    return stream.str();
72✔
203
}
72✔
204

205
std::string reserve_unique_file_name(const std::string& path, const std::string& template_string)
206
{
72✔
207
    REALM_ASSERT_DEBUG(template_string.find("XXXXXX") != std::string::npos);
72✔
208
    std::string path_buffer = file_path_by_appending_component(path, template_string, FilePathType::File);
72✔
209
    int fd = mkstemp(&path_buffer[0]);
72✔
210
    if (fd < 0) {
72✔
211
        int err = errno;
×
NEW
212
        throw RuntimeError(ErrorCodes::FileOperationFailed,
×
NEW
213
                           util::format("Failed to make temporary path: %1 (%2)",
×
NEW
214
                                        std::system_error(err, std::system_category()).what(), err));
×
UNCOV
215
    }
×
216
    // Remove the file so we can use the name for our own file.
36✔
217
#ifdef _WIN32
218
    _close(fd);
219
    _unlink(path_buffer.c_str());
220
#else
221
    close(fd);
72✔
222
    unlink(path_buffer.c_str());
72✔
223
#endif
72✔
224
    return path_buffer;
72✔
225
}
72✔
226

227
static std::string validate_and_clean_path(const std::string& path)
228
{
5,547✔
229
    REALM_ASSERT(path.length() > 0);
5,547✔
230
    std::string escaped_path = util::make_percent_encoded_string(path);
5,547✔
231
    if (filename_is_reserved(escaped_path))
5,547✔
NEW
232
        throw LogicError(
×
NEW
233
            ErrorCodes::InvalidArgument,
×
UNCOV
234
            util::format("A path can't have an identifier reserved by the filesystem: '%1'", escaped_path));
×
235
    return escaped_path;
5,547✔
236
}
5,547✔
237

238
} // namespace util
239

240
SyncFileManager::SyncFileManager(const std::string& base_path, const std::string& app_id)
241
    : m_base_path(util::file_path_by_appending_component(base_path, c_sync_directory, util::FilePathType::Directory))
242
    , m_app_path(util::file_path_by_appending_component(m_base_path, util::validate_and_clean_path(app_id),
243
                                                        util::FilePathType::Directory))
244
{
4,481✔
245
    util::try_make_dir(m_base_path);
4,481✔
246
    util::try_make_dir(m_app_path);
4,481✔
247
}
4,481✔
248

249
std::string SyncFileManager::get_special_directory(std::string directory_name) const
250
{
4,501✔
251
    auto dir_path = file_path_by_appending_component(m_app_path, directory_name, util::FilePathType::Directory);
4,501✔
252
    util::try_make_dir(dir_path);
4,501✔
253
    return dir_path;
4,501✔
254
}
4,501✔
255

256
std::string SyncFileManager::user_directory(const std::string& user_identity) const
257
{
322✔
258
    std::string user_path = get_user_directory_path(user_identity);
322✔
259
    util::try_make_dir(user_path);
322✔
260
    return user_path;
322✔
261
}
322✔
262

263
void SyncFileManager::remove_user_realms(const std::string& user_identity,
264
                                         const std::vector<std::string>& realm_paths) const
265
{
24✔
266
    for (auto& path : realm_paths) {
26✔
267
        remove_realm(path);
26✔
268
    }
26✔
269
    // The following is redundant except for apps built before file tracking.
12✔
270
    std::string user_path = get_user_directory_path(user_identity);
24✔
271
    util::try_remove_dir_recursive(user_path);
24✔
272
}
24✔
273

274
bool SyncFileManager::remove_realm(const std::string& absolute_path) const
275
{
64✔
276
    REALM_ASSERT(absolute_path.length() > 0);
64✔
277
    bool success = true;
64✔
278
    try {
64✔
279
        constexpr bool delete_lockfile = true;
64✔
280
        realm::DB::delete_files(absolute_path, &success, delete_lockfile);
64✔
281
    }
64✔
282
    catch (FileAccessError const&) {
33✔
283
        success = false;
2✔
284
    }
2✔
285
    return success;
64✔
286
}
64✔
287

288
bool SyncFileManager::copy_realm_file(const std::string& old_path, const std::string& new_path) const
289
{
22✔
290
    REALM_ASSERT(old_path.length() > 0);
22✔
291
    try {
22✔
292
        if (File::exists(new_path)) {
22✔
293
            return false;
×
294
        }
×
295
        File::copy(old_path, new_path);
22✔
296
    }
22✔
297
    catch (FileAccessError const&) {
11✔
298
        return false;
×
299
    }
×
300
    return true;
22✔
301
}
22✔
302

303
bool SyncFileManager::remove_realm(const std::string& user_identity,
304
                                   const std::vector<std::string>& legacy_user_identities,
305
                                   const std::string& raw_realm_path, const std::string& partition) const
306
{
4✔
307
    auto existing = get_existing_realm_file_path(user_identity, legacy_user_identities, raw_realm_path, partition);
4✔
308
    if (existing) {
4✔
309
        return remove_realm(*existing);
2✔
310
    }
2✔
311
    return false; // if there is nothing to remove this is considered to be not successful
2✔
312
}
2✔
313

314
bool SyncFileManager::try_file_exists(const std::string& path) noexcept
315
{
794✔
316
    try {
794✔
317
        // May throw; for example when the path is too long
397✔
318
        return util::File::exists(path);
794✔
319
    }
794✔
320
    catch (const std::exception&) {
8✔
321
        return false;
8✔
322
    }
8✔
323
}
794✔
324

325
static bool try_file_remove(const std::string& path) noexcept
326
{
152✔
327
    try {
152✔
328
        return util::File::try_remove(path);
152✔
329
    }
152✔
330
    catch (const std::exception&) {
4✔
331
        return false;
4✔
332
    }
4✔
333
}
152✔
334

335
util::Optional<std::string>
336
SyncFileManager::get_existing_realm_file_path(const std::string& user_identity,
337
                                              const std::vector<std::string>& legacy_user_identities,
338
                                              const std::string& realm_file_name, const std::string& partition) const
339
{
174✔
340
    std::string preferred_name_without_suffix = preferred_realm_path_without_suffix(user_identity, realm_file_name);
174✔
341
    if (try_file_exists(preferred_name_without_suffix)) {
174✔
342
        return preferred_name_without_suffix;
×
343
    }
×
344

87✔
345
    std::string preferred_name_with_suffix = preferred_name_without_suffix + c_realm_file_suffix;
174✔
346
    if (try_file_exists(preferred_name_with_suffix)) {
174✔
347
        return preferred_name_with_suffix;
4✔
348
    }
4✔
349

85✔
350
    // Shorten the Realm path to just `<rootDir>/<hashedAbsolutePath>.realm`
85✔
351
    std::string hashed_name = fallback_hashed_realm_file_path(preferred_name_without_suffix);
170✔
352
    std::string hashed_path = hashed_name + c_realm_file_suffix;
170✔
353
    if (try_file_exists(hashed_path)) {
170✔
354
        // detected that the hashed fallback has been used previously
3✔
355
        // it was created for a reason so keep using it
3✔
356
        return hashed_path;
6✔
357
    }
6✔
358

82✔
359
    // The legacy fallback paths are not applicable to flexible sync
82✔
360
    if (partition.empty()) {
164✔
361
        return util::none;
10✔
362
    }
10✔
363

77✔
364
    // We used to hash the string value of the partition. For compatibility, check that SHA256
77✔
365
    // hash file name exists, and if it does, continue to use it.
77✔
366
    if (!partition.empty()) {
154✔
367
        std::string hashed_partition_path = legacy_hashed_partition_path(user_identity, partition);
154✔
368
        if (try_file_exists(hashed_partition_path)) {
154✔
369
            return hashed_partition_path;
×
370
        }
×
371
    }
154✔
372

77✔
373
    for (auto& legacy_identity : legacy_user_identities) {
154✔
374
        // retain support for legacy paths
32✔
375
        std::string old_path = legacy_realm_file_path(legacy_identity, realm_file_name);
64✔
376
        if (try_file_exists(old_path)) {
64✔
377
            return old_path;
6✔
378
        }
6✔
379
        // retain support for legacy local identity paths
29✔
380
        std::string old_local_identity_path = legacy_local_identity_path(legacy_identity, partition);
58✔
381
        if (try_file_exists(old_local_identity_path)) {
58✔
382
            return old_local_identity_path;
8✔
383
        }
8✔
384
    }
58✔
385

77✔
386
    return util::none;
147✔
387
}
154✔
388

389
std::string SyncFileManager::realm_file_path(const std::string& user_identity,
390
                                             const std::vector<std::string>& legacy_user_identities,
391
                                             const std::string& realm_file_name, const std::string& partition) const
392
{
170✔
393
    auto existing_path =
170✔
394
        get_existing_realm_file_path(user_identity, legacy_user_identities, realm_file_name, partition);
170✔
395
    if (existing_path) {
170✔
396
        return *existing_path;
22✔
397
    }
22✔
398

74✔
399
    // since this appears to be a new file, test the normal location
74✔
400
    // we use a test file with the same name and a suffix of the
74✔
401
    // same length, so we can catch "filename too long" errors on windows
74✔
402
    std::string preferred_name_without_suffix = preferred_realm_path_without_suffix(user_identity, realm_file_name);
148✔
403
    std::string preferred_name_with_suffix = preferred_name_without_suffix + c_realm_file_suffix;
148✔
404
    try {
148✔
405
        std::string test_path = preferred_name_without_suffix + c_realm_file_test_suffix;
148✔
406
        auto defer = util::make_scope_exit([test_path]() noexcept {
148✔
407
            try_file_remove(test_path);
148✔
408
        });
148✔
409
        util::File f(test_path, util::File::Mode::mode_Write);
148✔
410
        // if the test file succeeds, delete it and return the preferred location
74✔
411
    }
148✔
412
    catch (const FileAccessError&) {
76✔
413
        // the preferred test failed, test the hashed path
2✔
414
        std::string hashed_name = fallback_hashed_realm_file_path(preferred_name_without_suffix);
4✔
415
        std::string hashed_path = hashed_name + c_realm_file_suffix;
4✔
416
        try {
4✔
417
            std::string test_hashed_path = hashed_name + c_realm_file_test_suffix;
4✔
418
            auto defer = util::make_scope_exit([test_hashed_path]() noexcept {
4✔
419
                try_file_remove(test_hashed_path);
4✔
420
            });
4✔
421
            util::File f(test_hashed_path, util::File::Mode::mode_Write);
4✔
422
            // at this point the create succeeded, clean up the test file and return the hashed path
2✔
423
            return hashed_path;
4✔
424
        }
4✔
425
        catch (const FileAccessError& e_hashed) {
×
426
            // hashed test path also failed, give up and report error to user.
NEW
427
            throw LogicError(ErrorCodes::InvalidArgument,
×
NEW
428
                             util::format("A valid realm path cannot be created for the "
×
NEW
429
                                          "Realm identity '%1' at neither '%2' nor '%3'. %4",
×
NEW
430
                                          realm_file_name, preferred_name_with_suffix, hashed_path, e_hashed.what()));
×
UNCOV
431
        }
×
432
    }
144✔
433

72✔
434
    return preferred_name_with_suffix;
144✔
435
}
144✔
436

437
std::string SyncFileManager::metadata_path() const
438
{
4,405✔
439
    auto dir_path = file_path_by_appending_component(get_utility_directory(), c_metadata_directory,
4,405✔
440
                                                     util::FilePathType::Directory);
4,405✔
441
    util::try_make_dir(dir_path);
4,405✔
442
    return util::file_path_by_appending_component(dir_path, c_metadata_realm);
4,405✔
443
}
4,405✔
444

445
bool SyncFileManager::remove_metadata_realm() const
446
{
2✔
447
    auto dir_path = file_path_by_appending_component(get_utility_directory(), c_metadata_directory,
2✔
448
                                                     util::FilePathType::Directory);
2✔
449
    try {
2✔
450
        util::try_remove_dir_recursive(dir_path);
2✔
451
        return true;
2✔
452
    }
2✔
453
    catch (FileAccessError const&) {
×
454
        return false;
×
455
    }
×
456
}
2✔
457

458
std::string SyncFileManager::preferred_realm_path_without_suffix(const std::string& user_identity,
459
                                                                 const std::string& realm_file_name) const
460
{
322✔
461
    auto escaped_file_name = util::validate_and_clean_path(realm_file_name);
322✔
462
    std::string preferred_name =
322✔
463
        util::file_path_by_appending_component(user_directory(user_identity), escaped_file_name);
322✔
464
    if (StringData(preferred_name).ends_with(c_realm_file_suffix)) {
322✔
465
        preferred_name = preferred_name.substr(0, preferred_name.size() - strlen(c_realm_file_suffix));
10✔
466
    }
10✔
467
    return preferred_name;
322✔
468
}
322✔
469

470
std::string SyncFileManager::fallback_hashed_realm_file_path(const std::string& preferred_path) const
471
{
174✔
472
    std::array<unsigned char, 32> hash;
174✔
473
    util::sha256(preferred_path.data(), preferred_path.size(), hash.data());
174✔
474
    std::string hashed_name =
174✔
475
        util::file_path_by_appending_component(m_app_path, util::hex_dump(hash.data(), hash.size(), ""));
174✔
476
    return hashed_name;
174✔
477
}
174✔
478

479
std::string SyncFileManager::legacy_hashed_partition_path(const std::string& user_identity,
480
                                                          const std::string& partition) const
481
{
154✔
482
    std::array<unsigned char, 32> hash;
154✔
483
    util::sha256(partition.data(), partition.size(), hash.data());
154✔
484
    std::string legacy_hashed_file_name = util::hex_dump(hash.data(), hash.size(), "");
154✔
485
    std::string legacy_partition_path = util::file_path_by_appending_component(
154✔
486
        get_user_directory_path(user_identity), legacy_hashed_file_name + c_realm_file_suffix);
154✔
487
    return legacy_partition_path;
154✔
488
}
154✔
489

490
std::string SyncFileManager::legacy_realm_file_path(const std::string& local_user_identity,
491
                                                    const std::string& realm_file_name) const
492
{
64✔
493
    auto path =
64✔
494
        util::file_path_by_appending_component(m_app_path, c_legacy_sync_directory, util::FilePathType::Directory);
64✔
495
    path = util::file_path_by_appending_component(path, util::validate_and_clean_path(local_user_identity),
64✔
496
                                                  util::FilePathType::Directory);
64✔
497
    path = util::file_path_by_appending_component(path, util::validate_and_clean_path(realm_file_name));
64✔
498
    return path;
64✔
499
}
64✔
500

501
std::string SyncFileManager::legacy_local_identity_path(const std::string& local_user_identity,
502
                                                        const std::string& realm_file_name) const
503
{
58✔
504
    auto escaped_file_name = util::validate_and_clean_path(realm_file_name);
58✔
505
    std::string user_path = get_user_directory_path(local_user_identity);
58✔
506
    std::string path_name = util::file_path_by_appending_component(user_path, escaped_file_name);
58✔
507
    std::string path = path_name + c_realm_file_suffix;
58✔
508

29✔
509
    return path;
58✔
510
}
58✔
511

512
std::string SyncFileManager::get_user_directory_path(const std::string& user_identity) const
513
{
558✔
514
    return file_path_by_appending_component(m_app_path, util::validate_and_clean_path(user_identity),
558✔
515
                                            util::FilePathType::Directory);
558✔
516
}
558✔
517

518
} // namespace realm
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