Skip to content

test: exercise the legacy-contract migration fallback before it is needed - #65

Merged
sanity merged 3 commits into
mainfrom
test/exercise-legacy-fallback
Aug 10, 2026
Merged

test: exercise the legacy-contract migration fallback before it is needed#65
sanity merged 3 commits into
mainfrom
test/exercise-legacy-fallback

Conversation

@sanity

@sanity sanity commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Problem

Freenet contract keys are content-addressed — ContractKey = BLAKE3(BLAKE3(wasm) || params) — so any change to repo-contract.wasm re-keys every repo and orphans the state stored under the old key. legacy_contracts.toml plus wsclient::get_state_with_legacy_fallback is this crate's remedy: record predecessor WASM hashes, and on a miss at the current key walk backwards through them.

That registry has been empty since the day it was added, and the fallback has therefore never run. Verified against current main:

  • legacy_contracts.toml has exactly one commit in its history (53276c6, 2026-04-30) and contained zero [[entry]] blocks then and now.
  • contracts/repo-contract.wasm has not changed since 2026-04-30 (b8edf90), so no re-key has happened across 0.1.1 → 0.1.27.
  • With an empty registry, legacy_hashes is an empty slice, so the legacy loop body in probe_all_keys has provably never executed in any shipped binary, and fetch_repo_state's migration branch has never been taken.

The existing tests cover the path's pure functions in isolation — dispatch_get_response, ProbeOutcome, outcomes_worth_retrying, format_fallback_failure, contract_id_from_wasm_hash — but nothing drives the sequencing: probe the current key, walk the predecessors in registry order, hand back what was found, re-PUT it forward. Nor has the build.rs TOML parser ever parsed an [[entry]] block.

The risk is not that the machinery is wrong today. It is that the first time it runs will be the first real re-key — which #63 already has queued — on live user data, with no prior evidence it works.

Approach

Tests only; no behavior change.

A fake Freenet gateway (tests/support/fake_gateway.rs) that speaks the real wire protocol — bincode-encoded ClientRequest/HostResponse frames over a real loopback WebSocket — so the real client code runs its real recv loop. Per this repo's testing philosophy a live node is reserved for transport concerns, and this is not one; the gateway is the smallest thing that lets the migration sequencing execute. It records every GET and PUT in arrival order, so probe order and the migration write are assertable rather than inferred.

Three test surfaces:

  • tests/legacy_fallback.rs (6) — recovery from a predecessor key, current-key precedence with no wasted probes, registry-order walking with the correct reported index, empty-response-is-not-recovery, transient-timeout retry, and absence reporting.
  • tests/legacy_registry_codegen.rs (5) — the build.rs half: TOML [[entry]] → generated const → a contract key that matches the predecessor WASM's own stdlib-derived key. Plus order preservation, the empty shipped registry, and the two loud-failure paths.
  • git-remote-freenet migration_tests (3) — the forward re-PUT to the current key, that it lands on the current key with the recovered bytes intact, that a second client then reads it directly without falling back, and that a rejected migration PUT still returns the user's repo.

One of the seven in legacy_fallback.rs is a source-scrape pin rather than a behavioural test, covering the one line the seam makes untestable: while the registry is empty, fetch_repo_state passing &[] behaves identically to passing LEGACY_REPO_CONTRACT_WASM_HASHES, so no behavioural test can tell them apart, and a regression there would surface only at the first genuine re-key. It carries three anti-vacuity guards — it fails loudly if the wrapper is renamed, compares whitespace-stripped so cargo fmt cannot disarm it, and lives in an integration file scraping a different file so it can neither self-match nor be switched off by deleting a #[cfg(test)] mod tests block.

Oracle independence

Every expected legacy key is derived with the stdlib's ContractInstanceId::from_params_and_code over synthetic predecessor WASM bytes — never with wsclient::contract_id_from_wasm_hash, the shortcut under test. Deriving the expectation from the shortcut would make the tests self-consistent rather than correct: they would keep passing if the shortcut drifted from the real derivation and every migration probed a key that never existed. Mutation M3 below confirms the distinction is load-bearing.

Two seams (no behavior change)

  • build.rs: Entry, parse_legacy_entries, generate_code made pub so a test can include! the file. Visibility in a build script is otherwise meaningless — nothing links against it.
  • fetch_repo_state split into a thin wrapper over fetch_repo_state_from_registry(..., registry). The generated const is empty in every build and can only be filled by editing the TOML and rebuilding, so tests cannot otherwise reach the migration branch. Production still passes LEGACY_REPO_CONTRACT_WASM_HASHES. Incidental improvement: the probe hashes and the description used in the log line now come from the same slice instead of two separate reads of the const, so they cannot disagree.

Testing

15 new tests. Full workspace suite green, cargo fmt --check clean, cargo clippy --workspace --all-targets -- -D warnings clean.

Mutation evidence

A test that runs the fallback but would pass even if the fallback were broken is worse than no test. Ten mutations were applied to production code one at a time, the suite run, and the mutation reverted. All ten were killed; no survivors. Expected outcomes were written down before running.

M9 is the one that justifies the pin existing: with the wrapper passing an empty registry, all 47 other tests still pass. Nothing else in the suite can see that line.

# Mutation Predicted Actual
M1 probe_all_keys: never probe legacy keys RED except current-key test 8 RED ✅
M2 legacy hit returns empty state RED on legacy-recovery tests 7 RED ✅
M3 contract_id_from_wasm_hash: swap hash/params order RED on every legacy-key test + codegen key check 9 RED ✅ (10 counting a pre-existing unit test, per independent review)
M4 drop the migration re-PUT RED only in migration_tests (3) exactly 3 RED ✅
M5 re-PUT empty state instead of recovered RED on forward-migration + durability 1 RED ✅
M6 generate_code emits bytes reversed RED on 2 codegen tests exactly 2 RED ✅
M7 parser drops the final entry (no trailing flush) RED on 3 codegen tests 4 RED (under-predicted by one)
M8 never retry a transient probe RED only on the retry test exactly 1 RED ✅
M9 wrapper passes &[] instead of the real registry RED only on the pin, everything else green exactly 1 RED, 47 green ✅
M10 rename the wrapper out from under the pin anti-vacuity guard fires, not a silent pass RED, "this pin is now testing nothing"

The patches, verbatim:

M1  crates/freenet-git/src/wsclient.rs
-   for (idx, legacy_hash) in legacy_wasm_hashes.iter().enumerate() {
+   for (idx, legacy_hash) in legacy_wasm_hashes.iter().take(0).enumerate() {

M2  crates/freenet-git/src/wsclient.rs   (legacy-hit arm of probe_all_keys)
-                   state,
+                   state: Vec::new(),

M3  crates/freenet-git/src/wsclient.rs
-   hasher.update(wasm_hash);  hasher.update(params_bytes);
+   hasher.update(params_bytes);  hasher.update(wasm_hash);

M4  crates/freenet-git/src/bin/git-remote-freenet.rs
-   if let GetSource::Legacy { index, instance } = source {
+   if let GetSource::Legacy { index, instance } = GetSource::Current {

M5  crates/freenet-git/src/bin/git-remote-freenet.rs   (the migration put_contract call)
-           state.clone(),
+           Vec::new(),

M6  crates/freenet-git/build.rs
-   for (i, b) in entry.wasm_hash.iter().enumerate() {
+   for (i, b) in entry.wasm_hash.iter().rev().enumerate() {

M7  crates/freenet-git/build.rs   (drop the flush after the parse loop)
-   flush(&mut current, &mut entries);
    entries

M8  crates/freenet-git/src/wsclient.rs
-   if attempt >= PROBE_MAX_ATTEMPTS || !outcomes_worth_retrying(&outcomes) {
+   if true {

M9  crates/freenet-git/src/bin/git-remote-freenet.rs   (the fetch_repo_state wrapper)
-       freenet_git_cli::legacy::LEGACY_REPO_CONTRACT_WASM_HASHES,
+       &[],

M10 crates/freenet-git/src/bin/git-remote-freenet.rs   (definition + all 3 call sites)
-   fetch_repo_state(
+   fetch_repo_state_renamed(
### M9-wrapper-passes-empty-registry
  failing tests: 1   (passing: 47)
    RED  production_fetch_repo_state_passes_the_generated_registry
### M10-rename-the-pinned-wrapper
  failing tests: 1
    RED  production_fetch_repo_state_passes_the_generated_registry
         "fn fetch_repo_state was renamed or removed; this pin is now testing nothing"

Full run output:

### M1-skip-legacy-probes  (wsclient.rs)
  failing tests: 8   (passing: 40)
    RED  empty_state_at_a_predecessor_is_not_mistaken_for_recovery
    RED  migration_tests::a_rejected_migration_put_still_returns_the_recovered_state
    RED  migration_tests::a_second_registry_entry_reports_the_right_index
    RED  migration_tests::recovered_predecessor_state_is_written_forward_to_the_current_key
    RED  recovers_state_from_a_predecessor_contract_key
    RED  reports_absence_when_neither_current_nor_predecessor_has_state
    RED  transient_predecessor_timeout_is_retried_and_then_recovers
    RED  walks_predecessors_in_registry_order_and_reports_the_index
### M2-legacy-hit-returns-empty-state  (wsclient.rs)
  failing tests: 7   (passing: 41)
    RED  empty_state_at_a_predecessor_is_not_mistaken_for_recovery
    RED  migration_tests::a_rejected_migration_put_still_returns_the_recovered_state
    RED  migration_tests::a_second_registry_entry_reports_the_right_index
    RED  migration_tests::recovered_predecessor_state_is_written_forward_to_the_current_key
    RED  recovers_state_from_a_predecessor_contract_key
    RED  transient_predecessor_timeout_is_retried_and_then_recovers
    RED  walks_predecessors_in_registry_order_and_reports_the_index
### M3-swap-wasmhash-params-order  (wsclient.rs)
  failing tests: 9   (passing: 39)
    RED  a_registry_entry_survives_the_trip_to_a_probeable_contract_key
    RED  empty_state_at_a_predecessor_is_not_mistaken_for_recovery
    RED  migration_tests::a_rejected_migration_put_still_returns_the_recovered_state
    RED  migration_tests::a_second_registry_entry_reports_the_right_index
    RED  migration_tests::recovered_predecessor_state_is_written_forward_to_the_current_key
    RED  recovers_state_from_a_predecessor_contract_key
    RED  reports_absence_when_neither_current_nor_predecessor_has_state
    RED  transient_predecessor_timeout_is_retried_and_then_recovers
    RED  walks_predecessors_in_registry_order_and_reports_the_index
### M4-drop-migration-reput  (git-remote-freenet.rs)
  failing tests: 3   (passing: 45)
    RED  migration_tests::a_rejected_migration_put_still_returns_the_recovered_state
    RED  migration_tests::a_second_registry_entry_reports_the_right_index
    RED  migration_tests::recovered_predecessor_state_is_written_forward_to_the_current_key
### M5-reput-empty-state  (git-remote-freenet.rs)
  failing tests: 1   (passing: 47)
    RED  migration_tests::recovered_predecessor_state_is_written_forward_to_the_current_key
### M6-codegen-reverses-bytes  (build.rs)
  failing tests: 2   (passing: 46)
    RED  a_registry_entry_survives_the_trip_to_a_probeable_contract_key
    RED  multiple_entries_keep_registry_order
### M7-parser-drops-final-entry  (build.rs)
  failing tests: 4   (passing: 44)
    RED  a_registry_entry_survives_the_trip_to_a_probeable_contract_key
    RED  a_truncated_hash_fails_the_build - should panic
    RED  an_entry_without_a_hash_fails_the_build - should panic
    RED  multiple_entries_keep_registry_order
### M8-never-retry-transient  (wsclient.rs)
  failing tests: 1   (passing: 47)
    RED  transient_predecessor_timeout_is_retried_and_then_recovers

Two things the mutation run caught

A false negative in the first sweep. The first run reported M1/M2/M3 killing only the three migration_tests, with the six legacy_fallback tests apparently indifferent to the legacy probe loop being deleted. That was cargo's default fail-fast stopping after the first failing test binary, not weak tests — the other two targets never ran. Re-run with --no-fail-fast (table above). Worth recording: a mutation sweep across multiple test targets silently under-reports without that flag, which is exactly the direction that manufactures false confidence about coverage.

A hang in one of the new tests, now fixed (second commit). Under M1 the retry test's healer task polled forever for a predecessor probe that never arrived, so tokio::join! hung instead of failing. A test that hangs on a regression reports a CI timeout rather than a diagnosis. The healer now has a deadline, and M1 kills the test properly.

One test is a deliberate canary — update it, do not delete it

a_comments_only_registry_generates_an_empty_array asserts the shipped legacy_contracts.toml still has no [[entry]] blocks. It is meant to go red the day someone adds the first real entry, and its failure message says so. That is intentional friction at exactly the right moment: the first re-key is when the migration path stops being hypothetical, and it should force a deliberate look rather than sliding through. Whoever does that re-key should update the assertion to the new expected state, not remove the test.

Findings

The fallback works. Nothing here is a bug fix — both halves behave as documented, including the details that are easy to get wrong: probe order, the !state.is_empty() guard, the index reported back to the caller, and the degraded path when the migration PUT is rejected.

One gap, filed separately: pack contracts have no migration path at all. LEGACY_REPO_CONTRACT_WASM_HASHES covers only the repo contract. Object bundles live in pack contracts keyed by BLAKE3(BLAKE3(pack_wasm) || pack_hash), and wsclient::get_pack / chunked::fetch_chunked_pack_with_progress derive that key from the current pack_wasm with no fallback. If pack-contract.wasm is ever rebuilt — a stdlib or toolchain bump rebuilds both contracts together — the repo state migrates forward and every bundle it references becomes unreachable, so a fresh clone would list refs and then fail to fetch objects. Relevant to #63, which documents the re-key procedure for the repo contract only. See #64.

Refs freenet/freenet-core#2776.

[AI-assisted - Claude]

sanity and others added 3 commits August 9, 2026 14:27
`legacy_contracts.toml` has been empty since it was added on
2026-04-30, so the migration path it feeds has never executed with a
non-empty registry: not in production, not in CI. The machinery is
wired, but the first time it runs would be the first real re-key, on
live user data, with no prior evidence it works.

Adds a fake Freenet gateway (in-process, real loopback WebSocket, real
bincode frames) and drives the real client against it:

  * tests/legacy_fallback.rs - probe order, recovery from a
    predecessor key, current-key precedence, empty-response handling,
    transient-timeout retry, and absence reporting.
  * tests/legacy_registry_codegen.rs - the build.rs registry reader:
    TOML entry -> generated const -> a contract key that matches the
    predecessor WASM's own stdlib-derived key.
  * git-remote-freenet migration_tests - the forward re-PUT to the
    current key, its durability, and the rejected-PUT degraded path.

Expected legacy keys are derived with the stdlib's
`from_params_and_code` over synthetic predecessor WASM, never with the
`contract_id_from_wasm_hash` shortcut under test, so a drifted shortcut
fails the tests instead of agreeing with itself.

Two small seams, no behavior change: build.rs helpers made `pub` so a
test can `include!` them, and `fetch_repo_state` split so the registry
can be injected (production still passes the generated const, and the
hash and its description now come from the same slice).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BEgtjegwuJPWSaAnVJ3z4e
…d of hanging

Found by mutation testing: with the legacy probe loop disabled, the
healer's `loop` waited forever for a probe that never arrived and
`tokio::join!` hung the test rather than failing it. A test that hangs
on a regression reports a CI timeout instead of a diagnosis.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BEgtjegwuJPWSaAnVJ3z4e
The seam made everything downstream of the registry argument testable,
but left one line unobservable: while `legacy_contracts.toml` is empty,
`fetch_repo_state` passing `&[]` behaves identically to passing
`LEGACY_REPO_CONTRACT_WASM_HASHES`. A regression there would surface
only at the first genuine re-key, which is the scenario these tests
exist to de-risk.

Source-scrape pin, with anti-vacuity guards: it fails loudly if the
wrapper is renamed, matches whitespace-stripped so rustfmt cannot
disarm it, and lives in an integration file scraping a different file
so it can neither self-match nor be cut with a `mod tests` block.

Mutation-checked: passing `&[]` fails this test and only this test
(47 others stay green, which is the point); renaming the wrapper trips
the "this pin is now testing nothing" guard rather than passing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BEgtjegwuJPWSaAnVJ3z4e

@sanity sanity left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Comprehensive PR Review: #65

Tier: Light — test-only. Production diff is a build.rs visibility change (inert; nothing links a build script) and a fetch_repo_statefetch_repo_state_from_registry split where the wrapper passes the same constant the old code read. wsclient.rs untouched.
Reviewers run: adversarial deep read (Fable), with four of the eight mutations independently re-run rather than taken from the table.

Findings

No blocking findings.

Mutations reproduced, not trusted. M1 (both fail-fast modes), M3, M4 and M6 were re-run in an isolated copy; each genuinely mutates production code and reproduced the reported kill set. M3 killed 10 rather than the reported 9 — a pre-existing unit test also catches it — so the table under-counted in the harmless direction.

The --no-fail-fast discovery is real and worth carrying forward. A multi-target mutation sweep under cargo's defaults stops after the first failing target, so kills in later targets are never observed and coverage looks absent when it isn't. It affects sweep counting only, not CI redness — but it is a check that quietly reports "nothing here" when the truth is "I stopped looking", and it produced a false negative in this very PR before being caught.

The fake gateway is faithful enough to mean something — real WebApi recv loop over a real tungstenite loopback with real bincode framing, so drift would fail the connection rather than silently pass.

The hang fix holds. Under M1 the retry test now fails in 5.01s via HEAL_DEADLINE instead of hanging tokio::join! forever, and no other test in the new files shares the unbounded-poll pattern. A test that hangs rather than fails is worse than one that fails, so this mattered.

The added registry pin closes a genuine blind spot. That production passes the real LEGACY_REPO_CONTRACT_WASM_HASHES (rather than &[], or the wrong constant) was unobservable by the entire suite while the registry is empty — M9 confirms it: substituting &[] reddens only the new pin, with all 47 other tests green. Its three anti-vacuity guards (fails loudly if the wrapper is renamed, whitespace-stripped comparison so cargo fmt cannot disarm it, lives in a different file from the one it scrapes) are the right precautions for a source pin.

Note for whoever performs the first re-key

a_comments_only_registry_generates_an_empty_array is a deliberate canary: it is meant to go red the day a real [[entry]] is added, and its failure message says so. Update the assertion; do not delete the test.

Verdict

Ready to Merge. HEAD reviewed: ab5a4c1.

Separately filed during this work: freenet-git#64 — pack contracts have no legacy-migration path at all, so a pack-contract re-key would migrate repo state forward while making every bundle it references unreachable. Relevant to #63.

[AI-assisted - Claude]

@sanity
sanity merged commit b3b1451 into main Aug 10, 2026
5 checks passed
@sanity
sanity deleted the test/exercise-legacy-fallback branch August 10, 2026 15:01
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.

1 participant