update parsing logic and add tests

This commit is contained in:
ppc
2026-01-09 20:09:44 +00:00
parent 6630496280
commit 59d1a8bd3e
4 changed files with 339 additions and 73 deletions
+199 -23
View File
@@ -2,10 +2,8 @@ use std::{sync::{Mutex, OnceLock}, time::{Duration, Instant}};
use tracing::debug;
use crate::utils::{parse_access_code, parse_idm_hex};
#[derive(Debug, Clone)]
pub (crate) struct StoredCard {
pub struct StoredCard {
pub access_code: [u8; 10],
pub card_idm: Option<[u8; 8]>,
@@ -18,16 +16,14 @@ impl StoredCard {
}
}
pub (crate) enum CardPresentResult {
pub enum CardPresentResult {
Accepted,
Rejected(String /* reason */),
SlotUnavailable(Instant /* release time */),
}
static PRESENTER: OnceLock<CardPresenter> = OnceLock::new();
#[derive(Debug)]
pub (crate) struct CardPresenter {
pub struct CardPresenter {
current_card: Mutex<Option<StoredCard>>,
}
@@ -62,29 +58,16 @@ impl CardPresenter {
card
}
pub fn present_card(&self, access_code: &String, idm_hex: Option<&String>, validity: Duration) -> CardPresentResult {
pub fn present_card(&self, access_code: [u8; 10], idm_hex: Option<[u8; 8]>, validity: Duration) -> CardPresentResult {
let mut guard = self.current_card.lock().unwrap();
if let Some(card) = guard.as_ref() && !card.is_expired() {
return CardPresentResult::SlotUnavailable(card.expiry_time);
}
let access_code_bytes = match parse_access_code(access_code) {
Some(bytes) => bytes,
None => return CardPresentResult::Rejected("Invalid access code format".to_string()),
};
let mut card_idm_bytes = None;
if let Some(idm_hex) = idm_hex {
match parse_idm_hex(idm_hex) {
Some(bytes) => card_idm_bytes = Some(bytes),
None => return CardPresentResult::Rejected("Invalid IDm format".to_string()),
}
}
let stored_card = StoredCard {
access_code: access_code_bytes,
card_idm: card_idm_bytes,
access_code,
card_idm: idm_hex,
expiry_time: Instant::now() + validity,
};
@@ -92,4 +75,197 @@ impl CardPresenter {
CardPresentResult::Accepted
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::utils::{parse_access_code, parse_idm_hex};
mod stored_card_tests {
use super::*;
#[test]
fn is_expired_returns_false_for_future_expiry() {
let card = StoredCard {
access_code: [0; 10],
card_idm: None,
expiry_time: Instant::now() + Duration::from_secs(60),
};
assert!(!card.is_expired());
}
#[test]
fn is_expired_returns_true_for_past_expiry() {
let card = StoredCard {
access_code: [0; 10],
card_idm: None,
expiry_time: Instant::now() - Duration::from_millis(1),
};
assert!(card.is_expired());
}
}
mod card_presenter_tests {
use super::*;
fn create_presenter() -> CardPresenter {
CardPresenter::default()
}
#[test]
fn request_card_returns_none_when_empty() {
let presenter = create_presenter();
assert!(presenter.request_card(None).is_none());
}
#[test]
fn present_card_accepts_valid_access_code() {
let presenter = create_presenter();
let access_code = parse_access_code(&"12345678901234567890".to_string()).unwrap();
match presenter.present_card(access_code, None, Duration::from_secs(5)) {
CardPresentResult::Accepted => {}
_ => panic!("Expected Accepted"),
}
}
#[test]
fn present_card_rejects_invalid_access_code() {
let access_code = parse_access_code(&"INVALID".to_string());
assert!(access_code.is_none(), "Expected parsing to fail for invalid access code");
}
#[test]
fn present_card_accepts_valid_idm() {
let presenter = create_presenter();
let access_code = parse_access_code(&"12345678901234567890".to_string()).unwrap();
let idm = parse_idm_hex(&"0102030405060708".to_string()).unwrap();
match presenter.present_card(access_code, Some(idm), Duration::from_secs(5)) {
CardPresentResult::Accepted => {}
_ => panic!("Expected Accepted"),
}
}
#[test]
fn present_card_rejects_invalid_idm() {
let idm = parse_idm_hex(&"INVALID_IDM".to_string());
assert!(idm.is_none(), "Expected parsing to fail for invalid IDm");
}
#[test]
fn present_card_returns_slot_unavailable_when_card_present() {
let presenter = create_presenter();
let access_code = parse_access_code(&"12345678901234567890".to_string()).unwrap();
// Present first card
presenter.present_card(access_code, None, Duration::from_secs(5));
// Try to present second card
match presenter.present_card(access_code, None, Duration::from_secs(5)) {
CardPresentResult::SlotUnavailable(_) => {}
_ => panic!("Expected SlotUnavailable"),
}
}
#[test]
fn request_card_returns_card_after_present() {
let presenter = create_presenter();
let access_code = parse_access_code(&"00101234567890123456".to_string()).unwrap();
presenter.present_card(access_code, None, Duration::from_secs(5));
let card = presenter.request_card(None);
assert!(card.is_some());
let card = card.unwrap();
assert_eq!(card.access_code, [0x00, 0x10, 0x12, 0x34, 0x56, 0x78, 0x90, 0x12, 0x34, 0x56]);
}
#[test]
fn request_card_consumes_the_card() {
let presenter = create_presenter();
let access_code = parse_access_code(&"12345678901234567890".to_string()).unwrap();
presenter.present_card(access_code, None, Duration::from_secs(5));
// First request should succeed
assert!(presenter.request_card(None).is_some());
// Second request should return None
assert!(presenter.request_card(None).is_none());
}
#[test]
fn request_card_filters_by_idm_requirement_true() {
let presenter = create_presenter();
let access_code = parse_access_code(&"12345678901234567890".to_string()).unwrap();
// Present card without IDm
presenter.present_card(access_code, None, Duration::from_secs(5));
// Request card that requires IDm should return None
assert!(presenter.request_card(Some(true)).is_none());
}
#[test]
fn request_card_filters_by_idm_requirement_false() {
let presenter = create_presenter();
let access_code = parse_access_code(&"12345678901234567890".to_string()).unwrap();
let idm = parse_idm_hex(&"0102030405060708".to_string()).unwrap();
// Present card with IDm
presenter.present_card(access_code, Some(idm), Duration::from_secs(5));
// Request card that requires no IDm should return None
assert!(presenter.request_card(Some(false)).is_none());
}
#[test]
fn request_card_returns_card_with_idm_when_required() {
let presenter = create_presenter();
let access_code = parse_access_code(&"12345678901234567890".to_string()).unwrap();
let idm = parse_idm_hex(&"0102030405060708".to_string()).unwrap();
presenter.present_card(access_code, Some(idm), Duration::from_secs(5));
let card = presenter.request_card(Some(true));
assert!(card.is_some());
assert!(card.unwrap().card_idm.is_some());
}
#[test]
fn expired_card_cannot_be_requested() {
let presenter = create_presenter();
let access_code = parse_access_code(&"12345678901234567890".to_string()).unwrap();
// Present card with very short validity
presenter.present_card(access_code, None, Duration::from_millis(1));
// Wait for expiry
std::thread::sleep(Duration::from_millis(10));
// Card should not be available
assert!(presenter.request_card(None).is_none());
}
#[test]
fn can_present_card_after_previous_expires() {
let presenter = create_presenter();
let access_code = parse_access_code(&"12345678901234567890".to_string()).unwrap();
// Present first card with very short validity
presenter.present_card(access_code, None, Duration::from_millis(1));
// Wait for expiry
std::thread::sleep(Duration::from_millis(10));
// Should be able to present another card
match presenter.present_card(access_code, None, Duration::from_secs(5)) {
CardPresentResult::Accepted => {}
_ => panic!("Expected Accepted after previous card expired"),
}
}
}
}
+9 -14
View File
@@ -67,26 +67,21 @@ pub extern "C" fn aime_io_nfc_poll(unit_no: u8) -> i32 {
}
};
if aime_code.is_empty() || !utils::is_valid_access_code(&aime_code) {
warn!("Access code file is either empty or contains invalid data");
if aime_code.is_empty() {
warn!("Access code file is empty");
return 1;
}
debug!("Access code read from file");
let presenter_result = CardPresenter::instance().present_card(
&aime_code,
None,
std::time::Duration::from_secs(5),
);
let Some(access_code) = utils::parse_access_code(&aime_code) else {
warn!("Invalid access code read from file");
return 1;
};
match presenter_result {
crate::card::CardPresentResult::Accepted => {
match CardPresenter::instance().present_card(access_code, None, std::time::Duration::from_secs(5)) {
card::CardPresentResult::Accepted => {
return 0;
},
crate::card::CardPresentResult::Rejected(reason) => {
warn!("Read access code was rejected: {}", reason);
}
crate::card::CardPresentResult::SlotUnavailable(release_time) => {
card::CardPresentResult::SlotUnavailable(release_time) => {
warn!("Access code was not accepted as there is already a card in the active slot until {:?}", release_time);
}
}
+108 -16
View File
@@ -41,25 +41,117 @@ pub (crate) fn parse_idm_hex(idm_hex: &String) -> Option<[u8; 8]> {
Some(idm_bytes)
}
pub (crate) fn is_valid_access_code(code: &String) -> bool {
if code.len() > 20 {
return false;
}
#[cfg(test)]
mod tests {
use super::*;
let cleaned_code: String = code.chars().filter(|c| !c.is_whitespace()).collect();
mod format_hex_bytes_tests {
use super::*;
if cleaned_code.len() != 20 {
return false;
}
let mut seen_non_zero = false;
let is_all_digits = cleaned_code.chars().all(|c| {
if c != '0' {
seen_non_zero = true;
#[test]
fn formats_empty_bytes() {
assert_eq!(format_hex_bytes(&[]), "");
}
c.is_ascii_digit()
});
#[test]
fn formats_access_code_bytes() {
let bytes = [0x00, 0x10, 0x12, 0x34, 0x56, 0x78, 0x90, 0x12, 0x34, 0x56];
assert_eq!(format_hex_bytes(&bytes), "0010 1234 5678 9012 3456");
}
}
is_all_digits && seen_non_zero
mod parse_access_code_tests {
use super::*;
#[test]
fn parses_full_20_digit_code() {
let code = "00101234567890123456".to_string();
let result = parse_access_code(&code);
assert!(result.is_some());
assert_eq!(result.unwrap(), [0x00, 0x10, 0x12, 0x34, 0x56, 0x78, 0x90, 0x12, 0x34, 0x56]);
}
#[test]
fn parses_code_with_spaces() {
let code = "0010 1234 5678 9012 3456".to_string();
let result = parse_access_code(&code);
assert!(result.is_some());
assert_eq!(result.unwrap(), [0x00, 0x10, 0x12, 0x34, 0x56, 0x78, 0x90, 0x12, 0x34, 0x56]);
}
#[test]
fn parses_code_with_leading_zeros() {
let code = "00000000000000001234".to_string(); // Valid 20-digit code with leading zeros
let result = parse_access_code(&code);
assert!(result.is_some());
assert_eq!(result.unwrap(), [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x12, 0x34]);
}
#[test]
fn returns_none_for_invalid_hex() {
let code = "GHIJKLMNOPQRSTUVWXYZ".to_string();
let result = parse_access_code(&code);
assert!(result.is_none());
}
#[test]
fn parses_code_with_all_zeros() {
let code = "00000000000000000000".to_string();
let result = parse_access_code(&code);
assert!(result.is_some());
assert_eq!(result.unwrap(), [0x00; 10]);
}
}
mod parse_idm_hex_tests {
use super::*;
#[test]
fn parses_full_16_char_idm() {
let idm = "0102030405060708".to_string();
let result = parse_idm_hex(&idm);
assert!(result.is_some());
assert_eq!(result.unwrap(), [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08]);
}
#[test]
fn parses_short_idm_returns_none() {
let idm = "1234".to_string();
let result = parse_idm_hex(&idm);
// IDm is too short - should fail
assert!(result.is_none());
}
#[test]
fn returns_none_for_too_long_idm() {
let idm = "01020304050607080910".to_string(); // 20 chars
let result = parse_idm_hex(&idm);
assert!(result.is_none());
}
#[test]
fn returns_none_for_non_hex_characters() {
let idm = "0102GHIJ".to_string();
let result = parse_idm_hex(&idm);
assert!(result.is_none());
}
#[test]
fn parses_empty_idm_returns_none() {
let idm = "".to_string();
let result = parse_idm_hex(&idm);
// Empty IDm should fail
assert!(result.is_none());
}
}
}
+23 -20
View File
@@ -24,6 +24,14 @@ pub fn build_server() -> Router {
.layer(services)
}
fn build_error_response(status: StatusCode, message: &str) -> Response {
let error = crate::webapi::ServerError {
error: message.to_string(),
};
(status, Json(error)).into_response()
}
async fn server_state_handler() -> Json<crate::webapi::ServerState> {
let config = Config::instance();
let metrics = SystemMetrics::instance();
@@ -49,23 +57,26 @@ async fn card_submission_handler(
return build_error_response(StatusCode::BAD_REQUEST, "Access code is required");
}
if !utils::is_valid_access_code(&payload.access_code) {
let Some(access_code) = utils::parse_access_code(&payload.access_code) else {
return build_error_response(StatusCode::UNPROCESSABLE_ENTITY, "Invalid access code provided");
}
};
if payload.card_idm_hex.as_ref().is_some_and(|idm| idm.chars().any(|c| !c.is_ascii_hexdigit())) {
return build_error_response(StatusCode::UNPROCESSABLE_ENTITY, "Invalid IDm provided");
}
let card_idm_bytes = if let Some(idm_hex) = payload.card_idm_hex.as_ref() {
if idm_hex.len() != 16 {
return build_error_response(StatusCode::BAD_REQUEST, "IDm must be a 16-char hex string");
}
let presenter = CardPresenter::instance().present_card(
&payload.access_code,
payload.card_idm_hex.as_ref(),
Duration::from_secs(5),
);
let Some(idm_bytes) = utils::parse_idm_hex(idm_hex) else {
return build_error_response(StatusCode::UNPROCESSABLE_ENTITY, "Invalid IDm provided");
};
match presenter {
Some(idm_bytes)
} else {
None
};
match CardPresenter::instance().present_card(access_code, card_idm_bytes, Duration::from_secs(5)) {
CardPresentResult::Accepted => StatusCode::ACCEPTED.into_response(),
CardPresentResult::Rejected(message) => build_error_response(StatusCode::FORBIDDEN, format!("Card rejected: {}", message).as_str()),
CardPresentResult::SlotUnavailable(release_time) => {
let now = Instant::now();
let wait_secs = if release_time > now {
@@ -86,11 +97,3 @@ async fn card_submission_handler(
}
}
}
fn build_error_response(status: StatusCode, message: &str) -> Response {
let error = crate::webapi::ServerError {
error: message.to_string(),
};
(status, Json(error)).into_response()
}