@@ -18,6 +18,7 @@ use std::fs::File;
1818use std:: fs:: OpenOptions ;
1919use std:: io:: Result ;
2020use std:: io:: Write ;
21+ use std:: path:: Path ;
2122use std:: path:: PathBuf ;
2223
2324use serde:: Deserialize ;
@@ -42,7 +43,7 @@ const HISTORY_FILENAME: &str = "history.jsonl";
4243const MAX_RETRIES : usize = 10 ;
4344const RETRY_SLEEP : Duration = Duration :: from_millis ( 100 ) ;
4445
45- #[ derive( Serialize , Deserialize , Debug , Clone ) ]
46+ #[ derive( Serialize , Deserialize , Debug , Clone , PartialEq ) ]
4647pub struct HistoryEntry {
4748 pub session_id : String ,
4849 pub ts : u64 ,
@@ -142,23 +143,54 @@ pub(crate) async fn append_entry(
142143/// the current number of entries by counting newline characters.
143144pub ( crate ) async fn history_metadata ( config : & Config ) -> ( u64 , usize ) {
144145 let path = history_filepath ( config) ;
146+ history_metadata_for_file ( & path) . await
147+ }
145148
146- #[ cfg( unix) ]
147- let log_id = {
148- use std:: os:: unix:: fs:: MetadataExt ;
149- // Obtain metadata (async) to get the identifier.
150- let meta = match fs:: metadata ( & path) . await {
151- Ok ( m) => m,
152- Err ( e) if e. kind ( ) == std:: io:: ErrorKind :: NotFound => return ( 0 , 0 ) ,
153- Err ( _) => return ( 0 , 0 ) ,
154- } ;
155- meta. ino ( )
149+ /// Given a `log_id` (on Unix this is the file's inode number,
150+ /// on Windows this is the file's creation time) and a zero-based
151+ /// `offset`, return the corresponding `HistoryEntry` if the identifier matches
152+ /// the current history file **and** the requested offset exists. Any I/O or
153+ /// parsing errors are logged and result in `None`.
154+ ///
155+ /// Note this function is not async because it uses a sync advisory file
156+ /// locking API.
157+ #[ cfg( any( unix, windows) ) ]
158+ pub ( crate ) fn lookup ( log_id : u64 , offset : usize , config : & Config ) -> Option < HistoryEntry > {
159+ let path = history_filepath ( config) ;
160+ lookup_history_entry ( & path, log_id, offset)
161+ }
162+
163+ /// On Unix systems, ensure the file permissions are `0o600` (rw-------). If the
164+ /// permissions cannot be changed the error is propagated to the caller.
165+ #[ cfg( unix) ]
166+ async fn ensure_owner_only_permissions ( file : & File ) -> Result < ( ) > {
167+ let metadata = file. metadata ( ) ?;
168+ let current_mode = metadata. permissions ( ) . mode ( ) & 0o777 ;
169+ if current_mode != 0o600 {
170+ let mut perms = metadata. permissions ( ) ;
171+ perms. set_mode ( 0o600 ) ;
172+ let perms_clone = perms. clone ( ) ;
173+ let file_clone = file. try_clone ( ) ?;
174+ tokio:: task:: spawn_blocking ( move || file_clone. set_permissions ( perms_clone) ) . await ??;
175+ }
176+ Ok ( ( ) )
177+ }
178+
179+ #[ cfg( windows) ]
180+ // On Windows, simply succeed.
181+ async fn ensure_owner_only_permissions ( _file : & File ) -> Result < ( ) > {
182+ Ok ( ( ) )
183+ }
184+
185+ async fn history_metadata_for_file ( path : & Path ) -> ( u64 , usize ) {
186+ let log_id = match fs:: metadata ( path) . await {
187+ Ok ( metadata) => history_log_id ( & metadata) . unwrap_or ( 0 ) ,
188+ Err ( e) if e. kind ( ) == std:: io:: ErrorKind :: NotFound => return ( 0 , 0 ) ,
189+ Err ( _) => return ( 0 , 0 ) ,
156190 } ;
157- #[ cfg( not( unix) ) ]
158- let log_id = 0u64 ;
159191
160192 // Open the file.
161- let mut file = match fs:: File :: open ( & path) . await {
193+ let mut file = match fs:: File :: open ( path) . await {
162194 Ok ( f) => f,
163195 Err ( _) => return ( log_id, 0 ) ,
164196 } ;
@@ -179,21 +211,12 @@ pub(crate) async fn history_metadata(config: &Config) -> (u64, usize) {
179211 ( log_id, count)
180212}
181213
182- /// Given a `log_id` (on Unix this is the file's inode number) and a zero-based
183- /// `offset`, return the corresponding `HistoryEntry` if the identifier matches
184- /// the current history file **and** the requested offset exists. Any I/O or
185- /// parsing errors are logged and result in `None`.
186- ///
187- /// Note this function is not async because it uses a sync advisory file
188- /// locking API.
189- #[ cfg( unix) ]
190- pub ( crate ) fn lookup ( log_id : u64 , offset : usize , config : & Config ) -> Option < HistoryEntry > {
214+ #[ cfg( any( unix, windows) ) ]
215+ fn lookup_history_entry ( path : & Path , log_id : u64 , offset : usize ) -> Option < HistoryEntry > {
191216 use std:: io:: BufRead ;
192217 use std:: io:: BufReader ;
193- use std:: os:: unix:: fs:: MetadataExt ;
194218
195- let path = history_filepath ( config) ;
196- let file: File = match OpenOptions :: new ( ) . read ( true ) . open ( & path) {
219+ let file: File = match OpenOptions :: new ( ) . read ( true ) . open ( path) {
197220 Ok ( f) => f,
198221 Err ( e) => {
199222 tracing:: warn!( error = %e, "failed to open history file" ) ;
@@ -209,7 +232,9 @@ pub(crate) fn lookup(log_id: u64, offset: usize, config: &Config) -> Option<Hist
209232 }
210233 } ;
211234
212- if metadata. ino ( ) != log_id {
235+ let current_log_id = history_log_id ( & metadata) ?;
236+
237+ if log_id != 0 && current_log_id != log_id {
213238 return None ;
214239 }
215240
@@ -256,31 +281,104 @@ pub(crate) fn lookup(log_id: u64, offset: usize, config: &Config) -> Option<Hist
256281 None
257282}
258283
259- /// Fallback stub for non-Unix systems: currently always returns `None`.
260- #[ cfg( not ( unix) ) ]
261- pub ( crate ) fn lookup ( log_id : u64 , offset : usize , config : & Config ) -> Option < HistoryEntry > {
262- let _ = ( log_id , offset , config ) ;
263- None
264- }
284+ fn history_log_id ( metadata : & std :: fs :: Metadata ) -> Option < u64 > {
285+ #[ cfg( unix) ]
286+ {
287+ use std :: os :: unix :: fs :: MetadataExt ;
288+ Some ( metadata . ino ( ) )
289+ }
265290
266- /// On Unix systems ensure the file permissions are `0o600` (rw-------). If the
267- /// permissions cannot be changed the error is propagated to the caller.
268- #[ cfg( unix) ]
269- async fn ensure_owner_only_permissions ( file : & File ) -> Result < ( ) > {
270- let metadata = file. metadata ( ) ?;
271- let current_mode = metadata. permissions ( ) . mode ( ) & 0o777 ;
272- if current_mode != 0o600 {
273- let mut perms = metadata. permissions ( ) ;
274- perms. set_mode ( 0o600 ) ;
275- let perms_clone = perms. clone ( ) ;
276- let file_clone = file. try_clone ( ) ?;
277- tokio:: task:: spawn_blocking ( move || file_clone. set_permissions ( perms_clone) ) . await ??;
291+ #[ cfg( windows) ]
292+ {
293+ use std:: os:: windows:: fs:: MetadataExt ;
294+ Some ( metadata. creation_time ( ) )
278295 }
279- Ok ( ( ) )
280296}
281297
282- #[ cfg( not( unix) ) ]
283- async fn ensure_owner_only_permissions ( _file : & File ) -> Result < ( ) > {
284- // For now, on non-Unix, simply succeed.
285- Ok ( ( ) )
298+ #[ cfg( all( test, any( unix, windows) ) ) ]
299+ mod tests {
300+ use super :: * ;
301+ use pretty_assertions:: assert_eq;
302+ use std:: fs:: File ;
303+ use std:: io:: Write ;
304+ use tempfile:: TempDir ;
305+
306+ #[ tokio:: test]
307+ async fn lookup_reads_history_entries ( ) {
308+ let temp_dir = TempDir :: new ( ) . expect ( "create temp dir" ) ;
309+ let history_path = temp_dir. path ( ) . join ( HISTORY_FILENAME ) ;
310+
311+ let entries = vec ! [
312+ HistoryEntry {
313+ session_id: "first-session" . to_string( ) ,
314+ ts: 1 ,
315+ text: "first" . to_string( ) ,
316+ } ,
317+ HistoryEntry {
318+ session_id: "second-session" . to_string( ) ,
319+ ts: 2 ,
320+ text: "second" . to_string( ) ,
321+ } ,
322+ ] ;
323+
324+ let mut file = File :: create ( & history_path) . expect ( "create history file" ) ;
325+ for entry in & entries {
326+ writeln ! (
327+ file,
328+ "{}" ,
329+ serde_json:: to_string( entry) . expect( "serialize history entry" )
330+ )
331+ . expect ( "write history entry" ) ;
332+ }
333+
334+ let ( log_id, count) = history_metadata_for_file ( & history_path) . await ;
335+ assert_eq ! ( count, entries. len( ) ) ;
336+
337+ let second_entry =
338+ lookup_history_entry ( & history_path, log_id, 1 ) . expect ( "fetch second history entry" ) ;
339+ assert_eq ! ( second_entry, entries[ 1 ] ) ;
340+ }
341+
342+ #[ tokio:: test]
343+ async fn lookup_uses_stable_log_id_after_appends ( ) {
344+ let temp_dir = TempDir :: new ( ) . expect ( "create temp dir" ) ;
345+ let history_path = temp_dir. path ( ) . join ( HISTORY_FILENAME ) ;
346+
347+ let initial = HistoryEntry {
348+ session_id : "first-session" . to_string ( ) ,
349+ ts : 1 ,
350+ text : "first" . to_string ( ) ,
351+ } ;
352+ let appended = HistoryEntry {
353+ session_id : "second-session" . to_string ( ) ,
354+ ts : 2 ,
355+ text : "second" . to_string ( ) ,
356+ } ;
357+
358+ let mut file = File :: create ( & history_path) . expect ( "create history file" ) ;
359+ writeln ! (
360+ file,
361+ "{}" ,
362+ serde_json:: to_string( & initial) . expect( "serialize initial entry" )
363+ )
364+ . expect ( "write initial entry" ) ;
365+
366+ let ( log_id, count) = history_metadata_for_file ( & history_path) . await ;
367+ assert_eq ! ( count, 1 ) ;
368+
369+ let mut append = std:: fs:: OpenOptions :: new ( )
370+ . append ( true )
371+ . open ( & history_path)
372+ . expect ( "open history file for append" ) ;
373+ writeln ! (
374+ append,
375+ "{}" ,
376+ serde_json:: to_string( & appended) . expect( "serialize appended entry" )
377+ )
378+ . expect ( "append history entry" ) ;
379+
380+ let fetched =
381+ lookup_history_entry ( & history_path, log_id, 1 ) . expect ( "lookup appended history entry" ) ;
382+ assert_eq ! ( fetched, appended) ;
383+ }
286384}
0 commit comments