use std::borrow::Cow;
use std::sync::Arc;
use axum::http::request::Parts;
use axum::http::{HeaderName, HeaderValue, StatusCode};
use lean_ctx_protocol::Sha256Digest;
use lean_ctx_protocol::context_gateway::{ClassificationV1, DestinationLocalityV1, DestinationV1};
use serde_json::Value;
use super::codec::{
RequestBodyEncoding, decode_gzip_bounded, decode_zstd_bounded, encode_gzip, encode_zstd,
request_body_encoding,
};
use super::forward::Caller;
use super::request_project::{self, ProxyProject};
use super::sealed_origin::{self, Lineage, LineageSlot, SealedContext, SealedProducer};
use lean_ctx::core::config::Config;
use lean_ctx::core::context_admission::AdmissionPolicy;
use lean_ctx::core::context_admission::egress::{
self, EgressBody, EgressOutcome, EgressTarget, TrustedDestinationBinding,
};
use lean_ctx::core::context_admission::receipt_store;
use lean_ctx::core::policy::runtime as project_policy;
pub(crate) const REFUSAL_HEADER: &str = "x-leanctx-refusal";
pub(crate) const REASON_CODES_HEADER: &str = "x-leanctx-reason-codes";
pub(crate) const WITHHELD_BLOCKS_HEADER: &str = "x-leanctx-withheld-blocks";
const REFUSAL_KIND: &str = "context_gateway";
const FALLBACK_CODE: &str = "gateway.refused";
const RECEIPT_UNAVAILABLE: &str = "receipt.unavailable";
const POLICY_CHANGED: &str = "policy.changed";
const WITHHELD_MARKER: &str = "[lean-ctx gateway: content withheld";
fn refusal_text(code: &str) -> String {
format!("request withheld by the context gateway ({code})")
}
const WITHHELD_STATUS: StatusCode = StatusCode::BAD_REQUEST;
#[path = "admitted_refusal.rs"]
mod refusal;
pub(crate) use refusal::GatewayRefusal;
fn usable_policy(snapshot: &Config) -> Result<AdmissionPolicy, GatewayRefusal> {
let policy = AdmissionPolicy::from_config(snapshot);
if policy.config_unloadable() {
return Err(GatewayRefusal::unavailable("gateway.config_unloadable"));
}
Ok(policy)
}
#[derive(Debug)]
pub(crate) struct Authority {
epoch: egress::PolicyEpoch,
epoch_project: Option<[u8; 32]>,
project: Option<project_policy::PublicationAuthority>,
original: Option<Box<Authority>>,
scope: Option<std::path::PathBuf>,
}
impl Authority {
pub(crate) fn verify_current(&self) -> Result<(), GatewayRefusal> {
let project =
request_project::resolve(&Config::load_arc()).map_err(GatewayRefusal::unavailable)?;
if project.scope() != self.scope.as_deref() {
return Err(GatewayRefusal::withheld(POLICY_CHANGED));
}
match &self.scope {
Some(root) => request_project::in_project(root, || self.verify_in_scope()),
None => self.verify_in_scope(),
}
}
fn verify_in_scope(&self) -> Result<(), GatewayRefusal> {
let current = usable_policy(&Config::load_arc())?;
if !self.epoch.matches(
¤t,
self.epoch_project.as_ref().map(<[u8; 32]>::as_slice),
) {
return Err(GatewayRefusal::withheld(POLICY_CHANGED));
}
let project_current = match &self.project {
Some(authority) => authority.verify().is_ok(),
None => !project_policy::is_active(),
};
if !project_current {
return Err(GatewayRefusal::withheld(POLICY_CHANGED));
}
self.original
.as_deref()
.map_or(Ok(()), Self::verify_in_scope)
}
fn same_identity(&self, other: &Self) -> bool {
let project = |authority: &Self| {
authority.project.as_ref().map(|project| {
(
project.fingerprint_bytes(),
project.role_fingerprint_bytes(),
)
})
};
self.epoch == other.epoch
&& self.epoch_project == other.epoch_project
&& project(self) == project(other)
}
pub(crate) fn stored(&self) -> Option<StoredAuthority> {
let project = match &self.project {
Some(project) => Some(project.stored()?),
None => None,
};
let original = match &self.original {
Some(original) => Some(Box::new(original.stored()?)),
None => None,
};
Some(StoredAuthority {
epoch: self.epoch.digest(),
epoch_project: self.epoch_project,
project,
original,
scope: self.scope.clone(),
})
}
pub(crate) fn restore(stored: StoredAuthority) -> Self {
Self {
epoch: egress::PolicyEpoch::from_digest(stored.epoch),
epoch_project: stored.epoch_project,
project: stored
.project
.map(project_policy::PublicationAuthority::restore),
original: stored
.original
.map(|original| Box::new(Self::restore(*original))),
scope: stored.scope,
}
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct StoredAuthority {
epoch: [u8; 32],
epoch_project: Option<[u8; 32]>,
project: Option<project_policy::StoredPublicationAuthority>,
original: Option<Box<StoredAuthority>>,
scope: Option<std::path::PathBuf>,
}
#[cfg(test)]
pub(crate) mod failpoint {
use std::cell::RefCell;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Point {
AuthorityCaptured,
Decided,
CallerVerified,
}
type Hook = Box<dyn FnMut(Point)>;
thread_local! {
static HOOK: RefCell<Option<Hook>> = const { RefCell::new(None) };
}
pub(crate) struct Armed;
pub(crate) fn arm(hook: impl FnMut(Point) + 'static) -> Armed {
HOOK.with(|slot| *slot.borrow_mut() = Some(Box::new(hook)));
Armed
}
impl Drop for Armed {
fn drop(&mut self) {
HOOK.with(|slot| *slot.borrow_mut() = None);
}
}
pub(super) fn reach(point: Point) {
let armed = HOOK.with(|slot| slot.borrow_mut().take());
if let Some(mut hook) = armed {
hook(point);
HOOK.with(|slot| *slot.borrow_mut() = Some(hook));
}
}
}
#[derive(Debug)]
pub(crate) struct Admission {
pub(crate) outcome: EgressOutcome,
authority: Authority,
project_epoch_bound: bool,
sealed: SealedContext,
lineage: Option<Lineage>,
}
impl Admission {
pub(crate) fn is_refused(&self) -> bool {
matches!(self.outcome.body, EgressBody::Refused(_))
}
pub(crate) fn take_rewritten(&mut self) -> Option<Value> {
match std::mem::replace(&mut self.outcome.body, EgressBody::Unchanged) {
EgressBody::Rewritten(doc) => Some(doc),
other => {
self.outcome.body = other;
None
}
}
}
fn refused(mut self, codes: &[&str]) -> Self {
self.outcome = self.outcome.refuse_with(codes);
self
}
#[must_use]
pub(crate) fn note_kept_whole(mut self, kept: &super::compress::KeptWhole) -> Self {
let codes: Vec<&str> = kept
.counts()
.into_iter()
.flat_map(|(code, blocks)| std::iter::repeat_n(code, blocks))
.collect();
if !codes.is_empty() {
self.outcome = self.outcome.note_with(&codes);
}
self
}
fn refusal(&self) -> GatewayRefusal {
let codes = self.outcome.refusal_codes();
if !codes.is_empty() {
let mut listed: Vec<&str> = Vec::new();
for code in &codes {
if !listed.contains(&code.as_str()) {
listed.push(code.as_str());
}
}
return GatewayRefusal::new(WITHHELD_STATUS, listed)
.with_blocks(self.outcome.refused_blocks());
}
match &self.outcome.body {
EgressBody::Refused(reason) => {
GatewayRefusal::from_reason(reason).with_blocks(self.outcome.refused_blocks())
}
EgressBody::Unchanged | EgressBody::Rewritten(_) => {
GatewayRefusal::withheld(FALLBACK_CODE)
}
}
}
#[cfg(test)]
pub(crate) fn into_authority(self) -> Authority {
self.authority
}
#[cfg(test)]
pub(crate) fn still_current(&self) -> Result<(), GatewayRefusal> {
self.verify_current()
}
fn verify_current(&self) -> Result<(), GatewayRefusal> {
self.authority.verify_current()?;
match &self.authority.project {
Some(project)
if self.project_epoch_bound
&& !self
.outcome
.policy_epoch_current_for_project(&project.fingerprint_bytes()) =>
{
Err(GatewayRefusal::withheld(POLICY_CHANGED))
}
_ => Ok(()),
}
}
}
pub(crate) fn transport_failure(error: &reqwest::Error) -> &'static str {
if error.is_timeout() {
"timeout"
} else if error.is_connect() {
"connection failure"
} else if error.is_body() {
"body transfer failure"
} else if error.is_decode() {
"response decode failure"
} else if error.is_request() {
"request failure"
} else {
"transport failure"
}
}
#[derive(Clone, Copy)]
pub(crate) enum Payload<'a> {
Document(&'a Value),
Opaque(usize),
}
pub(crate) fn admit(
payload: Payload<'_>,
target: &EgressTarget<'_>,
binding: TrustedDestinationBinding<'_>,
) -> Result<Admission, GatewayRefusal> {
admit_as(payload, target, binding, None)
}
pub(crate) fn admit_as(
payload: Payload<'_>,
target: &EgressTarget<'_>,
binding: TrustedDestinationBinding<'_>,
principal: Option<&Caller>,
) -> Result<Admission, GatewayRefusal> {
admit_at(payload, target, binding, principal, Stage::Caller)
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Stage {
Caller,
Final,
}
fn admit_at(
payload: Payload<'_>,
target: &EgressTarget<'_>,
binding: TrustedDestinationBinding<'_>,
principal: Option<&Caller>,
stage: Stage,
) -> Result<Admission, GatewayRefusal> {
let project =
request_project::resolve(&Config::load_arc()).map_err(GatewayRefusal::unavailable)?;
match project.scope() {
Some(root) => request_project::in_project(root, || {
admit_in(payload, target, binding, principal, stage, &project)
}),
None => admit_in(payload, target, binding, principal, stage, &project),
}
}
fn admit_in(
payload: Payload<'_>,
target: &EgressTarget<'_>,
binding: TrustedDestinationBinding<'_>,
principal: Option<&Caller>,
stage: Stage,
proxy_project: &ProxyProject,
) -> Result<Admission, GatewayRefusal> {
let derived: Option<LineageSlot> =
(stage == Stage::Caller).then(|| std::rc::Rc::new(std::cell::RefCell::new(None)));
let snapshot = Config::load_arc();
let captured = usable_policy(&snapshot)?;
#[cfg(test)]
failpoint::reach(failpoint::Point::AuthorityCaptured);
let principal = principal.cloned();
let conclude = |outcome: EgressOutcome,
project: Option<project_policy::PublicationAuthority>,
sealed: SealedContext|
-> Result<Admission, GatewayRefusal> {
conclude_admission(
outcome,
project,
sealed,
derived.as_ref(),
stage,
proxy_project,
&captured,
)
};
let governed = project_policy::with_governed_source_view(|| {
let authority = project_policy::PublicationAuthority::capture().ok()?;
let fingerprint = authority.fingerprint_bytes();
let policy_verified = project_policy::is_active();
let sealed = SealedContext::new(
target,
binding,
Some((fingerprint, authority.role_fingerprint_bytes())),
principal.clone(),
);
let pinned = project_policy::REQUEST_PROJECT
.try_with(|slot| slot.borrow().clone())
.ok()
.flatten();
let root = proxy_project
.source_project_under(policy_verified)
.filter(|root| Some(root) == pinned.as_ref());
let outcome = match payload {
Payload::Document(doc) => egress::with_sealed_resolver(
sealed_origin::resolver(sealed.clone(), derived.clone(), root),
|| {
egress::admit_request_for_project_with_binding(
doc,
target,
&fingerprint,
binding,
)
},
),
Payload::Opaque(bytes) => {
let outcome = egress::admit_opaque_for_project_with_binding(
bytes,
target,
&fingerprint,
binding,
);
if bytes > 0 && policy_verified && !matches!(outcome.body, EgressBody::Refused(_)) {
outcome.refuse_with(&["policy.content_withheld"])
} else {
outcome
}
}
};
Some(conclude(outcome, Some(authority), sealed))
})
.map_err(|_| GatewayRefusal::unavailable("policy.unverifiable"))?;
if let Some(decided) = governed {
decided.ok_or_else(|| GatewayRefusal::unavailable("policy.unverifiable"))?
} else {
let sources = proxy_project.source_project_under(false);
let sealed = SealedContext::new(target, binding, None, principal);
let outcome = match payload {
Payload::Document(doc) => egress::with_sealed_resolver(
sealed_origin::resolver(sealed.clone(), derived.clone(), sources),
|| egress::admit_request_with_binding(doc, target, binding),
),
Payload::Opaque(bytes) => egress::admit_opaque_with_binding(bytes, target, binding),
};
conclude(outcome, None, sealed)
}
}
fn conclude_admission(
outcome: EgressOutcome,
project: Option<project_policy::PublicationAuthority>,
sealed: SealedContext,
derived: Option<&LineageSlot>,
stage: Stage,
proxy_project: &ProxyProject,
captured: &AdmissionPolicy,
) -> Result<Admission, GatewayRefusal> {
let mut lineage = None;
if let Some(slot) = derived {
lineage = slot.take();
}
if lineage.is_none()
&& stage == Stage::Caller
&& lean_ctx::core::context_admission::stored::source_authority_required()
{
lineage = Some(Lineage::Unknown);
}
let project_epoch_bound = project.is_some();
let epoch_project = project_epoch_bound
.then(|| {
project
.as_ref()
.map(project_policy::PublicationAuthority::fingerprint_bytes)
})
.flatten();
let outcome = match sealed.principal_ref() {
Ok(reference) => outcome.for_principal(reference),
Err(code) if outcome.receipt_required() => return Err(GatewayRefusal::unavailable(code)),
Err(_) => outcome,
};
let admission = Admission {
project_epoch_bound,
authority: Authority {
epoch: outcome.policy_epoch(),
epoch_project,
project,
original: None,
scope: proxy_project.scope().map(std::path::Path::to_path_buf),
},
outcome,
sealed,
lineage,
};
#[cfg(test)]
failpoint::reach(failpoint::Point::Decided);
if !admission.authority.epoch.matches(
captured,
admission
.authority
.epoch_project
.as_ref()
.map(<[u8; 32]>::as_slice),
) {
return Err(GatewayRefusal::withheld(POLICY_CHANGED));
}
admission.authority.verify_current()?;
Ok(admission)
}
#[derive(Debug)]
pub(crate) struct CallerAdmission {
admission: Admission,
destination: DestinationV1,
principal: Option<Caller>,
}
impl CallerAdmission {
pub(crate) fn is_refused(&self) -> bool {
self.admission.is_refused()
}
pub(crate) fn take_rewritten(&mut self) -> Option<Value> {
self.admission.take_rewritten()
}
pub(crate) fn into_admission(self) -> Admission {
self.admission
}
}
pub(crate) fn admit_caller(
payload: Payload<'_>,
target: &EgressTarget<'_>,
binding: TrustedDestinationBinding<'_>,
principal: Option<Caller>,
) -> Result<CallerAdmission, GatewayRefusal> {
let admission = admit_as(payload, target, binding, principal.as_ref())?;
Ok(CallerAdmission {
destination: egress::destination_with_binding(target, binding),
admission,
principal,
})
}
pub(crate) fn admit_final_with(
payload: Payload<'_>,
target: &EgressTarget<'_>,
binding: TrustedDestinationBinding<'_>,
caller: CallerAdmission,
untouched: bool,
restore: impl FnOnce(Option<&Value>) -> Option<Value>,
) -> Result<(Admission, Option<Value>), GatewayRefusal> {
caller.admission.verify_current()?;
#[cfg(test)]
failpoint::reach(failpoint::Point::CallerVerified);
let destination = egress::destination_with_binding(target, binding);
if untouched && destination == caller.destination {
return Ok((caller.admission, None));
}
let mut check = admit_at(
payload,
target,
binding,
caller.principal.as_ref(),
Stage::Final,
)?;
let restored = match &check.outcome.body {
EgressBody::Unchanged => None,
EgressBody::Rewritten(admitted) => restore(Some(admitted)),
EgressBody::Refused(_) => restore(None),
};
if let Some(originals) = &restored {
check = admit_at(
Payload::Document(originals),
target,
binding,
caller.principal.as_ref(),
Stage::Final,
)?;
}
if !check.authority.same_identity(&caller.admission.authority) {
return Err(GatewayRefusal::withheld(POLICY_CHANGED));
}
let Admission {
outcome: caller_outcome,
authority: caller_authority,
lineage,
..
} = caller.admission;
let Admission {
outcome: final_outcome,
mut authority,
project_epoch_bound,
sealed,
..
} = check;
authority.original = Some(Box::new(caller_authority));
let mut outcome = caller_outcome.join(final_outcome);
if !matches!(outcome.body, EgressBody::Refused(_))
&& destination.locality != DestinationLocalityV1::Local
&& outcome
.classification
.is_some_and(|classification| classification >= ClassificationV1::Restricted)
{
outcome = outcome.refuse_with(&["destination.remote_restricted"]);
}
let mut admission = Admission {
outcome,
authority,
project_epoch_bound,
sealed,
lineage,
};
let rewritten = admission.take_rewritten();
if rewritten.is_some() {
tracing::warn!(
"lean-ctx proxy: a request transformation produced content the gateway masked \
again before forwarding"
);
}
Ok((admission, rewritten.or(restored)))
}
#[cfg(test)]
pub(crate) fn admit_final(
payload: Payload<'_>,
target: &EgressTarget<'_>,
binding: TrustedDestinationBinding<'_>,
caller: CallerAdmission,
untouched: bool,
) -> Result<(Admission, Option<Value>), GatewayRefusal> {
admit_final_with(payload, target, binding, caller, untouched, |_| None)
}
pub(crate) async fn record_refusal(
admission: Admission,
original_bytes: usize,
agent_id: Option<String>,
) -> GatewayRefusal {
let refusal = admission.refusal();
tracing::warn!(
"lean-ctx proxy: {} (see `lean-ctx inspect --proxy`)",
refusal.message()
);
discard(admission, original_bytes, agent_id).await;
refusal
}
async fn discard(admission: Admission, original_bytes: usize, agent_id: Option<String>) {
let _ = egress::finish_admission_off_runtime(admission.outcome, None, original_bytes, agent_id)
.await;
}
pub(crate) async fn bind_delivery(
admission: Admission,
bytes: &[u8],
original_bytes: usize,
) -> Result<BoundDelivery, GatewayRefusal> {
let receipt_required = admission.outcome.receipt_required();
let (admission_digest, producer) =
seal(admission, bytes.to_vec(), original_bytes, None).await?;
Ok(BoundDelivery {
admission_digest,
authority: producer.authority,
receipt_required,
})
}
const OBSERVATION_UNAVAILABLE: &str = "receipt.observation_unavailable";
#[derive(Debug)]
pub(crate) struct BoundDelivery {
admission_digest: Option<Sha256Digest>,
authority: Arc<Authority>,
receipt_required: bool,
}
impl BoundDelivery {
pub(crate) fn verify_current(&self) -> Result<(), GatewayRefusal> {
self.authority.verify_current()
}
pub(crate) async fn observe_frame(&self, sent: bool) -> Result<(), GatewayRefusal> {
let recorded =
super::egress_observation::record_websocket_frame(self.admission_digest.as_ref(), sent)
.await;
if recorded || !self.receipt_required {
Ok(())
} else {
Err(GatewayRefusal::unavailable(OBSERVATION_UNAVAILABLE))
}
}
}
async fn seal(
admission: Admission,
bytes: Vec<u8>,
original_bytes: usize,
agent_id: Option<String>,
) -> Result<(Option<Sha256Digest>, SealedProducer), GatewayRefusal> {
if admission.is_refused() {
return Err(record_refusal(admission, original_bytes, agent_id).await);
}
if let Err(refusal) = admission.verify_current() {
tracing::warn!("lean-ctx proxy: {}", refusal.message());
let code = refusal.codes.first().map_or(FALLBACK_CODE, String::as_str);
discard(admission.refused(&[code]), original_bytes, agent_id).await;
return Err(refusal);
}
let Admission {
outcome,
authority,
sealed,
lineage,
..
} = admission;
let digest =
egress::finish_admission_off_runtime(outcome, Some(bytes), original_bytes, agent_id)
.await
.map_err(|_| GatewayRefusal::unavailable(RECEIPT_UNAVAILABLE))?
.and_then(|evidence| evidence.admission_digest);
Ok((
digest,
SealedProducer {
context: sealed,
authority: Arc::new(authority),
lineage: lineage.map(Arc::new),
},
))
}
pub(crate) async fn admit_channel(
target: &EgressTarget<'_>,
binding: TrustedDestinationBinding<'_>,
principal: Option<&Caller>,
url: &str,
head: &RequestHead,
) -> Result<(), GatewayRefusal> {
let mut admission = admit_as(Payload::Opaque(0), target, binding, principal)?;
let url =
reqwest::Url::parse(url).map_err(|_| GatewayRefusal::withheld("destination.unbound"))?;
match bound_head(&admission, head, &url) {
Ok(reference) => admission.outcome = admission.outcome.with_request_head(reference),
Err(code) => {
discard(admission, 0, None).await;
return Err(GatewayRefusal::unavailable(code));
}
}
bind_delivery(admission, &[], 0).await.map(|_| ())
}
fn bound_head(
admission: &Admission,
head: &RequestHead,
url: &reqwest::Url,
) -> Result<Option<String>, &'static str> {
match head.reference(url) {
Ok(reference) => Ok(Some(reference)),
Err(code) if admission.outcome.receipt_required() => Err(code),
Err(_) => Ok(None),
}
}
pub(crate) struct TextAdmission {
pub(crate) admission: Admission,
pub(crate) rewritten: Option<String>,
}
pub(crate) fn admit_text(
text: &str,
target: &EgressTarget<'_>,
binding: TrustedDestinationBinding<'_>,
principal: Option<&Caller>,
) -> Result<TextAdmission, GatewayRefusal> {
let document = Value::String(text.to_owned());
let mut admission = admit_as(Payload::Document(&document), target, binding, principal)?;
let rewritten = match admission.take_rewritten() {
Some(Value::String(masked)) if masked.starts_with(WITHHELD_MARKER) => {
let listed = masked
.split_once('—')
.map_or("", |(_, codes)| codes)
.trim_end_matches(']');
let listed = listed.split(" · ").next().unwrap_or_default();
let codes: Vec<&str> = listed
.split(',')
.map(str::trim)
.filter(|code| !code.is_empty())
.collect();
admission = if codes.is_empty() {
admission.refused(&[FALLBACK_CODE])
} else {
admission.refused(&codes)
};
None
}
Some(Value::String(masked)) => Some(masked),
Some(_) => {
admission = admission.refused(&["egress.rewrite_unrepresentable"]);
None
}
None => None,
};
Ok(TextAdmission {
admission,
rewritten,
})
}
const TARGET_WITHHELD: &str = "egress.target_withheld";
pub(crate) async fn admit_request_target<'h>(
path_and_query: &str,
headers: impl IntoIterator<Item = (&'h HeaderName, &'h HeaderValue)>,
target: &EgressTarget<'_>,
binding: TrustedDestinationBinding<'_>,
principal: Option<&Caller>,
) -> Result<(), GatewayRefusal> {
let text = super::egress_target::target_text(
target.provider,
target.upstream_base,
path_and_query,
headers,
);
if text.is_empty() {
return Ok(());
}
let TextAdmission {
admission,
rewritten,
} = admit_text(&text, target, binding, principal)?;
if admission.is_refused() {
return Err(record_refusal(admission, text.len(), None).await);
}
if rewritten.is_some() {
let admission = admission.refused(&[TARGET_WITHHELD]);
return Err(record_refusal(admission, text.len(), None).await);
}
Ok(())
}
fn decode_body<'a>(parts: &Parts, body: &'a [u8], limit: usize) -> Option<Cow<'a, [u8]>> {
match request_body_encoding(parts) {
RequestBodyEncoding::Identity => Some(Cow::Borrowed(body)),
RequestBodyEncoding::Gzip => decode_gzip_bounded(body, limit).ok().map(Cow::Owned),
RequestBodyEncoding::Zstd => decode_zstd_bounded(body, limit).ok().map(Cow::Owned),
RequestBodyEncoding::Passthrough => None,
}
}
fn encode_body(parts: &Parts, logical: Vec<u8>) -> Option<Vec<u8>> {
match request_body_encoding(parts) {
RequestBodyEncoding::Identity => Some(logical),
RequestBodyEncoding::Gzip => encode_gzip(&logical).ok(),
RequestBodyEncoding::Zstd => encode_zstd(&logical).ok(),
RequestBodyEncoding::Passthrough => None,
}
}
fn is_json(text: &str) -> bool {
serde_json::from_str::<serde::de::IgnoredAny>(text).is_ok()
}
pub(crate) fn admit_passthrough(
parts: &Parts,
body: &[u8],
limit: usize,
target: &EgressTarget<'_>,
binding: TrustedDestinationBinding<'_>,
) -> Result<(Admission, Vec<u8>), GatewayRefusal> {
let principal = super::forward::sealed_principal(parts);
let principal = principal.as_ref();
let decoded = decode_body(parts, body, limit);
let text = decoded
.as_deref()
.and_then(|bytes| std::str::from_utf8(bytes).ok())
.filter(|text| !text.is_empty() && !text.contains('\0'));
let Some(text) = text else {
let admission = admit_as(Payload::Opaque(body.len()), target, binding, principal)?;
return Ok((admission, body.to_vec()));
};
let TextAdmission {
admission,
rewritten,
} = admit_text(text, target, binding, principal)?;
let Some(masked) = rewritten else {
return Ok((admission, body.to_vec()));
};
let encoded = if is_json(text) && !is_json(&masked) {
None
} else {
encode_body(parts, masked.into_bytes())
};
if let Some(bytes) = encoded {
return Ok((admission, bytes));
}
Ok((
admission.refused(&["egress.rewrite_unrepresentable"]),
Vec::new(),
))
}
#[derive(Debug)]
pub(crate) struct AdmittedBody {
bytes: Vec<u8>,
url: reqwest::Url,
head: RequestHead,
admission_digest: Option<Sha256Digest>,
producer: SealedProducer,
}
#[derive(Debug, Clone)]
pub(crate) struct RequestHead {
method: reqwest::Method,
headers: reqwest::header::HeaderMap,
}
impl RequestHead {
pub(crate) fn new(
method: reqwest::Method,
headers: impl IntoIterator<Item = (HeaderName, HeaderValue)>,
) -> Self {
let mut map = reqwest::header::HeaderMap::new();
for (name, value) in headers {
map.append(name, value);
}
Self {
method,
headers: map,
}
}
fn reference(&self, url: &reqwest::Url) -> Result<String, &'static str> {
receipt_store::keyed::request_head_ref(
self.method.as_str(),
url.as_str(),
self.headers
.iter()
.map(|(name, value)| (name.as_str(), value.as_bytes())),
)
}
}
const HEAD_UNBOUND: &str = "egress.head_unbound";
pub(crate) struct SendAttempt {
pub(crate) response: Result<reqwest::Response, reqwest::Error>,
pub(crate) recorded: bool,
}
fn bound_url(upstream_base: &str, url: &str) -> Option<reqwest::Url> {
let base = reqwest::Url::parse(upstream_base).ok()?;
let url = reqwest::Url::parse(url).ok()?;
(base.scheme() == url.scheme()
&& base.host_str().is_some()
&& base.host_str() == url.host_str()
&& base.port_or_known_default() == url.port_or_known_default()
&& base.username() == url.username()
&& base.password() == url.password())
.then_some(url)
}
impl AdmittedBody {
pub(crate) async fn bind(
mut admission: Admission,
bytes: Vec<u8>,
upstream_base: &str,
url: &str,
head: RequestHead,
original_bytes: usize,
agent_id: Option<String>,
) -> Result<Self, GatewayRefusal> {
if admission.is_refused() {
return Err(record_refusal(admission, original_bytes, agent_id).await);
}
let Some(url) = bound_url(upstream_base, url) else {
discard(admission, original_bytes, agent_id).await;
return Err(GatewayRefusal::withheld("destination.unbound"));
};
match bound_head(&admission, &head, &url) {
Ok(reference) => {
admission.outcome = admission.outcome.with_request_head(reference);
}
Err(code) => {
discard(admission, original_bytes, agent_id).await;
return Err(GatewayRefusal::unavailable(code));
}
}
let (admission_digest, producer) =
seal(admission, bytes.clone(), original_bytes, agent_id).await?;
Ok(Self {
bytes,
url,
head,
admission_digest,
producer,
})
}
pub(crate) fn bytes(&self) -> &[u8] {
&self.bytes
}
pub(crate) fn sealed_recorder(&self) -> sealed_origin::Recorder {
sealed_origin::Recorder::new(self.producer.clone())
}
#[cfg(test)]
pub(crate) fn admission_digest(&self) -> Option<&Sha256Digest> {
self.admission_digest.as_ref()
}
pub(crate) async fn send(
&self,
client: &reqwest::Client,
attempt: u32,
decorate: impl FnOnce(reqwest::RequestBuilder) -> reqwest::RequestBuilder,
) -> Result<SendAttempt, GatewayRefusal> {
let prove = || {
self.producer
.authority
.verify_current()
.inspect_err(|refusal| {
tracing::warn!(
"lean-ctx proxy: attempt {attempt} not sent: {}",
refusal.message()
);
})
};
prove()?;
let request = decorate(
client
.request(self.head.method.clone(), self.url.clone())
.headers(self.head.headers.clone()),
)
.body(self.bytes.clone())
.build()
.map_err(|_| GatewayRefusal::unavailable("egress.request_unbuildable"))?;
if request.method() != self.head.method
|| request.url() != &self.url
|| request.headers() != &self.head.headers
{
tracing::warn!("lean-ctx proxy: attempt {attempt} not sent: {HEAD_UNBOUND}");
return Err(GatewayRefusal::withheld(HEAD_UNBOUND));
}
prove()?;
let response = client.execute(request).await;
let recorded = super::egress_observation::record_http_attempt(
self.admission_digest.as_ref(),
attempt,
&response,
)
.await;
Ok(SendAttempt { response, recorded })
}
}
#[cfg(test)]
#[path = "admitted_tests.rs"]
mod tests;