From 9f2fc21e295efa28363b69e99af14a0e01f3893d Mon Sep 17 00:00:00 2001 From: rasapala Date: Thu, 28 May 2026 11:17:30 +0200 Subject: [PATCH 01/18] Fix user git access --- src/pull_module/libgit2.cpp | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/src/pull_module/libgit2.cpp b/src/pull_module/libgit2.cpp index 87c263e79a..8ba1d51d96 100644 --- a/src/pull_module/libgit2.cpp +++ b/src/pull_module/libgit2.cpp @@ -250,9 +250,35 @@ int cred_acquire_cb(git_credential** out, * @note Connects to internet: configures timeouts for remote git/LFS operations. */ Libgt2InitGuard::Libgt2InitGuard(const Libgit2Options& opts) { - SPDLOG_DEBUG("Initializing libgit2"); +SPDLOG_DEBUG("Initializing libgit2"); this->status = git_libgit2_init(); IF_ERROR_SET_MSG_AND_RETURN(); + // Disable ownership check so repositories owned by a different OS user can be + // opened for reading (equivalent to git's `safe.directory = *`). This is safe + // in a serving context where the operator intentionally mounts model directories + // that may have been downloaded by a different UID (e.g. root in the build + // container vs. a non-root serving user). + this->status = git_libgit2_opts(GIT_OPT_SET_OWNER_VALIDATION, 0); + IF_ERROR_SET_MSG_AND_RETURN(); + // Redirect all git config search paths (system, XDG, global) to an empty string so + // libgit2 never reads the operator's ~/.gitconfig or /etc/gitconfig. Without this, + // a host gitconfig that sets credential.helper, http.proxy, lfs.*, or safe.directory + // can silently override OVMS's intended proxy/token settings and cause spurious + // failures or credential leaks in multi-tenant environments. + this->status = git_libgit2_opts(GIT_OPT_SET_SEARCH_PATH, GIT_CONFIG_LEVEL_SYSTEM, ""); + IF_ERROR_SET_MSG_AND_RETURN(); + this->status = git_libgit2_opts(GIT_OPT_SET_SEARCH_PATH, GIT_CONFIG_LEVEL_XDG, ""); + IF_ERROR_SET_MSG_AND_RETURN(); + this->status = git_libgit2_opts(GIT_OPT_SET_SEARCH_PATH, GIT_CONFIG_LEVEL_GLOBAL, ""); + IF_ERROR_SET_MSG_AND_RETURN(); + // Skip .keep file existence checks when reading packfiles. libgit2 performs one + // stat() per pack per operation to honour .keep files (which prevent gc from + // collecting referenced packs). In an OVMS deployment the model directory is + // never garbage-collected and may live on NFS or other high-latency remote + // filesystems, so removing this stat() per open noticeably reduces latency on + // resume/status operations against large repositories. + this->status = git_libgit2_opts(GIT_OPT_DISABLE_PACK_KEEP_FILE_CHECKS, 1); + IF_ERROR_SET_MSG_AND_RETURN(); SPDLOG_TRACE("Setting libgit2 server connection timeout:{}", opts.serverConnectTimeoutMs); this->status = git_libgit2_opts(GIT_OPT_SET_SERVER_CONNECT_TIMEOUT, opts.serverConnectTimeoutMs); IF_ERROR_SET_MSG_AND_RETURN(); From 7fff31c0e3ccc26cfb06c2d3942642e2859426d6 Mon Sep 17 00:00:00 2001 From: rasapala Date: Thu, 28 May 2026 11:18:40 +0200 Subject: [PATCH 02/18] Style --- src/pull_module/libgit2.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pull_module/libgit2.cpp b/src/pull_module/libgit2.cpp index 8ba1d51d96..8903b6c2ad 100644 --- a/src/pull_module/libgit2.cpp +++ b/src/pull_module/libgit2.cpp @@ -250,7 +250,7 @@ int cred_acquire_cb(git_credential** out, * @note Connects to internet: configures timeouts for remote git/LFS operations. */ Libgt2InitGuard::Libgt2InitGuard(const Libgit2Options& opts) { -SPDLOG_DEBUG("Initializing libgit2"); + SPDLOG_DEBUG("Initializing libgit2"); this->status = git_libgit2_init(); IF_ERROR_SET_MSG_AND_RETURN(); // Disable ownership check so repositories owned by a different OS user can be From e9648ec318902390aefd24d5bda4ebb478ecab14 Mon Sep 17 00:00:00 2001 From: rasapala Date: Thu, 28 May 2026 11:41:09 +0200 Subject: [PATCH 03/18] Add tests --- src/pull_module/libgit2.cpp | 10 ++++-- src/test/libgit2_test.cpp | 72 +++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 2 deletions(-) diff --git a/src/pull_module/libgit2.cpp b/src/pull_module/libgit2.cpp index 8903b6c2ad..f1e800c6aa 100644 --- a/src/pull_module/libgit2.cpp +++ b/src/pull_module/libgit2.cpp @@ -260,17 +260,23 @@ Libgt2InitGuard::Libgt2InitGuard(const Libgit2Options& opts) { // container vs. a non-root serving user). this->status = git_libgit2_opts(GIT_OPT_SET_OWNER_VALIDATION, 0); IF_ERROR_SET_MSG_AND_RETURN(); - // Redirect all git config search paths (system, XDG, global) to an empty string so - // libgit2 never reads the operator's ~/.gitconfig or /etc/gitconfig. Without this, + // Redirect all git config search paths to an empty string so libgit2 never reads + // host-level git configuration (~/.gitconfig, /etc/gitconfig, etc.). Without this, // a host gitconfig that sets credential.helper, http.proxy, lfs.*, or safe.directory // can silently override OVMS's intended proxy/token settings and cause spurious // failures or credential leaks in multi-tenant environments. + // On Windows, GIT_CONFIG_LEVEL_PROGRAMDATA covers %PROGRAMDATA%\Git\config which is + // a machine-wide config that libgit2 reads before SYSTEM; it must be cleared as well. this->status = git_libgit2_opts(GIT_OPT_SET_SEARCH_PATH, GIT_CONFIG_LEVEL_SYSTEM, ""); IF_ERROR_SET_MSG_AND_RETURN(); this->status = git_libgit2_opts(GIT_OPT_SET_SEARCH_PATH, GIT_CONFIG_LEVEL_XDG, ""); IF_ERROR_SET_MSG_AND_RETURN(); this->status = git_libgit2_opts(GIT_OPT_SET_SEARCH_PATH, GIT_CONFIG_LEVEL_GLOBAL, ""); IF_ERROR_SET_MSG_AND_RETURN(); +#if defined(_WIN32) + this->status = git_libgit2_opts(GIT_OPT_SET_SEARCH_PATH, GIT_CONFIG_LEVEL_PROGRAMDATA, ""); + IF_ERROR_SET_MSG_AND_RETURN(); +#endif // Skip .keep file existence checks when reading packfiles. libgit2 performs one // stat() per pack per operation to honour .keep files (which prevent gc from // collecting referenced packs). In an OVMS deployment the model directory is diff --git a/src/test/libgit2_test.cpp b/src/test/libgit2_test.cpp index d44f8c2fbe..7fc45de3c0 100644 --- a/src/test/libgit2_test.cpp +++ b/src/test/libgit2_test.cpp @@ -849,3 +849,75 @@ TEST(LibGit2LfsWipMarker, MarkersForDifferentRepositoriesAreIndependent) { EXPECT_FALSE(ovms::libgit2::hasLfsWipMarker(repoAPath)); EXPECT_TRUE(ovms::libgit2::hasLfsWipMarker(repoBPath)); } + +// --------------------------------------------------------------------------- +// Libgt2InitGuard initialization behavior +// +// These tests exercise the process-global libgit2 options set in +// Libgt2InitGuard's constructor: ownership-validation suppression and config +// search-path isolation. Each test creates its own guard so that the options +// are set fresh; as git_libgit2_init() is ref-counted by libgit2 it is safe to +// call it multiple times within the same process. +// --------------------------------------------------------------------------- + +class Libgt2InitGuardTest : public ::testing::Test { +protected: + TempDir td; + ovms::Libgit2Options defaultOpts; +}; + +TEST_F(Libgt2InitGuardTest, ConstructionSucceeds) { + ovms::Libgt2InitGuard guard(defaultOpts); + EXPECT_GE(guard.status, 0); + EXPECT_TRUE(guard.errMsg.empty()); + EXPECT_TRUE(guard.countedAsInitialized); +} + +// After the guard is constructed, libgit2 must have owner-validation disabled +// so that repositories owned by a different OS user can be opened. +TEST_F(Libgt2InitGuardTest, OwnerValidationIsDisabled) { + ovms::Libgt2InitGuard guard(defaultOpts); + ASSERT_GE(guard.status, 0); + + int ownerValidation = 1; // preset to non-zero; guard must set it to 0 + int rc = git_libgit2_opts(GIT_OPT_GET_OWNER_VALIDATION, &ownerValidation); + EXPECT_EQ(rc, 0); + EXPECT_EQ(ownerValidation, 0); +} + +// The guard must clear the config search paths for all host-level config +// scopes so that no host gitconfig can interfere with OVMS's settings. +TEST_F(Libgt2InitGuardTest, ConfigSearchPathsAreCleared) { + ovms::Libgt2InitGuard guard(defaultOpts); + ASSERT_GE(guard.status, 0); + + static const int levels[] = { + GIT_CONFIG_LEVEL_SYSTEM, + GIT_CONFIG_LEVEL_XDG, + GIT_CONFIG_LEVEL_GLOBAL, + }; + for (int level : levels) { + git_buf buf = GIT_BUF_INIT; + int rc = git_libgit2_opts(GIT_OPT_GET_SEARCH_PATH, level, &buf); + EXPECT_EQ(rc, 0) << "GIT_OPT_GET_SEARCH_PATH failed for config level " << level; + const char* path = (buf.ptr != nullptr) ? buf.ptr : ""; + EXPECT_STREQ(path, "") << "Config search path not cleared for level " << level; + git_buf_dispose(&buf); + } +} + +#if defined(_WIN32) +// On Windows, libgit2 also supports GIT_CONFIG_LEVEL_PROGRAMDATA which maps to +// %PROGRAMDATA%\Git\config — a machine-wide config that must also be suppressed. +TEST_F(Libgt2InitGuardTest, ConfigSearchPathProgramdataClearedOnWindows) { + ovms::Libgt2InitGuard guard(defaultOpts); + ASSERT_GE(guard.status, 0); + + git_buf buf = GIT_BUF_INIT; + int rc = git_libgit2_opts(GIT_OPT_GET_SEARCH_PATH, GIT_CONFIG_LEVEL_PROGRAMDATA, &buf); + EXPECT_EQ(rc, 0); + const char* path = (buf.ptr != nullptr) ? buf.ptr : ""; + EXPECT_STREQ(path, ""); + git_buf_dispose(&buf); +} +#endif From 2c95e0f93a073b16507cd5cefe7de30981fee611 Mon Sep 17 00:00:00 2001 From: Rafal Sapala Date: Thu, 28 May 2026 13:59:05 +0200 Subject: [PATCH 04/18] Fix tests --- src/test/pull_hf_model_test.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/test/pull_hf_model_test.cpp b/src/test/pull_hf_model_test.cpp index 25ada82a97..ff67cc9129 100644 --- a/src/test/pull_hf_model_test.cpp +++ b/src/test/pull_hf_model_test.cpp @@ -1564,7 +1564,7 @@ class HfDownloaderHfEnvTest : public ::testing::Test { EnvGuard guard; }; -TEST(Libgt2InitGuardTest, LfsFilterCaptureDefaultResumeOptions) { +TEST(Libgt2InitGuardLfsFilterTest, LfsFilterCaptureDefaultResumeOptions) { // Need new process beacase we use INIT_ONCE in libgit2 lfs filter for env variables and once they are set they are set for the whole process lifetime EXPECT_EXIT({ // Act: capture stdout during object construction @@ -1584,7 +1584,7 @@ TEST(Libgt2InitGuardTest, LfsFilterCaptureDefaultResumeOptions) { exit(0); }, ::testing::ExitedWithCode(0), ""); } -TEST(Libgt2InitGuardTest, LfsFilterCaptureNonDefaultResumeOptions) { +TEST(Libgt2InitGuardLfsFilterTest, LfsFilterCaptureNonDefaultResumeOptions) { // Need new process beacase we use INIT_ONCE in libgit2 lfs filter for env variables and once they are set they are set for the whole process lifetime EXPECT_EXIT({ EnvGuard guard; From 42140f24f0f5f42b591f40114c610debea35e3c4 Mon Sep 17 00:00:00 2001 From: rasapala Date: Thu, 28 May 2026 16:53:29 +0200 Subject: [PATCH 05/18] Add should resume logic --- src/pull_module/libgit2.cpp | 65 +++++++++++++++++++++++++++++-------- 1 file changed, 52 insertions(+), 13 deletions(-) diff --git a/src/pull_module/libgit2.cpp b/src/pull_module/libgit2.cpp index f1e800c6aa..4574636a51 100644 --- a/src/pull_module/libgit2.cpp +++ b/src/pull_module/libgit2.cpp @@ -1128,6 +1128,7 @@ Status resumeLfsDownloadForFile(git_repository* repo, const char* filePathInRepo namespace { struct ResumeCandidates { + bool shouldResume = false; bool hasWipMarker = false; bool hasLfsErrorFile = false; bool interruptionLikely = false; @@ -1143,10 +1144,7 @@ struct ResumeCandidates { * @return ResumeCandidates containing LFS and non-LFS recovery targets. * @note Works on local repository metadata and filesystem; no network operations. */ -ResumeCandidates buildResumeCandidates(git_repository* repo, const std::string& downloadPath) { - ResumeCandidates candidates; - candidates.hasWipMarker = libgit2::hasLfsWipMarker(downloadPath); - candidates.hasLfsErrorFile = libgit2::hasLfsErrorFile(downloadPath); +ResumeCandidates buildResumeCandidates(git_repository* repo, const std::string& downloadPath, ResumeCandidates candidates) { // Checking if the download was partially finished for any files in repository, // including tracked LFS pointer blobs missing from the worktree after abrupt termination. @@ -1278,14 +1276,20 @@ Status resumeExistingRepository(git_repository* repo, return StatusCode::OK; } -Status handleExistingRepositoryWithoutOverwrite(const std::string& downloadPath, - const std::function& checkRepositoryStatusFn) { - // If the directory does not contain a .git entry, treat it as a user-provided model directory. - // The user has copied model files in by hand; skip the pull and let model loading proceed - // against whatever files are already on disk. Use --overwrite_models to replace it with a - // fresh download. +bool resumeCheckSecondCondition(const std::string& downloadPath, const ResumeCandidates& candidates) { + // Use repository object only when interruption markers indicate a previous + // pull likely failed and resume logic may be required. + if (!candidates.hasWipMarker && !candidates.hasLfsErrorFile) { + SPDLOG_DEBUG("Model pull operation found no interruption markers for this path: {}", downloadPath); + SPDLOG_INFO("Path already exists on local filesystem. Skipping download to path: {}", downloadPath); + return false; + } + return true; +} + +Status resumeCheckFirstCondition(const std::string& downloadPath, bool& gitEntryExists) { std::error_code ec; - const bool gitEntryExists = fs::exists(fs::path(downloadPath) / ".git", ec); + gitEntryExists = fs::exists(fs::path(downloadPath) / ".git", ec); if (ec) { // Probe itself failed (permission denied, I/O error, ...). Do not silently fall through // to the "not a git repository" branch, that would mask real filesystem problems. @@ -1296,6 +1300,41 @@ Status handleExistingRepositoryWithoutOverwrite(const std::string& downloadPath, SPDLOG_INFO("Path \"{}\" exists but is not a git repository. " "Skipping download and using existing files.", downloadPath); + } + return StatusCode::OK; +} + +Status checkSufficientResumeConditions(const std::string& downloadPath, ResumeCandidates& candidates) { + candidates.shouldResume = false; + + bool gitEntryExists = false; + auto firstConditionStatus = resumeCheckFirstCondition(downloadPath, gitEntryExists); + if (!firstConditionStatus.ok()) { + return firstConditionStatus; + } + if (!gitEntryExists) { + return StatusCode::OK; + } + + // Probe interruption markers once and reuse them later when building candidates. + candidates.hasWipMarker = libgit2::hasLfsWipMarker(downloadPath); + candidates.hasLfsErrorFile = libgit2::hasLfsErrorFile(downloadPath); + candidates.shouldResume = resumeCheckSecondCondition(downloadPath, candidates); + return StatusCode::OK; +} + +Status handleExistingRepositoryWithoutOverwrite(const std::string& downloadPath, + const std::function& checkRepositoryStatusFn) { + // If the directory does not contain a .git entry, treat it as a user-provided model directory. + // The user has copied model files in by hand; skip the pull and let model loading proceed + // against whatever files are already on disk. Use --overwrite_models to replace it with a + // fresh download. + ResumeCandidates candidates; + auto sufficientConditionsStatus = checkSufficientResumeConditions(downloadPath, candidates); + if (!sufficientConditionsStatus.ok()) { + return sufficientConditionsStatus; + } + if (!candidates.shouldResume) { return StatusCode::OK; } @@ -1308,9 +1347,9 @@ Status handleExistingRepositoryWithoutOverwrite(const std::string& downloadPath, return mapRepositoryOpenFailureToStatus(repoGuard); } - auto candidates = buildResumeCandidates(repoGuard.get(), downloadPath); + candidates = buildResumeCandidates(repoGuard.get(), downloadPath, std::move(candidates)); if (!candidates.interruptionLikely) { - SPDLOG_DEBUG("Model pull operation found no interruption signals for this path: {}", downloadPath); + SPDLOG_WARN("Interruption marker(s) were found but no resumable candidates were detected for path: {}", downloadPath); SPDLOG_INFO("Path already exists on local filesystem. Skipping download to path: {}", downloadPath); return StatusCode::OK; } From 62a3a68e628e00045dcc9bcfe381fc3af11f28e2 Mon Sep 17 00:00:00 2001 From: Rafal Sapala Date: Thu, 28 May 2026 16:58:46 +0200 Subject: [PATCH 06/18] Style --- src/pull_module/libgit2.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/src/pull_module/libgit2.cpp b/src/pull_module/libgit2.cpp index 4574636a51..0d6a9ba367 100644 --- a/src/pull_module/libgit2.cpp +++ b/src/pull_module/libgit2.cpp @@ -1145,7 +1145,6 @@ struct ResumeCandidates { * @note Works on local repository metadata and filesystem; no network operations. */ ResumeCandidates buildResumeCandidates(git_repository* repo, const std::string& downloadPath, ResumeCandidates candidates) { - // Checking if the download was partially finished for any files in repository, // including tracked LFS pointer blobs missing from the worktree after abrupt termination. candidates.lfsMatches = libgit2::findResumableLfsFiles(repo, downloadPath, candidates.hasWipMarker || candidates.hasLfsErrorFile); From e28abc4deb9b763711c82fc23ec98ddf66f5133e Mon Sep 17 00:00:00 2001 From: Rafal Sapala Date: Thu, 28 May 2026 17:20:13 +0200 Subject: [PATCH 07/18] Add new tests --- src/test/pull_hf_model_test.cpp | 167 +++++++++++++++++++++++++++++++- 1 file changed, 164 insertions(+), 3 deletions(-) diff --git a/src/test/pull_hf_model_test.cpp b/src/test/pull_hf_model_test.cpp index ff67cc9129..e290995ed0 100644 --- a/src/test/pull_hf_model_test.cpp +++ b/src/test/pull_hf_model_test.cpp @@ -685,13 +685,14 @@ TEST_F(HfPullCache, PullNonGit) { EXPECT_FALSE(std::filesystem::exists(gitDir)); } -// PullAgainstDirectoryWithEmptyDotGitFailsWithRepositoryError +// PullAgainstDirectoryWithEmptyDotGitSuccedsWithoutMarkers // // Companion to HfPullCache.PullNonGit. Verifies that when .git IS present but is // empty (a corrupt / partially-initialized repository) handleExistingRepositoryWithoutOverwrite() // does NOT silently succeed: the .git probe passes, GitRepositoryGuard then fails to open // the repository and the real error is propagated via mapRepositoryOpenFailureToStatus() // so the operator can act (re-clone, fix permissions, --overwrite_models, ...). +// Without file markers it will not check the git repository. TEST_F(HfPullCache, PullEmptyGitDir) { std::string modelName = "OpenVINO/Phi-3-mini-FastDraft-50M-int8-ov"; std::string downloadPath = ovms::FileSystem::joinPath({this->directoryPath, "repository"}); @@ -729,10 +730,170 @@ TEST_F(HfPullCache, PullEmptyGitDir) { ASSERT_TRUE(std::filesystem::is_directory(gitDir)); ASSERT_TRUE(std::filesystem::is_empty(gitDir)); - // Pull must NOT silently succeed: handleExistingRepositoryWithoutOverwrite should - // surface the libgit2 open failure (mapRepositoryOpenFailureToStatus -> non-OK). + // Pull will silently succeed: handleExistingRepositoryWithoutOverwrite should + // will not surface the libgit2 open failure because there are not interruption file markers present + this->ServerPullHfModel(modelName, downloadPath, task, EXIT_SUCCESS); + + // No work-in-progress marker should be created next to the model directory. + const std::string lfsWipPath = ovms::libgit2::getLfsWipMarkerPath(basePath).string(); + EXPECT_FALSE(std::filesystem::exists(lfsWipPath)); + + // Files must be left exactly as they were on disk. + ASSERT_TRUE(std::filesystem::exists(modelPath)); + ASSERT_TRUE(std::filesystem::exists(tokenizerPath)); + EXPECT_EQ(std::filesystem::file_size(modelPath), modelSizeBefore); + EXPECT_EQ(std::filesystem::file_size(tokenizerPath), tokenizerSizeBefore); + + std::string modelDigestAfter = sha256File(modelPath, ec); + ASSERT_EQ(ec, std::errc()); + std::string tokenizerDigestAfter = sha256File(tokenizerPath, ec); + ASSERT_EQ(ec, std::errc()); + EXPECT_EQ(modelDigestBefore, modelDigestAfter); + EXPECT_EQ(tokenizerDigestBefore, tokenizerDigestAfter); + + // .git is still present (we left an empty directory there); no fresh clone happened. + EXPECT_TRUE(std::filesystem::is_directory(gitDir)); + EXPECT_TRUE(std::filesystem::is_empty(gitDir)); +} + +// PullAgainstDirectoryWithEmptyDotGitSuccedsWithMarkers +// +// Companion to HfPullCache.PullNonGit. Verifies that when .git IS present but is +// empty (a corrupt / partially-initialized repository) handleExistingRepositoryWithoutOverwrite() +// does NOT silently succeed: the .git probe passes, GitRepositoryGuard then fails to open +// the repository and the real error is propagated via mapRepositoryOpenFailureToStatus() +// so the operator can act (re-clone, fix permissions, --overwrite_models, ...). +// With file markers it will check the git repository and fail. +TEST_F(HfPullCache, EmptyGitDirLfsMarker) { + std::string modelName = "OpenVINO/Phi-3-mini-FastDraft-50M-int8-ov"; + std::string downloadPath = ovms::FileSystem::joinPath({this->directoryPath, "repository"}); + std::string task = "text_generation"; + + std::string basePath = ovms::FileSystem::joinPath({downloadPath, "OpenVINO", "Phi-3-mini-FastDraft-50M-int8-ov"}); + std::string modelPath = ovms::FileSystem::appendSlash(basePath) + "openvino_model.bin"; + std::string tokenizerPath = ovms::FileSystem::appendSlash(basePath) + "openvino_tokenizer.bin"; + std::string gitDir = ovms::FileSystem::appendSlash(basePath) + ".git"; + + ASSERT_TRUE(std::filesystem::exists(modelPath)); + ASSERT_TRUE(std::filesystem::exists(tokenizerPath)); + ASSERT_TRUE(std::filesystem::is_directory(gitDir)); + + // Capture pre-pull file fingerprints so we can confirm pull did not modify them. + std::error_code ec; + const std::uintmax_t modelSizeBefore = std::filesystem::file_size(modelPath, ec); + ASSERT_EQ(ec, std::errc()); + const std::uintmax_t tokenizerSizeBefore = std::filesystem::file_size(tokenizerPath, ec); + ASSERT_EQ(ec, std::errc()); + std::string modelDigestBefore = sha256File(modelPath, ec); + ASSERT_EQ(ec, std::errc()); + std::string tokenizerDigestBefore = sha256File(tokenizerPath, ec); + ASSERT_EQ(ec, std::errc()); + + // Replace the cached .git with an empty directory to simulate corruption / partial init. + // Drop readonly attributes first so std::filesystem::remove_all succeeds on Windows. + RemoveReadonlyFileAttributeFromDir(gitDir); + ec.clear(); + std::filesystem::remove_all(gitDir, ec); + ASSERT_EQ(ec, std::errc()) << "Failed to remove .git from cached repository: " << ec.message(); + ec.clear(); + std::filesystem::create_directory(gitDir, ec); + ASSERT_EQ(ec, std::errc()) << "Failed to recreate empty .git directory: " << ec.message(); + ASSERT_TRUE(std::filesystem::is_directory(gitDir)); + ASSERT_TRUE(std::filesystem::is_empty(gitDir)); + + // Add interruption marker so resume path is taken and repository open is validated. + ASSERT_TRUE(ovms::libgit2::createLfsWipMarker(basePath)); + const std::string lfsWipPath = ovms::libgit2::getLfsWipMarkerPath(basePath).string(); + ASSERT_TRUE(std::filesystem::exists(lfsWipPath)); + + + // Pull will not silently succeed: handleExistingRepositoryWithoutOverwrite + // will surface the libgit2 open failure because there are interruption file markers present this->ServerPullHfModel(modelName, downloadPath, task, EXIT_FAILURE); + // Marker was pre-created and remains because resume did not complete successfully. + EXPECT_TRUE(std::filesystem::exists(lfsWipPath)); + + // Files must be left exactly as they were on disk. + ASSERT_TRUE(std::filesystem::exists(modelPath)); + ASSERT_TRUE(std::filesystem::exists(tokenizerPath)); + EXPECT_EQ(std::filesystem::file_size(modelPath), modelSizeBefore); + EXPECT_EQ(std::filesystem::file_size(tokenizerPath), tokenizerSizeBefore); + + std::string modelDigestAfter = sha256File(modelPath, ec); + ASSERT_EQ(ec, std::errc()); + std::string tokenizerDigestAfter = sha256File(tokenizerPath, ec); + ASSERT_EQ(ec, std::errc()); + EXPECT_EQ(modelDigestBefore, modelDigestAfter); + EXPECT_EQ(tokenizerDigestBefore, tokenizerDigestAfter); + + // .git is still present (we left an empty directory there); no fresh clone happened. + EXPECT_TRUE(std::filesystem::is_directory(gitDir)); + EXPECT_TRUE(std::filesystem::is_empty(gitDir)); +} + +// PullAgainstDirectoryWithEmptyDotGitSuccedsWithMarkers +// +// Companion to HfPullCache.PullNonGit. Verifies that when .git IS present but is +// empty (a corrupt / partially-initialized repository) handleExistingRepositoryWithoutOverwrite() +// does NOT silently succeed: the .git probe passes, GitRepositoryGuard then fails to open +// the repository and the real error is propagated via mapRepositoryOpenFailureToStatus() +// so the operator can act (re-clone, fix permissions, --overwrite_models, ...). +// With file markers it will check the git repository and fail. +TEST_F(HfPullCache, EmptyGitDirErrorMarker) { + std::string modelName = "OpenVINO/Phi-3-mini-FastDraft-50M-int8-ov"; + std::string downloadPath = ovms::FileSystem::joinPath({this->directoryPath, "repository"}); + std::string task = "text_generation"; + + std::string basePath = ovms::FileSystem::joinPath({downloadPath, "OpenVINO", "Phi-3-mini-FastDraft-50M-int8-ov"}); + std::string modelPath = ovms::FileSystem::appendSlash(basePath) + "openvino_model.bin"; + std::string tokenizerPath = ovms::FileSystem::appendSlash(basePath) + "openvino_tokenizer.bin"; + std::string gitDir = ovms::FileSystem::appendSlash(basePath) + ".git"; + + ASSERT_TRUE(std::filesystem::exists(modelPath)); + ASSERT_TRUE(std::filesystem::exists(tokenizerPath)); + ASSERT_TRUE(std::filesystem::is_directory(gitDir)); + + // Capture pre-pull file fingerprints so we can confirm pull did not modify them. + std::error_code ec; + const std::uintmax_t modelSizeBefore = std::filesystem::file_size(modelPath, ec); + ASSERT_EQ(ec, std::errc()); + const std::uintmax_t tokenizerSizeBefore = std::filesystem::file_size(tokenizerPath, ec); + ASSERT_EQ(ec, std::errc()); + std::string modelDigestBefore = sha256File(modelPath, ec); + ASSERT_EQ(ec, std::errc()); + std::string tokenizerDigestBefore = sha256File(tokenizerPath, ec); + ASSERT_EQ(ec, std::errc()); + + // Replace the cached .git with an empty directory to simulate corruption / partial init. + // Drop readonly attributes first so std::filesystem::remove_all succeeds on Windows. + RemoveReadonlyFileAttributeFromDir(gitDir); + ec.clear(); + std::filesystem::remove_all(gitDir, ec); + ASSERT_EQ(ec, std::errc()) << "Failed to remove .git from cached repository: " << ec.message(); + ec.clear(); + std::filesystem::create_directory(gitDir, ec); + ASSERT_EQ(ec, std::errc()) << "Failed to recreate empty .git directory: " << ec.message(); + ASSERT_TRUE(std::filesystem::is_directory(gitDir)); + ASSERT_TRUE(std::filesystem::is_empty(gitDir)); + + // Add interruption error marker so resume path is taken and repository open is validated. + const std::string lfsErrorPath = ovms::FileSystem::appendSlash(basePath) + "lfs_error.txt"; + { + std::ofstream errorFile(lfsErrorPath); + ASSERT_TRUE(errorFile.is_open()) << "Failed to create interruption marker: " << lfsErrorPath; + errorFile << "simulated lfs error"; + } + ASSERT_TRUE(std::filesystem::exists(lfsErrorPath)); + + + // Pull will not silently succeed: handleExistingRepositoryWithoutOverwrite + // will surface the libgit2 open failure because there are interruption file markers present + this->ServerPullHfModel(modelName, downloadPath, task, EXIT_FAILURE); + + // Marker was pre-created and remains because repository open failed before resume cleanup. + EXPECT_TRUE(std::filesystem::exists(lfsErrorPath)); + // No work-in-progress marker should be created next to the model directory. const std::string lfsWipPath = ovms::libgit2::getLfsWipMarkerPath(basePath).string(); EXPECT_FALSE(std::filesystem::exists(lfsWipPath)); From 06367dc1f8a8100ecf0029babd3446302cb4b9de Mon Sep 17 00:00:00 2001 From: Rafal Sapala Date: Thu, 28 May 2026 17:23:27 +0200 Subject: [PATCH 08/18] Style --- src/test/pull_hf_model_test.cpp | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/test/pull_hf_model_test.cpp b/src/test/pull_hf_model_test.cpp index e290995ed0..8da211dce8 100644 --- a/src/test/pull_hf_model_test.cpp +++ b/src/test/pull_hf_model_test.cpp @@ -806,7 +806,6 @@ TEST_F(HfPullCache, EmptyGitDirLfsMarker) { const std::string lfsWipPath = ovms::libgit2::getLfsWipMarkerPath(basePath).string(); ASSERT_TRUE(std::filesystem::exists(lfsWipPath)); - // Pull will not silently succeed: handleExistingRepositoryWithoutOverwrite // will surface the libgit2 open failure because there are interruption file markers present this->ServerPullHfModel(modelName, downloadPath, task, EXIT_FAILURE); @@ -886,7 +885,6 @@ TEST_F(HfPullCache, EmptyGitDirErrorMarker) { } ASSERT_TRUE(std::filesystem::exists(lfsErrorPath)); - // Pull will not silently succeed: handleExistingRepositoryWithoutOverwrite // will surface the libgit2 open failure because there are interruption file markers present this->ServerPullHfModel(modelName, downloadPath, task, EXIT_FAILURE); From 8450339d5fc76360dc78f3bd999517562a657271 Mon Sep 17 00:00:00 2001 From: Rafal Sapala Date: Thu, 28 May 2026 17:35:07 +0200 Subject: [PATCH 09/18] Add lfs condition --- src/pull_module/libgit2.cpp | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/pull_module/libgit2.cpp b/src/pull_module/libgit2.cpp index 0d6a9ba367..9e076f1a09 100644 --- a/src/pull_module/libgit2.cpp +++ b/src/pull_module/libgit2.cpp @@ -1276,13 +1276,19 @@ Status resumeExistingRepository(git_repository* repo, } bool resumeCheckSecondCondition(const std::string& downloadPath, const ResumeCandidates& candidates) { + auto existingMatches = ovms::libgit2::findLfsLikeFiles(downloadPath, true); + // Use repository object only when interruption markers indicate a previous // pull likely failed and resume logic may be required. - if (!candidates.hasWipMarker && !candidates.hasLfsErrorFile) { + if (!candidates.hasWipMarker && !candidates.hasLfsErrorFile && existingMatches.empty()) { SPDLOG_DEBUG("Model pull operation found no interruption markers for this path: {}", downloadPath); SPDLOG_INFO("Path already exists on local filesystem. Skipping download to path: {}", downloadPath); return false; } + + if (!existingMatches.empty()) { + SPDLOG_DEBUG("Found {} LFS-like file(s) under path: {}. Enabling resume check.", existingMatches.size(), downloadPath); + } return true; } @@ -1318,6 +1324,7 @@ Status checkSufficientResumeConditions(const std::string& downloadPath, ResumeCa // Probe interruption markers once and reuse them later when building candidates. candidates.hasWipMarker = libgit2::hasLfsWipMarker(downloadPath); candidates.hasLfsErrorFile = libgit2::hasLfsErrorFile(downloadPath); + candidates.shouldResume = resumeCheckSecondCondition(downloadPath, candidates); return StatusCode::OK; } From 6764e1cee70f0f6ce2ac455bf14545504cc45d0b Mon Sep 17 00:00:00 2001 From: Rafal Sapala Date: Thu, 28 May 2026 17:37:09 +0200 Subject: [PATCH 10/18] Syle --- src/pull_module/libgit2.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pull_module/libgit2.cpp b/src/pull_module/libgit2.cpp index 9e076f1a09..25e5e464e7 100644 --- a/src/pull_module/libgit2.cpp +++ b/src/pull_module/libgit2.cpp @@ -1324,7 +1324,7 @@ Status checkSufficientResumeConditions(const std::string& downloadPath, ResumeCa // Probe interruption markers once and reuse them later when building candidates. candidates.hasWipMarker = libgit2::hasLfsWipMarker(downloadPath); candidates.hasLfsErrorFile = libgit2::hasLfsErrorFile(downloadPath); - + candidates.shouldResume = resumeCheckSecondCondition(downloadPath, candidates); return StatusCode::OK; } From 051faee7eab6f5b2915480930d10a15f5eafbed0 Mon Sep 17 00:00:00 2001 From: Rafal Sapala Date: Mon, 8 Jun 2026 13:06:43 +0200 Subject: [PATCH 11/18] Code review --- src/pull_module/libgit2.cpp | 23 ++++++++++++----------- src/test/libgit2_test.cpp | 1 - 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/pull_module/libgit2.cpp b/src/pull_module/libgit2.cpp index 25e5e464e7..a046c15769 100644 --- a/src/pull_module/libgit2.cpp +++ b/src/pull_module/libgit2.cpp @@ -1137,14 +1137,16 @@ struct ResumeCandidates { }; /** - * Builds resume candidate lists based on interruption markers and repository scan. + * Populates resume candidate lists based on interruption markers and repository scan. * * @param repo Pointer to the git repository object. * @param downloadPath Repository worktree root path. - * @return ResumeCandidates containing LFS and non-LFS recovery targets. + * @param candidates [in/out] Resume candidates to populate; hasWipMarker and hasLfsErrorFile + * must already be set by the caller. On return, lfsMatches, missingNonLfsMatches, + * and interruptionLikely are filled in. * @note Works on local repository metadata and filesystem; no network operations. */ -ResumeCandidates buildResumeCandidates(git_repository* repo, const std::string& downloadPath, ResumeCandidates candidates) { +void buildResumeCandidates(git_repository* repo, const std::string& downloadPath, ResumeCandidates& candidates) { // Checking if the download was partially finished for any files in repository, // including tracked LFS pointer blobs missing from the worktree after abrupt termination. candidates.lfsMatches = libgit2::findResumableLfsFiles(repo, downloadPath, candidates.hasWipMarker || candidates.hasLfsErrorFile); @@ -1153,7 +1155,6 @@ ResumeCandidates buildResumeCandidates(git_repository* repo, const std::string& } candidates.interruptionLikely = candidates.hasWipMarker || candidates.hasLfsErrorFile || !candidates.lfsMatches.empty(); - return candidates; } void printResumeCandidates(const ResumeCandidates& candidates) { @@ -1275,7 +1276,7 @@ Status resumeExistingRepository(git_repository* repo, return StatusCode::OK; } -bool resumeCheckSecondCondition(const std::string& downloadPath, const ResumeCandidates& candidates) { +bool hasResumableStateBasedOnFiles(const std::string& downloadPath, const ResumeCandidates& candidates) { auto existingMatches = ovms::libgit2::findLfsLikeFiles(downloadPath, true); // Use repository object only when interruption markers indicate a previous @@ -1292,7 +1293,7 @@ bool resumeCheckSecondCondition(const std::string& downloadPath, const ResumeCan return true; } -Status resumeCheckFirstCondition(const std::string& downloadPath, bool& gitEntryExists) { +Status checkGitEntryExists(const std::string& downloadPath, bool& gitEntryExists) { std::error_code ec; gitEntryExists = fs::exists(fs::path(downloadPath) / ".git", ec); if (ec) { @@ -1313,7 +1314,7 @@ Status checkSufficientResumeConditions(const std::string& downloadPath, ResumeCa candidates.shouldResume = false; bool gitEntryExists = false; - auto firstConditionStatus = resumeCheckFirstCondition(downloadPath, gitEntryExists); + auto firstConditionStatus = checkGitEntryExists(downloadPath, gitEntryExists); if (!firstConditionStatus.ok()) { return firstConditionStatus; } @@ -1325,11 +1326,11 @@ Status checkSufficientResumeConditions(const std::string& downloadPath, ResumeCa candidates.hasWipMarker = libgit2::hasLfsWipMarker(downloadPath); candidates.hasLfsErrorFile = libgit2::hasLfsErrorFile(downloadPath); - candidates.shouldResume = resumeCheckSecondCondition(downloadPath, candidates); + candidates.shouldResume = hasResumableStateBasedOnFiles(downloadPath, candidates); return StatusCode::OK; } -Status handleExistingRepositoryWithoutOverwrite(const std::string& downloadPath, +Status handleExistingRepository(const std::string& downloadPath, const std::function& checkRepositoryStatusFn) { // If the directory does not contain a .git entry, treat it as a user-provided model directory. // The user has copied model files in by hand; skip the pull and let model loading proceed @@ -1353,7 +1354,7 @@ Status handleExistingRepositoryWithoutOverwrite(const std::string& downloadPath, return mapRepositoryOpenFailureToStatus(repoGuard); } - candidates = buildResumeCandidates(repoGuard.get(), downloadPath, std::move(candidates)); + buildResumeCandidates(repoGuard.get(), downloadPath, candidates); if (!candidates.interruptionLikely) { SPDLOG_WARN("Interruption marker(s) were found but no resumable candidates were detected for path: {}", downloadPath); SPDLOG_INFO("Path already exists on local filesystem. Skipping download to path: {}", downloadPath); @@ -1510,7 +1511,7 @@ Status HfDownloader::downloadModel() { // Repository exists and we do not want to overwrite if (std::filesystem::is_directory(this->downloadPath) && !this->overwriteModels) { - return handleExistingRepositoryWithoutOverwrite(this->downloadPath, checkRepositoryStatusFn); + return handleExistingRepository(this->downloadPath, checkRepositoryStatusFn); } auto status = IModelDownloader::checkIfOverwriteAndRemove(); diff --git a/src/test/libgit2_test.cpp b/src/test/libgit2_test.cpp index 7fc45de3c0..3411a5bbfe 100644 --- a/src/test/libgit2_test.cpp +++ b/src/test/libgit2_test.cpp @@ -862,7 +862,6 @@ TEST(LibGit2LfsWipMarker, MarkersForDifferentRepositoriesAreIndependent) { class Libgt2InitGuardTest : public ::testing::Test { protected: - TempDir td; ovms::Libgit2Options defaultOpts; }; From 99bf76144dd7c1232fa1adec3c42678f692d7ee4 Mon Sep 17 00:00:00 2001 From: Rafal Sapala Date: Tue, 9 Jun 2026 14:04:26 +0200 Subject: [PATCH 12/18] Add env to set path searches --- src/pull_module/libgit2.cpp | 39 +++++++++++++++++++++++-------------- src/test/libgit2_test.cpp | 26 +++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 15 deletions(-) diff --git a/src/pull_module/libgit2.cpp b/src/pull_module/libgit2.cpp index a046c15769..f73800db11 100644 --- a/src/pull_module/libgit2.cpp +++ b/src/pull_module/libgit2.cpp @@ -260,22 +260,31 @@ Libgt2InitGuard::Libgt2InitGuard(const Libgit2Options& opts) { // container vs. a non-root serving user). this->status = git_libgit2_opts(GIT_OPT_SET_OWNER_VALIDATION, 0); IF_ERROR_SET_MSG_AND_RETURN(); - // Redirect all git config search paths to an empty string so libgit2 never reads - // host-level git configuration (~/.gitconfig, /etc/gitconfig, etc.). Without this, - // a host gitconfig that sets credential.helper, http.proxy, lfs.*, or safe.directory - // can silently override OVMS's intended proxy/token settings and cause spurious - // failures or credential leaks in multi-tenant environments. - // On Windows, GIT_CONFIG_LEVEL_PROGRAMDATA covers %PROGRAMDATA%\Git\config which is - // a machine-wide config that libgit2 reads before SYSTEM; it must be cleared as well. - this->status = git_libgit2_opts(GIT_OPT_SET_SEARCH_PATH, GIT_CONFIG_LEVEL_SYSTEM, ""); - IF_ERROR_SET_MSG_AND_RETURN(); - this->status = git_libgit2_opts(GIT_OPT_SET_SEARCH_PATH, GIT_CONFIG_LEVEL_XDG, ""); - IF_ERROR_SET_MSG_AND_RETURN(); - this->status = git_libgit2_opts(GIT_OPT_SET_SEARCH_PATH, GIT_CONFIG_LEVEL_GLOBAL, ""); - IF_ERROR_SET_MSG_AND_RETURN(); + const char* enableSearchPathEnv = std::getenv("OVMS_GIT_ENABLE_SEARCH_PATH"); + const bool enableGitSearchPath = + (enableSearchPathEnv != nullptr) && (std::string(enableSearchPathEnv) == "1"); + // By default, redirect all git config search paths to an empty string so libgit2 + // never reads host-level git configuration (~/.gitconfig, /etc/gitconfig, etc.). + // Without this, a host gitconfig that sets credential.helper, http.proxy, lfs.*, or + // safe.directory can silently override OVMS's intended proxy/token settings and cause + // spurious failures or credential leaks in multi-tenant environments. + // To preserve historic behaviour or troubleshoot host gitconfig interactions, set + // OVMS_GIT_ENABLE_SEARCH_PATH=1 and skip this isolation step. + if (!enableGitSearchPath) { + this->status = git_libgit2_opts(GIT_OPT_SET_SEARCH_PATH, GIT_CONFIG_LEVEL_SYSTEM, ""); + IF_ERROR_SET_MSG_AND_RETURN(); + this->status = git_libgit2_opts(GIT_OPT_SET_SEARCH_PATH, GIT_CONFIG_LEVEL_XDG, ""); + IF_ERROR_SET_MSG_AND_RETURN(); + this->status = git_libgit2_opts(GIT_OPT_SET_SEARCH_PATH, GIT_CONFIG_LEVEL_GLOBAL, ""); + IF_ERROR_SET_MSG_AND_RETURN(); + } #if defined(_WIN32) - this->status = git_libgit2_opts(GIT_OPT_SET_SEARCH_PATH, GIT_CONFIG_LEVEL_PROGRAMDATA, ""); - IF_ERROR_SET_MSG_AND_RETURN(); + // On Windows, GIT_CONFIG_LEVEL_PROGRAMDATA covers %PROGRAMDATA%\Git\config. + // Keep it isolated unless explicit opt-in via OVMS_GIT_ENABLE_SEARCH_PATH=1. + if (!enableGitSearchPath) { + this->status = git_libgit2_opts(GIT_OPT_SET_SEARCH_PATH, GIT_CONFIG_LEVEL_PROGRAMDATA, ""); + IF_ERROR_SET_MSG_AND_RETURN(); + } #endif // Skip .keep file existence checks when reading packfiles. libgit2 performs one // stat() per pack per operation to honour .keep files (which prevent gc from diff --git a/src/test/libgit2_test.cpp b/src/test/libgit2_test.cpp index 3411a5bbfe..54d8edba49 100644 --- a/src/test/libgit2_test.cpp +++ b/src/test/libgit2_test.cpp @@ -24,6 +24,7 @@ #include #include "src/pull_module/libgit2.hpp" +#include "src/utils/env_guard.hpp" #include "environment.hpp" #include "test_utils.hpp" @@ -887,6 +888,9 @@ TEST_F(Libgt2InitGuardTest, OwnerValidationIsDisabled) { // The guard must clear the config search paths for all host-level config // scopes so that no host gitconfig can interfere with OVMS's settings. TEST_F(Libgt2InitGuardTest, ConfigSearchPathsAreCleared) { + EnvGuard envGuard; + envGuard.unset("OVMS_GIT_ENABLE_SEARCH_PATH"); + ovms::Libgt2InitGuard guard(defaultOpts); ASSERT_GE(guard.status, 0); @@ -905,6 +909,28 @@ TEST_F(Libgt2InitGuardTest, ConfigSearchPathsAreCleared) { } } +TEST_F(Libgt2InitGuardTest, ConfigSearchPathsRemainWhenEnabledByEnv) { + EnvGuard envGuard; + envGuard.set("OVMS_GIT_ENABLE_SEARCH_PATH", "1"); + + ovms::Libgt2InitGuard guard(defaultOpts); + ASSERT_GE(guard.status, 0); + + static const int levels[] = { + GIT_CONFIG_LEVEL_SYSTEM, + GIT_CONFIG_LEVEL_XDG, + GIT_CONFIG_LEVEL_GLOBAL, + }; + for (int level : levels) { + git_buf buf = GIT_BUF_INIT; + int rc = git_libgit2_opts(GIT_OPT_GET_SEARCH_PATH, level, &buf); + EXPECT_EQ(rc, 0) << "GIT_OPT_GET_SEARCH_PATH failed for config level " << level; + const char* path = (buf.ptr != nullptr) ? buf.ptr : ""; + EXPECT_STRNE(path, "") << "Config search path unexpectedly cleared for level " << level; + git_buf_dispose(&buf); + } +} + #if defined(_WIN32) // On Windows, libgit2 also supports GIT_CONFIG_LEVEL_PROGRAMDATA which maps to // %PROGRAMDATA%\Git\config — a machine-wide config that must also be suppressed. From 03bdd709af026daa404d41670b7742c8f2056358 Mon Sep 17 00:00:00 2001 From: Rafal Sapala Date: Tue, 9 Jun 2026 14:08:16 +0200 Subject: [PATCH 13/18] Fix --- src/test/libgit2_test.cpp | 47 ++++++++++++++++++++++++++++----------- 1 file changed, 34 insertions(+), 13 deletions(-) diff --git a/src/test/libgit2_test.cpp b/src/test/libgit2_test.cpp index 54d8edba49..082eb70405 100644 --- a/src/test/libgit2_test.cpp +++ b/src/test/libgit2_test.cpp @@ -913,22 +913,43 @@ TEST_F(Libgt2InitGuardTest, ConfigSearchPathsRemainWhenEnabledByEnv) { EnvGuard envGuard; envGuard.set("OVMS_GIT_ENABLE_SEARCH_PATH", "1"); + std::array baselinePaths; + ASSERT_EQ(git_libgit2_init(), 1); + { + git_buf systemBuf = GIT_BUF_INIT; + ASSERT_EQ(git_libgit2_opts(GIT_OPT_GET_SEARCH_PATH, GIT_CONFIG_LEVEL_SYSTEM, &systemBuf), 0); + baselinePaths[0] = (systemBuf.ptr != nullptr) ? systemBuf.ptr : ""; + git_buf_dispose(&systemBuf); + + git_buf xdgBuf = GIT_BUF_INIT; + ASSERT_EQ(git_libgit2_opts(GIT_OPT_GET_SEARCH_PATH, GIT_CONFIG_LEVEL_XDG, &xdgBuf), 0); + baselinePaths[1] = (xdgBuf.ptr != nullptr) ? xdgBuf.ptr : ""; + git_buf_dispose(&xdgBuf); + + git_buf globalBuf = GIT_BUF_INIT; + ASSERT_EQ(git_libgit2_opts(GIT_OPT_GET_SEARCH_PATH, GIT_CONFIG_LEVEL_GLOBAL, &globalBuf), 0); + baselinePaths[2] = (globalBuf.ptr != nullptr) ? globalBuf.ptr : ""; + git_buf_dispose(&globalBuf); + } + git_libgit2_shutdown(); + ovms::Libgt2InitGuard guard(defaultOpts); ASSERT_GE(guard.status, 0); - static const int levels[] = { - GIT_CONFIG_LEVEL_SYSTEM, - GIT_CONFIG_LEVEL_XDG, - GIT_CONFIG_LEVEL_GLOBAL, - }; - for (int level : levels) { - git_buf buf = GIT_BUF_INIT; - int rc = git_libgit2_opts(GIT_OPT_GET_SEARCH_PATH, level, &buf); - EXPECT_EQ(rc, 0) << "GIT_OPT_GET_SEARCH_PATH failed for config level " << level; - const char* path = (buf.ptr != nullptr) ? buf.ptr : ""; - EXPECT_STRNE(path, "") << "Config search path unexpectedly cleared for level " << level; - git_buf_dispose(&buf); - } + git_buf systemBuf = GIT_BUF_INIT; + ASSERT_EQ(git_libgit2_opts(GIT_OPT_GET_SEARCH_PATH, GIT_CONFIG_LEVEL_SYSTEM, &systemBuf), 0); + EXPECT_EQ((systemBuf.ptr != nullptr) ? systemBuf.ptr : "", baselinePaths[0]); + git_buf_dispose(&systemBuf); + + git_buf xdgBuf = GIT_BUF_INIT; + ASSERT_EQ(git_libgit2_opts(GIT_OPT_GET_SEARCH_PATH, GIT_CONFIG_LEVEL_XDG, &xdgBuf), 0); + EXPECT_EQ((xdgBuf.ptr != nullptr) ? xdgBuf.ptr : "", baselinePaths[1]); + git_buf_dispose(&xdgBuf); + + git_buf globalBuf = GIT_BUF_INIT; + ASSERT_EQ(git_libgit2_opts(GIT_OPT_GET_SEARCH_PATH, GIT_CONFIG_LEVEL_GLOBAL, &globalBuf), 0); + EXPECT_EQ((globalBuf.ptr != nullptr) ? globalBuf.ptr : "", baselinePaths[2]); + git_buf_dispose(&globalBuf); } #if defined(_WIN32) From a6d02628bd8307a2524e163c735b573d8349450e Mon Sep 17 00:00:00 2001 From: Rafal Sapala Date: Tue, 9 Jun 2026 14:16:15 +0200 Subject: [PATCH 14/18] Update doc and name --- docs/parameters.md | 1 + src/pull_module/libgit2.cpp | 6 +++--- src/test/libgit2_test.cpp | 4 ++-- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/parameters.md b/docs/parameters.md index 2386bfaab3..173004bbc2 100644 --- a/docs/parameters.md +++ b/docs/parameters.md @@ -122,6 +122,7 @@ There are also additional environment variables that may change the behavior of | `GIT_OPT_SET_SERVER_CONNECT_TIMEOUT`| `int` | Timeout to attempt connections to a remote server. Default value 4000 ms. | | `GIT_OPT_SET_SERVER_TIMEOUT` | `int` | Timeout for reading from and writing to a remote server. Default value 4000 ms. | | `GIT_OPT_SET_SSL_CERT_LOCATIONS` | `string`| Path to check for ssl certificates. | +| `GIT_OPT_SET_ENABLE_SEARCH_PATHS`| `int` | When set to 1, the pull functionality reads host-level git configuration locations like ~/.gitconfig. Default value 0. | Task specific parameters for different tasks (text generation/image generation/embeddings/rerank) are listed below: diff --git a/src/pull_module/libgit2.cpp b/src/pull_module/libgit2.cpp index f73800db11..d3e6f10e0c 100644 --- a/src/pull_module/libgit2.cpp +++ b/src/pull_module/libgit2.cpp @@ -260,7 +260,7 @@ Libgt2InitGuard::Libgt2InitGuard(const Libgit2Options& opts) { // container vs. a non-root serving user). this->status = git_libgit2_opts(GIT_OPT_SET_OWNER_VALIDATION, 0); IF_ERROR_SET_MSG_AND_RETURN(); - const char* enableSearchPathEnv = std::getenv("OVMS_GIT_ENABLE_SEARCH_PATH"); + const char* enableSearchPathEnv = std::getenv("GIT_OPT_SET_ENABLE_SEARCH_PATHS"); const bool enableGitSearchPath = (enableSearchPathEnv != nullptr) && (std::string(enableSearchPathEnv) == "1"); // By default, redirect all git config search paths to an empty string so libgit2 @@ -269,7 +269,7 @@ Libgt2InitGuard::Libgt2InitGuard(const Libgit2Options& opts) { // safe.directory can silently override OVMS's intended proxy/token settings and cause // spurious failures or credential leaks in multi-tenant environments. // To preserve historic behaviour or troubleshoot host gitconfig interactions, set - // OVMS_GIT_ENABLE_SEARCH_PATH=1 and skip this isolation step. + // GIT_OPT_SET_ENABLE_SEARCH_PATHS=1 and skip this isolation step. if (!enableGitSearchPath) { this->status = git_libgit2_opts(GIT_OPT_SET_SEARCH_PATH, GIT_CONFIG_LEVEL_SYSTEM, ""); IF_ERROR_SET_MSG_AND_RETURN(); @@ -280,7 +280,7 @@ Libgt2InitGuard::Libgt2InitGuard(const Libgit2Options& opts) { } #if defined(_WIN32) // On Windows, GIT_CONFIG_LEVEL_PROGRAMDATA covers %PROGRAMDATA%\Git\config. - // Keep it isolated unless explicit opt-in via OVMS_GIT_ENABLE_SEARCH_PATH=1. + // Keep it isolated unless explicit opt-in via GIT_OPT_SET_ENABLE_SEARCH_PATHS=1. if (!enableGitSearchPath) { this->status = git_libgit2_opts(GIT_OPT_SET_SEARCH_PATH, GIT_CONFIG_LEVEL_PROGRAMDATA, ""); IF_ERROR_SET_MSG_AND_RETURN(); diff --git a/src/test/libgit2_test.cpp b/src/test/libgit2_test.cpp index 082eb70405..e83aebb693 100644 --- a/src/test/libgit2_test.cpp +++ b/src/test/libgit2_test.cpp @@ -889,7 +889,7 @@ TEST_F(Libgt2InitGuardTest, OwnerValidationIsDisabled) { // scopes so that no host gitconfig can interfere with OVMS's settings. TEST_F(Libgt2InitGuardTest, ConfigSearchPathsAreCleared) { EnvGuard envGuard; - envGuard.unset("OVMS_GIT_ENABLE_SEARCH_PATH"); + envGuard.unset("GIT_OPT_SET_ENABLE_SEARCH_PATHS"); ovms::Libgt2InitGuard guard(defaultOpts); ASSERT_GE(guard.status, 0); @@ -911,7 +911,7 @@ TEST_F(Libgt2InitGuardTest, ConfigSearchPathsAreCleared) { TEST_F(Libgt2InitGuardTest, ConfigSearchPathsRemainWhenEnabledByEnv) { EnvGuard envGuard; - envGuard.set("OVMS_GIT_ENABLE_SEARCH_PATH", "1"); + envGuard.set("GIT_OPT_SET_ENABLE_SEARCH_PATHS", "1"); std::array baselinePaths; ASSERT_EQ(git_libgit2_init(), 1); From a9a986737bef5944f97b5ae485fb2ea1afedd64a Mon Sep 17 00:00:00 2001 From: Rafal Sapala Date: Wed, 10 Jun 2026 12:55:18 +0200 Subject: [PATCH 15/18] Fix tests --- src/pull_module/libgit2.cpp | 13 ++-- src/test/libgit2_test.cpp | 141 +++++++++++++++++------------------- 2 files changed, 71 insertions(+), 83 deletions(-) diff --git a/src/pull_module/libgit2.cpp b/src/pull_module/libgit2.cpp index d3e6f10e0c..426d19fa1a 100644 --- a/src/pull_module/libgit2.cpp +++ b/src/pull_module/libgit2.cpp @@ -41,6 +41,7 @@ #include "cmd_exec.hpp" #include "src/filesystem/filesystem.hpp" #include "src/filesystem/localfilesystem.hpp" +#include "src/utils/env_guard.hpp" #include "../logging.hpp" #include "../shutdown_state.hpp" #include "../stringutils.hpp" @@ -260,9 +261,7 @@ Libgt2InitGuard::Libgt2InitGuard(const Libgit2Options& opts) { // container vs. a non-root serving user). this->status = git_libgit2_opts(GIT_OPT_SET_OWNER_VALIDATION, 0); IF_ERROR_SET_MSG_AND_RETURN(); - const char* enableSearchPathEnv = std::getenv("GIT_OPT_SET_ENABLE_SEARCH_PATHS"); - const bool enableGitSearchPath = - (enableSearchPathEnv != nullptr) && (std::string(enableSearchPathEnv) == "1"); + const bool enableGitSearchPath = (GetEnvVar("GIT_OPT_SET_ENABLE_SEARCH_PATHS") == "1"); // By default, redirect all git config search paths to an empty string so libgit2 // never reads host-level git configuration (~/.gitconfig, /etc/gitconfig, etc.). // Without this, a host gitconfig that sets credential.helper, http.proxy, lfs.*, or @@ -277,15 +276,13 @@ Libgt2InitGuard::Libgt2InitGuard(const Libgit2Options& opts) { IF_ERROR_SET_MSG_AND_RETURN(); this->status = git_libgit2_opts(GIT_OPT_SET_SEARCH_PATH, GIT_CONFIG_LEVEL_GLOBAL, ""); IF_ERROR_SET_MSG_AND_RETURN(); - } #if defined(_WIN32) - // On Windows, GIT_CONFIG_LEVEL_PROGRAMDATA covers %PROGRAMDATA%\Git\config. - // Keep it isolated unless explicit opt-in via GIT_OPT_SET_ENABLE_SEARCH_PATHS=1. - if (!enableGitSearchPath) { + // On Windows, GIT_CONFIG_LEVEL_PROGRAMDATA covers %PROGRAMDATA%\Git\config. + // Keep it isolated unless explicit opt-in via GIT_OPT_SET_ENABLE_SEARCH_PATHS=1. this->status = git_libgit2_opts(GIT_OPT_SET_SEARCH_PATH, GIT_CONFIG_LEVEL_PROGRAMDATA, ""); IF_ERROR_SET_MSG_AND_RETURN(); - } #endif + } // Skip .keep file existence checks when reading packfiles. libgit2 performs one // stat() per pack per operation to honour .keep files (which prevent gc from // collecting referenced packs). In an OVMS deployment the model directory is diff --git a/src/test/libgit2_test.cpp b/src/test/libgit2_test.cpp index e83aebb693..f9b6c5a756 100644 --- a/src/test/libgit2_test.cpp +++ b/src/test/libgit2_test.cpp @@ -864,6 +864,66 @@ TEST(LibGit2LfsWipMarker, MarkersForDifferentRepositoriesAreIndependent) { class Libgt2InitGuardTest : public ::testing::Test { protected: ovms::Libgit2Options defaultOpts; + + static std::vector getCurrentSearchPaths() { + std::vector paths; + paths.reserve(4); + + git_buf systemBuf = GIT_BUF_INIT; + EXPECT_EQ(git_libgit2_opts(GIT_OPT_GET_SEARCH_PATH, GIT_CONFIG_LEVEL_SYSTEM, &systemBuf), 0); + paths.push_back((systemBuf.ptr != nullptr) ? systemBuf.ptr : ""); + git_buf_dispose(&systemBuf); + + git_buf xdgBuf = GIT_BUF_INIT; + EXPECT_EQ(git_libgit2_opts(GIT_OPT_GET_SEARCH_PATH, GIT_CONFIG_LEVEL_XDG, &xdgBuf), 0); + paths.push_back((xdgBuf.ptr != nullptr) ? xdgBuf.ptr : ""); + git_buf_dispose(&xdgBuf); + + git_buf globalBuf = GIT_BUF_INIT; + EXPECT_EQ(git_libgit2_opts(GIT_OPT_GET_SEARCH_PATH, GIT_CONFIG_LEVEL_GLOBAL, &globalBuf), 0); + paths.push_back((globalBuf.ptr != nullptr) ? globalBuf.ptr : ""); + git_buf_dispose(&globalBuf); + +#if defined(_WIN32) + git_buf programdataBuf = GIT_BUF_INIT; + EXPECT_EQ(git_libgit2_opts(GIT_OPT_GET_SEARCH_PATH, GIT_CONFIG_LEVEL_PROGRAMDATA, &programdataBuf), 0); + paths.push_back((programdataBuf.ptr != nullptr) ? programdataBuf.ptr : ""); + git_buf_dispose(&programdataBuf); +#endif + + return paths; + } + + void expectSearchPathBehavior(const std::optional& envValue, bool shouldRemainEnabled) { + EnvGuard envGuard; + if (envValue.has_value()) { + envGuard.set("GIT_OPT_SET_ENABLE_SEARCH_PATHS", envValue.value()); + } else { + envGuard.unset("GIT_OPT_SET_ENABLE_SEARCH_PATHS"); + } + + ASSERT_EQ(git_libgit2_init(), 1); + const auto baselinePaths = getCurrentSearchPaths(); + git_libgit2_shutdown(); + + ovms::Libgt2InitGuard guard(defaultOpts); + ASSERT_GE(guard.status, 0); + + const auto currentPaths = getCurrentSearchPaths(); + + if (shouldRemainEnabled) { + EXPECT_EQ(currentPaths, baselinePaths); + } else { + EXPECT_EQ(currentPaths.size(), baselinePaths.size()); + EXPECT_EQ(currentPaths[0], ""); + EXPECT_EQ(currentPaths[1], ""); + EXPECT_EQ(currentPaths[2], ""); +#if defined(_WIN32) + ASSERT_EQ(currentPaths.size(), 4u); + EXPECT_EQ(currentPaths[3], ""); +#endif + } + } }; TEST_F(Libgt2InitGuardTest, ConstructionSucceeds) { @@ -887,83 +947,14 @@ TEST_F(Libgt2InitGuardTest, OwnerValidationIsDisabled) { // The guard must clear the config search paths for all host-level config // scopes so that no host gitconfig can interfere with OVMS's settings. -TEST_F(Libgt2InitGuardTest, ConfigSearchPathsAreCleared) { - EnvGuard envGuard; - envGuard.unset("GIT_OPT_SET_ENABLE_SEARCH_PATHS"); - - ovms::Libgt2InitGuard guard(defaultOpts); - ASSERT_GE(guard.status, 0); - - static const int levels[] = { - GIT_CONFIG_LEVEL_SYSTEM, - GIT_CONFIG_LEVEL_XDG, - GIT_CONFIG_LEVEL_GLOBAL, - }; - for (int level : levels) { - git_buf buf = GIT_BUF_INIT; - int rc = git_libgit2_opts(GIT_OPT_GET_SEARCH_PATH, level, &buf); - EXPECT_EQ(rc, 0) << "GIT_OPT_GET_SEARCH_PATH failed for config level " << level; - const char* path = (buf.ptr != nullptr) ? buf.ptr : ""; - EXPECT_STREQ(path, "") << "Config search path not cleared for level " << level; - git_buf_dispose(&buf); - } +TEST_F(Libgt2InitGuardTest, ConfigSearchPathsRemainWhenEnvIsOne) { + expectSearchPathBehavior("1", true); } -TEST_F(Libgt2InitGuardTest, ConfigSearchPathsRemainWhenEnabledByEnv) { - EnvGuard envGuard; - envGuard.set("GIT_OPT_SET_ENABLE_SEARCH_PATHS", "1"); - - std::array baselinePaths; - ASSERT_EQ(git_libgit2_init(), 1); - { - git_buf systemBuf = GIT_BUF_INIT; - ASSERT_EQ(git_libgit2_opts(GIT_OPT_GET_SEARCH_PATH, GIT_CONFIG_LEVEL_SYSTEM, &systemBuf), 0); - baselinePaths[0] = (systemBuf.ptr != nullptr) ? systemBuf.ptr : ""; - git_buf_dispose(&systemBuf); - - git_buf xdgBuf = GIT_BUF_INIT; - ASSERT_EQ(git_libgit2_opts(GIT_OPT_GET_SEARCH_PATH, GIT_CONFIG_LEVEL_XDG, &xdgBuf), 0); - baselinePaths[1] = (xdgBuf.ptr != nullptr) ? xdgBuf.ptr : ""; - git_buf_dispose(&xdgBuf); - - git_buf globalBuf = GIT_BUF_INIT; - ASSERT_EQ(git_libgit2_opts(GIT_OPT_GET_SEARCH_PATH, GIT_CONFIG_LEVEL_GLOBAL, &globalBuf), 0); - baselinePaths[2] = (globalBuf.ptr != nullptr) ? globalBuf.ptr : ""; - git_buf_dispose(&globalBuf); - } - git_libgit2_shutdown(); - - ovms::Libgt2InitGuard guard(defaultOpts); - ASSERT_GE(guard.status, 0); - - git_buf systemBuf = GIT_BUF_INIT; - ASSERT_EQ(git_libgit2_opts(GIT_OPT_GET_SEARCH_PATH, GIT_CONFIG_LEVEL_SYSTEM, &systemBuf), 0); - EXPECT_EQ((systemBuf.ptr != nullptr) ? systemBuf.ptr : "", baselinePaths[0]); - git_buf_dispose(&systemBuf); - - git_buf xdgBuf = GIT_BUF_INIT; - ASSERT_EQ(git_libgit2_opts(GIT_OPT_GET_SEARCH_PATH, GIT_CONFIG_LEVEL_XDG, &xdgBuf), 0); - EXPECT_EQ((xdgBuf.ptr != nullptr) ? xdgBuf.ptr : "", baselinePaths[1]); - git_buf_dispose(&xdgBuf); - - git_buf globalBuf = GIT_BUF_INIT; - ASSERT_EQ(git_libgit2_opts(GIT_OPT_GET_SEARCH_PATH, GIT_CONFIG_LEVEL_GLOBAL, &globalBuf), 0); - EXPECT_EQ((globalBuf.ptr != nullptr) ? globalBuf.ptr : "", baselinePaths[2]); - git_buf_dispose(&globalBuf); +TEST_F(Libgt2InitGuardTest, ConfigSearchPathsAreClearedWhenEnvIsZero) { + expectSearchPathBehavior("0", false); } -#if defined(_WIN32) -// On Windows, libgit2 also supports GIT_CONFIG_LEVEL_PROGRAMDATA which maps to -// %PROGRAMDATA%\Git\config — a machine-wide config that must also be suppressed. -TEST_F(Libgt2InitGuardTest, ConfigSearchPathProgramdataClearedOnWindows) { - ovms::Libgt2InitGuard guard(defaultOpts); - ASSERT_GE(guard.status, 0); - - git_buf buf = GIT_BUF_INIT; - int rc = git_libgit2_opts(GIT_OPT_GET_SEARCH_PATH, GIT_CONFIG_LEVEL_PROGRAMDATA, &buf); - EXPECT_EQ(rc, 0); - const char* path = (buf.ptr != nullptr) ? buf.ptr : ""; - EXPECT_STREQ(path, ""); - git_buf_dispose(&buf); +TEST_F(Libgt2InitGuardTest, ConfigSearchPathsAreClearedWhenEnvIsUnset) { + expectSearchPathBehavior(std::nullopt, false); } -#endif From a9e451cee37a3451aa4dedeae7303baa3b303b00 Mon Sep 17 00:00:00 2001 From: Rafal Sapala Date: Wed, 10 Jun 2026 17:24:30 +0200 Subject: [PATCH 16/18] Update descriptions --- src/test/pull_hf_model_test.cpp | 50 ++++++++++++++++----------------- 1 file changed, 24 insertions(+), 26 deletions(-) diff --git a/src/test/pull_hf_model_test.cpp b/src/test/pull_hf_model_test.cpp index 993499dcc9..0196e893ad 100644 --- a/src/test/pull_hf_model_test.cpp +++ b/src/test/pull_hf_model_test.cpp @@ -1086,14 +1086,12 @@ TEST_F(HfPullCache, PullNonGit) { EXPECT_FALSE(std::filesystem::exists(gitDir)); } -// PullAgainstDirectoryWithEmptyDotGitSuccedsWithoutMarkers +// PullAgainstDirectoryWithEmptyDotGitSucceedsWithoutMarkers // // Companion to HfPullCache.PullNonGit. Verifies that when .git IS present but is -// empty (a corrupt / partially-initialized repository) handleExistingRepositoryWithoutOverwrite() -// does NOT silently succeed: the .git probe passes, GitRepositoryGuard then fails to open -// the repository and the real error is propagated via mapRepositoryOpenFailureToStatus() -// so the operator can act (re-clone, fix permissions, --overwrite_models, ...). -// Without file markers it will not check the git repository. +// empty (a corrupt / partially-initialized repository) and no interruption markers +// exist, handleExistingRepositoryWithoutOverwrite() skips the git repository check +// and returns success, leaving existing files untouched. TEST_F(HfPullCache, PullEmptyGitDir) { std::string basePath = modelBasePath; std::string tokenizerPath = openvinoTokenizerBinPath; @@ -1126,8 +1124,8 @@ TEST_F(HfPullCache, PullEmptyGitDir) { ASSERT_TRUE(std::filesystem::is_directory(gitDir)); ASSERT_TRUE(std::filesystem::is_empty(gitDir)); - // Pull will silently succeed: handleExistingRepositoryWithoutOverwrite should - // will not surface the libgit2 open failure because there are not interruption file markers present + // Pull will silently succeed: handleExistingRepositoryWithoutOverwrite will + // not surface the libgit2 open failure because there are no interruption file markers present this->ServerPullHfModel(modelName, downloadPath, task, EXIT_SUCCESS); // No work-in-progress marker should be created next to the model directory. @@ -1152,14 +1150,14 @@ TEST_F(HfPullCache, PullEmptyGitDir) { EXPECT_TRUE(std::filesystem::is_empty(gitDir)); } -// PullAgainstDirectoryWithEmptyDotGitSuccedsWithMarkers +// PullAgainstDirectoryWithEmptyDotGitFailsWithLfsWipMarker // // Companion to HfPullCache.PullNonGit. Verifies that when .git IS present but is -// empty (a corrupt / partially-initialized repository) handleExistingRepositoryWithoutOverwrite() -// does NOT silently succeed: the .git probe passes, GitRepositoryGuard then fails to open -// the repository and the real error is propagated via mapRepositoryOpenFailureToStatus() -// so the operator can act (re-clone, fix permissions, --overwrite_models, ...). -// With file markers it will check the git repository and fail. +// empty (a corrupt / partially-initialized repository) and an LFS WIP marker exists, +// handleExistingRepositoryWithoutOverwrite() does NOT silently succeed: the .git probe +// passes, GitRepositoryGuard then fails to open the repository and the real error is +// propagated via mapRepositoryOpenFailureToStatus() so the operator can act +// (re-clone, fix permissions, --overwrite_models, ...). TEST_F(HfPullCache, EmptyGitDirLfsMarker) { std::string modelName = "OpenVINO/Phi-3-mini-FastDraft-50M-int8-ov"; std::string downloadPath = ovms::FileSystem::joinPath({this->directoryPath, "repository"}); @@ -1202,8 +1200,8 @@ TEST_F(HfPullCache, EmptyGitDirLfsMarker) { const std::string lfsWipPath = ovms::libgit2::getLfsWipMarkerPath(basePath).string(); ASSERT_TRUE(std::filesystem::exists(lfsWipPath)); - // Pull will not silently succeed: handleExistingRepositoryWithoutOverwrite - // will surface the libgit2 open failure because there are interruption file markers present + // Pull will not silently succeed: handleExistingRepositoryWithoutOverwrite will + // surface the libgit2 open failure because there are interruption file markers present this->ServerPullHfModel(modelName, downloadPath, task, EXIT_FAILURE); // Marker was pre-created and remains because resume did not complete successfully. @@ -1227,14 +1225,14 @@ TEST_F(HfPullCache, EmptyGitDirLfsMarker) { EXPECT_TRUE(std::filesystem::is_empty(gitDir)); } -// PullAgainstDirectoryWithEmptyDotGitSuccedsWithMarkers +// PullAgainstDirectoryWithEmptyDotGitFailsWithErrorMarker // // Companion to HfPullCache.PullNonGit. Verifies that when .git IS present but is -// empty (a corrupt / partially-initialized repository) handleExistingRepositoryWithoutOverwrite() -// does NOT silently succeed: the .git probe passes, GitRepositoryGuard then fails to open -// the repository and the real error is propagated via mapRepositoryOpenFailureToStatus() -// so the operator can act (re-clone, fix permissions, --overwrite_models, ...). -// With file markers it will check the git repository and fail. +// empty (a corrupt / partially-initialized repository) and an lfs_error.txt marker +// exists, handleExistingRepositoryWithoutOverwrite() does NOT silently succeed: the +// .git probe passes, GitRepositoryGuard then fails to open the repository and the +// real error is propagated via mapRepositoryOpenFailureToStatus() so the operator +// can act (re-clone, fix permissions, --overwrite_models, ...). TEST_F(HfPullCache, EmptyGitDirErrorMarker) { std::string modelName = "OpenVINO/Phi-3-mini-FastDraft-50M-int8-ov"; std::string downloadPath = ovms::FileSystem::joinPath({this->directoryPath, "repository"}); @@ -1281,8 +1279,8 @@ TEST_F(HfPullCache, EmptyGitDirErrorMarker) { } ASSERT_TRUE(std::filesystem::exists(lfsErrorPath)); - // Pull will not silently succeed: handleExistingRepositoryWithoutOverwrite - // will surface the libgit2 open failure because there are interruption file markers present + // Pull will not silently succeed: handleExistingRepositoryWithoutOverwrite will + // surface the libgit2 open failure because there are interruption file markers present this->ServerPullHfModel(modelName, downloadPath, task, EXIT_FAILURE); // Marker was pre-created and remains because repository open failed before resume cleanup. @@ -1967,7 +1965,7 @@ class HfDownloaderHfEnvTest : public ::testing::Test { EnvGuard guard; }; -TEST(Libgt2InitGuardLfsFilterTest, LfsFilterCaptureDefaultResumeOptions) { +TEST(Libgt2InitGuardTest, LfsFilterCaptureDefaultResumeOptions) { // Need new process beacase we use INIT_ONCE in libgit2 lfs filter for env variables and once they are set they are set for the whole process lifetime EXPECT_EXIT({ // Act: capture stdout during object construction @@ -1987,7 +1985,7 @@ TEST(Libgt2InitGuardLfsFilterTest, LfsFilterCaptureDefaultResumeOptions) { exit(0); }, ::testing::ExitedWithCode(0), ""); } -TEST(Libgt2InitGuardLfsFilterTest, LfsFilterCaptureNonDefaultResumeOptions) { +TEST(Libgt2InitGuardTest, LfsFilterCaptureNonDefaultResumeOptions) { // Need new process beacase we use INIT_ONCE in libgit2 lfs filter for env variables and once they are set they are set for the whole process lifetime EXPECT_EXIT({ EnvGuard guard; From 6620102f0a02eb2a6184154c11ed57d3a2f8c8f0 Mon Sep 17 00:00:00 2001 From: Rafal Sapala Date: Wed, 10 Jun 2026 18:11:52 +0200 Subject: [PATCH 17/18] Test names --- src/test/pull_hf_model_test.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/test/pull_hf_model_test.cpp b/src/test/pull_hf_model_test.cpp index 0196e893ad..71d5c7fea8 100644 --- a/src/test/pull_hf_model_test.cpp +++ b/src/test/pull_hf_model_test.cpp @@ -1965,7 +1965,7 @@ class HfDownloaderHfEnvTest : public ::testing::Test { EnvGuard guard; }; -TEST(Libgt2InitGuardTest, LfsFilterCaptureDefaultResumeOptions) { +TEST(Libgt2InitGuardTestResume, LfsFilterCaptureDefaultResumeOptions) { // Need new process beacase we use INIT_ONCE in libgit2 lfs filter for env variables and once they are set they are set for the whole process lifetime EXPECT_EXIT({ // Act: capture stdout during object construction @@ -1985,7 +1985,7 @@ TEST(Libgt2InitGuardTest, LfsFilterCaptureDefaultResumeOptions) { exit(0); }, ::testing::ExitedWithCode(0), ""); } -TEST(Libgt2InitGuardTest, LfsFilterCaptureNonDefaultResumeOptions) { +TEST(Libgt2InitGuardTestResume, LfsFilterCaptureNonDefaultResumeOptions) { // Need new process beacase we use INIT_ONCE in libgit2 lfs filter for env variables and once they are set they are set for the whole process lifetime EXPECT_EXIT({ EnvGuard guard; From 5f1ae0ab413ade341fd99583fa5828054648e1e2 Mon Sep 17 00:00:00 2001 From: Rafal Sapala Date: Wed, 10 Jun 2026 18:30:46 +0200 Subject: [PATCH 18/18] Add test timeout log --- src/test/pull_hf_model_test.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/test/pull_hf_model_test.cpp b/src/test/pull_hf_model_test.cpp index 71d5c7fea8..0e786c2898 100644 --- a/src/test/pull_hf_model_test.cpp +++ b/src/test/pull_hf_model_test.cpp @@ -2079,7 +2079,10 @@ TEST_F(HfDownloadModelModule, TestInvalidProxyTimeout) { const bool hostHadProxy = (hostHttpsProxy != nullptr) && (std::string(hostHttpsProxy) != ""); eGuard.set("https_proxy", ""); if (!hostHadProxy) { + SPDLOG_DEBUG("Host has no proxy configured, setting HF_ENDPOINT to an unroutable address to force timeout - https://192.0.2.1/"); eGuard.set("HF_ENDPOINT", "https://192.0.2.1/"); + } else { + SPDLOG_DEBUG("Host has proxy configured, keeping default HF_ENDPOINT and relying on proxy-only network to cause timeout"); } const std::string timeoutConnectVal = "1000"; eGuard.set(ovms::HfPullModelModule::GIT_SERVER_CONNECT_TIMEOUT_ENV, timeoutConnectVal);