Add package:process (#2097)

diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index 60ddae2..a50b447 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -28,6 +28,7 @@
 pkgs/oauth2                     @dart-lang/dart-pub-team
 pkgs/package_config             @dart-lang/dart-ecosystem-team
 pkgs/pool                       @dart-lang/dart-ecosystem-team
+pkgs/process                    @dart-lang/dart-ecosystem-team
 pkgs/pub_semver                 @dart-lang/dart-pub-team
 pkgs/pubspec_parse              @dart-lang/dart-bat
 pkgs/source_maps                @dart-lang/dart-ecosystem-team
diff --git a/.github/labeler.yml b/.github/labeler.yml
index 6bdbffd..dbaf8e5 100644
--- a/.github/labeler.yml
+++ b/.github/labeler.yml
@@ -96,6 +96,10 @@
   - changed-files:
     - any-glob-to-any-file: 'pkgs/pool/**'
 
+'package:procoess':
+  - changed-files:
+    - any-glob-to-any-file: 'pkgs/procoess/**'
+
 'package:pub_semver':
   - changed-files:
     - any-glob-to-any-file: 'pkgs/pub_semver/**'
diff --git a/.github/workflows/process.yaml b/.github/workflows/process.yaml
new file mode 100644
index 0000000..9804433
--- /dev/null
+++ b/.github/workflows/process.yaml
@@ -0,0 +1,38 @@
+name: package:process
+permissions: read-all
+
+on:
+  schedule:
+    # “At 00:00 (UTC) on Sunday.”
+    - cron: '0 0 * * 0'
+  push:
+    branches: [ main ]
+    paths:
+      - '.github/workflows/process.yaml'
+      - 'pkgs/process/**'
+  pull_request:
+    branches: [ main ]
+    paths:
+      - '.github/workflows/process.yaml'
+      - 'pkgs/process/**'
+
+defaults:
+  run:
+    working-directory: pkgs/process/
+
+jobs:
+  build:
+    runs-on: ubuntu-latest
+    strategy:
+      matrix:
+        os: [ubuntu-latest]
+        sdk: [stable, dev]
+    steps:
+      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      - uses: dart-lang/setup-dart@e51d8e571e22473a2ddebf0ef8a2123f0ab2c02c
+
+      - run: dart pub get
+      - run: dart format --output=none --set-exit-if-changed .
+        if: ${{ matrix.sdk == 'stable' }}
+      - run: dart analyze --fatal-infos
+      - run: dart test
diff --git a/pkgs/process/.gitignore b/pkgs/process/.gitignore
new file mode 100644
index 0000000..1f1b0df
--- /dev/null
+++ b/pkgs/process/.gitignore
@@ -0,0 +1,3 @@
+# Don’t commit the following directories created by pub.
+.dart_tool
+pubspec.lock
diff --git a/pkgs/process/AUTHORS b/pkgs/process/AUTHORS
new file mode 100644
index 0000000..044d617
--- /dev/null
+++ b/pkgs/process/AUTHORS
@@ -0,0 +1,8 @@
+# Below is a list of people and organizations that have contributed
+# to the Process project. Names should be added to the list like so:
+#
+#   Name/Organization <email address>
+
+Google Inc.
+
+Bartek Pacia / @bartekpacia
diff --git a/pkgs/process/CHANGELOG.md b/pkgs/process/CHANGELOG.md
new file mode 100644
index 0000000..3791f21
--- /dev/null
+++ b/pkgs/process/CHANGELOG.md
@@ -0,0 +1,209 @@
+## 5.0.4
+
+* Updates minimum supported SDK version to Flutter 3.22/Dart 3.4.
+* Move the package into the `dart-lang/tools` repository.
+
+## 5.0.3
+
+* Adds `missing_code_block_language_in_doc_comment` lint.
+* Updates minimum supported SDK version to Flutter 3.19/Dart 3.3.
+
+## 5.0.2
+
+* Removes mention of the removed record/replay feature from README.
+* Updates minimum supported SDK version to Flutter 3.10/Dart 3.0.
+* Fixes new lint warnings.
+
+## 5.0.1
+
+* Transfers the package source from https://github.com/google/process.dart to
+  https://github.com/flutter/packages.
+
+## 5.0.0
+
+* Remove the `covariant` keyword from `stderrEncoding` and `stdoutEncoding`
+  parameters.
+* Update dependencies to work on Dart 3.
+* Bumped min SDK dependency to nearest non-prerelease version (2.14.0)
+
+## 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.
+
+## 4.0.0-nullsafety.2
+
+* Update supported SDK range.
+
+## 4.0.0-nullsafety.1
+
+* Migrate to null-safety.
+* Remove record/replay functionality.
+* Remove implicit casts in preparation for null-safety.
+* Remove dependency on `package:intl` and `package:meta`.
+
+## 3.0.13
+
+* Handle `currentDirectory` throwing an exception in `getExecutablePath()`.
+
+## 3.0.12
+
+* Updated version constraint on intl.
+
+## 3.0.11
+
+* Fix bug: don't add quotes if the file name already has quotes.
+
+## 3.0.10
+
+* Added quoted strings to indicate where the command name ends and the arguments
+begin otherwise, the file name is ambiguous on Windows.
+
+## 3.0.9
+
+* Fixed bug in `ProcessWrapper`
+
+## 3.0.8
+
+* Fixed bug in `ProcessWrapper`
+
+## 3.0.7
+
+* Renamed `Process` to `ProcessWrapper`
+
+## 3.0.6
+
+* Added class `Process`, a simple wrapper around dart:io's `Process` class.
+
+## 3.0.5
+
+* Fixes for missing_return analysis errors with 2.10.0-dev.1.0.
+
+## 3.0.4
+
+* Fix unit tests
+* Update SDK constraint to 3.
+
+## 3.0.3
+
+* Update dependency on `package:file`
+
+## 3.0.2
+
+* Remove upper case constants.
+* Update SDK constraint to 2.0.0-dev.54.0.
+* Fix tests for Dart 2.
+
+## 3.0.1
+
+* General cleanup
+
+## 3.0.0
+
+* Cleanup getExecutablePath() to better respect the platform
+
+## 2.0.9
+
+* Bumped `package:file` dependency
+
+### 2.0.8
+
+* Fixed method getArguments to qualify the map method with the specific
+  String type
+
+### 2.0.7
+
+* Remove `set exitCode` instances
+
+### 2.0.6
+
+* Fix SDK constraint.
+* rename .analysis_options file to analaysis_options.yaml.
+* Use covariant in place of @checked.
+* Update comment style generics.
+
+### 2.0.5
+
+* Bumped maximum Dart SDK version to 2.0.0-dev.infinity
+
+### 2.0.4
+
+* relax dependency requirement for `intl`
+
+### 2.0.3
+
+* relax dependency requirement for `platform`
+
+## 2.0.2
+
+* Fix a strong mode function expression return type inference bug with Dart
+  1.23.0-dev.10.0.
+
+## 2.0.1
+
+* Fixed bug in `ReplayProcessManager` whereby it could try to write to `stdout`
+  or `stderr` after the streams were closed.
+
+## 2.0.0
+
+* Bumped `package:file` dependency to 2.0.1
+
+## 1.1.0
+
+* Added support to transparently find the right executable under Windows.
+
+## 1.0.1
+
+* The `executable` and `arguments` parameters have been merged into one
+  `command` parameter in the `run`, `runSync`, and `start` methods of
+  `ProcessManager`.
+* Added support for sanitization of command elements in
+  `RecordingProcessManager` and `ReplayProcessManager` via the `CommandElement`
+  class.
+
+## 1.0.0
+
+* Initial version
diff --git a/pkgs/process/LICENSE b/pkgs/process/LICENSE
new file mode 100644
index 0000000..ed771e6
--- /dev/null
+++ b/pkgs/process/LICENSE
@@ -0,0 +1,27 @@
+Copyright 2013, the Dart project authors.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+    * Redistributions of source code must retain the above copyright
+      notice, this list of conditions and the following disclaimer.
+    * Redistributions in binary form must reproduce the above
+      copyright notice, this list of conditions and the following
+      disclaimer in the documentation and/or other materials provided
+      with the distribution.
+    * Neither the name of Google LLC nor the names of its
+      contributors may be used to endorse or promote products derived
+      from this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
\ No newline at end of file
diff --git a/pkgs/process/README.md b/pkgs/process/README.md
new file mode 100644
index 0000000..9cd7dde
--- /dev/null
+++ b/pkgs/process/README.md
@@ -0,0 +1,17 @@
+[![pub package](https://img.shields.io/pub/v/process.svg)](https://pub.dev/packages/process)
+[![package publisher](https://img.shields.io/pub/publisher/process.svg)](https://pub.dev/packages/process/publisher)
+
+A pluggable, mockable process invocation abstraction for Dart.
+
+## What's this?
+
+A generic process invocation abstraction for Dart.
+
+Like `dart:io`, `package:process` supplies a rich, Dart-idiomatic API for
+spawning OS processes.
+
+Unlike `dart:io`, `package:process` requires processes to be started with
+[ProcessManager], which allows for easy mocking and testing of code that
+spawns processes in a hermetic way.
+
+[ProcessManager]: https://pub.dev/documentation/process/latest/process/ProcessManager-class.html
diff --git a/pkgs/process/dart_test.yaml b/pkgs/process/dart_test.yaml
new file mode 100644
index 0000000..91ec220
--- /dev/null
+++ b/pkgs/process/dart_test.yaml
@@ -0,0 +1 @@
+test_on: vm
diff --git a/pkgs/process/lib/process.dart b/pkgs/process/lib/process.dart
new file mode 100644
index 0000000..2618e6d
--- /dev/null
+++ b/pkgs/process/lib/process.dart
@@ -0,0 +1,8 @@
+// Copyright (c) 2013, 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.
+
+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/pkgs/process/lib/src/interface/common.dart b/pkgs/process/lib/src/interface/common.dart
new file mode 100644
index 0000000..d374631
--- /dev/null
+++ b/pkgs/process/lib/src/interface/common.dart
@@ -0,0 +1,165 @@
+// Copyright (c) 2013, 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 'package:file/file.dart';
+import 'package:file/local.dart';
+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',
+  'android': 'posix',
+  'ios': 'posix',
+  'fuchsia': 'posix',
+  'windows': 'windows',
+};
+
+/// Sanitizes the executable path on Windows.
+/// https://github.com/dart-lang/sdk/issues/37751
+String sanitizeExecutablePath(String executable,
+    {Platform platform = const LocalPlatform()}) {
+  if (executable.isEmpty) {
+    return executable;
+  }
+  if (!platform.isWindows) {
+    return executable;
+  }
+  if (executable.contains(' ') && !executable.contains('"')) {
+    // Use quoted strings to indicate where the file name ends and the arguments begin;
+    // otherwise, the file name is ambiguous.
+    return '"$executable"';
+  }
+  return executable;
+}
+
+/// 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 [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.
+///
+/// Once the list of candidate paths has been constructed, this will pick the
+/// first such path that represents an existent file.
+///
+/// Return `null` if there were no viable candidates, meaning the executable
+/// could not be found.
+///
+/// If [platform] is not specified, it will default to the current platform.
+String? getExecutablePath(
+  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 {
+    // The `currentDirectory` getter can throw a FileSystemException for example
+    // when the process doesn't have read/list permissions in each component of
+    // the cwd path. In this case, fall back on '.'.
+    workingDirectory ??= '.';
+  }
+  final Context context =
+      Context(style: fs.path.style, current: workingDirectory);
+
+  // TODO(goderbauer): refactor when github.com/google/platform.dart/issues/2
+  //     is available.
+  final String pathSeparator = platform.isWindows ? ';' : ':';
+
+  List<String> extensions = <String>[];
+  if (platform.isWindows && context.extension(executable).isEmpty) {
+    extensions = platform.environment['PATHEXT']!.split(pathSeparator);
+  }
+
+  List<String> candidates = <String>[];
+  List<String> searchPath;
+  if (executable.contains(context.separator)) {
+    // Deal with commands that specify a relative or absolute path differently.
+    searchPath = <String>[workingDirectory];
+  } else {
+    searchPath = platform.environment['PATH']!.split(pathSeparator);
+  }
+  candidates = _getCandidatePaths(executable, searchPath, extensions, context);
+  final List<String> foundCandidates = <String>[];
+  for (final String path in candidates) {
+    final File candidate = fs.file(path);
+    final 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;
+}
+
+/// Returns all possible combinations of `$searchPath\$command.$ext` for
+/// `searchPath` in [searchPaths] and `ext` in [extensions].
+///
+/// If [extensions] is empty, it will just enumerate all
+/// `$searchPath\$command`.
+/// If [command] is an absolute path, it will just enumerate
+/// `$command.$ext`.
+List<String> _getCandidatePaths(
+  String command,
+  List<String> searchPaths,
+  List<String> extensions,
+  Context context,
+) {
+  final List<String> withExtensions = extensions.isNotEmpty
+      ? extensions.map((String ext) => '$command$ext').toList()
+      : <String>[command];
+  if (context.isAbsolute(command)) {
+    return withExtensions;
+  }
+  return searchPaths
+      .map((String path) =>
+          withExtensions.map((String command) => context.join(path, command)))
+      .expand((Iterable<String> e) => e)
+      .toList()
+      .cast<String>();
+}
diff --git a/pkgs/process/lib/src/interface/exceptions.dart b/pkgs/process/lib/src/interface/exceptions.dart
new file mode 100644
index 0000000..dec80d3
--- /dev/null
+++ b/pkgs/process/lib/src/interface/exceptions.dart
@@ -0,0 +1,101 @@
+// Copyright (c) 2013, 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(
+    super.executable, {
+    super.arguments,
+    super.message,
+    super.errorCode,
+    super.workingDirectory,
+    this.candidates = const <String>[],
+    this.searchPath = const <String>[],
+  });
+
+  /// 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() {
+    final StringBuffer buffer =
+        StringBuffer('ProcessPackageExecutableNotFoundException: $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/pkgs/process/lib/src/interface/local_process_manager.dart b/pkgs/process/lib/src/interface/local_process_manager.dart
new file mode 100644
index 0000000..ab7e96a
--- /dev/null
+++ b/pkgs/process/lib/src/interface/local_process_manager.dart
@@ -0,0 +1,152 @@
+// Copyright (c) 2013, 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:convert';
+import 'dart:io'
+    show
+        Process,
+        ProcessException,
+        ProcessResult,
+        ProcessSignal,
+        ProcessStartMode,
+        systemEncoding;
+
+import 'common.dart';
+import 'exceptions.dart';
+import 'process_manager.dart';
+
+/// Local implementation of the `ProcessManager` interface.
+///
+/// This implementation delegates directly to the corresponding static methods
+/// in `dart:io`.
+///
+/// All methods that take a `command` will run `toString()` on the command
+/// elements to derive the executable and arguments that should be passed to
+/// the underlying `dart:io` methods. Thus, the degenerate case of
+/// `List<String>` will trivially work as expected.
+class LocalProcessManager implements ProcessManager {
+  /// Creates a new `LocalProcessManager`.
+  const LocalProcessManager();
+
+  @override
+  Future<Process> start(
+    List<Object> command, {
+    String? workingDirectory,
+    Map<String, String>? environment,
+    bool includeParentEnvironment = true,
+    bool runInShell = false,
+    ProcessStartMode mode = ProcessStartMode.normal,
+  }) {
+    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(
+    List<Object> command, {
+    String? workingDirectory,
+    Map<String, String>? environment,
+    bool includeParentEnvironment = true,
+    bool runInShell = false,
+    Encoding? stdoutEncoding = systemEncoding,
+    Encoding? stderrEncoding = systemEncoding,
+  }) {
+    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(
+    List<Object> command, {
+    String? workingDirectory,
+    Map<String, String>? environment,
+    bool includeParentEnvironment = true,
+    bool runInShell = false,
+    Encoding? stdoutEncoding = systemEncoding,
+    Encoding? stderrEncoding = systemEncoding,
+  }) {
+    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;
+
+  @override
+  bool killPid(int pid, [ProcessSignal signal = ProcessSignal.sigterm]) {
+    return Process.killPid(pid, signal);
+  }
+}
+
+String _getExecutable(
+    List<dynamic> command, String? workingDirectory, bool runInShell) {
+  final String commandName = command.first.toString();
+  if (runInShell) {
+    return commandName;
+  }
+  return getExecutablePath(
+    commandName,
+    workingDirectory,
+    throwOnFailure: true,
+  )!;
+}
+
+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
+        .skip(1)
+        .map<String>((dynamic element) => element.toString())
+        .toList();
diff --git a/pkgs/process/lib/src/interface/process_manager.dart b/pkgs/process/lib/src/interface/process_manager.dart
new file mode 100644
index 0000000..69f6a2a
--- /dev/null
+++ b/pkgs/process/lib/src/interface/process_manager.dart
@@ -0,0 +1,187 @@
+// Copyright (c) 2013, 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:convert';
+import 'dart:io'
+    show
+        Process,
+        ProcessResult,
+        ProcessSignal,
+        ProcessStartMode,
+        systemEncoding;
+
+/// Manages the creation of abstract processes.
+///
+/// Using instances of this class provides level of indirection from the static
+/// methods in the [Process] class, which in turn allows the underlying
+/// implementation to be mocked out or decorated for testing and debugging
+/// purposes.
+abstract class ProcessManager {
+  /// Starts a process by running the specified [command].
+  ///
+  /// The first element in [command] will be treated as the executable to run,
+  /// with subsequent elements being passed as arguments to the executable. It
+  /// is left to implementations to decide what element types they support in
+  /// the [command] list.
+  ///
+  /// Returns a `Future<Process>` that completes with a Process instance when
+  /// the process has been successfully started. That [Process] object can be
+  /// used to interact with the process. If the process cannot be started, the
+  /// returned [Future] completes with an exception.
+  ///
+  /// Use [workingDirectory] to set the working directory for the process. Note
+  /// that the change of directory occurs before executing the process on some
+  /// platforms, which may have impact when using relative paths for the
+  /// executable and the arguments.
+  ///
+  /// Use [environment] to set the environment variables for the process. If not
+  /// set, the environment of the parent process is inherited. Currently, only
+  /// US-ASCII environment variables are supported and errors are likely to occur
+  /// if an environment variable with code-points outside the US-ASCII range is
+  /// passed in.
+  ///
+  /// If [includeParentEnvironment] is `true`, the process's environment will
+  /// include the parent process's environment, with [environment] taking
+  /// precedence. Default is `true`.
+  ///
+  /// If [runInShell] is `true`, the process will be spawned through a system
+  /// shell. On Linux and OS X, `/bin/sh` is used, while
+  /// `%WINDIR%\system32\cmd.exe` is used on Windows.
+  ///
+  /// Users must read all data coming on the `stdout` and `stderr`
+  /// streams of processes started with [start]. If the user
+  /// does not read all data on the streams the underlying system
+  /// resources will not be released since there is still pending data.
+  ///
+  /// The following code uses `start` to grep for `main` in the
+  /// file `test.dart` on Linux.
+  ///
+  /// ```dart
+  /// ProcessManager mgr = new LocalProcessManager();
+  /// mgr.start(['grep', '-i', 'main', 'test.dart']).then((process) {
+  ///   stdout.addStream(process.stdout);
+  ///   stderr.addStream(process.stderr);
+  /// });
+  /// ```
+  ///
+  /// If [mode] is [ProcessStartMode.normal] (the default) a child
+  /// process will be started with `stdin`, `stdout` and `stderr`
+  /// connected.
+  ///
+  /// If `mode` is [ProcessStartMode.detached] a detached process will
+  /// be created. A detached process has no connection to its parent,
+  /// and can keep running on its own when the parent dies. The only
+  /// information available from a detached process is its `pid`. There
+  /// is no connection to its `stdin`, `stdout` or `stderr`, nor will
+  /// the process' exit code become available when it terminates.
+  ///
+  /// If `mode` is [ProcessStartMode.detachedWithStdio] a detached
+  /// process will be created where the `stdin`, `stdout` and `stderr`
+  /// are connected. The creator can communicate with the child through
+  /// these. The detached process will keep running even if these
+  /// communication channels are closed. The process' exit code will
+  /// not become available when it terminated.
+  ///
+  /// The default value for `mode` is `ProcessStartMode.normal`.
+  Future<Process> start(
+    List<Object> command, {
+    String? workingDirectory,
+    Map<String, String>? environment,
+    bool includeParentEnvironment = true,
+    bool runInShell = false,
+    ProcessStartMode mode = ProcessStartMode.normal,
+  });
+
+  /// Starts a process and runs it non-interactively to completion.
+  ///
+  /// The first element in [command] will be treated as the executable to run,
+  /// with subsequent elements being passed as arguments to the executable. It
+  /// is left to implementations to decide what element types they support in
+  /// the [command] list.
+  ///
+  /// Use [workingDirectory] to set the working directory for the process. Note
+  /// that the change of directory occurs before executing the process on some
+  /// platforms, which may have impact when using relative paths for the
+  /// executable and the arguments.
+  ///
+  /// Use [environment] to set the environment variables for the process. If not
+  /// set the environment of the parent process is inherited. Currently, only
+  /// US-ASCII environment variables are supported and errors are likely to occur
+  /// if an environment variable with code-points outside the US-ASCII range is
+  /// passed in.
+  ///
+  /// If [includeParentEnvironment] is `true`, the process's environment will
+  /// include the parent process's environment, with [environment] taking
+  /// precedence. Default is `true`.
+  ///
+  /// If [runInShell] is true, the process will be spawned through a system
+  /// shell. On Linux and OS X, `/bin/sh` is used, while
+  /// `%WINDIR%\system32\cmd.exe` is used on Windows.
+  ///
+  /// The encoding used for decoding `stdout` and `stderr` into text is
+  /// controlled through [stdoutEncoding] and [stderrEncoding]. The
+  /// default encoding is [systemEncoding]. If `null` is used no
+  /// decoding will happen and the [ProcessResult] will hold binary
+  /// data.
+  ///
+  /// Returns a `Future<ProcessResult>` that completes with the
+  /// result of running the process, i.e., exit code, standard out and
+  /// standard in.
+  ///
+  /// The following code uses `run` to grep for `main` in the
+  /// file `test.dart` on Linux.
+  ///
+  /// ```dart
+  /// ProcessManager mgr = new LocalProcessManager();
+  /// mgr.run('grep', ['-i', 'main', 'test.dart']).then((result) {
+  ///   stdout.write(result.stdout);
+  ///   stderr.write(result.stderr);
+  /// });
+  /// ```
+  Future<ProcessResult> run(
+    List<Object> command, {
+    String? workingDirectory,
+    Map<String, String>? environment,
+    bool includeParentEnvironment = true,
+    bool runInShell = false,
+    Encoding? stdoutEncoding = systemEncoding,
+    Encoding? stderrEncoding = systemEncoding,
+  });
+
+  /// Starts a process and runs it to completion. This is a synchronous
+  /// call and will block until the child process terminates.
+  ///
+  /// The arguments are the same as for [run]`.
+  ///
+  /// Returns a `ProcessResult` with the result of running the process,
+  /// i.e., exit code, standard out and standard in.
+  ProcessResult runSync(
+    List<Object> command, {
+    String? workingDirectory,
+    Map<String, String>? environment,
+    bool includeParentEnvironment = true,
+    bool runInShell = false,
+    Encoding? stdoutEncoding = systemEncoding,
+    Encoding? stderrEncoding = systemEncoding,
+  });
+
+  /// Returns `true` if the [executable] exists and if it can be executed.
+  bool canRun(dynamic executable, {String? workingDirectory});
+
+  /// Kills the process with id [pid].
+  ///
+  /// Where possible, sends the [signal] to the process with id
+  /// `pid`. This includes Linux and OS X. The default signal is
+  /// [ProcessSignal.sigterm] which will normally terminate the
+  /// process.
+  ///
+  /// On platforms without signal support, including Windows, the call
+  /// just terminates the process with id `pid` in a platform specific
+  /// way, and the `signal` parameter is ignored.
+  ///
+  /// Returns `true` if the signal is successfully delivered to the
+  /// process. Otherwise the signal could not be sent, usually meaning
+  /// that the process is already dead.
+  bool killPid(int pid, [ProcessSignal signal = ProcessSignal.sigterm]);
+}
diff --git a/pkgs/process/lib/src/interface/process_wrapper.dart b/pkgs/process/lib/src/interface/process_wrapper.dart
new file mode 100644
index 0000000..9e53071
--- /dev/null
+++ b/pkgs/process/lib/src/interface/process_wrapper.dart
@@ -0,0 +1,83 @@
+// Copyright (c) 2013, 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:async';
+import 'dart:io' as io;
+
+/// A wrapper around an [io.Process] class that adds some convenience methods.
+class ProcessWrapper implements io.Process {
+  /// Constructs a [ProcessWrapper] object that delegates to the specified
+  /// underlying object.
+  ProcessWrapper(this._delegate)
+      : _stdout = StreamController<List<int>>(),
+        _stderr = StreamController<List<int>>(),
+        _stdoutDone = Completer<void>(),
+        _stderrDone = Completer<void>() {
+    _monitorStdioStream(_delegate.stdout, _stdout, _stdoutDone);
+    _monitorStdioStream(_delegate.stderr, _stderr, _stderrDone);
+  }
+
+  final io.Process _delegate;
+  final StreamController<List<int>> _stdout;
+  final StreamController<List<int>> _stderr;
+  final Completer<void> _stdoutDone;
+  final Completer<void> _stderrDone;
+
+  /// Listens to the specified [stream], repeating events on it via
+  /// [controller], and completing [completer] once the stream is done.
+  void _monitorStdioStream(
+    Stream<List<int>> stream,
+    StreamController<List<int>> controller,
+    Completer<void> completer,
+  ) {
+    stream.listen(
+      controller.add,
+      onError: controller.addError,
+      onDone: () {
+        controller.close();
+        completer.complete();
+      },
+    );
+  }
+
+  @override
+  Future<int> get exitCode => _delegate.exitCode;
+
+  /// A [Future] that completes when the process has exited and its standard
+  /// output and error streams have closed.
+  ///
+  /// This exists as an alternative to [exitCode], which does not guarantee
+  /// that the stdio streams have closed (it is possible for the exit code to
+  /// be available before stdout and stderr have closed).
+  ///
+  /// The future returned here will complete with the exit code of the process.
+  Future<int> get done async {
+    late int result;
+    await Future.wait<void>(<Future<void>>[
+      _stdoutDone.future,
+      _stderrDone.future,
+      _delegate.exitCode.then((int value) {
+        result = value;
+      }),
+    ]);
+    return result;
+  }
+
+  @override
+  bool kill([io.ProcessSignal signal = io.ProcessSignal.sigterm]) {
+    return _delegate.kill(signal);
+  }
+
+  @override
+  int get pid => _delegate.pid;
+
+  @override
+  io.IOSink get stdin => _delegate.stdin;
+
+  @override
+  Stream<List<int>> get stdout => _stdout.stream;
+
+  @override
+  Stream<List<int>> get stderr => _stderr.stream;
+}
diff --git a/pkgs/process/pubspec.yaml b/pkgs/process/pubspec.yaml
new file mode 100644
index 0000000..38ad51c
--- /dev/null
+++ b/pkgs/process/pubspec.yaml
@@ -0,0 +1,19 @@
+name: process
+description: A pluggable, mockable process invocation abstraction for Dart.
+version: 5.0.4
+repository: https://github.com/dart-lang/tools/tree/main/pkgs/process
+issue_tracker: https://github.com/dart-lang/tools/issues?q=is%3Aissue+is%3Aopen+label%3Apackage%3Aprocess
+
+topics:
+  - process
+
+environment:
+  sdk: ^3.4.0
+
+dependencies:
+  file: '>=6.0.0 <8.0.0'
+  path: ^1.8.0
+  platform: '^3.0.0'
+
+dev_dependencies:
+  test: ^1.16.8
diff --git a/pkgs/process/test/src/interface/common_test.dart b/pkgs/process/test/src/interface/common_test.dart
new file mode 100644
index 0000000..8290b5e
--- /dev/null
+++ b/pkgs/process/test/src/interface/common_test.dart
@@ -0,0 +1,589 @@
+// Copyright (c) 2013, 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' as io;
+
+import 'package:file/file.dart';
+import 'package:file/local.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';
+
+void main() {
+  group('getExecutablePath', () {
+    late FileSystem fs;
+    late Directory workingDir, dir1, dir2, dir3;
+
+    void initialize(FileSystemStyle style) {
+      setUp(() {
+        fs = MemoryFileSystem(style: style);
+        workingDir = fs.systemTempDirectory.createTempSync('work_dir_');
+        dir1 = fs.systemTempDirectory.createTempSync('dir1_');
+        dir2 = fs.systemTempDirectory.createTempSync('dir2_');
+        dir3 = fs.systemTempDirectory.createTempSync('dir3_');
+      });
+    }
+
+    tearDown(() {
+      for (final Directory directory in <Directory>[
+        workingDir,
+        dir1,
+        dir2,
+        dir3
+      ]) {
+        directory.deleteSync(recursive: true);
+      }
+    });
+
+    group('on windows', () {
+      late Platform platform;
+
+      initialize(FileSystemStyle.windows);
+
+      setUp(() {
+        platform = FakePlatform(
+          operatingSystem: 'windows',
+          environment: <String, String>{
+            'PATH': '${dir1.path};${dir2.path}',
+            'PATHEXT': '.exe;.bat'
+          },
+        );
+      });
+
+      test('absolute', () {
+        String command = fs.path.join(dir3.path, 'bla.exe');
+        final String expectedPath = command;
+        fs.file(command).createSync();
+
+        String? executablePath = getExecutablePath(
+          command,
+          workingDir.path,
+          platform: platform,
+          fs: fs,
+        );
+        _expectSamePath(executablePath, expectedPath);
+
+        command = fs.path.withoutExtension(command);
+        executablePath = getExecutablePath(
+          command,
+          workingDir.path,
+          platform: platform,
+          fs: fs,
+        );
+        _expectSamePath(executablePath, expectedPath);
+      });
+
+      test('in path', () {
+        String command = 'bla.exe';
+        final String expectedPath = fs.path.join(dir2.path, command);
+        fs.file(expectedPath).createSync();
+
+        String? executablePath = getExecutablePath(
+          command,
+          workingDir.path,
+          platform: platform,
+          fs: fs,
+        );
+        _expectSamePath(executablePath, expectedPath);
+
+        command = fs.path.withoutExtension(command);
+        executablePath = getExecutablePath(
+          command,
+          workingDir.path,
+          platform: platform,
+          fs: fs,
+        );
+        _expectSamePath(executablePath, expectedPath);
+      });
+
+      test('in path multiple times', () {
+        String command = 'bla.exe';
+        final String expectedPath = fs.path.join(dir1.path, command);
+        final String wrongPath = fs.path.join(dir2.path, command);
+        fs.file(expectedPath).createSync();
+        fs.file(wrongPath).createSync();
+
+        String? executablePath = getExecutablePath(
+          command,
+          workingDir.path,
+          platform: platform,
+          fs: fs,
+        );
+        _expectSamePath(executablePath, expectedPath);
+
+        command = fs.path.withoutExtension(command);
+        executablePath = getExecutablePath(
+          command,
+          workingDir.path,
+          platform: platform,
+          fs: fs,
+        );
+        _expectSamePath(executablePath, expectedPath);
+      });
+
+      test('in subdir of work dir', () {
+        String command = fs.path.join('.', 'foo', 'bla.exe');
+        final String expectedPath = fs.path.join(workingDir.path, command);
+        fs.file(expectedPath).createSync(recursive: true);
+
+        String? executablePath = getExecutablePath(
+          command,
+          workingDir.path,
+          platform: platform,
+          fs: fs,
+        );
+        _expectSamePath(executablePath, expectedPath);
+
+        command = fs.path.withoutExtension(command);
+        executablePath = getExecutablePath(
+          command,
+          workingDir.path,
+          platform: platform,
+          fs: fs,
+        );
+        _expectSamePath(executablePath, expectedPath);
+      });
+
+      test('in work dir', () {
+        String command = fs.path.join('.', 'bla.exe');
+        final String expectedPath = fs.path.join(workingDir.path, command);
+        final String wrongPath = fs.path.join(dir2.path, command);
+        fs.file(expectedPath).createSync();
+        fs.file(wrongPath).createSync();
+
+        String? executablePath = getExecutablePath(
+          command,
+          workingDir.path,
+          platform: platform,
+          fs: fs,
+        );
+        _expectSamePath(executablePath, expectedPath);
+
+        command = fs.path.withoutExtension(command);
+        executablePath = getExecutablePath(
+          command,
+          workingDir.path,
+          platform: platform,
+          fs: fs,
+        );
+        _expectSamePath(executablePath, expectedPath);
+      });
+
+      test('with multiple extensions', () {
+        const String command = 'foo';
+        final String expectedPath = fs.path.join(dir1.path, '$command.exe');
+        final String wrongPath1 = fs.path.join(dir1.path, '$command.bat');
+        final String wrongPath2 = fs.path.join(dir2.path, '$command.exe');
+        fs.file(expectedPath).createSync();
+        fs.file(wrongPath1).createSync();
+        fs.file(wrongPath2).createSync();
+
+        final String? executablePath = getExecutablePath(
+          command,
+          workingDir.path,
+          platform: platform,
+          fs: fs,
+        );
+        _expectSamePath(executablePath, expectedPath);
+      });
+
+      test('not found', () {
+        const String command = 'foo.exe';
+
+        final String? executablePath = getExecutablePath(
+          command,
+          workingDir.path,
+          platform: platform,
+          fs: fs,
+        );
+        expect(executablePath, isNull);
+      });
+
+      test('not found with throwOnFailure throws exception with match state',
+          () {
+        const String command = 'foo.exe';
+        expect(
+            () => getExecutablePath(
+                  command,
+                  workingDir.path,
+                  platform: platform,
+                  fs: fs,
+                  throwOnFailure: true,
+                ),
+            throwsA(isA<ProcessPackageExecutableNotFoundException>()
+                .having(
+                    (ProcessPackageExecutableNotFoundException
+                            notFoundException) =>
+                        notFoundException.candidates,
+                    'candidates',
+                    isEmpty)
+                .having(
+                    (ProcessPackageExecutableNotFoundException
+                            notFoundException) =>
+                        notFoundException.workingDirectory,
+                    'workingDirectory',
+                    equals(workingDir.path))
+                .having(
+                    (ProcessPackageExecutableNotFoundException
+                            notFoundException) =>
+                        notFoundException.toString(),
+                    '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(r'Program Files\bla.exe',
+                platform: platform),
+            r'"Program Files\bla.exe"');
+        expect(
+            sanitizeExecutablePath(r'ProgramFiles\bla.exe', platform: platform),
+            r'ProgramFiles\bla.exe');
+        expect(
+            sanitizeExecutablePath(r'"Program Files\bla.exe"',
+                platform: platform),
+            r'"Program Files\bla.exe"');
+        expect(
+            sanitizeExecutablePath(r'"Program Files\bla.exe"',
+                platform: platform),
+            r'"Program Files\bla.exe"');
+        expect(
+            sanitizeExecutablePath(r'C:\"Program Files"\bla.exe',
+                platform: platform),
+            r'C:\"Program Files"\bla.exe');
+      });
+
+      test('with absolute path when currentDirectory getter throws', () {
+        final FileSystem fsNoCwd = MemoryFileSystemNoCwd(fs);
+        final String command = fs.path.join(dir3.path, 'bla.exe');
+        final String expectedPath = command;
+        fs.file(command).createSync();
+
+        final String? executablePath = getExecutablePath(
+          command,
+          null,
+          platform: platform,
+          fs: fsNoCwd,
+        );
+        _expectSamePath(executablePath, expectedPath);
+      });
+
+      test('with relative path when currentDirectory getter throws', () {
+        final FileSystem fsNoCwd = MemoryFileSystemNoCwd(fs);
+        final String command = fs.path.join('.', 'bla.exe');
+
+        final String? executablePath = getExecutablePath(
+          command,
+          null,
+          platform: platform,
+          fs: fsNoCwd,
+        );
+        expect(executablePath, isNull);
+      });
+    });
+
+    group('on Linux', () {
+      late Platform platform;
+
+      initialize(FileSystemStyle.posix);
+
+      setUp(() {
+        platform = FakePlatform(
+            operatingSystem: 'linux',
+            environment: <String, String>{'PATH': '${dir1.path}:${dir2.path}'});
+      });
+
+      test('absolute', () {
+        final String command = fs.path.join(dir3.path, 'bla');
+        final String expectedPath = command;
+        final String wrongPath = fs.path.join(dir3.path, 'bla.bat');
+        fs.file(command).createSync();
+        fs.file(wrongPath).createSync();
+
+        final String? executablePath = getExecutablePath(
+          command,
+          workingDir.path,
+          platform: platform,
+          fs: fs,
+        );
+        _expectSamePath(executablePath, expectedPath);
+      });
+
+      test('in path multiple times', () {
+        const String command = 'xxx';
+        final String expectedPath = fs.path.join(dir1.path, command);
+        final String wrongPath = fs.path.join(dir2.path, command);
+        fs.file(expectedPath).createSync();
+        fs.file(wrongPath).createSync();
+
+        final String? executablePath = getExecutablePath(
+          command,
+          workingDir.path,
+          platform: platform,
+          fs: fs,
+        );
+        _expectSamePath(executablePath, expectedPath);
+      });
+
+      test('not found', () {
+        const String command = 'foo';
+
+        final String? executablePath = getExecutablePath(
+          command,
+          workingDir.path,
+          platform: platform,
+          fs: fs,
+        );
+        expect(executablePath, isNull);
+      });
+
+      test('not found with throwOnFailure throws exception with match state',
+          () {
+        const String command = 'foo';
+        expect(
+            () => getExecutablePath(
+                  command,
+                  workingDir.path,
+                  platform: platform,
+                  fs: fs,
+                  throwOnFailure: true,
+                ),
+            throwsA(isA<ProcessPackageExecutableNotFoundException>()
+                .having(
+                    (ProcessPackageExecutableNotFoundException
+                            notFoundException) =>
+                        notFoundException.candidates,
+                    'candidates',
+                    isEmpty)
+                .having(
+                    (ProcessPackageExecutableNotFoundException
+                            notFoundException) =>
+                        notFoundException.workingDirectory,
+                    'workingDirectory',
+                    equals(workingDir.path))
+                .having(
+                    (ProcessPackageExecutableNotFoundException
+                            notFoundException) =>
+                        notFoundException.toString(),
+                    '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',
+                platform: platform),
+            '/usr/local/bin/foo bar');
+      });
+    });
+  });
+  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 = const 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]);
+
+      final 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]);
+
+      expect(
+          () => getExecutablePath(
+                'command',
+                tmpDir.path,
+                platform: platform,
+                fs: fs,
+                throwOnFailure: true,
+              ),
+          throwsA(isA<ProcessPackageExecutableNotFoundException>()
+              .having(
+                  (ProcessPackageExecutableNotFoundException
+                          notFoundException) =>
+                      notFoundException.candidates,
+                  'candidates',
+                  equals(<String>[
+                    '${tmpDir.path}/path1/command',
+                    '${tmpDir.path}/path2/command',
+                    '${tmpDir.path}/path3/command',
+                    '${tmpDir.path}/path4/command',
+                    '${tmpDir.path}/path5/command',
+                  ]))
+              .having(
+                  (ProcessPackageExecutableNotFoundException
+                          notFoundException) =>
+                      notFoundException.toString(),
+                  'toString',
+                  contains(
+                      '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;
+      }
+
+      expect(
+          () => getExecutablePath(
+                'non-existent-command',
+                tmpDir.path,
+                platform: platform,
+                fs: fs,
+                throwOnFailure: true,
+              ),
+          throwsA(isA<ProcessPackageExecutableNotFoundException>()
+              .having(
+                  (ProcessPackageExecutableNotFoundException
+                          notFoundException) =>
+                      notFoundException.candidates,
+                  'candidates',
+                  isEmpty)
+              .having(
+                  (ProcessPackageExecutableNotFoundException
+                          notFoundException) =>
+                      notFoundException.toString(),
+                  'toString',
+                  contains(
+                      '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) {
+  expect(actual, isNotNull);
+  expect(actual!.toLowerCase(), expected!.toLowerCase());
+}
+
+class MemoryFileSystemNoCwd extends ForwardingFileSystem {
+  MemoryFileSystemNoCwd(super.delegate);
+
+  @override
+  Directory get currentDirectory {
+    throw const FileSystemException('Access denied');
+  }
+}
diff --git a/pkgs/process/test/src/interface/process_wrapper_test.dart b/pkgs/process/test/src/interface/process_wrapper_test.dart
new file mode 100644
index 0000000..27f8cfa
--- /dev/null
+++ b/pkgs/process/test/src/interface/process_wrapper_test.dart
@@ -0,0 +1,116 @@
+// Copyright (c) 2013, 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:async';
+import 'dart:convert';
+import 'dart:io' as io;
+
+import 'package:process/process.dart';
+import 'package:test/test.dart';
+
+void main() {
+  group('ProcessWrapper', () {
+    late TestProcess delegate;
+    late ProcessWrapper process;
+
+    setUp(() {
+      delegate = TestProcess();
+      process = ProcessWrapper(delegate);
+    });
+
+    group('done', () {
+      late bool done;
+
+      setUp(() {
+        done = false;
+        // ignore: unawaited_futures
+        process.done.then((int result) {
+          done = true;
+        });
+      });
+
+      test('completes only when all done', () async {
+        expect(done, isFalse);
+        delegate.exitCodeCompleter.complete(0);
+        await Future<void>.value();
+        expect(done, isFalse);
+        await delegate.stdoutController.close();
+        await Future<void>.value();
+        expect(done, isFalse);
+        await delegate.stderrController.close();
+        await Future<void>.value();
+        expect(done, isTrue);
+        expect(await process.exitCode, 0);
+      });
+
+      test('works in conjunction with subscribers to stdio streams', () async {
+        process.stdout
+            .transform<String>(utf8.decoder)
+            .transform<String>(const LineSplitter())
+            // ignore: avoid_print
+            .listen(print);
+        delegate.exitCodeCompleter.complete(0);
+        await delegate.stdoutController.close();
+        await delegate.stderrController.close();
+        await Future<void>.value();
+        expect(done, isTrue);
+      });
+    });
+
+    group('stdio', () {
+      test('streams properly close', () async {
+        Future<void> testStream(
+          Stream<List<int>> stream,
+          StreamController<List<int>> controller,
+          String name,
+        ) async {
+          bool closed = false;
+          stream.listen(
+            (_) {},
+            onDone: () {
+              closed = true;
+            },
+          );
+          await controller.close();
+          await Future<void>.value();
+          expect(closed, isTrue, reason: 'for $name');
+        }
+
+        await testStream(process.stdout, delegate.stdoutController, 'stdout');
+        await testStream(process.stderr, delegate.stderrController, 'stderr');
+      });
+    });
+  });
+}
+
+class TestProcess implements io.Process {
+  TestProcess([this.pid = 123])
+      : exitCodeCompleter = Completer<int>(),
+        stdoutController = StreamController<List<int>>(),
+        stderrController = StreamController<List<int>>();
+
+  @override
+  final int pid;
+  final Completer<int> exitCodeCompleter;
+  final StreamController<List<int>> stdoutController;
+  final StreamController<List<int>> stderrController;
+
+  @override
+  Future<int> get exitCode => exitCodeCompleter.future;
+
+  @override
+  bool kill([io.ProcessSignal signal = io.ProcessSignal.sigterm]) {
+    exitCodeCompleter.complete(-1);
+    return true;
+  }
+
+  @override
+  Stream<List<int>> get stderr => stderrController.stream;
+
+  @override
+  io.IOSink get stdin => throw UnsupportedError('Not supported');
+
+  @override
+  Stream<List<int>> get stdout => stdoutController.stream;
+}
diff --git a/pkgs/process/test/utils.dart b/pkgs/process/test/utils.dart
new file mode 100644
index 0000000..d35e3cf
--- /dev/null
+++ b/pkgs/process/test/utils.dart
@@ -0,0 +1,14 @@
+// Copyright (c) 2013, 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:convert';
+
+/// Decodes a UTF8-encoded byte array into a list of Strings, where each list
+/// entry represents a line of text.
+List<String> decode(List<int> data) =>
+    const LineSplitter().convert(utf8.decode(data));
+
+/// Consumes and returns an entire stream of bytes.
+Future<List<int>> consume(Stream<List<int>> stream) =>
+    stream.expand((List<int> data) => data).toList();