Skip to content

Commit 2addcfe

Browse files
authored
Merge branch 'main' into columns_env_support
2 parents c3e31fd + 5fd26c0 commit 2addcfe

File tree

3 files changed

+269
-12
lines changed

3 files changed

+269
-12
lines changed

src/uu/stty/src/stty.rs

Lines changed: 86 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
// spell-checker:ignore isig icanon iexten echoe crterase echok echonl noflsh xcase tostop echoprt prterase echoctl ctlecho echoke crtkill flusho extproc
1111
// spell-checker:ignore lnext rprnt susp swtch vdiscard veof veol verase vintr vkill vlnext vquit vreprint vstart vstop vsusp vswtc vwerase werase
1212
// spell-checker:ignore sigquit sigtstp
13-
// spell-checker:ignore cbreak decctlq evenp litout oddp tcsadrain exta extb
13+
// spell-checker:ignore cbreak decctlq evenp litout oddp tcsadrain exta extb NCCS
1414

1515
mod flags;
1616

@@ -30,7 +30,7 @@ use std::num::IntErrorKind;
3030
use std::os::fd::{AsFd, BorrowedFd};
3131
use std::os::unix::fs::OpenOptionsExt;
3232
use std::os::unix::io::{AsRawFd, RawFd};
33-
use uucore::error::{UError, UResult, USimpleError};
33+
use uucore::error::{UError, UResult, USimpleError, UUsageError};
3434
use uucore::format_usage;
3535
use uucore::translate;
3636

@@ -150,6 +150,7 @@ enum ArgOptions<'a> {
150150
Mapping((S, u8)),
151151
Special(SpecialSetting),
152152
Print(PrintSetting),
153+
SavedState(Vec<u32>),
153154
}
154155

155156
impl<'a> From<AllFlags<'a>> for ArgOptions<'a> {
@@ -352,8 +353,12 @@ fn stty(opts: &Options) -> UResult<()> {
352353
valid_args.push(ArgOptions::Print(PrintSetting::Size));
353354
}
354355
_ => {
356+
// Try to parse saved format (hex string like "6d02:5:4bf:8a3b:...")
357+
if let Some(state) = parse_saved_state(arg) {
358+
valid_args.push(ArgOptions::SavedState(state));
359+
}
355360
// control char
356-
if let Some(char_index) = cc_to_index(arg) {
361+
else if let Some(char_index) = cc_to_index(arg) {
357362
if let Some(mapping) = args_iter.next() {
358363
let cc_mapping = string_to_control_char(mapping).map_err(|e| {
359364
let message = match e {
@@ -370,7 +375,7 @@ fn stty(opts: &Options) -> UResult<()> {
370375
)
371376
}
372377
};
373-
USimpleError::new(1, message)
378+
UUsageError::new(1, message)
374379
})?;
375380
valid_args.push(ArgOptions::Mapping((char_index, cc_mapping)));
376381
} else {
@@ -418,6 +423,9 @@ fn stty(opts: &Options) -> UResult<()> {
418423
ArgOptions::Print(setting) => {
419424
print_special_setting(setting, opts.file.as_raw_fd())?;
420425
}
426+
ArgOptions::SavedState(state) => {
427+
apply_saved_state(&mut termios, state)?;
428+
}
421429
}
422430
}
423431
tcsetattr(opts.file.as_fd(), set_arg, &termios)?;
@@ -429,8 +437,9 @@ fn stty(opts: &Options) -> UResult<()> {
429437
Ok(())
430438
}
431439

440+
// The GNU implementation adds the --help message when the args are incorrectly formatted
432441
fn missing_arg<T>(arg: &str) -> Result<T, Box<dyn UError>> {
433-
Err::<T, Box<dyn UError>>(USimpleError::new(
442+
Err(UUsageError::new(
434443
1,
435444
translate!(
436445
"stty-error-missing-argument",
@@ -440,7 +449,7 @@ fn missing_arg<T>(arg: &str) -> Result<T, Box<dyn UError>> {
440449
}
441450

442451
fn invalid_arg<T>(arg: &str) -> Result<T, Box<dyn UError>> {
443-
Err::<T, Box<dyn UError>>(USimpleError::new(
452+
Err(UUsageError::new(
444453
1,
445454
translate!(
446455
"stty-error-invalid-argument",
@@ -450,7 +459,7 @@ fn invalid_arg<T>(arg: &str) -> Result<T, Box<dyn UError>> {
450459
}
451460

452461
fn invalid_integer_arg<T>(arg: &str) -> Result<T, Box<dyn UError>> {
453-
Err::<T, Box<dyn UError>>(USimpleError::new(
462+
Err(UUsageError::new(
454463
1,
455464
translate!(
456465
"stty-error-invalid-integer-argument",
@@ -478,6 +487,43 @@ fn parse_rows_cols(arg: &str) -> Option<u16> {
478487
None
479488
}
480489

490+
/// Parse a saved terminal state string in stty format.
491+
///
492+
/// The format is colon-separated hexadecimal values:
493+
/// `input_flags:output_flags:control_flags:local_flags:cc0:cc1:cc2:...`
494+
///
495+
/// - Must have exactly 4 + NCCS parts (4 flags + platform-specific control characters)
496+
/// - All parts must be non-empty valid hex values
497+
/// - Control characters must fit in u8 (0-255)
498+
/// - Returns `None` if format is invalid
499+
fn parse_saved_state(arg: &str) -> Option<Vec<u32>> {
500+
let parts: Vec<&str> = arg.split(':').collect();
501+
let expected_parts = 4 + nix::libc::NCCS;
502+
503+
// GNU requires exactly the right number of parts for this platform
504+
if parts.len() != expected_parts {
505+
return None;
506+
}
507+
508+
// Validate all parts are non-empty valid hex
509+
let mut values = Vec::with_capacity(expected_parts);
510+
for (i, part) in parts.iter().enumerate() {
511+
if part.is_empty() {
512+
return None; // GNU rejects empty hex values
513+
}
514+
let val = u32::from_str_radix(part, 16).ok()?;
515+
516+
// Control characters (indices 4+) must fit in u8
517+
if i >= 4 && val > 255 {
518+
return None;
519+
}
520+
521+
values.push(val);
522+
}
523+
524+
Some(values)
525+
}
526+
481527
fn check_flag_group<T>(flag: &Flag<T>, remove: bool) -> bool {
482528
remove && flag.group.is_some()
483529
}
@@ -938,6 +984,39 @@ fn apply_char_mapping(termios: &mut Termios, mapping: &(S, u8)) {
938984
termios.control_chars[mapping.0 as usize] = mapping.1;
939985
}
940986

987+
/// Apply a saved terminal state to the current termios.
988+
///
989+
/// The state array contains:
990+
/// - `state[0]`: input flags
991+
/// - `state[1]`: output flags
992+
/// - `state[2]`: control flags
993+
/// - `state[3]`: local flags
994+
/// - `state[4..]`: control characters (optional)
995+
///
996+
/// If state has fewer than 4 elements, no changes are applied. This is a defensive
997+
/// check that should never trigger since `parse_saved_state` rejects such states.
998+
fn apply_saved_state(termios: &mut Termios, state: &[u32]) -> nix::Result<()> {
999+
// Require at least 4 elements for the flags (defensive check)
1000+
if state.len() < 4 {
1001+
return Ok(()); // No-op for invalid state (already validated by parser)
1002+
}
1003+
1004+
// Apply the four flag groups, done (as _) for MacOS size compatibility
1005+
termios.input_flags = InputFlags::from_bits_truncate(state[0] as _);
1006+
termios.output_flags = OutputFlags::from_bits_truncate(state[1] as _);
1007+
termios.control_flags = ControlFlags::from_bits_truncate(state[2] as _);
1008+
termios.local_flags = LocalFlags::from_bits_truncate(state[3] as _);
1009+
1010+
// Apply control characters if present (stored as u32 but used as u8)
1011+
for (i, &cc_val) in state.iter().skip(4).enumerate() {
1012+
if i < termios.control_chars.len() {
1013+
termios.control_chars[i] = cc_val as u8;
1014+
}
1015+
}
1016+
1017+
Ok(())
1018+
}
1019+
9411020
fn apply_special_setting(
9421021
_termios: &mut Termios,
9431022
setting: &SpecialSetting,

tests/by-util/test_stty.rs

Lines changed: 180 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,18 @@
22
//
33
// For the full copyright and license information, please view the LICENSE
44
// file that was distributed with this source code.
5-
// spell-checker:ignore parenb parmrk ixany iuclc onlcr icanon noflsh econl igpar ispeed ospeed
5+
// spell-checker:ignore parenb parmrk ixany iuclc onlcr icanon noflsh econl igpar ispeed ospeed NCCS nonhex gstty
66

7-
use uutests::new_ucmd;
8-
use uutests::util::pty_path;
7+
use uutests::util::{expected_result, pty_path};
8+
use uutests::{at_and_ts, new_ucmd, unwrap_or_return};
9+
10+
/// Normalize stderr by replacing the full binary path with just the utility name
11+
/// This allows comparison between GNU (which shows "stty" or "gstty") and ours (which shows full path)
12+
fn normalize_stderr(stderr: &str) -> String {
13+
// Replace patterns like "Try 'gstty --help'" or "Try '/path/to/stty --help'" with "Try 'stty --help'"
14+
let re = regex::Regex::new(r"Try '[^']*(?:g)?stty --help'").unwrap();
15+
re.replace_all(stderr, "Try 'stty --help'").to_string()
16+
}
917

1018
#[test]
1119
fn test_invalid_arg() {
@@ -406,3 +414,172 @@ fn test_columns_env_wrapping() {
406414
);
407415
}
408416
}
417+
418+
// Tests for saved state parsing and restoration
419+
#[test]
420+
#[cfg(unix)]
421+
fn test_save_and_restore() {
422+
let (path, _controller, _replica) = pty_path();
423+
let saved = new_ucmd!()
424+
.args(&["--save", "--file", &path])
425+
.succeeds()
426+
.stdout_move_str();
427+
428+
let saved = saved.trim();
429+
assert!(saved.contains(':'));
430+
431+
new_ucmd!().args(&["--file", &path, saved]).succeeds();
432+
}
433+
434+
#[test]
435+
#[cfg(unix)]
436+
fn test_save_with_g_flag() {
437+
let (path, _controller, _replica) = pty_path();
438+
let saved = new_ucmd!()
439+
.args(&["-g", "--file", &path])
440+
.succeeds()
441+
.stdout_move_str();
442+
443+
let saved = saved.trim();
444+
assert!(saved.contains(':'));
445+
446+
new_ucmd!().args(&["--file", &path, saved]).succeeds();
447+
}
448+
449+
#[test]
450+
#[cfg(unix)]
451+
fn test_save_restore_after_change() {
452+
let (path, _controller, _replica) = pty_path();
453+
let saved = new_ucmd!()
454+
.args(&["--save", "--file", &path])
455+
.succeeds()
456+
.stdout_move_str();
457+
458+
let saved = saved.trim();
459+
460+
new_ucmd!()
461+
.args(&["--file", &path, "intr", "^A"])
462+
.succeeds();
463+
464+
new_ucmd!().args(&["--file", &path, saved]).succeeds();
465+
466+
new_ucmd!()
467+
.args(&["--file", &path])
468+
.succeeds()
469+
.stdout_str_check(|s| !s.contains("intr = ^A"));
470+
}
471+
472+
// These tests both validate what we expect each input to return and their error codes
473+
// and also use the GNU coreutils results to validate our results match expectations
474+
#[test]
475+
#[cfg(unix)]
476+
fn test_saved_state_valid_formats() {
477+
let (path, _controller, _replica) = pty_path();
478+
let (_at, ts) = at_and_ts!();
479+
480+
// Generate valid saved state from the actual terminal
481+
let saved = unwrap_or_return!(expected_result(&ts, &["-g", "--file", &path])).stdout_move_str();
482+
let saved = saved.trim();
483+
484+
let result = ts.ucmd().args(&["--file", &path, saved]).run();
485+
486+
result.success().no_stderr();
487+
488+
let exp_result = unwrap_or_return!(expected_result(&ts, &["--file", &path, saved]));
489+
let normalized_stderr = normalize_stderr(result.stderr_str());
490+
result
491+
.stdout_is(exp_result.stdout_str())
492+
.code_is(exp_result.code());
493+
assert_eq!(normalized_stderr, exp_result.stderr_str());
494+
}
495+
496+
#[test]
497+
#[cfg(unix)]
498+
fn test_saved_state_invalid_formats() {
499+
let (path, _controller, _replica) = pty_path();
500+
let (_at, ts) = at_and_ts!();
501+
502+
let num_cc = nix::libc::NCCS;
503+
504+
// Build test strings with platform-specific counts
505+
let cc_zeros = vec!["0"; num_cc].join(":");
506+
let cc_with_invalid = if num_cc > 0 {
507+
let mut parts = vec!["1c"; num_cc];
508+
parts[0] = "100"; // First control char > 255
509+
parts.join(":")
510+
} else {
511+
String::new()
512+
};
513+
let cc_with_space = if num_cc > 0 {
514+
let mut parts = vec!["1c"; num_cc];
515+
parts[0] = "1c "; // Space in hex
516+
parts.join(":")
517+
} else {
518+
String::new()
519+
};
520+
let cc_with_nonhex = if num_cc > 0 {
521+
let mut parts = vec!["1c"; num_cc];
522+
parts[0] = "xyz"; // Non-hex
523+
parts.join(":")
524+
} else {
525+
String::new()
526+
};
527+
let cc_with_empty = if num_cc > 0 {
528+
let mut parts = vec!["1c"; num_cc];
529+
parts[0] = ""; // Empty
530+
parts.join(":")
531+
} else {
532+
String::new()
533+
};
534+
535+
// Cannot test single value since it would be interpreted as baud rate
536+
let invalid_states = vec![
537+
"500:5:4bf".to_string(), // fewer than expected parts
538+
"500:5:4bf:8a3b".to_string(), // only 4 parts
539+
format!("500:5:{}:8a3b:{}", cc_zeros, "extra"), // too many parts
540+
format!("500::4bf:8a3b:{}", cc_zeros), // empty hex value in flags
541+
format!("500:5:4bf:8a3b:{}", cc_with_empty), // empty hex value in cc
542+
format!("500:5:4bf:8a3b:{}", cc_with_nonhex), // non-hex characters
543+
format!("500:5:4bf:8a3b:{}", cc_with_space), // space in hex value
544+
format!("500:5:4bf:8a3b:{}", cc_with_invalid), // control char > 255
545+
];
546+
547+
for state in &invalid_states {
548+
let result = ts.ucmd().args(&["--file", &path, state]).run();
549+
550+
result.failure().stderr_contains("invalid argument");
551+
552+
let exp_result = unwrap_or_return!(expected_result(&ts, &["--file", &path, state]));
553+
let normalized_stderr = normalize_stderr(result.stderr_str());
554+
let exp_normalized_stderr = normalize_stderr(exp_result.stderr_str());
555+
result
556+
.stdout_is(exp_result.stdout_str())
557+
.code_is(exp_result.code());
558+
assert_eq!(normalized_stderr, exp_normalized_stderr);
559+
}
560+
}
561+
562+
#[test]
563+
#[cfg(unix)]
564+
#[ignore = "Fails because the implementation of print state is not correctly printing flags on certain platforms"]
565+
fn test_saved_state_with_control_chars() {
566+
let (path, _controller, _replica) = pty_path();
567+
let (_at, ts) = at_and_ts!();
568+
569+
// Build a valid saved state with platform-specific number of control characters
570+
let num_cc = nix::libc::NCCS;
571+
let cc_values: Vec<String> = (1..=num_cc).map(|_| format!("{:x}", 0)).collect();
572+
let saved_state = format!("500:5:4bf:8a3b:{}", cc_values.join(":"));
573+
574+
ts.ucmd().args(&["--file", &path, &saved_state]).succeeds();
575+
576+
let result = ts.ucmd().args(&["-g", "--file", &path]).run();
577+
578+
result.success().stdout_contains(":");
579+
580+
let exp_result = unwrap_or_return!(expected_result(&ts, &["-g", "--file", &path]));
581+
result
582+
.stdout_is(exp_result.stdout_str())
583+
.stderr_is(exp_result.stderr_str())
584+
.code_is(exp_result.code());
585+
}

util/build-gnu.sh

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
# spell-checker:ignore (paths) abmon deref discrim eacces getlimits getopt ginstall inacc infloop inotify reflink ; (misc) INT_OFLOW OFLOW
66
# spell-checker:ignore baddecode submodules xstrtol distros ; (vars/env) SRCDIR vdir rcexp xpart dired OSTYPE ; (utils) gnproc greadlink gsed multihardlink texinfo CARGOFLAGS
7-
# spell-checker:ignore openat TOCTOU
7+
# spell-checker:ignore openat TOCTOU CFLAGS
88

99
set -e
1010

@@ -131,7 +131,8 @@ else
131131
# Disable useless checks
132132
"${SED}" -i 's|check-texinfo: $(syntax_checks)|check-texinfo:|' doc/local.mk
133133
./bootstrap --skip-po
134-
./configure --quiet --disable-gcc-warnings --disable-nls --disable-dependency-tracking --disable-bold-man-page-references \
134+
# Use CFLAGS for best build time since we discard GNU coreutils
135+
CFLAGS="${CFLAGS} -pipe -O0 -s" ./configure --quiet --disable-gcc-warnings --disable-nls --disable-dependency-tracking --disable-bold-man-page-references \
135136
"$([ "${SELINUX_ENABLED}" = 1 ] && echo --with-selinux || echo --without-selinux)"
136137
#Add timeout to to protect against hangs
137138
"${SED}" -i 's|^"\$@|'"${SYSTEM_TIMEOUT}"' 600 "\$@|' build-aux/test-driver

0 commit comments

Comments
 (0)