diff options
| author | Leitwolf | 2026-11-06 09:34:16 +0300 |
|---|---|---|
| committer | GitHub | 2026-11-06 09:34:16 +0300 |
| commit | f7b935972ae17dfab13195a51f3d1f3b89d77c48 (patch) | |
| tree | 99c4d53afb06eb1f2479392bdf56d9bc95621166 | |
| parent | 268698be92962247a21a86dda4de2d4267adcf8f (diff) | |
| parent | ad3633ea59ec491c9694a10307beb7d7189db744 (diff) | |
| download | catch3-f7b935972ae17dfab13195a51f3d1f3b89d77c48.tar.gz | |
Merge pull request #37 from Progmasoft/fix/child-environment
Withhold secret-looking variables from the processes the runner starts
| -rw-r--r-- | docs/command-line.md | 1 | ||||
| -rw-r--r-- | docs/progmasoft-runner.md | 13 | ||||
| -rw-r--r-- | extras/catch_amalgamated.cpp | 108 | ||||
| -rw-r--r-- | src/Progmasoft/Runner/Options.cpp | 11 | ||||
| -rw-r--r-- | src/Progmasoft/Runner/Options.hpp | 2 | ||||
| -rw-r--r-- | src/Progmasoft/Runner/Process.cpp | 84 | ||||
| -rw-r--r-- | src/Progmasoft/Runner/Process.hpp | 9 | ||||
| -rw-r--r-- | src/Progmasoft/Runner/ProjectConfig.cpp | 1 | ||||
| -rw-r--r-- | src/Progmasoft/Runner/Scheduler.cpp | 1 | ||||
| -rw-r--r-- | tests/Progmasoft/Runner.tests.cpp | 40 |
10 files changed, 265 insertions, 5 deletions
diff --git a/docs/command-line.md b/docs/command-line.md index 4bd46f3b..236128e1 100644 --- a/docs/command-line.md +++ b/docs/command-line.md @@ -71,6 +71,7 @@ Without a spec, every test runs except hidden ones, whose tags start with | `-j`, `--jobs=N` | worker processes; 0, the default, uses one per hardware thread | | `--in-process` | run the tests in this process, one after another | | `--isolate` | start a fresh process for every test | +| `--pass-env=NAME` | give the tests the variable `NAME`, which they do not get because its name looks like a secret (repeatable) | | `--timeout=SECONDS` | the limit for a test without history, and the cap of learned limits; 0 for none | | `--no-adaptive-timeout` | use `--timeout` for every test | | `--retries=N\|auto\|off` | `auto` (default) retries what may pass again; N retries every failure up to N times | diff --git a/docs/progmasoft-runner.md b/docs/progmasoft-runner.md index 8134ae18..19d1e06d 100644 --- a/docs/progmasoft-runner.md +++ b/docs/progmasoft-runner.md @@ -274,6 +274,17 @@ accident. `Progmasoft::Catch3::TestTemporaryDirectory()` in The directory is removed when the attempt passes and kept when it fails; the report says where. +## Environment variables + +Workers, and the processes the runner starts for itself, inherit its +environment without the variables whose names look like secrets: those that +contain, in any case, `TOKEN`, `SECRET`, `PASSWORD`, `PASSWD`, `CREDENTIAL`, +`API_KEY`, `APIKEY`, `PRIVATE_KEY` or `ACCESS_KEY`. A test that needs one +gets it with `--pass-env=NAME`, which can be repeated, or `"pass-env"` in +the project configuration. The runner itself starts with the whole +environment, so code that runs before the first worker starts still sees +them. + ## What a failure report says @@ -376,7 +387,7 @@ as on the command line, with the key named in the message: | a whole number or a string | `retries` (`"auto"`, `"off"`), `seed` (`"random"`) | | a number | `timeout` (seconds), `benchmark-confidence-interval` | | a path, relative to the file | `junit`, `json`, `trace`, `shard-durations`; `history`, or `false` for none | -| an array of strings | `tests` (test specs), `warn` | +| an array of strings | `tests` (test specs), `warn`, `pass-env` | | `"quiet"`, `"normal"` or `"verbose"` | `verbosity` | `"version": 1` is required; `"$schema"` may name a schema for editors. diff --git a/extras/catch_amalgamated.cpp b/extras/catch_amalgamated.cpp index 5be0c78b..737af11a 100644 --- a/extras/catch_amalgamated.cpp +++ b/extras/catch_amalgamated.cpp @@ -1081,6 +1081,8 @@ namespace Progmasoft::Catch3::Runner unsigned jobs = 0; bool inProcess = false; bool isolate = false; + /// Variables the tests keep although their names look like secrets. + std::vector<std::string> passedEnvironment; /// The per-test limit; zero disables it. std::chrono::milliseconds timeout{0}; bool timeoutGiven = false; @@ -1628,6 +1630,9 @@ namespace Progmasoft::Catch3::Runner std::vector<std::string> arguments; /// Variables added to, or replacing, those of this process. std::vector<std::pair<std::string, std::string>> environment; + /// Variables of this process the child keeps although their names + /// look like secrets; see `IsSecretVariable`. + std::vector<std::string> keptEnvironment; /// The working directory; empty keeps the current one. std::filesystem::path workingDirectory; bool searchPath = false; @@ -1737,6 +1742,12 @@ namespace Progmasoft::Catch3::Runner [[nodiscard]] unsigned HardwareThreads() noexcept; + /// Whether a variable of this name holds a secret that a child does not + /// inherit: its name contains, in any case, TOKEN, SECRET, PASSWORD, + /// PASSWD, CREDENTIAL, API_KEY, APIKEY, PRIVATE_KEY or ACCESS_KEY. + [[nodiscard]] bool + IsSecretVariable(std::string_view name); + /// The value of an environment variable; empty when it is not set. [[nodiscard]] std::string ReadEnvironment(const char *name); @@ -13388,6 +13399,17 @@ namespace Progmasoft::Catch3::Runner options.isolate = true; return Status::Ok(); }}, + {"pass-env", + '\0', + Takes::kValue, + "NAME", + "Running", + "give the tests the variable NAME, which they do not get " + "because its name looks like a secret (repeatable)", + [](Options &options, const std::optional<std::string> &value) { + options.passedEnvironment.push_back(*value); + return Status::Ok(); + }}, {"timeout", '\0', Takes::kValue, @@ -14862,9 +14884,12 @@ namespace Progmasoft::Catch3::Runner // Copyright (c) 2026 Progmasoft. // SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-2.0 #include <algorithm> +#include <cctype> #include <cstdio> #include <cstdlib> #include <cstring> +#include <iterator> +#include <string_view> #include <thread> @@ -14899,8 +14924,64 @@ extern char **environ; namespace Progmasoft::Catch3::Runner { + bool + IsSecretVariable(std::string_view name) + { + std::string upper; + upper.reserve(name.size()); + for (const char character : name) + { + upper.push_back(character >= 'a' && character <= 'z' + ? static_cast<char>(character - 'a' + 'A') + : character); + } + constexpr std::string_view kSecretWords[] = { + "TOKEN", "SECRET", "PASSWORD", "PASSWD", "CREDENTIAL", + "API_KEY", "APIKEY", "PRIVATE_KEY", "ACCESS_KEY", + }; + return std::any_of(std::begin(kSecretWords), + std::end(kSecretWords), + [&](std::string_view word) { + return upper.find(word) != std::string::npos; + }); + } + namespace { + /// Whether a variable of this process stays out of a child: a secret + /// that the options do not keep. Windows spells names in any case. + bool + Withheld(std::string_view name, const std::vector<std::string> &kept) + { + if (!IsSecretVariable(name)) + { + return false; + } + return std::none_of( + kept.begin(), + kept.end(), + [&](const std::string &keptName) { + if (keptName.size() != name.size()) + { + return false; + } +#if defined(_WIN32) + return std::equal( + keptName.begin(), + keptName.end(), + name.begin(), + [](char left, char right) { + return std::toupper( + static_cast<unsigned char>(left)) + == std::toupper( + static_cast<unsigned char>(right)); + }); +#else + return keptName == name; +#endif + }); + } + #if defined(_WIN32) HANDLE AsHandle(std::intptr_t value) noexcept @@ -15035,7 +15116,8 @@ namespace Progmasoft::Catch3::Runner std::wstring EnvironmentBlock( - const std::vector<std::pair<std::string, std::string>> &additions) + const std::vector<std::pair<std::string, std::string>> &additions, + const std::vector<std::string> &kept) { std::vector<std::wstring> entries; wchar_t *current = GetEnvironmentStringsW(); @@ -15044,6 +15126,13 @@ namespace Progmasoft::Catch3::Runner for (const wchar_t *entry = current; *entry != L'\0'; entry += wcslen(entry) + 1) { + // A leading '=' marks the hidden per-drive variables. + const std::wstring_view text{ entry }; + const auto equals = text.find(L'=', 1); + if (Withheld(Narrow(text.substr(0, equals)), kept)) + { + continue; + } entries.emplace_back(entry); } FreeEnvironmentStringsW(current); @@ -15075,6 +15164,12 @@ namespace Progmasoft::Catch3::Runner block += entry; block.push_back(L'\0'); } + // A block ends in an empty entry; one without variables is two + // terminators. + if (entries.empty()) + { + block.push_back(L'\0'); + } block.push_back(L'\0'); return block; } @@ -15494,7 +15589,8 @@ namespace Progmasoft::Catch3::Runner } AppendQuotedArgument(commandLine, Widen(options.arguments[index])); } - std::wstring environment = EnvironmentBlock(options.environment); + std::wstring environment + = EnvironmentBlock(options.environment, options.keptEnvironment); const std::wstring directory = options.workingDirectory.wstring(); PROCESS_INFORMATION information{}; @@ -15817,6 +15913,12 @@ namespace Progmasoft::Catch3::Runner entry != nullptr && *entry != nullptr; ++entry) { + const std::string_view text{ *entry }; + if (Withheld(text.substr(0, text.find('=')), + options.keptEnvironment)) + { + continue; + } environmentStorage.emplace_back(*entry); } for (const auto &[name, value] : options.environment) @@ -16224,6 +16326,7 @@ namespace Progmasoft::Catch3::Runner { "jobs", ValueKind::kCount }, { "in-process", ValueKind::kFlag }, { "isolate", ValueKind::kFlag }, + { "pass-env", ValueKind::kWords }, { "timeout", ValueKind::kNumber }, { "adaptive-timeout", ValueKind::kNegated, @@ -20604,6 +20707,7 @@ namespace Progmasoft::Catch3::Runner const std::size_t id = ++workersStarted_; child.environment.emplace_back("CATCH3_WORKER", std::to_string(id)); + child.keptEnvironment = options_.passedEnvironment; auto process = ChildProcess::Start(child); if (!process) { diff --git a/src/Progmasoft/Runner/Options.cpp b/src/Progmasoft/Runner/Options.cpp index 383ed68d..13cf4be7 100644 --- a/src/Progmasoft/Runner/Options.cpp +++ b/src/Progmasoft/Runner/Options.cpp @@ -361,6 +361,17 @@ namespace Progmasoft::Catch3::Runner options.isolate = true; return Status::Ok(); }}, + {"pass-env", + '\0', + Takes::kValue, + "NAME", + "Running", + "give the tests the variable NAME, which they do not get " + "because its name looks like a secret (repeatable)", + [](Options &options, const std::optional<std::string> &value) { + options.passedEnvironment.push_back(*value); + return Status::Ok(); + }}, {"timeout", '\0', Takes::kValue, diff --git a/src/Progmasoft/Runner/Options.hpp b/src/Progmasoft/Runner/Options.hpp index 35e6c302..256f5ac7 100644 --- a/src/Progmasoft/Runner/Options.hpp +++ b/src/Progmasoft/Runner/Options.hpp @@ -138,6 +138,8 @@ namespace Progmasoft::Catch3::Runner unsigned jobs = 0; bool inProcess = false; bool isolate = false; + /// Variables the tests keep although their names look like secrets. + std::vector<std::string> passedEnvironment; /// The per-test limit; zero disables it. std::chrono::milliseconds timeout{0}; bool timeoutGiven = false; diff --git a/src/Progmasoft/Runner/Process.cpp b/src/Progmasoft/Runner/Process.cpp index c1d01302..85987210 100644 --- a/src/Progmasoft/Runner/Process.cpp +++ b/src/Progmasoft/Runner/Process.cpp @@ -1,9 +1,12 @@ // Copyright (c) 2026 Progmasoft. // SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-2.0 #include <algorithm> +#include <cctype> #include <cstdio> #include <cstdlib> #include <cstring> +#include <iterator> +#include <string_view> #include <thread> #include "Process.hpp" @@ -39,8 +42,64 @@ extern char **environ; namespace Progmasoft::Catch3::Runner { + bool + IsSecretVariable(std::string_view name) + { + std::string upper; + upper.reserve(name.size()); + for (const char character : name) + { + upper.push_back(character >= 'a' && character <= 'z' + ? static_cast<char>(character - 'a' + 'A') + : character); + } + constexpr std::string_view kSecretWords[] = { + "TOKEN", "SECRET", "PASSWORD", "PASSWD", "CREDENTIAL", + "API_KEY", "APIKEY", "PRIVATE_KEY", "ACCESS_KEY", + }; + return std::any_of(std::begin(kSecretWords), + std::end(kSecretWords), + [&](std::string_view word) { + return upper.find(word) != std::string::npos; + }); + } + namespace { + /// Whether a variable of this process stays out of a child: a secret + /// that the options do not keep. Windows spells names in any case. + bool + Withheld(std::string_view name, const std::vector<std::string> &kept) + { + if (!IsSecretVariable(name)) + { + return false; + } + return std::none_of( + kept.begin(), + kept.end(), + [&](const std::string &keptName) { + if (keptName.size() != name.size()) + { + return false; + } +#if defined(_WIN32) + return std::equal( + keptName.begin(), + keptName.end(), + name.begin(), + [](char left, char right) { + return std::toupper( + static_cast<unsigned char>(left)) + == std::toupper( + static_cast<unsigned char>(right)); + }); +#else + return keptName == name; +#endif + }); + } + #if defined(_WIN32) HANDLE AsHandle(std::intptr_t value) noexcept @@ -175,7 +234,8 @@ namespace Progmasoft::Catch3::Runner std::wstring EnvironmentBlock( - const std::vector<std::pair<std::string, std::string>> &additions) + const std::vector<std::pair<std::string, std::string>> &additions, + const std::vector<std::string> &kept) { std::vector<std::wstring> entries; wchar_t *current = GetEnvironmentStringsW(); @@ -184,6 +244,13 @@ namespace Progmasoft::Catch3::Runner for (const wchar_t *entry = current; *entry != L'\0'; entry += wcslen(entry) + 1) { + // A leading '=' marks the hidden per-drive variables. + const std::wstring_view text{ entry }; + const auto equals = text.find(L'=', 1); + if (Withheld(Narrow(text.substr(0, equals)), kept)) + { + continue; + } entries.emplace_back(entry); } FreeEnvironmentStringsW(current); @@ -215,6 +282,12 @@ namespace Progmasoft::Catch3::Runner block += entry; block.push_back(L'\0'); } + // A block ends in an empty entry; one without variables is two + // terminators. + if (entries.empty()) + { + block.push_back(L'\0'); + } block.push_back(L'\0'); return block; } @@ -634,7 +707,8 @@ namespace Progmasoft::Catch3::Runner } AppendQuotedArgument(commandLine, Widen(options.arguments[index])); } - std::wstring environment = EnvironmentBlock(options.environment); + std::wstring environment + = EnvironmentBlock(options.environment, options.keptEnvironment); const std::wstring directory = options.workingDirectory.wstring(); PROCESS_INFORMATION information{}; @@ -957,6 +1031,12 @@ namespace Progmasoft::Catch3::Runner entry != nullptr && *entry != nullptr; ++entry) { + const std::string_view text{ *entry }; + if (Withheld(text.substr(0, text.find('=')), + options.keptEnvironment)) + { + continue; + } environmentStorage.emplace_back(*entry); } for (const auto &[name, value] : options.environment) diff --git a/src/Progmasoft/Runner/Process.hpp b/src/Progmasoft/Runner/Process.hpp index 733bdbf7..3e0a1dd2 100644 --- a/src/Progmasoft/Runner/Process.hpp +++ b/src/Progmasoft/Runner/Process.hpp @@ -121,6 +121,9 @@ namespace Progmasoft::Catch3::Runner std::vector<std::string> arguments; /// Variables added to, or replacing, those of this process. std::vector<std::pair<std::string, std::string>> environment; + /// Variables of this process the child keeps although their names + /// look like secrets; see `IsSecretVariable`. + std::vector<std::string> keptEnvironment; /// The working directory; empty keeps the current one. std::filesystem::path workingDirectory; bool searchPath = false; @@ -230,6 +233,12 @@ namespace Progmasoft::Catch3::Runner [[nodiscard]] unsigned HardwareThreads() noexcept; + /// Whether a variable of this name holds a secret that a child does not + /// inherit: its name contains, in any case, TOKEN, SECRET, PASSWORD, + /// PASSWD, CREDENTIAL, API_KEY, APIKEY, PRIVATE_KEY or ACCESS_KEY. + [[nodiscard]] bool + IsSecretVariable(std::string_view name); + /// The value of an environment variable; empty when it is not set. [[nodiscard]] std::string ReadEnvironment(const char *name); diff --git a/src/Progmasoft/Runner/ProjectConfig.cpp b/src/Progmasoft/Runner/ProjectConfig.cpp index e382b5c5..7a466f34 100644 --- a/src/Progmasoft/Runner/ProjectConfig.cpp +++ b/src/Progmasoft/Runner/ProjectConfig.cpp @@ -68,6 +68,7 @@ namespace Progmasoft::Catch3::Runner { "jobs", ValueKind::kCount }, { "in-process", ValueKind::kFlag }, { "isolate", ValueKind::kFlag }, + { "pass-env", ValueKind::kWords }, { "timeout", ValueKind::kNumber }, { "adaptive-timeout", ValueKind::kNegated, diff --git a/src/Progmasoft/Runner/Scheduler.cpp b/src/Progmasoft/Runner/Scheduler.cpp index 4637a851..56a2aa36 100644 --- a/src/Progmasoft/Runner/Scheduler.cpp +++ b/src/Progmasoft/Runner/Scheduler.cpp @@ -994,6 +994,7 @@ namespace Progmasoft::Catch3::Runner const std::size_t id = ++workersStarted_; child.environment.emplace_back("CATCH3_WORKER", std::to_string(id)); + child.keptEnvironment = options_.passedEnvironment; auto process = ChildProcess::Start(child); if (!process) { diff --git a/tests/Progmasoft/Runner.tests.cpp b/tests/Progmasoft/Runner.tests.cpp index 7b7d032e..66586f77 100644 --- a/tests/Progmasoft/Runner.tests.cpp +++ b/tests/Progmasoft/Runner.tests.cpp @@ -292,3 +292,43 @@ TEST_CASE("JUnit and trace outputs are written", "[progmasoft][runner]") REQUIRE(traceJson); CHECK((*traceJson)["traceEvents"].Items().size() >= 3); } + +TEST_CASE("A child does not inherit the secrets of the runner", + "[progmasoft][runner]") +{ + CHECK(Runner::IsSecretVariable("GITHUB_TOKEN")); + CHECK(Runner::IsSecretVariable("aws_secret_access_key")); + CHECK(Runner::IsSecretVariable("DB_PASSWORD")); + CHECK(Runner::IsSecretVariable("NPM_ApiKey")); + CHECK_FALSE(Runner::IsSecretVariable("PATH")); + CHECK_FALSE(Runner::IsSecretVariable("XAUTHORITY")); + + Runner::ChildOptions options; +#if defined(_WIN32) + REQUIRE(_putenv_s("CATCH3_TEST_TOKEN", "withheld") == 0); + REQUIRE(_putenv_s("CATCH3_TEST_PLAIN", "kept") == 0); + options.arguments = { "cmd.exe", "/d", "/c", "set" }; +#else + REQUIRE(setenv("CATCH3_TEST_TOKEN", "withheld", 1) == 0); + REQUIRE(setenv("CATCH3_TEST_PLAIN", "kept", 1) == 0); + options.arguments = { "env" }; +#endif + options.searchPath = true; + auto printed = Runner::RunAndCapture(options); + REQUIRE(printed); + CHECK(printed->output.find("CATCH3_TEST_TOKEN") == std::string::npos); + CHECK_THAT(printed->output, ContainsSubstring("CATCH3_TEST_PLAIN=kept")); + + options.keptEnvironment = { "CATCH3_TEST_TOKEN" }; + printed = Runner::RunAndCapture(options); + REQUIRE(printed); + CHECK_THAT(printed->output, + ContainsSubstring("CATCH3_TEST_TOKEN=withheld")); +#if defined(_WIN32) + CHECK(_putenv_s("CATCH3_TEST_TOKEN", "") == 0); + CHECK(_putenv_s("CATCH3_TEST_PLAIN", "") == 0); +#else + CHECK(unsetenv("CATCH3_TEST_TOKEN") == 0); + CHECK(unsetenv("CATCH3_TEST_PLAIN") == 0); +#endif +} |