Skip to content

Add Hasher.Clone and binary state marshaling - #13

Open
AskAlexSharov wants to merge 12 commits into
masterfrom
feat/clone-marshal
Open

Add Hasher.Clone and binary state marshaling#13
AskAlexSharov wants to merge 12 commits into
masterfrom
feat/clone-marshal

Conversation

@AskAlexSharov

@AskAlexSharov AskAlexSharov commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

What

  • Clone() (*Hasher, error) — deep copy of the hasher at its current position. Enables absorbing a shared prefix once and forking per suffix (trie/state-key pattern). It also papers over a portability footgun: plain struct copies of Hasher work on asm builds (value semantics) but alias shared state on fallback builds where the x/crypto state is a pointer.
  • MarshalBinary / AppendBinary / UnmarshalBinary — persist and resume hash state, mirroring what std-lib hashes provide. Hasher now satisfies encoding.BinaryMarshaler, encoding.BinaryAppender, encoding.BinaryUnmarshaler (compile-time asserted).

Encoding (canonical, single format)

magic "fk1�" (4) || state (200) || flags (1) || pos (1) — 206 bytes, with partially absorbed input always XORed into the state (x/crypto's own internal convention).

The format is deliberately independent of the active implementation: which backend runs is a runtime CPU-feature decision (useASM from BMI2 / FEAT_SHA3 detection), so the same binary can be on either path on different hosts. The high-effort review caught that an earlier two-format design CPU-feature-locked persisted states (reproduced under Rosetta: same amd64 binary, different encoding). Now:

  • Native sponge states encode directly; the fallback translates losslessly to/from x/crypto/sha3's stable marshal layout (sha� || rate || state || n || direction).
  • The same logical state marshals to identical bytes on every platform and implementation — pinned by a golden-fingerprint test that CI runs on both native-asm and purego builds. (One documented exception: a squeeze stopped at an exact block boundary may encode pre- or post-permutation — pos == rate vs 0 — because x/crypto permutes lazily; both decode identically everywhere.)
  • Marshal is 1 alloc (pre-sized); marshaling never mutates the hasher (zero-value states encode without allocating a backing state).
  • Strict validation: magic, exact length, flag bits, and position bounds (pos == rate only valid while squeezing).

Bonus fix

padAndSqueeze left absorbed at its pre-squeeze value. Harmless for hashing (Write/Sum panic once squeezing), but it violated the marshaling invariant — now reset to 0.

Testing

Clone independence, prefix-fork over block boundaries, clone mid-squeeze, zero-value clone, marshal round-trips at 8 split points, mid-squeeze round-trips including the exact-block-boundary lazy-permute state, append semantics, malformed-input rejection (magic/length/flags/pos tampering), and the golden cross-implementation fingerprint test. All pass on native arm64, purego, and under -race (both builds).

🤖 Generated with Claude Code

Fallback-arm coverage (added after review)

useASM is a runtime CPU decision, so the x/crypto fallback arms in keccak_asm.go (taken on amd64 without BMI2 / arm64 without SHA3 — old Xeons, Graviton 2) were compiled into every binary but executed by no CI configuration (purego compiles keccak_default.go, different code). keccak_fallback_test.go now forces useASM = false (restored via t.Cleanup) and re-runs the whole surface through those arms, plus TestCrossImplementationMarshal: an in-process, both-directions proof that the canonical format is portable between implementations and that both emit byte-identical encodings for the same logical state. Native-build coverage: 54% → 91.5% of statements; the remainder is defensive error branches unreachable with the pinned dependency.

Clone enables the 'absorb a shared prefix once, fork per suffix'
pattern common in trie workloads, and gives portable copy semantics:
plain struct copies of Hasher work on asm builds (value semantics)
but silently alias shared state on fallback builds, where the state
is held behind a pointer.

MarshalBinary/AppendBinary/UnmarshalBinary allow persisting and
resuming hash state. The native sponge uses a compact versioned
encoding (magic 'fk1n') that marshals only the live portion of the
input buffer; the x/crypto fallback delegates to sha3's own marshaler
under a distinct magic ('fk1x'), so restoring state on a build with a
different implementation fails with a descriptive error instead of
corrupting.

Also make padAndSqueeze reset absorbed: it previously stayed at its
pre-squeeze value, which was harmless for hashing (Write/Sum panic
once squeezing) but leaked stale buffered input into the marshaled
encoding.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Adds snapshotting capabilities to the keccak.Hasher so callers can (1) fork hashers via a deep Clone() and (2) persist/restore in-progress sponge state via Go’s standard binary marshaling interfaces, with separate encodings (and explicit errors) for the native sponge vs. the x/crypto fallback to avoid silent corruption.

Changes:

  • Add (*Hasher).Clone() (*Hasher, error) to safely fork hashers without shared-state aliasing on fallback builds.
  • Implement encoding.BinaryMarshaler, encoding.BinaryAppender, and encoding.BinaryUnmarshaler for Hasher, including strict magic-based implementation discrimination.
  • Fix native sponge padAndSqueeze to reset absorbed (prevents stale buffered bytes from being included in marshaled state).

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated no comments.

File Description
keccak.go Adds binary marshaling helpers, format magics, and compile-time interface assertions.
keccak_default.go Implements Clone and binary marshal/unmarshal for the pure x/crypto-backed hasher.
keccak_asm.go Implements Clone and native/x-crypto marshaling logic; fixes padAndSqueeze absorbed reset.
keccak_marshal_test.go Adds tests for clone independence and marshal/unmarshal round-trips and error cases.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Review (3 independent angles, one reproduced under Rosetta) showed the
dual fk1n/fk1x format was selected by runtime CPU detection, not build:
the same amd64 binary emits fk1n on a BMI2 host and fk1x on a non-BMI2
host and refuses to restore the other's blobs — persisted states were
CPU-feature-locked, and the docs misstated this as a build property.

Replace both formats with one canonical 206-byte encoding
(magic || state[200] || flags || pos) with partial input always XORed
into the state, which is exactly x/crypto/sha3's internal convention,
so the fallback translates losslessly in both directions via its
stable marshal layout. Restoring works everywhere regardless of which
implementation wrote the state. pos == rate is accepted while
squeezing (x/crypto permutes lazily at block boundaries); the native
decoder normalizes it by permuting eagerly.

Also from review:
- AppendBinary no longer lazily initializes h.xc (a nominally
  read-only snapshot mutated the receiver); the zero state is encoded
  directly with no allocation.
- MarshalBinary pre-sizes its buffer (1 alloc); appendState uses
  slices.Grow.
- Absorbing states can no longer smuggle a nonzero read index (the
  single pos field expresses one offset).
- cloneXC round-trips x/crypto's own encoding directly instead of
  wrapping and stripping a magic prefix.
- Tests: golden fingerprint test pins the wire format and proves
  cross-implementation byte equality (runs on native and purego in
  CI); error cases cover flag/pos/length tampering; test helper
  replaced with slices.Concat.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 4 out of 10 changed files in this pull request and generated 6 comments.

Files not reviewed (5)
  • .idea/.gitignore: Generated file
  • .idea/fastkeccak.iml: Generated file
  • .idea/go.imports.xml: Generated file
  • .idea/modules.xml: Generated file
  • .idea/vcs.xml: Generated file

Comment thread keccak_marshal_test.go
Comment thread .idea/vcs.xml Outdated
Comment thread .idea/modules.xml Outdated
Comment thread .idea/go.imports.xml Outdated
Comment thread .idea/fastkeccak.iml Outdated
Comment thread .idea/.gitignore Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

Comment thread keccak_default.go
Comment thread keccak_asm.go
new([200]byte) was already non-escaping here (appendState doesn't
retain its argument), but the explicit form makes the no-allocation
claim self-evident.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.

Comment thread keccak.go
Comment thread keccak.go
Comment thread keccak.go Outdated
Comment thread keccak.go Outdated
The hashing methods can only panic if x/crypto's hasher stopped
satisfying KeccakState (their interface signatures allow no error),
but xcFromState/cloneXC have error returns — degrade to errXCFormat
there instead of panicking on a hypothetical dependency change.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.

useASM is a runtime CPU decision, so the x/crypto fallback arms in
keccak_asm.go (taken on amd64 without BMI2 or arm64 without SHA3 —
old Xeons, Graviton 2) are compiled into every binary but were never
executed by any CI configuration: purego builds compile
keccak_default.go, which is different code, and native builds take the
asm arms. A regression there would have kept every test green.

forceFallback flips useASM (restored via t.Cleanup) after capturing
expected digests and squeeze streams from the active implementation,
then re-runs the full surface through the fallback: streaming writes,
non-destructive Sum/Sum256, extended Read, Reset, Clone, marshal
round-trips mid-absorb and mid-squeeze (including the pos == rate
lazy block-boundary state only the fallback produces), and zero-value
marshaling.

TestCrossImplementationMarshal additionally proves the canonical
format's portability in-process, both directions (native encode ->
fallback decode and vice versa), and that both implementations emit
byte-identical encodings for the same logical state — the scenario
that motivated the single-format design.

Native branch coverage: 54% -> 90% of statements; what remains is
defensive error branches unreachable with the pinned dependency.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.

staticcheck QF1008 flagged the redundant embedded-field selectors
(h.sponge.state and friends). Assembling a local sponge and storing it
once also removes the window where the receiver holds a partially
restored state.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.

Comment thread keccak.go
xcAppendState dropped the caller's prefix by returning nil on its
error paths, violating the encoding.BinaryAppender contract (on error,
return b unchanged). It is the error path of both Hasher.AppendBinary
implementations. Return b instead, and add a test that constructs a
state without BinaryMarshaler to exercise that path directly.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.

Comment thread keccak.go Outdated
Write the marshal blob into a fixed [xcSize]byte with named offsets
instead of a make+append chain, so the encode side mirrors the decode
side in xcAppendState (magic || rate || state || n || direction) and
the layout is self-documenting.

Not an allocation change: measured 1 alloc/op either way (the sole
allocation is x/crypto's state object; the blob never escaped).

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.

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.

2 participants