1use std::collections::BTreeMap;
9
10use anyhow::{Context, Result};
11use base64::engine::general_purpose::STANDARD as BASE64;
12use base64::Engine as _;
13use bytes::Bytes;
14use serde::Deserialize;
15
16use agentos_sidecar_client::wire;
17
18use crate::agent_os::AgentOs;
19use crate::error::ClientError;
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
40impl AgentOs {
41 pub async fn fetch(
46 &self,
47 port: u16,
48 request: http::Request<Bytes>,
49 ) -> Result<http::Response<Bytes>> {
50 let buffer_limit = self.fetch_buffer_limit();
51 let (parts, body) = request.into_parts();
52
53 let path = match parts.uri.path_and_query() {
56 Some(pq) => {
57 ensure_fetch_component_within_limit(
58 "fetch request path",
59 pq.as_str().len(),
60 buffer_limit,
61 )?;
62 pq.as_str().to_owned()
63 }
64 None => "/".to_owned(),
65 };
66
67 let method = parts.method.as_str().to_owned();
68
69 let mut header_map: BTreeMap<String, String> = BTreeMap::new();
72 let mut raw_header_bytes = 0usize;
73 for (name, value) in parts.headers.iter() {
74 raw_header_bytes = raw_header_bytes
75 .saturating_add(name.as_str().len())
76 .saturating_add(value.as_bytes().len());
77 header_map.insert(
78 name.as_str().to_owned(),
79 String::from_utf8_lossy(value.as_bytes()).into_owned(),
80 );
81 }
82 ensure_fetch_component_within_limit(
83 "fetch request headers",
84 raw_header_bytes,
85 buffer_limit,
86 )?;
87 let headers_json =
88 serde_json::to_string(&header_map).context("serializing fetch request headers")?;
89 ensure_fetch_component_within_limit(
90 "fetch request headers json",
91 headers_json.len(),
92 buffer_limit,
93 )?;
94
95 let wire_body = if method == "GET" || method == "HEAD" {
97 None
98 } else {
99 ensure_fetch_component_within_limit("fetch request body", body.len(), buffer_limit)?;
100 let body = String::from_utf8_lossy(&body).into_owned();
101 ensure_fetch_component_within_limit(
102 "fetch request body text",
103 body.len(),
104 buffer_limit,
105 )?;
106 Some(body)
107 };
108 ensure_fetch_request_payload_within_limit(
109 &method,
110 &path,
111 &headers_json,
112 wire_body.as_deref(),
113 buffer_limit,
114 )?;
115
116 let response = self
117 .transport()
118 .request_wire_bounded(
119 self.vm_fetch_ownership(),
120 wire::RequestPayload::VmFetchRequest(wire::VmFetchRequest {
121 port,
122 method,
123 path,
124 headers_json,
125 body: wire_body,
126 }),
127 buffer_limit,
128 )
129 .await?;
130
131 let response_json = match response {
132 wire::ResponsePayload::VmFetchResponse(result) => result.response_json,
133 wire::ResponsePayload::RejectedResponse(wire::RejectedResponse { code, message }) => {
134 return Err(ClientError::Kernel { code, message }.into());
135 }
136 other => {
137 return Err(
138 ClientError::Sidecar(format!("fetch: unexpected response {other:?}")).into(),
139 );
140 }
141 };
142 ensure_fetch_component_within_limit(
143 "fetch response json",
144 response_json.len(),
145 buffer_limit,
146 )?;
147
148 let payload: VmFetchResponsePayload =
149 serde_json::from_str(&response_json).context("parsing vm_fetch response json")?;
150
151 let decoded_body = match payload.body {
154 Some(encoded) => {
155 ensure_fetch_base64_body_within_limit(&encoded, buffer_limit)?;
156 Bytes::from(
157 BASE64
158 .decode(encoded.as_bytes())
159 .context("decoding base64 fetch response body")?,
160 )
161 }
162 None => Bytes::new(),
163 };
164
165 let status = http::StatusCode::from_u16(payload.status)
166 .context("fetch: invalid response status code")?;
167
168 let mut builder = http::Response::builder().status(status);
169 for (key, value) in payload.headers.unwrap_or_default() {
170 builder = builder.header(key, value);
171 }
172
173 let mut http_response = builder
174 .body(decoded_body)
175 .context("building fetch response")?;
176
177 if let Some(status_text) = payload.status_text {
180 http_response
181 .extensions_mut()
182 .insert(FetchStatusText(status_text));
183 }
184
185 Ok(http_response)
186 }
187
188 fn vm_fetch_ownership(&self) -> wire::OwnershipScope {
190 wire::OwnershipScope::VmOwnership(wire::VmOwnership {
191 connection_id: self.connection_id().to_string(),
192 session_id: self.wire_session_id().to_string(),
193 vm_id: self.vm_id().to_string(),
194 })
195 }
196
197 fn fetch_buffer_limit(&self) -> usize {
198 self.transport()
199 .max_frame_bytes()
200 .min(VM_FETCH_BUFFER_LIMIT_BYTES)
201 }
202}
203
204#[derive(Debug, Clone)]
207pub struct FetchStatusText(pub String);
208
209fn ensure_fetch_component_within_limit(
210 component: &str,
211 size: usize,
212 limit: usize,
213) -> Result<(), ClientError> {
214 if size > limit {
215 return Err(ClientError::Sidecar(format!(
216 "{component} is {size} bytes, limit is {limit}"
217 )));
218 }
219 Ok(())
220}
221
222fn ensure_fetch_base64_body_within_limit(encoded: &str, limit: usize) -> Result<(), ClientError> {
223 ensure_fetch_component_within_limit("fetch response body base64", encoded.len(), limit)?;
224 ensure_fetch_component_within_limit(
225 "fetch response body",
226 base64_decoded_upper_bound(encoded.len()),
227 limit,
228 )
229}
230
231fn ensure_fetch_request_payload_within_limit(
232 method: &str,
233 path: &str,
234 headers_json: &str,
235 body: Option<&str>,
236 limit: usize,
237) -> Result<(), ClientError> {
238 let size = method
239 .len()
240 .saturating_add(path.len())
241 .saturating_add(headers_json.len())
242 .saturating_add(body.map(str::len).unwrap_or_default());
243 ensure_fetch_component_within_limit("fetch request payload", size, limit)
244}
245
246fn base64_decoded_upper_bound(encoded_len: usize) -> usize {
247 encoded_len.saturating_add(3) / 4 * 3
248}
249
250#[cfg(test)]
251mod tests {
252 use super::{
253 base64_decoded_upper_bound, ensure_fetch_base64_body_within_limit,
254 ensure_fetch_component_within_limit, ensure_fetch_request_payload_within_limit,
255 VM_FETCH_BUFFER_LIMIT_BYTES,
256 };
257
258 #[test]
259 fn fetch_component_limit_rejects_oversized_buffers() {
260 assert!(ensure_fetch_component_within_limit("component", 8, 8).is_ok());
261
262 let error =
263 ensure_fetch_component_within_limit("component", 9, 8).expect_err("limit violation");
264 assert!(
265 error.to_string().contains("component is 9 bytes"),
266 "unexpected error: {error}"
267 );
268 }
269
270 #[test]
271 fn fetch_component_limit_rejects_expanded_request_text() {
272 let replacement = String::from_utf8_lossy(&[0xff]).into_owned();
273 assert_eq!(replacement.len(), 3);
274
275 let error = ensure_fetch_component_within_limit("fetch request body text", 3, 2)
276 .expect_err("expanded body text should exceed limit");
277 assert!(
278 error
279 .to_string()
280 .contains("fetch request body text is 3 bytes"),
281 "unexpected error: {error}"
282 );
283 }
284
285 #[test]
286 fn fetch_request_payload_limit_rejects_aggregate_oversize() {
287 let error =
288 ensure_fetch_request_payload_within_limit("POST", "/abc", "{}", Some("body"), 8)
289 .expect_err("aggregate request payload should exceed limit");
290 assert!(
291 error
292 .to_string()
293 .contains("fetch request payload is 14 bytes"),
294 "unexpected error: {error}"
295 );
296 }
297
298 #[test]
299 fn fetch_base64_guard_bounds_decoded_response_size() {
300 assert_eq!(base64_decoded_upper_bound(4), 3);
301 assert!(ensure_fetch_base64_body_within_limit("AAAA", 4).is_ok());
302
303 let error = ensure_fetch_base64_body_within_limit("AAAA", 2)
304 .expect_err("encoded body should exceed limit");
305 assert!(
306 error
307 .to_string()
308 .contains("fetch response body base64 is 4 bytes"),
309 "unexpected error: {error}"
310 );
311 }
312
313 #[test]
314 fn fetch_buffer_limit_is_fixed_to_default_frame_size() {
315 assert_eq!(
316 VM_FETCH_BUFFER_LIMIT_BYTES,
317 agentos_sidecar_client::wire::DEFAULT_MAX_FRAME_BYTES
318 );
319 }
320
321 use super::{VmFetchResponsePayload, BASE64};
330 use base64::Engine as _;
331
332 #[test]
335 fn vm_fetch_response_overflowing_status_fails_deserialization_without_panic() {
336 let json = r#"{"status":70000}"#;
337 let parsed: Result<VmFetchResponsePayload, _> = serde_json::from_str(json);
338 assert!(
339 parsed.is_err(),
340 "AOSCLIENT-P3-fetch: status 70000 overflows u16 and must fail to deserialize, not panic"
341 );
342 }
343
344 #[test]
347 fn vm_fetch_response_zero_status_is_rejected_by_status_code_without_panic() {
348 let json = r#"{"status":0}"#;
349 let payload: VmFetchResponsePayload =
350 serde_json::from_str(json).expect("status 0 is a valid u16 and should deserialize");
351 let status = http::StatusCode::from_u16(payload.status);
352 assert!(
353 status.is_err(),
354 "AOSCLIENT-P3-fetch: status code 0 must be rejected by StatusCode::from_u16, not panic"
355 );
356 }
357
358 #[test]
360 fn vm_fetch_response_malformed_base64_body_errors_without_panic() {
361 ensure_fetch_base64_body_within_limit("!!!", VM_FETCH_BUFFER_LIMIT_BYTES)
364 .expect("tiny body is within the limit");
365 let decoded = BASE64.decode("!!!".as_bytes());
366 assert!(
367 decoded.is_err(),
368 "AOSCLIENT-P3-fetch: malformed base64 response body \"!!!\" must error on decode, not panic"
369 );
370 }
371
372 #[test]
375 fn vm_fetch_response_over_limit_base64_body_is_rejected_before_decode() {
376 let oversized = "A".repeat(VM_FETCH_BUFFER_LIMIT_BYTES + 4);
378 let result = ensure_fetch_base64_body_within_limit(&oversized, VM_FETCH_BUFFER_LIMIT_BYTES);
379 let error = result.expect_err(
380 "AOSCLIENT-P3-fetch: an over-limit base64 body must be rejected before decode",
381 );
382 assert!(
383 error.to_string().contains("fetch response body base64"),
384 "AOSCLIENT-P3-fetch: over-limit base64 body must be rejected by the size guard, got: {error}"
385 );
386 }
387}