contextgraph_host/http.rs
1//! Streamable-HTTP transport: a remote Context Graph Protocol provider reached by POSTing the
2//! envelope to its URL (`SPEC.md` §3 "remote providers:
3//! streamable HTTP"). The reference host uses request/response JSON — the
4//! [`Envelope`] as the POST body, one [`Envelope`] back as the response body
5//! — which any streamable-HTTP server satisfies; chunked frame streaming is
6//! a documented forward extension, not needed for the v1 shape.
7//!
8//! Unlike stdio's process isolation, an HTTP provider is remote by nature, so
9//! its `egress` posture is decided by the URL host and gated through the same
10//! [`crate::consent`] store at the [`crate::host::Host`] layer.
11
12use std::fmt;
13use std::net::IpAddr;
14use std::time::Duration;
15
16use async_trait::async_trait;
17use contextgraph_types::{
18 Capabilities, ContextQuery, ContextQueryResult, PROTOCOL_VERSION, ProviderInfo, VerifyRequest,
19 VerifyResponse,
20};
21
22use crate::error::HostError;
23use crate::provider::ContextProvider;
24use crate::wire::{
25 Envelope, envelope_kind, next_correlation_id, verify_correlation, versions_compatible,
26};
27
28/// Total per-request budget for an HTTP exchange (handshake or query).
29const HTTP_TIMEOUT: Duration = Duration::from_secs(30);
30
31/// Largest response body the host will buffer from a remote provider, matching
32/// the stdio transport's `MAX_LINE_BYTES`. See [`read_bounded_body`] for why the
33/// remote transport needs the bound at least as much as the local one does.
34const MAX_RESPONSE_BYTES: usize = 16 * 1024 * 1024;
35
36/// A bearer credential a host uses to authenticate to a remote provider.
37///
38/// The secret is **never** rendered: both [`Debug`](fmt::Debug) and
39/// [`Display`](fmt::Display) print the fixed placeholder `Credential(<redacted>)`,
40/// so a credential that reaches a log line, an `{:?}`/`{}` interpolation, or a
41/// panic payload cannot spill its bytes (`SPEC.md` §4.2, **C8**). The only way
42/// to read the raw value is [`Credential::expose`], a crate-private method used
43/// solely to attach the header on the wire — a leak is therefore greppable.
44#[derive(Clone)]
45pub struct Credential {
46 /// The bearer token / `Authorization` value. Deliberately unexposed to any
47 /// formatting impl.
48 token: String,
49}
50
51impl Credential {
52 /// Wrap a bearer token. It is attached as `Authorization: Bearer <token>`
53 /// on every request this provider sends and is never logged (C8).
54 pub fn bearer(token: impl Into<String>) -> Self {
55 Self {
56 token: token.into(),
57 }
58 }
59
60 /// The raw secret — the single, greppable exit point, used only to set the
61 /// `Authorization` header on the wire.
62 fn expose(&self) -> &str {
63 &self.token
64 }
65}
66
67/// C8: a credential in a `{:?}` rendering (a log line, a panic payload) prints a
68/// fixed placeholder, never its bytes.
69impl fmt::Debug for Credential {
70 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
71 f.write_str("Credential(<redacted>)")
72 }
73}
74
75/// C8: a credential in a `{}` rendering prints the same fixed placeholder — so
76/// even an accidental `Display` interpolation cannot leak the secret.
77impl fmt::Display for Credential {
78 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
79 f.write_str("Credential(<redacted>)")
80 }
81}
82
83/// Whether a URL host component names the loopback interface — the one case an
84/// unencrypted (`http://`) transport is allowed, because the bytes never leave
85/// the machine (`SPEC.md` §4.2, **C7**). Mirrors the `localhost` exception
86/// [`verify::file_uri_to_path`](crate::verify) makes for `file://`, widened to
87/// the loopback IP ranges: the literal name `localhost`, `127.0.0.0/8`, and
88/// `::1`. IPv6 hosts arrive bracketed (`[::1]`) from a URL, so the brackets are
89/// stripped before parsing.
90fn is_loopback_host(host: &str) -> bool {
91 if host.eq_ignore_ascii_case("localhost") {
92 return true;
93 }
94 let bare = host
95 .strip_prefix('[')
96 .and_then(|inner| inner.strip_suffix(']'))
97 .unwrap_or(host);
98 // `IpAddr::is_loopback` is exactly `127.0.0.0/8` for v4 and `::1` for v6.
99 matches!(bare.parse::<IpAddr>(), Ok(ip) if ip.is_loopback())
100}
101
102/// Refuse a plaintext transport to a non-loopback provider **before** any bytes
103/// leave the host (`SPEC.md` §4.2, **C7**): an `http://` (not `https://`) URL
104/// whose host is not loopback would carry the query payload — and any bearer
105/// credential — across the network in cleartext. A loopback `http://` target is
106/// allowed (the bytes never leave the machine); every `https://` target is
107/// allowed. Called before the client is built or DNS is resolved, so a refusal
108/// short-circuits with zero network activity.
109///
110/// # Why this is public
111///
112/// [`Host::add_http`](crate::Host::add_http) already calls it, so C7 holds
113/// whether or not a caller does. It is exported for the case a host wants to
114/// classify a URL *before* attempting the connection — typically to report a
115/// plaintext endpoint as the configuration error it is, rather than as a
116/// connection failure or a non-conformant provider.
117///
118/// The alternative is that every host re-derives "which hosts are loopback"
119/// locally, and C7 ends up with one implementation per host, free to disagree
120/// about `[::1]`, `127.0.0.2`, or the casing of `LOCALHOST`. A normative rule
121/// with N implementations is N rules. This is the one.
122///
123/// ```no_run
124/// use contextgraph_host::{HostError, refuse_insecure_transport};
125///
126/// // Plaintext to a remote peer: refused, with the peer named.
127/// let refusal = refuse_insecure_transport("acme", "http://cgp.example.com/q");
128/// assert!(matches!(refusal, Err(HostError::InsecureTransport { .. })));
129///
130/// // Loopback plaintext and TLS are both fine.
131/// assert!(refuse_insecure_transport("local", "http://127.0.0.1:8080/q").is_ok());
132/// assert!(refuse_insecure_transport("acme", "https://cgp.example.com/q").is_ok());
133/// ```
134pub fn refuse_insecure_transport(id: &str, url: &str) -> Result<(), HostError> {
135 let parsed = reqwest::Url::parse(url).map_err(|e| HostError::Transport {
136 id: id.to_string(),
137 message: format!("invalid provider url: {e}"),
138 })?;
139 if parsed.scheme() == "http" {
140 let host = parsed.host_str().unwrap_or("");
141 if !is_loopback_host(host) {
142 return Err(HostError::InsecureTransport {
143 id: id.to_string(),
144 host: host.to_string(),
145 });
146 }
147 }
148 Ok(())
149}
150
151/// A [`ContextProvider`] backed by a remote HTTP endpoint. Handshakes once on
152/// [`HttpProvider::connect`] and caches the negotiated identity + capabilities.
153pub struct HttpProvider {
154 id: String,
155 url: String,
156 client: reqwest::Client,
157 info: ProviderInfo,
158 capabilities: Capabilities,
159 /// Bearer credential attached to every request, if the provider requires
160 /// one. Redacted from every rendering (C8).
161 credential: Option<Credential>,
162}
163
164impl HttpProvider {
165 /// Connect to a remote provider with no credential — a thin back-compat
166 /// wrapper over [`connect_with_auth`](Self::connect_with_auth). POST a
167 /// `handshake`, expect a compatible `handshake_ack`, and cache its identity
168 /// + capabilities. `id` is the host-facing routing/consent key.
169 pub async fn connect(id: impl Into<String>, url: impl Into<String>) -> Result<Self, HostError> {
170 Self::connect_with_auth(id, url, None).await
171 }
172
173 /// Connect to a remote provider, optionally attaching a bearer
174 /// [`Credential`] to every request. Enforces transport security before any
175 /// bytes leave the host: a plaintext (`http://`) transport to a non-loopback
176 /// provider is refused with [`HostError::InsecureTransport`], so neither the
177 /// handshake nor a credential ever crosses the network in cleartext
178 /// (`SPEC.md` §4.2, **C7**).
179 pub async fn connect_with_auth(
180 id: impl Into<String>,
181 url: impl Into<String>,
182 credential: Option<Credential>,
183 ) -> Result<Self, HostError> {
184 let id = id.into();
185 let url = url.into();
186 // C7 first, before the client is built or DNS is resolved: a refusal
187 // must short-circuit with zero network activity so no payload leaks.
188 refuse_insecure_transport(&id, &url)?;
189 let client = reqwest::Client::builder()
190 .timeout(HTTP_TIMEOUT)
191 // Follow no redirects (C2/C4/C7). `refuse_insecure_transport` above
192 // classifies the *configured* URL, once, before any bytes move — but
193 // a redirect is chosen by the peer afterwards, so a client that
194 // followed one would let a provider route the query payload to a
195 // destination the pre-flight check structurally cannot see. A 307 or
196 // 308 preserves the method and the body, so the whole payload — goal,
197 // query text, anchors, embedding — would be re-POSTed to a `Location`
198 // of the provider's choosing: plaintext (C7), a different non-loopback
199 // peer than the one classified as egress (C4), and one named by no
200 // consent receipt (C2). A CGP endpoint is a single POST target; a 3xx
201 // from one is a misconfiguration to surface, not a route to take.
202 .redirect(reqwest::redirect::Policy::none())
203 .build()
204 .map_err(|e| HostError::Transport {
205 id: id.clone(),
206 message: format!("building HTTP client: {e}"),
207 })?;
208
209 let ack = post_envelope(
210 &client,
211 &url,
212 &Envelope::Handshake {
213 protocol_version: PROTOCOL_VERSION.to_string(),
214 },
215 &id,
216 credential.as_ref(),
217 )
218 .await?;
219
220 match ack {
221 Envelope::HandshakeAck {
222 protocol_version,
223 provider,
224 capabilities,
225 ..
226 } => {
227 if !versions_compatible(PROTOCOL_VERSION, &protocol_version) {
228 return Err(HostError::VersionMismatch {
229 host: PROTOCOL_VERSION.to_string(),
230 provider: provider.name,
231 provider_version: protocol_version,
232 });
233 }
234 // An HTTP transport is egress by definition: every query is
235 // POSTed off-box to a remote URL. So the consent gate must key
236 // off transport, not the remote's self-report — a remote that
237 // handshakes `egress:false` would otherwise be queried with no
238 // consent. Force it on here regardless of what it declared.
239 // (The stdio path keeps its declared posture; a local child
240 // that doesn't reach the network genuinely may not be egress.)
241 let mut info = provider;
242 info.data_flow.egress = true;
243 Ok(Self {
244 id,
245 url,
246 client,
247 info,
248 capabilities,
249 credential,
250 })
251 }
252 other => Err(HostError::UnexpectedEnvelope {
253 id,
254 expected: "handshake_ack".into(),
255 got: envelope_kind(&other).into(),
256 }),
257 }
258 }
259}
260
261/// POST one envelope to the provider URL and decode the response as one
262/// envelope. A non-2xx status or a non-envelope body is a clean named error,
263/// never a panic (task deliverable 5).
264///
265/// When `credential` is present it is attached as `Authorization: Bearer …` via
266/// reqwest's [`bearer_auth`](reqwest::RequestBuilder::bearer_auth) — never a
267/// format string that could leak the secret into a log (C8).
268async fn post_envelope(
269 client: &reqwest::Client,
270 url: &str,
271 env: &Envelope,
272 id: &str,
273 credential: Option<&Credential>,
274) -> Result<Envelope, HostError> {
275 let mut request = client.post(url).json(env);
276 if let Some(credential) = credential {
277 request = request.bearer_auth(credential.expose());
278 }
279 let response = request.send().await.map_err(|e| HostError::Transport {
280 id: id.to_string(),
281 message: e.to_string(),
282 })?;
283
284 // A rejected credential is its own named error, distinct from any other
285 // transport failure — and it names only the id + status, never the
286 // credential (C8).
287 if response.status() == reqwest::StatusCode::UNAUTHORIZED {
288 return Err(HostError::Unauthorized { id: id.to_string() });
289 }
290
291 // A redirect reaches here as a status rather than a followed hop, because
292 // the client is built with `Policy::none()`. Name it for what it is: an
293 // endpoint that answers a CGP POST with "go somewhere else" is misconfigured
294 // (or hostile), and reporting it as a generic transport failure would send an
295 // operator hunting a network fault instead of reading the `Location`.
296 if response.status().is_redirection() {
297 let status = response.status();
298 let location = response
299 .headers()
300 .get(reqwest::header::LOCATION)
301 .and_then(|value| value.to_str().ok())
302 .map(truncate_for_error)
303 .unwrap_or_else(|| "<none>".to_string());
304 return Err(HostError::Transport {
305 id: id.to_string(),
306 message: format!(
307 "provider answered with HTTP {status} to {location}; redirects are not followed \
308 (SPEC.md §4.2 C2/C4/C7 — a redirect would move the query payload to a peer the \
309 transport never classified and consent never named). Configure the final URL."
310 ),
311 });
312 }
313
314 if !response.status().is_success() {
315 let status = response.status();
316 let body = read_bounded_body(response, id).await.unwrap_or_default();
317 return Err(HostError::Transport {
318 id: id.to_string(),
319 message: format!(
320 "HTTP {status}: {}",
321 truncate_for_error(&String::from_utf8_lossy(&body))
322 ),
323 });
324 }
325
326 let body = read_bounded_body(response, id).await?;
327 serde_json::from_slice::<Envelope>(&body).map_err(|e| {
328 HostError::Wire(format!(
329 "provider {id} returned a non-envelope HTTP body: {e}"
330 ))
331 })
332}
333
334/// Read a response body into memory, refusing one that exceeds
335/// [`MAX_RESPONSE_BYTES`].
336///
337/// The stdio transport has bounded a provider's output since it shipped
338/// ([`MAX_LINE_BYTES`](crate::stdio)); the HTTP transport did not, which had the
339/// hardening exactly backwards. A stdio provider is a child process the host
340/// spawned from a path the user configured; an HTTP provider is a remote peer
341/// across a network the host does not control — the one §4.2 already treats as
342/// adversarial. `Response::json` and `Response::text` both buffer the whole body
343/// with no cap, so a hostile or failing peer could stream until the host died.
344/// `HTTP_TIMEOUT` bounds the *duration* of that stream, not its size, and a fast
345/// link moves a great deal in 30 seconds.
346///
347/// The limit is enforced incrementally over the chunk stream, so an oversized
348/// body is abandoned as soon as it crosses the line rather than after it has
349/// already been allocated.
350async fn read_bounded_body(response: reqwest::Response, id: &str) -> Result<Vec<u8>, HostError> {
351 // Refuse on the advertised length first, when there is one: it costs nothing
352 // and avoids reading a single chunk of a body already declared too large.
353 if let Some(len) = response.content_length()
354 && len > MAX_RESPONSE_BYTES as u64
355 {
356 return Err(HostError::Wire(format!(
357 "provider {id} advertised a {len}-byte response, over the {MAX_RESPONSE_BYTES}-byte limit"
358 )));
359 }
360 let mut response = response;
361 let mut body = Vec::new();
362 while let Some(chunk) = response.chunk().await.map_err(|e| HostError::Transport {
363 id: id.to_string(),
364 message: e.to_string(),
365 })? {
366 if body.len() + chunk.len() > MAX_RESPONSE_BYTES {
367 return Err(HostError::Wire(format!(
368 "provider {id} exceeded the {MAX_RESPONSE_BYTES}-byte response limit"
369 )));
370 }
371 body.extend_from_slice(&chunk);
372 }
373 Ok(body)
374}
375
376/// Clamp provider-controlled text before it is interpolated into a host error.
377///
378/// An error body is written by the peer, and a `HostError` string is logged,
379/// surfaced, and sometimes shipped to an operator's aggregator. Embedding an
380/// unbounded remote string in one turns a failing provider into a log-flooding
381/// primitive.
382fn truncate_for_error(text: &str) -> String {
383 const LIMIT: usize = 512;
384 if text.len() <= LIMIT {
385 return text.to_string();
386 }
387 // Clamp to a char boundary so the truncation cannot split a UTF-8 sequence.
388 let mut end = LIMIT;
389 while end > 0 && !text.is_char_boundary(end) {
390 end -= 1;
391 }
392 format!("{}… ({} bytes truncated)", &text[..end], text.len() - end)
393}
394
395#[async_trait]
396impl ContextProvider for HttpProvider {
397 fn id(&self) -> &str {
398 &self.id
399 }
400
401 fn info(&self) -> &ProviderInfo {
402 &self.info
403 }
404
405 fn capabilities(&self) -> &Capabilities {
406 &self.capabilities
407 }
408
409 async fn query(&self, query: &ContextQuery) -> Result<ContextQueryResult, HostError> {
410 let sent_id = self.capabilities.correlation.then(next_correlation_id);
411 let reply = post_envelope(
412 &self.client,
413 &self.url,
414 &Envelope::Query {
415 id: sent_id.clone(),
416 query: query.clone(),
417 },
418 &self.id,
419 self.credential.as_ref(),
420 )
421 .await?;
422 match reply {
423 Envelope::Frames {
424 id: echoed, result, ..
425 } => {
426 verify_correlation(&self.id, sent_id.as_deref(), echoed.as_deref())?;
427 Ok(result)
428 }
429 Envelope::Error { message, code, .. } => Err(HostError::Provider {
430 id: self.id.clone(),
431 code,
432 message,
433 }),
434 other => Err(HostError::UnexpectedEnvelope {
435 id: self.id.clone(),
436 expected: "frames".into(),
437 got: envelope_kind(&other).into(),
438 }),
439 }
440 }
441
442 async fn verify(&self, request: &VerifyRequest) -> Result<VerifyResponse, HostError> {
443 let reply = post_envelope(
444 &self.client,
445 &self.url,
446 &Envelope::Verify {
447 request: request.clone(),
448 },
449 &self.id,
450 self.credential.as_ref(),
451 )
452 .await?;
453 match reply {
454 Envelope::Verified { response } => Ok(response),
455 Envelope::Error { message, code, .. } => Err(HostError::Provider {
456 id: self.id.clone(),
457 code,
458 message,
459 }),
460 other => Err(HostError::UnexpectedEnvelope {
461 id: self.id.clone(),
462 expected: "verified".into(),
463 got: envelope_kind(&other).into(),
464 }),
465 }
466 }
467
468 async fn shutdown(&self) -> Result<(), HostError> {
469 // Best-effort teardown notice; a remote endpoint is not ours to reap.
470 let _ = post_envelope(
471 &self.client,
472 &self.url,
473 &Envelope::Shutdown,
474 &self.id,
475 self.credential.as_ref(),
476 )
477 .await;
478 Ok(())
479 }
480}
481
482#[cfg(test)]
483mod tests {
484 use super::*;
485 use contextgraph_types::capability::QueryCapability;
486 use contextgraph_types::{ContextFrame, DataFlow, FrameKind};
487 use wiremock::matchers::{header, method};
488 use wiremock::{Mock, MockServer, ResponseTemplate};
489
490 fn ack_body(version: &str) -> serde_json::Value {
491 serde_json::to_value(Envelope::HandshakeAck {
492 protocol_version: version.to_string(),
493 provider: ProviderInfo {
494 name: "remote-docs".into(),
495 version: "0.1.0".into(),
496 data_flow: DataFlow {
497 reads: true,
498 writes: false,
499 egress: true,
500 egress_scopes: vec![],
501 },
502 },
503 capabilities: Capabilities {
504 query: QueryCapability {
505 kinds: vec!["doc".into()],
506 },
507 ..Capabilities::default()
508 },
509 attester_keys: vec![],
510 })
511 .unwrap()
512 }
513
514 fn frames_body() -> serde_json::Value {
515 serde_json::to_value(Envelope::Frames {
516 id: None,
517 result: ContextQueryResult {
518 frames: vec![ContextFrame {
519 id: "frm_h".into(),
520 kind: FrameKind::Doc,
521 title: "remote doc".into(),
522 content: Some("remote content".into()),
523 content_digest: None,
524 uri: Some("https://example.test/doc".into()),
525 representation: Default::default(),
526 content_fidelity: None,
527 canonical_content_hash: None,
528 content_ref: None,
529 transform: None,
530 minimum_content_fidelity: None,
531 inline_content_requirement: None,
532 score: 0.6,
533 token_cost: 20,
534 canonical_token_cost: None,
535 tokenizer_ref: None,
536 valid_from: None,
537 valid_to: None,
538 recorded_at: None,
539 provenance: vec![],
540 citation_label: Some("remote doc".into()),
541 embedding: None,
542 relations: vec![],
543 }],
544 truncated: false,
545 dropped_estimate: None,
546 ..Default::default()
547 },
548 })
549 .unwrap()
550 }
551
552 fn sample_query() -> ContextQuery {
553 ContextQuery {
554 goal: "g".into(),
555 query_text: None,
556 embedding: None,
557 kinds: vec![],
558 anchors: vec![],
559 max_frames: 5,
560 max_tokens: 4000,
561 as_of: None,
562 representation_preferences: vec![],
563 }
564 }
565
566 #[tokio::test]
567 async fn http_handshake_then_query_round_trips_via_wiremock() {
568 let server = MockServer::start().await;
569 // Both the handshake and the query POST to the same URL; the responder
570 // dispatches on the request envelope's `type`.
571 Mock::given(method("POST"))
572 .respond_with(|req: &wiremock::Request| {
573 let body = match serde_json::from_slice::<Envelope>(&req.body) {
574 Ok(Envelope::Handshake { .. }) => ack_body(PROTOCOL_VERSION),
575 Ok(Envelope::Query { .. }) => frames_body(),
576 _ => serde_json::to_value(Envelope::Error {
577 id: None,
578 code: None,
579 message: "unexpected request".into(),
580 })
581 .unwrap(),
582 };
583 ResponseTemplate::new(200).set_body_json(body)
584 })
585 .mount(&server)
586 .await;
587
588 let provider = HttpProvider::connect("remote", server.uri())
589 .await
590 .expect("handshake ok");
591 assert_eq!(provider.info().name, "remote-docs");
592 assert!(provider.info().data_flow.egress);
593
594 let result = provider.query(&sample_query()).await.expect("query ok");
595 assert_eq!(result.frames.len(), 1);
596 assert_eq!(result.frames[0].title, "remote doc");
597 }
598
599 #[tokio::test]
600 async fn http_version_mismatch_rejects_the_provider() {
601 let server = MockServer::start().await;
602 Mock::given(method("POST"))
603 .respond_with(ResponseTemplate::new(200).set_body_json(ack_body("contextgraph/2.0")))
604 .mount(&server)
605 .await;
606
607 let err = match HttpProvider::connect("remote", server.uri()).await {
608 Ok(_) => panic!("incompatible version must reject"),
609 Err(e) => e,
610 };
611 assert!(matches!(err, HostError::VersionMismatch { .. }));
612 }
613
614 #[tokio::test]
615 async fn a_non_envelope_http_body_is_a_clean_wire_error() {
616 let server = MockServer::start().await;
617 Mock::given(method("POST"))
618 .respond_with(
619 ResponseTemplate::new(200).set_body_string("<html>not contextgraph</html>"),
620 )
621 .mount(&server)
622 .await;
623
624 let err = match HttpProvider::connect("remote", server.uri()).await {
625 Ok(_) => panic!("garbage body must not panic the host"),
626 Err(e) => e,
627 };
628 assert!(matches!(err, HostError::Wire(_)));
629 }
630
631 #[tokio::test]
632 async fn http_transport_forces_egress_even_when_the_remote_claims_local() {
633 // A remote self-declares `egress:false` in its handshake. Using an HTTP
634 // transport IS egress (the query is POSTed off-box), so the host must
635 // override the claim and still gate consent — otherwise a remote could
636 // opt itself out of the consent gate by lying.
637 let server = MockServer::start().await;
638 let sneaky_ack = serde_json::to_value(Envelope::HandshakeAck {
639 protocol_version: PROTOCOL_VERSION.to_string(),
640 provider: ProviderInfo {
641 name: "sneaky-remote".into(),
642 version: "0.1.0".into(),
643 data_flow: DataFlow {
644 reads: true,
645 writes: false,
646 egress: false, // the lie the host must not trust
647 egress_scopes: vec![],
648 },
649 },
650 capabilities: Capabilities {
651 query: QueryCapability {
652 kinds: vec!["doc".into()],
653 },
654 ..Capabilities::default()
655 },
656 attester_keys: vec![],
657 })
658 .unwrap();
659 Mock::given(method("POST"))
660 .respond_with(ResponseTemplate::new(200).set_body_json(sneaky_ack))
661 .mount(&server)
662 .await;
663
664 let provider = HttpProvider::connect("remote", server.uri())
665 .await
666 .expect("handshake ok");
667
668 assert!(
669 provider.info().data_flow.egress,
670 "an HTTP transport must be treated as egress regardless of the remote's claim"
671 );
672 assert!(
673 crate::consent::ConsentStore::requires_consent(provider.info()),
674 "an HTTP provider must always require consent, even claiming egress:false"
675 );
676 }
677
678 // ---- transport security (§4.2, C7/C8) ----
679
680 #[tokio::test]
681 async fn a_plaintext_non_loopback_transport_is_refused_before_any_bytes_leave() {
682 // C7: an `http://` (not `https://`) URL whose host is not loopback is
683 // refused BEFORE a client is built or DNS is resolved — the query
684 // payload and any credential must never cross the network in cleartext.
685 // The proof it short-circuits is the error *kind*: a real network
686 // attempt to this host would surface as a `Transport` (connect) error,
687 // never `InsecureTransport`.
688 let err = match HttpProvider::connect("remote", "http://example.com:9/cgp").await {
689 Ok(_) => panic!("a plaintext non-loopback transport must be refused (C7)"),
690 Err(e) => e,
691 };
692 match err {
693 HostError::InsecureTransport { id, host } => {
694 assert_eq!(id, "remote");
695 assert_eq!(host, "example.com");
696 }
697 other => panic!("expected InsecureTransport, got {other:?}"),
698 }
699 }
700
701 /// The C7 rule is now public API ([`refuse_insecure_transport`]) so a host can
702 /// classify a URL without re-deriving "which hosts are loopback" locally. That
703 /// makes these edge cases part of the exported contract rather than an
704 /// internal detail, so they are pinned directly instead of only through
705 /// `connect`: they are exactly the cases an independent reimplementation gets
706 /// wrong, and the reason the rule is exported at all.
707 #[test]
708 fn the_exported_c7_rule_classifies_every_loopback_spelling() {
709 // Allowed: TLS anywhere, and plaintext to loopback in each of its
710 // spellings — the literal name (any casing), all of `127.0.0.0/8` rather
711 // than just `127.0.0.1`, and bracketed IPv6 `::1`.
712 for allowed in [
713 "https://example.com/cgp",
714 "http://localhost:8080/cgp",
715 "http://LOCALHOST:8080/cgp",
716 "http://127.0.0.1/cgp",
717 "http://127.0.0.2/cgp",
718 "http://[::1]:8080/cgp",
719 ] {
720 assert!(
721 refuse_insecure_transport("p", allowed).is_ok(),
722 "C7 must allow {allowed}"
723 );
724 }
725
726 // Refused: plaintext to anything off-machine. `127.0.0.1.example.com` is
727 // the prefix-matching trap — it *starts with* a loopback IP and is a
728 // remote DNS name.
729 for refused in [
730 "http://example.com/cgp",
731 "http://127.0.0.1.example.com/cgp",
732 "http://[2001:db8::1]/cgp",
733 "http://10.0.0.5/cgp",
734 ] {
735 assert!(
736 matches!(
737 refuse_insecure_transport("p", refused),
738 Err(HostError::InsecureTransport { .. })
739 ),
740 "C7 must refuse {refused}"
741 );
742 }
743
744 // An unparseable URL is a config error, not a security verdict: reporting
745 // it as `InsecureTransport` would tell an operator to add TLS to a string
746 // that is not a URL at all.
747 assert!(matches!(
748 refuse_insecure_transport("p", "not a url"),
749 Err(HostError::Transport { .. })
750 ));
751 }
752
753 #[tokio::test]
754 async fn a_plaintext_loopback_transport_is_allowed() {
755 // The C7 loopback exception: wiremock serves plain `http://` on
756 // `127.0.0.1`, and the host must NOT refuse it — the bytes never leave
757 // the machine. This is also what keeps every other wiremock test in this
758 // module (all on 127.0.0.1) working.
759 let server = MockServer::start().await;
760 assert!(
761 server.uri().starts_with("http://"),
762 "wiremock serves plaintext http on loopback"
763 );
764 Mock::given(method("POST"))
765 .respond_with(ResponseTemplate::new(200).set_body_json(ack_body(PROTOCOL_VERSION)))
766 .mount(&server)
767 .await;
768 let provider = HttpProvider::connect("remote", server.uri())
769 .await
770 .expect("a plaintext loopback (127.0.0.1) transport is allowed");
771 assert_eq!(provider.info().name, "remote-docs");
772 }
773
774 #[tokio::test]
775 async fn a_supplied_credential_is_attached_as_a_bearer_header() {
776 const TOKEN: &str = "s3cr3t-bearer-token-value";
777 let server = MockServer::start().await;
778 let auth_value = format!("Bearer {TOKEN}");
779 // The mock only matches when the `Authorization` header is present and
780 // exact. If the header were missing (or mangled), no mock matches,
781 // wiremock 404s, and the handshake/query below fail — so a green test
782 // proves the bearer credential was attached on the wire.
783 Mock::given(method("POST"))
784 .and(header("authorization", auth_value.as_str()))
785 .respond_with(|req: &wiremock::Request| {
786 let body = match serde_json::from_slice::<Envelope>(&req.body) {
787 Ok(Envelope::Handshake { .. }) => ack_body(PROTOCOL_VERSION),
788 Ok(Envelope::Query { .. }) => frames_body(),
789 _ => serde_json::to_value(Envelope::Error {
790 id: None,
791 code: None,
792 message: "unexpected request".into(),
793 })
794 .unwrap(),
795 };
796 ResponseTemplate::new(200).set_body_json(body)
797 })
798 .mount(&server)
799 .await;
800
801 let provider = HttpProvider::connect_with_auth(
802 "remote",
803 server.uri(),
804 Some(Credential::bearer(TOKEN)),
805 )
806 .await
807 .expect("handshake carries the bearer credential");
808 // The query carries it too — the same header matcher gates its response.
809 let result = provider.query(&sample_query()).await.expect("query ok");
810 assert_eq!(result.frames.len(), 1);
811 }
812
813 #[test]
814 fn a_credential_is_redacted_in_every_rendering_and_never_in_an_error() {
815 // C8: the secret must not appear in any `{:?}`/`{}` rendering — a
816 // credential that reaches a log line or a panic payload prints a fixed
817 // placeholder, not its bytes.
818 const SECRET: &str = "ghp_this_must_never_appear_in_a_log_0xDEADBEEF";
819 let credential = Credential::bearer(SECRET);
820
821 let debug = format!("{credential:?}");
822 let display = format!("{credential}");
823 assert_eq!(debug, "Credential(<redacted>)");
824 assert_eq!(display, "Credential(<redacted>)");
825 assert!(
826 !debug.contains(SECRET),
827 "Debug must not leak the secret (C8)"
828 );
829 assert!(
830 !display.contains(SECRET),
831 "Display must not leak the secret (C8)"
832 );
833 // Cloning preserves redaction — a duplicated credential still can't leak.
834 assert_eq!(
835 format!("{:?}", credential.clone()),
836 "Credential(<redacted>)"
837 );
838
839 // No `HostError` carries credential material: the auth-related variants
840 // render only id/host/status, so a secret can never reach a surfaced
841 // error string (C8).
842 let insecure = HostError::InsecureTransport {
843 id: "remote".into(),
844 host: "example.com".into(),
845 };
846 let unauthorized = HostError::Unauthorized {
847 id: "remote".into(),
848 };
849 assert!(!insecure.to_string().contains(SECRET));
850 assert!(!unauthorized.to_string().contains(SECRET));
851 }
852
853 /// **C2/C4/C7 — a provider must not be able to redirect the query payload
854 /// to a destination the host never vetted.**
855 ///
856 /// [`refuse_insecure_transport`] classifies the *configured* URL, once,
857 /// before the client is built. A redirect is decided by the peer afterwards,
858 /// so an HTTP client that follows one hands the provider a way to move the
859 /// bytes somewhere the pre-flight check structurally cannot see: a 307/308
860 /// preserves the method and the body, so the whole `query` payload — goal,
861 /// query text, anchors, embedding: workspace content — is re-POSTed to a
862 /// `Location` of the provider's choosing.
863 ///
864 /// That defeats three rules at once. **C7**, because the new target may be
865 /// plaintext `http://` on a remote host. **C4**, because it is a different
866 /// non-loopback peer than the one the transport classified as egress.
867 /// **C2**, because the consent receipt the host checked names the configured
868 /// provider, not wherever it was redirected to — the payload reaches a
869 /// destination with no recorded consent at all.
870 ///
871 /// The fix is to follow no redirects: a CGP endpoint is a single POST
872 /// target, and a 3xx from one is a misconfiguration to surface, never a
873 /// route to take.
874 #[tokio::test]
875 async fn a_provider_cannot_redirect_the_query_payload_to_an_unvetted_host() {
876 // The exfiltration endpoint: a peer the host never configured, never
877 // classified, and never took consent for.
878 let attacker = MockServer::start().await;
879 Mock::given(method("POST"))
880 .respond_with(ResponseTemplate::new(200).set_body_json(frames_body()))
881 .mount(&attacker)
882 .await;
883
884 // The configured provider: handshakes honestly, then redirects the
885 // query. A 307 preserves both the POST method and the request body.
886 let provider_server = MockServer::start().await;
887 let attacker_uri = attacker.uri();
888 Mock::given(method("POST"))
889 .respond_with(move |req: &wiremock::Request| {
890 match serde_json::from_slice::<Envelope>(&req.body) {
891 Ok(Envelope::Handshake { .. }) => {
892 ResponseTemplate::new(200).set_body_json(ack_body(PROTOCOL_VERSION))
893 }
894 _ => {
895 ResponseTemplate::new(307).insert_header("location", attacker_uri.as_str())
896 }
897 }
898 })
899 .mount(&provider_server)
900 .await;
901
902 let provider = HttpProvider::connect("remote", provider_server.uri())
903 .await
904 .expect("handshake ok");
905
906 // Whether the query succeeds or errors is beside the point. What must
907 // never happen is the payload arriving at the attacker.
908 let _ = provider.query(&sample_query()).await;
909
910 let leaked = attacker.received_requests().await.unwrap_or_default();
911 assert!(
912 leaked.is_empty(),
913 "the query payload was re-POSTed to an unvetted host via a provider-chosen \
914 redirect: {} request(s) reached it, body: {}",
915 leaked.len(),
916 String::from_utf8_lossy(&leaked[0].body)
917 );
918 }
919
920 /// A refused redirect is reported as the misconfiguration it is, naming the
921 /// `Location` — not as a generic transport fault, which would send an
922 /// operator hunting a network problem that isn't there.
923 #[tokio::test]
924 async fn a_refused_redirect_names_itself_and_its_target() {
925 let server = MockServer::start().await;
926 Mock::given(method("POST"))
927 .respond_with(
928 ResponseTemplate::new(308).insert_header("location", "https://elsewhere.test/q"),
929 )
930 .mount(&server)
931 .await;
932
933 // `HttpProvider` deliberately has no `Debug` (it holds a credential), so
934 // the error is unwrapped by match rather than `expect_err`.
935 let Err(error) = HttpProvider::connect("remote", server.uri()).await else {
936 panic!("a redirect is not a handshake");
937 };
938 let rendered = error.to_string();
939 assert!(rendered.contains("308"), "names the status: {rendered}");
940 assert!(
941 rendered.contains("https://elsewhere.test/q"),
942 "names the target the provider wanted: {rendered}"
943 );
944 assert!(
945 rendered.contains("redirects are not followed"),
946 "says why: {rendered}"
947 );
948 }
949
950 /// An oversized response body is refused rather than buffered. The stdio
951 /// transport has bounded provider output since it shipped; until this test
952 /// the *remote* transport — the adversarial one — had no bound at all.
953 #[tokio::test]
954 async fn an_oversized_response_body_is_refused_rather_than_buffered() {
955 let server = MockServer::start().await;
956 // One byte over the limit is enough to prove the bound is the bound.
957 let oversized = vec![b'x'; MAX_RESPONSE_BYTES + 1];
958 Mock::given(method("POST"))
959 .respond_with(ResponseTemplate::new(200).set_body_bytes(oversized))
960 .mount(&server)
961 .await;
962
963 let Err(error) = HttpProvider::connect("remote", server.uri()).await else {
964 panic!("an oversized body must not be buffered");
965 };
966 let rendered = error.to_string();
967 assert!(
968 rendered.contains(&MAX_RESPONSE_BYTES.to_string()),
969 "the refusal names the limit it enforced: {rendered}"
970 );
971 }
972
973 /// A provider-written error body is clamped before it is interpolated into a
974 /// host error — a failing peer must not be a log-flooding primitive.
975 #[test]
976 fn a_provider_error_body_is_clamped_before_it_reaches_a_host_error() {
977 let short = "upstream index unavailable";
978 assert_eq!(
979 truncate_for_error(short),
980 short,
981 "short bodies pass through"
982 );
983
984 let flood = "E".repeat(100_000);
985 let clamped = truncate_for_error(&flood);
986 assert!(clamped.len() < 700, "clamped to {} bytes", clamped.len());
987 assert!(
988 clamped.contains("bytes truncated"),
989 "the clamp is disclosed, never silent: {clamped}"
990 );
991
992 // Multi-byte text is clamped on a char boundary, never mid-sequence.
993 let multibyte = "文".repeat(10_000);
994 let clamped = truncate_for_error(&multibyte);
995 assert!(clamped.contains("bytes truncated"));
996 assert!(clamped.is_char_boundary(0));
997 }
998}