Skip to content

core: give the encrypted session store one owner#73

Merged
fosskar merged 1 commit into
mainfrom
session-store
Jul 25, 2026
Merged

core: give the encrypted session store one owner#73
fosskar merged 1 commit into
mainfrom
session-store

Conversation

@fosskar

@fosskar fosskar commented Jul 25, 2026

Copy link
Copy Markdown
Owner

Found by an architecture review of Engine, validated against the source before any code changed.

The problem

Engine held an AgeIdentity and a Node side by side and paired them by hand every time session bytes crossed the boundary. Publishing is identity.encrypt(…) then node.publish(…); reading is node.blob(…) then identity.decrypt(…). That recipe was spelled out at five sites: get_plain, get_plain_with, merge_with, the publish step of Action::Import, and Action::WriteFile.

Two of them were the same code twice:

async fn get_plain(&self, hash: Hash) -> Option<Vec<u8>> {
    let ciphertext = self.node.get_blob(hash).await.ok()?;
    self.identity.decrypt(&ciphertext).await.ok()
}

async fn get_plain_with(identity: &AgeIdentity, node: &Node, hash: Hash) -> Option<Vec<u8>> {
    let ciphertext = node.get_blob(hash).await.ok()?;
    identity.decrypt(&ciphertext).await.ok()
}

Byte-identical modulo the receiver; verdict_of/verdict_with likewise, modulo variable names. Neither pair is design — tick_once already holds &mut Engine and cannot call a &self method, so every read helper had to exist in both shapes, and execute took an 8-field Execution struct to thread the loose pieces back in.

The change

SessionStore (new crates/ssync-core/src/session_store.rs) is that pairing, named once:

publish(key, plaintext) -> Hash      // encrypt + publish, temp-tag ordering inside
local_plaintext(hash)   -> Option    // local only
fetch_plaintext(hash)   -> Result    // fetch from peers on a local miss
tombstone(key)          -> Result

It holds two shared references, so the same store is reachable from &self methods and from inside a pass holding &mut Engine. That is what dissolves the duplication: both helper pairs collapse to one, and Execution carries one store field instead of separate identity and node.

recipients_fingerprint moves next to the identity it fingerprints — Engine no longer knows that age recipients are joined with newlines and hashed.

Naming the blob-read distinction

Node offers get_blob (local only) and blob (fetch on miss, bounded, for iroh-docs#88). The names do not distinguish them, and picking wrong is silent: divergence relies on a local miss reading as an incomplete version set, which is what makes the merge all-or-skip so a partially downloaded fork is never unioned away (DECISIONS §8). Swapping the two compiles and passes every test.

local_plaintext and fetch_plaintext put that dependency in the call, and local_plaintext carries the reason it must not be swapped. Action::WriteFile's two-step fetch-then-decrypt, with two separate error arms, becomes one — its log line is now materialise rather than fetch/decrypt, which is what actually failed from the operator's point of view.

What this does not do

The review suggested this would make Action execution testable against an in-memory store. It does not, and I did not force it. AGENTS.md is explicit that test seams in this repo are named production methods (set_resync_interval, spawn_with_gc, disable_auto_download), not mocks — and a trait here would have exactly one production implementation, which is a hypothetical seam, not a real one. SessionStore is a concrete type; execute still needs a real node.

So the win is deduplication and one owner for ciphertext, not a new test surface. Flagging it because the review claimed more.

Verification

No behaviour change; existing tests cover the paths.

  • cargo clippy --workspace --all-targets -- -D warnings — clean
  • cargo test --workspace -- --test-threads=1 — 255 passed, 0 failed
  • nix fmt -- --ci — 0 changed
  • production code in lib.rs no longer pairs identity.encrypt/decrypt with node.publish/blob/get_blob; remaining occurrences are test-only (asserting a blob is ciphertext, or add_blob, which is documented for tests)

No version bump: pure refactor.


Stacking note: touches crates/ssync-core/src/lib.rs, as does #72. Both branch from main; whichever lands second will need a trivial rebase.

Engine held the age identity and the iroh Node side by side and paired
them by hand every time session bytes crossed the boundary: encrypt then
publish, fetch then decrypt, spelled out at five sites. Two of them
existed twice over — get_plain/get_plain_with and verdict_of/verdict_with
were the same code with a different receiver, because tick_once already
holds &mut Engine and cannot call a &self method.

SessionStore is that pairing, named once. It is two shared references, so
the same store is reachable from &self methods and from inside a pass
holding &mut Engine — the split that forced the duplication. Both helper
pairs collapse to one, and Execution carries one store instead of a
separate identity and node.

The blob-read distinction is now in the method name rather than a comment.
Node offers get_blob (local only) and blob (fetch on miss); picking wrong
is silent and expensive: divergence relies on a local miss reading as an
incomplete version set, so an all-or-skip merge never unions a partially
downloaded fork (DECISIONS §8). local_plaintext and fetch_plaintext say
which one they depend on, and local_plaintext documents why it must not
be swapped.

recipients_fingerprint moves next to the identity it fingerprints;
Engine no longer knows how age recipients are spelled.

No behaviour change. The store is a concrete type, not a trait: this repo
uses named production methods as test seams, not mocks (AGENTS.md), so
Action execution still needs a real node — the deduplication is the win
here, not the test surface.
@fosskar
fosskar merged commit 21e1000 into main Jul 25, 2026
2 checks passed
@fosskar
fosskar deleted the session-store branch July 25, 2026 09:06
@fosskar fosskar mentioned this pull request Jul 25, 2026
fosskar added a commit that referenced this pull request Jul 25, 2026
Three internal changes landed since v0.20.0 — no features, nothing
breaking — so PATCH per the pre-1.0 rule:

  #71 cli: single-source the systemd hardening set
  #72 core: give the pass verdict one definition
  #73 core: give the encrypted session store one owner

#71 is the only one that alters a shipped artifact: the unit written by
`ssync service install` now lists the sandbox properties alphabetically.
The property set is unchanged and systemd is order-insensitive here, so
behaviour is identical.

Bumping [workspace.package] version is the whole release: nix/effects.nix
tags v0.20.1 and generates notes once nixbot goes green on main.
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