From 61bd11a55118458704c4cbbf4e628cd657238d3e Mon Sep 17 00:00:00 2001 From: Will Shuttleworth Date: Thu, 5 Jun 2025 09:38:51 +0000 Subject: [PATCH] stty: set control characters (#7931) * reworked arg processing. control character mappings are correctly grouped now, ie 'stty erase ^H' * stty: setting control chars to undefined (disabling them) is implemented * setting control chars * stty: can now set control chars. need to improve checks on valid mappings * stty: matches GNU in what control character mappings are allowed * stty: run rustfmt and remove extra comments * stty: setting control char code review fixes * stty: fix rustfmt errors * stty: more small edits after review * stty: refactor set control char changes for better testing * stty: fix ci error * stty: fix issues from code review --- src/uu/stty/src/flags.rs | 25 ++++ src/uu/stty/src/stty.rs | 272 ++++++++++++++++++++++++++++--------- tests/by-util/test_stty.rs | 33 +++++ 3 files changed, 266 insertions(+), 64 deletions(-) diff --git a/src/uu/stty/src/flags.rs b/src/uu/stty/src/flags.rs index 79c85ceb2..d08029b5f 100644 --- a/src/uu/stty/src/flags.rs +++ b/src/uu/stty/src/flags.rs @@ -26,6 +26,31 @@ use nix::sys::termios::{ SpecialCharacterIndices as S, }; +pub enum AllFlags<'a> { + #[cfg(any( + target_os = "freebsd", + target_os = "dragonfly", + target_os = "ios", + target_os = "macos", + target_os = "netbsd", + target_os = "openbsd" + ))] + Baud(u32), + #[cfg(not(any( + target_os = "freebsd", + target_os = "dragonfly", + target_os = "ios", + target_os = "macos", + target_os = "netbsd", + target_os = "openbsd" + )))] + Baud(BaudRate), + ControlFlags((&'a Flag, bool)), + InputFlags((&'a Flag, bool)), + LocalFlags((&'a Flag, bool)), + OutputFlags((&'a Flag, bool)), +} + pub const CONTROL_FLAGS: &[Flag] = &[ Flag::new("parenb", C::PARENB), Flag::new("parodd", C::PARODD), diff --git a/src/uu/stty/src/stty.rs b/src/uu/stty/src/stty.rs index 5b5a9948c..6fb06acb8 100644 --- a/src/uu/stty/src/stty.rs +++ b/src/uu/stty/src/stty.rs @@ -3,10 +3,11 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// spell-checker:ignore clocal erange tcgetattr tcsetattr tcsanow tiocgwinsz tiocswinsz cfgetospeed cfsetospeed ushort vmin vtime +// spell-checker:ignore clocal erange tcgetattr tcsetattr tcsanow tiocgwinsz tiocswinsz cfgetospeed cfsetospeed ushort vmin vtime cflag lflag mod flags; +use crate::flags::AllFlags; use clap::{Arg, ArgAction, ArgMatches, Command}; use nix::libc::{O_NONBLOCK, TIOCGWINSZ, TIOCSWINSZ, c_ushort}; use nix::sys::termios::{ @@ -16,7 +17,6 @@ use nix::sys::termios::{ use nix::{ioctl_read_bad, ioctl_write_ptr_bad}; use std::fs::File; use std::io::{self, Stdout, stdout}; -use std::ops::ControlFlow; use std::os::fd::{AsFd, BorrowedFd}; use std::os::unix::fs::OpenOptionsExt; use std::os::unix::io::{AsRawFd, RawFd}; @@ -35,6 +35,8 @@ use uucore::locale::get_message; use flags::BAUD_RATES; use flags::{CONTROL_CHARS, CONTROL_FLAGS, INPUT_FLAGS, LOCAL_FLAGS, OUTPUT_FLAGS}; +const ASCII_DEL: u8 = 127; + #[derive(Clone, Copy, Debug)] pub struct Flag { name: &'static str, @@ -101,6 +103,22 @@ enum Device { Stdout(Stdout), } +enum ControlCharMappingError { + IntOutOfRange, + MultipleChars, +} + +enum ArgOptions<'a> { + Flags(AllFlags<'a>), + Mapping((SpecialCharacterIndices, u8)), +} + +impl<'a> From> for ArgOptions<'a> { + fn from(flag: AllFlags<'a>) -> Self { + ArgOptions::Flags(flag) + } +} + impl AsFd for Device { fn as_fd(&self) -> BorrowedFd<'_> { match self { @@ -208,19 +226,59 @@ fn stty(opts: &Options) -> UResult<()> { )); } - // TODO: Figure out the right error message for when tcgetattr fails - let mut termios = tcgetattr(opts.file.as_fd()).expect("Could not get terminal attributes"); + let mut valid_args: Vec = Vec::new(); - if let Some(settings) = &opts.settings { - for setting in settings { - if let ControlFlow::Break(false) = apply_setting(&mut termios, setting) { - return Err(USimpleError::new( - 1, - format!("invalid argument '{setting}'"), - )); + if let Some(args) = &opts.settings { + let mut args_iter = args.iter(); + // iterate over args: skip to next arg if current one is a control char + while let Some(arg) = args_iter.next() { + // control char + if let Some(char_index) = cc_to_index(arg) { + if let Some(mapping) = args_iter.next() { + let cc_mapping = string_to_control_char(mapping).map_err(|e| { + let message = match e { + ControlCharMappingError::IntOutOfRange => format!( + "invalid integer argument: '{mapping}': Value too large for defined data type" + ), + ControlCharMappingError::MultipleChars => { + format!("invalid integer argument: '{mapping}'") + } + }; + USimpleError::new(1, message) + })?; + valid_args.push(ArgOptions::Mapping((char_index, cc_mapping))); + } else { + return Err(USimpleError::new(1, format!("missing argument to '{arg}'"))); + } + // non control char flag + } else if let Some(flag) = string_to_flag(arg) { + let remove_group = match flag { + AllFlags::Baud(_) => false, + AllFlags::ControlFlags((flag, remove)) => check_flag_group(flag, remove), + AllFlags::InputFlags((flag, remove)) => check_flag_group(flag, remove), + AllFlags::LocalFlags((flag, remove)) => check_flag_group(flag, remove), + AllFlags::OutputFlags((flag, remove)) => check_flag_group(flag, remove), + }; + if remove_group { + return Err(USimpleError::new(1, format!("invalid argument '{arg}'"))); + } + valid_args.push(flag.into()); + // not a valid control char or flag + } else { + return Err(USimpleError::new(1, format!("invalid argument '{arg}'"))); } } + // TODO: Figure out the right error message for when tcgetattr fails + let mut termios = tcgetattr(opts.file.as_fd()).expect("Could not get terminal attributes"); + + // iterate over valid_args, match on the arg type, do the matching apply function + for arg in &valid_args { + match arg { + ArgOptions::Mapping(mapping) => apply_char_mapping(&mut termios, mapping), + ArgOptions::Flags(flag) => apply_setting(&mut termios, flag), + } + } tcsetattr( opts.file.as_fd(), nix::sys::termios::SetArg::TCSANOW, @@ -228,11 +286,17 @@ fn stty(opts: &Options) -> UResult<()> { ) .expect("Could not write terminal attributes"); } else { + // TODO: Figure out the right error message for when tcgetattr fails + let termios = tcgetattr(opts.file.as_fd()).expect("Could not get terminal attributes"); print_settings(&termios, opts).expect("TODO: make proper error here from nix error"); } Ok(()) } +fn check_flag_group(flag: &Flag, remove: bool) -> bool { + remove && flag.group.is_some() +} + fn print_terminal_size(termios: &Termios, opts: &Options) -> nix::Result<()> { let speed = cfgetospeed(termios); @@ -283,6 +347,70 @@ fn print_terminal_size(termios: &Termios, opts: &Options) -> nix::Result<()> { Ok(()) } +fn cc_to_index(option: &str) -> Option { + for cc in CONTROL_CHARS { + if option == cc.0 { + return Some(cc.1); + } + } + None +} + +// return Some(flag) if the input is a valid flag, None if not +fn string_to_flag(option: &str) -> Option { + // BSDs use a u32 for the baud rate, so any decimal number applies. + #[cfg(any( + target_os = "freebsd", + target_os = "dragonfly", + target_os = "ios", + target_os = "macos", + target_os = "netbsd", + target_os = "openbsd" + ))] + if let Ok(n) = option.parse::() { + return Some(AllFlags::Baud(n)); + } + + #[cfg(not(any( + target_os = "freebsd", + target_os = "dragonfly", + target_os = "ios", + target_os = "macos", + target_os = "netbsd", + target_os = "openbsd" + )))] + for (text, baud_rate) in BAUD_RATES { + if *text == option { + return Some(AllFlags::Baud(*baud_rate)); + } + } + + let remove = option.starts_with('-'); + let name = option.trim_start_matches('-'); + + for cflag in CONTROL_FLAGS { + if name == cflag.name { + return Some(AllFlags::ControlFlags((cflag, remove))); + } + } + for iflag in INPUT_FLAGS { + if name == iflag.name { + return Some(AllFlags::InputFlags((iflag, remove))); + } + } + for lflag in LOCAL_FLAGS { + if name == lflag.name { + return Some(AllFlags::LocalFlags((lflag, remove))); + } + } + for oflag in OUTPUT_FLAGS { + if name == oflag.name { + return Some(AllFlags::OutputFlags((oflag, remove))); + } + } + None +} + fn control_char_to_string(cc: nix::libc::cc_t) -> nix::Result { if cc == 0 { return Ok("".to_string()); @@ -390,55 +518,25 @@ fn print_flags(termios: &Termios, opts: &Options, flags: &[Flag< } /// Apply a single setting -/// -/// The value inside the `Break` variant of the `ControlFlow` indicates whether -/// the setting has been applied. -fn apply_setting(termios: &mut Termios, s: &str) -> ControlFlow { - apply_baud_rate_flag(termios, s)?; - - let (remove, name) = match s.strip_prefix('-') { - Some(s) => (true, s), - None => (false, s), - }; - apply_flag(termios, CONTROL_FLAGS, name, remove)?; - apply_flag(termios, INPUT_FLAGS, name, remove)?; - apply_flag(termios, OUTPUT_FLAGS, name, remove)?; - apply_flag(termios, LOCAL_FLAGS, name, remove)?; - ControlFlow::Break(false) -} - -/// Apply a flag to a slice of flags -/// -/// The value inside the `Break` variant of the `ControlFlow` indicates whether -/// the setting has been applied. -fn apply_flag( - termios: &mut Termios, - flags: &[Flag], - input: &str, - remove: bool, -) -> ControlFlow { - for Flag { - name, flag, group, .. - } in flags - { - if input == *name { - // Flags with groups cannot be removed - // Since the name matches, we can short circuit and don't have to check the other flags. - if remove && group.is_some() { - return ControlFlow::Break(false); - } - // If there is a group, the bits for that group should be cleared before applying the flag - if let Some(group) = group { - group.apply(termios, false); - } - flag.apply(termios, !remove); - return ControlFlow::Break(true); +fn apply_setting(termios: &mut Termios, setting: &AllFlags) { + match setting { + AllFlags::Baud(_) => apply_baud_rate_flag(termios, setting), + AllFlags::ControlFlags((setting, disable)) => { + setting.flag.apply(termios, !disable); + } + AllFlags::InputFlags((setting, disable)) => { + setting.flag.apply(termios, !disable); + } + AllFlags::LocalFlags((setting, disable)) => { + setting.flag.apply(termios, !disable); + } + AllFlags::OutputFlags((setting, disable)) => { + setting.flag.apply(termios, !disable); } } - ControlFlow::Continue(()) } -fn apply_baud_rate_flag(termios: &mut Termios, input: &str) -> ControlFlow { +fn apply_baud_rate_flag(termios: &mut Termios, input: &AllFlags) { // BSDs use a u32 for the baud rate, so any decimal number applies. #[cfg(any( target_os = "freebsd", @@ -448,9 +546,8 @@ fn apply_baud_rate_flag(termios: &mut Termios, input: &str) -> ControlFlow target_os = "netbsd", target_os = "openbsd" ))] - if let Ok(n) = input.parse::() { - cfsetospeed(termios, n).expect("Failed to set baud rate"); - return ControlFlow::Break(true); + if let AllFlags::Baud(n) = input { + cfsetospeed(termios, *n).expect("Failed to set baud rate"); } // Other platforms use an enum. @@ -462,13 +559,60 @@ fn apply_baud_rate_flag(termios: &mut Termios, input: &str) -> ControlFlow target_os = "netbsd", target_os = "openbsd" )))] - for (text, baud_rate) in BAUD_RATES { - if *text == input { - cfsetospeed(termios, *baud_rate).expect("Failed to set baud rate"); - return ControlFlow::Break(true); + if let AllFlags::Baud(br) = input { + cfsetospeed(termios, *br).expect("Failed to set baud rate"); + } +} + +fn apply_char_mapping(termios: &mut Termios, mapping: &(SpecialCharacterIndices, u8)) { + termios.control_chars[mapping.0 as usize] = mapping.1; +} + +// GNU stty defines some valid values for the control character mappings +// 1. Standard character, can be a a single char (ie 'C') or hat notation (ie '^C') +// 2. Integer +// a. hexadecimal, prefixed by '0x' +// b. octal, prefixed by '0' +// c. decimal, no prefix +// 3. Disabling the control character: '^-' or 'undef' +// +// This function returns the ascii value of valid control chars, or ControlCharMappingError if invalid +fn string_to_control_char(s: &str) -> Result { + if s == "undef" || s == "^-" { + return Ok(0); + } + + // try to parse integer (hex, octal, or decimal) + let ascii_num = if let Some(hex) = s.strip_prefix("0x") { + u32::from_str_radix(hex, 16).ok() + } else if let Some(octal) = s.strip_prefix("0") { + u32::from_str_radix(octal, 8).ok() + } else { + s.parse::().ok() + }; + + if let Some(val) = ascii_num { + if val > 255 { + return Err(ControlCharMappingError::IntOutOfRange); + } else { + return Ok(val as u8); } } - ControlFlow::Continue(()) + // try to parse ^ or just + let mut chars = s.chars(); + match (chars.next(), chars.next()) { + (Some('^'), Some(c)) => { + // special case: ascii value of '^?' is greater than '?' + if c == '?' { + return Ok(ASCII_DEL); + } + // subtract by '@' to turn the char into the ascii value of '^' + Ok((c.to_ascii_uppercase() as u8).wrapping_sub(b'@')) + } + (Some(c), None) => Ok(c as u8), + (Some(_), Some(_)) => Err(ControlCharMappingError::MultipleChars), + _ => unreachable!("No arguments provided: must have been caught earlier"), + } } pub fn uu_app() -> Command { diff --git a/tests/by-util/test_stty.rs b/tests/by-util/test_stty.rs index 7ccc56e5d..e9e455e32 100644 --- a/tests/by-util/test_stty.rs +++ b/tests/by-util/test_stty.rs @@ -64,3 +64,36 @@ fn save_and_all() { "the options for verbose and stty-readable output styles are mutually exclusive", ); } + +#[test] +fn no_mapping() { + new_ucmd!() + .args(&["intr"]) + .fails() + .stderr_contains("missing argument to 'intr'"); +} + +#[test] +fn invalid_mapping() { + new_ucmd!() + .args(&["intr", "cc"]) + .fails() + .stderr_contains("invalid integer argument: 'cc'"); + + new_ucmd!() + .args(&["intr", "256"]) + .fails() + .stderr_contains("invalid integer argument: '256': Value too large for defined data type"); + + new_ucmd!() + .args(&["intr", "0x100"]) + .fails() + .stderr_contains( + "invalid integer argument: '0x100': Value too large for defined data type", + ); + + new_ucmd!() + .args(&["intr", "0400"]) + .fails() + .stderr_contains("invalid integer argument: '0400': Value too large for defined data type"); +}