Skip to main content

squib_api/schemas/
drive.rs

1//! `/drives/{id}` PUT and PATCH bodies.
2//!
3//! Per [21-api-compat-matrix.md `/drives/{id}`
4//! PUT](../../../specs/21-api-compat-matrix.md#drivesid-put):
5//!
6//! - `drive_id` — `^[A-Za-z0-9_]{1,64}$` (`DriveId` newtype).
7//! - `path_on_host` — `SafePath` (1024-byte cap, no NUL).
8//! - `cache_type` — `Unsafe | Writeback`.
9//! - `io_engine` — `Sync | Async`.
10//! - `partuuid` — optional; when `is_root_device` true, threaded into `root=PARTUUID=`.
11//! - `socket` (vhost-user) — `A`: accept-and-warn at config-load.
12//!
13//! Diffs from upstream are intentionally absent at this layer; the deviations are
14//! enforced at the controller / VMM stage (e.g. vhost-user `socket` warning).
15
16use serde::{Deserialize, Serialize};
17
18use super::common::{DriveId, SafePath};
19
20/// Caching policy for the block device. Mirrors upstream verbatim.
21#[derive(Debug, Clone, Copy, Eq, PartialEq, Default, Serialize, Deserialize)]
22pub enum CacheType {
23    /// Pass writes straight to the backing file (no host page cache).
24    #[default]
25    Unsafe,
26    /// Buffer writes through the host page cache and flush on guest fsync.
27    Writeback,
28}
29
30/// Block-device IO engine. Sync = blocking; Async = `tokio::task::spawn_blocking`.
31#[derive(Debug, Clone, Copy, Eq, PartialEq, Default, Serialize, Deserialize)]
32pub enum IoEngine {
33    /// Blocking IO on the device thread.
34    #[default]
35    Sync,
36    /// Async IO offloaded to the tokio blocking pool.
37    Async,
38}
39
40/// Raw `/drives/{id}` PUT body off the wire.
41#[derive(Debug, Clone, Deserialize)]
42#[serde(deny_unknown_fields)]
43pub struct RawDriveConfig {
44    /// Caller-supplied unique identifier (`^[A-Za-z0-9_]{1,64}$`).
45    pub drive_id: String,
46    /// Host filesystem path to the backing image.
47    pub path_on_host: String,
48    /// Whether the guest should mount this as the root block device.
49    #[serde(default)]
50    pub is_root_device: bool,
51    /// Whether the guest sees the device read-only.
52    #[serde(default)]
53    pub is_read_only: bool,
54    /// Caching policy.
55    #[serde(default)]
56    pub cache_type: CacheType,
57    /// IO engine.
58    #[serde(default)]
59    pub io_engine: IoEngine,
60    /// Root partition UUID for `root=PARTUUID=...`. Only honored when `is_root_device`.
61    #[serde(default)]
62    pub partuuid: Option<String>,
63    /// Optional rate limiter (passed through verbatim for now).
64    #[serde(default)]
65    pub rate_limiter: Option<serde_json::Value>,
66    /// vhost-user socket path. Accept-and-warn (vhost-user is Linux-only).
67    #[serde(default)]
68    pub socket: Option<String>,
69}
70
71/// Validated `/drives/{id}` PUT body.
72#[derive(Debug, Clone, Serialize)]
73#[non_exhaustive]
74pub struct DriveConfig {
75    /// Validated identifier.
76    pub drive_id: DriveId,
77    /// Validated host path.
78    pub path_on_host: SafePath,
79    /// `is_root_device` is honored exactly once per VM; the controller enforces uniqueness.
80    pub is_root_device: bool,
81    /// `is_read_only`.
82    pub is_read_only: bool,
83    /// Caching policy.
84    pub cache_type: CacheType,
85    /// IO engine.
86    pub io_engine: IoEngine,
87    /// Validated root partition UUID, if present.
88    pub partuuid: Option<String>,
89    /// Rate limiter passthrough (validated structurally by the device layer).
90    pub rate_limiter: Option<serde_json::Value>,
91    /// Whether a vhost-user socket was supplied (accept-and-warn marker).
92    pub vhost_user_socket: Option<String>,
93}
94
95fn validate_partuuid(uuid: &str) -> Result<(), String> {
96    if uuid.is_empty() || uuid.len() > 64 {
97        return Err(format!(
98            "Invalid partuuid: must be 1..=64 bytes (got {} bytes)",
99            uuid.len()
100        ));
101    }
102    if !uuid.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-') {
103        return Err("Invalid partuuid: only [A-Za-z0-9-] permitted".into());
104    }
105    Ok(())
106}
107
108impl TryFrom<RawDriveConfig> for DriveConfig {
109    type Error = String;
110
111    fn try_from(raw: RawDriveConfig) -> Result<Self, Self::Error> {
112        let drive_id = DriveId::new(raw.drive_id)?;
113        let path_on_host =
114            SafePath::new(raw.path_on_host).map_err(|e| format!("Invalid path_on_host: {e}"))?;
115        if let Some(p) = raw.partuuid.as_deref() {
116            validate_partuuid(p)?;
117        }
118        let socket = match raw.socket {
119            Some(s) if s.is_empty() => return Err("Invalid socket: must not be empty".into()),
120            Some(s) if s.len() > 1024 => {
121                return Err("Invalid socket: exceeds 1024 bytes".into());
122            }
123            other => other,
124        };
125        Ok(Self {
126            drive_id,
127            path_on_host,
128            is_root_device: raw.is_root_device,
129            is_read_only: raw.is_read_only,
130            cache_type: raw.cache_type,
131            io_engine: raw.io_engine,
132            partuuid: raw.partuuid,
133            rate_limiter: raw.rate_limiter,
134            vhost_user_socket: socket,
135        })
136    }
137}
138
139/// Raw `/drives/{id}` PATCH body. Every mutable field optional.
140#[derive(Debug, Clone, Deserialize)]
141#[serde(deny_unknown_fields)]
142pub struct RawDrivePatch {
143    /// Drive ID being patched (must match the URL `{id}`).
144    pub drive_id: String,
145    /// Replacement backing path.
146    #[serde(default)]
147    pub path_on_host: Option<String>,
148    /// Replacement rate limiter.
149    #[serde(default)]
150    pub rate_limiter: Option<serde_json::Value>,
151}
152
153/// Validated `/drives/{id}` PATCH body.
154#[derive(Debug, Clone, Serialize)]
155#[non_exhaustive]
156pub struct DrivePatch {
157    /// Validated drive ID.
158    pub drive_id: DriveId,
159    /// Replacement path, if any.
160    pub path_on_host: Option<SafePath>,
161    /// Replacement rate limiter.
162    pub rate_limiter: Option<serde_json::Value>,
163}
164
165impl TryFrom<RawDrivePatch> for DrivePatch {
166    type Error = String;
167
168    fn try_from(raw: RawDrivePatch) -> Result<Self, Self::Error> {
169        let drive_id = DriveId::new(raw.drive_id)?;
170        let path_on_host = match raw.path_on_host {
171            Some(p) => Some(SafePath::new(p).map_err(|e| format!("Invalid path_on_host: {e}"))?),
172            None => None,
173        };
174        Ok(Self {
175            drive_id,
176            path_on_host,
177            rate_limiter: raw.rate_limiter,
178        })
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    fn raw(id: &str) -> RawDriveConfig {
187        RawDriveConfig {
188            drive_id: id.into(),
189            path_on_host: "/tmp/img.bin".into(),
190            is_root_device: false,
191            is_read_only: false,
192            cache_type: CacheType::Unsafe,
193            io_engine: IoEngine::Sync,
194            partuuid: None,
195            rate_limiter: None,
196            socket: None,
197        }
198    }
199
200    #[test]
201    fn test_should_accept_minimal_drive() {
202        let cfg = DriveConfig::try_from(raw("rootfs")).unwrap();
203        assert_eq!(cfg.drive_id.as_str(), "rootfs");
204    }
205
206    #[test]
207    fn test_should_reject_invalid_drive_id() {
208        assert!(DriveConfig::try_from(raw("root-fs")).is_err());
209    }
210
211    #[test]
212    fn test_should_validate_partuuid() {
213        let mut r = raw("rootfs");
214        r.partuuid = Some("ABC123-def456".into());
215        assert!(DriveConfig::try_from(r).is_ok());
216    }
217
218    #[test]
219    fn test_should_reject_partuuid_with_special_chars() {
220        let mut r = raw("rootfs");
221        r.partuuid = Some("ABC*1".into());
222        assert!(DriveConfig::try_from(r).is_err());
223    }
224
225    #[test]
226    fn test_should_default_io_engine_to_sync() {
227        let json = r#"{"drive_id":"rootfs","path_on_host":"/tmp/img.bin"}"#;
228        let raw: RawDriveConfig = serde_json::from_str(json).unwrap();
229        assert_eq!(raw.io_engine, IoEngine::Sync);
230    }
231
232    #[test]
233    fn test_should_round_trip_drive_patch() {
234        let json = r#"{"drive_id":"rootfs","path_on_host":"/tmp/x.bin"}"#;
235        let raw: RawDrivePatch = serde_json::from_str(json).unwrap();
236        let p = DrivePatch::try_from(raw).unwrap();
237        assert_eq!(p.drive_id.as_str(), "rootfs");
238        assert!(p.path_on_host.is_some());
239    }
240}