From 37cdba48988ccacdbd13ba6417678896d064fd31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D0=BE=D0=B1=D0=B5=D0=BB=D0=B5=D0=B2=20=D0=90=D0=BD?= =?UTF-8?q?=D0=B4=D1=80=D0=B5=D0=B9=20=D0=90=D0=BD=D0=B4=D1=80=D0=B5=D0=B5?= =?UTF-8?q?=D0=B2=D0=B8=D1=87?= Date: Thu, 20 Aug 2026 19:25:08 +0500 Subject: [PATCH] release: escpos-vfd 0.3.0 --- CHANGELOG.md | 18 +++++ Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 18 +++-- examples/clock.rs | 2 +- src/codec.rs | 119 +++++++++++++++++++++++++--------- src/config.rs | 9 +++ src/error.rs | 28 ++++++++ src/lib.rs | 5 +- src/tokio.rs | 111 +++++++++++++++++++++++++++---- src/vfd.rs | 24 ++++--- src/worker.rs | 102 ++++++++++++++++++++++++++--- taskfile.yml | 4 +- tests/security_regressions.rs | 97 +++++++++++++++++++++++++++ 14 files changed, 472 insertions(+), 69 deletions(-) create mode 100644 tests/security_regressions.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 6dc24b1..ddb4c58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,24 @@ Все заметные изменения проекта фиксируются в этом файле. +## [0.3.0] - 2026-08-20 + +### Changed + +- BREAKING: `EpsonCodec::new` теперь валидирует `DisplaySettings` и возвращает + `Result`. +- Текстовые API нейтрализуют управляющие символы; произвольные ESC/POS-команды + отправляются только через `write_raw`. +- Worker ограничивает queued raw payload, длину marquee и максимальную скорость marquee + публичными константами `MAX_QUEUED_RAW_BYTES`, `MAX_MARQUEE_CHARS` и + `MAX_MARQUEE_CPS`. + +### Fixed + +- Неподдерживаемые символы CP866/Windows-1251 заменяются одним байтом `?`, не нарушая + ширину строки числовыми character references. +- Некорректная геометрия codec-а возвращает `ConfigError` вместо позднего panic. + ## [0.2.0] - 2026-08-16 ### Added diff --git a/Cargo.lock b/Cargo.lock index 24a2b4b..ace3204 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -59,7 +59,7 @@ dependencies = [ [[package]] name = "escpos-vfd" -version = "0.2.0" +version = "0.3.0" dependencies = [ "encoding_rs", "serialport", diff --git a/Cargo.toml b/Cargo.toml index 1b8fc27..54e9a6d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "escpos-vfd" -version = "0.2.0" +version = "0.3.0" edition = "2024" rust-version = "1.85" description = "ESC/POS-compatible VFD customer display driver with sync and optional Tokio APIs" diff --git a/README.md b/README.md index 482f9eb..315f6dd 100644 --- a/README.md +++ b/README.md @@ -13,14 +13,14 @@ ```toml [dependencies] -escpos-vfd = "0.2" +escpos-vfd = "0.3" ``` Tokio API подключается отдельным feature: ```toml [dependencies] -escpos-vfd = { version = "0.2", features = ["tokio"] } +escpos-vfd = { version = "0.3", features = ["tokio"] } ``` Без feature `tokio` async-зависимости не подключаются. @@ -160,6 +160,11 @@ vfd.print_at(6, 2, "текст")?; `print_at()` пишет с указанной позиции и обрезает текст по правому краю. Автоматического переноса на следующую строку нет. +Текстовые методы заменяют управляющие символы, включая `NUL`, `ESC`, `US`, `CR`, `LF` +и form feed, пробелами. Для намеренной отправки команд используется только `write_raw()`. +В CP866 и Windows-1251 каждый неподдерживаемый Unicode-символ заменяется одним `?`, +поэтому кодирование не нарушает настроенную ширину строки. + Неверные координаты возвращают `VfdError::InvalidLine` или `VfdError::InvalidCoordinate`. @@ -213,7 +218,9 @@ display.start_marquee(1, 8, Duration::from_millis(1500))?; display.stop_marquee()?; ``` -`cps` задаёт скорость в символах в секунду, `end_pause` — паузу после полного прохода. +`cps` задаёт скорость в символах в секунду и ограничен значением +`MAX_MARQUEE_CPS`; `end_pause` — паузу после полного прохода. Текст ограничен +`MAX_MARQUEE_CHARS` символами. `stop_marquee()` останавливает анимацию, но не очищает уже отображённый текст. ## Tokio @@ -260,10 +267,13 @@ vfd.write_raw(&[0x1b, 0x40])?; `write_raw()` не кодирует и не интерпретирует данные. В worker API такая запись также не обновляет строковый кэш, поэтому после raw-команд, меняющих текст на экране, лучше -выполнить `clear()` или `print_line()`. +выполнить `clear()` или `print_line()`. Размер одной queued raw-команды ограничен +`MAX_QUEUED_RAW_BYTES`; низкоуровневые `Vfd::write_raw()` и `AsyncVfd::write_raw()` +пишут предоставленный slice напрямую без промежуточной очереди. Для нестандартных транспортов и диагностики доступен публичный `escpos_vfd::codec::EpsonCodec`, который формирует байты команд без открытия serial-порта. +`EpsonCodec::new(display)` валидирует геометрию и возвращает `Result`. ## Ошибки diff --git a/examples/clock.rs b/examples/clock.rs index a848386..8ca699f 100644 --- a/examples/clock.rs +++ b/examples/clock.rs @@ -90,7 +90,7 @@ fn main() -> Result<(), Box> { // 2) width - ширина строки, нужна для подгонки текста; // 3) brightness - яркость из диапазона пресета 1..=4. let mut args = common::ExampleArgs::from_env()?; - let brightness: u8 = args.parse_or(2); + let brightness: u8 = args.parse_or(4); let columns = args.columns; // Worker остаётся жить до конца процесса. Handle можно клонировать, но сам worker diff --git a/src/codec.rs b/src/codec.rs index 125edb4..bad77bd 100644 --- a/src/codec.rs +++ b/src/codec.rs @@ -6,8 +6,8 @@ //! остаётся публичным для диагностики и интеграции с собственным транспортом. use crate::config::{DisplaySettings, TextEncoding}; -use crate::error::{Result, VfdError}; -use encoding_rs::{IBM866, WINDOWS_1251}; +use crate::error::{ConfigError, Result, VfdError}; +use encoding_rs::{Encoding, IBM866, WINDOWS_1251}; /// Кодировщик Epson/ESC/POS-команд для выбранной геометрии и таблицы символов. /// @@ -24,10 +24,14 @@ impl EpsonCodec { /// Значение `display.code_table` влияет только на байты инициализации `ESC t n`; /// кодировка текста берётся из `display.encoding`. /// - /// Метод не валидирует настройки. Если codec создаётся не через [`crate::Vfd`], - /// вызовите [`DisplaySettings::validate`] самостоятельно. - pub fn new(display: DisplaySettings) -> Self { - Self { display } + /// Метод валидирует геометрию и диапазоны до сохранения настроек. + /// + /// # Ошибки + /// + /// Возвращает [`ConfigError`], если настройки дисплея некорректны. + pub fn new(display: DisplaySettings) -> std::result::Result { + display.validate()?; + Ok(Self { display }) } /// Настройки дисплея, для которых работает codec. @@ -95,18 +99,19 @@ impl EpsonCodec { /// Кодирует текст в настроенной кодировке. /// - /// Для `Ascii` символы вне ASCII заменяются на `?`. Для CP866 и Windows-1251 - /// используется `encoding_rs`, поэтому неподдерживаемые символы проходят стандартную - /// замену этой библиотеки. + /// Для `Ascii`, CP866 и Windows-1251 неподдерживаемый символ заменяется ровно одним + /// байтом `?`. Управляющие символы заменяются пробелами во всех кодировках, поэтому + /// произвольные команды следует отправлять только через `write_raw` API. pub fn encode_text(&self, text: &str) -> Vec { match self.display.encoding { - TextEncoding::Cp866 => IBM866.encode(text).0.into_owned(), - TextEncoding::Windows1251 => WINDOWS_1251.encode(text).0.into_owned(), + TextEncoding::Cp866 => encode_single_byte(IBM866, text), + TextEncoding::Windows1251 => encode_single_byte(WINDOWS_1251, text), TextEncoding::Ascii => text .chars() + .map(sanitize_char) .map(|ch| if ch.is_ascii() { ch as u8 } else { b'?' }) .collect(), - TextEncoding::Utf8 => text.as_bytes().to_vec(), + TextEncoding::Utf8 => sanitize_text(text).into_bytes(), } } @@ -115,7 +120,8 @@ impl EpsonCodec { /// Метод сначала применяет [`sanitize_text`], затем обрезает по числу символов и /// дополняет пробелами до `display.columns`. pub fn fit_line(&self, text: &str) -> String { - fit_to_width(&sanitize_text(text), self.display.columns) + let text = sanitize_to_width(text, self.display.columns); + fit_to_width(&text, self.display.columns) } /// Обрезает текст по правому краю от координаты `x`. @@ -129,7 +135,7 @@ impl EpsonCodec { pub fn clip_from(&self, x: u8, text: &str) -> Result { self.validate_xy(x, 1)?; let remaining = self.display.columns - usize::from(x) + 1; - Ok(truncate_chars(&sanitize_text(text), remaining).to_string()) + Ok(sanitize_to_width(text, remaining)) } /// Проверяет координаты относительно геометрии. @@ -188,20 +194,43 @@ pub fn truncate_chars(s: &str, max_chars: usize) -> &str { /// Заменяет типографские символы на безопасные аналоги для однобайтовых таблиц. /// -/// Функция намеренно не выбирает кодировку. Она только убирает символы вроде длинного -/// тире, табуляции и `№`, которые часто плохо представлены на VFD-дисплеях. +/// Функция намеренно не выбирает кодировку. Она заменяет управляющие символы пробелами и +/// нормализует символы вроде длинного тире и `№`, плохо представленные на VFD-дисплеях. pub fn sanitize_text(s: &str) -> String { - s.chars() - .map(|c| match c { - '…' => '.', - '—' | '–' => '-', - '№' => '#', - '\t' => ' ', - '“' | '”' => '"', - '‘' | '’' => '\'', - _ => c, - }) - .collect() + s.chars().map(sanitize_char).collect() +} + +pub(crate) fn sanitize_to_width(s: &str, width: usize) -> String { + s.chars().take(width).map(sanitize_char).collect() +} + +fn sanitize_char(c: char) -> char { + if c.is_control() { + return ' '; + } + match c { + '…' => '.', + '—' | '–' => '-', + '№' => '#', + '“' | '”' => '"', + '‘' | '’' => '\'', + _ => c, + } +} + +fn encode_single_byte(encoding: &'static Encoding, text: &str) -> Vec { + let mut out = Vec::with_capacity(text.chars().count()); + let mut utf8 = [0; 4]; + for ch in text.chars().map(sanitize_char) { + let encoded_char = ch.encode_utf8(&mut utf8); + let (encoded, _, had_errors) = encoding.encode(encoded_char); + if had_errors || encoded.len() != 1 { + out.push(b'?'); + } else { + out.push(encoded[0]); + } + } + out } /// Совместимый алиас для старого helper. @@ -281,7 +310,7 @@ mod tests { #[test] fn preset_init_matches_legacy_reset_and_cp866_table() { let cfg = VfdConfig::preset("test", Preset::Epson20x2Cp866).unwrap(); - let codec = EpsonCodec::new(cfg.display); + let codec = EpsonCodec::new(cfg.display).unwrap(); assert_eq!(codec.init(), vec![0x1B, 0x40, 0x1B, 0x74, 6]); } @@ -289,14 +318,14 @@ mod tests { #[test] fn optional_code_table_can_be_omitted() { let display = DisplaySettings::new(20, 2, TextEncoding::Cp866); - let codec = EpsonCodec::new(display); + let codec = EpsonCodec::new(display).unwrap(); assert_eq!(codec.init(), vec![0x1B, 0x40]); } #[test] fn validates_geometry_instead_of_ignoring_invalid_coordinates() { - let codec = EpsonCodec::new(DisplaySettings::new(20, 4, TextEncoding::Cp866)); + let codec = EpsonCodec::new(DisplaySettings::new(20, 4, TextEncoding::Cp866)).unwrap(); assert!(codec.goto_xy(1, 4).is_ok()); assert!(matches!( @@ -340,4 +369,34 @@ mod tests { ); assert!(changed_runs("без перемен", "без перемен").is_empty()); } + + #[test] + fn text_sanitization_neutralizes_protocol_controls() { + assert_eq!(sanitize_text("A\0\u{1b}\u{1f}\r\nB"), "A B"); + for encoding in [ + TextEncoding::Cp866, + TextEncoding::Windows1251, + TextEncoding::Ascii, + TextEncoding::Utf8, + ] { + let codec = EpsonCodec::new(DisplaySettings::new(8, 1, encoding)).unwrap(); + assert_eq!(codec.encode_text("\0\u{1b}\u{1f}\r\n"), b" "); + } + } + + #[test] + fn legacy_encodings_use_one_byte_for_unmappable_characters() { + for encoding in [TextEncoding::Cp866, TextEncoding::Windows1251] { + let codec = EpsonCodec::new(DisplaySettings::new(1, 1, encoding)).unwrap(); + assert_eq!(codec.encode_text("😀"), vec![b'?']); + } + } + + #[test] + fn codec_constructor_rejects_invalid_geometry() { + assert!(matches!( + EpsonCodec::new(DisplaySettings::new(usize::MAX, 1, TextEncoding::Ascii)), + Err(ConfigError::InvalidColumns(usize::MAX)) + )); + } } diff --git a/src/config.rs b/src/config.rs index 1495902..b4c6c76 100644 --- a/src/config.rs +++ b/src/config.rs @@ -13,6 +13,15 @@ use crate::error::ConfigError; use serialport::{DataBits, FlowControl, Parity, StopBits}; use std::time::Duration; +/// Максимальное число символов в тексте бегущей строки worker-а. +pub const MAX_MARQUEE_CHARS: usize = 4_096; + +/// Максимальный размер одной raw-команды, помещаемой в очередь worker-а. +pub const MAX_QUEUED_RAW_BYTES: usize = 64 * 1024; + +/// Максимальная скорость бегущей строки в кадрах/символах в секунду. +pub const MAX_MARQUEE_CPS: u32 = 100; + /// Преднастроенные профили известных ESC/POS-совместимых дисплеев. /// /// Пресеты нужны для сохранения проверенных наборов настроек, но не ограничивают ручную diff --git a/src/error.rs b/src/error.rs index 58eaf58..7716c98 100644 --- a/src/error.rs +++ b/src/error.rs @@ -73,6 +73,25 @@ pub enum VfdError { /// Максимальный поддерживаемый уровень. max: u8, }, + /// Текст бегущей строки превышает безопасный предел очереди worker-а. + TextTooLong { + /// Максимально допустимое число символов. + max: usize, + }, + /// Raw-команда превышает безопасный предел очереди worker-а. + RawPayloadTooLarge { + /// Фактический размер команды в байтах. + length: usize, + /// Максимально допустимый размер в байтах. + max: usize, + }, + /// Скорость бегущей строки превышает поддерживаемый предел. + InvalidMarqueeSpeed { + /// Запрошенная скорость в символах/кадрах в секунду. + cps: u32, + /// Максимально допустимая скорость. + max: u32, + }, /// Очередь фонового worker закрыта. QueueClosed, /// Worker остановлен до подтверждения команды. @@ -130,6 +149,15 @@ impl std::fmt::Display for VfdError { "brightness {level} is outside supported range {min}..={max}" ) } + Self::TextTooLong { max } => { + write!(f, "marquee text exceeds the limit of {max} characters") + } + Self::RawPayloadTooLarge { length, max } => { + write!(f, "raw payload is {length} bytes, maximum is {max}") + } + Self::InvalidMarqueeSpeed { cps, max } => { + write!(f, "marquee speed {cps} exceeds maximum {max}") + } Self::QueueClosed => f.write_str("VFD worker queue is closed"), Self::WorkerStopped => f.write_str("VFD worker stopped before acknowledging command"), Self::WorkerPanicked => f.write_str("VFD worker thread panicked"), diff --git a/src/lib.rs b/src/lib.rs index a3be0d7..fd43eb2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -46,7 +46,10 @@ pub mod worker; pub mod tokio; pub use codec::{fit_to_width, sanitize_for_cp866, sanitize_text}; -pub use config::{DisplaySettings, Preset, SerialSettings, TextEncoding, VfdConfig}; +pub use config::{ + DisplaySettings, MAX_MARQUEE_CHARS, MAX_MARQUEE_CPS, MAX_QUEUED_RAW_BYTES, Preset, + SerialSettings, TextEncoding, VfdConfig, +}; pub use error::{ConfigError, Result, VfdError}; pub use vfd::Vfd; pub use worker::{VfdHandle, VfdWorker}; diff --git a/src/tokio.rs b/src/tokio.rs index 2e30082..4d94ef7 100644 --- a/src/tokio.rs +++ b/src/tokio.rs @@ -6,8 +6,12 @@ //! `AsyncWrite`, поэтому тесты и нестандартные транспорты не требуют настоящего //! serial-порта. -use crate::codec::{EpsonCodec, changed_runs, fit_to_width, replace_cached_range, sanitize_text}; -use crate::config::{DisplaySettings, VfdConfig}; +use crate::codec::{ + EpsonCodec, changed_runs, fit_to_width, replace_cached_range, sanitize_text, sanitize_to_width, +}; +use crate::config::{ + DisplaySettings, MAX_MARQUEE_CHARS, MAX_MARQUEE_CPS, MAX_QUEUED_RAW_BYTES, VfdConfig, +}; use crate::error::{ConfigError, Result, VfdError}; use ::tokio::io::{AsyncWrite, AsyncWriteExt}; use ::tokio::sync::{mpsc, oneshot}; @@ -133,7 +137,7 @@ impl AsyncVfd { /// ``` pub async fn from_transport(mut transport: T, display: DisplaySettings) -> Result { display.validate()?; - let codec = EpsonCodec::new(display); + let codec = EpsonCodec::new(display)?; let init = codec.init(); if !init.is_empty() { transport.write_all(&init).await?; @@ -182,7 +186,8 @@ impl AsyncVfd { /// Выводит подготовленный кадр. /// - /// Метод не выполняет санацию текста. Используйте его для уже подготовленных кадров. + /// Метод не выполняет типографскую нормализацию до подгонки ширины. Управляющие + /// символы всё равно нейтрализуются на этапе кодирования. /// /// # Ошибки /// @@ -203,8 +208,7 @@ impl AsyncVfd { /// /// Возвращает [`VfdError::InvalidCoordinate`] или [`VfdError::Io`]. pub async fn print_at(&mut self, x: u8, y: u8, text: &str) -> Result<()> { - let text = sanitize_text(text); - self.print_at_prepared(x, y, &text).await + self.print_at_prepared(x, y, text).await } async fn print_at_prepared(&mut self, x: u8, y: u8, text: &str) -> Result<()> { @@ -282,6 +286,7 @@ impl AsyncVfd { #[derive(Clone)] pub struct AsyncVfdHandle { tx: mpsc::Sender, + columns: usize, } impl AsyncVfdHandle { @@ -312,6 +317,7 @@ impl AsyncVfdHandle { /// Возвращает [`VfdError::InvalidLine`], ошибки очереди или I/O. pub async fn print_line(&self, line: u8, text: impl Into) -> Result<()> { let text = text.into(); + let text = prepare_text_for_queue(&text, self.columns); self.call(|ack| Cmd::PrintLine { line, text, ack }).await } @@ -325,6 +331,7 @@ impl AsyncVfdHandle { /// Возвращает [`VfdError::InvalidLine`], ошибки очереди или I/O. pub async fn print_line_diff(&self, line: u8, text: impl Into) -> Result<()> { let text = text.into(); + let text = prepare_text_for_queue(&text, self.columns); self.call(|ack| Cmd::PrintLineDiff { line, text, ack }) .await } @@ -336,43 +343,61 @@ impl AsyncVfdHandle { /// Возвращает [`VfdError::InvalidCoordinate`], ошибки очереди или I/O. pub async fn print_at(&self, x: u8, y: u8, text: impl Into) -> Result<()> { let text = text.into(); + let remaining = if x == 0 { + 0 + } else { + self.columns + .checked_sub(usize::from(x)) + .map_or(0, |remaining| remaining + 1) + }; + let text = prepare_text_for_queue(&text, remaining); self.call(|ack| Cmd::PrintAt { x, y, text, ack }).await } /// Записывает байты напрямую. /// /// Raw-байты не обновляют строковый кэш worker. + /// Одна queued-команда ограничена [`MAX_QUEUED_RAW_BYTES`] байтами. /// /// # Ошибки /// /// Возвращает ошибки очереди или [`VfdError::Io`]. pub async fn write_raw(&self, bytes: impl Into>) -> Result<()> { let bytes = bytes.into(); + if bytes.len() > MAX_QUEUED_RAW_BYTES { + return Err(VfdError::RawPayloadTooLarge { + length: bytes.len(), + max: MAX_QUEUED_RAW_BYTES, + }); + } self.call(|ack| Cmd::WriteRaw { bytes, ack }).await } /// Заменяет текст бегущей строки. /// /// Если marquee уже активна, поток символов перестраивается сразу. Сам метод не - /// пишет кадр синхронно; запись произойдёт по таймеру worker-а. + /// пишет кадр синхронно; запись произойдёт по таймеру worker-а. Длина текста + /// ограничена [`MAX_MARQUEE_CHARS`] символами. /// /// # Ошибки /// /// Возвращает ошибки очереди или остановленного worker-а. pub async fn set_marquee_text(&self, text: impl Into) -> Result<()> { let text = text.into(); + let text = prepare_marquee_text(&text)?; self.call(|ack| Cmd::SetMarqueeText { text, ack }).await } /// Запускает бегущую строку. /// - /// `cps` - скорость в символах в секунду; `0` приводится к `1`. `end_pause` задаёт - /// async-паузу в конце полного прохода текста. + /// `cps` - скорость в символах в секунду; `0` приводится к `1`, а значения выше + /// [`MAX_MARQUEE_CPS`] отклоняются. `end_pause` задаёт async-паузу в конце прохода. /// /// # Ошибки /// /// Возвращает [`VfdError::InvalidLine`] или ошибки очереди. pub async fn start_marquee(&self, line: u8, cps: u32, end_pause: Duration) -> Result<()> { + validate_marquee_speed(cps)?; self.call(|ack| Cmd::StartMarquee { line, cps, @@ -452,7 +477,8 @@ impl AsyncVfdWorker { return Err(ConfigError::ZeroQueueCapacity.into()); } let (tx, rx) = mpsc::channel(queue_capacity); - let handle = AsyncVfdHandle { tx }; + let columns = vfd.columns(); + let handle = AsyncVfdHandle { tx, columns }; let join = ::tokio::spawn(async move { writer_loop(vfd, rx).await }); Ok(Self { @@ -562,7 +588,7 @@ impl MarqueeState { } fn step_interval(&self) -> Duration { - let cps = u64::from(self.cps.max(1)); + let cps = u64::from(self.cps.clamp(1, MAX_MARQUEE_CPS)); Duration::from_nanos((1_000_000_000 / cps).max(1)) } @@ -668,7 +694,12 @@ async fn handle_command( end_pause, ack, } => { - let result = if line == 0 || usize::from(line) > rows { + let result = if cps > MAX_MARQUEE_CPS { + Err(VfdError::InvalidMarqueeSpeed { + cps, + max: MAX_MARQUEE_CPS, + }) + } else if line == 0 || usize::from(line) > rows { Err(VfdError::InvalidLine { line, rows }) } else { last_lines[(line - 1) as usize].clear(); @@ -807,6 +838,29 @@ fn send_ack(ack: Ack, result: Result<()>) { let _ = ack.send(result); } +fn prepare_text_for_queue(text: &str, max_chars: usize) -> String { + sanitize_to_width(text, max_chars) +} + +fn prepare_marquee_text(text: &str) -> Result { + if text.chars().nth(MAX_MARQUEE_CHARS).is_some() { + return Err(VfdError::TextTooLong { + max: MAX_MARQUEE_CHARS, + }); + } + Ok(sanitize_to_width(text, MAX_MARQUEE_CHARS)) +} + +fn validate_marquee_speed(cps: u32) -> Result<()> { + if cps > MAX_MARQUEE_CPS { + return Err(VfdError::InvalidMarqueeSpeed { + cps, + max: MAX_MARQUEE_CPS, + }); + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -972,4 +1026,37 @@ mod tests { assert_eq!(vfd.rows(), 2); } + + #[tokio::test] + async fn async_text_and_queue_limits_match_sync_behavior() { + let display = DisplaySettings::new(8, 1, TextEncoding::Ascii); + let mut vfd = AsyncVfd::from_transport(Vec::::new(), display) + .await + .unwrap(); + vfd.print_line(1, "A\u{1b}@B\u{0c}C").await.unwrap(); + assert_eq!(&vfd.into_inner()[6..], b"A @B C "); + + let display = DisplaySettings::new(20, 2, TextEncoding::Ascii); + let worker = AsyncVfdWorker::from_transport(Vec::::new(), display, 2) + .await + .unwrap(); + let handle = worker.handle(); + assert!(matches!( + handle + .set_marquee_text("x".repeat(MAX_MARQUEE_CHARS + 1)) + .await, + Err(VfdError::TextTooLong { .. }) + )); + assert!(matches!( + handle.write_raw(vec![0; MAX_QUEUED_RAW_BYTES + 1]).await, + Err(VfdError::RawPayloadTooLarge { .. }) + )); + assert!(matches!( + handle + .start_marquee(1, MAX_MARQUEE_CPS + 1, Duration::ZERO) + .await, + Err(VfdError::InvalidMarqueeSpeed { .. }) + )); + worker.shutdown().await.unwrap(); + } } diff --git a/src/vfd.rs b/src/vfd.rs index 4468ec9..cd20f9c 100644 --- a/src/vfd.rs +++ b/src/vfd.rs @@ -5,7 +5,7 @@ //! функции. Для фоновой сериализации команд из разных частей приложения используйте //! [`crate::VfdWorker`] или `escpos_vfd::tokio::AsyncVfdWorker`. -use crate::codec::{EpsonCodec, fit_to_width, sanitize_text, truncate_chars}; +use crate::codec::{EpsonCodec, fit_to_width, truncate_chars}; use crate::config::{DisplaySettings, VfdConfig}; use crate::error::{Result, VfdError}; use serialport::SerialPort; @@ -84,7 +84,7 @@ impl Vfd { /// ``` pub fn from_transport(mut transport: T, display: DisplaySettings) -> Result { display.validate()?; - let codec = EpsonCodec::new(display); + let codec = EpsonCodec::new(display)?; let init = codec.init(); if !init.is_empty() { transport.write_all(&init)?; @@ -138,9 +138,8 @@ impl Vfd { /// Выводит подготовленный кадр с начала строки без предварительной очистки. /// - /// В отличие от [`Vfd::print_line`], метод не выполняет санацию текста. Используйте - /// его для заранее подготовленных кадров бегущей строки или тестовых байтовых - /// сценариев, когда содержимое уже нормализовано вызывающим кодом. + /// В отличие от [`Vfd::print_line`], метод не выполняет типографскую нормализацию до + /// подгонки ширины. Управляющие символы всё равно нейтрализуются на этапе кодирования. /// /// # Ошибки /// @@ -161,11 +160,10 @@ impl Vfd { /// /// Возвращает [`VfdError::InvalidCoordinate`] или [`VfdError::Io`]. pub fn print_at(&mut self, x: u8, y: u8, text: &str) -> Result<()> { - let text = sanitize_text(text); - self.print_at_prepared(x, y, &text) + self.print_at_prepared(x, y, text) } - /// Печатает уже подготовленный текст без повторной санации. + /// Печатает уже подготовленный текст; управляющие символы нейтрализуются codec-ом. pub(crate) fn print_at_prepared(&mut self, x: u8, y: u8, text: &str) -> Result<()> { self.codec.validate_xy(x, y)?; let remaining = self.columns() - usize::from(x) + 1; @@ -310,4 +308,14 @@ mod tests { vec![0x1B, 0x40, 0x1F, 0x24, 1, 1, 0xF2, b' ', b' ', b' '] ); } + + #[test] + fn high_level_text_neutralizes_protocol_controls() { + let display = DisplaySettings::new(8, 1, TextEncoding::Ascii); + let mut vfd = Vfd::from_transport(Vec::::new(), display).unwrap(); + + vfd.print_line(1, "A\u{1b}@B\u{0c}C").unwrap(); + + assert_eq!(&vfd.into_inner()[6..], b"A @B C "); + } } diff --git a/src/worker.rs b/src/worker.rs index 665d7a8..0f4472a 100644 --- a/src/worker.rs +++ b/src/worker.rs @@ -5,8 +5,12 @@ //! Очередь команд ограничена `queue_capacity`, поэтому быстрые producer-ы получают //! backpressure вместо неограниченного роста памяти. -use crate::codec::{changed_runs, fit_to_width, replace_cached_range, sanitize_text}; -use crate::config::{DisplaySettings, VfdConfig}; +use crate::codec::{ + changed_runs, fit_to_width, replace_cached_range, sanitize_text, sanitize_to_width, +}; +use crate::config::{ + DisplaySettings, MAX_MARQUEE_CHARS, MAX_MARQUEE_CPS, MAX_QUEUED_RAW_BYTES, VfdConfig, +}; use crate::error::{ConfigError, Result, VfdError}; use crate::vfd::Vfd; use serialport::SerialPort; @@ -73,6 +77,7 @@ enum Cmd { #[derive(Clone)] pub struct VfdHandle { tx: SyncSender, + columns: usize, } impl VfdHandle { @@ -108,6 +113,7 @@ impl VfdHandle { /// Возвращает [`VfdError::InvalidLine`], ошибки очереди или I/O. pub fn print_line(&self, line: u8, text: impl Into) -> Result<()> { let text = text.into(); + let text = prepare_text_for_queue(&text, self.columns); self.call(|ack| Cmd::PrintLine { line, text, ack }) } @@ -125,6 +131,7 @@ impl VfdHandle { /// Возвращает [`VfdError::InvalidLine`], ошибки очереди или I/O. pub fn print_line_diff(&self, line: u8, text: impl Into) -> Result<()> { let text = text.into(); + let text = prepare_text_for_queue(&text, self.columns); self.call(|ack| Cmd::PrintLineDiff { line, text, ack }) } @@ -138,6 +145,14 @@ impl VfdHandle { /// Возвращает [`VfdError::InvalidCoordinate`], ошибки очереди или I/O. pub fn print_at(&self, x: u8, y: u8, text: impl Into) -> Result<()> { let text = text.into(); + let remaining = if x == 0 { + 0 + } else { + self.columns + .checked_sub(usize::from(x)) + .map_or(0, |remaining| remaining + 1) + }; + let text = prepare_text_for_queue(&text, remaining); self.call(|ack| Cmd::PrintAt { x, y, text, ack }) } @@ -145,39 +160,49 @@ impl VfdHandle { /// /// Байты не кодируются и не отражаются в строковом кэше worker. После raw-команд, /// которые меняют видимый текст, лучше выполнить обычный `print_line` или `clear`, - /// чтобы синхронизировать кэш с дисплеем. + /// чтобы синхронизировать кэш с дисплеем. Одна queued-команда ограничена + /// [`MAX_QUEUED_RAW_BYTES`] байтами. /// /// # Ошибки /// /// Возвращает ошибки очереди или [`VfdError::Io`]. pub fn write_raw(&self, bytes: impl Into>) -> Result<()> { let bytes = bytes.into(); + if bytes.len() > MAX_QUEUED_RAW_BYTES { + return Err(VfdError::RawPayloadTooLarge { + length: bytes.len(), + max: MAX_QUEUED_RAW_BYTES, + }); + } self.call(|ack| Cmd::WriteRaw { bytes, ack }) } /// Заменяет текст бегущей строки. /// /// Если marquee уже активна, поток символов перестраивается сразу. Метод сам ничего - /// не пишет в дисплей; следующий кадр будет записан по таймеру worker. + /// не пишет в дисплей; следующий кадр будет записан по таймеру worker. Длина текста + /// ограничена [`MAX_MARQUEE_CHARS`] символами. /// /// # Ошибки /// /// Возвращает ошибки очереди, если worker закрыт или остановлен. pub fn set_marquee_text(&self, text: impl Into) -> Result<()> { let text = text.into(); + let text = prepare_marquee_text(&text)?; self.call(|ack| Cmd::SetMarqueeText { text, ack }) } /// Запускает бегущую строку на выбранной линии. /// /// `cps` - скорость в символах в секунду. Значение `0` безопасно приводится к `1`, - /// чтобы таймер не схлопнулся в нулевой интервал. `end_pause` - пауза после полного + /// значения выше [`MAX_MARQUEE_CPS`] отклоняются. `end_pause` - пауза после полного /// прохода текста перед следующим циклом. /// /// # Ошибки /// /// Возвращает [`VfdError::InvalidLine`], если строка вне дисплея, или ошибки очереди. pub fn start_marquee(&self, line: u8, cps: u32, end_pause: Duration) -> Result<()> { + validate_marquee_speed(cps)?; self.call(|ack| Cmd::StartMarquee { line, cps, @@ -261,7 +286,8 @@ impl VfdWorker { return Err(ConfigError::ZeroQueueCapacity.into()); } let (tx, rx) = mpsc::sync_channel::(queue_capacity); - let handle = VfdHandle { tx }; + let columns = vfd.columns(); + let handle = VfdHandle { tx, columns }; let join = thread::spawn(move || writer_loop(vfd, rx)); Ok(Self { @@ -373,7 +399,7 @@ impl MarqueeState { } fn step_interval(&self) -> Duration { - let cps = u64::from(self.cps.max(1)); + let cps = u64::from(self.cps.clamp(1, MAX_MARQUEE_CPS)); Duration::from_nanos((1_000_000_000 / cps).max(1)) } @@ -475,7 +501,12 @@ fn handle_command( end_pause, ack, } => { - let result = if line == 0 || usize::from(line) > rows { + let result = if cps > MAX_MARQUEE_CPS { + Err(VfdError::InvalidMarqueeSpeed { + cps, + max: MAX_MARQUEE_CPS, + }) + } else if line == 0 || usize::from(line) > rows { Err(VfdError::InvalidLine { line, rows }) } else { last_lines[(line - 1) as usize].clear(); @@ -612,6 +643,29 @@ fn render_marquee( Ok(()) } +fn prepare_text_for_queue(text: &str, max_chars: usize) -> String { + sanitize_to_width(text, max_chars) +} + +fn prepare_marquee_text(text: &str) -> Result { + if text.chars().nth(MAX_MARQUEE_CHARS).is_some() { + return Err(VfdError::TextTooLong { + max: MAX_MARQUEE_CHARS, + }); + } + Ok(sanitize_to_width(text, MAX_MARQUEE_CHARS)) +} + +fn validate_marquee_speed(cps: u32) -> Result<()> { + if cps > MAX_MARQUEE_CPS { + return Err(VfdError::InvalidMarqueeSpeed { + cps, + max: MAX_MARQUEE_CPS, + }); + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -646,7 +700,10 @@ mod tests { fn marquee_interval_never_collapses_to_zero() { let mut marquee = MarqueeState::new(); marquee.cps = u32::MAX; - assert!(!marquee.step_interval().is_zero()); + assert_eq!( + marquee.step_interval(), + Duration::from_nanos(1_000_000_000 / u64::from(MAX_MARQUEE_CPS)) + ); } #[test] @@ -715,4 +772,31 @@ mod tests { assert!(matches!(worker.shutdown(), Err(VfdError::Io(_)))); } + + #[test] + fn worker_rejects_oversized_queued_payloads_and_marquee_rate() { + let display = DisplaySettings::new(20, 2, TextEncoding::Ascii); + let worker = VfdWorker::from_transport(Vec::::new(), display, 2).unwrap(); + let handle = worker.handle(); + + assert!(matches!( + handle.set_marquee_text("x".repeat(MAX_MARQUEE_CHARS + 1)), + Err(VfdError::TextTooLong { .. }) + )); + assert!(matches!( + handle.write_raw(vec![0; MAX_QUEUED_RAW_BYTES + 1]), + Err(VfdError::RawPayloadTooLarge { .. }) + )); + assert!(matches!( + handle.start_marquee(1, MAX_MARQUEE_CPS + 1, Duration::ZERO), + Err(VfdError::InvalidMarqueeSpeed { .. }) + )); + + worker.shutdown().unwrap(); + } + + #[test] + fn queued_line_text_is_prepared_to_display_width() { + assert_eq!(prepare_text_for_queue("ab\u{1b}@long", 4), "ab @"); + } } diff --git a/taskfile.yml b/taskfile.yml index 10d8cd8..8f0162b 100644 --- a/taskfile.yml +++ b/taskfile.yml @@ -25,7 +25,7 @@ tasks: cmds: - cargo run --example clock -- {{.VFD_PORT}} {{.VFD_WIDTH}} {{.VFD_BRIGHTNESS | default "2"}} vars: - VFD_BRIGHTNESS: "2" + VFD_BRIGHTNESS: "4" vfd:marquee: desc: Run VFD marquee example @@ -34,7 +34,7 @@ tasks: vars: VFD_CPS: "8" VFD_END_PAUSE_MS: "1500" - VFD_BRIGHTNESS: "3" + VFD_BRIGHTNESS: "4" vfd:brightness: desc: Run VFD brightness example diff --git a/tests/security_regressions.rs b/tests/security_regressions.rs new file mode 100644 index 0000000..14d16fb --- /dev/null +++ b/tests/security_regressions.rs @@ -0,0 +1,97 @@ +use escpos_vfd::codec::EpsonCodec; +use escpos_vfd::{ + ConfigError, DisplaySettings, MAX_MARQUEE_CHARS, MAX_MARQUEE_CPS, MAX_QUEUED_RAW_BYTES, + TextEncoding, Vfd, VfdError, VfdWorker, +}; +use std::time::Duration; + +#[test] +fn public_text_api_neutralizes_controls_and_legacy_fallback_is_one_byte() { + let display = DisplaySettings::new(8, 1, TextEncoding::Ascii); + let mut vfd = Vfd::from_transport(Vec::::new(), display).unwrap(); + vfd.print_line(1, "A\u{1b}@B\u{0c}C").unwrap(); + assert_eq!(&vfd.into_inner()[6..], b"A @B C "); + + for encoding in [TextEncoding::Cp866, TextEncoding::Windows1251] { + let codec = EpsonCodec::new(DisplaySettings::new(1, 1, encoding)).unwrap(); + assert_eq!(codec.encode_text("😀"), vec![b'?']); + } +} + +#[test] +fn public_codec_constructor_returns_geometry_errors() { + assert!(matches!( + EpsonCodec::new(DisplaySettings::new(usize::MAX, 1, TextEncoding::Ascii)), + Err(ConfigError::InvalidColumns(usize::MAX)) + )); +} + +#[test] +fn sync_worker_enforces_payload_and_rate_boundaries() { + let display = DisplaySettings::new(20, 2, TextEncoding::Ascii); + let worker = VfdWorker::from_transport(Vec::::new(), display, 2).unwrap(); + let handle = worker.handle(); + + handle.print_line(1, "x".repeat(1_000_000)).unwrap(); + handle + .set_marquee_text("m".repeat(MAX_MARQUEE_CHARS)) + .unwrap(); + assert!(matches!( + handle.set_marquee_text("m".repeat(MAX_MARQUEE_CHARS + 1)), + Err(VfdError::TextTooLong { .. }) + )); + + handle.write_raw(vec![0; MAX_QUEUED_RAW_BYTES]).unwrap(); + assert!(matches!( + handle.write_raw(vec![0; MAX_QUEUED_RAW_BYTES + 1]), + Err(VfdError::RawPayloadTooLarge { .. }) + )); + + handle + .start_marquee(2, MAX_MARQUEE_CPS, Duration::ZERO) + .unwrap(); + handle.stop_marquee().unwrap(); + assert!(matches!( + handle.start_marquee(2, MAX_MARQUEE_CPS + 1, Duration::ZERO), + Err(VfdError::InvalidMarqueeSpeed { .. }) + )); + + let bytes = worker.shutdown().unwrap().into_inner(); + assert!(bytes.windows(20).any(|window| window == [b'x'; 20])); +} + +#[cfg(feature = "tokio")] +#[tokio::test] +async fn tokio_public_api_matches_security_boundaries() { + use escpos_vfd::tokio::{AsyncVfd, AsyncVfdWorker}; + + let display = DisplaySettings::new(8, 1, TextEncoding::Ascii); + let mut vfd = AsyncVfd::from_transport(Vec::::new(), display) + .await + .unwrap(); + vfd.print_line(1, "A\u{1b}@B\u{0c}C").await.unwrap(); + assert_eq!(&vfd.into_inner()[6..], b"A @B C "); + + let display = DisplaySettings::new(20, 2, TextEncoding::Ascii); + let worker = AsyncVfdWorker::from_transport(Vec::::new(), display, 2) + .await + .unwrap(); + let handle = worker.handle(); + assert!(matches!( + handle + .set_marquee_text("m".repeat(MAX_MARQUEE_CHARS + 1)) + .await, + Err(VfdError::TextTooLong { .. }) + )); + assert!(matches!( + handle.write_raw(vec![0; MAX_QUEUED_RAW_BYTES + 1]).await, + Err(VfdError::RawPayloadTooLarge { .. }) + )); + assert!(matches!( + handle + .start_marquee(1, MAX_MARQUEE_CPS + 1, Duration::ZERO) + .await, + Err(VfdError::InvalidMarqueeSpeed { .. }) + )); + worker.shutdown().await.unwrap(); +}