Skip to content

Keep init's key once publication was attempted, and share one durable write - #27

Merged
hmirin merged 1 commit into
mainfrom
fix/init-key-and-durable-writes
Oct 29, 2026
Merged

hmirin merged 1 commit into
mainfrom
fix/init-key-and-durable-writes

Conversation

@hmirin

@hmirin hmirin commented Oct 29, 2026

Copy link
Copy Markdown
Contributor

Step 3 of the refactoring plan (Opus 1.4; Astra Category 1 medium and Category 2 low).

A. init keeps its key once publication was attempted

Bug. git-e2ee init deleted the key it generated whenever a later step failed. That included failures after the genesis compare-and-swap had already published. When the floor step failed after publication, the storage was left initialized with its only owner key deleted. Reproduced on main: the new test fails there with the key deleted, after an "Is a directory" error from the floor step.

Fix. EncryptedRepository::initialize returns InitializeError. The variant is decided by where the error came from, not by its text:

Variant Meaning Key
BeforePublication prepare_genesis failed: floor lock, already-initialized checks, policy genesis, sealing and storing the manifest deleted
PublicationUncertain the genesis compare-and-swap returned an error kept
AfterPublication { id } the compare-and-swap succeeded and the floor step failed kept

When the key is kept, init fails with "kept key ; it may be the only owner key for ". The cause says either that storage is initialized, or that it may already be.

A carrier branch that already exists (refuse_foreign_branch) is now a typed, backend-neutral StorageOccupied error. It is refused before any push, so it counts as BeforePublication. A CasConflict on genesis still keeps the key; that choice is conservative.

Tests in tests/cli_targets.rs:

  • failed_init_removes_the_key_it_created still passes.
  • init_keeps_its_key_when_the_floor_fails_after_publication (unix). A carrier post-receive hook breaks the floor after the push lands. The key remains, stderr names it, and verify --key reports generation 0. This test fails on main.
  • init_keeps_its_key_when_genesis_publication_reports_an_error
  • init_refused_by_an_occupied_carrier_branch_removes_its_key

B. One durable-replacement primitive

persist::replace_file_durably(directory, name, contents) -> Result<(), ReplaceError> owns the whole sequence:

  • an exclusive .{name}.{hex}.tmp in the destination directory;
  • write and file sync, with both durability checkpoints;
  • rename, then directory sync;
  • removal of the temporary on every failure before the rename.

ReplaceError::NotPublished means the destination is untouched. ReplaceError::Unacknowledged means the rename ran but the directory sync failed. A name with a path separator is rejected.

Ported: continuity::write_json (this covers the floor, checkout progress and rejected-heads evidence), filesystem HEAD, FORMAT, README.md and the carrier cache current pointer. The pointer also gains the directory sync it lacked. write_filesystem_head_locked and write_file_durable are deleted.

Left unchanged, because they have a different shape:

  • the filesystem object stage, which publishes with a no-replace hard link after id verification;
  • carrier object staging and historical restore, which rename whole chunk directories;
  • KeyFile::write_new, which creates the final file in place.

Tests:

  • replace_publishes_new_bytes_and_leaves_no_temporary, which also checks the stage order
  • failed_rename_removes_the_temporary_and_keeps_the_destination. A mutation check confirmed it: removing the cleanup makes this test fail.
  • replace_into_a_missing_directory_creates_nothing
  • replace_rejects_a_name_with_a_separator

A write failure is not tested; I found no portable way to inject one.

Verification

cargo fmt --check, cargo clippy --all-targets -- -D warnings, cargo test --all-targets (175 passed). ~/.local/state/git-e2ee and ~/.config/git-e2ee did not exist before or after the suite.

… write

git-e2ee init deleted the key it generated whenever a later step failed,
including after the genesis compare-and-swap had published. A failure in the
floor step then left initialized storage whose only owner key was gone.
initialize now returns InitializeError::{BeforePublication,
PublicationUncertain, AfterPublication}, decided by where the error arose,
and init deletes the generated key only before publication. Otherwise it
keeps the key and says where it is and whether storage is or may be
initialized. A carrier branch that is already occupied is reported as a
typed StorageOccupied and counts as before publication.

The "write temporary, flush, rename, sync directory" sequence was written by
hand in several places with different cleanup. persist::replace_file_durably
now owns it: an exclusive temporary in the destination directory, file sync,
the durability checkpoints, rename and directory sync, and removal of the
temporary on every failure before the rename. The continuity floor, checkout
progress, rejected-heads evidence, filesystem HEAD, FORMAT, README and the
carrier cache pointer use it.
@hmirin
hmirin merged commit 379fd1a 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