1use std::collections::BTreeMap;
9
10use anyhow::{Context, Result};
11use base64::engine::general_purpose::STANDARD as BASE64;
12use base64::Engine as _;
13use serde::{Deserialize, Serialize};
14
15use agentos_sidecar_client::wire;
16
17use crate::agent_os::AgentOs;
18use crate::error::ClientError;
19use crate::fs::FileContent;
20
21const VM_FETCH_BUFFER_LIMIT_BYTES: usize = agentos_sidecar_client::wire::DEFAULT_MAX_FRAME_BYTES;
25
26#[derive(Debug, Deserialize)]
29struct VmFetchResponsePayload {
30 status: u16,
31 #[serde(rename = "statusText", default)]
32 status_text: Option<String>,
33 #[serde(default)]
34 headers: Option<Vec<(String, String)>>,
35 #[serde(default)]
37 body: Option<String>,
38}
39
40#[cfg_attr(feature = "contract", derive(ts_rs::TS))]
41#[cfg_attr(feature = "contract", ts(rename = "ActorHttpRequest"))]
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(rename_all = "camelCase", deny_unknown_fields)]
44pub struct HttpRequest {
45 pub port: u16,
46 pub path: String,
47 #[cfg_attr(feature = "contract", ts(optional, as = "Option<_>"))]
48 #[serde(default = "default_http_method")]
49 pub method: String,
50 #[cfg_attr(feature = "contract", ts(optional, as = "Option<_>"))]
51 #[serde(default)]
52 pub headers: BTreeMap<String, String>,
53 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub body: Option<FileContent>,
55}
56
57fn default_http_method() -> String {
58 "GET".to_string()
59}
60
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62pub struct HttpResponse {
63 pub status: u16,
64 #[serde(rename = "statusText")]
65 pub status_text: String,
66 pub headers: Vec<(String, String)>,
67 pub body: Vec<u8>,
68}
69
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
71#[serde(rename_all = "camelCase")]
72pub struct HttpStreamHead {
73 pub stream_id: String,
74 pub status: u16,
75 pub status_text: String,
76 pub headers: Vec<(String, String)>,
77}
78
79#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
80pub struct HttpStreamChunk {
81 pub body: Vec<u8>,
82 pub done: bool,
83}
84
85#[derive(Debug, Deserialize)]
86#[serde(rename_all = "camelCase")]
87struct VmFetchStreamHeadPayload {
88 stream_id: String,
89 status: u16,
90 #[serde(default)]
91 status_text: Option<String>,
92 #[serde(default)]
93 headers: Option<Vec<(String, String)>>,
94}
95
96#[derive(Debug, Deserialize)]
97struct VmFetchStreamChunkPayload {
98 body: String,
99 done: bool,
100}
101
102struct PreparedHttpRequest {
103 port: u16,
104 path: String,
105 method: String,
106 headers_json: String,
107 body_base64: Option<String>,
108}
109
110impl AgentOs {
111 pub async fn http_request(&self, request: HttpRequest) -> Result<HttpResponse> {
116 let buffer_limit = self.fetch_buffer_limit();
117 let request = prepare_http_request(request, buffer_limit)?;
118 let response_json = self
119 .vm_fetch_response(wire::VmFetchRequest {
120 port: request.port,
121 method: request.method,
122 path: request.path,
123 headers_json: request.headers_json,
124 body: None,
125 body_base64: request.body_base64,
126 stream_operation: None,
127 stream_id: None,
128 max_bytes: None,
129 })
130 .await?;
131 ensure_fetch_component_within_limit(
132 "fetch response json",
133 response_json.len(),
134 buffer_limit,
135 )?;
136
137 let payload: VmFetchResponsePayload =
138 serde_json::from_str(&response_json).context("parsing vm_fetch response json")?;
139 if !(100..=599).contains(&payload.status) {
140 return Err(ClientError::Sidecar(format!(
141 "HTTP response has invalid status {}",
142 payload.status
143 ))
144 .into());
145 }
146
147 let decoded_body = match payload.body {
150 Some(encoded) => {
151 ensure_fetch_base64_body_within_limit(&encoded, buffer_limit)?;
152 BASE64
153 .decode(encoded.as_bytes())
154 .context("decoding base64 fetch response body")?
155 }
156 None => Vec::new(),
157 };
158 Ok(HttpResponse {
159 status: payload.status,
160 status_text: payload.status_text.unwrap_or_default(),
161 headers: payload.headers.unwrap_or_default(),
162 body: decoded_body,
163 })
164 }
165
166 pub async fn http_request_stream_start(&self, request: HttpRequest) -> Result<HttpStreamHead> {
168 let buffer_limit = self.fetch_buffer_limit();
169 let request = prepare_http_request(request, buffer_limit)?;
170 let response_json = self
171 .vm_fetch_response(wire::VmFetchRequest {
172 port: request.port,
173 method: request.method,
174 path: request.path,
175 headers_json: request.headers_json,
176 body: None,
177 body_base64: request.body_base64,
178 stream_operation: Some(String::from("start")),
179 stream_id: None,
180 max_bytes: None,
181 })
182 .await?;
183 ensure_fetch_component_within_limit(
184 "fetch stream response head",
185 response_json.len(),
186 buffer_limit,
187 )?;
188 let payload: VmFetchStreamHeadPayload =
189 serde_json::from_str(&response_json).context("parsing vm_fetch stream head")?;
190 if !(100..=599).contains(&payload.status) {
191 return Err(ClientError::Sidecar(format!(
192 "HTTP stream response has invalid status {}",
193 payload.status
194 ))
195 .into());
196 }
197 Ok(HttpStreamHead {
198 stream_id: payload.stream_id,
199 status: payload.status,
200 status_text: payload.status_text.unwrap_or_default(),
201 headers: payload.headers.unwrap_or_default(),
202 })
203 }
204
205 pub async fn http_request_stream_read(
207 &self,
208 stream_id: &str,
209 max_bytes: u32,
210 ) -> Result<HttpStreamChunk> {
211 if stream_id.is_empty() {
212 return Err(
213 ClientError::Sidecar(String::from("fetch stream id cannot be empty")).into(),
214 );
215 }
216 if max_bytes == 0 {
217 return Err(ClientError::Sidecar(String::from(
218 "fetch stream max_bytes must be greater than zero",
219 ))
220 .into());
221 }
222 let response_json = self
223 .vm_fetch_response(wire::VmFetchRequest {
224 port: 0,
225 method: String::from("GET"),
226 path: String::from("/"),
227 headers_json: String::from("{}"),
228 body: None,
229 body_base64: None,
230 stream_operation: Some(String::from("read")),
231 stream_id: Some(stream_id.to_owned()),
232 max_bytes: Some(max_bytes),
233 })
234 .await?;
235 let payload: VmFetchStreamChunkPayload =
236 serde_json::from_str(&response_json).context("parsing vm_fetch stream chunk")?;
237 ensure_fetch_base64_body_within_limit(
238 &payload.body,
239 usize::try_from(max_bytes).unwrap_or(usize::MAX),
240 )?;
241 let body = BASE64
242 .decode(payload.body.as_bytes())
243 .context("decoding vm_fetch stream chunk")?;
244 Ok(HttpStreamChunk {
245 body,
246 done: payload.done,
247 })
248 }
249
250 pub async fn http_request_stream_cancel(&self, stream_id: &str) -> Result<()> {
252 if stream_id.is_empty() {
253 return Err(
254 ClientError::Sidecar(String::from("fetch stream id cannot be empty")).into(),
255 );
256 }
257 let response_json = self
258 .vm_fetch_response(wire::VmFetchRequest {
259 port: 0,
260 method: String::from("GET"),
261 path: String::from("/"),
262 headers_json: String::from("{}"),
263 body: None,
264 body_base64: None,
265 stream_operation: Some(String::from("cancel")),
266 stream_id: Some(stream_id.to_owned()),
267 max_bytes: None,
268 })
269 .await?;
270 let cancelled = serde_json::from_str::<serde_json::Value>(&response_json)
271 .context("parsing vm_fetch stream cancellation")?
272 .get("cancelled")
273 .and_then(serde_json::Value::as_bool)
274 .unwrap_or(false);
275 if !cancelled {
276 return Err(ClientError::Sidecar(String::from(
277 "fetch stream cancellation was not acknowledged",
278 ))
279 .into());
280 }
281 Ok(())
282 }
283
284 async fn vm_fetch_response(&self, payload: wire::VmFetchRequest) -> Result<String> {
285 let response = self
286 .transport()
287 .request_wire_bounded(
288 self.vm_fetch_ownership(),
289 wire::RequestPayload::VmFetchRequest(payload),
290 self.fetch_buffer_limit(),
291 )
292 .await?;
293 match response {
294 wire::ResponsePayload::VmFetchResponse(result) => Ok(result.response_json),
295 wire::ResponsePayload::RejectedResponse(rejected) => {
296 Err(ClientError::from_rejection(rejected).into())
297 }
298 other => {
299 Err(ClientError::Sidecar(format!("fetch: unexpected response {other:?}")).into())
300 }
301 }
302 }
303
304 fn vm_fetch_ownership(&self) -> wire::OwnershipScope {
306 wire::OwnershipScope::VmOwnership(wire::VmOwnership {
307 connection_id: self.connection_id().to_string(),
308 session_id: self.wire_session_id().to_string(),
309 vm_id: self.vm_id().to_string(),
310 })
311 }
312
313 fn fetch_buffer_limit(&self) -> usize {
314 self.transport()
315 .max_frame_bytes()
316 .min(VM_FETCH_BUFFER_LIMIT_BYTES)
317 }
318}
319
320fn prepare_http_request(request: HttpRequest, buffer_limit: usize) -> Result<PreparedHttpRequest> {
321 let HttpRequest {
322 port,
323 path,
324 method,
325 headers,
326 body,
327 } = request;
328 if port == 0 {
329 return Err(ClientError::Sidecar(String::from(
330 "HTTP request port must be greater than zero",
331 ))
332 .into());
333 }
334 if !path.starts_with('/') {
335 return Err(
336 ClientError::Sidecar(format!("HTTP request path must be absolute: {path}")).into(),
337 );
338 }
339 ensure_fetch_component_within_limit("HTTP request path", path.len(), buffer_limit)?;
340 let method = method.to_uppercase();
341 if method.is_empty() {
342 return Err(
343 ClientError::Sidecar(String::from("HTTP request method cannot be empty")).into(),
344 );
345 }
346 let raw_header_bytes = headers.iter().fold(0usize, |size, (name, value)| {
347 size.saturating_add(name.len()).saturating_add(value.len())
348 });
349 ensure_fetch_component_within_limit("fetch request headers", raw_header_bytes, buffer_limit)?;
350 let headers_json =
351 serde_json::to_string(&headers).context("serializing fetch request headers")?;
352 ensure_fetch_component_within_limit(
353 "fetch request headers json",
354 headers_json.len(),
355 buffer_limit,
356 )?;
357 let body_base64 = if method == "GET" || method == "HEAD" {
358 None
359 } else {
360 body.map(|body| BASE64.encode(body.into_bytes()))
361 };
362 if let Some(body) = &body_base64 {
363 ensure_fetch_component_within_limit("HTTP request body base64", body.len(), buffer_limit)?;
364 }
365 ensure_fetch_request_payload_within_limit(
366 &method,
367 &path,
368 &headers_json,
369 body_base64.as_deref(),
370 buffer_limit,
371 )?;
372 Ok(PreparedHttpRequest {
373 port,
374 path,
375 method,
376 headers_json,
377 body_base64,
378 })
379}
380
381fn ensure_fetch_component_within_limit(
382 component: &str,
383 size: usize,
384 limit: usize,
385) -> Result<(), ClientError> {
386 if size > limit {
387 return Err(ClientError::Sidecar(format!(
388 "{component} is {size} bytes, limit is {limit}"
389 )));
390 }
391 Ok(())
392}
393
394fn ensure_fetch_base64_body_within_limit(encoded: &str, limit: usize) -> Result<(), ClientError> {
395 ensure_fetch_component_within_limit("fetch response body base64", encoded.len(), limit)?;
396 ensure_fetch_component_within_limit(
397 "fetch response body",
398 base64_decoded_upper_bound(encoded.len()),
399 limit,
400 )
401}
402
403fn ensure_fetch_request_payload_within_limit(
404 method: &str,
405 path: &str,
406 headers_json: &str,
407 body: Option<&str>,
408 limit: usize,
409) -> Result<(), ClientError> {
410 let size = method
411 .len()
412 .saturating_add(path.len())
413 .saturating_add(headers_json.len())
414 .saturating_add(body.map(str::len).unwrap_or_default());
415 ensure_fetch_component_within_limit("fetch request payload", size, limit)
416}
417
418fn base64_decoded_upper_bound(encoded_len: usize) -> usize {
419 encoded_len.saturating_add(3) / 4 * 3
420}
421
422#[cfg(test)]
423mod tests {
424 use super::{
425 base64_decoded_upper_bound, ensure_fetch_base64_body_within_limit,
426 ensure_fetch_component_within_limit, ensure_fetch_request_payload_within_limit,
427 prepare_http_request, HttpRequest, VmFetchResponsePayload, BASE64,
428 VM_FETCH_BUFFER_LIMIT_BYTES,
429 };
430 use std::collections::BTreeMap;
431
432 #[test]
433 fn fetch_component_limit_rejects_oversized_buffers() {
434 assert!(ensure_fetch_component_within_limit("component", 8, 8).is_ok());
435
436 let error =
437 ensure_fetch_component_within_limit("component", 9, 8).expect_err("limit violation");
438 assert!(
439 error.to_string().contains("component is 9 bytes"),
440 "unexpected error: {error}"
441 );
442 }
443
444 #[test]
445 fn fetch_component_limit_rejects_expanded_request_text() {
446 let replacement = String::from_utf8_lossy(&[0xff]).into_owned();
447 assert_eq!(replacement.len(), 3);
448
449 let error = ensure_fetch_component_within_limit("fetch request body text", 3, 2)
450 .expect_err("expanded body text should exceed limit");
451 assert!(
452 error
453 .to_string()
454 .contains("fetch request body text is 3 bytes"),
455 "unexpected error: {error}"
456 );
457 }
458
459 #[test]
460 fn fetch_request_payload_limit_rejects_aggregate_oversize() {
461 let error =
462 ensure_fetch_request_payload_within_limit("POST", "/abc", "{}", Some("body"), 8)
463 .expect_err("aggregate request payload should exceed limit");
464 assert!(
465 error
466 .to_string()
467 .contains("fetch request payload is 14 bytes"),
468 "unexpected error: {error}"
469 );
470 }
471
472 #[test]
473 fn fetch_base64_guard_bounds_decoded_response_size() {
474 assert_eq!(base64_decoded_upper_bound(4), 3);
475 assert!(ensure_fetch_base64_body_within_limit("AAAA", 4).is_ok());
476
477 let error = ensure_fetch_base64_body_within_limit("AAAA", 2)
478 .expect_err("encoded body should exceed limit");
479 assert!(
480 error
481 .to_string()
482 .contains("fetch response body base64 is 4 bytes"),
483 "unexpected error: {error}"
484 );
485 }
486
487 #[test]
488 fn fetch_buffer_limit_is_fixed_to_default_frame_size() {
489 assert_eq!(
490 VM_FETCH_BUFFER_LIMIT_BYTES,
491 agentos_sidecar_client::wire::DEFAULT_MAX_FRAME_BYTES
492 );
493 }
494
495 #[test]
496 fn fetch_preserves_binary_request_bodies() {
497 let prepared = prepare_http_request(
498 HttpRequest {
499 port: 3000,
500 path: String::from("/upload"),
501 method: String::from("POST"),
502 headers: BTreeMap::new(),
503 body: Some(vec![0xff, 0x00, 0x80].into()),
504 },
505 VM_FETCH_BUFFER_LIMIT_BYTES,
506 )
507 .expect("prepare request");
508 assert_eq!(prepared.body_base64.as_deref(), Some("/wCA"));
509 }
510
511 #[test]
512 fn fetch_response_keeps_repeated_headers() {
513 let payload: VmFetchResponsePayload = serde_json::from_str(
514 r#"{"status":200,"headers":[["set-cookie","a=1"],["set-cookie","b=2"]]}"#,
515 )
516 .expect("parse repeated response headers");
517 assert_eq!(payload.headers.expect("headers").len(), 2);
518 }
519
520 use base64::Engine as _;
529
530 #[test]
533 fn vm_fetch_response_overflowing_status_fails_deserialization_without_panic() {
534 let json = r#"{"status":70000}"#;
535 let parsed: Result<VmFetchResponsePayload, _> = serde_json::from_str(json);
536 assert!(
537 parsed.is_err(),
538 "AOSCLIENT-P3-fetch: status 70000 overflows u16 and must fail to deserialize, not panic"
539 );
540 }
541
542 #[test]
545 fn vm_fetch_response_zero_status_is_rejected_by_status_code_without_panic() {
546 let json = r#"{"status":0}"#;
547 let payload: VmFetchResponsePayload =
548 serde_json::from_str(json).expect("status 0 is a valid u16 and should deserialize");
549 let status = http::StatusCode::from_u16(payload.status);
550 assert!(
551 status.is_err(),
552 "AOSCLIENT-P3-fetch: status code 0 must be rejected by StatusCode::from_u16, not panic"
553 );
554 }
555
556 #[test]
558 fn vm_fetch_response_malformed_base64_body_errors_without_panic() {
559 ensure_fetch_base64_body_within_limit("!!!", VM_FETCH_BUFFER_LIMIT_BYTES)
562 .expect("tiny body is within the limit");
563 let decoded = BASE64.decode("!!!".as_bytes());
564 assert!(
565 decoded.is_err(),
566 "AOSCLIENT-P3-fetch: malformed base64 response body \"!!!\" must error on decode, not panic"
567 );
568 }
569
570 #[test]
573 fn vm_fetch_response_over_limit_base64_body_is_rejected_before_decode() {
574 let oversized = "A".repeat(VM_FETCH_BUFFER_LIMIT_BYTES + 4);
576 let result = ensure_fetch_base64_body_within_limit(&oversized, VM_FETCH_BUFFER_LIMIT_BYTES);
577 let error = result.expect_err(
578 "AOSCLIENT-P3-fetch: an over-limit base64 body must be rejected before decode",
579 );
580 assert!(
581 error.to_string().contains("fetch response body base64"),
582 "AOSCLIENT-P3-fetch: over-limit base64 body must be rejected by the size guard, got: {error}"
583 );
584 }
585}