Skip to main content

rightkit_qa/
util.rs

1//! Shared error type and small helpers.
2use std::fmt;
3use std::time::{Duration, SystemTime, UNIX_EPOCH};
4
5pub type Result<T> = std::result::Result<T, Error>;
6
7/// One flat error: every failure in a QA run is a message a human reads.
8#[derive(Debug, Clone)]
9pub struct Error(pub String);
10
11impl fmt::Display for Error {
12    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
13        f.write_str(&self.0)
14    }
15}
16impl std::error::Error for Error {}
17impl From<std::io::Error> for Error {
18    fn from(e: std::io::Error) -> Self {
19        Error(e.to_string())
20    }
21}
22impl From<serde_json::Error> for Error {
23    fn from(e: serde_json::Error) -> Self {
24        Error(format!("json: {e}"))
25    }
26}
27impl From<toml::de::Error> for Error {
28    fn from(e: toml::de::Error) -> Self {
29        Error(format!("toml: {e}"))
30    }
31}
32
33pub fn err<T>(msg: impl Into<String>) -> Result<T> {
34    Err(Error(msg.into()))
35}
36
37/// Current UTC time as RFC 3339 (`2026-10-10T12:34:56.789Z`; the fractional part is
38/// omitted when zero and has trailing zeros trimmed).
39pub fn now_iso() -> String {
40    let since_epoch = SystemTime::now()
41        .duration_since(UNIX_EPOCH)
42        .unwrap_or_default();
43    format_rfc3339_utc(since_epoch.as_secs(), since_epoch.subsec_nanos())
44}
45
46fn format_rfc3339_utc(unix_secs: u64, nanos: u32) -> String {
47    let days = (unix_secs / 86_400) as i64;
48    let rem = unix_secs % 86_400;
49    // Civil-from-days (proleptic Gregorian), after Howard Hinnant.
50    let z = days + 719_468;
51    let era = z.div_euclid(146_097);
52    let doe = z.rem_euclid(146_097);
53    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
54    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
55    let mp = (5 * doy + 2) / 153;
56    let day = doy - (153 * mp + 2) / 5 + 1;
57    let month = if mp < 10 { mp + 3 } else { mp - 9 };
58    let year = yoe + era * 400 + i64::from(month <= 2);
59    let mut out = format!(
60        "{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}",
61        rem / 3_600,
62        rem % 3_600 / 60,
63        rem % 60
64    );
65    if nanos != 0 {
66        let frac = format!("{nanos:09}");
67        out.push('.');
68        out.push_str(frac.trim_end_matches('0'));
69    }
70    out.push('Z');
71    out
72}
73
74pub fn now_ms() -> u128 {
75    SystemTime::now()
76        .duration_since(UNIX_EPOCH)
77        .map(|d| d.as_millis())
78        .unwrap_or(0)
79}
80
81pub fn sha256_hex(bytes: &[u8]) -> String {
82    rightkit_fs::digest::sha256_bytes(bytes)
83}
84
85/// A random RFC 4122 version-4 identifier in hyphenated lowercase form (36 characters).
86pub fn new_id() -> String {
87    let mut b = [0u8; 16];
88    getrandom::fill(&mut b).expect("operating system random source unavailable");
89    b[6] = (b[6] & 0x0f) | 0x40;
90    b[8] = (b[8] & 0x3f) | 0x80;
91    let h = |r: std::ops::Range<usize>| b[r].iter().map(|x| format!("{x:02x}")).collect::<String>();
92    format!(
93        "{}-{}-{}-{}-{}",
94        h(0..4),
95        h(4..6),
96        h(6..8),
97        h(8..10),
98        h(10..16)
99    )
100}
101
102pub fn sleep_ms(ms: u64) {
103    std::thread::sleep(Duration::from_millis(ms));
104}
105
106/// Last `max` bytes of `s`, on a char boundary.
107pub fn tail(s: &str, max: usize) -> String {
108    if s.len() <= max {
109        return s.to_string();
110    }
111    let mut start = s.len() - max;
112    while !s.is_char_boundary(start) {
113        start += 1;
114    }
115    s[start..].to_string()
116}
117
118pub fn require_text(value: &str, name: &str) -> Result<String> {
119    let v = value.trim();
120    if v.is_empty() {
121        return err(format!("{name} is required"));
122    }
123    Ok(v.to_string())
124}
125
126/// `darwin` or `windows`; QA runs on those two desktops only.
127pub fn platform() -> Result<&'static str> {
128    if cfg!(target_os = "macos") {
129        Ok("darwin")
130    } else if cfg!(target_os = "windows") {
131        Ok("windows")
132    } else {
133        err(format!(
134            "rightkit-qa supports macOS and Windows, received {}",
135            std::env::consts::OS
136        ))
137    }
138}
139
140/// Longest ordinary path text this crate hands out: under MAX_PATH (260) with room for a file
141/// name the app or test appends.
142const PLAIN_PATH_LIMIT: usize = 200;
143
144fn is_reserved_device_name(component: &str) -> bool {
145    // Windows reserves the device name with or without an extension (`NUL.txt`).
146    let stem = component
147        .split('.')
148        .next()
149        .unwrap_or("")
150        .to_ascii_uppercase();
151    matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL")
152        || ((stem.starts_with("COM") || stem.starts_with("LPT"))
153            && stem.len() == 4
154            && matches!(stem.as_bytes()[3], b'1'..=b'9'))
155}
156
157/// The ordinary form of a Windows verbatim path text: `\\?\C:\dir` becomes `C:\dir` and
158/// `\\?\UNC\server\share` becomes `\\server\share`. Text that is not verbatim is returned
159/// unchanged. A verbatim path is also returned unchanged when the ordinary form would mean
160/// something else or not work: longer than the ordinary limit, a component that is a reserved
161/// device name (`CON`, `NUL.txt`, `COM1`), ends in a dot or a space, holds a character Windows
162/// rejects (`<>:"|?*`, control characters), or is empty (`a\\b`). Std only; pure text.
163pub fn plain_path_text(text: &str) -> String {
164    let (plain, rest) = if let Some(unc) = text.strip_prefix(r"\\?\UNC\") {
165        (format!(r"\\{unc}"), unc)
166    } else if let Some(rest) = text.strip_prefix(r"\\?\") {
167        let b = rest.as_bytes();
168        let drive = b.len() >= 3 && b[0].is_ascii_alphabetic() && b[1] == b':' && b[2] == b'\\';
169        if !drive {
170            return text.to_owned();
171        }
172        (rest.to_owned(), &rest[3..])
173    } else {
174        return text.to_owned();
175    };
176    if plain.chars().count() > PLAIN_PATH_LIMIT {
177        return text.to_owned();
178    }
179    let mut components = rest.split('\\').peekable();
180    while let Some(c) = components.next() {
181        if c.is_empty() {
182            // The drive root itself (`C:\`) has an empty last component; nothing else may.
183            if components.peek().is_none() && rest.len() <= 3 {
184                continue;
185            }
186            return text.to_owned();
187        }
188        if c.ends_with('.') && c != "."
189            || c.ends_with(' ')
190            || c == "."
191            || c == ".."
192            || is_reserved_device_name(c)
193            || c.chars()
194                .any(|ch| ch.is_control() || "<>:\"|?*/".contains(ch))
195        {
196            return text.to_owned();
197        }
198    }
199    plain
200}
201
202/// [`plain_path_text`] for a path: on Windows a verbatim path (what `fs::canonicalize` returns)
203/// becomes the ordinary drive path (`C:\...`) when that is safe, else it stays as it is.
204/// Every other path, and every path on other systems, is returned unchanged.
205pub fn plain_path(path: &std::path::Path) -> std::path::PathBuf {
206    if !cfg!(windows) {
207        return path.to_path_buf();
208    }
209    match path.to_str() {
210        Some(text) if text.starts_with(r"\\?\") => std::path::PathBuf::from(plain_path_text(text)),
211        _ => path.to_path_buf(),
212    }
213}
214
215/// `fs::canonicalize` with the result handed out as an ordinary path (see [`plain_path`]).
216pub fn canonical_plain(path: &std::path::Path) -> std::io::Result<std::path::PathBuf> {
217    std::fs::canonicalize(path).map(|p| plain_path(&p))
218}
219
220#[cfg(test)]
221mod plain_path_tests {
222    use super::*;
223
224    #[test]
225    fn drive_and_unc_paths_lose_the_verbatim_prefix() {
226        assert_eq!(
227            plain_path_text(r"\\?\C:\Users\a\run-1"),
228            r"C:\Users\a\run-1"
229        );
230        assert_eq!(plain_path_text(r"\\?\C:\"), r"C:\");
231        assert_eq!(plain_path_text(r"\\?\UNC\srv\share\x"), r"\\srv\share\x");
232        assert_eq!(plain_path_text(r"C:\already\plain"), r"C:\already\plain");
233        assert_eq!(plain_path_text("/usr/local"), "/usr/local");
234    }
235
236    #[test]
237    fn unsafe_paths_stay_verbatim() {
238        for p in [
239            r"\\?\C:\work\nul",
240            r"\\?\C:\work\NUL.txt",
241            r"\\?\C:\work\com1\x",
242            r"\\?\C:\work\LPT9.log",
243            r"\\?\C:\work\trailing.",
244            r"\\?\C:\work\trailing \x",
245            r"\\?\C:\work\a:b",
246            r"\\?\C:\work\a?b",
247            r"\\?\C:\work\\double",
248            r"\\?\C:\work\..\up",
249            r"\\?\Volume{1234}\x",
250            r"\\?\GLOBALROOT\Device\x",
251        ] {
252            assert_eq!(plain_path_text(p), p, "{p}");
253        }
254        // com0, comfort and names that merely start like a device stay plain.
255        assert_eq!(plain_path_text(r"\\?\C:\w\com0"), r"C:\w\com0");
256        assert_eq!(plain_path_text(r"\\?\C:\w\console"), r"C:\w\console");
257    }
258
259    #[test]
260    fn a_path_over_the_ordinary_limit_stays_verbatim() {
261        let long = format!(r"\\?\C:\{}", "d".repeat(PLAIN_PATH_LIMIT));
262        assert_eq!(plain_path_text(&long), long);
263        let fits = format!(r"\\?\C:\{}", "d".repeat(PLAIN_PATH_LIMIT - 10));
264        assert_eq!(plain_path_text(&fits), fits[4..].to_string());
265    }
266
267    #[cfg(windows)]
268    #[test]
269    fn canonical_plain_hands_out_a_drive_path() {
270        let dir = std::env::temp_dir();
271        let verbatim = std::fs::canonicalize(&dir).unwrap();
272        assert!(verbatim.to_string_lossy().starts_with(r"\\?\"));
273        let plain = canonical_plain(&dir).unwrap();
274        let text = plain.to_string_lossy();
275        assert!(!text.starts_with(r"\\?\"), "{text}");
276        assert!(plain.is_dir());
277        // It names the same directory.
278        assert_eq!(std::fs::canonicalize(&plain).unwrap(), verbatim);
279    }
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285
286    #[test]
287    fn rfc3339_known_instants() {
288        assert_eq!(format_rfc3339_utc(0, 0), "1970-01-01T00:00:00Z");
289        assert_eq!(format_rfc3339_utc(951_782_400, 0), "2000-02-29T00:00:00Z");
290        assert_eq!(
291            format_rfc3339_utc(1_791_635_696, 789_000_000),
292            "2026-10-10T12:34:56.789Z"
293        );
294        assert_eq!(
295            format_rfc3339_utc(86_399, 123_456_789),
296            "1970-01-01T23:59:59.123456789Z"
297        );
298        let now = now_iso();
299        assert!(now.ends_with('Z') && now.len() >= 20, "{now}");
300    }
301
302    #[test]
303    fn new_id_is_a_version_4_uuid() {
304        let id = new_id();
305        assert_eq!(id.len(), 36);
306        let parts: Vec<&str> = id.split('-').collect();
307        assert_eq!(
308            parts.iter().map(|p| p.len()).collect::<Vec<_>>(),
309            vec![8, 4, 4, 4, 12]
310        );
311        assert!(parts[2].starts_with('4'));
312        assert!(matches!(parts[3].as_bytes()[0], b'8' | b'9' | b'a' | b'b'));
313        assert!(id.bytes().all(|c| c == b'-' || c.is_ascii_hexdigit()));
314        assert_ne!(id, new_id());
315    }
316}
317
318/// Decode standard base64 (padding optional, whitespace ignored); `None` on any other byte.
319pub fn base64_decode(s: &str) -> Option<Vec<u8>> {
320    let mut out = Vec::with_capacity(s.len() * 3 / 4);
321    let (mut acc, mut bits) = (0u32, 0u32);
322    for c in s.bytes() {
323        let v = match c {
324            b'A'..=b'Z' => c - b'A',
325            b'a'..=b'z' => c - b'a' + 26,
326            b'0'..=b'9' => c - b'0' + 52,
327            b'+' => 62,
328            b'/' => 63,
329            b'=' | b'\n' | b'\r' | b' ' => continue,
330            _ => return None,
331        } as u32;
332        acc = (acc << 6) | v;
333        bits += 6;
334        if bits >= 8 {
335            bits -= 8;
336            out.push((acc >> bits) as u8);
337            acc &= (1 << bits) - 1;
338        }
339    }
340    Some(out)
341}
342
343#[cfg(test)]
344mod base64_tests {
345    use super::base64_decode;
346    #[test]
347    fn decodes_rfc4648_vectors_and_rejects_junk() {
348        for (i, o) in [
349            ("", ""),
350            ("Zg==", "f"),
351            ("Zm8=", "fo"),
352            ("Zm9v", "foo"),
353            ("Zm9vYg==", "foob"),
354            ("Zm9vYmFy", "foobar"),
355        ] {
356            assert_eq!(base64_decode(i).unwrap(), o.as_bytes());
357        }
358        assert!(base64_decode("Zm9v!").is_none());
359        // padding optional, whitespace ignored
360        assert_eq!(base64_decode("Zm8").unwrap(), b"fo");
361        assert_eq!(base64_decode("Zm9v\nYmFy ").unwrap(), b"foobar");
362    }
363}
364
365// ---------------------------------------------------------------------------------- secret forms
366
367/// One encoded form a leaked secret could take: `name` is the form, `text` the needle to search for.
368#[derive(Debug, Clone, PartialEq, Eq)]
369pub struct SecretForm {
370    pub name: &'static str,
371    pub text: String,
372}
373
374/// Standard base64 of `bytes`, with `=` padding.
375pub fn base64_encode(bytes: &[u8]) -> String {
376    b64(bytes, false, true)
377}
378
379/// URL-safe base64 (RFC 4648 section 5) of `bytes`, no padding: the JWT / webhook-signature form.
380pub fn base64url_encode(bytes: &[u8]) -> String {
381    b64(bytes, true, false)
382}
383
384/// Padded base64 of `bytes` in the standard (`+/`) or URL-safe (`-_`) alphabet.
385fn b64_padded(bytes: &[u8], url: bool) -> String {
386    const STD: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
387    const URL: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
388    let alpha = if url { URL } else { STD };
389    let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
390    for chunk in bytes.chunks(3) {
391        let b0 = u32::from(chunk[0]);
392        let b1 = u32::from(chunk.get(1).copied().unwrap_or(0));
393        let b2 = u32::from(chunk.get(2).copied().unwrap_or(0));
394        let n = (b0 << 16) | (b1 << 8) | b2;
395        out.push(alpha[((n >> 18) & 63) as usize] as char);
396        out.push(alpha[((n >> 12) & 63) as usize] as char);
397        out.push(if chunk.len() > 1 {
398            alpha[((n >> 6) & 63) as usize] as char
399        } else {
400            '='
401        });
402        out.push(if chunk.len() > 2 {
403            alpha[(n & 63) as usize] as char
404        } else {
405            '='
406        });
407    }
408    out
409}
410
411/// The four spellings of base64 (standard or URL-safe alphabet, padded or not).
412fn b64(bytes: &[u8], url: bool, pad: bool) -> String {
413    let mut out = b64_padded(bytes, url);
414    if !pad {
415        out.truncate(out.trim_end_matches('=').len());
416    }
417    out
418}
419
420const STREAM_NAMES: [(&str, &str); 3] = [
421    ("base64_at0", "base64url_at0"),
422    ("base64_at1", "base64url_at1"),
423    ("base64_at2", "base64url_at2"),
424];
425
426/// The part of base64(`prefix`-byte-offset `k` ++ `bytes` ++ anything) that depends on `bytes` alone, no
427/// padding: the leading chars that mix in the `k` preceding bytes and the trailing char that mixes in the
428/// following byte are dropped. `None` when under 4 chars remain (too short to search for).
429fn b64_stream(bytes: &[u8], k: usize, url: bool) -> Option<String> {
430    let mut buf = vec![0u8; k];
431    buf.extend_from_slice(bytes);
432    let full = b64(&buf, url, false);
433    let lead = [0, 2, 3][k];
434    let trail = usize::from(!buf.len().is_multiple_of(3));
435    let end = full.len().checked_sub(trail)?;
436    let text = full.get(lead..end)?;
437    (text.len() >= 4).then(|| text.to_string())
438}
439
440/// HMAC-SHA256 of `message` under `key` (RFC 2104), the raw 32-byte tag, for mocks that verify or
441/// sign webhook-style payloads. Built on `rightkit_fs::digest::Sha256Hasher`.
442pub fn hmac_sha256(key: &[u8], message: &[u8]) -> [u8; 32] {
443    const BLOCK: usize = 64;
444    let mut k = [0u8; BLOCK];
445    if key.len() > BLOCK {
446        let mut h = rightkit_fs::digest::Sha256Hasher::new();
447        h.update(key);
448        k[..32].copy_from_slice(&h.finalize_bytes());
449    } else {
450        k[..key.len()].copy_from_slice(key);
451    }
452    let mut inner = rightkit_fs::digest::Sha256Hasher::new();
453    let ipad: Vec<u8> = k.iter().map(|b| b ^ 0x36).collect();
454    inner.update(&ipad);
455    inner.update(message);
456    let inner_digest = inner.finalize_bytes();
457    let mut outer = rightkit_fs::digest::Sha256Hasher::new();
458    let opad: Vec<u8> = k.iter().map(|b| b ^ 0x5c).collect();
459    outer.update(&opad);
460    outer.update(&inner_digest);
461    outer.finalize_bytes()
462}
463
464/// Lowercase hex of [`hmac_sha256`].
465pub fn hmac_sha256_hex(key: &[u8], message: &[u8]) -> String {
466    rightkit_fs::digest::hex(&hmac_sha256(key, message))
467}
468
469/// Percent-encode every byte but the RFC 3986 unreserved set. `form` encodes a space as `+` (form
470/// encoding); otherwise it is `%20`. `upper` selects the hex-digit case of each escape.
471fn pct(text: &str, upper: bool, form: bool) -> String {
472    let mut out = String::new();
473    for &b in text.as_bytes() {
474        if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
475            out.push(b as char);
476        } else if form && b == b' ' {
477            out.push('+');
478        } else if upper {
479            out.push_str(&format!("%{b:02X}"));
480        } else {
481            out.push_str(&format!("%{b:02x}"));
482        }
483    }
484    out
485}
486
487/// Every encoded form a leak of `secret` could take, for secret-absence checks. Names and forms:
488/// `raw`; `hex_lower`, `hex_upper`; `base64` (standard alphabet, padded and unpadded); `base64url` (URL-safe
489/// alphabet, padded and unpadded); `percent_lower`, `percent_upper` (every non-unreserved byte escaped, a
490/// space as `%20`); `form_lower`, `form_upper` (as percent, a space as `+`). A form identical to one already
491/// listed is omitted. `base64_at0`, `base64_at1`, `base64_at2` (and `base64url_at*`) are the unpadded base64 of the
492/// secret as it appears inside a longer base64 stream at byte offset 0, 1 or 2 (mod 3), with the edge chars
493/// that depend on neighbouring bytes trimmed (none at offset 0 when the length is a multiple of 3); one shorter
494/// than 4 chars is omitted. A form identical to one already
495/// listed is omitted, so a secret with no special characters yields no percent or form copy. An empty secret
496/// yields nothing. Split or re-wrapped copies are not covered.
497pub fn secret_forms(secret: &str) -> Vec<SecretForm> {
498    let mut out: Vec<SecretForm> = Vec::new();
499    if secret.is_empty() {
500        return out;
501    }
502    let bytes = secret.as_bytes();
503    let hex = rightkit_fs::digest::hex(bytes);
504    let mut add = |name: &'static str, text: String| {
505        if !out.iter().any(|f| f.text == text) {
506            out.push(SecretForm { name, text });
507        }
508    };
509    add("raw", secret.to_string());
510    add("hex_lower", hex.to_ascii_lowercase());
511    add("hex_upper", hex.to_ascii_uppercase());
512    add("base64", b64(bytes, false, true));
513    add("base64", b64(bytes, false, false));
514    add("base64url", b64(bytes, true, true));
515    add("base64url", b64(bytes, true, false));
516    // Base64 of the secret inside a larger stream: at byte offset 0, 1 or 2 mod 3 the encoder's
517    // grouping differs, and the chars that mix in neighbouring bytes cannot be matched.
518    for (k, names) in STREAM_NAMES.iter().enumerate() {
519        for (url, name) in [(false, names.0), (true, names.1)] {
520            if let Some(t) = b64_stream(bytes, k, url) {
521                add(name, t);
522            }
523        }
524    }
525    add("percent_lower", pct(secret, false, false));
526    add("percent_upper", pct(secret, true, false));
527    add("form_lower", pct(secret, false, true));
528    add("form_upper", pct(secret, true, true));
529    out
530}
531
532/// Names of the forms of `secret` found in `haystack` (empty means the secret did not leak). Names only, never
533/// the secret. A name repeats when several of its spellings match.
534pub fn secret_leaks(haystack: &[u8], secret: &str) -> Vec<&'static str> {
535    secret_forms(secret)
536        .into_iter()
537        .filter(|f| {
538            haystack
539                .windows(f.text.len())
540                .any(|w| w == f.text.as_bytes())
541        })
542        .map(|f| f.name)
543        .collect()
544}
545
546#[cfg(test)]
547mod base64_encode_tests {
548    use super::*;
549
550    #[test]
551    fn public_encoders_match_rfc4648_and_round_trip() {
552        assert_eq!(base64_encode(b"fo"), "Zm8=");
553        assert_eq!(base64url_encode(b"fo"), "Zm8");
554        assert_eq!(base64url_encode(&[0xfb, 0xff, 0xfe]), "-__-");
555        assert_eq!(base64_encode(&[0xfb, 0xff, 0xfe]), "+//+");
556        assert_eq!(base64url_encode(b""), "");
557        let data: Vec<u8> = (0..=255).collect();
558        assert_eq!(base64_decode(&base64_encode(&data)).unwrap(), data);
559    }
560}
561
562#[cfg(test)]
563mod secret_form_tests {
564    use super::*;
565
566    fn text(forms: &[SecretForm], name: &str) -> Vec<String> {
567        forms
568            .iter()
569            .filter(|f| f.name == name)
570            .map(|f| f.text.clone())
571            .collect()
572    }
573
574    #[test]
575    fn plain_secret_yields_raw_hex_and_base64_only() {
576        let f = secret_forms("foobar");
577        assert_eq!(text(&f, "raw"), vec!["foobar"]);
578        assert_eq!(text(&f, "hex_lower"), vec!["666f6f626172"]);
579        assert_eq!(text(&f, "hex_upper"), vec!["666F6F626172"]);
580        assert_eq!(text(&f, "base64"), vec!["Zm9vYmFy"]);
581        assert!(text(&f, "percent_lower").is_empty() && text(&f, "form_upper").is_empty());
582    }
583
584    #[test]
585    fn base64_matches_rfc4648_padding_and_url_alphabet() {
586        let f = secret_forms("fo");
587        assert_eq!(text(&f, "base64"), vec!["Zm8=", "Zm8"]);
588        assert!(text(&f, "base64url").is_empty());
589        let f = secret_forms("a?>b~");
590        assert_eq!(base64_decode(&text(&f, "base64")[0]).unwrap(), b"a?>b~");
591        assert!(text(&f, "base64")[0].contains(['+', '/']));
592        assert!(text(&f, "base64url")[0].contains(['-', '_']));
593        assert!(!text(&f, "base64url")[0].contains(['+', '/']));
594        assert_eq!(text(&f, "base64url").len(), 2);
595    }
596
597    #[test]
598    fn percent_and_form_cases_and_space() {
599        let f = secret_forms("a b/c+é");
600        assert_eq!(text(&f, "percent_lower"), vec!["a%20b%2fc%2b%c3%a9"]);
601        assert_eq!(text(&f, "percent_upper"), vec!["a%20b%2Fc%2B%C3%A9"]);
602        assert_eq!(text(&f, "form_lower"), vec!["a+b%2fc%2b%c3%a9"]);
603        assert_eq!(text(&f, "form_upper"), vec!["a+b%2Fc%2B%C3%A9"]);
604        let f = secret_forms("a/b");
605        assert_eq!(text(&f, "percent_upper"), vec!["a%2Fb"]);
606        assert!(text(&f, "form_upper").is_empty());
607    }
608
609    #[test]
610    fn empty_secret_yields_nothing_and_leaks_are_named() {
611        assert!(secret_forms("").is_empty());
612        let secret = "tok en/1";
613        let hay = format!("x={} y", pct(secret, true, true));
614        assert_eq!(secret_leaks(hay.as_bytes(), secret), vec!["form_upper"]);
615        assert!(secret_leaks(b"clean", secret).is_empty());
616        assert_eq!(secret_leaks(b"has tok en/1 inside", secret), vec!["raw"]);
617    }
618
619    #[test]
620    fn base64_stream_forms_cover_all_three_alignments() {
621        // The secret carries sextets 62 and 63 in every alignment, so the url-safe copies differ from the
622        // standard copies and survive de-duplication under their `base64url*` names.
623        let secret = "sk_live_abc~def>123?";
624        let forms = secret_forms(secret);
625        for (pre, post) in [
626            ("", ""),
627            ("A", "Z"),
628            ("AB", "YZ"),
629            ("ABC", "XYZ"),
630            ("x", ""),
631            ("xy", "!"),
632        ] {
633            let mut stream = pre.as_bytes().to_vec();
634            stream.extend_from_slice(secret.as_bytes());
635            stream.extend_from_slice(post.as_bytes());
636            let enc = b64(&stream, false, true);
637            assert!(
638                forms
639                    .iter()
640                    .any(|f| enc.contains(&f.text) && f.name.starts_with("base64")),
641                "no form found the secret at offset {} in {enc}",
642                pre.len()
643            );
644            let url = b64(&stream, true, false);
645            assert!(forms
646                .iter()
647                .any(|f| f.name.starts_with("base64url") && url.contains(&f.text)));
648            assert!(
649                !secret_leaks(enc.as_bytes(), secret).is_empty(),
650                "offset {}",
651                pre.len()
652            );
653        }
654    }
655
656    #[test]
657    fn base64_stream_forms_trim_the_edges() {
658        // "foobar" (6 bytes): offset 0 is the whole, unpadded form; offset 1 drops 2 leading chars
659        // and the last; offset 2 drops 3 leading chars and the last.
660        assert_eq!(b64_stream(b"foobar", 0, false).unwrap(), "Zm9vYmFy");
661        assert_eq!(
662            b64_stream(b"foobar", 1, false).unwrap(),
663            b64(b"\0foobar", false, false)[2..9]
664        );
665        assert_eq!(
666            b64_stream(b"foobar", 2, false).unwrap(),
667            b64(b"\0\0foobar", false, false)[3..10]
668        );
669        assert!(b64_stream(b"fo", 1, false).is_none());
670        // Offset forms are real, distinct search terms.
671        let f = secret_forms("foobar-secret");
672        assert!(
673            f.iter().any(|x| x.name == "base64_at1") && f.iter().any(|x| x.name == "base64_at2")
674        );
675    }
676
677    #[test]
678    fn hmac_sha256_matches_rfc4231() {
679        let long = [0xaau8; 131];
680        assert_eq!(
681            hmac_sha256_hex(&[0x0b; 20], b"Hi There"),
682            "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7"
683        );
684        assert_eq!(
685            hmac_sha256_hex(b"Jefe", b"what do ya want for nothing?"),
686            "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"
687        );
688        assert_eq!(
689            hmac_sha256_hex(&[0xaa; 20], &[0xdd; 50]),
690            "773ea91e36800e46854db8ebd09181a72959098b3ef8c122d9635514ced565fe"
691        );
692        assert_eq!(
693            hmac_sha256_hex(
694                &long,
695                b"Test Using Larger Than Block-Size Key - Hash Key First"
696            ),
697            "60e431591ee0b67f0d8a26aacbf5b77f8e0bc6213728c5140546040f0ee37f54"
698        );
699        assert_eq!(hmac_sha256(b"k", b"m").len(), 32);
700    }
701}