From e2a6a4f74053b4bc05812e32e7e20df491a8df27 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sun, 4 Oct 2026 23:23:11 -0700 Subject: [PATCH 1/3] feat(aead-value): name an FfiValue's kind without a value A binding for a dynamically typed host has to declare what a field's values are before it has one, and the crate offered no "type without a value". cipherstash/stack#1069 had to mirror FfiValue's variants in its own eleven-variant enum, with hand-kept tables from each variant to its leaf tags, from a value to its variant, and to and from wire names. Every new FfiValue variant would have to be added there too, with nothing failing if it was missed. ValueKind is that vocabulary, owned next to the tag table it mirrors: FfiValue::kind, ValueKind::{ALL, name, tags, holds}, Display and FromStr. Null, Undefined and Passthrough have no kind: the first two are single-valued, and passthrough is a transport choice whose payload has a kind of its own. FfiValue::kind matches exhaustively, so a new variant must choose its kind before the crate compiles. The names (bool, int32, int64, uint32, uint64, float32, float64, string, bytes, array, object) are already on the wire in the consumer's plan grammar and are frozen like the tags. Conversion and index rules stay with the consumer. --- packages/aead-value/README.md | 12 ++ packages/aead-value/src/kind.rs | 362 ++++++++++++++++++++++++++++++++ packages/aead-value/src/lib.rs | 2 + 3 files changed, 376 insertions(+) create mode 100644 packages/aead-value/src/kind.rs diff --git a/packages/aead-value/README.md b/packages/aead-value/README.md index f250961b..5bc039d2 100644 --- a/packages/aead-value/README.md +++ b/packages/aead-value/README.md @@ -109,6 +109,17 @@ decode: `INT32`↔`int32`, `UINT32`↔`uint32`, `FLOAT32`↔`float32`, and the the matching width (e.g. `int16`→`INT32`), and `uint`/`uintptr` map to `UINT64`. +## Declaring a kind without a value + +A binding often has to say what a field's values are before it has one. +[`ValueKind`] is the `FfiValue` model without the payload: `bool`, `int32`, +`int64`, `uint32`, `uint64`, `float32`, `float64`, `string`, `bytes`, +`array` and `object`. Those names are frozen wire format, like the tag +table. `Null`, `Undefined` and `Passthrough` have no kind: the first two +are single-valued, and passthrough is a transport choice, not a type. +`FfiValue::kind` reports a value's kind, `ValueKind::holds` checks one, +and `ValueKind::tags` maps a kind to the tags above. + ## Security notes - String and byte leaves are held in `Protected`, so the Rust-side copies @@ -121,6 +132,7 @@ the matching width (e.g. `int16`→`INT32`), and `uint`/`uintptr` map to everywhere in Vitamin-C. [`FfiValue`]: crate::FfiValue +[`ValueKind`]: crate::ValueKind [`Cipher`]: vitaminc_aead::Cipher [`Decipher`]: vitaminc_aead::Decipher [`Context::for_map_entry`]: vitaminc_aead::Context::for_map_entry diff --git a/packages/aead-value/src/kind.rs b/packages/aead-value/src/kind.rs new file mode 100644 index 00000000..fa0c9e72 --- /dev/null +++ b/packages/aead-value/src/kind.rs @@ -0,0 +1,362 @@ +//! Value kinds — the FFI type vocabulary, without a value. +//! +//! A binding for a dynamically typed host (JavaScript, PHP, Ruby) often has +//! to say what a field's values *are* before it has one: "this field holds +//! `uint64`s", not "here is a `34`". [`ValueKind`] is that declaration. It is +//! the [`FfiValue`] model with the payload taken away: one kind per scalar +//! tag family in [`tags`] (two tags for `bool`, whose value is its tag) plus +//! the two containers, [`Array`](ValueKind::Array) and +//! [`Object`](ValueKind::Object). +//! +//! # What is not a kind +//! +//! - **`Null` and `Undefined`.** They are tagged leaves, but each is a type +//! with exactly one value, so declaring a field as one says nothing a +//! binding could use. A field declared with a kind is a field whose values +//! are of that kind; whether it may also be absent is the declaring +//! layer's business, not this vocabulary's. +//! - **`Passthrough`.** It is a transport choice (send this subtree in the +//! clear), not a type: the wrapped value has a kind of its own. +//! +//! So [`FfiValue::kind`] is `None` for those three variants, and no kind +//! [`holds`](ValueKind::holds) them. +//! +//! # Names are wire format +//! +//! [`ValueKind::name`] (also its [`Display`](fmt::Display) and +//! [`FromStr`] form) is how a binding spells a kind in a +//! declaration that crosses the FFI boundary. Like the tag table, the names +//! are frozen: they are matched exactly, case-sensitively, and are never +//! renamed. A new kind takes a new name. +//! +//! This module defines the vocabulary only. Rules over it — which numeric +//! kinds convert into which, which kinds an index is defined for — belong to +//! the layer that declares fields, not here. +//! +//! ``` +//! use vitaminc_aead_value::{FfiValue, ValueKind}; +//! +//! let kind: ValueKind = "uint64".parse().unwrap(); +//! assert_eq!(kind, ValueKind::UInt64); +//! assert!(kind.holds(&FfiValue::UInt64(34))); +//! assert!(!kind.holds(&FfiValue::Float64(34.0))); +//! assert_eq!(FfiValue::UInt64(34).kind(), Some(ValueKind::UInt64)); +//! assert_eq!(FfiValue::Null.kind(), None); +//! ``` + +use std::fmt; +use std::str::FromStr; + +use crate::{tags, FfiValue}; + +/// The kind of an [`FfiValue`]: its variant, without a payload. +/// +/// This is the type vocabulary a binding declares a field with. See the +/// [module docs](self) for why `Null`, `Undefined` and `Passthrough` have no +/// kind, and why [`name`](Self::name) is frozen. +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] +pub enum ValueKind { + /// `"bool"`: [`FfiValue::Bool`], tags [`BOOL_FALSE`](tags::BOOL_FALSE) + /// and [`BOOL_TRUE`](tags::BOOL_TRUE). + Bool, + /// `"int32"`: [`FfiValue::Int32`], tag [`INT32`](tags::INT32). + Int32, + /// `"int64"`: [`FfiValue::Int64`], tag [`INT64`](tags::INT64). + Int64, + /// `"uint32"`: [`FfiValue::UInt32`], tag [`UINT32`](tags::UINT32). + UInt32, + /// `"uint64"`: [`FfiValue::UInt64`], tag [`UINT64`](tags::UINT64). + UInt64, + /// `"float32"`: [`FfiValue::Float32`], tag [`FLOAT32`](tags::FLOAT32). + Float32, + /// `"float64"`: [`FfiValue::Float64`], tag [`FLOAT64`](tags::FLOAT64). + Float64, + /// `"string"`: [`FfiValue::String`], tag [`STRING`](tags::STRING). + String, + /// `"bytes"`: [`FfiValue::Bytes`], tag [`BYTES`](tags::BYTES). + Bytes, + /// `"array"`: [`FfiValue::Array`], sealed in the cipher's sequence mode + /// with no tag of its own. Says nothing about the elements' kinds. + Array, + /// `"object"`: [`FfiValue::Object`], sealed in the cipher's map mode + /// with no tag of its own. Says nothing about the entries' kinds. + Object, +} + +impl ValueKind { + /// Every kind, in declaration order. + pub const ALL: [ValueKind; 11] = [ + ValueKind::Bool, + ValueKind::Int32, + ValueKind::Int64, + ValueKind::UInt32, + ValueKind::UInt64, + ValueKind::Float32, + ValueKind::Float64, + ValueKind::String, + ValueKind::Bytes, + ValueKind::Array, + ValueKind::Object, + ]; + + /// How a declaration spells this kind: `"bool"`, `"int32"`, `"int64"`, + /// `"uint32"`, `"uint64"`, `"float32"`, `"float64"`, `"string"`, + /// `"bytes"`, `"array"` or `"object"`. Frozen wire format. + pub const fn name(self) -> &'static str { + match self { + ValueKind::Bool => "bool", + ValueKind::Int32 => "int32", + ValueKind::Int64 => "int64", + ValueKind::UInt32 => "uint32", + ValueKind::UInt64 => "uint64", + ValueKind::Float32 => "float32", + ValueKind::Float64 => "float64", + ValueKind::String => "string", + ValueKind::Bytes => "bytes", + ValueKind::Array => "array", + ValueKind::Object => "object", + } + } + + /// The [`tags`] a value of this kind seals under: one per scalar kind, + /// two for [`Bool`](Self::Bool) (the value is the tag), and none for + /// [`Array`](Self::Array) or [`Object`](Self::Object), which seal as + /// structure rather than as a tagged leaf. + pub const fn tags(self) -> &'static [u8] { + match self { + ValueKind::Bool => &[tags::BOOL_FALSE, tags::BOOL_TRUE], + ValueKind::Int32 => &[tags::INT32], + ValueKind::Int64 => &[tags::INT64], + ValueKind::UInt32 => &[tags::UINT32], + ValueKind::UInt64 => &[tags::UINT64], + ValueKind::Float32 => &[tags::FLOAT32], + ValueKind::Float64 => &[tags::FLOAT64], + ValueKind::String => &[tags::STRING], + ValueKind::Bytes => &[tags::BYTES], + ValueKind::Array | ValueKind::Object => &[], + } + } + + /// Whether `value` is of this kind. Exact: an `Int64` is not held by + /// [`UInt64`](Self::UInt64), and a [`FfiValue::Passthrough`] is held by + /// no kind, whatever it wraps. + pub fn holds(self, value: &FfiValue) -> bool { + value.kind() == Some(self) + } +} + +impl fmt::Display for ValueKind { + /// Writes [`name`](Self::name). + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.name()) + } +} + +impl FromStr for ValueKind { + type Err = ParseValueKindError; + + /// Parses a [`name`](Self::name), exactly: `"uint64"`, not `"UInt64"`, + /// `"u64"` or `" uint64"`. `"null"` and `"undefined"` are not kinds. + fn from_str(name: &str) -> Result { + ValueKind::ALL + .into_iter() + .find(|kind| kind.name() == name) + .ok_or(ParseValueKindError) + } +} + +/// The error from parsing a string that is not a [`ValueKind::name`]. +/// +/// Carries no detail: the caller holds the string it tried to parse. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub struct ParseValueKindError; + +impl fmt::Display for ParseValueKindError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("not a value kind") + } +} + +impl std::error::Error for ParseValueKindError {} + +impl FfiValue { + /// This value's [`ValueKind`], or `None` for [`Null`](FfiValue::Null), + /// [`Undefined`](FfiValue::Undefined) and + /// [`Passthrough`](FfiValue::Passthrough), which no field is declared as + /// (see the [`kind` module docs](crate::kind)). + pub fn kind(&self) -> Option { + // Exhaustive on purpose: a new `FfiValue` variant must decide its + // kind here before the crate compiles. + match self { + FfiValue::Bool(_) => Some(ValueKind::Bool), + FfiValue::Int32(_) => Some(ValueKind::Int32), + FfiValue::Int64(_) => Some(ValueKind::Int64), + FfiValue::UInt32(_) => Some(ValueKind::UInt32), + FfiValue::UInt64(_) => Some(ValueKind::UInt64), + FfiValue::Float32(_) => Some(ValueKind::Float32), + FfiValue::Float64(_) => Some(ValueKind::Float64), + FfiValue::String(_) => Some(ValueKind::String), + FfiValue::Bytes(_) => Some(ValueKind::Bytes), + FfiValue::Array(_) => Some(ValueKind::Array), + FfiValue::Object(_) => Some(ValueKind::Object), + FfiValue::Null | FfiValue::Undefined | FfiValue::Passthrough(_) => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use vitaminc_protected::Protected; + + /// One value per `FfiValue` variant, and the kind it should report. + /// + /// The `match` is exhaustive with no wildcard, so adding an `FfiValue` + /// variant fails to compile here until it is given a sample and a kind + /// (or explicitly none). + fn samples() -> Vec<(FfiValue, Option)> { + let all = [ + FfiValue::Null, + FfiValue::Undefined, + FfiValue::Bool(false), + FfiValue::Bool(true), + FfiValue::Int32(-3), + FfiValue::Int64(-4), + FfiValue::UInt32(34), + FfiValue::UInt64(35), + FfiValue::Float32(1.5), + FfiValue::Float64(2.5), + FfiValue::String("alice".into()), + FfiValue::Bytes(Protected::new(b"ab".to_vec())), + FfiValue::Array(vec![FfiValue::UInt32(1)]), + FfiValue::Object(vec![("k".to_string(), FfiValue::UInt32(1))]), + FfiValue::Passthrough(Box::new(FfiValue::UInt32(1))), + ]; + all.into_iter() + .map(|value| { + let expected = match &value { + FfiValue::Null | FfiValue::Undefined | FfiValue::Passthrough(_) => None, + FfiValue::Bool(_) => Some(ValueKind::Bool), + FfiValue::Int32(_) => Some(ValueKind::Int32), + FfiValue::Int64(_) => Some(ValueKind::Int64), + FfiValue::UInt32(_) => Some(ValueKind::UInt32), + FfiValue::UInt64(_) => Some(ValueKind::UInt64), + FfiValue::Float32(_) => Some(ValueKind::Float32), + FfiValue::Float64(_) => Some(ValueKind::Float64), + FfiValue::String(_) => Some(ValueKind::String), + FfiValue::Bytes(_) => Some(ValueKind::Bytes), + FfiValue::Array(_) => Some(ValueKind::Array), + FfiValue::Object(_) => Some(ValueKind::Object), + }; + (value, expected) + }) + .collect() + } + + #[test] + fn every_variant_reports_its_kind_and_all_covers_every_kinded_variant() { + let mut reported = Vec::new(); + for (value, expected) in samples() { + assert_eq!(value.kind(), expected, "{value:?}"); + if let Some(kind) = expected { + if !reported.contains(&kind) { + reported.push(kind); + } + } + } + assert_eq!(reported, ValueKind::ALL, "ALL is every kind, in order"); + } + + #[test] + fn a_kind_holds_exactly_the_values_of_that_kind() { + for (value, expected) in samples() { + for kind in ValueKind::ALL { + assert_eq!( + kind.holds(&value), + Some(kind) == expected, + "{kind} holds {value:?}?" + ); + } + } + } + + #[test] + fn names_are_the_frozen_wire_spelling_and_round_trip() { + let names: Vec<_> = ValueKind::ALL.iter().map(|kind| kind.name()).collect(); + assert_eq!( + names, + [ + "bool", "int32", "int64", "uint32", "uint64", "float32", "float64", "string", + "bytes", "array", "object" + ], + "the names are wire format" + ); + for kind in ValueKind::ALL { + assert_eq!(kind.name().parse::(), Ok(kind)); + assert_eq!(kind.to_string(), kind.name()); + } + for not_a_kind in [ + "", + "UInt64", + "Bool", + "u64", + " uint64", + "uint64 ", + "int", + "number", + "null", + "undefined", + "passthrough", + "text", + ] { + assert_eq!( + not_a_kind.parse::(), + Err(ParseValueKindError), + "{not_a_kind:?}" + ); + } + assert_eq!(ParseValueKindError.to_string(), "not a value kind"); + } + + /// The kinds are the tag table: every tag but `NULL` and `UNDEFINED` + /// belongs to exactly one kind, and the containers to none. + #[test] + fn tags_partition_the_scalar_tag_table() { + let mut claimed: Vec = ValueKind::ALL + .iter() + .flat_map(|kind| kind.tags().iter().copied()) + .collect(); + claimed.sort_unstable(); + assert_eq!( + claimed, + [ + tags::BOOL_FALSE, + tags::BOOL_TRUE, + tags::INT32, + tags::INT64, + tags::UINT32, + tags::UINT64, + tags::FLOAT32, + tags::FLOAT64, + tags::STRING, + tags::BYTES, + ] + ); + let expected: [(ValueKind, &[u8]); 11] = [ + (ValueKind::Bool, &[tags::BOOL_FALSE, tags::BOOL_TRUE]), + (ValueKind::Int32, &[tags::INT32]), + (ValueKind::Int64, &[tags::INT64]), + (ValueKind::UInt32, &[tags::UINT32]), + (ValueKind::UInt64, &[tags::UINT64]), + (ValueKind::Float32, &[tags::FLOAT32]), + (ValueKind::Float64, &[tags::FLOAT64]), + (ValueKind::String, &[tags::STRING]), + (ValueKind::Bytes, &[tags::BYTES]), + (ValueKind::Array, &[]), + (ValueKind::Object, &[]), + ]; + for (kind, tags) in expected { + assert_eq!(kind.tags(), tags, "{kind}"); + } + } +} diff --git a/packages/aead-value/src/lib.rs b/packages/aead-value/src/lib.rs index 3cf6a334..03f05b4d 100644 --- a/packages/aead-value/src/lib.rs +++ b/packages/aead-value/src/lib.rs @@ -1,10 +1,12 @@ #![deny(clippy::unwrap_used, clippy::todo, unsafe_code, unused_imports)] #![doc = include_str!("../README.md")] pub mod aad; +pub mod kind; pub mod tagged; pub mod tags; pub mod transport; mod value; pub use aad::LeafTypeAad; +pub use kind::{ParseValueKindError, ValueKind}; pub use value::{FfiValue, Utf8String}; From 3d357aec37eda62e651cdcb7350dc757c0bfe67e Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sun, 4 Oct 2026 23:47:29 -0700 Subject: [PATCH 2/3] ci(crap): pin the toolchain to the release test.yml uses The CRAP gate ran on unpinned stable (1.98.1 today) while test.yml pins 1.96.0 so that trybuild snapshots do not break on a compiler that rewords a diagnostic. The gate also runs every workspace test under coverage, trybuild included, so the vitaminc-protected snapshot protected_ref_cannot_be_constructed.stderr failed on this PR for a note line rustc 1.98 added, with no change to that crate. Pin both jobs to the same release and bump them together. --- .github/workflows/crap.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/crap.yml b/.github/workflows/crap.yml index 242b3bf0..a7635193 100644 --- a/.github/workflows/crap.yml +++ b/.github/workflows/crap.yml @@ -61,9 +61,15 @@ jobs: target/ key: ${{ runner.os }}-cargo-crap-${{ hashFiles('**/Cargo.lock') }} - - name: setup rust stable + - name: setup rust + # Pinned to the same release as test.yml, not `@stable`: this job runs + # the whole workspace's tests under coverage, including the trybuild + # cases whose .stderr snapshots record one compiler's wording. On an + # unpinned stable a release that rewords a diagnostic fails unrelated + # PRs here while test.yml stays green. Bump both files together. uses: dtolnay/rust-toolchain@stable with: + toolchain: 1.96.0 # llvm-tools-preview is required by cargo-llvm-cov to produce coverage. components: llvm-tools-preview From 30c0c1c5444e30d8f057367155be8899f3d71ddb Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 5 Oct 2026 00:00:52 -0700 Subject: [PATCH 3/3] test(aead-value): every kinded leaf seals under a tag its kind names ValueKind::tags() is a second variant-to-tag map beside the match in encrypt_with_aad. The kat_* tests pin each side to literal bytes separately, so a tag moved in one map and not the other passed every test. This pins the two maps to each other, as the review on #364 asked. --- packages/aead-value/src/value.rs | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/packages/aead-value/src/value.rs b/packages/aead-value/src/value.rs index b10fd103..d888178b 100644 --- a/packages/aead-value/src/value.rs +++ b/packages/aead-value/src/value.rs @@ -625,6 +625,33 @@ mod tests { assert_eq!(leaf_bytes(FfiValue::Bool(true)), [0x03]); } + /// `ValueKind::tags()` is a second map from variant to tag; the first is + /// the `match` in `encrypt_with_aad`. Each `kat_*` test pins one side + /// against literal bytes. This pins the two sides against each other, so a + /// tag moved in one and not the other fails here. + #[test] + fn every_kinded_leaf_seals_under_a_tag_its_kind_names() { + for value in [ + FfiValue::Bool(false), + FfiValue::Bool(true), + FfiValue::Int32(-3), + FfiValue::Int64(-4), + FfiValue::UInt32(34), + FfiValue::UInt64(35), + FfiValue::Float32(1.5), + FfiValue::Float64(2.5), + s("alice"), + FfiValue::Bytes(Protected::new(b"ab".to_vec())), + ] { + let kind = value.kind().expect("a scalar leaf has a kind"); + let tag = leaf_bytes(value)[0]; + assert!( + kind.tags().contains(&tag), + "{kind} does not name tag {tag:#04x}" + ); + } + } + #[test] fn kat_int32() { assert_eq!(