From 37927aaf0ec33eb3be964244547294ba0b1b5bb3 Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Thu, 13 Aug 2026 11:44:23 +0800 Subject: [PATCH] release 0.0.6 --- .cargo/config.toml | 2 +- .github/workflows/security-audit.yml | 33 ++++ CHANGELOG.md | 13 ++ CONTRIBUTING.md | 13 ++ Cargo.lock | 4 +- Cargo.toml | 2 +- README.md | 1 + src/app.rs | 20 +- src/document.rs | 72 +++++++- src/layout.rs | 4 +- src/locator.rs | 6 +- src/observer.rs | 64 ++++++- src/path_binding.rs | 4 + src/tui/input.rs | 267 ++++++++++++++++++++++++++- tests/cli.rs | 104 +++++++++-- tests/documentation.rs | 22 +++ 16 files changed, 584 insertions(+), 47 deletions(-) create mode 100644 .github/workflows/security-audit.yml diff --git a/.cargo/config.toml b/.cargo/config.toml index 72c8463..98f47fc 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,2 +1,2 @@ [patch.crates-io] -crossterm = { git = "https://github.com/m1ngsama/crossterm.git", rev = "e3888223b5064543484cc08c3966cc05d711003a" } +crossterm = { git = "https://github.com/m1ngsama/crossterm.git", rev = "0e7e884bafd39135bf926fe60e9ae70852f304a8" } diff --git a/.github/workflows/security-audit.yml b/.github/workflows/security-audit.yml new file mode 100644 index 0000000..0da9aec --- /dev/null +++ b/.github/workflows/security-audit.yml @@ -0,0 +1,33 @@ +name: Security audit + +on: + push: + branches: [main] + pull_request: + schedule: + - cron: "17 3 * * 1" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: security-audit-${{ github.ref }} + cancel-in-progress: true + +jobs: + rustsec: + name: RustSec / Cargo.lock + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@2c7215f132e9ebf062739d9130488b56d53c060c # reviewed 2026-07-18 + with: + toolchain: 1.88.0 + - name: Install cargo-audit + run: cargo +1.88.0 install cargo-audit --locked --version '=0.22.2' + - name: Audit locked dependencies + run: cargo +1.88.0 audit --file Cargo.lock diff --git a/CHANGELOG.md b/CHANGELOG.md index 04c638f..a6a63df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ tag does not describe the present program or its compatibility. ## Unreleased +## 0.0.6 - 2026-08-13 + ### Fixed - Heap-backed terminal cell symbols now share the 64 MiB terminal-buffer @@ -15,6 +17,16 @@ tag does not describe the present program or its compatibility. - Repeated public `tut::run` calls now recover from a closed terminal input when the process binds a different terminal, without leaking signal registrations or losing the original EOF result. +- CSI-u and Kitty keyboard events now preserve shifted and Caps-Lock ASCII + letters, distinguish physical command modifiers, and retain numeric prefixes + across separately reported modifier presses. +- Zero-valued cursor and mouse coordinates from terminal event reports now + clamp to the origin instead of overflowing during parsing. +- Session-log input-alias checks now re-resolve semantic path aliases before + opening and verify the opened file again before logging begins, rejecting + replacements already visible at either check. + +See the [v0.0.6 release notes][v0.0.6] for the complete release description. ## 0.0.5 - 2026-08-11 @@ -80,6 +92,7 @@ See the [v0.0.3 release notes][v0.0.3] for the complete release description. See the [v0.0.2 release notes][v0.0.2] for the complete release description. +[v0.0.6]: https://github.com/m1ngsama/TUT/releases/tag/v0.0.6 [v0.0.5]: https://github.com/m1ngsama/TUT/releases/tag/v0.0.5 [v0.0.4]: https://github.com/m1ngsama/TUT/releases/tag/v0.0.4 [v0.0.3]: https://github.com/m1ngsama/TUT/releases/tag/v0.0.3 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9bb4eba..68886fe 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,6 +21,19 @@ cargo clippy --all-targets --all-features --locked -- -D warnings cargo fmt --all -- --check ``` +Audit the locked dependency graph with the same `cargo-audit` release used by +CI: + +```sh +cargo install cargo-audit --locked --version '=0.22.2' +cargo audit --file Cargo.lock +``` + +CI runs this RustSec vulnerability check for every pull request and main-branch +push, and weekly so newly published advisories are detected without a dependency +change. Informational warnings remain visible for review but do not fail the +gate. + Before requesting review, also run the release and distribution checks when the change can affect arithmetic, packaging, installation, or terminal behavior: diff --git a/Cargo.lock b/Cargo.lock index 34fa110..d2bee33 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -76,7 +76,7 @@ dependencies = [ [[package]] name = "crossterm" version = "0.29.0" -source = "git+https://github.com/m1ngsama/crossterm.git?rev=e3888223b5064543484cc08c3966cc05d711003a#e3888223b5064543484cc08c3966cc05d711003a" +source = "git+https://github.com/m1ngsama/crossterm.git?rev=0e7e884bafd39135bf926fe60e9ae70852f304a8#0e7e884bafd39135bf926fe60e9ae70852f304a8" dependencies = [ "bitflags", "crossterm_winapi", @@ -819,7 +819,7 @@ checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "tut" -version = "0.0.5" +version = "0.0.6" dependencies = [ "crossterm", "libc", diff --git a/Cargo.toml b/Cargo.toml index 560ce24..6bf3f39 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tut" -version = "0.0.5" +version = "0.0.6" edition = "2024" rust-version = "1.88.0" publish = false diff --git a/README.md b/README.md index 3d7a17d..70024b7 100644 --- a/README.md +++ b/README.md @@ -191,6 +191,7 @@ The normal local checks mirror the CI gates: cargo fmt --all -- --check cargo clippy --all-targets --all-features --locked -- -D warnings cargo test --all-targets --locked +cargo audit --file Cargo.lock make distcheck ``` diff --git a/src/app.rs b/src/app.rs index 419a085..0fd90d5 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2699,7 +2699,7 @@ mod tests { use tempfile::tempdir; use super::*; - use crate::layout::DisplayAtoms; + use crate::{document::overwrite_with_distinct_fingerprint, layout::DisplayAtoms}; const BACKGROUND_STEP_LIMIT: usize = 100_000; @@ -3579,7 +3579,7 @@ mod tests { app.update(Action::LineDown).unwrap(); assert_eq!(app.background_work(), Some(BackgroundWork::Viewport)); - fs::write(path, "y".repeat(SOURCE_WINDOW_BYTES * 3)).unwrap(); + overwrite_with_distinct_fingerprint(&path, "y".repeat(SOURCE_WINDOW_BYTES * 3)); assert!(matches!(app.advance_background(), Err(TutError::Load(_)))); assert_eq!(app.anchor, anchor); @@ -3606,7 +3606,7 @@ mod tests { app.update(Action::LineDown).unwrap(); let request = app.viewport_request.unwrap(); assert_eq!(app.cached_viewport_location(request), None); - fs::write(path, "y".repeat(SOURCE_WINDOW_BYTES * 3)).unwrap(); + overwrite_with_distinct_fingerprint(&path, "y".repeat(SOURCE_WINDOW_BYTES * 3)); assert!(matches!(app.advance_background(), Err(TutError::Load(_)))); assert_eq!(app.anchor, warm_anchor); @@ -5599,7 +5599,7 @@ mod tests { settle_frame(&mut app); assert_eq!(published_boundary(&mut app), Some(ViewportBoundary::All)); - fs::write(&path, "other").unwrap(); + overwrite_with_distinct_fingerprint(&path, "other"); assert!(matches!(app.render_state(), Err(TutError::Load(_)))); } @@ -5697,7 +5697,7 @@ mod tests { assert!(!app.advance_background().unwrap()); assert!(matches!(app.render_body, RenderBody::Scanning(_))); - fs::write(path, "y".repeat(4096)).unwrap(); + overwrite_with_distinct_fingerprint(&path, "y".repeat(4096)); assert!(matches!(app.advance_background(), Err(TutError::Load(_)))); assert!(matches!(app.render_body, RenderBody::Empty)); @@ -5716,7 +5716,7 @@ mod tests { app.update(Action::LineDown).unwrap(); settle_viewport(&mut app); - fs::write(path, "y".repeat(128)).unwrap(); + overwrite_with_distinct_fingerprint(&path, "y".repeat(128)); assert!(matches!(app.advance_background(), Err(TutError::Load(_)))); assert!(app.render_cache().is_none()); @@ -5819,7 +5819,7 @@ mod tests { app.update(Action::LineDown).unwrap(); settle(&mut app); - fs::write(&path, "y".repeat(64)).unwrap(); + overwrite_with_distinct_fingerprint(&path, "y".repeat(64)); app.update(Action::LineUp).unwrap(); assert!(matches!(app.advance_background(), Err(TutError::Load(_)))); @@ -5856,7 +5856,7 @@ mod tests { let old_anchor = app.anchor; let old_follow_end = app.follow_end; - fs::write(&path, "y".repeat(83)).unwrap(); + overwrite_with_distinct_fingerprint(&path, "y".repeat(83)); assert!( matches!(app.advance_viewport_locator(), Err(TutError::Load(_))), @@ -5881,7 +5881,7 @@ mod tests { settle(&mut app); assert!(app.search.as_ref().unwrap().has_cached_block()); - fs::write(&path, "dog dog dog").unwrap(); + overwrite_with_distinct_fingerprint(&path, "dog dog dog"); app.update(Action::NextMatch).unwrap(); assert!(matches!(app.advance_background(), Err(TutError::Load(_)))); @@ -5897,7 +5897,7 @@ mod tests { commit(&mut app, "dog"); assert!(app.search.as_ref().unwrap().no_matches()); - fs::write(&path, "dog").unwrap(); + overwrite_with_distinct_fingerprint(&path, "dog"); assert!(matches!( app.update(Action::NextMatch), diff --git a/src/document.rs b/src/document.rs index 24346b6..7c4aeee 100644 --- a/src/document.rs +++ b/src/document.rs @@ -1242,6 +1242,21 @@ pub(super) struct InputIdentity<'a> { } impl InputIdentity<'_> { + fn current_path(self) -> std::io::Result> { + match ResolvedPath::parent(self.path.pathname.exact_path()) { + Ok(current) => Ok(Some(current)), + Err(error) + if matches!( + error.kind(), + io::ErrorKind::NotFound | io::ErrorKind::NotADirectory + ) => + { + Ok(None) + } + Err(error) => Err(error), + } + } + pub(super) fn pathname_matches(self, pathname: &PathIdentity, resolved: &PathIdentity) -> bool { self.path.pathname.exactly_matches(pathname) || (self.path.lexical_location @@ -1253,6 +1268,15 @@ impl InputIdentity<'_> { self.path.resolved.location() == location } + pub(super) fn current_location_matches( + self, + location: &ResolvedPathLocation, + ) -> std::io::Result { + Ok(self + .current_path()? + .is_some_and(|current| current.location() == location)) + } + pub(super) fn current_leaf_matches( self, file: &impl std::os::fd::AsFd, @@ -1260,6 +1284,15 @@ impl InputIdentity<'_> { self.path.resolved.current_leaf_matches(file) } + pub(super) fn current_path_matches( + self, + file: &impl std::os::fd::AsFd, + ) -> std::io::Result { + self.current_path()?.map_or(Ok(false), |current| { + current.into_anchor().current_leaf_matches(file) + }) + } + pub(super) fn file_matches(self, metadata: &std::fs::Metadata) -> bool { self.file == FileIdentity::from_metadata(metadata) } @@ -1289,6 +1322,41 @@ impl FileFingerprint { } } +#[cfg(test)] +/// Overwrites a tracked test file while making the fingerprint change deterministic. +/// +/// Some filesystems coalesce rapid timestamp updates, so an equal-length write can otherwise +/// leave the complete metadata fingerprint unchanged even though the contents differ. +pub(super) fn overwrite_with_distinct_fingerprint(path: &Path, contents: impl AsRef<[u8]>) { + let before_metadata = std::fs::metadata(path).expect("test input metadata is readable"); + let before = FileFingerprint::from_metadata(&before_metadata); + std::fs::write(path, contents).expect("test input can be overwritten"); + + let mut after = FileFingerprint::from_metadata( + &std::fs::metadata(path).expect("overwritten test input metadata is readable"), + ); + if after == before { + let modified = before_metadata + .modified() + .expect("test input has a modification time") + .checked_add(std::time::Duration::from_secs(1)) + .expect("test input modification time can advance"); + std::fs::OpenOptions::new() + .write(true) + .open(path) + .expect("overwritten test input can be reopened") + .set_modified(modified) + .expect("test input modification time can advance"); + after = FileFingerprint::from_metadata( + &std::fs::metadata(path).expect("retimed test input metadata is readable"), + ); + } + assert_ne!( + after, before, + "test input overwrite must change its tracked fingerprint" + ); +} + impl FileStore { fn open(path: PathBuf, limit: u64) -> Result { let bound = BoundPath::capture(&path).map_err(|source| LoadError::Open { @@ -2032,7 +2100,7 @@ mod tests { let path = directory.path().join("changing.txt"); fs::write(&path, "stable").unwrap(); let document = load(path.clone()).unwrap(); - fs::write(path, "change").unwrap(); + overwrite_with_distinct_fingerprint(&path, "change"); let mut cache = DocumentCache::default(); let mut reader = document.reader(&mut cache); @@ -2063,7 +2131,7 @@ mod tests { ); } - fs::write(path, "change").unwrap(); + overwrite_with_distinct_fingerprint(&path, "change"); let mut reader = document.reader(&mut cache); assert!(matches!( reader.graphemes(SourceOffset::ZERO), diff --git a/src/layout.rs b/src/layout.rs index 068d4bc..9394fc0 100644 --- a/src/layout.rs +++ b/src/layout.rs @@ -1131,7 +1131,7 @@ mod tests { use std::{cell::Cell, fs, path::Path}; use super::*; - use crate::document::{Document, DocumentCache, load}; + use crate::document::{Document, DocumentCache, load, overwrite_with_distinct_fingerprint}; use tempfile::tempdir; struct Fixture { @@ -1874,7 +1874,7 @@ mod tests { scanner }; - fs::write(path, "x yzuvw").unwrap(); + overwrite_with_distinct_fingerprint(&path, "x yzuvw"); cache.reset_metrics(); let error = { let mut reader = document.reader(&mut cache); diff --git a/src/locator.rs b/src/locator.rs index b6f3713..31a487c 100644 --- a/src/locator.rs +++ b/src/locator.rs @@ -675,7 +675,9 @@ mod tests { use super::*; use crate::{ - document::{Document, DocumentCache, SOURCE_WINDOW_BYTES, load}, + document::{ + Document, DocumentCache, SOURCE_WINDOW_BYTES, load, overwrite_with_distinct_fingerprint, + }, error::LayoutError, layout::rebuild_viewport_layout, }; @@ -1494,7 +1496,7 @@ mod tests { ); assert!(locator.row_scan.is_some()); let history = locator.history.clone(); - fs::write(path, "y".repeat(2_048)).unwrap(); + overwrite_with_distinct_fingerprint(&path, "y".repeat(2_048)); assert!(matches!( advance(&mut locator, &layout, &document, &mut cache, &mut rows), diff --git a/src/observer.rs b/src/observer.rs index 5c30d61..9830609 100644 --- a/src/observer.rs +++ b/src/observer.rs @@ -253,11 +253,21 @@ impl ActiveObserver { path: path.clone(), source, })?; - if input_identity.is_some_and(|input| { - input.pathname_matches(bound.identity(), resolved.identity()) + // The retained anchor protects the slot opened at load time. Re-resolve the original + // pathname as well so a rebound symlink parent cannot expose a replacement as the log. + if let Some(input) = input_identity { + let current_location_matches = input + .current_location_matches(resolved.location()) + .map_err(|source| LogError::Inspect { + path: path.clone(), + source, + })?; + if input.pathname_matches(bound.identity(), resolved.identity()) || input.location_matches(resolved.location()) - }) { - return Err(LogError::InputConflict(path)); + || current_location_matches + { + return Err(LogError::InputConflict(path)); + } } // The compared pathname slot and the log open stay anchored to this directory fd. let descriptor = resolved @@ -275,13 +285,20 @@ impl ActiveObserver { source, })?; let file = File::from(descriptor); + // Repeat the current-path comparison against the opened fd to narrow the rebind window. if let Some(input) = input_identity - && input + && (input .current_leaf_matches(&file) .map_err(|source| LogError::Inspect { path: path.clone(), source, })? + || input + .current_path_matches(&file) + .map_err(|source| LogError::Inspect { + path: path.clone(), + source, + })?) { return Err(LogError::InputConflict(path)); } @@ -683,6 +700,43 @@ mod tests { assert_eq!(fs::read(real.join("input.txt")).unwrap(), expected); } + #[test] + fn semantic_alias_rebinding_cannot_turn_the_current_replacement_into_a_log() { + let directory = tempdir().unwrap(); + let real = directory.path().join("real"); + let child = real.join("child"); + fs::create_dir_all(&child).unwrap(); + fs::write(real.join("input.txt"), "original input").unwrap(); + let alias = directory.path().join("alias"); + std::os::unix::fs::symlink(&child, &alias).unwrap(); + let input = alias.join("..").join("input.txt"); + let document = crate::document::load(input).unwrap(); + + fs::rename(&real, directory.path().join("moved")).unwrap(); + fs::create_dir_all(&child).unwrap(); + fs::write(real.join("input.txt"), "replacement must remain unchanged").unwrap(); + fs::set_permissions(real.join("input.txt"), fs::Permissions::from_mode(0o600)).unwrap(); + let expected = fs::read(real.join("input.txt")).unwrap(); + let lexical_component = directory.path().join("lexical-component"); + fs::create_dir(&lexical_component).unwrap(); + let log_alias = lexical_component + .join("..") + .join("alias") + .join("..") + .join("input.txt"); + let mut observer = Observer::new(Some(log_alias.clone())); + + assert!(matches!( + observer.start( + InputKind::Path, + document.content_len(), + document.input_identity(), + ), + Err(LogError::InputConflict(path)) if path == log_alias + )); + assert_eq!(fs::read(real.join("input.txt")).unwrap(), expected); + } + #[cfg(target_os = "macos")] #[test] fn case_folded_log_aliases_cannot_modify_resolved_replacements() { diff --git a/src/path_binding.rs b/src/path_binding.rs index 640d55b..10cd2df 100644 --- a/src/path_binding.rs +++ b/src/path_binding.rs @@ -78,6 +78,10 @@ impl PathIdentity { self.exact == other.exact } + pub(super) fn exact_path(&self) -> &Path { + &self.exact + } + pub(super) fn normalized_matches(&self, other: &Self) -> bool { self.normalized == other.normalized } diff --git a/src/tui/input.rs b/src/tui/input.rs index 21bbe58..b84ff44 100644 --- a/src/tui/input.rs +++ b/src/tui/input.rs @@ -1,4 +1,4 @@ -use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}; use crate::app::{Action, ContentMode, Geometry, Mode}; @@ -8,6 +8,33 @@ const NON_TEXT_MODIFIERS: KeyModifiers = KeyModifiers::CONTROL .union(KeyModifiers::HYPER) .union(KeyModifiers::META); +fn normalize_key_case(mut key: KeyEvent) -> KeyEvent { + let KeyCode::Char(character) = key.code else { + return key; + }; + if !character.is_ascii_alphabetic() { + return key; + } + + // Legacy and original CSI-u input contain the produced character, while Kitty-style CSI-u's + // primary codepoint is the unshifted key. Crossterm can also consume SHIFT when a Kitty + // alternate keycode supplies the produced character. Treat an uppercase codepoint as the + // consumed SHIFT representation, then apply Caps Lock only to text-like keys. Lock state must + // not change physical Ctrl/Alt shortcut matching (for example, Ctrl-C remains Ctrl-C while + // Caps Lock is on). + let shifted = character.is_ascii_uppercase() || key.modifiers.contains(KeyModifiers::SHIFT); + let uppercase = shifted + ^ (key.state.contains(KeyEventState::CAPS_LOCK) + && !key.modifiers.intersects(NON_TEXT_MODIFIERS)); + key.code = KeyCode::Char(if uppercase { + character.to_ascii_uppercase() + } else { + character.to_ascii_lowercase() + }); + key.modifiers.set(KeyModifiers::SHIFT, uppercase); + key +} + pub(super) fn map_event( mode: &Mode, repeat_active: bool, @@ -24,11 +51,17 @@ pub(super) fn map_event( if !matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) { return None; } + // Crossterm treats `Char('g') + SHIFT` and `Char('G') + SHIFT` as the same key, + // but different terminal protocols can emit either representation. Canonicalize before + // matching commands or inserting search text so their behavior agrees as well. + let key = normalize_key_case(key); if key.code == KeyCode::Char('c') && key.modifiers == KeyModifiers::CONTROL { return Some(Action::Interrupt); } if terminal_too_small { - return (key.code == KeyCode::Char('q') && key.modifiers == KeyModifiers::NONE) + return (key.kind == KeyEventKind::Press + && key.code == KeyCode::Char('q') + && key.modifiers == KeyModifiers::NONE) .then_some(Action::Quit); } @@ -79,6 +112,12 @@ fn map_reading_key(key: KeyEvent, repeat_active: bool) -> Option { (KeyCode::Char('q'), KeyModifiers::NONE) if key.kind == KeyEventKind::Press => { Some(Action::Quit) } + // Kitty can report modifier and lock keys separately. They change the following chord + // rather than acting as unknown commands, so they must not discard an active repeat + // prefix. + (KeyCode::Modifier(_) | KeyCode::CapsLock | KeyCode::ScrollLock | KeyCode::NumLock, _) => { + None + } _ if key.kind == KeyEventKind::Press && repeat_active => Some(Action::RepeatCancel), _ => None, } @@ -123,7 +162,7 @@ fn map_search_key(key: KeyEvent) -> Option { #[cfg(test)] mod tests { - use crossterm::event::{KeyEventState, MouseEvent, MouseEventKind}; + use crossterm::event::{ModifierKeyCode, MouseEvent, MouseEventKind}; use super::*; @@ -137,11 +176,24 @@ mod tests { } fn repeated_key(code: KeyCode, modifiers: KeyModifiers) -> Event { + key_with_kind_and_state(code, modifiers, KeyEventKind::Repeat, KeyEventState::NONE) + } + + fn key_with_state(code: KeyCode, modifiers: KeyModifiers, state: KeyEventState) -> Event { + key_with_kind_and_state(code, modifiers, KeyEventKind::Press, state) + } + + fn key_with_kind_and_state( + code: KeyCode, + modifiers: KeyModifiers, + kind: KeyEventKind, + state: KeyEventState, + ) -> Event { Event::Key(KeyEvent { code, modifiers, - kind: KeyEventKind::Repeat, - state: KeyEventState::NONE, + kind, + state, }) } @@ -251,6 +303,21 @@ mod tests { map(true, key(KeyCode::Char('?'), KeyModifiers::SHIFT)), Some(Action::RepeatCancel) ); + for (code, modifiers) in [ + (ModifierKeyCode::LeftShift, KeyModifiers::SHIFT), + (ModifierKeyCode::RightControl, KeyModifiers::CONTROL), + (ModifierKeyCode::LeftAlt, KeyModifiers::ALT), + (ModifierKeyCode::RightSuper, KeyModifiers::SUPER), + (ModifierKeyCode::LeftHyper, KeyModifiers::HYPER), + (ModifierKeyCode::RightMeta, KeyModifiers::META), + (ModifierKeyCode::IsoLevel3Shift, KeyModifiers::NONE), + (ModifierKeyCode::IsoLevel5Shift, KeyModifiers::NONE), + ] { + assert_eq!(map(true, key(KeyCode::Modifier(code), modifiers)), None); + } + for code in [KeyCode::CapsLock, KeyCode::ScrollLock, KeyCode::NumLock] { + assert_eq!(map(true, key(code, KeyModifiers::NONE)), None); + } assert_eq!( map(true, key(KeyCode::Char('g'), KeyModifiers::NONE)), Some(Action::DocumentStart) @@ -315,10 +382,19 @@ mod tests { map_event(&mode, false, key(KeyCode::Esc, KeyModifiers::NONE)), Some(Action::SearchCancel) ); - assert_eq!( - map_event(&mode, false, key(KeyCode::Char('x'), KeyModifiers::CONTROL)), - None - ); + for modifiers in [ + KeyModifiers::CONTROL, + KeyModifiers::ALT, + KeyModifiers::SUPER, + KeyModifiers::HYPER, + KeyModifiers::META, + KeyModifiers::SHIFT | KeyModifiers::ALT, + ] { + assert_eq!( + map_event(&mode, false, key(KeyCode::Char('x'), modifiers)), + None + ); + } assert_eq!( map_event(&mode, false, repeated_key(KeyCode::Up, KeyModifiers::NONE)), None @@ -333,6 +409,171 @@ mod tests { ); } + #[test] + fn csi_u_case_representations_are_normalized_before_mapping() { + assert_eq!( + map_event( + &reading_mode(), + false, + key(KeyCode::Char('g'), KeyModifiers::SHIFT) + ), + Some(Action::DocumentEnd) + ); + assert_eq!( + map_event( + &reading_mode(), + false, + key(KeyCode::Char('N'), KeyModifiers::NONE) + ), + Some(Action::PreviousMatch) + ); + assert_eq!( + map_event( + &reading_mode(), + false, + key(KeyCode::Char('q'), KeyModifiers::SHIFT) + ), + None + ); + + let search = Mode::Content(ContentMode::SearchInput { + draft: String::new(), + limit_hit: false, + }); + assert_eq!( + map_event(&search, false, key(KeyCode::Char('a'), KeyModifiers::SHIFT)), + Some(Action::SearchInsert('A')) + ); + } + + #[test] + fn reader_commands_and_interrupt_require_exact_physical_modifiers() { + for modifiers in [ + KeyModifiers::ALT, + KeyModifiers::CONTROL, + KeyModifiers::SUPER, + KeyModifiers::HYPER, + KeyModifiers::META, + KeyModifiers::SHIFT | KeyModifiers::ALT, + KeyModifiers::SHIFT | KeyModifiers::CONTROL, + ] { + for character in ['g', 'n', 'q'] { + assert_eq!( + map_event( + &reading_mode(), + false, + key(KeyCode::Char(character), modifiers), + ), + None + ); + } + } + + for modifiers in [ + KeyModifiers::NONE, + KeyModifiers::SHIFT, + KeyModifiers::ALT, + KeyModifiers::SUPER, + KeyModifiers::HYPER, + KeyModifiers::META, + KeyModifiers::CONTROL | KeyModifiers::ALT, + KeyModifiers::CONTROL | KeyModifiers::SHIFT, + ] { + assert_eq!( + map_event(&reading_mode(), false, key(KeyCode::Char('c'), modifiers),), + None + ); + } + assert_eq!( + map_event( + &reading_mode(), + false, + key(KeyCode::Char('c'), KeyModifiers::CONTROL), + ), + Some(Action::Interrupt) + ); + } + + #[test] + fn kitty_caps_lock_state_preserves_text_case_without_changing_control_shortcuts() { + let caps_lock = KeyEventState::CAPS_LOCK; + assert_eq!( + map_event( + &reading_mode(), + false, + key_with_state(KeyCode::Char('g'), KeyModifiers::NONE, caps_lock), + ), + Some(Action::DocumentEnd) + ); + assert_eq!( + map_event( + &reading_mode(), + false, + key_with_state(KeyCode::Char('n'), KeyModifiers::NONE, caps_lock), + ), + Some(Action::PreviousMatch) + ); + assert_eq!( + map_event( + &reading_mode(), + false, + key_with_state(KeyCode::Char('q'), KeyModifiers::NONE, caps_lock), + ), + None + ); + assert_eq!( + map_event( + &reading_mode(), + false, + key_with_state(KeyCode::Char('g'), KeyModifiers::SHIFT, caps_lock), + ), + Some(Action::DocumentStart) + ); + assert_eq!( + map_event( + &reading_mode(), + false, + key_with_state(KeyCode::Char('q'), KeyModifiers::SHIFT, caps_lock), + ), + Some(Action::Quit) + ); + + for (modifiers, expected) in [ + (KeyModifiers::CONTROL, Some(Action::Interrupt)), + (KeyModifiers::CONTROL | KeyModifiers::SHIFT, None), + ] { + assert_eq!( + map_event( + &reading_mode(), + false, + key_with_state(KeyCode::Char('c'), modifiers, caps_lock), + ), + expected + ); + } + + let search = Mode::Content(ContentMode::SearchInput { + draft: String::new(), + limit_hit: false, + }); + assert_eq!( + map_event( + &search, + false, + key_with_state(KeyCode::Char('a'), KeyModifiers::NONE, caps_lock), + ), + Some(Action::SearchInsert('A')) + ); + assert_eq!( + map_event( + &search, + false, + key_with_state(KeyCode::Char('a'), KeyModifiers::SHIFT, caps_lock), + ), + Some(Action::SearchInsert('a')) + ); + } + #[test] fn help_mode_maps_dismissal_but_not_reader_commands() { for event in [ @@ -442,6 +683,14 @@ mod tests { ), Some(Action::Quit) ); + assert_eq!( + map_event( + &reading_mode(), + true, + repeated_key(KeyCode::Char('q'), KeyModifiers::NONE) + ), + None + ); let search = Mode::Content(ContentMode::SearchInput { draft: String::new(), limit_hit: false, diff --git a/tests/cli.rs b/tests/cli.rs index a209194..b3176ef 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -168,8 +168,8 @@ mod pty { const SHOW_CURSOR: &[u8] = b"\x1b[?25h"; const NESTED_RAW_HELPER: &str = "TUT_TEST_NESTED_RAW_HELPER"; const REUSABLE_RUN_HELPER: &str = "TUT_TEST_REUSABLE_RUN_HELPER"; + const REUSABLE_RUN_FD: &str = "TUT_TEST_REUSABLE_RUN_FD"; const FIRST_RUN_EOF: &[u8] = b"TUT_TEST_FIRST_RUN_EOF"; - const REPLACEMENT_TTY_FD: libc::c_int = 200; const TIMEOUT: Duration = Duration::from_secs(5); fn reset_signal_state() -> io::Result<()> { @@ -368,19 +368,17 @@ mod pty { "--nocapture", ]) .env(REUSABLE_RUN_HELPER, path) + .env(REUSABLE_RUN_FD, replacement_fd.to_string()) .stdin(Stdio::from(terminal_input)) .stdout(Stdio::from(stdout)) .stderr(Stdio::piped()); configure_child_session(&mut command, Some(controlling_name)); - // The child setup only duplicates an already-open descriptor onto a fixed inherited - // slot. dup2 and fcntl are async-signal-safe on both supported test targets. + // The child setup only clears close-on-exec on an already-open descriptor. fcntl is + // async-signal-safe on both supported test targets. Passing the allocated descriptor + // avoids assuming that a fixed high slot is below the process file-descriptor limit. unsafe { command.pre_exec(move || { - let result = if replacement_fd == REPLACEMENT_TTY_FD { - libc::fcntl(REPLACEMENT_TTY_FD, libc::F_SETFD, 0) - } else { - libc::dup2(replacement_fd, REPLACEMENT_TTY_FD) - }; + let result = libc::fcntl(replacement_fd, libc::F_SETFD, 0); if result == -1 { return Err(io::Error::last_os_error()); } @@ -851,7 +849,7 @@ mod pty { assert_terminal_configuration(&restored, &initial); } - fn exercise_reusable_run(path: &Path) { + fn exercise_reusable_run(path: &Path, replacement_fd: libc::c_int) { let first = tut::run([path.as_os_str().to_owned()]).unwrap_err(); match first { tut::TutError::Io { operation, source } => { @@ -862,9 +860,9 @@ mod pty { error => panic!("expected terminal EOF from the first run, got {error:?}"), } - // SAFETY: the parent installed this owned inherited descriptor before exec and invokes - // this branch only in the dedicated helper process. File takes over the inherited slot. - let replacement = unsafe { File::from_raw_fd(REPLACEMENT_TTY_FD) }; + // SAFETY: the parent preserved this owned descriptor across exec and invokes this branch + // only in the dedicated helper process. File takes over the inherited descriptor. + let replacement = unsafe { File::from_raw_fd(replacement_fd) }; rustix::stdio::dup2_stdin(&replacement).unwrap(); drop(replacement); io::stdout().write_all(FIRST_RUN_EOF).unwrap(); @@ -902,7 +900,11 @@ mod pty { #[test] fn public_run_rebinds_terminal_input_after_eof() { if let Some(path) = std::env::var_os(REUSABLE_RUN_HELPER) { - exercise_reusable_run(Path::new(&path)); + let replacement_fd = std::env::var(REUSABLE_RUN_FD) + .expect("reusable-run helper descriptor is configured") + .parse() + .expect("reusable-run helper descriptor is numeric"); + exercise_reusable_run(Path::new(&path), replacement_fd); return; } @@ -1110,6 +1112,82 @@ mod pty { pty.assert_restored(); } + #[test] + fn zero_mouse_coordinates_remain_bounded_through_a_real_terminal() { + let file = NamedTempFile::new().unwrap(); + std::fs::write(file.path(), "coordinate input\n").unwrap(); + + let mut pty = PtyChild::spawn(file.path()).unwrap(); + pty.wait_for(HIDE_CURSOR).unwrap(); + pty.master + .write_all(b"\x1b[M\x20\x20\x20\x1b[<0;0;0Mq") + .unwrap(); + + assert_eq!(pty.wait().unwrap().code(), Some(0)); + assert!(pty.stderr_output.is_empty()); + pty.assert_restored(); + } + + #[test] + fn enhanced_keyboard_letters_preserve_commands_search_case_and_caps_lock() { + let file = NamedTempFile::new().unwrap(); + let mut text = String::new(); + for line in 0..100 { + match line { + 0 => text.push_str("START_SENTINEL\n"), + 30 => text.push_str("ALPHA? FIRST_ENHANCED_KEYBOARD_TARGET\n"), + 50 => text.push_str("ALPHA? PREFIX_PRESERVED_TARGET\n"), + 70 => text.push_str("ALPHA? SINGLE_PREVIOUS_TARGET\n"), + 99 => text.push_str("END_SENTINEL\n"), + _ => text.push_str("ordinary line\n"), + } + } + std::fs::write(file.path(), text).unwrap(); + + let mut pty = PtyChild::spawn(file.path()).unwrap(); + pty.wait_for(b"START_SENTINEL").unwrap(); + // Kitty alternate-key reporting supplies the shifted codepoint and Crossterm consumes the + // Shift modifier. The command must still agree with legacy uppercase G. + pty.master.write_all(b"\x1b[103:71;2:2u").unwrap(); + pty.wait_for(b"END_SENTINEL").unwrap(); + + // With all-key reporting, Caps Lock is state rather than part of the primary codepoint. + // Caps+Shift+G produces lowercase g and Caps+G produces uppercase G. + let start = pty.output.len(); + pty.master.write_all(b"\x1b[103;66u").unwrap(); + pty.wait_for_after(start, b"START_SENTINEL").unwrap(); + let end = pty.output.len(); + pty.master.write_all(b"\x1b[103;65u").unwrap(); + pty.wait_for_after(end, b"END_SENTINEL").unwrap(); + + let search_start = pty.output.len(); + // Kitty CSI-u without alternate reporting encodes shifted letters as lower-case codepoints + // plus Shift. Repeat text is inserted, release text is ignored, and an alternate codepoint + // preserves shifted punctuation after Crossterm consumes its Shift modifier. + pty.master + .write_all(b"/\x1b[97;2u\x1b[108;2:2uPHA\x1b[120;1:3u\x1b[47:63;2u\r") + .unwrap(); + pty.wait_for_after(search_start, b"FIRST_ENHANCED_KEYBOARD_TARGET") + .unwrap(); + + // A separately reported modifier press belongs to the following chord and must not clear + // the numeric prefix. Two previous-match steps select the middle of the three matches; if + // the modifier event cleared the prefix, the single-previous sentinel would be selected. + let prefix_start = pty.output.len(); + pty.master.write_all(b"2\x1b[57441;2u\x1b[110;2u").unwrap(); + pty.wait_for_after(prefix_start, b"PREFIX_PRESERVED_TARGET") + .unwrap(); + + // Caps+Q is uppercase text, not the unmodified quit command. + pty.master.write_all(b"\x1b[113;65ug").unwrap(); + pty.wait_for_after(search_start, b"START_SENTINEL").unwrap(); + pty.master.write_all(b"q").unwrap(); + + assert_eq!(pty.wait().unwrap().code(), Some(0)); + assert!(pty.stderr_output.is_empty()); + pty.assert_restored(); + } + #[test] fn search_draft_recall_clear_and_replace_work_through_a_real_terminal() { let file = NamedTempFile::new().unwrap(); diff --git a/tests/documentation.rs b/tests/documentation.rs index 88c0319..5c9bd51 100644 --- a/tests/documentation.rs +++ b/tests/documentation.rs @@ -6,6 +6,7 @@ const PROJECT: &str = include_str!("../docs/PROJECT.md"); const CHANGELOG: &str = include_str!("../CHANGELOG.md"); const CONTRIBUTING: &str = include_str!("../CONTRIBUTING.md"); const SECURITY: &str = include_str!("../SECURITY.md"); +const SECURITY_AUDIT: &str = include_str!("../.github/workflows/security-audit.yml"); const MANIFEST: &str = include_str!("../Cargo.toml"); fn plain_manual() -> String { @@ -126,3 +127,24 @@ fn release_and_maintenance_contracts_ship_with_the_crate() { assert!(SECURITY.contains("contact@m1ng.space")); assert!(SECURITY.contains("Do not open a public issue")); } + +#[test] +fn dependency_audit_commands_stay_pinned_and_discoverable() { + for contract in [ + "cargo-audit --locked --version '=0.22.2'", + "audit --file Cargo.lock", + ] { + assert!( + CONTRIBUTING.contains(contract), + "contributor guidance omits {contract}" + ); + assert!( + SECURITY_AUDIT.contains(contract), + "security audit workflow omits {contract}" + ); + } + assert!( + README.contains("cargo audit --file Cargo.lock"), + "README local checks omit the dependency audit gate" + ); +}