From c52dc9fd93c4f7e87d2b87be4984abf20bdad10c Mon Sep 17 00:00:00 2001 From: Thibault Sottiaux Date: Thu, 8 Jan 2026 09:31:23 -0800 Subject: [PATCH] fix(core): guard history log id after trims --- codex-rs/core/src/message_history.rs | 106 ++++++++++++++++++++++++--- 1 file changed, 96 insertions(+), 10 deletions(-) diff --git a/codex-rs/core/src/message_history.rs b/codex-rs/core/src/message_history.rs index cb3b10098c..ff53a5960b 100644 --- a/codex-rs/core/src/message_history.rs +++ b/codex-rs/core/src/message_history.rs @@ -16,8 +16,11 @@ //! Note: `conversation_id` stores the thread id; the field name is preserved for //! backwards compatibility with existing history files. +use std::collections::hash_map::DefaultHasher; use std::fs::File; use std::fs::OpenOptions; +use std::hash::Hash; +use std::hash::Hasher; use std::io::BufRead; use std::io::BufReader; use std::io::Read; @@ -33,7 +36,9 @@ use serde::Serialize; use std::time::Duration; use tokio::fs; +use tokio::io::AsyncBufReadExt; use tokio::io::AsyncReadExt; +use tokio::io::BufReader as AsyncBufReader; use crate::config::Config; use crate::config::types::HistoryPersistence; @@ -243,6 +248,23 @@ fn trim_target_bytes(max_bytes: u64, newest_entry_len: u64) -> u64 { soft_cap_bytes.max(newest_entry_len) } +fn trim_history_line_end(line: &str) -> &str { + line.trim_end_matches(['\n', '\r']) +} + +fn history_log_id_with_first_line(base_id: u64, first_line: Option<&str>) -> u64 { + if base_id == 0 { + return 0; + } + let Some(line) = first_line else { + return base_id; + }; + + let mut hasher = DefaultHasher::new(); + line.hash(&mut hasher); + base_id ^ hasher.finish() +} + /// Asynchronously fetch the history file's *identifier* (inode on Unix) and /// the current number of entries by counting newline characters. pub(crate) async fn history_metadata(config: &Config) -> (u64, usize) { @@ -286,7 +308,7 @@ async fn ensure_owner_only_permissions(_file: &File) -> Result<()> { } async fn history_metadata_for_file(path: &Path) -> (u64, usize) { - let log_id = match fs::metadata(path).await { + let base_id = match fs::metadata(path).await { Ok(metadata) => history_log_id(&metadata).unwrap_or(0), Err(e) if e.kind() == std::io::ErrorKind::NotFound => return (0, 0), Err(_) => return (0, 0), @@ -295,14 +317,29 @@ async fn history_metadata_for_file(path: &Path) -> (u64, usize) { // Open the file. let mut file = match fs::File::open(path).await { Ok(f) => f, - Err(_) => return (log_id, 0), + Err(_) => return (base_id, 0), }; - // Count newline bytes. - let mut buf = [0u8; 8192]; + let mut reader = AsyncBufReader::new(&mut file); + let mut first_line = String::new(); + let first_line_bytes = match reader.read_line(&mut first_line).await { + Ok(0) => return (history_log_id_with_first_line(base_id, None), 0), + Ok(bytes) => bytes, + Err(_) => return (history_log_id_with_first_line(base_id, None), 0), + }; + + let trimmed_first_line = trim_history_line_end(&first_line); + let log_id = history_log_id_with_first_line(base_id, Some(trimmed_first_line)); + let mut count = 0usize; + if first_line_bytes > 0 && first_line.ends_with('\n') { + count += 1; + } + + // Count newline bytes in the remainder. + let mut buf = [0u8; 8192]; loop { - match file.read(&mut buf).await { + match reader.read(&mut buf).await { Ok(0) => break, Ok(n) => { count += buf[..n].iter().filter(|&&b| b == b'\n').count(); @@ -334,11 +371,7 @@ fn lookup_history_entry(path: &Path, log_id: u64, offset: usize) -> Option Option { let reader = BufReader::new(&file); + let mut saw_first_line = false; for (idx, line_res) in reader.lines().enumerate() { let line = match line_res { Ok(l) => l, @@ -357,6 +391,15 @@ fn lookup_history_entry(path: &Path, log_id: u64, offset: usize) -> Option(&line) { Ok(entry) => return Some(entry), @@ -367,6 +410,12 @@ fn lookup_history_entry(path: &Path, log_id: u64, offset: usize) -> Option