Skip to content

Make Dart native builds reproducible - #1815

Open
ethicnology wants to merge 5 commits into
payjoin:masterfrom
SatoshiPortal:verify-dart-native-reproducibility
Open

Make Dart native builds reproducible#1815
ethicnology wants to merge 5 commits into
payjoin:masterfrom
SatoshiPortal:verify-dart-native-reproducibility

Conversation

@ethicnology

@ethicnology ethicnology commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Dart consumers currently resolve the native wrapper's Cargo graph and inherit build paths from each machine. That makes published-package builds sensitive to local dependency resolution and prevents byte-for-byte reproducibility.

This PR:

  • tracks the production native/Cargo.lock and builds it with --locked;
  • keeps the workspace path overlay local-only and explicitly opts test helpers into development tests;
  • remaps package, output, Cargo, and Rustup paths in the Dart native build hook;
  • adds focused flag and artifact-check tests;
  • documents reproducibility and isolated consumer smoke-test procedures.

Deliberate non-goals:

  • no changes to the Dart public API or generated bindings;
  • no changes to the production feature set beyond making its dependency graph explicit and locked;
  • no full reproducible Android build in this repository.

@coveralls

coveralls commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

Coverage Report for CI Build 32894300291

Coverage remained the same at 86.718%

Details

  • Coverage remained the same as the base build.
  • Patch coverage: No coverable lines changed in this PR.
  • No coverage regressions found.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 16458
Covered Lines: 14272
Line Coverage: 86.72%
Coverage Strength: 343.63 hits per line

💛 - Coveralls

@Jolah1 Jolah1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Concept ACK — tracking the production lockfile and remapping build paths is the right shape, and the .pubignore interaction holds up: .cargo/ is excluded as a directory, so renaming the overlay doesn't leak it into the
archive, and native/Cargo.lock isn't excluded, so it ships. The lockfile itself is clean (384 packages, only payjoin-ffi-wrapper sourceless).

The hook has four issues that stack, and none of them can surface individually because the first one masks the rest. In order: the env-var opt-in never reaches the hook, so _test-utils is always off — that's the 9 failing Dart
tests. Fix that and --config errors on a path that doesn't exist. Fix that and --locked rejects the patch overlay. And once rebased, the tracked lock's pinned rev no longer matches master's manifest, so --locked fails on the
production path too.

Details and repro commands inline. Also: nix fmt clears both the Formatting and flake-check (maintenance) failures — same treefmt diff. And given the repo's commit conventions this is probably five commits rather than one
(rename overlay, track lock, remap paths, add scripts, docs).

Comment thread payjoin-ffi/dart/hook/build.dart Outdated
final rustFlagEnvironment = hasEncodedRustFlags
? {'CARGO_ENCODED_RUSTFLAGS': rustFlags}
: {'RUSTFLAGS': rustFlags};
final enableTestUtils =

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is why the Dart job fails. hooks_runner spawns build hooks with includeParentEnvironment: false and an allowlisted environment — a static name list plus the prefixes CARGO_, RUSTUP_, NIX_, CCACHE_, DOTNET_, NUGET_, CONAN_. PAYJOIN_FFI_ENABLE_TEST_UTILS matches none of them, so it's always null here regardless of the export in contrib/test.sh:25, and enableTestUtils is always false.

Result in this PR's CI: 9 tests fail on Failed to lookup symbol 'uniffi_payjoin_ffi_fn_func_example_url', ..._original_psbt, ..._testservices_initialize — all _test-utils-gated. The bindings are still generated with _test-utils, so they declare symbols the library doesn't export.

Worth noting the mechanism you replaced worked precisely because it needed no environment. Options: a CARGO_-prefixed variable name, a file marker, or hooks user-defines. Same applies to RUSTFLAGS at line 17 — also not in the allowlist, so that branch is unreachable.

Comment thread payjoin-ffi/dart/hook/build.dart Outdated
extraCargoBuildArgs: [
if (enableTestUtils && localCargoConfig.existsSync()) ...[
'--config',
'../.cargo/config.local.toml',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cargo resolves a relative --config file path against its own cwd, which hooks_runner sets to input.packageRoot = payjoin-ffi/dart. So ../.cargo/config.local.toml points at payjoin-ffi/.cargo/config.local.toml, which doesn't exist — and cargo doesn't skip it, it hard-errors:

error: failed to parse value from --config argument ../.cargo/config.local.toml as a dotted key expression Should be .cargo/config.local.toml, or an absolute path off input.packageRoot.

Comment thread payjoin-ffi/dart/hook/build.dart Outdated
'--config',
'../.cargo/config.local.toml',
],
'--locked',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

--locked and the [patch] overlay above are mutually exclusive. The overlay redirects payjoin-ffi/payjoin from the pinned rev to workspace paths, which changes resolution, and --locked forbids the resulting lock update:

$ cargo build --offline --locked --config .cargo/patch.toml
error: cannot update the lock file ... because --locked was passed to prevent this

Same command without --config succeeds. --locked belongs on the production path only — inside an if (!enableTestUtils).

Comment thread payjoin-ffi/dart/hook/build_flags.dart Outdated
}

final inheritedPlain = _nonEmpty(rustFlags);
return [if (inheritedPlain != null) inheritedPlain, ...remaps].join(' ');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the normal consumer case neither RUSTFLAGS nor CARGO_ENCODED_RUSTFLAGS is inherited, so this joins the remaps with spaces — and cargo splits RUSTFLAGS on whitespace. input.outputDirectory lives under the consumer app's .dart_tool/, so a project at /Users/jane/My App yields --remap-path-prefix=/Users/jane/My plus a stray App/... argument, and rustc fails. Emitting CARGO_ENCODED_RUSTFLAGS unconditionally avoids the ambiguity — it's \x1f-separated.

Comment thread payjoin-ffi/dart/contrib/test.sh Outdated
rm -f native/Cargo.lock
echo "==> Checking production native Cargo.lock..."
test -f native/Cargo.lock
if grep -n 'path = ' native/Cargo.lock; then

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This guard can never fire. Cargo.lock has no path key — a path dependency is the absence of source. I reproduced the case it's meant to catch (lock resolved through a [patch] to a local path):

[[package]]
name = "once_cell"
version = "1.21.4" # no source line

grep -c 'path = ' → 0. Second data point: this repo's Cargo-recent.lock has six path members and zero path = . So it returns 0 matches whether the lock is correct or poisoned. smoke_consumer.sh:54's grep -Fq '...rust-payjoin.git?rev=' is the check with teeth — that, plus asserting no non-root package lacks a source.

artifacts=()
for copy in first second; do
matches=(
"$work_dir/consumer-$copy/.dart_tool/hooks_runner/shared"/*/build/*/target/"$rust_target"/release/*.so

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This glob matches nothing, so the script exits "found 0" on the first consumer and can never pass. native_toolchain_rust builds into input.outputDirectory/target, and hooks_runner sets outputDirectory to .dart_tool/hooks_runner///out/. The shared/ tree is a different directory, used for outputDirectoryShared.

Two more in the same block: CARGO_TARGET_DIR at line 90 is inert because RustBuildRunner passes --target-dir explicitly and CLI wins — which contradicts the CONTRIBUTING claim that the two runs use separate target dirs. And
line 111 passes a Dart version string where check_reproducible.sh expects a target triple, so the report prints Target: dart-3.x.y while $rust_target is only used in the glob.

Comment thread payjoin-ffi/dart/hook/build.dart Outdated
await build(args, (input, output) async {
final environment = Platform.environment;
final rustFlags = composeRustFlags(
packageRoot: input.packageRoot.toFilePath(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

input.packageRoot is a directory Uri, so toFilePath() keeps the trailing separator and rustc's plain prefix substitution yields /payjoin/packagenative/src/lib.rs rather than the documented /payjoin/package/native/.... Still deterministic, so reproducibility holds — but not the prefix the docs describe. The unit tests pass '/package' without a trailing slash, which is why they miss it; worth a case with one.

cp "$BINDING_SOURCE" "$package_copy/lib/payjoin.dart"

test -f "$package_copy/native/Cargo.lock"
test ! -d "$package_copy/.cargo"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

vacuous: line 43 just rm -rf'd this path, so it checks the script's own rm, not the archive.

(
cd "$consumer"
"$DART_BIN" pub get --offline
) > "$work_dir/$copy-pub-get.log" 2>&1

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shfmt wants >"$work_dir/$copy-pub-get.log" (no space); same at line 93. That plus dart format on build.dart is the entire Formatting / flake-check failure.

exit 1
fi

echo "==> Generating FFI bindings..."

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

check_reproducible_test.sh isn't called from anywhere, so the new checker's tests never run in CI.

The Dart package carries a .cargo/config.toml that patches payjoin-ffi to
workspace paths. Cargo discovers that file by walking up from the crate,
so every command run under payjoin-ffi/dart silently inherits the overlay,
and the hook could only tell a checkout from a published archive by the
same file's existence without ever saying so.

Rename it to config.local.toml, which Cargo does not pick up on its own,
and have the build hook pass it explicitly with --config. The presence of
the file stays the signal for a workspace build, but it is now the hook
that decides when the overlay applies. Pass the absolute path: Cargo
resolves a relative --config path against its own working directory, which
is the package root, and errors out when nothing is there.

Nothing here can move to an environment variable. hooks_runner spawns
build hooks with an allowlisted environment, so a project specific name
never reaches the hook.
The native wrapper records absolute paths from the machine that built it:
the package root, the hook output directory, and the Cargo and Rustup
homes all end up in the artifact. Two consumers building the same release
therefore produce different bytes, which makes the library impossible to
verify.

Pass --remap-path-prefix for each of those roots so the recorded paths
depend on the release rather than on the machine.

Emit them through CARGO_ENCODED_RUSTFLAGS rather than RUSTFLAGS. Cargo
splits RUSTFLAGS on whitespace, and the output directory lives under the
consumer's project: a path such as /Users/jane/My App would become two
arguments and fail the build. Flags inherited from the environment are
re-encoded argument by argument for the same reason.

Directory URIs render with a trailing separator, and rustc substitutes the
prefix literally, so drop it: otherwise native/src/lib.rs would be
recorded as /payjoin/packagenative/src/lib.rs.
Dart consumers resolve the native wrapper's Cargo graph themselves. Every
install therefore picks whatever versions are current, so two users of the
same package version can compile different code, and the release is never
tested against what they actually build.

Track native/Cargo.lock and have the hook build it with --locked, but only
when the local overlay is absent: that overlay redirects payjoin-ffi to
workspace paths, and Cargo refuses to update a locked file to match. The
workspace test run keeps the tracked lockfile aside, since the graph it
pins does not resolve against the patched workspace.

Check the lockfile in CI and before publishing. It cannot be checked by
grepping for a `path` key, which is what an earlier revision of this
branch did: a Cargo lockfile has no such key, and a dependency resolved
through the overlay is simply a package that lost its `source`. Assert
instead that the wrapper is the only sourceless package, and that the
pinned revision matches the manifest, which nothing else catches until a
consumer tries to build. The checker ships with tests over both cases.

prepare-publish.sh no longer deletes the lockfile, and asserts on the dry
run's file listing that the archive carries it and still withholds the
development overlay.
@ethicnology
ethicnology force-pushed the verify-dart-native-reproducibility branch from 51da8ed to 071cb61 Compare August 25, 2026 19:52
Nothing in the repository verifies that the remapped native build is
actually reproducible, or that a consumer installing the published package
gets a library it can compile at all.

check_reproducible.sh compares two artifacts byte-for-byte and reports the
toolchain versions that produced them, so a divergence names the inputs
that have to be pinned. smoke_consumer.sh drives the whole consumer path:
it stands in two isolated Dart projects on a copy of the package with
everything .pubignore withholds removed, builds each through the hook with
its own Cargo home, and feeds both artifacts to the checker.

The artifact is located by searching the hook output tree rather than by a
fixed glob, because hooks_runner has moved that directory between releases
and the path is not part of its contract. Nothing sets CARGO_TARGET_DIR:
the hook passes --target-dir explicitly and the command line wins, and the
two runs already build under separate project directories.

contrib/test.sh runs the checker's own tests, which need no Rust build.
The release notes did not say how to regenerate the tracked lockfile, how
the hook decides between a workspace and a consumer build, or how to check
that two builds of a release match.

Record the lockfile step with the command that reproduces it, describe the
hook's marker file and its remapped prefixes, and give the two-build
reproducibility procedure and the consumer smoke test their own sections.
Point Running Tests at contrib/test.sh, which is what CI runs and what
keeps the tracked lockfile out of the workspace build's way.
@ethicnology
ethicnology force-pushed the verify-dart-native-reproducibility branch from 071cb61 to fddecc8 Compare August 25, 2026 20:15
@ethicnology

Copy link
Copy Markdown
Contributor Author

Thanks @Jolah1

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants