@@ -18,6 +18,29 @@ use super::providers::{LLMProvider, Message, Role, ToolCall, Usage};
1818/// Current session format version (matches Pi)
1919pub const CURRENT_SESSION_VERSION : u32 = 1 ;
2020
21+ /// Maximum accepted length for externally supplied session IDs.
22+ pub const MAX_SESSION_ID_LEN : usize = 128 ;
23+
24+ /// Validate a session ID before it is used as a filename.
25+ pub fn is_valid_session_id ( session_id : & str ) -> bool {
26+ !session_id. is_empty ( )
27+ && session_id. len ( ) <= MAX_SESSION_ID_LEN
28+ && session_id
29+ . chars ( )
30+ . all ( |c| c. is_ascii_alphanumeric ( ) || c == '-' || c == '_' )
31+ }
32+
33+ fn validate_session_id ( session_id : & str ) -> Result < ( ) > {
34+ if is_valid_session_id ( session_id) {
35+ Ok ( ( ) )
36+ } else {
37+ anyhow:: bail!(
38+ "Invalid session ID: use 1-{} ASCII letters, numbers, hyphens, or underscores" ,
39+ MAX_SESSION_ID_LEN
40+ )
41+ }
42+ }
43+
2144/// Session state (internal representation)
2245#[ derive( Debug , Clone ) ]
2346pub struct Session {
@@ -137,9 +160,25 @@ impl Session {
137160 )
138161 }
139162
163+ pub fn new_with_id ( id : impl Into < String > ) -> Result < Self > {
164+ let id = id. into ( ) ;
165+ validate_session_id ( & id) ?;
166+
167+ Ok ( Self :: new_with_id_and_cwd (
168+ id,
169+ std:: env:: current_dir ( )
170+ . map ( |p| p. to_string_lossy ( ) . to_string ( ) )
171+ . unwrap_or_else ( |_| "." . to_string ( ) ) ,
172+ ) )
173+ }
174+
140175 pub fn new_with_cwd ( cwd : String ) -> Self {
176+ Self :: new_with_id_and_cwd ( Uuid :: new_v4 ( ) . to_string ( ) , cwd)
177+ }
178+
179+ fn new_with_id_and_cwd ( id : String , cwd : String ) -> Self {
141180 Self {
142- id : Uuid :: new_v4 ( ) . to_string ( ) ,
181+ id,
143182 created_at : Utc :: now ( ) ,
144183 cwd,
145184 messages : Vec :: new ( ) ,
@@ -300,6 +339,7 @@ impl Session {
300339
301340 /// Save session in Pi-compatible JSONL format
302341 pub fn save ( & self ) -> Result < PathBuf > {
342+ validate_session_id ( & self . id ) ?;
303343 let dir = get_sessions_dir ( ) ?;
304344 fs:: create_dir_all ( & dir) ?;
305345
@@ -309,6 +349,7 @@ impl Session {
309349 }
310350
311351 pub fn save_for_agent ( & self , agent_id : & str ) -> Result < PathBuf > {
352+ validate_session_id ( & self . id ) ?;
312353 let dir = get_sessions_dir_for_agent ( agent_id) ?;
313354 fs:: create_dir_all ( & dir) ?;
314355
@@ -435,6 +476,7 @@ impl Session {
435476 session_id : & str ,
436477 encryption_key : Option < & crate :: security:: encrypt:: EncryptionKey > ,
437478 ) -> Result < Self > {
479+ validate_session_id ( session_id) ?;
438480 let enc_path = base_path. with_extension ( "jsonl.enc" ) ;
439481
440482 // Try encrypted file first
@@ -465,6 +507,7 @@ impl Session {
465507
466508 /// Load session from an in-memory JSONL string.
467509 fn load_from_string ( content : & str , session_id : & str ) -> Result < Self > {
510+ validate_session_id ( session_id) ?;
468511 let mut session = Session {
469512 id : session_id. to_string ( ) ,
470513 created_at : Utc :: now ( ) ,
@@ -574,6 +617,7 @@ impl Session {
574617
575618 /// Load session (supports both old and Pi formats)
576619 pub fn load ( session_id : & str ) -> Result < Self > {
620+ validate_session_id ( session_id) ?;
577621 let dir = get_sessions_dir ( ) ?;
578622 let path = dir. join ( format ! ( "{}.jsonl" , session_id) ) ;
579623
@@ -586,6 +630,7 @@ impl Session {
586630
587631 /// Load a session for a specific agent ID.
588632 pub fn load_for_agent ( session_id : & str , agent_id : & str ) -> Result < Self > {
633+ validate_session_id ( session_id) ?;
589634 let dir = get_sessions_dir_for_agent ( agent_id) ?;
590635 let path = dir. join ( format ! ( "{}.jsonl" , session_id) ) ;
591636
@@ -626,6 +671,7 @@ impl Session {
626671 }
627672
628673 fn load_from_path ( path : & PathBuf , session_id : & str ) -> Result < Self > {
674+ validate_session_id ( session_id) ?;
629675 let file = File :: open ( path) ?;
630676 let reader = BufReader :: new ( file) ;
631677
@@ -846,6 +892,10 @@ pub fn recover_orphaned_sessions(agent_id: &str) -> Result<usize> {
846892 None => continue ,
847893 } ;
848894
895+ if !is_valid_session_id ( & session_id) {
896+ continue ;
897+ }
898+
849899 if let Ok ( mut session) = Session :: load_from_path ( & path, & session_id)
850900 && session. aborted_last_run
851901 {
@@ -897,7 +947,7 @@ pub fn list_sessions_for_agent(agent_id: &str) -> Result<Vec<SessionInfo>> {
897947
898948 let filename = path. file_stem ( ) . and_then ( |s| s. to_str ( ) ) . unwrap_or ( "" ) ;
899949
900- if filename . len ( ) < 32 {
950+ if ! is_valid_session_id ( filename ) {
901951 continue ;
902952 }
903953
@@ -1023,6 +1073,7 @@ pub fn search_sessions_for_agent(agent_id: &str, query: &str) -> Result<Vec<Sess
10231073
10241074 if path. extension ( ) . map ( |e| e == "jsonl" ) . unwrap_or ( false )
10251075 && let Some ( filename) = path. file_stem ( ) . and_then ( |s| s. to_str ( ) )
1076+ && is_valid_session_id ( filename)
10261077 && let Ok ( content) = fs:: read_to_string ( & path)
10271078 {
10281079 let content_lower = content. to_lowercase ( ) ;
@@ -1086,6 +1137,32 @@ mod tests {
10861137 assert_eq ! ( session. compaction_count( ) , 0 ) ;
10871138 }
10881139
1140+ #[ test]
1141+ fn test_session_new_with_id ( ) {
1142+ let session = Session :: new_with_id ( "http-session_123" ) . unwrap ( ) ;
1143+ assert_eq ! ( session. id( ) , "http-session_123" ) ;
1144+ }
1145+
1146+ #[ test]
1147+ fn test_session_rejects_invalid_ids ( ) {
1148+ assert ! ( Session :: new_with_id( "" ) . is_err( ) ) ;
1149+ assert ! ( Session :: new_with_id( "../escape" ) . is_err( ) ) ;
1150+ assert ! ( Session :: new_with_id( "has space" ) . is_err( ) ) ;
1151+ assert ! ( Session :: new_with_id( "unicode-\u{2603} " ) . is_err( ) ) ;
1152+ }
1153+
1154+ #[ test]
1155+ fn test_session_save_load_preserves_custom_id ( ) {
1156+ let tmp = tempfile:: TempDir :: new ( ) . unwrap ( ) ;
1157+ let path = tmp. path ( ) . join ( "custom-session_1.jsonl" ) ;
1158+ let session = Session :: new_with_id ( "custom-session_1" ) . unwrap ( ) ;
1159+
1160+ session. save_to_path ( & path) . unwrap ( ) ;
1161+ let loaded = Session :: load_from_path ( & path, "custom-session_1" ) . unwrap ( ) ;
1162+
1163+ assert_eq ! ( loaded. id( ) , "custom-session_1" ) ;
1164+ }
1165+
10891166 #[ test]
10901167 fn test_message_usage_from ( ) {
10911168 let usage = Usage {
0 commit comments