Skip to content

Commit aea1cd2

Browse files
committed
Add stack encoded buffer helper
1 parent 2509fd2 commit aea1cd2

5 files changed

Lines changed: 241 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
- Added named dependency-free profiles for MIME, PEM, bcrypt-style, and
1717
`crypt(3)`-style Base64 through `Profile`, `MIME`, `PEM`, `PEM_CRLF`,
1818
`BCRYPT`, and `CRYPT`.
19+
- Added `EncodedBuffer` and `encode_buffer` helpers for stack-backed short
20+
encoded output without requiring `alloc`.
1921

2022
## 0.5.0 - 2026-05-14
2123

README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ Implemented now:
3131
- Custom alphabet validation helpers for user-defined 64-byte alphabets.
3232
- Named dependency-free profiles for MIME, PEM, bcrypt-style, and
3333
`crypt(3)`-style Base64.
34+
- Stack-backed encoded output buffers for short values without `alloc`.
3435
- Separate `ct` scalar decode module for sensitive payloads that avoids
3536
secret-indexed lookup tables during Base64 symbol mapping.
3637
- `std::io` streaming encoders and decoders behind the `stream` feature.
@@ -309,6 +310,23 @@ returning the error. The legacy whitespace profile also provides
309310
The `ct` module provides the same clear-tail decode variants for callers using
310311
the constant-time-oriented scalar decoder.
311312

313+
For short values, `encode_buffer` returns a stack-backed `EncodedBuffer`
314+
without requiring the `alloc` feature:
315+
316+
```rust
317+
use base64_ng::{BCRYPT, STANDARD};
318+
319+
let encoded = STANDARD.encode_buffer::<8>(b"hello").unwrap();
320+
assert_eq!(encoded.as_str(), "aGVsbG8=");
321+
322+
let bcrypt = BCRYPT.encode_buffer::<4>(&[0xff, 0xff, 0xff]).unwrap();
323+
assert_eq!(bcrypt.as_bytes(), b"9999");
324+
```
325+
326+
`EncodedBuffer` exposes bytes only through `as_bytes` and `as_str`, redacts the
327+
payload from `Debug`, and clears its backing array when dropped as best-effort
328+
data-retention reduction.
329+
312330
With the default `alloc` feature, vector and string helpers are available:
313331

314332
```rust

docs/PLAN.md

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -174,12 +174,10 @@ the zero-runtime-dependency stance.
174174
- Named MIME, PEM, bcrypt-style, and `crypt(3)`-style profiles.
175175
- Custom alphabet validation helpers with duplicate-character, padding-byte,
176176
and visible-ASCII checks.
177+
- Stack-backed encoded output helpers for short values without `alloc`.
177178

178179
### Missing Secure Core Features
179180

180-
- Add zero-dependency small-output helpers for common short values, such as a
181-
stack-backed encoded buffer type that avoids heap allocation without forcing
182-
callers to hand-roll arrays.
183181
- Add internal best-effort wipe primitives for crate-owned temporary buffers,
184182
using volatile writes and compiler fences where appropriate. This must be
185183
documented as retention reduction, not a hard zeroization guarantee.
@@ -325,7 +323,8 @@ the zero-runtime-dependency stance.
325323
policies, including CRLF and LF output.
326324
- Add validate-only APIs for strict, legacy, profile-aware, and
327325
constant-time-oriented validation use cases.
328-
- Add zero-dependency stack-backed output helpers for short encoded values.
326+
- Completed zero-dependency stack-backed output helpers for short encoded
327+
values.
329328
- Add internal best-effort wiping helpers and secret wrapper types with redacted
330329
formatting for sensitive owned buffers.
331330
- Add the README trust dashboard and CWE/security-control mapping

src/lib.rs

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1503,6 +1503,126 @@ impl LineWrap {
15031503
}
15041504
}
15051505

1506+
/// Stack-backed encoded Base64 output.
1507+
///
1508+
/// This type is intended for short values where heap allocation would be
1509+
/// unnecessary but manually sizing and passing a separate output slice is
1510+
/// noisy. Its visible bytes are produced by crate encoders, so [`Self::as_str`]
1511+
/// can return `&str` without exposing a fallible UTF-8 conversion to callers.
1512+
///
1513+
/// The backing array is cleared when the value is dropped. This is best-effort
1514+
/// data-retention reduction and is not a formal zeroization guarantee.
1515+
pub struct EncodedBuffer<const CAP: usize> {
1516+
bytes: [u8; CAP],
1517+
len: usize,
1518+
}
1519+
1520+
impl<const CAP: usize> EncodedBuffer<CAP> {
1521+
/// Creates an empty encoded buffer.
1522+
#[must_use]
1523+
pub const fn new() -> Self {
1524+
Self {
1525+
bytes: [0u8; CAP],
1526+
len: 0,
1527+
}
1528+
}
1529+
1530+
/// Returns the number of visible encoded bytes.
1531+
#[must_use]
1532+
pub const fn len(&self) -> usize {
1533+
self.len
1534+
}
1535+
1536+
/// Returns whether the buffer has no visible encoded bytes.
1537+
#[must_use]
1538+
pub const fn is_empty(&self) -> bool {
1539+
self.len == 0
1540+
}
1541+
1542+
/// Returns the stack capacity in bytes.
1543+
#[must_use]
1544+
pub const fn capacity(&self) -> usize {
1545+
CAP
1546+
}
1547+
1548+
/// Returns the visible encoded bytes.
1549+
#[must_use]
1550+
pub fn as_bytes(&self) -> &[u8] {
1551+
&self.bytes[..self.len]
1552+
}
1553+
1554+
/// Returns the visible encoded bytes as UTF-8.
1555+
///
1556+
/// # Panics
1557+
///
1558+
/// Panics only if the crate's internal invariant is broken and the buffer
1559+
/// contains non-UTF-8 bytes.
1560+
#[must_use]
1561+
pub fn as_str(&self) -> &str {
1562+
match core::str::from_utf8(self.as_bytes()) {
1563+
Ok(output) => output,
1564+
Err(_) => unreachable!("base64 encoder produced non-UTF-8 output"),
1565+
}
1566+
}
1567+
1568+
/// Clears the visible bytes and the full backing array.
1569+
pub fn clear(&mut self) {
1570+
self.bytes.fill(0);
1571+
self.len = 0;
1572+
}
1573+
1574+
/// Clears bytes after the visible prefix.
1575+
pub fn clear_tail(&mut self) {
1576+
self.bytes[self.len..].fill(0);
1577+
}
1578+
}
1579+
1580+
impl<const CAP: usize> AsRef<[u8]> for EncodedBuffer<CAP> {
1581+
fn as_ref(&self) -> &[u8] {
1582+
self.as_bytes()
1583+
}
1584+
}
1585+
1586+
impl<const CAP: usize> Clone for EncodedBuffer<CAP> {
1587+
fn clone(&self) -> Self {
1588+
let mut output = Self::new();
1589+
output.bytes[..self.len].copy_from_slice(self.as_bytes());
1590+
output.len = self.len;
1591+
output
1592+
}
1593+
}
1594+
1595+
impl<const CAP: usize> core::fmt::Debug for EncodedBuffer<CAP> {
1596+
fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
1597+
formatter
1598+
.debug_struct("EncodedBuffer")
1599+
.field("bytes", &"<redacted>")
1600+
.field("len", &self.len)
1601+
.field("capacity", &CAP)
1602+
.finish()
1603+
}
1604+
}
1605+
1606+
impl<const CAP: usize> Default for EncodedBuffer<CAP> {
1607+
fn default() -> Self {
1608+
Self::new()
1609+
}
1610+
}
1611+
1612+
impl<const CAP: usize> Drop for EncodedBuffer<CAP> {
1613+
fn drop(&mut self) {
1614+
self.clear();
1615+
}
1616+
}
1617+
1618+
impl<const CAP: usize> Eq for EncodedBuffer<CAP> {}
1619+
1620+
impl<const CAP: usize> PartialEq for EncodedBuffer<CAP> {
1621+
fn eq(&self, other: &Self) -> bool {
1622+
self.as_bytes() == other.as_bytes()
1623+
}
1624+
}
1625+
15061626
/// A named Base64 profile with an engine and optional strict line wrapping.
15071627
///
15081628
/// Profiles are convenience values for protocol-shaped Base64. They keep the
@@ -1589,6 +1709,27 @@ where
15891709
}
15901710
}
15911711

1712+
/// Encodes `input` into a stack-backed buffer.
1713+
///
1714+
/// This is useful for short values where heap allocation is unnecessary.
1715+
/// If encoding fails, the internal backing array is cleared before the
1716+
/// error is returned.
1717+
pub fn encode_buffer<const CAP: usize>(
1718+
&self,
1719+
input: &[u8],
1720+
) -> Result<EncodedBuffer<CAP>, EncodeError> {
1721+
let mut output = EncodedBuffer::new();
1722+
let written = match self.encode_slice_clear_tail(input, &mut output.bytes) {
1723+
Ok(written) => written,
1724+
Err(err) => {
1725+
output.clear();
1726+
return Err(err);
1727+
}
1728+
};
1729+
output.len = written;
1730+
Ok(output)
1731+
}
1732+
15921733
/// Decodes `input` into `output` according to this profile.
15931734
pub fn decode_slice(&self, input: &[u8], output: &mut [u8]) -> Result<usize, DecodeError> {
15941735
match self.wrap {
@@ -2657,6 +2798,36 @@ where
26572798
Ok(written)
26582799
}
26592800

2801+
/// Encodes `input` into a stack-backed buffer.
2802+
///
2803+
/// This helper is useful for short values where callers want the
2804+
/// convenience of an owned result without enabling `alloc`.
2805+
///
2806+
/// # Examples
2807+
///
2808+
/// ```
2809+
/// use base64_ng::STANDARD;
2810+
///
2811+
/// let encoded = STANDARD.encode_buffer::<8>(b"hello").unwrap();
2812+
///
2813+
/// assert_eq!(encoded.as_str(), "aGVsbG8=");
2814+
/// ```
2815+
pub fn encode_buffer<const CAP: usize>(
2816+
&self,
2817+
input: &[u8],
2818+
) -> Result<EncodedBuffer<CAP>, EncodeError> {
2819+
let mut output = EncodedBuffer::new();
2820+
let written = match self.encode_slice_clear_tail(input, &mut output.bytes) {
2821+
Ok(written) => written,
2822+
Err(err) => {
2823+
output.clear();
2824+
return Err(err);
2825+
}
2826+
};
2827+
output.len = written;
2828+
Ok(output)
2829+
}
2830+
26602831
/// Encodes `input` into a newly allocated byte vector.
26612832
#[cfg(feature = "alloc")]
26622833
pub fn encode_vec(&self, input: &[u8]) -> Result<alloc::vec::Vec<u8>, EncodeError> {

tests/rfc4648.rs

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
use base64_ng::{
22
Alphabet, AlphabetError, BCRYPT, BCRYPT_NO_PAD, Bcrypt, CRYPT, CRYPT_NO_PAD, Crypt,
3-
DecodeError, EncodeError, Engine, LineEnding, LineWrap, MIME, PEM, PEM_CRLF, STANDARD,
4-
STANDARD_NO_PAD, Standard, URL_SAFE, URL_SAFE_NO_PAD, UrlSafe, checked_encoded_len, ct,
5-
decode_alphabet_byte, decoded_capacity, decoded_len, encoded_len, runtime, validate_alphabet,
6-
wrapped_encoded_len,
3+
DecodeError, EncodeError, EncodedBuffer, Engine, LineEnding, LineWrap, MIME, PEM, PEM_CRLF,
4+
STANDARD, STANDARD_NO_PAD, Standard, URL_SAFE, URL_SAFE_NO_PAD, UrlSafe, checked_encoded_len,
5+
ct, decode_alphabet_byte, decoded_capacity, decoded_len, encoded_len, runtime,
6+
validate_alphabet, wrapped_encoded_len,
77
};
88

99
#[cfg(feature = "stream")]
@@ -1695,6 +1695,49 @@ fn rejects_non_canonical_trailing_bits() {
16951695
);
16961696
}
16971697

1698+
#[test]
1699+
fn stack_encoded_buffer_helpers_avoid_alloc_and_clear_tail() {
1700+
let encoded = STANDARD.encode_buffer::<8>(b"hello").unwrap();
1701+
assert_eq!(encoded.len(), 8);
1702+
assert_eq!(encoded.capacity(), 8);
1703+
assert!(!encoded.is_empty());
1704+
assert_eq!(encoded.as_bytes(), b"aGVsbG8=");
1705+
assert_eq!(encoded.as_str(), "aGVsbG8=");
1706+
assert_eq!(encoded.as_ref(), b"aGVsbG8=");
1707+
assert_eq!(
1708+
format!("{encoded:?}"),
1709+
"EncodedBuffer { bytes: \"<redacted>\", len: 8, capacity: 8 }"
1710+
);
1711+
1712+
let cloned = encoded.clone();
1713+
assert_eq!(encoded, cloned);
1714+
1715+
let too_small: Result<EncodedBuffer<7>, EncodeError> = STANDARD.encode_buffer(b"hello");
1716+
assert_eq!(
1717+
too_small,
1718+
Err(EncodeError::OutputTooSmall {
1719+
required: 8,
1720+
available: 7,
1721+
})
1722+
);
1723+
1724+
let mut empty = EncodedBuffer::<8>::new();
1725+
assert!(empty.is_empty());
1726+
empty.clear_tail();
1727+
empty.clear();
1728+
assert_eq!(empty.as_bytes(), b"");
1729+
}
1730+
1731+
#[test]
1732+
fn profile_stack_encoded_buffer_respects_wrapping_policy() {
1733+
let encoded = MIME.encode_buffer::<82>(&[0x5a; 58]).unwrap();
1734+
assert_eq!(&encoded.as_bytes()[76..78], b"\r\n");
1735+
assert!(MIME.validate(encoded.as_bytes()));
1736+
1737+
let bcrypt = BCRYPT.encode_buffer::<4>(&[0xff, 0xff, 0xff]).unwrap();
1738+
assert_eq!(bcrypt.as_bytes(), b"9999");
1739+
}
1740+
16981741
#[cfg(feature = "alloc")]
16991742
#[test]
17001743
fn alloc_helpers_round_trip() {

0 commit comments

Comments
 (0)