Skip to main content

cognee_http_server/
multipart.rs

1//! Shared multipart-body parsing helpers.
2//!
3//! Provides [`parse_multipart`] — a generic drain over an `axum::extract::Multipart`
4//! stream that classifies each part as either an in-memory **form field** (≤ 4 KiB)
5//! or a **spooled file** written to a per-request temp directory.
6//!
7//! Per-router validation (filename traversal checks, URL-body detection, extension
8//! checks, …) belongs in each router's own parse adapter, **not** here.
9
10use std::collections::HashMap;
11use std::path::{Path, PathBuf};
12
13use axum::extract::Multipart;
14use bytes::Bytes;
15use tokio::io::AsyncWriteExt;
16
17use crate::error::ApiError;
18
19/// Options that control multipart parsing.
20pub struct MultipartOpts {
21    /// Maximum total number of parts (form fields + files combined).
22    pub max_parts: usize,
23    /// Maximum byte size of a form-field value (anything exceeding this is rejected).
24    pub form_field_max_bytes: usize,
25    /// Maximum byte size for a spooled file part.
26    pub file_max_bytes: usize,
27    /// Base directory for spooled temp files.  Each request gets its own sub-dir.
28    pub spool_dir: PathBuf,
29}
30
31impl Default for MultipartOpts {
32    fn default() -> Self {
33        Self {
34            max_parts: 256,
35            form_field_max_bytes: 4096,
36            file_max_bytes: 1024 * 1024 * 1024, // 1 GiB
37            spool_dir: std::env::temp_dir().join("cognee-uploads"),
38        }
39    }
40}
41
42/// A single spooled-to-disk file part.
43#[derive(Debug)]
44pub struct SpooledFile {
45    /// Original filename reported by the client (NOT sanitized for path safety —
46    /// callers must validate before using as a file system component).
47    pub filename: Option<String>,
48    /// Content-Type header value from the part, if present.
49    pub content_type: Option<String>,
50    /// Absolute path of the spooled temp file.
51    pub path: PathBuf,
52    /// Number of bytes written to disk.
53    pub byte_count: u64,
54}
55
56/// Result of a [`parse_multipart`] call.
57pub struct ParsedForm {
58    /// In-memory form fields keyed by part name.
59    /// Repeated names collect into `Vec<String>` (the outer map value is always
60    /// `Vec<String>` to handle repeated fields without losing data).
61    pub fields: HashMap<String, Vec<String>>,
62    /// Spooled files keyed by part name.  Multiple parts with the same name
63    /// accumulate in the `Vec`.
64    pub files: HashMap<String, Vec<SpooledFile>>,
65    /// Directory where all spooled files in this request live.  Callers should
66    /// either move/use the files before dropping this struct or wrap it in an
67    /// [`UploadGuard`].
68    pub spool_dir: PathBuf,
69}
70
71// ─── UploadGuard ─────────────────────────────────────────────────────────────
72
73/// RAII wrapper that removes the per-request spool directory on `Drop`.
74///
75/// Wrap the `ParsedForm` (or just the `spool_dir` path) in this guard so that
76/// failures — validation errors, pipeline panics, early returns — always clean
77/// up the temp files.
78pub struct UploadGuard {
79    spool_dir: PathBuf,
80}
81
82impl UploadGuard {
83    /// Create a guard for `dir`.  The directory is removed recursively when the
84    /// guard is dropped (best-effort — errors are silently ignored).
85    pub fn new(dir: PathBuf) -> Self {
86        Self { spool_dir: dir }
87    }
88
89    /// Return the protected directory path.
90    pub fn dir(&self) -> &Path {
91        &self.spool_dir
92    }
93}
94
95impl Drop for UploadGuard {
96    fn drop(&mut self) {
97        let _ = std::fs::remove_dir_all(&self.spool_dir);
98    }
99}
100
101// ─── parse_multipart ─────────────────────────────────────────────────────────
102
103/// Drain an axum `Multipart` stream into a [`ParsedForm`].
104///
105/// Parts whose name is absent are silently skipped.
106///
107/// # Errors
108/// Returns [`ApiError::BadRequest`] when:
109/// - more than `opts.max_parts` parts are seen,
110/// - a non-file part exceeds `opts.form_field_max_bytes`,
111/// - a file part exceeds `opts.file_max_bytes`.
112pub async fn parse_multipart(
113    mut multipart: Multipart,
114    opts: &MultipartOpts,
115    request_id: &str,
116) -> Result<ParsedForm, ApiError> {
117    let spool_dir = opts.spool_dir.join(sanitize_path_component(request_id));
118    tokio::fs::create_dir_all(&spool_dir)
119        .await
120        .map_err(|e| ApiError::Internal(anyhow::anyhow!("failed to create spool dir: {e}")))?;
121
122    let mut fields: HashMap<String, Vec<String>> = HashMap::new();
123    let mut files: HashMap<String, Vec<SpooledFile>> = HashMap::new();
124    let mut part_count = 0usize;
125
126    while let Some(field) = multipart
127        .next_field()
128        .await
129        .map_err(|e| ApiError::BadRequest(format!("multipart parse error: {e}")))?
130    {
131        part_count += 1;
132        if part_count > opts.max_parts {
133            return Err(ApiError::BadRequest(format!(
134                "Too many parts (max {})",
135                opts.max_parts
136            )));
137        }
138
139        let name = match field.name() {
140            Some(n) => n.to_owned(),
141            None => continue, // skip nameless parts
142        };
143
144        let filename = field.file_name().map(|s| s.to_owned());
145        let content_type = field.content_type().map(|s| s.to_owned());
146
147        // Decide: file part or form field?
148        // Treat a part as a file if it has a filename OR has a non-text content-type.
149        let is_file = filename.is_some()
150            || content_type
151                .as_deref()
152                .map(|ct| !ct.starts_with("text/"))
153                .unwrap_or(false);
154
155        if is_file {
156            let safe_name = filename
157                .as_deref()
158                .map(sanitize_path_component)
159                .unwrap_or_else(|| format!("part-{part_count}"));
160            let dest = spool_dir.join(format!("{part_count}-{safe_name}"));
161            let byte_count = stream_to_disk(field, &dest, opts.file_max_bytes).await?;
162            files.entry(name).or_default().push(SpooledFile {
163                filename,
164                content_type,
165                path: dest,
166                byte_count,
167            });
168        } else {
169            // Form field — buffer in memory with a size cap.
170            let data: Bytes = field
171                .bytes()
172                .await
173                .map_err(|e| ApiError::BadRequest(format!("form field read error: {e}")))?;
174            if data.len() > opts.form_field_max_bytes {
175                return Err(ApiError::BadRequest(format!(
176                    "Form field {name} exceeds {} bytes",
177                    opts.form_field_max_bytes
178                )));
179            }
180            let value = String::from_utf8_lossy(&data).into_owned();
181            fields.entry(name).or_default().push(value);
182        }
183    }
184
185    Ok(ParsedForm {
186        fields,
187        files,
188        spool_dir,
189    })
190}
191
192// ─── stream_to_disk ──────────────────────────────────────────────────────────
193
194/// Stream a multipart `field` to `dest`, enforcing a `max_bytes` limit.
195///
196/// Returns the total byte count written.
197async fn stream_to_disk(
198    field: axum::extract::multipart::Field<'_>,
199    dest: &Path,
200    max_bytes: usize,
201) -> Result<u64, ApiError> {
202    let mut file = tokio::fs::File::create(dest)
203        .await
204        .map_err(|e| ApiError::Internal(anyhow::anyhow!("failed to create spool file: {e}")))?;
205
206    let data: Bytes = field
207        .bytes()
208        .await
209        .map_err(|e| ApiError::BadRequest(format!("file read error: {e}")))?;
210
211    if data.len() > max_bytes {
212        return Err(ApiError::BadRequest(format!(
213            "file part exceeds maximum size of {max_bytes} bytes"
214        )));
215    }
216
217    file.write_all(&data)
218        .await
219        .map_err(|e| ApiError::Internal(anyhow::anyhow!("failed to write spool file: {e}")))?;
220
221    Ok(data.len() as u64)
222}
223
224// ─── helpers ─────────────────────────────────────────────────────────────────
225
226/// Replace any filesystem-unsafe characters with `_` and truncate to 200 chars.
227pub fn sanitize_path_component(name: &str) -> String {
228    let sanitized: String = name
229        .chars()
230        .map(|c| {
231            if c == '/'
232                || c == '\\'
233                || c == ':'
234                || c == '*'
235                || c == '?'
236                || c == '"'
237                || c == '<'
238                || c == '>'
239                || c == '|'
240                || c == '\0'
241            {
242                '_'
243            } else {
244                c
245            }
246        })
247        .collect();
248    // Truncate to 200 bytes (safe for most filesystems).
249    let mut truncated = sanitized;
250    while truncated.len() > 200 {
251        truncated.pop();
252    }
253    truncated
254}
255
256/// Check a filename for path traversal sequences.
257///
258/// Returns `Err` with an [`ApiError::BadRequest`] if the name contains `../`,
259/// `..\`, or starts with `/`.
260#[allow(clippy::result_large_err)] // ApiError is inherently large; boxing at this level adds noise
261pub fn check_filename_traversal(filename: &str) -> Result<(), ApiError> {
262    if filename.contains("../")
263        || filename.contains("..\\")
264        || filename.starts_with('/')
265        || filename.starts_with('\\')
266    {
267        return Err(ApiError::BadRequest(format!(
268            "Invalid filename: {filename}"
269        )));
270    }
271    Ok(())
272}