mirror of
https://github.com/latentPrion/libspinscale.git
synced 2026-06-23 11:38:33 +00:00
Add EnvKvStore for envvar parsing and interleaving
This commit is contained in:
@@ -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}
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user