1use std::path::{Path, PathBuf};
4
5use a3s_box_core::config::{ResourceConfig, TeeConfig};
6use a3s_box_core::{
7 BoxError, ExecutionBackend, ExecutionIsolation, ExecutionManagerError, ExecutionManagerResult,
8 NetworkMode,
9};
10use a3s_oci_sdk::{CreateAttachments, IoMode, IsolationRequest, OciBundle, ProcessIo};
11use async_trait::async_trait;
12
13use super::{
14 LocalExecutionResourcePlan, OciBundlePreparationContext, OciBundleProvider,
15 OciPreparedExecution, VmLocalExecutionBackend,
16};
17use crate::sandbox::probe_sandbox_capabilities_for;
18use crate::{BoxRecord, ManagedExecutionMetadata};
19
20#[derive(Clone)]
23pub struct NativeLinuxOciBundleProvider {
24 preparer: VmLocalExecutionBackend,
25 runtime_path: PathBuf,
26 agent_path: PathBuf,
27}
28
29#[derive(Clone)]
31pub struct WindowsWhpxOciBundleProvider {
32 preparer: VmLocalExecutionBackend,
33 runtime_root: PathBuf,
34}
35
36impl WindowsWhpxOciBundleProvider {
37 pub fn new(home_dir: impl Into<PathBuf>, runtime_root: impl Into<PathBuf>) -> Self {
38 Self {
39 preparer: VmLocalExecutionBackend::new(home_dir),
40 runtime_root: runtime_root.into(),
41 }
42 }
43
44 pub fn runtime_root(&self) -> &Path {
45 &self.runtime_root
46 }
47
48 pub fn with_pull_progress_fn(mut self, pull_progress_fn: crate::PullProgressFn) -> Self {
49 self.preparer = self.preparer.with_pull_progress_fn(pull_progress_fn);
50 self
51 }
52}
53
54impl NativeLinuxOciBundleProvider {
55 pub fn new(
56 home_dir: impl Into<PathBuf>,
57 runtime_path: impl Into<PathBuf>,
58 agent_path: impl Into<PathBuf>,
59 ) -> Self {
60 Self {
61 preparer: VmLocalExecutionBackend::new(home_dir),
62 runtime_path: runtime_path.into(),
63 agent_path: agent_path.into(),
64 }
65 }
66
67 pub fn runtime_path(&self) -> &Path {
68 &self.runtime_path
69 }
70
71 pub fn agent_path(&self) -> &Path {
72 &self.agent_path
73 }
74
75 pub fn with_pull_progress_fn(mut self, pull_progress_fn: crate::PullProgressFn) -> Self {
76 self.preparer = self.preparer.with_pull_progress_fn(pull_progress_fn);
77 self
78 }
79}
80
81#[async_trait]
82impl OciBundleProvider for NativeLinuxOciBundleProvider {
83 async fn plan_create_resources(
84 &self,
85 record: &BoxRecord,
86 ) -> ExecutionManagerResult<LocalExecutionResourcePlan> {
87 let metadata = native_linux_metadata(record)?;
88 let mut manager = self.preparer.new_oci_preparation_manager(record)?;
89 let anonymous_volumes =
90 if let Some(snapshot_id) = metadata.request.rootfs_snapshot_id.as_ref() {
91 let home_dir = self.preparer.home_dir().to_path_buf();
92 let snapshot_id = snapshot_id.to_string();
93 let expected_image = metadata.request.config.image.clone();
94 let config = tokio::task::spawn_blocking(move || {
95 let store = crate::SnapshotStore::new(&home_dir.join("snapshots"))?;
96 let _snapshot_lock = store.acquire_exclusive_lock()?;
97 let rootfs = store.rootfs_path(&snapshot_id);
98 crate::resolved_image::load_snapshot_oci_config(&rootfs, &expected_image)
99 })
100 .await
101 .map_err(|error| {
102 ExecutionManagerError::Internal(format!(
103 "native Linux OCI snapshot resource planning task failed: {error}"
104 ))
105 })?
106 .map_err(|error| preparation_error("plan snapshot-owned resources", error))?;
107 manager
108 .plan_anonymous_volumes(&config)
109 .map(|plans| plans.into_iter().map(|plan| plan.name).collect())
110 .map_err(|error| preparation_error("plan snapshot-owned resources", error))?
111 } else {
112 manager
113 .plan_image_anonymous_volumes()
114 .await
115 .map_err(|error| preparation_error("plan image-owned resources", error))?
116 };
117 Ok(LocalExecutionResourcePlan { anonymous_volumes })
118 }
119
120 async fn prepare(
121 &self,
122 record: &BoxRecord,
123 _context: &OciBundlePreparationContext,
124 ) -> ExecutionManagerResult<OciPreparedExecution> {
125 let metadata = native_linux_metadata(record)?;
126
127 let capabilities = probe_sandbox_capabilities_for(
130 ExecutionBackend::A3sOci,
131 Some(&self.runtime_path),
132 Some(&self.agent_path),
133 );
134 capabilities
135 .require_ready()
136 .map_err(|error| preparation_error("capability preflight", error))?;
137
138 let mut manager = self.preparer.new_oci_preparation_manager(record)?;
139 let prepared = manager
140 .prepare_runtime_owned_sandbox_bundle(&metadata.plan, &capabilities)
141 .await
142 .map_err(|error| preparation_error("prepare bundle", error))?;
143 let bundle = match OciBundle::load(&prepared.bundle_dir).await {
144 Ok(bundle) => bundle,
145 Err(error) => {
146 let cleanup = manager.cleanup_runtime_owned_sandbox_bundle();
147 return Err(match cleanup {
148 Ok(()) => ExecutionManagerError::Internal(format!(
149 "failed to load the generated OCI bundle: {error}"
150 )),
151 Err(cleanup) => ExecutionManagerError::Internal(format!(
152 "failed to load the generated OCI bundle: {error}; cleanup also failed: {cleanup}"
153 )),
154 });
155 }
156 };
157 let io = ProcessIo {
158 stdin: if metadata.request.config.stdin_open {
159 IoMode::Pipe
160 } else {
161 IoMode::Null
162 },
163 stdout: IoMode::Capture,
164 stderr: IoMode::Capture,
165 terminal_size: None,
166 };
167 let attachments = match CreateAttachments::from_bundle(&bundle, io) {
168 Ok(attachments) => attachments,
169 Err(error) => {
170 return Err(cleanup_after_prepare_failure(
171 &manager,
172 format!("failed to derive generated OCI bundle attachments: {error}"),
173 ));
174 }
175 };
176 let mut result = match OciPreparedExecution::with_attachments(
177 bundle,
178 attachments,
179 prepared.console_output,
180 ) {
181 Ok(result) => result,
182 Err(error) => {
183 return Err(cleanup_after_prepare_failure(
184 &manager,
185 format!("failed to validate generated OCI bundle attachments: {error}"),
186 ));
187 }
188 };
189 result.anonymous_volumes = prepared.anonymous_volumes;
190 Ok(result)
191 }
192
193 async fn cleanup(&self, record: &BoxRecord) -> ExecutionManagerResult<()> {
194 let manager = self.preparer.new_oci_preparation_manager(record)?;
195 manager
196 .cleanup_runtime_owned_sandbox_bundle()
197 .map_err(|error| preparation_error("cleanup bundle", error))
198 }
199
200 async fn ensure_log_projection(
201 &self,
202 record: &BoxRecord,
203 binding: &super::OciRuntimeBinding,
204 ) -> ExecutionManagerResult<()> {
205 super::oci_log_projection::ensure(record, binding).await
206 }
207
208 async fn wait_log_projection_drained(
209 &self,
210 record: &BoxRecord,
211 binding: &super::OciRuntimeBinding,
212 ) -> ExecutionManagerResult<()> {
213 super::oci_log_projection::wait_drained(record, binding).await
214 }
215
216 async fn wait_log_projection_stopped_after_owner_loss(
217 &self,
218 record: &BoxRecord,
219 binding: &super::OciRuntimeBinding,
220 ) -> ExecutionManagerResult<()> {
221 super::oci_log_projection::wait_stopped_after_owner_loss(record, binding).await
222 }
223}
224
225#[async_trait]
226impl OciBundleProvider for WindowsWhpxOciBundleProvider {
227 fn preflight(
228 &self,
229 record: &BoxRecord,
230 context: &OciBundlePreparationContext,
231 ) -> ExecutionManagerResult<()> {
232 if !cfg!(all(target_os = "windows", target_arch = "x86_64")) {
233 return Err(ExecutionManagerError::Unavailable(
234 "Box/WHPX OCI qualification requires Windows x86_64".to_string(),
235 ));
236 }
237 if record.isolation != ExecutionIsolation::Microvm
238 || !matches!(context.isolation(), IsolationRequest::DedicatedVm)
239 {
240 return Err(ExecutionManagerError::InvalidRequest(
241 "Box/WHPX OCI qualification requires dedicated MicroVM isolation".to_string(),
242 ));
243 }
244 let metadata = record.managed_execution.as_ref().ok_or_else(|| {
245 ExecutionManagerError::Internal(format!(
246 "execution {} has no managed lifecycle metadata",
247 record.id
248 ))
249 })?;
250 metadata
251 .validate()
252 .map_err(|error| ExecutionManagerError::Internal(error.to_string()))?;
253 if metadata.plan.backend != ExecutionBackend::Krun {
254 return Err(ExecutionManagerError::InvalidRequest(format!(
255 "execution {} did not resolve to the MicroVM backend",
256 record.id
257 )));
258 }
259 validate_whpx_qualification(record)?;
260 context.runtime_bundle_handoff_directory(&self.runtime_root)?;
261 validate_runtime_root(&self.runtime_root)
262 }
263
264 async fn prepare(
265 &self,
266 record: &BoxRecord,
267 context: &OciBundlePreparationContext,
268 ) -> ExecutionManagerResult<OciPreparedExecution> {
269 self.preflight(record, context)?;
270 let metadata = record.managed_execution.as_ref().ok_or_else(|| {
271 ExecutionManagerError::Internal(format!(
272 "execution {} has no managed lifecycle metadata",
273 record.id
274 ))
275 })?;
276 let bundle_directory = context.runtime_bundle_handoff_directory(&self.runtime_root)?;
277 let mut manager = self.preparer.new_oci_preparation_manager(record)?;
278 let prepared = manager
279 .prepare_runtime_owned_microvm_bundle(&metadata.plan, &bundle_directory)
280 .await
281 .map_err(|error| whpx_preparation_error("prepare bundle", error))?;
282 let bundle = match OciBundle::load(&prepared.bundle_dir).await {
283 Ok(bundle) => bundle,
284 Err(error) => {
285 return Err(cleanup_after_whpx_prepare_failure(
286 &manager,
287 &bundle_directory,
288 format!("failed to load the generated portable OCI bundle: {error}"),
289 ));
290 }
291 };
292 let io = ProcessIo {
293 stdin: if metadata.request.config.stdin_open {
294 IoMode::Pipe
295 } else {
296 IoMode::Null
297 },
298 stdout: IoMode::Capture,
299 stderr: IoMode::Capture,
300 terminal_size: None,
301 };
302 let attachments = match CreateAttachments::from_bundle(&bundle, io) {
303 Ok(attachments) => attachments,
304 Err(error) => {
305 return Err(cleanup_after_whpx_prepare_failure(
306 &manager,
307 &bundle_directory,
308 format!("failed to derive portable OCI bundle attachments: {error}"),
309 ));
310 }
311 };
312 let mut result = match OciPreparedExecution::with_attachments(
313 bundle,
314 attachments,
315 prepared.console_output,
316 ) {
317 Ok(result) => result,
318 Err(error) => {
319 return Err(cleanup_after_whpx_prepare_failure(
320 &manager,
321 &bundle_directory,
322 format!("failed to validate portable OCI bundle attachments: {error}"),
323 ));
324 }
325 };
326 result = match result.with_runtime_bundle_handoff(context, &self.runtime_root) {
327 Ok(result) => result,
328 Err(error) => {
329 return Err(cleanup_after_whpx_prepare_failure(
330 &manager,
331 &bundle_directory,
332 format!("failed to bind portable OCI bundle handoff: {error}"),
333 ));
334 }
335 };
336 result.anonymous_volumes = prepared.anonymous_volumes;
337 Ok(result)
338 }
339
340 async fn cleanup(&self, record: &BoxRecord) -> ExecutionManagerResult<()> {
341 let manager = self.preparer.new_oci_preparation_manager(record)?;
342 manager
343 .cleanup_runtime_owned_microvm_bundle()
344 .map_err(|error| whpx_preparation_error("cleanup bundle", error))
345 }
346
347 async fn ensure_log_projection(
348 &self,
349 record: &BoxRecord,
350 binding: &super::OciRuntimeBinding,
351 ) -> ExecutionManagerResult<()> {
352 super::oci_log_projection::ensure(record, binding).await
353 }
354
355 async fn wait_log_projection_drained(
356 &self,
357 record: &BoxRecord,
358 binding: &super::OciRuntimeBinding,
359 ) -> ExecutionManagerResult<()> {
360 super::oci_log_projection::wait_drained(record, binding).await
361 }
362
363 async fn wait_log_projection_stopped_after_owner_loss(
364 &self,
365 record: &BoxRecord,
366 binding: &super::OciRuntimeBinding,
367 ) -> ExecutionManagerResult<()> {
368 super::oci_log_projection::wait_stopped_after_owner_loss(record, binding).await
369 }
370}
371
372fn validate_whpx_qualification(record: &BoxRecord) -> ExecutionManagerResult<()> {
373 let metadata = record.managed_execution.as_ref().ok_or_else(|| {
374 ExecutionManagerError::Internal(format!(
375 "execution {} has no managed lifecycle metadata",
376 record.id
377 ))
378 })?;
379 let config = &metadata.request.config;
380 let defaults = ResourceConfig::default();
381 if config.resources.vcpus != 1 || config.resources.memory_mb != 512 {
382 return Err(unqualified(
383 "the fixed WHPX profile requires exactly 1 vCPU and 512 MiB of memory",
384 ));
385 }
386 if config.resources.disk_mb != defaults.disk_mb || config.resources.timeout != defaults.timeout
387 {
388 return Err(unqualified(
389 "custom disk size or lifetime timeout is not qualified for the WHPX OCI profile",
390 ));
391 }
392 if config.tee != TeeConfig::None {
393 return Err(unqualified("TEE is not qualified for the WHPX OCI profile"));
394 }
395 if !config.workspace.as_os_str().is_empty()
396 || !config.volumes.is_empty()
397 || config.virtiofs_cache.is_some()
398 || !metadata.request.policy.volume_names.is_empty()
399 || metadata.request.policy.managed_secret_root.is_some()
400 {
401 return Err(unqualified(
402 "workspace, bind, named, and secret mounts are not qualified for the WHPX OCI profile",
403 ));
404 }
405 if !matches!(config.network, NetworkMode::None)
406 || !matches!(record.network_mode, NetworkMode::None)
407 || !config.port_map.is_empty()
408 || !config.dns.is_empty()
409 || !config.add_hosts.is_empty()
410 {
411 return Err(unqualified(
412 "the WHPX OCI profile requires network=none and no network customization",
413 ));
414 }
415 if config.pool.enabled
416 || config.pool.snapshot_fork
417 || config.deferred_main
418 || config.ksm
419 || config.snapshot_mem_file.is_some()
420 || config.snapshot_sock.is_some()
421 || config.restore_from.is_some()
422 || metadata.request.rootfs_snapshot_id.is_some()
423 {
424 return Err(unqualified(
425 "pool, deferred-main, KSM, and Snapshot modes are not qualified for the WHPX OCI profile",
426 ));
427 }
428 if !config.tmpfs.is_empty()
429 || config.resource_limits != Default::default()
430 || !config.cap_add.is_empty()
431 || !config.cap_drop.is_empty()
432 || !config.security_opt.is_empty()
433 || !config.sysctls.is_empty()
434 || config.privileged
435 || config.read_only
436 || config.sidecar.is_some()
437 || config.persistent
438 {
439 return Err(unqualified(
440 "custom mounts, controls, privileges, sidecars, and persistence are not qualified for the WHPX OCI profile",
441 ));
442 }
443 let policy = &metadata.request.policy;
444 if policy.init
445 || !policy.devices.is_empty()
446 || policy.gpus.is_some()
447 || policy.shm_size.is_some()
448 || policy.oom_kill_disable
449 || policy.oom_score_adj.is_some()
450 {
451 return Err(unqualified(
452 "init, device, GPU, shared-memory, and OOM overrides are not qualified for the WHPX OCI profile",
453 ));
454 }
455 if policy.platform.as_deref().is_some_and(|platform| {
456 !matches!(
457 platform.trim().to_ascii_lowercase().as_str(),
458 "linux/amd64" | "linux/x86_64"
459 )
460 }) {
461 return Err(unqualified(
462 "the WHPX OCI profile supports only Linux amd64 images",
463 ));
464 }
465 if record.cpus != 1 || record.memory_mb != 512 {
466 return Err(ExecutionManagerError::Internal(
467 "Box record resources drifted from the fixed WHPX qualification profile".to_string(),
468 ));
469 }
470 Ok(())
471}
472
473fn unqualified(message: &str) -> ExecutionManagerError {
474 ExecutionManagerError::InvalidRequest(format!("Box/WHPX OCI qualification rejected: {message}"))
475}
476
477fn validate_runtime_root(path: &Path) -> ExecutionManagerResult<()> {
478 let metadata = std::fs::symlink_metadata(path).map_err(|error| {
479 ExecutionManagerError::Unavailable(format!(
480 "failed to inspect WHPX runtime root {}: {error}",
481 path.display()
482 ))
483 })?;
484 if !metadata.is_dir() || metadata_is_reparse_point(&metadata) {
485 return Err(ExecutionManagerError::InvalidRequest(format!(
486 "WHPX runtime root is not a plain directory: {}",
487 path.display()
488 )));
489 }
490 Ok(())
491}
492
493#[cfg(windows)]
494fn metadata_is_reparse_point(metadata: &std::fs::Metadata) -> bool {
495 use std::os::windows::fs::MetadataExt as _;
496 const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400;
497 metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
498}
499
500#[cfg(not(windows))]
501fn metadata_is_reparse_point(metadata: &std::fs::Metadata) -> bool {
502 metadata.file_type().is_symlink()
503}
504
505fn whpx_preparation_error(action: &str, error: BoxError) -> ExecutionManagerError {
506 match error {
507 BoxError::ConfigError(message) => ExecutionManagerError::InvalidRequest(message),
508 error => {
509 ExecutionManagerError::Unavailable(format!("Box/WHPX OCI {action} failed: {error}"))
510 }
511 }
512}
513
514fn cleanup_after_whpx_prepare_failure(
515 manager: &crate::VmManager,
516 bundle_directory: &Path,
517 message: String,
518) -> ExecutionManagerError {
519 let bundle_cleanup = remove_scoped_bundle(bundle_directory);
520 let rootfs_cleanup = manager.cleanup_runtime_owned_microvm_bundle();
521 match (bundle_cleanup, rootfs_cleanup) {
522 (Ok(()), Ok(())) => ExecutionManagerError::Internal(message),
523 (bundle, rootfs) => ExecutionManagerError::Internal(format!(
524 "{message}; cleanup also failed: handoff={bundle:?}, rootfs={rootfs:?}"
525 )),
526 }
527}
528
529fn remove_scoped_bundle(bundle_directory: &Path) -> std::io::Result<()> {
530 if bundle_directory.file_name().and_then(|name| name.to_str()) != Some("bundle")
531 || bundle_directory.parent().and_then(Path::parent).is_none()
532 {
533 return Err(std::io::Error::new(
534 std::io::ErrorKind::InvalidInput,
535 format!(
536 "refusing to remove an unscoped bundle: {}",
537 bundle_directory.display()
538 ),
539 ));
540 }
541 let metadata = match std::fs::symlink_metadata(bundle_directory) {
542 Ok(metadata) => metadata,
543 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
544 Err(error) => return Err(error),
545 };
546 if !metadata.is_dir() || metadata_is_reparse_point(&metadata) {
547 return Err(std::io::Error::new(
548 std::io::ErrorKind::InvalidData,
549 format!(
550 "bundle is not a plain directory: {}",
551 bundle_directory.display()
552 ),
553 ));
554 }
555 std::fs::remove_dir_all(bundle_directory)
556}
557
558fn preparation_error(action: &str, error: BoxError) -> ExecutionManagerError {
559 match error {
560 BoxError::ConfigError(message) => ExecutionManagerError::InvalidRequest(message),
561 error => {
562 ExecutionManagerError::Unavailable(format!("native Linux OCI {action} failed: {error}"))
563 }
564 }
565}
566
567fn native_linux_metadata(record: &BoxRecord) -> ExecutionManagerResult<&ManagedExecutionMetadata> {
568 if record.isolation != ExecutionIsolation::Sandbox {
569 return Err(ExecutionManagerError::InvalidRequest(format!(
570 "native Linux OCI migration only prepares Sandbox executions, got {:?}",
571 record.isolation
572 )));
573 }
574 let metadata = record.managed_execution.as_ref().ok_or_else(|| {
575 ExecutionManagerError::Internal(format!(
576 "execution {} has no managed lifecycle metadata",
577 record.id
578 ))
579 })?;
580 metadata
581 .validate()
582 .map_err(|error| ExecutionManagerError::Internal(error.to_string()))?;
583 if metadata.plan.backend != ExecutionBackend::A3sOci {
584 return Err(ExecutionManagerError::InvalidRequest(format!(
585 "execution {} did not resolve to the A3S OCI Sandbox backend",
586 record.id
587 )));
588 }
589 Ok(metadata)
590}
591
592fn cleanup_after_prepare_failure(
593 manager: &crate::VmManager,
594 message: String,
595) -> ExecutionManagerError {
596 match manager.cleanup_runtime_owned_sandbox_bundle() {
597 Ok(()) => ExecutionManagerError::Internal(message),
598 Err(cleanup) => {
599 ExecutionManagerError::Internal(format!("{message}; cleanup also failed: {cleanup}"))
600 }
601 }
602}
603
604#[cfg(test)]
605mod tests {
606 use std::collections::BTreeMap;
607
608 use a3s_box_core::{
609 BoxConfig, CreateExecutionRequest, ExecutionId, ExecutionIsolation, ExecutionSnapshotId,
610 NetworkMode, OperationId, SnapshotImageConfig, SnapshotMetadata,
611 };
612
613 use super::*;
614
615 #[tokio::test]
616 async fn native_resource_plan_uses_snapshot_metadata_without_resolving_a_moved_tag() {
617 let temporary = tempfile::tempdir().unwrap();
618 let home = temporary.path().join("home");
619 let snapshot_id = "anonymous-volume-snapshot";
620 let image = "example.invalid/moved:latest";
621 let source = temporary.path().join("snapshot-rootfs");
622 std::fs::create_dir_all(&source).unwrap();
623 let mut snapshot = SnapshotMetadata::new(
624 snapshot_id.to_string(),
625 snapshot_id.to_string(),
626 "source-execution".to_string(),
627 image.to_string(),
628 );
629 snapshot.image_config = Some(SnapshotImageConfig {
630 volumes: vec!["/snapshot-data".to_string()],
631 ..Default::default()
632 });
633 crate::SnapshotStore::new(&home.join("snapshots"))
634 .unwrap()
635 .save(snapshot, &source)
636 .unwrap();
637
638 let execution_id =
639 ExecutionId::new("12345678-0000-0000-0000-000000000001".to_string()).unwrap();
640 let request = CreateExecutionRequest {
641 external_sandbox_id: "snapshot-plan".to_string(),
642 config: BoxConfig {
643 image: image.to_string(),
644 isolation: ExecutionIsolation::Sandbox,
645 network: NetworkMode::None,
646 ..Default::default()
647 },
648 labels: BTreeMap::new(),
649 policy: Default::default(),
650 rootfs_snapshot_id: Some(ExecutionSnapshotId::new(snapshot_id).unwrap()),
651 };
652 let mut record = crate::local_execution::record::build_managed_record(
653 &home,
654 &execution_id,
655 OperationId::new("snapshot-resource-plan").unwrap(),
656 request,
657 chrono::Utc::now(),
658 )
659 .unwrap();
660 record.managed_execution.as_mut().unwrap().runtime_route =
661 crate::ManagedRuntimeRoute::OciSdk;
662 let provider = NativeLinuxOciBundleProvider::new(&home, "/runtime", "/agent");
663
664 let plan = provider.plan_create_resources(&record).await.unwrap();
665
666 assert_eq!(plan.anonymous_volumes.len(), 1);
667 assert!(plan.anonymous_volumes[0].starts_with("anon_12345678_"));
668 assert!(!home.join("images").exists());
669 assert!(!record.box_dir.exists());
670 assert!(!home.join("volumes").exists());
671 }
672}