diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6545911 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,155 @@ +name: CI/CD Pipeline + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main ] + release: + types: [ published ] + +jobs: + test: + name: Test on ${{ matrix.os }} with ${{ matrix.compiler }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + compiler: [gcc, clang, msvc] + exclude: + - os: windows-latest + compiler: gcc + - os: windows-latest + compiler: clang + - os: ubuntu-latest + compiler: msvc + - os: macos-latest + compiler: msvc + + steps: + - uses: actions/checkout@v4 + + - name: Setup GCC + if: matrix.compiler == 'gcc' + uses: egor-tensin/setup-gcc@v1 + with: + version: 14 + + - name: Setup Clang + if: matrix.compiler == 'clang' + uses: egor-tensin/setup-clang@v1 + with: + version: 19 + + - name: Setup MSVC + if: matrix.compiler == 'msvc' + uses: microsoft/setup-msbuild@v1.3 + + - name: Configure CMake (Unix) + if: runner.os != 'Windows' + run: | + cmake -B build -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_CXX_COMPILER=${{ matrix.compiler == 'gcc' && 'g++' || 'clang++' }} + + - name: Configure CMake (Windows) + if: runner.os == 'Windows' + run: cmake -B build -DCMAKE_BUILD_TYPE=Release + + - name: Build + run: cmake --build build --config Release + + - name: Run Tests + run: cmake --build build --target test --config Release + + static-analysis: + name: Static Analysis + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Dependencies + run: | + sudo apt-get update + sudo apt-get install -y cppcheck clang-tidy + + - name: Run cppcheck + run: | + cppcheck --enable=all --std=c++20 --suppress=missingIncludeSystem \ + --error-exitcode=1 CatalystCX.hpp + + - name: Configure for clang-tidy + run: cmake -B build -DCMAKE_EXPORT_COMPILE_COMMANDS=ON + + - name: Run clang-tidy + run: | + clang-tidy CatalystCX.hpp -p build --checks='-*,readability-*,performance-*,modernize-*' + + sanitizers: + name: Sanitizer Tests + runs-on: ubuntu-latest + strategy: + matrix: + sanitizer: [address, thread, undefined] + steps: + - uses: actions/checkout@v4 + + - name: Configure with Sanitizers + run: | + cmake -B build -DCMAKE_BUILD_TYPE=Debug \ + -DCMAKE_CXX_FLAGS="-fsanitize=${{ matrix.sanitizer }} -g -O1" + + - name: Build + run: cmake --build build + + - name: Run Tests with Sanitizer + run: cmake --build build --target test + + package: + name: Package Release + runs-on: ubuntu-latest + needs: [test, static-analysis] + if: github.event_name == 'release' + steps: + - uses: actions/checkout@v4 + + - name: Create Package + run: | + mkdir -p catalystcx-${{ github.ref_name }} + cp CatalystCX.hpp catalystcx-${{ github.ref_name }}/ + cp README.md catalystcx-${{ github.ref_name }}/ + cp CMakeLists.txt catalystcx-${{ github.ref_name }}/ + tar -czf catalystcx-${{ github.ref_name }}.tar.gz catalystcx-${{ github.ref_name }}/ + + - name: Upload Release Asset + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ github.event.release.upload_url }} + asset_path: ./catalystcx-${{ github.ref_name }}.tar.gz + asset_name: catalystcx-${{ github.ref_name }}.tar.gz + asset_content_type: application/gzip + + benchmark: + name: Performance Benchmarks + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Configure Optimized Build + run: | + cmake -B build -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_CXX_FLAGS="-O3 -march=native -DNDEBUG" + + - name: Build + run: cmake --build build + + - name: Run Benchmarks + run: | + echo "=== Performance Test ===" + time ctest --test-dir build --output-on-failure + echo "=== Memory Usage Test ===" + sudo apt-get update && sudo apt-get install -y valgrind + valgrind --tool=massif --massif-out-file=massif.out \ + ./build/cxtest 2>/dev/null || true \ No newline at end of file diff --git a/.gitignore b/.gitignore index f35e3c0..f5f39a4 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ *build* +.amazonq \ No newline at end of file diff --git a/CMakeLists.txt b/CMakeLists.txt index 22d2c13..b3e943f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,25 +1,24 @@ cmake_minimum_required(VERSION 3.10) -project(CatalystCX VERSION 0.0.1) +project(CatalystCX) set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD_REQUIRED ON) -set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -O2 -g0") +# Compiler flags +set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Wall -Wextra -O2") -add_executable(CatalystCX - CatalystCX.cpp -) +# Test suite executable +add_executable(cxtest Evaluate.cpp CatalystCX.hpp) -# --- Run Target --- -add_custom_target(run - COMMAND ${CMAKE_COMMAND} -E env "PATH=$ENV{PATH}" ./CatalystCX - DEPENDS CatalystCX +# Custom target to run tests +add_custom_target(test + COMMAND cxtest + DEPENDS cxtest WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR} ) -# --- Install Target --- -include(GNUInstallDirs) +# Install header +install(FILES CatalystCX.hpp DESTINATION include) -install(FILES CatalystCX.hpp - DESTINATION ${CMAKE_INSTALL_INCLUDEDIR} -) \ No newline at end of file +find_package(Threads REQUIRED) +target_link_libraries(cxtest PRIVATE Threads::Threads) \ No newline at end of file diff --git a/CatalystCX.cpp b/CatalystCX.cpp deleted file mode 100644 index f72d855..0000000 --- a/CatalystCX.cpp +++ /dev/null @@ -1,48 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -// Copyright (c) 2025 assembler-0 -// Licensed under GPL-3.0-or-later -#include "CatalystCX.hpp" -#include - -void PrintResult(const CommandResult& result) { - std::cout << "Exit Code: " << result.ExitCode << std::endl; - std::cout << "Timed Out: " << (result.TimedOut ? "Yes" : "No") << std::endl; - std::cout << "Execution Time: " << result.ExecutionTime.count() << "s" << std::endl; - -#ifdef __linux__ - std::cout << "User CPU Time: " << result.Usage.UserCpuTime << "us" << std::endl; - std::cout << "System CPU Time: " << result.Usage.SystemCpuTime << "us" << std::endl; - std::cout << "Max Resident Set Size: " << result.Usage.MaxResidentSetSize << "KB" << std::endl; -#endif - - std::cout << "\nStdout:\n" << result.Stdout << std::endl; - std::cout << "\nStderr:\n" << result.Stderr << std::endl; -} - -int main() { - std::cout << "--- Running ls -l ---" << std::endl; - auto result = Command("ls").Arg("-l").Status(); - PrintResult(result); - - std::cout << "\n--- Running ping with a 2-second timeout ---" << std::endl; - auto ping_result = Command("ping").Arg("8.8.8.8").Timeout(std::chrono::seconds(2)).Status(); - PrintResult(ping_result); - - std::cout << "\n--- Spawning a long-running process and waiting for it ---" << std::endl; - if (auto child = Command("sleep").Arg("3").Spawn()) { - std::cout << "Process spawned with PID: " << child->GetPid() << std::endl; - auto sleep_result = child->Wait(); - PrintResult(sleep_result); - } else { - std::cerr << "Failed to spawn sleep process." << std::endl; - } - - std::cout << "\n--- Attempting to spawn a non-existent command ---" << std::endl; - if (auto child = Command("nonexistentcommand").Spawn()) { - child->Wait(); - } else { - std::cerr << "Failed to spawn non-existent command, as expected." << std::endl; - } - - return 0; -} \ No newline at end of file diff --git a/CatalystCX.hpp b/CatalystCX.hpp index fa37eb5..a9244e1 100644 --- a/CatalystCX.hpp +++ b/CatalystCX.hpp @@ -1,107 +1,151 @@ // SPDX-License-Identifier: GPL-3.0-or-later // Copyright (c) 2025 assembler-0 // Licensed under GPL-3.0-or-later + +/** + * @brief CatalystCX - A cross-platform single-header C++ library for executing and managing external processes (or commands). + * @version 0.0.1 + * @author assembler-0 + */ + #pragma once #ifndef CATALYSTCX_HPP #define CATALYSTCX_HPP #include #include -#include -#include #include #include #include -#include #include #include #include +#include +#include +#include + +#ifdef _WIN32 +#include +#include +#include +#include +using pid_t = DWORD; +#else +#include +#include +#include #include #include #include #include -#include -#include -#include -#include +#ifdef __APPLE__ +#include +extern char **environ; +#endif +#endif namespace fs = std::filesystem; -// A struct to hold the result of a command execution struct CommandResult { int ExitCode{}; std::string Stdout; std::string Stderr; std::chrono::duration ExecutionTime{}; bool TimedOut = false; + + bool KilledBySignal = false; + int TerminatingSignal = 0; + bool CoreDumped = false; + bool Stopped = false; + int StopSignal = 0; -#ifdef __linux__ struct ResourceUsage { - long UserCpuTime; // in microseconds - long SystemCpuTime; // in microseconds - long MaxResidentSetSize; // in kilobytes - } Usage{}; +#if defined(__linux__) + long UserCpuTime; + long SystemCpuTime; + long MaxResidentSetSize; + long MinorPageFaults; + long MajorPageFaults; + long VoluntaryContextSwitches; + long InvoluntaryContextSwitches; +#elif defined(_WIN32) + FILETIME UserTime; + FILETIME KernelTime; + SIZE_T PeakWorkingSetSize; + SIZE_T PageFaultCount; #endif + } Usage{}; }; class Child { public: +#ifdef _WIN32 + Child(HANDLE process, HANDLE thread, HANDLE stdout_handle, HANDLE stderr_handle) + : ProcessHandle(process), ThreadHandle(thread), StdoutHandle(stdout_handle), StderrHandle(stderr_handle) { + ProcessId = GetProcessId(process); + } + + ~Child() { + if (ProcessHandle != INVALID_HANDLE_VALUE) CloseHandle(ProcessHandle); + if (ThreadHandle != INVALID_HANDLE_VALUE) CloseHandle(ThreadHandle); + } +#else Child(const pid_t pid, const int stdout_fd, const int stderr_fd) : ProcessId(pid), StdoutFd(stdout_fd), StderrFd(stderr_fd) {} +#endif - // Wait for the child process to exit and return the result [[nodiscard]] CommandResult Wait(std::optional> timeout = std::nullopt) const; - - // Get the process ID [[nodiscard]] pid_t GetPid() const { return ProcessId; } - // Send a signal to the process +#ifdef _WIN32 + void Kill(int signal = 0) const; +#else void Kill(int signal = SIGTERM) const; +#endif private: pid_t ProcessId; +#ifdef _WIN32 + HANDLE ProcessHandle; + HANDLE ThreadHandle; + HANDLE StdoutHandle; + HANDLE StderrHandle; +#else int StdoutFd; int StderrFd; +#endif }; class Command { public: explicit Command(std::string executable) : Executable(std::move(executable)) {} - // Add a single argument Command& Arg(std::string argument) { Arguments.push_back(std::move(argument)); return *this; } - // Add multiple arguments Command& Args(const std::vector& arguments) { Arguments.insert(Arguments.end(), arguments.begin(), arguments.end()); return *this; } - // Set the working directory Command& WorkingDirectory(std::string path) { WorkDir = std::move(path); return *this; } - // Set an environment variable Command& Environment(const std::string& key, const std::string& value) { EnvVars[key] = value; return *this; } - // Set a timeout for the command Command& Timeout(std::chrono::duration duration) { TimeoutDuration = duration; return *this; } - // Execute the command and wait for it to complete [[nodiscard]] CommandResult Status(); - - // Spawn the command and return a Child object [[nodiscard]] std::optional Spawn(); private: @@ -114,17 +158,27 @@ class Command { class AsyncPipeReader { public: +#ifdef _WIN32 + static std::pair ReadPipes(HANDLE stdout_handle, HANDLE stderr_handle); +private: + static bool ReadFromPipe(PipeData& pipe_data, std::array& buffer); +#else static std::pair ReadPipes(int stdout_fd, int stderr_fd); - private: + struct PipeData { +#ifdef _WIN32 + HANDLE Handle; +#else int Fd; +#endif std::string Buffer; bool Finished = false; }; static bool ReadFromPipe(PipeData& pipe_data, std::array& buffer); static bool IsPipeOpen(int fd); +#endif }; class ExecutionValidator { @@ -134,7 +188,197 @@ class ExecutionValidator { static bool CanExecuteCommand(const std::vector& args); }; -// Implementation of Child methods +// Windows implementations +#ifdef _WIN32 +inline CommandResult Child::Wait(std::optional> timeout) const { + auto start_time = std::chrono::steady_clock::now(); + CommandResult result; + + DWORD wait_time = timeout ? static_cast(timeout->count() * 1000) : INFINITE; + DWORD wait_result = WaitForSingleObject(ProcessHandle, wait_time); + + auto end_time = std::chrono::steady_clock::now(); + result.ExecutionTime = end_time - start_time; + + if (wait_result == WAIT_TIMEOUT) { + result.TimedOut = true; + TerminateProcess(ProcessHandle, 1); + WaitForSingleObject(ProcessHandle, INFINITE); + } + + DWORD exit_code; + GetExitCodeProcess(ProcessHandle, &exit_code); + result.ExitCode = static_cast(exit_code); + + FILETIME creation_time, exit_time; + GetProcessTimes(ProcessHandle, &creation_time, &exit_time, + &result.Usage.KernelTime, &result.Usage.UserTime); + + PROCESS_MEMORY_COUNTERS pmc; + if (GetProcessMemoryInfo(ProcessHandle, &pmc, sizeof(pmc))) { + result.Usage.PeakWorkingSetSize = pmc.PeakWorkingSetSize; + result.Usage.PageFaultCount = pmc.PageFaultCount; + } + + auto [stdout_result, stderr_result] = AsyncPipeReader::ReadPipes(StdoutHandle, StderrHandle); + result.Stdout = std::move(stdout_result); + result.Stderr = std::move(stderr_result); + + CloseHandle(StdoutHandle); + CloseHandle(StderrHandle); + + return result; +} + +inline void Child::Kill(int) const { + TerminateProcess(ProcessHandle, 1); +} + +inline std::optional Command::Spawn() { + std::vector args_vec; + args_vec.push_back(Executable); + args_vec.insert(args_vec.end(), Arguments.begin(), Arguments.end()); + + if (!ExecutionValidator::CanExecuteCommand(args_vec)) { + return std::nullopt; + } + + SECURITY_ATTRIBUTES sa = {sizeof(SECURITY_ATTRIBUTES), nullptr, TRUE}; + + HANDLE stdout_read, stdout_write, stderr_read, stderr_write; + if (!CreatePipe(&stdout_read, &stdout_write, &sa, 0) || + !CreatePipe(&stderr_read, &stderr_write, &sa, 0)) { + return std::nullopt; + } + + SetHandleInformation(stdout_read, HANDLE_FLAG_INHERIT, 0); + SetHandleInformation(stderr_read, HANDLE_FLAG_INHERIT, 0); + + STARTUPINFOA si = {sizeof(STARTUPINFOA)}; + si.dwFlags = STARTF_USESTDHANDLES; + si.hStdOutput = stdout_write; + si.hStdError = stderr_write; + si.hStdInput = GetStdHandle(STD_INPUT_HANDLE); + + PROCESS_INFORMATION pi = {}; + + auto QuoteArgWin = [](const std::string& s) -> std::string { + bool need_quotes = s.find_first_of(" \t\"") != std::string::npos; + if (!need_quotes) return s; + std::string out; + out.push_back('"'); + size_t bs = 0; + for (char c : s) { + if (c == '\\') { ++bs; continue; } + if (c == '"') { out.append(bs * 2 + 1, '\\'); out.push_back('"'); bs = 0; continue; } + if (bs) { out.append(bs, '\\'); bs = 0; } + out.push_back(c); + } + if (bs) out.append(bs * 2, '\\'); + out.push_back('"'); + return out; + }; + std::string cmdline = QuoteArgWin(Executable); + for (const auto& arg : Arguments) { + cmdline += ' '; + cmdline += QuoteArgWin(arg); + } + + std::string env_block; + if (!EnvVars.empty()) { + for (const auto& [key, value] : EnvVars) { + env_block += key + "=" + value + "\0"; + } + env_block += "\0"; + } + + BOOL success = CreateProcessA( + nullptr, const_cast(cmdline.c_str()), + nullptr, nullptr, TRUE, 0, + env_block.empty() ? nullptr : const_cast(env_block.c_str()), + WorkDir ? WorkDir->c_str() : nullptr, + &si, &pi + ); + + CloseHandle(stdout_write); + CloseHandle(stderr_write); + + if (!success) { + CloseHandle(stdout_read); + CloseHandle(stderr_read); + return std::nullopt; + } + + return Child(pi.hProcess, pi.hThread, stdout_read, stderr_read); +} + +inline std::pair AsyncPipeReader::ReadPipes(HANDLE stdout_handle, HANDLE stderr_handle) { + PipeData stdout_data{stdout_handle, {}}; + PipeData stderr_data{stderr_handle, {}}; + + std::array buffer; + + while (!stdout_data.Finished || !stderr_data.Finished) { + if (!stdout_data.Finished && !ReadFromPipe(stdout_data, buffer)) { + stdout_data.Finished = true; + } + if (!stderr_data.Finished && !ReadFromPipe(stderr_data, buffer)) { + stderr_data.Finished = true; + } + if (!stdout_data.Finished || !stderr_data.Finished) { + Sleep(10); + } + } + + return {std::move(stdout_data.Buffer), std::move(stderr_data.Buffer)}; +} + +inline bool AsyncPipeReader::ReadFromPipe(PipeData& pipe_data, std::array& buffer) { + DWORD bytes_read; + if (ReadFile(pipe_data.Handle, buffer.data(), buffer.size(), &bytes_read, nullptr)) { + if (bytes_read > 0) { + pipe_data.Buffer.append(buffer.data(), bytes_read); + return true; + } + } + return false; +} + +inline bool ExecutionValidator::IsFileExecutable(const std::string& path) { + DWORD attrs = GetFileAttributesA(path.c_str()); + return attrs != INVALID_FILE_ATTRIBUTES && !(attrs & FILE_ATTRIBUTE_DIRECTORY); +} + +inline bool ExecutionValidator::IsCommandExecutable(const std::string& command) { + if (command.find('\\') != std::string::npos || command.find('/') != std::string::npos) { + return IsFileExecutable(command) || IsFileExecutable(command + ".exe"); + } + + const char* path_env = getenv("PATH"); + if (!path_env) return false; + + std::string path_str(path_env); + size_t start = 0; + size_t end = path_str.find(';'); + + while (start < path_str.length()) { + std::string dir = path_str.substr(start, end - start); + std::string full_path = dir + "\\" + command; + + if (IsFileExecutable(full_path) || IsFileExecutable(full_path + ".exe")) { + return true; + } + + if (end == std::string::npos) break; + start = end + 1; + end = path_str.find(';', start); + } + + return false; +} + +#else +// Unix implementations inline CommandResult Child::Wait(std::optional> timeout) const { auto start_time = std::chrono::steady_clock::now(); @@ -182,10 +426,27 @@ inline CommandResult Child::Wait(std::optional> ti result.Usage.UserCpuTime = usage.ru_utime.tv_sec * 1000000 + usage.ru_utime.tv_usec; result.Usage.SystemCpuTime = usage.ru_stime.tv_sec * 1000000 + usage.ru_stime.tv_usec; result.Usage.MaxResidentSetSize = usage.ru_maxrss; + result.Usage.MinorPageFaults = usage.ru_minflt; + result.Usage.MajorPageFaults = usage.ru_majflt; + result.Usage.VoluntaryContextSwitches = usage.ru_nvcsw; + result.Usage.InvoluntaryContextSwitches = usage.ru_nivcsw; #endif + // Enhanced process termination analysis if (!result.TimedOut) { - result.ExitCode = WEXITSTATUS(status); + if (WIFEXITED(status)) { + result.ExitCode = WEXITSTATUS(status); + } else if (WIFSIGNALED(status)) { + result.KilledBySignal = true; + result.TerminatingSignal = WTERMSIG(status); + result.ExitCode = 128 + result.TerminatingSignal; +#ifdef WCOREDUMP + result.CoreDumped = WCOREDUMP(status); +#endif + } else if (WIFSTOPPED(status)) { + result.Stopped = true; + result.StopSignal = WSTOPSIG(status); + } } return result; @@ -195,17 +456,6 @@ inline void Child::Kill(const int signal) const { kill(ProcessId, signal); } -// Implementation of Command methods -inline CommandResult Command::Status() { - if (const auto child = Spawn()) { - return child->Wait(TimeoutDuration); - } - CommandResult result; - result.ExitCode = 127; - result.Stderr = "Failed to spawn process"; - return result; -} - inline std::optional Command::Spawn() { std::vector args_vec; args_vec.push_back(Executable); @@ -220,32 +470,86 @@ inline std::optional Command::Spawn() { return std::nullopt; } +#ifdef __APPLE__ + posix_spawn_file_actions_t file_actions; + posix_spawnattr_t attr; + + if (posix_spawn_file_actions_init(&file_actions) != 0 || + posix_spawnattr_init(&attr) != 0) { + close(stdout_pipe[0]); close(stdout_pipe[1]); + close(stderr_pipe[0]); close(stderr_pipe[1]); + return std::nullopt; + } + + posix_spawn_file_actions_adddup2(&file_actions, stdout_pipe[1], STDOUT_FILENO); + posix_spawn_file_actions_adddup2(&file_actions, stderr_pipe[1], STDERR_FILENO); + posix_spawn_file_actions_addclose(&file_actions, stdout_pipe[0]); + posix_spawn_file_actions_addclose(&file_actions, stdout_pipe[1]); + posix_spawn_file_actions_addclose(&file_actions, stderr_pipe[0]); + posix_spawn_file_actions_addclose(&file_actions, stderr_pipe[1]); + + if (WorkDir) { + posix_spawn_file_actions_addchdir_np(&file_actions, WorkDir->c_str()); + } + + std::vector argv, envp; + argv.reserve(args_vec.size() + 1); + for (const auto& s : args_vec) { + argv.push_back(const_cast(s.c_str())); + } + argv.push_back(nullptr); + + std::vector env_strings; + if (!EnvVars.empty()) { + for (char** env = environ; *env; ++env) { + std::string env_str(*env); + std::string key = env_str.substr(0, env_str.find('=')); + if (EnvVars.find(key) == EnvVars.end()) { + env_strings.push_back(env_str); + } + } + for (const auto& [key, value] : EnvVars) { + env_strings.push_back(key + "=" + value); + } + for (const auto& s : env_strings) { + envp.push_back(const_cast(s.c_str())); + } + envp.push_back(nullptr); + } + + pid_t pid; + int result = posix_spawnp(&pid, argv[0], &file_actions, &attr, + argv.data(), envp.empty() ? environ : envp.data()); // spawnp for PATH search + + posix_spawn_file_actions_destroy(&file_actions); + posix_spawnattr_destroy(&attr); + + if (result != 0) { + close(stdout_pipe[0]); close(stdout_pipe[1]); + close(stderr_pipe[0]); close(stderr_pipe[1]); + return std::nullopt; + } +#else const pid_t pid = fork(); if (pid == -1) { - close(stdout_pipe[0]); - close(stdout_pipe[1]); - close(stderr_pipe[0]); - close(stderr_pipe[1]); + close(stdout_pipe[0]); close(stdout_pipe[1]); + close(stderr_pipe[0]); close(stderr_pipe[1]); return std::nullopt; } - if (pid == 0) { // Child process - if (WorkDir) { - if (chdir(WorkDir->c_str()) != 0) { - _exit(127); - } + if (pid == 0) { + if (WorkDir && chdir(WorkDir->c_str()) != 0) { + _exit(127); } - for(const auto &[fst, snd] : EnvVars) { - setenv(fst.c_str(), snd.c_str(), 1); + for(const auto &[key, value] : EnvVars) { + setenv(key.c_str(), value.c_str(), 1); } dup2(stdout_pipe[1], STDOUT_FILENO); dup2(stderr_pipe[1], STDERR_FILENO); - close(stdout_pipe[0]); - close(stdout_pipe[1]); - close(stderr_pipe[0]); - close(stderr_pipe[1]); + close(stdout_pipe[0]); close(stdout_pipe[1]); + close(stderr_pipe[0]); close(stderr_pipe[1]); std::vector argv; argv.reserve(args_vec.size() + 1); @@ -255,10 +559,10 @@ inline std::optional Command::Spawn() { argv.push_back(nullptr); execvp(argv[0], argv.data()); - _exit(127); // exec failed + _exit(127); } +#endif - // Parent process close(stdout_pipe[1]); close(stderr_pipe[1]); @@ -314,78 +618,107 @@ inline bool AsyncPipeReader::ReadFromPipe(PipeData& pipe_data, std::array= PATH_MAX) { - continue; - } - if (IsFileExecutable(full_path)) { return true; } + + if (end == std::string::npos) break; + start = end + 1; + end = path_str.find(':', start); } return false; } +inline bool ExecutionValidator::IsFileExecutable(const std::string& path) { + struct stat st{}; + return stat(path.c_str(), &st) == 0 && st.st_mode & S_IXUSR; +} + +#endif + +inline CommandResult Command::Status() { + if (const auto child = Spawn()) { + return child->Wait(TimeoutDuration); + } + CommandResult result; + result.ExitCode = 127; + result.Stderr = "Failed to spawn process"; + return result; +} + inline bool ExecutionValidator::CanExecuteCommand(const std::vector& args) { - if (args.empty()) return false; - return IsCommandExecutable(args[0]); + return !args.empty() && IsCommandExecutable(args[0]); } +class SignalInfo { +public: + static const char* GetSignalName(const int signal) { +#ifndef _WIN32 + switch (signal) { + case SIGTERM: return "SIGTERM"; + case SIGKILL: return "SIGKILL"; + case SIGINT: return "SIGINT"; + case SIGQUIT: return "SIGQUIT"; + case SIGABRT: return "SIGABRT"; + case SIGFPE: return "SIGFPE"; + case SIGILL: return "SIGILL"; + case SIGSEGV: return "SIGSEGV"; + case SIGBUS: return "SIGBUS"; + case SIGPIPE: return "SIGPIPE"; + case SIGALRM: return "SIGALRM"; + case SIGUSR1: return "SIGUSR1"; + case SIGUSR2: return "SIGUSR2"; + case SIGCHLD: return "SIGCHLD"; + case SIGCONT: return "SIGCONT"; + case SIGSTOP: return "SIGSTOP"; + case SIGTSTP: return "SIGTSTP"; + default: return "UNKNOWN"; + } +#else + return "N/A"; +#endif + } + + static std::string GetProcessInfo(const CommandResult& result) { + std::ostringstream info; + if (result.KilledBySignal) { + info << "Killed by signal " << result.TerminatingSignal + << " (" << GetSignalName(result.TerminatingSignal) << ")"; + if (result.CoreDumped) info << " [core dumped]"; + } else if (result.Stopped) { + info << "Stopped by signal " << result.StopSignal + << " (" << GetSignalName(result.StopSignal) << ")"; + } else if (result.TimedOut) { + info << "Process timed out"; + } else { + info << "Exited normally with code " << result.ExitCode; + } + return info.str(); + } +}; -#endif // CATALYSTCX_HPP \ No newline at end of file +#endif \ No newline at end of file diff --git a/Evaluate.cpp b/Evaluate.cpp new file mode 100644 index 0000000..f1dd0f5 --- /dev/null +++ b/Evaluate.cpp @@ -0,0 +1,228 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (c) 2025 assembler-0 +// Licensed under GPL-3.0-or-later +#include +#include +#include +#include +#include "CatalystCX.hpp" + +class TestRunner { + int passed = 0; + int failed = 0; + +public: + void Assert(bool condition, const std::string& test_name) { + if (condition) { + std::cout << "[PASS] " << test_name << std::endl; + passed++; + } else { + std::cout << "[FAIL] " << test_name << std::endl; + failed++; + } + } + + void PrintSummary() { + std::cout << "\n=== Test Summary ===" << std::endl; + std::cout << "Passed: " << passed << std::endl; + std::cout << "Failed: " << failed << std::endl; + std::cout << "Total: " << (passed + failed) << std::endl; + } + + int GetFailedCount() const { return failed; } +}; + +void TestBasicExecution(TestRunner& runner) { + std::cout << "\n=== Basic Execution Tests ===" << std::endl; + + // Test simple command + auto result = Command("echo").Arg("hello").Status(); + runner.Assert(result.ExitCode == 0, "Echo command success"); + runner.Assert(result.Stdout.find("hello") != std::string::npos, "Echo output correct"); + + // Test command with multiple args + result = Command("echo").Args({"hello", "world"}).Status(); + runner.Assert(result.ExitCode == 0, "Multiple args success"); + runner.Assert(result.Stdout.find("hello world") != std::string::npos, "Multiple args output"); + + // Test command failure + result = Command("false").Status(); + runner.Assert(result.ExitCode == 1, "False command exit code"); +} + +void TestAsyncExecution(TestRunner& runner) { + std::cout << "\n=== Async Execution Tests ===" << std::endl; + + // Test spawn and wait + auto child = Command("sleep").Arg("1").Spawn(); + runner.Assert(child.has_value(), "Sleep spawn success"); + + if (child) { + pid_t pid = child->GetPid(); + runner.Assert(pid > 0, "Valid PID returned"); + + auto result = child->Wait(); + runner.Assert(result.ExitCode == 0, "Sleep completed successfully"); + runner.Assert(result.ExecutionTime.count() >= 1.0, "Sleep duration correct"); + } +} + +void TestTimeout(TestRunner& runner) { + std::cout << "\n=== Timeout Tests ===" << std::endl; + + // Test timeout functionality + auto start = std::chrono::steady_clock::now(); + auto result = Command("sleep").Arg("5").Timeout(std::chrono::seconds(1)).Status(); + auto duration = std::chrono::steady_clock::now() - start; + + runner.Assert(result.TimedOut, "Command timed out"); + runner.Assert(duration < std::chrono::seconds(2), "Timeout enforced quickly"); +} + +void TestSignalHandling(TestRunner& runner) { + std::cout << "\n=== Signal Handling Tests ===" << std::endl; + + // Test SIGTERM handling + auto child = Command("sleep").Arg("10").Spawn(); + if (child) { + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + child->Kill(SIGTERM); + auto result = child->Wait(); + + runner.Assert(result.KilledBySignal, "Process killed by signal"); + runner.Assert(result.TerminatingSignal == SIGTERM, "Correct terminating signal"); + runner.Assert(result.ExitCode == 128 + SIGTERM, "Correct exit code for signal"); + } + + // Test signal name lookup + runner.Assert(std::string(SignalInfo::GetSignalName(SIGTERM)) == "SIGTERM", "Signal name lookup"); + runner.Assert(std::string(SignalInfo::GetSignalName(SIGKILL)) == "SIGKILL", "SIGKILL name"); +} + +void TestEnvironmentVariables(TestRunner& runner) { + std::cout << "\n=== Environment Variable Tests ===" << std::endl; + + auto result = Command("printenv").Arg("TEST_VAR") + .Environment("TEST_VAR", "test_value") + .Status(); + + runner.Assert(result.ExitCode == 0, "Environment variable set"); + runner.Assert(result.Stdout.find("test_value") != std::string::npos, "Environment variable value"); +} + +void TestWorkingDirectory(TestRunner& runner) { + std::cout << "\n=== Working Directory Tests ===" << std::endl; + + auto result = Command("pwd").WorkingDirectory("/tmp").Status(); + runner.Assert(result.ExitCode == 0, "Working directory command success"); + runner.Assert(result.Stdout.find("/tmp") != std::string::npos, "Working directory set correctly"); +} + +void TestErrorHandling(TestRunner& runner) { + std::cout << "\n=== Error Handling Tests ===" << std::endl; + + // Test non-existent command + auto child = Command("nonexistentcommand12345").Spawn(); + runner.Assert(!child.has_value(), "Non-existent command fails to spawn"); + + // Test command that writes to stderr + auto result = Command("sh").Args({"-c", "echo error >&2; exit 42"}).Status(); + runner.Assert(result.ExitCode == 42, "Custom exit code preserved"); + runner.Assert(result.Stderr.find("error") != std::string::npos, "Stderr captured"); +} + +void TestResourceUsage(TestRunner& runner) { + std::cout << "\n=== Resource Usage Tests ===" << std::endl; + +#ifdef __linux__ + auto result = Command("dd").Args({"if=/dev/zero", "of=/dev/null", "bs=1M", "count=10"}).Status(); + runner.Assert(result.ExitCode == 0, "DD command success"); + runner.Assert(result.Usage.UserCpuTime >= 0, "User CPU time recorded"); + runner.Assert(result.Usage.SystemCpuTime >= 0, "System CPU time recorded"); + runner.Assert(result.Usage.MaxResidentSetSize > 0, "Memory usage recorded"); +#else + std::cout << "[SKIP] Resource usage tests (Linux only)" << std::endl; +#endif +} + +void TestPipeHandling(TestRunner& runner) { + std::cout << "\n=== Pipe Handling Tests ===" << std::endl; + + // Test large output + auto result = Command("seq").Args({"1", "1000"}).Status(); + runner.Assert(result.ExitCode == 0, "Large output command success"); + runner.Assert(result.Stdout.find("1000") != std::string::npos, "Large output captured"); + + // Test mixed stdout/stderr + result = Command("sh").Args({"-c", "echo stdout; echo stderr >&2"}).Status(); + runner.Assert(result.Stdout.find("stdout") != std::string::npos, "Stdout separated"); + runner.Assert(result.Stderr.find("stderr") != std::string::npos, "Stderr separated"); +} + +void TestExecutionValidator(TestRunner& runner) { + std::cout << "\n=== Execution Validator Tests ===" << std::endl; + + runner.Assert(ExecutionValidator::IsCommandExecutable("ls"), "ls is executable"); + runner.Assert(ExecutionValidator::IsCommandExecutable("echo"), "echo is executable"); + runner.Assert(!ExecutionValidator::IsCommandExecutable("nonexistentcmd123"), "Non-existent not executable"); + + std::vector valid_args = {"ls", "-l"}; + std::vector invalid_args = {"nonexistentcmd123"}; + + runner.Assert(ExecutionValidator::CanExecuteCommand(valid_args), "Valid command can execute"); + runner.Assert(!ExecutionValidator::CanExecuteCommand(invalid_args), "Invalid command cannot execute"); +} + +void TestProcessInfo(TestRunner& runner) { + std::cout << "\n=== Process Info Tests ===" << std::endl; + + // Test normal exit + auto result = Command("true").Status(); + std::string info = SignalInfo::GetProcessInfo(result); + runner.Assert(info.find("Exited normally") != std::string::npos, "Normal exit info"); + + // Test timeout info + result = Command("sleep").Arg("5").Timeout(std::chrono::milliseconds(100)).Status(); + info = SignalInfo::GetProcessInfo(result); + runner.Assert(info.find("timed out") != std::string::npos, "Timeout info"); +} + +void TestEdgeCases(TestRunner& runner) { + std::cout << "\n=== Edge Case Tests ===" << std::endl; + + // Empty command + std::vector empty_args; + runner.Assert(!ExecutionValidator::CanExecuteCommand(empty_args), "Empty args rejected"); + + // Command with spaces in args + auto result = Command("echo").Arg("hello world").Status(); + runner.Assert(result.Stdout.find("hello world") != std::string::npos, "Spaces in args handled"); + + // Very short timeout + result = Command("sleep").Arg("1").Timeout(std::chrono::milliseconds(1)).Status(); + runner.Assert(result.TimedOut, "Very short timeout works"); +} + +int main() { + TestRunner runner; + + std::cout << "CatalystCX Test Suite" << std::endl; + std::cout << "=====================" << std::endl; + + TestBasicExecution(runner); + TestAsyncExecution(runner); + TestTimeout(runner); + TestSignalHandling(runner); + TestEnvironmentVariables(runner); + TestWorkingDirectory(runner); + TestErrorHandling(runner); + TestResourceUsage(runner); + TestPipeHandling(runner); + TestExecutionValidator(runner); + TestProcessInfo(runner); + TestEdgeCases(runner); + + runner.PrintSummary(); + + return runner.GetFailedCount(); +} \ No newline at end of file diff --git a/README.md b/README.md index 6413295..e56a53e 100644 --- a/README.md +++ b/README.md @@ -1,63 +1,88 @@ # CatalystCX -A modern, secure, and flexible C++ single-header library for executing system commands. +A modern, secure, and cross-platform C++ single-header library for executing system commands. (made to address `system()` injection vulnerabilities) -CatalystCX provides a staged pipeline for building and executing commands, designed with security and performance in mind. It avoids shell-based execution (`system()`) in favor of direct process creation, preventing injection vulnerabilities. The API is designed to be intuitive and chainable, following modern C++ best practices. +CatalystCX provides a fluent API for building and executing commands with security and performance as top priorities. It completely avoids shell-based execution (`system()`) in favor of direct process creation, eliminating injection vulnerabilities while providing comprehensive process monitoring and control. -## Features +## Status + +![CI/CD](https://github.com/assembler-0/CatalystCX/actions/workflows/ci.yml/badge.svg) +[![codecov](https://codecov.io/gh/assembler-0/CatalystCX/branch/main/graph/badge.svg)](https://codecov.io/gh/assembler-0/CatalystCX) +[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0) +![Version](https://img.shields.io/badge/Version-0.0.1-brightgreen.svg) -- **Secure by Design:** Commands and arguments are passed directly to the OS, avoiding shell interpretation and injection vulnerabilities. -- **Fluent Builder API:** A chainable, easy-to-use builder pattern for constructing commands. -- **Standard Stream Capture:** Capture `stdout` and `stderr` with ease. -- **Asynchronous Execution:** Spawn processes and manage them asynchronously. -- **Timeouts:** Set timeouts for commands to prevent them from running indefinitely. -- **Resource Monitoring (Linux):** Get detailed information about execution time, CPU usage, and memory consumption. -- **Robust Error Handling:** Uses `std::optional` to safely handle process spawning failures. -- **Single Header:** Easy to integrate into any project. +## Features +- **No Shell Execution:** Direct process creation prevents command injection +- **Argument Validation:** Built-in executable verification +- **Safe Defaults:** Secure by default configuration +- **Zero-Copy Operations:** Efficient memory management +- **Async I/O:** Non-blocking pipe reading with `poll()`/`WaitForMultipleObjects` +- **Platform-Specific Optimizations:** `posix_spawn()` on macOS, `CreateProcess` on Windows +- **Linux:** Full feature support with detailed resource monitoring +- **macOS:** Optimized with `posix_spawn()` for better performance +- **Windows:** Native `CreateProcess` API integration +- **Signal Handling:** Detailed process termination analysis +- **Resource Usage:** CPU time, memory usage, page faults, context switches +- **Execution Metrics:** Precise timing and performance data +- **Fluent Builder API:** Chainable, intuitive command construction +- **Modern C++20:** Uses latest language features and best practices +- **Single Header:** Easy integration, no external dependencies ## Requirements -- C++20 compatible compiler (e.g., GCC 10+, Clang 11+) -- CMake 3.10+ +- **C++20** compatible compiler: + - GCC 11+ (Linux/macOS) + - Clang 14+ (Linux/macOS) + - MSVC 2022+ (Windows) +- **CMake 3.10+** +- **Platform Support:** + - Linux (Ubuntu 20.04+, RHEL 8+) + - macOS (10.15+) + - Windows (10/11) -## Building the Project +## Quick Start -```bash -# Configure the project -cmake -B build +### Single Header Integration -# Build the example executable -cmake --build build -``` +```cpp +#include -## Running the Example +int main() { + auto result = Command("echo").Arg("Hello, World!").Status(); + std::cout << result.Stdout; // "Hello, World!\n" + return result.ExitCode; +} +``` -To run the example application included in `CatalystCX.cpp`, use the `run` target: +### Building from Source ```bash -cmake --build build --target run -``` +# Clone and build +git clone https://github.com/assembler-0/CatalystCX.git +cd CatalystCX +cmake -B build -DCMAKE_BUILD_TYPE=Release +cmake --build build -## Installation +# Run tests +cmake --build build --target test +``` -To install the `CatalystCX.hpp` header to your system's include directory (e.g., `/usr/local/include`), use the `install` target: +### Installation ```bash -# First, build the project -cmake --build build - -# Then, install the header +# System-wide installation sudo cmake --install build + +# Or just copy the header +cp CatalystCX.hpp /your/project/include/ ``` ## API Usage Guide ### Basic Execution -To execute a command and wait for it to complete, use the `Status()` method. It returns a `CommandResult` struct. - ```cpp -#include "CatalystCX.hpp" +#include #include int main() { @@ -70,8 +95,6 @@ int main() { ### Asynchronous Execution -To spawn a process without blocking, use the `Spawn()` method. This returns an `std::optional`. You can `Wait()` for the result later. - ```cpp if (auto child = Command("sleep").Arg("5").Spawn()) { std::cout << "Process spawned with PID: " << child->GetPid() << std::endl; @@ -79,68 +102,150 @@ if (auto child = Command("sleep").Arg("5").Spawn()) { // ... do other work ... CommandResult result = child->Wait(); - std::cout << "Sleep command finished." - << std::endl; + std::cout << "Sleep command finished." << std::endl; } else { - std::cerr << "Failed to spawn process." - << std::endl; + std::cerr << "Failed to spawn process." << std::endl; } ``` ### Timeouts -Set a timeout for a command using the `Timeout()` method. The `CommandResult` will indicate if the command timed out. - ```cpp auto result = Command("ping").Arg("8.8.8.8") .Timeout(std::chrono::seconds(2)) .Status(); if (result.TimedOut) { - std::cout << "Command timed out!" - << std::endl; + std::cout << "Command timed out!" << std::endl; } ``` -### Error Handling +### Environment Variables and Working Directory -`Spawn()` returns a `std::optional`. Always check if it contains a value before using it. +```cpp +auto result = Command("printenv") + .Arg("MY_VAR") + .Environment("MY_VAR", "Hello") + .WorkingDirectory("/tmp") + .Status(); + +std::cout << result.Stdout; // "Hello\n" +``` + +### Signal Handling and Process Information ```cpp -if (auto child = Command("this-command-does-not-exist").Spawn()) { - // This block will not be executed - child->Wait(); -} else { - std::cerr << "Failed to spawn command, as expected." - << std::endl; +auto child = Command("sleep").Arg("10").Spawn(); +if (child) { + std::this_thread::sleep_for(std::chrono::seconds(1)); + child->Kill(SIGTERM); + + auto result = child->Wait(); + if (result.KilledBySignal) { + std::cout << "Process killed by signal: " + << SignalInfo::GetSignalName(result.TerminatingSignal) + << std::endl; + } + + // Get human-readable process info + std::cout << SignalInfo::GetProcessInfo(result) << std::endl; } ``` -### Accessing Detailed Results - -The `CommandResult` struct contains detailed information about the execution. +### Resource Monitoring ```cpp -CommandResult result = Command("your-command").Status(); +CommandResult result = Command("your-intensive-command").Status(); std::cout << "Exit Code: " << result.ExitCode << std::endl; -std::cout << "Execution Time: " << result.ExecutionTime.count() << "s" - << std::endl; +std::cout << "Execution Time: " << result.ExecutionTime.count() << "s" << std::endl; #ifdef __linux__ - std::cout << "User CPU Time: " << result.Usage.UserCpuTime << "us" - << std::endl; - std::cout << "System CPU Time: " << result.Usage.SystemCpuTime << "us" - << std::endl; - std::cout << "Max Memory Usage: " << result.Usage.MaxResidentSetSize << " KB" - << std::endl; +std::cout << "CPU Usage:\n"; +std::cout << " User: " << result.Usage.UserCpuTime << "μs\n"; +std::cout << " System: " << result.Usage.SystemCpuTime << "μs\n"; +std::cout << "Memory:\n"; +std::cout << " Peak RSS: " << result.Usage.MaxResidentSetSize << " KB\n"; +std::cout << " Page Faults: " << result.Usage.MajorPageFaults << std::endl; +#elif defined(_WIN32) +std::cout << "Peak Memory: " << result.Usage.PeakWorkingSetSize << " bytes\n"; +std::cout << "Page Faults: " << result.Usage.PageFaultCount << std::endl; #endif ``` +## Advanced Usage + +### Batch Processing + +```cpp +std::vector files = {"file1.txt", "file2.txt", "file3.txt"}; +std::vector> futures; + +for (const auto& file : files) { + futures.push_back(std::async(std::launch::async, [&file]() { + return Command("wc").Args({"-l", file}).Status(); + })); +} + +for (auto& future : futures) { + auto result = future.get(); + std::cout << "Lines: " << result.Stdout; +} +``` + +### Error Recovery + +```cpp +CommandResult result; +int retries = 3; + +while (retries-- > 0) { + result = Command("flaky-command") + .Timeout(std::chrono::seconds(30)) + .Status(); + + if (result.ExitCode == 0) break; + + std::cerr << "Attempt failed, retries left: " << retries << std::endl; + std::this_thread::sleep_for(std::chrono::seconds(1)); +} +``` + +## Testing + +```bash +# Run full test suite +cmake --build build --target test + +# Run with sanitizers +cmake -B build-debug -DCMAKE_BUILD_TYPE=Debug \ + -DCMAKE_CXX_FLAGS="-fsanitize=address,undefined" +cmake --build build-debug --target test + +# Generate coverage report +cmake -B build-coverage -DCMAKE_CXX_FLAGS="--coverage" +cmake --build build-coverage --target test +lcov --capture --directory build-coverage --output-file coverage.info +``` + + +### Development Setup + +```bash +# Install development dependencies +sudo apt-get install cppcheck clang-tidy valgrind lcov + +# Run static analysis +cppcheck --enable=all --std=c++20 CatalystCX.hpp +clang-tidy CatalystCX.cpp -checks='*' +``` + ## Contributing -Contributions are welcome! Please feel free to submit a pull request. +Contributions are welcome! Please feel free to submit a pull request, open an issue, or contact me directly. :) ## License -This project is licensed under the GPLv3 License. +This project is licensed under the [GPLv3 License](LICENSE) - see the LICENSE file for details. + +---