Merge package:ffigen into dart-lang/native
diff --git a/.github/ISSUE_TEMPLATE/native_assets_builder.md b/.github/ISSUE_TEMPLATE/native_assets_builder.md
new file mode 100644
index 0000000..cc51ab0
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/native_assets_builder.md
@@ -0,0 +1,5 @@
+---
+name: "package:native_assets_builder"
+about: "Create a bug or file a feature request against package:native_assets_builder."
+labels: "package:native_assets_builder"
+---
\ No newline at end of file
diff --git a/.github/ISSUE_TEMPLATE/native_assets_cli.md b/.github/ISSUE_TEMPLATE/native_assets_cli.md
new file mode 100644
index 0000000..d6a22a8
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/native_assets_cli.md
@@ -0,0 +1,5 @@
+---
+name: "package:native_assets_cli"
+about: "Create a bug or file a feature request against package:native_assets_cli."
+labels: "package:native_assets_cli"
+---
\ No newline at end of file
diff --git a/.github/ISSUE_TEMPLATE/native_toolchain_c.md b/.github/ISSUE_TEMPLATE/native_toolchain_c.md
new file mode 100644
index 0000000..161bec6
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/native_toolchain_c.md
@@ -0,0 +1,5 @@
+---
+name: "package:native_toolchain_c"
+about: "Create a bug or file a feature request against package:native_toolchain_c."
+labels: "package:native_toolchain_c"
+---
\ No newline at end of file
diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml
new file mode 100644
index 0000000..439e796
--- /dev/null
+++ b/.github/dependabot.yaml
@@ -0,0 +1,10 @@
+# Dependabot configuration file.
+version: 2
+
+updates:
+  - package-ecosystem: github-actions
+    directory: /
+    schedule:
+      interval: monthly
+    labels:
+      - autosubmit
diff --git a/.github/labeler.yml b/.github/labeler.yml
new file mode 100644
index 0000000..ca96162
--- /dev/null
+++ b/.github/labeler.yml
@@ -0,0 +1,13 @@
+# This configures the .github/workflows/pull_request_label.yml workflow.
+
+'type-infra':
+  - .github/**
+
+'package:native_assets_builder':
+  - pkgs/native_assets_builder/**/*
+
+'package:native_assets_cli':
+  - pkgs/native_assets_cli/**/*
+
+'package:native_toolchain_c':
+  - pkgs/native_toolchain_c/**/*
diff --git a/.github/workflows/native.yaml b/.github/workflows/native.yaml
new file mode 100644
index 0000000..2da22ce
--- /dev/null
+++ b/.github/workflows/native.yaml
@@ -0,0 +1,139 @@
+# CI for the native_* packages.
+#
+# Combined into a single workflow so that deps are configured and installed once.
+
+name: native
+permissions: read-all
+
+on:
+  pull_request:
+    branches: [main]
+    paths:
+      - ".github/workflows/native.yaml"
+      - "pkgs/native_assets_builder/**"
+      - "pkgs/native_assets_cli/**"
+      - "pkgs/native_toolchain_c/**"
+  push:
+    branches: [main]
+    paths:
+      - ".github/workflows/native.yaml"
+      - "pkgs/native_assets_builder/**"
+      - "pkgs/native_assets_cli/**"
+      - "pkgs/native_toolchain_c/**"
+  schedule:
+    - cron: "0 0 * * 0" # weekly
+
+jobs:
+  build:
+    strategy:
+      matrix:
+        os: [ubuntu, macos, windows]
+        sdk: [stable, dev]
+        package: [native_assets_builder, native_assets_cli, native_toolchain_c]
+        # Breaking changes temporarily break the example run on the Dart SDK until native_assets_builder is rolled into the Dart SDK dev build.
+        breaking-change: [true]
+        exclude:
+          # Only run analyze against dev on one host.
+          - os: macos
+            sdk: dev
+          - os: windows
+            sdk: dev
+
+    runs-on: ${{ matrix.os }}-latest
+
+    defaults:
+      run:
+        working-directory: pkgs/${{ matrix.package }}
+
+    steps:
+      - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11
+
+      - uses: dart-lang/setup-dart@b64355ae6ca0b5d484f0106a033dd1388965d06d
+        with:
+          sdk: ${{ matrix.sdk }}
+
+      - uses: nttld/setup-ndk@3354316c3285ea90da09d047280dd79d00d5a37a
+        with:
+          ndk-version: r26b
+        if: ${{ matrix.sdk == 'stable' }}
+
+      - run: dart pub get
+
+      - run: dart pub get -C test/data/dart_app/
+        if: ${{ matrix.package == 'native_assets_builder' }}
+
+      - run: dart pub get -C test/data/native_add/
+        if: ${{ matrix.package == 'native_assets_builder' }}
+
+      - run: dart pub get -C test/data/native_add_add_source/
+        if: ${{ matrix.package == 'native_assets_builder' }}
+
+      - run: dart pub get -C test/data/native_subtract/
+        if: ${{ matrix.package == 'native_assets_builder' }}
+
+      - run: dart pub get -C test/data/package_reading_metadata/
+        if: ${{ matrix.package == 'native_assets_builder' }}
+
+      - run: dart pub get -C test/data/package_with_metadata/
+        if: ${{ matrix.package == 'native_assets_builder' }}
+
+      - run: dart pub get -C example/native_add_app/
+        if: ${{ matrix.package == 'native_assets_cli' }}
+
+      - run: dart pub get -C example/native_add_library/
+        if: ${{ matrix.package == 'native_assets_cli' }}
+
+      - run: dart analyze --fatal-infos
+        # Run on dev to ensure we're not depending on deprecated SDK things.
+
+      - run: dart format --output=none --set-exit-if-changed .
+        if: ${{ matrix.sdk == 'stable' }}
+
+      - name: Install native toolchains
+        run: sudo apt-get update && sudo apt-get install clang-15 gcc-i686-linux-gnu gcc-aarch64-linux-gnu gcc-arm-linux-gnueabihf gcc-riscv64-linux-gnu
+        if: ${{ matrix.sdk == 'stable' && matrix.os == 'ubuntu' }}
+
+      - run: dart test
+        if: ${{ matrix.sdk == 'stable' }}
+
+      - run: dart --enable-experiment=native-assets test
+        working-directory: pkgs/${{ matrix.package }}/example/native_add_app/
+        if: ${{ matrix.package == 'native_assets_cli' && matrix.sdk == 'dev' && !matrix.breaking-change }}
+
+      - run: dart --enable-experiment=native-assets run
+        working-directory: pkgs/${{ matrix.package }}/example/native_add_app/
+        if: ${{ matrix.package == 'native_assets_cli' && matrix.sdk == 'dev' && !matrix.breaking-change }}
+
+      - run: dart --enable-experiment=native-assets build bin/native_add_app.dart
+        working-directory: pkgs/${{ matrix.package }}/example/native_add_app/
+        if: ${{ matrix.package == 'native_assets_cli' && matrix.sdk == 'dev' && !matrix.breaking-change }}
+
+      - run: ./native_add_app.exe
+        working-directory: pkgs/${{ matrix.package }}/example/native_add_app/bin/native_add_app/
+        if: ${{ matrix.package == 'native_assets_cli' && matrix.sdk == 'dev' && !matrix.breaking-change }}
+
+      - name: Install coverage
+        run: dart pub global activate coverage
+        if: ${{ matrix.sdk == 'stable' }}
+
+      - name: Collect coverage
+        run: dart pub global run coverage:test_with_coverage
+        if: ${{ matrix.sdk == 'stable' }}
+
+      - name: Upload coverage
+        uses: coverallsapp/github-action@3dfc5567390f6fa9267c0ee9c251e4c8c3f18949
+        with:
+          flag-name: ${{ matrix.package }}-${{ matrix.os }}
+          github-token: ${{ secrets.GITHUB_TOKEN }}
+          parallel: true
+        if: ${{ matrix.sdk == 'stable' }}
+
+  coverage-finished:
+    needs: [build]
+    runs-on: ubuntu-latest
+    steps:
+      - name: Upload coverage
+        uses: coverallsapp/github-action@3dfc5567390f6fa9267c0ee9c251e4c8c3f18949
+        with:
+          github-token: ${{ secrets.GITHUB_TOKEN }}
+          parallel-finished: true
diff --git a/.github/workflows/native_toolchain_c.yaml b/.github/workflows/native_toolchain_c.yaml
new file mode 100644
index 0000000..ceb668b
--- /dev/null
+++ b/.github/workflows/native_toolchain_c.yaml
@@ -0,0 +1,60 @@
+# Workflow that runs relevant tests with the clang from the Dart SDK.
+
+name: native_toolchain_c
+permissions: read-all
+
+on:
+  pull_request:
+    branches: [main]
+    paths:
+      - ".github/workflows/native_toolchain_c.yaml"
+      - "pkgs/native_toolchain_c/**"
+  push:
+    branches: [main]
+    paths:
+      - ".github/workflows/native_toolchain_c.yaml"
+      - "pkgs/native_toolchain_c/**"
+  schedule:
+    - cron: "0 0 * * 0" # weekly
+
+jobs:
+  dart-sdk-clang:
+    strategy:
+      matrix:
+        os: [ubuntu]
+        sdk: [stable]
+        package: [native_toolchain_c]
+
+    runs-on: ${{ matrix.os }}-latest
+
+    defaults:
+      run:
+        working-directory: pkgs/${{ matrix.package }}
+
+    steps:
+      - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11
+
+      - uses: dart-lang/setup-dart@b64355ae6ca0b5d484f0106a033dd1388965d06d
+        with:
+          sdk: ${{ matrix.sdk }}
+
+      - uses: nttld/setup-ndk@3354316c3285ea90da09d047280dd79d00d5a37a
+        with:
+          ndk-version: r26b
+        if: ${{ matrix.sdk == 'stable' }}
+
+      - run: dart pub get
+
+      - name: Install native toolchains
+        run: sudo apt-get update && sudo apt-get install gcc-i686-linux-gnu gcc-aarch64-linux-gnu gcc-arm-linux-gnueabihf gcc-riscv64-linux-gnu
+        if: ${{ matrix.sdk == 'stable' && matrix.os == 'ubuntu' }}
+
+      - run: git clone https://chromium.googlesource.com/chromium/tools/depot_tools.git
+      - run: echo "$PWD/depot_tools" >> $GITHUB_PATH
+      - run: mkdir dart-sdk
+      - run: cd dart-sdk && fetch --no-history dart
+      - run: echo "./dart-sdk/sdk/buildtools/linux-x64/clang/bin" >> $GITHUB_PATH
+      - run: clang --version
+
+      - run: dart test
+        if: ${{ matrix.sdk == 'stable' }}
diff --git a/.github/workflows/no-response.yml b/.github/workflows/no-response.yml
new file mode 100644
index 0000000..8e5ed57
--- /dev/null
+++ b/.github/workflows/no-response.yml
@@ -0,0 +1,37 @@
+# A workflow to close issues where the author hasn't responded to a request for
+# more information; see https://github.com/actions/stale.
+
+name: No Response
+
+# Run as a daily cron.
+on:
+  schedule:
+    # Every day at 8am
+    - cron: '0 8 * * *'
+
+# All permissions not specified are set to 'none'.
+permissions:
+  issues: write
+  pull-requests: write
+
+jobs:
+  no-response:
+    runs-on: ubuntu-latest
+    if: ${{ github.repository_owner == 'dart-lang' }}
+    steps:
+      - uses: actions/stale@1160a2240286f5da8ec72b1c0816ce2481aabf84
+        with:
+          # Don't automatically mark inactive issues+PRs as stale.
+          days-before-stale: -1
+          # Close needs-info issues and PRs after 14 days of inactivity.
+          days-before-close: 14
+          stale-issue-label: "needs-info"
+          close-issue-message: >
+            Without additional information we're not able to resolve this issue.
+            Feel free to add more info or respond to any questions above and we
+            can reopen the case. Thanks for your contribution!
+          stale-pr-label: "needs-info"
+          close-pr-message: >
+            Without additional information we're not able to resolve this PR.
+            Feel free to add more info or respond to any questions above.
+            Thanks for your contribution!
diff --git a/.github/workflows/post_summaries.yaml b/.github/workflows/post_summaries.yaml
new file mode 100644
index 0000000..a475092
--- /dev/null
+++ b/.github/workflows/post_summaries.yaml
@@ -0,0 +1,16 @@
+name: Comment on the pull request
+
+on:
+  # Trigger this workflow after the Health workflow completes. This workflow will have permissions to
+  # do things like create comments on the PR, even if the original workflow couldn't.
+  workflow_run:
+    workflows: 
+      - Publish
+    types:
+      - completed
+
+jobs:
+  upload:
+    uses: dart-lang/ecosystem/.github/workflows/post_summaries.yaml@main
+    permissions:
+      pull-requests: write
diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml
new file mode 100644
index 0000000..4bac353
--- /dev/null
+++ b/.github/workflows/publish.yaml
@@ -0,0 +1,19 @@
+# A CI configuration to auto-publish pub packages.
+
+name: Publish
+
+on:
+  pull_request:
+    branches: [ main ]
+  push:
+    tags: [ '[A-z]+-v[0-9]+.[0-9]+.[0-9]+' ]
+
+jobs:
+  publish:
+    if: ${{ github.repository_owner == 'dart-lang' }}
+    uses: dart-lang/ecosystem/.github/workflows/publish.yaml@main
+    permissions:
+      id-token: write # Required for authentication using OIDC
+      pull-requests: write # Required for writing the pull request note
+    with:
+      write-comments: false
diff --git a/.github/workflows/pull_request_label.yml b/.github/workflows/pull_request_label.yml
new file mode 100644
index 0000000..9933aad
--- /dev/null
+++ b/.github/workflows/pull_request_label.yml
@@ -0,0 +1,22 @@
+# This workflow applies labels to pull requests based on the paths that are
+# modified in the pull request.
+#
+# Edit `.github/labeler.yml` to configure labels. For more information, see
+# https://github.com/actions/labeler.
+
+name: Pull Request Labeler
+permissions: read-all
+
+on:
+  pull_request_target
+
+jobs:
+  label:
+    permissions:
+      pull-requests: write
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/labeler@ac9175f8a1f3625fd0d4fb234536d26811351594
+        with:
+          repo-token: "${{ secrets.GITHUB_TOKEN }}"
+          sync-labels: true
diff --git a/AUTHORS b/AUTHORS
new file mode 100644
index 0000000..846e4a1
--- /dev/null
+++ b/AUTHORS
@@ -0,0 +1,6 @@
+# Below is a list of people and organizations that have contributed
+# to the Dart project. Names should be added to the list like so:
+#
+#   Name/Organization <email address>
+
+Google LLC
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..3c28563
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,51 @@
+# How to Contribute
+
+We'd love to accept your patches and contributions to this project. There are
+just a few small guidelines you need to follow.
+
+## Contributor License Agreement
+
+Contributions to this project must be accompanied by a Contributor License
+Agreement (CLA). You (or your employer) retain the copyright to your
+contribution; this simply gives us permission to use and redistribute your
+contributions as part of the project. Head over to
+<https://cla.developers.google.com/> to see your current agreements on file or
+to sign a new one.
+
+You generally only need to submit a CLA once, so if you've already submitted one
+(even if it was for a different project), you probably don't need to do it
+again.
+
+## Code Reviews
+
+All submissions, including submissions by project members, require review. We
+use GitHub pull requests for this purpose. Consult
+[GitHub Help](https://help.github.com/articles/about-pull-requests/) for more
+information on using pull requests.
+
+## Coding style
+
+The Dart source code in this repo follows the:
+
+  * [Dart style guide](https://dart.dev/guides/language/effective-dart/style)
+
+You should familiarize yourself with those guidelines.
+
+## File headers
+
+All files in the Dart project must start with the following header; if you add a
+new file please also add this. The year should be a single number stating the
+year the file was created (don't use a range like "2011-2012"). Additionally, if
+you edit an existing file, you shouldn't update the year.
+
+    // Copyright (c) 2023, 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.
+
+## Community Guidelines
+
+This project follows
+[Google's Open Source Community Guidelines](https://opensource.google/conduct/).
+
+We pledge to maintain an open and welcoming environment. For details, see our
+[code of conduct](https://dart.dev/code-of-conduct).
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..4fd5739
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,27 @@
+Copyright 2023, 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.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..ed09c1d
--- /dev/null
+++ b/README.md
@@ -0,0 +1,22 @@
+[![Coverage Status](https://coveralls.io/repos/github/dart-lang/native/badge.svg?branch=main)](https://coveralls.io/github/dart-lang/native?branch=main)
+
+## Overview
+
+This repository is home to Dart packages related to FFI and native assets
+building and bundling.
+
+## Packages
+
+| Package                                      | Description                                                                                 | Version |
+| -------------------------------------------- | ------------------------------------------------------------------------------------------- | ------- |
+| [native_assets_builder](pkgs/native_assets_builder/) | A library that contains the logic for building native assets. This should not be used by users, and is used as shared implementation between dartdev and flutter_tools. | [![pub package](https://img.shields.io/pub/v/native_assets_builder.svg)](https://pub.dev/packages/native_assets_builder)         |
+| [native_assets_cli](pkgs/native_assets_cli/) | A library that contains the argument and file formats for implementing a native assets CLI. | [![pub package](https://img.shields.io/pub/v/native_assets_cli.svg)](https://pub.dev/packages/native_assets_cli)        |
+| [native_toolchain_c](pkgs/native_toolchain_c/)               | A library to invoke the native C compiler installed on the host machine.                    | [![pub package](https://img.shields.io/pub/v/native_toolchain_c.svg)](https://pub.dev/packages/native_toolchain_c) |
+
+## Publishing automation
+
+For information about our publishing automation and release process, see
+https://github.com/dart-lang/ecosystem/wiki/Publishing-automation.
+
+For additional information about contributing, see our
+[contributing](CONTRIBUTING.md) page.
diff --git a/pkgs/native_assets_builder/.gitignore b/pkgs/native_assets_builder/.gitignore
new file mode 100644
index 0000000..9fa208d
--- /dev/null
+++ b/pkgs/native_assets_builder/.gitignore
@@ -0,0 +1,11 @@
+# Please keep consistent with .pubignore.
+
+# https://dart.dev/guides/libraries/private-files
+# Created by `dart pub`
+.dart_tool/
+
+# Avoid committing pubspec.lock for library packages; see
+# https://dart.dev/guides/libraries/private-files#pubspeclock.
+pubspec.lock
+
+coverage/
diff --git a/pkgs/native_assets_builder/.pubignore b/pkgs/native_assets_builder/.pubignore
new file mode 100644
index 0000000..1d80e79
--- /dev/null
+++ b/pkgs/native_assets_builder/.pubignore
@@ -0,0 +1,14 @@
+# Please keep consistent with .gitignore.
+
+# https://dart.dev/guides/libraries/private-files
+# Created by `dart pub`
+.dart_tool/
+
+# Avoid committing pubspec.lock for library packages; see
+# https://dart.dev/guides/libraries/private-files#pubspeclock.
+pubspec.lock
+
+coverage/
+
+# Woraround https://github.com/dart-lang/pub/issues/3982
+test/
diff --git a/pkgs/native_assets_builder/CHANGELOG.md b/pkgs/native_assets_builder/CHANGELOG.md
new file mode 100644
index 0000000..5700d5d
--- /dev/null
+++ b/pkgs/native_assets_builder/CHANGELOG.md
@@ -0,0 +1,38 @@
+## 0.3.0
+
+- Bump `package:native_assets_cli` to 0.3.0
+  ([#142](https://github.com/dart-lang/native/issues/142)).
+
+## 0.2.3
+
+- Quicker build planning for 0 or 1 packages with native assets
+  ([#128](https://github.com/dart-lang/native/issues/128)).
+
+## 0.2.2
+
+- Take a `PackageLayout` argument for `build` and `dryRun`
+  ([flutter#134427](https://github.com/flutter/flutter/issues/134427)).
+
+## 0.2.1
+
+- Provide a `PackageLayout` constructor for already parsed `PackageConfig`
+  ([flutter#134427](https://github.com/flutter/flutter/issues/134427)).
+
+## 0.2.0
+
+- **Breaking change** `NativeAssetsBuildRunner`s methods now return an object
+  ([#105](https://github.com/dart-lang/native/issues/105)).
+- **Breaking change** `NativeAssetsBuildRunner`s methods now return value now
+  contain a success bool instead of throwing
+  ([#106](https://github.com/dart-lang/native/issues/106)). Error messages are
+  streamed to the logger.
+- Use an `out/` sub directory for building native assets
+  ([#98](https://github.com/dart-lang/native/issues/98)).
+- Check asset ids on having having a package uri with the owning package
+  ([#96](https://github.com/dart-lang/native/issues/96)).
+- `NativeAssetsBuildRunner` now supports multiple calls
+  ([#102](https://github.com/dart-lang/native/issues/102)).
+
+## 0.1.0
+
+- Initial version.
diff --git a/pkgs/native_assets_builder/LICENSE b/pkgs/native_assets_builder/LICENSE
new file mode 100644
index 0000000..4fd5739
--- /dev/null
+++ b/pkgs/native_assets_builder/LICENSE
@@ -0,0 +1,27 @@
+Copyright 2023, 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.
diff --git a/pkgs/native_assets_builder/README.md b/pkgs/native_assets_builder/README.md
new file mode 100644
index 0000000..dfdc0e8
--- /dev/null
+++ b/pkgs/native_assets_builder/README.md
@@ -0,0 +1,19 @@
+[![dart](https://github.com/dart-lang/native/actions/workflows/native.yaml/badge.svg)](https://github.com/dart-lang/native/actions/workflows/native.yaml)
+[![Coverage Status](https://coveralls.io/repos/github/dart-lang/native/badge.svg?branch=main)](https://coveralls.io/github/dart-lang/native?branch=main)
+[![pub package](https://img.shields.io/pub/v/native_assets_builder.svg)](https://pub.dev/packages/native_assets_builder)
+[![package publisher](https://img.shields.io/pub/publisher/native_assets_builder.svg)](https://pub.dev/packages/native_assets_builder/publisher)
+
+This package contains the shared logic for invoking [native assets CLI]s.
+
+## Audience
+
+This package is not intended for users, rather it is shared logic for Dart
+launchers to invoke the native assets CLIs of packages.
+Known Dart launchers using this shared logic: [dartdev] and [flutter_tools].
+
+For more information on how to use native assets as a user see 
+package [native assets CLI].
+
+[native assets CLI]: https://github.com/dart-lang/native/tree/main/pkgs/native_assets_cli
+[dartdev]: https://github.com/dart-lang/sdk/tree/main/pkg/dartdev
+[flutter_tools]: https://github.com/flutter/flutter/tree/master/packages/flutter_tools
diff --git a/pkgs/native_assets_builder/analysis_options.yaml b/pkgs/native_assets_builder/analysis_options.yaml
new file mode 100644
index 0000000..89ab6f0
--- /dev/null
+++ b/pkgs/native_assets_builder/analysis_options.yaml
@@ -0,0 +1,15 @@
+include: package:dart_flutter_team_lints/analysis_options.yaml
+
+analyzer:
+  language:
+    strict-raw-types: true
+  exclude:
+    # TODO(https://github.com/dart-lang/ecosystem/issues/150): Remove this.
+    - test/data/
+
+linter:
+  rules:
+    - prefer_const_declarations
+    - prefer_expression_function_bodies
+    - prefer_final_in_for_each
+    - prefer_final_locals
diff --git a/pkgs/native_assets_builder/dart_test.yaml b/pkgs/native_assets_builder/dart_test.yaml
new file mode 100644
index 0000000..c1c8a57
--- /dev/null
+++ b/pkgs/native_assets_builder/dart_test.yaml
@@ -0,0 +1,2 @@
+paths:
+  - test/build_runner/
diff --git a/pkgs/native_assets_builder/lib/native_assets_builder.dart b/pkgs/native_assets_builder/lib/native_assets_builder.dart
new file mode 100644
index 0000000..3ff29ab
--- /dev/null
+++ b/pkgs/native_assets_builder/lib/native_assets_builder.dart
@@ -0,0 +1,6 @@
+// Copyright (c) 2023, 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 'package:native_assets_builder/src/build_runner/build_runner.dart';
+export 'package:native_assets_builder/src/package_layout/package_layout.dart';
diff --git a/pkgs/native_assets_builder/lib/src/build_runner/build_planner.dart b/pkgs/native_assets_builder/lib/src/build_runner/build_planner.dart
new file mode 100644
index 0000000..24c533a
--- /dev/null
+++ b/pkgs/native_assets_builder/lib/src/build_runner/build_planner.dart
@@ -0,0 +1,108 @@
+// Copyright (c) 2023, 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';
+
+import 'package:graphs/graphs.dart' as graphs;
+import 'package:logging/logging.dart';
+import 'package:package_config/package_config.dart';
+
+class NativeAssetsBuildPlanner {
+  final PackageGraph packageGraph;
+  final List<Package> packagesWithNativeAssets;
+  final Uri dartExecutable;
+  final Logger logger;
+
+  NativeAssetsBuildPlanner({
+    required this.packageGraph,
+    required this.packagesWithNativeAssets,
+    required this.dartExecutable,
+    required this.logger,
+  });
+
+  static Future<NativeAssetsBuildPlanner> fromRootPackageRoot({
+    required Uri rootPackageRoot,
+    required List<Package> packagesWithNativeAssets,
+    required Uri dartExecutable,
+    required Logger logger,
+  }) async {
+    final result = await Process.run(
+      dartExecutable.toFilePath(),
+      [
+        'pub',
+        'deps',
+        '--json',
+      ],
+      workingDirectory: rootPackageRoot.toFilePath(),
+    );
+    final packageGraph =
+        PackageGraph.fromPubDepsJsonString(result.stdout as String);
+    return NativeAssetsBuildPlanner(
+      packageGraph: packageGraph,
+      packagesWithNativeAssets: packagesWithNativeAssets,
+      dartExecutable: dartExecutable,
+      logger: logger,
+    );
+  }
+
+  (List<Package> packages, bool success) plan() {
+    final packageMap = {
+      for (final package in packagesWithNativeAssets) package.name: package
+    };
+    final packagesToBuild = packageMap.keys.toSet();
+    final stronglyConnectedComponents = packageGraph.computeStrongComponents();
+    final result = <Package>[];
+    var success = true;
+    for (final stronglyConnectedComponent in stronglyConnectedComponents) {
+      final stronglyConnectedComponentWithNativeAssets = [
+        for (final packageName in stronglyConnectedComponent)
+          if (packagesToBuild.contains(packageName)) packageName
+      ];
+      if (stronglyConnectedComponentWithNativeAssets.length > 1) {
+        logger.severe(
+          'Cyclic dependency for native asset builds in the following '
+          'packages: $stronglyConnectedComponentWithNativeAssets.',
+        );
+        success = false;
+      } else if (stronglyConnectedComponentWithNativeAssets.length == 1) {
+        result.add(
+            packageMap[stronglyConnectedComponentWithNativeAssets.single]!);
+      }
+    }
+    return (result, success);
+  }
+}
+
+class PackageGraph {
+  final Map<String, List<String>> map;
+
+  PackageGraph(this.map);
+
+  /// Construct a graph from the JSON produced by `dart pub deps --json`.
+  factory PackageGraph.fromPubDepsJsonString(String json) =>
+      PackageGraph.fromPubDepsJson(jsonDecode(json) as Map<dynamic, dynamic>);
+
+  /// Construct a graph from the JSON produced by `dart pub deps --json`.
+  factory PackageGraph.fromPubDepsJson(Map<dynamic, dynamic> map) {
+    final result = <String, List<String>>{};
+    final packages = map['packages'] as List<dynamic>;
+    for (final package in packages) {
+      final package_ = package as Map<dynamic, dynamic>;
+      final name = package_['name'] as String;
+      final dependencies = (package_['dependencies'] as List<dynamic>)
+          .whereType<String>()
+          .toList();
+      result[name] = dependencies;
+    }
+    return PackageGraph(result);
+  }
+
+  Iterable<String> neighborsOf(String vertex) => map[vertex] ?? [];
+
+  Iterable<String> get vertices => map.keys;
+
+  List<List<String>> computeStrongComponents() =>
+      graphs.stronglyConnectedComponents(vertices, neighborsOf);
+}
diff --git a/pkgs/native_assets_builder/lib/src/build_runner/build_runner.dart b/pkgs/native_assets_builder/lib/src/build_runner/build_runner.dart
new file mode 100644
index 0000000..579855c
--- /dev/null
+++ b/pkgs/native_assets_builder/lib/src/build_runner/build_runner.dart
@@ -0,0 +1,478 @@
+// Copyright (c) 2023, 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';
+
+import 'package:logging/logging.dart';
+import 'package:native_assets_cli/native_assets_cli.dart';
+import 'package:package_config/package_config.dart';
+
+import '../package_layout/package_layout.dart';
+import '../utils/run_process.dart';
+import 'build_planner.dart';
+
+typedef DependencyMetadata = Map<String, Metadata>;
+
+/// The programmatic API to be used by Dart launchers to invoke native builds.
+///
+/// These methods are invoked by launchers such as dartdev (for `dart run`)
+/// and flutter_tools (for `flutter run` and `flutter build`).
+class NativeAssetsBuildRunner {
+  final Logger logger;
+  final Uri dartExecutable;
+
+  NativeAssetsBuildRunner({
+    required this.logger,
+    required this.dartExecutable,
+  });
+
+  /// [workingDirectory] is expected to contain `.dart_tool`.
+  ///
+  /// This method is invoked by launchers such as dartdev (for `dart run`) and
+  /// flutter_tools (for `flutter run` and `flutter build`).
+  ///
+  /// Completes the future with an error if the build fails.
+  Future<BuildResult> build({
+    required LinkModePreference linkModePreference,
+    required Target target,
+    required Uri workingDirectory,
+    required BuildMode buildMode,
+    CCompilerConfig? cCompilerConfig,
+    IOSSdk? targetIOSSdk,
+    int? targetAndroidNdkApi,
+    required bool includeParentEnvironment,
+    PackageLayout? packageLayout,
+  }) async {
+    packageLayout ??= await PackageLayout.fromRootPackageRoot(workingDirectory);
+    final packagesWithNativeAssets =
+        await packageLayout.packagesWithNativeAssets;
+    final List<Package> buildPlan;
+    final PackageGraph packageGraph;
+    if (packagesWithNativeAssets.length <= 1) {
+      buildPlan = packagesWithNativeAssets;
+      packageGraph = PackageGraph({
+        for (final p in packagesWithNativeAssets) p.name: [],
+      });
+    } else {
+      final planner = await NativeAssetsBuildPlanner.fromRootPackageRoot(
+        rootPackageRoot: packageLayout.rootPackageRoot,
+        packagesWithNativeAssets: packagesWithNativeAssets,
+        dartExecutable: Uri.file(Platform.resolvedExecutable),
+        logger: logger,
+      );
+      final (plan, planSuccess) = planner.plan();
+      if (!planSuccess) {
+        return _BuildResultImpl(
+          assets: [],
+          dependencies: [],
+          success: false,
+        );
+      }
+      buildPlan = plan;
+      packageGraph = planner.packageGraph;
+    }
+    final assets = <Asset>[];
+    final dependencies = <Uri>[];
+    final metadata = <String, Metadata>{};
+    var success = true;
+    for (final package in buildPlan) {
+      final dependencyMetadata = _metadataForPackage(
+        packageGraph: packageGraph,
+        packageName: package.name,
+        targetMetadata: metadata,
+      );
+      final config = await _cliConfig(
+        packageName: package.name,
+        packageRoot: packageLayout.packageRoot(package.name),
+        target: target,
+        buildMode: buildMode,
+        linkMode: linkModePreference,
+        buildParentDir: packageLayout.dartToolNativeAssetsBuilder,
+        dependencyMetadata: dependencyMetadata,
+        cCompilerConfig: cCompilerConfig,
+        targetIOSSdk: targetIOSSdk,
+        targetAndroidNdkApi: targetAndroidNdkApi,
+      );
+      final (
+        packageAssets,
+        packageDependencies,
+        packageMetadata,
+        packageSuccess,
+      ) = await _buildPackageCached(
+        config,
+        packageLayout.packageConfigUri,
+        workingDirectory,
+        includeParentEnvironment,
+      );
+      assets.addAll(packageAssets);
+      dependencies.addAll(packageDependencies);
+      success &= packageSuccess;
+      if (packageMetadata != null) {
+        metadata[config.packageName] = packageMetadata;
+      }
+    }
+    return _BuildResultImpl(
+      assets: assets,
+      dependencies: dependencies..sort(_uriCompare),
+      success: success,
+    );
+  }
+
+  /// [workingDirectory] is expected to contain `.dart_tool`.
+  ///
+  /// This method is invoked by launchers such as dartdev (for `dart run`) and
+  /// flutter_tools (for `flutter run` and `flutter build`).
+  ///
+  /// Completes the future with an error if the build fails.
+  Future<DryRunResult> dryRun({
+    required LinkModePreference linkModePreference,
+    required OS targetOs,
+    required Uri workingDirectory,
+    required bool includeParentEnvironment,
+    PackageLayout? packageLayout,
+  }) async {
+    packageLayout ??= await PackageLayout.fromRootPackageRoot(workingDirectory);
+    final packagesWithNativeAssets =
+        await packageLayout.packagesWithNativeAssets;
+    final List<Package> buildPlan;
+    if (packagesWithNativeAssets.length <= 1) {
+      buildPlan = packagesWithNativeAssets;
+    } else {
+      final planner = await NativeAssetsBuildPlanner.fromRootPackageRoot(
+        rootPackageRoot: packageLayout.rootPackageRoot,
+        packagesWithNativeAssets: packagesWithNativeAssets,
+        dartExecutable: Uri.file(Platform.resolvedExecutable),
+        logger: logger,
+      );
+      final (plan, planSuccess) = planner.plan();
+      if (!planSuccess) {
+        return _DryRunResultImpl(
+          assets: [],
+          success: false,
+        );
+      }
+      buildPlan = plan;
+    }
+    final assets = <Asset>[];
+    var success = true;
+    for (final package in buildPlan) {
+      final config = await _cliConfigDryRun(
+        packageName: package.name,
+        packageRoot: packageLayout.packageRoot(package.name),
+        targetOs: targetOs,
+        linkMode: linkModePreference,
+        buildParentDir: packageLayout.dartToolNativeAssetsBuilder,
+      );
+      final (packageAssets, _, _, packageSuccess) = await _buildPackage(
+        config,
+        packageLayout.packageConfigUri,
+        workingDirectory,
+        includeParentEnvironment,
+        dryRun: true,
+      );
+      assets.addAll(packageAssets);
+      success &= packageSuccess;
+    }
+    return _DryRunResultImpl(
+      assets: assets,
+      success: success,
+    );
+  }
+
+  Future<_PackageBuildRecord> _buildPackageCached(
+    BuildConfig config,
+    Uri packageConfigUri,
+    Uri workingDirectory,
+    bool includeParentEnvironment,
+  ) async {
+    final packageName = config.packageName;
+    final outDir = config.outDir;
+    if (!await Directory.fromUri(outDir).exists()) {
+      await Directory.fromUri(outDir).create(recursive: true);
+    }
+
+    final buildOutput = await BuildOutput.readFromFile(outDir: outDir);
+    final lastBuilt = buildOutput?.timestamp.roundDownToSeconds() ??
+        DateTime.fromMillisecondsSinceEpoch(0);
+    final dependencies = buildOutput?.dependencies;
+    final lastChange = await dependencies?.lastModified() ?? DateTime.now();
+
+    if (lastBuilt.isAfter(lastChange)) {
+      logger.info('Skipping build for $packageName in $outDir. '
+          'Last build on $lastBuilt, last input change on $lastChange.');
+      // All build flags go into [outDir]. Therefore we do not have to check
+      // here whether the config is equal.
+      final assets = buildOutput!.assets;
+      final dependencies = buildOutput.dependencies.dependencies;
+      final metadata = buildOutput.metadata;
+      return (assets, dependencies, metadata, true);
+    }
+
+    return await _buildPackage(
+      config,
+      packageConfigUri,
+      workingDirectory,
+      includeParentEnvironment,
+      dryRun: false,
+    );
+  }
+
+  Future<_PackageBuildRecord> _buildPackage(
+    BuildConfig config,
+    Uri packageConfigUri,
+    Uri workingDirectory,
+    bool includeParentEnvironment, {
+    required bool dryRun,
+  }) async {
+    final outDir = config.outDir;
+    final configFile = outDir.resolve('../config.yaml');
+    final buildDotDart = config.packageRoot.resolve('build.dart');
+    final configFileContents = config.toYamlString();
+    logger.info('config.yaml contents: $configFileContents');
+    await File.fromUri(configFile).writeAsString(configFileContents);
+    final buildOutputFile = File.fromUri(outDir.resolve(BuildOutput.fileName));
+    if (await buildOutputFile.exists()) {
+      // Ensure we'll never read outdated build results.
+      await buildOutputFile.delete();
+    }
+    final arguments = [
+      '--packages=${packageConfigUri.toFilePath()}',
+      buildDotDart.toFilePath(),
+      '--config=${configFile.toFilePath()}',
+    ];
+    final result = await runProcess(
+      workingDirectory: workingDirectory,
+      executable: dartExecutable,
+      arguments: arguments,
+      logger: logger,
+      includeParentEnvironment: includeParentEnvironment,
+    );
+    var success = true;
+    if (result.exitCode != 0) {
+      final printWorkingDir = workingDirectory != Directory.current.uri;
+      final commandString = [
+        if (printWorkingDir) '(cd ${workingDirectory.toFilePath()};',
+        dartExecutable.toFilePath(),
+        ...arguments.map((a) => a.contains(' ') ? "'$a'" : a),
+        if (printWorkingDir) ')',
+      ].join(' ');
+      logger.severe(
+        '''
+Building native assets for package:${config.packageName} failed.
+build.dart returned with exit code: ${result.exitCode}.
+To reproduce run:
+$commandString
+stderr:
+${result.stderr}
+stdout:
+${result.stdout}
+        ''',
+      );
+      success = false;
+    }
+
+    try {
+      final buildOutput = await BuildOutput.readFromFile(outDir: outDir);
+      final assets = buildOutput?.assets ?? [];
+      success &= validateAssetsPackage(assets, config.packageName);
+      final dependencies = buildOutput?.dependencies.dependencies ?? [];
+      final metadata = dryRun ? null : buildOutput?.metadata;
+      return (assets, dependencies, metadata, success);
+    } on FormatException catch (e) {
+      logger.severe('''
+Building native assets for package:${config.packageName} failed.
+build_output.yaml contained a format error.
+${e.message}
+        ''');
+      success = false;
+      return (<Asset>[], <Uri>[], const Metadata({}), false);
+      // TODO(https://github.com/dart-lang/native/issues/109): Stop throwing
+      // type errors in native_assets_cli, release a new version of that package
+      // and then remove this.
+      // ignore: avoid_catching_errors
+    } on TypeError {
+      logger.severe('''
+Building native assets for package:${config.packageName} failed.
+build_output.yaml contained a format error.
+        ''');
+      success = false;
+      return (<Asset>[], <Uri>[], const Metadata({}), false);
+    } finally {
+      if (!success) {
+        final buildOutputFile =
+            File.fromUri(outDir.resolve(BuildOutput.fileName));
+        if (await buildOutputFile.exists()) {
+          await buildOutputFile.delete();
+        }
+      }
+    }
+  }
+
+  static Future<BuildConfig> _cliConfig({
+    required String packageName,
+    required Uri packageRoot,
+    required Target target,
+    IOSSdk? targetIOSSdk,
+    int? targetAndroidNdkApi,
+    required BuildMode buildMode,
+    required LinkModePreference linkMode,
+    required Uri buildParentDir,
+    CCompilerConfig? cCompilerConfig,
+    DependencyMetadata? dependencyMetadata,
+  }) async {
+    final buildDirName = BuildConfig.checksum(
+      packageName: packageName,
+      packageRoot: packageRoot,
+      targetOs: target.os,
+      targetArchitecture: target.architecture,
+      buildMode: buildMode,
+      linkModePreference: linkMode,
+      targetIOSSdk: targetIOSSdk,
+      cCompiler: cCompilerConfig,
+      dependencyMetadata: dependencyMetadata,
+      targetAndroidNdkApi: targetAndroidNdkApi,
+    );
+    final outDirUri = buildParentDir.resolve('$buildDirName/out/');
+    final outDir = Directory.fromUri(outDirUri);
+    if (!await outDir.exists()) {
+      // TODO(https://dartbug.com/50565): Purge old or unused folders.
+      await outDir.create(recursive: true);
+    }
+    return BuildConfig(
+      outDir: outDirUri,
+      packageName: packageName,
+      packageRoot: packageRoot,
+      targetOs: target.os,
+      targetArchitecture: target.architecture,
+      buildMode: buildMode,
+      linkModePreference: linkMode,
+      targetIOSSdk: targetIOSSdk,
+      cCompiler: cCompilerConfig,
+      dependencyMetadata: dependencyMetadata,
+      targetAndroidNdkApi: targetAndroidNdkApi,
+    );
+  }
+
+  static Future<BuildConfig> _cliConfigDryRun({
+    required String packageName,
+    required Uri packageRoot,
+    required OS targetOs,
+    required LinkModePreference linkMode,
+    required Uri buildParentDir,
+  }) async {
+    final buildDirName = 'dry_run_${targetOs}_$linkMode';
+    final outDirUri = buildParentDir.resolve('$buildDirName/out/');
+    final outDir = Directory.fromUri(outDirUri);
+    if (!await outDir.exists()) {
+      await outDir.create(recursive: true);
+    }
+    return BuildConfig.dryRun(
+      outDir: outDirUri,
+      packageName: packageName,
+      packageRoot: packageRoot,
+      targetOs: targetOs,
+      linkModePreference: linkMode,
+    );
+  }
+
+  DependencyMetadata? _metadataForPackage({
+    required PackageGraph packageGraph,
+    required String packageName,
+    DependencyMetadata? targetMetadata,
+  }) {
+    if (targetMetadata == null) {
+      return null;
+    }
+    final dependencies = packageGraph.neighborsOf(packageName).toSet();
+    return {
+      for (final entry in targetMetadata.entries)
+        if (dependencies.contains(entry.key)) entry.key: entry.value,
+    };
+  }
+
+  bool validateAssetsPackage(List<Asset> assets, String packageName) {
+    final invalidAssetIds = assets
+        .map((a) => a.id)
+        .where((n) => !n.startsWith('package:$packageName/'))
+        .toSet()
+        .toList()
+      ..sort();
+    final success = invalidAssetIds.isEmpty;
+    if (!success) {
+      logger.severe(
+        '`package:$packageName` declares the following assets which do not '
+        'start with `package:$packageName/`: ${invalidAssetIds.join(', ')}.',
+      );
+    }
+    return success;
+  }
+}
+
+typedef _PackageBuildRecord = (
+  List<Asset>,
+  List<Uri> dependencies,
+  Metadata?,
+  bool success,
+);
+
+/// The result from a [NativeAssetsBuildRunner.dryRun].
+abstract interface class DryRunResult {
+  /// The native assets for all [Target]s for the build or dry run.
+  List<Asset> get assets;
+
+  /// Whether all builds completed without errors.
+  ///
+  /// All error messages are streamed to [NativeAssetsBuildRunner.logger].
+  bool get success;
+}
+
+final class _DryRunResultImpl implements DryRunResult {
+  @override
+  final List<Asset> assets;
+
+  @override
+  final bool success;
+
+  _DryRunResultImpl({
+    required this.assets,
+    required this.success,
+  });
+}
+
+/// The result from a [NativeAssetsBuildRunner.build].
+abstract class BuildResult implements DryRunResult {
+  /// All the files used for building the native assets of all packages.
+  ///
+  /// This aggregated list can be used to determine whether the
+  /// [NativeAssetsBuildRunner] needs to be invoked again. The
+  /// [NativeAssetsBuildRunner] determines per package with native assets
+  /// if it needs to run the build again.
+  List<Uri> get dependencies;
+}
+
+final class _BuildResultImpl implements BuildResult {
+  @override
+  final List<Asset> assets;
+
+  @override
+  final List<Uri> dependencies;
+
+  @override
+  final bool success;
+
+  _BuildResultImpl({
+    required this.assets,
+    required this.dependencies,
+    required this.success,
+  });
+}
+
+extension on DateTime {
+  DateTime roundDownToSeconds() =>
+      DateTime.fromMillisecondsSinceEpoch(millisecondsSinceEpoch -
+          millisecondsSinceEpoch % const Duration(seconds: 1).inMilliseconds);
+}
+
+int _uriCompare(Uri u1, Uri u2) => u1.toString().compareTo(u2.toString());
diff --git a/pkgs/native_assets_builder/lib/src/package_layout/package_layout.dart b/pkgs/native_assets_builder/lib/src/package_layout/package_layout.dart
new file mode 100644
index 0000000..00b97d2
--- /dev/null
+++ b/pkgs/native_assets_builder/lib/src/package_layout/package_layout.dart
@@ -0,0 +1,104 @@
+// Copyright (c) 2023, 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';
+
+import 'package:package_config/package_config.dart';
+
+/// Directory layout for dealing with native assets.
+///
+/// Build scripts for native assets will be run from the context of another
+/// root package.
+///
+/// The directory layout follows pub's convention for caching:
+/// https://dart.dev/tools/pub/package-layout#project-specific-caching-for-tools
+class PackageLayout {
+  /// The root folder of the current dart invocation root package.
+  ///
+  /// `$rootPackageRoot`.
+  final Uri rootPackageRoot;
+
+  /// Package config containing the information of where to foot the root [Uri]s
+  /// of other packages.
+  ///
+  /// Can be `null` to enable quick construction of a
+  /// [PackageLayout].
+  final PackageConfig packageConfig;
+
+  final Uri packageConfigUri;
+
+  PackageLayout._(
+      this.rootPackageRoot, this.packageConfig, this.packageConfigUri);
+
+  factory PackageLayout.fromPackageConfig(
+    PackageConfig packageConfig,
+    Uri packageConfigUri,
+  ) {
+    assert(File.fromUri(packageConfigUri).existsSync());
+    packageConfigUri = packageConfigUri.normalizePath();
+    final rootPackageRoot = packageConfigUri.resolve('../');
+    return PackageLayout._(rootPackageRoot, packageConfig, packageConfigUri);
+  }
+
+  static Future<PackageLayout> fromRootPackageRoot(Uri rootPackageRoot) async {
+    rootPackageRoot = rootPackageRoot.normalizePath();
+    final packageConfigUri =
+        rootPackageRoot.resolve('.dart_tool/package_config.json');
+    assert(await File.fromUri(packageConfigUri).exists());
+    final packageConfig = await loadPackageConfigUri(packageConfigUri);
+    return PackageLayout._(rootPackageRoot, packageConfig, packageConfigUri);
+  }
+
+  /// The .dart_tool directory is used to store built artifacts and caches.
+  ///
+  /// `$rootPackageRoot/.dart_tool/`.
+  ///
+  /// Each package should only modify the subfolder of `.dart_tool/` with its
+  /// own name.
+  /// https://dart.dev/tools/pub/package-layout#project-specific-caching-for-tools
+  late final Uri dartTool = rootPackageRoot.resolve('.dart_tool/');
+
+  /// The directory where `package:native_assets_builder` stores all persistent
+  /// information.
+  ///
+  /// This folder is owned by `package:native_assets_builder`, no other package
+  /// should read or modify it.
+  /// https://dart.dev/tools/pub/package-layout#project-specific-caching-for-tools
+  ///
+  /// `$rootPackageRoot/.dart_tool/native_assets_builder/`.
+  late final Uri dartToolNativeAssetsBuilder =
+      dartTool.resolve('native_assets_builder/');
+
+  /// The root of `package:$packageName`.
+  ///
+  /// `$packageName/`.
+  ///
+  /// This folder is owned by pub, and should _never_ be written to.
+  Uri packageRoot(String packageName) {
+    final package = packageConfig[packageName];
+    if (package == null) {
+      throw StateError('Package $packageName not found in packageConfig.');
+    }
+    return package.root;
+  }
+
+  /// All packages in [packageConfig] with native assets.
+  ///
+  /// Whether a package has native assets is defined by whether it contains
+  /// a `build.dart`.
+  ///
+  /// `package:native` itself is excluded.
+  late final Future<List<Package>> packagesWithNativeAssets = () async {
+    final result = <Package>[];
+    for (final package in packageConfig.packages) {
+      final packageRoot = package.root;
+      if (packageRoot.scheme == 'file') {
+        if (await File.fromUri(packageRoot.resolve('build.dart')).exists()) {
+          result.add(package);
+        }
+      }
+    }
+    return result;
+  }();
+}
diff --git a/pkgs/native_assets_builder/lib/src/utils/run_process.dart b/pkgs/native_assets_builder/lib/src/utils/run_process.dart
new file mode 100644
index 0000000..cb989c7
--- /dev/null
+++ b/pkgs/native_assets_builder/lib/src/utils/run_process.dart
@@ -0,0 +1,132 @@
+// Copyright (c) 2023, 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';
+
+import 'package:logging/logging.dart';
+
+/// Runs a [Process].
+///
+/// If [logger] is provided, stream stdout and stderr to it.
+///
+/// If [captureOutput], captures stdout and stderr.
+// TODO(dacoharkes): Share between package:native_toolchain_c and here.
+Future<RunProcessResult> runProcess({
+  required Uri executable,
+  List<String> arguments = const [],
+  Uri? workingDirectory,
+  Map<String, String>? environment,
+  bool includeParentEnvironment = true,
+  required Logger? logger,
+  bool captureOutput = true,
+  int expectedExitCode = 0,
+  bool throwOnUnexpectedExitCode = false,
+}) async {
+  if (Platform.isWindows && !includeParentEnvironment) {
+    const winEnvKeys = [
+      'SYSTEMROOT',
+      'TEMP',
+      'TMP',
+    ];
+    environment = {
+      for (final winEnvKey in winEnvKeys)
+        winEnvKey: Platform.environment[winEnvKey]!,
+      ...?environment,
+    };
+  }
+
+  final printWorkingDir =
+      workingDirectory != null && workingDirectory != Directory.current.uri;
+  final commandString = [
+    if (printWorkingDir) '(cd ${workingDirectory.toFilePath()};',
+    ...?environment?.entries.map((entry) => '${entry.key}=${entry.value}'),
+    executable.toFilePath(),
+    ...arguments.map((a) => a.contains(' ') ? "'$a'" : a),
+    if (printWorkingDir) ')',
+  ].join(' ');
+  logger?.info('Running `$commandString`.');
+
+  final stdoutBuffer = StringBuffer();
+  final stderrBuffer = StringBuffer();
+  final process = await Process.start(
+    executable.toFilePath(),
+    arguments,
+    workingDirectory: workingDirectory?.toFilePath(),
+    environment: environment,
+    includeParentEnvironment: includeParentEnvironment,
+    runInShell: Platform.isWindows &&
+        (!includeParentEnvironment || workingDirectory != null),
+  );
+
+  final stdoutSub = process.stdout
+      .transform(utf8.decoder)
+      .transform(const LineSplitter())
+      .listen(captureOutput
+          ? (s) {
+              logger?.fine(s);
+              stdoutBuffer.writeln(s);
+            }
+          : logger?.fine);
+  final stderrSub = process.stderr
+      .transform(utf8.decoder)
+      .transform(const LineSplitter())
+      .listen(captureOutput
+          ? (s) {
+              logger?.severe(s);
+              stderrBuffer.writeln(s);
+            }
+          : logger?.severe);
+
+  final (exitCode, _, _) = await (
+    process.exitCode,
+    stdoutSub.asFuture<void>(),
+    stderrSub.asFuture<void>()
+  ).wait;
+  final result = RunProcessResult(
+    pid: process.pid,
+    command: commandString,
+    exitCode: exitCode,
+    stdout: stdoutBuffer.toString(),
+    stderr: stderrBuffer.toString(),
+  );
+  if (throwOnUnexpectedExitCode && expectedExitCode != exitCode) {
+    throw ProcessException(
+      executable.toFilePath(),
+      arguments,
+      "Full command string: '$commandString'.\n"
+      "Exit code: '$exitCode'.\n"
+      'For the output of the process check the logger output.',
+    );
+  }
+  return result;
+}
+
+/// Drop in replacement of [ProcessResult].
+class RunProcessResult {
+  final int pid;
+
+  final String command;
+
+  final int exitCode;
+
+  final String stderr;
+
+  final String stdout;
+
+  RunProcessResult({
+    required this.pid,
+    required this.command,
+    required this.exitCode,
+    required this.stderr,
+    required this.stdout,
+  });
+
+  @override
+  String toString() => '''command: $command
+exitCode: $exitCode
+stdout: $stdout
+stderr: $stderr''';
+}
diff --git a/pkgs/native_assets_builder/pubspec.yaml b/pkgs/native_assets_builder/pubspec.yaml
new file mode 100644
index 0000000..af8c48e
--- /dev/null
+++ b/pkgs/native_assets_builder/pubspec.yaml
@@ -0,0 +1,20 @@
+name: native_assets_builder
+description: >-
+  This package is the backend that invokes top-level `build.dart` scripts.
+version: 0.3.0
+repository: https://github.com/dart-lang/native/tree/main/pkgs/native_assets_builder
+
+environment:
+  sdk: '>=3.0.0 <4.0.0'
+
+dependencies:
+  graphs: ^2.3.1
+  logging: ^1.2.0
+  native_assets_cli: ^0.3.0
+  package_config: ^2.1.0
+
+dev_dependencies:
+  dart_flutter_team_lints: ^2.1.1
+  file_testing: ^3.0.0
+  test: ^1.24.3
+  yaml: ^3.1.2
diff --git a/pkgs/native_assets_builder/test/build_runner/build_dependencies_test.dart b/pkgs/native_assets_builder/test/build_runner/build_dependencies_test.dart
new file mode 100644
index 0000000..4e5de91
--- /dev/null
+++ b/pkgs/native_assets_builder/test/build_runner/build_dependencies_test.dart
@@ -0,0 +1,55 @@
+// Copyright (c) 2023, 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';
+
+import 'package:test/test.dart';
+
+import '../helpers.dart';
+import 'helpers.dart';
+
+const Timeout longTimeout = Timeout(Duration(minutes: 5));
+
+void main() async {
+  test('dart_app build dependencies', timeout: longTimeout, () async {
+    await inTempDir((tempUri) async {
+      await copyTestProjects(targetUri: tempUri);
+      final packageUri = tempUri.resolve('dart_app/');
+
+      // First, run `pub get`, we need pub to resolve our dependencies.
+      await runPubGet(
+        workingDirectory: packageUri,
+        logger: logger,
+      );
+
+      // Trigger a build, should invoke build for libraries with native assets.
+      {
+        final logMessages = <String>[];
+        final result = await build(packageUri, logger, dartExecutable,
+            capturedLogs: logMessages);
+        expect(
+          logMessages.join('\n'),
+          stringContainsInOrder(
+            [
+              'native_add${Platform.pathSeparator}build.dart',
+              'native_subtract${Platform.pathSeparator}build.dart'
+            ],
+          ),
+        );
+        expect(result.assets.length, 2);
+        expect(
+          result.dependencies,
+          [
+            tempUri.resolve('native_add/').resolve('build.dart'),
+            tempUri.resolve('native_add/').resolve('src/native_add.c'),
+            tempUri.resolve('native_subtract/').resolve('build.dart'),
+            tempUri
+                .resolve('native_subtract/')
+                .resolve('src/native_subtract.c'),
+          ],
+        );
+      }
+    });
+  });
+}
diff --git a/pkgs/native_assets_builder/test/build_runner/build_planner_test.dart b/pkgs/native_assets_builder/test/build_runner/build_planner_test.dart
new file mode 100644
index 0000000..48daae3
--- /dev/null
+++ b/pkgs/native_assets_builder/test/build_runner/build_planner_test.dart
@@ -0,0 +1,77 @@
+// Copyright (c) 2023, 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';
+
+import 'package:native_assets_builder/native_assets_builder.dart';
+import 'package:native_assets_builder/src/build_runner/build_planner.dart';
+import 'package:test/test.dart';
+
+import '../helpers.dart';
+import 'helpers.dart';
+
+void main() async {
+  test('build dependency graph from pub', () async {
+    await inTempDir((tempUri) async {
+      await copyTestProjects(targetUri: tempUri);
+      final nativeAddUri = tempUri.resolve('native_add/');
+
+      // First, run `pub get`, we need pub to resolve our dependencies.
+      await runPubGet(workingDirectory: nativeAddUri, logger: logger);
+
+      final result = await runProcess(
+        executable: Uri.file(Platform.resolvedExecutable),
+        arguments: [
+          'pub',
+          'deps',
+          '--json',
+        ],
+        workingDirectory: nativeAddUri,
+        logger: logger,
+      );
+      expect(result.exitCode, 0);
+
+      final graph = PackageGraph.fromPubDepsJsonString(result.stdout);
+
+      final packageLayout =
+          await PackageLayout.fromRootPackageRoot(nativeAddUri);
+      final packagesWithNativeAssets =
+          await packageLayout.packagesWithNativeAssets;
+
+      final planner = NativeAssetsBuildPlanner(
+        packageGraph: graph,
+        packagesWithNativeAssets: packagesWithNativeAssets,
+        dartExecutable: Uri.file(Platform.resolvedExecutable),
+        logger: logger,
+      );
+      final (buildPlan, _) = planner.plan();
+      expect(buildPlan.length, 1);
+      expect(buildPlan.single.name, 'native_add');
+    });
+  });
+  test('build dependency graph fromPackageRoot', () async {
+    await inTempDir((tempUri) async {
+      await copyTestProjects(targetUri: tempUri);
+      final nativeAddUri = tempUri.resolve('native_add/');
+
+      // First, run `pub get`, we need pub to resolve our dependencies.
+      await runPubGet(workingDirectory: nativeAddUri, logger: logger);
+
+      final packageLayout =
+          await PackageLayout.fromRootPackageRoot(nativeAddUri);
+      final packagesWithNativeAssets =
+          await packageLayout.packagesWithNativeAssets;
+      final nativeAssetsBuildPlanner =
+          await NativeAssetsBuildPlanner.fromRootPackageRoot(
+        rootPackageRoot: nativeAddUri,
+        packagesWithNativeAssets: packagesWithNativeAssets,
+        dartExecutable: Uri.file(Platform.resolvedExecutable),
+        logger: logger,
+      );
+      final (buildPlan, _) = nativeAssetsBuildPlanner.plan();
+      expect(buildPlan.length, 1);
+      expect(buildPlan.single.name, 'native_add');
+    });
+  });
+}
diff --git a/pkgs/native_assets_builder/test/build_runner/build_runner_asset_id_test.dart b/pkgs/native_assets_builder/test/build_runner/build_runner_asset_id_test.dart
new file mode 100644
index 0000000..19ad319
--- /dev/null
+++ b/pkgs/native_assets_builder/test/build_runner/build_runner_asset_id_test.dart
@@ -0,0 +1,67 @@
+// Copyright (c) 2023, 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:logging/logging.dart';
+import 'package:test/test.dart';
+
+import '../helpers.dart';
+import 'helpers.dart';
+
+const Timeout longTimeout = Timeout(Duration(minutes: 5));
+
+void main() async {
+  test('wrong asset id', timeout: longTimeout, () async {
+    await inTempDir((tempUri) async {
+      await copyTestProjects(targetUri: tempUri);
+      final packageUri = tempUri.resolve('wrong_namespace_asset/');
+
+      await runPubGet(
+        workingDirectory: packageUri,
+        logger: logger,
+      );
+
+      {
+        final logMessages = <String>[];
+        final result = await build(
+          packageUri,
+          createCapturingLogger(logMessages, level: Level.SEVERE),
+          dartExecutable,
+        );
+        final fullLog = logMessages.join('\n');
+        expect(result.success, false);
+        expect(
+          fullLog,
+          contains(
+            '`package:wrong_namespace_asset` declares the following assets '
+            'which do not start with `package:wrong_namespace_asset/`:',
+          ),
+        );
+      }
+    });
+  });
+
+  test('right asset id but other directory', timeout: longTimeout, () async {
+    await inTempDir((tempUri) async {
+      final packageUri = tempUri.resolve('different_root_dir/');
+      await copyTestProjects(
+        sourceUri: testDataUri.resolve('native_add/'),
+        targetUri: packageUri,
+      );
+
+      await runPubGet(
+        workingDirectory: packageUri,
+        logger: logger,
+      );
+
+      {
+        final result = await build(
+          packageUri,
+          logger,
+          dartExecutable,
+        );
+        expect(result.success, true);
+      }
+    });
+  });
+}
diff --git a/pkgs/native_assets_builder/test/build_runner/build_runner_build_output_format_test.dart b/pkgs/native_assets_builder/test/build_runner/build_runner_build_output_format_test.dart
new file mode 100644
index 0000000..21f15f9
--- /dev/null
+++ b/pkgs/native_assets_builder/test/build_runner/build_runner_build_output_format_test.dart
@@ -0,0 +1,55 @@
+// Copyright (c) 2023, 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:logging/logging.dart';
+import 'package:test/test.dart';
+
+import '../helpers.dart';
+import 'helpers.dart';
+
+const Timeout longTimeout = Timeout(Duration(minutes: 5));
+
+void main() async {
+  for (final package in [
+    'wrong_build_output',
+    'wrong_build_output_2',
+    'wrong_build_output_3',
+  ]) {
+    test('wrong build output $package', timeout: longTimeout, () async {
+      await inTempDir((tempUri) async {
+        await copyTestProjects(targetUri: tempUri);
+        final packageUri = tempUri.resolve('$package/');
+
+        await runPubGet(
+          workingDirectory: packageUri,
+          logger: logger,
+        );
+
+        // Run twice, failures should not be cached and return the same errors.
+        for (final _ in [1, 2]) {
+          final logMessages = <String>[];
+          final result = await build(
+            packageUri,
+            createCapturingLogger(logMessages, level: Level.SEVERE),
+            dartExecutable,
+          );
+          final fullLog = logMessages.join('\n');
+          expect(result.success, false);
+          if (package == 'wrong_build_output_3') {
+            // Should re-execute the process on second run.
+            expect(
+              fullLog,
+              contains('build.dart returned with exit code: 1.'),
+            );
+          } else {
+            expect(
+              fullLog,
+              contains('build_output.yaml contained a format error.'),
+            );
+          }
+        }
+      });
+    });
+  }
+}
diff --git a/pkgs/native_assets_builder/test/build_runner/build_runner_caching_test.dart b/pkgs/native_assets_builder/test/build_runner/build_runner_caching_test.dart
new file mode 100644
index 0000000..2749e53
--- /dev/null
+++ b/pkgs/native_assets_builder/test/build_runner/build_runner_caching_test.dart
@@ -0,0 +1,118 @@
+// Copyright (c) 2023, 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';
+
+import 'package:test/test.dart';
+
+import '../helpers.dart';
+import 'helpers.dart';
+
+const Timeout longTimeout = Timeout(Duration(minutes: 5));
+
+void main() async {
+  test('cached build', timeout: longTimeout, () async {
+    await inTempDir((tempUri) async {
+      await copyTestProjects(targetUri: tempUri);
+      final packageUri = tempUri.resolve('native_add/');
+
+      await runPubGet(
+        workingDirectory: packageUri,
+        logger: logger,
+      );
+
+      {
+        final logMessages = <String>[];
+        final result = await build(packageUri, logger, dartExecutable,
+            capturedLogs: logMessages);
+        expect(
+          logMessages.join('\n'),
+          contains('native_add${Platform.pathSeparator}build.dart'),
+        );
+        expect(
+          result.dependencies,
+          [
+            packageUri.resolve('build.dart'),
+            packageUri.resolve('src/native_add.c'),
+          ],
+        );
+      }
+
+      {
+        final logMessages = <String>[];
+        final result = await build(packageUri, logger, dartExecutable,
+            capturedLogs: logMessages);
+        expect(
+          logMessages.join('\n'),
+          contains('Skipping build for native_add'),
+        );
+        expect(
+          logMessages.join('\n'),
+          isNot(contains('native_add${Platform.pathSeparator}build.dart')),
+        );
+        expect(
+          result.dependencies,
+          [
+            packageUri.resolve('build.dart'),
+            packageUri.resolve('src/native_add.c'),
+          ],
+        );
+      }
+    });
+  });
+
+  test('modify C file', timeout: longTimeout, () async {
+    await inTempDir((tempUri) async {
+      await copyTestProjects(targetUri: tempUri);
+      final packageUri = tempUri.resolve('native_add/');
+
+      await runPubGet(
+        workingDirectory: packageUri,
+        logger: logger,
+      );
+
+      {
+        final result = await build(packageUri, logger, dartExecutable);
+        await expectSymbols(asset: result.assets.single, symbols: ['add']);
+      }
+
+      await copyTestProjects(
+        sourceUri: testDataUri.resolve('native_add_add_symbol/'),
+        targetUri: packageUri,
+      );
+
+      {
+        final result = await build(packageUri, logger, dartExecutable);
+        await expectSymbols(
+          asset: result.assets.single,
+          symbols: ['add', 'subtract'],
+        );
+      }
+    });
+  });
+
+  test('add C file, modify script', timeout: longTimeout, () async {
+    await inTempDir((tempUri) async {
+      await copyTestProjects(targetUri: tempUri);
+      final packageUri = tempUri.resolve('native_add/');
+
+      await runPubGet(workingDirectory: packageUri, logger: logger);
+
+      {
+        final result = await build(packageUri, logger, dartExecutable);
+        await expectSymbols(asset: result.assets.single, symbols: ['add']);
+      }
+
+      await copyTestProjects(
+          sourceUri: testDataUri.resolve('native_add_add_source/'),
+          targetUri: packageUri);
+
+      {
+        final result = await build(packageUri, logger, dartExecutable);
+        await expectSymbols(
+            asset: result.assets.single, symbols: ['add', 'multiply']);
+      }
+    });
+  });
+}
diff --git a/pkgs/native_assets_builder/test/build_runner/build_runner_cycle_test.dart b/pkgs/native_assets_builder/test/build_runner/build_runner_cycle_test.dart
new file mode 100644
index 0000000..94a09c7
--- /dev/null
+++ b/pkgs/native_assets_builder/test/build_runner/build_runner_cycle_test.dart
@@ -0,0 +1,60 @@
+// Copyright (c) 2023, 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:logging/logging.dart';
+import 'package:test/test.dart';
+
+import '../helpers.dart';
+import 'helpers.dart';
+
+const Timeout longTimeout = Timeout(Duration(minutes: 5));
+
+void main() async {
+  test('cycle', timeout: longTimeout, () async {
+    await inTempDir((tempUri) async {
+      await copyTestProjects(targetUri: tempUri);
+      final packageUri = tempUri.resolve('cyclic_package_1/');
+
+      await runPubGet(
+        workingDirectory: packageUri,
+        logger: logger,
+      );
+      {
+        final logMessages = <String>[];
+        final result = await dryRun(
+          packageUri,
+          createCapturingLogger(logMessages, level: Level.SEVERE),
+          dartExecutable,
+        );
+        final fullLog = logMessages.join('\n');
+        expect(result.success, false);
+        expect(
+          fullLog,
+          contains(
+            'Cyclic dependency for native asset builds in the following '
+            'packages: [cyclic_package_1, cyclic_package_2]',
+          ),
+        );
+      }
+
+      {
+        final logMessages = <String>[];
+        final result = await build(
+          packageUri,
+          createCapturingLogger(logMessages, level: Level.SEVERE),
+          dartExecutable,
+        );
+        final fullLog = logMessages.join('\n');
+        expect(result.success, false);
+        expect(
+          fullLog,
+          contains(
+            'Cyclic dependency for native asset builds in the following '
+            'packages: [cyclic_package_1, cyclic_package_2]',
+          ),
+        );
+      }
+    });
+  });
+}
diff --git a/pkgs/native_assets_builder/test/build_runner/build_runner_dry_run_test.dart b/pkgs/native_assets_builder/test/build_runner/build_runner_dry_run_test.dart
new file mode 100644
index 0000000..be4f817
--- /dev/null
+++ b/pkgs/native_assets_builder/test/build_runner/build_runner_dry_run_test.dart
@@ -0,0 +1,50 @@
+// Copyright (c) 2023, 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';
+
+import 'package:file_testing/file_testing.dart';
+import 'package:native_assets_cli/native_assets_cli.dart';
+import 'package:test/test.dart';
+
+import '../helpers.dart';
+import 'helpers.dart';
+
+const Timeout longTimeout = Timeout(Duration(minutes: 5));
+
+void main() async {
+  test('dry_run', timeout: longTimeout, () async {
+    await inTempDir((tempUri) async {
+      await copyTestProjects(targetUri: tempUri);
+      final packageUri = tempUri.resolve('native_add/');
+
+      await runPubGet(
+        workingDirectory: packageUri,
+        logger: logger,
+      );
+
+      final dryRunAssets = (await dryRun(packageUri, logger, dartExecutable))
+          .assets
+          .where((element) => element.target == Target.current)
+          .toList();
+      final result = await build(packageUri, logger, dartExecutable);
+
+      expect(dryRunAssets.length, result.assets.length);
+      for (var i = 0; i < dryRunAssets.length; i++) {
+        final dryRunAsset = dryRunAssets[0];
+        final buildAsset = result.assets[0];
+        expect(dryRunAsset.linkMode, buildAsset.linkMode);
+        expect(dryRunAsset.id, buildAsset.id);
+        expect(dryRunAsset.target, buildAsset.target);
+        // The target folders are different, so the paths are different.
+      }
+
+      final dryRunDir = packageUri.resolve(
+          '.dart_tool/native_assets_builder/dry_run_${Target.current.os}_dynamic/');
+      expect(File.fromUri(dryRunDir.resolve('config.yaml')), exists);
+      expect(File.fromUri(dryRunDir.resolve('out/build_output.yaml')), exists);
+      //
+    });
+  });
+}
diff --git a/pkgs/native_assets_builder/test/build_runner/build_runner_failure_test.dart b/pkgs/native_assets_builder/test/build_runner/build_runner_failure_test.dart
new file mode 100644
index 0000000..c844be2
--- /dev/null
+++ b/pkgs/native_assets_builder/test/build_runner/build_runner_failure_test.dart
@@ -0,0 +1,83 @@
+// Copyright (c) 2023, 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';
+
+import 'package:logging/logging.dart';
+import 'package:test/test.dart';
+
+import '../helpers.dart';
+import 'helpers.dart';
+
+const Timeout longTimeout = Timeout(Duration(minutes: 5));
+
+void main() async {
+  test('break build', timeout: longTimeout, () async {
+    await inTempDir((tempUri) async {
+      await copyTestProjects(targetUri: tempUri);
+      final packageUri = tempUri.resolve('native_add/');
+
+      await runPubGet(
+        workingDirectory: packageUri,
+        logger: logger,
+      );
+
+      {
+        final result = await build(packageUri, logger, dartExecutable);
+        expect(result.assets.length, 1);
+        await expectSymbols(asset: result.assets.single, symbols: ['add']);
+        expect(
+          result.dependencies,
+          [
+            packageUri.resolve('build.dart'),
+            packageUri.resolve('src/native_add.c'),
+          ],
+        );
+      }
+
+      await copyTestProjects(
+        sourceUri: testDataUri.resolve('native_add_break_build/'),
+        targetUri: packageUri,
+      );
+
+      {
+        final logMessages = <String>[];
+        final result = await build(
+          packageUri,
+          createCapturingLogger(logMessages, level: Level.SEVERE),
+          dartExecutable,
+        );
+        final fullLog = logMessages.join('\n');
+        expect(result.success, false);
+        expect(fullLog, contains('To reproduce run:'));
+        final reproCommand = fullLog
+            .split('\n')
+            .skipWhile((l) => l != 'To reproduce run:')
+            .skip(1)
+            .first;
+        final reproResult =
+            await Process.run(reproCommand, [], runInShell: true);
+        expect(reproResult.exitCode, isNot(0));
+      }
+
+      await copyTestProjects(
+        sourceUri: testDataUri.resolve('native_add_fix_build/'),
+        targetUri: packageUri,
+      );
+
+      {
+        final result = await build(packageUri, logger, dartExecutable);
+        expect(result.assets.length, 1);
+        await expectSymbols(asset: result.assets.single, symbols: ['add']);
+        expect(
+          result.dependencies,
+          [
+            packageUri.resolve('build.dart'),
+            packageUri.resolve('src/native_add.c'),
+          ],
+        );
+      }
+    });
+  });
+}
diff --git a/pkgs/native_assets_builder/test/build_runner/build_runner_reusability_test.dart b/pkgs/native_assets_builder/test/build_runner/build_runner_reusability_test.dart
new file mode 100644
index 0000000..f4313cd
--- /dev/null
+++ b/pkgs/native_assets_builder/test/build_runner/build_runner_reusability_test.dart
@@ -0,0 +1,59 @@
+// Copyright (c) 2023, 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:native_assets_builder/src/build_runner/build_runner.dart';
+import 'package:native_assets_cli/native_assets_cli.dart';
+import 'package:test/test.dart';
+
+import '../helpers.dart';
+import 'helpers.dart';
+
+const Timeout longTimeout = Timeout(Duration(minutes: 5));
+
+void main() async {
+  test('multiple dryRun and build invocations', timeout: longTimeout, () async {
+    await inTempDir((tempUri) async {
+      await copyTestProjects(targetUri: tempUri);
+      final packageUri = tempUri.resolve('package_reading_metadata/');
+
+      // First, run `pub get`, we need pub to resolve our dependencies.
+      await runPubGet(
+        workingDirectory: packageUri,
+        logger: logger,
+      );
+
+      final buildRunner = NativeAssetsBuildRunner(
+        logger: logger,
+        dartExecutable: dartExecutable,
+      );
+
+      await buildRunner.dryRun(
+        targetOs: Target.current.os,
+        linkModePreference: LinkModePreference.dynamic,
+        workingDirectory: packageUri,
+        includeParentEnvironment: true,
+      );
+      await buildRunner.dryRun(
+        targetOs: Target.current.os,
+        linkModePreference: LinkModePreference.dynamic,
+        workingDirectory: packageUri,
+        includeParentEnvironment: true,
+      );
+      await buildRunner.build(
+        buildMode: BuildMode.release,
+        linkModePreference: LinkModePreference.dynamic,
+        target: Target.current,
+        workingDirectory: packageUri,
+        includeParentEnvironment: true,
+      );
+      await buildRunner.build(
+        buildMode: BuildMode.release,
+        linkModePreference: LinkModePreference.dynamic,
+        target: Target.current,
+        workingDirectory: packageUri,
+        includeParentEnvironment: true,
+      );
+    });
+  });
+}
diff --git a/pkgs/native_assets_builder/test/build_runner/build_runner_run_in_isolation_test.dart b/pkgs/native_assets_builder/test/build_runner/build_runner_run_in_isolation_test.dart
new file mode 100644
index 0000000..ffcbdc0
--- /dev/null
+++ b/pkgs/native_assets_builder/test/build_runner/build_runner_run_in_isolation_test.dart
@@ -0,0 +1,72 @@
+// Copyright (c) 2023, 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';
+
+import 'package:native_assets_cli/native_assets_cli.dart';
+import 'package:test/test.dart';
+
+import '../helpers.dart';
+import 'helpers.dart';
+
+const Timeout longTimeout = Timeout(Duration(minutes: 5));
+
+void main() async {
+  String unparseKey(String key) => key.replaceAll('.', '__').toUpperCase();
+  final arKey = unparseKey(CCompilerConfig.arConfigKeyFull);
+  final ccKey = unparseKey(CCompilerConfig.ccConfigKeyFull);
+  final ldKey = unparseKey(CCompilerConfig.ldConfigKeyFull);
+  final envScriptKey = unparseKey(CCompilerConfig.envScriptConfigKeyFull);
+  final envScriptArgsKey =
+      unparseKey(CCompilerConfig.envScriptArgsConfigKeyFull);
+
+  final cc = Platform.environment[ccKey]?.fileUri;
+
+  if (cc == null) {
+    // We don't set any compiler paths on the GitHub CI.
+    // We neither set compiler paths on MacOS on the Dart SDK CI
+    // in pkg/test_runner/lib/src/configuration.dart
+    // nativeCompilerEnvironmentVariables.
+    //
+    // We could potentially run this test if we default to some compilers
+    // we find on the path before running the test. However, the logic for
+    // discovering compilers is currently hidden inside
+    // package:native_toolchain_c.
+    return;
+  }
+
+  test('run in isolation', timeout: longTimeout, () async {
+    await inTempDir((tempUri) async {
+      await copyTestProjects(targetUri: tempUri);
+      final packageUri = tempUri.resolve('native_add/');
+
+      await runPubGet(workingDirectory: packageUri, logger: logger);
+
+      printOnFailure(
+          'Platform.environment[ccKey]: ${Platform.environment[ccKey]}');
+      printOnFailure('cc: $cc');
+
+      final result = await build(
+        packageUri,
+        logger,
+        dartExecutable,
+        // Manually pass in a compiler.
+        cCompilerConfig: CCompilerConfig(
+          ar: Platform.environment[arKey]?.fileUri,
+          cc: cc,
+          envScript: Platform.environment[envScriptKey]?.fileUri,
+          envScriptArgs: Platform.environment[envScriptArgsKey]?.split(' '),
+          ld: Platform.environment[ldKey]?.fileUri,
+        ),
+        // Prevent any other environment variables.
+        includeParentEnvironment: false,
+      );
+      expect(result.assets.length, 1);
+    });
+  });
+}
+
+extension on String {
+  Uri get fileUri => Uri.file(this);
+}
diff --git a/pkgs/native_assets_builder/test/build_runner/build_runner_test.dart b/pkgs/native_assets_builder/test/build_runner/build_runner_test.dart
new file mode 100644
index 0000000..f6fff2c
--- /dev/null
+++ b/pkgs/native_assets_builder/test/build_runner/build_runner_test.dart
@@ -0,0 +1,63 @@
+// Copyright (c) 2023, 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';
+
+import 'package:native_assets_builder/native_assets_builder.dart';
+import 'package:test/test.dart';
+
+import '../helpers.dart';
+import 'helpers.dart';
+
+const Timeout longTimeout = Timeout(Duration(minutes: 5));
+
+void main() async {
+  test('native_add build', timeout: longTimeout, () async {
+    await inTempDir((tempUri) async {
+      await copyTestProjects(targetUri: tempUri);
+      final packageUri = tempUri.resolve('native_add/');
+
+      // First, run `pub get`, we need pub to resolve our dependencies.
+      await runPubGet(
+        workingDirectory: packageUri,
+        logger: logger,
+      );
+
+      // Trigger a build, should invoke build for libraries with native assets.
+      {
+        final logMessages = <String>[];
+        final result = await build(packageUri, logger, dartExecutable,
+            capturedLogs: logMessages);
+        expect(
+            logMessages.join('\n'),
+            stringContainsInOrder(
+                ['native_add${Platform.pathSeparator}build.dart']));
+        expect(result.assets.length, 1);
+      }
+
+      // Trigger a build, should not invoke anything.
+      for (final passPackageLayout in [true, false]) {
+        PackageLayout? packageLayout;
+        if (passPackageLayout) {
+          packageLayout = await PackageLayout.fromRootPackageRoot(packageUri);
+        }
+        final logMessages = <String>[];
+        final result = await build(
+          packageUri,
+          logger,
+          dartExecutable,
+          capturedLogs: logMessages,
+          packageLayout: packageLayout,
+        );
+        expect(
+          false,
+          logMessages
+              .join('\n')
+              .contains('native_add${Platform.pathSeparator}build.dart'),
+        );
+        expect(result.assets.length, 1);
+      }
+    });
+  });
+}
diff --git a/pkgs/native_assets_builder/test/build_runner/helpers.dart b/pkgs/native_assets_builder/test/build_runner/helpers.dart
new file mode 100644
index 0000000..9609c1c
--- /dev/null
+++ b/pkgs/native_assets_builder/test/build_runner/helpers.dart
@@ -0,0 +1,138 @@
+// Copyright (c) 2023, 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';
+
+import 'package:file_testing/file_testing.dart';
+import 'package:logging/logging.dart';
+import 'package:native_assets_builder/native_assets_builder.dart';
+import 'package:native_assets_cli/native_assets_cli.dart';
+import 'package:test/test.dart';
+
+import '../helpers.dart';
+
+Future<void> runPubGet({
+  required Uri workingDirectory,
+  required Logger logger,
+}) async {
+  final result = await runProcess(
+    executable: Uri.file(Platform.resolvedExecutable),
+    arguments: [
+      'pub',
+      '--suppress-analytics', // Prevent extra log entries.
+      'get',
+    ],
+    workingDirectory: workingDirectory,
+    logger: logger,
+  );
+  expect(result.exitCode, 0);
+}
+
+Future<BuildResult> build(
+  Uri packageUri,
+  Logger logger,
+  Uri dartExecutable, {
+  LinkModePreference linkModePreference = LinkModePreference.dynamic,
+  CCompilerConfig? cCompilerConfig,
+  bool includeParentEnvironment = true,
+  List<String>? capturedLogs,
+  PackageLayout? packageLayout,
+}) async {
+  StreamSubscription<LogRecord>? subscription;
+  if (capturedLogs != null) {
+    subscription =
+        logger.onRecord.listen((event) => capturedLogs.add(event.message));
+  }
+
+  final result = await NativeAssetsBuildRunner(
+    logger: logger,
+    dartExecutable: dartExecutable,
+  ).build(
+    buildMode: BuildMode.release,
+    linkModePreference: linkModePreference,
+    target: Target.current,
+    workingDirectory: packageUri,
+    cCompilerConfig: cCompilerConfig,
+    includeParentEnvironment: includeParentEnvironment,
+    packageLayout: packageLayout,
+  );
+  if (result.success) {
+    await expectAssetsExist(result.assets);
+  }
+
+  if (subscription != null) {
+    await subscription.cancel();
+  }
+
+  return result;
+}
+
+Future<DryRunResult> dryRun(
+  Uri packageUri,
+  Logger logger,
+  Uri dartExecutable, {
+  LinkModePreference linkModePreference = LinkModePreference.dynamic,
+  CCompilerConfig? cCompilerConfig,
+  bool includeParentEnvironment = true,
+  List<String>? capturedLogs,
+  PackageLayout? packageLayout,
+}) async {
+  StreamSubscription<LogRecord>? subscription;
+  if (capturedLogs != null) {
+    subscription =
+        logger.onRecord.listen((event) => capturedLogs.add(event.message));
+  }
+
+  final result = await NativeAssetsBuildRunner(
+    logger: logger,
+    dartExecutable: dartExecutable,
+  ).dryRun(
+    linkModePreference: linkModePreference,
+    targetOs: Target.current.os,
+    workingDirectory: packageUri,
+    includeParentEnvironment: includeParentEnvironment,
+    packageLayout: packageLayout,
+  );
+
+  if (subscription != null) {
+    await subscription.cancel();
+  }
+
+  return result;
+}
+
+Future<void> expectAssetsExist(List<Asset> assets) async {
+  for (final asset in assets) {
+    final uri = (asset.path as AssetAbsolutePath).uri;
+    expect(
+        uri.toFilePath(),
+        contains('${Platform.pathSeparator}.dart_tool${Platform.pathSeparator}'
+            'native_assets_builder${Platform.pathSeparator}'));
+    final file = File.fromUri(uri);
+    expect(file, exists);
+  }
+}
+
+Future<void> expectSymbols({
+  required Asset asset,
+  required List<String> symbols,
+}) async {
+  if (Platform.isLinux) {
+    final assetUri = (asset.path as AssetAbsolutePath).uri;
+    final nmResult = await runProcess(
+      executable: Uri(path: 'nm'),
+      arguments: [
+        '-D',
+        assetUri.toFilePath(),
+      ],
+      logger: logger,
+    );
+
+    expect(
+      nmResult.stdout,
+      stringContainsInOrder(symbols),
+    );
+  }
+}
diff --git a/pkgs/native_assets_builder/test/build_runner/metadata_test.dart b/pkgs/native_assets_builder/test/build_runner/metadata_test.dart
new file mode 100644
index 0000000..12e9461
--- /dev/null
+++ b/pkgs/native_assets_builder/test/build_runner/metadata_test.dart
@@ -0,0 +1,41 @@
+// Copyright (c) 2023, 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';
+
+import 'package:test/test.dart';
+
+import '../helpers.dart';
+import 'helpers.dart';
+
+const Timeout longTimeout = Timeout(Duration(minutes: 5));
+
+void main() async {
+  test('get dependency metadata', timeout: longTimeout, () async {
+    await inTempDir((tempUri) async {
+      await copyTestProjects(targetUri: tempUri);
+      final packageUri = tempUri.resolve('package_reading_metadata/');
+
+      // First, run `pub get`, we need pub to resolve our dependencies.
+      await runPubGet(
+        workingDirectory: packageUri,
+        logger: logger,
+      );
+
+      // Trigger a build, should invoke build for libraries with native assets.
+      {
+        final logMessages = <String>[];
+        await build(packageUri, logger, dartExecutable,
+            capturedLogs: logMessages);
+        expect(
+            logMessages.join('\n'),
+            stringContainsInOrder([
+              'package_with_metadata${Platform.pathSeparator}build.dart',
+              'package_reading_metadata${Platform.pathSeparator}build.dart',
+              '{some_int: 3, some_key: some_value}',
+            ]));
+      }
+    });
+  });
+}
diff --git a/pkgs/native_assets_builder/test/build_runner/package_layout_test.dart b/pkgs/native_assets_builder/test/build_runner/package_layout_test.dart
new file mode 100644
index 0000000..a92992f
--- /dev/null
+++ b/pkgs/native_assets_builder/test/build_runner/package_layout_test.dart
@@ -0,0 +1,29 @@
+// Copyright (c) 2023, 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:native_assets_builder/native_assets_builder.dart';
+import 'package:test/test.dart';
+
+import '../helpers.dart';
+import 'helpers.dart';
+
+void main() async {
+  test('fromRootPackageRoot', () async {
+    await inTempDir((tempUri) async {
+      await copyTestProjects(targetUri: tempUri);
+      final nativeAddUri = tempUri.resolve('native_add/');
+
+      // First, run `pub get`, we need pub to resolve our dependencies.
+      await runPubGet(workingDirectory: nativeAddUri, logger: logger);
+
+      final packageLayout =
+          await PackageLayout.fromRootPackageRoot(nativeAddUri);
+      final packageLayout2 = PackageLayout.fromPackageConfig(
+        packageLayout.packageConfig,
+        packageLayout.packageConfigUri,
+      );
+      expect(packageLayout.rootPackageRoot, packageLayout2.rootPackageRoot);
+    });
+  });
+}
diff --git a/pkgs/native_assets_builder/test/build_runner/packaging_preference_test.dart b/pkgs/native_assets_builder/test/build_runner/packaging_preference_test.dart
new file mode 100644
index 0000000..e1fefee
--- /dev/null
+++ b/pkgs/native_assets_builder/test/build_runner/packaging_preference_test.dart
@@ -0,0 +1,60 @@
+// Copyright (c) 2023, 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:native_assets_cli/native_assets_cli.dart';
+import 'package:test/test.dart';
+
+import '../helpers.dart';
+import 'helpers.dart';
+
+const Timeout longTimeout = Timeout(Duration(minutes: 5));
+
+void main() async {
+  test('link mode preference', timeout: longTimeout, () async {
+    await inTempDir((tempUri) async {
+      await copyTestProjects(targetUri: tempUri);
+      final packageUri = tempUri.resolve('native_add/');
+
+      // First, run `pub get`, we need pub to resolve our dependencies.
+      await runPubGet(
+        workingDirectory: packageUri,
+        logger: logger,
+      );
+
+      final resultDynamic = await build(
+        packageUri,
+        logger,
+        dartExecutable,
+        linkModePreference: LinkModePreference.dynamic,
+      );
+
+      final resultPreferDynamic = await build(
+        packageUri,
+        logger,
+        dartExecutable,
+        linkModePreference: LinkModePreference.preferDynamic,
+      );
+
+      final resultStatic = await build(
+        packageUri,
+        logger,
+        dartExecutable,
+        linkModePreference: LinkModePreference.static,
+      );
+
+      final resultPreferStatic = await build(
+        packageUri,
+        logger,
+        dartExecutable,
+        linkModePreference: LinkModePreference.preferStatic,
+      );
+
+      // This package honors preferences.
+      expect(resultDynamic.assets.single.linkMode, LinkMode.dynamic);
+      expect(resultPreferDynamic.assets.single.linkMode, LinkMode.dynamic);
+      expect(resultStatic.assets.single.linkMode, LinkMode.static);
+      expect(resultPreferStatic.assets.single.linkMode, LinkMode.static);
+    });
+  });
+}
diff --git a/pkgs/native_assets_builder/test/data/README.md b/pkgs/native_assets_builder/test/data/README.md
new file mode 100644
index 0000000..311839a
--- /dev/null
+++ b/pkgs/native_assets_builder/test/data/README.md
@@ -0,0 +1,6 @@
+The `dart_app` depends on multiple packages with native assets.
+
+`manifest.yaml` contains a list of all the files of the test projects.
+This is used to copy the test projects to temporary folders when running tests,
+to prevent tests accidentally using temporary files or interferring with each
+other.
diff --git a/pkgs/native_assets_builder/test/data/cyclic_package_1/build.dart b/pkgs/native_assets_builder/test/data/cyclic_package_1/build.dart
new file mode 100644
index 0000000..2479e4e
--- /dev/null
+++ b/pkgs/native_assets_builder/test/data/cyclic_package_1/build.dart
@@ -0,0 +1,11 @@
+// Copyright (c) 2023, 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:native_assets_cli/native_assets_cli.dart';
+
+void main(List<String> args) async {
+  final buildConfig = await BuildConfig.fromArgs(args);
+  final buildOutput = BuildOutput();
+  await buildOutput.writeToFile(outDir: buildConfig.outDir);
+}
diff --git a/pkgs/native_assets_builder/test/data/cyclic_package_1/pubspec.yaml b/pkgs/native_assets_builder/test/data/cyclic_package_1/pubspec.yaml
new file mode 100644
index 0000000..ad3302f
--- /dev/null
+++ b/pkgs/native_assets_builder/test/data/cyclic_package_1/pubspec.yaml
@@ -0,0 +1,19 @@
+name: cyclic_package_1
+description: Part of two packages with native assets that have a cycle.
+version: 0.1.0
+
+publish_to: none
+
+environment:
+  sdk: '>=3.0.0 <4.0.0'
+
+dependencies:
+  cli_config: ^0.1.1
+  cyclic_package_2:
+    path: ../cyclic_package_2
+  native_assets_cli: ^0.3.0
+  yaml: ^3.1.1
+  yaml_edit: ^2.1.0
+
+dev_dependencies:
+  lints: ^3.0.0
diff --git a/pkgs/native_assets_builder/test/data/cyclic_package_2/build.dart b/pkgs/native_assets_builder/test/data/cyclic_package_2/build.dart
new file mode 100644
index 0000000..2479e4e
--- /dev/null
+++ b/pkgs/native_assets_builder/test/data/cyclic_package_2/build.dart
@@ -0,0 +1,11 @@
+// Copyright (c) 2023, 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:native_assets_cli/native_assets_cli.dart';
+
+void main(List<String> args) async {
+  final buildConfig = await BuildConfig.fromArgs(args);
+  final buildOutput = BuildOutput();
+  await buildOutput.writeToFile(outDir: buildConfig.outDir);
+}
diff --git a/pkgs/native_assets_builder/test/data/cyclic_package_2/pubspec.yaml b/pkgs/native_assets_builder/test/data/cyclic_package_2/pubspec.yaml
new file mode 100644
index 0000000..1bf06f4
--- /dev/null
+++ b/pkgs/native_assets_builder/test/data/cyclic_package_2/pubspec.yaml
@@ -0,0 +1,19 @@
+name: cyclic_package_2
+description: Part of two packages with native assets that have a cycle.
+version: 0.1.0
+
+publish_to: none
+
+environment:
+  sdk: '>=3.0.0 <4.0.0'
+
+dependencies:
+  cli_config: ^0.1.1
+  cyclic_package_1:
+    path: ../cyclic_package_1
+  native_assets_cli: ^0.3.0
+  yaml: ^3.1.1
+  yaml_edit: ^2.1.0
+
+dev_dependencies:
+  lints: ^3.0.0
diff --git a/pkgs/native_assets_builder/test/data/dart_app/.gitignore b/pkgs/native_assets_builder/test/data/dart_app/.gitignore
new file mode 100644
index 0000000..2a9e6b8
--- /dev/null
+++ b/pkgs/native_assets_builder/test/data/dart_app/.gitignore
@@ -0,0 +1,2 @@
+.dart_tool
+bin/dart_app/
diff --git a/pkgs/native_assets_builder/test/data/dart_app/bin/dart_app.dart b/pkgs/native_assets_builder/test/data/dart_app/bin/dart_app.dart
new file mode 100644
index 0000000..4e9dc84
--- /dev/null
+++ b/pkgs/native_assets_builder/test/data/dart_app/bin/dart_app.dart
@@ -0,0 +1,27 @@
+// Copyright (c) 2023, 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:native_add/native_add.dart';
+import 'package:native_subtract/native_subtract.dart';
+
+void main() {
+  testNativeAdd();
+  testNativeSubtract();
+}
+
+void testNativeAdd() {
+  final answer = add(5, 6);
+  if (answer != 5 + 6) {
+    throw 'Wrong answer';
+  }
+  print('add(5, 6) = $answer');
+}
+
+void testNativeSubtract() {
+  final answer = subtract(5, 6);
+  if (answer != 5 - 6) {
+    throw 'Wrong answer';
+  }
+  print('subtract(5, 6) = $answer');
+}
diff --git a/pkgs/native_assets_builder/test/data/dart_app/pubspec.yaml b/pkgs/native_assets_builder/test/data/dart_app/pubspec.yaml
new file mode 100644
index 0000000..1ead5cb
--- /dev/null
+++ b/pkgs/native_assets_builder/test/data/dart_app/pubspec.yaml
@@ -0,0 +1,12 @@
+name: dart_app
+
+publish_to: none
+
+environment:
+  sdk: '>=3.0.0 <4.0.0'
+
+dependencies:
+  native_add:
+    path: ../native_add
+  native_subtract:
+    path: ../native_subtract
diff --git a/pkgs/native_assets_builder/test/data/manifest.yaml b/pkgs/native_assets_builder/test/data/manifest.yaml
new file mode 100644
index 0000000..c52f500
--- /dev/null
+++ b/pkgs/native_assets_builder/test/data/manifest.yaml
@@ -0,0 +1,37 @@
+# The list of files to copy to a temporary folder to ensure running tests from
+# a completely clean setup.
+- cyclic_package_1/build.dart
+- cyclic_package_1/pubspec.yaml
+- cyclic_package_2/build.dart
+- cyclic_package_2/pubspec.yaml
+- dart_app/bin/dart_app.dart
+- dart_app/pubspec.yaml
+- native_add/build.dart
+- native_add/ffigen.yaml
+- native_add/lib/native_add.dart
+- native_add/lib/src/native_add_bindings_generated.dart
+- native_add/lib/src/native_add.dart
+- native_add/pubspec.yaml
+- native_add/src/native_add.c
+- native_add/src/native_add.h
+- native_add/test/native_add_test.dart
+- native_subtract/build.dart
+- native_subtract/ffigen.yaml
+- native_subtract/lib/native_subtract.dart
+- native_subtract/lib/src/native_subtract_bindings_generated.dart
+- native_subtract/lib/src/native_subtract.dart
+- native_subtract/pubspec.yaml
+- native_subtract/src/native_subtract.c
+- native_subtract/src/native_subtract.h
+- package_reading_metadata/build.dart
+- package_reading_metadata/pubspec.yaml
+- package_with_metadata/build.dart
+- package_with_metadata/pubspec.yaml
+- wrong_build_output/build.dart
+- wrong_build_output/pubspec.yaml
+- wrong_build_output_2/build.dart
+- wrong_build_output_2/pubspec.yaml
+- wrong_build_output_3/build.dart
+- wrong_build_output_3/pubspec.yaml
+- wrong_namespace_asset/build.dart
+- wrong_namespace_asset/pubspec.yaml
diff --git a/pkgs/native_assets_builder/test/data/native_add/build.dart b/pkgs/native_assets_builder/test/data/native_add/build.dart
new file mode 100644
index 0000000..69bbb9e
--- /dev/null
+++ b/pkgs/native_assets_builder/test/data/native_add/build.dart
@@ -0,0 +1,31 @@
+// Copyright (c) 2023, 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:logging/logging.dart';
+import 'package:native_assets_cli/native_assets_cli.dart';
+import 'package:native_toolchain_c/native_toolchain_c.dart';
+
+const packageName = 'native_add';
+
+void main(List<String> args) async {
+  final buildConfig = await BuildConfig.fromArgs(args);
+  final buildOutput = BuildOutput();
+  final cbuilder = CBuilder.library(
+    name: packageName,
+    assetId: 'package:$packageName/src/${packageName}_bindings_generated.dart',
+    sources: [
+      'src/$packageName.c',
+    ],
+  );
+  await cbuilder.run(
+    buildConfig: buildConfig,
+    buildOutput: buildOutput,
+    logger: Logger('')
+      ..level = Level.ALL
+      ..onRecord.listen((record) {
+        print('${record.level.name}: ${record.time}: ${record.message}');
+      }),
+  );
+  await buildOutput.writeToFile(outDir: buildConfig.outDir);
+}
diff --git a/pkgs/native_assets_builder/test/data/native_add/ffigen.yaml b/pkgs/native_assets_builder/test/data/native_add/ffigen.yaml
new file mode 100644
index 0000000..b8716bd
--- /dev/null
+++ b/pkgs/native_assets_builder/test/data/native_add/ffigen.yaml
@@ -0,0 +1,20 @@
+# Run with `flutter pub run ffigen --config ffigen.yaml`.
+name: NativeAddBindings
+description: |
+  Bindings for `src/native_add.h`.
+
+  Regenerate bindings with `flutter pub run ffigen --config ffigen.yaml`.
+output: "lib/src/native_add_bindings_generated.dart"
+headers:
+  entry-points:
+    - "src/native_add.h"
+  include-directives:
+    - "src/native_add.h"
+preamble: |
+  // Copyright (c) 2023, 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.
+comments:
+  style: any
+  length: full
+ffi-native:
diff --git a/pkgs/native_assets_builder/test/data/native_add/lib/native_add.dart b/pkgs/native_assets_builder/test/data/native_add/lib/native_add.dart
new file mode 100644
index 0000000..9eda681
--- /dev/null
+++ b/pkgs/native_assets_builder/test/data/native_add/lib/native_add.dart
@@ -0,0 +1,5 @@
+// Copyright (c) 2023, 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/native_add.dart';
diff --git a/pkgs/native_assets_builder/test/data/native_add/lib/src/native_add.dart b/pkgs/native_assets_builder/test/data/native_add/lib/src/native_add.dart
new file mode 100644
index 0000000..9c8a43b
--- /dev/null
+++ b/pkgs/native_assets_builder/test/data/native_add/lib/src/native_add.dart
@@ -0,0 +1,7 @@
+// Copyright (c) 2023, 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 'native_add_bindings_generated.dart' as bindings;
+
+int add(int a, int b) => bindings.add(a, b);
diff --git a/pkgs/native_assets_builder/test/data/native_add/lib/src/native_add_bindings_generated.dart b/pkgs/native_assets_builder/test/data/native_add/lib/src/native_add_bindings_generated.dart
new file mode 100644
index 0000000..4d1e803
--- /dev/null
+++ b/pkgs/native_assets_builder/test/data/native_add/lib/src/native_add_bindings_generated.dart
@@ -0,0 +1,15 @@
+// Copyright (c) 2023, 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.
+
+// AUTO GENERATED FILE, DO NOT EDIT.
+//
+// Generated by `package:ffigen`.
+// ignore_for_file: type=lint
+import 'dart:ffi' as ffi;
+
+@ffi.Native<ffi.Int32 Function(ffi.Int32, ffi.Int32)>(symbol: 'add')
+external int add(
+  int a,
+  int b,
+);
diff --git a/pkgs/native_assets_builder/test/data/native_add/manifest.yaml b/pkgs/native_assets_builder/test/data/native_add/manifest.yaml
new file mode 100644
index 0000000..e922db8
--- /dev/null
+++ b/pkgs/native_assets_builder/test/data/native_add/manifest.yaml
@@ -0,0 +1,9 @@
+- build.dart
+- ffigen.yaml
+- lib/native_add.dart
+- lib/src/native_add_bindings_generated.dart
+- lib/src/native_add.dart
+- pubspec.yaml
+- src/native_add.c
+- src/native_add.h
+- test/native_add_test.dart
diff --git a/pkgs/native_assets_builder/test/data/native_add/pubspec.yaml b/pkgs/native_assets_builder/test/data/native_add/pubspec.yaml
new file mode 100644
index 0000000..67632a6
--- /dev/null
+++ b/pkgs/native_assets_builder/test/data/native_add/pubspec.yaml
@@ -0,0 +1,19 @@
+name: native_add
+description: Sums two numbers with native code.
+version: 0.1.0
+
+publish_to: none
+
+environment:
+  sdk: '>=3.0.0 <4.0.0'
+
+dependencies:
+  cli_config: ^0.1.1
+  logging: ^1.1.1
+  native_assets_cli: ^0.3.0
+  native_toolchain_c: ^0.3.0
+
+dev_dependencies:
+  ffigen: ^8.0.2
+  lints: ^3.0.0
+  test: ^1.23.1
diff --git a/pkgs/native_assets_builder/test/data/native_add/src/native_add.c b/pkgs/native_assets_builder/test/data/native_add/src/native_add.c
new file mode 100644
index 0000000..cf21076
--- /dev/null
+++ b/pkgs/native_assets_builder/test/data/native_add/src/native_add.c
@@ -0,0 +1,9 @@
+// Copyright (c) 2023, 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.
+
+#include "native_add.h"
+
+int32_t add(int32_t a, int32_t b) {
+   return a + b;
+}
diff --git a/pkgs/native_assets_builder/test/data/native_add/src/native_add.h b/pkgs/native_assets_builder/test/data/native_add/src/native_add.h
new file mode 100644
index 0000000..5824f98
--- /dev/null
+++ b/pkgs/native_assets_builder/test/data/native_add/src/native_add.h
@@ -0,0 +1,13 @@
+// Copyright (c) 2023, 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.
+
+#include <stdint.h>
+
+#if _WIN32
+#define MYLIB_EXPORT __declspec(dllexport)
+#else
+#define MYLIB_EXPORT
+#endif
+
+MYLIB_EXPORT int32_t add(int32_t a, int32_t b);
diff --git a/pkgs/native_assets_builder/test/data/native_add/test/native_add_test.dart b/pkgs/native_assets_builder/test/data/native_add/test/native_add_test.dart
new file mode 100644
index 0000000..7531a62
--- /dev/null
+++ b/pkgs/native_assets_builder/test/data/native_add/test/native_add_test.dart
@@ -0,0 +1,13 @@
+// Copyright (c) 2023, 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:native_add/native_add.dart';
+import 'package:test/test.dart';
+
+void main() {
+  test('native add test', () {
+    final result = add(4, 6);
+    expect(result, equals(10));
+  });
+}
diff --git a/pkgs/native_assets_builder/test/data/native_add_add_source/README.md b/pkgs/native_assets_builder/test/data/native_add_add_source/README.md
new file mode 100644
index 0000000..5a1b807
--- /dev/null
+++ b/pkgs/native_assets_builder/test/data/native_add_add_source/README.md
@@ -0,0 +1 @@
+Used in automated testing to modify the native_add project.
diff --git a/pkgs/native_assets_builder/test/data/native_add_add_source/build.dart b/pkgs/native_assets_builder/test/data/native_add_add_source/build.dart
new file mode 100644
index 0000000..3fcb30e
--- /dev/null
+++ b/pkgs/native_assets_builder/test/data/native_add_add_source/build.dart
@@ -0,0 +1,32 @@
+// Copyright (c) 2023, 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:logging/logging.dart';
+import 'package:native_assets_cli/native_assets_cli.dart';
+import 'package:native_toolchain_c/native_toolchain_c.dart';
+
+const packageName = 'native_add';
+
+void main(List<String> args) async {
+  final buildConfig = await BuildConfig.fromArgs(args);
+  final buildOutput = BuildOutput();
+  final cbuilder = CBuilder.library(
+    name: packageName,
+    assetId: 'package:$packageName/src/${packageName}_bindings_generated.dart',
+    sources: [
+      'src/$packageName.c',
+      'src/native_multiply.c',
+    ],
+  );
+  await cbuilder.run(
+    buildConfig: buildConfig,
+    buildOutput: buildOutput,
+    logger: Logger('')
+      ..level = Level.ALL
+      ..onRecord.listen((record) {
+        print('${record.level.name}: ${record.time}: ${record.message}');
+      }),
+  );
+  await buildOutput.writeToFile(outDir: buildConfig.outDir);
+}
diff --git a/pkgs/native_assets_builder/test/data/native_add_add_source/manifest.yaml b/pkgs/native_assets_builder/test/data/native_add_add_source/manifest.yaml
new file mode 100644
index 0000000..3a2f6c3
--- /dev/null
+++ b/pkgs/native_assets_builder/test/data/native_add_add_source/manifest.yaml
@@ -0,0 +1,3 @@
+- build.dart
+- src/native_multiply.c
+- src/native_multiply.h
diff --git a/pkgs/native_assets_builder/test/data/native_add_add_source/pubspec.yaml b/pkgs/native_assets_builder/test/data/native_add_add_source/pubspec.yaml
new file mode 100644
index 0000000..67632a6
--- /dev/null
+++ b/pkgs/native_assets_builder/test/data/native_add_add_source/pubspec.yaml
@@ -0,0 +1,19 @@
+name: native_add
+description: Sums two numbers with native code.
+version: 0.1.0
+
+publish_to: none
+
+environment:
+  sdk: '>=3.0.0 <4.0.0'
+
+dependencies:
+  cli_config: ^0.1.1
+  logging: ^1.1.1
+  native_assets_cli: ^0.3.0
+  native_toolchain_c: ^0.3.0
+
+dev_dependencies:
+  ffigen: ^8.0.2
+  lints: ^3.0.0
+  test: ^1.23.1
diff --git a/pkgs/native_assets_builder/test/data/native_add_add_source/src/native_multiply.c b/pkgs/native_assets_builder/test/data/native_add_add_source/src/native_multiply.c
new file mode 100644
index 0000000..7801f65
--- /dev/null
+++ b/pkgs/native_assets_builder/test/data/native_add_add_source/src/native_multiply.c
@@ -0,0 +1,9 @@
+// Copyright (c) 2023, 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.
+
+#include "native_multiply.h"
+
+MYLIB_EXPORT intptr_t multiply(intptr_t a, intptr_t b) {
+  return a * b;
+}
diff --git a/pkgs/native_assets_builder/test/data/native_add_add_source/src/native_multiply.h b/pkgs/native_assets_builder/test/data/native_add_add_source/src/native_multiply.h
new file mode 100644
index 0000000..0827571
--- /dev/null
+++ b/pkgs/native_assets_builder/test/data/native_add_add_source/src/native_multiply.h
@@ -0,0 +1,13 @@
+// Copyright (c) 2023, 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.
+
+#include <stdint.h>
+
+#if _WIN32
+#define MYLIB_EXPORT __declspec(dllexport)
+#else
+#define MYLIB_EXPORT
+#endif
+
+MYLIB_EXPORT intptr_t multiply(intptr_t a, intptr_t b);
diff --git a/pkgs/native_assets_builder/test/data/native_add_add_symbol/README.md b/pkgs/native_assets_builder/test/data/native_add_add_symbol/README.md
new file mode 100644
index 0000000..5a1b807
--- /dev/null
+++ b/pkgs/native_assets_builder/test/data/native_add_add_symbol/README.md
@@ -0,0 +1 @@
+Used in automated testing to modify the native_add project.
diff --git a/pkgs/native_assets_builder/test/data/native_add_add_symbol/manifest.yaml b/pkgs/native_assets_builder/test/data/native_add_add_symbol/manifest.yaml
new file mode 100644
index 0000000..adc0539
--- /dev/null
+++ b/pkgs/native_assets_builder/test/data/native_add_add_symbol/manifest.yaml
@@ -0,0 +1,2 @@
+- src/native_add.c
+- src/native_add.h
diff --git a/pkgs/native_assets_builder/test/data/native_add_add_symbol/src/native_add.c b/pkgs/native_assets_builder/test/data/native_add_add_symbol/src/native_add.c
new file mode 100644
index 0000000..a493add
--- /dev/null
+++ b/pkgs/native_assets_builder/test/data/native_add_add_symbol/src/native_add.c
@@ -0,0 +1,14 @@
+// Copyright (c) 2023, 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.
+
+#include "native_add.h"
+
+MYLIB_EXPORT int32_t add(int32_t a, int32_t b) {
+   return a + b;
+}
+
+
+MYLIB_EXPORT intptr_t subtract(intptr_t a, intptr_t b) {
+  return a - b;
+}
diff --git a/pkgs/native_assets_builder/test/data/native_add_add_symbol/src/native_add.h b/pkgs/native_assets_builder/test/data/native_add_add_symbol/src/native_add.h
new file mode 100644
index 0000000..37d53b6
--- /dev/null
+++ b/pkgs/native_assets_builder/test/data/native_add_add_symbol/src/native_add.h
@@ -0,0 +1,15 @@
+// Copyright (c) 2023, 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.
+
+#include <stdint.h>
+
+#if _WIN32
+#define MYLIB_EXPORT __declspec(dllexport)
+#else
+#define MYLIB_EXPORT
+#endif
+
+MYLIB_EXPORT int32_t add(int32_t a, int32_t b);
+
+MYLIB_EXPORT intptr_t subtract(intptr_t a, intptr_t b);
diff --git a/pkgs/native_assets_builder/test/data/native_add_break_build/README.md b/pkgs/native_assets_builder/test/data/native_add_break_build/README.md
new file mode 100644
index 0000000..5a1b807
--- /dev/null
+++ b/pkgs/native_assets_builder/test/data/native_add_break_build/README.md
@@ -0,0 +1 @@
+Used in automated testing to modify the native_add project.
diff --git a/pkgs/native_assets_builder/test/data/native_add_break_build/manifest.yaml b/pkgs/native_assets_builder/test/data/native_add_break_build/manifest.yaml
new file mode 100644
index 0000000..a475918
--- /dev/null
+++ b/pkgs/native_assets_builder/test/data/native_add_break_build/manifest.yaml
@@ -0,0 +1 @@
+- src/native_add.c
diff --git a/pkgs/native_assets_builder/test/data/native_add_break_build/src/native_add.c b/pkgs/native_assets_builder/test/data/native_add_break_build/src/native_add.c
new file mode 100644
index 0000000..ba06d8b
--- /dev/null
+++ b/pkgs/native_assets_builder/test/data/native_add_break_build/src/native_add.c
@@ -0,0 +1,9 @@
+// Copyright (c) 2023, 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.
+
+#include "native_add.h"
+
+MYLIB_EXPORT int32_t add(int32_t different_parameters) {
+   return a + b;
+}
\ No newline at end of file
diff --git a/pkgs/native_assets_builder/test/data/native_add_fix_build/README.md b/pkgs/native_assets_builder/test/data/native_add_fix_build/README.md
new file mode 100644
index 0000000..5a1b807
--- /dev/null
+++ b/pkgs/native_assets_builder/test/data/native_add_fix_build/README.md
@@ -0,0 +1 @@
+Used in automated testing to modify the native_add project.
diff --git a/pkgs/native_assets_builder/test/data/native_add_fix_build/manifest.yaml b/pkgs/native_assets_builder/test/data/native_add_fix_build/manifest.yaml
new file mode 100644
index 0000000..a475918
--- /dev/null
+++ b/pkgs/native_assets_builder/test/data/native_add_fix_build/manifest.yaml
@@ -0,0 +1 @@
+- src/native_add.c
diff --git a/pkgs/native_assets_builder/test/data/native_add_fix_build/src/native_add.c b/pkgs/native_assets_builder/test/data/native_add_fix_build/src/native_add.c
new file mode 100644
index 0000000..cf21076
--- /dev/null
+++ b/pkgs/native_assets_builder/test/data/native_add_fix_build/src/native_add.c
@@ -0,0 +1,9 @@
+// Copyright (c) 2023, 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.
+
+#include "native_add.h"
+
+int32_t add(int32_t a, int32_t b) {
+   return a + b;
+}
diff --git a/pkgs/native_assets_builder/test/data/native_subtract/build.dart b/pkgs/native_assets_builder/test/data/native_subtract/build.dart
new file mode 100644
index 0000000..66b62ea
--- /dev/null
+++ b/pkgs/native_assets_builder/test/data/native_subtract/build.dart
@@ -0,0 +1,31 @@
+// Copyright (c) 2023, 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:logging/logging.dart';
+import 'package:native_assets_cli/native_assets_cli.dart';
+import 'package:native_toolchain_c/native_toolchain_c.dart';
+
+const packageName = 'native_subtract';
+
+void main(List<String> args) async {
+  final buildConfig = await BuildConfig.fromArgs(args);
+  final buildOutput = BuildOutput();
+  final cbuilder = CBuilder.library(
+    name: packageName,
+    assetId: 'package:$packageName/src/${packageName}_bindings_generated.dart',
+    sources: [
+      'src/$packageName.c',
+    ],
+  );
+  await cbuilder.run(
+    buildConfig: buildConfig,
+    buildOutput: buildOutput,
+    logger: Logger('')
+      ..level = Level.ALL
+      ..onRecord.listen((record) {
+        print('${record.level.name}: ${record.time}: ${record.message}');
+      }),
+  );
+  await buildOutput.writeToFile(outDir: buildConfig.outDir);
+}
diff --git a/pkgs/native_assets_builder/test/data/native_subtract/ffigen.yaml b/pkgs/native_assets_builder/test/data/native_subtract/ffigen.yaml
new file mode 100644
index 0000000..5f79dbe
--- /dev/null
+++ b/pkgs/native_assets_builder/test/data/native_subtract/ffigen.yaml
@@ -0,0 +1,20 @@
+# Run with `flutter pub run ffigen --config ffigen.yaml`.
+name: NativeAddBindings
+description: |
+  Bindings for `src/native_subtract.h`.
+
+  Regenerate bindings with `flutter pub run ffigen --config ffigen.yaml`.
+output: "lib/src/native_subtract_bindings_generated.dart"
+headers:
+  entry-points:
+    - "src/native_subtract.h"
+  include-directives:
+    - "src/native_subtract.h"
+preamble: |
+  // Copyright (c) 2023, 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.
+comments:
+  style: any
+  length: full
+ffi-native:
diff --git a/pkgs/native_assets_builder/test/data/native_subtract/lib/native_subtract.dart b/pkgs/native_assets_builder/test/data/native_subtract/lib/native_subtract.dart
new file mode 100644
index 0000000..a283156
--- /dev/null
+++ b/pkgs/native_assets_builder/test/data/native_subtract/lib/native_subtract.dart
@@ -0,0 +1,5 @@
+// Copyright (c) 2023, 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/native_subtract.dart';
diff --git a/pkgs/native_assets_builder/test/data/native_subtract/lib/src/native_subtract.dart b/pkgs/native_assets_builder/test/data/native_subtract/lib/src/native_subtract.dart
new file mode 100644
index 0000000..3d77e75
--- /dev/null
+++ b/pkgs/native_assets_builder/test/data/native_subtract/lib/src/native_subtract.dart
@@ -0,0 +1,7 @@
+// Copyright (c) 2023, 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 'native_subtract_bindings_generated.dart' as bindings;
+
+int subtract(int a, int b) => bindings.subtract(a, b);
diff --git a/pkgs/native_assets_builder/test/data/native_subtract/lib/src/native_subtract_bindings_generated.dart b/pkgs/native_assets_builder/test/data/native_subtract/lib/src/native_subtract_bindings_generated.dart
new file mode 100644
index 0000000..1a952fe
--- /dev/null
+++ b/pkgs/native_assets_builder/test/data/native_subtract/lib/src/native_subtract_bindings_generated.dart
@@ -0,0 +1,15 @@
+// Copyright (c) 2023, 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.
+
+// AUTO GENERATED FILE, DO NOT EDIT.
+//
+// Generated by `package:ffigen`.
+// ignore_for_file: type=lint
+import 'dart:ffi' as ffi;
+
+@ffi.Native<ffi.Int32 Function(ffi.Int32, ffi.Int32)>(symbol: 'subtract')
+external int subtract(
+  int a,
+  int b,
+);
diff --git a/pkgs/native_assets_builder/test/data/native_subtract/pubspec.yaml b/pkgs/native_assets_builder/test/data/native_subtract/pubspec.yaml
new file mode 100644
index 0000000..de93ce0
--- /dev/null
+++ b/pkgs/native_assets_builder/test/data/native_subtract/pubspec.yaml
@@ -0,0 +1,19 @@
+name: native_subtract
+description: Subtracts two numbers with native code.
+version: 0.1.0
+
+publish_to: none
+
+environment:
+  sdk: '>=3.0.0 <4.0.0'
+
+dependencies:
+  cli_config: ^0.1.1
+  logging: ^1.1.1
+  native_assets_cli: ^0.3.0
+  native_toolchain_c: ^0.3.0
+
+dev_dependencies:
+  ffigen: ^8.0.2
+  lints: ^3.0.0
+  test: ^1.23.1
diff --git a/pkgs/native_assets_builder/test/data/native_subtract/src/native_subtract.c b/pkgs/native_assets_builder/test/data/native_subtract/src/native_subtract.c
new file mode 100644
index 0000000..6562ae6
--- /dev/null
+++ b/pkgs/native_assets_builder/test/data/native_subtract/src/native_subtract.c
@@ -0,0 +1,9 @@
+// Copyright (c) 2023, 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.
+
+#include "native_subtract.h"
+
+int32_t subtract(int32_t a, int32_t b) {
+   return a - b;
+}
diff --git a/pkgs/native_assets_builder/test/data/native_subtract/src/native_subtract.h b/pkgs/native_assets_builder/test/data/native_subtract/src/native_subtract.h
new file mode 100644
index 0000000..407fc5d
--- /dev/null
+++ b/pkgs/native_assets_builder/test/data/native_subtract/src/native_subtract.h
@@ -0,0 +1,13 @@
+// Copyright (c) 2023, 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.
+
+#include <stdint.h>
+
+#if _WIN32
+#define MYLIB_EXPORT __declspec(dllexport)
+#else
+#define MYLIB_EXPORT
+#endif
+
+MYLIB_EXPORT int32_t subtract(int32_t a, int32_t b);
diff --git a/pkgs/native_assets_builder/test/data/package_reading_metadata/build.dart b/pkgs/native_assets_builder/test/data/package_reading_metadata/build.dart
new file mode 100644
index 0000000..e8009db
--- /dev/null
+++ b/pkgs/native_assets_builder/test/data/package_reading_metadata/build.dart
@@ -0,0 +1,22 @@
+// Copyright (c) 2023, 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:cli_config/cli_config.dart';
+import 'package:native_assets_cli/native_assets_cli.dart';
+
+void main(List<String> args) async {
+  final config = await Config.fromArgs(args: args);
+  final buildConfig = BuildConfig.fromConfig(config);
+  if (!buildConfig.dryRun) {
+    final metadata =
+        buildConfig.dependencyMetadata!['package_with_metadata']!.metadata;
+    final someValue = metadata['some_key'];
+    assert(someValue != null);
+    final someInt = metadata['some_int'];
+    assert(someInt != null);
+    print(metadata);
+  } else {
+    print('meta data not available in dry run');
+  }
+}
diff --git a/pkgs/native_assets_builder/test/data/package_reading_metadata/pubspec.yaml b/pkgs/native_assets_builder/test/data/package_reading_metadata/pubspec.yaml
new file mode 100644
index 0000000..43f2318
--- /dev/null
+++ b/pkgs/native_assets_builder/test/data/package_reading_metadata/pubspec.yaml
@@ -0,0 +1,19 @@
+name: package_reading_metadata
+description: Reads some metadata in its build.dart
+version: 0.1.0
+
+publish_to: none
+
+environment:
+  sdk: '>=3.0.0 <4.0.0'
+
+dependencies:
+  cli_config: ^0.1.1
+  native_assets_cli: ^0.3.0
+  package_with_metadata:
+    path: ../package_with_metadata/
+  yaml: ^3.1.1
+  yaml_edit: ^2.1.0
+
+dev_dependencies:
+  lints: ^3.0.0
diff --git a/pkgs/native_assets_builder/test/data/package_with_metadata/build.dart b/pkgs/native_assets_builder/test/data/package_with_metadata/build.dart
new file mode 100644
index 0000000..a5e908a
--- /dev/null
+++ b/pkgs/native_assets_builder/test/data/package_with_metadata/build.dart
@@ -0,0 +1,16 @@
+// Copyright (c) 2023, 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:native_assets_cli/native_assets_cli.dart';
+
+void main(List<String> args) async {
+  final buildConfig = await BuildConfig.fromArgs(args);
+  final buildOutput = BuildOutput(
+    metadata: Metadata({
+      'some_key': 'some_value',
+      'some_int': 3,
+    }),
+  );
+  await buildOutput.writeToFile(outDir: buildConfig.outDir);
+}
diff --git a/pkgs/native_assets_builder/test/data/package_with_metadata/pubspec.yaml b/pkgs/native_assets_builder/test/data/package_with_metadata/pubspec.yaml
new file mode 100644
index 0000000..bca9ddd
--- /dev/null
+++ b/pkgs/native_assets_builder/test/data/package_with_metadata/pubspec.yaml
@@ -0,0 +1,17 @@
+name: package_with_metadata
+description: Sets some metadata in its build.dart
+version: 0.1.0
+
+publish_to: none
+
+environment:
+  sdk: '>=3.0.0 <4.0.0'
+
+dependencies:
+  cli_config: ^0.1.1
+  native_assets_cli: ^0.3.0
+  yaml: ^3.1.1
+  yaml_edit: ^2.1.0
+
+dev_dependencies:
+  lints: ^3.0.0
diff --git a/pkgs/native_assets_builder/test/data/wrong_build_output/build.dart b/pkgs/native_assets_builder/test/data/wrong_build_output/build.dart
new file mode 100644
index 0000000..a34dbd0
--- /dev/null
+++ b/pkgs/native_assets_builder/test/data/wrong_build_output/build.dart
@@ -0,0 +1,21 @@
+// Copyright (c) 2023, 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';
+
+import 'package:native_assets_cli/native_assets_cli.dart';
+
+void main(List<String> args) async {
+  final buildConfig = await BuildConfig.fromArgs(args);
+  await File.fromUri(buildConfig.outDir.resolve(BuildOutput.fileName))
+      .writeAsString(_wrongContents);
+}
+
+const _wrongContents = '''
+timestamp: 2023-07-28 14:22:45.000
+assets: []
+dependencies: []
+metadata: {}
+version: 9001.0.0
+''';
diff --git a/pkgs/native_assets_builder/test/data/wrong_build_output/pubspec.yaml b/pkgs/native_assets_builder/test/data/wrong_build_output/pubspec.yaml
new file mode 100644
index 0000000..e6b5419
--- /dev/null
+++ b/pkgs/native_assets_builder/test/data/wrong_build_output/pubspec.yaml
@@ -0,0 +1,17 @@
+name: wrong_build_output
+description: Package that tries to add an asset with an id not prefixed by its package name.
+version: 0.1.0
+
+publish_to: none
+
+environment:
+  sdk: '>=3.0.0 <4.0.0'
+
+dependencies:
+  cli_config: ^0.1.1
+  native_assets_cli: ^0.3.0
+  yaml: ^3.1.1
+  yaml_edit: ^2.1.0
+
+dev_dependencies:
+  lints: ^3.0.0
diff --git a/pkgs/native_assets_builder/test/data/wrong_build_output_2/build.dart b/pkgs/native_assets_builder/test/data/wrong_build_output_2/build.dart
new file mode 100644
index 0000000..331cd2d
--- /dev/null
+++ b/pkgs/native_assets_builder/test/data/wrong_build_output_2/build.dart
@@ -0,0 +1,22 @@
+// Copyright (c) 2023, 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';
+
+import 'package:native_assets_cli/native_assets_cli.dart';
+
+void main(List<String> args) async {
+  final buildConfig = await BuildConfig.fromArgs(args);
+  await File.fromUri(buildConfig.outDir.resolve(BuildOutput.fileName))
+      .writeAsString(_wrongContents);
+}
+
+const _wrongContents = '''
+timestamp: 2023-07-28 14:22:45.000
+assets:
+  foo: 123
+dependencies: []
+metadata: {}
+version: 1.0.0
+''';
diff --git a/pkgs/native_assets_builder/test/data/wrong_build_output_2/pubspec.yaml b/pkgs/native_assets_builder/test/data/wrong_build_output_2/pubspec.yaml
new file mode 100644
index 0000000..9e87ed9
--- /dev/null
+++ b/pkgs/native_assets_builder/test/data/wrong_build_output_2/pubspec.yaml
@@ -0,0 +1,17 @@
+name: wrong_build_output_2
+description: Package that tries to add an asset with an id not prefixed by its package name.
+version: 0.1.0
+
+publish_to: none
+
+environment:
+  sdk: '>=3.0.0 <4.0.0'
+
+dependencies:
+  cli_config: ^0.1.1
+  native_assets_cli: ^0.3.0
+  yaml: ^3.1.1
+  yaml_edit: ^2.1.0
+
+dev_dependencies:
+  lints: ^3.0.0
diff --git a/pkgs/native_assets_builder/test/data/wrong_build_output_3/build.dart b/pkgs/native_assets_builder/test/data/wrong_build_output_3/build.dart
new file mode 100644
index 0000000..307dfff
--- /dev/null
+++ b/pkgs/native_assets_builder/test/data/wrong_build_output_3/build.dart
@@ -0,0 +1,22 @@
+// Copyright (c) 2023, 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';
+
+import 'package:native_assets_cli/native_assets_cli.dart';
+
+void main(List<String> args) async {
+  final buildConfig = await BuildConfig.fromArgs(args);
+  await File.fromUri(buildConfig.outDir.resolve(BuildOutput.fileName))
+      .writeAsString(_rightContents);
+  exit(1);
+}
+
+const _rightContents = '''
+timestamp: 2023-07-28 14:22:45.000
+assets: []
+dependencies: []
+metadata: {}
+version: 1.0.0
+''';
diff --git a/pkgs/native_assets_builder/test/data/wrong_build_output_3/pubspec.yaml b/pkgs/native_assets_builder/test/data/wrong_build_output_3/pubspec.yaml
new file mode 100644
index 0000000..1cdc5b2
--- /dev/null
+++ b/pkgs/native_assets_builder/test/data/wrong_build_output_3/pubspec.yaml
@@ -0,0 +1,17 @@
+name: wrong_build_output_3
+description: Package that outputs a valid build_output.yaml but a non-zero exit code.
+version: 0.1.0
+
+publish_to: none
+
+environment:
+  sdk: '>=3.0.0 <4.0.0'
+
+dependencies:
+  cli_config: ^0.1.1
+  native_assets_cli: ^0.3.0
+  yaml: ^3.1.1
+  yaml_edit: ^2.1.0
+
+dev_dependencies:
+  lints: ^3.0.0
diff --git a/pkgs/native_assets_builder/test/data/wrong_namespace_asset/build.dart b/pkgs/native_assets_builder/test/data/wrong_namespace_asset/build.dart
new file mode 100644
index 0000000..457aa90
--- /dev/null
+++ b/pkgs/native_assets_builder/test/data/wrong_namespace_asset/build.dart
@@ -0,0 +1,24 @@
+// Copyright (c) 2023, 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:native_assets_cli/native_assets_cli.dart';
+
+void main(List<String> args) async {
+  final buildConfig = await BuildConfig.fromArgs(args);
+  final buildOutput = BuildOutput(
+    assets: [
+      Asset(
+        id: 'package:other_package/foo',
+        linkMode: LinkMode.dynamic,
+        target: Target.current,
+        path: AssetAbsolutePath(
+          buildConfig.outDir.resolve(
+            Target.current.os.dylibFileName('foo'),
+          ),
+        ),
+      ),
+    ],
+  );
+  await buildOutput.writeToFile(outDir: buildConfig.outDir);
+}
diff --git a/pkgs/native_assets_builder/test/data/wrong_namespace_asset/pubspec.yaml b/pkgs/native_assets_builder/test/data/wrong_namespace_asset/pubspec.yaml
new file mode 100644
index 0000000..fc6b9e8
--- /dev/null
+++ b/pkgs/native_assets_builder/test/data/wrong_namespace_asset/pubspec.yaml
@@ -0,0 +1,17 @@
+name: wrong_namespace_asset
+description: Package that tries to add an asset with an id not prefixed by its package name.
+version: 0.1.0
+
+publish_to: none
+
+environment:
+  sdk: '>=3.0.0 <4.0.0'
+
+dependencies:
+  cli_config: ^0.1.1
+  native_assets_cli: ^0.3.0
+  yaml: ^3.1.1
+  yaml_edit: ^2.1.0
+
+dev_dependencies:
+  lints: ^3.0.0
diff --git a/pkgs/native_assets_builder/test/helpers.dart b/pkgs/native_assets_builder/test/helpers.dart
new file mode 100644
index 0000000..522aa97
--- /dev/null
+++ b/pkgs/native_assets_builder/test/helpers.dart
@@ -0,0 +1,188 @@
+// Copyright (c) 2023, 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';
+
+import 'package:logging/logging.dart';
+import 'package:native_assets_builder/src/utils/run_process.dart'
+    as run_process;
+import 'package:test/test.dart';
+import 'package:yaml/yaml.dart';
+
+extension UriExtension on Uri {
+  Uri get parent => File(toFilePath()).parent.uri;
+}
+
+const keepTempKey = 'KEEP_TEMPORARY_DIRECTORIES';
+
+Future<void> inTempDir(
+  Future<void> Function(Uri tempUri) fun, {
+  String? prefix,
+  bool keepTemp = false,
+}) async {
+  final tempDir = await Directory.systemTemp.createTemp(prefix);
+  // Deal with Windows temp folder aliases.
+  final tempUri =
+      Directory(await tempDir.resolveSymbolicLinks()).uri.normalizePath();
+  try {
+    await fun(tempUri);
+  } finally {
+    if ((!Platform.environment.containsKey(keepTempKey) ||
+            Platform.environment[keepTempKey]!.isEmpty) &&
+        !keepTemp) {
+      await tempDir.delete(recursive: true);
+    }
+  }
+}
+
+/// Runs a [Process].
+///
+/// If [logger] is provided, stream stdout and stderr to it.
+///
+/// If [captureOutput], captures stdout and stderr.
+Future<run_process.RunProcessResult> runProcess({
+  required Uri executable,
+  List<String> arguments = const [],
+  Uri? workingDirectory,
+  Map<String, String>? environment,
+  bool includeParentEnvironment = true,
+  required Logger? logger,
+  bool captureOutput = true,
+  int expectedExitCode = 0,
+  bool throwOnUnexpectedExitCode = false,
+}) =>
+    run_process.runProcess(
+      executable: executable,
+      arguments: arguments,
+      workingDirectory: workingDirectory,
+      environment: environment,
+      includeParentEnvironment: includeParentEnvironment,
+      logger: logger,
+      captureOutput: captureOutput,
+      expectedExitCode: expectedExitCode,
+      throwOnUnexpectedExitCode: throwOnUnexpectedExitCode,
+    );
+
+/// Test files are run in a variety of ways, find this package root in all.
+///
+/// Test files can be run from source from any working directory. The Dart SDK
+/// `tools/test.py` runs them from the root of the SDK for example.
+///
+/// Test files can be run from dill from the root of package. `package:test`
+/// does this.
+///
+/// https://github.com/dart-lang/test/issues/110
+Uri findPackageRoot(String packageName) {
+  final script = Platform.script;
+  final fileName = script.name;
+  if (fileName.endsWith('_test.dart')) {
+    // We're likely running from source.
+    var directory = script.resolve('.');
+    while (true) {
+      final dirName = directory.name;
+      if (dirName == packageName) {
+        return directory;
+      }
+      final parent = directory.resolve('..');
+      if (parent == directory) break;
+      directory = parent;
+    }
+  } else if (fileName.endsWith('.dill')) {
+    final cwd = Directory.current.uri;
+    final dirName = cwd.name;
+    if (dirName == packageName) {
+      return cwd;
+    }
+  }
+  throw StateError("Could not find package root for package '$packageName'. "
+      'Tried finding the package root via Platform.script '
+      "'${Platform.script.toFilePath()}' and Directory.current "
+      "'${Directory.current.uri.toFilePath()}'.");
+}
+
+final pkgNativeAssetsBuilderUri = findPackageRoot('native_assets_builder');
+
+final testDataUri = pkgNativeAssetsBuilderUri.resolve('test/data/');
+
+extension on Uri {
+  String get name => pathSegments.where((e) => e != '').last;
+}
+
+Future<void> copyTestProjects({
+  Uri? sourceUri,
+  required Uri targetUri,
+}) async {
+  sourceUri ??= testDataUri;
+  final manifestUri = sourceUri.resolve('manifest.yaml');
+  final manifestFile = File.fromUri(manifestUri);
+  final manifestString = await manifestFile.readAsString();
+  final manifestYaml = loadYamlDocument(manifestString);
+  final manifest = [
+    for (final path in manifestYaml.contents as YamlList)
+      Uri(path: path as String)
+  ];
+  final filesToCopy =
+      manifest.where((e) => e.pathSegments.last != 'pubspec.yaml').toList();
+  final filesToModify =
+      manifest.where((e) => e.pathSegments.last == 'pubspec.yaml').toList();
+
+  for (final pathToCopy in filesToCopy) {
+    final sourceFile = File.fromUri(sourceUri.resolveUri(pathToCopy));
+    final targetFileUri = targetUri.resolveUri(pathToCopy);
+    final targetDirUri = targetFileUri.parent;
+    final targetDir = Directory.fromUri(targetDirUri);
+    if (!(await targetDir.exists())) {
+      await targetDir.create(recursive: true);
+    }
+
+    // Copying files on MacOS and Windows preserves the source timestamps.
+    // The builder will use the cached build if the timestamps are equal.
+    // So just write the file instead.
+    final targetFile = File.fromUri(targetFileUri);
+    await targetFile.writeAsBytes(await sourceFile.readAsBytes());
+  }
+  for (final pathToModify in filesToModify) {
+    final sourceFile = File.fromUri(sourceUri.resolveUri(pathToModify));
+    final targetFileUri = targetUri.resolveUri(pathToModify);
+    final sourceString = await sourceFile.readAsString();
+    final modifiedString = sourceString.replaceAll(
+      'path: ../../../',
+      'path: ${pkgNativeAssetsBuilderUri.toFilePath().replaceAll('\\', '/')}',
+    );
+    await File.fromUri(targetFileUri)
+        .writeAsString(modifiedString, flush: true);
+  }
+}
+
+/// Logger that outputs the full trace when a test fails.
+Logger get logger => _logger ??= () {
+      // A new logger is lazily created for each test so that the messages
+      // captured by printOnFailure are scoped to the correct test.
+      addTearDown(() => _logger = null);
+      return _createTestLogger();
+    }();
+
+Logger? _logger;
+
+Logger createCapturingLogger(
+  List<String> capturedMessages, {
+  Level level = Level.ALL,
+}) =>
+    _createTestLogger(capturedMessages: capturedMessages, level: level);
+
+Logger _createTestLogger({
+  List<String>? capturedMessages,
+  Level level = Level.ALL,
+}) =>
+    Logger.detached('')
+      ..level = level
+      ..onRecord.listen((record) {
+        printOnFailure(
+          '${record.level.name}: ${record.time}: ${record.message}',
+        );
+        capturedMessages?.add(record.message);
+      });
+
+final dartExecutable = File(Platform.resolvedExecutable).uri;
diff --git a/pkgs/native_assets_cli/.gitignore b/pkgs/native_assets_cli/.gitignore
new file mode 100644
index 0000000..ef8a06e
--- /dev/null
+++ b/pkgs/native_assets_cli/.gitignore
@@ -0,0 +1,14 @@
+# https://dart.dev/guides/libraries/private-files
+# Created by `dart pub`
+.dart_tool/
+
+# Avoid committing pubspec.lock for library packages; see
+# https://dart.dev/guides/libraries/private-files#pubspeclock.
+pubspec.lock
+
+coverage/
+
+*.dll
+*.dylib
+*.exe
+*.so
diff --git a/pkgs/native_assets_cli/CHANGELOG.md b/pkgs/native_assets_cli/CHANGELOG.md
new file mode 100644
index 0000000..0159aab
--- /dev/null
+++ b/pkgs/native_assets_cli/CHANGELOG.md
@@ -0,0 +1,26 @@
+## 0.3.2
+
+- Fixed an issue where `Depenendencies.dependencies` could not be
+  modified when expected to.
+
+## 0.3.1
+
+- Added `Target.androidRiscv64`.
+
+## 0.3.0
+
+- **Breaking change** Add required `BuildConfig.packageName`
+  ([#142](https://github.com/dart-lang/native/issues/142)).
+
+## 0.2.0
+
+- **Breaking change** Rename `Asset.name` to `Asset.id`
+  ([#100](https://github.com/dart-lang/native/issues/100)).
+- Added topics.
+- Fixed metadata example.
+- Throws `FormatException`s instead of `TypeError`s when failing to parse Yaml
+  ([#109](https://github.com/dart-lang/native/issues/109)).
+
+## 0.1.0
+
+- Initial version.
diff --git a/pkgs/native_assets_cli/LICENSE b/pkgs/native_assets_cli/LICENSE
new file mode 100644
index 0000000..4fd5739
--- /dev/null
+++ b/pkgs/native_assets_cli/LICENSE
@@ -0,0 +1,27 @@
+Copyright 2023, 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.
diff --git a/pkgs/native_assets_cli/README.md b/pkgs/native_assets_cli/README.md
new file mode 100644
index 0000000..7a336e0
--- /dev/null
+++ b/pkgs/native_assets_cli/README.md
@@ -0,0 +1,54 @@
+[![package:native_assets_cli](https://github.com/dart-lang/native/actions/workflows/native.yaml/badge.svg)](https://github.com/dart-lang/native/actions/workflows/native.yaml)
+[![Coverage Status](https://coveralls.io/repos/github/dart-lang/native/badge.svg?branch=main)](https://coveralls.io/github/dart-lang/native?branch=main)
+[![pub package](https://img.shields.io/pub/v/native_assets_cli.svg)](https://pub.dev/packages/native_assets_cli)
+[![package publisher](https://img.shields.io/pub/publisher/native_assets_cli.svg)](https://pub.dev/packages/native_assets_cli/publisher)
+
+This library contains the CLI specification and a default implementation
+for bundling native code with Dart packages.
+
+## Status: Experimental
+
+**NOTE**: This package is currently experimental and published under the
+[labs.dart.dev](https://dart.dev/dart-team-packages) pub publisher in order to
+solicit feedback. 
+
+For packages in the labs.dart.dev publisher we generally plan to either graduate
+the package into a supported publisher (dart.dev, tools.dart.dev) after a period
+of feedback and iteration, or discontinue the package. These packages have a
+much higher expected rate of API and breaking changes.
+
+Your feedback is valuable and will help us evolve this package. 
+For bugs, please file an issue in the 
+[bug tracker](https://github.com/dart-lang/native/issues).
+For general feedback and suggestions for the native assets feature in Dart and
+Flutter, please comment in [dart-lang#50565] or [flutter#129757].
+
+## Example
+
+A typical layout of a package with native code is:
+
+* `lib/` contains Dart code which uses [`dart:ffi`] and [`package:ffigen`]
+  to call into native code.
+* `src/` contains C/C++/Rust code which is invoked through `dart:ffi`.
+* `build.dart` implements the CLI that communicates which native assets
+  to build/bundle with the Dart/Flutter SDK. This file uses the
+  protocol specified in this package.
+
+An example can be found in [example/](example/).
+
+## Usage
+
+Using the native assets feature requires passing
+`--enable-experiment=native-assets` in Dart on a dev build.
+
+The native assets feature is not yet available in Flutter.
+
+## Development
+
+The development of the feature can be tracked in [dart-lang#50565],
+[flutter#129757], and in the issue tracker on this repository.
+
+[`dart:ffi`]: https://api.dart.dev/stable/dart-ffi/dart-ffi-library.html
+[`package:ffigen`]: https://pub.dev/packages/ffigen
+[dart-lang#50565]: https://github.com/dart-lang/sdk/issues/50565
+[flutter#129757]: https://github.com/flutter/flutter/issues/129757
diff --git a/pkgs/native_assets_cli/analysis_options.yaml b/pkgs/native_assets_cli/analysis_options.yaml
new file mode 100644
index 0000000..dd3dcda
--- /dev/null
+++ b/pkgs/native_assets_cli/analysis_options.yaml
@@ -0,0 +1,12 @@
+include: package:dart_flutter_team_lints/analysis_options.yaml
+
+analyzer:
+  language:
+    strict-raw-types: true
+
+linter:
+  rules:
+    - prefer_const_declarations
+    - prefer_expression_function_bodies
+    - prefer_final_in_for_each
+    - prefer_final_locals
diff --git a/pkgs/native_assets_cli/example/README.md b/pkgs/native_assets_cli/example/README.md
new file mode 100644
index 0000000..1be681f
--- /dev/null
+++ b/pkgs/native_assets_cli/example/README.md
@@ -0,0 +1,9 @@
+The examples in this folder illustrate how native code is built and bundled
+in Dart and Flutter apps.
+
+* [native_add_app/](native_add_app/) has a dependency with C code.
+  This app should declare nothing special. Dart and Flutter should check
+  all dependencies for native code.
+* [native_add_library/](native_add_library/) contains a library with C code.
+  When Dart code in this library or dependent on this library is invoked, the
+  C code must be built and bundled so that it can be used by the Dart code.
diff --git a/pkgs/native_assets_cli/example/native_add_app/README.md b/pkgs/native_assets_cli/example/native_add_app/README.md
new file mode 100644
index 0000000..50a8bc0
--- /dev/null
+++ b/pkgs/native_assets_cli/example/native_add_app/README.md
@@ -0,0 +1,10 @@
+An example application to show invocation of native code bundled with the native
+assets feature.
+
+## Usage
+
+Run application with `dart --enable-experiment=native-assets run`.
+
+Run tests with `dart --enable-experiment=native-assets test`.
+
+Release application with `dart --enable-experiment=native-assets build bin/native_add_app`. (Run with `./bin/native_add_app/native_add_app.exe`.)
diff --git a/pkgs/native_assets_cli/example/native_add_app/bin/native_add_app.dart b/pkgs/native_assets_cli/example/native_add_app/bin/native_add_app.dart
new file mode 100644
index 0000000..40bd133
--- /dev/null
+++ b/pkgs/native_assets_cli/example/native_add_app/bin/native_add_app.dart
@@ -0,0 +1,11 @@
+// Copyright (c) 2023, 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:native_add_library/native_add_library.dart';
+
+void main() {
+  print('Invoking a native function to calculate 1 + 2.');
+  final result = add(1, 2);
+  print('Invocation success: 1 + 2 = $result.');
+}
diff --git a/pkgs/native_assets_cli/example/native_add_app/pubspec.yaml b/pkgs/native_assets_cli/example/native_add_app/pubspec.yaml
new file mode 100644
index 0000000..68f3d8e
--- /dev/null
+++ b/pkgs/native_assets_cli/example/native_add_app/pubspec.yaml
@@ -0,0 +1,17 @@
+publish_to: none
+
+name: native_add_app
+description: Invokes a package with native assets.
+version: 0.1.0
+repository: https://github.com/dart-lang/native/tree/main/pkgs/native_assets_cli/example/native_add_app
+
+environment:
+  sdk: '>=3.0.0 <4.0.0'
+
+dependencies:
+  native_add_library:
+    path: ../native_add_library/
+
+dev_dependencies:
+  lints: ^3.0.0
+  test: ^1.21.0
diff --git a/pkgs/native_assets_cli/example/native_add_app/test/native_add_library_test.dart b/pkgs/native_assets_cli/example/native_add_app/test/native_add_library_test.dart
new file mode 100644
index 0000000..692446e
--- /dev/null
+++ b/pkgs/native_assets_cli/example/native_add_app/test/native_add_library_test.dart
@@ -0,0 +1,12 @@
+// Copyright (c) 2023, 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:native_add_library/native_add_library.dart';
+import 'package:test/test.dart';
+
+void main() {
+  test('invoke native function', () {
+    expect(add(24, 18), 42);
+  });
+}
diff --git a/pkgs/native_assets_cli/example/native_add_library/README.md b/pkgs/native_assets_cli/example/native_add_library/README.md
new file mode 100644
index 0000000..180cb02
--- /dev/null
+++ b/pkgs/native_assets_cli/example/native_add_library/README.md
@@ -0,0 +1,16 @@
+An example library containing native code that should be bundled with Dart and
+Flutter applications.
+
+## Usage
+
+Run tests with `dart --enable-experiment=native-assets test`.
+
+## Code organization
+
+A typical layout of a package with native code is:
+
+* `lib/` contains Dart code which uses [`dart:ffi`] and [`package:ffigen`]
+  to call into native code.
+* `src/` contains C code which is built and then invoked through `dart:ffi`.
+* `build.dart` implements the CLI that communicates which native assets
+  to build/bundle with the Dart/Flutter SDK.
diff --git a/pkgs/native_assets_cli/example/native_add_library/build.dart b/pkgs/native_assets_cli/example/native_add_library/build.dart
new file mode 100644
index 0000000..ae39cdc
--- /dev/null
+++ b/pkgs/native_assets_cli/example/native_add_library/build.dart
@@ -0,0 +1,39 @@
+// Copyright (c) 2023, 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:logging/logging.dart';
+import 'package:native_assets_cli/native_assets_cli.dart';
+import 'package:native_toolchain_c/native_toolchain_c.dart';
+
+const packageName = 'native_add_library';
+
+/// Implements the protocol from `package:native_assets_cli` by building
+/// the C code in `src/` and reporting what native assets it built.
+void main(List<String> args) async {
+  // Parse the build configuration passed to this CLI from Dart or Flutter.
+  final buildConfig = await BuildConfig.fromArgs(args);
+  final buildOutput = BuildOutput();
+
+  // Configure `package:native_toolchain_c` to build the C code for us.
+  final cbuilder = CBuilder.library(
+    name: packageName,
+    assetId: 'package:$packageName/${packageName}.dart',
+    sources: [
+      'src/$packageName.c',
+    ],
+  );
+  await cbuilder.run(
+    buildConfig: buildConfig,
+    // `package:native_toolchain_c` will output the dynamic or static libraries it built,
+    // what files it accessed (for caching the build), etc.
+    buildOutput: buildOutput,
+    logger: Logger('')
+      ..level = Level.ALL
+      ..onRecord.listen((record) => print(record.message)),
+  );
+
+  // Write the output according to the native assets protocol so that Dart or
+  // Flutter can find the native assets produced by this script.
+  await buildOutput.writeToFile(outDir: buildConfig.outDir);
+}
diff --git a/pkgs/native_assets_cli/example/native_add_library/ffigen.yaml b/pkgs/native_assets_cli/example/native_add_library/ffigen.yaml
new file mode 100644
index 0000000..4b32465
--- /dev/null
+++ b/pkgs/native_assets_cli/example/native_add_library/ffigen.yaml
@@ -0,0 +1,20 @@
+# Run with `flutter pub run ffigen --config ffigen.yaml`.
+name: NativeAddBindings
+description: |
+  Bindings for `src/native_add.h`.
+
+  Regenerate bindings with `flutter pub run ffigen --config ffigen.yaml`.
+output: 'lib/native_add_library.dart'
+headers:
+  entry-points:
+    - 'src/native_add_library.h'
+  include-directives:
+    - 'src/native_add_library.h'
+preamble: |
+  // Copyright (c) 2023, 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.
+comments:
+  style: any
+  length: full
+ffi-native:
diff --git a/pkgs/native_assets_cli/example/native_add_library/lib/native_add_library.dart b/pkgs/native_assets_cli/example/native_add_library/lib/native_add_library.dart
new file mode 100644
index 0000000..4d1e803
--- /dev/null
+++ b/pkgs/native_assets_cli/example/native_add_library/lib/native_add_library.dart
@@ -0,0 +1,15 @@
+// Copyright (c) 2023, 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.
+
+// AUTO GENERATED FILE, DO NOT EDIT.
+//
+// Generated by `package:ffigen`.
+// ignore_for_file: type=lint
+import 'dart:ffi' as ffi;
+
+@ffi.Native<ffi.Int32 Function(ffi.Int32, ffi.Int32)>(symbol: 'add')
+external int add(
+  int a,
+  int b,
+);
diff --git a/pkgs/native_assets_cli/example/native_add_library/pubspec.yaml b/pkgs/native_assets_cli/example/native_add_library/pubspec.yaml
new file mode 100644
index 0000000..02f8219
--- /dev/null
+++ b/pkgs/native_assets_cli/example/native_add_library/pubspec.yaml
@@ -0,0 +1,20 @@
+publish_to: none
+
+name: native_add_library
+description: Sums two numbers with native code.
+version: 0.1.0
+repository: https://github.com/dart-lang/native/tree/main/pkgs/native_assets_cli/example/native_add_library
+
+environment:
+  sdk: '>=3.0.0 <4.0.0'
+
+dependencies:
+  cli_config: ^0.1.1
+  logging: ^1.1.1
+  native_assets_cli: ^0.3.0
+  native_toolchain_c: ^0.3.0
+
+dev_dependencies:
+  ffigen: ^8.0.2
+  lints: ^3.0.0
+  test: ^1.21.0
diff --git a/pkgs/native_assets_cli/example/native_add_library/src/native_add_library.c b/pkgs/native_assets_cli/example/native_add_library/src/native_add_library.c
new file mode 100644
index 0000000..cf68dd3
--- /dev/null
+++ b/pkgs/native_assets_cli/example/native_add_library/src/native_add_library.c
@@ -0,0 +1,16 @@
+// Copyright (c) 2023, 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.
+
+#include "native_add_library.h"
+
+#ifdef DEBUG
+#include <stdio.h>
+#endif
+
+int32_t add(int32_t a, int32_t b) {
+#ifdef DEBUG
+  printf("Adding %i and %i.\n", a, b);
+#endif
+  return a + b;
+}
diff --git a/pkgs/native_assets_cli/example/native_add_library/src/native_add_library.h b/pkgs/native_assets_cli/example/native_add_library/src/native_add_library.h
new file mode 100644
index 0000000..5824f98
--- /dev/null
+++ b/pkgs/native_assets_cli/example/native_add_library/src/native_add_library.h
@@ -0,0 +1,13 @@
+// Copyright (c) 2023, 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.
+
+#include <stdint.h>
+
+#if _WIN32
+#define MYLIB_EXPORT __declspec(dllexport)
+#else
+#define MYLIB_EXPORT
+#endif
+
+MYLIB_EXPORT int32_t add(int32_t a, int32_t b);
diff --git a/pkgs/native_assets_cli/example/native_add_library/test/native_add_library_test.dart b/pkgs/native_assets_cli/example/native_add_library/test/native_add_library_test.dart
new file mode 100644
index 0000000..692446e
--- /dev/null
+++ b/pkgs/native_assets_cli/example/native_add_library/test/native_add_library_test.dart
@@ -0,0 +1,12 @@
+// Copyright (c) 2023, 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:native_add_library/native_add_library.dart';
+import 'package:test/test.dart';
+
+void main() {
+  test('invoke native function', () {
+    expect(add(24, 18), 42);
+  });
+}
diff --git a/pkgs/native_assets_cli/lib/native_assets_cli.dart b/pkgs/native_assets_cli/lib/native_assets_cli.dart
new file mode 100644
index 0000000..8ee3d30
--- /dev/null
+++ b/pkgs/native_assets_cli/lib/native_assets_cli.dart
@@ -0,0 +1,18 @@
+// Copyright (c) 2023, 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.
+
+/// A library that contains the argument and file formats for implementing a
+/// native assets CLI.
+library native_assets_cli;
+
+export 'src/model/asset.dart';
+export 'src/model/build_config.dart';
+export 'src/model/build_mode.dart';
+export 'src/model/build_output.dart';
+export 'src/model/dependencies.dart';
+export 'src/model/ios_sdk.dart';
+export 'src/model/link_mode.dart';
+export 'src/model/link_mode_preference.dart';
+export 'src/model/metadata.dart';
+export 'src/model/target.dart';
diff --git a/pkgs/native_assets_cli/lib/src/model/asset.dart b/pkgs/native_assets_cli/lib/src/model/asset.dart
new file mode 100644
index 0000000..616688f
--- /dev/null
+++ b/pkgs/native_assets_cli/lib/src/model/asset.dart
@@ -0,0 +1,323 @@
+// Copyright (c) 2023, 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:yaml/yaml.dart';
+
+import '../utils/uri.dart';
+import '../utils/yaml.dart';
+import 'link_mode.dart';
+import 'target.dart';
+
+abstract class AssetPath {
+  factory AssetPath(String pathType, Uri? uri) {
+    switch (pathType) {
+      case AssetAbsolutePath._pathTypeValue:
+        return AssetAbsolutePath(uri!);
+      case AssetRelativePath._pathTypeValue:
+        return AssetRelativePath(uri!);
+      case AssetSystemPath._pathTypeValue:
+        return AssetSystemPath(uri!);
+      case AssetInExecutable._pathTypeValue:
+        return AssetInExecutable();
+      case AssetInProcess._pathTypeValue:
+        return AssetInProcess();
+    }
+    throw FormatException('Unknown pathType: $pathType.');
+  }
+
+  factory AssetPath.fromYaml(YamlMap yamlMap) {
+    final pathType = as<String>(yamlMap[_pathTypeKey]);
+    final uriString = as<String?>(yamlMap[_uriKey]);
+    final uri = uriString != null ? Uri(path: uriString) : null;
+    return AssetPath(pathType, uri);
+  }
+
+  Map<String, Object> toYaml();
+  List<String> toDartConst();
+
+  static const _pathTypeKey = 'path_type';
+  static const _uriKey = 'uri';
+
+  Future<bool> exists();
+}
+
+/// Asset at absolute path [uri].
+class AssetAbsolutePath implements AssetPath {
+  final Uri uri;
+
+  AssetAbsolutePath(this.uri);
+
+  static const _pathTypeValue = 'absolute';
+
+  @override
+  Map<String, Object> toYaml() => {
+        AssetPath._pathTypeKey: _pathTypeValue,
+        AssetPath._uriKey: uri.toFilePath(),
+      };
+
+  @override
+  List<String> toDartConst() => [_pathTypeValue, uri.toFilePath()];
+
+  @override
+  int get hashCode => Object.hash(uri, 133711);
+
+  @override
+  bool operator ==(Object other) {
+    if (other is! AssetAbsolutePath) {
+      return false;
+    }
+    return uri == other.uri;
+  }
+
+  @override
+  Future<bool> exists() => uri.fileSystemEntity.exists();
+}
+
+/// Asset is avaliable on a relative path.
+///
+/// If [LinkMode] of an [Asset] is [LinkMode.dynamic],
+/// `Platform.script.resolve(uri)` will be used to load the asset at runtime.
+class AssetRelativePath implements AssetPath {
+  final Uri uri;
+
+  AssetRelativePath(this.uri);
+
+  static const _pathTypeValue = 'relative';
+
+  @override
+  Map<String, Object> toYaml() => {
+        AssetPath._pathTypeKey: _pathTypeValue,
+        AssetPath._uriKey: uri.toFilePath(),
+      };
+
+  @override
+  List<String> toDartConst() => [_pathTypeValue, uri.toFilePath()];
+
+  @override
+  int get hashCode => Object.hash(uri, 133717);
+
+  @override
+  bool operator ==(Object other) {
+    if (other is! AssetRelativePath) {
+      return false;
+    }
+    return uri == other.uri;
+  }
+
+  @override
+  Future<bool> exists() => uri.fileSystemEntity.exists();
+}
+
+/// Asset is avaliable on the system `PATH`.
+///
+/// [uri] only contains a file name.
+class AssetSystemPath implements AssetPath {
+  final Uri uri;
+
+  AssetSystemPath(this.uri);
+
+  static const _pathTypeValue = 'system';
+
+  @override
+  Map<String, Object> toYaml() => {
+        AssetPath._pathTypeKey: _pathTypeValue,
+        AssetPath._uriKey: uri.toFilePath(),
+      };
+
+  @override
+  List<String> toDartConst() => [_pathTypeValue, uri.toFilePath()];
+
+  @override
+  int get hashCode => Object.hash(uri, 133723);
+
+  @override
+  bool operator ==(Object other) {
+    if (other is! AssetSystemPath) {
+      return false;
+    }
+    return uri == other.uri;
+  }
+
+  @override
+  Future<bool> exists() => Future.value(true);
+}
+
+/// Asset is loaded in the process and symbols are available through
+/// `DynamicLibrary.process()`.
+class AssetInProcess implements AssetPath {
+  AssetInProcess._();
+
+  static final AssetInProcess _singleton = AssetInProcess._();
+
+  factory AssetInProcess() => _singleton;
+
+  static const _pathTypeValue = 'process';
+
+  @override
+  Map<String, Object> toYaml() => {
+        AssetPath._pathTypeKey: _pathTypeValue,
+      };
+
+  @override
+  List<String> toDartConst() => [_pathTypeValue];
+
+  @override
+  Future<bool> exists() => Future.value(true);
+}
+
+/// Asset is embedded in executable and symbols are available through
+/// `DynamicLibrary.executable()`.
+class AssetInExecutable implements AssetPath {
+  AssetInExecutable._();
+
+  static final AssetInExecutable _singleton = AssetInExecutable._();
+
+  factory AssetInExecutable() => _singleton;
+
+  static const _pathTypeValue = 'executable';
+
+  @override
+  Map<String, Object> toYaml() => {
+        AssetPath._pathTypeKey: _pathTypeValue,
+      };
+
+  @override
+  List<String> toDartConst() => [_pathTypeValue];
+
+  @override
+  Future<bool> exists() => Future.value(true);
+}
+
+class Asset {
+  final LinkMode linkMode;
+  final String id;
+  final Target target;
+  final AssetPath path;
+
+  Asset({
+    required this.id,
+    required this.linkMode,
+    required this.target,
+    required this.path,
+  });
+
+  factory Asset.fromYaml(YamlMap yamlMap) => Asset(
+        id: as<String>(yamlMap[_idKey]),
+        path: AssetPath.fromYaml(as<YamlMap>(yamlMap[_pathKey])),
+        target: Target.fromString(as<String>(yamlMap[_targetKey])),
+        linkMode: LinkMode.fromName(as<String>(yamlMap[_linkModeKey])),
+      );
+
+  static List<Asset> listFromYamlString(String yaml) {
+    final yamlObject = loadYaml(yaml);
+    if (yamlObject == null) {
+      return [];
+    }
+    return [
+      for (final yamlElement in as<YamlList>(yamlObject))
+        Asset.fromYaml(as<YamlMap>(yamlElement)),
+    ];
+  }
+
+  static List<Asset> listFromYamlList(YamlList yamlList) => [
+        for (final yamlElement in yamlList)
+          Asset.fromYaml(as<YamlMap>(yamlElement)),
+      ];
+
+  Asset copyWith({
+    LinkMode? linkMode,
+    String? id,
+    Target? target,
+    AssetPath? path,
+  }) =>
+      Asset(
+        id: id ?? this.id,
+        linkMode: linkMode ?? this.linkMode,
+        target: target ?? this.target,
+        path: path ?? this.path,
+      );
+
+  @override
+  bool operator ==(Object other) {
+    if (other is! Asset) {
+      return false;
+    }
+    return other.id == id &&
+        other.linkMode == linkMode &&
+        other.target == target &&
+        other.path == path;
+  }
+
+  @override
+  int get hashCode => Object.hash(id, linkMode, target, path);
+
+  Map<String, Object> toYaml() => {
+        _idKey: id,
+        _linkModeKey: linkMode.name,
+        _pathKey: path.toYaml(),
+        _targetKey: target.toString(),
+      };
+
+  Map<String, List<String>> toDartConst() => {
+        id: path.toDartConst(),
+      };
+
+  String toYamlString() => yamlEncode(toYaml());
+
+  static const _idKey = 'id';
+  static const _linkModeKey = 'link_mode';
+  static const _pathKey = 'path';
+  static const _targetKey = 'target';
+
+  Future<bool> exists() => path.exists();
+
+  @override
+  String toString() => 'Asset(${toYaml()})';
+}
+
+extension AssetIterable on Iterable<Asset> {
+  List<Object> toYaml() => [for (final item in this) item.toYaml()];
+
+  String toYamlString() => yamlEncode(toYaml());
+
+  Iterable<Asset> whereLinkMode(LinkMode linkMode) =>
+      where((e) => e.linkMode == linkMode);
+
+  Map<Target, List<Asset>> get assetsPerTarget {
+    final result = <Target, List<Asset>>{};
+    for (final asset in this) {
+      final assets = result[asset.target] ?? [];
+      assets.add(asset);
+      result[asset.target] = assets;
+    }
+    return result;
+  }
+
+  Map<String, Map<String, List<String>>> toDartConst() => {
+        for (final entry in assetsPerTarget.entries)
+          entry.key.toString():
+              _combineMaps(entry.value.map((e) => e.toDartConst()).toList())
+      };
+
+  Map<Object, Object> toNativeAssetsFileEncoding() => {
+        'format-version': [1, 0, 0],
+        'native-assets': toDartConst(),
+      };
+
+  String toNativeAssetsFile() => yamlEncode(toNativeAssetsFileEncoding());
+
+  Future<bool> allExist() async {
+    final allResults = await Future.wait(map((e) => e.exists()));
+    final missing = allResults.contains(false);
+    return !missing;
+  }
+}
+
+Map<X, Y> _combineMaps<X, Y>(Iterable<Map<X, Y>> maps) {
+  final result = <X, Y>{};
+  for (final map in maps) {
+    result.addAll(map);
+  }
+  return result;
+}
diff --git a/pkgs/native_assets_cli/lib/src/model/build_config.dart b/pkgs/native_assets_cli/lib/src/model/build_config.dart
new file mode 100644
index 0000000..93e0d9d
--- /dev/null
+++ b/pkgs/native_assets_cli/lib/src/model/build_config.dart
@@ -0,0 +1,658 @@
+// Copyright (c) 2023, 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';
+
+import 'package:cli_config/cli_config.dart';
+import 'package:collection/collection.dart';
+import 'package:crypto/crypto.dart';
+import 'package:pub_semver/pub_semver.dart';
+
+import '../utils/map.dart';
+import '../utils/yaml.dart';
+import 'build_mode.dart';
+import 'ios_sdk.dart';
+import 'link_mode_preference.dart';
+import 'metadata.dart';
+import 'target.dart';
+
+class BuildConfig {
+  /// The folder in which all output and intermediate artifacts should be
+  /// placed.
+  Uri get outDir => _outDir;
+  late final Uri _outDir;
+
+  /// The name of the package the native assets are built for.
+  String get packageName => _packageName;
+  late final String _packageName;
+
+  /// The root of the package the native assets are built for.
+  ///
+  /// Often a package's native assets are built because a package is a
+  /// dependency of another. For this it is convenient to know the packageRoot.
+  Uri get packageRoot => _packageRoot;
+  late final Uri _packageRoot;
+
+  /// The target being compiled for.
+  ///
+  /// Not available in [dryRun].
+  late final Target target =
+      Target.fromArchitectureAndOs(targetArchitecture, targetOs);
+
+  /// The architecture being compiled for.
+  ///
+  /// Not available in [dryRun].
+  Architecture get targetArchitecture {
+    _ensureNotDryRun();
+    return _targetArchitecture;
+  }
+
+  late final Architecture _targetArchitecture;
+
+  /// The operating system being compiled for.
+  OS get targetOs => _targetOs;
+  late final OS _targetOs;
+
+  /// When compiling for iOS, whether to target device or simulator.
+  ///
+  /// Required when [targetOs] equals [OS.iOS].
+  ///
+  /// Not available in [dryRun].s
+  IOSSdk? get targetIOSSdk {
+    _ensureNotDryRun();
+    return _targetIOSSdk;
+  }
+
+  late final IOSSdk? _targetIOSSdk;
+
+  /// When compiling for Android, the minimum Android SDK API version to that
+  /// the compiled code will be compatible with.
+  ///
+  /// Required when [targetOs] equals [OS.android].
+  ///
+  /// Not available in [dryRun].
+  ///
+  /// For more information about the Android API version, refer to
+  /// [`minSdkVersion`](https://developer.android.com/ndk/guides/sdk-versions#minsdkversion)
+  /// in the Android documentation.
+  int? get targetAndroidNdkApi {
+    _ensureNotDryRun();
+    return _targetAndroidNdkApi;
+  }
+
+  late final int? _targetAndroidNdkApi;
+
+  /// Preferred linkMode method for library.
+  LinkModePreference get linkModePreference => _linkModePreference;
+  late final LinkModePreference _linkModePreference;
+
+  /// Metadata from direct dependencies.
+  ///
+  /// The key in the map is the package name of the dependency.
+  ///
+  /// The key in the nested map is the key for the metadata from the dependency.
+  ///
+  /// Not available in [dryRun].
+  Map<String, Metadata>? get dependencyMetadata {
+    _ensureNotDryRun();
+    return _dependencyMetadata;
+  }
+
+  late final Map<String, Metadata>? _dependencyMetadata;
+
+  /// The configuration for invoking the C compiler.
+  ///
+  /// Not available in [dryRun].
+  CCompilerConfig get cCompiler {
+    _ensureNotDryRun();
+    return _cCompiler;
+  }
+
+  late final CCompilerConfig _cCompiler;
+
+  /// Don't run the build, only report the native assets produced.
+  bool get dryRun => _dryRun ?? false;
+  late final bool? _dryRun;
+
+  /// The build mode that the code should be compiled in.
+  ///
+  /// Not available in [dryRun].
+  BuildMode get buildMode {
+    _ensureNotDryRun();
+    return _buildMode;
+  }
+
+  late final BuildMode _buildMode;
+
+  /// The underlying config.
+  ///
+  /// Can be used for easier access to values on [dependencyMetadata].
+  Config get config => _config;
+  late final Config _config;
+
+  factory BuildConfig({
+    required Uri outDir,
+    required String packageName,
+    required Uri packageRoot,
+    required BuildMode buildMode,
+    required Architecture targetArchitecture,
+    required OS targetOs,
+    IOSSdk? targetIOSSdk,
+    int? targetAndroidNdkApi,
+    CCompilerConfig? cCompiler,
+    required LinkModePreference linkModePreference,
+    Map<String, Metadata>? dependencyMetadata,
+  }) {
+    final nonValidated = BuildConfig._()
+      .._outDir = outDir
+      .._packageName = packageName
+      .._packageRoot = packageRoot
+      .._buildMode = buildMode
+      .._targetArchitecture = targetArchitecture
+      .._targetOs = targetOs
+      .._targetIOSSdk = targetIOSSdk
+      .._targetAndroidNdkApi = targetAndroidNdkApi
+      .._cCompiler = cCompiler ?? CCompilerConfig()
+      .._linkModePreference = linkModePreference
+      .._dependencyMetadata = dependencyMetadata
+      .._dryRun = false;
+    final parsedConfigFile = nonValidated.toYaml();
+    final config = Config(fileParsed: parsedConfigFile);
+    return BuildConfig.fromConfig(config);
+  }
+
+  factory BuildConfig.dryRun({
+    required Uri outDir,
+    required String packageName,
+    required Uri packageRoot,
+    required OS targetOs,
+    required LinkModePreference linkModePreference,
+  }) {
+    final nonValidated = BuildConfig._()
+      .._outDir = outDir
+      .._packageName = packageName
+      .._packageRoot = packageRoot
+      .._targetOs = targetOs
+      .._linkModePreference = linkModePreference
+      .._cCompiler = CCompilerConfig()
+      .._dryRun = true;
+    final parsedConfigFile = nonValidated.toYaml();
+    final config = Config(fileParsed: parsedConfigFile);
+    return BuildConfig.fromConfig(config);
+  }
+
+  /// Constructs a checksum for a [BuildConfig] based on the fields
+  /// of a buildconfig that influence the build.
+  ///
+  /// This can be used for an [outDir], but should not be used for dry-runs.
+  ///
+  /// In particular, it only takes the package name from [packageRoot],
+  /// so that the hash is equal across checkouts and ignores [outDir] itself.
+  static String checksum({
+    required String packageName,
+    required Uri packageRoot,
+    required Architecture targetArchitecture,
+    required OS targetOs,
+    required BuildMode buildMode,
+    IOSSdk? targetIOSSdk,
+    int? targetAndroidNdkApi,
+    CCompilerConfig? cCompiler,
+    required LinkModePreference linkModePreference,
+    Map<String, Metadata>? dependencyMetadata,
+  }) {
+    final input = [
+      packageName,
+      targetArchitecture.toString(),
+      targetOs.toString(),
+      targetIOSSdk.toString(),
+      targetAndroidNdkApi.toString(),
+      buildMode.toString(),
+      linkModePreference.toString(),
+      cCompiler?.ar.toString(),
+      cCompiler?.cc.toString(),
+      cCompiler?.envScript.toString(),
+      cCompiler?.envScriptArgs.toString(),
+      cCompiler?.ld.toString(),
+      if (dependencyMetadata != null)
+        for (final entry in dependencyMetadata.entries) ...[
+          entry.key,
+          json.encode(entry.value.toYaml()),
+        ]
+    ].join('###');
+    final sha256String = sha256.convert(utf8.encode(input)).toString();
+    // 256 bit hashes lead to 64 hex character strings.
+    // To avoid overflowing file paths limits, only use 32.
+    // Using 16 hex characters would also be unlikely to have collisions.
+    const nameLength = 32;
+    return sha256String.substring(0, nameLength);
+  }
+
+  BuildConfig._();
+
+  /// The version of [BuildConfig].
+  ///
+  /// This class is used in the protocol between the Dart and Flutter SDKs
+  /// and packages through `build.dart` invocations.
+  ///
+  /// If we ever were to make breaking changes, it would be useful to give
+  /// proper error messages rather than just fail to parse the YAML
+  /// representation in the protocol.
+  static Version version = Version(1, 0, 0);
+
+  factory BuildConfig.fromConfig(Config config) {
+    final result = BuildConfig._().._cCompiler = CCompilerConfig._();
+    final configExceptions = <Object>[];
+    for (final f in result._readFieldsFromConfig()) {
+      try {
+        f(config);
+      } on FormatException catch (e, st) {
+        configExceptions.add(e);
+        configExceptions.add(st);
+      }
+    }
+
+    if (configExceptions.isNotEmpty) {
+      throw FormatException('Configuration is not in the right format. '
+          'FormatExceptions: $configExceptions');
+    }
+
+    return result;
+  }
+
+  /// Constructs a config by parsing CLI arguments and loading the config file.
+  ///
+  /// The [args] must be commandline arguments.
+  ///
+  /// If provided, [environment] must be a map containing environment variables.
+  /// If not provided, [environment] defaults to [Platform.environment].
+  ///
+  /// If provided, [workingDirectory] is used to resolves paths inside
+  /// [environment].
+  /// If not provided, [workingDirectory] defaults to [Directory.current].
+  ///
+  /// This async constructor is intended to be used directly in CLI files.
+  static Future<BuildConfig> fromArgs(
+    List<String> args, {
+    Map<String, String>? environment,
+    Uri? workingDirectory,
+  }) async {
+    final config = await Config.fromArgs(
+      args: args,
+      environment: environment,
+      workingDirectory: workingDirectory,
+    );
+    return BuildConfig.fromConfig(config);
+  }
+
+  static const outDirConfigKey = 'out_dir';
+  static const packageNameConfigKey = 'package_name';
+  static const packageRootConfigKey = 'package_root';
+  static const dependencyMetadataConfigKey = 'dependency_metadata';
+  static const _versionKey = 'version';
+  static const targetAndroidNdkApiConfigKey = 'target_android_ndk_api';
+  static const dryRunConfigKey = 'dry_run';
+
+  List<void Function(Config)> _readFieldsFromConfig() {
+    var osSet = false;
+    var ccSet = false;
+    return [
+      (config) {
+        final configVersion = Version.parse(config.string('version'));
+        if (configVersion.major > version.major) {
+          throw FormatException(
+            'The config version $configVersion is newer than this '
+            'package:native_assets_cli config version $version, '
+            'please update native_assets_cli.',
+          );
+        }
+        if (configVersion.major < version.major) {
+          throw FormatException(
+            'The config version $configVersion is newer than this '
+            'package:native_assets_cli config version $version, '
+            'please update the Dart or Flutter SDK.',
+          );
+        }
+      },
+      (config) => _config = config,
+      (config) => _dryRun = config.optionalBool(dryRunConfigKey),
+      (config) => _outDir = config.path(outDirConfigKey, mustExist: true),
+      (config) => _packageName = config.string(packageNameConfigKey),
+      (config) =>
+          _packageRoot = config.path(packageRootConfigKey, mustExist: true),
+      (config) {
+        if (dryRun) {
+          _throwIfNotNullInDryRun<String>(BuildMode.configKey);
+        } else {
+          _buildMode = BuildMode.fromString(
+            config.string(
+              BuildMode.configKey,
+              validValues: BuildMode.values.map((e) => '$e'),
+            ),
+          );
+        }
+      },
+      (config) {
+        _targetOs = OS.fromString(
+          config.string(
+            OS.configKey,
+            validValues: OS.values.map((e) => '$e'),
+          ),
+        );
+        osSet = true;
+      },
+      (config) {
+        if (dryRun) {
+          _throwIfNotNullInDryRun<String>(Architecture.configKey);
+        } else {
+          final validArchitectures = [
+            if (!osSet)
+              ...Architecture.values
+            else
+              for (final target in Target.values)
+                if (target.os == _targetOs) target.architecture
+          ];
+          _targetArchitecture = Architecture.fromString(
+            config.string(
+              Architecture.configKey,
+              validValues: validArchitectures.map((e) => '$e'),
+            ),
+          );
+        }
+      },
+      (config) {
+        if (dryRun) {
+          _throwIfNotNullInDryRun<String>(IOSSdk.configKey);
+        } else {
+          _targetIOSSdk = (osSet && _targetOs == OS.iOS)
+              ? IOSSdk.fromString(
+                  config.string(
+                    IOSSdk.configKey,
+                    validValues: IOSSdk.values.map((e) => '$e'),
+                  ),
+                )
+              : null;
+        }
+      },
+      (config) {
+        if (dryRun) {
+          _throwIfNotNullInDryRun<int>(targetAndroidNdkApiConfigKey);
+        } else {
+          _targetAndroidNdkApi = (osSet && _targetOs == OS.android)
+              ? config.int(targetAndroidNdkApiConfigKey)
+              : null;
+        }
+      },
+      (config) {
+        if (dryRun) {
+          _throwIfNotNullInDryRun<int>(CCompilerConfig.arConfigKeyFull);
+        } else {
+          cCompiler._ar = config.optionalPath(
+            CCompilerConfig.arConfigKeyFull,
+            mustExist: true,
+          );
+        }
+      },
+      (config) {
+        if (dryRun) {
+          _throwIfNotNullInDryRun<int>(CCompilerConfig.ccConfigKeyFull);
+        } else {
+          cCompiler._cc = config.optionalPath(
+            CCompilerConfig.ccConfigKeyFull,
+            mustExist: true,
+          );
+          ccSet = true;
+        }
+      },
+      (config) {
+        if (dryRun) {
+          _throwIfNotNullInDryRun<int>(CCompilerConfig.ccConfigKeyFull);
+        } else {
+          cCompiler._ld = config.optionalPath(
+            CCompilerConfig.ldConfigKeyFull,
+            mustExist: true,
+          );
+        }
+      },
+      (config) {
+        if (dryRun) {
+          _throwIfNotNullInDryRun<int>(CCompilerConfig.ccConfigKeyFull);
+        } else {
+          cCompiler._envScript = (ccSet &&
+                  cCompiler.cc != null &&
+                  cCompiler.cc!.toFilePath().endsWith('cl.exe'))
+              ? config.path(CCompilerConfig.envScriptConfigKeyFull,
+                  mustExist: true)
+              : null;
+        }
+      },
+      (config) {
+        if (dryRun) {
+          _throwIfNotNullInDryRun<int>(CCompilerConfig.ccConfigKeyFull);
+        } else {
+          cCompiler._envScriptArgs = config.optionalStringList(
+            CCompilerConfig.envScriptArgsConfigKeyFull,
+            splitEnvironmentPattern: ' ',
+          );
+        }
+      },
+      (config) {
+        _linkModePreference = LinkModePreference.fromString(
+          config.string(
+            LinkModePreference.configKey,
+            validValues: LinkModePreference.values.map((e) => '$e'),
+          ),
+        );
+      },
+      (config) {
+        _dependencyMetadata = _readDependencyMetadataFromConfig(config);
+      },
+    ];
+  }
+
+  Map<String, Metadata>? _readDependencyMetadataFromConfig(Config config) {
+    final fileValue =
+        config.valueOf<Map<Object?, Object?>?>(dependencyMetadataConfigKey);
+    if (fileValue == null) {
+      return null;
+    }
+    final result = <String, Metadata>{};
+    for (final entry in fileValue.entries) {
+      final packageName = as<String>(entry.key);
+      final defines = entry.value;
+      if (defines is! Map) {
+        throw FormatException("Unexpected value '$defines' for key "
+            "'$dependencyMetadataConfigKey.$packageName' in config file. "
+            'Expected a Map.');
+      }
+      final packageResult = <String, Object>{};
+      for (final entry2 in defines.entries) {
+        final key = as<String>(entry2.key);
+        final value = as<Object>(entry2.value);
+        packageResult[key] = value;
+      }
+      result[packageName] = Metadata(packageResult.sortOnKey());
+    }
+    return result.sortOnKey();
+  }
+
+  Map<String, Object> toYaml() {
+    late Map<String, Object> cCompilerYaml;
+    if (!dryRun) {
+      cCompilerYaml = _cCompiler.toYaml();
+    }
+
+    return {
+      outDirConfigKey: _outDir.toFilePath(),
+      packageNameConfigKey: _packageName,
+      packageRootConfigKey: _packageRoot.toFilePath(),
+      OS.configKey: _targetOs.toString(),
+      LinkModePreference.configKey: _linkModePreference.toString(),
+      _versionKey: version.toString(),
+      if (dryRun) dryRunConfigKey: dryRun,
+      if (!dryRun) ...{
+        BuildMode.configKey: _buildMode.toString(),
+        Architecture.configKey: _targetArchitecture.toString(),
+        if (_targetIOSSdk != null) IOSSdk.configKey: _targetIOSSdk.toString(),
+        if (_targetAndroidNdkApi != null)
+          targetAndroidNdkApiConfigKey: _targetAndroidNdkApi!,
+        if (cCompilerYaml.isNotEmpty) CCompilerConfig.configKey: cCompilerYaml,
+        if (_dependencyMetadata != null)
+          dependencyMetadataConfigKey: {
+            for (final entry in _dependencyMetadata!.entries)
+              entry.key: entry.value.toYaml(),
+          },
+      },
+    }.sortOnKey();
+  }
+
+  String toYamlString() => yamlEncode(toYaml());
+
+  @override
+  bool operator ==(Object other) {
+    if (other is! BuildConfig) {
+      return false;
+    }
+    if (other.outDir != outDir) return false;
+    if (other.packageName != packageName) return false;
+    if (other.packageRoot != packageRoot) return false;
+    if (other.dryRun != dryRun) return false;
+    if (other.targetOs != targetOs) return false;
+    if (other.linkModePreference != linkModePreference) return false;
+    if (!dryRun) {
+      if (other.buildMode != buildMode) return false;
+      if (other.targetArchitecture != targetArchitecture) return false;
+      if (other.targetIOSSdk != targetIOSSdk) return false;
+      if (other.targetAndroidNdkApi != targetAndroidNdkApi) return false;
+      if (other.cCompiler != cCompiler) return false;
+      if (!const DeepCollectionEquality()
+          .equals(other.dependencyMetadata, _dependencyMetadata)) return false;
+    }
+    return true;
+  }
+
+  @override
+  int get hashCode => Object.hashAll([
+        outDir,
+        packageName,
+        packageRoot,
+        targetOs,
+        linkModePreference,
+        dryRun,
+        if (!dryRun) ...[
+          buildMode,
+          const DeepCollectionEquality().hash(dependencyMetadata),
+          targetArchitecture,
+          targetIOSSdk,
+          targetAndroidNdkApi,
+          cCompiler,
+        ],
+      ]);
+
+  @override
+  String toString() => 'BuildConfig(${toYaml()})';
+
+  void _ensureNotDryRun() {
+    if (dryRun) {
+      throw StateError('''This field is not available in dry runs.
+In Flutter projects, native builds are generated per OS which target multiple
+architectures, build modes, etc. Therefore, the list of native assets produced
+can _only_ depend on OS.''');
+    }
+  }
+
+  void _throwIfNotNullInDryRun<T>(String key) {
+    final object = config.valueOf<T?>(key);
+    if (object != null) {
+      throw const FormatException('''This field is not available in dry runs.
+In Flutter projects, native builds are generated per OS which target multiple
+architectures, build modes, etc. Therefore, the list of native assets produced
+can _only_ depend on OS.''');
+    }
+  }
+}
+
+class CCompilerConfig {
+  /// Path to a C compiler.
+  Uri? get cc => _cc;
+  late final Uri? _cc;
+
+  /// Path to a native linker.
+  Uri? get ld => _ld;
+  late final Uri? _ld;
+
+  /// Path to a native archiver.
+  Uri? get ar => _ar;
+  late final Uri? _ar;
+
+  /// Path to script that sets environment variables for [cc], [ld], and [ar].
+  Uri? get envScript => _envScript;
+  late final Uri? _envScript;
+
+  /// Arguments for [envScript].
+  List<String>? get envScriptArgs => _envScriptArgs;
+  late final List<String>? _envScriptArgs;
+
+  factory CCompilerConfig({
+    Uri? ar,
+    Uri? cc,
+    Uri? ld,
+    Uri? envScript,
+    List<String>? envScriptArgs,
+  }) =>
+      CCompilerConfig._()
+        .._ar = ar
+        .._cc = cc
+        .._ld = ld
+        .._envScript = envScript
+        .._envScriptArgs = envScriptArgs;
+
+  CCompilerConfig._();
+
+  static const configKey = 'c_compiler';
+  static const arConfigKey = 'ar';
+  static const arConfigKeyFull = '$configKey.$arConfigKey';
+  static const ccConfigKey = 'cc';
+  static const ccConfigKeyFull = '$configKey.$ccConfigKey';
+  static const ldConfigKey = 'ld';
+  static const ldConfigKeyFull = '$configKey.$ldConfigKey';
+  static const envScriptConfigKey = 'env_script';
+  static const envScriptConfigKeyFull = '$configKey.$envScriptConfigKey';
+  static const envScriptArgsConfigKey = 'env_script_arguments';
+  static const envScriptArgsConfigKeyFull =
+      '$configKey.$envScriptArgsConfigKey';
+
+  Map<String, Object> toYaml() => {
+        if (_ar != null) arConfigKey: _ar!.toFilePath(),
+        if (_cc != null) ccConfigKey: _cc!.toFilePath(),
+        if (_ld != null) ldConfigKey: _ld!.toFilePath(),
+        if (_envScript != null) envScriptConfigKey: _envScript!.toFilePath(),
+        if (_envScriptArgs != null) envScriptArgsConfigKey: _envScriptArgs!,
+      }.sortOnKey();
+
+  @override
+  bool operator ==(Object other) {
+    if (other is! CCompilerConfig) {
+      return false;
+    }
+    if (other.ar != ar) return false;
+    if (other.cc != cc) return false;
+    if (other.ld != ld) return false;
+    if (other.envScript != envScript) return false;
+    if (!const ListEquality<String>()
+        .equals(other.envScriptArgs, envScriptArgs)) {
+      return false;
+    }
+    return true;
+  }
+
+  @override
+  int get hashCode => Object.hash(
+        _ar,
+        _cc,
+        _ld,
+        _envScript,
+        const ListEquality<String>().hash(envScriptArgs),
+      );
+}
diff --git a/pkgs/native_assets_cli/lib/src/model/build_mode.dart b/pkgs/native_assets_cli/lib/src/model/build_mode.dart
new file mode 100644
index 0000000..8b8cd3c
--- /dev/null
+++ b/pkgs/native_assets_cli/lib/src/model/build_mode.dart
@@ -0,0 +1,26 @@
+// Copyright (c) 2023, 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.
+
+class BuildMode {
+  final String name;
+
+  const BuildMode._(this.name);
+
+  static const debug = BuildMode._('debug');
+  static const release = BuildMode._('release');
+
+  static const values = [
+    debug,
+    release,
+  ];
+
+  factory BuildMode.fromString(String target) =>
+      values.firstWhere((e) => e.name == target);
+
+  /// The `package:config` key preferably used.
+  static const String configKey = 'build_mode';
+
+  @override
+  String toString() => name;
+}
diff --git a/pkgs/native_assets_cli/lib/src/model/build_output.dart b/pkgs/native_assets_cli/lib/src/model/build_output.dart
new file mode 100644
index 0000000..6f88407
--- /dev/null
+++ b/pkgs/native_assets_cli/lib/src/model/build_output.dart
@@ -0,0 +1,138 @@
+// Copyright (c) 2023, 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';
+
+import 'package:collection/collection.dart';
+import 'package:pub_semver/pub_semver.dart';
+import 'package:yaml/yaml.dart';
+
+import '../utils/datetime.dart';
+import '../utils/file.dart';
+import '../utils/map.dart';
+import '../utils/yaml.dart';
+import 'asset.dart';
+import 'dependencies.dart';
+import 'metadata.dart';
+
+class BuildOutput {
+  /// Time the build this output belongs to started.
+  ///
+  /// Rounded down to whole seconds, because [File.lastModified] is rounded
+  /// to whole seconds and caching logic compares these timestamps.
+  final DateTime timestamp;
+  final List<Asset> assets;
+  final Dependencies dependencies;
+  final Metadata metadata;
+
+  BuildOutput({
+    DateTime? timestamp,
+    List<Asset>? assets,
+    Dependencies? dependencies,
+    Metadata? metadata,
+  })  : timestamp = (timestamp ?? DateTime.now()).roundDownToSeconds(),
+        assets = assets ?? [],
+        // ignore: prefer_const_constructors
+        dependencies = dependencies ?? Dependencies([]),
+        // ignore: prefer_const_constructors
+        metadata = metadata ?? Metadata({});
+
+  static const _assetsKey = 'assets';
+  static const _dependenciesKey = 'dependencies';
+  static const _metadataKey = 'metadata';
+  static const _timestampKey = 'timestamp';
+  static const _versionKey = 'version';
+
+  factory BuildOutput.fromYamlString(String yaml) {
+    final yamlObject = loadYaml(yaml);
+    return BuildOutput.fromYaml(as<YamlMap>(yamlObject));
+  }
+
+  factory BuildOutput.fromYaml(YamlMap yamlMap) {
+    final outputVersion = Version.parse(as<String>(yamlMap['version']));
+    if (outputVersion.major > version.major) {
+      throw FormatException(
+        'The output version $outputVersion is newer than the '
+        'package:native_assets_cli config version $version in Dart or Flutter, '
+        'please update the Dart or Flutter SDK.',
+      );
+    }
+    if (outputVersion.major < version.major) {
+      throw FormatException(
+        'The output version $outputVersion is newer than this '
+        'package:native_assets_cli config version $version in Dart or Flutter, '
+        'please update native_assets_cli.',
+      );
+    }
+
+    return BuildOutput(
+      timestamp: DateTime.parse(as<String>(yamlMap[_timestampKey])),
+      assets: Asset.listFromYamlList(as<YamlList>(yamlMap[_assetsKey])),
+      dependencies:
+          Dependencies.fromYaml(as<YamlList?>(yamlMap[_dependenciesKey])),
+      metadata: Metadata.fromYaml(as<YamlMap?>(yamlMap[_metadataKey])),
+    );
+  }
+
+  Map<String, Object> toYaml() => {
+        _timestampKey: timestamp.toString(),
+        _assetsKey: assets.toYaml(),
+        _dependenciesKey: dependencies.toYaml(),
+        _metadataKey: metadata.toYaml(),
+        _versionKey: version.toString(),
+      }..sortOnKey();
+
+  String toYamlString() => yamlEncode(toYaml());
+
+  /// The version of [BuildOutput].
+  ///
+  /// This class is used in the protocol between the Dart and Flutter SDKs
+  /// and packages through `build.dart` invocations.
+  ///
+  /// If we ever were to make breaking changes, it would be useful to give
+  /// proper error messages rather than just fail to parse the YAML
+  /// representation in the protocol.
+  static Version version = Version(1, 0, 0);
+
+  static const fileName = 'build_output.yaml';
+
+  /// Writes the YAML file from [outDir]/[fileName].
+  static Future<BuildOutput?> readFromFile({required Uri outDir}) async {
+    final buildOutputUri = outDir.resolve(fileName);
+    final buildOutputFile = File.fromUri(buildOutputUri);
+    if (!await buildOutputFile.exists()) {
+      return null;
+    }
+    return BuildOutput.fromYamlString(await buildOutputFile.readAsString());
+  }
+
+  /// Writes the [toYamlString] to [outDir]/[fileName].
+  Future<void> writeToFile({required Uri outDir}) async {
+    final buildOutputUri = outDir.resolve(fileName);
+    await File.fromUri(buildOutputUri)
+        .writeAsStringCreateDirectory(toYamlString());
+  }
+
+  @override
+  String toString() => toYamlString();
+
+  @override
+  bool operator ==(Object other) {
+    if (other is! BuildOutput) {
+      return false;
+    }
+    return other.timestamp == timestamp &&
+        const ListEquality<Asset>().equals(other.assets, assets) &&
+        other.dependencies == dependencies &&
+        other.metadata == metadata;
+  }
+
+  @override
+  int get hashCode => Object.hash(
+        timestamp.hashCode,
+        const ListEquality<Asset>().hash(assets),
+        dependencies,
+        metadata,
+      );
+}
diff --git a/pkgs/native_assets_cli/lib/src/model/dependencies.dart b/pkgs/native_assets_cli/lib/src/model/dependencies.dart
new file mode 100644
index 0000000..9ecc2cb
--- /dev/null
+++ b/pkgs/native_assets_cli/lib/src/model/dependencies.dart
@@ -0,0 +1,55 @@
+// Copyright (c) 2023, 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:collection/collection.dart';
+import 'package:yaml/yaml.dart';
+
+import '../utils/file.dart';
+import '../utils/uri.dart';
+import '../utils/yaml.dart';
+
+class Dependencies {
+  /// The dependencies a build relied on.
+  final List<Uri> dependencies;
+
+  const Dependencies(this.dependencies);
+
+  factory Dependencies.fromYamlString(String yamlString) {
+    final yaml = loadYaml(yamlString);
+    if (yaml is YamlList) {
+      return Dependencies.fromYaml(yaml);
+    }
+    // ignore: prefer_const_constructors
+    return Dependencies([]);
+  }
+
+  factory Dependencies.fromYaml(YamlList? yamlList) => Dependencies([
+        if (yamlList != null)
+          for (final dependency in yamlList)
+            fileSystemPathToUri(as<String>(dependency)),
+      ]);
+
+  List<String> toYaml() => [
+        for (final dependency in dependencies) dependency.toFilePath(),
+      ];
+
+  String toYamlString() => yamlEncode(toYaml());
+
+  @override
+  String toString() => toYamlString();
+
+  Future<DateTime> lastModified() =>
+      dependencies.map((u) => u.fileSystemEntity).lastModified();
+
+  @override
+  bool operator ==(Object other) {
+    if (other is! Dependencies) {
+      return false;
+    }
+    return const ListEquality<Uri>().equals(other.dependencies, dependencies);
+  }
+
+  @override
+  int get hashCode => const ListEquality<Uri>().hash(dependencies);
+}
diff --git a/pkgs/native_assets_cli/lib/src/model/ios_sdk.dart b/pkgs/native_assets_cli/lib/src/model/ios_sdk.dart
new file mode 100644
index 0000000..7d3deeb
--- /dev/null
+++ b/pkgs/native_assets_cli/lib/src/model/ios_sdk.dart
@@ -0,0 +1,29 @@
+// Copyright (c) 2023, 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.
+
+/// For an iOS target, a build is either done for the device or the simulator.
+///
+/// Only fat binaries or xcframeworks can contain both targets.
+class IOSSdk {
+  final String xcodebuildSdk;
+
+  const IOSSdk._(this.xcodebuildSdk);
+
+  static const iPhoneOs = IOSSdk._('iphoneos');
+  static const iPhoneSimulator = IOSSdk._('iphonesimulator');
+
+  static const values = [
+    iPhoneOs,
+    iPhoneSimulator,
+  ];
+
+  factory IOSSdk.fromString(String target) =>
+      values.firstWhere((e) => e.xcodebuildSdk == target);
+
+  /// The `package:config` key preferably used.
+  static const String configKey = 'target_ios_sdk';
+
+  @override
+  String toString() => xcodebuildSdk;
+}
diff --git a/pkgs/native_assets_cli/lib/src/model/link_mode.dart b/pkgs/native_assets_cli/lib/src/model/link_mode.dart
new file mode 100644
index 0000000..a7f4d3d
--- /dev/null
+++ b/pkgs/native_assets_cli/lib/src/model/link_mode.dart
@@ -0,0 +1,24 @@
+// Copyright (c) 2023, 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.
+
+class LinkMode {
+  final String name;
+
+  const LinkMode._(this.name);
+
+  static const LinkMode dynamic = LinkMode._('dynamic');
+  static const LinkMode static = LinkMode._('static');
+
+  /// Known values for [LinkMode].
+  static const List<LinkMode> values = [
+    dynamic,
+    static,
+  ];
+
+  factory LinkMode.fromName(String name) =>
+      values.where((element) => element.name == name).first;
+
+  @override
+  String toString() => name;
+}
diff --git a/pkgs/native_assets_cli/lib/src/model/link_mode_preference.dart b/pkgs/native_assets_cli/lib/src/model/link_mode_preference.dart
new file mode 100644
index 0000000..89a72ce
--- /dev/null
+++ b/pkgs/native_assets_cli/lib/src/model/link_mode_preference.dart
@@ -0,0 +1,72 @@
+// Copyright (c) 2023, 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 'link_mode.dart';
+
+class LinkModePreference {
+  final String name;
+  final String description;
+  final LinkMode preferredLinkMode;
+  final List<LinkMode> potentialLinkMode;
+
+  const LinkModePreference(
+    this.name,
+    this.description, {
+    required this.preferredLinkMode,
+    required this.potentialLinkMode,
+  });
+
+  factory LinkModePreference.fromString(String name) =>
+      values.where((element) => element.name == name).first;
+
+  static const dynamic = LinkModePreference(
+    'dynamic',
+    '''Provide native assets as dynamic libraries.
+Fails if not all native assets can only be provided as static library.
+Required to run Dart in JIT mode.''',
+    preferredLinkMode: LinkMode.dynamic,
+    potentialLinkMode: [LinkMode.dynamic],
+  );
+
+  static const static = LinkModePreference(
+    'static',
+    '''Provide native assets as static libraries.
+Fails if not all native assets can only be provided as dynamic library.
+Required for potential link-time tree-shaking of native code.
+Therefore, preferred to in Dart AOT mode.''',
+    preferredLinkMode: LinkMode.static,
+    potentialLinkMode: [LinkMode.static],
+  );
+
+  static const preferDynamic = LinkModePreference(
+    'prefer-dynamic',
+    '''Provide native assets as dynamic libraries, if possible.
+Otherwise, build native assets as static libraries.''',
+    preferredLinkMode: LinkMode.dynamic,
+    potentialLinkMode: LinkMode.values,
+  );
+
+  static const preferStatic = LinkModePreference(
+    'prefer-static',
+    '''Provide native assets as static libraries, if possible.
+Otherwise, build native assets as dynamic libraries.
+Preferred for AOT compilation, if there are any native assets which can only be
+provided as dynamic libraries.''',
+    preferredLinkMode: LinkMode.static,
+    potentialLinkMode: LinkMode.values,
+  );
+
+  static const values = [
+    dynamic,
+    static,
+    preferDynamic,
+    preferStatic,
+  ];
+
+  /// The `package:config` key preferably used.
+  static const String configKey = 'link_mode_preference';
+
+  @override
+  String toString() => name;
+}
diff --git a/pkgs/native_assets_cli/lib/src/model/metadata.dart b/pkgs/native_assets_cli/lib/src/model/metadata.dart
new file mode 100644
index 0000000..34949b1
--- /dev/null
+++ b/pkgs/native_assets_cli/lib/src/model/metadata.dart
@@ -0,0 +1,41 @@
+// Copyright (c) 2023, 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:collection/collection.dart';
+import 'package:yaml/yaml.dart';
+
+import '../utils/map.dart';
+import '../utils/yaml.dart';
+
+class Metadata {
+  final Map<String, Object> metadata;
+
+  const Metadata(this.metadata);
+
+  factory Metadata.fromYaml(YamlMap? yamlMap) =>
+      Metadata(yamlMap?.formatCast<String, Object>() ?? {});
+
+  factory Metadata.fromYamlString(String yaml) {
+    final yamlObject = as<YamlMap>(loadYaml(yaml));
+    return Metadata.fromYaml(yamlObject);
+  }
+
+  Map<String, Object> toYaml() => metadata..sortOnKey();
+
+  String toYamlString() => yamlEncode(toYaml());
+
+  @override
+  bool operator ==(Object other) {
+    if (other is! Metadata) {
+      return false;
+    }
+    return const DeepCollectionEquality().equals(other.metadata, metadata);
+  }
+
+  @override
+  int get hashCode => const DeepCollectionEquality().hash(metadata);
+
+  @override
+  String toString() => 'Metadata(${toYaml()})';
+}
diff --git a/pkgs/native_assets_cli/lib/src/model/target.dart b/pkgs/native_assets_cli/lib/src/model/target.dart
new file mode 100644
index 0000000..db61c62
--- /dev/null
+++ b/pkgs/native_assets_cli/lib/src/model/target.dart
@@ -0,0 +1,372 @@
+// Copyright (c) 2023, 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:ffi' show Abi;
+import 'dart:io';
+
+import 'link_mode.dart';
+
+/// The hardware architectures the Dart VM runs on.
+class Architecture {
+  /// This architecture as used in [Platform.version].
+  final String dartPlatform;
+
+  const Architecture._(this.dartPlatform);
+
+  factory Architecture.fromAbi(Abi abi) => _abiToArch[abi]!;
+
+  static const Architecture arm = Architecture._('arm');
+  static const Architecture arm64 = Architecture._('arm64');
+  static const Architecture ia32 = Architecture._('ia32');
+  static const Architecture riscv32 = Architecture._('riscv32');
+  static const Architecture riscv64 = Architecture._('riscv64');
+  static const Architecture x64 = Architecture._('x64');
+
+  /// Known values for [Architecture].
+  static const List<Architecture> values = [
+    arm,
+    arm64,
+    ia32,
+    riscv32,
+    riscv64,
+    x64,
+  ];
+
+  static const _abiToArch = {
+    Abi.androidArm: Architecture.arm,
+    Abi.androidArm64: Architecture.arm64,
+    Abi.androidIA32: Architecture.ia32,
+    Abi.androidX64: Architecture.x64,
+    Abi.androidRiscv64: Architecture.riscv64,
+    Abi.fuchsiaArm64: Architecture.arm64,
+    Abi.fuchsiaX64: Architecture.x64,
+    Abi.iosArm: Architecture.arm,
+    Abi.iosArm64: Architecture.arm64,
+    Abi.iosX64: Architecture.x64,
+    Abi.linuxArm: Architecture.arm,
+    Abi.linuxArm64: Architecture.arm64,
+    Abi.linuxIA32: Architecture.ia32,
+    Abi.linuxRiscv32: Architecture.riscv32,
+    Abi.linuxRiscv64: Architecture.riscv64,
+    Abi.linuxX64: Architecture.x64,
+    Abi.macosArm64: Architecture.arm64,
+    Abi.macosX64: Architecture.x64,
+    Abi.windowsArm64: Architecture.arm64,
+    Abi.windowsIA32: Architecture.ia32,
+    Abi.windowsX64: Architecture.x64,
+  };
+
+  /// The `package:config` key preferably used.
+  static const String configKey = 'target_architecture';
+
+  @override
+  String toString() => dartPlatform;
+
+  /// Mapping from strings as used in [Architecture.toString] to
+  /// [Architecture]s.
+  static final Map<String, Architecture> _stringToArchitecture =
+      Map.fromEntries(Architecture.values.map(
+          (architecture) => MapEntry(architecture.toString(), architecture)));
+
+  factory Architecture.fromString(String target) =>
+      _stringToArchitecture[target]!;
+
+  /// The current [Architecture].
+  ///
+  /// Read from the [Platform.version] string.
+  static final Architecture current = Target.current.architecture;
+}
+
+/// The operating systems the Dart VM runs on.
+class OS {
+  /// This OS as used in [Platform.version]
+  final String dartPlatform;
+
+  const OS._(this.dartPlatform);
+
+  factory OS.fromAbi(Abi abi) => _abiToOS[abi]!;
+
+  static const OS android = OS._('android');
+  static const OS fuchsia = OS._('fuchsia');
+  static const OS iOS = OS._('ios');
+  static const OS linux = OS._('linux');
+  static const OS macOS = OS._('macos');
+  static const OS windows = OS._('windows');
+
+  /// Known values for [OS].
+  static const List<OS> values = [
+    android,
+    fuchsia,
+    iOS,
+    linux,
+    macOS,
+    windows,
+  ];
+
+  static const _abiToOS = {
+    Abi.androidArm: OS.android,
+    Abi.androidArm64: OS.android,
+    Abi.androidIA32: OS.android,
+    Abi.androidX64: OS.android,
+    Abi.androidRiscv64: OS.android,
+    Abi.fuchsiaArm64: OS.fuchsia,
+    Abi.fuchsiaX64: OS.fuchsia,
+    Abi.iosArm: OS.iOS,
+    Abi.iosArm64: OS.iOS,
+    Abi.iosX64: OS.iOS,
+    Abi.linuxArm: OS.linux,
+    Abi.linuxArm64: OS.linux,
+    Abi.linuxIA32: OS.linux,
+    Abi.linuxRiscv32: OS.linux,
+    Abi.linuxRiscv64: OS.linux,
+    Abi.linuxX64: OS.linux,
+    Abi.macosArm64: OS.macOS,
+    Abi.macosX64: OS.macOS,
+    Abi.windowsArm64: OS.windows,
+    Abi.windowsIA32: OS.windows,
+    Abi.windowsX64: OS.windows,
+  };
+
+  /// Typical cross compilation between OSes.
+  static const _osCrossCompilationDefault = {
+    OS.macOS: [OS.macOS, OS.iOS, OS.android],
+    OS.linux: [OS.linux, OS.android],
+    OS.windows: [OS.windows, OS.android],
+  };
+
+  /// The default dynamic library file name on this [OS].
+  String dylibFileName(String name) {
+    final prefix = _dylibPrefix[this]!;
+    final extension = _dylibExtension[this]!;
+    return '$prefix$name.$extension';
+  }
+
+  /// The default static library file name on this [OS].
+  String staticlibFileName(String name) {
+    final prefix = _staticlibPrefix[this]!;
+    final extension = _staticlibExtension[this]!;
+    return '$prefix$name.$extension';
+  }
+
+  String libraryFileName(String name, LinkMode linkMode) {
+    if (linkMode == LinkMode.dynamic) {
+      return dylibFileName(name);
+    }
+    assert(linkMode == LinkMode.static);
+    return staticlibFileName(name);
+  }
+
+  /// The default executable file name on this [OS].
+  String executableFileName(String name) {
+    final extension = _executableExtension[this]!;
+    final dot = extension.isNotEmpty ? '.' : '';
+    return '$name$dot$extension';
+  }
+
+  /// The default name prefix for dynamic libraries per [OS].
+  static const _dylibPrefix = {
+    OS.android: 'lib',
+    OS.fuchsia: 'lib',
+    OS.iOS: 'lib',
+    OS.linux: 'lib',
+    OS.macOS: 'lib',
+    OS.windows: '',
+  };
+
+  /// The default extension for dynamic libraries per [OS].
+  static const _dylibExtension = {
+    OS.android: 'so',
+    OS.fuchsia: 'so',
+    OS.iOS: 'dylib',
+    OS.linux: 'so',
+    OS.macOS: 'dylib',
+    OS.windows: 'dll',
+  };
+
+  /// The default name prefix for static libraries per [OS].
+  static const _staticlibPrefix = _dylibPrefix;
+
+  /// The default extension for static libraries per [OS].
+  static const _staticlibExtension = {
+    OS.android: 'a',
+    OS.fuchsia: 'a',
+    OS.iOS: 'a',
+    OS.linux: 'a',
+    OS.macOS: 'a',
+    OS.windows: 'lib',
+  };
+
+  /// The default extension for executables per [OS].
+  static const _executableExtension = {
+    OS.android: '',
+    OS.fuchsia: '',
+    OS.iOS: '',
+    OS.linux: '',
+    OS.macOS: '',
+    OS.windows: 'exe',
+  };
+
+  /// The `package:config` key preferably used.
+  static const String configKey = 'target_os';
+
+  @override
+  String toString() => dartPlatform;
+
+  /// Mapping from strings as used in [OS.toString] to
+  /// [OS]s.
+  static final Map<String, OS> _stringToOS =
+      Map.fromEntries(OS.values.map((os) => MapEntry(os.toString(), os)));
+
+  factory OS.fromString(String target) => _stringToOS[target]!;
+
+  /// The current [OS].
+  ///
+  /// Read from the [Platform.version] string.
+  static final OS current = Target.current.os;
+}
+
+/// Application binary interface.
+///
+/// The Dart VM can run on a variety of [Target]s, see [Target.values].
+class Target implements Comparable<Target> {
+  final Abi abi;
+
+  const Target._(this.abi);
+
+  factory Target.fromString(String target) => _stringToTarget[target]!;
+
+  /// The [Target] corresponding the substring of [Platform.version]
+  /// describing the [Target].
+  ///
+  /// The [Platform.version] strings are formatted as follows:
+  /// `<version> (<date>) on "<Target>"`.
+  factory Target.fromDartPlatform(String versionStringFull) {
+    final split = versionStringFull.split('"');
+    if (split.length < 2) {
+      throw FormatException(
+          "Unknown version from Platform.version '$versionStringFull'.");
+    }
+    final versionString = split[1];
+    final target = _dartVMstringToTarget[versionString];
+    if (target == null) {
+      throw FormatException("Unknown ABI '$versionString' from Platform.version"
+          " '$versionStringFull'.");
+    }
+    return target;
+  }
+
+  factory Target.fromArchitectureAndOs(Architecture architecture, OS os) {
+    for (final value in values) {
+      if (value.os == os && value.architecture == architecture) {
+        return value;
+      }
+    }
+    throw ArgumentError('Unsupported combination of OS and architecture: '
+        "'${os}_$architecture'");
+  }
+
+  static const androidArm = Target._(Abi.androidArm);
+  static const androidArm64 = Target._(Abi.androidArm64);
+  static const androidIA32 = Target._(Abi.androidIA32);
+  static const androidX64 = Target._(Abi.androidX64);
+  static const androidRiscv64 = Target._(Abi.androidRiscv64);
+  static const fuchsiaArm64 = Target._(Abi.fuchsiaArm64);
+  static const fuchsiaX64 = Target._(Abi.fuchsiaX64);
+  static const iOSArm = Target._(Abi.iosArm);
+  static const iOSArm64 = Target._(Abi.iosArm64);
+  static const iOSX64 = Target._(Abi.iosX64);
+  static const linuxArm = Target._(Abi.linuxArm);
+  static const linuxArm64 = Target._(Abi.linuxArm64);
+  static const linuxIA32 = Target._(Abi.linuxIA32);
+  static const linuxRiscv32 = Target._(Abi.linuxRiscv32);
+  static const linuxRiscv64 = Target._(Abi.linuxRiscv64);
+  static const linuxX64 = Target._(Abi.linuxX64);
+  static const macOSArm64 = Target._(Abi.macosArm64);
+  static const macOSX64 = Target._(Abi.macosX64);
+  static const windowsArm64 = Target._(Abi.windowsArm64);
+  static const windowsIA32 = Target._(Abi.windowsIA32);
+  static const windowsX64 = Target._(Abi.windowsX64);
+
+  /// All Targets that we can build for.
+  ///
+  /// Note that for some of these a Dart SDK is not available and they are only
+  /// used as target architectures for Flutter apps.
+  static const values = {
+    androidArm,
+    androidArm64,
+    androidIA32,
+    androidX64,
+    androidRiscv64,
+    fuchsiaArm64,
+    fuchsiaX64,
+    iOSArm,
+    iOSArm64,
+    iOSX64,
+    linuxArm,
+    linuxArm64,
+    linuxIA32,
+    linuxRiscv32,
+    linuxRiscv64,
+    linuxX64,
+    macOSArm64,
+    macOSX64,
+    windowsArm64,
+    windowsIA32,
+    windowsX64,
+    // TODO(dacoharkes): Add support for `wasm`.
+  };
+
+  /// Mapping from strings as used in [Target.toString] to [Target]s.
+  static final Map<String, Target> _stringToTarget = Map.fromEntries(
+      Target.values.map((target) => MapEntry(target.toString(), target)));
+
+  /// Mapping from lowercased strings as used in [Platform.version] to
+  /// [Target]s.
+  static final Map<String, Target> _dartVMstringToTarget = Map.fromEntries(
+      Target.values.map((target) => MapEntry(target.dartVMToString(), target)));
+
+  /// The current [Target].
+  ///
+  /// Read from the [Platform.version] string.
+  static final Target current = Target.fromDartPlatform(Platform.version);
+
+  Architecture get architecture => Architecture.fromAbi(abi);
+
+  OS get os => OS.fromAbi(abi);
+
+  String get _architectureString => architecture.dartPlatform;
+
+  String get _osString => os.dartPlatform;
+
+  /// A string representation of this object.
+  @override
+  String toString() => dartVMToString();
+
+  /// As used in [Platform.version].
+  String dartVMToString() => '${_osString}_$_architectureString';
+
+  /// Compares `this` to [other].
+  ///
+  /// If [other] is also an [Target], consistent with sorting on [toString].
+  @override
+  int compareTo(Target other) => toString().compareTo(other.toString());
+
+  /// A list of supported target [Target]s from this host [os].
+  List<Target> supportedTargetTargets(
+          {Map<OS, List<OS>> osCrossCompilation =
+              OS._osCrossCompilationDefault}) =>
+      Target.values
+          .where((target) =>
+              // Only valid cross compilation.
+              osCrossCompilation[os]!.contains(target.os) &&
+              // And no deprecated architectures.
+              target != Target.iOSArm)
+          .sorted;
+}
+
+/// Common methods for manipulating iterables of [Target]s.
+extension TargetList on Iterable<Target> {
+  /// The [Target]s in `this` sorted by name alphabetically.
+  List<Target> get sorted => [for (final target in this) target]..sort();
+}
diff --git a/pkgs/native_assets_cli/lib/src/utils/datetime.dart b/pkgs/native_assets_cli/lib/src/utils/datetime.dart
new file mode 100644
index 0000000..11cda00
--- /dev/null
+++ b/pkgs/native_assets_cli/lib/src/utils/datetime.dart
@@ -0,0 +1,9 @@
+// Copyright (c) 2023, 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.
+
+extension DateTimeExtension on DateTime {
+  DateTime roundDownToSeconds() =>
+      DateTime.fromMillisecondsSinceEpoch(millisecondsSinceEpoch -
+          millisecondsSinceEpoch % const Duration(seconds: 1).inMilliseconds);
+}
diff --git a/pkgs/native_assets_cli/lib/src/utils/file.dart b/pkgs/native_assets_cli/lib/src/utils/file.dart
new file mode 100644
index 0000000..703f601
--- /dev/null
+++ b/pkgs/native_assets_cli/lib/src/utils/file.dart
@@ -0,0 +1,65 @@
+// Copyright (c) 2023, 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';
+
+extension FileExtension on File {
+  Future<File> writeAsStringCreateDirectory(String contents,
+      {FileMode mode = FileMode.write,
+      Encoding encoding = utf8,
+      bool flush = false}) async {
+    if (!await parent.exists()) {
+      await parent.create(recursive: true);
+    }
+    return await writeAsString(contents,
+        mode: mode, encoding: encoding, flush: flush);
+  }
+}
+
+extension FileSystemEntityExtension on FileSystemEntity {
+  Future<DateTime> lastModified() async {
+    final this_ = this;
+    if (this_ is Link || await FileSystemEntity.isLink(this_.path)) {
+      // Don't follow links.
+      return DateTime.fromMicrosecondsSinceEpoch(0);
+    }
+    if (this_ is File) {
+      if (!await this_.exists()) {
+        // If the file was deleted, regard it is modified recently.
+        return DateTime.now();
+      }
+      return await this_.lastModified();
+    }
+    assert(this_ is Directory);
+    this_ as Directory;
+    return await this_.lastModified();
+  }
+}
+
+extension FileSystemEntityIterable on Iterable<FileSystemEntity> {
+  Future<DateTime> lastModified() async {
+    var last = DateTime.fromMillisecondsSinceEpoch(0);
+    for (final entity in this) {
+      final entityTimestamp = await entity.lastModified();
+      if (entityTimestamp.isAfter(last)) {
+        last = entityTimestamp;
+      }
+    }
+    return last;
+  }
+}
+
+extension DirectoryExtension on Directory {
+  Future<DateTime> lastModified() async {
+    var last = DateTime.fromMillisecondsSinceEpoch(0);
+    await for (final entity in list()) {
+      final entityTimestamp = await entity.lastModified();
+      if (entityTimestamp.isAfter(last)) {
+        last = entityTimestamp;
+      }
+    }
+    return last;
+  }
+}
diff --git a/pkgs/native_assets_cli/lib/src/utils/map.dart b/pkgs/native_assets_cli/lib/src/utils/map.dart
new file mode 100644
index 0000000..bb767ed
--- /dev/null
+++ b/pkgs/native_assets_cli/lib/src/utils/map.dart
@@ -0,0 +1,15 @@
+// Copyright (c) 2023, 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.
+
+extension MapSorting<K extends Comparable<K>, V extends Object> on Map<K, V> {
+  Map<K, V> sortOnKey() {
+    final result = <K, V>{};
+    final keysSorted = keys.toList()..sort();
+    for (final key in keysSorted) {
+      final value = this[key]!;
+      result[key] = value;
+    }
+    return result;
+  }
+}
diff --git a/pkgs/native_assets_cli/lib/src/utils/uri.dart b/pkgs/native_assets_cli/lib/src/utils/uri.dart
new file mode 100644
index 0000000..ee4fd03
--- /dev/null
+++ b/pkgs/native_assets_cli/lib/src/utils/uri.dart
@@ -0,0 +1,21 @@
+// Copyright (c) 2023, 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';
+
+extension UriExtension on Uri {
+  FileSystemEntity get fileSystemEntity {
+    if (path.endsWith(Platform.pathSeparator) || path.endsWith('/')) {
+      return Directory.fromUri(this);
+    }
+    return File.fromUri(this);
+  }
+}
+
+Uri fileSystemPathToUri(String path) {
+  if (path.endsWith(Platform.pathSeparator) || path.endsWith('/')) {
+    return Uri.directory(path);
+  }
+  return Uri.file(path);
+}
diff --git a/pkgs/native_assets_cli/lib/src/utils/yaml.dart b/pkgs/native_assets_cli/lib/src/utils/yaml.dart
new file mode 100644
index 0000000..6e1b9e3
--- /dev/null
+++ b/pkgs/native_assets_cli/lib/src/utils/yaml.dart
@@ -0,0 +1,33 @@
+// Copyright (c) 2023, 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:yaml/yaml.dart';
+import 'package:yaml_edit/yaml_edit.dart';
+
+String yamlEncode(Object yamlEncoding) {
+  final editor = YamlEditor('');
+  editor.update(
+    [],
+    wrapAsYamlNode(
+      yamlEncoding,
+      collectionStyle: CollectionStyle.BLOCK,
+    ),
+  );
+  return editor.toString();
+}
+
+T as<T>(Object? object) {
+  if (object is T) {
+    return object;
+  }
+  throw FormatException(
+    "Unexpected value '$object' in YAML. Expected a $T.",
+  );
+}
+
+extension YamlMapCast on YamlMap {
+  Map<K, V> formatCast<K, V>() => <K, V>{
+        for (final e in entries) as<K>(e.key): as<V>(e.value),
+      };
+}
diff --git a/pkgs/native_assets_cli/pubspec.yaml b/pkgs/native_assets_cli/pubspec.yaml
new file mode 100644
index 0000000..1c944d7
--- /dev/null
+++ b/pkgs/native_assets_cli/pubspec.yaml
@@ -0,0 +1,26 @@
+name: native_assets_cli
+description: >-
+  A library that contains the argument and file formats for implementing a
+  native assets CLI.
+version: 0.3.2
+repository: https://github.com/dart-lang/native/tree/main/pkgs/native_assets_cli
+
+topics:
+  - ffi
+  - interop
+  - native-assets
+
+environment:
+  sdk: '>=3.0.0 <4.0.0'
+
+dependencies:
+  cli_config: ^0.1.1
+  collection: ^1.17.1
+  crypto: ^3.0.3
+  pub_semver: ^2.1.3
+  yaml: ^3.1.1
+  yaml_edit: ^2.1.0
+
+dev_dependencies:
+  dart_flutter_team_lints: ^2.1.1
+  test: ^1.21.0
diff --git a/pkgs/native_assets_cli/test/example/native_add_library_test.dart b/pkgs/native_assets_cli/test/example/native_add_library_test.dart
new file mode 100644
index 0000000..34da58d
--- /dev/null
+++ b/pkgs/native_assets_cli/test/example/native_add_library_test.dart
@@ -0,0 +1,92 @@
+// Copyright (c) 2023, 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.
+
+@OnPlatform({
+  'mac-os': Timeout.factor(2),
+  'windows': Timeout.factor(10),
+})
+library;
+
+import 'dart:io';
+
+import 'package:native_assets_cli/native_assets_cli.dart';
+import 'package:test/test.dart';
+
+import '../helpers.dart';
+
+void main() async {
+  late Uri tempUri;
+  const name = 'native_add_library';
+
+  setUp(() async {
+    tempUri = (await Directory.systemTemp.createTemp()).uri;
+  });
+
+  tearDown(() async {
+    await Directory.fromUri(tempUri).delete(recursive: true);
+  });
+
+  for (final dryRun in [true, false]) {
+    final testSuffix = dryRun ? ' dry_run' : '';
+    test('native_add build$testSuffix', () async {
+      final testTempUri = tempUri.resolve('test1/');
+      await Directory.fromUri(testTempUri).create();
+      final testPackageUri = packageUri.resolve('example/$name/');
+      final dartUri = Uri.file(Platform.resolvedExecutable);
+
+      final processResult = await Process.run(
+        dartUri.toFilePath(),
+        [
+          'build.dart',
+          '-Dout_dir=${tempUri.toFilePath()}',
+          '-Dpackage_name=$name',
+          '-Dpackage_root=${testPackageUri.toFilePath()}',
+          '-Dtarget_os=${OS.current}',
+          '-Dversion=${BuildConfig.version}',
+          '-Dlink_mode_preference=dynamic',
+          '-Ddry_run=$dryRun',
+          if (!dryRun) ...[
+            '-Dtarget_architecture=${Architecture.current}',
+            '-Dbuild_mode=debug',
+            if (cc != null) '-Dcc=${cc!.toFilePath()}',
+            if (envScript != null)
+              '-D${CCompilerConfig.envScriptConfigKeyFull}='
+                  '${envScript!.toFilePath()}',
+            if (envScriptArgs != null)
+              '-D${CCompilerConfig.envScriptArgsConfigKeyFull}='
+                  '${envScriptArgs!.join(' ')}',
+          ],
+        ],
+        workingDirectory: testPackageUri.toFilePath(),
+      );
+      if (processResult.exitCode != 0) {
+        print(processResult.stdout);
+        print(processResult.stderr);
+        print(processResult.exitCode);
+      }
+      expect(processResult.exitCode, 0);
+
+      final buildOutputUri = tempUri.resolve('build_output.yaml');
+      final buildOutput = BuildOutput.fromYamlString(
+          await File.fromUri(buildOutputUri).readAsString());
+      final assets = buildOutput.assets;
+      final dependencies = buildOutput.dependencies;
+      if (dryRun) {
+        expect(assets.length, greaterThanOrEqualTo(1));
+        expect(await assets.first.exists(), false);
+        expect(dependencies.dependencies, <Uri>[]);
+      } else {
+        expect(assets.length, 1);
+        expect(await assets.allExist(), true);
+        expect(
+          dependencies.dependencies,
+          [
+            testPackageUri.resolve('src/$name.c'),
+            testPackageUri.resolve('build.dart'),
+          ],
+        );
+      }
+    });
+  }
+}
diff --git a/pkgs/native_assets_cli/test/helpers.dart b/pkgs/native_assets_cli/test/helpers.dart
new file mode 100644
index 0000000..2bd2742
--- /dev/null
+++ b/pkgs/native_assets_cli/test/helpers.dart
@@ -0,0 +1,105 @@
+// Copyright (c) 2023, 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';
+
+import 'package:native_assets_cli/src/model/build_config.dart';
+
+const keepTempKey = 'KEEP_TEMPORARY_DIRECTORIES';
+
+Future<void> inTempDir(
+  Future<void> Function(Uri tempUri) fun, {
+  String? prefix,
+  bool keepTemp = false,
+}) async {
+  final tempDir = await Directory.systemTemp.createTemp(prefix);
+  // Deal with Windows temp folder aliases.
+  final tempUri =
+      Directory(await tempDir.resolveSymbolicLinks()).uri.normalizePath();
+  try {
+    await fun(tempUri);
+  } finally {
+    if ((!Platform.environment.containsKey(keepTempKey) ||
+            Platform.environment[keepTempKey]!.isEmpty) &&
+        !keepTemp) {
+      await tempDir.delete(recursive: true);
+    }
+  }
+}
+
+/// Test files are run in a variety of ways, find this package root in all.
+///
+/// Test files can be run from source from any working directory. The Dart SDK
+/// `tools/test.py` runs them from the root of the SDK for example.
+///
+/// Test files can be run from dill from the root of package. `package:test`
+/// does this.
+///
+/// https://github.com/dart-lang/test/issues/110
+Uri findPackageRoot(String packageName) {
+  final script = Platform.script;
+  final fileName = script.name;
+  if (fileName.endsWith('_test.dart')) {
+    // We're likely running from source.
+    var directory = script.resolve('.');
+    while (true) {
+      final dirName = directory.name;
+      if (dirName == packageName) {
+        return directory;
+      }
+      final parent = directory.resolve('..');
+      if (parent == directory) break;
+      directory = parent;
+    }
+  } else if (fileName.endsWith('.dill')) {
+    final cwd = Directory.current.uri;
+    final dirName = cwd.name;
+    if (dirName == packageName) {
+      return cwd;
+    }
+  }
+  throw StateError("Could not find package root for package '$packageName'. "
+      'Tried finding the package root via Platform.script '
+      "'${Platform.script.toFilePath()}' and Directory.current "
+      "'${Directory.current.uri.toFilePath()}'.");
+}
+
+Uri packageUri = findPackageRoot('native_assets_cli');
+
+extension on Uri {
+  String get name => pathSegments.where((e) => e != '').last;
+}
+
+String unparseKey(String key) => key.replaceAll('.', '__').toUpperCase();
+
+/// Archiver provided by the environment.
+final Uri? ar = Platform
+    .environment[unparseKey(CCompilerConfig.arConfigKeyFull)]
+    ?.asFileUri();
+
+/// Compiler provided by the environment.
+final Uri? cc = Platform
+    .environment[unparseKey(CCompilerConfig.ccConfigKeyFull)]
+    ?.asFileUri();
+
+/// Linker provided by the environment.
+final Uri? ld = Platform
+    .environment[unparseKey(CCompilerConfig.ldConfigKeyFull)]
+    ?.asFileUri();
+
+/// Path to script that sets environment variables for [cc], [ld], and [ar].
+///
+/// Provided by environment.
+final Uri? envScript = Platform
+    .environment[unparseKey(CCompilerConfig.envScriptConfigKeyFull)]
+    ?.asFileUri();
+
+/// Arguments for [envScript] provided by environment.
+final List<String>? envScriptArgs = Platform
+    .environment[unparseKey(CCompilerConfig.envScriptArgsConfigKeyFull)]
+    ?.split(' ');
+
+extension on String {
+  Uri asFileUri() => Uri.file(this);
+}
diff --git a/pkgs/native_assets_cli/test/model/asset_test.dart b/pkgs/native_assets_cli/test/model/asset_test.dart
new file mode 100644
index 0000000..c9f2e9f
--- /dev/null
+++ b/pkgs/native_assets_cli/test/model/asset_test.dart
@@ -0,0 +1,196 @@
+// Copyright (c) 2023, 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:collection/collection.dart';
+import 'package:native_assets_cli/native_assets_cli.dart';
+import 'package:test/test.dart';
+
+void main() {
+  final fooUri = Uri.file('path/to/libfoo.so');
+  final foo2Uri = Uri.file('path/to/libfoo2.so');
+  final foo3Uri = Uri(path: 'libfoo3.so');
+  final barUri = Uri(path: 'path/to/libbar.a');
+  final blaUri = Uri(path: 'path/with spaces/bla.dll');
+  final assets = [
+    Asset(
+      id: 'foo',
+      path: AssetAbsolutePath(fooUri),
+      target: Target.androidX64,
+      linkMode: LinkMode.dynamic,
+    ),
+    Asset(
+      id: 'foo2',
+      path: AssetRelativePath(foo2Uri),
+      target: Target.androidX64,
+      linkMode: LinkMode.dynamic,
+    ),
+    Asset(
+      id: 'foo3',
+      path: AssetSystemPath(foo3Uri),
+      target: Target.androidX64,
+      linkMode: LinkMode.dynamic,
+    ),
+    Asset(
+      id: 'foo4',
+      path: AssetInExecutable(),
+      target: Target.androidX64,
+      linkMode: LinkMode.dynamic,
+    ),
+    Asset(
+      id: 'foo5',
+      path: AssetInProcess(),
+      target: Target.androidX64,
+      linkMode: LinkMode.dynamic,
+    ),
+    Asset(
+      id: 'bar',
+      path: AssetAbsolutePath(barUri),
+      target: Target.linuxArm64,
+      linkMode: LinkMode.static,
+    ),
+    Asset(
+      id: 'bla',
+      path: AssetAbsolutePath(blaUri),
+      target: Target.windowsX64,
+      linkMode: LinkMode.dynamic,
+    ),
+  ];
+
+  final assetsYamlEncoding = '''- id: foo
+  link_mode: dynamic
+  path:
+    path_type: absolute
+    uri: ${fooUri.toFilePath()}
+  target: android_x64
+- id: foo2
+  link_mode: dynamic
+  path:
+    path_type: relative
+    uri: ${foo2Uri.toFilePath()}
+  target: android_x64
+- id: foo3
+  link_mode: dynamic
+  path:
+    path_type: system
+    uri: ${foo3Uri.toFilePath()}
+  target: android_x64
+- id: foo4
+  link_mode: dynamic
+  path:
+    path_type: executable
+  target: android_x64
+- id: foo5
+  link_mode: dynamic
+  path:
+    path_type: process
+  target: android_x64
+- id: bar
+  link_mode: static
+  path:
+    path_type: absolute
+    uri: ${barUri.toFilePath()}
+  target: linux_arm64
+- id: bla
+  link_mode: dynamic
+  path:
+    path_type: absolute
+    uri: ${blaUri.toFilePath()}
+  target: windows_x64''';
+
+  final assetsDartEncoding = '''format-version:
+  - 1
+  - 0
+  - 0
+native-assets:
+  android_x64:
+    foo:
+      - absolute
+      - ${fooUri.toFilePath()}
+    foo2:
+      - relative
+      - ${foo2Uri.toFilePath()}
+    foo3:
+      - system
+      - ${foo3Uri.toFilePath()}
+    foo4:
+      - executable
+    foo5:
+      - process
+  linux_arm64:
+    bar:
+      - absolute
+      - ${barUri.toFilePath()}
+  windows_x64:
+    bla:
+      - absolute
+      - ${blaUri.toFilePath()}''';
+
+  test('asset yaml', () {
+    final yaml = assets.toYamlString();
+    expect(yaml, assetsYamlEncoding);
+    final assets2 = Asset.listFromYamlString(yaml);
+    expect(assets, assets2);
+  });
+
+  test('asset yaml', () async {
+    final fileContents = assets.toNativeAssetsFile();
+    expect(fileContents, assetsDartEncoding);
+  });
+
+  test('AssetPath factory', () async {
+    expect(
+      () => AssetPath('wrong', null),
+      throwsA(predicate(
+        (e) => e is FormatException && e.message.contains('Unknown pathType'),
+      )),
+    );
+  });
+
+  test('Asset hashCode copyWith', () async {
+    final asset = assets.first;
+    final asset2 = asset.copyWith(id: 'foo321');
+    expect(asset.hashCode != asset2.hashCode, true);
+
+    final asset3 = asset.copyWith();
+    expect(asset.hashCode, asset3.hashCode);
+  });
+
+  test('List<Asset> hashCode', () async {
+    final assets2 = assets.take(3).toList();
+    const equality = ListEquality<Asset>();
+    expect(equality.hash(assets) != equality.hash(assets2), true);
+  });
+
+  test('List<Asset> whereLinkMode', () async {
+    final assets2 = assets.whereLinkMode(LinkMode.dynamic);
+    expect(assets2.length, 6);
+  });
+
+  test('Asset toString', () async {
+    assets.toString();
+  });
+
+  test('Asset toString', () async {
+    expect(await assets.allExist(), false);
+  });
+
+  test('Asset toYaml', () async {
+    expect(
+        assets.first.toYamlString(),
+        '''
+id: foo
+link_mode: dynamic
+path:
+  path_type: absolute
+  uri: ${fooUri.toFilePath()}
+target: android_x64
+'''
+            .trim());
+  });
+
+  test('Asset listFromYamlString', () async {
+    final assets = Asset.listFromYamlString('');
+    expect(assets, <Asset>[]);
+  });
+}
diff --git a/pkgs/native_assets_cli/test/model/build_config_test.dart b/pkgs/native_assets_cli/test/model/build_config_test.dart
new file mode 100644
index 0000000..9566913
--- /dev/null
+++ b/pkgs/native_assets_cli/test/model/build_config_test.dart
@@ -0,0 +1,593 @@
+// Copyright (c) 2023, 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';
+
+import 'package:cli_config/cli_config.dart';
+import 'package:native_assets_cli/native_assets_cli.dart';
+import 'package:test/test.dart';
+
+import '../helpers.dart';
+
+void main() async {
+  late Uri tempUri;
+  late Uri outDirUri;
+  late Uri outDir2Uri;
+  late String packageName;
+  late Uri packageRootUri;
+  late Uri fakeClang;
+  late Uri fakeLd;
+  late Uri fakeAr;
+  late Uri fakeCl;
+  late Uri fakeVcVars;
+
+  setUp(() async {
+    tempUri = (await Directory.systemTemp.createTemp()).uri;
+    outDirUri = tempUri.resolve('out1/');
+    await Directory.fromUri(outDirUri).create();
+    outDir2Uri = tempUri.resolve('out2/');
+    packageName = 'my_package';
+    await Directory.fromUri(outDir2Uri).create();
+    packageRootUri = tempUri.resolve('$packageName/');
+    await Directory.fromUri(packageRootUri).create();
+    fakeClang = tempUri.resolve('fake_clang');
+    await File.fromUri(fakeClang).create();
+    fakeLd = tempUri.resolve('fake_ld');
+    await File.fromUri(fakeLd).create();
+    fakeAr = tempUri.resolve('fake_ar');
+    await File.fromUri(fakeAr).create();
+    fakeCl = tempUri.resolve('cl.exe');
+    await File.fromUri(fakeCl).create();
+    fakeVcVars = tempUri.resolve('vcvarsall.bat');
+    await File.fromUri(fakeVcVars).create();
+  });
+
+  tearDown(() async {
+    await Directory.fromUri(tempUri).delete(recursive: true);
+  });
+
+  test('BuildConfig ==', () {
+    final config1 = BuildConfig(
+      outDir: outDirUri,
+      packageName: packageName,
+      packageRoot: tempUri,
+      targetArchitecture: Architecture.arm64,
+      targetOs: OS.iOS,
+      targetIOSSdk: IOSSdk.iPhoneOs,
+      cCompiler: CCompilerConfig(
+        cc: fakeClang,
+        ld: fakeLd,
+        ar: fakeAr,
+      ),
+      buildMode: BuildMode.release,
+      linkModePreference: LinkModePreference.preferStatic,
+    );
+
+    final config2 = BuildConfig(
+      outDir: outDir2Uri,
+      packageName: packageName,
+      packageRoot: tempUri,
+      targetArchitecture: Architecture.arm64,
+      targetOs: OS.android,
+      targetAndroidNdkApi: 30,
+      buildMode: BuildMode.release,
+      linkModePreference: LinkModePreference.preferStatic,
+    );
+
+    expect(config1, equals(config1));
+    expect(config1 == config2, false);
+    expect(config1.outDir != config2.outDir, true);
+    expect(config1.packageRoot, config2.packageRoot);
+    expect(config1.targetArchitecture == config2.targetArchitecture, true);
+    expect(config1.targetOs != config2.targetOs, true);
+    expect(config1.targetIOSSdk != config2.targetIOSSdk, true);
+    expect(config1.cCompiler.cc != config2.cCompiler.cc, true);
+    expect(config1.cCompiler.ld != config2.cCompiler.ld, true);
+    expect(config1.cCompiler.ar != config2.cCompiler.ar, true);
+    expect(config1.cCompiler.envScript == config2.cCompiler.envScript, true);
+    expect(config1.cCompiler.envScriptArgs == config2.cCompiler.envScriptArgs,
+        true);
+    expect(config1.cCompiler != config2.cCompiler, true);
+    expect(config1.linkModePreference, config2.linkModePreference);
+    expect(config1.dependencyMetadata, config2.dependencyMetadata);
+  });
+
+  test('BuildConfig fromConfig', () {
+    final buildConfig2 = BuildConfig(
+      outDir: outDirUri,
+      packageName: packageName,
+      packageRoot: packageRootUri,
+      targetArchitecture: Architecture.arm64,
+      targetOs: OS.android,
+      targetAndroidNdkApi: 30,
+      buildMode: BuildMode.release,
+      linkModePreference: LinkModePreference.preferStatic,
+    );
+
+    final config = Config(fileParsed: {
+      'build_mode': 'release',
+      'dry_run': false,
+      'link_mode_preference': 'prefer-static',
+      'out_dir': outDirUri.toFilePath(),
+      'package_name': packageName,
+      'package_root': packageRootUri.toFilePath(),
+      'target_android_ndk_api': 30,
+      'target_architecture': 'arm64',
+      'target_os': 'android',
+      'version': BuildOutput.version.toString(),
+    });
+
+    final fromConfig = BuildConfig.fromConfig(config);
+    expect(fromConfig, equals(buildConfig2));
+  });
+
+  test('BuildConfig.dryRun', () {
+    final buildConfig2 = BuildConfig.dryRun(
+      outDir: outDirUri,
+      packageName: packageName,
+      packageRoot: packageRootUri,
+      targetOs: OS.android,
+      linkModePreference: LinkModePreference.preferStatic,
+    );
+
+    final config = Config(fileParsed: {
+      'dry_run': true,
+      'link_mode_preference': 'prefer-static',
+      'out_dir': outDirUri.toFilePath(),
+      'package_name': packageName,
+      'package_root': packageRootUri.toFilePath(),
+      'target_os': 'android',
+      'version': BuildOutput.version.toString(),
+    });
+
+    final fromConfig = BuildConfig.fromConfig(config);
+    expect(fromConfig, equals(buildConfig2));
+  });
+
+  test('BuildConfig toYaml fromConfig', () {
+    final buildConfig1 = BuildConfig(
+      outDir: outDirUri,
+      packageName: packageName,
+      packageRoot: packageRootUri,
+      targetArchitecture: Architecture.arm64,
+      targetOs: OS.iOS,
+      targetIOSSdk: IOSSdk.iPhoneOs,
+      cCompiler: CCompilerConfig(
+        cc: fakeClang,
+        ld: fakeLd,
+      ),
+      buildMode: BuildMode.release,
+      linkModePreference: LinkModePreference.preferStatic,
+    );
+
+    final configFile = buildConfig1.toYaml();
+    final config = Config(fileParsed: configFile);
+    final fromConfig = BuildConfig.fromConfig(config);
+    expect(fromConfig, equals(buildConfig1));
+  });
+
+  test('BuildConfig == dependency metadata', () {
+    final buildConfig1 = BuildConfig(
+      outDir: outDirUri,
+      packageName: packageName,
+      packageRoot: tempUri,
+      targetArchitecture: Architecture.arm64,
+      targetOs: OS.android,
+      targetAndroidNdkApi: 30,
+      buildMode: BuildMode.release,
+      linkModePreference: LinkModePreference.preferStatic,
+      dependencyMetadata: {
+        'bar': const Metadata({
+          'key': 'value',
+          'foo': ['asdf', 'fdsa'],
+        }),
+        'foo': const Metadata({
+          'key': 321,
+        }),
+      },
+    );
+
+    final buildConfig2 = BuildConfig(
+      outDir: outDirUri,
+      packageName: packageName,
+      packageRoot: tempUri,
+      targetArchitecture: Architecture.arm64,
+      targetOs: OS.android,
+      targetAndroidNdkApi: 30,
+      buildMode: BuildMode.release,
+      linkModePreference: LinkModePreference.preferStatic,
+      dependencyMetadata: {
+        'bar': const Metadata({
+          'key': 'value',
+        }),
+        'foo': const Metadata({
+          'key': 123,
+        }),
+      },
+    );
+
+    expect(buildConfig1, equals(buildConfig1));
+    expect(buildConfig1 == buildConfig2, false);
+    expect(buildConfig1.hashCode == buildConfig2.hashCode, false);
+  });
+
+  test('BuildConfig toYaml fromYaml', () {
+    final outDir = outDirUri;
+    final buildConfig1 = BuildConfig(
+      outDir: outDir,
+      packageName: packageName,
+      packageRoot: tempUri,
+      targetArchitecture: Architecture.arm64,
+      targetOs: OS.iOS,
+      targetIOSSdk: IOSSdk.iPhoneOs,
+      cCompiler: CCompilerConfig(
+        cc: fakeClang,
+        ld: fakeLd,
+      ),
+      buildMode: BuildMode.release,
+      linkModePreference: LinkModePreference.preferStatic,
+      // This map should be sorted on key for two layers.
+      dependencyMetadata: {
+        'foo': const Metadata({
+          'z': ['z', 'a'],
+          'a': 321,
+        }),
+        'bar': const Metadata({
+          'key': 'value',
+        }),
+      },
+    );
+    final yamlString = buildConfig1.toYamlString();
+    final expectedYamlString = '''build_mode: release
+c_compiler:
+  cc: ${fakeClang.toFilePath()}
+  ld: ${fakeLd.toFilePath()}
+dependency_metadata:
+  bar:
+    key: value
+  foo:
+    a: 321
+    z:
+      - z
+      - a
+link_mode_preference: prefer-static
+out_dir: ${outDir.toFilePath()}
+package_name: $packageName
+package_root: ${tempUri.toFilePath()}
+target_architecture: arm64
+target_ios_sdk: iphoneos
+target_os: ios
+version: ${BuildConfig.version}''';
+    expect(yamlString, equals(expectedYamlString));
+
+    final buildConfig2 = BuildConfig.fromConfig(
+      Config.fromConfigFileContents(
+        fileContents: yamlString,
+      ),
+    );
+    expect(buildConfig2, buildConfig1);
+  });
+
+  test('BuildConfig FormatExceptions', () {
+    expect(
+      () => BuildConfig.fromConfig(Config(fileParsed: {})),
+      throwsA(predicate(
+        (e) =>
+            e is FormatException &&
+            e.message.contains(
+              'No value was provided for required key: build_mode',
+            ),
+      )),
+    );
+    expect(
+      () => BuildConfig.fromConfig(Config(fileParsed: {
+        'version': BuildConfig.version.toString(),
+        'package_name': packageName,
+        'package_root': packageRootUri.toFilePath(),
+        'target_architecture': 'arm64',
+        'target_os': 'android',
+        'target_android_ndk_api': 30,
+        'link_mode_preference': 'prefer-static',
+      })),
+      throwsA(predicate(
+        (e) =>
+            e is FormatException &&
+            e.message.contains(
+              'No value was provided for required key: out_dir',
+            ),
+      )),
+    );
+    expect(
+      () => BuildConfig.fromConfig(Config(fileParsed: {
+        'version': BuildConfig.version.toString(),
+        'out_dir': outDirUri.toFilePath(),
+        'package_name': packageName,
+        'package_root': packageRootUri.toFilePath(),
+        'target_architecture': 'arm64',
+        'target_os': 'android',
+        'target_android_ndk_api': 30,
+        'link_mode_preference': 'prefer-static',
+        'dependency_metadata': {
+          'bar': {'key': 'value'},
+          'foo': <int>[],
+        },
+      })),
+      throwsA(predicate(
+        (e) =>
+            e is FormatException &&
+            e.message.contains(
+              "Unexpected value '[]' for key 'dependency_metadata.foo' in "
+              'config file. Expected a Map.',
+            ),
+      )),
+    );
+    expect(
+      () => BuildConfig.fromConfig(Config(fileParsed: {
+        'out_dir': outDirUri.toFilePath(),
+        'version': BuildConfig.version.toString(),
+        'package_name': packageName,
+        'package_root': packageRootUri.toFilePath(),
+        'target_architecture': 'arm64',
+        'target_os': 'android',
+        'link_mode_preference': 'prefer-static',
+      })),
+      throwsA(predicate(
+        (e) =>
+            e is FormatException &&
+            e.message.contains(
+              'No value was provided for required key: target_android_ndk_api',
+            ),
+      )),
+    );
+  });
+
+  test('FormatExceptions contain full stack trace of wrapped exception', () {
+    try {
+      BuildConfig.fromConfig(Config(fileParsed: {
+        'out_dir': outDirUri.toFilePath(),
+        'package_root': packageRootUri.toFilePath(),
+        'target': [1, 2, 3, 4, 5],
+        'link_mode_preference': 'prefer-static',
+      }));
+    } on FormatException catch (e) {
+      expect(e.toString(), stringContainsInOrder(['Config.string']));
+    }
+  });
+
+  test('BuildConfig toString', () {
+    final config = BuildConfig(
+      outDir: outDirUri,
+      packageName: packageName,
+      packageRoot: tempUri,
+      targetArchitecture: Architecture.arm64,
+      targetOs: OS.iOS,
+      targetIOSSdk: IOSSdk.iPhoneOs,
+      cCompiler: CCompilerConfig(
+        cc: fakeClang,
+        ld: fakeLd,
+      ),
+      buildMode: BuildMode.release,
+      linkModePreference: LinkModePreference.preferStatic,
+    );
+    config.toString();
+  });
+
+  test('BuildConfig fromArgs', () async {
+    final buildConfig = BuildConfig(
+      outDir: outDirUri,
+      packageName: packageName,
+      packageRoot: tempUri,
+      targetArchitecture: Architecture.arm64,
+      targetOs: OS.android,
+      targetAndroidNdkApi: 30,
+      buildMode: BuildMode.release,
+      linkModePreference: LinkModePreference.preferStatic,
+    );
+    final configFileContents = buildConfig.toYamlString();
+    final configUri = tempUri.resolve('config.yaml');
+    final configFile = File.fromUri(configUri);
+    await configFile.writeAsString(configFileContents);
+    final buildConfig2 = await BuildConfig.fromArgs(
+      ['--config', configUri.toFilePath()],
+      environment: {}, // Don't inherit the test environment.
+    );
+    expect(buildConfig2, buildConfig);
+  });
+
+  test('dependency metadata via config accessor', () {
+    final buildConfig1 = BuildConfig(
+      outDir: outDirUri,
+      packageName: packageName,
+      packageRoot: tempUri,
+      targetArchitecture: Architecture.arm64,
+      targetOs: OS.android,
+      targetAndroidNdkApi: 30,
+      buildMode: BuildMode.release,
+      linkModePreference: LinkModePreference.preferStatic,
+      dependencyMetadata: {
+        'bar': const Metadata({
+          'key': {'key2': 'value'},
+        }),
+      },
+    );
+    // Useful for doing `path(..., exists: true)`.
+    expect(
+      buildConfig1.config.string([
+        BuildConfig.dependencyMetadataConfigKey,
+        'bar',
+        'key',
+        'key2'
+      ].join('.')),
+      'value',
+    );
+  });
+
+  test('envScript', () {
+    final buildConfig1 = BuildConfig(
+      outDir: outDirUri,
+      packageName: packageName,
+      packageRoot: packageRootUri,
+      targetArchitecture: Architecture.x64,
+      targetOs: OS.windows,
+      cCompiler: CCompilerConfig(
+        cc: fakeCl,
+        envScript: fakeVcVars,
+        envScriptArgs: ['x64'],
+      ),
+      buildMode: BuildMode.release,
+      linkModePreference: LinkModePreference.dynamic,
+    );
+
+    final configFile = buildConfig1.toYaml();
+    final config = Config(fileParsed: configFile);
+    final fromConfig = BuildConfig.fromConfig(config);
+    expect(fromConfig, equals(buildConfig1));
+  });
+
+  for (final version in ['9001.0.0', '0.0.1']) {
+    test('BuildConfig version $version', () {
+      final outDir = outDirUri;
+      final config = Config(fileParsed: {
+        'link_mode_preference': 'prefer-static',
+        'out_dir': outDir.toFilePath(),
+        'package_root': tempUri.toFilePath(),
+        'target_os': 'linux',
+        'target_architecture': 'x64',
+        'version': version,
+      });
+      expect(
+        () => BuildConfig.fromConfig(config),
+        throwsA(predicate(
+          (e) =>
+              e is FormatException &&
+              e.message.contains(version) &&
+              e.message.contains(BuildConfig.version.toString()),
+        )),
+      );
+    });
+  }
+
+  test('checksum', () async {
+    await inTempDir((tempUri) async {
+      final nativeAddUri = tempUri.resolve('native_add/');
+      final fakeClangUri = tempUri.resolve('fake_clang');
+      await File.fromUri(fakeClangUri).create();
+
+      final name1 = BuildConfig.checksum(
+        packageName: packageName,
+        packageRoot: nativeAddUri,
+        targetArchitecture: Architecture.x64,
+        targetOs: OS.linux,
+        buildMode: BuildMode.release,
+        linkModePreference: LinkModePreference.dynamic,
+      );
+
+      // Using the checksum for a build folder should be stable.
+      expect(name1, '037109b9824b2559502fa7bd42e1b6f8');
+
+      // Build folder different due to metadata.
+      final name2 = BuildConfig.checksum(
+        packageName: packageName,
+        packageRoot: nativeAddUri,
+        targetArchitecture: Architecture.x64,
+        targetOs: OS.linux,
+        buildMode: BuildMode.release,
+        linkModePreference: LinkModePreference.dynamic,
+        dependencyMetadata: {
+          'foo': const Metadata({'key': 'value'})
+        },
+      );
+      printOnFailure([name1, name2].toString());
+      expect(name1 != name2, true);
+
+      // Build folder different due to cc.
+      final name3 = BuildConfig.checksum(
+          packageName: packageName,
+          packageRoot: nativeAddUri,
+          targetArchitecture: Architecture.x64,
+          targetOs: OS.linux,
+          buildMode: BuildMode.release,
+          linkModePreference: LinkModePreference.dynamic,
+          cCompiler: CCompilerConfig(
+            cc: fakeClangUri,
+          ));
+      printOnFailure([name1, name3].toString());
+      expect(name1 != name3, true);
+    });
+  });
+
+  test('BuildConfig invalid target os architecture combination', () {
+    final outDir = outDirUri;
+    final config = Config(fileParsed: {
+      'link_mode_preference': 'prefer-static',
+      'out_dir': outDir.toFilePath(),
+      'package_name': packageName,
+      'package_root': tempUri.toFilePath(),
+      'target_os': 'windows',
+      'target_architecture': 'arm',
+      'build_mode': 'debug',
+      'version': BuildConfig.version.toString(),
+    });
+    expect(
+      () => BuildConfig.fromConfig(config),
+      throwsA(predicate(
+        (e) => e is FormatException && e.message.contains('arm'),
+      )),
+    );
+  });
+
+  test('BuildConfig dry_run access invalid args', () {
+    final outDir = outDirUri;
+    final config = Config(fileParsed: {
+      'link_mode_preference': 'prefer-static',
+      'out_dir': outDir.toFilePath(),
+      'package_name': packageName,
+      'package_root': tempUri.toFilePath(),
+      'target_os': 'windows',
+      'target_architecture': 'arm64',
+      'build_mode': 'debug',
+      'dry_run': true,
+      'version': BuildConfig.version.toString(),
+    });
+    expect(
+      () => BuildConfig.fromConfig(config),
+      throwsA(predicate(
+        (e) =>
+            e is FormatException && e.message.contains('In Flutter projects'),
+      )),
+    );
+  });
+
+  test('BuildConfig dry_run access invalid args', () {
+    final outDir = outDirUri;
+    final config = Config(fileParsed: {
+      'link_mode_preference': 'prefer-static',
+      'out_dir': outDir.toFilePath(),
+      'package_name': packageName,
+      'package_root': tempUri.toFilePath(),
+      'target_os': 'windows',
+      'dry_run': true,
+      'version': BuildConfig.version.toString(),
+    });
+    final buildConfig = BuildConfig.fromConfig(config);
+    expect(
+      () => buildConfig.targetArchitecture,
+      throwsA(predicate(
+        (e) => e is StateError && e.message.contains('In Flutter projects'),
+      )),
+    );
+  });
+
+  test('BuildConfig dry_run access invalid args', () {
+    final buildConfig = BuildConfig.dryRun(
+      packageName: packageName,
+      outDir: outDirUri,
+      packageRoot: tempUri,
+      targetOs: OS.windows,
+      linkModePreference: LinkModePreference.dynamic,
+    );
+    buildConfig.toYamlString();
+    // No crash.
+  });
+}
diff --git a/pkgs/native_assets_cli/test/model/build_output_test.dart b/pkgs/native_assets_cli/test/model/build_output_test.dart
new file mode 100644
index 0000000..0dc0bad
--- /dev/null
+++ b/pkgs/native_assets_cli/test/model/build_output_test.dart
@@ -0,0 +1,173 @@
+// Copyright (c) 2023, 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';
+
+import 'package:native_assets_cli/native_assets_cli.dart';
+import 'package:test/test.dart';
+
+void main() {
+  late Uri tempUri;
+
+  setUp(() async {
+    tempUri = (await Directory.systemTemp.createTemp()).uri;
+  });
+
+  tearDown(() async {
+    await Directory.fromUri(tempUri).delete(recursive: true);
+  });
+
+  final buildOutput = BuildOutput(
+    timestamp: DateTime.parse('2022-11-10 13:25:01.000'),
+    assets: [
+      Asset(
+        id: 'foo',
+        path: AssetAbsolutePath(Uri(path: 'path/to/libfoo.so')),
+        target: Target.androidX64,
+        linkMode: LinkMode.dynamic,
+      ),
+      Asset(
+        id: 'foo2',
+        path: AssetRelativePath(Uri(path: 'path/to/libfoo2.so')),
+        target: Target.androidX64,
+        linkMode: LinkMode.dynamic,
+      ),
+    ],
+    dependencies: Dependencies([
+      Uri.file('path/to/file.ext'),
+    ]),
+    metadata: const Metadata({
+      'key': 'value',
+    }),
+  );
+
+  final yamlEncoding = '''timestamp: 2022-11-10 13:25:01.000
+assets:
+  - id: foo
+    link_mode: dynamic
+    path:
+      path_type: absolute
+      uri: path/to/libfoo.so
+    target: android_x64
+  - id: foo2
+    link_mode: dynamic
+    path:
+      path_type: relative
+      uri: path/to/libfoo2.so
+    target: android_x64
+dependencies:
+  - path/to/file.ext
+metadata:
+  key: value
+version: ${BuildOutput.version}''';
+
+  test('built info yaml', () {
+    final yaml = buildOutput.toYamlString().replaceAll('\\', '/');
+    expect(yaml, yamlEncoding);
+    final buildOutput2 = BuildOutput.fromYamlString(yaml);
+    expect(buildOutput.hashCode, buildOutput2.hashCode);
+    expect(buildOutput, buildOutput2);
+  });
+
+  test('BuildOutput.toString', buildOutput.toString);
+
+  test('BuildOutput.hashCode', () {
+    final buildOutput2 = BuildOutput.fromYamlString(yamlEncoding);
+    expect(buildOutput.hashCode, buildOutput2.hashCode);
+
+    final buildOutput3 = BuildOutput(
+      timestamp: DateTime.parse('2022-11-10 13:25:01.000'),
+    );
+    expect(buildOutput.hashCode != buildOutput3.hashCode, true);
+  });
+
+  test('BuildOutput.readFromFile BuildOutput.writeToFile', () async {
+    final outDir = tempUri.resolve('out_dir/');
+    await buildOutput.writeToFile(outDir: outDir);
+    final buildOutput2 = await BuildOutput.readFromFile(outDir: outDir);
+    expect(buildOutput2, buildOutput);
+  });
+
+  test('Round timestamp', () {
+    final buildOutput3 = BuildOutput(
+      timestamp: DateTime.parse('2022-11-10 13:25:01.372257'),
+    );
+    expect(buildOutput3.timestamp, DateTime.parse('2022-11-10 13:25:01.000'));
+  });
+
+  for (final version in ['9001.0.0', '0.0.1']) {
+    test('BuildOutput version $version', () {
+      expect(
+        () => BuildOutput.fromYamlString('version: $version'),
+        throwsA(predicate(
+          (e) =>
+              e is FormatException &&
+              e.message.contains(version) &&
+              e.message.contains(BuildConfig.version.toString()),
+        )),
+      );
+    });
+  }
+
+  test('format exception', () {
+    expect(
+      () => BuildOutput.fromYamlString('''timestamp: 2022-11-10 13:25:01.000
+assets:
+  - name: foo
+    link_mode: dynamic
+    path:
+      path_type:
+        some: map
+      uri: path/to/libfoo.so
+    target: android_x64
+dependencies: []
+metadata:
+  key: value
+version: ${BuildOutput.version}'''),
+      throwsFormatException,
+    );
+    expect(
+      () => BuildOutput.fromYamlString('''timestamp: 2022-11-10 13:25:01.000
+assets:
+  - name: foo
+    link_mode: dynamic
+    path:
+      path_type: absolute
+      uri: path/to/libfoo.so
+    target: android_x64
+dependencies:
+  1: foo
+metadata:
+  key: value
+version: ${BuildOutput.version}'''),
+      throwsFormatException,
+    );
+    expect(
+      () => BuildOutput.fromYamlString('''timestamp: 2022-11-10 13:25:01.000
+assets:
+  - name: foo
+    link_mode: dynamic
+    path:
+      path_type: absolute
+      uri: path/to/libfoo.so
+    target: android_x64
+dependencies: []
+metadata:
+  123: value
+version: ${BuildOutput.version}'''),
+      throwsFormatException,
+    );
+  });
+
+  test('BuildOutput dependencies can be modified', () {
+    // TODO(https://github.com/dart-lang/native/issues/25):
+    // Remove once dependencies are made immutable.
+    final buildOutput = BuildOutput();
+    expect(
+      () => buildOutput.dependencies.dependencies
+          .add(Uri.file('path/to/file.ext')),
+      returnsNormally,
+    );
+  });
+}
diff --git a/pkgs/native_assets_cli/test/model/dependencies_test.dart b/pkgs/native_assets_cli/test/model/dependencies_test.dart
new file mode 100644
index 0000000..a4f05c5
--- /dev/null
+++ b/pkgs/native_assets_cli/test/model/dependencies_test.dart
@@ -0,0 +1,92 @@
+// Copyright (c) 2023, 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';
+
+import 'package:native_assets_cli/native_assets_cli.dart';
+import 'package:test/test.dart';
+
+void main() {
+  late Uri tempUri;
+
+  setUp(() async => tempUri = Directory(
+          await (await Directory.systemTemp.createTemp())
+              .resolveSymbolicLinks())
+      .uri);
+
+  tearDown(
+      () async => await Directory.fromUri(tempUri).delete(recursive: true));
+
+  final dependencies = Dependencies([
+    Uri.file('src/bar.c'),
+    Uri.file('src/baz.c'),
+    Uri.directory('src/bla/'),
+    Uri.file('build.dart'),
+  ]);
+
+  const yamlEncoding = '''- src/bar.c
+- src/baz.c
+- src/bla/
+- build.dart''';
+
+  test('dependencies yaml', () {
+    final yaml = dependencies.toYamlString().replaceAll('\\', '/');
+    expect(yaml, yamlEncoding);
+    final dependencies2 = Dependencies.fromYamlString(yaml);
+    expect(dependencies.hashCode, dependencies2.hashCode);
+    expect(dependencies, dependencies2);
+  });
+
+  test('dependencies toString', dependencies.toString);
+
+  test('dependencies fromYamlString', () {
+    final dependencies = Dependencies.fromYamlString('');
+    expect(dependencies, const Dependencies([]));
+  });
+
+  test('dependencies lastModified', () async {
+    final dirUri = tempUri.resolve('foo/');
+    final dir = Directory.fromUri(dirUri);
+    await dir.create();
+    final fileUri = tempUri.resolve('bla.c');
+    final file = File.fromUri(fileUri);
+    await file.writeAsString('dummy contents');
+    final dependencies = Dependencies([dirUri, fileUri]);
+    expect(await dependencies.lastModified(), await file.lastModified());
+  });
+
+  test('dependencies lastModified symlinks', () async {
+    if (Platform.isWindows) {
+      // Requires extra privilege, skip.
+      // "A required privilege is not held by the client"
+      return;
+    }
+    final symlink = Link.fromUri(tempUri.resolve('my_link'));
+    await symlink.create(tempUri.toFilePath());
+
+    final someFileUri = tempUri.resolve('foo.txt');
+    final someFile = File.fromUri(someFileUri);
+    await someFile.writeAsString('yay!');
+
+    final dependencies = Dependencies([tempUri]);
+    expect(await dependencies.lastModified(), await someFile.lastModified());
+  });
+
+  test('dependencies lastModified does not exist', () async {
+    final someFileUri = tempUri.resolve('foo.txt');
+    final someFile = File.fromUri(someFileUri);
+    await someFile.writeAsString('yay!');
+
+    final deletedFileUri = tempUri.resolve('bar.txt');
+
+    final now = DateTime.now();
+
+    final dependencies = Dependencies([
+      someFileUri,
+      deletedFileUri,
+    ]);
+    final depsLastModified = await dependencies.lastModified();
+    expect(depsLastModified == now || depsLastModified.isAfter(now), true);
+  });
+}
diff --git a/pkgs/native_assets_cli/test/model/link_mode_test.dart b/pkgs/native_assets_cli/test/model/link_mode_test.dart
new file mode 100644
index 0000000..ba86d44
--- /dev/null
+++ b/pkgs/native_assets_cli/test/model/link_mode_test.dart
@@ -0,0 +1,12 @@
+// Copyright (c) 2023, 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:native_assets_cli/native_assets_cli.dart';
+import 'package:test/test.dart';
+
+void main() {
+  test('LinkMode toString', () async {
+    LinkMode.static.toString();
+  });
+}
diff --git a/pkgs/native_assets_cli/test/model/metadata_test.dart b/pkgs/native_assets_cli/test/model/metadata_test.dart
new file mode 100644
index 0000000..fd80b68
--- /dev/null
+++ b/pkgs/native_assets_cli/test/model/metadata_test.dart
@@ -0,0 +1,25 @@
+// Copyright (c) 2023, 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:native_assets_cli/native_assets_cli.dart';
+import 'package:test/test.dart';
+
+void main() {
+  const metadata = Metadata({
+    'key': 'value',
+    'my_list': [1, 2, 3],
+    'my_map': {
+      3: 4,
+      'foo': 'bar',
+    },
+  });
+
+  test('Metadata toString', metadata.toString);
+
+  test('Metadata toYamlString fromYamlString', () {
+    final yamlString = metadata.toYamlString();
+    final metadata2 = Metadata.fromYamlString(yamlString);
+    expect(metadata2, metadata);
+  });
+}
diff --git a/pkgs/native_assets_cli/test/model/target_test.dart b/pkgs/native_assets_cli/test/model/target_test.dart
new file mode 100644
index 0000000..3b1ce7b
--- /dev/null
+++ b/pkgs/native_assets_cli/test/model/target_test.dart
@@ -0,0 +1,75 @@
+// Copyright (c) 2023, 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:ffi';
+import 'dart:io';
+
+import 'package:native_assets_cli/native_assets_cli.dart';
+import 'package:test/test.dart';
+
+void main() {
+  test('OS naming conventions', () async {
+    expect(OS.android.dylibFileName('foo'), 'libfoo.so');
+    expect(OS.android.staticlibFileName('foo'), 'libfoo.a');
+    expect(OS.windows.dylibFileName('foo'), 'foo.dll');
+    expect(OS.windows.libraryFileName('foo', LinkMode.dynamic), 'foo.dll');
+    expect(OS.windows.staticlibFileName('foo'), 'foo.lib');
+    expect(OS.windows.libraryFileName('foo', LinkMode.static), 'foo.lib');
+    expect(OS.windows.executableFileName('foo'), 'foo.exe');
+  });
+
+  test('Target current', () async {
+    final current = Target.current;
+    expect(current.toString(), Abi.current().toString());
+  });
+
+  test('Target fromDartPlatform', () async {
+    final current = Target.fromDartPlatform(Platform.version);
+    expect(current.toString(), Abi.current().toString());
+    expect(
+      () => Target.fromDartPlatform('bogus'),
+      throwsA(predicate(
+        (e) =>
+            e is FormatException &&
+            e.message.contains('bogus') &&
+            e.message.contains('Unknown version'),
+      )),
+    );
+    expect(
+      () => Target.fromDartPlatform(
+        '3.0.0 (be) (Wed Apr 5 14:19:42 2023 +0000) on "myfancyos_ia32"',
+      ),
+      throwsA(predicate(
+        (e) =>
+            e is FormatException &&
+            e.message.contains('myfancyos_ia32') &&
+            e.message.contains('Unknown ABI'),
+      )),
+    );
+  });
+
+  test('Target cross compilation', () async {
+    // All hosts can cross compile to Android.
+    expect(
+        Target.current.supportedTargetTargets(), contains(Target.androidArm64));
+    expect(
+        Target.macOSArm64.supportedTargetTargets(), contains(Target.iOSArm64));
+  });
+
+  test('Target fromArchitectureAndOs', () async {
+    final current =
+        Target.fromArchitectureAndOs(Architecture.current, OS.current);
+    expect(current.toString(), Abi.current().toString());
+
+    expect(
+      () => Target.fromArchitectureAndOs(Architecture.arm, OS.windows),
+      throwsA(predicate(
+        (e) =>
+            e is ArgumentError &&
+            (e.message as String).contains('arm') &&
+            (e.message as String).contains('windows'),
+      )),
+    );
+  });
+}
diff --git a/pkgs/native_toolchain_c/.gitignore b/pkgs/native_toolchain_c/.gitignore
new file mode 100644
index 0000000..58e48f3
--- /dev/null
+++ b/pkgs/native_toolchain_c/.gitignore
@@ -0,0 +1,9 @@
+# https://dart.dev/guides/libraries/private-files
+# Created by `dart pub`
+.dart_tool/
+
+# Avoid committing pubspec.lock for library packages; see
+# https://dart.dev/guides/libraries/private-files#pubspeclock.
+pubspec.lock
+
+coverage/
diff --git a/pkgs/native_toolchain_c/CHANGELOG.md b/pkgs/native_toolchain_c/CHANGELOG.md
new file mode 100644
index 0000000..c347917
--- /dev/null
+++ b/pkgs/native_toolchain_c/CHANGELOG.md
@@ -0,0 +1,52 @@
+## 0.3.2
+
+- Added workaround minSdkVersion 19 and 20 for Android.
+- Start using sysroot for Android.
+- Added tests for up to Android API version 34.
+
+## 0.3.1
+
+- Added MSVC arm64 toolchain.
+
+## 0.3.0
+
+- Bump `package:native_assets_cli` to 0.3.0.
+
+## 0.2.5
+
+- Explicitly tell linker to create position dependent or position independent executable
+  ([#113](https://github.com/dart-lang/native/issues/133)).
+
+## 0.2.4
+
+- Added `includes` for specifying include directories.
+- Added `flags` for specifying arbitrary compiler flags.
+- Added `std` for specifying a language standard.
+- Added `language` for selecting the language (`c` and `cpp`) to compile source files as.
+- Added `cppLinkStdLib` for specifying the C++ standard library to link against.
+
+## 0.2.3
+
+- Fix MSVC tool resolution inside (x86) folder
+  ([#123](https://github.com/dart-lang/native/issues/123)).
+
+## 0.2.2
+
+- Generate position independent code for libraries by default and add
+  `pic` option to control this behavior.
+
+## 0.2.1
+
+- Added `defines` for specifying custom defines.
+- Added `buildModeDefine` to toggle define for current build mode.
+- Added `ndebugDefine` to toggle define of `NDEBUG` for non-debug builds.
+
+## 0.2.0
+
+- **Breaking change** Rename `assetName` to `assetId`
+  ([#100](https://github.com/dart-lang/native/issues/100)).
+- Added topics.
+
+## 0.1.0
+
+- Initial version.
diff --git a/pkgs/native_toolchain_c/LICENSE b/pkgs/native_toolchain_c/LICENSE
new file mode 100644
index 0000000..4fd5739
--- /dev/null
+++ b/pkgs/native_toolchain_c/LICENSE
@@ -0,0 +1,27 @@
+Copyright 2023, 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.
diff --git a/pkgs/native_toolchain_c/README.md b/pkgs/native_toolchain_c/README.md
new file mode 100644
index 0000000..01b1448
--- /dev/null
+++ b/pkgs/native_toolchain_c/README.md
@@ -0,0 +1,26 @@
+[![dart](https://github.com/dart-lang/native/actions/workflows/native.yaml/badge.svg)](https://github.com/dart-lang/native/actions/workflows/native.yaml)
+[![Coverage Status](https://coveralls.io/repos/github/dart-lang/native/badge.svg?branch=main)](https://coveralls.io/github/dart-lang/native?branch=main)
+[![pub package](https://img.shields.io/pub/v/native_toolchain_c.svg)](https://pub.dev/packages/native_toolchain_c)
+[![package publisher](https://img.shields.io/pub/publisher/native_toolchain_c.svg)](https://pub.dev/packages/native_toolchain_c/publisher)
+
+A library to invoke the native C compiler installed on the host machine.
+
+## Status: Experimental
+
+**NOTE**: This package is currently experimental and published under the
+[labs.dart.dev](https://dart.dev/dart-team-packages) pub publisher in order to
+solicit feedback. 
+
+For packages in the labs.dart.dev publisher we generally plan to either graduate
+the package into a supported publisher (dart.dev, tools.dart.dev) after a period
+of feedback and iteration, or discontinue the package. These packages have a
+much higher expected rate of API and breaking changes.
+
+Your feedback is valuable and will help us evolve this package. 
+For bugs, please file an issue in the 
+[bug tracker](https://github.com/dart-lang/native/issues).
+
+
+## Example
+
+An example can be found in [../native_assets_cli/example/](../native_assets_cli/example/).
diff --git a/pkgs/native_toolchain_c/analysis_options.yaml b/pkgs/native_toolchain_c/analysis_options.yaml
new file mode 100644
index 0000000..dd3dcda
--- /dev/null
+++ b/pkgs/native_toolchain_c/analysis_options.yaml
@@ -0,0 +1,12 @@
+include: package:dart_flutter_team_lints/analysis_options.yaml
+
+analyzer:
+  language:
+    strict-raw-types: true
+
+linter:
+  rules:
+    - prefer_const_declarations
+    - prefer_expression_function_bodies
+    - prefer_final_in_for_each
+    - prefer_final_locals
diff --git a/pkgs/native_toolchain_c/lib/native_toolchain_c.dart b/pkgs/native_toolchain_c/lib/native_toolchain_c.dart
new file mode 100644
index 0000000..f6f90d1
--- /dev/null
+++ b/pkgs/native_toolchain_c/lib/native_toolchain_c.dart
@@ -0,0 +1,8 @@
+// Copyright (c) 2023, 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.
+
+/// A library to invoke the native C compiler installed on the host machine.
+library;
+
+export 'src/cbuilder/cbuilder.dart';
diff --git a/pkgs/native_toolchain_c/lib/src/cbuilder/cbuilder.dart b/pkgs/native_toolchain_c/lib/src/cbuilder/cbuilder.dart
new file mode 100644
index 0000000..a0a199b
--- /dev/null
+++ b/pkgs/native_toolchain_c/lib/src/cbuilder/cbuilder.dart
@@ -0,0 +1,289 @@
+// Copyright (c) 2023, 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';
+
+import 'package:logging/logging.dart';
+import 'package:meta/meta.dart';
+import 'package:native_assets_cli/native_assets_cli.dart';
+
+import 'run_cbuilder.dart';
+
+abstract class Builder {
+  Future<void> run({
+    required BuildConfig buildConfig,
+    required BuildOutput buildOutput,
+    required Logger? logger,
+  });
+}
+
+/// A programming language that can be selected for compilation of source files.
+///
+/// See [CBuilder.language] for more information.
+class Language {
+  /// The name of the language.
+  final String name;
+
+  const Language._(this.name);
+
+  static const Language c = Language._('c');
+  static const Language cpp = Language._('c++');
+
+  /// Known values for [Language].
+  static const List<Language> values = [c, cpp];
+
+  @override
+  String toString() => name;
+}
+
+/// Specification for building an artifact with a C compiler.
+class CBuilder implements Builder {
+  /// What kind of artifact to build.
+  final _CBuilderType _type;
+
+  /// Name of the library or executable to build.
+  ///
+  /// The filename will be decided by [BuildConfig.target] and
+  /// [OS.libraryFileName] or [OS.executableFileName].
+  ///
+  /// File will be placed in [BuildConfig.outDir].
+  final String name;
+
+  /// Asset identifier.
+  ///
+  /// Used to output the [BuildOutput.assets].
+  ///
+  /// If omitted, no asset will be added to the build output.
+  final String? assetId;
+
+  /// Sources to build the library or executable.
+  ///
+  /// Resolved against [BuildConfig.packageRoot].
+  ///
+  /// Used to output the [BuildOutput.dependencies].
+  final List<String> sources;
+
+  /// Include directories to pass to the compiler.
+  ///
+  /// Resolved against [BuildConfig.packageRoot].
+  ///
+  /// Used to output the [BuildOutput.dependencies].
+  final List<String> includes;
+
+  /// The dart files involved in building this artifact.
+  ///
+  /// Resolved against [BuildConfig.packageRoot].
+  ///
+  /// Used to output the [BuildOutput.dependencies].
+  final List<String> dartBuildFiles;
+
+  /// TODO(https://github.com/dart-lang/native/issues/54): Move to [BuildConfig]
+  /// or hide in public API.
+  @visibleForTesting
+  final Uri? installName;
+
+  /// Flags to pass to the compiler.
+  final List<String> flags;
+
+  /// Definitions of preprocessor macros.
+  ///
+  /// When the value is `null`, the macro is defined without a value.
+  final Map<String, String?> defines;
+
+  /// Whether to define a macro for the current [BuildMode].
+  ///
+  /// The macro name is the uppercase name of the build mode and does not have a
+  /// value.
+  ///
+  /// Defaults to `true`.
+  final bool buildModeDefine;
+
+  /// Whether to define the standard `NDEBUG` macro when _not_ building with
+  /// [BuildMode.debug].
+  ///
+  /// When `NDEBUG` is defined, the C/C++ standard library
+  /// [`assert` macro in `assert.h`](https://en.wikipedia.org/wiki/Assert.h)
+  /// becomes a no-op. Other C/C++ code commonly use `NDEBUG` to disable debug
+  /// features, as well.
+  ///
+  /// Defaults to `true`.
+  final bool ndebugDefine;
+
+  /// Whether the compiler will emit position independent code.
+  ///
+  /// When set to `true`, libraries will be compiled with `-fPIC` and
+  /// executables with `-fPIE`. Accordingly the corresponding parameter of the
+  /// [CBuilder.executable] constructor is named `pie`.
+  ///
+  /// When set to `null`, the default behavior of the compiler will be used.
+  ///
+  /// This option has no effect when building for Windows, where generation of
+  /// position independent code is not configurable.
+  ///
+  /// Defaults to `true` for libraries and `false` for executables.
+  final bool? pic;
+
+  /// The language standard to use.
+  ///
+  /// When set to `null`, the default behavior of the compiler will be used.
+  final String? std;
+
+  /// The language to compile [sources] as.
+  ///
+  /// [cppLinkStdLib] only has an effect when this option is set to
+  /// [Language.cpp].
+  final Language language;
+
+  /// The C++ standard library to link against.
+  ///
+  /// This option has no effect when [language] is not set to [Language.cpp] or
+  /// when compiling for Windows.
+  ///
+  /// When set to `null`, the following defaults will be used, based on the
+  /// target OS:
+  ///
+  /// | OS      | Library      |
+  /// | :------ | :----------- |
+  /// | Android | `c++_shared` |
+  /// | iOS     | `c++`        |
+  /// | Linux   | `stdc++`     |
+  /// | macOS   | `c++`        |
+  /// | Fuchsia | `c++`        |
+  final String? cppLinkStdLib;
+
+  CBuilder.library({
+    required this.name,
+    required this.assetId,
+    this.sources = const [],
+    this.includes = const [],
+    this.dartBuildFiles = const ['build.dart'],
+    @visibleForTesting this.installName,
+    this.flags = const [],
+    this.defines = const {},
+    this.buildModeDefine = true,
+    this.ndebugDefine = true,
+    this.pic = true,
+    this.std,
+    this.language = Language.c,
+    this.cppLinkStdLib,
+  }) : _type = _CBuilderType.library;
+
+  CBuilder.executable({
+    required this.name,
+    this.sources = const [],
+    this.includes = const [],
+    this.dartBuildFiles = const ['build.dart'],
+    this.flags = const [],
+    this.defines = const {},
+    this.buildModeDefine = true,
+    this.ndebugDefine = true,
+    bool? pie = false,
+    this.std,
+    this.language = Language.c,
+    this.cppLinkStdLib,
+  })  : _type = _CBuilderType.executable,
+        assetId = null,
+        installName = null,
+        pic = pie;
+
+  /// Runs the C Compiler with on this C build spec.
+  ///
+  /// Completes with an error if the build fails.
+  @override
+  Future<void> run({
+    required BuildConfig buildConfig,
+    required BuildOutput buildOutput,
+    required Logger? logger,
+  }) async {
+    final outDir = buildConfig.outDir;
+    final packageRoot = buildConfig.packageRoot;
+    await Directory.fromUri(outDir).create(recursive: true);
+    final linkMode = buildConfig.linkModePreference.preferredLinkMode;
+    final libUri =
+        outDir.resolve(buildConfig.targetOs.libraryFileName(name, linkMode));
+    final exeUri =
+        outDir.resolve(buildConfig.targetOs.executableFileName(name));
+    final sources = [
+      for (final source in this.sources)
+        packageRoot.resolveUri(Uri.file(source)),
+    ];
+    final includes = [
+      for (final directory in this.includes)
+        packageRoot.resolveUri(Uri.file(directory)),
+    ];
+    final dartBuildFiles = [
+      for (final source in this.dartBuildFiles) packageRoot.resolve(source),
+    ];
+    if (!buildConfig.dryRun) {
+      final task = RunCBuilder(
+        buildConfig: buildConfig,
+        logger: logger,
+        sources: sources,
+        includes: includes,
+        dynamicLibrary:
+            _type == _CBuilderType.library && linkMode == LinkMode.dynamic
+                ? libUri
+                : null,
+        staticLibrary:
+            _type == _CBuilderType.library && linkMode == LinkMode.static
+                ? libUri
+                : null,
+        executable: _type == _CBuilderType.executable ? exeUri : null,
+        installName: installName,
+        flags: flags,
+        defines: {
+          ...defines,
+          if (buildModeDefine) buildConfig.buildMode.name.toUpperCase(): null,
+          if (ndebugDefine && buildConfig.buildMode != BuildMode.debug)
+            'NDEBUG': null,
+        },
+        pic: pic,
+        std: std,
+        language: language,
+        cppLinkStdLib: cppLinkStdLib,
+      );
+      await task.run();
+    }
+
+    if (assetId != null) {
+      final targets = [
+        if (!buildConfig.dryRun)
+          buildConfig.target
+        else
+          for (final target in Target.values)
+            if (target.os == buildConfig.targetOs) target
+      ];
+      for (final target in targets) {
+        buildOutput.assets.add(Asset(
+          id: assetId!,
+          linkMode: linkMode,
+          target: target,
+          path: AssetAbsolutePath(libUri),
+        ));
+      }
+    }
+    if (!buildConfig.dryRun) {
+      final includeFiles = await Stream.fromIterable(includes)
+          .asyncExpand(
+            (include) => Directory(include.toFilePath())
+                .list(recursive: true)
+                .where((entry) => entry is File)
+                .map((file) => file.uri),
+          )
+          .toList();
+
+      buildOutput.dependencies.dependencies.addAll({
+        // Note: We use a Set here to deduplicate the dependencies.
+        ...sources,
+        ...includeFiles,
+        ...dartBuildFiles,
+      });
+    }
+  }
+}
+
+enum _CBuilderType {
+  executable,
+  library,
+}
diff --git a/pkgs/native_toolchain_c/lib/src/cbuilder/compiler_resolver.dart b/pkgs/native_toolchain_c/lib/src/cbuilder/compiler_resolver.dart
new file mode 100644
index 0000000..17ec90a
--- /dev/null
+++ b/pkgs/native_toolchain_c/lib/src/cbuilder/compiler_resolver.dart
@@ -0,0 +1,226 @@
+// Copyright (c) 2023, 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';
+
+import 'package:logging/logging.dart';
+import 'package:native_assets_cli/native_assets_cli.dart';
+
+import '../native_toolchain/android_ndk.dart';
+import '../native_toolchain/apple_clang.dart';
+import '../native_toolchain/clang.dart';
+import '../native_toolchain/gcc.dart';
+import '../native_toolchain/msvc.dart';
+import '../native_toolchain/recognizer.dart';
+import '../tool/tool.dart';
+import '../tool/tool_error.dart';
+import '../tool/tool_instance.dart';
+
+// TODO(dacoharkes): This should support alternatives.
+// For example use Clang or MSVC on Windows.
+class CompilerResolver {
+  final BuildConfig buildConfig;
+  final Logger? logger;
+  final Target host;
+
+  CompilerResolver({
+    required this.buildConfig,
+    required this.logger,
+    Target? host, // Only visible for testing.
+  }) : host = host ?? Target.current;
+
+  Future<ToolInstance> resolveCompiler() async {
+    // First, check if the launcher provided a direct path to the compiler.
+    var result = await _tryLoadCompilerFromConfig(
+      CCompilerConfig.ccConfigKeyFull,
+      (buildConfig) => buildConfig.cCompiler.cc,
+    );
+
+    // Then, try to detect on the host machine.
+    final tool = _selectCompiler();
+    if (tool != null) {
+      result ??= await _tryLoadToolFromNativeToolchain(tool);
+    }
+
+    if (result != null) {
+      return result;
+    }
+
+    final targetOs = buildConfig.targetOs;
+    final targetArchitecture = buildConfig.targetArchitecture;
+    final errorMessage = "No tools configured on host '$host' with target "
+        "'${targetOs}_$targetArchitecture'.";
+    logger?.severe(errorMessage);
+    throw ToolError(errorMessage);
+  }
+
+  /// Select the right compiler for cross compiling to the specified target.
+  Tool? _selectCompiler() {
+    final targetOs = buildConfig.targetOs;
+    final targetArch = buildConfig.targetArchitecture;
+
+    // TODO(dacoharkes): Support falling back on other tools.
+    if (targetArch == host.architecture &&
+        targetOs == host.os &&
+        host.os == OS.linux) return clang;
+    if (targetOs == OS.macOS || targetOs == OS.iOS) return appleClang;
+    if (targetOs == OS.android) return androidNdkClang;
+    if (host.os == OS.linux) {
+      switch (targetArch) {
+        case Architecture.arm:
+          return armLinuxGnueabihfGcc;
+        case Architecture.arm64:
+          return aarch64LinuxGnuGcc;
+        case Architecture.ia32:
+          return i686LinuxGnuGcc;
+        case Architecture.x64:
+          return x86_64LinuxGnuGcc;
+        case Architecture.riscv64:
+          return riscv64LinuxGnuGcc;
+      }
+    }
+
+    if (host.os == OS.windows) {
+      switch (targetArch) {
+        case Architecture.ia32:
+          return clIA32;
+        case Architecture.arm64:
+          return clArm64;
+        case Architecture.x64:
+          return cl;
+      }
+    }
+
+    return null;
+  }
+
+  Future<ToolInstance?> _tryLoadCompilerFromConfig(
+      String configKey, Uri? Function(BuildConfig) getter) async {
+    final configCcUri = getter(buildConfig);
+    if (configCcUri != null) {
+      assert(await File.fromUri(configCcUri).exists());
+      logger?.finer('Using compiler ${configCcUri.toFilePath()} '
+          'from config[${CCompilerConfig.ccConfigKeyFull}].');
+      return (await CompilerRecognizer(configCcUri).resolve(logger: logger))
+          .first;
+    }
+    logger?.finer(
+        'No compiler set in config[${CCompilerConfig.ccConfigKeyFull}].');
+    return null;
+  }
+
+  Future<ToolInstance?> _tryLoadToolFromNativeToolchain(Tool tool) async {
+    final resolved = (await tool.defaultResolver!.resolve(logger: logger))
+        .where((i) => i.tool == tool)
+        .toList()
+      ..sort();
+    return resolved.isEmpty ? null : resolved.first;
+  }
+
+  Future<ToolInstance> resolveArchiver() async {
+    // First, check if the launcher provided a direct path to the compiler.
+    var result = await _tryLoadArchiverFromConfig(
+      CCompilerConfig.arConfigKeyFull,
+      (buildConfig) => buildConfig.cCompiler.ar,
+    );
+
+    // Then, try to detect on the host machine.
+    final tool = _selectArchiver();
+    if (tool != null) {
+      result ??= await _tryLoadToolFromNativeToolchain(tool);
+    }
+
+    if (result != null) {
+      return result;
+    }
+
+    final targetOs = buildConfig.targetOs;
+    final targetArchitecture = buildConfig.targetArchitecture;
+    final errorMessage = "No tools configured on host '$host' with target "
+        "'${targetOs}_$targetArchitecture'.";
+    logger?.severe(errorMessage);
+    throw ToolError(errorMessage);
+  }
+
+  /// Select the right archiver for cross compiling to the specified target.
+  Tool? _selectArchiver() {
+    final targetOs = buildConfig.targetOs;
+    final targetArchitecture = buildConfig.targetArchitecture;
+
+    // TODO(dacoharkes): Support falling back on other tools.
+    if (targetArchitecture == host.architecture &&
+        targetOs == host.os &&
+        host.os == OS.linux) {
+      return llvmAr;
+    }
+    if (targetOs == OS.macOS || targetOs == OS.iOS) return appleAr;
+    if (targetOs == OS.android) return androidNdkLlvmAr;
+    if (host.os == OS.linux) {
+      switch (targetArchitecture) {
+        case Architecture.arm:
+          return armLinuxGnueabihfGccAr;
+        case Architecture.arm64:
+          return aarch64LinuxGnuGccAr;
+        case Architecture.ia32:
+          return i686LinuxGnuGccAr;
+        case Architecture.x64:
+          return x86_64LinuxGnuGccAr;
+        case Architecture.riscv64:
+          return riscv64LinuxGnuGccAr;
+      }
+    }
+    if (host.os == OS.windows) {
+      switch (targetArchitecture) {
+        case Architecture.ia32:
+          return libIA32;
+        case Architecture.arm64:
+          return libArm64;
+        case Architecture.x64:
+          return lib;
+      }
+    }
+
+    return null;
+  }
+
+  Future<ToolInstance?> _tryLoadArchiverFromConfig(
+      String configKey, Uri? Function(BuildConfig) getter) async {
+    final configArUri = getter(buildConfig);
+    if (configArUri != null) {
+      assert(await File.fromUri(configArUri).exists());
+      logger?.finer('Using archiver ${configArUri.toFilePath()} '
+          'from config[${CCompilerConfig.arConfigKeyFull}].');
+      return (await ArchiverRecognizer(configArUri).resolve(logger: logger))
+          .first;
+    }
+    logger?.finer(
+        'No archiver set in config[${CCompilerConfig.arConfigKeyFull}].');
+    return null;
+  }
+
+  Future<Uri?> toolchainEnvironmentScript(ToolInstance compiler) async {
+    final fromConfig = buildConfig.cCompiler.envScript;
+    if (fromConfig != null) {
+      logger?.fine('Using envScript from config: $fromConfig');
+      return fromConfig;
+    }
+
+    final compilerTool = compiler.tool;
+    assert(compilerTool == cl);
+    final vcvarsScript =
+        (await vcvars(compiler).defaultResolver!.resolve(logger: logger)).first;
+    return vcvarsScript.uri;
+  }
+
+  List<String>? toolchainEnvironmentScriptArguments() {
+    final fromConfig = buildConfig.cCompiler.envScriptArgs;
+    if (fromConfig != null) {
+      logger?.fine('Using envScriptArgs from config: $fromConfig');
+      return fromConfig;
+    }
+
+    // vcvars above already has x64 or x86 in the script name.
+    return null;
+  }
+}
diff --git a/pkgs/native_toolchain_c/lib/src/cbuilder/run_cbuilder.dart b/pkgs/native_toolchain_c/lib/src/cbuilder/run_cbuilder.dart
new file mode 100644
index 0000000..0db3dda
--- /dev/null
+++ b/pkgs/native_toolchain_c/lib/src/cbuilder/run_cbuilder.dart
@@ -0,0 +1,321 @@
+// Copyright (c) 2023, 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:math';
+
+import 'package:logging/logging.dart';
+import 'package:native_assets_cli/native_assets_cli.dart';
+
+import '../native_toolchain/apple_clang.dart';
+import '../native_toolchain/clang.dart';
+import '../native_toolchain/gcc.dart';
+import '../native_toolchain/msvc.dart';
+import '../native_toolchain/xcode.dart';
+import '../tool/tool_instance.dart';
+import '../utils/env_from_bat.dart';
+import '../utils/run_process.dart';
+import 'cbuilder.dart';
+import 'compiler_resolver.dart';
+
+class RunCBuilder {
+  final BuildConfig buildConfig;
+  final Logger? logger;
+  final List<Uri> sources;
+  final List<Uri> includes;
+  final Uri? executable;
+  final Uri? dynamicLibrary;
+  final Uri? staticLibrary;
+  final Uri outDir;
+  final Target target;
+
+  /// The install name of the [dynamicLibrary].
+  ///
+  /// Can be inspected with `otool -D <path-to-dylib>`.
+  ///
+  /// Can be modified with `install_name_tool`.
+  final Uri? installName;
+
+  final List<String> flags;
+  final Map<String, String?> defines;
+  final bool? pic;
+  final String? std;
+  final Language language;
+  final String? cppLinkStdLib;
+
+  RunCBuilder({
+    required this.buildConfig,
+    this.logger,
+    this.sources = const [],
+    this.includes = const [],
+    this.executable,
+    this.dynamicLibrary,
+    this.staticLibrary,
+    this.installName,
+    this.flags = const [],
+    this.defines = const {},
+    this.pic,
+    this.std,
+    this.language = Language.c,
+    this.cppLinkStdLib,
+  })  : outDir = buildConfig.outDir,
+        target = buildConfig.target,
+        assert([executable, dynamicLibrary, staticLibrary]
+                .whereType<Uri>()
+                .length ==
+            1) {
+    if (target.os == OS.windows && cppLinkStdLib != null) {
+      throw ArgumentError.value(
+        cppLinkStdLib,
+        'cppLinkStdLib',
+        'is not supported when targeting Windows',
+      );
+    }
+  }
+
+  late final _resolver =
+      CompilerResolver(buildConfig: buildConfig, logger: logger);
+
+  Future<ToolInstance> compiler() async => await _resolver.resolveCompiler();
+
+  Future<Uri> archiver() async => (await _resolver.resolveArchiver()).uri;
+
+  Future<Uri> iosSdk(IOSSdk iosSdk, {required Logger? logger}) async {
+    if (iosSdk == IOSSdk.iPhoneOs) {
+      return (await iPhoneOSSdk.defaultResolver!.resolve(logger: logger))
+          .where((i) => i.tool == iPhoneOSSdk)
+          .first
+          .uri;
+    }
+    assert(iosSdk == IOSSdk.iPhoneSimulator);
+    return (await iPhoneSimulatorSdk.defaultResolver!.resolve(logger: logger))
+        .where((i) => i.tool == iPhoneSimulatorSdk)
+        .first
+        .uri;
+  }
+
+  Future<Uri> macosSdk({required Logger? logger}) async =>
+      (await macosxSdk.defaultResolver!.resolve(logger: logger))
+          .where((i) => i.tool == macosxSdk)
+          .first
+          .uri;
+
+  Uri androidSysroot(ToolInstance compiler) =>
+      compiler.uri.resolve('../sysroot/');
+
+  Future<void> run() async {
+    final compiler_ = await compiler();
+    final compilerTool = compiler_.tool;
+    if (compilerTool == appleClang ||
+        compilerTool == clang ||
+        compilerTool == gcc) {
+      await runClangLike(compiler: compiler_);
+      return;
+    }
+    assert(compilerTool == cl);
+    await runCl(compiler: compiler_);
+  }
+
+  Future<void> runClangLike({required ToolInstance compiler}) async {
+    final isStaticLib = staticLibrary != null;
+    Uri? archiver_;
+    if (isStaticLib) {
+      archiver_ = await archiver();
+    }
+
+    late final IOSSdk targetIosSdk;
+    if (target.os == OS.iOS) {
+      targetIosSdk = buildConfig.targetIOSSdk!;
+    }
+
+    // The Android Gradle plugin does not honor API level 19 and 20 when
+    // invoking clang. Mimic that behavior here.
+    // See https://github.com/dart-lang/native/issues/171.
+    late final int targetAndroidNdkApi;
+    if (target.os == OS.android) {
+      targetAndroidNdkApi = max(buildConfig.targetAndroidNdkApi!, 21);
+    }
+
+    await runProcess(
+      executable: compiler.uri,
+      arguments: [
+        if (target.os == OS.android) ...[
+          '--target='
+              '${androidNdkClangTargetFlags[target]!}'
+              '$targetAndroidNdkApi',
+          '--sysroot=${androidSysroot(compiler).toFilePath()}',
+        ],
+        if (target.os == OS.macOS)
+          '--target=${appleClangMacosTargetFlags[target]!}',
+        if (target.os == OS.iOS)
+          '--target=${appleClangIosTargetFlags[target]![targetIosSdk]!}',
+        if (target.os == OS.iOS) ...[
+          '-isysroot',
+          (await iosSdk(targetIosSdk, logger: logger)).toFilePath(),
+        ],
+        if (target.os == OS.macOS) ...[
+          '-isysroot',
+          (await macosSdk(logger: logger)).toFilePath(),
+        ],
+        if (installName != null) ...[
+          '-install_name',
+          installName!.toFilePath(),
+        ],
+        if (pic != null)
+          if (pic!) ...[
+            if (dynamicLibrary != null) '-fPIC',
+            // Using PIC for static libraries allows them to be linked into
+            // any executable, but it is not necessarily the best option in
+            // terms of overhead. We would have to know wether the target into
+            // which the static library is linked is PIC, PIE or neither. Then
+            // we could use the same option for the static library.
+            if (staticLibrary != null) '-fPIC',
+            if (executable != null) ...[
+              // Generate position-independent code for executables.
+              '-fPIE',
+              // Tell the linker to generate a position-independent executable.
+              '-pie',
+            ],
+          ] else ...[
+            // Disable generation of any kind of position-independent code.
+            '-fno-PIC',
+            '-fno-PIE',
+            // Tell the linker to generate a position-dependent executable.
+            if (executable != null) '-no-pie',
+          ],
+        if (std != null) '-std=$std',
+        if (language == Language.cpp) ...[
+          '-x',
+          'c++',
+          '-l',
+          cppLinkStdLib ?? defaultCppLinkStdLib[target.os]!
+        ],
+        ...flags,
+        for (final MapEntry(key: name, :value) in defines.entries)
+          if (value == null) '-D$name' else '-D$name=$value',
+        for (final include in includes) '-I${include.toFilePath()}',
+        ...sources.map((e) => e.toFilePath()),
+        if (executable != null) ...[
+          '-o',
+          outDir.resolveUri(executable!).toFilePath(),
+        ],
+        if (dynamicLibrary != null) ...[
+          '--shared',
+          '-o',
+          outDir.resolveUri(dynamicLibrary!).toFilePath(),
+        ] else if (staticLibrary != null) ...[
+          '-c',
+          '-o',
+          outDir.resolve('out.o').toFilePath(),
+        ],
+      ],
+      logger: logger,
+      captureOutput: false,
+      throwOnUnexpectedExitCode: true,
+    );
+    if (staticLibrary != null) {
+      await runProcess(
+        executable: archiver_!,
+        arguments: [
+          'rc',
+          outDir.resolveUri(staticLibrary!).toFilePath(),
+          outDir.resolve('out.o').toFilePath(),
+        ],
+        logger: logger,
+        captureOutput: false,
+        throwOnUnexpectedExitCode: true,
+      );
+    }
+  }
+
+  Future<void> runCl({required ToolInstance compiler}) async {
+    final vcvars = (await _resolver.toolchainEnvironmentScript(compiler))!;
+    final vcvarsArgs = _resolver.toolchainEnvironmentScriptArguments();
+    final environment = await envFromBat(vcvars, arguments: vcvarsArgs ?? []);
+
+    final isStaticLib = staticLibrary != null;
+    Uri? archiver_;
+    if (isStaticLib) {
+      archiver_ = await archiver();
+    }
+
+    final result = await runProcess(
+      executable: compiler.uri,
+      arguments: [
+        if (std != null) '/std:$std',
+        if (language == Language.cpp) '/TP',
+        ...flags,
+        for (final MapEntry(key: name, :value) in defines.entries)
+          if (value == null) '/D$name' else '/D$name=$value',
+        for (final directory in includes) '/I${directory.toFilePath()}',
+        if (executable != null) ...[
+          ...sources.map((e) => e.toFilePath()),
+          '/link',
+          '/out:${outDir.resolveUri(executable!).toFilePath()}',
+        ],
+        if (dynamicLibrary != null) ...[
+          ...sources.map((e) => e.toFilePath()),
+          '/link',
+          '/DLL',
+          '/out:${outDir.resolveUri(dynamicLibrary!).toFilePath()}',
+        ],
+        if (staticLibrary != null) ...[
+          '/c',
+          ...sources.map((e) => e.toFilePath()),
+        ],
+      ],
+      workingDirectory: outDir,
+      environment: environment,
+      logger: logger,
+      captureOutput: false,
+      throwOnUnexpectedExitCode: true,
+    );
+
+    if (staticLibrary != null) {
+      await runProcess(
+        executable: archiver_!,
+        arguments: [
+          '/out:${staticLibrary!.toFilePath()}',
+          '*.obj',
+        ],
+        workingDirectory: outDir,
+        environment: environment,
+        logger: logger,
+        captureOutput: false,
+        throwOnUnexpectedExitCode: true,
+      );
+    }
+
+    assert(result.exitCode == 0);
+  }
+
+  static const androidNdkClangTargetFlags = {
+    Target.androidArm: 'armv7a-linux-androideabi',
+    Target.androidArm64: 'aarch64-linux-android',
+    Target.androidIA32: 'i686-linux-android',
+    Target.androidX64: 'x86_64-linux-android',
+  };
+
+  static const appleClangMacosTargetFlags = {
+    Target.macOSArm64: 'arm64-apple-darwin',
+    Target.macOSX64: 'x86_64-apple-darwin',
+  };
+
+  static const appleClangIosTargetFlags = {
+    Target.iOSArm64: {
+      IOSSdk.iPhoneOs: 'arm64-apple-ios',
+      IOSSdk.iPhoneSimulator: 'arm64-apple-ios-simulator',
+    },
+    Target.iOSX64: {
+      IOSSdk.iPhoneSimulator: 'x86_64-apple-ios-simulator',
+    },
+  };
+
+  static const defaultCppLinkStdLib = {
+    OS.android: 'c++_shared',
+    OS.fuchsia: 'c++',
+    OS.iOS: 'c++',
+    OS.linux: 'stdc++',
+    OS.macOS: 'c++',
+  };
+}
diff --git a/pkgs/native_toolchain_c/lib/src/native_toolchain/android_ndk.dart b/pkgs/native_toolchain_c/lib/src/native_toolchain/android_ndk.dart
new file mode 100644
index 0000000..31edfc5
--- /dev/null
+++ b/pkgs/native_toolchain_c/lib/src/native_toolchain/android_ndk.dart
@@ -0,0 +1,132 @@
+// Copyright (c) 2023, 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';
+
+import 'package:logging/logging.dart';
+import 'package:native_assets_cli/native_assets_cli.dart';
+
+import '../tool/tool.dart';
+import '../tool/tool_instance.dart';
+import '../tool/tool_resolver.dart';
+import 'clang.dart';
+
+final androidNdk = Tool(
+  name: 'Android NDK',
+  defaultResolver: _AndroidNdkResolver(),
+);
+
+/// [clang] with [Tool.defaultResolver] for the [OS.android] NDK.
+final androidNdkClang = Tool(
+  name: clang.name,
+  defaultResolver: _AndroidNdkResolver(),
+);
+
+/// [llvmAr] with [Tool.defaultResolver] for the [OS.android] NDK.
+final androidNdkLlvmAr = Tool(
+  name: llvmAr.name,
+  defaultResolver: _AndroidNdkResolver(),
+);
+
+/// [lld] with [Tool.defaultResolver] for the [OS.android] NDK.
+final androidNdkLld = Tool(
+  name: lld.name,
+  defaultResolver: _AndroidNdkResolver(),
+);
+
+class _AndroidNdkResolver implements ToolResolver {
+  final installLocationResolver = PathVersionResolver(
+    wrappedResolver: ToolResolvers([
+      RelativeToolResolver(
+        toolName: 'Android NDK',
+        wrappedResolver: PathToolResolver(
+          toolName: 'ndk-build',
+          executableName: Platform.isWindows ? 'ndk-build.cmd' : 'ndk-build',
+        ),
+        relativePath: Uri(path: ''),
+      ),
+      InstallLocationResolver(
+        toolName: 'Android NDK',
+        paths: [
+          if (Platform.isLinux) ...[
+            '\$HOME/Android/Sdk/ndk/*/',
+            '\$HOME/Android/Sdk/ndk-bundle/',
+          ],
+          if (Platform.isMacOS) ...[
+            '\$HOME/Library/Android/sdk/ndk/*/',
+          ],
+          if (Platform.isWindows) ...[
+            '\$HOME/AppData/Local/Android/Sdk/ndk/*/',
+          ],
+        ],
+      ),
+    ]),
+  );
+
+  @override
+  Future<List<ToolInstance>> resolve({required Logger? logger}) async {
+    final ndkInstances = await installLocationResolver.resolve(logger: logger);
+
+    return [
+      for (final ndkInstance in ndkInstances) ...[
+        ndkInstance,
+        ...await tryResolveClang(
+          ndkInstance,
+          logger: logger,
+        )
+      ]
+    ];
+  }
+
+  Future<List<ToolInstance>> tryResolveClang(
+    ToolInstance androidNdkInstance, {
+    required Logger? logger,
+  }) async {
+    final result = <ToolInstance>[];
+    final prebuiltUri =
+        androidNdkInstance.uri.resolve('toolchains/llvm/prebuilt/');
+    final prebuiltDir = Directory.fromUri(prebuiltUri);
+    final hostArchDirs =
+        (await prebuiltDir.list().toList()).whereType<Directory>().toList();
+    for (final hostArchDir in hostArchDirs) {
+      final clangUri = hostArchDir.uri
+          .resolve('bin/')
+          .resolve(Target.current.os.executableFileName('clang'));
+      if (await File.fromUri(clangUri).exists()) {
+        result.add(await CliVersionResolver.lookupVersion(
+          ToolInstance(
+            tool: androidNdkClang,
+            uri: clangUri,
+          ),
+          logger: logger,
+        ));
+      }
+      final arUri = hostArchDir.uri
+          .resolve('bin/')
+          .resolve(Target.current.os.executableFileName('llvm-ar'));
+      if (await File.fromUri(arUri).exists()) {
+        result.add(await CliVersionResolver.lookupVersion(
+          ToolInstance(
+            tool: androidNdkLlvmAr,
+            uri: arUri,
+          ),
+          logger: logger,
+        ));
+      }
+      final ldUri = hostArchDir.uri
+          .resolve('bin/')
+          .resolve(Target.current.os.executableFileName('ld.lld'));
+      if (await File.fromUri(arUri).exists()) {
+        result.add(await CliVersionResolver.lookupVersion(
+          ToolInstance(
+            tool: androidNdkLld,
+            uri: ldUri,
+          ),
+          logger: logger,
+        ));
+      }
+    }
+    return result;
+  }
+}
diff --git a/pkgs/native_toolchain_c/lib/src/native_toolchain/apple_clang.dart b/pkgs/native_toolchain_c/lib/src/native_toolchain/apple_clang.dart
new file mode 100644
index 0000000..9c7eb4c
--- /dev/null
+++ b/pkgs/native_toolchain_c/lib/src/native_toolchain/apple_clang.dart
@@ -0,0 +1,60 @@
+// Copyright (c) 2023, 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 '../tool/tool.dart';
+import '../tool/tool_resolver.dart';
+
+/// The Clang variant inside XCode.
+///
+/// https://developer.apple.com/xcode/
+final Tool appleClang = Tool(
+  name: 'Apple Clang',
+  defaultResolver: CliVersionResolver(
+    wrappedResolver: CliFilter(
+      cliArguments: ['--version'],
+      keepIf: ({required String stdout}) => stdout.contains('Apple clang'),
+      wrappedResolver: PathToolResolver(
+        toolName: 'Apple Clang',
+        executableName: 'clang',
+      ),
+    ),
+  ),
+);
+
+/// The archiver belonging to [appleClang].
+final Tool appleAr = Tool(
+  name: 'Apple archiver',
+  defaultResolver: ToolResolvers([
+    RelativeToolResolver(
+      toolName: 'Apple archiver',
+      wrappedResolver: appleClang.defaultResolver!,
+      relativePath: Uri.file('ar'),
+    ),
+  ]),
+);
+
+/// The linker belonging to [appleClang].
+final Tool appleLd = Tool(
+  name: 'Apple linker',
+  defaultResolver: ToolResolvers([
+    RelativeToolResolver(
+      toolName: 'Apple linker',
+      wrappedResolver: appleClang.defaultResolver!,
+      relativePath: Uri.file('ld'),
+    ),
+  ]),
+);
+
+/// The Mach-O dumping tool.
+///
+/// https://llvm.org/docs/CommandGuide/llvm-otool.html
+final Tool otool = Tool(
+  name: 'otool',
+  defaultResolver: CliVersionResolver(
+    wrappedResolver: PathToolResolver(
+      toolName: 'otool',
+      executableName: 'otool',
+    ),
+  ),
+);
diff --git a/pkgs/native_toolchain_c/lib/src/native_toolchain/clang.dart b/pkgs/native_toolchain_c/lib/src/native_toolchain/clang.dart
new file mode 100644
index 0000000..910e3b3
--- /dev/null
+++ b/pkgs/native_toolchain_c/lib/src/native_toolchain/clang.dart
@@ -0,0 +1,55 @@
+// Copyright (c) 2023, 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 '../tool/tool.dart';
+import '../tool/tool_resolver.dart';
+
+/// The Clang compiler.
+///
+/// https://clang.llvm.org/
+final Tool clang = Tool(
+  name: 'Clang',
+  defaultResolver: CliVersionResolver(
+    wrappedResolver: CliFilter(
+      cliArguments: ['--version'],
+      keepIf: ({required String stdout}) => !stdout.contains('Apple clang'),
+      wrappedResolver: PathToolResolver(
+        toolName: 'Clang',
+        executableName: 'clang',
+      ),
+    ),
+  ),
+);
+
+/// The LLVM archiver.
+///
+/// https://llvm.org/docs/CommandGuide/llvm-ar.html
+final Tool llvmAr = Tool(
+  name: 'LLVM archiver',
+  defaultResolver: CliVersionResolver(
+    wrappedResolver: ToolResolvers([
+      RelativeToolResolver(
+        toolName: 'LLVM archiver',
+        wrappedResolver: clang.defaultResolver!,
+        relativePath: Uri.file('llvm-ar'),
+      ),
+    ]),
+  ),
+);
+
+/// The LLVM Linker.
+///
+/// https://lld.llvm.org/
+final Tool lld = Tool(
+  name: 'LLD',
+  defaultResolver: CliVersionResolver(
+    wrappedResolver: ToolResolvers([
+      RelativeToolResolver(
+        toolName: 'LLD',
+        wrappedResolver: clang.defaultResolver!,
+        relativePath: Uri.file('ld.lld'),
+      ),
+    ]),
+  ),
+);
diff --git a/pkgs/native_toolchain_c/lib/src/native_toolchain/gcc.dart b/pkgs/native_toolchain_c/lib/src/native_toolchain/gcc.dart
new file mode 100644
index 0000000..c3ea294
--- /dev/null
+++ b/pkgs/native_toolchain_c/lib/src/native_toolchain/gcc.dart
@@ -0,0 +1,100 @@
+// Copyright (c) 2023, 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:native_assets_cli/native_assets_cli.dart';
+
+import '../tool/tool.dart';
+import '../tool/tool_resolver.dart';
+
+/// The GNU Compiler Collection for [Architecture.current].
+///
+/// https://gcc.gnu.org/
+final gcc = Tool(name: 'GCC');
+
+/// The GNU GCC archiver for [Architecture.current].
+final gnuArchiver = Tool(name: 'GNU archiver');
+
+/// The GNU linker for [Architecture.current].
+///
+/// https://ftp.gnu.org/old-gnu/Manuals/ld-2.9.1/ld.html
+final gnuLinker = Tool(name: 'GNU linker');
+
+/// [gcc] with [Tool.defaultResolver] for [Architecture.ia32].
+final i686LinuxGnuGcc = _gcc('i686-linux-gnu');
+
+/// [gnuArchiver] with [Tool.defaultResolver] for [Architecture.ia32].
+final i686LinuxGnuGccAr = _gnuArchiver('i686-linux-gnu');
+
+/// [gnuLinker] with [Tool.defaultResolver] for [Architecture.ia32].
+final i686LinuxGnuLd = _gnuLinker('i686-linux-gnu');
+
+/// [gcc] with [Tool.defaultResolver] for [Architecture.x64].
+final x86_64LinuxGnuGcc = _gcc('x86_64-linux-gnu');
+
+/// [gnuArchiver] with [Tool.defaultResolver] for [Architecture.x64].
+final x86_64LinuxGnuGccAr = _gnuArchiver('x86_64-linux-gnu');
+
+/// [gnuLinker] with [Tool.defaultResolver] for [Architecture.x64].
+final x86_64LinuxGnuLd = _gnuLinker('x86_64-linux-gnu');
+
+/// [gcc] with [Tool.defaultResolver] for [Architecture.arm].
+final armLinuxGnueabihfGcc = _gcc('arm-linux-gnueabihf');
+
+/// [gnuArchiver] with [Tool.defaultResolver] for [Architecture.arm].
+final armLinuxGnueabihfGccAr = _gnuArchiver('arm-linux-gnueabihf');
+
+/// [gnuLinker] with [Tool.defaultResolver] for [Architecture.arm].
+final armLinuxGnueabihfLd = _gnuLinker('arm-linux-gnueabihf');
+
+/// [gcc] with [Tool.defaultResolver] for [Architecture.arm64].
+final aarch64LinuxGnuGcc = _gcc('aarch64-linux-gnu');
+
+/// [gnuArchiver] with [Tool.defaultResolver] for [Architecture.arm64].
+final aarch64LinuxGnuGccAr = _gnuArchiver('aarch64-linux-gnu');
+
+/// [gnuLinker] with [Tool.defaultResolver] for [Architecture.arm64].
+final aarch64LinuxGnuLd = _gnuLinker('aarch64-linux-gnu');
+
+/// [gcc] with [Tool.defaultResolver] for [Architecture.riscv64].
+final riscv64LinuxGnuGcc = _gcc('riscv64-linux-gnu');
+
+/// [gnuArchiver] with [Tool.defaultResolver] for [Architecture.riscv64].
+final riscv64LinuxGnuGccAr = _gnuArchiver('riscv64-linux-gnu');
+
+/// [gnuLinker] with [Tool.defaultResolver] for [Architecture.riscv64].
+final riscv64LinuxGnuLd = _gnuLinker('riscv64-linux-gnu');
+
+Tool _gcc(String prefix) => Tool(
+      name: gcc.name,
+      defaultResolver: CliVersionResolver(
+        wrappedResolver: PathToolResolver(
+          toolName: gcc.name,
+          executableName: '$prefix-gcc',
+        ),
+      ),
+    );
+
+Tool _gnuArchiver(String prefix) {
+  final gcc = _gcc(prefix);
+  return Tool(
+    name: gnuArchiver.name,
+    defaultResolver: RelativeToolResolver(
+      toolName: gnuArchiver.name,
+      wrappedResolver: gcc.defaultResolver!,
+      relativePath: Uri.file('$prefix-gcc-ar'),
+    ),
+  );
+}
+
+Tool _gnuLinker(String prefix) {
+  final gcc = _gcc(prefix);
+  return Tool(
+    name: gnuLinker.name,
+    defaultResolver: RelativeToolResolver(
+      toolName: gnuLinker.name,
+      wrappedResolver: gcc.defaultResolver!,
+      relativePath: Uri.file('$prefix-ld'),
+    ),
+  );
+}
diff --git a/pkgs/native_toolchain_c/lib/src/native_toolchain/msvc.dart b/pkgs/native_toolchain_c/lib/src/native_toolchain/msvc.dart
new file mode 100644
index 0000000..a95356e
--- /dev/null
+++ b/pkgs/native_toolchain_c/lib/src/native_toolchain/msvc.dart
@@ -0,0 +1,310 @@
+// Copyright (c) 2023, 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';
+
+import 'package:glob/glob.dart';
+import 'package:logging/logging.dart';
+import 'package:native_assets_cli/native_assets_cli.dart';
+
+import '../tool/tool.dart';
+import '../tool/tool_instance.dart';
+import '../tool/tool_resolver.dart';
+import '../utils/run_process.dart';
+import '../utils/sem_version.dart';
+
+/// The Visual Studio Locator.
+///
+/// https://github.com/microsoft/vswhere
+final Tool vswhere = Tool(
+  name: 'Visual Studio Locator',
+  defaultResolver: CliVersionResolver(
+    arguments: [],
+    wrappedResolver: ToolResolvers(
+      [
+        PathToolResolver(
+          toolName: 'Visual Studio Locator',
+          executableName: 'vswhere.exe',
+        ),
+        InstallLocationResolver(
+          toolName: 'Visual Studio Locator',
+          paths: [
+            'C:/Program Files \\(x86\\)/Microsoft Visual Studio/Installer/vswhere.exe',
+            'C:/Program Files/Microsoft Visual Studio/Installer/vswhere.exe',
+          ],
+        ),
+      ],
+    ),
+  ),
+);
+
+/// Visual Studio.
+///
+/// https://visualstudio.microsoft.com/
+final Tool visualStudio = Tool(
+  name: 'Visual Studio',
+  defaultResolver: VisualStudioResolver(),
+);
+
+/// The C/C++ Optimizing Compiler.
+final Tool msvc = Tool(
+  name: 'MSVC',
+  defaultResolver: PathVersionResolver(
+    wrappedResolver: RelativeToolResolver(
+      toolName: 'MSVC',
+      wrappedResolver: visualStudio.defaultResolver!,
+      relativePath: Uri(path: './VC/Tools/MSVC/*/'),
+    ),
+  ),
+);
+
+Tool vcvars(ToolInstance toolInstance) {
+  final tool = toolInstance.tool;
+  assert(tool == cl || tool == link || tool == lib);
+  final vcDir = toolInstance.uri.resolve('../../../../../../');
+  final String fileName;
+  if (toolInstance.uri.toFilePath().contains('\\x86\\')) {
+    fileName = 'vcvars32.bat';
+  } else if (toolInstance.uri.toFilePath().contains('\\arm64\\')) {
+    // TODO(https://github.com/dart-lang/native/issues/170): Support native
+    // windows-arm64 MSVC toolchain.
+    // vcvarsarm64 only works on native windows-arm64. In case of cross
+    // compilation, it's better to stick to cross toolchain, which works under
+    // emulation on windows-arm64.
+    fileName = 'vcvarsamd64_arm64.bat';
+  } else {
+    fileName = 'vcvars64.bat';
+  }
+  final batchScript = vcDir.resolve('Auxiliary/Build/$fileName');
+  return Tool(
+    name: fileName,
+    defaultResolver: InstallLocationResolver(
+      toolName: fileName,
+      paths: [
+        Glob.quote(batchScript.toFilePath().replaceAll('\\', '/')),
+      ],
+    ),
+  );
+}
+
+final Tool vcvars64 = Tool(
+  name: 'vcvars64.bat',
+  defaultResolver: RelativeToolResolver(
+    toolName: 'vcvars64.bat',
+    wrappedResolver: visualStudio.defaultResolver!,
+    relativePath: Uri(path: './VC/Auxiliary/Build/vcvars64.bat'),
+  ),
+);
+
+final Tool vcvars32 = Tool(
+  name: 'vcvars32.bat',
+  defaultResolver: RelativeToolResolver(
+    toolName: 'vcvars32.bat',
+    wrappedResolver: visualStudio.defaultResolver!,
+    relativePath: Uri(path: './VC/Auxiliary/Build/vcvars32.bat'),
+  ),
+);
+
+final Tool vcvarsarm64 = Tool(
+  // TODO(https://github.com/dart-lang/native/issues/170): Support native
+  // windows-arm64 MSVC toolchain.
+  // vcvarsarm64 only works on native windows-arm64. In case of cross
+  // compilation, it's better to stick to cross toolchain, which works under
+  // emulation on windows-arm64.
+  name: 'vcvarsamd64_arm64.bat',
+  defaultResolver: RelativeToolResolver(
+    toolName: 'vcvarsamd64_arm64.bat',
+    wrappedResolver: visualStudio.defaultResolver!,
+    relativePath: Uri(path: './VC/Auxiliary/Build/vcvarsamd64_arm64.bat'),
+  ),
+);
+
+final Tool vcvarsall = Tool(
+  name: 'vcvarsall.bat',
+  defaultResolver: RelativeToolResolver(
+    toolName: 'vcvars32.bat',
+    wrappedResolver: visualStudio.defaultResolver!,
+    relativePath: Uri(path: './VC/Auxiliary/Build/vcvarsall.bat'),
+  ),
+);
+
+final Tool vsDevCmd = Tool(
+  name: 'VsDevCmd.bat',
+  defaultResolver: RelativeToolResolver(
+    toolName: 'VsDevCmd.bat',
+    wrappedResolver: visualStudio.defaultResolver!,
+    relativePath: Uri(path: './Common7/Tools/VsDevCmd.bat'),
+  ),
+);
+
+/// The C/C++ Optimizing Compiler main executable.
+///
+/// For targeting [Architecture.x64].
+final Tool cl = _msvcTool(
+  name: 'cl',
+  versionArguments: [],
+  targetArchitecture: Architecture.x64,
+  hostArchitecture: Target.current.architecture,
+);
+
+/// The C/C++ Optimizing Compiler main executable.
+///
+/// For targeting [Architecture.ia32].
+final Tool clIA32 = _msvcTool(
+  name: 'cl',
+  versionArguments: [],
+  targetArchitecture: Architecture.ia32,
+  hostArchitecture: Target.current.architecture,
+);
+
+/// The C/C++ Optimizing Compiler main executable.
+///
+/// For targeting [Architecture.arm64].
+final Tool clArm64 = _msvcTool(
+  name: 'cl',
+  versionArguments: [],
+  targetArchitecture: Architecture.arm64,
+  hostArchitecture: Target.current.architecture,
+);
+
+final Tool lib = _msvcTool(
+  name: 'lib',
+  targetArchitecture: Architecture.x64,
+  hostArchitecture: Target.current.architecture,
+  // https://github.com/dart-lang/native/issues/18
+  resolveVersion: false,
+);
+
+final Tool libIA32 = _msvcTool(
+  name: 'lib',
+  targetArchitecture: Architecture.ia32,
+  hostArchitecture: Target.current.architecture,
+  // https://github.com/dart-lang/native/issues/18
+  resolveVersion: false,
+);
+
+final Tool libArm64 = _msvcTool(
+  name: 'lib',
+  targetArchitecture: Architecture.arm64,
+  hostArchitecture: Target.current.architecture,
+  // https://github.com/dart-lang/native/issues/18
+  resolveVersion: false,
+);
+
+final Tool link = _msvcTool(
+  name: 'link',
+  versionArguments: ['/help'],
+  versionExitCode: 1100,
+  targetArchitecture: Architecture.x64,
+  hostArchitecture: Target.current.architecture,
+);
+
+final Tool linkIA32 = _msvcTool(
+  name: 'link',
+  versionArguments: ['/help'],
+  versionExitCode: 1100,
+  targetArchitecture: Architecture.ia32,
+  hostArchitecture: Target.current.architecture,
+);
+
+final Tool linkArm64 = _msvcTool(
+  name: 'link',
+  versionArguments: ['/help'],
+  versionExitCode: 1100,
+  targetArchitecture: Architecture.arm64,
+  hostArchitecture: Target.current.architecture,
+);
+
+final Tool dumpbin = _msvcTool(
+  name: 'dumpbin',
+  targetArchitecture: Architecture.x64,
+  hostArchitecture: Target.current.architecture,
+);
+
+const _msvcArchNames = {
+  Architecture.ia32: 'x86',
+  Architecture.x64: 'x64',
+  Architecture.arm64: 'arm64',
+};
+
+Tool _msvcTool({
+  required String name,
+  required Architecture targetArchitecture,
+  required Architecture hostArchitecture,
+  List<String> versionArguments = const ['--version'],
+  int versionExitCode = 0,
+  bool resolveVersion = true,
+}) {
+  final executableName = OS.windows.executableFileName(name);
+  if (Target.current.os != OS.windows) {
+    return Tool(
+      name: executableName,
+      defaultResolver: ToolResolvers([]),
+    );
+  }
+  final hostArchName = _msvcArchNames[hostArchitecture]!;
+  final targetArchName = _msvcArchNames[targetArchitecture]!;
+  ToolResolver resolver = RelativeToolResolver(
+    toolName: executableName,
+    wrappedResolver: msvc.defaultResolver!,
+    relativePath: Uri(
+      path: 'bin/Host$hostArchName/$targetArchName/$executableName',
+    ),
+  );
+  if (resolveVersion) {
+    resolver = CliVersionResolver(
+      expectedExitCode: versionExitCode,
+      arguments: versionArguments,
+      wrappedResolver: resolver,
+    );
+  }
+  return Tool(
+    name: executableName,
+    defaultResolver: resolver,
+  );
+}
+
+class VisualStudioResolver implements ToolResolver {
+  @override
+  Future<List<ToolInstance>> resolve({required Logger? logger}) async {
+    final vswhereInstances =
+        await vswhere.defaultResolver!.resolve(logger: logger);
+
+    final result = <ToolInstance>[];
+    for (final vswhereInstance in vswhereInstances.take(1)) {
+      final vswhereResult = await runProcess(
+        executable: vswhereInstance.uri,
+        logger: logger,
+      );
+      final toolInfos = vswhereResult.stdout.split(_newLine * 2).skip(1);
+      for (final toolInfo in toolInfos) {
+        final toolInfoParsed = parseToolInfo(toolInfo);
+        final dir = Directory(toolInfoParsed['installationPath']!);
+        assert(await dir.exists());
+        final uri = dir.uri;
+        final version = versionFromString(toolInfoParsed['installationName']!);
+        final instance =
+            ToolInstance(tool: visualStudio, uri: uri, version: version);
+        logger?.fine('Found $instance.');
+        result.add(instance);
+      }
+    }
+    return result;
+  }
+
+  static Map<String, String> parseToolInfo(String toolInfo) {
+    final result = <String, String>{};
+    final lines = toolInfo.split(_newLine);
+    for (final line in lines) {
+      final splitLine = line.split(': ');
+      final key = splitLine.first;
+      final value = splitLine.skip(1).join(': ');
+      result[key] = value;
+    }
+    return result;
+  }
+}
+
+// runProcess uses writeln which uses '\n'.
+const _newLine = '\n';
diff --git a/pkgs/native_toolchain_c/lib/src/native_toolchain/recognizer.dart b/pkgs/native_toolchain_c/lib/src/native_toolchain/recognizer.dart
new file mode 100644
index 0000000..0de8418
--- /dev/null
+++ b/pkgs/native_toolchain_c/lib/src/native_toolchain/recognizer.dart
@@ -0,0 +1,148 @@
+// Copyright (c) 2023, 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:logging/logging.dart';
+import 'package:native_assets_cli/native_assets_cli.dart';
+
+import '../tool/tool.dart';
+import '../tool/tool_instance.dart';
+import '../tool/tool_resolver.dart';
+import 'apple_clang.dart';
+import 'clang.dart';
+import 'gcc.dart';
+import 'msvc.dart';
+
+class CompilerRecognizer implements ToolResolver {
+  final Uri uri;
+
+  CompilerRecognizer(this.uri);
+
+  @override
+  Future<List<ToolInstance>> resolve({required Logger? logger}) async {
+    final os = Target.current.os;
+    logger?.finer('Trying to recognize $uri.');
+    final filePath = uri.toFilePath();
+    Tool? tool;
+    if (filePath.contains('-gcc')) {
+      tool = gcc;
+    } else if (filePath.endsWith(os.executableFileName('clang'))) {
+      final stdout = await CliFilter.executeCli(uri,
+          arguments: ['--version'], logger: logger);
+      if (stdout.contains('Apple clang')) {
+        tool = appleClang;
+      } else {
+        tool = clang;
+      }
+    } else if (filePath.endsWith('cl.exe')) {
+      tool = cl;
+    }
+
+    if (tool != null) {
+      logger?.fine('Tool instance $uri is likely $tool.');
+      final toolInstance = ToolInstance(tool: tool, uri: uri);
+      return [
+        await CliVersionResolver.lookupVersion(
+          toolInstance,
+          logger: logger,
+          arguments: [
+            if (tool != cl) '--version',
+          ],
+        ),
+      ];
+    }
+
+    logger?.severe('Tool instance $uri not recognized.');
+    return [];
+  }
+}
+
+class LinkerRecognizer implements ToolResolver {
+  final Uri uri;
+
+  LinkerRecognizer(this.uri);
+
+  @override
+  Future<List<ToolInstance>> resolve({required Logger? logger}) async {
+    final os = Target.current.os;
+    logger?.finer('Trying to recognize $uri.');
+    final filePath = uri.toFilePath();
+    Tool? tool;
+    if (filePath.contains('-ld')) {
+      tool = gnuLinker;
+    } else if (filePath.endsWith(os.executableFileName('ld.lld'))) {
+      tool = lld;
+    } else if (filePath.endsWith(os.executableFileName('ld'))) {
+      tool = appleLd;
+    } else if (filePath.endsWith('link.exe')) {
+      tool = link;
+    }
+
+    if (tool != null) {
+      logger?.fine('Tool instance $uri is likely $tool.');
+      final toolInstance = ToolInstance(tool: tool, uri: uri);
+      if (tool == lld) {
+        return [
+          await CliVersionResolver.lookupVersion(
+            toolInstance,
+            logger: logger,
+          ),
+        ];
+      }
+      if (tool == link) {
+        return [
+          await CliVersionResolver.lookupVersion(
+            toolInstance,
+            logger: logger,
+            arguments: ['/help'],
+            expectedExitCode: 1100,
+          ),
+        ];
+      }
+      return [toolInstance];
+    }
+
+    logger?.severe('Tool instance $uri not recognized.');
+    return [];
+  }
+}
+
+class ArchiverRecognizer implements ToolResolver {
+  final Uri uri;
+
+  ArchiverRecognizer(this.uri);
+
+  @override
+  Future<List<ToolInstance>> resolve({required Logger? logger}) async {
+    logger?.finer('Trying to recognize $uri.');
+    final os = Target.current.os;
+    final filePath = uri.toFilePath();
+    Tool? tool;
+    if (filePath.contains('-gcc-ar')) {
+      tool = gnuArchiver;
+    } else if (filePath.endsWith(os.executableFileName('llvm-ar'))) {
+      tool = llvmAr;
+    } else if (filePath.endsWith(os.executableFileName('ar'))) {
+      tool = appleAr;
+    } else if (filePath.endsWith('lib.exe')) {
+      tool = lib;
+    }
+
+    if (tool != null) {
+      logger?.fine('Tool instance $uri is likely $tool.');
+      final toolInstance = ToolInstance(tool: tool, uri: uri);
+      if (tool == llvmAr) {
+        return [
+          await CliVersionResolver.lookupVersion(
+            toolInstance,
+            logger: logger,
+          ),
+        ];
+      }
+      return [toolInstance];
+    }
+
+    logger?.severe('Tool instance $uri not recognized.');
+    return [];
+  }
+}
diff --git a/pkgs/native_toolchain_c/lib/src/native_toolchain/xcode.dart b/pkgs/native_toolchain_c/lib/src/native_toolchain/xcode.dart
new file mode 100644
index 0000000..48c25c3
--- /dev/null
+++ b/pkgs/native_toolchain_c/lib/src/native_toolchain/xcode.dart
@@ -0,0 +1,102 @@
+// Copyright (c) 2023, 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';
+
+import 'package:logging/logging.dart';
+
+import '../tool/tool.dart';
+import '../tool/tool_instance.dart';
+import '../tool/tool_resolver.dart';
+import '../utils/run_process.dart';
+
+/// The xcrun from XCode.
+///
+/// https://developer.apple.com/xcode/
+final Tool xcrun = Tool(
+  name: 'xcrun',
+  defaultResolver: CliVersionResolver(
+    wrappedResolver: PathToolResolver(
+      toolName: 'xcrun',
+      executableName: 'xcrun',
+    ),
+  ),
+);
+
+/// The MacOSX SDK.
+final Tool macosxSdk = Tool(
+  name: 'MacOSX SDK',
+  defaultResolver: XCodeSdkResolver(),
+);
+
+/// The iPhoneOS SDK.
+final Tool iPhoneOSSdk = Tool(
+  name: 'iPhoneOS SDK',
+  defaultResolver: XCodeSdkResolver(),
+);
+
+/// The iPhoneSimulator SDK.
+final Tool iPhoneSimulatorSdk = Tool(
+  name: 'iPhoneSimulator SDK',
+  defaultResolver: XCodeSdkResolver(),
+);
+
+class XCodeSdkResolver implements ToolResolver {
+  @override
+  Future<List<ToolInstance>> resolve({required Logger? logger}) async {
+    final xcrunInstances = await xcrun.defaultResolver!.resolve(logger: logger);
+
+    return [
+      for (final xcrunInstance in xcrunInstances) ...[
+        ...await tryResolveSdk(
+          xcrunInstance: xcrunInstance,
+          sdk: 'macosx',
+          tool: macosxSdk,
+          logger: logger,
+        ),
+        ...await tryResolveSdk(
+          xcrunInstance: xcrunInstance,
+          sdk: 'iphoneos',
+          tool: iPhoneOSSdk,
+          logger: logger,
+        ),
+        ...await tryResolveSdk(
+          xcrunInstance: xcrunInstance,
+          sdk: 'iphonesimulator',
+          tool: iPhoneSimulatorSdk,
+          logger: logger,
+        ),
+      ],
+      // xcrun --sdk macosx --show-sdk-path)
+    ];
+  }
+
+  static Future<List<ToolInstance>> tryResolveSdk({
+    required ToolInstance xcrunInstance,
+    required String sdk,
+    required Tool tool,
+    required Logger? logger,
+  }) async {
+    final result = await runProcess(
+      executable: xcrunInstance.uri,
+      arguments: ['--sdk', sdk, '--show-sdk-path'],
+      logger: logger,
+    );
+    if (result.exitCode == 1) {
+      assert(result.stderr.contains('cannot be located'));
+      logger?.warning('SDK $sdk not installed.');
+      return [];
+    }
+    assert(result.exitCode == 0);
+    final uriSymbolic = Uri.directory(result.stdout.trim());
+    logger?.fine('Found $sdk at ${uriSymbolic.toFilePath()}');
+    final uri = Uri.directory(
+        await Directory.fromUri(uriSymbolic).resolveSymbolicLinks());
+    if (uriSymbolic != uri) {
+      logger?.fine('Found $sdk at ${uri.toFilePath()}');
+    }
+    assert(await Directory.fromUri(uri).exists());
+    return [ToolInstance(tool: tool, uri: uri)];
+  }
+}
diff --git a/pkgs/native_toolchain_c/lib/src/tool/tool.dart b/pkgs/native_toolchain_c/lib/src/tool/tool.dart
new file mode 100644
index 0000000..ff42f28
--- /dev/null
+++ b/pkgs/native_toolchain_c/lib/src/tool/tool.dart
@@ -0,0 +1,25 @@
+// Copyright (c) 2023, 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 'tool_resolver.dart';
+
+class Tool {
+  final String name;
+
+  ToolResolver? defaultResolver;
+
+  Tool({
+    required this.name,
+    this.defaultResolver,
+  });
+
+  @override
+  bool operator ==(Object other) => other is Tool && name == other.name;
+
+  @override
+  int get hashCode => Object.hash(name, 133709);
+
+  @override
+  String toString() => 'Tool($name)';
+}
diff --git a/pkgs/native_toolchain_c/lib/src/tool/tool_error.dart b/pkgs/native_toolchain_c/lib/src/tool/tool_error.dart
new file mode 100644
index 0000000..def9ce4
--- /dev/null
+++ b/pkgs/native_toolchain_c/lib/src/tool/tool_error.dart
@@ -0,0 +1,14 @@
+// Copyright (c) 2023, 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.
+
+/// The operation could not be performed due to a configuration error on the
+/// host system.
+class ToolError extends Error {
+  final String message;
+
+  ToolError(this.message);
+
+  @override
+  String toString() => 'System not configured correctly: $message';
+}
diff --git a/pkgs/native_toolchain_c/lib/src/tool/tool_instance.dart b/pkgs/native_toolchain_c/lib/src/tool/tool_instance.dart
new file mode 100644
index 0000000..c1a373b
--- /dev/null
+++ b/pkgs/native_toolchain_c/lib/src/tool/tool_instance.dart
@@ -0,0 +1,78 @@
+// Copyright (c) 2023, 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:pub_semver/pub_semver.dart';
+
+import 'tool.dart';
+
+class ToolInstance implements Comparable<ToolInstance> {
+  /// The name of the tool.
+  final Tool tool;
+
+  /// The path of the native tool on the system.
+  final Uri uri;
+
+  /// The version of the native tool.
+  ///
+  /// Can be null if version is hard to determine.
+  final Version? version;
+
+  ToolInstance({
+    required this.tool,
+    required this.uri,
+    this.version,
+  });
+
+  ToolInstance copyWith({
+    Uri? uri,
+    Version? version,
+  }) =>
+      ToolInstance(
+        tool: tool,
+        uri: uri ?? this.uri,
+        version: version ?? this.version,
+      );
+
+  @override
+  String toString() => 'ToolInstance(${tool.name}, $version, $uri)';
+
+  /// Compares this tool instance to [other].
+  ///
+  /// When used in sorting, orders [ToolInstance]s according to:
+  /// 1. [tool] name, alphabetically; then
+  /// 2. [version], newest first and preferring having a version; then
+  /// 3. [uri], alphabetically.
+  @override
+  int compareTo(ToolInstance other) {
+    final nameCompare = tool.name.compareTo(other.tool.name);
+    if (nameCompare != 0) {
+      return nameCompare;
+    }
+    final version = this.version;
+    final otherVersion = other.version;
+    if (version != null || otherVersion != null) {
+      if (version == null) {
+        return 1;
+      }
+      if (otherVersion == null) {
+        return -1;
+      }
+      final versionCompare = version.compareTo(otherVersion);
+      if (versionCompare != 0) {
+        return -versionCompare;
+      }
+    }
+    return uri.toFilePath().compareTo(other.uri.toFilePath());
+  }
+
+  @override
+  bool operator ==(Object other) =>
+      other is ToolInstance &&
+      tool == other.tool &&
+      uri == other.uri &&
+      version == other.version;
+
+  @override
+  int get hashCode => Object.hash(tool, uri, version);
+}
diff --git a/pkgs/native_toolchain_c/lib/src/tool/tool_requirement.dart b/pkgs/native_toolchain_c/lib/src/tool/tool_requirement.dart
new file mode 100644
index 0000000..33dba8b
--- /dev/null
+++ b/pkgs/native_toolchain_c/lib/src/tool/tool_requirement.dart
@@ -0,0 +1,92 @@
+// Copyright (c) 2023, 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:pub_semver/pub_semver.dart';
+
+import 'tool.dart';
+import 'tool_instance.dart';
+
+abstract class Requirement {
+  /// Tries to satisfy this requirement.
+  ///
+  /// If the requirement can be satisfied, returns the set of tools that
+  /// satisfy the requirement.
+  ///
+  /// Currently does not check that we only use a single version of a tool.
+  List<ToolInstance>? satisfy(List<ToolInstance> allAvailableTools);
+}
+
+class ToolRequirement implements Requirement {
+  final Tool tool;
+
+  final Version? minimumVersion;
+
+  ToolRequirement(
+    this.tool, {
+    this.minimumVersion,
+  });
+
+  @override
+  String toString() =>
+      'ToolRequirement(${tool.name}, minimumVersion: $minimumVersion)';
+
+  @override
+  List<ToolInstance>? satisfy(List<ToolInstance> availableToolInstances) {
+    final candidates = <ToolInstance>[];
+    for (final instance in availableToolInstances) {
+      if (instance.tool == tool) {
+        final minimumVersion_ = minimumVersion;
+        if (minimumVersion_ == null) {
+          candidates.add(instance);
+        } else {
+          final version = instance.version;
+          if (version != null && version >= minimumVersion_) {
+            candidates.add(instance);
+          }
+        }
+      }
+    }
+    if (candidates.isEmpty) {
+      return null;
+    }
+    candidates.sort();
+    return [candidates.first];
+  }
+}
+
+class RequireOne implements Requirement {
+  final List<Requirement> alternatives;
+
+  RequireOne(this.alternatives);
+
+  @override
+  List<ToolInstance>? satisfy(List<ToolInstance> allAvailableTools) {
+    for (final alternative in alternatives) {
+      final result = alternative.satisfy(allAvailableTools);
+      if (result != null) {
+        return result;
+      }
+    }
+    return null;
+  }
+}
+
+class RequireAll implements Requirement {
+  final List<Requirement> requirements;
+
+  RequireAll(this.requirements);
+
+  @override
+  List<ToolInstance>? satisfy(List<ToolInstance> allAvailableTools) {
+    final result = <ToolInstance>[];
+    for (final requirement in requirements) {
+      final requirementResult = requirement.satisfy(allAvailableTools);
+      if (requirementResult == null) {
+        return null;
+      }
+      result.addAll(requirementResult);
+    }
+    return result;
+  }
+}
diff --git a/pkgs/native_toolchain_c/lib/src/tool/tool_resolver.dart b/pkgs/native_toolchain_c/lib/src/tool/tool_resolver.dart
new file mode 100644
index 0000000..257ef8e
--- /dev/null
+++ b/pkgs/native_toolchain_c/lib/src/tool/tool_resolver.dart
@@ -0,0 +1,349 @@
+// Copyright (c) 2023, 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';
+
+import 'package:glob/glob.dart';
+import 'package:glob/list_local_fs.dart';
+import 'package:logging/logging.dart';
+import 'package:native_assets_cli/native_assets_cli.dart';
+import 'package:pub_semver/pub_semver.dart';
+
+import '../utils/run_process.dart';
+import '../utils/sem_version.dart';
+import 'tool.dart';
+import 'tool_error.dart';
+import 'tool_instance.dart';
+
+abstract class ToolResolver {
+  /// Resolves tools on the host system.
+  Future<List<ToolInstance>> resolve({required Logger? logger});
+}
+
+/// Tries to resolve a tool on the `PATH`.
+///
+/// Uses `which` (`where` on Windows) to resolve a tool.
+class PathToolResolver extends ToolResolver {
+  /// The [Tool.name] of the [Tool] to find on the `PATH`.
+  final String toolName;
+
+  final String executableName;
+
+  PathToolResolver({
+    required this.toolName,
+    String? executableName,
+  }) : executableName = executableName ??
+            Target.current.os.executableFileName(toolName.toLowerCase());
+
+  @override
+  Future<List<ToolInstance>> resolve({required Logger? logger}) async {
+    logger?.finer('Looking for $toolName on PATH.');
+    final uri = await runWhich(logger: logger);
+    if (uri == null) {
+      logger?.fine('Did not find  $toolName on PATH.');
+      return [];
+    }
+    final toolInstances = [
+      ToolInstance(tool: Tool(name: toolName), uri: uri),
+    ];
+    logger?.fine('Found ${toolInstances.single}.');
+    return toolInstances;
+  }
+
+  static Uri get which => Uri.file(Platform.isWindows ? 'where' : 'which');
+
+  Future<Uri?> runWhich({required Logger? logger}) async {
+    final process = await runProcess(
+      executable: which,
+      arguments: [executableName],
+      logger: logger,
+    );
+    if (process.exitCode == 0) {
+      final file = File(LineSplitter.split(process.stdout).first);
+      final uri = File(await file.resolveSymbolicLinks()).uri;
+      if (uri.pathSegments.last == 'llvm') {
+        // https://github.com/dart-lang/native/issues/136
+        return file.uri;
+      }
+      return uri;
+    }
+    // The exit code for executable not being on the `PATH`.
+    assert(process.exitCode == 1);
+    return null;
+  }
+}
+
+class CliVersionResolver implements ToolResolver {
+  final ToolResolver wrappedResolver;
+  final List<String> arguments;
+  final int expectedExitCode;
+
+  CliVersionResolver({
+    required this.wrappedResolver,
+    this.arguments = const ['--version'],
+    this.expectedExitCode = 0,
+  });
+
+  @override
+  Future<List<ToolInstance>> resolve({required Logger? logger}) async {
+    final toolInstances = await wrappedResolver.resolve(logger: logger);
+    return [
+      for (final toolInstance in toolInstances)
+        await lookupVersion(
+          toolInstance,
+          arguments: arguments,
+          expectedExitCode: expectedExitCode,
+          logger: logger,
+        )
+    ];
+  }
+
+  static Future<ToolInstance> lookupVersion(
+    ToolInstance toolInstance, {
+    List<String> arguments = const ['--version'],
+    int expectedExitCode = 0,
+    required Logger? logger,
+  }) async {
+    if (toolInstance.version != null) return toolInstance;
+    logger?.finer('Looking up version with --version for $toolInstance.');
+    final version = await executableVersion(
+      toolInstance.uri,
+      arguments: arguments,
+      expectedExitCode: expectedExitCode,
+      logger: logger,
+    );
+    final result = toolInstance.copyWith(version: version);
+    logger?.fine('Found version for $result.');
+    return result;
+  }
+
+  static Future<Version> executableVersion(
+    Uri executable, {
+    List<String> arguments = const ['--version'],
+    int expectedExitCode = 0,
+    required Logger? logger,
+  }) async {
+    final process = await runProcess(
+      executable: executable,
+      arguments: arguments,
+      logger: logger,
+    );
+    if (process.exitCode != expectedExitCode) {
+      final executablePath = executable.toFilePath();
+      throw ToolError(
+          '`$executablePath ${arguments.join(' ')}` returned unexpected exit'
+          ' code: ${process.exitCode}.');
+    }
+    return versionFromString(process.stderr) ??
+        versionFromString(process.stdout)!;
+  }
+}
+
+class PathVersionResolver implements ToolResolver {
+  ToolResolver wrappedResolver;
+
+  PathVersionResolver({required this.wrappedResolver});
+
+  @override
+  Future<List<ToolInstance>> resolve({required Logger? logger}) async {
+    final toolInstances = await wrappedResolver.resolve(logger: logger);
+
+    return [
+      for (final toolInstance in toolInstances) lookupVersion(toolInstance)
+    ];
+  }
+
+  static ToolInstance lookupVersion(ToolInstance toolInstance) {
+    if (toolInstance.version != null) {
+      return toolInstance;
+    }
+    return toolInstance.copyWith(
+      version: version(toolInstance.uri),
+    );
+  }
+
+  static Version? version(Uri uri) {
+    final versionString = uri.pathSegments.where((e) => e != '').last;
+    final version = versionFromString(versionString);
+    return version;
+  }
+}
+
+/// A resolver which invokes all [resolvers] tools.
+class ToolResolvers implements ToolResolver {
+  final List<ToolResolver> resolvers;
+
+  ToolResolvers(this.resolvers);
+
+  @override
+  Future<List<ToolInstance>> resolve({required Logger? logger}) async => [
+        for (final resolver in resolvers)
+          ...await resolver.resolve(logger: logger)
+      ];
+}
+
+class InstallLocationResolver implements ToolResolver {
+  final String toolName;
+  final List<String> paths;
+
+  InstallLocationResolver({
+    required this.toolName,
+    required this.paths,
+  });
+
+  static const home = '\$HOME';
+
+  @override
+  Future<List<ToolInstance>> resolve({required Logger? logger}) async {
+    logger?.finer('Looking for $toolName in $paths.');
+    final resolvedPaths = [
+      for (final path in paths) ...await tryResolvePath(path)
+    ];
+    final toolInstances = [
+      for (final uri in resolvedPaths)
+        ToolInstance(tool: Tool(name: toolName), uri: uri),
+    ];
+    if (toolInstances.isNotEmpty) {
+      logger?.fine('Found $toolInstances.');
+    } else {
+      logger?.finer('Found no $toolName in $paths.');
+    }
+    return toolInstances;
+  }
+
+  Future<List<Uri>> tryResolvePath(String path) async {
+    if (path.startsWith(home)) {
+      final homeDir_ = homeDir;
+      assert(homeDir_ != null);
+      path = path.replaceAll(
+          '$home/', homeDir!.toFilePath().replaceAll('\\', '/'));
+    }
+
+    final result = <Uri>[];
+    final fileSystemEntities = await Glob(path).list().toList();
+    for (final fileSystemEntity in fileSystemEntities) {
+      if (!await fileSystemEntity.exists()) {
+        continue;
+      }
+      if (fileSystemEntity is! Directory && path.endsWith('/')) {
+        continue;
+      }
+      result.add(fileSystemEntity.uri);
+    }
+    return result;
+  }
+
+  static final Uri? homeDir = () {
+    final path =
+        Platform.environment['HOME'] ?? Platform.environment['USERPROFILE'];
+    if (path == null) return null;
+    return Directory(path).uri;
+  }();
+}
+
+class RelativeToolResolver implements ToolResolver {
+  final String toolName;
+  final ToolResolver wrappedResolver;
+  final Uri relativePath;
+
+  RelativeToolResolver({
+    required this.toolName,
+    required this.wrappedResolver,
+    required this.relativePath,
+  });
+
+  @override
+  Future<List<ToolInstance>> resolve({required Logger? logger}) async {
+    final otherToolInstances = await wrappedResolver.resolve(logger: logger);
+
+    logger?.finer('Looking for $toolName relative to $otherToolInstances '
+        'with $relativePath.');
+    final globs = [
+      for (final toolInstance in otherToolInstances)
+        Glob([
+          Glob.quote(
+              toolInstance.uri.resolve('.').toFilePath().replaceAll('\\', '/')),
+          relativePath.path
+        ].join())
+    ];
+    // print(globs);
+    // exit(0);
+    final fileSystemEntities = [
+      for (final glob in globs) ...await glob.list().toList(),
+    ];
+
+    final result = [
+      for (final fileSystemEntity in fileSystemEntities)
+        ToolInstance(
+          tool: Tool(name: toolName),
+          uri: fileSystemEntity.uri,
+        ),
+    ];
+
+    if (result.isNotEmpty) {
+      logger?.fine('Found $result.');
+    } else {
+      logger?.finer('Found no $toolName relative to $otherToolInstances.');
+    }
+    return result;
+  }
+}
+
+class CliFilter implements ToolResolver {
+  final ToolResolver wrappedResolver;
+  final List<String> cliArguments;
+  final bool Function({required String stdout}) keepIf;
+
+  CliFilter({
+    required this.wrappedResolver,
+    required this.cliArguments,
+    required this.keepIf,
+  });
+
+  @override
+  Future<List<ToolInstance>> resolve({required Logger? logger}) async {
+    final toolInstances = await wrappedResolver.resolve(logger: logger);
+    return [
+      for (final toolInstance in toolInstances)
+        await filter(toolInstance, logger: logger)
+    ].whereType<ToolInstance>().toList();
+  }
+
+  Future<ToolInstance?> filter(
+    ToolInstance toolInstance, {
+    required Logger? logger,
+  }) async {
+    if (toolInstance.version != null) return toolInstance;
+    logger?.finer('Checking if $toolInstance satisfies CLI filter.');
+    final stdout = await executeCli(
+      toolInstance.uri,
+      arguments: cliArguments,
+      logger: logger,
+    );
+    final doKeep = keepIf(stdout: stdout);
+    if (doKeep) {
+      logger?.fine('$toolInstance satisfies CLI filter.');
+      return toolInstance;
+    }
+    logger?.fine('$toolInstance does not satisfy CLI filter.');
+    return null;
+  }
+
+  static Future<String> executeCli(
+    Uri executable, {
+    required List<String> arguments,
+    int expectedExitCode = 0,
+    required Logger? logger,
+  }) async {
+    final process = await runProcess(
+      executable: executable,
+      arguments: arguments,
+      logger: logger,
+    );
+    final exitCode = process.exitCode;
+    assert(exitCode == expectedExitCode);
+    return process.stdout;
+  }
+}
diff --git a/pkgs/native_toolchain_c/lib/src/utils/env_from_bat.dart b/pkgs/native_toolchain_c/lib/src/utils/env_from_bat.dart
new file mode 100644
index 0000000..31958bb
--- /dev/null
+++ b/pkgs/native_toolchain_c/lib/src/utils/env_from_bat.dart
@@ -0,0 +1,48 @@
+// Copyright (c) 2023, 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';
+
+Future<Map<String, String>> envFromBat(
+  Uri batchFile, {
+  List<String> arguments = const [],
+}) async {
+  final fileName = batchFile.pathSegments.last;
+  final dir = batchFile.resolve('.');
+  const separator = '=======';
+  final processResult = await Process.run(
+    'set && echo $separator && $fileName ${arguments.join(' ')} > nul && set',
+    [],
+    runInShell: true,
+    workingDirectory: dir.toFilePath(),
+  );
+  assert(processResult.exitCode == 0);
+  final resultSplit = (processResult.stdout as String).split(separator);
+  assert(resultSplit.length == 2);
+  final unmodifiedParsed = parseDefines(resultSplit.first.trim());
+  final modifiedParsed = parseDefines(resultSplit[1].trim());
+  final result = <String, String>{};
+  for (final entry in modifiedParsed.entries) {
+    final key = entry.key;
+    final value = entry.value;
+    if (!unmodifiedParsed.containsKey(key) || value != unmodifiedParsed[key]) {
+      result[key] = value;
+    }
+  }
+  return result;
+}
+
+// Ensures it doesn't return empty keys.
+Map<String, String> parseDefines(String defines) {
+  final result = <String, String>{};
+  final lines = defines.trim().split('\r\n');
+  for (final line in lines) {
+    if (line.isEmpty) continue;
+    final lineSplit = line.split('=');
+    final key = lineSplit.first;
+    final value = lineSplit.skip(1).join('=');
+    result[key] = value;
+  }
+  return result;
+}
diff --git a/pkgs/native_toolchain_c/lib/src/utils/run_process.dart b/pkgs/native_toolchain_c/lib/src/utils/run_process.dart
new file mode 100644
index 0000000..f9204e5
--- /dev/null
+++ b/pkgs/native_toolchain_c/lib/src/utils/run_process.dart
@@ -0,0 +1,130 @@
+// Copyright (c) 2023, 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';
+
+import 'package:logging/logging.dart';
+
+/// Runs a [Process].
+///
+/// If [logger] is provided, stream stdout and stderr to it.
+///
+/// If [captureOutput], captures stdout and stderr.
+Future<RunProcessResult> runProcess({
+  required Uri executable,
+  List<String> arguments = const [],
+  Uri? workingDirectory,
+  Map<String, String>? environment,
+  bool includeParentEnvironment = true,
+  required Logger? logger,
+  bool captureOutput = true,
+  int expectedExitCode = 0,
+  bool throwOnUnexpectedExitCode = false,
+}) async {
+  if (Platform.isWindows && !includeParentEnvironment) {
+    const winEnvKeys = [
+      'SYSTEMROOT',
+      'TEMP',
+      'TMP',
+    ];
+    environment = {
+      for (final winEnvKey in winEnvKeys)
+        winEnvKey: Platform.environment[winEnvKey]!,
+      ...?environment,
+    };
+  }
+
+  final printWorkingDir =
+      workingDirectory != null && workingDirectory != Directory.current.uri;
+  final commandString = [
+    if (printWorkingDir) '(cd ${workingDirectory.toFilePath()};',
+    ...?environment?.entries.map((entry) => '${entry.key}=${entry.value}'),
+    executable.toFilePath(),
+    ...arguments.map((a) => a.contains(' ') ? "'$a'" : a),
+    if (printWorkingDir) ')',
+  ].join(' ');
+  logger?.info('Running `$commandString`.');
+
+  final stdoutBuffer = StringBuffer();
+  final stderrBuffer = StringBuffer();
+  final process = await Process.start(
+    executable.toFilePath(),
+    arguments,
+    workingDirectory: workingDirectory?.toFilePath(),
+    environment: environment,
+    includeParentEnvironment: includeParentEnvironment,
+    runInShell: Platform.isWindows && !includeParentEnvironment,
+  );
+
+  final stdoutSub = process.stdout
+      .transform(utf8.decoder)
+      .transform(const LineSplitter())
+      .listen(captureOutput
+          ? (s) {
+              logger?.fine(s);
+              stdoutBuffer.writeln(s);
+            }
+          : logger?.fine);
+  final stderrSub = process.stderr
+      .transform(utf8.decoder)
+      .transform(const LineSplitter())
+      .listen(captureOutput
+          ? (s) {
+              logger?.severe(s);
+              stderrBuffer.writeln(s);
+            }
+          : logger?.severe);
+
+  final (exitCode, _, _) = await (
+    process.exitCode,
+    stdoutSub.asFuture<void>(),
+    stderrSub.asFuture<void>()
+  ).wait;
+  final result = RunProcessResult(
+    pid: process.pid,
+    command: commandString,
+    exitCode: exitCode,
+    stdout: stdoutBuffer.toString(),
+    stderr: stderrBuffer.toString(),
+  );
+  if (throwOnUnexpectedExitCode && expectedExitCode != exitCode) {
+    throw ProcessException(
+      executable.toFilePath(),
+      arguments,
+      "Full command string: '$commandString'.\n"
+      "Exit code: '$exitCode'.\n"
+      'For the output of the process check the logger output.',
+    );
+  }
+  return result;
+}
+
+/// Drop in replacement of [ProcessResult].
+class RunProcessResult {
+  final int pid;
+
+  final String command;
+
+  final int exitCode;
+
+  final String stderr;
+
+  final String stdout;
+
+  RunProcessResult({
+    required this.pid,
+    required this.command,
+    required this.exitCode,
+    required this.stderr,
+    required this.stdout,
+  });
+
+  @override
+  String toString() => '''command: $command
+exitCode: $exitCode
+stdout: $stdout
+stderr: $stderr''';
+}
diff --git a/pkgs/native_toolchain_c/lib/src/utils/sem_version.dart b/pkgs/native_toolchain_c/lib/src/utils/sem_version.dart
new file mode 100644
index 0000000..39f8342
--- /dev/null
+++ b/pkgs/native_toolchain_c/lib/src/utils/sem_version.dart
@@ -0,0 +1,42 @@
+// Copyright (c) 2023, 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:pub_semver/pub_semver.dart';
+
+/// Reads a version out of a string.
+///
+/// If no semantic version is found, tries to read find a version number
+/// that doesn't follow semantic versioning. It will default [Version.minor]
+/// and [Version.patch] to `0` if the relaxed version doesn't contain them.
+Version? versionFromString(String containsVersion) {
+  final match = _semverRegex.firstMatch(containsVersion);
+  if (match != null) {
+    return Version(
+      int.parse(match.group(1)!),
+      int.parse(match.group(2)!),
+      int.parse(match.group(3)!),
+      pre: match.group(4),
+      build: match.group(5),
+    );
+  }
+
+  final relaxedMatch = _semverRegexReleaxed.firstMatch(containsVersion);
+  if (relaxedMatch != null) {
+    return Version(
+      int.parse(relaxedMatch.group(1)!),
+      int.parse(relaxedMatch.group(2) ?? '0'),
+      int.parse(relaxedMatch.group(3) ?? '0'),
+      pre: relaxedMatch.group(4),
+      build: relaxedMatch.group(5),
+    );
+  }
+
+  return null;
+}
+
+final _semverRegex = RegExp(
+    r'(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?');
+
+final _semverRegexReleaxed = RegExp(
+    r'(0|[1-9]\d*)(?:\.(0|[1-9]\d*))?(?:\.(0|[1-9]\d*))?(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?');
diff --git a/pkgs/native_toolchain_c/pubspec.yaml b/pkgs/native_toolchain_c/pubspec.yaml
new file mode 100644
index 0000000..886e745
--- /dev/null
+++ b/pkgs/native_toolchain_c/pubspec.yaml
@@ -0,0 +1,28 @@
+name: native_toolchain_c
+description: >-
+  A library to invoke the native C compiler installed on the host machine.
+version: 0.3.2
+repository: https://github.com/dart-lang/native/tree/main/pkgs/native_toolchain_c
+
+topics:
+  - compiler
+  - ffi
+  - interop
+  - native-assets
+  - native-toolchain
+
+environment:
+  sdk: '>=3.1.0 <4.0.0'
+
+dependencies:
+  cli_config: ^0.1.1
+  glob: ^2.1.1
+  logging: ^1.1.1
+  meta: ^1.9.1
+  native_assets_cli: ^0.3.0
+  pub_semver: ^2.1.3
+
+dev_dependencies:
+  collection: ^1.17.1
+  dart_flutter_team_lints: ^2.1.1
+  test: ^1.21.0
diff --git a/pkgs/native_toolchain_c/test/cbuilder/cbuilder_build_failure_test.dart b/pkgs/native_toolchain_c/test/cbuilder/cbuilder_build_failure_test.dart
new file mode 100644
index 0000000..f2a73c4
--- /dev/null
+++ b/pkgs/native_toolchain_c/test/cbuilder/cbuilder_build_failure_test.dart
@@ -0,0 +1,62 @@
+// Copyright (c) 2023, 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.
+
+@OnPlatform({
+  'mac-os': Timeout.factor(2),
+  'windows': Timeout.factor(10),
+})
+library;
+
+import 'dart:io';
+
+import 'package:native_assets_cli/native_assets_cli.dart';
+import 'package:native_toolchain_c/native_toolchain_c.dart';
+import 'package:test/test.dart';
+
+import '../helpers.dart';
+
+void main() {
+  test('build failure', () async {
+    final tempUri = await tempDirForTest();
+    final addCOriginalUri =
+        packageUri.resolve('test/cbuilder/testfiles/add/src/add.c');
+    final addCUri = tempUri.resolve('add.c');
+    final addCOriginalContents =
+        await File.fromUri(addCOriginalUri).readAsString();
+    final addCBrokenContents = addCOriginalContents.replaceAll(
+        'int32_t a, int32_t b', 'int64_t blabla');
+    await File.fromUri(addCUri).writeAsString(addCBrokenContents);
+    const name = 'add';
+
+    final buildConfig = BuildConfig(
+      outDir: tempUri,
+      packageName: name,
+      packageRoot: tempUri,
+      targetArchitecture: Architecture.current,
+      targetOs: OS.current,
+      linkModePreference: LinkModePreference.dynamic,
+      buildMode: BuildMode.release,
+      cCompiler: CCompilerConfig(
+        cc: cc,
+        envScript: envScript,
+        envScriptArgs: envScriptArgs,
+      ),
+    );
+    final buildOutput = BuildOutput();
+
+    final cbuilder = CBuilder.library(
+      sources: [addCUri.toFilePath()],
+      name: name,
+      assetId: name,
+    );
+    expect(
+      () => cbuilder.run(
+        buildConfig: buildConfig,
+        buildOutput: buildOutput,
+        logger: logger,
+      ),
+      throwsException,
+    );
+  });
+}
diff --git a/pkgs/native_toolchain_c/test/cbuilder/cbuilder_cross_android_test.dart b/pkgs/native_toolchain_c/test/cbuilder/cbuilder_cross_android_test.dart
new file mode 100644
index 0000000..0f5081a
--- /dev/null
+++ b/pkgs/native_toolchain_c/test/cbuilder/cbuilder_cross_android_test.dart
@@ -0,0 +1,150 @@
+// Copyright (c) 2023, 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';
+
+import 'package:native_assets_cli/native_assets_cli.dart';
+import 'package:native_toolchain_c/native_toolchain_c.dart';
+import 'package:native_toolchain_c/src/utils/run_process.dart';
+import 'package:test/test.dart';
+
+import '../helpers.dart';
+
+void main() {
+  const targets = [
+    Target.androidArm,
+    Target.androidArm64,
+    Target.androidIA32,
+    Target.androidX64,
+  ];
+
+  const readElfMachine = {
+    Target.androidArm: 'ARM',
+    Target.androidArm64: 'AArch64',
+    Target.androidIA32: 'Intel 80386',
+    Target.androidX64: 'Advanced Micro Devices X86-64',
+  };
+
+  const objdumpFileFormat = {
+    Target.androidArm: 'elf32-littlearm',
+    Target.androidArm64: 'elf64-littleaarch64',
+    Target.androidIA32: 'elf32-i386',
+    Target.androidX64: 'elf64-x86-64',
+  };
+
+  /// From https://docs.flutter.dev/reference/supported-platforms.
+  const flutterAndroidNdkVersionLowestBestEffort = 19;
+
+  /// From https://docs.flutter.dev/reference/supported-platforms.
+  const flutterAndroidNdkVersionLowestSupported = 21;
+
+  /// From https://docs.flutter.dev/reference/supported-platforms.
+  const flutterAndroidNdkVersionHighestSupported = 34;
+
+  for (final linkMode in LinkMode.values) {
+    for (final target in targets) {
+      for (final apiLevel in [
+        flutterAndroidNdkVersionLowestBestEffort,
+        flutterAndroidNdkVersionLowestSupported,
+        flutterAndroidNdkVersionHighestSupported,
+      ]) {
+        test('CBuilder $linkMode library $target minSdkVersion $apiLevel',
+            () async {
+          final tempUri = await tempDirForTest();
+          final libUri = await buildLib(
+            tempUri,
+            target,
+            apiLevel,
+            linkMode,
+          );
+          if (Platform.isLinux) {
+            final result = await runProcess(
+              executable: Uri.file('readelf'),
+              arguments: ['-h', libUri.path],
+              logger: logger,
+            );
+            expect(result.exitCode, 0);
+            final machine = result.stdout
+                .split('\n')
+                .firstWhere((e) => e.contains('Machine:'));
+            expect(machine, contains(readElfMachine[target]));
+          } else if (Platform.isMacOS) {
+            final result = await runProcess(
+              executable: Uri.file('objdump'),
+              arguments: ['-T', libUri.path],
+              logger: logger,
+            );
+            expect(result.exitCode, 0);
+            final machine = result.stdout
+                .split('\n')
+                .firstWhere((e) => e.contains('file format'));
+            expect(machine, contains(objdumpFileFormat[target]));
+          }
+        });
+      }
+    }
+  }
+
+  test('CBuilder API levels binary difference', () async {
+    const target = Target.androidArm64;
+    const linkMode = LinkMode.dynamic;
+    const apiLevel1 = flutterAndroidNdkVersionLowestSupported;
+    const apiLevel2 = flutterAndroidNdkVersionHighestSupported;
+    final tempUri = await tempDirForTest();
+    final out1Uri = tempUri.resolve('out1/');
+    final out2Uri = tempUri.resolve('out2/');
+    final out3Uri = tempUri.resolve('out3/');
+    await Directory.fromUri(out1Uri).create();
+    await Directory.fromUri(out2Uri).create();
+    await Directory.fromUri(out3Uri).create();
+    final lib1Uri = await buildLib(out1Uri, target, apiLevel1, linkMode);
+    final lib2Uri = await buildLib(out2Uri, target, apiLevel2, linkMode);
+    final lib3Uri = await buildLib(out3Uri, target, apiLevel2, linkMode);
+    final bytes1 = await File.fromUri(lib1Uri).readAsBytes();
+    final bytes2 = await File.fromUri(lib2Uri).readAsBytes();
+    final bytes3 = await File.fromUri(lib3Uri).readAsBytes();
+    // Different API levels should lead to a different binary.
+    expect(bytes1, isNot(bytes2));
+    // Identical API levels should lead to an identical binary.
+    expect(bytes2, bytes3);
+  });
+}
+
+Future<Uri> buildLib(
+  Uri tempUri,
+  Target target,
+  int androidNdkApi,
+  LinkMode linkMode,
+) async {
+  final addCUri = packageUri.resolve('test/cbuilder/testfiles/add/src/add.c');
+  const name = 'add';
+
+  final buildConfig = BuildConfig(
+    outDir: tempUri,
+    packageName: name,
+    packageRoot: tempUri,
+    targetArchitecture: target.architecture,
+    targetOs: target.os,
+    targetAndroidNdkApi: androidNdkApi,
+    buildMode: BuildMode.release,
+    linkModePreference: linkMode == LinkMode.dynamic
+        ? LinkModePreference.dynamic
+        : LinkModePreference.static,
+  );
+  final buildOutput = BuildOutput();
+
+  final cbuilder = CBuilder.library(
+    name: name,
+    assetId: name,
+    sources: [addCUri.toFilePath()],
+  );
+  await cbuilder.run(
+    buildConfig: buildConfig,
+    buildOutput: buildOutput,
+    logger: logger,
+  );
+
+  final libUri = tempUri.resolve(target.os.libraryFileName(name, linkMode));
+  return libUri;
+}
diff --git a/pkgs/native_toolchain_c/test/cbuilder/cbuilder_cross_ios_test.dart b/pkgs/native_toolchain_c/test/cbuilder/cbuilder_cross_ios_test.dart
new file mode 100644
index 0000000..586f7ff
--- /dev/null
+++ b/pkgs/native_toolchain_c/test/cbuilder/cbuilder_cross_ios_test.dart
@@ -0,0 +1,151 @@
+// Copyright (c) 2023, 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.
+
+@TestOn('mac-os')
+@OnPlatform({
+  'mac-os': Timeout.factor(2),
+})
+library;
+
+import 'dart:io';
+
+import 'package:native_assets_cli/native_assets_cli.dart';
+import 'package:native_toolchain_c/native_toolchain_c.dart';
+import 'package:native_toolchain_c/src/utils/run_process.dart';
+import 'package:test/test.dart';
+
+import '../helpers.dart';
+
+void main() {
+  if (!Platform.isMacOS) {
+    // Avoid needing status files on Dart SDK CI.
+    return;
+  }
+
+  const targets = [
+    Target.iOSArm64,
+    Target.iOSX64,
+  ];
+
+  // Dont include 'mach-o' or 'Mach-O', different spelling is used.
+  const objdumpFileFormat = {
+    Target.iOSArm64: 'arm64',
+    Target.iOSX64: '64-bit x86-64',
+  };
+
+  const name = 'add';
+
+  for (final linkMode in LinkMode.values) {
+    for (final targetIOSSdk in IOSSdk.values) {
+      for (final target in targets) {
+        if (target == Target.iOSX64 && targetIOSSdk == IOSSdk.iPhoneOs) {
+          continue;
+        }
+
+        final libName = target.os.libraryFileName(name, linkMode);
+        for (final installName in [
+          null,
+          if (linkMode == LinkMode.dynamic)
+            Uri.file('@executable_path/Frameworks/$libName'),
+        ]) {
+          test(
+              'CBuilder $linkMode library $targetIOSSdk $target'
+                      ' ${installName ?? ''}'
+                  .trim(), () async {
+            final tempUri = await tempDirForTest();
+            final addCUri =
+                packageUri.resolve('test/cbuilder/testfiles/add/src/add.c');
+            final buildConfig = BuildConfig(
+              outDir: tempUri,
+              packageName: name,
+              packageRoot: tempUri,
+              targetArchitecture: target.architecture,
+              targetOs: target.os,
+              buildMode: BuildMode.release,
+              linkModePreference: linkMode == LinkMode.dynamic
+                  ? LinkModePreference.dynamic
+                  : LinkModePreference.static,
+              targetIOSSdk: targetIOSSdk,
+            );
+            final buildOutput = BuildOutput();
+
+            final cbuilder = CBuilder.library(
+              name: name,
+              assetId: name,
+              sources: [addCUri.toFilePath()],
+              installName: installName,
+            );
+            await cbuilder.run(
+              buildConfig: buildConfig,
+              buildOutput: buildOutput,
+              logger: logger,
+            );
+
+            final libUri = tempUri.resolve(libName);
+            final objdumpResult = await runProcess(
+              executable: Uri.file('objdump'),
+              arguments: ['-t', libUri.path],
+              logger: logger,
+            );
+            expect(objdumpResult.exitCode, 0);
+            final machine = objdumpResult.stdout
+                .split('\n')
+                .firstWhere((e) => e.contains('file format'));
+            expect(machine, contains(objdumpFileFormat[target]));
+
+            final otoolResult = await runProcess(
+              executable: Uri.file('otool'),
+              arguments: ['-l', libUri.path],
+              logger: logger,
+            );
+            expect(otoolResult.exitCode, 0);
+            if (targetIOSSdk == IOSSdk.iPhoneOs || target == Target.iOSX64) {
+              // The x64 simulator behaves as device, presumably because the
+              // devices are never x64.
+              expect(otoolResult.stdout, contains('LC_VERSION_MIN_IPHONEOS'));
+              expect(otoolResult.stdout, isNot(contains('LC_BUILD_VERSION')));
+            } else {
+              expect(otoolResult.stdout,
+                  isNot(contains('LC_VERSION_MIN_IPHONEOS')));
+              expect(otoolResult.stdout, contains('LC_BUILD_VERSION'));
+              final platform = otoolResult.stdout
+                  .split('\n')
+                  .firstWhere((e) => e.contains('platform'));
+              const platformIosSimulator = 7;
+              expect(platform, contains(platformIosSimulator.toString()));
+            }
+
+            if (linkMode == LinkMode.dynamic) {
+              final libInstallName = await runOtoolInstallName(libUri, libName);
+              if (installName == null) {
+                // If no install path is passed, we have an absolute path.
+                final tempName = tempUri.pathSegments.lastWhere((e) => e != '');
+                final pathEnding =
+                    Uri.directory(tempName).resolve(libName).toFilePath();
+                expect(Uri.file(libInstallName).isAbsolute, true);
+                expect(libInstallName, contains(pathEnding));
+                final targetInstallName =
+                    '@executable_path/Frameworks/$libName';
+                await runProcess(
+                  executable: Uri.file('install_name_tool'),
+                  arguments: [
+                    '-id',
+                    targetInstallName,
+                    libUri.toFilePath(),
+                  ],
+                  logger: logger,
+                );
+                final libInstallName2 =
+                    await runOtoolInstallName(libUri, libName);
+                expect(libInstallName2, targetInstallName);
+              } else {
+                expect(libInstallName, installName.toFilePath());
+              }
+            }
+          });
+        }
+      }
+    }
+  }
+}
diff --git a/pkgs/native_toolchain_c/test/cbuilder/cbuilder_cross_linux_host_test.dart b/pkgs/native_toolchain_c/test/cbuilder/cbuilder_cross_linux_host_test.dart
new file mode 100644
index 0000000..8abe7a4
--- /dev/null
+++ b/pkgs/native_toolchain_c/test/cbuilder/cbuilder_cross_linux_host_test.dart
@@ -0,0 +1,86 @@
+// Copyright (c) 2023, 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.
+
+@TestOn('linux')
+library;
+
+import 'dart:io';
+
+import 'package:native_assets_cli/native_assets_cli.dart';
+import 'package:native_toolchain_c/native_toolchain_c.dart';
+import 'package:native_toolchain_c/src/utils/run_process.dart';
+import 'package:test/test.dart';
+
+import '../helpers.dart';
+
+void main() {
+  if (!Platform.isLinux) {
+    // Avoid needing status files on Dart SDK CI.
+    return;
+  }
+
+  const targets = [
+    Target.linuxArm,
+    Target.linuxArm64,
+    Target.linuxIA32,
+    Target.linuxX64,
+    Target.linuxRiscv64,
+  ];
+
+  const readElfMachine = {
+    Target.linuxArm: 'ARM',
+    Target.linuxArm64: 'AArch64',
+    Target.linuxIA32: 'Intel 80386',
+    Target.linuxX64: 'Advanced Micro Devices X86-64',
+    Target.linuxRiscv64: 'RISC-V',
+  };
+
+  for (final linkMode in LinkMode.values) {
+    for (final target in targets) {
+      test('CBuilder $linkMode library $target', () async {
+        final tempUri = await tempDirForTest();
+        final addCUri =
+            packageUri.resolve('test/cbuilder/testfiles/add/src/add.c');
+        const name = 'add';
+
+        final buildConfig = BuildConfig(
+          outDir: tempUri,
+          packageName: name,
+          packageRoot: tempUri,
+          targetArchitecture: target.architecture,
+          targetOs: target.os,
+          buildMode: BuildMode.release,
+          linkModePreference: linkMode == LinkMode.dynamic
+              ? LinkModePreference.dynamic
+              : LinkModePreference.static,
+        );
+        final buildOutput = BuildOutput();
+
+        final cbuilder = CBuilder.library(
+          name: name,
+          assetId: name,
+          sources: [addCUri.toFilePath()],
+        );
+        await cbuilder.run(
+          buildConfig: buildConfig,
+          buildOutput: buildOutput,
+          logger: logger,
+        );
+
+        final libUri =
+            tempUri.resolve(target.os.libraryFileName(name, linkMode));
+        final result = await runProcess(
+          executable: Uri.file('readelf'),
+          arguments: ['-h', libUri.path],
+          logger: logger,
+        );
+        expect(result.exitCode, 0);
+        final machine =
+            result.stdout.split('\n').firstWhere((e) => e.contains('Machine:'));
+        expect(machine, contains(readElfMachine[target]));
+        expect(result.exitCode, 0);
+      });
+    }
+  }
+}
diff --git a/pkgs/native_toolchain_c/test/cbuilder/cbuilder_cross_macos_host_test.dart b/pkgs/native_toolchain_c/test/cbuilder/cbuilder_cross_macos_host_test.dart
new file mode 100644
index 0000000..655ea25
--- /dev/null
+++ b/pkgs/native_toolchain_c/test/cbuilder/cbuilder_cross_macos_host_test.dart
@@ -0,0 +1,84 @@
+// Copyright (c) 2023, 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.
+
+@TestOn('mac-os')
+@OnPlatform({
+  'mac-os': Timeout.factor(2),
+})
+library;
+
+import 'dart:io';
+
+import 'package:native_assets_cli/native_assets_cli.dart';
+import 'package:native_toolchain_c/native_toolchain_c.dart';
+import 'package:native_toolchain_c/src/utils/run_process.dart';
+import 'package:test/test.dart';
+
+import '../helpers.dart';
+
+void main() {
+  if (!Platform.isMacOS) {
+    // Avoid needing status files on Dart SDK CI.
+    return;
+  }
+
+  const targets = [
+    Target.macOSArm64,
+    Target.macOSX64,
+  ];
+
+  // Dont include 'mach-o' or 'Mach-O', different spelling is used.
+  const objdumpFileFormat = {
+    Target.macOSArm64: 'arm64',
+    Target.macOSX64: '64-bit x86-64',
+  };
+
+  for (final linkMode in LinkMode.values) {
+    for (final target in targets) {
+      test('CBuilder $linkMode library $target', () async {
+        final tempUri = await tempDirForTest();
+        final addCUri =
+            packageUri.resolve('test/cbuilder/testfiles/add/src/add.c');
+        const name = 'add';
+
+        final buildConfig = BuildConfig(
+          outDir: tempUri,
+          packageName: name,
+          packageRoot: tempUri,
+          targetArchitecture: target.architecture,
+          targetOs: target.os,
+          buildMode: BuildMode.release,
+          linkModePreference: linkMode == LinkMode.dynamic
+              ? LinkModePreference.dynamic
+              : LinkModePreference.static,
+        );
+        final buildOutput = BuildOutput();
+
+        final cbuilder = CBuilder.library(
+          name: name,
+          assetId: name,
+          sources: [addCUri.toFilePath()],
+        );
+        await cbuilder.run(
+          buildConfig: buildConfig,
+          buildOutput: buildOutput,
+          logger: logger,
+        );
+
+        final libUri =
+            tempUri.resolve(target.os.libraryFileName(name, linkMode));
+        final result = await runProcess(
+          executable: Uri.file('objdump'),
+          arguments: ['-t', libUri.path],
+          logger: logger,
+        );
+        expect(result.exitCode, 0);
+        final machine = result.stdout
+            .split('\n')
+            .firstWhere((e) => e.contains('file format'));
+        expect(machine, contains(objdumpFileFormat[target]));
+      });
+    }
+  }
+}
diff --git a/pkgs/native_toolchain_c/test/cbuilder/cbuilder_cross_windows_host_test.dart b/pkgs/native_toolchain_c/test/cbuilder/cbuilder_cross_windows_host_test.dart
new file mode 100644
index 0000000..bf0e836
--- /dev/null
+++ b/pkgs/native_toolchain_c/test/cbuilder/cbuilder_cross_windows_host_test.dart
@@ -0,0 +1,100 @@
+// Copyright (c) 2023, 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.
+
+@TestOn('windows')
+@OnPlatform({
+  'windows': Timeout.factor(10),
+})
+library;
+
+import 'dart:io';
+
+import 'package:native_assets_cli/native_assets_cli.dart';
+import 'package:native_toolchain_c/native_toolchain_c.dart';
+import 'package:native_toolchain_c/src/native_toolchain/msvc.dart';
+import 'package:native_toolchain_c/src/utils/run_process.dart';
+import 'package:test/test.dart';
+
+import '../helpers.dart';
+
+void main() {
+  if (!Platform.isWindows) {
+    // Avoid needing status files on Dart SDK CI.
+    return;
+  }
+
+  const targets = [
+    Target.windowsIA32,
+    Target.windowsX64,
+  ];
+
+  late Uri dumpbinUri;
+
+  setUp(() async {
+    dumpbinUri =
+        (await dumpbin.defaultResolver!.resolve(logger: logger)).first.uri;
+  });
+
+  const dumpbinMachine = {
+    Target.windowsIA32: 'x86',
+    Target.windowsX64: 'x64',
+  };
+
+  const dumpbinFileType = {
+    LinkMode.dynamic: 'DLL',
+    LinkMode.static: 'LIBRARY',
+  };
+
+  for (final linkMode in LinkMode.values) {
+    for (final target in targets) {
+      test('CBuilder $linkMode library $target', () async {
+        final tempUri = await tempDirForTest();
+        final addCUri =
+            packageUri.resolve('test/cbuilder/testfiles/add/src/add.c');
+        const name = 'add';
+
+        final buildConfig = BuildConfig(
+          outDir: tempUri,
+          packageName: name,
+          packageRoot: tempUri,
+          targetOs: target.os,
+          targetArchitecture: target.architecture,
+          buildMode: BuildMode.release,
+          linkModePreference: linkMode == LinkMode.dynamic
+              ? LinkModePreference.dynamic
+              : LinkModePreference.static,
+        );
+        final buildOutput = BuildOutput();
+
+        final cbuilder = CBuilder.library(
+          name: name,
+          assetId: name,
+          sources: [addCUri.toFilePath()],
+        );
+        await cbuilder.run(
+          buildConfig: buildConfig,
+          buildOutput: buildOutput,
+          logger: logger,
+        );
+
+        final libUri =
+            tempUri.resolve(target.os.libraryFileName(name, linkMode));
+        expect(await File.fromUri(libUri).exists(), true);
+        final result = await runProcess(
+          executable: dumpbinUri,
+          arguments: ['/HEADERS', libUri.toFilePath()],
+          logger: logger,
+        );
+        expect(result.exitCode, 0);
+        final machine =
+            result.stdout.split('\n').firstWhere((e) => e.contains('machine'));
+        expect(machine, contains(dumpbinMachine[target]));
+        final fileType = result.stdout
+            .split('\n')
+            .firstWhere((e) => e.contains('File Type'));
+        expect(fileType, contains(dumpbinFileType[linkMode]));
+      });
+    }
+  }
+}
diff --git a/pkgs/native_toolchain_c/test/cbuilder/cbuilder_test.dart b/pkgs/native_toolchain_c/test/cbuilder/cbuilder_test.dart
new file mode 100644
index 0000000..53b5a96
--- /dev/null
+++ b/pkgs/native_toolchain_c/test/cbuilder/cbuilder_test.dart
@@ -0,0 +1,593 @@
+// Copyright (c) 2023, 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.
+
+@OnPlatform({
+  'mac-os': Timeout.factor(2),
+  'windows': Timeout.factor(10),
+})
+library;
+
+import 'dart:ffi';
+import 'dart:io';
+
+import 'package:native_assets_cli/native_assets_cli.dart';
+import 'package:native_toolchain_c/native_toolchain_c.dart';
+import 'package:native_toolchain_c/src/utils/run_process.dart';
+import 'package:test/test.dart';
+
+import '../helpers.dart';
+
+void main() {
+  test('Langauge.toString', () {
+    expect(Language.c.toString(), 'c');
+    expect(Language.cpp.toString(), 'c++');
+  });
+
+  for (final pic in [null, true, false]) {
+    final picTag =
+        switch (pic) { null => 'auto_pic', true => 'pic', false => 'no_pic' };
+
+    for (final buildMode in BuildMode.values) {
+      final suffix = testSuffix([buildMode, picTag]);
+
+      test('CBuilder executable$suffix', () async {
+        final tempUri = await tempDirForTest();
+        final helloWorldCUri = packageUri
+            .resolve('test/cbuilder/testfiles/hello_world/src/hello_world.c');
+        if (!await File.fromUri(helloWorldCUri).exists()) {
+          throw Exception('Run the test from the root directory.');
+        }
+        const name = 'hello_world';
+
+        final logMessages = <String>[];
+        final logger = createCapturingLogger(logMessages);
+
+        final buildConfig = BuildConfig(
+          outDir: tempUri,
+          packageName: name,
+          packageRoot: tempUri,
+          targetArchitecture: Architecture.current,
+          targetOs: OS.current,
+          buildMode: buildMode,
+          // Ignored by executables.
+          linkModePreference: LinkModePreference.dynamic,
+          cCompiler: CCompilerConfig(
+            cc: cc,
+            envScript: envScript,
+            envScriptArgs: envScriptArgs,
+          ),
+        );
+        final buildOutput = BuildOutput();
+        final cbuilder = CBuilder.executable(
+          name: name,
+          sources: [helloWorldCUri.toFilePath()],
+          pie: pic,
+        );
+        await cbuilder.run(
+          buildConfig: buildConfig,
+          buildOutput: buildOutput,
+          logger: logger,
+        );
+
+        final executableUri =
+            tempUri.resolve(Target.current.os.executableFileName(name));
+        expect(await File.fromUri(executableUri).exists(), true);
+        final result = await runProcess(
+          executable: executableUri,
+          logger: logger,
+        );
+        expect(result.exitCode, 0);
+        if (buildMode == BuildMode.debug) {
+          expect(result.stdout.trim(), startsWith('Running in debug mode.'));
+        }
+        expect(result.stdout.trim(), endsWith('Hello world.'));
+
+        final compilerInvocation = logMessages.singleWhere(
+          (message) => message.contains(helloWorldCUri.toFilePath()),
+        );
+
+        switch ((buildConfig.targetOs, pic)) {
+          case (OS.windows, _) || (_, null):
+            expect(compilerInvocation, isNot(contains('-fPIC')));
+            expect(compilerInvocation, isNot(contains('-fPIE')));
+            expect(compilerInvocation, isNot(contains('-fno-PIC')));
+            expect(compilerInvocation, isNot(contains('-fno-PIE')));
+          case (_, true):
+            expect(compilerInvocation, contains('-fPIE'));
+          case (_, false):
+            expect(compilerInvocation, contains('-fno-PIC'));
+            expect(compilerInvocation, contains('-fno-PIE'));
+        }
+      });
+    }
+
+    for (final dryRun in [true, false]) {
+      final suffix = testSuffix([if (dryRun) 'dry_run', picTag]);
+
+      test('CBuilder dylib$suffix', () async {
+        final tempUri = await tempDirForTest();
+        final addCUri =
+            packageUri.resolve('test/cbuilder/testfiles/add/src/add.c');
+        const name = 'add';
+
+        final logMessages = <String>[];
+        final logger = createCapturingLogger(logMessages);
+
+        final buildConfig = dryRun
+            ? BuildConfig.dryRun(
+                outDir: tempUri,
+                packageName: name,
+                packageRoot: tempUri,
+                targetOs: OS.current,
+                linkModePreference: LinkModePreference.dynamic,
+              )
+            : BuildConfig(
+                outDir: tempUri,
+                packageName: name,
+                packageRoot: tempUri,
+                targetArchitecture: Architecture.current,
+                targetOs: OS.current,
+                buildMode: BuildMode.release,
+                linkModePreference: LinkModePreference.dynamic,
+                cCompiler: CCompilerConfig(
+                  cc: cc,
+                  envScript: envScript,
+                  envScriptArgs: envScriptArgs,
+                ),
+              );
+        final buildOutput = BuildOutput();
+
+        final cbuilder = CBuilder.library(
+          sources: [addCUri.toFilePath()],
+          name: name,
+          assetId: name,
+          pic: pic,
+        );
+        await cbuilder.run(
+          buildConfig: buildConfig,
+          buildOutput: buildOutput,
+          logger: logger,
+        );
+
+        final dylibUri = tempUri.resolve(Target.current.os.dylibFileName(name));
+        expect(await File.fromUri(dylibUri).exists(), !dryRun);
+        if (!dryRun) {
+          final dylib = openDynamicLibraryForTest(dylibUri.toFilePath());
+          final add = dylib.lookupFunction<Int32 Function(Int32, Int32),
+              int Function(int, int)>('add');
+          expect(add(1, 2), 3);
+
+          final compilerInvocation = logMessages.singleWhere(
+            (message) => message.contains(addCUri.toFilePath()),
+          );
+          switch ((buildConfig.targetOs, pic)) {
+            case (OS.windows, _) || (_, null):
+              expect(compilerInvocation, isNot(contains('-fPIC')));
+              expect(compilerInvocation, isNot(contains('-fPIE')));
+              expect(compilerInvocation, isNot(contains('-fno-PIC')));
+              expect(compilerInvocation, isNot(contains('-fno-PIE')));
+            case (_, true):
+              expect(compilerInvocation, contains('-fPIC'));
+            case (_, false):
+              expect(compilerInvocation, contains('-fno-PIC'));
+              expect(compilerInvocation, contains('-fno-PIE'));
+          }
+        }
+      });
+    }
+  }
+
+  for (final buildMode in BuildMode.values) {
+    for (final enabled in [true, false]) {
+      final suffix = testSuffix([buildMode, enabled ? 'enabled' : 'disabled']);
+
+      test(
+        'CBuilder build mode defines$suffix',
+        () => testDefines(
+          buildMode: buildMode,
+          buildModeDefine: enabled,
+          ndebugDefine: enabled,
+        ),
+      );
+    }
+  }
+
+  for (final value in [true, false]) {
+    final suffix = testSuffix([value ? 'with_value' : 'without_value']);
+
+    test(
+      'CBuilder define$suffix',
+      () => testDefines(customDefineWithValue: value),
+    );
+  }
+
+  test('CBuilder flags', () async {
+    final tempUri = await tempDirForTest();
+    final definesCUri =
+        packageUri.resolve('test/cbuilder/testfiles/defines/src/defines.c');
+    if (!await File.fromUri(definesCUri).exists()) {
+      throw Exception('Run the test from the root directory.');
+    }
+    const name = 'defines';
+
+    final logMessages = <String>[];
+    final logger = createCapturingLogger(logMessages);
+
+    final buildConfig = BuildConfig(
+      outDir: tempUri,
+      packageName: name,
+      packageRoot: tempUri,
+      targetArchitecture: Architecture.current,
+      targetOs: OS.current,
+      buildMode: BuildMode.release,
+      // Ignored by executables.
+      linkModePreference: LinkModePreference.dynamic,
+      cCompiler: CCompilerConfig(
+        cc: cc,
+        envScript: envScript,
+        envScriptArgs: envScriptArgs,
+      ),
+    );
+    final buildOutput = BuildOutput();
+
+    final flag = switch (buildConfig.targetOs) {
+      OS.windows => '/DFOO=USER_FLAG',
+      _ => '-DFOO=USER_FLAG',
+    };
+
+    final cbuilder = CBuilder.executable(
+      name: name,
+      sources: [definesCUri.toFilePath()],
+      flags: [flag],
+    );
+    await cbuilder.run(
+      buildConfig: buildConfig,
+      buildOutput: buildOutput,
+      logger: logger,
+    );
+
+    final executableUri =
+        tempUri.resolve(Target.current.os.executableFileName(name));
+    expect(await File.fromUri(executableUri).exists(), true);
+    final result = await runProcess(
+      executable: executableUri,
+      logger: logger,
+    );
+    expect(result.exitCode, 0);
+    expect(result.stdout, contains('Macro FOO is defined: USER_FLAG'));
+
+    final compilerInvocation = logMessages.singleWhere(
+      (message) => message.contains(definesCUri.toFilePath()),
+    );
+    expect(compilerInvocation, contains(flag));
+  });
+
+  test('CBuilder includes', () async {
+    final tempUri = await tempDirForTest();
+    final includeDirectoryUri =
+        packageUri.resolve('test/cbuilder/testfiles/includes/include');
+    final includesHUri = packageUri
+        .resolve('test/cbuilder/testfiles/includes/include/includes.h');
+    final includesCUri =
+        packageUri.resolve('test/cbuilder/testfiles/includes/src/includes.c');
+    const name = 'includes';
+
+    final buildConfig = BuildConfig(
+      outDir: tempUri,
+      packageName: name,
+      packageRoot: tempUri,
+      targetArchitecture: Architecture.current,
+      targetOs: OS.current,
+      buildMode: BuildMode.release,
+      linkModePreference: LinkModePreference.dynamic,
+      cCompiler: CCompilerConfig(
+        cc: cc,
+        envScript: envScript,
+        envScriptArgs: envScriptArgs,
+      ),
+    );
+    final buildOutput = BuildOutput();
+
+    final cbuilder = CBuilder.library(
+      name: name,
+      assetId: name,
+      includes: [includeDirectoryUri.toFilePath()],
+      sources: [includesCUri.toFilePath()],
+    );
+    await cbuilder.run(
+      buildConfig: buildConfig,
+      buildOutput: buildOutput,
+      logger: logger,
+    );
+
+    expect(buildOutput.dependencies.dependencies, contains(includesHUri));
+
+    final dylibUri = tempUri.resolve(Target.current.os.dylibFileName(name));
+    final dylib = openDynamicLibraryForTest(dylibUri.toFilePath());
+    final x = dylib.lookup<Int>('x');
+    expect(x.value, 42);
+  });
+
+  test('CBuilder std', () async {
+    final tempUri = await tempDirForTest();
+    final addCUri = packageUri.resolve('test/cbuilder/testfiles/add/src/add.c');
+    const name = 'add';
+    const std = 'c99';
+
+    final logMessages = <String>[];
+    final logger = createCapturingLogger(logMessages);
+
+    final buildConfig = BuildConfig(
+      outDir: tempUri,
+      packageName: name,
+      packageRoot: tempUri,
+      targetArchitecture: Architecture.current,
+      targetOs: OS.current,
+      buildMode: BuildMode.release,
+      linkModePreference: LinkModePreference.dynamic,
+      cCompiler: CCompilerConfig(
+        cc: cc,
+        envScript: envScript,
+        envScriptArgs: envScriptArgs,
+      ),
+    );
+    final buildOutput = BuildOutput();
+
+    final stdFlag = switch (buildConfig.targetOs) {
+      OS.windows => '/std:$std',
+      _ => '-std=$std',
+    };
+
+    final cbuilder = CBuilder.library(
+      sources: [addCUri.toFilePath()],
+      name: name,
+      assetId: name,
+      std: std,
+    );
+    await cbuilder.run(
+      buildConfig: buildConfig,
+      buildOutput: buildOutput,
+      logger: logger,
+    );
+
+    final dylibUri = tempUri.resolve(Target.current.os.dylibFileName(name));
+
+    final dylib = openDynamicLibraryForTest(dylibUri.toFilePath());
+    final add = dylib.lookupFunction<Int32 Function(Int32, Int32),
+        int Function(int, int)>('add');
+    expect(add(1, 2), 3);
+
+    final compilerInvocation = logMessages.singleWhere(
+      (message) => message.contains(addCUri.toFilePath()),
+    );
+    expect(compilerInvocation, contains(stdFlag));
+  });
+
+  test('CBuilder compile c++', () async {
+    final tempUri = await tempDirForTest();
+    final helloWorldCppUri = packageUri.resolve(
+        'test/cbuilder/testfiles/hello_world_cpp/src/hello_world_cpp.cc');
+    if (!await File.fromUri(helloWorldCppUri).exists()) {
+      throw Exception('Run the test from the root directory.');
+    }
+    const name = 'hello_world_cpp';
+
+    final logMessages = <String>[];
+    final logger = createCapturingLogger(logMessages);
+
+    final buildConfig = BuildConfig(
+      buildMode: BuildMode.release,
+      outDir: tempUri,
+      packageName: name,
+      packageRoot: tempUri,
+      targetArchitecture: Architecture.current,
+      targetOs: OS.current,
+      // Ignored by executables.
+      linkModePreference: LinkModePreference.dynamic,
+      cCompiler: CCompilerConfig(
+        cc: cc,
+        envScript: envScript,
+        envScriptArgs: envScriptArgs,
+      ),
+    );
+    final buildOutput = BuildOutput();
+
+    final defaultStdLibLinkFlag = switch (buildConfig.targetOs) {
+      OS.windows => null,
+      OS.linux => '-l stdc++',
+      OS.macOS => '-l c++',
+      _ => throw UnimplementedError(),
+    };
+
+    final cbuilder = CBuilder.executable(
+      name: name,
+      sources: [helloWorldCppUri.toFilePath()],
+      language: Language.cpp,
+    );
+    await cbuilder.run(
+      buildConfig: buildConfig,
+      buildOutput: buildOutput,
+      logger: logger,
+    );
+
+    final executableUri =
+        tempUri.resolve(Target.current.os.executableFileName(name));
+    expect(await File.fromUri(executableUri).exists(), true);
+    final result = await runProcess(
+      executable: executableUri,
+      logger: logger,
+    );
+    expect(result.exitCode, 0);
+    expect(result.stdout.trim(), endsWith('Hello world.'));
+
+    if (defaultStdLibLinkFlag != null) {
+      final compilerInvocation = logMessages.singleWhere(
+        (message) => message.contains(helloWorldCppUri.toFilePath()),
+      );
+      expect(compilerInvocation, contains(defaultStdLibLinkFlag));
+    }
+  });
+
+  test('CBuilder cppLinkStdLib', () async {
+    final tempUri = await tempDirForTest();
+    final helloWorldCppUri = packageUri.resolve(
+        'test/cbuilder/testfiles/hello_world_cpp/src/hello_world_cpp.cc');
+    if (!await File.fromUri(helloWorldCppUri).exists()) {
+      throw Exception('Run the test from the root directory.');
+    }
+    const name = 'hello_world_cpp';
+
+    final logMessages = <String>[];
+    final logger = createCapturingLogger(logMessages);
+
+    final buildConfig = BuildConfig(
+      buildMode: BuildMode.release,
+      outDir: tempUri,
+      packageName: name,
+      packageRoot: tempUri,
+      targetArchitecture: Architecture.current,
+      targetOs: OS.current,
+      // Ignored by executables.
+      linkModePreference: LinkModePreference.dynamic,
+      cCompiler: CCompilerConfig(
+        cc: cc,
+        envScript: envScript,
+        envScriptArgs: envScriptArgs,
+      ),
+    );
+    final buildOutput = BuildOutput();
+    final cbuilder = CBuilder.executable(
+      name: name,
+      sources: [helloWorldCppUri.toFilePath()],
+      language: Language.cpp,
+      cppLinkStdLib: 'stdc++',
+    );
+
+    if (buildConfig.targetOs == OS.windows) {
+      await expectLater(
+        () => cbuilder.run(
+          buildConfig: buildConfig,
+          buildOutput: buildOutput,
+          logger: logger,
+        ),
+        throwsArgumentError,
+      );
+    } else {
+      await cbuilder.run(
+        buildConfig: buildConfig,
+        buildOutput: buildOutput,
+        logger: logger,
+      );
+
+      final executableUri =
+          tempUri.resolve(Target.current.os.executableFileName(name));
+      expect(await File.fromUri(executableUri).exists(), true);
+      final result = await runProcess(
+        executable: executableUri,
+        logger: logger,
+      );
+      expect(result.exitCode, 0);
+      expect(result.stdout.trim(), endsWith('Hello world.'));
+
+      final compilerInvocation = logMessages.singleWhere(
+        (message) => message.contains(helloWorldCppUri.toFilePath()),
+      );
+      expect(compilerInvocation, contains('-l stdc++'));
+    }
+  });
+}
+
+Future<void> testDefines({
+  BuildMode buildMode = BuildMode.debug,
+  bool buildModeDefine = false,
+  bool ndebugDefine = false,
+  bool? customDefineWithValue,
+}) async {
+  final tempUri = await tempDirForTest();
+  final definesCUri =
+      packageUri.resolve('test/cbuilder/testfiles/defines/src/defines.c');
+  if (!await File.fromUri(definesCUri).exists()) {
+    throw Exception('Run the test from the root directory.');
+  }
+  const name = 'defines';
+
+  final buildConfig = BuildConfig(
+    outDir: tempUri,
+    packageName: name,
+    packageRoot: tempUri,
+    targetArchitecture: Architecture.current,
+    targetOs: OS.current,
+    buildMode: buildMode,
+    // Ignored by executables.
+    linkModePreference: LinkModePreference.dynamic,
+    cCompiler: CCompilerConfig(
+      cc: cc,
+      envScript: envScript,
+      envScriptArgs: envScriptArgs,
+    ),
+  );
+  final buildOutput = BuildOutput();
+  final cbuilder = CBuilder.executable(
+    name: name,
+    sources: [definesCUri.toFilePath()],
+    defines: {
+      if (customDefineWithValue != null)
+        'FOO': customDefineWithValue ? 'BAR' : null,
+    },
+    buildModeDefine: buildModeDefine,
+    ndebugDefine: ndebugDefine,
+  );
+  await cbuilder.run(
+    buildConfig: buildConfig,
+    buildOutput: buildOutput,
+    logger: logger,
+  );
+
+  final executableUri =
+      tempUri.resolve(Target.current.os.executableFileName(name));
+  expect(await File.fromUri(executableUri).exists(), true);
+  final result = await runProcess(
+    executable: executableUri,
+    logger: logger,
+  );
+  expect(result.exitCode, 0);
+
+  if (buildModeDefine) {
+    expect(
+      result.stdout,
+      contains('Macro ${buildMode.name.toUpperCase()} is defined: 1'),
+    );
+  } else {
+    expect(
+      result.stdout,
+      contains('Macro ${buildMode.name.toUpperCase()} is undefined.'),
+    );
+  }
+
+  if (ndebugDefine && buildMode != BuildMode.debug) {
+    expect(
+      result.stdout,
+      contains('Macro NDEBUG is defined: 1'),
+    );
+  } else {
+    expect(
+      result.stdout,
+      contains('Macro NDEBUG is undefined.'),
+    );
+  }
+
+  if (customDefineWithValue != null) {
+    expect(
+      result.stdout,
+      contains(
+        'Macro FOO is defined: ${customDefineWithValue ? 'BAR' : '1'}',
+      ),
+    );
+  } else {
+    expect(
+      result.stdout,
+      contains('Macro FOO is undefined.'),
+    );
+  }
+}
diff --git a/pkgs/native_toolchain_c/test/cbuilder/compiler_resolver_test.dart b/pkgs/native_toolchain_c/test/cbuilder/compiler_resolver_test.dart
new file mode 100644
index 0000000..b77c231
--- /dev/null
+++ b/pkgs/native_toolchain_c/test/cbuilder/compiler_resolver_test.dart
@@ -0,0 +1,84 @@
+// Copyright (c) 2023, 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.
+
+@OnPlatform({
+  'mac-os': Timeout.factor(2),
+  'windows': Timeout.factor(10),
+})
+library;
+
+import 'package:collection/collection.dart';
+import 'package:native_assets_cli/native_assets_cli.dart';
+import 'package:native_toolchain_c/src/cbuilder/compiler_resolver.dart';
+import 'package:native_toolchain_c/src/native_toolchain/apple_clang.dart';
+import 'package:native_toolchain_c/src/native_toolchain/clang.dart';
+import 'package:native_toolchain_c/src/native_toolchain/msvc.dart';
+import 'package:native_toolchain_c/src/tool/tool_error.dart';
+import 'package:test/test.dart';
+
+import '../helpers.dart';
+
+void main() {
+  test('Config provided compiler', () async {
+    final tempUri = await tempDirForTest();
+    final ar = [
+      ...await appleAr.defaultResolver!.resolve(logger: logger),
+      ...await lib.defaultResolver!.resolve(logger: logger),
+      ...await llvmAr.defaultResolver!.resolve(logger: logger),
+    ].first.uri;
+    final cc = [
+      ...await appleClang.defaultResolver!.resolve(logger: logger),
+      ...await cl.defaultResolver!.resolve(logger: logger),
+      ...await clang.defaultResolver!.resolve(logger: logger),
+    ].first.uri;
+    final ld = [
+      ...await appleLd.defaultResolver!.resolve(logger: logger),
+      ...await link.defaultResolver!.resolve(logger: logger),
+      ...await lld.defaultResolver!.resolve(logger: logger),
+    ].first.uri;
+    final envScript = [
+      ...await vcvars64.defaultResolver!.resolve(logger: logger)
+    ].firstOrNull?.uri;
+    final buildConfig = BuildConfig(
+      outDir: tempUri,
+      packageName: 'dummy',
+      packageRoot: tempUri,
+      targetArchitecture: Architecture.current,
+      targetOs: OS.current,
+      buildMode: BuildMode.release,
+      linkModePreference: LinkModePreference.dynamic,
+      cCompiler: CCompilerConfig(
+        ar: ar,
+        cc: cc,
+        ld: ld,
+        envScript: envScript,
+      ),
+    );
+    final resolver = CompilerResolver(buildConfig: buildConfig, logger: logger);
+    final compiler = await resolver.resolveCompiler();
+    final archiver = await resolver.resolveArchiver();
+    expect(compiler.uri, buildConfig.cCompiler.cc);
+    expect(archiver.uri, buildConfig.cCompiler.ar);
+  });
+
+  test('No compiler found', () async {
+    final tempUri = await tempDirForTest();
+    final buildConfig = BuildConfig(
+      outDir: tempUri,
+      packageName: 'dummy',
+      packageRoot: tempUri,
+      targetArchitecture: Architecture.arm64,
+      targetOs: OS.windows,
+      buildMode: BuildMode.release,
+      linkModePreference: LinkModePreference.dynamic,
+    );
+    final resolver = CompilerResolver(
+      buildConfig: buildConfig,
+      logger: logger,
+      host: Target.androidArm64, // This is never a host.
+    );
+    expect(resolver.resolveCompiler, throwsA(isA<ToolError>()));
+    expect(resolver.resolveArchiver, throwsA(isA<ToolError>()));
+  });
+}
diff --git a/pkgs/native_toolchain_c/test/cbuilder/testfiles/add/src/add.c b/pkgs/native_toolchain_c/test/cbuilder/testfiles/add/src/add.c
new file mode 100644
index 0000000..6a16cca
--- /dev/null
+++ b/pkgs/native_toolchain_c/test/cbuilder/testfiles/add/src/add.c
@@ -0,0 +1,22 @@
+// Copyright (c) 2023, 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.
+
+#include <stdint.h>
+
+#ifdef DEBUG
+#include <stdio.h>
+#endif
+
+#if _WIN32
+#define FFI_EXPORT __declspec(dllexport)
+#else
+#define FFI_EXPORT
+#endif
+
+FFI_EXPORT int32_t add(int32_t a, int32_t b) {
+#ifdef DEBUG
+  printf("Adding %i and %i.\n", a, b);
+#endif
+  return a + b;
+}
diff --git a/pkgs/native_toolchain_c/test/cbuilder/testfiles/defines/src/defines.c b/pkgs/native_toolchain_c/test/cbuilder/testfiles/defines/src/defines.c
new file mode 100644
index 0000000..7bb554b
--- /dev/null
+++ b/pkgs/native_toolchain_c/test/cbuilder/testfiles/defines/src/defines.c
@@ -0,0 +1,38 @@
+// Copyright (c) 2023, 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.
+
+#include <stdio.h>
+
+#define STRINGIFY(X) #X
+#define MACRO_IS_UNDEFINED(name) printf("Macro " #name " is undefined.\n");
+#define MACRO_IS_DEFINED(name)                                                 \
+  printf("Macro " #name " is defined: " STRINGIFY(name) "\n");
+
+int main() {
+#ifdef DEBUG
+  MACRO_IS_DEFINED(DEBUG);
+#else
+  MACRO_IS_UNDEFINED(DEBUG);
+#endif
+
+#ifdef RELEASE
+  MACRO_IS_DEFINED(RELEASE);
+#else
+  MACRO_IS_UNDEFINED(RELEASE);
+#endif
+
+#ifdef NDEBUG
+  MACRO_IS_DEFINED(NDEBUG);
+#else
+  MACRO_IS_UNDEFINED(NDEBUG);
+#endif
+
+#ifdef FOO
+  MACRO_IS_DEFINED(FOO);
+#else
+  MACRO_IS_UNDEFINED(FOO);
+#endif
+
+  return 0;
+}
diff --git a/pkgs/native_toolchain_c/test/cbuilder/testfiles/hello_world/src/hello_world.c b/pkgs/native_toolchain_c/test/cbuilder/testfiles/hello_world/src/hello_world.c
new file mode 100644
index 0000000..2d42059
--- /dev/null
+++ b/pkgs/native_toolchain_c/test/cbuilder/testfiles/hello_world/src/hello_world.c
@@ -0,0 +1,13 @@
+// Copyright (c) 2023, 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.
+
+#include <stdio.h>
+
+int main() {
+#ifdef DEBUG
+  printf("Running in debug mode.\n");
+#endif
+  printf("Hello world.\n");
+  return 0;
+}
diff --git a/pkgs/native_toolchain_c/test/cbuilder/testfiles/hello_world_cpp/src/hello_world_cpp.cc b/pkgs/native_toolchain_c/test/cbuilder/testfiles/hello_world_cpp/src/hello_world_cpp.cc
new file mode 100644
index 0000000..d883a32
--- /dev/null
+++ b/pkgs/native_toolchain_c/test/cbuilder/testfiles/hello_world_cpp/src/hello_world_cpp.cc
@@ -0,0 +1,14 @@
+// Copyright (c) 2023, 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.
+
+#include <iostream>
+
+int main() {
+#ifdef DEBUG
+  std::cout << "Running in debug mode." << std::endl;
+#endif
+  std::cout << "Hello world." << std::endl;
+  return 0;
+}
+
diff --git a/pkgs/native_toolchain_c/test/cbuilder/testfiles/includes/include/includes.h b/pkgs/native_toolchain_c/test/cbuilder/testfiles/includes/include/includes.h
new file mode 100644
index 0000000..c2f78a8
--- /dev/null
+++ b/pkgs/native_toolchain_c/test/cbuilder/testfiles/includes/include/includes.h
@@ -0,0 +1,12 @@
+// Copyright (c) 2023, 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.
+
+#if _WIN32
+#define FFI_EXPORT __declspec(dllexport)
+#else
+#define FFI_EXPORT
+#endif
+
+FFI_EXPORT int x = 42;
+
diff --git a/pkgs/native_toolchain_c/test/cbuilder/testfiles/includes/src/includes.c b/pkgs/native_toolchain_c/test/cbuilder/testfiles/includes/src/includes.c
new file mode 100644
index 0000000..851907f
--- /dev/null
+++ b/pkgs/native_toolchain_c/test/cbuilder/testfiles/includes/src/includes.c
@@ -0,0 +1,6 @@
+// Copyright (c) 2023, 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.
+
+#include "includes.h"
+
diff --git a/pkgs/native_toolchain_c/test/helpers.dart b/pkgs/native_toolchain_c/test/helpers.dart
new file mode 100644
index 0000000..abb157b
--- /dev/null
+++ b/pkgs/native_toolchain_c/test/helpers.dart
@@ -0,0 +1,182 @@
+// Copyright (c) 2023, 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:ffi';
+import 'dart:io';
+
+import 'package:logging/logging.dart';
+import 'package:native_assets_cli/native_assets_cli.dart';
+import 'package:native_toolchain_c/src/native_toolchain/apple_clang.dart';
+import 'package:native_toolchain_c/src/utils/run_process.dart';
+import 'package:test/test.dart';
+
+/// Returns a suffix for a test that is parameterized.
+///
+/// [tags] represent the current configuration of the test. Each element
+/// is converted to a string by calling [Object.toString].
+///
+/// ## Example
+///
+/// The instances of the test below will have the following descriptions:
+///
+/// - `My test`
+/// - `My test (dry_run)`
+///
+/// ```dart
+/// void main() {
+///   for (final dryRun in [true, false]) {
+///     final suffix = testSuffix([if (dryRun) 'dry_run']);
+///
+///     test('My test$suffix', () {});
+///   }
+/// }
+/// ```
+String testSuffix(List<Object> tags) => switch (tags) {
+      [] => '',
+      _ => ' (${tags.join(', ')})',
+    };
+
+const keepTempKey = 'KEEP_TEMPORARY_DIRECTORIES';
+
+Future<Uri> tempDirForTest({String? prefix, bool keepTemp = false}) async {
+  final tempDir = await Directory.systemTemp.createTemp(prefix);
+  // Deal with Windows temp folder aliases.
+  final tempUri =
+      Directory(await tempDir.resolveSymbolicLinks()).uri.normalizePath();
+  if ((!Platform.environment.containsKey(keepTempKey) ||
+          Platform.environment[keepTempKey]!.isEmpty) &&
+      !keepTemp) {
+    addTearDown(() => tempDir.delete(recursive: true));
+  }
+  return tempUri;
+}
+
+/// Logger that outputs the full trace when a test fails.
+Logger get logger => _logger ??= () {
+      // A new logger is lazily created for each test so that the messages
+      // captured by printOnFailure are scoped to the correct test.
+      addTearDown(() => _logger = null);
+      return _createTestLogger();
+    }();
+
+Logger? _logger;
+
+Logger createCapturingLogger(List<String> capturedMessages) =>
+    _createTestLogger(capturedMessages: capturedMessages);
+
+Logger _createTestLogger({List<String>? capturedMessages}) =>
+    Logger.detached('')
+      ..level = Level.ALL
+      ..onRecord.listen((record) {
+        printOnFailure(
+            '${record.level.name}: ${record.time}: ${record.message}');
+        capturedMessages?.add(record.message);
+      });
+
+/// Test files are run in a variety of ways, find this package root in all.
+///
+/// Test files can be run from source from any working directory. The Dart SDK
+/// `tools/test.py` runs them from the root of the SDK for example.
+///
+/// Test files can be run from dill from the root of package. `package:test`
+/// does this.
+///
+/// https://github.com/dart-lang/test/issues/110
+Uri findPackageRoot(String packageName) {
+  final script = Platform.script;
+  final fileName = script.name;
+  if (fileName.endsWith('_test.dart')) {
+    // We're likely running from source.
+    var directory = script.resolve('.');
+    while (true) {
+      final dirName = directory.name;
+      if (dirName == packageName) {
+        return directory;
+      }
+      final parent = directory.resolve('..');
+      if (parent == directory) break;
+      directory = parent;
+    }
+  } else if (fileName.endsWith('.dill')) {
+    final cwd = Directory.current.uri;
+    final dirName = cwd.name;
+    if (dirName == packageName) {
+      return cwd;
+    }
+  }
+  throw StateError("Could not find package root for package '$packageName'. "
+      'Tried finding the package root via Platform.script '
+      "'${Platform.script.toFilePath()}' and Directory.current "
+      "'${Directory.current.uri.toFilePath()}'.");
+}
+
+Uri packageUri = findPackageRoot('native_toolchain_c');
+
+extension on Uri {
+  String get name => pathSegments.where((e) => e != '').last;
+}
+
+String unparseKey(String key) => key.replaceAll('.', '__').toUpperCase();
+
+/// Archiver provided by the environment.
+final Uri? ar = Platform
+    .environment[unparseKey(CCompilerConfig.arConfigKeyFull)]
+    ?.asFileUri();
+
+/// Compiler provided by the environment.
+final Uri? cc = Platform
+    .environment[unparseKey(CCompilerConfig.ccConfigKeyFull)]
+    ?.asFileUri();
+
+/// Linker provided by the environment.
+final Uri? ld = Platform
+    .environment[unparseKey(CCompilerConfig.ldConfigKeyFull)]
+    ?.asFileUri();
+
+/// Path to script that sets environment variables for [cc], [ld], and [ar].
+///
+/// Provided by environment.
+final Uri? envScript = Platform
+    .environment[unparseKey(CCompilerConfig.envScriptConfigKeyFull)]
+    ?.asFileUri();
+
+/// Arguments for [envScript] provided by environment.
+final List<String>? envScriptArgs = Platform
+    .environment[unparseKey(CCompilerConfig.envScriptArgsConfigKeyFull)]
+    ?.split(' ');
+
+extension on String {
+  Uri asFileUri() => Uri.file(this);
+}
+
+/// Looks up the install name of a dynamic library at [libraryUri].
+///
+/// Because `otool` output multiple names, [libraryName] as search parameter.
+Future<String> runOtoolInstallName(Uri libraryUri, String libraryName) async {
+  final otoolUri =
+      (await otool.defaultResolver!.resolve(logger: logger)).first.uri;
+  final otoolResult = await runProcess(
+    executable: otoolUri,
+    arguments: ['-l', libraryUri.path],
+    logger: logger,
+  );
+  expect(otoolResult.exitCode, 0);
+  // Leading space on purpose to differentiate from other types of names.
+  const installNameName = ' name ';
+  final installName = otoolResult.stdout
+      .split('\n')
+      .firstWhere((e) => e.contains(installNameName) && e.contains(libraryName))
+      .trim()
+      .split(' ')[1];
+  return installName;
+}
+
+/// Opens the [DynamicLibrary] at [path] and register a tear down hook to close
+/// it when the current test is done.
+DynamicLibrary openDynamicLibraryForTest(String path) {
+  final library = DynamicLibrary.open(path);
+  addTearDown(library.close);
+  return library;
+}
diff --git a/pkgs/native_toolchain_c/test/native_toolchain/apple_clang_test.dart b/pkgs/native_toolchain_c/test/native_toolchain/apple_clang_test.dart
new file mode 100644
index 0000000..14829cb
--- /dev/null
+++ b/pkgs/native_toolchain_c/test/native_toolchain/apple_clang_test.dart
@@ -0,0 +1,58 @@
+// Copyright (c) 2023, 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.
+
+@TestOn('mac-os')
+@OnPlatform({
+  'mac-os': Timeout.factor(2),
+})
+library;
+
+import 'dart:io';
+
+import 'package:native_toolchain_c/src/native_toolchain/apple_clang.dart';
+import 'package:native_toolchain_c/src/tool/tool_requirement.dart';
+import 'package:pub_semver/pub_semver.dart';
+import 'package:test/test.dart';
+
+import '../helpers.dart';
+
+void main() {
+  if (!Platform.isMacOS) {
+    // Avoid needing status files on Dart SDK CI.
+    return;
+  }
+
+  test('smoke test', () async {
+    final requirement = ToolRequirement(appleClang,
+        minimumVersion: Version(12, 0, 0, pre: '0'));
+    final resolved = await appleClang.defaultResolver!.resolve(logger: logger);
+    expect(resolved.isNotEmpty, true);
+    final satisfied = requirement.satisfy(resolved);
+    expect(satisfied?.length, 1);
+  });
+
+  test('ar test', () async {
+    final requirement = ToolRequirement(appleAr);
+    final resolved = await appleAr.defaultResolver!.resolve(logger: logger);
+    expect(resolved.isNotEmpty, true);
+    final satisfied = requirement.satisfy(resolved);
+    expect(satisfied?.length, 1);
+  });
+
+  test('ld test', () async {
+    final requirement = ToolRequirement(appleLd);
+    final resolved = await appleLd.defaultResolver!.resolve(logger: logger);
+    expect(resolved.isNotEmpty, true);
+    final satisfied = requirement.satisfy(resolved);
+    expect(satisfied?.length, 1);
+  });
+
+  test('otool test', () async {
+    final requirement = ToolRequirement(otool);
+    final resolved = await otool.defaultResolver!.resolve(logger: logger);
+    expect(resolved.isNotEmpty, true);
+    final satisfied = requirement.satisfy(resolved);
+    expect(satisfied?.length, 1);
+  });
+}
diff --git a/pkgs/native_toolchain_c/test/native_toolchain/clang_test.dart b/pkgs/native_toolchain_c/test/native_toolchain/clang_test.dart
new file mode 100644
index 0000000..88be30c
--- /dev/null
+++ b/pkgs/native_toolchain_c/test/native_toolchain/clang_test.dart
@@ -0,0 +1,60 @@
+// Copyright (c) 2023, 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.
+
+@TestOn('linux')
+library;
+
+import 'dart:io';
+
+import 'package:native_toolchain_c/src/native_toolchain/clang.dart';
+import 'package:native_toolchain_c/src/tool/tool_instance.dart';
+import 'package:native_toolchain_c/src/tool/tool_requirement.dart';
+import 'package:pub_semver/pub_semver.dart';
+import 'package:test/test.dart';
+
+import '../helpers.dart';
+
+void main() {
+  if (!Platform.isLinux) {
+    // Avoid needing status files on Dart SDK CI.
+    return;
+  }
+
+  test('clang smoke test', () async {
+    final requirement =
+        ToolRequirement(clang, minimumVersion: Version(14, 0, 0, pre: '0'));
+    final resolved = await clang.defaultResolver!.resolve(logger: logger);
+    expect(resolved.isNotEmpty, true);
+    final satisfied = requirement.satisfy(resolved);
+    expect(satisfied?.length, 1);
+  });
+
+  test('clang versions', () {
+    final clangInstance = ToolInstance(
+      tool: clang,
+      uri: Uri.file('some/path'),
+      version: Version.parse('14.0.0-1'),
+    );
+    final requirement =
+        ToolRequirement(clang, minimumVersion: Version(14, 0, 0, pre: '0'));
+    final satisfied = requirement.satisfy([clangInstance]);
+    expect(satisfied?.length, 1);
+  });
+
+  test('llvm-ar smoke test', () async {
+    final requirement = ToolRequirement(llvmAr);
+    final resolved = await llvmAr.defaultResolver!.resolve(logger: logger);
+    expect(resolved.isNotEmpty, true);
+    final satisfied = requirement.satisfy(resolved);
+    expect(satisfied?.length, 1);
+  });
+
+  test('ld test', () async {
+    final requirement = ToolRequirement(lld);
+    final resolved = await lld.defaultResolver!.resolve(logger: logger);
+    expect(resolved.isNotEmpty, true);
+    final satisfied = requirement.satisfy(resolved);
+    expect(satisfied?.length, 1);
+  });
+}
diff --git a/pkgs/native_toolchain_c/test/native_toolchain/gcc_test.dart b/pkgs/native_toolchain_c/test/native_toolchain/gcc_test.dart
new file mode 100644
index 0000000..c87f53e
--- /dev/null
+++ b/pkgs/native_toolchain_c/test/native_toolchain/gcc_test.dart
@@ -0,0 +1,74 @@
+// Copyright (c) 2023, 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.
+
+@TestOn('linux')
+library;
+
+import 'dart:io';
+
+import 'package:native_toolchain_c/src/native_toolchain/gcc.dart';
+import 'package:native_toolchain_c/src/tool/tool.dart';
+import 'package:native_toolchain_c/src/tool/tool_requirement.dart';
+import 'package:native_toolchain_c/src/tool/tool_resolver.dart';
+import 'package:test/test.dart';
+
+import '../helpers.dart';
+
+void main() {
+  if (!Platform.isLinux) {
+    // Avoid needing status files on Dart SDK CI.
+    return;
+  }
+
+  void testToolSet(String name, List<Tool> tools) {
+    test('gcc cross compilation $name smoke test', () async {
+      final resolver = ToolResolvers([
+        for (final tool in tools) tool.defaultResolver!,
+      ]);
+
+      final resolved = await resolver.resolve(logger: logger);
+      printOnFailure(resolved.toString());
+      expect(resolved.isNotEmpty, true);
+
+      final requirement = RequireAll([
+        for (final tool in tools) ToolRequirement(tool),
+      ]);
+
+      final satisfied = requirement.satisfy(resolved);
+      printOnFailure(tools.toString());
+      printOnFailure(satisfied.toString());
+      expect(satisfied?.length, tools.length);
+    });
+  }
+
+  testToolSet('aarch64LinuxGnuGcc', [
+    aarch64LinuxGnuGcc,
+    aarch64LinuxGnuGccAr,
+    aarch64LinuxGnuLd,
+  ]);
+
+  testToolSet('armLinuxGnueabihfGcc', [
+    armLinuxGnueabihfGcc,
+    armLinuxGnueabihfGccAr,
+    armLinuxGnueabihfLd,
+  ]);
+
+  testToolSet('i686LinuxGnuGcc', [
+    i686LinuxGnuGcc,
+    i686LinuxGnuGccAr,
+    i686LinuxGnuLd,
+  ]);
+
+  testToolSet('x86_64LinuxGnuGcc', [
+    x86_64LinuxGnuGcc,
+    x86_64LinuxGnuGccAr,
+    x86_64LinuxGnuLd,
+  ]);
+
+  testToolSet('riscv64LinuxGnuGcc', [
+    riscv64LinuxGnuGcc,
+    riscv64LinuxGnuGccAr,
+    riscv64LinuxGnuLd,
+  ]);
+}
diff --git a/pkgs/native_toolchain_c/test/native_toolchain/msvc_test.dart b/pkgs/native_toolchain_c/test/native_toolchain/msvc_test.dart
new file mode 100644
index 0000000..7e9e348
--- /dev/null
+++ b/pkgs/native_toolchain_c/test/native_toolchain/msvc_test.dart
@@ -0,0 +1,188 @@
+// Copyright (c) 2023, 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.
+
+@TestOn('windows')
+@OnPlatform({
+  'windows': Timeout.factor(10),
+})
+library;
+
+import 'dart:io';
+
+import 'package:native_toolchain_c/src/native_toolchain/msvc.dart';
+import 'package:native_toolchain_c/src/utils/env_from_bat.dart';
+import 'package:test/test.dart';
+
+import '../helpers.dart';
+
+void main() {
+  if (!Platform.isWindows) {
+    // Avoid needing status files on Dart SDK CI.
+    return;
+  }
+
+  test('vswhere', () async {
+    final instances = await vswhere.defaultResolver!.resolve(logger: logger);
+    expect(instances.isNotEmpty, true);
+  });
+
+  test('visualStudio', () async {
+    final instances =
+        await visualStudio.defaultResolver!.resolve(logger: logger);
+    expect(instances.isNotEmpty, true);
+  });
+
+  test('msvc', () async {
+    final instances = await msvc.defaultResolver!.resolve(logger: logger);
+    expect(instances.isNotEmpty, true);
+  });
+
+  test('cl', () async {
+    final instances = await cl.defaultResolver!.resolve(logger: logger);
+    expect(instances.isNotEmpty, true);
+  });
+
+  test('clIA32', () async {
+    final instances = await clIA32.defaultResolver!.resolve(logger: logger);
+    expect(instances.isNotEmpty, true);
+  });
+
+  test('clArm64', () async {
+    final instances = await clArm64.defaultResolver!.resolve(logger: logger);
+    expect(instances.isNotEmpty, true);
+  });
+
+  test('lib', () async {
+    final instances = await lib.defaultResolver!.resolve(logger: logger);
+    expect(instances.isNotEmpty, true);
+  });
+
+  test('libIA32', () async {
+    final instances = await libIA32.defaultResolver!.resolve(logger: logger);
+    expect(instances.isNotEmpty, true);
+  });
+
+  test('libArm64', () async {
+    final instances = await libArm64.defaultResolver!.resolve(logger: logger);
+    expect(instances.isNotEmpty, true);
+  });
+
+  test('link', () async {
+    final instances = await link.defaultResolver!.resolve(logger: logger);
+    expect(instances.isNotEmpty, true);
+  });
+
+  test('linkIA32', () async {
+    final instances = await linkIA32.defaultResolver!.resolve(logger: logger);
+    expect(instances.isNotEmpty, true);
+  });
+
+  test('linkArm64', () async {
+    final instances = await linkArm64.defaultResolver!.resolve(logger: logger);
+    expect(instances.isNotEmpty, true);
+  });
+
+  test('dumpbin', () async {
+    final instances = await dumpbin.defaultResolver!.resolve(logger: logger);
+    expect(instances.isNotEmpty, true);
+  });
+
+  test('vcvars32 from cl.exe', () async {
+    final clInstances = await clIA32.defaultResolver!.resolve(logger: logger);
+    expect(clInstances.isNotEmpty, true);
+
+    final instances = await vcvars(clInstances.first)
+        .defaultResolver!
+        .resolve(logger: logger);
+    expect(instances.isNotEmpty, true);
+    final instance = instances.first;
+    expect(instance.tool, vcvars32);
+    final env = await envFromBat(instance.uri);
+    expect(env['INCLUDE'] != null, true);
+    expect(env['WindowsSdkDir'] != null, true); // stdio.h
+  });
+
+  test('vcvars64 from cl.exe', () async {
+    final clInstances = await cl.defaultResolver!.resolve(logger: logger);
+    expect(clInstances.isNotEmpty, true);
+
+    final instances = await vcvars(clInstances.first)
+        .defaultResolver!
+        .resolve(logger: logger);
+    expect(instances.isNotEmpty, true);
+    final instance = instances.first;
+    expect(instance.tool, vcvars64);
+    final env = await envFromBat(instance.uri);
+    expect(env['INCLUDE'] != null, true);
+    expect(env['WindowsSdkDir'] != null, true); // stdio.h
+  });
+
+  test('vcvarsarm64 from cl.exe', () async {
+    final clInstances = await clArm64.defaultResolver!.resolve(logger: logger);
+    expect(clInstances.isNotEmpty, true);
+
+    final instances = await vcvars(clInstances.first)
+        .defaultResolver!
+        .resolve(logger: logger);
+    expect(instances.isNotEmpty, true);
+    final instance = instances.first;
+    expect(instance.tool, vcvarsarm64);
+    final env = await envFromBat(instance.uri);
+    expect(env['INCLUDE'] != null, true);
+    expect(env['WindowsSdkDir'] != null, true); // stdio.h
+  });
+
+  test('vcvars32', () async {
+    final instances = await vcvars32.defaultResolver!.resolve(logger: logger);
+    expect(instances.isNotEmpty, true);
+    final instance = instances.first;
+    final env = await envFromBat(instance.uri);
+    expect(env['INCLUDE'] != null, true);
+    expect(env['WindowsSdkDir'] != null, true); // stdio.h
+  });
+
+  test('vcvars64', () async {
+    final instances = await vcvars64.defaultResolver!.resolve(logger: logger);
+    expect(instances.isNotEmpty, true);
+    final instance = instances.first;
+    final env = await envFromBat(instance.uri);
+    expect(env['INCLUDE'] != null, true);
+    expect(env['WindowsSdkDir'] != null, true); // stdio.h
+  });
+
+  test('vcvarsarm64', () async {
+    final instances =
+        await vcvarsarm64.defaultResolver!.resolve(logger: logger);
+    expect(instances.isNotEmpty, true);
+    final instance = instances.first;
+    final env = await envFromBat(instance.uri);
+    expect(env['INCLUDE'] != null, true);
+    expect(env['WindowsSdkDir'] != null, true); // stdio.h
+  });
+
+  test('vcvarsall', () async {
+    final instances = await vcvarsall.defaultResolver!.resolve(logger: logger);
+    expect(instances.isNotEmpty, true);
+    final instance = instances.first;
+    final env = await envFromBat(
+      instance.uri,
+      arguments: [
+        'x64',
+        'uwp',
+        '10.0',
+      ],
+    );
+    expect(env['INCLUDE'] != null, true);
+    expect(env['WindowsSdkDir'] != null, true); // stdio.h
+  });
+
+  test('vsDevCmd', () async {
+    final instances = await vsDevCmd.defaultResolver!.resolve(logger: logger);
+    expect(instances.isNotEmpty, true);
+    final instance = instances.first;
+    final env = await envFromBat(instance.uri);
+    expect(env['INCLUDE'] != null, true);
+    expect(env['WindowsSdkDir'] != null, true); // stdio.h
+  });
+}
diff --git a/pkgs/native_toolchain_c/test/native_toolchain/ndk_test.dart b/pkgs/native_toolchain_c/test/native_toolchain/ndk_test.dart
new file mode 100644
index 0000000..f6cde28
--- /dev/null
+++ b/pkgs/native_toolchain_c/test/native_toolchain/ndk_test.dart
@@ -0,0 +1,23 @@
+// Copyright (c) 2023, 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:native_toolchain_c/src/native_toolchain/android_ndk.dart';
+import 'package:native_toolchain_c/src/tool/tool_requirement.dart';
+import 'package:test/test.dart';
+
+import '../helpers.dart';
+
+void main() {
+  test('NDK smoke test', () async {
+    final requirement = RequireAll([
+      ToolRequirement(androidNdk),
+      ToolRequirement(androidNdkClang),
+      ToolRequirement(androidNdkLlvmAr),
+      ToolRequirement(androidNdkLld),
+    ]);
+    final resolved = await androidNdk.defaultResolver!.resolve(logger: logger);
+    final satisfied = requirement.satisfy(resolved);
+    expect(satisfied?.length, 4);
+  });
+}
diff --git a/pkgs/native_toolchain_c/test/native_toolchain/recognizer_test.dart b/pkgs/native_toolchain_c/test/native_toolchain/recognizer_test.dart
new file mode 100644
index 0000000..0f18d39
--- /dev/null
+++ b/pkgs/native_toolchain_c/test/native_toolchain/recognizer_test.dart
@@ -0,0 +1,113 @@
+// Copyright (c) 2023, 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:collection/collection.dart';
+import 'package:native_toolchain_c/src/native_toolchain/android_ndk.dart';
+import 'package:native_toolchain_c/src/native_toolchain/apple_clang.dart';
+import 'package:native_toolchain_c/src/native_toolchain/clang.dart';
+import 'package:native_toolchain_c/src/native_toolchain/gcc.dart';
+import 'package:native_toolchain_c/src/native_toolchain/msvc.dart';
+import 'package:native_toolchain_c/src/native_toolchain/recognizer.dart';
+import 'package:native_toolchain_c/src/tool/tool.dart';
+import 'package:native_toolchain_c/src/tool/tool_instance.dart';
+import 'package:native_toolchain_c/src/tool/tool_resolver.dart';
+import 'package:test/test.dart';
+
+import '../helpers.dart';
+
+void main() async {
+  final tests = [
+    RecognizerTest(appleAr, ArchiverRecognizer.new),
+    RecognizerTest(appleClang, CompilerRecognizer.new),
+    RecognizerTest(appleLd, LinkerRecognizer.new),
+    RecognizerTest(aarch64LinuxGnuGcc, CompilerRecognizer.new),
+    RecognizerTest(aarch64LinuxGnuGccAr, ArchiverRecognizer.new),
+    RecognizerTest(aarch64LinuxGnuLd, LinkerRecognizer.new),
+    RecognizerTest(androidNdkClang, CompilerRecognizer.new),
+    RecognizerTest(androidNdkLld, LinkerRecognizer.new),
+    RecognizerTest(androidNdkLlvmAr, ArchiverRecognizer.new),
+    RecognizerTest(armLinuxGnueabihfGcc, CompilerRecognizer.new),
+    RecognizerTest(armLinuxGnueabihfGccAr, ArchiverRecognizer.new),
+    RecognizerTest(armLinuxGnueabihfLd, LinkerRecognizer.new),
+    RecognizerTest(cl, CompilerRecognizer.new),
+    RecognizerTest(clang, CompilerRecognizer.new),
+    RecognizerTest(i686LinuxGnuGcc, CompilerRecognizer.new),
+    RecognizerTest(i686LinuxGnuGccAr, ArchiverRecognizer.new),
+    RecognizerTest(i686LinuxGnuLd, LinkerRecognizer.new),
+    RecognizerTest(lib, ArchiverRecognizer.new),
+    RecognizerTest(link, LinkerRecognizer.new),
+    RecognizerTest(lld, LinkerRecognizer.new),
+    RecognizerTest(llvmAr, ArchiverRecognizer.new),
+    RecognizerTest(riscv64LinuxGnuGcc, CompilerRecognizer.new),
+    RecognizerTest(riscv64LinuxGnuGccAr, ArchiverRecognizer.new),
+    RecognizerTest(riscv64LinuxGnuLd, LinkerRecognizer.new),
+    RecognizerTest(x86_64LinuxGnuGcc, CompilerRecognizer.new),
+    RecognizerTest(x86_64LinuxGnuGccAr, ArchiverRecognizer.new),
+    RecognizerTest(x86_64LinuxGnuLd, LinkerRecognizer.new),
+  ];
+
+  for (final test in tests) {
+    await test.setUp();
+  }
+
+  for (final test in tests) {
+    test.addTest();
+  }
+
+  test('compiler does not exist', () async {
+    final tempUri = await tempDirForTest();
+    final recognizer = CompilerRecognizer(tempUri.resolve('asdf'));
+    final result = await recognizer.resolve(logger: logger);
+    expect(result, <ToolInstance>[]);
+  });
+
+  test('linker does not exist', () async {
+    final tempUri = await tempDirForTest();
+    final recognizer = LinkerRecognizer(tempUri.resolve('asdf'));
+    final result = await recognizer.resolve(logger: logger);
+    expect(result, <ToolInstance>[]);
+  });
+
+  test('archiver does not exist', () async {
+    final tempUri = await tempDirForTest();
+    final recognizer = ArchiverRecognizer(tempUri.resolve('asdf'));
+    final result = await recognizer.resolve(logger: logger);
+    expect(result, <ToolInstance>[]);
+  });
+}
+
+class RecognizerTest {
+  final Tool tool;
+  final ToolResolver Function(Uri) recognizer;
+  late final ToolInstance? toolInstance;
+
+  RecognizerTest(this.tool, this.recognizer);
+
+  Future<void> setUp() async {
+    toolInstance = (await tool.defaultResolver!.resolve(
+      logger: null /* no printOnFailure support in setup. */,
+    ))
+        .where((element) => element.tool == tool)
+        .firstOrNull;
+  }
+
+  void addTest() {
+    if (toolInstance == null) {
+      // We only want to test if we would recognize the tool again if it exists
+      // on the host. Skipping pollutes the stdout, so just don't run the test
+      // at all.
+      return;
+    }
+
+    test(
+      'recognize ${tool.name}',
+      () async {
+        final recognizer_ = recognizer(toolInstance!.uri);
+        final toolInstanceAgain =
+            (await recognizer_.resolve(logger: logger)).first;
+        expect(toolInstanceAgain, toolInstance);
+      },
+    );
+  }
+}
diff --git a/pkgs/native_toolchain_c/test/native_toolchain/xcode_test.dart b/pkgs/native_toolchain_c/test/native_toolchain/xcode_test.dart
new file mode 100644
index 0000000..208851b
--- /dev/null
+++ b/pkgs/native_toolchain_c/test/native_toolchain/xcode_test.dart
@@ -0,0 +1,64 @@
+// Copyright (c) 2023, 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.
+
+@TestOn('mac-os')
+@OnPlatform({
+  'mac-os': Timeout.factor(2),
+})
+library;
+
+import 'dart:io';
+
+import 'package:native_toolchain_c/src/native_toolchain/xcode.dart';
+import 'package:native_toolchain_c/src/tool/tool.dart';
+import 'package:native_toolchain_c/src/tool/tool_instance.dart';
+import 'package:test/test.dart';
+
+import '../helpers.dart';
+
+void main() {
+  if (!Platform.isMacOS) {
+    // Avoid needing status files on Dart SDK CI.
+    return;
+  }
+
+  test('xcrun', () async {
+    final resolved = (await xcrun.defaultResolver!.resolve(logger: logger))
+        .where((i) => i.tool == xcrun);
+    expect(resolved.isNotEmpty, true);
+  });
+
+  test('macosxSdk', () async {
+    final resolved = (await macosxSdk.defaultResolver!.resolve(logger: logger))
+        .where((i) => i.tool == macosxSdk);
+    expect(resolved.isNotEmpty, true);
+  });
+
+  test('iPhoneOSSdk', () async {
+    final resolved =
+        (await iPhoneOSSdk.defaultResolver!.resolve(logger: logger))
+            .where((i) => i.tool == iPhoneOSSdk);
+    expect(resolved.isNotEmpty, true);
+  });
+
+  test('iPhoneSimulatorSdk', () async {
+    final resolved =
+        (await iPhoneSimulatorSdk.defaultResolver!.resolve(logger: logger))
+            .where((i) => i.tool == iPhoneSimulatorSdk);
+    expect(resolved.isNotEmpty, true);
+  });
+
+  test('non-existing SDK', () async {
+    final xcrunInstance =
+        (await xcrun.defaultResolver!.resolve(logger: logger)).first;
+    final tool = Tool(name: 'non-tool');
+    final result = await XCodeSdkResolver.tryResolveSdk(
+      xcrunInstance: xcrunInstance,
+      sdk: 'doesnotexist',
+      tool: tool,
+      logger: logger,
+    );
+    expect(result, <ToolInstance>[]);
+  });
+}
diff --git a/pkgs/native_toolchain_c/test/tool/tool_instance_test.dart b/pkgs/native_toolchain_c/test/tool/tool_instance_test.dart
new file mode 100644
index 0000000..b4a873b
--- /dev/null
+++ b/pkgs/native_toolchain_c/test/tool/tool_instance_test.dart
@@ -0,0 +1,90 @@
+// Copyright (c) 2023, 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:collection/collection.dart';
+import 'package:native_toolchain_c/src/tool/tool.dart';
+import 'package:native_toolchain_c/src/tool/tool_instance.dart';
+import 'package:pub_semver/pub_semver.dart';
+import 'package:test/test.dart';
+
+void main() {
+  test('equals and hashCode', () {
+    final barToolInstance =
+        ToolInstance(tool: Tool(name: 'bar'), uri: Uri.file('path/to/bar'));
+    final fooToolInstance =
+        ToolInstance(tool: Tool(name: 'foo'), uri: Uri.file('path/to/foo'));
+
+    expect(barToolInstance, barToolInstance);
+    expect(barToolInstance != fooToolInstance, true);
+
+    expect(barToolInstance.hashCode, barToolInstance.hashCode);
+    expect(barToolInstance.hashCode != fooToolInstance.hashCode, true);
+
+    expect(
+        ToolInstance(
+                tool: Tool(name: 'bar'),
+                version: Version(1, 0, 0),
+                uri: Uri.file('path/to/bar')) !=
+            ToolInstance(
+                tool: Tool(name: 'bar'),
+                version: Version(1, 0, 1),
+                uri: Uri.file('path/to/bar')),
+        true);
+  });
+
+  test('compareTo', () {
+    final toolInstances = [
+      ToolInstance(
+        tool: Tool(name: 'bar'),
+        version: Version(2, 0, 0),
+        uri: Uri.file('path/to/bar'),
+      ),
+      ToolInstance(
+          tool: Tool(name: 'bar'),
+          version: Version(1, 0, 0),
+          uri: Uri.file('path/to/bar')),
+      ToolInstance(
+        tool: Tool(name: 'bar'),
+        uri: Uri.file('path/to/bar'),
+      ),
+      ToolInstance(
+        tool: Tool(name: 'bar'),
+        uri: Uri.file('path/to/some/other/bar'),
+      ),
+      ToolInstance(
+        tool: Tool(name: 'baz'),
+        uri: Uri.file('path/to/baz'),
+      ),
+    ];
+
+    final toolInstancesSorted = [...toolInstances]..sort();
+    expect(
+      const DeepCollectionEquality().equals(toolInstancesSorted, toolInstances),
+      true,
+    );
+  });
+
+  test('toString', () {
+    final instance = ToolInstance(
+      tool: Tool(name: 'bar'),
+      version: Version(1, 0, 0),
+      uri: Uri.file('path/to/bar'),
+    );
+
+    expect(instance.toString(), contains('bar'));
+    expect(instance.toString(), contains('1.0.0'));
+    expect(instance.toString(), contains('path/to/bar'));
+  });
+
+  test('copyWith', () {
+    final instance = ToolInstance(
+      tool: Tool(name: 'bar'),
+      version: Version(1, 0, 0),
+      uri: Uri.file('path/to/bar'),
+    );
+
+    expect(instance.copyWith(), instance);
+    expect(instance.copyWith(uri: Uri.file('foo/bar')) != instance, true);
+  });
+}
diff --git a/pkgs/native_toolchain_c/test/tool/tool_requirement_test.dart b/pkgs/native_toolchain_c/test/tool/tool_requirement_test.dart
new file mode 100644
index 0000000..9f802db
--- /dev/null
+++ b/pkgs/native_toolchain_c/test/tool/tool_requirement_test.dart
@@ -0,0 +1,48 @@
+// Copyright (c) 2023, 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:native_toolchain_c/src/tool/tool.dart';
+import 'package:native_toolchain_c/src/tool/tool_instance.dart';
+import 'package:native_toolchain_c/src/tool/tool_requirement.dart';
+import 'package:pub_semver/pub_semver.dart';
+import 'package:test/test.dart';
+
+void main() {
+  test('toString', () {
+    final requirement =
+        ToolRequirement(Tool(name: 'clang'), minimumVersion: Version(10, 0, 0));
+    expect(requirement.toString(), contains('clang'));
+    expect(requirement.toString(), contains('10.0.0'));
+  });
+
+  test('RequireOne', () {
+    final requirement = RequireOne([
+      ToolRequirement(Tool(name: 'bar'), minimumVersion: Version(10, 0, 0)),
+      ToolRequirement(Tool(name: 'foo'), minimumVersion: Version(10, 0, 0)),
+    ]);
+    final toolInstances = [
+      ToolInstance(
+        tool: Tool(name: 'bar'),
+        version: Version(10, 0, 0),
+        uri: Uri.file('path/to/bar'),
+      ),
+      ToolInstance(
+        tool: Tool(name: 'foo'),
+        version: Version(9, 0, 0),
+        uri: Uri.file('path/to/foo'),
+      ),
+    ];
+    final result = requirement.satisfy(toolInstances);
+    expect(
+      result,
+      [
+        ToolInstance(
+          tool: Tool(name: 'bar'),
+          version: Version(10, 0, 0),
+          uri: Uri.file('path/to/bar'),
+        )
+      ],
+    );
+  });
+}
diff --git a/pkgs/native_toolchain_c/test/tool/tool_resolver_test.dart b/pkgs/native_toolchain_c/test/tool/tool_resolver_test.dart
new file mode 100644
index 0000000..9f30ce0
--- /dev/null
+++ b/pkgs/native_toolchain_c/test/tool/tool_resolver_test.dart
@@ -0,0 +1,109 @@
+// Copyright (c) 2023, 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';
+
+import 'package:native_assets_cli/native_assets_cli.dart';
+import 'package:native_toolchain_c/src/native_toolchain/apple_clang.dart';
+import 'package:native_toolchain_c/src/native_toolchain/clang.dart';
+import 'package:native_toolchain_c/src/native_toolchain/msvc.dart';
+import 'package:native_toolchain_c/src/tool/tool.dart';
+import 'package:native_toolchain_c/src/tool/tool_error.dart';
+import 'package:native_toolchain_c/src/tool/tool_instance.dart';
+import 'package:native_toolchain_c/src/tool/tool_resolver.dart';
+import 'package:test/test.dart';
+
+import '../helpers.dart';
+
+void main() {
+  test('CliVersionResolver.executableVersion', () async {
+    final toolInstances = [
+      ...await appleClang.defaultResolver!.resolve(logger: logger),
+      ...await clang.defaultResolver!.resolve(logger: logger),
+      ...await cl.defaultResolver!.resolve(logger: logger),
+    ];
+    expect(toolInstances.isNotEmpty, true);
+    final toolInstance = toolInstances.first;
+    final versionArguments = [
+      if (toolInstance.tool != cl) '--version',
+    ];
+    final version = await CliVersionResolver.executableVersion(
+      toolInstance.uri,
+      arguments: versionArguments,
+      logger: logger,
+    );
+    expect(version.major > 5, true);
+    expect(
+      () => CliVersionResolver.executableVersion(
+        toolInstances.first.uri,
+        arguments: versionArguments,
+        expectedExitCode: 9999,
+        logger: logger,
+      ),
+      throwsA(isA<ToolError>()),
+    );
+
+    try {
+      await CliVersionResolver.executableVersion(
+        toolInstances.first.uri,
+        arguments: versionArguments,
+        expectedExitCode: 9999,
+        logger: logger,
+      );
+      // ignore: avoid_catching_errors
+    } on ToolError catch (e) {
+      expect(e.toString(), contains('returned unexpected exit code'));
+    }
+  });
+
+  test('RelativeToolResolver', () async {
+    final tempUri = await tempDirForTest();
+    final barExeUri =
+        tempUri.resolve(Target.current.os.executableFileName('bar'));
+    final bazExeName = Target.current.os.executableFileName('baz');
+    final bazExeUri = tempUri.resolve(bazExeName);
+    await File.fromUri(barExeUri).writeAsString('dummy');
+    await File.fromUri(bazExeUri).writeAsString('dummy');
+    expect(await File.fromUri(barExeUri).exists(), true);
+    expect(await File.fromUri(bazExeUri).exists(), true);
+    final barResolver = InstallLocationResolver(
+      toolName: 'bar',
+      paths: [barExeUri.toFilePath().replaceAll('\\', '/')],
+    );
+    final bazResolver = RelativeToolResolver(
+      toolName: 'baz',
+      wrappedResolver: barResolver,
+      relativePath: Uri.file(bazExeName),
+    );
+    final resolvedBarInstances = await barResolver.resolve(logger: logger);
+    expect(
+      resolvedBarInstances,
+      [ToolInstance(tool: Tool(name: 'bar'), uri: barExeUri)],
+    );
+    final resolvedBazInstances = await bazResolver.resolve(logger: logger);
+    expect(
+      resolvedBazInstances,
+      [ToolInstance(tool: Tool(name: 'baz'), uri: bazExeUri)],
+    );
+  });
+
+  test('logger', () async {
+    final tempUri = await tempDirForTest();
+    final barExeUri =
+        tempUri.resolve(Target.current.os.executableFileName('bar'));
+    final bazExeName = Target.current.os.executableFileName('baz');
+    final bazExeUri = tempUri.resolve(bazExeName);
+    await File.fromUri(barExeUri).writeAsString('dummy');
+    final barResolver = InstallLocationResolver(
+        toolName: 'bar', paths: [barExeUri.toFilePath().replaceAll('\\', '/')]);
+    final bazResolver = InstallLocationResolver(
+        toolName: 'baz', paths: [bazExeUri.toFilePath().replaceAll('\\', '/')]);
+    final barLogs = <String>[];
+    final bazLogs = <String>[];
+    await barResolver.resolve(logger: createCapturingLogger(barLogs));
+    await bazResolver.resolve(logger: createCapturingLogger(bazLogs));
+    expect(barLogs.join('\n'), contains('Found [ToolInstance(bar'));
+    expect(bazLogs.join('\n'), contains('Found no baz'));
+  });
+}
diff --git a/pkgs/native_toolchain_c/test/tool/tool_test.dart b/pkgs/native_toolchain_c/test/tool/tool_test.dart
new file mode 100644
index 0000000..c14aff9
--- /dev/null
+++ b/pkgs/native_toolchain_c/test/tool/tool_test.dart
@@ -0,0 +1,27 @@
+// Copyright (c) 2023, 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:native_toolchain_c/src/native_toolchain/android_ndk.dart';
+import 'package:native_toolchain_c/src/native_toolchain/clang.dart';
+import 'package:native_toolchain_c/src/tool/tool.dart';
+import 'package:native_toolchain_c/src/tool/tool_resolver.dart';
+import 'package:test/test.dart';
+
+void main() {
+  test('equals and hashCode', () async {
+    expect(clang, clang);
+    expect(clang != androidNdk, true);
+    expect(
+      Tool(name: 'foo'),
+      Tool(name: 'foo', defaultResolver: PathToolResolver(toolName: 'foo')),
+    );
+    expect(Tool(name: 'foo') != Tool(name: 'bar'), true);
+    expect(
+      Tool(name: 'foo').hashCode,
+      Tool(name: 'foo', defaultResolver: PathToolResolver(toolName: 'foo'))
+          .hashCode,
+    );
+    expect(Tool(name: 'foo').hashCode != Tool(name: 'bar').hashCode, true);
+  });
+}
diff --git a/pkgs/native_toolchain_c/test/utils/run_process_test.dart b/pkgs/native_toolchain_c/test/utils/run_process_test.dart
new file mode 100644
index 0000000..21d7c9d
--- /dev/null
+++ b/pkgs/native_toolchain_c/test/utils/run_process_test.dart
@@ -0,0 +1,47 @@
+// Copyright (c) 2023, 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';
+
+import 'package:native_toolchain_c/src/utils/run_process.dart';
+import 'package:test/test.dart';
+
+import '../helpers.dart';
+
+void main() {
+  final whichUri = Uri.file(Platform.isWindows ? 'where' : 'which');
+
+  test('log contains working dir', () async {
+    final tempUri = await tempDirForTest();
+    final messages = <String>[];
+    await runProcess(
+      executable: whichUri,
+      workingDirectory: tempUri,
+      logger: createCapturingLogger(messages),
+    );
+    expect(messages.join('\n'), contains('cd'));
+  });
+
+  test('log contains env', () async {
+    final messages = <String>[];
+    await runProcess(
+      executable: whichUri,
+      environment: {'FOO': 'BAR'},
+      logger: createCapturingLogger(messages),
+    );
+    expect(messages.join('\n'), contains('FOO=BAR'));
+  });
+
+  test('stderr', () async {
+    final messages = <String>[];
+    const filePath = 'a/dart/file/which/does/not/exist.dart';
+    final result = await runProcess(
+      executable: Uri.file(Platform.resolvedExecutable),
+      arguments: [filePath],
+      logger: createCapturingLogger(messages),
+    );
+    expect(result.stderr, contains(filePath));
+    expect(result.toString(), contains(filePath));
+  });
+}