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

libbitcoin / libbitcoin-system / 9950156475

16 Jul 2024 03:16AM UTC coverage: 83.203% (+0.3%) from 82.874%
9950156475

push

github

web-flow
Merge pull request #1498 from evoskuil/master

Optimizing deserializations.

205 of 222 new or added lines in 12 files covered. (92.34%)

14 existing lines in 8 files now uncovered.

10090 of 12127 relevant lines covered (83.2%)

4761709.16 hits per line

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

87.31
/src/chain/operation.cpp
1

2
/**
3
 * Copyright (c) 2011-2023 libbitcoin developers (see AUTHORS)
4
 *
5
 * This file is part of libbitcoin.
6
 *
7
 * This program is free software: you can redistribute it and/or modify
8
 * it under the terms of the GNU Affero General Public License as published by
9
 * the Free Software Foundation, either version 3 of the License, or
10
 * (at your option) any later version.
11
 *
12
 * This program is distributed in the hope that it will be useful,
13
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
14
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15
 * GNU Affero General Public License for more details.
16
 *
17
 * You should have received a copy of the GNU Affero General Public License
18
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
19
 */
20
#include <bitcoin/system/chain/operation.hpp>
21

22
#include <algorithm>
23
#include <memory>
24
#include <bitcoin/system/chain/enums/numbers.hpp>
25
#include <bitcoin/system/chain/enums/opcode.hpp>
26
#include <bitcoin/system/data/data.hpp>
27
#include <bitcoin/system/define.hpp>
28
#include <bitcoin/system/machine/machine.hpp>
29
#include <bitcoin/system/math/math.hpp>
30
#include <bitcoin/system/serial/serial.hpp>
31
#include <bitcoin/system/unicode/unicode.hpp>
32

33
namespace libbitcoin {
34
namespace system {
35
namespace chain {
36

37
BC_PUSH_WARNING(NO_THROW_IN_NOEXCEPT)
38

39
// Gotta set something when invalid minimal result, test is_valid.
40
static constexpr auto any_invalid = opcode::op_xor;
41

42
// static
43
const data_chunk& operation::no_data() NOEXCEPT
162✔
44
{
45
    static const data_chunk empty_data{};
162✔
46
    return empty_data;
162✔
47
}
48

49
// static
NEW
50
const chunk_cptr& operation::no_data_cptr() NOEXCEPT
×
51
{
52
    BC_PUSH_WARNING(NO_NEW_OR_DELETE)
NEW
53
    static const std::shared_ptr<const data_chunk> empty
×
54
    {
NEW
55
        new const data_chunk{}
×
NEW
56
    };
×
57
    BC_POP_WARNING()
UNCOV
58
    return empty;
×
59
}
60

61
// static
62
// Push data is not possible with an invalid code, combination is invalid.
63
const chunk_cptr& operation::any_data_cptr() NOEXCEPT
10✔
64
{
65
    BC_PUSH_WARNING(NO_NEW_OR_DELETE)
66
    static const std::shared_ptr<const data_chunk> any
10✔
67
    {
68
        new const data_chunk{ 0x42 }
1✔
69
    };
11✔
70
    BC_POP_WARNING()
71
    return any;
10✔
72
}
73

74
// Constructors.
75
// ----------------------------------------------------------------------------
76

77
operation::operation() NOEXCEPT
10✔
78
  : operation(any_invalid, any_data_cptr(), false)
10✔
79
{
80
}
10✔
81

82
// If code is push data the data member will be inconsistent (empty).
83
operation::operation(opcode code) NOEXCEPT
452✔
84
  : operation(code, nullptr, false)
452✔
85
{
86
}
452✔
87

88
operation::operation(data_chunk&& push_data, bool minimal) NOEXCEPT
44✔
89
  : operation(from_push_data(to_shared(std::move(push_data)), minimal))
44✔
90
{
91
}
44✔
92

93
operation::operation(const data_chunk& push_data, bool minimal) NOEXCEPT
5✔
94
  : operation(from_push_data(to_shared(push_data), minimal))
5✔
95
{
96
}
5✔
97

98
operation::operation(const chunk_cptr& push_data, bool minimal) NOEXCEPT
×
99
  : operation(from_push_data(push_data, minimal))
×
100
{
101
}
×
102

103
operation::operation(const data_slice& op_data) NOEXCEPT
35✔
104
    BC_PUSH_WARNING(NO_THROW_IN_NOEXCEPT)
105
  : operation(stream::in::copy(op_data))
35✔
106
    BC_POP_WARNING()
107
{
108
}
35✔
109

110
////operation::operation(stream::in::fast&& stream) NOEXCEPT
111
////  : operation(read::bytes::fast(stream))
112
////{
113
////}
114

115
operation::operation(stream::in::fast& stream) NOEXCEPT
1✔
116
  : operation(read::bytes::fast(stream))
1✔
117
{
118
}
1✔
119

120
operation::operation(std::istream&& stream) NOEXCEPT
35✔
121
  : operation(read::bytes::istream(stream))
35✔
122
{
123
}
35✔
124

125
operation::operation(std::istream& stream) NOEXCEPT
4✔
126
  : operation(read::bytes::istream(stream))
4✔
127
{
128
}
4✔
129

130
operation::operation(reader&& source) NOEXCEPT
40✔
131
  : operation(source/*from_data(source)*/)
40✔
132
{
UNCOV
133
}
×
134

135
operation::operation(reader& source) NOEXCEPT
82,608✔
136
////: operation(from_data(source))
137
{
138
    assign_data(source);
82,608✔
139
}
82,608✔
140

141
operation::operation(const std::string& mnemonic) NOEXCEPT
10,729✔
142
  : operation(from_string(mnemonic))
10,729✔
143
{
144
}
10,729✔
145

146
// protected
147
operation::operation(opcode code, const chunk_cptr& push_data,
11,234✔
148
    bool underflow) NOEXCEPT
462✔
149
  : code_(code), data_(push_data), underflow_(underflow)
11,234✔
150
{
151
}
×
152

153
// Operators.
154
// ----------------------------------------------------------------------------
155

156
bool operation::operator==(const operation& other) const NOEXCEPT
194✔
157
{
158
    return (code_ == other.code_)
194✔
159
        && (data_ == other.data_ || get_data() == other.get_data())
184✔
160
        && (underflow_ == other.underflow_);
378✔
161
}
162

163
bool operation::operator!=(const operation& other) const NOEXCEPT
2✔
164
{
165
    return !(*this == other);
2✔
166
}
167

168
// Deserialization.
169
// ----------------------------------------------------------------------------
170

171
// private
172
void operation::assign_data(reader& source) NOEXCEPT
82,608✔
173
{
174
    auto& allocator = source.get_allocator();
82,608✔
175

176
    // Guard against resetting a previously-invalid stream.
177
    if (!source)
82,608✔
178
    {
NEW
179
        allocator.construct<chunk_cptr>(&data_, nullptr);
×
NEW
180
        return;
×
181
    }
182

183
    // If stream is not empty then a non-data opcode will always deserialize.
184
    // A push-data opcode may indicate more bytes than are available. In this
185
    // case the the script is invalid, but it may not be evaluated, such as
186
    // with a coinbase input. So if an operation fails to deserialize it is
187
    // re-read and retained as an "underflow" operation. An underflow op
188
    // serializes as data only, and fails evaluation. Only the last operation
189
    // in a script could become an underflow, which may possibly contain the
190
    // entire script. This retains the read position in case of underflow.
191
    const auto start = source.get_read_position();
82,608✔
192

193
    // Size of a push-data opcode is not retained, as this is inherent in data.
194
    code_ = static_cast<opcode>(source.read_byte());
82,608✔
195
    const auto size = read_data_size(code_, source);
82,608✔
196

197
    // read_bytes only guarded from excessive allocation by stream limit.
198
    if (size > max_block_size)
82,608✔
199
        source.invalidate();
1✔
200

201
    // An invalid source.read_bytes_raw returns nullptr.
202
    allocator.construct<chunk_cptr>(&data_,
165,216✔
203
        source.read_bytes_raw(size),
82,608✔
204
        allocator.deleter<data_chunk>(source.get_arena()));
82,608✔
205

206
    underflow_ = !source;
82,608✔
207

208
    // This requires that provided stream terminates at the end of the script.
209
    // When passing ops as part of a stream longer than the script, such as for
210
    // a transaction, caller should apply source.set_limit(prefix_size), and
211
    // clear the stream limit upon return. Stream invalidation and set_position
212
    // do not alter a stream limit, it just behaves as a smaller stream buffer.
213
    // Without a limit, source.read_bytes() below consumes the remaining stream.
214
    if (underflow_)
82,608✔
215
    {
216
        code_ = any_invalid;
11✔
217
        source.set_position(start);
11✔
218
        data_ = to_shared(source.read_bytes());
22✔
219
    }
220

221
    // All byte vectors are deserializable, stream indicates own failure.
222
}
223

224
// static/private
225
operation operation::from_push_data(const chunk_cptr& data,
49✔
226
    bool minimal) NOEXCEPT
227
{
228
    const auto code = opcode_from_data(*data, minimal);
49✔
229

230
    // Minimal interpretation affects only single byte push data.
231
    // Revert data if (minimal) opcode_from_data produced a numeric encoding.
232
    const auto push = is_payload(code) ? data : nullptr;
49✔
233

234
    return { code, push, false };
49✔
235
}
236

237
inline bool is_push_token(const std::string& token) NOEXCEPT
10,729✔
238
{
239
    return token.size() > one && token.front() == '[' && token.back() == ']';
10,729✔
240
}
241

242
inline bool is_text_token(const std::string& token) NOEXCEPT
10,572✔
243
{
244
    return token.size() > one && token.front() == '\'' && token.back() == '\'';
10,572✔
245
}
246

247
inline bool is_underflow_token(const std::string& token) NOEXCEPT
9,327✔
248
{
249
    return token.size() > one && token.front() == '<' && token.back() == '>';
9,327✔
250
}
251

252
inline std::string remove_token_delimiters(const std::string& token) NOEXCEPT
1,417✔
253
{
254
    BC_ASSERT(token.size() > one);
1,417✔
255
    return std::string(std::next(token.begin()), std::prev(token.end()));
1,417✔
256
}
257

258
inline string_list split_push_token(const std::string& token) NOEXCEPT
157✔
259
{
260
    return split(remove_token_delimiters(token), ".", false, false);
314✔
261
}
262

263
static bool opcode_from_data_prefix(opcode& out_code,
22✔
264
    const std::string& prefix, const data_chunk& push_data) NOEXCEPT
265
{
266
    constexpr auto op_75 = static_cast<uint8_t>(opcode::push_size_75);
22✔
267
    const auto size = push_data.size();
22✔
268
    out_code = operation::opcode_from_size(size);
22✔
269

270
    if (prefix == "0")
22✔
271
    {
272
        return size <= op_75;
2✔
273
    }
274
    else if (prefix == "1")
20✔
275
    {
276
        out_code = opcode::push_one_size;
10✔
277
        return size <= max_uint8;
10✔
278
    }
279
    else if (prefix == "2")
10✔
280
    {
281
        out_code = opcode::push_two_size;
5✔
282
        return size <= max_uint16;
5✔
283
    }
284
    else if (prefix == "4")
5✔
285
    {
286
        out_code = opcode::push_four_size;
3✔
287
        return size <= max_uint32;
3✔
288
    }
289

290
    return false;
291
}
292

293
static bool data_from_decimal(data_chunk& out_data,
296✔
294
    const std::string& token) NOEXCEPT
295
{
296
    // Deserialization to a number can convert random text to zero.
297
    if (!is_ascii_numeric(token))
296✔
298
        return false;
299

300
    int64_t value;
296✔
301
    if (!deserialize(value, token))
296✔
302
        return false;
303

304
    out_data = machine::number::chunk::from_integer(value);
296✔
305
    return true;
296✔
306
}
307

308
// private/static
309
operation operation::from_string(const std::string& mnemonic) NOEXCEPT
10,729✔
310
{
311
    data_chunk chunk;
10,729✔
312
    auto valid = false;
10,729✔
313
    auto underflow = false;
10,729✔
314

315
    // Always defined below, but this fixes warning.
316
    opcode code{ opcode::op_xor };
10,729✔
317

318
    if (is_push_token(mnemonic))
10,729✔
319
    {
320
        // Data encoding uses single token with one or two parts.
321
        const auto parts = split_push_token(mnemonic);
157✔
322

323
        if (parts.size() == one)
157✔
324
        {
325
            // Extract operation using nominal data size decoding.
326
            if ((valid = decode_base16(chunk, parts.front())))
132✔
327
                code = nominal_opcode_from_data(chunk);
132✔
328
        }
329
        else if (parts.size() == two)
25✔
330
        {
331
            // Extract operation using explicit data size decoding.
332

333
            // More efficient [] dereference is guarded above.
334
            BC_PUSH_WARNING(NO_ARRAY_INDEXING)
335
            valid = decode_base16(chunk, parts[1]) &&
47✔
336
                opcode_from_data_prefix(code, parts[0], chunk);
22✔
337
            BC_POP_WARNING()
338
        }
339
    }
157✔
340
    else if (is_text_token(mnemonic))
10,572✔
341
    {
342
        // Extract operation using nominal data size decoding.
343
        chunk = to_chunk(remove_token_delimiters(mnemonic));
2,490✔
344
        code = nominal_opcode_from_data(chunk);
1,245✔
345
        valid = true;
1,245✔
346
    }
347
    else if (is_underflow_token(mnemonic))
9,327✔
348
    {
349
        // code is ignored for underflow ops.
350
        underflow = true;
2✔
351
        code = any_invalid;
2✔
352
        valid = decode_base16(chunk, remove_token_delimiters(mnemonic));
4✔
353
    }
354
    else if (opcode_from_mnemonic(code, mnemonic))
9,325✔
355
    {
356
        // Any push code may have empty data, so this is presumed here.
357
        // No data is obtained here from a push opcode (use push/text tokens).
358
        valid = true;
359
    }
360
    else if (data_from_decimal(chunk, mnemonic))
296✔
361
    {
362
        // opcode_from_mnemonic captures [-1, 0, 1..16] integers, others here.
363
        code = nominal_opcode_from_data(chunk);
296✔
364
        valid = true;
296✔
365
    }
366

367
    if (!valid)
10,729✔
368
        return {};
6✔
369

370
    return { code, to_shared(std::move(chunk)), underflow };
21,446✔
371
}
372

373
// Serialization.
374
// ----------------------------------------------------------------------------
375

376
data_chunk operation::to_data() const NOEXCEPT
24✔
377
{
378
    data_chunk data(serialized_size());
24✔
379

380
    BC_PUSH_WARNING(NO_THROW_IN_NOEXCEPT)
381
    stream::out::copy ostream(data);
24✔
382
    BC_POP_WARNING()
383

384
    to_data(ostream);
24✔
385
    return data;
48✔
386
}
24✔
387

388
void operation::to_data(std::ostream& stream) const NOEXCEPT
25✔
389
{
390
    write::bytes::ostream out(stream);
25✔
391
    to_data(out);
25✔
392
}
25✔
393

394
void operation::to_data(writer& sink) const NOEXCEPT
1,491,452✔
395
{
396
    // Underflow is op-undersized data, it is serialized with no opcode.
397
    // An underflow could only be a final token in a script deserialization.
398
    if (is_underflow())
1,491,452✔
399
    {
400
        sink.write_bytes(get_data());
14✔
401
    }
402
    else
403
    {
404
        const auto size = data_size();
1,491,438✔
405
        sink.write_byte(static_cast<uint8_t>(code_));
1,491,438✔
406

407
        switch (code_)
1,491,438✔
408
        {
409
            case opcode::push_one_size:
2✔
410
                sink.write_byte(narrow_cast<uint8_t>(size));
2✔
411
                break;
2✔
412
            case opcode::push_two_size:
3✔
413
                sink.write_2_bytes_little_endian(narrow_cast<uint16_t>(size));
3✔
414
                break;
3✔
415
            case opcode::push_four_size:
2✔
416
                sink.write_4_bytes_little_endian(
2✔
417
                    possible_narrow_cast<uint32_t>(size));
418
                break;
2✔
419
            default:
420
            break;
421
        }
422

423
        sink.write_bytes(get_data());
1,491,438✔
424
    }
425
}
1,491,452✔
426

427
// To String.
428
// ----------------------------------------------------------------------------
429

430
static std::string opcode_to_prefix(opcode code,
16✔
431
    const data_chunk& data) NOEXCEPT
432
{
433
    // If opcode is minimal for a size-based encoding, do not set a prefix.
434
    if (code == operation::opcode_from_size(data.size()))
16✔
435
        return "";
13✔
436

437
    switch (code)
3✔
438
    {
439
        case opcode::push_one_size:
1✔
440
            return "1.";
1✔
441
        case opcode::push_two_size:
1✔
442
            return "2.";
1✔
443
        case opcode::push_four_size:
1✔
444
            return "4.";
1✔
445
        default:
×
446
            return "0.";
×
447
    }
448
}
449

450
std::string operation::to_string(uint32_t active_flags) const NOEXCEPT
77✔
451
{
452
    if (!is_valid())
77✔
453
        return "(?)";
×
454

455
    if (underflow_)
77✔
456
        return "<" + encode_base16(get_data()) + ">";
4✔
457

458
    if (data_empty())
75✔
459
        return opcode_to_mnemonic(code_, active_flags);
59✔
460

461
    // Data encoding uses single token with explicit size prefix as required.
462
    return "[" + opcode_to_prefix(code_, get_data()) +
32✔
463
        encode_base16(get_data()) + "]";
48✔
464
}
465

466
// Properties.
467
// ----------------------------------------------------------------------------
468

469
bool operation::is_valid() const NOEXCEPT
10,791✔
470
{
471
    // Push data not possible with any is_invalid, combination is invalid.
472
    // This is necessary because there can be no invalid sentinel value.
473
    return !(code_ == any_invalid && !underflow_ && !data_empty());
10,791✔
474
}
475

476
opcode operation::code() const NOEXCEPT
1,227,365✔
477
{
478
    return code_;
1,227,365✔
479
}
480

481
const data_chunk& operation::data() const NOEXCEPT
378✔
482
{
483
    return get_data();
378✔
484
}
485

486
const chunk_cptr& operation::data_ptr() const NOEXCEPT
4,470✔
487
{
488
    return get_data_cptr();
4,470✔
489
}
490

491
size_t operation::serialized_size() const NOEXCEPT
11,223✔
492
{
493
    static constexpr auto op_size = sizeof(uint8_t);
11,223✔
494
    const auto size = data_size();
11,223✔
495

496
    if (underflow_)
11,223✔
497
        return size;
498

499
    switch (code_)
11,220✔
500
    {
501
        case opcode::push_one_size:
13✔
502
            return op_size + sizeof(uint8_t) + size;
13✔
503
        case opcode::push_two_size:
67✔
504
            return op_size + sizeof(uint16_t) + size;
67✔
505
        case opcode::push_four_size:
5✔
506
            return op_size + sizeof(uint32_t) + size;
5✔
507
        default:
11,135✔
508
            return op_size + size;
11,135✔
509
    }
510
}
511

512
// Utilities.
513
// ----------------------------------------------------------------------------
514

515
// static/private
516
// Advances stream, returns true unless exhausted.
517
// Does not advance to end position in the case of underflow operation.
518
bool operation::count_op(reader& source) NOEXCEPT
83,212✔
519
{
520
    if (source.is_exhausted())
83,212✔
521
        return false;
522

523
    const auto code = static_cast<opcode>(source.read_byte());
82,566✔
524
    source.skip_bytes(read_data_size(code, source));
82,566✔
525
    return true;
82,566✔
526
}
527

528
// static/private
529
uint32_t operation::read_data_size(opcode code, reader& source) NOEXCEPT
165,174✔
530
{
531
    constexpr auto op_75 = static_cast<uint8_t>(opcode::push_size_75);
165,174✔
532

533
    switch (code)
165,174✔
534
    {
535
        case opcode::push_one_size:
11✔
536
            return source.read_byte();
11✔
537
        case opcode::push_two_size:
9✔
538
            return source.read_2_bytes_little_endian();
9✔
539
        case opcode::push_four_size:
5✔
540
            return source.read_4_bytes_little_endian();
5✔
541
        default:
165,149✔
542
            const auto byte = static_cast<uint8_t>(code);
165,149✔
543
            return byte <= op_75 ? byte : 0;
165,149✔
544
    }
545
}
546

547
// Categories of operations.
548
// ----------------------------------------------------------------------------
549

550
bool operation::is_invalid() const NOEXCEPT
115,666✔
551
{
552
    return is_invalid(code_);
115,666✔
553
}
554

555
bool operation::is_push() const NOEXCEPT
1✔
556
{
557
    return is_push(code_);
1✔
558
}
559

560
bool operation::is_payload() const NOEXCEPT
×
561
{
562
    return is_payload(code_);
×
563
}
564

565
bool operation::is_counted() const NOEXCEPT
×
566
{
567
    return is_counted(code_);
×
568
}
569

570
bool operation::is_version() const NOEXCEPT
205✔
571
{
572
    return is_version(code_);
205✔
573
}
574

575
bool operation::is_numeric() const NOEXCEPT
×
576
{
577
    return is_numeric(code_);
×
578
}
579

580
bool operation::is_positive() const NOEXCEPT
×
581
{
582
    return is_positive(code_);
×
583
}
584

585
bool operation::is_reserved() const NOEXCEPT
×
586
{
587
    return is_reserved(code_);
×
588
}
589

590
bool operation::is_conditional() const NOEXCEPT
22,384✔
591
{
592
    return is_conditional(code_);
22,384✔
593
}
594

595
bool operation::is_relaxed_push() const NOEXCEPT
×
596
{
597
    return is_relaxed_push(code_);
×
598
}
599

600
bool operation::is_minimal_push() const NOEXCEPT
8✔
601
{
602
    return code_ == minimal_opcode_from_data(get_data());
8✔
603
}
604

605
bool operation::is_nominal_push() const NOEXCEPT
×
606
{
NEW
607
    return code_ == nominal_opcode_from_data(get_data());
×
608
}
609

610
bool operation::is_oversized() const NOEXCEPT
22,395✔
611
{
612
    // Rule max_push_data_size imposed by [0.3.6] soft fork.
613
    return data_size() > max_push_data_size;
22,395✔
614
}
615

616
bool operation::is_underclaimed() const NOEXCEPT
4,470✔
617
{
618
    return data_size() > operation::opcode_to_maximum_size(code_);
4,470✔
619
}
620

621
// ****************************************************************************
622
// CONSENSUS: An underflow is sized op-undersized data. This is valid as long
623
// as the operation is not executed. For example, coinbase input scripts.
624
// ****************************************************************************
625
bool operation::is_underflow() const NOEXCEPT
1,491,460✔
626
{
627
    return underflow_;
1,491,452✔
628
}
629

630
// JSON value convertors.
631
// ----------------------------------------------------------------------------
632

633
namespace json = boost::json;
634

635
// boost/json will soon have NOEXCEPT: github.com/boostorg/json/pull/636
636
BC_PUSH_WARNING(NO_THROW_IN_NOEXCEPT)
637

638
operation tag_invoke(json::value_to_tag<operation>,
1✔
639
    const json::value& value) NOEXCEPT
640
{
641
    return operation{ std::string(value.get_string().c_str()) };
1✔
642
}
643

644
void tag_invoke(json::value_from_tag, json::value& value,
2✔
645
    const operation& operation) NOEXCEPT
646
{
647
    value = operation.to_string(flags::all_rules);
2✔
648
}
2✔
649

650
BC_POP_WARNING()
651

652
operation::cptr tag_invoke(json::value_to_tag<operation::cptr>,
×
653
    const json::value& value) NOEXCEPT
654
{
655
    return to_shared(tag_invoke(json::value_to_tag<operation>{}, value));
×
656
}
657

658
// Shared pointer overload is required for navigation.
659
BC_PUSH_WARNING(SMART_PTR_NOT_NEEDED)
660
BC_PUSH_WARNING(NO_VALUE_OR_CONST_REF_SHARED_PTR)
661

662
void tag_invoke(json::value_from_tag tag, json::value& value,
×
663
    const operation::cptr& operation) NOEXCEPT
664
{
665
    tag_invoke(tag, value, *operation);
×
666
}
×
667

668
BC_POP_WARNING()
669
BC_POP_WARNING()
670
BC_POP_WARNING()
671

672
} // namespace chain
673
} // namespace system
674
} // namespace libbitcoin
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