Skip to content

Commit 4c334a7

Browse files
committed
Add constant-time decoded length helper
1 parent 8c37a75 commit 4c334a7

5 files changed

Lines changed: 66 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,8 @@
7474
policy logging uses the same dependency-free formatting surface.
7575
- Added `Engine::profile()` for explicit dependency-free promotion to an
7676
unwrapped `Profile`.
77+
- Added `ct::CtEngine::decoded_len()` so sensitive decode paths can size
78+
caller-owned buffers without switching to the diagnostic decoder.
7779

7880
## 0.8.0 - 2026-05-16
7981

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -390,8 +390,9 @@ returning the error. The legacy whitespace profile also provides
390390
`decode_in_place_wrapped`, `decode_in_place_wrapped_clear_tail`, and the same
391391
in-place behavior through `Profile::decode_in_place`. The `ct` module provides
392392
the same clear-tail decode variants for callers using the constant-time-oriented
393-
scalar decoder, plus `ct::CtEngine::decode_buffer` for stack-backed no-alloc
394-
decoded output.
393+
scalar decoder, `ct::CtEngine::decoded_len` for sizing caller-owned buffers
394+
under the same opaque malformed-input policy, plus
395+
`ct::CtEngine::decode_buffer` for stack-backed no-alloc decoded output.
395396

396397
For short values, `encode_buffer` returns a stack-backed `EncodedBuffer`
397398
and `decode_buffer` returns a stack-backed `DecodedBuffer` without requiring

portability/no_alloc_smoke/src/lib.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,9 @@ pub fn ct_stack_decode() -> bool {
9393
if ct_policy.as_bytes() != b"ct padded=true" {
9494
return false;
9595
}
96+
if ct::STANDARD.decoded_len(b"aGVsbG8=") != Ok(5) {
97+
return false;
98+
}
9699

97100
let decoded = match ct::STANDARD.decode_buffer::<5>(b"aGVsbG8=") {
98101
Ok(decoded) => decoded,

src/lib.rs

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1671,7 +1671,7 @@ pub mod stream {
16711671
pub mod ct {
16721672
use super::{
16731673
Alphabet, DecodeError, DecodedBuffer, Standard, UrlSafe, ct_decode_in_place,
1674-
ct_decode_slice, ct_validate_decode,
1674+
ct_decode_slice, ct_decoded_len, ct_validate_decode,
16751675
};
16761676
use core::marker::PhantomData;
16771677

@@ -1748,6 +1748,15 @@ pub mod ct {
17481748
self.validate_result(input).is_ok()
17491749
}
17501750

1751+
/// Returns the exact decoded length for valid input.
1752+
///
1753+
/// This uses the same constant-time-oriented validation policy as
1754+
/// [`Self::decode_slice`] before returning a length. Input length,
1755+
/// padding length, and final success or failure remain public.
1756+
pub fn decoded_len(&self, input: &[u8]) -> Result<usize, DecodeError> {
1757+
ct_decoded_len::<A, PAD>(input)
1758+
}
1759+
17511760
/// Decodes `input` into `output`, returning the number of bytes
17521761
/// written.
17531762
///
@@ -6024,6 +6033,25 @@ fn ct_validate_decode<A: Alphabet, const PAD: bool>(input: &[u8]) -> Result<(),
60246033
}
60256034
}
60266035

6036+
fn ct_decoded_len<A: Alphabet, const PAD: bool>(input: &[u8]) -> Result<usize, DecodeError> {
6037+
ct_validate_decode::<A, PAD>(input)?;
6038+
if input.is_empty() {
6039+
return Ok(0);
6040+
}
6041+
6042+
if PAD {
6043+
Ok(input.len() / 4 * 3 - ct_padding_len(input))
6044+
} else {
6045+
let full_quads = input.len() / 4 * 3;
6046+
match input.len() % 4 {
6047+
0 => Ok(full_quads),
6048+
2 => Ok(full_quads + 1),
6049+
3 => Ok(full_quads + 2),
6050+
_ => Err(DecodeError::InvalidLength),
6051+
}
6052+
}
6053+
}
6054+
60276055
fn ct_validate_padded<A: Alphabet>(input: &[u8]) -> Result<(), DecodeError> {
60286056
if !input.len().is_multiple_of(4) {
60296057
return Err(DecodeError::InvalidLength);

tests/rfc4648.rs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -735,6 +735,35 @@ fn ct_validate_matches_constant_time_decode_policy() {
735735
);
736736
}
737737

738+
#[test]
739+
fn ct_decoded_len_matches_constant_time_decode_policy() {
740+
assert_eq!(ct::STANDARD.decoded_len(b""), Ok(0));
741+
assert_eq!(ct::STANDARD.decoded_len(b"Zg=="), Ok(1));
742+
assert_eq!(ct::STANDARD.decoded_len(b"Zm8="), Ok(2));
743+
assert_eq!(ct::STANDARD.decoded_len(b"Zm9v"), Ok(3));
744+
assert_eq!(ct::STANDARD_NO_PAD.decoded_len(b""), Ok(0));
745+
assert_eq!(ct::STANDARD_NO_PAD.decoded_len(b"Zg"), Ok(1));
746+
assert_eq!(ct::STANDARD_NO_PAD.decoded_len(b"Zm8"), Ok(2));
747+
assert_eq!(ct::STANDARD_NO_PAD.decoded_len(b"Zm9v"), Ok(3));
748+
749+
assert_eq!(
750+
ct::STANDARD.decoded_len(b"AA-A"),
751+
Err(DecodeError::InvalidInput)
752+
);
753+
assert_eq!(
754+
ct::STANDARD.decoded_len(b"Zh=="),
755+
Err(DecodeError::InvalidInput)
756+
);
757+
assert_eq!(
758+
ct::STANDARD_NO_PAD.decoded_len(b"Zg=="),
759+
Err(DecodeError::InvalidInput)
760+
);
761+
assert_eq!(
762+
ct::STANDARD.decoded_len(b"Zg"),
763+
Err(DecodeError::InvalidLength)
764+
);
765+
}
766+
738767
fn assert_ct_validate_decode_agree<A, const PAD: bool>(
739768
engine: ct::CtEngine<A, PAD>,
740769
cases: &[&[u8]],

0 commit comments

Comments
 (0)