Add EnvKvStore for envvar parsing and interleaving

This commit is contained in:
2026-06-11 19:11:59 -04:00
parent 00be517f30
commit ffe86369e2
4 changed files with 492 additions and 0 deletions
+13
View File
@@ -80,6 +80,7 @@ add_library(spinscale SHARED
src/qutex.cpp src/qutex.cpp
src/componentThread.cpp src/componentThread.cpp
src/component.cpp src/component.cpp
src/envKvStore.cpp
src/puppeteerComponent.cpp src/puppeteerComponent.cpp
src/puppetApplication.cpp src/puppetApplication.cpp
src/runtime.cpp src/runtime.cpp
@@ -147,6 +148,18 @@ install(DIRECTORY include/spinscale
FILES_MATCHING PATTERN "*.h" 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 install(FILES include/boostAsioLinkageFix.h
DESTINATION ${CMAKE_INSTALL_INCLUDEDIR} DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}
) )
+44
View File
@@ -0,0 +1,44 @@
#ifndef SPINSCALE_ENV_KV_STORE_H
#define SPINSCALE_ENV_KV_STORE_H
#include <filesystem>
#include <optional>
#include <ostream>
#include <string>
#include <string_view>
#include <unordered_map>
#include <vector>
namespace sscl {
class EnvKvStore
{
public:
explicit EnvKvStore(
const std::vector<std::filesystem::path> &envFilePaths,
std::ostream &warningStream);
explicit EnvKvStore(
const std::vector<std::filesystem::path> &envFilePaths);
std::optional<std::string> get(std::string_view name) const;
private:
void loadFiles(
const std::vector<std::filesystem::path> &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<std::string, std::string> values;
};
} // namespace sscl
#endif // SPINSCALE_ENV_KV_STORE_H
+278
View File
@@ -0,0 +1,278 @@
#include <algorithm>
#include <cctype>
#include <cstdlib>
#include <fstream>
#include <iostream>
#include <ranges>
#include <sstream>
#include <stdexcept>
#include <spinscale/envKvStore.h>
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<unsigned char>(c)) || c == '_';
}
bool characterIsValidNameBody(char c)
{
return std::isalnum(static_cast<unsigned char>(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<std::string, std::string> 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<std::filesystem::path> &envFilePaths,
std::ostream &warningStream)
{
loadFiles(envFilePaths, warningStream);
}
EnvKvStore::EnvKvStore(
const std::vector<std::filesystem::path> &envFilePaths)
: EnvKvStore(envFilePaths, std::cerr)
{
}
void EnvKvStore::loadFiles(
const std::vector<std::filesystem::path> &envFilePaths,
std::ostream &warningStream)
{
for (const std::filesystem::path &envFilePath : envFilePaths)
{
loadFile(envFilePath, warningStream);
}
}
std::optional<std::string> 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
+157
View File
@@ -0,0 +1,157 @@
#include <cstdlib>
#include <chrono>
#include <filesystem>
#include <fstream>
#include <sstream>
#include <string>
#include <vector>
#include <gtest/gtest.h>
#include <spinscale/envKvStore.h>
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);
}
}