diff --git a/.github/workflows/process.yml b/.github/workflows/process.yml new file mode 100644 index 0000000..dfcf6cd --- /dev/null +++ b/.github/workflows/process.yml
@@ -0,0 +1,38 @@ +name: Process Package + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + workflow_dispatch: + +jobs: + correctness: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@5a4ac9002d0be2fb38bd78e4b4dbde5606d7042f + - uses: dart-lang/setup-dart@9a04e6d73cca37bd455e0608d7e5092f881fd603 + with: + sdk: dev + - name: Install dependencies + run: dart pub upgrade + - name: Verify formatting + run: dart format --output=none --set-exit-if-changed . + - name: Analyze project source + run: dart analyze --fatal-infos + test: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + sdk: [stable, beta, dev] + steps: + - uses: actions/checkout@5a4ac9002d0be2fb38bd78e4b4dbde5606d7042f + - uses: dart-lang/setup-dart@9a04e6d73cca37bd455e0608d7e5092f881fd603 + with: + sdk: ${{ matrix.sdk }} + - name: Install dependencies + run: dart pub upgrade + - name: Run Tests + run: dart test
diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 5722c5b..0000000 --- a/.travis.yml +++ /dev/null
@@ -1,15 +0,0 @@ -language: dart -sudo: false -dart: - - dev -install: - - gem install coveralls-lcov -before_script: - - ./dev/setup.sh -script: - - ./dev/travis.sh -after_success: - - (coveralls-lcov coverage/lcov.info) -cache: - directories: - - $HOME/.pub-cache
diff --git a/CHANGELOG.md b/CHANGELOG.md index 04be959..ff6cd05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md
@@ -1,3 +1,47 @@ +#### 4.2.4 + +* Mark `stderrEncoding` and `stdoutEncoding` parameters as nullable again, + now that the upstream SDK issue has been fixed. + +#### 4.2.3 + +* Rollback to version 4.2.1 (https://github.com/google/process.dart/issues/64) + +#### 4.2.2 + +* Mark `stderrEncoding` and `stdoutEncoding` parameters as nullable. + +#### 4.2.1 + +* Added custom exception types `ProcessPackageException` and + `ProcessPackageExecutableNotFoundException` to provide extra + information from exception conditions. + +#### 4.2.0 + +* Fix the signature of `ProcessManager.canRun` to be consistent with + `LocalProcessManager`. + +#### 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 + consistent with `LocalProcessManager`'s. +* Added more details to the `ArgumentError` thrown when a command cannot be resolved + to an executable. + +#### 4.0.0 + +* First stable null safe release. + +#### 4.0.0-nullsafety.4 + +* Update supported SDK range. + #### 4.0.0-nullsafety.3 * Update supported SDK range.
diff --git a/analysis_options.yaml b/analysis_options.yaml index 2c72e4b..8fbd2e4 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml
@@ -1,67 +1,6 @@ +include: package:lints/recommended.yaml + analyzer: - enable-experiment: - - non-nullable - strong-mode: - implicit-dynamic: false - implicit-casts: false errors: - missing_required_param: warning - missing_return: warning # Allow having TODOs in the code todo: ignore - -linter: - rules: - # these rules are documented on and in the same order as - # the Dart Lint rules page to make maintenance easier - # http://dart-lang.github.io/linter/lints/ - - # === error rules === - - avoid_empty_else - - comment_references - - cancel_subscriptions - - close_sinks - - control_flow_in_finally - - empty_statements - - hash_and_equals - - invariant_booleans - - iterable_contains_unrelated_type - - list_remove_unrelated_type - - literal_only_boolean_expressions - - test_types_in_equals - - throw_in_finally - - unrelated_type_equality_checks - - valid_regexps - - # === style rules === - - always_declare_return_types - - always_specify_types - - annotate_overrides - - avoid_init_to_null - - avoid_return_types_on_setters - - await_only_futures - - camel_case_types - - constant_identifier_names - - empty_constructor_bodies - - implementation_imports - - library_names - - library_prefixes - - non_constant_identifier_names - - one_member_abstracts - - only_throw_errors - - overridden_fields - - package_api_docs - - package_prefixed_library_names - - prefer_is_not_empty - - public_member_api_docs - - slash_for_doc_comments - - sort_constructors_first - - sort_unnamed_constructors_first - - type_annotate_public_apis - - type_init_formals - - unawaited_futures - - unnecessary_brace_in_string_interps - - unnecessary_getters_setters - - # === pub rules === - - package_names
diff --git a/dev/setup.sh b/dev/setup.sh deleted file mode 100755 index 99ec284..0000000 --- a/dev/setup.sh +++ /dev/null
@@ -1,6 +0,0 @@ -#!/bin/bash -# Copyright (c) 2017, the Dart project authors. Please see the AUTHORS file -# 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. - -pub upgrade
diff --git a/dev/travis.sh b/dev/travis.sh deleted file mode 100755 index 866b361..0000000 --- a/dev/travis.sh +++ /dev/null
@@ -1,30 +0,0 @@ -#!/bin/bash -# Copyright (c) 2017, the Dart project authors. Please see the AUTHORS file -# 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. - -# Make sure dartfmt is run on everything -echo "Checking dartfmt..." -needs_dartfmt="$(dartfmt -n lib test dev)" -if [[ -n "$needs_dartfmt" ]]; then - echo "FAILED" - echo "$needs_dartfmt" - exit 1 -fi -echo "PASSED" - -# Make sure we pass the analyzer -echo "Checking dartanalyzer..." -fails_analyzer="$(find lib test dev -name "*.dart" | xargs dartanalyzer --options .analysis_options)" -if [[ "$fails_analyzer" == *"[error]"* ]]; then - echo "FAILED" - echo "$fails_analyzer" - exit 1 -fi -echo "PASSED" - -# Fast fail the script on failures. -set -e - -# Run the tests. -pub run test
diff --git a/lib/process.dart b/lib/process.dart index dfeb1d0..af513a0 100644 --- a/lib/process.dart +++ b/lib/process.dart
@@ -2,6 +2,7 @@ // 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. +export 'src/interface/exceptions.dart'; export 'src/interface/local_process_manager.dart'; export 'src/interface/process_manager.dart'; export 'src/interface/process_wrapper.dart';
diff --git a/lib/src/interface/common.dart b/lib/src/interface/common.dart index a3aed86..25d52f5 100644 --- a/lib/src/interface/common.dart +++ b/lib/src/interface/common.dart
@@ -7,6 +7,8 @@ import 'package:path/path.dart' show Context; import 'package:platform/platform.dart'; +import 'exceptions.dart'; + const Map<String, String> _osToPathStyle = <String, String>{ 'linux': 'posix', 'macos': 'posix', @@ -16,7 +18,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()}) { @@ -34,10 +36,10 @@ return executable; } -/// Searches the `PATH` for the executable that [command] is supposed to launch. +/// Searches the `PATH` for the executable that [executable] is supposed to launch. /// /// This first builds a list of candidate paths where the executable may reside. -/// If [command] is already an absolute path, then the `PATH` environment +/// If [executable] is already an absolute path, then the `PATH` environment /// variable will not be consulted, and the specified absolute path will be the /// only candidate that is considered. /// @@ -49,13 +51,13 @@ /// /// If [platform] is not specified, it will default to the current platform. String? getExecutablePath( - String command, + String executable, String? workingDirectory, { Platform platform = const LocalPlatform(), FileSystem fs = const LocalFileSystem(), + bool throwOnFailure = false, }) { assert(_osToPathStyle[platform.operatingSystem] == fs.path.style.name); - try { workingDirectory ??= fs.currentDirectory.path; } on FileSystemException { @@ -71,24 +73,66 @@ String pathSeparator = platform.isWindows ? ';' : ':'; List<String> extensions = <String>[]; - if (platform.isWindows && context.extension(command).isEmpty) { + if (platform.isWindows && context.extension(executable).isEmpty) { extensions = platform.environment['PATHEXT']!.split(pathSeparator); } List<String> candidates = <String>[]; - if (command.contains(context.separator)) { - candidates = _getCandidatePaths( - command, <String>[workingDirectory], extensions, context); + List<String> searchPath; + if (executable.contains(context.separator)) { + // Deal with commands that specify a relative or absolute path differently. + searchPath = <String>[workingDirectory]; } else { - List<String> searchPath = - platform.environment['PATH']!.split(pathSeparator); - candidates = _getCandidatePaths(command, searchPath, extensions, context); + searchPath = platform.environment['PATH']!.split(pathSeparator); } + candidates = _getCandidatePaths(executable, searchPath, extensions, context); + final List<String> foundCandidates = <String>[]; 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; + } + + // File exists, but we don't know if it's readable/executable yet. + foundCandidates.add(candidate.path); + + 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; } } + if (throwOnFailure) { + if (foundCandidates.isNotEmpty) { + throw ProcessPackageExecutableNotFoundException( + executable, + message: + 'Found candidates, but lacked sufficient permissions to execute "$executable".', + workingDirectory: workingDirectory, + candidates: foundCandidates, + searchPath: searchPath, + ); + } else { + throw ProcessPackageExecutableNotFoundException( + executable, + message: 'Failed to find "$executable" in the search path.', + workingDirectory: workingDirectory, + searchPath: searchPath, + ); + } + } return null; }
diff --git a/lib/src/interface/exceptions.dart b/lib/src/interface/exceptions.dart new file mode 100644 index 0000000..ee407ca --- /dev/null +++ b/lib/src/interface/exceptions.dart
@@ -0,0 +1,106 @@ +// Copyright (c) 2017, the Dart project authors. Please see the AUTHORS file +// 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' show ProcessException; + +/// A specialized exception class for this package, so that it can throw +/// customized exceptions with more information. +class ProcessPackageException extends ProcessException { + /// Create a const ProcessPackageException. + /// + /// The [executable] should be the name of the executable to be run. + /// + /// The optional [workingDirectory] is the directory where the command + /// execution is attempted. + /// + /// The optional [arguments] is a list of the arguments to given to the + /// executable, already separated. + /// + /// The optional [message] is an additional message to be included in the + /// exception string when printed. + /// + /// The optional [errorCode] is the error code received when the executable + /// was run. Zero means it ran successfully, or that no error code was + /// available. + /// + /// See [ProcessException] for more information. + const ProcessPackageException( + String executable, { + List<String> arguments = const <String>[], + String message = "", + int errorCode = 0, + this.workingDirectory, + }) : super(executable, arguments, message, errorCode); + + /// Creates a [ProcessPackageException] from a [ProcessException]. + factory ProcessPackageException.fromProcessException( + ProcessException exception, { + String? workingDirectory, + }) { + return ProcessPackageException( + exception.executable, + arguments: exception.arguments, + message: exception.message, + errorCode: exception.errorCode, + workingDirectory: workingDirectory, + ); + } + + /// The optional working directory that the command was being executed in. + final String? workingDirectory; + + // Don't implement a toString() for this exception, since code may be + // depending upon the format of ProcessException.toString(). +} + +/// An exception for when an executable is not found that was expected to be found. +class ProcessPackageExecutableNotFoundException + extends ProcessPackageException { + /// Creates a const ProcessPackageExecutableNotFoundException + /// + /// The optional [candidates] are the files matching the expected executable + /// on the [searchPath]. + /// + /// The optional [searchPath] is the list of directories searched for the + /// expected executable. + /// + /// See [ProcessPackageException] for more information. + const ProcessPackageExecutableNotFoundException( + String executable, { + List<String> arguments = const <String>[], + String message = "", + int errorCode = 0, + String? workingDirectory, + this.candidates = const <String>[], + this.searchPath = const <String>[], + }) : super( + executable, + arguments: arguments, + message: message, + errorCode: errorCode, + workingDirectory: workingDirectory, + ); + + /// The list of non-viable executable candidates found. + final List<String> candidates; + + /// The search path used to find candidates. + final List<String> searchPath; + + @override + String toString() { + StringBuffer buffer = StringBuffer('$runtimeType: $message\n'); + // Don't add an extra space if there are no arguments. + final String args = arguments.isNotEmpty ? ' ${arguments.join(' ')}' : ''; + buffer.writeln(' Command: $executable$args'); + if (workingDirectory != null && workingDirectory!.isNotEmpty) { + buffer.writeln(' Working Directory: $workingDirectory'); + } + if (candidates.isNotEmpty) { + buffer.writeln(' Candidates:\n ${candidates.join('\n ')}'); + } + buffer.writeln(' Search Path:\n ${searchPath.join('\n ')}'); + return buffer.toString(); + } +}
diff --git a/lib/src/interface/local_process_manager.dart b/lib/src/interface/local_process_manager.dart index d5133bb..fc260b2 100644 --- a/lib/src/interface/local_process_manager.dart +++ b/lib/src/interface/local_process_manager.dart
@@ -9,9 +9,11 @@ ProcessResult, ProcessSignal, ProcessStartMode, + ProcessException, systemEncoding; import 'common.dart'; +import 'exceptions.dart'; import 'process_manager.dart'; /// Local implementation of the `ProcessManager` interface. @@ -29,83 +31,99 @@ @override Future<Process> start( - covariant List<Object> command, { + List<Object> command, { String? workingDirectory, Map<String, String>? environment, bool includeParentEnvironment = true, bool runInShell = false, ProcessStartMode mode = ProcessStartMode.normal, }) { - return Process.start( - sanitizeExecutablePath(_getExecutable( - command, - workingDirectory, - runInShell, - )), - _getArguments(command), - workingDirectory: workingDirectory, - environment: environment, - includeParentEnvironment: includeParentEnvironment, - runInShell: runInShell, - mode: mode, - ); + try { + return Process.start( + sanitizeExecutablePath(_getExecutable( + command, + workingDirectory, + runInShell, + )), + _getArguments(command), + workingDirectory: workingDirectory, + environment: environment, + includeParentEnvironment: includeParentEnvironment, + runInShell: runInShell, + mode: mode, + ); + } on ProcessException catch (exception) { + throw ProcessPackageException.fromProcessException(exception, + workingDirectory: workingDirectory); + } } @override Future<ProcessResult> run( - covariant List<Object> command, { + List<Object> command, { String? workingDirectory, Map<String, String>? environment, bool includeParentEnvironment = true, bool runInShell = false, - Encoding stdoutEncoding = systemEncoding, - Encoding stderrEncoding = systemEncoding, + Encoding? stdoutEncoding = systemEncoding, + Encoding? stderrEncoding = systemEncoding, }) { - return Process.run( - sanitizeExecutablePath(_getExecutable( - command, - workingDirectory, - runInShell, - )), - _getArguments(command), - workingDirectory: workingDirectory, - environment: environment, - includeParentEnvironment: includeParentEnvironment, - runInShell: runInShell, - stdoutEncoding: stdoutEncoding, - stderrEncoding: stderrEncoding, - ); + try { + return Process.run( + sanitizeExecutablePath(_getExecutable( + command, + workingDirectory, + runInShell, + )), + _getArguments(command), + workingDirectory: workingDirectory, + environment: environment, + includeParentEnvironment: includeParentEnvironment, + runInShell: runInShell, + stdoutEncoding: stdoutEncoding, + stderrEncoding: stderrEncoding, + ); + } on ProcessException catch (exception) { + throw ProcessPackageException.fromProcessException(exception, + workingDirectory: workingDirectory); + } } @override ProcessResult runSync( - covariant List<Object> command, { + List<Object> command, { String? workingDirectory, Map<String, String>? environment, bool includeParentEnvironment = true, bool runInShell = false, - Encoding stdoutEncoding = systemEncoding, - Encoding stderrEncoding = systemEncoding, + Encoding? stdoutEncoding = systemEncoding, + Encoding? stderrEncoding = systemEncoding, }) { - return Process.runSync( - sanitizeExecutablePath(_getExecutable( - command, - workingDirectory, - runInShell, - )), - _getArguments(command), - workingDirectory: workingDirectory, - environment: environment, - includeParentEnvironment: includeParentEnvironment, - runInShell: runInShell, - stdoutEncoding: stdoutEncoding, - stderrEncoding: stderrEncoding, - ); + try { + return Process.runSync( + sanitizeExecutablePath(_getExecutable( + command, + workingDirectory, + runInShell, + )), + _getArguments(command), + workingDirectory: workingDirectory, + environment: environment, + includeParentEnvironment: includeParentEnvironment, + runInShell: runInShell, + stdoutEncoding: stdoutEncoding, + stderrEncoding: stderrEncoding, + ); + } on ProcessException catch (exception) { + throw ProcessPackageException.fromProcessException(exception, + workingDirectory: workingDirectory); + } } @override bool canRun(covariant String executable, {String? workingDirectory}) => - getExecutablePath(executable, workingDirectory) != null; + getExecutablePath(executable, workingDirectory, throwOnFailure: false) != + null; @override bool killPid(int pid, [ProcessSignal signal = ProcessSignal.sigterm]) { @@ -119,14 +137,14 @@ if (runInShell) { return commandName; } - String? exe = getExecutablePath(commandName, workingDirectory); - if (exe == null) { - throw ArgumentError('Cannot find executable for $commandName.'); - } - return exe; + return getExecutablePath( + commandName, + workingDirectory, + throwOnFailure: true, + )!; } -List<String> _getArguments(List<dynamic> command) => +List<String> _getArguments(List<Object> command) => // Adding a specific type to map in order to workaround dart issue // https://github.com/dart-lang/sdk/issues/32414 command
diff --git a/lib/src/interface/process_manager.dart b/lib/src/interface/process_manager.dart index 5372742..d99a874 100644 --- a/lib/src/interface/process_manager.dart +++ b/lib/src/interface/process_manager.dart
@@ -83,9 +83,9 @@ /// /// The default value for `mode` is `ProcessStartMode.normal`. Future<Process> start( - List<dynamic> command, { - String workingDirectory, - Map<String, String> environment, + List<Object> command, { + String? workingDirectory, + Map<String, String>? environment, bool includeParentEnvironment = true, bool runInShell = false, ProcessStartMode mode = ProcessStartMode.normal, @@ -136,13 +136,14 @@ /// stderr.write(result.stderr); /// }); Future<ProcessResult> run( - List<dynamic> command, { - String workingDirectory, - Map<String, String> environment, + List<Object> command, { + String? workingDirectory, + Map<String, String>? environment, bool includeParentEnvironment = true, bool runInShell = false, - Encoding stdoutEncoding = systemEncoding, - Encoding stderrEncoding = systemEncoding, + // TODO(#64): Remove the `covariant` keyword. + covariant Encoding? stdoutEncoding = systemEncoding, + covariant Encoding? stderrEncoding = systemEncoding, }); /// Starts a process and runs it to completion. This is a synchronous @@ -153,17 +154,18 @@ /// Returns a `ProcessResult` with the result of running the process, /// i.e., exit code, standard out and standard in. ProcessResult runSync( - List<dynamic> command, { - String workingDirectory, - Map<String, String> environment, + List<Object> command, { + String? workingDirectory, + Map<String, String>? environment, bool includeParentEnvironment = true, bool runInShell = false, - Encoding stdoutEncoding = systemEncoding, - Encoding stderrEncoding = systemEncoding, + // TODO(#64): Remove the `covariant` keyword. + covariant Encoding? stdoutEncoding = systemEncoding, + covariant Encoding? stderrEncoding = systemEncoding, }); /// Returns `true` if the [executable] exists and if it can be executed. - bool canRun(dynamic executable, {String workingDirectory}); + bool canRun(dynamic executable, {String? workingDirectory}); /// Kills the process with id [pid]. ///
diff --git a/pubspec.yaml b/pubspec.yaml index 658feb5..ec925cc 100644 --- a/pubspec.yaml +++ b/pubspec.yaml
@@ -1,15 +1,16 @@ name: process -version: 4.0.0-nullsafety.4 +version: 4.2.4 description: A pluggable, mockable process invocation abstraction for Dart. homepage: https://github.com/google/process.dart environment: - sdk: '>=2.12.0-0 <3.0.0' + sdk: '>=2.14.0-0 <3.0.0' dependencies: - file: '^6.0.0-nullsafety.4' - path: ^1.8.0-nullsafety.3 - platform: '^3.0.0-nullsafety.4' + file: '^6.0.0' + path: ^1.8.0 + platform: '^3.0.0' dev_dependencies: - test: ^1.16.0-nullsafety.8 + lints: ^1.0.1 + test: ^1.16.8
diff --git a/test/src/interface/common_test.dart b/test/src/interface/common_test.dart index 6a3e2df..4f53269 100644 --- a/test/src/interface/common_test.dart +++ b/test/src/interface/common_test.dart
@@ -2,9 +2,12 @@ // 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:file/file.dart'; import 'package:file/memory.dart'; import 'package:platform/platform.dart'; +import 'package:process/process.dart'; import 'package:process/src/interface/common.dart'; import 'package:test/test.dart'; @@ -24,8 +27,9 @@ } tearDown(() { - <Directory>[workingDir, dir1, dir2, dir3] - .forEach((Directory d) => d.deleteSync(recursive: true)); + for (var directory in <Directory>[workingDir, dir1, dir2, dir3]) { + directory.deleteSync(recursive: true); + } }); group('on windows', () { @@ -192,26 +196,56 @@ expect(executablePath, isNull); }); + test('not found with throwOnFailure throws exception with match state', + () { + String command = 'foo.exe'; + io.ProcessException error; + try { + getExecutablePath( + command, + workingDir.path, + platform: platform, + fs: fs, + throwOnFailure: true, + ); + fail('Expected to throw'); + } on io.ProcessException catch (err) { + error = err; + } + + expect(error, isA<ProcessPackageExecutableNotFoundException>()); + ProcessPackageExecutableNotFoundException notFoundException = + error as ProcessPackageExecutableNotFoundException; + expect(notFoundException.candidates, isEmpty); + expect(notFoundException.workingDirectory, equals(workingDir.path)); + expect( + error.toString(), + contains(' Working Directory: C:\\.tmp_rand0\\work_dir_rand0\n' + ' Search Path:\n' + ' C:\\.tmp_rand0\\dir1_rand0\n' + ' C:\\.tmp_rand0\\dir2_rand0\n')); + }); + test('when path has spaces', () { expect( - sanitizeExecutablePath('Program Files\\bla.exe', + sanitizeExecutablePath(r'Program Files\bla.exe', platform: platform), - '"Program Files\\bla.exe"'); + r'"Program Files\bla.exe"'); expect( - sanitizeExecutablePath('ProgramFiles\\bla.exe', platform: platform), - 'ProgramFiles\\bla.exe'); + sanitizeExecutablePath(r'ProgramFiles\bla.exe', platform: platform), + r'ProgramFiles\bla.exe'); expect( - sanitizeExecutablePath('"Program Files\\bla.exe"', + sanitizeExecutablePath(r'"Program Files\bla.exe"', platform: platform), - '"Program Files\\bla.exe"'); + r'"Program Files\bla.exe"'); expect( - sanitizeExecutablePath('\"Program Files\\bla.exe\"', + sanitizeExecutablePath(r'"Program Files\bla.exe"', platform: platform), - '\"Program Files\\bla.exe\"'); + r'"Program Files\bla.exe"'); expect( - sanitizeExecutablePath('C:\\\"Program Files\"\\bla.exe', + sanitizeExecutablePath(r'C:\"Program Files"\bla.exe', platform: platform), - 'C:\\\"Program Files\"\\bla.exe'); + r'C:\"Program Files"\bla.exe'); }); test('with absolute path when currentDirectory getter throws', () { @@ -298,6 +332,36 @@ expect(executablePath, isNull); }); + test('not found with throwOnFailure throws exception with match state', + () { + String command = 'foo'; + io.ProcessException error; + try { + getExecutablePath( + command, + workingDir.path, + platform: platform, + fs: fs, + throwOnFailure: true, + ); + fail('Expected to throw'); + } on io.ProcessException catch (err) { + error = err; + } + + expect(error, isA<ProcessPackageExecutableNotFoundException>()); + ProcessPackageExecutableNotFoundException notFoundException = + error as ProcessPackageExecutableNotFoundException; + expect(notFoundException.candidates, isEmpty); + expect(notFoundException.workingDirectory, equals(workingDir.path)); + expect( + error.toString(), + contains(' Working Directory: /.tmp_rand0/work_dir_rand0\n' + ' Search Path:\n' + ' /.tmp_rand0/dir1_rand0\n' + ' /.tmp_rand0/dir2_rand0\n')); + }); + test('when path has spaces', () { expect( sanitizeExecutablePath('/usr/local/bin/foo bar', @@ -306,6 +370,196 @@ }); }); }); + group('Real Filesystem', () { + // These tests don't use the memory filesystem because Dart can't modify file + // executable permissions, so we have to create them with actual commands. + + 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); + }); + + 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); + }); + + test( + 'Test that finding non-executable paths throws with proper information', + () { + 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]); + + io.ProcessException error; + try { + getExecutablePath( + 'command', + tmpDir.path, + platform: platform, + fs: fs, + throwOnFailure: true, + ); + fail('Expected to throw'); + } on io.ProcessException catch (err) { + error = err; + } + + expect(error, isA<ProcessPackageExecutableNotFoundException>()); + ProcessPackageExecutableNotFoundException notFoundException = + error as ProcessPackageExecutableNotFoundException; + expect( + notFoundException.candidates, + equals(<String>[ + '${tmpDir.path}/path1/command', + '${tmpDir.path}/path2/command', + '${tmpDir.path}/path3/command', + '${tmpDir.path}/path4/command', + '${tmpDir.path}/path5/command', + ])); + expect( + error.toString(), + equals( + 'ProcessPackageExecutableNotFoundException: Found candidates, but lacked sufficient permissions to execute "command".\n' + ' Command: command\n' + ' Working Directory: ${tmpDir.path}\n' + ' Candidates:\n' + ' ${tmpDir.path}/path1/command\n' + ' ${tmpDir.path}/path2/command\n' + ' ${tmpDir.path}/path3/command\n' + ' ${tmpDir.path}/path4/command\n' + ' ${tmpDir.path}/path5/command\n' + ' Search Path:\n' + ' ${tmpDir.path}/path1\n' + ' ${tmpDir.path}/path2\n' + ' ${tmpDir.path}/path3\n' + ' ${tmpDir.path}/path4\n' + ' ${tmpDir.path}/path5\n', + ), + ); + }); + + test('Test that finding no executable paths throws with proper information', + () { + if (localPlatform.isWindows) { + // Windows doesn't check for executable-ness, and we can't run 'chmod' + // on Windows anyhow. + return; + } + + io.ProcessException error; + try { + getExecutablePath( + 'non-existent-command', + tmpDir.path, + platform: platform, + fs: fs, + throwOnFailure: true, + ); + fail('Expected to throw'); + } on io.ProcessException catch (err) { + error = err; + } + + expect(error, isA<ProcessPackageExecutableNotFoundException>()); + ProcessPackageExecutableNotFoundException notFoundException = + error as ProcessPackageExecutableNotFoundException; + expect(notFoundException.candidates, isEmpty); + expect( + error.toString(), + equals( + 'ProcessPackageExecutableNotFoundException: Failed to find "non-existent-command" in the search path.\n' + ' Command: non-existent-command\n' + ' Working Directory: ${tmpDir.path}\n' + ' Search Path:\n' + ' ${tmpDir.path}/path1\n' + ' ${tmpDir.path}/path2\n' + ' ${tmpDir.path}/path3\n' + ' ${tmpDir.path}/path4\n' + ' ${tmpDir.path}/path5\n'), + ); + }); + }); } void _expectSamePath(String? actual, String? expected) {