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