diff --git a/CMakeLists.txt b/CMakeLists.txt index 7397f34..3f2f847 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -80,6 +80,7 @@ add_library(spinscale SHARED src/qutex.cpp src/componentThread.cpp src/component.cpp + src/envKvStore.cpp src/puppeteerComponent.cpp src/puppetApplication.cpp src/runtime.cpp @@ -147,6 +148,18 @@ install(DIRECTORY include/spinscale FILES_MATCHING PATTERN "*.h" ) +if(BUILD_TESTING AND TARGET GTest::gtest_main) + add_executable(spinscale_env_kv_store_tests + tests/env_kv_store_test.cpp + ) + target_link_libraries(spinscale_env_kv_store_tests PRIVATE + spinscale + GTest::gtest_main + ) + include(GoogleTest) + gtest_discover_tests(spinscale_env_kv_store_tests) +endif() + install(FILES include/boostAsioLinkageFix.h DESTINATION ${CMAKE_INSTALL_INCLUDEDIR} ) diff --git a/include/spinscale/envKvStore.h b/include/spinscale/envKvStore.h new file mode 100644 index 0000000..e6297c9 --- /dev/null +++ b/include/spinscale/envKvStore.h @@ -0,0 +1,44 @@ +#ifndef SPINSCALE_ENV_KV_STORE_H +#define SPINSCALE_ENV_KV_STORE_H + +#include +#include +#include +#include +#include +#include +#include + +namespace sscl { + +class EnvKvStore +{ +public: + explicit EnvKvStore( + const std::vector &envFilePaths, + std::ostream &warningStream); + explicit EnvKvStore( + const std::vector &envFilePaths); + + std::optional get(std::string_view name) const; + +private: + void loadFiles( + const std::vector &envFilePaths, + std::ostream &warningStream); + void loadFile( + const std::filesystem::path &envFilePath, + std::ostream &warningStream); + void storeValue( + const std::filesystem::path &envFilePath, + const std::string &name, + const std::string &value, + std::ostream &warningStream); + +private: + std::unordered_map values; +}; + +} // namespace sscl + +#endif // SPINSCALE_ENV_KV_STORE_H diff --git a/src/envKvStore.cpp b/src/envKvStore.cpp new file mode 100644 index 0000000..bd8a5dc --- /dev/null +++ b/src/envKvStore.cpp @@ -0,0 +1,278 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace sscl { +namespace { + +std::string trim(std::string_view value) +{ + auto begin = std::ranges::find_if_not( + value, [](unsigned char c) { return std::isspace(c); }); + auto rbegin = std::ranges::find_if_not( + value | std::views::reverse, + [](unsigned char c) { return std::isspace(c); }); + auto end = rbegin.base(); + if (begin >= end) + { + return {}; + } + return std::string(begin, end); +} + +bool characterIsValidNameStart(char c) +{ + return std::isalpha(static_cast(c)) || c == '_'; +} + +bool characterIsValidNameBody(char c) +{ + return std::isalnum(static_cast(c)) || c == '_'; +} + +bool nameIsValid(std::string_view name) +{ + if (name.empty() || !characterIsValidNameStart(name.front())) + { + return false; + } + return std::ranges::all_of(name.substr(1), characterIsValidNameBody); +} + +std::runtime_error makeParseError( + const std::filesystem::path &envFilePath, + std::size_t lineNumber, + const std::string &message) +{ + std::ostringstream stream; + stream << envFilePath << ":" << lineNumber << ": " << message; + return std::runtime_error(stream.str()); +} + +bool lineIsBlankOrComment(const std::string &line) +{ + std::string trimmed = trim(line); + return trimmed.empty() || trimmed.front() == '#'; +} + +std::size_t findClosingQuote( + const std::filesystem::path &envFilePath, + std::size_t lineNumber, + std::string_view value) +{ + char quote = value.front(); + bool escapeNext = false; + for (std::size_t i = 1; i < value.size(); ++i) + { + if (escapeNext) + { + escapeNext = false; + continue; + } + if (quote == '"' && value[i] == '\\') + { + escapeNext = true; + continue; + } + if (value[i] == quote) + { + return i; + } + } + throw makeParseError(envFilePath, lineNumber, "Unterminated quoted value."); +} + +std::string decodeDoubleQuotedValue(std::string_view value) +{ + std::string decoded; + decoded.reserve(value.size()); + bool escapeNext = false; + for (char c : value) + { + if (!escapeNext && c == '\\') + { + escapeNext = true; + continue; + } + if (escapeNext) + { + switch (c) + { + case 'n': + decoded.push_back('\n'); + break; + case 'r': + decoded.push_back('\r'); + break; + case 't': + decoded.push_back('\t'); + break; + default: + decoded.push_back(c); + break; + } + escapeNext = false; + continue; + } + decoded.push_back(c); + } + if (escapeNext) + { + decoded.push_back('\\'); + } + return decoded; +} + +std::string parseQuotedValue( + const std::filesystem::path &envFilePath, + std::size_t lineNumber, + std::string_view value) +{ + char quote = value.front(); + std::size_t closingQuote = findClosingQuote(envFilePath, lineNumber, value); + std::string trailing = trim(value.substr(closingQuote + 1)); + if (!trailing.empty() && trailing.front() != '#') + { + throw makeParseError( + envFilePath, lineNumber, "Unexpected text after quoted value."); + } + std::string_view quotedBody = value.substr(1, closingQuote - 1); + if (quote == '"') + { + return decodeDoubleQuotedValue(quotedBody); + } + return std::string(quotedBody); +} + +std::string parseValue( + const std::filesystem::path &envFilePath, + std::size_t lineNumber, + std::string_view rawValue) +{ + std::string value = trim(rawValue); + if (value.empty()) + { + return {}; + } + if (value.front() == '\'' || value.front() == '"') + { + return parseQuotedValue(envFilePath, lineNumber, value); + } + return trim(value.substr(0, value.find('#'))); +} + +std::pair parseAssignment( + const std::filesystem::path &envFilePath, + std::size_t lineNumber, + const std::string &line) +{ + std::size_t separator = line.find('='); + if (separator == std::string::npos) + { + throw makeParseError(envFilePath, lineNumber, "Expected KEY=value."); + } + + std::string name = trim(std::string_view(line).substr(0, separator)); + if (!nameIsValid(name)) + { + throw makeParseError(envFilePath, lineNumber, "Invalid variable name."); + } + + return { + std::move(name), + parseValue( + envFilePath, + lineNumber, + std::string_view(line).substr(separator + 1))}; +} + +} // namespace + +EnvKvStore::EnvKvStore( + const std::vector &envFilePaths, + std::ostream &warningStream) +{ + loadFiles(envFilePaths, warningStream); +} + +EnvKvStore::EnvKvStore( + const std::vector &envFilePaths) +: EnvKvStore(envFilePaths, std::cerr) +{ +} + +void EnvKvStore::loadFiles( + const std::vector &envFilePaths, + std::ostream &warningStream) +{ + for (const std::filesystem::path &envFilePath : envFilePaths) + { + loadFile(envFilePath, warningStream); + } +} + +std::optional EnvKvStore::get(std::string_view name) const +{ + std::string ownedName(name); + if (const char *value = std::getenv(ownedName.c_str())) + { + return std::string(value); + } + auto value = values.find(std::string(name)); + if (value == values.end()) + { + return std::nullopt; + } + return value->second; +} + +void EnvKvStore::loadFile( + const std::filesystem::path &envFilePath, + std::ostream &warningStream) +{ + std::ifstream file(envFilePath); + if (!file) + { + throw std::runtime_error( + "Failed to open env file: " + envFilePath.string()); + } + + std::string line; + std::size_t lineNumber = 0; + while (std::getline(file, line)) + { + ++lineNumber; + if (lineIsBlankOrComment(line)) + { + continue; + } + auto [name, value] = parseAssignment(envFilePath, lineNumber, line); + storeValue(envFilePath, name, value, warningStream); + } +} + +void EnvKvStore::storeValue( + const std::filesystem::path &envFilePath, + const std::string &name, + const std::string &value, + std::ostream &warningStream) +{ + if (auto oldValue = values.find(name); oldValue != values.end()) + { + warningStream << "Warning: env file " << envFilePath + << " overwrites " << name << " from `" << oldValue->second + << "` to `" << value << "`.\n"; + oldValue->second = value; + return; + } + values.emplace(name, value); +} + +} // namespace sscl diff --git a/tests/env_kv_store_test.cpp b/tests/env_kv_store_test.cpp new file mode 100644 index 0000000..b7ae2dc --- /dev/null +++ b/tests/env_kv_store_test.cpp @@ -0,0 +1,157 @@ +#include +#include +#include +#include +#include +#include +#include + +#include + +#include + +namespace { + +class EnvKvStoreTest +: public testing::Test +{ +protected: + void SetUp() override + { + root = std::filesystem::temp_directory_path() + / ("spinscale-env-test-" + + std::to_string(std::chrono::steady_clock::now() + .time_since_epoch().count()) + + "-" + std::to_string(testCounter++)); + std::filesystem::create_directories(root); + unsetenv("SSCL_ENV_TEST_VALUE"); + } + + void TearDown() override + { + unsetenv("SSCL_ENV_TEST_VALUE"); + std::filesystem::remove_all(root); + } + + std::filesystem::path writeFile( + const std::string &filename, + const std::string &contents) + { + std::filesystem::path path = root / filename; + std::ofstream file(path); + file << contents; + return path; + } + + std::filesystem::path root; + static inline int testCounter = 0; +}; + +} // namespace + +TEST_F(EnvKvStoreTest, ParsesSupportedDotenvForms) +{ + std::filesystem::path envFile = writeFile( + "one.env", + "\n" + "# comment\n" + "PLAIN=value\n" + " TRIMMED = value with spaces \n" + "SINGLE=' preserved value '\n" + "DOUBLE=\"another preserved value\"\n" + "ESCAPED=\"quote: \\\" slash: \\\\ tab: \\t\"\n" + "COMMENTED=value # comment\n"); + + std::ostringstream warnings; + sscl::EnvKvStore store({envFile}, warnings); + + EXPECT_EQ(store.get("PLAIN"), "value"); + EXPECT_EQ(store.get("TRIMMED"), "value with spaces"); + EXPECT_EQ(store.get("SINGLE"), " preserved value "); + EXPECT_EQ(store.get("DOUBLE"), "another preserved value"); + EXPECT_EQ(store.get("ESCAPED"), "quote: \" slash: \\ tab: \t"); + EXPECT_EQ(store.get("COMMENTED"), "value"); + EXPECT_TRUE(warnings.str().empty()); +} + +TEST_F(EnvKvStoreTest, LaterFilesOverwriteEarlierFilesAndWarn) +{ + std::filesystem::path first = writeFile("first.env", "VALUE=first\n"); + std::filesystem::path second = writeFile("second.env", "VALUE=second\n"); + + std::ostringstream warnings; + sscl::EnvKvStore store({first, second}, warnings); + + EXPECT_EQ(store.get("VALUE"), "second"); + EXPECT_NE(warnings.str().find("VALUE"), std::string::npos); + EXPECT_NE(warnings.str().find("first"), std::string::npos); + EXPECT_NE(warnings.str().find("second"), std::string::npos); + EXPECT_NE(warnings.str().find(second.string()), std::string::npos); +} + +TEST_F(EnvKvStoreTest, DuplicateKeysInsideSameFileOverwriteAndWarn) +{ + std::filesystem::path envFile = + writeFile("one.env", "VALUE=first\nVALUE=second\n"); + + std::ostringstream warnings; + sscl::EnvKvStore store({envFile}, warnings); + + EXPECT_EQ(store.get("VALUE"), "second"); + EXPECT_NE(warnings.str().find("VALUE"), std::string::npos); + EXPECT_NE(warnings.str().find("first"), std::string::npos); + EXPECT_NE(warnings.str().find("second"), std::string::npos); + EXPECT_NE(warnings.str().find(envFile.string()), std::string::npos); +} + +TEST_F(EnvKvStoreTest, ProcessEnvironmentOverridesStoreSilently) +{ + std::filesystem::path envFile = + writeFile("one.env", "SSCL_ENV_TEST_VALUE=file\n"); + setenv("SSCL_ENV_TEST_VALUE", "process", 1); + + std::ostringstream warnings; + sscl::EnvKvStore store({envFile}, warnings); + + EXPECT_EQ(store.get("SSCL_ENV_TEST_VALUE"), "process"); + EXPECT_TRUE(warnings.str().empty()); +} + +TEST_F(EnvKvStoreTest, EmptyProcessEnvironmentValueOverridesStoreSilently) +{ + std::filesystem::path envFile = + writeFile("one.env", "SSCL_ENV_TEST_VALUE=file\n"); + setenv("SSCL_ENV_TEST_VALUE", "", 1); + + std::ostringstream warnings; + sscl::EnvKvStore store({envFile}, warnings); + + EXPECT_EQ(store.get("SSCL_ENV_TEST_VALUE"), ""); + EXPECT_TRUE(warnings.str().empty()); +} + +TEST_F(EnvKvStoreTest, MissingFileThrows) +{ + std::ostringstream warnings; + EXPECT_THROW( + sscl::EnvKvStore({root / "missing.env"}, warnings), + std::runtime_error); +} + +TEST_F(EnvKvStoreTest, MalformedLineThrows) +{ + std::filesystem::path envFile = writeFile("bad.env", "NOT AN ASSIGNMENT\n"); + std::ostringstream warnings; + + try + { + sscl::EnvKvStore store({envFile}, warnings); + FAIL() << "Expected malformed env file to throw."; + } + catch (const std::runtime_error &e) + { + std::string message = e.what(); + EXPECT_NE(message.find(envFile.string()), std::string::npos); + EXPECT_NE(message.find(":1:"), std::string::npos); + } +}