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

Aldaviva / Unfucked / 23378203323

21 Mar 2026 10:59AM UTC coverage: 35.442% (-11.7%) from 47.183%
23378203323

push

github

Aldaviva
Seal all possible classes for allegedly higher performance, since they weren't actually subclassable anyway due to C# not making methods virtual by default. If this change does more harm than good, blame Stephen Toub.

573 of 1629 branches covered (35.17%)

14 of 72 new or added lines in 15 files covered. (19.44%)

488 existing lines in 30 files now uncovered.

975 of 2751 relevant lines covered (35.44%)

162.06 hits per line

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

0.0
/Windows/Windows/WindowsProcesses.cs
1
using Microsoft.Win32.SafeHandles;
2
using System.ComponentModel;
3
using System.Diagnostics;
4
using System.Diagnostics.CodeAnalysis;
5
using System.Diagnostics.Contracts;
6
using System.Runtime.InteropServices;
7
using System.Security.Principal;
8
using System.Text;
9

10
namespace Unfucked.Windows;
11

12
/// <summary>
13
/// Methods that make it easier to work with processes and arguments.
14
/// </summary>
15
public static class WindowsProcesses {
16

17
    /// <summary>
18
    /// Split a single command-line string into a sequence of individual arguments using Windows rules.
19
    /// </summary>
20
    /// <param name="commandLine">A command-line string, possibly consisting of multiple arguments, escaping, and quotation marks.</param>
21
    /// <returns>An enumerable of the individual arguments in <paramref name="commandLine"/>, unescaped and unquoted.</returns>
22
    /// <remarks>
23
    /// By Mike Schwörer: <see href="https://stackoverflow.com/a/64236441/979493" />
24
    /// </remarks>
25
    [ExcludeFromCodeCoverage]
26
    [Pure]
27
    public static IEnumerable<string> CommandLineToEnumerable(string commandLine) {
28
        StringBuilder result = new();
29

30
        bool quoted     = false;
31
        bool escaped    = false;
32
        bool started    = false;
33
        bool allowcaret = false;
34
        for (int i = 0; i < commandLine.Length; i++) {
35
            char chr = commandLine[i];
36

37
            if (chr == '^' && !quoted) {
38
                if (allowcaret) {
39
                    result.Append(chr);
40
                    started    = true;
41
                    escaped    = false;
42
                    allowcaret = false;
43
                } else if (i + 1 < commandLine.Length && commandLine[i + 1] == '^') {
44
                    allowcaret = true;
45
                } else if (i + 1 == commandLine.Length) {
46
                    result.Append(chr);
47
                    started = true;
48
                    escaped = false;
49
                }
50
            } else if (escaped) {
51
                result.Append(chr);
52
                started = true;
53
                escaped = false;
54
            } else if (chr == '"') {
55
                quoted  = !quoted;
56
                started = true;
57
            } else if (chr == '\\' && i + 1 < commandLine.Length && commandLine[i + 1] == '"') {
58
                escaped = true;
59
            } else if (chr == ' ' && !quoted) {
60
                if (started) yield return result.ToString();
61
                result.Clear();
62
                started = false;
63
            } else {
64
                result.Append(chr);
65
                started = true;
66
            }
67
        }
68

69
        if (started) yield return result.ToString();
70
    }
71

72
    /// <summary>
73
    /// <para>Gets the process that started a given child process.</para>
74
    /// <para>Windows only.</para>
75
    /// </summary>
76
    /// <param name="pid">The child process ID of which you want to find the parent, or <c>null</c> to get the parent of the current process.</param>
77
    /// <returns>The parent process of the child process that has the given <paramref name="pid"/>, or <c>null</c> if the process cannot be found (possibly because it already exited). Remember to <see cref="IDisposable.Dispose"/> the returned value.</returns>
78
    /// <inheritdoc cref="GetParentProcess(Process)" path="/remarks" />
79
    [ExcludeFromCodeCoverage]
80
    [Pure]
81
    public static Process? GetParentProcess(int? pid = null) {
82
        using Process process = pid.HasValue ? Process.GetProcessById(pid.Value) : Process.GetCurrentProcess();
83
        return process.GetParentProcess();
84
    }
85

86
    /// <summary>
87
    /// <para>Gets the process that started a given child process.</para>
88
    /// <para>Windows only.</para>
89
    /// </summary>
90
    /// <param name="child">The child process of which you want to find the parent.</param>
91
    /// <returns>The parent process of <paramref name="child"/>. Remember to <see cref="IDisposable.Dispose"/> the returned value.</returns>
92
    /// <remarks>
93
    /// <para>By Simon Mourier: <see href="https://stackoverflow.com/a/3346055/979493"/></para>
94
    /// </remarks>
95
    [ExcludeFromCodeCoverage]
96
    [Pure]
97
    public static Process? GetParentProcess(this Process child) {
98
        try {
99
            if (0 != NtQueryInformationProcess(child.Handle, 0, out ProcessBasicInformation basicInfo, Marshal.SizeOf<ProcessBasicInformation>(), out int _)) {
100
                return null;
101
            }
102

103
            return Process.GetProcessById((int) basicInfo.InheritedFromUniqueProcessId.ToUInt32());
104
        } catch (ArgumentException) {
105
            // not found
106
            return null;
107
        } catch (InvalidOperationException) {
108
            // child process already exited
109
            return null;
110
        }
111
    }
112

113
    /// <summary>
114
    /// <para>List all currently running processes that were started by the given <paramref name="ancestor"/> process, including transitively to an unlimited depth.</para>
115
    /// <para>Windows only.</para>
116
    /// </summary>
117
    /// <param name="ancestor">The parent, grandparent, or further process that started the processes to return</param>
118
    /// <returns>List of processes which were started by either <paramref name="ancestor"/>, one of its children, grandchildren, or further to an unlimited depth. Remember to <see cref="IDisposable.Dispose"/> all of these <see cref="Process"/> instances.</returns>
119
    [Pure]
120
    public static IEnumerable<Process> GetDescendantProcesses(this Process ancestor) {
UNCOV
121
        Process[] allProcesses = Process.GetProcesses();
×
122

123
        //eagerly find child processes, because once we start killing processes, their parent PIDs won't mean anything anymore
124
        List<Process> descendants = GetDescendantProcesses(ancestor, allProcesses).ToList();
×
125

UNCOV
126
        foreach (Process nonDescendant in allProcesses.Except(descendants, ProcessIdEqualityComparer.INSTANCE)) {
×
UNCOV
127
            nonDescendant.Dispose();
×
128
        }
129

UNCOV
130
        return descendants;
×
131
    }
132

133
    [Pure]
134
    // ReSharper disable once ParameterTypeCanBeEnumerable.Local (Avoid double enumeration heuristic)
135
    private static IEnumerable<Process> GetDescendantProcesses(Process ancestor, Process[] allProcesses) =>
136
        allProcesses.SelectMany(descendant => {
×
137
            bool isDescendantOfParent = false;
×
138
            try {
×
139
                using Process? descendantParent = descendant.GetParentProcess();
×
140
                isDescendantOfParent = descendantParent?.Id == ancestor.Id;
×
141
            } catch (Exception e) when (e is not OutOfMemoryException) {
×
142
                //leave isDescendentOfParent false
×
143
            }
×
144

×
UNCOV
145
            return isDescendantOfParent ? new[] { descendant }.Concat(GetDescendantProcesses(descendant, allProcesses)) : [];
×
UNCOV
146
        });
×
147

148
    private sealed class ProcessIdEqualityComparer: IEqualityComparer<Process> {
149

150
        public static readonly ProcessIdEqualityComparer INSTANCE = new();
×
151

152
        public bool Equals(Process? x, Process? y) => ReferenceEquals(x, y) || (x is not null && y is not null && x.GetType() == y.GetType() && x.Id == y.Id);
×
153

UNCOV
154
        public int GetHashCode(Process obj) => obj.Id;
×
155

156
    }
157

158
    /// <summary>
159
    /// Determine whether a process is suspended or not.
160
    /// </summary>
161
    /// <param name="process">The process to check, such as <see cref="Process.GetCurrentProcess"/>.</param>
162
    /// <returns><c>true</c> if <paramref name="process"/> is suspended, or <c>false</c> if it is running normally</returns>
163
    [Pure]
164
    public static bool IsProcessSuspended(this Process process) {
UNCOV
165
        uint returnCode = NtQueryInformationProcess(process.Handle, ProcessInfoClass.PROCESS_BASIC_INFORMATION, out ProcessExtendedBasicInformation info,
×
UNCOV
166
            Marshal.SizeOf<ProcessExtendedBasicInformation>(), out int _);
×
UNCOV
167
        return returnCode == 0 && (info.Flags & ProcessExtendedBasicInformation.ProcessFlags.IS_FROZEN) != 0;
×
168
    }
169

170
    [DllImport("ntdll.dll", SetLastError = true)]
171
    private static extern uint NtQueryInformationProcess(IntPtr process, ProcessInfoClass query, out ProcessExtendedBasicInformation result, int inputSize, out int resultSize);
172

173
    [DllImport("ntdll.dll", SetLastError = true)]
174
    private static extern uint NtQueryInformationProcess(IntPtr process, ProcessInfoClass query, out ProcessBasicInformation result, int inputSize, out int resultSize);
175

176
    [StructLayout(LayoutKind.Sequential)]
177
    private struct ProcessExtendedBasicInformation {
178

179
        public UIntPtr                 Size;
180
        public ProcessBasicInformation BasicInfo;
181
        public ProcessFlags            Flags;
182

183
        [Flags]
184
        public enum ProcessFlags: uint {
185

186
            /*IS_PROTECTED_PROCESS    = 1 << 0,
187
            IS_WOW64_PROCESS        = 1 << 1,
188
            IS_PROCESS_DELETING     = 1 << 2,
189
            IS_CROSS_SESSION_CREATE = 1 << 3,*/
190
            IS_FROZEN = 1 << 4,
191
            /*IS_BACKGROUND           = 1 << 5,
192
            IS_STRONGLY_NAMED       = 1 << 6,
193
            IS_SECURE_PROCESS       = 1 << 7,
194
            IS_SUBSYSTEM_PROCESS    = 1 << 8,
195
            SPARE_BITS              = 1 << 9*/
196

197
        }
198

199
    }
200

201
    [StructLayout(LayoutKind.Sequential)]
202
    private struct ProcessBasicInformation {
203

204
        public uint    ExitStatus;
205
        public IntPtr  PebBaseAddress;
206
        public UIntPtr AffinityMask;
207
        public int     BasePriority;
208
        public UIntPtr UniqueProcessId;
209
        public UIntPtr InheritedFromUniqueProcessId;
210

211
    }
212

213
    private enum ProcessInfoClass: uint {
214

215
        PROCESS_BASIC_INFORMATION = 0x00,
216
        /*PROCESS_QUOTA_LIMITS                               = 0x01,
217
        PROCESS_IO_COUNTERS                                = 0x02,
218
        PROCESS_VM_COUNTERS                                = 0x03,
219
        PROCESS_TIMES                                      = 0x04,
220
        PROCESS_BASE_PRIORITY                              = 0x05,
221
        PROCESS_RAISE_PRIORITY                             = 0x06,
222
        PROCESS_DEBUG_PORT                                 = 0x07,
223
        PROCESS_EXCEPTION_PORT                             = 0x08,
224
        PROCESS_ACCESS_TOKEN                               = 0x09,
225
        PROCESS_LDT_INFORMATION                            = 0x0A,
226
        PROCESS_LDT_SIZE                                   = 0x0B,
227
        PROCESS_DEFAULT_HARD_ERROR_MODE                    = 0x0C,
228
        PROCESS_IO_PORT_HANDLERS                           = 0x0D,
229
        PROCESS_POOLED_USAGE_AND_LIMITS                    = 0x0E,
230
        PROCESS_WORKING_SET_WATCH                          = 0x0F,
231
        PROCESS_USER_MODE_IOPL                             = 0x10,
232
        PROCESS_ENABLE_ALIGNMENT_FAULT_FIXUP               = 0x11,
233
        PROCESS_PRIORITY_CLASS                             = 0x12,
234
        PROCESS_WX86_INFORMATION                           = 0x13,
235
        PROCESS_HANDLE_COUNT                               = 0x14,
236
        PROCESS_AFFINITY_MASK                              = 0x15,
237
        PROCESS_PRIORITY_BOOST                             = 0x16,
238
        PROCESS_DEVICE_MAP                                 = 0x17,
239
        PROCESS_SESSION_INFORMATION                        = 0x18,
240
        PROCESS_FOREGROUND_INFORMATION                     = 0x19,
241
        PROCESS_WOW64_INFORMATION                          = 0x1A,
242
        PROCESS_IMAGE_FILE_NAME                            = 0x1B,
243
        PROCESS_LUID_DEVICE_MAPS_ENABLED                   = 0x1C,
244
        PROCESS_BREAK_ON_TERMINATION                       = 0x1D,
245
        PROCESS_DEBUG_OBJECT_HANDLE                        = 0x1E,
246
        PROCESS_DEBUG_FLAGS                                = 0x1F,
247
        PROCESS_HANDLE_TRACING                             = 0x20,
248
        PROCESS_IO_PRIORITY                                = 0x21,
249
        PROCESS_EXECUTE_FLAGS                              = 0x22,
250
        PROCESS_RESOURCE_MANAGEMENT                        = 0x23,
251
        PROCESS_COOKIE                                     = 0x24,
252
        PROCESS_IMAGE_INFORMATION                          = 0x25,
253
        PROCESS_CYCLE_TIME                                 = 0x26,
254
        PROCESS_PAGE_PRIORITY                              = 0x27,
255
        PROCESS_INSTRUMENTATION_CALLBACK                   = 0x28,
256
        PROCESS_THREAD_STACK_ALLOCATION                    = 0x29,
257
        PROCESS_WORKING_SET_WATCH_EX                       = 0x2A,
258
        PROCESS_IMAGE_FILE_NAME_WIN32                      = 0x2B,
259
        PROCESS_IMAGE_FILE_MAPPING                         = 0x2C,
260
        PROCESS_AFFINITY_UPDATE_MODE                       = 0x2D,
261
        PROCESS_MEMORY_ALLOCATION_MODE                     = 0x2E,
262
        PROCESS_GROUP_INFORMATION                          = 0x2F,
263
        PROCESS_TOKEN_VIRTUALIZATION_ENABLED               = 0x30,
264
        PROCESS_CONSOLE_HOST_PROCESS                       = 0x31,
265
        PROCESS_WINDOW_INFORMATION                         = 0x32,
266
        PROCESS_HANDLE_INFORMATION                         = 0x33,
267
        PROCESS_MITIGATION_POLICY                          = 0x34,
268
        PROCESS_DYNAMIC_FUNCTION_TABLE_INFORMATION         = 0x35,
269
        PROCESS_HANDLE_CHECKING_MODE                       = 0x36,
270
        PROCESS_KEEP_ALIVE_COUNT                           = 0x37,
271
        PROCESS_REVOKE_FILE_HANDLES                        = 0x38,
272
        PROCESS_WORKING_SET_CONTROL                        = 0x39,
273
        PROCESS_HANDLE_TABLE                               = 0x3A,
274
        PROCESS_CHECK_STACK_EXTENTS_MODE                   = 0x3B,
275
        PROCESS_COMMAND_LINE_INFORMATION                   = 0x3C,
276
        PROCESS_PROTECTION_INFORMATION                     = 0x3D,
277
        PROCESS_MEMORY_EXHAUSTION                          = 0x3E,
278
        PROCESS_FAULT_INFORMATION                          = 0x3F,
279
        PROCESS_TELEMETRY_ID_INFORMATION                   = 0x40,
280
        PROCESS_COMMIT_RELEASE_INFORMATION                 = 0x41,
281
        PROCESS_DEFAULT_CPU_SETS_INFORMATION               = 0x42,
282
        PROCESS_ALLOWED_CPU_SETS_INFORMATION               = 0x43,
283
        PROCESS_SUBSYSTEM_PROCESS                          = 0x44,
284
        PROCESS_JOB_MEMORY_INFORMATION                     = 0x45,
285
        PROCESS_IN_PRIVATE                                 = 0x46,
286
        PROCESS_RAISE_UM_EXCEPTION_ON_INVALID_HANDLE_CLOSE = 0x47,
287
        PROCESS_IUM_CHALLENGE_RESPONSE                     = 0x48,
288
        PROCESS_CHILD_PROCESS_INFORMATION                  = 0x49,
289
        PROCESS_HIGH_GRAPHICS_PRIORITY_INFORMATION         = 0x4A,
290
        PROCESS_SUBSYSTEM_INFORMATION                      = 0x4B,
291
        PROCESS_ENERGY_VALUES                              = 0x4C,
292
        PROCESS_ACTIVITY_THROTTLE_STATE                    = 0x4D,
293
        PROCESS_ACTIVITY_THROTTLE_POLICY                   = 0x4E,
294
        PROCESS_WIN32_K_SYSCALL_FILTER_INFORMATION         = 0x4F,
295
        PROCESS_DISABLE_SYSTEM_ALLOWED_CPU_SETS            = 0x50,
296
        PROCESS_WAKE_INFORMATION                           = 0x51,
297
        PROCESS_ENERGY_TRACKING_STATE                      = 0x52,
298
        PROCESS_MANAGE_WRITES_TO_EXECUTABLE_MEMORY         = 0x53,
299
        PROCESS_CAPTURE_TRUSTLET_LIVE_DUMP                 = 0x54,
300
        PROCESS_TELEMETRY_COVERAGE                         = 0x55,
301
        PROCESS_ENCLAVE_INFORMATION                        = 0x56,
302
        PROCESS_ENABLE_READ_WRITE_VM_LOGGING               = 0x57,
303
        PROCESS_UPTIME_INFORMATION                         = 0x58,
304
        PROCESS_IMAGE_SECTION                              = 0x59,
305
        PROCESS_DEBUG_AUTH_INFORMATION                     = 0x5A,
306
        PROCESS_SYSTEM_RESOURCE_MANAGEMENT                 = 0x5B,
307
        PROCESS_SEQUENCE_NUMBER                            = 0x5C,
308
        PROCESS_LOADER_DETOUR                              = 0x5D,
309
        PROCESS_SECURITY_DOMAIN_INFORMATION                = 0x5E,
310
        PROCESS_COMBINE_SECURITY_DOMAINS_INFORMATION       = 0x5F,
311
        PROCESS_ENABLE_LOGGING                             = 0x60,
312
        PROCESS_LEAP_SECOND_INFORMATION                    = 0x61,
313
        PROCESS_FIBER_SHADOW_STACK_ALLOCATION              = 0x62,
314
        PROCESS_FREE_FIBER_SHADOW_STACK_ALLOCATION         = 0x63,
315
        MAX_PROCESS_INFO_CLASS                             = 0x64*/
316

317
    }
318

319
    /// <summary>
320
    /// <para>Call this on a child process if you want it to detach from the console and ignore Ctrl+C, because your parent console process will handle that signal.</para>
321
    /// <para>Strangely, in Windows console applications, pressing Ctrl+C in the terminal will send the signal to every attached descendant in the terminal's process tree, not just the top-most child running directly in the terminal.</para>
322
    /// <para>This is necessary if you have custom Ctrl+C handling in your parent (using <see cref="Console.CancelKeyPress"/>), and don't want the child to ignore that and exit on the first Ctrl+C anyway.</para>
323
    /// <para>The best way to solve this is by the child not attaching to the console in the first place, or detaching with <c>FreeConsole()</c>, but this is not possible if you can't make code changes to the child program. The second-best way to solve this is by specifying a <c>dwCreationFlags</c> of <c>DETACHED_PROCESS (0x8)</c> when calling <c>CreateProcess</c>, but these flags are insufficiently customizable when wrapped by .NET's <see cref="ProcessStartInfo"/> (API cliff).</para>
324
    /// <para>This technique injects a thread into the child process that calls <c>FreeConsole</c>, which is better than copying and reimplementing all of <see cref="Process.Start()"/> from the .NET BCL repository.</para>
325
    /// </summary>
326
    /// <param name="process"></param>
327
    public static void DetachFromConsole(this Process process) {
UNCOV
328
        int targetPid = process.Id;
×
329
        int selfPid;
330
#if NET5_0_OR_GREATER
331
        selfPid = Environment.ProcessId;
×
332
#else
333
        using Process selfProcess = Process.GetCurrentProcess();
334
        selfPid = selfProcess.Id;
335
#endif
336

337
        if (targetPid == selfPid) {
×
UNCOV
338
            FreeConsole();
×
339
        } else {
340
            // https://codingvision.net/c-inject-a-dll-into-a-process-w-createremotethread
UNCOV
341
            using SafeProcessHandle safeProcessHandle = OpenProcess(ProcessSecurityAndAccessRight.PROCESS_CREATE_THREAD, false, targetPid);
×
342

UNCOV
343
            IntPtr methodAddr = GetProcAddress(GetModuleHandle("kernel32.dll"), "FreeConsole");
×
UNCOV
344
            CreateRemoteThread(safeProcessHandle, IntPtr.Zero, 0, methodAddr, IntPtr.Zero, 0, IntPtr.Zero);
×
345
        }
UNCOV
346
    }
×
347

348
    [Flags]
349
    private enum ProcessSecurityAndAccessRight: uint {
350

351
        PROCESS_CREATE_THREAD = 0x2,
352

353
        /*PROCESS_QUERY_INFORMATION = 0x400,
354
        PROCESS_VM_OPERATION      = 0x8,
355
        PROCESS_VM_WRITE          = 0x20,
356
        PROCESS_VM_READ = 0x10*/
357

358
    }
359

360
    [DllImport("kernel32.dll")]
361
    [return: MarshalAs(UnmanagedType.Bool)]
362
    private static extern bool FreeConsole();
363

364
    [DllImport("kernel32.dll")]
365
    private static extern SafeProcessHandle OpenProcess(ProcessSecurityAndAccessRight dwDesiredAccess, bool bInheritHandle, int dwProcessId);
366

367
    [DllImport("kernel32.dll", CharSet = CharSet.Auto)]
368
    private static extern IntPtr GetModuleHandle(string lpModuleName);
369

370
    [DllImport("kernel32.dll", CharSet = CharSet.Ansi, ExactSpelling = true, SetLastError = true)]
371
    private static extern IntPtr GetProcAddress(IntPtr hModule, string procName);
372

373
    [DllImport("kernel32.dll")]
374
    private static extern IntPtr CreateRemoteThread(SafeProcessHandle hProcess, IntPtr lpThreadAttributes, uint dwStackSize, IntPtr lpStartAddress, IntPtr lpParameter, uint dwCreationFlags,
375
                                                    IntPtr lpThreadId);
376

377
    /// <summary>
378
    /// Determine whether a process is running elevated (as Administrator) or not.
379
    /// </summary>
380
    /// <param name="process">The process to check, such as <see cref="Process.GetCurrentProcess"/>.</param>
381
    /// <returns><c>true</c> if <paramref name="process"/> is running elevated, or <c>false</c> if it is unelevated</returns>
382
    /// <exception cref="Win32Exception">failed to open handle to <paramref name="process"/>, possibly due to privileges.</exception>
383
    /// <remarks>
384
    /// By John Smith: <see href="https://stackoverflow.com/a/55079599/979493"/>
385
    /// </remarks>
386
    [Pure]
387
    public static bool IsProcessElevated(this Process process) {
388
        const uint maximumAllowed = 0x2000000;
389

UNCOV
390
        if (!OpenProcessToken(process.Handle, maximumAllowed, out nint token)) {
×
UNCOV
391
            throw new Win32Exception(Marshal.GetLastWin32Error(), "OpenProcessToken failed");
×
392
        }
393

394
        try {
UNCOV
395
            using WindowsIdentity identity  = new(token);
×
UNCOV
396
            WindowsPrincipal      principal = new(identity);
×
UNCOV
397
            return principal.IsInRole(WindowsBuiltInRole.Administrator)
×
UNCOV
398
                || principal.IsInRole(0x200); //Domain Administrator
×
399
        } finally {
UNCOV
400
            CloseHandle(token);
×
UNCOV
401
        }
×
UNCOV
402
    }
×
403

404
    [DllImport("advapi32.dll", SetLastError = true)]
405
    [return: MarshalAs(UnmanagedType.Bool)]
406
    private static extern bool OpenProcessToken(nint processHandle, uint desiredAccess, out nint tokenHandle);
407

408
    [DllImport("kernel32.dll", SetLastError = true)]
409
    [return: MarshalAs(UnmanagedType.Bool)]
410
    private static extern bool CloseHandle(nint hObject);
411

412
}
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