@@ -59,7 +59,7 @@ use prost::Message;
5959use sha2:: { Digest , Sha256 } ;
6060use std:: collections:: { HashMap , HashSet } ;
6161use std:: fs;
62- use std:: io:: Read ;
62+ use std:: io:: { Read , Write } ;
6363use std:: net:: { IpAddr , Ipv4Addr } ;
6464#[ cfg( unix) ]
6565use std:: os:: unix:: fs:: PermissionsExt ;
@@ -2881,20 +2881,30 @@ impl VmDriver {
28812881 }
28822882 } ;
28832883
2884- let metadata = tokio:: fs:: metadata ( tar_path) . await . map_err ( |err| {
2885- Status :: failed_precondition ( format ! (
2886- "rootfs tar not accessible at {}: {err}" ,
2887- tar_path. display( )
2888- ) )
2889- } ) ?;
2890- let mtime = metadata
2891- . modified ( )
2892- . unwrap_or ( std:: time:: SystemTime :: UNIX_EPOCH )
2893- . duration_since ( std:: time:: SystemTime :: UNIX_EPOCH )
2894- . unwrap_or_default ( )
2895- . as_secs ( ) ;
2896- let tar_identity = format ! ( "rootfs-tar:{}:{mtime}" , tar_path. display( ) ) ;
2897- let cache_identity = prepared_image_cache_identity ( & tar_identity) ;
2884+ // Identity comes from the archive contents. See `rootfs_tar_cache_identity`.
2885+ let hash_source = tar_path. to_path_buf ( ) ;
2886+ let source_digest = match tokio:: task:: spawn_blocking ( move || {
2887+ compute_file_sha256_hex ( & hash_source)
2888+ } )
2889+ . await
2890+ {
2891+ Ok ( Ok ( digest) ) => digest,
2892+ Ok ( Err ( err) ) => {
2893+ cleanup_request_staging ( ) . await ;
2894+ return Err ( Status :: failed_precondition ( format ! (
2895+ "rootfs tar not readable at {}: {err}" ,
2896+ tar_path. display( )
2897+ ) ) ) ;
2898+ }
2899+ Err ( err) => {
2900+ cleanup_request_staging ( ) . await ;
2901+ return Err ( Status :: internal ( format ! (
2902+ "failed to hash rootfs tar at {}: {err}" ,
2903+ tar_path. display( )
2904+ ) ) ) ;
2905+ }
2906+ } ;
2907+ let cache_identity = rootfs_tar_cache_identity ( & source_digest) ;
28982908 let image_path = image_cache_rootfs_image ( & self . config . state_dir , & cache_identity) ;
28992909 let tar_display = tar_path. display ( ) . to_string ( ) ;
29002910
@@ -2942,11 +2952,37 @@ impl VmDriver {
29422952 ( "image_identity" . to_string ( ) , cache_identity. clone ( ) ) ,
29432953 ] ) ,
29442954 ) ;
2945- if let Err ( err) = tokio:: fs:: copy ( tar_path, & rootfs_archive) . await {
2955+ let copy_src = tar_path. to_path_buf ( ) ;
2956+ let copy_dst = rootfs_archive. clone ( ) ;
2957+ let copied_digest =
2958+ match tokio:: task:: spawn_blocking ( move || copy_file_sha256_hex ( & copy_src, & copy_dst) )
2959+ . await
2960+ {
2961+ Ok ( Ok ( digest) ) => digest,
2962+ Ok ( Err ( err) ) => {
2963+ let _ = tokio:: fs:: remove_dir_all ( & staging_dir) . await ;
2964+ cleanup_request_staging ( ) . await ;
2965+ return Err ( Status :: internal ( format ! (
2966+ "failed to copy rootfs tar to staging: {err}"
2967+ ) ) ) ;
2968+ }
2969+ Err ( err) => {
2970+ let _ = tokio:: fs:: remove_dir_all ( & staging_dir) . await ;
2971+ cleanup_request_staging ( ) . await ;
2972+ return Err ( Status :: internal ( format ! (
2973+ "failed to copy rootfs tar to staging: {err}"
2974+ ) ) ) ;
2975+ }
2976+ } ;
2977+
2978+ // The archive changed between the hash pass and the copy: the prepared
2979+ // disk we are about to build would not match the identity it is cached
2980+ // under. Reject rather than poison the cache.
2981+ if copied_digest != source_digest {
29462982 let _ = tokio:: fs:: remove_dir_all ( & staging_dir) . await ;
29472983 cleanup_request_staging ( ) . await ;
2948- return Err ( Status :: internal ( format ! (
2949- "failed to copy rootfs tar to staging: {err} "
2984+ return Err ( Status :: aborted ( format ! (
2985+ "rootfs tar {tar_display} changed while it was being staged; retry the request "
29502986 ) ) ) ;
29512987 }
29522988 cleanup_request_staging ( ) . await ;
@@ -4628,6 +4664,46 @@ fn compute_bytes_sha256_hex(bytes: &[u8]) -> String {
46284664 format ! ( "{:x}" , hasher. finalize( ) )
46294665}
46304666
4667+ /// Copy `src` to `dst` and return the SHA-256 of the bytes actually written.
4668+ ///
4669+ /// Hashing the copy rather than re-reading the source is what lets the caller
4670+ /// detect an archive that changed underneath it during staging: the digest
4671+ /// describes exactly the bytes that landed in the image cache.
4672+ fn copy_file_sha256_hex ( src : & Path , dst : & Path ) -> Result < String , String > {
4673+ let mut reader = fs:: File :: open ( src) . map_err ( |err| format ! ( "open {}: {err}" , src. display( ) ) ) ?;
4674+ let mut writer =
4675+ fs:: File :: create ( dst) . map_err ( |err| format ! ( "create {}: {err}" , dst. display( ) ) ) ?;
4676+ let mut hasher = Sha256 :: new ( ) ;
4677+ let mut buffer = vec ! [ 0_u8 ; 64 * 1024 ] . into_boxed_slice ( ) ;
4678+ loop {
4679+ let read = reader
4680+ . read ( & mut buffer)
4681+ . map_err ( |err| format ! ( "read {}: {err}" , src. display( ) ) ) ?;
4682+ if read == 0 {
4683+ break ;
4684+ }
4685+ hasher. update ( & buffer[ ..read] ) ;
4686+ writer
4687+ . write_all ( & buffer[ ..read] )
4688+ . map_err ( |err| format ! ( "write {}: {err}" , dst. display( ) ) ) ?;
4689+ }
4690+ writer
4691+ . flush ( )
4692+ . map_err ( |err| format ! ( "flush {}: {err}" , dst. display( ) ) ) ?;
4693+ Ok ( format ! ( "{:x}" , hasher. finalize( ) ) )
4694+ }
4695+
4696+ /// Cache identity for a rootfs tar archive, derived from its contents.
4697+ ///
4698+ /// Deliberately not path- or mtime-derived: staging directories are unique per
4699+ /// request, so a path-based key would never hit the cache, and a
4700+ /// seconds-truncated mtime cannot distinguish two writes within the same
4701+ /// second. A fixed-length digest also keeps the cache directory name inside
4702+ /// filesystem component limits regardless of how long the source path was.
4703+ fn rootfs_tar_cache_identity ( digest : & str ) -> String {
4704+ prepared_image_cache_identity ( & format ! ( "rootfs-tar:sha256:{digest}" ) )
4705+ }
4706+
46314707fn extract_layer_blob_to_dir (
46324708 blob_path : & Path ,
46334709 media_type : & str ,
@@ -8724,6 +8800,238 @@ mod tests {
87248800 } ;
87258801 use crate :: runtime:: VmBackend ;
87268802
8803+ /// Driver whose rootfs tar staging root is an isolated temp directory.
8804+ fn rootfs_tar_test_driver ( staging_root : & Path , max_bytes : Option < u64 > ) -> VmDriver {
8805+ let ( events, _) = broadcast:: channel ( WATCH_BUFFER ) ;
8806+ VmDriver {
8807+ config : VmDriverConfig {
8808+ rootfs_tar_staging_dir : Some ( staging_root. to_path_buf ( ) ) ,
8809+ rootfs_tar_max_bytes : max_bytes,
8810+ ..Default :: default ( )
8811+ } ,
8812+ launcher_bin : PathBuf :: from ( "openshell-driver-vm" ) ,
8813+ registry : Arc :: new ( Mutex :: new ( HashMap :: new ( ) ) ) ,
8814+ image_cache_lock : Arc :: new ( Mutex :: new ( ( ) ) ) ,
8815+ events,
8816+ gpu_inventory : None ,
8817+ subnet_allocator : Arc :: new ( std:: sync:: Mutex :: new ( SubnetAllocator :: new (
8818+ Ipv4Addr :: new ( 10 , 0 , 128 , 0 ) ,
8819+ 17 ,
8820+ ) ) ) ,
8821+ lifecycle_extensions : Arc :: new ( LifecycleExtensionRegistry :: new ( ) ) ,
8822+ }
8823+ }
8824+
8825+ /// `<staging_root>/req-<name>/<file>` with `contents`, the shape the
8826+ /// gateway allocates for one create request.
8827+ fn staged_rootfs_tar ( staging_root : & Path , request : & str , contents : & [ u8 ] ) -> PathBuf {
8828+ let request_dir = staging_root. join ( format ! ( "req-{request}" ) ) ;
8829+ std:: fs:: create_dir_all ( & request_dir) . expect ( "create request dir" ) ;
8830+ let archive = request_dir. join ( "rootfs.tar" ) ;
8831+ std:: fs:: write ( & archive, contents) . expect ( "write archive" ) ;
8832+ archive
8833+ }
8834+
8835+ #[ tokio:: test]
8836+ async fn validate_rootfs_tar_path_accepts_staged_archive ( ) {
8837+ let root = unique_temp_dir ( ) ;
8838+ std:: fs:: create_dir_all ( & root) . expect ( "create staging root" ) ;
8839+ let archive = staged_rootfs_tar ( & root, "a" , b"payload" ) ;
8840+ let driver = rootfs_tar_test_driver ( & root, None ) ;
8841+
8842+ let resolved = driver
8843+ . validate_rootfs_tar_path ( & archive)
8844+ . await
8845+ . expect ( "a correctly staged archive is accepted" ) ;
8846+
8847+ assert_eq ! (
8848+ resolved,
8849+ archive. canonicalize( ) . expect( "canonicalize archive" )
8850+ ) ;
8851+ let _ = std:: fs:: remove_dir_all ( & root) ;
8852+ }
8853+
8854+ /// The core of the fix: a caller-named host path must never reach
8855+ /// privileged driver I/O, even if the caller is authenticated.
8856+ #[ tokio:: test]
8857+ async fn validate_rootfs_tar_path_rejects_arbitrary_host_paths ( ) {
8858+ let root = unique_temp_dir ( ) ;
8859+ std:: fs:: create_dir_all ( & root) . expect ( "create staging root" ) ;
8860+ let driver = rootfs_tar_test_driver ( & root, None ) ;
8861+
8862+ for candidate in [ "/etc/passwd" , "/dev/zero" ] {
8863+ let path = Path :: new ( candidate) ;
8864+ if !path. exists ( ) {
8865+ continue ;
8866+ }
8867+ let Err ( err) = driver. validate_rootfs_tar_path ( path) . await else {
8868+ panic ! ( "{candidate} must be rejected" ) ;
8869+ } ;
8870+ assert_eq ! (
8871+ err. code( ) ,
8872+ Code :: PermissionDenied ,
8873+ "{candidate} should be denied, got: {err}"
8874+ ) ;
8875+ }
8876+ let _ = std:: fs:: remove_dir_all ( & root) ;
8877+ }
8878+
8879+ #[ tokio:: test]
8880+ async fn validate_rootfs_tar_path_rejects_symlink_escape ( ) {
8881+ let root = unique_temp_dir ( ) ;
8882+ let request_dir = root. join ( "req-a" ) ;
8883+ std:: fs:: create_dir_all ( & request_dir) . expect ( "create request dir" ) ;
8884+ let target = unique_temp_dir ( ) ;
8885+ std:: fs:: create_dir_all ( & target) . expect ( "create escape target dir" ) ;
8886+ let secret = target. join ( "secret.tar" ) ;
8887+ std:: fs:: write ( & secret, b"not yours" ) . expect ( "write escape target" ) ;
8888+ let link = request_dir. join ( "rootfs.tar" ) ;
8889+ std:: os:: unix:: fs:: symlink ( & secret, & link) . expect ( "create symlink" ) ;
8890+ let driver = rootfs_tar_test_driver ( & root, None ) ;
8891+
8892+ let err = driver
8893+ . validate_rootfs_tar_path ( & link)
8894+ . await
8895+ . expect_err ( "a symlink out of the staging root must be rejected" ) ;
8896+
8897+ assert_eq ! ( err. code( ) , Code :: PermissionDenied , "{err}" ) ;
8898+ let _ = std:: fs:: remove_dir_all ( & root) ;
8899+ let _ = std:: fs:: remove_dir_all ( & target) ;
8900+ }
8901+
8902+ #[ tokio:: test]
8903+ async fn validate_rootfs_tar_path_rejects_wrong_depth ( ) {
8904+ let root = unique_temp_dir ( ) ;
8905+ std:: fs:: create_dir_all ( & root) . expect ( "create staging root" ) ;
8906+ let shallow = root. join ( "rootfs.tar" ) ;
8907+ std:: fs:: write ( & shallow, b"payload" ) . expect ( "write shallow archive" ) ;
8908+ let deep_dir = root. join ( "req-a" ) . join ( "nested" ) ;
8909+ std:: fs:: create_dir_all ( & deep_dir) . expect ( "create deep dir" ) ;
8910+ let deep = deep_dir. join ( "rootfs.tar" ) ;
8911+ std:: fs:: write ( & deep, b"payload" ) . expect ( "write deep archive" ) ;
8912+ let driver = rootfs_tar_test_driver ( & root, None ) ;
8913+
8914+ for path in [ & shallow, & deep] {
8915+ let err = driver
8916+ . validate_rootfs_tar_path ( path)
8917+ . await
8918+ . expect_err ( "only request-directory depth is accepted" ) ;
8919+ assert_eq ! ( err. code( ) , Code :: PermissionDenied , "{err}" ) ;
8920+ }
8921+ let _ = std:: fs:: remove_dir_all ( & root) ;
8922+ }
8923+
8924+ #[ tokio:: test]
8925+ async fn validate_rootfs_tar_path_rejects_directory ( ) {
8926+ let root = unique_temp_dir ( ) ;
8927+ let request_dir = root. join ( "req-a" ) ;
8928+ let not_a_file = request_dir. join ( "rootfs.tar" ) ;
8929+ std:: fs:: create_dir_all ( & not_a_file) . expect ( "create directory in archive position" ) ;
8930+ let driver = rootfs_tar_test_driver ( & root, None ) ;
8931+
8932+ let err = driver
8933+ . validate_rootfs_tar_path ( & not_a_file)
8934+ . await
8935+ . expect_err ( "a directory is not a rootfs tar" ) ;
8936+
8937+ assert_eq ! ( err. code( ) , Code :: InvalidArgument , "{err}" ) ;
8938+ let _ = std:: fs:: remove_dir_all ( & root) ;
8939+ }
8940+
8941+ #[ tokio:: test]
8942+ async fn validate_rootfs_tar_path_enforces_max_bytes ( ) {
8943+ let root = unique_temp_dir ( ) ;
8944+ std:: fs:: create_dir_all ( & root) . expect ( "create staging root" ) ;
8945+ let archive = staged_rootfs_tar ( & root, "a" , & [ 0_u8 ; 64 ] ) ;
8946+ let driver = rootfs_tar_test_driver ( & root, Some ( 16 ) ) ;
8947+
8948+ let err = driver
8949+ . validate_rootfs_tar_path ( & archive)
8950+ . await
8951+ . expect_err ( "an oversized archive must be rejected" ) ;
8952+
8953+ assert_eq ! ( err. code( ) , Code :: InvalidArgument , "{err}" ) ;
8954+ let _ = std:: fs:: remove_dir_all ( & root) ;
8955+ }
8956+
8957+ /// Identity must follow the bytes, not the path. The gateway hands every
8958+ /// request its own staging directory, so a path-derived key would miss the
8959+ /// cache on every single create.
8960+ #[ test]
8961+ fn rootfs_tar_cache_identity_tracks_contents_not_path ( ) {
8962+ let same_a = rootfs_tar_cache_identity ( & compute_bytes_sha256_hex ( b"rootfs-bytes" ) ) ;
8963+ let same_b = rootfs_tar_cache_identity ( & compute_bytes_sha256_hex ( b"rootfs-bytes" ) ) ;
8964+ let different = rootfs_tar_cache_identity ( & compute_bytes_sha256_hex ( b"other-bytes" ) ) ;
8965+
8966+ assert_eq ! (
8967+ same_a, same_b,
8968+ "identical contents must share one prepared disk"
8969+ ) ;
8970+ assert_ne ! (
8971+ same_a, different,
8972+ "different contents must not collide on one prepared disk"
8973+ ) ;
8974+ }
8975+
8976+ /// The old key was `path + seconds-truncated mtime` run through a
8977+ /// punctuation sanitizer, so `/tmp/a/b.tar` and `/tmp/a-b.tar` collided and
8978+ /// a long path could blow past filesystem component limits.
8979+ #[ test]
8980+ fn rootfs_tar_cache_identity_is_bounded_and_separator_safe ( ) {
8981+ let long_path_digest = compute_bytes_sha256_hex ( & vec ! [ 7_u8 ; 4096 ] ) ;
8982+ let identity = rootfs_tar_cache_identity ( & long_path_digest) ;
8983+ let sanitized = sanitize_image_identity ( & identity) ;
8984+
8985+ assert ! (
8986+ sanitized. len( ) < 255 ,
8987+ "cache directory component must stay within filesystem limits, got {}" ,
8988+ sanitized. len( )
8989+ ) ;
8990+ assert_ne ! (
8991+ rootfs_tar_cache_identity( & compute_bytes_sha256_hex( b"/tmp/a/b.tar" ) ) ,
8992+ rootfs_tar_cache_identity( & compute_bytes_sha256_hex( b"/tmp/a-b.tar" ) ) ,
8993+ "separator-colliding inputs must not share an identity"
8994+ ) ;
8995+ }
8996+
8997+ #[ test]
8998+ fn copy_file_sha256_hex_matches_source_digest ( ) {
8999+ let base = unique_temp_dir ( ) ;
9000+ std:: fs:: create_dir_all ( & base) . expect ( "create base dir" ) ;
9001+ let src = base. join ( "src.tar" ) ;
9002+ let dst = base. join ( "dst.tar" ) ;
9003+ let payload = vec ! [ 3_u8 ; 200 * 1024 ] ;
9004+ std:: fs:: write ( & src, & payload) . expect ( "write source" ) ;
9005+
9006+ let copied = copy_file_sha256_hex ( & src, & dst) . expect ( "copy should succeed" ) ;
9007+
9008+ assert_eq ! ( copied, compute_file_sha256_hex( & src) . expect( "hash source" ) ) ;
9009+ assert_eq ! ( copied, compute_bytes_sha256_hex( & payload) ) ;
9010+ assert_eq ! ( std:: fs:: read( & dst) . expect( "read copy" ) , payload) ;
9011+ let _ = std:: fs:: remove_dir_all ( & base) ;
9012+ }
9013+
9014+ /// An archive rewritten between the hash pass and the copy pass yields a
9015+ /// different digest, which is what lets the caller reject it instead of
9016+ /// caching a disk under an identity that does not describe it.
9017+ #[ test]
9018+ fn copy_file_sha256_hex_detects_content_change_between_passes ( ) {
9019+ let base = unique_temp_dir ( ) ;
9020+ std:: fs:: create_dir_all ( & base) . expect ( "create base dir" ) ;
9021+ let src = base. join ( "src.tar" ) ;
9022+ std:: fs:: write ( & src, b"original" ) . expect ( "write source" ) ;
9023+ let first = compute_file_sha256_hex ( & src) . expect ( "hash source" ) ;
9024+
9025+ std:: fs:: write ( & src, b"replaced" ) . expect ( "rewrite source" ) ;
9026+ let second = copy_file_sha256_hex ( & src, & base. join ( "dst.tar" ) ) . expect ( "copy" ) ;
9027+
9028+ assert_ne ! (
9029+ first, second,
9030+ "a mid-staging rewrite must produce a different digest"
9031+ ) ;
9032+ let _ = std:: fs:: remove_dir_all ( & base) ;
9033+ }
9034+
87279035 fn test_driver_with_extensions ( extensions : LifecycleExtensionRegistry ) -> VmDriver {
87289036 let ( events, _) = broadcast:: channel ( WATCH_BUFFER ) ;
87299037 VmDriver {
0 commit comments