Skip to content

Commit 01af700

Browse files
authored
Merge pull request #48 from AI45Lab/fix/box-single-file-bind
fix(runtime): stage single-file bind mounts so virtio-fs can share them
2 parents 2dfcb77 + de1a834 commit 01af700

1 file changed

Lines changed: 108 additions & 10 deletions

File tree

src/runtime/src/vm/spec.rs

Lines changed: 108 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)