Skip to main content

supermachine/vmm/
resources.rs

1//! Typed VM configuration resources.
2//!
3//! This is the first reusable config boundary lifted out of the supermachine-worker
4//! command-line harness. Keep this free of CLI parsing and host process state
5//! so it can grow into the general Supermachine VM-library input surface.
6
7use std::fmt;
8
9pub const DEFAULT_CMDLINE: &str = "console=ttyAMA0 reboot=t panic=-1";
10pub const DEFAULT_MEMORY_MIB: usize = 256;
11pub const DEFAULT_VCPUS: u32 = 1;
12pub const THROUGHPUT_PROFILE_VCPUS: u32 = 4;
13
14#[derive(Clone, Copy, Debug, PartialEq, Eq)]
15pub enum VmProfile {
16    Latency,
17    Throughput,
18}
19
20impl VmProfile {
21    pub fn parse(s: &str) -> Option<Self> {
22        match s {
23            "latency" | "low-latency" => Some(Self::Latency),
24            "throughput" => Some(Self::Throughput),
25            _ => None,
26        }
27    }
28
29    pub fn default_vcpus(self) -> u32 {
30        match self {
31            Self::Latency => DEFAULT_VCPUS,
32            Self::Throughput => THROUGHPUT_PROFILE_VCPUS,
33        }
34    }
35}
36
37#[derive(Clone, Debug, PartialEq, Eq)]
38pub struct VmResources {
39    pub kernel_path: Option<String>,
40    pub initrd_path: Option<String>,
41    pub cmdline: String,
42    pub memory_mib: usize,
43    /// Read-only block devices (squashfs layers, delta squashfs).
44    /// Attached as virtio-blk before any volumes.
45    pub block_devices: Vec<String>,
46    /// Read-write block devices for `--volume` persistent volumes.
47    /// Attached as virtio-blk after `block_devices`. Each is a host
48    /// file the guest mounts as a writable filesystem.
49    pub volumes: Vec<VolumeSpec>,
50    /// virtio-fs DAX mounts. Each entry attaches a host directory
51    /// to the guest, served via FUSE-over-virtio with DAX
52    /// (zero-copy mmap'd reads, page-cache-shared across VMs).
53    pub mounts: Vec<MountSpec>,
54    pub vcpus: u32,
55    pub restore_from: Option<String>,
56    pub cow_restore: bool,
57    pub snapshot: SnapshotResources,
58    pub endpoints: EndpointResources,
59    /// If `Some(N)`, the runner asks the guest's virtio-balloon
60    /// driver to inflate by `N` 4 KiB pages after restore (or
61    /// after kernel boot in the no-restore path). The host then
62    /// reclaims those pages via `madvise(MADV_FREE)`, dropping
63    /// per-worker idle RSS. Driven by metadata.json's
64    /// `balloon_target_pages` field on the snapshot, plumbed
65    /// through `--balloon-target-pages N` on the worker CLI.
66    pub balloon_target_pages: Option<u32>,
67    /// TSI control-channel auth token (32 bytes). When `Some`,
68    /// the vsock muxer enforces that every TSI control DGRAM
69    /// carries this prefix; the kernel patch 0014 + the
70    /// `supermachine.tsi_token=<hex>` cmdline parameter produce
71    /// matching prefixes. Per-snapshot at bake time, re-injected
72    /// at restore. `None` means legacy (pre-0.6.0) snapshots
73    /// that don't have a baked token; the muxer accepts all
74    /// control DGRAMs in that case, matching the kernel's
75    /// "auth disabled" send path.
76    pub tsi_token: Option<[u8; 32]>,
77}
78
79/// A `--volume HOST_FILE:GUEST_PATH` entry. The host file is the
80/// canonical store; on first use init-oci formats it ext4. Once
81/// formatted, the same file persists across runs and across
82/// snapshot restores. Snapshots don't capture the volume contents
83/// (they live on the host); they only capture the mapping.
84///
85/// init-oci auto-mounts every declared volume at its `guest_path`
86/// before the guest workload starts. When `guest_path` nests under
87/// a virtio-fs [`MountSpec`] target (e.g. mount at `/workspace`,
88/// volume at `/workspace/node_modules`), set `guest_path` on BOTH
89/// — 0.7.28+ init-oci auto-mounts virtio-fs binds first, volumes
90/// second, so the layering matches Docker / Apple `container`
91/// conventions and child volumes correctly overlay parent binds.
92#[derive(Clone, Debug, PartialEq, Eq)]
93pub struct VolumeSpec {
94    /// Path to the host file backing this volume. Created (sparse,
95    /// `size_bytes`) if missing.
96    pub host_path: String,
97    /// Mount point inside the guest (e.g. `/var/lib/postgres`).
98    pub guest_path: String,
99    /// Size of the volume in bytes. Default 1 GiB. Files are
100    /// sparse — actual disk usage matches what the guest writes.
101    pub size_bytes: u64,
102}
103
104impl VolumeSpec {
105    pub const DEFAULT_SIZE_BYTES: u64 = 1024 * 1024 * 1024;
106
107    pub fn new(host_path: impl Into<String>, guest_path: impl Into<String>) -> Self {
108        Self {
109            host_path: host_path.into(),
110            guest_path: guest_path.into(),
111            size_bytes: Self::DEFAULT_SIZE_BYTES,
112        }
113    }
114
115    pub fn with_size_bytes(mut self, size_bytes: u64) -> Self {
116        self.size_bytes = size_bytes;
117        self
118    }
119}
120
121/// Per-mount symlink policy. Picks the right shape of symlink
122/// support for the trust level of what's running in the VM.
123///
124/// All three modes use `O_NOFOLLOW` on host-side path ops; the policy
125/// gates (a) whether the guest can create symlinks at all and
126/// (b) whether existing host symlinks pointing OUTSIDE the mount
127/// root are visible/traversable.
128#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
129pub enum SymlinkPolicy {
130    /// Guest cannot create symlinks (FUSE_SYMLINK → EPERM) and cannot
131    /// create hard links (FUSE_LINK → EPERM). Existing host symlinks
132    /// whose canonical target leaves the mount root are rejected with
133    /// EACCES at LOOKUP. Use for paranoid mounts where you want pure
134    /// file content with no metadata surprises.
135    Deny,
136    /// Guest can create symlinks; targets are stored as opaque bytes
137    /// (the host never resolves them — POSIX symlink(2) semantics).
138    /// Existing external host symlinks are rejected at LOOKUP under
139    /// the same rule as `Deny`. This is the safe-multi-tenant default:
140    /// npm/pnpm/yarn all work, but a hostile guest can't trick the
141    /// host into walking outside the mount root via a planted
142    /// `/escape -> /etc/shadow` symlink.
143    #[default]
144    Opaque,
145    /// Guest can create symlinks; external host symlinks (those
146    /// resolving outside the canonical mount root) are followed
147    /// unconditionally. Use ONLY for trusted single-tenant workloads
148    /// where the mount tree may legitimately reference absolute host
149    /// paths (e.g. `~/.cache` from a dev tree). This is the equivalent
150    /// of the pre-0.5.5 `allow_external_symlinks: true`.
151    Follow,
152}
153
154/// A virtio-fs DAX mount: expose a host directory to the guest at
155/// `guest_tag`. init-oci auto-mounts the share at `guest_path`
156/// (absolute path inside the guest) before the workload starts and
157/// before any volume auto-mounts — Docker / Apple `container`
158/// convention. Nested volume `guest_path`s overlay correctly.
159///
160/// Reads + writes flow through virtio-fs. When the guest mmaps a
161/// file, FUSE_SETUPMAPPING routes the file's host pages into the
162/// VM's DAX window (validated by spike 22) — zero-copy reads, host
163/// page-cache shared across VMs that mount the same path.
164#[derive(Clone, Debug, PartialEq, Eq)]
165pub struct MountSpec {
166    /// Host directory to expose. Must exist; must be a directory.
167    pub host_path: String,
168    /// virtiofs tag the guest mounts by. Max 35 UTF-8 bytes (one
169    /// reserved for the trailing NUL in the device config space).
170    pub guest_tag: String,
171    /// Absolute guest mount point. init-oci auto-mounts the share
172    /// here before the workload starts and before any volume
173    /// auto-mounts. Required — no "userspace will mount it" mode.
174    pub guest_path: String,
175    /// Read-only mode. When true the FUSE backend rejects WRITE +
176    /// CREATE + UNLINK / MKDIR / RMDIR. (Currently always read-only
177    /// at the backend level — write path lands in a follow-up
178    /// slice; this field is the future API surface.)
179    pub read_only: bool,
180    /// Symlink policy. See [`SymlinkPolicy`] for the three modes:
181    /// `Deny` (no symlinks at all), `Opaque` (default — guest may
182    /// create, host stores targets verbatim, external host symlinks
183    /// rejected), `Follow` (legacy `allow_external_symlinks: true`
184    /// behaviour).
185    pub symlinks: SymlinkPolicy,
186}
187
188impl MountSpec {
189    pub fn new(
190        host_path: impl Into<String>,
191        guest_tag: impl Into<String>,
192        guest_path: impl Into<String>,
193    ) -> Self {
194        Self {
195            host_path: host_path.into(),
196            guest_tag: guest_tag.into(),
197            guest_path: guest_path.into(),
198            read_only: false,
199            symlinks: SymlinkPolicy::default(),
200        }
201    }
202
203    pub fn read_only(mut self) -> Self {
204        self.read_only = true;
205        self
206    }
207
208    /// Set the symlink policy. See [`SymlinkPolicy`].
209    pub fn with_symlinks(mut self, p: SymlinkPolicy) -> Self {
210        self.symlinks = p;
211        self
212    }
213}
214
215#[derive(Clone, Debug, Default, PartialEq, Eq)]
216pub struct SnapshotResources {
217    pub after_ms: Option<u64>,
218    pub at_heartbeat: Option<u64>,
219    pub on_listener: bool,
220    /// Trigger snapshot on the per-VM
221    /// [`crate::devices::serial::SerialState::pre_exec_ready`] atomic —
222    /// init-oci has just printed "workload-pre-exec" and is in a 100 ms
223    /// nanosleep, giving us a stable WFI window to capture in. Used
224    /// by the always-pipelined-skip-warm `.build()` path: snapshots
225    /// guest BEFORE the workload runs, so each restore re-execs
226    /// the workload fresh. Saves 50-150 ms vs `on_listener`.
227    /// Mutually exclusive with `on_listener` in practice — when both
228    /// are set, pre-exec wins (it fires earlier in the boot timeline).
229    pub on_pre_exec: bool,
230    pub quiesce_ms: u64,
231    pub out_path: Option<String>,
232}
233
234#[derive(Clone, Debug, Default, PartialEq, Eq)]
235pub struct EndpointResources {
236    pub vsock_mux: Option<String>,
237    pub http_port: Option<String>,
238    /// SCM_RIGHTS handoff acceptor path. The router connects here
239    /// and passes accepted client TCP fds (plus a small prefix of
240    /// already-consumed bytes) so the worker bridges directly to
241    /// the guest without the router process being on the data path.
242    pub vsock_mux_handoff: Option<String>,
243    /// `<vsock_mux>-exec.sock` path. Each accepted unix-socket
244    /// client gets bridged to a *native* AF_VSOCK connection to
245    /// the guest's exec agent on `vsock_exec_guest_port` (default
246    /// 1028 — see [`DEFAULT_EXEC_GUEST_PORT`]). Used by
247    /// `supermachine exec` and `Vm::exec`.
248    pub vsock_exec: Option<String>,
249    /// Native AF_VSOCK port the guest agent listens on. None ⇒
250    /// [`DEFAULT_EXEC_GUEST_PORT`].
251    pub vsock_exec_guest_port: Option<u32>,
252}
253
254/// Default native AF_VSOCK port for the in-guest exec agent. Picked
255/// to sit above the host-direction reserved range (env service is
256/// `VSOCK_ENV_PORT=1026`, leaving room for future host-side
257/// listeners) and below typical TSI vm_port allocations.
258pub const DEFAULT_EXEC_GUEST_PORT: u32 = 1028;
259
260#[derive(Clone, Debug, PartialEq, Eq)]
261pub enum ResourceError {
262    MissingKernel,
263    ZeroMemory,
264    ZeroVcpus,
265    SnapshotTriggerWithoutOutput,
266}
267
268impl VmResources {
269    pub fn new() -> Self {
270        Self::default()
271    }
272
273    pub fn for_kernel(kernel_path: impl Into<String>, initrd_path: impl Into<String>) -> Self {
274        Self::new()
275            .with_kernel_path(kernel_path)
276            .with_initramfs(initrd_path)
277    }
278
279    pub fn from_snapshot(path: impl Into<String>) -> Self {
280        Self::new().with_restore(path)
281    }
282
283    pub fn with_kernel_path(mut self, path: impl Into<String>) -> Self {
284        self.kernel_path = Some(path.into());
285        self
286    }
287
288    pub fn with_initramfs(mut self, path: impl Into<String>) -> Self {
289        self.initrd_path = Some(path.into());
290        self
291    }
292
293    pub fn with_cmdline(mut self, cmdline: impl Into<String>) -> Self {
294        self.cmdline = cmdline.into();
295        self
296    }
297
298    pub fn with_memory_mib(mut self, memory_mib: usize) -> Self {
299        self.memory_mib = memory_mib;
300        self
301    }
302
303    pub fn with_profile(mut self, profile: VmProfile) -> Self {
304        self.apply_profile_defaults(profile);
305        self
306    }
307
308    pub fn with_vcpus(mut self, vcpus: u32) -> Self {
309        self.vcpus = vcpus;
310        self
311    }
312
313    pub fn with_block_device(mut self, path: impl Into<String>) -> Self {
314        self.block_devices.push(path.into());
315        self
316    }
317
318    /// Attach a writable volume. See [`VolumeSpec`].
319    pub fn with_volume(mut self, volume: VolumeSpec) -> Self {
320        self.volumes.push(volume);
321        self
322    }
323
324    /// Attach a virtio-fs DAX mount. See [`MountSpec`].
325    pub fn with_mount(mut self, mount: MountSpec) -> Self {
326        self.mounts.push(mount);
327        self
328    }
329
330    pub fn with_restore(mut self, path: impl Into<String>) -> Self {
331        self.restore_from = Some(path.into());
332        self
333    }
334
335    pub fn with_cow_restore(mut self, enabled: bool) -> Self {
336        self.cow_restore = enabled;
337        self
338    }
339
340    pub fn with_snapshot_after_ms(mut self, after_ms: u64, out_path: impl Into<String>) -> Self {
341        self.snapshot.after_ms = Some(after_ms);
342        self.snapshot.out_path = Some(out_path.into());
343        self
344    }
345
346    pub fn with_snapshot_at_heartbeat(
347        mut self,
348        at_heartbeat: u64,
349        out_path: impl Into<String>,
350    ) -> Self {
351        self.snapshot.at_heartbeat = Some(at_heartbeat);
352        self.snapshot.out_path = Some(out_path.into());
353        self
354    }
355
356    pub fn with_snapshot_on_listener(mut self, out_path: impl Into<String>) -> Self {
357        self.snapshot.on_listener = true;
358        self.snapshot.out_path = Some(out_path.into());
359        self
360    }
361
362    /// Trigger snapshot on init-oci's "workload-pre-exec" marker
363    /// (see [`SnapshotResources::on_pre_exec`]). For the always-
364    /// pipelined-skip-warm bake; with_warmup / service-image bakes
365    /// keep `on_listener` instead.
366    pub fn with_snapshot_on_pre_exec(mut self, out_path: impl Into<String>) -> Self {
367        self.snapshot.on_pre_exec = true;
368        self.snapshot.out_path = Some(out_path.into());
369        self
370    }
371
372    pub fn with_quiesce_ms(mut self, quiesce_ms: u64) -> Self {
373        self.snapshot.quiesce_ms = quiesce_ms;
374        self
375    }
376
377    pub fn with_vsock_mux(mut self, path: impl Into<String>) -> Self {
378        self.endpoints.vsock_mux = Some(path.into());
379        self
380    }
381
382    pub fn with_http_port(mut self, port: impl Into<String>) -> Self {
383        self.endpoints.http_port = Some(port.into());
384        self
385    }
386
387    pub fn with_vsock_mux_handoff(mut self, path: impl Into<String>) -> Self {
388        self.endpoints.vsock_mux_handoff = Some(path.into());
389        self
390    }
391
392    /// Set the path of the `<vsock_mux>-exec.sock` frontend. See
393    /// [`EndpointResources::vsock_exec`].
394    pub fn with_vsock_exec(mut self, path: impl Into<String>) -> Self {
395        self.endpoints.vsock_exec = Some(path.into());
396        self
397    }
398
399    /// Override the default guest-side AF_VSOCK port the exec
400    /// agent listens on. Match this to whatever your guest agent
401    /// binds. Default is [`DEFAULT_EXEC_GUEST_PORT`].
402    pub fn with_vsock_exec_guest_port(mut self, port: u32) -> Self {
403        self.endpoints.vsock_exec_guest_port = Some(port);
404        self
405    }
406
407    /// Set the TSI control-channel auth token (see
408    /// [`VmResources::tsi_token`]). Pass `None` to disable
409    /// enforcement; pass `Some([…])` to require every TSI control
410    /// DGRAM to carry this 32-byte prefix.
411    pub fn with_tsi_token(mut self, token: Option<[u8; 32]>) -> Self {
412        self.tsi_token = token;
413        self
414    }
415
416    pub fn memory_bytes(&self) -> usize {
417        self.memory_mib * 1024 * 1024
418    }
419
420    pub fn is_restore(&self) -> bool {
421        self.restore_from.is_some()
422    }
423
424    pub fn apply_profile_defaults(&mut self, profile: VmProfile) {
425        self.vcpus = profile.default_vcpus();
426    }
427
428    pub fn validate_for_run(&self) -> Result<(), ResourceError> {
429        if self.memory_mib == 0 {
430            return Err(ResourceError::ZeroMemory);
431        }
432        if self.vcpus == 0 {
433            return Err(ResourceError::ZeroVcpus);
434        }
435        if self.kernel_path.is_none() && self.restore_from.is_none() {
436            return Err(ResourceError::MissingKernel);
437        }
438        let wants_snapshot = self.snapshot.after_ms.is_some()
439            || self.snapshot.at_heartbeat.is_some()
440            || self.snapshot.on_listener
441            || self.snapshot.on_pre_exec;
442        if wants_snapshot && self.snapshot.out_path.is_none() {
443            return Err(ResourceError::SnapshotTriggerWithoutOutput);
444        }
445        Ok(())
446    }
447}
448
449impl Default for VmResources {
450    fn default() -> Self {
451        Self {
452            kernel_path: None,
453            initrd_path: None,
454            cmdline: DEFAULT_CMDLINE.to_string(),
455            memory_mib: DEFAULT_MEMORY_MIB,
456            block_devices: Vec::new(),
457            volumes: Vec::new(),
458            mounts: Vec::new(),
459            vcpus: DEFAULT_VCPUS,
460            restore_from: None,
461            cow_restore: false,
462            snapshot: SnapshotResources::default(),
463            endpoints: EndpointResources::default(),
464            balloon_target_pages: None,
465            tsi_token: None,
466        }
467    }
468}
469
470impl fmt::Display for ResourceError {
471    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
472        match self {
473            ResourceError::MissingKernel => {
474                write!(f, "kernel path or restore snapshot is required")
475            }
476            ResourceError::ZeroMemory => write!(f, "memory must be greater than zero"),
477            ResourceError::ZeroVcpus => write!(f, "vCPU count must be greater than zero"),
478            ResourceError::SnapshotTriggerWithoutOutput => {
479                write!(f, "snapshot trigger requires snapshot output path")
480            }
481        }
482    }
483}
484
485impl std::error::Error for ResourceError {}
486
487#[cfg(test)]
488mod tests {
489    use super::*;
490
491    #[test]
492    fn defaults_match_supermachine_cli() {
493        let resources = VmResources::default();
494        assert_eq!(resources.cmdline, DEFAULT_CMDLINE);
495        assert_eq!(resources.memory_mib, 256);
496        assert_eq!(resources.vcpus, 1);
497        assert_eq!(resources.memory_bytes(), 256 * 1024 * 1024);
498    }
499
500    #[test]
501    fn profile_defaults_are_stable() {
502        assert_eq!(VmProfile::parse("latency"), Some(VmProfile::Latency));
503        assert_eq!(VmProfile::parse("low-latency"), Some(VmProfile::Latency));
504        assert_eq!(VmProfile::parse("throughput"), Some(VmProfile::Throughput));
505        assert_eq!(VmProfile::parse("unknown"), None);
506        assert_eq!(VmProfile::Latency.default_vcpus(), 1);
507        assert_eq!(VmProfile::Throughput.default_vcpus(), 4);
508    }
509
510    #[test]
511    fn applies_profile_defaults_to_resources() {
512        let mut resources = VmResources::default();
513        resources.apply_profile_defaults(VmProfile::Throughput);
514        assert_eq!(resources.vcpus, 4);
515        resources.apply_profile_defaults(VmProfile::Latency);
516        assert_eq!(resources.vcpus, 1);
517    }
518
519    #[test]
520    fn convenience_constructors_cover_kernel_and_restore() {
521        let kernel = VmResources::for_kernel("kernel", "initrd");
522        assert_eq!(kernel.kernel_path.as_deref(), Some("kernel"));
523        assert_eq!(kernel.initrd_path.as_deref(), Some("initrd"));
524        assert!(!kernel.is_restore());
525
526        let restore = VmResources::from_snapshot("snap.sm");
527        assert_eq!(restore.restore_from.as_deref(), Some("snap.sm"));
528        assert!(restore.is_restore());
529    }
530
531    #[test]
532    fn builder_style_methods_cover_common_library_config() {
533        let resources = VmResources::new()
534            .with_kernel_path("kernel")
535            .with_initramfs("initrd")
536            .with_cmdline("console=ttyS0")
537            .with_memory_mib(512)
538            .with_profile(VmProfile::Throughput)
539            .with_vcpus(2)
540            .with_block_device("rootfs.squashfs")
541            .with_snapshot_at_heartbeat(1, "snap.sm")
542            .with_quiesce_ms(7)
543            .with_vsock_mux("/tmp/vsock.sock")
544            .with_http_port("8080");
545
546        assert_eq!(resources.kernel_path.as_deref(), Some("kernel"));
547        assert_eq!(resources.initrd_path.as_deref(), Some("initrd"));
548        assert_eq!(resources.cmdline, "console=ttyS0");
549        assert_eq!(resources.memory_mib, 512);
550        assert_eq!(resources.vcpus, 2);
551        assert_eq!(resources.block_devices, vec!["rootfs.squashfs"]);
552        assert_eq!(resources.snapshot.at_heartbeat, Some(1));
553        assert_eq!(resources.snapshot.quiesce_ms, 7);
554        assert_eq!(resources.snapshot.out_path.as_deref(), Some("snap.sm"));
555        assert_eq!(
556            resources.endpoints.vsock_mux.as_deref(),
557            Some("/tmp/vsock.sock")
558        );
559        assert_eq!(resources.endpoints.http_port.as_deref(), Some("8080"));
560    }
561
562    #[test]
563    fn builder_style_restore_config_is_valid_without_kernel() {
564        let resources = VmResources::new()
565            .with_restore("snap.sm")
566            .with_cow_restore(true);
567
568        assert!(resources.is_restore());
569        assert!(resources.cow_restore);
570        assert_eq!(resources.validate_for_run(), Ok(()));
571    }
572
573    #[test]
574    fn validates_kernel_or_restore() {
575        let mut resources = VmResources::default();
576        assert_eq!(
577            resources.validate_for_run(),
578            Err(ResourceError::MissingKernel)
579        );
580
581        resources.kernel_path = Some("vmlinux".to_string());
582        assert_eq!(resources.validate_for_run(), Ok(()));
583
584        resources.kernel_path = None;
585        resources.restore_from = Some("snap.sm".to_string());
586        assert_eq!(resources.validate_for_run(), Ok(()));
587    }
588
589    // === MountSpec / VolumeSpec — auto-mount API ===
590
591    #[test]
592    fn mount_spec_constructs_with_required_guest_path() {
593        let m = MountSpec::new("/host/x", "tag", "/workspace");
594        assert_eq!(m.host_path, "/host/x");
595        assert_eq!(m.guest_tag, "tag");
596        assert_eq!(m.guest_path, "/workspace");
597        assert_eq!(m.symlinks, SymlinkPolicy::Opaque);
598        assert!(!m.read_only);
599    }
600
601    #[test]
602    fn mount_spec_symlinks_composes_with_construction() {
603        let m = MountSpec::new("/h", "t", "/w").with_symlinks(SymlinkPolicy::Deny);
604        assert_eq!(m.guest_path, "/w");
605        assert_eq!(m.symlinks, SymlinkPolicy::Deny);
606    }
607
608    #[test]
609    fn mount_spec_read_only_composes_with_construction() {
610        let m = MountSpec::new("/h", "t", "/w").read_only();
611        assert_eq!(m.guest_path, "/w");
612        assert!(m.read_only);
613    }
614
615    #[test]
616    fn mount_spec_constructor_accepts_string_types() {
617        // Builder uses `impl Into<String>` for all three positional
618        // args — &str and String should both work.
619        let m_str = MountSpec::new("/h", "t", "/abs");
620        let m_string = MountSpec::new(String::from("/h"), String::from("t"), String::from("/abs"));
621        assert_eq!(m_str.guest_path, m_string.guest_path);
622        assert_eq!(m_str.host_path, m_string.host_path);
623        assert_eq!(m_str.guest_tag, m_string.guest_tag);
624    }
625
626    #[test]
627    fn volume_spec_basic_construction() {
628        let v = VolumeSpec::new("/host/nm.img", "/workspace/node_modules");
629        assert_eq!(v.host_path, "/host/nm.img");
630        assert_eq!(v.guest_path, "/workspace/node_modules");
631        assert_eq!(v.size_bytes, VolumeSpec::DEFAULT_SIZE_BYTES);
632    }
633
634    #[test]
635    fn volume_spec_with_size_bytes() {
636        let v = VolumeSpec::new("/h", "/g").with_size_bytes(4 * 1024 * 1024 * 1024);
637        assert_eq!(v.size_bytes, 4 * 1024 * 1024 * 1024);
638    }
639}