use crate::replay::recorder::{ReplayEvent, ReplayEventKind};
const MAX_REPLAY_BYTES: usize = 512 * 1024 * 1024;
const MAGIC: &[u8; 4] = b"LTR1";
const MAGIC_ZSTD: &[u8; 4] = b"LTRZ";
const TAG_PTY_BYTES: u8 = 0;
const TAG_RESIZE: u8 = 1;
const TAG_SNAPSHOT: u8 = 2;
pub enum EventRef<'a> {
PtyBytes(&'a [u8]),
Resize { cols: u16, rows: u16 },
Snapshot(&'a str),
}
pub fn write_ltr(path: &str, events: &[ReplayEvent]) -> Result<(), Box<dyn std::error::Error>> {
let buf = encode_ltr(events)?;
std::fs::write(path, &buf)?;
Ok(())
}
pub fn write_ltr_zst(path: &str, events: &[ReplayEvent]) -> Result<(), Box<dyn std::error::Error>> {
let buf = encode_ltr(events)?;
let compressed = zstd_encode(&buf[4..])?;
let mut out = Vec::with_capacity(4 + compressed.len());
out.extend_from_slice(MAGIC_ZSTD);
out.extend_from_slice(&compressed);
std::fs::write(path, &out)?;
Ok(())
}
pub fn read_ltr(path: &str) -> Result<Vec<ReplayEvent>, Box<dyn std::error::Error>> {
let data = read_replay_file(path)?;
if data.len() < 4 {
return Err("truncated LTR file".into());
}
let (magic, rest) = data.split_at(4);
let decoded = if magic == MAGIC_ZSTD {
zstd_decode(rest)?
} else if magic == MAGIC {
rest.to_vec()
} else {
return Err("invalid LTR magic".into());
};
decode_ltr_payload_or_full(&decoded)
}
fn encode_ltr(events: &[ReplayEvent]) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
let mut buf = Vec::new();
buf.extend_from_slice(MAGIC);
for ev in events {
buf.push(match &ev.kind {
ReplayEventKind::PtyBytes(_) => TAG_PTY_BYTES,
ReplayEventKind::Resize { .. } => TAG_RESIZE,
ReplayEventKind::Snapshot(_) => TAG_SNAPSHOT,
});
buf.extend_from_slice(&ev.timestamp_ms.to_le_bytes());
match &ev.kind {
ReplayEventKind::PtyBytes(data) => {
let len = data.len();
if len > u32::MAX as usize {
return Err("PtyBytes too large".into());
}
buf.extend_from_slice(&(len as u32).to_le_bytes());
buf.extend_from_slice(data);
}
ReplayEventKind::Resize { cols, rows } => {
buf.extend_from_slice(&cols.to_le_bytes());
buf.extend_from_slice(&rows.to_le_bytes());
}
ReplayEventKind::Snapshot(label) => {
let label_bytes = label.as_bytes();
let len = label_bytes.len();
if len > u32::MAX as usize {
return Err("Snapshot label too large".into());
}
buf.extend_from_slice(&(len as u32).to_le_bytes());
buf.extend_from_slice(label_bytes);
}
}
}
Ok(buf)
}
fn decode_ltr(data: &[u8]) -> Result<Vec<ReplayEvent>, Box<dyn std::error::Error>> {
let mut events = Vec::new();
let mut pos = 0usize;
while pos < data.len() {
let tag = read_u8(data, &mut pos, "tag")?;
let timestamp_ms = read_u64_le(data, &mut pos, "timestamp")?;
match tag {
TAG_PTY_BYTES => {
let len = read_u32_le(data, &mut pos, "PtyBytes length")? as usize;
let bytes = take(data, &mut pos, len, "PtyBytes data")?.to_vec();
events.push(ReplayEvent {
timestamp_ms,
kind: ReplayEventKind::PtyBytes(bytes),
});
}
TAG_RESIZE => {
let cols = read_u16_le(data, &mut pos, "Resize cols")?;
let rows = read_u16_le(data, &mut pos, "Resize rows")?;
events.push(ReplayEvent {
timestamp_ms,
kind: ReplayEventKind::Resize { cols, rows },
});
}
TAG_SNAPSHOT => {
let len = read_u32_le(data, &mut pos, "Snapshot length")? as usize;
let label =
String::from_utf8(take(data, &mut pos, len, "Snapshot label")?.to_vec())
.map_err(|_| "invalid UTF-8 in Snapshot label")?;
events.push(ReplayEvent {
timestamp_ms,
kind: ReplayEventKind::Snapshot(label),
});
}
_ => return Err(format!("unknown event tag: {}", tag).into()),
}
}
Ok(events)
}
fn decode_ltr_payload_or_full(data: &[u8]) -> Result<Vec<ReplayEvent>, Box<dyn std::error::Error>> {
if data.len() >= 4 && &data[..4] == MAGIC {
decode_ltr(&data[4..])
} else {
decode_ltr(data)
}
}
pub struct LtrReader<'a> {
data: &'a [u8],
pos: usize,
}
impl<'a> LtrReader<'a> {
pub fn new(data: &'a [u8]) -> Result<Self, Box<dyn std::error::Error>> {
if data.len() < 4 {
return Err("truncated LTR".into());
}
let (magic, rest) = data.split_at(4);
if magic != MAGIC && magic != MAGIC_ZSTD {
return Err("invalid LTR magic".into());
}
if magic == MAGIC_ZSTD {
return Err("streaming reader requires uncompressed data; use read_ltr() first".into());
}
Ok(Self { data: rest, pos: 0 })
}
pub fn next_event(&mut self) -> Result<Option<EventRef<'_>>, Box<dyn std::error::Error>> {
if self.pos >= self.data.len() {
return Ok(None);
}
let tag = read_u8(self.data, &mut self.pos, "tag")?;
let _timestamp_ms = read_u64_le(self.data, &mut self.pos, "timestamp")?;
match tag {
TAG_PTY_BYTES => {
let len = read_u32_le(self.data, &mut self.pos, "PtyBytes length")? as usize;
let slice = take(self.data, &mut self.pos, len, "PtyBytes data")?;
Ok(Some(EventRef::PtyBytes(slice)))
}
TAG_RESIZE => {
let cols = read_u16_le(self.data, &mut self.pos, "Resize cols")?;
let rows = read_u16_le(self.data, &mut self.pos, "Resize rows")?;
Ok(Some(EventRef::Resize { cols, rows }))
}
TAG_SNAPSHOT => {
let len = read_u32_le(self.data, &mut self.pos, "Snapshot length")? as usize;
let s = std::str::from_utf8(take(self.data, &mut self.pos, len, "Snapshot label")?)
.map_err(|_| "invalid UTF-8 in Snapshot label")?;
Ok(Some(EventRef::Snapshot(s)))
}
_ => Err(format!("unknown event tag: {}", tag).into()),
}
}
}
pub fn convert_jsonl_to_ltr(
jsonl_path: &str,
ltr_path: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let events = super::recorder::read_jsonl(jsonl_path)?;
write_ltr(ltr_path, &events)
}
pub fn fnv1a64(data: &[u8]) -> u64 {
let mut hash: u64 = 0xcbf29ce484222325;
for &b in data {
hash ^= b as u64;
hash = hash.wrapping_mul(0x100000001b3);
}
hash
}
pub fn grid_hash(grid: &crate::terminal::Grid) -> u64 {
let mut hasher = Fnv1aState::new();
for r in 0..grid.rows() {
for c in 0..grid.cols() {
if let Some(cell) = grid.cell(r, c) {
hash_cell(&mut hasher, cell);
}
}
}
hasher.finish()
}
pub fn scrollback_hash(sb: &crate::terminal::Scrollback) -> u64 {
let mut hasher = Fnv1aState::new();
for i in 0..sb.len() {
if let Some(row) = sb.row_cells(i) {
for cell in row {
hash_cell(&mut hasher, cell);
}
}
}
hasher.finish()
}
fn hash_cell(hasher: &mut Fnv1aState, cell: &crate::terminal::Cell) {
let mut buf = [0u8; 4];
let encoded = cell.c.encode_utf8(&mut buf);
hasher.write(&[encoded.len() as u8]);
hasher.write(encoded.as_bytes());
if let Some(extra) = &cell.extra {
hasher.write(extra.as_bytes());
}
hasher.write(&cell.fg.to_le_bytes());
hasher.write(&cell.bg.to_le_bytes());
hasher.write(&cell.attrs.to_le_bytes());
if cell.hyperlink_id != 0 {
hasher.write(&cell.hyperlink_id.to_le_bytes());
}
}
struct Fnv1aState(u64);
impl Fnv1aState {
fn new() -> Self {
Self(0xcbf29ce484222325)
}
fn write(&mut self, data: &[u8]) {
for &b in data {
self.0 ^= b as u64;
self.0 = self.0.wrapping_mul(0x100000001b3);
}
}
fn finish(&self) -> u64 {
self.0
}
}
pub fn read_any(path: &str) -> Result<Vec<ReplayEvent>, Box<dyn std::error::Error>> {
let data = read_replay_file(path)?;
if data.len() < 4 {
return Err("truncated replay file".into());
}
let magic = &data[..4];
if magic == MAGIC || magic == MAGIC_ZSTD {
let decoded = if magic == MAGIC_ZSTD {
let rest = &data[4..];
zstd_decode(rest)?
} else {
data[4..].to_vec()
};
decode_ltr_payload_or_full(&decoded)
} else {
let content = String::from_utf8(data).map_err(|_| "not valid UTF-8 for JSONL")?;
let mut events = Vec::new();
for line in content.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with("//") {
continue;
}
let event: ReplayEvent = serde_json::from_str(trimmed)?;
events.push(event);
}
Ok(events)
}
}
fn zstd_encode(data: &[u8]) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
#[cfg(feature = "compression")]
{
Ok(zstd::encode_all(std::io::Cursor::new(data), 3)?)
}
#[cfg(not(feature = "compression"))]
{
let _ = data;
Err("zstd compression not enabled (add feature 'compression')".into())
}
}
fn zstd_decode(data: &[u8]) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
#[cfg(feature = "compression")]
{
let decoder = zstd::Decoder::new(std::io::Cursor::new(data))?;
let mut limited = std::io::Read::take(decoder, MAX_REPLAY_BYTES as u64 + 1);
let mut out = Vec::new();
std::io::Read::read_to_end(&mut limited, &mut out)?;
if out.len() > MAX_REPLAY_BYTES {
return Err(format!(
"decompressed replay is too large (limit: {} bytes)",
MAX_REPLAY_BYTES
)
.into());
}
Ok(out)
}
#[cfg(not(feature = "compression"))]
{
let _ = data;
Err("zstd decompression not enabled (add feature 'compression')".into())
}
}
fn read_replay_file(path: &str) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
let metadata = std::fs::metadata(path)?;
if metadata.len() > MAX_REPLAY_BYTES as u64 {
return Err(format!(
"replay file is too large: {} bytes (limit: {} bytes)",
metadata.len(),
MAX_REPLAY_BYTES
)
.into());
}
let data = std::fs::read(path)?;
if data.len() > MAX_REPLAY_BYTES {
return Err(format!(
"replay file is too large: {} bytes (limit: {} bytes)",
data.len(),
MAX_REPLAY_BYTES
)
.into());
}
Ok(data)
}
fn read_u8(data: &[u8], pos: &mut usize, label: &str) -> Result<u8, Box<dyn std::error::Error>> {
Ok(take(data, pos, 1, label)?[0])
}
fn read_u16_le(
data: &[u8],
pos: &mut usize,
label: &str,
) -> Result<u16, Box<dyn std::error::Error>> {
let mut bytes = [0u8; 2];
bytes.copy_from_slice(take(data, pos, 2, label)?);
Ok(u16::from_le_bytes(bytes))
}
fn read_u32_le(
data: &[u8],
pos: &mut usize,
label: &str,
) -> Result<u32, Box<dyn std::error::Error>> {
let mut bytes = [0u8; 4];
bytes.copy_from_slice(take(data, pos, 4, label)?);
Ok(u32::from_le_bytes(bytes))
}
fn read_u64_le(
data: &[u8],
pos: &mut usize,
label: &str,
) -> Result<u64, Box<dyn std::error::Error>> {
let mut bytes = [0u8; 8];
bytes.copy_from_slice(take(data, pos, 8, label)?);
Ok(u64::from_le_bytes(bytes))
}
fn take<'a>(
data: &'a [u8],
pos: &mut usize,
len: usize,
label: &str,
) -> Result<&'a [u8], Box<dyn std::error::Error>> {
let end = pos
.checked_add(len)
.ok_or_else(|| format!("overflow while reading {label}"))?;
if end > data.len() {
return Err(format!("truncated: {label}").into());
}
let slice = &data[*pos..end];
*pos = end;
Ok(slice)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::replay::recorder::*;
fn make_event(timestamp_ms: u64, kind: ReplayEventKind) -> ReplayEvent {
ReplayEvent { timestamp_ms, kind }
}
fn roundtrip(events: &[ReplayEvent]) -> Vec<ReplayEvent> {
let buf = encode_ltr(events).unwrap();
let decoded = decode_ltr(&buf[4..]).unwrap(); decoded
}
#[test]
fn test_encode_decode_pty_bytes() {
let events = vec![make_event(0, ReplayEventKind::PtyBytes(b"hello".to_vec()))];
let decoded = roundtrip(&events);
assert_eq!(decoded.len(), 1);
if let ReplayEventKind::PtyBytes(data) = &decoded[0].kind {
assert_eq!(data, b"hello");
} else {
panic!("wrong variant");
}
assert_eq!(decoded[0].timestamp_ms, 0);
}
#[test]
fn test_encode_decode_resize() {
let events = vec![make_event(
42,
ReplayEventKind::Resize {
cols: 120,
rows: 40,
},
)];
let decoded = roundtrip(&events);
assert_eq!(decoded.len(), 1);
if let ReplayEventKind::Resize { cols, rows } = decoded[0].kind {
assert_eq!(cols, 120);
assert_eq!(rows, 40);
} else {
panic!("wrong variant");
}
assert_eq!(decoded[0].timestamp_ms, 42);
}
#[test]
fn test_encode_decode_snapshot() {
let events = vec![make_event(
100,
ReplayEventKind::Snapshot("vim_enter".into()),
)];
let decoded = roundtrip(&events);
assert_eq!(decoded.len(), 1);
if let ReplayEventKind::Snapshot(label) = &decoded[0].kind {
assert_eq!(label, "vim_enter");
} else {
panic!("wrong variant");
}
assert_eq!(decoded[0].timestamp_ms, 100);
}
#[test]
fn test_event_ordering_preserved() {
let events = vec![
make_event(0, ReplayEventKind::PtyBytes(b"a".to_vec())),
make_event(1, ReplayEventKind::Resize { cols: 80, rows: 24 }),
make_event(2, ReplayEventKind::PtyBytes(b"b".to_vec())),
make_event(3, ReplayEventKind::Snapshot("end".into())),
];
let decoded = roundtrip(&events);
assert_eq!(decoded.len(), 4);
assert!(matches!(decoded[0].kind, ReplayEventKind::PtyBytes(_)));
assert!(matches!(decoded[1].kind, ReplayEventKind::Resize { .. }));
assert!(matches!(decoded[2].kind, ReplayEventKind::PtyBytes(_)));
assert!(matches!(decoded[3].kind, ReplayEventKind::Snapshot(_)));
assert_eq!(decoded[0].timestamp_ms, 0);
assert_eq!(decoded[1].timestamp_ms, 1);
assert_eq!(decoded[2].timestamp_ms, 2);
assert_eq!(decoded[3].timestamp_ms, 3);
}
#[test]
fn test_empty_events() {
let events: Vec<ReplayEvent> = vec![];
let decoded = roundtrip(&events);
assert!(decoded.is_empty());
}
#[test]
fn test_many_events() {
let mut events = Vec::new();
for i in 0..1000 {
events.push(make_event(
i as u64,
ReplayEventKind::PtyBytes(vec![i as u8]),
));
}
let decoded = roundtrip(&events);
assert_eq!(decoded.len(), 1000);
for (i, ev) in decoded.iter().enumerate() {
assert_eq!(ev.timestamp_ms, i as u64);
}
}
#[test]
fn test_large_pty_bytes() {
let data = vec![0xABu8; 65535];
let events = vec![make_event(0, ReplayEventKind::PtyBytes(data.clone()))];
let decoded = roundtrip(&events);
if let ReplayEventKind::PtyBytes(d) = &decoded[0].kind {
assert_eq!(d.len(), 65535);
assert_eq!(d[0], 0xAB);
assert_eq!(d[65534], 0xAB);
} else {
panic!("wrong variant");
}
}
#[test]
fn test_empty_pty_bytes() {
let events = vec![make_event(0, ReplayEventKind::PtyBytes(vec![]))];
let decoded = roundtrip(&events);
if let ReplayEventKind::PtyBytes(d) = &decoded[0].kind {
assert!(d.is_empty());
} else {
panic!("wrong variant");
}
}
#[test]
fn test_fnv1a64_deterministic() {
let a = fnv1a64(b"hello world");
let b = fnv1a64(b"hello world");
assert_eq!(a, b);
}
#[test]
fn test_fnv1a64_different() {
let a = fnv1a64(b"abc");
let b = fnv1a64(b"xyz");
assert_ne!(a, b);
}
#[test]
fn test_fnv1a64_empty() {
let h = fnv1a64(b"");
assert_eq!(h, 0xcbf29ce484222325);
}
#[test]
fn test_magic_bytes_present() {
let events = vec![make_event(0, ReplayEventKind::PtyBytes(b"test".to_vec()))];
let buf = encode_ltr(&events).unwrap();
assert_eq!(&buf[..4], MAGIC);
}
#[test]
fn test_corrupted_file_returns_error() {
let result = decode_ltr(b"\xFF\xFF\xFF\xFF");
assert!(result.is_err());
}
#[test]
fn test_declared_pty_length_past_eof_returns_error() {
let mut data = vec![TAG_PTY_BYTES];
data.extend_from_slice(&0u64.to_le_bytes());
data.extend_from_slice(&u32::MAX.to_le_bytes());
let result = decode_ltr(&data);
assert!(result.is_err());
}
#[test]
fn test_stream_reader_declared_snapshot_length_past_eof_returns_error() {
let mut data = MAGIC.to_vec();
data.push(TAG_SNAPSHOT);
data.extend_from_slice(&0u64.to_le_bytes());
data.extend_from_slice(&u32::MAX.to_le_bytes());
let mut reader = LtrReader::new(&data).unwrap();
let result = reader.next_event();
assert!(result.is_err());
}
#[test]
fn test_truncated_file_returns_error() {
let result = decode_ltr(b"LTR1\x00");
assert!(result.is_err());
}
#[test]
fn test_read_any_jsonl_fallback() {
use std::io::Write;
let dir = std::env::temp_dir();
let path = dir.join("test_read_any.jsonl");
let mut f = std::fs::File::create(&path).unwrap();
writeln!(
f,
r#"{{"timestamp_ms":0,"type":"PtyBytes","data":"686578"}}"#
)
.unwrap();
let events = read_any(path.to_str().unwrap()).unwrap();
assert_eq!(events.len(), 1);
if let ReplayEventKind::PtyBytes(d) = &events[0].kind {
assert_eq!(d, b"hex");
} else {
panic!("wrong variant");
}
let _ = std::fs::remove_file(&path);
}
#[test]
fn test_read_any_nonexistent_returns_error() {
let result = read_any("/nonexistent/file.ltr");
assert!(result.is_err());
}
#[test]
fn test_ltr_stream_reader() {
let events = vec![
make_event(0, ReplayEventKind::PtyBytes(b"abc".to_vec())),
make_event(1, ReplayEventKind::Resize { cols: 80, rows: 24 }),
];
let buf = encode_ltr(&events).unwrap();
let mut reader = LtrReader::new(&buf).unwrap();
let e1 = reader.next_event().unwrap().unwrap();
if let EventRef::PtyBytes(d) = e1 {
assert_eq!(d, b"abc");
} else {
panic!("expected PtyBytes");
}
let e2 = reader.next_event().unwrap().unwrap();
if let EventRef::Resize { cols, rows } = e2 {
assert_eq!(cols, 80);
assert_eq!(rows, 24);
} else {
panic!("expected Resize");
}
assert!(reader.next_event().unwrap().is_none());
}
#[test]
fn test_convert_jsonl_to_ltr() {
use std::io::Write;
let dir = std::env::temp_dir();
let jsonl = dir.join("test_convert.jsonl");
let ltr = dir.join("test_convert.ltr");
{
let mut f = std::fs::File::create(&jsonl).unwrap();
writeln!(f, r#"{{"timestamp_ms":0,"type":"PtyBytes","data":"00ff"}}"#).unwrap();
}
let result = convert_jsonl_to_ltr(jsonl.to_str().unwrap(), ltr.to_str().unwrap());
assert!(result.is_ok());
let events = read_ltr(ltr.to_str().unwrap()).unwrap();
assert_eq!(events.len(), 1);
if let ReplayEventKind::PtyBytes(d) = &events[0].kind {
assert_eq!(d, &[0x00, 0xFF]);
}
let _ = std::fs::remove_file(&jsonl);
let _ = std::fs::remove_file(<r);
}
#[cfg(feature = "compression")]
#[test]
fn test_compressed_ltr_roundtrip() {
let dir = std::env::temp_dir();
let path = dir.join("test_compressed_roundtrip.ltr.zst");
let events = vec![
make_event(0, ReplayEventKind::PtyBytes(b"hello".to_vec())),
make_event(
1,
ReplayEventKind::Resize {
cols: 100,
rows: 30,
},
),
];
write_ltr_zst(path.to_str().unwrap(), &events).unwrap();
let decoded = read_any(path.to_str().unwrap()).unwrap();
assert_eq!(decoded.len(), events.len());
assert!(matches!(decoded[0].kind, ReplayEventKind::PtyBytes(_)));
assert!(matches!(decoded[1].kind, ReplayEventKind::Resize { .. }));
let _ = std::fs::remove_file(&path);
}
}