use std::sync::LazyLock;
use regex::Regex;
const CIRCLE_DETECTORS: &[&str] = &["circle", "circleci"];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FpShape {
EthAddress,
DerEcdsa,
}
impl FpShape {
pub fn as_tag(self) -> &'static str {
match self {
FpShape::EthAddress => "ETH_ADDRESS",
FpShape::DerEcdsa => "DER_ECDSA_SIGNATURE",
}
}
}
pub fn is_circle_detector(detector: &str) -> bool {
let lower = detector.to_ascii_lowercase();
CIRCLE_DETECTORS.iter().any(|n| lower == *n)
}
static ETH_ADDR_RE: LazyLock<Result<Regex, regex::Error>> =
LazyLock::new(|| Regex::new(r"^(?:0x)?([0-9a-fA-F]{40})$"));
static DER_ECDSA_RE: LazyLock<Result<Regex, regex::Error>> =
LazyLock::new(|| Regex::new(r"^30(?:44|45)02(?:20|21)[0-9a-fA-F]{32,}$"));
pub fn classify_circle_raw_shape(raw: &str) -> Option<FpShape> {
let der_ok = match &*DER_ECDSA_RE {
Ok(re) => re.is_match(raw),
Err(_) => false,
};
if der_ok {
return Some(FpShape::DerEcdsa);
}
let eth_ok = match &*ETH_ADDR_RE {
Ok(re) => re.is_match(raw) && has_mixed_hex_case(raw),
Err(_) => false,
};
if eth_ok {
return Some(FpShape::EthAddress);
}
None
}
fn has_mixed_hex_case(s: &str) -> bool {
let mut has_upper = false;
let mut has_lower = false;
for c in s.bytes() {
match c {
b'A'..=b'F' => has_upper = true,
b'a'..=b'f' => has_lower = true,
_ => {}
}
}
has_upper && has_lower
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn circle_detector_lowercase_match() {
assert!(is_circle_detector("Circle"));
assert!(is_circle_detector("CIRCLE"));
assert!(is_circle_detector("circle"));
assert!(is_circle_detector("CircleCI"));
assert!(is_circle_detector("circleci"));
}
#[test]
fn circle_detector_rejects_non_circle() {
assert!(!is_circle_detector("Slack"));
assert!(!is_circle_detector("PrivateKey"));
assert!(!is_circle_detector("CircleCo")); assert!(!is_circle_detector(""));
}
#[test]
fn classify_documented_eip55_addresses() {
let documented = [
"515669d308f887Fd83a471C7764F5d084886D34D",
"E5CAeF4Af8780E59Df925470b050Fb23C43CA68C",
"9D86b1B2554ec410ecCFfBf111A6994910111340",
"504cDe95dBC5d90d09B802F43B371971adbEcf79",
];
for raw in documented {
assert_eq!(
classify_circle_raw_shape(raw),
Some(FpShape::EthAddress),
"expected EIP-55 classification for {raw}"
);
}
}
#[test]
fn classify_eip55_with_0x_prefix() {
let raw = "0x515669d308f887Fd83a471C7764F5d084886D34D";
assert_eq!(classify_circle_raw_shape(raw), Some(FpShape::EthAddress));
}
#[test]
fn classify_pure_lowercase_hex_is_not_eip55() {
let raw = "515669d308f887fd83a471c7764f5d084886d34d";
assert_eq!(classify_circle_raw_shape(raw), None);
}
#[test]
fn classify_pure_uppercase_hex_is_not_eip55() {
let raw = "515669D308F887FD83A471C7764F5D084886D34D";
assert_eq!(classify_circle_raw_shape(raw), None);
}
#[test]
fn classify_short_hex_is_not_eip55() {
let raw = "515669d308f887Fd83a471C7764F5d084886D34";
assert_eq!(classify_circle_raw_shape(raw), None);
}
#[test]
fn classify_long_hex_is_not_eip55() {
let raw = "515669d308f887Fd83a471C7764F5d084886D34D5";
assert_eq!(classify_circle_raw_shape(raw), None);
}
#[test]
fn classify_documented_der_signatures() {
let documented = [
"3045022100d2e5f6c19461ccab51618430816711",
"3044022027356aa12903409a3881cfce99c38b09",
"3045022100fad45d5ed82b06577fc79b368cfe9b",
];
for raw in documented {
assert_eq!(
classify_circle_raw_shape(raw),
Some(FpShape::DerEcdsa),
"expected DerEcdsa classification for {raw}"
);
}
}
#[test]
fn classify_der_with_30_46_is_not_match() {
let raw = "3046022100d2e5f6c19461ccab51618430816711";
assert_eq!(classify_circle_raw_shape(raw), None);
}
#[test]
fn classify_der_with_02_22_is_not_match() {
let raw = "3045022200d2e5f6c19461ccab51618430816711";
assert_eq!(classify_circle_raw_shape(raw), None);
}
#[test]
fn classify_der_short_is_not_match() {
let raw = "3045022100";
assert_eq!(classify_circle_raw_shape(raw), None);
}
#[test]
fn classify_real_live_circle_key_is_not_fp() {
let raw = "LIVE_API_KEY:abcdef1234567890:ABCDEF1234567890";
assert_eq!(classify_circle_raw_shape(raw), None);
}
#[test]
fn classify_real_test_circle_key_is_not_fp() {
let raw = "TEST_API_KEY:7e54-ef21-aa11:9f8b3c2d1e4a5b6c7d8e";
assert_eq!(classify_circle_raw_shape(raw), None);
}
#[test]
fn classify_circle_uuid_secret_is_not_fp() {
let raw = "LIVE_API_KEY:550e8400-e29b-41d4-a716-446655440000:fedcba98";
assert_eq!(classify_circle_raw_shape(raw), None);
}
#[test]
fn classify_empty_string_is_not_fp() {
assert_eq!(classify_circle_raw_shape(""), None);
}
#[test]
fn classify_short_hex_is_not_fp() {
assert_eq!(classify_circle_raw_shape("deadbeef"), None);
}
#[test]
fn classify_random_text_is_not_fp() {
let raw = "circle_token_value=abcdef1234567890ABCDEF1234567890abcdef12";
assert_eq!(classify_circle_raw_shape(raw), None);
}
#[test]
fn mixed_hex_case_smoke() {
assert!(has_mixed_hex_case("aB"));
assert!(has_mixed_hex_case("123Aa456"));
assert!(!has_mixed_hex_case("AB"));
assert!(!has_mixed_hex_case("ab"));
assert!(!has_mixed_hex_case("123456"));
assert!(!has_mixed_hex_case(""));
}
#[test]
fn fp_shape_tags_are_stable() {
assert_eq!(FpShape::EthAddress.as_tag(), "ETH_ADDRESS");
assert_eq!(FpShape::DerEcdsa.as_tag(), "DER_ECDSA_SIGNATURE");
}
}