diff --git a/.gitattributes b/.gitattributes index a2e74c4..2d9bc2f 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,4 +1,5 @@ -# Source fingerprints must be identical on Windows and Unix checkouts. +# Line endings stay LF on every checkout, Windows included, so every host +# builds from the same bytes. hook/** text eol=lf rust/src/** text eol=lf rust/Cargo.toml text eol=lf diff --git a/.github/workflows/native-prebuilts.yml b/.github/workflows/native-prebuilts.yml deleted file mode 100644 index a889755..0000000 --- a/.github/workflows/native-prebuilts.yml +++ /dev/null @@ -1,197 +0,0 @@ -name: Build native prebuilts - -on: - push: - tags: ['native-*'] - workflow_dispatch: - -permissions: - contents: read - -concurrency: - group: native-prebuilts-${{ github.ref }} - cancel-in-progress: false - -jobs: - build: - if: startsWith(github.ref, 'refs/tags/native-') && github.event.deleted != true - name: ${{ matrix.target }} - runs-on: ${{ matrix.runner }} - timeout-minutes: 120 - strategy: - fail-fast: false - matrix: - include: - - target: x86_64-unknown-linux-gnu - runner: ubuntu-22.04 - smoke_library: libmwc_wallet.so - - target: x86_64-pc-windows-msvc - runner: windows-2022 - smoke_library: mwc_wallet.dll - - target: x86_64-apple-darwin - runner: macos-15-intel - smoke_library: libmwc_wallet.dylib - - target: aarch64-apple-darwin - runner: macos-15 - smoke_library: libmwc_wallet.dylib - - target: armv7-linux-androideabi - runner: ubuntu-22.04 - - target: aarch64-linux-android - runner: ubuntu-22.04 - - target: x86_64-linux-android - runner: ubuntu-22.04 - - target: aarch64-apple-ios - runner: macos-15 - - target: aarch64-apple-ios-sim - runner: macos-15 - - target: x86_64-apple-ios - runner: macos-15-intel - steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - with: - persist-credentials: false - - uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 - with: - channel: stable - flutter-version: '3.47.2' - cache: true - - run: flutter pub get - - name: Use short native build and Cargo paths - shell: bash - run: | - echo "CARGO_HOME=$RUNNER_TEMP/mwc-cargo" >> "$GITHUB_ENV" - echo "NATIVE_TARGET_DIR=$RUNNER_TEMP/mwc-target" >> "$GITHUB_ENV" - - name: Cache Cargo downloads - uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5 - with: - path: | - ${{ env.CARGO_HOME }}/registry - ${{ env.CARGO_HOME }}/git - key: cargo-downloads-${{ runner.os }}-${{ matrix.target }}-${{ hashFiles('rust/Cargo.lock', 'rust/rust-toolchain.toml') }} - - name: Install Linux build dependencies - if: runner.os == 'Linux' - run: | - sudo apt-get update - sudo apt-get install -y build-essential clang libclang-dev cmake pkg-config nasm perl protobuf-compiler - - name: Install Apple build dependencies - if: runner.os == 'macOS' - shell: bash - run: | - brew install protobuf cmake pkgconf nasm - echo "LIBCLANG_PATH=$(dirname "$(xcrun --find clang)")/../lib" >> "$GITHUB_ENV" - - name: Install Windows build dependencies - if: runner.os == 'Windows' - shell: pwsh - run: | - git config --global core.longpaths true - choco install protoc nasm --yes --no-progress - "LIBCLANG_PATH=C:\Program Files\LLVM\bin" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - - name: Install pinned Android NDK - if: contains(matrix.target, 'android') - shell: bash - run: | - "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" 'ndk;28.2.13676358' - echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/28.2.13676358" >> "$GITHUB_ENV" - - name: Build and package both link modes - env: - NATIVE_TARGET: ${{ matrix.target }} - shell: bash - run: dart --packages=.dart_tool/package_config.json tool/build_prebuilt.dart --target "$NATIVE_TARGET" --target-dir "$NATIVE_TARGET_DIR" --output build/prebuilts - - name: Smoke test the desktop C ABI - if: matrix.smoke_library != '' - env: - NATIVE_TARGET: ${{ matrix.target }} - SMOKE_LIBRARY: ${{ matrix.smoke_library }} - shell: bash - run: dart --packages=.dart_tool/package_config.json tool/smoke_prebuilt.dart "$NATIVE_TARGET_DIR/$NATIVE_TARGET/release/$SMOKE_LIBRARY" - - name: Upload verified target artifacts - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 - with: - name: prebuilt-${{ matrix.target }} - path: build/prebuilts/* - if-no-files-found: error - retention-days: 14 - compression-level: 1 - - release: - name: Publish verified prebuilts - needs: build - runs-on: ubuntu-22.04 - timeout-minutes: 30 - permissions: - contents: write - steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - with: - persist-credentials: false - - uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 - with: - channel: stable - flutter-version: '3.47.2' - cache: true - - run: flutter pub get - - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 - with: - pattern: prebuilt-* - path: build/downloaded - - name: Verify artifacts and assemble the manifest - run: dart --packages=.dart_tool/package_config.json tool/prebuilt_manifest.dart --artifacts build/downloaded --require-all - - name: Keep the complete release as a workflow artifact - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 - with: - name: native-release - path: build/native-release/* - if-no-files-found: error - retention-days: 14 - compression-level: 1 - - name: Publish release - env: - GH_TOKEN: ${{ github.token }} - RELEASE_TAG: ${{ github.ref_name }} - shell: bash - run: | - [[ "$RELEASE_TAG" =~ ^native-[A-Za-z0-9][A-Za-z0-9._-]*$ ]] || { echo 'Use a new native-* tag'; exit 1; } - source_commit=$(git rev-parse HEAD) - git fetch --depth=1 --no-tags origin "refs/tags/$RELEASE_TAG" - [[ "$(git rev-parse 'FETCH_HEAD^{commit}')" == "$source_commit" ]] || { echo 'Tag moved since this build started'; exit 1; } - read -r manifest_pin _ < build/native-release/manifest.sha256 - cat > "$RUNNER_TEMP/release-notes.md" < [!CAUTION] - > These binaries are provided AS IS, without warranty. Automated checks do not guarantee safety or correctness. Bugs may cause loss of funds or data. - - Built from source commit $source_commit. Use the matching package revision. - - Add to your root pubspec: - - hooks: - user_defines: - flutter_libmwc: - native_build: prebuilt - prebuilt_manifest_url: "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/releases/download/$RELEASE_TAG/manifest.json" - prebuilt_manifest_sha256: "$manifest_pin" - - Use the same root pubspec settings and normal Flutter commands in CI. - Each hook run downloads fresh, verified files. Flutter can reuse its - output for incremental builds; flutter clean removes it, so the next - build downloads again. In pub workspaces, clean from the workspace root. - Omit these settings or use native_build: source to build from source. - EOF - - if gh release view "$RELEASE_TAG" >/dev/null 2>&1; then - is_draft="$(gh release view "$RELEASE_TAG" --json isDraft --jq '.isDraft')" - - if [[ "$is_draft" == "true" ]]; then - echo "Draft release exists for $RELEASE_TAG; deleting stale draft and recreating it." - gh release delete "$RELEASE_TAG" --yes - else - echo "Published release already exists for $RELEASE_TAG; refusing to overwrite." - exit 1 - fi - fi - - # only create if the release does not already exist - gh release create "$RELEASE_TAG" build/native-release/* \ - --verify-tag --latest=false --title "$RELEASE_TAG" \ - --notes-file "$RUNNER_TEMP/release-notes.md" diff --git a/.github/workflows/native.yml b/.github/workflows/native.yml new file mode 100644 index 0000000..6752bb6 --- /dev/null +++ b/.github/workflows/native.yml @@ -0,0 +1,25 @@ +# Builds the Rust library on every reference host, fails if any host or the +# committed dnix.lock disagrees, and publishes the release the lock names +# when it reaches the default branch. +name: Native library + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: write + +# A newer push supersedes runs on a branch, never on main: a run there may +# be publishing. +concurrency: + group: native-${{ github.ref }} + cancel-in-progress: ${{ github.ref != format('refs/heads/{0}', github.event.repository.default_branch) }} + +jobs: + native: + # The same dnix commit as pubspec.yaml and flake.lock. + uses: cypherstack/dnix/.github/workflows/native.yml@e91763ef25d084b3a5cf271a0cfee69159b1f779 + with: + macos-runner: xcode-27 diff --git a/.github/workflows/prebuilt-checks.yml b/.github/workflows/prebuilt-checks.yml deleted file mode 100644 index 7a2370f..0000000 --- a/.github/workflows/prebuilt-checks.yml +++ /dev/null @@ -1,31 +0,0 @@ -name: Prebuilt integrity checks - -on: - pull_request: - push: - branches: [main, master] - workflow_dispatch: - -permissions: - contents: read - -jobs: - checks: - runs-on: ubuntu-22.04 - timeout-minutes: 20 - steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - with: - persist-credentials: false - - uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 - with: - channel: stable - flutter-version: '3.47.2' - cache: true - - run: flutter pub get - - name: Analyze the hook and release tooling - run: dart analyze hook tool test/prebuilt_test.dart test/prebuilt_producer_test.dart - - name: Test release tooling and the resolver without compiling Rust - run: dart --packages=.dart_tool/package_config.json tool/test_prebuilt.dart - - name: Check native build planning - run: dart --packages=.dart_tool/package_config.json tool/build_prebuilt.dart --target x86_64-unknown-linux-gnu --dry-run diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..89da744 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,60 @@ +name: Tests + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: tests-${{ github.ref }} + cancel-in-progress: true + +jobs: + flutter: + strategy: + fail-fast: false + matrix: + os: [ubuntu-24.04, xcode-27, windows-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 90 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2.23.0 + with: + flutter-version: 3.47.2 + channel: stable + cache: true + # The hook builds through the Nix flake; before the first native + # release that is the only path, and it is unavailable on Windows. + - uses: DeterminateSystems/nix-installer-action@3138316df39ed29be04236d7ffc686fa525866aa # v23 + if: runner.os != 'Windows' + - run: flutter pub get + - run: dart analyze hook tool test + # Prebuilt libraries exist once the lock's release is published from the + # default branch of the repository dnix.json names; until then only + # hosts with Nix can build them. + - name: Check whether the lock's release is published + id: release + shell: bash + run: | + repository=$(jq -r .repository dnix.json | sed 's/\.git$//; s#/$##') + tag="native-$(jq -r .release dnix.lock)" + echo "published=$([ "$(curl -s -o /dev/null -w '%{http_code}' "$repository/releases/tag/$tag")" = 200 ] && echo true || echo false)" >> "$GITHUB_OUTPUT" + - name: Choose the build mode + shell: bash + run: echo '${{ steps.release.outputs.published == 'true' && 'prebuilt' || 'source' }}' > .dnix-build + - name: Run flutter test + if: runner.os != 'Windows' || steps.release.outputs.published == 'true' + shell: bash + run: | + if ! flutter test 2>&1 | tee test.log; then + echo "::error::$(tail -c 1500 test.log | tr '\n' ' ')" + exit 1 + fi + diff --git a/.gitignore b/.gitignore index 96486fd..087637a 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,6 @@ migrate_working_dir/ .dart_tool/ .packages build/ + +# Per-checkout dnix build mode override (see README). +.dnix-build diff --git a/CHANGELOG.md b/CHANGELOG.md index 41cc7d8..1ed0d66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,16 @@ +## Unreleased + +* **Breaking change:** the native library now comes from + [dnix](https://github.com/cypherstack/dnix). By default the build hook + downloads the library of the release `dnix.lock` names and verifies its + SHA-256; with the `build` user define set to `source` it builds the same + library with Nix, on Linux or an Apple Silicon Mac. The `native_build`, + `prebuilt_manifest_url` and `prebuilt_manifest_sha256` user defines are + no longer read: pinning this package's commit pins its libraries. +* Every change is built on four hosts, which must produce the bytes + `dnix.lock` records; the default branch publishes the release it names. +* A Linux arm64 library, which before could only be built from source. + ## 0.0.1 * TODO: Describe initial release. diff --git a/README.md b/README.md index f694da2..b4c6554 100644 --- a/README.md +++ b/README.md @@ -1,81 +1,68 @@ # `flutter_libmwc` MWC wallet bindings for Dart 3.13+ and Flutter 3.47+. APIs are in `lib/lib.dart` -and `lib/mwc.dart`; Flutter bundles the native library automatically. +and `lib/mwc.dart`; Flutter bundles the native library automatically. Nothing +beyond Flutter is needed to use the package: the build hook downloads the +Rust library built for this version and checks its hash. -## Use prebuilts +## Native library -Copy the URL and SHA-256 from a published native release into your app's root -`pubspec.yaml` (workspace root for pub workspaces): +The hook obtains the Rust library in one of three modes, chosen with the +`build` user define in the consuming app's root `pubspec.yaml` (the workspace +root for pub workspaces), or for this checkout with a `.dnix-build` file +holding the mode: ```yaml hooks: user_defines: flutter_libmwc: - native_build: prebuilt - prebuilt_manifest_url: https://github.com/cypherstack/flutter_libmwc/releases/download/native-YOUR-RELEASE/manifest.json - prebuilt_manifest_sha256: "REPLACE_WITH_RELEASE_MANIFEST_SHA256" + build: prebuilt # the default; or source, or dev ``` -Use the package revision named in that release. The hook checks source identity, -platform compatibility, and checksums. Missing or invalid prebuilts stop the build. -Downloads require public HTTPS; Flutter's normal app build tools are still needed. +- `prebuilt` downloads the library of the release `dnix.lock` names and + verifies it against the hash recorded there. A mismatch is an error, never + a fallback. Downloads are cached by hash and need public HTTPS. +- `source` builds the library with Nix, the same build CI uses, and verifies + it against the same hash. Needs Nix on Linux or an Apple Silicon Mac; the + Apple targets need Xcode 27.0. OpenSSL is compiled from source inside that + build. +- `dev` builds with Nix but skips the Xcode pin and the hash check, for + working on the Rust code. The hook notices a `.dnix-build` file from its + next run on, so touch `dnix.json` after creating it. -Each hook run downloads and verifies fresh files into Flutter's `.dart_tool` -output. Flutter can reuse that output for incremental builds. `flutter clean` -removes it, so the next build downloads again. In pub workspaces, run clean -from the workspace root. - -Use the same root pubspec settings and normal Flutter commands in CI, such as -`flutter pub get` and `flutter test`. - -## Build from source - -Omit the prebuilt settings or set `native_build: source`. Install Rust 1.99.1 via -[rustup](https://rustup.rs/), a C/C++ compiler, CMake, libclang, pkg-config, Perl, -and `protoc`. Use Xcode for Apple, Android SDK/NDK r27+ for Android, and Visual -Studio's C++ workload for Windows. Run Flutter normally; the hook compiles Rust -with `--locked`. - -## Build releases - -Push a new `native-*` tag to build all targets and automatically publish a release -after the checks pass. Each release includes consumer YAML and a prominent -**USE AT YOUR OWN RISK** warning. Existing releases are never overwritten. - -```sh -git tag native-0.1.0-1 -git push origin native-0.1.0-1 -``` - -Enable [release immutability](https://docs.github.com/en/code-security/how-tos/secure-your-supply-chain/establish-provenance-and-integrity/prevent-release-changes) -to prevent published assets from being replaced. - -Both static and dynamic libraries are built: +Only dynamic libraries are provided, without sanitizers: | Platform | Targets | Minimum | | --- | --- | --- | -| Linux | x64 | glibc 2.35; no musl/Alpine | -| Windows | x64 | MSVC | +| Linux | arm64, x64 | glibc 2.28; no musl/Alpine | +| Windows | x64 | MSVC runtime | | macOS | arm64, x64 | macOS 11 | -| Android | armv7, arm64, x64 | API 21 | -| iOS | arm64 device; arm64/x64 simulator | iOS 13 | - -Linux deployments must meet the glibc minimum. Sanitized runtimes are unsupported. -For local builds, run from this repository's root with the native tools installed: - -```sh -flutter pub get -dart --packages=.dart_tool/package_config.json tool/build_prebuilt.dart --target aarch64-apple-darwin -dart --packages=.dart_tool/package_config.json tool/prebuilt_manifest.dart --artifacts build/prebuilts -``` - -Use fresh output directories; upload `build/native-release/*` together. -For Android, pass `--ndk` pointing to NDK 28.2.13676358. See `--help` for options. - -To test the prebuilt tooling without compiling Rust: - -```sh -flutter pub get -dart --packages=.dart_tool/package_config.json tool/test_prebuilt.dart -``` +| Android | armv7, arm64, x64 | API 21; C++ runtime linked statically | +| iOS | arm64 device; arm64/x64 simulator | iOS 13; arm64 simulator 14 | + +The floors, the Rust and Xcode versions, the location of the Rust workspace, +and the LMDB flags for Apple targets live in `dnix.json`. + +## Releases + +`dnix.lock` records the hash of every library the Rust source builds to and +names the GitHub release, `native-`, that holds those bytes. After +changing the Rust code, run `dart tool/update_lock.dart` on a Mac with Nix +and Xcode 27.0 and commit the lock with the change; without a Mac, push and +copy the lock CI prints once every host is green. CI +(`.github/workflows/native.yml`) builds on four hosts, fails if any host or +the lock disagrees, and publishes the release when the lock reaches the +default branch. Nothing is committed by CI, and old releases stay. + +Anyone can rebuild one platform with Nix and compare: +`dart tool/verify_release.dart linux` (or android, windows, macos, ios, or a +target triple), on Windows inside WSL2 for the non-Apple platforms. The build +comes from the shared [dnix](https://github.com/cypherstack/dnix) package, +whose documentation explains how the bytes stay identical across hosts. + +## Tests + +`flutter test` runs the Dart tests against the bundled library. Before the +lock's release is published, or to test local Rust changes, they need the +library built from source: `echo dev > .dnix-build` with Nix installed +(`source` to also check the hash). diff --git a/dnix.json b/dnix.json new file mode 100644 index 0000000..2a90f98 --- /dev/null +++ b/dnix.json @@ -0,0 +1,52 @@ +{ + "name": "flutter_libmwc", + "repository": "https://github.com/cypherstack/flutter_libmwc", + "library": "mwc_wallet", + "asset": "mwc.dart", + "rust": { + "workspace": "rust", + "crate": "mwc-wallet", + "cflags": { + "aarch64-apple-darwin": [ + "-DMDB_USE_POSIX_MUTEX=1", + "-DMDB_USE_ROBUST=0" + ], + "x86_64-apple-darwin": [ + "-DMDB_USE_POSIX_MUTEX=1", + "-DMDB_USE_ROBUST=0" + ] + }, + "env": { + "CXXSTDLIB_aarch64_linux_android": "static:-bundle=c++", + "CXXSTDLIB_armv7_linux_androideabi": "static:-bundle=c++", + "CXXSTDLIB_x86_64_linux_android": "static:-bundle=c++", + "PROTOC": "protoc" + }, + "tools": [ + "protobuf" + ] + }, + "toolchains": { + "rust": "1.99.1", + "xcode": "27.0" + }, + "floors": { + "macos": 11, + "ios": 13, + "android": 21, + "glibc": "2.28" + }, + "targets": [ + "aarch64-apple-darwin", + "x86_64-apple-darwin", + "aarch64-apple-ios", + "aarch64-apple-ios-sim", + "x86_64-apple-ios", + "aarch64-linux-android", + "armv7-linux-androideabi", + "x86_64-linux-android", + "aarch64-unknown-linux-gnu", + "x86_64-unknown-linux-gnu", + "x86_64-pc-windows-msvc" + ] +} diff --git a/dnix.lock b/dnix.lock new file mode 100644 index 0000000..cb43bca --- /dev/null +++ b/dnix.lock @@ -0,0 +1,17 @@ +{ + "release": "41ce961e0a4b", + "date": "2026-10-28", + "files": { + "aarch64-apple-darwin/libmwc_wallet.dylib": "499899e7208f813e5504e5f6b8c30bb8453083d58347819288f627aee6217828", + "aarch64-apple-ios-sim/libmwc_wallet.dylib": "1118f3b853eca86f29379462ced5112f11632503a3e4f50ebdeaa67c692e127b", + "aarch64-apple-ios/libmwc_wallet.dylib": "7e0fbbb0a5bc79456a716f043f29fedb0e6fa8c0a1690c9eda5239b74c7f12cf", + "aarch64-linux-android/libmwc_wallet.so": "cb361758267530c2b454f3aee1bdd0665b48d1da52beb956d9f76db6e57bd4f7", + "aarch64-unknown-linux-gnu/libmwc_wallet.so": "62928987451dfa14184bb818664100a792606a507129fb2eeedd8b96a908420b", + "armv7-linux-androideabi/libmwc_wallet.so": "29cc0dd76d762ac72ac80cb368d0c322453301a8c98005e6315455a09173a6b4", + "x86_64-apple-darwin/libmwc_wallet.dylib": "9318bffc19ac4685db4d66edf713f794f1e6f55bb640ac7a556d4110143deae7", + "x86_64-apple-ios/libmwc_wallet.dylib": "77bf055d942b3444c6da8a4b11d5bf059882fb2b4be02f146a78a4d87a658412", + "x86_64-linux-android/libmwc_wallet.so": "9009fa19513ec49c04e7f032d3b47f046122446d99facf97067ea1e3ae188b24", + "x86_64-pc-windows-msvc/mwc_wallet.dll": "1592d57fcfb478820110a2a04f06adae3229635b2495646de60e310fd726edb4", + "x86_64-unknown-linux-gnu/libmwc_wallet.so": "f366361b59307190de1e999f7fabdfeaa7b85ba72e869a384d0d4130515fe0a0" + } +} diff --git a/example/pubspec.lock b/example/pubspec.lock index 6da4379..884dfba 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -65,6 +65,15 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.3" + dnix: + dependency: transitive + description: + path: "." + ref: e91763ef25d084b3a5cf271a0cfee69159b1f779 + resolved-ref: e91763ef25d084b3a5cf271a0cfee69159b1f779 + url: "https://github.com/cypherstack/dnix.git" + source: git + version: "0.1.0" fake_async: dependency: transitive description: @@ -170,14 +179,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.0" - native_toolchain_rust: - dependency: transitive - description: - name: native_toolchain_rust - sha256: "8469a0e32a47a5c84b7fd300de9fd0e8082aac76c37922986324f5082a58350d" - url: "https://pub.dev" - source: hosted - version: "1.0.6" numeric_keyboard: dependency: "direct main" description: @@ -242,14 +243,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" - petitparser: - dependency: transitive - description: - name: petitparser - sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" - url: "https://pub.dev" - source: hosted - version: "7.0.2" platform: dependency: transitive description: @@ -343,14 +336,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.12" - toml: - dependency: transitive - description: - name: toml - sha256: "35a35f782228656a2af31e8c73d1353cc4ef3d683fd68af1111b44631879c05e" - url: "https://pub.dev" - source: hosted - version: "0.18.0" typed_data: dependency: transitive description: @@ -375,14 +360,6 @@ packages: url: "https://pub.dev" source: hosted version: "14.3.1" - web: - dependency: transitive - description: - name: web - sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" - url: "https://pub.dev" - source: hosted - version: "1.1.1" xdg_directories: dependency: transitive description: diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..928b931 --- /dev/null +++ b/flake.lock @@ -0,0 +1,123 @@ +{ + "nodes": { + "dnix": { + "inputs": { + "nixpkgs": "nixpkgs", + "rust-overlay": "rust-overlay", + "zig-overlay": "zig-overlay" + }, + "locked": { + "lastModified": 1793124403, + "narHash": "sha256-Nf8+4VKQWYAE7telMnxGa2no18Yu4BemMZJ2PCeG+II=", + "owner": "cypherstack", + "repo": "dnix", + "rev": "e91763ef25d084b3a5cf271a0cfee69159b1f779", + "type": "github" + }, + "original": { + "owner": "cypherstack", + "repo": "dnix", + "type": "github" + } + }, + "flake-compat": { + "flake": false, + "locked": { + "lastModified": 1696426674, + "narHash": "sha256-kvjfFW7WAETZlt09AgDn1MrtKzP7t90Vf7vypd3OL1U=", + "owner": "edolstra", + "repo": "flake-compat", + "rev": "0f9255e01c2351cc7d116c072cb317785dd33b33", + "type": "github" + }, + "original": { + "owner": "edolstra", + "repo": "flake-compat", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1792502884, + "narHash": "sha256-IoNisKcGbUjdd45qCqywGMCYmZ5EnkxezvLII0FWwNA=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "02c087f3fe673c9e317f206a627cb6b910336dbe", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "dnix": "dnix" + } + }, + "rust-overlay": { + "inputs": { + "nixpkgs": [ + "dnix", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1792567368, + "narHash": "sha256-mrM+zFDRO1pI85t2SEq5R739BIq7Nv5XkFtVZK9nkZM=", + "owner": "oxalica", + "repo": "rust-overlay", + "rev": "83dd732f2428827f35d997deae108a2ced5125f4", + "type": "github" + }, + "original": { + "owner": "oxalica", + "repo": "rust-overlay", + "type": "github" + } + }, + "systems": { + "flake": false, + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, + "zig-overlay": { + "inputs": { + "flake-compat": "flake-compat", + "nixpkgs": [ + "dnix", + "nixpkgs" + ], + "systems": "systems" + }, + "locked": { + "lastModified": 1792585651, + "narHash": "sha256-Kg8Qdi6ur+K5hNJwav0pEqdylGd7vXxEPeBvaKW5nxA=", + "owner": "mitchellh", + "repo": "zig-overlay", + "rev": "98819174f1bb7c9961c3ebbb00d19fc4e1c1960d", + "type": "github" + }, + "original": { + "owner": "mitchellh", + "repo": "zig-overlay", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..4b75d15 --- /dev/null +++ b/flake.nix @@ -0,0 +1,10 @@ +{ + description = "Reproducible reference builds of flutter_libmwc's Rust library"; + + inputs.dnix.url = "github:cypherstack/dnix"; + + outputs = { self, dnix, ... }: dnix.lib.package { + src = self; + config = ./dnix.json; + }; +} diff --git a/hook/build.dart b/hook/build.dart index c1748df..7997f79 100644 --- a/hook/build.dart +++ b/hook/build.dart @@ -1,81 +1,3 @@ -import 'dart:io'; +import 'package:dnix/dnix.dart'; -import 'package:code_assets/code_assets.dart'; -import 'package:hooks/hooks.dart'; -import 'package:native_toolchain_rust/native_toolchain_rust.dart'; - -import 'src/prebuilt.dart'; - -void main(List args) async { - await build(args, (input, output) async { - if (!input.config.buildCodeAssets) return; - if (await usePrebuilt(input, output)) return; - - final code = input.config.code; - - // OpenSSL refuses to build for a non-Windows target using Windows' perl. - if (Platform.isWindows && code.targetOS != OS.windows) { - throw UnsupportedError( - 'Cannot build ${code.targetOS} from Windows; use Linux, macOS, or WSL.', - ); - } - - final environment = { - if (code.targetOS == .iOS) - "IPHONEOS_DEPLOYMENT_TARGET": "${code.iOS.targetVersion}.0", - if (code.targetOS == .macOS) ...{ - "MACOSX_DEPLOYMENT_TARGET": "${code.macOS.targetVersion}.0", - // LMDB defaults to SysV semaphores on Apple targets, which the App - // Sandbox denies (EPERM on mdb_env_open). Keep the lock in lock.mdb via - // pthread mutexes instead; macOS has no robust mutexes, so disable those. - "CFLAGS_${code.targetArchitecture == .arm64 ? "aarch64" : "x86_64"}_apple_darwin": - "-DMDB_USE_POSIX_MUTEX=1 -DMDB_USE_ROBUST=0", - }, - }; - if (code.targetOS == OS.android) { - // native_toolchain_rust 1.0.6 defaults to API 35. Honor Flutter's minimum. - final (rustTarget, clangTarget) = switch (code.targetArchitecture) { - Architecture.arm => ( - 'armv7-linux-androideabi', - 'armv7a-linux-androideabi', - ), - Architecture.arm64 => ( - 'aarch64-linux-android', - 'aarch64-linux-android', - ), - Architecture.x64 => ('x86_64-linux-android', 'x86_64-linux-android'), - _ => throw UnsupportedError('Unsupported Android architecture'), - }; - final bin = code.cCompiler!.compiler.resolve('.'); - final suffix = Platform.isWindows ? '.cmd' : ''; - final ndkTarget = '$clangTarget${code.android.targetNdkApi}'; - final compiler = '$ndkTarget-clang'; - final cc = bin.resolve('$compiler$suffix').toFilePath(); - final target = rustTarget.replaceAll('-', '_'); - environment.addAll({ - 'CC_$target': cc, - 'CXX_$target': bin.resolve('$compiler++$suffix').toFilePath(), - // cc also supplies --target; keep the API level on the final argument. - 'CFLAGS_$target': '--target=$ndkTarget', - 'CXXFLAGS_$target': '--target=$ndkTarget', - // The NDK's libc++.a linker script includes libc++abi as well. - // Defer it to the final link instead of bundling the script in an rlib. - 'CXXSTDLIB_$target': 'static:-bundle=c++', - 'CARGO_TARGET_${target.toUpperCase()}_LINKER': cc, - }); - } - - await RustBuilder( - assetName: 'mwc.dart', - extraCargoBuildArgs: ['--lib', '--locked'], - extraCargoEnvironmentVariables: environment, - ).run(input: input, output: output); - output.dependencies.addAll( - [ - 'rust/Cargo.toml', - 'rust/Cargo.lock', - 'rust/rust-toolchain.toml', - ].map(input.packageRoot.resolve), - ); - }); -} +void main(List args) => buildNativeLibrary(args); diff --git a/hook/src/prebuilt.dart b/hook/src/prebuilt.dart deleted file mode 100644 index e0acd49..0000000 --- a/hook/src/prebuilt.dart +++ /dev/null @@ -1,377 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; - -import 'package:code_assets/code_assets.dart'; -import 'package:crypto/crypto.dart' as crypto; -import 'package:hooks/hooks.dart'; - -import 'source_fingerprint.dart'; - -const _maximumArtifactBytes = 2 * 1024 * 1024 * 1024; -final _sha256Pattern = RegExp(r'^[0-9a-fA-F]{64}$'); - -String _digest(Object? value, String label) { - if (value is! String || !_sha256Pattern.hasMatch(value)) { - throw FormatException( - '$label must be a SHA-256 digest (64 hex characters).', - ); - } - return value.toLowerCase(); -} - -void _requireHttps(Uri uri) { - if (uri.scheme != 'https' || - uri.host.isEmpty || - uri.userInfo.isNotEmpty || - uri.hasFragment) { - throw FormatException( - 'Prebuilt URLs must use HTTPS without credentials or fragments.', - ); - } -} - -class PrebuiltOptions { - final String nativeBuild; - final Uri? manifestUrl; - final String? manifestSha256; - - PrebuiltOptions._(this.nativeBuild, this.manifestUrl, this.manifestSha256); - - factory PrebuiltOptions.fromInput(BuildInput input) { - final defines = input.userDefines; - final mode = defines['native_build'] ?? 'source'; - if (mode != 'source' && mode != 'prebuilt') { - throw FormatException( - 'flutter_libmwc.native_build must be source or prebuilt.', - ); - } - if (mode == 'source') { - return PrebuiltOptions._('source', null, null); - } - final rawUrl = defines['prebuilt_manifest_url']; - if (rawUrl is! String || rawUrl.isEmpty) { - throw FormatException('Prebuilt mode requires prebuilt_manifest_url.'); - } - final url = Uri.parse(rawUrl); - _requireHttps(url); - final digest = _digest( - defines['prebuilt_manifest_sha256'], - 'prebuilt_manifest_sha256', - ); - return PrebuiltOptions._('prebuilt', url, digest); - } -} - -class PrebuiltTarget { - final String triple; - final String linkMode; - final int? minimumVersion; - final String libraryFileName; - - PrebuiltTarget._( - this.triple, - this.linkMode, - this.minimumVersion, - this.libraryFileName, - ); - - factory PrebuiltTarget.fromConfig(CodeConfig code) { - if (code.sanitizer != null) { - throw UnsupportedError('Prebuilt binaries do not support sanitizers.'); - } - final triple = switch ((code.targetOS, code.targetArchitecture)) { - (OS.android, Architecture.arm) => 'armv7-linux-androideabi', - (OS.android, Architecture.arm64) => 'aarch64-linux-android', - (OS.android, Architecture.x64) => 'x86_64-linux-android', - (OS.iOS, Architecture.arm64) when code.iOS.targetSdk == IOSSdk.iPhoneOS => - 'aarch64-apple-ios', - (OS.iOS, Architecture.arm64) - when code.iOS.targetSdk == IOSSdk.iPhoneSimulator => - 'aarch64-apple-ios-sim', - (OS.iOS, Architecture.x64) - when code.iOS.targetSdk == IOSSdk.iPhoneSimulator => - 'x86_64-apple-ios', - (OS.macOS, Architecture.arm64) => 'aarch64-apple-darwin', - (OS.macOS, Architecture.x64) => 'x86_64-apple-darwin', - (OS.linux, Architecture.arm64) => 'aarch64-unknown-linux-gnu', - (OS.linux, Architecture.x64) => 'x86_64-unknown-linux-gnu', - (OS.windows, Architecture.x64) => 'x86_64-pc-windows-msvc', - _ => throw UnsupportedError( - 'No prebuilt target for ${code.targetOS}/${code.targetArchitecture}.', - ), - }; - final mode = switch (code.linkModePreference) { - LinkModePreference.dynamic || - LinkModePreference.preferDynamic => 'dynamic', - LinkModePreference.static || LinkModePreference.preferStatic => 'static', - _ => throw UnsupportedError('Unsupported native link mode.'), - }; - final minimum = switch (code.targetOS) { - OS.android => code.android.targetNdkApi, - OS.iOS => code.iOS.targetVersion, - OS.macOS => code.macOS.targetVersion, - _ => null, - }; - final linkMode = mode == 'static' - ? StaticLinking() - : DynamicLoadingBundled(); - return PrebuiltTarget._( - triple, - mode, - minimum, - code.targetOS.libraryFileName('mwc_wallet', linkMode), - ); - } -} - -class PrebuiltArtifact { - final String file; - final String sha256; - final int size; - - PrebuiltArtifact._(this.file, this.sha256, this.size); - - factory PrebuiltArtifact.select( - Map manifest, - PrebuiltTarget target, { - required String sourceSha256, - }) { - if (manifest['schema_version'] != 1 || - manifest['package'] != 'flutter_libmwc') { - throw FormatException('Unsupported flutter_libmwc prebuilt manifest.'); - } - if (_digest(manifest['source_sha256'], 'source_sha256') != sourceSha256) { - throw StateError( - 'Prebuilt source fingerprint does not match this package. ' - 'Select a release built from this source or set native_build: source.', - ); - } - final artifacts = manifest['artifacts']; - if (artifacts is! List || - artifacts.any((entry) => entry is! Map)) { - throw FormatException('Manifest artifacts must be a list of objects.'); - } - final matches = artifacts - .cast>() - .where( - (entry) => - entry['target'] == target.triple && - entry['link_mode'] == target.linkMode, - ) - .toList(); - if (matches.length != 1) { - throw StateError( - 'Expected one ${target.triple}/${target.linkMode} prebuilt; ' - 'found ${matches.length}. Select another release or use native_build: source.', - ); - } - final entry = matches.single; - final file = entry['file']; - if (file is! String || - !RegExp(r'^[a-zA-Z0-9_-][a-zA-Z0-9_.-]*\.(so|dylib|dll|a|lib)$') - .hasMatch(file)) { - throw FormatException( - 'Prebuilt artifact file must be a library basename.', - ); - } - final digest = _digest(entry['sha256'], 'artifact sha256'); - final size = entry['size']; - if (size is! int || size <= 0 || size > _maximumArtifactBytes) { - throw FormatException( - 'Prebuilt artifact size must be between 1 byte and 2 GiB.', - ); - } - if (target.minimumVersion != null) { - final minimum = entry['minimum_os_version']; - if (minimum is! int || minimum <= 0) { - throw FormatException('Artifact must declare minimum_os_version.'); - } - if (minimum > target.minimumVersion!) { - throw UnsupportedError( - 'Prebuilt requires OS/API $minimum, but the app targets ' - '${target.minimumVersion}. Use native_build: source or a compatible release.', - ); - } - } - if (target.triple.endsWith('linux-gnu')) { - final glibc = entry['minimum_glibc_version']; - if (glibc is! String || !RegExp(r'^\d+\.\d+$').hasMatch(glibc)) { - throw FormatException( - 'Linux prebuilt must declare minimum_glibc_version.', - ); - } - } - return PrebuiltArtifact._(file, digest, size); - } -} - -/// Downloads fresh bytes and verifies them before making the file available. -class VerifiedDownloader { - final Directory directory; - final HttpClient? client; - - VerifiedDownloader(this.directory, {this.client}); - - Future get( - Uri uri, - String digest, { - int? size, - int maxBytes = _maximumArtifactBytes, - }) async { - _requireHttps(uri); - digest = _digest(digest, 'download digest'); - await directory.create(recursive: true); - final file = File.fromUri(directory.uri.resolve(digest)); - final temporaryDirectory = await directory.createTemp('.download-'); - final temporary = File.fromUri(temporaryDirectory.uri.resolve('asset')); - final http = client ?? HttpClient(); - try { - http.autoUncompress = false; - http.connectionTimeout = const Duration(seconds: 30); - final download = _download(http, uri, temporary, size ?? maxBytes); - try { - await download.timeout(const Duration(minutes: 10)); - } on TimeoutException { - // Stop I/O before deleting the partial download, including on Windows. - http.close(force: true); - try { - await download; - } catch (_) { - // Preserve the timeout that triggered cancellation. - } - rethrow; - } - await _verify(temporary, digest, size, maxBytes); - return await temporary.rename(file.path); - } finally { - if (client == null) http.close(force: true); - await temporaryDirectory.delete(recursive: true); - } - } - - Future _verify(File file, String digest, int? size, int maximum) async { - final length = await file.length(); - if (length > maximum || - (size != null && length != size) || - (await crypto.sha256.bind(file.openRead()).first).toString() != - digest) { - throw StateError( - 'Prebuilt integrity check failed for ${file.path}. ' - 'Never change the pin to bypass verification.', - ); - } - } - - Future _download( - HttpClient http, - Uri uri, - File file, - int maximum, - ) async { - for (var redirects = 0; redirects <= 5; redirects++) { - _requireHttps(uri); - final request = await http - .getUrl(uri) - .timeout(const Duration(seconds: 30)); - request.followRedirects = false; - request.headers.set(HttpHeaders.acceptEncodingHeader, 'identity'); - final response = await request.close().timeout( - const Duration(seconds: 30), - ); - if ([301, 302, 303, 307, 308].contains(response.statusCode)) { - final location = response.headers.value(HttpHeaders.locationHeader); - await response.listen(null).cancel(); - if (location == null) - throw HttpException('Prebuilt redirect has no Location.'); - uri = uri.resolve(location); - continue; - } - if (response.statusCode != HttpStatus.ok) { - await response.listen(null).cancel(); - throw HttpException( - 'Prebuilt download returned HTTP ${response.statusCode}. ' - 'Check that the release is published and publicly accessible.', - ); - } - if (response.contentLength > maximum) { - await response.listen(null).cancel(); - throw StateError('Prebuilt download exceeds the expected size.'); - } - final sink = file.openWrite(); - var received = 0; - try { - await for (final bytes in response.timeout( - const Duration(seconds: 30), - )) { - received += bytes.length; - if (received > maximum) - throw StateError('Prebuilt download exceeds the expected size.'); - sink.add(bytes); - // Apply disk backpressure instead of buffering a large library in RAM. - await sink.flush(); - } - } finally { - await sink.close(); - } - return; - } - throw HttpException('Too many prebuilt download redirects.'); - } -} - -Future usePrebuilt( - BuildInput input, - BuildOutputBuilder output, { - HttpClient? client, -}) async { - final options = PrebuiltOptions.fromInput(input); - if (options.nativeBuild == 'source') return false; - final target = PrebuiltTarget.fromConfig(input.config.code); - final source = await sourceFingerprint(input.packageRoot); - output.dependencies.addAll(source.dependencies); - // Keep all downloads in Flutter's hook output so flutter clean removes them. - final outputDirectory = Directory.fromUri(input.outputDirectory); - await outputDirectory.create(recursive: true); - final downloads = await outputDirectory.createTemp('.prebuilt-'); - try { - final downloader = VerifiedDownloader(downloads, client: client); - final manifestFile = await downloader.get( - options.manifestUrl!, - options.manifestSha256!, - maxBytes: 1024 * 1024, - ); - final json = jsonDecode(await manifestFile.readAsString()); - if (json is! Map) - throw FormatException('Manifest must be an object.'); - final artifact = PrebuiltArtifact.select( - json, - target, - sourceSha256: source.sha256, - ); - final downloaded = await downloader.get( - options.manifestUrl!.resolve(artifact.file), - artifact.sha256, - size: artifact.size, - ); - final destination = input.outputDirectory.resolve(target.libraryFileName); - final bundled = await downloaded.rename(destination.toFilePath()); - // The hook runner tracks CodeAsset output bytes separately from inputs. - output.assets.code.add( - CodeAsset( - package: input.packageName, - name: 'mwc.dart', - file: bundled.uri, - linkMode: target.linkMode == 'static' - ? StaticLinking() - : DynamicLoadingBundled(), - ), - ); - } finally { - await downloads.delete(recursive: true); - } - print( - 'Using verified flutter_libmwc prebuilt for ${target.triple}/${target.linkMode}.', - ); - return true; -} diff --git a/hook/src/source_fingerprint.dart b/hook/src/source_fingerprint.dart deleted file mode 100644 index 6299869..0000000 --- a/hook/src/source_fingerprint.dart +++ /dev/null @@ -1,57 +0,0 @@ -import 'dart:convert'; -import 'dart:io'; - -import 'package:crypto/crypto.dart' as crypto; - -/// Shared by the build hook and release tooling. Include paths as well as bytes -/// so moving, adding, or deleting native sources invalidates a release. -Future<({String sha256, List dependencies})> sourceFingerprint( - Uri packageRoot, -) async { - final files = {}; - final dependencies = []; - for (final path in [ - 'rust/Cargo.toml', - 'rust/Cargo.lock', - 'rust/rust-toolchain.toml', - 'lib/mwc.dart', - 'tool/build_prebuilt.dart', - 'tool/src/build_support.dart', - ]) { - files[path] = File.fromUri(packageRoot.resolve(path)); - } - for (final path in ['rust/src/', 'hook/']) { - final directory = Directory.fromUri(packageRoot.resolve(path)); - dependencies.add(directory.uri); - await for (final entity in directory.list( - recursive: true, - followLinks: false, - )) { - if (entity is Link) { - throw StateError( - 'Prebuilt source inputs cannot be symlinks: ${entity.path}', - ); - } - if (entity is Directory) dependencies.add(entity.uri); - if (entity is File) { - final relative = entity.uri.path.substring(packageRoot.path.length); - files[Uri.decodeComponent(relative)] = entity; - } - } - } - final paths = files.keys.toList()..sort(); - final framed = StringBuffer(); - for (final path in paths) { - final file = files[path]!; - if (await FileSystemEntity.isLink(file.path)) { - throw StateError('Prebuilt source inputs cannot be symlinks: $path'); - } - final digest = await crypto.sha256.bind(file.openRead()).first; - framed.write('$path\u0000$digest\n'); - dependencies.add(file.uri); - } - return ( - sha256: crypto.sha256.convert(utf8.encode(framed.toString())).toString(), - dependencies: dependencies, - ); -} diff --git a/pubspec.yaml b/pubspec.yaml index b49dcbd..1416dc7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -14,13 +14,13 @@ dependencies: mutex: ^3.0.0 code_assets: ^1.2.1 hooks: ^2.2.0 - native_toolchain_rust: ^1.0.6 - crypto: ^3.0.7 + dnix: + git: + url: https://github.com/cypherstack/dnix.git + ref: e91763ef25d084b3a5cf271a0cfee69159b1f779 dev_dependencies: flutter_test: sdk: flutter - test: ^1.31.0 - args: ^2.7.0 # flutter_lints: ^2.0.0 diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 63c065a..49572b5 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -48,11 +48,10 @@ ffi_helpers = "0.3.0" anyhow = "1.0.69" -# We never call openssl ourselves. This makes native-tls build OpenSSL from -# source instead of looking for a system copy, which is what lets Android and -# iOS cross-compile. Windows is excluded because native-tls uses SChannel -# there, and building OpenSSL produces paths past Windows' 260-character limit. -[target.'cfg(not(target_os = "windows"))'.dependencies] +# We never call openssl ourselves. Built from source where native-tls uses +# OpenSSL (Linux, Android) instead of looking for a system copy; Apple and +# Windows use their own TLS and never link it. +[target.'cfg(any(target_os = "linux", target_os = "android"))'.dependencies] openssl = { version = "0.10", features = ["vendored"] } [lib] diff --git a/test/prebuilt_producer_test.dart b/test/prebuilt_producer_test.dart deleted file mode 100644 index 236bb2f..0000000 --- a/test/prebuilt_producer_test.dart +++ /dev/null @@ -1,315 +0,0 @@ -import 'dart:convert'; -import 'dart:io'; - -import 'package:test/test.dart'; - -import '../tool/build_prebuilt.dart'; -import '../tool/prebuilt_manifest.dart'; -import '../tool/src/build_support.dart'; - -void main() { - late Directory temporary; - late Directory source; - late Directory artifacts; - late Directory output; - late File library; - late File fragmentFile; - late Map fragment; - - Future saveFragment() => - fragmentFile.writeAsString(jsonEncode(fragment)); - - Future assemble({bool requireAll = false}) => - assemblePrebuilts( - artifacts, - output, - root: source.uri, - requireAll: requireAll, - ); - - Map firstEntry() => - (fragment['artifacts'] as List).first as Map; - - Matcher invalid(String message) => throwsA( - isA().having( - (error) => error.message, - 'message', - contains(message), - ), - ); - - setUp(() async { - temporary = await Directory.systemTemp.createTemp('mwc-producer-test-'); - source = Directory.fromUri(temporary.uri.resolve('source/')); - artifacts = await Directory.fromUri(temporary.uri.resolve('artifacts/')) - .create(); - output = Directory.fromUri(temporary.uri.resolve('release/')); - for (final relative in [ - 'rust/Cargo.toml', - 'rust/Cargo.lock', - 'rust/rust-toolchain.toml', - 'rust/src/lib.rs', - 'hook/build.dart', - 'lib/mwc.dart', - 'tool/build_prebuilt.dart', - 'tool/src/build_support.dart', - ]) { - final file = File.fromUri(source.uri.resolve(relative)); - await file.parent.create(recursive: true); - await file.writeAsString('$relative\n'); - } - library = File.fromUri(artifacts.uri.resolve('mwc_wallet-test.dll')); - await library.writeAsString('native-binary-fixture'); - fragment = { - 'schema_version': 1, - 'package': 'flutter_libmwc', - 'source_sha256': await sourceSha256(source.uri), - 'artifacts': [ - { - 'target': 'x86_64-pc-windows-msvc', - 'link_mode': 'dynamic', - 'file': library.uri.pathSegments.last, - 'size': await library.length(), - 'sha256': await sha256File(library), - }, - ], - }; - fragmentFile = File.fromUri(artifacts.uri.resolve('fragment.json')); - await saveFragment(); - }); - - tearDown(() => temporary.delete(recursive: true)); - - test('source identity includes newly added nested Rust files', () async { - final original = await sourceSha256(source.uri); - final file = File.fromUri(source.uri.resolve('rust/src/new/module.rs')); - await file.parent.create(); - await file.writeAsString('fn changed() {}'); - expect(await sourceSha256(source.uri), isNot(original)); - }); - - test( - 'source identity ignores docs and includes release build policy', - () async { - final original = await sourceSha256(source.uri); - await File.fromUri(source.uri.resolve('README.md')).writeAsString('docs'); - expect(await sourceSha256(source.uri), original); - await File.fromUri(source.uri.resolve('tool/src/build_support.dart')) - .writeAsString('new policy'); - expect(await sourceSha256(source.uri), isNot(original)); - }, - ); - - test('assembled pin matches exact manifest', () async { - final result = await assemble(); - expect( - result.sha256, - await sha256File(File.fromUri(output.uri.resolve('manifest.json'))), - ); - expect( - await File.fromUri(output.uri.resolve('mwc_wallet-test.dll')) - .readAsBytes(), - await library.readAsBytes(), - ); - final pinFile = File.fromUri(output.uri.resolve('manifest.sha256')); - expect(await pinFile.readAsString(), '${result.sha256} manifest.json\n'); - }); - - test( - 'same-length corrupted artifact fails before output publication', - () async { - final bytes = await library.readAsBytes(); - bytes[0] ^= 1; - await library.writeAsBytes(bytes); - await expectLater(assemble(), invalid('checksum mismatch')); - expect(await output.exists(), isFalse); - }, - ); - - test('changed native sources reject old fragments', () async { - await File.fromUri(source.uri.resolve('hook/build.dart')) - .writeAsString('new source'); - await expectLater(assemble(), invalid('fingerprint mismatch')); - }); - - test('artifact filename cannot traverse out of its directory', () async { - firstEntry()['file'] = '../outside.dll'; - await saveFragment(); - await expectLater(assemble(), invalid('Unsafe artifact filename')); - }); - - test('same target and mode cannot appear twice', () async { - (fragment['artifacts'] as List).add( - Map.from(firstEntry()), - ); - await saveFragment(); - await expectLater(assemble(), invalid('Duplicate artifact')); - }); - - test('full release requires every supported target and link mode', () async { - await expectLater( - assemble(requireAll: true), - invalid('Missing release artifacts'), - ); - final entries = >[]; - for (final target in releaseTargets.entries) { - for (final mode in ['static', 'dynamic']) { - final suffix = libraryNames(target.key)[mode]!.split('.').last; - final filename = 'mwc_wallet-${target.key}-$mode.$suffix'; - final file = File.fromUri(artifacts.uri.resolve(filename)); - await file.writeAsString('${target.key}/$mode'); - entries.add({ - 'target': target.key, - 'link_mode': mode, - 'file': filename, - 'size': await file.length(), - 'sha256': await sha256File(file), - ...target.value, - }); - } - } - fragment['artifacts'] = entries; - await saveFragment(); - final result = await assemble(requireAll: true); - expect(result.manifest['artifacts'], hasLength(releaseTargets.length * 2)); - }); - - test('existing release output is never mixed with a new release', () async { - await assemble(); - await expectLater( - assemble(), - throwsA( - isA().having( - (error) => error.message, - 'message', - contains('Release output must be empty'), - ), - ), - ); - }); - - test('incorrect advertised deployment minimum is rejected', () async { - firstEntry().addAll({ - 'target': 'aarch64-linux-android', - 'minimum_os_version': 16, - }); - await saveFragment(); - await expectLater(assemble(), invalid('Incorrect minimum_os_version')); - }); - - test('noninteger sizes and unsupported schemas are rejected', () async { - firstEntry()['size'] = true; - await saveFragment(); - await expectLater(assemble(), invalid('Artifact size mismatch')); - firstEntry()['size'] = await library.length(); - fragment['schema_version'] = true; - await saveFragment(); - await expectLater(assemble(), invalid('Unsupported build fragment')); - }); - - test( - 'artifact symlinks are rejected', - () async { - final original = File.fromUri(temporary.uri.resolve('original.dll')); - await library.rename(original.path); - await Link(library.path).create(original.path); - await expectLater(assemble(), invalid('Missing or nonregular artifact')); - }, - skip: Platform.isWindows - ? 'Creating links requires Windows privileges' - : false, - ); - - test( - 'build CLI dry-run preserves release policy without invoking Rust', - () async { - final packageConfig = packageRoot - .resolve('.dart_tool/package_config.json') - .toFilePath(); - final process = await Process.run(Platform.resolvedExecutable, [ - '--packages=$packageConfig', - packageRoot.resolve('tool/build_prebuilt.dart').toFilePath(), - '--target', - 'aarch64-linux-android', - '--dry-run', - ], workingDirectory: packageRoot.toFilePath()); - expect(process.exitCode, 0, reason: process.stderr.toString()); - final plan = jsonDecode(process.stdout as String) as Map; - expect(plan['source_sha256'], await sourceSha256()); - expect(plan['target'], 'aarch64-linux-android'); - expect( - plan['cargo_arguments'], - containsAll(['--release', '--locked', '--lib']), - ); - expect(plan['compatibility'], {'minimum_os_version': 21}); - expect(plan['android_ndk'], androidNdkVersion); - expect(plan['libraries'], { - 'dynamic': 'libmwc_wallet.so', - 'static': 'libmwc_wallet.a', - }); - }, - ); - - test('manifest CLI help works without mandatory build inputs', () async { - final process = await Process.run(Platform.resolvedExecutable, [ - '--packages=${packageRoot.resolve('.dart_tool/package_config.json').toFilePath()}', - packageRoot.resolve('tool/prebuilt_manifest.dart').toFilePath(), - '--help', - ], workingDirectory: packageRoot.toFilePath()); - expect(process.exitCode, 0, reason: process.stderr.toString()); - expect(process.stdout, contains('--artifacts')); - expect(process.stdout, contains('--require-all')); - }); - - test('Linux Rust flags locate the actual static C++ archive', () async { - final env = await buildEnvironment('x86_64-unknown-linux-gnu'); - final searchPath = env['CARGO_ENCODED_RUSTFLAGS']! - .split('\u001f') - .singleWhere((flag) => flag.startsWith('-Lnative=')) - .substring('-Lnative='.length); - final archive = File.fromUri( - Directory(searchPath).uri.resolve('libstdc++.a'), - ); - final header = await archive - .openRead(0, 8) - .expand((bytes) => bytes) - .toList(); - expect(ascii.decode(header), '!\n'); - expect(env['CXXSTDLIB_x86_64_unknown_linux_gnu'], 'static=stdc++'); - }, skip: !Platform.isLinux); - - test('Linux metadata audit compares glibc versions numerically', () { - auditLinuxMetadata( - 'Name: GLIBC_2.9 Name: GLIBC_2.35', - '(NEEDED) Shared library: [libc.so.6]', - '2.35', - ); - expect( - () => auditLinuxMetadata('Name: GLIBC_2.36', '', '2.35'), - throwsA( - isA().having( - (error) => error.message, - 'message', - contains('above 2.35'), - ), - ), - ); - }); - - test('Linux metadata audit rejects a dynamic C++ runtime dependency', () { - expect( - () => auditLinuxMetadata( - 'Name: GLIBC_2.35', - '(NEEDED) Shared library: [libstdc++.so.6]', - '2.35', - ), - throwsA( - isA().having( - (error) => error.message, - 'message', - contains('Unexpected shared dependencies'), - ), - ), - ); - }); -} diff --git a/test/prebuilt_test.dart b/test/prebuilt_test.dart deleted file mode 100644 index dbbdf1a..0000000 --- a/test/prebuilt_test.dart +++ /dev/null @@ -1,631 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; - -import 'package:code_assets/code_assets.dart'; -import 'package:crypto/crypto.dart'; -import 'package:hooks/hooks.dart'; -import 'package:test/test.dart'; - -import '../hook/src/prebuilt.dart'; -import '../hook/src/source_fingerprint.dart'; - -void main() { - late Directory temporary; - - setUp(() async { - temporary = await Directory.systemTemp.createTemp('libmwc-prebuilt-test-'); - }); - - tearDown(() async { - await temporary.delete(recursive: true); - }); - - Future<_LoopbackClient> serve( - Future Function(HttpRequest) respond, - ) async { - final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); - final client = _LoopbackClient(server.port); - addTearDown(() async { - client.close(force: true); - await server.close(force: true); - }); - server.listen(respond); - return client; - } - - BuildInput input({ - Map defines = const {}, - OS os = OS.macOS, - Architecture architecture = Architecture.arm64, - LinkModePreference linkMode = LinkModePreference.dynamic, - IOSSdk sdk = IOSSdk.iPhoneOS, - int minimumVersion = 13, - Uri? packageRoot, - Sanitizer? sanitizer, - }) { - final builder = BuildInputBuilder() - ..setupShared( - packageRoot: packageRoot ?? Directory.current.uri, - packageName: 'flutter_libmwc', - outputDirectoryShared: temporary.uri.resolve('build/native-assets/'), - outputFile: temporary.uri.resolve('build/output.json'), - userDefines: PackageUserDefines( - workspacePubspec: PackageUserDefinesSource( - defines: defines, - basePath: temporary.uri, - ), - ), - ) - ..setupBuildInput(); - builder.config.setupBuild(linkingEnabled: false); - builder.addExtension( - CodeAssetExtension( - targetArchitecture: architecture, - targetOS: os, - linkModePreference: linkMode, - sanitizer: sanitizer, - macOS: os == OS.macOS - ? MacOSCodeConfig(targetVersion: minimumVersion) - : null, - iOS: os == OS.iOS - ? IOSCodeConfig(targetSdk: sdk, targetVersion: minimumVersion) - : null, - android: os == OS.android - ? AndroidCodeConfig(targetNdkApi: minimumVersion) - : null, - ), - ); - return builder.build(); - } - - Map settings([Map changes = const {}]) => { - 'native_build': 'prebuilt', - 'prebuilt_manifest_url': 'https://example.invalid/release/manifest.json', - 'prebuilt_manifest_sha256': 'a' * 64, - ...changes, - }; - - group('consumer settings', () { - test('defaults to source without requiring a manifest', () async { - final buildInput = input(); - expect(PrebuiltOptions.fromInput(buildInput).nativeBuild, 'source'); - expect(await usePrebuilt(buildInput, BuildOutputBuilder()), isFalse); - }); - - test('rejects malformed or unsafe settings', () { - for (final change in >[ - {'native_build': 'prefer'}, - {'native_build': true}, - {'prebuilt_manifest_url': 'http://example.invalid/manifest.json'}, - {'prebuilt_manifest_url': 'https://user:secret@example.invalid/m.json'}, - {'prebuilt_manifest_sha256': 'a' * 63}, - {'prebuilt_manifest_sha256': 'g' * 64}, - ]) { - expect( - () => PrebuiltOptions.fromInput(input(defines: settings(change))), - throwsA(anything), - reason: 'Invalid setting must fail: $change', - ); - } - }); - - test('requires an explicit manifest digest in prebuilt mode', () { - final defines = settings()..remove('prebuilt_manifest_sha256'); - expect( - () => PrebuiltOptions.fromInput(input(defines: defines)), - throwsA(anything), - ); - }); - }); - - Map artifact({ - String target = 'aarch64-apple-darwin', - String linkMode = 'dynamic', - String file = 'mwc_wallet-aarch64-apple-darwin-dynamic.dylib', - int minimumVersion = 11, - }) => { - 'target': target, - 'link_mode': linkMode, - 'file': file, - 'sha256': 'b' * 64, - 'size': 10, - 'minimum_os_version': minimumVersion, - }; - - Map manifest(List> artifacts) => { - 'schema_version': 1, - 'package': 'flutter_libmwc', - 'source_sha256': 'c' * 64, - 'artifacts': artifacts, - }; - - group('artifact compatibility', () { - test('rejects sanitizers and unsupported target combinations', () { - expect( - () => PrebuiltTarget.fromConfig( - input(sanitizer: Sanitizer.asan).config.code, - ), - throwsUnsupportedError, - ); - expect( - () => PrebuiltTarget.fromConfig( - input(os: OS.iOS, architecture: Architecture.x64).config.code, - ), - throwsUnsupportedError, - ); - }); - - test('distinguishes iOS device and simulator on the same architecture', () { - final device = PrebuiltTarget.fromConfig(input(os: OS.iOS).config.code); - final simulator = PrebuiltTarget.fromConfig( - input(os: OS.iOS, sdk: IOSSdk.iPhoneSimulator).config.code, - ); - expect(device.triple, 'aarch64-apple-ios'); - expect(simulator.triple, 'aarch64-apple-ios-sim'); - expect( - () => PrebuiltArtifact.select( - manifest([artifact(target: device.triple)]), - simulator, - sourceSha256: 'c' * 64, - ), - throwsA(anything), - ); - }); - - test('keeps canonical library filenames across Apple targets', () { - final arm = PrebuiltTarget.fromConfig(input().config.code); - final x64 = PrebuiltTarget.fromConfig( - input(architecture: Architecture.x64).config.code, - ); - expect(arm.libraryFileName, 'libmwc_wallet.dylib'); - expect(x64.libraryFileName, arm.libraryFileName); - }); - - test('honors both static and preferred static linking', () { - for (final preference in [ - LinkModePreference.static, - LinkModePreference.preferStatic, - ]) { - final target = PrebuiltTarget.fromConfig( - input(linkMode: preference).config.code, - ); - expect(target.linkMode, 'static'); - expect(target.libraryFileName, 'libmwc_wallet.a'); - expect( - () => PrebuiltArtifact.select( - manifest([artifact()]), - target, - sourceSha256: 'c' * 64, - ), - throwsA(anything), - ); - } - }); - - test('requires matching source and compatible deployment target', () { - final target = PrebuiltTarget.fromConfig(input().config.code); - expect( - PrebuiltArtifact.select( - manifest([artifact()]), - target, - sourceSha256: 'c' * 64, - ).file, - 'mwc_wallet-aarch64-apple-darwin-dynamic.dylib', - ); - expect( - () => PrebuiltArtifact.select( - manifest([artifact()]), - target, - sourceSha256: 'd' * 64, - ), - throwsA(anything), - ); - expect( - () => PrebuiltArtifact.select( - manifest([artifact(minimumVersion: 14)]), - target, - sourceSha256: 'c' * 64, - ), - throwsA(anything), - ); - }); - - test('rejects Android API levels newer than the consumer minimum', () { - final target = PrebuiltTarget.fromConfig( - input(os: OS.android, minimumVersion: 24).config.code, - ); - expect( - () => PrebuiltArtifact.select( - manifest([artifact(target: target.triple, minimumVersion: 35)]), - target, - sourceSha256: 'c' * 64, - ), - throwsUnsupportedError, - ); - }); - - test('requires a declared glibc baseline for Linux artifacts', () { - final target = PrebuiltTarget.fromConfig(input(os: OS.linux).config.code); - final library = artifact(target: target.triple, file: 'mwc_wallet.so'); - expect( - () => PrebuiltArtifact.select( - manifest([library]), - target, - sourceSha256: 'c' * 64, - ), - throwsFormatException, - ); - library['minimum_glibc_version'] = '2.35'; - expect( - PrebuiltArtifact.select( - manifest([library]), - target, - sourceSha256: 'c' * 64, - ).file, - 'mwc_wallet.so', - ); - }); - - test('rejects traversal, missing deployment metadata, and ambiguity', () { - final target = PrebuiltTarget.fromConfig(input().config.code); - for (final artifacts in [ - [artifact(file: '../outside.dylib')], - [artifact(file: 'https://elsewhere.invalid/library.dylib')], - [artifact()..remove('minimum_os_version')], - [artifact(), artifact()], - ]) { - expect( - () => PrebuiltArtifact.select( - manifest(artifacts), - target, - sourceSha256: 'c' * 64, - ), - throwsA(anything), - ); - } - }); - }); - - test( - 'source fingerprints detect edits, additions, renames, and symlinks', - () async { - final root = Directory.fromUri(temporary.uri.resolve('source/')); - final paths = [ - 'rust/Cargo.toml', - 'rust/Cargo.lock', - 'rust/rust-toolchain.toml', - 'rust/src/lib.rs', - 'rust/src/space % λ.rs', - 'lib/mwc.dart', - 'hook/build.dart', - 'tool/build_prebuilt.dart', - 'tool/src/build_support.dart', - ]..sort(); - final expectedFraming = StringBuffer(); - for (final path in paths) { - final file = File.fromUri(root.uri.resolveUri(Uri(path: path))); - await file.parent.create(recursive: true); - final content = 'fixture: $path'; - await file.writeAsString(content); - expectedFraming.write( - '$path\u0000${sha256.convert(utf8.encode(content))}\n', - ); - } - final initial = await sourceFingerprint(root.uri); - expect( - initial.sha256, - sha256.convert(utf8.encode(expectedFraming.toString())).toString(), - reason: 'The manifest protocol hashes decoded relative filenames.', - ); - expect((await sourceFingerprint(root.uri)).sha256, initial.sha256); - expect(initial.dependencies, contains(root.uri.resolve('rust/src/'))); - expect(initial.dependencies, contains(root.uri.resolve('hook/'))); - - final binding = File.fromUri(root.uri.resolve('lib/mwc.dart')); - await binding.writeAsString('changed FFI signature'); - final edited = await sourceFingerprint(root.uri); - expect(edited.sha256, isNot(initial.sha256)); - - final added = File.fromUri(root.uri.resolve('rust/src/added.rs')); - await added.writeAsString('new native module'); - final addedFingerprint = await sourceFingerprint(root.uri); - expect(addedFingerprint.sha256, isNot(edited.sha256)); - await added.rename(root.uri.resolve('rust/src/renamed.rs').toFilePath()); - expect( - (await sourceFingerprint(root.uri)).sha256, - isNot(addedFingerprint.sha256), - ); - - if (!Platform.isWindows) { - await Link.fromUri(root.uri.resolve('rust/src/link.rs')) - .create(binding.path); - await expectLater(sourceFingerprint(root.uri), throwsStateError); - } - }, - ); - - group('verified downloads', () { - test( - 'downloads afresh even when valid or corrupt bytes already exist', - () async { - final bytes = utf8.encode('downloaded binary'); - final digest = sha256.convert(bytes).toString(); - var requests = 0; - String? acceptEncoding; - final client = await serve((request) async { - requests++; - acceptEncoding = request.headers.value( - HttpHeaders.acceptEncodingHeader, - ); - request.response.add(bytes); - await request.response.close(); - }); - final downloader = VerifiedDownloader(temporary, client: client); - final uri = Uri.parse('https://example.invalid/library'); - final destination = File.fromUri(temporary.uri.resolve(digest)); - for (final previous in [bytes, utf8.encode('corrupt'), bytes]) { - await destination.writeAsBytes(previous); - final result = await downloader.get(uri, digest, size: bytes.length); - expect(result.path, destination.path); - expect(await result.readAsBytes(), bytes); - } - expect(requests, 3); - expect(acceptEncoding, 'identity'); - expect(temporary.listSync().map((entry) => entry.path), [ - destination.path, - ]); - }, - ); - - test('rejects an HTTPS redirect that downgrades to HTTP', () async { - var requests = 0; - final client = await serve((request) async { - requests++; - request.response.statusCode = HttpStatus.found; - request.response.headers.set( - HttpHeaders.locationHeader, - 'http://example.invalid/insecure-library', - ); - await request.response.close(); - }); - await expectLater( - VerifiedDownloader( - temporary, - client: client, - ).get(Uri.parse('https://example.invalid/library'), 'a' * 64), - throwsFormatException, - ); - expect(requests, 1); - expect( - File.fromUri(temporary.uri.resolve('a' * 64)).existsSync(), - isFalse, - ); - }); - - test( - 'rejects HTTP failures without publishing their response body', - () async { - final bytes = utf8.encode('not found'); - final digest = sha256.convert(bytes).toString(); - final client = await serve((request) async { - request.response.statusCode = HttpStatus.notFound; - request.response.add(bytes); - await request.response.close(); - }); - await expectLater( - VerifiedDownloader( - temporary, - client: client, - ).get(Uri.parse('https://example.invalid/library'), digest), - throwsA(isA()), - ); - expect( - File.fromUri(temporary.uri.resolve(digest)).existsSync(), - isFalse, - ); - }, - ); - - test('never publishes an unverified network response', () async { - final expected = utf8.encode('expected binary'); - final digest = sha256.convert(expected).toString(); - final client = await serve((request) async { - request.response.write('replaced binary'); - await request.response.close(); - }); - await expectLater( - VerifiedDownloader( - temporary, - client: client, - ).get(Uri.parse('https://example.invalid/library'), digest), - throwsA(anything), - ); - expect(File.fromUri(temporary.uri.resolve(digest)).existsSync(), isFalse); - }); - - test('does not reuse prior bytes after a failed fresh download', () async { - final bytes = utf8.encode('previous verified binary'); - final digest = sha256.convert(bytes).toString(); - final previous = File.fromUri(temporary.uri.resolve(digest)); - await previous.writeAsBytes(bytes); - var requests = 0; - final client = await serve((request) async { - requests++; - request.response.write('replacement with a different digest'); - await request.response.close(); - }); - await expectLater( - VerifiedDownloader( - temporary, - client: client, - ).get(Uri.parse('https://example.invalid/library'), digest), - throwsStateError, - ); - expect(requests, 1); - expect(await previous.readAsBytes(), bytes); - expect(temporary.listSync().map((entry) => entry.path), [previous.path]); - }); - - test( - 'cancels a timed-out download before removing temporary files', - () async { - final digest = 'a' * 64; - var requested = false; - var totalTimeoutScheduled = false; - final client = await serve((request) async { - requested = true; - request.response.bufferOutput = false; - request.response.add([1, 2, 3]); - // Deliberately leave the stream open after sending its first bytes. - // The production timeout must abort this response and close the sink. - unawaited(request.response.done.catchError((_) {})); - }); - await runZoned( - () => expectLater( - VerifiedDownloader( - temporary, - client: client, - ).get(Uri.parse('https://example.invalid/library'), digest), - throwsA(isA()), - ), - zoneSpecification: ZoneSpecification( - createTimer: (self, parent, zone, duration, callback) { - if (duration == const Duration(minutes: 10)) { - totalTimeoutScheduled = true; - duration = const Duration(milliseconds: 100); - } - return parent.createTimer(zone, duration, callback); - }, - ), - ); - expect(requested, isTrue); - expect(totalTimeoutScheduled, isTrue); - expect(client.forceClosed, isTrue); - expect( - File.fromUri(temporary.uri.resolve(digest)).existsSync(), - isFalse, - ); - expect(temporary.listSync().whereType(), isEmpty); - }, - timeout: const Timeout(Duration(seconds: 5)), - ); - - test('rejects oversized responses before committing an artifact', () async { - final bytes = List.filled(128, 42); - final digest = sha256.convert(bytes).toString(); - final client = await serve((request) async { - request.response.add(bytes); - await request.response.close(); - }); - await expectLater( - VerifiedDownloader(temporary, client: client).get( - Uri.parse('https://example.invalid/library'), - digest, - maxBytes: 64, - ), - throwsA(anything), - ); - expect(File.fromUri(temporary.uri.resolve(digest)).existsSync(), isFalse); - }); - }); - - test('resolver downloads into build output and fetches again after build cleanup', () async { - final fingerprint = await sourceFingerprint(Directory.current.uri); - final bytes = utf8.encode( - 'test library bytes; no native loading in this test', - ); - final binaryDigest = sha256.convert(bytes).toString(); - final document = manifest([ - artifact() - ..['sha256'] = binaryDigest - ..['size'] = bytes.length, - ])..['source_sha256'] = fingerprint.sha256; - final manifestBytes = utf8.encode(jsonEncode(document)); - final manifestDigest = sha256.convert(manifestBytes).toString(); - final paths = []; - final client = await serve((request) async { - paths.add(request.uri.path); - switch (request.uri.path) { - case '/release/manifest.json': - request.response.add(manifestBytes); - case '/release/mwc_wallet-aarch64-apple-darwin-dynamic.dylib': - request.response.add(bytes); - default: - request.response.statusCode = HttpStatus.notFound; - } - await request.response.close(); - }); - final buildInput = input( - defines: settings({'prebuilt_manifest_sha256': manifestDigest}), - ); - final outputDirectory = buildInput.outputDirectory; - for (var build = 0; build < 3; build++) { - if (build == 2) { - await Directory.fromUri(temporary.uri.resolve('build/')) - .delete(recursive: true); - expect(temporary.listSync(), isEmpty); - } - final output = BuildOutputBuilder(); - expect(await usePrebuilt(buildInput, output, client: client), isTrue); - final asset = output.build().assets.code.single; - expect(asset.id, 'package:flutter_libmwc/mwc.dart'); - expect(asset.linkMode, isA()); - expect(asset.file, outputDirectory.resolve('libmwc_wallet.dylib')); - expect(await File.fromUri(asset.file!).readAsBytes(), bytes); - expect( - output.build().dependencies, - containsAll(fingerprint.dependencies), - ); - expect( - temporary - .listSync(recursive: true) - .whereType() - .map((file) => file.uri), - [asset.file], - reason: - 'Only the canonical binary should survive in the build directory.', - ); - expect(paths.length, (build + 1) * 2); - } - expect(paths, [ - for (var build = 0; build < 3; build++) ...[ - '/release/manifest.json', - '/release/mwc_wallet-aarch64-apple-darwin-dynamic.dylib', - ], - ]); - }); -} - -/// Keep production HTTPS validation in place while routing test requests to a -/// local HTTP server. No external hosts or certificate exceptions are used. -final class _LoopbackClient implements HttpClient { - final int port; - final HttpClient delegate = HttpClient(); - bool forceClosed = false; - - _LoopbackClient(this.port); - - @override - Future getUrl(Uri url) => delegate.getUrl( - url.replace(scheme: 'http', host: '127.0.0.1', port: port), - ); - - @override - set connectionTimeout(Duration? value) => delegate.connectionTimeout = value; - - @override - set autoUncompress(bool value) => delegate.autoUncompress = value; - - @override - set findProxy(String Function(Uri)? value) => delegate.findProxy = value; - - @override - void close({bool force = false}) { - forceClosed = forceClosed || force; - delegate.close(force: force); - } - - @override - dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); -} diff --git a/tool/build_prebuilt.dart b/tool/build_prebuilt.dart deleted file mode 100644 index fa75476..0000000 --- a/tool/build_prebuilt.dart +++ /dev/null @@ -1,369 +0,0 @@ -/// Build one native release target and write its libraries and manifest fragment. -/// -/// Run with `dart --packages=.dart_tool/package_config.json -/// tool/build_prebuilt.dart` after `flutter pub get` to bypass native build hooks. -library; - -import 'dart:io'; - -import 'package:args/args.dart'; - -import 'src/build_support.dart'; - -Future> buildEnvironment( - String target, { - String? ndk, -}) async { - if (!releaseTargets.containsKey(target)) { - throw ArgumentError.value(target, 'target', 'Unsupported release target'); - } - final env = Map.of(Platform.environment) - // Local shell optimization flags must not silently change release ABI. - ..remove('RUSTFLAGS') - ..remove('CARGO_ENCODED_RUSTFLAGS') - ..['CARGO_INCREMENTAL'] = '0' - ..['CARGO_PROFILE_RELEASE_DEBUG'] = 'false'; - final key = target.replaceAll('-', '_'); - final rustflags = []; - - if (target.contains('apple')) { - if (!Platform.isMacOS) { - throw StateError('Apple targets require macOS and Xcode'); - } - env['PATH'] = (env['PATH'] ?? '') - .split(':') - .where((part) => !part.contains('Contents/Developer/')) - .join(':'); - rustflags.addAll([ - '-C', - 'link-arg=-Wl,-install_name,@rpath/libmwc_wallet.dylib', - ]); - if (target.contains('darwin')) { - env['MACOSX_DEPLOYMENT_TARGET'] = '11.0'; - // Match the source hook's LMDB sandbox fix. - env['CFLAGS_$key'] = '-DMDB_USE_POSIX_MUTEX=1 -DMDB_USE_ROBUST=0'; - } else { - env['IPHONEOS_DEPLOYMENT_TARGET'] = '13.0'; - final sdk = target == 'aarch64-apple-ios' - ? 'iphoneos' - : 'iphonesimulator'; - env['SDKROOT'] = (await runCommand([ - 'xcrun', - '--sdk', - sdk, - '--show-sdk-path', - ], capture: true))!; - } - } else if (target.contains('android')) { - if (Platform.isWindows) { - throw StateError( - 'Android releases require Linux or macOS (OpenSSL needs Unix Perl)', - ); - } - ndk ??= env['ANDROID_NDK_HOME'] ?? env['ANDROID_NDK_ROOT']; - if (ndk == null || ndk.isEmpty) { - throw StateError('Pass --ndk or set ANDROID_NDK_HOME'); - } - final ndkDirectory = Directory(ndk).absolute; - final properties = await File.fromUri( - ndkDirectory.uri.resolve('source.properties'), - ).readAsString(); - if (!RegExp( - '^Pkg.Revision\\s*=\\s*${RegExp.escape(androidNdkVersion)}\\s*\$', - multiLine: true, - ).hasMatch(properties)) { - throw StateError('Release builds require Android NDK $androidNdkVersion'); - } - final host = Platform.isMacOS ? 'darwin-x86_64' : 'linux-x86_64'; - final toolchain = ndkDirectory.uri.resolve( - 'toolchains/llvm/prebuilt/$host/', - ); - final binary = toolchain.resolve('bin/'); - final clangTarget = target.startsWith('armv7-') - ? 'armv7a-linux-androideabi' - : target; - final ndkTarget = '${clangTarget}21'; - final cc = File.fromUri(binary.resolve('$ndkTarget-clang')); - if (!await cc.exists()) { - throw StateError('Missing NDK compiler: ${cc.path}'); - } - final sysrootTarget = target.startsWith('armv7-') - ? 'arm-linux-androideabi' - : target; - final sysroot = toolchain.resolve('sysroot').toFilePath(); - env.addAll({ - 'CC_$key': cc.path, - 'CXX_$key': binary.resolve('$ndkTarget-clang++').toFilePath(), - 'AR_$key': binary.resolve('llvm-ar').toFilePath(), - 'RANLIB_$key': binary.resolve('llvm-ranlib').toFilePath(), - 'CFLAGS_$key': '--target=$ndkTarget', - 'CXXFLAGS_$key': '--target=$ndkTarget', - 'CXXSTDLIB_$key': 'static:-bundle=c++', - 'CARGO_TARGET_${key.toUpperCase()}_LINKER': cc.path, - 'BINDGEN_EXTRA_CLANG_ARGS_$key': - '--sysroot=$sysroot -I$sysroot/usr/include/$sysrootTarget', - }); - // Required for Android devices using 16 KiB pages, even at API 21. - rustflags.addAll(['-C', 'link-arg=-Wl,-z,max-page-size=16384']); - } else if (target.contains('linux')) { - if (!Platform.isLinux) { - throw StateError('Linux releases require a Linux host'); - } - env['CXXSTDLIB_$key'] = 'static=stdc++'; - // Rust bundles this archive before linking, without GCC's default paths. - final archive = File( - (await runCommand( - ['c++', '-print-file-name=libstdc++.a'], - environment: env, - capture: true, - ))!, - ).absolute; - if (!await archive.exists()) { - throw StateError( - 'Missing libstdc++.a; install the C++ development tools', - ); - } - rustflags.add('-Lnative=${archive.parent.path}'); - } else if (target.contains('windows') && !Platform.isWindows) { - throw StateError('Windows MSVC releases require Windows and Visual Studio'); - } - - // Preserve library paths containing spaces as a single compiler argument. - env['CARGO_ENCODED_RUSTFLAGS'] = rustflags.join('\u001f'); - return env; -} - -/// Reject a host ABI or shared dependency newer than the advertised floor. -Future auditLinuxLibrary(File library, String minimum) async { - final symbols = (await runCommand([ - 'readelf', - '--version-info', - library.path, - ], capture: true))!; - final dependencies = (await runCommand([ - 'readelf', - '--dynamic', - library.path, - ], capture: true))!; - auditLinuxMetadata(symbols, dependencies, minimum, name: library.path); -} - -void auditLinuxMetadata( - String symbols, - String dependencies, - String minimum, { - String name = 'Native library', -}) { - final floor = minimum.split('.').map(int.parse).toList(); - for (final match in RegExp(r'GLIBC_(\d+)\.(\d+)').allMatches(symbols)) { - final major = int.parse(match[1]!); - final minor = int.parse(match[2]!); - if (major > floor[0] || (major == floor[0] && minor > floor[1])) { - throw StateError('$name requires glibc $major.$minor, above $minimum'); - } - } - const allowed = { - 'libc.so.6', - 'libm.so.6', - 'libdl.so.2', - 'libpthread.so.0', - 'librt.so.1', - 'libgcc_s.so.1', - 'ld-linux-x86-64.so.2', - }; - final unexpected = - RegExp(r'\(NEEDED\).*\[([^\]]+)\]') - .allMatches(dependencies) - .map((match) => match[1]!) - .toSet() - .difference(allowed) - .toList() - ..sort(); - if (unexpected.isNotEmpty) { - throw StateError('Unexpected shared dependencies: $unexpected'); - } -} - -Future buildTarget( - String target, - Directory output, - Directory targetDirectory, { - String? ndk, - Uri? root, -}) async { - root ??= packageRoot; - output = output.absolute; - targetDirectory = targetDirectory.absolute; - final names = libraryNames(target); - final fingerprint = await sourceSha256(root); - final channel = await toolchainChannel(root); - final env = await buildEnvironment(target, ndk: ndk); - await runCommand([ - 'rustup', - 'toolchain', - 'install', - channel, - '--profile', - 'minimal', - '--no-self-update', - ], workingDirectory: root); - await runCommand([ - 'rustup', - 'target', - 'add', - '--toolchain', - channel, - target, - ], workingDirectory: root); - await runCommand( - [ - 'rustup', - 'run', - channel, - 'cargo', - 'build', - '--release', - '--locked', - '--lib', - '--manifest-path', - root.resolve('rust/Cargo.toml').toFilePath(), - '--target', - target, - '--target-dir', - targetDirectory.path, - ], - workingDirectory: root, - environment: env, - ); - if (await sourceSha256(root) != fingerprint) { - throw StateError( - 'Native sources changed during the build; refusing to package', - ); - } - final release = targetDirectory.uri.resolve('$target/release/'); - final minimumGlibc = releaseTargets[target]!['minimum_glibc_version']; - if (minimumGlibc is String) { - await auditLinuxLibrary( - File.fromUri(release.resolve(names['dynamic']!)), - minimumGlibc, - ); - } - final entries = <({String mode, File source, File destination})>[]; - final fragmentFile = File.fromUri(output.uri.resolve('$target.json')); - for (final entry in names.entries) { - final source = File.fromUri(release.resolve(entry.value)); - if (await FileSystemEntity.type(source.path, followLinks: false) != - FileSystemEntityType.file) { - throw StateError('Missing or nonregular built library: ${source.path}'); - } - final extension = entry.value.substring(entry.value.lastIndexOf('.')); - final destination = File.fromUri( - output.uri.resolve('mwc_wallet-$target-${entry.key}$extension'), - ); - entries.add((mode: entry.key, source: source, destination: destination)); - } - for (final file in [ - fragmentFile, - ...entries.map((entry) => entry.destination), - ]) { - if (await FileSystemEntity.type(file.path, followLinks: false) != - FileSystemEntityType.notFound) { - throw StateError('Refusing to overwrite an artifact: ${file.path}'); - } - } - await output.create(recursive: true); - final artifacts = >[]; - for (final entry in entries) { - final destination = await entry.source.copy(entry.destination.path); - artifacts.add({ - 'target': target, - 'link_mode': entry.mode, - 'file': destination.uri.pathSegments.last, - 'sha256': await sha256File(destination), - 'size': await destination.length(), - ...releaseTargets[target]!, - }); - } - await fragmentFile.writeAsString( - '${indentedJson.convert({ - 'schema_version': 1, - 'package': 'flutter_libmwc', - 'source_sha256': fingerprint, - 'artifacts': artifacts, - 'build': {'rust_version': channel, 'target': target}, - })}\n', - ); - stdout.writeln('Packaged $target; source_sha256=$fingerprint'); -} - -Future main(List arguments) async { - final parser = ArgParser() - ..addOption('target', allowed: releaseTargets.keys.toList()) - ..addOption( - 'output', - defaultsTo: packageRoot.resolve('build/prebuilts').toFilePath(), - ) - ..addOption( - 'target-dir', - defaultsTo: packageRoot.resolve('build/prebuilt-target').toFilePath(), - ) - ..addOption('ndk') - ..addFlag( - 'source-hash', - negatable: false, - help: 'Print the native source fingerprint without building.', - ) - ..addFlag( - 'dry-run', - negatable: false, - help: 'Print the release plan without installing or compiling anything.', - ) - ..addFlag('help', abbr: 'h', negatable: false); - try { - final args = parser.parse(arguments); - if (args.flag('help')) { - stdout.writeln('Build one native release target.\n${parser.usage}'); - return; - } - if (args.rest.isNotEmpty) - throw FormatException('Unexpected arguments: ${args.rest.join(' ')}'); - final target = args.option('target'); - if (args.flag('source-hash')) { - stdout.writeln(await sourceSha256()); - } else if (args.flag('dry-run') && target != null) { - stdout.writeln( - indentedJson.convert({ - 'source_sha256': await sourceSha256(), - 'rust_version': await toolchainChannel(), - 'target': target, - 'cargo_arguments': [ - 'build', - '--release', - '--locked', - '--lib', - '--target', - target, - ], - 'libraries': libraryNames(target), - 'compatibility': releaseTargets[target], - 'android_ndk': target.contains('android') ? androidNdkVersion : null, - }), - ); - } else if (target != null) { - await buildTarget( - target, - Directory(args.option('output')!), - Directory(args.option('target-dir')!), - ndk: args.option('ndk'), - ); - } else { - throw const FormatException('--target or --source-hash is required'); - } - } on FormatException catch (error) { - stderr.writeln('${error.message}\n${parser.usage}'); - exitCode = 64; - } catch (error) { - stderr.writeln('Native release build failed: $error'); - exitCode = 1; - } -} diff --git a/tool/prebuilt_manifest.dart b/tool/prebuilt_manifest.dart deleted file mode 100644 index be7e680..0000000 --- a/tool/prebuilt_manifest.dart +++ /dev/null @@ -1,191 +0,0 @@ -import 'dart:convert'; -import 'dart:io'; - -import 'package:args/args.dart'; - -import 'src/build_support.dart'; - -typedef ManifestResult = ({Map manifest, String sha256}); -Map _object(Object? value, String description) { - if (value is! Map) { - throw FormatException('Expected an object: $description'); - } - return Map.from(value); -} - -bool _matches(String pattern, String value) => - RegExp(pattern).firstMatch(value)?.group(0) == value; - -/// Verify all producer fragments before writing any release files. -Future assemblePrebuilts( - Directory artifactsDirectory, - Directory outputDirectory, { - Uri? root, - bool requireAll = false, -}) async { - if (await outputDirectory.exists() && !await outputDirectory.list().isEmpty) { - throw StateError('Release output must be empty; refusing to mix releases'); - } - final expectedSource = await sourceSha256(root); - final fragments = []; - await for (final entity in artifactsDirectory.list( - recursive: true, - followLinks: false, - )) { - if (entity is File && entity.path.endsWith('.json')) fragments.add(entity); - } - fragments.sort((first, second) => first.path.compareTo(second.path)); - if (fragments.isEmpty) { - throw const FormatException('No build fragments found'); - } - final artifacts = <({Map entry, File source})>[]; - final seenPairs = <(String, String)>{}; - final seenNames = {}; - for (final path in fragments) { - final fragment = _object(jsonDecode(await path.readAsString()), path.path); - if (fragment['schema_version'] != 1 || - fragment['package'] != 'flutter_libmwc') { - throw FormatException('Unsupported build fragment: ${path.path}'); - } - if (fragment['source_sha256'] != expectedSource) { - throw FormatException('Source fingerprint mismatch: ${path.path}'); - } - final entries = fragment['artifacts']; - if (entries is! List || entries.isEmpty) { - throw FormatException('Missing artifact entries: ${path.path}'); - } - for (final value in entries) { - final entry = _object(value, '${path.path} artifact'); - final target = entry['target']; - final mode = entry['link_mode']; - final name = entry['file']; - if (target is! String || - !releaseTargets.containsKey(target) || - mode is! String || - !['static', 'dynamic'].contains(mode)) { - throw FormatException('Unknown target or link mode in ${path.path}'); - } - if (name is! String || - !_matches( - r'[A-Za-z0-9_-][A-Za-z0-9_.-]*\.(so|dylib|dll|a|lib)', - name, - )) { - throw FormatException('Unsafe artifact filename in ${path.path}'); - } - if (seenPairs.contains((target, mode)) || seenNames.contains(name)) { - throw FormatException('Duplicate artifact: $target/$mode/$name'); - } - for (final compatibility in releaseTargets[target]!.entries) { - if (entry[compatibility.key] != compatibility.value) { - throw FormatException('Incorrect ${compatibility.key} for $target'); - } - } - final source = File.fromUri(path.parent.uri.resolve(name)); - if (await FileSystemEntity.type(source.path, followLinks: false) != - FileSystemEntityType.file) { - throw FormatException('Missing or nonregular artifact: ${source.path}'); - } - final size = entry['size']; - if (size is! int || - size <= 0 || - size > 2 * 1024 * 1024 * 1024 || - await source.length() != size) { - throw FormatException('Artifact size mismatch: ${source.path}'); - } - if (await sha256File(source) != entry['sha256']) { - throw FormatException('Artifact checksum mismatch: ${source.path}'); - } - seenPairs.add((target, mode)); - seenNames.add(name); - artifacts.add((entry: entry, source: source)); - } - } - final expectedPairs = { - for (final target in releaseTargets.keys) - for (final mode in ['static', 'dynamic']) (target, mode), - }; - if (requireAll && !seenPairs.containsAll(expectedPairs)) { - final missing = - expectedPairs.difference(seenPairs).map((p) => '$p').toList()..sort(); - throw FormatException('Missing release artifacts: $missing'); - } - artifacts.sort((first, second) { - final target = (first.entry['target'] as String).compareTo( - second.entry['target'] as String, - ); - return target != 0 - ? target - : (first.entry['link_mode'] as String).compareTo( - second.entry['link_mode'] as String, - ); - }); - final manifest = { - 'schema_version': 1, - 'package': 'flutter_libmwc', - 'source_sha256': expectedSource, - 'artifacts': artifacts.map((artifact) => artifact.entry).toList(), - }; - await outputDirectory.create(recursive: true); - for (final artifact in artifacts) { - final destination = File.fromUri( - outputDirectory.uri.resolve(artifact.entry['file'] as String), - ); - await artifact.source.copy(destination.path); - } - final manifestFile = File.fromUri( - outputDirectory.uri.resolve('manifest.json'), - ); - await manifestFile.writeAsString('${indentedJson.convert(manifest)}\n'); - final pin = await sha256File(manifestFile); - await File.fromUri(outputDirectory.uri.resolve('manifest.sha256')) - .writeAsString('$pin manifest.json\n'); - stdout.writeln('Manifest SHA-256: $pin'); - return (manifest: manifest, sha256: pin); -} - -Future main(List arguments) async { - final parser = ArgParser() - ..addOption('artifacts') - ..addOption( - 'output', - defaultsTo: packageRoot.resolve('build/native-release/').toFilePath(), - ) - ..addFlag('require-all', negatable: false) - ..addFlag('help', abbr: 'h', negatable: false); - try { - final args = parser.parse(arguments); - if (args.flag('help')) { - stdout.writeln( - 'Verify native artifacts and assemble a release.\n${parser.usage}', - ); - return; - } - if (args.rest.isNotEmpty) - throw const FormatException('Unexpected arguments'); - final artifactsPath = args.option('artifacts'); - if (artifactsPath == null) { - throw const FormatException('--artifacts is required'); - } - final output = Directory(args['output'] as String).absolute; - final result = await assemblePrebuilts( - Directory(artifactsPath).absolute, - output, - requireAll: args['require-all'] as bool, - ); - final summaryPath = Platform.environment['GITHUB_STEP_SUMMARY']; - if (summaryPath != null) { - await File(summaryPath).writeAsString( - 'Manifest SHA-256: `${result.sha256}`\n\n' - 'Native source SHA-256: `${result.manifest['source_sha256']}`\n', - mode: FileMode.append, - ); - } - } on FormatException catch (error) { - stderr.writeln(error.message); - stderr.writeln(parser.usage); - exitCode = 1; - } on Object catch (error) { - stderr.writeln(error); - exitCode = 1; - } -} diff --git a/tool/smoke_prebuilt.dart b/tool/smoke_prebuilt.dart deleted file mode 100644 index 7d932ee..0000000 --- a/tool/smoke_prebuilt.dart +++ /dev/null @@ -1,78 +0,0 @@ -/// Load a desktop release library and check its C ABI without creating a wallet. -library; - -import 'dart:ffi'; -import 'dart:io'; - -import 'package:args/args.dart'; -import 'package:ffi/ffi.dart'; - -void smoke(File file) { - final library = DynamicLibrary.open(file.absolute.path); - try { - final mnemonic = library - .lookupFunction Function(), Pointer Function()>( - 'mwc_get_mnemonic', - )(); - if (mnemonic == nullptr || - mnemonic.toDartString().trim().split(RegExp(r'\s+')).length != 24) { - throw StateError('Mnemonic ABI smoke test failed'); - } - final openWallet = library - .lookupFunction< - Pointer Function(Pointer, Pointer), - Pointer Function(Pointer, Pointer) - >('mwc_rust_open_wallet'); - final freeString = library - .lookupFunction< - Void Function(Pointer), - void Function(Pointer) - >('mwc_string_free'); - final config = '{}'.toNativeUtf8(); - final password = 'unused'.toNativeUtf8(); - try { - for (var index = 0; index < 100; index++) { - final pointer = openWallet(config, password); - if (pointer == nullptr) { - throw StateError('Open-wallet ABI returned null'); - } - try { - if (!pointer.toDartString().contains('Unable to get wallet config')) { - throw StateError('Open-wallet error ABI smoke test failed'); - } - } finally { - freeString(pointer); - } - } - } finally { - calloc.free(config); - calloc.free(password); - } - stdout.writeln( - 'Native library loaded; mnemonic and error/free ABI checks passed', - ); - } finally { - library.close(); - } -} - -void main(List arguments) { - final parser = ArgParser()..addFlag('help', abbr: 'h', negatable: false); - try { - final args = parser.parse(arguments); - if (args.flag('help')) { - stdout.writeln('Usage: smoke_prebuilt.dart \n${parser.usage}'); - return; - } - if (args.rest.length != 1) { - throw const FormatException('Supply exactly one native library path'); - } - smoke(File(args.rest.single)); - } on FormatException catch (error) { - stderr.writeln('${error.message}\nUsage: smoke_prebuilt.dart '); - exitCode = 64; - } catch (error) { - stderr.writeln('Native smoke test failed: $error'); - exitCode = 1; - } -} diff --git a/tool/src/build_support.dart b/tool/src/build_support.dart deleted file mode 100644 index 2718455..0000000 --- a/tool/src/build_support.dart +++ /dev/null @@ -1,95 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; - -import 'package:crypto/crypto.dart' as crypto; - -import '../../hook/src/source_fingerprint.dart'; - -const releaseTargets = >{ - 'x86_64-unknown-linux-gnu': {'minimum_glibc_version': '2.35'}, - 'x86_64-pc-windows-msvc': {}, - 'x86_64-apple-darwin': {'minimum_os_version': 11}, - 'aarch64-apple-darwin': {'minimum_os_version': 11}, - 'armv7-linux-androideabi': {'minimum_os_version': 21}, - 'aarch64-linux-android': {'minimum_os_version': 21}, - 'x86_64-linux-android': {'minimum_os_version': 21}, - 'aarch64-apple-ios': {'minimum_os_version': 13}, - 'aarch64-apple-ios-sim': {'minimum_os_version': 13}, - 'x86_64-apple-ios': {'minimum_os_version': 13}, -}; - -const androidNdkVersion = '28.2.13676358'; -const indentedJson = JsonEncoder.withIndent(' '); - -/// Run maintainer tools from the repository root. -final Uri packageRoot = Directory.current.uri; - -Future sha256File(File file) async => - (await crypto.sha256.bind(file.openRead()).first).toString(); - -/// Producer and consumer use the same source fingerprint implementation. -Future sourceSha256([Uri? root]) async => - (await sourceFingerprint(root ?? packageRoot)).sha256; - -Future toolchainChannel([Uri? root]) async { - final source = await File.fromUri( - (root ?? packageRoot).resolve('rust/rust-toolchain.toml'), - ).readAsString(); - final match = RegExp( - r'^channel\s*=\s*"(\d+\.\d+\.\d+)"\s*$', - multiLine: true, - ).firstMatch(source); - if (match == null) { - throw StateError('rust/rust-toolchain.toml must pin an exact Rust version'); - } - return match[1]!; -} - -Map libraryNames(String target) { - if (!releaseTargets.containsKey(target)) { - throw ArgumentError.value(target, 'target', 'Unsupported release target'); - } - if (target.contains('windows')) { - return {'dynamic': 'mwc_wallet.dll', 'static': 'mwc_wallet.lib'}; - } - return { - 'dynamic': 'libmwc_wallet${target.contains('apple') ? '.dylib' : '.so'}', - 'static': 'libmwc_wallet.a', - }; -} - -/// Run argument vectors without involving a shell. When an explicit environment -/// is supplied, omitted variables stay removed from the child process. -Future runCommand( - List command, { - Uri? workingDirectory, - Map? environment, - bool capture = false, -}) async { - stdout.writeln('+ ${command.join(' ')}'); - final process = await Process.start( - command.first, - command.skip(1).toList(), - workingDirectory: (workingDirectory ?? packageRoot).toFilePath(), - environment: environment, - includeParentEnvironment: environment == null, - ); - final (status, output, _, _) = await ( - process.exitCode, - capture - ? process.stdout.transform(systemEncoding.decoder).join() - : stdout.addStream(process.stdout).then((_) => ''), - stderr.addStream(process.stderr), - process.stdin.close(), - ).wait; - if (status != 0) { - throw ProcessException( - command.first, - command.skip(1).toList(), - 'Command failed', - status, - ); - } - return capture ? output.trim() : null; -} diff --git a/tool/test_prebuilt.dart b/tool/test_prebuilt.dart deleted file mode 100644 index de046ba..0000000 --- a/tool/test_prebuilt.dart +++ /dev/null @@ -1,41 +0,0 @@ -import 'dart:io'; -import 'dart:isolate'; - -/// Run with `dart --packages=.dart_tool/package_config.json -/// tool/test_prebuilt.dart` after `flutter pub get`. Invoking the VM directly -/// keeps resolver and release-tool tests independent of the native Cargo hook. -Future main(List args) async { - final root = Platform.script.resolve('../'); - final packageConfig = File.fromUri( - root.resolve('.dart_tool/package_config.json'), - ); - if (!await packageConfig.exists()) { - stderr.writeln('Run flutter pub get before testing the prebuilt resolver.'); - exitCode = 1; - return; - } - final testLibrary = await Isolate.resolvePackageUri( - Uri.parse('package:test/test.dart'), - ); - if (testLibrary == null) { - stderr.writeln('Cannot resolve package:test. Run flutter pub get first.'); - exitCode = 1; - return; - } - final runner = File.fromUri(testLibrary.resolve('../bin/test.dart')); - final process = await Process.start( - Platform.resolvedExecutable, - [ - '--packages=${packageConfig.path}', - runner.path, - if (args.isEmpty) ...[ - 'test/prebuilt_test.dart', - 'test/prebuilt_producer_test.dart', - ] else - ...args, - ], - workingDirectory: root.toFilePath(), - mode: ProcessStartMode.inheritStdio, - ); - exitCode = await process.exitCode; -} diff --git a/tool/update_lock.dart b/tool/update_lock.dart new file mode 100644 index 0000000..7308919 --- /dev/null +++ b/tool/update_lock.dart @@ -0,0 +1,4 @@ +import 'dart:io'; +import 'package:dnix/tools.dart'; + +void main(List args) async => exit(await updateLock(args)); diff --git a/tool/verify_release.dart b/tool/verify_release.dart new file mode 100644 index 0000000..0dd9d24 --- /dev/null +++ b/tool/verify_release.dart @@ -0,0 +1,4 @@ +import 'dart:io'; +import 'package:dnix/tools.dart'; + +void main(List args) async => exit(await verifyRelease(args));