use doppel::{patterns, register, restore, swap, types::Entry};
const SYNTH_ANTHROPIC: &[u8] = b"sk-ant-api03-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
const SYNTH_AWS_AKIA: &[u8] = b"AKIAIOSFODNN7EXAMPLE";
const SYNTH_GITHUB_CLASSIC: &[u8] = b"ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
#[allow(dead_code)]
const SYNTH_GITHUB_FG: &[u8] =
b"github_pat_AAAAAAAAAAAAAAAAAAAAAA_BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB";
#[allow(dead_code)]
const SYNTH_GCP: &[u8] = b"AIzaSyAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
#[test]
fn test_inv1_swap_replaces_every_detected_secret() {
let payload = [b"Authorization: ".as_slice(), SYNTH_ANTHROPIC].concat();
let result = swap(&payload, &[patterns::anthropic()]).expect("swap failed");
assert!(
!result
.payload
.windows(SYNTH_ANTHROPIC.len())
.any(|w| w == SYNTH_ANTHROPIC),
"INV-1: original secret must not appear in scrubbed payload"
);
}
#[test]
fn test_inv2_swap_does_not_modify_non_secret_bytes() {
let prefix = b"Authorization: ";
let suffix = b" end-of-header";
let payload = [prefix.as_slice(), SYNTH_ANTHROPIC, suffix].concat();
let result = swap(&payload, &[patterns::anthropic()]).expect("swap failed");
assert!(
result.payload.starts_with(prefix),
"INV-2: prefix unchanged"
);
assert!(result.payload.ends_with(suffix), "INV-2: suffix unchanged");
assert_eq!(
result.payload.len(),
payload.len(),
"INV-2: total payload length preserved"
);
}
#[test]
fn test_inv3_entries_exactly_one_per_distinct_secret() {
let payload = [SYNTH_ANTHROPIC, b" separator ".as_slice(), SYNTH_AWS_AKIA].concat();
let result =
swap(&payload, &[patterns::anthropic(), patterns::aws_akia()]).expect("swap failed");
assert_eq!(
result.entries.len(),
2,
"INV-3: one entry per distinct secret (2 different secrets)"
);
}
#[test]
fn test_inv4_restore_restores_across_chunk_boundaries() {
let payload = [b"ctx: ".as_slice(), SYNTH_GITHUB_CLASSIC].concat();
let scrub_result = swap(&payload, &[patterns::github_classic()]).expect("swap failed");
struct ByteByByteReader<'a> {
data: &'a [u8],
pos: usize,
}
impl<'a> std::io::Read for ByteByByteReader<'a> {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
if self.pos >= self.data.len() {
return Ok(0);
}
buf[0] = self.data[self.pos];
self.pos += 1;
Ok(1)
}
}
let mut reader = ByteByByteReader {
data: &scrub_result.payload,
pos: 0,
};
let mut output = Vec::new();
restore(
&mut reader,
&mut output,
&scrub_result.entries,
&scrub_result.session_key,
)
.unwrap();
assert_eq!(output, payload, "INV-4: chunk-boundary restoration failed");
}
#[test]
fn test_inv5_restore_does_not_emit_before_aead_verified() {
let payload = [b"ctx: ".as_slice(), SYNTH_GITHUB_CLASSIC].concat();
let mut scrub_result = swap(&payload, &[patterns::github_classic()]).expect("swap failed");
scrub_result.entries[0].flip_last_ciphertext_byte_for_testing();
let mut input = scrub_result.payload.as_slice();
let mut output = Vec::new();
let _ = restore(
&mut input,
&mut output,
&scrub_result.entries,
&scrub_result.session_key,
);
assert!(
!output
.windows(SYNTH_GITHUB_CLASSIC.len())
.any(|w| w == SYNTH_GITHUB_CLASSIC),
"INV-5: secret must not appear in output after tag failure"
);
}
#[cfg(feature = "async")]
#[test]
fn test_inv5_async_no_plaintext_before_aead_verified() {
use bytes::Bytes;
use doppel::restore_stream;
use futures::{StreamExt, stream};
use std::io;
let payload = [b"ctx: ".as_slice(), SYNTH_GITHUB_CLASSIC].concat();
let mut sr = swap(&payload, &[patterns::github_classic()]).expect("swap failed");
let last = sr.entries[0].ciphertext.len() - 1;
sr.entries[0].ciphertext[last] ^= 0xFF;
let chunks: Vec<Result<Bytes, io::Error>> = sr
.payload
.chunks(16)
.map(|c| Ok(Bytes::copy_from_slice(c)))
.collect();
let inner = stream::iter(chunks);
let stream = restore_stream(inner, sr.entries, sr.session_key).unwrap();
let result = futures::executor::block_on(async {
let mut all: Vec<u8> = Vec::new();
futures::pin_mut!(stream);
while let Some(item) = stream.next().await {
match item {
Ok(chunk) => all.extend_from_slice(&chunk),
Err(e) => return Err((e, all)),
}
}
Ok(all)
});
let (_, emitted) = result.expect_err("tampered tag must produce error");
assert!(
!emitted
.windows(SYNTH_GITHUB_CLASSIC.len())
.any(|w| w == SYNTH_GITHUB_CLASSIC),
"INV-5: secret must not appear in output before tag failure (async)"
);
}
#[test]
fn test_inv6_aead_tag_failure_produces_error() {
let payload = [b"ctx: ".as_slice(), SYNTH_GITHUB_CLASSIC].concat();
let mut scrub_result = swap(&payload, &[patterns::github_classic()]).expect("swap failed");
scrub_result.entries[0].flip_last_ciphertext_byte_for_testing();
let mut input = scrub_result.payload.as_slice();
let mut output = Vec::new();
let result = restore(
&mut input,
&mut output,
&scrub_result.entries,
&scrub_result.session_key,
);
assert!(result.is_err(), "INV-6: tag failure must return Err");
}
#[cfg(feature = "async")]
#[test]
fn test_inv6_async_aead_tag_failure_produces_error() {
use bytes::Bytes;
use doppel::{RestoreError, restore_stream};
use futures::{StreamExt, stream};
use std::io;
let payload = [b"ctx: ".as_slice(), SYNTH_GITHUB_CLASSIC].concat();
let mut sr = swap(&payload, &[patterns::github_classic()]).expect("swap failed");
let last = sr.entries[0].ciphertext.len() - 1;
sr.entries[0].ciphertext[last] ^= 0xFF;
let chunks: Vec<Result<Bytes, io::Error>> = sr
.payload
.chunks(16)
.map(|c| Ok(Bytes::copy_from_slice(c)))
.collect();
let inner = stream::iter(chunks);
let stream = restore_stream(inner, sr.entries, sr.session_key).unwrap();
let result = futures::executor::block_on(async {
let mut items_after_err = 0usize;
let mut saw_err = false;
futures::pin_mut!(stream);
while let Some(item) = stream.next().await {
if saw_err {
items_after_err += 1;
}
if item.is_err() {
assert!(
matches!(item, Err(RestoreError::AeadTagFailure { .. })),
"INV-6: must yield AeadTagFailure"
);
saw_err = true;
}
}
(saw_err, items_after_err)
});
assert!(result.0, "INV-6: must produce an error on tampered tag");
assert_eq!(
result.1, 0,
"INV-6: stream must not yield items after error"
);
}
#[test]
fn test_inv7_restore_no_fake_forwarded_unchanged() {
let payload = b"no secrets here at all";
let scrub_result = swap(payload, &[patterns::anthropic()]).expect("swap failed");
let response = b"a response with no matching content";
let mut input = response.as_slice();
let mut output = Vec::new();
let result = restore(
&mut input,
&mut output,
&scrub_result.entries,
&scrub_result.session_key,
);
assert!(
result.is_ok(),
"INV-7: must not produce error when no fake present"
);
assert_eq!(output, response, "INV-7: output identical to input");
}
#[test]
fn test_inv8_restore_bounded_hold() {
let payload = [b"ctx: ".as_slice(), SYNTH_ANTHROPIC].concat();
let scrub_result = swap(&payload, &[patterns::anthropic()]).expect("swap failed");
struct OneByteReader<'a> {
data: &'a [u8],
pos: usize,
}
impl<'a> std::io::Read for OneByteReader<'a> {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
if self.pos >= self.data.len() {
return Ok(0);
}
buf[0] = self.data[self.pos];
self.pos += 1;
Ok(1)
}
}
let mut reader = OneByteReader {
data: &scrub_result.payload,
pos: 0,
};
let mut output = Vec::new();
restore(
&mut reader,
&mut output,
&scrub_result.entries,
&scrub_result.session_key,
)
.unwrap();
assert_eq!(
output, payload,
"INV-8: round-trip successful (buffer bound enforced by implementation)"
);
}
#[cfg(feature = "async")]
#[test]
fn test_inv8_async_hold_bound_observed() {
use bytes::Bytes;
use doppel::restore_stream;
use futures::StreamExt;
use std::io;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
let payload = [b"prefix ".as_slice(), SYNTH_ANTHROPIC, b" suffix"].concat();
let sr = swap(&payload, &[patterns::anthropic()]).expect("swap failed");
let max_hold = sr.entries.iter().map(|e| e.fake.len()).max().unwrap_or(0);
let fed = Arc::new(AtomicUsize::new(0));
let fed_clone = fed.clone();
let chunks = sr.payload.iter().map(move |&b| {
fed_clone.fetch_add(1, Ordering::SeqCst);
Ok::<_, io::Error>(Bytes::from(vec![b]))
});
let inner = futures::stream::iter(chunks);
let stream = restore_stream(inner, sr.entries, sr.session_key).unwrap();
let mut max_lag = 0usize;
let mut total_out = 0usize;
futures::executor::block_on(async {
futures::pin_mut!(stream);
while let Some(item) = stream.next().await {
let chunk = item.expect("no error expected");
total_out += chunk.len();
let current_fed = fed.load(Ordering::SeqCst);
let lag = current_fed.saturating_sub(total_out);
if lag > max_lag {
max_lag = lag;
}
}
});
assert!(
max_lag <= max_hold,
"INV-8: max lag {} exceeds max_hold {}",
max_lag,
max_hold
);
assert_eq!(total_out, payload.len(), "round-trip length mismatch");
}
#[test]
fn test_inv9_entries_contain_no_plaintext_secret() {
let payload = [b"token: ".as_slice(), SYNTH_ANTHROPIC].concat();
let result = swap(&payload, &[patterns::anthropic()]).expect("swap failed");
let json = Entry::serialize_entries(&result.entries).unwrap();
assert!(
!json
.windows(SYNTH_ANTHROPIC.len())
.any(|w| w == SYNTH_ANTHROPIC),
"INV-9: plaintext secret must not appear in serialized entries"
);
}
#[test]
fn test_inv9_registered_no_secret_bytes_in_entries() {
let secret = b"my-arb-secret-value-0123456789!"; let pat = register(secret).unwrap();
let payload = [b"Authorization: ".as_slice(), secret].concat();
let result = swap(&payload, &[pat]).expect("swap failed");
let json = Entry::serialize_entries(&result.entries).unwrap();
for window in secret.windows(4) {
assert!(
!json.windows(4).any(|w| w == window),
"INV-9 VIOLATED: secret window {:?} found in serialized entries",
std::str::from_utf8(window).unwrap_or("<binary>")
);
}
}
#[test]
fn test_inv10_session_key_not_in_entries() {
let payload = [b"token: ".as_slice(), SYNTH_ANTHROPIC].concat();
let result = swap(&payload, &[patterns::anthropic()]).expect("swap failed");
let json = Entry::serialize_entries(&result.entries).unwrap();
let key_bytes = result.session_key.as_bytes();
assert!(
!json.windows(32).any(|w| w == key_bytes.as_slice()),
"INV-10: session key must not appear in serialized entries"
);
}
#[test]
fn test_inv11_session_key_zeroized_on_drop() {
use doppel::types::SessionKey;
fn assert_zeroize_on_drop<T: zeroize::ZeroizeOnDrop>() {}
assert_zeroize_on_drop::<SessionKey>();
}
#[test]
fn test_inv12_key_material_destroyed_on_drop() {
use doppel::types::SessionKey;
let _ = std::mem::size_of::<SessionKey>();
}
#[test]
fn test_inv13_same_secret_same_pattern_same_fake() {
let payload = [b"x: ".as_slice(), SYNTH_ANTHROPIC].concat();
let pat = patterns::anthropic();
let result1 = swap(&payload, std::slice::from_ref(&pat)).expect("swap failed");
let result2 = swap(&payload, std::slice::from_ref(&pat)).expect("swap failed");
assert_eq!(
result1.entries[0].fake, result2.entries[0].fake,
"INV-13: same secret + same Pattern must produce same fake"
);
}
#[test]
fn test_inv14_multiple_occurrences_one_entry_same_fake() {
let sep = b" separator ";
let payload = [SYNTH_ANTHROPIC, sep.as_slice(), SYNTH_ANTHROPIC].concat();
let result = swap(&payload, &[patterns::anthropic()]).expect("swap failed");
assert_eq!(
result.entries.len(),
1,
"INV-14: one entry for repeated secret"
);
let fake = &result.entries[0].fake;
assert!(
result.payload.starts_with(fake.as_slice()),
"INV-14: first occurrence → fake"
);
assert!(
result.payload.ends_with(fake.as_slice()),
"INV-14: second occurrence → same fake"
);
}
#[test]
fn test_inv15_fake_not_equal_to_original() {
let payload = [b"k: ".as_slice(), SYNTH_ANTHROPIC].concat();
for _ in 0..10 {
let result = swap(&payload, &[patterns::anthropic()]).expect("swap failed");
let fake = &result.entries[0].fake;
assert_ne!(
fake.as_slice(),
SYNTH_ANTHROPIC,
"INV-15: fake must not equal original"
);
assert!(
fake.starts_with(b"sk-ant-api03-"),
"INV-15: fake must preserve prefix"
);
assert_eq!(
fake.len(),
SYNTH_ANTHROPIC.len(),
"INV-15: fake must preserve length"
);
}
}
#[test]
fn test_inv16_registered_hmac_failure_passthrough() {
use doppel::register;
let real_secret = b"my-registered-api-secret-value!";
let pat = register(real_secret).unwrap();
let mut tampered = real_secret.to_vec();
tampered[12] ^= 0xFF;
let result = swap(&tampered, &[pat]).expect("swap failed");
assert_eq!(
result.payload, tampered,
"INV-16: HMAC failure → pass through unchanged"
);
assert!(
result.entries.is_empty(),
"INV-16: no entry for HMAC-failed candidate"
);
}
#[test]
fn test_inv17_registered_unique_salt_per_registration() {
use doppel::register;
let secret = b"my-secret-value-for-registration";
let pat1 = register(secret).unwrap();
let pat2 = register(secret).unwrap();
let payload1 = [b"token: ".as_slice(), secret].concat();
let r1 = swap(&payload1, &[pat1]).expect("swap failed");
let r2 = swap(&payload1, &[pat2]).expect("swap failed");
assert_eq!(r1.entries.len(), 1);
assert_eq!(r2.entries.len(), 1);
}
#[test]
fn test_inv18_leftmost_longest_match() {
let payload: &[u8] = b"sk-proj-BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBT3BlbkFJBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB";
let result = swap(
payload,
&[patterns::openai_classic(), patterns::openai_project()],
)
.expect("swap failed");
assert_eq!(
result.entries.len(),
1,
"INV-18: only one entry (no overlapping matches)"
);
assert!(
result.entries[0].fake.starts_with(b"sk-proj-"),
"INV-18: project key pattern wins (longer match)"
);
}
#[test]
fn test_inv19_restore_exact_matching_only() {
let payload = b"no secret here";
let scrub_result = swap(payload, &[patterns::anthropic()]).expect("swap failed");
let response_with_real_key = [b"response: ".as_slice(), SYNTH_ANTHROPIC].concat();
let mut input = response_with_real_key.as_slice();
let mut output = Vec::new();
restore(
&mut input,
&mut output,
&scrub_result.entries,
&scrub_result.session_key,
)
.unwrap();
assert_eq!(
output, response_with_real_key,
"INV-19: real key in response must pass through (exact matching only)"
);
}
#[test]
fn test_inv22_all_structural_built_in_classes_present() {
let cases: &[(&str, &[u8])] = &[
(
"Anthropic",
b"sk-ant-api03-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
),
(
"OpenAI classic",
b"sk-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
),
(
"OpenAI project",
b"sk-proj-BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBT3BlbkFJBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB",
),
("AWS AKIA", b"AKIAAAAAAAAAAAAAAAAA"),
("AWS ASIA", b"ASIAAAAAAAAAAAAAAAAA"),
(
"GitHub classic",
b"ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
),
(
"GitHub fine-grained",
b"github_pat_AAAAAAAAAAAAAAAAAAAAAA_BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB",
),
("GCP", b"AIzaAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"),
(
"OpenRouter",
b"sk-or-v1-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
),
(
"Slack bot",
b"xoxb-1234567890-1234567890-AAAAAAAAAAAAAAAAAAAAAAAA",
),
(
"OpenAI svcacct",
b"sk-svcacct-BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBT3BlbkFJBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB",
),
(
"Anthropic Admin01",
b"sk-ant-admin01-w8bVJRHra9S96i3ios_XhbLgzEBjS6qjPUEgiPrWjN2OeICCY1lwhK3Z35Z_jM89STjqSOxHh6GWGkG2R7uv-AohQLmK9AA",
),
(
"Anthropic Admin03",
b"sk-ant-admin03-w8bVJRHra9S96i3ios_XhbLgzEBjS6qjPUEgiPrWjN2OeICCY1lwhK3Z35Z_jM89STjqSOxHh6GWGkG2R7uv-AohQLmK9AA",
),
(
"Linear",
b"lin_api_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
),
(
"Groq",
b"gsk_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
),
(
"Perplexity",
b"pplx-abcdef0123456789abcdef0123456789abcdef0123456789",
),
(
"Cerebras",
b"csk-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
),
(
"Stripe live",
b"sk_live_AAAAAAAAAAAAAAAAAAAAAAAA",
),
(
"Stripe test",
b"sk_test_AAAAAAAAAAAAAAAAAAAAAAAA",
),
(
"Clerk",
b"sk_live_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
),
(
"Svix",
b"svix_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
),
(
"Chromatic",
b"chpt_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
),
(
"Google OAuth Secret",
b"GOCSPX-AAAAAAAAAAAAAAAAAAAAAAAAAAAA",
),
(
"GitHub OAuth",
b"gho_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
),
(
"GitHub App Server",
b"ghs_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
),
(
"GitHub App User",
b"ghu_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
),
(
"GitHub Refresh",
b"ghr_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
),
];
let all = patterns::all();
for (name, secret) in cases {
let result = swap(secret, &all).expect("swap failed");
assert_eq!(
result.entries.len(),
1,
"INV-22: {name} key must be detected by patterns::all()"
);
}
}
#[test]
fn test_inv23_no_detectable_secrets_returns_unchanged() {
let payload = b"Hello, world! This is a normal message with no API keys.";
let result = swap(payload, &patterns::all()).expect("swap failed");
assert_eq!(
result.payload.as_slice(),
payload,
"INV-23: payload unchanged"
);
assert!(result.entries.is_empty(), "INV-23: entries empty");
let result2 = swap(payload, &[]).expect("swap failed");
assert_eq!(
result2.payload.as_slice(),
payload,
"INV-23: empty patterns → payload unchanged"
);
assert!(
result2.entries.is_empty(),
"INV-23: empty patterns → entries empty"
);
}
#[test]
fn test_inv24_aad_fake_binding() {
let secret = SYNTH_ANTHROPIC;
let payload = [b"token: ".as_slice(), secret].concat();
let scrub_result = swap(&payload, &[patterns::anthropic()]).unwrap();
let mut tampered = scrub_result.entries.clone();
tampered[0].fake = b"ATTACKER_TRIGGER".to_vec();
let response = b"response contains ATTACKER_TRIGGER here";
let mut input = response.as_slice();
let mut output = Vec::new();
let result = restore(
&mut input,
&mut output,
&tampered,
&scrub_result.session_key,
);
assert!(
result.is_err(),
"INV-24: tampered fake must cause AeadTagFailure, not silent exfiltration"
);
assert!(
!output.windows(secret.len()).any(|w| w == secret),
"INV-24: original secret must not appear in output after fake tamper"
);
}
#[test]
fn test_inv28_literal_segments_reproduced_verbatim_in_fake() {
use patterns::{anthropic, github_fine_grained, openai_project, slack_bot};
{
let secret = SYNTH_ANTHROPIC;
let result = swap(secret, &[anthropic()]).expect("swap failed");
let fake = &result.entries[0].fake;
assert!(
fake.starts_with(b"sk-ant-api03-"),
"INV-28: Anthropic prefix literal"
);
assert!(
fake.ends_with(b"AA"),
"INV-28: Anthropic suffix literal 'AA'"
);
}
{
let secret: &[u8] = b"sk-proj-BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBT3BlbkFJBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB";
let result = swap(secret, &[openai_project()]).expect("swap failed");
let fake = &result.entries[0].fake;
assert!(
fake.starts_with(b"sk-proj-"),
"INV-28: OpenAI project prefix literal"
);
assert_eq!(
&fake[8 + 58..8 + 58 + 8],
b"T3BlbkFJ",
"INV-28: T3BlbkFJ embedded literal at position 66"
);
}
{
let secret = SYNTH_GITHUB_FG;
let result = swap(secret, &[github_fine_grained()]).expect("swap failed");
let fake = &result.entries[0].fake;
assert!(
fake.starts_with(b"github_pat_"),
"INV-28: GitHub FG prefix literal"
);
assert_eq!(
&fake[11 + 22..11 + 23],
b"_",
"INV-28: GitHub FG underscore separator at position 33"
);
}
{
let secret = b"xoxb-1234567890-1234567890-AAAAAAAAAAAAAAAAAAAAAAAA";
let result = swap(secret, &[slack_bot()]).expect("swap failed");
let fake = &result.entries[0].fake;
assert!(fake.starts_with(b"xoxb-"), "INV-28: Slack prefix literal");
assert_eq!(
fake[15], b'-',
"INV-28: Slack first '-' separator at position 15"
);
assert_eq!(
fake[26], b'-',
"INV-28: Slack second '-' separator at position 26"
);
}
}
#[test]
fn test_inv29_variable_segment_bytes_in_charset() {
use patterns::{anthropic, github_fine_grained, slack_bot};
let url_safe_b64: Vec<u8> = {
let mut v: Vec<u8> = (b'A'..=b'Z')
.chain(b'a'..=b'z')
.chain(b'0'..=b'9')
.chain([b'-', b'_'])
.collect();
v.sort_unstable();
v
};
let digits_cs: Vec<u8> = (b'0'..=b'9').collect();
let alnum_cs: Vec<u8> = (b'A'..=b'Z')
.chain(b'a'..=b'z')
.chain(b'0'..=b'9')
.collect();
{
let result = swap(SYNTH_ANTHROPIC, &[anthropic()]).expect("swap failed");
let fake = &result.entries[0].fake;
assert_eq!(fake.len(), 108);
assert!(
fake[13..106].iter().all(|b| url_safe_b64.contains(b)),
"INV-29: Anthropic variable region must be url_safe_b64"
);
}
{
let result = swap(SYNTH_GITHUB_FG, &[github_fine_grained()]).expect("swap failed");
let fake = &result.entries[0].fake;
assert_eq!(fake.len(), 93);
assert!(
fake[11..33].iter().all(|b| alnum_cs.contains(b)),
"INV-29: GitHub FG var1 must be alphanumeric"
);
assert!(
fake[34..93].iter().all(|b| alnum_cs.contains(b)),
"INV-29: GitHub FG var2 must be alphanumeric"
);
}
{
let secret = b"xoxb-1234567890-1234567890-AAAAAAAAAAAAAAAAAAAAAAAA";
let result = swap(secret, &[slack_bot()]).expect("swap failed");
let fake = &result.entries[0].fake;
assert_eq!(fake.len(), 51);
assert!(
fake[5..15].iter().all(|b| digits_cs.contains(b)),
"INV-29: Slack var1 must be digits"
);
assert!(
fake[16..26].iter().all(|b| digits_cs.contains(b)),
"INV-29: Slack var2 must be digits"
);
assert!(
fake[27..51].iter().all(|b| alnum_cs.contains(b)),
"INV-29: Slack var3 must be alphanumeric"
);
}
}
#[test]
fn test_inv13_cross_serialization_fake_stability() {
use doppel::{SecretOptions, SecretsFile, register_with_options, swap};
let secret = b"my-custom-secret-for-cross-serial-test";
let pat = register_with_options(secret, &SecretOptions::default()).unwrap();
let mut pf = SecretsFile::new();
pf.generate_missing_structural_salts();
pf.add_secret_pattern("my-custom-secret".to_string(), &pat)
.unwrap();
let payload = [b"token: ".as_slice(), secret].concat();
let result1 = swap(&payload, std::slice::from_ref(&pat)).unwrap();
let bytes = pf.serialize().unwrap();
let pf2 = SecretsFile::deserialize(&bytes).unwrap();
let patterns2 = pf2.to_patterns().unwrap();
let result2 = swap(&payload, &patterns2).unwrap();
assert_eq!(
result1.entries[0].fake, result2.entries[0].fake,
"INV-13: same secret through serialized patterns must produce same fake"
);
}
#[test]
fn test_inv25_register_empty_secret_returns_tooshort() {
use doppel::SecretError;
let result = doppel::register(b"");
assert!(
matches!(result, Err(SecretError::TooShort)),
"INV-25: empty secret must yield TooShort"
);
}
#[test]
fn test_inv25_register_no_variable_bytes_returns_error() {
use doppel::{SecretError, SecretOptions, register_with_options};
let secret = b"abcdefgh"; let opts = SecretOptions {
anchor_len: 5,
tail_anchor_len: 3,
restrict_charset: false,
..Default::default()
};
let result = register_with_options(secret, &opts);
assert!(
matches!(result, Err(SecretError::NoVariableBytes { .. })),
"INV-25: fully-anchored secret must yield NoVariableBytes"
);
}
#[test]
fn test_inv26_register_low_entropy_warns_but_succeeds() {
use doppel::{SecretOptions, register_with_options};
let secret = b"abc01234567890123"; let opts = SecretOptions {
anchor_len: 3,
restrict_charset: false,
..Default::default()
};
let result = register_with_options(secret, &opts);
assert!(
result.is_ok(),
"INV-26: registration with entropy in [83, 131) must succeed (got warning)"
);
}
#[test]
fn test_inv27_register_alphanumeric_secret_wide_charset_succeeds() {
use doppel::{SecretOptions, register_with_options};
let secret = b"myAlphaNumericSecret123";
let opts = SecretOptions {
restrict_charset: false,
..Default::default()
};
let result = register_with_options(secret, &opts);
assert!(
result.is_ok(),
"INV-27: registration must succeed (got warning)"
);
}
#[test]
fn test_fix2_restrict_charset_uses_detected_charset() {
use doppel::{SecretOptions, register_with_options, swap};
let secret = b"sk-abcdef01234567"; let opts = SecretOptions {
anchor_len: 3,
restrict_charset: true,
force: true, ..Default::default()
};
let pattern = register_with_options(secret, &opts).expect("registration must succeed");
let result = swap(secret, &[pattern]).expect("swap must succeed");
assert_eq!(result.entries.len(), 1, "must detect the secret");
let fake = &result.entries[0].fake;
let var_fake = &fake[3..];
let all_hex = var_fake
.iter()
.all(|&b| matches!(b, b'0'..=b'9' | b'a'..=b'f'));
assert!(
all_hex,
"restrict_charset=true with hex-lower secret: fake variable bytes must be hex-lower, got: {:?}",
var_fake
);
}
#[test]
fn test_fix1_hmac_all_digests_evaluated_no_early_exit() {
use doppel::{SecretOptions, register_with_options, swap};
let secret1 = b"sk-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; let secret2 = b"sk-BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"; let neither = b"sk-CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC"; let opts = SecretOptions {
anchor_len: 3,
..Default::default()
};
let p1 = register_with_options(secret1, &opts).unwrap();
let p2 = register_with_options(secret2, &opts).unwrap();
let r1 = swap(secret1, &[p1.clone(), p2.clone()]).unwrap();
assert_eq!(r1.entries.len(), 1, "secret1 must match p1");
let r_none = swap(neither, &[p1, p2]).unwrap();
assert_eq!(
r_none.entries.len(),
0,
"structurally matching non-registered secret must not be swapped"
);
}
#[test]
fn test_inv30_user_structural_requires_variable_segment() {
use doppel::{SecretsFile, segment::SegmentDef};
let mut pf = SecretsFile::new();
pf.generate_missing_structural_salts();
let result = pf.add_structural_entry(
"pure_literal".into(),
vec![SegmentDef::Literal {
value: "just-a-prefix".into(),
}],
[0u8; 32],
);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("at least one variable segment"),
"INV-30: pure literal segment list must be rejected"
);
}
#[test]
fn test_inv31_duplicate_identifier_rejected() {
use doppel::{SecretsFile, segment::SegmentDef};
let mut pf = SecretsFile::new();
pf.generate_missing_structural_salts();
let segs = vec![
SegmentDef::Literal {
value: "prefix_".into(),
},
SegmentDef::Variable {
charset: "alphanumeric".into(),
min: 10,
max: 10,
},
];
pf.add_structural_entry("my_custom".into(), segs.clone(), [1u8; 32])
.unwrap();
let err = pf
.add_structural_entry("my_custom".into(), segs, [2u8; 32])
.unwrap_err();
assert!(
err.to_string().contains("duplicate"),
"INV-31: duplicate identifier must be rejected"
);
}
#[test]
fn test_inv32_missing_builtin_is_allowed() {
use doppel::SecretsFile;
let pf = SecretsFile {
version: 3,
pattern: vec![],
};
let patterns = pf.to_patterns().unwrap();
assert_eq!(
patterns.len(),
0,
"INV-32: empty pattern list must produce zero patterns"
);
}
#[test]
fn test_inv33_version_must_be_3() {
use doppel::SecretsFile;
let data = b"version = 1\npattern = []\n";
let err = SecretsFile::deserialize(data).unwrap_err();
assert!(
err.to_string().contains("unsupported"),
"INV-33: version 1 must be rejected"
);
let data = b"version = 2\npattern = []\n";
let err = SecretsFile::deserialize(data).unwrap_err();
assert!(
err.to_string().contains("unsupported"),
"INV-33: version 2 must be rejected"
);
}
#[test]
fn test_inv25_collision_limit_path_exists() {
use doppel::{SecretError, register_with_options};
let secret = b"short-but-valid-secret-value";
let _ = register_with_options(secret, &Default::default()).unwrap();
let err: SecretError = SecretError::CollisionLimit { attempts: 1 };
assert!(
err.to_string().contains("exhausted"),
"INV-25: CollisionLimit error message must be descriptive"
);
}
#[test]
fn test_inv_empty_fake_sync_rejected() {
use doppel::types::{Entry, SessionKey};
use doppel::{RestoreError, restore};
let bad_entry = Entry::new_for_testing(vec![], vec![0u8; 24], vec![0u8; 32]);
let key = SessionKey::from_bytes([1u8; 32]);
let mut input = b"some payload".as_slice();
let mut output = Vec::new();
let result = restore(&mut input, &mut output, &[bad_entry], &key);
assert!(
matches!(result, Err(RestoreError::Build { .. })),
"empty fake MUST return Err(Build), not loop"
);
assert!(
output.is_empty(),
"guard MUST fire before any bytes are written to output"
);
}
#[cfg(feature = "async")]
#[test]
fn test_inv_empty_fake_async_rejected() {
use bytes::Bytes;
use doppel::types::{Entry, SessionKey};
use doppel::{RestoreError, restore_stream};
use futures::stream;
use std::io;
let bad_entry = Entry::new_for_testing(vec![], vec![0u8; 24], vec![0u8; 32]);
let key = SessionKey::from_bytes([1u8; 32]);
let inner = stream::empty::<Result<Bytes, io::Error>>();
let result = restore_stream(inner, vec![bad_entry], key);
assert!(
matches!(result, Err(RestoreError::Build { .. })),
"empty fake MUST return Err(Build) from constructor"
);
}
#[test]
fn test_inv28_opaque_fake_bytes_differ_from_anchor() {
use doppel::{SecretOptions, register_with_options, swap};
let secret = b"ABC_secret_value_here_12345678";
let opts = SecretOptions {
anchor_len: 4,
..Default::default()
};
let pattern = register_with_options(secret, &opts).unwrap();
let result = swap(secret, &[pattern]).unwrap();
assert_eq!(result.entries.len(), 1);
assert_ne!(
&result.payload[0..4],
b"ABC_",
"INV-28: opaque anchor segment must not appear verbatim in the fake"
);
assert_eq!(result.payload.len(), secret.len());
}
#[test]
fn test_inv31_instance_pattern_variable_must_be_fixed_len() {
use doppel::SecretsFile;
let toml_bad = concat!(
"version = 3\n",
"[[pattern]]\n",
"identifier = \"test-instance\"\n",
"salt = \"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\"\n",
"digests = [\"abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789\"]\n",
"[[pattern.segments]]\n",
"type = \"opaque\"\n",
"value = \"prefix_\"\n",
"[[pattern.segments]]\n",
"type = \"variable\"\n",
"charset = \"alphanumeric\"\n",
"min = 10\n",
"max = 20\n",
);
let result = SecretsFile::deserialize(toml_bad.as_bytes());
assert!(
result.is_err(),
"INV-31: instance pattern with variable-range segment must be rejected"
);
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("min") || err_msg.contains("max") || err_msg.contains("instance"),
"INV-31: error must describe the min/max constraint: {err_msg}"
);
}
#[test]
fn test_inv25_register_insufficient_entropy_hard_fail() {
use doppel::{SecretError, SecretOptions, register_with_options};
let short_secret = b"ABC12345678";
let opts = SecretOptions {
anchor_len: 3,
force: false,
..Default::default()
};
let result = register_with_options(short_secret, &opts);
assert!(
matches!(result, Err(SecretError::InsufficientEntropy { .. })),
"INV-25: low-entropy secret with force=false must yield InsufficientEntropy"
);
}
#[test]
fn test_inv18_literal_first_beats_opaque_first() {
use doppel::patterns;
use doppel::{SecretOptions, register_with_options, swap};
let registered_secret = b"sk-ant-test-fake-secret-123456789";
let opts = SecretOptions {
anchor_len: 7,
..Default::default()
};
let reg_pattern = register_with_options(registered_secret, &opts).unwrap();
let payload = [b"key: ".as_slice(), SYNTH_ANTHROPIC].concat();
let result = swap(&payload, &[patterns::anthropic(), reg_pattern]).unwrap();
assert_eq!(
result.entries.len(),
1,
"INV-18: only the structural literal match should fire"
);
assert!(
!result
.payload
.windows(SYNTH_ANTHROPIC.len())
.any(|w| w == SYNTH_ANTHROPIC),
"INV-18: structural match must replace the original secret"
);
}
#[test]
fn test_vc11_registered_hmac_mismatch_passthrough() {
use doppel::{register, swap};
let real = b"my-registered-secret-value-12345";
let pat = register(real).unwrap();
let mut similar = real.to_vec();
similar[12] ^= 0xFF;
let result = swap(&similar, &[pat]).unwrap();
assert_eq!(
result.payload.as_slice(),
similar.as_slice(),
"VC-11: HMAC mismatch must pass through unchanged"
);
assert!(
result.entries.is_empty(),
"VC-11: no entry produced for HMAC mismatch"
);
}
#[test]
fn test_wide_charset_entropy_uses_92_not_72() {
use doppel::{SecretOptions, register_with_options};
let secret = b"abc!@#$%^&*()-+=";
let opts = SecretOptions {
anchor_len: 3,
restrict_charset: false,
..Default::default()
};
let result = register_with_options(secret, &opts);
assert!(
result.is_ok(),
"13-byte wide-charset variable portion (84.8 bits) must pass entropy gate"
);
}
#[test]
fn test_vc17_group_pattern_detects_both_members() {
use doppel::{SecretOptions, SecretsFile, register_with_options, swap};
let secret_a = b"my-secret-alpha-value-for-vc17-test";
let secret_b = b"my-secret-beta-values-for-vc17-test";
let secret_c = b"my-secret-gamma-value-for-vc17-test";
let opts = SecretOptions::default();
let pat_a = register_with_options(secret_a, &opts).unwrap();
let mut pf = SecretsFile::new();
pf.add_secret_pattern("group-key".to_string(), &pat_a)
.unwrap();
pf.add_secret_to_group("group-key", secret_b).unwrap();
let patterns = pf.to_patterns().unwrap();
assert_eq!(patterns.len(), 1, "group produces one Pattern");
assert_eq!(
pf.pattern[0].digests.len(),
2,
"group entry has two digests"
);
let payload_a = [b"header: ".as_slice(), secret_a].concat();
let r_a = swap(&payload_a, &patterns).unwrap();
assert_eq!(r_a.entries.len(), 1, "secret_a detected");
assert!(
!r_a.payload.windows(secret_a.len()).any(|w| w == secret_a),
"VC-17: secret_a must be replaced in output"
);
let payload_b = [b"header: ".as_slice(), secret_b].concat();
let r_b = swap(&payload_b, &patterns).unwrap();
assert_eq!(r_b.entries.len(), 1, "secret_b detected");
assert!(
!r_b.payload.windows(secret_b.len()).any(|w| w == secret_b),
"VC-17: secret_b must be replaced in output"
);
assert_ne!(
r_a.entries[0].fake, r_b.entries[0].fake,
"VC-17: distinct fakes for A and B"
);
let payload_c = [b"header: ".as_slice(), secret_c].concat();
let r_c = swap(&payload_c, &patterns).unwrap();
assert_eq!(r_c.entries.len(), 0, "VC-17: non-member C not detected");
assert_eq!(r_c.payload, payload_c, "VC-17: C passes through unchanged");
}
#[test]
fn test_inv39_detector_swap_semantics_identical_to_free_swap() {
use doppel::{Detector, patterns, swap};
let pat = patterns::anthropic();
let payload = b"key: sk-ant-api03-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
let detector = Detector::new(vec![pat.clone()]);
let r_detector = detector.swap(payload).unwrap();
let r_free = swap(payload, &[pat]).unwrap();
assert_eq!(
r_detector.entries[0].fake, r_free.entries[0].fake,
"INV-39: Detector::swap and free swap must produce identical fakes"
);
assert_eq!(
r_detector.payload, r_free.payload,
"INV-39: swapped payloads must match"
);
}
#[test]
fn test_inv40_detector_is_send_sync() {
fn assert_send_sync<T: Send + Sync + 'static>() {}
assert_send_sync::<doppel::Detector>();
}
#[test]
fn test_inv41_detector_shared_automaton_produces_correct_results_across_calls() {
use doppel::{Detector, patterns};
let detector = Detector::new(patterns::all());
let key = b"sk-ant-api03-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
let payload_a = [b"token: ".as_slice(), key].concat();
let payload_b = b"no secrets here".as_ref();
let r_a = detector.swap(&payload_a).unwrap();
let r_b = detector.swap(payload_b).unwrap();
assert_eq!(
r_a.entries.len(),
1,
"INV-41: secret detected on first call"
);
assert_eq!(
r_b.entries.len(),
0,
"INV-41: no secret detected on second call"
);
assert_eq!(r_b.payload, payload_b, "INV-41: payload_b unchanged");
}
#[test]
fn test_inv39_detector_swap_semantics_identical_for_registered_pattern() {
use doppel::{Detector, register, swap};
let secret = b"inv39-registered-secret-value-check-0001";
let pat = register(secret).unwrap();
let payload = [b"token: ".as_slice(), secret].concat();
let detector = Detector::new(vec![pat.clone()]);
let r_detector = detector.swap(&payload).unwrap();
let r_free = swap(&payload, &[pat]).unwrap();
assert_eq!(
r_detector.entries.len(),
1,
"INV-39: Detector must detect the registered secret"
);
assert_eq!(
r_detector.entries[0].fake, r_free.entries[0].fake,
"INV-39: Detector::swap and free swap must produce identical fakes for registered pattern"
);
assert_eq!(
r_detector.payload, r_free.payload,
"INV-39: swapped payloads must match for registered pattern"
);
}
#[test]
fn test_add_secret_to_group_rejects_family_pattern() {
use doppel::{SecretsFile, SecretsFileError, segment::SegmentDef};
let mut pf = SecretsFile::new();
pf.add_structural_entry(
"my-family".to_string(),
vec![
SegmentDef::Literal {
value: "prefix_".into(),
},
SegmentDef::Variable {
charset: "alphanumeric".into(),
min: 10,
max: 20,
},
],
[0u8; 32],
)
.unwrap();
let result = pf.add_secret_to_group("my-family", b"some-secret-value-here-here-12345");
assert!(
matches!(result, Err(SecretsFileError::WrongPatternType)),
"add_secret_to_group must return WrongPatternType for family patterns, got: {:?}",
result
);
}
#[test]
fn test_vc14_family_pattern_opaque_first_detection_and_fake() {
use doppel::{SecretsFile, swap};
let toml = concat!(
"version = 3\n",
"[[pattern]]\n",
"identifier = \"vc14-family\"\n",
"salt = \"0000000000000000000000000000000000000000000000000000000000000001\"\n",
"digests = []\n",
"[[pattern.segments]]\n",
"type = \"opaque\"\n",
"value = \"sec_\"\n",
"[[pattern.segments]]\n",
"type = \"variable\"\n",
"charset = \"alphanumeric\"\n",
"min = 20\n",
"max = 20\n",
);
let pf = SecretsFile::deserialize(toml.as_bytes()).unwrap();
let patterns = pf.to_patterns().unwrap();
let secret = b"sec_AAAABBBBCCCCDDDDEEEE";
assert_eq!(secret.len(), 24);
let result = swap(secret, &patterns).unwrap();
assert_eq!(
result.entries.len(),
1,
"VC-14: family pattern must detect the secret"
);
let fake = &result.entries[0].fake;
assert_eq!(
fake.len(),
secret.len(),
"VC-14: fake must be same length as original"
);
assert_ne!(
&fake[..4],
b"sec_",
"VC-14: opaque segment fake bytes must not equal original anchor"
);
assert_ne!(
&fake[4..],
b"AAAABBBBCCCCDDDDEEEE",
"VC-14: variable segment fake bytes must differ from original"
);
}
#[test]
fn test_stripe_clerk_sk_live_gap_behavior() {
let payload = b"sk_live_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; assert_eq!(payload.len(), 44);
let result = swap(payload, &patterns::all()).unwrap();
assert_eq!(
result.entries.len(),
1,
"Stripe pattern fires for first 32 variable chars"
);
assert_eq!(
&result.payload[40..],
b"AAAA",
"trailing chars beyond stripe_live max=32 must pass through unchanged"
);
assert_ne!(
result.payload[..40].to_vec(),
payload[..40].to_vec(),
"first 40 bytes must be replaced with a fake"
);
}
#[test]
fn test_inv42_register_rejects_anchor_len_zero_or_one() {
use doppel::{SecretError, SecretOptions, register_with_options};
let secret = b"my-secret-api-key-long-enough";
for bad_len in [0usize, 1usize] {
let opts = SecretOptions {
anchor_len: bad_len,
..SecretOptions::default()
};
let err = match register_with_options(secret, &opts) {
Err(e) => e,
Ok(_) => panic!("INV-42: anchor_len={bad_len} must be rejected, but succeeded"),
};
assert!(
matches!(err, SecretError::AnchorTooShort { anchor_len } if anchor_len == bad_len),
"INV-42: expected AnchorTooShort for anchor_len={bad_len}, got {err:?}"
);
}
}
#[test]
fn test_inv43_define_rejects_first_segment_shorter_than_two_bytes() {
use doppel::{SecretsFile, SecretsFileError, segment::SegmentDef};
for bad_value in ["", "x"] {
let mut pf = SecretsFile::default();
let segments = vec![
SegmentDef::Literal {
value: bad_value.to_string(),
},
SegmentDef::Variable {
charset: "alphanumeric".to_string(),
min: 10,
max: 10,
},
];
let err = pf
.add_structural_entry("test-id".to_string(), segments, [0u8; 32])
.unwrap_err();
assert!(
matches!(err, SecretsFileError::InvalidSegment { .. }),
"INV-43: expected InvalidSegment for first segment value {:?}, got {err:?}",
bad_value
);
assert!(
err.to_string().contains("first segment") || err.to_string().contains("byte"),
"INV-43: error message should describe the constraint; got: {err}"
);
}
}