Fix getExecutablePath to only return files that are actually readable and executable. (#61)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index ec52840..e434226 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,8 @@
+#### 4.1.1
+
+* Fixed `getExecutablePath()` to only return path items that are
+  executable and readable to the user.
+
 #### 4.1.0
 
 * Fix the signatures of `ProcessManager.run`, `.runSync`, and `.start` to be
diff --git a/lib/src/interface/common.dart b/lib/src/interface/common.dart
index 2f6a5c9..e0866ad 100644
--- a/lib/src/interface/common.dart
+++ b/lib/src/interface/common.dart
@@ -16,7 +16,7 @@
   'windows': 'windows',
 };
 
-/// Sanatizes the executable path on Windows.
+/// Sanitizes the executable path on Windows.
 /// https://github.com/dart-lang/sdk/issues/37751
 String sanitizeExecutablePath(String executable,
     {Platform platform = const LocalPlatform()}) {
@@ -85,7 +85,26 @@
     candidates = _getCandidatePaths(command, searchPath, extensions, context);
   }
   for (String path in candidates) {
-    if (fs.file(path).existsSync()) {
+    final File candidate = fs.file(path);
+    FileStat stat = candidate.statSync();
+    // Only return files or links that exist.
+    if (stat.type == FileSystemEntityType.notFound ||
+        stat.type == FileSystemEntityType.directory) {
+      continue;
+    }
+
+    const int isExecutable = 0x40;
+    const int isReadable = 0x100;
+    const int isExecutableAndReadable = isExecutable | isReadable;
+    // Should only return files or links that are readable and executable by the
+    // user.
+
+    // On Windows it's not actually possible to only return files that are
+    // readable, since Dart reports files that have had read permission removed
+    // as being readable, but not checking for it is the same as checking for it
+    // and finding it readable, so we use the same check here on all platforms,
+    // so that if Dart ever gets fixed, it'll just work.
+    if (stat.mode & isExecutableAndReadable == isExecutableAndReadable) {
       return path;
     }
   }
diff --git a/pubspec.yaml b/pubspec.yaml
index c854b97..cdd3bb3 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,5 +1,5 @@
 name: process
-version: 4.1.0
+version: 4.1.1
 description: A pluggable, mockable process invocation abstraction for Dart.
 homepage: https://github.com/google/process.dart
 
@@ -12,4 +12,4 @@
   platform: '^3.0.0'
 
 dev_dependencies:
-  test: ^1.16.0-nullsafety.8
+  test: ^1.16.8
diff --git a/test/src/interface/common_test.dart b/test/src/interface/common_test.dart
index cbfd180..f394a81 100644
--- a/test/src/interface/common_test.dart
+++ b/test/src/interface/common_test.dart
@@ -2,6 +2,9 @@
 // for details. All rights reserved. Use of this source code is governed by a
 // BSD-style license that can be found in the LICENSE file.
 
+import 'dart:io' as io;
+import 'package:file/local.dart';
+import 'package:path/path.dart' as path;
 import 'package:file/file.dart';
 import 'package:file/memory.dart';
 import 'package:platform/platform.dart';
@@ -352,6 +355,87 @@
       });
     });
   });
+  group('Real Filesystem', () {
+    late Platform platform;
+    late Directory tmpDir;
+    late Directory pathDir1;
+    late Directory pathDir2;
+    late Directory pathDir3;
+    late Directory pathDir4;
+    late Directory pathDir5;
+    late File command1;
+    late File command2;
+    late File command3;
+    late File command4;
+    late File command5;
+    const Platform localPlatform = LocalPlatform();
+    late FileSystem fs;
+
+    setUp(() {
+      fs = LocalFileSystem();
+      tmpDir = fs.systemTempDirectory.createTempSync();
+      pathDir1 = tmpDir.childDirectory('path1')..createSync();
+      pathDir2 = tmpDir.childDirectory('path2')..createSync();
+      pathDir3 = tmpDir.childDirectory('path3')..createSync();
+      pathDir4 = tmpDir.childDirectory('path4')..createSync();
+      pathDir5 = tmpDir.childDirectory('path5')..createSync();
+      command1 = pathDir1.childFile('command')..createSync();
+      command2 = pathDir2.childFile('command')..createSync();
+      command3 = pathDir3.childFile('command')..createSync();
+      command4 = pathDir4.childFile('command')..createSync();
+      command5 = pathDir5.childFile('command')..createSync();
+      platform = FakePlatform(
+        operatingSystem: localPlatform.operatingSystem,
+        environment: <String, String>{
+          'PATH': <Directory>[
+            pathDir1,
+            pathDir2,
+            pathDir3,
+            pathDir4,
+            pathDir5,
+          ].map<String>((Directory dir) => dir.absolute.path).join(':'),
+        },
+      );
+    });
+
+    tearDown(() {
+      tmpDir.deleteSync(recursive: true);
+    });
+
+    // This doesn't use the memory filesystem because Dart can't modify file
+    // executable permissions, so we have to create them with actual commands.
+    test('Only returns executables in PATH', () {
+      if (localPlatform.isWindows) {
+        // Windows doesn't check for executable-ness, and we can't run 'chmod'
+        // on Windows anyhow.
+        return;
+      }
+
+      // Make the second command in the path executable, but not the first.
+      // No executable permissions
+      io.Process.runSync("chmod", <String>["0644", "--", command1.path]);
+      // Only group executable permissions
+      io.Process.runSync("chmod", <String>["0645", "--", command2.path]);
+      // Only other executable permissions
+      io.Process.runSync("chmod", <String>["0654", "--", command3.path]);
+      // All executable permissions, but not readable
+      io.Process.runSync("chmod", <String>["0311", "--", command4.path]);
+      // All executable permissions
+      io.Process.runSync("chmod", <String>["0755", "--", command5.path]);
+
+      String? executablePath = getExecutablePath(
+        'command',
+        tmpDir.path,
+        platform: platform,
+        fs: fs,
+      );
+
+      // Make sure that the path returned is for the last command, since that
+      // one comes last in the PATH, but is the only one executable by the
+      // user.
+      _expectSamePath(executablePath, command5.absolute.path);
+    });
+  });
 }
 
 void _expectSamePath(String? actual, String? expected) {