Skip to main content

smolvm_protocol/
lib.rs

1//! Protocol types for smolvm host-guest communication.
2//!
3//! This crate defines the wire protocol for vsock communication between
4//! the smolvm host and the guest agent (smolvm-agent).
5//!
6//! # Protocol Overview
7//!
8//! Communication uses JSON-encoded messages over vsock. Each message is
9//! prefixed with a 4-byte big-endian length header.
10//!
11//! ```text
12//! +----------------+-------------------+
13//! | Length (4 BE)  | JSON payload      |
14//! +----------------+-------------------+
15//! ```
16
17#![deny(missing_docs)]
18
19use serde::{Deserialize, Serialize};
20
21pub mod guest_env;
22pub mod image_ref;
23pub mod retry;
24pub mod secrets;
25
26pub use image_ref::{image_repo, normalize_image_ref};
27pub use secrets::{SecretRef, SecretSourceKind};
28
29/// Serde helper for encoding `Vec<u8>` as a base64 string in JSON.
30///
31/// Without this, serde_json serializes `Vec<u8>` as a JSON array of numbers
32/// (e.g., `[104,101,108,108,111]`), which inflates binary data by ~4x.
33/// Base64 encoding reduces this to ~1.33x.
34pub mod base64_bytes {
35    use base64::{engine::general_purpose::STANDARD, Engine};
36    use serde::{Deserialize, Deserializer, Serializer};
37
38    /// Serialize `Vec<u8>` as a base64 string.
39    pub fn serialize<S: Serializer>(data: &[u8], serializer: S) -> Result<S::Ok, S::Error> {
40        serializer.serialize_str(&STANDARD.encode(data))
41    }
42
43    /// Deserialize a base64 string into `Vec<u8>`.
44    pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Vec<u8>, D::Error> {
45        let s = String::deserialize(deserializer)?;
46        STANDARD.decode(&s).map_err(serde::de::Error::custom)
47    }
48}
49
50/// Protocol version.
51pub const PROTOCOL_VERSION: u32 = 1;
52
53/// Maximum frame size (32 MB - layer exports use chunked streaming).
54pub const MAX_FRAME_SIZE: u32 = 32 * 1024 * 1024;
55
56/// Chunk size for streaming layer data (~16 MB raw, ~21 MB as base64 JSON).
57pub const LAYER_CHUNK_SIZE: usize = 16 * 1024 * 1024;
58
59/// Files at or below this size are written with a single `FileWrite`
60/// message. Larger files must stream via
61/// `FileWriteBegin` + `FileWriteChunk` so no single frame approaches
62/// [`MAX_FRAME_SIZE`] (base64 + JSON inflation is ~1.4x).
63///
64/// Chosen to keep the single-shot frame comfortably under the frame
65/// limit while preserving the fast-path latency for small config
66/// files / scripts / keys.
67pub const FILE_WRITE_SINGLE_SHOT_MAX: usize = 1024 * 1024;
68
69/// Payload bytes per streaming upload chunk. Deliberately small —
70/// equal to [`FILE_WRITE_SINGLE_SHOT_MAX`] — so each chunk's encoded
71/// frame (~1.4 MB) fits inside typical kernel Unix-socket send
72/// buffers (`SO_SNDBUF` defaults on the order of 200–256 KiB but
73/// can grow). Larger chunks would force `write_all` to spin waiting
74/// for the agent to drain, and any latency spike trips the 10 s
75/// write timeout with `EAGAIN` — exactly the failure David
76/// reproduced before this fix landed.
77///
78/// Note: [`LAYER_CHUNK_SIZE`] is 16 MiB for agent→host (download)
79/// streaming, which works because the host side of the socket has
80/// more headroom than the guest side. Upload streaming is the
81/// asymmetric case and needs a smaller chunk.
82pub const FILE_WRITE_CHUNK_SIZE: usize = FILE_WRITE_SINGLE_SHOT_MAX;
83
84/// Hard ceiling on a single file transfer in either direction.
85///
86/// On the write path: enforced at `FileWriteBegin` by the agent —
87/// `total_size > FILE_TRANSFER_MAX_TOTAL` is rejected before any
88/// staging file is created.
89///
90/// On the read path: enforced by the host's `read_file` loop —
91/// after the first chunk that pushes the accumulated total past the
92/// cap, the call bails with an error and the partial buffer is
93/// dropped. This protects the host process from OOM if the guest
94/// (compromised or merely buggy) streams unbounded data.
95///
96/// 4 GiB matches the order-of-magnitude of the default overlay disk
97/// and the `gpu_vram_mib` cap. Callers that need to move larger
98/// blobs should stage via a virtiofs mount instead of `cp`.
99pub const FILE_TRANSFER_MAX_TOTAL: u64 = 4 * 1024 * 1024 * 1024;
100
101/// Filename of the virtiofs-visible marker the agent creates when it is
102/// ready to accept vsock connections.
103///
104/// The host polls for this file through its virtiofs mount of the guest
105/// rootfs. The agent writes it (and optionally a symlink from `/oldroot/`)
106/// during deferred init, just before opening the vsock listener.
107///
108/// Both sides must agree on this name; keeping it here prevents silent drift.
109pub const AGENT_READY_MARKER: &str = ".smolvm-ready";
110
111/// Well-known vsock ports.
112pub mod ports {
113    /// Control channel for workload VMs.
114    pub const WORKLOAD_CONTROL: u32 = 5000;
115    /// Log streaming from workload VMs.
116    pub const WORKLOAD_LOGS: u32 = 5001;
117    /// Agent control port (for OCI operations and management).
118    pub const AGENT_CONTROL: u32 = 6000;
119    /// SSH agent forwarding (host SSH_AUTH_SOCK bridged to guest).
120    pub const SSH_AGENT: u32 = 6001;
121    /// DNS filtering proxy (guest forwards DNS queries to host for filtering).
122    pub const DNS_FILTER: u32 = 6002;
123    /// CUDA-over-vsock (experimental): guest CUDA client forwards Driver-API
124    /// calls to a host CUDA server that runs them on the host NVIDIA GPU.
125    pub const CUDA: u32 = 7000;
126}
127
128/// vsock CID constants.
129pub mod cid {
130    /// Host CID (always 2).
131    pub const HOST: u32 = 2;
132    /// Guest CID (always 3 for the first/only guest).
133    pub const GUEST: u32 = 3;
134    /// Any CID (for listening).
135    pub const ANY: u32 = u32::MAX;
136}
137
138/// fsnotify event masks, mirroring the kernel's `FS_*` bits in
139/// `include/linux/fsnotify_backend.h`. Shared by the host watcher (which maps a
140/// host filesystem event to one of these) and the guest agent (which forwards
141/// the raw bits to `/proc/smolvm-fsnotify`). Only the subset relevant to
142/// file-watching tools is defined.
143pub mod fsnotify_mask {
144    /// File was modified.
145    pub const FS_MODIFY: u32 = 0x0000_0002;
146    /// Metadata changed (chmod/chown/utimes).
147    pub const FS_ATTRIB: u32 = 0x0000_0004;
148    /// Writable file was closed.
149    pub const FS_CLOSE_WRITE: u32 = 0x0000_0008;
150    /// File was moved away from the watched dir.
151    pub const FS_MOVED_FROM: u32 = 0x0000_0040;
152    /// File was moved into the watched dir.
153    pub const FS_MOVED_TO: u32 = 0x0000_0080;
154    /// Subfile was created.
155    pub const FS_CREATE: u32 = 0x0000_0100;
156    /// Subfile was deleted.
157    pub const FS_DELETE: u32 = 0x0000_0200;
158    /// Event occurred against a directory.
159    pub const FS_ISDIR: u32 = 0x4000_0000;
160}
161
162/// A single host-originated filesystem change to replay into the guest.
163#[derive(Debug, Clone, Serialize, Deserialize)]
164pub struct FsNotifyEvent {
165    /// Guest-side absolute path the event occurred on (virtiofs staging path).
166    pub path: String,
167    /// `fsnotify_mask::FS_*` bitmask for the event.
168    pub mask: u32,
169}
170
171// ============================================================================
172// Agent Protocol (OCI Operations)
173// ============================================================================
174
175/// Agent request types (for image management and OCI operations).
176#[derive(Debug, Clone, Serialize, Deserialize)]
177#[serde(tag = "method", rename_all = "snake_case")]
178pub enum AgentRequest {
179    /// Ping to check if agent is alive.
180    Ping,
181
182    /// Inject host-originated fsnotify events into the guest.
183    ///
184    /// virtiofs does not deliver host-side file changes to the guest as
185    /// fsnotify/inotify events, so inotify-based hot-reload (Vite, webpack,
186    /// nodemon) never fires when a mounted file is edited on the host. The host
187    /// watches the mount source and sends the resulting events here; the agent
188    /// writes them to `/proc/smolvm-fsnotify`, which fires the matching event on
189    /// the guest inode so watchers on the (bind-mounted) container path wake up.
190    /// Each `path` is a guest-side absolute path (the virtiofs staging path),
191    /// `mask` an `fsnotify_mask::FS_*` bitmask.
192    FsNotify {
193        /// Host-originated filesystem changes to replay as guest fsnotify events.
194        #[serde(default)]
195        events: Vec<FsNotifyEvent>,
196    },
197
198    /// Pull an OCI image and extract layers.
199    Pull {
200        /// Image reference (e.g., "alpine:latest", "docker.io/library/ubuntu:22.04").
201        image: String,
202        /// OCI platform to pull (e.g., "linux/arm64", "linux/amd64").
203        oci_platform: Option<String>,
204        /// Optional registry authentication credentials.
205        #[serde(default, skip_serializing_if = "Option::is_none")]
206        auth: Option<RegistryAuth>,
207        /// Proxy URL applied to the registry client (sets HTTP_PROXY and HTTPS_PROXY).
208        #[serde(default, skip_serializing_if = "Option::is_none")]
209        proxy: Option<String>,
210        /// Comma-separated NO_PROXY list of hosts/CIDRs that bypass the proxy.
211        #[serde(default, skip_serializing_if = "Option::is_none")]
212        no_proxy: Option<String>,
213    },
214
215    /// Query if an image exists locally.
216    Query {
217        /// Image reference.
218        image: String,
219    },
220
221    /// List all cached images.
222    ListImages,
223
224    /// Run garbage collection on unused layers.
225    GarbageCollect {
226        /// If true, only report what would be deleted.
227        dry_run: bool,
228        /// If true, delete all image manifests and configs first,
229        /// making all layers unreferenced so they get collected.
230        #[serde(default)]
231        purge_all: bool,
232    },
233
234    /// Prepare overlay rootfs for a workload.
235    PrepareOverlay {
236        /// Image reference.
237        image: String,
238        /// Unique workload ID for the overlay.
239        workload_id: String,
240    },
241
242    /// Clean up overlay rootfs for a workload.
243    CleanupOverlay {
244        /// Workload ID to clean up.
245        workload_id: String,
246    },
247
248    /// Format the storage disk (first-time setup).
249    FormatStorage,
250
251    /// Get storage disk status.
252    StorageStatus,
253
254    /// Test network connectivity directly from the agent (not via chroot).
255    /// Used to debug TSI networking.
256    NetworkTest {
257        /// URL to test (e.g., "http://1.1.1.1")
258        url: String,
259    },
260
261    /// Shutdown the agent.
262    Shutdown,
263
264    /// Export a layer as a tar archive.
265    ///
266    /// Used by `smolvm pack` to extract OCI layers for packaging.
267    /// The agent streams the layer tar data back via LayerData responses.
268    ExportLayer {
269        /// Image digest (sha256:...).
270        image_digest: String,
271        /// Layer index (0-based).
272        layer_index: usize,
273    },
274
275    /// Execute a command directly in the VM (not in a container).
276    ///
277    /// This runs the command in the agent's Alpine rootfs without any
278    /// container isolation. Useful for VM-level operations and debugging.
279    VmExec {
280        /// Command and arguments.
281        command: Vec<String>,
282        /// Environment variables.
283        #[serde(default)]
284        env: Vec<(String, String)>,
285        /// Working directory in the VM.
286        workdir: Option<String>,
287        /// Timeout in milliseconds.
288        #[serde(default)]
289        timeout_ms: Option<u64>,
290        /// Interactive mode - stream I/O instead of buffering.
291        #[serde(default)]
292        interactive: bool,
293        /// Allocate a pseudo-TTY for the command.
294        #[serde(default)]
295        tty: bool,
296        /// Background mode - spawn and return PID immediately without waiting.
297        #[serde(default)]
298        background: bool,
299        /// Data to pipe to the command's stdin.
300        #[serde(default)]
301        stdin_data: Option<String>,
302    },
303
304    /// Run a command in an image's rootfs.
305    ///
306    /// This prepares an overlay, chroots into it, and executes the command.
307    /// Returns stdout, stderr, and exit code when the command completes.
308    Run {
309        /// Image reference (must be pulled first).
310        image: String,
311        /// Command and arguments.
312        command: Vec<String>,
313        /// Environment variables.
314        #[serde(default)]
315        env: Vec<(String, String)>,
316        /// Working directory inside the rootfs.
317        workdir: Option<String>,
318        /// User inside the rootfs. If omitted, the OCI image default applies.
319        #[serde(default, skip_serializing_if = "Option::is_none")]
320        user: Option<String>,
321        /// Volume mounts to bind into the container.
322        /// Each tuple is (virtiofs_tag, container_path, read_only).
323        #[serde(default)]
324        mounts: Vec<(String, String, bool)>,
325        /// Timeout in milliseconds. If the command exceeds this duration,
326        /// it will be killed and return exit code 124.
327        #[serde(default)]
328        timeout_ms: Option<u64>,
329        /// Interactive mode - stream I/O instead of buffering.
330        /// When true, output is streamed via Stdout/Stderr responses,
331        /// and stdin can be sent via the Stdin request.
332        #[serde(default)]
333        interactive: bool,
334        /// Allocate a pseudo-TTY for the command.
335        /// Enables terminal features like colors, line editing, and signal handling.
336        #[serde(default)]
337        tty: bool,
338        /// Detached mode — start the container and return immediately with the
339        /// container ID. Only meaningful when `persistent_overlay_id` is set.
340        /// Returns a `Completed` response with `stdout` containing the container ID.
341        #[serde(default)]
342        detached: bool,
343        /// Run the workload as an unprivileged container: restricted capabilities,
344        /// read-only cgroup, and no extra tmpfs. The default (false) is "VM-grade"
345        /// — since the microVM is the isolation boundary, the workload gets a full
346        /// capability set and the mounts an init system needs (so any image, incl.
347        /// systemd, boots). Opt in for defense-in-depth when running untrusted code.
348        #[serde(default)]
349        unprivileged: bool,
350        /// If set, use a persistent overlay that survives across exec sessions.
351        /// The overlay is identified by this ID (typically the machine name)
352        /// and reused on subsequent runs. If not set, an ephemeral overlay is
353        /// created and destroyed after the run.
354        #[serde(default, skip_serializing_if = "Option::is_none")]
355        persistent_overlay_id: Option<String>,
356        /// Data to pipe to the command's stdin (non-interactive runs only).
357        /// The pipe is closed after writing, so the command sees EOF.
358        #[serde(default, skip_serializing_if = "Option::is_none")]
359        stdin_data: Option<String>,
360        /// Spawn the container and return immediately with the crun PID.
361        /// The container runs detached; stdout/stderr go to /dev/null.
362        /// Incompatible with `interactive` and `tty`.
363        #[serde(default)]
364        background: bool,
365    },
366
367    /// Send stdin data to a running interactive command.
368    Stdin {
369        /// Input data to send to the command's stdin.
370        #[serde(with = "base64_bytes")]
371        data: Vec<u8>,
372    },
373
374    /// Resize the PTY window (for TTY mode).
375    Resize {
376        /// New width in columns.
377        cols: u16,
378        /// New height in rows.
379        rows: u16,
380    },
381
382    // ========================================================================
383    // File I/O
384    // ========================================================================
385    /// Write a file inside the VM in a single message.
386    ///
387    /// Use only for files up to [`FILE_WRITE_SINGLE_SHOT_MAX`]. Larger
388    /// files must stream via [`Self::FileWriteBegin`] +
389    /// [`Self::FileWriteChunk`] to avoid exceeding [`MAX_FRAME_SIZE`]
390    /// after base64 + JSON inflation.
391    FileWrite {
392        /// Absolute path in the VM filesystem.
393        path: String,
394        /// File contents.
395        #[serde(with = "base64_bytes")]
396        data: Vec<u8>,
397        /// File mode (e.g., 0o644). None = default (0644).
398        #[serde(default)]
399        mode: Option<u32>,
400    },
401
402    /// Open a streaming file upload session on this connection.
403    ///
404    /// Must be followed by one or more [`Self::FileWriteChunk`]
405    /// requests. The final chunk sets `done: true` to finalize.
406    /// Dropping the connection (or sending any non-chunk request)
407    /// before `done` aborts the session and leaves no partial file
408    /// at `path`.
409    ///
410    /// Sessions are per-connection — one session at a time.
411    FileWriteBegin {
412        /// Absolute path in the VM filesystem.
413        path: String,
414        /// File mode (e.g., 0o644). None = default (0644).
415        #[serde(default)]
416        mode: Option<u32>,
417        /// Expected total size in bytes. Rejected if it exceeds
418        /// [`FILE_TRANSFER_MAX_TOTAL`]. The agent uses this for an
419        /// early-fail check only; the actual size written is the sum
420        /// of chunk byte lengths.
421        total_size: u64,
422    },
423
424    /// Append a chunk to the currently open streaming upload.
425    /// If `done` is true, the agent fsyncs and atomically renames the
426    /// staging file onto the target path.
427    FileWriteChunk {
428        /// Chunk bytes. Typically [`FILE_WRITE_CHUNK_SIZE`] except
429        /// for the last chunk.
430        #[serde(with = "base64_bytes")]
431        data: Vec<u8>,
432        /// True on the final chunk; closes and renames the staging
433        /// file. False on intermediate chunks.
434        done: bool,
435    },
436
437    /// Read a file from the VM.
438    FileRead {
439        /// Absolute path in the VM filesystem.
440        path: String,
441    },
442}
443
444impl AgentRequest {
445    /// A log-safe one-line summary of the request.
446    ///
447    /// This string is written to the machine's console log, which is exposed
448    /// over the logs API — so it must NEVER include credential- or data-bearing
449    /// fields: registry `auth`, `env` (which can carry host-resolved secrets),
450    /// `proxy` (may embed credentials), or `data` (file/stdin bytes). Only the
451    /// variant name plus a non-secret identifier (image) is emitted.
452    ///
453    /// The match is exhaustive with no catch-all on purpose: adding a new
454    /// variant forces a compile error here, so redaction is a deliberate
455    /// decision rather than an accidental leak in some future request type.
456    pub fn log_summary(&self) -> String {
457        match self {
458            AgentRequest::Ping => "Ping".into(),
459            AgentRequest::FsNotify { events } => format!("FsNotify {{ count: {} }}", events.len()),
460            AgentRequest::Pull { image, .. } => format!("Pull {{ image: {image} }}"),
461            AgentRequest::Query { image, .. } => format!("Query {{ image: {image} }}"),
462            AgentRequest::ListImages => "ListImages".into(),
463            AgentRequest::GarbageCollect { .. } => "GarbageCollect".into(),
464            AgentRequest::PrepareOverlay { .. } => "PrepareOverlay".into(),
465            AgentRequest::CleanupOverlay { .. } => "CleanupOverlay".into(),
466            AgentRequest::FormatStorage => "FormatStorage".into(),
467            AgentRequest::StorageStatus => "StorageStatus".into(),
468            AgentRequest::NetworkTest { .. } => "NetworkTest".into(),
469            AgentRequest::Shutdown => "Shutdown".into(),
470            AgentRequest::ExportLayer { .. } => "ExportLayer".into(),
471            AgentRequest::VmExec { .. } => "VmExec".into(),
472            AgentRequest::Run { image, .. } => format!("Run {{ image: {image} }}"),
473            AgentRequest::Stdin { .. } => "Stdin".into(),
474            AgentRequest::Resize { .. } => "Resize".into(),
475            AgentRequest::FileWrite { .. } => "FileWrite".into(),
476            AgentRequest::FileWriteBegin { .. } => "FileWriteBegin".into(),
477            AgentRequest::FileWriteChunk { .. } => "FileWriteChunk".into(),
478            AgentRequest::FileRead { .. } => "FileRead".into(),
479        }
480    }
481}
482
483/// Agent response types.
484#[derive(Debug, Clone, Serialize, Deserialize)]
485#[serde(tag = "status", rename_all = "snake_case")]
486pub enum AgentResponse {
487    /// Operation completed successfully.
488    Ok {
489        /// Response data (varies by request type).
490        #[serde(default, skip_serializing_if = "Option::is_none")]
491        data: Option<serde_json::Value>,
492    },
493
494    /// Pong response to ping.
495    Pong {
496        /// Protocol version.
497        version: u32,
498    },
499
500    /// Progress update (for long operations like pull).
501    Progress {
502        /// Human-readable message.
503        message: String,
504        /// Completion percentage (0-100).
505        #[serde(default, skip_serializing_if = "Option::is_none")]
506        percent: Option<u8>,
507        /// Current layer being processed.
508        #[serde(default, skip_serializing_if = "Option::is_none")]
509        layer: Option<String>,
510    },
511
512    /// Operation failed.
513    Error {
514        /// Error message.
515        message: String,
516        /// Error code (for programmatic handling).
517        #[serde(default, skip_serializing_if = "Option::is_none")]
518        code: Option<String>,
519    },
520
521    /// Command execution completed (non-interactive mode).
522    Completed {
523        /// Exit code from the command.
524        exit_code: i32,
525        /// Standard output (may be truncated). `Vec<u8>` preserves binary
526        /// output (image bytes, tarballs, etc.) that would be truncated by
527        /// `String` at the first non-UTF-8 byte. Serialized as base64 JSON
528        /// string — the same format as the streaming `Stdout` variant.
529        #[serde(with = "base64_bytes")]
530        stdout: Vec<u8>,
531        /// Standard error (may be truncated).
532        #[serde(with = "base64_bytes")]
533        stderr: Vec<u8>,
534    },
535
536    /// Command started (interactive mode).
537    /// Indicates the command is running and ready to receive stdin.
538    Started,
539
540    /// Stdout data from a running command (interactive mode).
541    Stdout {
542        /// Output data.
543        #[serde(with = "base64_bytes")]
544        data: Vec<u8>,
545    },
546
547    /// Stderr data from a running command (interactive mode).
548    Stderr {
549        /// Error output data.
550        #[serde(with = "base64_bytes")]
551        data: Vec<u8>,
552    },
553
554    /// Command exited (interactive mode).
555    Exited {
556        /// Exit code from the command.
557        exit_code: i32,
558    },
559
560    /// Streaming binary-data chunk.
561    ///
562    /// Used by every streaming download direction: the agent sends
563    /// one or more `DataChunk` responses in sequence, with `done: true`
564    /// on the final chunk. Current producers: `ExportLayer` and
565    /// `FileRead`.
566    ///
567    /// Payload size per chunk should stay under
568    /// [`LAYER_CHUNK_SIZE`] so the encoded frame (~1.33× after
569    /// base64) fits inside [`MAX_FRAME_SIZE`] with JSON overhead to
570    /// spare.
571    DataChunk {
572        /// Chunk bytes. Empty allowed on the final frame (common for
573        /// EOF-on-clean-boundary cases).
574        #[serde(with = "base64_bytes")]
575        data: Vec<u8>,
576        /// True on the final chunk of the stream.
577        done: bool,
578    },
579}
580
581// ============================================================================
582// Error Code Constants
583// ============================================================================
584//
585// Standard error codes for AgentResponse::Error. Using constants ensures
586// consistency across the codebase and makes error handling more reliable.
587
588/// Error codes for agent responses.
589pub mod error_codes {
590    /// Request payload was invalid or malformed.
591    pub const INVALID_REQUEST: &str = "INVALID_REQUEST";
592    /// Requested resource was not found.
593    pub const NOT_FOUND: &str = "NOT_FOUND";
594    /// Internal error during operation.
595    pub const INTERNAL_ERROR: &str = "INTERNAL_ERROR";
596    /// Image pull operation failed.
597    pub const PULL_FAILED: &str = "PULL_FAILED";
598    /// Image query operation failed.
599    pub const QUERY_FAILED: &str = "QUERY_FAILED";
600    /// Command execution failed.
601    pub const RUN_FAILED: &str = "RUN_FAILED";
602    /// Command execution failed in container.
603    pub const EXEC_FAILED: &str = "EXEC_FAILED";
604    /// Process spawn failed.
605    pub const SPAWN_FAILED: &str = "SPAWN_FAILED";
606    /// Mount operation failed.
607    pub const MOUNT_FAILED: &str = "MOUNT_FAILED";
608    /// File I/O operation failed.
609    pub const FILE_IO_FAILED: &str = "FILE_IO_FAILED";
610    /// Overlay filesystem operation failed.
611    pub const OVERLAY_FAILED: &str = "OVERLAY_FAILED";
612    /// Cleanup operation failed.
613    pub const CLEANUP_FAILED: &str = "CLEANUP_FAILED";
614    /// Storage format operation failed.
615    pub const FORMAT_FAILED: &str = "FORMAT_FAILED";
616    /// Storage status query failed.
617    pub const STATUS_FAILED: &str = "STATUS_FAILED";
618    /// List operation failed.
619    pub const LIST_FAILED: &str = "LIST_FAILED";
620    /// Garbage collection failed.
621    pub const GC_FAILED: &str = "GC_FAILED";
622    /// Container creation failed.
623    pub const CREATE_FAILED: &str = "CREATE_FAILED";
624    /// Container start failed.
625    pub const START_FAILED: &str = "START_FAILED";
626    /// Container stop failed.
627    pub const STOP_FAILED: &str = "STOP_FAILED";
628    /// Container delete failed.
629    pub const DELETE_FAILED: &str = "DELETE_FAILED";
630    /// Export operation failed.
631    pub const EXPORT_FAILED: &str = "EXPORT_FAILED";
632    /// Serialization error.
633    pub const SERIALIZATION_ERROR: &str = "SERIALIZATION_ERROR";
634    /// Message size exceeds maximum.
635    pub const MESSAGE_TOO_LARGE: &str = "MESSAGE_TOO_LARGE";
636    /// Process wait operation failed.
637    pub const WAIT_FAILED: &str = "WAIT_FAILED";
638}
639
640impl AgentResponse {
641    /// Create an error response with the given message and code.
642    ///
643    /// # Example
644    ///
645    /// ```
646    /// use smolvm_protocol::{AgentResponse, error_codes};
647    ///
648    /// let response = AgentResponse::error("image not found", error_codes::NOT_FOUND);
649    /// ```
650    pub fn error(message: impl Into<String>, code: &str) -> Self {
651        AgentResponse::Error {
652            message: message.into(),
653            code: Some(code.to_string()),
654        }
655    }
656
657    /// Create an error response from a Result's error, with the given code.
658    ///
659    /// # Example
660    ///
661    /// ```ignore
662    /// let response = some_operation()
663    ///     .map(|data| AgentResponse::ok_with_data(data))
664    ///     .unwrap_or_else(|e| AgentResponse::from_err(e, error_codes::PULL_FAILED));
665    /// ```
666    pub fn from_err<E: std::fmt::Display>(err: E, code: &str) -> Self {
667        AgentResponse::Error {
668            message: err.to_string(),
669            code: Some(code.to_string()),
670        }
671    }
672
673    /// Create an Ok response with optional JSON data.
674    pub fn ok(data: Option<serde_json::Value>) -> Self {
675        AgentResponse::Ok { data }
676    }
677
678    /// Create an Ok response with JSON-serializable data.
679    ///
680    /// Returns an error response if serialization fails.
681    pub fn ok_with_data<T: serde::Serialize>(data: T) -> Self {
682        match serde_json::to_value(data) {
683            Ok(value) => AgentResponse::Ok { data: Some(value) },
684            Err(e) => AgentResponse::error(
685                format!("failed to serialize response: {}", e),
686                error_codes::SERIALIZATION_ERROR,
687            ),
688        }
689    }
690
691    /// Convert a Result into an AgentResponse.
692    ///
693    /// On success, serializes the value to JSON. On error, creates an error response.
694    ///
695    /// # Example
696    ///
697    /// ```ignore
698    /// let response = AgentResponse::from_result(
699    ///     storage::pull_image(image),
700    ///     error_codes::PULL_FAILED,
701    /// );
702    /// ```
703    pub fn from_result<T, E>(result: Result<T, E>, error_code: &str) -> Self
704    where
705        T: serde::Serialize,
706        E: std::fmt::Display,
707    {
708        match result {
709            Ok(data) => Self::ok_with_data(data),
710            Err(e) => Self::from_err(e, error_code),
711        }
712    }
713}
714
715/// Image information returned by Query/ListImages.
716#[derive(Debug, Clone, Serialize, Deserialize)]
717pub struct ImageInfo {
718    /// Image reference.
719    pub reference: String,
720    /// Image digest (sha256:...).
721    pub digest: String,
722    /// Image size in bytes.
723    pub size: u64,
724    /// Creation timestamp (ISO 8601).
725    pub created: Option<String>,
726    /// Platform architecture.
727    pub architecture: String,
728    /// Platform OS.
729    pub os: String,
730    /// Number of layers.
731    pub layer_count: usize,
732    /// Layer digests in order.
733    pub layers: Vec<String>,
734    /// Image entrypoint (from OCI config).
735    #[serde(default)]
736    pub entrypoint: Vec<String>,
737    /// Image default command (from OCI config).
738    #[serde(default)]
739    pub cmd: Vec<String>,
740    /// Image environment variables (from OCI config).
741    #[serde(default)]
742    pub env: Vec<String>,
743    /// Image working directory (from OCI config).
744    #[serde(default)]
745    pub workdir: Option<String>,
746    /// Image default user (from OCI config).
747    #[serde(default)]
748    pub user: Option<String>,
749}
750
751/// Overlay preparation result.
752#[derive(Debug, Clone, Serialize, Deserialize)]
753pub struct OverlayInfo {
754    /// Path to the merged overlay rootfs.
755    pub rootfs_path: String,
756    /// Path to the upper (writable) directory.
757    pub upper_path: String,
758    /// Path to the work directory.
759    pub work_path: String,
760}
761
762/// Storage status information.
763#[derive(Debug, Clone, Serialize, Deserialize)]
764pub struct StorageStatus {
765    /// Whether the storage is formatted and ready.
766    pub ready: bool,
767    /// Total size in bytes.
768    pub total_bytes: u64,
769    /// Used size in bytes.
770    pub used_bytes: u64,
771    /// Number of cached layers.
772    pub layer_count: usize,
773    /// Number of cached images.
774    pub image_count: usize,
775}
776
777/// Registry authentication credentials for pulling images.
778///
779/// `Debug` is hand-written to redact the password: this value is carried inside
780/// `AgentRequest::Pull`, and any `{:?}` of that request (e.g. a tracing span)
781/// would otherwise serialize the token verbatim into the machine's console log,
782/// which is exposed over the logs API.
783#[derive(Clone, Serialize, Deserialize)]
784pub struct RegistryAuth {
785    /// Username for authentication.
786    pub username: String,
787    /// Password or token for authentication.
788    pub password: String,
789}
790
791impl std::fmt::Debug for RegistryAuth {
792    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
793        f.debug_struct("RegistryAuth")
794            .field("username", &self.username)
795            .field("password", &"***")
796            .finish()
797    }
798}
799
800// ============================================================================
801// Workload VM Protocol (Command Execution)
802// ============================================================================
803
804/// Messages from host to workload VM.
805#[derive(Debug, Clone, Serialize, Deserialize)]
806#[serde(tag = "type", rename_all = "snake_case")]
807pub enum HostMessage {
808    /// Authentication request.
809    Auth {
810        /// Authentication token (base64).
811        token: String,
812        /// Protocol version.
813        protocol_version: u32,
814    },
815
816    /// Run a command.
817    Run {
818        /// Request ID for correlating responses.
819        request_id: u64,
820        /// Command and arguments.
821        command: Vec<String>,
822        /// Environment variables.
823        env: Vec<(String, String)>,
824        /// Working directory.
825        workdir: Option<String>,
826    },
827
828    /// Execute a command in running VM.
829    Exec {
830        /// Request ID.
831        request_id: u64,
832        /// Command and arguments.
833        command: Vec<String>,
834        /// Allocate a TTY.
835        tty: bool,
836    },
837
838    /// Send a signal to a running command.
839    Signal {
840        /// Request ID of the command.
841        request_id: u64,
842        /// Signal number.
843        signal: i32,
844    },
845
846    /// Request graceful shutdown.
847    Stop {
848        /// Timeout in milliseconds.
849        timeout_ms: u64,
850    },
851}
852
853/// Messages from workload VM to host.
854#[derive(Debug, Clone, Serialize, Deserialize)]
855#[serde(tag = "type", rename_all = "snake_case")]
856pub enum GuestMessage {
857    /// Authentication successful.
858    AuthOk,
859
860    /// Authentication failed.
861    AuthFailed,
862
863    /// VM is ready to receive commands.
864    Ready,
865
866    /// Command started.
867    Started {
868        /// Request ID.
869        request_id: u64,
870    },
871
872    /// Stdout data from command.
873    Stdout {
874        /// Request ID.
875        request_id: u64,
876        /// Output data.
877        #[serde(with = "base64_bytes")]
878        data: Vec<u8>,
879        /// Whether output was truncated.
880        truncated: bool,
881    },
882
883    /// Stderr data from command.
884    Stderr {
885        /// Request ID.
886        request_id: u64,
887        /// Output data.
888        #[serde(with = "base64_bytes")]
889        data: Vec<u8>,
890        /// Whether output was truncated.
891        truncated: bool,
892    },
893
894    /// Command exited.
895    Exit {
896        /// Request ID.
897        request_id: u64,
898        /// Exit code.
899        code: i32,
900        /// Exit reason.
901        reason: String,
902    },
903
904    /// Error occurred.
905    Error {
906        /// Request ID (if applicable).
907        request_id: Option<u64>,
908        /// Error message.
909        message: String,
910    },
911}
912
913// ============================================================================
914// Wire Format Helpers
915// ============================================================================
916
917/// Envelope that wraps any message with an optional trace ID for correlation.
918///
919/// On the wire, the trace_id is flattened into the JSON alongside the message
920/// fields: `{"trace_id":"abc123","method":"ping"}`.
921#[derive(Debug, Clone, Serialize, Deserialize)]
922pub struct Envelope<T> {
923    /// Trace ID for correlating host API requests to agent operations.
924    #[serde(skip_serializing_if = "Option::is_none", default)]
925    pub trace_id: Option<String>,
926    /// The wrapped message.
927    #[serde(flatten)]
928    pub body: T,
929}
930
931impl<T> Envelope<T> {
932    /// Create an envelope with no trace ID.
933    pub fn new(body: T) -> Self {
934        Self {
935            trace_id: None,
936            body,
937        }
938    }
939
940    /// Create an envelope with an optional trace ID.
941    pub fn with_trace_id(body: T, trace_id: Option<String>) -> Self {
942        Self { trace_id, body }
943    }
944}
945
946/// Encode a message to wire format (length-prefixed JSON).
947pub fn encode_message<T: Serialize>(msg: &T) -> Result<Vec<u8>, serde_json::Error> {
948    let json = serde_json::to_vec(msg)?;
949    let len = json.len() as u32;
950
951    let mut buf = Vec::with_capacity(4 + json.len());
952    buf.extend_from_slice(&len.to_be_bytes());
953    buf.extend_from_slice(&json);
954
955    Ok(buf)
956}
957
958/// Decode a message from wire format.
959pub fn decode_message<T: for<'de> Deserialize<'de>>(data: &[u8]) -> Result<T, DecodeError> {
960    if data.len() < 4 {
961        return Err(DecodeError::TooShort);
962    }
963
964    let len = u32::from_be_bytes([data[0], data[1], data[2], data[3]]) as usize;
965
966    if len > MAX_FRAME_SIZE as usize {
967        return Err(DecodeError::TooLarge(len));
968    }
969
970    if data.len() < 4 + len {
971        return Err(DecodeError::Incomplete {
972            expected: len,
973            got: data.len() - 4,
974        });
975    }
976
977    serde_json::from_slice(&data[4..4 + len]).map_err(DecodeError::Json)
978}
979
980/// Error decoding a wire message.
981#[derive(Debug)]
982pub enum DecodeError {
983    /// Data too short to contain length header.
984    TooShort,
985    /// Frame size exceeds maximum.
986    TooLarge(usize),
987    /// Incomplete frame.
988    Incomplete {
989        /// Expected length.
990        expected: usize,
991        /// Actual length.
992        got: usize,
993    },
994    /// JSON parse error.
995    Json(serde_json::Error),
996}
997
998impl std::fmt::Display for DecodeError {
999    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1000        match self {
1001            DecodeError::TooShort => write!(f, "data too short for length header"),
1002            DecodeError::TooLarge(size) => write!(f, "frame too large: {} bytes", size),
1003            DecodeError::Incomplete { expected, got } => {
1004                write!(
1005                    f,
1006                    "incomplete frame: expected {} bytes, got {}",
1007                    expected, got
1008                )
1009            }
1010            DecodeError::Json(e) => write!(f, "JSON decode error: {}", e),
1011        }
1012    }
1013}
1014
1015impl std::error::Error for DecodeError {}
1016
1017#[cfg(test)]
1018mod tests {
1019    use super::*;
1020
1021    #[test]
1022    fn test_encode_decode_roundtrip() {
1023        let req = AgentRequest::Pull {
1024            image: "alpine:latest".to_string(),
1025            oci_platform: Some("linux/arm64".to_string()),
1026            auth: None,
1027            proxy: None,
1028            no_proxy: None,
1029        };
1030
1031        let encoded = encode_message(&req).unwrap();
1032        let decoded: AgentRequest = decode_message(&encoded).unwrap();
1033
1034        let AgentRequest::Pull {
1035            image,
1036            oci_platform,
1037            auth,
1038            proxy,
1039            no_proxy,
1040        } = decoded
1041        else {
1042            panic!("expected Pull variant, got {:?}", decoded);
1043        };
1044        assert_eq!(image, "alpine:latest");
1045        assert_eq!(oci_platform, Some("linux/arm64".to_string()));
1046        assert!(auth.is_none());
1047        assert!(proxy.is_none());
1048        assert!(no_proxy.is_none());
1049    }
1050
1051    #[test]
1052    fn test_encode_decode_with_auth() {
1053        let req = AgentRequest::Pull {
1054            image: "ghcr.io/owner/repo:latest".to_string(),
1055            oci_platform: None,
1056            auth: Some(RegistryAuth {
1057                username: "testuser".to_string(),
1058                password: "testpass".to_string(),
1059            }),
1060            proxy: None,
1061            no_proxy: None,
1062        };
1063
1064        let encoded = encode_message(&req).unwrap();
1065        let decoded: AgentRequest = decode_message(&encoded).unwrap();
1066
1067        let AgentRequest::Pull {
1068            image,
1069            oci_platform,
1070            auth,
1071            proxy: _,
1072            no_proxy: _,
1073        } = decoded
1074        else {
1075            panic!("expected Pull variant, got {:?}", decoded);
1076        };
1077        assert_eq!(image, "ghcr.io/owner/repo:latest");
1078        assert!(oci_platform.is_none());
1079        let auth = auth.expect("auth should be Some");
1080        assert_eq!(auth.username, "testuser");
1081        assert_eq!(auth.password, "testpass");
1082    }
1083
1084    #[test]
1085    fn test_encode_decode_with_proxy() {
1086        let req = AgentRequest::Pull {
1087            image: "alpine:latest".to_string(),
1088            oci_platform: None,
1089            auth: None,
1090            proxy: Some("http://192.168.127.254:3128".to_string()),
1091            no_proxy: Some("127.0.0.1,localhost,.internal".to_string()),
1092        };
1093
1094        let encoded = encode_message(&req).unwrap();
1095        let decoded: AgentRequest = decode_message(&encoded).unwrap();
1096
1097        let AgentRequest::Pull {
1098            proxy, no_proxy, ..
1099        } = decoded
1100        else {
1101            panic!("expected Pull variant, got {:?}", decoded);
1102        };
1103        assert_eq!(proxy.as_deref(), Some("http://192.168.127.254:3128"));
1104        assert_eq!(no_proxy.as_deref(), Some("127.0.0.1,localhost,.internal"));
1105    }
1106
1107    #[test]
1108    fn test_decode_too_short() {
1109        let data = [0u8; 2];
1110        let result: Result<AgentRequest, _> = decode_message(&data);
1111        assert!(matches!(result, Err(DecodeError::TooShort)));
1112    }
1113
1114    #[test]
1115    fn test_decode_incomplete() {
1116        let mut data = vec![0, 0, 0, 100]; // claims 100 bytes
1117        data.extend_from_slice(b"{}"); // only 2 bytes of payload
1118        let result: Result<AgentRequest, _> = decode_message(&data);
1119        assert!(matches!(result, Err(DecodeError::Incomplete { .. })));
1120    }
1121
1122    #[test]
1123    fn test_agent_request_serialization() {
1124        let req = AgentRequest::Ping;
1125        let json = serde_json::to_string(&req).unwrap();
1126        assert!(json.contains("ping"));
1127
1128        let req = AgentRequest::PrepareOverlay {
1129            image: "ubuntu:22.04".to_string(),
1130            workload_id: "wl-123".to_string(),
1131        };
1132        let json = serde_json::to_string(&req).unwrap();
1133        assert!(json.contains("prepare_overlay"));
1134    }
1135
1136    #[test]
1137    fn test_agent_response_serialization() {
1138        let resp = AgentResponse::Pong {
1139            version: PROTOCOL_VERSION,
1140        };
1141        let json = serde_json::to_string(&resp).unwrap();
1142        assert!(json.contains("pong"));
1143
1144        let resp = AgentResponse::Progress {
1145            message: "Pulling layer 1/3".to_string(),
1146            percent: Some(33),
1147            layer: Some("sha256:abc123".to_string()),
1148        };
1149        let json = serde_json::to_string(&resp).unwrap();
1150        assert!(json.contains("progress"));
1151    }
1152
1153    #[test]
1154    fn file_write_begin_roundtrips() {
1155        let req = AgentRequest::FileWriteBegin {
1156            path: "/tmp/target".into(),
1157            mode: Some(0o600),
1158            total_size: 123_456_789,
1159        };
1160        let bytes = encode_message(&req).unwrap();
1161        let back: AgentRequest = decode_message(&bytes).unwrap();
1162        match back {
1163            AgentRequest::FileWriteBegin {
1164                path,
1165                mode,
1166                total_size,
1167            } => {
1168                assert_eq!(path, "/tmp/target");
1169                assert_eq!(mode, Some(0o600));
1170                assert_eq!(total_size, 123_456_789);
1171            }
1172            _ => panic!("wrong variant"),
1173        }
1174    }
1175
1176    #[test]
1177    fn file_write_chunk_roundtrips_binary_data() {
1178        // Binary data (bytes outside UTF-8) must survive the base64
1179        // trip intact. If the encoding ever silently lossifies, this
1180        // fires.
1181        let payload: Vec<u8> = (0u8..=255).collect();
1182        let req = AgentRequest::FileWriteChunk {
1183            data: payload.clone(),
1184            done: true,
1185        };
1186        let bytes = encode_message(&req).unwrap();
1187        let back: AgentRequest = decode_message(&bytes).unwrap();
1188        match back {
1189            AgentRequest::FileWriteChunk { data, done } => {
1190                assert_eq!(data, payload);
1191                assert!(done);
1192            }
1193            _ => panic!("wrong variant"),
1194        }
1195    }
1196
1197    #[test]
1198    fn file_write_size_constants_are_frame_safe() {
1199        // Sanity: a single streaming chunk at FILE_WRITE_CHUNK_SIZE
1200        // must fit inside MAX_FRAME_SIZE after base64 (+ ~33%) and
1201        // JSON overhead. If anyone bumps CHUNK_SIZE past the limit,
1202        // this test fires before production does.
1203        let chunk_bytes = FILE_WRITE_CHUNK_SIZE as u64;
1204        let base64_bytes = chunk_bytes.div_ceil(3) * 4; // ceil(n/3)*4
1205        let json_overhead = 256u64; // method tag, done bool, quotes
1206        let total = base64_bytes + json_overhead;
1207        assert!(
1208            total < MAX_FRAME_SIZE as u64,
1209            "FILE_WRITE_CHUNK_SIZE of {} bytes would produce a frame \
1210             of ~{} bytes which exceeds MAX_FRAME_SIZE of {}",
1211            chunk_bytes,
1212            total,
1213            MAX_FRAME_SIZE
1214        );
1215
1216        // Single-shot threshold must be <= chunk size. They can be
1217        // equal (a 1 MiB file is a single shot; a 1 MiB + 1 byte
1218        // file streams as two chunks); but SINGLE_SHOT > CHUNK would
1219        // be incoherent — a file slightly over the shot threshold
1220        // would need to stream as... a single oversized chunk.
1221        assert!(FILE_WRITE_SINGLE_SHOT_MAX <= FILE_WRITE_CHUNK_SIZE);
1222    }
1223
1224    #[test]
1225    fn test_ports_constants() {
1226        assert_eq!(ports::WORKLOAD_CONTROL, 5000);
1227        assert_eq!(ports::WORKLOAD_LOGS, 5001);
1228        assert_eq!(ports::AGENT_CONTROL, 6000);
1229        assert_eq!(ports::SSH_AGENT, 6001);
1230    }
1231
1232    #[test]
1233    fn test_cid_constants() {
1234        assert_eq!(cid::HOST, 2);
1235        assert_eq!(cid::GUEST, 3);
1236    }
1237
1238    #[test]
1239    fn test_envelope_serialization_with_trace_id() {
1240        let req = AgentRequest::Ping;
1241        let envelope = Envelope::with_trace_id(&req, Some("abc123".to_string()));
1242        let json = serde_json::to_string(&envelope).unwrap();
1243
1244        // trace_id should be flattened alongside the method tag
1245        assert!(json.contains("\"trace_id\":\"abc123\""));
1246        assert!(json.contains("\"method\":\"ping\""));
1247
1248        // Deserialize back — Envelope<AgentRequest> with flatten
1249        let parsed: Envelope<AgentRequest> = serde_json::from_str(&json).unwrap();
1250        assert_eq!(parsed.trace_id.as_deref(), Some("abc123"));
1251        assert!(matches!(parsed.body, AgentRequest::Ping));
1252    }
1253
1254    #[test]
1255    fn test_envelope_without_trace_id() {
1256        let req = AgentRequest::Ping;
1257        let envelope = Envelope::new(&req);
1258        let json = serde_json::to_string(&envelope).unwrap();
1259
1260        // No trace_id field (skip_serializing_if = None)
1261        assert!(!json.contains("trace_id"));
1262        assert!(json.contains("\"method\":\"ping\""));
1263    }
1264
1265    #[test]
1266    fn test_envelope_backward_compat_bare_request() {
1267        // A bare AgentRequest (no Envelope) should fail to parse as Envelope
1268        // but succeed as bare AgentRequest — this is the agent's fallback path
1269        let bare_json = r#"{"method":"ping"}"#;
1270
1271        // Envelope parse should fail (no body field to flatten into)
1272        // Actually with flatten, this may work — let's verify
1273        let envelope_result = serde_json::from_str::<Envelope<AgentRequest>>(bare_json);
1274        let bare_result = serde_json::from_str::<AgentRequest>(bare_json);
1275
1276        // At least one must succeed for backward compat
1277        assert!(
1278            envelope_result.is_ok() || bare_result.is_ok(),
1279            "Neither Envelope nor bare parse succeeded"
1280        );
1281
1282        // Bare parse must always work
1283        assert!(bare_result.is_ok());
1284        assert!(matches!(bare_result.unwrap(), AgentRequest::Ping));
1285
1286        // If Envelope works, trace_id should be None
1287        if let Ok(env) = envelope_result {
1288            assert!(env.trace_id.is_none());
1289        }
1290    }
1291}