Skip to content

feat(orderable-bytes)!: variable-length encodings for strings and byte strings - #97

Open
coderdan wants to merge 7 commits into
test/orderable-bytes-golden-vectorsfrom
feat/orderable-bytes-variable-length
Open

coderdan wants to merge 7 commits into
test/orderable-bytes-golden-vectorsfrom
feat/orderable-bytes-variable-length

Conversation

@coderdan

@coderdan coderdan commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Summary

orderable-bytes turns values into bytes whose order matches the values' order, and order-revealing (ORE) and order-preserving (OPE) encryption run on top of those bytes. Until now it covered fixed-size types only. Strings had no shared encoding, so each scheme encoded text its own way, with different and lossy rules. This PR adds an encoding for strings and byte strings, so vitaminc-ore can use one order encoding for every kind of value.

Breaking (0.2.0): the traits are reorganised so that fixed-length and variable-length encodings can't be confused. Nothing about the encoded bytes changes: every golden vector from #96 passes unchanged.

Stacked on #96. Merge that first.

Traits

pub trait OrderableBytes: Sealed {          // was ToOrderableBytes
    type Bytes<'a>: AsRef<[u8]> where Self: 'a;
    fn to_orderable_bytes(&self) -> Self::Bytes<'_>;
}
pub trait FixedOrderableBytes: OrderableBytes {
    const ENCODED_LEN: usize;
    type Array: AsRef<[u8]> + Copy;          // always [u8; ENCODED_LEN]
    fn to_fixed_orderable_bytes(&self) -> Self::Array;
}
pub trait VariableOrderableBytes: OrderableBytes {}
  • Every type implements exactly one subtrait. Numbers, bool, char, [u8; N], Decimal and the chrono types implement FixedOrderableBytes. str, String, [u8] and Vec<u8> implement VariableOrderableBytes. &T implements the same traits as T.
  • Owned fixed-length bytes: to_orderable_bytes may borrow from the value, so its output can't outlive it. to_fixed_orderable_bytes returns the same bytes as an owned array, so generic code can return or store them (fn term<T: FixedOrderableBytes>(v: T) -> T::Array). Binding Bytes<'a> to an owned type for every 'a doesn't compile: the Self: 'a bound gives E0311.
  • Sealed: only this crate implements the traits. The compiler therefore enforces that each type implements exactly one subtrait, and that the fixed bytes are exactly ENCODED_LEN long, because one macro writes each type's impls from a single length. A compile_fail,E0277 doctest checks that downstream impls are rejected.
  • Why the rename: in 0.1, ToOrderableBytes meant fixed-length. If that name had become the general trait, 0.1 code bounded on it would still compile and silently accept strings. Code that zero-pads to a block would then make "a" equal "a\0", and left-padding would put "b" before "aa". With the old name removed, those bounds fail to compile, and code that pads bounds on FixedOrderableBytes, which rejects strings. A compile_fail,E0277 doctest pins this.
  • Bytes is a generic associated type, so variable-length impls can return a borrowed slice.

Changes

  • The new variable module:
    • Encoding: the value's own bytes, borrowed, so no copy of the plaintext is made.
    • Order: byte order is already the order these types define. A prefix sorts first, and str sorts by Unicode code point.
    • No normalisation: é written as U+00E9 and as e + U+0301 encode differently. Callers normalise first.
    • Never pad, never concatenate: the bytes don't mark where they end. The module docs and the README spell this out.
  • Fixed-length types:
    • One macro, impl_fixed_orderable_bytes!, writes every fixed-length type's impls. The 14 primitives are no longer copy-pasted impl pairs.
    • [u8; N] is new and encodes to itself, so hashes and UUIDs need no copy into a Vec<u8>.
    • chrono::datetime_utc::ENCODED_LEN is removed. It was the only per-module length constant; use the associated constant.
  • ore-rs:
    • It reads block lengths through FixedOrderableBytes.
    • The decimal bench now derives its block length instead of hard-coding 14.
    • Its public API and ciphertexts are unchanged, and its edits are in separate refactor(ore-rs) commits.
  • README:
    • New sections on variable-length encodings and on upgrading from 0.1. The upgrade guide covers T::Bytes becoming T::Array, and sealing.
    • An updated table of supported types.
    • The usage example previously called decimal::to_orderable_bytes and decimal::ENCODED_LEN, neither of which exists. It now goes through the traits and uses f64 and strings.
  • README doctests: the README's Rust examples run as doctests (a cfg(doctest) item includes the README). They need no optional feature, so a plain cargo test runs them.
  • Tests:
    • quickcheck properties: order and equality match String's and Vec<u8>'s own Ord/Eq;
    • a check that the encoding borrows rather than copies;
    • prefix ordering;
    • "no normalisation";
    • golden rows for str and bytes;
    • a check that each variable-length type implements VariableOrderableBytes;
    • compile_fail,E0277 doctests: String can't be passed to a pad-to-block function, and a downstream type can't implement OrderableBytes;
    • &T encodes as T under all three traits;
    • [u8; N]: golden rows and a quickcheck property that its order matches array order;
    • the golden check asserts that to_orderable_bytes and to_fixed_orderable_bytes return the same bytes.

Verification

  • cargo test --all-features passes on stable and on 1.78.0 (152 tests), and cargo test -p orderable-bytes passes with default features, including the README and compile_fail doctests. One README block is rust,ignore; it is the 0.1 code in the upgrade guide.
  • cargo clippy --no-deps --all-targets --all-features -- -D warnings passes on stable and 1.78. cargo fmt -- --check and cargo doc with -D warnings are clean.
  • cargo bench -p ore-rs --bench decimal --no-run builds.

Related

Review notes

  • Release: release-plz should compute orderable-bytes 0.2.0 from the ! commits. The pending release PR chore: release #88 (0.1.2) predates this.
  • ore-rs version: the ore-rs edits are in non-breaking refactor(ore-rs) commits, so ore-rs should get a patch release, not 0.9.0. Its orderable-bytes = "^0.1" requirement can't be raised to 0.2 in this PR, because the path crate is still 0.1.1 until release-plz bumps it. release-plz updates it at release time. Only a manual cargo publish before then would fail.
  • History: the two feat(orderable-bytes)! commits don't build ore-rs on their own; the refactor(ore-rs) commit after each one fixes it. This is deliberate, so that release-plz doesn't attribute a breaking change to ore-rs.
  • Design: the encoding is the identity on bytes, not an escaped and terminated format. That was decided in review.

@coderdan
coderdan force-pushed the feat/orderable-bytes-variable-length branch from 0c222e6 to b3b7800 Compare October 8, 2026 09:15
@coderdan
coderdan requested a balanced review from Copilot October 8, 2026 10:17
@coderdan
coderdan marked this pull request as ready for review October 8, 2026 10:17
@coderdan
coderdan added this pull request to stack #98 October 8, 2026 10:17

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.

🟡 Changes recommended

The public trait documentation incorrectly claims that per-type modules expose free encoded-length constants.

1 open finding
What changed in this PR

Adds borrowed variable-length orderable encodings while separating fixed-length metadata into a dedicated trait.

Changes:

  • Adds identity encodings for strings and byte strings.
  • Introduces FixedOrderableBytes and migrates fixed-width implementations.
  • Expands documentation, doctests, and golden/property tests.
File Description
packages/​ore-rs/​src/​encrypt.rs Uses the new fixed-length trait.
packages/​ore-rs/​src/​decimal.rs Migrates decimal length lookup.
packages/​ore-rs/​src/​chrono.rs Migrates chrono length lookups.
packages/​orderable-bytes/​tests/​golden.rs Adds variable-length golden vectors.
packages/​orderable-bytes/​src/​variable.rs Implements borrowed string and byte encodings.
packages/​orderable-bytes/​src/​primitive.rs Splits primitive trait implementations.
packages/​orderable-bytes/​src/​lib.rs Defines the revised trait API and doctests.
packages/​orderable-bytes/​src/​decimal.rs Implements the fixed-length trait for decimals.
packages/​orderable-bytes/​src/​chrono.rs Implements the fixed-length trait for chrono types.
packages/​orderable-bytes/​README.md Documents usage and variable-length constraints.
packages/​orderable-bytes/​Cargo.toml Updates the package description.

🧠 Review effort: Balanced


Give feedback about Copilot approvals in this survey to enter a drawing for a $150 gift card.

Comment thread packages/orderable-bytes/src/lib.rs Outdated
…e strings

ToOrderableBytes required a fixed ENCODED_LEN, so text and raw bytes
had no encoding here, and each ORE/OPE scheme encoded strings its own
lossy way. vitaminc-ore needs one order encoding for every kind.

ToOrderableBytes is now the general trait, with a GAT Bytes<'a> so an
impl can return a borrowed slice. ENCODED_LEN moves to a new subtrait,
FixedOrderableBytes, implemented by every existing type. The new
variable module implements ToOrderableBytes for str, String, [u8] and
Vec<u8> as the value's own bytes, borrowed: byte order is already their
order (a prefix sorts first; code-point order for str), and no copy of
the plaintext is made. No normalisation is applied, and the encodings do
not mark where they end, so a consumer must never zero-pad or
concatenate them; the module docs and README say so.

Every fixed-length encoding is unchanged: the golden vectors pass as
written.

The README's usage example called decimal::to_orderable_bytes and
decimal::ENCODED_LEN, neither of which exists; it now uses the trait.

Refs #94

BREAKING CHANGE: ToOrderableBytes::ENCODED_LEN moved to
FixedOrderableBytes::ENCODED_LEN, and ToOrderableBytes::Bytes is now the
generic associated type Bytes<'a>. Import FixedOrderableBytes wherever
ENCODED_LEN is named.
orderable-bytes moved ENCODED_LEN from ToOrderableBytes to the new
FixedOrderableBytes subtrait. ore-rs names each block length through it.
Its public API and ciphertexts are unchanged.
The README's usage example called decimal::to_orderable_bytes and
decimal::ENCODED_LEN, which never existed, and nothing noticed because
the README was not compiled. A cfg(doctest) item now includes the README
as doc text, so cargo test compiles and runs its Rust blocks. The worked
byte tables were unlabelled fences, which rustdoc treats as Rust; they
are now marked as text.

Checked by restoring the old call: the doctest fails with E0425.
Only chrono::datetime_utc exports a free ENCODED_LEN; primitive, decimal
and chrono::naive_date don't. Point callers at the associated constant
on FixedOrderableBytes alone.

Claude-Session: https://claude.ai/code/session_012zHhtTEo968WazExKxA5oT
Making ToOrderableBytes the general trait meant 0.1 code bounded on it,
which assumed a fixed length, kept compiling and silently accepted
strings. Code that zero-pads to a block then makes "a" equal "a\0", and
code that left-pads puts "b" before "aa".

The general trait is now OrderableBytes. Every type also implements
exactly one subtrait: FixedOrderableBytes (with ENCODED_LEN) or the new
VariableOrderableBytes (str, String, [u8], Vec<u8>). The old name is
gone, so 0.1 bounds fail to compile, and code that pads bounds on
FixedOrderableBytes and rejects strings at compile time. A compile_fail
doctest pins that.

Also in this release:
- FixedOrderableBytes documents that Bytes must be exactly ENCODED_LEN
  long as a requirement, not a convention.
- chrono::datetime_utc::ENCODED_LEN is removed. It was the only
  per-module length constant; use the associated constant.
- The README example uses f64 and strings, so its doctest runs without
  the decimal feature. The README gains a section on upgrading from 0.1.

Encoded bytes are unchanged: the golden vectors pass as written.

Refs #94

BREAKING CHANGE: ToOrderableBytes is renamed to OrderableBytes. Replace
a T: ToOrderableBytes bound with T: FixedOrderableBytes, and import
OrderableBytes wherever to_orderable_bytes is called.
chrono::datetime_utc::ENCODED_LEN is removed; use
<DateTime<Utc> as FixedOrderableBytes>::ENCODED_LEN.

Claude-Session: https://claude.ai/code/session_012zHhtTEo968WazExKxA5oT
…length

Follows the orderable-bytes rename of ToOrderableBytes to OrderableBytes.
The decimal bench hard-coded a 14-byte block and cited a constant that
does not exist; it now reads <Decimal as FixedOrderableBytes>::ENCODED_LEN.
No change to ore-rs's API or ciphertexts.

Claude-Session: https://claude.ai/code/session_012zHhtTEo968WazExKxA5oT
@coderdan
coderdan force-pushed the feat/orderable-bytes-variable-length branch from 2021151 to dd4fc2c Compare October 10, 2026 03:17
@coderdan
coderdan requested a balanced review from Copilot October 10, 2026 03:21

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.

🟢 Approval recommended

The trait migration, borrowed encodings, compatibility safeguards, and fixed-length ORE boundaries are consistent and comprehensively tested.

0 open findings

1 resolved since last review

🧠 Review effort: Balanced

OrderableBytes::Bytes<'a> borrows from the value so strings encode
without a copy, but that tied even a fixed-length [u8; N] to the value:
generic code bounded on FixedOrderableBytes could no longer return or
store the bytes, which 0.1's owned T::Bytes allowed.

FixedOrderableBytes now has an owned output, type Array = [u8; N], and
to_fixed_orderable_bytes(), which returns the same bytes as
to_orderable_bytes() without borrowing. Binding Bytes<'a> to an owned
type for every 'a was not an option: the GAT's `Self: 'a` bound makes
that supertrait fail with E0311.

The rules that every type implements exactly one of Fixed/Variable, and
that the fixed bytes are exactly ENCODED_LEN long, were docs only. All
three traits are now sealed, and one macro writes each fixed type's
impls from a single length, so the two can't drift and the 14 primitive
impls are no longer copy-pasted pairs.

Also:
- &T implements the same traits as T, so "abc" can be passed by value.
- [u8; N] is fixed-length and encodes to itself, so hashes and UUIDs
  need no copy into a Vec<u8>.
- Both compile_fail doctests name E0277, so they only pass for the
  error they pin.
- The golden check asserts that both methods return the same bytes, and
  pins [u8; N].
- The README upgrade guide covers T::Bytes -> T::Array and sealing.

Encoded bytes are unchanged: every golden vector passes as written.

Refs #94

BREAKING CHANGE: OrderableBytes, FixedOrderableBytes and
VariableOrderableBytes are sealed and can't be implemented outside this
crate. FixedOrderableBytes gains the associated type Array and the
method to_fixed_orderable_bytes; generic code that stores or returns
fixed-length bytes should use T::Array and to_fixed_orderable_bytes()
in place of T::Bytes and to_orderable_bytes().

Claude-Session: https://claude.ai/code/session_012zHhtTEo968WazExKxA5oT
@coderdan
coderdan requested a review from freshtonic October 10, 2026 03:50
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.

orderable-bytes has no variable-length encoding — text and bytes can't share the order layer

2 participants