open-standards/openpay/crates/op-bin/src/bin.rs
Transaction Science 5ae7e4ef86 ingest wave E: 3 crates · op-bin + smart-byte-tendermint + eoc-safety
OpenPay (1 crate, 1801 LOC, 57 tests)
  op-bin              BIN/IIN database + range matching + card-network
                      classification. 8-digit normalized prefix Bin +
                      BinRange. Sorted-vec interval tree with O(log N) point
                      lookup + overlap rejection. CardNetwork classify()
                      with structural rules (Visa 4, MC 51-55/2221-2720,
                      Amex 34/37, Discover, JCB, Diners, UnionPay, RuPay,
                      Mir, Troy, Maestro, Elo). Static prefix tables +
                      build_default_tree(). ISO/IEC 7812-1 Annex B Luhn
                      (validate / is_valid / check_digit). Card-type +
                      issuer-country + Durbin regulated-debit. PCI-safe:
                      8-digit cap, no full-PAN APIs.

Smart Byte (1 crate, ~1500 LOC, 20 tests)
  smart-byte-tendermint  BFT consensus. Step enum + Round + propose/prevote/
                         precommit/commit FSM with voting-power tallies.
                         ValidatorSet with 2/3+1 quorum, 1/3+1 trust
                         threshold, round-robin proposer. Block / Header /
                         Commit / CommitSig with Blake3 hashing. Light client
                         (sequential + skipping). ABCI Application trait +
                         EchoApp. DuplicateVoteEvidence + LightClientAttackEvidence.
                         IBC ClientState + ConsensusState + light-client
                         update.

EOC (1 crate, 2084 LOC, 23 tests)
  eoc-safety          AI safety primitives. RegexSet over 16 prompt-injection
                      signatures. 13-family jailbreak detector (DAN/DUDE/
                      Grandma/STAN/AIM/dev-mode/etc.). Presidio-style PII
                      redactor with Luhn-validated CC. Toxicity / bias /
                      NSFW classifier traits + heuristic baselines (5-axis
                      bias, 4-category NSFW with hard-reject for minors).
                      Constitutional AI critique→revise loop. Composable
                      InputGuard / OutputGuard pipelines. JSON-Schema-subset
                      output validator. Token-bucket rate limiter +
                      abuse-window detection. Red-team replay harness.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-24 16:52:07 -04:00

238 lines
6.8 KiB
Rust

//! BIN value and prefix-range types.
//!
//! ISO/IEC 7812 assigns issuer identification in 6-to-8-digit
//! prefixes. We normalize every input to its **8-digit prefix
//! form** (left-justified, right-padded with zeros for the low
//! end of a range; right-padded with nines for the high end).
//! This lets us collapse 6-, 7-, and 8-digit IINs into a single
//! `u32` keyspace in `0..=99_999_999`.
use core::fmt;
use serde::{Deserialize, Serialize};
use crate::card_type::CardType;
use crate::error::{Error, Result};
use crate::issuer_country::IssuerCountry;
use crate::network::CardNetwork;
/// A validated BIN — 6, 7, or 8 ASCII decimal digits.
///
/// Internally normalized to an 8-digit `u32` prefix (the original
/// length is preserved). Constructors accept strings *or* the
/// leading digits of a PAN; in the latter case the caller is
/// responsible for truncating to `<= 8` digits **before** calling
/// `Bin::parse` (PCI scope).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct Bin {
/// 8-digit prefix form, right-padded with zeros.
/// Range: `0..=99_999_999`.
prefix_8: u32,
/// Original digit length (6, 7, or 8).
length: u8,
}
impl Bin {
/// Parse a 6-to-8-digit ASCII numeric string into a `Bin`.
///
/// # Errors
///
/// - [`Error::InvalidBinLength`] if `s.len()` is not in `6..=8`.
/// - [`Error::InvalidBinCharacter`] for any non-`'0'..='9'`.
pub fn parse(s: &str) -> Result<Self> {
let n = s.len();
if !(6..=8).contains(&n) {
return Err(Error::InvalidBinLength { got: n });
}
let mut value: u32 = 0;
for c in s.chars() {
let d = c.to_digit(10).ok_or(Error::InvalidBinCharacter(c))?;
value = value * 10 + d;
}
// Left-justify into the 8-digit slot.
let pad = 8 - n;
let prefix_8 = value * 10u32.pow(pad as u32);
Ok(Self {
prefix_8,
length: n as u8,
})
}
/// Construct directly from a numeric value, asserting length.
///
/// # Errors
///
/// - [`Error::InvalidBinLength`] if `length` is not in `6..=8`.
pub fn from_u32(value: u32, length: u8) -> Result<Self> {
if !(6..=8).contains(&length) {
return Err(Error::InvalidBinLength {
got: length as usize,
});
}
let pad = 8 - length;
let prefix_8 = value * 10u32.pow(pad as u32);
Ok(Self { prefix_8, length })
}
/// 8-digit prefix form (right-padded with zeros).
pub const fn prefix_8(&self) -> u32 {
self.prefix_8
}
/// Original BIN length: 6, 7, or 8.
pub const fn length(&self) -> u8 {
self.length
}
/// Truncate the leading digits of a PAN to a `Bin`. The caller
/// must hand us **only** the first `digits` characters; this
/// helper exists so the BIN-extraction site is grep-able and
/// PCI-auditable. `digits` is clamped to `6..=8`.
///
/// # Errors
///
/// - As [`Bin::parse`].
pub fn from_pan_prefix(prefix: &str, digits: usize) -> Result<Self> {
let d = digits.clamp(6, 8);
if prefix.len() < d {
return Err(Error::InvalidBinLength { got: prefix.len() });
}
Self::parse(&prefix[..d])
}
}
impl fmt::Display for Bin {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let pad = 8 - self.length;
let value = self.prefix_8 / 10u32.pow(pad as u32);
write!(f, "{:0width$}", value, width = self.length as usize)
}
}
/// A half-open BIN prefix interval `[low, high)` annotated with
/// the network and bookkeeping flags. Bounds are expressed in
/// **8-digit prefix form**.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BinRange {
/// Inclusive lower bound (8-digit form).
pub low: u32,
/// Exclusive upper bound (8-digit form).
pub high: u32,
/// Card network that owns this range.
pub network: CardNetwork,
/// Card type — credit / debit / prepaid / charge.
pub card_type: CardType,
/// Issuer country (ISO 3166-1 alpha-2). Optional —
/// many ranges are multi-country.
pub country: Option<IssuerCountry>,
/// Whether issuer is subject to Regulation II
/// debit-interchange caps (Durbin Amendment).
pub durbin_regulated: bool,
}
impl BinRange {
/// Build a half-open prefix interval, validating ordering.
///
/// # Errors
///
/// - [`Error::InvalidRange`] if `low >= high`.
pub fn new(
low: u32,
high: u32,
network: CardNetwork,
card_type: CardType,
country: Option<IssuerCountry>,
durbin_regulated: bool,
) -> Result<Self> {
if low >= high {
return Err(Error::InvalidRange { low, high });
}
Ok(Self {
low,
high,
network,
card_type,
country,
durbin_regulated,
})
}
/// True iff the BIN falls within this half-open interval.
pub fn contains(&self, bin: &Bin) -> bool {
let p = bin.prefix_8();
p >= self.low && p < self.high
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bin_parse_6_digits() {
let b = Bin::parse("411111").expect("valid");
assert_eq!(b.length(), 6);
assert_eq!(b.prefix_8(), 41_111_100);
assert_eq!(b.to_string(), "411111");
}
#[test]
fn bin_parse_8_digits() {
let b = Bin::parse("41111111").expect("valid");
assert_eq!(b.length(), 8);
assert_eq!(b.prefix_8(), 41_111_111);
}
#[test]
fn bin_rejects_length_5() {
assert!(matches!(
Bin::parse("41111"),
Err(Error::InvalidBinLength { got: 5 })
));
}
#[test]
fn bin_rejects_non_digit() {
assert!(matches!(
Bin::parse("41A111"),
Err(Error::InvalidBinCharacter('A'))
));
}
#[test]
fn bin_from_pan_prefix_truncates() {
let b = Bin::from_pan_prefix("4111111111111111", 6).expect("valid");
assert_eq!(b.length(), 6);
assert_eq!(b.to_string(), "411111");
}
#[test]
fn range_contains() {
let r = BinRange::new(
40_000_000,
50_000_000,
CardNetwork::Visa,
CardType::Credit,
None,
false,
)
.expect("valid");
let b = Bin::parse("411111").expect("valid");
assert!(r.contains(&b));
}
#[test]
fn range_rejects_inverted() {
assert!(matches!(
BinRange::new(
50_000_000,
40_000_000,
CardNetwork::Visa,
CardType::Credit,
None,
false,
),
Err(Error::InvalidRange { .. })
));
}
}