From 4da7647d1d9ff0fea65d83b0b7de8d6fd7e6068a Mon Sep 17 00:00:00 2001 From: Guillaume Chauvel Date: Tue, 10 Nov 2026 11:00:56 +0100 Subject: [PATCH] Partial fix for #8328 (build dir: hash the addon files) The build dir hash only included the name and the arguments of the addons. Also hash the content of the json file, the script or the executable, and of the files given as OPTION=FILE in the arguments, such as --rule-texts=FILE --- cli/cmdlineparser.cpp | 2 ++ gui/mainwindow.cpp | 5 ++- lib/addoninfo.cpp | 44 +++++++++++++++++++++++ lib/addoninfo.h | 4 +++ lib/cppcheck.cpp | 1 + lib/utils.cpp | 14 ++++++++ lib/utils.h | 7 ++++ test/cli/other_test.py | 53 ++++++++++++++++++++++++++++ test/testcppcheck.cpp | 79 ++++++++++++++++++++++++++++++++++++++++++ 9 files changed, 208 insertions(+), 1 deletion(-) diff --git a/cli/cmdlineparser.cpp b/cli/cmdlineparser.cpp index 842d597b0a4..74ad2085b5a 100644 --- a/cli/cmdlineparser.cpp +++ b/cli/cmdlineparser.cpp @@ -2233,6 +2233,8 @@ bool CmdLineParser::loadAddons(Settings& settings) result = false; continue; } + if (!settings.buildDir.empty()) + addonInfo.initContentHash(); settings.addonInfos.emplace_back(std::move(addonInfo)); } return result; diff --git a/gui/mainwindow.cpp b/gui/mainwindow.cpp index 5ddb790b77b..143f0d8a8d7 100644 --- a/gui/mainwindow.cpp +++ b/gui/mainwindow.cpp @@ -1259,8 +1259,11 @@ bool MainWindow::getCppcheckSettings(Settings& settings, Suppressions& supprs) settings.severity.enable(Severity::portability); settings.severity.enable(Severity::information); settings.checks.enable(Checks::missingInclude); - if (!settings.buildDir.empty()) + if (!settings.buildDir.empty()) { settings.checks.enable(Checks::unusedFunction); + for (AddonInfo& addonInfo : settings.addonInfos) + addonInfo.initContentHash(); + } settings.debugwarnings = mSettings->value(SETTINGS_SHOW_DEBUG_WARNINGS, false).toBool(); settings.quiet = false; settings.verbose = true; diff --git a/lib/addoninfo.cpp b/lib/addoninfo.cpp index 388484916ea..c1368899543 100644 --- a/lib/addoninfo.cpp +++ b/lib/addoninfo.cpp @@ -25,6 +25,7 @@ #include #include #include +#include #include "json.h" @@ -209,6 +210,7 @@ std::string AddonInfo::getAddonInfo(const std::string &fileName, const std::stri std::ifstream fin(fileName); if (!fin.is_open()) return "Failed to open " + fileName; + jsonFile = Path::fromNativeSeparators(fileName); if (name.empty()) { name = Path::fromNativeSeparators(fileName); if (name.find('/') != std::string::npos) @@ -218,3 +220,45 @@ std::string AddonInfo::getAddonInfo(const std::string &fileName, const std::stri fin >> json; return parseAddonInfo(*this, json, fileName, exename); } + +// split the args as the shell running the addon does +static std::vector splitArgs(const std::string &args) { +#ifdef _WIN32 + const std::string quotes = "\""; +#else + // popen() runs /bin/sh, which also removes single quotes + const std::string quotes = "\"\'"; +#endif + std::vector ret(1); + char quote = '\0'; + for (const char c : args) { + if (quote == '\0' && quotes.find(c) != std::string::npos) + quote = c; + else if (c == quote) + quote = '\0'; + else if (c == ' ' && quote == '\0') + ret.emplace_back(); + else + ret.back() += c; + } + return ret; +} + +void AddonInfo::initContentHash() { + contentHash.clear(); + if (!jsonFile.empty()) + contentHash += hashFileContent(jsonFile); + if (!scriptFile.empty()) + contentHash += hashFileContent(scriptFile); + if (!executable.empty()) + contentHash += hashFileContent(executable); + // files referenced as OPTION=FILE in args, e.g. --rule-texts=FILE + for (const std::string &arg : splitArgs(args)) { + const std::string::size_type pos = arg.find('='); + if (pos == std::string::npos) + continue; + const std::string path = arg.substr(pos + 1); + if (Path::isFile(path)) + contentHash += hashFileContent(path); + } +} diff --git a/lib/addoninfo.h b/lib/addoninfo.h index 902a26c2e12..f039a2dd376 100644 --- a/lib/addoninfo.h +++ b/lib/addoninfo.h @@ -26,6 +26,7 @@ struct CPPCHECKLIB AddonInfo { std::string name; + std::string jsonFile; // addon json file std::string scriptFile; // addon script std::string executable; // addon executable std::string args; // special extra arguments @@ -34,7 +35,10 @@ struct CPPCHECKLIB AddonInfo { std::string runScript; std::map checkers; // checker name and its requirement + std::string contentHash; // hash of the addon files + std::string getAddonInfo(const std::string &fileName, const std::string &exename, bool debug = false); + void initContentHash(); }; #endif // addonInfoH diff --git a/lib/cppcheck.cpp b/lib/cppcheck.cpp index c2c3060f03f..38cfaaf7400 100644 --- a/lib/cppcheck.cpp +++ b/lib/cppcheck.cpp @@ -888,6 +888,7 @@ std::size_t CppCheck::calculateHash(const Preprocessor& preprocessor, const std: for (const auto &a : mSettings.addonInfos) { toolinfo << a.name; toolinfo << a.args; + toolinfo << a.contentHash; } toolinfo << mSettings.premiumArgs; // TODO: do we need to add more options? diff --git a/lib/utils.cpp b/lib/utils.cpp index b840fc3194b..187adc376e3 100644 --- a/lib/utils.cpp +++ b/lib/utils.cpp @@ -20,6 +20,10 @@ #include #include +#include +#include +#include +#include #include #include @@ -198,3 +202,13 @@ std::vector splitString(const std::string& str, char sep) } return l; } + +std::string hashFileContent(const std::string& path) +{ + std::ifstream fin(path, std::ios::binary); + if (!fin.is_open()) + return ""; + std::ostringstream ostr; + ostr << fin.rdbuf(); + return std::to_string(std::hash {}(ostr.str())); +} diff --git a/lib/utils.h b/lib/utils.h index 410bd7c6040..d09f7d4bc75 100644 --- a/lib/utils.h +++ b/lib/utils.h @@ -431,6 +431,13 @@ static inline T* empty_if_null(T* p) */ CPPCHECKLIB std::vector splitString(const std::string& str, char sep); +/** + * Hash the content of a file. + * @param path The file to read + * @return The hash, empty if the file cannot be opened. + */ +CPPCHECKLIB std::string hashFileContent(const std::string& path); + namespace utils { /** * Drop-in replacement for C++17's std::as_const diff --git a/test/cli/other_test.py b/test/cli/other_test.py index 8e13489e114..cd27efd05e1 100644 --- a/test/cli/other_test.py +++ b/test/cli/other_test.py @@ -2355,6 +2355,59 @@ def test_builddir_hash_check_level(tmp_path): # #13376 assert hash_1 != hash_2 +def test_builddir_hash_addon(tmp_path): + test_file = tmp_path / 'test.c' + with open(test_file, 'wt') as f: + f.write('void f() {}\n') + + addon_script = tmp_path / 'addon1.py' + with open(addon_script, 'wt') as f: + f.write('# addon1.py\n') + + rule_texts = tmp_path / 'rule texts.txt' + with open(rule_texts, 'wt') as f: + f.write('# rule texts.txt\n') + + extra_file = tmp_path / 'extra file.txt' + with open(extra_file, 'wt') as f: + f.write('# extra file.txt\n') + + addon_file = tmp_path / 'addon1.json' + with open(addon_file, 'wt') as f: + f.write(json.dumps({ + 'script': str(addon_script), + 'args': ['"--rule-texts={}"'.format(rule_texts), '--extra="{}"'.format(extra_file)] + })) + + build_dir = tmp_path / 'b1' + os.mkdir(build_dir) + + args = [ + '--addon={}'.format(addon_file), + '--cppcheck-build-dir={}'.format(build_dir), + str(test_file) + ] + + cache_file = build_dir / 'test.a1' + + def get_hash(): + exitcode, stdout, stderr = cppcheck(args) + assert exitcode == 0, stdout + assert stderr == '' + root = ElementTree.fromstring(cache_file.read_text()) + return root.findtext('hash') + + hash_1 = get_hash() + assert get_hash() == hash_1 + + for changed_file in (addon_script, rule_texts, extra_file, addon_file): + with open(changed_file, 'a') as f: + f.write('\n') + hash_2 = get_hash() + assert hash_2 != hash_1 + hash_1 = hash_2 + + def test_def_undef(tmp_path): test_file = tmp_path / 'test.c' with open(test_file, 'wt') as f: diff --git a/test/testcppcheck.cpp b/test/testcppcheck.cpp index d88daceb0d9..59e7aaef0a5 100644 --- a/test/testcppcheck.cpp +++ b/test/testcppcheck.cpp @@ -35,6 +35,7 @@ #include #include #include +#include #include #include #include @@ -100,6 +101,13 @@ class TestCppcheck : public TestFixture { TEST_CASE(getDumpFileContentsLibrary); TEST_CASE(checkPlistOutput); TEST_CASE(premiumResultsCache); + TEST_CASE(addonScriptHash); + TEST_CASE(addonJsonHash); + TEST_CASE(addonArgsHash); + TEST_CASE(addonQuotedArgsHash); +#ifndef _WIN32 + TEST_CASE(addonSingleQuotedArgsHash); +#endif TEST_CASE(purgedConfiguration); TEST_CASE(recheckInclude); } @@ -612,6 +620,77 @@ class TestCppcheck : public TestFixture { ASSERT(hash1 != hash2); } + void addonHashInternal(AddonInfo addon, const ScopedFile& file) const + { + Settings settings; + Suppressions supprs; + ErrorLogger2 errorLogger; + + std::vector files; + + const char code[] = "void f();\nint x;\n"; + simplecpp::TokenList tokens(code, files, "m1.c"); + + Preprocessor preprocessor(tokens, settings, errorLogger, Standards::Language::C); + ASSERT(preprocessor.loadAllIncludes(files)); + + addon.name = "testaddon"; + settings.addons.insert(addon.name); + settings.addonInfos.push_back(std::move(addon)); + settings.addonInfos.back().initContentHash(); + + CppCheck check(settings, supprs, errorLogger, nullptr, false, {}); + const size_t hash1 = check.calculateHash(preprocessor); + + settings.addonInfos.back().initContentHash(); + ASSERT_EQUALS(hash1, check.calculateHash(preprocessor)); + + { + std::ofstream of(file.path()); + of << "changed content\n"; + } + settings.addonInfos.back().initContentHash(); + // cppcheck-suppress knownConditionTrueFalse + ASSERT(hash1 != check.calculateHash(preprocessor)); + } + + void addonScriptHash() const { + ScopedFile addon_script("testaddon.py", "# addon v1\n"); + AddonInfo addon; + addon.scriptFile = addon_script.path(); + addonHashInternal(addon, addon_script); + } + + void addonJsonHash() const { + ScopedFile addon_config("testaddon.json", "{ \"script\": \"x.py\" }\n"); + AddonInfo addon; + addon.jsonFile = addon_config.path(); + addonHashInternal(addon, addon_config); + } + + void addonArgsHash() const { + ScopedFile rule_texts("testrules.txt", "rule v1\n"); + AddonInfo addon; + addon.args = "--rule-texts=" + rule_texts.path(); + addonHashInternal(addon, rule_texts); + } + + void addonQuotedArgsHash() const { + ScopedFile rule_texts("test rules.txt", "rule v1\n"); + AddonInfo addon; + addon.args = "--rule-texts=\"" + rule_texts.path() + "\""; + addonHashInternal(addon, rule_texts); + } + +#ifndef _WIN32 + void addonSingleQuotedArgsHash() const { + ScopedFile rule_texts("test rules.txt", "rule v1\n"); + AddonInfo addon; + addon.args = "--rule-texts='" + rule_texts.path() + "'"; + addonHashInternal(addon, rule_texts); + } +#endif + void purgedConfiguration() const { ScopedFile test_file("test.cpp",