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;
19
20const VM_FETCH_BUFFER_LIMIT_BYTES: usize = agentos_sidecar_client::wire::DEFAULT_MAX_FRAME_BYTES;
24
25#[derive(Debug, Deserialize)]
28struct VmFetchResponsePayload {
29 status: u16,
30 #[serde(rename = "statusText", default)]
31 status_text: Option<String>,
32 #[serde(default)]
33 headers: Option<Vec<(String, String)>>,
34 #[serde(default)]
36 body: Option<String>,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40pub struct HttpRequest {
41 pub port: u16,
42 pub path: String,
43 #[serde(default = "default_http_method")]
44 pub method: String,
45 #[serde(default)]
46 pub headers: BTreeMap<String, String>,
47 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub body: Option<Vec<u8>>,
49}
50
51fn default_http_method() -> String {
52 "GET".to_string()
53}
54
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
56pub struct HttpResponse {
57 pub status: u16,
58 #[serde(rename = "statusText")]
59 pub status_text: String,
60 pub headers: BTreeMap<String, String>,
61 pub body: Vec<u8>,
62}
63
64impl AgentOs {
65 pub async fn http_request(&self, request: HttpRequest) -> Result<HttpResponse> {
70 let buffer_limit = self.fetch_buffer_limit();
71 let HttpRequest {
72 port,
73 path,
74 method,
75 headers: header_map,
76 body,
77 } = request;
78 if !path.starts_with('/') {
79 return Err(ClientError::Sidecar(format!(
80 "HTTP request path must be absolute: {path}"
81 ))
82 .into());
83 }
84 ensure_fetch_component_within_limit("HTTP request path", path.len(), buffer_limit)?;
85 let method = method.to_uppercase();
86 let raw_header_bytes = header_map.iter().fold(0usize, |size, (name, value)| {
87 size.saturating_add(name.len()).saturating_add(value.len())
88 });
89 ensure_fetch_component_within_limit(
90 "fetch request headers",
91 raw_header_bytes,
92 buffer_limit,
93 )?;
94 let headers_json =
95 serde_json::to_string(&header_map).context("serializing fetch request headers")?;
96 ensure_fetch_component_within_limit(
97 "fetch request headers json",
98 headers_json.len(),
99 buffer_limit,
100 )?;
101
102 let wire_body = if method == "GET" || method == "HEAD" {
104 None
105 } else {
106 body.map(|body| String::from_utf8_lossy(&body).into_owned())
107 };
108 if let Some(body) = &wire_body {
109 ensure_fetch_component_within_limit("HTTP request body", body.len(), buffer_limit)?;
110 }
111 ensure_fetch_request_payload_within_limit(
112 &method,
113 &path,
114 &headers_json,
115 wire_body.as_deref(),
116 buffer_limit,
117 )?;
118
119 let response = self
120 .transport()
121 .request_wire_bounded(
122 self.vm_fetch_ownership(),
123 wire::RequestPayload::VmFetchRequest(wire::VmFetchRequest {
124 port,
125 method,
126 path,
127 headers_json,
128 body: wire_body,
129 body_base64: None,
130 stream_operation: None,
131 stream_id: None,
132 max_bytes: None,
133 }),
134 buffer_limit,
135 )
136 .await?;
137
138 let response_json = match response {
139 wire::ResponsePayload::VmFetchResponse(result) => result.response_json,
140 wire::ResponsePayload::RejectedResponse(rejected) => {
141 return Err(ClientError::from_rejection(rejected).into());
142 }
143 other => {
144 return Err(
145 ClientError::Sidecar(format!("fetch: unexpected response {other:?}")).into(),
146 );
147 }
148 };
149 ensure_fetch_component_within_limit(
150 "fetch response json",
151 response_json.len(),
152 buffer_limit,
153 )?;
154
155 let payload: VmFetchResponsePayload =
156 serde_json::from_str(&response_json).context("parsing vm_fetch response json")?;
157 if !(100..=599).contains(&payload.status) {
158 return Err(ClientError::Sidecar(format!(
159 "HTTP response has invalid status {}",
160 payload.status
161 ))
162 .into());
163 }
164
165 let decoded_body = match payload.body {
168 Some(encoded) => {
169 ensure_fetch_base64_body_within_limit(&encoded, buffer_limit)?;
170 BASE64
171 .decode(encoded.as_bytes())
172 .context("decoding base64 fetch response body")?
173 }
174 None => Vec::new(),
175 };
176 Ok(HttpResponse {
177 status: payload.status,
178 status_text: payload.status_text.unwrap_or_default(),
179 headers: payload.headers.unwrap_or_default().into_iter().collect(),
180 body: decoded_body,
181 })
182 }
183
184 fn vm_fetch_ownership(&self) -> wire::OwnershipScope {
186 wire::OwnershipScope::VmOwnership(wire::VmOwnership {
187 connection_id: self.connection_id().to_string(),
188 session_id: self.wire_session_id().to_string(),
189 vm_id: self.vm_id().to_string(),
190 })
191 }
192
193 fn fetch_buffer_limit(&self) -> usize {
194 self.transport()
195 .max_frame_bytes()
196 .min(VM_FETCH_BUFFER_LIMIT_BYTES)
197 }
198}
199
200fn ensure_fetch_component_within_limit(
201 component: &str,
202 size: usize,
203 limit: usize,
204) -> Result<(), ClientError> {
205 if size > limit {
206 return Err(ClientError::Sidecar(format!(
207 "{component} is {size} bytes, limit is {limit}"
208 )));
209 }
210 Ok(())
211}
212
213fn ensure_fetch_base64_body_within_limit(encoded: &str, limit: usize) -> Result<(), ClientError> {
214 ensure_fetch_component_within_limit("fetch response body base64", encoded.len(), limit)?;
215 ensure_fetch_component_within_limit(
216 "fetch response body",
217 base64_decoded_upper_bound(encoded.len()),
218 limit,
219 )
220}
221
222fn ensure_fetch_request_payload_within_limit(
223 method: &str,
224 path: &str,
225 headers_json: &str,
226 body: Option<&str>,
227 limit: usize,
228) -> Result<(), ClientError> {
229 let size = method
230 .len()
231 .saturating_add(path.len())
232 .saturating_add(headers_json.len())
233 .saturating_add(body.map(str::len).unwrap_or_default());
234 ensure_fetch_component_within_limit("fetch request payload", size, limit)
235}
236
237fn base64_decoded_upper_bound(encoded_len: usize) -> usize {
238 encoded_len.saturating_add(3) / 4 * 3
239}
240
241#[cfg(test)]
242mod tests {
243 use super::{
244 base64_decoded_upper_bound, ensure_fetch_base64_body_within_limit,
245 ensure_fetch_component_within_limit, ensure_fetch_request_payload_within_limit,
246 VM_FETCH_BUFFER_LIMIT_BYTES,
247 };
248
249 #[test]
250 fn fetch_component_limit_rejects_oversized_buffers() {
251 assert!(ensure_fetch_component_within_limit("component", 8, 8).is_ok());
252
253 let error =
254 ensure_fetch_component_within_limit("component", 9, 8).expect_err("limit violation");
255 assert!(
256 error.to_string().contains("component is 9 bytes"),
257 "unexpected error: {error}"
258 );
259 }
260
261 #[test]
262 fn fetch_component_limit_rejects_expanded_request_text() {
263 let replacement = String::from_utf8_lossy(&[0xff]).into_owned();
264 assert_eq!(replacement.len(), 3);
265
266 let error = ensure_fetch_component_within_limit("fetch request body text", 3, 2)
267 .expect_err("expanded body text should exceed limit");
268 assert!(
269 error
270 .to_string()
271 .contains("fetch request body text is 3 bytes"),
272 "unexpected error: {error}"
273 );
274 }
275
276 #[test]
277 fn fetch_request_payload_limit_rejects_aggregate_oversize() {
278 let error =
279 ensure_fetch_request_payload_within_limit("POST", "/abc", "{}", Some("body"), 8)
280 .expect_err("aggregate request payload should exceed limit");
281 assert!(
282 error
283 .to_string()
284 .contains("fetch request payload is 14 bytes"),
285 "unexpected error: {error}"
286 );
287 }
288
289 #[test]
290 fn fetch_base64_guard_bounds_decoded_response_size() {
291 assert_eq!(base64_decoded_upper_bound(4), 3);
292 assert!(ensure_fetch_base64_body_within_limit("AAAA", 4).is_ok());
293
294 let error = ensure_fetch_base64_body_within_limit("AAAA", 2)
295 .expect_err("encoded body should exceed limit");
296 assert!(
297 error
298 .to_string()
299 .contains("fetch response body base64 is 4 bytes"),
300 "unexpected error: {error}"
301 );
302 }
303
304 #[test]
305 fn fetch_buffer_limit_is_fixed_to_default_frame_size() {
306 assert_eq!(
307 VM_FETCH_BUFFER_LIMIT_BYTES,
308 agentos_sidecar_client::wire::DEFAULT_MAX_FRAME_BYTES
309 );
310 }
311
312 use super::{VmFetchResponsePayload, BASE64};
321 use base64::Engine as _;
322
323 #[test]
326 fn vm_fetch_response_overflowing_status_fails_deserialization_without_panic() {
327 let json = r#"{"status":70000}"#;
328 let parsed: Result<VmFetchResponsePayload, _> = serde_json::from_str(json);
329 assert!(
330 parsed.is_err(),
331 "AOSCLIENT-P3-fetch: status 70000 overflows u16 and must fail to deserialize, not panic"
332 );
333 }
334
335 #[test]
338 fn vm_fetch_response_zero_status_is_rejected_by_status_code_without_panic() {
339 let json = r#"{"status":0}"#;
340 let payload: VmFetchResponsePayload =
341 serde_json::from_str(json).expect("status 0 is a valid u16 and should deserialize");
342 let status = http::StatusCode::from_u16(payload.status);
343 assert!(
344 status.is_err(),
345 "AOSCLIENT-P3-fetch: status code 0 must be rejected by StatusCode::from_u16, not panic"
346 );
347 }
348
349 #[test]
351 fn vm_fetch_response_malformed_base64_body_errors_without_panic() {
352 ensure_fetch_base64_body_within_limit("!!!", VM_FETCH_BUFFER_LIMIT_BYTES)
355 .expect("tiny body is within the limit");
356 let decoded = BASE64.decode("!!!".as_bytes());
357 assert!(
358 decoded.is_err(),
359 "AOSCLIENT-P3-fetch: malformed base64 response body \"!!!\" must error on decode, not panic"
360 );
361 }
362
363 #[test]
366 fn vm_fetch_response_over_limit_base64_body_is_rejected_before_decode() {
367 let oversized = "A".repeat(VM_FETCH_BUFFER_LIMIT_BYTES + 4);
369 let result = ensure_fetch_base64_body_within_limit(&oversized, VM_FETCH_BUFFER_LIMIT_BYTES);
370 let error = result.expect_err(
371 "AOSCLIENT-P3-fetch: an over-limit base64 body must be rejected before decode",
372 );
373 assert!(
374 error.to_string().contains("fetch response body base64"),
375 "AOSCLIENT-P3-fetch: over-limit base64 body must be rejected by the size guard, got: {error}"
376 );
377 }
378}