Repository: CLIUtils/CLI11 (C++)

Issue:
ExistingFileValidator should also check for read permission
Hi @henryiii ,
ExistingFileValidator doesn't check if file is readable, e.g no permission.
I think it should be added as a default, instead of adding a new validator for this check.
Another option is adding a parameter to the ExistingFileValidator to check read write and not only if exists.
what do you think?



Discussion:

Patch:
diff --git a/README.md b/README.md
index 505bb371e..6b796009b 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 ae1e6fadc..6d18b58af 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 da17b033c..4d34d2123 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 701749963..adaa4fede 100644
--- a/include/CLI/impl/ExtraValidators_inl.hpp
+++ b/include/CLI/impl/ExtraValidators_inl.hpp
@@ -19,6 +19,7 @@
 
 // [CLI11:public_includes:set]
 #include <algorithm>
+#include <fstream>
 #include <map>
 #include <string>
 #include <utility>
@@ -94,7 +95,63 @@ CLI11_INLINE std::map<std::string, AsSizeValue::result_t> 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 bb7b1ac97..72e4a2c7f 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<int>(num_max), original.size());
             }
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index 3e3826feb..6989f6d0c 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


Test patch:
diff --git a/tests/ExtraValidatorsTest.cpp b/tests/ExtraValidatorsTest.cpp
index aa814337a..a95c850be 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 <filesystem>
+
+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<bool>(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<bool>(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<bool>(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<bool>(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