diff --git a/.github/workflows/prebuilt-binaries.yml b/.github/workflows/prebuilt-binaries.yml new file mode 100644 index 0000000..61d8d8b --- /dev/null +++ b/.github/workflows/prebuilt-binaries.yml @@ -0,0 +1,79 @@ +name: Publish Prebuilt Binaries + +# Publishes the prebuilt binaries of the dart_only_prebuilt_binaries example +# to a GitHub release, so they can be consumed via the (opt-in) +# prebuilt binaries support of native_toolchain_rust. +# See examples/dart_only_prebuilt_binaries/README.md for more information. + +on: + workflow_dispatch: + +env: + example_dir: examples/dart_only_prebuilt_binaries + +permissions: + contents: write + +jobs: + create_release: + runs-on: ubuntu-latest + outputs: + tag: ${{ steps.tag.outputs.tag }} + steps: + - uses: actions/checkout@v5 + - name: Determine the release tag from the example's pubspec version + id: tag + run: | + version=$(sed -n 's/^version: //p' "$example_dir/pubspec.yaml") + echo "tag=dart_only_prebuilt_binaries_example-v$version" >> "$GITHUB_OUTPUT" + - name: Create the release, if it does not exist yet + env: + GH_TOKEN: ${{ github.token }} + tag: ${{ steps.tag.outputs.tag }} + run: | + gh release view "$tag" --repo "$GITHUB_REPOSITORY" || + gh release create "$tag" --repo "$GITHUB_REPOSITORY" \ + --title "$tag" \ + --notes "Prebuilt binaries for the dart_only_prebuilt_binaries example." + + upload_binaries: + needs: create_release + strategy: + matrix: + # More targets (Android, iOS, ...) can be added analogously. + # They are omitted here to keep this example workflow simple. + include: + - os: macos-latest + target: aarch64-apple-darwin + binary: libdart_only_prebuilt_binaries_example.dylib + - os: macos-latest + target: x86_64-apple-darwin + binary: libdart_only_prebuilt_binaries_example.dylib + - os: ubuntu-latest + target: x86_64-unknown-linux-gnu + binary: libdart_only_prebuilt_binaries_example.so + - os: ubuntu-24.04-arm + target: aarch64-unknown-linux-gnu + binary: libdart_only_prebuilt_binaries_example.so + - os: windows-latest + target: x86_64-pc-windows-msvc + binary: dart_only_prebuilt_binaries_example.dll + runs-on: ${{ matrix.os }} + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v5 + - name: Install the Rust target + working-directory: ${{ env.example_dir }}/rust + run: rustup target add ${{ matrix.target }} + - name: Build the cdylib + working-directory: ${{ env.example_dir }}/rust + run: cargo build --release --target ${{ matrix.target }} + - name: Rename the binary to its target-specific asset name + run: cp "target/${{ matrix.target }}/release/${{ matrix.binary }}" "dart_only_prebuilt_binaries_example-${{ matrix.target }}.bin" + - name: Upload the binary to the release + env: + GH_TOKEN: ${{ github.token }} + tag: ${{ needs.create_release.outputs.tag }} + run: gh release upload "$tag" --repo "$GITHUB_REPOSITORY" --clobber "dart_only_prebuilt_binaries_example-${{ matrix.target }}.bin" diff --git a/.gitignore b/.gitignore index 3a3dedc..ed9ace5 100644 --- a/.gitignore +++ b/.gitignore @@ -42,4 +42,8 @@ coverage/ /target/ /Cargo.lock +# Prebuilt binaries opt-in config (see examples/dart_only_prebuilt_binaries): +# by ignoring it, CI always builds the Rust code from source. +native_toolchain_rust.toml + .direnv/ diff --git a/Cargo.toml b/Cargo.toml index f0ca56f..7a91d38 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,7 @@ [workspace] resolver = "3" -members = ["examples/dart_only/rust", "examples/flutter/rust"] +members = [ + "examples/dart_only/rust", + "examples/dart_only_prebuilt_binaries/rust", + "examples/flutter/rust", +] diff --git a/README.md b/README.md index 4b27b45..fb2d37d 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,8 @@ Rust support for Dart's [build hooks](https://dart.dev/tools/hooks). ## Getting Started 1. Install [rustup](https://rustup.rs), for Rust, on your development computer - (if you are a library author, consumers of your package will have to do the same) + (if you are a library author, consumers of your package will have to do the same, + unless they opt in to [Prebuilt Binaries](#prebuilt-binaries)) 2. Run `flutter pub add native_toolchain_rust hooks` for Flutter or `dart pub add native_toolchain_rust hooks` for Dart-only 3. See [Code Setup](#code-setup) @@ -89,3 +90,102 @@ targets = [ "x86_64-apple-darwin", ] ``` + + +## Prebuilt Binaries +Normally, consumers of a package built with `native_toolchain_rust` need rustup installed +so the Rust code can be compiled from source on their machine. +As an alternative, application developers can *opt in* to downloading prebuilt binaries +on a per-package basis, which skips the Rust build entirely (no rustup required). + +To opt in, create a `native_toolchain_rust.toml` file in the root of your project +(for [pub workspaces](https://dart.dev/tools/pub/workspaces), the workspace root): + +```toml +[prebuilt-binaries.some_package_name] +url = "https://github.com/some-user/some-repo/releases/download/v{version}/my_crate-{target}.bin" +``` + +The `url` is a template for where to download the binary from, +and supports the following placeholders: + +| Placeholder | Description | Example | +| --- | --- | --- | +| `{version}` | The version of the Dart package being built | `1.2.3` | +| `{target}` | The Rust target triple being built for | `aarch64-apple-darwin` | +| `{lib-name}` | The platform-specific dynamic library file name | `libmy_crate.so` | + +Some notes: +- The downloaded binary must match the link mode of the build. + It must be a `cdylib` for dynamic linking, which is what Dart/Flutter uses on all platforms as of now. +- The downloaded binary is saved locally under the correct platform-specific library name, + so the remote file name in the URL template does not matter. +- Downloaded binaries are cached in your project's `.dart_tool` directory, + keyed by their resolved download URL. + Thus, a binary is only downloaded once per package version and target, + until the cache is cleared (e.g., by a `flutter clean`). +- Only ever download binaries from a source you trust! + You are responsible for ensuring the binaries you download are safe + and were built from the package's actual source code. + +### Building From Source in CI +Add `native_toolchain_rust.toml` to your project's `.gitignore`: +since the file will then not exist in CI checkouts, +your CI will always build the Rust code from source, +while local development machines (with the file present) use the prebuilt binaries. + +### Publishing Prebuilt Binaries via GitHub Releases +The URL template works great with GitHub release assets, +which are downloadable (for public repositories) without any authentication: +``` +https://github.com///releases/download// +``` + +For example, package authors can attach a binary per target triple to each release +in a GitHub Actions workflow: +```yaml +jobs: + upload-prebuilt-binaries: + strategy: + matrix: + include: + - os: macos-latest + target: aarch64-apple-darwin + binary: libmy_crate.dylib + - os: ubuntu-latest + target: x86_64-unknown-linux-gnu + binary: libmy_crate.so + - os: windows-latest + target: x86_64-pc-windows-msvc + binary: my_crate.dll + # ...and any other targets you wish to support + runs-on: ${{ matrix.os }} + defaults: + run: + shell: bash + permissions: + contents: write + steps: + - uses: actions/checkout@v5 + - run: rustup target add ${{ matrix.target }} + working-directory: rust + - run: cargo build --release --target ${{ matrix.target }} + working-directory: rust + # NOTE: release asset download URLs use the uploaded *file* name, + # so rename the binary to its target-specific asset name before uploading. + - run: cp "rust/target/${{ matrix.target }}/release/${{ matrix.binary }}" "my_crate-${{ matrix.target }}.bin" + - run: gh release upload ${{ github.ref_name }} "my_crate-${{ matrix.target }}.bin" + env: + GH_TOKEN: ${{ github.token }} +``` + +For a complete working example, see the +[dart_only_prebuilt_binaries example](examples/dart_only_prebuilt_binaries) +and its [prebuilt-binaries workflow](.github/workflows/prebuilt-binaries.yml). + +Downstream users can then consume those binaries with the URL template from above: +```toml +[prebuilt-binaries.some_package_name] +url = "https://github.com/some-user/some-repo/releases/download/v{version}/my_crate-{target}.bin" +``` + diff --git a/examples/dart_only_prebuilt_binaries/README.md b/examples/dart_only_prebuilt_binaries/README.md new file mode 100644 index 0000000..fbbf540 --- /dev/null +++ b/examples/dart_only_prebuilt_binaries/README.md @@ -0,0 +1,38 @@ +# Dart-Only Prebuilt Binaries Example + +This example showcases [Prebuilt Binaries](../../README.md#prebuilt-binaries): +it is identical in spirit to the [dart_only](../dart_only) example, +except that its Rust binaries are also published to GitHub Releases +(via the [prebuilt-binaries workflow](../../.github/workflows/prebuilt-binaries.yml)), +so that consumers can opt in to downloading them instead of building from source. + +## Trying It Out +Prebuilt binaries are opt-in: +without any extra configuration, running `dart test` in this directory +builds the Rust code from source (requiring rustup), just like the other examples. + +To opt in to the prebuilt binaries instead, create a `native_toolchain_rust.toml` +in the root of *your* project. +Since this repository is a [pub workspace](https://dart.dev/tools/pub/workspaces), +the root project is the repository root, so create the file there: + +```toml +[prebuilt-binaries.dart_only_prebuilt_binaries_example] +url = "https://github.com/GregoryConrad/native_toolchain_rust/releases/download/dart_only_prebuilt_binaries_example-v{version}/dart_only_prebuilt_binaries_example-{target}.bin" +``` + +Then, `dart test` will download the prebuilt binary for your machine +and skip the Rust build entirely (no rustup required!). + +Note that `native_toolchain_rust.toml` is intentionally listed +in this repository's `.gitignore`: +since CI checkouts will never contain the file, +CI always builds (and tests) the Rust code from source. + +## How the Binaries Are Published +The [prebuilt-binaries workflow](../../.github/workflows/prebuilt-binaries.yml) +builds the cdylib for a matrix of targets and uploads each one +as an asset of the `dart_only_prebuilt_binaries_example-v` GitHub release, +where `` is the `version` in this example's [pubspec.yaml](pubspec.yaml). +The asset names embed the Rust target triple, +matching the `{target}` placeholder in the URL template above. diff --git a/examples/dart_only_prebuilt_binaries/hook/build.dart b/examples/dart_only_prebuilt_binaries/hook/build.dart new file mode 100644 index 0000000..c1f1d9d --- /dev/null +++ b/examples/dart_only_prebuilt_binaries/hook/build.dart @@ -0,0 +1,11 @@ +import 'package:hooks/hooks.dart'; +import 'package:native_toolchain_rust/native_toolchain_rust.dart'; + +void main(List args) async { + await build(args, (input, output) async { + await const RustBuilder(assetName: 'src/ffi.g.dart').run( + input: input, + output: output, + ); + }); +} diff --git a/examples/dart_only_prebuilt_binaries/lib/dart_only_prebuilt_binaries_example.dart b/examples/dart_only_prebuilt_binaries/lib/dart_only_prebuilt_binaries_example.dart new file mode 100644 index 0000000..3ad37ab --- /dev/null +++ b/examples/dart_only_prebuilt_binaries/lib/dart_only_prebuilt_binaries_example.dart @@ -0,0 +1,2 @@ +export 'package:dart_only_prebuilt_binaries_example/src/ffi.g.dart' + show rust_multiply; diff --git a/examples/dart_only_prebuilt_binaries/lib/src/ffi.g.dart b/examples/dart_only_prebuilt_binaries/lib/src/ffi.g.dart new file mode 100644 index 0000000..d6cd006 --- /dev/null +++ b/examples/dart_only_prebuilt_binaries/lib/src/ffi.g.dart @@ -0,0 +1,11 @@ +// AUTO GENERATED FILE, DO NOT EDIT. +// +// Generated by `package:ffigen`. +// ignore_for_file: type=lint, unused_import +import 'dart:ffi' as ffi; + +@ffi.Native() +external int rust_multiply( + int a, + int b, +); diff --git a/examples/dart_only_prebuilt_binaries/pubspec.yaml b/examples/dart_only_prebuilt_binaries/pubspec.yaml new file mode 100644 index 0000000..fbc783b --- /dev/null +++ b/examples/dart_only_prebuilt_binaries/pubspec.yaml @@ -0,0 +1,17 @@ +name: dart_only_prebuilt_binaries_example +publish_to: none +resolution: workspace +# NOTE: the version is used to resolve `{version}` in the prebuilt binary +# download URL; see the README for more information. +version: 0.1.0 + +environment: + sdk: ^3.9.0 + +dependencies: + hooks: ^2.0.2 + native_toolchain_rust: ^1.0.4+0 + +dev_dependencies: + ffigen: ^20.1.0 + test: ^1.29.0 diff --git a/examples/dart_only_prebuilt_binaries/rust/Cargo.toml b/examples/dart_only_prebuilt_binaries/rust/Cargo.toml new file mode 100644 index 0000000..005dae9 --- /dev/null +++ b/examples/dart_only_prebuilt_binaries/rust/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "dart-only-prebuilt-binaries-example" +edition = "2024" + +[lib] +crate-type = ["staticlib", "cdylib"] + +[build-dependencies] +cbindgen = "0.29.0" diff --git a/examples/dart_only_prebuilt_binaries/rust/bindings.h b/examples/dart_only_prebuilt_binaries/rust/bindings.h new file mode 100644 index 0000000..7a30a34 --- /dev/null +++ b/examples/dart_only_prebuilt_binaries/rust/bindings.h @@ -0,0 +1,11 @@ +/* +This file is automatically generated by build.rs; DO NOT MANUALLY EDIT! +This header is designed to be consumed by ffigen over on the Dart side. +*/ + +#include +#include +#include +#include + +int32_t rust_multiply(int32_t a, int32_t b); diff --git a/examples/dart_only_prebuilt_binaries/rust/build.rs b/examples/dart_only_prebuilt_binaries/rust/build.rs new file mode 100644 index 0000000..d291de1 --- /dev/null +++ b/examples/dart_only_prebuilt_binaries/rust/build.rs @@ -0,0 +1,22 @@ +use std::env; + +fn main() { + create_rust_to_dart_bindings(); +} + +fn create_rust_to_dart_bindings() { + let crate_dir = env::var("CARGO_MANIFEST_DIR").unwrap(); + + cbindgen::Builder::new() + .with_crate(crate_dir) + .with_language(cbindgen::Language::C) + .with_autogen_warning( + "/* +This file is automatically generated by build.rs; DO NOT MANUALLY EDIT! +This header is designed to be consumed by ffigen over on the Dart side. +*/", + ) + .generate() + .expect("Unable to generate bindings") + .write_to_file("bindings.h"); +} diff --git a/examples/dart_only_prebuilt_binaries/rust/rust-toolchain.toml b/examples/dart_only_prebuilt_binaries/rust/rust-toolchain.toml new file mode 100644 index 0000000..a815736 --- /dev/null +++ b/examples/dart_only_prebuilt_binaries/rust/rust-toolchain.toml @@ -0,0 +1,28 @@ +[toolchain] +channel = "1.90.0" +profile = "default" +components = ["rust-analyzer"] + +targets = [ + # Android + "armv7-linux-androideabi", + "aarch64-linux-android", + "x86_64-linux-android", + + # iOS (device + simulator) + "aarch64-apple-ios", + "aarch64-apple-ios-sim", + "x86_64-apple-ios", + + # Windows + "aarch64-pc-windows-msvc", + "x86_64-pc-windows-msvc", + + # Linux + "aarch64-unknown-linux-gnu", + "x86_64-unknown-linux-gnu", + + # macOS + "aarch64-apple-darwin", + "x86_64-apple-darwin", +] diff --git a/examples/dart_only_prebuilt_binaries/rust/src/lib.rs b/examples/dart_only_prebuilt_binaries/rust/src/lib.rs new file mode 100644 index 0000000..60911a1 --- /dev/null +++ b/examples/dart_only_prebuilt_binaries/rust/src/lib.rs @@ -0,0 +1,12 @@ +#[unsafe(no_mangle)] +pub extern "C" fn rust_multiply(a: i32, b: i32) -> i32 { + a * b +} + +#[cfg(test)] +mod tests { + #[test] + fn test_rust_multiply() { + assert_eq!(crate::rust_multiply(2, 3), 6); + } +} diff --git a/examples/dart_only_prebuilt_binaries/test/dart_only_prebuilt_binaries_example_test.dart b/examples/dart_only_prebuilt_binaries/test/dart_only_prebuilt_binaries_example_test.dart new file mode 100644 index 0000000..bd06f17 --- /dev/null +++ b/examples/dart_only_prebuilt_binaries/test/dart_only_prebuilt_binaries_example_test.dart @@ -0,0 +1,8 @@ +import 'package:dart_only_prebuilt_binaries_example/dart_only_prebuilt_binaries_example.dart'; +import 'package:test/test.dart'; + +void main() { + test('rust_multiply correctly multiplies', () { + expect(rust_multiply(2, 3), equals(6)); + }); +} diff --git a/examples/dart_only_prebuilt_binaries/tool/ffigen.dart b/examples/dart_only_prebuilt_binaries/tool/ffigen.dart new file mode 100644 index 0000000..2b73644 --- /dev/null +++ b/examples/dart_only_prebuilt_binaries/tool/ffigen.dart @@ -0,0 +1,12 @@ +import 'dart:io'; + +import 'package:ffigen/ffigen.dart'; + +void main() { + final packageRoot = Platform.script.resolve('../'); + FfiGenerator( + headers: Headers(entryPoints: [packageRoot.resolve('rust/bindings.h')]), + output: Output(dartFile: packageRoot.resolve('lib/src/ffi.g.dart')), + functions: Functions.includeSet({'rust_multiply'}), + ).generate(); +} diff --git a/native_toolchain_rust/lib/native_toolchain_rust.dart b/native_toolchain_rust/lib/native_toolchain_rust.dart index 38e1e03..9023b96 100644 --- a/native_toolchain_rust/lib/native_toolchain_rust.dart +++ b/native_toolchain_rust/lib/native_toolchain_rust.dart @@ -9,6 +9,7 @@ import 'package:native_toolchain_rust/src/build_runner.dart'; import 'package:native_toolchain_rust/src/crate_info_validator.dart'; import 'package:native_toolchain_rust/src/crate_resolver.dart'; import 'package:native_toolchain_rust/src/dependency_discoverer.dart'; +import 'package:native_toolchain_rust/src/prebuilt_binaries.dart'; import 'package:native_toolchain_rust/src/process_runner.dart'; import 'package:native_toolchain_rust/src/toml_parsing.dart'; @@ -24,6 +25,12 @@ enum BuildMode { } /// Builds a Rust project via `rustup`. +/// +/// Note that downstream users may also opt in to downloading a prebuilt +/// binary by creating a `native_toolchain_rust.toml` file in the root of +/// their project in which case `rustup` is not required. +/// For more information, see +/// https://github.com/GregoryConrad/native_toolchain_rust?tab=readme-ov-file#prebuilt-binaries final class RustBuilder implements Builder { /// Creates a [RustBuilder] with the supplied configuration. const RustBuilder({ @@ -110,6 +117,17 @@ final class RustBuilder implements Builder { cargoManifestParser: cargoManifestParser, ); final dependencyDiscoverer = DependencyDiscoverer(logger); + final prebuiltBinaryFetcher = PrebuiltBinaryFetcher( + logger: logger, + rootProjectResolver: const RootProjectResolver(), + configParser: PrebuiltBinaryConfigParser( + logger, + tomlDocumentWrapperFactory, + ), + pubspecVersionParser: PubspecVersionParser(logger), + downloadUrlResolver: DownloadUrlResolver(logger), + downloader: PrebuiltBinaryDownloader(logger), + ); return RustBuildRunner( config: this, @@ -119,6 +137,7 @@ final class RustBuilder implements Builder { buildEnvironmentFactory: buildEnvironmentFactory, crateInfoValidator: crateInfoValidator, dependencyDiscoverer: dependencyDiscoverer, + prebuiltBinaryFetcher: prebuiltBinaryFetcher, ).run(input: input, output: output, assetRouting: assetRouting); } } diff --git a/native_toolchain_rust/lib/src/build_runner.dart b/native_toolchain_rust/lib/src/build_runner.dart index ea548d4..4a91d21 100644 --- a/native_toolchain_rust/lib/src/build_runner.dart +++ b/native_toolchain_rust/lib/src/build_runner.dart @@ -10,6 +10,7 @@ import 'package:native_toolchain_rust/src/config_mapping.dart'; import 'package:native_toolchain_rust/src/crate_info_validator.dart'; import 'package:native_toolchain_rust/src/crate_resolver.dart'; import 'package:native_toolchain_rust/src/dependency_discoverer.dart'; +import 'package:native_toolchain_rust/src/prebuilt_binaries.dart'; import 'package:native_toolchain_rust/src/process_runner.dart'; import 'package:path/path.dart' as path; @@ -23,6 +24,7 @@ interface class RustBuildRunner { required this.buildEnvironmentFactory, required this.crateInfoValidator, required this.dependencyDiscoverer, + required this.prebuiltBinaryFetcher, }); final RustBuilder config; @@ -32,6 +34,7 @@ interface class RustBuildRunner { final BuildEnvironmentFactory buildEnvironmentFactory; final CrateInfoValidator crateInfoValidator; final DependencyDiscoverer dependencyDiscoverer; + final PrebuiltBinaryFetcher prebuiltBinaryFetcher; Future run({ required BuildInput input, @@ -78,6 +81,30 @@ interface class RustBuildRunner { toolchainTomlPath: path.join(crateDirectory.path, 'rust-toolchain.toml'), ); + logger.info('Checking if a prebuilt binary was requested'); + final prebuiltBinary = await prebuiltBinaryFetcher.fetch( + packageName: input.packageName, + packageRootPath: path.fromUri(input.packageRoot), + sharedOutputDirectoryPath: path.fromUri(input.outputDirectoryShared), + crateName: crateName, + targetTriple: targetTriple, + targetOS: targetOS, + linkMode: linkMode, + ); + if (prebuiltBinary != null) { + logger.info('Using the prebuilt binary and skipping the cargo build'); + output.dependencies.addAll(prebuiltBinary.dependencies.map(path.toUri)); + _addCodeAssets( + input: input, + assetName: assetName, + output: output, + assetRouting: assetRouting, + linkMode: linkMode, + binaryFilePath: prebuiltBinary.binaryFilePath, + ); + return; + } + logger.info('Ensuring $toolchainChannel is installed'); await ensureToolchainDownloaded(crateDirectory.path); @@ -122,6 +149,24 @@ interface class RustBuildRunner { dependencyDiscoverer.discover(path.setExtension(binaryFilePath, '.d')), ); + _addCodeAssets( + input: input, + assetName: assetName, + output: output, + assetRouting: assetRouting, + linkMode: linkMode, + binaryFilePath: binaryFilePath, + ); + } + + void _addCodeAssets({ + required BuildInput input, + required String assetName, + required BuildOutputBuilder output, + required List assetRouting, + required LinkMode linkMode, + required String binaryFilePath, + }) { for (final routing in assetRouting) { output.assets.code.add( CodeAsset( diff --git a/native_toolchain_rust/lib/src/exception.dart b/native_toolchain_rust/lib/src/exception.dart index b0be551..1ff198a 100644 --- a/native_toolchain_rust/lib/src/exception.dart +++ b/native_toolchain_rust/lib/src/exception.dart @@ -77,6 +77,19 @@ final class RustProcessException implements RustBuildException { ')'; } +/// A [RustBuildException] that specifies there was an issue +/// while downloading a prebuilt binary. +final class RustPrebuiltBinaryException implements RustBuildException { + /// Creates a [RustPrebuiltBinaryException] with [message]. + const RustPrebuiltBinaryException(this.message); + + /// The message associated with this [RustPrebuiltBinaryException]. + final String message; + + @override + String toString() => 'RustPrebuiltBinaryException(message: $message)'; +} + // NOTE: this is here so that end-users can't exhaustively pattern match // (and thus gives us some API flexibility for new types) // ignore: unused_element diff --git a/native_toolchain_rust/lib/src/prebuilt_binaries.dart b/native_toolchain_rust/lib/src/prebuilt_binaries.dart new file mode 100644 index 0000000..1cb7c7c --- /dev/null +++ b/native_toolchain_rust/lib/src/prebuilt_binaries.dart @@ -0,0 +1,340 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:code_assets/code_assets.dart'; +import 'package:crypto/crypto.dart'; +import 'package:logging/logging.dart'; +import 'package:meta/meta.dart'; +import 'package:native_toolchain_rust/src/exception.dart'; +import 'package:native_toolchain_rust/src/toml_parsing.dart'; +import 'package:path/path.dart' as path; + +/// The name of the (optional) config file that downstream users may place in +/// the root of their project to opt in to downloading prebuilt binaries. +@internal +const prebuiltBinaryConfigFileName = 'native_toolchain_rust.toml'; + +@internal +interface class RootProjectResolver { + const RootProjectResolver(); + + /// Resolves the root path of the project currently being built. + String? resolveRootProjectPath(String outputDirectoryPath) { + final segments = path.split(path.normalize(outputDirectoryPath)); + final dartToolIndex = segments.lastIndexOf('.dart_tool'); + if (dartToolIndex <= 0) return null; + return path.joinAll(segments.take(dartToolIndex)); + } +} + +@internal +interface class PrebuiltBinaryConfigParser { + const PrebuiltBinaryConfigParser(this.logger, this.tomlDocumentFactory); + final Logger logger; + final TomlDocumentWrapperFactory tomlDocumentFactory; + + /// Returns the templated download URL configured for [packageName], + /// or null if the config file at [configFilePath] does not exist + /// or does not contain an entry for [packageName]. + String? parseUrlTemplate({ + required String configFilePath, + required String packageName, + }) { + if (!File(configFilePath).existsSync()) { + logger.info( + 'No $prebuiltBinaryConfigFileName found at $configFilePath; ' + 'building from source', + ); + return null; + } + + logger.info('Parsing $configFilePath'); + final TomlDocumentWrapper config; + try { + config = tomlDocumentFactory.parseFile(configFilePath); + } on Object catch (exception, stackTrace) { + logger.severe( + 'Failed to parse $prebuiltBinaryConfigFileName', + exception, + stackTrace, + ); + throw RustValidationException([ + ''' +Failed to parse the $prebuiltBinaryConfigFileName file at $configFilePath. +For more information, see https://github.com/GregoryConrad/native_toolchain_rust?tab=readme-ov-file#prebuilt-binaries +The following exception was thrown: $exception''', + ]); + } + + final prebuiltBinaries = config.document.toMap()['prebuilt-binaries']; + final packageEntry = prebuiltBinaries is Map + ? prebuiltBinaries[packageName] + : null; + if (prebuiltBinaries == null || packageEntry == null) { + logger.info( + 'No `prebuilt-binaries.$packageName` entry found in $configFilePath; ' + 'building from source', + ); + return null; + } + + final urlTemplate = packageEntry is Map + ? packageEntry['url'] + : null; + if (urlTemplate is! String) { + throw RustValidationException([ + ''' +The `prebuilt-binaries.$packageName` entry in $configFilePath is malformed. +The entry must specify a `url`, for example: +[prebuilt-binaries.$packageName] +url = "https://github.com/some-user/some-repo/releases/download/v{version}/my_crate-{target}.bin" +For more information, see https://github.com/GregoryConrad/native_toolchain_rust?tab=readme-ov-file#prebuilt-binaries''', + ]); + } + + return urlTemplate; + } +} + +@internal +interface class PubspecVersionParser { + const PubspecVersionParser(this.logger); + final Logger logger; + + static final _versionPattern = RegExp( + r'''^version:\s*['"]?([^'"#\s]+)''', + ); + + /// Parses the `version` field out of the pubspec.yaml at [pubspecPath]. + String parseVersion(String pubspecPath) { + logger.info('Looking for the package version in $pubspecPath'); + final pubspecFile = File(pubspecPath); + if (!pubspecFile.existsSync()) { + throw RustValidationException([ + 'The pubspec.yaml file was not found at $pubspecPath.', + ]); + } + + final version = pubspecFile + .readAsLinesSync() + .map((line) => _versionPattern.firstMatch(line)?.group(1)) + .nonNulls + .firstOrNull; + if (version == null) { + throw RustValidationException([ + ''' +The pubspec.yaml file at $pubspecPath does not specify a `version` field, +which is required to resolve the `{version}` in prebuilt binary download URLs. +For more information, see https://github.com/GregoryConrad/native_toolchain_rust?tab=readme-ov-file#prebuilt-binaries''', + ]); + } + return version; + } +} + +@internal +interface class DownloadUrlResolver { + const DownloadUrlResolver(this.logger); + final Logger logger; + + /// Resolves [urlTemplate] into a concrete download [Uri] by substituting + /// all `{placeholder}`s with their values from [templateValues]. + Uri resolveDownloadUrl({ + required String urlTemplate, + required Map templateValues, + }) { + logger.info('Resolving download URL template: $urlTemplate'); + var url = urlTemplate; + for (final MapEntry(:key, :value) in templateValues.entries) { + url = url.replaceAll('{$key}', value); + } + + final unknownPlaceholders = RegExp( + r'\{[^{}]*\}', + ).allMatches(url).map((match) => match.group(0)).toList(); + if (unknownPlaceholders.isNotEmpty) { + throw RustValidationException([ + ''' +The prebuilt binary URL template `$urlTemplate` contains unsupported placeholders: $unknownPlaceholders. +The supported placeholders are: ${templateValues.keys.map((key) => '{$key}').join(', ')}. +For more information, see https://github.com/GregoryConrad/native_toolchain_rust?tab=readme-ov-file#prebuilt-binaries''', + ]); + } + + final uri = Uri.tryParse(url); + if (uri == null || !const {'http', 'https'}.contains(uri.scheme)) { + throw RustValidationException([ + ''' +The prebuilt binary URL template `$urlTemplate` resolved to `$url`, +which is not a valid http/https URL. +For more information, see https://github.com/GregoryConrad/native_toolchain_rust?tab=readme-ov-file#prebuilt-binaries''', + ]); + } + + logger.info('Resolved download URL: $uri'); + return uri; + } +} + +@internal +interface class PrebuiltBinaryDownloader { + const PrebuiltBinaryDownloader(this.logger); + final Logger logger; + + /// Downloads the file at [url] to [destinationPath], + /// following any redirects along the way. + /// + /// The file only ever appears at [destinationPath] in its entirety; + /// a failed/interrupted download will not leave a corrupt file behind + /// (which is essential, as [destinationPath] may be in a shared cache). + Future download({ + required Uri url, + required String destinationPath, + }) async { + logger.info('Downloading prebuilt binary from $url to $destinationPath'); + final client = HttpClient(); + final temporaryFile = File( + '$destinationPath.$pid.${DateTime.now().microsecondsSinceEpoch}.part', + ); + try { + final request = await client.getUrl(url); + final response = await request.close(); + if (response.statusCode != HttpStatus.ok) { + await response.drain(); + throw RustPrebuiltBinaryException( + 'Failed to download the prebuilt binary from $url; ' + 'the server responded with HTTP status code ' + '${response.statusCode}. ' + 'Please ensure the URL template in your ' + '$prebuiltBinaryConfigFileName is correct ' + 'and that a binary was published for this version and target.', + ); + } + + temporaryFile.parent.createSync(recursive: true); + await response.pipe(temporaryFile.openWrite()); + temporaryFile.renameSync(destinationPath); + logger.info('Finished downloading prebuilt binary to $destinationPath'); + } on IOException catch (exception, stackTrace) { + logger.severe( + 'Failed to download prebuilt binary from $url', + exception, + stackTrace, + ); + throw RustPrebuiltBinaryException( + 'Failed to download the prebuilt binary from $url; ' + 'please check your network connection and the URL template in your ' + '$prebuiltBinaryConfigFileName. ' + 'The following exception was thrown: $exception', + ); + } finally { + client.close(); + if (temporaryFile.existsSync()) { + temporaryFile.deleteSync(); + } + } + } +} + +@internal +interface class PrebuiltBinaryFetcher { + const PrebuiltBinaryFetcher({ + required this.logger, + required this.rootProjectResolver, + required this.configParser, + required this.pubspecVersionParser, + required this.downloadUrlResolver, + required this.downloader, + }); + + final Logger logger; + final RootProjectResolver rootProjectResolver; + final PrebuiltBinaryConfigParser configParser; + final PubspecVersionParser pubspecVersionParser; + final DownloadUrlResolver downloadUrlResolver; + final PrebuiltBinaryDownloader downloader; + + /// Checks whether the root project opted in to a prebuilt binary for + /// [packageName] (via a [prebuiltBinaryConfigFileName] file in its root), + /// and if so, downloads the binary for [targetTriple]. + /// + /// Returns the path of the downloaded binary alongside the files the build + /// hook now depends upon, or null if no prebuilt binary was configured + /// (in which case the caller should build from source). + Future<({String binaryFilePath, List dependencies})?> fetch({ + required String packageName, + required String packageRootPath, + required String sharedOutputDirectoryPath, + required String crateName, + required String targetTriple, + required OS targetOS, + required LinkMode linkMode, + }) async { + final rootProjectPath = rootProjectResolver.resolveRootProjectPath( + sharedOutputDirectoryPath, + ); + if (rootProjectPath == null) { + logger.info( + 'Could not resolve the root project from $sharedOutputDirectoryPath; ' + 'skipping the prebuilt binary check and building from source', + ); + return null; + } + + final configFilePath = path.join( + rootProjectPath, + prebuiltBinaryConfigFileName, + ); + final urlTemplate = configParser.parseUrlTemplate( + configFilePath: configFilePath, + packageName: packageName, + ); + if (urlTemplate == null) return null; + + logger.info( + 'Found a prebuilt binary configuration for $packageName ' + 'in $configFilePath', + ); + + final pubspecPath = path.join(packageRootPath, 'pubspec.yaml'); + // The crate name with `-` normalized to `_`, matching how Cargo names the + // library it produces. + final normalizedCrateName = crateName.replaceAll('-', '_'); + final libraryFileName = targetOS.libraryFileName( + normalizedCrateName, + linkMode, + ); + final url = downloadUrlResolver.resolveDownloadUrl( + urlTemplate: urlTemplate, + templateValues: { + 'version': pubspecVersionParser.parseVersion(pubspecPath), + 'target': targetTriple, + 'crate-name': normalizedCrateName, + }, + ); + + // NOTE: binaries are cached in the (config-independent) shared output + // directory, keyed by their resolved download URL. A new package version + // or an edited URL template yields a new URL (and thus a fresh download), + // while build hook re-runs with an unchanged URL reuse the cached binary. + final urlChecksum = sha256.convert(utf8.encode(url.toString())); + final binaryFilePath = path.join( + sharedOutputDirectoryPath, + 'prebuilt', + urlChecksum.toString(), + libraryFileName, + ); + if (File(binaryFilePath).existsSync()) { + logger.info('Reusing the cached prebuilt binary at $binaryFilePath'); + } else { + await downloader.download(url: url, destinationPath: binaryFilePath); + } + + // NOTE: re-run the build hook whenever the prebuilt binary config + // (download URL) or the package version may have changed. + return ( + binaryFilePath: binaryFilePath, + dependencies: [configFilePath, pubspecPath], + ); + } +} diff --git a/native_toolchain_rust/pubspec.yaml b/native_toolchain_rust/pubspec.yaml index 7b13c54..bc82d38 100644 --- a/native_toolchain_rust/pubspec.yaml +++ b/native_toolchain_rust/pubspec.yaml @@ -13,6 +13,7 @@ environment: dependencies: code_assets: ^1.2.1 + crypto: ^3.0.6 hooks: ^2.0.2 logging: ^1.3.0 meta: ^1.17.0 diff --git a/native_toolchain_rust/test/prebuilt_binaries_test.dart b/native_toolchain_rust/test/prebuilt_binaries_test.dart new file mode 100644 index 0000000..c8e5afc --- /dev/null +++ b/native_toolchain_rust/test/prebuilt_binaries_test.dart @@ -0,0 +1,617 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:code_assets/code_assets.dart'; +import 'package:crypto/crypto.dart'; +import 'package:logging/logging.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:native_toolchain_rust/src/exception.dart'; +import 'package:native_toolchain_rust/src/prebuilt_binaries.dart'; +import 'package:native_toolchain_rust/src/toml_parsing.dart'; +import 'package:path/path.dart' as path; +import 'package:test/test.dart'; + +class MockRootProjectResolver extends Mock implements RootProjectResolver {} + +class MockPrebuiltBinaryConfigParser extends Mock + implements PrebuiltBinaryConfigParser {} + +class MockPubspecVersionParser extends Mock implements PubspecVersionParser {} + +class MockDownloadUrlResolver extends Mock implements DownloadUrlResolver {} + +class MockPrebuiltBinaryDownloader extends Mock + implements PrebuiltBinaryDownloader {} + +void main() { + final logger = Logger.detached('prebuilt_binaries_test'); + + group('RootProjectResolver', () { + const resolver = RootProjectResolver(); + + test('resolveRootProjectPath returns the parent of .dart_tool', () { + final outputDirectory = path.join( + path.separator, + 'home', + 'user', + 'my_app', + '.dart_tool', + 'hooks_runner', + 'shared', + 'some_package', + 'out', + ); + + expect( + resolver.resolveRootProjectPath(outputDirectory), + path.join(path.separator, 'home', 'user', 'my_app'), + ); + }); + + test('resolveRootProjectPath returns null when .dart_tool is absent', () { + final outputDirectory = path.join( + path.separator, + 'home', + 'user', + 'somewhere_else', + ); + + expect(resolver.resolveRootProjectPath(outputDirectory), isNull); + }); + }); + + group('PrebuiltBinaryConfigParser', () { + late Directory tempDir; + late String configFilePath; + late PrebuiltBinaryConfigParser parser; + + setUp(() { + tempDir = Directory.systemTemp.createTempSync('prebuilt_config_test'); + configFilePath = path.join(tempDir.path, prebuiltBinaryConfigFileName); + parser = PrebuiltBinaryConfigParser( + logger, + TomlDocumentWrapperFactory(logger), + ); + }); + + tearDown(() { + tempDir.deleteSync(recursive: true); + }); + + test('parseUrlTemplate returns null when config file does not exist', () { + expect( + parser.parseUrlTemplate( + configFilePath: configFilePath, + packageName: 'my_package', + ), + isNull, + ); + }); + + test('parseUrlTemplate returns null when package has no entry', () { + File(configFilePath).writeAsStringSync(''' +[prebuilt-binaries.other_package] +url = "https://example.com/{version}/{target}" +'''); + + expect( + parser.parseUrlTemplate( + configFilePath: configFilePath, + packageName: 'my_package', + ), + isNull, + ); + }); + + test('parseUrlTemplate returns the configured url template', () { + File(configFilePath).writeAsStringSync(''' +[prebuilt-binaries.my_package] +url = "https://example.com/{version}/{target}" +'''); + + expect( + parser.parseUrlTemplate( + configFilePath: configFilePath, + packageName: 'my_package', + ), + 'https://example.com/{version}/{target}', + ); + }); + + test( + 'parseUrlTemplate throws RustValidationException on malformed toml', + () { + File(configFilePath).writeAsStringSync('not [valid toml'); + + expect( + () => parser.parseUrlTemplate( + configFilePath: configFilePath, + packageName: 'my_package', + ), + throwsA(isA()), + ); + }, + ); + + test( + 'parseUrlTemplate throws RustValidationException on a malformed entry', + () { + File(configFilePath).writeAsStringSync(''' +[prebuilt-binaries] +my_package = "https://example.com/{version}/{target}" +'''); + + expect( + () => parser.parseUrlTemplate( + configFilePath: configFilePath, + packageName: 'my_package', + ), + throwsA(isA()), + ); + }, + ); + }); + + group('PubspecVersionParser', () { + late Directory tempDir; + late String pubspecPath; + late PubspecVersionParser parser; + + setUp(() { + tempDir = Directory.systemTemp.createTempSync('pubspec_version_test'); + pubspecPath = path.join(tempDir.path, 'pubspec.yaml'); + parser = PubspecVersionParser(logger); + }); + + tearDown(() { + tempDir.deleteSync(recursive: true); + }); + + test('parseVersion returns the version field', () { + File(pubspecPath).writeAsStringSync(''' +name: my_package +version: 1.2.3+4 +'''); + + expect(parser.parseVersion(pubspecPath), '1.2.3+4'); + }); + + test('parseVersion handles quotes and trailing comments', () { + File(pubspecPath).writeAsStringSync(''' +name: my_package +version: "1.2.3" # some comment +'''); + + expect(parser.parseVersion(pubspecPath), '1.2.3'); + }); + + test( + 'parseVersion throws RustValidationException when file is missing', + () { + expect( + () => parser.parseVersion(pubspecPath), + throwsA(isA()), + ); + }, + ); + + test( + 'parseVersion throws RustValidationException when version is missing', + () { + File(pubspecPath).writeAsStringSync('name: my_package'); + + expect( + () => parser.parseVersion(pubspecPath), + throwsA(isA()), + ); + }, + ); + }); + + group('DownloadUrlResolver', () { + final resolver = DownloadUrlResolver(logger); + + test('resolveDownloadUrl substitutes all placeholders', () { + final url = resolver.resolveDownloadUrl( + urlTemplate: + 'https://github.com/u/r/releases/download/v{version}' + '/{target}-{crate-name}', + templateValues: { + 'version': '1.2.3', + 'target': 'aarch64-apple-darwin', + 'crate-name': 'my_crate', + }, + ); + + expect( + url, + Uri.parse( + 'https://github.com/u/r/releases/download/v1.2.3' + '/aarch64-apple-darwin-my_crate', + ), + ); + }); + + test( + 'resolveDownloadUrl throws RustValidationException ' + 'on unknown placeholders', + () { + expect( + () => resolver.resolveDownloadUrl( + urlTemplate: 'https://example.com/{typo}', + templateValues: {'version': '1.2.3'}, + ), + throwsA(isA()), + ); + }, + ); + + test( + 'resolveDownloadUrl throws RustValidationException ' + 'on non-http(s) URLs', + () { + expect( + () => resolver.resolveDownloadUrl( + urlTemplate: 'file:///etc/passwd', + templateValues: {'version': '1.2.3'}, + ), + throwsA(isA()), + ); + }, + ); + }); + + group('PrebuiltBinaryDownloader', () { + late Directory tempDir; + late HttpServer server; + late Uri serverUri; + final downloader = PrebuiltBinaryDownloader(logger); + + setUp(() async { + tempDir = Directory.systemTemp.createTempSync('downloader_test'); + server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + serverUri = Uri.parse('http://localhost:${server.port}'); + server.listen((request) { + switch (request.uri.path) { + case '/binary': + request.response.add([1, 2, 3, 4]); + case '/redirect': + unawaited( + request.response.redirect(serverUri.replace(path: '/binary')), + ); + return; + case '/interrupted': + // NOTE: hand-craft a partial response (3 of 100 promised bytes) + // and then kill the connection, so the client reliably sees an + // interrupted transfer. + unawaited(() async { + final socket = await request.response.detachSocket( + writeHeaders: false, + ); + socket.write('HTTP/1.1 200 OK\r\ncontent-length: 100\r\n\r\nabc'); + await socket.flush(); + socket.destroy(); + }()); + return; + default: + request.response.statusCode = HttpStatus.notFound; + } + unawaited(request.response.close()); + }); + }); + + tearDown(() async { + await server.close(force: true); + tempDir.deleteSync(recursive: true); + }); + + test('download writes the response body to the destination', () async { + final destinationPath = path.join(tempDir.path, 'nested', 'lib.so'); + + await downloader.download( + url: serverUri.replace(path: '/binary'), + destinationPath: destinationPath, + ); + + expect(File(destinationPath).readAsBytesSync(), [1, 2, 3, 4]); + }); + + test('download follows redirects', () async { + final destinationPath = path.join(tempDir.path, 'lib.so'); + + await downloader.download( + url: serverUri.replace(path: '/redirect'), + destinationPath: destinationPath, + ); + + expect(File(destinationPath).readAsBytesSync(), [1, 2, 3, 4]); + }); + + test( + 'download throws RustPrebuiltBinaryException on non-200 responses', + () async { + expect( + () => downloader.download( + url: serverUri.replace(path: '/missing'), + destinationPath: path.join(tempDir.path, 'lib.so'), + ), + throwsA(isA()), + ); + }, + ); + + test( + 'download leaves no file behind when interrupted mid-transfer', + () async { + final destinationPath = path.join(tempDir.path, 'lib.so'); + + await expectLater( + () => downloader.download( + url: serverUri.replace(path: '/interrupted'), + destinationPath: destinationPath, + ), + throwsA(isA()), + ); + + expect(tempDir.listSync(), isEmpty); + }, + ); + + test( + 'download throws RustPrebuiltBinaryException on connection errors', + () async { + await server.close(force: true); + + expect( + () => downloader.download( + url: serverUri.replace(path: '/binary'), + destinationPath: path.join(tempDir.path, 'lib.so'), + ), + throwsA(isA()), + ); + }, + ); + }); + + group('PrebuiltBinaryFetcher', () { + late MockRootProjectResolver mockRootProjectResolver; + late MockPrebuiltBinaryConfigParser mockConfigParser; + late MockPubspecVersionParser mockPubspecVersionParser; + late MockDownloadUrlResolver mockDownloadUrlResolver; + late MockPrebuiltBinaryDownloader mockDownloader; + late PrebuiltBinaryFetcher fetcher; + + late Directory tempDir; + late String sharedOutputDirectoryPath; + + final rootProjectPath = path.join(path.separator, 'home', 'user', 'app'); + final packageRootPath = path.join(path.separator, 'pub', 'my_package'); + final configFilePath = path.join( + rootProjectPath, + prebuiltBinaryConfigFileName, + ); + final pubspecPath = path.join(packageRootPath, 'pubspec.yaml'); + + String cachePathForUrl(Uri url, String libraryFileName) { + return path.join( + sharedOutputDirectoryPath, + 'prebuilt', + sha256.convert(utf8.encode(url.toString())).toString(), + libraryFileName, + ); + } + + setUpAll(() { + registerFallbackValue(Uri()); + }); + + setUp(() { + tempDir = Directory.systemTemp.createTempSync('prebuilt_fetcher_test'); + sharedOutputDirectoryPath = tempDir.path; + mockRootProjectResolver = MockRootProjectResolver(); + mockConfigParser = MockPrebuiltBinaryConfigParser(); + mockPubspecVersionParser = MockPubspecVersionParser(); + mockDownloadUrlResolver = MockDownloadUrlResolver(); + mockDownloader = MockPrebuiltBinaryDownloader(); + fetcher = PrebuiltBinaryFetcher( + logger: logger, + rootProjectResolver: mockRootProjectResolver, + configParser: mockConfigParser, + pubspecVersionParser: mockPubspecVersionParser, + downloadUrlResolver: mockDownloadUrlResolver, + downloader: mockDownloader, + ); + }); + + tearDown(() { + tempDir.deleteSync(recursive: true); + }); + + Future<({String binaryFilePath, List dependencies})?> fetch({ + LinkMode? linkMode, + }) { + return fetcher.fetch( + packageName: 'my_package', + packageRootPath: packageRootPath, + sharedOutputDirectoryPath: sharedOutputDirectoryPath, + crateName: 'my-crate', + targetTriple: 'x86_64-unknown-linux-gnu', + targetOS: OS.linux, + linkMode: linkMode ?? DynamicLoadingBundled(), + ); + } + + test('fetch returns null when the root project cannot be resolved', () { + when( + () => mockRootProjectResolver.resolveRootProjectPath(any()), + ).thenReturn(null); + + expect(fetch(), completion(isNull)); + }); + + test('fetch returns null when no url template is configured', () { + when( + () => mockRootProjectResolver.resolveRootProjectPath(any()), + ).thenReturn(rootProjectPath); + when( + () => mockConfigParser.parseUrlTemplate( + configFilePath: any(named: 'configFilePath'), + packageName: any(named: 'packageName'), + ), + ).thenReturn(null); + + expect(fetch(), completion(isNull)); + }); + + test('fetch downloads the binary and returns its path', () async { + final downloadUrl = Uri.parse( + 'https://example.com/1.2.3/x86_64-unknown-linux-gnu', + ); + when( + () => mockRootProjectResolver.resolveRootProjectPath(any()), + ).thenReturn(rootProjectPath); + when( + () => mockConfigParser.parseUrlTemplate( + configFilePath: any(named: 'configFilePath'), + packageName: any(named: 'packageName'), + ), + ).thenReturn('https://example.com/{version}/{target}'); + when( + () => mockPubspecVersionParser.parseVersion(any()), + ).thenReturn('1.2.3'); + when( + () => mockDownloadUrlResolver.resolveDownloadUrl( + urlTemplate: any(named: 'urlTemplate'), + templateValues: any(named: 'templateValues'), + ), + ).thenReturn(downloadUrl); + when( + () => mockDownloader.download( + url: any(named: 'url'), + destinationPath: any(named: 'destinationPath'), + ), + ).thenAnswer((_) async {}); + + final result = await fetch(); + + final expectedBinaryFilePath = cachePathForUrl( + downloadUrl, + 'libmy_crate.so', + ); + expect(result?.binaryFilePath, expectedBinaryFilePath); + expect(result?.dependencies, [configFilePath, pubspecPath]); + verify( + () => mockConfigParser.parseUrlTemplate( + configFilePath: configFilePath, + packageName: 'my_package', + ), + ).called(1); + verify( + () => mockDownloadUrlResolver.resolveDownloadUrl( + urlTemplate: 'https://example.com/{version}/{target}', + templateValues: { + 'version': '1.2.3', + 'target': 'x86_64-unknown-linux-gnu', + 'crate-name': 'my_crate', + }, + ), + ).called(1); + verify( + () => mockDownloader.download( + url: downloadUrl, + destinationPath: expectedBinaryFilePath, + ), + ).called(1); + }); + + test('fetch skips the download when the binary is cached', () async { + final downloadUrl = Uri.parse( + 'https://example.com/1.2.3/x86_64-unknown-linux-gnu', + ); + when( + () => mockRootProjectResolver.resolveRootProjectPath(any()), + ).thenReturn(rootProjectPath); + when( + () => mockConfigParser.parseUrlTemplate( + configFilePath: any(named: 'configFilePath'), + packageName: any(named: 'packageName'), + ), + ).thenReturn('https://example.com/{version}/{target}'); + when( + () => mockPubspecVersionParser.parseVersion(any()), + ).thenReturn('1.2.3'); + when( + () => mockDownloadUrlResolver.resolveDownloadUrl( + urlTemplate: any(named: 'urlTemplate'), + templateValues: any(named: 'templateValues'), + ), + ).thenReturn(downloadUrl); + final cachedBinaryFile = File( + cachePathForUrl(downloadUrl, 'libmy_crate.so'), + ); + cachedBinaryFile.parent.createSync(recursive: true); + cachedBinaryFile.createSync(); + + final result = await fetch(); + + expect(result?.binaryFilePath, cachedBinaryFile.path); + verifyNever( + () => mockDownloader.download( + url: any(named: 'url'), + destinationPath: any(named: 'destinationPath'), + ), + ); + }); + + test( + 'fetch resolves the staticlib name when static linking is requested', + () async { + when( + () => mockRootProjectResolver.resolveRootProjectPath(any()), + ).thenReturn(rootProjectPath); + when( + () => mockConfigParser.parseUrlTemplate( + configFilePath: any(named: 'configFilePath'), + packageName: any(named: 'packageName'), + ), + ).thenReturn('https://example.com/{crate-name}'); + when( + () => mockPubspecVersionParser.parseVersion(any()), + ).thenReturn('1.2.3'); + when( + () => mockDownloadUrlResolver.resolveDownloadUrl( + urlTemplate: any(named: 'urlTemplate'), + templateValues: any(named: 'templateValues'), + ), + ).thenReturn(Uri.parse('https://example.com/libmy_crate.a')); + when( + () => mockDownloader.download( + url: any(named: 'url'), + destinationPath: any(named: 'destinationPath'), + ), + ).thenAnswer((_) async {}); + + final result = await fetch(linkMode: StaticLinking()); + + expect( + result?.binaryFilePath, + cachePathForUrl( + Uri.parse('https://example.com/libmy_crate.a'), + 'libmy_crate.a', + ), + ); + verify( + () => mockDownloadUrlResolver.resolveDownloadUrl( + urlTemplate: 'https://example.com/{crate-name}', + templateValues: { + 'version': '1.2.3', + 'target': 'x86_64-unknown-linux-gnu', + 'crate-name': 'my_crate', + }, + ), + ).called(1); + }, + ); + }); +} diff --git a/pubspec.yaml b/pubspec.yaml index 704b275..478efa6 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -8,6 +8,7 @@ environment: workspace: - native_toolchain_rust - examples/dart_only + - examples/dart_only_prebuilt_binaries - examples/flutter dev_dependencies: