From 1748fcdf8134d054edd2a57005c99371341b00c2 Mon Sep 17 00:00:00 2001 From: Rain Date: Tue, 20 Oct 2026 21:13:32 -0700 Subject: [PATCH] add Base64Slice, the borrowed counterpart of Base64Vec I'd like to use this in a static context. `Base64Slice` is just a borrowed `[u8]` and doesn't need alloc. To handle that we need to write a displayer ourselves, but that isn't too bad -- mostly just about computing parameters like width and fill ourselves, and adding tests to ensure the display impls are the same. --- .gitignore | 1 + CHANGELOG.md | 19 +++ Cargo.lock | 75 ++++++++++ Cargo.toml | 9 +- README.md | 14 +- src/base64_slice.rs | 181 +++++++++++++++++++++++ src/base64_vec.rs | 116 ++++++++++----- src/lib.rs | 19 ++- tests/integration/base64.rs | 4 +- tests/integration/base64_schemars.rs | 28 +++- tests/integration/base64_slice.rs | 187 ++++++++++++++++++++++++ tests/integration/base64_slice_fmt.rs | 76 ++++++++++ tests/integration/base64_slice_serde.rs | 77 ++++++++++ tests/integration/helpers.rs | 30 ++++ tests/integration/main.rs | 12 +- tests/integration/typify_roundtrip.rs | 18 ++- tests/output/typify-base64-slice-cow.rs | 62 ++++++++ 17 files changed, 873 insertions(+), 55 deletions(-) create mode 100644 src/base64_slice.rs create mode 100644 tests/integration/base64_slice.rs create mode 100644 tests/integration/base64_slice_fmt.rs create mode 100644 tests/integration/base64_slice_serde.rs create mode 100644 tests/integration/helpers.rs create mode 100644 tests/output/typify-base64-slice-cow.rs diff --git a/.gitignore b/.gitignore index ea8c4bf..534a382 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /target +/.hegel/ diff --git a/CHANGELOG.md b/CHANGELOG.md index b02ef43..20c125d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,25 @@ ## Unreleased - ReleaseDate +### Added + +- `Base64Slice`, the borrowed counterpart of `Base64Vec`, analogous to `str` + and `String`. + +### Changed + +- **Breaking:** `Base64Vec` now dereferences to `Base64Slice` instead of + `Vec`, and no longer implements `DerefMut`. `Base64Slice` dereferences + to `[u8]`, so slice methods such as `len` and indexing still work. To modify + the bytes, use the public field (`.0`) or `AsMut<[u8]>`. +- **Breaking:** The `base64` feature no longer implies `alloc`. `Base64Slice` + is now available without `alloc`, but `Base64Vec` and `ParseBase64Error` + require both `base64` and `alloc`. `alloc` is enabled by default; if you use + `default-features = false` with `features = ["base64"]`, add `"alloc"` to keep + `Base64Vec`. +- `Base64Slice` and `Base64Vec` now format with `Display` and `Debug` without + allocating an intermediate `String`. + ### Fixed - `Base64Vec` can now be deserialized from CBOR byte strings longer than 4096 diff --git a/Cargo.lock b/Cargo.lock index 6ee4627..19e4ebb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -44,6 +44,7 @@ dependencies = [ "base64", "ciborium", "expectorate", + "hegeltest", "hex", "hex-literal", "prettyplease", @@ -216,6 +217,27 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hegeltest" +version = "0.46.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0e0896c9c8642f7909acc2f476b54eef9ea10b31cddf56f52712319fbb2a465" +dependencies = [ + "hegeltest-macros", + "parking_lot", +] + +[[package]] +name = "hegeltest-macros" +version = "0.46.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77e536668b3fc88fc10bbe26357d21a357b7c893ccec458fc84de335d490f8a9" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "hex" version = "0.4.3" @@ -276,6 +298,15 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.29" @@ -303,6 +334,29 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -337,6 +391,15 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + [[package]] name = "regress" version = "0.10.5" @@ -397,6 +460,12 @@ dependencies = [ "syn", ] +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "semver" version = "1.0.27" @@ -463,6 +532,12 @@ version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" +[[package]] +name = "smallvec" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" + [[package]] name = "syn" version = "2.0.114" diff --git a/Cargo.toml b/Cargo.toml index 68df13f..5b899a4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,15 +41,15 @@ pre-release-replacements = [ ] [features] -default = ["base64", "hex"] -alloc = ["serde_core?/alloc"] -base64 = ["alloc", "dep:base64"] +default = ["alloc", "base64", "hex"] +alloc = ["base64?/alloc", "serde_core?/alloc"] +base64 = ["dep:base64"] hex = ["dep:hex"] schemars08 = ["alloc", "dep:schemars08", "dep:serde_json"] serde = ["dep:serde_core"] [dependencies] -base64 = { version = "0.23", default-features = false, features = ["alloc"], optional = true } +base64 = { version = "0.23", default-features = false, optional = true } hex = { version = "0.4", default-features = false, optional = true } schemars08 = { package = "schemars", version = "0.8", optional = true } serde_core = { version = "1", default-features = false, optional = true } @@ -61,6 +61,7 @@ serde = { version = "1", default-features = false, features = ["derive"] } serde_json = "1" ciborium = "0.2" expectorate = "1" +hegeltest = "0.46.4" prettyplease = "0.2" quote = "1" syn = "2" diff --git a/README.md b/README.md index 1e39f42..8bcd345 100644 --- a/README.md +++ b/README.md @@ -18,13 +18,13 @@ human-readable encodings. [`HexArray`] encodes fixed-length byte arrays as hex strings, and [`Base64Vec`] encodes variable-length byte vectors as base64 strings. -With the `serde` feature, both types implement `Serialize` and +With the `serde` feature, this crate’s types implement `Serialize` and `Deserialize`, encoding as human-readable strings (hex or base64) in text formats like JSON, and as efficient raw bytes in binary formats like [CBOR]. You do not have to use the newtypes in your own type definitions; you can refer to them via `#[serde(with = "...")]` instead. -With the `schemars08` feature, both types implement [`JsonSchema`], +With the `schemars08` feature, this crate’s types implement [`JsonSchema`], including automatic opt-in replacement with [typify](https://crates.io/crates/typify) and [progenitor](https://crates.io/crates/progenitor). @@ -34,7 +34,9 @@ including automatic opt-in replacement with * [`HexArray`] encodes a fixed-length byte array as a hex string. (Requires the `hex` feature.) * [`Base64Vec`] encodes a variable-length byte vector as a base64 - string. (Requires the `base64` feature.) + string. (Requires the `base64` and `alloc` features.) +* [`Base64Slice`] encodes a borrowed byte slice as a base64 string. + (Requires the `base64` feature.) ## Examples @@ -107,9 +109,10 @@ The closest alternatives are: ## Features * **`hex`**: enables [`HexArray`]. *Enabled by default.* -* **`base64`**: enables [`Base64Vec`] (implies `alloc`). +* **`base64`**: enables [`Base64Slice`], and [`Base64Vec`] together with + `alloc`. *Enabled by default.* +* **`alloc`**: enables `alloc` support (required by [`Base64Vec`]). *Enabled by default.* -* **`alloc`**: enables `alloc` support (required by `base64`). * **`serde`**: implements `Serialize` and `Deserialize` for enabled types. *Not enabled by default.* * **`schemars08`**: derives `JsonSchema` for enabled types. @@ -121,6 +124,7 @@ All features other than `schemars08` are compatible with `no_std`. [`Base64Vec`]: https://docs.rs/byte-wrapper/0.1.1/byte_wrapper/base64_vec/struct.Base64Vec.html "struct byte_wrapper::base64_vec::Base64Vec" [CBOR]: https://cbor.io/ [`JsonSchema`]: https://docs.rs/schemars/0.8/schemars/trait.JsonSchema.html +[`Base64Slice`]: https://docs.rs/byte-wrapper/0.1.1/byte_wrapper/base64_slice/struct.Base64Slice.html "struct byte_wrapper::base64_slice::Base64Slice" [`serde-human-bytes`]: https://docs.rs/serde-human-bytes [`serde-encoded-bytes`]: https://docs.rs/serde-encoded-bytes [`serde_with`]: https://docs.rs/serde_with diff --git a/src/base64_slice.rs b/src/base64_slice.rs new file mode 100644 index 0000000..a10bea9 --- /dev/null +++ b/src/base64_slice.rs @@ -0,0 +1,181 @@ +// Copyright (c) The byte-wrapper Contributors +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! The [`Base64Slice`] newtype wrapper. + +use base64::{ + display::Base64Display, + encoded_len, + engine::general_purpose::{GeneralPurpose, STANDARD}, +}; +use core::fmt::{self, Write}; + +/// A byte slice that displays as base64. +/// +/// The borrowed, dynamically-sized equivalent to +/// [`Base64Vec`](crate::Base64Vec), akin to `str`'s relationship to `String`. +/// +/// `Base64Slice` wraps `[u8]`, providing a [`Display`](fmt::Display) +/// implementation that uses base64 encoding. +/// +/// With the **`serde`** feature enabled, it also implements +/// `Serialize` (base64 strings in human-readable formats, raw bytes in +/// binary formats). +/// +/// # Examples +/// +/// ``` +/// use byte_wrapper::Base64Slice; +/// +/// const BYTES: [u8; 3] = [1, 2, 3]; +/// const B: &Base64Slice = Base64Slice::new(&BYTES); +/// assert_eq!(B.to_string(), "AQID"); +/// ``` +#[derive(PartialEq, Eq, PartialOrd, Ord, Hash)] +#[repr(transparent)] +pub struct Base64Slice([u8]); + +impl Base64Slice { + /// Creates a new `Base64Slice` from a byte slice. + #[inline] + #[must_use] + pub const fn new(bytes: &[u8]) -> &Self { + // SAFETY: + // + // * `repr(transparent)` gives `Base64Slice` the layout and validity of + // `[u8]`. + // * An unsized-to-unsized pointer cast retains the length metadata and + // provenance. + // * The output borrow inherits the input's lifetime and shared-ness, + // and `Base64Slice` has no interior mutability. + unsafe { &*(bytes as *const [u8] as *const Self) } + } + + /// Returns the inner byte slice. + #[inline] + #[must_use] + pub const fn as_bytes(&self) -> &[u8] { + &self.0 + } +} + +fn base64_display(bytes: &[u8]) -> Base64Display<'_, 'static, GeneralPurpose> { + Base64Display::new(bytes, &STANDARD) +} + +/// Formats bytes as a quoted base64 string without allocating. +/// +/// This matches `String`'s `Debug` impl, since base64 never needs escaping. +pub(crate) struct QuotedBase64<'a>(pub(crate) &'a [u8]); + +impl fmt::Debug for QuotedBase64<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_char('"')?; + fmt::Display::fmt(&base64_display(self.0), f)?; + f.write_char('"') + } +} + +/// Forwards at most `remaining` bytes and drops the rest. +/// +/// base64 is ASCII, so bytes and chars are the same. +struct Truncate<'a, 'b> { + inner: &'a mut fmt::Formatter<'b>, + remaining: usize, +} + +impl Write for Truncate<'_, '_> { + fn write_str(&mut self, s: &str) -> fmt::Result { + let take = s.len().min(self.remaining); + self.remaining -= take; + self.inner.write_str(&s[..take]) + } +} + +impl fmt::Debug for Base64Slice { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_tuple("Base64Slice").field(&QuotedBase64(&self.0)).finish() + } +} + +impl fmt::Display for Base64Slice { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + // Implement the displayer by hand, matching how str computes width, + // fill, etc. We can't use String because this displayer is available in + // no-alloc contexts. + let encoded_len = encoded_len(self.0.len(), true) + .expect("base64 length of an in-memory slice fits in usize"); + let content_len = match f.precision() { + Some(precision) => precision.min(encoded_len), + None => encoded_len, + }; + let padding = match f.width() { + Some(width) => width.saturating_sub(content_len), + None => 0, + }; + let (pre, post) = match f.align() { + Some(fmt::Alignment::Left) | None => (0, padding), + Some(fmt::Alignment::Right) => (padding, 0), + Some(fmt::Alignment::Center) => { + (padding / 2, padding - padding / 2) + } + }; + + let fill = f.fill(); + for _ in 0..pre { + f.write_char(fill)?; + } + if content_len == encoded_len { + fmt::Display::fmt(&base64_display(&self.0), f)?; + } else { + let mut truncate = Truncate { inner: f, remaining: content_len }; + write!(truncate, "{}", base64_display(&self.0))?; + } + for _ in 0..post { + f.write_char(fill)?; + } + Ok(()) + } +} + +impl core::ops::Deref for Base64Slice { + type Target = [u8]; + + #[inline] + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl AsRef<[u8]> for Base64Slice { + #[inline] + fn as_ref(&self) -> &[u8] { + &self.0 + } +} + +impl AsRef for Base64Slice { + #[inline] + fn as_ref(&self) -> &Base64Slice { + self + } +} + +#[cfg(feature = "serde")] +mod serde_impls { + use super::{Base64Slice, base64_display}; + + #[cfg_attr(doc_cfg, doc(cfg(feature = "serde")))] + impl serde_core::Serialize for Base64Slice { + fn serialize(&self, serializer: S) -> Result + where + S: serde_core::Serializer, + { + if serializer.is_human_readable() { + serializer.collect_str(&base64_display(&self.0)) + } else { + serializer.serialize_bytes(&self.0) + } + } + } +} diff --git a/src/base64_vec.rs b/src/base64_vec.rs index 0b492d0..be5c862 100644 --- a/src/base64_vec.rs +++ b/src/base64_vec.rs @@ -3,9 +3,10 @@ //! The [`Base64Vec`] newtype wrapper. -use alloc::vec::Vec; +use crate::{Base64Slice, base64_slice::QuotedBase64}; +use alloc::{borrow::ToOwned, vec::Vec}; use base64::Engine; -use core::{error, fmt, str::FromStr}; +use core::{borrow::Borrow, error, fmt, str::FromStr}; /// A byte vector that displays and parses as base64. /// @@ -26,7 +27,7 @@ use core::{error, fmt, str::FromStr}; /// assert_eq!(b.to_string(), "AQID"); /// /// let parsed: Base64Vec = "AQID".parse().unwrap(); -/// assert_eq!(*parsed, [1, 2, 3]); +/// assert_eq!(parsed.as_bytes(), [1, 2, 3]); /// ``` #[must_use] #[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default)] @@ -45,6 +46,11 @@ impl Base64Vec { pub fn into_inner(self) -> Vec { self.0 } + + #[inline] + fn as_base64_slice(&self) -> &Base64Slice { + Base64Slice::new(&self.0) + } } /// Error returned by [`Base64Vec::from_str`]. @@ -129,31 +135,45 @@ impl FromStr for Base64Vec { impl fmt::Debug for Base64Vec { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_tuple("Base64Vec") - .field(&base64::engine::general_purpose::STANDARD.encode(&self.0)) - .finish() + f.debug_tuple("Base64Vec").field(&QuotedBase64(&self.0)).finish() } } impl fmt::Display for Base64Vec { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - base64::engine::general_purpose::STANDARD.encode(&self.0).fmt(f) + fmt::Display::fmt(self.as_base64_slice(), f) } } impl core::ops::Deref for Base64Vec { - type Target = Vec; + type Target = Base64Slice; #[inline] fn deref(&self) -> &Self::Target { - &self.0 + self.as_base64_slice() } } -impl core::ops::DerefMut for Base64Vec { +impl Borrow for Base64Vec { #[inline] - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.0 + fn borrow(&self) -> &Base64Slice { + self.as_base64_slice() + } +} + +impl ToOwned for Base64Slice { + type Owned = Base64Vec; + + #[inline] + fn to_owned(&self) -> Self::Owned { + Base64Vec(self.as_bytes().to_vec()) + } +} + +impl AsRef for Base64Vec { + #[inline] + fn as_ref(&self) -> &Base64Slice { + self.as_base64_slice() } } @@ -185,31 +205,42 @@ impl From for Vec { } } +impl PartialEq for Base64Vec { + #[inline] + fn eq(&self, other: &Base64Slice) -> bool { + self.as_base64_slice() == other + } +} + +impl PartialEq<&Base64Slice> for Base64Vec { + #[inline] + fn eq(&self, other: &&Base64Slice) -> bool { + self.as_base64_slice() == *other + } +} + +impl PartialEq for Base64Slice { + #[inline] + fn eq(&self, other: &Base64Vec) -> bool { + self == other.as_base64_slice() + } +} + +impl PartialEq for &Base64Slice { + #[inline] + fn eq(&self, other: &Base64Vec) -> bool { + *self == other.as_base64_slice() + } +} + #[cfg(feature = "serde")] mod serde_impls { use super::Base64Vec; + use crate::Base64Slice; use alloc::vec::Vec; use base64::Engine; use core::fmt; - use serde_core::{Deserializer, Serializer, de::Visitor}; - - /// Serializes a byte slice as base64 if human-readable, or as - /// raw bytes if not. - fn serialize_bytes( - bytes: &[u8], - serializer: S, - ) -> Result - where - S: Serializer, - { - if serializer.is_human_readable() { - let encoded = - base64::engine::general_purpose::STANDARD.encode(bytes); - serializer.serialize_str(&encoded) - } else { - serializer.serialize_bytes(bytes) - } - } + use serde_core::{Deserializer, Serialize, Serializer, de::Visitor}; /// Deserializes base64 strings (if human-readable) or byte /// arrays (if not) to `Vec`. @@ -275,7 +306,7 @@ mod serde_impls { where S: Serializer, { - serialize_bytes(bytes, serializer) + Base64Slice::new(bytes).serialize(serializer) } /// Deserializes a byte vector from base64 if the format is @@ -313,7 +344,7 @@ mod serde_impls { where S: Serializer, { - serialize_bytes(&self.0, serializer) + self.as_base64_slice().serialize(serializer) } } @@ -331,7 +362,7 @@ mod serde_impls { #[cfg(feature = "schemars08")] mod schemars_impls { use super::Base64Vec; - use crate::schemars_util::x_rust_type_extension; + use crate::{Base64Slice, schemars_util::x_rust_type_extension}; use alloc::string::String; use schemars08::{ JsonSchema, @@ -359,4 +390,21 @@ mod schemars_impls { }) } } + + // Base64Slice shares Base64Vec's schema (including `x-rust-type` set to + // `Base64Vec`) since generated code needs owned types. (This also covers + // `Cow<'_, Base64Slice>`.) + impl JsonSchema for Base64Slice { + fn schema_name() -> String { + Base64Vec::schema_name() + } + + fn is_referenceable() -> bool { + Base64Vec::is_referenceable() + } + + fn json_schema(generator: &mut SchemaGenerator) -> Schema { + Base64Vec::json_schema(generator) + } + } } diff --git a/src/lib.rs b/src/lib.rs index 1652f61..8d0f752 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,13 +9,13 @@ //! arrays as hex strings, and [`Base64Vec`] encodes variable-length //! byte vectors as base64 strings. //! -//! With the `serde` feature, both types implement `Serialize` and +//! With the `serde` feature, this crate's types implement `Serialize` and //! `Deserialize`, encoding as human-readable strings (hex or base64) in text //! formats like JSON, and as efficient raw bytes in binary formats like [CBOR]. //! You do not have to use the newtypes in your own type definitions; you can //! refer to them via `#[serde(with = "...")]` instead. //! -//! With the `schemars08` feature, both types implement [`JsonSchema`], +//! With the `schemars08` feature, this crate's types implement [`JsonSchema`], //! including automatic opt-in replacement with //! [typify](https://crates.io/crates/typify) and //! [progenitor](https://crates.io/crates/progenitor). @@ -27,7 +27,9 @@ //! * [`HexArray`] encodes a fixed-length byte array as a hex //! string. (Requires the `hex` feature.) //! * [`Base64Vec`] encodes a variable-length byte vector as a base64 -//! string. (Requires the `base64` feature.) +//! string. (Requires the `base64` and `alloc` features.) +//! * [`Base64Slice`] encodes a borrowed byte slice as a base64 string. +//! (Requires the `base64` feature.) //! //! # Examples //! @@ -120,9 +122,10 @@ //! # Features //! //! - **`hex`**: enables [`HexArray`]. *Enabled by default.* -//! - **`base64`**: enables [`Base64Vec`] (implies `alloc`). +//! - **`base64`**: enables [`Base64Slice`], and [`Base64Vec`] together with +//! `alloc`. *Enabled by default.* +//! - **`alloc`**: enables `alloc` support (required by [`Base64Vec`]). //! *Enabled by default.* -//! - **`alloc`**: enables `alloc` support (required by `base64`). //! - **`serde`**: implements `Serialize` and `Deserialize` for //! enabled types. *Not enabled by default.* //! - **`schemars08`**: derives `JsonSchema` for enabled types. @@ -138,15 +141,19 @@ extern crate alloc; #[cfg(feature = "base64")] +mod base64_slice; +#[cfg(all(feature = "base64", feature = "alloc"))] mod base64_vec; #[cfg(feature = "hex")] mod hex_array; #[cfg(all(feature = "schemars08", any(feature = "base64", feature = "hex")))] mod schemars_util; -#[cfg(all(feature = "serde", feature = "base64"))] +#[cfg(all(feature = "serde", feature = "base64", feature = "alloc"))] mod serde_util; #[cfg(feature = "base64")] +pub use base64_slice::Base64Slice; +#[cfg(all(feature = "base64", feature = "alloc"))] pub use base64_vec::{Base64Vec, ParseBase64Error}; #[cfg(feature = "hex")] pub use hex_array::{HexArray, ParseHexError}; diff --git a/tests/integration/base64.rs b/tests/integration/base64.rs index e235b4d..100a2d2 100644 --- a/tests/integration/base64.rs +++ b/tests/integration/base64.rs @@ -8,11 +8,11 @@ use byte_wrapper::{Base64Vec, ParseBase64Error}; #[test] fn base64_from_str() { let b: Base64Vec = "AQID".parse().expect("parsed"); - assert_eq!(*b, [1, 2, 3]); + assert_eq!(b.as_bytes(), [1, 2, 3]); // With padding. let b: Base64Vec = "AQIDBA==".parse().expect("parsed padded"); - assert_eq!(*b, [1, 2, 3, 4]); + assert_eq!(b.as_bytes(), [1, 2, 3, 4]); // Empty. let b: Base64Vec = "".parse().expect("parsed empty"); diff --git a/tests/integration/base64_schemars.rs b/tests/integration/base64_schemars.rs index 62d8db5..6b67eac 100644 --- a/tests/integration/base64_schemars.rs +++ b/tests/integration/base64_schemars.rs @@ -1,8 +1,9 @@ // Copyright (c) The byte-wrapper Contributors // SPDX-License-Identifier: MIT OR Apache-2.0 -use byte_wrapper::Base64Vec; +use byte_wrapper::{Base64Slice, Base64Vec}; use schemars08::{self as schemars, JsonSchema, schema_for}; +use std::borrow::Cow; #[test] fn base64_vec_schema() { @@ -21,6 +22,31 @@ fn base64_vec_schema() { } }); assert_eq!(actual, expected); + + let slice_schema = schema_for!(Base64Slice); + assert_eq!( + serde_json::to_value(&slice_schema).expect("serialized"), + actual, + "Base64Slice and Base64Vec share a schema", + ); +} + +#[expect(dead_code)] +#[derive(JsonSchema)] +struct WithBase64SliceCow { + data: Cow<'static, Base64Slice>, +} + +#[test] +fn with_base64_slice_cow() { + let actual = serde_json::to_value(schema_for!(WithBase64SliceCow)) + .expect("serialized"); + let direct = serde_json::to_value(schema_for!(WithBase64VecDirect)) + .expect("serialized"); + assert_eq!( + actual["properties"], direct["properties"], + "a Cow<'static, Base64Slice> field has the same schema as a Base64Vec field", + ); } #[expect(dead_code)] diff --git a/tests/integration/base64_slice.rs b/tests/integration/base64_slice.rs new file mode 100644 index 0000000..5b4a7ca --- /dev/null +++ b/tests/integration/base64_slice.rs @@ -0,0 +1,187 @@ +// Copyright (c) The byte-wrapper Contributors +// SPDX-License-Identifier: MIT OR Apache-2.0 + +use crate::helpers::draw_related_bytes; +use byte_wrapper::{Base64Slice, Base64Vec}; +use hegel::{TestCase, generators as gs}; +use std::{ + borrow::{Borrow, Cow}, + collections::{BTreeMap, HashMap}, + hash::{BuildHasher, RandomState}, +}; + +const BYTES: [u8; 3] = [1, 2, 3]; +const CONST_SLICE: &Base64Slice = Base64Slice::new(&BYTES); +const CONST_EMPTY: &Base64Slice = Base64Slice::new(&[]); +const CONST_COW: Cow<'static, Base64Slice> = + Cow::Borrowed(Base64Slice::new(&BYTES)); +static STATIC_SLICE: &Base64Slice = Base64Slice::new(&BYTES); + +#[test] +fn base64_slice_new_preserves_bytes() { + assert_eq!(CONST_SLICE.as_bytes(), &BYTES); + assert_eq!(CONST_SLICE.len(), 3); + assert_eq!(STATIC_SLICE.as_bytes(), &BYTES); + + assert_eq!(CONST_EMPTY.as_bytes(), &[] as &[u8]); + assert!(CONST_EMPTY.is_empty()); + + let backing = [0u8, 1, 2, 3, 4, 5]; + let middle = Base64Slice::new(&backing[2..5]); + assert_eq!(middle.as_bytes(), &[2, 3, 4]); + assert!(std::ptr::eq(middle.as_bytes(), &backing[2..5])); +} + +#[test] +fn base64_slice_const_matches_parsed() { + let parsed: Base64Vec = "AQID".parse().expect("parsed"); + assert_eq!(parsed, CONST_SLICE); + assert_eq!(CONST_SLICE, parsed); + assert_eq!(*parsed, *CONST_SLICE); + assert_eq!(CONST_COW, Cow::::Owned(parsed)); +} + +#[test] +fn base64_slice_display() { + assert_eq!(format!("{CONST_SLICE}"), "AQID"); + assert_eq!(format!("{CONST_EMPTY}"), ""); + + let v = Base64Vec::new(vec![1, 2, 3, 4]); + assert_eq!(format!("{v}"), "AQIDBA=="); + assert_eq!(format!("{}", &*v), "AQIDBA=="); + + // Display honors width and alignment. + assert_eq!(format!("{CONST_SLICE:>6}"), " AQID"); + assert_eq!(format!("{CONST_SLICE:<6}"), "AQID "); + assert_eq!(format!("{v:_^12}"), "__AQIDBA==__"); +} + +#[test] +fn base64_slice_debug() { + assert_eq!(format!("{CONST_SLICE:?}"), r#"Base64Slice("AQID")"#); + assert_eq!(format!("{CONST_EMPTY:?}"), r#"Base64Slice("")"#); + assert_eq!( + format!("{:?}", Base64Vec::new(BYTES.to_vec())), + r#"Base64Vec("AQID")"#, + ); +} + +#[test] +fn base64_slice_conversions() { + let v = Base64Vec::from(vec![1, 2, 3]); + let slice: &Base64Slice = &v; + assert_eq!(slice.as_bytes(), &[1, 2, 3]); + assert_eq!(AsRef::<[u8]>::as_ref(&v), &[1, 2, 3]); + assert_eq!(AsRef::::as_ref(&v), slice); + assert_eq!(AsRef::<[u8]>::as_ref(slice), &[1, 2, 3]); + + let owned: Base64Vec = slice.to_owned(); + assert_eq!(owned, v); + + let mut v = v; + v.as_mut()[0] = 0xff; + v.0.push(4); + assert_eq!(v.as_bytes(), &[0xff, 2, 3, 4]); + assert_eq!(Vec::from(v.clone()), vec![0xff, 2, 3, 4]); + assert_eq!(v.into_inner(), vec![0xff, 2, 3, 4]); +} + +#[test] +fn base64_slice_cow() { + let borrowed: Cow<'static, Base64Slice> = Cow::Borrowed(CONST_SLICE); + let owned: Cow<'static, Base64Slice> = + Cow::Owned("AQID".parse().expect("parsed")); + assert_eq!(borrowed, owned); + assert_eq!(borrowed.to_string(), owned.to_string()); + assert_eq!(borrowed.clone().into_owned(), owned.clone().into_owned()); + + let other: Cow<'static, Base64Slice> = Cow::Owned(Base64Vec::new(vec![1])); + assert_ne!(borrowed, other); +} + +#[test] +fn base64_slice_map_lookup() { + // `Base64Slice` lets maps keyed by `Base64Vec` be queried with + // `&Base64Slice`. + let mut map = HashMap::new(); + map.insert(Base64Vec::new(BYTES.to_vec()), "three"); + map.insert(Base64Vec::default(), "empty"); + assert_eq!(map.get(CONST_SLICE), Some(&"three")); + assert_eq!(map.get(CONST_EMPTY), Some(&"empty")); + assert_eq!(map.get(Base64Slice::new(&[1])), None); + + let mut btree = BTreeMap::new(); + btree.insert(Base64Vec::new(vec![2]), 2); + btree.insert(Base64Vec::new(vec![1, 0xff]), 1); + btree.insert(Base64Vec::new(vec![]), 0); + assert_eq!(btree.get(Base64Slice::new(&[1, 0xff])), Some(&1)); + let keys: Vec = btree.keys().map(|k| k.to_string()).collect(); + assert_eq!(keys, ["", "Af8=", "Ag=="]); +} + +fn draw_pair(tc: &TestCase) -> (Base64Vec, Base64Vec) { + let (a, b) = draw_related_bytes(tc); + (Base64Vec::new(a), Base64Vec::new(b)) +} + +#[hegel::test] +fn borrow_preserves_eq(tc: TestCase) { + let (a, b) = draw_pair(&tc); + let (a_slice, b_slice): (&Base64Slice, &Base64Slice) = + (a.borrow(), b.borrow()); + assert_eq!(a == b, a_slice == b_slice); +} + +#[hegel::test] +fn borrow_preserves_ord(tc: TestCase) { + let (a, b) = draw_pair(&tc); + let (a_slice, b_slice): (&Base64Slice, &Base64Slice) = + (a.borrow(), b.borrow()); + assert_eq!(a.cmp(&b), a_slice.cmp(b_slice)); +} + +#[hegel::test] +fn borrow_preserves_hash(tc: TestCase) { + let v = Base64Vec::new(tc.draw(gs::binary())); + let slice: &Base64Slice = v.borrow(); + let state = RandomState::new(); + assert_eq!(state.hash_one(&v), state.hash_one(slice)); +} + +#[hegel::test] +fn to_owned_round_trips(tc: TestCase) { + let bytes = tc.draw(gs::binary()); + let slice = Base64Slice::new(&bytes); + let owned: Base64Vec = slice.to_owned(); + let reborrowed: &Base64Slice = owned.borrow(); + assert_eq!(reborrowed, slice); + assert_eq!(owned.as_bytes(), bytes.as_slice()); +} + +#[hegel::test] +fn owned_and_borrowed_display_agree(tc: TestCase) { + let v = Base64Vec::new(tc.draw(gs::binary())); + let slice: &Base64Slice = v.borrow(); + assert_eq!(v.to_string(), slice.to_string()); +} + +#[hegel::test] +fn from_str_inverts_display(tc: TestCase) { + let v = Base64Vec::new(tc.draw(gs::binary())); + let parsed: Base64Vec = + v.to_string().parse().expect("Display output parses"); + assert_eq!(parsed, v); +} + +#[hegel::test] +fn from_str_rejects_non_base64_character(tc: TestCase) { + let v = Base64Vec::new(tc.draw(gs::binary())); + let mut encoded = v.to_string(); + let index = tc.draw(gs::integers::().max_value(encoded.len())); + let invalid = + tc.draw(gs::sampled_from(vec!['!', ' ', '-', '_', '.', '\n'])); + encoded.insert(index, invalid); + encoded + .parse::() + .expect_err("a non-base64 character is rejected"); +} diff --git a/tests/integration/base64_slice_fmt.rs b/tests/integration/base64_slice_fmt.rs new file mode 100644 index 0000000..cbc199b --- /dev/null +++ b/tests/integration/base64_slice_fmt.rs @@ -0,0 +1,76 @@ +// Copyright (c) The byte-wrapper Contributors +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! Tests for base64 slice formatting. +//! +//! These tests only need the `base64` feature, so they also cover scenarios +//! where the crate's `alloc` feature is disabled. + +use byte_wrapper::Base64Slice; +use hegel::{TestCase, generators as gs}; + +const BYTES: [u8; 4] = [1, 2, 3, 4]; +const SLICE: &Base64Slice = Base64Slice::new(&BYTES); + +#[test] +fn display_matches_known_encoding() { + assert_eq!(format!("{SLICE}"), "AQIDBA=="); + assert_eq!(format!("{}", Base64Slice::new(&[])), ""); + assert_eq!(format!("{}", Base64Slice::new(&[0xfb, 0xff])), "+/8="); +} + +#[test] +fn display_honors_flags() { + assert_eq!(format!("{SLICE:12}"), "AQIDBA== "); + assert_eq!(format!("{SLICE:>12}"), " AQIDBA=="); + assert_eq!(format!("{SLICE:_^13}"), "__AQIDBA==___"); + assert_eq!(format!("{SLICE:4}"), "AQIDBA=="); + assert_eq!(format!("{SLICE:.3}"), "AQI"); + assert_eq!(format!("{SLICE:.20}"), "AQIDBA=="); + assert_eq!(format!("{SLICE:>6.3}"), " AQI"); + assert_eq!(format!("{SLICE:.0}"), ""); +} + +#[test] +fn debug_quotes_encoding() { + assert_eq!(format!("{SLICE:?}"), r#"Base64Slice("AQIDBA==")"#); + assert_eq!(format!("{SLICE:#?}"), "Base64Slice(\n \"AQIDBA==\",\n)",); +} + +// Our hand-written Base64Slice Display must agree with str's Display for all +// possible parameters. +#[hegel::test] +fn display_flags_match_str(tc: TestCase) { + let bytes = tc.draw(gs::binary()); + let slice = Base64Slice::new(&bytes); + let encoded = slice.to_string(); + let w = tc.draw(gs::integers::().max_value(64)); + let p = tc.draw(gs::integers::().max_value(64)); + + assert_eq!(format!("{slice:w$}"), format!("{encoded:w$}")); + assert_eq!(format!("{slice:w$}"), format!("{encoded:>w$}")); + assert_eq!(format!("{slice:*^w$}"), format!("{encoded:*^w$}")); + assert_eq!(format!("{slice:.p$}"), format!("{encoded:.p$}")); + assert_eq!(format!("{slice:>w$.p$}"), format!("{encoded:>w$.p$}")); + assert_eq!(format!("{slice:-^w$.p$}"), format!("{encoded:-^w$.p$}")); +} + +mod reference { + // Same name as the real type, so derived Debug output is directly + // comparable. + #[derive(Debug)] + pub(crate) struct Base64Slice( + #[expect(dead_code, reason = "only read by the derived Debug impl")] + pub(crate) String, + ); +} + +#[hegel::test] +fn debug_matches_string_debug(tc: TestCase) { + let bytes = tc.draw(gs::binary()); + let slice = Base64Slice::new(&bytes); + let expected = reference::Base64Slice(slice.to_string()); + assert_eq!(format!("{slice:?}"), format!("{expected:?}")); + assert_eq!(format!("{slice:#?}"), format!("{expected:#?}")); +} diff --git a/tests/integration/base64_slice_serde.rs b/tests/integration/base64_slice_serde.rs new file mode 100644 index 0000000..0c6e66c --- /dev/null +++ b/tests/integration/base64_slice_serde.rs @@ -0,0 +1,77 @@ +// Copyright (c) The byte-wrapper Contributors +// SPDX-License-Identifier: MIT OR Apache-2.0 + +use byte_wrapper::Base64Slice; +use hex_literal::hex; +use serde::{Deserialize, Serialize}; +use std::borrow::Cow; + +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +struct WithBase64SliceCow { + data: Cow<'static, Base64Slice>, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +struct WithBase64SliceRef<'a> { + data: &'a Base64Slice, +} + +static FIXTURE: [u8; 16] = hex!("0123456789abcdef0123456789abcdef"); +const FIXTURE_SLICE: &Base64Slice = Base64Slice::new(&FIXTURE); + +static AS_JSON: &str = r#"{"data":"ASNFZ4mrze8BI0VniavN7w=="}"#; +static AS_CBOR: [u8; 23] = + hex!("a1646461746150 0123456789abcdef0123456789abcdef"); + +#[test] +fn base64_slice_serialize() { + let fixture = WithBase64SliceRef { data: FIXTURE_SLICE }; + let json = serde_json::to_string(&fixture).expect("serialized"); + assert_eq!(json, AS_JSON); + + let mut cbor = Vec::new(); + ciborium::ser::into_writer(&fixture, &mut cbor).expect("serialized CBOR"); + assert_eq!(cbor, AS_CBOR); + + let empty = + serde_json::to_string(Base64Slice::new(&[])).expect("serialized"); + assert_eq!(empty, r#""""#); +} + +#[test] +fn base64_slice_cow_serde() { + let borrowed = WithBase64SliceCow { data: Cow::Borrowed(FIXTURE_SLICE) }; + let json = serde_json::to_string(&borrowed).expect("serialized"); + assert_eq!(json, AS_JSON); + + let deserialized: WithBase64SliceCow = + serde_json::from_str(AS_JSON).expect("deserialized Cow"); + assert!( + matches!(deserialized.data, Cow::Owned(_)), + "Cow<'static, Base64Slice> deserializes to the owned variant", + ); + assert_eq!(deserialized, borrowed); + + let top_level: Cow<'static, Base64Slice> = + serde_json::from_str(r#""AQID""#).expect("deserialized bare Cow"); + assert_eq!(top_level, Cow::Borrowed(Base64Slice::new(&[1, 2, 3]))); + + let mut cbor = Vec::new(); + ciborium::ser::into_writer(&borrowed, &mut cbor).expect("serialized CBOR"); + assert_eq!(cbor, AS_CBOR); + let roundtrip: WithBase64SliceCow = + ciborium::de::from_reader(&cbor[..]).expect("deserialized CBOR"); + assert_eq!(roundtrip, borrowed); +} + +#[test] +fn base64_slice_cow_rejects_invalid() { + for (json, needle) in [ + (r#"{"data":"AQ!D"}"#, "Invalid symbol"), + (r#"{"data":"A"}"#, "Invalid input length"), + ] { + let err = serde_json::from_str::(json) + .expect_err("invalid base64 rejected for Cow"); + assert!(err.to_string().contains(needle), "{json}: got {err}"); + } +} diff --git a/tests/integration/helpers.rs b/tests/integration/helpers.rs new file mode 100644 index 0000000..0420196 --- /dev/null +++ b/tests/integration/helpers.rs @@ -0,0 +1,30 @@ +// Copyright (c) The byte-wrapper Contributors +// SPDX-License-Identifier: MIT OR Apache-2.0 + +use hegel::{TestCase, generators as gs}; + +/// Draws a pair of byte vectors that are likely to be related to each other. +pub(crate) fn draw_related_bytes(tc: &TestCase) -> (Vec, Vec) { + let a = tc.draw(gs::binary()); + let b = match tc.draw(gs::integers::().max_value(3)) { + // The returned pair is equal. + 0 => a.clone(), + 1 => { + // a is a prefix of b. + let mut b = a.clone(); + b.extend(tc.draw(gs::binary())); + b + } + 2 if !a.is_empty() => { + // a differs from b at a single byte. + let mut b = a.clone(); + let index = tc.draw(gs::integers::().max_value(a.len() - 1)); + b[index] = b[index] + .wrapping_add(tc.draw(gs::integers::().min_value(1))); + b + } + // a and b are unrelated. + _ => tc.draw(gs::binary()), + }; + (a, b) +} diff --git a/tests/integration/main.rs b/tests/integration/main.rs index 942b810..b379797 100644 --- a/tests/integration/main.rs +++ b/tests/integration/main.rs @@ -1,12 +1,20 @@ // Copyright (c) The byte-wrapper Contributors // SPDX-License-Identifier: MIT OR Apache-2.0 -#[cfg(feature = "base64")] +#[cfg(all(feature = "base64", feature = "alloc"))] mod base64; #[cfg(all(feature = "schemars08", feature = "base64"))] mod base64_schemars; -#[cfg(all(feature = "base64", feature = "serde"))] +#[cfg(all(feature = "base64", feature = "alloc", feature = "serde"))] mod base64_serde; +#[cfg(all(feature = "base64", feature = "alloc"))] +mod base64_slice; +#[cfg(feature = "base64")] +mod base64_slice_fmt; +#[cfg(all(feature = "base64", feature = "alloc", feature = "serde"))] +mod base64_slice_serde; +#[cfg(all(feature = "base64", feature = "alloc"))] +mod helpers; #[cfg(feature = "hex")] mod hex; #[cfg(all(feature = "schemars08", feature = "hex"))] diff --git a/tests/integration/typify_roundtrip.rs b/tests/integration/typify_roundtrip.rs index 20df433..1ffa343 100644 --- a/tests/integration/typify_roundtrip.rs +++ b/tests/integration/typify_roundtrip.rs @@ -6,8 +6,9 @@ #[cfg(feature = "base64")] mod base64_tests { - use byte_wrapper::Base64Vec; + use byte_wrapper::{Base64Slice, Base64Vec}; use schemars08::{self as schemars, JsonSchema, schema_for}; + use std::borrow::Cow; #[expect(dead_code)] #[derive(JsonSchema)] @@ -39,6 +40,21 @@ mod base64_tests { &code, ); } + + #[expect(dead_code)] + #[derive(JsonSchema)] + struct HasBase64SliceCow { + data: Cow<'static, Base64Slice>, + } + + #[test] + fn base64_slice_cow() { + let code = super::typify_output(&schema_for!(HasBase64SliceCow)); + expectorate::assert_contents( + "tests/output/typify-base64-slice-cow.rs", + &code, + ); + } } #[cfg(feature = "hex")] diff --git a/tests/output/typify-base64-slice-cow.rs b/tests/output/typify-base64-slice-cow.rs new file mode 100644 index 0000000..c86fe3c --- /dev/null +++ b/tests/output/typify-base64-slice-cow.rs @@ -0,0 +1,62 @@ +/// Error types. +pub mod error { + /// Error from a `TryFrom` or `FromStr` implementation. + pub struct ConversionError(::std::borrow::Cow<'static, str>); + impl ::std::error::Error for ConversionError {} + impl ::std::fmt::Display for ConversionError { + fn fmt( + &self, + f: &mut ::std::fmt::Formatter<'_>, + ) -> Result<(), ::std::fmt::Error> { + ::std::fmt::Display::fmt(&self.0, f) + } + } + impl ::std::fmt::Debug for ConversionError { + fn fmt( + &self, + f: &mut ::std::fmt::Formatter<'_>, + ) -> Result<(), ::std::fmt::Error> { + ::std::fmt::Debug::fmt(&self.0, f) + } + } + impl From<&'static str> for ConversionError { + fn from(value: &'static str) -> Self { + Self(value.into()) + } + } + impl From for ConversionError { + fn from(value: String) -> Self { + Self(value.into()) + } + } +} +///`HasBase64SliceCow` +/// +///
JSON schema +/// +/// ```json +///{ +/// "title": "HasBase64SliceCow", +/// "type": "object", +/// "required": [ +/// "data" +/// ], +/// "properties": { +/// "data": { +/// "type": "string", +/// "format": "byte", +/// "contentEncoding": "base64", +/// "x-rust-type": { +/// "crate": "byte-wrapper", +/// "path": "byte_wrapper::Base64Vec", +/// "version": "0.1.0" +/// } +/// } +/// } +///} +/// ``` +///
+#[derive(::serde::Deserialize, ::serde::Serialize, Clone, Debug)] +pub struct HasBase64SliceCow { + pub data: ::byte_wrapper::Base64Vec, +}