Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .cargo/config.toml
Original file line number Diff line number Diff line change
@@ -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" }
33 changes: 33 additions & 0 deletions .github/workflows/security-audit.yml
Original file line number Diff line number Diff line change
@@ -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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,25 @@ 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
accounting budget and over-budget frames are rejected before publication.
- 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

Expand Down Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "tut"
version = "0.0.5"
version = "0.0.6"
edition = "2024"
rust-version = "1.88.0"
publish = false
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand Down
20 changes: 10 additions & 10 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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(_))));
}
Expand Down Expand Up @@ -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));
Expand All @@ -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());
Expand Down Expand Up @@ -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(_))));
Expand Down Expand Up @@ -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(_))),
Expand All @@ -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(_))));
Expand All @@ -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),
Expand Down
72 changes: 70 additions & 2 deletions src/document.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1242,6 +1242,21 @@ pub(super) struct InputIdentity<'a> {
}

impl InputIdentity<'_> {
fn current_path(self) -> std::io::Result<Option<ResolvedPath>> {
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
Expand All @@ -1253,13 +1268,31 @@ impl InputIdentity<'_> {
self.path.resolved.location() == location
}

pub(super) fn current_location_matches(
self,
location: &ResolvedPathLocation,
) -> std::io::Result<bool> {
Ok(self
.current_path()?
.is_some_and(|current| current.location() == location))
}

pub(super) fn current_leaf_matches(
self,
file: &impl std::os::fd::AsFd,
) -> std::io::Result<bool> {
self.path.resolved.current_leaf_matches(file)
}

pub(super) fn current_path_matches(
self,
file: &impl std::os::fd::AsFd,
) -> std::io::Result<bool> {
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)
}
Expand Down Expand Up @@ -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<Self, LoadError> {
let bound = BoundPath::capture(&path).map_err(|source| LoadError::Open {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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),
Expand Down
4 changes: 2 additions & 2 deletions src/layout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down
6 changes: 4 additions & 2 deletions src/locator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down Expand Up @@ -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),
Expand Down
Loading