From 7ded401eb9162c37328887dfe055f425e7bc855c Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 20 Oct 2026 22:15:19 +1100 Subject: [PATCH 1/4] feat(aead)!: seal a fixed-width None as a typed null as long as a Some, so ciphertext length no longer shows which values are absent AES-GCM keeps the plaintext's length, and a sealed null was one byte where the value it stood in for was up to 17. Anyone who could read the ciphertexts could list the absent rows of a column without a key. A `None` of an int, a float, a date, a timestamp or a decimal now seals as a typed null: the new `TYPED_NULL` tag (0x15), the header a value of the type starts with, then zero padding to the value's leaf length. It opens only as `None` of an `Option` of the same type, with its header and padding checked strictly so every absence has one encoding; any other target refuses it as the wrong kind. The one-byte null leaf still opens as before, and bool, text and bytes still seal it. Breaking: `Sink` has a new required method, `typed_null`, which every sink must handle (a forwarding sink must forward it, or the padding is lost). `Encrypt` and `Decrypt` gain a `FIXED` constant, derived for fieldless enums and newtypes. A reader built before this release refuses a typed null. Refs #493 --- packages/aead-derive/src/encrypt.rs | 36 ++ packages/aead-derive/src/typed_decrypt.rs | 11 + packages/aead-frame/src/serde.rs | 21 ++ packages/aead-napi/src/js_builder.rs | 9 +- packages/aead/README.md | 5 +- packages/aead/src/encrypt_sink.rs | 51 ++- packages/aead/src/frame.rs | 32 +- packages/aead/src/leaf.rs | 430 +++++++++++++++++++++- packages/aead/src/lib.rs | 4 +- packages/aead/src/plaintext/kinds.rs | 174 +++++++++ packages/aead/src/plaintext/mod.rs | 39 +- packages/aead/src/plaintext/sources.rs | 83 ++++- packages/aead/src/plaintext/tee.rs | 23 +- packages/aead/src/seal.rs | 6 +- packages/aead/src/tags.rs | 15 + packages/aead/src/timestamp.rs | 6 +- packages/aead/src/typed.rs | 209 ++++++++++- packages/aead/src/walk.rs | 11 +- packages/aead/tests/derive_sink.rs | 14 +- packages/aead/tests/protected_vec_wipe.rs | 6 +- packages/aead/tests/typed_null.rs | 232 ++++++++++++ packages/encrypt/src/cipher_static.rs | 6 +- packages/encrypt/tests/sink_cipher.rs | 46 +++ packages/normalize/src/terms.rs | 11 +- packages/ore/src/scheme.rs | 8 +- 25 files changed, 1436 insertions(+), 52 deletions(-) create mode 100644 packages/aead/tests/typed_null.rs diff --git a/packages/aead-derive/src/encrypt.rs b/packages/aead-derive/src/encrypt.rs index f7a9412f..f2d1238c 100644 --- a/packages/aead-derive/src/encrypt.rs +++ b/packages/aead-derive/src/encrypt.rs @@ -77,11 +77,13 @@ pub(crate) fn derive(input: DeriveInput) -> Result { item_check, visit_check, } = depth(krate, &input, &shape, attrs.recursive); + let (fixed, ref_fixed) = fixed(krate, &input, &shape, attrs.recursive); Ok(quote! { #[automatically_derived] impl #impl_generics #krate::Encrypt for #name #ty_generics #where_clause { #depth + #fixed fn visit<__S>(#receiver, __sink: __S) -> ::core::result::Result<__S::Ok, __S::Error> where @@ -95,6 +97,7 @@ pub(crate) fn derive(input: DeriveInput) -> Result { #[automatically_derived] impl #ref_impl_generics #krate::Encrypt for &'__r #name #ty_generics #ref_where_clause { #ref_depth + #ref_fixed fn visit<__S>(self, __sink: __S) -> ::core::result::Result<__S::Ok, __S::Error> where @@ -215,6 +218,39 @@ fn depth(krate: &syn::Path, input: &DeriveInput, shape: &Shape, recursive: bool) } } +/// The `FIXED` constants for both impls: what an absent value of the type +/// is sealed as. A newtype is transparent, so it has its inner type's (by +/// value and by reference, as `DEPTH` does). A fieldless enum is always a +/// `u32` leaf. A struct is a map, which has none, and a recursive newtype +/// keeps the default rather than name its own constant in a cycle. +fn fixed( + krate: &syn::Path, + input: &DeriveInput, + shape: &Shape, + recursive: bool, +) -> (TokenStream, TokenStream) { + let decl = |value: TokenStream| quote!(const FIXED: ::core::option::Option<#krate::FixedType> = #value;); + match shape { + Shape::Newtype(_) if recursive || names_itself(&input.ident, shape) => { + (TokenStream::new(), TokenStream::new()) + } + Shape::Newtype(field) => { + let ty = &field.ty; + ( + decl(quote!(<#ty as #krate::Encrypt>::FIXED)), + decl(quote!(<&'__r #ty as #krate::Encrypt>::FIXED)), + ) + } + Shape::Enum(_) => { + let u32 = decl(quote!(::core::option::Option::Some( + #krate::FixedType::plain(#krate::Width::U32) + ))); + (u32.clone(), u32) + } + Shape::Map(_) | Shape::Empty => (TokenStream::new(), TokenStream::new()), + } +} + /// Whether a walked field's type names the type being derived, directly or /// as `Self`: a recursive type, whose depth depends on the value. Mutual /// recursion through another type cannot be seen from here; it needs diff --git a/packages/aead-derive/src/typed_decrypt.rs b/packages/aead-derive/src/typed_decrypt.rs index 2b5d2319..4808dba7 100644 --- a/packages/aead-derive/src/typed_decrypt.rs +++ b/packages/aead-derive/src/typed_decrypt.rs @@ -68,6 +68,8 @@ pub(crate) fn derive(input: DeriveInput) -> Result { <#inner as #krate::typed::Decrypt>::Sink, #wrap #ty_generics, >; + const FIXED: ::core::option::Option<#krate::FixedType> = + <#inner as #krate::typed::Decrypt>::FIXED; fn sink() -> Self::Sink { #krate::typed::Wrapped::new(<#inner as #krate::typed::Decrypt>::sink()) } @@ -211,6 +213,13 @@ fn map_target( ::core::result::Result::Err(#krate::DecodeError::Expected(#expected)) } + fn typed_null( + self, + _: #krate::FixedType, + ) -> ::core::result::Result { + ::core::result::Result::Err(#krate::DecodeError::Expected(#expected)) + } + fn seq( self, _: ::core::option::Option, @@ -298,6 +307,8 @@ fn enum_target(krate: &syn::Path, input: &DeriveInput, variants: &[VariantInfo]) #[automatically_derived] impl #impl_generics #krate::FromScalar for #name #ty_generics #where_clause { const EXPECTED: &'static str = #expected; + const FIXED: ::core::option::Option<#krate::FixedType> = + ::core::option::Option::Some(#krate::FixedType::plain(#krate::Width::U32)); fn from_scalar( __ty: ::core::option::Option<#krate::Logical>, diff --git a/packages/aead-frame/src/serde.rs b/packages/aead-frame/src/serde.rs index aa594531..905310f9 100644 --- a/packages/aead-frame/src/serde.rs +++ b/packages/aead-frame/src/serde.rs @@ -21,6 +21,11 @@ //! | bool | bool | //! | null, and JavaScript's `undefined` | unit, and `None` | //! +//! A typed null ([`tags::TYPED_NULL`](vitaminc_aead::tags::TYPED_NULL)) is +//! refused as malformed. It is how plaintext marks an absent fixed-width +//! value so its sealed length matches a present one, which is no concern of +//! data that is not sealed, and this format never writes one. +//! //! Each kind reads only as itself: a struct or a map reads an object, never //! an array by position, and a string reads text, never bytes that happen //! to be UTF-8, nor bytes text. @@ -1490,4 +1495,20 @@ mod tests { ); } } + + /// A typed null is plaintext's, not this format's: refused wherever it + /// appears, an option included, rather than read as a null. + #[test] + fn a_typed_null_is_refused() { + let null_u32 = [ + vitaminc_aead::tags::TYPED_NULL, + vitaminc_aead::tags::UINT32, + 0, + 0, + 0, + ]; + assert!(from_slice::>(&null_u32).is_err()); + assert!(from_slice::<()>(&null_u32).is_err()); + assert!(from_slice::(&null_u32).is_err()); + } } diff --git a/packages/aead-napi/src/js_builder.rs b/packages/aead-napi/src/js_builder.rs index 16b784e6..5a36da09 100644 --- a/packages/aead-napi/src/js_builder.rs +++ b/packages/aead-napi/src/js_builder.rs @@ -15,7 +15,9 @@ use napi::bindgen_prelude::{BigInt, Buffer, JsValue, Null, Object, ToNapiValue, Unknown}; use napi::{Env, Error, Status}; -use vitaminc_aead::{Encrypt, Float, Frame, Int, Logical, MapSink, Physical, SeqSink, Sink}; +use vitaminc_aead::{ + Encrypt, FixedType, Float, Frame, Int, Logical, MapSink, Physical, SeqSink, Sink, +}; use crate::error::JsFault; use crate::js::{ @@ -124,6 +126,11 @@ impl<'e, O: Out<'e>> Sink for JsBuilder<'e, O> { Ok(self.out.put(built)) } + /// An absent value is JavaScript's `null`, whatever its type. + fn typed_null(self, _: FixedType) -> Result { + self.scalar(None, Physical::Null) + } + fn seq(self, len: Option) -> Result, JsFault> { self.check_depth()?; Ok(JsSeq { diff --git a/packages/aead/README.md b/packages/aead/README.md index 1b48e3c4..b592c038 100644 --- a/packages/aead/README.md +++ b/packages/aead/README.md @@ -131,7 +131,10 @@ let mut map = BTreeMap::new(); map.insert("name".to_string(), "Ada".to_string()); assert!(matches!(encrypt(map, &cipher, ())?, CipherText::Map(entries) if entries[0].0 == "name")); -// An empty container is an authenticated marker; `None` is a null leaf. +// An empty container is an authenticated marker; `None` is a null leaf. A +// `None` of a fixed-width type (an int, a float, a date, a timestamp, a +// decimal) is a typed null, padded to the length of a `Some`, so the +// ciphertext's length does not show which values are absent. assert!(matches!(encrypt(Vec::::new(), &cipher, ())?, CipherText::EmptySequence(_))); assert!(matches!(encrypt(None::, &cipher, ())?, CipherText::Single(_))); # Ok::<(), Box>(()) diff --git a/packages/aead/src/encrypt_sink.rs b/packages/aead/src/encrypt_sink.rs index 7a215690..5f0fe7d7 100644 --- a/packages/aead/src/encrypt_sink.rs +++ b/packages/aead/src/encrypt_sink.rs @@ -36,7 +36,7 @@ use vitaminc_protected::{Controlled, Protected}; use crate::frame::PassthroughFrame; use crate::leaf::{self, ScalarError}; use crate::plaintext::{ - Encrypt, Error, Logical, MapSink, Physical, SeqSink, Sink, SourceError, Step, + Encrypt, Error, FixedType, Logical, MapSink, Physical, SeqSink, Sink, SourceError, Step, }; use crate::seal::{ seal_all, seal_all_async, AsyncCipher, Cipher, LeafCount, Node, Pending, SealError, @@ -271,6 +271,18 @@ impl EncryptSink { aad: self.aad, }))) } + + /// Plan an absent value of a fixed-width type as one leaf: a typed + /// null, as long as a leaf holding a value of the type. + fn typed_null_leaf(self, ty: FixedType) -> Result> { + shape::scalar(self.depth, None, &Physical::Null, &self.out)?; + let mut plaintext = Protected::new(Vec::with_capacity(leaf::typed_null_len(ty))); + leaf::write_typed_null(ty, plaintext.risky_inner_mut()); + Ok(self.out.put(CipherText::Single(Pending { + plaintext, + aad: self.aad, + }))) + } } fn marker(aad: Context<'static>) -> Pending { @@ -290,6 +302,10 @@ impl Sink for EncryptSink { self.leaf(ty, v) } + fn typed_null(self, ty: FixedType) -> Result { + self.typed_null_leaf(ty) + } + fn seq(self, len: Option) -> Result { shape::container(self.depth, &self.out)?; Ok(EncryptSeq { @@ -473,13 +489,40 @@ mod tests { #[test] fn none_is_a_null_leaf_not_a_marker() { - let node = planned(None::).expect("plans"); + let node = planned(None::).expect("plans"); assert!(matches!(node, CipherText::Single(_))); let leaves = leaves_with_aad(node, &"ctx".into_aad()); assert_eq!(leaves[0].0.plaintext.risky_ref(), &[0x00]); assert_eq!(leaves[0].1, "ctx".into_aad()); } + #[test] + fn a_fixed_width_none_is_a_typed_null_as_long_as_a_some() { + let node = planned(None::).expect("plans"); + assert!(matches!(node, CipherText::Single(_))); + let leaves = leaves_with_aad(node, &"ctx".into_aad()); + assert_eq!(leaves[0].0.plaintext.risky_ref(), &[0x15, 0x06, 0, 0, 0]); + assert_eq!(leaves[0].1, "ctx".into_aad()); + let some = leaves_with_aad(planned(Some(34u32)).expect("plans"), &"ctx".into_aad()); + assert_eq!(some[0].0.plaintext.risky_ref().len(), 5); + } + + #[test] + fn a_typed_null_too_deep_is_refused_like_a_scalar() { + use crate::plaintext::{FixedType, Width}; + let sink = EncryptSink:: { + aad: "ctx".into_aad().into_owned(), + out: Root, + depth: crate::frame::MAX_DEPTH + 1, + _error: PhantomData, + }; + let err = sink + .typed_null(FixedType::plain(Width::I8)) + .err() + .expect("too deep"); + assert!(matches!(err, EncryptError::Shape(_)), "{err:?}"); + } + #[test] fn empty_containers_are_markers() { let node = planned(Vec::::new()).expect("plans"); @@ -575,6 +618,10 @@ mod tests { *self.0 += 1; Ok(()) } + fn typed_null(self, _: FixedType) -> Result<(), E> { + *self.0 += 1; + Ok(()) + } fn seq(self, _: Option) -> Result { Ok(self) } diff --git a/packages/aead/src/frame.rs b/packages/aead/src/frame.rs index 24f7857d..7cb52546 100644 --- a/packages/aead/src/frame.rs +++ b/packages/aead/src/frame.rs @@ -21,8 +21,10 @@ use std::collections::HashSet; use vitaminc_protected::{Controlled, OpaqueDebug, Protected, TimingSafeEq}; use zeroize::Zeroize; -use crate::leaf::{self, Form, ScalarError}; -use crate::plaintext::{Encrypt, Error, Logical, MapSink, Physical, SeqSink, Sink, SourceError}; +use crate::leaf::{self, Form, Item, ScalarError}; +use crate::plaintext::{ + Encrypt, Error, FixedType, Logical, MapSink, Physical, SeqSink, Sink, SourceError, +}; /// Framing tag for a sequence: `ARRAY`, `u32` count, then the elements. pub const ARRAY: u8 = 0xF0; @@ -117,11 +119,11 @@ impl<'a> Cursor<'a> { core::str::from_utf8(self.take(len)?).map_err(|_| FrameError) } - fn scalar(&mut self) -> Result<(Option, Physical<'a>), FrameError> { - let (scalar, used) = - leaf::read(&self.buf[self.pos..], Form::Frame).map_err(|_| FrameError)?; + fn scalar(&mut self) -> Result, FrameError> { + let (item, used) = + leaf::read_item(&self.buf[self.pos..], Form::Frame).map_err(|_| FrameError)?; self.pos += used; - Ok(scalar) + Ok(item) } /// The next whole value node, as its own frame. @@ -221,10 +223,10 @@ fn visit_node(c: &mut Cursor<'_>, sink: S) -> Result { c.byte().expect(VALIDATED); sink.passthrough(c.node(0).expect(VALIDATED)) } - _ => { - let (ty, v) = c.scalar().expect(VALIDATED); - sink.scalar(ty, v) - } + _ => match c.scalar().expect(VALIDATED) { + Item::Scalar(ty, v) => sink.scalar(ty, v), + Item::TypedNull(ty) => sink.typed_null(ty), + }, } } @@ -454,6 +456,16 @@ impl<'o> Sink for FrameWriter<'o> { }) } + /// Carried as it is, so a host that encrypts from the frame pads it. + fn typed_null(self, ty: FixedType) -> Result<(), FrameWriteError> { + if self.depth > MAX_DEPTH { + return Err(FrameWriteError::TooDeep); + } + reserve(self.out, leaf::typed_null_len(ty)); + leaf::write_typed_null(ty, self.out); + Ok(()) + } + fn seq(self, len: Option) -> Result, FrameWriteError> { Ok(FrameSeq { count: self.container(ARRAY)?, diff --git a/packages/aead/src/leaf.rs b/packages/aead/src/leaf.rs index 4f1e86b4..24e7910a 100644 --- a/packages/aead/src/leaf.rs +++ b/packages/aead/src/leaf.rs @@ -29,8 +29,17 @@ //! leap second. It has no count in any unit and is refused. //! - A `DECIMAL` with the sign bit set on a zero mantissa reads as zero, //! and zero is always written without the sign. - -use crate::plaintext::kinds::{Float, Int, Logical, Physical, TimeUnit, MAX_DECIMAL_SCALE}; +//! +//! An absent value of a fixed-width type is a **typed null** +//! ([`tags::TYPED_NULL`]), padded to the length of a value of the type so +//! the two cannot be told apart by length once sealed. It is not a scalar: +//! [`write_typed_null`] writes one, [`read_item`] and [`read_leaf_item`] +//! read one, and [`read`] and [`read_leaf`] refuse one, so a reader that +//! does not know about typed nulls fails closed. + +use crate::plaintext::kinds::{ + FixedType, Float, Int, Logical, Physical, TimeUnit, Width, MAX_DECIMAL_SCALE, +}; use crate::tags; /// Which wire form a scalar is in. @@ -167,6 +176,128 @@ pub fn write_leaf( write(ty, v, Form::Leaf, out) } +/// Write a typed null of `ty`: [`tags::TYPED_NULL`], the header a value of +/// `ty` starts with, then zeros, to the length a value of `ty` takes. The +/// same bytes in either form, since a fixed-width scalar has no length +/// prefix. Cannot fail: a [`FixedType`] is valid by construction. +/// +/// # Examples +/// +/// A null date is as long as a date. +/// +/// ``` +/// use vitaminc_aead::leaf::{read_leaf_item, write_typed_null, Item, ScalarError}; +/// use vitaminc_aead::plaintext::{FixedType, Logical, Width}; +/// use vitaminc_aead::tags; +/// +/// let date = FixedType::new(Some(Logical::Date), Width::I32).expect("fixed"); +/// let mut out = Vec::new(); +/// write_typed_null(date, &mut out); +/// assert_eq!(out, [tags::TYPED_NULL, tags::DATE, 0, 0, 0]); +/// assert_eq!(read_leaf_item(&out)?, Item::TypedNull(date)); +/// # Ok::<(), ScalarError>(()) +/// ``` +pub fn write_typed_null(ty: FixedType, out: &mut Vec) { + let Header { + bytes, + len, + payload, + } = header(ty); + out.push(tags::TYPED_NULL); + out.extend_from_slice(&bytes[..len]); + // One byte of the payload's length went to `TYPED_NULL`. Every + // payload here is at least one byte, so this never underflows. + out.resize(out.len() + payload - 1, 0); +} + +/// The header a value of a [`FixedType`] starts with, and the length of +/// the payload after it. +struct Header { + /// The header, in the first `len` bytes. + bytes: [u8; 4], + len: usize, + payload: usize, +} + +fn header(ty: FixedType) -> Header { + let width = ty.width(); + let tag = |tag, payload| Header { + bytes: [tag, 0, 0, 0], + len: 1, + payload, + }; + match ty.logical() { + None => tag(width_tag(width), width_payload(width)), + Some(Logical::Date) => tag(tags::DATE, 4), + Some(Logical::Timestamp(TimeUnit::Nanos)) => tag(tags::TIMESTAMP, 12), + Some(Logical::Decimal { .. }) => tag(tags::DECIMAL, 16), + Some(logical) => { + let (id, param) = logical.to_wire(); + Header { + bytes: [tags::LOGICAL, id, param, width_tag(width)], + len: 4, + payload: width_payload(width), + } + } + } +} + +/// The tag a plain value of `width` is written with. +fn width_tag(width: Width) -> u8 { + match width { + Width::I8 => tags::INT8, + Width::I16 => tags::INT16, + Width::I32 => tags::INT32, + Width::I64 => tags::INT64, + Width::I128 => tags::INT128, + Width::U8 => tags::UINT8, + Width::U16 => tags::UINT16, + Width::U32 => tags::UINT32, + Width::U64 => tags::UINT64, + Width::U128 => tags::UINT128, + Width::F32 => tags::FLOAT32, + Width::F64 => tags::FLOAT64, + } +} + +/// The payload length of a plain value of `width`. +fn width_payload(width: Width) -> usize { + match width { + Width::I8 | Width::U8 => 1, + Width::I16 | Width::U16 => 2, + Width::I32 | Width::U32 | Width::F32 => 4, + Width::I64 | Width::U64 | Width::F64 => 8, + Width::I128 | Width::U128 => 16, + } +} + +/// The width a plain tag is written for, or `None` for a tag that is not a +/// plain int or float. +fn tag_width(tag: u8) -> Option { + Some(match tag { + tags::INT8 => Width::I8, + tags::INT16 => Width::I16, + tags::INT32 => Width::I32, + tags::INT64 => Width::I64, + tags::INT128 => Width::I128, + tags::UINT8 => Width::U8, + tags::UINT16 => Width::U16, + tags::UINT32 => Width::U32, + tags::UINT64 => Width::U64, + tags::UINT128 => Width::U128, + tags::FLOAT32 => Width::F32, + tags::FLOAT64 => Width::F64, + _ => return None, + }) +} + +/// The length of a typed null of `ty`, which is the length of a leaf +/// holding a value of `ty`. +pub fn typed_null_len(ty: FixedType) -> usize { + let Header { len, payload, .. } = header(ty); + len + payload +} + /// An upper bound on the bytes [`write()`] appends for `v` in either form: /// an envelope, a tag, a length and the payload. Lets a caller size a /// `Protected` buffer once rather than let it regrow, since a regrowing @@ -256,8 +387,19 @@ fn write_int(i: Int, out: &mut Vec) { /// One scalar as read from the wire. pub type Scalar<'a> = (Option, Physical<'a>); +/// One leaf or frame scalar node as read from the wire: a scalar, or a +/// typed null. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Item<'a> { + /// A value, or the plain null. + Scalar(Option, Physical<'a>), + /// An absent value of a fixed-width type. + TypedNull(FixedType), +} + /// Read one scalar in `form` from the front of `bytes`, validating it. -/// Returns the scalar and how many bytes it occupied. +/// Returns the scalar and how many bytes it occupied. A typed null is not +/// a scalar and is refused; [`read_item`] reads either. /// /// # Examples /// @@ -283,7 +425,8 @@ pub fn read(bytes: &[u8], form: Form) -> Result<(Scalar<'_>, usize), ScalarError Ok((scalar, c.pos)) } -/// Read one sealed leaf's plaintext, which must be exactly one scalar. +/// Read one sealed leaf's plaintext, which must be exactly one scalar. A +/// typed null is refused; [`read_leaf_item`] reads either. pub fn read_leaf(bytes: &[u8]) -> Result, ScalarError> { let (scalar, used) = read(bytes, Form::Leaf)?; if used != bytes.len() { @@ -292,6 +435,56 @@ pub fn read_leaf(bytes: &[u8]) -> Result, ScalarError> { Ok(scalar) } +/// Read one scalar or typed null in `form` from the front of `bytes`, +/// validating it. Returns it and how many bytes it occupied. +pub fn read_item(bytes: &[u8], form: Form) -> Result<(Item<'_>, usize), ScalarError> { + if bytes.first() == Some(&tags::TYPED_NULL) { + let mut c = Cursor { buf: bytes, pos: 1 }; + let ty = read_typed_null(&mut c)?; + return Ok((Item::TypedNull(ty), c.pos)); + } + let ((ty, v), used) = read(bytes, form)?; + Ok((Item::Scalar(ty, v), used)) +} + +/// Read one sealed leaf's plaintext, which must be exactly one scalar or +/// typed null. +pub fn read_leaf_item(bytes: &[u8]) -> Result, ScalarError> { + let (item, used) = read_item(bytes, Form::Leaf)?; + if used != bytes.len() { + return Err(ScalarError::Malformed); + } + Ok(item) +} + +/// A typed null after its `TYPED_NULL` tag. Strict, so that every absence +/// has one encoding: the header must be one this codec writes for some +/// [`FixedType`] and the padding must be zero. +fn read_typed_null(c: &mut Cursor<'_>) -> Result { + let tag = c.byte()?; + let (ty, width) = match tag { + tags::DATE => (Some(Logical::Date), Width::I32), + tags::TIMESTAMP => (Some(Logical::Timestamp(TimeUnit::Nanos)), Width::I128), + tags::DECIMAL => (Some(Logical::Decimal { scale: 0 }), Width::I128), + tags::LOGICAL => { + let ty = Logical::from_wire(c.byte()?, c.byte()?).ok_or(ScalarError::Malformed)?; + if has_shorthand(ty) { + return Err(ScalarError::Malformed); + } + let width = tag_width(c.byte()?).ok_or(ScalarError::Malformed)?; + (Some(ty), width) + } + tag => (None, tag_width(tag).ok_or(ScalarError::Malformed)?), + }; + // A logical type over a physical type it cannot ride on (a uuid over + // an int, a timestamp over a float) has no typed null. + let fixed = FixedType::new(ty, width).ok_or(ScalarError::Malformed)?; + if c.take(header(fixed).payload - 1)?.iter().any(|&b| b != 0) { + return Err(ScalarError::Malformed); + } + Ok(fixed) +} + struct Cursor<'a> { buf: &'a [u8], pos: usize, @@ -737,7 +930,7 @@ mod tests { assert_eq!(read_leaf(&[0x06, 1, 2]), Err(ScalarError::Malformed)); assert_eq!(read_leaf(&[0x00, 0x00]), Err(ScalarError::Malformed)); assert_eq!(read_leaf(&[0x0A, 0xFF]), Err(ScalarError::Malformed)); - assert_eq!(read_leaf(&[0x15]), Err(ScalarError::Malformed)); + assert_eq!(read_leaf(&[0x16]), Err(ScalarError::Malformed)); assert_eq!(read_leaf(&[0xF0]), Err(ScalarError::Malformed)); assert_eq!( read(&[0x0A, 5, 0, 0, 0, b'x'], Form::Frame), @@ -1052,9 +1245,13 @@ mod tests { read_leaf(&[0xF3, 200, 0, 0x12, 0x44, 0x24, 0x0B, 0x00]), Err(ScalarError::Malformed) ); - // An unknown tag, and no tag at all. + // An unknown tag, a typed null, and no tag at all. assert_eq!( - read_leaf(&[0xF3, 200, 0, 0x15]), + read_leaf(&[0xF3, 200, 0, 0x16]), + Err(ScalarError::Malformed) + ); + assert_eq!( + read_leaf_item(&[0xF3, 200, 0, tags::TYPED_NULL, tags::INT8]), Err(ScalarError::Malformed) ); assert_eq!(read_leaf(&[0xF3, 200, 0]), Err(ScalarError::Malformed)); @@ -1075,6 +1272,225 @@ mod tests { assert_eq!(read_leaf(&[tags::BYTES]), Ok((None, Physical::Bytes(&[])))); } + fn typed_null(ty: FixedType) -> Vec { + let mut out = Vec::new(); + write_typed_null(ty, &mut out); + out + } + + fn fixed(ty: Option, width: Width) -> FixedType { + FixedType::new(ty, width).expect("fixed width") + } + + /// Every fixed-width type's typed null, pinned, beside a value of the + /// type: the cross-language wire commitment, and the same length. + #[test] + fn typed_null_known_answers() { + use tags::TYPED_NULL as T; + let ms = Logical::Timestamp(TimeUnit::Millis); + let unknown = Logical::from_wire(200, 9); + let cases: Vec<(FixedType, Vec, Physical<'_>)> = vec![ + ( + FixedType::plain(Width::I8), + vec![T, 0x0C], + Physical::Int(Int::I8(-1)), + ), + ( + FixedType::plain(Width::U8), + vec![T, 0x0D], + Physical::Int(Int::U8(1)), + ), + ( + FixedType::plain(Width::I16), + vec![T, 0x0E, 0], + Physical::Int(Int::I16(1)), + ), + ( + FixedType::plain(Width::U16), + vec![T, 0x0F, 0], + Physical::Int(Int::U16(1)), + ), + ( + FixedType::plain(Width::I32), + vec![T, 0x04, 0, 0, 0], + Physical::Int(Int::I32(1)), + ), + ( + FixedType::plain(Width::U32), + vec![T, 0x06, 0, 0, 0], + Physical::Int(Int::U32(1)), + ), + ( + FixedType::plain(Width::F32), + vec![T, 0x08, 0, 0, 0], + Physical::Float(Float::F32(1.5)), + ), + ( + FixedType::plain(Width::I64), + vec![T, 0x05, 0, 0, 0, 0, 0, 0, 0], + Physical::Int(Int::I64(1)), + ), + ( + FixedType::plain(Width::U64), + vec![T, 0x07, 0, 0, 0, 0, 0, 0, 0], + Physical::Int(Int::U64(1)), + ), + ( + FixedType::plain(Width::F64), + vec![T, 0x09, 0, 0, 0, 0, 0, 0, 0], + Physical::Float(Float::F64(1.5)), + ), + ( + FixedType::plain(Width::I128), + [&[T, 0x10][..], &[0; 15]].concat(), + Physical::Int(Int::I128(1)), + ), + ( + FixedType::plain(Width::U128), + [&[T, 0x11][..], &[0; 15]].concat(), + Physical::Int(Int::U128(1)), + ), + ( + fixed(Some(Logical::Date), Width::I32), + vec![T, 0x12, 0, 0, 0], + Physical::Int(Int::I32(11_017)), + ), + ( + fixed(Some(Logical::Timestamp(TimeUnit::Nanos)), Width::I128), + [&[T, 0x13][..], &[0; 11]].concat(), + Physical::Int(Int::I128(1_000_000_005)), + ), + ( + fixed(Some(Logical::Decimal { scale: 0 }), Width::I128), + [&[T, 0x14][..], &[0; 15]].concat(), + Physical::Int(Int::I128(150)), + ), + // Through the envelope: the envelope's header, then the + // physical tag, then the padding. + ( + fixed(Some(ms), Width::I64), + [&[T, 0xF3, 3, 1, 0x05][..], &[0; 7]].concat(), + Physical::Int(Int::I64(1_700_000_000_000)), + ), + ( + fixed(unknown, Width::F32), + vec![T, 0xF3, 200, 9, 0x08, 0, 0, 0], + Physical::Float(Float::F32(1.5)), + ), + ]; + for (ty, expected, value) in cases { + assert_eq!(typed_null(ty), expected, "{ty:?}"); + assert_eq!(typed_null_len(ty), expected.len(), "{ty:?}"); + assert_eq!( + read_leaf_item(&expected).expect("reads"), + Item::TypedNull(ty), + "{ty:?}" + ); + // A frame carries the same bytes, and reads to where they end. + let framed = [&expected[..], &[tags::NULL]].concat(); + assert_eq!( + read_item(&framed, Form::Frame).expect("reads"), + (Item::TypedNull(ty), expected.len()), + "{ty:?}" + ); + // As long as a value of the type: the point of it. + let present = leaf(ty.logical(), value); + assert_eq!(present.len(), expected.len(), "{ty:?}"); + assert_eq!(FixedType::of(ty.logical(), &value), Some(ty), "{ty:?}"); + } + } + + #[test] + fn a_typed_null_is_not_a_scalar() { + let null_date = typed_null(fixed(Some(Logical::Date), Width::I32)); + assert_eq!(read_leaf(&null_date), Err(ScalarError::Malformed)); + assert_eq!(read(&null_date, Form::Frame), Err(ScalarError::Malformed)); + // And a scalar reads through `read_item` as a scalar. + assert_eq!( + read_leaf_item(&[0x06, 34, 0, 0, 0]), + Ok(Item::Scalar(None, Physical::Int(Int::U32(34)))) + ); + assert_eq!( + read_leaf_item(&[0x00]), + Ok(Item::Scalar(None, Physical::Null)) + ); + } + + /// Every absence has one encoding, so a key-holding writer cannot pass + /// another plaintext off as one. + #[test] + fn a_typed_null_is_read_strictly() { + use tags::TYPED_NULL as T; + let refused: &[&[u8]] = &[ + // No header, and a header with no padding. + &[T], + &[T, 0x06], + // Padding that is not zero, short, or followed by more. + &[T, 0x06, 0, 1, 0], + &[T, 0x06, 0, 0], + &[T, 0x06, 0, 0, 0, 0], + &[T, 0x12, 0, 0, 0, 0], + // A tag with no typed null: null, undefined, bool, text, bytes, + // another typed null, an unknown tag. + &[T, 0x00], + &[T, 0x01], + &[T, 0x02], + &[T, 0x03], + &[T, 0x0A], + &[T, 0x0B], + &[T, T, 0x06, 0, 0, 0], + &[T, 0x16], + // An envelope where a shorthand tag exists (a date), with no + // logical type (id 0), and over the reserved long envelope. + &[T, 0xF3, 2, 0, 0x04, 0, 0, 0], + &[T, 0xF3, 0, 0, 0x04, 0, 0, 0], + &[T, 0xF4, 200, 0, 0x04, 0, 0, 0], + // A logical type over a physical type it cannot ride on: a + // uuid over an int, a millisecond timestamp over a float, an + // unknown type over a bool or over text. + &[T, 0xF3, 5, 0, 0x04, 0, 0, 0], + &[T, 0xF3, 3, 1, 0x09, 0, 0, 0, 0, 0, 0, 0], + &[T, 0xF3, 200, 0, 0x02], + &[T, 0xF3, 200, 0, 0x0A], + ]; + for bytes in refused { + assert_eq!( + read_leaf_item(bytes), + Err(ScalarError::Malformed), + "{bytes:?}" + ); + } + } + + /// Whatever `read_item` accepts after a typed null tag writes back to + /// the bytes it read: one encoding per absence. + #[quickcheck] + fn any_typed_null_input_reads_without_panic_and_has_one_encoding( + tag: u8, + id: u8, + param: u8, + rest: Vec, + ) -> bool { + let tag = tag % 0x16; + // Mostly zero padding, so the accepting path is exercised too. + let rest: Vec = rest.iter().map(|&b| if b < 0xF0 { 0 } else { b }).collect(); + let plain = [&[tags::TYPED_NULL, tag][..], &rest[..]].concat(); + let envelope = [ + &[tags::TYPED_NULL, tags::LOGICAL, id % 8, param % 32, tag][..], + &rest, + ] + .concat(); + [plain, envelope].iter().all(|bytes| { + [Form::Leaf, Form::Frame] + .into_iter() + .all(|form| match read_item(bytes, form) { + Err(_) => true, + Ok((Item::TypedNull(ty), used)) => typed_null(ty) == bytes[..used], + Ok((Item::Scalar(..), _)) => false, + }) + }) + } + #[test] fn scalar_errors_display_and_are_errors() { fn is_error(_: &E) {} diff --git a/packages/aead/src/lib.rs b/packages/aead/src/lib.rs index c1f96c43..c6403726 100644 --- a/packages/aead/src/lib.rs +++ b/packages/aead/src/lib.rs @@ -23,8 +23,8 @@ mod walk; // The data model: sources, sinks and the scalar kinds. #[doc(inline)] pub use plaintext::{ - Bytes, Encrypt, Float, Int, Kind, KnownLogical, Logical, MapSink, Physical, Raw, SeqSink, Sink, - SourceError, Tee, TimeUnit, UnknownLogical, UnknownLogicalId, + Bytes, Encrypt, FixedType, Float, Int, Kind, KnownLogical, Logical, MapSink, Physical, Raw, + SeqSink, Sink, SourceError, Tee, TimeUnit, UnknownLogical, UnknownLogicalId, Width, }; /// Derive [`Encrypt`] for a struct or a fieldless enum. A struct walks as /// a map keyed by field name; a fieldless enum as an int leaf holding the diff --git a/packages/aead/src/plaintext/kinds.rs b/packages/aead/src/plaintext/kinds.rs index 4fb4bed2..2b85c6c8 100644 --- a/packages/aead/src/plaintext/kinds.rs +++ b/packages/aead/src/plaintext/kinds.rs @@ -250,6 +250,129 @@ pub enum Float { F64(f64), } +/// The physical type of a fixed-width scalar whose leaf has a payload: an +/// int or a float, with its width. What a [`FixedType`] rides on. +/// +/// `bool` is fixed width too, but its leaf is the tag alone, one byte, the +/// same length as the plain null leaf, so it has no width here. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Width { + I8, + I16, + I32, + I64, + I128, + U8, + U16, + U32, + U64, + U128, + F32, + F64, +} + +impl Width { + /// The physical kind: [`Kind::Int`] or [`Kind::Float`]. + pub const fn kind(self) -> Kind { + match self { + Width::F32 | Width::F64 => Kind::Float, + _ => Kind::Int, + } + } + + /// The width of a value of this physical type, or `None` for a value + /// with no [`Width`]: null, bool, text and bytes. + pub fn of(v: &Physical<'_>) -> Option { + Some(match v { + Physical::Int(Int::I8(_)) => Width::I8, + Physical::Int(Int::I16(_)) => Width::I16, + Physical::Int(Int::I32(_)) => Width::I32, + Physical::Int(Int::I64(_)) => Width::I64, + Physical::Int(Int::I128(_)) => Width::I128, + Physical::Int(Int::U8(_)) => Width::U8, + Physical::Int(Int::U16(_)) => Width::U16, + Physical::Int(Int::U32(_)) => Width::U32, + Physical::Int(Int::U64(_)) => Width::U64, + Physical::Int(Int::U128(_)) => Width::U128, + Physical::Float(Float::F32(_)) => Width::F32, + Physical::Float(Float::F64(_)) => Width::F64, + Physical::Null | Physical::Bool(_) | Physical::Text(_) | Physical::Bytes(_) => { + return None + } + }) + } +} + +/// A fixed-width scalar type, named without a value: what an absent value +/// of that type stands in for. +/// +/// AES-GCM keeps the plaintext's length, so a plain null leaf (one byte) +/// sealed in place of a date (five) shows which values are absent to +/// anyone who can see the ciphertexts. A null of a `FixedType` is sealed as +/// a **typed null** padded to the type's own leaf length, so a sealed +/// `None` and a sealed `Some` are the same length. See +/// [`Sink::typed_null`](crate::plaintext::Sink::typed_null) and +/// [`tags::TYPED_NULL`](crate::tags::TYPED_NULL). +/// +/// A `FixedType` is normalised to how its leaf is written, so two are equal +/// exactly when their typed nulls are the same bytes. A logical type with a +/// tag of its own fixes its width: a date is written as an `i32` day count +/// and a nanosecond timestamp and a decimal as 128-bit words, whatever +/// width the value came with. A decimal's scale travels inside its payload +/// rather than in its tag, so every decimal has the one typed null, of +/// scale 0. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct FixedType { + ty: Option, + width: Width, +} + +impl FixedType { + /// A plain int or float, with no logical type. + pub const fn plain(width: Width) -> Self { + Self { ty: None, width } + } + + /// `ty` over `width`, normalised, or `None` if `ty` does not ride on a + /// fixed-width physical type: undefined (over null) and uuid (over + /// bytes, whose frame form is variable width), and a date, timestamp or + /// decimal over a float. + pub const fn new(ty: Option, width: Width) -> Option { + let float = matches!(width, Width::F32 | Width::F64); + let (ty, width) = match ty { + None => (None, width), + Some(Logical::Unknown(u)) => (Some(Logical::Unknown(u)), width), + Some(Logical::Undefined | Logical::Uuid) => return None, + Some(_) if float => return None, + Some(Logical::Date) => (Some(Logical::Date), Width::I32), + Some(Logical::Timestamp(TimeUnit::Nanos)) => { + (Some(Logical::Timestamp(TimeUnit::Nanos)), Width::I128) + } + Some(Logical::Decimal { .. }) => (Some(Logical::Decimal { scale: 0 }), Width::I128), + Some(Logical::Timestamp(unit)) => (Some(Logical::Timestamp(unit)), width), + }; + Some(Self { ty, width }) + } + + /// The type of `v` under `ty`, or `None` if it has none (see + /// [`new`](FixedType::new)): what a host that knows the value's type + /// but holds no value passes to + /// [`Sink::typed_null`](crate::plaintext::Sink::typed_null). + pub fn of(ty: Option, v: &Physical<'_>) -> Option { + Self::new(ty, Width::of(v)?) + } + + /// The logical type, if any. + pub const fn logical(self) -> Option { + self.ty + } + + /// The physical width. + pub const fn width(self) -> Width { + self.width + } +} + // `Physical` and `Float` derive their constant-time equality: the derive // compares same-variant payloads with `ts_eq` and treats different // variants as unequal, which is the structural equality wanted here. The @@ -748,6 +871,57 @@ impl fmt::Display for Logical { mod tests { use super::*; + #[test] + fn a_fixed_type_is_normalised_to_how_its_leaf_is_written() { + let fixed = |ty, width| FixedType::new(ty, width).expect("fixed width"); + // A tag of its own fixes the width, and a decimal's scale. + assert_eq!( + fixed(Some(Logical::Date), Width::I64), + fixed(Some(Logical::Date), Width::I32) + ); + assert_eq!(fixed(Some(Logical::Date), Width::U8).width(), Width::I32); + let ns = Some(Logical::Timestamp(TimeUnit::Nanos)); + assert_eq!(fixed(ns, Width::I64).width(), Width::I128); + assert_eq!( + fixed(Some(Logical::Decimal { scale: 2 }), Width::I64), + fixed(Some(Logical::Decimal { scale: 0 }), Width::I128) + ); + // Through the envelope, the width is the value's own. + let ms = Some(Logical::Timestamp(TimeUnit::Millis)); + assert_eq!(fixed(ms, Width::I64).width(), Width::I64); + assert_ne!(fixed(ms, Width::I64), fixed(ms, Width::I32)); + let unknown = Logical::from_wire(200, 9); + assert_eq!(fixed(unknown, Width::F32).logical(), unknown); + assert_eq!(FixedType::plain(Width::U8).logical(), None); + } + + #[test] + fn a_type_not_over_a_fixed_width_physical_type_has_no_fixed_type() { + assert_eq!(FixedType::new(Some(Logical::Uuid), Width::U128), None); + assert_eq!(FixedType::new(Some(Logical::Undefined), Width::U8), None); + assert_eq!(FixedType::new(Some(Logical::Date), Width::F64), None); + assert_eq!( + FixedType::new(Some(Logical::Timestamp(TimeUnit::Millis)), Width::F64), + None + ); + assert_eq!( + FixedType::new(Some(Logical::Decimal { scale: 2 }), Width::F32), + None + ); + for v in [ + Physical::Null, + Physical::Bool(true), + Physical::Text("x"), + Physical::Bytes(&[1]), + ] { + assert_eq!(Width::of(&v), None); + assert_eq!(FixedType::of(None, &v), None); + } + assert_eq!(Width::of(&Physical::Int(Int::U16(1))), Some(Width::U16)); + assert_eq!(Width::F32.kind(), Kind::Float); + assert_eq!(Width::I128.kind(), Kind::Int); + } + #[test] fn every_known_logical_type_round_trips_through_the_wire() { let known = [ diff --git a/packages/aead/src/plaintext/mod.rs b/packages/aead/src/plaintext/mod.rs index f75751d9..92289c80 100644 --- a/packages/aead/src/plaintext/mod.rs +++ b/packages/aead/src/plaintext/mod.rs @@ -26,7 +26,8 @@ mod sources; mod tee; pub use kinds::{ - Float, Int, Kind, KnownLogical, Logical, Physical, TimeUnit, UnknownLogical, UnknownLogicalId, + FixedType, Float, Int, Kind, KnownLogical, Logical, Physical, TimeUnit, UnknownLogical, + UnknownLogicalId, Width, }; #[cfg(any(test, feature = "test-support"))] #[doc(hidden)] @@ -89,6 +90,21 @@ pub trait Encrypt { /// value of the type can nest, or a type that is fine fails to compile. const DEPTH: Option = None; + /// The fixed-width scalar type every value of this type has, if it has + /// one: an int, a float, a date, a timestamp, a decimal. `None`, the + /// default, for everything else: a bool (whose leaf is already as short + /// as a null's), text, bytes, a container, a value whose type depends + /// on the value. + /// + /// `Option` walks its `None` as a [`Sink::typed_null`] of this + /// type, so the encrypting sink seals it padded to the length of a + /// `Some`, and [`Decrypt`](crate::Decrypt) reads that typed null back + /// only into an `Option` of the same type. It must be the type + /// [`visit`](Encrypt::visit) always walks, normalised as + /// [`FixedType`] normalises it, or a `None` and a `Some` seal at + /// different lengths. + const FIXED: Option = None; + /// Describe `self` to `sink`, consuming it. fn visit(self, sink: S) -> Result; } @@ -176,6 +192,21 @@ pub trait Sink: Sized { /// ``` fn scalar(self, ty: Option, value: Physical<'_>) -> Result; + /// An absent value of a fixed-width type: a null that says what it + /// stands in for. `Option` walks its `None` here when `T` has a + /// [`FIXED`](Encrypt::FIXED) type, and a host that knows a missing + /// value's type does the same. + /// + /// It means what `scalar(None, Physical::Null)` means; the type is + /// there so its length can be hidden. The encrypting sink seals it as a + /// typed null padded to the type's leaf length (see + /// [`tags::TYPED_NULL`](crate::tags::TYPED_NULL)), the frame carries + /// it, and a typed decode target reads it only as an `Option` of the + /// same type. A sink that only needs to know a value is absent treats + /// it as a null. A sink that forwards to other sinks must forward it as + /// it is, or the padding is lost and absent values show again. + fn typed_null(self, ty: FixedType) -> Result; + /// Start a sequence. `len` is a hint; a source that does not know its /// length passes `None`. fn seq(self, len: Option) -> Result; @@ -281,6 +312,7 @@ pub(crate) mod record { #[derive(Debug, Clone, PartialEq)] pub enum Event { Scalar(Option, Owned), + TypedNull(FixedType), Seq(Option), Element, EndSeq, @@ -338,6 +370,11 @@ pub(crate) mod record { Ok(()) } + fn typed_null(self, ty: FixedType) -> Result<(), RecordError> { + self.0.push(Event::TypedNull(ty)); + Ok(()) + } + fn seq(self, len: Option) -> Result { self.0.push(Event::Seq(len)); Ok(self) diff --git a/packages/aead/src/plaintext/sources.rs b/packages/aead/src/plaintext/sources.rs index 1f248f0a..31af4a36 100644 --- a/packages/aead/src/plaintext/sources.rs +++ b/packages/aead/src/plaintext/sources.rs @@ -12,22 +12,23 @@ use std::collections::{BTreeMap, HashMap}; use vitaminc_protected::{Controlled, OpaqueDebug, Protected}; use zeroize::Zeroize; -use super::kinds::{Float, Logical, Physical}; +use super::kinds::{FixedType, Float, Logical, Physical, Width}; use super::{depth, visit_seq, Encrypt, MapSink, SeqSink, Sink}; macro_rules! int { - ($($ty:ty),* $(,)?) => { + ($($ty:ty => $width:ident),* $(,)?) => { $( impl Encrypt for $ty { const DEPTH: Option = depth::SCALAR; + const FIXED: Option = Some(FixedType::plain(Width::$width)); fn visit(self, sink: S) -> Result { sink.scalar(None, Physical::Int(self.into())) } } impl Encrypt for &$ty { - const DEPTH: Option = depth::SCALAR; + const FIXED: Option = <$ty as Encrypt>::FIXED; fn visit(self, sink: S) -> Result { (*self).visit(sink) } @@ -36,7 +37,10 @@ macro_rules! int { }; } -int!(i8, i16, i32, i64, i128, u16, u32, u64, u128); +int! { + i8 => I8, i16 => I16, i32 => I32, i64 => I64, i128 => I128, + u16 => U16, u32 => U32, u64 => U64, u128 => U128, +} impl Encrypt for bool { const DEPTH: Option = depth::SCALAR; @@ -47,6 +51,7 @@ impl Encrypt for bool { impl Encrypt for f32 { const DEPTH: Option = depth::SCALAR; + const FIXED: Option = Some(FixedType::plain(Width::F32)); fn visit(self, sink: S) -> Result { sink.scalar(None, Physical::Float(Float::F32(self))) } @@ -54,6 +59,7 @@ impl Encrypt for f32 { impl Encrypt for f64 { const DEPTH: Option = depth::SCALAR; + const FIXED: Option = Some(FixedType::plain(Width::F64)); fn visit(self, sink: S) -> Result { sink.scalar(None, Physical::Float(Float::F64(self))) } @@ -66,6 +72,7 @@ macro_rules! by_ref { $( impl Encrypt for &$ty { const DEPTH: Option = depth::SCALAR; + const FIXED: Option = <$ty as Encrypt>::FIXED; fn visit(self, sink: S) -> Result { (*self).visit(sink) } @@ -188,6 +195,7 @@ where &'b T: Encrypt, { const DEPTH: Option = <&'b T as Encrypt>::DEPTH; + const FIXED: Option = <&'b T as Encrypt>::FIXED; fn visit(self, sink: S) -> Result { (*self).visit(sink) } @@ -195,12 +203,18 @@ where /// `None` is a null. There is no separate "absent" in the data model, so a /// host null and a Rust `None` are the same leaf. +/// +/// When `T` has a [`FIXED`](Encrypt::FIXED) type, `None` is a typed null +/// of it, which the encrypting sink pads to the length of a `Some`. +/// Otherwise it is the plain null. An `Option>` has `T`'s type +/// too, so its two `None`s seal alike, as they always have. impl Encrypt for Option { const DEPTH: Option = T::DEPTH; + const FIXED: Option = T::FIXED; fn visit(self, sink: S) -> Result { match self { Some(v) => v.visit(sink), - None => sink.scalar(None, Physical::Null), + None => null::(sink), } } } @@ -210,14 +224,23 @@ where &'a T: Encrypt, { const DEPTH: Option = <&'a T as Encrypt>::DEPTH; + const FIXED: Option = <&'a T as Encrypt>::FIXED; fn visit(self, sink: S) -> Result { match self { Some(v) => v.visit(sink), - None => sink.scalar(None, Physical::Null), + None => null::<&'a T, S>(sink), } } } +/// The null an absent `T` walks as: typed if `T` is fixed width. +fn null(sink: S) -> Result { + match T::FIXED { + Some(ty) => sink.typed_null(ty), + None => sink.scalar(None, Physical::Null), + } +} + /// A protected value walks as a borrow of what it wraps, so nothing leaves /// the wrapper during the walk, and everything in it, visited or not, is /// zeroized when the wrapper drops at the end of it. @@ -226,6 +249,7 @@ where for<'a> &'a T: Encrypt, { const DEPTH: Option = <&T as Encrypt>::DEPTH; + const FIXED: Option = <&T as Encrypt>::FIXED; fn visit(self, sink: S) -> Result { self.risky_ref().visit(sink) // `self` drops here and zeroizes the whole value. @@ -239,6 +263,7 @@ where &'a T: Encrypt, { const DEPTH: Option = <&'a T as Encrypt>::DEPTH; + const FIXED: Option = <&'a T as Encrypt>::FIXED; fn visit(self, sink: S) -> Result { self.risky_ref().visit(sink) } @@ -316,6 +341,10 @@ impl Sink for Discard { Ok(()) } + fn typed_null(self, _: FixedType) -> Result<(), Discarded> { + Ok(()) + } + fn seq(self, _: Option) -> Result { Ok(Discard) } @@ -442,6 +471,7 @@ mod chrono_sources { impl Encrypt for NaiveDate { const DEPTH: Option = depth::SCALAR; + const FIXED: Option = FixedType::new(Some(Logical::Date), Width::I32); fn visit(self, sink: S) -> Result { sink.scalar( Some(Logical::Date), @@ -455,6 +485,8 @@ mod chrono_sources { /// field refuses it when the value is built instead. impl Encrypt for DateTime { const DEPTH: Option = depth::SCALAR; + const FIXED: Option = + FixedType::new(Some(Logical::Timestamp(TimeUnit::Nanos)), Width::I128); fn visit(self, sink: S) -> Result { let nanos = conv::timestamp_nanos(self).ok_or_else(|| { S::Error::source(SourceError::msg( @@ -470,6 +502,7 @@ mod chrono_sources { impl Encrypt for &NaiveDate { const DEPTH: Option = depth::SCALAR; + const FIXED: Option = NaiveDate::FIXED; fn visit(self, sink: S) -> Result { (*self).visit(sink) } @@ -477,6 +510,7 @@ mod chrono_sources { impl Encrypt for &DateTime { const DEPTH: Option = depth::SCALAR; + const FIXED: Option = as Encrypt>::FIXED; fn visit(self, sink: S) -> Result { (*self).visit(sink) } @@ -486,6 +520,9 @@ mod chrono_sources { #[cfg(feature = "rust_decimal")] impl Encrypt for rust_decimal::Decimal { const DEPTH: Option = depth::SCALAR; + /// Every scale shares the one typed null: the scale is in the payload. + const FIXED: Option = + FixedType::new(Some(Logical::Decimal { scale: 0 }), Width::I128); fn visit(self, sink: S) -> Result { let (scale, mantissa) = super::kinds::decimal::parts(self); sink.scalar( @@ -498,6 +535,7 @@ impl Encrypt for rust_decimal::Decimal { #[cfg(feature = "rust_decimal")] impl Encrypt for &rust_decimal::Decimal { const DEPTH: Option = depth::SCALAR; + const FIXED: Option = ::FIXED; fn visit(self, sink: S) -> Result { (*self).visit(sink) } @@ -742,6 +780,10 @@ mod tests { Ok(()) } + fn typed_null(self, _: FixedType) -> Result<(), RecordError> { + Ok(()) + } + fn seq(self, _: Option) -> Result { Ok(self) } @@ -863,7 +905,11 @@ mod tests { record(&Some(1.5f64)).expect("ok"), record(Some(1.5f64)).expect("ok") ); - assert_eq!(record(&None::).expect("ok"), [scalar(Owned::Null)]); + assert_eq!( + record(&None::).expect("ok"), + [Event::TypedNull(FixedType::plain(Width::I8))] + ); + assert_eq!(record(&None::<&str>).expect("ok"), [scalar(Owned::Null)]); // A borrowed `Vec` is still a byte leaf, not a sequence. assert_eq!( record(&vec![1u8, 2]).expect("ok"), @@ -886,13 +932,34 @@ mod tests { #[test] fn option_none_is_null() { - assert_eq!(record(None::).expect("ok"), [scalar(Owned::Null)]); + assert_eq!(record(None::).expect("ok"), [scalar(Owned::Null)]); + assert_eq!(record(None::).expect("ok"), [scalar(Owned::Null)]); + assert_eq!(record(None::>).expect("ok"), [scalar(Owned::Null)]); assert_eq!( record(Some(1u32)).expect("ok"), [scalar(Owned::Int(Int::U32(1)))] ); } + /// A fixed-width `None` names its type, at every layer that wraps it. + // The borrows are the point: each one picks the reference impl. + #[allow(clippy::needless_borrows_for_generic_args)] + #[test] + fn a_fixed_width_none_is_a_typed_null() { + let typed = |w| [Event::TypedNull(FixedType::plain(w))]; + assert_eq!(record(None::).expect("ok"), typed(Width::U32)); + assert_eq!(record(None::).expect("ok"), typed(Width::I128)); + assert_eq!(record(None::).expect("ok"), typed(Width::F32)); + assert_eq!(record(&None::).expect("ok"), typed(Width::F64)); + assert_eq!(record(None::>).expect("ok"), typed(Width::I16)); + assert_eq!(record(Some(None::)).expect("ok"), typed(Width::I16)); + assert_eq!( + record(Protected::new(None::)).expect("ok"), + typed(Width::U64) + ); + assert_eq!(record(&&None::).expect("ok"), typed(Width::I64)); + } + #[test] fn containers_push_each_child_into_its_own_sink() { let mut map = BTreeMap::new(); diff --git a/packages/aead/src/plaintext/tee.rs b/packages/aead/src/plaintext/tee.rs index 8db2505a..163c0a73 100644 --- a/packages/aead/src/plaintext/tee.rs +++ b/packages/aead/src/plaintext/tee.rs @@ -4,7 +4,7 @@ //! source as one sink, so both consumers receive every call in the same //! order. Any sinks with the same error type compose, at any depth. -use super::kinds::{Logical, Physical}; +use super::kinds::{FixedType, Logical, Physical}; use super::{Encrypt, MapSink, SeqSink, Sink}; /// A pair of sinks that forwards every call to both and pairs the results. @@ -41,6 +41,12 @@ impl> Sink for Tee { .and_then(|a| b.scalar(ty, value).map(|b| (a, b))) } + fn typed_null(self, ty: FixedType) -> Result { + let (a, b) = (self.0, self.1); + a.typed_null(ty) + .and_then(|a| b.typed_null(ty).map(|b| (a, b))) + } + fn seq(self, len: Option) -> Result { let (a, b) = (self.0, self.1); a.seq(len).and_then(|a| b.seq(len).map(|b| Tee(a, b))) @@ -159,6 +165,18 @@ mod tests { assert!(matches!(left[0], Event::Scalar(Some(t), _) if t.to_wire() == (200, 9))); } + #[test] + fn a_typed_null_reaches_both_sides_as_a_typed_null() { + use crate::plaintext::kinds::Width; + use crate::plaintext::record::Event; + let (mut left, mut right) = (Vec::new(), Vec::new()); + None:: + .visit(Tee(Record(&mut left), Record(&mut right))) + .expect("ok"); + assert_eq!(left, right); + assert_eq!(left, [Event::TypedNull(FixedType::plain(Width::I32))]); + } + #[test] fn visit_map_walks_each_entry_into_its_own_sink() { use crate::plaintext::record::{Event, Owned}; @@ -217,6 +235,9 @@ mod tests { fn scalar(self, _: Option, _: Physical<'_>) -> Result<(), RecordError> { Err(RecordError("refused".into())) } + fn typed_null(self, _: FixedType) -> Result<(), RecordError> { + Err(RecordError("refused".into())) + } fn seq(self, _: Option) -> Result { Ok(self) } diff --git a/packages/aead/src/seal.rs b/packages/aead/src/seal.rs index b2e145d8..92278509 100644 --- a/packages/aead/src/seal.rs +++ b/packages/aead/src/seal.rs @@ -512,9 +512,11 @@ mod tests { leaves[4].1, "ctx".into_aad().for_map_entry("c").for_sequence_element() ); - // The marker is empty; the null leaf is a NULL tag. + // The marker is empty; the absent `u32` is a typed null, as long + // as the `u32` leaves beside it. assert!(leaves[3].0.plaintext.risky_ref().is_empty()); - assert_eq!(leaves[1].0.plaintext.risky_ref(), &[0x00]); + assert_eq!(leaves[1].0.plaintext.risky_ref(), &[0x15, 0x06, 0, 0, 0]); + assert_eq!(leaves[0].0.plaintext.risky_ref().len(), 5); // And the plan's AAD is what the sealed leaf is bound under. let sealed = encrypt(sample(), &Mock, "ctx").expect("encrypts"); let sealed = leaves_with_aad(sealed, &"ctx".into_aad()); diff --git a/packages/aead/src/tags.rs b/packages/aead/src/tags.rs index 8df1ea8d..f61decaf 100644 --- a/packages/aead/src/tags.rs +++ b/packages/aead/src/tags.rs @@ -89,6 +89,21 @@ pub const TIMESTAMP: u8 = 0x13; /// [`Logical::Decimal`](crate::Logical) over an int holding the mantissa, /// so a negative zero reads as zero (see [`crate::leaf`]). pub const DECIMAL: u8 = 0x14; +/// Typed null: an absent value of a fixed-width type, padded to the length +/// of that type's leaf so a sealed absence cannot be told from a sealed +/// value by its length. `TYPED_NULL`, then the header a value of the type +/// starts with (its own tag, or the [`LOGICAL`] envelope's `LOGICAL, id, +/// param, tag`), then zero bytes for all but one byte of its payload. A +/// null date is `[TYPED_NULL, DATE, 0, 0, 0]`, five bytes like +/// `[DATE, d0, d1, d2, d3]`. +/// +/// Only an int or a float, plain or under a logical type, has a typed null +/// (see [`FixedType`](crate::FixedType)). A bool's leaf is the tag alone, +/// already as short as [`NULL`]; text and bytes are variable width. A +/// reader refuses a typed null of any other tag, an envelope where a +/// shorthand tag exists, and padding that is not zero, so every absence +/// has one encoding. [`NULL`] stays a valid absence of any type. +pub const TYPED_NULL: u8 = 0x15; /// The generic logical-type envelope: `LOGICAL, id, param`, then the plain /// physical scalar. Carries any logical type that has no tag of its own. diff --git a/packages/aead/src/timestamp.rs b/packages/aead/src/timestamp.rs index faa09bed..6070d8fa 100644 --- a/packages/aead/src/timestamp.rs +++ b/packages/aead/src/timestamp.rs @@ -4,7 +4,7 @@ use ::chrono::{DateTime, Utc}; use vitaminc_protected::{OpaqueDebug, TimingSafeEq}; use zeroize::{Zeroize, ZeroizeOnDrop}; -use crate::plaintext::{depth, Encrypt, Int, Logical, Physical, Sink, TimeUnit}; +use crate::plaintext::{depth, Encrypt, FixedType, Int, Logical, Physical, Sink, TimeUnit, Width}; use crate::typed::FromScalar; /// A UTC instant that is never a leap second, so it can always be encrypted. @@ -110,6 +110,7 @@ impl From for DateTime { /// sake. impl Encrypt for Timestamp { const DEPTH: Option = depth::SCALAR; + const FIXED: Option = <&Timestamp as Encrypt>::FIXED; fn visit(self, sink: S) -> Result { (&self).visit(sink) } @@ -117,6 +118,8 @@ impl Encrypt for Timestamp { impl Encrypt for &Timestamp { const DEPTH: Option = depth::SCALAR; + const FIXED: Option = + FixedType::new(Some(Logical::Timestamp(TimeUnit::Nanos)), Width::I128); fn visit(self, sink: S) -> Result { sink.scalar( Some(Logical::Timestamp(TimeUnit::Nanos)), @@ -128,6 +131,7 @@ impl Encrypt for &Timestamp { /// Any unit, as for `DateTime`. A decoded count is never a leap second. impl FromScalar for Timestamp { const EXPECTED: &'static str = "timestamp"; + const FIXED: Option = ::FIXED; fn from_scalar(ty: Option, value: Physical<'_>) -> Option { DateTime::::from_scalar(ty, value).and_then(|instant| Self::new(instant).ok()) } diff --git a/packages/aead/src/typed.rs b/packages/aead/src/typed.rs index 6f7532e3..3602820d 100644 --- a/packages/aead/src/typed.rs +++ b/packages/aead/src/typed.rs @@ -9,7 +9,10 @@ //! Integer targets read the value, not the width: a `u32` accepts an `i8` //! leaf holding 34 and refuses one holding -1. The width is a host-fidelity //! hint. `Option` reads null and `undefined` as `None` and defers -//! anything else to `T`. `HashMap` and `BTreeMap` +//! anything else to `T`. A typed null (see [`Sink::typed_null`]) is `None` +//! only for an `Option` whose `T` has that same +//! [`FIXED`](Decrypt::FIXED) type, the one its own `None` seals as; any +//! other target refuses it as the wrong kind, never reads it as a zero. `HashMap` and `BTreeMap` //! read a map, each value through `T`'s sink. //! //! `Protected` is a target for every `T` that implements @@ -39,7 +42,7 @@ use vitaminc_protected::{Controlled, Protected}; use zeroize::Zeroize; use crate::plaintext::{ - Encrypt, Error, Float, Logical, MapSink, Physical, SeqSink, Sink, SourceError, + Encrypt, Error, FixedType, Float, Logical, MapSink, Physical, SeqSink, Sink, SourceError, Width, }; use crate::seal::{AsyncCipher, Cipher, Node}; use crate::walk::{decrypt_into, decrypt_into_async, DecryptError}; @@ -96,6 +99,13 @@ impl Error for DecodeError { /// A type that can be decrypted: it names the sink that builds it. pub trait Decrypt: Sized { type Sink: Sink; + + /// The fixed-width type whose typed null an `Option` reads as + /// `None`: the [`Encrypt::FIXED`] of the same type, so a `None` reads + /// back as it was sealed. `None`, the default, for a type with none, + /// whose `Option` then refuses every typed null. + const FIXED: Option = None; + fn sink() -> Self::Sink; } @@ -132,6 +142,8 @@ where pub trait FromScalar: Sized { /// What the type is called in a refusal. const EXPECTED: &'static str; + /// The type's [`Decrypt::FIXED`]. + const FIXED: Option = None; fn from_scalar(ty: Option, value: Physical<'_>) -> Option; /// Decode straight into `Protected`: the seam `Protected`'s target @@ -160,6 +172,11 @@ impl Sink for ScalarSink { T::from_scalar(ty, v).ok_or(DecodeError::Expected(T::EXPECTED)) } + /// An absent value is not a value, whatever its type. + fn typed_null(self, _: FixedType) -> Result { + Err(DecodeError::Expected(T::EXPECTED)) + } + fn seq(self, _: Option) -> Result, DecodeError> { Err(DecodeError::Expected(T::EXPECTED)) } @@ -175,6 +192,7 @@ impl Sink for ScalarSink { impl Decrypt for T { type Sink = ScalarSink; + const FIXED: Option = T::FIXED; fn sink() -> ScalarSink { ScalarSink(PhantomData) } @@ -195,6 +213,10 @@ impl Sink for Nothing { match self {} } + fn typed_null(self, _: FixedType) -> Result<(), DecodeError> { + match self {} + } + fn seq(self, _: Option) -> Result, DecodeError> { match self {} } @@ -257,10 +279,11 @@ impl MapSink for Never { } macro_rules! int_target { - ($($ty:ty),* $(,)?) => { + ($($ty:ty => $width:ident),* $(,)?) => { $( impl FromScalar for $ty { const EXPECTED: &'static str = stringify!($ty); + const FIXED: Option = Some(FixedType::plain(Width::$width)); fn from_scalar(ty: Option, v: Physical<'_>) -> Option { match (ty, v) { (None, Physical::Int(i)) => i @@ -277,7 +300,10 @@ macro_rules! int_target { // `u8` is not a target for the same reason it is not a source: `Vec` is // a byte leaf, and the generic `Vec` sequence target must not overlap it. -int_target!(i8, i16, i32, i64, i128, u16, u32, u64, u128); +int_target! { + i8 => I8, i16 => I16, i32 => I32, i64 => I64, i128 => I128, + u16 => U16, u32 => U32, u64 => U64, u128 => U128, +} impl FromScalar for bool { const EXPECTED: &'static str = "bool"; @@ -291,6 +317,7 @@ impl FromScalar for bool { impl FromScalar for f32 { const EXPECTED: &'static str = "f32"; + const FIXED: Option = Some(FixedType::plain(Width::F32)); fn from_scalar(ty: Option, v: Physical<'_>) -> Option { match (ty, v) { (None, Physical::Float(Float::F32(f))) => Some(f), @@ -302,6 +329,7 @@ impl FromScalar for f32 { /// Widening from `f32` is exact, so an `f64` target accepts both widths. impl FromScalar for f64 { const EXPECTED: &'static str = "f64"; + const FIXED: Option = Some(FixedType::plain(Width::F64)); fn from_scalar(ty: Option, v: Physical<'_>) -> Option { match (ty, v) { (None, Physical::Float(Float::F64(f))) => Some(f), @@ -363,6 +391,7 @@ mod chrono_targets { impl FromScalar for NaiveDate { const EXPECTED: &'static str = "date"; + const FIXED: Option = ::FIXED; fn from_scalar(ty: Option, v: Physical<'_>) -> Option { match (ty, v) { (Some(Logical::Date), Physical::Int(days)) => conv::date(days), @@ -375,6 +404,7 @@ mod chrono_targets { /// Rust timestamp in nanoseconds. impl FromScalar for DateTime { const EXPECTED: &'static str = "timestamp"; + const FIXED: Option = as Encrypt>::FIXED; fn from_scalar(ty: Option, v: Physical<'_>) -> Option { match (ty, v) { (Some(Logical::Timestamp(unit)), Physical::Int(count)) => { @@ -389,6 +419,7 @@ mod chrono_targets { #[cfg(feature = "rust_decimal")] impl FromScalar for rust_decimal::Decimal { const EXPECTED: &'static str = "decimal"; + const FIXED: Option = ::FIXED; fn from_scalar(ty: Option, v: Physical<'_>) -> Option { match (ty, v) { (Some(Logical::Decimal { scale }), Physical::Int(mantissa)) => { @@ -463,6 +494,11 @@ impl> Sink for Forward { Ok(()) } + fn typed_null(self, ty: FixedType) -> Result<(), DecodeError> { + self.1.put(self.0.typed_null(ty)?); + Ok(()) + } + fn seq(self, len: Option) -> Result { Ok(ForwardEnd(self.0.seq(len)?, self.1, PhantomData)) } @@ -541,6 +577,13 @@ impl> Sink for Either { } } + fn typed_null(self, ty: FixedType) -> Result { + match self { + Either::Left(l) => l.typed_null(ty).map(Either::Left), + Either::Right(r) => r.typed_null(ty).map(Either::Right), + } + } + fn seq(self, len: Option) -> Result { match self { Either::Left(l) => l.seq(len).map(Either::Left), @@ -633,6 +676,10 @@ impl> Sink for Clear { Err(DecodeError::Expected("passthrough")) } + fn typed_null(self, _: FixedType) -> Result<(), DecodeError> { + Err(DecodeError::Expected("passthrough")) + } + fn seq(self, _: Option) -> Result, DecodeError> { Err(DecodeError::Expected("passthrough")) } @@ -676,6 +723,10 @@ impl, W: Rewrap> Sink for Wrapped { self.0.scalar(ty, v).and_then(W::rewrap) } + fn typed_null(self, ty: FixedType) -> Result { + self.0.typed_null(ty).and_then(W::rewrap) + } + fn seq(self, len: Option) -> Result { Ok(WrappedEnd(self.0.seq(len)?, PhantomData)) } @@ -739,6 +790,10 @@ impl Sink for List { Err(DecodeError::Expected("sequence")) } + fn typed_null(self, _: FixedType) -> Result, DecodeError> { + Err(DecodeError::Expected("sequence")) + } + fn seq(self, len: Option) -> Result, DecodeError> { Ok(ListSeq(Vec::with_capacity(len.unwrap_or(0).min(1024)))) } @@ -798,6 +853,10 @@ macro_rules! dict_target { Err(DecodeError::Expected("map")) } + fn typed_null(self, _: FixedType) -> Result { + Err(DecodeError::Expected("map")) + } + fn seq(self, _: Option) -> Result { Err(DecodeError::Expected("map")) } @@ -841,6 +900,8 @@ macro_rules! dict_target { dict_target!(HashMap, BTreeMap); /// `Option`: null and `undefined` are `None`; anything else is `T`. +/// A typed null is `None` if it is of `T`'s [`FIXED`](Decrypt::FIXED) +/// type, and otherwise refused by `T`'s sink as the wrong kind. /// /// Since `None` is written as null, `Option>` cannot tell /// `Some(None)` from `None`: both read back as `None`. @@ -859,6 +920,13 @@ impl Sink for Optional { } } + fn typed_null(self, ty: FixedType) -> Result, DecodeError> { + if T::FIXED == Some(ty) { + return Ok(None); + } + self.0.typed_null(ty).map(Some) + } + fn seq(self, len: Option) -> Result { Ok(Some_(self.0.seq(len)?, PhantomData)) } @@ -911,6 +979,7 @@ impl> MapSink for Some_ { impl Decrypt for Option { type Sink = Optional; + const FIXED: Option = T::FIXED; fn sink() -> Optional { Optional(T::sink()) } @@ -931,6 +1000,7 @@ pub trait DecryptProtected: Decrypt + Zeroize { impl Decrypt for Protected { type Sink = T::ProtectedSink; + const FIXED: Option = T::FIXED; fn sink() -> T::ProtectedSink { T::protected_sink() } @@ -962,6 +1032,10 @@ impl Sink for ScalarProtected { T::from_scalar_protected(ty, v).ok_or(DecodeError::Expected(T::EXPECTED)) } + fn typed_null(self, _: FixedType) -> Result, DecodeError> { + Err(DecodeError::Expected(T::EXPECTED)) + } + fn seq(self, _: Option) -> Result { Err(DecodeError::Expected(T::EXPECTED)) } @@ -1001,6 +1075,10 @@ impl Sink for ProtectedList { Err(DecodeError::Expected("sequence")) } + fn typed_null(self, _: FixedType) -> Result { + Err(DecodeError::Expected("sequence")) + } + fn seq(self, len: Option) -> Result, DecodeError> { let items = Vec::with_capacity(len.unwrap_or(0).min(1024)); Ok(ProtectedListSeq(Protected::new(items))) @@ -1082,6 +1160,13 @@ impl Sink for ProtectedOptional { } } + fn typed_null(self, ty: FixedType) -> Result { + if T::FIXED == Some(ty) { + return Ok(Protected::new(None)); + } + self.0.typed_null(ty).map(some) + } + fn seq(self, len: Option) -> Result { Ok(SomeProtected(self.0.seq(len)?, PhantomData)) } @@ -1124,6 +1209,10 @@ where self.0.scalar(ty, v).map(Protected::new) } + fn typed_null(self, ty: FixedType) -> Result { + self.0.typed_null(ty).map(Protected::new) + } + fn seq(self, len: Option) -> Result { Ok(WrapEnd(self.0.seq(len)?)) } @@ -1329,6 +1418,114 @@ mod tests { ); } + /// A typed null as a host would describe it. + struct Null(FixedType); + + impl Encrypt for Null { + fn visit(self, sink: S) -> Result { + sink.typed_null(self.0) + } + } + + #[test] + fn a_typed_null_reads_as_none_only_for_an_option_of_its_type() { + // Sealed by `Option`'s own `None`, and read back by it. + assert_eq!(round_trip::>(None::).expect("u32"), None); + assert_eq!(round_trip::>(None::).expect("i8"), None); + assert_eq!(round_trip::>(None::).expect("f32"), None); + assert_eq!( + round_trip::>>(None::).expect("nested"), + None + ); + assert_eq!( + round_trip::>>(vec![None, Some(3u16)]).expect("elements"), + [None, Some(3)] + ); + // Any other type's typed null is the wrong kind, never a `None`: + // not another width (even one whose values the target reads), not + // a float for an int, not a logical type over the same int. + let u32_null = || Null(FixedType::plain(Width::U32)); + assert_eq!(expected(round_trip::>(u32_null())), "u64"); + assert_eq!(expected(round_trip::>(u32_null())), "f32"); + assert_eq!(expected(round_trip::>(u32_null())), "string"); + assert_eq!( + expected(round_trip::>(Null(FixedType::plain( + Width::F32 + )))), + "f64" + ); + let date = FixedType::new(Some(Logical::Date), Width::I32).expect("fixed"); + assert_eq!(expected(round_trip::>(Null(date))), "i32"); + assert_eq!( + expected(round_trip::>>(u32_null())), + "sequence" + ); + } + + #[test] + fn a_typed_null_is_not_a_value() { + assert_eq!(expected(round_trip::(None::)), "u32"); + assert_eq!(expected(round_trip::(None::)), "f64"); + assert_eq!(expected(round_trip::>(None::)), "sequence"); + assert_eq!( + expected(round_trip::>(None::)), + "map" + ); + assert_eq!(expected(round_trip::>(None::)), "u32"); + } + + #[test] + fn the_plain_null_still_reads_as_none_for_any_option() { + // What every writer sealed before typed nulls, and still seals for + // a type with no fixed width. + assert_eq!( + round_trip::>(Raw(None, Physical::Null)).expect("u32"), + None + ); + assert_eq!( + round_trip::>>(Raw(None, Physical::Null)) + .expect("protected") + .risky_ref(), + &None + ); + } + + #[test] + fn a_protected_option_reads_its_typed_null() { + assert_eq!( + round_trip::>>(None::) + .expect("none") + .risky_ref(), + &None + ); + assert_eq!( + expected(round_trip::>>(None::)), + "u64" + ); + } + + #[test] + fn decrypt_and_encrypt_agree_on_every_fixed_type() { + fn same() { + assert_eq!(::FIXED, ::FIXED); + } + same::(); + same::(); + same::(); + same::(); + same::(); + same::(); + same::(); + same::(); + same::(); + same::(); + same::(); + same::(); + same::(); + same::>(); + same::>(); + } + #[test] fn option_reads_null_and_undefined_as_none() { assert_eq!(round_trip::>(None::).expect("none"), None); @@ -1912,6 +2109,10 @@ mod tests { Err(DecodeError::Expected("point")) } + fn typed_null(self, _: FixedType) -> Result { + Err(DecodeError::Expected("point")) + } + fn seq(self, _: Option) -> Result, DecodeError> { Err(DecodeError::Expected("point")) } diff --git a/packages/aead/src/walk.rs b/packages/aead/src/walk.rs index 31cbe226..50f91960 100644 --- a/packages/aead/src/walk.rs +++ b/packages/aead/src/walk.rs @@ -19,7 +19,7 @@ use std::collections::HashSet; use vitaminc_protected::{Controlled, Protected}; -use crate::leaf; +use crate::leaf::{self, Item}; use crate::plaintext::{Error, MapSink, SeqSink, Sink, SourceError}; use crate::seal::{open_all, open_all_async, AsyncCipher, Cipher, Node, OpenError, Opened}; use crate::{CipherText, IntoAad}; @@ -139,9 +139,12 @@ pub fn walk(node: Node, sink: S) -> Result { - let (ty, v) = - leaf::read_leaf(plaintext.risky_ref()).map_err(|_| DecryptError::Malformed)?; - Ok(sink.scalar(ty, v)?) + let item = + leaf::read_leaf_item(plaintext.risky_ref()).map_err(|_| DecryptError::Malformed)?; + Ok(match item { + Item::Scalar(ty, v) => sink.scalar(ty, v)?, + Item::TypedNull(ty) => sink.typed_null(ty)?, + }) // `plaintext` drops and wipes here. } CipherText::Sequence(items) => walk_seq(items, sink), diff --git a/packages/aead/tests/derive_sink.rs b/packages/aead/tests/derive_sink.rs index b02f2432..d08a2b4a 100644 --- a/packages/aead/tests/derive_sink.rs +++ b/packages/aead/tests/derive_sink.rs @@ -6,8 +6,8 @@ use std::collections::BTreeMap; use vitaminc_aead::{ - decrypt, encrypt, Bytes, Context, DecodeError, Decrypt, DecryptError, Encrypt, Int, IntoAad, - Logical, MapSink, Physical, Raw, SeqSink, Sink, SourceError, + decrypt, encrypt, Bytes, Context, DecodeError, Decrypt, DecryptError, Encrypt, FixedType, Int, + IntoAad, Logical, MapSink, Physical, Raw, SeqSink, Sink, SourceError, }; use vitaminc_protected::{Controlled, Protected}; use zeroize::ZeroizeOnDrop; @@ -18,6 +18,7 @@ use zeroize::ZeroizeOnDrop; #[derive(Debug, Clone, PartialEq)] enum Event { Scalar(Option, String), + TypedNull(FixedType), Seq(Option), Element, EndSeq, @@ -57,6 +58,11 @@ impl Sink for Record<'_> { Ok(()) } + fn typed_null(self, ty: FixedType) -> Result<(), RecordError> { + self.0.push(Event::TypedNull(ty)); + Ok(()) + } + fn seq(self, len: Option) -> Result { self.0.push(Event::Seq(len)); Ok(self) @@ -834,6 +840,10 @@ impl Sink for StopAt { Ok(()) } + fn typed_null(self, _: FixedType) -> Result<(), RecordError> { + Ok(()) + } + fn seq(self, _: Option) -> Result { Ok(self) } diff --git a/packages/aead/tests/protected_vec_wipe.rs b/packages/aead/tests/protected_vec_wipe.rs index b9ed8dc3..64307c9c 100644 --- a/packages/aead/tests/protected_vec_wipe.rs +++ b/packages/aead/tests/protected_vec_wipe.rs @@ -11,7 +11,7 @@ use std::alloc::{GlobalAlloc, Layout, System}; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use vitaminc_aead::plaintext::{ - Encrypt, Error, Logical, MapSink, Physical, SeqSink, Sink, SourceError, + Encrypt, Error, FixedType, Logical, MapSink, Physical, SeqSink, Sink, SourceError, }; use vitaminc_protected::Protected; @@ -70,6 +70,10 @@ impl Sink for Quiet { Ok(()) } + fn typed_null(self, _: FixedType) -> Result<(), Stopped> { + Ok(()) + } + fn seq(self, _: Option) -> Result { Ok(self) } diff --git a/packages/aead/tests/typed_null.rs b/packages/aead/tests/typed_null.rs new file mode 100644 index 00000000..68cc0d2e --- /dev/null +++ b/packages/aead/tests/typed_null.rs @@ -0,0 +1,232 @@ +//! Typed nulls end to end: a sealed `None` of a fixed-width type is as long +//! as a sealed `Some`, so ciphertext length does not show which values are +//! absent, and it reads back only as an `Option` of the same type. Sealing +//! goes through a cipher that keeps the plaintext in the clear, so a +//! leaf's length is its plaintext's; AES-GCM adds the same nonce and tag +//! to every leaf. + +use vitaminc_aead::{ + decrypt, decrypt_to_frame, encrypt, tags, to_frame, CipherText, Context, DecodeError, Decrypt, + DecryptError, Encrypt, FixedType, Frame, IntoAad, Node, Width, +}; +use vitaminc_protected::{Controlled, Protected}; + +#[derive(Debug)] +struct Leaf { + aad: Vec, + plaintext: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct Refused; + +struct Mock; + +impl vitaminc_aead::seal::Cipher for Mock { + type Leaf = Leaf; + type Error = Refused; + + fn seal(&self, plaintext: Protected>, aad: &Context<'_>) -> Result { + Ok(Leaf { + aad: aad.as_bytes().to_vec(), + plaintext: plaintext.risky_unwrap(), + }) + } + + fn open(&self, leaf: Leaf, aad: &Context<'_>) -> Result>, Refused> { + if leaf.aad != aad.as_bytes() { + return Err(Refused); + } + Ok(Protected::new(leaf.plaintext)) + } +} + +/// The plaintext of the one leaf `value` seals as. +fn sealed(value: P) -> Vec { + match encrypt(value, &Mock, "ctx").expect("encrypts") { + CipherText::Single(leaf) => leaf.plaintext, + other => panic!("not one leaf: {other:?}"), + } +} + +/// `None` and `Some(value)` seal to leaves of the same length, the `None` +/// as a typed null, and the `None` reads back as `None`. +fn hides_absence(value: T) +where + T: Encrypt + Decrypt + PartialEq + core::fmt::Debug, +{ + let none = sealed(None::); + let some = sealed(Some(value)); + assert_eq!(none.first(), Some(&tags::TYPED_NULL), "{none:?}"); + assert_eq!(none.len(), some.len(), "{none:?} vs {some:?}"); + let read: Option = decrypt( + &Mock, + encrypt(None::, &Mock, "ctx").expect("encrypts"), + "ctx", + ) + .expect("decrypts"); + assert_eq!(read, None); +} + +#[test] +fn every_fixed_width_primitive_hides_absence() { + hides_absence(-1i8); + hides_absence(-1i16); + hides_absence(-1i32); + hides_absence(-1i64); + hides_absence(-1i128); + hides_absence(1u16); + hides_absence(1u32); + hides_absence(1u64); + hides_absence(1u128); + hides_absence(1.5f32); + hides_absence(1.5f64); +} + +/// A bool's leaf is the tag alone, as short as the plain null, so its +/// `None` stays the plain null and hides nothing it does not already. +#[test] +fn a_bool_none_is_the_plain_null_and_as_long_as_a_bool() { + assert_eq!(sealed(None::), [tags::NULL]); + assert_eq!(sealed(Some(true)).len(), 1); +} + +/// Text and bytes are variable width: out of scope, and unchanged. +#[test] +fn a_variable_width_none_is_the_plain_null() { + assert_eq!(sealed(None::), [tags::NULL]); + assert_eq!(sealed(None::>), [tags::NULL]); +} + +#[cfg(feature = "chrono")] +#[test] +fn chrono_types_and_timestamp_hide_absence() { + use chrono::{DateTime, NaiveDate, Utc}; + hides_absence(NaiveDate::from_ymd_opt(1999, 12, 31).expect("date")); + hides_absence(DateTime::::from_timestamp(1_700_000_000, 5).expect("instant")); + hides_absence( + vitaminc_aead::Timestamp::new(DateTime::::from_timestamp(7, 0).expect("instant")) + .expect("not a leap second"), + ); + // A `Timestamp` and a `DateTime` are the one leaf, absent or not. + assert_eq!( + sealed(None::), + sealed(None::>) + ); +} + +#[cfg(feature = "rust_decimal")] +#[test] +fn a_decimal_hides_absence_whatever_its_scale() { + use rust_decimal::Decimal; + for s in ["0", "-1234.5678", "79228162514264337593543950335"] { + hides_absence(s.parse::().expect("decimal")); + } +} + +#[derive(Encrypt, Decrypt, Debug, PartialEq, Clone, Copy)] +enum Status { + Active, + Suspended = 7, +} + +#[derive(Encrypt, Decrypt, Debug, PartialEq, Clone, Copy)] +struct Age(u32); + +#[test] +fn a_derived_enum_and_newtype_hide_absence() { + hides_absence(Status::Suspended); + hides_absence(Age(42)); + assert_eq!(::FIXED, Some(FixedType::plain(Width::U32))); + assert_eq!(::FIXED, Some(FixedType::plain(Width::U32))); + assert_eq!(<&Age as Encrypt>::FIXED, Some(FixedType::plain(Width::U32))); + assert_eq!(::FIXED, ::FIXED); + // An absent enum is not one of its variants. + let read: Result = decrypt( + &Mock, + encrypt(None::, &Mock, "ctx").expect("encrypts"), + "ctx", + ); + assert!(matches!( + read, + Err(DecryptError::Sink(DecodeError::Expected("Status"))) + )); +} + +#[derive(Encrypt, Decrypt, Debug, PartialEq, Clone)] +struct Profile { + name: String, + born: Option, + score: Option, + status: Option, +} + +/// The lengths of a sealed record's leaves, entry by entry. +fn leaf_lengths(node: Node) -> Vec<(String, usize)> { + match node { + CipherText::Map(entries) => entries + .into_iter() + .map(|(key, value)| match value { + CipherText::Single(leaf) => (key, leaf.plaintext.len()), + other => panic!("{key}: not one leaf: {other:?}"), + }) + .collect(), + other => panic!("not a map: {other:?}"), + } +} + +#[test] +fn a_struct_with_optional_fields_seals_the_same_lengths_either_way() { + let present = Profile { + name: "Ada".into(), + born: Some(1815), + score: Some(9.5), + status: Some(Status::Active), + }; + let absent = Profile { + name: "Ada".into(), + born: None, + score: None, + status: None, + }; + let lengths = |p: &Profile| leaf_lengths(encrypt(p.clone(), &Mock, "ctx").expect("encrypts")); + assert_eq!(lengths(&present), lengths(&absent)); + for p in [present, absent] { + let read: Profile = decrypt( + &Mock, + encrypt(p.clone(), &Mock, "ctx").expect("encrypts"), + "ctx", + ) + .expect("decrypts"); + assert_eq!(read, p); + } +} + +/// The frame carries a typed null both ways, so a host that encrypts from +/// a frame (the Go guest) pads it, and decrypting to a frame keeps it. +#[test] +fn a_typed_null_survives_the_frame_both_ways() { + let frame = to_frame(None::).expect("frames"); + assert_eq!( + frame.risky_ref(), + &[tags::TYPED_NULL, tags::UINT32, 0, 0, 0] + ); + let parsed = Frame::parse(frame.risky_ref()).expect("parses"); + assert_eq!(sealed(parsed), [tags::TYPED_NULL, tags::UINT32, 0, 0, 0]); + + let node = encrypt(None::, &Mock, "ctx").expect("encrypts"); + let back = decrypt_to_frame(&Mock, node, "ctx").expect("decrypts"); + assert_eq!(back.risky_ref(), frame.risky_ref()); +} + +/// A typed null whose padding is not zero is not an absence: a writer with +/// the key cannot pass other plaintext off as one. +#[test] +fn a_typed_null_with_nonzero_padding_is_refused() { + let forged = Leaf { + aad: "ctx".into_aad().as_bytes().to_vec(), + plaintext: vec![tags::TYPED_NULL, tags::UINT32, 0, 0, 1], + }; + let read: Result, _> = decrypt(&Mock, CipherText::Single(forged), "ctx"); + assert!(matches!(read, Err(DecryptError::Malformed)), "{read:?}"); +} diff --git a/packages/encrypt/src/cipher_static.rs b/packages/encrypt/src/cipher_static.rs index 1aa64962..ab3779b1 100644 --- a/packages/encrypt/src/cipher_static.rs +++ b/packages/encrypt/src/cipher_static.rs @@ -500,8 +500,10 @@ mod test { let absent = (&cipher).encrypt_none(aad).unwrap(); let plaintext = open_local(&cipher, absent.into_local(), aad).unwrap(); assert_eq!(plaintext.risky_ref(), &NULL_LEAF); - // And what the sink-based path seals for `None` verifies as absent. - let node = vitaminc_aead::encrypt(None::, &cipher, aad).unwrap(); + // And what the sink-based path seals for the `None` of the bytes an + // `Encrypted` holds verifies as absent. (A fixed-width `None` seals + // as a typed null instead, which is not an `Absent`.) + let node = vitaminc_aead::encrypt(None::>, &cipher, aad).unwrap(); let vitaminc_aead::CipherText::Single(leaf) = node else { panic!("a None is one leaf"); }; diff --git a/packages/encrypt/tests/sink_cipher.rs b/packages/encrypt/tests/sink_cipher.rs index d25b1ce2..0241317c 100644 --- a/packages/encrypt/tests/sink_cipher.rs +++ b/packages/encrypt/tests/sink_cipher.rs @@ -153,3 +153,49 @@ mod given_protected_bytes { assert_eq!(got.risky_unwrap(), vec![0xDE, 0xAD, 0xBE, 0xEF]); } } + +mod given_an_absent_fixed_width_value { + use super::*; + + /// The ciphertext length of the one leaf `value` seals as. + fn sealed_len(value: P) -> usize { + match encrypt(value, &cipher(), "ctx").expect("encrypt") { + CipherText::Single(leaf) => leaf.as_ref().len(), + other => panic!("not one leaf: {other:?}"), + } + } + + fn same_length(value: T) { + assert_eq!( + sealed_len(None::), + sealed_len(Some(value)), + "{}", + core::any::type_name::() + ); + } + + #[test] + fn seals_to_the_length_of_a_present_one() { + same_length(-1i8); + same_length(-1i16); + same_length(-1i32); + same_length(-1i64); + same_length(-1i128); + same_length(1u16); + same_length(1u32); + same_length(1u64); + same_length(1u128); + same_length(1.5f32); + same_length(1.5f64); + // A bool's plain null is already as long as a bool. + same_length(true); + } + + #[test] + fn round_trips_through_aes_as_none() { + let cipher = cipher(); + let node = encrypt(None::, &cipher, "ctx").expect("encrypt"); + let got: Option = decrypt(&cipher, node, "ctx").expect("decrypt"); + assert_eq!(got, None); + } +} diff --git a/packages/normalize/src/terms.rs b/packages/normalize/src/terms.rs index a2dd6ae3..eb45fa06 100644 --- a/packages/normalize/src/terms.rs +++ b/packages/normalize/src/terms.rs @@ -20,8 +20,8 @@ use vitaminc_aead::plaintext::{Error, Path, Step}; use vitaminc_aead::seal::{seal_all, seal_all_async, AsyncCipher, Cipher}; use vitaminc_aead::shape::{self, Locate, MapRules, SeqRules, Settle}; use vitaminc_aead::{ - Encrypt, EncryptError, EncryptSink, IntoAad, Logical, MapSink, Node, Pending, Physical, - Refusal, SeqSink, Sink, SourceError, Tee, + Encrypt, EncryptError, EncryptSink, FixedType, IntoAad, Logical, MapSink, Node, Pending, + Physical, Refusal, SeqSink, Sink, SourceError, Tee, }; use vitaminc_protected::OpaqueDebug; @@ -444,6 +444,13 @@ impl<'t, E: Error, N: Normalize> Sink for Terms<'t, E, N> { Ok(()) } + /// Indexed as a plain null, which has no term: whether, and how, an + /// absent value gets one is still to be decided + /// (cipherstash/vitaminc#493). + fn typed_null(self, _: FixedType) -> Result<(), E> { + self.scalar(None, Physical::Null) + } + fn seq(self, _: Option) -> Result { let rules = match self.rules { Some(node) => { diff --git a/packages/ore/src/scheme.rs b/packages/ore/src/scheme.rs index 43323519..839ee5ee 100644 --- a/packages/ore/src/scheme.rs +++ b/packages/ore/src/scheme.rs @@ -15,7 +15,7 @@ use core::fmt; use std::sync::OnceLock; use vitaminc_aead::plaintext::{self, SourceError}; -use vitaminc_aead::{Encrypt, Logical, MapSink, Physical, SeqSink, Sink}; +use vitaminc_aead::{Encrypt, FixedType, Logical, MapSink, Physical, SeqSink, Sink}; use vitaminc_context::{Context, IntoContext}; use vitaminc_normalize::NormalizeError; use vitaminc_protected::{Controlled, OpaqueDebug, Protected}; @@ -790,6 +790,12 @@ impl Sink for Scalar { order(self.0, ty, value).map_err(ScalarError::Term) } + /// The same as a plain null for now: whether, and how, an absent value + /// gets an order term is still to be decided (cipherstash/vitaminc#493). + fn typed_null(self, _: FixedType) -> Result { + self.scalar(None, Physical::Null) + } + fn seq(self, _: Option) -> Result { Err(ScalarError::NotScalar) } From e838f1e425ea2be638b8375ad87ab33ca9bfe200 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 20 Oct 2026 22:31:21 +1100 Subject: [PATCH 2/4] fix(aead): address review of the typed null: plain null in frames, widening-tolerant reads, uuid - Write the plain null in every frame this crate writes (decrypt_to_frame, to_frame, passthrough subtrees). A frame is never sealed, so padding hides nothing there, and host decoders (Go's vcffi among them) already read the plain null. A frame a host sends may still carry a typed null. - Read a typed null as None for any Option whose T reads a value of its type, so a widened field (u32 to u64, f32 to f64) reads its old Nones as it reads its old values. `Decrypt::reads_null` replaces the exact-match `Decrypt::FIXED`; `FromScalar::FIXED` stays only for a target that refuses zero, such as a fieldless enum. - Give a uuid a typed null: its type fixes it at sixteen bytes (`Width::Bytes16`). `FixedType::plain` now returns an Option, since plain bytes have no fixed width. - Write a typed null from a zero of its type through the codec every value goes through, and read one by comparing it with what would be written, so the two can never drift apart. - Fix docs that still said every Rust None is the plain null leaf. Refs #493 --- packages/aead-derive/src/encrypt.rs | 4 +- packages/aead-derive/src/typed_decrypt.rs | 8 +- packages/aead/README.md | 2 +- packages/aead/src/container.rs | 4 +- packages/aead/src/encrypt_sink.rs | 2 +- packages/aead/src/frame.rs | 17 +- packages/aead/src/hlist/cipher.rs | 3 +- packages/aead/src/hlist/types.rs | 4 +- packages/aead/src/leaf.rs | 208 ++++++++++++---------- packages/aead/src/plaintext/kinds.rs | 74 ++++++-- packages/aead/src/plaintext/sources.rs | 22 ++- packages/aead/src/plaintext/tee.rs | 7 +- packages/aead/src/tags.rs | 8 +- packages/aead/src/timestamp.rs | 1 - packages/aead/src/typed.rs | 154 +++++++++------- packages/aead/tests/typed_null.rs | 85 +++++++-- packages/encrypt/src/cipher_static.rs | 13 +- 17 files changed, 395 insertions(+), 221 deletions(-) diff --git a/packages/aead-derive/src/encrypt.rs b/packages/aead-derive/src/encrypt.rs index f2d1238c..dbd24c1c 100644 --- a/packages/aead-derive/src/encrypt.rs +++ b/packages/aead-derive/src/encrypt.rs @@ -242,9 +242,7 @@ fn fixed( ) } Shape::Enum(_) => { - let u32 = decl(quote!(::core::option::Option::Some( - #krate::FixedType::plain(#krate::Width::U32) - ))); + let u32 = decl(quote!(#krate::FixedType::plain(#krate::Width::U32))); (u32.clone(), u32) } Shape::Map(_) | Shape::Empty => (TokenStream::new(), TokenStream::new()), diff --git a/packages/aead-derive/src/typed_decrypt.rs b/packages/aead-derive/src/typed_decrypt.rs index 4808dba7..a1a23abb 100644 --- a/packages/aead-derive/src/typed_decrypt.rs +++ b/packages/aead-derive/src/typed_decrypt.rs @@ -68,8 +68,9 @@ pub(crate) fn derive(input: DeriveInput) -> Result { <#inner as #krate::typed::Decrypt>::Sink, #wrap #ty_generics, >; - const FIXED: ::core::option::Option<#krate::FixedType> = - <#inner as #krate::typed::Decrypt>::FIXED; + fn reads_null(__ty: #krate::FixedType) -> bool { + <#inner as #krate::typed::Decrypt>::reads_null(__ty) + } fn sink() -> Self::Sink { #krate::typed::Wrapped::new(<#inner as #krate::typed::Decrypt>::sink()) } @@ -307,8 +308,9 @@ fn enum_target(krate: &syn::Path, input: &DeriveInput, variants: &[VariantInfo]) #[automatically_derived] impl #impl_generics #krate::FromScalar for #name #ty_generics #where_clause { const EXPECTED: &'static str = #expected; + // Its own `None`, even with no variant at zero to read. const FIXED: ::core::option::Option<#krate::FixedType> = - ::core::option::Option::Some(#krate::FixedType::plain(#krate::Width::U32)); + #krate::FixedType::plain(#krate::Width::U32); fn from_scalar( __ty: ::core::option::Option<#krate::Logical>, diff --git a/packages/aead/README.md b/packages/aead/README.md index b592c038..d422a673 100644 --- a/packages/aead/README.md +++ b/packages/aead/README.md @@ -133,7 +133,7 @@ assert!(matches!(encrypt(map, &cipher, ())?, CipherText::Map(entries) if entries // An empty container is an authenticated marker; `None` is a null leaf. A // `None` of a fixed-width type (an int, a float, a date, a timestamp, a -// decimal) is a typed null, padded to the length of a `Some`, so the +// decimal, a uuid) is a typed null, padded to the length of a `Some`, so the // ciphertext's length does not show which values are absent. assert!(matches!(encrypt(Vec::::new(), &cipher, ())?, CipherText::EmptySequence(_))); assert!(matches!(encrypt(None::, &cipher, ())?, CipherText::Single(_))); diff --git a/packages/aead/src/container.rs b/packages/aead/src/container.rs index c6843c1a..7bafafbe 100644 --- a/packages/aead/src/container.rs +++ b/packages/aead/src/container.rs @@ -9,7 +9,9 @@ /// [`EmptyMap`](CipherText::EmptyMap), since they contain no element /// ciphertexts to authenticate the supplied AAD. Nested structures are /// represented recursively. An absent value (`Option::None`, a host null) -/// is a null leaf, a `Single` like any other scalar. +/// is a null leaf, a `Single` like any other scalar: the plain null, or a +/// typed null padded to its type's length (see +/// [`tags::TYPED_NULL`](crate::tags::TYPED_NULL)). /// /// The two parameters: /// diff --git a/packages/aead/src/encrypt_sink.rs b/packages/aead/src/encrypt_sink.rs index 5f0fe7d7..f60426ea 100644 --- a/packages/aead/src/encrypt_sink.rs +++ b/packages/aead/src/encrypt_sink.rs @@ -517,7 +517,7 @@ mod tests { _error: PhantomData, }; let err = sink - .typed_null(FixedType::plain(Width::I8)) + .typed_null(FixedType::plain(Width::I8).expect("fixed")) .err() .expect("too deep"); assert!(matches!(err, EncryptError::Shape(_)), "{err:?}"); diff --git a/packages/aead/src/frame.rs b/packages/aead/src/frame.rs index 7cb52546..a9d5484c 100644 --- a/packages/aead/src/frame.rs +++ b/packages/aead/src/frame.rs @@ -456,14 +456,15 @@ impl<'o> Sink for FrameWriter<'o> { }) } - /// Carried as it is, so a host that encrypts from the frame pads it. - fn typed_null(self, ty: FixedType) -> Result<(), FrameWriteError> { - if self.depth > MAX_DEPTH { - return Err(FrameWriteError::TooDeep); - } - reserve(self.out, leaf::typed_null_len(ty)); - leaf::write_typed_null(ty, self.out); - Ok(()) + /// Written as the plain null. A frame is plaintext, never sealed, so + /// padding hides nothing in it: what a frame writer produces goes to a + /// host (the frame [`decrypt_to_frame`](crate::decrypt_to_frame) + /// returns) or into the clear (a passthrough subtree), where the plain + /// null is what every reader already knows. A frame a host sends may + /// still carry a typed null, which [`Frame`] reads, so the host's + /// absent values are padded when sealed. + fn typed_null(self, _: FixedType) -> Result<(), FrameWriteError> { + self.scalar(None, Physical::Null) } fn seq(self, len: Option) -> Result, FrameWriteError> { diff --git a/packages/aead/src/hlist/cipher.rs b/packages/aead/src/hlist/cipher.rs index 707403dd..5fd73fc5 100644 --- a/packages/aead/src/hlist/cipher.rs +++ b/packages/aead/src/hlist/cipher.rs @@ -41,7 +41,8 @@ pub trait StaticCipher: Sized + Copy { A: IntoAad<'a>; /// Produce an authenticated "no value" marker bound to `aad`: the null - /// leaf, `[tags::NULL]`, the same leaf the sink path seals for `None`. + /// leaf, `[tags::NULL]`, the same leaf the sink path seals for a `None` + /// of bytes. Bytes are variable width, so it is not padded. fn encrypt_none<'a, A>(self, aad: A) -> Result where A: IntoAad<'a>; diff --git a/packages/aead/src/hlist/types.rs b/packages/aead/src/hlist/types.rs index bca7c3fa..0a4dcf9c 100644 --- a/packages/aead/src/hlist/types.rs +++ b/packages/aead/src/hlist/types.rs @@ -30,7 +30,9 @@ impl Encrypted { } /// An authenticated "no value" marker: the null leaf (`[tags::NULL]`) -/// sealed under the slot's AAD, the same leaf the sink path seals for `None`. +/// sealed under the slot's AAD, the same leaf the sink path seals for the +/// `None` of the bytes an [`Encrypted`] holds. (A `None` of a fixed-width +/// type seals there as a typed null instead, which is not an `Absent`.) /// /// Inner field is private for the same reason as [`Encrypted`]. #[derive(Debug)] diff --git a/packages/aead/src/leaf.rs b/packages/aead/src/leaf.rs index 24e7910a..1d789848 100644 --- a/packages/aead/src/leaf.rs +++ b/packages/aead/src/leaf.rs @@ -198,81 +198,37 @@ pub fn write_leaf( /// # Ok::<(), ScalarError>(()) /// ``` pub fn write_typed_null(ty: FixedType, out: &mut Vec) { - let Header { - bytes, - len, - payload, - } = header(ty); + let (zero, header) = zero_leaf(ty); out.push(tags::TYPED_NULL); - out.extend_from_slice(&bytes[..len]); - // One byte of the payload's length went to `TYPED_NULL`. Every - // payload here is at least one byte, so this never underflows. - out.resize(out.len() + payload - 1, 0); + out.extend_from_slice(&zero[..header]); + // One byte of the value's length went to `TYPED_NULL`. Every payload + // here is at least one byte, so nothing is lost to the saturation. + out.resize(out.len() + zero.len().saturating_sub(header + 1), 0); } -/// The header a value of a [`FixedType`] starts with, and the length of -/// the payload after it. -struct Header { - /// The header, in the first `len` bytes. - bytes: [u8; 4], - len: usize, - payload: usize, -} - -fn header(ty: FixedType) -> Header { - let width = ty.width(); - let tag = |tag, payload| Header { - bytes: [tag, 0, 0, 0], - len: 1, - payload, +/// The leaf of a zero of `ty`, and how many of its bytes are the header: +/// the tag, or the envelope's `LOGICAL, id, param, tag`. Written by the +/// codec every value goes through, so a typed null cannot drift from the +/// value it stands in for. +fn zero_leaf(ty: FixedType) -> (Vec, usize) { + let zero = ty.width().zero(); + let mut leaf = Vec::with_capacity(max_len(&zero)); + // A `FixedType` admits its own zero (a test pins every kind), so this + // cannot fail. + let written = write(ty.logical(), zero, Form::Leaf, &mut leaf); + debug_assert!(written.is_ok(), "{ty:?} refuses its zero"); + let header = if leaf.first() == Some(&tags::LOGICAL) { + 4 + } else { + 1 }; - match ty.logical() { - None => tag(width_tag(width), width_payload(width)), - Some(Logical::Date) => tag(tags::DATE, 4), - Some(Logical::Timestamp(TimeUnit::Nanos)) => tag(tags::TIMESTAMP, 12), - Some(Logical::Decimal { .. }) => tag(tags::DECIMAL, 16), - Some(logical) => { - let (id, param) = logical.to_wire(); - Header { - bytes: [tags::LOGICAL, id, param, width_tag(width)], - len: 4, - payload: width_payload(width), - } - } - } -} - -/// The tag a plain value of `width` is written with. -fn width_tag(width: Width) -> u8 { - match width { - Width::I8 => tags::INT8, - Width::I16 => tags::INT16, - Width::I32 => tags::INT32, - Width::I64 => tags::INT64, - Width::I128 => tags::INT128, - Width::U8 => tags::UINT8, - Width::U16 => tags::UINT16, - Width::U32 => tags::UINT32, - Width::U64 => tags::UINT64, - Width::U128 => tags::UINT128, - Width::F32 => tags::FLOAT32, - Width::F64 => tags::FLOAT64, - } + let header = header.min(leaf.len()); + (leaf, header) } -/// The payload length of a plain value of `width`. -fn width_payload(width: Width) -> usize { - match width { - Width::I8 | Width::U8 => 1, - Width::I16 | Width::U16 => 2, - Width::I32 | Width::U32 | Width::F32 => 4, - Width::I64 | Width::U64 | Width::F64 => 8, - Width::I128 | Width::U128 => 16, - } -} - -/// The width a plain tag is written for, or `None` for a tag that is not a -/// plain int or float. +/// The width a plain tag is written for, or `None` for a tag no +/// [`FixedType`] is written with. Bytes are sixteen wide only under a +/// uuid, which [`FixedType::new`] checks. fn tag_width(tag: u8) -> Option { Some(match tag { tags::INT8 => Width::I8, @@ -287,6 +243,7 @@ fn tag_width(tag: u8) -> Option { tags::UINT128 => Width::U128, tags::FLOAT32 => Width::F32, tags::FLOAT64 => Width::F64, + tags::BYTES => Width::Bytes16, _ => return None, }) } @@ -294,8 +251,7 @@ fn tag_width(tag: u8) -> Option { /// The length of a typed null of `ty`, which is the length of a leaf /// holding a value of `ty`. pub fn typed_null_len(ty: FixedType) -> usize { - let Header { len, payload, .. } = header(ty); - len + payload + zero_leaf(ty).0.len() } /// An upper bound on the bytes [`write()`] appends for `v` in either form: @@ -458,19 +414,17 @@ pub fn read_leaf_item(bytes: &[u8]) -> Result, ScalarError> { } /// A typed null after its `TYPED_NULL` tag. Strict, so that every absence -/// has one encoding: the header must be one this codec writes for some -/// [`FixedType`] and the padding must be zero. +/// has one encoding: the header names a [`FixedType`], and the whole typed +/// null, header and padding, must be byte for byte what +/// [`write_typed_null`] writes for it. fn read_typed_null(c: &mut Cursor<'_>) -> Result { - let tag = c.byte()?; - let (ty, width) = match tag { + let start = c.pos - 1; + let (ty, width) = match c.byte()? { tags::DATE => (Some(Logical::Date), Width::I32), tags::TIMESTAMP => (Some(Logical::Timestamp(TimeUnit::Nanos)), Width::I128), tags::DECIMAL => (Some(Logical::Decimal { scale: 0 }), Width::I128), tags::LOGICAL => { let ty = Logical::from_wire(c.byte()?, c.byte()?).ok_or(ScalarError::Malformed)?; - if has_shorthand(ty) { - return Err(ScalarError::Malformed); - } let width = tag_width(c.byte()?).ok_or(ScalarError::Malformed)?; (Some(ty), width) } @@ -479,7 +433,12 @@ fn read_typed_null(c: &mut Cursor<'_>) -> Result { // A logical type over a physical type it cannot ride on (a uuid over // an int, a timestamp over a float) has no typed null. let fixed = FixedType::new(ty, width).ok_or(ScalarError::Malformed)?; - if c.take(header(fixed).payload - 1)?.iter().any(|&b| b != 0) { + // An envelope where a shorthand tag exists, or padding that is not + // zero, is not what the writer writes. + let mut expected = Vec::new(); + write_typed_null(fixed, &mut expected); + c.pos = start; + if c.take(expected.len())? != expected { return Err(ScalarError::Malformed); } Ok(fixed) @@ -1291,62 +1250,62 @@ mod tests { let unknown = Logical::from_wire(200, 9); let cases: Vec<(FixedType, Vec, Physical<'_>)> = vec![ ( - FixedType::plain(Width::I8), + FixedType::plain(Width::I8).expect("fixed"), vec![T, 0x0C], Physical::Int(Int::I8(-1)), ), ( - FixedType::plain(Width::U8), + FixedType::plain(Width::U8).expect("fixed"), vec![T, 0x0D], Physical::Int(Int::U8(1)), ), ( - FixedType::plain(Width::I16), + FixedType::plain(Width::I16).expect("fixed"), vec![T, 0x0E, 0], Physical::Int(Int::I16(1)), ), ( - FixedType::plain(Width::U16), + FixedType::plain(Width::U16).expect("fixed"), vec![T, 0x0F, 0], Physical::Int(Int::U16(1)), ), ( - FixedType::plain(Width::I32), + FixedType::plain(Width::I32).expect("fixed"), vec![T, 0x04, 0, 0, 0], Physical::Int(Int::I32(1)), ), ( - FixedType::plain(Width::U32), + FixedType::plain(Width::U32).expect("fixed"), vec![T, 0x06, 0, 0, 0], Physical::Int(Int::U32(1)), ), ( - FixedType::plain(Width::F32), + FixedType::plain(Width::F32).expect("fixed"), vec![T, 0x08, 0, 0, 0], Physical::Float(Float::F32(1.5)), ), ( - FixedType::plain(Width::I64), + FixedType::plain(Width::I64).expect("fixed"), vec![T, 0x05, 0, 0, 0, 0, 0, 0, 0], Physical::Int(Int::I64(1)), ), ( - FixedType::plain(Width::U64), + FixedType::plain(Width::U64).expect("fixed"), vec![T, 0x07, 0, 0, 0, 0, 0, 0, 0], Physical::Int(Int::U64(1)), ), ( - FixedType::plain(Width::F64), + FixedType::plain(Width::F64).expect("fixed"), vec![T, 0x09, 0, 0, 0, 0, 0, 0, 0], Physical::Float(Float::F64(1.5)), ), ( - FixedType::plain(Width::I128), + FixedType::plain(Width::I128).expect("fixed"), [&[T, 0x10][..], &[0; 15]].concat(), Physical::Int(Int::I128(1)), ), ( - FixedType::plain(Width::U128), + FixedType::plain(Width::U128).expect("fixed"), [&[T, 0x11][..], &[0; 15]].concat(), Physical::Int(Int::U128(1)), ), @@ -1372,6 +1331,12 @@ mod tests { [&[T, 0xF3, 3, 1, 0x05][..], &[0; 7]].concat(), Physical::Int(Int::I64(1_700_000_000_000)), ), + // A uuid's sixteen bytes are fixed by its type: no length. + ( + fixed(Some(Logical::Uuid), Width::Bytes16), + [&[T, 0xF3, 5, 0, 0x0B][..], &[0; 15]].concat(), + Physical::Bytes(&[0xAB; 16]), + ), ( fixed(unknown, Width::F32), vec![T, 0xF3, 200, 9, 0x08, 0, 0, 0], @@ -1400,6 +1365,59 @@ mod tests { } } + /// `write_typed_null` writes from a zero of the type, so every + /// [`FixedType`] must admit its zero. + #[test] + fn every_fixed_type_admits_its_zero() { + let widths = [ + Width::I8, + Width::I16, + Width::I32, + Width::I64, + Width::I128, + Width::U8, + Width::U16, + Width::U32, + Width::U64, + Width::U128, + Width::F32, + Width::F64, + Width::Bytes16, + ]; + let types = [ + None, + Some(Logical::Undefined), + Some(Logical::Date), + Some(Logical::Timestamp(TimeUnit::Seconds)), + Some(Logical::Timestamp(TimeUnit::Millis)), + Some(Logical::Timestamp(TimeUnit::Micros)), + Some(Logical::Timestamp(TimeUnit::Nanos)), + Some(Logical::Decimal { scale: 28 }), + Some(Logical::Uuid), + Logical::from_wire(200, 9), + ]; + let mut checked = 0; + for ty in types { + for width in widths { + let Some(fixed) = FixedType::new(ty, width) else { + continue; + }; + let mut out = Vec::new(); + assert!( + write_leaf(fixed.logical(), width.zero(), &mut out).is_ok(), + "{fixed:?}" + ); + assert_eq!(typed_null(fixed).len(), out.len(), "{fixed:?}"); + assert_eq!( + read_leaf_item(&typed_null(fixed)), + Ok(Item::TypedNull(fixed)) + ); + checked += 1; + } + } + assert!(checked > 40, "only {checked}"); + } + #[test] fn a_typed_null_is_not_a_scalar() { let null_date = typed_null(fixed(Some(Logical::Date), Width::I32)); @@ -1447,11 +1465,17 @@ mod tests { &[T, 0xF4, 200, 0, 0x04, 0, 0, 0], // A logical type over a physical type it cannot ride on: a // uuid over an int, a millisecond timestamp over a float, an - // unknown type over a bool or over text. + // unknown type over a bool, text or bytes; and plain bytes. &[T, 0xF3, 5, 0, 0x04, 0, 0, 0], &[T, 0xF3, 3, 1, 0x09, 0, 0, 0, 0, 0, 0, 0], &[T, 0xF3, 200, 0, 0x02], &[T, 0xF3, 200, 0, 0x0A], + &[ + T, 0xF3, 200, 0, 0x0B, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + ], + &[T, 0x0B, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + // A uuid's typed null with a length, as a frame gives bytes. + &[T, 0xF3, 5, 0, 0x0B, 16, 0, 0, 0], ]; for bytes in refused { assert_eq!( diff --git a/packages/aead/src/plaintext/kinds.rs b/packages/aead/src/plaintext/kinds.rs index 2b85c6c8..efac8f2e 100644 --- a/packages/aead/src/plaintext/kinds.rs +++ b/packages/aead/src/plaintext/kinds.rs @@ -251,10 +251,13 @@ pub enum Float { } /// The physical type of a fixed-width scalar whose leaf has a payload: an -/// int or a float, with its width. What a [`FixedType`] rides on. +/// int or a float, with its width, or sixteen bytes. What a [`FixedType`] +/// rides on. /// /// `bool` is fixed width too, but its leaf is the tag alone, one byte, the -/// same length as the plain null leaf, so it has no width here. +/// same length as the plain null leaf, so it has no width here. Bytes have +/// a fixed width only where a logical type fixes it: [`Width::Bytes16`] is +/// for a uuid. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Width { I8, @@ -269,19 +272,43 @@ pub enum Width { U128, F32, F64, + /// Sixteen bytes. + Bytes16, } impl Width { - /// The physical kind: [`Kind::Int`] or [`Kind::Float`]. + /// The physical kind: [`Kind::Int`], [`Kind::Float`] or [`Kind::Bytes`]. pub const fn kind(self) -> Kind { match self { Width::F32 | Width::F64 => Kind::Float, + Width::Bytes16 => Kind::Bytes, _ => Kind::Int, } } + /// A zero of this physical type: a value whose leaf has the header and + /// the length every value of the type has. + pub const fn zero(self) -> Physical<'static> { + match self { + Width::I8 => Physical::Int(Int::I8(0)), + Width::I16 => Physical::Int(Int::I16(0)), + Width::I32 => Physical::Int(Int::I32(0)), + Width::I64 => Physical::Int(Int::I64(0)), + Width::I128 => Physical::Int(Int::I128(0)), + Width::U8 => Physical::Int(Int::U8(0)), + Width::U16 => Physical::Int(Int::U16(0)), + Width::U32 => Physical::Int(Int::U32(0)), + Width::U64 => Physical::Int(Int::U64(0)), + Width::U128 => Physical::Int(Int::U128(0)), + Width::F32 => Physical::Float(Float::F32(0.0)), + Width::F64 => Physical::Float(Float::F64(0.0)), + Width::Bytes16 => Physical::Bytes(&[0; 16]), + } + } + /// The width of a value of this physical type, or `None` for a value - /// with no [`Width`]: null, bool, text and bytes. + /// with no [`Width`]: null, bool, text, and bytes of any length but + /// sixteen. pub fn of(v: &Physical<'_>) -> Option { Some(match v { Physical::Int(Int::I8(_)) => Width::I8, @@ -296,6 +323,7 @@ impl Width { Physical::Int(Int::U128(_)) => Width::U128, Physical::Float(Float::F32(_)) => Width::F32, Physical::Float(Float::F64(_)) => Width::F64, + Physical::Bytes(b) if b.len() == 16 => Width::Bytes16, Physical::Null | Physical::Bool(_) | Physical::Text(_) | Physical::Bytes(_) => { return None } @@ -328,21 +356,27 @@ pub struct FixedType { } impl FixedType { - /// A plain int or float, with no logical type. - pub const fn plain(width: Width) -> Self { - Self { ty: None, width } + /// A plain int or float, with no logical type, or `None` for + /// [`Width::Bytes16`]: plain bytes are variable width. + pub const fn plain(width: Width) -> Option { + Self::new(None, width) } - /// `ty` over `width`, normalised, or `None` if `ty` does not ride on a - /// fixed-width physical type: undefined (over null) and uuid (over - /// bytes, whose frame form is variable width), and a date, timestamp or - /// decimal over a float. + /// `ty` over `width`, normalised, or `None` if the pair has no fixed + /// width: undefined (over null), a date, timestamp or decimal over a + /// float, a uuid over anything but sixteen bytes, and sixteen bytes + /// under anything but a uuid (plain or unknown bytes are variable + /// width, so a reader could not tell where their typed null ends). pub const fn new(ty: Option, width: Width) -> Option { let float = matches!(width, Width::F32 | Width::F64); + let bytes = matches!(width, Width::Bytes16); let (ty, width) = match ty { + Some(Logical::Uuid) if bytes => (Some(Logical::Uuid), width), + Some(Logical::Uuid) => return None, + _ if bytes => return None, None => (None, width), Some(Logical::Unknown(u)) => (Some(Logical::Unknown(u)), width), - Some(Logical::Undefined | Logical::Uuid) => return None, + Some(Logical::Undefined) => return None, Some(_) if float => return None, Some(Logical::Date) => (Some(Logical::Date), Width::I32), Some(Logical::Timestamp(TimeUnit::Nanos)) => { @@ -892,12 +926,24 @@ mod tests { assert_ne!(fixed(ms, Width::I64), fixed(ms, Width::I32)); let unknown = Logical::from_wire(200, 9); assert_eq!(fixed(unknown, Width::F32).logical(), unknown); - assert_eq!(FixedType::plain(Width::U8).logical(), None); + assert_eq!( + FixedType::plain(Width::U8).map(FixedType::logical), + Some(None) + ); + // Plain bytes, even sixteen of them, are variable width. + assert_eq!(FixedType::plain(Width::Bytes16), None); + assert_eq!( + fixed(Some(Logical::Uuid), Width::Bytes16).width(), + Width::Bytes16 + ); } #[test] fn a_type_not_over_a_fixed_width_physical_type_has_no_fixed_type() { assert_eq!(FixedType::new(Some(Logical::Uuid), Width::U128), None); + let unknown = Logical::from_wire(200, 0); + assert_eq!(FixedType::new(unknown, Width::Bytes16), None); + assert_eq!(FixedType::new(Some(Logical::Date), Width::Bytes16), None); assert_eq!(FixedType::new(Some(Logical::Undefined), Width::U8), None); assert_eq!(FixedType::new(Some(Logical::Date), Width::F64), None); assert_eq!( @@ -908,6 +954,8 @@ mod tests { FixedType::new(Some(Logical::Decimal { scale: 2 }), Width::F32), None ); + assert_eq!(Width::of(&Physical::Bytes(&[0; 16])), Some(Width::Bytes16)); + assert_eq!(Width::Bytes16.kind(), Kind::Bytes); for v in [ Physical::Null, Physical::Bool(true), diff --git a/packages/aead/src/plaintext/sources.rs b/packages/aead/src/plaintext/sources.rs index 31af4a36..9ae2f1c6 100644 --- a/packages/aead/src/plaintext/sources.rs +++ b/packages/aead/src/plaintext/sources.rs @@ -20,7 +20,7 @@ macro_rules! int { $( impl Encrypt for $ty { const DEPTH: Option = depth::SCALAR; - const FIXED: Option = Some(FixedType::plain(Width::$width)); + const FIXED: Option = FixedType::plain(Width::$width); fn visit(self, sink: S) -> Result { sink.scalar(None, Physical::Int(self.into())) } @@ -51,7 +51,7 @@ impl Encrypt for bool { impl Encrypt for f32 { const DEPTH: Option = depth::SCALAR; - const FIXED: Option = Some(FixedType::plain(Width::F32)); + const FIXED: Option = FixedType::plain(Width::F32); fn visit(self, sink: S) -> Result { sink.scalar(None, Physical::Float(Float::F32(self))) } @@ -59,7 +59,7 @@ impl Encrypt for f32 { impl Encrypt for f64 { const DEPTH: Option = depth::SCALAR; - const FIXED: Option = Some(FixedType::plain(Width::F64)); + const FIXED: Option = FixedType::plain(Width::F64); fn visit(self, sink: S) -> Result { sink.scalar(None, Physical::Float(Float::F64(self))) } @@ -201,12 +201,12 @@ where } } -/// `None` is a null. There is no separate "absent" in the data model, so a -/// host null and a Rust `None` are the same leaf. +/// `None` is a null. There is no separate "absent" in the data model: a +/// Rust `None` and a host null mean the same thing. /// /// When `T` has a [`FIXED`](Encrypt::FIXED) type, `None` is a typed null /// of it, which the encrypting sink pads to the length of a `Some`. -/// Otherwise it is the plain null. An `Option>` has `T`'s type +/// Otherwise it is the plain null, the same leaf as a host null. An `Option>` has `T`'s type /// too, so its two `None`s seal alike, as they always have. impl Encrypt for Option { const DEPTH: Option = T::DEPTH; @@ -544,6 +544,9 @@ impl Encrypt for &rust_decimal::Decimal { #[cfg(feature = "uuid")] impl Encrypt for uuid::Uuid { const DEPTH: Option = depth::SCALAR; + /// Sixteen bytes under the uuid type: the one bytes leaf whose width is + /// fixed. + const FIXED: Option = FixedType::new(Some(Logical::Uuid), Width::Bytes16); fn visit(self, sink: S) -> Result { sink.scalar(Some(Logical::Uuid), Physical::Bytes(self.as_bytes())) } @@ -552,6 +555,7 @@ impl Encrypt for uuid::Uuid { #[cfg(feature = "uuid")] impl Encrypt for &uuid::Uuid { const DEPTH: Option = depth::SCALAR; + const FIXED: Option = ::FIXED; fn visit(self, sink: S) -> Result { (*self).visit(sink) } @@ -907,7 +911,9 @@ mod tests { ); assert_eq!( record(&None::).expect("ok"), - [Event::TypedNull(FixedType::plain(Width::I8))] + [Event::TypedNull( + FixedType::plain(Width::I8).expect("fixed") + )] ); assert_eq!(record(&None::<&str>).expect("ok"), [scalar(Owned::Null)]); // A borrowed `Vec` is still a byte leaf, not a sequence. @@ -946,7 +952,7 @@ mod tests { #[allow(clippy::needless_borrows_for_generic_args)] #[test] fn a_fixed_width_none_is_a_typed_null() { - let typed = |w| [Event::TypedNull(FixedType::plain(w))]; + let typed = |w| [Event::TypedNull(FixedType::plain(w).expect("fixed"))]; assert_eq!(record(None::).expect("ok"), typed(Width::U32)); assert_eq!(record(None::).expect("ok"), typed(Width::I128)); assert_eq!(record(None::).expect("ok"), typed(Width::F32)); diff --git a/packages/aead/src/plaintext/tee.rs b/packages/aead/src/plaintext/tee.rs index 163c0a73..3d654319 100644 --- a/packages/aead/src/plaintext/tee.rs +++ b/packages/aead/src/plaintext/tee.rs @@ -174,7 +174,12 @@ mod tests { .visit(Tee(Record(&mut left), Record(&mut right))) .expect("ok"); assert_eq!(left, right); - assert_eq!(left, [Event::TypedNull(FixedType::plain(Width::I32))]); + assert_eq!( + left, + [Event::TypedNull( + FixedType::plain(Width::I32).expect("fixed") + )] + ); } #[test] diff --git a/packages/aead/src/tags.rs b/packages/aead/src/tags.rs index f61decaf..ce04a2e8 100644 --- a/packages/aead/src/tags.rs +++ b/packages/aead/src/tags.rs @@ -97,9 +97,11 @@ pub const DECIMAL: u8 = 0x14; /// null date is `[TYPED_NULL, DATE, 0, 0, 0]`, five bytes like /// `[DATE, d0, d1, d2, d3]`. /// -/// Only an int or a float, plain or under a logical type, has a typed null -/// (see [`FixedType`](crate::FixedType)). A bool's leaf is the tag alone, -/// already as short as [`NULL`]; text and bytes are variable width. A +/// Only an int or a float, plain or under a logical type, and a uuid have a +/// typed null (see [`FixedType`](crate::FixedType)). A bool's leaf is the +/// tag alone, already as short as [`NULL`]; other text and bytes are +/// variable width. A uuid's typed null has no length before its padding, +/// even in a frame: its type fixes the length. A /// reader refuses a typed null of any other tag, an envelope where a /// shorthand tag exists, and padding that is not zero, so every absence /// has one encoding. [`NULL`] stays a valid absence of any type. diff --git a/packages/aead/src/timestamp.rs b/packages/aead/src/timestamp.rs index 6070d8fa..794c777b 100644 --- a/packages/aead/src/timestamp.rs +++ b/packages/aead/src/timestamp.rs @@ -131,7 +131,6 @@ impl Encrypt for &Timestamp { /// Any unit, as for `DateTime`. A decoded count is never a leap second. impl FromScalar for Timestamp { const EXPECTED: &'static str = "timestamp"; - const FIXED: Option = ::FIXED; fn from_scalar(ty: Option, value: Physical<'_>) -> Option { DateTime::::from_scalar(ty, value).and_then(|instant| Self::new(instant).ok()) } diff --git a/packages/aead/src/typed.rs b/packages/aead/src/typed.rs index 3602820d..0d1ead52 100644 --- a/packages/aead/src/typed.rs +++ b/packages/aead/src/typed.rs @@ -10,9 +10,9 @@ //! leaf holding 34 and refuses one holding -1. The width is a host-fidelity //! hint. `Option` reads null and `undefined` as `None` and defers //! anything else to `T`. A typed null (see [`Sink::typed_null`]) is `None` -//! only for an `Option` whose `T` has that same -//! [`FIXED`](Decrypt::FIXED) type, the one its own `None` seals as; any -//! other target refuses it as the wrong kind, never reads it as a zero. `HashMap` and `BTreeMap` +//! for an `Option` whose `T` reads values of its type +//! ([`Decrypt::reads_null`]); any other target refuses it as the wrong +//! kind, and never reads it as a zero. `HashMap` and `BTreeMap` //! read a map, each value through `T`'s sink. //! //! `Protected` is a target for every `T` that implements @@ -42,7 +42,7 @@ use vitaminc_protected::{Controlled, Protected}; use zeroize::Zeroize; use crate::plaintext::{ - Encrypt, Error, FixedType, Float, Logical, MapSink, Physical, SeqSink, Sink, SourceError, Width, + Encrypt, Error, FixedType, Float, Logical, MapSink, Physical, SeqSink, Sink, SourceError, }; use crate::seal::{AsyncCipher, Cipher, Node}; use crate::walk::{decrypt_into, decrypt_into_async, DecryptError}; @@ -100,13 +100,17 @@ impl Error for DecodeError { pub trait Decrypt: Sized { type Sink: Sink; - /// The fixed-width type whose typed null an `Option` reads as - /// `None`: the [`Encrypt::FIXED`] of the same type, so a `None` reads - /// back as it was sealed. `None`, the default, for a type with none, - /// whose `Option` then refuses every typed null. - const FIXED: Option = None; - fn sink() -> Self::Sink; + + /// Whether an `Option` reads a typed null of `ty` as `None`: for + /// a scalar target, whether it reads a value of `ty` (see + /// [`FromScalar::FIXED`]), so a `None` reads back wherever a `Some` + /// does, a widened field included. `false`, the default, for a target + /// that is not a scalar, whose `Option` then refuses every typed null. + fn reads_null(ty: FixedType) -> bool { + let _ = ty; + false + } } /// Decrypt into `T`. @@ -142,7 +146,16 @@ where pub trait FromScalar: Sized { /// What the type is called in a refusal. const EXPECTED: &'static str; - /// The type's [`Decrypt::FIXED`]. + /// A fixed type whose typed null an `Option` reads as `None` + /// even though `from_scalar` refuses that type's zero. + /// + /// An `Option` reads a typed null of any type whose zero + /// `from_scalar` accepts ([`Width::zero`](crate::Width::zero)): every + /// type it reads a value of, for the built-in targets, so a `u64` + /// field reads a `u32` field's `None` as it reads its values. Only a + /// target that refuses zero needs this, to read its own `None`: a + /// fieldless enum with no variant at zero sets it to its + /// [`Encrypt::FIXED`]. const FIXED: Option = None; fn from_scalar(ty: Option, value: Physical<'_>) -> Option; @@ -192,10 +205,13 @@ impl Sink for ScalarSink { impl Decrypt for T { type Sink = ScalarSink; - const FIXED: Option = T::FIXED; fn sink() -> ScalarSink { ScalarSink(PhantomData) } + + fn reads_null(ty: FixedType) -> bool { + T::FIXED == Some(ty) || T::from_scalar(ty.logical(), ty.width().zero()).is_some() + } } /// A sink nothing is ever walked into: the element and entry type of a @@ -279,11 +295,10 @@ impl MapSink for Never { } macro_rules! int_target { - ($($ty:ty => $width:ident),* $(,)?) => { + ($($ty:ty),* $(,)?) => { $( impl FromScalar for $ty { const EXPECTED: &'static str = stringify!($ty); - const FIXED: Option = Some(FixedType::plain(Width::$width)); fn from_scalar(ty: Option, v: Physical<'_>) -> Option { match (ty, v) { (None, Physical::Int(i)) => i @@ -300,10 +315,7 @@ macro_rules! int_target { // `u8` is not a target for the same reason it is not a source: `Vec` is // a byte leaf, and the generic `Vec` sequence target must not overlap it. -int_target! { - i8 => I8, i16 => I16, i32 => I32, i64 => I64, i128 => I128, - u16 => U16, u32 => U32, u64 => U64, u128 => U128, -} +int_target!(i8, i16, i32, i64, i128, u16, u32, u64, u128); impl FromScalar for bool { const EXPECTED: &'static str = "bool"; @@ -317,7 +329,6 @@ impl FromScalar for bool { impl FromScalar for f32 { const EXPECTED: &'static str = "f32"; - const FIXED: Option = Some(FixedType::plain(Width::F32)); fn from_scalar(ty: Option, v: Physical<'_>) -> Option { match (ty, v) { (None, Physical::Float(Float::F32(f))) => Some(f), @@ -329,7 +340,6 @@ impl FromScalar for f32 { /// Widening from `f32` is exact, so an `f64` target accepts both widths. impl FromScalar for f64 { const EXPECTED: &'static str = "f64"; - const FIXED: Option = Some(FixedType::plain(Width::F64)); fn from_scalar(ty: Option, v: Physical<'_>) -> Option { match (ty, v) { (None, Physical::Float(Float::F64(f))) => Some(f), @@ -391,7 +401,6 @@ mod chrono_targets { impl FromScalar for NaiveDate { const EXPECTED: &'static str = "date"; - const FIXED: Option = ::FIXED; fn from_scalar(ty: Option, v: Physical<'_>) -> Option { match (ty, v) { (Some(Logical::Date), Physical::Int(days)) => conv::date(days), @@ -404,7 +413,6 @@ mod chrono_targets { /// Rust timestamp in nanoseconds. impl FromScalar for DateTime { const EXPECTED: &'static str = "timestamp"; - const FIXED: Option = as Encrypt>::FIXED; fn from_scalar(ty: Option, v: Physical<'_>) -> Option { match (ty, v) { (Some(Logical::Timestamp(unit)), Physical::Int(count)) => { @@ -419,7 +427,6 @@ mod chrono_targets { #[cfg(feature = "rust_decimal")] impl FromScalar for rust_decimal::Decimal { const EXPECTED: &'static str = "decimal"; - const FIXED: Option = ::FIXED; fn from_scalar(ty: Option, v: Physical<'_>) -> Option { match (ty, v) { (Some(Logical::Decimal { scale }), Physical::Int(mantissa)) => { @@ -900,8 +907,9 @@ macro_rules! dict_target { dict_target!(HashMap, BTreeMap); /// `Option`: null and `undefined` are `None`; anything else is `T`. -/// A typed null is `None` if it is of `T`'s [`FIXED`](Decrypt::FIXED) -/// type, and otherwise refused by `T`'s sink as the wrong kind. +/// A typed null is `None` if `T` reads values of its type +/// ([`Decrypt::reads_null`]), and otherwise refused by `T`'s sink as the +/// wrong kind. /// /// Since `None` is written as null, `Option>` cannot tell /// `Some(None)` from `None`: both read back as `None`. @@ -921,7 +929,7 @@ impl Sink for Optional { } fn typed_null(self, ty: FixedType) -> Result, DecodeError> { - if T::FIXED == Some(ty) { + if T::reads_null(ty) { return Ok(None); } self.0.typed_null(ty).map(Some) @@ -979,10 +987,13 @@ impl> MapSink for Some_ { impl Decrypt for Option { type Sink = Optional; - const FIXED: Option = T::FIXED; fn sink() -> Optional { Optional(T::sink()) } + + fn reads_null(ty: FixedType) -> bool { + T::reads_null(ty) + } } /// A type that can be decrypted straight into `Protected`: it names @@ -1000,10 +1011,13 @@ pub trait DecryptProtected: Decrypt + Zeroize { impl Decrypt for Protected { type Sink = T::ProtectedSink; - const FIXED: Option = T::FIXED; fn sink() -> T::ProtectedSink { T::protected_sink() } + + fn reads_null(ty: FixedType) -> bool { + T::reads_null(ty) + } } /// Any scalar target that can be wiped, read into `Protected`. A heap @@ -1161,7 +1175,7 @@ impl Sink for ProtectedOptional { } fn typed_null(self, ty: FixedType) -> Result { - if T::FIXED == Some(ty) { + if T::reads_null(ty) { return Ok(Protected::new(None)); } self.0.typed_null(ty).map(some) @@ -1320,7 +1334,7 @@ where #[cfg(test)] mod tests { use super::*; - use crate::plaintext::{Int, Raw}; + use crate::plaintext::{Int, Raw, Width}; use crate::seal::mock::{Mock, Remote}; use crate::{encrypt, encrypt_async}; use futures_executor::block_on; @@ -1427,8 +1441,12 @@ mod tests { } } + fn plain(width: Width) -> FixedType { + FixedType::plain(width).expect("fixed") + } + #[test] - fn a_typed_null_reads_as_none_only_for_an_option_of_its_type() { + fn a_typed_null_reads_as_none_for_an_option_of_a_type_that_reads_its_values() { // Sealed by `Option`'s own `None`, and read back by it. assert_eq!(round_trip::>(None::).expect("u32"), None); assert_eq!(round_trip::>(None::).expect("i8"), None); @@ -1441,25 +1459,30 @@ mod tests { round_trip::>>(vec![None, Some(3u16)]).expect("elements"), [None, Some(3)] ); - // Any other type's typed null is the wrong kind, never a `None`: - // not another width (even one whose values the target reads), not - // a float for an int, not a logical type over the same int. - let u32_null = || Null(FixedType::plain(Width::U32)); - assert_eq!(expected(round_trip::>(u32_null())), "u64"); - assert_eq!(expected(round_trip::>(u32_null())), "f32"); - assert_eq!(expected(round_trip::>(u32_null())), "string"); + // A widened field reads its old `None`s as it reads its old values. + assert_eq!(round_trip::>(None::).expect("wider"), None); assert_eq!( - expected(round_trip::>(Null(FixedType::plain( - Width::F32 - )))), - "f64" + round_trip::>(None::).expect("narrower"), + None ); - let date = FixedType::new(Some(Logical::Date), Width::I32).expect("fixed"); - assert_eq!(expected(round_trip::>(Null(date))), "i32"); + assert_eq!(round_trip::>(None::).expect("f64"), None); + } + + #[test] + fn a_typed_null_of_a_type_the_target_does_not_read_is_the_wrong_kind() { + let u32_null = || Null(plain(Width::U32)); + assert_eq!(expected(round_trip::>(u32_null())), "f32"); + assert_eq!(expected(round_trip::>(u32_null())), "string"); + assert_eq!(expected(round_trip::>>(u32_null())), "bytes"); assert_eq!( expected(round_trip::>>(u32_null())), "sequence" ); + assert_eq!(expected(round_trip::>(None::)), "f32"); + assert_eq!(expected(round_trip::>(None::)), "u32"); + // A logical type over the same int is not that int. + let date = FixedType::new(Some(Logical::Date), Width::I32).expect("fixed"); + assert_eq!(expected(round_trip::>(Null(date))), "i32"); } #[test] @@ -1499,31 +1522,34 @@ mod tests { &None ); assert_eq!( - expected(round_trip::>>(None::)), - "u64" + expected(round_trip::>>(None::)), + "f32" ); } + /// What every built-in source seals for `None`, its target reads back. #[test] - fn decrypt_and_encrypt_agree_on_every_fixed_type() { - fn same() { - assert_eq!(::FIXED, ::FIXED); + fn every_target_reads_its_own_sources_none() { + fn own() { + let ty = ::FIXED.expect("fixed width"); + assert!(::reads_null(ty), "{ty:?}"); + assert!( as Decrypt>::reads_null(ty), "{ty:?}"); } - same::(); - same::(); - same::(); - same::(); - same::(); - same::(); - same::(); - same::(); - same::(); - same::(); - same::(); - same::(); - same::(); - same::>(); - same::>(); + own::(); + own::(); + own::(); + own::(); + own::(); + own::(); + own::(); + own::(); + own::(); + own::(); + own::(); + // And a type with no fixed width reads none. + assert!(!::reads_null(plain(Width::U32))); + assert!(! as Decrypt>::reads_null(plain(Width::U32))); + assert!(!::reads_null(plain(Width::U8))); } #[test] diff --git a/packages/aead/tests/typed_null.rs b/packages/aead/tests/typed_null.rs index 68cc0d2e..cc3ca0c1 100644 --- a/packages/aead/tests/typed_null.rs +++ b/packages/aead/tests/typed_null.rs @@ -137,10 +137,12 @@ struct Age(u32); fn a_derived_enum_and_newtype_hide_absence() { hides_absence(Status::Suspended); hides_absence(Age(42)); - assert_eq!(::FIXED, Some(FixedType::plain(Width::U32))); - assert_eq!(::FIXED, Some(FixedType::plain(Width::U32))); - assert_eq!(<&Age as Encrypt>::FIXED, Some(FixedType::plain(Width::U32))); - assert_eq!(::FIXED, ::FIXED); + let u32 = FixedType::plain(Width::U32); + assert_eq!(::FIXED, u32); + assert_eq!(<&Age as Encrypt>::FIXED, u32); + assert_eq!(::FIXED, u32); + assert!(::reads_null(u32.expect("fixed"))); + assert!(::reads_null(u32.expect("fixed"))); // An absent enum is not one of its variants. let read: Result = decrypt( &Mock, @@ -202,21 +204,76 @@ fn a_struct_with_optional_fields_seals_the_same_lengths_either_way() { } } -/// The frame carries a typed null both ways, so a host that encrypts from -/// a frame (the Go guest) pads it, and decrypting to a frame keeps it. +/// A frame a host sends may carry a typed null, which is sealed padded. A +/// frame written here is plaintext for a host, so it carries the plain +/// null every host decoder reads: padding hides nothing unsealed. #[test] -fn a_typed_null_survives_the_frame_both_ways() { - let frame = to_frame(None::).expect("frames"); +fn a_frame_reads_a_typed_null_and_writes_the_plain_null() { + let from_host = [tags::TYPED_NULL, tags::UINT32, 0, 0, 0]; + let parsed = Frame::parse(&from_host).expect("parses"); + assert_eq!(sealed(parsed), from_host); + assert_eq!( - frame.risky_ref(), - &[tags::TYPED_NULL, tags::UINT32, 0, 0, 0] + to_frame(None::).expect("frames").risky_ref(), + &[tags::NULL] ); - let parsed = Frame::parse(frame.risky_ref()).expect("parses"); - assert_eq!(sealed(parsed), [tags::TYPED_NULL, tags::UINT32, 0, 0, 0]); - let node = encrypt(None::, &Mock, "ctx").expect("encrypts"); let back = decrypt_to_frame(&Mock, node, "ctx").expect("decrypts"); - assert_eq!(back.risky_ref(), frame.risky_ref()); + assert_eq!(back.risky_ref(), &[tags::NULL]); +} + +#[derive(Encrypt, Decrypt, Debug, PartialEq, Clone)] +struct Row { + #[aead(passthrough)] + rank: Option, + secret: Option, +} + +/// A passthrough field is stored in the clear for other readers, so its +/// absence is the plain null, unchanged; only the sealed field pads. +#[test] +fn a_passthrough_none_stays_the_plain_null() { + let row = Row { + rank: None, + secret: None, + }; + let CipherText::Map(entries) = encrypt(row.clone(), &Mock, "ctx").expect("encrypts") else { + panic!("not a map"); + }; + for (key, value) in entries { + match (key.as_str(), value) { + ("rank", CipherText::Passthrough(frame)) => assert_eq!(frame.0, [tags::NULL]), + ("secret", CipherText::Single(leaf)) => { + assert_eq!(leaf.plaintext, [tags::TYPED_NULL, tags::UINT32, 0, 0, 0]) + } + (key, other) => panic!("{key}: {other:?}"), + } + } + let read: Row = decrypt( + &Mock, + encrypt(row.clone(), &Mock, "ctx").expect("encrypts"), + "ctx", + ) + .expect("decrypts"); + assert_eq!(read, row); +} + +#[cfg(feature = "uuid")] +#[test] +fn a_uuid_hides_absence() { + hides_absence(uuid::Uuid::from_bytes([0xAB; 16])); +} + +/// A field widened after data was written reads its old `None`s as it +/// reads its old values. +#[test] +fn a_widened_field_reads_its_old_nones() { + let node = encrypt(None::, &Mock, "ctx").expect("encrypts"); + let read: Option = decrypt(&Mock, node, "ctx").expect("decrypts"); + assert_eq!(read, None); + let node = encrypt(None::, &Mock, "ctx").expect("encrypts"); + let read: Option = decrypt(&Mock, node, "ctx").expect("decrypts"); + assert_eq!(read, None); } /// A typed null whose padding is not zero is not an absence: a writer with diff --git a/packages/encrypt/src/cipher_static.rs b/packages/encrypt/src/cipher_static.rs index ab3779b1..2f20749f 100644 --- a/packages/encrypt/src/cipher_static.rs +++ b/packages/encrypt/src/cipher_static.rs @@ -8,9 +8,10 @@ use vitaminc_aead::{tags, IntoAad, Unspecified}; use vitaminc_protected::{Controlled, Protected}; use zeroize::Zeroizing; -/// What an [`Absent`] seals: the null leaf, as `None` is everywhere else -/// (ADR 0001). No separate absence derivation is needed, because the leaf -/// itself says what it is. +/// What an [`Absent`] seals: the null leaf, as a `None` of bytes is +/// everywhere else (ADR 0001). No separate absence derivation is needed, +/// because the leaf itself says what it is. An [`Encrypted`] holds bytes, +/// which are variable width, so its absence is not padded. const NULL_LEAF: [u8; 1] = [tags::NULL]; /// What an [`Encrypted`] seals: the caller's bytes as a tagged bytes leaf, @@ -71,8 +72,8 @@ impl StaticCipher for &Aes256Cipher { A: IntoAad<'a>, { // The marker is a sealed null leaf under the slot's own AAD: the same - // bytes `encrypt` seals for a `None` there, so the static and the - // sink-based paths agree on what absence is. An `Encrypted` is a + // bytes `encrypt` seals for a `None::>` there, so the static + // and the sink-based paths agree on what an absent byte string is. An `Encrypted` is a // tagged bytes leaf, so neither can stand in for the other: // `verify_absent` requires the null leaf and `open_encrypted` the // bytes tag. @@ -490,7 +491,7 @@ mod test { } // An `Absent` is a sealed null leaf, which is what `encrypt` seals for - // a `None` too, so the two paths agree on absence. An `Encrypted` is a + // a `None::>` too, so the two paths agree on absence. An `Encrypted` is a // tagged bytes leaf, which is what `encrypt` seals for a `Vec`. #[test] From ca38b7a8b61cc93afd3d59c8ab0347b3ee3253d3 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 21 Oct 2026 10:40:00 +1100 Subject: [PATCH 3/4] test(aead): pin reads_null forwarding, the passthrough sink's typed-null refusal and the derived FIXED, so the mutants gate passes Option and Protected forward reads_null to their inner target, Clear refuses a typed null like any non-passthrough, and the derive declares FIXED by shape and leaves it out of a recursive newtype; tests now pin each. Nothing's typed_null is unreachable (Nothing is uninhabited), so it joins scalar and passthrough in the mutants exclusion, and the default reads_null drops its unused binding so its body is plainly false. --- .cargo/mutants.toml | 9 +++-- packages/aead-derive/src/encrypt.rs | 62 +++++++++++++++++++++++++++++ packages/aead/src/typed.rs | 10 ++++- 3 files changed, 75 insertions(+), 6 deletions(-) diff --git a/.cargo/mutants.toml b/.cargo/mutants.toml index 84eec229..d90b29d2 100644 --- a/.cargo/mutants.toml +++ b/.cargo/mutants.toml @@ -55,10 +55,11 @@ exclude_re = [ # and the port is checked byte for byte against the frozen crate besides. "replace \\| with \\^ in (orderable|strip_trailing_zeros)$", # Unreachable, not untested: `Nothing` (vitaminc-aead typed.rs) is an - # uninhabited enum, so no value exists to call `scalar` or `passthrough` - # on and replacing either body with `Ok(())` cannot be observed. Its - # `seq` and `map` mutants are unviable for the same reason. - "replace ::(scalar|passthrough) ", + # uninhabited enum, so no value exists to call `scalar`, `typed_null` or + # `passthrough` on and replacing any of those bodies with `Ok(())` cannot + # be observed. Its `seq` and `map` mutants are unviable for the same + # reason. + "replace ::(scalar|typed_null|passthrough) ", # Equivalent mutant: `max` (vitaminc-aead plaintext/depth.rs) picks the # larger of two depths, and when they are equal `a > b` and `a >= b` # return the same number, so no input can tell them apart. diff --git a/packages/aead-derive/src/encrypt.rs b/packages/aead-derive/src/encrypt.rs index dbd24c1c..bc1687df 100644 --- a/packages/aead-derive/src/encrypt.rs +++ b/packages/aead-derive/src/encrypt.rs @@ -743,6 +743,68 @@ mod tests { assert_lacks(&out, quote!(::vitaminc_aead::Physical::Text)); } + /// What an absent value of the type is sealed as: a newtype's inner + /// type's, by value and by reference; a `u32` leaf for a fieldless + /// enum; nothing for a struct, which is a map. + #[test] + fn fixed_follows_the_shape() { + let out = expand(parse_quote!( + struct Age(u32); + )); + assert_contains( + &out, + quote!( + const FIXED: ::core::option::Option<::vitaminc_aead::FixedType> = + ::FIXED; + ), + ); + assert_contains( + &out, + quote!( + const FIXED: ::core::option::Option<::vitaminc_aead::FixedType> = + <&'__r u32 as ::vitaminc_aead::Encrypt>::FIXED; + ), + ); + + let out = expand(parse_quote! { + enum Status { + Active, + } + }); + assert_contains( + &out, + quote!( + const FIXED: ::core::option::Option<::vitaminc_aead::FixedType> = + ::vitaminc_aead::FixedType::plain(::vitaminc_aead::Width::U32); + ), + ); + + let out = expand(parse_quote! { + struct User { + id: u32, + } + }); + assert_lacks(&out, quote!(const FIXED)); + } + + /// A recursive newtype keeps the default `FIXED` rather than name its + /// own constant in a cycle, whether it names itself or says so with + /// `#[aead(recursive)]`. + #[test] + fn a_recursive_newtype_declares_no_fixed() { + for input in [ + parse_quote!( + struct Wrapped(Option>); + ), + parse_quote!( + #[aead(recursive)] + struct Even(Option>); + ), + ] { + assert_lacks(&expand(input), quote!(const FIXED)); + } + } + /// Serde-style: bounding the parameter rather than each field's type lets a /// field of type `Vec` pick up `Vec: Encrypt` from the blanket /// impl. The by-reference impl bounds the parameter by reference, and its diff --git a/packages/aead/src/typed.rs b/packages/aead/src/typed.rs index 0d1ead52..1327d159 100644 --- a/packages/aead/src/typed.rs +++ b/packages/aead/src/typed.rs @@ -107,8 +107,7 @@ pub trait Decrypt: Sized { /// [`FromScalar::FIXED`]), so a `None` reads back wherever a `Some` /// does, a widened field included. `false`, the default, for a target /// that is not a scalar, whose `Option` then refuses every typed null. - fn reads_null(ty: FixedType) -> bool { - let _ = ty; + fn reads_null(_: FixedType) -> bool { false } } @@ -1550,6 +1549,12 @@ mod tests { assert!(!::reads_null(plain(Width::U32))); assert!(! as Decrypt>::reads_null(plain(Width::U32))); assert!(!::reads_null(plain(Width::U8))); + // `Option` and `Protected` read whatever their inner target reads. + assert!(! as Decrypt>::reads_null(plain(Width::U32))); + assert!( as Decrypt>::reads_null(plain(Width::U32))); + assert!(! as Decrypt>::reads_null(plain( + Width::U32 + ))); } #[test] @@ -1720,6 +1725,7 @@ mod tests { }; let mut held: Option = None; refused(Clear::::new(Slot(&mut held)).scalar(None, Physical::Null)); + refused(Clear::::new(Slot(&mut held)).typed_null(plain(Width::U16))); refused(Clear::::new(Slot(&mut held)).seq(None).map(|_| ())); refused(Clear::::new(Slot(&mut held)).map(None).map(|_| ())); assert!(matches!( From 9be42c89d5756ce2886b8de2f4ab6c97e26a6afe Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 21 Oct 2026 11:30:01 +1100 Subject: [PATCH 4/4] test(ore,normalize): pin that a fixed-width None gets no index term The ore scalar sink and the normalize Terms sink both forward a typed null to the plain-null path, which has no term. Nothing visited that forward: the existing tests only sent a plain null, so a change to the forward could give an absent field an order or equality term unnoticed. A test on each sink now walks a None of a fixed-width type and checks that only the present field has a term. --- packages/normalize/tests/normalizer.rs | 19 +++++++++++++++++++ packages/ore/src/scheme.rs | 20 ++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/packages/normalize/tests/normalizer.rs b/packages/normalize/tests/normalizer.rs index 6abdae34..774e8379 100644 --- a/packages/normalize/tests/normalizer.rs +++ b/packages/normalize/tests/normalizer.rs @@ -96,3 +96,22 @@ fn two_normalizers_share_one_walk() { [".a"] ); } + +/// A fixed-width `None` reaches the sink as a typed null. It is indexed +/// as a plain null, which has no term, so an absent field gains no term +/// and does not stop the walk (cipherstash/vitaminc#493). +#[test] +fn a_fixed_width_none_has_no_term_and_does_not_stop_the_walk() { + let mut doc = BTreeMap::new(); + doc.insert("gone", None::); + doc.insert("here", Some(5i32)); + let found = terms(doc).expect("a typed null does not stop the walk"); + assert_eq!( + found.iter().map(|t| render(&t.path)).collect::>(), + [".here"], + "only the present field has a term" + ); + let by_name = terms_by(LowByte, BTreeMap::from([("gone", None::)])) + .expect("a typed null does not stop a normalizer of the caller's own"); + assert!(by_name.is_empty(), "a typed null has no term under LowByte"); +} diff --git a/packages/ore/src/scheme.rs b/packages/ore/src/scheme.rs index 839ee5ee..502e909d 100644 --- a/packages/ore/src/scheme.rs +++ b/packages/ore/src/scheme.rs @@ -968,4 +968,24 @@ mod tests { Err(ScalarError::Term(NormalizeError::NoDomain)) )); } + + /// A fixed-width `None` reaches the sink as a typed null, which gets + /// no order term, the same as a plain null (cipherstash/vitaminc#493). + #[test] + fn the_scalar_sink_gives_a_typed_null_no_term_like_a_plain_null() { + assert!( + matches!( + None::.visit(Scalar(OrderTarget::U32)), + Err(ScalarError::Term(NormalizeError::NoDomain)) + ), + "a typed null must have no order term" + ); + assert!( + matches!( + None::.visit(Scalar(OrderTarget::Text)), + Err(ScalarError::Term(NormalizeError::NoDomain)) + ), + "the target does not give a typed null a term either" + ); + } }