@@ -30,9 +30,16 @@ impl VmManager {
3030 read_only: false ,
3131 } ] ;
3232
33- // Add user-specified volume mounts (-v host:guest or -v host:guest:ro)
33+ // Add user-specified volume mounts (-v host:guest or -v host:guest:ro).
34+ // Single-file binds are staged under this per-box dir (cleaned with the
35+ // box) since virtio-fs can only share directories — see parse_volume_mount.
36+ let filemounts_dir = self
37+ . home_dir
38+ . join ( "boxes" )
39+ . join ( & self . box_id )
40+ . join ( ".filemounts" ) ;
3441 for ( i, vol) in self . config . volumes . iter ( ) . enumerate ( ) {
35- let mount = Self :: parse_volume_mount ( vol, i) ?;
42+ let mount = Self :: parse_volume_mount ( vol, i, & filemounts_dir ) ?;
3643 fs_mounts. push ( mount) ;
3744 }
3845
@@ -512,7 +519,7 @@ impl VmManager {
512519 ///
513520 /// Handles Windows paths with drive letters (e.g. `C:\Users\Temp:/data:ro`) by
514521 /// using the colon-split parts array to reliably determine the host/guest boundary.
515- fn parse_volume_mount ( volume : & str , index : usize ) -> Result < FsMount > {
522+ fn parse_volume_mount ( volume : & str , index : usize , filemounts_dir : & Path ) -> Result < FsMount > {
516523 let parts: Vec < & str > = volume. split ( ':' ) . collect ( ) ;
517524
518525 // A valid volume must have at least 2 colon-separated parts (host:guest)
@@ -626,6 +633,18 @@ impl VmManager {
626633 hint : None ,
627634 } ) ?;
628635
636+ // virtio-fs can only share a directory, so a single-file bind (host path
637+ // is a file) is staged into a per-box directory and that directory is
638+ // shared instead. The file is hard-linked in (so the bind stays live in
639+ // both directions), falling back to a copy across filesystems. It is named
640+ // with the guest path's basename — exactly what the guest looks for inside
641+ // the share (see mount_user_volumes' `file` branch).
642+ let host_path = if host_path. is_file ( ) {
643+ Self :: stage_single_file_mount ( & host_path, guest_path_str, index, filemounts_dir) ?
644+ } else {
645+ host_path
646+ } ;
647+
629648 // Use a unique tag for each user volume
630649 let tag = format ! ( "vol{}" , index) ;
631650
@@ -644,6 +663,53 @@ impl VmManager {
644663 } )
645664 }
646665
666+ /// Stage a single-file bind source into a per-box directory so virtio-fs (which
667+ /// shares directories, not bare files) can expose it. Returns the directory to
668+ /// share; it contains exactly one entry — the file under the guest path's
669+ /// basename, which `mount_user_volumes` then binds onto the guest path. The
670+ /// file is hard-linked to keep the bind live in both directions; across
671+ /// filesystems it falls back to a copy (host-side writes then do not propagate).
672+ fn stage_single_file_mount (
673+ source : & Path ,
674+ guest_path : & str ,
675+ index : usize ,
676+ filemounts_dir : & Path ,
677+ ) -> Result < PathBuf > {
678+ let basename = Path :: new ( guest_path) . file_name ( ) . ok_or_else ( || {
679+ BoxError :: ConfigError ( format ! (
680+ "Single-file bind guest path has no file name: {guest_path}"
681+ ) )
682+ } ) ?;
683+ let stage_dir = filemounts_dir. join ( index. to_string ( ) ) ;
684+ std:: fs:: create_dir_all ( & stage_dir) . map_err ( |e| BoxError :: BoxBootError {
685+ message : format ! (
686+ "Failed to create file-mount staging dir {}: {}" ,
687+ stage_dir. display( ) ,
688+ e
689+ ) ,
690+ hint : None ,
691+ } ) ?;
692+ let staged = stage_dir. join ( basename) ;
693+ let _ = std:: fs:: remove_file ( & staged) ; // idempotent across restarts
694+ if std:: fs:: hard_link ( source, & staged) . is_err ( ) {
695+ std:: fs:: copy ( source, & staged) . map_err ( |e| BoxError :: BoxBootError {
696+ message : format ! (
697+ "Failed to stage single-file mount {} -> {}: {}" ,
698+ source. display( ) ,
699+ staged. display( ) ,
700+ e
701+ ) ,
702+ hint : None ,
703+ } ) ?;
704+ tracing:: warn!(
705+ source = %source. display( ) ,
706+ "Single-file bind staged by copy (source on a different filesystem); \
707+ host-side writes will not propagate to the container"
708+ ) ;
709+ }
710+ Ok ( stage_dir)
711+ }
712+
647713 /// Create an anonymous volume via VolumeStore.
648714 ///
649715 /// Returns the host path of the created volume.
@@ -732,7 +798,8 @@ mod tests {
732798 let host_path = temp. path ( ) . to_str ( ) . unwrap ( ) ;
733799 let volume = format ! ( "{}:/data" , host_path) ;
734800
735- let mount = VmManager :: parse_volume_mount ( & volume, 0 ) . unwrap ( ) ;
801+ let mount =
802+ VmManager :: parse_volume_mount ( & volume, 0 , std:: path:: Path :: new ( "/tmp" ) ) . unwrap ( ) ;
736803 assert_eq ! ( mount. tag, "vol0" ) ;
737804 assert_eq ! ( mount. host_path, temp. path( ) . canonicalize( ) . unwrap( ) ) ;
738805 assert ! ( !mount. read_only) ;
@@ -744,7 +811,8 @@ mod tests {
744811 let host_path = temp. path ( ) . to_str ( ) . unwrap ( ) ;
745812 let volume = format ! ( "{}:/data:ro" , host_path) ;
746813
747- let mount = VmManager :: parse_volume_mount ( & volume, 1 ) . unwrap ( ) ;
814+ let mount =
815+ VmManager :: parse_volume_mount ( & volume, 1 , std:: path:: Path :: new ( "/tmp" ) ) . unwrap ( ) ;
748816 assert_eq ! ( mount. tag, "vol1" ) ;
749817 assert ! ( mount. read_only) ;
750818 }
@@ -755,18 +823,47 @@ mod tests {
755823 let host_path = temp. path ( ) . to_str ( ) . unwrap ( ) ;
756824 let volume = format ! ( "{}:/data:rw" , host_path) ;
757825
758- let mount = VmManager :: parse_volume_mount ( & volume, 2 ) . unwrap ( ) ;
826+ let mount =
827+ VmManager :: parse_volume_mount ( & volume, 2 , std:: path:: Path :: new ( "/tmp" ) ) . unwrap ( ) ;
759828 assert_eq ! ( mount. tag, "vol2" ) ;
760829 assert ! ( !mount. read_only) ;
761830 }
762831
832+ #[ test]
833+ fn test_parse_volume_mount_single_file_is_staged_as_dir ( ) {
834+ let temp = TempDir :: new ( ) . unwrap ( ) ;
835+ // A real source FILE (not a directory).
836+ let src = temp. path ( ) . join ( "hostfile.txt" ) ;
837+ std:: fs:: write ( & src, b"DATA" ) . unwrap ( ) ;
838+ let stage_base = temp. path ( ) . join ( "filemounts" ) ;
839+ let volume = format ! ( "{}:/etc/myconf" , src. display( ) ) ;
840+
841+ let mount = VmManager :: parse_volume_mount ( & volume, 3 , & stage_base) . unwrap ( ) ;
842+
843+ // virtio-fs shares directories, so host_path must be the staging DIR, not
844+ // the bare file.
845+ assert ! (
846+ mount. host_path. is_dir( ) ,
847+ "single-file bind must be staged into a directory, got {}" ,
848+ mount. host_path. display( )
849+ ) ;
850+ // The staged dir holds the file under the GUEST basename — what the guest
851+ // binds onto the guest path.
852+ let staged = mount. host_path . join ( "myconf" ) ;
853+ assert ! (
854+ staged. exists( ) ,
855+ "staged file under guest basename must exist"
856+ ) ;
857+ assert_eq ! ( std:: fs:: read( & staged) . unwrap( ) , b"DATA" ) ;
858+ }
859+
763860 #[ test]
764861 fn test_parse_volume_mount_invalid_mode ( ) {
765862 let temp = TempDir :: new ( ) . unwrap ( ) ;
766863 let host_path = temp. path ( ) . to_str ( ) . unwrap ( ) ;
767864 let volume = format ! ( "{}:/data:invalid" , host_path) ;
768865
769- let result = VmManager :: parse_volume_mount ( & volume, 0 ) ;
866+ let result = VmManager :: parse_volume_mount ( & volume, 0 , std :: path :: Path :: new ( "/tmp" ) ) ;
770867 assert ! ( result. is_err( ) ) ;
771868 assert ! ( result
772869 . unwrap_err( )
@@ -776,7 +873,7 @@ mod tests {
776873
777874 #[ test]
778875 fn test_parse_volume_mount_invalid_format ( ) {
779- let result = VmManager :: parse_volume_mount ( "invalid" , 0 ) ;
876+ let result = VmManager :: parse_volume_mount ( "invalid" , 0 , std :: path :: Path :: new ( "/tmp" ) ) ;
780877 assert ! ( result. is_err( ) ) ;
781878 assert ! ( result
782879 . unwrap_err( )
@@ -791,7 +888,8 @@ mod tests {
791888 let volume = format ! ( "{}:/data" , host_path. display( ) ) ;
792889
793890 assert ! ( !host_path. exists( ) ) ;
794- let mount = VmManager :: parse_volume_mount ( & volume, 0 ) . unwrap ( ) ;
891+ let mount =
892+ VmManager :: parse_volume_mount ( & volume, 0 , std:: path:: Path :: new ( "/tmp" ) ) . unwrap ( ) ;
795893 assert ! ( host_path. exists( ) ) ;
796894 assert_eq ! ( mount. host_path, host_path. canonicalize( ) . unwrap( ) ) ;
797895 }
@@ -1195,7 +1293,7 @@ mod tests {
11951293 // Path like /host/path:/guest/path:ro where guest path contains colon
11961294 let volume = format ! ( "{}:/data:/media/c:ro" , host_path) ;
11971295
1198- let result = VmManager :: parse_volume_mount ( & volume, 0 ) ;
1296+ let result = VmManager :: parse_volume_mount ( & volume, 0 , std :: path :: Path :: new ( "/tmp" ) ) ;
11991297 // Should handle this gracefully or error on the guest path with colon
12001298 // The exact behavior depends on implementation
12011299 assert ! ( result. is_err( ) || result. is_ok( ) ) ; // Just verify it doesn't panic
0 commit comments