open-standards/openpay/crates/op-emv/tests/conformance.rs
Transaction Science 70ecd6caa0 Initial import — three open standards under one shelf
Transaction Science open standards monorepo. Each subdirectory is a
self-contained reference protocol with its own README and licence.

- openpay/    Apache-2.0  payment-acceptance stack (Rust, 26 crates)
- smart-byte/ CC-BY-4.0   content-addressed value carrier (spec)
- eoc/        CC-BY-4.0   energy-optimised compute (spec)

The wire format and the right to fork are public. The optional hosted
services are the offer; the protocols are not.
2026-05-23 07:58:09 -04:00

201 lines
7 KiB
Rust

//! Conformance tests against canonical EMV BER-TLV byte sequences.
//!
//! Each `.hex` file in `vectors/` is a hex-encoded TLV blob. The
//! assertions here encode our understanding of what the bytes mean.
//! When the EMV spec doesn't change (which is its whole point), these
//! values are fixed for the life of the project.
//!
//! Sources:
//! - `fci_template.hex` — Payment System Environment selection response,
//! verbatim from EMV Book 1 Annex A example (also widely cited at
//! emvlab.org/tlvutils).
//! - `taptopay_flat.hex`, `taptopay_nested.hex` — six standard
//! transaction-data tags as a terminal kernel would emit them on a
//! Tap-to-Pay $1.00 USD purchase, May 17 2026.
use op_emv::stream::TlvIter;
use op_emv::tag::Tag;
use op_emv::tree::{Tlv, TlvBody, TlvSliceExt};
fn unhex(s: &str) -> Vec<u8> {
let s = s.trim();
let mut out = Vec::with_capacity(s.len() / 2);
let bytes = s.as_bytes();
let mut i = 0;
while i + 1 < bytes.len() {
let hi = (bytes[i] as char).to_digit(16).expect("invalid hex");
let lo = (bytes[i + 1] as char).to_digit(16).expect("invalid hex");
out.push((hi * 16 + lo) as u8);
i += 2;
}
out
}
const FCI: &str = include_str!("../vectors/fci_template.hex");
const TAPTOPAY_FLAT: &str = include_str!("../vectors/taptopay_flat.hex");
const TAPTOPAY_NESTED: &str = include_str!("../vectors/taptopay_nested.hex");
// ---- FCI Template (canonical EMV PSE response) ----
#[test]
fn fci_template_parses_to_expected_tree() {
let buf = unhex(FCI);
// 6F (FCI Template, constructed) ─┬─ 84 (DF Name) = "1PAY.SYS.DDF01"
// └─ A5 (FCI Proprietary) ─┬─ 88 (SFI) = 0x02
// └─ 5F2D (Language) = "en"
let tree = Tlv::parse_all(&buf).unwrap();
assert_eq!(tree.len(), 1, "FCI is a single top-level TLV");
let fci = &tree[0];
assert_eq!(fci.tag, Tag::FCI_TEMPLATE);
let children = match &fci.body {
TlvBody::Constructed(c) => c,
TlvBody::Primitive(_) => panic!("FCI must be constructed"),
};
assert_eq!(children.len(), 2);
// 84 — DF Name
assert_eq!(children[0].tag, Tag::DF_NAME);
assert_eq!(children[0].primitive().unwrap(), b"1PAY.SYS.DDF01");
// A5 — FCI Proprietary
assert_eq!(children[1].tag, Tag::FCI_PROPRIETARY);
let prop_children = match &children[1].body {
TlvBody::Constructed(c) => c,
_ => panic!("A5 must be constructed"),
};
assert_eq!(prop_children.len(), 2);
assert_eq!(prop_children[0].tag, Tag::SFI);
assert_eq!(prop_children[0].primitive().unwrap(), &[0x02]);
assert_eq!(prop_children[1].tag, Tag::LANGUAGE);
assert_eq!(prop_children[1].primitive().unwrap(), b"en");
}
#[test]
fn fci_template_find_locates_language_deep() {
let buf = unhex(FCI);
let tree = Tlv::parse_all(&buf).unwrap();
let language = tree.find_tag(Tag::LANGUAGE).expect("5F2D should be in FCI");
assert_eq!(language.primitive().unwrap(), b"en");
}
#[test]
fn fci_template_encoded_len_matches_input() {
let buf = unhex(FCI);
let tree = Tlv::parse_all(&buf).unwrap();
let total: usize = tree.iter().map(|t| t.encoded_len()).sum();
assert_eq!(
total,
buf.len(),
"encoded_len must round-trip the input length"
);
}
// ---- Tap-to-Pay flat ----
#[test]
fn taptopay_flat_contains_six_standard_fields() {
let buf = unhex(TAPTOPAY_FLAT);
let parsed: Vec<_> = TlvIter::new(&buf).collect::<Result<Vec<_>, _>>().unwrap();
assert_eq!(parsed.len(), 6, "flat bundle has six TLVs");
// 9F02 — Amount, Authorised: 12-digit BCD $1.00 = 000000000100
assert_eq!(parsed[0].tag, Tag::AMOUNT_AUTHORISED);
assert_eq!(parsed[0].value, &[0x00, 0x00, 0x00, 0x00, 0x01, 0x00]);
// 9F03 — Amount, Other: 0
assert_eq!(parsed[1].tag, Tag::AMOUNT_OTHER);
assert_eq!(parsed[1].value, &[0x00; 6]);
// 9F1A — Terminal Country Code: 0840 (USA, ISO 3166-1 numeric)
assert_eq!(parsed[2].tag, Tag::TERMINAL_COUNTRY);
assert_eq!(parsed[2].value, &[0x08, 0x40]);
// 5F2A — Transaction Currency Code: 0840 (USD, ISO 4217 numeric)
assert_eq!(parsed[3].tag, Tag::TXN_CURRENCY);
assert_eq!(parsed[3].value, &[0x08, 0x40]);
// 9A — Transaction Date: 260517 (YYMMDD = 2026-05-17)
assert_eq!(parsed[4].tag, Tag::TXN_DATE);
assert_eq!(parsed[4].value, &[0x26, 0x05, 0x17]);
// 9C — Transaction Type: 00 (purchase)
assert_eq!(parsed[5].tag, Tag::TXN_TYPE);
assert_eq!(parsed[5].value, &[0x00]);
}
#[test]
fn taptopay_flat_offsets_are_contiguous() {
let buf = unhex(TAPTOPAY_FLAT);
let parsed: Vec<_> = TlvIter::new(&buf).collect::<Result<Vec<_>, _>>().unwrap();
// Expected offsets: 0, 8, 16, 20, 24, 29
// 9F02: tag(2) + len(1) + val(6) = 9 bytes; starts at 0
// 9F03: same, 9 bytes; starts at 9? — no, both are 9 bytes -> 0, 9. Let me recount.
// Actually: each multi-byte tag entry: 2 (tag) + 1 (len) + value
// 9F02 + 06 + 6 = 9 bytes -> offset 0
// 9F03 + 06 + 6 = 9 bytes -> offset 9
// 9F1A + 02 + 2 = 5 bytes -> offset 18
// 5F2A + 02 + 2 = 5 bytes -> offset 23
// 9A + 03 + 3 = 5 bytes -> offset 28
// 9C + 01 + 1 = 3 bytes -> offset 33
assert_eq!(parsed[0].offset, 0);
assert_eq!(parsed[1].offset, 9);
assert_eq!(parsed[2].offset, 18);
assert_eq!(parsed[3].offset, 23);
assert_eq!(parsed[4].offset, 28);
assert_eq!(parsed[5].offset, 33);
}
// ---- Tap-to-Pay nested in E1 ----
#[test]
fn taptopay_nested_descends_into_wrapper() {
let buf = unhex(TAPTOPAY_NESTED);
let parsed: Vec<_> = TlvIter::new(&buf).collect::<Result<Vec<_>, _>>().unwrap();
assert_eq!(parsed.len(), 1);
let wrapper = &parsed[0];
assert_eq!(wrapper.tag.0, 0xE1);
assert!(wrapper.tag.is_constructed());
let children: Vec<_> = wrapper
.children()
.unwrap()
.collect::<Result<Vec<_>, _>>()
.unwrap();
assert_eq!(children.len(), 6);
assert_eq!(children[0].tag, Tag::AMOUNT_AUTHORISED);
assert_eq!(children[5].tag, Tag::TXN_TYPE);
}
#[test]
fn taptopay_nested_find_locates_amount() {
let buf = unhex(TAPTOPAY_NESTED);
let tree = Tlv::parse_all(&buf).unwrap();
let amt = tree
.find_tag(Tag::AMOUNT_AUTHORISED)
.expect("amount must be discoverable inside the wrapper");
assert_eq!(
amt.primitive().unwrap(),
&[0x00, 0x00, 0x00, 0x00, 0x01, 0x00]
);
}
// ---- Malformed inputs must not panic ----
#[test]
fn truncated_input_yields_error_not_panic() {
// FCI but cut short.
let buf = unhex(FCI);
let truncated = &buf[..buf.len() / 2];
let result: Result<Vec<_>, _> = TlvIter::new(truncated).collect();
assert!(result.is_err());
}
#[test]
fn random_garbage_yields_error_not_panic() {
let garbage = [0xFF, 0xFF, 0xFF, 0xFF, 0xFF];
let result: Result<Vec<_>, _> = TlvIter::new(&garbage).collect();
// Either errors cleanly or returns nothing — must not panic.
let _ = result;
}