Skip to content

Commit a972420

Browse files
authored
Generate seeded random test strings (#2265)
1 parent 3e41b0b commit a972420

File tree

4 files changed

+150
-40
lines changed

4 files changed

+150
-40
lines changed

.vscode/cspell.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
"asyncoperation",
2626
"azsdk",
2727
"azurecli",
28+
"bugbug",
2829
"clippy",
2930
"contoso",
3031
"cplusplus",

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

sdk/core/azure_core_test/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ tokio = { workspace = true, features = [
5050
azure_security_keyvault_secrets = { path = "../../keyvault/azure_security_keyvault_secrets" }
5151
clap.workspace = true
5252
tracing-subscriber = { workspace = true, features = ["env-filter", "fmt"] }
53+
uuid.workspace = true
5354

5455
[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies]
5556
tokio = { workspace = true, features = ["signal"] }

sdk/core/azure_core_test/src/recording.rs

Lines changed: 147 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
//! The [`Recording`] and other types used in recorded tests.
55
6-
// cspell:ignore csprng seedable
6+
// cspell:ignore csprng seedable tpbwhbkhckmk
77
use crate::{
88
proxy::{
99
client::{
@@ -26,7 +26,7 @@ use azure_core::{
2626
};
2727
use azure_identity::DefaultAzureCredential;
2828
use rand::{
29-
distributions::{Distribution, Standard},
29+
distributions::{Alphanumeric, DistString, Distribution, Standard},
3030
Rng, SeedableRng,
3131
};
3232
use rand_chacha::ChaCha20Rng;
@@ -143,10 +143,29 @@ impl Recording {
143143
///
144144
/// # Examples
145145
///
146+
/// Generate a random integer.
147+
///
148+
/// ```
149+
/// # let recording = azure_core_test::Recording::with_seed();
150+
/// let i: i32 = recording.random();
151+
/// # assert_eq!(i, 1054672670);
152+
/// ```
153+
///
146154
/// Generate a symmetric data encryption key (DEK).
147155
///
148-
/// ```no_compile
156+
/// ```
157+
/// # let recording = azure_core_test::Recording::with_seed();
149158
/// let dek: [u8; 32] = recording.random();
159+
/// # assert_eq!(typespec_client_core::base64::encode(dek), "HumPRAN6RqKWf0YhFV2CAFWu/8L/pwh0LRzeam5VlGo=");
160+
/// ```
161+
///
162+
/// Generate a UUID.
163+
///
164+
/// ```
165+
/// use azure_core::Uuid;
166+
/// # let recording = azure_core_test::Recording::with_seed();
167+
/// let uuid: Uuid = Uuid::from_u128(recording.random());
168+
/// # assert_eq!(uuid.to_string(), "fe906b44-5838-cc8f-05e3-c7e93edd071e");
150169
/// ```
151170
///
152171
/// # Panics
@@ -158,52 +177,68 @@ impl Recording {
158177
where
159178
Standard: Distribution<T>,
160179
{
161-
const NAME: &str = "RandomSeed";
162-
163-
// Use ChaCha20 for a deterministic, portable CSPRNG.
164-
let rng = self.rand.get_or_init(|| match self.test_mode {
165-
TestMode::Live => ChaCha20Rng::from_entropy().into(),
166-
TestMode::Playback => {
167-
let variables = self
168-
.variables
169-
.read()
170-
.map_err(read_lock_error)
171-
.unwrap_or_else(|err| panic!("{err}"));
172-
let seed: String = variables
173-
.get(NAME)
174-
.map(Into::into)
175-
.unwrap_or_else(|| panic!("random seed variable not set"));
176-
let seed = base64::decode(seed)
177-
.unwrap_or_else(|err| panic!("failed to decode random seed: {err}"));
178-
let seed = seed
179-
.first_chunk::<32>()
180-
.unwrap_or_else(|| panic!("insufficient random seed variable"));
181-
182-
ChaCha20Rng::from_seed(*seed).into()
183-
}
184-
TestMode::Record => {
185-
let rng = ChaCha20Rng::from_entropy();
186-
let seed = rng.get_seed();
187-
let seed = base64::encode(seed);
180+
let rng = self.rng();
181+
let Ok(mut rng) = rng.lock() else {
182+
panic!("failed to lock RNG");
183+
};
188184

189-
let mut variables = self
190-
.variables
191-
.write()
192-
.map_err(write_lock_error)
193-
.unwrap_or_else(|err| panic!("{err}"));
194-
variables.insert(NAME.to_string(), Value::from(Some(seed), None));
185+
rng.gen()
186+
}
195187

196-
rng.into()
188+
/// Generate a random string with optional prefix.
189+
///
190+
/// This will always be the OS cryptographically secure pseudo-random number generator (CSPRNG) when running live.
191+
/// When recording, it will initialize from the OS CSPRNG but save the seed value to the recording file.
192+
/// When playing back, the saved seed value is read from the recording to reproduce the same sequence of random data.
193+
///
194+
/// # Examples
195+
///
196+
/// Generate a random string.
197+
///
198+
/// ```
199+
/// # let recording = azure_core_test::Recording::with_seed();
200+
/// let id = recording.random_string::<12>(Some("t")).to_ascii_lowercase();
201+
/// # assert_eq!(id, "tpbwhbkhckmk");
202+
/// ```
203+
///
204+
/// # Panics
205+
///
206+
/// Panics if the recording variables cannot be locked for reading or writing,
207+
/// if the random seed cannot be encoded or decoded properly,
208+
/// if `LEN` is 0,
209+
/// or if the length of `prefix` is greater than or equal to `LEN`.
210+
///
211+
/// ```should_panic
212+
/// # let recording = azure_core_test::Recording::with_seed();
213+
/// let vault_name = recording.random_string::<8>(Some("keyvault"));
214+
/// ```
215+
///
216+
pub fn random_string<const LEN: usize>(&self, prefix: Option<&str>) -> String {
217+
struct NonZero<const N: usize>;
218+
impl<const N: usize> NonZero<N> {
219+
const ASSERT: () = assert!(N > 0, "LEN must be greater than 0");
220+
}
221+
#[allow(clippy::let_unit_value)]
222+
let _ = NonZero::<LEN>::ASSERT;
223+
let len = match prefix {
224+
Some(p) => {
225+
assert!(p.len() < LEN, "prefix length must be less than LEN");
226+
LEN - p.len()
197227
}
198-
});
228+
None => LEN,
229+
};
199230

231+
let rng = self.rng();
200232
let Ok(mut rng) = rng.lock() else {
201233
panic!("failed to lock RNG");
202234
};
203235

204-
rng.gen()
236+
let value = Alphanumeric.sample_string(&mut *rng, len);
237+
match prefix {
238+
Some(prefix) => prefix.to_string() + &value,
239+
None => value,
240+
}
205241
}
206-
207242
/// Removes the list of sanitizers from the recording.
208243
///
209244
/// You can find a list of default sanitizers in [source code](https://github.com/Azure/azure-sdk-tools/blob/main/tools/test-proxy/Azure.Sdk.Tools.TestProxy/Common/SanitizerDictionary.cs).
@@ -285,6 +320,8 @@ impl Recording {
285320
}
286321
}
287322

323+
const RANDOM_SEED_NAME: &str = "RandomSeed";
324+
288325
impl Recording {
289326
pub(crate) fn new(
290327
test_mode: TestMode,
@@ -310,6 +347,28 @@ impl Recording {
310347
}
311348
}
312349

350+
// #[cfg(any(test, doctest))] // BUGBUG: https://github.com/rust-lang/rust/issues/67295
351+
#[doc(hidden)]
352+
pub fn with_seed() -> Self {
353+
let span = tracing::trace_span!("Recording::seeded");
354+
Self {
355+
test_mode: TestMode::Playback,
356+
span: span.entered(),
357+
_proxy: None,
358+
client: None,
359+
policy: OnceCell::new(),
360+
service_directory: String::from("sdk/core"),
361+
recording_file: String::from("none"),
362+
recording_assets_file: None,
363+
id: None,
364+
variables: RwLock::new(HashMap::from([(
365+
RANDOM_SEED_NAME.into(),
366+
"8S9UCR2yV8LU01tq+VNEwGssAXVUbL0Hd488GAYVosM=".into(),
367+
)])),
368+
rand: OnceLock::new(),
369+
}
370+
}
371+
313372
fn env<K>(&self, key: K) -> Option<String>
314373
where
315374
K: AsRef<str>,
@@ -322,6 +381,45 @@ impl Recording {
322381
.and_then(|v| v.into_string().ok())
323382
}
324383

384+
fn rng(&self) -> &Mutex<ChaCha20Rng> {
385+
// Use ChaCha20 for a deterministic, portable CSPRNG.
386+
self.rand.get_or_init(|| match self.test_mode {
387+
TestMode::Live => ChaCha20Rng::from_entropy().into(),
388+
TestMode::Playback => {
389+
let variables = self
390+
.variables
391+
.read()
392+
.map_err(read_lock_error)
393+
.unwrap_or_else(|err| panic!("{err}"));
394+
let seed: String = variables
395+
.get(RANDOM_SEED_NAME)
396+
.map(Into::into)
397+
.unwrap_or_else(|| panic!("random seed variable not set"));
398+
let seed = base64::decode(seed)
399+
.unwrap_or_else(|err| panic!("failed to decode random seed: {err}"));
400+
let seed = seed
401+
.first_chunk::<32>()
402+
.unwrap_or_else(|| panic!("insufficient random seed variable"));
403+
404+
ChaCha20Rng::from_seed(*seed).into()
405+
}
406+
TestMode::Record => {
407+
let rng = ChaCha20Rng::from_entropy();
408+
let seed = rng.get_seed();
409+
let seed = base64::encode(seed);
410+
411+
let mut variables = self
412+
.variables
413+
.write()
414+
.map_err(write_lock_error)
415+
.unwrap_or_else(|err| panic!("{err}"));
416+
variables.insert(RANDOM_SEED_NAME.to_string(), Value::from(Some(seed), None));
417+
418+
rng.into()
419+
}
420+
})
421+
}
422+
325423
fn set_skip(&self, skip: Option<Skip>) -> azure_core::Result<()> {
326424
let Some(policy) = self.policy.get() else {
327425
return Ok(());
@@ -502,6 +600,15 @@ impl Value {
502600
}
503601
}
504602

603+
impl From<&str> for Value {
604+
fn from(value: &str) -> Self {
605+
Self {
606+
value: value.into(),
607+
sanitized: None,
608+
}
609+
}
610+
}
611+
505612
impl From<String> for Value {
506613
fn from(value: String) -> Self {
507614
Self {

0 commit comments

Comments
 (0)