Skip to main content

adminx_core/
resource.rs

1// adminx-core/src/resource.rs
2//
3// The framework-neutral Resource trait. Default CRUD is expressed purely in
4// terms of `ReqCtx` -> `ApiResponse` and the global `Storage`, so a single
5// implementation serves Actix, Axum, or any future adapter, over SQL or Mongo.
6
7use crate::actions::CustomAction;
8use crate::authz::Action;
9use crate::crud;
10use crate::error::CoreError;
11use crate::export::{rows_to_csv, EXPORT_CAP};
12use crate::filters::parse_query;
13use crate::menu::{MenuAction, MenuItem};
14use crate::request::ReqCtx;
15use crate::response::{ApiBody, ApiResponse};
16use crate::storage::{storage, QueryOptions};
17use crate::ui;
18use async_trait::async_trait;
19use serde_json::{json, Map, Value};
20use std::collections::HashMap;
21use std::collections::HashSet;
22
23#[async_trait]
24pub trait Resource: Send + Sync {
25    // ===== REQUIRED =====
26    fn resource_name(&self) -> &'static str;
27    fn base_path(&self) -> &'static str;
28    /// Backing table (SQL) or collection (Mongo) name.
29    fn table_name(&self) -> &'static str;
30    fn clone_box(&self) -> Box<dyn Resource>;
31
32    // ===== CONFIG (defaults) =====
33    fn primary_key(&self) -> &'static str {
34        "id"
35    }
36    fn menu_group(&self) -> Option<&'static str> {
37        None
38    }
39    fn menu(&self) -> &'static str {
40        self.resource_name()
41    }
42    fn allowed_roles(&self) -> Vec<String> {
43        vec!["admin".to_string()]
44    }
45    fn allowed_actions(&self) -> Option<Vec<MenuAction>> {
46        None
47    }
48
49    /// Extra id-scoped operations beyond CRUD, exposed at
50    /// `POST /{base}/{id}/action/{name}` and as buttons on the detail page.
51    fn custom_actions(&self) -> Vec<CustomAction> {
52        vec![]
53    }
54    /// Mass-assignment allow-list for create/update.
55    fn permit_keys(&self) -> Vec<&'static str> {
56        vec![]
57    }
58    /// Columns that must never be client-set.
59    fn readonly_keys(&self) -> Vec<&'static str> {
60        vec!["id", "created_at", "updated_at"]
61    }
62    /// Whether delete should soft-delete (set `deleted = true`).
63    fn soft_delete(&self) -> bool {
64        self.permit_keys().contains(&"deleted")
65    }
66
67    /// Custom form layout for create/edit pages. Return `None` to derive fields
68    /// from `permit_keys()`. Shape: `{ "groups": [{ "fields": [...] }] }`.
69    fn form_structure(&self) -> Option<Value> {
70        None
71    }
72
73    /// Columns exposed as filters on the list page. Empty (the default) means no
74    /// filter bar is shown. Build entries with `FilterField::text/select/boolean`.
75    fn filterable_fields(&self) -> Vec<crate::filters::FilterField> {
76        Vec::new()
77    }
78
79    /// File attachments this resource accepts. Empty (the default) means no
80    /// upload widgets are shown. Each field renders an `<input type=file>` on the
81    /// detail page, backed by the attach/serve/detach routes. Requires an
82    /// attachment backend (`adminx-storage`) to be registered; without one the
83    /// widgets are hidden.
84    fn file_fields(&self) -> Vec<crate::attach::FileField> {
85        Vec::new()
86    }
87
88    /// Columns included in this resource's full-text search document. Empty (the
89    /// default) means the resource is not searchable — no search box, no
90    /// indexing. When non-empty *and* a search backend (`adminx-search`) is
91    /// registered, create/update keep the index in sync, delete removes the
92    /// record, and the list page grows a `?q=` search box.
93    fn search_fields(&self) -> Vec<&'static str> {
94        Vec::new()
95    }
96
97    // ===== DEFAULT CRUD =====
98
99    async fn list(&self, ctx: &ReqCtx) -> ApiResponse {
100        if !self.authorize(ctx, Action::List) {
101            return CoreError::Unauthorized.into();
102        }
103        let opts = parse_query(&ctx.query);
104        match storage().list(self.table_name(), &opts).await {
105            Ok(page) => ApiResponse::ok(json!({
106                "data": page.rows,
107                "total": page.total,
108                "page": opts.page,
109                "per_page": opts.per_page,
110            })),
111            Err(e) => CoreError::from(e).into(),
112        }
113    }
114
115    async fn get(&self, ctx: &ReqCtx, id: &str) -> ApiResponse {
116        if !self.authorize(ctx, Action::Read) {
117            return CoreError::Unauthorized.into();
118        }
119        match storage().get(self.table_name(), self.primary_key(), id).await {
120            Ok(Some(row)) => ApiResponse::ok(row),
121            Ok(None) => CoreError::NotFound.into(),
122            Err(e) => CoreError::from(e).into(),
123        }
124    }
125
126    /// Create. The body lives in [`crud::create`](crate::crud::create) so an
127    /// overriding impl can delegate to it and keep the audit recording; see that
128    /// module for why.
129    async fn create(&self, ctx: &ReqCtx, body: Value) -> ApiResponse {
130        crud::create(self, ctx, body).await
131    }
132
133    /// Update. Body in [`crud::update`](crate::crud::update).
134    async fn update(&self, ctx: &ReqCtx, id: &str, body: Value) -> ApiResponse {
135        crud::update(self, ctx, id, body).await
136    }
137
138    /// Delete. Body in [`crud::delete`](crate::crud::delete).
139    async fn delete(&self, ctx: &ReqCtx, id: &str) -> ApiResponse {
140        crud::delete(self, ctx, id).await
141    }
142
143    // ===== HTML UI PAGES (served identically by every web adapter) =====
144
145    /// The form fields to render on create/edit. Derived from an explicit
146    /// `form_structure()` when present, otherwise from `permit_keys()`.
147    fn form_fields(&self) -> Vec<Value> {
148        match self.form_structure() {
149            Some(structure) => ui::fields_from_structure(&structure),
150            None => ui::default_fields(&self.permit_keys()),
151        }
152    }
153
154    async fn list_page(&self, ctx: &ReqCtx) -> ApiResponse {
155        if !self.authorize(ctx, Action::List) {
156            return crate::auth::login_redirect(ctx);
157        }
158
159        // `?download=json|csv` exports instead of rendering the table.
160        let params: HashMap<String, String> =
161            serde_urlencoded::from_str(&ctx.query).unwrap_or_default();
162        if let Some(format) = params.get("download") {
163            return self.export(ctx, format).await;
164        }
165
166        let mut opts = parse_query(&ctx.query);
167        let filter_fields = self.filterable_fields();
168        opts.filters = crate::filters::parse_filters(&ctx.query, &filter_fields);
169
170        // Raw input values for repopulating the form (handles date-range
171        // from/to keys, which the clause list can't represent one-to-one).
172        let current_filters = crate::filters::filter_values(&ctx.query, &filter_fields);
173
174        // A searchable resource shows a search box. A `?q=` term switches the
175        // table from the normal paginated list to full-text results: ask the
176        // index for matching ids in rank order, then hydrate the rows.
177        let searchable = crate::search::is_enabled() && !self.search_fields().is_empty();
178        let search_term = if searchable {
179            crate::search::query_term(ctx)
180        } else {
181            None
182        };
183
184        let page = if let Some(q) = &search_term {
185            let ids = crate::search::search_ids(self.base_path(), q, opts.per_page as usize).await;
186            let mut rows = Vec::new();
187            for id in &ids {
188                if let Ok(Some(row)) =
189                    storage().get(self.table_name(), self.primary_key(), id).await
190                {
191                    rows.push(row);
192                }
193            }
194            let total = rows.len() as u64;
195            crate::storage::ListPage { rows, total }
196        } else {
197            match storage().list(self.table_name(), &opts).await {
198                Ok(p) => p,
199                Err(e) => return CoreError::from(e).into(),
200            }
201        };
202        let headers = ui::derive_headers(&page.rows, self.primary_key());
203
204        let mut c = ui::base_context(ctx, self.resource_name());
205        c.insert("resource_name", self.resource_name());
206        c.insert("base_path", self.base_path());
207        c.insert("pk", self.primary_key());
208        c.insert("headers", &headers);
209        c.insert("rows", &page.rows);
210        c.insert("total", &page.total);
211        c.insert("page", &opts.page);
212        c.insert("per_page", &opts.per_page);
213        c.insert("filter_fields", &filter_fields);
214        c.insert("current_filters", &current_filters);
215        c.insert("has_filters", &(!filter_fields.is_empty()));
216        c.insert("has_active_filters", &(!opts.filters.is_empty()));
217        c.insert("searchable", &searchable);
218        c.insert("search_term", &search_term.clone().unwrap_or_default());
219        // Each row carries a delete form, so the page needs a CSRF token.
220        ui::render_with_csrf(ctx, c, "list.html")
221    }
222
223    async fn new_page(&self, ctx: &ReqCtx) -> ApiResponse {
224        if !self.authorize(ctx, Action::Create) {
225            return crate::auth::login_redirect(ctx);
226        }
227        let mut c = ui::base_context(ctx, self.resource_name());
228        c.insert("resource_name", self.resource_name());
229        c.insert("base_path", self.base_path());
230        c.insert("fields", &self.form_fields());
231        c.insert("is_edit", &false);
232        c.insert("record", &json!({}));
233        ui::render_with_csrf(ctx, c, "form.html")
234    }
235
236    async fn edit_page(&self, ctx: &ReqCtx, id: &str) -> ApiResponse {
237        if !self.authorize(ctx, Action::Update) {
238            return crate::auth::login_redirect(ctx);
239        }
240        let record = match storage().get(self.table_name(), self.primary_key(), id).await {
241            Ok(Some(r)) => r,
242            Ok(None) => return CoreError::NotFound.into(),
243            Err(e) => return CoreError::from(e).into(),
244        };
245        let mut c = ui::base_context(ctx, self.resource_name());
246        c.insert("resource_name", self.resource_name());
247        c.insert("base_path", self.base_path());
248        c.insert("fields", &self.form_fields());
249        c.insert("is_edit", &true);
250        c.insert("item_id", &id);
251        c.insert("record", &record);
252        ui::render_with_csrf(ctx, c, "form.html")
253    }
254
255    async fn view_page(&self, ctx: &ReqCtx, id: &str) -> ApiResponse {
256        if !self.authorize(ctx, Action::Read) {
257            return crate::auth::login_redirect(ctx);
258        }
259        let record = match storage().get(self.table_name(), self.primary_key(), id).await {
260            Ok(Some(r)) => r,
261            Ok(None) => return CoreError::NotFound.into(),
262            Err(e) => return CoreError::from(e).into(),
263        };
264        let headers = ui::derive_headers(std::slice::from_ref(&record), self.primary_key());
265        let actions: Vec<Value> = self
266            .custom_actions()
267            .iter()
268            .map(|a| json!({ "name": a.name, "label": a.display_label() }))
269            .collect();
270
271        let mut c = ui::base_context(ctx, self.resource_name());
272        c.insert("resource_name", self.resource_name());
273        c.insert("base_path", self.base_path());
274        c.insert("item_id", &id);
275        c.insert("headers", &headers);
276        c.insert("record", &record);
277        c.insert("actions", &actions);
278        // The History link is pointless without a log behind it.
279        c.insert("audit_enabled", &crate::audit::is_enabled());
280
281        // File fields: render an upload widget per declared field, pre-filled
282        // with whatever is already attached. Only when a backend is registered —
283        // otherwise the widgets would post to routes that can't store anything.
284        let file_fields = self.file_fields();
285        let show_files = crate::attach::is_enabled() && !file_fields.is_empty();
286        if show_files {
287            let attached = crate::attach::list(self.base_path(), id).await;
288            let widgets: Vec<Value> = file_fields
289                .iter()
290                .map(|f| {
291                    let current = attached.iter().find(|a| a.field == f.name);
292                    json!({
293                        "name": f.name,
294                        "label": f.label,
295                        "accept": f.accept,
296                        "filename": current.map(|a| a.filename.clone()),
297                        "byte_size": current.map(|a| a.byte_size),
298                        "content_type": current.map(|a| a.content_type.clone()),
299                    })
300                })
301                .collect();
302            c.insert("file_fields", &widgets);
303        }
304        c.insert("show_files", &show_files);
305
306        // The detail page renders a POST form per custom action.
307        ui::render_with_csrf(ctx, c, "view.html")
308    }
309
310    /// Store an uploaded file against this record under `field`. Called by the
311    /// web adapter after it parses the multipart body; redirects back to the
312    /// detail page on success. Gated on `Update` — attaching a file changes the
313    /// record.
314    async fn attach_file(
315        &self,
316        ctx: &ReqCtx,
317        id: &str,
318        field: &str,
319        csrf: Option<String>,
320        file: crate::attach::UploadedFile,
321    ) -> ApiResponse {
322        if !self.authorize(ctx, Action::Update) {
323            return crate::auth::login_redirect(ctx);
324        }
325        if let Some(reject) = csrf_guard(ctx, csrf) {
326            return reject;
327        }
328        // Only fields the resource actually declared are attachable, so the
329        // endpoint can't be used to write arbitrary keys.
330        if !self.file_fields().iter().any(|f| f.name == field) {
331            return CoreError::NotFound.into();
332        }
333        match crate::attach::store(self.base_path(), id, field, file).await {
334            Ok(_) => ApiResponse::redirect(format!(
335                "{}/{}/view/{}",
336                ctx.mount,
337                self.base_path(),
338                id
339            )),
340            Err(resp) => resp,
341        }
342    }
343
344    /// Stream a stored attachment back. Gated on `Read` — seeing the file is a
345    /// read of the record it belongs to. Returns the bytes with the stored
346    /// content type and a download-friendly filename.
347    async fn serve_file(&self, ctx: &ReqCtx, id: &str, field: &str) -> ApiResponse {
348        if !self.authorize(ctx, Action::Read) {
349            return crate::auth::login_redirect(ctx);
350        }
351        let backend = match crate::attach::attachments() {
352            Some(b) => b,
353            None => return CoreError::NotFound.into(),
354        };
355        let meta = match backend.get(self.base_path(), id, field).await {
356            Ok(Some(m)) => m,
357            Ok(None) => return CoreError::NotFound.into(),
358            Err(e) => return CoreError::from(e).into(),
359        };
360        let bytes = match backend.read(&meta.storage_key).await {
361            Ok(b) => b,
362            Err(e) => return CoreError::from(e).into(),
363        };
364        ApiResponse::new(
365            200,
366            crate::response::ApiBody::Bytes {
367                content_type: meta.content_type,
368                data: bytes,
369            },
370        )
371        // `inline` so an image previews in-browser; the filename is used if the
372        // user chooses to save it.
373        .with_header(
374            "Content-Disposition",
375            format!("inline; filename=\"{}\"", sanitize_filename(&meta.filename)),
376        )
377    }
378
379    /// Remove one field's attachment. Gated on `Update`; redirects back to the
380    /// detail page.
381    async fn detach_file(
382        &self,
383        ctx: &ReqCtx,
384        id: &str,
385        field: &str,
386        csrf: Option<String>,
387    ) -> ApiResponse {
388        if !self.authorize(ctx, Action::Update) {
389            return crate::auth::login_redirect(ctx);
390        }
391        if let Some(reject) = csrf_guard(ctx, csrf) {
392            return reject;
393        }
394        if let Some(backend) = crate::attach::attachments() {
395            if let Err(e) = backend.delete(self.base_path(), id, field).await {
396                return CoreError::from(e).into();
397            }
398        }
399        ApiResponse::redirect(format!("{}/{}/view/{}", ctx.mount, self.base_path(), id))
400    }
401
402    /// The recorded history of one record. Reads through the audit seam, so with
403    /// no auditor registered it renders an empty log rather than 404 — the route
404    /// exists either way and the page explains itself.
405    async fn history_page(&self, ctx: &ReqCtx, id: &str) -> ApiResponse {
406        // Gated on Read: seeing what a record used to be is a read of that
407        // record, so anyone who may view it may see its history.
408        if !self.authorize(ctx, Action::Read) {
409            return crate::auth::login_redirect(ctx);
410        }
411        let versions = crate::audit::history(self.base_path(), id).await;
412
413        let mut c = ui::base_context(ctx, self.resource_name());
414        c.insert("resource_name", self.resource_name());
415        c.insert("base_path", self.base_path());
416        c.insert("item_id", &id);
417        c.insert("versions", &versions);
418        c.insert("audit_enabled", &crate::audit::is_enabled());
419        c.insert("limit", &crate::audit::HISTORY_LIMIT);
420        // No form on this page, so no CSRF token is needed.
421        ui::render("history.html", &c)
422    }
423
424    /// Handle a submitted create form; redirects to the list on success.
425    async fn create_form(&self, ctx: &ReqCtx, mut form: HashMap<String, String>) -> ApiResponse {
426        if !self.authorize(ctx, Action::Create) {
427            return crate::auth::login_redirect(ctx);
428        }
429        if let Some(reject) = csrf_guard(ctx, form.remove(crate::csrf::FIELD_NAME)) {
430            return reject;
431        }
432        let body = ui::form_to_json(form);
433        let resp = self.create(ctx, body).await;
434        if resp.status < 300 {
435            ApiResponse::redirect(format!("{}/{}/list", ctx.mount, self.base_path()))
436        } else {
437            resp
438        }
439    }
440
441    /// Handle a submitted edit form; redirects to the item view on success.
442    async fn update_form(
443        &self,
444        ctx: &ReqCtx,
445        id: &str,
446        mut form: HashMap<String, String>,
447    ) -> ApiResponse {
448        if !self.authorize(ctx, Action::Update) {
449            return crate::auth::login_redirect(ctx);
450        }
451        if let Some(reject) = csrf_guard(ctx, form.remove(crate::csrf::FIELD_NAME)) {
452            return reject;
453        }
454        let body = ui::form_to_json(form);
455        let resp = self.update(ctx, id, body).await;
456        if resp.status < 300 {
457            ApiResponse::redirect(format!("{}/{}/view/{}", ctx.mount, self.base_path(), id))
458        } else {
459            resp
460        }
461    }
462
463    /// Handle a delete from the list UI; redirects back to the list. `csrf` is
464    /// the submitted hidden field, checked against the cookie before anything
465    /// is removed.
466    async fn delete_form(&self, ctx: &ReqCtx, id: &str, csrf: Option<String>) -> ApiResponse {
467        if !self.authorize(ctx, Action::Delete) {
468            return crate::auth::login_redirect(ctx);
469        }
470        if let Some(reject) = csrf_guard(ctx, csrf) {
471            return reject;
472        }
473        let resp = self.delete(ctx, id).await;
474        if resp.status < 300 {
475            ApiResponse::redirect(format!("{}/{}/list", ctx.mount, self.base_path()))
476        } else {
477            resp
478        }
479    }
480
481    // ===== CUSTOM ACTIONS =====
482
483    /// Look up a custom action by name and run it (after auth + CSRF checks).
484    /// `csrf` is the submitted hidden field; the action button posts a form, so
485    /// it's guarded like the other mutating form handlers.
486    async fn run_action(
487        &self,
488        ctx: &ReqCtx,
489        name: &str,
490        id: String,
491        body: Value,
492        csrf: Option<String>,
493    ) -> ApiResponse {
494        if !self.authorize(ctx, Action::Custom(name)) {
495            return CoreError::Unauthorized.into();
496        }
497        if let Some(reject) = csrf_guard(ctx, csrf) {
498            return reject;
499        }
500        for action in self.custom_actions() {
501            if action.name == name {
502                return (action.handler)(ctx.clone(), id, body).await;
503            }
504        }
505        CoreError::NotFound.into()
506    }
507
508    // ===== EXPORT =====
509
510    /// Export the resource's rows as `json` or `csv` (used by `?download=`).
511    async fn export(&self, ctx: &ReqCtx, format: &str) -> ApiResponse {
512        if !self.authorize(ctx, Action::Export) {
513            return crate::auth::login_redirect(ctx);
514        }
515
516        let opts = QueryOptions {
517            page: 1,
518            per_page: EXPORT_CAP,
519            sort_by: None,
520            sort_desc: false,
521            // Export honours the active filters from the list query.
522            filters: crate::filters::parse_filters(&ctx.query, &self.filterable_fields()),
523        };
524        let page = match storage().list(self.table_name(), &opts).await {
525            Ok(p) => p,
526            Err(e) => return CoreError::from(e).into(),
527        };
528
529        match format {
530            "json" => {
531                let data = serde_json::to_vec_pretty(&page.rows).unwrap_or_default();
532                ApiResponse::new(
533                    200,
534                    ApiBody::Bytes {
535                        content_type: "application/json".to_string(),
536                        data,
537                    },
538                )
539                .with_header(
540                    "Content-Disposition",
541                    format!("attachment; filename=\"{}.json\"", self.base_path()),
542                )
543            }
544            "csv" => {
545                let headers = ui::derive_headers(&page.rows, self.primary_key());
546                let data = rows_to_csv(&headers, &page.rows).into_bytes();
547                ApiResponse::new(
548                    200,
549                    ApiBody::Bytes {
550                        content_type: "text/csv".to_string(),
551                        data,
552                    },
553                )
554                .with_header(
555                    "Content-Disposition",
556                    format!("attachment; filename=\"{}.csv\"", self.base_path()),
557                )
558            }
559            other => {
560                CoreError::BadRequest(format!("unsupported export format: {other}")).into()
561            }
562        }
563    }
564
565    // ===== HELPERS =====
566
567    /// Whether the principal in `ctx` may perform `action` on this resource.
568    /// Delegates to the authorization seam: always allowed when auth is not
569    /// configured; a registered [`Authorizer`](crate::authz::Authorizer) decides
570    /// per action; otherwise the principal must hold one of `allowed_roles()`.
571    fn authorize(&self, ctx: &ReqCtx, action: Action<'_>) -> bool {
572        crate::authz::authorize(ctx, &self.allowed_roles(), self.base_path(), action)
573    }
574
575    /// Apply the permit/readonly/primary-key rules to an incoming JSON body,
576    /// returning the writable column map or a ready-made error response.
577    fn filter_writable(&self, body: Value) -> Result<Map<String, Value>, ApiResponse> {
578        let permitted: HashSet<&str> = self.permit_keys().into_iter().collect();
579        let readonly: HashSet<&str> = self.readonly_keys().into_iter().collect();
580        let pk = self.primary_key();
581
582        let mut out = Map::new();
583        if let Value::Object(map) = body {
584            for (k, v) in map {
585                // Deny-list wins over allow-list; the primary key is never client-set.
586                if permitted.contains(k.as_str()) && !readonly.contains(k.as_str()) && k != pk {
587                    out.insert(k, v);
588                }
589            }
590        }
591
592        if out.is_empty() {
593            return Err(ApiResponse::error(CoreError::BadRequest(
594                "No permitted fields in payload".into(),
595            )));
596        }
597        Ok(out)
598    }
599
600    // ===== MENU =====
601    fn generate_menu(&self) -> Option<MenuItem> {
602        Some(MenuItem {
603            title: self.menu().to_string(),
604            path: self.base_path().to_string(),
605            icon: Some("table".to_string()),
606            order: Some(10),
607            children: None,
608        })
609    }
610}
611
612impl Clone for Box<dyn Resource> {
613    fn clone(&self) -> Self {
614        self.clone_box()
615    }
616}
617
618/// CSRF check shared by every mutating form handler. Returns `Some(reject)` when
619/// the submitted `_csrf` field is missing or doesn't match the cookie, and
620/// `None` when the post may proceed. Taking the token by value lets callers hand
621/// over the value they lifted out of the form map with `remove`.
622fn csrf_guard(ctx: &ReqCtx, submitted: Option<String>) -> Option<ApiResponse> {
623    // Mirrors `is_authorized`: with auth unconfigured the whole panel is public
624    // by design, so form posts stay frictionless too. Once auth is on, so is this.
625    if !crate::auth::is_configured() {
626        return None;
627    }
628    if crate::csrf::verify(ctx, submitted.as_deref()) {
629        None
630    } else {
631        // A 403 the browser can read. These posts are already SameSite-protected,
632        // so this fires mainly on a token that lapsed with the browser session —
633        // reloading the page mints a fresh one.
634        Some(ApiResponse::html(
635            403,
636            "<h1>403 Forbidden</h1><p>Your session expired or the request could \
637             not be verified. Please reload the page and try again.</p>"
638                .to_string(),
639        ))
640    }
641}
642
643/// Strip path separators and control characters from an uploaded filename before
644/// it goes into a `Content-Disposition` header, so a crafted name can't inject a
645/// header or imply a path. Keeps a plain basename.
646fn sanitize_filename(name: &str) -> String {
647    name.rsplit(['/', '\\'])
648        .next()
649        .unwrap_or(name)
650        .chars()
651        .filter(|c| !c.is_control() && *c != '"')
652        .take(255)
653        .collect()
654}
655
656#[cfg(test)]
657mod tests {
658    use super::sanitize_filename;
659
660    #[test]
661    fn filename_is_reduced_to_a_safe_basename() {
662        assert_eq!(sanitize_filename("../../etc/passwd"), "passwd");
663        assert_eq!(sanitize_filename(r"C:\Windows\evil.exe"), "evil.exe");
664        assert_eq!(sanitize_filename("photo.png"), "photo.png");
665    }
666
667    #[test]
668    fn header_breaking_characters_are_dropped() {
669        assert_eq!(
670            sanitize_filename("a\"b\r\nContent-Length: 0.png"),
671            "abContent-Length: 0.png"
672        );
673    }
674}