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