33use crate :: { Result , RevertPoint , Snapshot , SnapshotError } ;
44use serde:: { Deserialize , Serialize } ;
55use std:: collections:: HashMap ;
6- use std:: path:: PathBuf ;
6+ use std:: path:: { Component , Path , PathBuf } ;
77use tokio:: fs;
88use tracing:: { debug, info} ;
99
@@ -27,7 +27,7 @@ impl SnapshotStorage {
2727 /// Save a snapshot.
2828 pub async fn save_snapshot ( & self , snapshot : & Snapshot ) -> Result < ( ) > {
2929 self . init ( ) . await ?;
30- let path = self . snapshot_path ( & snapshot. id ) ;
30+ let path = self . snapshot_path ( & snapshot. id ) ? ;
3131 let json = serde_json:: to_string_pretty ( snapshot)
3232 . map_err ( |e| SnapshotError :: CreateFailed ( e. to_string ( ) ) ) ?;
3333 fs:: write ( & path, json) . await ?;
@@ -37,7 +37,7 @@ impl SnapshotStorage {
3737
3838 /// Load a snapshot by ID.
3939 pub async fn load_snapshot ( & self , id : & str ) -> Result < Snapshot > {
40- let path = self . snapshot_path ( id) ;
40+ let path = self . snapshot_path ( id) ? ;
4141 let json = fs:: read_to_string ( & path)
4242 . await
4343 . map_err ( |_| SnapshotError :: NotFound ( id. to_string ( ) ) ) ?;
@@ -48,7 +48,7 @@ impl SnapshotStorage {
4848
4949 /// Delete a snapshot.
5050 pub async fn delete_snapshot ( & self , id : & str ) -> Result < ( ) > {
51- let path = self . snapshot_path ( id) ;
51+ let path = self . snapshot_path ( id) ? ;
5252 if path. exists ( ) {
5353 fs:: remove_file ( & path) . await ?;
5454 debug ! ( "Deleted snapshot: {}" , id) ;
@@ -94,7 +94,7 @@ impl SnapshotStorage {
9494 history : & [ RevertPoint ] ,
9595 ) -> Result < ( ) > {
9696 self . init ( ) . await ?;
97- let path = self . history_path ( session_id) ;
97+ let path = self . history_path ( session_id) ? ;
9898 let json = serde_json:: to_string_pretty ( history)
9999 . map_err ( |e| SnapshotError :: CreateFailed ( e. to_string ( ) ) ) ?;
100100 fs:: write ( & path, json) . await ?;
@@ -104,7 +104,7 @@ impl SnapshotStorage {
104104
105105 /// Load session revert history.
106106 pub async fn load_revert_history ( & self , session_id : & str ) -> Result < Vec < RevertPoint > > {
107- let path = self . history_path ( session_id) ;
107+ let path = self . history_path ( session_id) ? ;
108108 if !path. exists ( ) {
109109 return Ok ( Vec :: new ( ) ) ;
110110 }
@@ -132,16 +132,34 @@ impl SnapshotStorage {
132132 Ok ( removed)
133133 }
134134
135- fn snapshot_path ( & self , id : & str ) -> PathBuf {
136- self . storage_path . join ( format ! ( "{}.json" , id) )
135+ fn snapshot_path ( & self , id : & str ) -> Result < PathBuf > {
136+ validate_storage_id ( "snapshot id" , id) ?;
137+ Ok ( self . storage_path . join ( format ! ( "{}.json" , id) ) )
137138 }
138139
139- fn history_path ( & self , session_id : & str ) -> PathBuf {
140- self . storage_path
141- . join ( format ! ( "history_{}.json" , session_id) )
140+ fn history_path ( & self , session_id : & str ) -> Result < PathBuf > {
141+ validate_storage_id ( "session id" , session_id) ?;
142+ Ok ( self
143+ . storage_path
144+ . join ( format ! ( "history_{}.json" , session_id) ) )
142145 }
143146}
144147
148+ fn validate_storage_id ( kind : & str , id : & str ) -> Result < ( ) > {
149+ if id. is_empty ( )
150+ || id. chars ( ) . any ( |c| matches ! ( c, '/' | '\\' | '\0' ) )
151+ || Path :: new ( id)
152+ . components ( )
153+ . any ( |component| !matches ! ( component, Component :: Normal ( _) ) )
154+ {
155+ return Err ( SnapshotError :: InvalidId ( format ! (
156+ "{kind} must be a single filename segment: {id:?}"
157+ ) ) ) ;
158+ }
159+
160+ Ok ( ( ) )
161+ }
162+
145163/// Index for fast snapshot lookup.
146164#[ derive( Debug , Default , Serialize , Deserialize ) ]
147165pub struct SnapshotIndex {
@@ -203,4 +221,57 @@ mod tests {
203221 assert_eq ! ( loaded. tree_hash, snapshot. tree_hash) ;
204222 assert_eq ! ( loaded. description, snapshot. description) ;
205223 }
224+
225+ #[ tokio:: test]
226+ async fn test_snapshot_storage_rejects_traversal_snapshot_id ( ) {
227+ let temp_dir = TempDir :: new ( ) . unwrap ( ) ;
228+ let storage = SnapshotStorage :: new ( temp_dir. path ( ) ) ;
229+
230+ let mut snapshot = Snapshot :: new ( "test_hash" . to_string ( ) ) ;
231+ snapshot. id = "../escaped_snapshot" . to_string ( ) ;
232+
233+ let result = storage. save_snapshot ( & snapshot) . await ;
234+ assert ! ( matches!( result, Err ( SnapshotError :: InvalidId ( _) ) ) ) ;
235+ assert ! ( !temp_dir. path( ) . join( "escaped_snapshot.json" ) . exists( ) ) ;
236+ }
237+
238+ #[ tokio:: test]
239+ async fn test_revert_history_rejects_traversal_session_id ( ) {
240+ let temp_dir = TempDir :: new ( ) . unwrap ( ) ;
241+ let storage = SnapshotStorage :: new ( temp_dir. path ( ) ) ;
242+ storage. init ( ) . await . unwrap ( ) ;
243+ fs:: create_dir_all ( storage. storage_path . join ( "history_existing" ) )
244+ . await
245+ . unwrap ( ) ;
246+
247+ let result = storage
248+ . save_revert_history ( "existing/../../escaped_history" , & [ ] )
249+ . await ;
250+
251+ assert ! ( matches!( result, Err ( SnapshotError :: InvalidId ( _) ) ) ) ;
252+ assert ! ( !temp_dir. path( ) . join( "escaped_history.json" ) . exists( ) ) ;
253+ }
254+
255+ #[ test]
256+ fn test_storage_ids_allow_plain_uuid_like_segments ( ) {
257+ assert ! ( validate_storage_id( "snapshot id" , "550e8400-e29b-41d4-a716-446655440000" ) . is_ok( ) ) ;
258+ assert ! ( validate_storage_id( "session id" , "session_name.1" ) . is_ok( ) ) ;
259+ }
260+
261+ #[ test]
262+ fn test_storage_ids_reject_path_segments ( ) {
263+ for id in [
264+ "../escape" ,
265+ "/absolute" ,
266+ "nested/path" ,
267+ r"nested\path" ,
268+ "." ,
269+ ".." ,
270+ ] {
271+ assert ! ( matches!(
272+ validate_storage_id( "snapshot id" , id) ,
273+ Err ( SnapshotError :: InvalidId ( _) )
274+ ) ) ;
275+ }
276+ }
206277}
0 commit comments