use std::fs;
use std::io;
use std::path::PathBuf;
const SUBDIR: &str = "pty-sessions";
const HISTORY_FILE: &str = "history.ansi";
const TMP_FILE: &str = "history.ansi.tmp";
fn session_dir(key: &str) -> Option<PathBuf> {
if key.is_empty() || !key.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
return None;
}
let home = home_dir()?;
Some(home.join(".omniterm").join(SUBDIR).join(key))
}
fn home_dir() -> Option<PathBuf> {
#[cfg(unix)]
{
std::env::var_os("HOME").map(PathBuf::from)
}
#[cfg(windows)]
{
std::env::var_os("USERPROFILE").map(PathBuf::from)
}
}
pub fn history_path(key: &str) -> Option<PathBuf> {
session_dir(key).map(|d| d.join(HISTORY_FILE))
}
pub fn save(key: &str, bytes: &[u8]) -> io::Result<()> {
let Some(dir) = session_dir(key) else {
return Err(io::Error::new(io::ErrorKind::InvalidInput, "invalid session key"));
};
fs::create_dir_all(&dir)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&dir, fs::Permissions::from_mode(0o700))?;
}
let tmp = dir.join(TMP_FILE);
fs::write(&tmp, bytes)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&tmp, fs::Permissions::from_mode(0o600))?;
}
fs::rename(&tmp, dir.join(HISTORY_FILE))
}
pub fn load(key: &str) -> Option<Vec<u8>> {
let path = history_path(key)?;
let bytes = fs::read(path).ok()?;
Some(trim_to_utf8_boundary(bytes))
}
pub fn remove(key: &str) {
if let Some(dir) = session_dir(key) {
let _ = fs::remove_dir_all(dir);
}
}
fn trim_to_utf8_boundary(mut bytes: Vec<u8>) -> Vec<u8> {
let mut skip = 0;
while skip < bytes.len().min(4) && is_continuation(bytes[skip]) {
skip += 1;
}
if skip > 0 {
bytes.drain(..skip);
}
bytes
}
fn is_continuation(b: u8) -> bool {
b & 0b1100_0000 == 0b1000_0000
}
#[cfg(test)]
mod tests {
use super::*;
fn unique_key(tag: &str) -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let nanos = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos();
format!("test-{tag}-{nanos:x}")
}
#[test]
fn save_load_roundtrip() {
let key = unique_key("rt");
save(&key, b"hello \x1b[31mworld\x1b[0m").unwrap();
let loaded = load(&key).expect("history must load");
assert_eq!(loaded, b"hello \x1b[31mworld\x1b[0m");
remove(&key);
assert!(load(&key).is_none());
}
#[cfg(unix)]
#[test]
fn history_file_is_0600() {
use std::os::unix::fs::PermissionsExt;
let key = unique_key("perm");
save(&key, b"secret-ish terminal output").unwrap();
let path = history_path(&key).unwrap();
let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "history file must be owner-only readable");
remove(&key);
}
#[test]
fn trims_leading_utf8_continuation_bytes() {
let trimmed = trim_to_utf8_boundary(vec![0xB1, 0x89, b'o', b'k']);
assert_eq!(trimmed, b"ok");
assert_eq!(trim_to_utf8_boundary(b"ok".to_vec()), b"ok");
assert!(trim_to_utf8_boundary(vec![0x80, 0x80, 0x80, 0x80]).is_empty());
}
#[test]
fn rejects_path_traversal_keys() {
assert!(session_dir("../evil").is_none());
assert!(session_dir("a/b").is_none());
assert!(session_dir("").is_none());
assert!(history_path("..").is_none());
}
#[test]
fn load_missing_is_none() {
assert!(load(&unique_key("missing")).is_none());
}
}