1use crate::crypto::{Codec, decode_session_key};
5use crate::error::{Error, ServerErrorKind};
6use crate::operations::{
7 CashInOutRequest, DateTimeRequest, DateTimeResponse, EmptyResponse, FiscalReportRequest,
8 GetReturnableReceiptRequest, HdmTimeSyncRequest, ListOpsAndDepsRequest, ListOpsAndDepsResponse,
9 Operation, OperatorLoginRequest, OperatorLogoutRequest, PaymentSystemsListRequest,
10 PaymentSystemsListResponse, PrintLastReceiptRequest, PrintReceiptRequest,
11 PrintReturnReceiptRequest, ReceiptResponse, ReceiptSampleRequest, ReturnReceiptResponse,
12 ReturnableReceiptResponse, SetupHeaderFooterRequest, SetupHeaderLogoRequest,
13 SingleEmarkRequest,
14};
15use crate::seq::SequenceProvider;
16use crate::wire::{RESPONSE_CODE_OK, Request, ResponseHeader};
17use serde::Serialize;
18use std::io::{Read, Write};
19
20#[derive(Serialize)]
24struct Sequenced<R> {
25 seq: i64,
26 #[serde(flatten)]
27 body: R,
28}
29
30pub struct Client<T: Read + Write, S: SequenceProvider> {
44 transport: T,
45 password_codec: Codec,
46 session_codec: Option<Codec>,
47 seq: S,
48 password: String,
49}
50
51impl<T: Read + Write, S: SequenceProvider> Client<T, S> {
52 pub fn new(transport: T, password: impl Into<String>, seq: S) -> Self {
54 let password = password.into();
55 let password_codec = Codec::from_password(&password);
56 Self {
57 transport,
58 password_codec,
59 session_codec: None,
60 seq,
61 password,
62 }
63 }
64
65 #[must_use]
67 pub const fn is_logged_in(&self) -> bool {
68 self.session_codec.is_some()
69 }
70
71 pub const fn forget_session(&mut self) {
73 self.session_codec = None;
74 }
75
76 pub fn list_operators_and_departments(&mut self) -> Result<ListOpsAndDepsResponse, Error> {
83 let request = ListOpsAndDepsRequest {
84 password: self.password.clone(),
85 };
86 self.execute_with_password(&request)
87 }
88
89 pub fn login(&mut self, cashier: u32, pin: impl Into<String>) -> Result<(), Error> {
98 let request = OperatorLoginRequest {
99 password: self.password.clone(),
100 cashier,
101 pin: pin.into(),
102 };
103 let response = self.execute_with_password(&request)?;
104 let session_key = decode_session_key(&response.key)?;
105 self.session_codec = Some(Codec::from_key(session_key));
106 log::info!("hdm-am: session established for cashier {cashier}");
107 Ok(())
108 }
109
110 pub fn logout(&mut self) -> Result<(), Error> {
115 let _: EmptyResponse = self.execute_with_session(OperatorLogoutRequest {})?;
116 self.session_codec = None;
117 log::info!("hdm-am: session closed");
118 Ok(())
119 }
120
121 pub fn print_receipt(
129 &mut self,
130 request: PrintReceiptRequest,
131 ) -> Result<ReceiptResponse, Error> {
132 self.execute_with_session(request)
133 }
134
135 pub fn print_last_receipt(&mut self) -> Result<EmptyResponse, Error> {
140 self.execute_with_session(PrintLastReceiptRequest {})
141 }
142
143 pub fn get_returnable_receipt(
151 &mut self,
152 receipt_id: impl Into<String>,
153 crn: impl Into<String>,
154 ) -> Result<ReturnableReceiptResponse, Error> {
155 self.execute_with_session(GetReturnableReceiptRequest {
156 receipt_id: receipt_id.into(),
157 crn: crn.into(),
158 })
159 }
160
161 pub fn setup_header_footer(
166 &mut self,
167 request: SetupHeaderFooterRequest,
168 ) -> Result<EmptyResponse, Error> {
169 self.execute_with_session(request)
170 }
171
172 pub fn setup_header_logo(
177 &mut self,
178 logo_base64: impl Into<String>,
179 ) -> Result<EmptyResponse, Error> {
180 self.execute_with_session(SetupHeaderLogoRequest {
181 header_logo: logo_base64.into(),
182 })
183 }
184
185 pub fn fiscal_report(&mut self, request: FiscalReportRequest) -> Result<EmptyResponse, Error> {
190 self.execute_with_session(request)
191 }
192
193 pub fn print_return_receipt(
200 &mut self,
201 request: PrintReturnReceiptRequest,
202 ) -> Result<ReturnReceiptResponse, Error> {
203 self.execute_with_session(request)
204 }
205
206 pub fn cash_in_out(&mut self, request: CashInOutRequest) -> Result<EmptyResponse, Error> {
211 self.execute_with_session(request)
212 }
213
214 pub fn date_time(&mut self) -> Result<DateTimeResponse, Error> {
219 self.execute_with_session(DateTimeRequest {})
220 }
221
222 pub fn receipt_sample(&mut self) -> Result<EmptyResponse, Error> {
227 self.execute_with_session(ReceiptSampleRequest {})
228 }
229
230 pub fn hdm_time_sync(&mut self) -> Result<EmptyResponse, Error> {
235 self.execute_with_session(HdmTimeSyncRequest {})
236 }
237
238 pub fn payment_systems_list(&mut self) -> Result<PaymentSystemsListResponse, Error> {
245 self.execute_with_session(PaymentSystemsListRequest {})
246 }
247
248 pub fn single_emark(&mut self, e_mark: impl Into<String>) -> Result<EmptyResponse, Error> {
253 self.execute_with_session(SingleEmarkRequest {
254 e_mark: e_mark.into(),
255 })
256 }
257
258 fn execute_with_password<R: Operation>(&mut self, request: &R) -> Result<R::Response, Error> {
262 const { assert!(R::USES_PASSWORD_KEY, "expected password-key op") };
263 let codec = self.password_codec.clone();
264 self.round_trip_op::<R>(request, &codec)
265 }
266
267 fn execute_with_session<R: Operation>(&mut self, request: R) -> Result<R::Response, Error> {
273 const { assert!(!R::USES_PASSWORD_KEY, "expected session-key op") };
274 let codec = self
275 .session_codec
276 .as_ref()
277 .ok_or(Error::NotLoggedIn)?
278 .clone();
279 let seq = self.next_seq()?;
280 let body = Sequenced { seq, body: request };
281 let result = self.round_trip_op::<R>(&body, &codec);
282 if let Err(ref err) = result {
283 if err.requires_relogin() {
284 self.session_codec = None;
285 log::debug!("hdm-am: session invalidated after a relogin-class error");
286 }
287 }
288 result
289 }
290
291 fn round_trip_op<R: Operation>(
294 &mut self,
295 body: &impl Serialize,
296 codec: &Codec,
297 ) -> Result<R::Response, Error> {
298 let plaintext = serde_json::to_vec(body).map_err(Error::Encode)?;
299 let ciphertext = codec.encrypt(&plaintext)?;
300 let payload_len = ciphertext.len();
301 u16::try_from(payload_len).map_err(|_| Error::PayloadTooLarge { len: payload_len })?;
302
303 log::debug!(
304 "hdm-am: -> op {:?} ({} plaintext / {} ciphertext bytes)",
305 R::CODE,
306 plaintext.len(),
307 ciphertext.len()
308 );
309
310 let wire = Request {
311 op: R::CODE,
312 payload: ciphertext,
313 };
314 wire.encode(&mut self.transport)?;
315
316 let header = ResponseHeader::read(&mut self.transport)?;
317
318 log::debug!(
319 "hdm-am: <- op {:?} response code {} ({} bytes payload)",
320 R::CODE,
321 header.code,
322 header.payload_len
323 );
324
325 let mut payload = vec![0u8; usize::from(header.payload_len)];
328 if header.payload_len > 0 {
329 self.transport.read_exact(&mut payload)?;
330 }
331
332 if header.code != RESPONSE_CODE_OK {
333 let kind = ServerErrorKind::from_code(header.code);
334 log::warn!(
335 "hdm-am: op {:?} returned server code {} ({:?})",
336 R::CODE,
337 header.code,
338 kind
339 );
340 return Err(Error::Server {
341 code: header.code,
342 kind,
343 });
344 }
345
346 if payload.is_empty() {
348 return serde_json::from_slice(b"{}").map_err(Error::Decode);
349 }
350
351 let plaintext_response = codec.decrypt(&payload)?;
352 if R::RESPONSE_IS_SECRET {
353 log::trace!(
355 "hdm-am: <- op {:?} decrypted payload: [redacted: response carries a secret]",
356 R::CODE
357 );
358 } else {
359 log::trace!(
360 "hdm-am: <- op {:?} decrypted payload: {}",
361 R::CODE,
362 String::from_utf8_lossy(&plaintext_response)
363 );
364 }
365 serde_json::from_slice(&plaintext_response).map_err(Error::Decode)
366 }
367
368 fn next_seq(&mut self) -> Result<i64, Error> {
369 self.seq.next().map_err(Error::Transport)
370 }
371}
372
373#[cfg(test)]
374mod tests {
375 use super::*;
376 use crate::error::CryptoError;
377 use crate::seq::InMemorySeq;
378 use crate::wire::{MAGIC, OperationCode, PROTOCOL_VERSION};
379 use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
380 use rust_decimal::Decimal;
381 use std::io::{self, Cursor};
382
383 struct Loopback {
386 written: Vec<u8>,
387 incoming: Cursor<Vec<u8>>,
388 }
389
390 impl Loopback {
391 fn new(incoming: Vec<u8>) -> Self {
392 Self {
393 written: Vec::new(),
394 incoming: Cursor::new(incoming),
395 }
396 }
397
398 fn no_incoming() -> Self {
399 Self::new(Vec::new())
400 }
401 }
402
403 impl Read for Loopback {
404 fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
405 self.incoming.read(buf)
406 }
407 }
408
409 impl Write for Loopback {
410 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
411 self.written.extend_from_slice(buf);
412 Ok(buf.len())
413 }
414 fn flush(&mut self) -> io::Result<()> {
415 Ok(())
416 }
417 }
418
419 fn make_response(code: u16, codec: &Codec, plaintext: &[u8]) -> Vec<u8> {
421 let ciphertext = codec.encrypt(plaintext).expect("encrypt");
422 let len = u16::try_from(ciphertext.len()).expect("fits in u16");
423 let mut out = Vec::new();
424 out.extend_from_slice(&[0x00, 0x05]); out.extend_from_slice(&[0x02, 0x02, 0x10]); out.extend_from_slice(&code.to_be_bytes()); out.extend_from_slice(&len.to_be_bytes()); out.extend_from_slice(&[0x00, 0x00]); out.extend_from_slice(&ciphertext);
431 out
432 }
433
434 #[test]
436 fn is_logged_in_false_until_login() {
437 let client = Client::new(Loopback::no_incoming(), "pw", InMemorySeq::default());
438 assert!(!client.is_logged_in());
439 }
440
441 #[test]
443 fn session_ops_require_login() {
444 let mut client = Client::new(Loopback::no_incoming(), "pw", InMemorySeq::default());
445 let err = client.logout().expect_err("expected NotLoggedIn");
446 assert!(matches!(err, Error::NotLoggedIn));
447 assert!(err.requires_relogin());
448 }
449
450 #[test]
452 fn login_happy_path_installs_session() {
453 let password = "test-password";
455 let password_codec = Codec::from_password(password);
456 let session_key = [0xAB_u8; 24];
457 let key_b64 = BASE64.encode(session_key);
458 let response_json = format!(r#"{{"key":"{key_b64}"}}"#);
459 let incoming = make_response(200, &password_codec, response_json.as_bytes());
460
461 let mut client = Client::new(Loopback::new(incoming), password, InMemorySeq::default());
462 client.login(42, "1234").expect("login should succeed");
463 assert!(client.is_logged_in());
464 }
465
466 #[test]
468 fn login_writes_correct_wire_framing() {
469 let password = "pw";
470 let password_codec = Codec::from_password(password);
471 let key_b64 = BASE64.encode([0u8; 24]);
472 let response_json = format!(r#"{{"key":"{key_b64}"}}"#);
473 let incoming = make_response(200, &password_codec, response_json.as_bytes());
474
475 let mut client = Client::new(Loopback::new(incoming), password, InMemorySeq::default());
476 client.login(1, "0000").expect("login");
477 let written = &client.transport.written;
478 assert!(written.starts_with(&MAGIC));
479 assert_eq!(&written[6..8], PROTOCOL_VERSION);
480 assert_eq!(written[8], OperationCode::OperatorLogin as u8);
481 assert_eq!(written[9], 0); }
483
484 #[test]
486 fn login_surfaces_bad_password_error() {
487 let password = "wrong";
488 let password_codec = Codec::from_password(password);
489 let incoming = make_response(111, &password_codec, b"{}");
492
493 let mut client = Client::new(Loopback::new(incoming), password, InMemorySeq::default());
494 let err = client.login(1, "0000").expect_err("expected login failure");
495 match err {
496 Error::Server { code, kind } => {
497 assert_eq!(code, 111);
498 assert_eq!(kind, ServerErrorKind::BadOperatorPassword);
499 assert!(kind.requires_relogin());
500 }
501 other => panic!("unexpected variant: {other:?}"),
502 }
503 assert!(!client.is_logged_in());
505 }
506
507 #[test]
509 fn decryption_failure_after_login_surfaces_as_crypto_error() {
510 let password = "pw";
511 let password_codec = Codec::from_password(password);
512
513 let session_key = [0x11_u8; 24];
515 let key_b64 = BASE64.encode(session_key);
516 let login_response = format!(r#"{{"key":"{key_b64}"}}"#);
517 let mut incoming = make_response(200, &password_codec, login_response.as_bytes());
518
519 let wrong_codec = Codec::from_key([0x99; 24]);
521 incoming.extend_from_slice(&make_response(200, &wrong_codec, br#"{"dt":"now"}"#));
522
523 let mut client = Client::new(Loopback::new(incoming), password, InMemorySeq::default());
524 client.login(1, "0000").expect("login");
525
526 let err = client.date_time().expect_err("expected decryption failure");
527 match err {
528 Error::Crypto(CryptoError::BadPadding) => {}
529 other => panic!("expected Crypto(BadPadding), got {other:?}"),
530 }
531 assert!(err.requires_relogin());
532 }
533
534 #[test]
536 fn print_receipt_injects_sequence_number() {
537 use crate::operations::PrintMode;
538
539 let password = "pw";
540 let password_codec = Codec::from_password(password);
541 let key_b64 = BASE64.encode([0; 24]);
542 let login_resp = format!(r#"{{"key":"{key_b64}"}}"#);
543 let session_codec = Codec::from_key([0; 24]);
544
545 let mut wire = make_response(200, &password_codec, login_resp.as_bytes());
546 let receipt_json = r#"{"rseq":1,"crn":"","sn":"","tin":"","taxpayer":"","address":"","time":0,"fiscal":"","lottery":"","prize":0,"total":0.0,"change":0.0}"#;
548 wire.extend_from_slice(&make_response(200, &session_codec, receipt_json.as_bytes()));
549
550 let seq = InMemorySeq::starting_at(99);
551 let mut client = Client::new(Loopback::new(wire), password, seq);
552 client.login(1, "0").unwrap();
553
554 let request = PrintReceiptRequest {
555 mode: PrintMode::Simple,
556 paid_amount: Decimal::from(100),
557 paid_amount_card: Decimal::ZERO,
558 partial_amount: Decimal::ZERO,
559 pre_payment_amount: Decimal::ZERO,
560 dep: Some(1),
561 partner_tin: None,
562 use_ext_pos: false,
563 payment_system: None,
564 rrn: None,
565 terminal_id: None,
566 e_marks: vec![],
567 items: vec![],
568 };
569 let response = client.print_receipt(request).expect("print receipt");
570 assert_eq!(response.rseq, 1);
571
572 let written = &client.transport.written;
576 let mut off = 0;
577 let mut last_payload: &[u8] = &[];
578 while off + 12 <= written.len() {
579 let len = usize::from(u16::from_be_bytes([written[off + 10], written[off + 11]]));
580 last_payload = &written[off + 12..off + 12 + len];
581 off += 12 + len;
582 }
583 let decrypted = session_codec
584 .decrypt(last_payload)
585 .expect("decrypt request");
586 let json: serde_json::Value = serde_json::from_slice(&decrypted).expect("valid JSON");
587 assert_eq!(json["seq"], 100, "client must inject the sequence number");
588 assert_eq!(json["mode"], 1, "request body is flattened alongside seq");
589 }
590
591 #[test]
593 fn error_response_drains_payload_to_keep_transport_in_sync() {
594 let password = "pw";
595 let password_codec = Codec::from_password(password);
596
597 let mut incoming = make_response(151, &password_codec, b"some-irrelevant-error-body");
601 let key_b64 = BASE64.encode([0u8; 24]);
602 let login_response = format!(r#"{{"key":"{key_b64}"}}"#);
603 incoming.extend_from_slice(&make_response(
604 200,
605 &password_codec,
606 login_response.as_bytes(),
607 ));
608
609 let mut client = Client::new(Loopback::new(incoming), password, InMemorySeq::default());
610
611 let err = client
613 .list_operators_and_departments()
614 .expect_err("expected error");
615 assert!(matches!(
616 err,
617 Error::Server {
618 kind: ServerErrorKind::NoSuchDepartment,
619 ..
620 }
621 ));
622
623 client.login(1, "0000").expect("second call should align");
625 assert!(client.is_logged_in());
626 }
627}