55import socket
66from contextlib import suppress
77from dataclasses import dataclass , field
8+ from tempfile import NamedTemporaryFile
89from typing import Any , Dict , List , Optional
910
1011from jumpstarter_driver_opendal .driver import Opendal
@@ -66,9 +67,7 @@ def __post_init__(self):
6667 os .makedirs (self .root_dir , exist_ok = True )
6768
6869 self .children ["storage" ] = Opendal (
69- scheme = "fs" ,
70- kwargs = {"root" : self .root_dir },
71- remove_created_on_close = self .remove_created_on_close
70+ scheme = "fs" , kwargs = {"root" : self .root_dir }, remove_created_on_close = self .remove_created_on_close
7271 )
7372 self .storage = self .children ["storage" ]
7473
@@ -306,6 +305,15 @@ def _safe_join_under_root(self, rel_path: str) -> str:
306305 os .makedirs (os .path .dirname (full_path ), exist_ok = True )
307306 return full_path
308307
308+ def _check_no_symlinks_in_path (self , path : str ) -> None :
309+ """Verify no path component is a symlink to prevent writing outside root."""
310+ path_to_check = path
311+ root_abs = os .path .abspath (self .root_dir )
312+ while path_to_check != root_abs and path_to_check != os .path .dirname (path_to_check ):
313+ if os .path .lexists (path_to_check ) and os .path .islink (path_to_check ):
314+ raise ISCSIError (f"Destination path contains symlink: { path_to_check } " )
315+ path_to_check = os .path .dirname (path_to_check )
316+
309317 @export
310318 def decompress (self , src_path : str , dst_path : str , algo : str ) -> None :
311319 """Decompress a file under storage root into another path under storage root.
@@ -315,6 +323,7 @@ def decompress(self, src_path: str, dst_path: str, algo: str) -> None:
315323 """
316324 src_full = self ._safe_join_under_root (src_path )
317325 dst_full = self ._safe_join_under_root (dst_path )
326+ self ._check_no_symlinks_in_path (dst_full )
318327
319328 def _copy_stream (read_f , write_f ):
320329 while True :
@@ -323,26 +332,31 @@ def _copy_stream(read_f, write_f):
323332 break
324333 write_f .write (chunk )
325334
335+ tmp_path = None
326336 try :
327- if algo == "gz" :
328- with open (dst_full , "wb" ) as out_f :
337+ with NamedTemporaryFile (dir = os .path .dirname (dst_full ), prefix = ".decomp-" , delete = False ) as tf :
338+ tmp_path = tf .name
339+ if algo == "gz" :
329340 with gzip .open (src_full , "rb" ) as decomp :
330- _copy_stream (decomp , out_f )
331- elif algo == "xz" :
332- with open (dst_full , "wb" ) as out_f :
341+ _copy_stream (decomp , tf )
342+ elif algo == "xz" :
333343 with lzma .open (src_full , "rb" ) as decomp :
334- _copy_stream (decomp , out_f )
335- elif algo == "bz2" :
336- with open (dst_full , "wb" ) as out_f :
344+ _copy_stream (decomp , tf )
345+ elif algo == "bz2" :
337346 with bz2 .open (src_full , "rb" ) as decomp :
338- _copy_stream (decomp , out_f )
339- else :
340- raise ISCSIError (f"Unsupported compression algo: { algo } " )
347+ _copy_stream (decomp , tf )
348+ else :
349+ raise ISCSIError (f"Unsupported compression algo: { algo } " )
350+ tf .flush ()
351+ os .fsync (tf .fileno ())
352+ os .replace (tmp_path , dst_full )
341353 except Exception as e :
354+ with suppress (Exception ):
355+ if tmp_path is not None :
356+ os .remove (tmp_path )
342357 raise ISCSIError (f"Decompression failed: { e } " ) from e
343358
344359 def _create_file_storage_object (self , name : str , full_path : str , size_mb : int ) -> tuple :
345- """Create file-backed storage object and return (storage_obj, final_size_mb)"""
346360 if not os .path .exists (full_path ):
347361 if size_mb <= 0 :
348362 raise ISCSIError ("size_mb must be > 0 for new file-backed LUNs" )
0 commit comments