diff --git a/src/capi_frontend/capi.cpp b/src/capi_frontend/capi.cpp index 2eb9b25920..77bd7dc732 100644 --- a/src/capi_frontend/capi.cpp +++ b/src/capi_frontend/capi.cpp @@ -62,6 +62,7 @@ #include "servablemetadata.hpp" #include "server_settings.hpp" #include "serialization.hpp" +#include "../filesystem/filesystem.hpp" using ovms::Buffer; using ovms::ExecutionContext; @@ -585,7 +586,7 @@ DLL_PUBLIC OVMS_Status* OVMS_ServerSettingsSetAllowedLocalMediaPath(OVMS_ServerS return reinterpret_cast(new Status(StatusCode::NONEXISTENT_PTR, "log path")); } ovms::ServerSettingsImpl* serverSettings = reinterpret_cast(settings); - serverSettings->allowedLocalMediaPath = allowed_local_media_path; + serverSettings->allowedLocalMediaPath = ovms::FileSystem::normalizeConfiguredPath(allowed_local_media_path); return nullptr; } diff --git a/src/cli_parser.cpp b/src/cli_parser.cpp index c5908078c3..fc0545219f 100644 --- a/src/cli_parser.cpp +++ b/src/cli_parser.cpp @@ -532,7 +532,7 @@ void CLIParser::prepareServer(ServerSettingsImpl& serverSettings) { serverSettings.allowedMediaDomains = result->operator[]("allowed_media_domains").as>(); } if (result->count("allowed_local_media_path")) { - serverSettings.allowedLocalMediaPath = result->operator[]("allowed_local_media_path").as(); + serverSettings.allowedLocalMediaPath = FileSystem::normalizeConfiguredPath(result->operator[]("allowed_local_media_path").as()); } if (result->count("grpc_bind_address")) diff --git a/src/filesystem/filesystem.cpp b/src/filesystem/filesystem.cpp index edafa435e9..a726601ca1 100644 --- a/src/filesystem/filesystem.cpp +++ b/src/filesystem/filesystem.cpp @@ -15,6 +15,7 @@ //***************************************************************************** #include "filesystem.hpp" +#include #include #ifdef _WIN32 #include @@ -200,4 +201,26 @@ Status FileSystem::createFileOverwrite(const std::string& filePath, const std::s return StatusCode::OK; } +std::string FileSystem::normalizeConfiguredPath(const std::string& pathString) { + std::string normalized = pathString; +#ifdef _WIN32 + // Backslash is a path separator only on Windows. On POSIX it is a valid + // filename character; rewriting it would let a path like + // "/allowed\secret.jpg" be authorized as "/allowed/secret.jpg" while + // actually opening a sibling file outside the allowlist. + std::replace(normalized.begin(), normalized.end(), '\\', '/'); +#endif + std::filesystem::path path(normalized); + if (path.is_relative()) { + path = std::filesystem::current_path() / path; + } + path = path.lexically_normal(); + std::error_code ec; + auto weakCanonicalPath = std::filesystem::weakly_canonical(path, ec); + if (!ec) { + return weakCanonicalPath.lexically_normal().string(); + } + return path.string(); +} + } // namespace ovms diff --git a/src/filesystem/filesystem.hpp b/src/filesystem/filesystem.hpp index fb658e8f0e..b5b1aca12c 100644 --- a/src/filesystem/filesystem.hpp +++ b/src/filesystem/filesystem.hpp @@ -231,6 +231,8 @@ class FileSystem { return !path.empty() && (path[0] == '/'); } + static std::string normalizeConfiguredPath(const std::string& pathString); + static std::string joinPath(std::initializer_list segments) { std::string joined; diff --git a/src/llm/apis/openai_api_handler.cpp b/src/llm/apis/openai_api_handler.cpp index 0647807948..c7899ea938 100644 --- a/src/llm/apis/openai_api_handler.cpp +++ b/src/llm/apis/openai_api_handler.cpp @@ -18,6 +18,7 @@ #include #include +#include #include #include #include @@ -46,6 +47,17 @@ namespace ovms { constexpr size_t DEFAULT_MAX_STOP_WORDS = 16; // same as deep-seek +namespace { + +bool isPathInsideDirectory(const std::filesystem::path& testedPath, const std::filesystem::path& allowedDirectory) { + const auto mismatch = std::mismatch( + allowedDirectory.begin(), allowedDirectory.end(), + testedPath.begin(), testedPath.end()); + return mismatch.first == allowedDirectory.end(); +} + +} // namespace + ov::genai::JsonContainer rapidJsonValueToJsonContainer(const rapidjson::Value& value) { if (value.IsNull()) { return ov::genai::JsonContainer(nullptr); @@ -234,15 +246,17 @@ absl::StatusOr loadImage(const std::string& imageSource, return absl::InvalidArgumentError(ss.str()); } SPDLOG_LOGGER_TRACE(llm_calculator_logger, "Loading image from local filesystem"); - const auto firstMissmatch = std::mismatch(imageSource.begin(), imageSource.end(), allowedLocalMediaPath.value().begin(), allowedLocalMediaPath.value().end()); - if (firstMissmatch.second != allowedLocalMediaPath.value().end()) { + const std::filesystem::path resolvedAllowedPath = FileSystem::normalizeConfiguredPath(allowedLocalMediaPath.value()); + const std::string resolvedImagePathStr = FileSystem::normalizeConfiguredPath(imageSource); + const std::filesystem::path resolvedImagePath = resolvedImagePathStr; + if (!isPathInsideDirectory(resolvedImagePath, resolvedAllowedPath)) { return absl::InvalidArgumentError("Given filepath is not subpath of allowed_local_media_path"); } try { - tensor = loadImageStbiFromFile(imageSource.c_str()); + tensor = loadImageStbiFromFile(resolvedImagePathStr.c_str()); } catch (std::runtime_error& e) { std::stringstream ss; - ss << "Image file " << imageSource.c_str() << " parsing failed: " << e.what(); + ss << "Image file " << resolvedImagePathStr << " parsing failed: " << e.what(); SPDLOG_LOGGER_DEBUG(llm_calculator_logger, ss.str()); return absl::InvalidArgumentError(ss.str()); } diff --git a/src/test/c_api_tests.cpp b/src/test/c_api_tests.cpp index 637a1edb5d..712fdcea62 100644 --- a/src/test/c_api_tests.cpp +++ b/src/test/c_api_tests.cpp @@ -38,6 +38,7 @@ #include "../capi_frontend/capi_dag_utils.hpp" #include "../capi_frontend/servablemetadata.hpp" #include "../dags/pipelinedefinitionstatus.hpp" +#include "../filesystem/filesystem.hpp" #include "src/metrics/metric_module.hpp" #include "../ovms.h" #include "../servablemanagermodule.hpp" @@ -183,7 +184,7 @@ TEST(CAPIConfigTest, MultiModelConfiguration) { EXPECT_EQ(serverSettings->logLevel, "TRACE"); EXPECT_EQ(serverSettings->logPath, getGenericFullPathForTmp("/tmp/logs")); ASSERT_TRUE(serverSettings->allowedLocalMediaPath.has_value()); - EXPECT_EQ(serverSettings->allowedLocalMediaPath.value(), getGenericFullPathForTmp("/tmp/path")); + EXPECT_EQ(serverSettings->allowedLocalMediaPath.value(), ovms::FileSystem::normalizeConfiguredPath(getGenericFullPathForTmp("/tmp/path"))); ASSERT_TRUE(serverSettings->allowedMediaDomains.has_value()); EXPECT_EQ(serverSettings->allowedMediaDomains.value().size(), 3); EXPECT_EQ(serverSettings->allowedMediaDomains.value()[0], "raw.githubusercontent.com"); @@ -258,6 +259,22 @@ TEST(CAPIConfigTest, SingleModelConfiguration) { GTEST_SKIP() << "Use C-API to initialize in next stages, currently not supported"; } +TEST(CAPIConfigTest, AllowedLocalMediaPathRelativeIsNormalized) { + OVMS_ServerSettings* serverSettingsRaw = nullptr; + ASSERT_CAPI_STATUS_NULL(OVMS_ServerSettingsNew(&serverSettingsRaw)); + ASSERT_NE(serverSettingsRaw, nullptr); + ServerSettingsImpl* serverSettings = reinterpret_cast(serverSettingsRaw); + + ASSERT_CAPI_STATUS_NULL(OVMS_ServerSettingsSetAllowedLocalMediaPath(serverSettingsRaw, "src/test")); + ASSERT_TRUE(serverSettings->allowedLocalMediaPath.has_value()); + + const auto configuredPath = std::filesystem::path(serverSettings->allowedLocalMediaPath.value()); + const auto expectedPath = std::filesystem::path(ovms::FileSystem::normalizeConfiguredPath("src/test")); + EXPECT_EQ(configuredPath.lexically_normal(), expectedPath.lexically_normal()); + + OVMS_ServerSettingsDelete(serverSettingsRaw); +} + TEST(CAPIStartTest, InitializingMultipleServers) { OVMS_Server* srv1 = nullptr; OVMS_Server* srv2 = nullptr; diff --git a/src/test/http_openai_handler_test.cpp b/src/test/http_openai_handler_test.cpp index c3a40cba3c..a379d86bbd 100644 --- a/src/test/http_openai_handler_test.cpp +++ b/src/test/http_openai_handler_test.cpp @@ -14,6 +14,7 @@ // limitations under the License. //***************************************************************************** #include +#include #include #include #include @@ -24,6 +25,7 @@ #include #include "../http_rest_api_handler.hpp" +#include "../filesystem/filesystem.hpp" #include "../llm/apis/openai_completions.hpp" #include "../llm/apis/openai_responses.hpp" #include @@ -2146,7 +2148,289 @@ TEST_F(HttpOpenAIHandlerParsingTest, ParsingMessagesImageLocalFilesystemWithinAl EXPECT_EQ(json, std::string("{\"model\":\"llama\",\"messages\":[{\"role\":\"user\",\"content\":\"What is in this image?\"}]}")); } +TEST_F(HttpOpenAIHandlerParsingTest, ParsingMessagesImageLocalFilesystemWithinAllowedPathMixedSeparators) { +#ifndef _WIN32 + GTEST_SKIP() << "Backslash is a valid filename character on POSIX and is not treated as a path separator."; +#else + std::string json = R"({ +"model": "llama", +"messages": [ + { + "role": "user", + "content": [ + { + "type": "text", + "text": "What is in this image?" + }, + { + "type": "image_url", + "image_url": { + "url": ")" + getGenericFullPathForSrcTest("/ovms/src/test/binaryutils/rgb.jpg") + + R"(" + } + } + ] + } +] +})"; + doc.Parse(json.c_str()); + ASSERT_FALSE(doc.HasParseError()); + + std::string mixedSeparatorAllowedPath = getGenericFullPathForSrcTest("/ovms/src/test/binaryutils"); + std::replace(mixedSeparatorAllowedPath.begin(), mixedSeparatorAllowedPath.end(), '/', '\\'); + + std::shared_ptr apiHandler = std::make_shared(doc, ovms::Endpoint::CHAT_COMPLETIONS, std::chrono::system_clock::now(), *tokenizer); + ASSERT_EQ(apiHandler->parseMessages(mixedSeparatorAllowedPath), absl::OkStatus()); + const ovms::ImageHistory& imageHistory = apiHandler->getImageHistory(); + ASSERT_EQ(imageHistory.size(), 1); + auto [index, image] = imageHistory[0]; + EXPECT_EQ(index, 0); + EXPECT_EQ(image.get_element_type(), ov::element::u8); + EXPECT_EQ(image.get_size(), 3); +#endif +} + TEST_F(HttpOpenAIHandlerParsingTest, ParsingMessagesImageLocalFilesystemNotWithinAllowedPath) { + const std::string imageUrl = getGenericFullPathForSrcTest("/ovms/src/test/binaryutils/rgb.jpg"); + const std::string allowedPath = getGenericFullPathForSrcTest("/ovms/src/test/llm"); + std::string json = R"({ +"model": "llama", +"messages": [ + { + "role": "user", + "content": [ + { + "type": "text", + "text": "What is in this image?" + }, + { + "type": "image_url", + "image_url": { + "url": ")" + imageUrl + + R"(" + } + } + ] + } +] +})"; + doc.Parse(json.c_str()); + ASSERT_FALSE(doc.HasParseError()); + std::shared_ptr apiHandler = std::make_shared(doc, ovms::Endpoint::CHAT_COMPLETIONS, std::chrono::system_clock::now(), *tokenizer); + ASSERT_EQ(apiHandler->parseMessages(allowedPath), absl::InvalidArgumentError("Given filepath is not subpath of allowed_local_media_path")); +} + +TEST_F(HttpOpenAIHandlerParsingTest, ParsingMessagesImageLocalFilesystemPrefixPathBypassPrevented) { + const std::string allowedLocalMediaPath = getGenericFullPathForSrcTest("/ovms/src/test/binaryutils"); + const std::string siblingPrefixPath = allowedLocalMediaPath + "_private/rgb.jpg"; + std::string json = R"({ +"model": "llama", +"messages": [ + { + "role": "user", + "content": [ + { + "type": "text", + "text": "What is in this image?" + }, + { + "type": "image_url", + "image_url": { + "url": ")" + siblingPrefixPath + + R"(" + } + } + ] + } +] +})"; + doc.Parse(json.c_str()); + ASSERT_FALSE(doc.HasParseError()); + std::shared_ptr apiHandler = std::make_shared(doc, ovms::Endpoint::CHAT_COMPLETIONS, std::chrono::system_clock::now(), *tokenizer); + ASSERT_EQ(apiHandler->parseMessages(allowedLocalMediaPath), absl::InvalidArgumentError("Given filepath is not subpath of allowed_local_media_path")); +} + +TEST_F(HttpOpenAIHandlerParsingTest, ParsingMessagesImageLocalFilesystemRelativeImagePathInsideAllowedPath) { + // Verify that a relative image path is resolved against the current working directory + // and accepted when the resolved location is inside allowed_local_media_path. + // Copy the fixture into cwd so the relative path is a single component (no "..", + // which FileSystem::isPathEscaped would reject before normalization). + const std::filesystem::path absoluteImage = getGenericFullPathForSrcTest("/ovms/src/test/binaryutils/rgb.jpg"); + const std::string relativeImageName = "ovms_relative_image_test_inside.jpg"; + const std::filesystem::path relativeImageInCwd = std::filesystem::current_path() / relativeImageName; + std::error_code ec; + std::filesystem::copy_file(absoluteImage, relativeImageInCwd, std::filesystem::copy_options::overwrite_existing, ec); + ASSERT_FALSE(ec) << "Cannot copy fixture into cwd: " << ec.message(); + const std::string allowedPath = std::filesystem::current_path().generic_string(); + std::string json = R"({ +"model": "llama", +"messages": [ + { + "role": "user", + "content": [ + { + "type": "text", + "text": "What is in this image?" + }, + { + "type": "image_url", + "image_url": { + "url": ")" + relativeImageName + + R"(" + } + } + ] + } +] +})"; + doc.Parse(json.c_str()); + ASSERT_FALSE(doc.HasParseError()); + std::shared_ptr apiHandler = std::make_shared(doc, ovms::Endpoint::CHAT_COMPLETIONS, std::chrono::system_clock::now(), *tokenizer); + const auto status = apiHandler->parseMessages(allowedPath); + std::filesystem::remove(relativeImageInCwd, ec); + ASSERT_EQ(status, absl::OkStatus()); + const ovms::ImageHistory& imageHistory = apiHandler->getImageHistory(); + ASSERT_EQ(imageHistory.size(), 1); + auto [index, image] = imageHistory[0]; + EXPECT_EQ(index, 0); + EXPECT_EQ(image.get_element_type(), ov::element::u8); + EXPECT_EQ(image.get_size(), 3); +} + +TEST_F(HttpOpenAIHandlerParsingTest, ParsingMessagesImageLocalFilesystemRelativeImagePathOutsideAllowedPath) { + // A relative image path resolves against the current working directory; if the resolved + // location is outside allowed_local_media_path the request must be rejected. + const std::string imageUrl = "rgb.jpg"; + const std::string allowedPath = getGenericFullPathForSrcTest("/ovms/src/test/binaryutils"); + std::string json = R"({ +"model": "llama", +"messages": [ + { + "role": "user", + "content": [ + { + "type": "text", + "text": "What is in this image?" + }, + { + "type": "image_url", + "image_url": { + "url": ")" + imageUrl + + R"(" + } + } + ] + } +] +})"; + doc.Parse(json.c_str()); + ASSERT_FALSE(doc.HasParseError()); + std::shared_ptr apiHandler = std::make_shared(doc, ovms::Endpoint::CHAT_COMPLETIONS, std::chrono::system_clock::now(), *tokenizer); + ASSERT_EQ(apiHandler->parseMessages(allowedPath), absl::InvalidArgumentError("Given filepath is not subpath of allowed_local_media_path")); +} + +TEST_F(HttpOpenAIHandlerParsingTest, ParsingMessagesImageLocalFilesystemRelativeAllowedPathInside) { + // A relative allowed_local_media_path is resolved against the current working directory. + // Use "." so allowlist resolves to cwd; copy the fixture into cwd so the (absolute) image + // path falls inside the resolved allowlist. + const std::filesystem::path absoluteImage = getGenericFullPathForSrcTest("/ovms/src/test/binaryutils/rgb.jpg"); + const std::string relativeImageName = "ovms_relative_allowed_test_inside.jpg"; + const std::filesystem::path imageInCwd = std::filesystem::current_path() / relativeImageName; + std::error_code ec; + std::filesystem::copy_file(absoluteImage, imageInCwd, std::filesystem::copy_options::overwrite_existing, ec); + ASSERT_FALSE(ec) << "Cannot copy fixture into cwd: " << ec.message(); + const std::string imageUrl = imageInCwd.generic_string(); + std::string json = R"({ +"model": "llama", +"messages": [ + { + "role": "user", + "content": [ + { + "type": "text", + "text": "What is in this image?" + }, + { + "type": "image_url", + "image_url": { + "url": ")" + imageUrl + + R"(" + } + } + ] + } +] +})"; + doc.Parse(json.c_str()); + ASSERT_FALSE(doc.HasParseError()); + std::shared_ptr apiHandler = std::make_shared(doc, ovms::Endpoint::CHAT_COMPLETIONS, std::chrono::system_clock::now(), *tokenizer); + const auto status = apiHandler->parseMessages("."); + std::filesystem::remove(imageInCwd, ec); + ASSERT_EQ(status, absl::OkStatus()); + const ovms::ImageHistory& imageHistory = apiHandler->getImageHistory(); + ASSERT_EQ(imageHistory.size(), 1); + auto [index, image] = imageHistory[0]; + EXPECT_EQ(index, 0); + EXPECT_EQ(image.get_element_type(), ov::element::u8); + EXPECT_EQ(image.get_size(), 3); +} + +TEST_F(HttpOpenAIHandlerParsingTest, ParsingMessagesImageLocalFilesystemRelativeAllowedPathOutside) { + // A relative allowed_local_media_path resolves against the current working directory; an + // absolute image path located outside of that resolved directory must still be rejected. + const std::string allowedPath = "."; + const std::string imageUrl = getGenericFullPathForSrcTest("/ovms/src/test/binaryutils/rgb.jpg"); + if (std::filesystem::path(imageUrl).lexically_normal().string().rfind( + std::filesystem::current_path().lexically_normal().string(), 0) == 0) { + GTEST_SKIP() << "Image path is inside the current working directory; cannot exercise the outside-of-allowlist case."; + } + std::string json = R"({ +"model": "llama", +"messages": [ + { + "role": "user", + "content": [ + { + "type": "text", + "text": "What is in this image?" + }, + { + "type": "image_url", + "image_url": { + "url": ")" + imageUrl + + R"(" + } + } + ] + } +] +})"; + doc.Parse(json.c_str()); + ASSERT_FALSE(doc.HasParseError()); + std::shared_ptr apiHandler = std::make_shared(doc, ovms::Endpoint::CHAT_COMPLETIONS, std::chrono::system_clock::now(), *tokenizer); + ASSERT_EQ(apiHandler->parseMessages(allowedPath), absl::InvalidArgumentError("Given filepath is not subpath of allowed_local_media_path")); +} + +TEST_F(HttpOpenAIHandlerParsingTest, ParsingMessagesImageLocalFilesystemSymlinkEscapeIsRejected) { +#ifdef _WIN32 + GTEST_SKIP() << "Creating filesystem symlinks on Windows requires elevated privileges and is unreliable in CI."; +#else + // Build an allowed directory that contains a symlink pointing to a sibling directory holding the + // real image. The image, when accessed through the symlink, appears to live inside the allowlist, + // but its canonical location is outside it. This regression test ensures the authorization check + // resolves the symlink (via weakly_canonical) before the allowlist comparison. + const std::filesystem::path realImageDir = getGenericFullPathForSrcTest("/ovms/src/test/binaryutils"); + const std::filesystem::path allowedRoot = std::filesystem::temp_directory_path() / "ovms_symlink_allowlist_test"; + std::error_code ec; + std::filesystem::remove_all(allowedRoot, ec); + ASSERT_TRUE(std::filesystem::create_directories(allowedRoot, ec)) << ec.message(); + const std::filesystem::path symlinkInsideAllowed = allowedRoot / "linked"; + std::filesystem::create_directory_symlink(realImageDir, symlinkInsideAllowed, ec); + if (ec) { + std::filesystem::remove_all(allowedRoot); + GTEST_SKIP() << "Cannot create symlink for test: " << ec.message(); + } + const std::string imageUrl = (symlinkInsideAllowed / "rgb.jpg").string(); std::string json = R"({ "model": "llama", "messages": [ @@ -2160,7 +2444,8 @@ TEST_F(HttpOpenAIHandlerParsingTest, ParsingMessagesImageLocalFilesystemNotWithi { "type": "image_url", "image_url": { - "url": "/ovms/src/test/binaryutils/rgb.jpg" + "url": ")" + imageUrl + + R"(" } } ] @@ -2170,10 +2455,15 @@ TEST_F(HttpOpenAIHandlerParsingTest, ParsingMessagesImageLocalFilesystemNotWithi doc.Parse(json.c_str()); ASSERT_FALSE(doc.HasParseError()); std::shared_ptr apiHandler = std::make_shared(doc, ovms::Endpoint::CHAT_COMPLETIONS, std::chrono::system_clock::now(), *tokenizer); - ASSERT_EQ(apiHandler->parseMessages("src/test"), absl::InvalidArgumentError("Given filepath is not subpath of allowed_local_media_path")); + const auto status = apiHandler->parseMessages(allowedRoot.string()); + std::filesystem::remove_all(allowedRoot, ec); + ASSERT_EQ(status, absl::InvalidArgumentError("Given filepath is not subpath of allowed_local_media_path")); +#endif } TEST_F(HttpOpenAIHandlerParsingTest, ParsingMessagesImageLocalFilesystemInvalidPath) { + const std::string allowedPath = getGenericFullPathForSrcTest("/ovms/src/test/"); + const std::string imageUrl = getGenericFullPathForSrcTest("/ovms/src/test/not_existing.jpeg"); std::string json = R"({ "model": "llama", "messages": [ @@ -2187,7 +2477,8 @@ TEST_F(HttpOpenAIHandlerParsingTest, ParsingMessagesImageLocalFilesystemInvalidP { "type": "image_url", "image_url": { - "url": "/ovms/not_exisiting.jpeg" + "url": ")" + + imageUrl + R"(" } } ] @@ -2197,7 +2488,7 @@ TEST_F(HttpOpenAIHandlerParsingTest, ParsingMessagesImageLocalFilesystemInvalidP doc.Parse(json.c_str()); ASSERT_FALSE(doc.HasParseError()); std::shared_ptr apiHandler = std::make_shared(doc, ovms::Endpoint::CHAT_COMPLETIONS, std::chrono::system_clock::now(), *tokenizer); - EXPECT_EQ(apiHandler->parseMessages("/ovms/"), absl::InvalidArgumentError("Image file /ovms/not_exisiting.jpeg parsing failed: can't fopen")); + EXPECT_EQ(apiHandler->parseMessages(allowedPath), absl::InvalidArgumentError("Image file " + ovms::FileSystem::normalizeConfiguredPath(imageUrl) + " parsing failed: can't fopen")); } TEST_F(HttpOpenAIHandlerParsingTest, ParsingMessagesImageLocalFilesystemInvalidEscaped) { diff --git a/src/test/ovmsconfig_test.cpp b/src/test/ovmsconfig_test.cpp index af76b5bb55..b59a2d985c 100644 --- a/src/test/ovmsconfig_test.cpp +++ b/src/test/ovmsconfig_test.cpp @@ -2425,7 +2425,7 @@ TEST(OvmsConfigTest, positiveMulti) { #endif EXPECT_EQ(config.cacheDir(), "/tmp/model_cache"); ASSERT_TRUE(config.getServerSettings().allowedLocalMediaPath.has_value()); - EXPECT_EQ(config.getServerSettings().allowedLocalMediaPath.value(), "/tmp/path"); + EXPECT_EQ(config.getServerSettings().allowedLocalMediaPath.value(), ovms::FileSystem::normalizeConfiguredPath("/tmp/path")); ASSERT_TRUE(config.getServerSettings().allowedMediaDomains.has_value()); EXPECT_EQ(config.getServerSettings().allowedMediaDomains.value().size(), 3); EXPECT_EQ(config.getServerSettings().allowedMediaDomains.value()[0], "raw.githubusercontent.com"); @@ -2446,6 +2446,25 @@ TEST(OvmsConfigTest, positiveMulti) { #endif } +TEST(OvmsConfigTest, allowedLocalMediaPathRelativeIsNormalized) { + char* n_argv[] = { + "ovms", + "--rest_port", "45", + "--allowed_local_media_path", + "src/test", + "--config_path", + "/config.json"}; + + int arg_count = 7; + ConstructorEnabledConfig config; + config.parse(arg_count, n_argv); + + ASSERT_TRUE(config.getServerSettings().allowedLocalMediaPath.has_value()); + const auto configuredPath = std::filesystem::path(config.getServerSettings().allowedLocalMediaPath.value()); + const auto expectedPath = std::filesystem::path(ovms::FileSystem::normalizeConfiguredPath("src/test")); + EXPECT_EQ(configuredPath.lexically_normal(), expectedPath.lexically_normal()); +} + TEST(OvmsConfigTest, positiveSingle) { #ifdef _WIN32 const std::string cpu_extension_lib_path = "tmp_cpu_extension_library_dir";