Repository navigation
Conversation
|
|
No actionable comments were generated in the recent review. Walkthrough
Merge Risk:
|
88737dc to
55ff30f
Compare
ReviewRebase first. The branch is rebased onto Checked by running code: Standards
Spec
Standards: 4 confirmed, 1 judgement call. Worst: the CHANGELOG |
Mutation testing (cargo-mutants,
|
| caught | missed | unviable | timeout |
|---|---|---|---|
| 14 | 0 | 41 | 0 |
Every mutant in the changed lines was caught by a test.
Review findings → fixes
c4784c9 is rustfmt only. Verification on c4784c9:
|
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
|
@coderabbitai review |
Action performed
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c4784c9329
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| let (mine, theirs) = divide(source); | ||
| (self.build)(mine, cipher, cx.clone()).zip((other.build)(theirs, cipher, cx)) |
There was a problem hiding this comment.
Guard unvisited fields while executing an owned split
When an earlier field's declaration panics during synchronous execution, theirs is dropped without being wiped because its target has not yet received it. Derived owned struct records use this combinator for every field, so a panic in the first field's borrowed encryption() (called inside its default owned guard), for example, leaves later sealed plaintext fields unwiped. The new collection implementation explicitly guards unvisited elements against this same scenario, but the record path does not. Keep the remaining parts guarded until their descriptions take ownership.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 9d3729a, without a new bound on passthrough types.
Encryption::split now takes a fourth argument, wipe: fn(&mut U). The waiting part sits in a guard while the first part's description runs. If that description panics, the guard hands the part to wipe during the unwind. The part leaves the guard only once the first description has returned.
The derive writes each wipe itself. It zeroizes the sealed fields still waiting and leaves the passthrough ones alone. A sealed field's plaintext is already Zeroize, because every EncryptTarget source is, so no bound is added anywhere. A passthrough field is stored in the clear and gets no bound. split is new in this PR, so the extra argument breaks no released caller.
I rejected the other two designs. (a) does not work: guarding the whole struct means the struct cannot be destructured until every field has run, but each owned field build consumes its field. (c) cannot close the gap either: the panic can come from running a declaration, not only from constructing one (for example a project selector), and that has to happen after the field has been moved out.
The witness is a_panicking_field_declaration_wipes_the_sealed_fields_after_it in tests/owned_records.rs. The record has three sealed fields with a passthrough field between them. The test runs twice: once with the first field's selector panicking, which exercises the outer split, and once with the third field's selector panicking, which exercises a nested split. It fails before the fix. It is enrolled in the owned-records lifecycle profile.
| source.risky_ref(), | ||
| T::ContextInput::omitted(), | ||
| ) | ||
| cipher.run(T::owned_encryption(), self, T::ContextInput::omitted()) |
There was a problem hiding this comment.
Guard plaintext while constructing the owned description
For the context-omitting conversion, self is now unguarded while T::owned_encryption() is constructed. If that construction panics—for example, while a derived struct target constructs one of its field descriptions—the consumed plaintext is dropped without calling Zeroize. Previously the Protected guard was established before constructing the description, and the supplied-context method still establishes that guard. Retain a guard here until description construction and omitted-context initialization have succeeded, then transfer ownership to the description.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Confirmed and fixed in b40cf93. The context-omitting encrypt_into now holds the plaintext in Protected until T::owned_encryption() and T::ContextInput::omitted() have both returned. Only then does it hand the plaintext to the declaration. encrypt_into_with_context now also builds the declaration under its guard. Before, the declaration was built as a call argument, which was safe only because of argument evaluation order.
The witness is a_declaration_that_panics_while_constructed_wipes_the_plaintext. It uses a target whose owned_encryption() panics while it is constructed, and checks both entry points. It failed before the fix, on the omitted-context path. It is enrolled in the owned-records lifecycle profile.
cipherstash-agent
left a comment
There was a problem hiding this comment.
Review attestation
Reviewed c4784c9 · posted from Claude Code · claude-sonnet-5-5
Sources: code-review (medium) · claude-sonnet-5-5
Skills: code-review, review-cross-check
No findings.
Six candidate findings were verified against c4784c9 and cross-checked against existing feedback: two are already raised in open threads (unguarded plaintext in encrypt_into at conversion.rs:80; unguarded remainder in Encryption::split), and the others were dismissed as documented design or left undecided, so none is posted.
Generated by Claude Code
Codex findings and Mutants gate: fixes
I confirmed the two killed mutants by applying each one by hand: the new test failed under each. Both panic witnesses failed before their fixes and are enrolled in the CodeRabbit finished its review with no actionable comments. Verification on bfa3d58:
|
…rget and DecryptSource Records, derives and plans still spoke the structural `EncryptFrom` / `DecryptInto` pair over a borrowed plaintext while the scalar API had moved to `EncryptTarget` / `DecryptSource` with explicit caller-context capabilities. A user could not write the mixed record the public examples show (`id` in the clear, `name` and `email` sealed) as an owned conversion that consumes and wipes the plaintext, and nothing said how the two pairs relate. `EncryptTarget` gains `owned_encryption()`, the description over the plaintext by value, defaulting to guard-and-wipe over the borrowed `encryption()`, and `indexes()`. `EncryptInto` and the owned column run it. A new `#[derive(EncryptTarget, DecryptSource)]` composes the field targets directly: a `struct = User` record projects each field out of a borrow in its borrowed form and divides the struct between its fields with the new `Encryption::split` in its owned form, so a `#[stash(passthrough)]` field is moved into the record (never cloned, never encrypted or authenticated) and each sealed field is guarded by its own target's owned description. Caller context is a capability composed through the tree: `ContextExtension<C>` for a record whose fields carry their own pairs, `ExpectedContext<T>` (now a `ContextInput`) for a stored context, and `RequiredContext` of the core contexts for `plaintext` records and the core leaves, which all implement the owned pair now (terms as `EncryptTarget` alone). A field whose target cannot take the record's pair is refused at that field. Both owned derives also emit the structural declarations as a bridge, so an owned record is a field of a structural record or a typed plan field; `FieldsBuilder::target` admits an owned-only target such as a `Ciphertext`. The dynamic lowering is untouched: the equivalence test seals one record through a hand-written target, the derive and the data plan against one recording provider and compares descriptors, term bytes and cross-opening. Breaking: the structural `Vec<T>: DecryptInto<Vec<P>>` and `DecryptField<Vec<P>, _>` impls open through the wiping collection path and so require `P: Zeroize`; before, the elements recovered before a late element failure were dropped unwiped (the CodeRabbit finding on the owned collections PR). A drop-witness test fails without the wipe. ADR-0011 records the signature and ownership map. The structural derives stay for the plans and the dynamic engine; their removal is #1488. Closes #1483 Refs #1488
…declaration panics The owned `Vec<S>` conversion hands each element to its own declaration by value. A declaration that panicked unwound through `vec::IntoIter`'s drop, which freed every element not yet handed over without wiping it. The whole vector's guard did wipe them before this branch moved the column to per-element owned declarations. The elements not yet handed over now stay in a guard that wipes them. An element is taken out only once its description and context exist. This also pins the structural `Vec<T>: DecryptField<Vec<P>, _>` impl. Swapping its `all_wiping` back to `all` failed no test, because the existing test reaches only the `DecryptInto` impl. A structural record with a column field now opens a recovered prefix and a late refusal through it.
…a plaintext field unaccounted for
The owned form of a `struct = ..` record replaces the default guard over
the whole plaintext. It destructures the struct with `..`, so any field
that the record neither sealed nor carried through was freed unwiped.
The destructure is now exhaustive. Such a field is a compile error at
the `struct = ..` path ("pattern does not mention field"), just as the
decrypt side already refuses a plaintext it cannot rebuild.
…eld is refused A `struct = ..` record deriving `DecryptSource` zipped its field openings with `Pending::zip`. When a later field failed authentication after its key arrived, `zip` dropped the plaintext of the fields already opened without wiping it. `Decryption::zip_wiping` (backed by `Pending::zip_wiping`) holds the left side in a guard, which wipes it when the right side fails or panics. The owned derive opens its fields through it. The structural `DecryptInto` derive is unchanged. Its field plaintexts carry no `Zeroize` bound today.
… the changelog sections ADR-0011 is taken by the opaque-blob ADR on a sibling branch, so the owned-record ADR becomes 0012. Nothing in the tree linked to it by number. The `[Unreleased]` `Deprecated` heading sat in the middle of `Added`, which filed the owned-collections bullet under deprecations. It now follows the full `Added` list. The owned-record bullet, the derive's "Owned records" docs and the ADR now cover what the fixes on this branch made true: a `struct` record names every plaintext field, its plaintext cannot implement `Drop`, and a field opened before a later refusal is wiped. A new bullet adds `Decryption::zip_wiping`. The owned-collections bullet now describes the per-element owned form.
…fecycle profile The owned-column slice enrolled its drop witnesses. The owned-record witnesses are the same evidence for records and now go in the same place. A new `owned-records` suite (`tests/owned_records.rs`) adds three witnesses: - under buffer cleanup, the record wipes its sealed fields before any await and on a refused context, and moves its passthrough fields; - under partial output, a field opened before a later field is refused is wiped. Two new collection witnesses join the existing `collections` suite. One checks the elements after a panicking declaration. The other checks a structural record's column field.
… its declaration The context-omitting EncryptInto::encrypt_into handed self to the declaration only after T::owned_encryption() had been constructed, with nothing guarding it meanwhile, so a declaration that panicked while constructed (a record building its fields' declarations) dropped the plaintext unwiped. It now holds the plaintext in Protected until the declaration and the omitted context exist, as encrypt_into_with_context already did for its context conversion; that variant now also constructs the declaration under the guard rather than as an argument.
… over Encryption::split ran the first part's description while the other part sat in a local, so a panic in that description (a field declaration's selector, say) dropped the later sealed fields of an owned struct record unwiped. split now takes a wipe for the waiting part and holds it in a guard that calls it during the unwind; the part leaves the guard only once the first description has returned. The derive writes each wipe to zeroize the sealed fields still waiting and leave the passthrough ones. A sealed field's plaintext is already Zeroize, as every EncryptTarget source is, so no bound is added, and passthrough field types take none. BREAKING CHANGE: Encryption::split takes a fourth argument, wipe: fn(&mut U).
…ion inspection Covers two mutants that survived the in-diff gate: MatchTerms' EncryptTarget::indexes replaced with vec![] (each_owned_term_target_names_its_index, which also pins the other owned terms) and ContextExtension::into_inner replaced with None (a_context_extension_is_inspected_as_supplied_or_omitted). The third survivor, ExpectedContext's omitted replaced with Default::default(), is equivalent: the body is Self::default(). It is excluded in .cargo/mutants.toml beside the matching From<()> entry, with the test that pins the omitted behaviour named.
bfa3d58 to
b3f71ad
Compare
|
Rebased onto the rebased #1501 ( |
Summary
Stack Encrypt (the Rust crate
stack-encrypt) encrypts a value under a per-value ZeroKMS data key. Until now a user could encrypt one scalar the owned way (keyset.encrypt(value)consumes the value and wipes it afterwards) but a record (a struct whose fields are encrypted individually, some kept in the clear) could only be declared the older structural way, over a borrowed value, with no way to say "carry this field through in the clear".With this PR a user writes:
The conversion wipes the plaintext fields it seals and moves (never copies) the fields the caller keeps in the clear. A
structrecord must name every field of its plaintext (an unaccounted field is a compile error, not a field dropped unwiped), and a field opened before a later field is refused is wiped; a caller's context extends every field's pair (encrypt_into_with_context(&keyset, row_id)) and the same extension opens it. Records nest, a#[stash(plaintext = String)]record is a searchable scalar (ciphertext plus equality term) usable as a field, a record can store its own context, and a field whose target cannot take the record's context is a compile error at that field.The structural derives (
EncryptFrom/DecryptInto) stay; the owned derives emit them as a bridge so an owned record is also a field of a structural record or a typed plan field. Their removal is a separate issue.Changes
Core (
packages/stack-encrypt)EncryptTargetgainsowned_encryption()(the description over the plaintext by value, defaulting to guard-with-Protected, run the borrowedencryption(), wipe) andindexes().EncryptIntoand the ownedVec<T>column now runowned_encryption; the column runs every element even after an earlier element fails, so none is dropped unwiped.Decryption::zip_wiping:zipthat wipes the left side's plaintext when the right side fails or panics; the owned record derive opens its fields through it. The ownedVeccolumn keeps the elements it has not yet handed over in a wiping guard, so a panicking element declaration does not drop the rest unwiped.Encryption::split(two descriptions over two different parts of one owned value, one context, one batch) andEncryption::project_owned: how a record divides its struct between its fields without cloning.splitalso takes awipe: fn(&mut U)for the part that waits while the first description runs. If that description panics, the waiting part is wiped during the unwind. The derive'swipezeroizes only the sealed fields, so passthrough field types get noZeroizebound.EncryptInto::encrypt_into(context omitted) now guards the plaintext withProtecteduntil the declaration and its context are built, asencrypt_into_with_contextalready did. A declaration that panics while it is being built wipes the plaintext.ContextExtension<C>(omit = declared contexts stand; supply = extend each) andExpectedContext<T>as aContextInput(omit = open under the stored context; supply = also check it). Conversions so a record field is handed its pair:RequiredContext<C>: From<CallerContext>,ContextExtension<C>: From<CallerContext>,RequiredContext<NonEmpty<T>>into the core contexts, and the reverse for the bridge.StackCipherText(both ways,RequiredContext<AeadContext>),Encrypted<Terms>(seal underRequiredContext<CallerContext>, open underRequiredContext<AeadContext>),EqualityTerm,MatchTerms,OreTerm,OpeTerm,OreBlockTermasEncryptTargetalone.CiphertextisDecryptableand aDecryptField, so it is a record field.FieldsBuilder::target::<T, _>(): a typed plan field laid out by an owned-only target (e.g.Ciphertext).Vec<T>: DecryptInto<Vec<P>>/DecryptField<Vec<P>, _>impls open throughDecryption::all_wipingand so requireP: Zeroize; previously the elements recovered before a late element failure were dropped unwiped. A new drop-witness test fails without the wipe.Derive (
packages/stack-encrypt-derive)#[derive(EncryptTarget)]and#[derive(DecryptSource)], taking the existing attributes plus#[stash(passthrough)](withstruct = ..only; refused on the structural derives, onplaintextrecords and besideidentity/default/decrypt, each with its own message). The owned form destructures the plaintext exhaustively, so the plaintext struct cannot implementDrop.structrecords get both a borrowed and an owned form;plaintextrecords,context_type = AeadContextrecords andcontext_fieldrecords are supported. Both derives also emit the structuralEncryptFrom/DecryptInto/DecryptFieldbridge andDecryptable.Tests
tests/owned_records.rs: the public mixed-record and searchable-record programs as executable tests (serde round trip, descriptors per field, row-binding extension, passthrough moved not cloned — aClonethat panics — sealed fields wiped before any await and on a refused context,Vec<User>intoVec<SearchableUser>as onegenerate_keyscall and the matched rows opened in oneretrieve_keyscall, nested/renamed/pinned fields and their composed descriptors, stored context with expectation, plaintext records over structural and opaque leaves, the bridge into a structural record and a plan).tests/owned_equivalence.rs: one record sealed by a hand-writtenEncryptTarget, the derive and the dynamic data plan against one recording provider: same descriptors, same equality-term bytes, every author opens every author's record.tests/ui: field-specific compile-fail cases (NoCallerContext/RequiredContext<u64>field in astructrecord, a term in anAeadContext-only record, omitted required context, non-Zeroizeplaintext, everypassthroughmisuse) and a must-compile file of the owned forms; eight existing snapshots re-recorded for rustc's longer implementor lists.tests/collections.rs: the structuralVecpath wipes its recovered prefix after a late element failure, as a column and as a column field of a structural record; a panicking element declaration wipes the elements after it.tests/owned_records.rsalso pins that a record field opened before a later refusal is wiped;tests/ui/owned_struct_unmapped_field.rspins the unaccounted-field refusal.tests/owned_records.rspanic witnesses:a_panicking_field_declaration_wipes_the_sealed_fields_after_it(a panic in the outer split and in a nested split) anda_declaration_that_panics_while_constructed_wipes_the_plaintext. Both are enrolled in theowned-recordslifecycle suite.each_owned_term_target_names_its_indexanda_context_extension_is_inspected_as_supplied_or_omitted.ExpectedContext'somitted -> Default::default()is an equivalent mutant (its body isSelf::default()), so it is excluded in.cargo/mutants.tomlwith the reason.lifecycle-profiles.json: a newowned-recordssuite enrolls the record drop witnesses; the new collection witnesses joincollections.Docs
targetmodule docs with the trait/ownership table, derive docs "Owned records" section and thepassthroughattribute row, ADR-0012 (signature/import/ownership map and the bridge decision; numbered 0012 because feat(stack-encrypt)!: add OpaqueBlob whole-value encryption #1503 takes 0011),conformance/stack-encrypt/coverage.mdrows, CHANGELOG[Unreleased](Breaking / Added / Deprecated).Verification
All run from the worktree on the rebased head, no credentials:
cargo fmt --all --check— clean.STACK_CONFORMANCE_REQUIRED=1 mise x --env test -- cargo nextest run -p stack-encrypt -p stack-encrypt-derive --all-features— 1130 passed, 1 skipped (theuitrybuild suite included: 67 compile-fail cases, 11 pass files).cargo clippy -p stack-encrypt -p stack-encrypt-derive --all-features --all-targets -- -D warnings— clean.cargo test -p stack-encrypt --doc --all-features— 57 passed;cargo test -p stack-encrypt-derive --doc— 12 passed (the new owned-record example included).mise run doc:stack-encrypt— builds with warnings as errors.cargo check -p eql-bindings --features stack-encryptfrompackages/eql— compiles unchanged.cargo checkfromlanguages/golang/encrypt/guest(CARGO_TARGET_DIRunset) — compiles unchanged.npx vitest run scripts/__tests__/stack-conformance-ci.test.mjs scripts/__tests__/stack-lifecycle-conformance.test.mjs scripts/__tests__/stack-eql-conformance.test.mjs— 17 passed.node scripts/stack-lifecycle-conformance.mjs rust— passes,owned-recordssuite included.Related
EncryptFrom/DecryptIntosurface follows once every consumer has an owned equivalent; nothing is removed here)Vec<S>collections as one batch #1501 (legacyVecdecrypt path left recovered elements unwiped)Vec<S>collections as one batch #1501 (basefeat/stack-encrypt-owned-collections)Review notes
Start with
packages/stack-encrypt/docs/adr/0012-owned-record-declarations.md, thensrc/target/conversion.rs(owned_encryption),src/target/input.rs(the inputs and conversions) andpackages/stack-encrypt-derive/src/owned_encrypt.rs(splitchain).tests/owned_records.rsis the executable version of the public examples.Deliberately deferred, with reasons:
target(options = factory)attribute): the only configurable target's plaintext isserde_json::Value, which is notZeroize, so it cannot be an owned source yet. Nested targets with and without caller context are supported; a typed-options attribute waits on a zeroizing document plaintext.Option<T>and tuple targets stay structural-only in this slice (nullable scalars are deferred by the scalar slice too).eql-bindingscompiles unchanged and keeps its structural declarations. AddingEncryptTargetimpls there would build against the crates.iostack-encrypt, which lacks this surface until the next release (see AGENTS.md on thestack-encryptfeature's registry gate).#[deprecated]attribute onEncryptFrom/DecryptInto: the crate's own plan and dynamic modules implement them, so the attribute would only be noise until stack-encrypt refactor: remove transitional traits and redundant execution APIs #1488; the CHANGELOGDeprecatedsection carries the notice.#[stash(passthrough)]field type must beClone: the record's borrowed form (what a plan or a structural record runs) copies it; the owned form never does, and a test with a panickingCloneholds it to that.Summary by CodeRabbit