Skip to main content

gproxy_protocol/adapt/files/
upload.rs

1use crate::{
2    HttpBody, WireRequest, WireResponse,
3    adapt::JsonInvocation,
4    capability::Upstream,
5    codec::{self, CodecErrorKind, CodecLimits, MultipartEncoder},
6    connection::MultipartPart,
7    transform::{TransformError, TransformErrorKind},
8    wire::{DeclaredFields, gemini::files as g},
9};
10use serde::de::DeserializeOwned;
11
12#[derive(Debug)]
13pub enum UploadFailure {
14    Transform(TransformError),
15    Rejected(Box<WireResponse<HttpBody>>),
16}
17
18/// A request may have reached upstream even if its transport future failed.
19/// `attempted_calls` is not permission to retry a side effect.
20#[derive(Debug)]
21pub struct UploadError {
22    pub attempted_calls: usize,
23    pub failure: UploadFailure,
24}
25
26impl From<TransformError> for UploadError {
27    fn from(error: TransformError) -> Self {
28        Self {
29            attempted_calls: 0,
30            failure: UploadFailure::Transform(error),
31        }
32    }
33}
34
35fn after(error: TransformError) -> UploadError {
36    UploadError {
37        attempted_calls: 1,
38        failure: UploadFailure::Transform(error),
39    }
40}
41
42fn codec_error(error: codec::CodecError, response: bool) -> TransformError {
43    let kind = match error.kind() {
44        CodecErrorKind::Limit => TransformErrorKind::Limit,
45        CodecErrorKind::Transport => TransformErrorKind::Host,
46        CodecErrorKind::Invalid
47        | CodecErrorKind::UnexpectedEof
48        | CodecErrorKind::Utf8
49        | CodecErrorKind::Json
50        | CodecErrorKind::Multipart => {
51            if response {
52                TransformErrorKind::InvalidResult
53            } else {
54                TransformErrorKind::InvalidInput
55            }
56        }
57    };
58    TransformError::with_source(kind, "file.upload", error.to_string(), error)
59}
60
61fn bounded(mut limits: CodecLimits, bytes: u64) -> CodecLimits {
62    limits.max_body_bytes = limits.max_body_bytes.min(bytes);
63    limits.max_value_bytes = limits.max_value_bytes.min(bytes);
64    limits.max_buffer_bytes = limits.max_buffer_bytes.min(bytes);
65    limits
66}
67
68fn clean(headers: &mut http::HeaderMap) {
69    for key in [
70        http::header::CONTENT_LENGTH,
71        http::header::CONTENT_ENCODING,
72        http::header::TRANSFER_ENCODING,
73    ] {
74        headers.remove(key);
75    }
76}
77
78async fn decode<O: DeserializeOwned + DeclaredFields>(
79    response: WireResponse<HttpBody>,
80    limits: CodecLimits,
81) -> Result<JsonInvocation<O>, TransformError> {
82    if !response.status.is_success() {
83        return Ok(JsonInvocation::Rejected(response));
84    }
85    let WireResponse {
86        status,
87        mut headers,
88        body,
89    } = response;
90    let bytes = codec::read_http_body(body, limits)
91        .await
92        .map_err(|e| codec_error(e, true))?;
93    let body: O = codec::decode_json(&bytes, limits).map_err(|e| codec_error(e, true))?;
94    clean(&mut headers);
95    headers.insert(
96        http::header::CONTENT_TYPE,
97        http::HeaderValue::from_static("application/json"),
98    );
99    Ok(JsonInvocation::Success(WireResponse {
100        status,
101        headers,
102        body: body.into_declared(),
103    }))
104}
105
106pub async fn upload_multipart_json<U: Upstream, O: DeserializeOwned + DeclaredFields>(
107    upstream: &U,
108    target: &U::Target,
109    mut template: WireRequest<()>,
110    boundary: String,
111    parts: Vec<MultipartPart>,
112    limits: CodecLimits,
113) -> Result<JsonInvocation<O>, UploadError> {
114    super::path(&template.path)?;
115    if template.method != http::Method::POST {
116        return Err(TransformError::shape("upload.method", "POST required").into());
117    }
118    let host = upstream.limits();
119    let encoder = MultipartEncoder::new(boundary.clone(), parts, bounded(limits, host.write_bytes))
120        .map_err(|e| codec_error(e, false))?;
121    let body = futures_util::stream::try_unfold(encoder, |mut encoder| async move {
122        encoder
123            .next_chunk()
124            .await
125            .map(|chunk| chunk.map(|chunk| (chunk, encoder)))
126            .map_err(crate::connection::TransportError::from)
127    });
128    clean(&mut template.headers);
129    template.headers.insert(
130        http::header::CONTENT_TYPE,
131        http::HeaderValue::from_str(&format!("multipart/form-data; boundary={boundary}"))
132            .map_err(|e| TransformError::shape("content_type", e.to_string()))?,
133    );
134    let response = upstream
135        .send(
136            target,
137            WireRequest {
138                method: template.method,
139                path: template.path,
140                query: template.query,
141                headers: template.headers,
142                body: HttpBody::Stream(Box::pin(body)),
143            },
144        )
145        .await
146        .map_err(|e| after(e.into()))?;
147    decode(response, bounded(limits, host.read_bytes))
148        .await
149        .map_err(after)
150}
151
152#[derive(Debug, Clone)]
153pub struct GeminiUploadSession {
154    upload_path: String,
155    query: Option<String>,
156    origin: String,
157    offset: u64,
158    size: u64,
159    closed: bool,
160}
161
162impl GeminiUploadSession {
163    pub fn offset(&self) -> u64 {
164        self.offset
165    }
166    pub fn size(&self) -> u64 {
167        self.size
168    }
169    pub fn origin(&self) -> &str {
170        &self.origin
171    }
172    pub fn is_closed(&self) -> bool {
173        self.closed
174    }
175}
176
177fn origin(value: &str) -> Result<String, TransformError> {
178    let uri: http::Uri = value.parse().map_err(|e: http::uri::InvalidUri| {
179        TransformError::shape("upload.origin", e.to_string())
180    })?;
181    if uri.scheme_str() != Some("https")
182        || uri.authority().is_none()
183        || uri.authority().unwrap().as_str().contains('@')
184        || !matches!(uri.path(), "" | "/")
185        || uri.query().is_some()
186    {
187        return Err(TransformError::shape(
188            "upload.origin",
189            "HTTPS origin without userinfo/path/query required",
190        ));
191    }
192    Ok(format!(
193        "https://{}",
194        uri.authority().unwrap().as_str().to_ascii_lowercase()
195    ))
196}
197
198#[allow(clippy::too_many_arguments)] // Explicit host target, metadata and origin facts.
199pub async fn gemini_start_resumable<U: Upstream>(
200    upstream: &U,
201    target: &U::Target,
202    mut template: WireRequest<()>,
203    metadata: g::UploadFileMetadata,
204    size: u64,
205    mime: &str,
206    expected_origin: &str,
207    limits: CodecLimits,
208) -> Result<GeminiUploadSession, UploadError> {
209    let expected_origin = origin(expected_origin)?;
210    super::path(&template.path)?;
211    if template.method != http::Method::POST || mime.is_empty() {
212        return Err(TransformError::shape("upload.start", "POST and MIME required").into());
213    }
214    let body = codec::encode_json(
215        &metadata.into_declared(),
216        bounded(limits, upstream.limits().write_bytes),
217    )
218    .map_err(|e| codec_error(e, false))?;
219    clean(&mut template.headers);
220    for (name, value) in [
221        ("x-goog-upload-protocol", "resumable".to_owned()),
222        ("x-goog-upload-command", "start".to_owned()),
223        ("x-goog-upload-header-content-length", size.to_string()),
224        ("x-goog-upload-header-content-type", mime.to_owned()),
225        ("content-type", "application/json".to_owned()),
226    ] {
227        template.headers.insert(
228            http::header::HeaderName::from_bytes(name.as_bytes()).unwrap(),
229            http::HeaderValue::from_str(&value)
230                .map_err(|e| TransformError::shape("upload.headers", e.to_string()))?,
231        );
232    }
233    let response = upstream
234        .send(
235            target,
236            WireRequest {
237                method: template.method,
238                path: template.path,
239                query: template.query,
240                headers: template.headers,
241                body: HttpBody::Bytes(body),
242            },
243        )
244        .await
245        .map_err(|e| after(e.into()))?;
246    if !response.status.is_success() {
247        return Err(UploadError {
248            attempted_calls: 1,
249            failure: UploadFailure::Rejected(Box::new(response)),
250        });
251    }
252    let url = response
253        .headers
254        .get("x-goog-upload-url")
255        .ok_or_else(|| after(TransformError::missing_metadata("x-goog-upload-url")))?
256        .to_str()
257        .map_err(|e| after(TransformError::invalid_result("upload_url", e.to_string())))?;
258    let parsed: http::Uri = url.parse().map_err(|e: http::uri::InvalidUri| {
259        after(TransformError::invalid_result("upload_url", e.to_string()))
260    })?;
261    let received = format!(
262        "{}://{}",
263        parsed.scheme_str().unwrap_or(""),
264        parsed.authority().map(|a| a.as_str()).unwrap_or("")
265    );
266    if origin(&received).map_err(after)? != expected_origin {
267        return Err(after(TransformError::invalid_result(
268            "upload_url",
269            "session origin differs from selected target",
270        )));
271    }
272    super::path(parsed.path()).map_err(after)?;
273    Ok(GeminiUploadSession {
274        upload_path: parsed.path().into(),
275        query: parsed.query().map(str::to_owned),
276        origin: expected_origin,
277        offset: 0,
278        size,
279        closed: false,
280    })
281}
282
283#[derive(Debug)]
284pub enum GeminiUploadProgress {
285    Accepted(http::StatusCode),
286    Finalized(Box<WireResponse<g::UploadFileResponseBody>>),
287}
288
289#[allow(clippy::too_many_arguments)] // Chunk data and target-origin validation remain explicit.
290pub async fn gemini_upload_chunk<U: Upstream>(
291    upstream: &U,
292    target: &U::Target,
293    session: &mut GeminiUploadSession,
294    headers: http::HeaderMap,
295    expected_origin: &str,
296    chunk: bytes::Bytes,
297    finalize: bool,
298    limits: CodecLimits,
299) -> Result<GeminiUploadProgress, UploadError> {
300    if session.closed {
301        return Err(TransformError::shape(
302            "upload.session",
303            "session is closed or outcome uncertain",
304        )
305        .into());
306    }
307    if origin(expected_origin)? != session.origin {
308        return Err(TransformError::shape(
309            "upload.origin",
310            "selected target origin differs from session",
311        )
312        .into());
313    }
314    let next = session
315        .offset
316        .checked_add(chunk.len() as u64)
317        .ok_or_else(|| {
318            TransformError::new(TransformErrorKind::Limit, "upload.offset", "overflow")
319        })?;
320    if next > session.size || finalize && next != session.size {
321        return Err(TransformError::shape(
322            "upload.size",
323            "chunk/finalize does not match declared size",
324        )
325        .into());
326    }
327    if chunk.len() as u64 > upstream.limits().write_bytes.min(limits.max_body_bytes) {
328        return Err(TransformError::new(
329            TransformErrorKind::Limit,
330            "upload.chunk",
331            "chunk exceeds write limit",
332        )
333        .into());
334    }
335    let mut headers = headers;
336    clean(&mut headers);
337    headers.insert(
338        "x-goog-upload-command",
339        http::HeaderValue::from_static(if finalize {
340            "upload, finalize"
341        } else {
342            "upload"
343        }),
344    );
345    headers.insert(
346        "x-goog-upload-offset",
347        http::HeaderValue::from_str(&session.offset.to_string()).unwrap(),
348    );
349    headers.insert(
350        http::header::CONTENT_LENGTH,
351        http::HeaderValue::from_str(&chunk.len().to_string()).unwrap(),
352    );
353    // Mark uncertain before awaiting: cancellation or transport failure must not
354    // silently permit replay of a possibly applied chunk.
355    session.closed = true;
356    let response = upstream
357        .send(
358            target,
359            WireRequest {
360                method: http::Method::POST,
361                path: session.upload_path.clone(),
362                query: session.query.clone(),
363                headers,
364                body: HttpBody::Bytes(chunk),
365            },
366        )
367        .await
368        .map_err(|e| after(e.into()))?;
369    if !response.status.is_success() {
370        return Err(UploadError {
371            attempted_calls: 1,
372            failure: UploadFailure::Rejected(Box::new(response)),
373        });
374    }
375    if finalize {
376        match decode(response, bounded(limits, upstream.limits().read_bytes))
377            .await
378            .map_err(after)?
379        {
380            JsonInvocation::Success(response) => {
381                session.offset = next;
382                Ok(GeminiUploadProgress::Finalized(Box::new(response)))
383            }
384            JsonInvocation::Rejected(_) => unreachable!("checked status"),
385        }
386    } else {
387        let status = response.status;
388        codec::read_http_body(response.body, bounded(limits, upstream.limits().read_bytes))
389            .await
390            .map_err(|e| after(codec_error(e, true)))?;
391        session.offset = next;
392        session.closed = false;
393        Ok(GeminiUploadProgress::Accepted(status))
394    }
395}