diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6545911..7207a02 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,52 +9,18 @@ on: 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 - + build: + name: Build and Test + runs-on: ubuntu-latest 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' + - name: Setup Build Environment run: | - cmake -B build -DCMAKE_BUILD_TYPE=Release \ - -DCMAKE_CXX_COMPILER=${{ matrix.compiler == 'gcc' && 'g++' || 'clang++' }} + sudo apt-get update + sudo apt-get install -y cmake clang - - name: Configure CMake (Windows) - if: runner.os == 'Windows' - run: cmake -B build -DCMAKE_BUILD_TYPE=Release + - name: Configure CMake + run: cmake -B build -DCMAKE_BUILD_TYPE=Release -G "Unix Makefiles" - name: Build run: cmake --build build --config Release @@ -75,8 +41,7 @@ jobs: - name: Run cppcheck run: | - cppcheck --enable=all --std=c++20 --suppress=missingIncludeSystem \ - --error-exitcode=1 CatalystCX.hpp + cppcheck --enable=all --std=c++20 --suppress=missingIncludeSystem CatalystCX.hpp - name: Configure for clang-tidy run: cmake -B build -DCMAKE_EXPORT_COMPILE_COMMANDS=ON @@ -105,32 +70,6 @@ jobs: - 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 diff --git a/CMakeLists.txt b/CMakeLists.txt index b3e943f..e0b98ac 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -17,6 +17,10 @@ add_custom_target(test WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR} ) +add_compile_definitions( + WCOREDUMP +) + # Install header install(FILES CatalystCX.hpp DESTINATION include) diff --git a/CatalystCX.hpp b/CatalystCX.hpp index a9244e1..c906dac 100644 --- a/CatalystCX.hpp +++ b/CatalystCX.hpp @@ -145,7 +145,7 @@ class Command { return *this; } - [[nodiscard]] CommandResult Status(); + [[nodiscard]] CommandResult Execute(); [[nodiscard]] std::optional Spawn(); private: @@ -157,15 +157,6 @@ 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; @@ -175,6 +166,14 @@ class AsyncPipeReader { std::string Buffer; bool Finished = false; }; +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: static bool ReadFromPipe(PipeData& pipe_data, std::array& buffer); static bool IsPipeOpen(int fd); @@ -193,40 +192,44 @@ class ExecutionValidator { inline CommandResult Child::Wait(std::optional> timeout) const { auto start_time = std::chrono::steady_clock::now(); CommandResult result; - + + // Start asynchronous pipe reader to avoid deadlocks on full pipes + auto reader_future = std::async(std::launch::async, AsyncPipeReader::ReadPipes, StdoutHandle, StderrHandle); + 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; + + DWORD exit_code = 0; 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); - + + 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); + + // Gather output after process has exited (pipes should be closed by child) + auto [stdout_result, stderr_result] = reader_future.get(); result.Stdout = std::move(stdout_result); result.Stderr = std::move(stderr_result); - + CloseHandle(StdoutHandle); CloseHandle(StderrHandle); - + + auto end_time = std::chrono::steady_clock::now(); + result.ExecutionTime = end_time - start_time; + return result; } @@ -284,12 +287,42 @@ inline std::optional Command::Spawn() { cmdline += QuoteArgWin(arg); } + // Build environment block: merge current environment with overrides (if any) std::string env_block; if (!EnvVars.empty()) { + // Gather current environment + LPCH env_strings = GetEnvironmentStringsA(); + if (env_strings) { + // Copy all existing vars unless overridden (case-insensitive on Windows) + std::unordered_map lower_over; + lower_over.reserve(EnvVars.size()); + for (const auto& [k, v] : EnvVars) { + std::string lk = k; for (auto& c : lk) c = static_cast(::CharLowerA(reinterpret_cast(&c))); + lower_over.emplace(std::move(lk), v); + } + for (LPCSTR p = env_strings; *p; ) { + std::string entry = p; + size_t eq = entry.find('='); + if (eq != std::string::npos) { + std::string key = entry.substr(0, eq); + std::string lk = key; for (auto& c : lk) c = static_cast(::CharLowerA(reinterpret_cast(&c))); + if (lower_over.find(lk) == lower_over.end()) { + env_block += entry; + env_block.push_back('\0'); + } + } + p += entry.size() + 1; + } + FreeEnvironmentStringsA(env_strings); + } + // Add/override with provided variables for (const auto& [key, value] : EnvVars) { - env_block += key + "=" + value + "\0"; + env_block += key; + env_block += '='; + env_block += value; + env_block.push_back('\0'); } - env_block += "\0"; + env_block.push_back('\0'); } BOOL success = CreateProcessA( @@ -350,30 +383,64 @@ inline bool ExecutionValidator::IsFileExecutable(const std::string& path) { } 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"); + auto has_sep = command.find('\\') != std::string::npos || command.find('/') != std::string::npos; + + auto has_ext = [](const std::string& p) { + auto pos = p.find_last_of('.') ; + auto slash = p.find_last_of("/\\"); + return pos != std::string::npos && (slash == std::string::npos || pos > slash); + }; + + auto is_exec_path = [&](const std::string& p){ return IsFileExecutable(p); }; + + // Collect PATHEXT list + std::vector exts; + const char* pathext = getenv("PATHEXT"); + if (pathext && *pathext) { + std::string s = pathext; + size_t b = 0, e = s.find(';'); + while (true) { + exts.push_back(s.substr(b, e - b)); + if (e == std::string::npos) break; + b = e + 1; e = s.find(';', b); + } + } else { + exts = {".COM", ".EXE", ".BAT", ".CMD"}; } - + + auto try_with_exts = [&](const std::string& base){ + if (is_exec_path(base)) return true; + if (!has_ext(base)) { + for (const auto& ext : exts) { + std::string cand = base + ext; + if (is_exec_path(cand)) return true; + } + } + return false; + }; + + if (has_sep) { + return try_with_exts(command); + } + 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; + + while (start <= path_str.length()) { + std::string dir = path_str.substr(start, (end == std::string::npos ? path_str.length() : end) - start); + if (!dir.empty()) { + std::string full_path = dir + "\\" + command; + if (try_with_exts(full_path)) return true; } - if (end == std::string::npos) break; start = end + 1; end = path_str.find(';', start); } - + return false; } @@ -386,6 +453,9 @@ inline CommandResult Child::Wait(std::optional> ti int status = 0; rusage usage{}; + // Start asynchronous pipe reader to avoid deadlocks while child runs + auto reader_future = std::async(std::launch::async, AsyncPipeReader::ReadPipes, StdoutFd, StderrFd); + if (timeout) { while (true) { const int wait_result = waitpid(ProcessId, &status, WNOHANG); @@ -412,10 +482,8 @@ inline CommandResult Child::Wait(std::optional> ti wait4(ProcessId, &status, 0, &usage); } - auto end_time = std::chrono::steady_clock::now(); - result.ExecutionTime = end_time - start_time; - - auto [stdout_result, stderr_result] = AsyncPipeReader::ReadPipes(StdoutFd, StderrFd); + // Collect outputs (reader finishes when pipes close) + auto [stdout_result, stderr_result] = reader_future.get(); result.Stdout = std::move(stdout_result); result.Stderr += stderr_result; @@ -449,6 +517,9 @@ inline CommandResult Child::Wait(std::optional> ti } } + auto end_time = std::chrono::steady_clock::now(); + result.ExecutionTime = end_time - start_time; + return result; } @@ -627,7 +698,7 @@ inline bool AsyncPipeReader::IsPipeOpen(const int fd) { inline bool ExecutionValidator::IsCommandExecutable(const std::string& command) { if (command.find('/') != std::string::npos) { - return IsFileExecutable(command); + return access(command.c_str(), X_OK) == 0; } const char* path_env = getenv("PATH"); @@ -637,14 +708,14 @@ inline bool ExecutionValidator::IsCommandExecutable(const std::string& command) 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)) { - return true; + while (start <= path_str.length()) { + std::string dir = path_str.substr(start, (end == std::string::npos ? path_str.length() : end) - start); + if (!dir.empty()) { + std::string full_path = dir + "/" + command; + if (access(full_path.c_str(), X_OK) == 0) { + return true; + } } - if (end == std::string::npos) break; start = end + 1; end = path_str.find(':', start); @@ -654,13 +725,12 @@ inline bool ExecutionValidator::IsCommandExecutable(const std::string& command) } inline bool ExecutionValidator::IsFileExecutable(const std::string& path) { - struct stat st{}; - return stat(path.c_str(), &st) == 0 && st.st_mode & S_IXUSR; + return access(path.c_str(), X_OK) == 0; } #endif -inline CommandResult Command::Status() { +inline CommandResult Command::Execute() { if (const auto child = Spawn()) { return child->Wait(TimeoutDuration); } diff --git a/Evaluate.cpp b/Evaluate.cpp index f1dd0f5..498aa96 100644 --- a/Evaluate.cpp +++ b/Evaluate.cpp @@ -5,6 +5,9 @@ #include #include #include +#include +#include +#include #include "CatalystCX.hpp" class TestRunner { @@ -12,7 +15,7 @@ class TestRunner { int failed = 0; public: - void Assert(bool condition, const std::string& test_name) { + void Assert(const bool condition, const std::string& test_name) { if (condition) { std::cout << "[PASS] " << test_name << std::endl; passed++; @@ -22,31 +25,31 @@ class TestRunner { } } - void PrintSummary() { + void PrintSummary() const { 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; } + [[nodiscard]] 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(); + auto result = Command("echo").Arg("hello").Execute(); 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(); + result = Command("echo").Args({"hello", "world"}).Execute(); 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(); + result = Command("false").Execute(); runner.Assert(result.ExitCode == 1, "False command exit code"); } @@ -54,14 +57,14 @@ void TestAsyncExecution(TestRunner& runner) { std::cout << "\n=== Async Execution Tests ===" << std::endl; // Test spawn and wait - auto child = Command("sleep").Arg("1").Spawn(); + const auto child = Command("sleep").Arg("1").Spawn(); runner.Assert(child.has_value(), "Sleep spawn success"); if (child) { - pid_t pid = child->GetPid(); + const pid_t pid = child->GetPid(); runner.Assert(pid > 0, "Valid PID returned"); - - auto result = child->Wait(); + + const auto result = child->Wait(); runner.Assert(result.ExitCode == 0, "Sleep completed successfully"); runner.Assert(result.ExecutionTime.count() >= 1.0, "Sleep duration correct"); } @@ -71,9 +74,9 @@ 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; + const auto start = std::chrono::steady_clock::now(); + const auto result = Command("sleep").Arg("5").Timeout(std::chrono::seconds(1)).Execute(); + const 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"); @@ -83,11 +86,10 @@ void TestSignalHandling(TestRunner& runner) { std::cout << "\n=== Signal Handling Tests ===" << std::endl; // Test SIGTERM handling - auto child = Command("sleep").Arg("10").Spawn(); - if (child) { + if (const auto child = Command("sleep").Arg("10").Spawn()) { std::this_thread::sleep_for(std::chrono::milliseconds(100)); child->Kill(SIGTERM); - auto result = child->Wait(); + const auto result = child->Wait(); runner.Assert(result.KilledBySignal, "Process killed by signal"); runner.Assert(result.TerminatingSignal == SIGTERM, "Correct terminating signal"); @@ -101,10 +103,10 @@ void TestSignalHandling(TestRunner& runner) { void TestEnvironmentVariables(TestRunner& runner) { std::cout << "\n=== Environment Variable Tests ===" << std::endl; - - auto result = Command("printenv").Arg("TEST_VAR") + + const auto result = Command("printenv").Arg("TEST_VAR") .Environment("TEST_VAR", "test_value") - .Status(); + .Execute(); runner.Assert(result.ExitCode == 0, "Environment variable set"); runner.Assert(result.Stdout.find("test_value") != std::string::npos, "Environment variable value"); @@ -112,8 +114,8 @@ void TestEnvironmentVariables(TestRunner& runner) { void TestWorkingDirectory(TestRunner& runner) { std::cout << "\n=== Working Directory Tests ===" << std::endl; - - auto result = Command("pwd").WorkingDirectory("/tmp").Status(); + + const auto result = Command("pwd").WorkingDirectory("/tmp").Execute(); runner.Assert(result.ExitCode == 0, "Working directory command success"); runner.Assert(result.Stdout.find("/tmp") != std::string::npos, "Working directory set correctly"); } @@ -122,11 +124,11 @@ void TestErrorHandling(TestRunner& runner) { std::cout << "\n=== Error Handling Tests ===" << std::endl; // Test non-existent command - auto child = Command("nonexistentcommand12345").Spawn(); + const 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(); + const auto result = Command("sh").Args({"-c", "echo error >&2; exit 42"}).Execute(); runner.Assert(result.ExitCode == 42, "Custom exit code preserved"); runner.Assert(result.Stderr.find("error") != std::string::npos, "Stderr captured"); } @@ -135,7 +137,7 @@ 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(); + const auto result = Command("dd").Args({"if=/dev/zero", "of=/dev/null", "bs=1M", "count=10"}).Execute(); 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"); @@ -149,12 +151,12 @@ void TestPipeHandling(TestRunner& runner) { std::cout << "\n=== Pipe Handling Tests ===" << std::endl; // Test large output - auto result = Command("seq").Args({"1", "1000"}).Status(); + auto result = Command("seq").Args({"1", "1000"}).Execute(); 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(); + result = Command("sh").Args({"-c", "echo stdout; echo stderr >&2"}).Execute(); runner.Assert(result.Stdout.find("stdout") != std::string::npos, "Stdout separated"); runner.Assert(result.Stderr.find("stderr") != std::string::npos, "Stderr separated"); } @@ -165,9 +167,9 @@ void TestExecutionValidator(TestRunner& runner) { 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"}; + + const std::vector valid_args = {"ls", "-l"}; + const 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"); @@ -177,12 +179,12 @@ void TestProcessInfo(TestRunner& runner) { std::cout << "\n=== Process Info Tests ===" << std::endl; // Test normal exit - auto result = Command("true").Status(); + auto result = Command("true").Execute(); 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(); + result = Command("sleep").Arg("5").Timeout(std::chrono::milliseconds(100)).Execute(); info = SignalInfo::GetProcessInfo(result); runner.Assert(info.find("timed out") != std::string::npos, "Timeout info"); } @@ -191,18 +193,24 @@ void TestEdgeCases(TestRunner& runner) { std::cout << "\n=== Edge Case Tests ===" << std::endl; // Empty command - std::vector empty_args; + constexpr 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(); + auto result = Command("echo").Arg("hello world").Execute(); 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(); + result = Command("sleep").Arg("1").Timeout(std::chrono::milliseconds(1)).Execute(); runner.Assert(result.TimedOut, "Very short timeout works"); } +#ifndef _WIN32 +void TestLargeStdoutStderr(TestRunner& runner); +void TestWorkingDirectoryRelativeExec(TestRunner& runner); +void TestExecutionValidatorFilePermissions(TestRunner& runner); +void TestEnvMergingAndOverride(TestRunner& runner); +#endif int main() { TestRunner runner; @@ -221,8 +229,132 @@ int main() { TestExecutionValidator(runner); TestProcessInfo(runner); TestEdgeCases(runner); +#ifndef _WIN32 + TestLargeStdoutStderr(runner); + TestWorkingDirectoryRelativeExec(runner); + TestExecutionValidatorFilePermissions(runner); + TestEnvMergingAndOverride(runner); +#endif runner.PrintSummary(); return runner.GetFailedCount(); -} \ No newline at end of file +} + +// ===== Additional Linux-focused tests and helpers ===== +#ifndef _WIN32 +static std::string JoinPath(const std::string& a, const std::string& b) { + if (a.empty()) return b; + if (a.back() == '/') return a + b; + return a + "/" + b; +} + +static std::string MakeTempDir() { + std::string tmpl = "/tmp/catalystcxXXXXXX"; + std::vector buf(tmpl.begin(), tmpl.end()); + buf.push_back('\0'); + char* p = mkdtemp(buf.data()); + if (!p) return {}; + return {p}; +} + +static bool WriteTextFile(const std::string& path, const std::string& content) { + std::ofstream ofs(path, std::ios::out | std::ios::trunc); + if (!ofs) return false; + ofs << content; + return ofs.good(); +} + +static void CleanupTemp(const std::string& dir, const std::vector& files) { + for (const auto& f : files) { + unlink(JoinPath(dir, f).c_str()); + } + rmdir(dir.c_str()); +} + +void TestLargeStdoutStderr(TestRunner& runner) { + std::cout << "\n=== Large Stdout/Stderr Tests ===" << std::endl; + + // Large stdout (~5MB) + auto res = Command("sh").Args({"-c", "dd if=/dev/zero bs=1M count=5 2>/dev/null"}).Execute(); + runner.Assert(res.ExitCode == 0, "Large stdout command success"); + runner.Assert(res.Stdout.size() >= 5 * 1024 * 1024, "Large stdout captured without deadlock"); + + // Large stderr (~5MB zeros) + res = Command("sh").Args({"-c", "dd if=/dev/zero of=/dev/stderr bs=1M count=5 1>/dev/null"}).Execute(); + runner.Assert(res.ExitCode == 0, "Large stderr command success"); + runner.Assert(res.Stderr.size() >= 5 * 1024 * 1024, "Large stderr captured without deadlock"); + + // Interleaved stdout and stderr + res = Command("sh").Args({"-c", "for i in $(seq 1 2000); do echo outline; echo errline >&2; done"}).Execute(); + runner.Assert(res.ExitCode == 0, "Interleaved out/err success"); + runner.Assert(res.Stdout.find("outline") != std::string::npos, "Interleaved stdout captured"); + runner.Assert(res.Stderr.find("errline") != std::string::npos, "Interleaved stderr captured"); +} + +void TestWorkingDirectoryRelativeExec(TestRunner& runner) { + std::cout << "\n=== Working Directory Relative Exec Tests ===" << std::endl; + + const std::string dir = MakeTempDir(); + if (dir.empty()) { + std::cout << "[SKIP] Unable to create temp dir" << std::endl; + return; + } + const std::string script = "script.sh"; + const std::string script_path = JoinPath(dir, script); + + if (!WriteTextFile(script_path, "#!/bin/sh\necho temp-ok\n")) { + std::cout << "[SKIP] Unable to write temp script" << std::endl; + rmdir(dir.c_str()); + return; + } + chmod(script_path.c_str(), 0755); + + const auto res = Command("sh").Args({"-c", "./" + script}).WorkingDirectory(dir).Execute(); + runner.Assert(res.ExitCode == 0, "Run relative executable in working dir"); + runner.Assert(res.Stdout.find("temp-ok") != std::string::npos, "Relative exec output correct"); + + CleanupTemp(dir, {script}); +} + +void TestExecutionValidatorFilePermissions(TestRunner& runner) { + std::cout << "\n=== Execution Validator File Permission Tests ===" << std::endl; + const std::string dir = MakeTempDir(); + if (dir.empty()) { + std::cout << "[SKIP] Unable to create temp dir" << std::endl; + return; + } + std::string fname = "permtest.sh"; + const std::string path = JoinPath(dir, fname); + + WriteTextFile(path, "#!/bin/sh\necho ok\n"); + chmod(path.c_str(), 0644); // no exec + + runner.Assert(!ExecutionValidator::IsFileExecutable(path), "Non-executable file rejected"); + runner.Assert(!ExecutionValidator::IsCommandExecutable("./" + fname), "IsCommandExecutable false for non-exec"); + + chmod(path.c_str(), 0755); + + runner.Assert(ExecutionValidator::IsFileExecutable(path), "Executable bit recognized"); + runner.Assert(ExecutionValidator::IsCommandExecutable(path) == true, "IsCommandExecutable true for exec"); + + CleanupTemp(dir, {fname}); +} + +void TestEnvMergingAndOverride(TestRunner& runner) { + std::cout << "\n=== Environment Merge/Override Tests ===" << std::endl; + + // New variable should be visible + auto res = Command("sh").Args({"-c", "printf '%s' \"$NEW_VAR\""}) + .Environment("NEW_VAR", "new_value").Execute(); + runner.Assert(res.ExitCode == 0, "New env var set"); + runner.Assert(res.Stdout == "new_value", "New env var value visible"); + + // Override an env var for the child only + // Use HOME which should exist; don't leak to parent + res = Command("sh").Args({"-c", "printf '%s' \"$HOME\""}) + .Environment("HOME", "/tmp/testhome").Execute(); + runner.Assert(res.ExitCode == 0, "Override env var success"); + runner.Assert(res.Stdout == "/tmp/testhome", "Override value applied in child"); +} +#endif // _WIN32