Cleaned up the protocol layer and metadata
Check, format and test / build (push) Failing after 5s

- request() now reads exactly the expected response length with
  read_exact instead of a single non-guaranteed fill_buf, fixing a
  latent bug where a response split across multiple TCP packets could
  be mistaken for malformed data; the per-method length checks are now
  redundant and were removed
- sequence_number wraps explicitly via wrapping_add instead of
  relying on implicit (and debug-vs-release inconsistent) overflow
  behavior after 65536 requests
- get_name validates UTF-8 strictly instead of silently lossy-decoding
  invalid bytes; on invalid data it now returns the FromUtf8Error
  itself (byte-level \0 trimming moved after the UTF-8 check), letting
  the caller choose to fail or fall back to a lossy rendering
- removed the unused Default derive from Clock and Record
- removed npm from the CI image (no JS/Node in this repo), fixed a
  typo in a CI step name
- fixed the Cargo.toml license field to the actual SPDX identifier
  (AGPL-3.0-or-later, per the notice at the end of LICENSE) instead of
  the free-text "GNU AGPLv3.0"

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 14:43:50 +02:00
co-authored by Claude Sonnet 5
parent 9af4a82e39
commit e0e453c9c5
5 changed files with 41 additions and 51 deletions
+2 -2
View File
@@ -11,7 +11,7 @@ jobs:
run: | run: |
rustup component add clippy rustfmt && rustup component add clippy rustfmt &&
apk update && apk update &&
apk add musl-dev git npm apk add musl-dev git
- name: Checkout the code - name: Checkout the code
uses: actions/checkout@v3 uses: actions/checkout@v3
@@ -27,5 +27,5 @@ jobs:
- name: Check if the code is formatted correctly - name: Check if the code is formatted correctly
run: cargo fmt --check run: cargo fmt --check
- name: Check if Clippy has someting to say - name: Check if Clippy has something to say
run: cargo clippy --all-targets run: cargo clippy --all-targets
+1 -1
View File
@@ -2,7 +2,7 @@
name = "r701" name = "r701"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
license = "GNU AGPLv3.0" license = "AGPL-3.0-or-later"
repository = "https://git.nicolabelluti.me/nicolabelluti/r701" repository = "https://git.nicolabelluti.me/nicolabelluti/r701"
[dependencies] [dependencies]
+5 -5
View File
@@ -45,11 +45,11 @@ If you want to read about this reverse engineering attempt, check out
println!("No\tMchn\tEnNo\t\tName\t\tMode\tIOMd\tDateTime\t"); println!("No\tMchn\tEnNo\t\tName\t\tMode\tIOMd\tDateTime\t");
while let Some(record) = record_stream.next().await { while let Some(record) = record_stream.next().await {
let record = record.unwrap(); let record = record.unwrap();
let name = r701 let name = match r701.get_name(record.employee_id).await.unwrap() {
.get_name(record.employee_id) Some(Ok(name)) => name,
.await Some(Err(error)) => String::from_utf8_lossy(error.as_bytes()).into_owned(),
.unwrap() None => format!("user #{}", record.employee_id),
.unwrap_or(format!("user #{}", record.employee_id)); };
println!( println!(
"{:0>6}\t{}\t{:0>9}\t{: <10}\t{}\t{}\t{}", "{:0>6}\t{}\t{:0>9}\t{: <10}\t{}\t{}\t{}",
+31 -34
View File
@@ -1,6 +1,6 @@
use crate::RecordIterator; use crate::RecordIterator;
use std::io::{Error, ErrorKind::InvalidData, Result}; use std::io::{Error, ErrorKind::InvalidData, Result};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpStream, ToSocketAddrs}; use tokio::net::{TcpStream, ToSocketAddrs};
use tokio::sync::Mutex; use tokio::sync::Mutex;
@@ -35,7 +35,7 @@ impl R701 {
// The lock must stay held for the whole write+read round-trip, so that // The lock must stay held for the whole write+read round-trip, so that
// concurrent callers can't interleave their requests on the wire // concurrent callers can't interleave their requests on the wire
#[allow(clippy::significant_drop_tightening)] #[allow(clippy::significant_drop_tightening)]
pub async fn request(&self, payload: &[u8; 12]) -> Result<Vec<u8>> { pub async fn request(&self, payload: &[u8; 12], response_len: usize) -> Result<Vec<u8>> {
let mut state = self.state.lock().await; let mut state = self.state.lock().await;
// Create a blank request // Create a blank request
@@ -46,14 +46,16 @@ impl R701 {
// Insert the sequence number // Insert the sequence number
request[14..].clone_from_slice(&state.sequence_number.to_le_bytes()); request[14..].clone_from_slice(&state.sequence_number.to_le_bytes());
state.sequence_number += 1; state.sequence_number = state.sequence_number.wrapping_add(1);
// Send the request // Send the request
state.tcp_stream.write_all(&request).await?; state.tcp_stream.write_all(&request).await?;
// Create a buffer and return the response // Read exactly response_len bytes, looping over multiple reads if
let mut buffer = BufReader::new(&mut state.tcp_stream); // the response arrives split across several TCP packets
Ok(buffer.fill_buf().await?.to_vec()) let mut buffer = vec![0u8; response_len];
state.tcp_stream.read_exact(&mut buffer).await?;
Ok(buffer)
} }
#[allow(clippy::missing_errors_doc)] #[allow(clippy::missing_errors_doc)]
@@ -61,7 +63,7 @@ impl R701 {
// Create a request with a payload of `01 80 00 00 00 00 00 00 00 00 00 // Create a request with a payload of `01 80 00 00 00 00 00 00 00 00 00
// 00` and get the response // 00` and get the response
let response = self let response = self
.request(&[0x01, 0x80, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]) .request(&[0x01, 0x80, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 10)
.await?; .await?;
// If the response is not `aa 55 01 01 00 00 00 00 00 00` then return an // If the response is not `aa 55 01 01 00 00 00 00 00 00` then return an
@@ -74,39 +76,38 @@ impl R701 {
} }
#[allow(clippy::missing_errors_doc)] #[allow(clippy::missing_errors_doc)]
pub async fn get_name(&self, id: u32) -> Result<Option<String>> { pub async fn get_name(
&self,
id: u32,
) -> Result<Option<std::result::Result<String, std::string::FromUtf8Error>>> {
// Create a request with a payload of `01 c7 xx xx xx xx 00 00 00 00 14 // Create a request with a payload of `01 c7 xx xx xx xx 00 00 00 00 14
// 00`, where `xx xx xx xx` is the employee ID in little endian // 00`, where `xx xx xx xx` is the employee ID in little endian
let mut request = [0x01, 0xc7, 0, 0, 0, 0, 0, 0, 0, 0, 0x14, 0]; let mut request = [0x01, 0xc7, 0, 0, 0, 0, 0, 0, 0, 0, 0x14, 0];
request[2..6].clone_from_slice(&id.to_le_bytes()); request[2..6].clone_from_slice(&id.to_le_bytes());
// Get the response // Get the response
let response = self.request(&request).await?; let response = self.request(&request, 34).await?;
// If the response length is right but the header is `01 00 00 00 00 00 // If the header is `01 00 00 00 00 00 00 00` then the request has
// 00 00` then the request is been succesful but the name was not found // been succesful but the name was not found
if response.len() == 34 if response[..12] == [0xaa, 0x55, 0x01, 0, 0, 0, 0, 0, 0, 0, 0x55, 0xaa] {
&& response[..12] == [0xaa, 0x55, 0x01, 0, 0, 0, 0, 0, 0, 0, 0x55, 0xaa]
{
return Ok(None); return Ok(None);
} }
// If one between the response length or the response header is wrong // If the response header is wrong return an error
// return an error if response[..12] != [0xaa, 0x55, 0x01, 0x01, 0, 0, 0, 0, 0, 0, 0x55, 0xaa] {
if response.len() != 34
|| response[..12] != [0xaa, 0x55, 0x01, 0x01, 0, 0, 0, 0, 0, 0, 0x55, 0xaa]
{
return Err(Error::new(InvalidData, "Malformed response")); return Err(Error::new(InvalidData, "Malformed response"));
} }
// Get the name as a UTF-8 string and delete the `\0` at the end // Get the name as a UTF-8 string and delete the `\0` at the end
let name = String::from_utf8_lossy(&response[12..22]) let name = match String::from_utf8(response[12..22].to_vec()) {
.trim_end_matches('\0') Ok(name) => name.trim_end_matches('\0').to_string(),
.to_string(); Err(error) => return Ok(Some(Err(error))),
};
// Return None if the name is empty, else return the name wrapped into a // Return None if the name is empty, else return the name wrapped into a
// Some // Some
Ok(Some(name).filter(|name| !name.is_empty())) Ok(Some(name).filter(|name| !name.is_empty()).map(Ok))
} }
#[allow(clippy::missing_errors_doc)] #[allow(clippy::missing_errors_doc)]
@@ -114,15 +115,12 @@ impl R701 {
// Create a request with a payload of `01 b4 08 00 00 00 00 00 ff ff 00 // Create a request with a payload of `01 b4 08 00 00 00 00 00 ff ff 00
// 00` and get the response // 00` and get the response
let response = self let response = self
.request(&[0x01, 0xb4, 0x08, 0, 0, 0, 0, 0, 0xff, 0xff, 0, 0]) .request(&[0x01, 0xb4, 0x08, 0, 0, 0, 0, 0, 0xff, 0xff, 0, 0], 10)
.await?; .await?;
// If one between the response length or the response header is wrong // If one between the response header or the trailing bytes is wrong
// return an error // return an error
if response.len() != 10 if response[..4] != [0xaa, 0x55, 0x01, 0x01] || response[6..] != [0u8; 4] {
|| response[..4] != [0xaa, 0x55, 0x01, 0x01]
|| response[6..] != [0u8; 4]
{
return Err(Error::new(InvalidData, "Malformed response")); return Err(Error::new(InvalidData, "Malformed response"));
} }
@@ -155,12 +153,11 @@ impl R701 {
} }
// Get the response // Get the response
let response = self.request(&request).await?; let response = self.request(&request, 1038).await?;
// If one between the response length, the response header or the last // If one between the response header or the last two bits is wrong
// two bits is wrong return an error // return an error
if response.len() != 1038 if response[..12] != [0xaa, 0x55, 0x01, 0x01, 0, 0, 0, 0, 0, 0, 0x55, 0xaa]
|| response[..12] != [0xaa, 0x55, 0x01, 0x01, 0, 0, 0, 0, 0, 0, 0x55, 0xaa]
|| response[1036..] != [0, 0] || response[1036..] != [0, 0]
{ {
return Err(Error::new(InvalidData, "Malformed response")); return Err(Error::new(InvalidData, "Malformed response"));
+2 -9
View File
@@ -2,20 +2,16 @@ use chrono::{DateTime, Local, TimeZone};
#[cfg(feature = "serde")] #[cfg(feature = "serde")]
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] #[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Hash)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub enum Clock { pub enum Clock {
#[default]
FirstIn, FirstIn,
FirstOut, FirstOut,
SecondIn, SecondIn,
SecondOut, SecondOut,
} }
// unsafe impl Send for Clock {} #[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Hash)]
// unsafe impl Sync for Clock {}
#[derive(Debug, Clone, Copy, Default, Eq, PartialEq, Ord, PartialOrd, Hash)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct Record { pub struct Record {
pub employee_id: u32, pub employee_id: u32,
@@ -23,9 +19,6 @@ pub struct Record {
pub datetime: DateTime<Local>, pub datetime: DateTime<Local>,
} }
// unsafe impl Send for Record {}
// unsafe impl Sync for Record {}
impl TryFrom<&[u8]> for Record { impl TryFrom<&[u8]> for Record {
type Error = &'static str; type Error = &'static str;