Skip to content

Keep one continuity floor per repository root and remove the unpinned paths - #26

Merged
hmirin merged 3 commits into
mainfrom
fix/one-continuity-floor
Oct 29, 2026
Merged

hmirin merged 3 commits into
mainfrom
fix/one-continuity-floor

Conversation

@hmirin

@hmirin hmirin commented Oct 29, 2026

Copy link
Copy Markdown
Contributor

Step 2 of the refactoring plan. It fixes a confirmed high-severity bug from the Astra review, and removes the unpinned path flagged by both reviews (Opus 2.1).

Bug (reproduced on main)

The machine kept two disconnected continuity pins:

  • device-add, device-revoke and device-list advanced an administrator pin;
  • the remote helper validated only the checkout's pin.

Reproduction, directory backend, real binaries:

  1. Add device D; the owner's clone fetches.
  2. Snapshot storage, then device-revoke D.
  3. Restore the snapshot.
  4. git push from the clone.

The push succeeded and published a generation where D was still an active reader. D's key decrypted the post-revocation commit. device-list detected the same rollback. The helper was protected only if the clone had fetched after the revocation.

Change

  • One floor. src/continuity.rs stores one floor per repository root per machine at <state_dir>/<root>/continuity.json (default $XDG_STATE_HOME/git-e2ee), behind a single lock. Every operation that validates or publishes uses it: helper list, fetch, push and dry run, init, device-add, device-revoke, device-list, verify and recover. Each one validates the chain against the floor and advances it after success. advance never moves backwards, and rejects a different head at the same generation (a fork) and a lower policy generation.
  • Per-checkout progress. .git/git-remote-e2ee/<root>/progress.json records only import progress: imported packs, imported generation and verified refs. It is never a continuity authority. Rejected-heads evidence stays per checkout.
  • Unpinned paths removed.
    • Deleted: the hidden git-e2ee push and fetch commands, push_ref, push_update, delete_ref, the *_for_remote and validate_* variants, push_update_inner, change_ref_inner, the admin pin and ClientState.
    • What remains: push(repo, dest, RefChange) and check_push.
    • Read-only devices get a typed ReadOnlyDevice error.
  • Explicit state directory. EncryptedRepository::new(storage, key, state_dir). Only the two binaries resolve the XDG default, so every test uses a temporary state directory.
  • Stale views refresh before they count as a rollback. With one floor per machine, a process holding an older carrier snapshot used to fail with "rolled back" after another process on the same machine had published. pinned_chain now calls Storage::refresh() once when validation fails and validates again. Only a view that is still stale or forked is an error. The carrier re-fetches its branch; the directory backend reads live. A push validates before packing, so a later race still hits the compare-and-swap and reports fetch first. recover keeps unrefreshed validation.
  • Benchmarks. The native helper path only. benchmark-comparison.sh loses BENCH_E2EE_PUSH_MODE and its stale verify --storage call. docs/benchmarks.md keeps its historical numbers, with a note that direct mode is gone.
  • Docs. spec §9 and §11, guide, design and durability now describe the single floor.

Behaviour changes to review

  • list advances the floor after authentication and the continuity check, without waiting for a connectivity check. The administrator pin already worked this way. Import progress still waits for connectivity.
  • init refuses when this machine already has a floor for the key's root, before publishing.
  • current_manifest validates against the floor and advances it.
  • carrier_git_allows_exactly_one_concurrent_writer keeps its two writers on separate machines. With one shared state directory, the floor lock serializes the pushes, so both succeed by design.
  • Upgrading loses existing pins. The local state format changes with no migration. The production cutover re-initializes storage anyway.

Tests

  • tests/continuity_floor.rs::push_after_rollback_of_a_revocation_seen_only_by_device_revoke_is_rejected drives the real binaries. Before the change, the push over the replayed pre-revocation state succeeded. Now it fails with "rolled back" and storage HEAD is unchanged.
  • stale_carrier_views_on_one_machine_refresh_instead_of_reporting_rollback. Before, it failed with "remote manifest rolled back from generation 1 to 0". Now the stale fetch sees the new generation, a stale push to main gets fetch first, and a new ref publishes.
  • 59 push call sites were ported to RefChange. Two-machine scenarios get separate state directories.
  • cargo fmt --check, cargo clippy --all-targets -- -D warnings, cargo test --all-targets (169 passed). ~/.local/state/git-e2ee and ~/.config/git-e2ee did not exist before or after the suite. scripts/benchmark-local.sh on a one-commit repository exits 0.

Known flake (not from this PR)

native_git_fetch_reimports_packs_after_local_objects_are_pruned, added in #24, failed once on Linux CI for #25 with fatal: object … not found and passed on a rerun. It is not investigated yet.

… paths

The machine kept two disconnected pins: device-add/revoke/list advanced an
administrator pin, while the remote helper validated only the checkout's
pin. After a revocation seen only by device-revoke, storage could replay the
pre-revocation state and a git push from the same machine published a
generation the revoked device could still decrypt.

Continuity now lives in one floor per repository root per machine,
<state_dir>/<root>/continuity.json, guarded by one lock. Every operation that
validates or publishes storage state (helper list, fetch, push and dry run,
init, device-add, device-revoke, device-list, verify, recover) validates the
chain against it and advances it after success. Per-checkout progress.json
records only import progress (imported packs, imported generation, verified
refs) and is never a continuity authority.

The unpinned publish and fetch paths are removed: the hidden push and fetch
commands, push_ref, push_update, delete_ref, the *_for_remote and validate_*
variants and the admin pin. One push(repo, dest, RefChange) and check_push
remain. EncryptedRepository takes an explicit state directory; only the
binaries resolve the XDG default. Benchmarks keep only the native helper.
With one floor per machine, a process holding an older carrier snapshot
failed with "rolled back" after another process on the same machine had
published, although nothing was rolled back. pinned_chain now asks the
storage to refresh once when validation against the floor fails, and
validates the refreshed chain again; only a still-stale or forked view is an
error. GitStorage::refresh re-fetches the carrier branch and resets the
checkout; the directory backend reads live and needs no refresh. A push now
validates before packing and then hits the usual compare-and-swap, which
reports fetch first on a race. recover keeps unrefreshed validation.
@hmirin
hmirin merged commit 8338748 into main Oct 29, 2026
1 check passed
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