api_files.rs
⎇
Raw
1//! File API: listing, download/preview/content, editor save, mutations,
2//! upload, access control and path-safety.
3
4mod common;
5
6use axum::http::StatusCode;
7use common::*;
8use serde_json::json;
9
10/// Root id for the whole-root (".") user root is 1 (first row inserted).
11const ROOT: i64 = 1;
12
13fn root_path(rel: &str) -> String {
14 // No trailing slash for the bare root: axum's routes are
15 // `/api/files/{root_id}` and `/api/files/{root_id}/{*path}`.
16 if rel.is_empty() {
17 format!("/api/files/{ROOT}")
18 } else {
19 format!("/api/files/{ROOT}/{rel}")
20 }
21}
22
23#[tokio::test]
24async fn list_root_sorted_folders_first() {
25 let env = Env::new().await;
26 let admin = env.admin().await;
27 let r = admin.get(&root_path("")).await;
28 assert_eq!(r.status, StatusCode::OK);
29 let j = r.json();
30 let entries = j["entries"].as_array().unwrap();
31 let names: Vec<&str> = entries
32 .iter()
33 .map(|e| e["name"].as_str().unwrap())
34 .collect();
35 assert_eq!(
36 names,
37 vec![
38 "docs",
39 "src",
40 "blob.bin",
41 "config.json",
42 "editme.txt",
43 "notes.md"
44 ]
45 );
46 // Entry fields.
47 let docs = &entries[0];
48 assert_eq!(docs["is_dir"], true);
49 let editme = entries.iter().find(|e| e["name"] == "editme.txt").unwrap();
50 assert_eq!(editme["is_dir"], false);
51 assert_eq!(editme["size"], 2);
52 assert!(editme["mtime"].as_str().unwrap().ends_with('Z'));
53}
54
55#[tokio::test]
56async fn list_subdir_and_errors() {
57 let env = Env::new().await;
58 let admin = env.admin().await;
59
60 let r = admin.get(&root_path("docs")).await;
61 let j = r.json();
62 let names: Vec<&str> = j
63 .get("entries")
64 .unwrap()
65 .as_array()
66 .unwrap()
67 .iter()
68 .map(|e| e["name"].as_str().unwrap())
69 .collect();
70 assert_eq!(names, vec!["inner", "a.txt"]);
71
72 // Missing path → 404.
73 assert_eq!(
74 admin.get(&root_path("nope")).await.status,
75 StatusCode::NOT_FOUND
76 );
77 // Listing a file → 400.
78 assert_eq!(
79 admin.get(&root_path("editme.txt")).await.status,
80 StatusCode::BAD_REQUEST
81 );
82 // Unknown root id → 403.
83 assert_eq!(
84 admin.get("/api/files/999").await.status,
85 StatusCode::FORBIDDEN
86 );
87 // No session → 401.
88 let anon = Client::new(env.app.clone());
89 assert_eq!(
90 anon.get(&root_path("")).await.status,
91 StatusCode::UNAUTHORIZED
92 );
93}
94
95#[tokio::test]
96async fn path_traversal_is_blocked() {
97 let env = Env::new().await;
98 let admin = env.admin().await;
99
100 // Encoded `..` segments reach the handler and are rejected.
101 let r = admin.get("/api/files/1/%2e%2e%2f%2e%2e%2fetc").await;
102 assert!(
103 r.status == StatusCode::FORBIDDEN || r.status == StatusCode::NOT_FOUND,
104 "traversal returned {:?}",
105 r.status
106 );
107 // Literal `..` segments: must never succeed.
108 let r = admin.get("/api/files/1/../../etc").await;
109 assert_ne!(
110 r.status,
111 StatusCode::OK,
112 "literal traversal must not be served"
113 );
114 // Traversal inside a deeper path.
115 let r = admin.get("/api/files/1/docs/..%2f..%2fsrc").await;
116 assert!(
117 r.status == StatusCode::FORBIDDEN || r.status == StatusCode::NOT_FOUND,
118 "deep traversal returned {:?}",
119 r.status
120 );
121}
122
123#[tokio::test]
124async fn download_single_file() {
125 let env = Env::new().await;
126 let admin = env.admin().await;
127 let r = admin
128 .get(&format!("{}?action=download", root_path("editme.txt")))
129 .await;
130 assert_eq!(r.status, StatusCode::OK);
131 assert_eq!(
132 r.header("content-disposition").as_deref(),
133 Some("attachment; filename=\"editme.txt\"")
134 );
135 assert_eq!(r.header("content-type").as_deref(), Some("text/plain"));
136 assert_eq!(r.body, b"v1");
137 // Binary content survives.
138 let r = admin
139 .get(&format!("{}?action=download", root_path("blob.bin")))
140 .await;
141 assert_eq!(r.body, (0..64u8).collect::<Vec<_>>());
142}
143
144#[tokio::test]
145async fn download_folder_as_all_archive_formats() {
146 let env = Env::new().await;
147 let admin = env.admin().await;
148 let path = format!("{}?action=download", root_path("docs"));
149
150 let r = admin.get(&format!("{path}&format=zip")).await;
151 assert_eq!(r.status, StatusCode::OK);
152 assert_eq!(r.header("content-type").as_deref(), Some("application/zip"));
153 assert_eq!(
154 r.header("content-disposition").as_deref(),
155 Some("attachment; filename=\"docs.zip\"")
156 );
157 let map = zip_map(&r.body);
158 assert_eq!(map.get("docs/a.txt").unwrap(), b"file a");
159 assert_eq!(map.get("docs/inner/hello.txt").unwrap(), b"hello world");
160
161 let r = admin.get(&format!("{path}&format=tar")).await;
162 assert_eq!(
163 r.header("content-type").as_deref(),
164 Some("application/x-tar")
165 );
166 assert_eq!(
167 r.header("content-disposition").as_deref(),
168 Some("attachment; filename=\"docs.tar\"")
169 );
170 let map = tar_map(&r.body, Compress::None);
171 assert_eq!(map.get("docs/a.txt").unwrap(), b"file a");
172 assert_eq!(map.get("docs/inner/hello.txt").unwrap(), b"hello world");
173
174 let r = admin.get(&format!("{path}&format=tar.gz")).await;
175 assert_eq!(
176 r.header("content-type").as_deref(),
177 Some("application/gzip")
178 );
179 assert_eq!(
180 r.header("content-disposition").as_deref(),
181 Some("attachment; filename=\"docs.tar.gz\"")
182 );
183 let map = tar_map(&r.body, Compress::Gz);
184 assert_eq!(map.get("docs/inner/hello.txt").unwrap(), b"hello world");
185
186 let r = admin.get(&format!("{path}&format=tar.zst")).await;
187 assert_eq!(
188 r.header("content-type").as_deref(),
189 Some("application/zstd")
190 );
191 assert_eq!(
192 r.header("content-disposition").as_deref(),
193 Some("attachment; filename=\"docs.tar.zst\"")
194 );
195 let map = tar_map(&r.body, Compress::Zst);
196 assert_eq!(map.get("docs/a.txt").unwrap(), b"file a");
197}
198
199#[tokio::test]
200async fn download_folder_requires_valid_format() {
201 let env = Env::new().await;
202 let admin = env.admin().await;
203 let path = format!("{}?action=download", root_path("docs"));
204 // No format → 400.
205 assert_eq!(admin.get(&path).await.status, StatusCode::BAD_REQUEST);
206 // Unknown format → 400.
207 assert_eq!(
208 admin.get(&format!("{path}&format=rar")).await.status,
209 StatusCode::BAD_REQUEST
210 );
211 // Downloading a file with a format is fine (format ignored).
212 let r = admin
213 .get(&format!(
214 "{}?action=download&format=zip",
215 root_path("editme.txt")
216 ))
217 .await;
218 assert_eq!(r.status, StatusCode::OK);
219 assert_eq!(r.body, b"v1");
220}
221
222#[tokio::test]
223async fn preview_serves_inline_and_rejects_dirs() {
224 let env = Env::new().await;
225 let admin = env.admin().await;
226 let r = admin
227 .get(&format!("{}?action=preview", root_path("config.json")))
228 .await;
229 assert_eq!(r.status, StatusCode::OK);
230 assert!(
231 r.header("content-disposition")
232 .unwrap()
233 .starts_with("inline;")
234 );
235 assert_eq!(r.body, b"{\"k\": 1}");
236 assert_eq!(
237 admin
238 .get(&format!("{}?action=preview", root_path("docs")))
239 .await
240 .status,
241 StatusCode::BAD_REQUEST
242 );
243}
244
245#[tokio::test]
246async fn content_action_serves_raw_bytes_with_mtime() {
247 let env = Env::new().await;
248 let admin = env.admin().await;
249 let r = admin
250 .get(&format!("{}?action=content", root_path("notes.md")))
251 .await;
252 assert_eq!(r.status, StatusCode::OK);
253 assert_eq!(
254 r.header("content-type").as_deref(),
255 Some("text/plain; charset=utf-8")
256 );
257 let mtime = r.header("x-file-mtime").unwrap();
258 assert!(mtime.parse::<i64>().is_ok());
259 assert_eq!(r.body, b"# notes");
260 assert_eq!(
261 admin
262 .get(&format!("{}?action=content", root_path("docs")))
263 .await
264 .status,
265 StatusCode::BAD_REQUEST
266 );
267}
268
269#[tokio::test]
270async fn content_is_capped_at_two_mibibytes() {
271 let env = Env::new().await;
272 let admin = env.admin().await;
273 let big = vec![b'x'; 2 * 1024 * 1024 + 1];
274 std::fs::write(env.file("big.bin"), &big).unwrap();
275 let r = admin
276 .get(&format!("{}?action=content", root_path("big.bin")))
277 .await;
278 assert_eq!(r.status, StatusCode::PAYLOAD_TOO_LARGE);
279 // The file itself still downloads fine.
280 let r = admin
281 .get(&format!("{}?action=download", root_path("big.bin")))
282 .await;
283 assert_eq!(r.status, StatusCode::OK);
284 assert_eq!(r.body.len(), big.len());
285}
286
287#[tokio::test]
288async fn editor_save_round_trip_and_conflict() {
289 let env = Env::new().await;
290 let admin = env.admin().await;
291 let path = format!("{}?action=content", root_path("editme.txt"));
292
293 // Read current mtime via the content endpoint.
294 let r = admin.get(&path).await;
295 assert_eq!(r.status, StatusCode::OK);
296 let mtime: i64 = r.header("x-file-mtime").unwrap().parse().unwrap();
297
298 // Save with a matching expected mtime.
299 let r = admin.put_content(&path, b"v2", Some(mtime)).await;
300 assert_eq!(r.status, StatusCode::OK);
301 let new_mtime = r.json()["mtime"].as_i64().unwrap();
302 assert!(new_mtime >= mtime);
303 assert_eq!(std::fs::read(env.file("editme.txt")).unwrap(), b"v2");
304
305 // A stale/wrong expected mtime conflicts (409). Use a value far from the
306 // current mtime so this is deterministic regardless of the filesystem's
307 // timestamp granularity (the mtime may not have advanced after the save).
308 let r = admin.put_content(&path, b"v3", Some(mtime + 999_999)).await;
309 assert_eq!(r.status, StatusCode::CONFLICT);
310 // A conflict must not modify the file.
311 assert_eq!(std::fs::read(env.file("editme.txt")).unwrap(), b"v2");
312
313 // No expected mtime → force save.
314 let r = admin.put_content(&path, b"v4", None).await;
315 assert_eq!(r.status, StatusCode::OK);
316 assert_eq!(std::fs::read(env.file("editme.txt")).unwrap(), b"v4");
317
318 // Saving a missing file → 404; a directory → 400.
319 // (PUT without action=content → 400.)
320 let r = admin
321 .raw(
322 axum::http::Method::PUT,
323 &root_path("editme.txt"),
324 &[("content-type", "text/plain")],
325 b"x".to_vec(),
326 )
327 .await;
328 assert_eq!(r.status, StatusCode::BAD_REQUEST);
329
330 let r = admin
331 .put_content(
332 &format!("{}?action=content", root_path("ghost.txt")),
333 b"x",
334 None,
335 )
336 .await;
337 assert_eq!(r.status, StatusCode::NOT_FOUND);
338 let r = admin
339 .put_content(&format!("{}?action=content", root_path("docs")), b"x", None)
340 .await;
341 assert_eq!(r.status, StatusCode::BAD_REQUEST);
342
343 // Oversized body → 413.
344 let r = admin
345 .put_content(&path, &vec![b'a'; 2 * 1024 * 1024 + 1], None)
346 .await;
347 assert_eq!(r.status, StatusCode::PAYLOAD_TOO_LARGE);
348}
349
350#[tokio::test]
351async fn mkdir_and_rename() {
352 let env = Env::new().await;
353 let admin = env.admin().await;
354
355 // mkdir names itself with ?action=mkdir.
356 let mkdir_url = |name: &str| format!("{}?action=mkdir", root_path(name));
357 let r = admin
358 .raw(
359 axum::http::Method::POST,
360 &mkdir_url("newdir"),
361 &[],
362 Vec::new(),
363 )
364 .await;
365 assert_eq!(r.status, StatusCode::OK);
366 assert!(env.file("newdir").is_dir());
367 // Duplicate → 409.
368 let r = admin
369 .raw(
370 axum::http::Method::POST,
371 &mkdir_url("newdir"),
372 &[],
373 Vec::new(),
374 )
375 .await;
376 assert_eq!(r.status, StatusCode::CONFLICT);
377 // Empty name → 400 (bare root POST with JSON op is rejected too).
378 let r = admin
379 .raw(axum::http::Method::POST, &mkdir_url(""), &[], Vec::new())
380 .await;
381 assert_eq!(r.status, StatusCode::BAD_REQUEST);
382 // A POST that names no action and carries no known body type is rejected
383 // instead of silently creating a folder.
384 let r = admin
385 .raw(
386 axum::http::Method::POST,
387 &root_path("sneaky"),
388 &[],
389 Vec::new(),
390 )
391 .await;
392 assert_eq!(r.status, StatusCode::UNSUPPORTED_MEDIA_TYPE);
393 assert!(!env.file("sneaky").exists());
394
395 // Rename.
396 let r = admin
397 .post_json(
398 &root_path("editme.txt"),
399 &json!({ "op": "rename", "new_name": "renamed.txt" }),
400 )
401 .await;
402 assert_eq!(r.status, StatusCode::OK);
403 assert!(env.file("renamed.txt").exists());
404 // Conflict.
405 let r = admin
406 .post_json(
407 &root_path("renamed.txt"),
408 &json!({ "op": "rename", "new_name": "config.json" }),
409 )
410 .await;
411 assert_eq!(r.status, StatusCode::CONFLICT);
412 // With overwrite.
413 let r = admin
414 .post_json(
415 &root_path("renamed.txt"),
416 &json!({ "op": "rename", "new_name": "config.json", "overwrite": true }),
417 )
418 .await;
419 assert_eq!(r.status, StatusCode::OK);
420 assert_eq!(std::fs::read(env.file("config.json")).unwrap(), b"v1");
421 // Invalid name.
422 let r = admin
423 .post_json(
424 &root_path("notes.md"),
425 &json!({ "op": "rename", "new_name": "a/b" }),
426 )
427 .await;
428 assert_eq!(r.status, StatusCode::BAD_REQUEST);
429 // Missing source.
430 let r = admin
431 .post_json(
432 &root_path("ghost"),
433 &json!({ "op": "rename", "new_name": "x" }),
434 )
435 .await;
436 assert_eq!(r.status, StatusCode::NOT_FOUND);
437 // Unknown op: `api_types::Op` has no such variant, so the body fails to
438 // deserialize. `dispatch_inner` parses it itself, so this stays a 400.
439 let r = admin
440 .post_json(&root_path("notes.md"), &json!({ "op": "explode" }))
441 .await;
442 assert_eq!(r.status, StatusCode::BAD_REQUEST);
443}
444
445#[tokio::test]
446async fn move_and_copy_across_dirs() {
447 let env = Env::new().await;
448 let admin = env.admin().await;
449
450 // Move notes.md into docs/.
451 let r = admin
452 .post_json(
453 &root_path("notes.md"),
454 &json!({ "op": "move", "dst_root_id": ROOT, "dst": "docs" }),
455 )
456 .await;
457 assert_eq!(r.status, StatusCode::OK);
458 assert!(!env.file("notes.md").exists());
459 assert_eq!(
460 std::fs::read(env.file("docs/notes.md")).unwrap(),
461 b"# notes"
462 );
463
464 // Copy docs/inner back out — as a folder.
465 let r = admin
466 .post_json(
467 &root_path("docs/inner"),
468 &json!({ "op": "copy", "dst_root_id": ROOT, "dst": "src" }),
469 )
470 .await;
471 assert_eq!(r.status, StatusCode::OK);
472 assert_eq!(
473 std::fs::read(env.file("src/inner/hello.txt")).unwrap(),
474 b"hello world"
475 );
476 assert!(env.file("docs/inner/hello.txt").exists());
477
478 // Conflict without overwrite, ok with: copy into a folder that already
479 // holds a file with the same name.
480 let r = admin
481 .post_json(
482 &root_path("docs/a.txt"),
483 &json!({ "op": "copy", "dst_root_id": ROOT, "dst": "src" }),
484 )
485 .await;
486 assert_eq!(r.status, StatusCode::OK);
487 assert_eq!(std::fs::read(env.file("src/a.txt")).unwrap(), b"file a");
488 std::fs::write(env.file("docs/a.txt"), "file a2").unwrap();
489 let r = admin
490 .post_json(
491 &root_path("docs/a.txt"),
492 &json!({ "op": "copy", "dst_root_id": ROOT, "dst": "src" }),
493 )
494 .await;
495 assert_eq!(r.status, StatusCode::CONFLICT);
496 let r = admin
497 .post_json(
498 &root_path("docs/a.txt"),
499 &json!({ "op": "copy", "dst_root_id": ROOT, "dst": "src", "overwrite": true }),
500 )
501 .await;
502 assert_eq!(r.status, StatusCode::OK);
503 assert_eq!(std::fs::read(env.file("src/a.txt")).unwrap(), b"file a2");
504
505 // Copying an item into its own folder (same path) is a no-op success.
506 let r = admin
507 .post_json(
508 &root_path("docs/a.txt"),
509 &json!({ "op": "copy", "dst_root_id": ROOT, "dst": "docs" }),
510 )
511 .await;
512 assert_eq!(r.status, StatusCode::OK);
513
514 // Moving a folder into itself → 400.
515 let r = admin
516 .post_json(
517 &root_path("docs"),
518 &json!({ "op": "move", "dst_root_id": ROOT, "dst": "docs" }),
519 )
520 .await;
521 assert_eq!(r.status, StatusCode::BAD_REQUEST);
522
523 // Missing dst_root_id / dst dir.
524 let r = admin
525 .post_json(&root_path("docs/a.txt"), &json!({ "op": "move" }))
526 .await;
527 assert_eq!(r.status, StatusCode::BAD_REQUEST);
528 let r = admin
529 .post_json(
530 &root_path("docs/a.txt"),
531 &json!({ "op": "move", "dst_root_id": ROOT, "dst": "no-such-dir" }),
532 )
533 .await;
534 assert_eq!(r.status, StatusCode::NOT_FOUND);
535}
536
537#[tokio::test]
538async fn delete_file_and_folder() {
539 let env = Env::new().await;
540 let admin = env.admin().await;
541
542 let r = admin.delete(&root_path("editme.txt")).await;
543 assert_eq!(r.status, StatusCode::OK);
544 assert_eq!(r.json()["is_dir"], false);
545 assert!(!env.file("editme.txt").exists());
546
547 let r = admin.delete(&root_path("docs")).await;
548 assert_eq!(r.json()["is_dir"], true);
549 assert!(!env.file("docs").exists());
550
551 // Missing → 404. A DELETE on the bare root matches no route's method →
552 // 405 (the path only has GET/POST routes).
553 assert_eq!(
554 admin.delete(&root_path("ghost")).await.status,
555 StatusCode::NOT_FOUND
556 );
557 assert_eq!(
558 admin.delete("/api/files/1").await.status,
559 StatusCode::METHOD_NOT_ALLOWED
560 );
561 // DELETE with a trailing-slash root matches no route at all → 404 via
562 // the SPA fallback's API guard.
563 let r = admin.delete("/api/files/1/").await;
564 assert_eq!(r.status, StatusCode::NOT_FOUND);
565 assert_eq!(r.text(), "unknown endpoint");
566}
567
568#[tokio::test]
569async fn upload_creates_files_and_folders() {
570 let env = Env::new().await;
571 let admin = env.admin().await;
572
573 // Single file into the root, nested part name creates the folder.
574 let r = admin
575 .post_multipart(
576 &root_path(""),
577 &[("docs/uploaded.txt", b"up1"), ("new/nested.txt", b"up2")],
578 "",
579 )
580 .await;
581 assert_eq!(r.status, StatusCode::OK);
582 assert_eq!(r.json()["uploaded"], 2);
583 assert_eq!(
584 std::fs::read(env.file("docs/uploaded.txt")).unwrap(),
585 b"up1"
586 );
587 assert_eq!(std::fs::read(env.file("new/nested.txt")).unwrap(), b"up2");
588
589 // Conflict: existing file, no overwrite → 409 with the skipped list.
590 let r = admin
591 .post_multipart(&root_path(""), &[("docs/uploaded.txt", b"again")], "")
592 .await;
593 assert_eq!(r.status, StatusCode::CONFLICT);
594 assert_eq!(r.json()["skipped"], json!(["docs/uploaded.txt"]));
595 assert_eq!(
596 std::fs::read(env.file("docs/uploaded.txt")).unwrap(),
597 b"up1"
598 );
599
600 // Mixed: one conflict + one new file → 409, the new one is uploaded.
601 let r = admin
602 .post_multipart(
603 &root_path(""),
604 &[("docs/uploaded.txt", b"again"), ("fresh.txt", b"new")],
605 "",
606 )
607 .await;
608 assert_eq!(r.status, StatusCode::CONFLICT);
609 assert_eq!(r.json()["skipped"], json!(["docs/uploaded.txt"]));
610 assert_eq!(r.json()["uploaded"], 1);
611 assert_eq!(std::fs::read(env.file("fresh.txt")).unwrap(), b"new");
612
613 // overwrite=true replaces.
614 let r = admin
615 .post_multipart(
616 &root_path(""),
617 &[("docs/uploaded.txt", b"v3")],
618 "overwrite=true",
619 )
620 .await;
621 assert_eq!(r.status, StatusCode::OK);
622 assert_eq!(std::fs::read(env.file("docs/uploaded.txt")).unwrap(), b"v3");
623
624 // A part name that is an existing directory → 409.
625 let r = admin
626 .post_multipart(&root_path(""), &[("new", b"dir?")], "")
627 .await;
628 assert_eq!(r.status, StatusCode::CONFLICT);
629
630 // Path traversal in a part name → 400.
631 let r = admin
632 .post_multipart(&root_path(""), &[("../evil.txt", b"x")], "")
633 .await;
634 assert!(matches!(
635 r.status,
636 StatusCode::BAD_REQUEST | StatusCode::FORBIDDEN
637 ));
638 assert!(!env.file("../evil.txt").exists());
639 assert!(!env.root.path().parent().unwrap().join("evil.txt").exists());
640
641 // No parts at all → 400.
642 let (ct, body) = multipart_body(&[], "b");
643 let r = admin
644 .raw(
645 axum::http::Method::POST,
646 &root_path(""),
647 &[("content-type", &ct)],
648 body,
649 )
650 .await;
651 assert_eq!(r.status, StatusCode::BAD_REQUEST);
652}
653
654#[tokio::test]
655async fn read_only_root_blocks_writes_but_allows_reads() {
656 let env = Env::new().await;
657 let admin = env.admin().await;
658 create_user(&admin, "carol", "carolpass1", &[("docs", "ro")]).await;
659 let carol = login(&env, "carol", "carolpass1").await;
660 let carol_root_id = carol.get("/api/auth/me").await.json()["roots"][0]["id"]
661 .as_i64()
662 .unwrap();
663
664 // Reads work.
665 let r = carol.get(&format!("/api/files/{carol_root_id}")).await;
666 assert_eq!(r.status, StatusCode::OK);
667 assert!(!r.json()["entries"].as_array().unwrap().is_empty());
668 let r = carol
669 .get(&format!("/api/files/{carol_root_id}/a.txt?action=download"))
670 .await;
671 assert_eq!(r.body, b"file a");
672
673 // Writes are blocked.
674 let base = format!("/api/files/{carol_root_id}/x?action=mkdir");
675 assert_eq!(
676 carol
677 .raw(axum::http::Method::POST, &base, &[], Vec::new())
678 .await
679 .status,
680 StatusCode::FORBIDDEN
681 );
682 assert_eq!(
683 carol
684 .delete(&format!("/api/files/{carol_root_id}/a.txt"))
685 .await
686 .status,
687 StatusCode::FORBIDDEN
688 );
689 assert_eq!(
690 carol
691 .post_json(
692 &format!("/api/files/{carol_root_id}/a.txt"),
693 &json!({ "op": "rename", "new_name": "b.txt" })
694 )
695 .await
696 .status,
697 StatusCode::FORBIDDEN
698 );
699}
700
701#[tokio::test]
702async fn user_cannot_touch_foreign_root() {
703 let env = Env::new().await;
704 let admin = env.admin().await;
705 create_user(&admin, "dave", "davepass12", &[("src", "rw")]).await;
706 let dave = login(&env, "dave", "davepass12").await;
707 let dave_root_id = dave.get("/api/auth/me").await.json()["roots"][0]["id"]
708 .as_i64()
709 .unwrap();
710
711 // His own root works.
712 assert_eq!(
713 dave.get(&format!("/api/files/{dave_root_id}")).await.status,
714 StatusCode::OK
715 );
716 // The admin's root id (1) is not his → 403.
717 assert_eq!(dave.get("/api/files/1").await.status, StatusCode::FORBIDDEN);
718 // Writing into a root he doesn't have → 403.
719 assert_eq!(
720 dave.raw(
721 axum::http::Method::POST,
722 "/api/files/1/evil?action=mkdir",
723 &[],
724 Vec::new()
725 )
726 .await
727 .status,
728 StatusCode::FORBIDDEN
729 );
730}
731
732/// Listings report a content-sniffed `kind`, not an extension guess.
733#[tokio::test]
734async fn listing_reports_sniffed_kinds() {
735 let env = Env::new().await;
736 let admin = env.admin().await;
737 // A PNG named .txt and a text file named .png: the bytes must win.
738 std::fs::write(
739 env.file("lies.txt"),
740 [0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A],
741 )
742 .unwrap();
743 std::fs::write(env.file("lies.png"), "just words\n").unwrap();
744 std::fs::write(env.file("report.html"), "<!doctype html><p>hi").unwrap();
745 std::fs::write(env.file("noext"), "plain text, no extension\n").unwrap();
746
747 let r = admin.get(&root_path("")).await;
748 assert_eq!(r.status, StatusCode::OK);
749 let j = r.json();
750 let kind = |name: &str| -> String {
751 j["entries"]
752 .as_array()
753 .unwrap()
754 .iter()
755 .find(|e| e["name"] == name)
756 .unwrap_or_else(|| panic!("{name} missing from listing"))["kind"]
757 .as_str()
758 .unwrap()
759 .to_string()
760 };
761 assert_eq!(kind("lies.txt"), "image");
762 assert_eq!(kind("lies.png"), "text");
763 assert_eq!(kind("report.html"), "text");
764 assert_eq!(kind("noext"), "text");
765 assert_eq!(kind("blob.bin"), "binary");
766 assert_eq!(kind("docs"), "dir");
767 assert_eq!(kind("config.json"), "text");
768}
769
770/// A file the browser would parse as a document is served sandboxed, so it can
771/// render as a page without being able to act as the app. Everything else
772/// keeps the app policy.
773#[tokio::test]
774async fn scriptable_files_are_served_sandboxed() {
775 let env = Env::new().await;
776 let admin = env.admin().await;
777 std::fs::write(env.file("page.html"), "<!doctype html><p>hi").unwrap();
778 std::fs::write(
779 env.file("logo.svg"),
780 "<svg xmlns=\"http://www.w3.org/2000/svg\"/>",
781 )
782 .unwrap();
783
784 for name in ["page.html", "logo.svg"] {
785 let r = admin
786 .get(&format!("{}?action=preview", root_path(name)))
787 .await;
788 assert_eq!(r.status, StatusCode::OK);
789 let csp = r.header("content-security-policy").unwrap();
790 assert!(csp.contains("sandbox "), "{name} not sandboxed: {csp}");
791 assert!(csp.contains("allow-scripts"), "{name}: {csp}");
792 // The whole security property: an opaque origin.
793 assert!(
794 !csp.contains("allow-same-origin"),
795 "{name} must never get allow-same-origin: {csp}"
796 );
797 assert!(
798 !csp.contains("allow-top-navigation ") && !csp.contains("allow-popups-to-escape"),
799 "{name}: {csp}"
800 );
801 // Still rendered as a document, not downloaded.
802 assert!(
803 r.header("content-disposition")
804 .unwrap()
805 .starts_with("inline")
806 );
807 }
808
809 // A non-scriptable file keeps the app policy (no sandbox at all).
810 let r = admin
811 .get(&format!("{}?action=preview", root_path("blob.bin")))
812 .await;
813 let csp = r.header("content-security-policy").unwrap();
814 assert!(!csp.contains("sandbox"), "{csp}");
815 assert!(
816 csp.contains("wasm-unsafe-eval"),
817 "expected app policy: {csp}"
818 );
819
820 // And the app's own pages are untouched by the `if_not_present` switch.
821 let r = admin.get("/").await;
822 let csp = r.header("content-security-policy").unwrap();
823 assert!(
824 csp.contains("wasm-unsafe-eval") && !csp.contains("sandbox"),
825 "{csp}"
826 );
827}
828
829#[tokio::test]
830async fn archive_does_not_follow_symlinks_out_of_the_root() {
831 let env = Env::new().await;
832 let admin = env.admin().await;
833
834 // A directory outside the served root, linked to from inside it.
835 let outside = tempfile::tempdir().unwrap();
836 std::fs::write(outside.path().join("secret.txt"), "leaked").unwrap();
837 std::os::unix::fs::symlink(outside.path(), env.file("docs/link")).unwrap();
838
839 let r = admin
840 .get(&format!("{}?action=download&format=tar", root_path("docs")))
841 .await;
842 assert_eq!(r.status, StatusCode::OK);
843 let map = tar_map(&r.body, Compress::None);
844 assert!(
845 !map.keys().any(|k| k.contains("secret.txt")),
846 "archive escaped the root: {:?}",
847 map.keys().collect::<Vec<_>>()
848 );
849 // The legitimate entries are still there.
850 assert_eq!(map.get("docs/a.txt").unwrap(), b"file a");
851 assert_eq!(map.get("docs/inner/hello.txt").unwrap(), b"hello world");
852}
853