From c8dc5f627aeb4679261a0ebec057b6ebd3457a28 Mon Sep 17 00:00:00 2001 From: Philip Top Date: Mon, 8 Sep 2025 05:16:49 -0700 Subject: [PATCH] add permission validators as an Extra Validator (#1203) an update of #250 Fixes #249 --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- README.md | 34 +++++--- azure-pipelines.yml | 43 ++++++--- include/CLI/ExtraValidators.hpp | 20 +++++ include/CLI/impl/ExtraValidators_inl.hpp | 59 ++++++++++++- include/CLI/impl/Option_inl.hpp | 5 +- src/CMakeLists.txt | 4 + tests/ExtraValidatorsTest.cpp | 106 +++++++++++++++++++++++ 7 files changed, 247 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 505bb371..6b796009 100644 --- a/README.md +++ b/README.md @@ -38,14 +38,15 @@ set with a simple and intuitive interface. - [Option options](#option-options) - [Validators](#validators) - [Default Validators](#default-validators) - - [Validatrs that may be disabled 🚧](#validatrs-that-may-be-disabled-) + - [Validators that may be disabled 🚧](#validators-that-may-be-disabled-) - [Extra Validators 🚧](#extra-validators-) - - [Validator Usage](#validator-usage) - - [Transforming Validators](#transforming-validators) - - [Validator operations](#validator-operations) - - [Custom Validators](#custom-validators) - - [Querying Validators](#querying-validators) - - [Getting results](#getting-results) + - [permission. Requires C++17.](#permission-requires-c17) + - [Validator Usage](#validator-usage) + - [Transforming Validators](#transforming-validators) + - [Validator operations](#validator-operations) + - [Custom Validators](#custom-validators) + - [Querying Validators](#querying-validators) + - [Getting results](#getting-results) - [Subcommands](#subcommands) - [Subcommand options](#subcommand-options) - [Callbacks](#callbacks) @@ -575,7 +576,9 @@ they can be disabled by using #### Default Validators -These validators are always available regardless of definitions +These validators are always available regardless of definitions. These are used +internally or are very commonly used, so will always remain available regardless +of flags. - `CLI::ExistingFile`: Requires that the file exists if given. - `CLI::ExistingDirectory`: Requires that the directory exists. @@ -590,11 +593,14 @@ These validators are always available regardless of definitions - `CLI::NonNegativeNumber`: Requires the number be greater or equal to 0 - `CLI::Number`: Requires the input be a number. -#### Validatrs that may be disabled 🚧 +#### Validators that may be disabled 🚧 Validators that may be disabled by setting `CLI11_DISABLE_EXTRA_VALIDATORS` to 1 or enabled by setting `CLI11_ENABLE_EXTRA_VALIDATORS` to 1. By default they are -enabled. +enabled. In version 3.0 these will likely move to be disabled by default and be +controlled solely by the `CLI11_ENABLE_EXTRA_VALIDATORS` option. These +validators are less commonly used or are template heavy and require additional +computation time that may not be valuable for some use cases. - `CLI::IsMember(...)`: Require an option be a member of a given set. See [Transforming Validators](#transforming-validators) for more details. @@ -627,6 +633,14 @@ enabled. New validators will go into code sections that must be explicitly enabled by setting `CLI11_ENABLE_EXTRA_VALIDATORS` to 1 +- `CLI::ReadPermission`: Requires that the file or folder given exist and have + read permission. Requires C++17. +- `CLI::WritePermission`: Requires that the file or folder given exist and have + write permission. Requires C++17. +- `CLI::ExecPermission`: Requires that the file given exist and have execution + permission. Requires C++17. +- + #### Validator Usage These Validators once enabled can be used by simply passing the name into the diff --git a/azure-pipelines.yml b/azure-pipelines.yml index ae1e6fad..6d18b58a 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -34,7 +34,9 @@ jobs: vmImage: "windows-2025" cli11.std: 17 cli11.build_type: Debug - cli11.options: -G "Visual Studio 17 2022" -A ARM64 + cli11.options: + -G "Visual Studio 17 2022" -A ARM64 + -DCLI11_ENABLE_EXTRA_VALIDATORS=1 pool: vmImage: $(vmImage) @@ -52,12 +54,15 @@ jobs: macOS-15_23: vmImage: "macOS-15" cli11.std: 23 + cli11.options: -DCLI11_ENABLE_EXTRA_VALIDATORS=1 macOS-14_20: vmImage: "macOS-14" cli11.std: 20 + cli11.options: -DCLI11_ENABLE_EXTRA_VALIDATORS=1 macOS-13_17: vmImage: "macOS-13" cli11.std: 17 + cli11.options: -DCLI11_ENABLE_EXTRA_VALIDATORS=1 macOS-14_11: vmImage: "macOS-14" cli11.std: 11 @@ -68,10 +73,12 @@ jobs: Windows17: vmImage: "windows-2022" cli11.std: 17 + cli11.options: -DCLI11_ENABLE_EXTRA_VALIDATORS=1 Windows17PC: vmImage: "windows-2022" cli11.std: 17 cli11.precompile: ON + cli11.options: -DCLI11_ENABLE_EXTRA_VALIDATORS=1 Windows11: vmImage: "windows-2022" cli11.std: 11 @@ -87,7 +94,8 @@ jobs: Linux17nortti: vmImage: "ubuntu-latest" cli11.std: 17 - cli11.options: -DCMAKE_CXX_FLAGS="-fno-rtti" + cli11.options: + -DCMAKE_CXX_FLAGS="-fno-rtti" -DCLI11_ENABLE_EXTRA_VALIDATORS=1 pool: vmImage: $(vmImage) steps: @@ -126,7 +134,9 @@ jobs: gcc9: containerImage: gcc:9 cli11.std: 17 - cli11.options: -DCMAKE_CXX_FLAGS="-Wstrict-overflow=5" + cli11.options: + -DCMAKE_CXX_FLAGS="-Wstrict-overflow=5" + -DCLI11_ENABLE_EXTRA_VALIDATORS=1 gcc11: containerImage: gcc:11 cli11.std: 20 @@ -143,19 +153,24 @@ jobs: clang3.4: containerImage: silkeh/clang:3.4 cli11.std: 11 - cli11.options: -DCLI11_WARNINGS_AS_ERRORS=OFF + cli11.options: + -DCLI11_WARNINGS_AS_ERRORS=OFF -DCLI11_DISABLE_EXTRA_VALIDATORS=1 clang8: containerImage: silkeh/clang:8 cli11.std: 14 - cli11.options: -DCLI11_FORCE_LIBCXX=ON + cli11.options: + -DCLI11_FORCE_LIBCXX=ON -DCLI11_DISABLE_EXTRA_VALIDATORS=1 clang8_17: containerImage: silkeh/clang:8 cli11.std: 17 - cli11.options: -DCLI11_FORCE_LIBCXX=ON + cli11.options: + -DCLI11_FORCE_LIBCXX=ON -DCLI11_ENABLE_EXTRA_VALIDATORS=1 clang10_20: containerImage: silkeh/clang:10 cli11.std: 20 - cli11.options: -DCLI11_FORCE_LIBCXX=ON -DCMAKE_CXX_FLAGS=-std=c++20 + cli11.options: + -DCLI11_FORCE_LIBCXX=ON -DCMAKE_CXX_FLAGS=-std=c++20 + -DCLI11_ENABLE_EXTRA_VALIDATORS=1 container: $[ variables['containerImage'] ] steps: - template: .ci/azure-cmake.yml @@ -172,19 +187,25 @@ jobs: gcc13_17: containerImage: gcc:13 cli11.std: 17 - cli11.options: -DCMAKE_CXX_FLAGS="-Wstrict-overflow=5" + cli11.options: + -DCMAKE_CXX_FLAGS="-Wstrict-overflow=5" + -DCLI11_ENABLE_EXTRA_VALIDATORS=1 gcc12_20: containerImage: gcc:12 cli11.std: 20 - cli11.options: -DCMAKE_CXX_FLAGS="-Wredundant-decls -Wconversion" + cli11.options: + -DCMAKE_CXX_FLAGS="-Wredundant-decls -Wconversion" + -DCLI11_ENABLE_EXTRA_VALIDATORS=1 clang17_23: containerImage: silkeh/clang:17 cli11.std: 23 - cli11.options: -DCMAKE_CXX_FLAGS=-std=c++23 + cli11.options: + -DCMAKE_CXX_FLAGS=-std=c++23 -DCLI11_ENABLE_EXTRA_VALIDATORS=1 clang20_26: containerImage: silkeh/clang:20 cli11.std: 26 - cli11.options: -DCMAKE_CXX_FLAGS=-std=c++2c + cli11.options: + -DCMAKE_CXX_FLAGS=-std=c++2c -DCLI11_ENABLE_EXTRA_VALIDATORS=1 container: $[ variables['containerImage'] ] steps: - template: .ci/azure-cmake-new.yml diff --git a/include/CLI/ExtraValidators.hpp b/include/CLI/ExtraValidators.hpp index da17b033..4d34d212 100644 --- a/include/CLI/ExtraValidators.hpp +++ b/include/CLI/ExtraValidators.hpp @@ -4,6 +4,8 @@ // // SPDX-License-Identifier: BSD-3-Clause +#define CLI11_ENABLE_EXTRA_VALIDATORS 1 + #pragma once #if (defined(CLI11_ENABLE_EXTRA_VALIDATORS) && CLI11_ENABLE_EXTRA_VALIDATORS == 1) || \ (!defined(CLI11_DISABLE_EXTRA_VALIDATORS) || CLI11_DISABLE_EXTRA_VALIDATORS == 0) @@ -591,6 +593,24 @@ class AsSizeValue : public AsNumberWithUnit { #if defined(CLI11_ENABLE_EXTRA_VALIDATORS) && CLI11_ENABLE_EXTRA_VALIDATORS != 0 // new extra validators +#if CLI11_HAS_FILESYSTEM +namespace detail { +enum class Permission : std::uint8_t { none = 0, read = 1, write = 2, exec = 4 }; +class PermissionValidator : public Validator { + public: + explicit PermissionValidator(Permission permission); +}; +} // namespace detail + +/// Check that the file exist and available for read +const detail::PermissionValidator ReadPermissions(detail::Permission::read); + +/// Check that the file exist and available for write +const detail::PermissionValidator WritePermissions(detail::Permission::write); + +/// Check that the file exist and available for write +const detail::PermissionValidator ExecPermissions(detail::Permission::exec); +#endif #endif // [CLI11:extra_validators_hpp:end] diff --git a/include/CLI/impl/ExtraValidators_inl.hpp b/include/CLI/impl/ExtraValidators_inl.hpp index 70174996..adaa4fed 100644 --- a/include/CLI/impl/ExtraValidators_inl.hpp +++ b/include/CLI/impl/ExtraValidators_inl.hpp @@ -19,6 +19,7 @@ // [CLI11:public_includes:set] #include +#include #include #include #include @@ -94,7 +95,63 @@ CLI11_INLINE std::map AsSizeValue::get_mappi namespace detail {} // namespace detail /// @} -// [CLI11:extra_validators_inl_hpp:end] +#if defined(CLI11_ENABLE_EXTRA_VALIDATORS) && CLI11_ENABLE_EXTRA_VALIDATORS != 0 +// new extra validators +namespace detail { + +#if defined CLI11_HAS_FILESYSTEM && CLI11_HAS_FILESYSTEM > 0 +CLI11_INLINE PermissionValidator::PermissionValidator(Permission permission) { + std::filesystem::perms permission_code = std::filesystem::perms::none; + std::string permission_name; + switch(permission) { + case Permission::read: + permission_code = std::filesystem::perms::owner_read | std::filesystem::perms::group_read | + std::filesystem::perms::others_read; + permission_name = "read"; + break; + case Permission::write: + permission_code = std::filesystem::perms::owner_write | std::filesystem::perms::group_write | + std::filesystem::perms::others_write; + permission_name = "write"; + break; + case Permission::exec: + permission_code = std::filesystem::perms::owner_exec | std::filesystem::perms::group_exec | + std::filesystem::perms::others_exec; + permission_name = "exec"; + break; + case Permission::none: + default: + permission_code = std::filesystem::perms::none; + break; + } + func_ = [permission_code](std::string &path) { + std::error_code ec; + auto p = std::filesystem::path(path); + if(!std::filesystem::exists(p, ec)) { + return std::string("Path does not exist: ") + path; + } + if(ec) { + return std::string("Error checking path: ") + ec.message(); // LCOV_EXCL_LINE + } + if(permission_code == std::filesystem::perms::none) { + return std::string{}; + } + auto perms = std::filesystem::status(p, ec).permissions(); + if(ec) { + return std::string("Error checking path status: ") + ec.message(); // LCOV_EXCL_LINE + } + if((perms & permission_code) == std::filesystem::perms::none) { + return std::string("Path does not have required permissions: ") + path; + } + return std::string{}; + }; + description("Path with " + permission_name + " permission"); +} +#endif + +} // namespace detail +#endif + // [CLI11:extra_validators_inl_hpp:end] } // namespace CLI #endif diff --git a/include/CLI/impl/Option_inl.hpp b/include/CLI/impl/Option_inl.hpp index bb7b1ac9..72e4a2c7 100644 --- a/include/CLI/impl/Option_inl.hpp +++ b/include/CLI/impl/Option_inl.hpp @@ -640,8 +640,9 @@ CLI11_INLINE void Option::_reduce_results(results_t &out, const results_t &origi } if(original.size() > num_max) { if(original.size() == 2 && num_max == 1 && original[1] == "%%" && original[0] == "{}") { - // this condition is a trap for the following empty indicator check on config files - out = original; + // this condition is a trap for the following empty indicator check on config files, it may not be used + // anymore + out = original; // LCOV_EXCL_LINE } else { throw ArgumentMismatch::AtMost(get_name(), static_cast(num_max), original.size()); } diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 3e3826fe..6989f6d0 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -19,6 +19,10 @@ endif() if(CLI11_ENABLE_EXTRA_VALIDATORS) target_compile_definitions(CLI11 ${PUBLIC_OR_INTERFACE} -DCLI11_ENABLE_EXTRA_VALIDATORS=1) +elseif(CLI11_DISABLE_EXTRA_VALIDATORS) + target_compile_definitions(CLI11 ${PUBLIC_OR_INTERFACE} -DCLI11_DISABLE_EXTRA_VALIDATORS=1) +elseif(DEFINED CLI11_ENABLE_EXTRA_VALIDATORS) + target_compile_definitions(CLI11 ${PUBLIC_OR_INTERFACE} -DCLI11_ENABLE_EXTRA_VALIDATORS=0) endif() # Allow IDE's to group targets into folders add_library(CLI11::CLI11 ALIAS CLI11) # for add_subdirectory calls diff --git a/tests/ExtraValidatorsTest.cpp b/tests/ExtraValidatorsTest.cpp index aa814337..a95c850b 100644 --- a/tests/ExtraValidatorsTest.cpp +++ b/tests/ExtraValidatorsTest.cpp @@ -543,4 +543,110 @@ TEST_CASE_METHOD(TApp, "AsSizeValue1024", "[transform]") { CHECK(ki_value == value); } +#if (defined(CLI11_ENABLE_EXTRA_VALIDATORS) && CLI11_ENABLE_EXTRA_VALIDATORS == 1) + +#if defined CLI11_HAS_FILESYSTEM && CLI11_HAS_FILESYSTEM > 0 +#include + +TEST_CASE_METHOD(TApp, "FileExistsForRead", "[validate]") { + std::string myfile{"TestNonFileNotUsed.txt"}; + if(std::filesystem::exists(myfile)) { + std::filesystem::remove(myfile); + } + CHECK(!CLI::ReadPermissions(myfile).empty()); + + bool ok = static_cast(std::ofstream(myfile.c_str()).put('a')); // create file + CHECK(ok); + + std::string filename = "Failed"; + app.add_option("--file", filename)->check(CLI::ReadPermissions); + args = {"--file", myfile}; + + run(); + + CHECK(myfile == filename); + + std::filesystem::permissions(std::filesystem::path(myfile), std::filesystem::perms::owner_exec); + +#if !defined(_WIN32) + // not sure how to make a file unreadable on windows in this context + CHECK_THROWS_AS(run(), CLI::ValidationError); +#endif + std::filesystem::permissions(std::filesystem::path(myfile), std::filesystem::perms::owner_write); + std::filesystem::remove(myfile); +} + +TEST_CASE_METHOD(TApp, "FileExistsForWrite", "[validate]") { + std::string myfile{"TestNonFileNotUsed.txt"}; + if(std::filesystem::exists(myfile)) { + std::filesystem::remove(myfile); + } + CHECK(!CLI::WritePermissions(myfile).empty()); + + bool ok = static_cast(std::ofstream(myfile.c_str()).put('a')); // create file + CHECK(ok); + + std::string filename = "Failed"; + app.add_option("--file", filename)->check(CLI::WritePermissions); + args = {"--file", myfile}; + + run(); + + CHECK(myfile == filename); + + std::filesystem::permissions(std::filesystem::path(myfile), std::filesystem::perms::owner_read); + CHECK_THROWS_AS(run(), CLI::ValidationError); + + std::remove(myfile.c_str()); +} + +TEST_CASE_METHOD(TApp, "FileExistsForExec", "[validate]") { + std::string myfile{"TestNonFileNotUsed.txt"}; + if(std::filesystem::exists(myfile)) { + std::filesystem::remove(myfile); + } + CHECK(!CLI::ExecPermissions(myfile).empty()); + + bool ok = static_cast(std::ofstream(myfile.c_str()).put('a')); // create file + CHECK(ok); + + std::string filename = "Failed"; + app.add_option("--file", filename)->check(CLI::ExecPermissions); + args = {"--file", myfile}; + + std::filesystem::permissions(std::filesystem::path(myfile), + std::filesystem::perms::owner_exec | std::filesystem::perms::owner_read); + run(); + + CHECK(myfile == filename); +#if !defined(_WIN32) + std::filesystem::permissions(std::filesystem::path(myfile), std::filesystem::perms::owner_read); + CHECK_THROWS_AS(run(), CLI::ValidationError); + // exec permission not really a thing on windows +#endif + + std::remove(myfile.c_str()); +} + +TEST_CASE_METHOD(TApp, "noPermissionCheck", "[validate]") { + std::string myfile{"TestNonFileNotUsed.txt"}; + if(std::filesystem::exists(myfile)) { + std::filesystem::remove(myfile); + } + CHECK(!CLI::detail::PermissionValidator(CLI::detail::Permission::none)(myfile).empty()); + + bool ok = static_cast(std::ofstream(myfile.c_str()).put('a')); // create file + CHECK(ok); + + std::string filename = "Failed"; + app.add_option("--file", filename)->check(CLI::detail::PermissionValidator(CLI::detail::Permission::none)); + args = {"--file", myfile}; + + run(); + + CHECK(myfile == filename); + std::remove(myfile.c_str()); +} +#endif +#endif #endif