Skip to main content

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/// A bearer credential a host uses to authenticate to a remote provider.
32///
33/// The secret is **never** rendered: both [`Debug`](fmt::Debug) and
34/// [`Display`](fmt::Display) print the fixed placeholder `Credential(<redacted>)`,
35/// so a credential that reaches a log line, an `{:?}`/`{}` interpolation, or a
36/// panic payload cannot spill its bytes (`SPEC.md` §4.2, **C8**). The only way
37/// to read the raw value is [`Credential::expose`], a crate-private method used
38/// solely to attach the header on the wire — a leak is therefore greppable.
39#[derive(Clone)]
40pub struct Credential {
41    /// The bearer token / `Authorization` value. Deliberately unexposed to any
42    /// formatting impl.
43    token: String,
44}
45
46impl Credential {
47    /// Wrap a bearer token. It is attached as `Authorization: Bearer <token>`
48    /// on every request this provider sends and is never logged (C8).
49    pub fn bearer(token: impl Into<String>) -> Self {
50        Self {
51            token: token.into(),
52        }
53    }
54
55    /// The raw secret — the single, greppable exit point, used only to set the
56    /// `Authorization` header on the wire.
57    fn expose(&self) -> &str {
58        &self.token
59    }
60}
61
62/// C8: a credential in a `{:?}` rendering (a log line, a panic payload) prints a
63/// fixed placeholder, never its bytes.
64impl fmt::Debug for Credential {
65    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
66        f.write_str("Credential(<redacted>)")
67    }
68}
69
70/// C8: a credential in a `{}` rendering prints the same fixed placeholder — so
71/// even an accidental `Display` interpolation cannot leak the secret.
72impl fmt::Display for Credential {
73    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
74        f.write_str("Credential(<redacted>)")
75    }
76}
77
78/// Whether a URL host component names the loopback interface — the one case an
79/// unencrypted (`http://`) transport is allowed, because the bytes never leave
80/// the machine (`SPEC.md` §4.2, **C7**). Mirrors the `localhost` exception
81/// [`verify::file_uri_to_path`](crate::verify) makes for `file://`, widened to
82/// the loopback IP ranges: the literal name `localhost`, `127.0.0.0/8`, and
83/// `::1`. IPv6 hosts arrive bracketed (`[::1]`) from a URL, so the brackets are
84/// stripped before parsing.
85fn is_loopback_host(host: &str) -> bool {
86    if host.eq_ignore_ascii_case("localhost") {
87        return true;
88    }
89    let bare = host
90        .strip_prefix('[')
91        .and_then(|inner| inner.strip_suffix(']'))
92        .unwrap_or(host);
93    // `IpAddr::is_loopback` is exactly `127.0.0.0/8` for v4 and `::1` for v6.
94    matches!(bare.parse::<IpAddr>(), Ok(ip) if ip.is_loopback())
95}
96
97/// Refuse a plaintext transport to a non-loopback provider **before** any bytes
98/// leave the host (`SPEC.md` §4.2, **C7**): an `http://` (not `https://`) URL
99/// whose host is not loopback would carry the query payload — and any bearer
100/// credential — across the network in cleartext. A loopback `http://` target is
101/// allowed (the bytes never leave the machine); every `https://` target is
102/// allowed. Called before the client is built or DNS is resolved, so a refusal
103/// short-circuits with zero network activity.
104///
105/// # Why this is public
106///
107/// [`Host::add_http`](crate::Host::add_http) already calls it, so C7 holds
108/// whether or not a caller does. It is exported for the case a host wants to
109/// classify a URL *before* attempting the connection — typically to report a
110/// plaintext endpoint as the configuration error it is, rather than as a
111/// connection failure or a non-conformant provider.
112///
113/// The alternative is that every host re-derives "which hosts are loopback"
114/// locally, and C7 ends up with one implementation per host, free to disagree
115/// about `[::1]`, `127.0.0.2`, or the casing of `LOCALHOST`. A normative rule
116/// with N implementations is N rules. This is the one.
117///
118/// ```no_run
119/// use contextgraph_host::{HostError, refuse_insecure_transport};
120///
121/// // Plaintext to a remote peer: refused, with the peer named.
122/// let refusal = refuse_insecure_transport("acme", "http://cgp.example.com/q");
123/// assert!(matches!(refusal, Err(HostError::InsecureTransport { .. })));
124///
125/// // Loopback plaintext and TLS are both fine.
126/// assert!(refuse_insecure_transport("local", "http://127.0.0.1:8080/q").is_ok());
127/// assert!(refuse_insecure_transport("acme", "https://cgp.example.com/q").is_ok());
128/// ```
129pub fn refuse_insecure_transport(id: &str, url: &str) -> Result<(), HostError> {
130    let parsed = reqwest::Url::parse(url).map_err(|e| HostError::Transport {
131        id: id.to_string(),
132        message: format!("invalid provider url: {e}"),
133    })?;
134    if parsed.scheme() == "http" {
135        let host = parsed.host_str().unwrap_or("");
136        if !is_loopback_host(host) {
137            return Err(HostError::InsecureTransport {
138                id: id.to_string(),
139                host: host.to_string(),
140            });
141        }
142    }
143    Ok(())
144}
145
146/// A [`ContextProvider`] backed by a remote HTTP endpoint. Handshakes once on
147/// [`HttpProvider::connect`] and caches the negotiated identity + capabilities.
148pub struct HttpProvider {
149    id: String,
150    url: String,
151    client: reqwest::Client,
152    info: ProviderInfo,
153    capabilities: Capabilities,
154    /// Bearer credential attached to every request, if the provider requires
155    /// one. Redacted from every rendering (C8).
156    credential: Option<Credential>,
157}
158
159impl HttpProvider {
160    /// Connect to a remote provider with no credential — a thin back-compat
161    /// wrapper over [`connect_with_auth`](Self::connect_with_auth). POST a
162    /// `handshake`, expect a compatible `handshake_ack`, and cache its identity
163    /// + capabilities. `id` is the host-facing routing/consent key.
164    pub async fn connect(id: impl Into<String>, url: impl Into<String>) -> Result<Self, HostError> {
165        Self::connect_with_auth(id, url, None).await
166    }
167
168    /// Connect to a remote provider, optionally attaching a bearer
169    /// [`Credential`] to every request. Enforces transport security before any
170    /// bytes leave the host: a plaintext (`http://`) transport to a non-loopback
171    /// provider is refused with [`HostError::InsecureTransport`], so neither the
172    /// handshake nor a credential ever crosses the network in cleartext
173    /// (`SPEC.md` §4.2, **C7**).
174    pub async fn connect_with_auth(
175        id: impl Into<String>,
176        url: impl Into<String>,
177        credential: Option<Credential>,
178    ) -> Result<Self, HostError> {
179        let id = id.into();
180        let url = url.into();
181        // C7 first, before the client is built or DNS is resolved: a refusal
182        // must short-circuit with zero network activity so no payload leaks.
183        refuse_insecure_transport(&id, &url)?;
184        let client = reqwest::Client::builder()
185            .timeout(HTTP_TIMEOUT)
186            .build()
187            .map_err(|e| HostError::Transport {
188                id: id.clone(),
189                message: format!("building HTTP client: {e}"),
190            })?;
191
192        let ack = post_envelope(
193            &client,
194            &url,
195            &Envelope::Handshake {
196                protocol_version: PROTOCOL_VERSION.to_string(),
197            },
198            &id,
199            credential.as_ref(),
200        )
201        .await?;
202
203        match ack {
204            Envelope::HandshakeAck {
205                protocol_version,
206                provider,
207                capabilities,
208            } => {
209                if !versions_compatible(PROTOCOL_VERSION, &protocol_version) {
210                    return Err(HostError::VersionMismatch {
211                        host: PROTOCOL_VERSION.to_string(),
212                        provider: provider.name,
213                        provider_version: protocol_version,
214                    });
215                }
216                // An HTTP transport is egress by definition: every query is
217                // POSTed off-box to a remote URL. So the consent gate must key
218                // off transport, not the remote's self-report — a remote that
219                // handshakes `egress:false` would otherwise be queried with no
220                // consent. Force it on here regardless of what it declared.
221                // (The stdio path keeps its declared posture; a local child
222                // that doesn't reach the network genuinely may not be egress.)
223                let mut info = provider;
224                info.data_flow.egress = true;
225                Ok(Self {
226                    id,
227                    url,
228                    client,
229                    info,
230                    capabilities,
231                    credential,
232                })
233            }
234            other => Err(HostError::UnexpectedEnvelope {
235                id,
236                expected: "handshake_ack".into(),
237                got: envelope_kind(&other).into(),
238            }),
239        }
240    }
241}
242
243/// POST one envelope to the provider URL and decode the response as one
244/// envelope. A non-2xx status or a non-envelope body is a clean named error,
245/// never a panic (task deliverable 5).
246///
247/// When `credential` is present it is attached as `Authorization: Bearer …` via
248/// reqwest's [`bearer_auth`](reqwest::RequestBuilder::bearer_auth) — never a
249/// format string that could leak the secret into a log (C8).
250async fn post_envelope(
251    client: &reqwest::Client,
252    url: &str,
253    env: &Envelope,
254    id: &str,
255    credential: Option<&Credential>,
256) -> Result<Envelope, HostError> {
257    let mut request = client.post(url).json(env);
258    if let Some(credential) = credential {
259        request = request.bearer_auth(credential.expose());
260    }
261    let response = request.send().await.map_err(|e| HostError::Transport {
262        id: id.to_string(),
263        message: e.to_string(),
264    })?;
265
266    // A rejected credential is its own named error, distinct from any other
267    // transport failure — and it names only the id + status, never the
268    // credential (C8).
269    if response.status() == reqwest::StatusCode::UNAUTHORIZED {
270        return Err(HostError::Unauthorized { id: id.to_string() });
271    }
272
273    if !response.status().is_success() {
274        let status = response.status();
275        let body = response.text().await.unwrap_or_default();
276        return Err(HostError::Transport {
277            id: id.to_string(),
278            message: format!("HTTP {status}: {body}"),
279        });
280    }
281
282    response.json::<Envelope>().await.map_err(|e| {
283        HostError::Wire(format!(
284            "provider {id} returned a non-envelope HTTP body: {e}"
285        ))
286    })
287}
288
289#[async_trait]
290impl ContextProvider for HttpProvider {
291    fn id(&self) -> &str {
292        &self.id
293    }
294
295    fn info(&self) -> &ProviderInfo {
296        &self.info
297    }
298
299    fn capabilities(&self) -> &Capabilities {
300        &self.capabilities
301    }
302
303    async fn query(&self, query: &ContextQuery) -> Result<ContextQueryResult, HostError> {
304        let sent_id = self.capabilities.correlation.then(next_correlation_id);
305        let reply = post_envelope(
306            &self.client,
307            &self.url,
308            &Envelope::Query {
309                id: sent_id.clone(),
310                query: query.clone(),
311            },
312            &self.id,
313            self.credential.as_ref(),
314        )
315        .await?;
316        match reply {
317            Envelope::Frames { id: echoed, result } => {
318                verify_correlation(&self.id, sent_id.as_deref(), echoed.as_deref())?;
319                Ok(result)
320            }
321            Envelope::Error { message, code, .. } => Err(HostError::Provider {
322                id: self.id.clone(),
323                code,
324                message,
325            }),
326            other => Err(HostError::UnexpectedEnvelope {
327                id: self.id.clone(),
328                expected: "frames".into(),
329                got: envelope_kind(&other).into(),
330            }),
331        }
332    }
333
334    async fn verify(&self, request: &VerifyRequest) -> Result<VerifyResponse, HostError> {
335        let reply = post_envelope(
336            &self.client,
337            &self.url,
338            &Envelope::Verify {
339                request: request.clone(),
340            },
341            &self.id,
342            self.credential.as_ref(),
343        )
344        .await?;
345        match reply {
346            Envelope::Verified { response } => Ok(response),
347            Envelope::Error { message, code, .. } => Err(HostError::Provider {
348                id: self.id.clone(),
349                code,
350                message,
351            }),
352            other => Err(HostError::UnexpectedEnvelope {
353                id: self.id.clone(),
354                expected: "verified".into(),
355                got: envelope_kind(&other).into(),
356            }),
357        }
358    }
359
360    async fn shutdown(&self) -> Result<(), HostError> {
361        // Best-effort teardown notice; a remote endpoint is not ours to reap.
362        let _ = post_envelope(
363            &self.client,
364            &self.url,
365            &Envelope::Shutdown,
366            &self.id,
367            self.credential.as_ref(),
368        )
369        .await;
370        Ok(())
371    }
372}
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377    use contextgraph_types::capability::QueryCapability;
378    use contextgraph_types::{ContextFrame, DataFlow, FrameKind};
379    use wiremock::matchers::{header, method};
380    use wiremock::{Mock, MockServer, ResponseTemplate};
381
382    fn ack_body(version: &str) -> serde_json::Value {
383        serde_json::to_value(Envelope::HandshakeAck {
384            protocol_version: version.to_string(),
385            provider: ProviderInfo {
386                name: "remote-docs".into(),
387                version: "0.1.0".into(),
388                data_flow: DataFlow {
389                    reads: true,
390                    writes: false,
391                    egress: true,
392                    egress_scopes: vec![],
393                },
394            },
395            capabilities: Capabilities {
396                query: QueryCapability {
397                    kinds: vec!["doc".into()],
398                },
399                ..Capabilities::default()
400            },
401        })
402        .unwrap()
403    }
404
405    fn frames_body() -> serde_json::Value {
406        serde_json::to_value(Envelope::Frames {
407            id: None,
408            result: ContextQueryResult {
409                frames: vec![ContextFrame {
410                    id: "frm_h".into(),
411                    kind: FrameKind::Doc,
412                    title: "remote doc".into(),
413                    content: Some("remote content".into()),
414                    content_digest: None,
415                    uri: Some("https://example.test/doc".into()),
416                    representation: Default::default(),
417                    content_fidelity: None,
418                    canonical_content_hash: None,
419                    content_ref: None,
420                    transform: None,
421                    minimum_content_fidelity: None,
422                    inline_content_requirement: None,
423                    score: 0.6,
424                    token_cost: 20,
425                    canonical_token_cost: None,
426                    tokenizer_ref: None,
427                    valid_from: None,
428                    valid_to: None,
429                    recorded_at: None,
430                    provenance: vec![],
431                    citation_label: Some("remote doc".into()),
432                    embedding: None,
433                    relations: vec![],
434                }],
435                truncated: false,
436                dropped_estimate: None,
437            },
438        })
439        .unwrap()
440    }
441
442    fn sample_query() -> ContextQuery {
443        ContextQuery {
444            goal: "g".into(),
445            query_text: None,
446            embedding: None,
447            kinds: vec![],
448            anchors: vec![],
449            max_frames: 5,
450            max_tokens: 4000,
451            as_of: None,
452            representation_preferences: vec![],
453        }
454    }
455
456    #[tokio::test]
457    async fn http_handshake_then_query_round_trips_via_wiremock() {
458        let server = MockServer::start().await;
459        // Both the handshake and the query POST to the same URL; the responder
460        // dispatches on the request envelope's `type`.
461        Mock::given(method("POST"))
462            .respond_with(|req: &wiremock::Request| {
463                let body = match serde_json::from_slice::<Envelope>(&req.body) {
464                    Ok(Envelope::Handshake { .. }) => ack_body(PROTOCOL_VERSION),
465                    Ok(Envelope::Query { .. }) => frames_body(),
466                    _ => serde_json::to_value(Envelope::Error {
467                        id: None,
468                        code: None,
469                        message: "unexpected request".into(),
470                    })
471                    .unwrap(),
472                };
473                ResponseTemplate::new(200).set_body_json(body)
474            })
475            .mount(&server)
476            .await;
477
478        let provider = HttpProvider::connect("remote", server.uri())
479            .await
480            .expect("handshake ok");
481        assert_eq!(provider.info().name, "remote-docs");
482        assert!(provider.info().data_flow.egress);
483
484        let result = provider.query(&sample_query()).await.expect("query ok");
485        assert_eq!(result.frames.len(), 1);
486        assert_eq!(result.frames[0].title, "remote doc");
487    }
488
489    #[tokio::test]
490    async fn http_version_mismatch_rejects_the_provider() {
491        let server = MockServer::start().await;
492        Mock::given(method("POST"))
493            .respond_with(ResponseTemplate::new(200).set_body_json(ack_body("contextgraph/2.0")))
494            .mount(&server)
495            .await;
496
497        let err = match HttpProvider::connect("remote", server.uri()).await {
498            Ok(_) => panic!("incompatible version must reject"),
499            Err(e) => e,
500        };
501        assert!(matches!(err, HostError::VersionMismatch { .. }));
502    }
503
504    #[tokio::test]
505    async fn a_non_envelope_http_body_is_a_clean_wire_error() {
506        let server = MockServer::start().await;
507        Mock::given(method("POST"))
508            .respond_with(
509                ResponseTemplate::new(200).set_body_string("<html>not contextgraph</html>"),
510            )
511            .mount(&server)
512            .await;
513
514        let err = match HttpProvider::connect("remote", server.uri()).await {
515            Ok(_) => panic!("garbage body must not panic the host"),
516            Err(e) => e,
517        };
518        assert!(matches!(err, HostError::Wire(_)));
519    }
520
521    #[tokio::test]
522    async fn http_transport_forces_egress_even_when_the_remote_claims_local() {
523        // A remote self-declares `egress:false` in its handshake. Using an HTTP
524        // transport IS egress (the query is POSTed off-box), so the host must
525        // override the claim and still gate consent — otherwise a remote could
526        // opt itself out of the consent gate by lying.
527        let server = MockServer::start().await;
528        let sneaky_ack = serde_json::to_value(Envelope::HandshakeAck {
529            protocol_version: PROTOCOL_VERSION.to_string(),
530            provider: ProviderInfo {
531                name: "sneaky-remote".into(),
532                version: "0.1.0".into(),
533                data_flow: DataFlow {
534                    reads: true,
535                    writes: false,
536                    egress: false, // the lie the host must not trust
537                    egress_scopes: vec![],
538                },
539            },
540            capabilities: Capabilities {
541                query: QueryCapability {
542                    kinds: vec!["doc".into()],
543                },
544                ..Capabilities::default()
545            },
546        })
547        .unwrap();
548        Mock::given(method("POST"))
549            .respond_with(ResponseTemplate::new(200).set_body_json(sneaky_ack))
550            .mount(&server)
551            .await;
552
553        let provider = HttpProvider::connect("remote", server.uri())
554            .await
555            .expect("handshake ok");
556
557        assert!(
558            provider.info().data_flow.egress,
559            "an HTTP transport must be treated as egress regardless of the remote's claim"
560        );
561        assert!(
562            crate::consent::ConsentStore::requires_consent(provider.info()),
563            "an HTTP provider must always require consent, even claiming egress:false"
564        );
565    }
566
567    // ---- transport security (§4.2, C7/C8) ----
568
569    #[tokio::test]
570    async fn a_plaintext_non_loopback_transport_is_refused_before_any_bytes_leave() {
571        // C7: an `http://` (not `https://`) URL whose host is not loopback is
572        // refused BEFORE a client is built or DNS is resolved — the query
573        // payload and any credential must never cross the network in cleartext.
574        // The proof it short-circuits is the error *kind*: a real network
575        // attempt to this host would surface as a `Transport` (connect) error,
576        // never `InsecureTransport`.
577        let err = match HttpProvider::connect("remote", "http://example.com:9/cgp").await {
578            Ok(_) => panic!("a plaintext non-loopback transport must be refused (C7)"),
579            Err(e) => e,
580        };
581        match err {
582            HostError::InsecureTransport { id, host } => {
583                assert_eq!(id, "remote");
584                assert_eq!(host, "example.com");
585            }
586            other => panic!("expected InsecureTransport, got {other:?}"),
587        }
588    }
589
590    /// The C7 rule is now public API ([`refuse_insecure_transport`]) so a host can
591    /// classify a URL without re-deriving "which hosts are loopback" locally. That
592    /// makes these edge cases part of the exported contract rather than an
593    /// internal detail, so they are pinned directly instead of only through
594    /// `connect`: they are exactly the cases an independent reimplementation gets
595    /// wrong, and the reason the rule is exported at all.
596    #[test]
597    fn the_exported_c7_rule_classifies_every_loopback_spelling() {
598        // Allowed: TLS anywhere, and plaintext to loopback in each of its
599        // spellings — the literal name (any casing), all of `127.0.0.0/8` rather
600        // than just `127.0.0.1`, and bracketed IPv6 `::1`.
601        for allowed in [
602            "https://example.com/cgp",
603            "http://localhost:8080/cgp",
604            "http://LOCALHOST:8080/cgp",
605            "http://127.0.0.1/cgp",
606            "http://127.0.0.2/cgp",
607            "http://[::1]:8080/cgp",
608        ] {
609            assert!(
610                refuse_insecure_transport("p", allowed).is_ok(),
611                "C7 must allow {allowed}"
612            );
613        }
614
615        // Refused: plaintext to anything off-machine. `127.0.0.1.example.com` is
616        // the prefix-matching trap — it *starts with* a loopback IP and is a
617        // remote DNS name.
618        for refused in [
619            "http://example.com/cgp",
620            "http://127.0.0.1.example.com/cgp",
621            "http://[2001:db8::1]/cgp",
622            "http://10.0.0.5/cgp",
623        ] {
624            assert!(
625                matches!(
626                    refuse_insecure_transport("p", refused),
627                    Err(HostError::InsecureTransport { .. })
628                ),
629                "C7 must refuse {refused}"
630            );
631        }
632
633        // An unparseable URL is a config error, not a security verdict: reporting
634        // it as `InsecureTransport` would tell an operator to add TLS to a string
635        // that is not a URL at all.
636        assert!(matches!(
637            refuse_insecure_transport("p", "not a url"),
638            Err(HostError::Transport { .. })
639        ));
640    }
641
642    #[tokio::test]
643    async fn a_plaintext_loopback_transport_is_allowed() {
644        // The C7 loopback exception: wiremock serves plain `http://` on
645        // `127.0.0.1`, and the host must NOT refuse it — the bytes never leave
646        // the machine. This is also what keeps every other wiremock test in this
647        // module (all on 127.0.0.1) working.
648        let server = MockServer::start().await;
649        assert!(
650            server.uri().starts_with("http://"),
651            "wiremock serves plaintext http on loopback"
652        );
653        Mock::given(method("POST"))
654            .respond_with(ResponseTemplate::new(200).set_body_json(ack_body(PROTOCOL_VERSION)))
655            .mount(&server)
656            .await;
657        let provider = HttpProvider::connect("remote", server.uri())
658            .await
659            .expect("a plaintext loopback (127.0.0.1) transport is allowed");
660        assert_eq!(provider.info().name, "remote-docs");
661    }
662
663    #[tokio::test]
664    async fn a_supplied_credential_is_attached_as_a_bearer_header() {
665        const TOKEN: &str = "s3cr3t-bearer-token-value";
666        let server = MockServer::start().await;
667        let auth_value = format!("Bearer {TOKEN}");
668        // The mock only matches when the `Authorization` header is present and
669        // exact. If the header were missing (or mangled), no mock matches,
670        // wiremock 404s, and the handshake/query below fail — so a green test
671        // proves the bearer credential was attached on the wire.
672        Mock::given(method("POST"))
673            .and(header("authorization", auth_value.as_str()))
674            .respond_with(|req: &wiremock::Request| {
675                let body = match serde_json::from_slice::<Envelope>(&req.body) {
676                    Ok(Envelope::Handshake { .. }) => ack_body(PROTOCOL_VERSION),
677                    Ok(Envelope::Query { .. }) => frames_body(),
678                    _ => serde_json::to_value(Envelope::Error {
679                        id: None,
680                        code: None,
681                        message: "unexpected request".into(),
682                    })
683                    .unwrap(),
684                };
685                ResponseTemplate::new(200).set_body_json(body)
686            })
687            .mount(&server)
688            .await;
689
690        let provider = HttpProvider::connect_with_auth(
691            "remote",
692            server.uri(),
693            Some(Credential::bearer(TOKEN)),
694        )
695        .await
696        .expect("handshake carries the bearer credential");
697        // The query carries it too — the same header matcher gates its response.
698        let result = provider.query(&sample_query()).await.expect("query ok");
699        assert_eq!(result.frames.len(), 1);
700    }
701
702    #[test]
703    fn a_credential_is_redacted_in_every_rendering_and_never_in_an_error() {
704        // C8: the secret must not appear in any `{:?}`/`{}` rendering — a
705        // credential that reaches a log line or a panic payload prints a fixed
706        // placeholder, not its bytes.
707        const SECRET: &str = "ghp_this_must_never_appear_in_a_log_0xDEADBEEF";
708        let credential = Credential::bearer(SECRET);
709
710        let debug = format!("{credential:?}");
711        let display = format!("{credential}");
712        assert_eq!(debug, "Credential(<redacted>)");
713        assert_eq!(display, "Credential(<redacted>)");
714        assert!(
715            !debug.contains(SECRET),
716            "Debug must not leak the secret (C8)"
717        );
718        assert!(
719            !display.contains(SECRET),
720            "Display must not leak the secret (C8)"
721        );
722        // Cloning preserves redaction — a duplicated credential still can't leak.
723        assert_eq!(
724            format!("{:?}", credential.clone()),
725            "Credential(<redacted>)"
726        );
727
728        // No `HostError` carries credential material: the auth-related variants
729        // render only id/host/status, so a secret can never reach a surfaced
730        // error string (C8).
731        let insecure = HostError::InsecureTransport {
732            id: "remote".into(),
733            host: "example.com".into(),
734        };
735        let unauthorized = HostError::Unauthorized {
736            id: "remote".into(),
737        };
738        assert!(!insecure.to_string().contains(SECRET));
739        assert!(!unauthorized.to_string().contains(SECRET));
740    }
741}